@furystack/shades-common-components
Version:
Common UI components for FuryStack Shades
621 lines • 29 kB
JavaScript
import { Shade, createComponent } from '@furystack/shades';
import { cssVariableTheme } from '../../services/css-variable-theme.js';
import { ThemeProviderService } from '../../services/theme-provider-service.js';
import { FormContextToken } from '../form.js';
import { check, close } from '../icons/icon-definitions.js';
import { Icon } from '../icons/icon.js';
/** Flattens optionGroups + options into a single flat list */
const getAllOptions = (props) => {
const flatOptions = props.options || [];
const groupedOptions = (props.optionGroups || []).flatMap((g) => g.options);
return [...flatOptions, ...groupedOptions];
};
const defaultFilterOption = (searchText, option) => {
return option.label.toLowerCase().includes(searchText.toLowerCase());
};
export const Select = Shade({
customElementName: 'shade-select',
css: {
display: 'block',
fontFamily: cssVariableTheme.typography.fontFamily,
position: 'relative',
marginBottom: '1.25em',
'& label': {
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start',
justifyContent: 'space-between',
fontSize: cssVariableTheme.typography.fontSize.xs,
fontWeight: cssVariableTheme.typography.fontWeight.medium,
letterSpacing: '0.01em',
padding: '12px 14px',
borderRadius: cssVariableTheme.shape.borderRadius.md,
transition: `all ${cssVariableTheme.transitions.duration.normal} ${cssVariableTheme.transitions.easing.default}`,
cursor: 'pointer',
color: cssVariableTheme.text.secondary,
background: 'transparent',
border: '2px solid transparent',
boxShadow: 'none',
},
'&[data-variant="outlined"] label': {
borderColor: cssVariableTheme.action.subtleBorder,
},
'&[data-variant="contained"] label': {
borderColor: cssVariableTheme.action.subtleBorder,
background: 'color-mix(in srgb, var(--select-primary-color) 8%, transparent)',
},
'&[data-focused] label': {
color: 'var(--select-primary-color)',
},
'&[data-variant="outlined"][data-focused] label, &[data-variant="contained"][data-focused] label': {
borderColor: 'var(--select-primary-color)',
boxShadow: cssVariableTheme.action.focusRing,
},
'&[data-variant="contained"][data-focused] label': {
background: 'color-mix(in srgb, var(--select-primary-color) 12%, transparent)',
},
'&[data-invalid] label': {
color: 'var(--select-error-color)',
},
'&[data-invalid][data-variant="outlined"] label, &[data-invalid][data-variant="contained"] label': {
borderColor: 'var(--select-error-color)',
},
'&[data-invalid][data-variant="contained"] label': {
background: 'color-mix(in srgb, var(--select-error-color) 8%, transparent)',
},
'&[data-invalid][data-focused] label': {
color: 'var(--select-error-color)',
},
'&[data-invalid][data-variant="outlined"][data-focused] label, &[data-invalid][data-variant="contained"][data-focused] label': {
borderColor: 'var(--select-error-color)',
boxShadow: cssVariableTheme.action.focusRing,
},
'&[data-invalid][data-variant="contained"][data-focused] label': {
background: 'color-mix(in srgb, var(--select-error-color) 12%, transparent)',
},
'&[data-disabled] label': {
color: cssVariableTheme.text.disabled,
filter: 'grayscale(100%)',
opacity: cssVariableTheme.action.disabledOpacity,
cursor: 'not-allowed',
},
'&[data-disabled][data-focused] label': {
boxShadow: 'none',
},
'& .select-trigger': {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
width: '100%',
marginTop: cssVariableTheme.spacing.sm,
marginBottom: '2px',
cursor: 'inherit',
},
'& .select-value': {
fontSize: cssVariableTheme.typography.fontSize.sm,
fontWeight: cssVariableTheme.typography.fontWeight.normal,
lineHeight: '1.5',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
flexGrow: '1',
},
'& .select-value[data-placeholder]': {
opacity: '0.5',
},
'& .select-multi-values': {
display: 'flex',
flexWrap: 'wrap',
gap: cssVariableTheme.spacing.xs,
flexGrow: '1',
minHeight: '24px',
alignItems: 'center',
},
'& .select-chip': {
display: 'inline-flex',
alignItems: 'center',
gap: cssVariableTheme.spacing.xs,
padding: '2px 8px',
borderRadius: cssVariableTheme.shape.borderRadius.lg,
background: 'color-mix(in srgb, var(--select-primary-color) 15%, transparent)',
color: 'var(--select-primary-color)',
fontSize: cssVariableTheme.typography.fontSize.xs,
fontWeight: cssVariableTheme.typography.fontWeight.medium,
lineHeight: '1.5',
maxWidth: '150px',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
},
'& .select-chip-remove': {
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
border: 'none',
background: 'transparent',
color: 'inherit',
padding: '0',
fontSize: '12px',
lineHeight: '1',
opacity: '0.7',
flexShrink: '0',
},
'& .select-chip-remove:hover': {
opacity: '1',
},
'& .select-arrow': {
display: 'flex',
alignItems: 'center',
marginLeft: cssVariableTheme.spacing.sm,
transition: `transform ${cssVariableTheme.transitions.duration.fast} ${cssVariableTheme.transitions.easing.default}`,
fontSize: '10px',
opacity: cssVariableTheme.action.disabledOpacity,
flexShrink: '0',
},
'&[data-open] .select-arrow': {
transform: 'rotate(180deg)',
},
'& .dropdown-backdrop': {
position: 'fixed',
top: '0',
left: '0',
width: '100vw',
height: '100vh',
zIndex: cssVariableTheme.zIndex.dropdown,
},
'& .dropdown': {
position: 'absolute',
left: '0',
right: '0',
zIndex: cssVariableTheme.zIndex.dropdown,
maxHeight: '240px',
overflowY: 'auto',
background: cssVariableTheme.background.paper,
border: `1px solid ${cssVariableTheme.action.subtleBorder}`,
borderRadius: cssVariableTheme.shape.borderRadius.md,
boxShadow: cssVariableTheme.shadows.lg,
marginTop: cssVariableTheme.spacing.xs,
padding: `${cssVariableTheme.spacing.xs} 0`,
listStyle: 'none',
},
'& .dropdown[data-direction="up"]': {
bottom: '100%',
marginTop: '0',
marginBottom: cssVariableTheme.spacing.xs,
},
'& .dropdown-search': {
display: 'block',
width: '100%',
padding: '8px 14px',
border: 'none',
borderBottom: `1px solid ${cssVariableTheme.action.subtleBorder}`,
background: 'transparent',
color: cssVariableTheme.text.primary,
fontSize: cssVariableTheme.typography.fontSize.sm,
fontFamily: cssVariableTheme.typography.fontFamily,
outline: 'none',
boxSizing: 'border-box',
},
'& .dropdown-search::placeholder': {
color: cssVariableTheme.text.disabled,
},
'& .dropdown-group-label': {
padding: '8px 14px 4px',
fontSize: cssVariableTheme.typography.fontSize.xs,
fontWeight: cssVariableTheme.typography.fontWeight.semibold,
color: cssVariableTheme.text.secondary,
textTransform: 'uppercase',
letterSpacing: '0.05em',
userSelect: 'none',
},
'& .dropdown-item': {
display: 'flex',
alignItems: 'center',
padding: '8px 14px',
fontSize: cssVariableTheme.typography.fontSize.sm,
cursor: 'pointer',
color: cssVariableTheme.text.primary,
transition: `background ${cssVariableTheme.transitions.duration.fast} ${cssVariableTheme.transitions.easing.default}`,
},
'& .dropdown-item:hover, & .dropdown-item[data-highlighted]': {
background: cssVariableTheme.action.hoverBackground,
},
'& .dropdown-item[data-selected]': {
color: 'var(--select-primary-color)',
fontWeight: cssVariableTheme.typography.fontWeight.medium,
},
'& .dropdown-item[data-disabled]': {
opacity: cssVariableTheme.action.disabledOpacity,
cursor: 'not-allowed',
},
'& .dropdown-item .check-icon': {
marginRight: cssVariableTheme.spacing.sm,
width: '16px',
fontSize: '12px',
flexShrink: '0',
},
'& .dropdown-no-results': {
padding: '8px 14px',
fontSize: cssVariableTheme.typography.fontSize.sm,
color: cssVariableTheme.text.disabled,
textAlign: 'center',
},
'& .helperText': {
fontSize: cssVariableTheme.typography.fontSize.xs,
marginTop: '6px',
opacity: '0.85',
lineHeight: '1.4',
},
// Size: small
'&[data-size="small"] label': {
padding: `${cssVariableTheme.spacing.xs} ${cssVariableTheme.spacing.sm}`,
},
'&[data-size="small"] .select-value': {
fontSize: cssVariableTheme.typography.fontSize.xs,
},
'&[data-size="small"] .dropdown-item': {
padding: `6px ${cssVariableTheme.spacing.sm}`,
fontSize: cssVariableTheme.typography.fontSize.xs,
},
// Size: large
'&[data-size="large"] label': {
padding: `${cssVariableTheme.spacing.md} ${cssVariableTheme.spacing.lg}`,
fontSize: cssVariableTheme.typography.fontSize.sm,
},
'&[data-size="large"] .select-value': {
fontSize: cssVariableTheme.typography.fontSize.md,
},
'&[data-size="large"] .dropdown-item': {
padding: `${cssVariableTheme.spacing.sm} ${cssVariableTheme.spacing.lg}`,
fontSize: cssVariableTheme.typography.fontSize.md,
},
},
render: ({ props, injector, useState, useDisposable, useHostProps, useRef }) => {
const selectRootRef = useRef('selectRoot');
const hiddenInputRef = useRef('hiddenInput');
const searchInputRef = useRef('searchInput');
const dropdownRef = useRef('dropdown');
useDisposable('form-registration', () => {
const formService = injector.get(FormContextToken);
if (formService) {
queueMicrotask(() => {
if (hiddenInputRef.current)
formService.inputs.add(hiddenInputRef.current);
});
}
return {
[Symbol.dispose]: () => {
if (hiddenInputRef.current && formService)
formService.inputs.delete(hiddenInputRef.current);
},
};
});
const themeProvider = injector.get(ThemeProviderService);
const isMultiple = props.mode === 'multiple';
const allOptions = getAllOptions(props);
const normalizeValue = (v) => {
if (isMultiple) {
if (Array.isArray(v))
return v;
if (typeof v === 'string' && v)
return [v];
return [];
}
if (Array.isArray(v))
return v[0] || '';
return v || '';
};
const initialState = {
value: normalizeValue(props.value),
isOpen: false,
highlightedIndex: -1,
searchText: '',
};
const [state, setState] = useState('selectState', initialState);
// We want to use the CSS state hooks for the focused and dropdown direction states, so we need to disable the rule
// eslint-disable-next-line furystack/no-css-state-hooks
const [isFocused, setIsFocused] = useState('isFocused', false);
const [dropdownDirection, setDropdownDirection] = useState('dropdownDirection', 'down');
const validationResult = props.getValidationResult?.({ state });
const primaryColor = themeProvider.theme.palette[props.defaultColor || 'primary'].main;
useHostProps({
'data-variant': props.variant || undefined,
'data-size': props.size && props.size !== 'medium' ? props.size : undefined,
'data-disabled': props.disabled ? '' : undefined,
'data-multiple': isMultiple ? '' : undefined,
'data-open': state.isOpen && !props.disabled ? '' : undefined,
'data-focused': (state.isOpen || isFocused) && !props.disabled ? '' : undefined,
'data-invalid': validationResult?.isValid === false ? '' : undefined,
style: {
'--select-primary-color': primaryColor,
'--select-error-color': themeProvider.theme.palette.error.main,
},
});
const formServiceForValidity = injector.get(FormContextToken);
if (formServiceForValidity) {
formServiceForValidity.setFieldState(props.name, validationResult || { isValid: true }, {});
}
const filterFn = props.filterOption || defaultFilterOption;
const getFilteredOptions = (opts) => {
if (!props.showSearch || !state.searchText)
return opts;
return opts.filter((o) => filterFn(state.searchText, o));
};
const filteredAllOptions = getFilteredOptions(allOptions);
const getSelectedLabel = (value) => {
const option = allOptions.find((o) => o.value === value);
return option?.label;
};
const closeDropdown = () => {
setState({ ...state, isOpen: false, highlightedIndex: -1, searchText: '' });
};
const openDropdown = () => {
if (props.disabled)
return;
const firstIndex = isMultiple ? 0 : filteredAllOptions.findIndex((o) => o.value === state.value);
setState({ ...state, isOpen: true, highlightedIndex: Math.max(firstIndex, 0), searchText: '' });
};
// The Shades render cycle is microtask-batched, so the hidden input's
// declarative `value` prop has not been applied yet when we react to a
// user-driven mutation. Set the value imperatively and synthesise a
// bubbling `change` event so a surrounding `<Form>` observes the update
// (native `<input type="hidden">` never fires `change` on its own).
const notifyFormChange = (newValue) => {
const input = hiddenInputRef.current;
if (!input)
return;
input.value = newValue;
input.dispatchEvent(new Event('change', { bubbles: true }));
};
const selectOption = (option) => {
if (option.disabled)
return;
if (isMultiple) {
const currentValues = state.value;
const isSelected = currentValues.includes(option.value);
const newValues = isSelected
? currentValues.filter((v) => v !== option.value)
: [...currentValues, option.value];
setState({
...state,
value: newValues,
highlightedIndex: -1,
});
props.onMultiValueChange?.(newValues);
props.onValueChange?.(newValues.join(','));
notifyFormChange(newValues.join(','));
}
else {
setState({
...state,
value: option.value,
isOpen: false,
highlightedIndex: -1,
searchText: '',
});
props.onValueChange?.(option.value);
notifyFormChange(option.value);
}
};
const removeChip = (value) => {
if (props.disabled || !isMultiple)
return;
const currentValues = state.value;
const newValues = currentValues.filter((v) => v !== value);
setState({ ...state, value: newValues });
props.onMultiValueChange?.(newValues);
props.onValueChange?.(newValues.join(','));
notifyFormChange(newValues.join(','));
};
const getEnabledFilteredOptions = () => filteredAllOptions.filter((o) => !o.disabled);
const handleKeyDown = (ev) => {
if (props.disabled)
return;
if (!state.isOpen) {
if (ev.key === 'Enter' || ev.key === ' ' || ev.key === 'ArrowDown' || ev.key === 'ArrowUp') {
ev.preventDefault();
openDropdown();
return;
}
}
const enabledOptions = getEnabledFilteredOptions();
switch (ev.key) {
case 'Escape':
ev.preventDefault();
closeDropdown();
break;
case 'ArrowDown': {
ev.preventDefault();
const nextEnabled = enabledOptions.findIndex((o) => filteredAllOptions.indexOf(o) > state.highlightedIndex);
if (nextEnabled !== -1) {
setState({ ...state, highlightedIndex: filteredAllOptions.indexOf(enabledOptions[nextEnabled]) });
}
break;
}
case 'ArrowUp': {
ev.preventDefault();
const prevEnabled = [...enabledOptions]
.reverse()
.find((o) => filteredAllOptions.indexOf(o) < state.highlightedIndex);
if (prevEnabled) {
setState({ ...state, highlightedIndex: filteredAllOptions.indexOf(prevEnabled) });
}
break;
}
case 'Enter':
ev.preventDefault();
if (state.highlightedIndex >= 0 && state.highlightedIndex < filteredAllOptions.length) {
selectOption(filteredAllOptions[state.highlightedIndex]);
}
break;
case ' ':
if (props.showSearch)
break;
ev.preventDefault();
if (state.highlightedIndex >= 0 && state.highlightedIndex < filteredAllOptions.length) {
selectOption(filteredAllOptions[state.highlightedIndex]);
}
break;
case 'Home': {
ev.preventDefault();
if (enabledOptions.length > 0) {
setState({ ...state, highlightedIndex: filteredAllOptions.indexOf(enabledOptions[0]) });
}
break;
}
case 'End': {
ev.preventDefault();
if (enabledOptions.length > 0) {
setState({
...state,
highlightedIndex: filteredAllOptions.indexOf(enabledOptions[enabledOptions.length - 1]),
});
}
break;
}
case 'Backspace': {
if (isMultiple && props.showSearch && !state.searchText) {
const currentValues = state.value;
if (currentValues.length > 0) {
const newValues = currentValues.slice(0, -1);
setState({ ...state, value: newValues });
props.onMultiValueChange?.(newValues);
props.onValueChange?.(newValues.join(','));
notifyFormChange(newValues.join(','));
}
}
break;
}
default:
break;
}
};
const handleSearchInput = (ev) => {
const target = ev.target;
setState({ ...state, searchText: target.value, highlightedIndex: 0 });
};
// After re-render: restore search focus + compute dropdown direction
if (state.isOpen) {
queueMicrotask(() => {
if (props.showSearch) {
if (searchInputRef.current && document.activeElement !== searchInputRef.current) {
searchInputRef.current.focus();
}
}
const rect = selectRootRef.current?.getBoundingClientRect();
const dropdownHeight = dropdownRef.current?.scrollHeight ?? 0;
if (rect && dropdownHeight) {
const spaceBelow = window.innerHeight - rect.bottom;
const spaceAbove = rect.top;
const newDirection = spaceBelow < dropdownHeight && spaceAbove > spaceBelow ? 'up' : 'down';
if (newDirection !== dropdownDirection) {
setDropdownDirection(newDirection);
}
}
});
}
const hiddenValue = isMultiple ? state.value.join(',') : state.value;
const helperText = (validationResult?.isValid === false && validationResult?.message) ||
props.getHelperText?.({ state, validationResult }) ||
'';
const renderSelectedValues = () => {
if (isMultiple) {
const selectedValues = state.value;
if (selectedValues.length === 0) {
return (createComponent("span", { className: "select-value", "data-placeholder": "" }, props.placeholder || ''));
}
return (createComponent("div", { className: "select-multi-values" }, selectedValues.map((val) => {
const label = getSelectedLabel(val) || val;
return (createComponent("span", { className: "select-chip" },
label,
!props.disabled ? (createComponent("span", { className: "select-chip-remove", role: "button", onclick: (ev) => {
ev.stopPropagation();
removeChip(val);
} },
createComponent(Icon, { icon: close, size: 12 }))) : null));
})));
}
const selectedLabel = getSelectedLabel(state.value);
return (createComponent("span", { className: "select-value", ...(selectedLabel ? {} : { 'data-placeholder': '' }) }, selectedLabel || props.placeholder || ''));
};
const renderOptionItem = (option, flatIndex) => {
const isSelected = isMultiple ? state.value.includes(option.value) : option.value === state.value;
return (createComponent("li", { className: "dropdown-item", role: "option", "aria-selected": isSelected ? 'true' : 'false', ...(isSelected ? { 'data-selected': '' } : {}), ...(flatIndex === state.highlightedIndex ? { 'data-highlighted': '' } : {}), ...(option.disabled ? { 'data-disabled': '' } : {}), onclick: (ev) => {
ev.stopPropagation();
selectOption(option);
} },
isMultiple ? createComponent("span", { className: "check-icon" }, isSelected ? createComponent(Icon, { icon: check, size: 14 }) : '') : null,
option.label));
};
const renderDropdownContent = () => {
const hasGroups = props.optionGroups && props.optionGroups.length > 0;
if (hasGroups) {
let flatIndex = 0;
const flatFiltered = props.options ? getFilteredOptions(props.options) : [];
const groupsContent = [];
// Render ungrouped options first
if (flatFiltered.length > 0) {
for (const option of flatFiltered) {
groupsContent.push(renderOptionItem(option, flatIndex));
flatIndex++;
}
}
// Render grouped options
for (const group of props.optionGroups) {
const groupFiltered = getFilteredOptions(group.options);
if (groupFiltered.length === 0)
continue;
groupsContent.push(createComponent("li", { className: "dropdown-group-label", role: "presentation" }, group.label));
for (const option of groupFiltered) {
groupsContent.push(renderOptionItem(option, flatIndex));
flatIndex++;
}
}
if (groupsContent.length === 0) {
return createComponent("li", { className: "dropdown-no-results" }, "No results found");
}
return createComponent(createComponent, null, groupsContent);
}
// Flat options
if (filteredAllOptions.length === 0) {
return createComponent("li", { className: "dropdown-no-results" }, "No results found");
}
return createComponent(createComponent, null, filteredAllOptions.map((option, index) => renderOptionItem(option, index)));
};
return (createComponent("div", { ref: selectRootRef },
createComponent("input", { ref: hiddenInputRef, type: "hidden", name: props.name, value: hiddenValue, required: props.required }),
createComponent("label", { ...props.labelProps },
props.labelTitle,
createComponent("div", { className: "select-trigger", role: "combobox", "aria-expanded": state.isOpen ? 'true' : 'false', "aria-haspopup": "listbox", tabIndex: props.disabled ? -1 : 0, onclick: (ev) => {
ev.stopPropagation();
ev.preventDefault();
if (props.disabled)
return;
if (state.isOpen && !isMultiple) {
closeDropdown();
}
else if (!state.isOpen) {
openDropdown();
}
}, onkeydown: handleKeyDown, onfocus: () => {
if (!props.disabled)
setIsFocused(true);
}, onblur: () => {
setIsFocused(false);
} },
renderSelectedValues(),
createComponent("span", { className: "select-arrow" }, "\u25BC")),
createComponent("span", { className: "helperText" }, helperText)),
state.isOpen ? (createComponent(createComponent, null,
createComponent("div", { className: "dropdown-backdrop", onclick: (ev) => {
ev.stopPropagation();
closeDropdown();
} }),
createComponent("ul", { ref: dropdownRef, className: "dropdown", role: "listbox", "aria-multiselectable": isMultiple ? 'true' : undefined, "data-direction": dropdownDirection === 'up' ? 'up' : undefined },
props.showSearch ? (createComponent("li", { role: "presentation" },
createComponent("input", { ref: searchInputRef, className: "dropdown-search", type: "text", placeholder: "Search...", value: state.searchText, oninput: handleSearchInput, onkeydown: handleKeyDown, onclick: (ev) => ev.stopPropagation() }))) : null,
renderDropdownContent()))) : null));
},
});
//# sourceMappingURL=select.js.map