@lutra-ui-system/react 0.1.2 → 0.3.0

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/README.md CHANGED
@@ -82,6 +82,29 @@ The library does **not** read `prefers-color-scheme`. Deciding when an applicati
82
82
  the application, and plenty have no dark design at all — so set `data-theme` from your own theme
83
83
  logic, where a user's explicit choice can still win over the system preference.
84
84
 
85
+ ### Density
86
+
87
+ A second, independent axis. Spacious is the default; Condensed is one attribute, on any element:
88
+
89
+ ```html
90
+ <html data-theme="dark" data-density="condensed"></html>
91
+ ```
92
+
93
+ Density tightens the vertical rhythm and supporting-text indent of Field and Select Field (8px →
94
+ 4px) and lowers the control text of Input and Select (16px → 14px). It does **not** change control
95
+ heights, inline padding or target geometry — those belong to `size` and are identical in both modes.
96
+
97
+ It is global and inherited, so a dense region inside a comfortable page is a wrapper:
98
+
99
+ ```html
100
+ <section data-density="condensed">…</section>
101
+ ```
102
+
103
+ There is deliberately **no `density` prop** on Field, Select Field, Input or Select. Density is one
104
+ decision that applies to a whole subtree, which a prop cannot express. Note that Condensed control
105
+ text is 14px, and Mobile Safari zooms the page when a focused field's text is below 16px — keep
106
+ Spacious for anything filled in on a phone.
107
+
85
108
  ### Overriding a token
86
109
 
87
110
  Override on any element below the root and it wins outright, because a declaration on an element
@@ -94,7 +117,7 @@ beats one inherited into it:
94
117
  ```
95
118
 
96
119
  Overriding at `:root` also works, but your stylesheet must load **after** `@lutra-ui-system/react/styles.css`
97
- — `:root` and `[data-theme]` have equal specificity, so source order decides.
120
+ — `:root`, `[data-theme]` and `[data-density]` all have equal specificity, so source order decides.
98
121
 
99
122
  ### Tokens as data
100
123
 
@@ -102,8 +125,9 @@ Overriding at `:root` also works, but your stylesheet must load **after** `@lutr
102
125
  import tokens from '@lutra-ui-system/react/tokens.json' with { type: 'json' };
