@open-mercato/ui 0.6.8-develop.6924.1.a8d208fcdc → 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.6924.1.a8d208fcdc",
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.6924.1.a8d208fcdc",
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.6924.1.a8d208fcdc",
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 (
@@ -0,0 +1,97 @@
1
+ /** @jest-environment jsdom */
2
+
3
+ jest.mock('next/navigation', () => ({
4
+ useRouter: () => ({ push: jest.fn() }),
5
+ usePathname: () => '/',
6
+ useSearchParams: () => new URLSearchParams(),
7
+ }))
8
+ jest.mock('remark-gfm', () => ({ __esModule: true, default: {} }))
9
+ jest.mock('../confirm-dialog', () => ({
10
+ useConfirmDialog: () => ({ confirm: jest.fn().mockResolvedValue(true), ConfirmDialogElement: null }),
11
+ }))
12
+ jest.mock('../custom-fields/FieldDefinitionsManager', () => {
13
+ const React = require('react')
14
+ return { __esModule: true, FieldDefinitionsManager: React.forwardRef(() => <div>field definitions</div>) }
15
+ })
16
+ jest.mock('../utils/customFieldForms', () => ({
17
+ __esModule: true,
18
+ buildFormFieldFromCustomFieldDef: jest.fn(() => null),
19
+ buildFormFieldsFromCustomFields: jest.fn(() => []),
20
+ fetchCustomFieldFormStructure: jest.fn(async () => ({
21
+ fields: [],
22
+ definitions: [],
23
+ metadata: { items: [], fieldsetsByEntity: {}, entitySettings: {} },
24
+ })),
25
+ }))
26
+
27
+ import * as React from 'react'
28
+ import { fireEvent, screen, waitFor } from '@testing-library/react'
29
+ import { renderWithProviders } from '@open-mercato/shared/lib/testing/renderWithProviders'
30
+ import { CrudForm, type CrudField } from '../CrudForm'
31
+
32
+ const dict = { 'ui.forms.actions.save': 'Save' }
33
+
34
+ const fields: CrudField[] = [
35
+ { id: 'name', label: 'Name', type: 'text' },
36
+ { id: 'isActive', label: 'Active', type: 'checkbox' },
37
+ ]
38
+
39
+ /**
40
+ * A checkbox seeded `false` must be able to reach `true`. The users edit form uses this
41
+ * to reactivate a deactivated account, so a stuck-off checkbox would let an operator
42
+ * deactivate someone with no way back.
43
+ */
44
+ describe('CrudForm checkbox toggling', () => {
45
+ it('submits true after toggling a checkbox that started false', async () => {
46
+ const onSubmit = jest.fn().mockResolvedValue(undefined)
47
+
48
+ renderWithProviders(
49
+ <CrudForm
50
+ title="Test"
51
+ fields={fields}
52
+ initialValues={{ name: 'thing', isActive: false }}
53
+ onSubmit={onSubmit}
54
+ submitLabel="Save"
55
+ />,
56
+ { dict },
57
+ )
58
+
59
+ const checkbox = await screen.findByRole('checkbox')
60
+ expect(checkbox).toHaveAttribute('data-state', 'unchecked')
61
+
62
+ fireEvent.click(checkbox)
63
+
64
+ await waitFor(() => expect(checkbox).toHaveAttribute('data-state', 'checked'))
65
+
66
+ fireEvent.click(screen.getAllByRole('button', { name: 'Save' })[0])
67
+
68
+ await waitFor(() => expect(onSubmit).toHaveBeenCalled())
69
+ expect(onSubmit.mock.calls[0][0]).toMatchObject({ isActive: true })
70
+ })
71
+
72
+ it('submits false after clearing a checkbox that started true', async () => {
73
+ const onSubmit = jest.fn().mockResolvedValue(undefined)
74
+
75
+ renderWithProviders(
76
+ <CrudForm
77
+ title="Test"
78
+ fields={fields}
79
+ initialValues={{ name: 'thing', isActive: true }}
80
+ onSubmit={onSubmit}
81
+ submitLabel="Save"
82
+ />,
83
+ { dict },
84
+ )
85
+
86
+ const checkbox = await screen.findByRole('checkbox')
87
+ expect(checkbox).toHaveAttribute('data-state', 'checked')
88
+
89
+ fireEvent.click(checkbox)
90
+ await waitFor(() => expect(checkbox).toHaveAttribute('data-state', 'unchecked'))
91
+
92
+ fireEvent.click(screen.getAllByRole('button', { name: 'Save' })[0])
93
+
94
+ await waitFor(() => expect(onSubmit).toHaveBeenCalled())
95
+ expect(onSubmit.mock.calls[0][0]).toMatchObject({ isActive: false })
96
+ })
97
+ })
@@ -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' : '',