@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
|
@@ -13,7 +13,15 @@ type SelectFieldSharedProps<T extends FieldValues = FieldValues> = PropsWithChil
|
|
|
13
13
|
* with `value` + `onChange`. */
|
|
14
14
|
control?: Control<T>;
|
|
15
15
|
required?: boolean;
|
|
16
|
+
/** Copy for the "nothing to pick" row. Defaults to "No options found." */
|
|
16
17
|
emptyMessage?: string;
|
|
18
|
+
/**
|
|
19
|
+
* Placed on the field's own root box, merged with its spacing rather than
|
|
20
|
+
* replacing it. The root is the element a parent grid positions, so
|
|
21
|
+
* without this a consumer had to wrap the field in a spare div just to
|
|
22
|
+
* span a column.
|
|
23
|
+
*/
|
|
24
|
+
className?: string;
|
|
17
25
|
'data-testid'?: string;
|
|
18
26
|
};
|
|
19
27
|
/**
|
|
@@ -39,6 +47,6 @@ type SelectFieldModeProps = {
|
|
|
39
47
|
onChange?: (value: string) => void;
|
|
40
48
|
};
|
|
41
49
|
export type SelectFieldProps<T extends FieldValues = FieldValues> = SelectFieldSharedProps<T> & SelectFieldModeProps;
|
|
42
|
-
export declare const SelectField: <T extends FieldValues = FieldValues>({ name, label, tooltip, labelExtra, required, placeholder, description, disabled, readOnly, multi, control, value, children, emptyMessage, onChange, ...others }: SelectFieldProps<T>) => React.JSX.Element;
|
|
50
|
+
export declare const SelectField: <T extends FieldValues = FieldValues>({ name, label, tooltip, labelExtra, required, placeholder, description, disabled, readOnly, multi, control, value, children, emptyMessage, className, onChange, ...others }: SelectFieldProps<T>) => React.JSX.Element;
|
|
43
51
|
export {};
|
|
44
52
|
//# sourceMappingURL=index.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../../src/components/form/select-field/index.tsx"],"names":[],"mappings":"AAuBA,OAAO,KAAK,EAAE,EAAE,iBAAiB,EAAE,SAAS,EAAE,MAAM,OAAO,CAAA;AAC3D,OAAO,EACL,OAAO,EAEP,WAAW,EAEZ,MAAM,iBAAiB,CAAA;AAExB,KAAK,sBAAsB,CAAC,CAAC,SAAS,WAAW,GAAG,WAAW,IAC7D,iBAAiB,GAAG;IAClB,IAAI,EAAE,MAAM,CAAA;IACZ,KAAK,CAAC,EAAE,SAAS,CAAA;IACjB,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,UAAU,CAAC,EAAE,KAAK,CAAC,SAAS,CAAA;IAC5B,WAAW,CAAC,EAAE,SAAS,CAAA;IACvB,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB;qCACiC;IACjC,OAAO,CAAC,EAAE,OAAO,CAAC,CAAC,CAAC,CAAA;IACpB,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,aAAa,CAAC,EAAE,MAAM,CAAA;CACvB,CAAA;AAEH;;;;;;;;GAQG;AACH,KAAK,oBAAoB,GACrB;IACE,KAAK,EAAE,IAAI,CAAA;IACX;yEACqE;IACrE,KAAK,CAAC,EAAE,MAAM,EAAE,CAAA;IAChB,QAAQ,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,IAAI,CAAA;CACrC,GACD;IACE,KAAK,CAAC,EAAE,KAAK,CAAA;IACb;yEACqE;IACrE,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,QAAQ,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAA;CACnC,CAAA;AAEL,MAAM,MAAM,gBAAgB,CAAC,CAAC,SAAS,WAAW,GAAG,WAAW,IAC9D,sBAAsB,CAAC,CAAC,CAAC,GAAG,oBAAoB,CAAA;AAWlD,eAAO,MAAM,WAAW,GAAI,CAAC,SAAS,WAAW,GAAG,WAAW,EAAE,
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../../src/components/form/select-field/index.tsx"],"names":[],"mappings":"AAuBA,OAAO,KAAK,EAAE,EAAE,iBAAiB,EAAE,SAAS,EAAE,MAAM,OAAO,CAAA;AAC3D,OAAO,EACL,OAAO,EAEP,WAAW,EAEZ,MAAM,iBAAiB,CAAA;AAExB,KAAK,sBAAsB,CAAC,CAAC,SAAS,WAAW,GAAG,WAAW,IAC7D,iBAAiB,GAAG;IAClB,IAAI,EAAE,MAAM,CAAA;IACZ,KAAK,CAAC,EAAE,SAAS,CAAA;IACjB,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,UAAU,CAAC,EAAE,KAAK,CAAC,SAAS,CAAA;IAC5B,WAAW,CAAC,EAAE,SAAS,CAAA;IACvB,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB;qCACiC;IACjC,OAAO,CAAC,EAAE,OAAO,CAAC,CAAC,CAAC,CAAA;IACpB,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB,0EAA0E;IAC1E,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB;;;;;OAKG;IACH,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,aAAa,CAAC,EAAE,MAAM,CAAA;CACvB,CAAA;AAEH;;;;;;;;GAQG;AACH,KAAK,oBAAoB,GACrB;IACE,KAAK,EAAE,IAAI,CAAA;IACX;yEACqE;IACrE,KAAK,CAAC,EAAE,MAAM,EAAE,CAAA;IAChB,QAAQ,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,IAAI,CAAA;CACrC,GACD;IACE,KAAK,CAAC,EAAE,KAAK,CAAA;IACb;yEACqE;IACrE,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,QAAQ,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAA;CACnC,CAAA;AAEL,MAAM,MAAM,gBAAgB,CAAC,CAAC,SAAS,WAAW,GAAG,WAAW,IAC9D,sBAAsB,CAAC,CAAC,CAAC,GAAG,oBAAoB,CAAA;AAWlD,eAAO,MAAM,WAAW,GAAI,CAAC,SAAS,WAAW,GAAG,WAAW,EAAE,6KAkB9D,gBAAgB,CAAC,CAAC,CAAC,sBA6FrB,CAAA"}
|
|
@@ -3,14 +3,14 @@ import { FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessa
|
|
|
3
3
|
import { MultipleSelect, MultipleSelectContent, MultipleSelectTrigger, MultipleSelectValue } from '../../../components/ui/multiple-select/index.js';
|
|
4
4
|
import { Select, SelectContent, SelectEmpty, SelectTrigger, SelectValue } from '../../../components/ui/select/index.js';
|
|
5
5
|
import { cn } from '../../../lib/utils.js';
|
|
6
|
-
export const SelectField = ({ name, label, tooltip, labelExtra, required, placeholder, description, disabled, readOnly, multi, control, value, children, emptyMessage = 'No options found.', onChange, ...others }) => {
|
|
6
|
+
export const SelectField = ({ name, label, tooltip, labelExtra, required, placeholder, description, disabled, readOnly, multi, control, value, children, emptyMessage = 'No options found.', className, onChange, ...others }) => {
|
|
7
7
|
// The props union already pairs `multi` with the callback shape that branch
|
|
8
8
|
// emits, but destructuring an intersection loses that correlation — TS sees
|
|
9
9
|
// two unrelated callbacks. Both branches report through this one seam, each
|
|
10
10
|
// with the payload its own union member declared.
|
|
11
11
|
const emitChange = onChange;
|
|
12
12
|
const renderItem = (field) => {
|
|
13
|
-
return (_jsxs(FormItem, { required: required, children: [label && (_jsx(FormLabel, { extra: tooltip ? _jsx(FormTooltip, { children: tooltip }) : labelExtra, children: label })), multi ? (_jsxs(MultipleSelect, { onValueChange: (value) => {
|
|
13
|
+
return (_jsxs(FormItem, { required: required, className: className, children: [label && (_jsx(FormLabel, { extra: tooltip ? _jsx(FormTooltip, { children: tooltip }) : labelExtra, children: label })), multi ? (_jsxs(MultipleSelect, { onValueChange: (value) => {
|
|
14
14
|
field.onChange(value);
|
|
15
15
|
emitChange?.(value);
|
|
16
16
|
}, disabled: disabled, ...field, children: [_jsx(MultipleSelectTrigger, { readOnly: readOnly, children: _jsx(MultipleSelectValue, { placeholder: placeholder }) }), _jsx(MultipleSelectContent, { children: children })] })) : (_jsxs(Select, { onValueChange: (value) => {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../../src/components/ui/calendar/index.tsx"],"names":[],"mappings":"AAEA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAA;AAC9B,OAAO,EACL,SAAS,
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../../src/components/ui/calendar/index.tsx"],"names":[],"mappings":"AAEA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAA;AAC9B,OAAO,EACL,SAAS,EAGT,KAAK,SAAS,EACf,MAAM,kBAAkB,CAAA;AAGzB,OAAO,EAAE,MAAM,EAAkB,MAAM,wBAAwB,CAAA;AAO/D,iBAAS,QAAQ,CAAC,EAChB,SAAS,EACT,UAAU,EACV,eAAsB,EACtB,aAAuB,EACvB,aAAuB,EACvB,UAAU,EACV,UAAU,EACV,GAAG,KAAK,EACT,EAAE,KAAK,CAAC,cAAc,CAAC,OAAO,SAAS,CAAC,GAAG;IAC1C,aAAa,CAAC,EAAE,KAAK,CAAC,cAAc,CAAC,OAAO,MAAM,CAAC,CAAC,SAAS,CAAC,CAAA;CAC/D,qBAgKA;AAED,iBAAS,iBAAiB,CAAC,EACzB,SAAS,EACT,GAAG,EACH,SAAS,EACT,GAAG,KAAK,EACT,EAAE,KAAK,CAAC,cAAc,CAAC,OAAO,SAAS,CAAC,qBAqCxC;AAED,OAAO,EAAE,QAAQ,EAAE,iBAAiB,EAAE,CAAA"}
|
|
@@ -1,14 +1,20 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
3
|
import * as React from 'react';
|
|
4
|
-
import { DayPicker, getDefaultClassNames } from 'react-day-picker';
|
|
4
|
+
import { DayPicker, defaultDateLib, getDefaultClassNames } from 'react-day-picker';
|
|
5
5
|
import { cn } from '../../../lib/utils.js';
|
|
6
6
|
import { Button, buttonVariants } from '../../../components/ui/button/index.js';
|
|
7
7
|
import { ChevronLeftIcon, ChevronRightIcon, ChevronDownIcon } from 'lucide-react';
|
|
8
8
|
function Calendar({ className, classNames, showOutsideDays = true, captionLayout = 'label', buttonVariant = 'plain', formatters, components, ...props }) {
|
|
9
9
|
const defaultClassNames = getDefaultClassNames();
|
|
10
10
|
return (_jsx(DayPicker, { showOutsideDays: showOutsideDays, className: cn('bg-background group/calendar p-2 [--cell-radius:var(--radius-md)] [--cell-size:34px] [--nav-size:28px] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent', String.raw `rtl:**:[.rdp-button\_next>svg]:rotate-180`, String.raw `rtl:**:[.rdp-button\_previous>svg]:rotate-180`, className), captionLayout: captionLayout, formatters: {
|
|
11
|
-
|
|
11
|
+
// react-day-picker's own default is dateLib.format(month, 'LLLL'), the
|
|
12
|
+
// full month name in the locale it was given. Shortening it has to go
|
|
13
|
+
// through the same dateLib: Date.toLocaleString('default') reads the
|
|
14
|
+
// runtime locale instead, so the dropdown fell back to English while
|
|
15
|
+
// the weekdays and the caption around it stayed in the consumer's
|
|
16
|
+
// language. 'LLL' is the abbreviated form of the same standalone month.
|
|
17
|
+
formatMonthDropdown: (month, dateLib = defaultDateLib) => dateLib.format(month, 'LLL'),
|
|
12
18
|
...formatters
|
|
13
19
|
}, classNames: {
|
|
14
20
|
root: cn('w-fit', defaultClassNames.root),
|
|
@@ -1,9 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* FileUpload — a controlled "pick a file,
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
2
|
+
* FileUpload — a controlled "pick a file, hand it back" primitive. The defining
|
|
3
|
+
* job: select one file (by click, keyboard, or drag-and-drop), validate it
|
|
4
|
+
* against an accept filter + a byte ceiling, and emit `{ file, text }`.
|
|
5
|
+
*
|
|
6
|
+
* `readAs` decides whether the bytes are decoded on the way through. `'text'`
|
|
7
|
+
* (the default) reads UTF-8 via FileReader.readAsText, which is what a PEM/CSV
|
|
8
|
+
* host wants. `'none'` hands the `File` over untouched and is the only mode a
|
|
9
|
+
* BINARY file can use: a PDF, XLSX or PFX put through readAsText decodes into
|
|
10
|
+
* replacement-character garbage, and that garbage string is then retained for
|
|
11
|
+
* as long as the host holds the value — 20 MiB of PDF became a useless 20 MiB
|
|
12
|
+
* string, and the host still had to read the file a second time to get at the
|
|
13
|
+
* real bytes.
|
|
7
14
|
*
|
|
8
15
|
* The real `<input type="file">` IS the accessible control: it is `sr-only`
|
|
9
16
|
* (visually hidden) but focusable and labelable — never `aria-hidden`, never
|
|
@@ -24,6 +31,11 @@
|
|
|
24
31
|
* a value, so it cannot masquerade as valid. `accept` is validated against
|
|
25
32
|
* BOTH extension and MIME because the native `accept` attribute is only a
|
|
26
33
|
* browser hint and is bypassable via drag-drop.
|
|
34
|
+
*
|
|
35
|
+
* A host that already announces the rejection itself — a toast in its own
|
|
36
|
+
* locale, driven off `onError` — silences this one by returning nothing from
|
|
37
|
+
* `labels.error`. Two announcements for one event, in two languages, is the
|
|
38
|
+
* accessibility defect; the wording is not.
|
|
27
39
|
*/
|
|
28
40
|
import * as React from 'react';
|
|
29
41
|
export type FileUploadResult = {
|
|
@@ -42,6 +54,33 @@ export type FileUploadError = {
|
|
|
42
54
|
kind: 'read-failed';
|
|
43
55
|
file: File;
|
|
44
56
|
};
|
|
57
|
+
/** How the picked file's bytes are handled on the way to `onSelect`. */
|
|
58
|
+
export type FileUploadReadAs = 'text' | 'none';
|
|
59
|
+
/**
|
|
60
|
+
* Overrides for the fixed English copy. Every field is optional and defaults to
|
|
61
|
+
* the current value, so a consumer that passes nothing renders exactly as
|
|
62
|
+
* before. These four strings were the whole of the component's user-visible
|
|
63
|
+
* text, and none of them was reachable — a pt-BR console rendered an English
|
|
64
|
+
* affordance and handed a screen reader an English accessible name.
|
|
65
|
+
*/
|
|
66
|
+
export interface FileUploadLabels {
|
|
67
|
+
/** Emphasised call to action in the empty zone. Defaults to "Choose a file". */
|
|
68
|
+
action?: string;
|
|
69
|
+
/** Trailing hint after the action. Defaults to "or drag and drop". */
|
|
70
|
+
hint?: string;
|
|
71
|
+
/** Accessible name of the clear button. Defaults to "Remove file". */
|
|
72
|
+
remove?: string;
|
|
73
|
+
/**
|
|
74
|
+
* Copy for the component's own `role="alert"` refusal. Receives the rejection
|
|
75
|
+
* so the message can interpolate the cap or the accept filter; use the
|
|
76
|
+
* exported `humanizeSize` to format `maxSizeBytes` the way the chip does.
|
|
77
|
+
*
|
|
78
|
+
* Return `null`, `undefined` or `''` to stay SILENT and leave the
|
|
79
|
+
* announcement to the host's own `onError` handling. Defaults to the
|
|
80
|
+
* built-in English messages.
|
|
81
|
+
*/
|
|
82
|
+
error?: (error: FileUploadError) => string | null | undefined;
|
|
83
|
+
}
|
|
45
84
|
export type FileUploadProps = {
|
|
46
85
|
/** Comma-separated accept filter, e.g. ".pem,.key" or "text/plain". Mirrors the native input accept. */
|
|
47
86
|
accept?: string;
|
|
@@ -49,7 +88,18 @@ export type FileUploadProps = {
|
|
|
49
88
|
maxSizeBytes?: number;
|
|
50
89
|
/** Controlled selection. `null` = empty. The host owns state. */
|
|
51
90
|
value?: FileUploadResult | null;
|
|
52
|
-
/**
|
|
91
|
+
/**
|
|
92
|
+
* How the accepted file is handed over.
|
|
93
|
+
* - `'text'` (default): decode as UTF-8 via FileReader.readAsText and emit
|
|
94
|
+
* `{ file, text }`. Unchanged behaviour for every existing consumer.
|
|
95
|
+
* - `'none'`: skip decoding entirely and emit `{ file, text: '' }`. The mode
|
|
96
|
+
* for BINARY files (PDF, XLSX, PFX/DER): the host gets the `File` intact
|
|
97
|
+
* and `text` carries nothing, so do not read it in this mode.
|
|
98
|
+
*/
|
|
99
|
+
readAs?: FileUploadReadAs;
|
|
100
|
+
/** Override the fixed English copy. Omitted fields keep their defaults. */
|
|
101
|
+
labels?: FileUploadLabels;
|
|
102
|
+
/** Fires on accept (with the result) or clear (null). See `readAs` for whether `text` is populated. */
|
|
53
103
|
onSelect: (result: FileUploadResult | null) => void;
|
|
54
104
|
/** Fires when a pick is rejected (size/type/read). Optional — the component also shows its own inline error. */
|
|
55
105
|
onError?: (error: FileUploadError) => void;
|
|
@@ -60,7 +110,7 @@ export type FileUploadProps = {
|
|
|
60
110
|
'aria-required'?: boolean;
|
|
61
111
|
'aria-describedby'?: string;
|
|
62
112
|
'aria-label'?: string;
|
|
63
|
-
} & Omit<React.InputHTMLAttributes<HTMLInputElement>, 'type' | 'accept' | 'value' | 'disabled' | 'onChange' | 'onSelect' | 'className' | 'multiple'>;
|
|
113
|
+
} & Omit<React.InputHTMLAttributes<HTMLInputElement>, 'type' | 'accept' | 'value' | 'disabled' | 'onChange' | 'onSelect' | 'className' | 'onError' | 'multiple'>;
|
|
64
114
|
/**
|
|
65
115
|
* Validate a chosen file against an accept filter and a byte ceiling. Exported
|
|
66
116
|
* so hosts can pre-validate before handing a file over. `accept` is matched
|
|
@@ -72,6 +122,12 @@ export declare function validateFile(file: File, opts: {
|
|
|
72
122
|
accept?: string;
|
|
73
123
|
maxSizeBytes?: number;
|
|
74
124
|
}): FileUploadError | null;
|
|
125
|
+
/**
|
|
126
|
+
* Humanize a byte count for the selected-file chip. Binary units, 1 decimal.
|
|
127
|
+
* Exported so a `labels.error` override can format `maxSizeBytes` exactly the
|
|
128
|
+
* way the chip and the default message do, instead of re-deriving binary units.
|
|
129
|
+
*/
|
|
130
|
+
export declare function humanizeSize(bytes: number): string;
|
|
75
131
|
export declare const FileUpload: React.ForwardRefExoticComponent<{
|
|
76
132
|
/** Comma-separated accept filter, e.g. ".pem,.key" or "text/plain". Mirrors the native input accept. */
|
|
77
133
|
accept?: string;
|
|
@@ -79,7 +135,18 @@ export declare const FileUpload: React.ForwardRefExoticComponent<{
|
|
|
79
135
|
maxSizeBytes?: number;
|
|
80
136
|
/** Controlled selection. `null` = empty. The host owns state. */
|
|
81
137
|
value?: FileUploadResult | null;
|
|
82
|
-
/**
|
|
138
|
+
/**
|
|
139
|
+
* How the accepted file is handed over.
|
|
140
|
+
* - `'text'` (default): decode as UTF-8 via FileReader.readAsText and emit
|
|
141
|
+
* `{ file, text }`. Unchanged behaviour for every existing consumer.
|
|
142
|
+
* - `'none'`: skip decoding entirely and emit `{ file, text: '' }`. The mode
|
|
143
|
+
* for BINARY files (PDF, XLSX, PFX/DER): the host gets the `File` intact
|
|
144
|
+
* and `text` carries nothing, so do not read it in this mode.
|
|
145
|
+
*/
|
|
146
|
+
readAs?: FileUploadReadAs;
|
|
147
|
+
/** Override the fixed English copy. Omitted fields keep their defaults. */
|
|
148
|
+
labels?: FileUploadLabels;
|
|
149
|
+
/** Fires on accept (with the result) or clear (null). See `readAs` for whether `text` is populated. */
|
|
83
150
|
onSelect: (result: FileUploadResult | null) => void;
|
|
84
151
|
/** Fires when a pick is rejected (size/type/read). Optional — the component also shows its own inline error. */
|
|
85
152
|
onError?: (error: FileUploadError) => void;
|
|
@@ -90,5 +157,5 @@ export declare const FileUpload: React.ForwardRefExoticComponent<{
|
|
|
90
157
|
'aria-required'?: boolean;
|
|
91
158
|
'aria-describedby'?: string;
|
|
92
159
|
'aria-label'?: string;
|
|
93
|
-
} & Omit<React.InputHTMLAttributes<HTMLInputElement>, "className" | "disabled" | "type" | "value" | "onChange" | "onSelect" | "accept" | "multiple"> & React.RefAttributes<HTMLInputElement>>;
|
|
160
|
+
} & Omit<React.InputHTMLAttributes<HTMLInputElement>, "className" | "disabled" | "type" | "value" | "onChange" | "onError" | "onSelect" | "accept" | "multiple"> & React.RefAttributes<HTMLInputElement>>;
|
|
94
161
|
//# sourceMappingURL=index.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../../src/components/ui/file-upload/index.tsx"],"names":[],"mappings":"AAEA
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../../src/components/ui/file-upload/index.tsx"],"names":[],"mappings":"AAEA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsCG;AACH,OAAO,KAAK,KAAK,MAAM,OAAO,CAAA;AAM9B,MAAM,MAAM,gBAAgB,GAAG;IAAE,IAAI,EAAE,IAAI,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,CAAA;AAE3D,MAAM,MAAM,eAAe,GACvB;IAAE,IAAI,EAAE,WAAW,CAAC;IAAC,IAAI,EAAE,IAAI,CAAC;IAAC,YAAY,EAAE,MAAM,CAAA;CAAE,GACvD;IAAE,IAAI,EAAE,YAAY,CAAC;IAAC,IAAI,EAAE,IAAI,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,GAClD;IAAE,IAAI,EAAE,aAAa,CAAC;IAAC,IAAI,EAAE,IAAI,CAAA;CAAE,CAAA;AAEvC,wEAAwE;AACxE,MAAM,MAAM,gBAAgB,GAAG,MAAM,GAAG,MAAM,CAAA;AAE9C;;;;;;GAMG;AACH,MAAM,WAAW,gBAAgB;IAC/B,gFAAgF;IAChF,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,sEAAsE;IACtE,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,sEAAsE;IACtE,MAAM,CAAC,EAAE,MAAM,CAAA;IACf;;;;;;;;OAQG;IACH,KAAK,CAAC,EAAE,CAAC,KAAK,EAAE,eAAe,KAAK,MAAM,GAAG,IAAI,GAAG,SAAS,CAAA;CAC9D;AAED,MAAM,MAAM,eAAe,GAAG;IAC5B,wGAAwG;IACxG,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,0FAA0F;IAC1F,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,iEAAiE;IACjE,KAAK,CAAC,EAAE,gBAAgB,GAAG,IAAI,CAAA;IAC/B;;;;;;;OAOG;IACH,MAAM,CAAC,EAAE,gBAAgB,CAAA;IACzB,2EAA2E;IAC3E,MAAM,CAAC,EAAE,gBAAgB,CAAA;IACzB,uGAAuG;IACvG,QAAQ,EAAE,CAAC,MAAM,EAAE,gBAAgB,GAAG,IAAI,KAAK,IAAI,CAAA;IACnD,gHAAgH;IAChH,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,eAAe,KAAK,IAAI,CAAA;IAC1C,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB,EAAE,CAAC,EAAE,MAAM,CAAA;IACX,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,cAAc,CAAC,EAAE,OAAO,CAAA;IACxB,eAAe,CAAC,EAAE,OAAO,CAAA;IACzB,kBAAkB,CAAC,EAAE,MAAM,CAAA;IAC3B,YAAY,CAAC,EAAE,MAAM,CAAA;CACtB,GAAG,IAAI,CACN,KAAK,CAAC,mBAAmB,CAAC,gBAAgB,CAAC,EAEzC,MAAM,GACN,QAAQ,GACR,OAAO,GACP,UAAU,GACV,UAAU,GACV,UAAU,GACV,WAAW,GAQX,SAAS,GAET,UAAU,CACb,CAAA;AAED;;;;;;GAMG;AACH,wBAAgB,YAAY,CAC1B,IAAI,EAAE,IAAI,EACV,IAAI,EAAE;IAAE,MAAM,CAAC,EAAE,MAAM,CAAC;IAAC,YAAY,CAAC,EAAE,MAAM,CAAA;CAAE,GAC/C,eAAe,GAAG,IAAI,CASxB;AAqBD;;;;GAIG;AACH,wBAAgB,YAAY,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAUlD;AAcD,eAAO,MAAM,UAAU;IAvHrB,wGAAwG;aAC/F,MAAM;IACf,0FAA0F;mBAC3E,MAAM;IACrB,iEAAiE;YACzD,gBAAgB,GAAG,IAAI;IAC/B;;;;;;;OAOG;aACM,gBAAgB;IACzB,2EAA2E;aAClE,gBAAgB;IACzB,uGAAuG;cAC7F,CAAC,MAAM,EAAE,gBAAgB,GAAG,IAAI,KAAK,IAAI;IACnD,gHAAgH;cACtG,CAAC,KAAK,EAAE,eAAe,KAAK,IAAI;eAC/B,OAAO;SACb,MAAM;gBACC,MAAM;qBACD,OAAO;sBACN,OAAO;yBACJ,MAAM;mBACZ,MAAM;yMAkUtB,CAAA"}
|
|
@@ -1,11 +1,18 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
3
|
/**
|
|
4
|
-
* FileUpload — a controlled "pick a file,
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
4
|
+
* FileUpload — a controlled "pick a file, hand it back" primitive. The defining
|
|
5
|
+
* job: select one file (by click, keyboard, or drag-and-drop), validate it
|
|
6
|
+
* against an accept filter + a byte ceiling, and emit `{ file, text }`.
|
|
7
|
+
*
|
|
8
|
+
* `readAs` decides whether the bytes are decoded on the way through. `'text'`
|
|
9
|
+
* (the default) reads UTF-8 via FileReader.readAsText, which is what a PEM/CSV
|
|
10
|
+
* host wants. `'none'` hands the `File` over untouched and is the only mode a
|
|
11
|
+
* BINARY file can use: a PDF, XLSX or PFX put through readAsText decodes into
|
|
12
|
+
* replacement-character garbage, and that garbage string is then retained for
|
|
13
|
+
* as long as the host holds the value — 20 MiB of PDF became a useless 20 MiB
|
|
14
|
+
* string, and the host still had to read the file a second time to get at the
|
|
15
|
+
* real bytes.
|
|
9
16
|
*
|
|
10
17
|
* The real `<input type="file">` IS the accessible control: it is `sr-only`
|
|
11
18
|
* (visually hidden) but focusable and labelable — never `aria-hidden`, never
|
|
@@ -26,6 +33,11 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
26
33
|
* a value, so it cannot masquerade as valid. `accept` is validated against
|
|
27
34
|
* BOTH extension and MIME because the native `accept` attribute is only a
|
|
28
35
|
* browser hint and is bypassable via drag-drop.
|
|
36
|
+
*
|
|
37
|
+
* A host that already announces the rejection itself — a toast in its own
|
|
38
|
+
* locale, driven off `onError` — silences this one by returning nothing from
|
|
39
|
+
* `labels.error`. Two announcements for one event, in two languages, is the
|
|
40
|
+
* accessibility defect; the wording is not.
|
|
29
41
|
*/
|
|
30
42
|
import * as React from 'react';
|
|
31
43
|
import { Upload, X } from 'lucide-react';
|
|
@@ -68,8 +80,12 @@ function matchesAccept(file, accept) {
|
|
|
68
80
|
return mime === token;
|
|
69
81
|
});
|
|
70
82
|
}
|
|
71
|
-
/**
|
|
72
|
-
|
|
83
|
+
/**
|
|
84
|
+
* Humanize a byte count for the selected-file chip. Binary units, 1 decimal.
|
|
85
|
+
* Exported so a `labels.error` override can format `maxSizeBytes` exactly the
|
|
86
|
+
* way the chip and the default message do, instead of re-deriving binary units.
|
|
87
|
+
*/
|
|
88
|
+
export function humanizeSize(bytes) {
|
|
73
89
|
if (bytes < 1024)
|
|
74
90
|
return `${bytes} B`;
|
|
75
91
|
const units = ['KB', 'MB', 'GB'];
|
|
@@ -81,8 +97,8 @@ function humanizeSize(bytes) {
|
|
|
81
97
|
}
|
|
82
98
|
return `${size.toFixed(1)} ${units[unit]}`;
|
|
83
99
|
}
|
|
84
|
-
/**
|
|
85
|
-
function
|
|
100
|
+
/** The default English announcement for each rejection kind. */
|
|
101
|
+
function defaultErrorMessage(error) {
|
|
86
102
|
switch (error.kind) {
|
|
87
103
|
case 'too-large':
|
|
88
104
|
return `File is too large (max ${humanizeSize(error.maxSizeBytes)}).`;
|
|
@@ -92,7 +108,7 @@ function errorMessage(error) {
|
|
|
92
108
|
return 'Could not read the file.';
|
|
93
109
|
}
|
|
94
110
|
}
|
|
95
|
-
export const 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) {
|
|
111
|
+
export const 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) {
|
|
96
112
|
const internalRef = React.useRef(null);
|
|
97
113
|
React.useImperativeHandle(ref, () => internalRef.current);
|
|
98
114
|
const reactId = React.useId();
|
|
@@ -104,12 +120,21 @@ export const FileUpload = React.forwardRef(function FileUpload({ accept, maxSize
|
|
|
104
120
|
// component's own validation/read error — a self-detected bad pick must read
|
|
105
121
|
// as invalid even outside a form.
|
|
106
122
|
const invalid = ariaInvalid || error !== null;
|
|
123
|
+
// Resolve the announcement BEFORE deciding whether the alert exists: a
|
|
124
|
+
// consumer that returns nothing is opting out of this surface entirely,
|
|
125
|
+
// and an association pointing at an unrendered node is worse than none.
|
|
126
|
+
const resolved = error
|
|
127
|
+
? labels?.error
|
|
128
|
+
? labels.error(error)
|
|
129
|
+
: defaultErrorMessage(error)
|
|
130
|
+
: null;
|
|
131
|
+
const errorText = resolved ? resolved : null;
|
|
107
132
|
// Radix Slot OVERWRITES aria-describedby (it does not merge), so merge the
|
|
108
133
|
// primitive's own role=alert error id with the FormControl-injected one so
|
|
109
134
|
// both associations coexist on the input. A plain join, never `cn` —
|
|
110
135
|
// tailwind-merge treats these as class names and would drop an id that
|
|
111
136
|
// happens to look like a conflicting utility.
|
|
112
|
-
const describedBy = [ariaDescribedby,
|
|
137
|
+
const describedBy = [ariaDescribedby, errorText ? errorId : undefined]
|
|
113
138
|
.filter(Boolean)
|
|
114
139
|
.join(' ') || undefined;
|
|
115
140
|
// Last-resolved-wins: a slow read for pick A must not overwrite a newer
|
|
@@ -125,6 +150,15 @@ export const FileUpload = React.forwardRef(function FileUpload({ accept, maxSize
|
|
|
125
150
|
onError?.(validationError);
|
|
126
151
|
return;
|
|
127
152
|
}
|
|
153
|
+
// Binary path: no decode, no reader, no retained garbage string. The
|
|
154
|
+
// stale-rejection clear still has to happen here — the alert is this
|
|
155
|
+
// component's own state and a good pick must retire it.
|
|
156
|
+
if (readAs === 'none') {
|
|
157
|
+
readerRef.current = null;
|
|
158
|
+
setError(null);
|
|
159
|
+
onSelect({ file, text: '' });
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
128
162
|
const reader = new FileReader();
|
|
129
163
|
readerRef.current = reader;
|
|
130
164
|
reader.onload = () => {
|
|
@@ -191,10 +225,10 @@ export const FileUpload = React.forwardRef(function FileUpload({ accept, maxSize
|
|
|
191
225
|
return (_jsxs("div", { className: cn('space-y-2', className), children: [_jsxs("div", { onClick: openPicker, onDragOver: onDragOver, onDragLeave: onDragLeave, onDrop: onDrop, className: 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 &&
|
|
192
226
|
'border-ring ring-ring ring-offset-background ring-2 ring-offset-1', disabled
|
|
193
227
|
? 'border-muted bg-muted/30 cursor-not-allowed shadow-none'
|
|
194
|
-
: 'cursor-pointer'), "aria-invalid": invalid || undefined, children: [_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 }), _jsx(Upload, { className: "text-muted-foreground size-4 shrink-0", "aria-hidden": "true" }), value ? (_jsxs(_Fragment, { children: [_jsxs("span", { className: "min-w-0 flex-1 truncate", children: [_jsx("span", { className: "text-foreground font-medium", children: value.file.name }), ' ', _jsx("span", { className: "text-muted-foreground tabular-nums", children: humanizeSize(value.file.size) })] }), _jsx(IconButton, { type: "button", variant: "plain", size: "small", disabled: disabled, "aria-label":
|
|
228
|
+
: 'cursor-pointer'), "aria-invalid": invalid || undefined, children: [_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 }), _jsx(Upload, { className: "text-muted-foreground size-4 shrink-0", "aria-hidden": "true" }), value ? (_jsxs(_Fragment, { children: [_jsxs("span", { className: "min-w-0 flex-1 truncate", children: [_jsx("span", { className: "text-foreground font-medium", children: value.file.name }), ' ', _jsx("span", { className: "text-muted-foreground tabular-nums", children: humanizeSize(value.file.size) })] }), _jsx(IconButton, { type: "button", variant: "plain", size: "small", disabled: disabled, "aria-label": labels?.remove?.trim() ? labels.remove : 'Remove file', onClick: (event) => {
|
|
195
229
|
// Don't bubble to the zone's openPicker and re-open the dialog.
|
|
196
230
|
event.stopPropagation();
|
|
197
231
|
clear();
|
|
198
|
-
}, children: _jsx(X, { className: "size-4", "aria-hidden": "true" }) })] })) : (_jsxs("span", { className: "text-muted-foreground", children: [_jsx("span", { className: "text-foreground font-medium", children:
|
|
232
|
+
}, children: _jsx(X, { className: "size-4", "aria-hidden": "true" }) })] })) : (_jsxs("span", { className: "text-muted-foreground", children: [_jsx("span", { className: "text-foreground font-medium", children: labels?.action ?? 'Choose a file' }), ' ', labels?.hint ?? 'or drag and drop'] }))] }), errorText ? (_jsx("p", { id: errorId, role: "alert", className: "text-system-error-h1a text-xs font-medium", children: errorText })) : null] }));
|
|
199
233
|
});
|
|
200
234
|
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"}
|
|
@@ -23,7 +23,7 @@ const useFormField = () => {
|
|
|
23
23
|
const fieldState = form && fieldContext.name
|
|
24
24
|
? form.getFieldState(fieldContext.name, form.formState)
|
|
25
25
|
: undefined;
|
|
26
|
-
const { id, required } = itemContext;
|
|
26
|
+
const { id, required, described, onDescribedChange } = itemContext;
|
|
27
27
|
return {
|
|
28
28
|
id,
|
|
29
29
|
name: fieldContext.name,
|
|
@@ -31,13 +31,37 @@ const useFormField = () => {
|
|
|
31
31
|
formDescriptionId: `${id}-form-item-description`,
|
|
32
32
|
formMessageId: `${id}-form-item-message`,
|
|
33
33
|
required,
|
|
34
|
+
described,
|
|
35
|
+
onDescribedChange,
|
|
34
36
|
...fieldState
|
|
35
37
|
};
|
|
36
38
|
};
|
|
37
39
|
const FormItemContext = React.createContext({});
|
|
40
|
+
/**
|
|
41
|
+
* Announce that this slot is on the page, for as long as it is.
|
|
42
|
+
*
|
|
43
|
+
* `rendered` is a parameter rather than a caller-side condition because
|
|
44
|
+
* `FormMessage` returns null when it has nothing to say, and a hook cannot sit
|
|
45
|
+
* behind that early return.
|
|
46
|
+
*/
|
|
47
|
+
function useDescribingSlot(slot, rendered) {
|
|
48
|
+
const { onDescribedChange } = React.useContext(FormItemContext);
|
|
49
|
+
React.useEffect(() => {
|
|
50
|
+
if (!rendered)
|
|
51
|
+
return;
|
|
52
|
+
onDescribedChange?.(slot, true);
|
|
53
|
+
return () => onDescribedChange?.(slot, false);
|
|
54
|
+
}, [onDescribedChange, slot, rendered]);
|
|
55
|
+
}
|
|
38
56
|
const FormItem = React.forwardRef(({ className, required, ...props }, ref) => {
|
|
39
57
|
const id = React.useId();
|
|
40
|
-
|
|
58
|
+
const [described, setDescribed] = React.useState({ message: false, description: false });
|
|
59
|
+
// Keeps the object identity when nothing moved, so a slot re-registering
|
|
60
|
+
// does not re-render the item for no reason.
|
|
61
|
+
const onDescribedChange = React.useCallback((slot, rendered) => setDescribed((current) => current[slot] === rendered
|
|
62
|
+
? current
|
|
63
|
+
: { ...current, [slot]: rendered }), []);
|
|
64
|
+
return (_jsx(FormItemContext.Provider, { value: { id, required, described, onDescribedChange }, children: _jsx("div", { ref: ref, className: cn('space-y-2', className), ...props }) }));
|
|
41
65
|
});
|
|
42
66
|
FormItem.displayName = 'FormItem';
|
|
43
67
|
const FormLabel = React.forwardRef(({ className, extra, children, ...props }, ref) => {
|
|
@@ -50,20 +74,38 @@ const FormLabel = React.forwardRef(({ className, extra, children, ...props }, re
|
|
|
50
74
|
FormLabel.displayName = 'FormLabel';
|
|
51
75
|
export const FormTooltip = ({ children, ...others }) => (_jsx(TooltipProvider, { ...others, children: _jsxs(Tooltip, { children: [_jsx(TooltipTrigger, { asChild: true, children: _jsx(HelpCircle, { className: "text-container-text ml-2 h-4 w-4" }) }), _jsx(TooltipContent, { children: children })] }) }));
|
|
52
76
|
const FormControl = React.forwardRef(({ ...props }, ref) => {
|
|
53
|
-
const { error, formItemId, formDescriptionId, formMessageId } = useFormField();
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
77
|
+
const { error, formItemId, formDescriptionId, formMessageId, described } = useFormField();
|
|
78
|
+
// Only ids that resolve. A screen reader drops a dangling IDREF silently, so
|
|
79
|
+
// the barrier this removes is diagnostic rather than operational: a form with
|
|
80
|
+
// thirteen broken associations is a form where a real one cannot be spotted.
|
|
81
|
+
//
|
|
82
|
+
// Message first: a reader hears the error before the hint. `undefined` when
|
|
83
|
+
// both slots are empty, which drops the attribute — and, because Radix Slot
|
|
84
|
+
// OVERWRITES rather than merges, still lets the wrapped element carry its own
|
|
85
|
+
// (`ui/file-upload` merges the injected id with its own error id that way).
|
|
86
|
+
const describedBy = [
|
|
87
|
+
described?.message ? formMessageId : undefined,
|
|
88
|
+
described?.description ? formDescriptionId : undefined
|
|
89
|
+
]
|
|
90
|
+
.filter(Boolean)
|
|
91
|
+
.join(' ') || undefined;
|
|
92
|
+
return (_jsx(Slot, { ref: ref, id: formItemId, "aria-describedby": describedBy, "aria-invalid": !!error, ...props }));
|
|
57
93
|
});
|
|
58
94
|
FormControl.displayName = 'FormControl';
|
|
59
95
|
const FormDescription = React.forwardRef(({ className, ...props }, ref) => {
|
|
60
96
|
const { formDescriptionId } = useFormField();
|
|
97
|
+
useDescribingSlot('description', true);
|
|
61
98
|
return (_jsx("p", { ref: ref, id: formDescriptionId, className: cn('text-muted-foreground text-xs font-medium', className), ...props }));
|
|
62
99
|
});
|
|
63
100
|
FormDescription.displayName = 'FormDescription';
|
|
64
101
|
const FormMessage = React.forwardRef(({ className, children, ...props }, ref) => {
|
|
65
102
|
const { error, formMessageId } = useFormField();
|
|
66
103
|
const body = error ? String(error?.message) : children;
|
|
104
|
+
// Registered on `body`, not on `error`: a message given plain children
|
|
105
|
+
// renders copy that the control has to point at too. The old expression only
|
|
106
|
+
// added the message id when react-hook-form reported an error, so a
|
|
107
|
+
// standalone message was rendered and never associated.
|
|
108
|
+
useDescribingSlot('message', Boolean(body));
|
|
67
109
|
if (!body) {
|
|
68
110
|
return null;
|
|
69
111
|
}
|
|
@@ -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"}
|
|
@@ -26,12 +26,58 @@ const toggleVariants = cva("hover:bg-muted hover:text-muted-foreground focus-vis
|
|
|
26
26
|
size: 'default'
|
|
27
27
|
}
|
|
28
28
|
});
|
|
29
|
+
const ARROW_KEYS = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'];
|
|
30
|
+
/**
|
|
31
|
+
* Is an arrow key down right now?
|
|
32
|
+
*
|
|
33
|
+
* This is how an item tells keyboard navigation apart from a pointer press or a
|
|
34
|
+
* programmatic focus, and it is the seam Radix uses for the same job in its own
|
|
35
|
+
* `RadioGroup`. It works because `RovingFocusGroup` defers the focus move to a
|
|
36
|
+
* `setTimeout`: the keydown has already bubbled to `document` and set the flag
|
|
37
|
+
* by the time the item receives `focus`.
|
|
38
|
+
*
|
|
39
|
+
* The distinction is not cosmetic. A pointer press focuses BEFORE the click
|
|
40
|
+
* lands, so selecting on every focus would turn one click into
|
|
41
|
+
* select-then-deselect and break mouse selection outright.
|
|
42
|
+
*
|
|
43
|
+
* Returns null when disabled, so `type="multiple"` carries no flag to read.
|
|
44
|
+
*/
|
|
45
|
+
function useArrowKeyPressed(enabled) {
|
|
46
|
+
const arrowKeyPressed = React.useRef(false);
|
|
47
|
+
React.useEffect(() => {
|
|
48
|
+
if (!enabled)
|
|
49
|
+
return;
|
|
50
|
+
const onKeyDown = (event) => {
|
|
51
|
+
if (ARROW_KEYS.includes(event.key))
|
|
52
|
+
arrowKeyPressed.current = true;
|
|
53
|
+
};
|
|
54
|
+
const onKeyUp = () => {
|
|
55
|
+
arrowKeyPressed.current = false;
|
|
56
|
+
};
|
|
57
|
+
document.addEventListener('keydown', onKeyDown);
|
|
58
|
+
document.addEventListener('keyup', onKeyUp);
|
|
59
|
+
window.addEventListener('blur', onKeyUp);
|
|
60
|
+
return () => {
|
|
61
|
+
document.removeEventListener('keydown', onKeyDown);
|
|
62
|
+
document.removeEventListener('keyup', onKeyUp);
|
|
63
|
+
window.removeEventListener('blur', onKeyUp);
|
|
64
|
+
// A group unmounted mid-keystroke never sees the keyup.
|
|
65
|
+
arrowKeyPressed.current = false;
|
|
66
|
+
};
|
|
67
|
+
}, [enabled]);
|
|
68
|
+
return enabled ? arrowKeyPressed : null;
|
|
69
|
+
}
|
|
29
70
|
const ToggleGroupContext = React.createContext({
|
|
30
71
|
size: 'default',
|
|
31
72
|
variant: 'default',
|
|
32
|
-
spacing: 0
|
|
73
|
+
spacing: 0,
|
|
74
|
+
arrowKeyPressed: null
|
|
33
75
|
});
|
|
34
76
|
function ToggleGroup({ className, variant, size, spacing = 0, children, style, ...props }) {
|
|
77
|
+
// Read, never destructured: `type` is the discriminant of the Radix props
|
|
78
|
+
// union, and pulling it out then spreading it back widens the union so the
|
|
79
|
+
// single/multiple props stop resolving.
|
|
80
|
+
const arrowKeyPressed = useArrowKeyPressed(props.type === 'single');
|
|
35
81
|
return (_jsx(ToggleGroupPrimitive.Root, { "data-slot": "toggle-group", "data-variant": variant, "data-size": size, "data-spacing": spacing,
|
|
36
82
|
// Caller style first: `--gap` is ours and must survive, but everything
|
|
37
83
|
// else the caller passes has to reach the element.
|
|
@@ -39,13 +85,36 @@ function ToggleGroup({ className, variant, size, spacing = 0, children, style, .
|
|
|
39
85
|
// Legacy carried a `data-[spacing=default]:...:shadow-xs` rule here that
|
|
40
86
|
// never matched — `data-spacing` is always a number. Removed rather than
|
|
41
87
|
// activated, so the rendered pixels stay identical to sindarian-x.
|
|
42
|
-
'group/toggle-group flex w-fit items-center gap-[--spacing(var(--gap))] rounded-md', className), ...props, children: _jsx(ToggleGroupContext.Provider, { value: { variant, size, spacing }, children: children }) }));
|
|
88
|
+
'group/toggle-group flex w-fit items-center gap-[--spacing(var(--gap))] rounded-md', className), ...props, children: _jsx(ToggleGroupContext.Provider, { value: { variant, size, spacing, arrowKeyPressed }, children: children }) }));
|
|
43
89
|
}
|
|
44
|
-
function ToggleGroupItem({ className, children, variant, size, ...props }) {
|
|
90
|
+
function ToggleGroupItem({ className, children, variant, size, onFocus, ...props }) {
|
|
45
91
|
const context = React.useContext(ToggleGroupContext);
|
|
92
|
+
/**
|
|
93
|
+
* Selection follows focus, which is what `role="radio"` promises.
|
|
94
|
+
*
|
|
95
|
+
* With `type="single"` Radix puts the group on the ARIA radio pattern: the
|
|
96
|
+
* root is a `radiogroup`, each item a `radio` with `aria-checked`. Under that
|
|
97
|
+
* pattern an arrow key both moves focus and checks the item it lands on.
|
|
98
|
+
* Radix only moves focus, so a screen-reader operator used to arrow across
|
|
99
|
+
* "radio, not checked, 2 of 3" with nothing ever selected.
|
|
100
|
+
*
|
|
101
|
+
* `type="multiple"` is a `toolbar` of `aria-pressed` buttons and keeps its
|
|
102
|
+
* current behavior, where moving focus without pressing is correct: the flag
|
|
103
|
+
* is null there.
|
|
104
|
+
*
|
|
105
|
+
* Clicking the element rather than writing the value keeps a single
|
|
106
|
+
* activation path, so Radix's own toggle and any caller `onClick` both fire
|
|
107
|
+
* exactly as they do for a real click.
|
|
108
|
+
*/
|
|
109
|
+
const handleFocus = (event) => {
|
|
110
|
+
onFocus?.(event);
|
|
111
|
+
if (context.arrowKeyPressed?.current) {
|
|
112
|
+
event.currentTarget.click();
|
|
113
|
+
}
|
|
114
|
+
};
|
|
46
115
|
return (_jsx(ToggleGroupPrimitive.Item, { "data-slot": "toggle-group-item", "data-variant": context.variant || variant, "data-size": context.size || size, "data-spacing": context.spacing, className: cn(toggleVariants({
|
|
47
116
|
variant: context.variant || variant,
|
|
48
117
|
size: context.size || size
|
|
49
|
-
}), '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 }));
|
|
118
|
+
}), '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 }));
|
|
50
119
|
}
|
|
51
120
|
export { ToggleGroup, ToggleGroupItem };
|