Skip to content

Commit 40c694d

Browse files
arraypressclaude
andcommitted
A region that follows its country
A state or a province is chosen from a list only once the country is known, and the country is another field. With a fixed country the region is a plain select over wp-countries' regions, membership enforced on save. Naming a sibling instead renders a text input that always works and an empty select beside it; the script asks the kit's search endpoint for the sibling's regions and swaps the select in when there are any, the text back when there are none, and only the live control submits. Regions come through a built-in source anyone may read, since a list of states is not a secret. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015Kwx2STJ4EaVaJofEJJ9Zo
1 parent 1069a7f commit 40c694d

10 files changed

Lines changed: 1230 additions & 15 deletions

File tree

‎README.md‎

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ wherever the screen happens to be.
2525
* Group fields into tabs or collapsible sections
2626
* Sit two short fields on one line with `'width' => 'half'`
2727
* Take an amount with its currency symbol inside the control
28+
* Pick a country, then a state or province from the ones it has
2829
* Fill a select from a named preset — roles, timezones, countries — with one string
2930
* Fold a repeater's rows away, each titled by one of its own fields
3031
* Reorder a repeater by dragging its handle, or from the keyboard
@@ -108,7 +109,7 @@ Date, time and colour
108109
: `date` `time` `datetime` `color` `gradient` `date_range` `time_range`
109110

110111
Choice
111-
: `select` `enhanced_select` `select_multiple` `checkbox` `toggle` `radio` `checkbox_group` `button_group` `card_choice` `country` `currency`
112+
: `select` `enhanced_select` `select_multiple` `checkbox` `toggle` `radio` `checkbox_group` `button_group` `card_choice` `country` `region` `currency`
112113

113114
Relational — one search endpoint behind all of them
114115
: `post` `page` `user` `taxonomy` `ajax` `tags`
@@ -157,6 +158,34 @@ options pass through the `field_kit_preset_options` filter. Register your own
157158
with `Presets::register( 'products', fn() => $options )` and any field can say
158159
`'options' => 'products'`.
159160

161+
## A country and its regions
162+
163+
A `region` is a state, a province or a county. Given a fixed `country` it is
164+
a select over that country's subdivisions, storing the code the way `country`
165+
stores its own. Given `country_key` — the key of the country field beside it
166+
— it follows whatever is chosen there: a select where the country has
167+
subdivisions on file, a text input where it does not, swapped by the script
168+
on load and on every change.
169+
170+
```php
171+
use ArrayPress\FieldKit\FieldSet;
172+
use ArrayPress\FieldKit\Context\OptionContext;
173+
174+
$set = new FieldSet(
175+
[
176+
'country' => [ 'type' => 'country', 'label' => __( 'Country', 'my-plugin' ) ],
177+
'region' => [ 'type' => 'region', 'label' => __( 'State or province', 'my-plugin' ), 'country_key' => 'country' ],
178+
],
179+
new OptionContext( 'my_plugin' ),
180+
'my_plugin'
181+
);
182+
```
183+
184+
Without the script the text input is what submits, so a typed code still
185+
saves. Inside a repeater the lookup is scoped to the row, so each row's
186+
region follows that row's country. The subdivisions are WooCommerce's, by
187+
way of `arraypress/wp-countries`.
188+
160189
## Requirements
161190

162191
* PHP 8.3 or later

