@arbor-education/design-system.components 4.9.2 → 5.0.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/CHANGELOG.md +20 -0
- package/dist/components/datePicker/DatePicker.d.ts.map +1 -1
- package/dist/components/datePicker/DatePicker.js +2 -1
- package/dist/components/datePicker/DatePicker.js.map +1 -1
- package/dist/components/datePicker/DatePicker.test.js +32 -0
- package/dist/components/datePicker/DatePicker.test.js.map +1 -1
- package/dist/components/datePicker/dateInputUtils.d.ts +2 -1
- package/dist/components/datePicker/dateInputUtils.d.ts.map +1 -1
- package/dist/components/datePicker/dateInputUtils.js +20 -0
- package/dist/components/datePicker/dateInputUtils.js.map +1 -1
- package/dist/components/datePicker/dateInputUtils.test.d.ts +2 -0
- package/dist/components/datePicker/dateInputUtils.test.d.ts.map +1 -0
- package/dist/components/datePicker/dateInputUtils.test.js +28 -0
- package/dist/components/datePicker/dateInputUtils.test.js.map +1 -0
- package/dist/components/dateTimePicker/DateTimePicker.d.ts.map +1 -1
- package/dist/components/dateTimePicker/DateTimePicker.js +34 -10
- package/dist/components/dateTimePicker/DateTimePicker.js.map +1 -1
- package/dist/components/dateTimePicker/DateTimePicker.stories.d.ts.map +1 -1
- package/dist/components/dateTimePicker/DateTimePicker.stories.js +5 -0
- package/dist/components/dateTimePicker/DateTimePicker.stories.js.map +1 -1
- package/dist/components/dateTimePicker/DateTimePicker.test.js +136 -0
- package/dist/components/dateTimePicker/DateTimePicker.test.js.map +1 -1
- package/dist/components/formField/FormField.stories.d.ts.map +1 -1
- package/dist/components/formField/FormField.stories.js +3 -4
- package/dist/components/formField/FormField.stories.js.map +1 -1
- package/dist/components/formField/FormField.test.js +1 -1
- package/dist/components/formField/FormField.test.js.map +1 -1
- package/dist/components/formField/inputs/textArea/TextArea.d.ts +4 -1
- package/dist/components/formField/inputs/textArea/TextArea.d.ts.map +1 -1
- package/dist/components/formField/inputs/textArea/TextArea.js +33 -14
- package/dist/components/formField/inputs/textArea/TextArea.js.map +1 -1
- package/dist/components/formField/inputs/textArea/TextArea.stories.d.ts +6 -1
- package/dist/components/formField/inputs/textArea/TextArea.stories.d.ts.map +1 -1
- package/dist/components/formField/inputs/textArea/TextArea.stories.js +33 -13
- package/dist/components/formField/inputs/textArea/TextArea.stories.js.map +1 -1
- package/dist/components/formField/inputs/textArea/TextArea.test.d.ts.map +1 -1
- package/dist/components/formField/inputs/textArea/TextArea.test.js +76 -2
- package/dist/components/formField/inputs/textArea/TextArea.test.js.map +1 -1
- package/dist/components/formField/inputs/time/TimeInput.d.ts.map +1 -1
- package/dist/components/formField/inputs/time/TimeInput.js +24 -8
- package/dist/components/formField/inputs/time/TimeInput.js.map +1 -1
- package/dist/components/formField/inputs/time/TimeInput.stories.d.ts +0 -1
- package/dist/components/formField/inputs/time/TimeInput.stories.d.ts.map +1 -1
- package/dist/components/formField/inputs/time/TimeInput.stories.js +38 -67
- package/dist/components/formField/inputs/time/TimeInput.stories.js.map +1 -1
- package/dist/components/formField/inputs/time/TimeInput.test.js +51 -6
- package/dist/components/formField/inputs/time/TimeInput.test.js.map +1 -1
- package/dist/components/kvpList/KVPList.d.ts +2 -1
- package/dist/components/kvpList/KVPList.d.ts.map +1 -1
- package/dist/components/kvpList/KVPList.js +1 -1
- package/dist/components/kvpList/KVPList.js.map +1 -1
- package/dist/components/kvpList/KVPList.stories.d.ts +2 -1
- package/dist/components/kvpList/KVPList.stories.d.ts.map +1 -1
- package/dist/components/kvpList/KVPList.stories.js +35 -0
- package/dist/components/kvpList/KVPList.stories.js.map +1 -1
- package/dist/components/kvpList/KVPList.test.js +16 -0
- package/dist/components/kvpList/KVPList.test.js.map +1 -1
- package/dist/components/row/Row.d.ts +1 -0
- package/dist/components/row/Row.d.ts.map +1 -1
- package/dist/components/row/Row.js +3 -2
- package/dist/components/row/Row.js.map +1 -1
- package/dist/components/row/Row.stories.d.ts +13 -0
- package/dist/components/row/Row.stories.d.ts.map +1 -1
- package/dist/components/row/Row.stories.js +53 -0
- package/dist/components/row/Row.stories.js.map +1 -1
- package/dist/components/row/Row.test.js +40 -0
- package/dist/components/row/Row.test.js.map +1 -1
- package/dist/index.css +10 -5
- package/dist/index.css.map +1 -1
- package/dist/utils/hooks/useIsControlledOnceSet.d.ts +6 -0
- package/dist/utils/hooks/useIsControlledOnceSet.d.ts.map +1 -0
- package/dist/utils/hooks/useIsControlledOnceSet.js +13 -0
- package/dist/utils/hooks/useIsControlledOnceSet.js.map +1 -0
- package/dist/utils/hooks/useIsControlledOnceSet.test.d.ts +2 -0
- package/dist/utils/hooks/useIsControlledOnceSet.test.d.ts.map +1 -0
- package/dist/utils/hooks/useIsControlledOnceSet.test.js +25 -0
- package/dist/utils/hooks/useIsControlledOnceSet.test.js.map +1 -0
- package/package.json +1 -1
- package/src/components/banner/banner.scss +4 -0
- package/src/components/datePicker/DatePicker.test.tsx +48 -0
- package/src/components/datePicker/DatePicker.tsx +2 -1
- package/src/components/datePicker/dateInputUtils.test.ts +29 -0
- package/src/components/datePicker/dateInputUtils.ts +24 -0
- package/src/components/dateTimePicker/DateTimePicker.stories.tsx +5 -0
- package/src/components/dateTimePicker/DateTimePicker.test.tsx +186 -0
- package/src/components/dateTimePicker/DateTimePicker.tsx +38 -8
- package/src/components/formField/FormField.stories.tsx +3 -4
- package/src/components/formField/FormField.test.tsx +1 -1
- package/src/components/formField/inputs/textArea/TextArea.stories.tsx +61 -13
- package/src/components/formField/inputs/textArea/TextArea.test.tsx +94 -2
- package/src/components/formField/inputs/textArea/TextArea.tsx +39 -13
- package/src/components/formField/inputs/time/TimeInput.stories.tsx +38 -87
- package/src/components/formField/inputs/time/TimeInput.test.tsx +75 -6
- package/src/components/formField/inputs/time/TimeInput.tsx +29 -10
- package/src/components/formField/inputs/time/timeInput.scss +0 -6
- package/src/components/kvpList/KVPList.stories.tsx +54 -0
- package/src/components/kvpList/KVPList.test.tsx +52 -0
- package/src/components/kvpList/KVPList.tsx +10 -1
- package/src/components/kvpList/kvpList.scss +4 -0
- package/src/components/row/Row.stories.tsx +63 -0
- package/src/components/row/Row.test.tsx +59 -0
- package/src/components/row/Row.tsx +4 -0
- package/src/components/row/row.scss +5 -2
- package/src/utils/hooks/useIsControlledOnceSet.test.ts +44 -0
- package/src/utils/hooks/useIsControlledOnceSet.ts +13 -0
|
@@ -20,10 +20,11 @@ const DESCRIPTION_INTRO = [
|
|
|
20
20
|
'TimeInput is a flexible time-entry field that operates in **two distinct modes** depending on',
|
|
21
21
|
'whether the `options` prop is supplied.',
|
|
22
22
|
'',
|
|
23
|
-
'**Native mode** (no `options`) renders a
|
|
24
|
-
'
|
|
25
|
-
'
|
|
26
|
-
'
|
|
23
|
+
'**Native mode** (no `options`) renders a plain text field decorated with a clock icon. Typed input is',
|
|
24
|
+
'limited to digits and colons, so no browser time picker appears and every browser shows the same',
|
|
25
|
+
'single control. Like the other input components, it does not validate the value: range checks and',
|
|
26
|
+
'error messages belong to the consuming code, which presents them through `hasError` and `aria-invalid`.',
|
|
27
|
+
'This mode supports `granularity`, `className`, a forwarded `ref`, and `onChange`.',
|
|
27
28
|
'',
|
|
28
29
|
'**Combobox mode** (`options` provided) replaces the native input with a searchable Combobox.',
|
|
29
30
|
'The user can type to filter or click to select from a predefined list of time values. This mode',
|
|
@@ -37,8 +38,8 @@ const DESCRIPTION_INTRO = [
|
|
|
37
38
|
const USAGE_GUIDANCE = [
|
|
38
39
|
'### When to use',
|
|
39
40
|
'',
|
|
40
|
-
'- **
|
|
41
|
-
'
|
|
41
|
+
'- **Free-text time entry** — use native mode (the default). Validate and normalise the typed text in',
|
|
42
|
+
' your own code, and present errors with `hasError` and `aria-invalid`.',
|
|
42
43
|
'- **Fixed set of allowed times** — use combobox mode with `options`. Ideal for lesson periods,',
|
|
43
44
|
' appointment slots, or any scenario where only specific times are valid.',
|
|
44
45
|
'- **Inside a form** — always wrap in `<FormField inputType="time">` so label, error text, and',
|
|
@@ -52,7 +53,7 @@ const USAGE_GUIDANCE = [
|
|
|
52
53
|
'|---|---|',
|
|
53
54
|
'| Date + time combined | Separate `DateInput` and `TimeInput` fields |',
|
|
54
55
|
'| Free-form duration entry | `TextInput` with format hint |',
|
|
55
|
-
'| More than ~50 time options | Native mode
|
|
56
|
+
'| More than ~50 time options | Native mode, validating the range in your own code |',
|
|
56
57
|
'| Status or category selection | `SelectDropdown` with meaningful labels |',
|
|
57
58
|
].join('\n');
|
|
58
59
|
|
|
@@ -64,44 +65,46 @@ const DEVELOPER_NOTES = [
|
|
|
64
65
|
'',
|
|
65
66
|
'| Prop | Native mode | Combobox mode |',
|
|
66
67
|
'|---|---|---|',
|
|
67
|
-
'| `granularity` | Sets `
|
|
68
|
-
'| `min` / `max` |
|
|
68
|
+
'| `granularity` | Sets the placeholder to `HH:mm` or `HH:mm:ss` | **Ignored** |',
|
|
69
|
+
'| `min` / `max` / `step` | Passed to the `<input>` but a text field ignores them. Enforce ranges in your own code | **Ignored** — filter `options` array instead |',
|
|
69
70
|
'| `className` | Applied to the wrapper `<div>` | **Ignored** |',
|
|
70
|
-
'| `ref` (forwarded) | Attached to the `<input>`
|
|
71
|
+
'| `ref` (forwarded) | Attached to the visible text `<input>` | **Ignored** |',
|
|
71
72
|
'| `onChange` | Native `ChangeEvent<HTMLInputElement>` | **Ignored** — use `onValueChange` |',
|
|
72
73
|
'| `searchType` | **Ignored** | Controls `prefix`/`substring` matching |',
|
|
73
74
|
'| `highlightStringMatches` | **Ignored** | Bolds matched characters in dropdown |',
|
|
74
75
|
'| `onValueChange` | Fires with the string value | Fires with the string value |',
|
|
75
76
|
'',
|
|
76
|
-
'
|
|
77
|
-
'
|
|
77
|
+
'### Validation (native mode)',
|
|
78
|
+
'',
|
|
79
|
+
'TimeInput only filters characters: keystrokes and pasted text are limited to digits and colons, so',
|
|
80
|
+
'`abc` and `9.05` never reach the field. It does not parse, normalise or validate the result, so `25:00`',
|
|
81
|
+
'is emitted as typed. The component never sets `aria-invalid`, so range validation is the consumer\'s job:',
|
|
78
82
|
'',
|
|
79
83
|
'```tsx',
|
|
80
|
-
'// Standalone —
|
|
81
|
-
'<TimeInput hasError aria-invalid={
|
|
84
|
+
'// Standalone — validate in your code and present the result:',
|
|
85
|
+
'<TimeInput hasError={!isValid} aria-invalid={!isValid} aria-label="Start time" value={text} onValueChange={setText} />',
|
|
82
86
|
'',
|
|
83
|
-
'// Inside FormField —
|
|
84
|
-
'<FormField label="Start time" id="start" inputType="time" errorText="
|
|
87
|
+
'// Inside FormField — pass errorText and FormField sets hasError and aria-invalid:',
|
|
88
|
+
'<FormField label="Start time" id="start" inputType="time" errorText="Enter a valid time." inputProps={{}} />',
|
|
85
89
|
'```',
|
|
86
90
|
'',
|
|
87
|
-
'**`onValueChange` is the preferred callback** — it works in both modes and receives the
|
|
88
|
-
'
|
|
89
|
-
'mode and is not forwarded in combobox mode.',
|
|
91
|
+
'**`onValueChange` is the preferred callback** — it works in both modes and receives the string value.',
|
|
92
|
+
'`onChange` is only available in native mode and is not forwarded in combobox mode.',
|
|
90
93
|
'',
|
|
91
|
-
'**`TimeValue` is format-shaped
|
|
92
|
-
'
|
|
93
|
-
'time for `<input type="time">`, but the component itself does no validation.',
|
|
94
|
+
'**`TimeValue` is format-shaped.** The TypeScript type `` `${string}:${string}` `` accepts any string',
|
|
95
|
+
'containing a colon, so it does not guarantee a valid time.',
|
|
94
96
|
'',
|
|
95
97
|
'---',
|
|
96
98
|
'',
|
|
97
99
|
'### Accessibility',
|
|
98
100
|
'',
|
|
99
101
|
'- Always provide a visible label via `<FormField>` or `aria-label` / `aria-labelledby`.',
|
|
100
|
-
'- In native mode
|
|
101
|
-
'
|
|
102
|
+
'- In native mode the field is a text input, so screen readers announce the text exactly as typed.',
|
|
103
|
+
' Errors are announced only when the consumer sets `aria-invalid`. The clock button is a labelled',
|
|
104
|
+
' button ("Select time") that moves focus to the field and selects the hours.',
|
|
102
105
|
'- In combobox mode, the Combobox component implements the ARIA combobox pattern',
|
|
103
106
|
' (`role="combobox"`, `aria-expanded`, `aria-activedescendant`).',
|
|
104
|
-
'- The clock icon is decorative
|
|
107
|
+
'- The clock icon is decorative; the clock button is labelled "Select time".',
|
|
105
108
|
'- Pair `hasError` with `aria-invalid={true}` when used standalone — one is visual, the other',
|
|
106
109
|
' is for screen readers. `<FormField>` handles this automatically when `errorText` is set.',
|
|
107
110
|
'',
|
|
@@ -171,7 +174,7 @@ const meta = {
|
|
|
171
174
|
argTypes: {
|
|
172
175
|
'value': {
|
|
173
176
|
description: [
|
|
174
|
-
'Controlled value
|
|
177
|
+
'Controlled text value, limited to digits and colons. Pass `HH:MM` or `HH:MM:SS` for a time.',
|
|
175
178
|
'Pair with `onValueChange` to keep state in sync.',
|
|
176
179
|
'If `undefined`, the component manages its own state (uncontrolled).',
|
|
177
180
|
].join(' '),
|
|
@@ -194,7 +197,8 @@ const meta = {
|
|
|
194
197
|
},
|
|
195
198
|
'onValueChange': {
|
|
196
199
|
description: [
|
|
197
|
-
'Callback fired with the plain string value
|
|
200
|
+
'Callback fired with the plain string value whenever the committed value changes.',
|
|
201
|
+
'In native mode it is the digits and colons entered, with no parsing or range validation.',
|
|
198
202
|
'Works in **both** native and combobox modes.',
|
|
199
203
|
'Prefer this over the native `onChange` event for cross-mode compatibility.',
|
|
200
204
|
].join(' '),
|
|
@@ -207,8 +211,8 @@ const meta = {
|
|
|
207
211
|
'options': {
|
|
208
212
|
description: [
|
|
209
213
|
'Array of `TimeValue` strings to display as selectable options.',
|
|
210
|
-
'Providing this prop switches to **combobox mode** — the
|
|
211
|
-
'replaced by a searchable Combobox. Omit to
|
|
214
|
+
'Providing this prop switches to **combobox mode** — the text field is',
|
|
215
|
+
'replaced by a searchable Combobox. Omit to type a time into the text field.',
|
|
212
216
|
].join(' '),
|
|
213
217
|
control: 'object',
|
|
214
218
|
table: {
|
|
@@ -219,7 +223,7 @@ const meta = {
|
|
|
219
223
|
'granularity': {
|
|
220
224
|
description: [
|
|
221
225
|
'**Native mode only.** Controls the precision of the time input.',
|
|
222
|
-
'`"second"` sets
|
|
226
|
+
'`"second"` sets the placeholder to `HH:mm:ss`.',
|
|
223
227
|
'Silently ignored when `options` is provided.',
|
|
224
228
|
].join(' '),
|
|
225
229
|
control: { type: 'select' },
|
|
@@ -257,7 +261,7 @@ const meta = {
|
|
|
257
261
|
'hasError': {
|
|
258
262
|
description: [
|
|
259
263
|
'Applies error-state visual styling (red border).',
|
|
260
|
-
'Does **not** set `aria-invalid`
|
|
264
|
+
'Does **not** set `aria-invalid` for errors you supply when used standalone outside FormField.',
|
|
261
265
|
'Always pair with `aria-invalid={true}` for screen-reader coverage.',
|
|
262
266
|
].join(' '),
|
|
263
267
|
control: 'boolean',
|
|
@@ -292,7 +296,7 @@ const meta = {
|
|
|
292
296
|
},
|
|
293
297
|
},
|
|
294
298
|
'placeholder': {
|
|
295
|
-
description: 'Placeholder text shown
|
|
299
|
+
description: 'Placeholder text shown while the field is empty, e.g. `HH:mm`. It disappears once a value is entered.',
|
|
296
300
|
control: 'text',
|
|
297
301
|
table: {
|
|
298
302
|
type: { summary: 'string' },
|
|
@@ -311,8 +315,7 @@ const meta = {
|
|
|
311
315
|
'aria-invalid': {
|
|
312
316
|
description: [
|
|
313
317
|
'Marks the input as invalid for screen readers.',
|
|
314
|
-
'
|
|
315
|
-
'when `errorText` is provided.',
|
|
318
|
+
'Never set by TimeInput. Set it when validating the text yourself, or let FormField set it when `errorText` is provided.',
|
|
316
319
|
].join(' '),
|
|
317
320
|
control: 'boolean',
|
|
318
321
|
table: {
|
|
@@ -409,26 +412,6 @@ const GranularitySecondTemplate = () => {
|
|
|
409
412
|
);
|
|
410
413
|
};
|
|
411
414
|
|
|
412
|
-
const WithMinMaxBoundsTemplate = () => {
|
|
413
|
-
const [value, setValue] = useState<TimeValue | ''>('');
|
|
414
|
-
return (
|
|
415
|
-
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-small)', maxWidth: '280px' }}>
|
|
416
|
-
<TimeInput
|
|
417
|
-
min="08:00"
|
|
418
|
-
max="17:00"
|
|
419
|
-
value={value}
|
|
420
|
-
onValueChange={v => setValue(v as TimeValue | '')}
|
|
421
|
-
id="bounded-time"
|
|
422
|
-
name="school-time"
|
|
423
|
-
aria-label="School hours time"
|
|
424
|
-
/>
|
|
425
|
-
<p style={{ margin: 0, color: 'var(--color-grey-600)' }}>
|
|
426
|
-
Restricted to school hours: 08:00 – 17:00
|
|
427
|
-
</p>
|
|
428
|
-
</div>
|
|
429
|
-
);
|
|
430
|
-
};
|
|
431
|
-
|
|
432
415
|
const WithOptionsTemplate = () => {
|
|
433
416
|
const [value, setValue] = useState<TimeValue | ''>('');
|
|
434
417
|
return (
|
|
@@ -697,39 +680,7 @@ export default PreciseTimeInputExample;
|
|
|
697
680
|
},
|
|
698
681
|
render: () => <GranularitySecondTemplate />,
|
|
699
682
|
},
|
|
700
|
-
'Setting `granularity="second"`
|
|
701
|
-
);
|
|
702
|
-
|
|
703
|
-
export const WithMinMaxBounds: Story = withDescription(
|
|
704
|
-
{
|
|
705
|
-
parameters: {
|
|
706
|
-
controls: { disable: true },
|
|
707
|
-
docs: {
|
|
708
|
-
source: {
|
|
709
|
-
language: 'tsx',
|
|
710
|
-
code: `
|
|
711
|
-
import { TimeInput, type TimeValue } from '@arbor-education/design-system.components';
|
|
712
|
-
|
|
713
|
-
function BoundedTimeInputExample() {
|
|
714
|
-
return (
|
|
715
|
-
<TimeInput
|
|
716
|
-
min="08:00"
|
|
717
|
-
max="17:00"
|
|
718
|
-
id="school-time"
|
|
719
|
-
name="school-time"
|
|
720
|
-
aria-label="School hours time"
|
|
721
|
-
/>
|
|
722
|
-
);
|
|
723
|
-
}
|
|
724
|
-
|
|
725
|
-
export default BoundedTimeInputExample;
|
|
726
|
-
`.trim(),
|
|
727
|
-
},
|
|
728
|
-
},
|
|
729
|
-
},
|
|
730
|
-
render: () => <WithMinMaxBoundsTemplate />,
|
|
731
|
-
},
|
|
732
|
-
'The `min` and `max` props restrict the selectable range in the native browser picker to school hours. **Native mode only** — in combobox mode, restrict available times by filtering the `options` array instead.',
|
|
683
|
+
'Setting `granularity="second"` sets the placeholder to `HH:mm:ss`. Seconds are typed by the user, and only digits and colons are accepted. **Native mode only** — silently ignored when `options` is provided.',
|
|
733
684
|
);
|
|
734
685
|
|
|
735
686
|
export const WithError: Story = withDescription(
|
|
@@ -19,17 +19,22 @@ describe('TimeInput', () => {
|
|
|
19
19
|
vi.restoreAllMocks();
|
|
20
20
|
});
|
|
21
21
|
|
|
22
|
-
test('renders a
|
|
22
|
+
test('renders a text input so no browser picker is drawn', () => {
|
|
23
23
|
render(<TimeInput aria-label="Start time" />);
|
|
24
|
-
const input = screen.getByLabelText('Start time');
|
|
25
24
|
|
|
26
|
-
expect(
|
|
27
|
-
|
|
25
|
+
expect(screen.getByLabelText('Start time')).toHaveAttribute('type', 'text');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('shows HH:mm as the placeholder by default', () => {
|
|
29
|
+
render(<TimeInput aria-label="Start time" />);
|
|
30
|
+
|
|
31
|
+
expect(screen.getByLabelText('Start time')).toHaveAttribute('placeholder', 'HH:mm');
|
|
28
32
|
});
|
|
29
33
|
|
|
30
|
-
test('
|
|
34
|
+
test('shows HH:mm:ss as the placeholder when granularity is second', () => {
|
|
31
35
|
render(<TimeInput aria-label="Start time" granularity="second" />);
|
|
32
|
-
|
|
36
|
+
|
|
37
|
+
expect(screen.getByLabelText('Start time')).toHaveAttribute('placeholder', 'HH:mm:ss');
|
|
33
38
|
});
|
|
34
39
|
|
|
35
40
|
test('native mode emits the string value when changed', () => {
|
|
@@ -43,6 +48,70 @@ describe('TimeInput', () => {
|
|
|
43
48
|
expect(onValueChange).toHaveBeenCalledWith('14:30');
|
|
44
49
|
});
|
|
45
50
|
|
|
51
|
+
test('blocks keystrokes other than digits and colons', async () => {
|
|
52
|
+
const user = userEvent.setup();
|
|
53
|
+
const onValueChange = vi.fn();
|
|
54
|
+
render(<TimeInput aria-label="Start time" onValueChange={onValueChange} />);
|
|
55
|
+
|
|
56
|
+
await user.type(screen.getByLabelText('Start time'), '9.7x:a');
|
|
57
|
+
|
|
58
|
+
expect(onValueChange).toHaveBeenLastCalledWith('97:');
|
|
59
|
+
expect(screen.getByLabelText('Start time')).toHaveValue('97:');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('strips non-time characters from pasted text', async () => {
|
|
63
|
+
const user = userEvent.setup();
|
|
64
|
+
const onValueChange = vi.fn();
|
|
65
|
+
render(<TimeInput aria-label="Start time" onValueChange={onValueChange} />);
|
|
66
|
+
const input = screen.getByLabelText('Start time');
|
|
67
|
+
|
|
68
|
+
await user.click(input);
|
|
69
|
+
await user.paste('abc14:30');
|
|
70
|
+
|
|
71
|
+
expect(onValueChange).toHaveBeenLastCalledWith('14:30');
|
|
72
|
+
expect(input).toHaveValue('14:30');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('a typed colon is accepted', async () => {
|
|
76
|
+
const user = userEvent.setup();
|
|
77
|
+
render(<TimeInput aria-label="Start time" />);
|
|
78
|
+
|
|
79
|
+
await user.type(screen.getByLabelText('Start time'), '14:30');
|
|
80
|
+
|
|
81
|
+
expect(screen.getByLabelText('Start time')).toHaveValue('14:30');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('does not set aria-invalid itself, so validation stays with the consumer', async () => {
|
|
85
|
+
const user = userEvent.setup();
|
|
86
|
+
render(<TimeInput aria-label="Start time" />);
|
|
87
|
+
const input = screen.getByLabelText('Start time');
|
|
88
|
+
|
|
89
|
+
await user.type(input, 'abc');
|
|
90
|
+
|
|
91
|
+
expect(input).not.toHaveAttribute('aria-invalid');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('applies error styling from hasError and reflects aria-invalid passed by the consumer', () => {
|
|
95
|
+
render(<TimeInput aria-label="Start time" hasError aria-invalid />);
|
|
96
|
+
const input = screen.getByLabelText('Start time');
|
|
97
|
+
|
|
98
|
+
expect(input).toHaveClass('ds-input--error');
|
|
99
|
+
expect(input).toHaveAttribute('aria-invalid', 'true');
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('submits the typed text under the field name', async () => {
|
|
103
|
+
const user = userEvent.setup();
|
|
104
|
+
render(
|
|
105
|
+
<form aria-label="Schedule">
|
|
106
|
+
<TimeInput aria-label="Start time" name="start" />
|
|
107
|
+
</form>,
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
await user.type(screen.getByLabelText('Start time'), '9:05');
|
|
111
|
+
|
|
112
|
+
expect(new FormData(screen.getByRole<HTMLFormElement>('form')).get('start')).toBe('9:05');
|
|
113
|
+
});
|
|
114
|
+
|
|
46
115
|
test('clicking the clock icon focuses the input and selects the hour segment when supported', async () => {
|
|
47
116
|
const user = userEvent.setup();
|
|
48
117
|
const setSelectionRange = vi.spyOn(HTMLInputElement.prototype, 'setSelectionRange').mockImplementation(() => {});
|
|
@@ -2,7 +2,7 @@ import classNames from 'classnames';
|
|
|
2
2
|
import { Combobox } from 'Components/combobox/Combobox';
|
|
3
3
|
import type { ComboboxOption, ComboboxSearchType } from 'Components/combobox/types';
|
|
4
4
|
import { Icon } from 'Components/icon/Icon';
|
|
5
|
-
import { forwardRef, useCallback, useMemo, useRef, useState, type ChangeEvent, type InputHTMLAttributes } from 'react';
|
|
5
|
+
import { forwardRef, useCallback, useMemo, useRef, useState, type ChangeEvent, type InputHTMLAttributes, type KeyboardEvent } from 'react';
|
|
6
6
|
|
|
7
7
|
/** Expected string `HH:MM` or `HH:MM:SS`; format-shaped versus strict time validation. */
|
|
8
8
|
export type TimeValue = `${string}:${string}` | `${string}:${string}:${string}`;
|
|
@@ -22,6 +22,14 @@ export type TimeInputProps = {
|
|
|
22
22
|
|
|
23
23
|
const clockIcon = <Icon name="clock-3" size={16} />;
|
|
24
24
|
|
|
25
|
+
const TIME_CHARACTER_PATTERN = /^[0-9:]$/;
|
|
26
|
+
const NON_TIME_CHARACTERS_PATTERN = /[^0-9:]/g;
|
|
27
|
+
|
|
28
|
+
const NATIVE_PLACEHOLDER_BY_GRANULARITY: Record<TimeGranularity, string> = {
|
|
29
|
+
minute: 'HH:mm',
|
|
30
|
+
second: 'HH:mm:ss',
|
|
31
|
+
};
|
|
32
|
+
|
|
25
33
|
/** The forwarded ref is attached in native mode but when `options` is provided, no input ref is exposed. */
|
|
26
34
|
export const TimeInput = forwardRef<HTMLInputElement, TimeInputProps>((props, ref) => {
|
|
27
35
|
const {
|
|
@@ -32,6 +40,7 @@ export const TimeInput = forwardRef<HTMLInputElement, TimeInputProps>((props, re
|
|
|
32
40
|
searchType = 'prefix',
|
|
33
41
|
highlightStringMatches = false,
|
|
34
42
|
onChange,
|
|
43
|
+
onKeyDown,
|
|
35
44
|
className,
|
|
36
45
|
disabled = false,
|
|
37
46
|
id,
|
|
@@ -76,8 +85,22 @@ export const TimeInput = forwardRef<HTMLInputElement, TimeInputProps>((props, re
|
|
|
76
85
|
} as ChangeEvent<HTMLInputElement>);
|
|
77
86
|
}, [isControlled, onChange, onValueChange]);
|
|
78
87
|
|
|
88
|
+
const handleNativeKeyDown = useCallback((event: KeyboardEvent<HTMLInputElement>) => {
|
|
89
|
+
const isShortcut = event.ctrlKey || event.metaKey || event.altKey;
|
|
90
|
+
const isPrintable = event.key.length === 1;
|
|
91
|
+
if (isPrintable && !isShortcut && !TIME_CHARACTER_PATTERN.test(event.key)) {
|
|
92
|
+
event.preventDefault();
|
|
93
|
+
}
|
|
94
|
+
onKeyDown?.(event);
|
|
95
|
+
}, [onKeyDown]);
|
|
96
|
+
|
|
79
97
|
const handleNativeChange = useCallback((event: ChangeEvent<HTMLInputElement>) => {
|
|
80
|
-
|
|
98
|
+
const { value } = event.currentTarget;
|
|
99
|
+
const timeCharacters = value.replace(NON_TIME_CHARACTERS_PATTERN, '');
|
|
100
|
+
if (timeCharacters !== value) {
|
|
101
|
+
event.currentTarget.value = timeCharacters;
|
|
102
|
+
}
|
|
103
|
+
updateValue(timeCharacters, event);
|
|
81
104
|
}, [updateValue]);
|
|
82
105
|
|
|
83
106
|
const focusAndSelectHours = useCallback(() => {
|
|
@@ -87,11 +110,7 @@ export const TimeInput = forwardRef<HTMLInputElement, TimeInputProps>((props, re
|
|
|
87
110
|
}
|
|
88
111
|
|
|
89
112
|
input.focus();
|
|
90
|
-
|
|
91
|
-
try {
|
|
92
|
-
input.setSelectionRange(0, 2);
|
|
93
|
-
}
|
|
94
|
-
catch { /* Some browsers do not expose selection APIs on time inputs. */ }
|
|
113
|
+
input.setSelectionRange(0, 2);
|
|
95
114
|
}, []);
|
|
96
115
|
|
|
97
116
|
const timeOptions = useMemo<ComboboxOption[]>(
|
|
@@ -139,18 +158,18 @@ export const TimeInput = forwardRef<HTMLInputElement, TimeInputProps>((props, re
|
|
|
139
158
|
ref={setRefs}
|
|
140
159
|
id={id}
|
|
141
160
|
name={name}
|
|
142
|
-
type="
|
|
161
|
+
type="text"
|
|
143
162
|
value={currentValue}
|
|
144
|
-
step={granularity === 'second' ? 1 : 60}
|
|
145
163
|
disabled={disabled}
|
|
146
164
|
aria-describedby={ariaDescribedBy}
|
|
147
165
|
aria-invalid={ariaInvalid}
|
|
148
166
|
aria-label={ariaLabel}
|
|
149
|
-
placeholder={placeholder}
|
|
167
|
+
placeholder={placeholder ?? NATIVE_PLACEHOLDER_BY_GRANULARITY[granularity]}
|
|
150
168
|
className={classNames('ds-input', 'ds-input--M', 'ds-time-input__input', {
|
|
151
169
|
'ds-input--error': hasError,
|
|
152
170
|
})}
|
|
153
171
|
onChange={handleNativeChange}
|
|
172
|
+
onKeyDown={handleNativeKeyDown}
|
|
154
173
|
/>
|
|
155
174
|
<button
|
|
156
175
|
type="button"
|
|
@@ -4,13 +4,7 @@
|
|
|
4
4
|
}
|
|
5
5
|
|
|
6
6
|
.ds-time-input__input {
|
|
7
|
-
appearance: none;
|
|
8
7
|
padding-right: calc(var(--form-field-text-medium-height) + var(--spacing-small));
|
|
9
|
-
|
|
10
|
-
&::-webkit-calendar-picker-indicator {
|
|
11
|
-
display: none;
|
|
12
|
-
appearance: none;
|
|
13
|
-
}
|
|
14
8
|
}
|
|
15
9
|
|
|
16
10
|
.ds-time-input__icon-button {
|
|
@@ -33,6 +33,7 @@ const TERM_PROPS = [
|
|
|
33
33
|
'| Prop | Type | Default | Description |',
|
|
34
34
|
'|---|---|---|---|',
|
|
35
35
|
'| `children` | `ReactNode` | — | The key/label text |',
|
|
36
|
+
'| `prominence` | `\'neutral\' \\| \'strong\'` | `\'neutral\'` | `neutral` = body text; `strong` = bold body weight, e.g. a totals row label |',
|
|
36
37
|
'| `className` | `string` | — | Additional CSS classes on the `<dt>` element |',
|
|
37
38
|
'| `...HTMLAttributes<dt>` | | | All standard element attributes forwarded |',
|
|
38
39
|
].join('\n');
|
|
@@ -372,6 +373,59 @@ export default StrongProminenceExample;
|
|
|
372
373
|
'`prominence="strong"` applies h2-scale typography to the definition — rendered as `<dd>` semantically but visually dominant. Use for the primary metric value in a list; `"neutral"` for supporting context.',
|
|
373
374
|
);
|
|
374
375
|
|
|
376
|
+
export const TotalsRow: Story = withDescription(
|
|
377
|
+
{
|
|
378
|
+
parameters: {
|
|
379
|
+
controls: { disable: true },
|
|
380
|
+
docs: {
|
|
381
|
+
source: {
|
|
382
|
+
language: 'tsx',
|
|
383
|
+
code: `
|
|
384
|
+
import { KVPList } from '@arbor-education/design-system.components';
|
|
385
|
+
|
|
386
|
+
function TotalsRowExample() {
|
|
387
|
+
return (
|
|
388
|
+
<KVPList aria-label="Basket totals">
|
|
389
|
+
<KVPList.Row orientation="horizontal">
|
|
390
|
+
<KVPList.Term>Subtotal</KVPList.Term>
|
|
391
|
+
<KVPList.Definition>£40.00</KVPList.Definition>
|
|
392
|
+
</KVPList.Row>
|
|
393
|
+
<KVPList.Row orientation="horizontal">
|
|
394
|
+
<KVPList.Term>Delivery</KVPList.Term>
|
|
395
|
+
<KVPList.Definition>£2.00</KVPList.Definition>
|
|
396
|
+
</KVPList.Row>
|
|
397
|
+
<KVPList.Row orientation="horizontal">
|
|
398
|
+
<KVPList.Term prominence="strong">Total</KVPList.Term>
|
|
399
|
+
<KVPList.Definition prominence="strong">£42.00</KVPList.Definition>
|
|
400
|
+
</KVPList.Row>
|
|
401
|
+
</KVPList>
|
|
402
|
+
);
|
|
403
|
+
}
|
|
404
|
+
export default TotalsRowExample;
|
|
405
|
+
`.trim(),
|
|
406
|
+
},
|
|
407
|
+
},
|
|
408
|
+
},
|
|
409
|
+
render: () => (
|
|
410
|
+
<KVPList aria-label="Basket totals">
|
|
411
|
+
<KVPList.Row orientation="horizontal">
|
|
412
|
+
<KVPList.Term>Subtotal</KVPList.Term>
|
|
413
|
+
<KVPList.Definition>£40.00</KVPList.Definition>
|
|
414
|
+
</KVPList.Row>
|
|
415
|
+
<KVPList.Row orientation="horizontal">
|
|
416
|
+
<KVPList.Term>Delivery</KVPList.Term>
|
|
417
|
+
<KVPList.Definition>£2.00</KVPList.Definition>
|
|
418
|
+
</KVPList.Row>
|
|
419
|
+
<KVPList.Row orientation="horizontal">
|
|
420
|
+
<KVPList.Term prominence="strong">Total</KVPList.Term>
|
|
421
|
+
<KVPList.Definition prominence="strong">£42.00</KVPList.Definition>
|
|
422
|
+
</KVPList.Row>
|
|
423
|
+
</KVPList>
|
|
424
|
+
),
|
|
425
|
+
},
|
|
426
|
+
'`prominence="strong"` on `KVPList.Term` applies the bold body weight token (`--type-body-bold-weight`) so the label of a totals row balances a strong definition. Unemphasised terms are unchanged.',
|
|
427
|
+
);
|
|
428
|
+
|
|
375
429
|
export const IsRow: Story = withDescription(
|
|
376
430
|
{
|
|
377
431
|
parameters: {
|
|
@@ -51,6 +51,58 @@ describe('KVPList', () => {
|
|
|
51
51
|
expect(container.querySelector('.ds-kvp-list__description--row')).toBeInTheDocument();
|
|
52
52
|
});
|
|
53
53
|
|
|
54
|
+
test('applies the strong modifier to a term with strong prominence', () => {
|
|
55
|
+
render(
|
|
56
|
+
<KVPList>
|
|
57
|
+
<KVPList.Row orientation="horizontal">
|
|
58
|
+
<KVPList.Term prominence="strong">Total</KVPList.Term>
|
|
59
|
+
<KVPList.Definition prominence="strong">£42.00</KVPList.Definition>
|
|
60
|
+
</KVPList.Row>
|
|
61
|
+
</KVPList>,
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
expect(screen.getByText('Total')).toHaveClass('ds-kvp-list__term', 'ds-kvp-list__term--strong');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('does not forward prominence to the term element', () => {
|
|
68
|
+
render(
|
|
69
|
+
<KVPList>
|
|
70
|
+
<KVPList.Row orientation="horizontal">
|
|
71
|
+
<KVPList.Term prominence="strong">Total</KVPList.Term>
|
|
72
|
+
<KVPList.Definition prominence="strong">£42.00</KVPList.Definition>
|
|
73
|
+
</KVPList.Row>
|
|
74
|
+
</KVPList>,
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
expect(screen.getByText('Total')).not.toHaveAttribute('prominence');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('leaves a term without prominence unchanged', () => {
|
|
81
|
+
render(
|
|
82
|
+
<KVPList>
|
|
83
|
+
<KVPList.Row orientation="horizontal">
|
|
84
|
+
<KVPList.Term className="custom-term">Subtotal</KVPList.Term>
|
|
85
|
+
<KVPList.Definition>£40.00</KVPList.Definition>
|
|
86
|
+
</KVPList.Row>
|
|
87
|
+
</KVPList>,
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
expect(screen.getByText('Subtotal')).toHaveAttribute('class', 'ds-kvp-list__term custom-term');
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('leaves a term with neutral prominence unchanged', () => {
|
|
94
|
+
render(
|
|
95
|
+
<KVPList>
|
|
96
|
+
<KVPList.Row orientation="horizontal">
|
|
97
|
+
<KVPList.Term prominence="neutral">Delivery</KVPList.Term>
|
|
98
|
+
<KVPList.Definition>£2.00</KVPList.Definition>
|
|
99
|
+
</KVPList.Row>
|
|
100
|
+
</KVPList>,
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
expect(screen.getByText('Delivery')).toHaveAttribute('class', 'ds-kvp-list__term');
|
|
104
|
+
});
|
|
105
|
+
|
|
54
106
|
test('does not add group semantics when no accessible label is provided', () => {
|
|
55
107
|
render(
|
|
56
108
|
<KVPList>
|
|
@@ -11,6 +11,7 @@ export type KVPListRowProps = React.HTMLAttributes<HTMLDListElement> & {
|
|
|
11
11
|
|
|
12
12
|
export type KVPListTermProps = React.HTMLAttributes<HTMLElement> & {
|
|
13
13
|
children?: React.ReactNode;
|
|
14
|
+
prominence?: 'neutral' | 'strong';
|
|
14
15
|
};
|
|
15
16
|
|
|
16
17
|
export type KVPListDefinitionProps = React.HTMLAttributes<HTMLElement> & {
|
|
@@ -60,9 +61,17 @@ const KVPListRow = ({
|
|
|
60
61
|
const KVPListTerm = ({
|
|
61
62
|
children,
|
|
62
63
|
className,
|
|
64
|
+
prominence = 'neutral',
|
|
63
65
|
...rest
|
|
64
66
|
}: KVPListTermProps): React.JSX.Element => (
|
|
65
|
-
<dt
|
|
67
|
+
<dt
|
|
68
|
+
className={classNames(
|
|
69
|
+
'ds-kvp-list__term',
|
|
70
|
+
{ 'ds-kvp-list__term--strong': prominence === 'strong' },
|
|
71
|
+
className,
|
|
72
|
+
)}
|
|
73
|
+
{...rest}
|
|
74
|
+
>
|
|
66
75
|
{children}
|
|
67
76
|
</dt>
|
|
68
77
|
);
|
|
@@ -21,6 +21,10 @@
|
|
|
21
21
|
line-height: var(--type-body-line-height);
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
.ds-kvp-list__term--strong {
|
|
25
|
+
font-weight: var(--type-body-bold-weight);
|
|
26
|
+
}
|
|
27
|
+
|
|
24
28
|
.ds-kvp-list__description--strong {
|
|
25
29
|
color: var(--kvp-list-color-text-strong);
|
|
26
30
|
font-family: var(--type-headings-h2-family);
|