Skip to content

Commit 45ca55d

Browse files
committed
Merge branch 'feature/region'
# Conflicts: # README.md # assets/css/field-kit.css # assets/js/field-kit.js # src/Assets.php # tests/js/smoke.js
2 parents f02246d + 40c694d commit 45ca55d

10 files changed

Lines changed: 1228 additions & 14 deletions

File tree

‎README.md‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,34 @@ $set = new FieldSet(
180180
);
181181
```
182182

183+
## A country and its regions
184+
185+
A `region` is a state, a province or a county. Given a fixed `country` it is
186+
a select over that country's subdivisions, storing the code the way `country`
187+
stores its own. Given `country_key` — the key of the country field beside it
188+
— it follows whatever is chosen there: a select where the country has
189+
subdivisions on file, a text input where it does not, swapped by the script
190+
on load and on every change.
191+
192+
```php
193+
use ArrayPress\FieldKit\FieldSet;
194+
use ArrayPress\FieldKit\Context\OptionContext;
195+
196+
$set = new FieldSet(
197+
[
198+
'country' => [ 'type' => 'country', 'label' => __( 'Country', 'my-plugin' ) ],
199+
'region' => [ 'type' => 'region', 'label' => __( 'State or province', 'my-plugin' ), 'country_key' => 'country' ],
200+
],
201+
new OptionContext( 'my_plugin' ),
202+
'my_plugin'
203+
);
204+
```
205+
206+
Without the script the text input is what submits, so a typed code still
207+
saves. Inside a repeater the lookup is scoped to the row, so each row's
208+
region follows that row's country. The subdivisions are WooCommerce's, by
209+
way of `arraypress/wp-countries`.
210+
183211
## Requirements
184212

185213
* 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;
@@ -3447,6 +3448,31 @@ span.field-kit__provider-logo {
34473448
font-weight: 600;
34483449
}
34493450

3451+
/* A region is drawn twice -- a text input for a country whose subdivisions
3452+
are not on file and a select for one whose are -- and only the script
3453+
knows which. The wrapper sits inline like the select it stands in for,
3454+
capped at core's own select width, so whichever control is live lines up
3455+
with the fields around it the same way. */
3456+
.field-kit__region {
3457+
display: inline-block;
3458+
max-width: 25rem;
3459+
vertical-align: middle;
3460+
}
3461+
3462+
.field-kit__region .field-kit__region-select,
3463+
.field-kit__region .field-kit__region-text {
3464+
max-width: 100%;
3465+
}
3466+
3467+
/* The swap is done with the hidden attribute, which the browser applies as
3468+
a bare element rule. Neither control here is given a display of its own,
3469+
but core gives inputs and selects one on some screens, so the attribute's
3470+
rule is restated where it cannot lose. */
3471+
.field-kit__region-select[hidden],
3472+
.field-kit__region-text[hidden] {
3473+
display: none;
3474+
}
3475+
34503476
/* A row folds away to its header. `li` on purpose: a <tr> given a display of
34513477
its own leaves the table's column geometry, and its cells stop lining up
34523478
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
* Password reveal
37433932
* ================================================================= */
@@ -3901,7 +4090,7 @@
39014090
function init( root ) {
39024091
root = root || document;
39034092

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

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

‎src/Assets.php‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,7 @@ private function config(): array {
243243
'embedOnSave' => __( 'This will render once saved.', 'arraypress' ),
244244
'showPassword' => __( 'Show password', 'arraypress' ),
245245
'hidePassword' => __( 'Hide password', 'arraypress' ),
246+
'selectRegion' => __( 'Select a region', 'arraypress' ),
246247
],
247248
];
248249
}

‎src/Registry.php‎

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

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

0 commit comments

Comments
 (0)