‎assets/css/field-kit.css‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1642,6 +1642,7 @@ li.field-kit__repeater-row {
16421642
.field-kit__clipboard .field-kit__clipboard-value,
16431643
.field-kit__code-value,
16441644
.field-kit__select,
1645+
.field-kit__region,
16451646
.field-kit__combobox,
16461647
.field-kit__combobox-input {
16471648
min-width: 0;
@@ -3286,6 +3287,31 @@ span.field-kit__provider-logo {
32863287
padding-inline-start: 3.3rem;
32873288
}
32883289

3290+
/* A region is drawn twice -- a text input for a country whose subdivisions
3291+
are not on file and a select for one whose are -- and only the script
3292+
knows which. The wrapper sits inline like the select it stands in for,
3293+
capped at core's own select width, so whichever control is live lines up
3294+
with the fields around it the same way. */
3295+
.field-kit__region {
3296+
display: inline-block;
3297+
max-width: 25rem;
3298+
vertical-align: middle;
3299+
}
3300+
3301+
.field-kit__region .field-kit__region-select,
3302+
.field-kit__region .field-kit__region-text {
3303+
max-width: 100%;
3304+
}
3305+
3306+
/* The swap is done with the hidden attribute, which the browser applies as
3307+
a bare element rule. Neither control here is given a display of its own,
3308+
but core gives inputs and selects one on some screens, so the attribute's
3309+
rule is restated where it cannot lose. */
3310+
.field-kit__region-select[hidden],
3311+
.field-kit__region-text[hidden] {
3312+
display: none;
3313+
}
3314+
32893315
/* A row folds away to its header. `li` on purpose: a <tr> given a display of
32903316
its own leaves the table's column geometry, and its cells stop lining up
32913317
under the headers at all. A table repeater is never collapsible, but the

‎assets/js/field-kit.js‎

Lines changed: 198 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,32 @@
9393
}, 50 );
9494
}
9595

96+
/**
97+
* A sibling control, found by its field key.
98+
*
99+
* Scoped to the repeater row the caller sits in, then the form, then the
100+
* document: a repeater has one of each key per row, and the first match
101+
* in the form would give every row the first row's value. The key is
102+
* matched as a bare name or as the last segment of a bracketed one,
103+
* which is how a row's inputs are named.
104+
*
105+
* Money reads its currency this way and region its country -- one lookup
106+
* rather than two copies of the same query, free to drift apart.
107+
*
108+
* @param {Element} wrap The control asking.
109+
* @param {string} key Field key of the sibling.
110+
* @return {Element|null} The control, if there is one.
111+
*/
112+
function sibling( wrap, key ) {
113+
if ( ! key ) {
114+
return null;
115+
}
116+
117+
var scope = wrap.closest( '.field-kit__repeater-row' ) || wrap.closest( 'form' ) || document;
118+
119+
return scope.querySelector( '[name$="[' + key + ']"], [name="' + key + '"]' );
120+
}
121+
96122
/* ====================================================================
97123
* Conditional logic
98124
* ================================================================= */
@@ -661,7 +687,7 @@
661687
}
662688
};
663689

664-
window.ArrayPressFieldKitModules = { Conditions: Conditions, Range: Range, Toggle: Toggle, Clipboard: Clipboard, CodeGenerator: CodeGenerator, Oembed: Oembed, announce: announce, bound: bound, t: t, config: config };
690+
window.ArrayPressFieldKitModules = { Conditions: Conditions, Range: Range, Toggle: Toggle, Clipboard: Clipboard, CodeGenerator: CodeGenerator, Oembed: Oembed, announce: announce, bound: bound, sibling: sibling, t: t, config: config };
665691
} )();
666692

667693
/**
@@ -3694,18 +3720,15 @@
36943720
/**
36953721
* The control holding this amount's currency code.
36963722
*
3697-
* Scoped to the row rather than the document: a repeater has one per
3698-
* row, and the first match in the form would give every row the
3699-
* first row's currency.
3723+
* The kit's sibling lookup, which is scoped to the row: a repeater
3724+
* has one per row, and the first match in the form would give every
3725+
* row the first row's currency.
37003726
*
37013727
* @param {Element} wrap The money field.
37023728
* @return {Element|null} The control, if there is one.
37033729
*/
37043730
source: function ( wrap ) {
3705-
var scope = wrap.closest( '.field-kit__repeater-row' ) || wrap.closest( 'form' ) || document;
3706-
var key = wrap.dataset.currencyKey;
3707-
3708-
return scope.querySelector( '[name$="[' + key + ']"], [name="' + key + '"]' );
3731+
return kit.sibling( wrap, wrap.dataset.currencyKey );
37093732
},
37103733

