@lerianstudio/sindarian-ui 2.0.0-beta.1 → 2.0.0-beta.2
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/components/form/file-upload-field/index.d.ts +11 -2
- package/dist/components/form/file-upload-field/index.d.ts.map +1 -1
- package/dist/components/form/file-upload-field/index.js +8 -3
- package/dist/components/form/input-field/index.d.ts +41 -2
- package/dist/components/form/input-field/index.d.ts.map +1 -1
- package/dist/components/form/input-field/index.js +73 -15
- package/dist/components/form/select-field/index.d.ts +9 -1
- package/dist/components/form/select-field/index.d.ts.map +1 -1
- package/dist/components/form/select-field/index.js +2 -2
- package/dist/components/ui/button/styles.css +31 -1
- package/dist/components/ui/calendar/index.d.ts.map +1 -1
- package/dist/components/ui/calendar/index.js +7 -1
- package/dist/components/ui/file-upload/index.d.ts +76 -9
- package/dist/components/ui/file-upload/index.d.ts.map +1 -1
- package/dist/components/ui/file-upload/index.js +47 -12
- package/dist/components/ui/form.d.ts +4 -0
- package/dist/components/ui/form.d.ts.map +1 -1
- package/dist/components/ui/form.js +48 -6
- package/dist/components/ui/toggle-group/index.d.ts +1 -1
- package/dist/components/ui/toggle-group/index.d.ts.map +1 -1
- package/dist/components/ui/toggle-group/index.js +73 -4
- package/dist/esm/components/form/file-upload-field/index.d.ts +11 -2
- package/dist/esm/components/form/file-upload-field/index.d.ts.map +1 -1
- package/dist/esm/components/form/file-upload-field/index.js +8 -3
- package/dist/esm/components/form/input-field/index.d.ts +41 -2
- package/dist/esm/components/form/input-field/index.d.ts.map +1 -1
- package/dist/esm/components/form/input-field/index.js +73 -15
- package/dist/esm/components/form/select-field/index.d.ts +9 -1
- package/dist/esm/components/form/select-field/index.d.ts.map +1 -1
- package/dist/esm/components/form/select-field/index.js +2 -2
- package/dist/esm/components/ui/calendar/index.d.ts.map +1 -1
- package/dist/esm/components/ui/calendar/index.js +8 -2
- package/dist/esm/components/ui/file-upload/index.d.ts +76 -9
- package/dist/esm/components/ui/file-upload/index.d.ts.map +1 -1
- package/dist/esm/components/ui/file-upload/index.js +47 -13
- package/dist/esm/components/ui/form.d.ts +4 -0
- package/dist/esm/components/ui/form.d.ts.map +1 -1
- package/dist/esm/components/ui/form.js +48 -6
- package/dist/esm/components/ui/toggle-group/index.d.ts +1 -1
- package/dist/esm/components/ui/toggle-group/index.d.ts.map +1 -1
- package/dist/esm/components/ui/toggle-group/index.js +73 -4
- package/package.json +1 -1
|
@@ -36,13 +36,21 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
36
36
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
37
37
|
exports.FileUpload = void 0;
|
|
38
38
|
exports.validateFile = validateFile;
|
|
39
|
+
exports.humanizeSize = humanizeSize;
|
|
39
40
|
const jsx_runtime_1 = require("react/jsx-runtime");
|
|
40
41
|
/**
|
|
41
|
-
* FileUpload — a controlled "pick a file,
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
42
|
+
* FileUpload — a controlled "pick a file, hand it back" primitive. The defining
|
|
43
|
+
* job: select one file (by click, keyboard, or drag-and-drop), validate it
|
|
44
|
+
* against an accept filter + a byte ceiling, and emit `{ file, text }`.
|
|
45
|
+
*
|
|
46
|
+
* `readAs` decides whether the bytes are decoded on the way through. `'text'`
|
|
47
|
+
* (the default) reads UTF-8 via FileReader.readAsText, which is what a PEM/CSV
|
|
48
|
+
* host wants. `'none'` hands the `File` over untouched and is the only mode a
|
|
49
|
+
* BINARY file can use: a PDF, XLSX or PFX put through readAsText decodes into
|
|
50
|
+
* replacement-character garbage, and that garbage string is then retained for
|
|
51
|
+
* as long as the host holds the value — 20 MiB of PDF became a useless 20 MiB
|
|
52
|
+
* string, and the host still had to read the file a second time to get at the
|
|
53
|
+
* real bytes.
|
|
46
54
|
*
|
|
47
55
|
* The real `<input type="file">` IS the accessible control: it is `sr-only`
|
|
48
56
|
* (visually hidden) but focusable and labelable — never `aria-hidden`, never
|
|
@@ -63,6 +71,11 @@ const jsx_runtime_1 = require("react/jsx-runtime");
|
|
|
63
71
|
* a value, so it cannot masquerade as valid. `accept` is validated against
|
|
64
72
|
* BOTH extension and MIME because the native `accept` attribute is only a
|
|
65
73
|
* browser hint and is bypassable via drag-drop.
|
|
74
|
+
*
|
|
75
|
+
* A host that already announces the rejection itself — a toast in its own
|
|
76
|
+
* locale, driven off `onError` — silences this one by returning nothing from
|
|
77
|
+
* `labels.error`. Two announcements for one event, in two languages, is the
|
|
78
|
+
* accessibility defect; the wording is not.
|
|
66
79
|
*/
|
|
67
80
|
const React = __importStar(require("react"));
|
|
68
81
|
const lucide_react_1 = require("lucide-react");
|
|
@@ -105,7 +118,11 @@ function matchesAccept(file, accept) {
|
|
|
105
118
|
return mime === token;
|
|
106
119
|
});
|
|
107
120
|
}
|
|
108
|
-
/**
|
|
121
|
+
/**
|
|
122
|
+
* Humanize a byte count for the selected-file chip. Binary units, 1 decimal.
|
|
123
|
+
* Exported so a `labels.error` override can format `maxSizeBytes` exactly the
|
|
124
|
+
* way the chip and the default message do, instead of re-deriving binary units.
|
|
125
|
+
*/
|
|
109
126
|
function humanizeSize(bytes) {
|
|
110
127
|
if (bytes < 1024)
|
|
111
128
|
return `${bytes} B`;
|
|
@@ -118,8 +135,8 @@ function humanizeSize(bytes) {
|
|
|
118
135
|
}
|
|
119
136
|
return `${size.toFixed(1)} ${units[unit]}`;
|
|
120
137
|
}
|
|
121
|
-
/**
|
|
122
|
-
function
|
|
138
|
+
/** The default English announcement for each rejection kind. */
|
|
139
|
+
function defaultErrorMessage(error) {
|
|
123
140
|
switch (error.kind) {
|
|
124
141
|
case 'too-large':
|
|
125
142
|
return `File is too large (max ${humanizeSize(error.maxSizeBytes)}).`;
|
|
@@ -129,7 +146,7 @@ function errorMessage(error) {
|
|
|
129
146
|
return 'Could not read the file.';
|
|
130
147
|
}
|
|
131
148
|
}
|
|
132
|
-
exports.FileUpload = React.forwardRef(function FileUpload({ accept, maxSizeBytes, value, onSelect, onError, disabled = false, id, className, 'aria-invalid': ariaInvalid, 'aria-required': ariaRequired, 'aria-describedby': ariaDescribedby, 'aria-label': ariaLabel, ...rest }, ref) {
|
|
149
|
+
exports.FileUpload = React.forwardRef(function FileUpload({ accept, maxSizeBytes, value, readAs = 'text', labels, onSelect, onError, disabled = false, id, className, 'aria-invalid': ariaInvalid, 'aria-required': ariaRequired, 'aria-describedby': ariaDescribedby, 'aria-label': ariaLabel, ...rest }, ref) {
|
|
133
150
|
const internalRef = React.useRef(null);
|
|
134
151
|
React.useImperativeHandle(ref, () => internalRef.current);
|
|
135
152
|
const reactId = React.useId();
|
|
@@ -141,12 +158,21 @@ exports.FileUpload = React.forwardRef(function FileUpload({ accept, maxSizeBytes
|
|
|
141
158
|
// component's own validation/read error — a self-detected bad pick must read
|
|
142
159
|
// as invalid even outside a form.
|
|
143
160
|
const invalid = ariaInvalid || error !== null;
|
|
161
|
+
// Resolve the announcement BEFORE deciding whether the alert exists: a
|
|
162
|
+
// consumer that returns nothing is opting out of this surface entirely,
|
|
163
|
+
// and an association pointing at an unrendered node is worse than none.
|
|
164
|
+
const resolved = error
|
|
165
|
+
? labels?.error
|
|
166
|
+
? labels.error(error)
|
|
167
|
+
: defaultErrorMessage(error)
|
|
168
|
+
: null;
|
|
169
|
+
const errorText = resolved ? resolved : null;
|
|
144
170
|
// Radix Slot OVERWRITES aria-describedby (it does not merge), so merge the
|
|
145
171
|
// primitive's own role=alert error id with the FormControl-injected one so
|
|
146
172
|
// both associations coexist on the input. A plain join, never `cn` —
|
|
147
173
|
// tailwind-merge treats these as class names and would drop an id that
|
|
148
174
|
// happens to look like a conflicting utility.
|
|
149
|
-
const describedBy = [ariaDescribedby,
|
|
175
|
+
const describedBy = [ariaDescribedby, errorText ? errorId : undefined]
|
|
150
176
|
.filter(Boolean)
|
|
151
177
|
.join(' ') || undefined;
|
|
152
178
|
// Last-resolved-wins: a slow read for pick A must not overwrite a newer
|
|
@@ -162,6 +188,15 @@ exports.FileUpload = React.forwardRef(function FileUpload({ accept, maxSizeBytes
|
|
|
162
188
|
onError?.(validationError);
|
|
163
189
|
return;
|
|
164
190
|
}
|
|
191
|
+
// Binary path: no decode, no reader, no retained garbage string. The
|
|
192
|
+
// stale-rejection clear still has to happen here — the alert is this
|
|
193
|
+
// component's own state and a good pick must retire it.
|
|
194
|
+
if (readAs === 'none') {
|
|
195
|
+
readerRef.current = null;
|
|
196
|
+
setError(null);
|
|
197
|
+
onSelect({ file, text: '' });
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
165
200
|
const reader = new FileReader();
|
|
166
201
|
readerRef.current = reader;
|
|
167
202
|
reader.onload = () => {
|
|
@@ -228,10 +263,10 @@ exports.FileUpload = React.forwardRef(function FileUpload({ accept, maxSizeBytes
|
|
|
228
263
|
return ((0, jsx_runtime_1.jsxs)("div", { className: (0, utils_1.cn)('space-y-2', className), children: [(0, jsx_runtime_1.jsxs)("div", { onClick: openPicker, onDragOver: onDragOver, onDragLeave: onDragLeave, onDrop: onDrop, className: (0, utils_1.cn)('border-input bg-card focus-within:ring-ring focus-within:ring-offset-background aria-[invalid=true]:border-destructive aria-[invalid=true]:focus-within:ring-destructive flex w-full items-center gap-3 rounded-md border px-3 py-4 text-sm shadow-xs transition-colors focus-within:ring-2 focus-within:ring-offset-1 focus-within:outline-none', dragActive &&
|
|
229
264
|
'border-ring ring-ring ring-offset-background ring-2 ring-offset-1', disabled
|
|
230
265
|
? 'border-muted bg-muted/30 cursor-not-allowed shadow-none'
|
|
231
|
-
: 'cursor-pointer'), "aria-invalid": invalid || undefined, children: [(0, jsx_runtime_1.jsx)("input", { ref: internalRef, id: inputId, type: "file", accept: accept, disabled: disabled, className: "sr-only", "aria-invalid": invalid || undefined, "aria-required": ariaRequired || undefined, "aria-describedby": describedBy, "aria-label": ariaLabel, onChange: onInputChange, ...rest }), (0, jsx_runtime_1.jsx)(lucide_react_1.Upload, { className: "text-muted-foreground size-4 shrink-0", "aria-hidden": "true" }), value ? ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsxs)("span", { className: "min-w-0 flex-1 truncate", children: [(0, jsx_runtime_1.jsx)("span", { className: "text-foreground font-medium", children: value.file.name }), ' ', (0, jsx_runtime_1.jsx)("span", { className: "text-muted-foreground tabular-nums", children: humanizeSize(value.file.size) })] }), (0, jsx_runtime_1.jsx)(icon_button_1.IconButton, { type: "button", variant: "plain", size: "small", disabled: disabled, "aria-label":
|
|
266
|
+
: 'cursor-pointer'), "aria-invalid": invalid || undefined, children: [(0, jsx_runtime_1.jsx)("input", { ref: internalRef, id: inputId, type: "file", accept: accept, disabled: disabled, className: "sr-only", "aria-invalid": invalid || undefined, "aria-required": ariaRequired || undefined, "aria-describedby": describedBy, "aria-label": ariaLabel, onChange: onInputChange, ...rest }), (0, jsx_runtime_1.jsx)(lucide_react_1.Upload, { className: "text-muted-foreground size-4 shrink-0", "aria-hidden": "true" }), value ? ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsxs)("span", { className: "min-w-0 flex-1 truncate", children: [(0, jsx_runtime_1.jsx)("span", { className: "text-foreground font-medium", children: value.file.name }), ' ', (0, jsx_runtime_1.jsx)("span", { className: "text-muted-foreground tabular-nums", children: humanizeSize(value.file.size) })] }), (0, jsx_runtime_1.jsx)(icon_button_1.IconButton, { type: "button", variant: "plain", size: "small", disabled: disabled, "aria-label": labels?.remove?.trim() ? labels.remove : 'Remove file', onClick: (event) => {
|
|
232
267
|
// Don't bubble to the zone's openPicker and re-open the dialog.
|
|
233
268
|
event.stopPropagation();
|
|
234
269
|
clear();
|
|
235
|
-
}, children: (0, jsx_runtime_1.jsx)(lucide_react_1.X, { className: "size-4", "aria-hidden": "true" }) })] })) : ((0, jsx_runtime_1.jsxs)("span", { className: "text-muted-foreground", children: [(0, jsx_runtime_1.jsx)("span", { className: "text-foreground font-medium", children:
|
|
270
|
+
}, children: (0, jsx_runtime_1.jsx)(lucide_react_1.X, { className: "size-4", "aria-hidden": "true" }) })] })) : ((0, jsx_runtime_1.jsxs)("span", { className: "text-muted-foreground", children: [(0, jsx_runtime_1.jsx)("span", { className: "text-foreground font-medium", children: labels?.action ?? 'Choose a file' }), ' ', labels?.hint ?? 'or drag and drop'] }))] }), errorText ? ((0, jsx_runtime_1.jsx)("p", { id: errorId, role: "alert", className: "text-system-error-h1a text-xs font-medium", children: errorText })) : null] }));
|
|
236
271
|
});
|
|
237
272
|
exports.FileUpload.displayName = 'FileUpload';
|
|
@@ -16,7 +16,11 @@ declare const useFormField: () => {
|
|
|
16
16
|
formDescriptionId: string;
|
|
17
17
|
formMessageId: string;
|
|
18
18
|
required: boolean | undefined;
|
|
19
|
+
described: Record<DescribingSlot, boolean> | undefined;
|
|
20
|
+
onDescribedChange: ((slot: DescribingSlot, rendered: boolean) => void) | undefined;
|
|
19
21
|
};
|
|
22
|
+
/** The two slots that can describe a control, in the order a reader hears them. */
|
|
23
|
+
type DescribingSlot = 'message' | 'description';
|
|
20
24
|
export type FormItemProps = React.HTMLAttributes<HTMLDivElement> & {
|
|
21
25
|
required?: boolean;
|
|
22
26
|
};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"form.d.ts","sourceRoot":"","sources":["../../../src/components/ui/form.tsx"],"names":[],"mappings":"AAEA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAA;AAC9B,OAAO,KAAK,cAAc,MAAM,uBAAuB,CAAA;AAEvD,OAAO,EAEL,eAAe,EACf,SAAS,EACT,WAAW,EAGZ,MAAM,iBAAiB,CAAA;AAKxB,OAAO,EAAE,oBAAoB,EAAE,MAAM,yBAAyB,CAAA;AAS9D,QAAA,MAAM,IAAI,4MAAe,CAAA;AAazB,QAAA,MAAM,SAAS,GACb,YAAY,SAAS,WAAW,GAAG,WAAW,EAC9C,KAAK,SAAS,SAAS,CAAC,YAAY,CAAC,GAAG,SAAS,CAAC,YAAY,CAAC,EAC/D,cAEC,eAAe,CAAC,YAAY,EAAE,KAAK,CAAC,sBAMtC,CAAA;AAED,QAAA,MAAM,YAAY
|
|
1
|
+
{"version":3,"file":"form.d.ts","sourceRoot":"","sources":["../../../src/components/ui/form.tsx"],"names":[],"mappings":"AAEA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAA;AAC9B,OAAO,KAAK,cAAc,MAAM,uBAAuB,CAAA;AAEvD,OAAO,EAEL,eAAe,EACf,SAAS,EACT,WAAW,EAGZ,MAAM,iBAAiB,CAAA;AAKxB,OAAO,EAAE,oBAAoB,EAAE,MAAM,yBAAyB,CAAA;AAS9D,QAAA,MAAM,IAAI,4MAAe,CAAA;AAazB,QAAA,MAAM,SAAS,GACb,YAAY,SAAS,WAAW,GAAG,WAAW,EAC9C,KAAK,SAAS,SAAS,CAAC,YAAY,CAAC,GAAG,SAAS,CAAC,YAAY,CAAC,EAC/D,cAEC,eAAe,CAAC,YAAY,EAAE,KAAK,CAAC,sBAMtC,CAAA;AAED,QAAA,MAAM,YAAY;;;;;;;;;;;;;+BA4CW,cAAc,YAAY,OAAO,KAAK,IAAI;CAjBtE,CAAA;AAED,mFAAmF;AACnF,KAAK,cAAc,GAAG,SAAS,GAAG,aAAa,CAAA;AAwC/C,MAAM,MAAM,aAAa,GAAG,KAAK,CAAC,cAAc,CAAC,cAAc,CAAC,GAAG;IACjE,QAAQ,CAAC,EAAE,OAAO,CAAA;CACnB,CAAA;AAED,QAAA,MAAM,QAAQ;eAHD,OAAO;wCA8BnB,CAAA;AAGD,MAAM,MAAM,cAAc,GAAG,KAAK,CAAC,wBAAwB,CACzD,OAAO,cAAc,CAAC,IAAI,CAC3B,GAAG;IACF,KAAK,CAAC,EAAE,KAAK,CAAC,SAAS,CAAA;CACxB,CAAA;AAED,QAAA,MAAM,SAAS;YAHL,KAAK,CAAC,SAAS;0CA6BvB,CAAA;AAGF,eAAO,MAAM,WAAW,GAAI,yBAAyB,oBAAoB,sBASxE,CAAA;AAED,QAAA,MAAM,WAAW;;gFAgCf,CAAA;AAGF,QAAA,MAAM,eAAe,yHAgBnB,CAAA;AAGF,QAAA,MAAM,WAAW,yHA6Bf,CAAA;AAGF,OAAO,EACL,YAAY,EACZ,IAAI,EACJ,QAAQ,EACR,SAAS,EACT,WAAW,EACX,eAAe,EACf,WAAW,EACX,SAAS,EACV,CAAA"}
|
|
@@ -61,7 +61,7 @@ const useFormField = () => {
|
|
|
61
61
|
const fieldState = form && fieldContext.name
|
|
62
62
|
? form.getFieldState(fieldContext.name, form.formState)
|
|
63
63
|
: undefined;
|
|
64
|
-
const { id, required } = itemContext;
|
|
64
|
+
const { id, required, described, onDescribedChange } = itemContext;
|
|
65
65
|
return {
|
|
66
66
|
id,
|
|
67
67
|
name: fieldContext.name,
|
|
@@ -69,14 +69,38 @@ const useFormField = () => {
|
|
|
69
69
|
formDescriptionId: `${id}-form-item-description`,
|
|
70
70
|
formMessageId: `${id}-form-item-message`,
|
|
71
71
|
required,
|
|
72
|
+
described,
|
|
73
|
+
onDescribedChange,
|
|
72
74
|
...fieldState
|
|
73
75
|
};
|
|
74
76
|
};
|
|
75
77
|
exports.useFormField = useFormField;
|
|
76
78
|
const FormItemContext = React.createContext({});
|
|
79
|
+
/**
|
|
80
|
+
* Announce that this slot is on the page, for as long as it is.
|
|
81
|
+
*
|
|
82
|
+
* `rendered` is a parameter rather than a caller-side condition because
|
|
83
|
+
* `FormMessage` returns null when it has nothing to say, and a hook cannot sit
|
|
84
|
+
* behind that early return.
|
|
85
|
+
*/
|
|
86
|
+
function useDescribingSlot(slot, rendered) {
|
|
87
|
+
const { onDescribedChange } = React.useContext(FormItemContext);
|
|
88
|
+
React.useEffect(() => {
|
|
89
|
+
if (!rendered)
|
|
90
|
+
return;
|
|
91
|
+
onDescribedChange?.(slot, true);
|
|
92
|
+
return () => onDescribedChange?.(slot, false);
|
|
93
|
+
}, [onDescribedChange, slot, rendered]);
|
|
94
|
+
}
|
|
77
95
|
const FormItem = React.forwardRef(({ className, required, ...props }, ref) => {
|
|
78
96
|
const id = React.useId();
|
|
79
|
-
|
|
97
|
+
const [described, setDescribed] = React.useState({ message: false, description: false });
|
|
98
|
+
// Keeps the object identity when nothing moved, so a slot re-registering
|
|
99
|
+
// does not re-render the item for no reason.
|
|
100
|
+
const onDescribedChange = React.useCallback((slot, rendered) => setDescribed((current) => current[slot] === rendered
|
|
101
|
+
? current
|
|
102
|
+
: { ...current, [slot]: rendered }), []);
|
|
103
|
+
return ((0, jsx_runtime_1.jsx)(FormItemContext.Provider, { value: { id, required, described, onDescribedChange }, children: (0, jsx_runtime_1.jsx)("div", { ref: ref, className: (0, utils_1.cn)('space-y-2', className), ...props }) }));
|
|
80
104
|
});
|
|
81
105
|
exports.FormItem = FormItem;
|
|
82
106
|
FormItem.displayName = 'FormItem';
|
|
@@ -92,15 +116,28 @@ FormLabel.displayName = 'FormLabel';
|
|
|
92
116
|
const FormTooltip = ({ children, ...others }) => ((0, jsx_runtime_1.jsx)(tooltip_1.TooltipProvider, { ...others, children: (0, jsx_runtime_1.jsxs)(tooltip_1.Tooltip, { children: [(0, jsx_runtime_1.jsx)(tooltip_1.TooltipTrigger, { asChild: true, children: (0, jsx_runtime_1.jsx)(lucide_react_1.HelpCircle, { className: "text-container-text ml-2 h-4 w-4" }) }), (0, jsx_runtime_1.jsx)(tooltip_1.TooltipContent, { children: children })] }) }));
|
|
93
117
|
exports.FormTooltip = FormTooltip;
|
|
94
118
|
const FormControl = React.forwardRef(({ ...props }, ref) => {
|
|
95
|
-
const { error, formItemId, formDescriptionId, formMessageId } = useFormField();
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
119
|
+
const { error, formItemId, formDescriptionId, formMessageId, described } = useFormField();
|
|
120
|
+
// Only ids that resolve. A screen reader drops a dangling IDREF silently, so
|
|
121
|
+
// the barrier this removes is diagnostic rather than operational: a form with
|
|
122
|
+
// thirteen broken associations is a form where a real one cannot be spotted.
|
|
123
|
+
//
|
|
124
|
+
// Message first: a reader hears the error before the hint. `undefined` when
|
|
125
|
+
// both slots are empty, which drops the attribute — and, because Radix Slot
|
|
126
|
+
// OVERWRITES rather than merges, still lets the wrapped element carry its own
|
|
127
|
+
// (`ui/file-upload` merges the injected id with its own error id that way).
|
|
128
|
+
const describedBy = [
|
|
129
|
+
described?.message ? formMessageId : undefined,
|
|
130
|
+
described?.description ? formDescriptionId : undefined
|
|
131
|
+
]
|
|
132
|
+
.filter(Boolean)
|
|
133
|
+
.join(' ') || undefined;
|
|
134
|
+
return ((0, jsx_runtime_1.jsx)(react_slot_1.Slot, { ref: ref, id: formItemId, "aria-describedby": describedBy, "aria-invalid": !!error, ...props }));
|
|
99
135
|
});
|
|
100
136
|
exports.FormControl = FormControl;
|
|
101
137
|
FormControl.displayName = 'FormControl';
|
|
102
138
|
const FormDescription = React.forwardRef(({ className, ...props }, ref) => {
|
|
103
139
|
const { formDescriptionId } = useFormField();
|
|
140
|
+
useDescribingSlot('description', true);
|
|
104
141
|
return ((0, jsx_runtime_1.jsx)("p", { ref: ref, id: formDescriptionId, className: (0, utils_1.cn)('text-muted-foreground text-xs font-medium', className), ...props }));
|
|
105
142
|
});
|
|
106
143
|
exports.FormDescription = FormDescription;
|
|
@@ -108,6 +145,11 @@ FormDescription.displayName = 'FormDescription';
|
|
|
108
145
|
const FormMessage = React.forwardRef(({ className, children, ...props }, ref) => {
|
|
109
146
|
const { error, formMessageId } = useFormField();
|
|
110
147
|
const body = error ? String(error?.message) : children;
|
|
148
|
+
// Registered on `body`, not on `error`: a message given plain children
|
|
149
|
+
// renders copy that the control has to point at too. The old expression only
|
|
150
|
+
// added the message id when react-hook-form reported an error, so a
|
|
151
|
+
// standalone message was rendered and never associated.
|
|
152
|
+
useDescribingSlot('message', Boolean(body));
|
|
111
153
|
if (!body) {
|
|
112
154
|
return null;
|
|
113
155
|
}
|
|
@@ -13,6 +13,6 @@ declare const toggleVariants: (props?: ({
|
|
|
13
13
|
declare function ToggleGroup({ className, variant, size, spacing, children, style, ...props }: React.ComponentProps<typeof ToggleGroupPrimitive.Root> & VariantProps<typeof toggleVariants> & {
|
|
14
14
|
spacing?: number;
|
|
15
15
|
}): React.JSX.Element;
|
|
16
|
-
declare function ToggleGroupItem({ className, children, variant, size, ...props }: React.ComponentProps<typeof ToggleGroupPrimitive.Item> & VariantProps<typeof toggleVariants>): React.JSX.Element;
|
|
16
|
+
declare function ToggleGroupItem({ className, children, variant, size, onFocus, ...props }: React.ComponentProps<typeof ToggleGroupPrimitive.Item> & VariantProps<typeof toggleVariants>): React.JSX.Element;
|
|
17
17
|
export { ToggleGroup, ToggleGroupItem };
|
|
18
18
|
//# sourceMappingURL=index.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../src/components/ui/toggle-group/index.tsx"],"names":[],"mappings":"AAEA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAA;AAC9B,OAAO,KAAK,oBAAoB,MAAM,8BAA8B,CAAA;AACpE,OAAO,EAAO,KAAK,YAAY,EAAE,MAAM,0BAA0B,CAAA;AAIjE;;;;GAIG;AACH,QAAA,MAAM,cAAc;;;8EAoBnB,CAAA;
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../src/components/ui/toggle-group/index.tsx"],"names":[],"mappings":"AAEA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAA;AAC9B,OAAO,KAAK,oBAAoB,MAAM,8BAA8B,CAAA;AACpE,OAAO,EAAO,KAAK,YAAY,EAAE,MAAM,0BAA0B,CAAA;AAIjE;;;;GAIG;AACH,QAAA,MAAM,cAAc;;;8EAoBnB,CAAA;AA4DD,iBAAS,WAAW,CAAC,EACnB,SAAS,EACT,OAAO,EACP,IAAI,EACJ,OAAW,EACX,QAAQ,EACR,KAAK,EACL,GAAG,KAAK,EACT,EAAE,KAAK,CAAC,cAAc,CAAC,OAAO,oBAAoB,CAAC,IAAI,CAAC,GACvD,YAAY,CAAC,OAAO,cAAc,CAAC,GAAG;IACpC,OAAO,CAAC,EAAE,MAAM,CAAA;CACjB,qBA+BF;AAED,iBAAS,eAAe,CAAC,EACvB,SAAS,EACT,QAAQ,EACR,OAAO,EACP,IAAI,EACJ,OAAO,EACP,GAAG,KAAK,EACT,EAAE,KAAK,CAAC,cAAc,CAAC,OAAO,oBAAoB,CAAC,IAAI,CAAC,GACvD,YAAY,CAAC,OAAO,cAAc,CAAC,qBAiDpC;AAED,OAAO,EAAE,WAAW,EAAE,eAAe,EAAE,CAAA"}
|
|
@@ -63,12 +63,58 @@ const toggleVariants = (0, class_variance_authority_1.cva)("hover:bg-muted hover
|
|
|
63
63
|
size: 'default'
|
|
64
64
|
}
|
|
65
65
|
});
|
|
66
|
+
const ARROW_KEYS = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'];
|
|
67
|
+
/**
|
|
68
|
+
* Is an arrow key down right now?
|
|
69
|
+
*
|
|
70
|
+
* This is how an item tells keyboard navigation apart from a pointer press or a
|
|
71
|
+
* programmatic focus, and it is the seam Radix uses for the same job in its own
|
|
72
|
+
* `RadioGroup`. It works because `RovingFocusGroup` defers the focus move to a
|
|
73
|
+
* `setTimeout`: the keydown has already bubbled to `document` and set the flag
|
|
74
|
+
* by the time the item receives `focus`.
|
|
75
|
+
*
|
|
76
|
+
* The distinction is not cosmetic. A pointer press focuses BEFORE the click
|
|
77
|
+
* lands, so selecting on every focus would turn one click into
|
|
78
|
+
* select-then-deselect and break mouse selection outright.
|
|
79
|
+
*
|
|
80
|
+
* Returns null when disabled, so `type="multiple"` carries no flag to read.
|
|
81
|
+
*/
|
|
82
|
+
function useArrowKeyPressed(enabled) {
|
|
83
|
+
const arrowKeyPressed = React.useRef(false);
|
|
84
|
+
React.useEffect(() => {
|
|
85
|
+
if (!enabled)
|
|
86
|
+
return;
|
|
87
|
+
const onKeyDown = (event) => {
|
|
88
|
+
if (ARROW_KEYS.includes(event.key))
|
|
89
|
+
arrowKeyPressed.current = true;
|
|
90
|
+
};
|
|
91
|
+
const onKeyUp = () => {
|
|
92
|
+
arrowKeyPressed.current = false;
|
|
93
|
+
};
|
|
94
|
+
document.addEventListener('keydown', onKeyDown);
|
|
95
|
+
document.addEventListener('keyup', onKeyUp);
|
|
96
|
+
window.addEventListener('blur', onKeyUp);
|
|
97
|
+
return () => {
|
|
98
|
+
document.removeEventListener('keydown', onKeyDown);
|
|
99
|
+
document.removeEventListener('keyup', onKeyUp);
|
|
100
|
+
window.removeEventListener('blur', onKeyUp);
|
|
101
|
+
// A group unmounted mid-keystroke never sees the keyup.
|
|
102
|
+
arrowKeyPressed.current = false;
|
|
103
|
+
};
|
|
104
|
+
}, [enabled]);
|
|
105
|
+
return enabled ? arrowKeyPressed : null;
|
|
106
|
+
}
|
|
66
107
|
const ToggleGroupContext = React.createContext({
|
|
67
108
|
size: 'default',
|
|
68
109
|
variant: 'default',
|
|
69
|
-
spacing: 0
|
|
110
|
+
spacing: 0,
|
|
111
|
+
arrowKeyPressed: null
|
|
70
112
|
});
|
|
71
113
|
function ToggleGroup({ className, variant, size, spacing = 0, children, style, ...props }) {
|
|
114
|
+
// Read, never destructured: `type` is the discriminant of the Radix props
|
|
115
|
+
// union, and pulling it out then spreading it back widens the union so the
|
|
116
|
+
// single/multiple props stop resolving.
|
|
117
|
+
const arrowKeyPressed = useArrowKeyPressed(props.type === 'single');
|
|
72
118
|
return ((0, jsx_runtime_1.jsx)(ToggleGroupPrimitive.Root, { "data-slot": "toggle-group", "data-variant": variant, "data-size": size, "data-spacing": spacing,
|
|
73
119
|
// Caller style first: `--gap` is ours and must survive, but everything
|
|
74
120
|
// else the caller passes has to reach the element.
|
|
@@ -76,12 +122,35 @@ function ToggleGroup({ className, variant, size, spacing = 0, children, style, .
|
|
|
76
122
|
// Legacy carried a `data-[spacing=default]:...:shadow-xs` rule here that
|
|
77
123
|
// never matched — `data-spacing` is always a number. Removed rather than
|
|
78
124
|
// activated, so the rendered pixels stay identical to sindarian-x.
|
|
79
|
-
'group/toggle-group flex w-fit items-center gap-[--spacing(var(--gap))] rounded-md', className), ...props, children: (0, jsx_runtime_1.jsx)(ToggleGroupContext.Provider, { value: { variant, size, spacing }, children: children }) }));
|
|
125
|
+
'group/toggle-group flex w-fit items-center gap-[--spacing(var(--gap))] rounded-md', className), ...props, children: (0, jsx_runtime_1.jsx)(ToggleGroupContext.Provider, { value: { variant, size, spacing, arrowKeyPressed }, children: children }) }));
|
|
80
126
|
}
|
|
81
|
-
function ToggleGroupItem({ className, children, variant, size, ...props }) {
|
|
127
|
+
function ToggleGroupItem({ className, children, variant, size, onFocus, ...props }) {
|
|
82
128
|
const context = React.useContext(ToggleGroupContext);
|
|
129
|
+
/**
|
|
130
|
+
* Selection follows focus, which is what `role="radio"` promises.
|
|
131
|
+
*
|
|
132
|
+
* With `type="single"` Radix puts the group on the ARIA radio pattern: the
|
|
133
|
+
* root is a `radiogroup`, each item a `radio` with `aria-checked`. Under that
|
|
134
|
+
* pattern an arrow key both moves focus and checks the item it lands on.
|
|
135
|
+
* Radix only moves focus, so a screen-reader operator used to arrow across
|
|
136
|
+
* "radio, not checked, 2 of 3" with nothing ever selected.
|
|
137
|
+
*
|
|
138
|
+
* `type="multiple"` is a `toolbar` of `aria-pressed` buttons and keeps its
|
|
139
|
+
* current behavior, where moving focus without pressing is correct: the flag
|
|
140
|
+
* is null there.
|
|
141
|
+
*
|
|
142
|
+
* Clicking the element rather than writing the value keeps a single
|
|
143
|
+
* activation path, so Radix's own toggle and any caller `onClick` both fire
|
|
144
|
+
* exactly as they do for a real click.
|
|
145
|
+
*/
|
|
146
|
+
const handleFocus = (event) => {
|
|
147
|
+
onFocus?.(event);
|
|
148
|
+
if (context.arrowKeyPressed?.current) {
|
|
149
|
+
event.currentTarget.click();
|
|
150
|
+
}
|
|
151
|
+
};
|
|
83
152
|
return ((0, jsx_runtime_1.jsx)(ToggleGroupPrimitive.Item, { "data-slot": "toggle-group-item", "data-variant": context.variant || variant, "data-size": context.size || size, "data-spacing": context.spacing, className: (0, utils_1.cn)(toggleVariants({
|
|
84
153
|
variant: context.variant || variant,
|
|
85
154
|
size: context.size || size
|
|
86
|
-
}), 'w-auto min-w-0 shrink-0 px-3 focus:z-10 focus-visible:z-10', 'data-[spacing=0]:rounded-none data-[spacing=0]:shadow-none data-[spacing=0]:first:rounded-l-md data-[spacing=0]:last:rounded-r-md data-[spacing=0]:data-[variant=outline]:border-l-0 data-[spacing=0]:data-[variant=outline]:first:border-l', className), ...props, children: children }));
|
|
155
|
+
}), 'w-auto min-w-0 shrink-0 px-3 focus:z-10 focus-visible:z-10', 'data-[spacing=0]:rounded-none data-[spacing=0]:shadow-none data-[spacing=0]:first:rounded-l-md data-[spacing=0]:last:rounded-r-md data-[spacing=0]:data-[variant=outline]:border-l-0 data-[spacing=0]:data-[variant=outline]:first:border-l', className), ...props, onFocus: handleFocus, children: children }));
|
|
87
156
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { ReactNode } from 'react';
|
|
2
2
|
import { Control, FieldPathByValue, FieldValues } from 'react-hook-form';
|
|
3
|
-
import { type FileUploadResult } from '../../../components/ui/file-upload/index.js';
|
|
3
|
+
import { type FileUploadError, type FileUploadLabels, type FileUploadResult } from '../../../components/ui/file-upload/index.js';
|
|
4
4
|
import { type RenderableLabel } from '../../../components/form/renderable-label.js';
|
|
5
5
|
type FileUploadFieldOwnProps<T extends FieldValues = FieldValues> = {
|
|
6
6
|
control: Control<T>;
|
|
@@ -17,6 +17,15 @@ type FileUploadFieldOwnProps<T extends FieldValues = FieldValues> = {
|
|
|
17
17
|
maxSizeBytes?: number;
|
|
18
18
|
disabled?: boolean;
|
|
19
19
|
className?: string;
|
|
20
|
+
/**
|
|
21
|
+
* Override the picker's user-visible copy. Threaded straight to FileUpload;
|
|
22
|
+
* omitted fields keep the English defaults. Note `labels.error` governs the
|
|
23
|
+
* PICKER's own size/type/read refusal only — resolver messages still come
|
|
24
|
+
* from the schema through FormMessage.
|
|
25
|
+
*/
|
|
26
|
+
labels?: FileUploadLabels;
|
|
27
|
+
/** Observe size, type, and read failures after the form value is cleared. */
|
|
28
|
+
onError?: (error: FileUploadError) => void;
|
|
20
29
|
/** Optional escape hatch to also observe the picked File/clear alongside the form's text value. */
|
|
21
30
|
onSelect?: (result: FileUploadResult | null) => void;
|
|
22
31
|
};
|
|
@@ -47,6 +56,6 @@ export type FileUploadFieldProps<T extends FieldValues = FieldValues> = FileUplo
|
|
|
47
56
|
* rejection; on resolver failure the FormMessage announces. Both surfaces are
|
|
48
57
|
* wired.
|
|
49
58
|
*/
|
|
50
|
-
export declare const FileUploadField: <T extends FieldValues = FieldValues>({ control, name, label, description, tooltip, required, accept, maxSizeBytes, disabled, className, onSelect, "aria-label": ariaLabelProp }: FileUploadFieldProps<T>) => import("react").JSX.Element;
|
|
59
|
+
export declare const FileUploadField: <T extends FieldValues = FieldValues>({ control, name, label, description, tooltip, required, accept, maxSizeBytes, disabled, className, labels, onError, onSelect, "aria-label": ariaLabelProp }: FileUploadFieldProps<T>) => import("react").JSX.Element;
|
|
51
60
|
export {};
|
|
52
61
|
//# sourceMappingURL=index.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../../src/components/form/file-upload-field/index.tsx"],"names":[],"mappings":"AAEA,OAAO,EAAE,SAAS,EAAY,MAAM,OAAO,CAAA;AAC3C,OAAO,EAAE,OAAO,EAAE,gBAAgB,EAAE,WAAW,EAAE,MAAM,iBAAiB,CAAA;AAWxE,OAAO,
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../../src/components/form/file-upload-field/index.tsx"],"names":[],"mappings":"AAEA,OAAO,EAAE,SAAS,EAAY,MAAM,OAAO,CAAA;AAC3C,OAAO,EAAE,OAAO,EAAE,gBAAgB,EAAE,WAAW,EAAE,MAAM,iBAAiB,CAAA;AAWxE,OAAO,EAEL,KAAK,eAAe,EACpB,KAAK,gBAAgB,EACrB,KAAK,gBAAgB,EACtB,MAAM,6BAA6B,CAAA;AACpC,OAAO,EAEL,KAAK,eAAe,EACrB,MAAM,oCAAoC,CAAA;AAE3C,KAAK,uBAAuB,CAAC,CAAC,SAAS,WAAW,GAAG,WAAW,IAAI;IAClE,OAAO,EAAE,OAAO,CAAC,CAAC,CAAC,CAAA;IACnB;;;;OAIG;IACH,IAAI,EAAE,gBAAgB,CAAC,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC,CAAA;IAC7C,WAAW,CAAC,EAAE,SAAS,CAAA;IACvB,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB;;;;;OAKG;IACH,MAAM,CAAC,EAAE,gBAAgB,CAAA;IACzB,6EAA6E;IAC7E,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,eAAe,KAAK,IAAI,CAAA;IAC1C,mGAAmG;IACnG,QAAQ,CAAC,EAAE,CAAC,MAAM,EAAE,gBAAgB,GAAG,IAAI,KAAK,IAAI,CAAA;CACrD,CAAA;AAED;;;;GAIG;AACH,MAAM,MAAM,oBAAoB,CAAC,CAAC,SAAS,WAAW,GAAG,WAAW,IAClE,uBAAuB,CAAC,CAAC,CAAC,GACxB,CACI;IAAE,KAAK,EAAE,eAAe,CAAC;IAAC,YAAY,CAAC,EAAE,MAAM,CAAA;CAAE,GACjD;IAAE,KAAK,CAAC,EAAE,KAAK,CAAC;IAAC,YAAY,EAAE,MAAM,CAAA;CAAE,CAC1C,CAAA;AAEL;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,eAAe,GAAI,CAAC,SAAS,WAAW,GAAG,WAAW,EAAE,6JAelE,oBAAoB,CAAC,CAAC,CAAC,gCAoFzB,CAAA"}
|
|
@@ -19,7 +19,7 @@ import { hasRenderableLabel } from '../../../components/form/renderable-label.js
|
|
|
19
19
|
* rejection; on resolver failure the FormMessage announces. Both surfaces are
|
|
20
20
|
* wired.
|
|
21
21
|
*/
|
|
22
|
-
export const FileUploadField = ({ control, name, label, description, tooltip, required, accept, maxSizeBytes, disabled, className, onSelect, 'aria-label': ariaLabelProp }) => {
|
|
22
|
+
export const FileUploadField = ({ control, name, label, description, tooltip, required, accept, maxSizeBytes, disabled, className, labels, onError, onSelect, 'aria-label': ariaLabelProp }) => {
|
|
23
23
|
const [localValue, setLocalValue] = useState(null);
|
|
24
24
|
const showLabel = hasRenderableLabel(label);
|
|
25
25
|
// An empty or whitespace-only aria-label is worse than none: it names the
|
|
@@ -51,10 +51,15 @@ export const FileUploadField = ({ control, name, label, description, tooltip, re
|
|
|
51
51
|
// value carries nothing else to tell them apart. Fix needs a reset epoch
|
|
52
52
|
// from react-hook-form, or a form value richer than the file text.
|
|
53
53
|
const shown = localValue && field.value === localValue.text ? localValue : null;
|
|
54
|
-
return (_jsxs(FormItem, { required: required, children: [showLabel && (_jsx(FormLabel, { extra: tooltip ? _jsx(FormTooltip, { children: tooltip }) : undefined, children: label })), _jsx(FormControl, { children: _jsx(FileUpload, { ref: field.ref, name: field.name, onBlur: field.onBlur, "aria-label": ariaLabel, accept: accept, maxSizeBytes: maxSizeBytes, disabled: disabled, className: className, value: shown, onSelect: (result) => {
|
|
54
|
+
return (_jsxs(FormItem, { required: required, children: [showLabel && (_jsx(FormLabel, { extra: tooltip ? _jsx(FormTooltip, { children: tooltip }) : undefined, children: label })), _jsx(FormControl, { children: _jsx(FileUpload, { ref: field.ref, name: field.name, onBlur: field.onBlur, "aria-label": ariaLabel, labels: labels, accept: accept, maxSizeBytes: maxSizeBytes, disabled: disabled, className: className, value: shown, onSelect: (result) => {
|
|
55
55
|
setLocalValue(result);
|
|
56
56
|
field.onChange(result?.text ?? '');
|
|
57
57
|
onSelect?.(result);
|
|
58
|
-
}, onError: () =>
|
|
58
|
+
}, onError: (error) => {
|
|
59
|
+
setLocalValue(null);
|
|
60
|
+
field.onChange('');
|
|
61
|
+
onSelect?.(null);
|
|
62
|
+
onError?.(error);
|
|
63
|
+
} }) }), _jsx(FormMessage, {}), description && _jsx(FormDescription, { children: description })] }));
|
|
59
64
|
} }));
|
|
60
65
|
};
|
|
@@ -1,9 +1,48 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { type InputRef } from '../../../components/ui/input/index.js';
|
|
2
|
+
import { HTMLInputTypeAttribute, ReactNode, Ref } from 'react';
|
|
2
3
|
import { Control, FieldValues } from 'react-hook-form';
|
|
3
4
|
export type InputFieldProps<T extends FieldValues = FieldValues> = {
|
|
4
5
|
className?: string;
|
|
5
6
|
name: string;
|
|
6
7
|
type?: HTMLInputTypeAttribute;
|
|
8
|
+
/**
|
|
9
|
+
* Announce the field's own invalidity. Reaches the rendered control on BOTH
|
|
10
|
+
* branches, so a field with no `control` — and therefore no react-hook-form
|
|
11
|
+
* error state — can still tell a screen reader it is invalid.
|
|
12
|
+
*
|
|
13
|
+
* ORed with the form's error, never overriding it: `false` here cannot
|
|
14
|
+
* suppress a live resolver error. Nobody asked to mute a real validation
|
|
15
|
+
* failure, and on a financial console a muted one is a wrong number reaching
|
|
16
|
+
* a money path.
|
|
17
|
+
*
|
|
18
|
+
* TypeScript exempts hyphenated JSX attributes from excess-property
|
|
19
|
+
* checking, so passing this used to compile and then vanish.
|
|
20
|
+
*/
|
|
21
|
+
'aria-invalid'?: boolean;
|
|
22
|
+
/** Lower bound, forwarded to the single-line control on both branches. */
|
|
23
|
+
min?: number | string;
|
|
24
|
+
/** Upper bound, forwarded to the single-line control on both branches. */
|
|
25
|
+
max?: number | string;
|
|
26
|
+
/** Increment, forwarded to the single-line control on both branches. */
|
|
27
|
+
step?: number | string;
|
|
28
|
+
/** Character ceiling, forwarded on both branches (single-line and textarea). */
|
|
29
|
+
maxLength?: number;
|
|
30
|
+
/** Browser autofill hint. `"off"` is the one a money field usually wants. */
|
|
31
|
+
autoComplete?: React.HTMLInputAutoCompleteAttribute;
|
|
32
|
+
/** Virtual-keyboard hint, e.g. `"numeric"` or `"decimal"`. Both branches. */
|
|
33
|
+
inputMode?: React.HTMLAttributes<HTMLElement>['inputMode'];
|
|
34
|
+
/** Native validation regex. Single-line only — a textarea has no `pattern`. */
|
|
35
|
+
pattern?: string;
|
|
36
|
+
/** Focus the control on mount. React focuses the node rather than emitting an attribute. */
|
|
37
|
+
autoFocus?: boolean;
|
|
38
|
+
/**
|
|
39
|
+
* Imperative handle on the rendered control: `focus()`/`blur()` on the
|
|
40
|
+
* single-line branch, the textarea node on the `textArea` branch (which
|
|
41
|
+
* satisfies the same shape). COMPOSED with react-hook-form's own ref when
|
|
42
|
+
* `control` is present, never replacing it — the form keeps the node it
|
|
43
|
+
* needs for `shouldFocusError`.
|
|
44
|
+
*/
|
|
45
|
+
ref?: Ref<InputRef>;
|
|
7
46
|
label?: ReactNode;
|
|
8
47
|
tooltip?: string;
|
|
9
48
|
labelExtra?: ReactNode;
|
|
@@ -32,5 +71,5 @@ export type InputFieldProps<T extends FieldValues = FieldValues> = {
|
|
|
32
71
|
'data-testid'?: string;
|
|
33
72
|
onChange?: (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => void;
|
|
34
73
|
};
|
|
35
|
-
export declare const InputField: <T extends FieldValues = FieldValues>({ className, type, label, tooltip, labelExtra, placeholder, description, startAdornment, endAdornment, required, readOnly, minHeight, maxHeight, textArea, defaultValue, value, onChange, ...others }: InputFieldProps<T>) => import("react").JSX.Element;
|
|
74
|
+
export declare const InputField: <T extends FieldValues = FieldValues>({ className, type, label, tooltip, labelExtra, placeholder, description, startAdornment, endAdornment, required, readOnly, minHeight, maxHeight, textArea, defaultValue, value, onChange, min, max, step, maxLength, autoComplete, inputMode, pattern, autoFocus, ref, "aria-invalid": ariaInvalid, ...others }: InputFieldProps<T>) => import("react").JSX.Element;
|
|
36
75
|
//# sourceMappingURL=index.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../../src/components/form/input-field/index.tsx"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../../src/components/form/input-field/index.tsx"],"names":[],"mappings":"AAUA,OAAO,EAAS,KAAK,QAAQ,EAAE,MAAM,uBAAuB,CAAA;AAC5D,OAAO,EAAE,sBAAsB,EAAE,SAAS,EAAE,GAAG,EAAmB,MAAM,OAAO,CAAA;AAC/E,OAAO,EAAE,OAAO,EAAkB,WAAW,EAAQ,MAAM,iBAAiB,CAAA;AAE5E,MAAM,MAAM,eAAe,CAAC,CAAC,SAAS,WAAW,GAAG,WAAW,IAAI;IACjE,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,IAAI,EAAE,MAAM,CAAA;IACZ,IAAI,CAAC,EAAE,sBAAsB,CAAA;IAC7B;;;;;;;;;;;;OAYG;IACH,cAAc,CAAC,EAAE,OAAO,CAAA;IACxB,0EAA0E;IAC1E,GAAG,CAAC,EAAE,MAAM,GAAG,MAAM,CAAA;IACrB,0EAA0E;IAC1E,GAAG,CAAC,EAAE,MAAM,GAAG,MAAM,CAAA;IACrB,wEAAwE;IACxE,IAAI,CAAC,EAAE,MAAM,GAAG,MAAM,CAAA;IACtB,gFAAgF;IAChF,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,6EAA6E;IAC7E,YAAY,CAAC,EAAE,KAAK,CAAC,8BAA8B,CAAA;IACnD,6EAA6E;IAC7E,SAAS,CAAC,EAAE,KAAK,CAAC,cAAc,CAAC,WAAW,CAAC,CAAC,WAAW,CAAC,CAAA;IAC1D,+EAA+E;IAC/E,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,4FAA4F;IAC5F,SAAS,CAAC,EAAE,OAAO,CAAA;IACnB;;;;;;OAMG;IACH,GAAG,CAAC,EAAE,GAAG,CAAC,QAAQ,CAAC,CAAA;IACnB,KAAK,CAAC,EAAE,SAAS,CAAA;IACjB,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,UAAU,CAAC,EAAE,SAAS,CAAA;IACtB,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,WAAW,CAAC,EAAE,SAAS,CAAA;IACvB,cAAc,CAAC,EAAE,SAAS,CAAA;IAC1B,YAAY,CAAC,EAAE,SAAS,CAAA;IACxB;oDACgD;IAChD,OAAO,CAAC,EAAE,OAAO,CAAC,CAAC,CAAC,CAAA;IACpB,kDAAkD;IAClD,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB;;;;;OAKG;IACH,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,aAAa,CAAC,EAAE,MAAM,CAAA;IACtB,QAAQ,CAAC,EAAE,CACT,CAAC,EAAE,KAAK,CAAC,WAAW,CAAC,gBAAgB,GAAG,mBAAmB,CAAC,KACzD,IAAI,CAAA;CACV,CAAA;AA4CD,eAAO,MAAM,UAAU,GAAI,CAAC,SAAS,WAAW,GAAG,WAAW,EAAE,iTA6B7D,eAAe,CAAC,CAAC,CAAC,gCAyHpB,CAAA"}
|
|
@@ -2,8 +2,59 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
2
2
|
import { AutosizeTextarea } from '../../../components/ui/autosize-textarea/index.js';
|
|
3
3
|
import { FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage, FormTooltip } from '../../../components/ui/form.js';
|
|
4
4
|
import { Input } from '../../../components/ui/input/index.js';
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
import { useMemo, useRef } from 'react';
|
|
6
|
+
/**
|
|
7
|
+
* Fan one node out to several refs. React hands a node to ONE ref, so a
|
|
8
|
+
* consumer ref and react-hook-form's `field.ref` cannot both be passed
|
|
9
|
+
* directly — one silently wins. Only used when the consumer actually supplied
|
|
10
|
+
* a ref, so a field without one keeps `field.ref`'s own identity and React
|
|
11
|
+
* never re-attaches it.
|
|
12
|
+
*/
|
|
13
|
+
function fanOutRef(...refs) {
|
|
14
|
+
return (node) => {
|
|
15
|
+
const cleanups = [];
|
|
16
|
+
for (const ref of refs) {
|
|
17
|
+
if (typeof ref === 'function') {
|
|
18
|
+
const cleanup = ref(node);
|
|
19
|
+
cleanups.push(typeof cleanup === 'function' ? cleanup : () => ref(null));
|
|
20
|
+
}
|
|
21
|
+
else if (ref) {
|
|
22
|
+
;
|
|
23
|
+
ref.current = node;
|
|
24
|
+
cleanups.push(() => {
|
|
25
|
+
;
|
|
26
|
+
ref.current = null;
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return () => cleanups.forEach((cleanup) => cleanup());
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
export const InputField = ({ className, type, label, tooltip, labelExtra, placeholder, description, startAdornment, endAdornment, required, readOnly, minHeight, maxHeight, textArea, defaultValue, value, onChange, min, max, step, maxLength, autoComplete, inputMode, pattern, autoFocus, ref, 'aria-invalid': ariaInvalid, ...others }) => {
|
|
34
|
+
// OR with the form's error, expressed through the Slot merge rather than by
|
|
35
|
+
// reading the form: FormControl already injects `aria-invalid={!!error}`, and
|
|
36
|
+
// a child prop only wins that merge when the KEY is present. So emit the key
|
|
37
|
+
// only for an explicit `true`, and let FormControl answer for every other
|
|
38
|
+
// case. `true || error` is true; `false || error` is whatever the form says,
|
|
39
|
+
// which is the whole point — an explicit `false` cannot mute a live error.
|
|
40
|
+
// With no `control` there is no error to OR against, so FormControl injects
|
|
41
|
+
// `false` and today's behaviour is unchanged.
|
|
42
|
+
const ariaInvalidProp = ariaInvalid ? { 'aria-invalid': true } : undefined;
|
|
43
|
+
const formRef = useRef(undefined);
|
|
44
|
+
const composedRef = useMemo(() => ref ? fanOutRef((node) => formRef.current?.(node), ref) : ref,
|
|
45
|
+
// `others.name` belongs here, and leaving it out is not a lint nit.
|
|
46
|
+
// react-hook-form hands a fresh `field.ref` for every name, and that ref
|
|
47
|
+
// only receives the element when React reattaches, which it does only when
|
|
48
|
+
// this callback's identity changes. Memoised on `ref` alone, one identity
|
|
49
|
+
// survived a name change, the new field never got its element, and
|
|
50
|
+
// `form.setFocus(newName)` then found nothing to focus and said nothing.
|
|
51
|
+
[ref, others.name]);
|
|
52
|
+
const renderItem = (binding) => (_jsxs(FormItem, { required: required, children: [label && (_jsx(FormLabel, { extra: tooltip ? _jsx(FormTooltip, { children: tooltip }) : labelExtra, children: label })), _jsx(FormControl, { children: textArea ? (_jsx(AutosizeTextarea, { className: className, placeholder: placeholder, readOnly: readOnly, minHeight: minHeight, maxHeight: maxHeight, maxLength: maxLength, autoComplete: autoComplete, inputMode: inputMode, autoFocus: autoFocus, "data-testid": others['data-testid'], ...ariaInvalidProp, ...binding })) : (_jsx(Input, { className: className, type: type, placeholder: placeholder, readOnly: readOnly, startAdornment: startAdornment, endAdornment: endAdornment,
|
|
53
|
+
// Bounds belong to the single-line control only: `min`/`max`/`step`
|
|
54
|
+
// mean nothing on a textarea and would render as invalid markup.
|
|
55
|
+
min: min, max: max, step: step, maxLength: maxLength, autoComplete: autoComplete, inputMode: inputMode,
|
|
56
|
+
// Single-line only: `<textarea pattern>` is not a thing.
|
|
57
|
+
pattern: pattern, autoFocus: autoFocus, "data-testid": others['data-testid'], ...ariaInvalidProp, ...binding })) }), _jsx(FormMessage, {}), description && _jsx(FormDescription, { children: description })] }));
|
|
7
58
|
if (!others.control) {
|
|
8
59
|
return renderItem({
|
|
9
60
|
name: others.name,
|
|
@@ -11,7 +62,10 @@ export const InputField = ({ className, type, label, tooltip, labelExtra, placeh
|
|
|
11
62
|
// Controlled when the caller holds the value, uncontrolled when it only
|
|
12
63
|
// seeds one — supplying both is what React warns about.
|
|
13
64
|
...(value !== undefined ? { value } : { defaultValue }),
|
|
14
|
-
onChange: (e) => onChange?.(e)
|
|
65
|
+
onChange: (e) => onChange?.(e),
|
|
66
|
+
// Nothing else claims the node on this branch, so the consumer ref goes
|
|
67
|
+
// straight through. Without it there was no node to focus at all.
|
|
68
|
+
ref: ref
|
|
15
69
|
});
|
|
16
70
|
}
|
|
17
71
|
return (_jsx(FormField, { ...others, name: others.name,
|
|
@@ -19,16 +73,20 @@ export const InputField = ({ className, type, label, tooltip, labelExtra, placeh
|
|
|
19
73
|
// value painted on the box only makes the field LOOK filled while the
|
|
20
74
|
// form still submits nothing for that name. Controller seeds its own
|
|
21
75
|
// state from this, so `field.value` carries the seed on both branches.
|
|
22
|
-
defaultValue: defaultValue, render: ({ field }) =>
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
field.
|
|
31
|
-
onChange
|
|
32
|
-
|
|
33
|
-
|
|
76
|
+
defaultValue: defaultValue, render: ({ field }) => {
|
|
77
|
+
formRef.current = field.ref;
|
|
78
|
+
return renderItem({
|
|
79
|
+
...field,
|
|
80
|
+
// Controlled from mount. react-hook-form hands `undefined` for any
|
|
81
|
+
// name the form declares no default for; React then mounts the box
|
|
82
|
+
// uncontrolled and flips it on the first keystroke, losing that
|
|
83
|
+
// character.
|
|
84
|
+
value: field.value ?? '',
|
|
85
|
+
onChange: (e) => {
|
|
86
|
+
field.onChange(e);
|
|
87
|
+
onChange?.(e);
|
|
88
|
+
},
|
|
89
|
+
ref: (ref ? composedRef : field.ref)
|
|
90
|
+
});
|
|
91
|
+
} }));
|
|
34
92
|
};
|