Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions src/templates/_components/app/address-fields.twig
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,8 @@ since the array key will be the same #}
placeholder: 'addressFields.select'|t('foster-checkout'),
required: true,
value: address ? address.countryCode : '',
errors: errors.countryCode ?? []
errors: errors.countryCode ?? [],
autocomplete: 'country-name'
} %}
<template x-if="'countryCode' in errors">
<div
Expand Down Expand Up @@ -181,7 +182,7 @@ since the array key will be the same #}
{% if customField.multiline %}

<label for="{{ ('custom-field-' ~ customField.handle)|namespaceInputId(context) }}"
class="block text-sm font-medium text-gray-700">
class="block text-sm font-medium text-gray-700">
{{ customField.name|t('foster-checkout') }}{% if customField.required %}*{% endif %}
</label>

Expand Down
56 changes: 32 additions & 24 deletions src/templates/_components/app/address-region-field.twig
Original file line number Diff line number Diff line change
Expand Up @@ -24,30 +24,38 @@

<div
x-data="{
regions: {{ countriesAndRegions|json_encode }},
initialRegions: [],
}"
x-init="initialRegions = regions[countryCode] ?? []"
regions: {{ countriesAndRegions|json_encode }},
initialRegions: [],
currentRegions: [],
}"
x-init="
initialRegions = regions[countryCode] ?? [];
currentRegions = initialRegions;
"
@selected.window="
if ($event.detail.name === '{{ context|default ? (context ~ "[countryCode]") : "countryCode" }}') {
$dispatch('addressregions', regions[$event.detail.value]);
}
"
if ($event.detail.name === '{{ context|default ? (context ~ "[countryCode]") : "countryCode" }}') {
currentRegions = regions[$event.detail.value] ?? [];
$nextTick(() => {
$dispatch('addressregions', currentRegions);
});
}
"
>
<div>
{% include 'foster-checkout/_components/base/input-select-searchable' with {
context: context,
id: 'administrativeArea',
model: 'administrativeArea',
name: 'administrativeArea',
options: 'initialRegions',
eventName: 'addressregions',
fallbackOptions: fallbackOptions,
label: 'addressFields.stateLabel'|t('foster-checkout'),
placeholder: 'addressFields.select'|t('foster-checkout'),
required: true,
value: address ? address.administrativeArea : '',
errors: errors.administrativeArea ?? []
} %}
</div>
<div x-show="currentRegions.length > 0">
{% include 'foster-checkout/_components/base/input-select-searchable' with {
context: context,
id: 'administrativeArea',
model: 'administrativeArea',
name: 'administrativeArea',
options: 'initialRegions',
eventName: 'addressregions',
fallbackOptions: fallbackOptions,
label: 'addressFields.stateLabel'|t('foster-checkout'),
placeholder: 'addressFields.select'|t('foster-checkout'),
required: true,
value: address ? address.administrativeArea : '',
errors: errors.administrativeArea ?? [],
autocomplete: 'address-level1'
} %}
</div>
</div>
54 changes: 30 additions & 24 deletions src/templates/_components/base/input-select-searchable.twig
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
{% set model = model ?? '' %}
{% set options = options ?? [] %}
{% set placeholder = placeholder ?? 'Select' %}
{% set autocomplete = autocomplete ?? 'off' %}

{% set fallbackOptions = fallbackOptions|default ? fallbackOptions : options %}
{% set fallbackClasses = ['w-full h-12 px-4 bg-white rounded-lg focus:!ring-0 focus:!outline-none placeholder:text-gray-400 transition-color duration-300'] %}
Expand Down Expand Up @@ -81,47 +82,51 @@
</label>
{% endif %}

<!-- Button that behaves like a native select -->
<button
type="button"
x-ref="button"
:id="buttonId()"
@focus="toggleListbox()"
@keydown.arrow-down.prevent="openListbox()"
@keydown.arrow-up.prevent="openListbox()"
@keydown.enter.prevent="toggleListbox()"
@keydown.space.prevent="toggleListbox()"
class="relative w-full py-[11px] pl-3 pr-10 rounded-lg cursor-default border bg-white text-left focus:!ring-0 focus:!outline-none transition-color duration-300"
:class="errors.length ? 'border-red-500 focus:border-red-500' : 'border-gray-250 focus:border-black'"
aria-haspopup="listbox"
:aria-expanded="open ? 'true' : 'false'"
:aria-labelledby="labelId()"
:aria-controls="listboxId()"
tabindex="0"
>
<span class="block truncate" x-text="buttonLabel"></span>
<!-- Text input trigger — visible to browsers for autofill, styled like a select -->
<div class="relative">
<input
type="text"
x-ref="button"
:id="buttonId()"
:name="`${name}_display`"
:required="required && options.length > 0"
:value="buttonLabel === placeholder ? '' : buttonLabel"
:placeholder="placeholder"
autocomplete="{{ autocomplete }}"
@click="toggleListbox()"
@keydown.arrow-down.prevent="openListbox()"
@keydown.arrow-up.prevent="openListbox()"
@keydown.enter.prevent="open ? selectActiveOption() : toggleListbox()"
@keydown="onTriggerKeydown($event)"
@input="onTriggerInput($event)"
class="w-full py-[11px] pl-3 pr-10 rounded-lg cursor-default border bg-white text-left focus:!ring-0 focus:!outline-none transition-color duration-300 caret-transparent"
:class="errors.length ? 'border-red-500 focus:border-red-500' : 'border-gray-250 focus:border-black'"
aria-haspopup="listbox"
:aria-expanded="open ? 'true' : 'false'"
:aria-labelledby="labelId()"
:aria-controls="listboxId()"
/>
<span class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
<svg class="h-6 w-6" viewBox="0 0 20 20" fill="none" aria-hidden="true">
<path stroke='#6B7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'></path>
</svg>
</span>
</button>
</div>

