@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.
@@ -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": "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",
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.6925.1.cefa200fa4",
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.6925.1.cefa200fa4",
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.6925.1.cefa200fa4",
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",
@@ -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 usesResponsive = fieldList.some(
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
- {fieldList.map((f) => {
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('button', { name: 'Alpha' })).toBeInTheDocument()
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
- {showSuggestions && !disabled && (loading || filteredSuggestions.length > 0) && (
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 className="flex flex-col gap-1.5 max-h-80 overflow-y-auto -mx-0.5 px-0.5 py-0.5">
190
- {items.map((item) => {
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="button"
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-pressed={isSelected}
298
+ aria-selected={isSelected}
217
299
  aria-disabled={item.disabled && !isSelected ? true : undefined}
218
300
  title={isSelected ? resolvedSelectedLabel : resolvedSelectLabel}
219
301
  >