@lutra-ui-system/react 0.2.0 → 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 +32 -6
- package/dist/components/field/field.d.ts +16 -9
- package/dist/components/field/field.d.ts.map +1 -1
- package/dist/components/field/field.js +6 -4
- package/dist/components/field/field.js.map +1 -1
- package/dist/components/input/input.d.ts +19 -6
- package/dist/components/input/input.d.ts.map +1 -1
- package/dist/components/input/input.js +42 -9
- package/dist/components/input/input.js.map +1 -1
- package/dist/components/input/overflow.d.ts +68 -0
- package/dist/components/input/overflow.d.ts.map +1 -0
- package/dist/components/input/overflow.js +111 -0
- package/dist/components/input/overflow.js.map +1 -0
- package/dist/components/select-field/select-field.d.ts +9 -6
- package/dist/components/select-field/select-field.d.ts.map +1 -1
- package/dist/components/select-field/select-field.js +6 -4
- package/dist/components/select-field/select-field.js.map +1 -1
- package/dist/internal/field-support.d.ts +38 -6
- package/dist/internal/field-support.d.ts.map +1 -1
- package/dist/internal/field-support.js +36 -8
- package/dist/internal/field-support.js.map +1 -1
- package/dist/styles.css +255 -55
- package/dist/tokens.json +15 -1
- package/package.json +3 -2
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-
|
|
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.
|
|
106
|
-
|
|
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
|
|
205
|
-
they declare custom properties and nothing else — a stylesheet must
|
|
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
|
|
20
|
-
* format guidance the
|
|
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
|
|
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
|
-
*
|
|
34
|
-
*
|
|
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,
|
|
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 →
|
|
52
|
-
*
|
|
57
|
+
* Order is label → control → hint → 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
|
|
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,
|
|
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 →
|
|
17
|
-
*
|
|
17
|
+
* Order is label → control → hint → 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;
|
|
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
|
-
*
|
|
85
|
-
*
|
|
86
|
-
* the
|
|
87
|
-
*
|
|
88
|
-
* `
|
|
89
|
-
*
|
|
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;
|
|
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
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* the
|
|
16
|
-
*
|
|
17
|
-
* `
|
|
18
|
-
*
|
|
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
|
-
|
|
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;
|
|
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
|
|
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
|
|
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,
|
|
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
|
|
35
|
-
* form, and a change to that behaviour lands in one
|
|
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
|
|
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,
|
|
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
|
|
11
|
-
* form, and a change to that behaviour lands in one
|
|
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;
|
|
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 →
|
|
66
|
+
* label → control → hint → error.
|
|
59
67
|
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
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
|
|
64
|
-
*
|
|
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
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"field-support.d.ts","sourceRoot":"","sources":["../../src/internal/field-support.tsx"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAC;AAEvC;;;;;;;;;;;GAWG;AAEH,wDAAwD;AACxD,MAAM,MAAM,cAAc,GAAG,MAAM,GAAG,UAAU,GAAG,UAAU,CAAC;AAE9D,UAAU,aAAa;IACrB,4CAA4C;IAC5C,EAAE,EAAE,MAAM,GAAG,SAAS,CAAC;IACvB,OAAO,EAAE,OAAO,CAAC;IACjB,QAAQ,EAAE,OAAO,CAAC;IAClB,4DAA4D;IAC5D,WAAW,EAAE,MAAM,GAAG,SAAS,CAAC;CACjC;AAED,MAAM,WAAW,QAAQ;IACvB,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC;IAC3B,OAAO,EAAE,MAAM,GAAG,SAAS,CAAC;IAC5B,yFAAyF;IACzF,WAAW,EAAE,MAAM,GAAG,SAAS,CAAC;CACjC;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,WAAW,CAAC,EAAE,EAAE,EAAE,OAAO,EAAE,QAAQ,EAAE,WAAW,EAAE,EAAE,aAAa,GAAG,QAAQ,
|
|
1
|
+
{"version":3,"file":"field-support.d.ts","sourceRoot":"","sources":["../../src/internal/field-support.tsx"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAC;AAEvC;;;;;;;;;;;GAWG;AAEH,wDAAwD;AACxD,MAAM,MAAM,cAAc,GAAG,MAAM,GAAG,UAAU,GAAG,UAAU,CAAC;AAE9D,UAAU,aAAa;IACrB,4CAA4C;IAC5C,EAAE,EAAE,MAAM,GAAG,SAAS,CAAC;IACvB,OAAO,EAAE,OAAO,CAAC;IACjB,QAAQ,EAAE,OAAO,CAAC;IAClB,4DAA4D;IAC5D,WAAW,EAAE,MAAM,GAAG,SAAS,CAAC;CACjC;AAED,MAAM,WAAW,QAAQ;IACvB,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC;IAC3B,OAAO,EAAE,MAAM,GAAG,SAAS,CAAC;IAC5B,yFAAyF;IACzF,WAAW,EAAE,MAAM,GAAG,SAAS,CAAC;CACjC;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,WAAW,CAAC,EAAE,EAAE,EAAE,OAAO,EAAE,QAAQ,EAAE,WAAW,EAAE,EAAE,aAAa,GAAG,QAAQ,CAmB3F;AAED,UAAU,eAAe;IACvB,SAAS,EAAE,MAAM,CAAC;IAClB,KAAK,EAAE,SAAS,CAAC;IACjB,SAAS,EAAE,cAAc,CAAC;IAC1B;;;;;;OAMG;IACH,QAAQ,EAAE,OAAO,CAAC;IAClB,IAAI,EAAE,SAAS,CAAC;IAChB,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC;IAC3B,KAAK,EAAE,SAAS,CAAC;IACjB,OAAO,EAAE,MAAM,GAAG,SAAS,CAAC;IAC5B,QAAQ,EAAE,OAAO,CAAC;IAClB,SAAS,EAAE,MAAM,GAAG,SAAS,CAAC;IAC9B,0EAA0E;IAC1E,QAAQ,EAAE,SAAS,CAAC;CACrB;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiCG;AACH,wBAAgB,UAAU,CAAC,EACzB,SAAS,EACT,KAAK,EACL,SAAS,EACT,QAAQ,EACR,IAAI,EACJ,MAAM,EACN,KAAK,EACL,OAAO,EACP,QAAQ,EACR,SAAS,EACT,QAAQ,GACT,EAAE,eAAe,+BAsDjB"}
|
|
@@ -17,7 +17,9 @@ export function useFieldIds({ id, hasHint, hasError, describedBy }) {
|
|
|
17
17
|
const hintId = hasHint ? `${controlId}-hint` : undefined;
|
|
18
18
|
const errorId = hasError ? `${controlId}-error` : undefined;
|
|
19
19
|
// Order matters: the consumer's own description first, then the hint, then the error. A screen
|
|
20
|
-
// reader reads them in this order,
|
|
20
|
+
// reader reads them in this order, the error is the thing to leave ringing in the ear, and since
|
|
21
|
+
// supporting text moved below the control this is also the DOM order — reading order and
|
|
22
|
+
// description order are now the same sequence rather than two orders to keep in step.
|
|
21
23
|
const parts = [describedBy, hintId, errorId].filter((part) => Boolean(part));
|
|
22
24
|
return {
|
|
23
25
|
controlId,
|
|
@@ -28,15 +30,41 @@ export function useFieldIds({ id, hasHint, hasError, describedBy }) {
|
|
|
28
30
|
}
|
|
29
31
|
/**
|
|
30
32
|
* Renders the visible parts around a control, in the documented order:
|
|
31
|
-
* label →
|
|
33
|
+
* label → control → hint → error.
|
|
32
34
|
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
+
* **Supporting text sits below the control**, which is what the Figma component draws and what its
|
|
36
|
+
* accessibility annotation describes. An earlier implementation placed the hint above the control on
|
|
37
|
+
* the argument that guidance should be read before typing; the design system decided otherwise, and
|
|
38
|
+
* the contract is the design system's to set. The `aria-describedby` order is unchanged and now
|
|
39
|
+
* matches the DOM order exactly — consumer description, then hint, then error.
|
|
35
40
|
*
|
|
36
|
-
* The indicator
|
|
37
|
-
*
|
|
41
|
+
* ## The indicator and the accessible name
|
|
42
|
+
*
|
|
43
|
+
* The indicator is rendered inside the `<label>`, so whatever of it is exposed becomes part of the
|
|
44
|
+
* accessible name. Figma draws `(required *)` and `(optional)` immediately after the label text, and
|
|
45
|
+
* the visible output is exactly that in every case. What differs is how much of it assistive
|
|
46
|
+
* technology sees, and the rule is: **expose the indicator only when nothing else carries the same
|
|
47
|
+
* information.**
|
|
48
|
+
*
|
|
49
|
+
* - **`required` indicator with the native `required` attribute** — hidden entirely. The attribute
|
|
50
|
+
* already conveys the required state, and repeating the word in the name says the same thing
|
|
51
|
+
* twice about one control. This is the ordinary case, because `indicator` defaults to `'required'`
|
|
52
|
+
* precisely when `required` is set.
|
|
53
|
+
* - **`required` indicator without the attribute** — the word stays in the name. A consumer may ask
|
|
54
|
+
* for the indicator on a field that is validated elsewhere, and in that case the name is the only
|
|
55
|
+
* thing carrying it. Hiding it there would leave the requirement visible-only, which is the defect
|
|
56
|
+
* this rule exists to avoid.
|
|
57
|
+
* - **`optional` indicator** — always stays in the name. There is no native attribute and no ARIA
|
|
58
|
+
* state for "optional", so nothing else conveys it and there is no duplication to remove.
|
|
59
|
+
*
|
|
60
|
+
* The asterisk is separately `aria-hidden` in the one case where the word is exposed: it is a
|
|
61
|
+
* decorative convention duplicating the word beside it, and it must never be the only signal.
|
|
62
|
+
*
|
|
63
|
+
* How any given screen reader words the required state is its own business and is not asserted here.
|
|
38
64
|
*/
|
|
39
|
-
export function FieldShell({ controlId, label, indicator, hint, hintId, error, errorId, disabled, className, children, }) {
|
|
40
|
-
|
|
65
|
+
export function FieldShell({ controlId, label, indicator, required, hint, hintId, error, errorId, disabled, className, children, }) {
|
|
66
|
+
// The one case where the indicator says nothing the control has not already said.
|
|
67
|
+
const indicatorIsRedundant = indicator === 'required' && required;
|
|
68
|
+
return (_jsxs("div", { className: className === undefined ? 'lutra-field' : `lutra-field ${className}`, "data-invalid": error ? 'true' : undefined, "data-disabled": disabled ? 'true' : undefined, children: [_jsxs("label", { className: "lutra-field__label", htmlFor: controlId, children: [label, indicator !== 'none' && (_jsxs(_Fragment, { children: [' ', _jsx("span", { className: "lutra-field__indicator", "data-indicator": indicator, "aria-hidden": indicatorIsRedundant || undefined, children: indicator === 'required' ? (_jsxs(_Fragment, { children: ['(required', _jsx("span", { "aria-hidden": "true", children: ' *' }), ')'] })) : ('(optional)') })] }))] }), children, hint !== undefined && hint !== null && hint !== false && (_jsx("p", { className: "lutra-field__hint", id: hintId, children: hint })), error !== undefined && error !== null && error !== false && (_jsx("p", { className: "lutra-field__error", id: errorId, children: error }))] }));
|
|
41
69
|
}
|
|
42
70
|
//# sourceMappingURL=field-support.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"field-support.js","sourceRoot":"","sources":["../../src/internal/field-support.tsx"],"names":[],"mappings":";AAAA,OAAO,EAAE,KAAK,EAAE,MAAM,OAAO,CAAC;AAoC9B;;;;;;;;;;GAUG;AACH,MAAM,UAAU,WAAW,CAAC,EAAE,EAAE,EAAE,OAAO,EAAE,QAAQ,EAAE,WAAW,EAAiB;IAC/E,MAAM,SAAS,GAAG,KAAK,EAAE,CAAC;IAC1B,MAAM,SAAS,GAAG,EAAE,IAAI,eAAe,SAAS,EAAE,CAAC;IAEnD,MAAM,MAAM,GAAG,OAAO,CAAC,CAAC,CAAC,GAAG,SAAS,OAAO,CAAC,CAAC,CAAC,SAAS,CAAC;IACzD,MAAM,OAAO,GAAG,QAAQ,CAAC,CAAC,CAAC,GAAG,SAAS,QAAQ,CAAC,CAAC,CAAC,SAAS,CAAC;IAE5D,+FAA+F;IAC/F,
|
|
1
|
+
{"version":3,"file":"field-support.js","sourceRoot":"","sources":["../../src/internal/field-support.tsx"],"names":[],"mappings":";AAAA,OAAO,EAAE,KAAK,EAAE,MAAM,OAAO,CAAC;AAoC9B;;;;;;;;;;GAUG;AACH,MAAM,UAAU,WAAW,CAAC,EAAE,EAAE,EAAE,OAAO,EAAE,QAAQ,EAAE,WAAW,EAAiB;IAC/E,MAAM,SAAS,GAAG,KAAK,EAAE,CAAC;IAC1B,MAAM,SAAS,GAAG,EAAE,IAAI,eAAe,SAAS,EAAE,CAAC;IAEnD,MAAM,MAAM,GAAG,OAAO,CAAC,CAAC,CAAC,GAAG,SAAS,OAAO,CAAC,CAAC,CAAC,SAAS,CAAC;IACzD,MAAM,OAAO,GAAG,QAAQ,CAAC,CAAC,CAAC,GAAG,SAAS,QAAQ,CAAC,CAAC,CAAC,SAAS,CAAC;IAE5D,+FAA+F;IAC/F,iGAAiG;IACjG,yFAAyF;IACzF,sFAAsF;IACtF,MAAM,KAAK,GAAG,CAAC,WAAW,EAAE,MAAM,EAAE,OAAO,CAAC,CAAC,MAAM,CAAC,CAAC,IAAI,EAAkB,EAAE,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC;IAE7F,OAAO;QACL,SAAS;QACT,MAAM;QACN,OAAO;QACP,WAAW,EAAE,KAAK,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,SAAS;KAC5D,CAAC;AACJ,CAAC;AAwBD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiCG;AACH,MAAM,UAAU,UAAU,CAAC,EACzB,SAAS,EACT,KAAK,EACL,SAAS,EACT,QAAQ,EACR,IAAI,EACJ,MAAM,EACN,KAAK,EACL,OAAO,EACP,QAAQ,EACR,SAAS,EACT,QAAQ,GACQ;IAChB,kFAAkF;IAClF,MAAM,oBAAoB,GAAG,SAAS,KAAK,UAAU,IAAI,QAAQ,CAAC;IAElE,OAAO,CACL,eACE,SAAS,EAAE,SAAS,KAAK,SAAS,CAAC,CAAC,CAAC,aAAa,CAAC,CAAC,CAAC,eAAe,SAAS,EAAE,kBACjE,KAAK,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,SAAS,mBACzB,QAAQ,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,SAAS,aAE5C,iBAAO,SAAS,EAAC,oBAAoB,EAAC,OAAO,EAAE,SAAS,aACrD,KAAK,EACL,SAAS,KAAK,MAAM,IAAI,CACvB,8BAGoF,GAAG,EACrF,eACE,SAAS,EAAC,wBAAwB,oBAClB,SAAS,iBACZ,oBAAoB,IAAI,SAAS,YAE7C,SAAS,KAAK,UAAU,CAAC,CAAC,CAAC,CAC1B,8BACG,WAAW,EAIZ,8BAAkB,MAAM,YAAE,IAAI,GAAQ,EACrC,GAAG,IACH,CACJ,CAAC,CAAC,CAAC,CACF,YAAY,CACb,GACI,IACN,CACJ,IACK,EAEP,QAAQ,EAER,IAAI,KAAK,SAAS,IAAI,IAAI,KAAK,IAAI,IAAI,IAAI,KAAK,KAAK,IAAI,CACxD,YAAG,SAAS,EAAC,mBAAmB,EAAC,EAAE,EAAE,MAAM,YACxC,IAAI,GACH,CACL,EAEA,KAAK,KAAK,SAAS,IAAI,KAAK,KAAK,IAAI,IAAI,KAAK,KAAK,KAAK,IAAI,CAC3D,YAAG,SAAS,EAAC,oBAAoB,EAAC,EAAE,EAAE,OAAO,YAC1C,KAAK,GACJ,CACL,IACG,CACP,CAAC;AACJ,CAAC"}
|
package/dist/styles.css
CHANGED
|
@@ -15,18 +15,24 @@
|
|
|
15
15
|
* The file has two layers, and the order matters:
|
|
16
16
|
*
|
|
17
17
|
* 1. The generated token layer, which declares every Lutra design token as a CSS
|
|
18
|
-
* custom property on `:root`, plus the dark values on `[data-theme='dark']
|
|
19
|
-
*
|
|
18
|
+
* custom property on `:root`, plus the dark values on `[data-theme='dark']`
|
|
19
|
+
* and the condensed values on `[data-density='condensed']`. Theme and density
|
|
20
|
+
* are independent axes and compose freely. It is produced from
|
|
21
|
+
* `tokens/source.json` and is not hand-edited.
|
|
20
22
|
* 2. One file per component, which reads those tokens and never a raw value.
|
|
21
23
|
*
|
|
22
24
|
* Rules for anything added here:
|
|
23
25
|
* - Every selector is prefixed with `lutra-`, so the library cannot collide with
|
|
24
26
|
* a consuming application's styles.
|
|
25
|
-
* - The only global selectors permitted are the token layer's `:root
|
|
26
|
-
* `[data-theme]` blocks, and they declare custom properties
|
|
27
|
-
* A declaration that paints — a colour, a font, a margin on
|
|
28
|
-
* appear on a global selector: a library stylesheet does not
|
|
29
|
-
* page it was merely imported into. See
|
|
27
|
+
* - The only global selectors permitted are the token layer's `:root`,
|
|
28
|
+
* `[data-theme]` and `[data-density]` blocks, and they declare custom properties
|
|
29
|
+
* and nothing else. A declaration that paints — a colour, a font, a margin on
|
|
30
|
+
* `body` — must never appear on a global selector: a library stylesheet does not
|
|
31
|
+
* get to restyle a page it was merely imported into. See
|
|
32
|
+
* `docs/decisions/0002-design-token-pipeline.md` and
|
|
33
|
+
* `docs/decisions/0004-global-density-modes.md`.
|
|
34
|
+
* - A component rule never matches on `data-theme` or `data-density`. Those are
|
|
35
|
+
* global modes resolved once in the token layer; a component reads the tokens.
|
|
30
36
|
* - No component rule may reach for `:root`, an element selector, or `*`.
|
|
31
37
|
* - Sizes use `rem` and unitless line heights, so the library respects the user's
|
|
32
38
|
* browser font size and keeps working under zoom. Border and outline widths are
|
|
@@ -245,6 +251,10 @@
|
|
|
245
251
|
--lutra-layout-gutter-tablet: 1.5rem;
|
|
246
252
|
--lutra-layout-section-lg: 6rem;
|
|
247
253
|
--lutra-layout-section-sm: 4rem;
|
|
254
|
+
|
|
255
|
+
/* Lutra Density */
|
|
256
|
+
--lutra-control-text-size: 1rem;
|
|
257
|
+
--lutra-field-spacing: 0.5rem;
|
|
248
258
|
}
|
|
249
259
|
|
|
250
260
|
/* Dark mode. Every token whose value differs in dark, plus every alias that reaches one — an
|
|
@@ -494,6 +504,27 @@
|
|
|
494
504
|
--lutra-component-tooltip-text: var(--lutra-color-background-canvas);
|
|
495
505
|
}
|
|
496
506
|
|
|
507
|
+
/* Condensed density. Every token whose value differs between the density modes, plus every
|
|
508
|
+
* alias that reaches one — an alias is substituted where it is declared, so it has to be
|
|
509
|
+
* re-declared here to resolve against condensed values rather than inheriting the spacious
|
|
510
|
+
* result from :root.
|
|
511
|
+
*
|
|
512
|
+
* Independent of theme by construction: this block declares no colour and the theme blocks
|
|
513
|
+
* declare no density value, so `data-theme` and `data-density` compose freely on the same
|
|
514
|
+
* element or on different ancestors. Density changes composition spacing and control text
|
|
515
|
+
* size only — never a control height or a target size. */
|
|
516
|
+
[data-density='condensed'] {
|
|
517
|
+
--lutra-control-text-size: 0.875rem;
|
|
518
|
+
--lutra-field-spacing: 0.25rem;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/* An explicit spacious region, so a spacious island inside a condensed one is possible. Same
|
|
522
|
+
* set as above, back at their spacious values. */
|
|
523
|
+
[data-density='spacious'] {
|
|
524
|
+
--lutra-control-text-size: 1rem;
|
|
525
|
+
--lutra-field-spacing: 0.5rem;
|
|
526
|
+
}
|
|
527
|
+
|
|
497
528
|
/*
|
|
498
529
|
* Button.
|
|
499
530
|
*
|
|
@@ -2185,10 +2216,16 @@
|
|
|
2185
2216
|
* focus and disabled are selected from the platform — `:hover`, `:focus-visible`,
|
|
2186
2217
|
* `:disabled` — never from a prop.
|
|
2187
2218
|
*
|
|
2219
|
+
* `data-overflow` is set by the component, not the consumer: it means part of the
|
|
2220
|
+
* value is scrolled out of sight. See the overflow earmark near the bottom of
|
|
2221
|
+
* this file, and `overflow.ts` for how the state is derived.
|
|
2222
|
+
*
|
|
2188
2223
|
* Colour comes from Figma's `component/input/*` alias layer, so the design system
|
|
2189
2224
|
* decides what an input surface, its text, its placeholder and each of its border
|
|
2190
|
-
* states are.
|
|
2191
|
-
*
|
|
2225
|
+
* states are. Geometry and spacing come from the core scales, and the text size
|
|
2226
|
+
* comes from the global density axis — `control/text-size`, inherited through
|
|
2227
|
+
* `data-density`, not a prop and not a function of `size`. The values with no
|
|
2228
|
+
* Figma variable behind them are marked.
|
|
2192
2229
|
*
|
|
2193
2230
|
* The error state is styled from `aria-invalid` rather than a `data-*` attribute
|
|
2194
2231
|
* of its own. There is then no way for the field to look wrong while reporting
|
|
@@ -2231,18 +2268,23 @@
|
|
|
2231
2268
|
--lutra-input-padding-inline-md: var(--lutra-space-md);
|
|
2232
2269
|
--lutra-input-padding-inline-lg: var(--lutra-space-lg);
|
|
2233
2270
|
|
|
2234
|
-
/*
|
|
2235
|
-
|
|
2236
|
-
|
|
2237
|
-
|
|
2238
|
-
|
|
2239
|
-
|
|
2240
|
-
*
|
|
2241
|
-
*
|
|
2242
|
-
|
|
2243
|
-
|
|
2244
|
-
|
|
2245
|
-
|
|
2271
|
+
/* Control text size, from the global density axis rather than from `size`.
|
|
2272
|
+
*
|
|
2273
|
+
* Figma binds `control/text-size` to the value text of all eighteen Input
|
|
2274
|
+
* variants, so `size` owns height and inline padding and density owns the
|
|
2275
|
+
* type: 16px in Spacious, 14px in Condensed, the same in sm, md and lg. It is
|
|
2276
|
+
* inherited from whatever `data-density` is in scope and is declared here, on
|
|
2277
|
+
* the control, so a nested `[data-density]` region resolves it correctly.
|
|
2278
|
+
* There is deliberately no density prop — see `docs/design-tokens.md`. */
|
|
2279
|
+
--lutra-input-font-size: var(--lutra-control-text-size);
|
|
2280
|
+
|
|
2281
|
+
/* One leading for one size. Figma draws a flat 120% here with no variable
|
|
2282
|
+
* behind it; the body ramp is used instead because `control/text-size`
|
|
2283
|
+
* resolves to `text/body` in the default mode, and inside a min-height control
|
|
2284
|
+
* the two are visually identical. Condensed keeps this ratio on 14px text,
|
|
2285
|
+
* which is looser than the body-small ramp and is what lets the line still
|
|
2286
|
+
* grow rather than crowd when the reader raises their font size. */
|
|
2287
|
+
--lutra-input-line-height: var(--lutra-type-line-height-body);
|
|
2246
2288
|
|
|
2247
2289
|
/* NO FIGMA VARIABLE. Figma draws inputs at a fixed 36/40/44px; these become
|
|
2248
2290
|
* `min-block-size` instead, because a fixed height on a text-bearing control
|
|
@@ -2255,6 +2297,41 @@
|
|
|
2255
2297
|
* vertical breathing room the text needs once the field is free to grow. */
|
|
2256
2298
|
--lutra-input-padding-block: var(--lutra-space-2xs);
|
|
2257
2299
|
|
|
2300
|
+
/* Trailing affordance reserve — an internal contract, not public API.
|
|
2301
|
+
*
|
|
2302
|
+
* Two additive slots, both `0px` unless something claims them, added to the
|
|
2303
|
+
* trailing inline padding by each size rule. Additive because they answer to
|
|
2304
|
+
* different owners and can apply at once, and CSS cannot let one custom
|
|
2305
|
+
* property add to itself:
|
|
2306
|
+
*
|
|
2307
|
+
* - `--lutra-input-trailing-reserve` is for a *composition* that puts a
|
|
2308
|
+
* control at the trailing edge — the Info trigger `Input with Tooltip`
|
|
2309
|
+
* needs, a password reveal, a clear button. Nothing claims it today. A
|
|
2310
|
+
* composition sets it on the input to the width it is occupying, and the
|
|
2311
|
+
* value keeps clear of whatever it placed there.
|
|
2312
|
+
* - `--lutra-input-overflow-reserve` belongs to this file and is claimed by
|
|
2313
|
+
* the `data-overflow` rule below. It is not the composition's to set.
|
|
2314
|
+
*
|
|
2315
|
+
* Neither is documented for consumers and neither is a prop. If a public
|
|
2316
|
+
* trailing-slot API is ever wanted, it is a decision record, not a rename. */
|
|
2317
|
+
--lutra-input-trailing-reserve: 0px;
|
|
2318
|
+
--lutra-input-overflow-reserve: 0px;
|
|
2319
|
+
|
|
2320
|
+
/* The overflow earmark.
|
|
2321
|
+
*
|
|
2322
|
+
* Size: Figma draws the earmark vector at a flat 8x8 in all eighteen variants
|
|
2323
|
+
* and binds no variable to it, so `space/sm` is the token that equals what is
|
|
2324
|
+
* drawn — the same treatment as the 36/40/44px control heights below.
|
|
2325
|
+
*
|
|
2326
|
+
* Colour: Figma binds the earmark's fill to the *border* token of the current
|
|
2327
|
+
* status — `component/input/border/default`, `/invalid`, and
|
|
2328
|
+
* `color/border/disabled`. Code currently draws it in `color/icon/default` in
|
|
2329
|
+
* every state instead. That is a real disagreement, not a tokenisation
|
|
2330
|
+
* difference, and it is open rather than settled: the earmark's treatment is
|
|
2331
|
+
* being revisited as its own design decision. See `contract.md` §14. */
|
|
2332
|
+
--lutra-input-overflow-mark-size: var(--lutra-space-sm);
|
|
2333
|
+
--lutra-input-overflow-mark: var(--lutra-color-icon-default);
|
|
2334
|
+
|
|
2258
2335
|
box-sizing: border-box;
|
|
2259
2336
|
inline-size: 100%;
|
|
2260
2337
|
max-inline-size: 100%;
|
|
@@ -2267,7 +2344,9 @@
|
|
|
2267
2344
|
background-color: var(--lutra-input-surface);
|
|
2268
2345
|
color: var(--lutra-input-text);
|
|
2269
2346
|
font-family: var(--lutra-input-font-family);
|
|
2347
|
+
font-size: var(--lutra-input-font-size);
|
|
2270
2348
|
font-weight: var(--lutra-input-font-weight);
|
|
2349
|
+
line-height: var(--lutra-input-line-height);
|
|
2271
2350
|
|
|
2272
2351
|
/* Strips the platform's own field chrome — notably iOS Safari's inner shadow
|
|
2273
2352
|
* and rounded corners — so the token values are what shows. */
|
|
@@ -2275,29 +2354,40 @@
|
|
|
2275
2354
|
}
|
|
2276
2355
|
|
|
2277
2356
|
/* Sizes.
|
|
2357
|
+
*
|
|
2358
|
+
* Geometry only — height and inline padding. Type comes from the density axis
|
|
2359
|
+
* above and is identical across the three, which is what Figma encodes.
|
|
2278
2360
|
*
|
|
2279
2361
|
* `min-block-size` rather than `height`: it reproduces the Figma height at the
|
|
2280
|
-
* default font size and lets the field grow when the reader raises theirs.
|
|
2362
|
+
* default font size and lets the field grow when the reader raises theirs. That
|
|
2363
|
+
* is also why Condensed cannot shrink a control: it lowers the text size inside
|
|
2364
|
+
* a minimum that density never touches. */
|
|
2281
2365
|
|
|
2282
2366
|
.lutra-input[data-size='sm'] {
|
|
2283
2367
|
min-block-size: var(--lutra-input-min-height-sm);
|
|
2284
|
-
padding-inline: var(--lutra-input-padding-inline-sm);
|
|
2285
|
-
|
|
2286
|
-
|
|
2368
|
+
padding-inline-start: var(--lutra-input-padding-inline-sm);
|
|
2369
|
+
padding-inline-end: calc(
|
|
2370
|
+
var(--lutra-input-padding-inline-sm) + var(--lutra-input-trailing-reserve) +
|
|
2371
|
+
var(--lutra-input-overflow-reserve)
|
|
2372
|
+
);
|
|
2287
2373
|
}
|
|
2288
2374
|
|
|
2289
2375
|
.lutra-input[data-size='md'] {
|
|
2290
2376
|
min-block-size: var(--lutra-input-min-height-md);
|
|
2291
|
-
padding-inline: var(--lutra-input-padding-inline-md);
|
|
2292
|
-
|
|
2293
|
-
|
|
2377
|
+
padding-inline-start: var(--lutra-input-padding-inline-md);
|
|
2378
|
+
padding-inline-end: calc(
|
|
2379
|
+
var(--lutra-input-padding-inline-md) + var(--lutra-input-trailing-reserve) +
|
|
2380
|
+
var(--lutra-input-overflow-reserve)
|
|
2381
|
+
);
|
|
2294
2382
|
}
|
|
2295
2383
|
|
|
2296
2384
|
.lutra-input[data-size='lg'] {
|
|
2297
2385
|
min-block-size: var(--lutra-input-min-height-lg);
|
|
2298
|
-
padding-inline: var(--lutra-input-padding-inline-lg);
|
|
2299
|
-
|
|
2300
|
-
|
|
2386
|
+
padding-inline-start: var(--lutra-input-padding-inline-lg);
|
|
2387
|
+
padding-inline-end: calc(
|
|
2388
|
+
var(--lutra-input-padding-inline-lg) + var(--lutra-input-trailing-reserve) +
|
|
2389
|
+
var(--lutra-input-overflow-reserve)
|
|
2390
|
+
);
|
|
2301
2391
|
}
|
|
2302
2392
|
|
|
2303
2393
|
/* Placeholder.
|
|
@@ -2384,6 +2474,94 @@
|
|
|
2384
2474
|
-webkit-text-fill-color: var(--lutra-input-disabled-text);
|
|
2385
2475
|
}
|
|
2386
2476
|
|
|
2477
|
+
/* The overflow earmark.
|
|
2478
|
+
*
|
|
2479
|
+
* A single-line input scrolls its value rather than wrapping, and gives no sign
|
|
2480
|
+
* that it is doing so — `…@an-unusually-long-` looks exactly like a value that
|
|
2481
|
+
* ends there. `data-overflow` is set by the component when part of the value is
|
|
2482
|
+
* outside the visible area, and this draws a small folded corner to say so.
|
|
2483
|
+
*
|
|
2484
|
+
* ## Why a background rather than an element
|
|
2485
|
+
*
|
|
2486
|
+
* An `<input>` is a replaced element and cannot render `::before` or `::after`,
|
|
2487
|
+
* so a mark inside the control is either a background or a sibling in a wrapper.
|
|
2488
|
+
* A background needs no wrapper, no extra node and no change to what Input
|
|
2489
|
+
* renders — and it is decorative by construction: there is nothing to give a
|
|
2490
|
+
* role, nothing to focus, nothing in the accessibility tree, and no way for it
|
|
2491
|
+
* to touch the accessible name or value. Select has a wrapper because its
|
|
2492
|
+
* chevron must be a token-coloured mask; this needs no mask.
|
|
2493
|
+
*
|
|
2494
|
+
* ## The shape
|
|
2495
|
+
*
|
|
2496
|
+
* `linear-gradient(to bottom left, … 50%, transparent 50%)` in a square placed
|
|
2497
|
+
* at the top right. The gradient axis runs toward the bottom-left corner, so the
|
|
2498
|
+
* hard stop at 50% is a line from the square's top-left to its bottom-right and
|
|
2499
|
+
* the filled half is the one nearest the top-right corner. That yields a
|
|
2500
|
+
* triangle whose two flat edges lie along the top and right edges of the field
|
|
2501
|
+
* with the diagonal facing inward, toward the content — a folded corner, not an
|
|
2502
|
+
* arrow pointing out of it.
|
|
2503
|
+
*
|
|
2504
|
+
* The size is `space/sm` (8px), which the field's own `radius/md` corner then
|
|
2505
|
+
* rounds off at the tip. That is deliberate: a mark flush with a rounded corner
|
|
2506
|
+
* has to follow it, and the result reads as a fold rather than a pasted-on
|
|
2507
|
+
* triangle.
|
|
2508
|
+
*
|
|
2509
|
+
* Figma draws the same triangle: an 8x8 `Overflow earmark` vector in all
|
|
2510
|
+
* eighteen variants, pinned to the top-right corner by MAX/MIN constraints,
|
|
2511
|
+
* whose path `M 0 0 L 8 0 L 8 8 L 0 0 Z` has its vertices at the box's
|
|
2512
|
+
* top-left, top-right and bottom-right. Flat top, flat right, diagonal facing
|
|
2513
|
+
* inward — the shape this gradient produces.
|
|
2514
|
+
*
|
|
2515
|
+
* ## Colour — currently disagreeing with Figma
|
|
2516
|
+
*
|
|
2517
|
+
* Figma binds the earmark's fill to the border token of the current status, so
|
|
2518
|
+
* it turns red on an invalid field. This draws it in `color/icon/default` in
|
|
2519
|
+
* every state instead, on the argument that it reports a fact about the viewport
|
|
2520
|
+
* rather than a problem with the value and must not read as a validation
|
|
2521
|
+
* indicator.
|
|
2522
|
+
*
|
|
2523
|
+
* That argument may lose. The earmark's treatment has been identified as too
|
|
2524
|
+
* subtle and is being revisited as its own design decision, and the state
|
|
2525
|
+
* colouring belongs to that decision rather than to this file. **Do not
|
|
2526
|
+
* reconcile it either way here** — `contract.md` §14 records it.
|
|
2527
|
+
*
|
|
2528
|
+
* ## Clearance
|
|
2529
|
+
*
|
|
2530
|
+
* The mark claims `--lutra-input-overflow-reserve`, which the size rules add to
|
|
2531
|
+
* the trailing padding, so the value's scroll area stops before the corner
|
|
2532
|
+
* instead of running under it. The reserve exists only while the mark does, so a
|
|
2533
|
+
* field that fits wastes nothing. Measuring against the reduced width is what
|
|
2534
|
+
* keeps that stable: once the mark is showing, the value has to shrink past the
|
|
2535
|
+
* reserve to lose it, so a value sitting on the boundary cannot flicker.
|
|
2536
|
+
*
|
|
2537
|
+
* Figma's own Value layers are set to `textTruncation: ENDING`, which reserves
|
|
2538
|
+
* no trailing space — its static text is already cut off with an ellipsis before
|
|
2539
|
+
* the corner. The runtime reserves the space because its text *scrolls*, so
|
|
2540
|
+
* without it the value would pass under the mark as the caret moved. See
|
|
2541
|
+
* `contract.md` §6.
|
|
2542
|
+
*
|
|
2543
|
+
* Forced-colours modes drop background images, so the mark is absent there. It
|
|
2544
|
+
* is a discoverability cue rather than information, the value is still reachable
|
|
2545
|
+
* by caret and by assistive technology, and painting a substitute would mean
|
|
2546
|
+
* inventing a border treatment the design system has not got. */
|
|
2547
|
+
|
|
2548
|
+
.lutra-input[data-overflow='true'] {
|
|
2549
|
+
--lutra-input-overflow-reserve: var(--lutra-input-overflow-mark-size);
|
|
2550
|
+
|
|
2551
|
+
background-image: linear-gradient(
|
|
2552
|
+
to bottom left,
|
|
2553
|
+
var(--lutra-input-overflow-mark) 50%,
|
|
2554
|
+
transparent 50%
|
|
2555
|
+
);
|
|
2556
|
+
background-repeat: no-repeat;
|
|
2557
|
+
background-position: top right;
|
|
2558
|
+
background-size: var(--lutra-input-overflow-mark-size) var(--lutra-input-overflow-mark-size);
|
|
2559
|
+
}
|
|
2560
|
+
|
|
2561
|
+
.lutra-input[data-overflow='true']:disabled {
|
|
2562
|
+
--lutra-input-overflow-mark: var(--lutra-input-disabled-text);
|
|
2563
|
+
}
|
|
2564
|
+
|
|
2387
2565
|
/* Read-only keeps the normal visual treatment. It stays focusable and its value
|
|
2388
2566
|
* stays selectable and copyable, which is the behaviour that distinguishes it
|
|
2389
2567
|
* from disabled, so it must not be dressed up to look unavailable. */
|
|
@@ -2498,13 +2676,14 @@
|
|
|
2498
2676
|
--lutra-select-padding-inline-md: var(--lutra-space-md);
|
|
2499
2677
|
--lutra-select-padding-inline-lg: var(--lutra-space-lg);
|
|
2500
2678
|
|
|
2501
|
-
|
|
2502
|
-
|
|
2503
|
-
|
|
2504
|
-
|
|
2505
|
-
|
|
2506
|
-
|
|
2507
|
-
--lutra-select-
|
|
2679
|
+
/* Control text size, from the global density axis rather than from `size` —
|
|
2680
|
+
* exactly as Input does it, and for the same reason: Figma binds
|
|
2681
|
+
* `control/text-size` to the value text of every Select variant, so the
|
|
2682
|
+
* placeholder and the selected value are 16px in Spacious and 14px in
|
|
2683
|
+
* Condensed at all three sizes. Declared here on the control so a nested
|
|
2684
|
+
* `[data-density]` region resolves it. There is no density prop. */
|
|
2685
|
+
--lutra-select-font-size: var(--lutra-control-text-size);
|
|
2686
|
+
--lutra-select-line-height: var(--lutra-type-line-height-body);
|
|
2508
2687
|
|
|
2509
2688
|
/* NO FIGMA VARIABLE. Figma draws 36/40/44px fixed heights; these become `min-block-size` so the
|
|
2510
2689
|
* control grows with the reader's font size instead of clipping at 200% zoom. */
|
|
@@ -2525,15 +2704,20 @@
|
|
|
2525
2704
|
background-color: var(--lutra-select-surface);
|
|
2526
2705
|
color: var(--lutra-select-text);
|
|
2527
2706
|
font-family: var(--lutra-select-font-family);
|
|
2707
|
+
font-size: var(--lutra-select-font-size);
|
|
2528
2708
|
font-weight: var(--lutra-select-font-weight);
|
|
2709
|
+
line-height: var(--lutra-select-line-height);
|
|
2529
2710
|
cursor: pointer;
|
|
2530
2711
|
|
|
2531
2712
|
/* Removes the platform's own dropdown arrow, so the Figma chevron is the only one drawn. */
|
|
2532
2713
|
appearance: none;
|
|
2533
2714
|
}
|
|
2534
2715
|
|
|
2535
|
-
/* Sizes.
|
|
2536
|
-
*
|
|
2716
|
+
/* Sizes. Geometry only — height, inline padding and the chevron reserve. Type comes from the density
|
|
2717
|
+
* axis and is the same across the three.
|
|
2718
|
+
*
|
|
2719
|
+
* `min-block-size` rather than `height`, so the control grows with the reader's font size. Trailing
|
|
2720
|
+
* padding reserves room for the chevron. */
|
|
2537
2721
|
|
|
2538
2722
|
.lutra-select[data-size='sm'] {
|
|
2539
2723
|
min-block-size: var(--lutra-select-min-height-sm);
|
|
@@ -2542,8 +2726,6 @@
|
|
|
2542
2726
|
var(--lutra-select-padding-inline-sm) + var(--lutra-select-chevron-size) +
|
|
2543
2727
|
var(--lutra-select-chevron-gap)
|
|
2544
2728
|
);
|
|
2545
|
-
font-size: var(--lutra-select-font-size-sm);
|
|
2546
|
-
line-height: var(--lutra-select-line-height-sm);
|
|
2547
2729
|
}
|
|
2548
2730
|
|
|
2549
2731
|
.lutra-select[data-size='md'] {
|
|
@@ -2553,8 +2735,6 @@
|
|
|
2553
2735
|
var(--lutra-select-padding-inline-md) + var(--lutra-select-chevron-size) +
|
|
2554
2736
|
var(--lutra-select-chevron-gap)
|
|
2555
2737
|
);
|
|
2556
|
-
font-size: var(--lutra-select-font-size-md);
|
|
2557
|
-
line-height: var(--lutra-select-line-height-md);
|
|
2558
2738
|
}
|
|
2559
2739
|
|
|
2560
2740
|
.lutra-select[data-size='lg'] {
|
|
@@ -2564,8 +2744,6 @@
|
|
|
2564
2744
|
var(--lutra-select-padding-inline-lg) + var(--lutra-select-chevron-size) +
|
|
2565
2745
|
var(--lutra-select-chevron-gap)
|
|
2566
2746
|
);
|
|
2567
|
-
font-size: var(--lutra-select-font-size-lg);
|
|
2568
|
-
line-height: var(--lutra-select-line-height-lg);
|
|
2569
2747
|
}
|
|
2570
2748
|
|
|
2571
2749
|
/* The chevron sits at the same inset as the control's own padding, per size. */
|
|
@@ -3177,18 +3355,28 @@
|
|
|
3177
3355
|
* These classes are shared: `Field` and `SelectField` render the same shell around different
|
|
3178
3356
|
* controls, so both use `.lutra-field*`. The control inside brings its own styling.
|
|
3179
3357
|
*
|
|
3180
|
-
* Order is label →
|
|
3358
|
+
* Order is label → control → hint → error, expressed as source order in a vertical stack rather
|
|
3181
3359
|
* than by any reordering property, so the visual order and the DOM order — which is the order a
|
|
3182
|
-
* screen reader and the Tab key follow — are the same thing.
|
|
3183
|
-
*
|
|
3184
|
-
*
|
|
3185
|
-
*
|
|
3186
|
-
*
|
|
3187
|
-
*
|
|
3360
|
+
* screen reader and the Tab key follow — are the same thing. Nothing here uses `order`,
|
|
3361
|
+
* `flex-direction: column-reverse` or `grid-row`, and nothing should: a field whose visual order
|
|
3362
|
+
* differed from its DOM order would read in one sequence and look like another.
|
|
3363
|
+
*
|
|
3364
|
+
* Figma binds the field's auto-layout `itemSpacing` and its supporting text's indent to
|
|
3365
|
+
* `field/spacing`, the global density variable — 8px in Spacious, 4px in Condensed. Both come from
|
|
3366
|
+
* the one token here, so the vertical rhythm and the indent cannot drift apart, and neither is a
|
|
3367
|
+
* component property: density is inherited from `data-density`. See `docs/design-tokens.md`.
|
|
3368
|
+
*
|
|
3369
|
+
* Figma draws one supporting slot, below the control, that becomes the error when invalid. Code
|
|
3370
|
+
* keeps hint and error as separate slots because both can be present at once, and renders them in
|
|
3371
|
+
* that order underneath the control. See `field.md`.
|
|
3188
3372
|
*/
|
|
3189
3373
|
|
|
3190
3374
|
.lutra-field {
|
|
3191
|
-
|
|
3375
|
+
/* Density-owned. Declared on the field rather than inherited pre-resolved from `:root`, so a
|
|
3376
|
+
* `[data-density]` region anywhere above it re-substitutes and takes effect. */
|
|
3377
|
+
--lutra-field-gap: var(--lutra-field-spacing);
|
|
3378
|
+
--lutra-field-support-indent: var(--lutra-field-spacing);
|
|
3379
|
+
|
|
3192
3380
|
--lutra-field-label-color: var(--lutra-color-text-primary);
|
|
3193
3381
|
--lutra-field-label-size: var(--lutra-text-body-small);
|
|
3194
3382
|
--lutra-field-label-line-height: var(--lutra-type-line-height-body-small);
|
|
@@ -3214,7 +3402,8 @@
|
|
|
3214
3402
|
|
|
3215
3403
|
/* Normal inline flow rather than flex, so the indicator wraps inline with the label text the way
|
|
3216
3404
|
* the annotation describes, and so the space between them is a real text space that the accessible
|
|
3217
|
-
* name includes. A flex `gap` would look right and read as "Email
|
|
3405
|
+
* name includes. A flex `gap` would look right and read as "Email address(required)". Figma builds
|
|
3406
|
+
* the same result with a `Label Row` frame set to WRAP; the DOM gets it from text flow. */
|
|
3218
3407
|
|
|
3219
3408
|
.lutra-field__label {
|
|
3220
3409
|
display: block;
|
|
@@ -3241,9 +3430,20 @@
|
|
|
3241
3430
|
color: var(--lutra-field-indicator-required-color);
|
|
3242
3431
|
}
|
|
3243
3432
|
|
|
3433
|
+
/* Supporting text.
|
|
3434
|
+
*
|
|
3435
|
+
* The indent sets the supporting and error text in from the label and the control edge, which is
|
|
3436
|
+
* what Figma draws. Figma expresses it as `paragraphIndent` because a Figma TEXT node has no
|
|
3437
|
+
* padding; `padding-inline-start` is the runtime equivalent and is deliberately not `text-indent`,
|
|
3438
|
+
* which would indent only the first line and leave a wrapped error message ragged against its own
|
|
3439
|
+
* left edge. Logical property, so it flips with the writing mode.
|
|
3440
|
+
*
|
|
3441
|
+
* Nothing here sets a height or prevents wrapping — a long message grows the field. */
|
|
3442
|
+
|
|
3244
3443
|
.lutra-field__hint,
|
|
3245
3444
|
.lutra-field__error {
|
|
3246
3445
|
margin: 0;
|
|
3446
|
+
padding-inline-start: var(--lutra-field-support-indent);
|
|
3247
3447
|
font-size: var(--lutra-field-label-size);
|
|
3248
3448
|
line-height: var(--lutra-field-label-line-height);
|
|
3249
3449
|
font-weight: var(--lutra-field-hint-weight);
|
package/dist/tokens.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"figma": {
|
|
4
4
|
"fileKey": "3I29aswJJqsIwh8jO1uBje",
|
|
5
5
|
"fileName": "Lutra UI",
|
|
6
|
-
"readAt": "2026-
|
|
6
|
+
"readAt": "2026-09-02 (Lutra Density collection added; Spacious is the default mode)"
|
|
7
7
|
},
|
|
8
8
|
"tokens": {
|
|
9
9
|
"--lutra-color-background-canvas": {
|
|
@@ -996,6 +996,20 @@
|
|
|
996
996
|
"figmaName": "layout/section/sm",
|
|
997
997
|
"value": "4rem",
|
|
998
998
|
"themed": false
|
|
999
|
+
},
|
|
1000
|
+
"--lutra-control-text-size": {
|
|
1001
|
+
"figmaName": "control/text-size",
|
|
1002
|
+
"spacious": "1rem",
|
|
1003
|
+
"condensed": "0.875rem",
|
|
1004
|
+
"themed": false,
|
|
1005
|
+
"density": true
|
|
1006
|
+
},
|
|
1007
|
+
"--lutra-field-spacing": {
|
|
1008
|
+
"figmaName": "field/spacing",
|
|
1009
|
+
"spacious": "0.5rem",
|
|
1010
|
+
"condensed": "0.25rem",
|
|
1011
|
+
"themed": false,
|
|
1012
|
+
"density": true
|
|
999
1013
|
}
|
|
1000
1014
|
}
|
|
1001
1015
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lutra-ui-system/react",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Accessible React components and design tokens for Lutra UI",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -54,7 +54,8 @@
|
|
|
54
54
|
"test:watch": "vitest",
|
|
55
55
|
"lint": "eslint .",
|
|
56
56
|
"storybook": "storybook dev --port 6006 --no-open",
|
|
57
|
-
"build-storybook": "storybook build"
|
|
57
|
+
"build-storybook": "storybook build",
|
|
58
|
+
"storybook:verify": "node ./scripts/verify-manager-bundle.mjs"
|
|
58
59
|
},
|
|
59
60
|
"peerDependencies": {
|
|
60
61
|
"react": "^18.3.0 || ^19.0.0",
|