Component props.
columnId - Column id; the accordion's panelId is derived as
search-${columnId}.config - The column's meta.drawer payload.expandedAccordion - Currently expanded accordion's panel id
(or false for none).onAccordionChange - MUI Accordion onChange factory; call
with the panelId to get the handler.An MUI Accordion summary + the widget matching config.widget.
// Country column — meta.drawer is widget: "autocompleteObjects", bound
// to searchFilters.country with code/label option objects.
<ColumnDrawerSection
columnId="country"
config={countryDrawerConfig}
expandedAccordion="search-country"
onAccordionChange={(panelId) => (_e, isOpen) => setExpanded(isOpen ? panelId : false)}
/>
// Renders:
// <Accordion panelId="search-country" expanded>
// Country (2 selected) ← summary hint updates with value.length
// <Autocomplete multiple value={[{code:"US",label:"United States"}, ...]}
// onChange={... setSearchFilters({..., country: codes}) ...} />
export default function ColumnDrawerSection({
columnId,
config,
expandedAccordion,
onAccordionChange,
}: ColumnDrawerSectionProps) {
const {
selectedSuppliers,
setSelectedSuppliers,
searchFilters,
setSearchFilters,
userSettings,
setUserSettings,
} = useAppContext();
// Each supplier's country + shipping scope, used both to grey out suppliers that
// can't satisfy the active shipping/country filters and to grey out shipping/
// country options no selected supplier offers. Read once (instantiates suppliers).
const shippingMeta = useMemo(() => SupplierFactory.supplierShippingMeta(), []);
// Supplier keys to grey out + disable in the supplier autocomplete: those that
// won't ship to the user's location (gated by the toggle), plus those ruled out
// by the active shipping-type / country search filters. Empty unless this is the
// supplier selector.
const excludeSuppliers = userSettings.suppliers?.excludeNonShipping ?? true;
const { location } = userSettings;
const isSupplierSelector =
config.widget === 'autocompleteStrings' && config.bind.kind === 'selectedSuppliers';
const excludedSuppliers = useMemo(() => {
if (!isSupplierSelector) return new Set<string>();
const excluded = new Set<string>();
if (excludeSuppliers && location) {
for (const [key, ships] of Object.entries(
SupplierFactory.supplierShipsTo(location as CountryCode),
)) {
if (!ships) excluded.add(key);
}
}
for (const key of suppliersExcludedBySearchFilters(shippingMeta, searchFilters)) {
excluded.add(key);
}
return excluded;
}, [isSupplierSelector, excludeSuppliers, location, shippingMeta, searchFilters]);
const panelId = `search-${columnId}`;
const isExpanded = expandedAccordion === panelId;
const summary = (hint?: ReactNode) => (
<StyledAccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography>
{config.label}
{hint !== undefined && <span className={styles['accordion-hint']}>{hint}</span>}
</Typography>
</StyledAccordionSummary>
);
// autocompleteStrings — e.g. Search Suppliers (keys: string[]).
if (config.widget === 'autocompleteStrings') {
if (config.bind.kind !== 'selectedSuppliers' && config.bind.kind !== 'searchFilters') {
return null;
}
const { options, optionLabels, emptyHelperText, placeholder } = config;
// `selectedSuppliers` and per-filter arrays may be undefined before the
// mount hydration lands — coalesce to [] so the Autocomplete + summary
// code below can treat `currentValue` as a concrete array uniformly.
const currentValue: string[] =
config.bind.kind === 'selectedSuppliers'
? (selectedSuppliers ?? [])
: // `bind.key` is `keyof SearchFilters`, whose values are `string |
// string[]`; this widget only binds to the `string[]` filters.
((searchFilters[config.bind.key] as string[] | undefined) ?? []);
const handleChange = (_event: SyntheticEvent, newValue: string[]) => {
if (config.bind.kind === 'selectedSuppliers') {
// Autocomplete yields plain strings; keep only valid supplier names.
setSelectedSuppliers(newValue.filter(SupplierFactory.isSupplierClassName));
} else if (config.bind.kind === 'searchFilters') {
setSearchFilters({ ...searchFilters, [config.bind.key]: newValue });
}
};
return (
<Accordion expanded={isExpanded} onChange={onAccordionChange(panelId)}>
{summary(currentValue.length > 0 ? ` (${currentValue.length} selected)` : undefined)}
<StyledAccordionDetails>
<Autocomplete
multiple
size="small"
disableCloseOnSelect
options={[...options]}
getOptionLabel={(option) => optionLabels?.[option] ?? option}
filterOptions={(opts, { inputValue }) => {
const term = inputValue.toLowerCase();
return opts.filter(
(opt) =>
!currentValue.includes(opt) &&
(optionLabels?.[opt] ?? opt).toLowerCase().includes(term),
);
}}
value={currentValue}
onChange={handleChange}
getOptionDisabled={(option) => excludedSuppliers.has(option)}
renderOption={(props, option) => {
// MUI passes `key` inside props; pull it out to set it explicitly.
const { key, ...optionProps } = props;
const excluded = excludedSuppliers.has(option);
return (
<Box
component="li"
key={key}
{...optionProps}
sx={excluded ? { fontStyle: 'italic', color: 'text.disabled' } : undefined}
>
{optionLabels?.[option] ?? option}
</Box>
);
}}
renderInput={(params) => (
<TextField
{...params}
label={i18n('filter_by_label', [config.label.toLowerCase()])}
placeholder={placeholder}
helperText={currentValue.length === 0 ? emptyHelperText : undefined}
slotProps={{ formHelperText: { sx: { fontStyle: 'italic' } } }}
/>
)}
/>
{isSupplierSelector && (
<FormControlLabel
sx={{ mt: 1 }}
control={
<Switch
size="small"
checked={excludeSuppliers}
onChange={(e) =>
setUserSettings({
...userSettings,
suppliers: {
...userSettings.suppliers,
excludeNonShipping: e.target.checked,
},
})
}
/>
}
label={i18n('drawer_only_shipping_suppliers')}
/>
)}
{isSupplierSelector && (
<FormControlLabel
sx={{ mt: 1 }}
control={
<Switch
size="small"
checked={userSettings.hideRestrictedProducts ?? true}
onChange={(e) =>
setUserSettings({
...userSettings,
hideRestrictedProducts: e.target.checked,
})
}
/>
}
label={i18n('drawer_hide_restricted_products')}
/>
)}
</StyledAccordionDetails>
</Accordion>
);
}
// autocompleteObjects — e.g. Country (options are { code, label }).
if (config.widget === 'autocompleteObjects') {
if (config.bind.kind !== 'searchFilters') return null;
const bindKey = config.bind.key;
const { options, emptyHelperText, placeholder } = config;
// `bindKey` is `keyof SearchFilters` (values `string | string[]`); this
// widget only binds to the `string[]` filters.
const selectedCodes = searchFilters[bindKey] as string[];
const currentValue: CountryOption[] = options.filter((opt) => selectedCodes.includes(opt.code));
// When suppliers are selected and this is the country filter, grey out
// countries none of the selected suppliers reside in — they could never match.
const suppliers = selectedSuppliers ?? [];
const offeredCountries =
bindKey === 'country' && suppliers.length > 0
? new Set<string>(countriesForSuppliers(shippingMeta, suppliers))
: undefined;
const handleChange = (_event: SyntheticEvent, newValue: CountryOption[]) => {
setSearchFilters({
...searchFilters,
[bindKey]: newValue.map((opt) => opt.code),
});
};
return (
<Accordion expanded={isExpanded} onChange={onAccordionChange(panelId)}>
{summary(selectedCodes.length > 0 ? ` (${selectedCodes.length} selected)` : undefined)}
<StyledAccordionDetails>
<Autocomplete
multiple
size="small"
disableCloseOnSelect
options={[...options]}
getOptionLabel={(option) => option.label}
filterOptions={(opts, { inputValue }) => {
const term = inputValue.toLowerCase();
return opts.filter(
(opt) =>
!selectedCodes.includes(opt.code) &&
(opt.label.toLowerCase().includes(term) || opt.code.toLowerCase().includes(term)),
);
}}
value={currentValue}
onChange={handleChange}
isOptionEqualToValue={(option, value) => option.code === value.code}
getOptionDisabled={(option) =>
offeredCountries !== undefined && !offeredCountries.has(option.code)
}
renderInput={(params) => (
<TextField
{...params}
label={i18n('filter_by_label', [config.label.toLowerCase()])}
placeholder={placeholder}
helperText={selectedCodes.length === 0 ? emptyHelperText : undefined}
slotProps={{ formHelperText: { sx: { fontStyle: 'italic' } } }}
/>
)}
/>
</StyledAccordionDetails>
</Accordion>
);
}
// chips — e.g. Shipping Type (chip toggle for a fixed string list).
if (config.widget === 'chips') {
if (config.bind.kind !== 'searchFilters') return null;
const bindKey = config.bind.key;
const { options, formatChipLabel } = config;
// `bindKey` is `keyof SearchFilters` (values `string | string[]`); this
// widget only binds to the `string[]` filters.
const selected = searchFilters[bindKey] as string[];
// Only the shipping-type filter is constrained by supplier selection
// (availability is independent of suppliers): once suppliers are selected,
// grey out shipping scopes none of them can fulfill (respecting the hierarchy,
// so a domestic supplier still enables "local") — but never a currently-selected
// chip, so the user can always toggle it back off.
const suppliers = selectedSuppliers ?? [];
const fulfillable =
bindKey === 'shippingType' && suppliers.length > 0
? new Set<string>(fulfillableShippingRanges(shippingMeta, suppliers))
: undefined;
const isOptionDisabled = (option: string) =>
fulfillable !== undefined && !fulfillable.has(option) && !selected.includes(option);
const toggle = (value: string) => {
const next = selected.includes(value)
? selected.filter((item) => item !== value)
: [...selected, value];
setSearchFilters({ ...searchFilters, [bindKey]: next });
};
return (
<Accordion expanded={isExpanded} onChange={onAccordionChange(panelId)}>
{summary(selected.length > 0 ? ` (${selected.length} selected)` : undefined)}
<StyledAccordionDetails>
<Box className={styles['chip-container']}>
{options.map((option) => (
<Chip
key={option}
label={formatChipLabel ? formatChipLabel(option) : option}
size="small"
disabled={isOptionDisabled(option)}
onClick={() => toggle(option)}
color={selected.includes(option) ? 'primary' : 'default'}
variant={selected.includes(option) ? 'filled' : 'outlined'}
/>
))}
</Box>
</StyledAccordionDetails>
</Accordion>
);
}
// numberRange — e.g. Price Range (two numeric inputs with optional adornment).
if (config.widget === 'numberRange') {
if (config.bind.kind !== 'userSettingsRange') return null;
const { minKey, maxKey } = config.bind;
// `minKey`/`maxKey` are `keyof UserSettings` (a heterogeneous interface);
// this widget only binds them to the numeric range settings.
const minValue = userSettings[minKey] as number | undefined;
const maxValue = userSettings[maxKey] as number | undefined;
// Resolve the `"currency"` sentinel at render time so the symbol follows
// the user's current currency setting (USD → "$", EUR → "€", etc.).
const adornment =
config.adornment === 'currency'
? userSettings.currency
? CURRENCY_SYMBOL_MAP[userSettings.currency]
: undefined
: config.adornment;
const hint =
minValue != null || maxValue != null
? ` (${
minValue != null && maxValue != null
? `${adornment ?? ''}${minValue} - ${adornment ?? ''}${maxValue}`
: minValue != null
? i18n('results_column_filter_number_min', [adornment ?? '' + minValue])
: i18n('results_column_filter_number_max', [adornment ?? '' + maxValue])
})`
: undefined;
const handleNumberChange = (key: keyof UserSettings) => (e: ChangeEvent<HTMLInputElement>) => {
// Blank or malformed input clears the bound setting rather than storing NaN.
setUserSettings({ ...userSettings, [key]: toFiniteNumber(e.target.value) });
};
return (
<Accordion expanded={isExpanded} onChange={onAccordionChange(panelId)}>
{summary(hint)}
<StyledAccordionDetails>
<Box sx={{ display: 'flex', gap: 2 }}>
<TextField
label={i18n('drawer_range_min')}
type="number"
size="small"
value={minValue ?? ''}
onChange={handleNumberChange(minKey)}
slotProps={{
input: adornment
? {
startAdornment: <InputAdornment position="start">{adornment}</InputAdornment>,
}
: undefined,
htmlInput: { min: 0 },
}}
/>
<TextField
label={i18n('drawer_range_max')}
type="number"
size="small"
value={maxValue ?? ''}
onChange={handleNumberChange(maxKey)}
slotProps={{
input: adornment
? {
startAdornment: <InputAdornment position="start">{adornment}</InputAdornment>,
}
: undefined,
htmlInput: { min: 0 },
}}
/>
</Box>
</StyledAccordionDetails>
</Accordion>
);
}
return null;
}
Renders one drawer accordion section for a column that declared
meta.drawer. The widget (autocompleteStrings,autocompleteObjects,chips,numberRange) determines the input, andconfig.bindtells the component which slice of app state to read/write.Keeps columns free of context knowledge — columns describe what the user sees, this component wires it up to
selectedSuppliers,searchFilters, oruserSettingsviauseAppContext.