103
126
  ```
104
127
 
105
- Each entry carries its Figma name, whether it is themed, and its value or its light/dark pair. Useful
106
- for tooling; components should read the CSS custom properties.
128
+ Each entry carries its Figma name, whether it is themed, and its value or its light/dark pair. A
129
+ density token carries `spacious` and `condensed` instead of a single value, and is marked
130
+ `density: true`. Useful for tooling; components should read the CSS custom properties.
107
131
 
108
132
  ## Public API
109
133
 
@@ -201,9 +225,11 @@ requests. The build fails if an import survives.
201
225
  Rules for anything added to the stylesheet:
202
226
 
203
227
  - prefix every selector with `lutra-`
204
- - the only global selectors permitted are the token layer's `:root` and `[data-theme]` blocks, and
205
- they declare custom properties and nothing else — a stylesheet must not restyle a page it was
206
- merely imported into, and a custom property declaration paints nothing
228
+ - the only global selectors permitted are the token layer's `:root`, `[data-theme]` and
229
+ `[data-density]` blocks, and they declare custom properties and nothing else — a stylesheet must
230
+ not restyle a page it was merely imported into, and a custom property declaration paints nothing
231
+ - a component rule never matches on `data-theme` or `data-density`; it reads the tokens those blocks
232
+ declare
207
233
  - take every value from a design token; no raw colours, sizes, or type
208
234
  - size in `rem` with unitless line heights, so the library respects user font size and zoom; border
209
235
  and outline widths stay in `px`
@@ -16,8 +16,9 @@ export interface FieldProps extends ForwardedInputProps {
16
16
  */
17
17
  label: ReactNode;
18
18
  /**
19
- * Supporting text shown above the control and connected with `aria-describedby`. Use it for
20
- * format guidance the person needs before they start typing.
19
+ * Supporting text shown below the control and connected with `aria-describedby`. Use it for
20
+ * format guidance — a screen reader reads it as part of the control's description, so it is
21
+ * available before the field is filled in even though it is drawn underneath.
21
22
  */
22
23
  hint?: ReactNode;
23
24
  /**
@@ -27,18 +28,23 @@ export interface FieldProps extends ForwardedInputProps {
27
28
  */
28
29
  error?: ReactNode;
29
30
  /**
30
- * The word shown after the label text and included in the accessible name.
31
+ * The indicator shown immediately after the label text.
31
32
  *
32
- * Defaults to `'required'` when the native `required` attribute is set, and `'none'` otherwise,
33
- * so the visible word and the announced state agree by default. Pass `'optional'` to mark
34
- * optional fields in a form where most are required.
33
+ * Defaults to `'required'` when the native `required` attribute is set, and `'none'` otherwise.
34
+ * Pass `'optional'` to mark optional fields in a form where most are required.
35
+ *
36
+ * Drawn as `(required *)` or `(optional)`. When the control is natively `required`, the indicator
37
+ * is hidden from assistive technology because the attribute already carries that state; the
38
+ * word stays in the accessible name whenever nothing else conveys it. See
39
+ * `internal/field-support.tsx`.
35
40
  */
36
41
  indicator?: FieldIndicator;
37
42
  /** Extra class on the field wrapper, not on the control. */
38
43
  className?: string;
39
44
  }
40
45
  /**
41
- * A labelled text field: label, optional hint, an `Input`, and an optional error message.
46
+ * A labelled text field: label, an `Input`, optional supporting text, and an optional error
47
+ * message.
42
48
  *
43
49
  * This is the ordinary way to render an Input. The primitive deliberately does not manufacture a
44
50
  * label; Field is what supplies one, associates it with `for`/`id`, and wires the hint and error to
@@ -48,8 +54,9 @@ export interface FieldProps extends ForwardedInputProps {
48
54
  * `placeholder`, `required`, `readOnly`, `autoComplete`, `size`, `fullWidth`, the event handlers and
49
55
  * `data-*` all behave exactly as they do there. A `ref` reaches the native `<input>`.
50
56
  *
51
- * Order is label → hintcontrol → error. The hint is above the control so it is read before the
52
- * control is operated; the error is below it, matching the Figma annotation and Field Group.
57
+ * Order is label → controlhint → error. Supporting text sits below the control, which is what
58
+ * the Figma component draws and what its accessibility annotation describes; `aria-describedby`
59
+ * lists the hint then the error, so the description order matches the reading order.
53
60
  */
54
61
  export declare const Field: import("react").ForwardRefExoticComponent<FieldProps & import("react").RefAttributes<HTMLInputElement>>;
55
62
  //# sourceMappingURL=field.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"field.d.ts","sourceRoot":"","sources":["../../../src/components/field/field.tsx"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAC;AAGvC,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,iCAAiC,CAAC;AAEtE,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAC;AAEpD,YAAY,EAAE,cAAc,EAAE,CAAC;AAE/B;;;;;GAKG;AACH,KAAK,mBAAmB,GAAG,IAAI,CAAC,UAAU,EAAE,SAAS,CAAC,CAAC;AAEvD,MAAM,WAAW,UAAW,SAAQ,mBAAmB;IACrD;;;OAGG;IACH,KAAK,EAAE,SAAS,CAAC;IACjB;;;OAGG;IACH,IAAI,CAAC,EAAE,SAAS,CAAC;IACjB;;;;OAIG;IACH,KAAK,CAAC,EAAE,SAAS,CAAC;IAClB;;;;;;OAMG;IACH,SAAS,CAAC,EAAE,cAAc,CAAC;IAC3B,4DAA4D;IAC5D,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,KAAK,yGA4ChB,CAAC"}
1
+ {"version":3,"file":"field.d.ts","sourceRoot":"","sources":["../../../src/components/field/field.tsx"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAC;AAGvC,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,iCAAiC,CAAC;AAEtE,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAC;AAEpD,YAAY,EAAE,cAAc,EAAE,CAAC;AAE/B;;;;;GAKG;AACH,KAAK,mBAAmB,GAAG,IAAI,CAAC,UAAU,EAAE,SAAS,CAAC,CAAC;AAEvD,MAAM,WAAW,UAAW,SAAQ,mBAAmB;IACrD;;;OAGG;IACH,KAAK,EAAE,SAAS,CAAC;IACjB;;;;OAIG;IACH,IAAI,CAAC,EAAE,SAAS,CAAC;IACjB;;;;OAIG;IACH,KAAK,CAAC,EAAE,SAAS,CAAC;IAClB;;;;;;;;;;OAUG;IACH,SAAS,CAAC,EAAE,cAAc,CAAC;IAC3B,4DAA4D;IAC5D,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED;;;;;;;;;;;;;;;GAeG;AACH,eAAO,MAAM,KAAK,yGA6ChB,CAAC"}
@@ -3,7 +3,8 @@ import { forwardRef } from 'react';
3
3
  import { FieldShell, useFieldIds } from '../../internal/field-support.js';
4
4
  import { Input } from '../input/index.js';
5
5
  /**
6
- * A labelled text field: label, optional hint, an `Input`, and an optional error message.
6
+ * A labelled text field: label, an `Input`, optional supporting text, and an optional error
7
+ * message.
7
8
  *
8
9
  * This is the ordinary way to render an Input. The primitive deliberately does not manufacture a
9
10
  * label; Field is what supplies one, associates it with `for`/`id`, and wires the hint and error to
@@ -13,14 +14,15 @@ import { Input } from '../input/index.js';
13
14
  * `placeholder`, `required`, `readOnly`, `autoComplete`, `size`, `fullWidth`, the event handlers and
14
15
  * `data-*` all behave exactly as they do there. A `ref` reaches the native `<input>`.
15
16
  *
16
- * Order is label → hintcontrol → error. The hint is above the control so it is read before the
17
- * control is operated; the error is below it, matching the Figma annotation and Field Group.
17
+ * Order is label → controlhint → error. Supporting text sits below the control, which is what
18
+ * the Figma component draws and what its accessibility annotation describes; `aria-describedby`
19
+ * lists the hint then the error, so the description order matches the reading order.
18
20
  */
19
21
  export const Field = forwardRef(function Field({ label, hint, error, indicator, className, id, required = false, disabled = false, 'aria-describedby': describedBy, ...inputProps }, ref) {
20
22
  const hasHint = hint !== undefined && hint !== null && hint !== false;
21
23
  const hasError = error !== undefined && error !== null && error !== false;
22
24
  const ids = useFieldIds({ id, hasHint, hasError, describedBy });
23
25
  const resolvedIndicator = indicator ?? (required ? 'required' : 'none');
24
- return (_jsx(FieldShell, { controlId: ids.controlId, label: label, indicator: resolvedIndicator, hint: hint, hintId: ids.hintId, error: error, errorId: ids.errorId, disabled: disabled, className: className, children: _jsx(Input, { ...inputProps, ref: ref, id: ids.controlId, required: required, disabled: disabled, invalid: hasError, "aria-describedby": ids.describedBy }) }));
26
+ return (_jsx(FieldShell, { controlId: ids.controlId, label: label, indicator: resolvedIndicator, required: required, hint: hint, hintId: ids.hintId, error: error, errorId: ids.errorId, disabled: disabled, className: className, children: _jsx(Input, { ...inputProps, ref: ref, id: ids.controlId, required: required, disabled: disabled, invalid: hasError, "aria-describedby": ids.describedBy }) }));
25
27
  });
26
28
  //# sourceMappingURL=field.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"field.js","sourceRoot":"","sources":["../../../src/components/field/field.tsx"],"names":[],"mappings":";AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,OAAO,CAAC;AAGnC,OAAO,EAAE,UAAU,EAAE,WAAW,EAAE,MAAM,iCAAiC,CAAC;AAE1E,OAAO,EAAE,KAAK,EAAE,MAAM,mBAAmB,CAAC;AA0C1C;;;;;;;;;;;;;GAaG;AACH,MAAM,CAAC,MAAM,KAAK,GAAG,UAAU,CAA+B,SAAS,KAAK,CAC1E,EACE,KAAK,EACL,IAAI,EACJ,KAAK,EACL,SAAS,EACT,SAAS,EACT,EAAE,EACF,QAAQ,GAAG,KAAK,EAChB,QAAQ,GAAG,KAAK,EAChB,kBAAkB,EAAE,WAAW,EAC/B,GAAG,UAAU,EACd,EACD,GAAG;IAEH,MAAM,OAAO,GAAG,IAAI,KAAK,SAAS,IAAI,IAAI,KAAK,IAAI,IAAI,IAAI,KAAK,KAAK,CAAC;IACtE,MAAM,QAAQ,GAAG,KAAK,KAAK,SAAS,IAAI,KAAK,KAAK,IAAI,IAAI,KAAK,KAAK,KAAK,CAAC;IAE1E,MAAM,GAAG,GAAG,WAAW,CAAC,EAAE,EAAE,EAAE,OAAO,EAAE,QAAQ,EAAE,WAAW,EAAE,CAAC,CAAC;IAChE,MAAM,iBAAiB,GAAmB,SAAS,IAAI,CAAC,QAAQ,CAAC,CAAC,CAAC,UAAU,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC;IAExF,OAAO,CACL,KAAC,UAAU,IACT,SAAS,EAAE,GAAG,CAAC,SAAS,EACxB,KAAK,EAAE,KAAK,EACZ,SAAS,EAAE,iBAAiB,EAC5B,IAAI,EAAE,IAAI,EACV,MAAM,EAAE,GAAG,CAAC,MAAM,EAClB,KAAK,EAAE,KAAK,EACZ,OAAO,EAAE,GAAG,CAAC,OAAO,EACpB,QAAQ,EAAE,QAAQ,EAClB,SAAS,EAAE,SAAS,YAEpB,KAAC,KAAK,OACA,UAAU,EACd,GAAG,EAAE,GAAG,EACR,EAAE,EAAE,GAAG,CAAC,SAAS,EACjB,QAAQ,EAAE,QAAQ,EAClB,QAAQ,EAAE,QAAQ,EAClB,OAAO,EAAE,QAAQ,sBACC,GAAG,CAAC,WAAW,GACjC,GACS,CACd,CAAC;AACJ,CAAC,CAAC,CAAC"}
1
+ {"version":3,"file":"field.js","sourceRoot":"","sources":["../../../src/components/field/field.tsx"],"names":[],"mappings":";AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,OAAO,CAAC;AAGnC,OAAO,EAAE,UAAU,EAAE,WAAW,EAAE,MAAM,iCAAiC,CAAC;AAE1E,OAAO,EAAE,KAAK,EAAE,MAAM,mBAAmB,CAAC;AA+C1C;;;;;;;;;;;;;;;GAeG;AACH,MAAM,CAAC,MAAM,KAAK,GAAG,UAAU,CAA+B,SAAS,KAAK,CAC1E,EACE,KAAK,EACL,IAAI,EACJ,KAAK,EACL,SAAS,EACT,SAAS,EACT,EAAE,EACF,QAAQ,GAAG,KAAK,EAChB,QAAQ,GAAG,KAAK,EAChB,kBAAkB,EAAE,WAAW,EAC/B,GAAG,UAAU,EACd,EACD,GAAG;IAEH,MAAM,OAAO,GAAG,IAAI,KAAK,SAAS,IAAI,IAAI,KAAK,IAAI,IAAI,IAAI,KAAK,KAAK,CAAC;IACtE,MAAM,QAAQ,GAAG,KAAK,KAAK,SAAS,IAAI,KAAK,KAAK,IAAI,IAAI,KAAK,KAAK,KAAK,CAAC;IAE1E,MAAM,GAAG,GAAG,WAAW,CAAC,EAAE,EAAE,EAAE,OAAO,EAAE,QAAQ,EAAE,WAAW,EAAE,CAAC,CAAC;IAChE,MAAM,iBAAiB,GAAmB,SAAS,IAAI,CAAC,QAAQ,CAAC,CAAC,CAAC,UAAU,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC;IAExF,OAAO,CACL,KAAC,UAAU,IACT,SAAS,EAAE,GAAG,CAAC,SAAS,EACxB,KAAK,EAAE,KAAK,EACZ,SAAS,EAAE,iBAAiB,EAC5B,QAAQ,EAAE,QAAQ,EAClB,IAAI,EAAE,IAAI,EACV,MAAM,EAAE,GAAG,CAAC,MAAM,EAClB,KAAK,EAAE,KAAK,EACZ,OAAO,EAAE,GAAG,CAAC,OAAO,EACpB,QAAQ,EAAE,QAAQ,EAClB,SAAS,EAAE,SAAS,YAEpB,KAAC,KAAK,OACA,UAAU,EACd,GAAG,EAAE,GAAG,EACR,EAAE,EAAE,GAAG,CAAC,SAAS,EACjB,QAAQ,EAAE,QAAQ,EAClB,QAAQ,EAAE,QAAQ,EAClB,OAAO,EAAE,QAAQ,sBACC,GAAG,CAAC,WAAW,GACjC,GACS,CACd,CAAC;AACJ,CAAC,CAAC,CAAC"}
@@ -81,12 +81,25 @@ export interface InputProps extends NativeInputProps {
81
81
  * Hover and focus are browser states expressed in CSS, not props. The only states
82
82
  * Input models are the ones a consumer owns: `disabled`, `readOnly` and `invalid`.
83
83
  *
84
- * **Every rendered Input must have an accessible name.** This primitive does not
85
- * manufacture one, because the name normally comes from a visible `<label>` that
86
- * the surrounding field composition owns. Until that component exists, associate
87
- * a real `<label htmlFor>` yourself, or pass `aria-label` / `aria-labelledby`. A
88
- * `placeholder` is not a name it disappears on the first keystroke. See
89
- * `input.md`.
84
+ * One state is neither: **whether part of the value is scrolled out of sight**.
85
+ * A single-line input scrolls rather than wraps, and gives no sign that it is
86
+ * doing so, so the control measures itself and marks its top-right corner when
87
+ * some of the value is not visible. It is derived rather than passed — there is
88
+ * no `overflow` prop, because whether a value fits depends on the container
89
+ * width, the density-driven text size and the reader's font size, none of which
90
+ * a consumer can know. See `overflow.ts` and `input.md`.
91
+ *
92
+ * **Use `Field` for an ordinary labelled text input.** It is the recommended
93
+ * composition and owns the label, the required or optional indicator, the
94
+ * supporting text, the error message and the ARIA wiring between them. Reach for
95
+ * this primitive directly only when another composition already owns all of
96
+ * that — a table's filter cell, a toolbar search box.
97
+ *
98
+ * **Every rendered Input must have an accessible name**, and this primitive does
99
+ * not manufacture one: the name belongs to whatever composition wraps it. Give it
100
+ * an associated `<label htmlFor>`, or `aria-label` / `aria-labelledby` where
101
+ * there is genuinely no visible label. A `placeholder` is not a name — it
102
+ * disappears on the first keystroke. See `input.md`.
90
103
  *
91
104
  * `forwardRef` rather than a plain `ref` prop: this package supports React 18.3
92
105
  * as well as 19, and ref-as-a-prop only works in 19.
@@ -1 +1 @@
1
- {"version":3,"file":"input.d.ts","sourceRoot":"","sources":["../../../src/components/input/input.tsx"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,OAAO,CAAC;AAEjD,sEAAsE;AACtE,MAAM,MAAM,SAAS,GAAG,IAAI,GAAG,IAAI,GAAG,IAAI,CAAC;AAE3C;;;;;;;;;;;;;;GAcG;AACH,MAAM,MAAM,SAAS,GACjB,MAAM,GACN,OAAO,GACP,UAAU,GACV,QAAQ,GACR,KAAK,GACL,KAAK,GACL,QAAQ,GACR,MAAM,GACN,gBAAgB,GAChB,OAAO,GACP,MAAM,GACN,MAAM,CAAC;AAEX;;;;;;;;;;;;;;;GAeG;AACH,KAAK,gBAAgB,GAAG,IAAI,CAC1B,mBAAmB,CAAC,gBAAgB,CAAC,EACrC,MAAM,GAAG,MAAM,GAAG,cAAc,GAAG,UAAU,CAC9C,CAAC;AAEF,MAAM,WAAW,UAAW,SAAQ,gBAAgB;IAClD;;;;;;;;OAQG;IACH,IAAI,CAAC,EAAE,SAAS,CAAC;IACjB;;;;;;;;;;;;;;OAcG;IACH,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB;;;;;OAKG;IACH,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,sBAAsB;IACtB,IAAI,CAAC,EAAE,SAAS,CAAC;CAClB;AAED;;;;;;;;;;;;;;;;;;;GAmBG;AACH,eAAO,MAAM,KAAK,yGAkBhB,CAAC"}
1
+ {"version":3,"file":"input.d.ts","sourceRoot":"","sources":["../../../src/components/input/input.tsx"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,OAAO,CAAC;AAIjD,sEAAsE;AACtE,MAAM,MAAM,SAAS,GAAG,IAAI,GAAG,IAAI,GAAG,IAAI,CAAC;AAE3C;;;;;;;;;;;;;;GAcG;AACH,MAAM,MAAM,SAAS,GACjB,MAAM,GACN,OAAO,GACP,UAAU,GACV,QAAQ,GACR,KAAK,GACL,KAAK,GACL,QAAQ,GACR,MAAM,GACN,gBAAgB,GAChB,OAAO,GACP,MAAM,GACN,MAAM,CAAC;AAEX;;;;;;;;;;;;;;;GAeG;AACH,KAAK,gBAAgB,GAAG,IAAI,CAC1B,mBAAmB,CAAC,gBAAgB,CAAC,EACrC,MAAM,GAAG,MAAM,GAAG,cAAc,GAAG,UAAU,CAC9C,CAAC;AAaF,MAAM,WAAW,UAAW,SAAQ,gBAAgB;IAClD;;;;;;;;OAQG;IACH,IAAI,CAAC,EAAE,SAAS,CAAC;IACjB;;;;;;;;;;;;;;OAcG;IACH,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB;;;;;OAKG;IACH,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,sBAAsB;IACtB,IAAI,CAAC,EAAE,SAAS,CAAC;CAClB;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AACH,eAAO,MAAM,KAAK,yGA0DhB,CAAC"}
@@ -1,5 +1,6 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
- import { forwardRef } from 'react';
2
+ import { forwardRef, useCallback, useRef } from 'react';
3
+ import { useValueOverflow } from './overflow.js';
3
4
  /**
4
5
  * A single-line text entry control.
5
6
  *
@@ -10,17 +11,49 @@ import { forwardRef } from 'react';
10
11
  * Hover and focus are browser states expressed in CSS, not props. The only states
11
12
  * Input models are the ones a consumer owns: `disabled`, `readOnly` and `invalid`.
12
13
  *
13
- * **Every rendered Input must have an accessible name.** This primitive does not
14
- * manufacture one, because the name normally comes from a visible `<label>` that
15
- * the surrounding field composition owns. Until that component exists, associate
16
- * a real `<label htmlFor>` yourself, or pass `aria-label` / `aria-labelledby`. A
17
- * `placeholder` is not a name it disappears on the first keystroke. See
18
- * `input.md`.
14
+ * One state is neither: **whether part of the value is scrolled out of sight**.
15
+ * A single-line input scrolls rather than wraps, and gives no sign that it is
16
+ * doing so, so the control measures itself and marks its top-right corner when
17
+ * some of the value is not visible. It is derived rather than passed — there is
18
+ * no `overflow` prop, because whether a value fits depends on the container
19
+ * width, the density-driven text size and the reader's font size, none of which
20
+ * a consumer can know. See `overflow.ts` and `input.md`.
21
+ *
22
+ * **Use `Field` for an ordinary labelled text input.** It is the recommended
23
+ * composition and owns the label, the required or optional indicator, the
24
+ * supporting text, the error message and the ARIA wiring between them. Reach for
25
+ * this primitive directly only when another composition already owns all of
26
+ * that — a table's filter cell, a toolbar search box.
27
+ *
28
+ * **Every rendered Input must have an accessible name**, and this primitive does
29
+ * not manufacture one: the name belongs to whatever composition wraps it. Give it
30
+ * an associated `<label htmlFor>`, or `aria-label` / `aria-labelledby` where
31
+ * there is genuinely no visible label. A `placeholder` is not a name — it
32
+ * disappears on the first keystroke. See `input.md`.
19
33
  *
20
34
  * `forwardRef` rather than a plain `ref` prop: this package supports React 18.3
21
35
  * as well as 19, and ref-as-a-prop only works in 19.
22
36
  */
23
- export const Input = forwardRef(function Input({ size = 'md', invalid = false, fullWidth = false, type = 'text', className, ...nativeProps }, ref) {
24
- return (_jsx("input", { ...nativeProps, ref: ref, type: type, className: className === undefined ? 'lutra-input' : `lutra-input ${className}`, "aria-invalid": invalid || undefined, "data-size": size, "data-full-width": fullWidth ? 'true' : undefined }));
37
+ export const Input = forwardRef(function Input({ size = 'md', invalid = false, fullWidth = false, type = 'text', className, onInput, ...nativeProps }, ref) {
38
+ // The component needs the element to measure it, and the consumer's `ref` still has to reach the
39
+ // same element. One callback ref feeds both, rather than the consumer losing their ref or the
40
+ // measurement reaching into the DOM by selector.
41
+ const inputRef = useRef(null);
42
+ const { overflowing, remeasure } = useValueOverflow(inputRef);
43
+ const attachRef = useCallback((element) => {
44
+ inputRef.current = element;
45
+ if (typeof ref === 'function')
46
+ ref(element);
47
+ else if (ref)
48
+ ref.current = element;
49
+ }, [ref]);
50
+ // `onInput` rather than wrapping `onChange`: React dispatches both from the same native event, so
51
+ // taking the one consumers reach for less often leaves their handler untouched. An uncontrolled
52
+ // input is the case that needs it — its value changes with no re-render to measure after.
53
+ const handleInput = useCallback((event) => {
54
+ onInput?.(event);
55
+ remeasure();
56
+ }, [onInput, remeasure]);
57
+ return (_jsx("input", { ...nativeProps, ref: attachRef, type: type, className: className === undefined ? 'lutra-input' : `lutra-input ${className}`, onInput: handleInput, "aria-invalid": invalid || undefined, "data-size": size, "data-full-width": fullWidth ? 'true' : undefined, "data-overflow": overflowing ? 'true' : undefined }));
25
58
  });
26
59
  //# sourceMappingURL=input.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"input.js","sourceRoot":"","sources":["../../../src/components/input/input.tsx"],"names":[],"mappings":";AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,OAAO,CAAC;AA8FnC;;;;;;;;;;;;;;;;;;;GAmBG;AACH,MAAM,CAAC,MAAM,KAAK,GAAG,UAAU,CAA+B,SAAS,KAAK,CAC1E,EAAE,IAAI,GAAG,IAAI,EAAE,OAAO,GAAG,KAAK,EAAE,SAAS,GAAG,KAAK,EAAE,IAAI,GAAG,MAAM,EAAE,SAAS,EAAE,GAAG,WAAW,EAAE,EAC7F,GAAG;IAEH,OAAO,CACL,mBACM,WAAW,EACf,GAAG,EAAE,GAAG,EACR,IAAI,EAAE,IAAI,EACV,SAAS,EAAE,SAAS,KAAK,SAAS,CAAC,CAAC,CAAC,aAAa,CAAC,CAAC,CAAC,eAAe,SAAS,EAAE,kBAIjE,OAAO,IAAI,SAAS,eACvB,IAAI,qBACE,SAAS,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,SAAS,GAC/C,CACH,CAAC;AACJ,CAAC,CAAC,CAAC"}
1
+ {"version":3,"file":"input.js","sourceRoot":"","sources":["../../../src/components/input/input.tsx"],"names":[],"mappings":";AAAA,OAAO,EAAE,UAAU,EAAE,WAAW,EAAE,MAAM,EAAE,MAAM,OAAO,CAAC;AAGxD,OAAO,EAAE,gBAAgB,EAAE,MAAM,eAAe,CAAC;AAwGjD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AACH,MAAM,CAAC,MAAM,KAAK,GAAG,UAAU,CAA+B,SAAS,KAAK,CAC1E,EACE,IAAI,GAAG,IAAI,EACX,OAAO,GAAG,KAAK,EACf,SAAS,GAAG,KAAK,EACjB,IAAI,GAAG,MAAM,EACb,SAAS,EACT,OAAO,EACP,GAAG,WAAW,EACf,EACD,GAAG;IAEH,iGAAiG;IACjG,8FAA8F;IAC9F,iDAAiD;IACjD,MAAM,QAAQ,GAAG,MAAM,CAA0B,IAAI,CAAC,CAAC;IACvD,MAAM,EAAE,WAAW,EAAE,SAAS,EAAE,GAAG,gBAAgB,CAAC,QAAQ,CAAC,CAAC;IAE9D,MAAM,SAAS,GAAG,WAAW,CAC3B,CAAC,OAAgC,EAAE,EAAE;QACnC,QAAQ,CAAC,OAAO,GAAG,OAAO,CAAC;QAC3B,IAAI,OAAO,GAAG,KAAK,UAAU;YAAE,GAAG,CAAC,OAAO,CAAC,CAAC;aACvC,IAAI,GAAG;YAAE,GAAG,CAAC,OAAO,GAAG,OAAO,CAAC;IACtC,CAAC,EACD,CAAC,GAAG,CAAC,CACN,CAAC;IAEF,kGAAkG;IAClG,gGAAgG;IAChG,0FAA0F;IAC1F,MAAM,WAAW,GAAG,WAAW,CAC7B,CAAC,KAAyB,EAAE,EAAE;QAC5B,OAAO,EAAE,CAAC,KAAK,CAAC,CAAC;QACjB,SAAS,EAAE,CAAC;IACd,CAAC,EACD,CAAC,OAAO,EAAE,SAAS,CAAC,CACrB,CAAC;IAEF,OAAO,CACL,mBACM,WAAW,EACf,GAAG,EAAE,SAAS,EACd,IAAI,EAAE,IAAI,EACV,SAAS,EAAE,SAAS,KAAK,SAAS,CAAC,CAAC,CAAC,aAAa,CAAC,CAAC,CAAC,eAAe,SAAS,EAAE,EAC/E,OAAO,EAAE,WAAW,kBAIN,OAAO,IAAI,SAAS,eACvB,IAAI,qBACE,SAAS,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,SAAS,mBAKhC,WAAW,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,SAAS,GAC/C,CACH,CAAC;AACJ,CAAC,CAAC,CAAC"}
@@ -0,0 +1,68 @@
1
+ import type { RefObject } from 'react';
2
+ /**
3
+ * Overflow smaller than this is ignored.
4
+ *
5
+ * NO FIGMA VARIABLE, and not a design value — it is a measurement guard. Browsers disagree about
6
+ * sub-pixel rounding of `scrollWidth` on a text control, and a field whose value exceeds its box by
7
+ * a fraction of a pixel is a field whose value fits as far as any reader is concerned. Without this,
8
+ * the mark could appear on an ordinary filled input for no visible reason, which is worse than
9
+ * missing a one-pixel truncation.
10
+ */
11
+ export declare const OVERFLOW_TOLERANCE_PX = 1;
12
+ /**
13
+ * The three facts the decision is made from. Kept as data so the rule is testable without a DOM.
14
+ *
15
+ * Not exported: nothing outside this module names it — the tests pass object literals — and an
16
+ * exported type with no consumer is surface a reader has to account for.
17
+ */
18
+ interface OverflowMeasurement {
19
+ /** The control's current value. */
20
+ value: string;
21
+ /** Total scrollable width of the control's content. */
22
+ scrollWidth: number;
23
+ /** Width of the visible content area. */
24
+ clientWidth: number;
25
+ }
26
+ /**
27
+ * Whether some of the value is outside the visible area.
28
+ *
29
+ * **An empty control never overflows, however long its placeholder is.** That is a decision, not a
30
+ * side effect of the arithmetic. The mark says "there is content you cannot see", and a placeholder
31
+ * is not content — it is a format example that vanishes on the first keystroke, so a mark pointing
32
+ * at it would promise a value that does not exist. Browsers do not generally make a long placeholder
33
+ * scrollable either, so this mostly agrees with the measurement; the explicit check is here so the
34
+ * behaviour is ours and stated rather than inherited and assumed.
35
+ */
36
+ export declare function hasHiddenValue({ value, scrollWidth, clientWidth }: OverflowMeasurement): boolean;
37
+ /**
38
+ * Tracks whether the referenced input is hiding part of its value.
39
+ *
40
+ * Three triggers, chosen to be the smallest set that covers the real causes, and no polling:
41
+ *
42
+ * 1. **After every render**, in a layout effect. This is what catches a controlled input whose value
43
+ * changed from above, a `size` change, a `type` change, and a density switch that re-rendered the
44
+ * tree. It runs before paint, so the mark never appears a frame late. Two DOM reads per render is
45
+ * cheap enough not to warrant a dependency list that would have to enumerate every prop that can
46
+ * move the text.
47
+ * 2. **`ResizeObserver` on the control**, for the width changing without React knowing — a container
48
+ * that reflowed, a window resize, a sibling that grew. This is the one cause no render can catch.
49
+ * 3. **The `input` event**, via the caller. An *uncontrolled* input's value changes with no re-render
50
+ * at all, so typing would otherwise go unnoticed until something else re-rendered.
51
+ *
52
+ * Density is worth a note: it is a CSS axis, so switching it re-resolves `control/text-size` and
53
+ * changes how wide the value draws without changing the control's box. `ResizeObserver` does not
54
+ * fire for that, and neither does a render unless the application re-rendered to flip the attribute
55
+ * — which is what happens in practice, because something has to set `data-density`. A page that
56
+ * changed the attribute from outside React entirely would keep a stale mark until its next render.
57
+ * That is measured rather than assumed, and bounded rather than solved: `contract.md` §12 has the
58
+ * numbers and §14 records why a `MutationObserver` was rejected.
59
+ *
60
+ * @param inputRef the control to measure
61
+ * @returns the current state, and a `remeasure` for the caller to run on `input`
62
+ */
63
+ export declare function useValueOverflow(inputRef: RefObject<HTMLInputElement | null>): {
64
+ overflowing: boolean;
65
+ remeasure: () => void;
66
+ };
67
+ export {};
68
+ //# sourceMappingURL=overflow.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"overflow.d.ts","sourceRoot":"","sources":["../../../src/components/input/overflow.ts"],"names":[],"mappings":"AA2BA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAC;AAIvC;;;;;;;;GAQG;AACH,eAAO,MAAM,qBAAqB,IAAI,CAAC;AAEvC;;;;;GAKG;AACH,UAAU,mBAAmB;IAC3B,mCAAmC;IACnC,KAAK,EAAE,MAAM,CAAC;IACd,uDAAuD;IACvD,WAAW,EAAE,MAAM,CAAC;IACpB,yCAAyC;IACzC,WAAW,EAAE,MAAM,CAAC;CACrB;AAED;;;;;;;;;GASG;AACH,wBAAgB,cAAc,CAAC,EAAE,KAAK,EAAE,WAAW,EAAE,WAAW,EAAE,EAAE,mBAAmB,GAAG,OAAO,CAGhG;AAED;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,wBAAgB,gBAAgB,CAAC,QAAQ,EAAE,SAAS,CAAC,gBAAgB,GAAG,IAAI,CAAC,GAAG;IAC9E,WAAW,EAAE,OAAO,CAAC;IACrB,SAAS,EAAE,MAAM,IAAI,CAAC;CACvB,CAoCA"}
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Detecting that some of an Input's value is not currently visible.
3
+ *
4
+ * A single-line `<input>` scrolls its own value rather than wrapping, which is correct native
5
+ * behaviour — but it gives no sign that it is doing so. A field showing `…@an-unusually-long-` looks
6
+ * exactly like a field whose value ends there. The earmark this module drives is a sighted-user
7
+ * discoverability cue for that one situation, and nothing more.
8
+ *
9
+ * ## What the state means
10
+ *
11
+ * **"There is value content outside the currently visible portion of this control."** Not "the value
12
+ * is long", and not "there is more text to the right" — while someone edits a long value the browser
13
+ * scrolls horizontally to keep the caret visible, so hidden content can be on either side or both.
14
+ * One corner mark for "some of it is not visible" is the deliberate first implementation. Splitting
15
+ * it into left and right marks would need `scrollLeft` and a scroll listener, and would say more than
16
+ * the design has asked for.
17
+ *
18
+ * ## Why it is derived, never passed
19
+ *
20
+ * There is no `overflow` prop and there must not be one. Whether a value fits is a fact about the
21
+ * rendered control — it depends on the container width, the density-driven text size, the reader's
22
+ * font-size preference and the value itself — so a consumer could only ever guess at it, and a
23
+ * consumer's guess would be wrong the moment any of those changed. The component measures instead.
24
+ *
25
+ * Nothing here is exported from the package root.
26
+ */
27
+ import { useCallback, useEffect, useState } from 'react';
28
+ import { useIsomorphicLayoutEffect } from '../../internal/isomorphic-layout-effect.js';
29
+ /**
30
+ * Overflow smaller than this is ignored.
31
+ *
32
+ * NO FIGMA VARIABLE, and not a design value — it is a measurement guard. Browsers disagree about
33
+ * sub-pixel rounding of `scrollWidth` on a text control, and a field whose value exceeds its box by
34
+ * a fraction of a pixel is a field whose value fits as far as any reader is concerned. Without this,
35
+ * the mark could appear on an ordinary filled input for no visible reason, which is worse than
36
+ * missing a one-pixel truncation.
37
+ */
38
+ export const OVERFLOW_TOLERANCE_PX = 1;
39
+ /**
40
+ * Whether some of the value is outside the visible area.
41
+ *
42
+ * **An empty control never overflows, however long its placeholder is.** That is a decision, not a
43
+ * side effect of the arithmetic. The mark says "there is content you cannot see", and a placeholder
44
+ * is not content — it is a format example that vanishes on the first keystroke, so a mark pointing
45
+ * at it would promise a value that does not exist. Browsers do not generally make a long placeholder
46
+ * scrollable either, so this mostly agrees with the measurement; the explicit check is here so the
47
+ * behaviour is ours and stated rather than inherited and assumed.
48
+ */
49
+ export function hasHiddenValue({ value, scrollWidth, clientWidth }) {
50
+ if (value === '')
51
+ return false;
52
+ return scrollWidth - clientWidth > OVERFLOW_TOLERANCE_PX;
53
+ }
54
+ /**
55
+ * Tracks whether the referenced input is hiding part of its value.
56
+ *
57
+ * Three triggers, chosen to be the smallest set that covers the real causes, and no polling:
58
+ *
59
+ * 1. **After every render**, in a layout effect. This is what catches a controlled input whose value
60
+ * changed from above, a `size` change, a `type` change, and a density switch that re-rendered the
61
+ * tree. It runs before paint, so the mark never appears a frame late. Two DOM reads per render is
62
+ * cheap enough not to warrant a dependency list that would have to enumerate every prop that can
63
+ * move the text.
64
+ * 2. **`ResizeObserver` on the control**, for the width changing without React knowing — a container
65
+ * that reflowed, a window resize, a sibling that grew. This is the one cause no render can catch.
66
+ * 3. **The `input` event**, via the caller. An *uncontrolled* input's value changes with no re-render
67
+ * at all, so typing would otherwise go unnoticed until something else re-rendered.
68
+ *
69
+ * Density is worth a note: it is a CSS axis, so switching it re-resolves `control/text-size` and
70
+ * changes how wide the value draws without changing the control's box. `ResizeObserver` does not
71
+ * fire for that, and neither does a render unless the application re-rendered to flip the attribute
72
+ * — which is what happens in practice, because something has to set `data-density`. A page that
73
+ * changed the attribute from outside React entirely would keep a stale mark until its next render.
74
+ * That is measured rather than assumed, and bounded rather than solved: `contract.md` §12 has the
75
+ * numbers and §14 records why a `MutationObserver` was rejected.
76
+ *
77
+ * @param inputRef the control to measure
78
+ * @returns the current state, and a `remeasure` for the caller to run on `input`
79
+ */
80
+ export function useValueOverflow(inputRef) {
81
+ const [overflowing, setOverflowing] = useState(false);
82
+ const remeasure = useCallback(() => {
83
+ const element = inputRef.current;
84
+ if (!element)
85
+ return;
86
+ // Measured against the *current* client width, which is what gives this its hysteresis: once the
87
+ // mark is showing it has reserved trailing space, so the value has to shrink past that reserve
88
+ // before the mark goes away. Without it, a value sitting exactly on the boundary would toggle
89
+ // the reserve, change the width, and toggle back.
90
+ setOverflowing(hasHiddenValue({
91
+ value: element.value,
92
+ scrollWidth: element.scrollWidth,
93
+ clientWidth: element.clientWidth,
94
+ }));
95
+ }, [inputRef]);
96
+ useIsomorphicLayoutEffect(remeasure);
97
+ useEffect(() => {
98
+ const element = inputRef.current;
99
+ // Not in jsdom, and not in every browser this package supports. The other two triggers still
100
+ // work without it; only a width change outside React goes unnoticed.
101
+ if (!element || typeof ResizeObserver === 'undefined')
102
+ return;
103
+ const observer = new ResizeObserver(remeasure);
104
+ observer.observe(element);
105
+ return () => {
106
+ observer.disconnect();
107
+ };
108
+ }, [inputRef, remeasure]);
109
+ return { overflowing, remeasure };
110
+ }
111
+ //# sourceMappingURL=overflow.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"overflow.js","sourceRoot":"","sources":["../../../src/components/input/overflow.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,OAAO,EAAE,WAAW,EAAE,SAAS,EAAE,QAAQ,EAAE,MAAM,OAAO,CAAC;AAGzD,OAAO,EAAE,yBAAyB,EAAE,MAAM,4CAA4C,CAAC;AAEvF;;;;;;;;GAQG;AACH,MAAM,CAAC,MAAM,qBAAqB,GAAG,CAAC,CAAC;AAiBvC;;;;;;;;;GASG;AACH,MAAM,UAAU,cAAc,CAAC,EAAE,KAAK,EAAE,WAAW,EAAE,WAAW,EAAuB;IACrF,IAAI,KAAK,KAAK,EAAE;QAAE,OAAO,KAAK,CAAC;IAC/B,OAAO,WAAW,GAAG,WAAW,GAAG,qBAAqB,CAAC;AAC3D,CAAC;AAED;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,MAAM,UAAU,gBAAgB,CAAC,QAA4C;IAI3E,MAAM,CAAC,WAAW,EAAE,cAAc,CAAC,GAAG,QAAQ,CAAC,KAAK,CAAC,CAAC;IAEtD,MAAM,SAAS,GAAG,WAAW,CAAC,GAAG,EAAE;QACjC,MAAM,OAAO,GAAG,QAAQ,CAAC,OAAO,CAAC;QACjC,IAAI,CAAC,OAAO;YAAE,OAAO;QAErB,iGAAiG;QACjG,+FAA+F;QAC/F,8FAA8F;QAC9F,kDAAkD;QAClD,cAAc,CACZ,cAAc,CAAC;YACb,KAAK,EAAE,OAAO,CAAC,KAAK;YACpB,WAAW,EAAE,OAAO,CAAC,WAAW;YAChC,WAAW,EAAE,OAAO,CAAC,WAAW;SACjC,CAAC,CACH,CAAC;IACJ,CAAC,EAAE,CAAC,QAAQ,CAAC,CAAC,CAAC;IAEf,yBAAyB,CAAC,SAAS,CAAC,CAAC;IAErC,SAAS,CAAC,GAAG,EAAE;QACb,MAAM,OAAO,GAAG,QAAQ,CAAC,OAAO,CAAC;QACjC,6FAA6F;QAC7F,qEAAqE;QACrE,IAAI,CAAC,OAAO,IAAI,OAAO,cAAc,KAAK,WAAW;YAAE,OAAO;QAE9D,MAAM,QAAQ,GAAG,IAAI,cAAc,CAAC,SAAS,CAAC,CAAC;QAC/C,QAAQ,CAAC,OAAO,CAAC,OAAO,CAAC,CAAC;QAC1B,OAAO,GAAG,EAAE;YACV,QAAQ,CAAC,UAAU,EAAE,CAAC;QACxB,CAAC,CAAC;IACJ,CAAC,EAAE,CAAC,QAAQ,EAAE,SAAS,CAAC,CAAC,CAAC;IAE1B,OAAO,EAAE,WAAW,EAAE,SAAS,EAAE,CAAC;AACpC,CAAC"}
@@ -9,7 +9,7 @@ type ForwardedSelectProps = Omit<SelectProps, 'invalid'>;
9
9
  export interface SelectFieldProps extends ForwardedSelectProps {
10
10
  /** The visible label. Always required. */
11
11
  label: ReactNode;
12
- /** Supporting text shown above the control and connected with `aria-describedby`. */
12
+ /** Supporting text shown below the control and connected with `aria-describedby`. */
13
13
  hint?: ReactNode;
14
14
  /**
15
15
  * The error message. Its presence is what puts the field in the error state — it drives the red
@@ -17,8 +17,9 @@ export interface SelectFieldProps extends ForwardedSelectProps {
17
17
  */
18
18
  error?: ReactNode;
19
19
  /**
20
- * The word shown after the label text and included in the accessible name. Defaults to
21
- * `'required'` when the native `required` attribute is set.
20
+ * The indicator shown immediately after the label text, drawn as `(required *)` or
21
+ * `(optional)`. Defaults to `'required'` when the native `required` attribute is set. Its
22
+ * accessible-name behaviour is `Field`'s — see `internal/field-support.tsx`.
22
23
  */
23
24
  indicator?: FieldIndicator;
24
25
  /** Extra class on the field wrapper, not on the control. */
@@ -27,12 +28,14 @@ export interface SelectFieldProps extends ForwardedSelectProps {
27
28
  children: ReactNode;
28
29
  }
29
30
  /**
30
- * A labelled dropdown: label, optional hint, a `Select`, and an optional error message.
31
+ * A labelled dropdown: label, a `Select`, optional supporting text, and an optional error
32
+ * message.
31
33
  *
32
34
  * Identical composition to `Field`, around a different control. Both render the same shell — the
33
35
  * same label element, the same indicator placement, the same id derivation, the same
34
- * `aria-describedby` assembly so a text field and a dropdown behave and read the same way in a
35
- * form, and a change to that behaviour lands in one place.
36
+ * `aria-describedby` assembly, the same label control hint error order so a text field and a
37
+ * dropdown behave and read the same way in a form, and a change to that behaviour lands in one
38
+ * place.
36
39
  *
37
40
  * Everything not listed above is forwarded to `Select`, so `name`, `value`, `defaultValue`,
38
41
  * `required`, `onChange`, `size` and `fullWidth` behave as they do there. A `ref` reaches the native
@@ -1 +1 @@
1
- {"version":3,"file":"select-field.d.ts","sourceRoot":"","sources":["../../../src/components/select-field/select-field.tsx"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAC;AAGvC,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,iCAAiC,CAAC;AAEtE,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,oBAAoB,CAAC;AAEtD;;;GAGG;AACH,KAAK,oBAAoB,GAAG,IAAI,CAAC,WAAW,EAAE,SAAS,CAAC,CAAC;AAEzD,MAAM,WAAW,gBAAiB,SAAQ,oBAAoB;IAC5D,0CAA0C;IAC1C,KAAK,EAAE,SAAS,CAAC;IACjB,qFAAqF;IACrF,IAAI,CAAC,EAAE,SAAS,CAAC;IACjB;;;OAGG;IACH,KAAK,CAAC,EAAE,SAAS,CAAC;IAClB;;;OAGG;IACH,SAAS,CAAC,EAAE,cAAc,CAAC;IAC3B,4DAA4D;IAC5D,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,gDAAgD;IAChD,QAAQ,EAAE,SAAS,CAAC;CACrB;AAED;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,WAAW,gHA+CtB,CAAC"}
1
+ {"version":3,"file":"select-field.d.ts","sourceRoot":"","sources":["../../../src/components/select-field/select-field.tsx"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAC;AAGvC,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,iCAAiC,CAAC;AAEtE,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,oBAAoB,CAAC;AAEtD;;;GAGG;AACH,KAAK,oBAAoB,GAAG,IAAI,CAAC,WAAW,EAAE,SAAS,CAAC,CAAC;AAEzD,MAAM,WAAW,gBAAiB,SAAQ,oBAAoB;IAC5D,0CAA0C;IAC1C,KAAK,EAAE,SAAS,CAAC;IACjB,qFAAqF;IACrF,IAAI,CAAC,EAAE,SAAS,CAAC;IACjB;;;OAGG;IACH,KAAK,CAAC,EAAE,SAAS,CAAC;IAClB;;;;OAIG;IACH,SAAS,CAAC,EAAE,cAAc,CAAC;IAC3B,4DAA4D;IAC5D,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,gDAAgD;IAChD,QAAQ,EAAE,SAAS,CAAC;CACrB;AAED;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,WAAW,gHAgDtB,CAAC"}
@@ -3,12 +3,14 @@ import { forwardRef } from 'react';
3
3
  import { FieldShell, useFieldIds } from '../../internal/field-support.js';
4
4
  import { Select } from '../select/index.js';
5
5
  /**
6
- * A labelled dropdown: label, optional hint, a `Select`, and an optional error message.
6
+ * A labelled dropdown: label, a `Select`, optional supporting text, and an optional error
7
+ * message.
7
8
  *
8
9
  * Identical composition to `Field`, around a different control. Both render the same shell — the
9
10
  * same label element, the same indicator placement, the same id derivation, the same
10
- * `aria-describedby` assembly so a text field and a dropdown behave and read the same way in a
11
- * form, and a change to that behaviour lands in one place.
11
+ * `aria-describedby` assembly, the same label control hint error order so a text field and a
12
+ * dropdown behave and read the same way in a form, and a change to that behaviour lands in one
13
+ * place.
12
14
  *
13
15
  * Everything not listed above is forwarded to `Select`, so `name`, `value`, `defaultValue`,
14
16
  * `required`, `onChange`, `size` and `fullWidth` behave as they do there. A `ref` reaches the native
@@ -19,6 +21,6 @@ export const SelectField = forwardRef(function SelectField({ label, hint, error,
19
21
  const hasError = error !== undefined && error !== null && error !== false;
20
22
  const ids = useFieldIds({ id, hasHint, hasError, describedBy });
21
23
  const resolvedIndicator = indicator ?? (required ? 'required' : 'none');
22
- return (_jsx(FieldShell, { controlId: ids.controlId, label: label, indicator: resolvedIndicator, hint: hint, hintId: ids.hintId, error: error, errorId: ids.errorId, disabled: disabled, className: className, children: _jsx(Select, { ...selectProps, ref: ref, id: ids.controlId, required: required, disabled: disabled, invalid: hasError, "aria-describedby": ids.describedBy, children: children }) }));
24
+ return (_jsx(FieldShell, { controlId: ids.controlId, label: label, indicator: resolvedIndicator, required: required, hint: hint, hintId: ids.hintId, error: error, errorId: ids.errorId, disabled: disabled, className: className, children: _jsx(Select, { ...selectProps, ref: ref, id: ids.controlId, required: required, disabled: disabled, invalid: hasError, "aria-describedby": ids.describedBy, children: children }) }));
23
25
  });
24
26
  //# sourceMappingURL=select-field.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"select-field.js","sourceRoot":"","sources":["../../../src/components/select-field/select-field.tsx"],"names":[],"mappings":";AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,OAAO,CAAC;AAGnC,OAAO,EAAE,UAAU,EAAE,WAAW,EAAE,MAAM,iCAAiC,CAAC;AAE1E,OAAO,EAAE,MAAM,EAAE,MAAM,oBAAoB,CAAC;AA8B5C;;;;;;;;;;;GAWG;AACH,MAAM,CAAC,MAAM,WAAW,GAAG,UAAU,CAAsC,SAAS,WAAW,CAC7F,EACE,KAAK,EACL,IAAI,EACJ,KAAK,EACL,SAAS,EACT,SAAS,EACT,EAAE,EACF,QAAQ,GAAG,KAAK,EAChB,QAAQ,GAAG,KAAK,EAChB,kBAAkB,EAAE,WAAW,EAC/B,QAAQ,EACR,GAAG,WAAW,EACf,EACD,GAAG;IAEH,MAAM,OAAO,GAAG,IAAI,KAAK,SAAS,IAAI,IAAI,KAAK,IAAI,IAAI,IAAI,KAAK,KAAK,CAAC;IACtE,MAAM,QAAQ,GAAG,KAAK,KAAK,SAAS,IAAI,KAAK,KAAK,IAAI,IAAI,KAAK,KAAK,KAAK,CAAC;IAE1E,MAAM,GAAG,GAAG,WAAW,CAAC,EAAE,EAAE,EAAE,OAAO,EAAE,QAAQ,EAAE,WAAW,EAAE,CAAC,CAAC;IAChE,MAAM,iBAAiB,GAAmB,SAAS,IAAI,CAAC,QAAQ,CAAC,CAAC,CAAC,UAAU,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC;IAExF,OAAO,CACL,KAAC,UAAU,IACT,SAAS,EAAE,GAAG,CAAC,SAAS,EACxB,KAAK,EAAE,KAAK,EACZ,SAAS,EAAE,iBAAiB,EAC5B,IAAI,EAAE,IAAI,EACV,MAAM,EAAE,GAAG,CAAC,MAAM,EAClB,KAAK,EAAE,KAAK,EACZ,OAAO,EAAE,GAAG,CAAC,OAAO,EACpB,QAAQ,EAAE,QAAQ,EAClB,SAAS,EAAE,SAAS,YAEpB,KAAC,MAAM,OACD,WAAW,EACf,GAAG,EAAE,GAAG,EACR,EAAE,EAAE,GAAG,CAAC,SAAS,EACjB,QAAQ,EAAE,QAAQ,EAClB,QAAQ,EAAE,QAAQ,EAClB,OAAO,EAAE,QAAQ,sBACC,GAAG,CAAC,WAAW,YAEhC,QAAQ,GACF,GACE,CACd,CAAC;AACJ,CAAC,CAAC,CAAC"}
1
+ {"version":3,"file":"select-field.js","sourceRoot":"","sources":["../../../src/components/select-field/select-field.tsx"],"names":[],"mappings":";AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,OAAO,CAAC;AAGnC,OAAO,EAAE,UAAU,EAAE,WAAW,EAAE,MAAM,iCAAiC,CAAC;AAE1E,OAAO,EAAE,MAAM,EAAE,MAAM,oBAAoB,CAAC;AA+B5C;;;;;;;;;;;;;GAaG;AACH,MAAM,CAAC,MAAM,WAAW,GAAG,UAAU,CAAsC,SAAS,WAAW,CAC7F,EACE,KAAK,EACL,IAAI,EACJ,KAAK,EACL,SAAS,EACT,SAAS,EACT,EAAE,EACF,QAAQ,GAAG,KAAK,EAChB,QAAQ,GAAG,KAAK,EAChB,kBAAkB,EAAE,WAAW,EAC/B,QAAQ,EACR,GAAG,WAAW,EACf,EACD,GAAG;IAEH,MAAM,OAAO,GAAG,IAAI,KAAK,SAAS,IAAI,IAAI,KAAK,IAAI,IAAI,IAAI,KAAK,KAAK,CAAC;IACtE,MAAM,QAAQ,GAAG,KAAK,KAAK,SAAS,IAAI,KAAK,KAAK,IAAI,IAAI,KAAK,KAAK,KAAK,CAAC;IAE1E,MAAM,GAAG,GAAG,WAAW,CAAC,EAAE,EAAE,EAAE,OAAO,EAAE,QAAQ,EAAE,WAAW,EAAE,CAAC,CAAC;IAChE,MAAM,iBAAiB,GAAmB,SAAS,IAAI,CAAC,QAAQ,CAAC,CAAC,CAAC,UAAU,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC;IAExF,OAAO,CACL,KAAC,UAAU,IACT,SAAS,EAAE,GAAG,CAAC,SAAS,EACxB,KAAK,EAAE,KAAK,EACZ,SAAS,EAAE,iBAAiB,EAC5B,QAAQ,EAAE,QAAQ,EAClB,IAAI,EAAE,IAAI,EACV,MAAM,EAAE,GAAG,CAAC,MAAM,EAClB,KAAK,EAAE,KAAK,EACZ,OAAO,EAAE,GAAG,CAAC,OAAO,EACpB,QAAQ,EAAE,QAAQ,EAClB,SAAS,EAAE,SAAS,YAEpB,KAAC,MAAM,OACD,WAAW,EACf,GAAG,EAAE,GAAG,EACR,EAAE,EAAE,GAAG,CAAC,SAAS,EACjB,QAAQ,EAAE,QAAQ,EAClB,QAAQ,EAAE,QAAQ,EAClB,OAAO,EAAE,QAAQ,sBACC,GAAG,CAAC,WAAW,YAEhC,QAAQ,GACF,GACE,CACd,CAAC;AACJ,CAAC,CAAC,CAAC"}
@@ -44,6 +44,14 @@ interface FieldShellProps {
44
44
  controlId: string;
45
45
  label: ReactNode;
46
46
  indicator: FieldIndicator;
47
+ /**
48
+ * Whether the control carries the native `required` attribute.
49
+ *
50
+ * The shell needs this to decide whether the required indicator is redundant to a screen reader —
51
+ * see the note on the accessible name below. It never renders the attribute itself; that belongs
52
+ * to the control.
53
+ */
54
+ required: boolean;
47
55
  hint: ReactNode;
48
56
  hintId: string | undefined;
49
57
  error: ReactNode;
@@ -55,14 +63,38 @@ interface FieldShellProps {
55
63
  }
56
64
  /**
57
65
  * Renders the visible parts around a control, in the documented order:
58
- * label → hintcontrol → error.
66
+ * label → controlhint → error.
59
67
  *
60
- * The hint sits above the control so it is read before the control is operated; the error sits
61
- * below it, which is where the Figma annotation places it and where Field Group puts its own.
68
+ * **Supporting text sits below the control**, which is what the Figma component draws and what its
69
+ * accessibility annotation describes. An earlier implementation placed the hint above the control on
70
+ * the argument that guidance should be read before typing; the design system decided otherwise, and
71
+ * the contract is the design system's to set. The `aria-describedby` order is unchanged and now
72
+ * matches the DOM order exactly — consumer description, then hint, then error.
62
73
  *
63
- * The indicator word is rendered *inside* the `<label>`, so it becomes part of the accessible name
64
- * and is announced with it rather than being a separate visual flourish the label does not carry.
74
+ * ## The indicator and the accessible name
75
+ *
76
+ * The indicator is rendered inside the `<label>`, so whatever of it is exposed becomes part of the
77
+ * accessible name. Figma draws `(required *)` and `(optional)` immediately after the label text, and
78
+ * the visible output is exactly that in every case. What differs is how much of it assistive
79
+ * technology sees, and the rule is: **expose the indicator only when nothing else carries the same
80
+ * information.**
81
+ *
82
+ * - **`required` indicator with the native `required` attribute** — hidden entirely. The attribute
83
+ * already conveys the required state, and repeating the word in the name says the same thing
84
+ * twice about one control. This is the ordinary case, because `indicator` defaults to `'required'`
85
+ * precisely when `required` is set.
86
+ * - **`required` indicator without the attribute** — the word stays in the name. A consumer may ask
87
+ * for the indicator on a field that is validated elsewhere, and in that case the name is the only
88
+ * thing carrying it. Hiding it there would leave the requirement visible-only, which is the defect
89
+ * this rule exists to avoid.
90
+ * - **`optional` indicator** — always stays in the name. There is no native attribute and no ARIA
91
+ * state for "optional", so nothing else conveys it and there is no duplication to remove.
92
+ *
93
+ * The asterisk is separately `aria-hidden` in the one case where the word is exposed: it is a
94
+ * decorative convention duplicating the word beside it, and it must never be the only signal.
95
+ *
96
+ * How any given screen reader words the required state is its own business and is not asserted here.
65
97
  */
66
- export declare function FieldShell({ controlId, label, indicator, hint, hintId, error, errorId, disabled, className, children, }: FieldShellProps): import("react").JSX.Element;
98
+ export declare function FieldShell({ controlId, label, indicator, required, hint, hintId, error, errorId, disabled, className, children, }: FieldShellProps): import("react").JSX.Element;
67
99
  export {};
68
100
  //# sourceMappingURL=field-support.d.ts.map