37113734
/**
@@ -3738,6 +3761,172 @@
37383761

37393762
window.ArrayPressFieldKitModules.Money = Money;
37403763

3764+
/* =================================================================
3765+
* Region
3766+
* ================================================================= */
3767+
3768+
var Region = {
3769+
3770+
/**
3771+
* Fit each region control to the country of its row.
3772+
*
3773+
* PHP drew two controls -- a text input, live, and a select, empty
3774+
* and disabled -- because it could not know the country at render
3775+
* time. This asks the server for that country's regions and keeps
3776+
* whichever control fits: the select where there is a list, the text
3777+
* input where there is not. Every step is guarded so that a field
3778+
* with no country beside it, or a request that fails, is left as it
3779+
* was drawn, with the text input live and submitting.
3780+
*
3781+
* @param {Element} root Container.
3782+
*/
3783+
init: function ( root ) {
3784+
root.querySelectorAll( '.field-kit__region[data-country-key]' ).forEach( function ( wrap ) {
3785+
if ( wrap.dataset.fkBound ) {
3786+
return;
3787+
}
3788+
3789+
wrap.dataset.fkBound = '1';
3790+
3791+
var source = kit.sibling( wrap, wrap.dataset.countryKey );
3792+
3793+
if ( ! source ) {
3794+
return;
3795+
}
3796+
3797+
Region.load( wrap, source.value );
3798+
3799+
source.addEventListener( 'change', function () {
3800+
Region.load( wrap, source.value );
3801+
} );
3802+
} );
3803+
},
3804+
3805+
/**
3806+
* Fetch a country's regions and fit the control to them.
3807+
*
3808+
* Numbered, as the combobox numbers its searches: a slow answer for
3809+
* the previous country must not land after the answer for this one
3810+
* and put the wrong list in the select.
3811+
*
3812+
* @param {Element} wrap The region field.
3813+
* @param {string} country The country code.
3814+
*/
3815+
load: function ( wrap, country ) {
3816+
var endpoint = wrap.dataset.searchEndpoint || ( config.restUrl ? config.restUrl + 'search' : '' );
3817+
var ticket;
3818+
var url;
3819+
3820+
wrap.fieldKitTicket = ( wrap.fieldKitTicket || 0 ) + 1;
3821+
ticket = wrap.fieldKitTicket;
3822+
3823+
if ( ! country || ! endpoint || typeof fetch !== 'function' ) {
3824+
Region.apply( wrap, [] );
3825+
3826+
return;
3827+
}
3828+
3829+
try {
3830+
url = new URL( endpoint, window.location.origin );
3831+
} catch ( error ) {
3832+
Region.apply( wrap, [] );
3833+
3834+
return;
3835+
}
3836+
3837+
url.searchParams.set( 'source', 'region' );
3838+
url.searchParams.set( 'args[country]', country );
3839+
3840+
fetch( url, {
3841+
credentials: 'same-origin',
3842+
headers: { 'X-WP-Nonce': wrap.dataset.searchNonce || config.restNonce || '' }
3843+
} ).then( function ( response ) {
3844+
return response.ok ? response.json() : { results: [] };
3845+
} ).then( function ( data ) {
3846+
if ( ticket === wrap.fieldKitTicket ) {
3847+
Region.apply( wrap, data.results || [] );
3848+
}
3849+
} ).catch( function () {
3850+
if ( ticket === wrap.fieldKitTicket ) {
3851+
Region.apply( wrap, [] );
3852+
}
3853+
} );
3854+
},
3855+
3856+
/**
3857+
* Make one control live and the other inert.
3858+
*
3859+
* Only one may submit, so the one standing down is disabled as well
3860+
* as hidden: a hidden control still posts. Nothing is announced. A
3861+
* select appearing where a text box was is a change of control, not
3862+
* of state, and the label reads the same either way.
3863+
*
3864+
* @param {Element} wrap The region field.
3865+
* @param {Array} regions Rows of {id, text}; empty for a country without any.
3866+
*/
3867+
apply: function ( wrap, regions ) {
3868+
var select = wrap.querySelector( '.field-kit__region-select' );
3869+
var text = wrap.querySelector( '.field-kit__region-text' );
3870+
3871+
if ( ! select || ! text ) {
3872+
return;
3873+
}
3874+
3875+
// Whichever control is live holds the current value: the stored
3876+
// one from the text input before the first swap, the chosen one
3877+
// from the select after it.
3878+
var current = select.disabled ? text.value : select.value;
3879+
3880+
select.textContent = '';
3881+
3882+
if ( ! regions.length ) {
3883+
select.disabled = true;
3884+
select.hidden = true;
3885+
text.disabled = false;
3886+
text.hidden = false;
3887+
3888+
return;
3889+
}
3890+
3891+
if ( ! select.hasAttribute( 'required' ) ) {
3892+
select.appendChild( Region.option( '', select.dataset.placeholder || t( 'selectRegion', 'Select a region' ) ) );
3893+
}
3894+
3895+
regions.forEach( function ( region ) {
3896+
var option = Region.option( String( region.id ), String( region.text ) );
3897+
3898+
if ( option.value === current ) {
3899+
option.selected = true;
3900+
}
3901+
3902+
select.appendChild( option );
3903+
} );
3904+
3905+
text.disabled = true;
3906+
text.hidden = true;
3907+
select.disabled = false;
3908+
select.hidden = false;
3909+
},
3910+
3911+
/**
3912+
* One option.
3913+
*
3914+
* @param {string} value Its value.
3915+
* @param {string} label Its text.
3916+
* @return {Element} The option.
3917+
*/
3918+
option: function ( value, label ) {
3919+
var option = document.createElement( 'option' );
3920+
3921+
option.value = value;
3922+
option.textContent = label;
3923+
3924+
return option;
3925+
}
3926+
};
3927+
3928+
window.ArrayPressFieldKitModules.Region = Region;
3929+
37413930
/* ====================================================================
37423931
* Bootstrap
37433932
* ================================================================= */
@@ -3754,7 +3943,7 @@
37543943
function init( root ) {
37553944
root = root || document;
37563945

3757-
[ 'Conditions', 'Range', 'Toggle', 'Clipboard', 'CodeGenerator', 'Oembed', 'Combobox', 'Reorder', 'Gallery', 'Repeater', 'Media', 'Tags', 'CodeEditor', 'ColorPicker', 'TagModal', 'PanelTabs', 'EmailPanel', 'ActionButton', 'Tooltip', 'IconPreview', 'Providers', 'Money' ].forEach( function ( name ) {
3946+
[ 'Conditions', 'Range', 'Toggle', 'Clipboard', 'CodeGenerator', 'Oembed', 'Combobox', 'Reorder', 'Gallery', 'Repeater', 'Media', 'Tags', 'CodeEditor', 'ColorPicker', 'TagModal', 'PanelTabs', 'EmailPanel', 'ActionButton', 'Tooltip', 'IconPreview', 'Providers', 'Money', 'Region' ].forEach( function ( name ) {
37583947
var module = window.ArrayPressFieldKitModules[ name ];
37593948

37603949
if ( module && typeof module.init === 'function' ) {

‎src/Assets.php‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,7 @@ private function config(): array {
241241
'embedResolving' => __( 'Looking for an embed…', 'arraypress' ),
242242
'embedFailed' => __( 'That URL could not be embedded.', 'arraypress' ),
243243
'embedOnSave' => __( 'This will render once saved.', 'arraypress' ),
244+
'selectRegion' => __( 'Select a region', 'arraypress' ),
244245
],
245246
];
246247
}

‎src/Registry.php‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ final class Registry {
6464
'button_group' => Types\ButtonGroupType::class,
6565
'country' => Types\CountryType::class,
6666
'currency' => Types\CurrencyType::class,
67+
'region' => Types\RegionType::class,
6768

6869
// Media.
6970
'image' => Types\ImageType::class,

0 commit comments

Comments
 (0)