-
Notifications
You must be signed in to change notification settings - Fork 18
Picker (widget)
The Picker widget is a custom dropdown component that provides full control over styling and interaction. It replaces native <select> elements with a fully accessible, keyboard-navigable custom implementation.
-
JavaScript:
express/code/scripts/widgets/picker.js -
CSS:
express/code/scripts/widgets/picker.css -
Tests:
test/scripts/widgets/picker.test.js
import { createPicker } from '../../../../scripts/widgets/picker.js';
const picker = createPicker({
id: 'size-selector',
name: 'size',
label: 'Size',
options: [
{ value: 's', text: 'Small' },
{ value: 'm', text: 'Medium' },
{ value: 'l', text: 'Large' },
],
defaultValue: 'm',
onChange: (value) => {
console.log('Selected:', value);
},
});
document.body.appendChild(picker);const picker = createPicker({
id: 'product-selector',
name: 'product',
label: 'Choose Product',
required: true,
helpText: 'Select a product to continue',
options: [
{ value: '1', text: 'Product 1' },
{ value: '2', text: 'Product 2', disabled: true },
{ value: '3', text: 'Product 3' },
],
defaultValue: '1',
onChange: (value, event) => {
console.log('New value:', value);
},
disabled: false,
size: 'm',
variant: 'default',
labelPosition: 'side',
ariaLabel: 'Product selection',
ariaDescribedBy: 'product-help',
});| Parameter | Type | Default | Description |
|---|---|---|---|
id |
string |
required | Unique ID for the picker |
name |
string |
id value |
Name for the hidden input field |
label |
string |
'' |
Label text displayed above/beside picker |
required |
boolean |
false |
Adds asterisk to label if true |
helpText |
string |
'' |
Help text displayed below picker |
options |
Array<{value, text, disabled}> |
[] |
Array of option objects |
defaultValue |
string|number |
First option | Initially selected value |
onChange |
Function(value, event) |
null |
Called when selection changes |
disabled |
boolean |
false |
Disables the entire picker |
size |
string |
'm' |
Size variant: 's', 'm', 'l', 'xl'
|
variant |
string |
'default' |
Visual variant: 'default', 'quiet'
|
labelPosition |
string |
'top' |
Label position: 'top', 'side'
|
ariaLabel |
string |
'' |
ARIA label for accessibility |
ariaDescribedBy |
string |
'' |
ARIA described-by attribute |
Programmatically set the selected value.
picker.setPicker('l');Get the currently selected value.
const currentValue = picker.getPicker();Replace all options with new ones.
picker.setOptions([
{ value: 'xs', text: 'Extra Small' },
{ value: 's', text: 'Small' },
{ value: 'm', text: 'Medium' },
]);Enable or disable the picker.
picker.setDisabled(true); // Disable
picker.setDisabled(false); // EnableSet error state and message.
picker.setError('Please select a valid option');Clear error state and restore original help text.
picker.clearError();Set loading state (also disables picker).
picker.setLoading(true); // Start loading
picker.setLoading(false); // Stop loadingClean up event listeners.
picker.destroy();| Key | Action |
|---|---|
Space / Enter
|
Open dropdown (when closed) / Select focused option (when open) |
Escape |
Close dropdown and return focus to button |
ArrowDown |
Open dropdown (closed) / Move focus to next option (open) |
ArrowUp |
Open dropdown (closed) / Move focus to previous option (open) |
Home |
Focus first option (when open) |
End |
Focus last option (when open) |
Tab |
Close dropdown and move to next focusable element |
The picker implements the following ARIA attributes:
-
role="button"on the closed state button -
role="listbox"on the options container -
role="option"on each option -
aria-haspopup="listbox"on the button -
aria-expanded(true/false) on the button -
aria-selected(true/false) on options -
aria-label(optional) on the button -
aria-describedby(optional) connecting to help text
- Focus ring only appears on keyboard navigation (
:focus-visible) - After selection, focus returns to the picker button
- Handles DOM re-creation scenarios (e.g., when
onChangetriggers re-render) - Uses
MutationObserverto track new DOM elements and restore focus
- Selected value is announced when picker is focused
- Options are properly labeled with
aria-selected - Help text is connected via
aria-describedby
The picker uses the following CSS variables from the design system:
-
--body-font-family- Font family - Standard color values (
#292929,#e1e1e1,#5c5ce0, etc.)
All styles are mobile-first with desktop overrides:
/* Mobile default */
.picker-options-wrapper {
width: 100%;
}
/* Desktop (768px+) */
@media (min-width: 768px) {
.picker-options-wrapper {
width: max-content;
}
}Standard picker with gray background:
createPicker({ variant: 'default' })Minimal styling with bottom border:
createPicker({ variant: 'quiet' })Label appears above the picker:
createPicker({ labelPosition: 'top' })Label appears to the left of the picker (desktop only):
createPicker({ labelPosition: 'side' })For multiple related pickers (e.g., Size and Quantity):
.picker-group {
display: flex;
gap: 16px;
width: 208px; /* Desktop width */
}For pickers with associated links (e.g., "Size chart"):
.picker-with-link {
display: flex;
flex-direction: column;
gap: 4px;
align-items: flex-end;
max-width: 400px;
}<div class="picker-container">
<label class="picker-label">Size</label>
<div class="picker-wrapper">
<div class="picker-button-wrapper" role="button" aria-expanded="false">
<span class="picker-current-value">Medium</span>
<img class="picker-chevron" src="/express/code/icons/drop-down-arrow.svg" />
</div>
<div class="picker-options-wrapper" role="listbox">
<div class="picker-option-button active" role="option" aria-selected="true">
<img class="picker-option-checkmark" src="/express/code/icons/checkmark.svg" />
<span class="picker-option-text">Medium</span>
</div>
<!-- More options -->
</div>
<input type="hidden" name="size" value="m" />
</div>
</div>The picker maintains internal state using closures:
-
currentValue- Currently selected value -
isOpen- Dropdown open/closed state -
focusedOptionIndex- Index of keyboard-focused option -
disabled- Original disabled state (immutable)
The picker automatically closes when clicking outside:
const handleClickOutside = (e) => {
if (isOpen && !container.contains(e.target)) {
closeDropdown();
}
};
document.addEventListener('click', handleClickOutside);Remember to call picker.destroy() to clean up this event listener.
The onChange callback receives:
-
value- The new selected value -
event- Event object with{ target: { value } }
onChange: (value, event) => {
console.log('Value:', value);
console.log('Event:', event);
}When onChange triggers a DOM re-render (e.g., in React-like environments), the picker uses MutationObserver to detect when the new picker button is added to the DOM and automatically restores focus:
const observer = new MutationObserver((mutations, obs) => {
const newButton = document.getElementById(id);
if (newButton && newButton !== buttonWrapper) {
newButton.focus();
obs.disconnect();
}
});
observer.observe(document.body, { childList: true, subtree: true });The observer auto-disconnects after 2 seconds as a fallback.
Run the test suite:
npm test -- --grep "Picker Widget"Tests cover:
- ✅ Initialization and rendering
- ✅ Click and keyboard interactions
- ✅ Value management and onChange callbacks
- ✅ Public API methods
- ✅ Accessibility (ARIA attributes)
- ✅ Disabled state
- ✅ Error handling
- ✅ Edge cases
- ✅ Chrome/Edge (latest)
- ✅ Firefox (latest)
- ✅ Safari (latest)
- ✅ Mobile Safari (iOS)
- ✅ Chrome Mobile (Android)
createPicker({
id: 'product',
label: 'Product',
options: productsArray.map(p => ({ value: p.id, text: p.name })),
onChange: (id) => {
loadProductDetails(id);
},
});const sizeSelector = createPicker({
id: 'size',
label: 'Size',
options: [
{ value: 's', text: 'Small' },
{ value: 'm', text: 'Medium' },
{ value: 'l', text: 'Large' },
],
onChange: updatePrice,
});
const qtySelector = createPicker({
id: 'qty',
label: 'Quantity',
options: Array.from({ length: 10 }, (_, i) => ({
value: String(i + 1),
text: String(i + 1),
})),
onChange: updatePrice,
});
const pickerGroup = createTag('div', { class: 'picker-group' });
pickerGroup.appendChild(sizeSelector);
pickerGroup.appendChild(qtySelector);const picker = createPicker({
id: 'category',
label: 'Category',
options: [{ value: '', text: 'Loading...' }],
disabled: true,
});
fetchCategories().then(categories => {
picker.setOptions(categories.map(c => ({ value: c.id, text: c.name })));
picker.setDisabled(false);
});Solution: Ensure parent containers don't have overflow: hidden or display: contents.
Solution: Check that you're passing onChange in the config object, not as a separate parameter.
Solution: Store the value externally and use setPicker() to restore it after re-render, or ensure your onChange callback properly updates your state management.
Solution: The picker automatically handles this with MutationObserver. If issues persist, ensure the new picker element has the same id.
- Styles are loaded once per page (cached)
- Event listeners are attached at the container level where possible
- MutationObserver auto-disconnects to prevent memory leaks
- Always call
destroy()when removing pickers dynamically
Potential improvements for future versions:
- Multi-select support
- Search/filter functionality
- Grouped options (optgroup)
- Custom option templates
- Virtual scrolling for large option lists
- Animation transitions
-
Tooltip Widget:
scripts/widgets/tooltip.js - Button Components: See design system
-
Form Components: Various form widgets in
scripts/widgets/
For issues or questions:
- Check test file:
test/scripts/widgets/picker.test.js - Review existing implementations in
blocks/print-product-detail - Consult Figma designs for visual specifications