UNPKG

@furystack/shades-common-components

Version:

Common UI components for FuryStack Shades

621 lines 29 kB
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