@open-mercato/ui 0.9.1-develop.7473.1.de5b22888f → 0.9.1-develop.7475.1.28ca93ed97

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.
@@ -0,0 +1,149 @@
1
+ "use client";
2
+ import { jsx, jsxs } from "react/jsx-runtime";
3
+ import * as React from "react";
4
+ import { useT } from "@open-mercato/shared/lib/i18n/context";
5
+ import { ComboboxInput } from "./ComboboxInput.js";
6
+ import { TagsInput } from "./TagsInput.js";
7
+ import {
8
+ resolveLookupFailureReason
9
+ } from "./lookupSources.js";
10
+ function LookupFailureMessage({ failure }) {
11
+ const t = useT();
12
+ if (!failure) return null;
13
+ return /* @__PURE__ */ jsx("p", { className: "mt-1 text-xs text-status-error-text", role: "alert", "data-testid": "lookup-picker-failure", children: failure === "forbidden" ? t("ui.inputs.lookupPicker.forbidden", "You do not have permission to load these options.") : t("ui.inputs.lookupPicker.loadFailed", "Could not load options. Try again.") });
14
+ }
15
+ function useLookupFailure() {
16
+ const [failure, setFailure] = React.useState(null);
17
+ const guard = React.useCallback(async (run, fallback) => {
18
+ try {
19
+ const value = await run();
20
+ setFailure(null);
21
+ return value;
22
+ } catch (error) {
23
+ setFailure(resolveLookupFailureReason(error));
24
+ return fallback;
25
+ }
26
+ }, []);
27
+ return { failure, setFailure, guard };
28
+ }
29
+ function useLookupLabels(source, ids) {
30
+ const [options, setOptions] = React.useState({});
31
+ const { failure, setFailure, guard } = useLookupFailure();
32
+ const requested = React.useRef(/* @__PURE__ */ new Set());
33
+ const register = React.useCallback((next) => {
34
+ if (next.length === 0) return;
35
+ setOptions((previous) => {
36
+ const merged = { ...previous };
37
+ for (const option of next) merged[option.value] = option;
38
+ return merged;
39
+ });
40
+ }, []);
41
+ const idsKey = ids.join(",");
42
+ React.useEffect(() => {
43
+ const missing = idsKey.split(",").filter((id) => id.length > 0 && !requested.current.has(id));
44
+ if (missing.length === 0) return;
45
+ for (const id of missing) requested.current.add(id);
46
+ let cancelled = false;
47
+ let settled = false;
48
+ const release = () => {
49
+ for (const id of missing) requested.current.delete(id);
50
+ };
51
+ void source.resolve(missing).then(
52
+ (resolved) => {
53
+ settled = true;
54
+ if (cancelled) return;
55
+ register(resolved);
56
+ setFailure(null);
57
+ },
58
+ (error) => {
59
+ settled = true;
60
+ if (cancelled) return;
61
+ release();
62
+ setFailure(resolveLookupFailureReason(error));
63
+ }
64
+ );
65
+ return () => {
66
+ cancelled = true;
67
+ if (!settled) release();
68
+ };
69
+ }, [idsKey, register, setFailure, source]);
70
+ const search = React.useCallback(
71
+ (query) => guard(async () => {
72
+ const found = await source.search(query);
73
+ register(found);
74
+ return found;
75
+ }, []),
76
+ [guard, register, source]
77
+ );
78
+ return { options, search, failure };
79
+ }
80
+ function LookupMultiPicker({ source, value, onChange, placeholder, disabled }) {
81
+ const { options, search, failure } = useLookupLabels(source, value);
82
+ const suggestions = React.useMemo(() => Object.values(options), [options]);
83
+ return /* @__PURE__ */ jsxs("div", { children: [
84
+ /* @__PURE__ */ jsx(
85
+ TagsInput,
86
+ {
87
+ value,
88
+ onChange,
89
+ suggestions,
90
+ loadSuggestions: search,
91
+ allowCustomValues: false,
92
+ closeSuggestionsOnSelect: true,
93
+ resolveLabel: (id) => options[id]?.label ?? id,
94
+ resolveDescription: (id) => options[id]?.description ?? null,
95
+ placeholder,
96
+ disabled
97
+ }
98
+ ),
99
+ /* @__PURE__ */ jsx(LookupFailureMessage, { failure })
100
+ ] });
101
+ }
102
+ function LookupSinglePicker({
103
+ source,
104
+ value,
105
+ onChange,
106
+ placeholder,
107
+ disabled,
108
+ clearable
109
+ }) {
110
+ const { failure, guard } = useLookupFailure();
111
+ const resolveLabel = React.useCallback(
112
+ (id) => guard(async () => {
113
+ const [option] = await source.resolve([id]);
114
+ return option?.label ?? id;
115
+ }, id),
116
+ [guard, source]
117
+ );
118
+ const search = React.useCallback((query) => guard(() => source.search(query), []), [guard, source]);
119
+ return /* @__PURE__ */ jsxs("div", { children: [
120
+ /* @__PURE__ */ jsx(
121
+ ComboboxInput,
122
+ {
123
+ value,
124
+ onChange,
125
+ allowCustomValues: false,
126
+ clearable,
127
+ placeholder,
128
+ disabled,
129
+ loadSuggestions: search,
130
+ resolveLabel
131
+ }
132
+ ),
133
+ /* @__PURE__ */ jsx(LookupFailureMessage, { failure })
134
+ ] });
135
+ }
136
+ function useLookupLabelMap(source, ids) {
137
+ const { options } = useLookupLabels(source, ids);
138
+ return React.useMemo(() => {
139
+ const labels = {};
140
+ for (const [id, option] of Object.entries(options)) labels[id] = option.label;
141
+ return labels;
142
+ }, [options]);
143
+ }
144
+ export {
145
+ LookupMultiPicker,
146
+ LookupSinglePicker,
147
+ useLookupLabelMap
148
+ };
149
+ //# sourceMappingURL=LookupPickers.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../src/backend/inputs/LookupPickers.tsx"],
4
+ "sourcesContent": ["'use client'\n\nimport * as React from 'react'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport { ComboboxInput } from './ComboboxInput'\nimport { TagsInput } from './TagsInput'\nimport {\n resolveLookupFailureReason,\n type LookupLoadFailureReason,\n type LookupOption,\n type LookupSource,\n} from './lookupSources'\n\nfunction LookupFailureMessage({ failure }: { failure: LookupLoadFailureReason | null }) {\n const t = useT()\n if (!failure) return null\n return (\n <p className=\"mt-1 text-xs text-status-error-text\" role=\"alert\" data-testid=\"lookup-picker-failure\">\n {failure === 'forbidden'\n ? t('ui.inputs.lookupPicker.forbidden', 'You do not have permission to load these options.')\n : t('ui.inputs.lookupPicker.loadFailed', 'Could not load options. Try again.')}\n </p>\n )\n}\n\nfunction useLookupFailure() {\n const [failure, setFailure] = React.useState<LookupLoadFailureReason | null>(null)\n const guard = React.useCallback(async <TValue,>(run: () => Promise<TValue>, fallback: TValue): Promise<TValue> => {\n try {\n const value = await run()\n setFailure(null)\n return value\n } catch (error) {\n setFailure(resolveLookupFailureReason(error))\n return fallback\n }\n }, [])\n return { failure, setFailure, guard }\n}\n\nfunction useLookupLabels(source: LookupSource, ids: readonly string[]) {\n const [options, setOptions] = React.useState<Record<string, LookupOption>>({})\n const { failure, setFailure, guard } = useLookupFailure()\n const requested = React.useRef(new Set<string>())\n\n const register = React.useCallback((next: readonly LookupOption[]) => {\n if (next.length === 0) return\n setOptions((previous) => {\n const merged = { ...previous }\n for (const option of next) merged[option.value] = option\n return merged\n })\n }, [])\n\n const idsKey = ids.join(',')\n React.useEffect(() => {\n const missing = idsKey\n .split(',')\n .filter((id) => id.length > 0 && !requested.current.has(id))\n if (missing.length === 0) return\n for (const id of missing) requested.current.add(id)\n let cancelled = false\n let settled = false\n const release = () => {\n for (const id of missing) requested.current.delete(id)\n }\n void source.resolve(missing).then(\n (resolved) => {\n settled = true\n if (cancelled) return\n register(resolved)\n setFailure(null)\n },\n (error: unknown) => {\n settled = true\n if (cancelled) return\n release()\n setFailure(resolveLookupFailureReason(error))\n },\n )\n return () => {\n cancelled = true\n if (!settled) release()\n }\n }, [idsKey, register, setFailure, source])\n\n const search = React.useCallback(\n (query?: string) =>\n guard(async () => {\n const found = await source.search(query)\n register(found)\n return found\n }, []),\n [guard, register, source],\n )\n\n return { options, search, failure }\n}\n\ntype LookupMultiPickerProps = {\n source: LookupSource\n value: string[]\n onChange: (next: string[]) => void\n placeholder?: string\n disabled?: boolean\n}\n\nexport function LookupMultiPicker({ source, value, onChange, placeholder, disabled }: LookupMultiPickerProps) {\n const { options, search, failure } = useLookupLabels(source, value)\n const suggestions = React.useMemo(() => Object.values(options), [options])\n return (\n <div>\n <TagsInput\n value={value}\n onChange={onChange}\n suggestions={suggestions}\n loadSuggestions={search}\n allowCustomValues={false}\n closeSuggestionsOnSelect\n resolveLabel={(id) => options[id]?.label ?? id}\n resolveDescription={(id) => options[id]?.description ?? null}\n placeholder={placeholder}\n disabled={disabled}\n />\n <LookupFailureMessage failure={failure} />\n </div>\n )\n}\n\ntype LookupSinglePickerProps = {\n source: LookupSource\n value: string\n onChange: (next: string) => void\n placeholder?: string\n disabled?: boolean\n clearable?: boolean\n}\n\nexport function LookupSinglePicker({\n source,\n value,\n onChange,\n placeholder,\n disabled,\n clearable,\n}: LookupSinglePickerProps) {\n const { failure, guard } = useLookupFailure()\n const resolveLabel = React.useCallback(\n (id: string) =>\n guard(async () => {\n const [option] = await source.resolve([id])\n return option?.label ?? id\n }, id),\n [guard, source],\n )\n const search = React.useCallback((query?: string) => guard(() => source.search(query), []), [guard, source])\n return (\n <div>\n <ComboboxInput\n value={value}\n onChange={onChange}\n allowCustomValues={false}\n clearable={clearable}\n placeholder={placeholder}\n disabled={disabled}\n loadSuggestions={search}\n resolveLabel={resolveLabel}\n />\n <LookupFailureMessage failure={failure} />\n </div>\n )\n}\n\nexport function useLookupLabelMap(source: LookupSource, ids: readonly string[]): Record<string, string> {\n const { options } = useLookupLabels(source, ids)\n return React.useMemo(() => {\n const labels: Record<string, string> = {}\n for (const [id, option] of Object.entries(options)) labels[id] = option.label\n return labels\n }, [options])\n}\n"],
5
+ "mappings": "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",
6
+ "names": []
7
+ }
@@ -34,7 +34,8 @@ function TagsInput({
34
34
  disabled = false,
35
35
  allowCustomValues = true,
36
36
  showSuggestionsOnFocus = true,
37
- suppressInitialSuggestionsOnFocus = false
37
+ suppressInitialSuggestionsOnFocus = false,
38
+ closeSuggestionsOnSelect = false
38
39
  }) {
39
40
  const t = useT();
40
41
  const [input, setInput] = React.useState("");
@@ -232,6 +233,7 @@ function TagsInput({
232
233
  event.preventDefault();
233
234
  addTag(input);
234
235
  setInput("");
236
+ if (closeSuggestionsOnSelect) setSuggestionsDismissed(true);
235
237
  } else if (event.key === "Backspace" && input === "" && value.length > 0) {
236
238
  removeTag(value[value.length - 1]);
237
239
  }
@@ -264,6 +266,7 @@ function TagsInput({
264
266
  suppressBlurCommitRef.current = false;
265
267
  addValue(option.value);
266
268
  setInput("");
269
+ if (closeSuggestionsOnSelect) setSuggestionsDismissed(true);
267
270
  },
268
271
  children: [
269
272
  /* @__PURE__ */ jsx("span", { children: option.label }),
@@ -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 { Tag } from '../../primitives/tag'\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 [suggestionsDismissed, setSuggestionsDismissed] = React.useState(false)\n const inputRef = React.useRef<HTMLInputElement>(null)\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 suggestionsOpen = !suggestionsDismissed\n && ((loading && touched) || (!loading && filteredSuggestions.length > 0))\n\n React.useEffect(() => {\n if (!suggestionsOpen) return\n const handleEscape = (event: KeyboardEvent) => {\n if (event.key !== 'Escape' || event.target !== inputRef.current) return\n event.preventDefault()\n event.stopPropagation()\n setSuggestionsDismissed(true)\n }\n window.addEventListener('keydown', handleEscape, { capture: true })\n return () => window.removeEventListener('keydown', handleEscape, { capture: true })\n }, [suggestionsOpen])\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 <Tag\n key={tag}\n shape=\"square\"\n variant=\"default\"\n disabled={disabled}\n onRemove={() => removeTag(tag)}\n removeAriaLabel={t('ui.inputs.tagsInput.removeTag', 'Remove {label}', { label })}\n >\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 </Tag>\n )\n })}\n <input\n ref={inputRef}\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 setSuggestionsDismissed(false)\n }\n }}\n onMouseDown={() => {\n setTouched(true)\n setSuggestionsDismissed(false)\n }}\n onChange={(event) => {\n setTouched(true)\n setSuggestionsDismissed(false)\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 {suggestionsOpen && loading && touched ? (\n <div className=\"basis-full mt-1 text-xs text-muted-foreground\">{t('ui.inputs.tagsInput.loading', 'Loading suggestions\u2026')}</div>\n ) : null}\n {suggestionsOpen && !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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",
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 { Tag } from '../../primitives/tag'\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 closeSuggestionsOnSelect?: 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 closeSuggestionsOnSelect = 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 [suggestionsDismissed, setSuggestionsDismissed] = React.useState(false)\n const inputRef = React.useRef<HTMLInputElement>(null)\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 suggestionsOpen = !suggestionsDismissed\n && ((loading && touched) || (!loading && filteredSuggestions.length > 0))\n\n React.useEffect(() => {\n if (!suggestionsOpen) return\n const handleEscape = (event: KeyboardEvent) => {\n if (event.key !== 'Escape' || event.target !== inputRef.current) return\n event.preventDefault()\n event.stopPropagation()\n setSuggestionsDismissed(true)\n }\n window.addEventListener('keydown', handleEscape, { capture: true })\n return () => window.removeEventListener('keydown', handleEscape, { capture: true })\n }, [suggestionsOpen])\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 <Tag\n key={tag}\n shape=\"square\"\n variant=\"default\"\n disabled={disabled}\n onRemove={() => removeTag(tag)}\n removeAriaLabel={t('ui.inputs.tagsInput.removeTag', 'Remove {label}', { label })}\n >\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 </Tag>\n )\n })}\n <input\n ref={inputRef}\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 setSuggestionsDismissed(false)\n }\n }}\n onMouseDown={() => {\n setTouched(true)\n setSuggestionsDismissed(false)\n }}\n onChange={(event) => {\n setTouched(true)\n setSuggestionsDismissed(false)\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 if (closeSuggestionsOnSelect) setSuggestionsDismissed(true)\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 {suggestionsOpen && loading && touched ? (\n <div className=\"basis-full mt-1 text-xs text-muted-foreground\">{t('ui.inputs.tagsInput.loading', 'Loading suggestions\u2026')}</div>\n ) : null}\n {suggestionsOpen && !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 if (closeSuggestionsOnSelect) setSuggestionsDismissed(true)\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
  }
@@ -10,4 +10,6 @@ export * from "./TimePicker.js";
10
10
  export * from "./DateTimePicker.js";
11
11
  export * from "./DatePicker.js";
12
12
  export * from "./DurationInput.js";
13
+ export * from "./lookupSources.js";
14
+ export * from "./LookupPickers.js";
13
15
  //# sourceMappingURL=index.js.map
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../src/backend/inputs/index.ts"],
4
- "sourcesContent": ["export * from './PhoneNumberField'\nexport * from './TagsInput'\nexport * from './LookupSelect'\nexport * from './ComboboxInput'\nexport * from './SwitchableMarkdownInput'\nexport * from './EventSelect'\nexport * from './EventPatternInput'\nexport * from './TimeInput'\nexport * from './TimePicker'\nexport * from './DateTimePicker'\nexport * from './DatePicker'\nexport * from './DurationInput'\n"],
5
- "mappings": "AAAA,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;",
4
+ "sourcesContent": ["export * from './PhoneNumberField'\nexport * from './TagsInput'\nexport * from './LookupSelect'\nexport * from './ComboboxInput'\nexport * from './SwitchableMarkdownInput'\nexport * from './EventSelect'\nexport * from './EventPatternInput'\nexport * from './TimeInput'\nexport * from './TimePicker'\nexport * from './DateTimePicker'\nexport * from './DatePicker'\nexport * from './DurationInput'\nexport * from './lookupSources'\nexport * from './LookupPickers'\n"],
5
+ "mappings": "AAAA,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;",
6
6
  "names": []
7
7
  }
@@ -0,0 +1,84 @@
1
+ import { createLogger } from "@open-mercato/shared/lib/logger";
2
+ import { readApiResultOrThrow } from "../utils/apiCall.js";
3
+ const logger = createLogger("ui").child({ component: "lookup-sources" });
4
+ class LookupLoadError extends Error {
5
+ constructor(reason, path) {
6
+ super(`[internal] lookup load failed (${reason}) for ${path}`);
7
+ this.name = "LookupLoadError";
8
+ this.reason = reason;
9
+ }
10
+ }
11
+ function resolveLookupFailureReason(error) {
12
+ if (!error || typeof error !== "object") return "failed";
13
+ const candidate = error;
14
+ return candidate.name === "LookupLoadError" && candidate.reason === "forbidden" ? "forbidden" : "failed";
15
+ }
16
+ function readErrorStatus(error) {
17
+ if (!error || typeof error !== "object") return null;
18
+ const status = error.status;
19
+ return typeof status === "number" ? status : null;
20
+ }
21
+ const SEARCH_PAGE_SIZE = "50";
22
+ const RESOLVE_CHUNK_SIZE = 100;
23
+ function pickLookupString(item, ...keys) {
24
+ for (const key of keys) {
25
+ const value = item[key];
26
+ if (typeof value === "string" && value.trim().length > 0) return value.trim();
27
+ }
28
+ return "";
29
+ }
30
+ async function loadItems(path, params) {
31
+ try {
32
+ const search = new URLSearchParams(params);
33
+ const payload = await readApiResultOrThrow(`${path}?${search.toString()}`, void 0, {
34
+ fallback: { items: [] }
35
+ });
36
+ return Array.isArray(payload?.items) ? payload.items : [];
37
+ } catch (err) {
38
+ const status = readErrorStatus(err);
39
+ logger.warn("ui.lookups.load.failed", { err, path, status });
40
+ throw new LookupLoadError(status === 401 || status === 403 ? "forbidden" : "failed", path);
41
+ }
42
+ }
43
+ function mapItems(items, mapItem) {
44
+ return items.map(mapItem).filter((option) => option !== null);
45
+ }
46
+ function chunkIds(ids) {
47
+ const unique = Array.from(new Set(ids));
48
+ const chunks = [];
49
+ for (let start = 0; start < unique.length; start += RESOLVE_CHUNK_SIZE) {
50
+ chunks.push(unique.slice(start, start + RESOLVE_CHUNK_SIZE));
51
+ }
52
+ return chunks;
53
+ }
54
+ async function resolveByIds(path, ids, mapItem, extraParams = {}) {
55
+ const chunks = chunkIds(ids);
56
+ const pages = await Promise.all(
57
+ chunks.map(
58
+ (chunk) => loadItems(path, { ...extraParams, ids: chunk.join(","), pageSize: String(chunk.length) })
59
+ )
60
+ );
61
+ return mapItems(pages.flat(), mapItem);
62
+ }
63
+ function searchParams(query, pageSize) {
64
+ const trimmed = (query ?? "").trim();
65
+ return trimmed.length > 0 ? { search: trimmed, pageSize } : { pageSize };
66
+ }
67
+ function createIdsLookupSource(id, path, mapItem, extraParams = {}) {
68
+ return {
69
+ id,
70
+ search: async (query) => mapItems(await loadItems(path, { ...extraParams, ...searchParams(query, SEARCH_PAGE_SIZE) }), mapItem),
71
+ resolve: (ids) => resolveByIds(path, ids, mapItem, extraParams)
72
+ };
73
+ }
74
+ function lookupLabelWithCode(label, code) {
75
+ return code && code !== label ? `${label} (${code})` : label;
76
+ }
77
+ export {
78
+ LookupLoadError,
79
+ createIdsLookupSource,
80
+ lookupLabelWithCode,
81
+ pickLookupString,
82
+ resolveLookupFailureReason
83
+ };
84
+ //# sourceMappingURL=lookupSources.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../src/backend/inputs/lookupSources.ts"],
4
+ "sourcesContent": ["import { createLogger } from '@open-mercato/shared/lib/logger'\nimport { readApiResultOrThrow } from '../utils/apiCall'\n\nconst logger = createLogger('ui').child({ component: 'lookup-sources' })\n\nexport type LookupOption = { value: string; label: string; description?: string | null }\n\nexport type LookupSource = {\n id: string\n search: (query?: string) => Promise<LookupOption[]>\n resolve: (ids: readonly string[]) => Promise<LookupOption[]>\n}\n\nexport type LookupRemoteItem = Record<string, unknown>\n\nexport type LookupLoadFailureReason = 'forbidden' | 'failed'\n\nexport class LookupLoadError extends Error {\n readonly reason: LookupLoadFailureReason\n\n constructor(reason: LookupLoadFailureReason, path: string) {\n super(`[internal] lookup load failed (${reason}) for ${path}`)\n this.name = 'LookupLoadError'\n this.reason = reason\n }\n}\n\nexport function resolveLookupFailureReason(error: unknown): LookupLoadFailureReason {\n if (!error || typeof error !== 'object') return 'failed'\n const candidate = error as { name?: unknown; reason?: unknown }\n return candidate.name === 'LookupLoadError' && candidate.reason === 'forbidden' ? 'forbidden' : 'failed'\n}\n\nfunction readErrorStatus(error: unknown): number | null {\n if (!error || typeof error !== 'object') return null\n const status = (error as { status?: unknown }).status\n return typeof status === 'number' ? status : null\n}\n\nconst SEARCH_PAGE_SIZE = '50'\nconst RESOLVE_CHUNK_SIZE = 100\n\nexport function pickLookupString(item: LookupRemoteItem, ...keys: string[]): string {\n for (const key of keys) {\n const value = item[key]\n if (typeof value === 'string' && value.trim().length > 0) return value.trim()\n }\n return ''\n}\n\nasync function loadItems(path: string, params: Record<string, string>): Promise<LookupRemoteItem[]> {\n try {\n const search = new URLSearchParams(params)\n const payload = await readApiResultOrThrow<{ items?: LookupRemoteItem[] }>(`${path}?${search.toString()}`, undefined, {\n fallback: { items: [] },\n })\n return Array.isArray(payload?.items) ? payload.items : []\n } catch (err) {\n const status = readErrorStatus(err)\n logger.warn('ui.lookups.load.failed', { err, path, status })\n throw new LookupLoadError(status === 401 || status === 403 ? 'forbidden' : 'failed', path)\n }\n}\n\nfunction mapItems(items: LookupRemoteItem[], mapItem: (item: LookupRemoteItem) => LookupOption | null): LookupOption[] {\n return items.map(mapItem).filter((option): option is LookupOption => option !== null)\n}\n\nfunction chunkIds(ids: readonly string[]): string[][] {\n const unique = Array.from(new Set(ids))\n const chunks: string[][] = []\n for (let start = 0; start < unique.length; start += RESOLVE_CHUNK_SIZE) {\n chunks.push(unique.slice(start, start + RESOLVE_CHUNK_SIZE))\n }\n return chunks\n}\n\n/** Resolves `ids` through the `?ids=` filter in chunks of at most 100, one request per chunk. */\nasync function resolveByIds(\n path: string,\n ids: readonly string[],\n mapItem: (item: LookupRemoteItem) => LookupOption | null,\n extraParams: Record<string, string> = {},\n): Promise<LookupOption[]> {\n const chunks = chunkIds(ids)\n const pages = await Promise.all(\n chunks.map((chunk) =>\n loadItems(path, { ...extraParams, ids: chunk.join(','), pageSize: String(chunk.length) }),\n ),\n )\n return mapItems(pages.flat(), mapItem)\n}\n\nfunction searchParams(query: string | undefined, pageSize: string): Record<string, string> {\n const trimmed = (query ?? '').trim()\n return trimmed.length > 0 ? { search: trimmed, pageSize } : { pageSize }\n}\n\nexport function createIdsLookupSource(\n id: string,\n path: string,\n mapItem: (item: LookupRemoteItem) => LookupOption | null,\n extraParams: Record<string, string> = {},\n): LookupSource {\n return {\n id,\n search: async (query) => mapItems(await loadItems(path, { ...extraParams, ...searchParams(query, SEARCH_PAGE_SIZE) }), mapItem),\n resolve: (ids) => resolveByIds(path, ids, mapItem, extraParams),\n }\n}\n\nexport function lookupLabelWithCode(label: string, code: string): string {\n return code && code !== label ? `${label} (${code})` : label\n}\n"],
5
+ "mappings": "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",
6
+ "names": []
7
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/ui",
3
- "version": "0.9.1-develop.7473.1.de5b22888f",
3
+ "version": "0.9.1-develop.7475.1.28ca93ed97",
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.9.1-develop.7473.1.de5b22888f",
158
+ "@open-mercato/shared": "0.9.1-develop.7475.1.28ca93ed97",
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.9.1-develop.7473.1.de5b22888f",
165
+ "@open-mercato/shared": "0.9.1-develop.7475.1.28ca93ed97",
166
166
  "@testing-library/dom": "^10.4.1",
167
167
  "@testing-library/jest-dom": "^7.0.1",
168
168
  "@testing-library/react": "^16.3.3",
@@ -4266,12 +4266,16 @@ function NumberInput({
4266
4266
  placeholder,
4267
4267
  autoFocus,
4268
4268
  onSubmit,
4269
+ disabled,
4270
+ readOnly,
4269
4271
  }: {
4270
4272
  value: number | string | null | undefined
4271
4273
  onChange: (v: number | undefined) => void
4272
4274
  placeholder?: string
4273
4275
  autoFocus?: boolean
4274
4276
  onSubmit?: () => void
4277
+ disabled?: boolean
4278
+ readOnly?: boolean
4275
4279
  }) {
4276
4280
  const locale = useOptionalLocale()
4277
4281
  const serializedValue = value !== undefined && value !== null ? String(value) : ''
@@ -4353,6 +4357,8 @@ function NumberInput({
4353
4357
  onFocus={handleFocus}
4354
4358
  onBlur={handleBlur}
4355
4359
  autoFocus={autoFocus}
4360
+ disabled={disabled}
4361
+ readOnly={readOnly}
4356
4362
  data-crud-focus-target=""
4357
4363
  />
4358
4364
  )
@@ -4743,6 +4749,8 @@ const FieldControl = React.memo(function FieldControlImpl({
4743
4749
  onChange={fieldSetValue}
4744
4750
  autoFocus={autoFocusField}
4745
4751
  onSubmit={onSubmitRequest}
4752
+ disabled={disabled}
4753
+ readOnly={readOnly}
4746
4754
  />
4747
4755
  )}
4748
4756
  {field.type === 'date' && (
@@ -4842,6 +4850,7 @@ const FieldControl = React.memo(function FieldControlImpl({
4842
4850
  suppressInitialSuggestionsOnFocus={autoFocusField}
4843
4851
  suggestions={options.map((opt) => ({ value: opt.value, label: opt.label }))}
4844
4852
  loadSuggestions={loadFieldSuggestions}
4853
+ disabled={disabled}
4845
4854
  />
4846
4855
  )}
4847
4856
  {field.type === 'combobox' && (
@@ -0,0 +1,57 @@
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 } from '../CrudForm'
25
+
26
+ const dict = {
27
+ 'ui.forms.actions.save': 'Save',
28
+ 'ui.forms.select.emptyOption': '—',
29
+ }
30
+
31
+ describe('CrudForm field-level disabled', () => {
32
+ it('disables a number field', () => {
33
+ const fields: CrudField[] = [{ id: 'leadTime', label: 'Lead time', type: 'number', disabled: true }]
34
+ const { container } = renderWithProviders(
35
+ <CrudForm title="Form" fields={fields} initialValues={{ leadTime: 5 }} onSubmit={() => {}} />,
36
+ { dict },
37
+ )
38
+
39
+ const input = container.querySelector('[data-crud-field-id="leadTime"] input') as HTMLInputElement
40
+ expect(input).not.toBeNull()
41
+ expect(input).toBeDisabled()
42
+ })
43
+
44
+ it('disables a tags field so values cannot be added or removed', () => {
45
+ const fields: CrudField[] = [{ id: 'locales', label: 'Locales', type: 'tags', disabled: true }]
46
+ const { container } = renderWithProviders(
47
+ <CrudForm title="Form" fields={fields} initialValues={{ locales: ['en', 'pl'] }} onSubmit={() => {}} />,
48
+ { dict },
49
+ )
50
+
51
+ const field = container.querySelector('[data-crud-field-id="locales"]') as HTMLElement
52
+ expect(field.querySelector('input')).toBeDisabled()
53
+ const removeButtons = Array.from(field.querySelectorAll('button'))
54
+ expect(removeButtons.length).toBeGreaterThan(0)
55
+ removeButtons.forEach((button) => expect(button).toBeDisabled())
56
+ })
57
+ })