From da21b5d456ad7893cbf1ad923975c807cc6f15f8 Mon Sep 17 00:00:00 2001 From: DasProffi <67233923+DasProffi@users.noreply.github.com> Date: Thu, 19 Feb 2026 23:00:56 +0100 Subject: [PATCH 01/13] feat: add search to select --- CHANGELOG.md | 5 + locales/de-DE.arb | 2 + locales/en-US.arb | 2 + .../layout/dialog/premade/LanguageDialog.tsx | 4 +- .../layout/dialog/premade/ThemeDialog.tsx | 7 +- .../layout/table/TableFilterPopups.tsx | 68 +++++-- .../layout/table/TablePagination.tsx | 4 +- src/components/layout/table/types.ts | 2 +- .../user-interaction/date/TimePicker.tsx | 6 +- .../user-interaction/input/DateTimeInput.tsx | 2 +- .../user-interaction/select/MultiSelect.tsx | 8 +- .../select/MultiSelectChipDisplay.tsx | 6 +- .../select/SelectComponents.tsx | 163 ++++++++++------- .../user-interaction/select/SelectContext.tsx | 169 +++++++++++------- .../Select/MultiSelect.stories.tsx | 31 ++-- .../Select/MultiSelectChipDisplay.stories.tsx | 31 ++-- .../Select/Select.stories.tsx | 33 ++-- 17 files changed, 343 insertions(+), 200 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a64014b..533e3a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,11 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.9.0] - 2026-02-19 + +## Fixed +- imports in `TimePicker` and `DateTimeInput` + ## [0.8.12] - 2026-02-15 ### Fixed diff --git a/locales/de-DE.arb b/locales/de-DE.arb index 7041e18..bd152fb 100644 --- a/locales/de-DE.arb +++ b/locales/de-DE.arb @@ -34,6 +34,7 @@ "error": "Fehler", "errorOccurred": "Ein Fehler ist aufgetreten", "exit": "Beenden", + "filterOptions": "Optionen filtern", "fieldRequiredError": "Dieses Feld ist erforderlich.", "first": "Erste", "previous": "Vorherige", @@ -61,6 +62,7 @@ "save": "Speichern", "saved": "Gespeichert", "search": "Suche", + "searchResults": "Suchergebnisse", "select": "Select", "selection": "Auswahl", "selectOption": "Option auswählen", diff --git a/locales/en-US.arb b/locales/en-US.arb index bcfbc0a..1fb1c76 100644 --- a/locales/en-US.arb +++ b/locales/en-US.arb @@ -35,6 +35,7 @@ "error": "Error", "errorOccurred": "An error occurred", "exit": "Exit", + "filterOptions": "Filter options", "fieldRequiredError": "This field is required.", "first": "First", "previous": "Previous", @@ -62,6 +63,7 @@ "save": "Save", "saved": "Saved", "search": "Search", + "searchResults": "Search results", "select": "Select", "selection": "Selection", "selectOption": "Select an option", diff --git a/src/components/layout/dialog/premade/LanguageDialog.tsx b/src/components/layout/dialog/premade/LanguageDialog.tsx index 45d7f67..9e1d7df 100644 --- a/src/components/layout/dialog/premade/LanguageDialog.tsx +++ b/src/components/layout/dialog/premade/LanguageDialog.tsx @@ -30,7 +30,9 @@ export const LanguageSelect = ({ ...props }: LanguageSelectProps) => { }} > {LocalizationUtil.locals.map((local) => ( - {LocalizationUtil.languagesLocalNames[local]} + + {LocalizationUtil.languagesLocalNames[local]} + ))} ) diff --git a/src/components/layout/dialog/premade/ThemeDialog.tsx b/src/components/layout/dialog/premade/ThemeDialog.tsx index 688e297..879d846 100644 --- a/src/components/layout/dialog/premade/ThemeDialog.tsx +++ b/src/components/layout/dialog/premade/ThemeDialog.tsx @@ -51,7 +51,12 @@ export const ThemeSelect = ({ ...props }: ThemeSelectProps) => { }} > {ThemeUtil.themes.map((theme) => ( - +
{translation('sThemeMode', { theme: theme })} diff --git a/src/components/layout/table/TableFilterPopups.tsx b/src/components/layout/table/TableFilterPopups.tsx index a66842e..251cdb1 100644 --- a/src/components/layout/table/TableFilterPopups.tsx +++ b/src/components/layout/table/TableFilterPopups.tsx @@ -232,7 +232,12 @@ export const TextFilter = ({ filterValue, onFilterValueChange }: TextFilterProps buttonProps={{ className: 'min-w-64' }} > {availableOperators.map((op) => ( - + ))} @@ -301,7 +306,12 @@ export const NumberFilter = ({ filterValue, onFilterValueChange }: NumberFilterP buttonProps={{ className: 'min-w-64' }} > {availableOperators.map((op) => ( - + ))} @@ -411,7 +421,12 @@ export const DateFilter = ({ filterValue, onFilterValueChange }: DateFilterProps buttonProps={{ className: 'min-w-64' }} > {availableOperators.map((op) => ( - + ))} @@ -543,7 +558,12 @@ export const DatetimeFilter = ({ filterValue, onFilterValueChange }: DatetimeFil buttonProps={{ className: 'min-w-64' }} > {availableOperators.map((op) => ( - + ))} @@ -643,6 +663,7 @@ export const DatetimeFilter = ({ filterValue, onFilterValueChange }: DatetimeFil export type BooleanFilterProps = TableFilterBaseProps export const BooleanFilter = ({ filterValue, onFilterValueChange }: BooleanFilterProps) => { + const translation = useHightideTranslation() const operator = filterValue?.operator ?? 'booleanIsTrue' const availableOperators = useMemo(() => [ @@ -664,7 +685,12 @@ export const BooleanFilter = ({ filterValue, onFilterValueChange }: BooleanFilte buttonProps={{ className: 'min-w-64' }} > {availableOperators.map((op) => ( - + ))} @@ -711,7 +737,12 @@ export const TagsFilter = ({ columnId, filterValue, onFilterValueChange }: TagsF buttonProps={{ className: 'min-w-64' }} > {availableOperators.map((op) => ( - + ))} @@ -729,7 +760,7 @@ export const TagsFilter = ({ columnId, filterValue, onFilterValueChange }: TagsF buttonProps={{ className: 'min-w-64' }} > {availableTags.map(({ tag, label }) => ( - + {label} ))} @@ -782,7 +813,12 @@ export const TagsSingleFilter = ({ columnId, filterValue, onFilterValueChange }: buttonProps={{ className: 'min-w-64' }} > {availableOperators.map((op) => ( - + ))} @@ -800,9 +836,7 @@ export const TagsSingleFilter = ({ columnId, filterValue, onFilterValueChange }: buttonProps={{ className: 'min-w-64' }} > {availableTags.map(({ tag, label }) => ( - - {label} - + ))} @@ -818,9 +852,7 @@ export const TagsSingleFilter = ({ columnId, filterValue, onFilterValueChange }: buttonProps={{ className: 'min-w-64' }} > {availableTags.map(({ tag, label }) => ( - - {label} - + ))} @@ -836,6 +868,7 @@ export const TagsSingleFilter = ({ columnId, filterValue, onFilterValueChange }: export type GenericFilterProps = TableFilterBaseProps export const GenericFilter = ({ filterValue, onFilterValueChange }: GenericFilterProps) => { + const translation = useHightideTranslation() const operator = filterValue?.operator ?? 'notUndefined' const availableOperators = useMemo(() => [ @@ -855,7 +888,12 @@ export const GenericFilter = ({ filterValue, onFilterValueChange }: GenericFilte buttonProps={{ className: 'min-w-64' }} > {availableOperators.map((op) => ( - + ))} diff --git a/src/components/layout/table/TablePagination.tsx b/src/components/layout/table/TablePagination.tsx index 3369b1d..1fe17b5 100644 --- a/src/components/layout/table/TablePagination.tsx +++ b/src/components/layout/table/TablePagination.tsx @@ -45,9 +45,7 @@ export const TablePageSizeSelect = ({ onValueChange={(value) => table.setPageSize(Number(value))} > {pageSizeOptions.map(size => ( - - {size} - + ))} ) diff --git a/src/components/layout/table/types.ts b/src/components/layout/table/types.ts index 9061d91..75ab334 100644 --- a/src/components/layout/table/types.ts +++ b/src/components/layout/table/types.ts @@ -6,7 +6,7 @@ declare module '@tanstack/react-table' { interface ColumnMeta { className?: string, filterData?: { - tags?: { tag: string, label: ReactNode }[], + tags?: { tag: string, label: string, display?: ReactNode }[], }, columnLabel?: string, } diff --git a/src/components/user-interaction/date/TimePicker.tsx b/src/components/user-interaction/date/TimePicker.tsx index da87e41..2253f5c 100644 --- a/src/components/user-interaction/date/TimePicker.tsx +++ b/src/components/user-interaction/date/TimePicker.tsx @@ -1,10 +1,10 @@ import { useEffect, useMemo, useRef } from 'react' import { closestMatch, range } from '@/src/utils/array' import { Button } from '@/src/components/user-interaction/Button' -import type { FormFieldDataHandling } from '../../form/FormField' +import type { FormFieldDataHandling } from '@/src/components/form/FormField' import { useControlledState } from '@/src/hooks/useControlledState' -import type { DateTimePrecision } from '@/src/utils' -import { Visibility } from '../../layout' +import type { DateTimePrecision } from '@/src/utils/date' +import { Visibility } from '@/src/components/layout/Visibility' export type TimePickerMinuteIncrement = '1min' | '5min' | '10min' | '15min' | '30min' diff --git a/src/components/user-interaction/input/DateTimeInput.tsx b/src/components/user-interaction/input/DateTimeInput.tsx index 4d74a92..2280467 100644 --- a/src/components/user-interaction/input/DateTimeInput.tsx +++ b/src/components/user-interaction/input/DateTimeInput.tsx @@ -13,7 +13,7 @@ import type { FormFieldInteractionStates } from '@/src/components/form/FieldLayo import { PopUp } from '../../layout/popup/PopUp' import { IconButton } from '../IconButton' import { DateUtils, type DateTimeFormat } from '@/src/utils/date' -import { useDelay } from '@/src/hooks' +import { useDelay } from '@/src/hooks/useDelay' export interface DateTimeInputProps extends Partial, diff --git a/src/components/user-interaction/select/MultiSelect.tsx b/src/components/user-interaction/select/MultiSelect.tsx index 2082d95..8e0d8d5 100644 --- a/src/components/user-interaction/select/MultiSelect.tsx +++ b/src/components/user-interaction/select/MultiSelect.tsx @@ -7,10 +7,10 @@ import { forwardRef } from 'react' // // MultiSelect // -export type MultiSelectProps = MultiSelectRootProps & { - contentPanelProps?: MultiSelectContentProps, - buttonProps?: MultiSelectButtonProps, - } +export interface MultiSelectProps extends MultiSelectRootProps { + contentPanelProps?: MultiSelectContentProps, + buttonProps?: MultiSelectButtonProps, +} export const MultiSelect = forwardRef(function MultiSelect({ children, diff --git a/src/components/user-interaction/select/MultiSelectChipDisplay.tsx b/src/components/user-interaction/select/MultiSelectChipDisplay.tsx index c3a43dd..c2a9019 100644 --- a/src/components/user-interaction/select/MultiSelectChipDisplay.tsx +++ b/src/components/user-interaction/select/MultiSelectChipDisplay.tsx @@ -60,9 +60,9 @@ export const MultiSelectChipDisplayButton = forwardRef - {state.selectedOptions.map(({ value, label }) => ( + {state.selectedOptions.map(({ value, display }) => (
- {label} + {display} { @@ -98,7 +98,7 @@ export const MultiSelectChipDisplayButton = forwardRef(null) + +export function useSelectOptionDisplayLocation(): SelectOptionDisplayLocation { + const context = useContext(SelectOptionDisplayContext) + if (!context) { + throw new Error('useSelectOptionDisplayLocation must be used within a SelectOptionDisplayContext') + } + return context +} // // SelectOption // -export type SelectOptionProps = Omit, 'children'> & { - value: string, - disabled?: boolean, - iconAppearance?: SelectIconAppearance, - children?: ReactNode, - } +export interface SelectOptionProps extends Omit, 'children'> { + value: string, + label: string, + disabled?: boolean, + iconAppearance?: SelectIconAppearance, + children?: ReactNode, +} export const SelectOption = forwardRef( - function SelectOption({ children, value, disabled = false, iconAppearance, className, ...restProps }, ref) { + function SelectOption({ children, label, value, disabled = false, iconAppearance, className, ...restProps }, ref) { const { state, config, item, trigger } = useSelectContext() const { register, unregister, toggleSelection, highlightItem } = item const itemRef = useRef(null) iconAppearance ??= config.iconAppearance - const label = children ?? value + const display: ReactNode = children ?? label - // Register with parent useEffect(() => { register({ value, label, + display, disabled, ref: itemRef, }) return () => unregister(value) - }, [value, disabled, register, unregister, children, label]) + }, [value, label, disabled, register, unregister, display]) const isHighlighted = state.highlightedValue === value const isSelected = state.value.includes(value) + const isVisible = state.visibleOptions.some(opt => opt.value === value) return (
  • ( role="option" aria-disabled={disabled} aria-selected={isSelected} + aria-hidden={!isVisible} data-highlighted={isHighlighted ? '' : undefined} data-selected={isSelected ? '' : undefined} data-disabled={disabled ? '' : undefined} + data-visible={isVisible ? '' : undefined} className={clsx( 'flex-row-1 items-center px-2 py-1 rounded-md', 'data-highlighted:bg-primary/20', 'data-disabled:text-disabled data-disabled:cursor-not-allowed', 'not-data-disabled:cursor-pointer', + !isVisible && 'hidden', className )} onClick={(event) => { @@ -86,7 +104,9 @@ export const SelectOption = forwardRef( aria-hidden={true} /> )} - {label} + + {display} + {iconAppearance === 'right' && (state.value.length > 0 || config.isMultiSelect) && ( (fun aria-invalid={invalid} aria-disabled={disabled} - aria-haspopup="listbox" + aria-haspopup="dialog" aria-expanded={state.isOpen} aria-controls={state.isOpen ? ids.content : undefined} > {hasValue ? selectedDisplay?.(state.value) ?? (
    - {state.selectedOptions.map(({ value, label }, index) => ( + {state.selectedOptions.map(({ value, display }, index) => ( - {label} + + {display} + {index < state.value.length - 1 && ({','})} ))} @@ -198,20 +220,27 @@ export const SelectButton = forwardRef(fun /// /// SelectContent /// -export type SelectContentProps = PopUpProps +export type SelectContentProps = PopUpProps & { + showSearch?: boolean, + searchInputProps?: Omit, 'value' | 'onValueChange'>, +} export const SelectContent = forwardRef(function SelectContent({ id, options, + showSearch: showSearchOverride, + searchInputProps, ...props }, ref) { + const translation = useHightideTranslation() const innerRef = useRef(null) + const searchInputRef = useRef(null) useImperativeHandle(ref, () => innerRef.current) - const { trigger, state, config, item, ids, setIds } = useSelectContext() + const { trigger, state, config, item, ids, setIds, search } = useSelectContext() useEffect(() => { - if(id) { + if (id) { setIds(prev => ({ ...prev, content: id, @@ -219,6 +248,9 @@ export const SelectContent = forwardRef(fu } }, [id, setIds]) + const showSearch = showSearchOverride ?? search.showSearch + const listboxAriaLabel = showSearch ? translation('searchResults') : undefined + return ( (fu trigger.toggleOpen(false) props.onClose?.() }} - aria-labelledby={ids.trigger} > -
      { - switch (event.key) { - case 'ArrowDown': - item.moveHighlightedIndex(1) - event.preventDefault() - break - case 'ArrowUp': - item.moveHighlightedIndex(-1) - event.preventDefault() - break - case 'Home': - // TODO support later by selecting the first not disabled entry - event.preventDefault() - break - case 'End': - // TODO support later by selecting the last not disabled entry - event.preventDefault() - break - case 'Enter': // Fall through - case ' ': - if (state.highlightedValue) { - item.toggleSelection(state.highlightedValue) - if (!config.isMultiSelect) { - trigger.toggleOpen(false) - } +
      + {showSearch && ( + + )} +
        { + switch (event.key) { + case 'ArrowDown': + item.moveHighlightedIndex(1) + event.preventDefault() + break + case 'ArrowUp': + item.moveHighlightedIndex(-1) + event.preventDefault() + break + case 'Home': + event.preventDefault() + break + case 'End': event.preventDefault() + break + case 'Enter': + case ' ': + if (state.highlightedValue) { + item.toggleSelection(state.highlightedValue) + if (!config.isMultiSelect) { + trigger.toggleOpen(false) + } + event.preventDefault() + } + break } - break - } - }} - - className={clsx('flex-col-0 p-2 overflow-auto')} - - role="listbox" - aria-multiselectable={config.isMultiSelect} - aria-orientation="vertical" - tabIndex={0} - > - {props.children} -
      + }} + className={clsx('flex-col-0 p-2 overflow-auto')} + role="listbox" + aria-multiselectable={config.isMultiSelect} + aria-orientation="vertical" + aria-label={listboxAriaLabel} + tabIndex={0} + > + {props.children} +
    +
    ) }) diff --git a/src/components/user-interaction/select/SelectContext.tsx b/src/components/user-interaction/select/SelectContext.tsx index b8547a8..c89993e 100644 --- a/src/components/user-interaction/select/SelectContext.tsx +++ b/src/components/user-interaction/select/SelectContext.tsx @@ -1,70 +1,81 @@ -import type { Dispatch, PropsWithChildren, ReactNode, SetStateAction } from 'react' +import type { Dispatch, ReactNode, SetStateAction } from 'react' import { createContext, useCallback, useContext, useEffect, useId, useMemo, useRef, useState } from 'react' import type { FormFieldInteractionStates } from '../../form/FieldLayout' import type { FormFieldDataHandling } from '../../form/FormField' import { useControlledState } from '@/src/hooks/useControlledState' +import { MultiSearchWithMapping } from '@/src/utils/simpleSearch' // // Context // type RegisteredOption = { - value: string, - label: ReactNode, - disabled: boolean, - ref: React.RefObject, - } + value: string, + label: string, + display: ReactNode, + disabled: boolean, + ref: React.RefObject, +} export type HighlightStartPositionBehavior = 'first' | 'last' export type SelectIconAppearance = 'left' | 'right' | 'none' type InternalSelectContextState = { - isOpen: boolean, - options: RegisteredOption[], - highlightedValue?: string, + isOpen: boolean, + options: RegisteredOption[], + highlightedValue?: string, + searchQuery: string, } type SelectContextIds = { - trigger: string, - content: string, + trigger: string, + content: string, + listbox: string, + searchInput: string, } type SelectContextState = InternalSelectContextState & FormFieldInteractionStates & { - value: string[], - selectedOptions: RegisteredOption[], + value: string[], + selectedOptions: RegisteredOption[], + visibleOptions: RegisteredOption[], } type SelectConfiguration = { -isMultiSelect: boolean, -iconAppearance: SelectIconAppearance, + isMultiSelect: boolean, + iconAppearance: SelectIconAppearance, } type ToggleOpenOptions = { -highlightStartPositionBehavior?: HighlightStartPositionBehavior, + highlightStartPositionBehavior?: HighlightStartPositionBehavior, } const defaultToggleOpenOptions: ToggleOpenOptions = { highlightStartPositionBehavior: 'first', } - type SelectContextType = { - ids: SelectContextIds, - setIds: Dispatch>, - state: SelectContextState, - config: SelectConfiguration, - item: { - register: (item: RegisteredOption) => void, - unregister: (value: string) => void, - toggleSelection: (value: string, isSelected?: boolean) => void, - highlightItem: (value: string) => void, - moveHighlightedIndex: (delta: number) => void, - }, - trigger: { - ref: React.RefObject, - register: (element: React.RefObject) => void, - unregister: () => void, - toggleOpen: (isOpen?: boolean, options?: ToggleOpenOptions) => void, - }, - } +type SelectContextType = { + ids: SelectContextIds, + setIds: Dispatch>, + state: SelectContextState, + config: SelectConfiguration, + item: { + register: (item: RegisteredOption) => void, + unregister: (value: string) => void, + toggleSelection: (value: string, isSelected?: boolean) => void, + highlightItem: (value: string) => void, + moveHighlightedIndex: (delta: number) => void, + }, + trigger: { + ref: React.RefObject, + register: (element: React.RefObject) => void, + unregister: () => void, + toggleOpen: (isOpen?: boolean, options?: ToggleOpenOptions) => void, + }, + search: { + showSearch: boolean, + searchQuery: string, + setSearchQuery: (query: string) => void, + }, +} export const SelectContext = createContext(null) @@ -80,21 +91,23 @@ export function useSelectContext() { // // PrimitiveSelectRoot // -export type SharedSelectRootProps = Partial & PropsWithChildren & { - id?: string, - initialIsOpen?: boolean, - iconAppearance?: SelectIconAppearance, - onClose?: () => void, - } +export interface SharedSelectRootProps extends Partial { + children: ReactNode, + id?: string, + initialIsOpen?: boolean, + iconAppearance?: SelectIconAppearance, + showSearch?: boolean, + onClose?: () => void, +} -type PrimitiveSelectRootProps = SharedSelectRootProps & { - initialValue?: string, - value?: string, - onValueChange?: (value: string) => void, - initialValues?: string[], - values?: string[], - onValuesChange?: (value: string[]) => void, - isMultiSelect?: boolean, +interface PrimitiveSelectRootProps extends SharedSelectRootProps { + initialValue?: string, + value?: string, + onValueChange?: (value: string) => void, + initialValues?: string[], + values?: string[], + onValuesChange?: (value: string[]) => void, + isMultiSelect?: boolean, } const PrimitveSelectRoot = ({ @@ -113,6 +126,7 @@ const PrimitveSelectRoot = ({ required = false, invalid = false, isMultiSelect = false, + showSearch = false, iconAppearance = 'left', }: PrimitiveSelectRootProps) => { const [value, setValue] = useControlledState({ @@ -128,14 +142,18 @@ const PrimitveSelectRoot = ({ const triggerRef = useRef(null) const generatedId = useId() + const prefix = isMultiSelect ? 'multi-select-' : 'select-' const [ids, setIds] = useState({ - trigger: id ?? (isMultiSelect ? 'multi-select-' + generatedId : 'select-' + generatedId), - content: isMultiSelect ? 'multi-select-content-' + generatedId : 'select-content-' + generatedId, + trigger: id ?? (prefix + generatedId), + content: prefix + 'content-' + generatedId, + listbox: prefix + 'listbox-' + generatedId, + searchInput: prefix + 'search-' + generatedId, }) const [internalState, setInternalState] = useState({ isOpen: initialIsOpen, options: [], + searchQuery: '', }) const selectedValues = useMemo(() => isMultiSelect ? (values ?? []) : [value].filter(Boolean), @@ -145,6 +163,12 @@ const PrimitveSelectRoot = ({ selectedValues.map(value => internalState.options.find(option => value === option.value)).filter(Boolean), [selectedValues, internalState.options]) + const visibleOptions = useMemo(() => { + const q = internalState.searchQuery.trim().toLowerCase() + if (!q) return internalState.options + return MultiSearchWithMapping(internalState.searchQuery, internalState.options, o => [o.label]) + }, [internalState.options, internalState.searchQuery]) + const state: SelectContextState = { ...internalState, disabled, @@ -153,6 +177,7 @@ const PrimitveSelectRoot = ({ required, value: selectedValues, selectedOptions, + visibleOptions, } const config: SelectConfiguration = { @@ -240,12 +265,17 @@ const PrimitveSelectRoot = ({ triggerRef.current = null }, []) + const setSearchQuery = useCallback((query: string) => { + setInternalState(prev => ({ ...prev, searchQuery: query })) + }, []) + const toggleOpen = (isOpen?: boolean, toggleOpenOptions?: ToggleOpenOptions) => { const { highlightStartPositionBehavior } = { ...defaultToggleOpenOptions, ...toggleOpenOptions } + const optionsToUse = visibleOptions let firstSelectedValue: string | undefined let firstEnabledValue: string | undefined - for (let i = 0; i < state.options.length; i++) { - const currentOption = state.options[highlightStartPositionBehavior === 'first' ? i : state.options.length - i - 1] + for (let i = 0; i < optionsToUse.length; i++) { + const currentOption = optionsToUse[highlightStartPositionBehavior === 'first' ? i : optionsToUse.length - i - 1] if (!currentOption.disabled) { if (!firstEnabledValue) { firstEnabledValue = currentOption.value @@ -260,7 +290,8 @@ const PrimitveSelectRoot = ({ setInternalState(prevState => ({ ...prevState, isOpen: newIsOpen, - highlightedValue: firstSelectedValue ?? firstEnabledValue + highlightedValue: firstSelectedValue ?? firstEnabledValue, + ...(newIsOpen ? {} : { searchQuery: '' }), })) if (!newIsOpen) { onClose?.() @@ -268,18 +299,20 @@ const PrimitveSelectRoot = ({ } const moveHighlightedIndex = (delta: number) => { - let highlightedIndex = state.options.findIndex(value => value.value === internalState.highlightedValue) + const optionsToUse = visibleOptions + if (optionsToUse.length === 0) return + let highlightedIndex = optionsToUse.findIndex(opt => opt.value === internalState.highlightedValue) if (highlightedIndex === -1) { highlightedIndex = 0 } - const optionLength = state.options.length + const optionLength = optionsToUse.length const startIndex = (highlightedIndex + (delta % optionLength) + optionLength) % optionLength const isForward = delta >= 0 - let highlightedValue = state.options[startIndex].value - for (let i = 0; i < state.options.length; i++) { + let highlightedValue = optionsToUse[startIndex]?.value + for (let i = 0; i < optionsToUse.length; i++) { const index = (startIndex + (isForward ? i : -i) + optionLength) % optionLength - if (!state.options[index].disabled) { - highlightedValue = state.options[index].value + if (!optionsToUse[index].disabled) { + highlightedValue = optionsToUse[index].value break } } @@ -291,14 +324,15 @@ const PrimitveSelectRoot = ({ } useEffect(() => { - if (!internalState.highlightedValue) return - const highlighted = internalState.options.find(value => value.value === internalState.highlightedValue) + const highlighted = visibleOptions.find(opt => opt.value === internalState.highlightedValue) if (highlighted) { highlighted.ref.current?.scrollIntoView({ behavior: 'instant', block: 'nearest' }) + } else if (visibleOptions.length > 0) { + setInternalState(prev => ({ ...prev, highlightedValue: visibleOptions[0].value })) } else { - console.error(`SelectRoot: Could not find highlighted value (${internalState.highlightedValue})`) + setInternalState(prev => ({ ...prev, highlightedValue: undefined })) } - }, [internalState.highlightedValue, internalState.options]) + }, [internalState.highlightedValue, visibleOptions]) const contextValue: SelectContextType = { ids, @@ -318,6 +352,11 @@ const PrimitveSelectRoot = ({ unregister: unregisterTrigger, toggleOpen, }, + search: { + showSearch, + searchQuery: internalState.searchQuery, + setSearchQuery, + }, } return ( @@ -351,7 +390,7 @@ export const SelectRoot = ({ value, onValueChange, onEditComplete, ...props }: S // // MultiSelectRoot // -export type MultiSelectRootProps = SharedSelectRootProps & Partial> & { +export interface MultiSelectRootProps extends SharedSelectRootProps, Partial> { initialValue?: string[], } diff --git a/stories/User Interaction/Select/MultiSelect.stories.tsx b/stories/User Interaction/Select/MultiSelect.stories.tsx index ce0325b..3f2d3b2 100644 --- a/stories/User Interaction/Select/MultiSelect.stories.tsx +++ b/stories/User Interaction/Select/MultiSelect.stories.tsx @@ -15,22 +15,25 @@ export const multiSelect: Story = { initialValue: ['Apple', 'Cherry'], disabled: false, invalid: false, + showSearch: true, + readOnly: false, + required: false, onValueChange: action('onValueChange'), onEditComplete: action('onEditComplete'), children: [ - { value: 'Apple' }, - { value: 'Banana', disabled: true }, - { value: 'Cherry' }, - { value: 'Dragonfruit', className: '!text-red-400' }, - { value: 'Elderberry' }, - { value: 'Fig' }, - { value: 'Grapefruit' }, - { value: 'Honeydew' }, - { value: 'Indianfig' }, - { value: 'Jackfruit' }, - { value: 'Kiwifruit' }, - { value: 'Lemon', disabled: true } - ].sort((a,b) => a.value.localeCompare(b.value)) - .map((value, index) => ()), + { value: 'Apple', label: 'Apple' }, + { value: 'Banana', label: 'Banana', disabled: true }, + { value: 'Cherry', label: 'Cherry' }, + { value: 'Dragonfruit', label: 'Dragonfruit', className: '!text-red-400' }, + { value: 'Elderberry', label: 'Elderberry' }, + { value: 'Fig', label: 'Fig' }, + { value: 'Grapefruit', label: 'Grapefruit' }, + { value: 'Honeydew', label: 'Honeydew' }, + { value: 'Indianfig', label: 'Indianfig' }, + { value: 'Jackfruit', label: 'Jackfruit' }, + { value: 'Kiwifruit', label: 'Kiwifruit' }, + { value: 'Lemon', label: 'Lemon', disabled: true } + ].sort((a, b) => a.value.localeCompare(b.value)) + .map((item, index) => ()), }, } diff --git a/stories/User Interaction/Select/MultiSelectChipDisplay.stories.tsx b/stories/User Interaction/Select/MultiSelectChipDisplay.stories.tsx index c4fc003..b7c6a8b 100644 --- a/stories/User Interaction/Select/MultiSelectChipDisplay.stories.tsx +++ b/stories/User Interaction/Select/MultiSelectChipDisplay.stories.tsx @@ -17,22 +17,25 @@ export const multiSelectChipDisplay: Story = { initialValue: ['Apple', 'Cherry'], disabled: false, invalid: false, + showSearch: false, + readOnly: false, + required: false, onValueChange: action('onValueChange'), onEditComplete: action('onEditComplete'), children: [ - { value: 'Apple' }, - { value: 'Banana', disabled: true }, - { value: 'Cherry' }, - { value: 'Dragonfruit', className: '!text-red-400' }, - { value: 'Elderberry' }, - { value: 'Fig' }, - { value: 'Grapefruit' }, - { value: 'Honeydew' }, - { value: 'Indianfig' }, - { value: 'Jackfruit' }, - { value: 'Kiwifruit' }, - { value: 'Lemon', disabled: true } - ].sort((a,b) => a.value.localeCompare(b.value)) - .map((value, index) => ()), + { value: 'Apple', label: 'Apple' }, + { value: 'Banana', label: 'Banana', disabled: true }, + { value: 'Cherry', label: 'Cherry' }, + { value: 'Dragonfruit', label: 'Dragonfruit', className: '!text-red-400' }, + { value: 'Elderberry', label: 'Elderberry' }, + { value: 'Fig', label: 'Fig' }, + { value: 'Grapefruit', label: 'Grapefruit' }, + { value: 'Honeydew', label: 'Honeydew' }, + { value: 'Indianfig', label: 'Indianfig' }, + { value: 'Jackfruit', label: 'Jackfruit' }, + { value: 'Kiwifruit', label: 'Kiwifruit' }, + { value: 'Lemon', label: 'Lemon', disabled: true } + ].sort((a, b) => a.value.localeCompare(b.value)) + .map((item, index) => ()), }, } diff --git a/stories/User Interaction/Select/Select.stories.tsx b/stories/User Interaction/Select/Select.stories.tsx index 051b4d3..6fe4adc 100644 --- a/stories/User Interaction/Select/Select.stories.tsx +++ b/stories/User Interaction/Select/Select.stories.tsx @@ -15,23 +15,26 @@ export const select: Story = { initialValue: undefined, disabled: false, invalid: false, + showSearch: false, + readOnly: false, + required: false, onValueChange: action('onValueChange'), onEditComplete: action('onEditComplete'), children: [ - { value: 'Apple' }, - { value: 'Pear', disabled: true }, - { value: 'Strawberry' }, - { value: 'Pineapple' }, - { value: 'Blackberry' }, - { value: 'Blueberry', disabled: true }, - { value: 'Banana' }, - { value: 'Kiwi', disabled: true }, - { value: 'Maracuja', disabled: true }, - { value: 'Wildberry', disabled: true }, - { value: 'Watermelon' }, - { value: 'Honeymelon' }, - { value: 'Papja' } - ].sort((a,b) => a.value.localeCompare(b.value)) - .map((value, index) => ()), + { value: 'Apple', label: 'Apple' }, + { value: 'Pear', label: 'Pear', disabled: true }, + { value: 'Strawberry', label: 'Strawberry' }, + { value: 'Pineapple', label: 'Pineapple' }, + { value: 'Blackberry', label: 'Blackberry' }, + { value: 'Blueberry', label: 'Blueberry', disabled: true }, + { value: 'Banana', label: 'Banana' }, + { value: 'Kiwi', label: 'Kiwi', disabled: true }, + { value: 'Maracuja', label: 'Maracuja', disabled: true }, + { value: 'Wildberry', label: 'Wildberry', disabled: true }, + { value: 'Watermelon', label: 'Watermelon' }, + { value: 'Honeymelon', label: 'Honeymelon' }, + { value: 'Papja', label: 'Papja' } + ].sort((a, b) => a.value.localeCompare(b.value)) + .map((item, index) => ()), }, } From b91f64b7f92e62bfe2267a232db0e89ce662d0f5 Mon Sep 17 00:00:00 2001 From: DasProffi <67233923+DasProffi@users.noreply.github.com> Date: Fri, 20 Feb 2026 00:27:45 +0100 Subject: [PATCH 02/13] feat: make select search accessible --- locales/de-DE.arb | 6 + locales/en-US.arb | 6 + .../layout/dialog/premade/LanguageDialog.tsx | 2 +- .../layout/dialog/premade/ThemeDialog.tsx | 2 +- .../layout/table/TableFilterPopups.tsx | 4 +- .../layout/table/TablePagination.tsx | 2 +- .../properties/SelectProperty.tsx | 7 +- .../user-interaction/select/MultiSelect.tsx | 7 +- .../select/MultiSelectChipDisplay.tsx | 2 +- .../user-interaction/select/Select.tsx | 9 +- .../user-interaction/select/SelectButton.tsx | 121 ++++++ .../select/SelectComponents.tsx | 346 ------------------ .../user-interaction/select/SelectContent.tsx | 146 ++++++++ .../user-interaction/select/SelectContext.tsx | 24 +- .../user-interaction/select/SelectOption.tsx | 122 ++++++ .../selection-models}/ListBox.tsx | 0 .../User Interaction/Form/Form.stories.tsx | 2 +- stories/User Interaction/ListBox.stories.tsx | 2 +- .../MultiSelectProperty.stories.tsx | 4 +- .../SingleSelectProperty.stories.tsx | 4 +- .../Select/MultiSelect.stories.tsx | 2 +- .../Select/MultiSelectChipDisplay.stories.tsx | 2 +- .../Select/Select.stories.tsx | 2 +- 23 files changed, 449 insertions(+), 375 deletions(-) create mode 100644 src/components/user-interaction/select/SelectButton.tsx delete mode 100644 src/components/user-interaction/select/SelectComponents.tsx create mode 100644 src/components/user-interaction/select/SelectContent.tsx create mode 100644 src/components/user-interaction/select/SelectOption.tsx rename src/components/{layout => user-interaction/selection-models}/ListBox.tsx (100%) diff --git a/locales/de-DE.arb b/locales/de-DE.arb index bd152fb..98fcb9e 100644 --- a/locales/de-DE.arb +++ b/locales/de-DE.arb @@ -52,6 +52,12 @@ "no": "Nein", "none": "Nichts", "nothingFound": "Nichts gefunden", + "nResultsFound": "{count, plural, =1{# Ergebnis gefunden} other{# Ergebnisse gefunden}}", + "@nResultsFound": { + "placeholders": { + "count": { "type": "number" } + } + }, "of": "von", "optional": "Optional", "pleaseWait": "Bitte warten...", diff --git a/locales/en-US.arb b/locales/en-US.arb index 1fb1c76..12e3560 100644 --- a/locales/en-US.arb +++ b/locales/en-US.arb @@ -53,6 +53,12 @@ "no": "No", "none": "None", "nothingFound": "Nothing found", + "nResultsFound": "{count, plural, =1{# result found} other{# results found}}", + "@nResultsFound": { + "placeholders": { + "count": { "type": "number" } + } + }, "of": "of", "optional": "Optional", "pleaseWait": "Please wait...", diff --git a/src/components/layout/dialog/premade/LanguageDialog.tsx b/src/components/layout/dialog/premade/LanguageDialog.tsx index 9e1d7df..2ce76af 100644 --- a/src/components/layout/dialog/premade/LanguageDialog.tsx +++ b/src/components/layout/dialog/premade/LanguageDialog.tsx @@ -8,7 +8,7 @@ import { useHightideTranslation } from '@/src/i18n/useHightideTranslation' import type { HightideTranslationLocales } from '@/src/i18n/translations' import type { SelectProps } from '@/src/components/user-interaction/select/Select' import { Select } from '@/src/components/user-interaction/select/Select' -import { SelectOption } from '@/src/components/user-interaction/select/SelectComponents' +import { SelectOption } from '@/src/components/user-interaction/select/SelectContent' import clsx from 'clsx' type LanguageSelectProps = Omit diff --git a/src/components/layout/dialog/premade/ThemeDialog.tsx b/src/components/layout/dialog/premade/ThemeDialog.tsx index 879d846..f5615f1 100644 --- a/src/components/layout/dialog/premade/ThemeDialog.tsx +++ b/src/components/layout/dialog/premade/ThemeDialog.tsx @@ -10,7 +10,7 @@ import { Button } from '@/src/components/user-interaction/Button' import { useHightideTranslation } from '@/src/i18n/useHightideTranslation' import type { SelectProps } from '@/src/components/user-interaction/select/Select' import { Select } from '@/src/components/user-interaction/select/Select' -import { SelectOption } from '@/src/components/user-interaction/select/SelectComponents' +import { SelectOption } from '@/src/components/user-interaction/select/SelectContent' export interface ThemeIconProps extends HTMLAttributes { theme?: ThemeType, diff --git a/src/components/layout/table/TableFilterPopups.tsx b/src/components/layout/table/TableFilterPopups.tsx index 251cdb1..95cbf7b 100644 --- a/src/components/layout/table/TableFilterPopups.tsx +++ b/src/components/layout/table/TableFilterPopups.tsx @@ -25,9 +25,9 @@ import type { } from './TableFilter' import { TableFilterOperator } from './TableFilter' import { Select } from '../../user-interaction/select/Select' -import { SelectOption } from '../../user-interaction/select/SelectComponents' +import { SelectOption } from '../../user-interaction/select/SelectContent' import { MultiSelect } from '../../user-interaction/select/MultiSelect' -import { MultiSelectOption } from '../../user-interaction/select/SelectComponents' +import { MultiSelectOption } from '../../user-interaction/select/SelectContent' import { Visibility } from '../Visibility' import { ArrowRight, diff --git a/src/components/layout/table/TablePagination.tsx b/src/components/layout/table/TablePagination.tsx index 1fe17b5..56327af 100644 --- a/src/components/layout/table/TablePagination.tsx +++ b/src/components/layout/table/TablePagination.tsx @@ -1,7 +1,7 @@ import { Pagination, type PaginationProps } from '@/src/components/layout/navigation/Pagination' import type { HTMLAttributes } from 'react' import { Select, type SelectProps } from '@/src/components/user-interaction/select/Select' -import { SelectOption } from '@/src/components/user-interaction/select/SelectComponents' +import { SelectOption } from '@/src/components/user-interaction/select/SelectContent' import { Visibility } from '../Visibility' import clsx from 'clsx' import { useTableStateWithoutSizingContext } from './TableContext' diff --git a/src/components/user-interaction/properties/SelectProperty.tsx b/src/components/user-interaction/properties/SelectProperty.tsx index 9559626..6c7933e 100644 --- a/src/components/user-interaction/properties/SelectProperty.tsx +++ b/src/components/user-interaction/properties/SelectProperty.tsx @@ -3,10 +3,11 @@ import type { PropsWithChildren } from 'react' import type { PropertyField } from '@/src/components/user-interaction/properties/PropertyBase' import { PropertyBase } from '@/src/components/user-interaction/properties/PropertyBase' import { PropsUtil } from '@/src/utils/propsUtil' -import { SelectRoot } from '../select/SelectContext' -import { SelectButton, SelectContent } from '../select/SelectComponents' +import { SelectRoot } from '@/src/components/user-interaction/select/SelectContext' +import { SelectButton } from '@/src/components/user-interaction/select/SelectButton' +import { SelectContent } from '@/src/components/user-interaction/select/SelectContent' -export type SingleSelectPropertyProps = PropertyField & PropsWithChildren +export interface SingleSelectPropertyProps extends PropertyField, PropsWithChildren {} /** * An Input for SingleSelect properties diff --git a/src/components/user-interaction/select/MultiSelect.tsx b/src/components/user-interaction/select/MultiSelect.tsx index 8e0d8d5..acf85a7 100644 --- a/src/components/user-interaction/select/MultiSelect.tsx +++ b/src/components/user-interaction/select/MultiSelect.tsx @@ -1,7 +1,8 @@ import type { MultiSelectRootProps } from './SelectContext' import { MultiSelectRoot } from './SelectContext' -import type { MultiSelectContentProps, MultiSelectButtonProps } from './SelectComponents' -import { MultiSelectButton, MultiSelectContent } from './SelectComponents' +import type { MultiSelectButtonProps } from './SelectButton' +import { MultiSelectButton } from './SelectButton' +import { type MultiSelectContentProps, MultiSelectContent } from './SelectContent' import { forwardRef } from 'react' // @@ -12,7 +13,7 @@ export interface MultiSelectProps extends MultiSelectRootProps { buttonProps?: MultiSelectButtonProps, } -export const MultiSelect = forwardRef(function MultiSelect({ +export const MultiSelect = forwardRef(function MultiSelect({ children, contentPanelProps, buttonProps, diff --git a/src/components/user-interaction/select/MultiSelectChipDisplay.tsx b/src/components/user-interaction/select/MultiSelectChipDisplay.tsx index c2a9019..7c1e7f2 100644 --- a/src/components/user-interaction/select/MultiSelectChipDisplay.tsx +++ b/src/components/user-interaction/select/MultiSelectChipDisplay.tsx @@ -3,7 +3,7 @@ import { MultiSelectRoot, useSelectContext } from './SelectContext' import type { HTMLAttributes, ReactNode } from 'react' import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react' import { XIcon, Plus } from 'lucide-react' -import { MultiSelectContent, type MultiSelectContentProps } from './SelectComponents' +import { MultiSelectContent, type MultiSelectContentProps } from './SelectContent' import { IconButton } from '../IconButton' import { useHightideTranslation } from '@/src/i18n/useHightideTranslation' diff --git a/src/components/user-interaction/select/Select.tsx b/src/components/user-interaction/select/Select.tsx index 137b415..00f53ec 100644 --- a/src/components/user-interaction/select/Select.tsx +++ b/src/components/user-interaction/select/Select.tsx @@ -4,9 +4,10 @@ import { } from 'react' import type { SelectRootProps } from './SelectContext' import { SelectRoot } from './SelectContext' -import type { SelectButtonProps, SelectContentProps } from './SelectComponents' -import { SelectButton } from './SelectComponents' -import { SelectContent } from './SelectComponents' +import type { SelectButtonProps } from './SelectButton' +import { SelectButton } from './SelectButton' +import type { SelectContentProps } from './SelectContent' +import { SelectContent } from './SelectContent' // // Select @@ -16,7 +17,7 @@ export type SelectProps = SelectRootProps & { buttonProps?: Omit & { selectedDisplay?: (value: string) => ReactNode }, } -export const Select = forwardRef(function Select({ +export const Select = forwardRef(function Select({ children, contentPanelProps, buttonProps, diff --git a/src/components/user-interaction/select/SelectButton.tsx b/src/components/user-interaction/select/SelectButton.tsx new file mode 100644 index 0000000..851a600 --- /dev/null +++ b/src/components/user-interaction/select/SelectButton.tsx @@ -0,0 +1,121 @@ +import type { HTMLAttributes, ReactNode } from 'react' +import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react' +import { useSelectContext } from './SelectContext' +import clsx from 'clsx' +import { useHightideTranslation } from '@/src/i18n/useHightideTranslation' +import { ExpansionIcon } from '@/src/components/display-and-visualization/ExpansionIcon' +import { SelectOptionDisplayContext } from './SelectOption' + +export interface SelectButtonProps extends HTMLAttributes { + placeholder?: ReactNode, + disabled?: boolean, + selectedDisplay?: (value: string[]) => ReactNode, + hideExpansionIcon?: boolean, +} + +export const SelectButton = forwardRef(function SelectButton({ + id, + placeholder, + disabled: disabledOverride, + selectedDisplay, + hideExpansionIcon = false, + ...props +}, ref) { + const translation = useHightideTranslation() + const { state, trigger, setIds, ids } = useSelectContext() + const { register, unregister, toggleOpen } = trigger + + useEffect(() => { + if(id) { + setIds(prev => ({ + ...prev, + trigger: id, + })) + } + }, [id, setIds]) + const innerRef = useRef(null) + useImperativeHandle(ref, () => innerRef.current) + + useEffect(() => { + register(innerRef) + return () => unregister() + }, [register, unregister]) + + const disabled = !!disabledOverride || !!state.disabled + const invalid = state.invalid + const hasValue = state.value.length > 0 + + return ( +
    { + props.onClick?.(event) + toggleOpen(!state.isOpen) + }} + onKeyDown={event => { + props.onKeyDown?.(event) + if(disabled) return + + switch (event.key) { + case 'Enter': + case ' ': + if(disabled) return + toggleOpen(!state.isOpen) + event.preventDefault() + event.stopPropagation() + break + case 'ArrowDown': + toggleOpen(true, { highlightStartPositionBehavior: 'first' }) + event.preventDefault() + event.stopPropagation() + break + case 'ArrowUp': + toggleOpen(true, { highlightStartPositionBehavior: 'last' }) + event.preventDefault() + event.stopPropagation() + break + } + }} + + data-name={props['data-name'] ?? 'select-button'} + data-value={hasValue ? '' : undefined} + data-disabled={disabled ? '' : undefined} + data-invalid={invalid ? '' : undefined} + + tabIndex={disabled ? -1 : 0} + role="button" + aria-invalid={invalid} + aria-disabled={disabled} + aria-haspopup="dialog" + aria-expanded={state.isOpen} + aria-controls={state.isOpen ? ids.content : undefined} + > + {hasValue ? + selectedDisplay?.(state.value) ?? ( +
    + {state.selectedOptions.map(({ value, display }, index) => ( + + + {display} + + {index < state.value.length - 1 && ({','})} + + ))} +
    + ) + : placeholder ?? translation('clickToSelect') + } + {!hideExpansionIcon && } +
    + ) +}) + +/// +/// MultiSelectButton +/// +export type MultiSelectButtonProps = SelectButtonProps + +export const MultiSelectButton = SelectButton diff --git a/src/components/user-interaction/select/SelectComponents.tsx b/src/components/user-interaction/select/SelectComponents.tsx deleted file mode 100644 index 40b59e2..0000000 --- a/src/components/user-interaction/select/SelectComponents.tsx +++ /dev/null @@ -1,346 +0,0 @@ -import type { ButtonHTMLAttributes, ComponentProps, HTMLAttributes, ReactNode, RefObject } from 'react' -import type { SelectIconAppearance } from './SelectContext' -import { createContext, forwardRef, useContext, useEffect, useImperativeHandle, useRef } from 'react' -import { useSelectContext } from './SelectContext' -import clsx from 'clsx' -import { CheckIcon } from 'lucide-react' -import { useHightideTranslation } from '@/src/i18n/useHightideTranslation' -import { ExpansionIcon } from '@/src/components/display-and-visualization/ExpansionIcon' -import { PopUp, type PopUpProps } from '../../layout/popup/PopUp' -import { Input } from '@/src/components/user-interaction/input/Input' - -export type SelectOptionDisplayLocation = 'trigger' | 'list' - -const SelectOptionDisplayContext = createContext(null) - -export function useSelectOptionDisplayLocation(): SelectOptionDisplayLocation { - const context = useContext(SelectOptionDisplayContext) - if (!context) { - throw new Error('useSelectOptionDisplayLocation must be used within a SelectOptionDisplayContext') - } - return context -} - -// -// SelectOption -// -export interface SelectOptionProps extends Omit, 'children'> { - value: string, - label: string, - disabled?: boolean, - iconAppearance?: SelectIconAppearance, - children?: ReactNode, -} - -export const SelectOption = forwardRef( - function SelectOption({ children, label, value, disabled = false, iconAppearance, className, ...restProps }, ref) { - const { state, config, item, trigger } = useSelectContext() - const { register, unregister, toggleSelection, highlightItem } = item - const itemRef = useRef(null) - - iconAppearance ??= config.iconAppearance - - const display: ReactNode = children ?? label - - useEffect(() => { - register({ - value, - label, - display, - disabled, - ref: itemRef, - }) - return () => unregister(value) - }, [value, label, disabled, register, unregister, display]) - - const isHighlighted = state.highlightedValue === value - const isSelected = state.value.includes(value) - const isVisible = state.visibleOptions.some(opt => opt.value === value) - - return ( -
  • { - itemRef.current = node - if (typeof ref === 'function') ref(node) - else if (ref) (ref as RefObject).current = node - }} - id={value} - role="option" - aria-disabled={disabled} - aria-selected={isSelected} - aria-hidden={!isVisible} - data-highlighted={isHighlighted ? '' : undefined} - data-selected={isSelected ? '' : undefined} - data-disabled={disabled ? '' : undefined} - data-visible={isVisible ? '' : undefined} - className={clsx( - 'flex-row-1 items-center px-2 py-1 rounded-md', - 'data-highlighted:bg-primary/20', - 'data-disabled:text-disabled data-disabled:cursor-not-allowed', - 'not-data-disabled:cursor-pointer', - !isVisible && 'hidden', - className - )} - onClick={(event) => { - if (!disabled) { - toggleSelection(value) - if (!config.isMultiSelect) { - trigger.toggleOpen(false) - } - restProps.onClick?.(event) - } - }} - onMouseEnter={(event) => { - if (!disabled) { - highlightItem(value) - restProps.onMouseEnter?.(event) - } - }} - > - {iconAppearance === 'left' && (state.value.length > 0 || config.isMultiSelect) && ( - - )} - - {display} - - {iconAppearance === 'right' && (state.value.length > 0 || config.isMultiSelect) && ( - - )} -
  • - ) - } -) - -/// -/// SelectButton -/// -export type SelectButtonProps = ButtonHTMLAttributes & { - placeholder?: ReactNode, - selectedDisplay?: (value: string[]) => ReactNode, - hideExpansionIcon?: boolean, -} - -export const SelectButton = forwardRef(function SelectButton({ - id, - placeholder, - selectedDisplay, - hideExpansionIcon = false, - ...props -}, ref) { - const translation = useHightideTranslation() - const { state, trigger, setIds, ids } = useSelectContext() - const { register, unregister, toggleOpen } = trigger - - useEffect(() => { - if(id) { - setIds(prev => ({ - ...prev, - trigger: id, - })) - } - }, [id, setIds]) - const innerRef = useRef(null) - useImperativeHandle(ref, () => innerRef.current) - - useEffect(() => { - register(innerRef) - return () => unregister() - }, [register, unregister]) - - const disabled = !!props?.disabled || !!state.disabled - const invalid = state.invalid - const hasValue = state.value.length > 0 - - return ( - - ) -}) - -/// -/// SelectContent -/// -export type SelectContentProps = PopUpProps & { - showSearch?: boolean, - searchInputProps?: Omit, 'value' | 'onValueChange'>, -} - -export const SelectContent = forwardRef(function SelectContent({ - id, - options, - showSearch: showSearchOverride, - searchInputProps, - ...props -}, ref) { - const translation = useHightideTranslation() - const innerRef = useRef(null) - const searchInputRef = useRef(null) - useImperativeHandle(ref, () => innerRef.current) - - const { trigger, state, config, item, ids, setIds, search } = useSelectContext() - - useEffect(() => { - if (id) { - setIds(prev => ({ - ...prev, - content: id, - })) - } - }, [id, setIds]) - - const showSearch = showSearchOverride ?? search.showSearch - const listboxAriaLabel = showSearch ? translation('searchResults') : undefined - - return ( - { - trigger.toggleOpen(false) - props.onClose?.() - }} - aria-labelledby={ids.trigger} - > -
    - {showSearch && ( - - )} -
      { - switch (event.key) { - case 'ArrowDown': - item.moveHighlightedIndex(1) - event.preventDefault() - break - case 'ArrowUp': - item.moveHighlightedIndex(-1) - event.preventDefault() - break - case 'Home': - event.preventDefault() - break - case 'End': - event.preventDefault() - break - case 'Enter': - case ' ': - if (state.highlightedValue) { - item.toggleSelection(state.highlightedValue) - if (!config.isMultiSelect) { - trigger.toggleOpen(false) - } - event.preventDefault() - } - break - } - }} - className={clsx('flex-col-0 p-2 overflow-auto')} - role="listbox" - aria-multiselectable={config.isMultiSelect} - aria-orientation="vertical" - aria-label={listboxAriaLabel} - tabIndex={0} - > - {props.children} -
    -
    -
    - ) -}) - -/// -/// MultiSelectOption -/// -export type MultiSelectOptionProps = SelectOptionProps - -export const MultiSelectOption = SelectOption - - -/// -/// MultiSelectContent -/// -export type MultiSelectContentProps = SelectContentProps - -export const MultiSelectContent = SelectContent - -/// -/// MultiSelectButton -/// -export type MultiSelectButtonProps = SelectButtonProps - -export const MultiSelectButton = SelectButton diff --git a/src/components/user-interaction/select/SelectContent.tsx b/src/components/user-interaction/select/SelectContent.tsx new file mode 100644 index 0000000..cfad0c4 --- /dev/null +++ b/src/components/user-interaction/select/SelectContent.tsx @@ -0,0 +1,146 @@ +import type { ComponentProps } from 'react' +import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react' +import { useSelectContext } from './SelectContext' +import clsx from 'clsx' +import { useHightideTranslation } from '@/src/i18n/useHightideTranslation' +import { PopUp, type PopUpProps } from '@/src/components/layout/popup/PopUp' +import { Input } from '@/src/components/user-interaction/input/Input' +import { Visibility } from '../../layout/Visibility' + +export interface SelectContentProps extends PopUpProps { + showSearch?: boolean, + searchInputProps?: Omit, 'value' | 'onValueChange'>, +} + +export const SelectContent = forwardRef(function SelectContent({ + id, + options, + showSearch: showSearchOverride, + searchInputProps, + ...props +}, ref) { + const translation = useHightideTranslation() + const innerRef = useRef(null) + const searchInputRef = useRef(null) + useImperativeHandle(ref, () => innerRef.current) + + const { trigger, state, config, item, ids, setIds, search } = useSelectContext() + + useEffect(() => { + if (id) { + setIds(prev => ({ + ...prev, + content: id, + })) + } + }, [id, setIds]) + + const showSearch = showSearchOverride ?? search.showSearch + const listboxAriaLabel = showSearch ? translation('searchResults') : undefined + + const keyHandler = (event: React.KeyboardEvent) => { + switch (event.key) { + case 'ArrowDown': + item.moveHighlightedIndex(1) + event.preventDefault() + break + case 'ArrowUp': + item.moveHighlightedIndex(-1) + event.preventDefault() + break + case 'Home': + event.preventDefault() + item.highlightFirst() + break + case 'End': + event.preventDefault() + item.highlightLast() + break + case 'Enter': + case ' ': + if(showSearch && event.key === ' ') return + + if (state.highlightedValue) { + item.toggleSelection(state.highlightedValue) + if (!config.isMultiSelect) { + trigger.toggleOpen(false) + } + event.preventDefault() + } + break + } + } + + return ( + { + trigger.toggleOpen(false) + props.onClose?.() + }} + aria-labelledby={ids.trigger} + className="gap-y-1" + > + {showSearch && ( + + )} +
      + {props.children} + +
    • 0 })} + > + {translation('nResultsFound', { count: state.visibleOptions.length })} +
    • +
      +
    +
    + ) +}) + + +/// +/// MultiSelectContent +/// +export type MultiSelectContentProps = SelectContentProps + +export const MultiSelectContent = SelectContent diff --git a/src/components/user-interaction/select/SelectContext.tsx b/src/components/user-interaction/select/SelectContext.tsx index c89993e..7cd459d 100644 --- a/src/components/user-interaction/select/SelectContext.tsx +++ b/src/components/user-interaction/select/SelectContext.tsx @@ -13,7 +13,7 @@ type RegisteredOption = { label: string, display: ReactNode, disabled: boolean, - ref: React.RefObject, + ref: React.RefObject, } export type HighlightStartPositionBehavior = 'first' | 'last' @@ -61,6 +61,8 @@ type SelectContextType = { register: (item: RegisteredOption) => void, unregister: (value: string) => void, toggleSelection: (value: string, isSelected?: boolean) => void, + highlightFirst: () => void, + highlightLast: () => void, highlightItem: (value: string) => void, moveHighlightedIndex: (delta: number) => void, }, @@ -247,15 +249,27 @@ const PrimitveSelectRoot = ({ })) } - const highlightItem = (value: string) => { - if (disabled) { + const highlightItem = useCallback((value: string) => { + if (disabled || !state.visibleOptions.some(opt => opt.value === value && !opt.disabled)) { return } setInternalState(prevState => ({ ...prevState, highlightedValue: value, })) - } + }, [disabled, state.visibleOptions]) + + const highlightFirst = useCallback(() => { + const firstOption = state.visibleOptions.find(opt => !opt.disabled) + if(!firstOption) return + highlightItem(firstOption.value) + }, [highlightItem, state.visibleOptions]) + + const highlightLast = useCallback(() => { + const lastOption = [...state.visibleOptions].reverse().find(opt => !opt.disabled) + if(!lastOption) return + highlightItem(lastOption.value) + }, [highlightItem, state.visibleOptions]) const registerTrigger = useCallback((ref: React.RefObject) => { triggerRef.current = ref.current @@ -343,6 +357,8 @@ const PrimitveSelectRoot = ({ register: registerItem, unregister: unregisterItem, toggleSelection, + highlightFirst, + highlightLast, highlightItem, moveHighlightedIndex, }, diff --git a/src/components/user-interaction/select/SelectOption.tsx b/src/components/user-interaction/select/SelectOption.tsx new file mode 100644 index 0000000..9e26900 --- /dev/null +++ b/src/components/user-interaction/select/SelectOption.tsx @@ -0,0 +1,122 @@ +import clsx from 'clsx' +import { CheckIcon } from 'lucide-react' +import type { HTMLAttributes, ReactNode, RefObject } from 'react' +import { forwardRef, useContext, useEffect, useRef, createContext } from 'react' +import type { SelectIconAppearance } from './SelectContext' +import { useSelectContext } from './SelectContext' + +export type SelectOptionDisplayLocation = 'trigger' | 'list' + +export const SelectOptionDisplayContext = createContext(null) + +export function useSelectOptionDisplayLocation(): SelectOptionDisplayLocation { + const context = useContext(SelectOptionDisplayContext) + if (!context) { + throw new Error('useSelectOptionDisplayLocation must be used within a SelectOptionDisplayContext') + } + return context +} + +// +// SelectOption +// +export interface SelectOptionProps extends Omit, 'children'> { + value: string, + label: string, + disabled?: boolean, + iconAppearance?: SelectIconAppearance, + children?: ReactNode, +} + +export const SelectOption = forwardRef( + function SelectOption({ children, label, value, disabled = false, iconAppearance, className, ...restProps }, ref) { + const { state, config, item, trigger } = useSelectContext() + const { register, unregister, toggleSelection, highlightItem } = item + const itemRef = useRef(null) + + iconAppearance ??= config.iconAppearance + + const display: ReactNode = children ?? label + + useEffect(() => { + register({ + value, + label, + display, + disabled, + ref: itemRef, + }) + return () => unregister(value) + }, [value, label, disabled, register, unregister, display]) + + const isHighlighted = state.highlightedValue === value + const isSelected = state.value.includes(value) + const isVisible = state.visibleOptions.some(opt => opt.value === value) + + return ( +
  • { + itemRef.current = node + if (typeof ref === 'function') ref(node) + else if (ref) (ref as RefObject).current = node + }} + id={value} + role="option" + aria-disabled={disabled} + aria-selected={isSelected} + aria-hidden={!isVisible} + data-highlighted={isHighlighted ? '' : undefined} + data-selected={isSelected ? '' : undefined} + data-disabled={disabled ? '' : undefined} + data-visible={isVisible ? '' : undefined} + className={clsx( + 'flex-row-1 items-center px-2 py-1 rounded-md', + 'data-highlighted:bg-primary/20', + 'data-disabled:text-disabled data-disabled:cursor-not-allowed', + 'not-data-disabled:cursor-pointer', + !isVisible && 'hidden', + className + )} + onClick={(event) => { + if (!disabled) { + toggleSelection(value) + if (!config.isMultiSelect) { + trigger.toggleOpen(false) + } + restProps.onClick?.(event) + } + }} + onMouseEnter={(event) => { + if (!disabled) { + highlightItem(value) + restProps.onMouseEnter?.(event) + } + }} + > + {iconAppearance === 'left' && (state.value.length > 0 || config.isMultiSelect) && ( + + )} + + {display} + + {iconAppearance === 'right' && (state.value.length > 0 || config.isMultiSelect) && ( + + )} +
  • + ) + } +) + +/// +/// MultiSelectOption +/// +export type MultiSelectOptionProps = SelectOptionProps + +export const MultiSelectOption = SelectOption diff --git a/src/components/layout/ListBox.tsx b/src/components/user-interaction/selection-models/ListBox.tsx similarity index 100% rename from src/components/layout/ListBox.tsx rename to src/components/user-interaction/selection-models/ListBox.tsx diff --git a/stories/User Interaction/Form/Form.stories.tsx b/stories/User Interaction/Form/Form.stories.tsx index 42316c9..ba12de2 100644 --- a/stories/User Interaction/Form/Form.stories.tsx +++ b/stories/User Interaction/Form/Form.stories.tsx @@ -6,7 +6,7 @@ import { useTranslatedValidators } from '@/src/hooks/useValidators' import { Input } from '@/src/components/user-interaction/input/Input' import { MultiSelect } from '@/src/components/user-interaction/select/MultiSelect' import { Select } from '@/src/components/user-interaction/select/Select' -import { SelectOption } from '@/src/components/user-interaction/select/SelectComponents' +import { SelectOption } from '@/src/components/user-interaction/select/SelectContent' import { Textarea } from '@/src/components/user-interaction/Textarea' import { Button } from '@/src/components/user-interaction/Button' import { useCreateForm } from '@/src/components/form/useCreateForm' diff --git a/stories/User Interaction/ListBox.stories.tsx b/stories/User Interaction/ListBox.stories.tsx index a4e426d..7461c15 100644 --- a/stories/User Interaction/ListBox.stories.tsx +++ b/stories/User Interaction/ListBox.stories.tsx @@ -1,6 +1,6 @@ import type { Meta, StoryObj } from '@storybook/nextjs' import { action } from 'storybook/actions' -import { ListBoxItem, ListBox } from '@/src/components/layout/ListBox' +import { ListBoxItem, ListBox } from '@/src/components/user-interaction/selection-models/ListBox' const meta = { diff --git a/stories/User Interaction/Properties/MultiSelectProperty.stories.tsx b/stories/User Interaction/Properties/MultiSelectProperty.stories.tsx index 8ff7d97..8b48e83 100644 --- a/stories/User Interaction/Properties/MultiSelectProperty.stories.tsx +++ b/stories/User Interaction/Properties/MultiSelectProperty.stories.tsx @@ -4,7 +4,7 @@ import { action } from 'storybook/actions' import clsx from 'clsx' import { MultiSelectProperty } from '@/src/components/user-interaction/properties/MultiSelectProperty' import { StorybookHelper } from '@/src/storybook/helper' -import { MultiSelectOption } from '@/src/components/user-interaction/select/SelectComponents' +import { MultiSelectOption } from '@/src/components/user-interaction/select/SelectOption' const options = StorybookHelper.selectValues @@ -22,7 +22,7 @@ export const multiSelectProperty: Story = { value: options.slice(3, 5), readOnly: false, children: options.map(option => ( - + ( - + Date: Fri, 20 Feb 2026 00:36:46 +0100 Subject: [PATCH 03/13] feat: add type ahead to select --- CHANGELOG.md | 4 + package.json | 2 +- .../user-interaction/select/SelectContent.tsx | 74 ++++++++++++++++++- 3 files changed, 75 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 533e3a1..d2904d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [0.9.0] - 2026-02-19 +## Added +- Search for `Select` and `MultiSelect` +- Type ahead support for `Select` and `MultiSelect` + ## Fixed - imports in `TimePicker` and `DateTimeInput` diff --git a/package.json b/package.json index 6d796fa..c942a9d 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "url": "git+https://github.com/helpwave/hightide.git" }, "license": "MPL-2.0", - "version": "0.8.12", + "version": "0.9.0", "files": [ "dist" ], diff --git a/src/components/user-interaction/select/SelectContent.tsx b/src/components/user-interaction/select/SelectContent.tsx index cfad0c4..cb52db3 100644 --- a/src/components/user-interaction/select/SelectContent.tsx +++ b/src/components/user-interaction/select/SelectContent.tsx @@ -1,5 +1,5 @@ import type { ComponentProps } from 'react' -import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react' +import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef } from 'react' import { useSelectContext } from './SelectContext' import clsx from 'clsx' import { useHightideTranslation } from '@/src/i18n/useHightideTranslation' @@ -7,6 +7,8 @@ import { PopUp, type PopUpProps } from '@/src/components/layout/popup/PopUp' import { Input } from '@/src/components/user-interaction/input/Input' import { Visibility } from '../../layout/Visibility' +const TYPEAHEAD_RESET_MS = 500 + export interface SelectContentProps extends PopUpProps { showSearch?: boolean, searchInputProps?: Omit, 'value' | 'onValueChange'>, @@ -22,6 +24,8 @@ export const SelectContent = forwardRef(fu const translation = useHightideTranslation() const innerRef = useRef(null) const searchInputRef = useRef(null) + const typeAheadBufferRef = useRef('') + const typeAheadTimeoutRef = useRef | null>(null) useImperativeHandle(ref, () => innerRef.current) const { trigger, state, config, item, ids, setIds, search } = useSelectContext() @@ -35,10 +39,30 @@ export const SelectContent = forwardRef(fu } }, [id, setIds]) + useEffect(() => { + if (!state.isOpen) { + typeAheadBufferRef.current = '' + if (typeAheadTimeoutRef.current) { + clearTimeout(typeAheadTimeoutRef.current) + typeAheadTimeoutRef.current = null + } + } + }, [state.isOpen]) + + useEffect(() => { + return () => { + if (typeAheadTimeoutRef.current) { + clearTimeout(typeAheadTimeoutRef.current) + } + } + }, []) + const showSearch = showSearchOverride ?? search.showSearch const listboxAriaLabel = showSearch ? translation('searchResults') : undefined - const keyHandler = (event: React.KeyboardEvent) => { + const keyHandler = useCallback((event: React.KeyboardEvent) => { + + switch (event.key) { case 'ArrowDown': item.moveHighlightedIndex(1) @@ -58,7 +82,7 @@ export const SelectContent = forwardRef(fu break case 'Enter': case ' ': - if(showSearch && event.key === ' ') return + if (showSearch && event.key === ' ') return if (state.highlightedValue) { item.toggleSelection(state.highlightedValue) @@ -68,8 +92,50 @@ export const SelectContent = forwardRef(fu event.preventDefault() } break + default: + if ( + !showSearch && + !event.ctrlKey && + !event.metaKey && + !event.altKey + ) { + const char = event.key.toLowerCase() + if (typeAheadTimeoutRef.current) { + clearTimeout(typeAheadTimeoutRef.current) + } + typeAheadBufferRef.current += char + typeAheadTimeoutRef.current = setTimeout(() => { + typeAheadBufferRef.current = '' + }, TYPEAHEAD_RESET_MS) + + const opts = state.visibleOptions + const buf = typeAheadBufferRef.current + if (opts.length === 0) { + event.preventDefault() + return + } + const currentIndex = opts.findIndex(o => o.value === state.highlightedValue) + const startFrom = currentIndex >= 0 ? (currentIndex + 1) % opts.length : 0 + for (let i = 0; i < opts.length; i++) { + const j = (startFrom + i) % opts.length + if (!opts[j].disabled && opts[j].label.toLowerCase().startsWith(buf)) { + item.highlightItem(opts[j].value) + event.preventDefault() + return + } + } + event.preventDefault() + return + } } - } + }, [ + showSearch, + state.visibleOptions, + state.highlightedValue, + item, + config.isMultiSelect, + trigger, + ]) return ( Date: Sun, 22 Feb 2026 02:06:25 +0100 Subject: [PATCH 04/13] feat: add combobox and FilterList --- CHANGELOG.md | 4 +- locales/de-DE.arb | 80 + locales/en-US.arb | 80 + package-lock.json | 3127 ++++++++--------- package.json | 19 +- .../display-and-visualization/Chip.tsx | 2 +- src/components/layout/popup/PopUp.tsx | 6 +- src/components/layout/table/TableColumn.tsx | 4 +- src/components/layout/table/TableFilter.ts | 187 +- .../layout/table/TableFilterButton.tsx | 78 +- .../layout/table/TableFilterPopups.tsx | 930 ----- src/components/layout/table/TableHeader.tsx | 9 +- .../layout/table/TablePagination.tsx | 4 +- src/components/layout/table/TableProvider.tsx | 24 +- src/components/layout/table/types.ts | 2 +- src/components/user-interaction/Button.tsx | 2 +- src/components/user-interaction/Combobox.tsx | 317 ++ .../user-interaction/IconButton.tsx | 7 +- .../user-interaction/data/FilterList.tsx | 122 + .../user-interaction/data/FilterOperator.tsx | 183 + .../data/FilterOperatorLabel.tsx | 20 + .../user-interaction/data/FilterPopUp.tsx | 758 ++++ .../user-interaction/data/data-types.tsx | 70 + .../user-interaction/data/filter-function.ts | 463 +++ .../user-interaction/select/Select.tsx | 2 +- .../user-interaction/select/SelectButton.tsx | 32 +- src/components/utils/Polymorphic.tsx | 23 + src/hooks/useOverlayRegistry.ts | 12 +- src/style/theme/colors/utilities.css | 2 + src/style/theme/components/button.css | 12 +- src/style/theme/components/checkbox.css | 8 + src/style/theme/components/combobox.css | 21 + src/style/theme/components/icon-button.css | 12 +- src/style/theme/components/index.css | 1 + src/style/theme/components/input-elements.css | 8 + src/style/theme/components/pop-up.css | 9 +- src/style/utitlity/coloring.css | 27 + src/utils/date.ts | 28 + src/utils/filter.ts | 355 -- .../Layout/Table/AsyncDataExample.stories.tsx | 97 +- .../Layout/Table/FilterListTable.stories.tsx | 154 + 41 files changed, 4020 insertions(+), 3281 deletions(-) delete mode 100644 src/components/layout/table/TableFilterPopups.tsx create mode 100644 src/components/user-interaction/Combobox.tsx create mode 100644 src/components/user-interaction/data/FilterList.tsx create mode 100644 src/components/user-interaction/data/FilterOperator.tsx create mode 100644 src/components/user-interaction/data/FilterOperatorLabel.tsx create mode 100644 src/components/user-interaction/data/FilterPopUp.tsx create mode 100644 src/components/user-interaction/data/data-types.tsx create mode 100644 src/components/user-interaction/data/filter-function.ts create mode 100644 src/components/utils/Polymorphic.tsx create mode 100644 src/style/theme/components/combobox.css delete mode 100644 src/utils/filter.ts create mode 100644 stories/Layout/Table/FilterListTable.stories.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index d2904d6..69acc16 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,11 +5,13 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [0.9.0] - 2026-02-19 +## [0.9.0] - 2026-02-20 ## Added - Search for `Select` and `MultiSelect` - Type ahead support for `Select` and `MultiSelect` +- `Combobox` component +- `FilterList` component for dynamically choosing and setting filters ## Fixed - imports in `TimePicker` and `DateTimeInput` diff --git a/locales/de-DE.arb b/locales/de-DE.arb index 98fcb9e..35aede0 100644 --- a/locales/de-DE.arb +++ b/locales/de-DE.arb @@ -1,5 +1,6 @@ { "add": "Hinzufügen", + "addFilter": "Filter hinzufügen", "all": "Alle", "apply": "Anwenden", "back": "Zurück", @@ -30,6 +31,7 @@ "discardChanges": "Änderungen Verwerfen", "done": "Fertig", "edit": "Bearbeiten", + "editFilter": "Filter bearbeiten", "enterText": "Text hier eingeben", "error": "Fehler", "errorOccurred": "Ein Fehler ist aufgetreten", @@ -63,8 +65,83 @@ "pleaseWait": "Bitte warten...", "previous": "Vorherige", "remove": "Entfernen", + "removeFilter": "Filter entfernen", "required": "Erforderlich", "reset": "Zurücksetzen", + "rBetween": "Zwischen {value1} und {value2}", + "@rBetween": { + "placeholders": { + "value1": {}, + "value2": {} + } + }, + "rContains": "Enthält {value}", + "@rContains": { + "placeholders": { + "value": {} + } + }, + "rEndsWith": "Endet mit {value}", + "@rEndsWith": { + "placeholders": { + "value": {} + } + }, + "rEquals": "Gleich {value}", + "@rEquals": { + "placeholders": { + "value": {} + } + }, + "rGreaterThan": "Größer als {value}", + "@rGreaterThan": { + "placeholders": { + "value": {} + } + }, + "rGreaterThanOrEqual": "Größer oder gleich {value}", + "@rGreaterThanOrEqual": { + "placeholders": { + "value": {} + } + }, + "rLessThan": "Kleiner als {value}", + "@rLessThan": { + "placeholders": { + "value": {} + } + }, + "rLessThanOrEqual": "Kleiner oder gleich {value}", + "@rLessThanOrEqual": { + "placeholders": { + "value": {} + } + }, + "rNotBetween": "Nicht zwischen {value1} und {value2}", + "@rNotBetween": { + "placeholders": { + "value1": {}, + "value2": {} + } + }, + "rNotContains": "Enthält nicht {value}", + "@rNotContains": { + "placeholders": { + "value": {} + } + }, + "rNotEquals": "Nicht gleich {value}", + "@rNotEquals": { + "placeholders": { + "value": {} + } + }, + "rStartsWith": "Beginnt mit {value}", + "@rStartsWith": { + "placeholders": { + "value": {} + } + }, "save": "Speichern", "saved": "Gespeichert", "search": "Suche", @@ -145,6 +222,8 @@ "invalidEmail": "Die E-Mail ist ungültig.", "isFalse": "Ist falsch", "isTrue": "Ist wahr", + "isUndefined": "Ist undefiniert", + "isNotUndefined": "Ist definiert", "lessThan": "Kleiner als", "lessThanOrEqual": "Kleiner oder gleich", "after": "Nach", @@ -226,6 +305,7 @@ "showColumn": "Spalte einblenden", "pinned": "Angeheftet", "unpin": "Loslösen", + "unknown": "Unbekannt", "pinLeft": "Links anheften", "pinRight": "Rechts anheften", "changeVisibility": "Sichtbarkeit ändern", diff --git a/locales/en-US.arb b/locales/en-US.arb index 12e3560..83d344e 100644 --- a/locales/en-US.arb +++ b/locales/en-US.arb @@ -1,5 +1,6 @@ { "add": "Add", + "addFilter": "Add filter", "all": "All", "apply": "Apply", "back": "Back", @@ -31,6 +32,7 @@ "discardChanges": "Discard Changes", "done": "Done", "edit": "Edit", + "editFilter": "Edit filter", "enterText": "Enter text here", "error": "Error", "errorOccurred": "An error occurred", @@ -64,8 +66,83 @@ "pleaseWait": "Please wait...", "previous": "Previous", "remove": "Remove", + "removeFilter": "Remove filter", "required": "Required", "reset": "Reset", + "rBetween": "Between {value1} and {value2}", + "@rBetween": { + "placeholders": { + "value1": {}, + "value2": {} + } + }, + "rContains": "Contains {value}", + "@rContains": { + "placeholders": { + "value": {} + } + }, + "rEndsWith": "Ends with {value}", + "@rEndsWith": { + "placeholders": { + "value": {} + } + }, + "rEquals": "Equals {value}", + "@rEquals": { + "placeholders": { + "value": {} + } + }, + "rGreaterThan": "Greater than {value}", + "@rGreaterThan": { + "placeholders": { + "value": {} + } + }, + "rGreaterThanOrEqual": "Greater than or equal {value}", + "@rGreaterThanOrEqual": { + "placeholders": { + "value": {} + } + }, + "rLessThan": "Less than {value}", + "@rLessThan": { + "placeholders": { + "value": {} + } + }, + "rLessThanOrEqual": "Less than or equal {value}", + "@rLessThanOrEqual": { + "placeholders": { + "value": {} + } + }, + "rNotBetween": "Not between {value1} and {value2}", + "@rNotBetween": { + "placeholders": { + "value1": {}, + "value2": {} + } + }, + "rNotContains": "Not contains {value}", + "@rNotContains": { + "placeholders": { + "value": {} + } + }, + "rNotEquals": "Not equals {value}", + "@rNotEquals": { + "placeholders": { + "value": {} + } + }, + "rStartsWith": "Starts with {value}", + "@rStartsWith": { + "placeholders": { + "value": {} + } + }, "save": "Save", "saved": "Saved", "search": "Search", @@ -146,6 +223,8 @@ "invalidEmail": "The email is not valid.", "isFalse": "Is false", "isTrue": "Is true", + "isUndefined": "Is undefined", + "isNotUndefined": "Is defined", "lessThan": "Less than", "lessThanOrEqual": "Less than or equal", "after": "After", @@ -227,6 +306,7 @@ "showColumn": "Show column", "pinned": "Pinned", "unpin": "Unpin", + "unknown": "Unknown", "pinLeft": "Pin to left", "pinRight": "Pin to right", "changeVisibility": "Change visibility", diff --git a/package-lock.json b/package-lock.json index dc540d4..0284df1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,16 @@ { "name": "@helpwave/hightide", - "version": "0.8.8", + "version": "0.9.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@helpwave/hightide", - "version": "0.8.8", + "version": "0.9.0", "license": "MPL-2.0", "dependencies": { "@helpwave/internationalization": "0.4.0", + "@radix-ui/react-slot": "1.2.4", "@tailwindcss/cli": "4.1.18", "@tanstack/react-table": "8.21.3", "clsx": "2.1.1", @@ -28,9 +29,9 @@ "@babel/preset-typescript": "7.26.0", "@faker-js/faker": "10.1.0", "@helpwave/eslint-config": "0.0.11", - "@storybook/addon-docs": "10.2.8", - "@storybook/addon-links": "10.2.8", - "@storybook/nextjs": "^10.2.8", + "@storybook/addon-docs": "10.2.10", + "@storybook/addon-links": "10.2.10", + "@storybook/nextjs": "10.2.10", "@tailwindcss/postcss": "4.1.18", "@types/jest": "30.0.0", "@types/node": "20.17.10", @@ -39,13 +40,13 @@ "@types/tinycolor2": "1.4.6", "@vitest/mocker": "4.0.16", "autoprefixer": "10.4.23", - "eslint": "9.31.0", - "eslint-plugin-storybook": "10.2.8", + "eslint": "10.0.1", + "eslint-plugin-storybook": "10.2.10", "jest": "30.2.0", "postcss": "8.5.6", - "storybook": "10.2.8", - "ts-jest": "29.4.5", - "tsup": "8.5.0", + "storybook": "10.2.10", + "ts-jest": "29.4.6", + "tsup": "8.5.1", "typescript": "5.7.2", "webpack": "5.105.2" } @@ -115,7 +116,6 @@ "integrity": "sha512-i1SLeK+DzNnQ3LL/CswPCa/E5u4lh1k6IAEphON8F+cXt0t9euTshDru0q7/IqMa1PMPz5RnHuHscF8/ZJsStg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.26.0", @@ -2532,65 +2532,44 @@ } }, "node_modules/@eslint/config-array": { - "version": "0.21.1", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", - "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.2.tgz", + "integrity": "sha512-YF+fE6LV4v5MGWRGj7G404/OZzGNepVF8fxk7jqmqo3lrza7a0uUcDnROGRBG1WFC1omYUS/Wp1f42i0M+3Q3A==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/object-schema": "^2.1.7", + "@eslint/object-schema": "^3.0.2", "debug": "^4.3.1", - "minimatch": "^3.1.2" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/config-array/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/@eslint/config-array/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" + "minimatch": "^10.2.1" }, "engines": { - "node": "*" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@eslint/config-helpers": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.3.1.tgz", - "integrity": "sha512-xR93k9WhrDYpXHORXpxVL5oHj3Era7wo6k/Wd8/IsQNnZUTzkGS29lyn3nAT05v6ltUuTFVCCYDEGfy2Or/sPA==", + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.2.tgz", + "integrity": "sha512-a5MxrdDXEvqnIq+LisyCX6tQMPF/dSJpCfBgBauY+pNZ28yCtSsTvyTYrMhaI+LK26bVyCJfJkT0u8KIj2i1dQ==", "dev": true, "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.1.0" + }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@eslint/core": { - "version": "0.15.2", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.2.tgz", - "integrity": "sha512-78Md3/Rrxh83gCxoUc0EiciuOHsIITzLy53m3d9UyiW8y9Dj2D29FeETqyKA+BRK76tnTp6RXWb3pCay8Oyomg==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.1.0.tgz", + "integrity": "sha512-/nr9K9wkr3P1EzFTdFdMoLuo1PmIxjmwvPozwoSodjNBdefGujXQUF93u1DDZpEaTuDvMsIQddsd35BwtrW9Xw==", "dev": true, "license": "Apache-2.0", "dependencies": { "@types/json-schema": "^7.0.15" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@eslint/eslintrc": { @@ -2628,6 +2607,37 @@ "concat-map": "0.0.1" } }, + "node_modules/@eslint/eslintrc/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, "node_modules/@eslint/eslintrc/node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -2642,9 +2652,9 @@ } }, "node_modules/@eslint/js": { - "version": "9.39.2", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.2.tgz", - "integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==", + "version": "9.39.3", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.3.tgz", + "integrity": "sha512-1B1VkCq6FuUNlQvlBYb+1jDu/gV297TIs/OeiaSR9l1H27SVW55ONE1e1Vp16NqP683+xEGzxYtv4XCiDPaQiw==", "dev": true, "license": "MIT", "engines": { @@ -2655,27 +2665,27 @@ } }, "node_modules/@eslint/object-schema": { - "version": "2.1.7", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", - "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.2.tgz", + "integrity": "sha512-HOy56KJt48Bx8KmJ+XGQNSUMT/6dZee/M54XyUyuvTvPXJmsERRvBchsUVx1UMe1WwIH49XLAczNC7V2INsuUw==", "dev": true, "license": "Apache-2.0", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@eslint/plugin-kit": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.5.tgz", - "integrity": "sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w==", + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.6.0.tgz", + "integrity": "sha512-bIZEUzOI1jkhviX2cp5vNyXQc6olzb2ohewQubuYlMXZ2Q/XjBO0x0XhGPvc9fjSIiUN0vw+0hq53BJ4eQSJKQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.15.2", + "@eslint/core": "^1.1.0", "levn": "^0.4.1" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@faker-js/faker": { @@ -2712,130 +2722,400 @@ "typescript-eslint": "^8.32.1" } }, - "node_modules/@helpwave/internationalization": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@helpwave/internationalization/-/internationalization-0.4.0.tgz", - "integrity": "sha512-mvlg9wPojQq7JcLPPIGXGbUbWfuUvGgegJwh/WBHY2SOkj2VRbQ5PjGhc7h7gWwY8/cqDUAeewIo0worqyFjfw==", - "license": "MPL-2.0", - "bin": { - "build-intl": "dist/scripts/compile-arb.js" + "node_modules/@helpwave/eslint-config/node_modules/@eslint/config-array": { + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", + "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@humanfs/core": { - "version": "0.19.1", - "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", - "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "node_modules/@helpwave/eslint-config/node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", "dev": true, "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, "engines": { - "node": ">=18.18.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@humanfs/node": { - "version": "0.16.7", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", - "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "node_modules/@helpwave/eslint-config/node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@humanfs/core": "^0.19.1", - "@humanwhocodes/retry": "^0.4.0" + "@types/json-schema": "^7.0.15" }, "engines": { - "node": ">=18.18.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@humanwhocodes/module-importer": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", - "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "node_modules/@helpwave/eslint-config/node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", "dev": true, "license": "Apache-2.0", "engines": { - "node": ">=12.22" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@humanwhocodes/retry": { - "version": "0.4.3", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", - "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "node_modules/@helpwave/eslint-config/node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", "dev": true, "license": "Apache-2.0", - "engines": { - "node": ">=18.18" + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@img/colour": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz", - "integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==", + "node_modules/@helpwave/eslint-config/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "dev": true, "license": "MIT", - "optional": true, - "engines": { - "node": ">=18" + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" } }, - "node_modules/@img/sharp-darwin-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", - "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", - "cpu": [ - "arm64" - ], + "node_modules/@helpwave/eslint-config/node_modules/eslint": { + "version": "9.39.3", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.3.tgz", + "integrity": "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==", "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.1", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.39.3", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { - "url": "https://opencollective.com/libvips" + "url": "https://eslint.org/donate" }, - "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.2.4" + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } } }, - "node_modules/@img/sharp-darwin-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", - "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", - "cpu": [ - "x64" - ], + "node_modules/@helpwave/eslint-config/node_modules/eslint-plugin-react": { + "version": "7.37.5", + "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz", + "integrity": "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==", "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.8", + "array.prototype.findlast": "^1.2.5", + "array.prototype.flatmap": "^1.3.3", + "array.prototype.tosorted": "^1.1.4", + "doctrine": "^2.1.0", + "es-iterator-helpers": "^1.2.1", + "estraverse": "^5.3.0", + "hasown": "^2.0.2", + "jsx-ast-utils": "^2.4.1 || ^3.0.0", + "minimatch": "^3.1.2", + "object.entries": "^1.1.9", + "object.fromentries": "^2.0.8", + "object.values": "^1.2.1", + "prop-types": "^15.8.1", + "resolve": "^2.0.0-next.5", + "semver": "^6.3.1", + "string.prototype.matchall": "^4.0.12", + "string.prototype.repeat": "^1.0.0" }, - "funding": { - "url": "https://opencollective.com/libvips" + "engines": { + "node": ">=4" }, - "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.2.4" + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" } }, - "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", - "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", - "cpu": [ - "arm64" + "node_modules/@helpwave/eslint-config/node_modules/eslint-plugin-react-hooks": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.2.0.tgz", + "integrity": "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "node_modules/@helpwave/eslint-config/node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@helpwave/eslint-config/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@helpwave/eslint-config/node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@helpwave/eslint-config/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@helpwave/eslint-config/node_modules/resolve": { + "version": "2.0.0-next.6", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.6.tgz", + "integrity": "sha512-3JmVl5hMGtJ3kMmB3zi3DL25KfkCEyy3Tw7Gmw7z5w8M9WlwoPFnIvwChzu1+cF3iaK3sp18hhPz8ANeimdJfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "node-exports-info": "^1.6.0", + "object-keys": "^1.1.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/@helpwave/internationalization": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@helpwave/internationalization/-/internationalization-0.4.0.tgz", + "integrity": "sha512-mvlg9wPojQq7JcLPPIGXGbUbWfuUvGgegJwh/WBHY2SOkj2VRbQ5PjGhc7h7gWwY8/cqDUAeewIo0worqyFjfw==", + "license": "MPL-2.0", + "bin": { + "build-intl": "dist/scripts/compile-arb.js" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@img/colour": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz", + "integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" ], "dev": true, "license": "LGPL-3.0-or-later", @@ -2843,6 +3123,7 @@ "os": [ "darwin" ], + "peer": true, "funding": { "url": "https://opencollective.com/libvips" } @@ -2860,6 +3141,7 @@ "os": [ "darwin" ], + "peer": true, "funding": { "url": "https://opencollective.com/libvips" } @@ -2877,6 +3159,7 @@ "os": [ "linux" ], + "peer": true, "funding": { "url": "https://opencollective.com/libvips" } @@ -2894,6 +3177,7 @@ "os": [ "linux" ], + "peer": true, "funding": { "url": "https://opencollective.com/libvips" } @@ -2911,6 +3195,7 @@ "os": [ "linux" ], + "peer": true, "funding": { "url": "https://opencollective.com/libvips" } @@ -2928,6 +3213,7 @@ "os": [ "linux" ], + "peer": true, "funding": { "url": "https://opencollective.com/libvips" } @@ -2945,6 +3231,7 @@ "os": [ "linux" ], + "peer": true, "funding": { "url": "https://opencollective.com/libvips" } @@ -2962,6 +3249,7 @@ "os": [ "linux" ], + "peer": true, "funding": { "url": "https://opencollective.com/libvips" } @@ -2979,6 +3267,7 @@ "os": [ "linux" ], + "peer": true, "funding": { "url": "https://opencollective.com/libvips" } @@ -2996,6 +3285,7 @@ "os": [ "linux" ], + "peer": true, "funding": { "url": "https://opencollective.com/libvips" } @@ -3013,6 +3303,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, @@ -3036,6 +3327,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, @@ -3059,6 +3351,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, @@ -3082,6 +3375,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, @@ -3105,6 +3399,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, @@ -3128,6 +3423,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, @@ -3151,6 +3447,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, @@ -3174,6 +3471,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, @@ -3194,6 +3492,7 @@ "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", "optional": true, + "peer": true, "dependencies": { "@emnapi/runtime": "^1.7.0" }, @@ -3217,6 +3516,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, @@ -3237,6 +3537,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, @@ -3257,6 +3558,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, @@ -3282,23 +3584,108 @@ "node": ">=12" } }, - "node_modules/@istanbuljs/load-nyc-config": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", - "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", "dev": true, - "license": "ISC", - "dependencies": { - "camelcase": "^5.3.1", - "find-up": "^4.1.0", - "get-package-type": "^0.1.0", - "js-yaml": "^3.13.1", - "resolve-from": "^5.0.0" - }, + "license": "MIT", "engines": { - "node": ">=8" - } - }, + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/@istanbuljs/load-nyc-config": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", + "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "camelcase": "^5.3.1", + "find-up": "^4.1.0", + "get-package-type": "^0.1.0", + "js-yaml": "^3.13.1", + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/@istanbuljs/load-nyc-config/node_modules/argparse": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", @@ -3465,6 +3852,137 @@ } } }, + "node_modules/@jest/core/node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@jest/core/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@jest/core/node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@jest/core/node_modules/jest-config": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-30.2.0.tgz", + "integrity": "sha512-g4WkyzFQVWHtu6uqGmQR4CQxz/CH3yDSlhzXMWzNjDx843gYjReZnMRanjRCq5XZFuQrGDxgUaiYWE8BRfVckA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.27.4", + "@jest/get-type": "30.1.0", + "@jest/pattern": "30.0.1", + "@jest/test-sequencer": "30.2.0", + "@jest/types": "30.2.0", + "babel-jest": "30.2.0", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "deepmerge": "^4.3.1", + "glob": "^10.3.10", + "graceful-fs": "^4.2.11", + "jest-circus": "30.2.0", + "jest-docblock": "30.2.0", + "jest-environment-node": "30.2.0", + "jest-regex-util": "30.0.1", + "jest-resolve": "30.2.0", + "jest-runner": "30.2.0", + "jest-util": "30.2.0", + "jest-validate": "30.2.0", + "micromatch": "^4.0.8", + "parse-json": "^5.2.0", + "pretty-format": "30.2.0", + "slash": "^3.0.0", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "@types/node": "*", + "esbuild-register": ">=3.4.0", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "esbuild-register": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/@jest/core/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/@jest/diff-sequences": { "version": "30.0.1", "resolved": "https://registry.npmjs.org/@jest/diff-sequences/-/diff-sequences-30.0.1.tgz", @@ -3619,36 +4137,84 @@ } } }, - "node_modules/@jest/schemas": { - "version": "30.0.5", - "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.0.5.tgz", - "integrity": "sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA==", + "node_modules/@jest/reporters/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", "dev": true, "license": "MIT", "dependencies": { - "@sinclair/typebox": "^0.34.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "balanced-match": "^1.0.0" } }, - "node_modules/@jest/snapshot-utils": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/snapshot-utils/-/snapshot-utils-30.2.0.tgz", - "integrity": "sha512-0aVxM3RH6DaiLcjj/b0KrIBZhSX1373Xci4l3cW5xiUWPctZ59zQ7jj4rqcJQ/Z8JuN/4wX3FpJSa3RssVvCug==", + "node_modules/@jest/reporters/node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", "dev": true, - "license": "MIT", + "license": "ISC", "dependencies": { - "@jest/types": "30.2.0", - "chalk": "^4.1.2", - "graceful-fs": "^4.2.11", - "natural-compare": "^1.4.0" + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/@jest/source-map": { + "node_modules/@jest/reporters/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@jest/schemas": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.0.5.tgz", + "integrity": "sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.34.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/snapshot-utils": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/snapshot-utils/-/snapshot-utils-30.2.0.tgz", + "integrity": "sha512-0aVxM3RH6DaiLcjj/b0KrIBZhSX1373Xci4l3cW5xiUWPctZ59zQ7jj4rqcJQ/Z8JuN/4wX3FpJSa3RssVvCug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.2.0", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "natural-compare": "^1.4.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/source-map": { "version": "30.0.1", "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-30.0.1.tgz", "integrity": "sha512-MIRWMUUR3sdbP36oyNyhbThLHyJ2eEDClPCiHVbrYAe5g3CHRArIVpBw7cdSB5fr+ofSfIb2Tnsw8iEHL0PYQg==", @@ -3723,21 +4289,21 @@ } }, "node_modules/@jest/transform/node_modules/@babel/core": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.6.tgz", - "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/generator": "^7.28.6", + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", - "@babel/parser": "^7.28.6", + "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", - "@babel/traverse": "^7.28.6", - "@babel/types": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", @@ -3863,12 +4429,13 @@ "resolved": "https://registry.npmjs.org/@next/env/-/env-16.1.6.tgz", "integrity": "sha512-N1ySLuZjnAtN3kFnwhAwPvZah8RJxKasD7x1f8shFqhncnWZn4JMfg37diLNuoHsLAlrDfM3g4mawVdtAG8XLQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@next/eslint-plugin-next": { - "version": "15.5.11", - "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-15.5.11.tgz", - "integrity": "sha512-tS/HYQOjIoX9ZNDQitba/baS8sTvo3ekY6Vgdx5lmhN4jov082bdApIChXr94qhMZHvEciz9DZglFFnhguQp/A==", + "version": "15.5.12", + "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-15.5.12.tgz", + "integrity": "sha512-+ZRSDFTv4aC96aMb5E41rMjysx8ApkryevnvEYZvPZO52KvkqP5rNExLUXJFr9P4s0f3oqNQR6vopCZsPWKDcQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3888,6 +4455,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">= 10" } @@ -3905,6 +4473,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">= 10" } @@ -3922,6 +4491,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 10" } @@ -3939,6 +4509,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 10" } @@ -3956,6 +4527,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 10" } @@ -3973,6 +4545,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 10" } @@ -3990,6 +4563,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">= 10" } @@ -4007,6 +4581,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">= 10" } @@ -4442,6 +5017,39 @@ "node": ">= 12" } }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz", + "integrity": "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.57.1", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", @@ -4820,16 +5428,16 @@ } }, "node_modules/@storybook/addon-docs": { - "version": "10.2.8", - "resolved": "https://registry.npmjs.org/@storybook/addon-docs/-/addon-docs-10.2.8.tgz", - "integrity": "sha512-cEoWqQrLzrxOwZFee5zrD4cYrdEWKV80POb7jUZO0r5vfl2DuslIr3n/+RfLT52runCV4aZcFEfOfP/IWHNPxg==", + "version": "10.2.10", + "resolved": "https://registry.npmjs.org/@storybook/addon-docs/-/addon-docs-10.2.10.tgz", + "integrity": "sha512-2wIYtdvZIzPbQ5194M5Igpy8faNbQ135nuO5ZaZ2VuttqGr+IJcGnDP42zYwbAsGs28G8ohpkbSgIzVyJWUhPQ==", "dev": true, "license": "MIT", "dependencies": { "@mdx-js/react": "^3.0.0", - "@storybook/csf-plugin": "10.2.8", + "@storybook/csf-plugin": "10.2.10", "@storybook/icons": "^2.0.1", - "@storybook/react-dom-shim": "10.2.8", + "@storybook/react-dom-shim": "10.2.10", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "ts-dedent": "^2.0.0" @@ -4839,13 +5447,13 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { - "storybook": "^10.2.8" + "storybook": "^10.2.10" } }, "node_modules/@storybook/addon-docs/node_modules/@storybook/csf-plugin": { - "version": "10.2.8", - "resolved": "https://registry.npmjs.org/@storybook/csf-plugin/-/csf-plugin-10.2.8.tgz", - "integrity": "sha512-kKkLYhRXb33YtIPdavD2DU25sb14sqPYdcQFpyqu4TaD9truPPqW8P5PLTUgERydt/eRvRlnhauPHavU1kjsnA==", + "version": "10.2.10", + "resolved": "https://registry.npmjs.org/@storybook/csf-plugin/-/csf-plugin-10.2.10.tgz", + "integrity": "sha512-aFvgaNDAnKMjuyhPK5ialT22pPqMN0XfPBNPeeNVPYztngkdKBa8WFqF/umDd47HxAjebq+vn6uId1xHyOHH3g==", "dev": true, "license": "MIT", "dependencies": { @@ -4858,7 +5466,7 @@ "peerDependencies": { "esbuild": "*", "rollup": "*", - "storybook": "^10.2.8", + "storybook": "^10.2.10", "vite": "*", "webpack": "*" }, @@ -4878,9 +5486,9 @@ } }, "node_modules/@storybook/addon-links": { - "version": "10.2.8", - "resolved": "https://registry.npmjs.org/@storybook/addon-links/-/addon-links-10.2.8.tgz", - "integrity": "sha512-5yy8+6z1OLxrQGpLzwIChO53hCMGVMMrRSG98IslMzhExEbK4+prf6gKMA0t4SdWAjkKgzbRz2YNnv9N6rEO5Q==", + "version": "10.2.10", + "resolved": "https://registry.npmjs.org/@storybook/addon-links/-/addon-links-10.2.10.tgz", + "integrity": "sha512-oo9Xx4/2OVJtptXKpqH4ySri7ZuBdiSOXlZVGejEfLa0Jeajlh/KIlREpGvzPPOqUVT7dSddWzBjJmJUyQC3ew==", "dev": true, "license": "MIT", "dependencies": { @@ -4892,7 +5500,7 @@ }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "storybook": "^10.2.8" + "storybook": "^10.2.10" }, "peerDependenciesMeta": { "react": { @@ -4901,13 +5509,13 @@ } }, "node_modules/@storybook/builder-webpack5": { - "version": "10.2.8", - "resolved": "https://registry.npmjs.org/@storybook/builder-webpack5/-/builder-webpack5-10.2.8.tgz", - "integrity": "sha512-77i/is0a4HIRwkcxs3wQnQCnIahLONKxSp0cURjBU38kj/M0ukOOlOPIIJOm4HgI202yLjvGNiaMcLWFxHfl8w==", + "version": "10.2.10", + "resolved": "https://registry.npmjs.org/@storybook/builder-webpack5/-/builder-webpack5-10.2.10.tgz", + "integrity": "sha512-bIHAXiX9NwZlB5dJ2W+rZcwo1Dkmg0JOwL/F/rB9O4IlkjTsoOe/+BcLchfRdqRk7ENCVFNwaq8aXxnKmiIOMQ==", "dev": true, "license": "MIT", "dependencies": { - "@storybook/core-webpack": "10.2.8", + "@storybook/core-webpack": "10.2.10", "case-sensitive-paths-webpack-plugin": "^2.4.0", "cjs-module-lexer": "^1.2.3", "css-loader": "^7.1.2", @@ -4928,7 +5536,7 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { - "storybook": "^10.2.8" + "storybook": "^10.2.10" }, "peerDependenciesMeta": { "typescript": { @@ -4937,9 +5545,9 @@ } }, "node_modules/@storybook/builder-webpack5/node_modules/css-loader": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/css-loader/-/css-loader-7.1.3.tgz", - "integrity": "sha512-frbERmjT0UC5lMheWpJmMilnt9GEhbZJN/heUb7/zaJYeIzj5St9HvDcfshzzOqbsS+rYpMk++2SD3vGETDSyA==", + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/css-loader/-/css-loader-7.1.4.tgz", + "integrity": "sha512-vv3J9tlOl04WjiMvHQI/9tmIrCxVrj6PFbHemBB1iihpeRbi/I4h033eoFIhwxBBqLhI0KYFS7yvynBFhIZfTw==", "dev": true, "license": "MIT", "dependencies": { @@ -4960,7 +5568,7 @@ "url": "https://opencollective.com/webpack" }, "peerDependencies": { - "@rspack/core": "0.x || 1.x", + "@rspack/core": "0.x || ^1.0.0 || ^2.0.0-0", "webpack": "^5.27.0" }, "peerDependenciesMeta": { @@ -5003,9 +5611,9 @@ } }, "node_modules/@storybook/core-webpack": { - "version": "10.2.8", - "resolved": "https://registry.npmjs.org/@storybook/core-webpack/-/core-webpack-10.2.8.tgz", - "integrity": "sha512-TmKUbFVxDEoCybFC9Ps6gfcbZnKCc4DIclmIxEnkzKUuP0I6gh5w5Xd4Uf1hXroWIzZPNtm0SWsNOKycP+FQqQ==", + "version": "10.2.10", + "resolved": "https://registry.npmjs.org/@storybook/core-webpack/-/core-webpack-10.2.10.tgz", + "integrity": "sha512-bhz20jQWn0UB6GfYeO3oou8w8jXSVs+dgPglsxPr+tOusUuyT5FO270PHixZovVtrHgFAKHLXUEHUNuOvUsMig==", "dev": true, "license": "MIT", "dependencies": { @@ -5016,7 +5624,7 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { - "storybook": "^10.2.8" + "storybook": "^10.2.10" } }, "node_modules/@storybook/global": { @@ -5038,9 +5646,9 @@ } }, "node_modules/@storybook/nextjs": { - "version": "10.2.8", - "resolved": "https://registry.npmjs.org/@storybook/nextjs/-/nextjs-10.2.8.tgz", - "integrity": "sha512-CZqHsNqMYbw9tK2pUfSzpc7VVBaeAticpw4lnxUANxW3nCTpX82wrQGj4bRWqZL3WfUMw8WfdAC4htJo5kLVBA==", + "version": "10.2.10", + "resolved": "https://registry.npmjs.org/@storybook/nextjs/-/nextjs-10.2.10.tgz", + "integrity": "sha512-OTyghwvsvXpAtcZcY7XNUKGC1hJQKmz7x/y56h7kIedVrw+v8UZA1wu+obmml+QCkPXOAMS5GQzXIkNJGxyYTw==", "dev": true, "license": "MIT", "dependencies": { @@ -5058,9 +5666,9 @@ "@babel/preset-typescript": "^7.28.5", "@babel/runtime": "^7.28.4", "@pmmmwh/react-refresh-webpack-plugin": "^0.5.11", - "@storybook/builder-webpack5": "10.2.8", - "@storybook/preset-react-webpack": "10.2.8", - "@storybook/react": "10.2.8", + "@storybook/builder-webpack5": "10.2.10", + "@storybook/preset-react-webpack": "10.2.10", + "@storybook/react": "10.2.10", "@types/semver": "^7.7.1", "babel-loader": "^9.1.3", "css-loader": "^6.7.3", @@ -5086,7 +5694,7 @@ "next": "^14.1.0 || ^15.0.0 || ^16.0.0", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "storybook": "^10.2.8", + "storybook": "^10.2.10", "webpack": "^5.0.0" }, "peerDependenciesMeta": { @@ -5104,7 +5712,6 @@ "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", @@ -5304,13 +5911,13 @@ } }, "node_modules/@storybook/preset-react-webpack": { - "version": "10.2.8", - "resolved": "https://registry.npmjs.org/@storybook/preset-react-webpack/-/preset-react-webpack-10.2.8.tgz", - "integrity": "sha512-R+w1aT+NQ2eXHkPRpVnt/aBk5V5/L7+1EhFTnyQaEcviIanPlRURKhbOQi02gSGW/alekMLKtSvPTzow/VyvRA==", + "version": "10.2.10", + "resolved": "https://registry.npmjs.org/@storybook/preset-react-webpack/-/preset-react-webpack-10.2.10.tgz", + "integrity": "sha512-DaV7uKpNF/2iBjcGL81HA7Kx8ZZb9D4MfG1VxpdtmDOKS20YIDNdCFeUbcAkUlG3lhshUGcGL8YiRp3o4b1X6Q==", "dev": true, "license": "MIT", "dependencies": { - "@storybook/core-webpack": "10.2.8", + "@storybook/core-webpack": "10.2.10", "@storybook/react-docgen-typescript-plugin": "1.0.6--canary.9.0c3f3b7.0", "@types/semver": "^7.7.1", "magic-string": "^0.30.5", @@ -5327,7 +5934,7 @@ "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "storybook": "^10.2.8" + "storybook": "^10.2.10" }, "peerDependenciesMeta": { "typescript": { @@ -5349,14 +5956,14 @@ } }, "node_modules/@storybook/react": { - "version": "10.2.8", - "resolved": "https://registry.npmjs.org/@storybook/react/-/react-10.2.8.tgz", - "integrity": "sha512-nMFqQFUXq6Zg2O5SeuomyWnrIx61QfpNQMrfor8eCEzHrWNnXrrvVsz2RnHIgXN8RVyaWGDPh1srAECu/kDHXw==", + "version": "10.2.10", + "resolved": "https://registry.npmjs.org/@storybook/react/-/react-10.2.10.tgz", + "integrity": "sha512-PcsChzPI8lhllB9exV7nFb96093i6sTwIl0jpPjaTFPQCRoueR9E/YeP3qSKQL9xt4cmii0cW7F0RUx25rW93Q==", "dev": true, "license": "MIT", "dependencies": { "@storybook/global": "^5.0.0", - "@storybook/react-dom-shim": "10.2.8", + "@storybook/react-dom-shim": "10.2.10", "react-docgen": "^8.0.2" }, "funding": { @@ -5366,7 +5973,7 @@ "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "storybook": "^10.2.8", + "storybook": "^10.2.10", "typescript": ">= 4.9.x" }, "peerDependenciesMeta": { @@ -5396,9 +6003,9 @@ } }, "node_modules/@storybook/react-dom-shim": { - "version": "10.2.8", - "resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-10.2.8.tgz", - "integrity": "sha512-Xde9X3VszFV1pTXfc2ZFM89XOCGRxJD8MUIzDwkcT9xaki5a+8srs/fsXj75fMY6gMYfcL5lNRZvCqg37HOmcQ==", + "version": "10.2.10", + "resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-10.2.10.tgz", + "integrity": "sha512-TmBrhyLHn8B8rvDHKk5uW5BqzO1M1T+fqFNWg88NIAJOoyX4Uc90FIJjDuN1OJmWKGwB5vLmPwaKBYsTe1yS+w==", "dev": true, "license": "MIT", "funding": { @@ -5408,7 +6015,7 @@ "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "storybook": "^10.2.8" + "storybook": "^10.2.10" } }, "node_modules/@storybook/react/node_modules/@babel/core": { @@ -5510,12 +6117,44 @@ "eslint": ">=8.40.0" } }, + "node_modules/@stylistic/eslint-plugin/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@stylistic/eslint-plugin/node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, "node_modules/@swc/helpers": { "version": "0.5.15", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "tslib": "^2.8.0" } @@ -5834,6 +6473,7 @@ "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", @@ -5848,22 +6488,13 @@ "node": ">=18" } }, - "node_modules/@testing-library/dom/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/@testing-library/dom/node_modules/ansi-styles": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10" }, @@ -5877,6 +6508,7 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -5891,7 +6523,8 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@testing-library/jest-dom": { "version": "6.9.1", @@ -5949,7 +6582,8 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -6043,6 +6677,13 @@ "@types/estree": "*" } }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -6123,9 +6764,8 @@ "version": "19.2.3", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.3.tgz", "integrity": "sha512-k5dJVszUiNr1DSe8Cs+knKR6IrqhqdhpUwzqhkS8ecQTSf3THNtbfIp/umqHMpX2bv+9dkx3fwDv/86LcSfvSg==", - "dev": true, + "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -6186,17 +6826,17 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.54.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.54.0.tgz", - "integrity": "sha512-hAAP5io/7csFStuOmR782YmTthKBJ9ND3WVL60hcOjvtGFb+HJxH4O5huAcmcZ9v9G8P+JETiZ/G1B8MALnWZQ==", + "version": "8.56.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.0.tgz", + "integrity": "sha512-lRyPDLzNCuae71A3t9NEINBiTn7swyOhvUj3MyUOxb8x6g6vPEFoOU+ZRmGMusNC3X3YMhqMIX7i8ShqhT74Pw==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.54.0", - "@typescript-eslint/type-utils": "8.54.0", - "@typescript-eslint/utils": "8.54.0", - "@typescript-eslint/visitor-keys": "8.54.0", + "@typescript-eslint/scope-manager": "8.56.0", + "@typescript-eslint/type-utils": "8.56.0", + "@typescript-eslint/utils": "8.56.0", + "@typescript-eslint/visitor-keys": "8.56.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.4.0" @@ -6209,8 +6849,8 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.54.0", - "eslint": "^8.57.0 || ^9.0.0", + "@typescript-eslint/parser": "^8.56.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, @@ -6225,17 +6865,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.54.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.54.0.tgz", - "integrity": "sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==", + "version": "8.56.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.56.0.tgz", + "integrity": "sha512-IgSWvLobTDOjnaxAfDTIHaECbkNlAlKv2j5SjpB2v7QHKv1FIfjwMy8FsDbVfDX/KjmCmYICcw7uGaXLhtsLNg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "@typescript-eslint/scope-manager": "8.54.0", - "@typescript-eslint/types": "8.54.0", - "@typescript-eslint/typescript-estree": "8.54.0", - "@typescript-eslint/visitor-keys": "8.54.0", + "@typescript-eslint/scope-manager": "8.56.0", + "@typescript-eslint/types": "8.56.0", + "@typescript-eslint/typescript-estree": "8.56.0", + "@typescript-eslint/visitor-keys": "8.56.0", "debug": "^4.4.3" }, "engines": { @@ -6246,19 +6885,19 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.54.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.54.0.tgz", - "integrity": "sha512-YPf+rvJ1s7MyiWM4uTRhE4DvBXrEV+d8oC3P9Y2eT7S+HBS0clybdMIPnhiATi9vZOYDc7OQ1L/i6ga6NFYK/g==", + "version": "8.56.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.56.0.tgz", + "integrity": "sha512-M3rnyL1vIQOMeWxTWIW096/TtVP+8W3p/XnaFflhmcFp+U4zlxUxWj4XwNs6HbDeTtN4yun0GNTTDBw/SvufKg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.54.0", - "@typescript-eslint/types": "^8.54.0", + "@typescript-eslint/tsconfig-utils": "^8.56.0", + "@typescript-eslint/types": "^8.56.0", "debug": "^4.4.3" }, "engines": { @@ -6273,14 +6912,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.54.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.54.0.tgz", - "integrity": "sha512-27rYVQku26j/PbHYcVfRPonmOlVI6gihHtXFbTdB5sb6qA0wdAQAbyXFVarQ5t4HRojIz64IV90YtsjQSSGlQg==", + "version": "8.56.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.56.0.tgz", + "integrity": "sha512-7UiO/XwMHquH+ZzfVCfUNkIXlp/yQjjnlYUyYz7pfvlK3/EyyN6BK+emDmGNyQLBtLGaYrTAI6KOw8tFucWL2w==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.54.0", - "@typescript-eslint/visitor-keys": "8.54.0" + "@typescript-eslint/types": "8.56.0", + "@typescript-eslint/visitor-keys": "8.56.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -6291,9 +6930,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.54.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.54.0.tgz", - "integrity": "sha512-dRgOyT2hPk/JwxNMZDsIXDgyl9axdJI3ogZ2XWhBPsnZUv+hPesa5iuhdYt2gzwA9t8RE5ytOJ6xB0moV0Ujvw==", + "version": "8.56.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.56.0.tgz", + "integrity": "sha512-bSJoIIt4o3lKXD3xmDh9chZcjCz5Lk8xS7Rxn+6l5/pKrDpkCwtQNQQwZ2qRPk7TkUYhrq3WPIHXOXlbXP0itg==", "dev": true, "license": "MIT", "engines": { @@ -6308,15 +6947,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.54.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.54.0.tgz", - "integrity": "sha512-hiLguxJWHjjwL6xMBwD903ciAwd7DmK30Y9Axs/etOkftC3ZNN9K44IuRD/EB08amu+Zw6W37x9RecLkOo3pMA==", + "version": "8.56.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.56.0.tgz", + "integrity": "sha512-qX2L3HWOU2nuDs6GzglBeuFXviDODreS58tLY/BALPC7iu3Fa+J7EOTwnX9PdNBxUI7Uh0ntP0YWGnxCkXzmfA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.54.0", - "@typescript-eslint/typescript-estree": "8.54.0", - "@typescript-eslint/utils": "8.54.0", + "@typescript-eslint/types": "8.56.0", + "@typescript-eslint/typescript-estree": "8.56.0", + "@typescript-eslint/utils": "8.56.0", "debug": "^4.4.3", "ts-api-utils": "^2.4.0" }, @@ -6328,14 +6967,14 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/types": { - "version": "8.54.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.54.0.tgz", - "integrity": "sha512-PDUI9R1BVjqu7AUDsRBbKMtwmjWcn4J3le+5LpcFgWULN3LvHC5rkc9gCVxbrsrGmO1jfPybN5s6h4Jy+OnkAA==", + "version": "8.56.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.56.0.tgz", + "integrity": "sha512-DBsLPs3GsWhX5HylbP9HNG15U0bnwut55Lx12bHB9MpXxQ+R5GC8MwQe+N1UFXxAeQDvEsEDY6ZYwX03K7Z6HQ==", "dev": true, "license": "MIT", "engines": { @@ -6347,16 +6986,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.54.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.54.0.tgz", - "integrity": "sha512-BUwcskRaPvTk6fzVWgDPdUndLjB87KYDrN5EYGetnktoeAvPtO4ONHlAZDnj5VFnUANg0Sjm7j4usBlnoVMHwA==", + "version": "8.56.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.56.0.tgz", + "integrity": "sha512-ex1nTUMWrseMltXUHmR2GAQ4d+WjkZCT4f+4bVsps8QEdh0vlBsaCokKTPlnqBFqqGaxilDNJG7b8dolW2m43Q==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.54.0", - "@typescript-eslint/tsconfig-utils": "8.54.0", - "@typescript-eslint/types": "8.54.0", - "@typescript-eslint/visitor-keys": "8.54.0", + "@typescript-eslint/project-service": "8.56.0", + "@typescript-eslint/tsconfig-utils": "8.56.0", + "@typescript-eslint/types": "8.56.0", + "@typescript-eslint/visitor-keys": "8.56.0", "debug": "^4.4.3", "minimatch": "^9.0.5", "semver": "^7.7.3", @@ -6374,10 +7013,36 @@ "typescript": ">=4.8.4 <6.0.0" } }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "dev": true, "license": "ISC", "bin": { @@ -6388,16 +7053,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.54.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.54.0.tgz", - "integrity": "sha512-9Cnda8GS57AQakvRyG0PTejJNlA2xhvyNtEVIMlDWOOeEyBkYWhGPnfrIAnqxLMTSTo6q8g12XVjjev5l1NvMA==", + "version": "8.56.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.56.0.tgz", + "integrity": "sha512-RZ3Qsmi2nFGsS+n+kjLAYDPVlrzf7UhTffrDIKr+h2yzAlYP/y5ZulU0yeDEPItos2Ph46JAL5P/On3pe7kDIQ==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.54.0", - "@typescript-eslint/types": "8.54.0", - "@typescript-eslint/typescript-estree": "8.54.0" + "@typescript-eslint/scope-manager": "8.56.0", + "@typescript-eslint/types": "8.56.0", + "@typescript-eslint/typescript-estree": "8.56.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -6407,19 +7072,19 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.54.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.54.0.tgz", - "integrity": "sha512-VFlhGSl4opC0bprJiItPQ1RfUhGDIBokcPwaFH4yiBCaNPeld/9VeXbiPO1cLyorQi1G1vL+ecBk1x8o1axORA==", + "version": "8.56.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.56.0.tgz", + "integrity": "sha512-q+SL+b+05Ud6LbEE35qe4A99P+htKTKVbyiNEe45eCbJFyh/HVK9QXwlrbz+Q4L8SOW4roxSVwXYj4DMBT7Ieg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.54.0", - "eslint-visitor-keys": "^4.2.1" + "@typescript-eslint/types": "8.56.0", + "eslint-visitor-keys": "^5.0.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -6989,12 +7654,11 @@ } }, "node_modules/acorn": { - "version": "8.15.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", - "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -7055,12 +7719,11 @@ } }, "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -7091,9 +7754,9 @@ } }, "node_modules/ajv-formats/node_modules/ajv": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", - "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "dev": true, "license": "MIT", "dependencies": { @@ -7167,16 +7830,13 @@ } }, "node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, "license": "MIT", "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" + "node": ">=8" } }, "node_modules/ansi-styles": { @@ -7397,9 +8057,9 @@ } }, "node_modules/asn1.js/node_modules/bn.js": { - "version": "4.12.2", - "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz", - "integrity": "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==", + "version": "4.12.3", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz", + "integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==", "dev": true, "license": "MIT" }, @@ -7832,9 +8492,9 @@ } }, "node_modules/bn.js": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.2.tgz", - "integrity": "sha512-v2YAxEmKaBLahNwE1mjp4WON6huMNeuDvagFZW+ASCuA/ku0bXR9hSMw0XpiqMoA3+rmnyck/tPRSFQkoC9Cuw==", + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.3.tgz", + "integrity": "sha512-EAcmnPkxpntVL+DS7bO1zhcZNvCkxqtkd0ZY53h06GNQ3DEkkGZ/gKgmDv6DdZQGj9BgfSPKtJJ7Dp1GPP8f7w==", "dev": true, "license": "MIT" }, @@ -7846,13 +8506,26 @@ "license": "ISC" }, "node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.2.tgz", + "integrity": "sha512-Pdk8c9poy+YhOgVWw1JNN22/HcivgKWwpxKq04M/jTmHyCZn12WPJebZxdjSa5TmBqISrUSgNYU3eRORljfCCw==", "dev": true, "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0" + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/brace-expansion/node_modules/balanced-match": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.3.tgz", + "integrity": "sha512-1pHv8LX9CpKut1Zp4EXey7Z8OfH11ONNH6Dhi2WDUt31VVZFXZzKwXcysBgqSumFCmR+0dqjMK5v5JiFHzi0+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": "20 || >=22" } }, "node_modules/braces": { @@ -8028,7 +8701,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -8419,113 +9091,50 @@ "node": ">=12" } }, - "node_modules/cliui/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/co": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", + "integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==", "dev": true, "license": "MIT", "engines": { - "node": ">=8" + "iojs": ">= 1.0.0", + "node": ">= 0.12.0" } }, - "node_modules/cliui/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "node_modules/collect-v8-coverage": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.3.tgz", + "integrity": "sha512-1L5aqIkwPfiodaMgQunkF1zRhNqifHBmtbbbxcr6yVxxBnliw4TDOW6NxpO8DJLgJ16OT+Y4ztZqP6p/FtXnAw==", "dev": true, "license": "MIT" }, - "node_modules/cliui/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "dev": true, "license": "MIT", "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" + "color-name": "~1.1.4" }, "engines": { - "node": ">=8" + "node": ">=7.0.0" } }, - "node_modules/cliui/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/cliui/node_modules/wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/clsx": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", - "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/co": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", - "integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==", - "dev": true, - "license": "MIT", - "engines": { - "iojs": ">= 1.0.0", - "node": ">= 0.12.0" - } - }, - "node_modules/collect-v8-coverage": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.3.tgz", - "integrity": "sha512-1L5aqIkwPfiodaMgQunkF1zRhNqifHBmtbbbxcr6yVxxBnliw4TDOW6NxpO8DJLgJ16OT+Y4ztZqP6p/FtXnAw==", - "dev": true, - "license": "MIT" - }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "dev": true, "license": "MIT" }, @@ -8676,9 +9285,9 @@ } }, "node_modules/create-ecdh/node_modules/bn.js": { - "version": "4.12.2", - "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz", - "integrity": "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==", + "version": "4.12.3", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz", + "integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==", "dev": true, "license": "MIT" }, @@ -8856,7 +9465,7 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/data-view-buffer": { @@ -9097,9 +9706,9 @@ } }, "node_modules/diffie-hellman/node_modules/bn.js": { - "version": "4.12.2", - "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz", - "integrity": "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==", + "version": "4.12.3", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz", + "integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==", "dev": true, "license": "MIT" }, @@ -9121,7 +9730,8 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/dom-converter": { "version": "0.2.0", @@ -9262,9 +9872,9 @@ } }, "node_modules/elliptic/node_modules/bn.js": { - "version": "4.12.2", - "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz", - "integrity": "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==", + "version": "4.12.3", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz", + "integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==", "dev": true, "license": "MIT" }, @@ -9282,9 +9892,9 @@ } }, "node_modules/emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "dev": true, "license": "MIT" }, @@ -9554,7 +10164,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -9614,35 +10223,30 @@ } }, "node_modules/eslint": { - "version": "9.31.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.31.0.tgz", - "integrity": "sha512-QldCVh/ztyKJJZLr4jXNUByx3gR+TDYZCRXEktiZoUR3PGy4qCmSbkxcIle8GEwGpb5JBZazlaJ/CxLidXdEbQ==", + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.0.1.tgz", + "integrity": "sha512-20MV9SUdeN6Jd84xESsKhRly+/vxI+hwvpBMA93s+9dAcjdCuCojn4IqUGS3lvVaqjVYGYHSRMCpeFtF2rQYxQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "@eslint-community/eslint-utils": "^4.2.0", - "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.21.0", - "@eslint/config-helpers": "^0.3.0", - "@eslint/core": "^0.15.0", - "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.31.0", - "@eslint/plugin-kit": "^0.3.1", + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.2", + "@eslint/config-helpers": "^0.5.2", + "@eslint/core": "^1.1.0", + "@eslint/plugin-kit": "^0.6.0", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", - "@types/json-schema": "^7.0.15", "ajv": "^6.12.4", - "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", - "eslint-scope": "^8.4.0", - "eslint-visitor-keys": "^4.2.1", - "espree": "^10.4.0", - "esquery": "^1.5.0", + "eslint-scope": "^9.1.1", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.1.1", + "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", @@ -9652,8 +10256,7 @@ "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", - "lodash.merge": "^4.6.2", - "minimatch": "^3.1.2", + "minimatch": "^10.2.1", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, @@ -9661,7 +10264,7 @@ "eslint": "bin/eslint.js" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://eslint.org/donate" @@ -9675,98 +10278,10 @@ } } }, - "node_modules/eslint-plugin-react": { - "version": "7.37.5", - "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz", - "integrity": "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==", - "dev": true, - "license": "MIT", - "dependencies": { - "array-includes": "^3.1.8", - "array.prototype.findlast": "^1.2.5", - "array.prototype.flatmap": "^1.3.3", - "array.prototype.tosorted": "^1.1.4", - "doctrine": "^2.1.0", - "es-iterator-helpers": "^1.2.1", - "estraverse": "^5.3.0", - "hasown": "^2.0.2", - "jsx-ast-utils": "^2.4.1 || ^3.0.0", - "minimatch": "^3.1.2", - "object.entries": "^1.1.9", - "object.fromentries": "^2.0.8", - "object.values": "^1.2.1", - "prop-types": "^15.8.1", - "resolve": "^2.0.0-next.5", - "semver": "^6.3.1", - "string.prototype.matchall": "^4.0.12", - "string.prototype.repeat": "^1.0.0" - }, - "engines": { - "node": ">=4" - }, - "peerDependencies": { - "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" - } - }, - "node_modules/eslint-plugin-react-hooks": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.2.0.tgz", - "integrity": "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" - } - }, - "node_modules/eslint-plugin-react/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/eslint-plugin-react/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/eslint-plugin-react/node_modules/resolve": { - "version": "2.0.0-next.5", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.5.tgz", - "integrity": "sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-core-module": "^2.13.0", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/eslint-plugin-storybook": { - "version": "10.2.8", - "resolved": "https://registry.npmjs.org/eslint-plugin-storybook/-/eslint-plugin-storybook-10.2.8.tgz", - "integrity": "sha512-BtysXrg1RoYT3DIrCc+svZ0+L3mbWsu7suxTLGrihBY5HfWHkJge+qjlBBR1Nm2ZMslfuFS5K0NUWbWCJRu6kg==", + "version": "10.2.10", + "resolved": "https://registry.npmjs.org/eslint-plugin-storybook/-/eslint-plugin-storybook-10.2.10.tgz", + "integrity": "sha512-aWkoh2rhTaEsMA4yB1iVIcISM5wb0uffp09ZqhwpoD4GAngCs131uq6un+QdnOMc7vXyAnBBfsuhtOj8WwCUgw==", "dev": true, "license": "MIT", "dependencies": { @@ -9774,89 +10289,54 @@ }, "peerDependencies": { "eslint": ">=8", - "storybook": "^10.2.8" + "storybook": "^10.2.10" } }, "node_modules/eslint-scope": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", - "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "version": "9.1.1", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.1.tgz", + "integrity": "sha512-GaUN0sWim5qc8KVErfPBWmc31LEsOkrUJbvJZV+xuL3u2phMUK4HIvXlWAakfC8W4nzlK+chPEAkYOYb5ZScIw==", "dev": true, "license": "BSD-2-Clause", "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", "esrecurse": "^4.3.0", "estraverse": "^5.2.0" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://opencollective.com/eslint" } }, "node_modules/eslint-visitor-keys": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", - "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", "dev": true, "license": "Apache-2.0", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://opencollective.com/eslint" } }, - "node_modules/eslint/node_modules/@eslint/js": { - "version": "9.31.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.31.0.tgz", - "integrity": "sha512-LOm5OVt7D4qiKCqoiPbA7LWmI+tbw1VbTUowBcUMgQSuM6poJufkFkYDcQpo5KfgD39TnNySV26QjOh7VFpSyw==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://eslint.org/donate" - } - }, - "node_modules/eslint/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/eslint/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, "node_modules/espree": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", - "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "version": "11.1.1", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.1.1.tgz", + "integrity": "sha512-AVHPqQoZYc+RUM4/3Ly5udlZY/U4LS8pIG05jEjWM2lQMU/oaZ7qshzAl2YP1tfNmXfftH3ohurfwNAug+MnsQ==", "dev": true, "license": "BSD-2-Clause", "dependencies": { - "acorn": "^8.15.0", + "acorn": "^8.16.0", "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^4.2.1" + "eslint-visitor-keys": "^5.0.1" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://opencollective.com/eslint" @@ -9987,13 +10467,6 @@ "url": "https://github.com/sindresorhus/execa?sponsor=1" } }, - "node_modules/execa/node_modules/signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "dev": true, - "license": "ISC" - }, "node_modules/exit-x": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/exit-x/-/exit-x-0.2.2.tgz", @@ -10287,6 +10760,19 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/foreground-child/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/fork-ts-checker-webpack-plugin": { "version": "9.1.0", "resolved": "https://registry.npmjs.org/fork-ts-checker-webpack-plugin/-/fork-ts-checker-webpack-plugin-9.1.0.tgz", @@ -10581,21 +11067,22 @@ } }, "node_modules/glob": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", - "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", "dev": true, "license": "ISC", "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" - }, - "bin": { - "glob": "dist/esm/bin.mjs" + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -10621,6 +11108,30 @@ "dev": true, "license": "BSD-2-Clause" }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/globals": { "version": "14.0.0", "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", @@ -11654,9 +12165,9 @@ } }, "node_modules/istanbul-lib-instrument/node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "dev": true, "license": "ISC", "bin": { @@ -11698,9 +12209,9 @@ } }, "node_modules/istanbul-lib-report/node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "dev": true, "license": "ISC", "bin": { @@ -11779,7 +12290,6 @@ "integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/core": "30.2.0", "@jest/types": "30.2.0", @@ -11896,7 +12406,70 @@ } } }, - "node_modules/jest-config": { + "node_modules/jest-cli/node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/jest-cli/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/jest-cli/node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/jest-cli/node_modules/jest-config": { "version": "30.2.0", "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-30.2.0.tgz", "integrity": "sha512-g4WkyzFQVWHtu6uqGmQR4CQxz/CH3yDSlhzXMWzNjDx843gYjReZnMRanjRCq5XZFuQrGDxgUaiYWE8BRfVckA==", @@ -11948,35 +12521,20 @@ } } }, - "node_modules/jest-config/node_modules/@babel/core": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.6.tgz", - "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==", + "node_modules/jest-cli/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", "dev": true, - "license": "MIT", + "license": "ISC", "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/generator": "^7.28.6", - "@babel/helper-compilation-targets": "^7.28.6", - "@babel/helper-module-transforms": "^7.28.6", - "@babel/helpers": "^7.28.6", - "@babel/parser": "^7.28.6", - "@babel/template": "^7.28.6", - "@babel/traverse": "^7.28.6", - "@babel/types": "^7.28.6", - "@jridgewell/remapping": "^2.3.5", - "convert-source-map": "^2.0.0", - "debug": "^4.1.0", - "gensync": "^1.0.0-beta.2", - "json5": "^2.2.3", - "semver": "^6.3.1" + "brace-expansion": "^2.0.1" }, "engines": { - "node": ">=6.9.0" + "node": ">=16 || 14 >=14.17" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/babel" + "url": "https://github.com/sponsors/isaacs" } }, "node_modules/jest-diff": { @@ -12231,6 +12789,17 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, + "node_modules/jest-runner/node_modules/source-map-support": { + "version": "0.5.13", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz", + "integrity": "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, "node_modules/jest-runtime": { "version": "30.2.0", "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-30.2.0.tgz", @@ -12265,6 +12834,16 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, + "node_modules/jest-runtime/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, "node_modules/jest-runtime/node_modules/cjs-module-lexer": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.2.0.tgz", @@ -12272,6 +12851,44 @@ "dev": true, "license": "MIT" }, + "node_modules/jest-runtime/node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/jest-runtime/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/jest-snapshot": { "version": "30.2.0", "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-30.2.0.tgz", @@ -12306,21 +12923,21 @@ } }, "node_modules/jest-snapshot/node_modules/@babel/core": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.6.tgz", - "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/generator": "^7.28.6", + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", - "@babel/parser": "^7.28.6", + "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", - "@babel/traverse": "^7.28.6", - "@babel/types": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", @@ -12347,9 +12964,9 @@ } }, "node_modules/jest-snapshot/node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "dev": true, "license": "ISC", "bin": { @@ -12964,13 +13581,6 @@ "dev": true, "license": "MIT" }, - "node_modules/lodash.sortby": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz", - "integrity": "sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==", - "dev": true, - "license": "MIT" - }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -13026,6 +13636,7 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -13166,9 +13777,9 @@ } }, "node_modules/miller-rabin/node_modules/bn.js": { - "version": "4.12.2", - "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz", - "integrity": "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==", + "version": "4.12.3", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz", + "integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==", "dev": true, "license": "MIT" }, @@ -13230,16 +13841,16 @@ "license": "MIT" }, "node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.2.tgz", + "integrity": "sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^5.0.2" }, "engines": { - "node": ">=16 || 14 >=14.17" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -13256,11 +13867,11 @@ } }, "node_modules/minipass": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", - "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "engines": { "node": ">=16 || 14 >=14.17" } @@ -13361,6 +13972,7 @@ "integrity": "sha512-hkyRkcu5x/41KoqnROkfTm2pZVbKxvbZRuNvKXLRXxs3VfyO0WhY50TQS40EuKO9SW3rBj/sF3WbVwDACeMZyw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@next/env": "16.1.6", "@swc/helpers": "0.5.15", @@ -13429,6 +14041,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", @@ -13444,6 +14057,7 @@ "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "client-only": "0.0.1" }, @@ -13486,6 +14100,25 @@ "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", "license": "MIT" }, + "node_modules/node-exports-info": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.0.tgz", + "integrity": "sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "array.prototype.flatmap": "^1.3.3", + "es-errors": "^1.3.0", + "object.entries": "^1.1.9", + "semver": "^6.3.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", @@ -14188,7 +14821,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -14498,9 +15130,9 @@ } }, "node_modules/public-encrypt/node_modules/bn.js": { - "version": "4.12.2", - "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz", - "integrity": "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==", + "version": "4.12.3", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz", + "integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==", "dev": true, "license": "MIT" }, @@ -14613,7 +15245,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -14681,7 +15312,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -14702,7 +15332,6 @@ "integrity": "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -14902,29 +15531,6 @@ "strip-ansi": "^6.0.1" } }, - "node_modules/renderkid/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/renderkid/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -15066,52 +15672,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/rimraf/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/rimraf/node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "dev": true, - "license": "ISC", - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/rimraf/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, "node_modules/ripemd160": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/ripemd160/-/ripemd160-2.0.3.tgz", @@ -15195,7 +15755,6 @@ "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -15416,12 +15975,11 @@ } }, "node_modules/schema-utils/node_modules/ajv": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", - "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -15558,6 +16116,7 @@ "hasInstallScript": true, "license": "Apache-2.0", "optional": true, + "peer": true, "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", @@ -15603,6 +16162,7 @@ "dev": true, "license": "ISC", "optional": true, + "peer": true, "bin": { "semver": "bin/semver.js" }, @@ -15710,17 +16270,11 @@ } }, "node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", "dev": true, - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } + "license": "ISC" }, "node_modules/slash": { "version": "3.0.0", @@ -15752,9 +16306,9 @@ } }, "node_modules/source-map-support": { - "version": "0.5.13", - "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz", - "integrity": "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==", + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", "dev": true, "license": "MIT", "dependencies": { @@ -15814,12 +16368,11 @@ } }, "node_modules/storybook": { - "version": "10.2.8", - "resolved": "https://registry.npmjs.org/storybook/-/storybook-10.2.8.tgz", - "integrity": "sha512-885uSIn8NQw2ZG7vy84K45lHCOSyz1DVsDV8pHiHQj3J0riCuWLNeO50lK9z98zE8kjhgTtxAAkMTy5nkmNRKQ==", + "version": "10.2.10", + "resolved": "https://registry.npmjs.org/storybook/-/storybook-10.2.10.tgz", + "integrity": "sha512-N4U42qKgzMHS7DjqLz5bY4P7rnvJtYkWFCyKspZr3FhPUuy6CWOae3aYC2BjXkHrdug0Jyta6VxFTuB1tYUKhg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@storybook/global": "^5.0.0", "@storybook/icons": "^2.0.1", @@ -15954,45 +16507,19 @@ "node": ">=10" } }, - "node_modules/string-length/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/string-length/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/string-width": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", "dev": true, "license": "MIT", "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" }, "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=8" } }, "node_modules/string-width-cjs": { @@ -16011,36 +16538,6 @@ "node": ">=8" } }, - "node_modules/string-width-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/string-width-cjs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" - }, - "node_modules/string-width-cjs/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/string.prototype.matchall": { "version": "4.0.12", "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", @@ -16140,19 +16637,16 @@ } }, "node_modules/strip-ansi": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", - "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "dev": true, "license": "MIT", "dependencies": { - "ansi-regex": "^6.0.1" + "ansi-regex": "^5.0.1" }, "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" + "node": ">=8" } }, "node_modules/strip-ansi-cjs": { @@ -16169,16 +16663,6 @@ "node": ">=8" } }, - "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/strip-bom": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", @@ -16452,17 +16936,6 @@ "dev": true, "license": "MIT" }, - "node_modules/terser/node_modules/source-map-support": { - "version": "0.5.21", - "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", - "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", - "dev": true, - "license": "MIT", - "dependencies": { - "buffer-from": "^1.0.0", - "source-map": "^0.6.0" - } - }, "node_modules/test-exclude": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", @@ -16489,28 +16962,6 @@ "concat-map": "0.0.1" } }, - "node_modules/test-exclude/node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Glob versions prior to v9 are no longer supported", - "dev": true, - "license": "ISC", - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/test-exclude/node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -16646,16 +17097,6 @@ "node": ">=8.0" } }, - "node_modules/tr46": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-1.0.1.tgz", - "integrity": "sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA==", - "dev": true, - "license": "MIT", - "dependencies": { - "punycode": "^2.1.0" - } - }, "node_modules/tree-kill": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", @@ -16697,9 +17138,9 @@ "license": "Apache-2.0" }, "node_modules/ts-jest": { - "version": "29.4.5", - "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.5.tgz", - "integrity": "sha512-HO3GyiWn2qvTQA4kTgjDcXiMwYQt68a1Y8+JuLRVpdIzm+UOLSHgl/XqR4c6nzJkq5rOkjc02O2I7P7l/Yof0Q==", + "version": "29.4.6", + "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.6.tgz", + "integrity": "sha512-fSpWtOO/1AjSNQguk43hb/JCo16oJDnMJf3CdEGNkqsEX3t0KX96xvyX1D7PfLCpVoKu4MfVrqUkFyblYoY4lA==", "dev": true, "license": "MIT", "dependencies": { @@ -16777,587 +17218,103 @@ }, "node_modules/tsconfig-paths": { "version": "4.2.0", - "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-4.2.0.tgz", - "integrity": "sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==", - "dev": true, - "license": "MIT", - "dependencies": { - "json5": "^2.2.2", - "minimist": "^1.2.6", - "strip-bom": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/tsconfig-paths-webpack-plugin": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/tsconfig-paths-webpack-plugin/-/tsconfig-paths-webpack-plugin-4.2.0.tgz", - "integrity": "sha512-zbem3rfRS8BgeNK50Zz5SIQgXzLafiHjOwUAvk/38/o1jHn/V5QAgVUcz884or7WYcPaH3N2CIfUc2u0ul7UcA==", - "dev": true, - "license": "MIT", - "dependencies": { - "chalk": "^4.1.0", - "enhanced-resolve": "^5.7.0", - "tapable": "^2.2.1", - "tsconfig-paths": "^4.1.2" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/tsconfig-paths/node_modules/strip-bom": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", - "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "devOptional": true, - "license": "0BSD" - }, - "node_modules/tsup": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/tsup/-/tsup-8.5.0.tgz", - "integrity": "sha512-VmBp77lWNQq6PfuMqCHD3xWl22vEoWsKajkF8t+yMBawlUS8JzEI+vOVMeuNZIuMML8qXRizFKi9oD5glKQVcQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "bundle-require": "^5.1.0", - "cac": "^6.7.14", - "chokidar": "^4.0.3", - "consola": "^3.4.0", - "debug": "^4.4.0", - "esbuild": "^0.25.0", - "fix-dts-default-cjs-exports": "^1.0.0", - "joycon": "^3.1.1", - "picocolors": "^1.1.1", - "postcss-load-config": "^6.0.1", - "resolve-from": "^5.0.0", - "rollup": "^4.34.8", - "source-map": "0.8.0-beta.0", - "sucrase": "^3.35.0", - "tinyexec": "^0.3.2", - "tinyglobby": "^0.2.11", - "tree-kill": "^1.2.2" - }, - "bin": { - "tsup": "dist/cli-default.js", - "tsup-node": "dist/cli-node.js" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@microsoft/api-extractor": "^7.36.0", - "@swc/core": "^1", - "postcss": "^8.4.12", - "typescript": ">=4.5.0" - }, - "peerDependenciesMeta": { - "@microsoft/api-extractor": { - "optional": true - }, - "@swc/core": { - "optional": true - }, - "postcss": { - "optional": true - }, - "typescript": { - "optional": true - } - } - }, - "node_modules/tsup/node_modules/@esbuild/aix-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", - "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsup/node_modules/@esbuild/android-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", - "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsup/node_modules/@esbuild/android-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", - "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsup/node_modules/@esbuild/android-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", - "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsup/node_modules/@esbuild/darwin-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", - "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsup/node_modules/@esbuild/darwin-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", - "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsup/node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", - "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsup/node_modules/@esbuild/freebsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", - "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsup/node_modules/@esbuild/linux-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", - "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsup/node_modules/@esbuild/linux-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", - "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsup/node_modules/@esbuild/linux-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", - "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsup/node_modules/@esbuild/linux-loong64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", - "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsup/node_modules/@esbuild/linux-mips64el": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", - "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsup/node_modules/@esbuild/linux-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", - "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsup/node_modules/@esbuild/linux-riscv64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", - "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsup/node_modules/@esbuild/linux-s390x": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", - "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsup/node_modules/@esbuild/linux-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", - "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsup/node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", - "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsup/node_modules/@esbuild/netbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", - "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsup/node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", - "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsup/node_modules/@esbuild/openbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", - "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsup/node_modules/@esbuild/openharmony-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", - "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsup/node_modules/@esbuild/sunos-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", - "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsup/node_modules/@esbuild/win32-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", - "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", - "cpu": [ - "arm64" - ], + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-4.2.0.tgz", + "integrity": "sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "win32" - ], + "dependencies": { + "json5": "^2.2.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" + }, "engines": { - "node": ">=18" + "node": ">=6" } }, - "node_modules/tsup/node_modules/@esbuild/win32-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", - "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", - "cpu": [ - "ia32" - ], + "node_modules/tsconfig-paths-webpack-plugin": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths-webpack-plugin/-/tsconfig-paths-webpack-plugin-4.2.0.tgz", + "integrity": "sha512-zbem3rfRS8BgeNK50Zz5SIQgXzLafiHjOwUAvk/38/o1jHn/V5QAgVUcz884or7WYcPaH3N2CIfUc2u0ul7UcA==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "win32" - ], + "dependencies": { + "chalk": "^4.1.0", + "enhanced-resolve": "^5.7.0", + "tapable": "^2.2.1", + "tsconfig-paths": "^4.1.2" + }, "engines": { - "node": ">=18" + "node": ">=10.13.0" } }, - "node_modules/tsup/node_modules/@esbuild/win32-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", - "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", - "cpu": [ - "x64" - ], + "node_modules/tsconfig-paths/node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "win32" - ], "engines": { - "node": ">=18" + "node": ">=4" } }, - "node_modules/tsup/node_modules/esbuild": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", - "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "devOptional": true, + "license": "0BSD" + }, + "node_modules/tsup": { + "version": "8.5.1", + "resolved": "https://registry.npmjs.org/tsup/-/tsup-8.5.1.tgz", + "integrity": "sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing==", "dev": true, - "hasInstallScript": true, "license": "MIT", + "dependencies": { + "bundle-require": "^5.1.0", + "cac": "^6.7.14", + "chokidar": "^4.0.3", + "consola": "^3.4.0", + "debug": "^4.4.0", + "esbuild": "^0.27.0", + "fix-dts-default-cjs-exports": "^1.0.0", + "joycon": "^3.1.1", + "picocolors": "^1.1.1", + "postcss-load-config": "^6.0.1", + "resolve-from": "^5.0.0", + "rollup": "^4.34.8", + "source-map": "^0.7.6", + "sucrase": "^3.35.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.11", + "tree-kill": "^1.2.2" + }, "bin": { - "esbuild": "bin/esbuild" + "tsup": "dist/cli-default.js", + "tsup-node": "dist/cli-node.js" }, "engines": { "node": ">=18" }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.12", - "@esbuild/android-arm": "0.25.12", - "@esbuild/android-arm64": "0.25.12", - "@esbuild/android-x64": "0.25.12", - "@esbuild/darwin-arm64": "0.25.12", - "@esbuild/darwin-x64": "0.25.12", - "@esbuild/freebsd-arm64": "0.25.12", - "@esbuild/freebsd-x64": "0.25.12", - "@esbuild/linux-arm": "0.25.12", - "@esbuild/linux-arm64": "0.25.12", - "@esbuild/linux-ia32": "0.25.12", - "@esbuild/linux-loong64": "0.25.12", - "@esbuild/linux-mips64el": "0.25.12", - "@esbuild/linux-ppc64": "0.25.12", - "@esbuild/linux-riscv64": "0.25.12", - "@esbuild/linux-s390x": "0.25.12", - "@esbuild/linux-x64": "0.25.12", - "@esbuild/netbsd-arm64": "0.25.12", - "@esbuild/netbsd-x64": "0.25.12", - "@esbuild/openbsd-arm64": "0.25.12", - "@esbuild/openbsd-x64": "0.25.12", - "@esbuild/openharmony-arm64": "0.25.12", - "@esbuild/sunos-x64": "0.25.12", - "@esbuild/win32-arm64": "0.25.12", - "@esbuild/win32-ia32": "0.25.12", - "@esbuild/win32-x64": "0.25.12" + "peerDependencies": { + "@microsoft/api-extractor": "^7.36.0", + "@swc/core": "^1", + "postcss": "^8.4.12", + "typescript": ">=4.5.0" + }, + "peerDependenciesMeta": { + "@microsoft/api-extractor": { + "optional": true + }, + "@swc/core": { + "optional": true + }, + "postcss": { + "optional": true + }, + "typescript": { + "optional": true + } } }, "node_modules/tsup/node_modules/resolve-from": { @@ -17371,17 +17328,13 @@ } }, "node_modules/tsup/node_modules/source-map": { - "version": "0.8.0-beta.0", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.8.0-beta.0.tgz", - "integrity": "sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA==", - "deprecated": "The work that was done in this beta branch won't be included in future versions", + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", + "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", "dev": true, "license": "BSD-3-Clause", - "dependencies": { - "whatwg-url": "^7.0.0" - }, "engines": { - "node": ">= 8" + "node": ">= 12" } }, "node_modules/tty-browserify": { @@ -17420,7 +17373,6 @@ "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", "dev": true, "license": "(MIT OR CC0-1.0)", - "peer": true, "engines": { "node": ">=10" }, @@ -17512,7 +17464,6 @@ "integrity": "sha512-i5t66RHxDvVN40HfDd1PsEThGNnlMCMT3jMUuoh9/0TaqWevNontacunWyN02LA9/fIbEWlcHZcgTKb9QoaLfg==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -17522,16 +17473,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.54.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.54.0.tgz", - "integrity": "sha512-CKsJ+g53QpsNPqbzUsfKVgd3Lny4yKZ1pP4qN3jdMOg/sisIDLGyDMezycquXLE5JsEU0wp3dGNdzig0/fmSVQ==", + "version": "8.56.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.56.0.tgz", + "integrity": "sha512-c7toRLrotJ9oixgdW7liukZpsnq5CZ7PuKztubGYlNppuTqhIoWfhgHo/7EU0v06gS2l/x0i2NEFK1qMIf0rIg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.54.0", - "@typescript-eslint/parser": "8.54.0", - "@typescript-eslint/typescript-estree": "8.54.0", - "@typescript-eslint/utils": "8.54.0" + "@typescript-eslint/eslint-plugin": "8.56.0", + "@typescript-eslint/parser": "8.56.0", + "@typescript-eslint/typescript-estree": "8.56.0", + "@typescript-eslint/utils": "8.56.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -17541,7 +17492,7 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, @@ -17843,20 +17794,12 @@ "node": ">=10.13.0" } }, - "node_modules/webidl-conversions": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz", - "integrity": "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==", - "dev": true, - "license": "BSD-2-Clause" - }, "node_modules/webpack": { "version": "5.105.2", "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.105.2.tgz", "integrity": "sha512-dRXm0a2qcHPUBEzVk8uph0xWSjV/xZxenQQbLwnwP7caQCYpqG1qddwlyEkIDkYn0K8tvmcrZ+bOrzoQ3HxCDw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", @@ -17935,36 +17878,12 @@ "integrity": "sha512-khZGfAeJx6I8K9zKohEWWYN6KDlVw2DHownoe+6Vtwj1LP9WFgegXnVMSkZ/dBEBtXFwrkkydsaPFlB7f8wU2A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ansi-html-community": "0.0.8", "html-entities": "^2.1.0", "strip-ansi": "^6.0.0" } }, - "node_modules/webpack-hot-middleware/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/webpack-hot-middleware/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/webpack-sources": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.3.3.tgz", @@ -18013,18 +17932,6 @@ "node": ">=4.0" } }, - "node_modules/whatwg-url": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-7.1.0.tgz", - "integrity": "sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg==", - "dev": true, - "license": "MIT", - "dependencies": { - "lodash.sortby": "^4.7.0", - "tr46": "^1.0.1", - "webidl-conversions": "^4.0.2" - } - }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -18148,18 +18055,18 @@ "license": "MIT" }, "node_modules/wrap-ansi": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", - "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", "dev": true, "license": "MIT", "dependencies": { - "ansi-styles": "^6.1.0", - "string-width": "^5.0.1", - "strip-ansi": "^7.0.1" + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" }, "engines": { - "node": ">=12" + "node": ">=10" }, "funding": { "url": "https://github.com/chalk/wrap-ansi?sponsor=1" @@ -18184,64 +18091,6 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" - }, - "node_modules/wrap-ansi-cjs/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", @@ -18263,6 +18112,19 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, + "node_modules/write-file-atomic/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/ws": { "version": "8.19.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", @@ -18357,51 +18219,6 @@ "node": ">=12" } }, - "node_modules/yargs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/yargs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" - }, - "node_modules/yargs/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/yargs/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index c942a9d..959990e 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ }, "dependencies": { "@helpwave/internationalization": "0.4.0", + "@radix-ui/react-slot": "1.2.4", "@tailwindcss/cli": "4.1.18", "@tanstack/react-table": "8.21.3", "clsx": "2.1.1", @@ -54,9 +55,9 @@ "@babel/preset-typescript": "7.26.0", "@faker-js/faker": "10.1.0", "@helpwave/eslint-config": "0.0.11", - "@storybook/addon-docs": "10.2.8", - "@storybook/addon-links": "10.2.8", - "@storybook/nextjs": "^10.2.8", + "@storybook/addon-docs": "10.2.10", + "@storybook/addon-links": "10.2.10", + "@storybook/nextjs": "10.2.10", "@tailwindcss/postcss": "4.1.18", "@types/jest": "30.0.0", "@types/node": "20.17.10", @@ -65,13 +66,13 @@ "@types/tinycolor2": "1.4.6", "@vitest/mocker": "4.0.16", "autoprefixer": "10.4.23", - "eslint": "9.31.0", - "eslint-plugin-storybook": "10.2.8", + "eslint": "10.0.1", + "eslint-plugin-storybook": "10.2.10", "jest": "30.2.0", "postcss": "8.5.6", - "storybook": "10.2.8", - "ts-jest": "29.4.5", - "tsup": "8.5.0", + "storybook": "10.2.10", + "ts-jest": "29.4.6", + "tsup": "8.5.1", "typescript": "5.7.2", "webpack": "5.105.2" }, @@ -87,4 +88,4 @@ "overrides": { "elliptic": "^6.6.1" } -} \ No newline at end of file +} diff --git a/src/components/display-and-visualization/Chip.tsx b/src/components/display-and-visualization/Chip.tsx index 25b37b7..76ed5fa 100644 --- a/src/components/display-and-visualization/Chip.tsx +++ b/src/components/display-and-visualization/Chip.tsx @@ -3,7 +3,7 @@ import { ButtonUtil } from '@/src/components/user-interaction/Button' type ChipSize = 'xs' | 'sm' | 'md' | 'lg' | null -type ChipColoringStyle = 'solid' | 'tonal' | null +type ChipColoringStyle = 'solid' | 'tonal' | 'outline' | 'tonal-outline' | null const chipColors = ButtonUtil.colors export type ChipColor = typeof chipColors[number] diff --git a/src/components/layout/popup/PopUp.tsx b/src/components/layout/popup/PopUp.tsx index 6754e69..82fbed5 100644 --- a/src/components/layout/popup/PopUp.tsx +++ b/src/components/layout/popup/PopUp.tsx @@ -24,7 +24,7 @@ export interface PopUpProps extends AnchoredFloatingContainerProps, Partial(function PopUp({ +export const PopUp = forwardRef(function PopUp({ children, isOpen: isOpenOverwrite, focusTrapOptions, @@ -52,7 +52,8 @@ export const PopUp = forwardRef(function PopUp({ context?.setIsOpen(false) }, [onCloseStable, context]) - const { zIndex, isInFront } = useOverlayRegistry({ isActive: isOpen, tags: useMemo(() => ['popup'], []) }) + const { zIndex, tagPositions } = useOverlayRegistry({ isActive: isOpen, tags: useMemo(() => ['popup'], []) }) + const isInFront = tagPositions?.['popup'] === 0 useOutsideClick({ onOutsideClick: useCallback((event: MouseEvent | TouchEvent) => { @@ -90,7 +91,6 @@ export const PopUp = forwardRef(function PopUp({ zIndex, position: 'fixed', overflow: 'hidden', - transition: `top ${props.options?.pollingInterval ?? 100}ms linear, left ${props.options?.pollingInterval ?? 100}ms linear`, ...props.style }} data-name={props['data-name'] ?? 'pop-up'} diff --git a/src/components/layout/table/TableColumn.tsx b/src/components/layout/table/TableColumn.tsx index 5390e4e..277bd55 100644 --- a/src/components/layout/table/TableColumn.tsx +++ b/src/components/layout/table/TableColumn.tsx @@ -1,11 +1,11 @@ import type { ColumnDef } from '@tanstack/react-table' import { memo, useEffect, useMemo, useState } from 'react' import { useTableColumnDefinitionContext } from './TableContext' -import type { TableFilterCategory } from './TableFilter' +import type { DataType } from '../../user-interaction/data/data-types' import { useLogOnce } from '@/src/hooks/useLogOnce' export type TableColumnProps = ColumnDef & { - filterType?: TableFilterCategory, + filterType?: DataType, } const TableColumnComponent = ({ diff --git a/src/components/layout/table/TableFilter.ts b/src/components/layout/table/TableFilter.ts index e671ff4..5764057 100644 --- a/src/components/layout/table/TableFilter.ts +++ b/src/components/layout/table/TableFilter.ts @@ -1,163 +1,78 @@ import type { FilterFn } from '@tanstack/react-table' -import { - filterText, - filterNumber, - filterDate, - filterDatetime, - filterBoolean, - filterTags, - filterTagsSingle, - filterGeneric -} from '@/src/utils/filter' +import { FilterOperatorUtils } from '../../user-interaction/data/FilterOperator' +import { FilterFunctions, type FilterValue } from '../../user-interaction/data/filter-function' -export const TableFilterOperator = { - text: ['textEquals', 'textNotEquals', 'textNotWhitespace', 'textContains', 'textNotContains', 'textStartsWith', 'textEndsWith'], - number: ['numberEquals', 'numberNotEquals', 'numberGreaterThan', 'numberGreaterThanOrEqual', 'numberLessThan', 'numberLessThanOrEqual', 'numberBetween', 'numberNotBetween'], - date: ['dateEquals', 'dateNotEquals', 'dateGreaterThan', 'dateGreaterThanOrEqual', 'dateLessThan', 'dateLessThanOrEqual', 'dateBetween', 'dateNotBetween'], - dateTime: ['dateTimeEquals', 'dateTimeNotEquals', 'dateTimeGreaterThan', 'dateTimeGreaterThanOrEqual', 'dateTimeLessThan', 'dateTimeLessThanOrEqual', 'dateTimeBetween', 'dateTimeNotBetween'], - boolean: ['booleanIsTrue', 'booleanIsFalse'], - multiTags: ['tagsEquals', 'tagsNotEquals', 'tagsContains', 'tagsNotContains'], - singleTag: ['tagsSingleEquals', 'tagsSingleNotEquals', 'tagsSingleContains', 'tagsSingleNotContains'], - generic: ['undefined', 'notUndefined'] -} as const -export type TableGenericFilter = (typeof TableFilterOperator.generic)[number] -export type TableTextFilter = (typeof TableFilterOperator.text)[number] | TableGenericFilter -export type TableNumberFilter = (typeof TableFilterOperator.number)[number] | TableGenericFilter -export type TableDateFilter = (typeof TableFilterOperator.date)[number]| TableGenericFilter -export type TableDatetimeFilter = (typeof TableFilterOperator.dateTime)[number] | TableGenericFilter -export type TableBooleanFilter = (typeof TableFilterOperator.boolean)[number] | TableGenericFilter -export type TableTagsFilter = (typeof TableFilterOperator.multiTags)[number] | TableGenericFilter -export type TableTagsSingleFilter = (typeof TableFilterOperator.singleTag)[number] | TableGenericFilter - - -export type TableFilterType = TableTextFilter | TableNumberFilter | TableDateFilter | TableDatetimeFilter -| TableBooleanFilter | TableTagsFilter | TableTagsSingleFilter | TableGenericFilter - -export type TableFilterCategory = keyof typeof TableFilterOperator - -export function isTableFilterCategory(value: unknown): value is TableFilterCategory { - return typeof value === 'string' && value in TableFilterOperator -} - -export type TextFilterParameter = { - searchText?: string, - isCaseSensitive?: boolean, -} - -export type NumberFilterParameter = { - compareValue?: number, - min?: number, - max?: number, -} - -export type DateFilterParameter = { - compareDate?: Date, - min?: Date, - max?: Date, -} - -export type DatetimeFilterParameter = { - compareDatetime?: Date, - min?: Date, - max?: Date, -} - -export type BooleanFilterParameter = Record - -export type TagsFilterParameter = { - searchTags?: unknown[], -} - -export type TagsSingleFilterParameter = { - searchTag?: unknown, - searchTagsContains?: unknown[], -} - -export type GenericFilterParameter = Record - -export type TextFilterValue = { - operator: TableTextFilter, - parameter: TextFilterParameter, -} - -export type NumberFilterValue = { - operator: TableNumberFilter, - parameter: NumberFilterParameter, -} - -export type DateFilterValue = { - operator: TableDateFilter, - parameter: DateFilterParameter, -} - -export type DatetimeFilterValue = { - operator: TableDatetimeFilter, - parameter: DatetimeFilterParameter, -} - -export type BooleanFilterValue = { - operator: TableBooleanFilter, - parameter: BooleanFilterParameter, -} - -export type TagsFilterValue = { - operator: TableTagsFilter, - parameter: TagsFilterParameter, -} - -export type TagsSingleFilterValue = { - operator: TableTagsSingleFilter, - parameter: TagsSingleFilterParameter, -} - -export type GenericFilterValue = { - operator: TableGenericFilter, - parameter: GenericFilterParameter, -} - - -export type TableFilterValue = TextFilterValue | NumberFilterValue | DateFilterValue | DatetimeFilterValue -| BooleanFilterValue | TagsFilterValue | TagsSingleFilterValue | GenericFilterValue - -const textFilter: FilterFn = (row, columnId, filterValue: TextFilterValue) => { +const textFilter: FilterFn = (row, columnId, filterValue: FilterValue) => { const value = row.getValue(columnId) - return filterText(value, filterValue) + const operator = filterValue.operator + if (!FilterOperatorUtils.typeCheck.text(operator)) { + return true + } + return FilterFunctions.text(value, operator, filterValue.parameter) } -const numberFilter: FilterFn = (row, columnId, filterValue: NumberFilterValue) => { +const numberFilter: FilterFn = (row, columnId, filterValue: FilterValue) => { const value = row.getValue(columnId) - return filterNumber(value, filterValue) + const operator = filterValue.operator + if (!FilterOperatorUtils.typeCheck.number(operator)) { + return true + } + return FilterFunctions.number(value, operator, filterValue.parameter) } -const dateFilter: FilterFn = (row, columnId, filterValue: DateFilterValue) => { +const dateFilter: FilterFn = (row, columnId, filterValue: FilterValue) => { const value = row.getValue(columnId) - return filterDate(value, filterValue) + const operator = filterValue.operator + if (!FilterOperatorUtils.typeCheck.date(operator)) { + return true + } + return FilterFunctions.date(value, operator, filterValue.parameter) } -const dateTimeFilter: FilterFn = (row, columnId, filterValue: DatetimeFilterValue) => { +const dateTimeFilter: FilterFn = (row, columnId, filterValue: FilterValue) => { const value = row.getValue(columnId) - return filterDatetime(value, filterValue) + const operator = filterValue.operator + if (!FilterOperatorUtils.typeCheck.datetime(operator)) { + return true + } + return FilterFunctions.dateTime(value, operator, filterValue.parameter) } -const booleanFilter: FilterFn = (row, columnId, filterValue: BooleanFilterValue) => { +const booleanFilter: FilterFn = (row, columnId, filterValue: FilterValue) => { const value = row.getValue(columnId) - return filterBoolean(value, filterValue) + const operator = filterValue.operator + if (!FilterOperatorUtils.typeCheck.boolean(operator)) { + return true + } + return FilterFunctions.boolean(value, operator, filterValue.parameter) } -const multiTagsFilter: FilterFn = (row, columnId, filterValue: TagsFilterValue) => { +const multiTagsFilter: FilterFn = (row, columnId, filterValue: FilterValue) => { const value = row.getValue(columnId) - return filterTags(value, filterValue) + const operator = filterValue.operator + if (!FilterOperatorUtils.typeCheck.tags(operator)) { + return true + } + return FilterFunctions.multiTags(value, operator, filterValue.parameter) } -const singleTagFilter: FilterFn = (row, columnId, filterValue: TagsSingleFilterValue) => { +const singleTagFilter: FilterFn = (row, columnId, filterValue: FilterValue) => { const value = row.getValue(columnId) - return filterTagsSingle(value, filterValue) + const operator = filterValue.operator + if (!FilterOperatorUtils.typeCheck.tagsSingle(operator)) { + return true + } + return FilterFunctions.singleTag(value, operator, filterValue.parameter) } -const genericFilter: FilterFn = (row, columnId, filterValue: GenericFilterValue) => { +const unknownTypeFilter: FilterFn = (row, columnId, filterValue: FilterValue) => { const value = row.getValue(columnId) - return filterGeneric(value, filterValue) + const operator = filterValue.operator + if (!FilterOperatorUtils.typeCheck.unknownType(operator)) { + return true + } + return FilterFunctions.unknownType(value, operator, filterValue.parameter) } export const TableFilter = { @@ -168,5 +83,5 @@ export const TableFilter = { boolean: booleanFilter, multiTags: multiTagsFilter, singleTag: singleTagFilter, - generic: genericFilter, + unknownType: unknownTypeFilter, } diff --git a/src/components/layout/table/TableFilterButton.tsx b/src/components/layout/table/TableFilterButton.tsx index c0b4d82..38f9e15 100644 --- a/src/components/layout/table/TableFilterButton.tsx +++ b/src/components/layout/table/TableFilterButton.tsx @@ -1,26 +1,27 @@ -import { Button } from '../../user-interaction/Button' import { FilterIcon } from 'lucide-react' import { useEffect, useId, useMemo, useRef, useState } from 'react' -import type { Column } from '@tanstack/react-table' +import type { Header } from '@tanstack/react-table' +import { flexRender } from '@tanstack/react-table' import { useHightideTranslation } from '@/src/i18n/useHightideTranslation' import { Visibility } from '../Visibility' -import { PopUp } from '../popup/PopUp' -import type { TableFilterCategory, TableFilterValue } from './TableFilter' -import { TableFilterContent } from './TableFilterPopups' import { IconButton } from '../../user-interaction/IconButton' +import type { DataType } from '../../user-interaction/data/data-types' +import type { FilterValue } from '../../user-interaction/data/filter-function' +import { FilterPopUp } from '../../user-interaction/data/FilterPopUp' -export type TableFilterButtonProps = { - filterType: TableFilterCategory, - column: Column, +export type TableFilterButtonProps = { + filterType: DataType, + header: Header, } -export const TableFilterButton = ({ +export const TableFilterButton = ({ filterType, - column, -}: TableFilterButtonProps) => { + header, +}: TableFilterButtonProps) => { const translation = useHightideTranslation() + const column = header.column const columnFilterValue = column.getFilterValue() - const [filterValue, setFilterValue] = useState(columnFilterValue as TableFilterValue) + const [filterValue, setFilterValue] = useState(columnFilterValue as FilterValue | undefined) const hasFilter = !!filterValue const anchorRef = useRef(null) const containerRef = useRef(null) @@ -33,7 +34,7 @@ export const TableFilterButton = ({ }), [id]) useEffect(() => { - setFilterValue(columnFilterValue as TableFilterValue) + setFilterValue(columnFilterValue as FilterValue) }, [columnFilterValue]) const isTagsFilter = filterType === 'multiTags' || filterType === 'singleTag' @@ -47,7 +48,7 @@ export const TableFilterButton = ({ ({
    - ({ }} anchor={anchorRef} - onClose={() => setIsOpen(false)} + onValueChange={setFilterValue} + onRemove={() => { + column.setFilterValue(undefined) + setIsOpen(false) + }} + onClose={() => { + column.setFilterValue(filterValue) + setIsOpen(false) + }} - role="dialog" - aria-labelledby={ids.label} + className="flex-col-2 px-3 py-2 items-start" + dataType={filterType} + value={filterValue} - className="flex-col-2 p-2 items-start" - > - {translation('filter')} - -
    - {hasFilter && ( - - )} - -
    -
    + name={flexRender(column.columnDef.header, header.getContext())} + + tags={column.columnDef.meta?.filterData?.tags ?? []} + /> ) } \ No newline at end of file diff --git a/src/components/layout/table/TableFilterPopups.tsx b/src/components/layout/table/TableFilterPopups.tsx deleted file mode 100644 index 95cbf7b..0000000 --- a/src/components/layout/table/TableFilterPopups.tsx +++ /dev/null @@ -1,930 +0,0 @@ -import { Input } from '../../user-interaction/input/Input' -import { DateTimeInput } from '../../user-interaction/input/DateTimeInput' -import { FormFieldLayout } from '../../form/FieldLayout' -import { useHightideTranslation } from '@/src/i18n/useHightideTranslation' -import { useId, useMemo, useState } from 'react' -import type { - TableTextFilter, - TableNumberFilter, - TableDateFilter, - TableBooleanFilter, - TableTagsFilter, - TableGenericFilter, - TextFilterValue, - NumberFilterValue, - DateFilterValue, - BooleanFilterValue, - TagsFilterValue, - GenericFilterValue, - TableFilterValue, - TableDatetimeFilter, - DatetimeFilterValue, - TagsSingleFilterValue, - TableTagsSingleFilter, - TableFilterCategory -} from './TableFilter' -import { TableFilterOperator } from './TableFilter' -import { Select } from '../../user-interaction/select/Select' -import { SelectOption } from '../../user-interaction/select/SelectContent' -import { MultiSelect } from '../../user-interaction/select/MultiSelect' -import { MultiSelectOption } from '../../user-interaction/select/SelectContent' -import { Visibility } from '../Visibility' -import { - ArrowRight, - ArrowLeft, - ChevronRight, - ChevronLeft, - CheckCircle2, - XCircle, - Equal, - EqualNot, - TextInitial, - SearchCheck, - SearchX, - CircleDashed, - CircleDot -} from 'lucide-react' -import type { TableFilterType } from './TableFilter' -import { useTableStateWithoutSizingContext } from './TableContext' -import { Checkbox } from '../../user-interaction/Checkbox' - -export interface TableFilterBaseProps { - columnId: string, - filterValue?: T | undefined, - onFilterValueChange: (value: T | undefined) => void, -} - -const getOperatorInfo = (operator: TableFilterType) => { - switch (operator) { - case 'textEquals': return { icon: , translationKey: 'equals' } - case 'textNotEquals': return { icon: , translationKey: 'notEquals' } - case 'textNotWhitespace': return { icon: , translationKey: 'filterNonWhitespace' } - case 'textContains': return { icon: , translationKey: 'contains' } - case 'textNotContains': return { icon: , translationKey: 'notContains' } - case 'textStartsWith': return { icon: , translationKey: 'startsWith' } - case 'textEndsWith': return { icon: , translationKey: 'endsWith' } - case 'numberEquals': return { icon: , translationKey: 'equals' } - case 'numberNotEquals': return { icon: , translationKey: 'notEquals' } - case 'numberGreaterThan': return { icon: , translationKey: 'greaterThan' } - case 'numberGreaterThanOrEqual': return { - icon: (
    - - -
    - ), - translationKey: 'greaterThanOrEqual' - } - case 'numberLessThan': return { icon: , translationKey: 'lessThan' } - case 'numberLessThanOrEqual': return { - icon: (
    - - -
    - ), - translationKey: 'lessThanOrEqual' - } - case 'numberBetween': return { - icon: (
    - - -
    - ), - translationKey: 'between' - } - case 'numberNotBetween': return { - icon: (
    - - -
    - ), - translationKey: 'notBetween' - } - case 'dateEquals': return { icon: , translationKey: 'equals' } - case 'dateNotEquals': return { icon: , translationKey: 'notEquals' } - case 'dateGreaterThan': return { icon: , translationKey: 'after' } - case 'dateGreaterThanOrEqual': return { - icon: (
    - - -
    - ), - translationKey: 'onOrAfter' - } - case 'dateLessThan': return { icon: , translationKey: 'before' } - case 'dateLessThanOrEqual': return { - icon: (
    - - -
    - ), - translationKey: 'onOrBefore' - } - case 'dateBetween': return { - icon: (
    - - -
    - ), - translationKey: 'between' - } - case 'dateNotBetween': return { - icon: (
    - - -
    - ), - translationKey: 'notBetween' - } - case 'booleanIsTrue': return { icon: , translationKey: 'isTrue' } - case 'booleanIsFalse': return { icon: , translationKey: 'isFalse' } - case 'tagsEquals': return { icon: , translationKey: 'equals' } - case 'tagsNotEquals': return { icon: , translationKey: 'notEquals' } - case 'tagsContains': return { icon: , translationKey: 'contains' } - case 'tagsNotContains': return { icon: , translationKey: 'notContains' } - case 'dateTimeEquals': return { icon: , translationKey: 'equals' } - case 'dateTimeNotEquals': return { icon: , translationKey: 'notEquals' } - case 'dateTimeGreaterThan': return { icon: , translationKey: 'after' } - case 'dateTimeGreaterThanOrEqual': return { - icon: (
    - - -
    - ), - translationKey: 'onOrAfter' - } - case 'dateTimeLessThan': return { icon: , translationKey: 'before' } - case 'dateTimeLessThanOrEqual': return { - icon: (
    - - -
    - ), - translationKey: 'onOrBefore' - } - case 'dateTimeBetween': return { - icon: (
    - - -
    - ), - translationKey: 'between' - } - case 'dateTimeNotBetween': return { - icon: (
    - - -
    - ), - translationKey: 'notBetween' - } - case 'tagsSingleEquals': return { icon: , translationKey: 'equals' } - case 'tagsSingleNotEquals': return { icon: , translationKey: 'notEquals' } - case 'tagsSingleContains': return { icon: , translationKey: 'contains' } - case 'tagsSingleNotContains': return { icon: , translationKey: 'notContains' } - case 'undefined': return { icon: , translationKey: 'filterUndefined' } - case 'notUndefined': return { icon: , translationKey: 'filterNotUndefined' } - default: return { icon: null, translationKey: 'undefined translation' } - } -} - -export type OperatorLabelProps = { - operator: TableFilterType, -} - -export const OperatorLabel = ({ operator }: OperatorLabelProps) => { - const translation = useHightideTranslation() - const { icon, translationKey } = getOperatorInfo(operator) - const label = typeof translationKey === 'string' ? translation(translationKey) : translationKey - - return ( -
    - {icon} - {label} -
    - ) -} - -export type TextFilterProps = TableFilterBaseProps - -export const TextFilter = ({ filterValue, onFilterValueChange }: TextFilterProps) => { - const translation = useHightideTranslation() - const operator = filterValue?.operator ?? 'textContains' - const parameter = filterValue?.parameter ?? {} - const id = useId() - - const availableOperators = useMemo(() => [ - ...TableFilterOperator.text, - ...TableFilterOperator.generic, - ], []) - - const needsParameterInput = !['textNotWhitespace', 'undefined', 'notUndefined'].includes(operator) - - return ( -
    - - {translation('parameter')} - - { - onFilterValueChange({ - operator, - parameter: { ...parameter, searchText }, - }) - }} - className="min-w-64" - /> -
    - { - onFilterValueChange({ - operator, - parameter: { ...parameter, isCaseSensitive }, - }) - }} - /> - -
    -
    - - - {translation('noParameterRequired')} - - -
    - ) -} - -export type NumberFilterProps = TableFilterBaseProps - -export const NumberFilter = ({ filterValue, onFilterValueChange }: NumberFilterProps) => { - const translation = useHightideTranslation() - const operator = filterValue?.operator ?? 'numberBetween' - const parameter = filterValue?.parameter ?? {} - - const availableOperators = useMemo(() => [ - ...TableFilterOperator.number, - ...TableFilterOperator.generic, - ], []) - - const needsRangeInput = operator === 'numberBetween' || operator === 'numberNotBetween' - const needsParameterInput = operator !== 'undefined' && operator !== 'notUndefined' - - return ( -
    - - {translation('parameter')} - -
    - - {({ ariaAttributes, interactionStates, id }) => ( - { - const num = Number(text) - onFilterValueChange({ - operator, - parameter: { ...parameter, min: isNaN(num) ? undefined : num }, - }) - }} - className="input-indicator-hidden min-w-64" - /> - )} - - - {({ ariaAttributes, interactionStates, id }) => ( - { - const num = Number(text) - onFilterValueChange({ - operator, - parameter: { ...parameter, max: isNaN(num) ? undefined : num }, - }) - }} - className="input-indicator-hidden min-w-64" - /> - )} - -
    -
    - - { - const num = Number(text) - onFilterValueChange({ - operator, - parameter: { compareValue: isNaN(num) ? undefined : num }, - }) - }} - className="min-w-64" - /> - - - - {translation('noParameterRequired')} - - -
    - ) -} - -export type DateFilterProps = TableFilterBaseProps - -export const DateFilter = ({ filterValue, onFilterValueChange }: DateFilterProps) => { - const translation = useHightideTranslation() - const id = useId() - const ids = { - startDate: `date-filter-start-date-${id}`, - endDate: `date-filter-end-date-${id}`, - compareDate: `date-filter-compare-date-${id}`, - } - const operator = filterValue?.operator ?? 'dateBetween' - const parameter = filterValue?.parameter ?? {} - const [temporaryMinDateValue, setTemporaryMinDateValue] = useState(null) - const [temporaryMaxDateValue, setTemporaryMaxDateValue] = useState(null) - - const availableOperators = useMemo(() => [ - ...TableFilterOperator.date, - ...TableFilterOperator.generic, - ], []) - - const needsRangeInput = operator === 'dateBetween' || operator === 'dateNotBetween' - const needsParameterInput = operator !== 'undefined' && operator !== 'notUndefined' - - return ( -
    - - {translation('parameter')} - -
    - - setTemporaryMinDateValue(value)} - onEditComplete={value => { - if (value && parameter.max && value > parameter.max) { - if (!parameter.min) { - onFilterValueChange({ - operator, - parameter: { min: parameter.max, max: value }, - }) - } else { - const diff = parameter.max.getTime() - parameter.min.getTime() - onFilterValueChange({ - operator, - parameter: { min: value, max: new Date(value.getTime() + diff) }, - }) - } - } else { - onFilterValueChange({ - operator, - parameter: { ...parameter, min: value }, - }) - } - setTemporaryMinDateValue(null) - }} - allowRemove={true} - outsideClickCloses={false} - className="min-w-64" - /> - - setTemporaryMaxDateValue(value)} - onEditComplete={value => { - if (value && parameter.min && value < parameter.min) { - if (!parameter.max) { - onFilterValueChange({ - operator, - parameter: { min: value, max: parameter.min }, - }) - } else { - const diff = parameter.max.getTime() - parameter.min.getTime() - onFilterValueChange({ - operator, - parameter: { min: new Date(value.getTime() - diff), max: value }, - }) - } - } else { - onFilterValueChange({ - operator, - parameter: { ...parameter, max: value }, - }) - } - }} - allowRemove={true} - outsideClickCloses={false} - className="min-w-64" - /> -
    -
    - - - { - onFilterValueChange({ - operator, - parameter: { compareDate }, - }) - }} - allowRemove={true} - outsideClickCloses={false} - className="min-w-64" - /> - - - - {translation('noParameterRequired')} - - -
    - ) -} - -export type DatetimeFilterProps = TableFilterBaseProps - -export const DatetimeFilter = ({ filterValue, onFilterValueChange }: DatetimeFilterProps) => { - const translation = useHightideTranslation() - const id = useId() - const ids = { - startDate: `datetime-filter-start-date-${id}`, - endDate: `datetime-filter-end-date-${id}`, - compareDate: `datetime-filter-compare-date-${id}`, - } - const operator = filterValue?.operator ?? 'dateTimeBetween' - const parameter = filterValue?.parameter ?? {} - const [temporaryMinDateValue, setTemporaryMinDateValue] = useState(null) - const [temporaryMaxDateValue, setTemporaryMaxDateValue] = useState(null) - - const availableOperators = useMemo(() => [ - ...TableFilterOperator.dateTime, - ...TableFilterOperator.generic, - ], []) - - const needsRangeInput = operator === 'dateTimeBetween' || operator === 'dateTimeNotBetween' - const needsParameterInput = operator !== 'undefined' && operator !== 'notUndefined' - - return ( -
    - - {translation('parameter')} - -
    - - setTemporaryMinDateValue(value)} - onEditComplete={value => { - if (value && parameter.max && value > parameter.max) { - if (!parameter.min) { - onFilterValueChange({ - operator, - parameter: { min: parameter.max, max: value }, - }) - } else { - const diff = parameter.max.getTime() - parameter.min.getTime() - onFilterValueChange({ - operator, - parameter: { min: value, max: new Date(value.getTime() + diff) }, - }) - } - } else { - onFilterValueChange({ - operator, - parameter: { ...parameter, min: value }, - }) - } - setTemporaryMinDateValue(null) - }} - allowRemove={true} - outsideClickCloses={false} - className="min-w-64" - /> - - setTemporaryMaxDateValue(value)} - onEditComplete={value => { - if (value && parameter.min && value < parameter.min) { - if (!parameter.max) { - onFilterValueChange({ - operator, - parameter: { min: value, max: parameter.min }, - }) - } else { - const diff = parameter.max.getTime() - parameter.min.getTime() - onFilterValueChange({ - operator, - parameter: { min: new Date(value.getTime() - diff), max: value }, - }) - } - } else { - onFilterValueChange({ - operator, - parameter: { ...parameter, max: value }, - }) - } - }} - allowRemove={true} - outsideClickCloses={false} - className="min-w-64" - /> -
    -
    - - - { - onFilterValueChange({ - operator, - parameter: { compareDatetime }, - }) - }} - allowRemove={true} - outsideClickCloses={false} - className="min-w-64" - /> - - - - {translation('noParameterRequired')} - - -
    - ) -} -export type BooleanFilterProps = TableFilterBaseProps - -export const BooleanFilter = ({ filterValue, onFilterValueChange }: BooleanFilterProps) => { - const translation = useHightideTranslation() - const operator = filterValue?.operator ?? 'booleanIsTrue' - - const availableOperators = useMemo(() => [ - ...TableFilterOperator.boolean, - ...TableFilterOperator.generic, - ], []) - - - return ( -
    - -
    - ) -} - -export type TagsFilterProps = TableFilterBaseProps - -export const TagsFilter = ({ columnId, filterValue, onFilterValueChange }: TagsFilterProps) => { - const translation = useHightideTranslation() - const { table } = useTableStateWithoutSizingContext() - const operator = filterValue?.operator ?? 'tagsContains' - const parameter = filterValue?.parameter ?? {} - - const availableOperators = useMemo(() => [ - ...TableFilterOperator.multiTags, - ...TableFilterOperator.generic, - ], []) - - const availableTags = useMemo(() => { - const column = table.getColumn(columnId) - if (!column) return [] - return column.columnDef.meta?.filterData?.tags ?? [] - }, [columnId, table]) - - if (availableTags.length === 0) { - return null - } - - const needsParameterInput = operator !== 'undefined' && operator !== 'notUndefined' - - return ( -
    - - {translation('parameter')} - - String(tag)) : []} - onValueChange={(selectedTags: string[]) => { - onFilterValueChange({ - operator, - parameter: { searchTags: selectedTags.length > 0 ? selectedTags : undefined }, - }) - }} - buttonProps={{ className: 'min-w-64' }} - > - {availableTags.map(({ tag, label }) => ( - - {label} - - ))} - - - - - {translation('noParameterRequired')} - - -
    - ) -} - -export type TagsSingleFilterProps = TableFilterBaseProps -export const TagsSingleFilter = ({ columnId, filterValue, onFilterValueChange }: TagsSingleFilterProps) => { - const translation = useHightideTranslation() - const { table } = useTableStateWithoutSizingContext() - const operator = filterValue?.operator ?? 'tagsSingleContains' - const parameter = filterValue?.parameter ?? {} - - const availableOperators = useMemo(() => [ - ...TableFilterOperator.singleTag, - ...TableFilterOperator.generic, - ], []) - - const availableTags = useMemo(() => { - const column = table.getColumn(columnId) - if (!column) return [] - return column.columnDef.meta?.filterData?.tags ?? [] - }, [columnId, table]) - - if (availableTags.length === 0) { - return null - } - - const needsParameterInput = operator !== 'undefined' && operator !== 'notUndefined' - const needsMultiSelect = operator === 'tagsSingleContains' || operator === 'tagsSingleNotContains' - - return ( -
    - - {translation('parameter')} - - String(tag)) : []} - onValueChange={(selectedTags: string[]) => { - onFilterValueChange({ - operator, - parameter: { searchTagsContains: selectedTags.length > 0 ? selectedTags : undefined }, - }) - }} - buttonProps={{ className: 'min-w-64' }} - > - {availableTags.map(({ tag, label }) => ( - - ))} - - - - - - - - {translation('noParameterRequired')} - - -
    - ) -} - -export type GenericFilterProps = TableFilterBaseProps - -export const GenericFilter = ({ filterValue, onFilterValueChange }: GenericFilterProps) => { - const translation = useHightideTranslation() - const operator = filterValue?.operator ?? 'notUndefined' - - const availableOperators = useMemo(() => [ - ...TableFilterOperator.generic, - ], []) - - return ( -
    - -
    - ) -} - -export interface TableFilterContentProps extends TableFilterBaseProps { - filterType: TableFilterCategory, -} - -export const TableFilterContent = ({ filterType, ...props }: TableFilterContentProps) => { - switch (filterType) { - case 'text': - return } /> - case 'number': - return } /> - case 'date': - return } /> - case 'dateTime': - return } /> - case 'boolean': - return } /> - case 'multiTags': - return } /> - case 'singleTag': - return } /> - case 'generic': - return } /> - default: - return null - } -} diff --git a/src/components/layout/table/TableHeader.tsx b/src/components/layout/table/TableHeader.tsx index 61618f2..c36230a 100644 --- a/src/components/layout/table/TableHeader.tsx +++ b/src/components/layout/table/TableHeader.tsx @@ -5,9 +5,8 @@ import { Visibility } from '../Visibility' import { TableSortButton } from './TableSortButton' import { TableFilterButton } from './TableFilterButton' import { useCallback, useEffect } from 'react' -import type { TableFilterCategory } from './TableFilter' -import { isTableFilterCategory } from './TableFilter' import { TableStateContext, useTableStateWithoutSizingContext } from './TableContext' +import { DataTypeUtils, type DataType } from '../../user-interaction/data/data-types' export type TableHeaderProps = { isSticky?: boolean, @@ -125,10 +124,10 @@ export const TableHeader = ({ isSticky = false }: TableHeaderProps) => { }} /> - + {flexRender( diff --git a/src/components/layout/table/TablePagination.tsx b/src/components/layout/table/TablePagination.tsx index 56327af..13e2db4 100644 --- a/src/components/layout/table/TablePagination.tsx +++ b/src/components/layout/table/TablePagination.tsx @@ -1,7 +1,7 @@ import { Pagination, type PaginationProps } from '@/src/components/layout/navigation/Pagination' import type { HTMLAttributes } from 'react' import { Select, type SelectProps } from '@/src/components/user-interaction/select/Select' -import { SelectOption } from '@/src/components/user-interaction/select/SelectContent' +import { SelectOption } from '@/src/components/user-interaction/select/SelectOption' import { Visibility } from '../Visibility' import clsx from 'clsx' import { useTableStateWithoutSizingContext } from './TableContext' @@ -27,7 +27,7 @@ export const TablePaginationMenu = ({ ...props }: TablePaginationMenuProps) => { const defaultPageSizeOptions: number[] = [10, 25, 50, 100, 500, 1000] as const -export interface TablePageSizeSelectProps extends SelectProps { +export interface TablePageSizeSelectProps extends Omit { pageSizeOptions?: number[], } diff --git a/src/components/layout/table/TableProvider.tsx b/src/components/layout/table/TableProvider.tsx index 239fc31..62e4f31 100644 --- a/src/components/layout/table/TableProvider.tsx +++ b/src/components/layout/table/TableProvider.tsx @@ -13,16 +13,16 @@ import { useWindowResizeObserver } from '@/src/hooks/useResizeCallbackWrapper' import { AutoColumnOrderFeature } from './AutoColumnOrderFeature' export type TableProviderProps = { - data: T[], - columns?: ColumnDef[], - children?: ReactNode, - isUsingFillerRows?: boolean, - fillerRowCell?: (columnId: string, table: ReactTable) => ReactNode, - initialState?: InitialTableState, - onRowClick?: (row: Row, table: ReactTable) => void, - onFillerRowClick?: (index: number, table: ReactTable) => void, - state?: Partial, - } & Partial> + data: T[], + columns?: ColumnDef[], + children?: ReactNode, + isUsingFillerRows?: boolean, + fillerRowCell?: (columnId: string, table: ReactTable) => ReactNode, + initialState?: InitialTableState, + onRowClick?: (row: Row, table: ReactTable) => void, + onFillerRowClick?: (index: number, table: ReactTable) => void, + state?: Partial, +} & Partial> export const TableProvider = ({ data, @@ -133,7 +133,7 @@ export const TableProvider = ({ boolean: TableFilter.boolean, multiTags: TableFilter.multiTags, singleTag: TableFilter.singleTag, - generic: TableFilter.generic, + unknownType: TableFilter.unknownType, }, _features: [ ...(tableOptions._features ?? []), @@ -254,4 +254,4 @@ export const TableProvider = ({ ) -} \ No newline at end of file +} diff --git a/src/components/layout/table/types.ts b/src/components/layout/table/types.ts index 75ab334..89d24ea 100644 --- a/src/components/layout/table/types.ts +++ b/src/components/layout/table/types.ts @@ -24,7 +24,7 @@ declare module '@tanstack/react-table' { boolean: FilterFn, multiTags: FilterFn, singleTag: FilterFn, - generic: FilterFn, + unknownType: FilterFn, } // eslint-disable-next-line @typescript-eslint/no-unused-vars diff --git a/src/components/user-interaction/Button.tsx b/src/components/user-interaction/Button.tsx index 0b4c7f0..0c22130 100644 --- a/src/components/user-interaction/Button.tsx +++ b/src/components/user-interaction/Button.tsx @@ -6,7 +6,7 @@ import { forwardRef } from 'react' */ type ButtonSize = 'xs' | 'sm' | 'md' | 'lg' | null -type ButtonColoringStyle = 'outline' | 'solid' | 'text' | 'tonal' | null +type ButtonColoringStyle = 'outline' | 'solid' | 'text' | 'tonal' | 'tonal-outline' | null const buttonColorsList = ['primary', 'secondary', 'positive', 'warning', 'negative', 'neutral'] as const diff --git a/src/components/user-interaction/Combobox.tsx b/src/components/user-interaction/Combobox.tsx new file mode 100644 index 0000000..e4c6824 --- /dev/null +++ b/src/components/user-interaction/Combobox.tsx @@ -0,0 +1,317 @@ +import type { HTMLAttributes, ReactNode, RefObject } from 'react' +import { + createContext, + useCallback, + useContext, + useEffect, + useId, + useMemo, + useRef, + useState +} from 'react' +import { forwardRef } from 'react' +import clsx from 'clsx' +import { MultiSearchWithMapping } from '@/src/utils/simpleSearch' +import { Input } from '@/src/components/user-interaction/input/Input' +import { useHightideTranslation } from '@/src/i18n/useHightideTranslation' + +type RegisteredOption = { + value: string, + label: string, + display?: ReactNode, + ref: React.RefObject, +} + +type ComboboxContextIds = { + root: string, + listbox: string, +} + +type ComboboxContextType = { + ids: ComboboxContextIds, + searchString: string, + setSearchString: (s: string) => void, + options: RegisteredOption[], + visibleOptions: RegisteredOption[], + registerOption: (option: Omit & { ref: React.RefObject }) => () => void, + highlightedValue: string | undefined, + highlightItem: (value: string) => void, + moveHighlightedIndex: (delta: number) => void, + onItemClick: (id: string) => void, + listRef: React.RefObject, +} + +const ComboboxContext = createContext(null) + +function useComboboxContext() { + const ctx = useContext(ComboboxContext) + if (!ctx) { + throw new Error('Combobox components must be used within ComboboxRoot') + } + return ctx +} + +export interface ComboboxRootProps { + children: ReactNode, + onItemClick: (id: string) => void, + id?: string, +} + +export function ComboboxRoot({ children, onItemClick, id: idProp }: ComboboxRootProps) { + const generatedId = useId() + const rootId = idProp ?? `combobox-${generatedId}` + const listboxId = `${rootId}-listbox` + + const [searchString, setSearchString] = useState('') + const [options, setOptions] = useState([]) + const [highlightedValue, setHighlightedValue] = useState(undefined) + const listRef = useRef(null) + + const visibleOptions = useMemo(() => { + const q = searchString.trim().toLowerCase() + if (!q) return options + return MultiSearchWithMapping(searchString, options, o => [o.label]) + }, [options, searchString]) + + const registerOption = useCallback((option: RegisteredOption) => { + setOptions(prev => { + const next = prev.filter(o => o.value !== option.value) + next.push(option) + next.sort((a, b) => { + const aEl = a.ref.current + const bEl = b.ref.current + if (!aEl || !bEl) return 0 + return aEl.compareDocumentPosition(bEl) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1 + }) + return next + }) + return () => setOptions(prev => prev.filter(o => o.value !== option.value)) + }, []) + + const highlightItem = useCallback((value: string) => { + setHighlightedValue(value) + }, []) + + const moveHighlightedIndex = useCallback((delta: number) => { + setHighlightedValue(prev => { + const list = visibleOptions + if (list.length === 0) return undefined + const idx = list.findIndex(o => o.value === prev) + const nextIdx = idx < 0 ? 0 : (idx + delta + list.length) % list.length + return list[nextIdx]?.value + }) + }, [visibleOptions]) + + useEffect(() => { + const inList = visibleOptions.some(o => o.value === highlightedValue) + if (!inList && visibleOptions.length > 0) { + setHighlightedValue(visibleOptions[0].value) + } else if (!inList) { + setHighlightedValue(undefined) + } + }, [highlightedValue, visibleOptions]) + + useEffect(() => { + if (!highlightedValue) return + const opt = visibleOptions.find(o => o.value === highlightedValue) + opt?.ref.current?.scrollIntoView({ behavior: 'instant', block: 'nearest' }) + }, [highlightedValue, visibleOptions]) + + const value: ComboboxContextType = useMemo(() => ({ + ids: { root: rootId, listbox: listboxId }, + searchString, + setSearchString, + options, + visibleOptions, + registerOption, + highlightedValue, + highlightItem, + moveHighlightedIndex, + onItemClick, + listRef, + }), [ + rootId, + listboxId, + searchString, + options, + visibleOptions, + registerOption, + highlightedValue, + highlightItem, + moveHighlightedIndex, + onItemClick, + ]) + + return ( + +
    + {children} +
    +
    + ) +} + +export interface ComboboxInputProps extends Omit, 'value' | 'onValueChange'> { + value?: string, + onValueChange?: (value: string) => void, +} + +export const ComboboxInput = forwardRef(function ComboboxInput(props, ref) { + const translation = useHightideTranslation() + const { + searchString, + setSearchString, + visibleOptions, + highlightedValue, + moveHighlightedIndex, + onItemClick, + ids, + } = useComboboxContext() + + const handleKeyDown = useCallback((event: React.KeyboardEvent) => { + props.onKeyDown?.(event) + switch (event.key) { + case 'ArrowDown': + moveHighlightedIndex(1) + event.preventDefault() + break + case 'ArrowUp': + moveHighlightedIndex(-1) + event.preventDefault() + break + case 'Enter': + if (highlightedValue) { + onItemClick(highlightedValue) + event.preventDefault() + } + break + default: + break + } + }, [props, moveHighlightedIndex, highlightedValue, onItemClick]) + + return ( + 0} + aria-controls={ids.listbox} + aria-activedescendant={highlightedValue ? `highlightedValue` : undefined} + aria-autocomplete="list" + /> + ) +}) + +export type ComboboxListProps = HTMLAttributes + +export const ComboboxList = forwardRef(function ComboboxList( + { children, className, ...props }, + ref +) { + const { ids, listRef } = useComboboxContext() + + const setRefs = useCallback((node: HTMLUListElement | null) => { + (listRef as RefObject).current = node + if (typeof ref === 'function') ref(node) + else if (ref) (ref as RefObject).current = node + }, [ref, listRef]) + + return ( +
      + {children} +
    + ) +}) + +export interface ComboboxOptionProps extends Omit, 'children'> { + value: string, + label: string, + children?: ReactNode, +} + +export const ComboboxOption = forwardRef(function ComboboxOption( + { children, value, label, className, ...restProps }, + ref +) { + const { visibleOptions, registerOption, highlightItem, onItemClick, highlightedValue } = useComboboxContext() + const itemRef = useRef(null) + + const display = children ?? label + + useEffect(() => { + const unregister = registerOption({ + value, + label, + display, + ref: itemRef, + }) + return unregister + }, [value, label, registerOption, display]) + + const isVisible = visibleOptions.some(o => o.value === value) + const highlighted = highlightedValue === value + + return ( +
  • { + itemRef.current = node + if (typeof ref === 'function') ref(node) + else if (ref) (ref as RefObject).current = node + }} + id={value} + role="option" + aria-selected={highlighted} + aria-hidden={!isVisible} + data-name="combobox-option" + data-highlighted={highlighted ? '' : undefined} + data-visible={isVisible ? '' : undefined} + className={clsx(!isVisible && 'hidden', className)} + onClick={event => { + onItemClick(value) + restProps.onClick?.(event) + }} + onMouseEnter={event => { + highlightItem(value) + restProps.onMouseEnter?.(event) + }} + > + {display} +
  • + ) +}) + +ComboboxOption.displayName = 'ComboboxOption' + +export interface ComboboxProps { + children: ReactNode, + onItemClick: (id: string) => void, + id?: string, + inputProps?: ComboboxInputProps, + listProps?: ComboboxListProps, +} + +export function Combobox({ children, onItemClick, id, inputProps, listProps }: ComboboxProps) { + return ( + + + {children} + + ) +} diff --git a/src/components/user-interaction/IconButton.tsx b/src/components/user-interaction/IconButton.tsx index d7630d8..edb7398 100644 --- a/src/components/user-interaction/IconButton.tsx +++ b/src/components/user-interaction/IconButton.tsx @@ -10,7 +10,7 @@ import { useLogOnce } from '@/src/hooks/useLogOnce' */ type IconButtonSize = 'xs' | 'sm' | 'md' | 'lg' | null -type IconButtonColoringStyle = 'outline' | 'solid' | 'text' | 'tonal' | null +type IconButtonColoringStyle = 'outline' | 'solid' | 'text' | 'tonal' | 'tonal-outline' | null @@ -23,7 +23,6 @@ export interface IconButtonBaseProps extends ButtonHTMLAttributes(function IconButtonBase({ @@ -31,7 +30,6 @@ export const IconButtonBase = forwardRef size = 'md', color = 'primary', coloringStyle = 'solid', - allowClickEventPropagation = false, disabled, ...props }, ref) { @@ -44,9 +42,6 @@ export const IconButtonBase = forwardRef onClick={event => { - if(!allowClickEventPropagation) { - event.stopPropagation() - } props.onClick?.(event) }} diff --git a/src/components/user-interaction/data/FilterList.tsx b/src/components/user-interaction/data/FilterList.tsx new file mode 100644 index 0000000..5d29970 --- /dev/null +++ b/src/components/user-interaction/data/FilterList.tsx @@ -0,0 +1,122 @@ +import { useMemo, useState, type ReactNode } from 'react' +import type { FilterValue } from './filter-function' +import { useFilterValueTranslation } from './filter-function' +import { DataTypeUtils, type DataType } from './data-types' +import { useHightideTranslation } from '@/src/i18n/useHightideTranslation' +import { PlusIcon } from 'lucide-react' +import { PopUpRoot } from '../../layout/popup/PopUpRoot' +import { PopUp } from '../../layout/popup/PopUp' +import { PopUpOpener } from '../../layout/popup/PopUpOpener' +import { Button } from '../Button' +import { FilterPopUp } from './FilterPopUp' +import { Combobox, ComboboxOption } from '../Combobox' +import { PopUpContext } from '../../layout/popup/PopUpContext' +import { ExpansionIcon } from '../../display-and-visualization/ExpansionIcon' +import { FilterOperatorUtils } from './FilterOperator' + +export interface IdentifierFilterValue extends FilterValue { + id: string, +} + +export interface FilterListItem { + id: string, + label: string, + display?: ReactNode, + dataType: DataType, + tags: ReadonlyArray<{ tag: string, label: string, display?: ReactNode }>, +} + +export interface FilterListProps { + value: IdentifierFilterValue[], + onValueChange: (value: IdentifierFilterValue[]) => void, + availableItems: FilterListItem[], +} + +export const FilterList = ({ value, onValueChange, availableItems }: FilterListProps) => { + const translation = useHightideTranslation() + const filterValueToLabel = useFilterValueTranslation() + const activeIds = useMemo(() => value.map((item) => item.id), [value]) + const inactiveItems = useMemo(() => availableItems.filter((item) => !activeIds.includes(item.id)).sort((a, b) => a.label.localeCompare(b.label)), [availableItems, activeIds]) + const itemRecord = useMemo(() => availableItems.reduce((acc, item) => { + acc[item.id] = item + return acc + }, {} as Record), [availableItems]) + const [editState, setEditState] = useState(undefined) + + return ( +
    + + + {({ toggleOpen, props }) => ( + + )} + + + + {({ setIsOpen }) => ( + { + const item = itemRecord[id] + if(!item) return + const newValue: IdentifierFilterValue = { + id: item.id, + dataType: item.dataType, + operator: FilterOperatorUtils.getDefaultOperator(item.dataType), + parameter: {} + } + onValueChange([...value, newValue]) + setEditState(newValue) + setIsOpen(false) + }}> + {inactiveItems.map(item => ( + + {DataTypeUtils.toIcon(item.dataType)} + {item.label} + + ))} + + )} + + + + {value.map(filterValue => { + const item = itemRecord[filterValue.id] + if(!item) return null + return ( + { + if (!isOpen) { + onValueChange(value.map(prevItem => prevItem.id === filterValue.id ? { ...prevItem, ...(editState ?? {}) } : prevItem)) + setEditState(undefined) + } else { + const valueItem = value.find(prevItem => prevItem.id === filterValue.id) + setEditState({ ...valueItem }) + } + }} + > + + {({ toggleOpen, props, isOpen }) => ( + + )} + + setEditState({ ...filterValue, ...value })} + onRemove={() => onValueChange(value.filter(prevItem => prevItem.id !== filterValue.id))} + /> + + ) + })} +
    + ) +} \ No newline at end of file diff --git a/src/components/user-interaction/data/FilterOperator.tsx b/src/components/user-interaction/data/FilterOperator.tsx new file mode 100644 index 0000000..f4d5eb7 --- /dev/null +++ b/src/components/user-interaction/data/FilterOperator.tsx @@ -0,0 +1,183 @@ +import { + ChevronRight, + ChevronLeft, + CheckCircle2, + XCircle, + Equal, + EqualNot, + SearchCheck, + SearchX, + CircleDashed, + CircleDot +} from 'lucide-react' +import type { DataType } from '@/src/components/user-interaction/data/data-types' +import type { ReactNode } from 'react' + +const filterOperators = [ + 'equals', 'notEquals', 'contains', 'notContains', 'startsWith', 'endsWith', + 'greaterThan', 'greaterThanOrEqual', 'lessThan', 'lessThanOrEqual', 'between', 'notBetween', + 'isTrue', 'isFalse', + 'isUndefined', 'isNotUndefined' +] as const + +export type FilterOperator = (typeof filterOperators)[number] + +const filterOperatorsByCategory: Record = { + text: ['equals', 'notEquals', 'contains', 'notContains', 'startsWith', 'endsWith', 'isUndefined', 'isNotUndefined'], + number: ['equals', 'notEquals', 'greaterThan', 'greaterThanOrEqual', 'lessThan', 'lessThanOrEqual', 'between', 'notBetween', 'isUndefined', 'isNotUndefined'], + date: ['equals', 'notEquals', 'greaterThan', 'greaterThanOrEqual', 'lessThan', 'lessThanOrEqual', 'between', 'notBetween', 'isUndefined', 'isNotUndefined'], + dateTime: ['equals', 'notEquals', 'greaterThan', 'greaterThanOrEqual', 'lessThan', 'lessThanOrEqual', 'between', 'notBetween', 'isUndefined', 'isNotUndefined'], + boolean: ['isTrue', 'isFalse', 'isUndefined', 'isNotUndefined'], + multiTags: ['equals', 'notEquals', 'contains', 'notContains', 'isUndefined', 'isNotUndefined'], + singleTag: ['equals', 'notEquals', 'contains', 'notContains', 'isUndefined', 'isNotUndefined'], + unknownType: ['isUndefined', 'isNotUndefined'], +} as const + +export type FilterOperatorUnknownType = (typeof filterOperatorsByCategory.unknownType)[number] +export type FilterOperatorText = (typeof filterOperatorsByCategory.text)[number] +export type FilterOperatorNumber = (typeof filterOperatorsByCategory.number)[number] +export type FilterOperatorDate = (typeof filterOperatorsByCategory.date)[number] +export type FilterOperatorDatetime = (typeof filterOperatorsByCategory.dateTime)[number] +export type FilterOperatorBoolean = (typeof filterOperatorsByCategory.boolean)[number] +export type FilterOperatorTags = (typeof filterOperatorsByCategory.multiTags)[number] +export type FilterOperatorTagsSingle = (typeof filterOperatorsByCategory.singleTag)[number] + +function isFilterOperatorText(value: unknown): value is FilterOperatorText { + return typeof value === 'string' && filterOperatorsByCategory.text.some(o => o === value) +} + +function isFilterOperatorNumber(value: unknown): value is FilterOperatorNumber { + return typeof value === 'string' && filterOperatorsByCategory.number.some(o => o === value) +} + +function isFilterOperatorDate(value: unknown): value is FilterOperatorDate { + return typeof value === 'string' && filterOperatorsByCategory.date.some(o => o === value) +} + +function isFilterOperatorDatetime(value: unknown): value is FilterOperatorDatetime { + return typeof value === 'string' && filterOperatorsByCategory.dateTime.some(o => o === value) +} + +function isFilterOperatorBoolean(value: unknown): value is FilterOperatorBoolean { + return typeof value === 'string' && filterOperatorsByCategory.boolean.some(o => o === value) +} + +function isFilterOperatorTags(value: unknown): value is FilterOperatorTags { + return typeof value === 'string' && filterOperatorsByCategory.multiTags.some(o => o === value) +} + +function isFilterOperatorTagsSingle(value: unknown): value is FilterOperatorTagsSingle { + return typeof value === 'string' && filterOperatorsByCategory.singleTag.some(o => o === value) +} + +function isFilterOperatorUnknownType(value: unknown): value is FilterOperatorUnknownType { + return typeof value === 'string' && filterOperatorsByCategory.unknownType.some(o => o === value) +} + +function isFilterOperator(value: unknown): value is FilterOperator { + return typeof value === 'string' && filterOperators.some(o => o === value) +} + +type OperatorInfoResult = { + icon: ReactNode, + translationKey: string, + replacementTranslationKey: string, +} +const getOperatorInfo = (operator: FilterOperator) : OperatorInfoResult => { + switch (operator) { + case 'equals': return { icon: , translationKey: 'equals', replacementTranslationKey: 'rEquals' } + case 'notEquals': return { icon: , translationKey: 'notEquals', replacementTranslationKey: 'rNotEquals' } + case 'contains': return { icon: , translationKey: 'contains', replacementTranslationKey: 'rContains' } + case 'notContains': return { icon: , translationKey: 'notContains', replacementTranslationKey: 'rNotContains' } + case 'startsWith': return { icon: , translationKey: 'startsWith', replacementTranslationKey: 'rStartsWith' } + case 'endsWith': return { icon: , translationKey: 'endsWith', replacementTranslationKey: 'rEndsWith' } + case 'greaterThan': return { + icon: (
    + + +
    + ), + translationKey: 'greaterThanOrEqual', + replacementTranslationKey: 'rGreaterThanOrEqual' + } + case 'greaterThanOrEqual': return { + icon: (
    + + +
    + ), + translationKey: 'greaterThanOrEqual', + replacementTranslationKey: 'rGreaterThanOrEqual' + } + case 'lessThan': return { + icon: , + translationKey: 'lessThan', + replacementTranslationKey: 'rLessThan' + } + case 'lessThanOrEqual': return { + icon: (
    + + +
    + ), + translationKey: 'lessThanOrEqual', + replacementTranslationKey: 'rLessThanOrEqual' + } + case 'between': return { + icon: (
    + + +
    + ), + translationKey: 'between', + replacementTranslationKey: 'rBetween' + } + case 'notBetween': return { + icon: (
    + + +
    + ), + translationKey: 'notBetween', + replacementTranslationKey: 'rNotBetween' + } + case 'isTrue': return { icon: , translationKey: 'isTrue', replacementTranslationKey: 'isTrue' } + case 'isFalse': return { icon: , translationKey: 'isFalse', replacementTranslationKey: 'isFalse' } + case 'isUndefined': return { icon: , translationKey: 'isUndefined', replacementTranslationKey: 'isUndefined' } + case 'isNotUndefined': return { icon: , translationKey: 'isNotUndefined', replacementTranslationKey: 'isNotUndefined' } + default: return { icon: null, translationKey: 'unknown translation key', replacementTranslationKey: 'unknown' } + } +} + +function getDefaultOperator(dataType: DataType): FilterOperator { + switch (dataType) { + case 'text': return 'contains' + case 'number': return 'between' + case 'date': return 'between' + case 'dateTime': return 'between' + case 'boolean': return 'isTrue' + case 'multiTags': return 'contains' + case 'singleTag': return 'contains' + case 'unknownType': return 'isNotUndefined' + } +} + + + +export const FilterOperatorUtils = { + operators: filterOperators, + operatorsByCategory: filterOperatorsByCategory, + getInfo: getOperatorInfo, + getDefaultOperator, + typeCheck: { + all: isFilterOperator, + text: isFilterOperatorText, + number: isFilterOperatorNumber, + date: isFilterOperatorDate, + datetime: isFilterOperatorDatetime, + boolean: isFilterOperatorBoolean, + tags: isFilterOperatorTags, + tagsSingle: isFilterOperatorTagsSingle, + unknownType: isFilterOperatorUnknownType, + }, +} diff --git a/src/components/user-interaction/data/FilterOperatorLabel.tsx b/src/components/user-interaction/data/FilterOperatorLabel.tsx new file mode 100644 index 0000000..ec6f0ec --- /dev/null +++ b/src/components/user-interaction/data/FilterOperatorLabel.tsx @@ -0,0 +1,20 @@ +import { useHightideTranslation } from '@/src/i18n/useHightideTranslation' +import type { FilterOperator } from '@/src/components/user-interaction/data/FilterOperator' +import { FilterOperatorUtils } from '@/src/components/user-interaction/data/FilterOperator' + +export type FilterOperatorLabelProps = { + operator: FilterOperator, + } + +export const FilterOperatorLabel = ({ operator }: FilterOperatorLabelProps) => { + const translation = useHightideTranslation() + const { icon, translationKey } = FilterOperatorUtils.getInfo(operator) + const label = typeof translationKey === 'string' ? translation(translationKey) : translationKey + + return ( +
    + {icon} + {label} +
    + ) +} \ No newline at end of file diff --git a/src/components/user-interaction/data/FilterPopUp.tsx b/src/components/user-interaction/data/FilterPopUp.tsx new file mode 100644 index 0000000..44b5918 --- /dev/null +++ b/src/components/user-interaction/data/FilterPopUp.tsx @@ -0,0 +1,758 @@ +import { useHightideTranslation } from '@/src/i18n/useHightideTranslation' +import { Visibility } from '@/src/components/layout/Visibility' +import { IconButton } from '@/src/components/user-interaction/IconButton' +import { TrashIcon, XIcon } from 'lucide-react' +import { PopUp, type PopUpProps } from '@/src/components/layout/popup/PopUp' +import type { FilterValue } from './filter-function' +import type { FilterOperator } from './FilterOperator' +import { FilterOperatorUtils } from './FilterOperator' +import type { ReactNode } from 'react' +import { forwardRef, useId, useMemo, useState } from 'react' +import { Select } from '../select/Select' +import { SelectOption } from '../select/SelectOption' +import { Input } from '../input/Input' +import { Checkbox } from '../Checkbox' +import { DateTimeInput } from '../input/DateTimeInput' +import { MultiSelect } from '../select/MultiSelect' +import { MultiSelectOption } from '../select/SelectOption' +import type { DataType } from './data-types' +import clsx from 'clsx' +import { FilterOperatorLabel } from './FilterOperatorLabel' + +export interface FilterPopUpProps extends PopUpProps { + name?: ReactNode, + value?: FilterValue, + onValueChange: (value: FilterValue) => void, + onRemove: () => void, +} + +export interface FilterPopUpBaseProps extends PopUpProps { + /** + * The name of the object/column the filter is applied to + */ + name?: ReactNode, + operator: FilterOperator, + onOperatorChange: (operator: FilterOperator) => void, + onRemove: () => void, + allowedOperators: FilterOperator[], + hasValue: boolean, + noParameterRequired?: boolean, +} + +export const FilterBasePopUp = forwardRef(function FilterBasePopUp ({ + children, + name, + operator, + onOperatorChange, + onRemove, + allowedOperators, + hasValue, + noParameterRequired = false, + ...props +}: FilterPopUpBaseProps, ref) { + const translation = useHightideTranslation() + + return ( + +
    +
    + {name ?? translation('filter')} + +
    + + + + + + + + + + +
    + {children} + +
    + {translation('noParameterRequired')} +
    +
    +
    + ) +}) + + +export const TextFilterPopUp = forwardRef(function TextFilterPopUp ({ + name, value, onValueChange, onRemove, ...props +}: FilterPopUpProps, ref) { + const translation = useHightideTranslation() + const id = useId() + const ids = { + search: `text-filter-search-${id}`, + caseSensitive: `text-filter-case-sensitive-${id}`, + } + const operator = useMemo(() => { + const suggestion = value?.operator ?? 'contains' + if(!FilterOperatorUtils.typeCheck.text(suggestion)) { + return 'contains' + } + return suggestion + }, [value]) + const parameter = value?.parameter ?? {} + + const needsParameterInput = operator !== 'isUndefined' && operator !== 'isNotUndefined' + return ( + onValueChange({ dataType: 'text', parameter, operator: newOperator })} + onRemove={onRemove} + allowedOperators={FilterOperatorUtils.operatorsByCategory.text} + hasValue={!!value} + noParameterRequired={!needsParameterInput} + > + +
    + + { + onValueChange({ + dataType: 'text', + operator, + parameter: { ...parameter, searchText }, + }) + }} + className="min-w-64" + /> +
    +
    + { + onValueChange({ + dataType: 'text', + operator, + parameter: { ...parameter, isCaseSensitive }, + }) + }} + /> + +
    +
    +
    + ) +}) + +export const NumberFilterPopUp = forwardRef(function NumberFilterPopUp ({ + name, value, onValueChange, onRemove, ...props +}: FilterPopUpProps, ref) { + const translation = useHightideTranslation() + const id = useId() + const ids = { + min: `number-filter-min-${id}`, + max: `number-filter-max-${id}`, + compareValue: `number-filter-compare-value-${id}`, + } + const operator = useMemo(() => { + const suggestion = value?.operator ?? 'between' + if (!FilterOperatorUtils.typeCheck.number(suggestion)) { + return 'between' + } + return suggestion + }, [value]) + const parameter = value?.parameter ?? {} + + const needsRangeInput = operator === 'between' || operator === 'notBetween' + const needsParameterInput = operator !== 'isUndefined' && operator !== 'isNotUndefined' + + return ( + onValueChange({ dataType: 'number', parameter, operator: newOperator })} + onRemove={onRemove} + allowedOperators={FilterOperatorUtils.operatorsByCategory.number} + hasValue={!!value} + noParameterRequired={!needsParameterInput} + > + +
    + + { + const num = Number(text) + onValueChange({ + dataType: 'number', + operator, + parameter: { ...parameter, minNumber: isNaN(num) ? undefined : num }, + }) + }} + className="min-w-64" + /> +
    +
    + + { + const num = Number(text) + onValueChange({ + dataType: 'number', + operator, + parameter: { ...parameter, maxNumber: isNaN(num) ? undefined : num }, + }) + }} + className="min-w-64" + /> +
    +
    + + { + const num = Number(text) + onValueChange({ + dataType: 'number', + operator, + parameter: { ...parameter, compareValue: isNaN(num) ? undefined : num }, + }) + }} + className="min-w-64" + /> + +
    + ) +}) + +export const DateFilterPopUp = forwardRef(function DateFilterPopUp ({ + name, value, onValueChange, onRemove, ...props +}: FilterPopUpProps, ref) { + const translation = useHightideTranslation() + const id = useId() + const ids = { + startDate: `date-filter-start-date-${id}`, + endDate: `date-filter-end-date-${id}`, + compareDate: `date-filter-compare-date-${id}`, + } + const operator = useMemo(() => { + const suggestion = value?.operator ?? 'between' + if (!FilterOperatorUtils.typeCheck.date(suggestion)) { + return 'between' + } + return suggestion + }, [value]) + const parameter = value?.parameter ?? {} + const [temporaryMinDateValue, setTemporaryMinDateValue] = useState(null) + const [temporaryMaxDateValue, setTemporaryMaxDateValue] = useState(null) + + const needsRangeInput = operator === 'between' || operator === 'notBetween' + const needsParameterInput = operator !== 'isUndefined' && operator !== 'isNotUndefined' + + return ( + onValueChange({ dataType: 'date', parameter, operator: newOperator })} + onRemove={onRemove} + allowedOperators={FilterOperatorUtils.operatorsByCategory.date} + hasValue={!!value} + noParameterRequired={!needsParameterInput} + > + +
    + + { + if (dateValue && parameter.maxDate && dateValue > parameter.maxDate) { + if (!parameter.minDate) { + onValueChange({ + dataType: 'date', + operator, + parameter: { ...parameter, minDate: parameter.maxDate, maxDate: dateValue }, + }) + } else { + const diff = parameter.maxDate.getTime() - parameter.minDate.getTime() + onValueChange({ + dataType: 'date', + operator, + parameter: { ...parameter, minDate: dateValue, maxDate: new Date(dateValue.getTime() + diff) }, + }) + } + } else { + onValueChange({ + dataType: 'date', + operator, + parameter: { ...parameter, minDate: dateValue }, + }) + } + setTemporaryMinDateValue(null) + }} + allowRemove={true} + outsideClickCloses={false} + className="min-w-64" + /> +
    +
    + + { + if (dateValue && parameter.minDate && dateValue < parameter.minDate) { + if (!parameter.maxDate) { + onValueChange({ + dataType: 'date', + operator, + parameter: { ...parameter, minDate: dateValue, maxDate: parameter.minDate }, + }) + } else { + const diff = parameter.maxDate.getTime() - parameter.minDate.getTime() + onValueChange({ + dataType: 'date', + operator, + parameter: { ...parameter, minDate: new Date(dateValue.getTime() - diff), maxDate: dateValue }, + }) + } + } else { + onValueChange({ + dataType: 'date', + operator, + parameter: { ...parameter, maxDate: dateValue }, + }) + } + setTemporaryMaxDateValue(null) + }} + allowRemove={true} + outsideClickCloses={false} + className="min-w-64" + /> +
    +
    + + + { + onValueChange({ + ...value, + parameter: { ...parameter, compareDate }, + }) + }} + allowRemove={true} + outsideClickCloses={false} + className="min-w-64" + /> + + + + {translation('noParameterRequired')} + + +
    + ) +}) + +export const DatetimeFilterPopUp = forwardRef(function DatetimeFilterPopUp ({ + name, value, onValueChange, onRemove, ...props +}: FilterPopUpProps, ref) { + const translation = useHightideTranslation() + const id = useId() + const ids = { + startDate: `datetime-filter-start-date-${id}`, + endDate: `datetime-filter-end-date-${id}`, + compareDate: `datetime-filter-compare-date-${id}`, + } + const operator = useMemo(() => { + const suggestion = value?.operator ?? 'between' + if (!FilterOperatorUtils.typeCheck.datetime(suggestion)) { + return 'between' + } + return suggestion + }, [value]) + const parameter = value?.parameter ?? {} + const [temporaryMinDateValue, setTemporaryMinDateValue] = useState(null) + const [temporaryMaxDateValue, setTemporaryMaxDateValue] = useState(null) + + const needsRangeInput = operator === 'between' || operator === 'notBetween' + const needsParameterInput = operator !== 'isUndefined' && operator !== 'isNotUndefined' + + return ( + onValueChange({ dataType: 'dateTime', parameter, operator: newOperator })} + onRemove={onRemove} + allowedOperators={FilterOperatorUtils.operatorsByCategory.dateTime} + hasValue={!!value} + > + {translation('parameter')} + +
    + + { + if (dateValue && parameter.maxDate && dateValue > parameter.maxDate) { + if (!parameter.minDate) { + onValueChange({ + dataType: 'dateTime', + operator, + parameter: { ...parameter, minDate: parameter.maxDate, maxDate: dateValue }, + }) + } else { + const diff = parameter.maxDate.getTime() - parameter.minDate.getTime() + onValueChange({ + dataType: 'dateTime', + operator, + parameter: { ...parameter, minDate: dateValue, maxDate: new Date(dateValue.getTime() + diff) }, + }) + } + } else { + onValueChange({ + dataType: 'dateTime', + operator, + parameter: { ...parameter, minDate: dateValue }, + }) + } + setTemporaryMinDateValue(null) + }} + allowRemove={true} + outsideClickCloses={false} + className="min-w-64" + /> + + { + if (dateValue && parameter.minDate && dateValue < parameter.minDate) { + if (!parameter.maxDate) { + onValueChange({ + dataType: 'dateTime', + operator, + parameter: { ...parameter, minDate: dateValue, maxDate: parameter.minDate }, + }) + } else { + const diff = parameter.maxDate.getTime() - parameter.minDate.getTime() + onValueChange({ + dataType: 'dateTime', + operator, + parameter: { ...parameter, minDate: new Date(dateValue.getTime() - diff), maxDate: dateValue }, + }) + } + } else { + onValueChange({ + dataType: 'dateTime', + operator, + parameter: { ...parameter, maxDate: dateValue }, + }) + } + setTemporaryMaxDateValue(null) + }} + allowRemove={true} + outsideClickCloses={false} + className="min-w-64" + /> +
    +
    + + + { + onValueChange({ + dataType: 'dateTime', + operator, + parameter: { ...parameter, compareDate }, + }) + }} + allowRemove={true} + outsideClickCloses={false} + className="min-w-64" + /> + + + + {translation('noParameterRequired')} + + +
    + ) +}) + +export const BooleanFilterPopUp = forwardRef(function BooleanFilterPopUp ({ + name, value, onValueChange, onRemove, ...props +}: FilterPopUpProps, ref) { + const operator = useMemo(() => { + const suggestion = value?.operator ?? 'isTrue' + if (!FilterOperatorUtils.typeCheck.boolean(suggestion)) { + return 'isTrue' + } + return suggestion + }, [value]) + const parameter = value?.parameter ?? {} + + return ( + onValueChange({ dataType: 'boolean', parameter, operator: newOperator })} + onRemove={onRemove} + allowedOperators={FilterOperatorUtils.operatorsByCategory.boolean} + hasValue={!!value} + /> + ) +}) + +export interface TagsFilterPopUpProps extends FilterPopUpProps { + tags: ReadonlyArray<{ tag: string, label: string, display?: ReactNode }>, +} + +export const TagsFilterPopUp = forwardRef(function TagsFilterPopUp ({ + name, value, onValueChange, onRemove, tags: availableTags, ...props +}: TagsFilterPopUpProps, ref) { + const translation = useHightideTranslation() + const operator = useMemo(() => { + const suggestion = value?.operator ?? 'contains' + if (!FilterOperatorUtils.typeCheck.tags(suggestion)) { + return 'contains' + } + return suggestion + }, [value]) + const parameter = value?.parameter ?? {} + const selectedTags = (Array.isArray(parameter.multiOptionSearch) ? parameter.multiOptionSearch : []) as string[] + + const needsParameterInput = operator !== 'isUndefined' && operator !== 'isNotUndefined' + + if (availableTags.length === 0) { + return null + } + + return ( + onValueChange({ dataType: 'multiTags', parameter, operator: newOperator })} + onRemove={onRemove} + allowedOperators={FilterOperatorUtils.operatorsByCategory.multiTags} + hasValue={!!value} + > + {translation('parameter')} + + { + onValueChange({ + dataType: 'multiTags', + operator, + parameter: { ...parameter, multiOptionSearch: selected.length > 0 ? selected : undefined }, + }) + }} + buttonProps={{ className: 'min-w-64' }} + > + {availableTags.map(({ tag, label }) => ( + + {label} + + ))} + + + + + {translation('noParameterRequired')} + + + + ) +}) + +export interface TagsSingleFilterPopUpProps extends FilterPopUpProps { + tags: ReadonlyArray<{ tag: string, label: string, display?: ReactNode }>, +} + +export const TagsSingleFilterPopUp = forwardRef(function TagsSingleFilterPopUp ({ + name, value, onValueChange, onRemove, tags: availableTags, ...props +}: TagsSingleFilterPopUpProps, ref) { + const translation = useHightideTranslation() + const operator = useMemo(() => { + const suggestion = value?.operator ?? 'contains' + if (!FilterOperatorUtils.typeCheck.tagsSingle(suggestion)) { + return 'contains' + } + return suggestion + }, [value]) + const parameter = value?.parameter ?? {} + const selectedTagsMulti = (Array.isArray(parameter.multiOptionSearch) ? parameter.multiOptionSearch : []) as string[] + const selectedTagSingle = parameter.singleOptionSearch != null ? String(parameter.singleOptionSearch) : undefined + + const needsParameterInput = operator !== 'isUndefined' && operator !== 'isNotUndefined' + const needsMultiSelect = operator === 'contains' || operator === 'notContains' + + if (availableTags.length === 0) { + return null + } + + return ( + onValueChange({ dataType: 'singleTag', parameter, operator: newOperator })} + onRemove={onRemove} + allowedOperators={FilterOperatorUtils.operatorsByCategory.singleTag} + hasValue={!!value} + > + {translation('parameter')} + + { + onValueChange({ + dataType: 'singleTag', + operator, + parameter: { ...parameter, multiOptionSearch: selected.length > 0 ? selected : undefined }, + }) + }} + buttonProps={{ className: 'min-w-64' }} + > + {availableTags.map(({ tag, label }) => ( + + ))} + + + + + + + + {translation('noParameterRequired')} + + + + ) +}) + +export const GenericFilterPopUp = forwardRef(function GenericFilterPopUp ({ name, value, onValueChange, ...props }: FilterPopUpProps, ref) { + const operator = useMemo(() => { + const suggestion = value?.operator ?? 'isNotUndefined' + if (!FilterOperatorUtils.typeCheck.unknownType(suggestion)) { + return 'isNotUndefined' + } + return suggestion + }, [value]) + + return ( + onValueChange({ ...value, operator: newOperator })} + onRemove={() => onValueChange({ ...value, operator: undefined })} + allowedOperators={FilterOperatorUtils.operatorsByCategory.unknownType} + hasValue={!!value} + /> + ) +}) + +export interface DataTypeFilterPopUpProps extends FilterPopUpProps { + dataType: DataType, + tags: ReadonlyArray<{ tag: string, label: string, display?: ReactNode }>, +} +export const FilterPopUp = forwardRef(function FilterPopUp ({ + name, + value, + onValueChange, + dataType, + tags, + ...props +}: DataTypeFilterPopUpProps, ref) { + switch (dataType) { + case 'text': + return + case 'number': + return + case 'date': + return + case 'dateTime': + return + case 'boolean': + return + case 'multiTags': + return + case 'singleTag': + return + case 'unknownType': + return + } +}) \ No newline at end of file diff --git a/src/components/user-interaction/data/data-types.tsx b/src/components/user-interaction/data/data-types.tsx new file mode 100644 index 0000000..aa7206c --- /dev/null +++ b/src/components/user-interaction/data/data-types.tsx @@ -0,0 +1,70 @@ +import { Binary, Calendar, CalendarClock, Check, Database, Tag, Tags, TextIcon } from 'lucide-react' +import type { ReactNode } from 'react' + +const dataTypes = [ + 'text', + 'number', + 'date', + 'dateTime', + 'boolean', + 'singleTag', + 'multiTags', + 'unknownType', +] as const +export type DataType = (typeof dataTypes)[number] + +export interface DataValue { + textValue?: string, + numberValue?: number, + booleanValue?: boolean, + dateValue?: Date, + singleSelectValue?: string, + multiSelectValue?: string[], +} + +const getDefaultValue = (type: DataType, selectOptions?: string[]): DataValue => { + switch (type) { + case 'text': + return { textValue: '' } + case 'number': + return { numberValue: 0 } + case 'boolean': + return { booleanValue: false } + case 'date': + case 'dateTime': + return { dateValue: new Date() } + case 'singleTag': + return { singleSelectValue: selectOptions?.[0] } + case 'multiTags': + return { multiSelectValue: [] } + default: + return {} + } +} + +function toIcon(type: DataType): ReactNode { + switch (type) { + case 'text': + return + case 'number': + return + case 'boolean': + return + case 'date': + return + case 'dateTime': + return + case 'singleTag': + return + case 'multiTags': + return + case 'unknownType': + return + } +} + +export const DataTypeUtils = { + types: dataTypes, + getDefaultValue, + toIcon, +} \ No newline at end of file diff --git a/src/components/user-interaction/data/filter-function.ts b/src/components/user-interaction/data/filter-function.ts new file mode 100644 index 0000000..8282c29 --- /dev/null +++ b/src/components/user-interaction/data/filter-function.ts @@ -0,0 +1,463 @@ +import { useCallback } from 'react' +import { DateUtils } from '@/src/utils/date' +import { useLocale } from '@/src/global-contexts/LocaleContext' +import { useHightideTranslation } from '@/src/i18n/useHightideTranslation' +import { type FilterOperator } from './FilterOperator' +import type { DataType } from './data-types' + +export type FilterParameter = { + searchText?: string, + isCaseSensitive?: boolean, + compareValue?: number, + minNumber?: number, + maxNumber?: number, + compareDate?: Date, + minDate?: Date, + maxDate?: Date, + multiOptionSearch?: unknown[], + singleOptionSearch?: unknown, +} + +export type FilterValue = { + dataType: DataType, + operator: FilterOperator, + parameter: FilterParameter, +} + +/** + * Filters a text value based on the provided filter value. + */ +function filterText(value: unknown, operator: FilterOperator, parameter: FilterParameter): boolean { + const isCaseSensitive = parameter.isCaseSensitive ?? false + + const searchText = isCaseSensitive ? parameter.searchText ?? '' : (parameter.searchText ?? '').toLowerCase() + const cellText = isCaseSensitive ? value?.toString() ?? '' : value?.toString().toLowerCase() ?? '' + + switch (operator) { + case 'equals': + return cellText === searchText + case 'notEquals': + return cellText !== searchText + case 'contains': + return cellText.includes(searchText) + case 'notContains': + return !cellText.includes(searchText) + case 'startsWith': + return cellText.startsWith(searchText) + case 'endsWith': + return cellText.endsWith(searchText) + case 'isUndefined': + return value === undefined || value === null + case 'isNotUndefined': + return value !== undefined && value !== null + default: + return false + } +} + +/** + * Filters a number value based on the provided filter value. + */ +function filterNumber(value: unknown, operator: FilterOperator, parameter: FilterParameter): boolean { + if (typeof value !== 'number') { + if (operator === 'isUndefined') { + return value === undefined || value === null + } + if (operator === 'isNotUndefined') { + return value !== undefined && value !== null + } + return false + } + + switch (operator) { + case 'equals': + return value === parameter.compareValue + case 'notEquals': + return value !== parameter.compareValue + case 'greaterThan': + return value > (parameter.compareValue ?? 0) + case 'greaterThanOrEqual': + return value >= (parameter.compareValue ?? 0) + case 'lessThan': + return value < (parameter.compareValue ?? 0) + case 'lessThanOrEqual': + return value <= (parameter.compareValue ?? 0) + case 'between': + return value >= (parameter.minNumber ?? -Infinity) && value <= (parameter.maxNumber ?? Infinity) + case 'notBetween': + return value < (parameter.minNumber ?? -Infinity) || value > (parameter.maxNumber ?? Infinity) + case 'isUndefined': + return value === undefined || value === null + case 'isNotUndefined': + return value !== undefined && value !== null + default: + return false + } +} + +/** + * Filters a date value based on the provided filter value. + * Only compares dates, ignoring time components. + */ +function filterDate(value: unknown, operator: FilterOperator, parameter: FilterParameter): boolean { + const date = DateUtils.tryParseDate(value as Date | string | number | undefined | null) + if (!date) { + if (operator === 'isUndefined') { + return value === undefined || value === null + } + if (operator === 'isNotUndefined') { + return value !== undefined && value !== null + } + return false + } + + const normalizedDate = DateUtils.toOnlyDate(date) + + switch (operator) { + case 'equals': { + const filterDate = DateUtils.tryParseDate(parameter.compareDate) + if (!filterDate) return false + return normalizedDate.getTime() === DateUtils.toOnlyDate(filterDate).getTime() + } + case 'notEquals': { + const filterDate = DateUtils.tryParseDate(parameter.compareDate) + if (!filterDate) return false + return normalizedDate.getTime() !== DateUtils.toOnlyDate(filterDate).getTime() + } + case 'greaterThan': { + const filterDate = DateUtils.tryParseDate(parameter.compareDate) + if (!filterDate) return false + return normalizedDate > DateUtils.toOnlyDate(filterDate) + } + case 'greaterThanOrEqual': { + const filterDate = DateUtils.tryParseDate(parameter.compareDate) + if (!filterDate) return false + return normalizedDate >= DateUtils.toOnlyDate(filterDate) + } + case 'lessThan': { + const filterDate = DateUtils.tryParseDate(parameter.compareDate) + if (!filterDate) return false + return normalizedDate < DateUtils.toOnlyDate(filterDate) + } + case 'lessThanOrEqual': { + const filterDate = DateUtils.tryParseDate(parameter.compareDate) + if (!filterDate) return false + return normalizedDate <= DateUtils.toOnlyDate(filterDate) + } + case 'between': { + const minDate = DateUtils.tryParseDate(parameter.minDate) + const maxDate = DateUtils.tryParseDate(parameter.maxDate) + if (!minDate || !maxDate) return false + return normalizedDate >= DateUtils.toOnlyDate(minDate) && normalizedDate <= DateUtils.toOnlyDate(maxDate) + } + case 'notBetween': { + const minDate = DateUtils.tryParseDate(parameter.minDate) + const maxDate = DateUtils.tryParseDate(parameter.maxDate) + if (!minDate || !maxDate) return false + return normalizedDate < DateUtils.toOnlyDate(minDate) || normalizedDate > DateUtils.toOnlyDate(maxDate) + } + default: + return false + } +} + +/** + * Filters a dateTime value based on the provided filter value. + */ +function filterDateTime(value: unknown, operator: FilterOperator, parameter: FilterParameter): boolean { + const dateTime = DateUtils.tryParseDate(value as Date | string | number | undefined | null) + if (!dateTime) { + if (operator === 'isUndefined') { + return value === undefined || value === null + } + if (operator === 'isNotUndefined') { + return value !== undefined && value !== null + } + return false + } + + const normalizedDatetime = DateUtils.toDateTimeOnly(dateTime) + + switch (operator) { + case 'equals': { + const filterDatetime = DateUtils.tryParseDate(parameter.compareDate) + if (!filterDatetime) return false + return normalizedDatetime.getTime() === DateUtils.toDateTimeOnly(filterDatetime).getTime() + } + case 'notEquals': { + const filterDatetime = DateUtils.tryParseDate(parameter.compareDate) + if (!filterDatetime) return false + return normalizedDatetime.getTime() !== DateUtils.toDateTimeOnly(filterDatetime).getTime() + } + case 'greaterThan': { + const filterDatetime = DateUtils.tryParseDate(parameter.compareDate) + if (!filterDatetime) return false + return normalizedDatetime > DateUtils.toDateTimeOnly(filterDatetime) + } + case 'greaterThanOrEqual': { + const filterDatetime = DateUtils.tryParseDate(parameter.compareDate) + if (!filterDatetime) return false + return normalizedDatetime >= DateUtils.toDateTimeOnly(filterDatetime) + } + case 'lessThan': { + const filterDatetime = DateUtils.tryParseDate(parameter.compareDate) + if (!filterDatetime) return false + return normalizedDatetime < DateUtils.toDateTimeOnly(filterDatetime) + } + case 'lessThanOrEqual': { + const filterDatetime = DateUtils.tryParseDate(parameter.compareDate) + if (!filterDatetime) return false + return normalizedDatetime <= DateUtils.toDateTimeOnly(filterDatetime) + } + case 'between': { + const minDatetime = DateUtils.tryParseDate(parameter.minDate) + const maxDatetime = DateUtils.tryParseDate(parameter.maxDate) + if (!minDatetime || !maxDatetime) return false + return normalizedDatetime >= DateUtils.toDateTimeOnly(minDatetime) && normalizedDatetime <= DateUtils.toDateTimeOnly(maxDatetime) + } + case 'notBetween': { + const minDatetime = DateUtils.tryParseDate(parameter.minDate) + const maxDatetime = DateUtils.tryParseDate(parameter.maxDate) + if (!minDatetime || !maxDatetime) return false + return normalizedDatetime < DateUtils.toDateTimeOnly(minDatetime) || normalizedDatetime > DateUtils.toDateTimeOnly(maxDatetime) + } + default: + return false + } +} + +/** + * Filters a boolean value based on the provided filter value. + */ +function filterBoolean(value: unknown, operator: FilterOperator): boolean { + switch (operator) { + case 'isTrue': + return value === true + case 'isFalse': + return value === false + case 'isUndefined': + return value === undefined || value === null + case 'isNotUndefined': + return value !== undefined && value !== null + default: + return false + } +} + +/** + * Filters a tags array value based on the provided filter value. + */ +function filterMultiTags(value: unknown, operator: FilterOperator, parameter: FilterParameter): boolean { + switch (operator) { + case 'equals': { + if (!Array.isArray(value) || !Array.isArray(parameter.multiOptionSearch)) return false + if (value.length !== parameter.multiOptionSearch.length) return false + const valueSet = new Set(value) + const searchTagsSet = new Set(parameter.multiOptionSearch) + if (valueSet.size !== searchTagsSet.size) return false + return Array.from(valueSet).every(tag => searchTagsSet.has(tag)) + } + case 'notEquals': { + if (!Array.isArray(value) || !Array.isArray(parameter.multiOptionSearch)) return true + if (value.length !== parameter.multiOptionSearch.length) return true + const valueSet = new Set(value) + const searchTagsSet = new Set(parameter.multiOptionSearch) + if (valueSet.size !== searchTagsSet.size) return true + return !Array.from(valueSet).every(tag => searchTagsSet.has(tag)) + } + case 'contains': { + if (!Array.isArray(value) || !Array.isArray(parameter.multiOptionSearch)) return false + return parameter.multiOptionSearch.every(tag => value.includes(tag)) + } + case 'notContains': { + if (!Array.isArray(value) || !Array.isArray(parameter.multiOptionSearch)) return true + return !parameter.multiOptionSearch.every(tag => value.includes(tag)) + } + case 'isUndefined': + return value === undefined || value === null + case 'isNotUndefined': + return value !== undefined && value !== null + default: + return false + } +} + +/** + * Filters a single tag value based on the provided filter value. + */ +function filterSingleTag(value: unknown, operator: FilterOperator, parameter: FilterParameter): boolean { + switch (operator) { + case 'equals': + return value === parameter.singleOptionSearch + case 'notEquals': + return value !== parameter.singleOptionSearch + case 'contains': + return parameter.multiOptionSearch?.includes(value) ?? false + case 'notContains': + return !(parameter.multiOptionSearch?.includes(value) ?? false) + case 'isUndefined': + return value === undefined || value === null + case 'isNotUndefined': + return value !== undefined && value !== null + default: + return false + } +} + +/** + * Filters a generic value based on the provided filter value. + */ +function filterUnknownType(value: unknown, operator: FilterOperator): boolean { + switch (operator) { + case 'isUndefined': + return value === undefined || value === null + case 'isNotUndefined': + return value !== undefined && value !== null + default: + return false + } +} + +export const FilterFunctions: Record boolean> = { + text: filterText, + number: filterNumber, + date: filterDate, + dateTime: filterDateTime, + boolean: filterBoolean, + singleTag: filterSingleTag, + multiTags: filterMultiTags, + unknownType: filterUnknownType, +} + +export type FilterValueTranslationOptions = { + tags?: ReadonlyArray<{ tag: string, label: string }>, +} + +function formatDateParam( + dateParam: Date | string | number | undefined | null, + locale: string, + format: 'date' | 'dateTime' +): string { + const d = DateUtils.tryParseDate(dateParam) + return d ? DateUtils.formatAbsolute(d, locale, format) : '' +} + +function tagToLabel(tags: ReadonlyArray<{ tag: string, label: string }> | undefined, value: unknown): string { + if (!tags) return String(value ?? '') + const entry = tags.find(t => t.tag === value || t.tag === String(value)) + return entry?.label ?? String(value ?? '') +} + +export function useFilterValueTranslation(): (value: FilterValue, options?: FilterValueTranslationOptions) => string { + const translation = useHightideTranslation() + const { locale } = useLocale() + + return useCallback((value: FilterValue, options?: FilterValueTranslationOptions): string => { + const p = value.parameter + const tags = options?.tags + const dateFormat = value.dataType === 'dateTime' ? 'dateTime' as const : 'date' as const + + switch (value.operator) { + case 'equals': + if (value.dataType === 'date' || value.dataType === 'dateTime') { + return translation('rEquals', { value: formatDateParam(p.compareDate, locale, dateFormat) ?? '-' }) + } + if (value.dataType === 'singleTag') { + return translation('rEquals', { value: tagToLabel(tags, p.singleOptionSearch) }) + } + if (value.dataType === 'multiTags') { + const valueStr = (p.multiOptionSearch ?? []).map(v => tagToLabel(tags, v)).join(', ') + return translation('rEquals', { value: valueStr }) + } + return translation('rEquals', { value: String(p.searchText ?? p.compareValue ?? '') }) + case 'notEquals': + if (value.dataType === 'date' || value.dataType === 'dateTime') { + return translation('rNotEquals', { value: formatDateParam(p.compareDate, locale, dateFormat) }) + } + if (value.dataType === 'singleTag') { + return translation('rNotEquals', { value: tagToLabel(tags, p.singleOptionSearch) }) + } + if (value.dataType === 'multiTags') { + const valueStr = (p.multiOptionSearch ?? []).map(v => tagToLabel(tags, v)).join(', ') + return translation('rNotEquals', { value: valueStr }) + } + return translation('rNotEquals', { value: String(p.searchText ?? p.compareValue ?? '') }) + case 'contains': + if (value.dataType === 'multiTags' || value.dataType === 'singleTag') { + const valueStr = value.dataType === 'singleTag' + ? tagToLabel(tags, p.singleOptionSearch) + : (p.multiOptionSearch ?? []).map(v => tagToLabel(tags, v)).join(', ') + return translation('rContains', { value: valueStr }) + } + return translation('rContains', { value: String(p.searchText ?? '') }) + case 'notContains': + if (value.dataType === 'multiTags' || value.dataType === 'singleTag') { + const valueStr = value.dataType === 'singleTag' + ? tagToLabel(tags, p.singleOptionSearch) + : (p.multiOptionSearch ?? []).map(v => tagToLabel(tags, v)).join(', ') + return translation('rNotContains', { value: valueStr }) + } + return translation('rNotContains', { value: `"${String(p.searchText ?? '')}"` }) + case 'startsWith': + return translation('rStartsWith', { value: `"${String(p.searchText ?? '')}"` }) + case 'endsWith': + return translation('rEndsWith', { value: `"${String(p.searchText ?? '')}"` }) + case 'greaterThan': + return translation('rGreaterThan', { + value: value.dataType === 'date' || value.dataType === 'dateTime' + ? formatDateParam(p.compareDate, locale, dateFormat) ?? '-' + : String(p.compareValue ?? '-'), + }) + case 'greaterThanOrEqual': + return translation('rGreaterThanOrEqual', { + value: value.dataType === 'date' || value.dataType === 'dateTime' + ? formatDateParam(p.compareDate, locale, dateFormat) ?? '-' + : String(p.compareValue ?? '-'), + }) + case 'lessThan': + return translation('rLessThan', { + value: value.dataType === 'date' || value.dataType === 'dateTime' + ? formatDateParam(p.compareDate, locale, dateFormat) ?? '-' + : String(p.compareValue ?? '-'), + }) + case 'lessThanOrEqual': + return translation('rLessThanOrEqual', { + value: value.dataType === 'date' || value.dataType === 'dateTime' + ? formatDateParam(p.compareDate, locale, dateFormat) ?? '-' + : String(p.compareValue ?? '-'), + }) + case 'between': + if (value.dataType === 'date' || value.dataType === 'dateTime') { + return translation('rBetween', { + value1: formatDateParam(p.minDate, locale, dateFormat) ?? '-', + value2: formatDateParam(p.maxDate, locale, dateFormat) ?? '-', + }) + } + return translation('rBetween', { + value1: String(p.minNumber ?? '-'), + value2: String(p.maxNumber ?? '-'), + }) + case 'notBetween': + if (value.dataType === 'date' || value.dataType === 'dateTime') { + return translation('rNotBetween', { + value1: formatDateParam(p.minDate, locale, dateFormat) ?? '-', + value2: formatDateParam(p.maxDate, locale, dateFormat) ?? '-', + }) + } + return translation('rNotBetween', { + value1: String(p.minNumber ?? '-'), + value2: String(p.maxNumber ?? '-'), + }) + case 'isTrue': + return translation('isTrue') + case 'isFalse': + return translation('isFalse') + case 'isUndefined': + return translation('isUndefined') + case 'isNotUndefined': + return translation('isNotUndefined') + default: + return '' + } + }, [translation, locale]) +} \ No newline at end of file diff --git a/src/components/user-interaction/select/Select.tsx b/src/components/user-interaction/select/Select.tsx index 00f53ec..61b0fab 100644 --- a/src/components/user-interaction/select/Select.tsx +++ b/src/components/user-interaction/select/Select.tsx @@ -14,7 +14,7 @@ import { SelectContent } from './SelectContent' // export type SelectProps = SelectRootProps & { contentPanelProps?: SelectContentProps, - buttonProps?: Omit & { selectedDisplay?: (value: string) => ReactNode }, + buttonProps?: Omit & { selectedDisplay?: (value: string) => ReactNode } & { [key: string]: unknown }, } export const Select = forwardRef(function Select({ diff --git a/src/components/user-interaction/select/SelectButton.tsx b/src/components/user-interaction/select/SelectButton.tsx index 851a600..19e592c 100644 --- a/src/components/user-interaction/select/SelectButton.tsx +++ b/src/components/user-interaction/select/SelectButton.tsx @@ -1,4 +1,4 @@ -import type { HTMLAttributes, ReactNode } from 'react' +import type { ComponentPropsWithoutRef, ReactNode } from 'react' import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react' import { useSelectContext } from './SelectContext' import clsx from 'clsx' @@ -6,7 +6,7 @@ import { useHightideTranslation } from '@/src/i18n/useHightideTranslation' import { ExpansionIcon } from '@/src/components/display-and-visualization/ExpansionIcon' import { SelectOptionDisplayContext } from './SelectOption' -export interface SelectButtonProps extends HTMLAttributes { +export interface SelectButtonProps extends ComponentPropsWithoutRef<'div'> { placeholder?: ReactNode, disabled?: boolean, selectedDisplay?: (value: string[]) => ReactNode, @@ -93,21 +93,21 @@ export const SelectButton = forwardRef(functi aria-expanded={state.isOpen} aria-controls={state.isOpen ? ids.content : undefined} > - {hasValue ? - selectedDisplay?.(state.value) ?? ( -
    - {state.selectedOptions.map(({ value, display }, index) => ( - - + + {hasValue ? + selectedDisplay?.(state.value) ?? ( +
    + {state.selectedOptions.map(({ value, display }, index) => ( + {display} - - {index < state.value.length - 1 && ({','})} - - ))} -
    - ) - : placeholder ?? translation('clickToSelect') - } + {index < state.value.length - 1 && ({','})} +
    + ))} +
    + ) + : placeholder ?? translation('clickToSelect') + } + {!hideExpansionIcon && }
    ) diff --git a/src/components/utils/Polymorphic.tsx b/src/components/utils/Polymorphic.tsx new file mode 100644 index 0000000..892befb --- /dev/null +++ b/src/components/utils/Polymorphic.tsx @@ -0,0 +1,23 @@ +import type { SlotProps } from '@radix-ui/react-slot' +import { Slot } from '@radix-ui/react-slot' +import type { RefObject , Ref } from 'react' +import { forwardRef, type ElementType } from 'react' + +export interface PolymorphicSlotProps extends SlotProps { + asChild?: boolean, + defaultComponent?: ElementType, +} + +export const PolymorphicSlot = forwardRef(function PolymorphicSlot({ + children, + asChild, + defaultComponent = 'div', + ...props +}: PolymorphicSlotProps, ref: RefObject) { + const Component = asChild ? Slot : defaultComponent + return ( + }> + {children} + + ) +}) \ No newline at end of file diff --git a/src/hooks/useOverlayRegistry.ts b/src/hooks/useOverlayRegistry.ts index ebb8559..86a880e 100644 --- a/src/hooks/useOverlayRegistry.ts +++ b/src/hooks/useOverlayRegistry.ts @@ -73,14 +73,10 @@ export class OverlayRegistry { zIndex: startZIndex + index, } for(const tag of item.tags ?? []) { - let position = tagCount[tag] - if(position === undefined) { - position = 0 - } else { - position++ - } - tagCount[tag] = position - itemInformation[id].tagPositions[tag] = position + const count = tagCount[tag] ?? 0 + const nextPosition = count + tagCount[tag] = count + 1 + itemInformation[id].tagPositions[tag] = nextPosition } } for (const callback of this.listeners) { diff --git a/src/style/theme/colors/utilities.css b/src/style/theme/colors/utilities.css index ebf8133..db8d76c 100644 --- a/src/style/theme/colors/utilities.css +++ b/src/style/theme/colors/utilities.css @@ -73,6 +73,7 @@ @apply data-[coloringstyle=text]:coloring-text; @apply data-[coloringstyle=outline]:coloring-outline; @apply data-[coloringstyle=tonal]:coloring-tonal; + @apply data-[coloringstyle=tonal-outline]:coloring-tonal-outline; } @utility coloring-style-hover-detect { @@ -80,6 +81,7 @@ @apply data-[coloringstyle=text]:coloring-text-hover; @apply data-[coloringstyle=outline]:coloring-outline-hover; @apply data-[coloringstyle=tonal]:coloring-tonal-hover; + @apply data-[coloringstyle=tonal-outline]:coloring-tonal-outline-hover; } @utility coloring-color-detect { diff --git a/src/style/theme/components/button.css b/src/style/theme/components/button.css index 6991b47..16003f6 100644 --- a/src/style/theme/components/button.css +++ b/src/style/theme/components/button.css @@ -14,7 +14,8 @@ &[data-size="xs"] { @apply gap-x-1 sizing-xs; @apply text-xs min-w-20; - &[data-coloringstyle="outline"] { + &[data-coloringstyle="outline"], + &[data-coloringstyle="tonal-outline"] { padding: calc(var(--spacing-element-padding-xs) - var(--coloring-outline-width)) calc(var(--spacing-element-padding-direction-xs) - var(--coloring-outline-width)); @@ -23,7 +24,8 @@ &[data-size="sm"] { @apply gap-x-1 sizing-sm; @apply min-w-28; - &[data-coloringstyle="outline"] { + &[data-coloringstyle="outline"], + &[data-coloringstyle="tonal-outline"] { padding: calc(var(--spacing-element-padding-sm) - var(--coloring-outline-width)) calc(var(--spacing-element-padding-direction-sm) - var(--coloring-outline-width)); @@ -32,7 +34,8 @@ &[data-size="md"] { @apply gap-x-2 sizing-md; @apply min-w-36; - &[data-coloringstyle="outline"] { + &[data-coloringstyle="outline"], + &[data-coloringstyle="tonal-outline"] { padding: calc(var(--spacing-element-padding-md) - var(--coloring-outline-width)) calc(var(--spacing-element-padding-direction-md) - var(--coloring-outline-width)); @@ -41,7 +44,8 @@ &[data-size="lg"] { @apply gap-x-2 sizing-lg; @apply text-lg min-w-45; - &[data-coloringstyle="outline"] { + &[data-coloringstyle="outline"], + &[data-coloringstyle="tonal-outline"] { padding: calc(var(--spacing-element-padding-lg) - var(--coloring-outline-width)) calc(var(--spacing-element-padding-direction-lg) - var(--coloring-outline-width)); diff --git a/src/style/theme/components/checkbox.css b/src/style/theme/components/checkbox.css index 5132fe6..860cc2d 100644 --- a/src/style/theme/components/checkbox.css +++ b/src/style/theme/components/checkbox.css @@ -1,6 +1,14 @@ @layer components { [data-name="checkbox"] { @apply flex-col-0 items-center justify-center rounded border-2; + transition: + border-color var(--animation-duration-in, 250ms) ease-in-out, + box-shadow var(--animation-duration-in, 250ms) ease-in-out, + outline-color var(--animation-duration-in, 250ms) ease-in-out, + outline-offset var(--animation-duration-in, 250ms) ease-in-out, + color 100ms ease-in-out, + background-color 100ms ease-in-out; + &:not([data-disabled]) { @apply hover:cursor-pointer; } diff --git a/src/style/theme/components/combobox.css b/src/style/theme/components/combobox.css new file mode 100644 index 0000000..eec5415 --- /dev/null +++ b/src/style/theme/components/combobox.css @@ -0,0 +1,21 @@ +@layer components { + [data-name="combobox-root"] { + @apply flex-col-2; + } + + [data-name="combobox-input"] { + @apply input-element rounded-md; + } + + [data-name="combobox-list"] { + @apply flex-col-1 overflow-y-auto; + } + + [data-name="combobox-option"] { + @apply flex-row-1 items-center px-2 py-1 rounded-md cursor-pointer; + + &[data-highlighted] { + @apply bg-primary/20; + } + } +} diff --git a/src/style/theme/components/icon-button.css b/src/style/theme/components/icon-button.css index 0e7b9ae..bb12921 100644 --- a/src/style/theme/components/icon-button.css +++ b/src/style/theme/components/icon-button.css @@ -12,25 +12,29 @@ &[data-size="xs"] { @apply sizing-xs; - &[data-coloringstyle="outline"] { + &[data-coloringstyle="outline"], + &[data-coloringstyle="tonal-outline"] { padding: calc(var(--spacing-element-padding-xs) - var(--coloring-outline-width)); } } &[data-size="sm"] { @apply sizing-sm; - &[data-coloringstyle="outline"] { + &[data-coloringstyle="outline"], + &[data-coloringstyle="tonal-outline"] { padding: calc(var(--spacing-element-padding-sm) - var(--coloring-outline-width)); } } &[data-size="md"] { @apply sizing-md; - &[data-coloringstyle="outline"] { + &[data-coloringstyle="outline"], + &[data-coloringstyle="tonal-outline"] { padding: calc(var(--spacing-element-padding-md) - var(--coloring-outline-width)); } } &[data-size="lg"] { @apply sizing-lg; - &[data-coloringstyle="outline"] { + &[data-coloringstyle="outline"], + &[data-coloringstyle="tonal-outline"] { padding: calc(var(--spacing-element-padding-lg) - var(--coloring-outline-width)); } } diff --git a/src/style/theme/components/index.css b/src/style/theme/components/index.css index 98a2fbb..2b30c1c 100644 --- a/src/style/theme/components/index.css +++ b/src/style/theme/components/index.css @@ -24,6 +24,7 @@ @import "./property.css"; @import "./pop-up.css"; @import "./select.css"; +@import "./combobox.css"; @import "./general.css"; @import "./icon-button.css"; @import "./date-time-input.css"; \ No newline at end of file diff --git a/src/style/theme/components/input-elements.css b/src/style/theme/components/input-elements.css index 6bbe81e..dcc8960 100644 --- a/src/style/theme/components/input-elements.css +++ b/src/style/theme/components/input-elements.css @@ -1,5 +1,13 @@ @utility input-element { @apply border-2 focus-style-none focus-style-border focus-style-shadow; + transition: + border-color var(--animation-duration-in, 250ms) ease-in-out, + box-shadow var(--animation-duration-in, 250ms) ease-in-out, + outline-color var(--animation-duration-in, 250ms) ease-in-out, + outline-offset var(--animation-duration-in, 250ms) ease-in-out, + color var(--animation-duration-in, 250ms) ease-in-out, + background-color var(--animation-duration-in, 250ms) ease-in-out; + &:not([data-disabled]):not([data-invalid]) { @apply bg-input-background hover:border-primary-hover; diff --git a/src/style/theme/components/pop-up.css b/src/style/theme/components/pop-up.css index fc116c0..dec7ebc 100644 --- a/src/style/theme/components/pop-up.css +++ b/src/style/theme/components/pop-up.css @@ -1,7 +1,14 @@ [data-name="pop-up"] { - @apply surface coloring-solid rounded-md border-2 border-outline-variant shadow-md; + @apply surface coloring-solid rounded-md border-2 border-outline-variant shadow-lg shadow-black/15; @apply focus-within:border-primary; + transition: + top var(--anchored-position-polling-interval, 100ms) linear, + left var(--anchored-position-polling-interval, 100ms) linear, + right var(--anchored-position-polling-interval, 100ms) linear, + bottom var(--anchored-position-polling-interval, 100ms) linear, + border-color var(--animation-duration-in, 250ms) ease-in-out, + box-shadow var(--animation-duration-in, 250ms) ease-in-out; &[data-positioned] { @apply animate-pop-in; } diff --git a/src/style/utitlity/coloring.css b/src/style/utitlity/coloring.css index 6e6e1bf..4629dba 100644 --- a/src/style/utitlity/coloring.css +++ b/src/style/utitlity/coloring.css @@ -1,4 +1,15 @@ +@utility coloring-transition { + transition: + border-color var(--animation-duration-in, 250ms) ease-in-out, + box-shadow var(--animation-duration-in, 250ms) ease-in-out, + outline-color var(--animation-duration-in, 250ms) ease-in-out, + outline-offset var(--animation-duration-in, 250ms) ease-in-out, + color var(--animation-duration-in, 250ms) ease-in-out, + background-color var(--animation-duration-in, 250ms) ease-in-out; +} + @utility coloring-solid { + @apply coloring-transition; @apply bg-[var(--coloring-solid-color,var(--coloring-color))] text-[var(--coloring-solid-text,var(--coloring-on-color))]; } @@ -9,6 +20,7 @@ } @utility coloring-tonal { + @apply coloring-transition; @apply bg-[var(--coloring-tonal-background,var(--coloring-tonal,var(--coloring-color)))]/20 text-[var(--coloring-tonal-text,var(--coloring-tonal,var(--coloring-color)))]; } @@ -19,6 +31,7 @@ } @utility coloring-text { + @apply coloring-transition; @apply text-[var(--coloring-text,var(--coloring-color))]; } @@ -28,6 +41,7 @@ } @utility coloring-outline { + @apply coloring-transition; border-width: var(--coloring-outline-width, 0.125rem); @apply border-[var(--coloring-border,var(--coloring-outline,var(--coloring-color)))]; @apply text-[var(--coloring-outline,var(--coloring-color))]; @@ -39,6 +53,19 @@ hover:text-[var(--coloring-outline-hover,var(--coloring-hover))]; } +@utility coloring-tonal-outline { + @apply coloring-transition coloring-tonal; + border-width: var(--coloring-outline-width, 0.125rem); + @apply border-[var(--coloring-border,var(--coloring-outline,var(--coloring-color)))]; +} + +@utility coloring-tonal-outline-hover { + @apply coloring-tonal-outline + hover:border-[var(--coloring-border-hover,--coloring-hover)] + hover:text-[var(--coloring-outline-hover,var(--coloring-hover))] + hover:bg-[var(--coloring-tonal-hover,var(--coloring-tonal-background,var(--coloring-tonal,var(--coloring-color))))]/30; +} + @utility coloring-reset-variables { --coloring-color: initial; --coloring-on-color: initial; diff --git a/src/utils/date.ts b/src/utils/date.ts index 371ee28..46a0724 100644 --- a/src/utils/date.ts +++ b/src/utils/date.ts @@ -232,6 +232,28 @@ const toInputString = (date: Date, format: DateTimeFormat, precision: DateTimePr } } +function tryParseDate(dateValue: Date | string | number | undefined | null): Date | null { + if (!dateValue) return null + if (dateValue instanceof Date) return dateValue + if (typeof dateValue === 'string' || typeof dateValue === 'number') { + const parsed = new Date(dateValue) + return isNaN(parsed.getTime()) ? null : parsed + } + return null +} + +function normalizeToDateOnly(date: Date): Date { + const normalized = new Date(date) + normalized.setHours(0, 0, 0, 0) + return normalized +} + +function normalizeDatetime(dateTime: Date): Date { + const normalized = new Date(dateTime) + normalized.setSeconds(0, 0) + return normalized +} + export const DateUtils = { monthsList, weekDayList, @@ -244,4 +266,10 @@ export const DateUtils = { weeksForCalenderMonth, timesInSeconds, toInputString, + tryParseDate, + toOnlyDate: normalizeToDateOnly, + /** + * Normalizes a datetime by removing seconds and milliseconds. + */ + toDateTimeOnly: normalizeDatetime, } \ No newline at end of file diff --git a/src/utils/filter.ts b/src/utils/filter.ts deleted file mode 100644 index 78250a5..0000000 --- a/src/utils/filter.ts +++ /dev/null @@ -1,355 +0,0 @@ -import type { - TextFilterValue, - NumberFilterValue, - DateFilterValue, - DatetimeFilterValue, - BooleanFilterValue, - TagsFilterValue, - TagsSingleFilterValue, - GenericFilterValue -} from '../components/layout/table/TableFilter' -import { TableFilterOperator } from '../components/layout/table/TableFilter' - - - -/** - * Filters a text value based on the provided filter value. - */ -export function filterText(value: unknown, filterValue: TextFilterValue): boolean { - const parameter = filterValue.parameter - const operator = filterValue.operator - const isCaseSensitive = filterValue.parameter.isCaseSensitive ?? false - - if (operator === 'textNotWhitespace') { - return value?.toString().trim().length > 0 - } - - const searchText = isCaseSensitive ? parameter.searchText ?? '' : (parameter.searchText ?? '').toLowerCase() - const cellText = isCaseSensitive ? value?.toString() ?? '' : value?.toString().toLowerCase() ?? '' - - switch (operator) { - case 'textEquals': - return cellText === searchText - case 'textNotEquals': - return cellText !== searchText - case 'textContains': - return cellText.includes(searchText) - case 'textNotContains': - return !cellText.includes(searchText) - case 'textStartsWith': - return cellText.startsWith(searchText) - case 'textEndsWith': - return cellText.endsWith(searchText) - case 'undefined': - return value === undefined || value === null - case 'notUndefined': - return value !== undefined && value !== null - default: - return false - } -} - -/** - * Filters a number value based on the provided filter value. - */ -export function filterNumber(value: unknown, filterValue: NumberFilterValue): boolean { - const parameter = filterValue.parameter - const operator = filterValue.operator - - if (typeof value !== 'number') { - if (operator === 'undefined') { - return value === undefined || value === null - } - if (operator === 'notUndefined') { - return value !== undefined && value !== null - } - return false - } - - switch (operator) { - case 'numberEquals': - return value === parameter.compareValue - case 'numberNotEquals': - return value !== parameter.compareValue - case 'numberGreaterThan': - return value > (parameter.compareValue ?? 0) - case 'numberGreaterThanOrEqual': - return value >= (parameter.compareValue ?? 0) - case 'numberLessThan': - return value < (parameter.compareValue ?? 0) - case 'numberLessThanOrEqual': - return value <= (parameter.compareValue ?? 0) - case 'numberBetween': - return value >= (parameter.min ?? -Infinity) && value <= (parameter.max ?? Infinity) - case 'numberNotBetween': - return value < (parameter.min ?? -Infinity) || value > (parameter.max ?? Infinity) - case 'undefined': - return value === undefined || value === null - case 'notUndefined': - return value !== undefined && value !== null - default: - return false - } -} - -/** - * Parses a date value from various formats. - */ -function parseDate(dateValue: Date | string | number | undefined | null): Date | null { - if (!dateValue) return null - if (dateValue instanceof Date) return dateValue - if (typeof dateValue === 'string' || typeof dateValue === 'number') { - const parsed = new Date(dateValue) - return isNaN(parsed.getTime()) ? null : parsed - } - return null -} - -/** - * Normalizes a date to date-only (removes time component). - */ -function normalizeToDateOnly(date: Date): Date { - const normalized = new Date(date) - normalized.setHours(0, 0, 0, 0) - return normalized -} - -/** - * Filters a date value based on the provided filter value. - * Only compares dates, ignoring time components. - */ -export function filterDate(value: unknown, filterValue: DateFilterValue): boolean { - const parameter = filterValue.parameter - const operator = filterValue.operator - - const date = parseDate(value as Date | string | number | undefined | null) - if (!date && !TableFilterOperator.generic.some(o => o === operator)) return false - - const normalizedDate = date ? normalizeToDateOnly(date) : null - - switch (operator) { - case 'dateEquals': { - const filterDate = parseDate(parameter.compareDate) - if (!filterDate || !normalizedDate) return false - return normalizedDate.getTime() === normalizeToDateOnly(filterDate).getTime() - } - case 'dateNotEquals': { - const filterDate = parseDate(parameter.compareDate) - if (!filterDate || !normalizedDate) return false - return normalizedDate.getTime() !== normalizeToDateOnly(filterDate).getTime() - } - case 'dateGreaterThan': { - const filterDate = parseDate(parameter.compareDate) - if (!filterDate || !normalizedDate) return false - return normalizedDate > normalizeToDateOnly(filterDate) - } - case 'dateGreaterThanOrEqual': { - const filterDate = parseDate(parameter.compareDate) - if (!filterDate || !normalizedDate) return false - return normalizedDate >= normalizeToDateOnly(filterDate) - } - case 'dateLessThan': { - const filterDate = parseDate(parameter.compareDate) - if (!filterDate || !normalizedDate) return false - return normalizedDate < normalizeToDateOnly(filterDate) - } - case 'dateLessThanOrEqual': { - const filterDate = parseDate(parameter.compareDate) - if (!filterDate || !normalizedDate) return false - return normalizedDate <= normalizeToDateOnly(filterDate) - } - case 'dateBetween': { - const minDate = parseDate(parameter.min) - const maxDate = parseDate(parameter.max) - if (!minDate || !maxDate || !normalizedDate) return false - return normalizedDate >= normalizeToDateOnly(minDate) && normalizedDate <= normalizeToDateOnly(maxDate) - } - case 'dateNotBetween': { - const minDate = parseDate(parameter.min) - const maxDate = parseDate(parameter.max) - if (!minDate || !maxDate || !normalizedDate) return false - return normalizedDate < normalizeToDateOnly(minDate) || normalizedDate > normalizeToDateOnly(maxDate) - } - case 'undefined': - return value === undefined || value === null - case 'notUndefined': - return value !== undefined && value !== null - default: - return false - } -} - -/** - * Normalizes a dateTime by removing seconds and milliseconds. - */ -function normalizeDatetime(dateTime: Date): Date { - const normalized = new Date(dateTime) - normalized.setSeconds(0, 0) - return normalized -} - -/** - * Filters a dateTime value based on the provided filter value. - */ -export function filterDatetime(value: unknown, filterValue: DatetimeFilterValue): boolean { - const parameter = filterValue.parameter - const operator = filterValue.operator - - const dateTime = parseDate(value as Date | string | number | undefined | null) - if (!dateTime && !TableFilterOperator.generic.some(o => o === operator)) return false - - const normalizedDatetime = dateTime ? normalizeDatetime(dateTime) : null - - switch (operator) { - case 'dateTimeEquals': { - const filterDatetime = parseDate(parameter.compareDatetime) - if (!filterDatetime || !normalizedDatetime) return false - return normalizedDatetime.getTime() === normalizeDatetime(filterDatetime).getTime() - } - case 'dateTimeNotEquals': { - const filterDatetime = parseDate(parameter.compareDatetime) - if (!filterDatetime || !normalizedDatetime) return false - return normalizedDatetime.getTime() !== normalizeDatetime(filterDatetime).getTime() - } - case 'dateTimeGreaterThan': { - const filterDatetime = parseDate(parameter.compareDatetime) - if (!filterDatetime || !normalizedDatetime) return false - return normalizedDatetime > normalizeDatetime(filterDatetime) - } - case 'dateTimeGreaterThanOrEqual': { - const filterDatetime = parseDate(parameter.compareDatetime) - if (!filterDatetime || !normalizedDatetime) return false - return normalizedDatetime >= normalizeDatetime(filterDatetime) - } - case 'dateTimeLessThan': { - const filterDatetime = parseDate(parameter.compareDatetime) - if (!filterDatetime || !normalizedDatetime) return false - return normalizedDatetime < normalizeDatetime(filterDatetime) - } - case 'dateTimeLessThanOrEqual': { - const filterDatetime = parseDate(parameter.compareDatetime) - if (!filterDatetime || !normalizedDatetime) return false - return normalizedDatetime <= normalizeDatetime(filterDatetime) - } - case 'dateTimeBetween': { - const minDatetime = parseDate(parameter.min) - const maxDatetime = parseDate(parameter.max) - if (!minDatetime || !maxDatetime || !normalizedDatetime) return false - return normalizedDatetime >= normalizeDatetime(minDatetime) && normalizedDatetime <= normalizeDatetime(maxDatetime) - } - case 'dateTimeNotBetween': { - const minDatetime = parseDate(parameter.min) - const maxDatetime = parseDate(parameter.max) - if (!minDatetime || !maxDatetime || !normalizedDatetime) return false - return normalizedDatetime < normalizeDatetime(minDatetime) || normalizedDatetime > normalizeDatetime(maxDatetime) - } - case 'undefined': - return value === undefined || value === null - case 'notUndefined': - return value !== undefined && value !== null - default: - return false - } -} - -/** - * Filters a boolean value based on the provided filter value. - */ -export function filterBoolean(value: unknown, filterValue: BooleanFilterValue): boolean { - const operator = filterValue.operator - - switch (operator) { - case 'booleanIsTrue': - return value === true - case 'booleanIsFalse': - return value === false - case 'undefined': - return value === undefined || value === null - case 'notUndefined': - return value !== undefined && value !== null - default: - return false - } -} - -/** - * Filters a tags array value based on the provided filter value. - */ -export function filterTags(value: unknown, filterValue: TagsFilterValue): boolean { - const parameter = filterValue.parameter - const operator = filterValue.operator - - switch (operator) { - case 'tagsEquals': { - if (!Array.isArray(value) || !Array.isArray(parameter.searchTags)) return false - if (value.length !== parameter.searchTags.length) return false - const valueSet = new Set(value) - const searchTagsSet = new Set(parameter.searchTags) - if (valueSet.size !== searchTagsSet.size) return false - return Array.from(valueSet).every(tag => searchTagsSet.has(tag)) - } - case 'tagsNotEquals': { - if (!Array.isArray(value) || !Array.isArray(parameter.searchTags)) return true - if (value.length !== parameter.searchTags.length) return true - const valueSet = new Set(value) - const searchTagsSet = new Set(parameter.searchTags) - if (valueSet.size !== searchTagsSet.size) return true - return !Array.from(valueSet).every(tag => searchTagsSet.has(tag)) - } - case 'tagsContains': { - if (!Array.isArray(value) || !Array.isArray(parameter.searchTags)) return false - return parameter.searchTags.every(tag => value.includes(tag)) - } - case 'tagsNotContains': { - if (!Array.isArray(value) || !Array.isArray(parameter.searchTags)) return true - return !parameter.searchTags.every(tag => value.includes(tag)) - } - case 'undefined': - return value === undefined || value === null - case 'notUndefined': - return value !== undefined && value !== null - default: - return false - } -} - -/** - * Filters a single tag value based on the provided filter value. - */ -export function filterTagsSingle(value: unknown, filterValue: TagsSingleFilterValue): boolean { - const parameter = filterValue.parameter - const operator = filterValue.operator - - switch (operator) { - case 'tagsSingleEquals': - return value === parameter.searchTag - case 'tagsSingleNotEquals': - return value !== parameter.searchTag - case 'tagsSingleContains': - return parameter.searchTagsContains?.includes(value) ?? false - case 'tagsSingleNotContains': - return !(parameter.searchTagsContains?.includes(value) ?? false) - case 'undefined': - return value === undefined || value === null - case 'notUndefined': - return value !== undefined && value !== null - default: - return false - } -} - -/** - * Filters a generic value based on the provided filter value. - */ -export function filterGeneric(value: unknown, filterValue: GenericFilterValue): boolean { - const operator = filterValue.operator - - switch (operator) { - case 'undefined': - return value === undefined || value === null - case 'notUndefined': - return value !== undefined && value !== null - default: - return false - } -} diff --git a/stories/Layout/Table/AsyncDataExample.stories.tsx b/stories/Layout/Table/AsyncDataExample.stories.tsx index 1d98a4d..bc13e86 100644 --- a/stories/Layout/Table/AsyncDataExample.stories.tsx +++ b/stories/Layout/Table/AsyncDataExample.stories.tsx @@ -10,28 +10,7 @@ import { TableColumnSwitcher } from '@/src/components/layout/table/TableColumnSw import { Chip } from '@/src/components/display-and-visualization/Chip' import { Table } from '@/src/components/layout/table/Table' import { Visibility } from '@/src/components/layout/Visibility' -import { - filterText, - filterNumber, - filterDate, - filterDatetime, - filterBoolean, - filterTags, - filterTagsSingle, - filterGeneric -} from '@/src/utils/filter' -import { - TableFilterOperator, - type TextFilterValue, - type NumberFilterValue, - type DateFilterValue, - type DatetimeFilterValue, - type BooleanFilterValue, - type TagsFilterValue, - type TagsSingleFilterValue, - type GenericFilterValue, - type TableFilterValue -} from '@/src/components/layout/table/TableFilter' +import { FilterFunctions, type FilterValue } from '@/src/components/user-interaction/data/filter-function' const relationShipTags = ['Friend', 'Family', 'Work', 'School', 'Other'] as const type RelationShipTag = (typeof relationShipTags)[number] @@ -66,52 +45,6 @@ const createRandomDataType = (): DataType => { const TOTAL_ITEMS = 10000 const allData: DataType[] = range(TOTAL_ITEMS).map(() => createRandomDataType()) -/** - * Determines the filter category based on the operator string. - */ -function getFilterCategory(operator: string): keyof typeof TableFilterOperator | null { - const allOperators = [ - ...TableFilterOperator.generic, - ...TableFilterOperator.text, - ...TableFilterOperator.number, - ...TableFilterOperator.date, - ...TableFilterOperator.dateTime, - ...TableFilterOperator.boolean, - ...TableFilterOperator.multiTags, - ...TableFilterOperator.singleTag, - ] as readonly string[] - - if (!allOperators.includes(operator)) { - return null - } - - if (TableFilterOperator.generic.includes(operator as typeof TableFilterOperator.generic[number])) { - return 'generic' - } - if (TableFilterOperator.text.includes(operator as typeof TableFilterOperator.text[number])) { - return 'text' - } - if (TableFilterOperator.number.includes(operator as typeof TableFilterOperator.number[number])) { - return 'number' - } - if (TableFilterOperator.date.includes(operator as typeof TableFilterOperator.date[number])) { - return 'date' - } - if (TableFilterOperator.dateTime.includes(operator as typeof TableFilterOperator.dateTime[number])) { - return 'dateTime' - } - if (TableFilterOperator.boolean.includes(operator as typeof TableFilterOperator.boolean[number])) { - return 'boolean' - } - if (TableFilterOperator.multiTags.includes(operator as typeof TableFilterOperator.multiTags[number])) { - return 'multiTags' - } - if (TableFilterOperator.singleTag.includes(operator as typeof TableFilterOperator.singleTag[number])) { - return 'singleTag' - } - return null -} - const fetchPaginatedData = async ( pageIndex: number, pageSize: number, @@ -130,30 +63,30 @@ const fetchPaginatedData = async ( const rowValue = row[id as keyof DataType] if (typeof value === 'object' && 'operator' in value && 'parameter' in value) { - const filterValue = value as TableFilterValue - const category = getFilterCategory(filterValue.operator) + const filterValue = value as FilterValue + const dataType = filterValue.dataType - if (!category) { + if (!dataType) { return true } - switch (category) { + switch (dataType) { case 'text': - return filterText(rowValue, filterValue as TextFilterValue) + return FilterFunctions.text(rowValue, filterValue.operator, filterValue.parameter) case 'number': - return filterNumber(rowValue, filterValue as NumberFilterValue) + return FilterFunctions.number(rowValue, filterValue.operator, filterValue.parameter) case 'date': - return filterDate(rowValue, filterValue as DateFilterValue) + return FilterFunctions.date(rowValue, filterValue.operator, filterValue.parameter) case 'dateTime': - return filterDatetime(rowValue, filterValue as DatetimeFilterValue) + return FilterFunctions.dateTime(rowValue, filterValue.operator, filterValue.parameter) case 'boolean': - return filterBoolean(rowValue, filterValue as BooleanFilterValue) - case 'multiTags': - return filterTags(rowValue, filterValue as TagsFilterValue) + return FilterFunctions.boolean(rowValue, filterValue.operator, filterValue.parameter) case 'singleTag': - return filterTagsSingle(rowValue, filterValue as TagsSingleFilterValue) - case 'generic': - return filterGeneric(rowValue, filterValue as GenericFilterValue) + return FilterFunctions.singleTag(rowValue, filterValue.operator, filterValue.parameter) + case 'multiTags': + return FilterFunctions.multiTags(rowValue, filterValue.operator, filterValue.parameter) + case 'unknownType': + return FilterFunctions.unknownType(rowValue, filterValue.operator, filterValue.parameter) default: return true } diff --git a/stories/Layout/Table/FilterListTable.stories.tsx b/stories/Layout/Table/FilterListTable.stories.tsx new file mode 100644 index 0000000..71d7411 --- /dev/null +++ b/stories/Layout/Table/FilterListTable.stories.tsx @@ -0,0 +1,154 @@ +import type { Meta, StoryObj } from '@storybook/nextjs' +import { useMemo, useState } from 'react' +import { faker } from '@faker-js/faker' +import { range } from '@/src/utils/array' +import { Table } from '@/src/components/layout/table/Table' +import { TableColumn } from '@/src/components/layout/table/TableColumn' +import { TableCell } from '@/src/components/layout/table/TableCell' +import { useHightideTranslation } from '@/src/i18n/useHightideTranslation' +import { FilterList } from '@/src/components/user-interaction/data/FilterList' +import type { IdentifierFilterValue, FilterListItem } from '@/src/components/user-interaction/data/FilterList' +import { FilterFunctions } from '@/src/components/user-interaction/data/filter-function' +import type { DataType } from '@/src/components/user-interaction/data/data-types' + +type Row = { + name: string, + age: number, + entryDate: Date, + hasChildren: boolean, +} + +const createRow = (): Row => ({ + name: faker.person.fullName(), + age: faker.number.int(100), + entryDate: faker.date.past({ years: 20 }), + hasChildren: faker.datatype.boolean(), +}) + +const allData: Row[] = range(100).map(() => createRow()) + +const availableItems: FilterListItem[] = [ + { + id: 'name', + label: 'Name', + dataType: 'text', + tags: [], + }, + { + id: 'age', + label: 'Age', + dataType: 'number', + tags: [], + }, + { + id: 'entryDate', + label: 'Entry Date', + dataType: 'date', + tags: [], + }, + { + id: 'hasChildren', + label: 'Has Children', + dataType: 'boolean', + tags: [], + }, +] + +function filterData(data: Row[], filters: IdentifierFilterValue[]): Row[] { + if (filters.length === 0) return data + return data.filter(row => { + return filters.every(f => { + const rowValue = row[f.id as keyof Row] + const fn = FilterFunctions[f.dataType as DataType] + if (!fn) return true + return fn(rowValue, f.operator, f.parameter) + }) + }) +} + +const meta: Meta = { + component: Table, +} + +export default meta +type Story = StoryObj + +export const filterListTable: Story = { + args: {}, + render: () => { + const translation = useHightideTranslation() + const [filterValue, setFilterValue] = useState([]) + + const filteredData = useMemo( + () => filterData(allData, filterValue), + [filterValue] + ) + + return ( + + Table with Filter List + + {filteredData.length} of {allData.length} rows + + + + )} + > + + + ( + + {(cell.getValue() as Date).toLocaleDateString()} + + )} + sortingFn="datetime" + minSize={140} + size={160} + /> + ( + + {cell.getValue() ? translation('yes') : translation('no')} + + )} + sortingFn="basic" + minSize={100} + size={120} + /> +
    + ) + }, +} From 546ac98a0556e2deaa809222acba3ef814ce0973 Mon Sep 17 00:00:00 2001 From: DasProffi <67233923+DasProffi@users.noreply.github.com> Date: Sun, 22 Feb 2026 14:57:49 +0100 Subject: [PATCH 05/13] feat: add SelectionContext and HighlightContext --- src/components/utils/HighlightContext.tsx | 142 ++++++++++++++++ src/components/utils/SelectionContext.tsx | 196 ++++++++++++++++++++++ src/hooks/useControlledState.ts | 1 - src/utils/dom.ts | 10 ++ 4 files changed, 348 insertions(+), 1 deletion(-) create mode 100644 src/components/utils/HighlightContext.tsx create mode 100644 src/components/utils/SelectionContext.tsx create mode 100644 src/utils/dom.ts diff --git a/src/components/utils/HighlightContext.tsx b/src/components/utils/HighlightContext.tsx new file mode 100644 index 0000000..e07e1e5 --- /dev/null +++ b/src/components/utils/HighlightContext.tsx @@ -0,0 +1,142 @@ +import type { RefObject } from "react"; +import { createContext, ReactNode, useCallback, useContext, useEffect, useMemo, useState } from "react"; +import { DOMUtils } from "@/src/utils/dom"; +import { useControlledState } from "@/src/hooks/useControlledState"; + +export interface HighlightOption { + id: string; + disabled: boolean; + ref: RefObject; +} + +export interface HighlightContextType { + highlightedId: string | null; + highlightedOption: HighlightOption | null; + options: ReadonlyArray; + highlight: (id: string) => void; + highlightNext: () => void; + highlightPrevious: () => void; + registerOption: (option: HighlightOption) => () => void; +} + +export const HighlightContext = createContext(null); + +export function useHighlightContext(): HighlightContextType { + const context = useContext(HighlightContext); + if (!context) { + throw new Error("useHighlightContext must be used within a HighlightProvider"); + } + return context; +} + +function sortOptionsByDomPosition(options: HighlightOption[]): HighlightOption[] { + return [...options].sort((a, b) => + DOMUtils.compareDocumentPosition(a.ref?.current, b.ref?.current) + ); +} + +function firstEnabledId(options: ReadonlyArray): string | null { + const opt = options.find((o) => !o.disabled); + return opt?.id ?? null; +} + +export interface HighlightProviderProps { + children: ReactNode; + value?: null; + onHighlightChange?: (highlightedId: string) => void; + initialHighlightId?: string; +} + +export function HighlightProvider({ children, value, onHighlightChange, initialHighlightId }: HighlightProviderProps) { + const [options, setOptions] = useState([]); + const [highlightedId, setHighlightedId] = useControlledState({ + value: value, + onValueChange: onHighlightChange, + defaultValue: initialHighlightId, + }); + + const sortedOptions = useMemo(() => sortOptionsByDomPosition(options), [options]); + + const resolveHighlightId = useCallback((opts: ReadonlyArray, currentId: string | null): string | null => { + if (opts.length === 0) return null; + const defaultId = + initialHighlightId && opts.some((o) => o.id === initialHighlightId && !o.disabled) + ? initialHighlightId + : null; + const candidate = defaultId ?? firstEnabledId(opts); + if (currentId && opts.some((o) => o.id === currentId && !o.disabled)) return currentId; + return candidate; + }, + [initialHighlightId] + ); + + useEffect(() => { + const next = resolveHighlightId(sortedOptions, highlightedId); + if (next !== highlightedId) setHighlightedId(next); + }, [sortedOptions, highlightedId, resolveHighlightId]); + + const registerOption = useCallback((option: HighlightOption) => { + setOptions((prev) => sortOptionsByDomPosition([...prev, option])); + return () => setOptions((prev) => prev.filter((o) => o.id !== option.id)); + }, []); + + const highlight = useCallback( + (id: string) => { + if (!sortedOptions.some((o) => o.id === id && !o.disabled)) return; + setHighlightedId(id); + }, + [sortedOptions] + ); + + const enabledOptions = useMemo( + () => sortedOptions.filter((o) => !o.disabled), + [sortedOptions] + ); + + const highlightNext = useCallback(() => { + if (enabledOptions.length <= 1) return; + const idx = enabledOptions.findIndex((o) => o.id === highlightedId); + const nextIdx = idx < 0 ? 0 : (idx + 1) % enabledOptions.length; + setHighlightedId(enabledOptions[nextIdx].id); + }, [enabledOptions, highlightedId]); + + const highlightPrevious = useCallback(() => { + if (enabledOptions.length <= 1) return; + const idx = enabledOptions.findIndex((o) => o.id === highlightedId); + const nextIdx = + idx <= 0 ? enabledOptions.length - 1 : (idx - 1 + enabledOptions.length) % enabledOptions.length; + setHighlightedId(enabledOptions[nextIdx].id); + }, [enabledOptions, highlightedId]); + + const highlightedOption = useMemo( + () => sortedOptions.find((o) => o.id === highlightedId), + [sortedOptions, highlightedId] + ); + + const contextValue = useMemo( + (): HighlightContextType => ({ + highlightedId, + highlightedOption: highlightedOption ?? null, + options: sortedOptions, + highlight, + highlightNext, + highlightPrevious, + registerOption, + }), + [ + highlightedId, + highlightedOption, + sortedOptions, + highlight, + highlightNext, + highlightPrevious, + registerOption, + ] + ); + + return ( + + {children} + + ); +} diff --git a/src/components/utils/SelectionContext.tsx b/src/components/utils/SelectionContext.tsx new file mode 100644 index 0000000..5e3b10b --- /dev/null +++ b/src/components/utils/SelectionContext.tsx @@ -0,0 +1,196 @@ +import { useControlledState } from "@/src/hooks/useControlledState"; +import { createContext, ReactNode, useCallback, useContext, useMemo, useState } from "react"; + +export interface SelectionOption { + value: T, + label: string, + display: ReactNode, + disabled: boolean, +} + +// +// Single Selection Context +// + +export interface SingleSelectionContextType { + selection: T | null, + selectedOption: SelectionOption | null; + options: ReadonlyArray>; + changeSelection: (selection: T) => void; + registerOption: (option: SelectionOption) => () => void; +} + +export const SingleSelectionContext = createContext | null>(null); + +export function useSingleSelectionContext(): SingleSelectionContextType | null { + const context = useContext(SingleSelectionContext); + if (!context) { + throw new Error('useSingleSelectionContext must be used within a SingleSelectionProvider'); + } + return context as SingleSelectionContextType; +} + +export interface SingleSelectionProviderProps { + children: ReactNode, + value: T | null, + onSelectionChange: (selection: T) => void, + initialSelection: T | null, + isControlled: boolean, + compareOptions?: (option1: T, option2: T) => boolean, +} + +export function SingleSelectionProvider({ + children, + value, + onSelectionChange, + initialSelection, + isControlled, + compareOptions +}: SingleSelectionProviderProps) { + const [options, setOptions] = useState[]>([]) + const [selection, setSelection] = useControlledState({ + value, + onValueChange: onSelectionChange, + defaultValue: initialSelection, + isControlled: isControlled, + }); + + const compareFunction = useMemo(() => { + return compareOptions ? compareOptions : Object.is; + }, [compareOptions]); + + const selectedOption = useMemo(() => { + if (!selection) return null; + return options.find(option => compareFunction(option.value, selection)); + }, [options, selection]); + + const registerOption = useCallback((option: SelectionOption) => { + setOptions(prev => [...prev, option]) + return () => { + setOptions(prev => prev.filter(o => !compareFunction(o.value, option.value))); + } + }, [setOptions, compareFunction]); + + const changeSelection = useCallback((selection: T) => { + const option = options.find(option => compareFunction(option.value, selection)); + if(!option || option.disabled) return; + setSelection(selection); + }, [setSelection, compareFunction]); + + return ( + ({ + selection, + selectedOption, + options, + changeSelection, + registerOption + }), [selection, selectedOption, options, setSelection, registerOption])} + > + {children} + + ); +} + +// +// Multi Selection Context +// + +export interface MultiSelectionContextType { + selection: ReadonlyArray; + selectedOptions: ReadonlyArray>; + options: ReadonlyArray>; + setSelection: (selection: ReadonlyArray) => void; + toggleSelection: (value: T) => void; + isSelected: (value: T) => boolean; + registerOption: (option: SelectionOption) => () => void; +} + +export const MultiSelectionContext = createContext | null>(null); + +export function useMultiSelectionContext(): MultiSelectionContextType { + const context = useContext(MultiSelectionContext); + if (!context) { + throw new Error('useMultiSelectionContext must be used within a MultiSelectionProvider'); + } + return context as MultiSelectionContextType; +} + +export interface MultiSelectionProviderProps { + children: ReactNode; + value?: ReadonlyArray; + onSelectionChange: (selection: ReadonlyArray) => void; + initialSelection?: ReadonlyArray; + compareOptions?: (option1: T, option2: T) => boolean; +} + +export function MultiSelectionProvider({ + children, + value, + onSelectionChange, + initialSelection = [], + compareOptions, +}: MultiSelectionProviderProps) { + const [options, setOptions] = useState[]>([]); + const [selection, setSelection] = useControlledState({ + value: value as T[] | undefined, + onValueChange: onSelectionChange as (v: T[]) => void, + defaultValue: initialSelection, + }); + + const compareFunction = useMemo(() => (compareOptions ?? Object.is), [compareOptions]); + + const selectedOptions = useMemo(() => + selection + .map((s) => options.find((o) => compareFunction(o.value, s))) + .filter((o): o is SelectionOption => o != null) + ,[options, selection, compareFunction]); + + const isSelected = useCallback( + (value: T) => selection.some((s) => compareFunction(s, value)), + [selection, compareFunction] + ); + + const registerOption = useCallback( + (option: SelectionOption) => { + setOptions((prev) => [...prev, option]); + return () => setOptions((prev) => prev.filter((o) => !compareFunction(o.value, option.value))); + }, + [compareFunction] + ); + + const toggleSelection = useCallback( + (value: T) => { + const option = options.find((o) => compareFunction(o.value, value)); + if (!option || option.disabled) return; + setSelection((prev) => { + const next = prev.some((s) => compareFunction(s, value)) + ? prev.filter((s) => !compareFunction(s, value)) + : [...prev, value]; + return next; + }); + }, + [options, compareFunction, setSelection] + ); + + const setSelectionValue = useCallback( + (next: ReadonlyArray) => setSelection(Array.from(next)), + [setSelection] + ); + + const contextValue = useMemo(() => ({ + selection, + selectedOptions, + options, + setSelection: setSelectionValue, + toggleSelection, + isSelected, + registerOption, + }), [selection, selectedOptions, options, setSelectionValue, toggleSelection, isSelected, registerOption]); + + return ( + + {children} + + ); +} \ No newline at end of file diff --git a/src/hooks/useControlledState.ts b/src/hooks/useControlledState.ts index ef571c1..7b1b4f9 100644 --- a/src/hooks/useControlledState.ts +++ b/src/hooks/useControlledState.ts @@ -50,7 +50,6 @@ export const useControlledState = ({ const setState: React.Dispatch> = useCallback((action) => { const resolved = resolveSetState(action, lastValue.current) - if(resolved === lastValue.current) return if(!isControlled) { lastValue.current = resolved setInternalValue(resolved) diff --git a/src/utils/dom.ts b/src/utils/dom.ts new file mode 100644 index 0000000..07049ec --- /dev/null +++ b/src/utils/dom.ts @@ -0,0 +1,10 @@ +function compareDocumentPosition(a: Node | null | undefined, b: Node | null | undefined) { + if (!a && !b) return 0 + if (!a) return 1 + if (!b) return -1 + return a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1 +} + +export const DOMUtils = { + compareDocumentPosition, +} \ No newline at end of file From d5e03623566bf7b5fb39958fedbeaea82c4283e1 Mon Sep 17 00:00:00 2001 From: DasProffi <67233923+DasProffi@users.noreply.github.com> Date: Sun, 22 Feb 2026 19:07:39 +0100 Subject: [PATCH 06/13] fix: combobox --- CHANGELOG.md | 3 +- .../layout/dialog/premade/LanguageDialog.tsx | 8 +- .../layout/dialog/premade/ThemeDialog.tsx | 8 +- .../layout/table/TablePagination.tsx | 4 +- src/components/user-interaction/Combobox.tsx | 317 ------------- .../user-interaction/Combobox/Combobox.tsx | 41 ++ .../Combobox/ComboboxContext.tsx | 48 ++ .../Combobox/ComboboxInput.tsx | 69 +++ .../Combobox/ComboboxList.tsx | 47 ++ .../Combobox/ComboboxOption.tsx | 76 ++++ .../Combobox/ComboboxRoot.tsx | 39 ++ .../user-interaction/Combobox/useCombobox.ts | 103 +++++ .../MultiSelect/MultiSelect.tsx | 24 + .../MultiSelect/MultiSelectButton.tsx | 100 ++++ .../MultiSelect/MultiSelectChipDisplay.tsx | 117 +++++ .../MultiSelect/MultiSelectContent.tsx | 176 +++++++ .../MultiSelect/MultiSelectContext.tsx | 338 ++++++++++++++ .../MultiSelect/MultiSelectOption.tsx | 111 +++++ .../user-interaction/Select/Select.tsx | 35 ++ .../user-interaction/Select/SelectButton.tsx | 100 ++++ .../user-interaction/Select/SelectContent.tsx | 175 +++++++ .../user-interaction/Select/SelectContext.tsx | 336 ++++++++++++++ .../user-interaction/Select/SelectOption.tsx | 107 +++++ .../user-interaction/data/FilterList.tsx | 31 +- .../user-interaction/data/FilterPopUp.tsx | 8 +- .../properties/MultiSelectProperty.tsx | 2 +- .../properties/SelectProperty.tsx | 6 +- .../user-interaction/select/MultiSelect.tsx | 28 -- .../select/MultiSelectChipDisplay.tsx | 134 ------ .../user-interaction/select/Select.tsx | 40 -- .../user-interaction/select/SelectButton.tsx | 121 ----- .../user-interaction/select/SelectContent.tsx | 212 --------- .../user-interaction/select/SelectContext.tsx | 429 ------------------ .../user-interaction/select/SelectOption.tsx | 122 ----- src/components/utils/HighlightContext.tsx | 142 ------ src/components/utils/SelectionContext.tsx | 196 -------- src/hooks/useListNavigation.tsx | 80 ++++ src/hooks/useMultiSelection.ts | 107 +++++ src/hooks/useSingleSelection.ts | 84 ++++ src/style/theme/components/combobox.css | 4 + stories/User Interaction/Combobox.stories.tsx | 43 ++ .../User Interaction/Form/Form.stories.tsx | 6 +- .../MultiSelectProperty.stories.tsx | 2 +- .../SingleSelectProperty.stories.tsx | 2 +- .../Select/MultiSelect.stories.tsx | 4 +- .../Select/MultiSelectChipDisplay.stories.tsx | 4 +- .../Select/Select.stories.tsx | 4 +- 47 files changed, 2408 insertions(+), 1785 deletions(-) delete mode 100644 src/components/user-interaction/Combobox.tsx create mode 100644 src/components/user-interaction/Combobox/Combobox.tsx create mode 100644 src/components/user-interaction/Combobox/ComboboxContext.tsx create mode 100644 src/components/user-interaction/Combobox/ComboboxInput.tsx create mode 100644 src/components/user-interaction/Combobox/ComboboxList.tsx create mode 100644 src/components/user-interaction/Combobox/ComboboxOption.tsx create mode 100644 src/components/user-interaction/Combobox/ComboboxRoot.tsx create mode 100644 src/components/user-interaction/Combobox/useCombobox.ts create mode 100644 src/components/user-interaction/MultiSelect/MultiSelect.tsx create mode 100644 src/components/user-interaction/MultiSelect/MultiSelectButton.tsx create mode 100644 src/components/user-interaction/MultiSelect/MultiSelectChipDisplay.tsx create mode 100644 src/components/user-interaction/MultiSelect/MultiSelectContent.tsx create mode 100644 src/components/user-interaction/MultiSelect/MultiSelectContext.tsx create mode 100644 src/components/user-interaction/MultiSelect/MultiSelectOption.tsx create mode 100644 src/components/user-interaction/Select/Select.tsx create mode 100644 src/components/user-interaction/Select/SelectButton.tsx create mode 100644 src/components/user-interaction/Select/SelectContent.tsx create mode 100644 src/components/user-interaction/Select/SelectContext.tsx create mode 100644 src/components/user-interaction/Select/SelectOption.tsx delete mode 100644 src/components/user-interaction/select/MultiSelect.tsx delete mode 100644 src/components/user-interaction/select/MultiSelectChipDisplay.tsx delete mode 100644 src/components/user-interaction/select/Select.tsx delete mode 100644 src/components/user-interaction/select/SelectButton.tsx delete mode 100644 src/components/user-interaction/select/SelectContent.tsx delete mode 100644 src/components/user-interaction/select/SelectContext.tsx delete mode 100644 src/components/user-interaction/select/SelectOption.tsx delete mode 100644 src/components/utils/HighlightContext.tsx delete mode 100644 src/components/utils/SelectionContext.tsx create mode 100644 src/hooks/useListNavigation.tsx create mode 100644 src/hooks/useMultiSelection.ts create mode 100644 src/hooks/useSingleSelection.ts create mode 100644 stories/User Interaction/Combobox.stories.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 69acc16..0ee91e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,9 +9,10 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## Added - Search for `Select` and `MultiSelect` -- Type ahead support for `Select` and `MultiSelect` +- Type ahead support for `Select` and `MultiSelect`npm - `Combobox` component - `FilterList` component for dynamically choosing and setting filters +- `SingleSelectionContext`, `MultiSelectionContext` and `HighlightContext` ## Fixed - imports in `TimePicker` and `DateTimeInput` diff --git a/src/components/layout/dialog/premade/LanguageDialog.tsx b/src/components/layout/dialog/premade/LanguageDialog.tsx index 2ce76af..7cd5f1f 100644 --- a/src/components/layout/dialog/premade/LanguageDialog.tsx +++ b/src/components/layout/dialog/premade/LanguageDialog.tsx @@ -6,12 +6,12 @@ import { useLocale } from '@/src/global-contexts/LocaleContext' import { Button } from '@/src/components/user-interaction/Button' import { useHightideTranslation } from '@/src/i18n/useHightideTranslation' import type { HightideTranslationLocales } from '@/src/i18n/translations' -import type { SelectProps } from '@/src/components/user-interaction/select/Select' -import { Select } from '@/src/components/user-interaction/select/Select' -import { SelectOption } from '@/src/components/user-interaction/select/SelectContent' +import type { SelectProps } from '@/src/components/user-interaction/Select/Select' +import { Select } from '@/src/components/user-interaction/Select/Select' +import { SelectOption } from '@/src/components/user-interaction/Select/SelectOption' import clsx from 'clsx' -type LanguageSelectProps = Omit +type LanguageSelectProps = Omit export const LanguageSelect = ({ ...props }: LanguageSelectProps) => { const { locale, setLocale } = useLocale() diff --git a/src/components/layout/dialog/premade/ThemeDialog.tsx b/src/components/layout/dialog/premade/ThemeDialog.tsx index f5615f1..a4ef1c2 100644 --- a/src/components/layout/dialog/premade/ThemeDialog.tsx +++ b/src/components/layout/dialog/premade/ThemeDialog.tsx @@ -8,9 +8,9 @@ import type { ThemeType } from '@/src/global-contexts/ThemeContext' import { ThemeUtil, useTheme } from '@/src/global-contexts/ThemeContext' import { Button } from '@/src/components/user-interaction/Button' import { useHightideTranslation } from '@/src/i18n/useHightideTranslation' -import type { SelectProps } from '@/src/components/user-interaction/select/Select' -import { Select } from '@/src/components/user-interaction/select/Select' -import { SelectOption } from '@/src/components/user-interaction/select/SelectContent' +import type { SelectProps } from '@/src/components/user-interaction/Select/Select' +import { Select } from '@/src/components/user-interaction/Select/Select' +import { SelectOption } from '@/src/components/user-interaction/Select/SelectOption' export interface ThemeIconProps extends HTMLAttributes { theme?: ThemeType, @@ -30,7 +30,7 @@ export const ThemeIcon = ({ theme: themeOverride, ...props }: ThemeIconProps) => } } -export type ThemeSelectProps = Omit +export type ThemeSelectProps = Omit export const ThemeSelect = ({ ...props }: ThemeSelectProps) => { const translation = useHightideTranslation() diff --git a/src/components/layout/table/TablePagination.tsx b/src/components/layout/table/TablePagination.tsx index 13e2db4..403c11b 100644 --- a/src/components/layout/table/TablePagination.tsx +++ b/src/components/layout/table/TablePagination.tsx @@ -1,7 +1,7 @@ import { Pagination, type PaginationProps } from '@/src/components/layout/navigation/Pagination' import type { HTMLAttributes } from 'react' -import { Select, type SelectProps } from '@/src/components/user-interaction/select/Select' -import { SelectOption } from '@/src/components/user-interaction/select/SelectOption' +import { Select, type SelectProps } from '@/src/components/user-interaction/Select/Select' +import { SelectOption } from '@/src/components/user-interaction/Select/SelectOption' import { Visibility } from '../Visibility' import clsx from 'clsx' import { useTableStateWithoutSizingContext } from './TableContext' diff --git a/src/components/user-interaction/Combobox.tsx b/src/components/user-interaction/Combobox.tsx deleted file mode 100644 index e4c6824..0000000 --- a/src/components/user-interaction/Combobox.tsx +++ /dev/null @@ -1,317 +0,0 @@ -import type { HTMLAttributes, ReactNode, RefObject } from 'react' -import { - createContext, - useCallback, - useContext, - useEffect, - useId, - useMemo, - useRef, - useState -} from 'react' -import { forwardRef } from 'react' -import clsx from 'clsx' -import { MultiSearchWithMapping } from '@/src/utils/simpleSearch' -import { Input } from '@/src/components/user-interaction/input/Input' -import { useHightideTranslation } from '@/src/i18n/useHightideTranslation' - -type RegisteredOption = { - value: string, - label: string, - display?: ReactNode, - ref: React.RefObject, -} - -type ComboboxContextIds = { - root: string, - listbox: string, -} - -type ComboboxContextType = { - ids: ComboboxContextIds, - searchString: string, - setSearchString: (s: string) => void, - options: RegisteredOption[], - visibleOptions: RegisteredOption[], - registerOption: (option: Omit & { ref: React.RefObject }) => () => void, - highlightedValue: string | undefined, - highlightItem: (value: string) => void, - moveHighlightedIndex: (delta: number) => void, - onItemClick: (id: string) => void, - listRef: React.RefObject, -} - -const ComboboxContext = createContext(null) - -function useComboboxContext() { - const ctx = useContext(ComboboxContext) - if (!ctx) { - throw new Error('Combobox components must be used within ComboboxRoot') - } - return ctx -} - -export interface ComboboxRootProps { - children: ReactNode, - onItemClick: (id: string) => void, - id?: string, -} - -export function ComboboxRoot({ children, onItemClick, id: idProp }: ComboboxRootProps) { - const generatedId = useId() - const rootId = idProp ?? `combobox-${generatedId}` - const listboxId = `${rootId}-listbox` - - const [searchString, setSearchString] = useState('') - const [options, setOptions] = useState([]) - const [highlightedValue, setHighlightedValue] = useState(undefined) - const listRef = useRef(null) - - const visibleOptions = useMemo(() => { - const q = searchString.trim().toLowerCase() - if (!q) return options - return MultiSearchWithMapping(searchString, options, o => [o.label]) - }, [options, searchString]) - - const registerOption = useCallback((option: RegisteredOption) => { - setOptions(prev => { - const next = prev.filter(o => o.value !== option.value) - next.push(option) - next.sort((a, b) => { - const aEl = a.ref.current - const bEl = b.ref.current - if (!aEl || !bEl) return 0 - return aEl.compareDocumentPosition(bEl) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1 - }) - return next - }) - return () => setOptions(prev => prev.filter(o => o.value !== option.value)) - }, []) - - const highlightItem = useCallback((value: string) => { - setHighlightedValue(value) - }, []) - - const moveHighlightedIndex = useCallback((delta: number) => { - setHighlightedValue(prev => { - const list = visibleOptions - if (list.length === 0) return undefined - const idx = list.findIndex(o => o.value === prev) - const nextIdx = idx < 0 ? 0 : (idx + delta + list.length) % list.length - return list[nextIdx]?.value - }) - }, [visibleOptions]) - - useEffect(() => { - const inList = visibleOptions.some(o => o.value === highlightedValue) - if (!inList && visibleOptions.length > 0) { - setHighlightedValue(visibleOptions[0].value) - } else if (!inList) { - setHighlightedValue(undefined) - } - }, [highlightedValue, visibleOptions]) - - useEffect(() => { - if (!highlightedValue) return - const opt = visibleOptions.find(o => o.value === highlightedValue) - opt?.ref.current?.scrollIntoView({ behavior: 'instant', block: 'nearest' }) - }, [highlightedValue, visibleOptions]) - - const value: ComboboxContextType = useMemo(() => ({ - ids: { root: rootId, listbox: listboxId }, - searchString, - setSearchString, - options, - visibleOptions, - registerOption, - highlightedValue, - highlightItem, - moveHighlightedIndex, - onItemClick, - listRef, - }), [ - rootId, - listboxId, - searchString, - options, - visibleOptions, - registerOption, - highlightedValue, - highlightItem, - moveHighlightedIndex, - onItemClick, - ]) - - return ( - -
    - {children} -
    -
    - ) -} - -export interface ComboboxInputProps extends Omit, 'value' | 'onValueChange'> { - value?: string, - onValueChange?: (value: string) => void, -} - -export const ComboboxInput = forwardRef(function ComboboxInput(props, ref) { - const translation = useHightideTranslation() - const { - searchString, - setSearchString, - visibleOptions, - highlightedValue, - moveHighlightedIndex, - onItemClick, - ids, - } = useComboboxContext() - - const handleKeyDown = useCallback((event: React.KeyboardEvent) => { - props.onKeyDown?.(event) - switch (event.key) { - case 'ArrowDown': - moveHighlightedIndex(1) - event.preventDefault() - break - case 'ArrowUp': - moveHighlightedIndex(-1) - event.preventDefault() - break - case 'Enter': - if (highlightedValue) { - onItemClick(highlightedValue) - event.preventDefault() - } - break - default: - break - } - }, [props, moveHighlightedIndex, highlightedValue, onItemClick]) - - return ( - 0} - aria-controls={ids.listbox} - aria-activedescendant={highlightedValue ? `highlightedValue` : undefined} - aria-autocomplete="list" - /> - ) -}) - -export type ComboboxListProps = HTMLAttributes - -export const ComboboxList = forwardRef(function ComboboxList( - { children, className, ...props }, - ref -) { - const { ids, listRef } = useComboboxContext() - - const setRefs = useCallback((node: HTMLUListElement | null) => { - (listRef as RefObject).current = node - if (typeof ref === 'function') ref(node) - else if (ref) (ref as RefObject).current = node - }, [ref, listRef]) - - return ( -
      - {children} -
    - ) -}) - -export interface ComboboxOptionProps extends Omit, 'children'> { - value: string, - label: string, - children?: ReactNode, -} - -export const ComboboxOption = forwardRef(function ComboboxOption( - { children, value, label, className, ...restProps }, - ref -) { - const { visibleOptions, registerOption, highlightItem, onItemClick, highlightedValue } = useComboboxContext() - const itemRef = useRef(null) - - const display = children ?? label - - useEffect(() => { - const unregister = registerOption({ - value, - label, - display, - ref: itemRef, - }) - return unregister - }, [value, label, registerOption, display]) - - const isVisible = visibleOptions.some(o => o.value === value) - const highlighted = highlightedValue === value - - return ( -
  • { - itemRef.current = node - if (typeof ref === 'function') ref(node) - else if (ref) (ref as RefObject).current = node - }} - id={value} - role="option" - aria-selected={highlighted} - aria-hidden={!isVisible} - data-name="combobox-option" - data-highlighted={highlighted ? '' : undefined} - data-visible={isVisible ? '' : undefined} - className={clsx(!isVisible && 'hidden', className)} - onClick={event => { - onItemClick(value) - restProps.onClick?.(event) - }} - onMouseEnter={event => { - highlightItem(value) - restProps.onMouseEnter?.(event) - }} - > - {display} -
  • - ) -}) - -ComboboxOption.displayName = 'ComboboxOption' - -export interface ComboboxProps { - children: ReactNode, - onItemClick: (id: string) => void, - id?: string, - inputProps?: ComboboxInputProps, - listProps?: ComboboxListProps, -} - -export function Combobox({ children, onItemClick, id, inputProps, listProps }: ComboboxProps) { - return ( - - - {children} - - ) -} diff --git a/src/components/user-interaction/Combobox/Combobox.tsx b/src/components/user-interaction/Combobox/Combobox.tsx new file mode 100644 index 0000000..cf51563 --- /dev/null +++ b/src/components/user-interaction/Combobox/Combobox.tsx @@ -0,0 +1,41 @@ +import type { ReactNode } from "react"; +import { ComboboxRoot } from "./ComboboxRoot"; +import { ComboboxInput } from "./ComboboxInput"; +import { ComboboxList } from "./ComboboxList"; +import type { ComboboxInputProps } from "./ComboboxInput"; +import type { ComboboxListProps } from "./ComboboxList"; + +export interface ComboboxProps { + children: ReactNode; + onItemClick: (id: string) => void; + id?: string; + searchString?: string; + onSearchStringChange?: (value: string) => void; + initialSearchString?: string; + inputProps?: ComboboxInputProps; + listProps?: ComboboxListProps; +} + +export function Combobox({ + children, + onItemClick, + id, + searchString, + onSearchStringChange, + initialSearchString, + inputProps, + listProps, +}: ComboboxProps) { + return ( + + + {children} + + ); +} diff --git a/src/components/user-interaction/Combobox/ComboboxContext.tsx b/src/components/user-interaction/Combobox/ComboboxContext.tsx new file mode 100644 index 0000000..e6798df --- /dev/null +++ b/src/components/user-interaction/Combobox/ComboboxContext.tsx @@ -0,0 +1,48 @@ +import { createContext, ReactNode, RefObject, useContext } from "react"; + +export interface ComboboxOptionType { + value: T; + label: string; + display?: ReactNode; + disabled?: boolean; +} + +export interface RegisteredComboboxOption extends ComboboxOptionType { + id: string; + ref: RefObject; +} + +export interface ComboboxContextIds { + root: string; + listbox: string; +} + +export interface ComboboxHighlighting { + value: string | undefined; + setValue: (value: string) => void; + next: () => void; + previous: () => void; + first: () => void; + last: () => void; +} + +export interface ComboboxContextType { + ids: ComboboxContextIds; + searchString: string; + setSearchString: (s: string) => void; + visibleOptions: ReadonlyArray>; + highlighting: ComboboxHighlighting; + onItemClick: (id: T) => void; + listRef: RefObject; + registerOption: (option: RegisteredComboboxOption) => () => void; +} + +export const ComboboxContext = createContext | null>(null); + +export function useComboboxContext(): ComboboxContextType { + const ctx = useContext(ComboboxContext); + if (ctx == null) { + throw new Error("Combobox components must be used within ComboboxRoot"); + } + return ctx as unknown as ComboboxContextType; +} diff --git a/src/components/user-interaction/Combobox/ComboboxInput.tsx b/src/components/user-interaction/Combobox/ComboboxInput.tsx new file mode 100644 index 0000000..f333b43 --- /dev/null +++ b/src/components/user-interaction/Combobox/ComboboxInput.tsx @@ -0,0 +1,69 @@ +import { ComponentProps, forwardRef, useCallback } from "react"; +import { Input } from "@/src/components/user-interaction/input/Input"; +import { useHightideTranslation } from "@/src/i18n/useHightideTranslation"; +import { useComboboxContext } from "./ComboboxContext"; + +export interface ComboboxInputProps extends Omit, "value"> {} + +export const ComboboxInput = forwardRef( + function ComboboxInput(props, ref) { + const translation = useHightideTranslation(); + const { + searchString, + setSearchString, + visibleOptions, + highlighting, + onItemClick, + ids, + } = useComboboxContext(); + + const handleKeyDown = useCallback( + (event: React.KeyboardEvent) => { + props.onKeyDown?.(event); + switch (event.key) { + case "ArrowDown": + highlighting.next(); + event.preventDefault(); + break; + case "ArrowUp": + highlighting.previous(); + event.preventDefault(); + break; + case "Home": + highlighting.first(); + event.preventDefault(); + break; + case "End": + highlighting.last(); + event.preventDefault(); + break; + case "Enter": + if (highlighting.value) { + onItemClick(highlighting.value); + event.preventDefault(); + } + break; + default: + break; + } + }, + [props, highlighting, onItemClick] + ); + + return ( + 0} + aria-controls={ids.listbox} + aria-activedescendant={highlighting.value ?? undefined} + aria-autocomplete="list" + /> + ); + } +); diff --git a/src/components/user-interaction/Combobox/ComboboxList.tsx b/src/components/user-interaction/Combobox/ComboboxList.tsx new file mode 100644 index 0000000..42cf9fa --- /dev/null +++ b/src/components/user-interaction/Combobox/ComboboxList.tsx @@ -0,0 +1,47 @@ +import type { HTMLAttributes, RefObject } from "react"; +import { forwardRef, useCallback } from "react"; +import clsx from "clsx"; +import { useComboboxContext } from "./ComboboxContext"; +import { useHightideTranslation } from "@/src/i18n/useHightideTranslation"; + +export interface ComboboxListProps extends HTMLAttributes {} + +export const ComboboxList = forwardRef( + function ComboboxList({ children, ...props }, ref) { + const translation = useHightideTranslation(); + const { ids, listRef, visibleOptions } = useComboboxContext(); + + const setRefs = useCallback((node: HTMLUListElement | null) => { + (listRef as RefObject).current = node; + if (typeof ref === "function") ref(node); + else if (ref) (ref as RefObject).current = node; + }, [ref, listRef]); + + const count = visibleOptions.length; + + return ( +
      + {children} +
    • 0 })} + > + {translation("nResultsFound", { count })} +
    • +
    + ); + } +); diff --git a/src/components/user-interaction/Combobox/ComboboxOption.tsx b/src/components/user-interaction/Combobox/ComboboxOption.tsx new file mode 100644 index 0000000..bbef51e --- /dev/null +++ b/src/components/user-interaction/Combobox/ComboboxOption.tsx @@ -0,0 +1,76 @@ +import type { HTMLAttributes, ReactNode, RefObject } from "react"; +import { forwardRef, useEffect, useId, useRef } from "react"; +import clsx from "clsx"; +import { useComboboxContext } from "./ComboboxContext"; + +export interface ComboboxOptionProps extends HTMLAttributes { + value: T; + label: string; + disabled?: boolean; + children?: ReactNode; +} + +export const ComboboxOption = forwardRef( function ComboboxOption({ + children, + value, + label, + disabled = false, + className, + ...restProps +}, ref) { + const id = useId(); + const { + visibleOptions, + registerOption, + highlighting, + onItemClick, + } = useComboboxContext(); + const itemRef = useRef(null); + + const resolvedDisplay = children ?? label; + + useEffect(() => { + return registerOption({ + id, + value, + label, + display: resolvedDisplay, + disabled, + ref: itemRef, + }); + }, [value, label, resolvedDisplay, disabled, registerOption]); + + const isVisible = visibleOptions.some((o) => o.value === value); + const highlighted = highlighting.value === id; + + return ( +
  • { + itemRef.current = node; + if (typeof ref === "function") ref(node); + else if (ref) (ref as RefObject).current = node; + }} + id={id} + role="option" + aria-selected={highlighted} + aria-hidden={!isVisible} + data-name="combobox-option" + data-highlighted={highlighted ? "" : undefined} + data-visible={isVisible ? "" : undefined} + className={clsx(!isVisible && "hidden", className)} + onClick={(event) => { + onItemClick(value); + restProps.onClick?.(event); + }} + onMouseEnter={(event) => { + highlighting.setValue(value); + restProps.onMouseEnter?.(event); + }} + > + {resolvedDisplay} +
  • + ); +}) + +ComboboxOption.displayName = "ComboboxOption"; diff --git a/src/components/user-interaction/Combobox/ComboboxRoot.tsx b/src/components/user-interaction/Combobox/ComboboxRoot.tsx new file mode 100644 index 0000000..6c45f7a --- /dev/null +++ b/src/components/user-interaction/Combobox/ComboboxRoot.tsx @@ -0,0 +1,39 @@ +import type { ReactNode } from "react"; +import { useCallback, useState } from "react"; +import { ComboboxContext } from "./ComboboxContext"; +import type { RegisteredComboboxOption } from "./ComboboxContext"; +import type { UseComboboxProps } from "./useCombobox"; +import { useCombobox } from "./useCombobox"; +import { DOMUtils } from "@/src/utils/dom"; + +export interface ComboboxRootProps extends Omit { + children: ReactNode; +} + +export function ComboboxRoot(props: ComboboxRootProps) { + const { children, ...hookProps } = props; + const [options, setOptions] = useState([]); + + const registerOption = useCallback( + (option: RegisteredComboboxOption) => { + setOptions((prev) => { + const next = prev.filter((o) => o.value !== option.value); + next.push(option); + next.sort((a, b) => + DOMUtils.compareDocumentPosition(a.ref.current, b.ref.current) + ); + return next; + }); + return () => + setOptions((prev) => prev.filter((o) => o.value !== option.value)); + }, + [] + ); + + const value = useCombobox({ ...hookProps, options }); + return ( + + {children} + + ); +} diff --git a/src/components/user-interaction/Combobox/useCombobox.ts b/src/components/user-interaction/Combobox/useCombobox.ts new file mode 100644 index 0000000..55884be --- /dev/null +++ b/src/components/user-interaction/Combobox/useCombobox.ts @@ -0,0 +1,103 @@ +import { RefObject, useEffect, useId, useMemo, useRef } from "react"; +import { useListNavigation } from "@/src/hooks/useListNavigation"; +import { useControlledState } from "@/src/hooks/useControlledState"; +import { MultiSearchWithMapping } from "@/src/utils/simpleSearch"; +import type { + ComboboxContextType, +} from "./ComboboxContext"; + +export interface UseComboboxOption { + id: string; + value: T; + label: string; + disabled?: boolean; + ref: RefObject; +} + +export interface UseComboboxConfiguration { + id?: string; +} + +export interface UseComboboxState { + searchString?: string; + onSearchStringChange?: (value: string) => void; + initialSearchString?: string; +} + +export interface UseComboboxProps extends UseComboboxConfiguration, UseComboboxState { + options: ReadonlyArray>; + onItemClick: (id: T) => void; +} + +export type UseComboboxResult = Omit, "registerOption">; + +export function useCombobox({ + onItemClick, + id: idProp, + options, + searchString: controlledSearchString, + onSearchStringChange, + initialSearchString = "", +}: UseComboboxProps): UseComboboxResult { + const generatedId = useId(); + const rootId = idProp ?? `combobox-${generatedId}`; + const listboxId = `${rootId}-listbox`; + + const [searchString, setSearchString] = useControlledState({ + value: controlledSearchString, + onValueChange: onSearchStringChange, + defaultValue: initialSearchString, + isControlled: controlledSearchString !== undefined, + }); + + const listRef = useRef(null); + + const visibleOptions = useMemo(() => { + const q = (searchString ?? "").trim().toLowerCase(); + if (!q) return options; + return MultiSearchWithMapping(searchString ?? "", [...options], (o) => [o.label]); + }, [options, searchString]); + + const visibleOptionIds = useMemo(() => visibleOptions.map((o) => o.id), [visibleOptions]); + + const listNav = useListNavigation({ + options: visibleOptionIds, + }); + + const lastScrolledId = useRef(null); + useEffect(() => { + if(lastScrolledId.current === listNav.highlightedId) return; + const opt = options.find((o) => o.id === listNav.highlightedId); + if(opt?.ref?.current) { + lastScrolledId.current = listNav.highlightedId; + opt.ref.current.scrollIntoView?.({ behavior: "smooth", block: "nearest" }); + } + }, [listNav.highlightedId, options]); + + const highlighting = useMemo( + () => ({ + value: listNav.highlightedId ?? undefined, + setValue: (id: string) => { + const option = options.find((o) => o.id === id); + if (option) { + listNav.highlight(id); + } + }, + next: listNav.next, + previous: listNav.previous, + first: listNav.first, + last: listNav.last, + }), + [visibleOptions, listNav] + ); + + return useMemo((): UseComboboxResult => ({ + ids: { root: rootId, listbox: listboxId }, + searchString: searchString ?? "", + setSearchString, + visibleOptions, + highlighting, + onItemClick, + listRef, + }), [rootId, listboxId, searchString, setSearchString, visibleOptions, highlighting, onItemClick, listRef]); +} diff --git a/src/components/user-interaction/MultiSelect/MultiSelect.tsx b/src/components/user-interaction/MultiSelect/MultiSelect.tsx new file mode 100644 index 0000000..1ad7cdf --- /dev/null +++ b/src/components/user-interaction/MultiSelect/MultiSelect.tsx @@ -0,0 +1,24 @@ +import { forwardRef } from "react"; +import type { MultiSelectRootProps } from "./MultiSelectContext"; +import { MultiSelectRoot } from "./MultiSelectContext"; +import type { MultiSelectButtonProps } from "./MultiSelectButton"; +import { MultiSelectButton } from "./MultiSelectButton"; +import type { MultiSelectContentProps } from "./MultiSelectContent"; +import { MultiSelectContent } from "./MultiSelectContent"; + +export interface MultiSelectProps extends MultiSelectRootProps { + contentPanelProps?: MultiSelectContentProps; + buttonProps?: MultiSelectButtonProps; +} + +export const MultiSelect = forwardRef(function MultiSelect( + { children, contentPanelProps, buttonProps, ...props }, + ref +) { + return ( + + + {children} + + ); +}); diff --git a/src/components/user-interaction/MultiSelect/MultiSelectButton.tsx b/src/components/user-interaction/MultiSelect/MultiSelectButton.tsx new file mode 100644 index 0000000..a96b839 --- /dev/null +++ b/src/components/user-interaction/MultiSelect/MultiSelectButton.tsx @@ -0,0 +1,100 @@ +import type { ComponentPropsWithoutRef, ReactNode } from "react"; +import { forwardRef, useEffect, useImperativeHandle, useRef } from "react"; +import { useMultiSelectContext } from "./MultiSelectContext"; +import clsx from "clsx"; +import { useHightideTranslation } from "@/src/i18n/useHightideTranslation"; +import { ExpansionIcon } from "@/src/components/display-and-visualization/ExpansionIcon"; +import { MultiSelectOptionDisplayContext } from "./MultiSelectOption"; + +export interface MultiSelectButtonProps extends ComponentPropsWithoutRef<"div"> { + placeholder?: ReactNode; + disabled?: boolean; + selectedDisplay?: (values: string[]) => ReactNode; + hideExpansionIcon?: boolean; +} + +export const MultiSelectButton = forwardRef(function MultiSelectButton( + { id, placeholder, disabled: disabledOverride, selectedDisplay, hideExpansionIcon = false, ...props }, + ref +) { + const translation = useHightideTranslation(); + const { state, trigger, setIds, ids } = useMultiSelectContext(); + const { register, unregister, toggleOpen } = trigger; + + useEffect(() => { + if (id) setIds((prev) => ({ ...prev, trigger: id })); + }, [id, setIds]); + + const innerRef = useRef(null); + useImperativeHandle(ref, () => innerRef.current!); + + useEffect(() => { + register(innerRef); + return () => unregister(); + }, [register, unregister]); + + const disabled = !!disabledOverride || !!state.disabled; + const invalid = state.invalid; + const hasValue = state.value.length > 0; + + return ( +
    { + props.onClick?.(event); + toggleOpen(!state.isOpen); + }} + onKeyDown={(event) => { + props.onKeyDown?.(event); + if (disabled) return; + switch (event.key) { + case "Enter": + case " ": + toggleOpen(!state.isOpen); + event.preventDefault(); + event.stopPropagation(); + break; + case "ArrowDown": + toggleOpen(true, { highlightStartPositionBehavior: "first" }); + event.preventDefault(); + event.stopPropagation(); + break; + case "ArrowUp": + toggleOpen(true, { highlightStartPositionBehavior: "last" }); + event.preventDefault(); + event.stopPropagation(); + break; + } + }} + data-name={props["data-name"] ?? "select-button"} + data-value={hasValue ? "" : undefined} + data-disabled={disabled ? "" : undefined} + data-invalid={invalid ? "" : undefined} + tabIndex={disabled ? -1 : 0} + role="button" + aria-invalid={invalid} + aria-disabled={disabled} + aria-haspopup="dialog" + aria-expanded={state.isOpen} + aria-controls={state.isOpen ? ids.content : undefined} + > + + {hasValue + ? selectedDisplay?.(state.value) ?? ( +
    + {state.selectedOptions.map(({ value, display }, index) => ( + + {display} + {index < state.value.length - 1 && ,} + + ))} +
    + ) + : placeholder ?? translation("clickToSelect")} +
    + {!hideExpansionIcon && } +
    + ); +}); diff --git a/src/components/user-interaction/MultiSelect/MultiSelectChipDisplay.tsx b/src/components/user-interaction/MultiSelect/MultiSelectChipDisplay.tsx new file mode 100644 index 0000000..35983ad --- /dev/null +++ b/src/components/user-interaction/MultiSelect/MultiSelectChipDisplay.tsx @@ -0,0 +1,117 @@ +import type { HTMLAttributes, ReactNode } from "react"; +import { forwardRef, useEffect, useImperativeHandle, useRef } from "react"; +import { + MultiSelectRoot, + useMultiSelectContext, + type MultiSelectRootProps, +} from "./MultiSelectContext"; +import type { MultiSelectContentProps } from "./MultiSelectContent"; +import { MultiSelectContent } from "./MultiSelectContent"; +import { IconButton } from "@/src/components/user-interaction/IconButton"; +import { useHightideTranslation } from "@/src/i18n/useHightideTranslation"; +import { XIcon, Plus } from "lucide-react"; + +export type MultiSelectChipDisplayButtonProps = HTMLAttributes & { + disabled?: boolean; + placeholder?: ReactNode; +}; + +export const MultiSelectChipDisplayButton = forwardRef< + HTMLDivElement, + MultiSelectChipDisplayButtonProps +>(function MultiSelectChipDisplayButton({ id, ...props }, ref) { + const translation = useHightideTranslation(); + const { state, trigger, item, ids, setIds } = useMultiSelectContext(); + const { register, unregister, toggleOpen } = trigger; + + useEffect(() => { + if (id) setIds((prev) => ({ ...prev, trigger: id })); + }, [id, setIds]); + + const innerRef = useRef(null); + useImperativeHandle(ref, () => innerRef.current!); + + useEffect(() => { + register(innerRef); + return () => unregister(); + }, [register, unregister]); + + const disabled = !!props?.disabled || !!state.disabled; + const invalid = state.invalid; + + return ( +
    { + toggleOpen(); + props.onClick?.(event); + }} + data-name={props["data-name"] ?? "select-chip-display"} + data-value={state.value.length > 0 ? "" : undefined} + data-disabled={disabled ? "" : undefined} + data-invalid={invalid ? "" : undefined} + aria-invalid={invalid} + aria-disabled={disabled} + > + {state.selectedOptions.map(({ value, display }) => ( +
    + {display} + item.toggleSelection(value, false)} + size="sm" + color="negative" + coloringStyle="text" + className="flex-row-0 items-center size-7 p-1" + > + + +
    + ))} + { + event.stopPropagation(); + toggleOpen(); + }} + onKeyDown={(event) => { + switch (event.key) { + case "ArrowDown": + toggleOpen(true, { highlightStartPositionBehavior: "first" }); + break; + case "ArrowUp": + toggleOpen(true, { highlightStartPositionBehavior: "last" }); + } + }} + tooltip={translation("changeSelection")} + size="md" + color="neutral" + aria-invalid={invalid} + aria-disabled={disabled} + aria-haspopup="dialog" + aria-expanded={state.isOpen} + aria-controls={state.isOpen ? ids.content : undefined} + className="size-9" + > + + +
    + ); +}); + +export type MultiSelectChipDisplayProps = MultiSelectRootProps & { + contentPanelProps?: MultiSelectContentProps; + chipDisplayProps?: MultiSelectChipDisplayButtonProps; +}; + +export const MultiSelectChipDisplay = forwardRef( + function MultiSelectChipDisplay({ children, contentPanelProps, chipDisplayProps, ...props }, ref) { + return ( + + + {children} + + ); + } +); diff --git a/src/components/user-interaction/MultiSelect/MultiSelectContent.tsx b/src/components/user-interaction/MultiSelect/MultiSelectContent.tsx new file mode 100644 index 0000000..b58862a --- /dev/null +++ b/src/components/user-interaction/MultiSelect/MultiSelectContent.tsx @@ -0,0 +1,176 @@ +import type { ComponentProps } from "react"; +import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef } from "react"; +import { useMultiSelectContext } from "./MultiSelectContext"; +import clsx from "clsx"; +import { useHightideTranslation } from "@/src/i18n/useHightideTranslation"; +import { PopUp, type PopUpProps } from "@/src/components/layout/popup/PopUp"; +import { Input } from "@/src/components/user-interaction/input/Input"; +import { Visibility } from "@/src/components/layout/Visibility"; + +const TYPEAHEAD_RESET_MS = 500; + +export interface MultiSelectContentProps extends PopUpProps { + showSearch?: boolean; + searchInputProps?: Omit, "value" | "onValueChange">; +} + +export const MultiSelectContent = forwardRef( + function MultiSelectContent( + { id, options, showSearch: showSearchOverride, searchInputProps, ...props }, + ref + ) { + const translation = useHightideTranslation(); + const innerRef = useRef(null); + const searchInputRef = useRef(null); + const typeAheadBufferRef = useRef(""); + const typeAheadTimeoutRef = useRef | null>(null); + useImperativeHandle(ref, () => innerRef.current!); + + const { trigger, state, item, ids, setIds, search } = useMultiSelectContext(); + + useEffect(() => { + if (id) setIds((prev) => ({ ...prev, content: id })); + }, [id, setIds]); + + useEffect(() => { + if (!state.isOpen) { + typeAheadBufferRef.current = ""; + if (typeAheadTimeoutRef.current) { + clearTimeout(typeAheadTimeoutRef.current); + typeAheadTimeoutRef.current = null; + } + } + }, [state.isOpen]); + + useEffect( + () => () => { + if (typeAheadTimeoutRef.current) clearTimeout(typeAheadTimeoutRef.current); + }, + [] + ); + + const showSearch = showSearchOverride ?? search.showSearch; + const listboxAriaLabel = showSearch ? translation("searchResults") : undefined; + + const keyHandler = useCallback( + (event: React.KeyboardEvent) => { + switch (event.key) { + case "ArrowDown": + item.moveHighlightedIndex(1); + event.preventDefault(); + break; + case "ArrowUp": + item.moveHighlightedIndex(-1); + event.preventDefault(); + break; + case "Home": + event.preventDefault(); + item.highlightFirst(); + break; + case "End": + event.preventDefault(); + item.highlightLast(); + break; + case "Enter": + case " ": + if (showSearch && event.key === " ") return; + if (state.highlightedValue) { + item.toggleSelection(state.highlightedValue); + event.preventDefault(); + } + break; + default: + if (!showSearch && !event.ctrlKey && !event.metaKey && !event.altKey) { + const char = event.key.toLowerCase(); + if (typeAheadTimeoutRef.current) clearTimeout(typeAheadTimeoutRef.current); + typeAheadBufferRef.current += char; + typeAheadTimeoutRef.current = setTimeout(() => { + typeAheadBufferRef.current = ""; + }, TYPEAHEAD_RESET_MS); + const opts = state.visibleOptions; + const buf = typeAheadBufferRef.current; + if (opts.length === 0) { + event.preventDefault(); + return; + } + const currentIndex = opts.findIndex((o) => o.value === state.highlightedValue); + const startFrom = currentIndex >= 0 ? (currentIndex + 1) % opts.length : 0; + for (let i = 0; i < opts.length; i++) { + const j = (startFrom + i) % opts.length; + if (!opts[j].disabled && opts[j].label.toLowerCase().startsWith(buf)) { + item.highlightItem(opts[j].value); + event.preventDefault(); + return; + } + } + event.preventDefault(); + } + break; + } + }, + [showSearch, state.visibleOptions, state.highlightedValue, item] + ); + + return ( + { + trigger.toggleOpen(false); + props.onClose?.(); + }} + aria-labelledby={ids.trigger} + className="gap-y-1" + > + {showSearch && ( + + )} +
      + {props.children} + +
    • 0 })} + > + {translation("nResultsFound", { count: state.visibleOptions.length })} +
    • +
      +
    +
    + ); + } +); diff --git a/src/components/user-interaction/MultiSelect/MultiSelectContext.tsx b/src/components/user-interaction/MultiSelect/MultiSelectContext.tsx new file mode 100644 index 0000000..1b328f9 --- /dev/null +++ b/src/components/user-interaction/MultiSelect/MultiSelectContext.tsx @@ -0,0 +1,338 @@ +import type { Dispatch, ReactNode, SetStateAction } from "react"; +import { + createContext, + useCallback, + useContext, + useEffect, + useId, + useMemo, + useRef, + useState, +} from "react"; +import type { FormFieldInteractionStates } from "@/src/components/form/FieldLayout"; +import type { FormFieldDataHandling } from "@/src/components/form/FormField"; +import { useMultiSelection } from "@/src/hooks/useMultiSelection"; +import { useListNavigation } from "@/src/hooks/useListNavigation"; +import { MultiSearchWithMapping } from "@/src/utils/simpleSearch"; + +export type MultiSelectIconAppearance = "left" | "right" | "none"; + +type RegisteredOption = { + value: string; + label: string; + display: ReactNode; + disabled: boolean; + ref: React.RefObject; +}; + +type MultiSelectContextIds = { + trigger: string; + content: string; + listbox: string; + searchInput: string; +}; + +type MultiSelectContextState = FormFieldInteractionStates & { + isOpen: boolean; + options: RegisteredOption[]; + visibleOptions: RegisteredOption[]; + searchQuery: string; + value: string[]; + selectedOptions: RegisteredOption[]; + highlightedValue: string | undefined; +}; + +export type MultiSelectContextType = { + ids: MultiSelectContextIds; + setIds: Dispatch>; + state: MultiSelectContextState; + iconAppearance: MultiSelectIconAppearance; + item: { + register: (item: RegisteredOption) => () => void; + unregister: (value: string) => void; + toggleSelection: (value: string, isSelected?: boolean) => void; + highlightFirst: () => void; + highlightLast: () => void; + highlightItem: (value: string) => void; + moveHighlightedIndex: (delta: number) => void; + }; + trigger: { + ref: React.RefObject; + register: (element: React.RefObject) => void; + unregister: () => void; + toggleOpen: (isOpen?: boolean, options?: { highlightStartPositionBehavior?: "first" | "last" }) => void; + }; + search: { + showSearch: boolean; + searchQuery: string; + setSearchQuery: (query: string) => void; + }; +}; + +const MultiSelectContext = createContext(null); + +export function useMultiSelectContext(): MultiSelectContextType { + const ctx = useContext(MultiSelectContext); + if (!ctx) throw new Error("useMultiSelectContext must be used within MultiSelectRoot"); + return ctx; +} + +export interface SharedMultiSelectRootProps extends Partial { + children: ReactNode; + id?: string; + initialIsOpen?: boolean; + iconAppearance?: MultiSelectIconAppearance; + showSearch?: boolean; + onClose?: () => void; +} + +export interface MultiSelectRootProps + extends SharedMultiSelectRootProps, + Partial> { + initialValue?: string[]; +} + +export function useMultiSelect(props: MultiSelectRootProps): MultiSelectContextType { + const { + id, + value: controlledValues, + onValueChange, + initialValue, + onEditComplete, + onClose, + initialIsOpen = false, + disabled = false, + readOnly = false, + required = false, + invalid = false, + showSearch = false, + iconAppearance = "left", + } = props; + + const triggerRef = useRef(null); + const generatedId = useId(); + const [ids, setIds] = useState({ + trigger: id ?? "multi-select-" + generatedId, + content: "multi-select-content-" + generatedId, + listbox: "multi-select-listbox-" + generatedId, + searchInput: "multi-select-search-" + generatedId, + }); + const [isOpen, setIsOpen] = useState(initialIsOpen); + const [options, setOptions] = useState([]); + const [searchQuery, setSearchQuery] = useState(""); + + const selection = useMultiSelection({ + value: controlledValues, + onSelectionChange: (v) => onValueChange?.(Array.from(v)), + initialSelection: initialValue, + isControlled: controlledValues !== undefined, + }); + + const visibleOptions = useMemo(() => { + const q = searchQuery.trim().toLowerCase(); + if (!q) return options; + return MultiSearchWithMapping(searchQuery, options, (o) => [o.label]); + }, [options, searchQuery]); + + const listNav = useListNavigation({ + options: visibleOptions.map((o) => o.value), + initialValue: controlledValues?.[0] ?? initialValue?.[0], + }); + + const value = useMemo(() => [...selection.selection], [selection.selection]); + const selectedOptions = useMemo( + () => + value + .map((v) => options.find((o) => o.value === v)) + .filter((o): o is RegisteredOption => o != null), + [value, options] + ); + + useEffect(() => { + if ( + listNav.highlightedId != null && + !visibleOptions.some((o) => o.value === listNav.highlightedId) + ) { + const first = visibleOptions.find((o) => !o.disabled); + if (first) listNav.highlight(first.value); + } + }, [visibleOptions, listNav.highlightedId, listNav.highlight]); + + useEffect(() => { + const opt = options.find((o) => o.value === listNav.highlightedId); + opt?.ref.current?.scrollIntoView({ behavior: "instant", block: "nearest" }); + }, [listNav.highlightedId, options]); + + const registerItem = useCallback( + (item: RegisteredOption) => { + setOptions((prev) => { + const next = prev.filter((o) => o.value !== item.value); + next.push(item); + next.sort((a, b) => { + const aEl = a.ref.current; + const bEl = b.ref.current; + if (!aEl || !bEl) return 0; + return aEl.compareDocumentPosition(bEl) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1; + }); + return next; + }); + const unregSelection = selection.registerOption({ + value: item.value, + label: item.label, + display: item.display, + disabled: item.disabled, + }); + return () => { + unregSelection(); + setOptions((prev) => prev.filter((o) => o.value !== item.value)); + }; + }, + [selection, listNav] + ); + + const unregisterItem = useCallback((value: string) => { + setOptions((prev) => prev.filter((o) => o.value !== value)); + }, []); + + const toggleSelectionValue = useCallback( + (optionValue: string, isSelected?: boolean) => { + if (disabled) return; + const before = selection.isSelected(optionValue); + const next = isSelected ?? !before; + if (next) selection.toggleSelection(optionValue); + else selection.setSelection([...selection.selection].filter((v) => v !== optionValue)); + listNav.highlight(optionValue); + }, + [disabled, selection, listNav] + ); + + const highlightItem = useCallback( + (value: string) => { + if (disabled || !visibleOptions.some((o) => o.value === value && !o.disabled)) return; + listNav.highlight(value); + }, + [disabled, visibleOptions, listNav] + ); + + const highlightFirst = useCallback(() => { + const first = visibleOptions.find((o) => !o.disabled); + if (first) listNav.highlight(first.value); + }, [visibleOptions, listNav]); + + const highlightLast = useCallback(() => { + const last = [...visibleOptions].reverse().find((o) => !o.disabled); + if (last) listNav.highlight(last.value); + }, [visibleOptions, listNav]); + + const moveHighlightedIndex = useCallback( + (delta: number) => { + const list = visibleOptions.filter((o) => !o.disabled); + if (list.length === 0) return; + const idx = list.findIndex((o) => o.value === listNav.highlightedId); + const startIdx = idx < 0 ? 0 : (idx + (delta % list.length) + list.length) % list.length; + const isForward = delta >= 0; + let nextIdx = startIdx; + for (let i = 0; i < list.length; i++) { + const j = (startIdx + (isForward ? i : -i) + list.length) % list.length; + if (!list[j].disabled) { + nextIdx = j; + break; + } + } + listNav.highlight(list[nextIdx].value); + }, + [visibleOptions, listNav] + ); + + const registerTrigger = useCallback((ref: React.RefObject) => { + (triggerRef as React.MutableRefObject).current = ref.current; + }, []); + + const unregisterTrigger = useCallback(() => { + (triggerRef as React.MutableRefObject).current = null; + }, []); + + const handleClose = useCallback(() => { + onEditComplete?.(controlledValues); + onClose?.(); + }, [onEditComplete, onClose, controlledValues]); + + const toggleOpen = useCallback( + (open?: boolean, opts?: { highlightStartPositionBehavior?: "first" | "last" }) => { + const next = open ?? !isOpen; + if (next) { + const behavior = opts?.highlightStartPositionBehavior ?? "first"; + const list = visibleOptions.filter((o) => !o.disabled); + const firstSelected = list.find((o) => selection.isSelected(o.value)); + const fallback = behavior === "first" ? list[0] : list[list.length - 1]; + const toHighlight = firstSelected ?? fallback; + if (toHighlight) listNav.highlight(toHighlight.value); + } else { + setSearchQuery(""); + handleClose(); + } + setIsOpen(next); + }, + [isOpen, visibleOptions, selection, listNav, handleClose] + ); + + const state: MultiSelectContextState = { + isOpen, + options, + visibleOptions, + searchQuery, + value, + selectedOptions, + highlightedValue: listNav.highlightedId ?? undefined, + disabled, + invalid, + readOnly, + required, + }; + + return useMemo( + (): MultiSelectContextType => ({ + ids, + setIds, + state, + iconAppearance, + item: { + register: registerItem, + unregister: unregisterItem, + toggleSelection: toggleSelectionValue, + highlightFirst, + highlightLast, + highlightItem, + moveHighlightedIndex, + }, + trigger: { ref: triggerRef, register: registerTrigger, unregister: unregisterTrigger, toggleOpen }, + search: { showSearch, searchQuery, setSearchQuery }, + }), + [ + ids, + state, + iconAppearance, + registerItem, + unregisterItem, + toggleSelectionValue, + highlightFirst, + highlightLast, + highlightItem, + moveHighlightedIndex, + registerTrigger, + unregisterTrigger, + toggleOpen, + showSearch, + searchQuery, + ] + ); +} + +export function MultiSelectRoot(props: MultiSelectRootProps) { + const value = useMultiSelect(props); + return ( + + {props.children} + + ); +} diff --git a/src/components/user-interaction/MultiSelect/MultiSelectOption.tsx b/src/components/user-interaction/MultiSelect/MultiSelectOption.tsx new file mode 100644 index 0000000..a0e6bf3 --- /dev/null +++ b/src/components/user-interaction/MultiSelect/MultiSelectOption.tsx @@ -0,0 +1,111 @@ +import clsx from "clsx"; +import { CheckIcon } from "lucide-react"; +import type { HTMLAttributes, ReactNode, RefObject } from "react"; +import { createContext, forwardRef, useContext, useEffect, useRef } from "react"; +import type { MultiSelectIconAppearance } from "./MultiSelectContext"; +import { useMultiSelectContext } from "./MultiSelectContext"; + +export type MultiSelectOptionDisplayLocation = "trigger" | "list"; + +export const MultiSelectOptionDisplayContext = + createContext(null); + +export function useMultiSelectOptionDisplayLocation(): MultiSelectOptionDisplayLocation { + const context = useContext(MultiSelectOptionDisplayContext); + if (!context) { + throw new Error( + "useMultiSelectOptionDisplayLocation must be used within a MultiSelectOptionDisplayContext" + ); + } + return context; +} + +export interface MultiSelectOptionProps extends Omit, "children"> { + value: string; + label: string; + disabled?: boolean; + iconAppearance?: MultiSelectIconAppearance; + children?: ReactNode; +} + +export const MultiSelectOption = forwardRef(function MultiSelectOption( + { children, label, value, disabled = false, iconAppearance, className, ...restProps }, + ref +) { + const { state, item, trigger, iconAppearance: ctxIconAppearance } = useMultiSelectContext(); + const { register, toggleSelection, highlightItem } = item; + const itemRef = useRef(null); + + const display: ReactNode = children ?? label; + const iconAppearanceResolved = iconAppearance ?? ctxIconAppearance; + + useEffect(() => { + return register({ + value, + label, + display, + disabled, + ref: itemRef as React.RefObject, + }); + }, [value, label, disabled, register, display]); + + const isHighlighted = state.highlightedValue === value; + const isSelected = state.value.includes(value); + const isVisible = state.visibleOptions.some((opt) => opt.value === value); + + return ( +
  • { + itemRef.current = node; + if (typeof ref === "function") ref(node); + else if (ref) (ref as RefObject).current = node; + }} + id={value} + role="option" + aria-disabled={disabled} + aria-selected={isSelected} + aria-hidden={!isVisible} + data-highlighted={isHighlighted ? "" : undefined} + data-selected={isSelected ? "" : undefined} + data-disabled={disabled ? "" : undefined} + data-visible={isVisible ? "" : undefined} + className={clsx( + "flex-row-1 items-center px-2 py-1 rounded-md", + "data-highlighted:bg-primary/20", + "data-disabled:text-disabled data-disabled:cursor-not-allowed", + "not-data-disabled:cursor-pointer", + !isVisible && "hidden", + className + )} + onClick={(event) => { + if (!disabled) { + toggleSelection(value); + restProps.onClick?.(event); + } + }} + onMouseEnter={(event) => { + if (!disabled) { + highlightItem(value); + restProps.onMouseEnter?.(event); + } + }} + > + {iconAppearanceResolved === "left" && ( + + )} + + {display} + + {iconAppearanceResolved === "right" && ( + + )} +
  • + ); +}); diff --git a/src/components/user-interaction/Select/Select.tsx b/src/components/user-interaction/Select/Select.tsx new file mode 100644 index 0000000..5df5329 --- /dev/null +++ b/src/components/user-interaction/Select/Select.tsx @@ -0,0 +1,35 @@ +import type { ReactNode } from "react"; +import { forwardRef } from "react"; +import type { SelectRootProps } from "./SelectContext"; +import { SelectRoot } from "./SelectContext"; +import type { SelectButtonProps } from "./SelectButton"; +import { SelectButton } from "./SelectButton"; +import type { SelectContentProps } from "./SelectContent"; +import { SelectContent } from "./SelectContent"; + +export type SelectProps = SelectRootProps & { + contentPanelProps?: SelectContentProps; + buttonProps?: Omit & { + selectedDisplay?: (value: string) => ReactNode; + } & { [key: string]: unknown }; +}; + +export const Select = forwardRef(function Select( + { children, contentPanelProps, buttonProps, ...props }, + ref +) { + return ( + + { + const value = values[0]; + if (!buttonProps?.selectedDisplay) return undefined; + return buttonProps.selectedDisplay(value); + }} + /> + {children} + + ); +}); diff --git a/src/components/user-interaction/Select/SelectButton.tsx b/src/components/user-interaction/Select/SelectButton.tsx new file mode 100644 index 0000000..cc93f18 --- /dev/null +++ b/src/components/user-interaction/Select/SelectButton.tsx @@ -0,0 +1,100 @@ +import type { ComponentPropsWithoutRef, ReactNode } from "react"; +import { forwardRef, useEffect, useImperativeHandle, useRef } from "react"; +import { useSelectContext } from "./SelectContext"; +import clsx from "clsx"; +import { useHightideTranslation } from "@/src/i18n/useHightideTranslation"; +import { ExpansionIcon } from "@/src/components/display-and-visualization/ExpansionIcon"; +import { SelectOptionDisplayContext } from "./SelectOption"; + +export interface SelectButtonProps extends ComponentPropsWithoutRef<"div"> { + placeholder?: ReactNode; + disabled?: boolean; + selectedDisplay?: (value: string[]) => ReactNode; + hideExpansionIcon?: boolean; +} + +export const SelectButton = forwardRef(function SelectButton( + { id, placeholder, disabled: disabledOverride, selectedDisplay, hideExpansionIcon = false, ...props }, + ref +) { + const translation = useHightideTranslation(); + const { state, trigger, setIds, ids } = useSelectContext(); + const { register, unregister, toggleOpen } = trigger; + + useEffect(() => { + if (id) setIds((prev) => ({ ...prev, trigger: id })); + }, [id, setIds]); + + const innerRef = useRef(null); + useImperativeHandle(ref, () => innerRef.current!); + + useEffect(() => { + register(innerRef); + return () => unregister(); + }, [register, unregister]); + + const disabled = !!disabledOverride || !!state.disabled; + const invalid = state.invalid; + const hasValue = state.value.length > 0; + + return ( +
    { + props.onClick?.(event); + toggleOpen(!state.isOpen); + }} + onKeyDown={(event) => { + props.onKeyDown?.(event); + if (disabled) return; + switch (event.key) { + case "Enter": + case " ": + toggleOpen(!state.isOpen); + event.preventDefault(); + event.stopPropagation(); + break; + case "ArrowDown": + toggleOpen(true, { highlightStartPositionBehavior: "first" }); + event.preventDefault(); + event.stopPropagation(); + break; + case "ArrowUp": + toggleOpen(true, { highlightStartPositionBehavior: "last" }); + event.preventDefault(); + event.stopPropagation(); + break; + } + }} + data-name={props["data-name"] ?? "select-button"} + data-value={hasValue ? "" : undefined} + data-disabled={disabled ? "" : undefined} + data-invalid={invalid ? "" : undefined} + tabIndex={disabled ? -1 : 0} + role="button" + aria-invalid={invalid} + aria-disabled={disabled} + aria-haspopup="dialog" + aria-expanded={state.isOpen} + aria-controls={state.isOpen ? ids.content : undefined} + > + + {hasValue + ? selectedDisplay?.(state.value) ?? ( +
    + {state.selectedOptions.map(({ value, display }, index) => ( + + {display} + {index < state.value.length - 1 && ,} + + ))} +
    + ) + : placeholder ?? translation("clickToSelect")} +
    + {!hideExpansionIcon && } +
    + ); +}); diff --git a/src/components/user-interaction/Select/SelectContent.tsx b/src/components/user-interaction/Select/SelectContent.tsx new file mode 100644 index 0000000..e7b76bb --- /dev/null +++ b/src/components/user-interaction/Select/SelectContent.tsx @@ -0,0 +1,175 @@ +import type { ComponentProps } from "react"; +import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef } from "react"; +import { useSelectContext } from "./SelectContext"; +import clsx from "clsx"; +import { useHightideTranslation } from "@/src/i18n/useHightideTranslation"; +import { PopUp, type PopUpProps } from "@/src/components/layout/popup/PopUp"; +import { Input } from "@/src/components/user-interaction/input/Input"; +import { Visibility } from "@/src/components/layout/Visibility"; + +const TYPEAHEAD_RESET_MS = 500; + +export interface SelectContentProps extends PopUpProps { + showSearch?: boolean; + searchInputProps?: Omit, "value" | "onValueChange">; +} + +export const SelectContent = forwardRef(function SelectContent( + { id, options, showSearch: showSearchOverride, searchInputProps, ...props }, + ref +) { + const translation = useHightideTranslation(); + const innerRef = useRef(null); + const searchInputRef = useRef(null); + const typeAheadBufferRef = useRef(""); + const typeAheadTimeoutRef = useRef | null>(null); + useImperativeHandle(ref, () => innerRef.current!); + + const { trigger, state, item, ids, setIds, search } = useSelectContext(); + + useEffect(() => { + if (id) setIds((prev) => ({ ...prev, content: id })); + }, [id, setIds]); + + useEffect(() => { + if (!state.isOpen) { + typeAheadBufferRef.current = ""; + if (typeAheadTimeoutRef.current) { + clearTimeout(typeAheadTimeoutRef.current); + typeAheadTimeoutRef.current = null; + } + } + }, [state.isOpen]); + + useEffect( + () => () => { + if (typeAheadTimeoutRef.current) clearTimeout(typeAheadTimeoutRef.current); + }, + [] + ); + + const showSearch = showSearchOverride ?? search.showSearch; + const listboxAriaLabel = showSearch ? translation("searchResults") : undefined; + + const keyHandler = useCallback( + (event: React.KeyboardEvent) => { + switch (event.key) { + case "ArrowDown": + item.moveHighlightedIndex(1); + event.preventDefault(); + break; + case "ArrowUp": + item.moveHighlightedIndex(-1); + event.preventDefault(); + break; + case "Home": + event.preventDefault(); + item.highlightFirst(); + break; + case "End": + event.preventDefault(); + item.highlightLast(); + break; + case "Enter": + case " ": + if (showSearch && event.key === " ") return; + if (state.highlightedValue) { + item.toggleSelection(state.highlightedValue); + trigger.toggleOpen(false); + event.preventDefault(); + } + break; + default: + if (!showSearch && !event.ctrlKey && !event.metaKey && !event.altKey) { + const char = event.key.toLowerCase(); + if (typeAheadTimeoutRef.current) clearTimeout(typeAheadTimeoutRef.current); + typeAheadBufferRef.current += char; + typeAheadTimeoutRef.current = setTimeout(() => { + typeAheadBufferRef.current = ""; + }, TYPEAHEAD_RESET_MS); + const opts = state.visibleOptions; + const buf = typeAheadBufferRef.current; + if (opts.length === 0) { + event.preventDefault(); + return; + } + const currentIndex = opts.findIndex((o) => o.value === state.highlightedValue); + const startFrom = currentIndex >= 0 ? (currentIndex + 1) % opts.length : 0; + for (let i = 0; i < opts.length; i++) { + const j = (startFrom + i) % opts.length; + if (!opts[j].disabled && opts[j].label.toLowerCase().startsWith(buf)) { + item.highlightItem(opts[j].value); + event.preventDefault(); + return; + } + } + event.preventDefault(); + } + break; + } + }, + [showSearch, state.visibleOptions, state.highlightedValue, item, trigger] + ); + + return ( + { + trigger.toggleOpen(false); + props.onClose?.(); + }} + aria-labelledby={ids.trigger} + className="gap-y-1" + > + {showSearch && ( + + )} +
      + {props.children} + +
    • 0 })} + > + {translation("nResultsFound", { count: state.visibleOptions.length })} +
    • +
      +
    +
    + ); +}); diff --git a/src/components/user-interaction/Select/SelectContext.tsx b/src/components/user-interaction/Select/SelectContext.tsx new file mode 100644 index 0000000..588491e --- /dev/null +++ b/src/components/user-interaction/Select/SelectContext.tsx @@ -0,0 +1,336 @@ +import type { Dispatch, ReactNode, SetStateAction } from "react"; +import { + createContext, + useCallback, + useContext, + useEffect, + useId, + useMemo, + useRef, + useState, +} from "react"; +import type { FormFieldInteractionStates } from "@/src/components/form/FieldLayout"; +import type { FormFieldDataHandling } from "@/src/components/form/FormField"; +import { useSingleSelection } from "@/src/hooks/useSingleSelection"; +import { useListNavigation } from "@/src/hooks/useListNavigation"; +import { MultiSearchWithMapping } from "@/src/utils/simpleSearch"; + +export type SelectIconAppearance = "left" | "right" | "none"; + +type RegisteredOption = { + value: string; + label: string; + display: ReactNode; + disabled: boolean; + ref: React.RefObject; +}; + +type SelectContextIds = { + trigger: string; + content: string; + listbox: string; + searchInput: string; +}; + +type SelectContextState = FormFieldInteractionStates & { + isOpen: boolean; + options: RegisteredOption[]; + visibleOptions: RegisteredOption[]; + searchQuery: string; + value: string[]; + selectedOptions: RegisteredOption[]; + highlightedValue: string | undefined; +}; + +export type SelectContextType = { + ids: SelectContextIds; + setIds: Dispatch>; + state: SelectContextState; + iconAppearance: SelectIconAppearance; + item: { + register: (item: RegisteredOption) => () => void; + unregister: (value: string) => void; + toggleSelection: (value: string) => void; + highlightFirst: () => void; + highlightLast: () => void; + highlightItem: (value: string) => void; + moveHighlightedIndex: (delta: number) => void; + }; + trigger: { + ref: React.RefObject; + register: (element: React.RefObject) => void; + unregister: () => void; + toggleOpen: (isOpen?: boolean, options?: { highlightStartPositionBehavior?: "first" | "last" }) => void; + }; + search: { + showSearch: boolean; + searchQuery: string; + setSearchQuery: (query: string) => void; + }; +}; + +const SelectContext = createContext(null); + +export function useSelectContext(): SelectContextType { + const ctx = useContext(SelectContext); + if (!ctx) throw new Error("useSelectContext must be used within SelectRoot"); + return ctx; +} + +export interface SharedSelectRootProps extends Partial { + children: ReactNode; + id?: string; + initialIsOpen?: boolean; + iconAppearance?: SelectIconAppearance; + showSearch?: boolean; + onClose?: () => void; +} + +export type SelectRootProps = SharedSelectRootProps & + Partial> & { + initialValue?: string; + }; + +export function useSingleSelect(props: SelectRootProps): SelectContextType { + const { + id, + value: controlledValue, + onValueChange, + initialValue, + onClose, + initialIsOpen = false, + disabled = false, + readOnly = false, + required = false, + invalid = false, + showSearch = false, + iconAppearance = "left", + } = props; + + const triggerRef = useRef(null); + const generatedId = useId(); + const [ids, setIds] = useState({ + trigger: id ?? "select-" + generatedId, + content: "select-content-" + generatedId, + listbox: "select-listbox-" + generatedId, + searchInput: "select-search-" + generatedId, + }); + const [isOpen, setIsOpen] = useState(initialIsOpen); + const [options, setOptions] = useState([]); + const [searchQuery, setSearchQuery] = useState(""); + + const selection = useSingleSelection({ + value: controlledValue !== undefined ? controlledValue : null, + onSelectionChange: (v) => { + onValueChange?.(v); + props.onEditComplete?.(v); + }, + initialSelection: initialValue ?? null, + isControlled: controlledValue !== undefined, + }); + + const visibleOptions = useMemo(() => { + const q = searchQuery.trim().toLowerCase(); + if (!q) return options; + return MultiSearchWithMapping(searchQuery, options, (o) => [o.label]); + }, [options, searchQuery]); + + const listNav = useListNavigation({ + options: visibleOptions.map((o) => o.value), + initialValue: controlledValue ?? initialValue ?? undefined, + }); + + const selectedOptions = useMemo( + () => + selection.selection != null + ? [options.find((o) => o.value === selection.selection)].filter( + (o): o is RegisteredOption => o != null + ) + : [], + [selection.selection, options] + ); + const value = useMemo( + () => (selection.selection != null ? [selection.selection] : []), + [selection.selection] + ); + + useEffect(() => { + if ( + listNav.highlightedId != null && + !visibleOptions.some((o) => o.value === listNav.highlightedId) + ) { + const first = visibleOptions.find((o) => !o.disabled); + if (first) listNav.highlight(first.value); + } + }, [visibleOptions, listNav.highlightedId, listNav.highlight]); + + useEffect(() => { + const opt = options.find((o) => o.value === listNav.highlightedId); + opt?.ref.current?.scrollIntoView({ behavior: "instant", block: "nearest" }); + }, [listNav.highlightedId, options]); + + const registerItem = useCallback( + (item: RegisteredOption) => { + setOptions((prev) => { + const next = prev.filter((o) => o.value !== item.value); + next.push(item); + next.sort((a, b) => { + const aEl = a.ref.current; + const bEl = b.ref.current; + if (!aEl || !bEl) return 0; + return aEl.compareDocumentPosition(bEl) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1; + }); + return next; + }); + const unregSelection = selection.registerOption({ + value: item.value, + label: item.label, + display: item.display, + disabled: item.disabled, + }); + return () => { + unregSelection(); + setOptions((prev) => prev.filter((o) => o.value !== item.value)); + }; + }, + [selection, listNav] + ); + + const unregisterItem = useCallback((value: string) => { + setOptions((prev) => prev.filter((o) => o.value !== value)); + }, []); + + const toggleSelection = useCallback( + (value: string) => { + if (disabled) return; + selection.changeSelection(value); + setIsOpen((prev) => (prev ? false : prev)); + }, + [disabled, selection] + ); + + const highlightItem = useCallback( + (value: string) => { + if (disabled || !visibleOptions.some((o) => o.value === value && !o.disabled)) return; + listNav.highlight(value); + }, + [disabled, visibleOptions, listNav] + ); + + const highlightFirst = useCallback(() => { + const first = visibleOptions.find((o) => !o.disabled); + if (first) listNav.highlight(first.value); + }, [visibleOptions, listNav]); + + const highlightLast = useCallback(() => { + const last = [...visibleOptions].reverse().find((o) => !o.disabled); + if (last) listNav.highlight(last.value); + }, [visibleOptions, listNav]); + + const moveHighlightedIndex = useCallback( + (delta: number) => { + const list = visibleOptions.filter((o) => !o.disabled); + if (list.length === 0) return; + const idx = list.findIndex((o) => o.value === listNav.highlightedId); + const startIdx = idx < 0 ? 0 : (idx + (delta % list.length) + list.length) % list.length; + const isForward = delta >= 0; + let nextIdx = startIdx; + for (let i = 0; i < list.length; i++) { + const j = (startIdx + (isForward ? i : -i) + list.length) % list.length; + if (!list[j].disabled) { + nextIdx = j; + break; + } + } + listNav.highlight(list[nextIdx].value); + }, + [visibleOptions, listNav] + ); + + const registerTrigger = useCallback((ref: React.RefObject) => { + (triggerRef as React.MutableRefObject).current = ref.current; + }, []); + + const unregisterTrigger = useCallback(() => { + (triggerRef as React.MutableRefObject).current = null; + }, []); + + const toggleOpen = useCallback( + (open?: boolean, opts?: { highlightStartPositionBehavior?: "first" | "last" }) => { + const next = open ?? !isOpen; + if (next) { + const behavior = opts?.highlightStartPositionBehavior ?? "first"; + const list = visibleOptions.filter((o) => !o.disabled); + const firstSelected = list.find((o) => o.value === selection.selection); + const fallback = behavior === "first" ? list[0] : list[list.length - 1]; + const toHighlight = firstSelected ?? fallback; + if (toHighlight) listNav.highlight(toHighlight.value); + } else { + setSearchQuery(""); + onClose?.(); + } + setIsOpen(next); + }, + [isOpen, visibleOptions, selection.selection, listNav, onClose] + ); + + const state: SelectContextState = { + isOpen, + options, + visibleOptions, + searchQuery, + value, + selectedOptions, + highlightedValue: listNav.highlightedId ?? undefined, + disabled, + invalid, + readOnly, + required, + }; + + return useMemo( + (): SelectContextType => ({ + ids, + setIds, + state, + iconAppearance, + item: { + register: registerItem, + unregister: unregisterItem, + toggleSelection, + highlightFirst, + highlightLast, + highlightItem, + moveHighlightedIndex, + }, + trigger: { ref: triggerRef, register: registerTrigger, unregister: unregisterTrigger, toggleOpen }, + search: { showSearch, searchQuery, setSearchQuery }, + }), + [ + ids, + state, + iconAppearance, + registerItem, + unregisterItem, + toggleSelection, + highlightFirst, + highlightLast, + highlightItem, + moveHighlightedIndex, + registerTrigger, + unregisterTrigger, + toggleOpen, + showSearch, + searchQuery, + ] + ); +} + +export function SelectRoot(props: SelectRootProps) { + const value = useSingleSelect(props); + return ( + + {props.children} + + ); +} diff --git a/src/components/user-interaction/Select/SelectOption.tsx b/src/components/user-interaction/Select/SelectOption.tsx new file mode 100644 index 0000000..f9335d4 --- /dev/null +++ b/src/components/user-interaction/Select/SelectOption.tsx @@ -0,0 +1,107 @@ +import clsx from "clsx"; +import { CheckIcon } from "lucide-react"; +import type { HTMLAttributes, ReactNode, RefObject } from "react"; +import { createContext, forwardRef, useContext, useEffect, useRef } from "react"; +import type { SelectIconAppearance } from "./SelectContext"; +import { useSelectContext } from "./SelectContext"; + +export type SelectOptionDisplayLocation = "trigger" | "list"; + +export const SelectOptionDisplayContext = createContext(null); + +export function useSelectOptionDisplayLocation(): SelectOptionDisplayLocation { + const context = useContext(SelectOptionDisplayContext); + if (!context) { + throw new Error("useSelectOptionDisplayLocation must be used within a SelectOptionDisplayContext"); + } + return context; +} + +export interface SelectOptionProps extends Omit, "children"> { + value: string; + label: string; + disabled?: boolean; + iconAppearance?: SelectIconAppearance; + children?: ReactNode; +} + +export const SelectOption = forwardRef(function SelectOption( + { children, label, value, disabled = false, iconAppearance, className, ...restProps }, + ref +) { + const { state, item, trigger, iconAppearance: ctxIconAppearance } = useSelectContext(); + const { register, unregister, toggleSelection, highlightItem } = item; + const itemRef = useRef(null); + + const display: ReactNode = children ?? label; + const iconAppearanceResolved = iconAppearance ?? ctxIconAppearance; + + useEffect(() => { + return register({ + value, + label, + display, + disabled, + ref: itemRef as React.RefObject, + }); + }, [value, label, disabled, register, display]); + + const isHighlighted = state.highlightedValue === value; + const isSelected = state.value.includes(value); + const isVisible = state.visibleOptions.some((opt) => opt.value === value); + + return ( +
  • { + itemRef.current = node; + if (typeof ref === "function") ref(node); + else if (ref) (ref as RefObject).current = node; + }} + id={value} + role="option" + aria-disabled={disabled} + aria-selected={isSelected} + aria-hidden={!isVisible} + data-highlighted={isHighlighted ? "" : undefined} + data-selected={isSelected ? "" : undefined} + data-disabled={disabled ? "" : undefined} + data-visible={isVisible ? "" : undefined} + className={clsx( + "flex-row-1 items-center px-2 py-1 rounded-md", + "data-highlighted:bg-primary/20", + "data-disabled:text-disabled data-disabled:cursor-not-allowed", + "not-data-disabled:cursor-pointer", + !isVisible && "hidden", + className + )} + onClick={(event) => { + if (!disabled) { + toggleSelection(value); + trigger.toggleOpen(false); + restProps.onClick?.(event); + } + }} + onMouseEnter={(event) => { + if (!disabled) { + highlightItem(value); + restProps.onMouseEnter?.(event); + } + }} + > + {iconAppearanceResolved === "left" && state.value.length > 0 && ( + + )} + {display} + {iconAppearanceResolved === "right" && state.value.length > 0 && ( + + )} +
  • + ); +}); diff --git a/src/components/user-interaction/data/FilterList.tsx b/src/components/user-interaction/data/FilterList.tsx index 5d29970..f30dd14 100644 --- a/src/components/user-interaction/data/FilterList.tsx +++ b/src/components/user-interaction/data/FilterList.tsx @@ -9,7 +9,8 @@ import { PopUp } from '../../layout/popup/PopUp' import { PopUpOpener } from '../../layout/popup/PopUpOpener' import { Button } from '../Button' import { FilterPopUp } from './FilterPopUp' -import { Combobox, ComboboxOption } from '../Combobox' +import { Combobox } from '@/src/components/user-interaction/Combobox/Combobox' +import { ComboboxOption } from '@/src/components/user-interaction/Combobox/ComboboxOption' import { PopUpContext } from '../../layout/popup/PopUpContext' import { ExpansionIcon } from '../../display-and-visualization/ExpansionIcon' import { FilterOperatorUtils } from './FilterOperator' @@ -57,19 +58,21 @@ export const FilterList = ({ value, onValueChange, availableItems }: FilterListP {({ setIsOpen }) => ( - { - const item = itemRecord[id] - if(!item) return - const newValue: IdentifierFilterValue = { - id: item.id, - dataType: item.dataType, - operator: FilterOperatorUtils.getDefaultOperator(item.dataType), - parameter: {} - } - onValueChange([...value, newValue]) - setEditState(newValue) - setIsOpen(false) - }}> + { + const item = itemRecord[id] + if(!item) return + const newValue: IdentifierFilterValue = { + id: item.id, + dataType: item.dataType, + operator: FilterOperatorUtils.getDefaultOperator(item.dataType), + parameter: {} + } + onValueChange([...value, newValue]) + setEditState(newValue) + setIsOpen(false) + }} + > {inactiveItems.map(item => ( {DataTypeUtils.toIcon(item.dataType)} diff --git a/src/components/user-interaction/data/FilterPopUp.tsx b/src/components/user-interaction/data/FilterPopUp.tsx index 44b5918..031692e 100644 --- a/src/components/user-interaction/data/FilterPopUp.tsx +++ b/src/components/user-interaction/data/FilterPopUp.tsx @@ -8,13 +8,13 @@ import type { FilterOperator } from './FilterOperator' import { FilterOperatorUtils } from './FilterOperator' import type { ReactNode } from 'react' import { forwardRef, useId, useMemo, useState } from 'react' -import { Select } from '../select/Select' -import { SelectOption } from '../select/SelectOption' +import { Select } from '../Select/Select' +import { SelectOption } from '../Select/SelectOption' import { Input } from '../input/Input' import { Checkbox } from '../Checkbox' import { DateTimeInput } from '../input/DateTimeInput' -import { MultiSelect } from '../select/MultiSelect' -import { MultiSelectOption } from '../select/SelectOption' +import { MultiSelect } from '../MultiSelect/MultiSelect' +import { MultiSelectOption } from '../MultiSelect/MultiSelectOption' import type { DataType } from './data-types' import clsx from 'clsx' import { FilterOperatorLabel } from './FilterOperatorLabel' diff --git a/src/components/user-interaction/properties/MultiSelectProperty.tsx b/src/components/user-interaction/properties/MultiSelectProperty.tsx index 3cac36e..db206ac 100644 --- a/src/components/user-interaction/properties/MultiSelectProperty.tsx +++ b/src/components/user-interaction/properties/MultiSelectProperty.tsx @@ -2,7 +2,7 @@ import { List } from 'lucide-react' import { PropertyBase, type PropertyField } from '@/src/components/user-interaction/properties/PropertyBase' import type { PropsWithChildren } from 'react' import { PropsUtil } from '@/src/utils/propsUtil' -import { MultiSelectChipDisplay } from '@/src/components/user-interaction/select/MultiSelectChipDisplay' +import { MultiSelectChipDisplay } from '@/src/components/user-interaction/MultiSelect/MultiSelectChipDisplay' export type MultiSelectPropertyProps = PropertyField & PropsWithChildren /** diff --git a/src/components/user-interaction/properties/SelectProperty.tsx b/src/components/user-interaction/properties/SelectProperty.tsx index 6c7933e..ed0cf51 100644 --- a/src/components/user-interaction/properties/SelectProperty.tsx +++ b/src/components/user-interaction/properties/SelectProperty.tsx @@ -3,9 +3,9 @@ import type { PropsWithChildren } from 'react' import type { PropertyField } from '@/src/components/user-interaction/properties/PropertyBase' import { PropertyBase } from '@/src/components/user-interaction/properties/PropertyBase' import { PropsUtil } from '@/src/utils/propsUtil' -import { SelectRoot } from '@/src/components/user-interaction/select/SelectContext' -import { SelectButton } from '@/src/components/user-interaction/select/SelectButton' -import { SelectContent } from '@/src/components/user-interaction/select/SelectContent' +import { SelectRoot } from '@/src/components/user-interaction/Select/SelectContext' +import { SelectButton } from '@/src/components/user-interaction/Select/SelectButton' +import { SelectContent } from '@/src/components/user-interaction/Select/SelectContent' export interface SingleSelectPropertyProps extends PropertyField, PropsWithChildren {} diff --git a/src/components/user-interaction/select/MultiSelect.tsx b/src/components/user-interaction/select/MultiSelect.tsx deleted file mode 100644 index acf85a7..0000000 --- a/src/components/user-interaction/select/MultiSelect.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import type { MultiSelectRootProps } from './SelectContext' -import { MultiSelectRoot } from './SelectContext' -import type { MultiSelectButtonProps } from './SelectButton' -import { MultiSelectButton } from './SelectButton' -import { type MultiSelectContentProps, MultiSelectContent } from './SelectContent' -import { forwardRef } from 'react' - -// -// MultiSelect -// -export interface MultiSelectProps extends MultiSelectRootProps { - contentPanelProps?: MultiSelectContentProps, - buttonProps?: MultiSelectButtonProps, -} - -export const MultiSelect = forwardRef(function MultiSelect({ - children, - contentPanelProps, - buttonProps, - ...props -}, ref) { - return ( - - - {children} - - ) -}) diff --git a/src/components/user-interaction/select/MultiSelectChipDisplay.tsx b/src/components/user-interaction/select/MultiSelectChipDisplay.tsx deleted file mode 100644 index 7c1e7f2..0000000 --- a/src/components/user-interaction/select/MultiSelectChipDisplay.tsx +++ /dev/null @@ -1,134 +0,0 @@ -import type { MultiSelectRootProps } from './SelectContext' -import { MultiSelectRoot, useSelectContext } from './SelectContext' -import type { HTMLAttributes, ReactNode } from 'react' -import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react' -import { XIcon, Plus } from 'lucide-react' -import { MultiSelectContent, type MultiSelectContentProps } from './SelectContent' -import { IconButton } from '../IconButton' -import { useHightideTranslation } from '@/src/i18n/useHightideTranslation' - -/// -/// MultiSelectChipDisplay -/// -type MultiSelectChipDisplayButtonProps = HTMLAttributes & { - disabled?: boolean, - placeholder?: ReactNode, - } - -export const MultiSelectChipDisplayButton = forwardRef(function MultiSelectChipDisplayButton({ - id, - ...props -}, ref) { - const translation = useHightideTranslation() - const { state, trigger, item, ids, setIds } = useSelectContext() - const { register, unregister, toggleOpen } = trigger - - useEffect(() => { - if(id) { - setIds(prev => ({ - ...prev, - trigger: id, - })) - } - }, [id, setIds]) - const innerRef = useRef(null) - useImperativeHandle(ref, () => innerRef.current) - - useEffect(() => { - register(innerRef) - return () => unregister() - }, [register, unregister]) - - const disabled = !!props?.disabled || !!state.disabled - const invalid = state.invalid - - return ( -
    { - toggleOpen() - props.onClick?.(event) - }} - - data-name={props['data-name'] ?? 'select-chip-display'} - data-value={state.value.length > 0 ? '' : undefined} - data-disabled={disabled ? '' : undefined} - data-invalid={invalid ? '' : undefined} - - aria-invalid={invalid} - aria-disabled={disabled} - > - {state.selectedOptions.map(({ value, display }) => ( -
    - {display} - { - item.toggleSelection(value, false) - }} - size="sm" - color="negative" - coloringStyle="text" - className="flex-row-0 items-center size-7 p-1" - > - - -
    - ))} - { - event.stopPropagation() - toggleOpen() - }} - onKeyDown={event => { - switch (event.key) { - case 'ArrowDown': - toggleOpen(true, { highlightStartPositionBehavior: 'first' }) - break - case 'ArrowUp': - toggleOpen(true, { highlightStartPositionBehavior: 'last' }) - } - }} - tooltip={translation('changeSelection')} - size="md" - color="neutral" - - aria-invalid={invalid} - aria-disabled={disabled} - aria-haspopup="dialog" - aria-expanded={state.isOpen} - aria-controls={state.isOpen ? ids.content : undefined} - - className="size-9" - > - - -
    - ) -}) - - -// -// MultiSelectChipDisplay -// -export type MultiSelectChipDisplayProps = MultiSelectRootProps & { - contentPanelProps?: MultiSelectContentProps, - chipDisplayProps?: MultiSelectChipDisplayButtonProps, - } - -export const MultiSelectChipDisplay = forwardRef(function MultiSelectChipDisplay({ - children, - contentPanelProps, - chipDisplayProps, - ...props -}, ref) { - return ( - - - {children} - - ) -}) diff --git a/src/components/user-interaction/select/Select.tsx b/src/components/user-interaction/select/Select.tsx deleted file mode 100644 index 61b0fab..0000000 --- a/src/components/user-interaction/select/Select.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import type { ReactNode } from 'react' -import { - forwardRef -} from 'react' -import type { SelectRootProps } from './SelectContext' -import { SelectRoot } from './SelectContext' -import type { SelectButtonProps } from './SelectButton' -import { SelectButton } from './SelectButton' -import type { SelectContentProps } from './SelectContent' -import { SelectContent } from './SelectContent' - -// -// Select -// -export type SelectProps = SelectRootProps & { - contentPanelProps?: SelectContentProps, - buttonProps?: Omit & { selectedDisplay?: (value: string) => ReactNode } & { [key: string]: unknown }, -} - -export const Select = forwardRef(function Select({ - children, - contentPanelProps, - buttonProps, - ...props -}, ref) { - return ( - - { - const value = values[0] - if (!buttonProps?.selectedDisplay) return undefined - return buttonProps.selectedDisplay(value) - }} - /> - {children} - - ) -}) diff --git a/src/components/user-interaction/select/SelectButton.tsx b/src/components/user-interaction/select/SelectButton.tsx deleted file mode 100644 index 19e592c..0000000 --- a/src/components/user-interaction/select/SelectButton.tsx +++ /dev/null @@ -1,121 +0,0 @@ -import type { ComponentPropsWithoutRef, ReactNode } from 'react' -import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react' -import { useSelectContext } from './SelectContext' -import clsx from 'clsx' -import { useHightideTranslation } from '@/src/i18n/useHightideTranslation' -import { ExpansionIcon } from '@/src/components/display-and-visualization/ExpansionIcon' -import { SelectOptionDisplayContext } from './SelectOption' - -export interface SelectButtonProps extends ComponentPropsWithoutRef<'div'> { - placeholder?: ReactNode, - disabled?: boolean, - selectedDisplay?: (value: string[]) => ReactNode, - hideExpansionIcon?: boolean, -} - -export const SelectButton = forwardRef(function SelectButton({ - id, - placeholder, - disabled: disabledOverride, - selectedDisplay, - hideExpansionIcon = false, - ...props -}, ref) { - const translation = useHightideTranslation() - const { state, trigger, setIds, ids } = useSelectContext() - const { register, unregister, toggleOpen } = trigger - - useEffect(() => { - if(id) { - setIds(prev => ({ - ...prev, - trigger: id, - })) - } - }, [id, setIds]) - const innerRef = useRef(null) - useImperativeHandle(ref, () => innerRef.current) - - useEffect(() => { - register(innerRef) - return () => unregister() - }, [register, unregister]) - - const disabled = !!disabledOverride || !!state.disabled - const invalid = state.invalid - const hasValue = state.value.length > 0 - - return ( -
    { - props.onClick?.(event) - toggleOpen(!state.isOpen) - }} - onKeyDown={event => { - props.onKeyDown?.(event) - if(disabled) return - - switch (event.key) { - case 'Enter': - case ' ': - if(disabled) return - toggleOpen(!state.isOpen) - event.preventDefault() - event.stopPropagation() - break - case 'ArrowDown': - toggleOpen(true, { highlightStartPositionBehavior: 'first' }) - event.preventDefault() - event.stopPropagation() - break - case 'ArrowUp': - toggleOpen(true, { highlightStartPositionBehavior: 'last' }) - event.preventDefault() - event.stopPropagation() - break - } - }} - - data-name={props['data-name'] ?? 'select-button'} - data-value={hasValue ? '' : undefined} - data-disabled={disabled ? '' : undefined} - data-invalid={invalid ? '' : undefined} - - tabIndex={disabled ? -1 : 0} - role="button" - aria-invalid={invalid} - aria-disabled={disabled} - aria-haspopup="dialog" - aria-expanded={state.isOpen} - aria-controls={state.isOpen ? ids.content : undefined} - > - - {hasValue ? - selectedDisplay?.(state.value) ?? ( -
    - {state.selectedOptions.map(({ value, display }, index) => ( - - {display} - {index < state.value.length - 1 && ({','})} - - ))} -
    - ) - : placeholder ?? translation('clickToSelect') - } -
    - {!hideExpansionIcon && } -
    - ) -}) - -/// -/// MultiSelectButton -/// -export type MultiSelectButtonProps = SelectButtonProps - -export const MultiSelectButton = SelectButton diff --git a/src/components/user-interaction/select/SelectContent.tsx b/src/components/user-interaction/select/SelectContent.tsx deleted file mode 100644 index cb52db3..0000000 --- a/src/components/user-interaction/select/SelectContent.tsx +++ /dev/null @@ -1,212 +0,0 @@ -import type { ComponentProps } from 'react' -import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef } from 'react' -import { useSelectContext } from './SelectContext' -import clsx from 'clsx' -import { useHightideTranslation } from '@/src/i18n/useHightideTranslation' -import { PopUp, type PopUpProps } from '@/src/components/layout/popup/PopUp' -import { Input } from '@/src/components/user-interaction/input/Input' -import { Visibility } from '../../layout/Visibility' - -const TYPEAHEAD_RESET_MS = 500 - -export interface SelectContentProps extends PopUpProps { - showSearch?: boolean, - searchInputProps?: Omit, 'value' | 'onValueChange'>, -} - -export const SelectContent = forwardRef(function SelectContent({ - id, - options, - showSearch: showSearchOverride, - searchInputProps, - ...props -}, ref) { - const translation = useHightideTranslation() - const innerRef = useRef(null) - const searchInputRef = useRef(null) - const typeAheadBufferRef = useRef('') - const typeAheadTimeoutRef = useRef | null>(null) - useImperativeHandle(ref, () => innerRef.current) - - const { trigger, state, config, item, ids, setIds, search } = useSelectContext() - - useEffect(() => { - if (id) { - setIds(prev => ({ - ...prev, - content: id, - })) - } - }, [id, setIds]) - - useEffect(() => { - if (!state.isOpen) { - typeAheadBufferRef.current = '' - if (typeAheadTimeoutRef.current) { - clearTimeout(typeAheadTimeoutRef.current) - typeAheadTimeoutRef.current = null - } - } - }, [state.isOpen]) - - useEffect(() => { - return () => { - if (typeAheadTimeoutRef.current) { - clearTimeout(typeAheadTimeoutRef.current) - } - } - }, []) - - const showSearch = showSearchOverride ?? search.showSearch - const listboxAriaLabel = showSearch ? translation('searchResults') : undefined - - const keyHandler = useCallback((event: React.KeyboardEvent) => { - - - switch (event.key) { - case 'ArrowDown': - item.moveHighlightedIndex(1) - event.preventDefault() - break - case 'ArrowUp': - item.moveHighlightedIndex(-1) - event.preventDefault() - break - case 'Home': - event.preventDefault() - item.highlightFirst() - break - case 'End': - event.preventDefault() - item.highlightLast() - break - case 'Enter': - case ' ': - if (showSearch && event.key === ' ') return - - if (state.highlightedValue) { - item.toggleSelection(state.highlightedValue) - if (!config.isMultiSelect) { - trigger.toggleOpen(false) - } - event.preventDefault() - } - break - default: - if ( - !showSearch && - !event.ctrlKey && - !event.metaKey && - !event.altKey - ) { - const char = event.key.toLowerCase() - if (typeAheadTimeoutRef.current) { - clearTimeout(typeAheadTimeoutRef.current) - } - typeAheadBufferRef.current += char - typeAheadTimeoutRef.current = setTimeout(() => { - typeAheadBufferRef.current = '' - }, TYPEAHEAD_RESET_MS) - - const opts = state.visibleOptions - const buf = typeAheadBufferRef.current - if (opts.length === 0) { - event.preventDefault() - return - } - const currentIndex = opts.findIndex(o => o.value === state.highlightedValue) - const startFrom = currentIndex >= 0 ? (currentIndex + 1) % opts.length : 0 - for (let i = 0; i < opts.length; i++) { - const j = (startFrom + i) % opts.length - if (!opts[j].disabled && opts[j].label.toLowerCase().startsWith(buf)) { - item.highlightItem(opts[j].value) - event.preventDefault() - return - } - } - event.preventDefault() - return - } - } - }, [ - showSearch, - state.visibleOptions, - state.highlightedValue, - item, - config.isMultiSelect, - trigger, - ]) - - return ( - { - trigger.toggleOpen(false) - props.onClose?.() - }} - aria-labelledby={ids.trigger} - className="gap-y-1" - > - {showSearch && ( - - )} -
      - {props.children} - -
    • 0 })} - > - {translation('nResultsFound', { count: state.visibleOptions.length })} -
    • -
      -
    -
    - ) -}) - - -/// -/// MultiSelectContent -/// -export type MultiSelectContentProps = SelectContentProps - -export const MultiSelectContent = SelectContent diff --git a/src/components/user-interaction/select/SelectContext.tsx b/src/components/user-interaction/select/SelectContext.tsx deleted file mode 100644 index 7cd459d..0000000 --- a/src/components/user-interaction/select/SelectContext.tsx +++ /dev/null @@ -1,429 +0,0 @@ -import type { Dispatch, ReactNode, SetStateAction } from 'react' -import { createContext, useCallback, useContext, useEffect, useId, useMemo, useRef, useState } from 'react' -import type { FormFieldInteractionStates } from '../../form/FieldLayout' -import type { FormFieldDataHandling } from '../../form/FormField' -import { useControlledState } from '@/src/hooks/useControlledState' -import { MultiSearchWithMapping } from '@/src/utils/simpleSearch' - -// -// Context -// -type RegisteredOption = { - value: string, - label: string, - display: ReactNode, - disabled: boolean, - ref: React.RefObject, -} - -export type HighlightStartPositionBehavior = 'first' | 'last' -export type SelectIconAppearance = 'left' | 'right' | 'none' - -type InternalSelectContextState = { - isOpen: boolean, - options: RegisteredOption[], - highlightedValue?: string, - searchQuery: string, -} - -type SelectContextIds = { - trigger: string, - content: string, - listbox: string, - searchInput: string, -} - -type SelectContextState = InternalSelectContextState & FormFieldInteractionStates & { - value: string[], - selectedOptions: RegisteredOption[], - visibleOptions: RegisteredOption[], -} - -type SelectConfiguration = { - isMultiSelect: boolean, - iconAppearance: SelectIconAppearance, -} - -type ToggleOpenOptions = { - highlightStartPositionBehavior?: HighlightStartPositionBehavior, -} - -const defaultToggleOpenOptions: ToggleOpenOptions = { - highlightStartPositionBehavior: 'first', -} - -type SelectContextType = { - ids: SelectContextIds, - setIds: Dispatch>, - state: SelectContextState, - config: SelectConfiguration, - item: { - register: (item: RegisteredOption) => void, - unregister: (value: string) => void, - toggleSelection: (value: string, isSelected?: boolean) => void, - highlightFirst: () => void, - highlightLast: () => void, - highlightItem: (value: string) => void, - moveHighlightedIndex: (delta: number) => void, - }, - trigger: { - ref: React.RefObject, - register: (element: React.RefObject) => void, - unregister: () => void, - toggleOpen: (isOpen?: boolean, options?: ToggleOpenOptions) => void, - }, - search: { - showSearch: boolean, - searchQuery: string, - setSearchQuery: (query: string) => void, - }, -} - -export const SelectContext = createContext(null) - -export function useSelectContext() { - const ctx = useContext(SelectContext) - if (!ctx) { - throw new Error('useSelectContext must be used within a SelectRoot or MultiSelectRoot') - } - return ctx -} - - -// -// PrimitiveSelectRoot -// -export interface SharedSelectRootProps extends Partial { - children: ReactNode, - id?: string, - initialIsOpen?: boolean, - iconAppearance?: SelectIconAppearance, - showSearch?: boolean, - onClose?: () => void, -} - -interface PrimitiveSelectRootProps extends SharedSelectRootProps { - initialValue?: string, - value?: string, - onValueChange?: (value: string) => void, - initialValues?: string[], - values?: string[], - onValuesChange?: (value: string[]) => void, - isMultiSelect?: boolean, -} - -const PrimitveSelectRoot = ({ - children, - id, - initialValue, - value: controlledValue, - onValueChange, - initialValues, - values: controlledValues, - onValuesChange, - onClose, - initialIsOpen = false, - disabled = false, - readOnly = false, - required = false, - invalid = false, - isMultiSelect = false, - showSearch = false, - iconAppearance = 'left', -}: PrimitiveSelectRootProps) => { - const [value, setValue] = useControlledState({ - value: controlledValue, - onValueChange, - defaultValue: initialValue, - }) - const [values, setValues] = useControlledState({ - value: controlledValues, - onValueChange: onValuesChange, - defaultValue: initialValues ?? [], - }) - - const triggerRef = useRef(null) - const generatedId = useId() - const prefix = isMultiSelect ? 'multi-select-' : 'select-' - const [ids, setIds] = useState({ - trigger: id ?? (prefix + generatedId), - content: prefix + 'content-' + generatedId, - listbox: prefix + 'listbox-' + generatedId, - searchInput: prefix + 'search-' + generatedId, - }) - - const [internalState, setInternalState] = useState({ - isOpen: initialIsOpen, - options: [], - searchQuery: '', - }) - - const selectedValues = useMemo(() => isMultiSelect ? (values ?? []) : [value].filter(Boolean), - [isMultiSelect, value, values]) - - const selectedOptions = useMemo(() => - selectedValues.map(value => internalState.options.find(option => value === option.value)).filter(Boolean), - [selectedValues, internalState.options]) - - const visibleOptions = useMemo(() => { - const q = internalState.searchQuery.trim().toLowerCase() - if (!q) return internalState.options - return MultiSearchWithMapping(internalState.searchQuery, internalState.options, o => [o.label]) - }, [internalState.options, internalState.searchQuery]) - - const state: SelectContextState = { - ...internalState, - disabled, - invalid, - readOnly, - required, - value: selectedValues, - selectedOptions, - visibleOptions, - } - - const config: SelectConfiguration = { - isMultiSelect, - iconAppearance, - } - - const registerItem = useCallback((item: RegisteredOption) => { - setInternalState(prev => { - const updatedOptions = [...prev.options, item] - updatedOptions.sort((a, b) => { - const aEl = a.ref.current - const bEl = b.ref.current - if (!aEl || !bEl) return 0 - return aEl.compareDocumentPosition(bEl) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1 - }) - return { - ...prev, - options: updatedOptions, - } - }) - }, []) - - const unregisterItem = useCallback((value: string) => { - setInternalState(prev => { - const updatedOptions = prev.options.filter(i => i.value !== value) - return { - ...prev, - options: updatedOptions, - } - }) - }, []) - - // Setting isSelected to false only works for multiselects - const toggleSelection = (value: string, isSelected?: boolean) => { - if (disabled) { - return - } - const option = state.options.find(i => i.value === value) - if (!option) { - console.error(`SelectOption with value: ${value} not found`) - return - } - - let newValue: string[] - if (isMultiSelect) { - const isSelectedBefore = state.value.includes(value) - const isSelectedAfter = isSelected ?? !isSelectedBefore - if (!isSelectedAfter) { - newValue = state.value.filter(v => v !== value) - } else { - newValue = [...state.value, value] - } - } else { - newValue = [value] - } - - if (!isMultiSelect) { - setValue(newValue[0]) - } else { - setValues(newValue) - } - - setInternalState(prevState => ({ - ...prevState, - highlightedValue: value, - })) - } - - const highlightItem = useCallback((value: string) => { - if (disabled || !state.visibleOptions.some(opt => opt.value === value && !opt.disabled)) { - return - } - setInternalState(prevState => ({ - ...prevState, - highlightedValue: value, - })) - }, [disabled, state.visibleOptions]) - - const highlightFirst = useCallback(() => { - const firstOption = state.visibleOptions.find(opt => !opt.disabled) - if(!firstOption) return - highlightItem(firstOption.value) - }, [highlightItem, state.visibleOptions]) - - const highlightLast = useCallback(() => { - const lastOption = [...state.visibleOptions].reverse().find(opt => !opt.disabled) - if(!lastOption) return - highlightItem(lastOption.value) - }, [highlightItem, state.visibleOptions]) - - const registerTrigger = useCallback((ref: React.RefObject) => { - triggerRef.current = ref.current - }, []) - - const unregisterTrigger = useCallback(() => { - triggerRef.current = null - }, []) - - const setSearchQuery = useCallback((query: string) => { - setInternalState(prev => ({ ...prev, searchQuery: query })) - }, []) - - const toggleOpen = (isOpen?: boolean, toggleOpenOptions?: ToggleOpenOptions) => { - const { highlightStartPositionBehavior } = { ...defaultToggleOpenOptions, ...toggleOpenOptions } - const optionsToUse = visibleOptions - let firstSelectedValue: string | undefined - let firstEnabledValue: string | undefined - for (let i = 0; i < optionsToUse.length; i++) { - const currentOption = optionsToUse[highlightStartPositionBehavior === 'first' ? i : optionsToUse.length - i - 1] - if (!currentOption.disabled) { - if (!firstEnabledValue) { - firstEnabledValue = currentOption.value - } - if (selectedValues.includes(currentOption.value)) { - firstSelectedValue = currentOption.value - break - } - } - } - const newIsOpen = isOpen ?? !internalState.isOpen - setInternalState(prevState => ({ - ...prevState, - isOpen: newIsOpen, - highlightedValue: firstSelectedValue ?? firstEnabledValue, - ...(newIsOpen ? {} : { searchQuery: '' }), - })) - if (!newIsOpen) { - onClose?.() - } - } - - const moveHighlightedIndex = (delta: number) => { - const optionsToUse = visibleOptions - if (optionsToUse.length === 0) return - let highlightedIndex = optionsToUse.findIndex(opt => opt.value === internalState.highlightedValue) - if (highlightedIndex === -1) { - highlightedIndex = 0 - } - const optionLength = optionsToUse.length - const startIndex = (highlightedIndex + (delta % optionLength) + optionLength) % optionLength - const isForward = delta >= 0 - let highlightedValue = optionsToUse[startIndex]?.value - for (let i = 0; i < optionsToUse.length; i++) { - const index = (startIndex + (isForward ? i : -i) + optionLength) % optionLength - if (!optionsToUse[index].disabled) { - highlightedValue = optionsToUse[index].value - break - } - } - - setInternalState(prevState => ({ - ...prevState, - highlightedValue, - })) - } - - useEffect(() => { - const highlighted = visibleOptions.find(opt => opt.value === internalState.highlightedValue) - if (highlighted) { - highlighted.ref.current?.scrollIntoView({ behavior: 'instant', block: 'nearest' }) - } else if (visibleOptions.length > 0) { - setInternalState(prev => ({ ...prev, highlightedValue: visibleOptions[0].value })) - } else { - setInternalState(prev => ({ ...prev, highlightedValue: undefined })) - } - }, [internalState.highlightedValue, visibleOptions]) - - const contextValue: SelectContextType = { - ids, - setIds, - state, - config, - item: { - register: registerItem, - unregister: unregisterItem, - toggleSelection, - highlightFirst, - highlightLast, - highlightItem, - moveHighlightedIndex, - }, - trigger: { - ref: triggerRef, - register: registerTrigger, - unregister: unregisterTrigger, - toggleOpen, - }, - search: { - showSearch, - searchQuery: internalState.searchQuery, - setSearchQuery, - }, - } - - return ( - - {children} - - ) -} - -// -// SelectRoot -// -export type SelectRootProps = SharedSelectRootProps & Partial> & { - initialValue?: string, -} - -export const SelectRoot = ({ value, onValueChange, onEditComplete, ...props }: SelectRootProps) => { - return ( - { - onValueChange?.(value) - onEditComplete?.(value) - }} - /> - ) -} - -// -// MultiSelectRoot -// -export interface MultiSelectRootProps extends SharedSelectRootProps, Partial> { - initialValue?: string[], -} - -export const MultiSelectRoot = ( { value, onValueChange, initialValue, onEditComplete,...props }: MultiSelectRootProps) => { - return ( - { - onValueChange?.(values) - }} - onClose={() => { - onEditComplete?.(value) - props.onClose?.() - }} - /> - ) -} \ No newline at end of file diff --git a/src/components/user-interaction/select/SelectOption.tsx b/src/components/user-interaction/select/SelectOption.tsx deleted file mode 100644 index 9e26900..0000000 --- a/src/components/user-interaction/select/SelectOption.tsx +++ /dev/null @@ -1,122 +0,0 @@ -import clsx from 'clsx' -import { CheckIcon } from 'lucide-react' -import type { HTMLAttributes, ReactNode, RefObject } from 'react' -import { forwardRef, useContext, useEffect, useRef, createContext } from 'react' -import type { SelectIconAppearance } from './SelectContext' -import { useSelectContext } from './SelectContext' - -export type SelectOptionDisplayLocation = 'trigger' | 'list' - -export const SelectOptionDisplayContext = createContext(null) - -export function useSelectOptionDisplayLocation(): SelectOptionDisplayLocation { - const context = useContext(SelectOptionDisplayContext) - if (!context) { - throw new Error('useSelectOptionDisplayLocation must be used within a SelectOptionDisplayContext') - } - return context -} - -// -// SelectOption -// -export interface SelectOptionProps extends Omit, 'children'> { - value: string, - label: string, - disabled?: boolean, - iconAppearance?: SelectIconAppearance, - children?: ReactNode, -} - -export const SelectOption = forwardRef( - function SelectOption({ children, label, value, disabled = false, iconAppearance, className, ...restProps }, ref) { - const { state, config, item, trigger } = useSelectContext() - const { register, unregister, toggleSelection, highlightItem } = item - const itemRef = useRef(null) - - iconAppearance ??= config.iconAppearance - - const display: ReactNode = children ?? label - - useEffect(() => { - register({ - value, - label, - display, - disabled, - ref: itemRef, - }) - return () => unregister(value) - }, [value, label, disabled, register, unregister, display]) - - const isHighlighted = state.highlightedValue === value - const isSelected = state.value.includes(value) - const isVisible = state.visibleOptions.some(opt => opt.value === value) - - return ( -
  • { - itemRef.current = node - if (typeof ref === 'function') ref(node) - else if (ref) (ref as RefObject).current = node - }} - id={value} - role="option" - aria-disabled={disabled} - aria-selected={isSelected} - aria-hidden={!isVisible} - data-highlighted={isHighlighted ? '' : undefined} - data-selected={isSelected ? '' : undefined} - data-disabled={disabled ? '' : undefined} - data-visible={isVisible ? '' : undefined} - className={clsx( - 'flex-row-1 items-center px-2 py-1 rounded-md', - 'data-highlighted:bg-primary/20', - 'data-disabled:text-disabled data-disabled:cursor-not-allowed', - 'not-data-disabled:cursor-pointer', - !isVisible && 'hidden', - className - )} - onClick={(event) => { - if (!disabled) { - toggleSelection(value) - if (!config.isMultiSelect) { - trigger.toggleOpen(false) - } - restProps.onClick?.(event) - } - }} - onMouseEnter={(event) => { - if (!disabled) { - highlightItem(value) - restProps.onMouseEnter?.(event) - } - }} - > - {iconAppearance === 'left' && (state.value.length > 0 || config.isMultiSelect) && ( - - )} - - {display} - - {iconAppearance === 'right' && (state.value.length > 0 || config.isMultiSelect) && ( - - )} -
  • - ) - } -) - -/// -/// MultiSelectOption -/// -export type MultiSelectOptionProps = SelectOptionProps - -export const MultiSelectOption = SelectOption diff --git a/src/components/utils/HighlightContext.tsx b/src/components/utils/HighlightContext.tsx deleted file mode 100644 index e07e1e5..0000000 --- a/src/components/utils/HighlightContext.tsx +++ /dev/null @@ -1,142 +0,0 @@ -import type { RefObject } from "react"; -import { createContext, ReactNode, useCallback, useContext, useEffect, useMemo, useState } from "react"; -import { DOMUtils } from "@/src/utils/dom"; -import { useControlledState } from "@/src/hooks/useControlledState"; - -export interface HighlightOption { - id: string; - disabled: boolean; - ref: RefObject; -} - -export interface HighlightContextType { - highlightedId: string | null; - highlightedOption: HighlightOption | null; - options: ReadonlyArray; - highlight: (id: string) => void; - highlightNext: () => void; - highlightPrevious: () => void; - registerOption: (option: HighlightOption) => () => void; -} - -export const HighlightContext = createContext(null); - -export function useHighlightContext(): HighlightContextType { - const context = useContext(HighlightContext); - if (!context) { - throw new Error("useHighlightContext must be used within a HighlightProvider"); - } - return context; -} - -function sortOptionsByDomPosition(options: HighlightOption[]): HighlightOption[] { - return [...options].sort((a, b) => - DOMUtils.compareDocumentPosition(a.ref?.current, b.ref?.current) - ); -} - -function firstEnabledId(options: ReadonlyArray): string | null { - const opt = options.find((o) => !o.disabled); - return opt?.id ?? null; -} - -export interface HighlightProviderProps { - children: ReactNode; - value?: null; - onHighlightChange?: (highlightedId: string) => void; - initialHighlightId?: string; -} - -export function HighlightProvider({ children, value, onHighlightChange, initialHighlightId }: HighlightProviderProps) { - const [options, setOptions] = useState([]); - const [highlightedId, setHighlightedId] = useControlledState({ - value: value, - onValueChange: onHighlightChange, - defaultValue: initialHighlightId, - }); - - const sortedOptions = useMemo(() => sortOptionsByDomPosition(options), [options]); - - const resolveHighlightId = useCallback((opts: ReadonlyArray, currentId: string | null): string | null => { - if (opts.length === 0) return null; - const defaultId = - initialHighlightId && opts.some((o) => o.id === initialHighlightId && !o.disabled) - ? initialHighlightId - : null; - const candidate = defaultId ?? firstEnabledId(opts); - if (currentId && opts.some((o) => o.id === currentId && !o.disabled)) return currentId; - return candidate; - }, - [initialHighlightId] - ); - - useEffect(() => { - const next = resolveHighlightId(sortedOptions, highlightedId); - if (next !== highlightedId) setHighlightedId(next); - }, [sortedOptions, highlightedId, resolveHighlightId]); - - const registerOption = useCallback((option: HighlightOption) => { - setOptions((prev) => sortOptionsByDomPosition([...prev, option])); - return () => setOptions((prev) => prev.filter((o) => o.id !== option.id)); - }, []); - - const highlight = useCallback( - (id: string) => { - if (!sortedOptions.some((o) => o.id === id && !o.disabled)) return; - setHighlightedId(id); - }, - [sortedOptions] - ); - - const enabledOptions = useMemo( - () => sortedOptions.filter((o) => !o.disabled), - [sortedOptions] - ); - - const highlightNext = useCallback(() => { - if (enabledOptions.length <= 1) return; - const idx = enabledOptions.findIndex((o) => o.id === highlightedId); - const nextIdx = idx < 0 ? 0 : (idx + 1) % enabledOptions.length; - setHighlightedId(enabledOptions[nextIdx].id); - }, [enabledOptions, highlightedId]); - - const highlightPrevious = useCallback(() => { - if (enabledOptions.length <= 1) return; - const idx = enabledOptions.findIndex((o) => o.id === highlightedId); - const nextIdx = - idx <= 0 ? enabledOptions.length - 1 : (idx - 1 + enabledOptions.length) % enabledOptions.length; - setHighlightedId(enabledOptions[nextIdx].id); - }, [enabledOptions, highlightedId]); - - const highlightedOption = useMemo( - () => sortedOptions.find((o) => o.id === highlightedId), - [sortedOptions, highlightedId] - ); - - const contextValue = useMemo( - (): HighlightContextType => ({ - highlightedId, - highlightedOption: highlightedOption ?? null, - options: sortedOptions, - highlight, - highlightNext, - highlightPrevious, - registerOption, - }), - [ - highlightedId, - highlightedOption, - sortedOptions, - highlight, - highlightNext, - highlightPrevious, - registerOption, - ] - ); - - return ( - - {children} - - ); -} diff --git a/src/components/utils/SelectionContext.tsx b/src/components/utils/SelectionContext.tsx deleted file mode 100644 index 5e3b10b..0000000 --- a/src/components/utils/SelectionContext.tsx +++ /dev/null @@ -1,196 +0,0 @@ -import { useControlledState } from "@/src/hooks/useControlledState"; -import { createContext, ReactNode, useCallback, useContext, useMemo, useState } from "react"; - -export interface SelectionOption { - value: T, - label: string, - display: ReactNode, - disabled: boolean, -} - -// -// Single Selection Context -// - -export interface SingleSelectionContextType { - selection: T | null, - selectedOption: SelectionOption | null; - options: ReadonlyArray>; - changeSelection: (selection: T) => void; - registerOption: (option: SelectionOption) => () => void; -} - -export const SingleSelectionContext = createContext | null>(null); - -export function useSingleSelectionContext(): SingleSelectionContextType | null { - const context = useContext(SingleSelectionContext); - if (!context) { - throw new Error('useSingleSelectionContext must be used within a SingleSelectionProvider'); - } - return context as SingleSelectionContextType; -} - -export interface SingleSelectionProviderProps { - children: ReactNode, - value: T | null, - onSelectionChange: (selection: T) => void, - initialSelection: T | null, - isControlled: boolean, - compareOptions?: (option1: T, option2: T) => boolean, -} - -export function SingleSelectionProvider({ - children, - value, - onSelectionChange, - initialSelection, - isControlled, - compareOptions -}: SingleSelectionProviderProps) { - const [options, setOptions] = useState[]>([]) - const [selection, setSelection] = useControlledState({ - value, - onValueChange: onSelectionChange, - defaultValue: initialSelection, - isControlled: isControlled, - }); - - const compareFunction = useMemo(() => { - return compareOptions ? compareOptions : Object.is; - }, [compareOptions]); - - const selectedOption = useMemo(() => { - if (!selection) return null; - return options.find(option => compareFunction(option.value, selection)); - }, [options, selection]); - - const registerOption = useCallback((option: SelectionOption) => { - setOptions(prev => [...prev, option]) - return () => { - setOptions(prev => prev.filter(o => !compareFunction(o.value, option.value))); - } - }, [setOptions, compareFunction]); - - const changeSelection = useCallback((selection: T) => { - const option = options.find(option => compareFunction(option.value, selection)); - if(!option || option.disabled) return; - setSelection(selection); - }, [setSelection, compareFunction]); - - return ( - ({ - selection, - selectedOption, - options, - changeSelection, - registerOption - }), [selection, selectedOption, options, setSelection, registerOption])} - > - {children} - - ); -} - -// -// Multi Selection Context -// - -export interface MultiSelectionContextType { - selection: ReadonlyArray; - selectedOptions: ReadonlyArray>; - options: ReadonlyArray>; - setSelection: (selection: ReadonlyArray) => void; - toggleSelection: (value: T) => void; - isSelected: (value: T) => boolean; - registerOption: (option: SelectionOption) => () => void; -} - -export const MultiSelectionContext = createContext | null>(null); - -export function useMultiSelectionContext(): MultiSelectionContextType { - const context = useContext(MultiSelectionContext); - if (!context) { - throw new Error('useMultiSelectionContext must be used within a MultiSelectionProvider'); - } - return context as MultiSelectionContextType; -} - -export interface MultiSelectionProviderProps { - children: ReactNode; - value?: ReadonlyArray; - onSelectionChange: (selection: ReadonlyArray) => void; - initialSelection?: ReadonlyArray; - compareOptions?: (option1: T, option2: T) => boolean; -} - -export function MultiSelectionProvider({ - children, - value, - onSelectionChange, - initialSelection = [], - compareOptions, -}: MultiSelectionProviderProps) { - const [options, setOptions] = useState[]>([]); - const [selection, setSelection] = useControlledState({ - value: value as T[] | undefined, - onValueChange: onSelectionChange as (v: T[]) => void, - defaultValue: initialSelection, - }); - - const compareFunction = useMemo(() => (compareOptions ?? Object.is), [compareOptions]); - - const selectedOptions = useMemo(() => - selection - .map((s) => options.find((o) => compareFunction(o.value, s))) - .filter((o): o is SelectionOption => o != null) - ,[options, selection, compareFunction]); - - const isSelected = useCallback( - (value: T) => selection.some((s) => compareFunction(s, value)), - [selection, compareFunction] - ); - - const registerOption = useCallback( - (option: SelectionOption) => { - setOptions((prev) => [...prev, option]); - return () => setOptions((prev) => prev.filter((o) => !compareFunction(o.value, option.value))); - }, - [compareFunction] - ); - - const toggleSelection = useCallback( - (value: T) => { - const option = options.find((o) => compareFunction(o.value, value)); - if (!option || option.disabled) return; - setSelection((prev) => { - const next = prev.some((s) => compareFunction(s, value)) - ? prev.filter((s) => !compareFunction(s, value)) - : [...prev, value]; - return next; - }); - }, - [options, compareFunction, setSelection] - ); - - const setSelectionValue = useCallback( - (next: ReadonlyArray) => setSelection(Array.from(next)), - [setSelection] - ); - - const contextValue = useMemo(() => ({ - selection, - selectedOptions, - options, - setSelection: setSelectionValue, - toggleSelection, - isSelected, - registerOption, - }), [selection, selectedOptions, options, setSelectionValue, toggleSelection, isSelected, registerOption]); - - return ( - - {children} - - ); -} \ No newline at end of file diff --git a/src/hooks/useListNavigation.tsx b/src/hooks/useListNavigation.tsx new file mode 100644 index 0000000..908d035 --- /dev/null +++ b/src/hooks/useListNavigation.tsx @@ -0,0 +1,80 @@ +import { useCallback, useMemo } from "react"; +import { useControlledState } from "@/src/hooks/useControlledState"; + +export interface ListNavigationReturn { + highlightedId: string | null; + highlight: (id: string) => void; + first: () => void; + last: () => void; + next: () => void; + previous: () => void; +} + +export interface ListNavigationOptions { + options: ReadonlyArray; + value?: string | null; + onValueChange?: (highlightedId: string | null) => void; + initialValue?: string | null; +} + +export function useListNavigation({ + options, + value, + onValueChange, + initialValue, +}: ListNavigationOptions): ListNavigationReturn { + const [highlightedId, setHighlightedId] = useControlledState({ + value, + onValueChange, + defaultValue: initialValue, + }); + + const resolvedHighlightId = useMemo(() => { + if (options.length === 0) return null; + if (highlightedId != null && options.includes(highlightedId)) { + return highlightedId; + } + return options[0] ?? null; + }, [options, highlightedId]); + + const highlight = useCallback((id: string) => { + if (!options.includes(id)) return; + setHighlightedId(id); + }, [options, setHighlightedId]) + + const next = useCallback(() => { + if (options.length <= 1 || resolvedHighlightId === null) return; + const idx = options.indexOf(resolvedHighlightId); + const nextIdx = idx < 0 ? 0 : (idx + 1) % options.length; + setHighlightedId(options[nextIdx]); + }, [options, resolvedHighlightId, setHighlightedId]); + + const previous = useCallback(() => { + if (options.length <= 1 || resolvedHighlightId === null) return; + const idx = options.indexOf(resolvedHighlightId); + const previousIdx = + idx <= 0 + ? options.length - 1 + : (idx - 1 + options.length) % options.length; + setHighlightedId(options[previousIdx]); + }, [options, resolvedHighlightId, setHighlightedId]); + + const first = useCallback(() => { + if (options.length <= 1 || resolvedHighlightId === null) return; + setHighlightedId(options[0]); + }, [options, resolvedHighlightId, setHighlightedId]); + + const last = useCallback(() => { + if (options.length <= 1 || resolvedHighlightId === null) return; + setHighlightedId(options[options.length - 1]); + }, [options, resolvedHighlightId, setHighlightedId]); + + return useMemo((): ListNavigationReturn => ({ + highlightedId: resolvedHighlightId, + highlight, + first, + last, + next, + previous, + }), [resolvedHighlightId, highlight, first, last, next, previous]); +} diff --git a/src/hooks/useMultiSelection.ts b/src/hooks/useMultiSelection.ts new file mode 100644 index 0000000..7a32ab7 --- /dev/null +++ b/src/hooks/useMultiSelection.ts @@ -0,0 +1,107 @@ +import type { SelectionOption } from "@/src/hooks/useSingleSelection"; +import { useControlledState } from "@/src/hooks/useControlledState"; +import { useCallback, useMemo, useState } from "react"; + +export interface UseMultiSelectionOptions { + value?: ReadonlyArray; + onSelectionChange: (selection: ReadonlyArray) => void; + initialSelection?: ReadonlyArray; + isControlled?: boolean; + compareOptions?: (a: T, b: T) => boolean; +} + +export interface MultiSelectionReturn { + selection: ReadonlyArray; + selectedOptions: ReadonlyArray>; + options: ReadonlyArray>; + setSelection: (selection: ReadonlyArray) => void; + toggleSelection: (value: T) => void; + isSelected: (value: T) => boolean; + registerOption: (option: SelectionOption) => () => void; +} + +export function useMultiSelection( + options: UseMultiSelectionOptions +): MultiSelectionReturn { + const { + value, + onSelectionChange, + initialSelection = [], + isControlled, + compareOptions, + } = options; + + const [optionsList, setOptionsList] = useState[]>([]); + const [selection, setSelection] = useControlledState({ + value: value as T[] | undefined, + onValueChange: onSelectionChange as (v: T[]) => void, + defaultValue: [...initialSelection], + isControlled, + }); + + const compare = useMemo(() => compareOptions ?? Object.is, [compareOptions]); + + const selectedOptions = useMemo( + () => + selection + .map((s) => optionsList.find((o) => compare(o.value, s))) + .filter((o): o is SelectionOption => o != null), + [selection, optionsList, compare] + ); + + const isSelected = useCallback( + (value: T) => selection.some((s) => compare(s, value)), + [selection, compare] + ); + + const registerOption = useCallback( + (option: SelectionOption) => { + setOptionsList((prev) => [...prev, option]); + return () => + setOptionsList((prev) => + prev.filter((o) => !compare(o.value, option.value)) + ); + }, + [compare] + ); + + const toggleSelection = useCallback( + (value: T) => { + const option = optionsList.find((o) => compare(o.value, value)); + if (!option || option.disabled) return; + setSelection((prev) => { + const next = prev.some((s) => compare(s, value)) + ? prev.filter((s) => !compare(s, value)) + : [...prev, value]; + return next; + }); + }, + [optionsList, compare, setSelection] + ); + + const setSelectionValue = useCallback( + (next: ReadonlyArray) => setSelection(Array.from(next)), + [setSelection] + ); + + return useMemo( + () => ({ + selection, + selectedOptions, + options: optionsList, + setSelection: setSelectionValue, + toggleSelection, + isSelected, + registerOption, + }), + [ + selection, + selectedOptions, + optionsList, + setSelectionValue, + toggleSelection, + isSelected, + registerOption, + ] + ); +} diff --git a/src/hooks/useSingleSelection.ts b/src/hooks/useSingleSelection.ts new file mode 100644 index 0000000..5dae8a0 --- /dev/null +++ b/src/hooks/useSingleSelection.ts @@ -0,0 +1,84 @@ +import type { ReactNode } from "react"; +import { useCallback, useMemo, useState } from "react"; +import { useControlledState } from "@/src/hooks/useControlledState"; + +export interface SelectionOption { + value: T; + label: string; + display: ReactNode; + disabled: boolean; +} + +export interface UseSingleSelectionOptions { + value: T | null | undefined; + onSelectionChange: (selection: T) => void; + initialSelection: T | null; + isControlled?: boolean; + compareOptions?: (a: T, b: T) => boolean; +} + +export interface SingleSelectionReturn { + selection: T | null; + selectedOption: SelectionOption | null; + options: ReadonlyArray>; + changeSelection: (selection: T) => void; + registerOption: (option: SelectionOption) => () => void; +} + +export function useSingleSelection( + options: UseSingleSelectionOptions +): SingleSelectionReturn { + const { + value, + onSelectionChange, + initialSelection, + isControlled, + compareOptions, + } = options; + + const [optionsList, setOptionsList] = useState[]>([]); + const [selection, setSelection] = useControlledState({ + value: value ?? undefined, + onValueChange: onSelectionChange, + defaultValue: initialSelection, + isControlled: isControlled ?? value !== undefined, + }); + + const compare = useMemo(() => compareOptions ?? Object.is, [compareOptions]); + + const selectedOption = useMemo(() => { + if (selection == null) return null; + return optionsList.find((o) => compare(o.value, selection)) ?? null; + }, [optionsList, selection, compare]); + + const registerOption = useCallback( + (option: SelectionOption) => { + setOptionsList((prev) => [...prev, option]); + return () => + setOptionsList((prev) => + prev.filter((o) => !compare(o.value, option.value)) + ); + }, + [compare] + ); + + const changeSelection = useCallback( + (next: T) => { + const option = optionsList.find((o) => compare(o.value, next)); + if (!option || option.disabled) return; + setSelection(next); + }, + [optionsList, compare, setSelection] + ); + + return useMemo( + () => ({ + selection: selection ?? null, + selectedOption, + options: optionsList, + changeSelection, + registerOption, + }), + [selection, selectedOption, optionsList, changeSelection, registerOption] + ); +} diff --git a/src/style/theme/components/combobox.css b/src/style/theme/components/combobox.css index eec5415..18d8c1d 100644 --- a/src/style/theme/components/combobox.css +++ b/src/style/theme/components/combobox.css @@ -18,4 +18,8 @@ @apply bg-primary/20; } } + + [data-name="combobox-list-status"] { + @apply text-description text-sm px-2 py-1 rounded-md; + } } diff --git a/stories/User Interaction/Combobox.stories.tsx b/stories/User Interaction/Combobox.stories.tsx new file mode 100644 index 0000000..d620977 --- /dev/null +++ b/stories/User Interaction/Combobox.stories.tsx @@ -0,0 +1,43 @@ +import type { Meta, StoryObj } from "@storybook/nextjs"; +import { action } from "storybook/actions"; +import { Combobox } from "@/src/components/user-interaction/Combobox"; +import { ComboboxOption } from "@/src/components/user-interaction/Combobox/ComboboxOption"; + +const options = [ + { value: "apple", label: "Apple" }, + { value: "banana", label: "Banana" }, + { value: "blueberry", label: "Blueberry" }, + { value: "cherry", label: "Cherry" }, + { value: "grape", label: "Grape" }, + { value: "kiwi", label: "Kiwi" }, + { value: "mango", label: "Mango" }, + { value: "orange", label: "Orange" }, + { value: "papaya", label: "Papaya" }, + { value: "pineapple", label: "Pineapple" }, + { value: "strawberry", label: "Strawberry" }, + { value: "watermelon", label: "Watermelon" }, +]; + +const meta: Meta = { + component: Combobox, +}; + +export default meta; +type Story = StoryObj; + +export const combobox: Story = { + args: { + id: undefined, + children: options.map(({ value, label }) => ( + + {label} + + )), + onItemClick: action("onItemClick"), + }, + render: (args) => ( +
    + +
    + ), +}; diff --git a/stories/User Interaction/Form/Form.stories.tsx b/stories/User Interaction/Form/Form.stories.tsx index ba12de2..2a9068d 100644 --- a/stories/User Interaction/Form/Form.stories.tsx +++ b/stories/User Interaction/Form/Form.stories.tsx @@ -4,9 +4,9 @@ import type { StorybookHelperSelectType } from '@/src/storybook/helper' import { StorybookHelper } from '@/src/storybook/helper' import { useTranslatedValidators } from '@/src/hooks/useValidators' import { Input } from '@/src/components/user-interaction/input/Input' -import { MultiSelect } from '@/src/components/user-interaction/select/MultiSelect' -import { Select } from '@/src/components/user-interaction/select/Select' -import { SelectOption } from '@/src/components/user-interaction/select/SelectContent' +import { MultiSelect } from '@/src/components/user-interaction/MultiSelect/MultiSelect' +import { Select } from '@/src/components/user-interaction/Select/Select' +import { SelectOption } from '@/src/components/user-interaction/Select/SelectOption' import { Textarea } from '@/src/components/user-interaction/Textarea' import { Button } from '@/src/components/user-interaction/Button' import { useCreateForm } from '@/src/components/form/useCreateForm' diff --git a/stories/User Interaction/Properties/MultiSelectProperty.stories.tsx b/stories/User Interaction/Properties/MultiSelectProperty.stories.tsx index 8b48e83..26af2f8 100644 --- a/stories/User Interaction/Properties/MultiSelectProperty.stories.tsx +++ b/stories/User Interaction/Properties/MultiSelectProperty.stories.tsx @@ -4,7 +4,7 @@ import { action } from 'storybook/actions' import clsx from 'clsx' import { MultiSelectProperty } from '@/src/components/user-interaction/properties/MultiSelectProperty' import { StorybookHelper } from '@/src/storybook/helper' -import { MultiSelectOption } from '@/src/components/user-interaction/select/SelectOption' +import { MultiSelectOption } from '@/src/components/user-interaction/MultiSelect/MultiSelectOption' const options = StorybookHelper.selectValues diff --git a/stories/User Interaction/Properties/SingleSelectProperty.stories.tsx b/stories/User Interaction/Properties/SingleSelectProperty.stories.tsx index e018613..bc0c892 100644 --- a/stories/User Interaction/Properties/SingleSelectProperty.stories.tsx +++ b/stories/User Interaction/Properties/SingleSelectProperty.stories.tsx @@ -4,7 +4,7 @@ import { action } from 'storybook/actions' import { StorybookHelper } from '@/src/storybook/helper' import clsx from 'clsx' import { SingleSelectProperty } from '@/src/components/user-interaction/properties/SelectProperty' -import { SelectOption } from '@/src/components/user-interaction/select/SelectOption' +import { SelectOption } from '@/src/components/user-interaction/Select/SelectOption' const options = [...StorybookHelper.selectValues] diff --git a/stories/User Interaction/Select/MultiSelect.stories.tsx b/stories/User Interaction/Select/MultiSelect.stories.tsx index 7faad1d..7edff10 100644 --- a/stories/User Interaction/Select/MultiSelect.stories.tsx +++ b/stories/User Interaction/Select/MultiSelect.stories.tsx @@ -1,7 +1,7 @@ import { action } from 'storybook/actions' import type { Meta, StoryObj } from '@storybook/nextjs' -import { MultiSelect } from '@/src/components/user-interaction/select/MultiSelect' -import { MultiSelectOption } from '@/src/components/user-interaction/select/SelectOption' +import { MultiSelect } from '@/src/components/user-interaction/MultiSelect/MultiSelect' +import { MultiSelectOption } from '@/src/components/user-interaction/MultiSelect/MultiSelectOption' const meta = { component: MultiSelect, diff --git a/stories/User Interaction/Select/MultiSelectChipDisplay.stories.tsx b/stories/User Interaction/Select/MultiSelectChipDisplay.stories.tsx index 4f1cab8..f5bb583 100644 --- a/stories/User Interaction/Select/MultiSelectChipDisplay.stories.tsx +++ b/stories/User Interaction/Select/MultiSelectChipDisplay.stories.tsx @@ -1,8 +1,8 @@ import { action } from 'storybook/actions' import { MultiSelectChipDisplay -} from '@/src/components/user-interaction/select/MultiSelectChipDisplay' -import { MultiSelectOption } from '@/src/components/user-interaction/select/SelectOption' +} from '@/src/components/user-interaction/MultiSelect/MultiSelectChipDisplay' +import { MultiSelectOption } from '@/src/components/user-interaction/MultiSelect/MultiSelectOption' import type { Meta, StoryObj } from '@storybook/nextjs' const meta = { diff --git a/stories/User Interaction/Select/Select.stories.tsx b/stories/User Interaction/Select/Select.stories.tsx index 7135159..8441d51 100644 --- a/stories/User Interaction/Select/Select.stories.tsx +++ b/stories/User Interaction/Select/Select.stories.tsx @@ -1,7 +1,7 @@ import type { Meta, StoryObj } from '@storybook/nextjs' import { action } from 'storybook/actions' -import { Select } from '@/src/components/user-interaction/select/Select' -import { SelectOption } from '@/src/components/user-interaction/select/SelectOption' +import { Select } from '@/src/components/user-interaction/Select/Select' +import { SelectOption } from '@/src/components/user-interaction/Select/SelectOption' const meta = { component: Select, From 4328d68c92d74f14d75cc05593bfa9c796024bd0 Mon Sep 17 00:00:00 2001 From: DasProffi <67233923+DasProffi@users.noreply.github.com> Date: Sun, 22 Feb 2026 22:36:15 +0100 Subject: [PATCH 07/13] fix: contexts for Select and MultiSelect --- .../MultiSelect/MultiSelect.tsx | 25 +- .../MultiSelect/MultiSelectButton.tsx | 14 +- .../MultiSelect/MultiSelectChipDisplay.tsx | 8 +- .../MultiSelect/MultiSelectContent.tsx | 4 +- .../MultiSelect/MultiSelectContext.tsx | 320 ++---------------- .../MultiSelect/MultiSelectOption.tsx | 2 +- .../MultiSelect/MultiSelectRoot.tsx | 47 +++ .../MultiSelect/useMultiSelect.ts | 269 +++++++++++++++ .../user-interaction/Select/Select.tsx | 4 +- .../user-interaction/Select/SelectButton.tsx | 169 ++++----- .../user-interaction/Select/SelectContent.tsx | 4 +- .../user-interaction/Select/SelectContext.tsx | 318 ++--------------- .../user-interaction/Select/SelectOption.tsx | 4 +- .../user-interaction/Select/SelectRoot.tsx | 47 +++ .../user-interaction/Select/useSelect.ts | 267 +++++++++++++++ .../properties/SelectProperty.tsx | 2 +- src/hooks/useMultiSelection.ts | 37 +- src/hooks/useSingleSelection.ts | 38 +-- src/style/theme/components/select.css | 36 +- stories/User Interaction/Combobox.stories.tsx | 2 +- 20 files changed, 855 insertions(+), 762 deletions(-) create mode 100644 src/components/user-interaction/MultiSelect/MultiSelectRoot.tsx create mode 100644 src/components/user-interaction/MultiSelect/useMultiSelect.ts create mode 100644 src/components/user-interaction/Select/SelectRoot.tsx create mode 100644 src/components/user-interaction/Select/useSelect.ts diff --git a/src/components/user-interaction/MultiSelect/MultiSelect.tsx b/src/components/user-interaction/MultiSelect/MultiSelect.tsx index 1ad7cdf..ea755bf 100644 --- a/src/components/user-interaction/MultiSelect/MultiSelect.tsx +++ b/src/components/user-interaction/MultiSelect/MultiSelect.tsx @@ -1,6 +1,6 @@ import { forwardRef } from "react"; -import type { MultiSelectRootProps } from "./MultiSelectContext"; -import { MultiSelectRoot } from "./MultiSelectContext"; +import type { MultiSelectRootProps } from "./MultiSelectRoot"; +import { MultiSelectRoot } from "./MultiSelectRoot"; import type { MultiSelectButtonProps } from "./MultiSelectButton"; import { MultiSelectButton } from "./MultiSelectButton"; import type { MultiSelectContentProps } from "./MultiSelectContent"; @@ -11,14 +11,13 @@ export interface MultiSelectProps extends MultiSelectRootProps { buttonProps?: MultiSelectButtonProps; } -export const MultiSelect = forwardRef(function MultiSelect( - { children, contentPanelProps, buttonProps, ...props }, - ref -) { - return ( - - - {children} - - ); -}); +export const MultiSelect = forwardRef( + function MultiSelect({ children, contentPanelProps, buttonProps, ...props }, ref) { + return ( + + + {children} + + ); + } +); diff --git a/src/components/user-interaction/MultiSelect/MultiSelectButton.tsx b/src/components/user-interaction/MultiSelect/MultiSelectButton.tsx index a96b839..900630c 100644 --- a/src/components/user-interaction/MultiSelect/MultiSelectButton.tsx +++ b/src/components/user-interaction/MultiSelect/MultiSelectButton.tsx @@ -13,8 +13,18 @@ export interface MultiSelectButtonProps extends ComponentPropsWithoutRef<"div"> hideExpansionIcon?: boolean; } -export const MultiSelectButton = forwardRef(function MultiSelectButton( - { id, placeholder, disabled: disabledOverride, selectedDisplay, hideExpansionIcon = false, ...props }, +export const MultiSelectButton = forwardRef< + HTMLDivElement, + MultiSelectButtonProps +>(function MultiSelectButton( + { + id, + placeholder, + disabled: disabledOverride, + selectedDisplay, + hideExpansionIcon = false, + ...props + }, ref ) { const translation = useHightideTranslation(); diff --git a/src/components/user-interaction/MultiSelect/MultiSelectChipDisplay.tsx b/src/components/user-interaction/MultiSelect/MultiSelectChipDisplay.tsx index 35983ad..be402dc 100644 --- a/src/components/user-interaction/MultiSelect/MultiSelectChipDisplay.tsx +++ b/src/components/user-interaction/MultiSelect/MultiSelectChipDisplay.tsx @@ -1,10 +1,8 @@ import type { HTMLAttributes, ReactNode } from "react"; import { forwardRef, useEffect, useImperativeHandle, useRef } from "react"; -import { - MultiSelectRoot, - useMultiSelectContext, - type MultiSelectRootProps, -} from "./MultiSelectContext"; +import { useMultiSelectContext } from "./MultiSelectContext"; +import type { MultiSelectRootProps } from "./MultiSelectRoot"; +import { MultiSelectRoot } from "./MultiSelectRoot"; import type { MultiSelectContentProps } from "./MultiSelectContent"; import { MultiSelectContent } from "./MultiSelectContent"; import { IconButton } from "@/src/components/user-interaction/IconButton"; diff --git a/src/components/user-interaction/MultiSelect/MultiSelectContent.tsx b/src/components/user-interaction/MultiSelect/MultiSelectContent.tsx index b58862a..9814b41 100644 --- a/src/components/user-interaction/MultiSelect/MultiSelectContent.tsx +++ b/src/components/user-interaction/MultiSelect/MultiSelectContent.tsx @@ -164,7 +164,9 @@ export const MultiSelectContent = forwardRef 0 })} + aria-atomic={true} + data-name="select-list-status" + className={clsx({ "sr-only": state.visibleOptions.length > 0 })} > {translation("nResultsFound", { count: state.visibleOptions.length })} diff --git a/src/components/user-interaction/MultiSelect/MultiSelectContext.tsx b/src/components/user-interaction/MultiSelect/MultiSelectContext.tsx index 1b328f9..24d4b8b 100644 --- a/src/components/user-interaction/MultiSelect/MultiSelectContext.tsx +++ b/src/components/user-interaction/MultiSelect/MultiSelectContext.tsx @@ -1,55 +1,44 @@ -import type { Dispatch, ReactNode, SetStateAction } from "react"; -import { - createContext, - useCallback, - useContext, - useEffect, - useId, - useMemo, - useRef, - useState, -} from "react"; +import type { Dispatch, ReactNode, RefObject, SetStateAction } from "react"; +import { createContext, useContext } from "react"; import type { FormFieldInteractionStates } from "@/src/components/form/FieldLayout"; -import type { FormFieldDataHandling } from "@/src/components/form/FormField"; -import { useMultiSelection } from "@/src/hooks/useMultiSelection"; -import { useListNavigation } from "@/src/hooks/useListNavigation"; -import { MultiSearchWithMapping } from "@/src/utils/simpleSearch"; -export type MultiSelectIconAppearance = "left" | "right" | "none"; - -type RegisteredOption = { +export interface MultiSelectOptionType { value: string; label: string; display: ReactNode; disabled: boolean; - ref: React.RefObject; -}; +} + +export interface RegisteredMultiSelectOption extends MultiSelectOptionType { + ref: RefObject; +} -type MultiSelectContextIds = { +export interface MultiSelectContextIds { trigger: string; content: string; listbox: string; searchInput: string; -}; +} -type MultiSelectContextState = FormFieldInteractionStates & { +export interface MultiSelectContextState extends FormFieldInteractionStates { isOpen: boolean; - options: RegisteredOption[]; - visibleOptions: RegisteredOption[]; + options: ReadonlyArray; + visibleOptions: ReadonlyArray; searchQuery: string; value: string[]; - selectedOptions: RegisteredOption[]; + selectedOptions: ReadonlyArray; highlightedValue: string | undefined; -}; +} -export type MultiSelectContextType = { +export type MultiSelectIconAppearance = "left" | "right" | "none"; + +export interface MultiSelectContextType { ids: MultiSelectContextIds; setIds: Dispatch>; state: MultiSelectContextState; iconAppearance: MultiSelectIconAppearance; item: { - register: (item: RegisteredOption) => () => void; - unregister: (value: string) => void; + register: (item: RegisteredMultiSelectOption) => () => void; toggleSelection: (value: string, isSelected?: boolean) => void; highlightFirst: () => void; highlightLast: () => void; @@ -57,17 +46,20 @@ export type MultiSelectContextType = { moveHighlightedIndex: (delta: number) => void; }; trigger: { - ref: React.RefObject; - register: (element: React.RefObject) => void; + ref: RefObject; + register: (element: RefObject) => void; unregister: () => void; - toggleOpen: (isOpen?: boolean, options?: { highlightStartPositionBehavior?: "first" | "last" }) => void; + toggleOpen: ( + isOpen?: boolean, + options?: { highlightStartPositionBehavior?: "first" | "last" } + ) => void; }; search: { showSearch: boolean; searchQuery: string; setSearchQuery: (query: string) => void; }; -}; +} const MultiSelectContext = createContext(null); @@ -77,262 +69,4 @@ export function useMultiSelectContext(): MultiSelectContextType { return ctx; } -export interface SharedMultiSelectRootProps extends Partial { - children: ReactNode; - id?: string; - initialIsOpen?: boolean; - iconAppearance?: MultiSelectIconAppearance; - showSearch?: boolean; - onClose?: () => void; -} - -export interface MultiSelectRootProps - extends SharedMultiSelectRootProps, - Partial> { - initialValue?: string[]; -} - -export function useMultiSelect(props: MultiSelectRootProps): MultiSelectContextType { - const { - id, - value: controlledValues, - onValueChange, - initialValue, - onEditComplete, - onClose, - initialIsOpen = false, - disabled = false, - readOnly = false, - required = false, - invalid = false, - showSearch = false, - iconAppearance = "left", - } = props; - - const triggerRef = useRef(null); - const generatedId = useId(); - const [ids, setIds] = useState({ - trigger: id ?? "multi-select-" + generatedId, - content: "multi-select-content-" + generatedId, - listbox: "multi-select-listbox-" + generatedId, - searchInput: "multi-select-search-" + generatedId, - }); - const [isOpen, setIsOpen] = useState(initialIsOpen); - const [options, setOptions] = useState([]); - const [searchQuery, setSearchQuery] = useState(""); - - const selection = useMultiSelection({ - value: controlledValues, - onSelectionChange: (v) => onValueChange?.(Array.from(v)), - initialSelection: initialValue, - isControlled: controlledValues !== undefined, - }); - - const visibleOptions = useMemo(() => { - const q = searchQuery.trim().toLowerCase(); - if (!q) return options; - return MultiSearchWithMapping(searchQuery, options, (o) => [o.label]); - }, [options, searchQuery]); - - const listNav = useListNavigation({ - options: visibleOptions.map((o) => o.value), - initialValue: controlledValues?.[0] ?? initialValue?.[0], - }); - - const value = useMemo(() => [...selection.selection], [selection.selection]); - const selectedOptions = useMemo( - () => - value - .map((v) => options.find((o) => o.value === v)) - .filter((o): o is RegisteredOption => o != null), - [value, options] - ); - - useEffect(() => { - if ( - listNav.highlightedId != null && - !visibleOptions.some((o) => o.value === listNav.highlightedId) - ) { - const first = visibleOptions.find((o) => !o.disabled); - if (first) listNav.highlight(first.value); - } - }, [visibleOptions, listNav.highlightedId, listNav.highlight]); - - useEffect(() => { - const opt = options.find((o) => o.value === listNav.highlightedId); - opt?.ref.current?.scrollIntoView({ behavior: "instant", block: "nearest" }); - }, [listNav.highlightedId, options]); - - const registerItem = useCallback( - (item: RegisteredOption) => { - setOptions((prev) => { - const next = prev.filter((o) => o.value !== item.value); - next.push(item); - next.sort((a, b) => { - const aEl = a.ref.current; - const bEl = b.ref.current; - if (!aEl || !bEl) return 0; - return aEl.compareDocumentPosition(bEl) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1; - }); - return next; - }); - const unregSelection = selection.registerOption({ - value: item.value, - label: item.label, - display: item.display, - disabled: item.disabled, - }); - return () => { - unregSelection(); - setOptions((prev) => prev.filter((o) => o.value !== item.value)); - }; - }, - [selection, listNav] - ); - - const unregisterItem = useCallback((value: string) => { - setOptions((prev) => prev.filter((o) => o.value !== value)); - }, []); - - const toggleSelectionValue = useCallback( - (optionValue: string, isSelected?: boolean) => { - if (disabled) return; - const before = selection.isSelected(optionValue); - const next = isSelected ?? !before; - if (next) selection.toggleSelection(optionValue); - else selection.setSelection([...selection.selection].filter((v) => v !== optionValue)); - listNav.highlight(optionValue); - }, - [disabled, selection, listNav] - ); - - const highlightItem = useCallback( - (value: string) => { - if (disabled || !visibleOptions.some((o) => o.value === value && !o.disabled)) return; - listNav.highlight(value); - }, - [disabled, visibleOptions, listNav] - ); - - const highlightFirst = useCallback(() => { - const first = visibleOptions.find((o) => !o.disabled); - if (first) listNav.highlight(first.value); - }, [visibleOptions, listNav]); - - const highlightLast = useCallback(() => { - const last = [...visibleOptions].reverse().find((o) => !o.disabled); - if (last) listNav.highlight(last.value); - }, [visibleOptions, listNav]); - - const moveHighlightedIndex = useCallback( - (delta: number) => { - const list = visibleOptions.filter((o) => !o.disabled); - if (list.length === 0) return; - const idx = list.findIndex((o) => o.value === listNav.highlightedId); - const startIdx = idx < 0 ? 0 : (idx + (delta % list.length) + list.length) % list.length; - const isForward = delta >= 0; - let nextIdx = startIdx; - for (let i = 0; i < list.length; i++) { - const j = (startIdx + (isForward ? i : -i) + list.length) % list.length; - if (!list[j].disabled) { - nextIdx = j; - break; - } - } - listNav.highlight(list[nextIdx].value); - }, - [visibleOptions, listNav] - ); - - const registerTrigger = useCallback((ref: React.RefObject) => { - (triggerRef as React.MutableRefObject).current = ref.current; - }, []); - - const unregisterTrigger = useCallback(() => { - (triggerRef as React.MutableRefObject).current = null; - }, []); - - const handleClose = useCallback(() => { - onEditComplete?.(controlledValues); - onClose?.(); - }, [onEditComplete, onClose, controlledValues]); - - const toggleOpen = useCallback( - (open?: boolean, opts?: { highlightStartPositionBehavior?: "first" | "last" }) => { - const next = open ?? !isOpen; - if (next) { - const behavior = opts?.highlightStartPositionBehavior ?? "first"; - const list = visibleOptions.filter((o) => !o.disabled); - const firstSelected = list.find((o) => selection.isSelected(o.value)); - const fallback = behavior === "first" ? list[0] : list[list.length - 1]; - const toHighlight = firstSelected ?? fallback; - if (toHighlight) listNav.highlight(toHighlight.value); - } else { - setSearchQuery(""); - handleClose(); - } - setIsOpen(next); - }, - [isOpen, visibleOptions, selection, listNav, handleClose] - ); - - const state: MultiSelectContextState = { - isOpen, - options, - visibleOptions, - searchQuery, - value, - selectedOptions, - highlightedValue: listNav.highlightedId ?? undefined, - disabled, - invalid, - readOnly, - required, - }; - - return useMemo( - (): MultiSelectContextType => ({ - ids, - setIds, - state, - iconAppearance, - item: { - register: registerItem, - unregister: unregisterItem, - toggleSelection: toggleSelectionValue, - highlightFirst, - highlightLast, - highlightItem, - moveHighlightedIndex, - }, - trigger: { ref: triggerRef, register: registerTrigger, unregister: unregisterTrigger, toggleOpen }, - search: { showSearch, searchQuery, setSearchQuery }, - }), - [ - ids, - state, - iconAppearance, - registerItem, - unregisterItem, - toggleSelectionValue, - highlightFirst, - highlightLast, - highlightItem, - moveHighlightedIndex, - registerTrigger, - unregisterTrigger, - toggleOpen, - showSearch, - searchQuery, - ] - ); -} - -export function MultiSelectRoot(props: MultiSelectRootProps) { - const value = useMultiSelect(props); - return ( - - {props.children} - - ); -} +export { MultiSelectContext }; diff --git a/src/components/user-interaction/MultiSelect/MultiSelectOption.tsx b/src/components/user-interaction/MultiSelect/MultiSelectOption.tsx index a0e6bf3..088d5ee 100644 --- a/src/components/user-interaction/MultiSelect/MultiSelectOption.tsx +++ b/src/components/user-interaction/MultiSelect/MultiSelectOption.tsx @@ -32,7 +32,7 @@ export const MultiSelectOption = forwardRef(null); diff --git a/src/components/user-interaction/MultiSelect/MultiSelectRoot.tsx b/src/components/user-interaction/MultiSelect/MultiSelectRoot.tsx new file mode 100644 index 0000000..728cee0 --- /dev/null +++ b/src/components/user-interaction/MultiSelect/MultiSelectRoot.tsx @@ -0,0 +1,47 @@ +import type { ReactNode } from "react"; +import { useCallback, useState } from "react"; +import { MultiSelectContext } from "./MultiSelectContext"; +import type { RegisteredMultiSelectOption } from "./MultiSelectContext"; +import type { UseMultiSelectProps } from "./useMultiSelect"; +import { useMultiSelect } from "./useMultiSelect"; +import { DOMUtils } from "@/src/utils/dom"; + +export interface MultiSelectRootProps extends Omit { + children: ReactNode; +} + +export function MultiSelectRoot(props: MultiSelectRootProps) { + const { children, ...hookProps } = props; + const [options, setOptions] = useState([]); + + const registerOption = useCallback( + (item: RegisteredMultiSelectOption) => { + setOptions((prev) => { + const next = prev.filter((o) => o.value !== item.value); + next.push(item); + next.sort((a, b) => + DOMUtils.compareDocumentPosition(a.ref.current, b.ref.current) + ); + return next; + }); + return () => + setOptions((prev) => prev.filter((o) => o.value !== item.value)); + }, + [] + ); + + const value = useMultiSelect({ ...hookProps, options }); + return ( + + {children} + + ); +} diff --git a/src/components/user-interaction/MultiSelect/useMultiSelect.ts b/src/components/user-interaction/MultiSelect/useMultiSelect.ts new file mode 100644 index 0000000..8bb5cf3 --- /dev/null +++ b/src/components/user-interaction/MultiSelect/useMultiSelect.ts @@ -0,0 +1,269 @@ +import type { Dispatch, ReactNode, RefObject, SetStateAction } from "react"; +import { + useCallback, + useEffect, + useId, + useMemo, + useRef, + useState, +} from "react"; +import type { FormFieldInteractionStates } from "@/src/components/form/FieldLayout"; +import type { FormFieldDataHandling } from "@/src/components/form/FormField"; +import { useMultiSelection } from "@/src/hooks/useMultiSelection"; +import { useListNavigation } from "@/src/hooks/useListNavigation"; +import { MultiSearchWithMapping } from "@/src/utils/simpleSearch"; +import type { SelectionOption } from "@/src/hooks/useSingleSelection"; +import type { + MultiSelectContextType, + MultiSelectContextState, + RegisteredMultiSelectOption, +} from "./MultiSelectContext"; +import type { MultiSelectIconAppearance } from "./MultiSelectContext"; + +export interface UseMultiSelectConfiguration extends Partial { + id?: string; + initialIsOpen?: boolean; + iconAppearance?: MultiSelectIconAppearance; + showSearch?: boolean; + onClose?: () => void; +} + +export interface UseMultiSelectState extends Partial> { + initialValue?: string[]; +} + +export interface UseMultiSelectProps extends UseMultiSelectConfiguration, UseMultiSelectState { + options: ReadonlyArray; +} + +export type UseMultiSelectResult = Omit & { + item: Omit; +}; + +function toSelectionOptions( + options: ReadonlyArray +): ReadonlyArray> { + return options.map((o) => ({ + value: o.value, + label: o.label, + display: o.display, + disabled: o.disabled, + })); +} + +export function useMultiSelect(props: UseMultiSelectProps): UseMultiSelectResult { + const { + options, + id, + value: controlledValue, + onValueChange, + onEditComplete, + initialValue, + onClose, + initialIsOpen = false, + disabled = false, + readOnly = false, + required = false, + invalid = false, + showSearch = false, + iconAppearance = "left", + } = props; + + const triggerRef = useRef(null); + const generatedId = useId(); + const [ids, setIds] = useState({ + trigger: id ?? "multi-select-" + generatedId, + content: "multi-select-content-" + generatedId, + listbox: "multi-select-listbox-" + generatedId, + searchInput: "multi-select-search-" + generatedId, + }); + const [isOpen, setIsOpen] = useState(initialIsOpen); + const [searchQuery, setSearchQuery] = useState(""); + + const selectionOptions = useMemo(() => toSelectionOptions(options), [options]); + + const selection = useMultiSelection({ + options: selectionOptions, + value: controlledValue, + onSelectionChange: (v) => { onValueChange?.(Array.from(v)) }, + initialSelection: initialValue ?? [], + isControlled: controlledValue !== undefined, + }); + + const visibleOptions = useMemo(() => { + const q = searchQuery.trim().toLowerCase(); + if (!q) return options; + return MultiSearchWithMapping(searchQuery, [...options], (o) => [o.label]); + }, [options, searchQuery]); + + const listNav = useListNavigation({ + options: visibleOptions.map((o) => o.value), + initialValue: controlledValue?.[0] ?? initialValue?.[0], + }); + + const value = useMemo(() => [...selection.selection], [selection.selection]); + const selectedOptions = useMemo( + () => + value + .map((v) => options.find((o) => o.value === v)) + .filter((o): o is RegisteredMultiSelectOption => o != null), + [value, options] + ); + + useEffect(() => { + if ( + listNav.highlightedId != null && + !visibleOptions.some((o) => o.value === listNav.highlightedId) + ) { + const first = visibleOptions.find((o) => !o.disabled); + if (first) listNav.highlight(first.value); + } + }, [visibleOptions, listNav.highlightedId, listNav.highlight]); + + useEffect(() => { + const opt = options.find((o) => o.value === listNav.highlightedId); + opt?.ref?.current?.scrollIntoView?.({ behavior: "instant", block: "nearest" }); + }, [listNav.highlightedId, options]); + + const toggleSelectionValue = useCallback( + (optionValue: string, isSelected?: boolean) => { + if (disabled) return; + const before = selection.isSelected(optionValue); + const next = isSelected ?? !before; + if (next) { + selection.toggleSelection(optionValue); + } else { + selection.setSelection(selection.selection.filter((v) => v !== optionValue)); + } + listNav.highlight(optionValue); + }, + [disabled, selection, listNav] + ); + + const highlightItem = useCallback( + (value: string) => { + if ( + disabled || + !visibleOptions.some((o) => o.value === value && !o.disabled) + ) + return; + listNav.highlight(value); + }, + [disabled, visibleOptions, listNav] + ); + + const highlightFirst = useCallback(() => { + const first = visibleOptions.find((o) => !o.disabled); + if (first) listNav.highlight(first.value); + }, [visibleOptions, listNav]); + + const highlightLast = useCallback(() => { + const last = [...visibleOptions].reverse().find((o) => !o.disabled); + if (last) listNav.highlight(last.value); + }, [visibleOptions, listNav]); + + const moveHighlightedIndex = useCallback( + (delta: number) => { + const list = visibleOptions.filter((o) => !o.disabled); + if (list.length === 0) return; + const idx = list.findIndex((o) => o.value === listNav.highlightedId); + const startIdx = + idx < 0 ? 0 : (idx + (delta % list.length) + list.length) % list.length; + const isForward = delta >= 0; + let nextIdx = startIdx; + for (let i = 0; i < list.length; i++) { + const j = + (startIdx + (isForward ? i : -i) + list.length) % list.length; + if (!list[j].disabled) { + nextIdx = j; + break; + } + } + listNav.highlight(list[nextIdx].value); + }, + [visibleOptions, listNav] + ); + + const registerTrigger = useCallback((ref: RefObject) => { + (triggerRef as React.MutableRefObject).current = + ref.current; + }, []); + + const unregisterTrigger = useCallback(() => { + (triggerRef as React.MutableRefObject).current = null; + }, []); + + const toggleOpen = useCallback( + ( + open?: boolean, + opts?: { highlightStartPositionBehavior?: "first" | "last" } + ) => { + const next = open ?? !isOpen; + if (next) { + const behavior = opts?.highlightStartPositionBehavior ?? "first"; + const list = visibleOptions.filter((o) => !o.disabled); + const firstSelected = list.find((o) => selection.isSelected(o.value)); + const fallback = behavior === "first" ? list[0] : list[list.length - 1]; + const toHighlight = firstSelected ?? fallback; + if (toHighlight) listNav.highlight(toHighlight.value); + } else { + setSearchQuery(""); + onClose?.(); + } + setIsOpen(next); + }, + [isOpen, visibleOptions, selection, listNav, onClose] + ); + + const state: MultiSelectContextState = { + isOpen, + options, + visibleOptions, + searchQuery, + value, + selectedOptions, + highlightedValue: listNav.highlightedId ?? undefined, + disabled, + invalid, + readOnly, + required, + }; + + return useMemo( + (): UseMultiSelectResult => ({ + ids, + setIds: setIds as Dispatch>, + state, + iconAppearance, + item: { + toggleSelection: toggleSelectionValue, + highlightFirst, + highlightLast, + highlightItem, + moveHighlightedIndex, + }, + trigger: { + ref: triggerRef, + register: registerTrigger, + unregister: unregisterTrigger, + toggleOpen, + }, + search: { showSearch, searchQuery, setSearchQuery }, + }), + [ + ids, + state, + iconAppearance, + toggleSelectionValue, + highlightFirst, + highlightLast, + highlightItem, + moveHighlightedIndex, + registerTrigger, + unregisterTrigger, + toggleOpen, + showSearch, + searchQuery, + ] + ); +} diff --git a/src/components/user-interaction/Select/Select.tsx b/src/components/user-interaction/Select/Select.tsx index 5df5329..682dad1 100644 --- a/src/components/user-interaction/Select/Select.tsx +++ b/src/components/user-interaction/Select/Select.tsx @@ -1,7 +1,7 @@ import type { ReactNode } from "react"; import { forwardRef } from "react"; -import type { SelectRootProps } from "./SelectContext"; -import { SelectRoot } from "./SelectContext"; +import type { SelectRootProps } from "./SelectRoot"; +import { SelectRoot } from "./SelectRoot"; import type { SelectButtonProps } from "./SelectButton"; import { SelectButton } from "./SelectButton"; import type { SelectContentProps } from "./SelectContent"; diff --git a/src/components/user-interaction/Select/SelectButton.tsx b/src/components/user-interaction/Select/SelectButton.tsx index cc93f18..7a868b1 100644 --- a/src/components/user-interaction/Select/SelectButton.tsx +++ b/src/components/user-interaction/Select/SelectButton.tsx @@ -13,88 +13,97 @@ export interface SelectButtonProps extends ComponentPropsWithoutRef<"div"> { hideExpansionIcon?: boolean; } -export const SelectButton = forwardRef(function SelectButton( - { id, placeholder, disabled: disabledOverride, selectedDisplay, hideExpansionIcon = false, ...props }, - ref -) { - const translation = useHightideTranslation(); - const { state, trigger, setIds, ids } = useSelectContext(); - const { register, unregister, toggleOpen } = trigger; +export const SelectButton = forwardRef( + function SelectButton( + { + id, + placeholder, + disabled: disabledOverride, + selectedDisplay, + hideExpansionIcon = false, + ...props + }, + ref + ) { + const translation = useHightideTranslation(); + const { state, trigger, setIds, ids } = useSelectContext(); + const { register, unregister, toggleOpen } = trigger; - useEffect(() => { - if (id) setIds((prev) => ({ ...prev, trigger: id })); - }, [id, setIds]); + useEffect(() => { + if (id) setIds((prev) => ({ ...prev, trigger: id })); + }, [id, setIds]); - const innerRef = useRef(null); - useImperativeHandle(ref, () => innerRef.current!); + const innerRef = useRef(null); + useImperativeHandle(ref, () => innerRef.current!); - useEffect(() => { - register(innerRef); - return () => unregister(); - }, [register, unregister]); + useEffect(() => { + register(innerRef); + return () => unregister(); + }, [register, unregister]); - const disabled = !!disabledOverride || !!state.disabled; - const invalid = state.invalid; - const hasValue = state.value.length > 0; + const disabled = !!disabledOverride || !!state.disabled; + const invalid = state.invalid; + const hasValue = state.value.length > 0; - return ( -
    { - props.onClick?.(event); - toggleOpen(!state.isOpen); - }} - onKeyDown={(event) => { - props.onKeyDown?.(event); - if (disabled) return; - switch (event.key) { - case "Enter": - case " ": - toggleOpen(!state.isOpen); - event.preventDefault(); - event.stopPropagation(); - break; - case "ArrowDown": - toggleOpen(true, { highlightStartPositionBehavior: "first" }); - event.preventDefault(); - event.stopPropagation(); - break; - case "ArrowUp": - toggleOpen(true, { highlightStartPositionBehavior: "last" }); - event.preventDefault(); - event.stopPropagation(); - break; - } - }} - data-name={props["data-name"] ?? "select-button"} - data-value={hasValue ? "" : undefined} - data-disabled={disabled ? "" : undefined} - data-invalid={invalid ? "" : undefined} - tabIndex={disabled ? -1 : 0} - role="button" - aria-invalid={invalid} - aria-disabled={disabled} - aria-haspopup="dialog" - aria-expanded={state.isOpen} - aria-controls={state.isOpen ? ids.content : undefined} - > - - {hasValue - ? selectedDisplay?.(state.value) ?? ( -
    - {state.selectedOptions.map(({ value, display }, index) => ( - - {display} - {index < state.value.length - 1 && ,} - - ))} -
    - ) - : placeholder ?? translation("clickToSelect")} -
    - {!hideExpansionIcon && } -
    - ); -}); + return ( +
    { + props.onClick?.(event); + toggleOpen(!state.isOpen); + }} + onKeyDown={(event) => { + props.onKeyDown?.(event); + if (disabled) return; + switch (event.key) { + case "Enter": + case " ": + toggleOpen(!state.isOpen); + event.preventDefault(); + event.stopPropagation(); + break; + case "ArrowDown": + toggleOpen(true, { highlightStartPositionBehavior: "first" }); + event.preventDefault(); + event.stopPropagation(); + break; + case "ArrowUp": + toggleOpen(true, { highlightStartPositionBehavior: "last" }); + event.preventDefault(); + event.stopPropagation(); + break; + } + }} + data-name={props["data-name"] ?? "select-button"} + data-value={hasValue ? "" : undefined} + data-disabled={disabled ? "" : undefined} + data-invalid={invalid ? "" : undefined} + tabIndex={disabled ? -1 : 0} + role="button" + aria-invalid={invalid} + aria-disabled={disabled} + aria-haspopup="dialog" + aria-expanded={state.isOpen} + aria-controls={state.isOpen ? ids.content : undefined} + > + + {hasValue + ? selectedDisplay?.(state.value) ?? ( +
    + {state.selectedOptions.map(({ value, display }, index) => ( + + {display} + {index < state.value.length - 1 && ,} + + ))} +
    + ) + : placeholder ?? translation("clickToSelect")} +
    + {!hideExpansionIcon && } +
    + ); + } +); diff --git a/src/components/user-interaction/Select/SelectContent.tsx b/src/components/user-interaction/Select/SelectContent.tsx index e7b76bb..cee08ea 100644 --- a/src/components/user-interaction/Select/SelectContent.tsx +++ b/src/components/user-interaction/Select/SelectContent.tsx @@ -164,7 +164,9 @@ export const SelectContent = forwardRef(fu aria-selected={false} aria-disabled={true} aria-live="polite" - className={clsx("text-description", { "sr-only": state.visibleOptions.length > 0 })} + aria-atomic={true} + data-name="select-list-status" + className={clsx({ "sr-only": state.visibleOptions.length > 0 })} > {translation("nResultsFound", { count: state.visibleOptions.length })} diff --git a/src/components/user-interaction/Select/SelectContext.tsx b/src/components/user-interaction/Select/SelectContext.tsx index 588491e..9ec8839 100644 --- a/src/components/user-interaction/Select/SelectContext.tsx +++ b/src/components/user-interaction/Select/SelectContext.tsx @@ -1,55 +1,44 @@ -import type { Dispatch, ReactNode, SetStateAction } from "react"; -import { - createContext, - useCallback, - useContext, - useEffect, - useId, - useMemo, - useRef, - useState, -} from "react"; +import type { Dispatch, ReactNode, RefObject, SetStateAction } from "react"; +import { createContext, useContext } from "react"; import type { FormFieldInteractionStates } from "@/src/components/form/FieldLayout"; -import type { FormFieldDataHandling } from "@/src/components/form/FormField"; -import { useSingleSelection } from "@/src/hooks/useSingleSelection"; -import { useListNavigation } from "@/src/hooks/useListNavigation"; -import { MultiSearchWithMapping } from "@/src/utils/simpleSearch"; -export type SelectIconAppearance = "left" | "right" | "none"; - -type RegisteredOption = { +export interface SelectOptionType { value: string; label: string; display: ReactNode; disabled: boolean; - ref: React.RefObject; -}; +} + +export interface RegisteredSelectOption extends SelectOptionType { + ref: RefObject; +} -type SelectContextIds = { +export interface SelectContextIds { trigger: string; content: string; listbox: string; searchInput: string; -}; +} -type SelectContextState = FormFieldInteractionStates & { +export interface SelectContextState extends FormFieldInteractionStates { isOpen: boolean; - options: RegisteredOption[]; - visibleOptions: RegisteredOption[]; + options: ReadonlyArray; + visibleOptions: ReadonlyArray; searchQuery: string; value: string[]; - selectedOptions: RegisteredOption[]; + selectedOptions: ReadonlyArray; highlightedValue: string | undefined; -}; +} -export type SelectContextType = { +export type SelectIconAppearance = "left" | "right" | "none"; + +export interface SelectContextType { ids: SelectContextIds; setIds: Dispatch>; state: SelectContextState; iconAppearance: SelectIconAppearance; item: { - register: (item: RegisteredOption) => () => void; - unregister: (value: string) => void; + register: (item: RegisteredSelectOption) => () => void; toggleSelection: (value: string) => void; highlightFirst: () => void; highlightLast: () => void; @@ -57,17 +46,20 @@ export type SelectContextType = { moveHighlightedIndex: (delta: number) => void; }; trigger: { - ref: React.RefObject; - register: (element: React.RefObject) => void; + ref: RefObject; + register: (element: RefObject) => void; unregister: () => void; - toggleOpen: (isOpen?: boolean, options?: { highlightStartPositionBehavior?: "first" | "last" }) => void; + toggleOpen: ( + isOpen?: boolean, + options?: { highlightStartPositionBehavior?: "first" | "last" } + ) => void; }; search: { showSearch: boolean; searchQuery: string; setSearchQuery: (query: string) => void; }; -}; +} const SelectContext = createContext(null); @@ -77,260 +69,4 @@ export function useSelectContext(): SelectContextType { return ctx; } -export interface SharedSelectRootProps extends Partial { - children: ReactNode; - id?: string; - initialIsOpen?: boolean; - iconAppearance?: SelectIconAppearance; - showSearch?: boolean; - onClose?: () => void; -} - -export type SelectRootProps = SharedSelectRootProps & - Partial> & { - initialValue?: string; - }; - -export function useSingleSelect(props: SelectRootProps): SelectContextType { - const { - id, - value: controlledValue, - onValueChange, - initialValue, - onClose, - initialIsOpen = false, - disabled = false, - readOnly = false, - required = false, - invalid = false, - showSearch = false, - iconAppearance = "left", - } = props; - - const triggerRef = useRef(null); - const generatedId = useId(); - const [ids, setIds] = useState({ - trigger: id ?? "select-" + generatedId, - content: "select-content-" + generatedId, - listbox: "select-listbox-" + generatedId, - searchInput: "select-search-" + generatedId, - }); - const [isOpen, setIsOpen] = useState(initialIsOpen); - const [options, setOptions] = useState([]); - const [searchQuery, setSearchQuery] = useState(""); - - const selection = useSingleSelection({ - value: controlledValue !== undefined ? controlledValue : null, - onSelectionChange: (v) => { - onValueChange?.(v); - props.onEditComplete?.(v); - }, - initialSelection: initialValue ?? null, - isControlled: controlledValue !== undefined, - }); - - const visibleOptions = useMemo(() => { - const q = searchQuery.trim().toLowerCase(); - if (!q) return options; - return MultiSearchWithMapping(searchQuery, options, (o) => [o.label]); - }, [options, searchQuery]); - - const listNav = useListNavigation({ - options: visibleOptions.map((o) => o.value), - initialValue: controlledValue ?? initialValue ?? undefined, - }); - - const selectedOptions = useMemo( - () => - selection.selection != null - ? [options.find((o) => o.value === selection.selection)].filter( - (o): o is RegisteredOption => o != null - ) - : [], - [selection.selection, options] - ); - const value = useMemo( - () => (selection.selection != null ? [selection.selection] : []), - [selection.selection] - ); - - useEffect(() => { - if ( - listNav.highlightedId != null && - !visibleOptions.some((o) => o.value === listNav.highlightedId) - ) { - const first = visibleOptions.find((o) => !o.disabled); - if (first) listNav.highlight(first.value); - } - }, [visibleOptions, listNav.highlightedId, listNav.highlight]); - - useEffect(() => { - const opt = options.find((o) => o.value === listNav.highlightedId); - opt?.ref.current?.scrollIntoView({ behavior: "instant", block: "nearest" }); - }, [listNav.highlightedId, options]); - - const registerItem = useCallback( - (item: RegisteredOption) => { - setOptions((prev) => { - const next = prev.filter((o) => o.value !== item.value); - next.push(item); - next.sort((a, b) => { - const aEl = a.ref.current; - const bEl = b.ref.current; - if (!aEl || !bEl) return 0; - return aEl.compareDocumentPosition(bEl) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1; - }); - return next; - }); - const unregSelection = selection.registerOption({ - value: item.value, - label: item.label, - display: item.display, - disabled: item.disabled, - }); - return () => { - unregSelection(); - setOptions((prev) => prev.filter((o) => o.value !== item.value)); - }; - }, - [selection, listNav] - ); - - const unregisterItem = useCallback((value: string) => { - setOptions((prev) => prev.filter((o) => o.value !== value)); - }, []); - - const toggleSelection = useCallback( - (value: string) => { - if (disabled) return; - selection.changeSelection(value); - setIsOpen((prev) => (prev ? false : prev)); - }, - [disabled, selection] - ); - - const highlightItem = useCallback( - (value: string) => { - if (disabled || !visibleOptions.some((o) => o.value === value && !o.disabled)) return; - listNav.highlight(value); - }, - [disabled, visibleOptions, listNav] - ); - - const highlightFirst = useCallback(() => { - const first = visibleOptions.find((o) => !o.disabled); - if (first) listNav.highlight(first.value); - }, [visibleOptions, listNav]); - - const highlightLast = useCallback(() => { - const last = [...visibleOptions].reverse().find((o) => !o.disabled); - if (last) listNav.highlight(last.value); - }, [visibleOptions, listNav]); - - const moveHighlightedIndex = useCallback( - (delta: number) => { - const list = visibleOptions.filter((o) => !o.disabled); - if (list.length === 0) return; - const idx = list.findIndex((o) => o.value === listNav.highlightedId); - const startIdx = idx < 0 ? 0 : (idx + (delta % list.length) + list.length) % list.length; - const isForward = delta >= 0; - let nextIdx = startIdx; - for (let i = 0; i < list.length; i++) { - const j = (startIdx + (isForward ? i : -i) + list.length) % list.length; - if (!list[j].disabled) { - nextIdx = j; - break; - } - } - listNav.highlight(list[nextIdx].value); - }, - [visibleOptions, listNav] - ); - - const registerTrigger = useCallback((ref: React.RefObject) => { - (triggerRef as React.MutableRefObject).current = ref.current; - }, []); - - const unregisterTrigger = useCallback(() => { - (triggerRef as React.MutableRefObject).current = null; - }, []); - - const toggleOpen = useCallback( - (open?: boolean, opts?: { highlightStartPositionBehavior?: "first" | "last" }) => { - const next = open ?? !isOpen; - if (next) { - const behavior = opts?.highlightStartPositionBehavior ?? "first"; - const list = visibleOptions.filter((o) => !o.disabled); - const firstSelected = list.find((o) => o.value === selection.selection); - const fallback = behavior === "first" ? list[0] : list[list.length - 1]; - const toHighlight = firstSelected ?? fallback; - if (toHighlight) listNav.highlight(toHighlight.value); - } else { - setSearchQuery(""); - onClose?.(); - } - setIsOpen(next); - }, - [isOpen, visibleOptions, selection.selection, listNav, onClose] - ); - - const state: SelectContextState = { - isOpen, - options, - visibleOptions, - searchQuery, - value, - selectedOptions, - highlightedValue: listNav.highlightedId ?? undefined, - disabled, - invalid, - readOnly, - required, - }; - - return useMemo( - (): SelectContextType => ({ - ids, - setIds, - state, - iconAppearance, - item: { - register: registerItem, - unregister: unregisterItem, - toggleSelection, - highlightFirst, - highlightLast, - highlightItem, - moveHighlightedIndex, - }, - trigger: { ref: triggerRef, register: registerTrigger, unregister: unregisterTrigger, toggleOpen }, - search: { showSearch, searchQuery, setSearchQuery }, - }), - [ - ids, - state, - iconAppearance, - registerItem, - unregisterItem, - toggleSelection, - highlightFirst, - highlightLast, - highlightItem, - moveHighlightedIndex, - registerTrigger, - unregisterTrigger, - toggleOpen, - showSearch, - searchQuery, - ] - ); -} - -export function SelectRoot(props: SelectRootProps) { - const value = useSingleSelect(props); - return ( - - {props.children} - - ); -} +export { SelectContext }; diff --git a/src/components/user-interaction/Select/SelectOption.tsx b/src/components/user-interaction/Select/SelectOption.tsx index f9335d4..fac5908 100644 --- a/src/components/user-interaction/Select/SelectOption.tsx +++ b/src/components/user-interaction/Select/SelectOption.tsx @@ -30,7 +30,7 @@ export const SelectOption = forwardRef(functio ref ) { const { state, item, trigger, iconAppearance: ctxIconAppearance } = useSelectContext(); - const { register, unregister, toggleSelection, highlightItem } = item; + const { register, toggleSelection, highlightItem } = item; const itemRef = useRef(null); const display: ReactNode = children ?? label; @@ -41,7 +41,7 @@ export const SelectOption = forwardRef(functio value, label, display, - disabled, + disabled: disabled, ref: itemRef as React.RefObject, }); }, [value, label, disabled, register, display]); diff --git a/src/components/user-interaction/Select/SelectRoot.tsx b/src/components/user-interaction/Select/SelectRoot.tsx new file mode 100644 index 0000000..20d1b6d --- /dev/null +++ b/src/components/user-interaction/Select/SelectRoot.tsx @@ -0,0 +1,47 @@ +import type { ReactNode } from "react"; +import { useCallback, useState } from "react"; +import { SelectContext } from "./SelectContext"; +import type { RegisteredSelectOption } from "./SelectContext"; +import type { UseSelectProps } from "./useSelect"; +import { useSelect } from "./useSelect"; +import { DOMUtils } from "@/src/utils/dom"; + +export interface SelectRootProps extends Omit { + children: ReactNode; +} + +export function SelectRoot(props: SelectRootProps) { + const { children, ...hookProps } = props; + const [options, setOptions] = useState([]); + + const registerOption = useCallback( + (item: RegisteredSelectOption) => { + setOptions((prev) => { + const next = prev.filter((o) => o.value !== item.value); + next.push(item); + next.sort((a, b) => + DOMUtils.compareDocumentPosition(a.ref.current, b.ref.current) + ); + return next; + }); + return () => + setOptions((prev) => prev.filter((o) => o.value !== item.value)); + }, + [] + ); + + const value = useSelect({ ...hookProps, options }); + return ( + + {children} + + ); +} diff --git a/src/components/user-interaction/Select/useSelect.ts b/src/components/user-interaction/Select/useSelect.ts new file mode 100644 index 0000000..4d9514c --- /dev/null +++ b/src/components/user-interaction/Select/useSelect.ts @@ -0,0 +1,267 @@ +import type { Dispatch, ReactNode, RefObject, SetStateAction } from "react"; +import { + useCallback, + useEffect, + useId, + useMemo, + useRef, + useState, +} from "react"; +import type { FormFieldInteractionStates } from "@/src/components/form/FieldLayout"; +import type { FormFieldDataHandling } from "@/src/components/form/FormField"; +import { useSingleSelection } from "@/src/hooks/useSingleSelection"; +import { useListNavigation } from "@/src/hooks/useListNavigation"; +import { MultiSearchWithMapping } from "@/src/utils/simpleSearch"; +import type { SelectionOption } from "@/src/hooks/useSingleSelection"; +import type { + SelectContextType, + SelectContextState, + SelectIconAppearance, + RegisteredSelectOption, +} from "./SelectContext"; + +export interface UseSelectConfiguration extends Partial { + id?: string; + initialIsOpen?: boolean; + iconAppearance?: SelectIconAppearance; + showSearch?: boolean; + +} + +export interface UseSelectState extends Partial> { + initialValue?: string; +} + +export interface UseSelectProps extends UseSelectConfiguration, UseSelectState { + onClose?: () => void; + options: ReadonlyArray; +} + +export type UseSelectResult = Omit & { + item: Omit; +}; + +function toSelectionOptions( + options: ReadonlyArray +): ReadonlyArray> { + return options.map((o) => ({ + value: o.value, + label: o.label, + display: o.display, + disabled: o.disabled, + })); +} + +export function useSelect(props: UseSelectProps): UseSelectResult { + const { + options, + id, + value: controlledValue, + onValueChange, + onEditComplete, + initialValue, + onClose, + initialIsOpen = false, + disabled = false, + readOnly = false, + required = false, + invalid = false, + showSearch = false, + iconAppearance = "left", + } = props; + + const triggerRef = useRef(null); + const generatedId = useId(); + const [ids, setIds] = useState({ + trigger: id ?? "select-" + generatedId, + content: "select-content-" + generatedId, + listbox: "select-listbox-" + generatedId, + searchInput: "select-search-" + generatedId, + }); + const [isOpen, setIsOpen] = useState(initialIsOpen); + const [searchQuery, setSearchQuery] = useState(""); + + const selectionOptions = useMemo( + () => toSelectionOptions(options), + [options] + ); + + const selection = useSingleSelection({ + options: selectionOptions, + value: controlledValue !== undefined ? controlledValue : null, + onSelectionChange: (v) => { + onValueChange?.(v); + onEditComplete?.(v); + }, + initialSelection: initialValue ?? null, + isControlled: controlledValue !== undefined, + }); + + const visibleOptions = useMemo(() => { + const q = searchQuery.trim().toLowerCase(); + if (!q) return options; + return MultiSearchWithMapping(searchQuery, [...options], (o) => [o.label]); + }, [options, searchQuery]); + + const listNav = useListNavigation({ + options: visibleOptions.map((o) => o.value), + initialValue: controlledValue ?? initialValue ?? undefined, + }); + + const selectedOptions = useMemo( + () => + selection.selection != null + ? options.filter((o) => o.value === selection.selection) + : [], + [selection.selection, options] + ); + const value = useMemo( + () => (selection.selection != null ? [selection.selection] : []), + [selection.selection] + ); + + useEffect(() => { + if ( + listNav.highlightedId != null && + !visibleOptions.some((o) => o.value === listNav.highlightedId) + ) { + const first = visibleOptions.find((o) => !o.disabled); + if (first) listNav.highlight(first.value); + } + }, [visibleOptions, listNav.highlightedId, listNav.highlight]); + + useEffect(() => { + const opt = options.find((o) => o.value === listNav.highlightedId); + opt?.ref?.current?.scrollIntoView?.({ behavior: "instant", block: "nearest" }); + }, [listNav.highlightedId, options]); + + const toggleSelection = useCallback( + (value: string) => { + if (disabled) return; + selection.changeSelection(value); + setIsOpen((prev) => (prev ? false : prev)); + }, + [disabled, selection] + ); + + const highlightItem = useCallback( + (value: string) => { + if (disabled || !visibleOptions.some((o) => o.value === value && !o.disabled)) + return; + listNav.highlight(value); + }, + [disabled, visibleOptions, listNav] + ); + + const highlightFirst = useCallback(() => { + const first = visibleOptions.find((o) => !o.disabled); + if (first) listNav.highlight(first.value); + }, [visibleOptions, listNav]); + + const highlightLast = useCallback(() => { + const last = [...visibleOptions].reverse().find((o) => !o.disabled); + if (last) listNav.highlight(last.value); + }, [visibleOptions, listNav]); + + const moveHighlightedIndex = useCallback( + (delta: number) => { + const list = visibleOptions.filter((o) => !o.disabled); + if (list.length === 0) return; + const idx = list.findIndex((o) => o.value === listNav.highlightedId); + const startIdx = + idx < 0 ? 0 : (idx + (delta % list.length) + list.length) % list.length; + const isForward = delta >= 0; + let nextIdx = startIdx; + for (let i = 0; i < list.length; i++) { + const j = + (startIdx + (isForward ? i : -i) + list.length) % list.length; + if (!list[j].disabled) { + nextIdx = j; + break; + } + } + listNav.highlight(list[nextIdx].value); + }, + [visibleOptions, listNav] + ); + + const registerTrigger = useCallback((ref: RefObject) => { + (triggerRef as React.MutableRefObject).current = + ref.current; + }, []); + + const unregisterTrigger = useCallback(() => { + (triggerRef as React.MutableRefObject).current = null; + }, []); + + const toggleOpen = useCallback( + (open?: boolean, opts?: { highlightStartPositionBehavior?: "first" | "last" }) => { + const next = open ?? !isOpen; + if (next) { + const behavior = opts?.highlightStartPositionBehavior ?? "first"; + const list = visibleOptions.filter((o) => !o.disabled); + const firstSelected = list.find((o) => o.value === selection.selection); + const fallback = behavior === "first" ? list[0] : list[list.length - 1]; + const toHighlight = firstSelected ?? fallback; + if (toHighlight) listNav.highlight(toHighlight.value); + } else { + setSearchQuery(""); + onClose?.(); + } + setIsOpen(next); + }, + [isOpen, visibleOptions, selection.selection, listNav, onClose] + ); + + const state: SelectContextState = { + isOpen, + options, + visibleOptions, + searchQuery, + value, + selectedOptions, + highlightedValue: listNav.highlightedId ?? undefined, + disabled, + invalid, + readOnly, + required, + }; + + return useMemo( + (): UseSelectResult => ({ + ids, + setIds: setIds as Dispatch>, + state, + iconAppearance, + item: { + toggleSelection, + highlightFirst, + highlightLast, + highlightItem, + moveHighlightedIndex, + }, + trigger: { + ref: triggerRef, + register: registerTrigger, + unregister: unregisterTrigger, + toggleOpen, + }, + search: { showSearch, searchQuery, setSearchQuery }, + }), + [ + ids, + state, + iconAppearance, + toggleSelection, + highlightFirst, + highlightLast, + highlightItem, + moveHighlightedIndex, + registerTrigger, + unregisterTrigger, + toggleOpen, + showSearch, + searchQuery, + ] + ); +} diff --git a/src/components/user-interaction/properties/SelectProperty.tsx b/src/components/user-interaction/properties/SelectProperty.tsx index ed0cf51..a395d56 100644 --- a/src/components/user-interaction/properties/SelectProperty.tsx +++ b/src/components/user-interaction/properties/SelectProperty.tsx @@ -3,7 +3,7 @@ import type { PropsWithChildren } from 'react' import type { PropertyField } from '@/src/components/user-interaction/properties/PropertyBase' import { PropertyBase } from '@/src/components/user-interaction/properties/PropertyBase' import { PropsUtil } from '@/src/utils/propsUtil' -import { SelectRoot } from '@/src/components/user-interaction/Select/SelectContext' +import { SelectRoot } from '@/src/components/user-interaction/Select/SelectRoot' import { SelectButton } from '@/src/components/user-interaction/Select/SelectButton' import { SelectContent } from '@/src/components/user-interaction/Select/SelectContent' diff --git a/src/hooks/useMultiSelection.ts b/src/hooks/useMultiSelection.ts index 7a32ab7..2fb7c5f 100644 --- a/src/hooks/useMultiSelection.ts +++ b/src/hooks/useMultiSelection.ts @@ -1,8 +1,9 @@ import type { SelectionOption } from "@/src/hooks/useSingleSelection"; import { useControlledState } from "@/src/hooks/useControlledState"; -import { useCallback, useMemo, useState } from "react"; +import { useCallback, useMemo } from "react"; export interface UseMultiSelectionOptions { + options: ReadonlyArray>; value?: ReadonlyArray; onSelectionChange: (selection: ReadonlyArray) => void; initialSelection?: ReadonlyArray; @@ -17,21 +18,16 @@ export interface MultiSelectionReturn { setSelection: (selection: ReadonlyArray) => void; toggleSelection: (value: T) => void; isSelected: (value: T) => boolean; - registerOption: (option: SelectionOption) => () => void; } -export function useMultiSelection( - options: UseMultiSelectionOptions -): MultiSelectionReturn { - const { - value, - onSelectionChange, - initialSelection = [], - isControlled, - compareOptions, - } = options; - - const [optionsList, setOptionsList] = useState[]>([]); +export function useMultiSelection({ + options: optionsList, + value, + onSelectionChange, + initialSelection = [], + isControlled, + compareOptions, +}: UseMultiSelectionOptions): MultiSelectionReturn { const [selection, setSelection] = useControlledState({ value: value as T[] | undefined, onValueChange: onSelectionChange as (v: T[]) => void, @@ -54,17 +50,6 @@ export function useMultiSelection( [selection, compare] ); - const registerOption = useCallback( - (option: SelectionOption) => { - setOptionsList((prev) => [...prev, option]); - return () => - setOptionsList((prev) => - prev.filter((o) => !compare(o.value, option.value)) - ); - }, - [compare] - ); - const toggleSelection = useCallback( (value: T) => { const option = optionsList.find((o) => compare(o.value, value)); @@ -92,7 +77,6 @@ export function useMultiSelection( setSelection: setSelectionValue, toggleSelection, isSelected, - registerOption, }), [ selection, @@ -101,7 +85,6 @@ export function useMultiSelection( setSelectionValue, toggleSelection, isSelected, - registerOption, ] ); } diff --git a/src/hooks/useSingleSelection.ts b/src/hooks/useSingleSelection.ts index 5dae8a0..0d6a3fb 100644 --- a/src/hooks/useSingleSelection.ts +++ b/src/hooks/useSingleSelection.ts @@ -1,5 +1,5 @@ import type { ReactNode } from "react"; -import { useCallback, useMemo, useState } from "react"; +import { useCallback, useMemo } from "react"; import { useControlledState } from "@/src/hooks/useControlledState"; export interface SelectionOption { @@ -10,6 +10,7 @@ export interface SelectionOption { } export interface UseSingleSelectionOptions { + options: ReadonlyArray>; value: T | null | undefined; onSelectionChange: (selection: T) => void; initialSelection: T | null; @@ -22,21 +23,16 @@ export interface SingleSelectionReturn { selectedOption: SelectionOption | null; options: ReadonlyArray>; changeSelection: (selection: T) => void; - registerOption: (option: SelectionOption) => () => void; } -export function useSingleSelection( - options: UseSingleSelectionOptions -): SingleSelectionReturn { - const { - value, - onSelectionChange, - initialSelection, - isControlled, - compareOptions, - } = options; - - const [optionsList, setOptionsList] = useState[]>([]); +export function useSingleSelection({ + options: optionsList, + value, + onSelectionChange, + initialSelection, + isControlled, + compareOptions, +}: UseSingleSelectionOptions): SingleSelectionReturn { const [selection, setSelection] = useControlledState({ value: value ?? undefined, onValueChange: onSelectionChange, @@ -51,17 +47,6 @@ export function useSingleSelection( return optionsList.find((o) => compare(o.value, selection)) ?? null; }, [optionsList, selection, compare]); - const registerOption = useCallback( - (option: SelectionOption) => { - setOptionsList((prev) => [...prev, option]); - return () => - setOptionsList((prev) => - prev.filter((o) => !compare(o.value, option.value)) - ); - }, - [compare] - ); - const changeSelection = useCallback( (next: T) => { const option = optionsList.find((o) => compare(o.value, next)); @@ -77,8 +62,7 @@ export function useSingleSelection( selectedOption, options: optionsList, changeSelection, - registerOption, }), - [selection, selectedOption, optionsList, changeSelection, registerOption] + [selection, selectedOption, optionsList, changeSelection] ); } diff --git a/src/style/theme/components/select.css b/src/style/theme/components/select.css index 89e60f0..355643f 100644 --- a/src/style/theme/components/select.css +++ b/src/style/theme/components/select.css @@ -1,19 +1,25 @@ @layer components { - [data-name="select-button"] { - @apply input-element flex-row-2 items-center justify-between rounded-md px-3 py-2; - &:not([data-disabled]) { - @apply hover:cursor-pointer; - } - } - - [data-name="select-chip-display"] { - @apply input-element flex flex-wrap gap-2 items-center rounded-md px-2.5 py-2.5; - &:not([data-disabled]) { - @apply hover:cursor-pointer; - } + [data-name="select-button"] { + @apply input-element flex-row-2 items-center justify-between rounded-md px-3 py-2; + + &:not([data-disabled]) { + @apply hover:cursor-pointer; } + } + + [data-name="select-chip-display"] { + @apply input-element flex flex-wrap gap-2 items-center rounded-md px-2.5 py-2.5; - [data-name="select-chip-display-chip"] { - @apply flex-row-1 items-center pl-2 pr-1 coloring-solid neutral rounded-md h-9; + &:not([data-disabled]) { + @apply hover:cursor-pointer; } - } \ No newline at end of file + } + + [data-name="select-chip-display-chip"] { + @apply flex-row-1 items-center pl-2 pr-1 coloring-solid neutral rounded-md h-9; + } + + [data-name="select-list-status"] { + @apply text-description text-sm px-2 py-1 rounded-md; + } +} \ No newline at end of file diff --git a/stories/User Interaction/Combobox.stories.tsx b/stories/User Interaction/Combobox.stories.tsx index d620977..42761fd 100644 --- a/stories/User Interaction/Combobox.stories.tsx +++ b/stories/User Interaction/Combobox.stories.tsx @@ -1,6 +1,6 @@ import type { Meta, StoryObj } from "@storybook/nextjs"; import { action } from "storybook/actions"; -import { Combobox } from "@/src/components/user-interaction/Combobox"; +import { Combobox } from "@/src/components/user-interaction/Combobox/Combobox"; import { ComboboxOption } from "@/src/components/user-interaction/Combobox/ComboboxOption"; const options = [ From 7d133e35052b71cf044745f9e042c1f05d4410f0 Mon Sep 17 00:00:00 2001 From: DasProffi <67233923+DasProffi@users.noreply.github.com> Date: Fri, 6 Mar 2026 14:46:43 +0100 Subject: [PATCH 08/13] feat: add hooks for select, mutliselect and combobox --- CHANGELOG.md | 2 +- .../layout/dialog/premade/ThemeDialog.tsx | 12 +- src/components/layout/popup/PopUp.tsx | 9 +- .../user-interaction/Combobox/Combobox.tsx | 36 +- .../Combobox/ComboboxContext.tsx | 69 ++-- .../Combobox/ComboboxInput.tsx | 35 +- .../Combobox/ComboboxList.tsx | 19 +- .../Combobox/ComboboxOption.tsx | 61 +-- .../Combobox/ComboboxRoot.tsx | 123 +++++- .../user-interaction/Combobox/useCombobox.ts | 143 ++++--- .../MultiSelect/MultiSelect.tsx | 10 +- .../MultiSelect/MultiSelectButton.tsx | 62 +-- .../MultiSelect/MultiSelectChipDisplay.tsx | 66 ++-- .../MultiSelect/MultiSelectContent.tsx | 322 ++++++++------- .../MultiSelect/MultiSelectContext.tsx | 94 ++--- .../MultiSelect/MultiSelectOption.tsx | 67 ++-- .../MultiSelect/MultiSelectRoot.tsx | 226 +++++++++-- .../MultiSelect/useMultiSelect.ts | 365 ++++++++---------- .../user-interaction/Select/Select.tsx | 23 +- .../user-interaction/Select/SelectButton.tsx | 58 ++- .../user-interaction/Select/SelectContent.tsx | 81 ++-- .../user-interaction/Select/SelectContext.tsx | 97 ++--- .../user-interaction/Select/SelectOption.tsx | 68 ++-- .../user-interaction/Select/SelectRoot.tsx | 178 ++++++++- .../user-interaction/Select/useSelect.ts | 348 ++++++----------- .../user-interaction/data/FilterList.tsx | 81 +++- .../user-interaction/data/FilterPopUp.tsx | 12 +- .../user-interaction/data/filter-function.ts | 96 +++++ .../properties/MultiSelectProperty.tsx | 9 +- .../selection-models/ListBox.tsx | 317 --------------- src/hooks/useMultiSelection.ts | 43 +-- src/hooks/useSingleSelection.ts | 129 ++++--- src/style/theme/colors/utilities.css | 7 +- src/style/theme/components/general.css | 4 +- src/style/theme/components/select.css | 35 +- src/style/utitlity/focus.css | 4 +- .../Layout/Table/FilterListTable.stories.tsx | 85 +++- .../User Interaction/Form/Form.stories.tsx | 9 +- stories/User Interaction/ListBox.stories.tsx | 50 --- 39 files changed, 1864 insertions(+), 1591 deletions(-) delete mode 100644 src/components/user-interaction/selection-models/ListBox.tsx delete mode 100644 stories/User Interaction/ListBox.stories.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ee91e9..50317e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - Type ahead support for `Select` and `MultiSelect`npm - `Combobox` component - `FilterList` component for dynamically choosing and setting filters -- `SingleSelectionContext`, `MultiSelectionContext` and `HighlightContext` +- `useSelectState`, `useMultiSelectState`, `useCombobox`, `useSingleSelection`, `useMultiSelection` ## Fixed - imports in `TimePicker` and `DateTimeInput` diff --git a/src/components/layout/dialog/premade/ThemeDialog.tsx b/src/components/layout/dialog/premade/ThemeDialog.tsx index a4ef1c2..f77d592 100644 --- a/src/components/layout/dialog/premade/ThemeDialog.tsx +++ b/src/components/layout/dialog/premade/ThemeDialog.tsx @@ -30,18 +30,19 @@ export const ThemeIcon = ({ theme: themeOverride, ...props }: ThemeIconProps) => } } -export type ThemeSelectProps = Omit +export type ThemeSelectProps = Omit, 'value' | 'children'> export const ThemeSelect = ({ ...props }: ThemeSelectProps) => { const translation = useHightideTranslation() const { theme, setTheme } = useTheme() return ( - 0} - aria-controls={ids.listbox} - aria-activedescendant={highlighting.value ?? undefined} + aria-expanded={context.visibleOptionIds.length > 0} + aria-controls={context.config.ids.listbox} + aria-activedescendant={context.highlightedId ?? undefined} aria-autocomplete="list" /> ); diff --git a/src/components/user-interaction/Combobox/ComboboxList.tsx b/src/components/user-interaction/Combobox/ComboboxList.tsx index 42cf9fa..e66aa24 100644 --- a/src/components/user-interaction/Combobox/ComboboxList.tsx +++ b/src/components/user-interaction/Combobox/ComboboxList.tsx @@ -1,5 +1,5 @@ import type { HTMLAttributes, RefObject } from "react"; -import { forwardRef, useCallback } from "react"; +import { forwardRef, useEffect, useRef } from "react"; import clsx from "clsx"; import { useComboboxContext } from "./ComboboxContext"; import { useHightideTranslation } from "@/src/i18n/useHightideTranslation"; @@ -9,21 +9,26 @@ export interface ComboboxListProps extends HTMLAttributes {} export const ComboboxList = forwardRef( function ComboboxList({ children, ...props }, ref) { const translation = useHightideTranslation(); - const { ids, listRef, visibleOptions } = useComboboxContext(); + const context = useComboboxContext(); + const innerRef = useRef(null); - const setRefs = useCallback((node: HTMLUListElement | null) => { - (listRef as RefObject).current = node; + useEffect(() => { + return context.layout.registerList(innerRef as RefObject); + }, [context.layout.registerList]); + + const setRefs = (node: HTMLUListElement | null) => { + (innerRef as RefObject).current = node; if (typeof ref === "function") ref(node); else if (ref) (ref as RefObject).current = node; - }, [ref, listRef]); + }; - const count = visibleOptions.length; + const count = context.visibleOptionIds.length; return (
      extends HTMLAttributes( function ComboboxOption({ +export const ComboboxOption = forwardRef>(function ComboboxOption({ children, value, label, disabled = false, + id: idProp, className, - ...restProps + ...restProps }, ref) { - const id = useId(); - const { - visibleOptions, - registerOption, - highlighting, - onItemClick, - } = useComboboxContext(); + const context = useComboboxContext(); const itemRef = useRef(null); + const generatedId = useId(); + const optionId = idProp ?? `combobox-option-${generatedId}`; - const resolvedDisplay = children ?? label; + const resolvedDisplay: ReactNode = children ?? label; useEffect(() => { - return registerOption({ - id, + return context.registerOption({ + id: optionId, value, label, display: resolvedDisplay, disabled, - ref: itemRef, + ref: itemRef as React.RefObject, }); - }, [value, label, resolvedDisplay, disabled, registerOption]); + }, [optionId, value, label, resolvedDisplay, disabled, context.registerOption]); - const isVisible = visibleOptions.some((o) => o.value === value); - const highlighted = highlighting.value === id; + useEffect(() => { + if (context.highlightedId === optionId) { + itemRef.current?.scrollIntoView?.({ behavior: "smooth", block: "nearest" }); + } + }, [context.highlightedId, optionId]); + + const isVisible = context.visibleOptionIds.includes(optionId); + const isHighlighted = context.highlightedId === optionId; return (
    • ( fu if (typeof ref === "function") ref(node); else if (ref) (ref as RefObject).current = node; }} - id={id} + id={optionId} + hidden={!isVisible} + role="option" - aria-selected={highlighted} + aria-selected={isHighlighted} + aria-disabled={disabled} aria-hidden={!isVisible} + data-name="combobox-option" - data-highlighted={highlighted ? "" : undefined} + data-highlighted={isHighlighted ? "" : undefined} data-visible={isVisible ? "" : undefined} + data-disabled={disabled ? "" : undefined} className={clsx(!isVisible && "hidden", className)} onClick={(event) => { - onItemClick(value); - restProps.onClick?.(event); + if (!disabled) { + context.selectOption(optionId); + restProps.onClick?.(event); + } }} onMouseEnter={(event) => { - highlighting.setValue(value); - restProps.onMouseEnter?.(event); + if (!disabled) { + context.highlightItem(optionId); + restProps.onMouseEnter?.(event); + } }} > {resolvedDisplay}
    • ); -}) +}); ComboboxOption.displayName = "ComboboxOption"; diff --git a/src/components/user-interaction/Combobox/ComboboxRoot.tsx b/src/components/user-interaction/Combobox/ComboboxRoot.tsx index 6c45f7a..368f30b 100644 --- a/src/components/user-interaction/Combobox/ComboboxRoot.tsx +++ b/src/components/user-interaction/Combobox/ComboboxRoot.tsx @@ -1,23 +1,33 @@ -import type { ReactNode } from "react"; -import { useCallback, useState } from "react"; +import type { ReactNode, RefObject } from "react"; +import { useCallback, useId, useMemo, useState } from "react"; import { ComboboxContext } from "./ComboboxContext"; -import type { RegisteredComboboxOption } from "./ComboboxContext"; -import type { UseComboboxProps } from "./useCombobox"; +import type { ComboboxContextConfig, ComboboxContextIds, ComboboxContextLayout, ComboboxContextType, ComboboxOptionType } from "./ComboboxContext"; +import type { UseComboboxOptions } from "./useCombobox"; import { useCombobox } from "./useCombobox"; import { DOMUtils } from "@/src/utils/dom"; -export interface ComboboxRootProps extends Omit { +export interface ComboboxRootProps extends Omit { children: ReactNode; + onItemClick?: (value: T) => void; } -export function ComboboxRoot(props: ComboboxRootProps) { - const { children, ...hookProps } = props; - const [options, setOptions] = useState([]); +export function ComboboxRoot({ + children, + onItemClick, + ...hookProps +}: ComboboxRootProps) { + const [options, setOptions] = useState[]>([]); + const [listRef, setListRef] = useState | null>(null); + const generatedId = useId(); + const [ids, setIds] = useState({ + trigger: `combobox-${generatedId}`, + listbox: `combobox-${generatedId}-listbox`, + }); const registerOption = useCallback( - (option: RegisteredComboboxOption) => { + (option: ComboboxOptionType) => { setOptions((prev) => { - const next = prev.filter((o) => o.value !== option.value); + const next = prev.filter((o) => o.id !== option.id); next.push(option); next.sort((a, b) => DOMUtils.compareDocumentPosition(a.ref.current, b.ref.current) @@ -25,14 +35,101 @@ export function ComboboxRoot(props: ComboboxRootProps) { return next; }); return () => - setOptions((prev) => prev.filter((o) => o.value !== option.value)); + setOptions((prev) => prev.filter((o) => o.id !== option.id)); }, [] ); - const value = useCombobox({ ...hookProps, options }); + const registerList = useCallback((ref: RefObject) => { + setListRef(() => ref); + return () => setListRef(null); + }, []); + + const hookOptions = useMemo( + () => + options.map((o) => ({ + id: o.id, + label: o.label, + disabled: o.disabled, + })), + [options] + ); + + const state = useCombobox({ ...hookProps, options: hookOptions }); + + const idToOptionMap = useMemo(() => { + return options.reduce((acc, o) => { + acc[o.id] = o; + return acc; + }, {} as Record>); + }, [options]); + + const selectOption = useCallback( + (id: string) => { + const option = idToOptionMap[id]; + if (option) onItemClick?.(option.value as T); + }, + [idToOptionMap, onItemClick] + ); + + const config: ComboboxContextConfig = useMemo( + () => ({ ids, setIds }), + [ids, setIds] + ); + + const layout: ComboboxContextLayout = useMemo( + () => ({ + listRef: listRef ?? { current: null }, + registerList, + }), + [listRef, registerList] + ); + + const search = useMemo( + () => ({ + searchQuery: state.searchQuery, + setSearchQuery: state.setSearchQuery, + }), + [state.searchQuery, state.setSearchQuery] + ); + + const contextValue = useMemo( + () => ({ + highlightedId: state.highlightedId, + options, + visibleOptionIds: state.visibleOptionIds, + idToOptionMap, + registerOption, + selectOption, + highlightFirst: state.highlightFirst, + highlightLast: state.highlightLast, + highlightNext: state.highlightNext, + highlightPrevious: state.highlightPrevious, + highlightItem: state.highlightItem, + config, + layout, + search, + }), + [ + state.highlightedId, + state.visibleOptionIds, + state.highlightFirst, + state.highlightLast, + state.highlightNext, + state.highlightPrevious, + state.highlightItem, + options, + idToOptionMap, + registerOption, + selectOption, + config, + layout, + search, + ] + ); + return ( - + }> {children} ); diff --git a/src/components/user-interaction/Combobox/useCombobox.ts b/src/components/user-interaction/Combobox/useCombobox.ts index 55884be..38ec44c 100644 --- a/src/components/user-interaction/Combobox/useCombobox.ts +++ b/src/components/user-interaction/Combobox/useCombobox.ts @@ -1,103 +1,90 @@ -import { RefObject, useEffect, useId, useMemo, useRef } from "react"; +import { useCallback, useMemo } from "react"; import { useListNavigation } from "@/src/hooks/useListNavigation"; import { useControlledState } from "@/src/hooks/useControlledState"; import { MultiSearchWithMapping } from "@/src/utils/simpleSearch"; -import type { - ComboboxContextType, -} from "./ComboboxContext"; -export interface UseComboboxOption { +export interface UseComboboxOption { id: string; - value: T; - label: string; + label?: string; disabled?: boolean; - ref: RefObject; } -export interface UseComboboxConfiguration { - id?: string; +export interface UseComboboxOptions { + options: ReadonlyArray; + searchQuery?: string; + onSearchQueryChange?: (query: string) => void; + initialSearchQuery?: string; } -export interface UseComboboxState { - searchString?: string; - onSearchStringChange?: (value: string) => void; - initialSearchString?: string; +export interface UseComboboxReturn { + searchQuery: string; + setSearchQuery: (query: string) => void; + visibleOptionIds: ReadonlyArray; + highlightedId: string | null; + highlightFirst: () => void; + highlightLast: () => void; + highlightNext: () => void; + highlightPrevious: () => void; + highlightItem: (id: string) => void; } -export interface UseComboboxProps extends UseComboboxConfiguration, UseComboboxState { - options: ReadonlyArray>; - onItemClick: (id: T) => void; -} - -export type UseComboboxResult = Omit, "registerOption">; - -export function useCombobox({ - onItemClick, - id: idProp, +export function useCombobox({ options, - searchString: controlledSearchString, - onSearchStringChange, - initialSearchString = "", -}: UseComboboxProps): UseComboboxResult { - const generatedId = useId(); - const rootId = idProp ?? `combobox-${generatedId}`; - const listboxId = `${rootId}-listbox`; - - const [searchString, setSearchString] = useControlledState({ - value: controlledSearchString, - onValueChange: onSearchStringChange, - defaultValue: initialSearchString, - isControlled: controlledSearchString !== undefined, + searchQuery: controlledSearchQuery, + onSearchQueryChange, + initialSearchQuery = "", +}: UseComboboxOptions): UseComboboxReturn { + const [searchQuery, setSearchQuery] = useControlledState({ + value: controlledSearchQuery, + onValueChange: onSearchQueryChange, + defaultValue: initialSearchQuery, }); - const listRef = useRef(null); - const visibleOptions = useMemo(() => { - const q = (searchString ?? "").trim().toLowerCase(); + const q = (searchQuery ?? "").trim().toLowerCase(); if (!q) return options; - return MultiSearchWithMapping(searchString ?? "", [...options], (o) => [o.label]); - }, [options, searchString]); + return MultiSearchWithMapping(searchQuery ?? "", [...options], (o) => [o.label]); + }, [options, searchQuery]); const visibleOptionIds = useMemo(() => visibleOptions.map((o) => o.id), [visibleOptions]); - const listNav = useListNavigation({ - options: visibleOptionIds, - }); + const enabledOptionIds = useMemo( + () => visibleOptions.filter((o) => !o.disabled).map((o) => o.id), + [visibleOptions] + ); - const lastScrolledId = useRef(null); - useEffect(() => { - if(lastScrolledId.current === listNav.highlightedId) return; - const opt = options.find((o) => o.id === listNav.highlightedId); - if(opt?.ref?.current) { - lastScrolledId.current = listNav.highlightedId; - opt.ref.current.scrollIntoView?.({ behavior: "smooth", block: "nearest" }); - } - }, [listNav.highlightedId, options]); + const listNav = useListNavigation({ options: enabledOptionIds }); - const highlighting = useMemo( - () => ({ - value: listNav.highlightedId ?? undefined, - setValue: (id: string) => { - const option = options.find((o) => o.id === id); - if (option) { - listNav.highlight(id); - } - }, - next: listNav.next, - previous: listNav.previous, - first: listNav.first, - last: listNav.last, - }), - [visibleOptions, listNav] + const highlightItem = useCallback( + (id: string) => { + if (!enabledOptionIds.includes(id)) return; + listNav.highlight(id); + }, + [enabledOptionIds, listNav] ); - return useMemo((): UseComboboxResult => ({ - ids: { root: rootId, listbox: listboxId }, - searchString: searchString ?? "", - setSearchString, - visibleOptions, - highlighting, - onItemClick, - listRef, - }), [rootId, listboxId, searchString, setSearchString, visibleOptions, highlighting, onItemClick, listRef]); + return useMemo( + (): UseComboboxReturn => ({ + searchQuery: searchQuery ?? "", + setSearchQuery, + visibleOptionIds, + highlightedId: listNav.highlightedId, + highlightFirst: listNav.first, + highlightLast: listNav.last, + highlightNext: listNav.next, + highlightPrevious: listNav.previous, + highlightItem, + }), + [ + searchQuery, + setSearchQuery, + visibleOptionIds, + listNav.highlightedId, + listNav.first, + listNav.last, + listNav.next, + listNav.previous, + highlightItem, + ] + ); } diff --git a/src/components/user-interaction/MultiSelect/MultiSelect.tsx b/src/components/user-interaction/MultiSelect/MultiSelect.tsx index ea755bf..6d0379b 100644 --- a/src/components/user-interaction/MultiSelect/MultiSelect.tsx +++ b/src/components/user-interaction/MultiSelect/MultiSelect.tsx @@ -6,15 +6,15 @@ import { MultiSelectButton } from "./MultiSelectButton"; import type { MultiSelectContentProps } from "./MultiSelectContent"; import { MultiSelectContent } from "./MultiSelectContent"; -export interface MultiSelectProps extends MultiSelectRootProps { +export interface MultiSelectProps extends MultiSelectRootProps { contentPanelProps?: MultiSelectContentProps; - buttonProps?: MultiSelectButtonProps; + buttonProps?: MultiSelectButtonProps; } -export const MultiSelect = forwardRef( - function MultiSelect({ children, contentPanelProps, buttonProps, ...props }, ref) { +export const MultiSelect = forwardRef>( + function MultiSelect({ children, contentPanelProps, buttonProps, ...props }: MultiSelectProps, ref) { return ( - + {...props}> {children} diff --git a/src/components/user-interaction/MultiSelect/MultiSelectButton.tsx b/src/components/user-interaction/MultiSelect/MultiSelectButton.tsx index 900630c..8242f10 100644 --- a/src/components/user-interaction/MultiSelect/MultiSelectButton.tsx +++ b/src/components/user-interaction/MultiSelect/MultiSelectButton.tsx @@ -1,22 +1,22 @@ import type { ComponentPropsWithoutRef, ReactNode } from "react"; import { forwardRef, useEffect, useImperativeHandle, useRef } from "react"; import { useMultiSelectContext } from "./MultiSelectContext"; -import clsx from "clsx"; import { useHightideTranslation } from "@/src/i18n/useHightideTranslation"; import { ExpansionIcon } from "@/src/components/display-and-visualization/ExpansionIcon"; import { MultiSelectOptionDisplayContext } from "./MultiSelectOption"; -export interface MultiSelectButtonProps extends ComponentPropsWithoutRef<"div"> { +export interface MultiSelectButtonProps + extends ComponentPropsWithoutRef<"div"> { placeholder?: ReactNode; disabled?: boolean; - selectedDisplay?: (values: string[]) => ReactNode; + selectedDisplay?: (values: T[]) => ReactNode; hideExpansionIcon?: boolean; } export const MultiSelectButton = forwardRef< HTMLDivElement, - MultiSelectButtonProps ->(function MultiSelectButton( + MultiSelectButtonProps +>(function MultiSelectButton( { id, placeholder, @@ -24,37 +24,39 @@ export const MultiSelectButton = forwardRef< selectedDisplay, hideExpansionIcon = false, ...props - }, + }: MultiSelectButtonProps, ref ) { const translation = useHightideTranslation(); - const { state, trigger, setIds, ids } = useMultiSelectContext(); - const { register, unregister, toggleOpen } = trigger; + const context = useMultiSelectContext(); useEffect(() => { - if (id) setIds((prev) => ({ ...prev, trigger: id })); - }, [id, setIds]); + if (id) context.config.setIds((prev) => ({ ...prev, trigger: id })); + }, [id, context.config.setIds]); const innerRef = useRef(null); useImperativeHandle(ref, () => innerRef.current!); useEffect(() => { - register(innerRef); + const unregister = context.layout.registerTrigger(innerRef); return () => unregister(); - }, [register, unregister]); + }, [context.layout.registerTrigger]); - const disabled = !!disabledOverride || !!state.disabled; - const invalid = state.invalid; - const hasValue = state.value.length > 0; + const disabled = !!disabledOverride || !!context.disabled; + const invalid = context.invalid; + const hasValue = context.value.length > 0; + const selectedOptions = context.selectedIds + .map((id) => context.idToOptionMap[id]) + .filter(Boolean); return (
      { props.onClick?.(event); - toggleOpen(!state.isOpen); + context.toggleIsOpen(); }} onKeyDown={(event) => { props.onKeyDown?.(event); @@ -62,23 +64,23 @@ export const MultiSelectButton = forwardRef< switch (event.key) { case "Enter": case " ": - toggleOpen(!state.isOpen); + context.toggleIsOpen(); event.preventDefault(); event.stopPropagation(); break; case "ArrowDown": - toggleOpen(true, { highlightStartPositionBehavior: "first" }); + context.setIsOpen(true, "first"); event.preventDefault(); event.stopPropagation(); break; case "ArrowUp": - toggleOpen(true, { highlightStartPositionBehavior: "last" }); + context.setIsOpen(true, "last"); event.preventDefault(); event.stopPropagation(); break; } }} - data-name={props["data-name"] ?? "select-button"} + data-name={props["data-name"] ?? "multi-select-button"} data-value={hasValue ? "" : undefined} data-disabled={disabled ? "" : undefined} data-invalid={invalid ? "" : undefined} @@ -87,24 +89,24 @@ export const MultiSelectButton = forwardRef< aria-invalid={invalid} aria-disabled={disabled} aria-haspopup="dialog" - aria-expanded={state.isOpen} - aria-controls={state.isOpen ? ids.content : undefined} + aria-expanded={context.isOpen} + aria-controls={context.isOpen ? context.config.ids.content : undefined} > {hasValue - ? selectedDisplay?.(state.value) ?? ( -
      - {state.selectedOptions.map(({ value, display }, index) => ( - - {display} - {index < state.value.length - 1 && ,} + ? selectedDisplay?.(context.value) ?? ( +
      + {selectedOptions.map((opt, index) => ( + + {opt.display} + {index < selectedOptions.length - 1 && ,} ))}
      ) : placeholder ?? translation("clickToSelect")} - {!hideExpansionIcon && } + {!hideExpansionIcon && }
      ); }); diff --git a/src/components/user-interaction/MultiSelect/MultiSelectChipDisplay.tsx b/src/components/user-interaction/MultiSelect/MultiSelectChipDisplay.tsx index be402dc..cf395e0 100644 --- a/src/components/user-interaction/MultiSelect/MultiSelectChipDisplay.tsx +++ b/src/components/user-interaction/MultiSelect/MultiSelectChipDisplay.tsx @@ -19,45 +19,51 @@ export const MultiSelectChipDisplayButton = forwardRef< MultiSelectChipDisplayButtonProps >(function MultiSelectChipDisplayButton({ id, ...props }, ref) { const translation = useHightideTranslation(); - const { state, trigger, item, ids, setIds } = useMultiSelectContext(); - const { register, unregister, toggleOpen } = trigger; + const context = useMultiSelectContext(); useEffect(() => { - if (id) setIds((prev) => ({ ...prev, trigger: id })); - }, [id, setIds]); + if (id) context.config.setIds((prev) => ({ ...prev, trigger: id })); + }, [id, context.config.setIds]); const innerRef = useRef(null); useImperativeHandle(ref, () => innerRef.current!); useEffect(() => { - register(innerRef); + const unregister = context.layout.registerTrigger(innerRef); return () => unregister(); - }, [register, unregister]); + }, [context.layout.registerTrigger]); - const disabled = !!props?.disabled || !!state.disabled; - const invalid = state.invalid; + const disabled = !!props?.disabled || !!context.disabled; + const invalid = context.invalid; + const selectedOptions = context.selectedIds + .map((oid) => context.idToOptionMap[oid]) + .filter(Boolean); return (
      { - toggleOpen(); props.onClick?.(event); + if(event.defaultPrevented) return; + context.toggleIsOpen(); }} - data-name={props["data-name"] ?? "select-chip-display"} - data-value={state.value.length > 0 ? "" : undefined} + data-name={props["data-name"] ?? "multi-select-chip-display-button"} + data-value={context.value.length > 0 ? "" : undefined} data-disabled={disabled ? "" : undefined} data-invalid={invalid ? "" : undefined} aria-invalid={invalid} aria-disabled={disabled} > - {state.selectedOptions.map(({ value, display }) => ( -
      - {display} + {selectedOptions.map((opt) => ( +
      + {opt.display} item.toggleSelection(value, false)} + onClick={(e) => { + context.toggleSelection(opt.id, false) + e.preventDefault(); + }} size="sm" color="negative" coloringStyle="text" @@ -68,18 +74,18 @@ export const MultiSelectChipDisplayButton = forwardRef<
      ))} { event.stopPropagation(); - toggleOpen(); + context.toggleIsOpen(); }} onKeyDown={(event) => { switch (event.key) { case "ArrowDown": - toggleOpen(true, { highlightStartPositionBehavior: "first" }); + context.setIsOpen(true, "first"); break; case "ArrowUp": - toggleOpen(true, { highlightStartPositionBehavior: "last" }); + context.setIsOpen(true, "last"); } }} tooltip={translation("changeSelection")} @@ -88,8 +94,10 @@ export const MultiSelectChipDisplayButton = forwardRef< aria-invalid={invalid} aria-disabled={disabled} aria-haspopup="dialog" - aria-expanded={state.isOpen} - aria-controls={state.isOpen ? ids.content : undefined} + aria-expanded={context.isOpen} + aria-controls={ + context.isOpen ? context.config.ids.content : undefined + } className="size-9" > @@ -98,15 +106,23 @@ export const MultiSelectChipDisplayButton = forwardRef< ); }); -export type MultiSelectChipDisplayProps = MultiSelectRootProps & { +export type MultiSelectChipDisplayProps = MultiSelectRootProps & { contentPanelProps?: MultiSelectContentProps; chipDisplayProps?: MultiSelectChipDisplayButtonProps; }; -export const MultiSelectChipDisplay = forwardRef( - function MultiSelectChipDisplay({ children, contentPanelProps, chipDisplayProps, ...props }, ref) { +export const MultiSelectChipDisplay = forwardRef( + function MultiSelectChipDisplay( + { + children, + contentPanelProps, + chipDisplayProps, + ...props + }: MultiSelectChipDisplayProps, + ref: React.ForwardedRef + ) { return ( - + {...props}> {children} diff --git a/src/components/user-interaction/MultiSelect/MultiSelectContent.tsx b/src/components/user-interaction/MultiSelect/MultiSelectContent.tsx index 9814b41..abcb5e4 100644 --- a/src/components/user-interaction/MultiSelect/MultiSelectContent.tsx +++ b/src/components/user-interaction/MultiSelect/MultiSelectContent.tsx @@ -14,165 +14,195 @@ export interface MultiSelectContentProps extends PopUpProps { searchInputProps?: Omit, "value" | "onValueChange">; } -export const MultiSelectContent = forwardRef( - function MultiSelectContent( - { id, options, showSearch: showSearchOverride, searchInputProps, ...props }, - ref - ) { - const translation = useHightideTranslation(); - const innerRef = useRef(null); - const searchInputRef = useRef(null); - const typeAheadBufferRef = useRef(""); - const typeAheadTimeoutRef = useRef | null>(null); - useImperativeHandle(ref, () => innerRef.current!); +export const MultiSelectContent = forwardRef< + HTMLUListElement, + MultiSelectContentProps +>(function MultiSelectContent( + { id, options, showSearch: showSearchOverride, searchInputProps, ...props }, + ref +) { + const translation = useHightideTranslation(); + const innerRef = useRef(null); + const searchInputRef = useRef(null); + const typeAheadBufferRef = useRef(""); + const typeAheadTimeoutRef = useRef | null>(null); + useImperativeHandle(ref, () => innerRef.current!); - const { trigger, state, item, ids, setIds, search } = useMultiSelectContext(); + const context = useMultiSelectContext(); - useEffect(() => { - if (id) setIds((prev) => ({ ...prev, content: id })); - }, [id, setIds]); + useEffect(() => { + if (id) context.config.setIds((prev) => ({ ...prev, content: id })); + }, [id, context.config.setIds]); - useEffect(() => { - if (!state.isOpen) { - typeAheadBufferRef.current = ""; - if (typeAheadTimeoutRef.current) { - clearTimeout(typeAheadTimeoutRef.current); - typeAheadTimeoutRef.current = null; - } + useEffect(() => { + if (!context.isOpen) { + typeAheadBufferRef.current = ""; + if (typeAheadTimeoutRef.current) { + clearTimeout(typeAheadTimeoutRef.current); + typeAheadTimeoutRef.current = null; } - }, [state.isOpen]); + } + }, [context.isOpen]); - useEffect( - () => () => { - if (typeAheadTimeoutRef.current) clearTimeout(typeAheadTimeoutRef.current); - }, - [] - ); + useEffect( + () => () => { + if (typeAheadTimeoutRef.current) clearTimeout(typeAheadTimeoutRef.current); + }, + [] + ); - const showSearch = showSearchOverride ?? search.showSearch; - const listboxAriaLabel = showSearch ? translation("searchResults") : undefined; + const showSearch = showSearchOverride ?? context.search.hasSearch; + const listboxAriaLabel = showSearch ? translation("searchResults") : undefined; - const keyHandler = useCallback( - (event: React.KeyboardEvent) => { - switch (event.key) { - case "ArrowDown": - item.moveHighlightedIndex(1); + const keyHandler = useCallback( + (event: React.KeyboardEvent) => { + switch (event.key) { + case "ArrowDown": + context.highlightNext(); + event.preventDefault(); + break; + case "ArrowUp": + context.highlightPrevious(); + event.preventDefault(); + break; + case "Home": + event.preventDefault(); + context.highlightFirst(); + break; + case "End": + event.preventDefault(); + context.highlightLast(); + break; + case "Enter": + case " ": + if (showSearch && event.key === " ") return; + if (context.highlightedId) { + context.toggleSelection(context.highlightedId); event.preventDefault(); - break; - case "ArrowUp": - item.moveHighlightedIndex(-1); - event.preventDefault(); - break; - case "Home": - event.preventDefault(); - item.highlightFirst(); - break; - case "End": - event.preventDefault(); - item.highlightLast(); - break; - case "Enter": - case " ": - if (showSearch && event.key === " ") return; - if (state.highlightedValue) { - item.toggleSelection(state.highlightedValue); + } + break; + default: + if (!showSearch && !event.ctrlKey && !event.metaKey && !event.altKey) { + const char = event.key.toLowerCase(); + if (typeAheadTimeoutRef.current) + clearTimeout(typeAheadTimeoutRef.current); + typeAheadBufferRef.current += char; + typeAheadTimeoutRef.current = setTimeout(() => { + typeAheadBufferRef.current = ""; + }, TYPEAHEAD_RESET_MS); + const optionIds = context.visibleOptionIds; + const buf = typeAheadBufferRef.current; + if (optionIds.length === 0) { event.preventDefault(); + return; } - break; - default: - if (!showSearch && !event.ctrlKey && !event.metaKey && !event.altKey) { - const char = event.key.toLowerCase(); - if (typeAheadTimeoutRef.current) clearTimeout(typeAheadTimeoutRef.current); - typeAheadBufferRef.current += char; - typeAheadTimeoutRef.current = setTimeout(() => { - typeAheadBufferRef.current = ""; - }, TYPEAHEAD_RESET_MS); - const opts = state.visibleOptions; - const buf = typeAheadBufferRef.current; - if (opts.length === 0) { + const currentIndex = optionIds.findIndex( + (oid) => oid === context.highlightedId + ); + const startFrom = + currentIndex >= 0 + ? (currentIndex + 1) % optionIds.length + : 0; + for (let i = 0; i < optionIds.length; i++) { + const j = (startFrom + i) % optionIds.length; + const option = context.idToOptionMap[optionIds[j]]; + if ( + option && + !option.disabled && + (option.label ?? "").toLowerCase().startsWith(buf) + ) { + context.highlightItem(option.id); event.preventDefault(); return; } - const currentIndex = opts.findIndex((o) => o.value === state.highlightedValue); - const startFrom = currentIndex >= 0 ? (currentIndex + 1) % opts.length : 0; - for (let i = 0; i < opts.length; i++) { - const j = (startFrom + i) % opts.length; - if (!opts[j].disabled && opts[j].label.toLowerCase().startsWith(buf)) { - item.highlightItem(opts[j].value); - event.preventDefault(); - return; - } - } - event.preventDefault(); } - break; - } - }, - [showSearch, state.visibleOptions, state.highlightedValue, item] - ); + event.preventDefault(); + } + break; + } + }, + [ + showSearch, + context.visibleOptionIds, + context.highlightedId, + context.highlightItem, + context.toggleSelection, + context.highlightNext, + context.highlightPrevious, + context.highlightFirst, + context.highlightLast, + ] + ); - return ( - { - trigger.toggleOpen(false); - props.onClose?.(); - }} - aria-labelledby={ids.trigger} - className="gap-y-1" + return ( + { + context.setIsOpen(false); + props.onClose?.(); + }} + aria-labelledby={context.config.ids.trigger} + className={clsx("gap-y-1", props.className)} + > + {showSearch && ( + + )} +
        - {showSearch && ( - - )} -
          - {props.children} - -
        • 0 })} - > - {translation("nResultsFound", { count: state.visibleOptions.length })} -
        • -
          -
        - - ); - } -); + {props.children} + +
      • 0, + })} + > + {translation("nResultsFound", { + count: context.visibleOptionIds.length, + })} +
      • +
        +
      +
      + ); +}); diff --git a/src/components/user-interaction/MultiSelect/MultiSelectContext.tsx b/src/components/user-interaction/MultiSelect/MultiSelectContext.tsx index 24d4b8b..d0d64f1 100644 --- a/src/components/user-interaction/MultiSelect/MultiSelectContext.tsx +++ b/src/components/user-interaction/MultiSelect/MultiSelectContext.tsx @@ -1,15 +1,14 @@ import type { Dispatch, ReactNode, RefObject, SetStateAction } from "react"; import { createContext, useContext } from "react"; import type { FormFieldInteractionStates } from "@/src/components/form/FieldLayout"; +import { UseMultiSelectFirstHighlightBehavior } from "./useMultiSelect"; -export interface MultiSelectOptionType { - value: string; - label: string; - display: ReactNode; - disabled: boolean; -} - -export interface RegisteredMultiSelectOption extends MultiSelectOptionType { +export interface MultiSelectOptionType { + id: string; + value: T; + label?: string; + display?: ReactNode; + disabled?: boolean; ref: RefObject; } @@ -20,53 +19,62 @@ export interface MultiSelectContextIds { searchInput: string; } -export interface MultiSelectContextState extends FormFieldInteractionStates { +export interface MultiSelectContextInternalState extends FormFieldInteractionStates { + selectedIds: string[]; + highlightedId: string | null; isOpen: boolean; - options: ReadonlyArray; - visibleOptions: ReadonlyArray; - searchQuery: string; - value: string[]; - selectedOptions: ReadonlyArray; - highlightedValue: string | undefined; +} + +export interface MultiSelectContextComputedState { + options: ReadonlyArray>; + visibleOptionIds: ReadonlyArray; + idToOptionMap: Record>; + value: T[]; +} + +export interface MultiSelectContextActions { + registerOption(option: MultiSelectOptionType): () => void; + toggleSelection(id: string, isSelected?: boolean): void; + highlightFirst(): void; + highlightLast(): void; + highlightNext(): void; + highlightPrevious(): void; + highlightItem(id: string): void; + setIsOpen(open: boolean, behavior?: UseMultiSelectFirstHighlightBehavior): void; + toggleIsOpen(behavior?: UseMultiSelectFirstHighlightBehavior): void; +} + +export interface MultiSelectContextLayout { + triggerRef: RefObject; + registerTrigger(element: RefObject): () => void; +} + +export interface MultiSelectContextSearch { + hasSearch: boolean; + searchQuery?: string; + setSearchQuery(query: string): void; } export type MultiSelectIconAppearance = "left" | "right" | "none"; -export interface MultiSelectContextType { +export interface MultiSelectContextConfig { + iconAppearance: MultiSelectIconAppearance; ids: MultiSelectContextIds; setIds: Dispatch>; - state: MultiSelectContextState; - iconAppearance: MultiSelectIconAppearance; - item: { - register: (item: RegisteredMultiSelectOption) => () => void; - toggleSelection: (value: string, isSelected?: boolean) => void; - highlightFirst: () => void; - highlightLast: () => void; - highlightItem: (value: string) => void; - moveHighlightedIndex: (delta: number) => void; - }; - trigger: { - ref: RefObject; - register: (element: RefObject) => void; - unregister: () => void; - toggleOpen: ( - isOpen?: boolean, - options?: { highlightStartPositionBehavior?: "first" | "last" } - ) => void; - }; - search: { - showSearch: boolean; - searchQuery: string; - setSearchQuery: (query: string) => void; - }; } -const MultiSelectContext = createContext(null); +export interface MultiSelectContextType extends MultiSelectContextActions, MultiSelectContextInternalState, MultiSelectContextComputedState { + config: MultiSelectContextConfig; + layout: MultiSelectContextLayout; + search: MultiSelectContextSearch; +} + +const MultiSelectContext = createContext | null>(null); -export function useMultiSelectContext(): MultiSelectContextType { +export function useMultiSelectContext(): MultiSelectContextType { const ctx = useContext(MultiSelectContext); if (!ctx) throw new Error("useMultiSelectContext must be used within MultiSelectRoot"); - return ctx; + return ctx as MultiSelectContextType; } export { MultiSelectContext }; diff --git a/src/components/user-interaction/MultiSelect/MultiSelectOption.tsx b/src/components/user-interaction/MultiSelect/MultiSelectOption.tsx index 088d5ee..2c8bc75 100644 --- a/src/components/user-interaction/MultiSelect/MultiSelectOption.tsx +++ b/src/components/user-interaction/MultiSelect/MultiSelectOption.tsx @@ -1,7 +1,7 @@ import clsx from "clsx"; import { CheckIcon } from "lucide-react"; import type { HTMLAttributes, ReactNode, RefObject } from "react"; -import { createContext, forwardRef, useContext, useEffect, useRef } from "react"; +import { createContext, forwardRef, useContext, useEffect, useId, useRef } from "react"; import type { MultiSelectIconAppearance } from "./MultiSelectContext"; import { useMultiSelectContext } from "./MultiSelectContext"; @@ -20,74 +20,83 @@ export function useMultiSelectOptionDisplayLocation(): MultiSelectOptionDisplayL return context; } -export interface MultiSelectOptionProps extends Omit, "children"> { - value: string; +export interface MultiSelectOptionProps extends HTMLAttributes { + value: T; label: string; disabled?: boolean; iconAppearance?: MultiSelectIconAppearance; - children?: ReactNode; } -export const MultiSelectOption = forwardRef(function MultiSelectOption( - { children, label, value, disabled = false, iconAppearance, className, ...restProps }, +export const MultiSelectOption = forwardRef< + HTMLLIElement, + MultiSelectOptionProps +>(function MultiSelectOption( + { + children, + label, + value, + disabled = false, + iconAppearance, + className, + ...props + }: MultiSelectOptionProps, ref ) { - const { state, item, iconAppearance: ctxIconAppearance } = useMultiSelectContext(); - const { register, toggleSelection, highlightItem } = item; + const context = useMultiSelectContext(); const itemRef = useRef(null); const display: ReactNode = children ?? label; - const iconAppearanceResolved = iconAppearance ?? ctxIconAppearance; + const iconAppearanceResolved = iconAppearance ?? context.config.iconAppearance; + + const generatedId = useId(); + const optionId = props?.id ?? "multi-select-option-" + generatedId; useEffect(() => { - return register({ + return context.registerOption({ + id: optionId, value, label, display, - disabled, + disabled: Boolean(disabled), ref: itemRef as React.RefObject, }); - }, [value, label, disabled, register, display]); + }, [optionId, value, label, disabled, context.registerOption, display]); - const isHighlighted = state.highlightedValue === value; - const isSelected = state.value.includes(value); - const isVisible = state.visibleOptions.some((opt) => opt.value === value); + const isHighlighted = context.highlightedId === optionId; + const isSelected = context.selectedIds.includes(optionId); + const isVisible = context.visibleOptionIds.includes(optionId); return (
    • { itemRef.current = node; if (typeof ref === "function") ref(node); else if (ref) (ref as RefObject).current = node; }} - id={value} + id={optionId} + hidden={!isVisible} role="option" aria-disabled={disabled} aria-selected={isSelected} aria-hidden={!isVisible} + + data-name="multi-select-list-option" data-highlighted={isHighlighted ? "" : undefined} data-selected={isSelected ? "" : undefined} data-disabled={disabled ? "" : undefined} data-visible={isVisible ? "" : undefined} - className={clsx( - "flex-row-1 items-center px-2 py-1 rounded-md", - "data-highlighted:bg-primary/20", - "data-disabled:text-disabled data-disabled:cursor-not-allowed", - "not-data-disabled:cursor-pointer", - !isVisible && "hidden", - className - )} + onClick={(event) => { if (!disabled) { - toggleSelection(value); - restProps.onClick?.(event); + context.toggleSelection(optionId); + props.onClick?.(event); } }} onMouseEnter={(event) => { if (!disabled) { - highlightItem(value); - restProps.onMouseEnter?.(event); + context.highlightItem(optionId); + props.onMouseEnter?.(event); } }} > diff --git a/src/components/user-interaction/MultiSelect/MultiSelectRoot.tsx b/src/components/user-interaction/MultiSelect/MultiSelectRoot.tsx index 728cee0..f37c488 100644 --- a/src/components/user-interaction/MultiSelect/MultiSelectRoot.tsx +++ b/src/components/user-interaction/MultiSelect/MultiSelectRoot.tsx @@ -1,47 +1,207 @@ -import type { ReactNode } from "react"; -import { useCallback, useState } from "react"; +import type { ReactNode, RefObject } from "react"; +import { useCallback, useEffect, useId, useMemo, useState } from "react"; import { MultiSelectContext } from "./MultiSelectContext"; -import type { RegisteredMultiSelectOption } from "./MultiSelectContext"; -import type { UseMultiSelectProps } from "./useMultiSelect"; +import type { MultiSelectContextType, MultiSelectIconAppearance, MultiSelectOptionType } from "./MultiSelectContext"; import { useMultiSelect } from "./useMultiSelect"; import { DOMUtils } from "@/src/utils/dom"; +import type { FormFieldDataHandling } from "@/src/components/form/FormField"; +import type { FormFieldInteractionStates } from "@/src/components/form/FieldLayout"; +import { PopUpContext } from "../../layout"; -export interface MultiSelectRootProps extends Omit { +export interface MultiSelectIds { + trigger: string; + content: string; + listbox: string; + searchInput: string; +} + +export interface MultiSelectRootProps extends Partial>, Partial { + initialValue?: T[]; + compareFunction?: (a: T, b: T) => boolean; + initialIsOpen?: boolean; + onClose?: () => void; + showSearch?: boolean; + iconAppearance?: MultiSelectIconAppearance; children: ReactNode; } -export function MultiSelectRoot(props: MultiSelectRootProps) { - const { children, ...hookProps } = props; - const [options, setOptions] = useState([]); - - const registerOption = useCallback( - (item: RegisteredMultiSelectOption) => { - setOptions((prev) => { - const next = prev.filter((o) => o.value !== item.value); - next.push(item); - next.sort((a, b) => - DOMUtils.compareDocumentPosition(a.ref.current, b.ref.current) - ); - return next; - }); - return () => - setOptions((prev) => prev.filter((o) => o.value !== item.value)); +export function MultiSelectRoot({ + children, + value, + onValueChange, + onEditComplete, + initialValue, + compareFunction, + initialIsOpen = false, + onClose, + showSearch = true, + iconAppearance = "right", + invalid = false, + disabled = false, + readOnly = false, + required = false, +}: MultiSelectRootProps) { + const [triggerRef, setTriggerRef] = useState | null>(null); + const [options, setOptions] = useState[]>([]); + const generatedId = useId(); + const [ids, setIds] = useState({ + trigger: "multi-select-" + generatedId, + content: "multi-select-content-" + generatedId, + listbox: "multi-select-listbox-" + generatedId, + searchInput: "multi-select-search-" + generatedId, + }); + + const registerOption = useCallback((item: MultiSelectOptionType) => { + setOptions((prev) => { + const next = prev.filter((o) => o.id !== item.id); + next.push(item); + next.sort((a, b) => + DOMUtils.compareDocumentPosition(a.ref.current, b.ref.current) + ); + return next; + }); + return () => setOptions((prev) => prev.filter((o) => o.id !== item.id)); + }, []); + + const registerTrigger = useCallback((ref: RefObject) => { + setTriggerRef(ref); + return () => setTriggerRef(null); + }, []); + + const compare = useMemo(() => compareFunction ?? Object.is, [compareFunction]); + + const idToOptionMap = useMemo( + () => + options.reduce( + (acc, o) => { + acc[o.id] = o; + return acc; + }, + {} as Record> + ), + [options] + ); + + const mappedValueIds = useMemo(() => { + if (value == null) return undefined; + return value + .map((v) => options.find((o) => compare(o.value, v))?.id) + .filter((id) => id !== undefined); + }, [options, value, compare]); + + const mappedInitialValueIds = useMemo(() => { + if (initialValue == null) return []; + return initialValue + .map((v) => options.find((o) => compare(o.value, v))?.id) + .filter((id) => id !== undefined); + }, [options, initialValue, compare]); + + const onValueChangeStable = useCallback( + (ids: string[]) => { + const values = ids + .map((id) => idToOptionMap[id]?.value) + .filter((v): v is T => v != null); + onValueChange?.(values); + }, + [idToOptionMap, onValueChange] + ); + + const onEditCompleteStable = useCallback( + (ids: string[]) => { + const values = ids + .map((id) => idToOptionMap[id]?.value) + .filter((v): v is T => v != null); + onEditComplete?.(values); }, - [] + [idToOptionMap, onEditComplete] ); - const value = useMultiSelect({ ...hookProps, options }); + const state = useMultiSelect({ + options: options.map((o) => ({ id: o.id, label: o.label, disabled: o.disabled })), + value: mappedValueIds, + onValueChange: onValueChangeStable, + onEditComplete: onEditCompleteStable, + initialValue: mappedInitialValueIds, + initialIsOpen, + onClose, + }); + + useEffect(() => { + if (showSearch === false) { + state.setSearchQuery(""); + } + }, [showSearch, state.setSearchQuery]); + + const contextValue = useMemo((): MultiSelectContextType => { + const valueT = state.value + .map((id) => idToOptionMap[id]?.value) + .filter((v): v is T => v != null); + return { + invalid, + disabled, + readOnly, + required, + selectedIds: state.value, + highlightedId: state.highlightedId, + isOpen: state.isOpen, + options, + visibleOptionIds: state.visibleOptionIds, + idToOptionMap, + value: valueT, + registerOption, + toggleSelection: state.toggleSelection, + highlightFirst: state.highlightFirst, + highlightLast: state.highlightLast, + highlightNext: state.highlightNext, + highlightPrevious: state.highlightPrevious, + highlightItem: state.highlightItem, + setIsOpen: state.setIsOpen, + toggleIsOpen: state.toggleOpen, + config: { + iconAppearance, + ids, + setIds, + }, + layout: { + triggerRef, + registerTrigger, + }, + search: { + hasSearch: showSearch, + searchQuery: state.searchQuery, + setSearchQuery: state.setSearchQuery, + }, + }; + }, [ + invalid, + disabled, + readOnly, + required, + state, + options, + idToOptionMap, + registerOption, + iconAppearance, + ids, + triggerRef, + registerTrigger, + showSearch, + ]); + return ( - - {children} + }> + + {children} + ); } diff --git a/src/components/user-interaction/MultiSelect/useMultiSelect.ts b/src/components/user-interaction/MultiSelect/useMultiSelect.ts index 8bb5cf3..475d2ba 100644 --- a/src/components/user-interaction/MultiSelect/useMultiSelect.ts +++ b/src/components/user-interaction/MultiSelect/useMultiSelect.ts @@ -1,269 +1,226 @@ -import type { Dispatch, ReactNode, RefObject, SetStateAction } from "react"; import { useCallback, - useEffect, - useId, useMemo, - useRef, useState, } from "react"; -import type { FormFieldInteractionStates } from "@/src/components/form/FieldLayout"; -import type { FormFieldDataHandling } from "@/src/components/form/FormField"; import { useMultiSelection } from "@/src/hooks/useMultiSelection"; import { useListNavigation } from "@/src/hooks/useListNavigation"; import { MultiSearchWithMapping } from "@/src/utils/simpleSearch"; -import type { SelectionOption } from "@/src/hooks/useSingleSelection"; -import type { - MultiSelectContextType, - MultiSelectContextState, - RegisteredMultiSelectOption, -} from "./MultiSelectContext"; -import type { MultiSelectIconAppearance } from "./MultiSelectContext"; +import { useEventCallbackStabilizer } from "@/src/hooks/useEventCallbackStabelizer"; -export interface UseMultiSelectConfiguration extends Partial { - id?: string; +export interface UseMultiSelectOption { + id: string; + label?: string; + disabled?: boolean; +} + +export interface UseMultiSelectOptions { + options: ReadonlyArray; + value?: ReadonlyArray; + onValueChange?: (value: string[]) => void; + onEditComplete?: (value: string[]) => void; + initialValue?: string[]; initialIsOpen?: boolean; - iconAppearance?: MultiSelectIconAppearance; - showSearch?: boolean; onClose?: () => void; } -export interface UseMultiSelectState extends Partial> { - initialValue?: string[]; +export type UseMultiSelectFirstHighlightBehavior = "first" | "last"; + +export interface UseMultiSelectOpenState { + isOpen: boolean; + setIsOpen: (isOpen: boolean, behavior?: UseMultiSelectFirstHighlightBehavior) => void; + toggleOpen: (behavior?: UseMultiSelectFirstHighlightBehavior) => void; } -export interface UseMultiSelectProps extends UseMultiSelectConfiguration, UseMultiSelectState { - options: ReadonlyArray; +export interface UseMultiSelectSearchState { + searchQuery: string; + setSearchQuery: (query: string) => void; } -export type UseMultiSelectResult = Omit & { - item: Omit; -}; +export interface UseMultiSelectHighlightState { + highlightedId: string | null; + highlightFirst: () => void; + highlightLast: () => void; + highlightNext: () => void; + highlightPrevious: () => void; + highlightItem: (id: string) => void; +} -function toSelectionOptions( - options: ReadonlyArray -): ReadonlyArray> { - return options.map((o) => ({ - value: o.value, - label: o.label, - display: o.display, - disabled: o.disabled, - })); +export interface UseMultiSelectSelectionState { + value: string[]; + toggleSelection: (id: string, isSelected?: boolean) => void; + setSelection: (ids: string[]) => void; + isSelected: (id: string) => boolean; } -export function useMultiSelect(props: UseMultiSelectProps): UseMultiSelectResult { - const { - options, - id, - value: controlledValue, - onValueChange, - onEditComplete, - initialValue, - onClose, - initialIsOpen = false, - disabled = false, - readOnly = false, - required = false, - invalid = false, - showSearch = false, - iconAppearance = "left", - } = props; +export interface UseMultiSelectReturn + extends UseMultiSelectOpenState, + UseMultiSelectSearchState, + UseMultiSelectHighlightState, + UseMultiSelectSelectionState { + options: ReadonlyArray; + visibleOptionIds: ReadonlyArray; +} - const triggerRef = useRef(null); - const generatedId = useId(); - const [ids, setIds] = useState({ - trigger: id ?? "multi-select-" + generatedId, - content: "multi-select-content-" + generatedId, - listbox: "multi-select-listbox-" + generatedId, - searchInput: "multi-select-search-" + generatedId, - }); +export function useMultiSelect({ + options, + value: controlledValue, + onValueChange, + onEditComplete, + initialValue = [], + onClose, + initialIsOpen = false, +}: UseMultiSelectOptions): UseMultiSelectReturn { const [isOpen, setIsOpen] = useState(initialIsOpen); const [searchQuery, setSearchQuery] = useState(""); - const selectionOptions = useMemo(() => toSelectionOptions(options), [options]); + const selectionOptions = useMemo( + () => options.map((o) => ({ id: o.id, disabled: o.disabled })), + [options] + ); - const selection = useMultiSelection({ + const selection = useMultiSelection({ options: selectionOptions, value: controlledValue, - onSelectionChange: (v) => { onValueChange?.(Array.from(v)) }, + onSelectionChange: (ids) => onValueChange?.(Array.from(ids)), initialSelection: initialValue ?? [], isControlled: controlledValue !== undefined, }); + const editCompleteStable = useEventCallbackStabilizer(onEditComplete); + const onCloseStable = useEventCallbackStabilizer(onClose); + const visibleOptions = useMemo(() => { const q = searchQuery.trim().toLowerCase(); if (!q) return options; - return MultiSearchWithMapping(searchQuery, [...options], (o) => [o.label]); + return MultiSearchWithMapping(searchQuery, [...options], (o) => [o.label ?? ""]); }, [options, searchQuery]); - const listNav = useListNavigation({ - options: visibleOptions.map((o) => o.value), - initialValue: controlledValue?.[0] ?? initialValue?.[0], - }); + const visibleOptionIds = useMemo( + () => visibleOptions.map((o) => o.id), + [visibleOptions] + ); - const value = useMemo(() => [...selection.selection], [selection.selection]); - const selectedOptions = useMemo( - () => - value - .map((v) => options.find((o) => o.value === v)) - .filter((o): o is RegisteredMultiSelectOption => o != null), - [value, options] + const enabledOptions = useMemo( + () => visibleOptions.filter((o) => !o.disabled), + [visibleOptions] ); - useEffect(() => { - if ( - listNav.highlightedId != null && - !visibleOptions.some((o) => o.value === listNav.highlightedId) - ) { - const first = visibleOptions.find((o) => !o.disabled); - if (first) listNav.highlight(first.value); - } - }, [visibleOptions, listNav.highlightedId, listNav.highlight]); + const listNav = useListNavigation({ + options: enabledOptions.map((o) => o.id), + initialValue: selection.selection[0] ?? null, + }); - useEffect(() => { - const opt = options.find((o) => o.value === listNav.highlightedId); - opt?.ref?.current?.scrollIntoView?.({ behavior: "instant", block: "nearest" }); - }, [listNav.highlightedId, options]); + const highlightState: UseMultiSelectHighlightState = useMemo( + () => ({ + highlightedId: listNav.highlightedId, + highlightFirst: listNav.first, + highlightLast: listNav.last, + highlightNext: listNav.next, + highlightPrevious: listNav.previous, + highlightItem: (id: string) => { + if (!enabledOptions.some((o) => o.id === id)) return; + listNav.highlight(id); + }, + }), + [enabledOptions, listNav] + ); const toggleSelectionValue = useCallback( - (optionValue: string, isSelected?: boolean) => { - if (disabled) return; - const before = selection.isSelected(optionValue); + (id: string, isSelected?: boolean) => { + const before = selection.isSelected(id); const next = isSelected ?? !before; if (next) { - selection.toggleSelection(optionValue); + selection.toggleSelection(id); } else { - selection.setSelection(selection.selection.filter((v) => v !== optionValue)); + selection.setSelection(selection.selection.filter((s) => s !== id)); } - listNav.highlight(optionValue); + highlightState.highlightItem(id); }, - [disabled, selection, listNav] + [selection, highlightState] ); - const highlightItem = useCallback( - (value: string) => { - if ( - disabled || - !visibleOptions.some((o) => o.value === value && !o.disabled) - ) - return; - listNav.highlight(value); - }, - [disabled, visibleOptions, listNav] + const selectionState: UseMultiSelectSelectionState = useMemo( + () => ({ + value: [...selection.selection], + toggleSelection: toggleSelectionValue, + setSelection: (ids: string[]) => selection.setSelection(ids), + isSelected: selection.isSelected, + }), + [selection.selection, selection.setSelection, selection.isSelected, toggleSelectionValue] ); - const highlightFirst = useCallback(() => { - const first = visibleOptions.find((o) => !o.disabled); - if (first) listNav.highlight(first.value); - }, [visibleOptions, listNav]); - - const highlightLast = useCallback(() => { - const last = [...visibleOptions].reverse().find((o) => !o.disabled); - if (last) listNav.highlight(last.value); - }, [visibleOptions, listNav]); - - const moveHighlightedIndex = useCallback( - (delta: number) => { - const list = visibleOptions.filter((o) => !o.disabled); - if (list.length === 0) return; - const idx = list.findIndex((o) => o.value === listNav.highlightedId); - const startIdx = - idx < 0 ? 0 : (idx + (delta % list.length) + list.length) % list.length; - const isForward = delta >= 0; - let nextIdx = startIdx; - for (let i = 0; i < list.length; i++) { - const j = - (startIdx + (isForward ? i : -i) + list.length) % list.length; - if (!list[j].disabled) { - nextIdx = j; - break; + const setIsOpenWrapper = useCallback( + (open: boolean, behavior?: UseMultiSelectFirstHighlightBehavior) => { + setIsOpen(open); + behavior = behavior ?? "first"; + if (open) { + if (enabledOptions.length > 0) { + let selected: UseMultiSelectOption | undefined + if(behavior === "first") { + selected = enabledOptions.find((o) => + selection.isSelected(o.id) + ); + selected ??= enabledOptions[0]; + } else if (behavior === "last") { + selected = [...enabledOptions].reverse().find((o) => + selection.isSelected(o.id) + ); + selected ??= enabledOptions[enabledOptions.length - 1]; + } + if (selected) highlightState.highlightItem(selected.id); } - } - listNav.highlight(list[nextIdx].value); - }, - [visibleOptions, listNav] - ); - - const registerTrigger = useCallback((ref: RefObject) => { - (triggerRef as React.MutableRefObject).current = - ref.current; - }, []); - - const unregisterTrigger = useCallback(() => { - (triggerRef as React.MutableRefObject).current = null; - }, []); - - const toggleOpen = useCallback( - ( - open?: boolean, - opts?: { highlightStartPositionBehavior?: "first" | "last" } - ) => { - const next = open ?? !isOpen; - if (next) { - const behavior = opts?.highlightStartPositionBehavior ?? "first"; - const list = visibleOptions.filter((o) => !o.disabled); - const firstSelected = list.find((o) => selection.isSelected(o.value)); - const fallback = behavior === "first" ? list[0] : list[list.length - 1]; - const toHighlight = firstSelected ?? fallback; - if (toHighlight) listNav.highlight(toHighlight.value); } else { setSearchQuery(""); - onClose?.(); + onCloseStable?.(); + editCompleteStable?.(Array.from(selection.selection)); } - setIsOpen(next); }, - [isOpen, visibleOptions, selection, listNav, onClose] + [ + highlightState, + onCloseStable, + editCompleteStable, + selection.selection, + selection.isSelected, + enabledOptions, + ] ); - const state: MultiSelectContextState = { - isOpen, - options, - visibleOptions, - searchQuery, - value, - selectedOptions, - highlightedValue: listNav.highlightedId ?? undefined, - disabled, - invalid, - readOnly, - required, - }; + const openState: UseMultiSelectOpenState = useMemo( + () => ({ + isOpen, + setIsOpen: setIsOpenWrapper, + toggleOpen: (behavior?: UseMultiSelectFirstHighlightBehavior) => { + setIsOpenWrapper(!isOpen, behavior); + }, + }), + [isOpen, setIsOpenWrapper] + ); + + const searchState: UseMultiSelectSearchState = useMemo( + () => ({ + searchQuery, + setSearchQuery, + }), + [searchQuery, setSearchQuery] + ); return useMemo( - (): UseMultiSelectResult => ({ - ids, - setIds: setIds as Dispatch>, - state, - iconAppearance, - item: { - toggleSelection: toggleSelectionValue, - highlightFirst, - highlightLast, - highlightItem, - moveHighlightedIndex, - }, - trigger: { - ref: triggerRef, - register: registerTrigger, - unregister: unregisterTrigger, - toggleOpen, - }, - search: { showSearch, searchQuery, setSearchQuery }, + (): UseMultiSelectReturn => ({ + ...openState, + ...highlightState, + ...selectionState, + ...searchState, + options, + visibleOptionIds, }), [ - ids, - state, - iconAppearance, - toggleSelectionValue, - highlightFirst, - highlightLast, - highlightItem, - moveHighlightedIndex, - registerTrigger, - unregisterTrigger, - toggleOpen, - showSearch, - searchQuery, + openState, + highlightState, + selectionState, + searchState, + options, + visibleOptionIds, ] ); } diff --git a/src/components/user-interaction/Select/Select.tsx b/src/components/user-interaction/Select/Select.tsx index 682dad1..1961434 100644 --- a/src/components/user-interaction/Select/Select.tsx +++ b/src/components/user-interaction/Select/Select.tsx @@ -1,4 +1,4 @@ -import type { ReactNode } from "react"; +import type { ReactNode, JSX } from "react"; import { forwardRef } from "react"; import type { SelectRootProps } from "./SelectRoot"; import { SelectRoot } from "./SelectRoot"; @@ -6,30 +6,31 @@ import type { SelectButtonProps } from "./SelectButton"; import { SelectButton } from "./SelectButton"; import type { SelectContentProps } from "./SelectContent"; import { SelectContent } from "./SelectContent"; +import { SelectOptionType } from "./SelectContext"; -export type SelectProps = SelectRootProps & { +export type SelectProps = SelectRootProps & { contentPanelProps?: SelectContentProps; - buttonProps?: Omit & { - selectedDisplay?: (value: string) => ReactNode; + buttonProps?: Omit, "selectedDisplay"> & { + selectedDisplay?: (value: SelectOptionType | null) => ReactNode; } & { [key: string]: unknown }; }; -export const Select = forwardRef(function Select( - { children, contentPanelProps, buttonProps, ...props }, +export const Select = forwardRef>(function Select( + { children, contentPanelProps, buttonProps, ...props }: SelectProps, ref ) { + return ( - + {...props}> { - const value = values[0]; + selectedDisplay={(value: SelectOptionType | null) => { if (!buttonProps?.selectedDisplay) return undefined; - return buttonProps.selectedDisplay(value); + return buttonProps.selectedDisplay(value as SelectOptionType); }} /> {children} ); -}); +}) as (props: SelectProps & React.RefAttributes) => JSX.Element; diff --git a/src/components/user-interaction/Select/SelectButton.tsx b/src/components/user-interaction/Select/SelectButton.tsx index 7a868b1..ce17093 100644 --- a/src/components/user-interaction/Select/SelectButton.tsx +++ b/src/components/user-interaction/Select/SelectButton.tsx @@ -1,20 +1,19 @@ import type { ComponentPropsWithoutRef, ReactNode } from "react"; import { forwardRef, useEffect, useImperativeHandle, useRef } from "react"; -import { useSelectContext } from "./SelectContext"; -import clsx from "clsx"; +import { SelectOptionType, useSelectContext } from "./SelectContext"; import { useHightideTranslation } from "@/src/i18n/useHightideTranslation"; import { ExpansionIcon } from "@/src/components/display-and-visualization/ExpansionIcon"; import { SelectOptionDisplayContext } from "./SelectOption"; -export interface SelectButtonProps extends ComponentPropsWithoutRef<"div"> { +export interface SelectButtonProps extends ComponentPropsWithoutRef<"div"> { placeholder?: ReactNode; disabled?: boolean; - selectedDisplay?: (value: string[]) => ReactNode; + selectedDisplay?: (value: SelectOptionType | null) => ReactNode; hideExpansionIcon?: boolean; } -export const SelectButton = forwardRef( - function SelectButton( +export const SelectButton = forwardRef>( + function SelectButton( { id, placeholder, @@ -22,37 +21,37 @@ export const SelectButton = forwardRef( selectedDisplay, hideExpansionIcon = false, ...props - }, + }: SelectButtonProps, ref ) { const translation = useHightideTranslation(); - const { state, trigger, setIds, ids } = useSelectContext(); - const { register, unregister, toggleOpen } = trigger; + const context = useSelectContext(); useEffect(() => { - if (id) setIds((prev) => ({ ...prev, trigger: id })); - }, [id, setIds]); + if (id) context.config.setIds((prev) => ({ ...prev, trigger: id })); + }, [id, context.config.setIds]); const innerRef = useRef(null); useImperativeHandle(ref, () => innerRef.current!); useEffect(() => { - register(innerRef); + const unregister = context.layout.registerTrigger(innerRef); return () => unregister(); - }, [register, unregister]); + }, [context.layout.registerTrigger]); - const disabled = !!disabledOverride || !!state.disabled; - const invalid = state.invalid; - const hasValue = state.value.length > 0; + const disabled = !!disabledOverride || !!context.disabled; + const invalid = context.invalid; + const hasValue = context.selectedId !== null; + const selectedOption = context.idToOptionMap[context.selectedId] ?? null; return (
      { props.onClick?.(event); - toggleOpen(!state.isOpen); + context.toggleIsOpen(); }} onKeyDown={(event) => { props.onKeyDown?.(event); @@ -60,17 +59,17 @@ export const SelectButton = forwardRef( switch (event.key) { case "Enter": case " ": - toggleOpen(!state.isOpen); + context.toggleIsOpen(); event.preventDefault(); event.stopPropagation(); break; case "ArrowDown": - toggleOpen(true, { highlightStartPositionBehavior: "first" }); + context.setIsOpen(true, "first"); event.preventDefault(); event.stopPropagation(); break; case "ArrowUp": - toggleOpen(true, { highlightStartPositionBehavior: "last" }); + context.setIsOpen(true, "last"); event.preventDefault(); event.stopPropagation(); break; @@ -85,24 +84,15 @@ export const SelectButton = forwardRef( aria-invalid={invalid} aria-disabled={disabled} aria-haspopup="dialog" - aria-expanded={state.isOpen} - aria-controls={state.isOpen ? ids.content : undefined} + aria-expanded={context.isOpen} + aria-controls={context.isOpen ? context.config.ids.content : undefined} > {hasValue - ? selectedDisplay?.(state.value) ?? ( -
      - {state.selectedOptions.map(({ value, display }, index) => ( - - {display} - {index < state.value.length - 1 && ,} - - ))} -
      - ) + ? selectedDisplay?.(selectedOption) ?? (selectedOption.display) : placeholder ?? translation("clickToSelect")}
      - {!hideExpansionIcon && } + {!hideExpansionIcon && }
      ); } diff --git a/src/components/user-interaction/Select/SelectContent.tsx b/src/components/user-interaction/Select/SelectContent.tsx index cee08ea..ccf19b3 100644 --- a/src/components/user-interaction/Select/SelectContent.tsx +++ b/src/components/user-interaction/Select/SelectContent.tsx @@ -14,10 +14,9 @@ export interface SelectContentProps extends PopUpProps { searchInputProps?: Omit, "value" | "onValueChange">; } -export const SelectContent = forwardRef(function SelectContent( - { id, options, showSearch: showSearchOverride, searchInputProps, ...props }, - ref -) { +export const SelectContent = forwardRef(function SelectContent({ + id, options, showSearch: showSearchOverride, searchInputProps, ...props +}, ref) { const translation = useHightideTranslation(); const innerRef = useRef(null); const searchInputRef = useRef(null); @@ -25,21 +24,21 @@ export const SelectContent = forwardRef(fu const typeAheadTimeoutRef = useRef | null>(null); useImperativeHandle(ref, () => innerRef.current!); - const { trigger, state, item, ids, setIds, search } = useSelectContext(); + const context = useSelectContext(); useEffect(() => { - if (id) setIds((prev) => ({ ...prev, content: id })); - }, [id, setIds]); + if (id) context.config.setIds((prev) => ({ ...prev, content: id })); + }, [id, context.config.setIds]); useEffect(() => { - if (!state.isOpen) { + if (!context.isOpen) { typeAheadBufferRef.current = ""; if (typeAheadTimeoutRef.current) { clearTimeout(typeAheadTimeoutRef.current); typeAheadTimeoutRef.current = null; } } - }, [state.isOpen]); + }, [context.isOpen]); useEffect( () => () => { @@ -48,34 +47,33 @@ export const SelectContent = forwardRef(fu [] ); - const showSearch = showSearchOverride ?? search.showSearch; + const showSearch = showSearchOverride ?? context.search.hasSearch; const listboxAriaLabel = showSearch ? translation("searchResults") : undefined; const keyHandler = useCallback( (event: React.KeyboardEvent) => { switch (event.key) { case "ArrowDown": - item.moveHighlightedIndex(1); + context.highlightNext(); event.preventDefault(); break; case "ArrowUp": - item.moveHighlightedIndex(-1); + context.highlightPrevious(); event.preventDefault(); break; case "Home": event.preventDefault(); - item.highlightFirst(); + context.highlightFirst(); break; case "End": event.preventDefault(); - item.highlightLast(); + context.highlightLast(); break; case "Enter": case " ": if (showSearch && event.key === " ") return; - if (state.highlightedValue) { - item.toggleSelection(state.highlightedValue); - trigger.toggleOpen(false); + if (context.highlightedId) { + context.toggleSelection(context.highlightedId); event.preventDefault(); } break; @@ -87,18 +85,19 @@ export const SelectContent = forwardRef(fu typeAheadTimeoutRef.current = setTimeout(() => { typeAheadBufferRef.current = ""; }, TYPEAHEAD_RESET_MS); - const opts = state.visibleOptions; + const optionIds = context.visibleOptionIds; const buf = typeAheadBufferRef.current; - if (opts.length === 0) { + if (optionIds.length === 0) { event.preventDefault(); return; } - const currentIndex = opts.findIndex((o) => o.value === state.highlightedValue); - const startFrom = currentIndex >= 0 ? (currentIndex + 1) % opts.length : 0; - for (let i = 0; i < opts.length; i++) { - const j = (startFrom + i) % opts.length; - if (!opts[j].disabled && opts[j].label.toLowerCase().startsWith(buf)) { - item.highlightItem(opts[j].value); + const currentIndex = optionIds.findIndex((id) => id === context.highlightedId); + const startFrom = currentIndex >= 0 ? (currentIndex + 1) % optionIds.length : 0; + for (let i = 0; i < optionIds.length; i++) { + const j = (startFrom + i) % optionIds.length; + const option = context.idToOptionMap[optionIds[j]]; + if (!option.disabled && option.label.toLowerCase().startsWith(buf)) { + context.highlightItem(option.id); event.preventDefault(); return; } @@ -108,39 +107,39 @@ export const SelectContent = forwardRef(fu break; } }, - [showSearch, state.visibleOptions, state.highlightedValue, item, trigger] + [showSearch, context.visibleOptionIds, context.highlightedId, context.highlightItem, context.toggleSelection] ); return ( { - trigger.toggleOpen(false); + context.setIsOpen(false); props.onClose?.(); }} - aria-labelledby={ids.trigger} - className="gap-y-1" + aria-labelledby={context.config.ids.trigger} + className={clsx("gap-y-1", props.className)} > {showSearch && ( (fu )}
        (fu aria-live="polite" aria-atomic={true} data-name="select-list-status" - className={clsx({ "sr-only": state.visibleOptions.length > 0 })} + className={clsx({ "sr-only": context.visibleOptionIds.length > 0 })} > - {translation("nResultsFound", { count: state.visibleOptions.length })} + {translation("nResultsFound", { count: context.visibleOptionIds.length })}
      diff --git a/src/components/user-interaction/Select/SelectContext.tsx b/src/components/user-interaction/Select/SelectContext.tsx index 9ec8839..48a5715 100644 --- a/src/components/user-interaction/Select/SelectContext.tsx +++ b/src/components/user-interaction/Select/SelectContext.tsx @@ -1,15 +1,14 @@ import type { Dispatch, ReactNode, RefObject, SetStateAction } from "react"; import { createContext, useContext } from "react"; -import type { FormFieldInteractionStates } from "@/src/components/form/FieldLayout"; +import { UseSelectFirstHighlightBehavior } from "./useSelect"; +import { FormFieldInteractionStates } from "../../form/FieldLayout"; -export interface SelectOptionType { - value: string; - label: string; - display: ReactNode; - disabled: boolean; -} - -export interface RegisteredSelectOption extends SelectOptionType { +export interface SelectOptionType { + id: string; + value: T; + label?: string; + display?: ReactNode; + disabled?: boolean; ref: RefObject; } @@ -20,53 +19,59 @@ export interface SelectContextIds { searchInput: string; } -export interface SelectContextState extends FormFieldInteractionStates { +export interface SelectContextInternalState extends FormFieldInteractionStates { + selectedId: string | null; + highlightedId: string | null; isOpen: boolean; - options: ReadonlyArray; - visibleOptions: ReadonlyArray; - searchQuery: string; - value: string[]; - selectedOptions: ReadonlyArray; - highlightedValue: string | undefined; +} + +export interface SelectContextComputedState { + options: ReadonlyArray>; + visibleOptionIds: ReadonlyArray; + idToOptionMap: Record>; +} + +export interface SelectContextActions { + registerOption(option: SelectOptionType): () => void; + toggleSelection(id: string): void; + highlightFirst(): void; + highlightLast(): void; + highlightNext(): void; + highlightPrevious(): void; + highlightItem(id: string): void; + setIsOpen(open: boolean, behavior?: UseSelectFirstHighlightBehavior): void; + toggleIsOpen(behavior?: UseSelectFirstHighlightBehavior): void; +} + +export interface SelectContextLayout { + triggerRef: RefObject; + registerTrigger(element: RefObject): () => void; +} + +export interface SelectContextSearch { + hasSearch: boolean, + searchQuery?: string, + setSearchQuery(query: string): void; } export type SelectIconAppearance = "left" | "right" | "none"; -export interface SelectContextType { +export interface SelectContextConfig { + iconAppearance: SelectIconAppearance; ids: SelectContextIds; setIds: Dispatch>; - state: SelectContextState; - iconAppearance: SelectIconAppearance; - item: { - register: (item: RegisteredSelectOption) => () => void; - toggleSelection: (value: string) => void; - highlightFirst: () => void; - highlightLast: () => void; - highlightItem: (value: string) => void; - moveHighlightedIndex: (delta: number) => void; - }; - trigger: { - ref: RefObject; - register: (element: RefObject) => void; - unregister: () => void; - toggleOpen: ( - isOpen?: boolean, - options?: { highlightStartPositionBehavior?: "first" | "last" } - ) => void; - }; - search: { - showSearch: boolean; - searchQuery: string; - setSearchQuery: (query: string) => void; - }; } -const SelectContext = createContext(null); +export interface SelectContextType extends SelectContextActions, SelectContextInternalState, SelectContextComputedState { + config: SelectContextConfig; + layout: SelectContextLayout; + search: SelectContextSearch; +} + +export const SelectContext = createContext | null>(null); -export function useSelectContext(): SelectContextType { +export function useSelectContext(): SelectContextType { const ctx = useContext(SelectContext); if (!ctx) throw new Error("useSelectContext must be used within SelectRoot"); - return ctx; + return ctx as SelectContextType; } - -export { SelectContext }; diff --git a/src/components/user-interaction/Select/SelectOption.tsx b/src/components/user-interaction/Select/SelectOption.tsx index fac5908..56a7b55 100644 --- a/src/components/user-interaction/Select/SelectOption.tsx +++ b/src/components/user-interaction/Select/SelectOption.tsx @@ -1,7 +1,7 @@ import clsx from "clsx"; import { CheckIcon } from "lucide-react"; import type { HTMLAttributes, ReactNode, RefObject } from "react"; -import { createContext, forwardRef, useContext, useEffect, useRef } from "react"; +import { createContext, forwardRef, useContext, useEffect, useId, useRef } from "react"; import type { SelectIconAppearance } from "./SelectContext"; import { useSelectContext } from "./SelectContext"; @@ -17,86 +17,88 @@ export function useSelectOptionDisplayLocation(): SelectOptionDisplayLocation { return context; } -export interface SelectOptionProps extends Omit, "children"> { - value: string; +export interface SelectOptionProps extends HTMLAttributes { + value: T; label: string; disabled?: boolean; iconAppearance?: SelectIconAppearance; - children?: ReactNode; } -export const SelectOption = forwardRef(function SelectOption( - { children, label, value, disabled = false, iconAppearance, className, ...restProps }, - ref -) { - const { state, item, trigger, iconAppearance: ctxIconAppearance } = useSelectContext(); - const { register, toggleSelection, highlightItem } = item; +export const SelectOption = forwardRef>(function SelectOption({ + children, + label, + value, + disabled = false, + iconAppearance, + className, + ...props +}: SelectOptionProps, ref) { + const context= useSelectContext(); const itemRef = useRef(null); const display: ReactNode = children ?? label; - const iconAppearanceResolved = iconAppearance ?? ctxIconAppearance; + const iconAppearanceResolved = iconAppearance ?? context.config.iconAppearance; + + const generatedId = useId(); + const optionId = props?.id ?? "select-option-" + generatedId; useEffect(() => { - return register({ + return context.registerOption({ + id: optionId, value, label, display, disabled: disabled, ref: itemRef as React.RefObject, }); - }, [value, label, disabled, register, display]); + }, [value, label, disabled, context.registerOption, display]); - const isHighlighted = state.highlightedValue === value; - const isSelected = state.value.includes(value); - const isVisible = state.visibleOptions.some((opt) => opt.value === value); + const isHighlighted = context.highlightedId === optionId; + const isSelected = context.selectedId === optionId; + const isVisible = context.visibleOptionIds.includes(optionId); return (
    • { itemRef.current = node; if (typeof ref === "function") ref(node); else if (ref) (ref as RefObject).current = node; }} - id={value} + id={optionId} + hidden={!isVisible} role="option" aria-disabled={disabled} aria-selected={isSelected} aria-hidden={!isVisible} + + data-name="select-list-option" data-highlighted={isHighlighted ? "" : undefined} data-selected={isSelected ? "" : undefined} data-disabled={disabled ? "" : undefined} data-visible={isVisible ? "" : undefined} - className={clsx( - "flex-row-1 items-center px-2 py-1 rounded-md", - "data-highlighted:bg-primary/20", - "data-disabled:text-disabled data-disabled:cursor-not-allowed", - "not-data-disabled:cursor-pointer", - !isVisible && "hidden", - className - )} + onClick={(event) => { if (!disabled) { - toggleSelection(value); - trigger.toggleOpen(false); - restProps.onClick?.(event); + context.toggleSelection(optionId); + props.onClick?.(event); } }} onMouseEnter={(event) => { if (!disabled) { - highlightItem(value); - restProps.onMouseEnter?.(event); + context.highlightItem(optionId); + props.onMouseEnter?.(event); } }} > - {iconAppearanceResolved === "left" && state.value.length > 0 && ( + {iconAppearanceResolved === "left" && context.selectedId !== null && ( )} {display} - {iconAppearanceResolved === "right" && state.value.length > 0 && ( + {iconAppearanceResolved === "right" && context.selectedId !== null && ( { +export interface SelectIds { + trigger: string; + content: string; + listbox: string; + searchInput: string; +} + +export interface SelectRootProps extends Partial>, Partial { + value?: T | null; + initialValue?: T | null; + compareFunction?: (a: T | null, b: T | null) => boolean; + initialIsOpen?: boolean; + onClose?: () => void; + onIsOpenChange?: (isOpen: boolean) => void; + showSearch?: boolean; + iconAppearance?: "left" | "right" | "none"; children: ReactNode; } -export function SelectRoot(props: SelectRootProps) { - const { children, ...hookProps } = props; - const [options, setOptions] = useState([]); +export function SelectRoot({ + children, + value, + onValueChange, + onEditComplete, + initialValue, + compareFunction, + initialIsOpen = false, + onClose, + onIsOpenChange, + showSearch = true, + iconAppearance = "right", + invalid = false, + disabled = false, + readOnly = false, + required = false, +}: SelectRootProps) { + const [triggerRef, setTriggerRef] = useState | null>(null); + const [options, setOptions] = useState[]>([]); + const generatedId = useId(); + const [ids, setIds] = useState({ + trigger: "select-" + generatedId, + content: "select-content-" + generatedId, + listbox: "select-listbox-" + generatedId, + searchInput: "select-search-" + generatedId, + }); + const registerOption = useCallback( - (item: RegisteredSelectOption) => { + (item: SelectOptionType) => { setOptions((prev) => { const next = prev.filter((o) => o.value !== item.value); next.push(item); @@ -30,18 +72,126 @@ export function SelectRoot(props: SelectRootProps) { [] ); - const value = useSelect({ ...hookProps, options }); + const registerTrigger = useCallback((ref: RefObject) => { + setTriggerRef(ref); + return () => { + setTriggerRef(null); + }; + }, []); + + const compare = useMemo(() => compareFunction ?? Object.is, [compareFunction]); + + const idToOptionMap = useMemo(() => { + return options.reduce((acc, o) => { + acc[o.id] = o; + return acc; + }, {} as Record>); + }, [options]); + + const mappedValueId = useMemo(() => { + if(value === undefined) return undefined; + return options.find((o) => compare(o.value, value))?.id ?? null; + }, [options, value, compare]); + + const mappedInitialValueId = useMemo(() => { + if(initialValue === undefined) return undefined; + return options.find((o) => compare(o.value, initialValue))?.id ?? null; + }, [options, initialValue, compare]); + + const onValueChangeStable = useEventCallbackStabilizer(onValueChange); + const onEditCompleteStable = useEventCallbackStabilizer(onEditComplete); + const onIsOpenChangeStable = useEventCallbackStabilizer(onIsOpenChange); + + const onValueChangeWrapper = useCallback((value: string) => { + const option = idToOptionMap[value] + if(option === undefined) { + console.warn(`Attempted to select an option ${value} that is not valid`); + return; + } + onValueChangeStable(option.value); + }, [onValueChangeStable, idToOptionMap]); + + const onEditCompleteWrapper = useCallback((value: string) => { + const option = idToOptionMap[value] + if(option === undefined) { + console.warn(`Attempted to edit complete an option ${value} that is not valid`); + return; + } + onEditCompleteStable(option.value); + }, [onEditCompleteStable, idToOptionMap]); + + + const state = useSelect({ + value: mappedValueId, + initialValue: mappedInitialValueId, + onValueChange: onValueChangeWrapper, + onEditComplete: onEditCompleteWrapper, + options, + initialIsOpen, + onClose, + onIsOpenChange: onIsOpenChangeStable, + }); + + useEffect(() => { + if(showSearch === false) { + state.setSearchQuery(""); + } + }, [showSearch]); + + const config: SelectContextConfig = useMemo(() => ({ + iconAppearance, + ids, + setIds, + }), [iconAppearance, ids, setIds]); + + const layout: SelectContextLayout = useMemo(() => ({ + triggerRef, + registerTrigger, + }), [triggerRef, registerTrigger]); + return ( + {children} + ); } diff --git a/src/components/user-interaction/Select/useSelect.ts b/src/components/user-interaction/Select/useSelect.ts index 4d9514c..0385617 100644 --- a/src/components/user-interaction/Select/useSelect.ts +++ b/src/components/user-interaction/Select/useSelect.ts @@ -1,267 +1,167 @@ -import type { Dispatch, ReactNode, RefObject, SetStateAction } from "react"; import { useCallback, - useEffect, - useId, useMemo, - useRef, useState, } from "react"; -import type { FormFieldInteractionStates } from "@/src/components/form/FieldLayout"; -import type { FormFieldDataHandling } from "@/src/components/form/FormField"; import { useSingleSelection } from "@/src/hooks/useSingleSelection"; import { useListNavigation } from "@/src/hooks/useListNavigation"; import { MultiSearchWithMapping } from "@/src/utils/simpleSearch"; -import type { SelectionOption } from "@/src/hooks/useSingleSelection"; -import type { - SelectContextType, - SelectContextState, - SelectIconAppearance, - RegisteredSelectOption, -} from "./SelectContext"; +import { useEventCallbackStabilizer } from "@/src/hooks/useEventCallbackStabelizer"; -export interface UseSelectConfiguration extends Partial { - id?: string; +export interface UseSelectOption { + id: string; + label?: string; + disabled?: boolean; +} + +export interface UseSelectOptions { + options: ReadonlyArray; + value?: string | null; + onValueChange?: (value: string) => void; + onEditComplete?: (value: string) => void; + initialValue?: string | null; initialIsOpen?: boolean; - iconAppearance?: SelectIconAppearance; - showSearch?: boolean; - + onClose?: () => void; + onIsOpenChange?: (isOpen: boolean) => void; } -export interface UseSelectState extends Partial> { - initialValue?: string; +export type UseSelectFirstHighlightBehavior = "first" | "last"; + +export interface UseSelectOpenState { + isOpen: boolean; + setIsOpen: (isOpen: boolean, behavior?: UseSelectFirstHighlightBehavior) => void; + toggleOpen: (behavior?: UseSelectFirstHighlightBehavior) => void; } -export interface UseSelectProps extends UseSelectConfiguration, UseSelectState { - onClose?: () => void; - options: ReadonlyArray; +export interface UseSelectSearchState { + searchQuery: string; + setSearchQuery: (query: string) => void; } -export type UseSelectResult = Omit & { - item: Omit; -}; +export interface UseSelectHighlightState { + highlightedValue: string | undefined; + highlightFirst: () => void; + highlightLast: () => void; + highlightNext: () => void; + highlightPrevious: () => void; + highlightItem: (value: string) => void; +} -function toSelectionOptions( - options: ReadonlyArray -): ReadonlyArray> { - return options.map((o) => ({ - value: o.value, - label: o.label, - display: o.display, - disabled: o.disabled, - })); +export interface UseSelectSelectionState { + value: string | null; + selectValue: (value: string) => void; } -export function useSelect(props: UseSelectProps): UseSelectResult { - const { - options, - id, - value: controlledValue, - onValueChange, - onEditComplete, - initialValue, - onClose, - initialIsOpen = false, - disabled = false, - readOnly = false, - required = false, - invalid = false, - showSearch = false, - iconAppearance = "left", - } = props; +export interface UseSelectReturn extends UseSelectOpenState, UseSelectSearchState, UseSelectHighlightState, UseSelectSelectionState { + options: ReadonlyArray; + visibleOptionIds: ReadonlyArray; +} - const triggerRef = useRef(null); - const generatedId = useId(); - const [ids, setIds] = useState({ - trigger: id ?? "select-" + generatedId, - content: "select-content-" + generatedId, - listbox: "select-listbox-" + generatedId, - searchInput: "select-search-" + generatedId, - }); - const [isOpen, setIsOpen] = useState(initialIsOpen); +export function useSelect({ + options, + value: controlledValue, + onValueChange, + onEditComplete, + initialValue = null, + onClose, + onIsOpenChange, + initialIsOpen = false, +}: UseSelectOptions): UseSelectReturn { + const [isOpen, setIsOpen] = useState(initialIsOpen); const [searchQuery, setSearchQuery] = useState(""); - const selectionOptions = useMemo( - () => toSelectionOptions(options), - [options] - ); + const onValueChangeStable = useEventCallbackStabilizer(onValueChange); + const onEditCompleteStable = useEventCallbackStabilizer(onEditComplete); + const onCloseStable = useEventCallbackStabilizer(onClose); + const onIsOpenChangeStable = useEventCallbackStabilizer(onIsOpenChange); + + const onSelectionChangeWrapper = useCallback((id: string | null) => { + if(id === null) return; + onValueChangeStable(id) + onEditCompleteStable(id) + setIsOpen(false); + }, [onValueChangeStable, onEditCompleteStable, setIsOpen]); const selection = useSingleSelection({ - options: selectionOptions, - value: controlledValue !== undefined ? controlledValue : null, - onSelectionChange: (v) => { - onValueChange?.(v); - onEditComplete?.(v); - }, - initialSelection: initialValue ?? null, - isControlled: controlledValue !== undefined, + options: options, + selection: controlledValue, + onSelectionChange: onSelectionChangeWrapper, + initialSelection: initialValue, }); - + const visibleOptions = useMemo(() => { const q = searchQuery.trim().toLowerCase(); if (!q) return options; return MultiSearchWithMapping(searchQuery, [...options], (o) => [o.label]); }, [options, searchQuery]); + const visibleOptionIds = useMemo(() => visibleOptions.map((o) => o.id), [visibleOptions]); + + const enabledOptions = useMemo(() => visibleOptions.filter((o) => !o.disabled), [visibleOptions]); + const listNav = useListNavigation({ - options: visibleOptions.map((o) => o.value), - initialValue: controlledValue ?? initialValue ?? undefined, + options: enabledOptions.map((o) => o.id), + initialValue: selection.selection, }); - const selectedOptions = useMemo( - () => - selection.selection != null - ? options.filter((o) => o.value === selection.selection) - : [], - [selection.selection, options] - ); - const value = useMemo( - () => (selection.selection != null ? [selection.selection] : []), - [selection.selection] - ); - - useEffect(() => { - if ( - listNav.highlightedId != null && - !visibleOptions.some((o) => o.value === listNav.highlightedId) - ) { - const first = visibleOptions.find((o) => !o.disabled); - if (first) listNav.highlight(first.value); + const highlightState: UseSelectHighlightState = useMemo(() => ({ + highlightedValue: listNav.highlightedId, + highlightFirst: listNav.first, + highlightLast: listNav.last, + highlightNext: listNav.next, + highlightPrevious: listNav.previous, + highlightItem: (value: string) => { + if (!enabledOptions.some((o) => o.id === value)) return; + listNav.highlight(value) } - }, [visibleOptions, listNav.highlightedId, listNav.highlight]); - - useEffect(() => { - const opt = options.find((o) => o.value === listNav.highlightedId); - opt?.ref?.current?.scrollIntoView?.({ behavior: "instant", block: "nearest" }); - }, [listNav.highlightedId, options]); - - const toggleSelection = useCallback( - (value: string) => { - if (disabled) return; - selection.changeSelection(value); - setIsOpen((prev) => (prev ? false : prev)); - }, - [disabled, selection] - ); - - const highlightItem = useCallback( - (value: string) => { - if (disabled || !visibleOptions.some((o) => o.value === value && !o.disabled)) - return; - listNav.highlight(value); - }, - [disabled, visibleOptions, listNav] - ); - - const highlightFirst = useCallback(() => { - const first = visibleOptions.find((o) => !o.disabled); - if (first) listNav.highlight(first.value); - }, [visibleOptions, listNav]); - - const highlightLast = useCallback(() => { - const last = [...visibleOptions].reverse().find((o) => !o.disabled); - if (last) listNav.highlight(last.value); - }, [visibleOptions, listNav]); - - const moveHighlightedIndex = useCallback( - (delta: number) => { - const list = visibleOptions.filter((o) => !o.disabled); - if (list.length === 0) return; - const idx = list.findIndex((o) => o.value === listNav.highlightedId); - const startIdx = - idx < 0 ? 0 : (idx + (delta % list.length) + list.length) % list.length; - const isForward = delta >= 0; - let nextIdx = startIdx; - for (let i = 0; i < list.length; i++) { - const j = - (startIdx + (isForward ? i : -i) + list.length) % list.length; - if (!list[j].disabled) { - nextIdx = j; - break; + }), [enabledOptions, listNav]); + + const setIsOpenWrapper = useCallback((isOpen: boolean, behavior?: UseSelectFirstHighlightBehavior) => { + behavior = behavior ?? "first"; + if(isOpen) { + if(selection.selection == null) { + if(behavior === "first") { + highlightState.highlightFirst(); + } else if (behavior === "last") { + highlightState.highlightLast(); } + } else { + highlightState.highlightItem(selection.selection); } - listNav.highlight(list[nextIdx].value); - }, - [visibleOptions, listNav] - ); - - const registerTrigger = useCallback((ref: RefObject) => { - (triggerRef as React.MutableRefObject).current = - ref.current; - }, []); + } else { + setSearchQuery(""); + onCloseStable?.(); + } + setIsOpen(isOpen); + onIsOpenChangeStable(isOpen); + }, [setIsOpen, highlightState, onCloseStable, onEditCompleteStable, selection.selection, onIsOpenChangeStable]); - const unregisterTrigger = useCallback(() => { - (triggerRef as React.MutableRefObject).current = null; - }, []); - const toggleOpen = useCallback( - (open?: boolean, opts?: { highlightStartPositionBehavior?: "first" | "last" }) => { - const next = open ?? !isOpen; - if (next) { - const behavior = opts?.highlightStartPositionBehavior ?? "first"; - const list = visibleOptions.filter((o) => !o.disabled); - const firstSelected = list.find((o) => o.value === selection.selection); - const fallback = behavior === "first" ? list[0] : list[list.length - 1]; - const toHighlight = firstSelected ?? fallback; - if (toHighlight) listNav.highlight(toHighlight.value); - } else { - setSearchQuery(""); - onClose?.(); - } - setIsOpen(next); - }, - [isOpen, visibleOptions, selection.selection, listNav, onClose] - ); + const selectionState: UseSelectSelectionState = useMemo(() => ({ + value: selection.selection, + selectValue: (id: string) => selection.selectValue(id), + }), [selection.selection, selection.selectValue]); - const state: SelectContextState = { + const openState: UseSelectOpenState = useMemo(() => ({ isOpen, - options, - visibleOptions, - searchQuery, - value, - selectedOptions, - highlightedValue: listNav.highlightedId ?? undefined, - disabled, - invalid, - readOnly, - required, - }; + setIsOpen: setIsOpenWrapper, + toggleOpen: (behavior?: UseSelectFirstHighlightBehavior) => { + const next = !isOpen; + setIsOpenWrapper(next, behavior); + } + }), [isOpen, setIsOpenWrapper]); - return useMemo( - (): UseSelectResult => ({ - ids, - setIds: setIds as Dispatch>, - state, - iconAppearance, - item: { - toggleSelection, - highlightFirst, - highlightLast, - highlightItem, - moveHighlightedIndex, - }, - trigger: { - ref: triggerRef, - register: registerTrigger, - unregister: unregisterTrigger, - toggleOpen, - }, - search: { showSearch, searchQuery, setSearchQuery }, - }), - [ - ids, - state, - iconAppearance, - toggleSelection, - highlightFirst, - highlightLast, - highlightItem, - moveHighlightedIndex, - registerTrigger, - unregisterTrigger, - toggleOpen, - showSearch, - searchQuery, - ] - ); + const searchState: UseSelectSearchState = useMemo(() => ({ + searchQuery, + setSearchQuery, + }), [searchQuery, setSearchQuery]); + + return useMemo((): UseSelectReturn => ({ + ...openState, + ...highlightState, + ...selectionState, + ...searchState, + options, + visibleOptionIds, + }), [openState, highlightState, selectionState, searchState, options, visibleOptionIds] ); } diff --git a/src/components/user-interaction/data/FilterList.tsx b/src/components/user-interaction/data/FilterList.tsx index f30dd14..271670b 100644 --- a/src/components/user-interaction/data/FilterList.tsx +++ b/src/components/user-interaction/data/FilterList.tsx @@ -1,6 +1,6 @@ import { useMemo, useState, type ReactNode } from 'react' import type { FilterValue } from './filter-function' -import { useFilterValueTranslation } from './filter-function' +import { FilterValueUtils, useFilterValueTranslation } from './filter-function' import { DataTypeUtils, type DataType } from './data-types' import { useHightideTranslation } from '@/src/i18n/useHightideTranslation' import { PlusIcon } from 'lucide-react' @@ -19,12 +19,23 @@ export interface IdentifierFilterValue extends FilterValue { id: string, } +export interface FilterListPopUpBuilderProps { + value: FilterValue, + onValueChange: (value: FilterValue) => void, + onRemove: () => void, + dataType: DataType, + tags: ReadonlyArray<{ tag: string, label: string, display?: ReactNode }>, + name: string, + isOpen: boolean, + close: () => void, +} + export interface FilterListItem { id: string, label: string, - display?: ReactNode, dataType: DataType, tags: ReadonlyArray<{ tag: string, label: string, display?: ReactNode }>, + popUpBuilder?: (props: FilterListPopUpBuilderProps) => ReactNode, } export interface FilterListProps { @@ -44,6 +55,20 @@ export const FilterList = ({ value, onValueChange, availableItems }: FilterListP }, {} as Record), [availableItems]) const [editState, setEditState] = useState(undefined) + const valueWithEditState = useMemo(() => { + let foundEditValue = false + for(const item of value) { + if(item.id === editState?.id) { + foundEditValue = true + break + } + } + if(!foundEditValue && editState) { + return [...value, editState] + } + return value + }, [value, editState]) + return (
      @@ -55,7 +80,7 @@ export const FilterList = ({ value, onValueChange, availableItems }: FilterListP )} - + {({ setIsOpen }) => ( - {value.map(filterValue => { + {valueWithEditState.map(filterValue => { const item = itemRecord[filterValue.id] if(!item) return null return ( @@ -93,10 +117,14 @@ export const FilterList = ({ value, onValueChange, availableItems }: FilterListP isOpen={editState?.id === filterValue.id} onIsOpenChange={isOpen => { if (!isOpen) { - onValueChange(value.map(prevItem => prevItem.id === filterValue.id ? { ...prevItem, ...(editState ?? {}) } : prevItem)) + const isEditStateValid = editState ? FilterValueUtils.isValid(editState) : false; + if(isEditStateValid) { + onValueChange(valueWithEditState.map(prevItem => prevItem.id === filterValue.id ? { ...prevItem, ...editState } : prevItem)) + } setEditState(undefined) } else { const valueItem = value.find(prevItem => prevItem.id === filterValue.id) + if(!valueItem) return setEditState({ ...valueItem }) } }} @@ -109,14 +137,39 @@ export const FilterList = ({ value, onValueChange, availableItems }: FilterListP )} - setEditState({ ...filterValue, ...value })} - onRemove={() => onValueChange(value.filter(prevItem => prevItem.id !== filterValue.id))} - /> + {item.popUpBuilder ? ( + + {({ isOpen, setIsOpen }) => ( + item.popUpBuilder({ + value: editState?.id === filterValue.id ? editState : filterValue, + onValueChange: value => setEditState({ ...filterValue, ...value }), + onRemove: () => { + onValueChange(value.filter(prevItem => prevItem.id !== filterValue.id)) + setEditState(undefined) + }, + dataType: item.dataType, + tags: item.tags, + name: item.label, + isOpen, + close: () => setIsOpen(false), + }) + )} + + ) : ( + { + setEditState({ ...filterValue, ...value }) + }} + onRemove={() => { + onValueChange(value.filter(prevItem => prevItem.id !== filterValue.id)) + setEditState(undefined) + }} + /> + )} ) })} diff --git a/src/components/user-interaction/data/FilterPopUp.tsx b/src/components/user-interaction/data/FilterPopUp.tsx index 031692e..225ea00 100644 --- a/src/components/user-interaction/data/FilterPopUp.tsx +++ b/src/components/user-interaction/data/FilterPopUp.tsx @@ -53,7 +53,11 @@ export const FilterBasePopUp = forwardRef( const translation = useHightideTranslation() return ( - +
      {name ?? translation('filter')} @@ -63,7 +67,7 @@ export const FilterBasePopUp = forwardRef( buttonProps={{ 'data-name': 'filter-operator-select', 'className': 'w-fit coloring-text-hover neutral flex-row-1 items-center h-element-sm px-2 py-1 rounded-md hover:cursor-pointer font-bold', - 'selectedDisplay': (op: FilterOperator) => translation(FilterOperatorUtils.getInfo(op).translationKey) + 'selectedDisplay': (option) => option ? translation(FilterOperatorUtils.getInfo(option.value as FilterOperator).translationKey) : '' }} iconAppearance="right" > @@ -201,7 +205,9 @@ export const NumberFilterPopUp = forwardRef(fu {...props} name={name} operator={operator} - onOperatorChange={(newOperator) => onValueChange({ dataType: 'number', parameter, operator: newOperator })} + onOperatorChange={(newOperator) => { + onValueChange({ dataType: 'number', parameter, operator: newOperator }) + }} onRemove={onRemove} allowedOperators={FilterOperatorUtils.operatorsByCategory.number} hasValue={!!value} diff --git a/src/components/user-interaction/data/filter-function.ts b/src/components/user-interaction/data/filter-function.ts index 8282c29..ce591f4 100644 --- a/src/components/user-interaction/data/filter-function.ts +++ b/src/components/user-interaction/data/filter-function.ts @@ -18,12 +18,108 @@ export type FilterParameter = { singleOptionSearch?: unknown, } +const allowedOperatorsByDataType: Record = { + text: ['equals', 'notEquals', 'contains', 'notContains', 'startsWith', 'endsWith', 'isUndefined', 'isNotUndefined'], + number: ['equals', 'notEquals', 'greaterThan', 'greaterThanOrEqual', 'lessThan', 'lessThanOrEqual', 'between', 'notBetween', 'isUndefined', 'isNotUndefined'], + date: ['equals', 'notEquals', 'greaterThan', 'greaterThanOrEqual', 'lessThan', 'lessThanOrEqual', 'between', 'notBetween', 'isUndefined', 'isNotUndefined'], + dateTime: ['equals', 'notEquals', 'greaterThan', 'greaterThanOrEqual', 'lessThan', 'lessThanOrEqual', 'between', 'notBetween', 'isUndefined', 'isNotUndefined'], + boolean: ['isTrue', 'isFalse', 'isUndefined', 'isNotUndefined'], + multiTags: ['equals', 'notEquals', 'contains', 'notContains', 'isUndefined', 'isNotUndefined'], + singleTag: ['equals', 'notEquals', 'contains', 'notContains', 'isUndefined', 'isNotUndefined'], + unknownType: ['isUndefined', 'isNotUndefined'], +} + export type FilterValue = { dataType: DataType, operator: FilterOperator, parameter: FilterParameter, } +const OPERATORS_WITHOUT_PARAMETERS: FilterOperator[] = [ + 'isUndefined', + 'isNotUndefined', + 'isTrue', + 'isFalse', +] + +function isParameterValidForOperator( + dataType: DataType, + operator: FilterOperator, + parameter: FilterParameter +): boolean { + if (OPERATORS_WITHOUT_PARAMETERS.includes(operator)) { + return true + } + + switch (dataType) { + case 'text': { + return typeof parameter.searchText === 'string' + } + case 'number': { + if (operator === 'between' || operator === 'notBetween') { + const min = parameter.minNumber + const max = parameter.maxNumber + return ( + typeof min === 'number' && + !Number.isNaN(min) && + typeof max === 'number' && + !Number.isNaN(max) && + min <= max + ) + } + const v = parameter.compareValue + return typeof v === 'number' && !Number.isNaN(v) + } + case 'date': + case 'dateTime': { + if (operator === 'between' || operator === 'notBetween') { + const minDate = DateUtils.tryParseDate(parameter.minDate) + const maxDate = DateUtils.tryParseDate(parameter.maxDate) + if (!minDate || !maxDate) return false + const minNorm = dataType === 'date' + ? DateUtils.toOnlyDate(minDate).getTime() + : DateUtils.toDateTimeOnly(minDate).getTime() + const maxNorm = dataType === 'date' + ? DateUtils.toOnlyDate(maxDate).getTime() + : DateUtils.toDateTimeOnly(maxDate).getTime() + return minNorm <= maxNorm + } + return DateUtils.tryParseDate(parameter.compareDate) != null + } + case 'boolean': + return true + case 'multiTags': { + return Array.isArray(parameter.multiOptionSearch) + } + case 'singleTag': { + if (operator === 'contains' || operator === 'notContains') { + return Array.isArray(parameter.multiOptionSearch) + } + if(operator === 'equals' || operator === 'notEquals') { + return typeof parameter.singleOptionSearch === 'string' + } + return true + } + case 'unknownType': + return true + default: + return false + } +} + +function isFilterValueValid(value: FilterValue): boolean { + const allowed = allowedOperatorsByDataType[value.dataType] + if (!allowed?.includes(value.operator)) { + return false + } + return isParameterValidForOperator(value.dataType, value.operator, value.parameter) +} + +export const FilterValueUtils = { + allowedOperatorsByDataType, + isValid: isFilterValueValid, +} + /** * Filters a text value based on the provided filter value. */ diff --git a/src/components/user-interaction/properties/MultiSelectProperty.tsx b/src/components/user-interaction/properties/MultiSelectProperty.tsx index db206ac..704906a 100644 --- a/src/components/user-interaction/properties/MultiSelectProperty.tsx +++ b/src/components/user-interaction/properties/MultiSelectProperty.tsx @@ -4,7 +4,7 @@ import type { PropsWithChildren } from 'react' import { PropsUtil } from '@/src/utils/propsUtil' import { MultiSelectChipDisplay } from '@/src/components/user-interaction/MultiSelect/MultiSelectChipDisplay' -export type MultiSelectPropertyProps = PropertyField & PropsWithChildren +export interface MultiSelectPropertyProps extends PropertyField, PropsWithChildren {} /** * An Input for MultiSelect properties */ @@ -30,9 +30,10 @@ export const MultiSelectProperty = ({ > { - onValueChange?.(value) - onEditComplete?.(value) + onValueChange={(val) => { + const arr = val as string[] + onValueChange?.(arr) + onEditComplete?.(arr) }} disabled={props.readOnly} contentPanelProps={{ diff --git a/src/components/user-interaction/selection-models/ListBox.tsx b/src/components/user-interaction/selection-models/ListBox.tsx deleted file mode 100644 index f159ed3..0000000 --- a/src/components/user-interaction/selection-models/ListBox.tsx +++ /dev/null @@ -1,317 +0,0 @@ -import type { HTMLAttributes, RefObject } from 'react' -import React, { createContext, forwardRef, useCallback, useContext, useEffect, useRef, useState } from 'react' -import { clsx } from 'clsx' -import { match } from '@/src/utils/match' -import { useControlledState } from '@/src/hooks/useControlledState' - -// -// Context -// -type RegisteredItem = { - id: string, - value: string, - disabled: boolean, - ref: React.RefObject, -} - -type ListBoxContextType = { - registerItem: (item: RegisteredItem) => void, - unregisterItem: (id: string) => void, - - highlightedId?: string, - setHighlightedId: (id: string) => void, - - onItemClick: (id: string) => void, - isSelected: (value: string) => boolean, -} - -const ListBoxContext = createContext(null) - -function useListBoxContext() { - const ctx = useContext(ListBoxContext) - if (!ctx) { - throw new Error('ListBoxItem must be used within a ListBoxPrimitive') - } - return ctx -} - - -/* - * ListBoxItem - */ -export type ListBoxItemProps = HTMLAttributes & { - value: string, - disabled?: boolean, -} - -export const ListBoxItem = forwardRef( - function ListBoxItem({ value, disabled = false, children, className, ...rest }, ref) { - const { - registerItem, - unregisterItem, - highlightedId, - setHighlightedId, - onItemClick, - isSelected, - } = useListBoxContext() - - const itemRef = useRef(null) - const id = React.useId() - - // Register with parent - useEffect(() => { - registerItem({ id, value, disabled, ref: itemRef }) - return () => unregisterItem(id) - }, [id, value, disabled, registerItem, unregisterItem]) - - const isHighlighted = highlightedId === id - const selected = isSelected(value) - - return ( -
    • { - itemRef.current = node - if (typeof ref === 'function') ref(node) - else if (ref) (ref as RefObject).current = node - }} - id={id} - role="option" - aria-disabled={disabled} - aria-selected={selected} - data-highlighted={isHighlighted ? '' : undefined} - data-selected={selected ? '' : undefined} - data-disabled={disabled ? '' : undefined} - className={clsx( - 'flex-row-1 items-center px-2 py-1 rounded-md', - 'data-highlighted:bg-primary/20', - 'data-disabled:text-disabled data-disabled:cursor-not-allowed', - 'not-data-disabled:cursor-pointer', - className - )} - onClick={() => { - if (!disabled) onItemClick(id) - }} - onMouseEnter={() => { - if (!disabled) { - setHighlightedId(id) - } - }} - {...rest} - > - {children ?? value} -
    • - ) - } -) - -type ListBoxOrientation = 'vertical' | 'horizontal' - -// -// ListBoxPrimitive -// -export type ListBoxPrimitiveProps = HTMLAttributes & { - value?: string[], - initialValue?: string[], - onItemClicked?: (value: string) => void, - onSelectionChanged?: (value: string[]) => void, - isSelection?: boolean, - isMultiple?: boolean, - orientation?: ListBoxOrientation, -} - - -export const ListBoxPrimitive = forwardRef( - function ListBoxPrimitive({ - value: controlledValue, - initialValue, - onSelectionChanged, - onItemClicked, - isSelection = false, - isMultiple = false, - orientation = 'vertical', - ...props - }, ref) { - const [value, setValue] = useControlledState({ - value: controlledValue, - onValueChange: onSelectionChanged, - defaultValue: initialValue, - }) - const itemsRef = useRef([]) - const [highlightedIndex, setHighlightedIndex] = useState(undefined) - - const registerItem = useCallback((item: RegisteredItem) => { - itemsRef.current.push(item) - itemsRef.current.sort((a, b) => { - const aEl = a.ref.current - const bEl = b.ref.current - if (!aEl || !bEl) return 0 - return aEl.compareDocumentPosition(bEl) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1 - }) - }, []) - - const unregisterItem = useCallback((id: string) => { - itemsRef.current = itemsRef.current.filter(i => i.id !== id) - }, []) - - const isSelected = useCallback( - (val: string) => (value ?? []).includes(val), - [value] - ) - - const onItemClickedHandler = useCallback( - (id: string) => { - const index = itemsRef.current.findIndex(i => i.id === id) - if (index === -1) { - console.error('ListBoxItem provided an invalid id') - return - } - const item = itemsRef.current[index] - const val = item.value - onItemClicked?.(val) - setHighlightedIndex(index) - if (!isSelection) return - if (!isMultiple) { - setValue([val]) - } else { - if (isSelected(val)) { - setValue((value ?? []).filter(v => v !== val)) - } else { - setValue([...(value ?? []), val]) - } - } - }, - [onItemClicked, isSelection, isMultiple, setValue, isSelected, value] - ) - - const setHighlightedId = useCallback((id: string) => { - const index = itemsRef.current.findIndex(i => i.id === id) - if (index !== -1) { - setHighlightedIndex(index) - } - }, []) - - // Scroll highlighted item into view - useEffect(() => { - if (highlightedIndex !== undefined) { - itemsRef.current[highlightedIndex]?.ref.current?.scrollIntoView({ block: 'nearest', behavior: 'auto' }) - } - }, [highlightedIndex]) - - const highlightedItem: RegisteredItem | undefined = itemsRef.current[highlightedIndex] - const ctxValue: ListBoxContextType = { - registerItem, - unregisterItem, - highlightedId: highlightedItem?.id, - setHighlightedId, - onItemClick: onItemClickedHandler, - isSelected, - } - - const moveHighlight = (delta: number) => { - if (itemsRef.current.length === 0) return - let nextIndex = highlightedIndex ?? -1 - for (let i = 0; i < itemsRef.current.length; i++) { - nextIndex = (nextIndex + delta + itemsRef.current.length) % itemsRef.current.length - if (!itemsRef.current[nextIndex].disabled) break - } - setHighlightedIndex(nextIndex) - } - - return ( - -
        { - if (highlightedIndex === undefined) { - const firstEnabled = itemsRef.current.findIndex(i => !i.disabled) - setHighlightedIndex(firstEnabled !== -1 ? firstEnabled : undefined) - } - props.onFocus?.(event) - }} - onBlur={event => { - setHighlightedIndex(undefined) - props.onBlur?.(event) - }} - onKeyDown={(event) => { - switch (event.key) { - case match(orientation, { - vertical: 'ArrowDown', - horizontal: 'ArrowUp' - }): - moveHighlight(1) - event.preventDefault() - break - case match(orientation, { - vertical: 'ArrowUp', - horizontal: 'ArrowDown' - }): - moveHighlight(-1) - event.preventDefault() - break - case 'Home': - setHighlightedIndex(itemsRef.current.findIndex(i => !i.disabled)) - event.preventDefault() - break - case 'End': - for (let i = itemsRef.current.length - 1; i >= 0; i--) { - if (!itemsRef.current[i].disabled) { - setHighlightedIndex(i) - break - } - } - event.preventDefault() - break - case 'Enter': - case ' ': - if (highlightedIndex !== undefined) { - event.preventDefault() - onItemClickedHandler(itemsRef.current[highlightedIndex].id) - } - break - } - props.onKeyDown?.(event) - }} - role="listbox" - aria-multiselectable={isSelection ? isMultiple : undefined} - aria-orientation={orientation} - tabIndex={0} - > - {props.children} -
      -
      - ) - } -) - -/* - * ListBoxMultiple - */ -export type ListBoxMultipleProps = Omit -export const ListBoxMultiple = ({ ...props }: ListBoxMultipleProps) => { - return ( - - ) -} - -export type ListBoxProps = Omit & { - value?: string, - onSelectionChanged?: (value: string) => void, -} -export const ListBox = forwardRef(function ListBox({ - value, - onSelectionChanged, - ...props -}, ref) { - return ( - { - onSelectionChanged(newValue[0] ?? value) - }} - isMultiple={false} - {...props} - /> - ) -}) diff --git a/src/hooks/useMultiSelection.ts b/src/hooks/useMultiSelection.ts index 2fb7c5f..d47cf04 100644 --- a/src/hooks/useMultiSelection.ts +++ b/src/hooks/useMultiSelection.ts @@ -1,9 +1,13 @@ -import type { SelectionOption } from "@/src/hooks/useSingleSelection"; import { useControlledState } from "@/src/hooks/useControlledState"; import { useCallback, useMemo } from "react"; +export interface UseMultiSelectionOption { + id: string; + disabled?: boolean; +} + export interface UseMultiSelectionOptions { - options: ReadonlyArray>; + options: ReadonlyArray; value?: ReadonlyArray; onSelectionChange: (selection: ReadonlyArray) => void; initialSelection?: ReadonlyArray; @@ -13,8 +17,8 @@ export interface UseMultiSelectionOptions { export interface MultiSelectionReturn { selection: ReadonlyArray; - selectedOptions: ReadonlyArray>; - options: ReadonlyArray>; + selectedOptions: ReadonlyArray; + options: ReadonlyArray; setSelection: (selection: ReadonlyArray) => void; toggleSelection: (value: T) => void; isSelected: (value: T) => boolean; @@ -37,32 +41,23 @@ export function useMultiSelection({ const compare = useMemo(() => compareOptions ?? Object.is, [compareOptions]); - const selectedOptions = useMemo( - () => - selection - .map((s) => optionsList.find((o) => compare(o.value, s))) - .filter((o): o is SelectionOption => o != null), - [selection, optionsList, compare] - ); + const selectedOptions = useMemo(() => selection + .map((s) => optionsList.find((o) => compare(o.id, s))) + .filter((o): o is UseMultiSelectionOption => o != null) + , [selection, optionsList, compare]); const isSelected = useCallback( (value: T) => selection.some((s) => compare(s, value)), [selection, compare] ); - const toggleSelection = useCallback( - (value: T) => { - const option = optionsList.find((o) => compare(o.value, value)); - if (!option || option.disabled) return; - setSelection((prev) => { - const next = prev.some((s) => compare(s, value)) - ? prev.filter((s) => !compare(s, value)) - : [...prev, value]; - return next; - }); - }, - [optionsList, compare, setSelection] - ); + const toggleSelection = useCallback((value: T) => { + const option = optionsList.find((o) => compare(o.id, value)); + if (!option || option.disabled) return; + setSelection((prev) => prev.some((s) => compare(s, value)) + ? prev.filter((s) => !compare(s, value)) + : [...prev, value]); + }, [optionsList, compare, setSelection]); const setSelectionValue = useCallback( (next: ReadonlyArray) => setSelection(Array.from(next)), diff --git a/src/hooks/useSingleSelection.ts b/src/hooks/useSingleSelection.ts index 0d6a3fb..78eaed2 100644 --- a/src/hooks/useSingleSelection.ts +++ b/src/hooks/useSingleSelection.ts @@ -1,68 +1,103 @@ -import type { ReactNode } from "react"; import { useCallback, useMemo } from "react"; import { useControlledState } from "@/src/hooks/useControlledState"; -export interface SelectionOption { - value: T; - label: string; - display: ReactNode; - disabled: boolean; +export interface SelectionOption { + id: string; + disabled?: boolean; } -export interface UseSingleSelectionOptions { - options: ReadonlyArray>; - value: T | null | undefined; - onSelectionChange: (selection: T) => void; - initialSelection: T | null; - isControlled?: boolean; - compareOptions?: (a: T, b: T) => boolean; +export interface UseSingleSelectionOptions { + options: ReadonlyArray; + selection?: string | null; + onSelectionChange?: (selection: string | null) => void; + initialSelection?: string | null; + isLooping?: boolean; } -export interface SingleSelectionReturn { - selection: T | null; - selectedOption: SelectionOption | null; - options: ReadonlyArray>; - changeSelection: (selection: T) => void; +export interface SingleSelectionReturn { + selection: string | null; + selectedIndex: number | null; + selectByIndex: (index: number) => void; + selectValue: (value: string | null) => void; + selectFirst: () => void; + selectLast: () => void; + selectNext: () => void; + selectPrevious: () => void; } -export function useSingleSelection({ +export function useSingleSelection({ options: optionsList, - value, + selection: controlledSelection, onSelectionChange, initialSelection, - isControlled, - compareOptions, -}: UseSingleSelectionOptions): SingleSelectionReturn { + isLooping = true, +}: UseSingleSelectionOptions): SingleSelectionReturn { const [selection, setSelection] = useControlledState({ - value: value ?? undefined, + value: controlledSelection, onValueChange: onSelectionChange, defaultValue: initialSelection, - isControlled: isControlled ?? value !== undefined, }); - const compare = useMemo(() => compareOptions ?? Object.is, [compareOptions]); + const selectedIndex = useMemo(() => { + return optionsList.findIndex((o) => o.id === selection); + }, [optionsList, selection]); - const selectedOption = useMemo(() => { - if (selection == null) return null; - return optionsList.find((o) => compare(o.value, selection)) ?? null; - }, [optionsList, selection, compare]); + const enabledOptions = useMemo(() => optionsList.filter((o) => !o.disabled), [optionsList]); - const changeSelection = useCallback( - (next: T) => { - const option = optionsList.find((o) => compare(o.value, next)); - if (!option || option.disabled) return; - setSelection(next); - }, - [optionsList, compare, setSelection] - ); + const changeSelection = useCallback((next: string | null) => { + const option = enabledOptions.find((o) => o.id === next); + if(!option && next != null) { + console.warn(`Attempted to select an option ${next} that is not valid or disabled`); + return; + } + setSelection(option?.id ?? null); + }, [enabledOptions, setSelection]); - return useMemo( - () => ({ - selection: selection ?? null, - selectedOption, - options: optionsList, - changeSelection, - }), - [selection, selectedOption, optionsList, changeSelection] - ); + const selectByIndex = useCallback((index: number) => { + const option = optionsList[index]; + if(!option || option.disabled || index < 0 || index >= optionsList.length) { + console.warn(`Attempted to select an index ${index} that is not valid or disabled`); + return; + } + setSelection(option.id); + }, [optionsList, setSelection]); + + const selectFirst = useCallback(() => { + if(enabledOptions.length === 0) return; + const first = enabledOptions.find((o) => !o.disabled); + setSelection(first?.id ?? null); + }, [enabledOptions, setSelection]); + + const selectLast = useCallback(() => { + if(enabledOptions.length === 0) return; + const last = [...enabledOptions].reverse().find((o) => !o.disabled); + setSelection(last?.id ?? null); + }, [enabledOptions, setSelection]); + + const selectNext = useCallback(() => { + if(enabledOptions.length === 0) return; + let currentIndex = enabledOptions.findIndex((o) => o.id === selection); + if(currentIndex === -1) currentIndex = 0; + const nextIndex = isLooping ? (currentIndex + 1) % enabledOptions.length : Math.min(currentIndex + 1, enabledOptions.length - 1); + setSelection(enabledOptions[nextIndex].id); + }, [enabledOptions, selection, isLooping, setSelection]); + + const selectPrevious = useCallback(() => { + if(enabledOptions.length === 0) return; + let currentIndex = enabledOptions.findIndex((o) => o.id === selection); + if(currentIndex === -1) currentIndex = enabledOptions.length; + const previousIndex = isLooping ? (currentIndex - 1 + enabledOptions.length) % enabledOptions.length : Math.max(currentIndex - 1, 0); + setSelection(enabledOptions[previousIndex].id); + }, [enabledOptions, selection, isLooping, setSelection]); + + return useMemo(() => ({ + selection, + selectedIndex, + selectByIndex, + selectValue: changeSelection, + selectFirst, + selectLast, + selectNext, + selectPrevious, + }), [selection, selectedIndex, enabledOptions, changeSelection, selectFirst, selectLast, selectNext, selectPrevious]); } diff --git a/src/style/theme/colors/utilities.css b/src/style/theme/colors/utilities.css index db8d76c..25d2403 100644 --- a/src/style/theme/colors/utilities.css +++ b/src/style/theme/colors/utilities.css @@ -3,6 +3,7 @@ --coloring-color: var(--color-primary); --coloring-on-color: var(--color-on-primary); --coloring-hover: var(--color-primary-hover); + --color-focus: var(--color-primary); } @utility secondary { @@ -10,6 +11,7 @@ --coloring-color: var(--color-secondary); --coloring-on-color: var(--color-on-secondary); --coloring-hover: var(--color-secondary-hover); + --color-focus: var(--color-secondary); } @utility positive { @@ -17,6 +19,7 @@ --coloring-color: var(--color-positive); --coloring-on-color: var(--color-on-positive); --coloring-hover: var(--color-positive-hover); + --color-focus: var(--color-positive); } @utility negative { @@ -24,6 +27,7 @@ --coloring-color: var(--color-negative); --coloring-on-color: var(--color-on-negative); --coloring-hover: var(--color-negative-hover); + --color-focus: var(--color-negative); } @utility warning { @@ -31,6 +35,7 @@ --coloring-color: var(--color-warning); --coloring-on-color: var(--color-on-warning); --coloring-hover: var(--color-warning-hover); + --color-focus: var(--color-warning); } @utility neutral { @@ -94,4 +99,4 @@ @apply data-[color=description]:description; @apply data-[color=surface]:surface; @apply data-[color=disabled]:disabled; -} +} \ No newline at end of file diff --git a/src/style/theme/components/general.css b/src/style/theme/components/general.css index 400a5ac..75c1777 100644 --- a/src/style/theme/components/general.css +++ b/src/style/theme/components/general.css @@ -23,6 +23,6 @@ } * { - @apply focus-style-outline focusable; + @apply focus-style-outline focusable outline-focus; } -} +} \ No newline at end of file diff --git a/src/style/theme/components/select.css b/src/style/theme/components/select.css index 355643f..0273415 100644 --- a/src/style/theme/components/select.css +++ b/src/style/theme/components/select.css @@ -1,5 +1,7 @@ @layer components { - [data-name="select-button"] { + + [data-name="select-button"], + [data-name="multi-select-button"] { @apply input-element flex-row-2 items-center justify-between rounded-md px-3 py-2; &:not([data-disabled]) { @@ -7,7 +9,7 @@ } } - [data-name="select-chip-display"] { + [data-name="multi-select-chip-display-button"] { @apply input-element flex flex-wrap gap-2 items-center rounded-md px-2.5 py-2.5; &:not([data-disabled]) { @@ -15,11 +17,34 @@ } } - [data-name="select-chip-display-chip"] { + [data-name="multi-select-chip-display-chip"] { @apply flex-row-1 items-center pl-2 pr-1 coloring-solid neutral rounded-md h-9; } - [data-name="select-list-status"] { - @apply text-description text-sm px-2 py-1 rounded-md; + [data-name="select-list"], + [data-name="multi-select-list"] { + @apply flex-col-1 overflow-y-auto; + } + + [data-name="select-list-option"], + [data-name="multi-select-list-option"] { + @apply flex-row-1 items-center px-2 py-1 rounded-md; + + &[data-disabled] { + @apply cursor-not-allowed text-disabled; + } + + &:not([data-disabled]) { + @apply cursor-pointer; + + &[data-highlighted] { + @apply bg-primary/20; + } + } + } + + [data-name="select-list-status"], + [data-name="multi-select-list-status"] { + @apply text-description px-2 py-1 rounded-md; } } \ No newline at end of file diff --git a/src/style/utitlity/focus.css b/src/style/utitlity/focus.css index 392248a..a8f9d90 100644 --- a/src/style/utitlity/focus.css +++ b/src/style/utitlity/focus.css @@ -9,7 +9,7 @@ } @utility focus-style-shadow { - --focus-box-shadow: 0 0 8px 2px color-mix(in srgb, var(--color-focus) 70%, transparent); + --focus-box-shadow: 0 0 calc(var(--spacing) * 1) calc(var(--spacing) * 0.5) color-mix(in srgb, var(--color-focus) 70%, transparent); } @utility focus-style-none { @@ -30,4 +30,4 @@ box-shadow: var(--focus-box-shadow); border-color: var(--focus-border-color); } -} +} \ No newline at end of file diff --git a/stories/Layout/Table/FilterListTable.stories.tsx b/stories/Layout/Table/FilterListTable.stories.tsx index 71d7411..47cf8cd 100644 --- a/stories/Layout/Table/FilterListTable.stories.tsx +++ b/stories/Layout/Table/FilterListTable.stories.tsx @@ -1,5 +1,5 @@ import type { Meta, StoryObj } from '@storybook/nextjs' -import { useMemo, useState } from 'react' +import { useId, useMemo, useState } from 'react' import { faker } from '@faker-js/faker' import { range } from '@/src/utils/array' import { Table } from '@/src/components/layout/table/Table' @@ -7,9 +7,10 @@ import { TableColumn } from '@/src/components/layout/table/TableColumn' import { TableCell } from '@/src/components/layout/table/TableCell' import { useHightideTranslation } from '@/src/i18n/useHightideTranslation' import { FilterList } from '@/src/components/user-interaction/data/FilterList' -import type { IdentifierFilterValue, FilterListItem } from '@/src/components/user-interaction/data/FilterList' +import type { IdentifierFilterValue, FilterListItem, FilterListPopUpBuilderProps } from '@/src/components/user-interaction/data/FilterList' import { FilterFunctions } from '@/src/components/user-interaction/data/filter-function' import type { DataType } from '@/src/components/user-interaction/data/data-types' +import { FilterBasePopUp, FilterOperatorUtils, Input, Select, SelectOption, Visibility } from '@/src' type Row = { name: string, @@ -25,6 +26,81 @@ const createRow = (): Row => ({ hasChildren: faker.datatype.boolean(), }) +const AgeFilterPopUp = ({ value, onValueChange, onRemove, name }: FilterListPopUpBuilderProps) => { + const translation = useHightideTranslation() + const id = useId() + const ids = { + range: `number-filter-range-${id}`, + compareValue: `number-filter-compare-value-${id}`, + } + + const operator = useMemo(() => { + const suggestion = value?.operator ?? 'between' + if (!FilterOperatorUtils.typeCheck.number(suggestion)) { + return 'between' + } + return suggestion + }, [value]) + + const parameter = value?.parameter ?? {} + + const needsRangeInput = operator === 'between' || operator === 'notBetween' + const needsParameterInput = operator !== 'isUndefined' && operator !== 'isNotUndefined' + + const ageRange = useMemo(() => range(11).map(i => i * 10), []) + + return ( + onValueChange({ dataType: 'number', parameter, operator: newOperator })} + onRemove={onRemove} + allowedOperators={FilterOperatorUtils.operatorsByCategory.number} + hasValue={!!value} + noParameterRequired={!needsParameterInput} + > + +
      + + + buttonProps={{ id: ids.range }} + value={parameter.minNumber !== undefined && parameter.maxNumber !== undefined ? [parameter.minNumber, parameter.maxNumber] : null} + onValueChange={(newRange) => { + onValueChange({ ...value, parameter: { ...parameter, minNumber: newRange[0], maxNumber: newRange[1] } }) + }} + compareFunction={(a, b) => { + if(a === null || b === null) return false + return a[0] === b[0] && a[1] === b[1] + }} + > + {range(ageRange.length - 1).map(i => ( + + {ageRange[i]} - {ageRange[i + 1]} + + ))} + +
      +
      + + { + const num = Number(text) + onValueChange({ + dataType: 'number', + operator, + parameter: { ...parameter, compareValue: isNaN(num) ? undefined : num }, + }) + }} + className="min-w-64" + /> + +
      + ) +} + const allData: Row[] = range(100).map(() => createRow()) const availableItems: FilterListItem[] = [ @@ -39,6 +115,7 @@ const availableItems: FilterListItem[] = [ label: 'Age', dataType: 'number', tags: [], + popUpBuilder: (props: FilterListPopUpBuilderProps) => , }, { id: 'entryDate', @@ -100,7 +177,9 @@ export const filterListTable: Story = { { + setFilterValue(value) + }} availableItems={availableItems} />
      diff --git a/stories/User Interaction/Form/Form.stories.tsx b/stories/User Interaction/Form/Form.stories.tsx index 2a9068d..dbeea30 100644 --- a/stories/User Interaction/Form/Form.stories.tsx +++ b/stories/User Interaction/Form/Form.stories.tsx @@ -18,6 +18,7 @@ import type { FormFieldDataHandling } from '@/src/components/form/FormField' import { FormField } from '@/src/components/form/FormField' import { FormProvider } from '@/src/components/form/FormContext' import { DateTimeInput } from '@/src/components/user-interaction/input/DateTimeInput' +import { MultiSelectOption } from '@/src/components/user-interaction/MultiSelect/MultiSelectOption' type FormState = 'editing' | 'sending' | 'submitted' @@ -150,7 +151,7 @@ export const basic: Story = { {({ dataProps, focusableElementProps, interactionStates }) => ( )} @@ -164,9 +165,9 @@ export const basic: Story = { validationBehaviour={validationBehaviour} > {({ dataProps, focusableElementProps, interactionStates }) => ( - } {...focusableElementProps} {...interactionStates}> + {StorybookHelper.selectValues.map(value => ( - + ))} )} @@ -181,7 +182,7 @@ export const basic: Story = { {({ dataProps, focusableElementProps, interactionStates }) => ( } {...focusableElementProps} {...interactionStates}> {StorybookHelper.selectValues.map(value => ( - + ))} )} diff --git a/stories/User Interaction/ListBox.stories.tsx b/stories/User Interaction/ListBox.stories.tsx deleted file mode 100644 index 7461c15..0000000 --- a/stories/User Interaction/ListBox.stories.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/nextjs' -import { action } from 'storybook/actions' -import { ListBoxItem, ListBox } from '@/src/components/user-interaction/selection-models/ListBox' - - -const meta = { - component: ListBox, -} satisfies Meta - -export default meta -type Story = StoryObj; - -export const listBox: Story = { - args: { - isSelection: true, - onItemClicked: action('onItemClick'), - onSelectionChanged: action('onSelectionChanged'), - children: [ - { value: 'Apple' }, - { value: 'Banana', disabled: true }, - { value: 'Kiwi' }, - { value: 'Blueberry' }, - { value: 'Strawberry' }, - { value: 'Melon' }, - { value: 'Orange' }, - { value: 'Mango' }, - { value: 'Pineapple', disabled: true }, - { value: 'Papaya' }, - { value: 'Grapes' }, - { value: 'Cherry' }, - { value: 'Peach' }, - { value: 'Plum' }, - { value: 'Pear' }, - { value: 'Fig' }, - { value: 'Lemon' }, - { value: 'Lime' }, - { value: 'Coconut' }, - { value: 'Guava' }, - { value: 'Apricot' }, - { value: 'Pomegranate' }, - { value: 'Raspberry', disabled: true }, - { value: 'Blackberry' }, - { value: 'Tangerine' }, - { value: 'Dragonfruit' } - ].sort((a, b) => a.value.localeCompare(b.value)) - .map((value, index) => ( - - )), - }, -} From cbc45db153ba24a6ed39413f370b584c6423af2f Mon Sep 17 00:00:00 2001 From: DasProffi <67233923+DasProffi@users.noreply.github.com> Date: Fri, 6 Mar 2026 14:59:36 +0100 Subject: [PATCH 09/13] fix: fix imports --- .../MultiSelect/MultiSelectRoot.tsx | 2 +- .../user-interaction/Select/SelectRoot.tsx | 2 +- .../user-interaction/Select/useSelect.ts | 103 +++++++++--------- .../Layout/Table/FilterListTable.stories.tsx | 7 +- 4 files changed, 57 insertions(+), 57 deletions(-) diff --git a/src/components/user-interaction/MultiSelect/MultiSelectRoot.tsx b/src/components/user-interaction/MultiSelect/MultiSelectRoot.tsx index f37c488..52d2e9a 100644 --- a/src/components/user-interaction/MultiSelect/MultiSelectRoot.tsx +++ b/src/components/user-interaction/MultiSelect/MultiSelectRoot.tsx @@ -6,7 +6,7 @@ import { useMultiSelect } from "./useMultiSelect"; import { DOMUtils } from "@/src/utils/dom"; import type { FormFieldDataHandling } from "@/src/components/form/FormField"; import type { FormFieldInteractionStates } from "@/src/components/form/FieldLayout"; -import { PopUpContext } from "../../layout"; +import { PopUpContext } from "@/src/components/layout/popup/PopUpContext"; export interface MultiSelectIds { trigger: string; diff --git a/src/components/user-interaction/Select/SelectRoot.tsx b/src/components/user-interaction/Select/SelectRoot.tsx index e3d6b63..29fff7d 100644 --- a/src/components/user-interaction/Select/SelectRoot.tsx +++ b/src/components/user-interaction/Select/SelectRoot.tsx @@ -7,7 +7,7 @@ import { DOMUtils } from "@/src/utils/dom"; import { FormFieldDataHandling } from "../../form/FormField"; import { useEventCallbackStabilizer } from "@/src/hooks/useEventCallbackStabelizer"; import { FormFieldInteractionStates } from "../../form/FieldLayout"; -import { PopUpContext } from "../../layout"; +import { PopUpContext } from "@/src/components/layout/popup/PopUpContext"; export interface SelectIds { trigger: string; diff --git a/src/components/user-interaction/Select/useSelect.ts b/src/components/user-interaction/Select/useSelect.ts index 0385617..7fdf039 100644 --- a/src/components/user-interaction/Select/useSelect.ts +++ b/src/components/user-interaction/Select/useSelect.ts @@ -27,35 +27,31 @@ export interface UseSelectOptions { export type UseSelectFirstHighlightBehavior = "first" | "last"; -export interface UseSelectOpenState { +export interface UseSelectState { + value: string | null; + highlightedValue: string | undefined; isOpen: boolean; - setIsOpen: (isOpen: boolean, behavior?: UseSelectFirstHighlightBehavior) => void; - toggleOpen: (behavior?: UseSelectFirstHighlightBehavior) => void; + searchQuery: string; + options: ReadonlyArray; } -export interface UseSelectSearchState { - searchQuery: string; - setSearchQuery: (query: string) => void; +export interface UseSelectComputedState { + visibleOptionIds: ReadonlyArray; } -export interface UseSelectHighlightState { - highlightedValue: string | undefined; +export interface UseSelectActions { + setIsOpen: (isOpen: boolean, behavior?: UseSelectFirstHighlightBehavior) => void; + toggleOpen: (behavior?: UseSelectFirstHighlightBehavior) => void; + setSearchQuery: (query: string) => void; highlightFirst: () => void; highlightLast: () => void; highlightNext: () => void; highlightPrevious: () => void; highlightItem: (value: string) => void; -} - -export interface UseSelectSelectionState { - value: string | null; selectValue: (value: string) => void; } -export interface UseSelectReturn extends UseSelectOpenState, UseSelectSearchState, UseSelectHighlightState, UseSelectSelectionState { - options: ReadonlyArray; - visibleOptionIds: ReadonlyArray; -} +export interface UseSelectReturn extends UseSelectState, UseSelectComputedState, UseSelectActions {} export function useSelect({ options, @@ -104,29 +100,34 @@ export function useSelect({ initialValue: selection.selection, }); - const highlightState: UseSelectHighlightState = useMemo(() => ({ + const state: UseSelectState = useMemo(() => ({ + value: selection.selection, highlightedValue: listNav.highlightedId, - highlightFirst: listNav.first, - highlightLast: listNav.last, - highlightNext: listNav.next, - highlightPrevious: listNav.previous, - highlightItem: (value: string) => { - if (!enabledOptions.some((o) => o.id === value)) return; - listNav.highlight(value) - } - }), [enabledOptions, listNav]); + isOpen, + searchQuery, + options, + }), [selection.selection, listNav.highlightedId, isOpen, searchQuery, options]); + + const computedState: UseSelectComputedState = useMemo(() => ({ + visibleOptionIds, + }), [visibleOptionIds]); + + const highlightItem = useCallback((value: string) => { + if (!enabledOptions.some((o) => o.id === value)) return; + listNav.highlight(value) + }, [enabledOptions, listNav]); const setIsOpenWrapper = useCallback((isOpen: boolean, behavior?: UseSelectFirstHighlightBehavior) => { behavior = behavior ?? "first"; if(isOpen) { if(selection.selection == null) { if(behavior === "first") { - highlightState.highlightFirst(); + listNav.first(); } else if (behavior === "last") { - highlightState.highlightLast(); + listNav.last(); } } else { - highlightState.highlightItem(selection.selection); + highlightItem(selection.selection); } } else { setSearchQuery(""); @@ -134,34 +135,28 @@ export function useSelect({ } setIsOpen(isOpen); onIsOpenChangeStable(isOpen); - }, [setIsOpen, highlightState, onCloseStable, onEditCompleteStable, selection.selection, onIsOpenChangeStable]); + }, [setIsOpen, highlightItem, onCloseStable, onEditCompleteStable, selection.selection, onIsOpenChangeStable]); + const toggleOpenWrapper = useCallback((behavior?: UseSelectFirstHighlightBehavior) => { + const next = !isOpen; + setIsOpenWrapper(next, behavior); + }, [setIsOpenWrapper]); - const selectionState: UseSelectSelectionState = useMemo(() => ({ - value: selection.selection, + const actions: UseSelectActions = useMemo(() => ({ selectValue: (id: string) => selection.selectValue(id), - }), [selection.selection, selection.selectValue]); - - const openState: UseSelectOpenState = useMemo(() => ({ - isOpen, setIsOpen: setIsOpenWrapper, - toggleOpen: (behavior?: UseSelectFirstHighlightBehavior) => { - const next = !isOpen; - setIsOpenWrapper(next, behavior); - } - }), [isOpen, setIsOpenWrapper]); - - const searchState: UseSelectSearchState = useMemo(() => ({ - searchQuery, + toggleOpen: toggleOpenWrapper, setSearchQuery, - }), [searchQuery, setSearchQuery]); - - return useMemo((): UseSelectReturn => ({ - ...openState, - ...highlightState, - ...selectionState, - ...searchState, - options, - visibleOptionIds, - }), [openState, highlightState, selectionState, searchState, options, visibleOptionIds] ); + highlightFirst: listNav.first, + highlightLast: listNav.last, + highlightNext: listNav.next, + highlightPrevious: listNav.previous, + highlightItem, + }), [setIsOpenWrapper, listNav.first, listNav.last, listNav.next, listNav.previous, highlightItem]); + + return useMemo(() => ({ + ...state, + ...computedState, + ...actions, + }), [state, computedState, actions]); } diff --git a/stories/Layout/Table/FilterListTable.stories.tsx b/stories/Layout/Table/FilterListTable.stories.tsx index 47cf8cd..0a208f2 100644 --- a/stories/Layout/Table/FilterListTable.stories.tsx +++ b/stories/Layout/Table/FilterListTable.stories.tsx @@ -10,7 +10,12 @@ import { FilterList } from '@/src/components/user-interaction/data/FilterList' import type { IdentifierFilterValue, FilterListItem, FilterListPopUpBuilderProps } from '@/src/components/user-interaction/data/FilterList' import { FilterFunctions } from '@/src/components/user-interaction/data/filter-function' import type { DataType } from '@/src/components/user-interaction/data/data-types' -import { FilterBasePopUp, FilterOperatorUtils, Input, Select, SelectOption, Visibility } from '@/src' +import { FilterOperatorUtils } from '@/src/components/user-interaction/data/FilterOperator' +import { FilterBasePopUp } from '@/src/components/user-interaction/data/FilterPopUp' +import { Input } from '@/src/components/user-interaction/input/Input' +import { Select } from '@/src/components/user-interaction/Select/Select' +import { SelectOption } from '@/src/components/user-interaction/Select/SelectOption' +import { Visibility } from '@/src/components/layout/Visibility' type Row = { name: string, From 556048c8add123aad4987bdf4edda5587392cad5 Mon Sep 17 00:00:00 2001 From: DasProffi <67233923+DasProffi@users.noreply.github.com> Date: Fri, 6 Mar 2026 16:05:05 +0100 Subject: [PATCH 10/13] feat: add useTypeAhead and update search --- CHANGELOG.md | 4 + .../user-interaction/Combobox/useCombobox.ts | 70 ++++--- .../MultiSelect/MultiSelectContent.tsx | 79 +------ .../MultiSelect/MultiSelectContext.tsx | 9 +- .../MultiSelect/MultiSelectRoot.tsx | 1 + .../MultiSelect/useMultiSelect.ts | 196 ++++++++++-------- .../user-interaction/Select/SelectContent.tsx | 50 +---- .../user-interaction/Select/SelectContext.tsx | 7 +- .../user-interaction/Select/SelectRoot.tsx | 1 + .../user-interaction/Select/useSelect.ts | 52 ++++- src/hooks/useSearch.ts | 100 +++------ src/hooks/useTypeAheadSearch.ts | 68 ++++++ 12 files changed, 329 insertions(+), 308 deletions(-) create mode 100644 src/hooks/useTypeAheadSearch.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 50317e5..5695f3e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,10 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - `Combobox` component - `FilterList` component for dynamically choosing and setting filters - `useSelectState`, `useMultiSelectState`, `useCombobox`, `useSingleSelection`, `useMultiSelection` +- `useTypeAheadSearch` for getting the value of a timed type ahead search + +## Changed +- `useSearch` to require less parameter and only do a simple search and caching the result ## Fixed - imports in `TimePicker` and `DateTimeInput` diff --git a/src/components/user-interaction/Combobox/useCombobox.ts b/src/components/user-interaction/Combobox/useCombobox.ts index 38ec44c..3d36350 100644 --- a/src/components/user-interaction/Combobox/useCombobox.ts +++ b/src/components/user-interaction/Combobox/useCombobox.ts @@ -1,7 +1,7 @@ import { useCallback, useMemo } from "react"; import { useListNavigation } from "@/src/hooks/useListNavigation"; import { useControlledState } from "@/src/hooks/useControlledState"; -import { MultiSearchWithMapping } from "@/src/utils/simpleSearch"; +import { useSearch } from "@/src/hooks"; export interface UseComboboxOption { id: string; @@ -16,11 +16,17 @@ export interface UseComboboxOptions { initialSearchQuery?: string; } -export interface UseComboboxReturn { +export interface UseComboboxState { searchQuery: string; - setSearchQuery: (query: string) => void; - visibleOptionIds: ReadonlyArray; highlightedId: string | null; +} + +export interface UseComboboxComputedState { + visibleOptionIds: ReadonlyArray; +} + +export interface UseComboboxActions { + setSearchQuery: (query: string) => void; highlightFirst: () => void; highlightLast: () => void; highlightNext: () => void; @@ -28,6 +34,8 @@ export interface UseComboboxReturn { highlightItem: (id: string) => void; } +export interface UseComboboxReturn extends UseComboboxState, UseComboboxComputedState, UseComboboxActions {} + export function useCombobox({ options, searchQuery: controlledSearchQuery, @@ -40,13 +48,16 @@ export function useCombobox({ defaultValue: initialSearchQuery, }); - const visibleOptions = useMemo(() => { - const q = (searchQuery ?? "").trim().toLowerCase(); - if (!q) return options; - return MultiSearchWithMapping(searchQuery ?? "", [...options], (o) => [o.label]); - }, [options, searchQuery]); + const { searchResult: visibleOptions } = useSearch({ + items: options, + searchQuery: searchQuery ?? "", + toTags: useCallback((o: UseComboboxOption) => [o.label], []), + }); - const visibleOptionIds = useMemo(() => visibleOptions.map((o) => o.id), [visibleOptions]); + const visibleOptionIds = useMemo( + () => visibleOptions.map((o) => o.id), + [visibleOptions] + ); const enabledOptionIds = useMemo( () => visibleOptions.filter((o) => !o.disabled).map((o) => o.id), @@ -63,28 +74,37 @@ export function useCombobox({ [enabledOptionIds, listNav] ); - return useMemo( - (): UseComboboxReturn => ({ + const state: UseComboboxState = useMemo( + () => ({ searchQuery: searchQuery ?? "", - setSearchQuery, - visibleOptionIds, highlightedId: listNav.highlightedId, + }), + [searchQuery, listNav.highlightedId] + ); + + const computedState: UseComboboxComputedState = useMemo( + () => ({ visibleOptionIds }), + [visibleOptionIds] + ); + + const actions: UseComboboxActions = useMemo( + () => ({ + setSearchQuery, highlightFirst: listNav.first, highlightLast: listNav.last, highlightNext: listNav.next, highlightPrevious: listNav.previous, highlightItem, }), - [ - searchQuery, - setSearchQuery, - visibleOptionIds, - listNav.highlightedId, - listNav.first, - listNav.last, - listNav.next, - listNav.previous, - highlightItem, - ] + [setSearchQuery, listNav.first, listNav.last, listNav.next, listNav.previous, highlightItem] + ); + + return useMemo( + (): UseComboboxReturn => ({ + ...state, + ...computedState, + ...actions, + }), + [state, computedState, actions] ); } diff --git a/src/components/user-interaction/MultiSelect/MultiSelectContent.tsx b/src/components/user-interaction/MultiSelect/MultiSelectContent.tsx index abcb5e4..a8ee1eb 100644 --- a/src/components/user-interaction/MultiSelect/MultiSelectContent.tsx +++ b/src/components/user-interaction/MultiSelect/MultiSelectContent.tsx @@ -7,8 +7,6 @@ import { PopUp, type PopUpProps } from "@/src/components/layout/popup/PopUp"; import { Input } from "@/src/components/user-interaction/input/Input"; import { Visibility } from "@/src/components/layout/Visibility"; -const TYPEAHEAD_RESET_MS = 500; - export interface MultiSelectContentProps extends PopUpProps { showSearch?: boolean; searchInputProps?: Omit, "value" | "onValueChange">; @@ -24,8 +22,6 @@ export const MultiSelectContent = forwardRef< const translation = useHightideTranslation(); const innerRef = useRef(null); const searchInputRef = useRef(null); - const typeAheadBufferRef = useRef(""); - const typeAheadTimeoutRef = useRef | null>(null); useImperativeHandle(ref, () => innerRef.current!); const context = useMultiSelectContext(); @@ -34,23 +30,6 @@ export const MultiSelectContent = forwardRef< if (id) context.config.setIds((prev) => ({ ...prev, content: id })); }, [id, context.config.setIds]); - useEffect(() => { - if (!context.isOpen) { - typeAheadBufferRef.current = ""; - if (typeAheadTimeoutRef.current) { - clearTimeout(typeAheadTimeoutRef.current); - typeAheadTimeoutRef.current = null; - } - } - }, [context.isOpen]); - - useEffect( - () => () => { - if (typeAheadTimeoutRef.current) clearTimeout(typeAheadTimeoutRef.current); - }, - [] - ); - const showSearch = showSearchOverride ?? context.search.hasSearch; const listboxAriaLabel = showSearch ? translation("searchResults") : undefined; @@ -82,56 +61,21 @@ export const MultiSelectContent = forwardRef< } break; default: - if (!showSearch && !event.ctrlKey && !event.metaKey && !event.altKey) { - const char = event.key.toLowerCase(); - if (typeAheadTimeoutRef.current) - clearTimeout(typeAheadTimeoutRef.current); - typeAheadBufferRef.current += char; - typeAheadTimeoutRef.current = setTimeout(() => { - typeAheadBufferRef.current = ""; - }, TYPEAHEAD_RESET_MS); - const optionIds = context.visibleOptionIds; - const buf = typeAheadBufferRef.current; - if (optionIds.length === 0) { + if ( + !showSearch && + !event.ctrlKey && + !event.metaKey && + !event.altKey && + event.key.length === 1 + ) { + if (context.handleTypeaheadKey(event.key)) { event.preventDefault(); - return; - } - const currentIndex = optionIds.findIndex( - (oid) => oid === context.highlightedId - ); - const startFrom = - currentIndex >= 0 - ? (currentIndex + 1) % optionIds.length - : 0; - for (let i = 0; i < optionIds.length; i++) { - const j = (startFrom + i) % optionIds.length; - const option = context.idToOptionMap[optionIds[j]]; - if ( - option && - !option.disabled && - (option.label ?? "").toLowerCase().startsWith(buf) - ) { - context.highlightItem(option.id); - event.preventDefault(); - return; - } } - event.preventDefault(); } break; } }, - [ - showSearch, - context.visibleOptionIds, - context.highlightedId, - context.highlightItem, - context.toggleSelection, - context.highlightNext, - context.highlightPrevious, - context.highlightFirst, - context.highlightLast, - ] + [showSearch, context] ); return ( @@ -163,9 +107,7 @@ export const MultiSelectContent = forwardRef< aria-expanded={context.isOpen} aria-controls={context.config.ids.listbox} aria-activedescendant={ - context.highlightedId - ? context.highlightedId - : undefined + context.highlightedId ? context.highlightedId : undefined } aria-label={searchInputProps?.["aria-label"] ?? translation("filterOptions")} className={clsx("mx-2 mt-2 shrink-0", searchInputProps?.className)} @@ -176,7 +118,6 @@ export const MultiSelectContent = forwardRef< id={context.config.ids.listbox} onKeyDown={showSearch ? undefined : keyHandler} role="listbox" - data-name="multi-select-list" aria-multiselectable={true} aria-orientation="vertical" diff --git a/src/components/user-interaction/MultiSelect/MultiSelectContext.tsx b/src/components/user-interaction/MultiSelect/MultiSelectContext.tsx index d0d64f1..f782c1c 100644 --- a/src/components/user-interaction/MultiSelect/MultiSelectContext.tsx +++ b/src/components/user-interaction/MultiSelect/MultiSelectContext.tsx @@ -19,17 +19,17 @@ export interface MultiSelectContextIds { searchInput: string; } -export interface MultiSelectContextInternalState extends FormFieldInteractionStates { +export interface MultiSelectContextState extends FormFieldInteractionStates { + value: T[]; + options: ReadonlyArray>; selectedIds: string[]; highlightedId: string | null; isOpen: boolean; } export interface MultiSelectContextComputedState { - options: ReadonlyArray>; visibleOptionIds: ReadonlyArray; idToOptionMap: Record>; - value: T[]; } export interface MultiSelectContextActions { @@ -40,6 +40,7 @@ export interface MultiSelectContextActions { highlightNext(): void; highlightPrevious(): void; highlightItem(id: string): void; + handleTypeaheadKey(key: string): boolean; setIsOpen(open: boolean, behavior?: UseMultiSelectFirstHighlightBehavior): void; toggleIsOpen(behavior?: UseMultiSelectFirstHighlightBehavior): void; } @@ -63,7 +64,7 @@ export interface MultiSelectContextConfig { setIds: Dispatch>; } -export interface MultiSelectContextType extends MultiSelectContextActions, MultiSelectContextInternalState, MultiSelectContextComputedState { +export interface MultiSelectContextType extends MultiSelectContextActions, MultiSelectContextState, MultiSelectContextComputedState { config: MultiSelectContextConfig; layout: MultiSelectContextLayout; search: MultiSelectContextSearch; diff --git a/src/components/user-interaction/MultiSelect/MultiSelectRoot.tsx b/src/components/user-interaction/MultiSelect/MultiSelectRoot.tsx index 52d2e9a..f0b42e4 100644 --- a/src/components/user-interaction/MultiSelect/MultiSelectRoot.tsx +++ b/src/components/user-interaction/MultiSelect/MultiSelectRoot.tsx @@ -155,6 +155,7 @@ export function MultiSelectRoot({ highlightNext: state.highlightNext, highlightPrevious: state.highlightPrevious, highlightItem: state.highlightItem, + handleTypeaheadKey: state.handleTypeaheadKey, setIsOpen: state.setIsOpen, toggleIsOpen: state.toggleOpen, config: { diff --git a/src/components/user-interaction/MultiSelect/useMultiSelect.ts b/src/components/user-interaction/MultiSelect/useMultiSelect.ts index 475d2ba..3fb1584 100644 --- a/src/components/user-interaction/MultiSelect/useMultiSelect.ts +++ b/src/components/user-interaction/MultiSelect/useMultiSelect.ts @@ -1,12 +1,14 @@ import { useCallback, + useEffect, useMemo, + useRef, useState, } from "react"; import { useMultiSelection } from "@/src/hooks/useMultiSelection"; import { useListNavigation } from "@/src/hooks/useListNavigation"; -import { MultiSearchWithMapping } from "@/src/utils/simpleSearch"; import { useEventCallbackStabilizer } from "@/src/hooks/useEventCallbackStabelizer"; +import { useSearch, useTypeAheadSearch } from "@/src/hooks"; export interface UseMultiSelectOption { id: string; @@ -22,45 +24,39 @@ export interface UseMultiSelectOptions { initialValue?: string[]; initialIsOpen?: boolean; onClose?: () => void; + typeAheadResetMs?: number; } export type UseMultiSelectFirstHighlightBehavior = "first" | "last"; -export interface UseMultiSelectOpenState { +export interface UseMultiSelectState { + value: string[]; + highlightedId: string | null; isOpen: boolean; - setIsOpen: (isOpen: boolean, behavior?: UseMultiSelectFirstHighlightBehavior) => void; - toggleOpen: (behavior?: UseMultiSelectFirstHighlightBehavior) => void; + searchQuery: string; + options: ReadonlyArray; } -export interface UseMultiSelectSearchState { - searchQuery: string; - setSearchQuery: (query: string) => void; +export interface UseMultiSelectComputedState { + visibleOptionIds: ReadonlyArray; } -export interface UseMultiSelectHighlightState { - highlightedId: string | null; +export interface UseMultiSelectActions { + setIsOpen: (isOpen: boolean, behavior?: UseMultiSelectFirstHighlightBehavior) => void; + toggleOpen: (behavior?: UseMultiSelectFirstHighlightBehavior) => void; + setSearchQuery: (query: string) => void; highlightFirst: () => void; highlightLast: () => void; highlightNext: () => void; highlightPrevious: () => void; highlightItem: (id: string) => void; -} - -export interface UseMultiSelectSelectionState { - value: string[]; toggleSelection: (id: string, isSelected?: boolean) => void; setSelection: (ids: string[]) => void; isSelected: (id: string) => boolean; + handleTypeaheadKey: (key: string) => boolean; } -export interface UseMultiSelectReturn - extends UseMultiSelectOpenState, - UseMultiSelectSearchState, - UseMultiSelectHighlightState, - UseMultiSelectSelectionState { - options: ReadonlyArray; - visibleOptionIds: ReadonlyArray; -} +export interface UseMultiSelectReturn extends UseMultiSelectState, UseMultiSelectComputedState, UseMultiSelectActions {} export function useMultiSelect({ options, @@ -70,6 +66,7 @@ export function useMultiSelect({ initialValue = [], onClose, initialIsOpen = false, + typeAheadResetMs = 500, }: UseMultiSelectOptions): UseMultiSelectReturn { const [isOpen, setIsOpen] = useState(initialIsOpen); const [searchQuery, setSearchQuery] = useState(""); @@ -90,11 +87,11 @@ export function useMultiSelect({ const editCompleteStable = useEventCallbackStabilizer(onEditComplete); const onCloseStable = useEventCallbackStabilizer(onClose); - const visibleOptions = useMemo(() => { - const q = searchQuery.trim().toLowerCase(); - if (!q) return options; - return MultiSearchWithMapping(searchQuery, [...options], (o) => [o.label ?? ""]); - }, [options, searchQuery]); + const { searchResult: visibleOptions } = useSearch({ + items: options, + searchQuery, + toTags: useCallback((o: UseMultiSelectOption) => [o.label ?? ""], []), + }); const visibleOptionIds = useMemo( () => visibleOptions.map((o) => o.id), @@ -111,21 +108,38 @@ export function useMultiSelect({ initialValue: selection.selection[0] ?? null, }); - const highlightState: UseMultiSelectHighlightState = useMemo( - () => ({ - highlightedId: listNav.highlightedId, - highlightFirst: listNav.first, - highlightLast: listNav.last, - highlightNext: listNav.next, - highlightPrevious: listNav.previous, - highlightItem: (id: string) => { - if (!enabledOptions.some((o) => o.id === id)) return; - listNav.highlight(id); + const typeAhead = useTypeAheadSearch({ + options: enabledOptions, + resetTimer: typeAheadResetMs, + toString: (o) => o.label ?? "", + onResultChange: useCallback( + (option: UseMultiSelectOption | null) => { + if (option) listNav.highlight(option.id); }, - }), + [listNav] + ), + }); + + useEffect(() => { + if (!isOpen) typeAhead.reset(); + }, [isOpen]); + + const highlightItem = useCallback( + (id: string) => { + if (!enabledOptions.some((o) => o.id === id)) return; + listNav.highlight(id); + }, [enabledOptions, listNav] ); + const handleTypeaheadKey = useCallback( + (key: string): boolean => { + typeAhead.addToTypeAhead(key.toLowerCase()); + return true; + }, + [typeAhead] + ); + const toggleSelectionValue = useCallback( (id: string, isSelected?: boolean) => { const before = selection.isSelected(id); @@ -135,19 +149,9 @@ export function useMultiSelect({ } else { selection.setSelection(selection.selection.filter((s) => s !== id)); } - highlightState.highlightItem(id); + highlightItem(id); }, - [selection, highlightState] - ); - - const selectionState: UseMultiSelectSelectionState = useMemo( - () => ({ - value: [...selection.selection], - toggleSelection: toggleSelectionValue, - setSelection: (ids: string[]) => selection.setSelection(ids), - isSelected: selection.isSelected, - }), - [selection.selection, selection.setSelection, selection.isSelected, toggleSelectionValue] + [selection, highlightItem] ); const setIsOpenWrapper = useCallback( @@ -156,19 +160,17 @@ export function useMultiSelect({ behavior = behavior ?? "first"; if (open) { if (enabledOptions.length > 0) { - let selected: UseMultiSelectOption | undefined - if(behavior === "first") { - selected = enabledOptions.find((o) => - selection.isSelected(o.id) - ); + let selected: UseMultiSelectOption | undefined; + if (behavior === "first") { + selected = enabledOptions.find((o) => selection.isSelected(o.id)); selected ??= enabledOptions[0]; } else if (behavior === "last") { - selected = [...enabledOptions].reverse().find((o) => - selection.isSelected(o.id) - ); + selected = [...enabledOptions] + .reverse() + .find((o) => selection.isSelected(o.id)); selected ??= enabledOptions[enabledOptions.length - 1]; } - if (selected) highlightState.highlightItem(selected.id); + if (selected) highlightItem(selected.id); } } else { setSearchQuery(""); @@ -177,7 +179,7 @@ export function useMultiSelect({ } }, [ - highlightState, + highlightItem, onCloseStable, editCompleteStable, selection.selection, @@ -186,41 +188,71 @@ export function useMultiSelect({ ] ); - const openState: UseMultiSelectOpenState = useMemo( + const toggleOpenWrapper = useCallback( + (behavior?: UseMultiSelectFirstHighlightBehavior) => { + setIsOpenWrapper(!isOpen, behavior); + }, + [isOpen, setIsOpenWrapper] + ); + + const state: UseMultiSelectState = useMemo( () => ({ + value: [...selection.selection], + highlightedId: listNav.highlightedId, isOpen, - setIsOpen: setIsOpenWrapper, - toggleOpen: (behavior?: UseMultiSelectFirstHighlightBehavior) => { - setIsOpenWrapper(!isOpen, behavior); - }, + searchQuery, + options, }), - [isOpen, setIsOpenWrapper] + [ + selection.selection, + listNav.highlightedId, + isOpen, + searchQuery, + options, + ] + ); + + const computedState: UseMultiSelectComputedState = useMemo( + () => ({ visibleOptionIds }), + [visibleOptionIds] ); - const searchState: UseMultiSelectSearchState = useMemo( + const actions: UseMultiSelectActions = useMemo( () => ({ - searchQuery, + setIsOpen: setIsOpenWrapper, + toggleOpen: toggleOpenWrapper, setSearchQuery, + highlightFirst: listNav.first, + highlightLast: listNav.last, + highlightNext: listNav.next, + highlightPrevious: listNav.previous, + highlightItem, + toggleSelection: toggleSelectionValue, + setSelection: (ids: string[]) => selection.setSelection(ids), + isSelected: selection.isSelected, + handleTypeaheadKey, }), - [searchQuery, setSearchQuery] + [ + setIsOpenWrapper, + toggleOpenWrapper, + listNav.first, + listNav.last, + listNav.next, + listNav.previous, + highlightItem, + toggleSelectionValue, + selection.setSelection, + selection.isSelected, + handleTypeaheadKey, + ] ); return useMemo( (): UseMultiSelectReturn => ({ - ...openState, - ...highlightState, - ...selectionState, - ...searchState, - options, - visibleOptionIds, + ...state, + ...computedState, + ...actions, }), - [ - openState, - highlightState, - selectionState, - searchState, - options, - visibleOptionIds, - ] + [state, computedState, actions] ); } diff --git a/src/components/user-interaction/Select/SelectContent.tsx b/src/components/user-interaction/Select/SelectContent.tsx index ccf19b3..934b69f 100644 --- a/src/components/user-interaction/Select/SelectContent.tsx +++ b/src/components/user-interaction/Select/SelectContent.tsx @@ -7,21 +7,17 @@ import { PopUp, type PopUpProps } from "@/src/components/layout/popup/PopUp"; import { Input } from "@/src/components/user-interaction/input/Input"; import { Visibility } from "@/src/components/layout/Visibility"; -const TYPEAHEAD_RESET_MS = 500; - export interface SelectContentProps extends PopUpProps { showSearch?: boolean; searchInputProps?: Omit, "value" | "onValueChange">; } -export const SelectContent = forwardRef(function SelectContent({ +export const SelectContent = forwardRef(function SelectContent({ id, options, showSearch: showSearchOverride, searchInputProps, ...props }, ref) { const translation = useHightideTranslation(); const innerRef = useRef(null); const searchInputRef = useRef(null); - const typeAheadBufferRef = useRef(""); - const typeAheadTimeoutRef = useRef | null>(null); useImperativeHandle(ref, () => innerRef.current!); const context = useSelectContext(); @@ -30,23 +26,6 @@ export const SelectContent = forwardRef(fu if (id) context.config.setIds((prev) => ({ ...prev, content: id })); }, [id, context.config.setIds]); - useEffect(() => { - if (!context.isOpen) { - typeAheadBufferRef.current = ""; - if (typeAheadTimeoutRef.current) { - clearTimeout(typeAheadTimeoutRef.current); - typeAheadTimeoutRef.current = null; - } - } - }, [context.isOpen]); - - useEffect( - () => () => { - if (typeAheadTimeoutRef.current) clearTimeout(typeAheadTimeoutRef.current); - }, - [] - ); - const showSearch = showSearchOverride ?? context.search.hasSearch; const listboxAriaLabel = showSearch ? translation("searchResults") : undefined; @@ -78,36 +57,15 @@ export const SelectContent = forwardRef(fu } break; default: - if (!showSearch && !event.ctrlKey && !event.metaKey && !event.altKey) { - const char = event.key.toLowerCase(); - if (typeAheadTimeoutRef.current) clearTimeout(typeAheadTimeoutRef.current); - typeAheadBufferRef.current += char; - typeAheadTimeoutRef.current = setTimeout(() => { - typeAheadBufferRef.current = ""; - }, TYPEAHEAD_RESET_MS); - const optionIds = context.visibleOptionIds; - const buf = typeAheadBufferRef.current; - if (optionIds.length === 0) { + if (!showSearch && !event.ctrlKey && !event.metaKey && !event.altKey && event.key.length === 1) { + if (context.handleTypeaheadKey(event.key)) { event.preventDefault(); - return; } - const currentIndex = optionIds.findIndex((id) => id === context.highlightedId); - const startFrom = currentIndex >= 0 ? (currentIndex + 1) % optionIds.length : 0; - for (let i = 0; i < optionIds.length; i++) { - const j = (startFrom + i) % optionIds.length; - const option = context.idToOptionMap[optionIds[j]]; - if (!option.disabled && option.label.toLowerCase().startsWith(buf)) { - context.highlightItem(option.id); - event.preventDefault(); - return; - } - } - event.preventDefault(); } break; } }, - [showSearch, context.visibleOptionIds, context.highlightedId, context.highlightItem, context.toggleSelection] + [showSearch, context] ); return ( diff --git a/src/components/user-interaction/Select/SelectContext.tsx b/src/components/user-interaction/Select/SelectContext.tsx index 48a5715..52a04d6 100644 --- a/src/components/user-interaction/Select/SelectContext.tsx +++ b/src/components/user-interaction/Select/SelectContext.tsx @@ -19,14 +19,14 @@ export interface SelectContextIds { searchInput: string; } -export interface SelectContextInternalState extends FormFieldInteractionStates { +export interface SelectContextState extends FormFieldInteractionStates { selectedId: string | null; + options: ReadonlyArray>; highlightedId: string | null; isOpen: boolean; } export interface SelectContextComputedState { - options: ReadonlyArray>; visibleOptionIds: ReadonlyArray; idToOptionMap: Record>; } @@ -39,6 +39,7 @@ export interface SelectContextActions { highlightNext(): void; highlightPrevious(): void; highlightItem(id: string): void; + handleTypeaheadKey(key: string): boolean; setIsOpen(open: boolean, behavior?: UseSelectFirstHighlightBehavior): void; toggleIsOpen(behavior?: UseSelectFirstHighlightBehavior): void; } @@ -62,7 +63,7 @@ export interface SelectContextConfig { setIds: Dispatch>; } -export interface SelectContextType extends SelectContextActions, SelectContextInternalState, SelectContextComputedState { +export interface SelectContextType extends SelectContextActions, SelectContextState, SelectContextComputedState { config: SelectContextConfig; layout: SelectContextLayout; search: SelectContextSearch; diff --git a/src/components/user-interaction/Select/SelectRoot.tsx b/src/components/user-interaction/Select/SelectRoot.tsx index 29fff7d..544e70d 100644 --- a/src/components/user-interaction/Select/SelectRoot.tsx +++ b/src/components/user-interaction/Select/SelectRoot.tsx @@ -169,6 +169,7 @@ export function SelectRoot({ highlightNext: state.highlightNext, highlightPrevious: state.highlightPrevious, highlightItem: state.highlightItem, + handleTypeaheadKey: state.handleTypeaheadKey, setIsOpen: state.setIsOpen, toggleIsOpen: state.toggleOpen, config, diff --git a/src/components/user-interaction/Select/useSelect.ts b/src/components/user-interaction/Select/useSelect.ts index 7fdf039..c28fa16 100644 --- a/src/components/user-interaction/Select/useSelect.ts +++ b/src/components/user-interaction/Select/useSelect.ts @@ -1,12 +1,14 @@ import { useCallback, + useEffect, useMemo, + useRef, useState, } from "react"; import { useSingleSelection } from "@/src/hooks/useSingleSelection"; import { useListNavigation } from "@/src/hooks/useListNavigation"; -import { MultiSearchWithMapping } from "@/src/utils/simpleSearch"; import { useEventCallbackStabilizer } from "@/src/hooks/useEventCallbackStabelizer"; +import { useSearch, useTypeAheadSearch } from "@/src/hooks"; export interface UseSelectOption { id: string; @@ -17,12 +19,13 @@ export interface UseSelectOption { export interface UseSelectOptions { options: ReadonlyArray; value?: string | null; - onValueChange?: (value: string) => void; - onEditComplete?: (value: string) => void; initialValue?: string | null; initialIsOpen?: boolean; + onValueChange?: (value: string) => void; + onEditComplete?: (value: string) => void; onClose?: () => void; onIsOpenChange?: (isOpen: boolean) => void; + typeAheadResetMs?: number; } export type UseSelectFirstHighlightBehavior = "first" | "last"; @@ -49,6 +52,7 @@ export interface UseSelectActions { highlightPrevious: () => void; highlightItem: (value: string) => void; selectValue: (value: string) => void; + handleTypeaheadKey: (key: string) => boolean; } export interface UseSelectReturn extends UseSelectState, UseSelectComputedState, UseSelectActions {} @@ -62,8 +66,9 @@ export function useSelect({ onClose, onIsOpenChange, initialIsOpen = false, + typeAheadResetMs = 500, }: UseSelectOptions): UseSelectReturn { - const [isOpen, setIsOpen] = useState(initialIsOpen); + const [isOpen, setIsOpen] = useState(initialIsOpen); const [searchQuery, setSearchQuery] = useState(""); const onValueChangeStable = useEventCallbackStabilizer(onValueChange); @@ -85,11 +90,11 @@ export function useSelect({ initialSelection: initialValue, }); - const visibleOptions = useMemo(() => { - const q = searchQuery.trim().toLowerCase(); - if (!q) return options; - return MultiSearchWithMapping(searchQuery, [...options], (o) => [o.label]); - }, [options, searchQuery]); + const { searchResult: visibleOptions } = useSearch({ + items: options, + searchQuery, + toTags: useCallback((o: UseSelectOption) => [o.label], []), + }); const visibleOptionIds = useMemo(() => visibleOptions.map((o) => o.id), [visibleOptions]); @@ -100,6 +105,22 @@ export function useSelect({ initialValue: selection.selection, }); + const typeAhead = useTypeAheadSearch({ + options: enabledOptions, + resetTimer: typeAheadResetMs, + toString: (o) => o.label ?? "", + onResultChange: useCallback( + (option: UseSelectOption | null) => { + if (option) listNav.highlight(option.id); + }, + [listNav] + ), + }); + + useEffect(() => { + if (!isOpen) typeAhead.reset(); + }, [isOpen]); + const state: UseSelectState = useMemo(() => ({ value: selection.selection, highlightedValue: listNav.highlightedId, @@ -114,9 +135,17 @@ export function useSelect({ const highlightItem = useCallback((value: string) => { if (!enabledOptions.some((o) => o.id === value)) return; - listNav.highlight(value) + listNav.highlight(value); }, [enabledOptions, listNav]); + const handleTypeaheadKey = useCallback( + (key: string): boolean => { + typeAhead.addToTypeAhead(key.toLowerCase()); + return true; + }, + [typeAhead] + ); + const setIsOpenWrapper = useCallback((isOpen: boolean, behavior?: UseSelectFirstHighlightBehavior) => { behavior = behavior ?? "first"; if(isOpen) { @@ -152,7 +181,8 @@ export function useSelect({ highlightNext: listNav.next, highlightPrevious: listNav.previous, highlightItem, - }), [setIsOpenWrapper, listNav.first, listNav.last, listNav.next, listNav.previous, highlightItem]); + handleTypeaheadKey, + }), [setIsOpenWrapper, listNav.first, listNav.last, listNav.next, listNav.previous, highlightItem, handleTypeaheadKey]); return useMemo(() => ({ ...state, diff --git a/src/hooks/useSearch.ts b/src/hooks/useSearch.ts index 5de7076..8548f97 100644 --- a/src/hooks/useSearch.ts +++ b/src/hooks/useSearch.ts @@ -1,72 +1,36 @@ -import { useCallback, useEffect, useMemo, useState } from 'react' -import { MultiSubjectSearchWithMapping } from '@/src/utils/simpleSearch' - -export type UseSearchProps = { - list: T[], - searchMapping: (item: T) => string[], - initialSearch?: string, - additionalSearchTags?: string[], - isSearchInstant?: boolean, - sortingFunction?: (a: T, b: T) => number, - filter?: (item: T) => boolean, - disabled?: boolean, +import { useMemo } from "react"; +import { MultiSearchWithMapping } from "@/src/utils/simpleSearch"; +import { useEventCallbackStabilizer } from "@/src/hooks/useEventCallbackStabelizer"; + +export interface UseSearchOptions { + items: ReadonlyArray; + searchQuery: string; + toTags?: (value: T) => string[]; } -export const useSearch = ({ - list, - initialSearch, - searchMapping, - additionalSearchTags, - isSearchInstant = true, - sortingFunction, - filter, - disabled = false, -}: UseSearchProps) => { - const [search, setSearch] = useState(initialSearch ?? '') - const [result, setResult] = useState(list) - const searchTags = useMemo(() => additionalSearchTags ?? [], [additionalSearchTags]) - - const updateSearch = useCallback((newSearch?: string) => { - const usedSearch = newSearch ?? search - if (newSearch) { - setSearch(search) - } - setResult(MultiSubjectSearchWithMapping([usedSearch, ...searchTags], list, searchMapping)) - }, [searchTags, list, search, searchMapping]) - - useEffect(() => { - if (isSearchInstant) { - setResult(MultiSubjectSearchWithMapping([search, ...searchTags], list, searchMapping)) - } - }, [searchTags, isSearchInstant, list, search, searchMapping, additionalSearchTags]) - - const filteredResult: T[] = useMemo(() => { - if (!filter) { - return result - } - return result.filter(filter) - }, [result, filter]) - - const sortedAndFilteredResult: T[] = useMemo(() => { - if (!sortingFunction) { - return filteredResult - } - return filteredResult.sort(sortingFunction) - }, [filteredResult, sortingFunction]) +export interface UseSearchReturn { + searchResult: ReadonlyArray; +} - const usedResult = useMemo(() => { - if (!disabled) { - return sortedAndFilteredResult - } - return list - }, [disabled, list, sortedAndFilteredResult]) +function defaultToTags(value: T): string[] { + return [String(value)]; +} - return { - result: usedResult, - hasResult: usedResult.length > 0, - allItems: list, - updateSearch, - search, - setSearch, - } -} \ No newline at end of file +export function useSearch({ + items, + searchQuery, + toTags, +}: UseSearchOptions): UseSearchReturn { + const toTagsResolved = toTags ?? defaultToTags; + const toTagsStable = useEventCallbackStabilizer(toTagsResolved); + + const searchResult = useMemo(() => { + const q = searchQuery.trim().toLowerCase(); + if (!q) return items; + return MultiSearchWithMapping(searchQuery, [...items], (item) => toTagsStable(item)); + }, [items, searchQuery, toTags, toTagsStable]); + + return useMemo((): UseSearchReturn => ({ + searchResult, + }), [searchResult]); +} diff --git a/src/hooks/useTypeAheadSearch.ts b/src/hooks/useTypeAheadSearch.ts new file mode 100644 index 0000000..4e6dbaf --- /dev/null +++ b/src/hooks/useTypeAheadSearch.ts @@ -0,0 +1,68 @@ +import { useCallback, useEffect, useRef } from "react"; +import { useEventCallbackStabilizer } from "@/src/hooks/useEventCallbackStabelizer"; + +export interface UseTypeAheadSearchOptions { + options: ReadonlyArray; + resetTimer: number; + toString?: (value: T) => string; + onResultChange: (value: T | null) => void; +} + +export interface UseTypeAheadSearchReturn { + addToTypeAhead: (str: string) => void; + reset: () => void; +} + +function defaultToString(value: T): string { + return String(value); +} + +export function useTypeAheadSearch({ + options, + resetTimer, + toString: toStringProp, + onResultChange, +}: UseTypeAheadSearchOptions): UseTypeAheadSearchReturn { + const bufferRef = useRef(""); + const timeoutRef = useRef | null>(null); + + const toString = toStringProp ?? defaultToString; + const toStringStable = useEventCallbackStabilizer(toString); + const onResultChangeStable = useEventCallbackStabilizer(onResultChange); + + const reset = useCallback(() => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } + bufferRef.current = ""; + onResultChangeStable(null); + }, [onResultChangeStable]); + + useEffect(() => () => { + if (timeoutRef.current) clearTimeout(timeoutRef.current); + }, []); + + const addToTypeAhead = useCallback((str: string) => { + if (timeoutRef.current) clearTimeout(timeoutRef.current); + bufferRef.current += str; + timeoutRef.current = setTimeout(() => { + timeoutRef.current = null; + bufferRef.current = ""; + onResultChangeStable(null); + }, resetTimer); + + const buf = bufferRef.current.trim().toLowerCase(); + if (!buf) { + onResultChangeStable(null); + return; + } + const found = options.find((opt) => { + const s = toStringStable(opt)?.trim().toLowerCase() ?? ""; + return s.startsWith(buf); + }); + onResultChangeStable(found ?? null); + }, [options, resetTimer, toStringStable, onResultChangeStable]); + + return { addToTypeAhead, reset }; +} From b63c46d900cfee68e59de3fcdcc56274caadc9f9 Mon Sep 17 00:00:00 2001 From: DasProffi <67233923+DasProffi@users.noreply.github.com> Date: Sat, 14 Mar 2026 09:19:59 +0100 Subject: [PATCH 11/13] chore: remove unused typeahead return value from Selects --- .../MultiSelect/MultiSelectContext.tsx | 2 +- .../user-interaction/MultiSelect/useMultiSelect.ts | 14 +++----------- .../user-interaction/Select/SelectContext.tsx | 2 +- .../user-interaction/Select/useSelect.ts | 14 +++----------- 4 files changed, 8 insertions(+), 24 deletions(-) diff --git a/src/components/user-interaction/MultiSelect/MultiSelectContext.tsx b/src/components/user-interaction/MultiSelect/MultiSelectContext.tsx index f782c1c..ff9af02 100644 --- a/src/components/user-interaction/MultiSelect/MultiSelectContext.tsx +++ b/src/components/user-interaction/MultiSelect/MultiSelectContext.tsx @@ -40,7 +40,7 @@ export interface MultiSelectContextActions { highlightNext(): void; highlightPrevious(): void; highlightItem(id: string): void; - handleTypeaheadKey(key: string): boolean; + handleTypeaheadKey(key: string): void; setIsOpen(open: boolean, behavior?: UseMultiSelectFirstHighlightBehavior): void; toggleIsOpen(behavior?: UseMultiSelectFirstHighlightBehavior): void; } diff --git a/src/components/user-interaction/MultiSelect/useMultiSelect.ts b/src/components/user-interaction/MultiSelect/useMultiSelect.ts index 3fb1584..6b50212 100644 --- a/src/components/user-interaction/MultiSelect/useMultiSelect.ts +++ b/src/components/user-interaction/MultiSelect/useMultiSelect.ts @@ -53,7 +53,7 @@ export interface UseMultiSelectActions { toggleSelection: (id: string, isSelected?: boolean) => void; setSelection: (ids: string[]) => void; isSelected: (id: string) => boolean; - handleTypeaheadKey: (key: string) => boolean; + handleTypeaheadKey: (key: string) => void; } export interface UseMultiSelectReturn extends UseMultiSelectState, UseMultiSelectComputedState, UseMultiSelectActions {} @@ -132,14 +132,6 @@ export function useMultiSelect({ [enabledOptions, listNav] ); - const handleTypeaheadKey = useCallback( - (key: string): boolean => { - typeAhead.addToTypeAhead(key.toLowerCase()); - return true; - }, - [typeAhead] - ); - const toggleSelectionValue = useCallback( (id: string, isSelected?: boolean) => { const before = selection.isSelected(id); @@ -230,7 +222,7 @@ export function useMultiSelect({ toggleSelection: toggleSelectionValue, setSelection: (ids: string[]) => selection.setSelection(ids), isSelected: selection.isSelected, - handleTypeaheadKey, + handleTypeaheadKey: typeAhead.addToTypeAhead, }), [ setIsOpenWrapper, @@ -243,7 +235,7 @@ export function useMultiSelect({ toggleSelectionValue, selection.setSelection, selection.isSelected, - handleTypeaheadKey, + typeAhead.addToTypeAhead, ] ); diff --git a/src/components/user-interaction/Select/SelectContext.tsx b/src/components/user-interaction/Select/SelectContext.tsx index 52a04d6..f3ee032 100644 --- a/src/components/user-interaction/Select/SelectContext.tsx +++ b/src/components/user-interaction/Select/SelectContext.tsx @@ -39,7 +39,7 @@ export interface SelectContextActions { highlightNext(): void; highlightPrevious(): void; highlightItem(id: string): void; - handleTypeaheadKey(key: string): boolean; + handleTypeaheadKey(key: string): void; setIsOpen(open: boolean, behavior?: UseSelectFirstHighlightBehavior): void; toggleIsOpen(behavior?: UseSelectFirstHighlightBehavior): void; } diff --git a/src/components/user-interaction/Select/useSelect.ts b/src/components/user-interaction/Select/useSelect.ts index c28fa16..8f49553 100644 --- a/src/components/user-interaction/Select/useSelect.ts +++ b/src/components/user-interaction/Select/useSelect.ts @@ -52,7 +52,7 @@ export interface UseSelectActions { highlightPrevious: () => void; highlightItem: (value: string) => void; selectValue: (value: string) => void; - handleTypeaheadKey: (key: string) => boolean; + handleTypeaheadKey: (key: string) => void; } export interface UseSelectReturn extends UseSelectState, UseSelectComputedState, UseSelectActions {} @@ -138,14 +138,6 @@ export function useSelect({ listNav.highlight(value); }, [enabledOptions, listNav]); - const handleTypeaheadKey = useCallback( - (key: string): boolean => { - typeAhead.addToTypeAhead(key.toLowerCase()); - return true; - }, - [typeAhead] - ); - const setIsOpenWrapper = useCallback((isOpen: boolean, behavior?: UseSelectFirstHighlightBehavior) => { behavior = behavior ?? "first"; if(isOpen) { @@ -181,8 +173,8 @@ export function useSelect({ highlightNext: listNav.next, highlightPrevious: listNav.previous, highlightItem, - handleTypeaheadKey, - }), [setIsOpenWrapper, listNav.first, listNav.last, listNav.next, listNav.previous, highlightItem, handleTypeaheadKey]); + handleTypeaheadKey: typeAhead.addToTypeAhead, + }), [setIsOpenWrapper, listNav.first, listNav.last, listNav.next, listNav.previous, highlightItem, typeAhead.addToTypeAhead]); return useMemo(() => ({ ...state, From 78f82db074547cedc5770df6e58f4d21c527a39d Mon Sep 17 00:00:00 2001 From: DasProffi <67233923+DasProffi@users.noreply.github.com> Date: Sat, 14 Mar 2026 10:16:47 +0100 Subject: [PATCH 12/13] fix: fix lint --- CHANGELOG.md | 3 + package-lock.json | 689 ++++++------------ package.json | 4 +- .../user-interaction/Combobox/Combobox.tsx | 33 +- .../Combobox/ComboboxContext.tsx | 68 +- .../Combobox/ComboboxInput.tsx | 75 +- .../Combobox/ComboboxList.tsx | 44 +- .../Combobox/ComboboxOption.tsx | 69 +- .../Combobox/ComboboxRoot.tsx | 77 +- .../user-interaction/Combobox/useCombobox.ts | 70 +- .../MultiSelect/MultiSelect.tsx | 22 +- .../MultiSelect/MultiSelectButton.tsx | 123 ++-- .../MultiSelect/MultiSelectChipDisplay.tsx | 97 +-- .../MultiSelect/MultiSelectContent.tsx | 133 ++-- .../MultiSelect/MultiSelectContext.tsx | 96 +-- .../MultiSelect/MultiSelectOption.tsx | 92 +-- .../MultiSelect/MultiSelectRoot.tsx | 128 ++-- .../MultiSelect/useMultiSelect.ts | 207 +++--- .../user-interaction/Select/Select.tsx | 34 +- .../user-interaction/Select/SelectButton.tsx | 106 +-- .../user-interaction/Select/SelectContent.tsx | 129 ++-- .../user-interaction/Select/SelectContext.tsx | 88 +-- .../user-interaction/Select/SelectOption.tsx | 92 +-- .../user-interaction/Select/SelectRoot.tsx | 152 ++-- .../user-interaction/Select/useSelect.ts | 193 ++--- .../user-interaction/data/FilterList.tsx | 2 +- .../user-interaction/data/FilterPopUp.tsx | 2 +- src/hooks/useListNavigation.tsx | 74 +- src/hooks/useMultiSelection.ts | 89 +-- src/hooks/useSearch.ts | 30 +- src/hooks/useSingleSelection.ts | 104 +-- src/hooks/useTypeAheadSearch.ts | 80 +- .../Layout/Table/FilterListTable.stories.tsx | 2 +- stories/User Interaction/Combobox.stories.tsx | 42 +- .../MultiSelect/MultiSelect.stories.tsx | 127 ++++ .../MultiSelectChipDisplay.stories.tsx | 0 .../Select/MultiSelect.stories.tsx | 39 - .../Select/Select.stories.tsx | 120 ++- 38 files changed, 1739 insertions(+), 1796 deletions(-) create mode 100644 stories/User Interaction/MultiSelect/MultiSelect.stories.tsx rename stories/User Interaction/{Select => MultiSelect}/MultiSelectChipDisplay.stories.tsx (100%) delete mode 100644 stories/User Interaction/Select/MultiSelect.stories.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 5695f3e..d59ddc4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,9 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## Fixed - imports in `TimePicker` and `DateTimeInput` +## Security +- update packages + ## [0.8.12] - 2026-02-15 ### Fixed diff --git a/package-lock.json b/package-lock.json index 0284df1..d5143c3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -40,7 +40,7 @@ "@types/tinycolor2": "1.4.6", "@vitest/mocker": "4.0.16", "autoprefixer": "10.4.23", - "eslint": "10.0.1", + "eslint": "9.31.0", "eslint-plugin-storybook": "10.2.10", "jest": "30.2.0", "postcss": "8.5.6", @@ -2532,44 +2532,41 @@ } }, "node_modules/@eslint/config-array": { - "version": "0.23.2", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.2.tgz", - "integrity": "sha512-YF+fE6LV4v5MGWRGj7G404/OZzGNepVF8fxk7jqmqo3lrza7a0uUcDnROGRBG1WFC1omYUS/Wp1f42i0M+3Q3A==", + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/object-schema": "^3.0.2", + "@eslint/object-schema": "^2.1.7", "debug": "^4.3.1", - "minimatch": "^10.2.1" + "minimatch": "^3.1.5" }, "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, "node_modules/@eslint/config-helpers": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.2.tgz", - "integrity": "sha512-a5MxrdDXEvqnIq+LisyCX6tQMPF/dSJpCfBgBauY+pNZ28yCtSsTvyTYrMhaI+LK26bVyCJfJkT0u8KIj2i1dQ==", + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.3.1.tgz", + "integrity": "sha512-xR93k9WhrDYpXHORXpxVL5oHj3Era7wo6k/Wd8/IsQNnZUTzkGS29lyn3nAT05v6ltUuTFVCCYDEGfy2Or/sPA==", "dev": true, "license": "Apache-2.0", - "dependencies": { - "@eslint/core": "^1.1.0" - }, "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, "node_modules/@eslint/core": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.1.0.tgz", - "integrity": "sha512-/nr9K9wkr3P1EzFTdFdMoLuo1PmIxjmwvPozwoSodjNBdefGujXQUF93u1DDZpEaTuDvMsIQddsd35BwtrW9Xw==", + "version": "0.15.2", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.2.tgz", + "integrity": "sha512-78Md3/Rrxh83gCxoUc0EiciuOHsIITzLy53m3d9UyiW8y9Dj2D29FeETqyKA+BRK76tnTp6RXWb3pCay8Oyomg==", "dev": true, "license": "Apache-2.0", "dependencies": { "@types/json-schema": "^7.0.15" }, "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, "node_modules/@eslint/eslintrc": { @@ -2596,61 +2593,6 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/@eslint/eslintrc/node_modules/eslint-visitor-keys": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", - "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@eslint/eslintrc/node_modules/espree": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", - "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "acorn": "^8.15.0", - "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^4.2.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@eslint/eslintrc/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, "node_modules/@eslint/js": { "version": "9.39.3", "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.3.tgz", @@ -2665,27 +2607,27 @@ } }, "node_modules/@eslint/object-schema": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.2.tgz", - "integrity": "sha512-HOy56KJt48Bx8KmJ+XGQNSUMT/6dZee/M54XyUyuvTvPXJmsERRvBchsUVx1UMe1WwIH49XLAczNC7V2INsuUw==", + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", "dev": true, "license": "Apache-2.0", "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, "node_modules/@eslint/plugin-kit": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.6.0.tgz", - "integrity": "sha512-bIZEUzOI1jkhviX2cp5vNyXQc6olzb2ohewQubuYlMXZ2Q/XjBO0x0XhGPvc9fjSIiUN0vw+0hq53BJ4eQSJKQ==", + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.5.tgz", + "integrity": "sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^1.1.0", + "@eslint/core": "^0.15.2", "levn": "^0.4.1" }, "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, "node_modules/@faker-js/faker": { @@ -2722,21 +2664,6 @@ "typescript-eslint": "^8.32.1" } }, - "node_modules/@helpwave/eslint-config/node_modules/@eslint/config-array": { - "version": "0.21.1", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", - "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/object-schema": "^2.1.7", - "debug": "^4.3.1", - "minimatch": "^3.1.2" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, "node_modules/@helpwave/eslint-config/node_modules/@eslint/config-helpers": { "version": "0.4.2", "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", @@ -2763,16 +2690,6 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@helpwave/eslint-config/node_modules/@eslint/object-schema": { - "version": "2.1.7", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", - "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, "node_modules/@helpwave/eslint-config/node_modules/@eslint/plugin-kit": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", @@ -2787,17 +2704,6 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@helpwave/eslint-config/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, "node_modules/@helpwave/eslint-config/node_modules/eslint": { "version": "9.39.3", "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.3.tgz", @@ -2904,23 +2810,6 @@ "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" } }, - "node_modules/@helpwave/eslint-config/node_modules/eslint-scope": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", - "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "esrecurse": "^4.3.0", - "estraverse": "^5.2.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, "node_modules/@helpwave/eslint-config/node_modules/eslint-visitor-keys": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", @@ -2934,37 +2823,6 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/@helpwave/eslint-config/node_modules/espree": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", - "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "acorn": "^8.15.0", - "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^4.2.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@helpwave/eslint-config/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, "node_modules/@helpwave/eslint-config/node_modules/resolve": { "version": "2.0.0-next.6", "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.6.tgz", @@ -3968,13 +3826,13 @@ } }, "node_modules/@jest/core/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", "dev": true, "license": "ISC", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^2.0.2" }, "engines": { "node": ">=16 || 14 >=14.17" @@ -4170,13 +4028,13 @@ } }, "node_modules/@jest/reporters/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", "dev": true, "license": "ISC", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^2.0.2" }, "engines": { "node": ">=16 || 14 >=14.17" @@ -5051,9 +4909,9 @@ } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", - "integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", "cpu": [ "arm" ], @@ -5065,9 +4923,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz", - "integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", "cpu": [ "arm64" ], @@ -5079,9 +4937,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz", - "integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", "cpu": [ "arm64" ], @@ -5093,9 +4951,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz", - "integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", "cpu": [ "x64" ], @@ -5107,9 +4965,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz", - "integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", "cpu": [ "arm64" ], @@ -5121,9 +4979,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz", - "integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", "cpu": [ "x64" ], @@ -5135,9 +4993,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz", - "integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", "cpu": [ "arm" ], @@ -5149,9 +5007,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz", - "integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", "cpu": [ "arm" ], @@ -5163,9 +5021,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz", - "integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", "cpu": [ "arm64" ], @@ -5177,9 +5035,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz", - "integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", "cpu": [ "arm64" ], @@ -5191,9 +5049,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz", - "integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", "cpu": [ "loong64" ], @@ -5205,9 +5063,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz", - "integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", "cpu": [ "loong64" ], @@ -5219,9 +5077,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz", - "integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", "cpu": [ "ppc64" ], @@ -5233,9 +5091,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz", - "integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", "cpu": [ "ppc64" ], @@ -5247,9 +5105,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz", - "integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", "cpu": [ "riscv64" ], @@ -5261,9 +5119,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz", - "integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", "cpu": [ "riscv64" ], @@ -5275,9 +5133,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz", - "integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", "cpu": [ "s390x" ], @@ -5289,9 +5147,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz", - "integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", "cpu": [ "x64" ], @@ -5303,9 +5161,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz", - "integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", "cpu": [ "x64" ], @@ -5317,9 +5175,9 @@ ] }, "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz", - "integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", "cpu": [ "x64" ], @@ -5331,9 +5189,9 @@ ] }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz", - "integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", "cpu": [ "arm64" ], @@ -5345,9 +5203,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz", - "integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", "cpu": [ "arm64" ], @@ -5359,9 +5217,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz", - "integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", "cpu": [ "ia32" ], @@ -5373,9 +5231,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz", - "integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", "cpu": [ "x64" ], @@ -5387,9 +5245,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz", - "integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", "cpu": [ "x64" ], @@ -6130,24 +5988,6 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/@stylistic/eslint-plugin/node_modules/espree": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", - "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "acorn": "^8.15.0", - "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^4.2.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, "node_modules/@swc/helpers": { "version": "0.5.15", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", @@ -6677,13 +6517,6 @@ "@types/estree": "*" } }, - "node_modules/@types/esrecurse": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", - "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -7024,13 +6857,13 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", "dev": true, "license": "ISC", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^2.0.2" }, "engines": { "node": ">=16 || 14 >=14.17" @@ -8506,26 +8339,14 @@ "license": "ISC" }, "node_modules/brace-expansion": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.2.tgz", - "integrity": "sha512-Pdk8c9poy+YhOgVWw1JNN22/HcivgKWwpxKq04M/jTmHyCZn12WPJebZxdjSa5TmBqISrUSgNYU3eRORljfCCw==", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "dev": true, "license": "MIT", "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/brace-expansion/node_modules/balanced-match": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.3.tgz", - "integrity": "sha512-1pHv8LX9CpKut1Zp4EXey7Z8OfH11ONNH6Dhi2WDUt31VVZFXZzKwXcysBgqSumFCmR+0dqjMK5v5JiFHzi0+g==", - "dev": true, - "license": "MIT", - "engines": { - "node": "20 || >=22" + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" } }, "node_modules/braces": { @@ -10223,30 +10044,34 @@ } }, "node_modules/eslint": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.0.1.tgz", - "integrity": "sha512-20MV9SUdeN6Jd84xESsKhRly+/vxI+hwvpBMA93s+9dAcjdCuCojn4IqUGS3lvVaqjVYGYHSRMCpeFtF2rQYxQ==", + "version": "9.31.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.31.0.tgz", + "integrity": "sha512-QldCVh/ztyKJJZLr4jXNUByx3gR+TDYZCRXEktiZoUR3PGy4qCmSbkxcIle8GEwGpb5JBZazlaJ/CxLidXdEbQ==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/eslint-utils": "^4.8.0", - "@eslint-community/regexpp": "^4.12.2", - "@eslint/config-array": "^0.23.2", - "@eslint/config-helpers": "^0.5.2", - "@eslint/core": "^1.1.0", - "@eslint/plugin-kit": "^0.6.0", + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.0", + "@eslint/config-helpers": "^0.3.0", + "@eslint/core": "^0.15.0", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.31.0", + "@eslint/plugin-kit": "^0.3.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", + "@types/json-schema": "^7.0.15", "ajv": "^6.12.4", + "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", - "eslint-scope": "^9.1.1", - "eslint-visitor-keys": "^5.0.1", - "espree": "^11.1.1", - "esquery": "^1.7.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", @@ -10256,7 +10081,8 @@ "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", - "minimatch": "^10.2.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, @@ -10264,7 +10090,7 @@ "eslint": "bin/eslint.js" }, "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "url": "https://eslint.org/donate" @@ -10293,19 +10119,17 @@ } }, "node_modules/eslint-scope": { - "version": "9.1.1", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.1.tgz", - "integrity": "sha512-GaUN0sWim5qc8KVErfPBWmc31LEsOkrUJbvJZV+xuL3u2phMUK4HIvXlWAakfC8W4nzlK+chPEAkYOYb5ZScIw==", + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", "dev": true, "license": "BSD-2-Clause", "dependencies": { - "@types/esrecurse": "^4.3.1", - "@types/estree": "^1.0.8", "esrecurse": "^4.3.0", "estraverse": "^5.2.0" }, "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "url": "https://opencollective.com/eslint" @@ -10324,19 +10148,58 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/eslint/node_modules/@eslint/js": { + "version": "9.31.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.31.0.tgz", + "integrity": "sha512-LOm5OVt7D4qiKCqoiPbA7LWmI+tbw1VbTUowBcUMgQSuM6poJufkFkYDcQpo5KfgD39TnNySV26QjOh7VFpSyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/eslint/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, "node_modules/espree": { - "version": "11.1.1", - "resolved": "https://registry.npmjs.org/espree/-/espree-11.1.1.tgz", - "integrity": "sha512-AVHPqQoZYc+RUM4/3Ly5udlZY/U4LS8pIG05jEjWM2lQMU/oaZ7qshzAl2YP1tfNmXfftH3ohurfwNAug+MnsQ==", + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", "dev": true, "license": "BSD-2-Clause", "dependencies": { - "acorn": "^8.16.0", + "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^5.0.1" + "eslint-visitor-keys": "^4.2.1" }, "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "url": "https://opencollective.com/eslint" @@ -10721,9 +10584,9 @@ } }, "node_modules/flatted": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", - "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.1.tgz", + "integrity": "sha512-IxfVbRFVlV8V/yRaGzk0UVIcsKKHMSfYw66T/u4nTwlWteQePsxe//LjudR1AMX4tZW3WFCh3Zqa/sjlqpbURQ==", "dev": true, "license": "ISC" }, @@ -10801,30 +10664,6 @@ "webpack": "^5.11.0" } }, - "node_modules/fork-ts-checker-webpack-plugin/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/fork-ts-checker-webpack-plugin/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, "node_modules/fork-ts-checker-webpack-plugin/node_modules/schema-utils": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", @@ -11108,30 +10947,6 @@ "dev": true, "license": "BSD-2-Clause" }, - "node_modules/glob/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/glob/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, "node_modules/globals": { "version": "14.0.0", "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", @@ -12522,13 +12337,13 @@ } }, "node_modules/jest-cli/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", "dev": true, "license": "ISC", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^2.0.2" }, "engines": { "node": ">=16 || 14 >=14.17" @@ -12874,13 +12689,13 @@ } }, "node_modules/jest-runtime/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", "dev": true, "license": "ISC", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^2.0.2" }, "engines": { "node": ">=16 || 14 >=14.17" @@ -13841,19 +13656,16 @@ "license": "MIT" }, "node_modules/minimatch": { - "version": "10.2.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.2.tgz", - "integrity": "sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, - "license": "BlueOak-1.0.0", + "license": "ISC", "dependencies": { - "brace-expansion": "^5.0.2" + "brace-expansion": "^1.1.7" }, "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "node": "*" } }, "node_modules/minimist": { @@ -15750,9 +15562,9 @@ "license": "MIT" }, "node_modules/rollup": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz", - "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", "dev": true, "license": "MIT", "dependencies": { @@ -15766,31 +15578,31 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.57.1", - "@rollup/rollup-android-arm64": "4.57.1", - "@rollup/rollup-darwin-arm64": "4.57.1", - "@rollup/rollup-darwin-x64": "4.57.1", - "@rollup/rollup-freebsd-arm64": "4.57.1", - "@rollup/rollup-freebsd-x64": "4.57.1", - "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", - "@rollup/rollup-linux-arm-musleabihf": "4.57.1", - "@rollup/rollup-linux-arm64-gnu": "4.57.1", - "@rollup/rollup-linux-arm64-musl": "4.57.1", - "@rollup/rollup-linux-loong64-gnu": "4.57.1", - "@rollup/rollup-linux-loong64-musl": "4.57.1", - "@rollup/rollup-linux-ppc64-gnu": "4.57.1", - "@rollup/rollup-linux-ppc64-musl": "4.57.1", - "@rollup/rollup-linux-riscv64-gnu": "4.57.1", - "@rollup/rollup-linux-riscv64-musl": "4.57.1", - "@rollup/rollup-linux-s390x-gnu": "4.57.1", - "@rollup/rollup-linux-x64-gnu": "4.57.1", - "@rollup/rollup-linux-x64-musl": "4.57.1", - "@rollup/rollup-openbsd-x64": "4.57.1", - "@rollup/rollup-openharmony-arm64": "4.57.1", - "@rollup/rollup-win32-arm64-msvc": "4.57.1", - "@rollup/rollup-win32-ia32-msvc": "4.57.1", - "@rollup/rollup-win32-x64-gnu": "4.57.1", - "@rollup/rollup-win32-x64-msvc": "4.57.1", + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", "fsevents": "~2.3.2" } }, @@ -16021,16 +15833,6 @@ "semver": "bin/semver.js" } }, - "node_modules/serialize-javascript": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", - "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "randombytes": "^2.1.0" - } - }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", @@ -16864,16 +16666,15 @@ } }, "node_modules/terser-webpack-plugin": { - "version": "5.3.16", - "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.16.tgz", - "integrity": "sha512-h9oBFCWrq78NyWWVcSwZarJkZ01c2AyGrzs1crmHZO3QUg9D61Wu4NPjBy69n7JqylFF5y+CsUZYmYEIZ3mR+Q==", + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.4.0.tgz", + "integrity": "sha512-Bn5vxm48flOIfkdl5CaD2+1CiUVbonWQ3KQPyP7/EuIl9Gbzq/gQFOzaMFUEgVjB1396tcK0SG8XcNJ/2kDH8g==", "dev": true, "license": "MIT", "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "jest-worker": "^27.4.5", "schema-utils": "^4.3.0", - "serialize-javascript": "^6.0.2", "terser": "^5.31.1" }, "engines": { @@ -16951,30 +16752,6 @@ "node": ">=8" } }, - "node_modules/test-exclude/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/test-exclude/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, "node_modules/thenify": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", diff --git a/package.json b/package.json index 959990e..dcaff71 100644 --- a/package.json +++ b/package.json @@ -66,7 +66,7 @@ "@types/tinycolor2": "1.4.6", "@vitest/mocker": "4.0.16", "autoprefixer": "10.4.23", - "eslint": "10.0.1", + "eslint": "9.31.0", "eslint-plugin-storybook": "10.2.10", "jest": "30.2.0", "postcss": "8.5.6", @@ -88,4 +88,4 @@ "overrides": { "elliptic": "^6.6.1" } -} +} \ No newline at end of file diff --git a/src/components/user-interaction/Combobox/Combobox.tsx b/src/components/user-interaction/Combobox/Combobox.tsx index 2c5e9ff..4884015 100644 --- a/src/components/user-interaction/Combobox/Combobox.tsx +++ b/src/components/user-interaction/Combobox/Combobox.tsx @@ -1,19 +1,20 @@ -import { forwardRef, type ReactNode, JSX } from "react"; -import { ComboboxRoot } from "./ComboboxRoot"; -import { ComboboxInput } from "./ComboboxInput"; -import { ComboboxList } from "./ComboboxList"; -import type { ComboboxInputProps } from "./ComboboxInput"; -import type { ComboboxListProps } from "./ComboboxList"; +import type { JSX } from 'react' +import { forwardRef, type ReactNode } from 'react' +import { ComboboxRoot } from './ComboboxRoot' +import { ComboboxInput } from './ComboboxInput' +import { ComboboxList } from './ComboboxList' +import type { ComboboxInputProps } from './ComboboxInput' +import type { ComboboxListProps } from './ComboboxList' export interface ComboboxProps { - children: ReactNode; - onItemClick?: (value: T) => void; - id?: string; - searchQuery?: string; - onSearchQueryChange?: (value: string) => void; - initialSearchQuery?: string; - inputProps?: ComboboxInputProps; - listProps?: ComboboxListProps; + children: ReactNode, + onItemClick?: (value: T) => void, + id?: string, + searchQuery?: string, + onSearchQueryChange?: (value: string) => void, + initialSearchQuery?: string, + inputProps?: ComboboxInputProps, + listProps?: ComboboxListProps, } export const Combobox = forwardRef>(function Combobox ({ @@ -35,5 +36,5 @@ export const Combobox = forwardRef>(fun {children} - ); -}) as (props: ComboboxProps & React.RefAttributes) => JSX.Element; + ) +}) as (props: ComboboxProps & React.RefAttributes) => JSX.Element diff --git a/src/components/user-interaction/Combobox/ComboboxContext.tsx b/src/components/user-interaction/Combobox/ComboboxContext.tsx index 85d97f4..064cc0e 100644 --- a/src/components/user-interaction/Combobox/ComboboxContext.tsx +++ b/src/components/user-interaction/Combobox/ComboboxContext.tsx @@ -1,67 +1,67 @@ -import type { Dispatch, ReactNode, RefObject, SetStateAction } from "react"; -import { createContext, useContext } from "react"; +import type { Dispatch, ReactNode, RefObject, SetStateAction } from 'react' +import { createContext, useContext } from 'react' export interface ComboboxOptionType { - id: string; - value: T; - label?: string; - display?: ReactNode; - disabled?: boolean; - ref: RefObject; + id: string, + value: T, + label?: string, + display?: ReactNode, + disabled?: boolean, + ref: RefObject, } export interface ComboboxContextIds { - trigger: string; - listbox: string; + trigger: string, + listbox: string, } export interface ComboboxContextInternalState { - highlightedId: string | null; + highlightedId: string | null, } export interface ComboboxContextComputedState { - options: ReadonlyArray>; - visibleOptionIds: ReadonlyArray; - idToOptionMap: Record>; + options: ReadonlyArray>, + visibleOptionIds: ReadonlyArray, + idToOptionMap: Record>, } export interface ComboboxContextActions { - registerOption(option: ComboboxOptionType): () => void; - selectOption(id: string): void; - highlightFirst(): void; - highlightLast(): void; - highlightNext(): void; - highlightPrevious(): void; - highlightItem(id: string): void; + registerOption(option: ComboboxOptionType): () => void, + selectOption(id: string): void, + highlightFirst(): void, + highlightLast(): void, + highlightNext(): void, + highlightPrevious(): void, + highlightItem(id: string): void, } export interface ComboboxContextLayout { - listRef: RefObject; - registerList(ref: RefObject): () => void; + listRef: RefObject, + registerList(ref: RefObject): () => void, } export interface ComboboxContextSearch { - searchQuery: string; - setSearchQuery(query: string): void; + searchQuery: string, + setSearchQuery(query: string): void, } export interface ComboboxContextConfig { - ids: ComboboxContextIds; - setIds: Dispatch>; + ids: ComboboxContextIds, + setIds: Dispatch>, } export interface ComboboxContextType extends ComboboxContextInternalState, ComboboxContextComputedState, ComboboxContextActions { - config: ComboboxContextConfig; - layout: ComboboxContextLayout; - search: ComboboxContextSearch; + config: ComboboxContextConfig, + layout: ComboboxContextLayout, + search: ComboboxContextSearch, } -export const ComboboxContext = createContext | null>(null); +export const ComboboxContext = createContext | null>(null) export function useComboboxContext(): ComboboxContextType { - const ctx = useContext(ComboboxContext); + const ctx = useContext(ComboboxContext) if (ctx == null) { - throw new Error("useComboboxContext must be used within ComboboxRoot"); + throw new Error('useComboboxContext must be used within ComboboxRoot') } - return ctx as ComboboxContextType; + return ctx as ComboboxContextType } diff --git a/src/components/user-interaction/Combobox/ComboboxInput.tsx b/src/components/user-interaction/Combobox/ComboboxInput.tsx index 3710425..ca3b9f6 100644 --- a/src/components/user-interaction/Combobox/ComboboxInput.tsx +++ b/src/components/user-interaction/Combobox/ComboboxInput.tsx @@ -1,47 +1,48 @@ -import { type ComponentProps, forwardRef, useCallback } from "react"; -import { Input } from "@/src/components/user-interaction/input/Input"; -import { useHightideTranslation } from "@/src/i18n/useHightideTranslation"; -import { useComboboxContext } from "./ComboboxContext"; +import { type ComponentProps, forwardRef, useCallback } from 'react' +import { Input } from '@/src/components/user-interaction/input/Input' +import { useHightideTranslation } from '@/src/i18n/useHightideTranslation' +import { useComboboxContext } from './ComboboxContext' -export interface ComboboxInputProps extends Omit, "value"> {} +export type ComboboxInputProps = Omit, 'value'> export const ComboboxInput = forwardRef( function ComboboxInput(props, ref) { - const translation = useHightideTranslation(); - const context = useComboboxContext(); + const translation = useHightideTranslation() + const context = useComboboxContext() + const { highlightNext, highlightPrevious, highlightFirst, highlightLast, highlightedId, selectOption } = context const handleKeyDown = useCallback( (event: React.KeyboardEvent) => { - props.onKeyDown?.(event); + props.onKeyDown?.(event) switch (event.key) { - case "ArrowDown": - context.highlightNext(); - event.preventDefault(); - break; - case "ArrowUp": - context.highlightPrevious(); - event.preventDefault(); - break; - case "Home": - context.highlightFirst(); - event.preventDefault(); - break; - case "End": - context.highlightLast(); - event.preventDefault(); - break; - case "Enter": - if (context.highlightedId) { - context.selectOption(context.highlightedId); - event.preventDefault(); - } - break; - default: - break; + case 'ArrowDown': + highlightNext() + event.preventDefault() + break + case 'ArrowUp': + highlightPrevious() + event.preventDefault() + break + case 'Home': + highlightFirst() + event.preventDefault() + break + case 'End': + highlightLast() + event.preventDefault() + break + case 'Enter': + if (highlightedId) { + selectOption(highlightedId) + event.preventDefault() + } + break + default: + break } }, - [props, context.highlightedId, context.highlightNext, context.highlightPrevious, context.highlightFirst, context.highlightLast, context.selectOption] - ); + [props, highlightedId, selectOption, highlightNext, highlightPrevious, highlightFirst, highlightLast] + ) return ( ( value={context.search.searchQuery} onValueChange={context.search.setSearchQuery} onKeyDown={handleKeyDown} - placeholder={props.placeholder ?? translation("search")} + placeholder={props.placeholder ?? translation('search')} role="combobox" aria-expanded={context.visibleOptionIds.length > 0} aria-controls={context.config.ids.listbox} aria-activedescendant={context.highlightedId ?? undefined} aria-autocomplete="list" /> - ); + ) } -); +) diff --git a/src/components/user-interaction/Combobox/ComboboxList.tsx b/src/components/user-interaction/Combobox/ComboboxList.tsx index e66aa24..52c3c21 100644 --- a/src/components/user-interaction/Combobox/ComboboxList.tsx +++ b/src/components/user-interaction/Combobox/ComboboxList.tsx @@ -1,28 +1,30 @@ -import type { HTMLAttributes, RefObject } from "react"; -import { forwardRef, useEffect, useRef } from "react"; -import clsx from "clsx"; -import { useComboboxContext } from "./ComboboxContext"; -import { useHightideTranslation } from "@/src/i18n/useHightideTranslation"; +import type { HTMLAttributes, RefObject } from 'react' +import { forwardRef, useEffect, useRef } from 'react' +import clsx from 'clsx' +import { useComboboxContext } from './ComboboxContext' +import { useHightideTranslation } from '@/src/i18n/useHightideTranslation' -export interface ComboboxListProps extends HTMLAttributes {} +export type ComboboxListProps = HTMLAttributes export const ComboboxList = forwardRef( function ComboboxList({ children, ...props }, ref) { - const translation = useHightideTranslation(); - const context = useComboboxContext(); - const innerRef = useRef(null); + const translation = useHightideTranslation() + const context = useComboboxContext() + const { layout } = context + const { registerList } = layout + const innerRef = useRef(null) useEffect(() => { - return context.layout.registerList(innerRef as RefObject); - }, [context.layout.registerList]); + return registerList(innerRef as RefObject) + }, [registerList]) const setRefs = (node: HTMLUListElement | null) => { - (innerRef as RefObject).current = node; - if (typeof ref === "function") ref(node); - else if (ref) (ref as RefObject).current = node; - }; + (innerRef as RefObject).current = node + if (typeof ref === 'function') ref(node) + else if (ref) (ref as RefObject).current = node + } - const count = context.visibleOptionIds.length; + const count = context.visibleOptionIds.length return (
        ( ref={setRefs} id={context.config.ids.listbox} role="listbox" - aria-label={translation("filterOptions")} + aria-label={translation('filterOptions')} tabIndex={-1} data-name="combobox-list" > @@ -42,11 +44,11 @@ export const ComboboxList = forwardRef( aria-live="polite" aria-atomic={true} data-name="combobox-list-status" - className={clsx({ "sr-only": count > 0 })} + className={clsx({ 'sr-only': count > 0 })} > - {translation("nResultsFound", { count })} + {translation('nResultsFound', { count })}
      - ); + ) } -); +) diff --git a/src/components/user-interaction/Combobox/ComboboxOption.tsx b/src/components/user-interaction/Combobox/ComboboxOption.tsx index 3ecb3c3..fa5bb8d 100644 --- a/src/components/user-interaction/Combobox/ComboboxOption.tsx +++ b/src/components/user-interaction/Combobox/ComboboxOption.tsx @@ -1,12 +1,12 @@ -import type { HTMLAttributes, ReactNode, RefObject } from "react"; -import { forwardRef, useEffect, useId, useRef } from "react"; -import clsx from "clsx"; -import { useComboboxContext } from "./ComboboxContext"; +import type { HTMLAttributes, ReactNode, RefObject } from 'react' +import { forwardRef, useEffect, useId, useRef } from 'react' +import clsx from 'clsx' +import { useComboboxContext } from './ComboboxContext' export interface ComboboxOptionProps extends HTMLAttributes { - value: T; - label: string; - disabled?: boolean; + value: T, + label: string, + disabled?: boolean, } export const ComboboxOption = forwardRef>(function ComboboxOption({ @@ -18,40 +18,41 @@ export const ComboboxOption = forwardRef(); - const itemRef = useRef(null); - const generatedId = useId(); - const optionId = idProp ?? `combobox-option-${generatedId}`; + const context = useComboboxContext() + const { registerOption } = context + const itemRef = useRef(null) + const generatedId = useId() + const optionId = idProp ?? `combobox-option-${generatedId}` - const resolvedDisplay: ReactNode = children ?? label; + const resolvedDisplay: ReactNode = children ?? label useEffect(() => { - return context.registerOption({ + return registerOption({ id: optionId, value, label, display: resolvedDisplay, disabled, ref: itemRef as React.RefObject, - }); - }, [optionId, value, label, resolvedDisplay, disabled, context.registerOption]); + }) + }, [optionId, value, label, resolvedDisplay, disabled, registerOption]) useEffect(() => { if (context.highlightedId === optionId) { - itemRef.current?.scrollIntoView?.({ behavior: "smooth", block: "nearest" }); + itemRef.current?.scrollIntoView?.({ behavior: 'smooth', block: 'nearest' }) } - }, [context.highlightedId, optionId]); + }, [context.highlightedId, optionId]) - const isVisible = context.visibleOptionIds.includes(optionId); - const isHighlighted = context.highlightedId === optionId; + const isVisible = context.visibleOptionIds.includes(optionId) + const isHighlighted = context.highlightedId === optionId return (
    • { - itemRef.current = node; - if (typeof ref === "function") ref(node); - else if (ref) (ref as RefObject).current = node; + itemRef.current = node + if (typeof ref === 'function') ref(node) + else if (ref) (ref as RefObject).current = node }} id={optionId} hidden={!isVisible} @@ -60,28 +61,28 @@ export const ComboboxOption = forwardRef { if (!disabled) { - context.selectOption(optionId); - restProps.onClick?.(event); + context.selectOption(optionId) + restProps.onClick?.(event) } }} onMouseEnter={(event) => { if (!disabled) { - context.highlightItem(optionId); - restProps.onMouseEnter?.(event); + context.highlightItem(optionId) + restProps.onMouseEnter?.(event) } }} > {resolvedDisplay}
    • - ); -}); + ) +}) -ComboboxOption.displayName = "ComboboxOption"; +ComboboxOption.displayName = 'ComboboxOption' diff --git a/src/components/user-interaction/Combobox/ComboboxRoot.tsx b/src/components/user-interaction/Combobox/ComboboxRoot.tsx index 368f30b..fa5eb46 100644 --- a/src/components/user-interaction/Combobox/ComboboxRoot.tsx +++ b/src/components/user-interaction/Combobox/ComboboxRoot.tsx @@ -1,14 +1,14 @@ -import type { ReactNode, RefObject } from "react"; -import { useCallback, useId, useMemo, useState } from "react"; -import { ComboboxContext } from "./ComboboxContext"; -import type { ComboboxContextConfig, ComboboxContextIds, ComboboxContextLayout, ComboboxContextType, ComboboxOptionType } from "./ComboboxContext"; -import type { UseComboboxOptions } from "./useCombobox"; -import { useCombobox } from "./useCombobox"; -import { DOMUtils } from "@/src/utils/dom"; +import type { ReactNode, RefObject } from 'react' +import { useCallback, useId, useMemo, useState } from 'react' +import { ComboboxContext } from './ComboboxContext' +import type { ComboboxContextConfig, ComboboxContextIds, ComboboxContextLayout, ComboboxContextType, ComboboxOptionType } from './ComboboxContext' +import type { UseComboboxOptions } from './useCombobox' +import { useCombobox } from './useCombobox' +import { DOMUtils } from '@/src/utils/dom' -export interface ComboboxRootProps extends Omit { - children: ReactNode; - onItemClick?: (value: T) => void; +export interface ComboboxRootProps extends Omit { + children: ReactNode, + onItemClick?: (value: T) => void, } export function ComboboxRoot({ @@ -16,34 +16,33 @@ export function ComboboxRoot({ onItemClick, ...hookProps }: ComboboxRootProps) { - const [options, setOptions] = useState[]>([]); - const [listRef, setListRef] = useState | null>(null); - const generatedId = useId(); + const [options, setOptions] = useState[]>([]) + const [listRef, setListRef] = useState | null>(null) + const generatedId = useId() const [ids, setIds] = useState({ trigger: `combobox-${generatedId}`, listbox: `combobox-${generatedId}-listbox`, - }); + }) const registerOption = useCallback( (option: ComboboxOptionType) => { setOptions((prev) => { - const next = prev.filter((o) => o.id !== option.id); - next.push(option); + const next = prev.filter((o) => o.id !== option.id) + next.push(option) next.sort((a, b) => - DOMUtils.compareDocumentPosition(a.ref.current, b.ref.current) - ); - return next; - }); + DOMUtils.compareDocumentPosition(a.ref.current, b.ref.current)) + return next + }) return () => - setOptions((prev) => prev.filter((o) => o.id !== option.id)); + setOptions((prev) => prev.filter((o) => o.id !== option.id)) }, [] - ); + ) const registerList = useCallback((ref: RefObject) => { - setListRef(() => ref); - return () => setListRef(null); - }, []); + setListRef(() => ref) + return () => setListRef(null) + }, []) const hookOptions = useMemo( () => @@ -53,29 +52,29 @@ export function ComboboxRoot({ disabled: o.disabled, })), [options] - ); + ) - const state = useCombobox({ ...hookProps, options: hookOptions }); + const state = useCombobox({ ...hookProps, options: hookOptions }) const idToOptionMap = useMemo(() => { return options.reduce((acc, o) => { - acc[o.id] = o; - return acc; - }, {} as Record>); - }, [options]); + acc[o.id] = o + return acc + }, {} as Record>) + }, [options]) const selectOption = useCallback( (id: string) => { - const option = idToOptionMap[id]; - if (option) onItemClick?.(option.value as T); + const option = idToOptionMap[id] + if (option) onItemClick?.(option.value as T) }, [idToOptionMap, onItemClick] - ); + ) const config: ComboboxContextConfig = useMemo( () => ({ ids, setIds }), [ids, setIds] - ); + ) const layout: ComboboxContextLayout = useMemo( () => ({ @@ -83,7 +82,7 @@ export function ComboboxRoot({ registerList, }), [listRef, registerList] - ); + ) const search = useMemo( () => ({ @@ -91,7 +90,7 @@ export function ComboboxRoot({ setSearchQuery: state.setSearchQuery, }), [state.searchQuery, state.setSearchQuery] - ); + ) const contextValue = useMemo( () => ({ @@ -126,11 +125,11 @@ export function ComboboxRoot({ layout, search, ] - ); + ) return ( }> {children} - ); + ) } diff --git a/src/components/user-interaction/Combobox/useCombobox.ts b/src/components/user-interaction/Combobox/useCombobox.ts index 3d36350..f60b588 100644 --- a/src/components/user-interaction/Combobox/useCombobox.ts +++ b/src/components/user-interaction/Combobox/useCombobox.ts @@ -1,37 +1,37 @@ -import { useCallback, useMemo } from "react"; -import { useListNavigation } from "@/src/hooks/useListNavigation"; -import { useControlledState } from "@/src/hooks/useControlledState"; -import { useSearch } from "@/src/hooks"; +import { useCallback, useMemo } from 'react' +import { useListNavigation } from '@/src/hooks/useListNavigation' +import { useControlledState } from '@/src/hooks/useControlledState' +import { useSearch } from '@/src/hooks' export interface UseComboboxOption { - id: string; - label?: string; - disabled?: boolean; + id: string, + label?: string, + disabled?: boolean, } export interface UseComboboxOptions { - options: ReadonlyArray; - searchQuery?: string; - onSearchQueryChange?: (query: string) => void; - initialSearchQuery?: string; + options: ReadonlyArray, + searchQuery?: string, + onSearchQueryChange?: (query: string) => void, + initialSearchQuery?: string, } export interface UseComboboxState { - searchQuery: string; - highlightedId: string | null; + searchQuery: string, + highlightedId: string | null, } export interface UseComboboxComputedState { - visibleOptionIds: ReadonlyArray; + visibleOptionIds: ReadonlyArray, } export interface UseComboboxActions { - setSearchQuery: (query: string) => void; - highlightFirst: () => void; - highlightLast: () => void; - highlightNext: () => void; - highlightPrevious: () => void; - highlightItem: (id: string) => void; + setSearchQuery: (query: string) => void, + highlightFirst: () => void, + highlightLast: () => void, + highlightNext: () => void, + highlightPrevious: () => void, + highlightItem: (id: string) => void, } export interface UseComboboxReturn extends UseComboboxState, UseComboboxComputedState, UseComboboxActions {} @@ -40,52 +40,52 @@ export function useCombobox({ options, searchQuery: controlledSearchQuery, onSearchQueryChange, - initialSearchQuery = "", + initialSearchQuery = '', }: UseComboboxOptions): UseComboboxReturn { const [searchQuery, setSearchQuery] = useControlledState({ value: controlledSearchQuery, onValueChange: onSearchQueryChange, defaultValue: initialSearchQuery, - }); + }) const { searchResult: visibleOptions } = useSearch({ items: options, - searchQuery: searchQuery ?? "", + searchQuery: searchQuery ?? '', toTags: useCallback((o: UseComboboxOption) => [o.label], []), - }); + }) const visibleOptionIds = useMemo( () => visibleOptions.map((o) => o.id), [visibleOptions] - ); + ) const enabledOptionIds = useMemo( () => visibleOptions.filter((o) => !o.disabled).map((o) => o.id), [visibleOptions] - ); + ) - const listNav = useListNavigation({ options: enabledOptionIds }); + const listNav = useListNavigation({ options: enabledOptionIds }) const highlightItem = useCallback( (id: string) => { - if (!enabledOptionIds.includes(id)) return; - listNav.highlight(id); + if (!enabledOptionIds.includes(id)) return + listNav.highlight(id) }, [enabledOptionIds, listNav] - ); + ) const state: UseComboboxState = useMemo( () => ({ - searchQuery: searchQuery ?? "", + searchQuery: searchQuery ?? '', highlightedId: listNav.highlightedId, }), [searchQuery, listNav.highlightedId] - ); + ) const computedState: UseComboboxComputedState = useMemo( () => ({ visibleOptionIds }), [visibleOptionIds] - ); + ) const actions: UseComboboxActions = useMemo( () => ({ @@ -97,7 +97,7 @@ export function useCombobox({ highlightItem, }), [setSearchQuery, listNav.first, listNav.last, listNav.next, listNav.previous, highlightItem] - ); + ) return useMemo( (): UseComboboxReturn => ({ @@ -106,5 +106,5 @@ export function useCombobox({ ...actions, }), [state, computedState, actions] - ); + ) } diff --git a/src/components/user-interaction/MultiSelect/MultiSelect.tsx b/src/components/user-interaction/MultiSelect/MultiSelect.tsx index 6d0379b..807fe59 100644 --- a/src/components/user-interaction/MultiSelect/MultiSelect.tsx +++ b/src/components/user-interaction/MultiSelect/MultiSelect.tsx @@ -1,14 +1,14 @@ -import { forwardRef } from "react"; -import type { MultiSelectRootProps } from "./MultiSelectRoot"; -import { MultiSelectRoot } from "./MultiSelectRoot"; -import type { MultiSelectButtonProps } from "./MultiSelectButton"; -import { MultiSelectButton } from "./MultiSelectButton"; -import type { MultiSelectContentProps } from "./MultiSelectContent"; -import { MultiSelectContent } from "./MultiSelectContent"; +import { forwardRef } from 'react' +import type { MultiSelectRootProps } from './MultiSelectRoot' +import { MultiSelectRoot } from './MultiSelectRoot' +import type { MultiSelectButtonProps } from './MultiSelectButton' +import { MultiSelectButton } from './MultiSelectButton' +import type { MultiSelectContentProps } from './MultiSelectContent' +import { MultiSelectContent } from './MultiSelectContent' export interface MultiSelectProps extends MultiSelectRootProps { - contentPanelProps?: MultiSelectContentProps; - buttonProps?: MultiSelectButtonProps; + contentPanelProps?: MultiSelectContentProps, + buttonProps?: MultiSelectButtonProps, } export const MultiSelect = forwardRef>( @@ -18,6 +18,6 @@ export const MultiSelect = forwardRef> {children} - ); + ) } -); +) as (props: MultiSelectProps & { ref?: React.Ref }) => React.ReactElement diff --git a/src/components/user-interaction/MultiSelect/MultiSelectButton.tsx b/src/components/user-interaction/MultiSelect/MultiSelectButton.tsx index 8242f10..55b7cc2 100644 --- a/src/components/user-interaction/MultiSelect/MultiSelectButton.tsx +++ b/src/components/user-interaction/MultiSelect/MultiSelectButton.tsx @@ -1,16 +1,16 @@ -import type { ComponentPropsWithoutRef, ReactNode } from "react"; -import { forwardRef, useEffect, useImperativeHandle, useRef } from "react"; -import { useMultiSelectContext } from "./MultiSelectContext"; -import { useHightideTranslation } from "@/src/i18n/useHightideTranslation"; -import { ExpansionIcon } from "@/src/components/display-and-visualization/ExpansionIcon"; -import { MultiSelectOptionDisplayContext } from "./MultiSelectOption"; +import type { ComponentPropsWithoutRef, ReactNode } from 'react' +import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react' +import { useMultiSelectContext } from './MultiSelectContext' +import { useHightideTranslation } from '@/src/i18n/useHightideTranslation' +import { ExpansionIcon } from '@/src/components/display-and-visualization/ExpansionIcon' +import { MultiSelectOptionDisplayContext } from './MultiSelectOption' export interface MultiSelectButtonProps - extends ComponentPropsWithoutRef<"div"> { - placeholder?: ReactNode; - disabled?: boolean; - selectedDisplay?: (values: T[]) => ReactNode; - hideExpansionIcon?: boolean; + extends ComponentPropsWithoutRef<'div'> { + placeholder?: ReactNode, + disabled?: boolean, + selectedDisplay?: (values: T[]) => ReactNode, + hideExpansionIcon?: boolean, } export const MultiSelectButton = forwardRef< @@ -27,27 +27,30 @@ export const MultiSelectButton = forwardRef< }: MultiSelectButtonProps, ref ) { - const translation = useHightideTranslation(); - const context = useMultiSelectContext(); + const translation = useHightideTranslation() + const context = useMultiSelectContext() + const { config, layout } = context + const { setIds } = config + const { registerTrigger } = layout useEffect(() => { - if (id) context.config.setIds((prev) => ({ ...prev, trigger: id })); - }, [id, context.config.setIds]); + if (id) setIds((prev) => ({ ...prev, trigger: id })) + }, [id, setIds]) - const innerRef = useRef(null); - useImperativeHandle(ref, () => innerRef.current!); + const innerRef = useRef(null) + useImperativeHandle(ref, () => innerRef.current!) useEffect(() => { - const unregister = context.layout.registerTrigger(innerRef); - return () => unregister(); - }, [context.layout.registerTrigger]); + const unregister = registerTrigger(innerRef) + return () => unregister() + }, [registerTrigger]) - const disabled = !!disabledOverride || !!context.disabled; - const invalid = context.invalid; - const hasValue = context.value.length > 0; + const disabled = !!disabledOverride || !!context.disabled + const invalid = context.invalid + const hasValue = context.value.length > 0 const selectedOptions = context.selectedIds .map((id) => context.idToOptionMap[id]) - .filter(Boolean); + .filter(Boolean) return (
      { - props.onClick?.(event); - context.toggleIsOpen(); + props.onClick?.(event) + context.toggleIsOpen() }} onKeyDown={(event) => { - props.onKeyDown?.(event); - if (disabled) return; + props.onKeyDown?.(event) + if (disabled) return switch (event.key) { - case "Enter": - case " ": - context.toggleIsOpen(); - event.preventDefault(); - event.stopPropagation(); - break; - case "ArrowDown": - context.setIsOpen(true, "first"); - event.preventDefault(); - event.stopPropagation(); - break; - case "ArrowUp": - context.setIsOpen(true, "last"); - event.preventDefault(); - event.stopPropagation(); - break; + case 'Enter': + case ' ': + context.toggleIsOpen() + event.preventDefault() + event.stopPropagation() + break + case 'ArrowDown': + context.setIsOpen(true, 'first') + event.preventDefault() + event.stopPropagation() + break + case 'ArrowUp': + context.setIsOpen(true, 'last') + event.preventDefault() + event.stopPropagation() + break } }} - data-name={props["data-name"] ?? "multi-select-button"} - data-value={hasValue ? "" : undefined} - data-disabled={disabled ? "" : undefined} - data-invalid={invalid ? "" : undefined} + data-name={props['data-name'] ?? 'multi-select-button'} + data-value={hasValue ? '' : undefined} + data-disabled={disabled ? '' : undefined} + data-invalid={invalid ? '' : undefined} tabIndex={disabled ? -1 : 0} role="button" aria-invalid={invalid} @@ -95,18 +98,18 @@ export const MultiSelectButton = forwardRef< {hasValue ? selectedDisplay?.(context.value) ?? ( -
      - {selectedOptions.map((opt, index) => ( - - {opt.display} - {index < selectedOptions.length - 1 && ,} - - ))} -
      - ) - : placeholder ?? translation("clickToSelect")} +
      + {selectedOptions.map((opt, index) => ( + + {opt.display} + {index < selectedOptions.length - 1 && ,} + + ))} +
      + ) + : placeholder ?? translation('clickToSelect')}
      {!hideExpansionIcon && }
      - ); -}); + ) +}) diff --git a/src/components/user-interaction/MultiSelect/MultiSelectChipDisplay.tsx b/src/components/user-interaction/MultiSelect/MultiSelectChipDisplay.tsx index cf395e0..f2ebf79 100644 --- a/src/components/user-interaction/MultiSelect/MultiSelectChipDisplay.tsx +++ b/src/components/user-interaction/MultiSelect/MultiSelectChipDisplay.tsx @@ -1,57 +1,60 @@ -import type { HTMLAttributes, ReactNode } from "react"; -import { forwardRef, useEffect, useImperativeHandle, useRef } from "react"; -import { useMultiSelectContext } from "./MultiSelectContext"; -import type { MultiSelectRootProps } from "./MultiSelectRoot"; -import { MultiSelectRoot } from "./MultiSelectRoot"; -import type { MultiSelectContentProps } from "./MultiSelectContent"; -import { MultiSelectContent } from "./MultiSelectContent"; -import { IconButton } from "@/src/components/user-interaction/IconButton"; -import { useHightideTranslation } from "@/src/i18n/useHightideTranslation"; -import { XIcon, Plus } from "lucide-react"; +import type { HTMLAttributes, ReactNode } from 'react' +import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react' +import { useMultiSelectContext } from './MultiSelectContext' +import type { MultiSelectRootProps } from './MultiSelectRoot' +import { MultiSelectRoot } from './MultiSelectRoot' +import type { MultiSelectContentProps } from './MultiSelectContent' +import { MultiSelectContent } from './MultiSelectContent' +import { IconButton } from '@/src/components/user-interaction/IconButton' +import { useHightideTranslation } from '@/src/i18n/useHightideTranslation' +import { XIcon, Plus } from 'lucide-react' export type MultiSelectChipDisplayButtonProps = HTMLAttributes & { - disabled?: boolean; - placeholder?: ReactNode; + disabled?: boolean, + placeholder?: ReactNode, }; export const MultiSelectChipDisplayButton = forwardRef< HTMLDivElement, MultiSelectChipDisplayButtonProps >(function MultiSelectChipDisplayButton({ id, ...props }, ref) { - const translation = useHightideTranslation(); - const context = useMultiSelectContext(); + const translation = useHightideTranslation() + const context = useMultiSelectContext() + const { config, layout } = context + const { setIds } = config + const { registerTrigger } = layout useEffect(() => { - if (id) context.config.setIds((prev) => ({ ...prev, trigger: id })); - }, [id, context.config.setIds]); + if (id) setIds((prev) => ({ ...prev, trigger: id })) + }, [id, setIds]) - const innerRef = useRef(null); - useImperativeHandle(ref, () => innerRef.current!); + const innerRef = useRef(null) + useImperativeHandle(ref, () => innerRef.current!) useEffect(() => { - const unregister = context.layout.registerTrigger(innerRef); - return () => unregister(); - }, [context.layout.registerTrigger]); + const unregister = registerTrigger(innerRef) + return () => unregister() + }, [registerTrigger]) - const disabled = !!props?.disabled || !!context.disabled; - const invalid = context.invalid; + const disabled = !!props?.disabled || !!context.disabled + const invalid = context.invalid const selectedOptions = context.selectedIds .map((oid) => context.idToOptionMap[oid]) - .filter(Boolean); + .filter(Boolean) return (
      { - props.onClick?.(event); - if(event.defaultPrevented) return; - context.toggleIsOpen(); + props.onClick?.(event) + if(event.defaultPrevented) return + context.toggleIsOpen() }} - data-name={props["data-name"] ?? "multi-select-chip-display-button"} - data-value={context.value.length > 0 ? "" : undefined} - data-disabled={disabled ? "" : undefined} - data-invalid={invalid ? "" : undefined} + data-name={props['data-name'] ?? 'multi-select-chip-display-button'} + data-value={context.value.length > 0 ? '' : undefined} + data-disabled={disabled ? '' : undefined} + data-invalid={invalid ? '' : undefined} aria-invalid={invalid} aria-disabled={disabled} > @@ -59,10 +62,10 @@ export const MultiSelectChipDisplayButton = forwardRef<
      {opt.display} { context.toggleSelection(opt.id, false) - e.preventDefault(); + e.preventDefault() }} size="sm" color="negative" @@ -76,19 +79,19 @@ export const MultiSelectChipDisplayButton = forwardRef< { - event.stopPropagation(); - context.toggleIsOpen(); + event.stopPropagation() + context.toggleIsOpen() }} onKeyDown={(event) => { switch (event.key) { - case "ArrowDown": - context.setIsOpen(true, "first"); - break; - case "ArrowUp": - context.setIsOpen(true, "last"); + case 'ArrowDown': + context.setIsOpen(true, 'first') + break + case 'ArrowUp': + context.setIsOpen(true, 'last') } }} - tooltip={translation("changeSelection")} + tooltip={translation('changeSelection')} size="md" color="neutral" aria-invalid={invalid} @@ -103,12 +106,12 @@ export const MultiSelectChipDisplayButton = forwardRef<
      - ); -}); + ) +}) export type MultiSelectChipDisplayProps = MultiSelectRootProps & { - contentPanelProps?: MultiSelectContentProps; - chipDisplayProps?: MultiSelectChipDisplayButtonProps; + contentPanelProps?: MultiSelectContentProps, + chipDisplayProps?: MultiSelectChipDisplayButtonProps, }; export const MultiSelectChipDisplay = forwardRef( @@ -126,6 +129,6 @@ export const MultiSelectChipDisplay = forwardRef( {children} - ); + ) } -); +) diff --git a/src/components/user-interaction/MultiSelect/MultiSelectContent.tsx b/src/components/user-interaction/MultiSelect/MultiSelectContent.tsx index a8ee1eb..9cd16cc 100644 --- a/src/components/user-interaction/MultiSelect/MultiSelectContent.tsx +++ b/src/components/user-interaction/MultiSelect/MultiSelectContent.tsx @@ -1,15 +1,15 @@ -import type { ComponentProps } from "react"; -import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef } from "react"; -import { useMultiSelectContext } from "./MultiSelectContext"; -import clsx from "clsx"; -import { useHightideTranslation } from "@/src/i18n/useHightideTranslation"; -import { PopUp, type PopUpProps } from "@/src/components/layout/popup/PopUp"; -import { Input } from "@/src/components/user-interaction/input/Input"; -import { Visibility } from "@/src/components/layout/Visibility"; +import type { ComponentProps } from 'react' +import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef } from 'react' +import { useMultiSelectContext } from './MultiSelectContext' +import clsx from 'clsx' +import { useHightideTranslation } from '@/src/i18n/useHightideTranslation' +import { PopUp, type PopUpProps } from '@/src/components/layout/popup/PopUp' +import { Input } from '@/src/components/user-interaction/input/Input' +import { Visibility } from '@/src/components/layout/Visibility' export interface MultiSelectContentProps extends PopUpProps { - showSearch?: boolean; - searchInputProps?: Omit, "value" | "onValueChange">; + showSearch?: boolean, + searchInputProps?: Omit, 'value' | 'onValueChange'>, } export const MultiSelectContent = forwardRef< @@ -19,64 +19,65 @@ export const MultiSelectContent = forwardRef< { id, options, showSearch: showSearchOverride, searchInputProps, ...props }, ref ) { - const translation = useHightideTranslation(); - const innerRef = useRef(null); - const searchInputRef = useRef(null); - useImperativeHandle(ref, () => innerRef.current!); + const translation = useHightideTranslation() + const innerRef = useRef(null) + const searchInputRef = useRef(null) + useImperativeHandle(ref, () => innerRef.current!) - const context = useMultiSelectContext(); + const context = useMultiSelectContext() + const { config, highlightNext, highlightPrevious, highlightFirst, highlightLast, highlightedId, handleTypeaheadKey, toggleSelection } = context + const { setIds } = config useEffect(() => { - if (id) context.config.setIds((prev) => ({ ...prev, content: id })); - }, [id, context.config.setIds]); + if (id) setIds((prev) => ({ ...prev, content: id })) + }, [id, setIds]) - const showSearch = showSearchOverride ?? context.search.hasSearch; - const listboxAriaLabel = showSearch ? translation("searchResults") : undefined; + const showSearch = showSearchOverride ?? context.search.hasSearch + const listboxAriaLabel = showSearch ? translation('searchResults') : undefined const keyHandler = useCallback( (event: React.KeyboardEvent) => { switch (event.key) { - case "ArrowDown": - context.highlightNext(); - event.preventDefault(); - break; - case "ArrowUp": - context.highlightPrevious(); - event.preventDefault(); - break; - case "Home": - event.preventDefault(); - context.highlightFirst(); - break; - case "End": - event.preventDefault(); - context.highlightLast(); - break; - case "Enter": - case " ": - if (showSearch && event.key === " ") return; - if (context.highlightedId) { - context.toggleSelection(context.highlightedId); - event.preventDefault(); - } - break; - default: - if ( - !showSearch && + case 'ArrowDown': + highlightNext() + event.preventDefault() + break + case 'ArrowUp': + highlightPrevious() + event.preventDefault() + break + case 'Home': + event.preventDefault() + highlightFirst() + break + case 'End': + event.preventDefault() + highlightLast() + break + case 'Enter': + case ' ': + if (showSearch && event.key === ' ') return + if (highlightedId) { + toggleSelection(highlightedId) + event.preventDefault() + } + break + default: + if ( + !showSearch && !event.ctrlKey && !event.metaKey && !event.altKey && event.key.length === 1 - ) { - if (context.handleTypeaheadKey(event.key)) { - event.preventDefault(); - } - } - break; + ) { + handleTypeaheadKey(event.key) + event.preventDefault() + } + break } }, - [showSearch, context] - ); + [showSearch, handleTypeaheadKey, toggleSelection, highlightedId, highlightNext, highlightPrevious, highlightFirst, highlightLast] + ) return ( { - context.setIsOpen(false); - props.onClose?.(); + context.setIsOpen(false) + props.onClose?.() }} aria-labelledby={context.config.ids.trigger} - className={clsx("gap-y-1", props.className)} + className={clsx('gap-y-1', props.className)} > {showSearch && ( )}
        {props.children} @@ -135,15 +136,15 @@ export const MultiSelectContent = forwardRef< aria-atomic={true} data-name="multi-select-list-status" className={clsx({ - "sr-only": context.visibleOptionIds.length > 0, + 'sr-only': context.visibleOptionIds.length > 0, })} > - {translation("nResultsFound", { + {translation('nResultsFound', { count: context.visibleOptionIds.length, })}
      - ); -}); + ) +}) diff --git a/src/components/user-interaction/MultiSelect/MultiSelectContext.tsx b/src/components/user-interaction/MultiSelect/MultiSelectContext.tsx index ff9af02..542ffed 100644 --- a/src/components/user-interaction/MultiSelect/MultiSelectContext.tsx +++ b/src/components/user-interaction/MultiSelect/MultiSelectContext.tsx @@ -1,81 +1,81 @@ -import type { Dispatch, ReactNode, RefObject, SetStateAction } from "react"; -import { createContext, useContext } from "react"; -import type { FormFieldInteractionStates } from "@/src/components/form/FieldLayout"; -import { UseMultiSelectFirstHighlightBehavior } from "./useMultiSelect"; +import type { Dispatch, ReactNode, RefObject, SetStateAction } from 'react' +import { createContext, useContext } from 'react' +import type { FormFieldInteractionStates } from '@/src/components/form/FieldLayout' +import type { UseMultiSelectFirstHighlightBehavior } from './useMultiSelect' export interface MultiSelectOptionType { - id: string; - value: T; - label?: string; - display?: ReactNode; - disabled?: boolean; - ref: RefObject; + id: string, + value: T, + label?: string, + display?: ReactNode, + disabled?: boolean, + ref: RefObject, } export interface MultiSelectContextIds { - trigger: string; - content: string; - listbox: string; - searchInput: string; + trigger: string, + content: string, + listbox: string, + searchInput: string, } export interface MultiSelectContextState extends FormFieldInteractionStates { - value: T[]; - options: ReadonlyArray>; - selectedIds: string[]; - highlightedId: string | null; - isOpen: boolean; + value: T[], + options: ReadonlyArray>, + selectedIds: string[], + highlightedId: string | null, + isOpen: boolean, } export interface MultiSelectContextComputedState { - visibleOptionIds: ReadonlyArray; - idToOptionMap: Record>; + visibleOptionIds: ReadonlyArray, + idToOptionMap: Record>, } export interface MultiSelectContextActions { - registerOption(option: MultiSelectOptionType): () => void; - toggleSelection(id: string, isSelected?: boolean): void; - highlightFirst(): void; - highlightLast(): void; - highlightNext(): void; - highlightPrevious(): void; - highlightItem(id: string): void; - handleTypeaheadKey(key: string): void; - setIsOpen(open: boolean, behavior?: UseMultiSelectFirstHighlightBehavior): void; - toggleIsOpen(behavior?: UseMultiSelectFirstHighlightBehavior): void; + registerOption(option: MultiSelectOptionType): () => void, + toggleSelection(id: string, isSelected?: boolean): void, + highlightFirst(): void, + highlightLast(): void, + highlightNext(): void, + highlightPrevious(): void, + highlightItem(id: string): void, + handleTypeaheadKey(key: string): void, + setIsOpen(open: boolean, behavior?: UseMultiSelectFirstHighlightBehavior): void, + toggleIsOpen(behavior?: UseMultiSelectFirstHighlightBehavior): void, } export interface MultiSelectContextLayout { - triggerRef: RefObject; - registerTrigger(element: RefObject): () => void; + triggerRef: RefObject, + registerTrigger(element: RefObject): () => void, } export interface MultiSelectContextSearch { - hasSearch: boolean; - searchQuery?: string; - setSearchQuery(query: string): void; + hasSearch: boolean, + searchQuery?: string, + setSearchQuery(query: string): void, } -export type MultiSelectIconAppearance = "left" | "right" | "none"; +export type MultiSelectIconAppearance = 'left' | 'right' | 'none'; export interface MultiSelectContextConfig { - iconAppearance: MultiSelectIconAppearance; - ids: MultiSelectContextIds; - setIds: Dispatch>; + iconAppearance: MultiSelectIconAppearance, + ids: MultiSelectContextIds, + setIds: Dispatch>, } export interface MultiSelectContextType extends MultiSelectContextActions, MultiSelectContextState, MultiSelectContextComputedState { - config: MultiSelectContextConfig; - layout: MultiSelectContextLayout; - search: MultiSelectContextSearch; + config: MultiSelectContextConfig, + layout: MultiSelectContextLayout, + search: MultiSelectContextSearch, } -const MultiSelectContext = createContext | null>(null); +const MultiSelectContext = createContext | null>(null) export function useMultiSelectContext(): MultiSelectContextType { - const ctx = useContext(MultiSelectContext); - if (!ctx) throw new Error("useMultiSelectContext must be used within MultiSelectRoot"); - return ctx as MultiSelectContextType; + const ctx = useContext(MultiSelectContext) + if (!ctx) throw new Error('useMultiSelectContext must be used within MultiSelectRoot') + return ctx as MultiSelectContextType } -export { MultiSelectContext }; +export { MultiSelectContext } diff --git a/src/components/user-interaction/MultiSelect/MultiSelectOption.tsx b/src/components/user-interaction/MultiSelect/MultiSelectOption.tsx index 2c8bc75..1c43dba 100644 --- a/src/components/user-interaction/MultiSelect/MultiSelectOption.tsx +++ b/src/components/user-interaction/MultiSelect/MultiSelectOption.tsx @@ -1,30 +1,30 @@ -import clsx from "clsx"; -import { CheckIcon } from "lucide-react"; -import type { HTMLAttributes, ReactNode, RefObject } from "react"; -import { createContext, forwardRef, useContext, useEffect, useId, useRef } from "react"; -import type { MultiSelectIconAppearance } from "./MultiSelectContext"; -import { useMultiSelectContext } from "./MultiSelectContext"; +import clsx from 'clsx' +import { CheckIcon } from 'lucide-react' +import type { HTMLAttributes, ReactNode, RefObject } from 'react' +import { createContext, forwardRef, useContext, useEffect, useId, useRef } from 'react' +import type { MultiSelectIconAppearance } from './MultiSelectContext' +import { useMultiSelectContext } from './MultiSelectContext' -export type MultiSelectOptionDisplayLocation = "trigger" | "list"; +export type MultiSelectOptionDisplayLocation = 'trigger' | 'list'; export const MultiSelectOptionDisplayContext = - createContext(null); + createContext(null) export function useMultiSelectOptionDisplayLocation(): MultiSelectOptionDisplayLocation { - const context = useContext(MultiSelectOptionDisplayContext); + const context = useContext(MultiSelectOptionDisplayContext) if (!context) { throw new Error( - "useMultiSelectOptionDisplayLocation must be used within a MultiSelectOptionDisplayContext" - ); + 'useMultiSelectOptionDisplayLocation must be used within a MultiSelectOptionDisplayContext' + ) } - return context; + return context } export interface MultiSelectOptionProps extends HTMLAttributes { - value: T; - label: string; - disabled?: boolean; - iconAppearance?: MultiSelectIconAppearance; + value: T, + label: string, + disabled?: boolean, + iconAppearance?: MultiSelectIconAppearance, } export const MultiSelectOption = forwardRef< @@ -37,42 +37,42 @@ export const MultiSelectOption = forwardRef< value, disabled = false, iconAppearance, - className, ...props }: MultiSelectOptionProps, ref ) { - const context = useMultiSelectContext(); - const itemRef = useRef(null); + const context = useMultiSelectContext() + const { registerOption } = context + const itemRef = useRef(null) - const display: ReactNode = children ?? label; - const iconAppearanceResolved = iconAppearance ?? context.config.iconAppearance; + const display: ReactNode = children ?? label + const iconAppearanceResolved = iconAppearance ?? context.config.iconAppearance - const generatedId = useId(); - const optionId = props?.id ?? "multi-select-option-" + generatedId; + const generatedId = useId() + const optionId = props?.id ?? 'multi-select-option-' + generatedId useEffect(() => { - return context.registerOption({ + return registerOption({ id: optionId, value, label, display, disabled: Boolean(disabled), ref: itemRef as React.RefObject, - }); - }, [optionId, value, label, disabled, context.registerOption, display]); + }) + }, [optionId, value, label, disabled, registerOption, display]) - const isHighlighted = context.highlightedId === optionId; - const isSelected = context.selectedIds.includes(optionId); - const isVisible = context.visibleOptionIds.includes(optionId); + const isHighlighted = context.highlightedId === optionId + const isSelected = context.selectedIds.includes(optionId) + const isVisible = context.visibleOptionIds.includes(optionId) return (
    • { - itemRef.current = node; - if (typeof ref === "function") ref(node); - else if (ref) (ref as RefObject).current = node; + itemRef.current = node + if (typeof ref === 'function') ref(node) + else if (ref) (ref as RefObject).current = node }} id={optionId} hidden={!isVisible} @@ -82,39 +82,39 @@ export const MultiSelectOption = forwardRef< aria-hidden={!isVisible} data-name="multi-select-list-option" - data-highlighted={isHighlighted ? "" : undefined} - data-selected={isSelected ? "" : undefined} - data-disabled={disabled ? "" : undefined} - data-visible={isVisible ? "" : undefined} + data-highlighted={isHighlighted ? '' : undefined} + data-selected={isSelected ? '' : undefined} + data-disabled={disabled ? '' : undefined} + data-visible={isVisible ? '' : undefined} onClick={(event) => { if (!disabled) { - context.toggleSelection(optionId); - props.onClick?.(event); + context.toggleSelection(optionId) + props.onClick?.(event) } }} onMouseEnter={(event) => { if (!disabled) { - context.highlightItem(optionId); - props.onMouseEnter?.(event); + context.highlightItem(optionId) + props.onMouseEnter?.(event) } }} > - {iconAppearanceResolved === "left" && ( + {iconAppearanceResolved === 'left' && ( )} {display} - {iconAppearanceResolved === "right" && ( + {iconAppearanceResolved === 'right' && ( )}
    • - ); -}); + ) +}) diff --git a/src/components/user-interaction/MultiSelect/MultiSelectRoot.tsx b/src/components/user-interaction/MultiSelect/MultiSelectRoot.tsx index f0b42e4..a265a47 100644 --- a/src/components/user-interaction/MultiSelect/MultiSelectRoot.tsx +++ b/src/components/user-interaction/MultiSelect/MultiSelectRoot.tsx @@ -1,28 +1,28 @@ -import type { ReactNode, RefObject } from "react"; -import { useCallback, useEffect, useId, useMemo, useState } from "react"; -import { MultiSelectContext } from "./MultiSelectContext"; -import type { MultiSelectContextType, MultiSelectIconAppearance, MultiSelectOptionType } from "./MultiSelectContext"; -import { useMultiSelect } from "./useMultiSelect"; -import { DOMUtils } from "@/src/utils/dom"; -import type { FormFieldDataHandling } from "@/src/components/form/FormField"; -import type { FormFieldInteractionStates } from "@/src/components/form/FieldLayout"; -import { PopUpContext } from "@/src/components/layout/popup/PopUpContext"; +import type { ReactNode, RefObject } from 'react' +import { useCallback, useEffect, useId, useMemo, useState } from 'react' +import { MultiSelectContext } from './MultiSelectContext' +import type { MultiSelectContextType, MultiSelectIconAppearance, MultiSelectOptionType } from './MultiSelectContext' +import { useMultiSelect } from './useMultiSelect' +import { DOMUtils } from '@/src/utils/dom' +import type { FormFieldDataHandling } from '@/src/components/form/FormField' +import type { FormFieldInteractionStates } from '@/src/components/form/FieldLayout' +import { PopUpContext } from '@/src/components/layout/popup/PopUpContext' export interface MultiSelectIds { - trigger: string; - content: string; - listbox: string; - searchInput: string; + trigger: string, + content: string, + listbox: string, + searchInput: string, } export interface MultiSelectRootProps extends Partial>, Partial { - initialValue?: T[]; - compareFunction?: (a: T, b: T) => boolean; - initialIsOpen?: boolean; - onClose?: () => void; - showSearch?: boolean; - iconAppearance?: MultiSelectIconAppearance; - children: ReactNode; + initialValue?: T[], + compareFunction?: (a: T, b: T) => boolean, + initialIsOpen?: boolean, + onClose?: () => void, + showSearch?: boolean, + iconAppearance?: MultiSelectIconAppearance, + children: ReactNode, } export function MultiSelectRoot({ @@ -35,86 +35,85 @@ export function MultiSelectRoot({ initialIsOpen = false, onClose, showSearch = true, - iconAppearance = "right", + iconAppearance = 'right', invalid = false, disabled = false, readOnly = false, required = false, }: MultiSelectRootProps) { - const [triggerRef, setTriggerRef] = useState | null>(null); - const [options, setOptions] = useState[]>([]); - const generatedId = useId(); + const [triggerRef, setTriggerRef] = useState | null>(null) + const [options, setOptions] = useState[]>([]) + const generatedId = useId() const [ids, setIds] = useState({ - trigger: "multi-select-" + generatedId, - content: "multi-select-content-" + generatedId, - listbox: "multi-select-listbox-" + generatedId, - searchInput: "multi-select-search-" + generatedId, - }); + trigger: 'multi-select-' + generatedId, + content: 'multi-select-content-' + generatedId, + listbox: 'multi-select-listbox-' + generatedId, + searchInput: 'multi-select-search-' + generatedId, + }) const registerOption = useCallback((item: MultiSelectOptionType) => { setOptions((prev) => { - const next = prev.filter((o) => o.id !== item.id); - next.push(item); + const next = prev.filter((o) => o.id !== item.id) + next.push(item) next.sort((a, b) => - DOMUtils.compareDocumentPosition(a.ref.current, b.ref.current) - ); - return next; - }); - return () => setOptions((prev) => prev.filter((o) => o.id !== item.id)); - }, []); + DOMUtils.compareDocumentPosition(a.ref.current, b.ref.current)) + return next + }) + return () => setOptions((prev) => prev.filter((o) => o.id !== item.id)) + }, []) const registerTrigger = useCallback((ref: RefObject) => { - setTriggerRef(ref); - return () => setTriggerRef(null); - }, []); + setTriggerRef(ref) + return () => setTriggerRef(null) + }, []) - const compare = useMemo(() => compareFunction ?? Object.is, [compareFunction]); + const compare = useMemo(() => compareFunction ?? Object.is, [compareFunction]) const idToOptionMap = useMemo( () => options.reduce( (acc, o) => { - acc[o.id] = o; - return acc; + acc[o.id] = o + return acc }, {} as Record> ), [options] - ); + ) const mappedValueIds = useMemo(() => { - if (value == null) return undefined; + if (value == null) return undefined return value .map((v) => options.find((o) => compare(o.value, v))?.id) - .filter((id) => id !== undefined); - }, [options, value, compare]); + .filter((id) => id !== undefined) + }, [options, value, compare]) const mappedInitialValueIds = useMemo(() => { - if (initialValue == null) return []; + if (initialValue == null) return [] return initialValue .map((v) => options.find((o) => compare(o.value, v))?.id) - .filter((id) => id !== undefined); - }, [options, initialValue, compare]); + .filter((id) => id !== undefined) + }, [options, initialValue, compare]) const onValueChangeStable = useCallback( (ids: string[]) => { const values = ids .map((id) => idToOptionMap[id]?.value) - .filter((v): v is T => v != null); - onValueChange?.(values); + .filter((v): v is T => v != null) + onValueChange?.(values) }, [idToOptionMap, onValueChange] - ); + ) const onEditCompleteStable = useCallback( (ids: string[]) => { const values = ids .map((id) => idToOptionMap[id]?.value) - .filter((v): v is T => v != null); - onEditComplete?.(values); + .filter((v): v is T => v != null) + onEditComplete?.(values) }, [idToOptionMap, onEditComplete] - ); + ) const state = useMultiSelect({ options: options.map((o) => ({ id: o.id, label: o.label, disabled: o.disabled })), @@ -124,18 +123,19 @@ export function MultiSelectRoot({ initialValue: mappedInitialValueIds, initialIsOpen, onClose, - }); + }) + const { setSearchQuery } = state useEffect(() => { if (showSearch === false) { - state.setSearchQuery(""); + setSearchQuery('') } - }, [showSearch, state.setSearchQuery]); + }, [showSearch, setSearchQuery]) const contextValue = useMemo((): MultiSelectContextType => { const valueT = state.value .map((id) => idToOptionMap[id]?.value) - .filter((v): v is T => v != null); + .filter((v): v is T => v != null) return { invalid, disabled, @@ -172,7 +172,7 @@ export function MultiSelectRoot({ searchQuery: state.searchQuery, setSearchQuery: state.setSearchQuery, }, - }; + } }, [ invalid, disabled, @@ -187,11 +187,11 @@ export function MultiSelectRoot({ triggerRef, registerTrigger, showSearch, - ]); + ]) return ( }> - ({ {children} - ); + ) } diff --git a/src/components/user-interaction/MultiSelect/useMultiSelect.ts b/src/components/user-interaction/MultiSelect/useMultiSelect.ts index 6b50212..4aead8e 100644 --- a/src/components/user-interaction/MultiSelect/useMultiSelect.ts +++ b/src/components/user-interaction/MultiSelect/useMultiSelect.ts @@ -2,58 +2,57 @@ import { useCallback, useEffect, useMemo, - useRef, - useState, -} from "react"; -import { useMultiSelection } from "@/src/hooks/useMultiSelection"; -import { useListNavigation } from "@/src/hooks/useListNavigation"; -import { useEventCallbackStabilizer } from "@/src/hooks/useEventCallbackStabelizer"; -import { useSearch, useTypeAheadSearch } from "@/src/hooks"; + useState +} from 'react' +import { useMultiSelection } from '@/src/hooks/useMultiSelection' +import { useListNavigation } from '@/src/hooks/useListNavigation' +import { useEventCallbackStabilizer } from '@/src/hooks/useEventCallbackStabelizer' +import { useSearch, useTypeAheadSearch } from '@/src/hooks' export interface UseMultiSelectOption { - id: string; - label?: string; - disabled?: boolean; + id: string, + label?: string, + disabled?: boolean, } export interface UseMultiSelectOptions { - options: ReadonlyArray; - value?: ReadonlyArray; - onValueChange?: (value: string[]) => void; - onEditComplete?: (value: string[]) => void; - initialValue?: string[]; - initialIsOpen?: boolean; - onClose?: () => void; - typeAheadResetMs?: number; + options: ReadonlyArray, + value?: ReadonlyArray, + onValueChange?: (value: string[]) => void, + onEditComplete?: (value: string[]) => void, + initialValue?: string[], + initialIsOpen?: boolean, + onClose?: () => void, + typeAheadResetMs?: number, } -export type UseMultiSelectFirstHighlightBehavior = "first" | "last"; +export type UseMultiSelectFirstHighlightBehavior = 'first' | 'last'; export interface UseMultiSelectState { - value: string[]; - highlightedId: string | null; - isOpen: boolean; - searchQuery: string; - options: ReadonlyArray; + value: string[], + highlightedId: string | null, + isOpen: boolean, + searchQuery: string, + options: ReadonlyArray, } export interface UseMultiSelectComputedState { - visibleOptionIds: ReadonlyArray; + visibleOptionIds: ReadonlyArray, } export interface UseMultiSelectActions { - setIsOpen: (isOpen: boolean, behavior?: UseMultiSelectFirstHighlightBehavior) => void; - toggleOpen: (behavior?: UseMultiSelectFirstHighlightBehavior) => void; - setSearchQuery: (query: string) => void; - highlightFirst: () => void; - highlightLast: () => void; - highlightNext: () => void; - highlightPrevious: () => void; - highlightItem: (id: string) => void; - toggleSelection: (id: string, isSelected?: boolean) => void; - setSelection: (ids: string[]) => void; - isSelected: (id: string) => boolean; - handleTypeaheadKey: (key: string) => void; + setIsOpen: (isOpen: boolean, behavior?: UseMultiSelectFirstHighlightBehavior) => void, + toggleOpen: (behavior?: UseMultiSelectFirstHighlightBehavior) => void, + setSearchQuery: (query: string) => void, + highlightFirst: () => void, + highlightLast: () => void, + highlightNext: () => void, + highlightPrevious: () => void, + highlightItem: (id: string) => void, + toggleSelection: (id: string, isSelected?: boolean) => void, + setSelection: (ids: string[]) => void, + isSelected: (id: string) => boolean, + handleTypeaheadKey: (key: string) => void, } export interface UseMultiSelectReturn extends UseMultiSelectState, UseMultiSelectComputedState, UseMultiSelectActions {} @@ -68,146 +67,140 @@ export function useMultiSelect({ initialIsOpen = false, typeAheadResetMs = 500, }: UseMultiSelectOptions): UseMultiSelectReturn { - const [isOpen, setIsOpen] = useState(initialIsOpen); - const [searchQuery, setSearchQuery] = useState(""); + const [isOpen, setIsOpen] = useState(initialIsOpen) + const [searchQuery, setSearchQuery] = useState('') const selectionOptions = useMemo( () => options.map((o) => ({ id: o.id, disabled: o.disabled })), [options] - ); + ) - const selection = useMultiSelection({ + const { selection, toggleSelection, setSelection, isSelected } = useMultiSelection({ options: selectionOptions, value: controlledValue, onSelectionChange: (ids) => onValueChange?.(Array.from(ids)), initialSelection: initialValue ?? [], isControlled: controlledValue !== undefined, - }); + }) - const editCompleteStable = useEventCallbackStabilizer(onEditComplete); - const onCloseStable = useEventCallbackStabilizer(onClose); + const editCompleteStable = useEventCallbackStabilizer(onEditComplete) + const onCloseStable = useEventCallbackStabilizer(onClose) const { searchResult: visibleOptions } = useSearch({ items: options, searchQuery, - toTags: useCallback((o: UseMultiSelectOption) => [o.label ?? ""], []), - }); + toTags: useCallback((o: UseMultiSelectOption) => [o.label ?? ''], []), + }) const visibleOptionIds = useMemo( () => visibleOptions.map((o) => o.id), [visibleOptions] - ); + ) const enabledOptions = useMemo( () => visibleOptions.filter((o) => !o.disabled), [visibleOptions] - ); + ) const listNav = useListNavigation({ options: enabledOptions.map((o) => o.id), - initialValue: selection.selection[0] ?? null, - }); + initialValue: selection[0] ?? null, + }) + const { highlight: listNavHighlight } = listNav const typeAhead = useTypeAheadSearch({ options: enabledOptions, resetTimer: typeAheadResetMs, - toString: (o) => o.label ?? "", + toString: (o) => o.label ?? '', onResultChange: useCallback( (option: UseMultiSelectOption | null) => { - if (option) listNav.highlight(option.id); + if (option) listNav.highlight(option.id) }, [listNav] ), - }); + }) + const { reset: typeAheadReset, addToTypeAhead } = typeAhead useEffect(() => { - if (!isOpen) typeAhead.reset(); - }, [isOpen]); - - const highlightItem = useCallback( - (id: string) => { - if (!enabledOptions.some((o) => o.id === id)) return; - listNav.highlight(id); - }, - [enabledOptions, listNav] - ); - - const toggleSelectionValue = useCallback( - (id: string, isSelected?: boolean) => { - const before = selection.isSelected(id); - const next = isSelected ?? !before; - if (next) { - selection.toggleSelection(id); - } else { - selection.setSelection(selection.selection.filter((s) => s !== id)); - } - highlightItem(id); - }, - [selection, highlightItem] - ); + if (!isOpen) typeAheadReset() + }, [isOpen, typeAheadReset]) + + const highlightItem = useCallback((id: string) => { + if (!enabledOptions.some((o) => o.id === id)) return + listNavHighlight(id) + }, [enabledOptions, listNavHighlight]) + + const toggleSelectionValue = useCallback((id: string, newIsSelected?: boolean) => { + const next = newIsSelected ?? !isSelected(id) + if (next) { + toggleSelection(id) + } else { + setSelection(selection.filter((s) => s !== id)) + } + highlightItem(id) + }, [toggleSelection, setSelection, highlightItem, isSelected, selection]) const setIsOpenWrapper = useCallback( (open: boolean, behavior?: UseMultiSelectFirstHighlightBehavior) => { - setIsOpen(open); - behavior = behavior ?? "first"; + setIsOpen(open) + behavior = behavior ?? 'first' if (open) { if (enabledOptions.length > 0) { - let selected: UseMultiSelectOption | undefined; - if (behavior === "first") { - selected = enabledOptions.find((o) => selection.isSelected(o.id)); - selected ??= enabledOptions[0]; - } else if (behavior === "last") { + let selected: UseMultiSelectOption | undefined + if (behavior === 'first') { + selected = enabledOptions.find((o) => isSelected(o.id)) + selected ??= enabledOptions[0] + } else if (behavior === 'last') { selected = [...enabledOptions] .reverse() - .find((o) => selection.isSelected(o.id)); - selected ??= enabledOptions[enabledOptions.length - 1]; + .find((o) => isSelected(o.id)) + selected ??= enabledOptions[enabledOptions.length - 1] } - if (selected) highlightItem(selected.id); + if (selected) highlightItem(selected.id) } } else { - setSearchQuery(""); - onCloseStable?.(); - editCompleteStable?.(Array.from(selection.selection)); + setSearchQuery('') + onCloseStable?.() + editCompleteStable?.(Array.from(selection)) } }, [ + selection, isSelected, highlightItem, onCloseStable, editCompleteStable, - selection.selection, - selection.isSelected, enabledOptions, ] - ); + ) const toggleOpenWrapper = useCallback( (behavior?: UseMultiSelectFirstHighlightBehavior) => { - setIsOpenWrapper(!isOpen, behavior); + setIsOpenWrapper(!isOpen, behavior) }, [isOpen, setIsOpenWrapper] - ); + ) const state: UseMultiSelectState = useMemo( () => ({ - value: [...selection.selection], + value: [...selection], highlightedId: listNav.highlightedId, isOpen, searchQuery, options, }), [ - selection.selection, + selection, listNav.highlightedId, isOpen, searchQuery, options, ] - ); + ) const computedState: UseMultiSelectComputedState = useMemo( () => ({ visibleOptionIds }), [visibleOptionIds] - ); + ) const actions: UseMultiSelectActions = useMemo( () => ({ @@ -220,9 +213,9 @@ export function useMultiSelect({ highlightPrevious: listNav.previous, highlightItem, toggleSelection: toggleSelectionValue, - setSelection: (ids: string[]) => selection.setSelection(ids), - isSelected: selection.isSelected, - handleTypeaheadKey: typeAhead.addToTypeAhead, + setSelection: setSelection, + isSelected, + handleTypeaheadKey: addToTypeAhead, }), [ setIsOpenWrapper, @@ -233,11 +226,11 @@ export function useMultiSelect({ listNav.previous, highlightItem, toggleSelectionValue, - selection.setSelection, - selection.isSelected, - typeAhead.addToTypeAhead, + setSelection, + isSelected, + addToTypeAhead, ] - ); + ) return useMemo( (): UseMultiSelectReturn => ({ @@ -246,5 +239,5 @@ export function useMultiSelect({ ...actions, }), [state, computedState, actions] - ); + ) } diff --git a/src/components/user-interaction/Select/Select.tsx b/src/components/user-interaction/Select/Select.tsx index 1961434..f9d030b 100644 --- a/src/components/user-interaction/Select/Select.tsx +++ b/src/components/user-interaction/Select/Select.tsx @@ -1,18 +1,18 @@ -import type { ReactNode, JSX } from "react"; -import { forwardRef } from "react"; -import type { SelectRootProps } from "./SelectRoot"; -import { SelectRoot } from "./SelectRoot"; -import type { SelectButtonProps } from "./SelectButton"; -import { SelectButton } from "./SelectButton"; -import type { SelectContentProps } from "./SelectContent"; -import { SelectContent } from "./SelectContent"; -import { SelectOptionType } from "./SelectContext"; +import type { ReactNode, JSX } from 'react' +import { forwardRef } from 'react' +import type { SelectRootProps } from './SelectRoot' +import { SelectRoot } from './SelectRoot' +import type { SelectButtonProps } from './SelectButton' +import { SelectButton } from './SelectButton' +import type { SelectContentProps } from './SelectContent' +import { SelectContent } from './SelectContent' +import type { SelectOptionType } from './SelectContext' export type SelectProps = SelectRootProps & { - contentPanelProps?: SelectContentProps; - buttonProps?: Omit, "selectedDisplay"> & { - selectedDisplay?: (value: SelectOptionType | null) => ReactNode; - } & { [key: string]: unknown }; + contentPanelProps?: SelectContentProps, + buttonProps?: Omit, 'selectedDisplay'> & { + selectedDisplay?: (value: SelectOptionType | null) => ReactNode, + } & { [key: string]: unknown }, }; export const Select = forwardRef>(function Select( @@ -26,11 +26,11 @@ export const Select = forwardRef>(function ref={ref} {...buttonProps} selectedDisplay={(value: SelectOptionType | null) => { - if (!buttonProps?.selectedDisplay) return undefined; - return buttonProps.selectedDisplay(value as SelectOptionType); + if (!buttonProps?.selectedDisplay) return undefined + return buttonProps.selectedDisplay(value as SelectOptionType) }} /> {children} - ); -}) as (props: SelectProps & React.RefAttributes) => JSX.Element; + ) +}) as (props: SelectProps & React.RefAttributes) => JSX.Element diff --git a/src/components/user-interaction/Select/SelectButton.tsx b/src/components/user-interaction/Select/SelectButton.tsx index ce17093..3751a79 100644 --- a/src/components/user-interaction/Select/SelectButton.tsx +++ b/src/components/user-interaction/Select/SelectButton.tsx @@ -1,15 +1,16 @@ -import type { ComponentPropsWithoutRef, ReactNode } from "react"; -import { forwardRef, useEffect, useImperativeHandle, useRef } from "react"; -import { SelectOptionType, useSelectContext } from "./SelectContext"; -import { useHightideTranslation } from "@/src/i18n/useHightideTranslation"; -import { ExpansionIcon } from "@/src/components/display-and-visualization/ExpansionIcon"; -import { SelectOptionDisplayContext } from "./SelectOption"; +import type { ComponentPropsWithoutRef, ReactNode } from 'react' +import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react' +import type { SelectOptionType } from './SelectContext' +import { useSelectContext } from './SelectContext' +import { useHightideTranslation } from '@/src/i18n/useHightideTranslation' +import { ExpansionIcon } from '@/src/components/display-and-visualization/ExpansionIcon' +import { SelectOptionDisplayContext } from './SelectOption' -export interface SelectButtonProps extends ComponentPropsWithoutRef<"div"> { - placeholder?: ReactNode; - disabled?: boolean; - selectedDisplay?: (value: SelectOptionType | null) => ReactNode; - hideExpansionIcon?: boolean; +export interface SelectButtonProps extends ComponentPropsWithoutRef<'div'> { + placeholder?: ReactNode, + disabled?: boolean, + selectedDisplay?: (value: SelectOptionType | null) => ReactNode, + hideExpansionIcon?: boolean, } export const SelectButton = forwardRef>( @@ -24,25 +25,28 @@ export const SelectButton = forwardRef, ref ) { - const translation = useHightideTranslation(); - const context = useSelectContext(); + const translation = useHightideTranslation() + const context = useSelectContext() + const { config, layout } = context + const { setIds } = config + const { registerTrigger } = layout useEffect(() => { - if (id) context.config.setIds((prev) => ({ ...prev, trigger: id })); - }, [id, context.config.setIds]); + if (id) setIds((prev) => ({ ...prev, trigger: id })) + }, [id, setIds]) - const innerRef = useRef(null); - useImperativeHandle(ref, () => innerRef.current!); + const innerRef = useRef(null) + useImperativeHandle(ref, () => innerRef.current!) useEffect(() => { - const unregister = context.layout.registerTrigger(innerRef); - return () => unregister(); - }, [context.layout.registerTrigger]); + const unregister = registerTrigger(innerRef) + return () => unregister() + }, [registerTrigger]) - const disabled = !!disabledOverride || !!context.disabled; - const invalid = context.invalid; - const hasValue = context.selectedId !== null; - const selectedOption = context.idToOptionMap[context.selectedId] ?? null; + const disabled = !!disabledOverride || !!context.disabled + const invalid = context.invalid + const hasValue = context.selectedId !== null + const selectedOption = context.idToOptionMap[context.selectedId] ?? null return (
      { - props.onClick?.(event); - context.toggleIsOpen(); + props.onClick?.(event) + context.toggleIsOpen() }} onKeyDown={(event) => { - props.onKeyDown?.(event); - if (disabled) return; + props.onKeyDown?.(event) + if (disabled) return switch (event.key) { - case "Enter": - case " ": - context.toggleIsOpen(); - event.preventDefault(); - event.stopPropagation(); - break; - case "ArrowDown": - context.setIsOpen(true, "first"); - event.preventDefault(); - event.stopPropagation(); - break; - case "ArrowUp": - context.setIsOpen(true, "last"); - event.preventDefault(); - event.stopPropagation(); - break; + case 'Enter': + case ' ': + context.toggleIsOpen() + event.preventDefault() + event.stopPropagation() + break + case 'ArrowDown': + context.setIsOpen(true, 'first') + event.preventDefault() + event.stopPropagation() + break + case 'ArrowUp': + context.setIsOpen(true, 'last') + event.preventDefault() + event.stopPropagation() + break } }} - data-name={props["data-name"] ?? "select-button"} - data-value={hasValue ? "" : undefined} - data-disabled={disabled ? "" : undefined} - data-invalid={invalid ? "" : undefined} + data-name={props['data-name'] ?? 'select-button'} + data-value={hasValue ? '' : undefined} + data-disabled={disabled ? '' : undefined} + data-invalid={invalid ? '' : undefined} tabIndex={disabled ? -1 : 0} role="button" aria-invalid={invalid} @@ -90,10 +94,10 @@ export const SelectButton = forwardRef {hasValue ? selectedDisplay?.(selectedOption) ?? (selectedOption.display) - : placeholder ?? translation("clickToSelect")} + : placeholder ?? translation('clickToSelect')} {!hideExpansionIcon && }
      - ); + ) } -); +) diff --git a/src/components/user-interaction/Select/SelectContent.tsx b/src/components/user-interaction/Select/SelectContent.tsx index 934b69f..23dfcf1 100644 --- a/src/components/user-interaction/Select/SelectContent.tsx +++ b/src/components/user-interaction/Select/SelectContent.tsx @@ -1,72 +1,73 @@ -import type { ComponentProps } from "react"; -import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef } from "react"; -import { useSelectContext } from "./SelectContext"; -import clsx from "clsx"; -import { useHightideTranslation } from "@/src/i18n/useHightideTranslation"; -import { PopUp, type PopUpProps } from "@/src/components/layout/popup/PopUp"; -import { Input } from "@/src/components/user-interaction/input/Input"; -import { Visibility } from "@/src/components/layout/Visibility"; +import type { ComponentProps } from 'react' +import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef } from 'react' +import { useSelectContext } from './SelectContext' +import clsx from 'clsx' +import { useHightideTranslation } from '@/src/i18n/useHightideTranslation' +import { PopUp, type PopUpProps } from '@/src/components/layout/popup/PopUp' +import { Input } from '@/src/components/user-interaction/input/Input' +import { Visibility } from '@/src/components/layout/Visibility' export interface SelectContentProps extends PopUpProps { - showSearch?: boolean; - searchInputProps?: Omit, "value" | "onValueChange">; + showSearch?: boolean, + searchInputProps?: Omit, 'value' | 'onValueChange'>, } export const SelectContent = forwardRef(function SelectContent({ id, options, showSearch: showSearchOverride, searchInputProps, ...props }, ref) { - const translation = useHightideTranslation(); - const innerRef = useRef(null); - const searchInputRef = useRef(null); - useImperativeHandle(ref, () => innerRef.current!); + const translation = useHightideTranslation() + const innerRef = useRef(null) + const searchInputRef = useRef(null) + useImperativeHandle(ref, () => innerRef.current!) - const context = useSelectContext(); + const context = useSelectContext() + const { config, handleTypeaheadKey, toggleSelection, highlightNext, highlightPrevious, highlightFirst, highlightLast, highlightedId } = context + const { setIds } = config useEffect(() => { - if (id) context.config.setIds((prev) => ({ ...prev, content: id })); - }, [id, context.config.setIds]); + if (id) setIds((prev) => ({ ...prev, content: id })) + }, [id, setIds]) - const showSearch = showSearchOverride ?? context.search.hasSearch; - const listboxAriaLabel = showSearch ? translation("searchResults") : undefined; + const showSearch = showSearchOverride ?? context.search.hasSearch + const listboxAriaLabel = showSearch ? translation('searchResults') : undefined const keyHandler = useCallback( (event: React.KeyboardEvent) => { switch (event.key) { - case "ArrowDown": - context.highlightNext(); - event.preventDefault(); - break; - case "ArrowUp": - context.highlightPrevious(); - event.preventDefault(); - break; - case "Home": - event.preventDefault(); - context.highlightFirst(); - break; - case "End": - event.preventDefault(); - context.highlightLast(); - break; - case "Enter": - case " ": - if (showSearch && event.key === " ") return; - if (context.highlightedId) { - context.toggleSelection(context.highlightedId); - event.preventDefault(); - } - break; - default: - if (!showSearch && !event.ctrlKey && !event.metaKey && !event.altKey && event.key.length === 1) { - if (context.handleTypeaheadKey(event.key)) { - event.preventDefault(); - } - } - break; + case 'ArrowDown': + highlightNext() + event.preventDefault() + break + case 'ArrowUp': + highlightPrevious() + event.preventDefault() + break + case 'Home': + event.preventDefault() + highlightFirst() + break + case 'End': + event.preventDefault() + highlightLast() + break + case 'Enter': + case ' ': + if (showSearch && event.key === ' ') return + if (highlightedId) { + toggleSelection(highlightedId) + event.preventDefault() + } + break + default: + if (!showSearch && !event.ctrlKey && !event.metaKey && !event.altKey && event.key.length === 1) { + handleTypeaheadKey(event.key) + event.preventDefault() + } + break } }, - [showSearch, context] - ); + [showSearch, handleTypeaheadKey, toggleSelection, highlightedId, highlightNext, highlightPrevious, highlightFirst, highlightLast] + ) return ( (fu options={options} forceMount={true} onClose={() => { - context.setIsOpen(false); - props.onClose?.(); + context.setIsOpen(false) + props.onClose?.() }} aria-labelledby={context.config.ids.trigger} - className={clsx("gap-y-1", props.className)} + className={clsx('gap-y-1', props.className)} > {showSearch && ( (fu value={context.search.searchQuery} onValueChange={context.search.setSearchQuery} onKeyDown={keyHandler} - placeholder={searchInputProps?.placeholder ?? translation("filterOptions")} + placeholder={searchInputProps?.placeholder ?? translation('filterOptions')} role="combobox" aria-autocomplete="list" aria-expanded={context.isOpen} aria-controls={context.config.ids.listbox} aria-activedescendant={ - context.highlightedId ? context.config.ids.listbox + "-" + context.highlightedId : undefined + context.highlightedId ? context.config.ids.listbox + '-' + context.highlightedId : undefined } - aria-label={searchInputProps?.["aria-label"] ?? translation("filterOptions")} - className={clsx("mx-2 mt-2 shrink-0", searchInputProps?.className)} + aria-label={searchInputProps?.['aria-label'] ?? translation('filterOptions')} + className={clsx('mx-2 mt-2 shrink-0', searchInputProps?.className)} /> )}
        (fu aria-orientation="vertical" aria-label={listboxAriaLabel} tabIndex={showSearch ? undefined : 0} - className={clsx("flex-col-1 p-2 overflow-auto")} + className={clsx('flex-col-1 p-2 overflow-auto')} > {props.children} @@ -123,12 +124,12 @@ export const SelectContent = forwardRef(fu aria-live="polite" aria-atomic={true} data-name="select-list-status" - className={clsx({ "sr-only": context.visibleOptionIds.length > 0 })} + className={clsx({ 'sr-only': context.visibleOptionIds.length > 0 })} > - {translation("nResultsFound", { count: context.visibleOptionIds.length })} + {translation('nResultsFound', { count: context.visibleOptionIds.length })}
      - ); -}); + ) +}) diff --git a/src/components/user-interaction/Select/SelectContext.tsx b/src/components/user-interaction/Select/SelectContext.tsx index f3ee032..00d64a6 100644 --- a/src/components/user-interaction/Select/SelectContext.tsx +++ b/src/components/user-interaction/Select/SelectContext.tsx @@ -1,78 +1,78 @@ -import type { Dispatch, ReactNode, RefObject, SetStateAction } from "react"; -import { createContext, useContext } from "react"; -import { UseSelectFirstHighlightBehavior } from "./useSelect"; -import { FormFieldInteractionStates } from "../../form/FieldLayout"; +import type { Dispatch, ReactNode, RefObject, SetStateAction } from 'react' +import { createContext, useContext } from 'react' +import type { UseSelectFirstHighlightBehavior } from './useSelect' +import type { FormFieldInteractionStates } from '../../form/FieldLayout' export interface SelectOptionType { - id: string; - value: T; - label?: string; - display?: ReactNode; - disabled?: boolean; - ref: RefObject; + id: string, + value: T, + label?: string, + display?: ReactNode, + disabled?: boolean, + ref: RefObject, } export interface SelectContextIds { - trigger: string; - content: string; - listbox: string; - searchInput: string; + trigger: string, + content: string, + listbox: string, + searchInput: string, } export interface SelectContextState extends FormFieldInteractionStates { - selectedId: string | null; - options: ReadonlyArray>; - highlightedId: string | null; - isOpen: boolean; + selectedId: string | null, + options: ReadonlyArray>, + highlightedId: string | null, + isOpen: boolean, } export interface SelectContextComputedState { - visibleOptionIds: ReadonlyArray; - idToOptionMap: Record>; + visibleOptionIds: ReadonlyArray, + idToOptionMap: Record>, } export interface SelectContextActions { - registerOption(option: SelectOptionType): () => void; - toggleSelection(id: string): void; - highlightFirst(): void; - highlightLast(): void; - highlightNext(): void; - highlightPrevious(): void; - highlightItem(id: string): void; - handleTypeaheadKey(key: string): void; - setIsOpen(open: boolean, behavior?: UseSelectFirstHighlightBehavior): void; - toggleIsOpen(behavior?: UseSelectFirstHighlightBehavior): void; + registerOption(option: SelectOptionType): () => void, + toggleSelection(id: string): void, + highlightFirst(): void, + highlightLast(): void, + highlightNext(): void, + highlightPrevious(): void, + highlightItem(id: string): void, + handleTypeaheadKey(key: string): void, + setIsOpen(open: boolean, behavior?: UseSelectFirstHighlightBehavior): void, + toggleIsOpen(behavior?: UseSelectFirstHighlightBehavior): void, } export interface SelectContextLayout { - triggerRef: RefObject; - registerTrigger(element: RefObject): () => void; + triggerRef: RefObject, + registerTrigger(element: RefObject): () => void, } export interface SelectContextSearch { hasSearch: boolean, searchQuery?: string, - setSearchQuery(query: string): void; + setSearchQuery(query: string): void, } -export type SelectIconAppearance = "left" | "right" | "none"; +export type SelectIconAppearance = 'left' | 'right' | 'none'; export interface SelectContextConfig { - iconAppearance: SelectIconAppearance; - ids: SelectContextIds; - setIds: Dispatch>; + iconAppearance: SelectIconAppearance, + ids: SelectContextIds, + setIds: Dispatch>, } export interface SelectContextType extends SelectContextActions, SelectContextState, SelectContextComputedState { - config: SelectContextConfig; - layout: SelectContextLayout; - search: SelectContextSearch; + config: SelectContextConfig, + layout: SelectContextLayout, + search: SelectContextSearch, } -export const SelectContext = createContext | null>(null); +export const SelectContext = createContext | null>(null) export function useSelectContext(): SelectContextType { - const ctx = useContext(SelectContext); - if (!ctx) throw new Error("useSelectContext must be used within SelectRoot"); - return ctx as SelectContextType; + const ctx = useContext(SelectContext) + if (!ctx) throw new Error('useSelectContext must be used within SelectRoot') + return ctx as SelectContextType } diff --git a/src/components/user-interaction/Select/SelectOption.tsx b/src/components/user-interaction/Select/SelectOption.tsx index 56a7b55..66d770d 100644 --- a/src/components/user-interaction/Select/SelectOption.tsx +++ b/src/components/user-interaction/Select/SelectOption.tsx @@ -1,27 +1,27 @@ -import clsx from "clsx"; -import { CheckIcon } from "lucide-react"; -import type { HTMLAttributes, ReactNode, RefObject } from "react"; -import { createContext, forwardRef, useContext, useEffect, useId, useRef } from "react"; -import type { SelectIconAppearance } from "./SelectContext"; -import { useSelectContext } from "./SelectContext"; +import clsx from 'clsx' +import { CheckIcon } from 'lucide-react' +import type { HTMLAttributes, ReactNode, RefObject } from 'react' +import { createContext, forwardRef, useContext, useEffect, useId, useRef } from 'react' +import type { SelectIconAppearance } from './SelectContext' +import { useSelectContext } from './SelectContext' -export type SelectOptionDisplayLocation = "trigger" | "list"; +export type SelectOptionDisplayLocation = 'trigger' | 'list'; -export const SelectOptionDisplayContext = createContext(null); +export const SelectOptionDisplayContext = createContext(null) export function useSelectOptionDisplayLocation(): SelectOptionDisplayLocation { - const context = useContext(SelectOptionDisplayContext); + const context = useContext(SelectOptionDisplayContext) if (!context) { - throw new Error("useSelectOptionDisplayLocation must be used within a SelectOptionDisplayContext"); + throw new Error('useSelectOptionDisplayLocation must be used within a SelectOptionDisplayContext') } - return context; + return context } export interface SelectOptionProps extends HTMLAttributes { - value: T; - label: string; - disabled?: boolean; - iconAppearance?: SelectIconAppearance; + value: T, + label: string, + disabled?: boolean, + iconAppearance?: SelectIconAppearance, } export const SelectOption = forwardRef>(function SelectOption({ @@ -30,40 +30,40 @@ export const SelectOption = forwardRef value, disabled = false, iconAppearance, - className, ...props }: SelectOptionProps, ref) { - const context= useSelectContext(); - const itemRef = useRef(null); + const context= useSelectContext() + const { registerOption } = context + const itemRef = useRef(null) - const display: ReactNode = children ?? label; - const iconAppearanceResolved = iconAppearance ?? context.config.iconAppearance; - - const generatedId = useId(); - const optionId = props?.id ?? "select-option-" + generatedId; + const display: ReactNode = children ?? label + const iconAppearanceResolved = iconAppearance ?? context.config.iconAppearance + + const generatedId = useId() + const optionId = props?.id ?? 'select-option-' + generatedId useEffect(() => { - return context.registerOption({ + return registerOption({ id: optionId, value, label, display, disabled: disabled, ref: itemRef as React.RefObject, - }); - }, [value, label, disabled, context.registerOption, display]); + }) + }, [value, label, disabled, registerOption, display, optionId]) - const isHighlighted = context.highlightedId === optionId; - const isSelected = context.selectedId === optionId; - const isVisible = context.visibleOptionIds.includes(optionId); + const isHighlighted = context.highlightedId === optionId + const isSelected = context.selectedId === optionId + const isVisible = context.visibleOptionIds.includes(optionId) return (
    • { - itemRef.current = node; - if (typeof ref === "function") ref(node); - else if (ref) (ref as RefObject).current = node; + itemRef.current = node + if (typeof ref === 'function') ref(node) + else if (ref) (ref as RefObject).current = node }} id={optionId} hidden={!isVisible} @@ -73,37 +73,37 @@ export const SelectOption = forwardRef aria-hidden={!isVisible} data-name="select-list-option" - data-highlighted={isHighlighted ? "" : undefined} - data-selected={isSelected ? "" : undefined} - data-disabled={disabled ? "" : undefined} - data-visible={isVisible ? "" : undefined} + data-highlighted={isHighlighted ? '' : undefined} + data-selected={isSelected ? '' : undefined} + data-disabled={disabled ? '' : undefined} + data-visible={isVisible ? '' : undefined} onClick={(event) => { if (!disabled) { - context.toggleSelection(optionId); - props.onClick?.(event); + context.toggleSelection(optionId) + props.onClick?.(event) } }} onMouseEnter={(event) => { if (!disabled) { - context.highlightItem(optionId); - props.onMouseEnter?.(event); + context.highlightItem(optionId) + props.onMouseEnter?.(event) } }} > - {iconAppearanceResolved === "left" && context.selectedId !== null && ( + {iconAppearanceResolved === 'left' && context.selectedId !== null && ( )} {display} - {iconAppearanceResolved === "right" && context.selectedId !== null && ( + {iconAppearanceResolved === 'right' && context.selectedId !== null && ( )}
    • - ); -}); + ) +}) diff --git a/src/components/user-interaction/Select/SelectRoot.tsx b/src/components/user-interaction/Select/SelectRoot.tsx index 544e70d..e56802b 100644 --- a/src/components/user-interaction/Select/SelectRoot.tsx +++ b/src/components/user-interaction/Select/SelectRoot.tsx @@ -1,31 +1,31 @@ -import type { ReactNode, RefObject } from "react"; -import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react"; -import { SelectContext } from "./SelectContext"; -import type { SelectContextConfig, SelectContextLayout, SelectOptionType } from "./SelectContext"; -import { useSelect } from "./useSelect"; -import { DOMUtils } from "@/src/utils/dom"; -import { FormFieldDataHandling } from "../../form/FormField"; -import { useEventCallbackStabilizer } from "@/src/hooks/useEventCallbackStabelizer"; -import { FormFieldInteractionStates } from "../../form/FieldLayout"; -import { PopUpContext } from "@/src/components/layout/popup/PopUpContext"; +import type { ReactNode, RefObject } from 'react' +import { useCallback, useEffect, useId, useMemo, useState } from 'react' +import { SelectContext } from './SelectContext' +import type { SelectContextConfig, SelectContextLayout, SelectOptionType } from './SelectContext' +import { useSelect } from '@/src/components/user-interaction/Select/useSelect' +import { DOMUtils } from '@/src/utils/dom' +import type { FormFieldDataHandling } from '@/src/components/form/FormField' +import { useEventCallbackStabilizer } from '@/src/hooks/useEventCallbackStabelizer' +import type { FormFieldInteractionStates } from '@/src/components/form/FieldLayout' +import { PopUpContext } from '@/src/components/layout/popup/PopUpContext' export interface SelectIds { - trigger: string; - content: string; - listbox: string; - searchInput: string; + trigger: string, + content: string, + listbox: string, + searchInput: string, } export interface SelectRootProps extends Partial>, Partial { - value?: T | null; - initialValue?: T | null; - compareFunction?: (a: T | null, b: T | null) => boolean; - initialIsOpen?: boolean; - onClose?: () => void; - onIsOpenChange?: (isOpen: boolean) => void; - showSearch?: boolean; - iconAppearance?: "left" | "right" | "none"; - children: ReactNode; + value?: T | null, + initialValue?: T | null, + compareFunction?: (a: T | null, b: T | null) => boolean, + initialIsOpen?: boolean, + onClose?: () => void, + onIsOpenChange?: (isOpen: boolean) => void, + showSearch?: boolean, + iconAppearance?: 'left' | 'right' | 'none', + children: ReactNode, } export function SelectRoot({ @@ -39,115 +39,115 @@ export function SelectRoot({ onClose, onIsOpenChange, showSearch = true, - iconAppearance = "right", + iconAppearance = 'right', invalid = false, disabled = false, readOnly = false, required = false, }: SelectRootProps) { - const [triggerRef, setTriggerRef] = useState | null>(null); - const [options, setOptions] = useState[]>([]); - const generatedId = useId(); + const [triggerRef, setTriggerRef] = useState | null>(null) + const [options, setOptions] = useState[]>([]) + const generatedId = useId() const [ids, setIds] = useState({ - trigger: "select-" + generatedId, - content: "select-content-" + generatedId, - listbox: "select-listbox-" + generatedId, - searchInput: "select-search-" + generatedId, - }); - + trigger: 'select-' + generatedId, + content: 'select-content-' + generatedId, + listbox: 'select-listbox-' + generatedId, + searchInput: 'select-search-' + generatedId, + }) + const registerOption = useCallback( (item: SelectOptionType) => { setOptions((prev) => { - const next = prev.filter((o) => o.value !== item.value); - next.push(item); + const next = prev.filter((o) => o.value !== item.value) + next.push(item) next.sort((a, b) => - DOMUtils.compareDocumentPosition(a.ref.current, b.ref.current) - ); - return next; - }); + DOMUtils.compareDocumentPosition(a.ref.current, b.ref.current)) + return next + }) return () => - setOptions((prev) => prev.filter((o) => o.value !== item.value)); + setOptions((prev) => prev.filter((o) => o.value !== item.value)) }, [] - ); + ) const registerTrigger = useCallback((ref: RefObject) => { - setTriggerRef(ref); + setTriggerRef(ref) return () => { - setTriggerRef(null); - }; - }, []); + setTriggerRef(null) + } + }, []) - const compare = useMemo(() => compareFunction ?? Object.is, [compareFunction]); + const compare = useMemo(() => compareFunction ?? Object.is, [compareFunction]) const idToOptionMap = useMemo(() => { return options.reduce((acc, o) => { - acc[o.id] = o; - return acc; - }, {} as Record>); - }, [options]); + acc[o.id] = o + return acc + }, {} as Record>) + }, [options]) const mappedValueId = useMemo(() => { - if(value === undefined) return undefined; - return options.find((o) => compare(o.value, value))?.id ?? null; - }, [options, value, compare]); + if(value === undefined) return undefined + return options.find((o) => compare(o.value, value))?.id ?? null + }, [options, value, compare]) const mappedInitialValueId = useMemo(() => { - if(initialValue === undefined) return undefined; - return options.find((o) => compare(o.value, initialValue))?.id ?? null; - }, [options, initialValue, compare]); + if(initialValue === undefined) return undefined + return options.find((o) => compare(o.value, initialValue))?.id ?? null + }, [options, initialValue, compare]) - const onValueChangeStable = useEventCallbackStabilizer(onValueChange); - const onEditCompleteStable = useEventCallbackStabilizer(onEditComplete); - const onIsOpenChangeStable = useEventCallbackStabilizer(onIsOpenChange); + const onValueChangeStable = useEventCallbackStabilizer(onValueChange) + const onEditCompleteStable = useEventCallbackStabilizer(onEditComplete) + const onIsOpenChangeStable = useEventCallbackStabilizer(onIsOpenChange) const onValueChangeWrapper = useCallback((value: string) => { const option = idToOptionMap[value] if(option === undefined) { - console.warn(`Attempted to select an option ${value} that is not valid`); - return; + console.warn(`Attempted to select an option ${value} that is not valid`) + return } - onValueChangeStable(option.value); - }, [onValueChangeStable, idToOptionMap]); + onValueChangeStable(option.value) + }, [onValueChangeStable, idToOptionMap]) const onEditCompleteWrapper = useCallback((value: string) => { const option = idToOptionMap[value] if(option === undefined) { - console.warn(`Attempted to edit complete an option ${value} that is not valid`); - return; + console.warn(`Attempted to edit complete an option ${value} that is not valid`) + return } - onEditCompleteStable(option.value); - }, [onEditCompleteStable, idToOptionMap]); + onEditCompleteStable(option.value) + }, [onEditCompleteStable, idToOptionMap]) const state = useSelect({ value: mappedValueId, initialValue: mappedInitialValueId, - onValueChange: onValueChangeWrapper, + onValueChange: onValueChangeWrapper, onEditComplete: onEditCompleteWrapper, options, initialIsOpen, onClose, onIsOpenChange: onIsOpenChangeStable, - }); + }) + const { setSearchQuery } = state useEffect(() => { if(showSearch === false) { - state.setSearchQuery(""); + setSearchQuery('') } - }, [showSearch]); + }, [showSearch, setSearchQuery]) const config: SelectContextConfig = useMemo(() => ({ iconAppearance, ids, setIds, - }), [iconAppearance, ids, setIds]); + }), [iconAppearance, ids, setIds]) const layout: SelectContextLayout = useMemo(() => ({ triggerRef, registerTrigger, - }), [triggerRef, registerTrigger]); + }), [triggerRef, registerTrigger]) return ( ({ }} > ({ setTriggerRef, }} > - {children} + {children} - ); + ) } diff --git a/src/components/user-interaction/Select/useSelect.ts b/src/components/user-interaction/Select/useSelect.ts index 8f49553..b5c0a0e 100644 --- a/src/components/user-interaction/Select/useSelect.ts +++ b/src/components/user-interaction/Select/useSelect.ts @@ -2,57 +2,56 @@ import { useCallback, useEffect, useMemo, - useRef, - useState, -} from "react"; -import { useSingleSelection } from "@/src/hooks/useSingleSelection"; -import { useListNavigation } from "@/src/hooks/useListNavigation"; -import { useEventCallbackStabilizer } from "@/src/hooks/useEventCallbackStabelizer"; -import { useSearch, useTypeAheadSearch } from "@/src/hooks"; + useState +} from 'react' +import { useSingleSelection } from '@/src/hooks/useSingleSelection' +import { useListNavigation } from '@/src/hooks/useListNavigation' +import { useEventCallbackStabilizer } from '@/src/hooks/useEventCallbackStabelizer' +import { useSearch, useTypeAheadSearch } from '@/src/hooks' export interface UseSelectOption { - id: string; - label?: string; - disabled?: boolean; + id: string, + label?: string, + disabled?: boolean, } export interface UseSelectOptions { - options: ReadonlyArray; - value?: string | null; - initialValue?: string | null; - initialIsOpen?: boolean; - onValueChange?: (value: string) => void; - onEditComplete?: (value: string) => void; - onClose?: () => void; - onIsOpenChange?: (isOpen: boolean) => void; - typeAheadResetMs?: number; + options: ReadonlyArray, + value?: string | null, + initialValue?: string | null, + initialIsOpen?: boolean, + onValueChange?: (value: string) => void, + onEditComplete?: (value: string) => void, + onClose?: () => void, + onIsOpenChange?: (isOpen: boolean) => void, + typeAheadResetMs?: number, } -export type UseSelectFirstHighlightBehavior = "first" | "last"; +export type UseSelectFirstHighlightBehavior = 'first' | 'last'; export interface UseSelectState { - value: string | null; - highlightedValue: string | undefined; - isOpen: boolean; - searchQuery: string; - options: ReadonlyArray; + value: string | null, + highlightedValue: string | undefined, + isOpen: boolean, + searchQuery: string, + options: ReadonlyArray, } export interface UseSelectComputedState { - visibleOptionIds: ReadonlyArray; + visibleOptionIds: ReadonlyArray, } export interface UseSelectActions { - setIsOpen: (isOpen: boolean, behavior?: UseSelectFirstHighlightBehavior) => void; - toggleOpen: (behavior?: UseSelectFirstHighlightBehavior) => void; - setSearchQuery: (query: string) => void; - highlightFirst: () => void; - highlightLast: () => void; - highlightNext: () => void; - highlightPrevious: () => void; - highlightItem: (value: string) => void; - selectValue: (value: string) => void; - handleTypeaheadKey: (key: string) => void; + setIsOpen: (isOpen: boolean, behavior?: UseSelectFirstHighlightBehavior) => void, + toggleOpen: (behavior?: UseSelectFirstHighlightBehavior) => void, + setSearchQuery: (query: string) => void, + highlightFirst: () => void, + highlightLast: () => void, + highlightNext: () => void, + highlightPrevious: () => void, + highlightItem: (value: string) => void, + selectValue: (value: string) => void, + handleTypeaheadKey: (key: string) => void, } export interface UseSelectReturn extends UseSelectState, UseSelectComputedState, UseSelectActions {} @@ -68,117 +67,121 @@ export function useSelect({ initialIsOpen = false, typeAheadResetMs = 500, }: UseSelectOptions): UseSelectReturn { - const [isOpen, setIsOpen] = useState(initialIsOpen); - const [searchQuery, setSearchQuery] = useState(""); + const [isOpen, setIsOpen] = useState(initialIsOpen) + const [searchQuery, setSearchQuery] = useState('') - const onValueChangeStable = useEventCallbackStabilizer(onValueChange); - const onEditCompleteStable = useEventCallbackStabilizer(onEditComplete); - const onCloseStable = useEventCallbackStabilizer(onClose); - const onIsOpenChangeStable = useEventCallbackStabilizer(onIsOpenChange); + const onValueChangeStable = useEventCallbackStabilizer(onValueChange) + const onEditCompleteStable = useEventCallbackStabilizer(onEditComplete) + const onCloseStable = useEventCallbackStabilizer(onClose) + const onIsOpenChangeStable = useEventCallbackStabilizer(onIsOpenChange) const onSelectionChangeWrapper = useCallback((id: string | null) => { - if(id === null) return; + if(id === null) return onValueChangeStable(id) onEditCompleteStable(id) - setIsOpen(false); - }, [onValueChangeStable, onEditCompleteStable, setIsOpen]); + setIsOpen(false) + }, [onValueChangeStable, onEditCompleteStable, setIsOpen]) - const selection = useSingleSelection({ + const { selection, selectValue } = useSingleSelection({ options: options, selection: controlledValue, onSelectionChange: onSelectionChangeWrapper, initialSelection: initialValue, - }); - + }) + const { searchResult: visibleOptions } = useSearch({ items: options, searchQuery, toTags: useCallback((o: UseSelectOption) => [o.label], []), - }); + }) - const visibleOptionIds = useMemo(() => visibleOptions.map((o) => o.id), [visibleOptions]); + const visibleOptionIds = useMemo(() => visibleOptions.map((o) => o.id), [visibleOptions]) - const enabledOptions = useMemo(() => visibleOptions.filter((o) => !o.disabled), [visibleOptions]); + const enabledOptions = useMemo(() => visibleOptions.filter((o) => !o.disabled), [visibleOptions]) - const listNav = useListNavigation({ + const { + highlightedId, + highlight: listNavHighlight, + first: listNavFirst, + last: listNavLast, + next: listNavNext, + previous: listNavPrevious, + } = useListNavigation({ options: enabledOptions.map((o) => o.id), - initialValue: selection.selection, - }); + initialValue: selection, + }) - const typeAhead = useTypeAheadSearch({ + const { addToTypeAhead, reset: typeAheadReset } = useTypeAheadSearch({ options: enabledOptions, resetTimer: typeAheadResetMs, - toString: (o) => o.label ?? "", - onResultChange: useCallback( - (option: UseSelectOption | null) => { - if (option) listNav.highlight(option.id); - }, - [listNav] - ), - }); + toString: (o) => o.label ?? '', + onResultChange: useCallback((option: UseSelectOption | null) => { + if (option) listNavHighlight(option.id) + }, [listNavHighlight]), + }) useEffect(() => { - if (!isOpen) typeAhead.reset(); - }, [isOpen]); + if (!isOpen) typeAheadReset() + }, [isOpen, typeAheadReset]) const state: UseSelectState = useMemo(() => ({ - value: selection.selection, - highlightedValue: listNav.highlightedId, + value: selection, + highlightedValue: highlightedId, isOpen, searchQuery, options, - }), [selection.selection, listNav.highlightedId, isOpen, searchQuery, options]); + }), [selection, highlightedId, isOpen, searchQuery, options]) const computedState: UseSelectComputedState = useMemo(() => ({ visibleOptionIds, - }), [visibleOptionIds]); + }), [visibleOptionIds]) const highlightItem = useCallback((value: string) => { - if (!enabledOptions.some((o) => o.id === value)) return; - listNav.highlight(value); - }, [enabledOptions, listNav]); + if (!enabledOptions.some((o) => o.id === value)) return + listNavHighlight(value) + }, [enabledOptions, listNavHighlight]) const setIsOpenWrapper = useCallback((isOpen: boolean, behavior?: UseSelectFirstHighlightBehavior) => { - behavior = behavior ?? "first"; + behavior = behavior ?? 'first' if(isOpen) { - if(selection.selection == null) { - if(behavior === "first") { - listNav.first(); - } else if (behavior === "last") { - listNav.last(); + if(selection == null) { + if(behavior === 'first') { + listNavFirst() + } else if (behavior === 'last') { + listNavLast() } } else { - highlightItem(selection.selection); + highlightItem(selection) } } else { - setSearchQuery(""); - onCloseStable?.(); + setSearchQuery('') + onCloseStable?.() } - setIsOpen(isOpen); - onIsOpenChangeStable(isOpen); - }, [setIsOpen, highlightItem, onCloseStable, onEditCompleteStable, selection.selection, onIsOpenChangeStable]); + setIsOpen(isOpen) + onIsOpenChangeStable(isOpen) + }, [setIsOpen, highlightItem, onCloseStable, setSearchQuery, onIsOpenChangeStable, selection, listNavFirst, listNavLast]) const toggleOpenWrapper = useCallback((behavior?: UseSelectFirstHighlightBehavior) => { - const next = !isOpen; - setIsOpenWrapper(next, behavior); - }, [setIsOpenWrapper]); + const next = !isOpen + setIsOpenWrapper(next, behavior) + }, [isOpen, setIsOpenWrapper]) const actions: UseSelectActions = useMemo(() => ({ - selectValue: (id: string) => selection.selectValue(id), + selectValue: (id: string) => selectValue(id), setIsOpen: setIsOpenWrapper, toggleOpen: toggleOpenWrapper, setSearchQuery, - highlightFirst: listNav.first, - highlightLast: listNav.last, - highlightNext: listNav.next, - highlightPrevious: listNav.previous, + highlightFirst: listNavFirst, + highlightLast: listNavLast, + highlightNext: listNavNext, + highlightPrevious: listNavPrevious, highlightItem, - handleTypeaheadKey: typeAhead.addToTypeAhead, - }), [setIsOpenWrapper, listNav.first, listNav.last, listNav.next, listNav.previous, highlightItem, typeAhead.addToTypeAhead]); + handleTypeaheadKey: addToTypeAhead, + }), [selectValue, setIsOpenWrapper, listNavFirst, listNavLast, listNavNext, listNavPrevious, highlightItem, addToTypeAhead, toggleOpenWrapper]) return useMemo(() => ({ ...state, ...computedState, ...actions, - }), [state, computedState, actions]); + }), [state, computedState, actions]) } diff --git a/src/components/user-interaction/data/FilterList.tsx b/src/components/user-interaction/data/FilterList.tsx index 271670b..ee5b938 100644 --- a/src/components/user-interaction/data/FilterList.tsx +++ b/src/components/user-interaction/data/FilterList.tsx @@ -117,7 +117,7 @@ export const FilterList = ({ value, onValueChange, availableItems }: FilterListP isOpen={editState?.id === filterValue.id} onIsOpenChange={isOpen => { if (!isOpen) { - const isEditStateValid = editState ? FilterValueUtils.isValid(editState) : false; + const isEditStateValid = editState ? FilterValueUtils.isValid(editState) : false if(isEditStateValid) { onValueChange(valueWithEditState.map(prevItem => prevItem.id === filterValue.id ? { ...prevItem, ...editState } : prevItem)) } diff --git a/src/components/user-interaction/data/FilterPopUp.tsx b/src/components/user-interaction/data/FilterPopUp.tsx index 225ea00..0bf4e3c 100644 --- a/src/components/user-interaction/data/FilterPopUp.tsx +++ b/src/components/user-interaction/data/FilterPopUp.tsx @@ -53,7 +53,7 @@ export const FilterBasePopUp = forwardRef( const translation = useHightideTranslation() return ( - void; - first: () => void; - last: () => void; - next: () => void; - previous: () => void; + highlightedId: string | null, + highlight: (id: string) => void, + first: () => void, + last: () => void, + next: () => void, + previous: () => void, } export interface ListNavigationOptions { - options: ReadonlyArray; - value?: string | null; - onValueChange?: (highlightedId: string | null) => void; - initialValue?: string | null; + options: ReadonlyArray, + value?: string | null, + onValueChange?: (highlightedId: string | null) => void, + initialValue?: string | null, } export function useListNavigation({ @@ -27,47 +27,47 @@ export function useListNavigation({ value, onValueChange, defaultValue: initialValue, - }); + }) const resolvedHighlightId = useMemo(() => { - if (options.length === 0) return null; + if (options.length === 0) return null if (highlightedId != null && options.includes(highlightedId)) { - return highlightedId; + return highlightedId } - return options[0] ?? null; - }, [options, highlightedId]); + return options[0] ?? null + }, [options, highlightedId]) const highlight = useCallback((id: string) => { - if (!options.includes(id)) return; - setHighlightedId(id); + if (!options.includes(id)) return + setHighlightedId(id) }, [options, setHighlightedId]) const next = useCallback(() => { - if (options.length <= 1 || resolvedHighlightId === null) return; - const idx = options.indexOf(resolvedHighlightId); - const nextIdx = idx < 0 ? 0 : (idx + 1) % options.length; - setHighlightedId(options[nextIdx]); - }, [options, resolvedHighlightId, setHighlightedId]); + if (options.length <= 1 || resolvedHighlightId === null) return + const idx = options.indexOf(resolvedHighlightId) + const nextIdx = idx < 0 ? 0 : (idx + 1) % options.length + setHighlightedId(options[nextIdx]) + }, [options, resolvedHighlightId, setHighlightedId]) const previous = useCallback(() => { - if (options.length <= 1 || resolvedHighlightId === null) return; - const idx = options.indexOf(resolvedHighlightId); + if (options.length <= 1 || resolvedHighlightId === null) return + const idx = options.indexOf(resolvedHighlightId) const previousIdx = idx <= 0 - ? options.length - 1 - : (idx - 1 + options.length) % options.length; - setHighlightedId(options[previousIdx]); - }, [options, resolvedHighlightId, setHighlightedId]); + ? options.length - 1 + : (idx - 1 + options.length) % options.length + setHighlightedId(options[previousIdx]) + }, [options, resolvedHighlightId, setHighlightedId]) const first = useCallback(() => { - if (options.length <= 1 || resolvedHighlightId === null) return; - setHighlightedId(options[0]); - }, [options, resolvedHighlightId, setHighlightedId]); + if (options.length <= 1 || resolvedHighlightId === null) return + setHighlightedId(options[0]) + }, [options, resolvedHighlightId, setHighlightedId]) const last = useCallback(() => { - if (options.length <= 1 || resolvedHighlightId === null) return; - setHighlightedId(options[options.length - 1]); - }, [options, resolvedHighlightId, setHighlightedId]); + if (options.length <= 1 || resolvedHighlightId === null) return + setHighlightedId(options[options.length - 1]) + }, [options, resolvedHighlightId, setHighlightedId]) return useMemo((): ListNavigationReturn => ({ highlightedId: resolvedHighlightId, @@ -76,5 +76,5 @@ export function useListNavigation({ last, next, previous, - }), [resolvedHighlightId, highlight, first, last, next, previous]); + }), [resolvedHighlightId, highlight, first, last, next, previous]) } diff --git a/src/hooks/useMultiSelection.ts b/src/hooks/useMultiSelection.ts index d47cf04..36af1d5 100644 --- a/src/hooks/useMultiSelection.ts +++ b/src/hooks/useMultiSelection.ts @@ -1,85 +1,64 @@ -import { useControlledState } from "@/src/hooks/useControlledState"; -import { useCallback, useMemo } from "react"; +import { useControlledState } from '@/src/hooks/useControlledState' +import { useCallback, useMemo } from 'react' export interface UseMultiSelectionOption { - id: string; - disabled?: boolean; + id: string, + disabled?: boolean, } -export interface UseMultiSelectionOptions { - options: ReadonlyArray; - value?: ReadonlyArray; - onSelectionChange: (selection: ReadonlyArray) => void; - initialSelection?: ReadonlyArray; - isControlled?: boolean; - compareOptions?: (a: T, b: T) => boolean; +export interface UseMultiSelectionOptions { + options: ReadonlyArray, + value?: ReadonlyArray, + onSelectionChange?: (selection: ReadonlyArray) => void, + initialSelection?: ReadonlyArray, + isControlled?: boolean, } -export interface MultiSelectionReturn { - selection: ReadonlyArray; - selectedOptions: ReadonlyArray; - options: ReadonlyArray; - setSelection: (selection: ReadonlyArray) => void; - toggleSelection: (value: T) => void; - isSelected: (value: T) => boolean; +export interface UseMultiSelectionReturn { + selection: ReadonlyArray, + setSelection: (selection: ReadonlyArray) => void, + toggleSelection: (id: string) => void, + isSelected: (id: string) => boolean, } -export function useMultiSelection({ +export function useMultiSelection({ options: optionsList, value, onSelectionChange, initialSelection = [], isControlled, - compareOptions, -}: UseMultiSelectionOptions): MultiSelectionReturn { +}: UseMultiSelectionOptions): UseMultiSelectionReturn { const [selection, setSelection] = useControlledState({ - value: value as T[] | undefined, - onValueChange: onSelectionChange as (v: T[]) => void, + value, + onValueChange: onSelectionChange, defaultValue: [...initialSelection], isControlled, - }); + }) - const compare = useMemo(() => compareOptions ?? Object.is, [compareOptions]); + const isSelected = useCallback((id: string) => selection.includes(id), [selection]) - const selectedOptions = useMemo(() => selection - .map((s) => optionsList.find((o) => compare(o.id, s))) - .filter((o): o is UseMultiSelectionOption => o != null) - , [selection, optionsList, compare]); - - const isSelected = useCallback( - (value: T) => selection.some((s) => compare(s, value)), - [selection, compare] - ); - - const toggleSelection = useCallback((value: T) => { - const option = optionsList.find((o) => compare(o.id, value)); - if (!option || option.disabled) return; - setSelection((prev) => prev.some((s) => compare(s, value)) - ? prev.filter((s) => !compare(s, value)) - : [...prev, value]); - }, [optionsList, compare, setSelection]); + const toggleSelection = useCallback( + (id: string) => { + const option = optionsList.find((o) => o.id === id) + if (!option || option.disabled) return + setSelection((prev) => + prev.includes(id) ? prev.filter((s) => s !== id) : [...prev, id]) + }, + [optionsList, setSelection] + ) const setSelectionValue = useCallback( - (next: ReadonlyArray) => setSelection(Array.from(next)), + (next: ReadonlyArray) => setSelection(Array.from(next)), [setSelection] - ); + ) return useMemo( () => ({ selection, - selectedOptions, - options: optionsList, setSelection: setSelectionValue, toggleSelection, isSelected, }), - [ - selection, - selectedOptions, - optionsList, - setSelectionValue, - toggleSelection, - isSelected, - ] - ); + [selection, setSelectionValue, toggleSelection, isSelected] + ) } diff --git a/src/hooks/useSearch.ts b/src/hooks/useSearch.ts index 8548f97..9047e04 100644 --- a/src/hooks/useSearch.ts +++ b/src/hooks/useSearch.ts @@ -1,19 +1,19 @@ -import { useMemo } from "react"; -import { MultiSearchWithMapping } from "@/src/utils/simpleSearch"; -import { useEventCallbackStabilizer } from "@/src/hooks/useEventCallbackStabelizer"; +import { useMemo } from 'react' +import { MultiSearchWithMapping } from '@/src/utils/simpleSearch' +import { useEventCallbackStabilizer } from '@/src/hooks/useEventCallbackStabelizer' export interface UseSearchOptions { - items: ReadonlyArray; - searchQuery: string; - toTags?: (value: T) => string[]; + items: ReadonlyArray, + searchQuery: string, + toTags?: (value: T) => string[], } export interface UseSearchReturn { - searchResult: ReadonlyArray; + searchResult: ReadonlyArray, } function defaultToTags(value: T): string[] { - return [String(value)]; + return [String(value)] } export function useSearch({ @@ -21,16 +21,16 @@ export function useSearch({ searchQuery, toTags, }: UseSearchOptions): UseSearchReturn { - const toTagsResolved = toTags ?? defaultToTags; - const toTagsStable = useEventCallbackStabilizer(toTagsResolved); + const toTagsResolved = toTags ?? defaultToTags + const toTagsStable = useEventCallbackStabilizer(toTagsResolved) const searchResult = useMemo(() => { - const q = searchQuery.trim().toLowerCase(); - if (!q) return items; - return MultiSearchWithMapping(searchQuery, [...items], (item) => toTagsStable(item)); - }, [items, searchQuery, toTags, toTagsStable]); + const q = searchQuery.trim().toLowerCase() + if (!q) return items + return MultiSearchWithMapping(searchQuery, [...items], (item) => toTagsStable(item)) + }, [items, searchQuery, toTagsStable]) return useMemo((): UseSearchReturn => ({ searchResult, - }), [searchResult]); + }), [searchResult]) } diff --git a/src/hooks/useSingleSelection.ts b/src/hooks/useSingleSelection.ts index 78eaed2..293dd10 100644 --- a/src/hooks/useSingleSelection.ts +++ b/src/hooks/useSingleSelection.ts @@ -1,28 +1,28 @@ -import { useCallback, useMemo } from "react"; -import { useControlledState } from "@/src/hooks/useControlledState"; +import { useCallback, useMemo } from 'react' +import { useControlledState } from '@/src/hooks/useControlledState' export interface SelectionOption { - id: string; - disabled?: boolean; + id: string, + disabled?: boolean, } export interface UseSingleSelectionOptions { - options: ReadonlyArray; - selection?: string | null; - onSelectionChange?: (selection: string | null) => void; - initialSelection?: string | null; - isLooping?: boolean; + options: ReadonlyArray, + selection?: string | null, + onSelectionChange?: (selection: string | null) => void, + initialSelection?: string | null, + isLooping?: boolean, } export interface SingleSelectionReturn { - selection: string | null; - selectedIndex: number | null; - selectByIndex: (index: number) => void; - selectValue: (value: string | null) => void; - selectFirst: () => void; - selectLast: () => void; - selectNext: () => void; - selectPrevious: () => void; + selection: string | null, + selectedIndex: number | null, + selectByIndex: (index: number) => void, + selectValue: (value: string | null) => void, + selectFirst: () => void, + selectLast: () => void, + selectNext: () => void, + selectPrevious: () => void, } export function useSingleSelection({ @@ -36,59 +36,59 @@ export function useSingleSelection({ value: controlledSelection, onValueChange: onSelectionChange, defaultValue: initialSelection, - }); + }) const selectedIndex = useMemo(() => { - return optionsList.findIndex((o) => o.id === selection); - }, [optionsList, selection]); + return optionsList.findIndex((o) => o.id === selection) + }, [optionsList, selection]) - const enabledOptions = useMemo(() => optionsList.filter((o) => !o.disabled), [optionsList]); + const enabledOptions = useMemo(() => optionsList.filter((o) => !o.disabled), [optionsList]) const changeSelection = useCallback((next: string | null) => { - const option = enabledOptions.find((o) => o.id === next); + const option = enabledOptions.find((o) => o.id === next) if(!option && next != null) { - console.warn(`Attempted to select an option ${next} that is not valid or disabled`); - return; + console.warn(`Attempted to select an option ${next} that is not valid or disabled`) + return } - setSelection(option?.id ?? null); - }, [enabledOptions, setSelection]); + setSelection(option?.id ?? null) + }, [enabledOptions, setSelection]) const selectByIndex = useCallback((index: number) => { - const option = optionsList[index]; + const option = optionsList[index] if(!option || option.disabled || index < 0 || index >= optionsList.length) { - console.warn(`Attempted to select an index ${index} that is not valid or disabled`); - return; + console.warn(`Attempted to select an index ${index} that is not valid or disabled`) + return } - setSelection(option.id); - }, [optionsList, setSelection]); + setSelection(option.id) + }, [optionsList, setSelection]) const selectFirst = useCallback(() => { - if(enabledOptions.length === 0) return; - const first = enabledOptions.find((o) => !o.disabled); - setSelection(first?.id ?? null); - }, [enabledOptions, setSelection]); + if(enabledOptions.length === 0) return + const first = enabledOptions.find((o) => !o.disabled) + setSelection(first?.id ?? null) + }, [enabledOptions, setSelection]) const selectLast = useCallback(() => { - if(enabledOptions.length === 0) return; - const last = [...enabledOptions].reverse().find((o) => !o.disabled); - setSelection(last?.id ?? null); - }, [enabledOptions, setSelection]); + if(enabledOptions.length === 0) return + const last = [...enabledOptions].reverse().find((o) => !o.disabled) + setSelection(last?.id ?? null) + }, [enabledOptions, setSelection]) const selectNext = useCallback(() => { - if(enabledOptions.length === 0) return; - let currentIndex = enabledOptions.findIndex((o) => o.id === selection); - if(currentIndex === -1) currentIndex = 0; - const nextIndex = isLooping ? (currentIndex + 1) % enabledOptions.length : Math.min(currentIndex + 1, enabledOptions.length - 1); - setSelection(enabledOptions[nextIndex].id); - }, [enabledOptions, selection, isLooping, setSelection]); + if(enabledOptions.length === 0) return + let currentIndex = enabledOptions.findIndex((o) => o.id === selection) + if(currentIndex === -1) currentIndex = 0 + const nextIndex = isLooping ? (currentIndex + 1) % enabledOptions.length : Math.min(currentIndex + 1, enabledOptions.length - 1) + setSelection(enabledOptions[nextIndex].id) + }, [enabledOptions, selection, isLooping, setSelection]) const selectPrevious = useCallback(() => { - if(enabledOptions.length === 0) return; - let currentIndex = enabledOptions.findIndex((o) => o.id === selection); - if(currentIndex === -1) currentIndex = enabledOptions.length; - const previousIndex = isLooping ? (currentIndex - 1 + enabledOptions.length) % enabledOptions.length : Math.max(currentIndex - 1, 0); - setSelection(enabledOptions[previousIndex].id); - }, [enabledOptions, selection, isLooping, setSelection]); + if(enabledOptions.length === 0) return + let currentIndex = enabledOptions.findIndex((o) => o.id === selection) + if(currentIndex === -1) currentIndex = enabledOptions.length + const previousIndex = isLooping ? (currentIndex - 1 + enabledOptions.length) % enabledOptions.length : Math.max(currentIndex - 1, 0) + setSelection(enabledOptions[previousIndex].id) + }, [enabledOptions, selection, isLooping, setSelection]) return useMemo(() => ({ selection, @@ -99,5 +99,5 @@ export function useSingleSelection({ selectLast, selectNext, selectPrevious, - }), [selection, selectedIndex, enabledOptions, changeSelection, selectFirst, selectLast, selectNext, selectPrevious]); + }), [selection, selectedIndex, changeSelection, selectFirst, selectLast, selectNext, selectPrevious, selectByIndex]) } diff --git a/src/hooks/useTypeAheadSearch.ts b/src/hooks/useTypeAheadSearch.ts index 4e6dbaf..f2f8644 100644 --- a/src/hooks/useTypeAheadSearch.ts +++ b/src/hooks/useTypeAheadSearch.ts @@ -1,20 +1,20 @@ -import { useCallback, useEffect, useRef } from "react"; -import { useEventCallbackStabilizer } from "@/src/hooks/useEventCallbackStabelizer"; +import { useCallback, useEffect, useRef } from 'react' +import { useEventCallbackStabilizer } from '@/src/hooks/useEventCallbackStabelizer' export interface UseTypeAheadSearchOptions { - options: ReadonlyArray; - resetTimer: number; - toString?: (value: T) => string; - onResultChange: (value: T | null) => void; + options: ReadonlyArray, + resetTimer: number, + toString?: (value: T) => string, + onResultChange: (value: T | null) => void, } export interface UseTypeAheadSearchReturn { - addToTypeAhead: (str: string) => void; - reset: () => void; + addToTypeAhead: (str: string) => void, + reset: () => void, } function defaultToString(value: T): string { - return String(value); + return String(value) } export function useTypeAheadSearch({ @@ -23,46 +23,46 @@ export function useTypeAheadSearch({ toString: toStringProp, onResultChange, }: UseTypeAheadSearchOptions): UseTypeAheadSearchReturn { - const bufferRef = useRef(""); - const timeoutRef = useRef | null>(null); + const bufferRef = useRef('') + const timeoutRef = useRef | null>(null) - const toString = toStringProp ?? defaultToString; - const toStringStable = useEventCallbackStabilizer(toString); - const onResultChangeStable = useEventCallbackStabilizer(onResultChange); + const toString = toStringProp ?? defaultToString + const toStringStable = useEventCallbackStabilizer(toString) + const onResultChangeStable = useEventCallbackStabilizer(onResultChange) const reset = useCallback(() => { if (timeoutRef.current) { - clearTimeout(timeoutRef.current); - timeoutRef.current = null; + clearTimeout(timeoutRef.current) + timeoutRef.current = null } - bufferRef.current = ""; - onResultChangeStable(null); - }, [onResultChangeStable]); + bufferRef.current = '' + onResultChangeStable(null) + }, [onResultChangeStable]) useEffect(() => () => { - if (timeoutRef.current) clearTimeout(timeoutRef.current); - }, []); + if (timeoutRef.current) clearTimeout(timeoutRef.current) + }, []) const addToTypeAhead = useCallback((str: string) => { - if (timeoutRef.current) clearTimeout(timeoutRef.current); - bufferRef.current += str; - timeoutRef.current = setTimeout(() => { - timeoutRef.current = null; - bufferRef.current = ""; - onResultChangeStable(null); - }, resetTimer); + if (timeoutRef.current) clearTimeout(timeoutRef.current) + bufferRef.current += str + timeoutRef.current = setTimeout(() => { + timeoutRef.current = null + bufferRef.current = '' + onResultChangeStable(null) + }, resetTimer) - const buf = bufferRef.current.trim().toLowerCase(); - if (!buf) { - onResultChangeStable(null); - return; - } - const found = options.find((opt) => { - const s = toStringStable(opt)?.trim().toLowerCase() ?? ""; - return s.startsWith(buf); - }); - onResultChangeStable(found ?? null); - }, [options, resetTimer, toStringStable, onResultChangeStable]); + const buf = bufferRef.current.trim().toLowerCase() + if (!buf) { + onResultChangeStable(null) + return + } + const found = options.find((opt) => { + const s = toStringStable(opt)?.trim().toLowerCase() ?? '' + return s.startsWith(buf) + }) + onResultChangeStable(found ?? null) + }, [options, resetTimer, toStringStable, onResultChangeStable]) - return { addToTypeAhead, reset }; + return { addToTypeAhead, reset } } diff --git a/stories/Layout/Table/FilterListTable.stories.tsx b/stories/Layout/Table/FilterListTable.stories.tsx index 0a208f2..89a131c 100644 --- a/stories/Layout/Table/FilterListTable.stories.tsx +++ b/stories/Layout/Table/FilterListTable.stories.tsx @@ -38,7 +38,7 @@ const AgeFilterPopUp = ({ value, onValueChange, onRemove, name }: FilterListPopU range: `number-filter-range-${id}`, compareValue: `number-filter-compare-value-${id}`, } - + const operator = useMemo(() => { const suggestion = value?.operator ?? 'between' if (!FilterOperatorUtils.typeCheck.number(suggestion)) { diff --git a/stories/User Interaction/Combobox.stories.tsx b/stories/User Interaction/Combobox.stories.tsx index 42761fd..8ee7ca6 100644 --- a/stories/User Interaction/Combobox.stories.tsx +++ b/stories/User Interaction/Combobox.stories.tsx @@ -1,28 +1,28 @@ -import type { Meta, StoryObj } from "@storybook/nextjs"; -import { action } from "storybook/actions"; -import { Combobox } from "@/src/components/user-interaction/Combobox/Combobox"; -import { ComboboxOption } from "@/src/components/user-interaction/Combobox/ComboboxOption"; +import type { Meta, StoryObj } from '@storybook/nextjs' +import { action } from 'storybook/actions' +import { Combobox } from '@/src/components/user-interaction/Combobox/Combobox' +import { ComboboxOption } from '@/src/components/user-interaction/Combobox/ComboboxOption' const options = [ - { value: "apple", label: "Apple" }, - { value: "banana", label: "Banana" }, - { value: "blueberry", label: "Blueberry" }, - { value: "cherry", label: "Cherry" }, - { value: "grape", label: "Grape" }, - { value: "kiwi", label: "Kiwi" }, - { value: "mango", label: "Mango" }, - { value: "orange", label: "Orange" }, - { value: "papaya", label: "Papaya" }, - { value: "pineapple", label: "Pineapple" }, - { value: "strawberry", label: "Strawberry" }, - { value: "watermelon", label: "Watermelon" }, -]; + { value: 'apple', label: 'Apple' }, + { value: 'banana', label: 'Banana' }, + { value: 'blueberry', label: 'Blueberry' }, + { value: 'cherry', label: 'Cherry' }, + { value: 'grape', label: 'Grape' }, + { value: 'kiwi', label: 'Kiwi' }, + { value: 'mango', label: 'Mango' }, + { value: 'orange', label: 'Orange' }, + { value: 'papaya', label: 'Papaya' }, + { value: 'pineapple', label: 'Pineapple' }, + { value: 'strawberry', label: 'Strawberry' }, + { value: 'watermelon', label: 'Watermelon' }, +] const meta: Meta = { component: Combobox, -}; +} -export default meta; +export default meta type Story = StoryObj; export const combobox: Story = { @@ -33,11 +33,11 @@ export const combobox: Story = { {label} )), - onItemClick: action("onItemClick"), + onItemClick: action('onItemClick'), }, render: (args) => (
      ), -}; +} diff --git a/stories/User Interaction/MultiSelect/MultiSelect.stories.tsx b/stories/User Interaction/MultiSelect/MultiSelect.stories.tsx new file mode 100644 index 0000000..4014a80 --- /dev/null +++ b/stories/User Interaction/MultiSelect/MultiSelect.stories.tsx @@ -0,0 +1,127 @@ +import { action } from 'storybook/actions' +import type { Meta, StoryObj } from '@storybook/nextjs' +import { useEffect, useState } from 'react' +import { MultiSelect } from '@/src/components/user-interaction/MultiSelect/MultiSelect' +import { MultiSelectOption } from '@/src/components/user-interaction/MultiSelect/MultiSelectOption' + +const meta: Meta = { + component: MultiSelect, +} + +export default meta +type Story = StoryObj; + +const fruitOptions = [ + { value: 'Apple', label: 'Apple' }, + { value: 'Banana', label: 'Banana', disabled: true }, + { value: 'Cherry', label: 'Cherry' }, + { value: 'Dragonfruit', label: 'Dragonfruit', className: '!text-red-400' }, + { value: 'Elderberry', label: 'Elderberry' }, + { value: 'Fig', label: 'Fig' }, + { value: 'Grapefruit', label: 'Grapefruit' }, + { value: 'Honeydew', label: 'Honeydew' }, + { value: 'Indianfig', label: 'Indianfig' }, + { value: 'Jackfruit', label: 'Jackfruit' }, + { value: 'Kiwifruit', label: 'Kiwifruit' }, + { value: 'Lemon', label: 'Lemon', disabled: true } +].sort((a, b) => a.value.localeCompare(b.value)) + +export const multiSelect: Story = { + args: { + initialValue: ['Apple', 'Cherry'], + disabled: false, + invalid: false, + showSearch: true, + readOnly: false, + required: false, + onValueChange: action('onValueChange'), + onEditComplete: action('onEditComplete'), + children: fruitOptions.map((item, index) => ( + + )), + }, +} + +export interface User { + uuid: string + name: string + email: string +} + +const users: User[] = [ + { uuid: '1', name: 'Alice Chen', email: 'alice@example.com' }, + { uuid: '2', name: 'Bob Smith', email: 'bob@example.com' }, + { uuid: '3', name: 'Carol Jones', email: 'carol@example.com' }, + { uuid: '4', name: 'David Lee', email: 'david@example.com' }, + { uuid: '5', name: 'Eve Wilson', email: 'eve@example.com' }, +] + +function compareUser(a: User, b: User): boolean { + return a.uuid === b.uuid +} + +export const multiSelectWithUser: Story = { + args: { + value: undefined, + initialValue: undefined, + disabled: false, + invalid: false, + showSearch: true, + readOnly: false, + required: false, + compareFunction: compareUser, + onValueChange: action('onValueChange'), + onEditComplete: action('onEditComplete'), + buttonProps: { + placeholder: 'Select users', + selectedDisplay: (values: User[]) => ( +
      + {values.map((user) => ( +
      + {user.name} + {user.email} +
      + ))} +
      + ), + }, + children: users.map((user) => ( + +
      + {user.name} + {user.email} +
      +
      + )), + }, + render: (args) => { + const [value, setValue] = useState(args.value as User[] | undefined ?? []) + + useEffect(() => { + setValue(args.value as User[] | undefined ?? []) + }, [args.value]) + + const initialValue = args.initialValue as User[] | undefined ?? [] + + return ( + + {...args} + initialValue={initialValue} + value={value} + onValueChange={(v) => { + args.onValueChange?.(v) + setValue(v) + }} + onEditComplete={(v) => { + args.onEditComplete?.(v) + setValue(v) + }} + /> + ) + }, +} diff --git a/stories/User Interaction/Select/MultiSelectChipDisplay.stories.tsx b/stories/User Interaction/MultiSelect/MultiSelectChipDisplay.stories.tsx similarity index 100% rename from stories/User Interaction/Select/MultiSelectChipDisplay.stories.tsx rename to stories/User Interaction/MultiSelect/MultiSelectChipDisplay.stories.tsx diff --git a/stories/User Interaction/Select/MultiSelect.stories.tsx b/stories/User Interaction/Select/MultiSelect.stories.tsx deleted file mode 100644 index 7edff10..0000000 --- a/stories/User Interaction/Select/MultiSelect.stories.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { action } from 'storybook/actions' -import type { Meta, StoryObj } from '@storybook/nextjs' -import { MultiSelect } from '@/src/components/user-interaction/MultiSelect/MultiSelect' -import { MultiSelectOption } from '@/src/components/user-interaction/MultiSelect/MultiSelectOption' - -const meta = { - component: MultiSelect, -} satisfies Meta - -export default meta -type Story = StoryObj; - -export const multiSelect: Story = { - args: { - initialValue: ['Apple', 'Cherry'], - disabled: false, - invalid: false, - showSearch: true, - readOnly: false, - required: false, - onValueChange: action('onValueChange'), - onEditComplete: action('onEditComplete'), - children: [ - { value: 'Apple', label: 'Apple' }, - { value: 'Banana', label: 'Banana', disabled: true }, - { value: 'Cherry', label: 'Cherry' }, - { value: 'Dragonfruit', label: 'Dragonfruit', className: '!text-red-400' }, - { value: 'Elderberry', label: 'Elderberry' }, - { value: 'Fig', label: 'Fig' }, - { value: 'Grapefruit', label: 'Grapefruit' }, - { value: 'Honeydew', label: 'Honeydew' }, - { value: 'Indianfig', label: 'Indianfig' }, - { value: 'Jackfruit', label: 'Jackfruit' }, - { value: 'Kiwifruit', label: 'Kiwifruit' }, - { value: 'Lemon', label: 'Lemon', disabled: true } - ].sort((a, b) => a.value.localeCompare(b.value)) - .map((item, index) => ()), - }, -} diff --git a/stories/User Interaction/Select/Select.stories.tsx b/stories/User Interaction/Select/Select.stories.tsx index 8441d51..b7ad05d 100644 --- a/stories/User Interaction/Select/Select.stories.tsx +++ b/stories/User Interaction/Select/Select.stories.tsx @@ -1,15 +1,32 @@ import type { Meta, StoryObj } from '@storybook/nextjs' import { action } from 'storybook/actions' +import { useEffect, useState } from 'react' import { Select } from '@/src/components/user-interaction/Select/Select' import { SelectOption } from '@/src/components/user-interaction/Select/SelectOption' -const meta = { +const meta: Meta = { component: Select, -} satisfies Meta +} export default meta type Story = StoryObj; +const fruitOptions = [ + { value: 'Apple', label: 'Apple' }, + { value: 'Pear', label: 'Pear', disabled: true }, + { value: 'Strawberry', label: 'Strawberry' }, + { value: 'Pineapple', label: 'Pineapple' }, + { value: 'Blackberry', label: 'Blackberry' }, + { value: 'Blueberry', label: 'Blueberry', disabled: true }, + { value: 'Banana', label: 'Banana' }, + { value: 'Kiwi', label: 'Kiwi', disabled: true }, + { value: 'Maracuja', label: 'Maracuja', disabled: true }, + { value: 'Wildberry', label: 'Wildberry', disabled: true }, + { value: 'Watermelon', label: 'Watermelon' }, + { value: 'Honeymelon', label: 'Honeymelon' }, + { value: 'Papja', label: 'Papja' } +].sort((a, b) => a.value.localeCompare(b.value)) + export const select: Story = { args: { initialValue: undefined, @@ -20,21 +37,88 @@ export const select: Story = { required: false, onValueChange: action('onValueChange'), onEditComplete: action('onEditComplete'), - children: [ - { value: 'Apple', label: 'Apple' }, - { value: 'Pear', label: 'Pear', disabled: true }, - { value: 'Strawberry', label: 'Strawberry' }, - { value: 'Pineapple', label: 'Pineapple' }, - { value: 'Blackberry', label: 'Blackberry' }, - { value: 'Blueberry', label: 'Blueberry', disabled: true }, - { value: 'Banana', label: 'Banana' }, - { value: 'Kiwi', label: 'Kiwi', disabled: true }, - { value: 'Maracuja', label: 'Maracuja', disabled: true }, - { value: 'Wildberry', label: 'Wildberry', disabled: true }, - { value: 'Watermelon', label: 'Watermelon' }, - { value: 'Honeymelon', label: 'Honeymelon' }, - { value: 'Papja', label: 'Papja' } - ].sort((a, b) => a.value.localeCompare(b.value)) - .map((item, index) => ()), + children: fruitOptions.map((item, index) => ( + + )), + }, +} + +export interface User { + uuid: string + name: string + email: string +} + +const users: User[] = [ + { uuid: '1', name: 'Alice Chen', email: 'alice@example.com' }, + { uuid: '2', name: 'Bob Smith', email: 'bob@example.com' }, + { uuid: '3', name: 'Carol Jones', email: 'carol@example.com' }, + { uuid: '4', name: 'David Lee', email: 'david@example.com' }, + { uuid: '5', name: 'Eve Wilson', email: 'eve@example.com' }, +] + +function compareUser(a: User | null, b: User | null): boolean { + if (a === null || b === null) return a === b + return a.uuid === b.uuid +} + +export const selectWithUser: Story = { + args: { + value: undefined, + initialValue: undefined, + disabled: false, + invalid: false, + showSearch: true, + readOnly: false, + required: false, + compareFunction: compareUser, + onValueChange: action('onValueChange'), + onEditComplete: action('onEditComplete'), + buttonProps: { + placeholder: 'Select a user', + selectedDisplay: (option) => { + if (!option) return null + const user = option.value as User + return ( +
      + {user.name} + {user.email} +
      + ) + }, + }, + children: users.map((user) => ( + +
      + {user.name} + {user.email} +
      +
      + )), + }, + render: (args) => { + const [value, setValue] = useState(args.value ?? null) + useEffect(() => { + setValue(args.value ?? null) + }, [args.value]) + return ( + + {...args} + value={value} + onValueChange={(v) => { + args.onValueChange?.(v) + setValue(v) + }} + onEditComplete={(v) => { + args.onEditComplete?.(v) + setValue(v) + }} + /> + ) }, } From ef4d54a696c84d8d177424ea0858116440745c48 Mon Sep 17 00:00:00 2001 From: DasProffi <67233923+DasProffi@users.noreply.github.com> Date: Sat, 14 Mar 2026 12:01:46 +0100 Subject: [PATCH 13/13] fix: fix bin path in package.json --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index dcaff71..26ab823 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ "module": "dist/index.js", "types": "dist/index.d.ts", "bin": { - "barrel": ".dist/scripts/barrel.js" + "barrel": "dist/scripts/barrel.js" }, "exports": { ".": { @@ -88,4 +88,4 @@ "overrides": { "elliptic": "^6.6.1" } -} \ No newline at end of file +}