@open-mercato/ui 0.6.8-develop.6925.1.cefa200fa4 → 0.6.8-develop.6930.1.1e5976efc3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +1 -1
- package/dist/backend/CrudForm.js +9 -2
- package/dist/backend/CrudForm.js.map +2 -2
- package/dist/backend/icons/lucideRegistry.generated.js +10 -0
- package/dist/backend/icons/lucideRegistry.generated.js.map +2 -2
- package/dist/backend/inputs/ComboboxInput.js +24 -1
- package/dist/backend/inputs/ComboboxInput.js.map +2 -2
- package/dist/backend/inputs/LookupSelect.js +125 -47
- package/dist/backend/inputs/LookupSelect.js.map +2 -2
- package/package.json +3 -3
- package/src/backend/CrudForm.tsx +9 -2
- package/src/backend/__tests__/CrudForm.render.test.tsx +1 -1
- package/src/backend/__tests__/CrudForm.validation.test.tsx +53 -2
- package/src/backend/__tests__/CrudForm.visibleWhen.test.tsx +108 -0
- package/src/backend/icons/lucideRegistry.generated.tsx +10 -0
- package/src/backend/inputs/ComboboxInput.tsx +29 -2
- package/src/backend/inputs/LookupSelect.tsx +87 -5
- package/src/backend/inputs/__tests__/ComboboxInput.test.tsx +3 -3
- package/src/backend/inputs/__tests__/LookupSelect.test.tsx +77 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/backend/inputs/LookupSelect.tsx"],
|
|
4
|
-
"sourcesContent": ["\"use client\"\n\nimport * as React from 'react'\nimport { Check, Loader2, Search, X } from 'lucide-react'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport { Button } from '../../primitives/button'\nimport { cn } from '@open-mercato/shared/lib/utils'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\n\nconst logger = createLogger('ui').child({ component: 'LookupSelect' })\n\nexport type LookupSelectItem = {\n id: string\n title: string\n subtitle?: string | null\n badge?: string | null\n icon?: React.ReactNode\n disabled?: boolean\n rightLabel?: string | null\n description?: string | null\n}\n\ntype LookupSelectProps = {\n value: string | null\n onChange: (next: string | null) => void\n fetchItems?: (query: string) => Promise<LookupSelectItem[]>\n fetchOptions?: (query?: string) => Promise<LookupSelectItem[]>\n options?: LookupSelectItem[]\n minQuery?: number\n actionSlot?: React.ReactNode\n onReady?: (controls: { setQuery: (value: string) => void }) => void\n searchPlaceholder?: string\n placeholder?: string\n clearLabel?: string\n emptyLabel?: string\n loadingLabel?: string\n selectLabel?: string\n selectedLabel?: string\n minQueryHintLabel?: string\n startTypingLabel?: string\n selectedHintLabel?: (id: string) => string\n disabled?: boolean\n loading?: boolean\n defaultOpen?: boolean\n}\n\nexport function LookupSelect({\n value,\n onChange,\n fetchItems,\n fetchOptions,\n options,\n minQuery = 2,\n actionSlot,\n onReady,\n placeholder,\n searchPlaceholder,\n clearLabel,\n emptyLabel,\n loadingLabel,\n selectLabel,\n selectedLabel,\n minQueryHintLabel,\n startTypingLabel,\n selectedHintLabel,\n disabled = false,\n loading: loadingProp = false,\n defaultOpen = false,\n}: LookupSelectProps) {\n const t = useT()\n const resolvedSearchPlaceholder = searchPlaceholder ?? placeholder ?? t('ui.lookupSelect.searchPlaceholder', 'Search\u2026')\n const resolvedClearLabel = clearLabel ?? t('ui.lookupSelect.clearSelection', 'Clear selection')\n const resolvedEmptyLabel = emptyLabel ?? t('ui.lookupSelect.noResults', 'No results')\n const resolvedLoadingLabel = loadingLabel ?? t('ui.lookupSelect.searching', 'Searching\u2026')\n const resolvedSelectLabel = selectLabel ?? t('ui.lookupSelect.select', 'Select')\n const resolvedSelectedLabel = selectedLabel ?? t('ui.lookupSelect.selected', 'Selected')\n const resolvedStartTypingLabel = startTypingLabel ?? t('ui.lookupSelect.startTyping', 'Start typing to search.')\n const resolvedMinQueryHintLabel = minQueryHintLabel ?? t(\n 'ui.lookupSelect.minQueryHint',\n 'Type at least {minQuery} characters or paste an id to search.',\n { minQuery: String(minQuery) }\n )\n const [query, setQuery] = React.useState('')\n const [items, setItems] = React.useState<LookupSelectItem[]>(options ?? [])\n const [loading, setLoading] = React.useState(false)\n const [hasTyped, setHasTyped] = React.useState(defaultOpen)\n const [error, setError] = React.useState<string | null>(null)\n const [fetchKey, setFetchKey] = React.useState(0)\n const fetchItemsRef = React.useRef(fetchItems ?? fetchOptions)\n const setQueryRef = React.useRef(setQuery)\n const onReadyRef = React.useRef(onReady)\n const optionsWasArrayRef = React.useRef(Array.isArray(options))\n\n React.useEffect(() => {\n fetchItemsRef.current = fetchItems ?? fetchOptions\n }, [fetchItems, fetchOptions])\n\n React.useEffect(() => {\n onReadyRef.current = onReady\n }, [onReady])\n\n React.useEffect(() => {\n if (Array.isArray(options)) {\n optionsWasArrayRef.current = true\n setItems(options)\n } else if (optionsWasArrayRef.current) {\n optionsWasArrayRef.current = false\n setFetchKey((k) => k + 1)\n }\n }, [options])\n\n React.useEffect(() => {\n setQueryRef.current = setQuery\n if (onReadyRef.current) onReadyRef.current({ setQuery })\n }, [setQuery])\n\n const shouldSearch =\n defaultOpen || query.trim().length >= minQuery || Boolean(value && (options?.length ?? 0) > 0)\n React.useEffect(() => {\n if (disabled) {\n setItems(options ?? [])\n setLoading(false)\n return\n }\n let cancelled = false\n let timer: ReturnType<typeof setTimeout> | null = null\n if (!shouldSearch) {\n setItems(options ?? [])\n setLoading(false)\n setError(null)\n return () => { cancelled = true }\n }\n setLoading(true)\n setError(null)\n timer = setTimeout(() => {\n const requestId = Date.now()\n const fetcher = fetchItemsRef.current\n const loader = fetcher ?? (() => Promise.resolve(options ?? []))\n loader(query.trim())\n .then((result) => {\n if (cancelled) return\n setItems(result)\n })\n .catch((err) => {\n if (cancelled) return\n logger.error('Failed to fetch lookup items', { err })\n setError('error')\n })\n .finally(() => {\n if (!cancelled) setLoading(false)\n })\n return requestId\n }, 220)\n return () => {\n cancelled = true\n if (timer) clearTimeout(timer)\n }\n }, [query, shouldSearch, fetchKey])\n\n return (\n <div className=\"space-y-3\">\n <div className=\"flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3\">\n <div className=\"relative flex-1\">\n <Search className=\"pointer-events-none absolute left-3.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground\" />\n <input\n className=\"w-full h-10 rounded-lg border border-input bg-background pl-10 pr-3 text-sm shadow-xs transition-colors outline-none placeholder:text-muted-foreground hover:border-foreground/20 focus-visible:shadow-focus focus-visible:border-brand-violet disabled:bg-bg-disabled disabled:border-border-disabled disabled:text-muted-foreground disabled:cursor-not-allowed\"\n value={query}\n onChange={(event) => {\n setQuery(event.target.value)\n setHasTyped(true)\n }}\n placeholder={resolvedSearchPlaceholder}\n disabled={disabled}\n />\n </div>\n {actionSlot ? <div className=\"sm:self-start\">{actionSlot}</div> : null}\n </div>\n {shouldSearch ? (\n <div className=\"space-y-2\">\n {loading || loadingProp ? (\n <div className=\"flex items-center gap-2 text-sm text-muted-foreground\">\n <Loader2 className=\"h-4 w-4 animate-spin\" />\n {resolvedLoadingLabel}\n </div>\n ) : null}\n {!loading && !loadingProp && !items.length ? (\n <p className=\"text-xs text-muted-foreground\">{resolvedEmptyLabel}</p>\n ) : null}\n <div className=\"flex flex-col gap-1.5 max-h-80 overflow-y-auto -mx-0.5 px-0.5 py-0.5\">\n {items.map((item) => {\n const isSelected = value === item.id\n const isInteractive = !item.disabled || isSelected\n return (\n <div\n key={item.id}\n className={cn(\n 'group flex items-center gap-4 rounded-xl border p-4 transition-all duration-150 focus-visible:outline-none focus-visible:shadow-focus',\n isInteractive ? 'cursor-pointer' : 'cursor-not-allowed opacity-60',\n isSelected\n ? 'border-brand-violet bg-brand-violet/5 shadow-sm'\n : 'border-input bg-card hover:border-foreground/20 hover:bg-muted/30 hover:shadow-sm'\n )}\n role=\"button\"\n tabIndex={item.disabled ? -1 : 0}\n onClick={() => {\n if (!isInteractive) return\n onChange(item.id)\n }}\n onKeyDown={(event) => {\n if (event.key === 'Enter' || event.key === ' ') {\n event.preventDefault()\n if (!isInteractive) return\n onChange(item.id)\n }\n }}\n aria-pressed={isSelected}\n aria-disabled={item.disabled && !isSelected ? true : undefined}\n title={isSelected ? resolvedSelectedLabel : resolvedSelectLabel}\n >\n {item.icon ? (\n <div className=\"flex h-12 w-12 shrink-0 items-center justify-center overflow-hidden [&>svg]:size-6 [&_svg]:text-muted-foreground\">\n {item.icon}\n </div>\n ) : (\n <div className={cn(\n 'flex h-12 w-12 shrink-0 items-center justify-center overflow-hidden rounded-lg border transition-colors',\n isSelected\n ? 'border-brand-violet/40 bg-brand-violet/10 text-brand-violet'\n : 'border-input bg-muted text-muted-foreground group-hover:border-foreground/20'\n )}>\n <span className=\"text-base font-semibold uppercase\">{item.title.slice(0, 1)}</span>\n </div>\n )}\n <div className=\"flex min-w-0 flex-1 flex-col gap-0.5\">\n <div className=\"flex items-center justify-between gap-3\">\n <div className=\"truncate text-sm font-semibold text-foreground\">{item.title}</div>\n {item.rightLabel ? (\n <div className=\"shrink-0 text-overline font-medium uppercase tracking-wider text-muted-foreground\">\n {item.rightLabel}\n </div>\n ) : null}\n </div>\n {item.subtitle ? (\n <div className=\"text-xs text-muted-foreground truncate\">{item.subtitle}</div>\n ) : null}\n {item.description ? (\n <div className=\"text-xs text-muted-foreground/70 truncate\">{item.description}</div>\n ) : null}\n </div>\n <div className=\"flex shrink-0 items-center justify-center\">\n {isSelected ? (\n <Check className=\"size-5 text-brand-violet\" aria-hidden=\"true\" />\n ) : (\n <div className=\"size-5\" aria-hidden=\"true\" />\n )}\n </div>\n </div>\n )\n })}\n </div>\n {value ? (\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"sm\"\n className=\"w-fit gap-1 text-sm font-normal\"\n onClick={() => onChange(null)}\n >\n <X className=\"h-4 w-4\" />\n {resolvedClearLabel}\n </Button>\n ) : null}\n </div>\n ) : hasTyped ? (\n <p className=\"text-xs text-muted-foreground\">\n {resolvedMinQueryHintLabel}\n </p>\n ) : (\n <p className=\"text-xs text-muted-foreground\">{resolvedStartTypingLabel}</p>\n )}\n {error ? <p className=\"text-xs text-status-error-text\" role=\"alert\">{resolvedEmptyLabel}</p> : null}\n </div>\n )\n}\n"],
|
|
5
|
-
"mappings": ";
|
|
4
|
+
"sourcesContent": ["\"use client\"\n\nimport * as React from 'react'\nimport { Check, Loader2, Search, X } from 'lucide-react'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport { Button } from '../../primitives/button'\nimport { cn } from '@open-mercato/shared/lib/utils'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\n\nconst logger = createLogger('ui').child({ component: 'LookupSelect' })\n\nexport type LookupSelectItem = {\n id: string\n title: string\n subtitle?: string | null\n badge?: string | null\n icon?: React.ReactNode\n disabled?: boolean\n rightLabel?: string | null\n description?: string | null\n}\n\ntype LookupSelectProps = {\n value: string | null\n onChange: (next: string | null) => void\n fetchItems?: (query: string) => Promise<LookupSelectItem[]>\n fetchOptions?: (query?: string) => Promise<LookupSelectItem[]>\n options?: LookupSelectItem[]\n minQuery?: number\n actionSlot?: React.ReactNode\n onReady?: (controls: { setQuery: (value: string) => void }) => void\n searchPlaceholder?: string\n placeholder?: string\n clearLabel?: string\n emptyLabel?: string\n loadingLabel?: string\n selectLabel?: string\n selectedLabel?: string\n minQueryHintLabel?: string\n startTypingLabel?: string\n selectedHintLabel?: (id: string) => string\n disabled?: boolean\n loading?: boolean\n defaultOpen?: boolean\n}\n\nexport function LookupSelect({\n value,\n onChange,\n fetchItems,\n fetchOptions,\n options,\n minQuery = 2,\n actionSlot,\n onReady,\n placeholder,\n searchPlaceholder,\n clearLabel,\n emptyLabel,\n loadingLabel,\n selectLabel,\n selectedLabel,\n minQueryHintLabel,\n startTypingLabel,\n selectedHintLabel,\n disabled = false,\n loading: loadingProp = false,\n defaultOpen = false,\n}: LookupSelectProps) {\n const t = useT()\n const resolvedSearchPlaceholder = searchPlaceholder ?? placeholder ?? t('ui.lookupSelect.searchPlaceholder', 'Search\u2026')\n const resolvedClearLabel = clearLabel ?? t('ui.lookupSelect.clearSelection', 'Clear selection')\n const resolvedEmptyLabel = emptyLabel ?? t('ui.lookupSelect.noResults', 'No results')\n const resolvedLoadingLabel = loadingLabel ?? t('ui.lookupSelect.searching', 'Searching\u2026')\n const resolvedSelectLabel = selectLabel ?? t('ui.lookupSelect.select', 'Select')\n const resolvedSelectedLabel = selectedLabel ?? t('ui.lookupSelect.selected', 'Selected')\n const resolvedStartTypingLabel = startTypingLabel ?? t('ui.lookupSelect.startTyping', 'Start typing to search.')\n const resolvedMinQueryHintLabel = minQueryHintLabel ?? t(\n 'ui.lookupSelect.minQueryHint',\n 'Type at least {minQuery} characters or paste an id to search.',\n { minQuery: String(minQuery) }\n )\n const [query, setQuery] = React.useState('')\n const [items, setItems] = React.useState<LookupSelectItem[]>(options ?? [])\n const [loading, setLoading] = React.useState(false)\n const [hasTyped, setHasTyped] = React.useState(defaultOpen)\n const [error, setError] = React.useState<string | null>(null)\n const [fetchKey, setFetchKey] = React.useState(0)\n const [activeIndex, setActiveIndex] = React.useState(-1)\n const listboxId = React.useId()\n const fetchItemsRef = React.useRef(fetchItems ?? fetchOptions)\n const setQueryRef = React.useRef(setQuery)\n const onReadyRef = React.useRef(onReady)\n const optionsWasArrayRef = React.useRef(Array.isArray(options))\n\n React.useEffect(() => {\n fetchItemsRef.current = fetchItems ?? fetchOptions\n }, [fetchItems, fetchOptions])\n\n React.useEffect(() => {\n onReadyRef.current = onReady\n }, [onReady])\n\n React.useEffect(() => {\n if (Array.isArray(options)) {\n optionsWasArrayRef.current = true\n setItems(options)\n } else if (optionsWasArrayRef.current) {\n optionsWasArrayRef.current = false\n setFetchKey((k) => k + 1)\n }\n }, [options])\n\n React.useEffect(() => {\n setQueryRef.current = setQuery\n if (onReadyRef.current) onReadyRef.current({ setQuery })\n }, [setQuery])\n\n const shouldSearch =\n defaultOpen || query.trim().length >= minQuery || Boolean(value && (options?.length ?? 0) > 0)\n\n React.useEffect(() => {\n setActiveIndex(-1)\n }, [items])\n\n const optionDomId = React.useCallback(\n (index: number) => `${listboxId}-option-${index}`,\n [listboxId],\n )\n\n const isInteractiveItem = React.useCallback(\n (item: LookupSelectItem) => !item.disabled || value === item.id,\n [value],\n )\n\n const moveActiveIndex = React.useCallback((direction: 1 | -1) => {\n setActiveIndex((current) => {\n if (!items.length) return -1\n let next = current\n for (let step = 0; step < items.length; step += 1) {\n next = (next + direction + items.length) % items.length\n if (isInteractiveItem(items[next])) return next\n }\n return current\n })\n }, [isInteractiveItem, items])\n\n React.useEffect(() => {\n if (activeIndex < 0) return\n const activeElement = typeof document !== 'undefined'\n ? document.getElementById(optionDomId(activeIndex))\n : null\n if (typeof activeElement?.scrollIntoView === 'function') {\n activeElement.scrollIntoView({ block: 'nearest' })\n }\n }, [activeIndex, optionDomId])\n\n const listboxVisible = shouldSearch && !disabled\n const handleInputKeyDown = React.useCallback((event: React.KeyboardEvent<HTMLInputElement>) => {\n if (!listboxVisible) return\n if (event.key === 'ArrowDown') {\n event.preventDefault()\n moveActiveIndex(1)\n return\n }\n if (event.key === 'ArrowUp') {\n event.preventDefault()\n moveActiveIndex(-1)\n return\n }\n if (event.key === 'Enter') {\n if (activeIndex < 0 || activeIndex >= items.length) return\n const item = items[activeIndex]\n if (!isInteractiveItem(item)) return\n event.preventDefault()\n onChange(item.id)\n setActiveIndex(-1)\n return\n }\n if (event.key === 'Escape') {\n if (query.length === 0 && activeIndex < 0) return\n event.preventDefault()\n event.stopPropagation()\n setQuery('')\n setActiveIndex(-1)\n }\n }, [activeIndex, items, isInteractiveItem, listboxVisible, moveActiveIndex, onChange, query])\n React.useEffect(() => {\n if (disabled) {\n setItems(options ?? [])\n setLoading(false)\n return\n }\n let cancelled = false\n let timer: ReturnType<typeof setTimeout> | null = null\n if (!shouldSearch) {\n setItems(options ?? [])\n setLoading(false)\n setError(null)\n return () => { cancelled = true }\n }\n setLoading(true)\n setError(null)\n timer = setTimeout(() => {\n const requestId = Date.now()\n const fetcher = fetchItemsRef.current\n const loader = fetcher ?? (() => Promise.resolve(options ?? []))\n loader(query.trim())\n .then((result) => {\n if (cancelled) return\n setItems(result)\n })\n .catch((err) => {\n if (cancelled) return\n logger.error('Failed to fetch lookup items', { err })\n setError('error')\n })\n .finally(() => {\n if (!cancelled) setLoading(false)\n })\n return requestId\n }, 220)\n return () => {\n cancelled = true\n if (timer) clearTimeout(timer)\n }\n }, [query, shouldSearch, fetchKey])\n\n return (\n <div className=\"space-y-3\">\n <div className=\"flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3\">\n <div className=\"relative flex-1\">\n <Search className=\"pointer-events-none absolute left-3.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground\" />\n <input\n className=\"w-full h-10 rounded-lg border border-input bg-background pl-10 pr-3 text-sm shadow-xs transition-colors outline-none placeholder:text-muted-foreground hover:border-foreground/20 focus-visible:shadow-focus focus-visible:border-brand-violet disabled:bg-bg-disabled disabled:border-border-disabled disabled:text-muted-foreground disabled:cursor-not-allowed\"\n value={query}\n onChange={(event) => {\n setQuery(event.target.value)\n setHasTyped(true)\n }}\n onKeyDown={handleInputKeyDown}\n placeholder={resolvedSearchPlaceholder}\n disabled={disabled}\n role=\"combobox\"\n aria-expanded={listboxVisible}\n aria-controls={listboxId}\n aria-autocomplete=\"list\"\n aria-activedescendant={activeIndex >= 0 ? optionDomId(activeIndex) : undefined}\n />\n </div>\n {actionSlot ? <div className=\"sm:self-start\">{actionSlot}</div> : null}\n </div>\n {shouldSearch ? (\n <div className=\"space-y-2\">\n {loading || loadingProp ? (\n <div className=\"flex items-center gap-2 text-sm text-muted-foreground\">\n <Loader2 className=\"h-4 w-4 animate-spin\" />\n {resolvedLoadingLabel}\n </div>\n ) : null}\n {!loading && !loadingProp && !items.length ? (\n <p className=\"text-xs text-muted-foreground\">{resolvedEmptyLabel}</p>\n ) : null}\n <div\n id={listboxId}\n role=\"listbox\"\n className=\"flex flex-col gap-1.5 max-h-80 overflow-y-auto -mx-0.5 px-0.5 py-0.5\"\n >\n {items.map((item, index) => {\n const isSelected = value === item.id\n const isInteractive = !item.disabled || isSelected\n const isActive = index === activeIndex\n return (\n <div\n key={item.id}\n id={optionDomId(index)}\n className={cn(\n 'group flex items-center gap-4 rounded-xl border p-4 transition-all duration-150 focus-visible:outline-none focus-visible:shadow-focus',\n isInteractive ? 'cursor-pointer' : 'cursor-not-allowed opacity-60',\n isSelected\n ? 'border-brand-violet bg-brand-violet/5 shadow-sm'\n : 'border-input bg-card hover:border-foreground/20 hover:bg-muted/30 hover:shadow-sm',\n isActive && !isSelected ? 'border-foreground/20 bg-muted/30 shadow-sm' : null\n )}\n role=\"option\"\n tabIndex={item.disabled ? -1 : 0}\n onClick={() => {\n if (!isInteractive) return\n onChange(item.id)\n }}\n onKeyDown={(event) => {\n if (event.key === 'Enter' || event.key === ' ') {\n event.preventDefault()\n if (!isInteractive) return\n onChange(item.id)\n }\n }}\n aria-selected={isSelected}\n aria-disabled={item.disabled && !isSelected ? true : undefined}\n title={isSelected ? resolvedSelectedLabel : resolvedSelectLabel}\n >\n {item.icon ? (\n <div className=\"flex h-12 w-12 shrink-0 items-center justify-center overflow-hidden [&>svg]:size-6 [&_svg]:text-muted-foreground\">\n {item.icon}\n </div>\n ) : (\n <div className={cn(\n 'flex h-12 w-12 shrink-0 items-center justify-center overflow-hidden rounded-lg border transition-colors',\n isSelected\n ? 'border-brand-violet/40 bg-brand-violet/10 text-brand-violet'\n : 'border-input bg-muted text-muted-foreground group-hover:border-foreground/20'\n )}>\n <span className=\"text-base font-semibold uppercase\">{item.title.slice(0, 1)}</span>\n </div>\n )}\n <div className=\"flex min-w-0 flex-1 flex-col gap-0.5\">\n <div className=\"flex items-center justify-between gap-3\">\n <div className=\"truncate text-sm font-semibold text-foreground\">{item.title}</div>\n {item.rightLabel ? (\n <div className=\"shrink-0 text-overline font-medium uppercase tracking-wider text-muted-foreground\">\n {item.rightLabel}\n </div>\n ) : null}\n </div>\n {item.subtitle ? (\n <div className=\"text-xs text-muted-foreground truncate\">{item.subtitle}</div>\n ) : null}\n {item.description ? (\n <div className=\"text-xs text-muted-foreground/70 truncate\">{item.description}</div>\n ) : null}\n </div>\n <div className=\"flex shrink-0 items-center justify-center\">\n {isSelected ? (\n <Check className=\"size-5 text-brand-violet\" aria-hidden=\"true\" />\n ) : (\n <div className=\"size-5\" aria-hidden=\"true\" />\n )}\n </div>\n </div>\n )\n })}\n </div>\n {value ? (\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"sm\"\n className=\"w-fit gap-1 text-sm font-normal\"\n onClick={() => onChange(null)}\n >\n <X className=\"h-4 w-4\" />\n {resolvedClearLabel}\n </Button>\n ) : null}\n </div>\n ) : hasTyped ? (\n <p className=\"text-xs text-muted-foreground\">\n {resolvedMinQueryHintLabel}\n </p>\n ) : (\n <p className=\"text-xs text-muted-foreground\">{resolvedStartTypingLabel}</p>\n )}\n {error ? <p className=\"text-xs text-status-error-text\" role=\"alert\">{resolvedEmptyLabel}</p> : null}\n </div>\n )\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/ui",
|
|
3
|
-
"version": "0.6.8-develop.
|
|
3
|
+
"version": "0.6.8-develop.6930.1.1e5976efc3",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -155,14 +155,14 @@
|
|
|
155
155
|
"remark-gfm": "^4.0.1"
|
|
156
156
|
},
|
|
157
157
|
"peerDependencies": {
|
|
158
|
-
"@open-mercato/shared": "0.6.8-develop.
|
|
158
|
+
"@open-mercato/shared": "0.6.8-develop.6930.1.1e5976efc3",
|
|
159
159
|
"react": ">=18.0.0",
|
|
160
160
|
"react-dom": ">=18.0.0",
|
|
161
161
|
"react-is": ">=18.0.0"
|
|
162
162
|
},
|
|
163
163
|
"devDependencies": {
|
|
164
164
|
"@figma/code-connect": "^1.3.4",
|
|
165
|
-
"@open-mercato/shared": "0.6.8-develop.
|
|
165
|
+
"@open-mercato/shared": "0.6.8-develop.6930.1.1e5976efc3",
|
|
166
166
|
"@testing-library/dom": "^10.4.1",
|
|
167
167
|
"@testing-library/jest-dom": "^7.0.0",
|
|
168
168
|
"@testing-library/react": "^16.3.1",
|
package/src/backend/CrudForm.tsx
CHANGED
|
@@ -960,6 +960,7 @@ export function CrudForm<TValues extends Record<string, unknown>>({
|
|
|
960
960
|
dirtyBaselineSnapshotRef.current = createDirtySnapshot(source)
|
|
961
961
|
dirtyBaselineValuesRef.current = { ...source }
|
|
962
962
|
userEditedFieldIdsRef.current.clear()
|
|
963
|
+
everEditedFieldIdsRef.current.clear()
|
|
963
964
|
isDirtyRef.current = false
|
|
964
965
|
setHasUnsavedChanges(false)
|
|
965
966
|
}, [])
|
|
@@ -1857,6 +1858,7 @@ export function CrudForm<TValues extends Record<string, unknown>>({
|
|
|
1857
1858
|
const field = fieldById.get(fieldId)
|
|
1858
1859
|
if (!field || field.disabled) return
|
|
1859
1860
|
if (hiddenBaseFieldIds.has(fieldId) || hiddenInjectedFieldIds.has(fieldId)) return
|
|
1861
|
+
if (!everEditedFieldIdsRef.current.has(fieldId)) return
|
|
1860
1862
|
|
|
1861
1863
|
const nextValues = sourceValues ?? valuesRef.current
|
|
1862
1864
|
const nextFieldErrors: Record<string, string> = {}
|
|
@@ -2309,6 +2311,7 @@ export function CrudForm<TValues extends Record<string, unknown>>({
|
|
|
2309
2311
|
return
|
|
2310
2312
|
}
|
|
2311
2313
|
userEditedFieldIdsRef.current.add(id)
|
|
2314
|
+
everEditedFieldIdsRef.current.add(id)
|
|
2312
2315
|
}, [])
|
|
2313
2316
|
|
|
2314
2317
|
const setValue = React.useCallback((id: string, nextValue: unknown) => {
|
|
@@ -2446,6 +2449,7 @@ export function CrudForm<TValues extends Record<string, unknown>>({
|
|
|
2446
2449
|
const dirtyBaselineSnapshotRef = React.useRef<string | undefined>(undefined)
|
|
2447
2450
|
const dirtyBaselineValuesRef = React.useRef<Record<string, unknown> | undefined>(undefined)
|
|
2448
2451
|
const userEditedFieldIdsRef = React.useRef<Set<string>>(new Set())
|
|
2452
|
+
const everEditedFieldIdsRef = React.useRef<Set<string>>(new Set())
|
|
2449
2453
|
React.useLayoutEffect(() => {
|
|
2450
2454
|
if (!initialValues) return
|
|
2451
2455
|
const snapshot = JSON.stringify({
|
|
@@ -3093,13 +3097,16 @@ export function CrudForm<TValues extends Record<string, unknown>>({
|
|
|
3093
3097
|
}
|
|
3094
3098
|
|
|
3095
3099
|
const renderFields = (fieldList: CrudField[]) => {
|
|
3096
|
-
const
|
|
3100
|
+
const visibleFieldList = fieldList.filter(
|
|
3101
|
+
(field) => !hiddenBaseFieldIds.has(field.id) && !hiddenInjectedFieldIds.has(field.id)
|
|
3102
|
+
)
|
|
3103
|
+
const usesResponsive = visibleFieldList.some(
|
|
3097
3104
|
(field) => field.layout === 'half' || field.layout === 'third'
|
|
3098
3105
|
)
|
|
3099
3106
|
const gridClass = usesResponsive ? 'grid grid-cols-1 gap-4 md:grid-cols-6' : 'grid grid-cols-1 gap-4'
|
|
3100
3107
|
return (
|
|
3101
3108
|
<div className={gridClass}>
|
|
3102
|
-
{
|
|
3109
|
+
{visibleFieldList.map((f) => {
|
|
3103
3110
|
const layout = f.layout ?? 'full'
|
|
3104
3111
|
const wrapperClassName = usesResponsive ? resolveLayoutClass(layout) : undefined
|
|
3105
3112
|
return (
|
|
@@ -360,7 +360,7 @@ describe('CrudForm initialValues', () => {
|
|
|
360
360
|
})
|
|
361
361
|
|
|
362
362
|
await waitFor(() => {
|
|
363
|
-
expect(queryByRole('
|
|
363
|
+
expect(queryByRole('option', { name: 'Alpha' })).toBeInTheDocument()
|
|
364
364
|
})
|
|
365
365
|
|
|
366
366
|
jest.useRealTimers()
|
|
@@ -175,13 +175,13 @@ describe('CrudForm validation state', () => {
|
|
|
175
175
|
})
|
|
176
176
|
})
|
|
177
177
|
|
|
178
|
-
it('validates number fields on blur', async () => {
|
|
178
|
+
it('validates number fields on blur only after the user edited them', async () => {
|
|
179
179
|
const fields: CrudField[] = [
|
|
180
180
|
{ id: 'title', label: 'Title', type: 'text' },
|
|
181
181
|
{ id: 'cf_priority', label: 'Priority', type: 'number', required: true },
|
|
182
182
|
]
|
|
183
183
|
|
|
184
|
-
const { container, findByText } = renderWithProviders(
|
|
184
|
+
const { container, findByText, queryByText } = renderWithProviders(
|
|
185
185
|
<CrudForm title="Form" fields={fields} onSubmit={() => {}} />,
|
|
186
186
|
{
|
|
187
187
|
dict: {
|
|
@@ -194,9 +194,60 @@ describe('CrudForm validation state', () => {
|
|
|
194
194
|
const priorityInput = container.querySelector('[data-crud-field-id="cf_priority"] input[type="number"]')
|
|
195
195
|
expect(priorityInput).not.toBeNull()
|
|
196
196
|
|
|
197
|
+
// Blurring an untouched field must NOT flag the required error — tabbing
|
|
198
|
+
// through an empty form is not an error condition.
|
|
197
199
|
await act(async () => {
|
|
198
200
|
fireEvent.blur(priorityInput as HTMLInputElement)
|
|
199
201
|
})
|
|
202
|
+
expect(queryByText('This field is required')).toBeNull()
|
|
203
|
+
|
|
204
|
+
// After the user actually edits the field, clearing it and blurring flags it.
|
|
205
|
+
await act(async () => {
|
|
206
|
+
fireEvent.change(priorityInput as HTMLInputElement, { target: { value: '5' } })
|
|
207
|
+
})
|
|
208
|
+
await act(async () => {
|
|
209
|
+
fireEvent.change(priorityInput as HTMLInputElement, { target: { value: '' } })
|
|
210
|
+
})
|
|
211
|
+
await act(async () => {
|
|
212
|
+
fireEvent.blur(priorityInput as HTMLInputElement)
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
expect(await findByText('This field is required')).toBeInTheDocument()
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
it('does not run schema validation when an untouched required field blurs', async () => {
|
|
219
|
+
const fields: CrudField[] = [
|
|
220
|
+
{ id: 'firstName', label: 'First name', type: 'text', required: true },
|
|
221
|
+
]
|
|
222
|
+
const schema = z.object({
|
|
223
|
+
firstName: z.string().trim().min(1),
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
const { container, findByText, queryByText } = renderWithProviders(
|
|
227
|
+
<CrudForm title="Form" schema={schema} fields={fields} onSubmit={() => {}} />,
|
|
228
|
+
{
|
|
229
|
+
dict: {
|
|
230
|
+
'ui.forms.actions.save': 'Save',
|
|
231
|
+
'ui.forms.errors.required': 'This field is required',
|
|
232
|
+
},
|
|
233
|
+
},
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
const firstNameInput = container.querySelector('[data-crud-field-id="firstName"] input[type="text"]')
|
|
237
|
+
expect(firstNameInput).not.toBeNull()
|
|
238
|
+
|
|
239
|
+
await act(async () => {
|
|
240
|
+
fireEvent.blur(firstNameInput as HTMLInputElement)
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
expect(queryByText('Invalid input: expected string, received undefined')).toBeNull()
|
|
244
|
+
expect(queryByText('This field is required')).toBeNull()
|
|
245
|
+
|
|
246
|
+
await act(async () => {
|
|
247
|
+
fireEvent.change(firstNameInput as HTMLInputElement, { target: { value: 'Jane' } })
|
|
248
|
+
fireEvent.change(firstNameInput as HTMLInputElement, { target: { value: '' } })
|
|
249
|
+
fireEvent.blur(firstNameInput as HTMLInputElement)
|
|
250
|
+
})
|
|
200
251
|
|
|
201
252
|
expect(await findByText('This field is required')).toBeInTheDocument()
|
|
202
253
|
})
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/** @jest-environment jsdom */
|
|
2
|
+
jest.setTimeout(15000)
|
|
3
|
+
|
|
4
|
+
jest.mock('next/navigation', () => ({
|
|
5
|
+
useRouter: () => ({ push: () => {} }),
|
|
6
|
+
usePathname: () => '/',
|
|
7
|
+
useSearchParams: () => new URLSearchParams(),
|
|
8
|
+
}))
|
|
9
|
+
jest.mock('remark-gfm', () => ({ __esModule: true, default: {} }))
|
|
10
|
+
jest.mock('../injection/InjectionSpot', () => ({
|
|
11
|
+
__esModule: true,
|
|
12
|
+
InjectionSpot: () => null,
|
|
13
|
+
useInjectionWidgets: () => ({ widgets: [], loading: false, error: null }),
|
|
14
|
+
useInjectionSpotEvents: () => ({ triggerEvent: async () => ({ ok: true }) }),
|
|
15
|
+
}))
|
|
16
|
+
jest.mock('../injection/useInjectionDataWidgets', () => ({
|
|
17
|
+
__esModule: true,
|
|
18
|
+
useInjectionDataWidgets: () => ({ widgets: [], isLoading: false, error: null }),
|
|
19
|
+
}))
|
|
20
|
+
|
|
21
|
+
import * as React from 'react'
|
|
22
|
+
import { act, fireEvent } from '@testing-library/react'
|
|
23
|
+
import { renderWithProviders } from '@open-mercato/shared/lib/testing/renderWithProviders'
|
|
24
|
+
import { CrudForm, type CrudField, type CrudFieldGroup } from '../CrudForm'
|
|
25
|
+
|
|
26
|
+
const dict = {
|
|
27
|
+
'ui.forms.actions.save': 'Save',
|
|
28
|
+
'ui.forms.select.emptyOption': '—',
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const fields: CrudField[] = [
|
|
32
|
+
{
|
|
33
|
+
id: 'commodity',
|
|
34
|
+
label: 'Commodity',
|
|
35
|
+
type: 'select',
|
|
36
|
+
options: [
|
|
37
|
+
{ value: 'coffee', label: 'Coffee' },
|
|
38
|
+
{ value: 'wood', label: 'Wood' },
|
|
39
|
+
],
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
id: 'speciesScientificName',
|
|
43
|
+
label: 'Scientific species name',
|
|
44
|
+
type: 'text',
|
|
45
|
+
visibleWhen: { field: 'commodity', equals: 'wood' },
|
|
46
|
+
},
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
const groups: CrudFieldGroup[] = [
|
|
50
|
+
{ id: 'details', title: 'Details', column: 1, fields: ['commodity', 'speciesScientificName'] },
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
function renderForm(commodity: string, withGroups: boolean) {
|
|
54
|
+
return renderWithProviders(
|
|
55
|
+
<CrudForm
|
|
56
|
+
title="Form"
|
|
57
|
+
fields={fields}
|
|
58
|
+
groups={withGroups ? groups : undefined}
|
|
59
|
+
initialValues={{ commodity, speciesScientificName: '' }}
|
|
60
|
+
onSubmit={() => {}}
|
|
61
|
+
/>,
|
|
62
|
+
{ dict },
|
|
63
|
+
)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
describe('CrudForm visibleWhen', () => {
|
|
67
|
+
it('hides a conditional field inside a group when the condition is not met', () => {
|
|
68
|
+
const { container } = renderForm('coffee', true)
|
|
69
|
+
expect(container.querySelector('[data-crud-field-id="commodity"]')).not.toBeNull()
|
|
70
|
+
expect(container.querySelector('[data-crud-field-id="speciesScientificName"]')).toBeNull()
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('shows a conditional field inside a group when the condition is met', () => {
|
|
74
|
+
const { container } = renderForm('wood', true)
|
|
75
|
+
expect(container.querySelector('[data-crud-field-id="speciesScientificName"]')).not.toBeNull()
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('hides a conditional field inside a group when the driving value is empty', () => {
|
|
79
|
+
const { container } = renderForm('', true)
|
|
80
|
+
expect(container.querySelector('[data-crud-field-id="speciesScientificName"]')).toBeNull()
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('hides a conditional field in an ungrouped form when the condition is not met', () => {
|
|
84
|
+
const { container } = renderForm('coffee', false)
|
|
85
|
+
expect(container.querySelector('[data-crud-field-id="speciesScientificName"]')).toBeNull()
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('reveals a grouped conditional field when the driving value changes', async () => {
|
|
89
|
+
const { container } = renderForm('coffee', true)
|
|
90
|
+
expect(container.querySelector('[data-crud-field-id="speciesScientificName"]')).toBeNull()
|
|
91
|
+
|
|
92
|
+
const select = container.querySelector('[data-crud-field-id="commodity"] select') as HTMLSelectElement | null
|
|
93
|
+
if (!select) {
|
|
94
|
+
// The select renders as a Radix combobox in some builds; drive the value through the
|
|
95
|
+
// hidden native control the field always mirrors.
|
|
96
|
+
const hidden = container.querySelector('[data-crud-field-id="commodity"] input') as HTMLInputElement
|
|
97
|
+
await act(async () => {
|
|
98
|
+
fireEvent.change(hidden, { target: { value: 'wood' } })
|
|
99
|
+
})
|
|
100
|
+
} else {
|
|
101
|
+
await act(async () => {
|
|
102
|
+
fireEvent.change(select, { target: { value: 'wood' } })
|
|
103
|
+
})
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
expect(container.querySelector('[data-crud-field-id="speciesScientificName"]')).not.toBeNull()
|
|
107
|
+
})
|
|
108
|
+
})
|
|
@@ -50,8 +50,10 @@ import {
|
|
|
50
50
|
Download,
|
|
51
51
|
ExternalLink,
|
|
52
52
|
Eye,
|
|
53
|
+
FileCheck,
|
|
53
54
|
FileMinus,
|
|
54
55
|
FilePenLine,
|
|
56
|
+
Files,
|
|
55
57
|
FileSpreadsheet,
|
|
56
58
|
FileText,
|
|
57
59
|
FilterX,
|
|
@@ -74,6 +76,7 @@ import {
|
|
|
74
76
|
Key,
|
|
75
77
|
KeyRound,
|
|
76
78
|
Layers,
|
|
79
|
+
Leaf,
|
|
77
80
|
Lightbulb,
|
|
78
81
|
LineChart,
|
|
79
82
|
Link,
|
|
@@ -83,6 +86,7 @@ import {
|
|
|
83
86
|
Lock,
|
|
84
87
|
Mail,
|
|
85
88
|
MailOpen,
|
|
89
|
+
Map,
|
|
86
90
|
MapPin,
|
|
87
91
|
MapPinned,
|
|
88
92
|
MessageCircle,
|
|
@@ -133,6 +137,7 @@ import {
|
|
|
133
137
|
TriangleAlert,
|
|
134
138
|
Trophy,
|
|
135
139
|
Truck,
|
|
140
|
+
Undo,
|
|
136
141
|
Undo2,
|
|
137
142
|
Unlock,
|
|
138
143
|
User,
|
|
@@ -198,10 +203,12 @@ export const LUCIDE_ICON_REGISTRY: Record<string, LucideIcon> = {
|
|
|
198
203
|
'download': Download,
|
|
199
204
|
'external-link': ExternalLink,
|
|
200
205
|
'eye': Eye,
|
|
206
|
+
'file-check': FileCheck,
|
|
201
207
|
'file-minus': FileMinus,
|
|
202
208
|
'file-pen-line': FilePenLine,
|
|
203
209
|
'file-spreadsheet': FileSpreadsheet,
|
|
204
210
|
'file-text': FileText,
|
|
211
|
+
'files': Files,
|
|
205
212
|
'filter-x': FilterX,
|
|
206
213
|
'flag': Flag,
|
|
207
214
|
'flame': Flame,
|
|
@@ -222,6 +229,7 @@ export const LUCIDE_ICON_REGISTRY: Record<string, LucideIcon> = {
|
|
|
222
229
|
'key': Key,
|
|
223
230
|
'key-round': KeyRound,
|
|
224
231
|
'layers': Layers,
|
|
232
|
+
'leaf': Leaf,
|
|
225
233
|
'lightbulb': Lightbulb,
|
|
226
234
|
'line-chart': LineChart,
|
|
227
235
|
'link': Link,
|
|
@@ -231,6 +239,7 @@ export const LUCIDE_ICON_REGISTRY: Record<string, LucideIcon> = {
|
|
|
231
239
|
'lock': Lock,
|
|
232
240
|
'mail': Mail,
|
|
233
241
|
'mail-open': MailOpen,
|
|
242
|
+
'map': Map,
|
|
234
243
|
'map-pin': MapPin,
|
|
235
244
|
'map-pinned': MapPinned,
|
|
236
245
|
'message-circle': MessageCircle,
|
|
@@ -281,6 +290,7 @@ export const LUCIDE_ICON_REGISTRY: Record<string, LucideIcon> = {
|
|
|
281
290
|
'triangle-alert': TriangleAlert,
|
|
282
291
|
'trophy': Trophy,
|
|
283
292
|
'truck': Truck,
|
|
293
|
+
'undo': Undo,
|
|
284
294
|
'undo-2': Undo2,
|
|
285
295
|
'unlock': Unlock,
|
|
286
296
|
'user': User,
|
|
@@ -92,6 +92,7 @@ export function ComboboxInput({
|
|
|
92
92
|
const [touched, setTouched] = React.useState(false)
|
|
93
93
|
const [showSuggestions, setShowSuggestions] = React.useState(false)
|
|
94
94
|
const [selectedIndex, setSelectedIndex] = React.useState(-1)
|
|
95
|
+
const listboxId = React.useId()
|
|
95
96
|
const inputRef = React.useRef<HTMLInputElement>(null)
|
|
96
97
|
const loadingRef = React.useRef(false)
|
|
97
98
|
const blurCloseTimerRef = React.useRef<number | null>(null)
|
|
@@ -365,7 +366,9 @@ export function ComboboxInput({
|
|
|
365
366
|
confirmSelection(input)
|
|
366
367
|
}
|
|
367
368
|
} else if (event.key === 'Escape') {
|
|
369
|
+
if (!showSuggestions) return
|
|
368
370
|
event.preventDefault()
|
|
371
|
+
event.stopPropagation()
|
|
369
372
|
setShowSuggestions(false)
|
|
370
373
|
setSelectedIndex(-1)
|
|
371
374
|
}
|
|
@@ -373,7 +376,23 @@ export function ComboboxInput({
|
|
|
373
376
|
[confirmSelection, disabled, filteredSuggestions, input, selectValue, selectedIndex, showSuggestions]
|
|
374
377
|
)
|
|
375
378
|
|
|
379
|
+
const optionDomId = React.useCallback(
|
|
380
|
+
(index: number) => `${listboxId}-option-${index}`,
|
|
381
|
+
[listboxId],
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
React.useEffect(() => {
|
|
385
|
+
if (selectedIndex < 0 || !showSuggestions) return
|
|
386
|
+
const activeElement = typeof document !== 'undefined'
|
|
387
|
+
? document.getElementById(optionDomId(selectedIndex))
|
|
388
|
+
: null
|
|
389
|
+
if (typeof activeElement?.scrollIntoView === 'function') {
|
|
390
|
+
activeElement.scrollIntoView({ block: 'nearest' })
|
|
391
|
+
}
|
|
392
|
+
}, [optionDomId, selectedIndex, showSuggestions])
|
|
393
|
+
|
|
376
394
|
const showClearButton = clearable && !disabled && (value !== '' || input !== '')
|
|
395
|
+
const listboxVisible = showSuggestions && !disabled && (loading || filteredSuggestions.length > 0)
|
|
377
396
|
|
|
378
397
|
return (
|
|
379
398
|
<div className="relative w-full">
|
|
@@ -395,6 +414,11 @@ export function ComboboxInput({
|
|
|
395
414
|
autoFocus={autoFocus}
|
|
396
415
|
data-crud-focus-target=""
|
|
397
416
|
disabled={disabled}
|
|
417
|
+
role="combobox"
|
|
418
|
+
aria-expanded={listboxVisible}
|
|
419
|
+
aria-controls={listboxId}
|
|
420
|
+
aria-autocomplete="list"
|
|
421
|
+
aria-activedescendant={listboxVisible && selectedIndex >= 0 ? optionDomId(selectedIndex) : undefined}
|
|
398
422
|
onFocus={() => {
|
|
399
423
|
setTouched(true)
|
|
400
424
|
if (suppressOpenOnFocusRef.current) {
|
|
@@ -442,18 +466,21 @@ export function ComboboxInput({
|
|
|
442
466
|
</IconButton>
|
|
443
467
|
) : null}
|
|
444
468
|
|
|
445
|
-
{
|
|
469
|
+
{listboxVisible && (
|
|
446
470
|
<div className="absolute z-popover w-full mt-1 rounded-md border border-input bg-popover p-2 shadow-md max-h-48 sm:max-h-60 overflow-auto">
|
|
447
471
|
{loading && touched ? (
|
|
448
472
|
<div className="px-2 py-1.5 text-xs text-muted-foreground">{loadingLabel}</div>
|
|
449
473
|
) : (
|
|
450
|
-
<div className="flex flex-col gap-1">
|
|
474
|
+
<div id={listboxId} role="listbox" className="flex flex-col gap-1">
|
|
451
475
|
{filteredSuggestions.map((option, index) => (
|
|
452
476
|
<Button
|
|
453
477
|
key={option.value}
|
|
478
|
+
id={optionDomId(index)}
|
|
454
479
|
type="button"
|
|
455
480
|
variant="ghost"
|
|
456
481
|
size="sm"
|
|
482
|
+
role="option"
|
|
483
|
+
aria-selected={index === selectedIndex}
|
|
457
484
|
className={[
|
|
458
485
|
'w-full h-auto justify-start font-normal text-left flex flex-col items-start rounded-lg p-2',
|
|
459
486
|
index === selectedIndex ? 'bg-muted' : '',
|
|
@@ -86,6 +86,8 @@ export function LookupSelect({
|
|
|
86
86
|
const [hasTyped, setHasTyped] = React.useState(defaultOpen)
|
|
87
87
|
const [error, setError] = React.useState<string | null>(null)
|
|
88
88
|
const [fetchKey, setFetchKey] = React.useState(0)
|
|
89
|
+
const [activeIndex, setActiveIndex] = React.useState(-1)
|
|
90
|
+
const listboxId = React.useId()
|
|
89
91
|
const fetchItemsRef = React.useRef(fetchItems ?? fetchOptions)
|
|
90
92
|
const setQueryRef = React.useRef(setQuery)
|
|
91
93
|
const onReadyRef = React.useRef(onReady)
|
|
@@ -116,6 +118,73 @@ export function LookupSelect({
|
|
|
116
118
|
|
|
117
119
|
const shouldSearch =
|
|
118
120
|
defaultOpen || query.trim().length >= minQuery || Boolean(value && (options?.length ?? 0) > 0)
|
|
121
|
+
|
|
122
|
+
React.useEffect(() => {
|
|
123
|
+
setActiveIndex(-1)
|
|
124
|
+
}, [items])
|
|
125
|
+
|
|
126
|
+
const optionDomId = React.useCallback(
|
|
127
|
+
(index: number) => `${listboxId}-option-${index}`,
|
|
128
|
+
[listboxId],
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
const isInteractiveItem = React.useCallback(
|
|
132
|
+
(item: LookupSelectItem) => !item.disabled || value === item.id,
|
|
133
|
+
[value],
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
const moveActiveIndex = React.useCallback((direction: 1 | -1) => {
|
|
137
|
+
setActiveIndex((current) => {
|
|
138
|
+
if (!items.length) return -1
|
|
139
|
+
let next = current
|
|
140
|
+
for (let step = 0; step < items.length; step += 1) {
|
|
141
|
+
next = (next + direction + items.length) % items.length
|
|
142
|
+
if (isInteractiveItem(items[next])) return next
|
|
143
|
+
}
|
|
144
|
+
return current
|
|
145
|
+
})
|
|
146
|
+
}, [isInteractiveItem, items])
|
|
147
|
+
|
|
148
|
+
React.useEffect(() => {
|
|
149
|
+
if (activeIndex < 0) return
|
|
150
|
+
const activeElement = typeof document !== 'undefined'
|
|
151
|
+
? document.getElementById(optionDomId(activeIndex))
|
|
152
|
+
: null
|
|
153
|
+
if (typeof activeElement?.scrollIntoView === 'function') {
|
|
154
|
+
activeElement.scrollIntoView({ block: 'nearest' })
|
|
155
|
+
}
|
|
156
|
+
}, [activeIndex, optionDomId])
|
|
157
|
+
|
|
158
|
+
const listboxVisible = shouldSearch && !disabled
|
|
159
|
+
const handleInputKeyDown = React.useCallback((event: React.KeyboardEvent<HTMLInputElement>) => {
|
|
160
|
+
if (!listboxVisible) return
|
|
161
|
+
if (event.key === 'ArrowDown') {
|
|
162
|
+
event.preventDefault()
|
|
163
|
+
moveActiveIndex(1)
|
|
164
|
+
return
|
|
165
|
+
}
|
|
166
|
+
if (event.key === 'ArrowUp') {
|
|
167
|
+
event.preventDefault()
|
|
168
|
+
moveActiveIndex(-1)
|
|
169
|
+
return
|
|
170
|
+
}
|
|
171
|
+
if (event.key === 'Enter') {
|
|
172
|
+
if (activeIndex < 0 || activeIndex >= items.length) return
|
|
173
|
+
const item = items[activeIndex]
|
|
174
|
+
if (!isInteractiveItem(item)) return
|
|
175
|
+
event.preventDefault()
|
|
176
|
+
onChange(item.id)
|
|
177
|
+
setActiveIndex(-1)
|
|
178
|
+
return
|
|
179
|
+
}
|
|
180
|
+
if (event.key === 'Escape') {
|
|
181
|
+
if (query.length === 0 && activeIndex < 0) return
|
|
182
|
+
event.preventDefault()
|
|
183
|
+
event.stopPropagation()
|
|
184
|
+
setQuery('')
|
|
185
|
+
setActiveIndex(-1)
|
|
186
|
+
}
|
|
187
|
+
}, [activeIndex, items, isInteractiveItem, listboxVisible, moveActiveIndex, onChange, query])
|
|
119
188
|
React.useEffect(() => {
|
|
120
189
|
if (disabled) {
|
|
121
190
|
setItems(options ?? [])
|
|
@@ -169,8 +238,14 @@ export function LookupSelect({
|
|
|
169
238
|
setQuery(event.target.value)
|
|
170
239
|
setHasTyped(true)
|
|
171
240
|
}}
|
|
241
|
+
onKeyDown={handleInputKeyDown}
|
|
172
242
|
placeholder={resolvedSearchPlaceholder}
|
|
173
243
|
disabled={disabled}
|
|
244
|
+
role="combobox"
|
|
245
|
+
aria-expanded={listboxVisible}
|
|
246
|
+
aria-controls={listboxId}
|
|
247
|
+
aria-autocomplete="list"
|
|
248
|
+
aria-activedescendant={activeIndex >= 0 ? optionDomId(activeIndex) : undefined}
|
|
174
249
|
/>
|
|
175
250
|
</div>
|
|
176
251
|
{actionSlot ? <div className="sm:self-start">{actionSlot}</div> : null}
|
|
@@ -186,21 +261,28 @@ export function LookupSelect({
|
|
|
186
261
|
{!loading && !loadingProp && !items.length ? (
|
|
187
262
|
<p className="text-xs text-muted-foreground">{resolvedEmptyLabel}</p>
|
|
188
263
|
) : null}
|
|
189
|
-
<div
|
|
190
|
-
{
|
|
264
|
+
<div
|
|
265
|
+
id={listboxId}
|
|
266
|
+
role="listbox"
|
|
267
|
+
className="flex flex-col gap-1.5 max-h-80 overflow-y-auto -mx-0.5 px-0.5 py-0.5"
|
|
268
|
+
>
|
|
269
|
+
{items.map((item, index) => {
|
|
191
270
|
const isSelected = value === item.id
|
|
192
271
|
const isInteractive = !item.disabled || isSelected
|
|
272
|
+
const isActive = index === activeIndex
|
|
193
273
|
return (
|
|
194
274
|
<div
|
|
195
275
|
key={item.id}
|
|
276
|
+
id={optionDomId(index)}
|
|
196
277
|
className={cn(
|
|
197
278
|
'group flex items-center gap-4 rounded-xl border p-4 transition-all duration-150 focus-visible:outline-none focus-visible:shadow-focus',
|
|
198
279
|
isInteractive ? 'cursor-pointer' : 'cursor-not-allowed opacity-60',
|
|
199
280
|
isSelected
|
|
200
281
|
? 'border-brand-violet bg-brand-violet/5 shadow-sm'
|
|
201
|
-
: 'border-input bg-card hover:border-foreground/20 hover:bg-muted/30 hover:shadow-sm'
|
|
282
|
+
: 'border-input bg-card hover:border-foreground/20 hover:bg-muted/30 hover:shadow-sm',
|
|
283
|
+
isActive && !isSelected ? 'border-foreground/20 bg-muted/30 shadow-sm' : null
|
|
202
284
|
)}
|
|
203
|
-
role="
|
|
285
|
+
role="option"
|
|
204
286
|
tabIndex={item.disabled ? -1 : 0}
|
|
205
287
|
onClick={() => {
|
|
206
288
|
if (!isInteractive) return
|
|
@@ -213,7 +295,7 @@ export function LookupSelect({
|
|
|
213
295
|
onChange(item.id)
|
|
214
296
|
}
|
|
215
297
|
}}
|
|
216
|
-
aria-
|
|
298
|
+
aria-selected={isSelected}
|
|
217
299
|
aria-disabled={item.disabled && !isSelected ? true : undefined}
|
|
218
300
|
title={isSelected ? resolvedSelectedLabel : resolvedSelectLabel}
|
|
219
301
|
>
|