@open-mercato/ui 0.6.1-develop.3187.1.4c96d17341 → 0.6.1-develop.3191.1.2511ae315f
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/dist/backend/CrudForm.js +6 -4
- package/dist/backend/CrudForm.js.map +2 -2
- package/dist/backend/inputs/ComboboxInput.js +5 -0
- package/dist/backend/inputs/ComboboxInput.js.map +2 -2
- package/dist/backend/inputs/TagsInput.js +10 -1
- package/dist/backend/inputs/TagsInput.js.map +2 -2
- package/package.json +3 -3
- package/src/backend/CrudForm.tsx +12 -4
- package/src/backend/__tests__/CrudForm.render.test.tsx +122 -1
- package/src/backend/inputs/ComboboxInput.tsx +5 -0
- package/src/backend/inputs/TagsInput.tsx +11 -0
- package/src/backend/inputs/__tests__/TagsInput.test.tsx +37 -0
|
@@ -38,6 +38,7 @@ function ComboboxInput({
|
|
|
38
38
|
const [showSuggestions, setShowSuggestions] = React.useState(false);
|
|
39
39
|
const [selectedIndex, setSelectedIndex] = React.useState(-1);
|
|
40
40
|
const inputRef = React.useRef(null);
|
|
41
|
+
const suppressOpenOnFocusRef = React.useRef(Boolean(autoFocus && !disabled));
|
|
41
42
|
const staticOptions = React.useMemo(() => normalizeOptions(suggestions), [suggestions]);
|
|
42
43
|
const optionMap = React.useMemo(() => {
|
|
43
44
|
const map = /* @__PURE__ */ new Map();
|
|
@@ -183,6 +184,10 @@ function ComboboxInput({
|
|
|
183
184
|
disabled,
|
|
184
185
|
onFocus: () => {
|
|
185
186
|
setTouched(true);
|
|
187
|
+
if (suppressOpenOnFocusRef.current) {
|
|
188
|
+
suppressOpenOnFocusRef.current = false;
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
186
191
|
setShowSuggestions(true);
|
|
187
192
|
},
|
|
188
193
|
onChange: (event) => {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/backend/inputs/ComboboxInput.tsx"],
|
|
4
|
-
"sourcesContent": ["\"use client\"\n\nimport * as React from 'react'\nimport { Button } from '../../primitives/button'\n\nexport type ComboboxOption = {\n value: string\n label: string\n description?: string | null\n}\n\nexport type ComboboxInputProps = {\n value: string\n onChange: (next: string) => void\n placeholder?: string\n suggestions?: Array<string | ComboboxOption>\n loadSuggestions?: (query?: string) => Promise<Array<string | ComboboxOption>>\n resolveLabel?: (value: string) => string\n resolveDescription?: (value: string) => string | null | undefined\n autoFocus?: boolean\n disabled?: boolean\n allowCustomValues?: boolean\n}\n\nfunction normalizeOptions(input?: Array<string | ComboboxOption>): ComboboxOption[] {\n if (!Array.isArray(input)) return []\n return input\n .map((option) => {\n if (typeof option === 'string') {\n const trimmed = option.trim()\n if (!trimmed) return null\n return { value: trimmed, label: trimmed }\n }\n const value = typeof option.value === 'string' ? option.value.trim() : ''\n if (!value) return null\n return {\n value,\n label: option.label?.trim() || value,\n description: option.description ?? null,\n }\n })\n .filter((option): option is ComboboxOption => !!option)\n}\n\nexport function ComboboxInput({\n value,\n onChange,\n placeholder,\n suggestions,\n loadSuggestions,\n resolveLabel,\n resolveDescription,\n autoFocus,\n disabled = false,\n allowCustomValues = true,\n}: ComboboxInputProps) {\n const [input, setInput] = React.useState('')\n const [asyncOptions, setAsyncOptions] = React.useState<ComboboxOption[]>([])\n const [loading, setLoading] = React.useState(false)\n const [touched, setTouched] = React.useState(false)\n const [showSuggestions, setShowSuggestions] = React.useState(false)\n const [selectedIndex, setSelectedIndex] = React.useState(-1)\n const inputRef = React.useRef<HTMLInputElement>(null)\n\n const staticOptions = React.useMemo(() => normalizeOptions(suggestions), [suggestions])\n\n const optionMap = React.useMemo(() => {\n const map = new Map<string, ComboboxOption>()\n const register = (option: ComboboxOption) => {\n if (!map.has(option.value)) {\n map.set(option.value, option)\n }\n }\n staticOptions.forEach(register)\n asyncOptions.forEach(register)\n if (value) {\n const existing = map.get(value)\n if (!existing) {\n map.set(value, {\n value,\n label: resolveLabel?.(value) ?? value,\n description: resolveDescription?.(value) ?? null,\n })\n }\n }\n return map\n }, [asyncOptions, resolveDescription, resolveLabel, staticOptions, value])\n\n const availableOptions = React.useMemo(() => {\n return Array.from(optionMap.values())\n }, [optionMap])\n\n const filteredSuggestions = React.useMemo(() => {\n const query = input.toLowerCase().trim()\n if (!query) return availableOptions\n return availableOptions.filter((option) => {\n const labelMatch = option.label.toLowerCase().includes(query)\n const descMatch = option.description?.toLowerCase().includes(query)\n return labelMatch || Boolean(descMatch)\n })\n }, [availableOptions, input])\n\n React.useEffect(() => {\n if (!loadSuggestions || !touched || disabled) return\n const query = input.trim()\n let cancelled = false\n const handle = window.setTimeout(async () => {\n setLoading(true)\n try {\n const items = await loadSuggestions(query)\n if (!cancelled) {\n setAsyncOptions(normalizeOptions(items))\n }\n } finally {\n if (!cancelled) setLoading(false)\n }\n }, 200)\n return () => {\n cancelled = true\n window.clearTimeout(handle)\n }\n }, [disabled, input, loadSuggestions, touched])\n\n // Sync input with value when value changes externally and input is not focused\n React.useEffect(() => {\n if (document.activeElement !== inputRef.current) {\n const option = optionMap.get(value)\n setInput(option?.label ?? value ?? '')\n }\n }, [value, optionMap])\n\n const selectValue = React.useCallback(\n (nextValue: string) => {\n if (disabled) return\n const trimmed = nextValue.trim()\n onChange(trimmed)\n const option = optionMap.get(trimmed)\n setInput(option?.label ?? trimmed)\n setShowSuggestions(false)\n setSelectedIndex(-1)\n },\n [disabled, onChange, optionMap]\n )\n\n const findOptionForInput = React.useCallback(\n (raw: string): ComboboxOption | null => {\n const query = raw.trim().toLowerCase()\n if (!query) return null\n for (const option of optionMap.values()) {\n if (option.value === raw.trim()) return option\n if (option.label.toLowerCase() === query) return option\n }\n return null\n },\n [optionMap]\n )\n\n const confirmSelection = React.useCallback(\n (raw: string) => {\n if (disabled) return\n const option = findOptionForInput(raw)\n if (option) {\n selectValue(option.value)\n return\n }\n if (!allowCustomValues) {\n // Revert to current value if custom values not allowed\n const currentOption = optionMap.get(value)\n setInput(currentOption?.label ?? value ?? '')\n setShowSuggestions(false)\n return\n }\n selectValue(raw)\n },\n [allowCustomValues, disabled, findOptionForInput, optionMap, selectValue, value]\n )\n\n const handleKeyDown = React.useCallback(\n (event: React.KeyboardEvent<HTMLInputElement>) => {\n if (disabled) return\n\n if (event.key === 'ArrowDown') {\n event.preventDefault()\n if (!showSuggestions) {\n setShowSuggestions(true)\n setSelectedIndex(0)\n } else {\n setSelectedIndex((prev) => Math.min(prev + 1, filteredSuggestions.length - 1))\n }\n } else if (event.key === 'ArrowUp') {\n event.preventDefault()\n setSelectedIndex((prev) => Math.max(prev - 1, -1))\n } else if (event.key === 'Enter') {\n event.preventDefault()\n if (selectedIndex >= 0 && filteredSuggestions[selectedIndex]) {\n selectValue(filteredSuggestions[selectedIndex].value)\n } else {\n confirmSelection(input)\n }\n } else if (event.key === 'Escape') {\n event.preventDefault()\n setShowSuggestions(false)\n setSelectedIndex(-1)\n }\n },\n [confirmSelection, disabled, filteredSuggestions, input, selectValue, selectedIndex, showSuggestions]\n )\n\n return (\n <div className=\"relative w-full\">\n {/* Use raw <input> here instead of the DS Input primitive: ComboboxInput's\n focus / suggestions-popup interplay relies on the trigger being a plain\n input element. The DS wrapper introduces a <div> that desyncs autocomplete\n on this specific surface. Keeps the rest of the form on Input primitive. */}\n <input\n ref={inputRef}\n type=\"text\"\n className=\"w-full h-9 rounded border px-2 text-sm disabled:bg-muted disabled:text-muted-foreground disabled:cursor-not-allowed\"\n value={input}\n placeholder={placeholder || 'Type to search...'}\n autoFocus={autoFocus}\n data-crud-focus-target=\"\"\n disabled={disabled}\n onFocus={() => {\n setTouched(true)\n setShowSuggestions(true)\n }}\n onChange={(event) => {\n setTouched(true)\n setInput(event.target.value)\n setShowSuggestions(true)\n setSelectedIndex(-1)\n }}\n onKeyDown={handleKeyDown}\n onBlur={() => {\n // Delay to allow click on suggestions\n setTimeout(() => {\n if (disabled) return\n confirmSelection(input)\n }, 200)\n }}\n />\n\n {showSuggestions && !disabled && (loading || filteredSuggestions.length > 0) && (\n <div className=\"absolute z-popover w-full mt-1 rounded border bg-popover shadow-lg max-h-48 sm:max-h-60 overflow-auto\">\n {loading && touched ? (\n <div className=\"px-3 py-2 text-xs text-muted-foreground\">Loading suggestions\u2026</div>\n ) : (\n filteredSuggestions.map((option, index) => (\n <Button\n key={option.value}\n type=\"button\"\n variant=\"ghost\"\n size=\"sm\"\n className={[\n 'w-full h-auto justify-start font-normal text-left flex flex-col items-start px-3 py-2',\n index === selectedIndex ? 'bg-accent' : '',\n ]\n .filter(Boolean)\n .join(' ')}\n onMouseDown={(event) => event.preventDefault()}\n onClick={() => selectValue(option.value)}\n onMouseEnter={() => setSelectedIndex(index)}\n >\n <span className=\"font-medium\">{option.label}</span>\n {option.description ? (\n <span className=\"text-xs text-muted-foreground\">{option.description}</span>\n ) : null}\n </Button>\n ))\n )}\n </div>\n )}\n </div>\n )\n}\n"],
|
|
5
|
-
"mappings": ";
|
|
4
|
+
"sourcesContent": ["\"use client\"\n\nimport * as React from 'react'\nimport { Button } from '../../primitives/button'\n\nexport type ComboboxOption = {\n value: string\n label: string\n description?: string | null\n}\n\nexport type ComboboxInputProps = {\n value: string\n onChange: (next: string) => void\n placeholder?: string\n suggestions?: Array<string | ComboboxOption>\n loadSuggestions?: (query?: string) => Promise<Array<string | ComboboxOption>>\n resolveLabel?: (value: string) => string\n resolveDescription?: (value: string) => string | null | undefined\n autoFocus?: boolean\n disabled?: boolean\n allowCustomValues?: boolean\n}\n\nfunction normalizeOptions(input?: Array<string | ComboboxOption>): ComboboxOption[] {\n if (!Array.isArray(input)) return []\n return input\n .map((option) => {\n if (typeof option === 'string') {\n const trimmed = option.trim()\n if (!trimmed) return null\n return { value: trimmed, label: trimmed }\n }\n const value = typeof option.value === 'string' ? option.value.trim() : ''\n if (!value) return null\n return {\n value,\n label: option.label?.trim() || value,\n description: option.description ?? null,\n }\n })\n .filter((option): option is ComboboxOption => !!option)\n}\n\nexport function ComboboxInput({\n value,\n onChange,\n placeholder,\n suggestions,\n loadSuggestions,\n resolveLabel,\n resolveDescription,\n autoFocus,\n disabled = false,\n allowCustomValues = true,\n}: ComboboxInputProps) {\n const [input, setInput] = React.useState('')\n const [asyncOptions, setAsyncOptions] = React.useState<ComboboxOption[]>([])\n const [loading, setLoading] = React.useState(false)\n const [touched, setTouched] = React.useState(false)\n const [showSuggestions, setShowSuggestions] = React.useState(false)\n const [selectedIndex, setSelectedIndex] = React.useState(-1)\n const inputRef = React.useRef<HTMLInputElement>(null)\n const suppressOpenOnFocusRef = React.useRef(Boolean(autoFocus && !disabled))\n\n const staticOptions = React.useMemo(() => normalizeOptions(suggestions), [suggestions])\n\n const optionMap = React.useMemo(() => {\n const map = new Map<string, ComboboxOption>()\n const register = (option: ComboboxOption) => {\n if (!map.has(option.value)) {\n map.set(option.value, option)\n }\n }\n staticOptions.forEach(register)\n asyncOptions.forEach(register)\n if (value) {\n const existing = map.get(value)\n if (!existing) {\n map.set(value, {\n value,\n label: resolveLabel?.(value) ?? value,\n description: resolveDescription?.(value) ?? null,\n })\n }\n }\n return map\n }, [asyncOptions, resolveDescription, resolveLabel, staticOptions, value])\n\n const availableOptions = React.useMemo(() => {\n return Array.from(optionMap.values())\n }, [optionMap])\n\n const filteredSuggestions = React.useMemo(() => {\n const query = input.toLowerCase().trim()\n if (!query) return availableOptions\n return availableOptions.filter((option) => {\n const labelMatch = option.label.toLowerCase().includes(query)\n const descMatch = option.description?.toLowerCase().includes(query)\n return labelMatch || Boolean(descMatch)\n })\n }, [availableOptions, input])\n\n React.useEffect(() => {\n if (!loadSuggestions || !touched || disabled) return\n const query = input.trim()\n let cancelled = false\n const handle = window.setTimeout(async () => {\n setLoading(true)\n try {\n const items = await loadSuggestions(query)\n if (!cancelled) {\n setAsyncOptions(normalizeOptions(items))\n }\n } finally {\n if (!cancelled) setLoading(false)\n }\n }, 200)\n return () => {\n cancelled = true\n window.clearTimeout(handle)\n }\n }, [disabled, input, loadSuggestions, touched])\n\n // Sync input with value when value changes externally and input is not focused\n React.useEffect(() => {\n if (document.activeElement !== inputRef.current) {\n const option = optionMap.get(value)\n setInput(option?.label ?? value ?? '')\n }\n }, [value, optionMap])\n\n const selectValue = React.useCallback(\n (nextValue: string) => {\n if (disabled) return\n const trimmed = nextValue.trim()\n onChange(trimmed)\n const option = optionMap.get(trimmed)\n setInput(option?.label ?? trimmed)\n setShowSuggestions(false)\n setSelectedIndex(-1)\n },\n [disabled, onChange, optionMap]\n )\n\n const findOptionForInput = React.useCallback(\n (raw: string): ComboboxOption | null => {\n const query = raw.trim().toLowerCase()\n if (!query) return null\n for (const option of optionMap.values()) {\n if (option.value === raw.trim()) return option\n if (option.label.toLowerCase() === query) return option\n }\n return null\n },\n [optionMap]\n )\n\n const confirmSelection = React.useCallback(\n (raw: string) => {\n if (disabled) return\n const option = findOptionForInput(raw)\n if (option) {\n selectValue(option.value)\n return\n }\n if (!allowCustomValues) {\n // Revert to current value if custom values not allowed\n const currentOption = optionMap.get(value)\n setInput(currentOption?.label ?? value ?? '')\n setShowSuggestions(false)\n return\n }\n selectValue(raw)\n },\n [allowCustomValues, disabled, findOptionForInput, optionMap, selectValue, value]\n )\n\n const handleKeyDown = React.useCallback(\n (event: React.KeyboardEvent<HTMLInputElement>) => {\n if (disabled) return\n\n if (event.key === 'ArrowDown') {\n event.preventDefault()\n if (!showSuggestions) {\n setShowSuggestions(true)\n setSelectedIndex(0)\n } else {\n setSelectedIndex((prev) => Math.min(prev + 1, filteredSuggestions.length - 1))\n }\n } else if (event.key === 'ArrowUp') {\n event.preventDefault()\n setSelectedIndex((prev) => Math.max(prev - 1, -1))\n } else if (event.key === 'Enter') {\n event.preventDefault()\n if (selectedIndex >= 0 && filteredSuggestions[selectedIndex]) {\n selectValue(filteredSuggestions[selectedIndex].value)\n } else {\n confirmSelection(input)\n }\n } else if (event.key === 'Escape') {\n event.preventDefault()\n setShowSuggestions(false)\n setSelectedIndex(-1)\n }\n },\n [confirmSelection, disabled, filteredSuggestions, input, selectValue, selectedIndex, showSuggestions]\n )\n\n return (\n <div className=\"relative w-full\">\n {/* Use raw <input> here instead of the DS Input primitive: ComboboxInput's\n focus / suggestions-popup interplay relies on the trigger being a plain\n input element. The DS wrapper introduces a <div> that desyncs autocomplete\n on this specific surface. Keeps the rest of the form on Input primitive. */}\n <input\n ref={inputRef}\n type=\"text\"\n className=\"w-full h-9 rounded border px-2 text-sm disabled:bg-muted disabled:text-muted-foreground disabled:cursor-not-allowed\"\n value={input}\n placeholder={placeholder || 'Type to search...'}\n autoFocus={autoFocus}\n data-crud-focus-target=\"\"\n disabled={disabled}\n onFocus={() => {\n setTouched(true)\n if (suppressOpenOnFocusRef.current) {\n suppressOpenOnFocusRef.current = false\n return\n }\n setShowSuggestions(true)\n }}\n onChange={(event) => {\n setTouched(true)\n setInput(event.target.value)\n setShowSuggestions(true)\n setSelectedIndex(-1)\n }}\n onKeyDown={handleKeyDown}\n onBlur={() => {\n // Delay to allow click on suggestions\n setTimeout(() => {\n if (disabled) return\n confirmSelection(input)\n }, 200)\n }}\n />\n\n {showSuggestions && !disabled && (loading || filteredSuggestions.length > 0) && (\n <div className=\"absolute z-popover w-full mt-1 rounded border bg-popover shadow-lg max-h-48 sm:max-h-60 overflow-auto\">\n {loading && touched ? (\n <div className=\"px-3 py-2 text-xs text-muted-foreground\">Loading suggestions\u2026</div>\n ) : (\n filteredSuggestions.map((option, index) => (\n <Button\n key={option.value}\n type=\"button\"\n variant=\"ghost\"\n size=\"sm\"\n className={[\n 'w-full h-auto justify-start font-normal text-left flex flex-col items-start px-3 py-2',\n index === selectedIndex ? 'bg-accent' : '',\n ]\n .filter(Boolean)\n .join(' ')}\n onMouseDown={(event) => event.preventDefault()}\n onClick={() => selectValue(option.value)}\n onMouseEnter={() => setSelectedIndex(index)}\n >\n <span className=\"font-medium\">{option.label}</span>\n {option.description ? (\n <span className=\"text-xs text-muted-foreground\">{option.description}</span>\n ) : null}\n </Button>\n ))\n )}\n </div>\n )}\n </div>\n )\n}\n"],
|
|
5
|
+
"mappings": ";AAuNM,cAuCQ,YAvCR;AArNN,YAAY,WAAW;AACvB,SAAS,cAAc;AAqBvB,SAAS,iBAAiB,OAA0D;AAClF,MAAI,CAAC,MAAM,QAAQ,KAAK,EAAG,QAAO,CAAC;AACnC,SAAO,MACJ,IAAI,CAAC,WAAW;AACf,QAAI,OAAO,WAAW,UAAU;AAC9B,YAAM,UAAU,OAAO,KAAK;AAC5B,UAAI,CAAC,QAAS,QAAO;AACrB,aAAO,EAAE,OAAO,SAAS,OAAO,QAAQ;AAAA,IAC1C;AACA,UAAM,QAAQ,OAAO,OAAO,UAAU,WAAW,OAAO,MAAM,KAAK,IAAI;AACvE,QAAI,CAAC,MAAO,QAAO;AACnB,WAAO;AAAA,MACL;AAAA,MACA,OAAO,OAAO,OAAO,KAAK,KAAK;AAAA,MAC/B,aAAa,OAAO,eAAe;AAAA,IACrC;AAAA,EACF,CAAC,EACA,OAAO,CAAC,WAAqC,CAAC,CAAC,MAAM;AAC1D;AAEO,SAAS,cAAc;AAAA,EAC5B;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,WAAW;AAAA,EACX,oBAAoB;AACtB,GAAuB;AACrB,QAAM,CAAC,OAAO,QAAQ,IAAI,MAAM,SAAS,EAAE;AAC3C,QAAM,CAAC,cAAc,eAAe,IAAI,MAAM,SAA2B,CAAC,CAAC;AAC3E,QAAM,CAAC,SAAS,UAAU,IAAI,MAAM,SAAS,KAAK;AAClD,QAAM,CAAC,SAAS,UAAU,IAAI,MAAM,SAAS,KAAK;AAClD,QAAM,CAAC,iBAAiB,kBAAkB,IAAI,MAAM,SAAS,KAAK;AAClE,QAAM,CAAC,eAAe,gBAAgB,IAAI,MAAM,SAAS,EAAE;AAC3D,QAAM,WAAW,MAAM,OAAyB,IAAI;AACpD,QAAM,yBAAyB,MAAM,OAAO,QAAQ,aAAa,CAAC,QAAQ,CAAC;AAE3E,QAAM,gBAAgB,MAAM,QAAQ,MAAM,iBAAiB,WAAW,GAAG,CAAC,WAAW,CAAC;AAEtF,QAAM,YAAY,MAAM,QAAQ,MAAM;AACpC,UAAM,MAAM,oBAAI,IAA4B;AAC5C,UAAM,WAAW,CAAC,WAA2B;AAC3C,UAAI,CAAC,IAAI,IAAI,OAAO,KAAK,GAAG;AAC1B,YAAI,IAAI,OAAO,OAAO,MAAM;AAAA,MAC9B;AAAA,IACF;AACA,kBAAc,QAAQ,QAAQ;AAC9B,iBAAa,QAAQ,QAAQ;AAC7B,QAAI,OAAO;AACT,YAAM,WAAW,IAAI,IAAI,KAAK;AAC9B,UAAI,CAAC,UAAU;AACb,YAAI,IAAI,OAAO;AAAA,UACb;AAAA,UACA,OAAO,eAAe,KAAK,KAAK;AAAA,UAChC,aAAa,qBAAqB,KAAK,KAAK;AAAA,QAC9C,CAAC;AAAA,MACH;AAAA,IACF;AACA,WAAO;AAAA,EACT,GAAG,CAAC,cAAc,oBAAoB,cAAc,eAAe,KAAK,CAAC;AAEzE,QAAM,mBAAmB,MAAM,QAAQ,MAAM;AAC3C,WAAO,MAAM,KAAK,UAAU,OAAO,CAAC;AAAA,EACtC,GAAG,CAAC,SAAS,CAAC;AAEd,QAAM,sBAAsB,MAAM,QAAQ,MAAM;AAC9C,UAAM,QAAQ,MAAM,YAAY,EAAE,KAAK;AACvC,QAAI,CAAC,MAAO,QAAO;AACnB,WAAO,iBAAiB,OAAO,CAAC,WAAW;AACzC,YAAM,aAAa,OAAO,MAAM,YAAY,EAAE,SAAS,KAAK;AAC5D,YAAM,YAAY,OAAO,aAAa,YAAY,EAAE,SAAS,KAAK;AAClE,aAAO,cAAc,QAAQ,SAAS;AAAA,IACxC,CAAC;AAAA,EACH,GAAG,CAAC,kBAAkB,KAAK,CAAC;AAE5B,QAAM,UAAU,MAAM;AACpB,QAAI,CAAC,mBAAmB,CAAC,WAAW,SAAU;AAC9C,UAAM,QAAQ,MAAM,KAAK;AACzB,QAAI,YAAY;AAChB,UAAM,SAAS,OAAO,WAAW,YAAY;AAC3C,iBAAW,IAAI;AACf,UAAI;AACF,cAAM,QAAQ,MAAM,gBAAgB,KAAK;AACzC,YAAI,CAAC,WAAW;AACd,0BAAgB,iBAAiB,KAAK,CAAC;AAAA,QACzC;AAAA,MACF,UAAE;AACA,YAAI,CAAC,UAAW,YAAW,KAAK;AAAA,MAClC;AAAA,IACF,GAAG,GAAG;AACN,WAAO,MAAM;AACX,kBAAY;AACZ,aAAO,aAAa,MAAM;AAAA,IAC5B;AAAA,EACF,GAAG,CAAC,UAAU,OAAO,iBAAiB,OAAO,CAAC;AAG9C,QAAM,UAAU,MAAM;AACpB,QAAI,SAAS,kBAAkB,SAAS,SAAS;AAC/C,YAAM,SAAS,UAAU,IAAI,KAAK;AAClC,eAAS,QAAQ,SAAS,SAAS,EAAE;AAAA,IACvC;AAAA,EACF,GAAG,CAAC,OAAO,SAAS,CAAC;AAErB,QAAM,cAAc,MAAM;AAAA,IACxB,CAAC,cAAsB;AACrB,UAAI,SAAU;AACd,YAAM,UAAU,UAAU,KAAK;AAC/B,eAAS,OAAO;AAChB,YAAM,SAAS,UAAU,IAAI,OAAO;AACpC,eAAS,QAAQ,SAAS,OAAO;AACjC,yBAAmB,KAAK;AACxB,uBAAiB,EAAE;AAAA,IACrB;AAAA,IACA,CAAC,UAAU,UAAU,SAAS;AAAA,EAChC;AAEA,QAAM,qBAAqB,MAAM;AAAA,IAC/B,CAAC,QAAuC;AACtC,YAAM,QAAQ,IAAI,KAAK,EAAE,YAAY;AACrC,UAAI,CAAC,MAAO,QAAO;AACnB,iBAAW,UAAU,UAAU,OAAO,GAAG;AACvC,YAAI,OAAO,UAAU,IAAI,KAAK,EAAG,QAAO;AACxC,YAAI,OAAO,MAAM,YAAY,MAAM,MAAO,QAAO;AAAA,MACnD;AACA,aAAO;AAAA,IACT;AAAA,IACA,CAAC,SAAS;AAAA,EACZ;AAEA,QAAM,mBAAmB,MAAM;AAAA,IAC7B,CAAC,QAAgB;AACf,UAAI,SAAU;AACd,YAAM,SAAS,mBAAmB,GAAG;AACrC,UAAI,QAAQ;AACV,oBAAY,OAAO,KAAK;AACxB;AAAA,MACF;AACA,UAAI,CAAC,mBAAmB;AAEtB,cAAM,gBAAgB,UAAU,IAAI,KAAK;AACzC,iBAAS,eAAe,SAAS,SAAS,EAAE;AAC5C,2BAAmB,KAAK;AACxB;AAAA,MACF;AACA,kBAAY,GAAG;AAAA,IACjB;AAAA,IACA,CAAC,mBAAmB,UAAU,oBAAoB,WAAW,aAAa,KAAK;AAAA,EACjF;AAEA,QAAM,gBAAgB,MAAM;AAAA,IAC1B,CAAC,UAAiD;AAChD,UAAI,SAAU;AAEd,UAAI,MAAM,QAAQ,aAAa;AAC7B,cAAM,eAAe;AACrB,YAAI,CAAC,iBAAiB;AACpB,6BAAmB,IAAI;AACvB,2BAAiB,CAAC;AAAA,QACpB,OAAO;AACL,2BAAiB,CAAC,SAAS,KAAK,IAAI,OAAO,GAAG,oBAAoB,SAAS,CAAC,CAAC;AAAA,QAC/E;AAAA,MACF,WAAW,MAAM,QAAQ,WAAW;AAClC,cAAM,eAAe;AACrB,yBAAiB,CAAC,SAAS,KAAK,IAAI,OAAO,GAAG,EAAE,CAAC;AAAA,MACnD,WAAW,MAAM,QAAQ,SAAS;AAChC,cAAM,eAAe;AACrB,YAAI,iBAAiB,KAAK,oBAAoB,aAAa,GAAG;AAC5D,sBAAY,oBAAoB,aAAa,EAAE,KAAK;AAAA,QACtD,OAAO;AACL,2BAAiB,KAAK;AAAA,QACxB;AAAA,MACF,WAAW,MAAM,QAAQ,UAAU;AACjC,cAAM,eAAe;AACrB,2BAAmB,KAAK;AACxB,yBAAiB,EAAE;AAAA,MACrB;AAAA,IACF;AAAA,IACA,CAAC,kBAAkB,UAAU,qBAAqB,OAAO,aAAa,eAAe,eAAe;AAAA,EACtG;AAEA,SACE,qBAAC,SAAI,WAAU,mBAKb;AAAA;AAAA,MAAC;AAAA;AAAA,QACC,KAAK;AAAA,QACL,MAAK;AAAA,QACL,WAAU;AAAA,QACV,OAAO;AAAA,QACP,aAAa,eAAe;AAAA,QAC5B;AAAA,QACA,0BAAuB;AAAA,QACvB;AAAA,QACA,SAAS,MAAM;AACb,qBAAW,IAAI;AACf,cAAI,uBAAuB,SAAS;AAClC,mCAAuB,UAAU;AACjC;AAAA,UACF;AACA,6BAAmB,IAAI;AAAA,QACzB;AAAA,QACA,UAAU,CAAC,UAAU;AACnB,qBAAW,IAAI;AACf,mBAAS,MAAM,OAAO,KAAK;AAC3B,6BAAmB,IAAI;AACvB,2BAAiB,EAAE;AAAA,QACrB;AAAA,QACA,WAAW;AAAA,QACX,QAAQ,MAAM;AAEZ,qBAAW,MAAM;AACf,gBAAI,SAAU;AACd,6BAAiB,KAAK;AAAA,UACxB,GAAG,GAAG;AAAA,QACR;AAAA;AAAA,IACF;AAAA,IAEC,mBAAmB,CAAC,aAAa,WAAW,oBAAoB,SAAS,MACxE,oBAAC,SAAI,WAAU,yGACZ,qBAAW,UACV,oBAAC,SAAI,WAAU,2CAA0C,uCAAoB,IAE7E,oBAAoB,IAAI,CAAC,QAAQ,UAC/B;AAAA,MAAC;AAAA;AAAA,QAEC,MAAK;AAAA,QACL,SAAQ;AAAA,QACR,MAAK;AAAA,QACL,WAAW;AAAA,UACT;AAAA,UACA,UAAU,gBAAgB,cAAc;AAAA,QAC1C,EACG,OAAO,OAAO,EACd,KAAK,GAAG;AAAA,QACX,aAAa,CAAC,UAAU,MAAM,eAAe;AAAA,QAC7C,SAAS,MAAM,YAAY,OAAO,KAAK;AAAA,QACvC,cAAc,MAAM,iBAAiB,KAAK;AAAA,QAE1C;AAAA,8BAAC,UAAK,WAAU,eAAe,iBAAO,OAAM;AAAA,UAC3C,OAAO,cACN,oBAAC,UAAK,WAAU,iCAAiC,iBAAO,aAAY,IAClE;AAAA;AAAA;AAAA,MAjBC,OAAO;AAAA,IAkBd,CACD,GAEL;AAAA,KAEJ;AAEJ;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -33,7 +33,8 @@ function TagsInput({
|
|
|
33
33
|
autoFocus,
|
|
34
34
|
disabled = false,
|
|
35
35
|
allowCustomValues = true,
|
|
36
|
-
showSuggestionsOnFocus = true
|
|
36
|
+
showSuggestionsOnFocus = true,
|
|
37
|
+
suppressInitialSuggestionsOnFocus = false
|
|
37
38
|
}) {
|
|
38
39
|
const t = useT();
|
|
39
40
|
const [input, setInput] = React.useState("");
|
|
@@ -41,10 +42,14 @@ function TagsInput({
|
|
|
41
42
|
const [loading, setLoading] = React.useState(false);
|
|
42
43
|
const [touched, setTouched] = React.useState(false);
|
|
43
44
|
const suppressBlurCommitRef = React.useRef(false);
|
|
45
|
+
const suppressSuggestionsOnFocusRef = React.useRef(Boolean(autoFocus && suppressInitialSuggestionsOnFocus && !disabled));
|
|
44
46
|
const valueRef = React.useRef(value);
|
|
45
47
|
React.useEffect(() => {
|
|
46
48
|
valueRef.current = value;
|
|
47
49
|
}, [value]);
|
|
50
|
+
React.useEffect(() => {
|
|
51
|
+
suppressSuggestionsOnFocusRef.current = Boolean(autoFocus && suppressInitialSuggestionsOnFocus && !disabled);
|
|
52
|
+
}, [autoFocus, disabled, suppressInitialSuggestionsOnFocus]);
|
|
48
53
|
const staticOptions = React.useMemo(() => normalizeOptions(suggestions), [suggestions]);
|
|
49
54
|
const selectedOptionList = React.useMemo(
|
|
50
55
|
() => normalizeOptions(selectedOptions),
|
|
@@ -191,6 +196,10 @@ function TagsInput({
|
|
|
191
196
|
"data-crud-focus-target": "",
|
|
192
197
|
disabled,
|
|
193
198
|
onFocus: () => {
|
|
199
|
+
if (suppressSuggestionsOnFocusRef.current) {
|
|
200
|
+
suppressSuggestionsOnFocusRef.current = false;
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
194
203
|
if (showSuggestionsOnFocus) {
|
|
195
204
|
setTouched(true);
|
|
196
205
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/backend/inputs/TagsInput.tsx"],
|
|
4
|
-
"sourcesContent": ["\"use client\"\n\nimport * as React from 'react'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport { Button } from '../../primitives/button'\nimport { IconButton } from '../../primitives/icon-button'\n\nexport type TagsInputOption = {\n value: string\n label: string\n description?: string | null\n}\n\nexport type TagsInputProps = {\n value: string[]\n onChange: (next: string[]) => void\n placeholder?: string\n suggestions?: Array<string | TagsInputOption>\n loadSuggestions?: (query?: string) => Promise<Array<string | TagsInputOption>>\n selectedOptions?: TagsInputOption[]\n resolveLabel?: (value: string) => string\n resolveDescription?: (value: string) => string | null | undefined\n autoFocus?: boolean\n disabled?: boolean\n allowCustomValues?: boolean\n showSuggestionsOnFocus?: boolean\n}\n\nfunction normalizeOptions(input?: Array<string | TagsInputOption>): TagsInputOption[] {\n if (!Array.isArray(input)) return []\n return input\n .map((option) => {\n if (typeof option === 'string') {\n const trimmed = option.trim()\n if (!trimmed) return null\n return { value: trimmed, label: trimmed }\n }\n const value = typeof option.value === 'string' ? option.value.trim() : ''\n if (!value) return null\n return {\n value,\n label: option.label?.trim() || value,\n description: option.description ?? null,\n }\n })\n .filter((option): option is TagsInputOption => !!option)\n}\n\nexport function TagsInput({\n value,\n onChange,\n placeholder,\n suggestions,\n loadSuggestions,\n selectedOptions,\n resolveLabel,\n resolveDescription,\n autoFocus,\n disabled = false,\n allowCustomValues = true,\n showSuggestionsOnFocus = true,\n}: TagsInputProps) {\n const t = useT()\n const [input, setInput] = React.useState('')\n const [asyncOptions, setAsyncOptions] = React.useState<TagsInputOption[]>([])\n const [loading, setLoading] = React.useState(false)\n const [touched, setTouched] = React.useState(false)\n const suppressBlurCommitRef = React.useRef(false)\n const valueRef = React.useRef(value)\n\n React.useEffect(() => {\n valueRef.current = value\n }, [value])\n\n const staticOptions = React.useMemo(() => normalizeOptions(suggestions), [suggestions])\n const selectedOptionList = React.useMemo(\n () => normalizeOptions(selectedOptions),\n [selectedOptions]\n )\n\n const optionMap = React.useMemo(() => {\n const map = new Map<string, TagsInputOption>()\n const register = (option: TagsInputOption) => {\n if (!map.has(option.value)) {\n map.set(option.value, option)\n }\n }\n staticOptions.forEach(register)\n asyncOptions.forEach(register)\n selectedOptionList.forEach(register)\n value.forEach((val) => {\n if (map.has(val)) return\n map.set(val, {\n value: val,\n label: resolveLabel?.(val) ?? val,\n description: resolveDescription?.(val) ?? null,\n })\n })\n return map\n }, [asyncOptions, resolveDescription, resolveLabel, selectedOptionList, staticOptions, value])\n\n const availableOptions = React.useMemo(() => {\n return Array.from(optionMap.values()).filter((option) => !value.includes(option.value))\n }, [optionMap, value])\n\n const filteredSuggestions = React.useMemo(() => {\n const query = input.toLowerCase().trim()\n if (!query) return availableOptions.slice(0, 8)\n return availableOptions.filter((option) => {\n const labelMatch = option.label.toLowerCase().includes(query)\n const descMatch = option.description?.toLowerCase().includes(query)\n return labelMatch || Boolean(descMatch)\n })\n }, [availableOptions, input])\n\n React.useEffect(() => {\n if (!loadSuggestions || !touched || disabled) return\n const query = input.trim()\n let cancelled = false\n const handle = window.setTimeout(async () => {\n setLoading(true)\n try {\n const items = await loadSuggestions(query)\n if (!cancelled) {\n setAsyncOptions(normalizeOptions(items))\n }\n } finally {\n if (!cancelled) setLoading(false)\n }\n }, 200)\n return () => {\n cancelled = true\n window.clearTimeout(handle)\n }\n }, [disabled, input, loadSuggestions, touched])\n\n const addValue = React.useCallback(\n (nextValue: string) => {\n if (disabled) return\n const trimmed = nextValue.trim()\n if (!trimmed) return\n const currentValue = valueRef.current\n if (currentValue.includes(trimmed)) return\n const next = [...currentValue, trimmed]\n valueRef.current = next\n onChange(next)\n },\n [disabled, onChange]\n )\n\n const findOptionForInput = React.useCallback(\n (raw: string): TagsInputOption | null => {\n const query = raw.trim().toLowerCase()\n if (!query) return null\n for (const option of optionMap.values()) {\n if (option.value === raw.trim()) return option\n if (option.label.toLowerCase() === query) return option\n }\n return null\n },\n [optionMap]\n )\n\n const addTag = React.useCallback(\n (raw: string) => {\n if (disabled) return\n const option = findOptionForInput(raw)\n if (option) {\n addValue(option.value)\n return\n }\n if (!allowCustomValues) return\n addValue(raw)\n },\n [addValue, allowCustomValues, disabled, findOptionForInput]\n )\n\n const removeTag = React.useCallback(\n (tag: string) => {\n if (disabled) return\n const next = valueRef.current.filter((candidate) => candidate !== tag)\n valueRef.current = next\n onChange(next)\n },\n [disabled, onChange]\n )\n\n return (\n <div\n className={[\n 'w-full rounded border px-2 py-1',\n disabled ? 'bg-muted text-muted-foreground/80 cursor-not-allowed' : '',\n ]\n .filter(Boolean)\n .join(' ')}\n aria-disabled={disabled || undefined}\n >\n <div className=\"flex flex-wrap gap-1\">\n {value.map((tag) => {\n const option = optionMap.get(tag)\n const label = option?.label ?? tag\n const description = option?.description\n return (\n <span key={tag} className=\"inline-flex items-center gap-2 rounded-sm bg-muted px-2 py-0.5 text-xs\">\n <span className=\"flex flex-col items-start leading-tight\">\n <span className=\"whitespace-nowrap\">{label}</span>\n {description ? (\n <span className=\"text-overline text-muted-foreground\">{description}</span>\n ) : null}\n </span>\n <IconButton\n type=\"button\"\n variant=\"ghost\"\n size=\"xs\"\n className=\"opacity-60 hover:opacity-100\"\n onClick={() => removeTag(tag)}\n disabled={disabled}\n >\n \u00D7\n </IconButton>\n </span>\n )\n })}\n <input\n className=\"flex-1 min-w-[80px] sm:min-w-[120px] border-0 py-1 text-sm outline-none disabled:bg-transparent\"\n value={input}\n placeholder={placeholder || t('ui.inputs.tagsInput.placeholder', 'Add tag and press Enter')}\n autoFocus={autoFocus}\n data-crud-focus-target=\"\"\n disabled={disabled}\n onFocus={() => {\n if (showSuggestionsOnFocus) {\n setTouched(true)\n }\n }}\n onMouseDown={() => {\n setTouched(true)\n }}\n onChange={(event) => {\n setTouched(true)\n setInput(event.target.value)\n }}\n onKeyDown={(event) => {\n if (disabled) return\n if (event.key === 'Enter' || event.key === ',') {\n event.preventDefault()\n addTag(input)\n setInput('')\n } else if (event.key === 'Backspace' && input === '' && value.length > 0) {\n removeTag(value[value.length - 1])\n }\n }}\n onBlur={() => {\n if (disabled) return\n if (suppressBlurCommitRef.current) {\n suppressBlurCommitRef.current = false\n setInput('')\n return\n }\n addTag(input)\n setInput('')\n }}\n />\n {loading && touched ? (\n <div className=\"basis-full mt-1 text-xs text-muted-foreground\">Loading suggestions\u2026</div>\n ) : null}\n {!loading && filteredSuggestions.length ? (\n <div className=\"basis-full mt-1 flex flex-col gap-1\">\n {filteredSuggestions.map((option) => (\n <Button\n key={option.value}\n type=\"button\"\n variant=\"ghost\"\n size=\"sm\"\n className=\"w-full justify-start font-normal flex flex-col items-start text-xs px-1.5 py-1\"\n onMouseDown={(event) => {\n suppressBlurCommitRef.current = true\n event.preventDefault()\n }}\n onClick={() => {\n suppressBlurCommitRef.current = false\n addValue(option.value)\n setInput('')\n }}\n >\n <span>{option.label}</span>\n {option.description ? (\n <span className=\"text-overline text-muted-foreground\">{option.description}</span>\n ) : null}\n </Button>\n ))}\n </div>\n ) : null}\n </div>\n </div>\n )\n}\n"],
|
|
5
|
-
"mappings": ";
|
|
4
|
+
"sourcesContent": ["\"use client\"\n\nimport * as React from 'react'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport { Button } from '../../primitives/button'\nimport { IconButton } from '../../primitives/icon-button'\n\nexport type TagsInputOption = {\n value: string\n label: string\n description?: string | null\n}\n\nexport type TagsInputProps = {\n value: string[]\n onChange: (next: string[]) => void\n placeholder?: string\n suggestions?: Array<string | TagsInputOption>\n loadSuggestions?: (query?: string) => Promise<Array<string | TagsInputOption>>\n selectedOptions?: TagsInputOption[]\n resolveLabel?: (value: string) => string\n resolveDescription?: (value: string) => string | null | undefined\n autoFocus?: boolean\n disabled?: boolean\n allowCustomValues?: boolean\n showSuggestionsOnFocus?: boolean\n suppressInitialSuggestionsOnFocus?: boolean\n}\n\nfunction normalizeOptions(input?: Array<string | TagsInputOption>): TagsInputOption[] {\n if (!Array.isArray(input)) return []\n return input\n .map((option) => {\n if (typeof option === 'string') {\n const trimmed = option.trim()\n if (!trimmed) return null\n return { value: trimmed, label: trimmed }\n }\n const value = typeof option.value === 'string' ? option.value.trim() : ''\n if (!value) return null\n return {\n value,\n label: option.label?.trim() || value,\n description: option.description ?? null,\n }\n })\n .filter((option): option is TagsInputOption => !!option)\n}\n\nexport function TagsInput({\n value,\n onChange,\n placeholder,\n suggestions,\n loadSuggestions,\n selectedOptions,\n resolveLabel,\n resolveDescription,\n autoFocus,\n disabled = false,\n allowCustomValues = true,\n showSuggestionsOnFocus = true,\n suppressInitialSuggestionsOnFocus = false,\n}: TagsInputProps) {\n const t = useT()\n const [input, setInput] = React.useState('')\n const [asyncOptions, setAsyncOptions] = React.useState<TagsInputOption[]>([])\n const [loading, setLoading] = React.useState(false)\n const [touched, setTouched] = React.useState(false)\n const suppressBlurCommitRef = React.useRef(false)\n const suppressSuggestionsOnFocusRef = React.useRef(Boolean(autoFocus && suppressInitialSuggestionsOnFocus && !disabled))\n const valueRef = React.useRef(value)\n\n React.useEffect(() => {\n valueRef.current = value\n }, [value])\n\n React.useEffect(() => {\n suppressSuggestionsOnFocusRef.current = Boolean(autoFocus && suppressInitialSuggestionsOnFocus && !disabled)\n }, [autoFocus, disabled, suppressInitialSuggestionsOnFocus])\n\n const staticOptions = React.useMemo(() => normalizeOptions(suggestions), [suggestions])\n const selectedOptionList = React.useMemo(\n () => normalizeOptions(selectedOptions),\n [selectedOptions]\n )\n\n const optionMap = React.useMemo(() => {\n const map = new Map<string, TagsInputOption>()\n const register = (option: TagsInputOption) => {\n if (!map.has(option.value)) {\n map.set(option.value, option)\n }\n }\n staticOptions.forEach(register)\n asyncOptions.forEach(register)\n selectedOptionList.forEach(register)\n value.forEach((val) => {\n if (map.has(val)) return\n map.set(val, {\n value: val,\n label: resolveLabel?.(val) ?? val,\n description: resolveDescription?.(val) ?? null,\n })\n })\n return map\n }, [asyncOptions, resolveDescription, resolveLabel, selectedOptionList, staticOptions, value])\n\n const availableOptions = React.useMemo(() => {\n return Array.from(optionMap.values()).filter((option) => !value.includes(option.value))\n }, [optionMap, value])\n\n const filteredSuggestions = React.useMemo(() => {\n const query = input.toLowerCase().trim()\n if (!query) return availableOptions.slice(0, 8)\n return availableOptions.filter((option) => {\n const labelMatch = option.label.toLowerCase().includes(query)\n const descMatch = option.description?.toLowerCase().includes(query)\n return labelMatch || Boolean(descMatch)\n })\n }, [availableOptions, input])\n\n React.useEffect(() => {\n if (!loadSuggestions || !touched || disabled) return\n const query = input.trim()\n let cancelled = false\n const handle = window.setTimeout(async () => {\n setLoading(true)\n try {\n const items = await loadSuggestions(query)\n if (!cancelled) {\n setAsyncOptions(normalizeOptions(items))\n }\n } finally {\n if (!cancelled) setLoading(false)\n }\n }, 200)\n return () => {\n cancelled = true\n window.clearTimeout(handle)\n }\n }, [disabled, input, loadSuggestions, touched])\n\n const addValue = React.useCallback(\n (nextValue: string) => {\n if (disabled) return\n const trimmed = nextValue.trim()\n if (!trimmed) return\n const currentValue = valueRef.current\n if (currentValue.includes(trimmed)) return\n const next = [...currentValue, trimmed]\n valueRef.current = next\n onChange(next)\n },\n [disabled, onChange]\n )\n\n const findOptionForInput = React.useCallback(\n (raw: string): TagsInputOption | null => {\n const query = raw.trim().toLowerCase()\n if (!query) return null\n for (const option of optionMap.values()) {\n if (option.value === raw.trim()) return option\n if (option.label.toLowerCase() === query) return option\n }\n return null\n },\n [optionMap]\n )\n\n const addTag = React.useCallback(\n (raw: string) => {\n if (disabled) return\n const option = findOptionForInput(raw)\n if (option) {\n addValue(option.value)\n return\n }\n if (!allowCustomValues) return\n addValue(raw)\n },\n [addValue, allowCustomValues, disabled, findOptionForInput]\n )\n\n const removeTag = React.useCallback(\n (tag: string) => {\n if (disabled) return\n const next = valueRef.current.filter((candidate) => candidate !== tag)\n valueRef.current = next\n onChange(next)\n },\n [disabled, onChange]\n )\n\n return (\n <div\n className={[\n 'w-full rounded border px-2 py-1',\n disabled ? 'bg-muted text-muted-foreground/80 cursor-not-allowed' : '',\n ]\n .filter(Boolean)\n .join(' ')}\n aria-disabled={disabled || undefined}\n >\n <div className=\"flex flex-wrap gap-1\">\n {value.map((tag) => {\n const option = optionMap.get(tag)\n const label = option?.label ?? tag\n const description = option?.description\n return (\n <span key={tag} className=\"inline-flex items-center gap-2 rounded-sm bg-muted px-2 py-0.5 text-xs\">\n <span className=\"flex flex-col items-start leading-tight\">\n <span className=\"whitespace-nowrap\">{label}</span>\n {description ? (\n <span className=\"text-overline text-muted-foreground\">{description}</span>\n ) : null}\n </span>\n <IconButton\n type=\"button\"\n variant=\"ghost\"\n size=\"xs\"\n className=\"opacity-60 hover:opacity-100\"\n onClick={() => removeTag(tag)}\n disabled={disabled}\n >\n \u00D7\n </IconButton>\n </span>\n )\n })}\n <input\n className=\"flex-1 min-w-[80px] sm:min-w-[120px] border-0 py-1 text-sm outline-none disabled:bg-transparent\"\n value={input}\n placeholder={placeholder || t('ui.inputs.tagsInput.placeholder', 'Add tag and press Enter')}\n autoFocus={autoFocus}\n data-crud-focus-target=\"\"\n disabled={disabled}\n onFocus={() => {\n if (suppressSuggestionsOnFocusRef.current) {\n suppressSuggestionsOnFocusRef.current = false\n return\n }\n if (showSuggestionsOnFocus) {\n setTouched(true)\n }\n }}\n onMouseDown={() => {\n setTouched(true)\n }}\n onChange={(event) => {\n setTouched(true)\n setInput(event.target.value)\n }}\n onKeyDown={(event) => {\n if (disabled) return\n if (event.key === 'Enter' || event.key === ',') {\n event.preventDefault()\n addTag(input)\n setInput('')\n } else if (event.key === 'Backspace' && input === '' && value.length > 0) {\n removeTag(value[value.length - 1])\n }\n }}\n onBlur={() => {\n if (disabled) return\n if (suppressBlurCommitRef.current) {\n suppressBlurCommitRef.current = false\n setInput('')\n return\n }\n addTag(input)\n setInput('')\n }}\n />\n {loading && touched ? (\n <div className=\"basis-full mt-1 text-xs text-muted-foreground\">Loading suggestions\u2026</div>\n ) : null}\n {!loading && filteredSuggestions.length ? (\n <div className=\"basis-full mt-1 flex flex-col gap-1\">\n {filteredSuggestions.map((option) => (\n <Button\n key={option.value}\n type=\"button\"\n variant=\"ghost\"\n size=\"sm\"\n className=\"w-full justify-start font-normal flex flex-col items-start text-xs px-1.5 py-1\"\n onMouseDown={(event) => {\n suppressBlurCommitRef.current = true\n event.preventDefault()\n }}\n onClick={() => {\n suppressBlurCommitRef.current = false\n addValue(option.value)\n setInput('')\n }}\n >\n <span>{option.label}</span>\n {option.description ? (\n <span className=\"text-overline text-muted-foreground\">{option.description}</span>\n ) : null}\n </Button>\n ))}\n </div>\n ) : null}\n </div>\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.1-develop.
|
|
3
|
+
"version": "0.6.1-develop.3191.1.2511ae315f",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"scripts": {
|
|
@@ -140,12 +140,12 @@
|
|
|
140
140
|
"remark-gfm": "^4.0.1"
|
|
141
141
|
},
|
|
142
142
|
"peerDependencies": {
|
|
143
|
-
"@open-mercato/shared": "0.6.1-develop.
|
|
143
|
+
"@open-mercato/shared": "0.6.1-develop.3191.1.2511ae315f",
|
|
144
144
|
"react": ">=18.0.0",
|
|
145
145
|
"react-dom": ">=18.0.0"
|
|
146
146
|
},
|
|
147
147
|
"devDependencies": {
|
|
148
|
-
"@open-mercato/shared": "0.6.1-develop.
|
|
148
|
+
"@open-mercato/shared": "0.6.1-develop.3191.1.2511ae315f",
|
|
149
149
|
"@testing-library/dom": "^10.4.1",
|
|
150
150
|
"@testing-library/jest-dom": "^6.9.1",
|
|
151
151
|
"@testing-library/react": "^16.3.1",
|
package/src/backend/CrudForm.tsx
CHANGED
|
@@ -268,6 +268,12 @@ export type CrudFormProps<TValues extends Record<string, unknown>> = {
|
|
|
268
268
|
schema?: z.ZodType<TValues>
|
|
269
269
|
fields: CrudField[]
|
|
270
270
|
initialValues?: Partial<TValues>
|
|
271
|
+
/**
|
|
272
|
+
* When true, suppresses the form's mount-time autofocus entirely.
|
|
273
|
+
* Hosts can keep this enabled while async record data is still loading, then
|
|
274
|
+
* flip it off once the initial values are ready to let the first field gain focus.
|
|
275
|
+
*/
|
|
276
|
+
disableInitialFocus?: boolean
|
|
271
277
|
submitLabel?: string
|
|
272
278
|
submitIcon?: React.ComponentType<{ className?: string }>
|
|
273
279
|
formId?: string
|
|
@@ -528,6 +534,7 @@ export function CrudForm<TValues extends Record<string, unknown>>({
|
|
|
528
534
|
schema,
|
|
529
535
|
fields,
|
|
530
536
|
initialValues,
|
|
537
|
+
disableInitialFocus = false,
|
|
531
538
|
submitLabel,
|
|
532
539
|
submitIcon,
|
|
533
540
|
formId: providedFormId,
|
|
@@ -1882,7 +1889,7 @@ export function CrudForm<TValues extends Record<string, unknown>>({
|
|
|
1882
1889
|
React.useEffect(() => {
|
|
1883
1890
|
if (typeof window === 'undefined' || typeof document === 'undefined') return
|
|
1884
1891
|
|
|
1885
|
-
if (isLoading || isLoadingCustomFields || formReadOnly) {
|
|
1892
|
+
if (disableInitialFocus || isLoading || isLoadingCustomFields || formReadOnly) {
|
|
1886
1893
|
lastFocusedFieldRef.current = null
|
|
1887
1894
|
return
|
|
1888
1895
|
}
|
|
@@ -1929,7 +1936,7 @@ export function CrudForm<TValues extends Record<string, unknown>>({
|
|
|
1929
1936
|
window.clearTimeout(frame as number)
|
|
1930
1937
|
}
|
|
1931
1938
|
}
|
|
1932
|
-
}, [firstFieldId, formId, formReadOnly, isLoading, isLoadingCustomFields])
|
|
1939
|
+
}, [disableInitialFocus, firstFieldId, formId, formReadOnly, isLoading, isLoadingCustomFields])
|
|
1933
1940
|
|
|
1934
1941
|
React.useEffect(() => {
|
|
1935
1942
|
if (typeof window === 'undefined' || typeof document === 'undefined') return
|
|
@@ -2669,7 +2676,7 @@ export function CrudForm<TValues extends Record<string, unknown>>({
|
|
|
2669
2676
|
onBlurRequest={onBlurRequest}
|
|
2670
2677
|
values={values}
|
|
2671
2678
|
loadFieldOptions={loadFieldOptions}
|
|
2672
|
-
|
|
2679
|
+
autoFocus={!disableInitialFocus && !formReadOnly && Boolean(firstFieldId && f.id === firstFieldId)}
|
|
2673
2680
|
onSubmitRequest={requestSubmit}
|
|
2674
2681
|
wrapperClassName={wrapperClassName}
|
|
2675
2682
|
entityIdForField={primaryEntityId ?? undefined}
|
|
@@ -3230,7 +3237,7 @@ export function CrudForm<TValues extends Record<string, unknown>>({
|
|
|
3230
3237
|
onBlurRequest={onBlurRequest}
|
|
3231
3238
|
values={values}
|
|
3232
3239
|
loadFieldOptions={loadFieldOptions}
|
|
3233
|
-
autoFocus={!formReadOnly && Boolean(firstFieldId && f.id === firstFieldId)}
|
|
3240
|
+
autoFocus={!disableInitialFocus && !formReadOnly && Boolean(firstFieldId && f.id === firstFieldId)}
|
|
3234
3241
|
onSubmitRequest={requestSubmit}
|
|
3235
3242
|
wrapperClassName={wrapperClassName}
|
|
3236
3243
|
entityIdForField={primaryEntityId ?? undefined}
|
|
@@ -4047,6 +4054,7 @@ const FieldControl = React.memo(function FieldControlImpl({
|
|
|
4047
4054
|
onChange={(next) => fieldSetValue(next)}
|
|
4048
4055
|
placeholder={placeholder}
|
|
4049
4056
|
autoFocus={autoFocusField}
|
|
4057
|
+
suppressInitialSuggestionsOnFocus={autoFocusField}
|
|
4050
4058
|
suggestions={options.map((opt) => ({ value: opt.value, label: opt.label }))}
|
|
4051
4059
|
loadSuggestions={
|
|
4052
4060
|
typeof builtin?.loadOptions === 'function'
|
|
@@ -26,7 +26,7 @@ jest.mock('../injection/useInjectionDataWidgets', () => ({
|
|
|
26
26
|
|
|
27
27
|
import * as React from 'react'
|
|
28
28
|
import { renderToString } from 'react-dom/server'
|
|
29
|
-
import { act, fireEvent } from '@testing-library/react'
|
|
29
|
+
import { act, fireEvent, waitFor } from '@testing-library/react'
|
|
30
30
|
import { renderWithProviders } from '@open-mercato/shared/lib/testing/renderWithProviders'
|
|
31
31
|
import { CrudForm, type CrudField } from '../CrudForm'
|
|
32
32
|
import { I18nProvider } from '@open-mercato/shared/lib/i18n/context'
|
|
@@ -172,4 +172,125 @@ describe('CrudForm initialValues', () => {
|
|
|
172
172
|
expect(queryByRole('button', { name: 'Save' })).not.toBeInTheDocument()
|
|
173
173
|
expect(queryByRole('button', { name: 'Delete' })).not.toBeInTheDocument()
|
|
174
174
|
})
|
|
175
|
+
|
|
176
|
+
it('autofocuses the first combobox without opening suggestions on mount', async () => {
|
|
177
|
+
jest.useFakeTimers()
|
|
178
|
+
|
|
179
|
+
const loadOptions = jest.fn().mockResolvedValue([{ label: 'Alpha', value: 'alpha' }])
|
|
180
|
+
const fields: CrudField[] = [
|
|
181
|
+
{ id: 'primary_choice', label: 'Primary choice', type: 'combobox', loadOptions },
|
|
182
|
+
{ id: 'secondary_title', label: 'Secondary title', type: 'text' },
|
|
183
|
+
]
|
|
184
|
+
|
|
185
|
+
const { container, queryByRole } = renderWithProviders(
|
|
186
|
+
<CrudForm title="Form" fields={fields} onSubmit={() => {}} />,
|
|
187
|
+
{
|
|
188
|
+
dict: {
|
|
189
|
+
'ui.forms.actions.save': 'Save',
|
|
190
|
+
},
|
|
191
|
+
},
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
const comboboxInput = container.querySelector('[data-crud-field-id="primary_choice"] input[type="text"]') as HTMLInputElement | null
|
|
195
|
+
expect(comboboxInput).not.toBeNull()
|
|
196
|
+
|
|
197
|
+
await waitFor(() => {
|
|
198
|
+
expect(document.activeElement).toBe(comboboxInput)
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
await act(async () => {
|
|
202
|
+
jest.advanceTimersByTime(250)
|
|
203
|
+
await Promise.resolve()
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
expect(queryByRole('button', { name: 'Alpha' })).toBeNull()
|
|
207
|
+
jest.useRealTimers()
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
it('autofocuses the first relation field on mount', async () => {
|
|
211
|
+
const loadOptions = jest.fn().mockResolvedValue([{ label: 'Alpha', value: 'alpha' }])
|
|
212
|
+
const fields: CrudField[] = [
|
|
213
|
+
{ id: 'primary_choice', label: 'Primary choice', type: 'relation', loadOptions },
|
|
214
|
+
{ id: 'secondary_title', label: 'Secondary title', type: 'text' },
|
|
215
|
+
]
|
|
216
|
+
|
|
217
|
+
const { container } = renderWithProviders(
|
|
218
|
+
<CrudForm title="Form" fields={fields} onSubmit={() => {}} />,
|
|
219
|
+
{
|
|
220
|
+
dict: {
|
|
221
|
+
'ui.forms.actions.save': 'Save',
|
|
222
|
+
},
|
|
223
|
+
},
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
const relationInput = container.querySelector('[data-crud-field-id="primary_choice"] input[type="text"]') as HTMLInputElement | null
|
|
227
|
+
expect(relationInput).not.toBeNull()
|
|
228
|
+
|
|
229
|
+
await waitFor(() => {
|
|
230
|
+
expect(document.activeElement).toBe(relationInput)
|
|
231
|
+
})
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
it('respects disableInitialFocus', async () => {
|
|
235
|
+
const fields: CrudField[] = [
|
|
236
|
+
{ id: 'title', label: 'Title', type: 'text' },
|
|
237
|
+
{ id: 'summary', label: 'Summary', type: 'text' },
|
|
238
|
+
]
|
|
239
|
+
|
|
240
|
+
const { container } = renderWithProviders(
|
|
241
|
+
<CrudForm title="Form" fields={fields} disableInitialFocus onSubmit={() => {}} />,
|
|
242
|
+
{
|
|
243
|
+
dict: {
|
|
244
|
+
'ui.forms.actions.save': 'Save',
|
|
245
|
+
},
|
|
246
|
+
},
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
const titleInput = container.querySelector('[data-crud-field-id="title"] input[type="text"]') as HTMLInputElement | null
|
|
250
|
+
expect(titleInput).not.toBeNull()
|
|
251
|
+
|
|
252
|
+
await waitFor(() => {
|
|
253
|
+
expect(document.activeElement).not.toBe(titleInput)
|
|
254
|
+
})
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
it('can autofocus the first combobox after initial loading is finished', async () => {
|
|
258
|
+
jest.useFakeTimers()
|
|
259
|
+
|
|
260
|
+
const loadOptions = jest.fn().mockResolvedValue([{ label: 'Alpha', value: 'alpha' }])
|
|
261
|
+
const fields: CrudField[] = [
|
|
262
|
+
{ id: 'primary_choice', label: 'Primary choice', type: 'combobox', loadOptions },
|
|
263
|
+
{ id: 'secondary_title', label: 'Secondary title', type: 'text' },
|
|
264
|
+
]
|
|
265
|
+
|
|
266
|
+
const { container, rerender, queryByRole } = renderWithProviders(
|
|
267
|
+
<CrudForm title="Form" fields={fields} disableInitialFocus onSubmit={() => {}} />,
|
|
268
|
+
{
|
|
269
|
+
dict: {
|
|
270
|
+
'ui.forms.actions.save': 'Save',
|
|
271
|
+
},
|
|
272
|
+
},
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
const comboboxInput = container.querySelector('[data-crud-field-id="primary_choice"] input[type="text"]') as HTMLInputElement | null
|
|
276
|
+
expect(comboboxInput).not.toBeNull()
|
|
277
|
+
expect(document.activeElement).not.toBe(comboboxInput)
|
|
278
|
+
|
|
279
|
+
await act(async () => {
|
|
280
|
+
rerender(<CrudForm title="Form" fields={fields} onSubmit={() => {}} />)
|
|
281
|
+
jest.advanceTimersByTime(250)
|
|
282
|
+
await Promise.resolve()
|
|
283
|
+
await Promise.resolve()
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
await waitFor(() => {
|
|
287
|
+
expect(document.activeElement).toBe(comboboxInput)
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
await waitFor(() => {
|
|
291
|
+
expect(queryByRole('button', { name: 'Alpha' })).toBeInTheDocument()
|
|
292
|
+
})
|
|
293
|
+
|
|
294
|
+
jest.useRealTimers()
|
|
295
|
+
})
|
|
175
296
|
})
|
|
@@ -61,6 +61,7 @@ export function ComboboxInput({
|
|
|
61
61
|
const [showSuggestions, setShowSuggestions] = React.useState(false)
|
|
62
62
|
const [selectedIndex, setSelectedIndex] = React.useState(-1)
|
|
63
63
|
const inputRef = React.useRef<HTMLInputElement>(null)
|
|
64
|
+
const suppressOpenOnFocusRef = React.useRef(Boolean(autoFocus && !disabled))
|
|
64
65
|
|
|
65
66
|
const staticOptions = React.useMemo(() => normalizeOptions(suggestions), [suggestions])
|
|
66
67
|
|
|
@@ -223,6 +224,10 @@ export function ComboboxInput({
|
|
|
223
224
|
disabled={disabled}
|
|
224
225
|
onFocus={() => {
|
|
225
226
|
setTouched(true)
|
|
227
|
+
if (suppressOpenOnFocusRef.current) {
|
|
228
|
+
suppressOpenOnFocusRef.current = false
|
|
229
|
+
return
|
|
230
|
+
}
|
|
226
231
|
setShowSuggestions(true)
|
|
227
232
|
}}
|
|
228
233
|
onChange={(event) => {
|
|
@@ -24,6 +24,7 @@ export type TagsInputProps = {
|
|
|
24
24
|
disabled?: boolean
|
|
25
25
|
allowCustomValues?: boolean
|
|
26
26
|
showSuggestionsOnFocus?: boolean
|
|
27
|
+
suppressInitialSuggestionsOnFocus?: boolean
|
|
27
28
|
}
|
|
28
29
|
|
|
29
30
|
function normalizeOptions(input?: Array<string | TagsInputOption>): TagsInputOption[] {
|
|
@@ -59,6 +60,7 @@ export function TagsInput({
|
|
|
59
60
|
disabled = false,
|
|
60
61
|
allowCustomValues = true,
|
|
61
62
|
showSuggestionsOnFocus = true,
|
|
63
|
+
suppressInitialSuggestionsOnFocus = false,
|
|
62
64
|
}: TagsInputProps) {
|
|
63
65
|
const t = useT()
|
|
64
66
|
const [input, setInput] = React.useState('')
|
|
@@ -66,12 +68,17 @@ export function TagsInput({
|
|
|
66
68
|
const [loading, setLoading] = React.useState(false)
|
|
67
69
|
const [touched, setTouched] = React.useState(false)
|
|
68
70
|
const suppressBlurCommitRef = React.useRef(false)
|
|
71
|
+
const suppressSuggestionsOnFocusRef = React.useRef(Boolean(autoFocus && suppressInitialSuggestionsOnFocus && !disabled))
|
|
69
72
|
const valueRef = React.useRef(value)
|
|
70
73
|
|
|
71
74
|
React.useEffect(() => {
|
|
72
75
|
valueRef.current = value
|
|
73
76
|
}, [value])
|
|
74
77
|
|
|
78
|
+
React.useEffect(() => {
|
|
79
|
+
suppressSuggestionsOnFocusRef.current = Boolean(autoFocus && suppressInitialSuggestionsOnFocus && !disabled)
|
|
80
|
+
}, [autoFocus, disabled, suppressInitialSuggestionsOnFocus])
|
|
81
|
+
|
|
75
82
|
const staticOptions = React.useMemo(() => normalizeOptions(suggestions), [suggestions])
|
|
76
83
|
const selectedOptionList = React.useMemo(
|
|
77
84
|
() => normalizeOptions(selectedOptions),
|
|
@@ -229,6 +236,10 @@ export function TagsInput({
|
|
|
229
236
|
data-crud-focus-target=""
|
|
230
237
|
disabled={disabled}
|
|
231
238
|
onFocus={() => {
|
|
239
|
+
if (suppressSuggestionsOnFocusRef.current) {
|
|
240
|
+
suppressSuggestionsOnFocusRef.current = false
|
|
241
|
+
return
|
|
242
|
+
}
|
|
232
243
|
if (showSuggestionsOnFocus) {
|
|
233
244
|
setTouched(true)
|
|
234
245
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
/** @jest-environment jsdom */
|
|
1
2
|
import * as React from 'react'
|
|
2
3
|
import { act, fireEvent, screen } from '@testing-library/react'
|
|
3
4
|
import { renderWithProviders } from '@open-mercato/shared/lib/testing/renderWithProviders'
|
|
@@ -75,4 +76,40 @@ describe('TagsInput', () => {
|
|
|
75
76
|
|
|
76
77
|
jest.useRealTimers()
|
|
77
78
|
})
|
|
79
|
+
|
|
80
|
+
it('skips loading suggestions on the first programmatic focus when suppressed', async () => {
|
|
81
|
+
jest.useFakeTimers()
|
|
82
|
+
|
|
83
|
+
const loadSuggestions = jest.fn().mockResolvedValue([
|
|
84
|
+
{ value: 'catalog.product.deleted', label: 'Product Deleted' },
|
|
85
|
+
])
|
|
86
|
+
|
|
87
|
+
function Harness() {
|
|
88
|
+
const [value, setValue] = React.useState<string[]>([])
|
|
89
|
+
|
|
90
|
+
return (
|
|
91
|
+
<TagsInput
|
|
92
|
+
value={value}
|
|
93
|
+
onChange={setValue}
|
|
94
|
+
autoFocus
|
|
95
|
+
suppressInitialSuggestionsOnFocus
|
|
96
|
+
loadSuggestions={loadSuggestions}
|
|
97
|
+
/>
|
|
98
|
+
)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const { getByRole } = renderWithProviders(<Harness />)
|
|
102
|
+
const input = getByRole('textbox')
|
|
103
|
+
|
|
104
|
+
await act(async () => {
|
|
105
|
+
jest.advanceTimersByTime(300)
|
|
106
|
+
await Promise.resolve()
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
expect(loadSuggestions).not.toHaveBeenCalled()
|
|
110
|
+
|
|
111
|
+
expect(loadSuggestions).not.toHaveBeenCalled()
|
|
112
|
+
|
|
113
|
+
jest.useRealTimers()
|
|
114
|
+
})
|
|
78
115
|
})
|