@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.
Files changed (42) hide show
  1. package/dist/components/form/file-upload-field/index.d.ts +11 -2
  2. package/dist/components/form/file-upload-field/index.d.ts.map +1 -1
  3. package/dist/components/form/file-upload-field/index.js +8 -3
  4. package/dist/components/form/input-field/index.d.ts +41 -2
  5. package/dist/components/form/input-field/index.d.ts.map +1 -1
  6. package/dist/components/form/input-field/index.js +73 -15
  7. package/dist/components/form/select-field/index.d.ts +9 -1
  8. package/dist/components/form/select-field/index.d.ts.map +1 -1
  9. package/dist/components/form/select-field/index.js +2 -2
  10. package/dist/components/ui/button/styles.css +31 -1
  11. package/dist/components/ui/calendar/index.d.ts.map +1 -1
  12. package/dist/components/ui/calendar/index.js +7 -1
  13. package/dist/components/ui/file-upload/index.d.ts +76 -9
  14. package/dist/components/ui/file-upload/index.d.ts.map +1 -1
  15. package/dist/components/ui/file-upload/index.js +47 -12
  16. package/dist/components/ui/form.d.ts +4 -0
  17. package/dist/components/ui/form.d.ts.map +1 -1
  18. package/dist/components/ui/form.js +48 -6
  19. package/dist/components/ui/toggle-group/index.d.ts +1 -1
  20. package/dist/components/ui/toggle-group/index.d.ts.map +1 -1
  21. package/dist/components/ui/toggle-group/index.js +73 -4
  22. package/dist/esm/components/form/file-upload-field/index.d.ts +11 -2
  23. package/dist/esm/components/form/file-upload-field/index.d.ts.map +1 -1
  24. package/dist/esm/components/form/file-upload-field/index.js +8 -3
  25. package/dist/esm/components/form/input-field/index.d.ts +41 -2
  26. package/dist/esm/components/form/input-field/index.d.ts.map +1 -1
  27. package/dist/esm/components/form/input-field/index.js +73 -15
  28. package/dist/esm/components/form/select-field/index.d.ts +9 -1
  29. package/dist/esm/components/form/select-field/index.d.ts.map +1 -1
  30. package/dist/esm/components/form/select-field/index.js +2 -2
  31. package/dist/esm/components/ui/calendar/index.d.ts.map +1 -1
  32. package/dist/esm/components/ui/calendar/index.js +8 -2
  33. package/dist/esm/components/ui/file-upload/index.d.ts +76 -9
  34. package/dist/esm/components/ui/file-upload/index.d.ts.map +1 -1
  35. package/dist/esm/components/ui/file-upload/index.js +47 -13
  36. package/dist/esm/components/ui/form.d.ts +4 -0
  37. package/dist/esm/components/ui/form.d.ts.map +1 -1
  38. package/dist/esm/components/ui/form.js +48 -6
  39. package/dist/esm/components/ui/toggle-group/index.d.ts +1 -1
  40. package/dist/esm/components/ui/toggle-group/index.d.ts.map +1 -1
  41. package/dist/esm/components/ui/toggle-group/index.js +73 -4
  42. 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,kKAiB9D,gBAAgB,CAAC,CAAC,CAAC,sBA6FrB,CAAA"}
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,EAET,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,qBA0JA;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
+ {"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
- formatMonthDropdown: (date) => date.toLocaleString('default', { month: 'short' }),
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, read it to text, hand back the text"
3
- * primitive. The defining job: select one file (by click, keyboard, or
4
- * drag-and-drop), validate it against an accept filter + a byte ceiling, read
5
- * it as UTF-8 via FileReader.readAsText, and emit `{ file, text }`. Binary/DER
6
- * (.pfx/.p12) is explicitly OUT of scope — readAsText only.
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
- /** Fires on accept (with {file,text}) or clear (null). Reads UTF-8 via FileReader.readAsText. */
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
- /** Fires on accept (with {file,text}) or clear (null). Reads UTF-8 via FileReader.readAsText. */
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;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;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,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,iGAAiG;IACjG,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,GAEX,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;AA8CD,eAAO,MAAM,UAAU;IAhGrB,wGAAwG;aAC/F,MAAM;IACf,0FAA0F;mBAC3E,MAAM;IACrB,iEAAiE;YACzD,gBAAgB,GAAG,IAAI;IAC/B,iGAAiG;cACvF,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;6LA6RtB,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../../src/components/ui/file-upload/index.tsx"],"names":[],"mappings":"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"}
@@ -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, read it to text, hand back the text"
5
- * primitive. The defining job: select one file (by click, keyboard, or
6
- * drag-and-drop), validate it against an accept filter + a byte ceiling, read
7
- * it as UTF-8 via FileReader.readAsText, and emit `{ file, text }`. Binary/DER
8
- * (.pfx/.p12) is explicitly OUT of scope — readAsText only.
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
- /** Humanize a byte count for the selected-file chip. Binary units, 1 decimal. */
72
- function humanizeSize(bytes) {
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
- /** A human-readable announcement for each rejection kind. */
85
- function errorMessage(error) {
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, error ? errorId : undefined]
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": "Remove file", onClick: (event) => {
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: "Choose a file" }), ' ', "or drag and drop"] }))] }), error ? (_jsx("p", { id: errorId, role: "alert", className: "text-system-error-h1a text-xs font-medium", children: errorMessage(error) })) : null] }));
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;;;;;;;;;;;;CAyBjB,CAAA;AAWD,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;wCAanB,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;;gFAmBf,CAAA;AAGF,QAAA,MAAM,eAAe,yHAcnB,CAAA;AAGF,QAAA,MAAM,WAAW,yHAuBf,CAAA;AAGF,OAAO,EACL,YAAY,EACZ,IAAI,EACJ,QAAQ,EACR,SAAS,EACT,WAAW,EACX,eAAe,EACf,WAAW,EACX,SAAS,EACV,CAAA"}
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
- return (_jsx(FormItemContext.Provider, { value: { id, required }, children: _jsx("div", { ref: ref, className: cn('space-y-2', className), ...props }) }));
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
- return (_jsx(Slot, { ref: ref, id: formItemId, "aria-describedby": !error
55
- ? `${formDescriptionId}`
56
- : `${formDescriptionId} ${formMessageId}`, "aria-invalid": !!error, ...props }));
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;AAYD,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,qBAwBF;AAED,iBAAS,eAAe,CAAC,EACvB,SAAS,EACT,QAAQ,EACR,OAAO,EACP,IAAI,EACJ,GAAG,KAAK,EACT,EAAE,KAAK,CAAC,cAAc,CAAC,OAAO,oBAAoB,CAAC,IAAI,CAAC,GACvD,YAAY,CAAC,OAAO,cAAc,CAAC,qBAuBpC;AAED,OAAO,EAAE,WAAW,EAAE,eAAe,EAAE,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 };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lerianstudio/sindarian-ui",
3
- "version": "2.0.0-beta.1",
3
+ "version": "2.0.0-beta.2",
4
4
  "description": "Sindarian UI - A UI library for Midaz Console",
5
5
  "license": "ISC",
6
6
  "author": {