<!-- Hidden input for form submission (server sees just the value/code) -->
<input
type="text"
class="w-0 h-0 opacity-0 absolute pointer-events-none"
type="hidden"
:name="name"
:value="modelValue ?? ''"
:aria-invalid="errors.length ? true : false"
:aria-errormessage="errorId()"
x-ref="hiddenValue"
@input="selectByValue($event.target.value)"
/>

<!-- Dropdown -->
<div
x-show="open"
x-trap.inert.noreturn="open"
x-trap.inert="open"
class="relative"
>
<div
Expand All @@ -137,6 +142,7 @@
x-ref="search"
type="text"
x-model="search"
autocomplete="off"
role="searchbox"
:aria-controls="listboxId()"
:aria-activedescendant="open && hasOptions ? optionId(activeIndex) : null"
Expand Down
115 changes: 102 additions & 13 deletions src/web/assets/checkout/dist/js/alpine.js
Original file line number Diff line number Diff line change
Expand Up @@ -113,8 +113,15 @@ const SearchableSelect = (props) => {
const value = this.tmpInputEventValue;
this.tmpInputEventValue = null;

this.selectedOption = this.options.find(matchOptionByValue(value)) || null;
this.selectByValue(value);
}

this.$nextTick(() => {
const input = this.$refs.button;
if (input && input.value && !this.selectedOption) {
this.selectByValue(input.value);
}
});
});

// parent -> child
Expand Down Expand Up @@ -142,6 +149,65 @@ const SearchableSelect = (props) => {
});
},

/**
* Called when the trigger input receives an `input` event.
* Handles two scenarios:
* a) Browser autofill just set the value — match it to an option
* b) User is typing directly — open dropdown and pipe text into search
*/
onTriggerInput(event) {
if (this._keydownHandled) {
this._keydownHandled = false;
event.target.value = this.selectedOption ? this.selectedOption.label : '';
return;
}

const val = event.target.value;

// Try autofill match first
if (this.selectByValue(val)) return;

// If value was stored as pending (options not loaded yet), don't open dropdown
if (this.tmpInputEventValue) return;

// Not an autofill match — user is typing.
// Open the dropdown and forward their text into the search field.
if (!this.open) {
this.open = true;
this.resetActiveIndex();
}

this.$nextTick(() => {
if (this.$refs.search) {
this.$refs.search.value = val;
this.search = val;
}
// Reset the trigger input to the current selection
event.target.value = this.selectedOption ? this.selectedOption.label : '';
});
},

/**
* When the user presses a printable key on the trigger input,
* open the dropdown so typing flows into the search field.
*/
onTriggerKeydown(event) {
if (event.key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey) {
if (!this.open) {
this._keydownHandled = true;
this.openListbox();
setTimeout(() => {
if (this.$refs.search) {
this.$refs.search.value = event.key;
this.search = event.key;
const len = this.$refs.search.value.length;
this.$refs.search.setSelectionRange(len, len);
}
}, 50);
}
}
},

get buttonLabel() {
return this.selectedOption ? this.selectedOption.label : this.placeholder;
},
Expand Down Expand Up @@ -233,9 +299,6 @@ const SearchableSelect = (props) => {

closeAndFocusButton() {
this.closeListbox();
this.$nextTick(() => {
this.$refs.button.focus();
});
},

resetActiveIndex() {
Expand Down Expand Up @@ -290,8 +353,13 @@ const SearchableSelect = (props) => {

// --- selection ---
selectOption(option) {
const wasOpen = this.open;
this.selectedOption = option; // watcher will push value into modelValue
this.closeListbox();
if (wasOpen) {
this.closeAndFocusButton();
} else {
this.closeListbox();
}
},

isSelected(option) {
Expand All @@ -304,14 +372,41 @@ const SearchableSelect = (props) => {
// When the form is autofilled, we can't assume the options will be immediately available if they've
// been changed based on some other field's value. So we set a temporary value if we don't match
// anything at this point.
this.tmpInputEventValue = value;
const selectedOption = this.options.find(matchOptionByValue(value)) || null;
if (!value) {
return null;
}

const q = value.toLowerCase().trim();

// Exact match on label
let selectedOption = this.options.find(o => String(o.label).toLowerCase() === q);

// Exact match on value/code (e.g. "CA", "US")
if (!selectedOption) {
selectedOption = this.options.find(o => String(o.value).toLowerCase() === q);
}

// Fuzzy: starts-with on label
if (!selectedOption) {
selectedOption = this.options.find(o => String(o.label).toLowerCase().startsWith(q));
}

// Fuzzy: includes on label
if (!selectedOption) {
selectedOption = this.options.find(o => String(o.label).toLowerCase().includes(q));
}

if (selectedOption) {
// If we found the option, we can clear this value
this.tmpInputEventValue = null;
this.selectedOption = selectedOption;
return selectedOption;
}

// No match — store as pending for when options load (e.g. state autofilled before country)
this.tmpInputEventValue = value;

return null;
},

updateLastPinned(event) {
Expand All @@ -326,12 +421,6 @@ const SearchableSelect = (props) => {
};
};

const matchOptionByValue = (value) => {
const lowercaseValue = value.toLowerCase();
return (option) => option.label.toLowerCase() === lowercaseValue
|| option.value.toLowerCase() === lowercaseValue;
}

const LineItem = (props) => {
return {
id: props.lineItemId,
Expand Down
Loading