@astryxdesign/core 0.7.3-canary.f94e962 → 0.7.3
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 +88 -0
- package/dist/BottomSheet/BottomSheet.d.ts.map +1 -1
- package/dist/BottomSheet/BottomSheet.js +8 -4
- package/dist/BottomSheet/BottomSheetSwitcher.d.ts.map +1 -1
- package/dist/BottomSheet/BottomSheetSwitcher.js +7 -0
- package/dist/Button/Button.d.ts.map +1 -1
- package/dist/Button/Button.js +15 -2
- package/dist/Calendar/Calendar.js +3 -2
- package/dist/Chat/ChatComposer.d.ts.map +1 -1
- package/dist/Chat/ChatComposer.js +25 -2
- package/dist/Chat/ChatSystemMessage.d.ts +4 -0
- package/dist/Chat/ChatSystemMessage.d.ts.map +1 -1
- package/dist/Chat/ChatSystemMessage.js +4 -2
- package/dist/CodeBlock/CodeBlock.d.ts.map +1 -1
- package/dist/CodeBlock/CodeBlock.js +11 -1
- package/dist/DateRangeInput/DateRangeInput.d.ts +5 -5
- package/dist/DateRangeInput/DateRangeInput.d.ts.map +1 -1
- package/dist/DateRangeInput/DateRangeInput.js +27 -18
- package/dist/DateTimeInput/DateTimeInput.d.ts +5 -5
- package/dist/DateTimeInput/DateTimeInput.d.ts.map +1 -1
- package/dist/DateTimeInput/DateTimeInput.js +25 -27
- package/dist/DateTimeInput/NativeDateSegment.d.ts +3 -2
- package/dist/DateTimeInput/NativeDateSegment.d.ts.map +1 -1
- package/dist/DateTimeInput/NativeDateSegment.js +5 -5
- package/dist/DateTimeInput/NativeTimeSegment.d.ts +3 -2
- package/dist/DateTimeInput/NativeTimeSegment.d.ts.map +1 -1
- package/dist/DateTimeInput/NativeTimeSegment.js +5 -5
- package/dist/DateTimeInput/TouchDateTimeField.d.ts.map +1 -1
- package/dist/DateTimeInput/TouchDateTimeField.js +12 -9
- package/dist/Dialog/Dialog.d.ts.map +1 -1
- package/dist/Dialog/Dialog.js +9 -4
- package/dist/DropdownMenu/DropdownMenuRadioGroup.d.ts +9 -5
- package/dist/DropdownMenu/DropdownMenuRadioGroup.d.ts.map +1 -1
- package/dist/DropdownMenu/DropdownMenuRadioGroup.js +2 -0
- package/dist/Markdown/Markdown.d.ts.map +1 -1
- package/dist/Markdown/Markdown.js +48 -15
- package/dist/MultiSelector/MultiSelector.d.ts +4 -4
- package/dist/MultiSelector/MultiSelector.d.ts.map +1 -1
- package/dist/MultiSelector/MultiSelector.disabledFocus.a11y.chromium.spec.d.ts +2 -0
- package/dist/MultiSelector/MultiSelector.disabledFocus.a11y.chromium.spec.d.ts.map +1 -0
- package/dist/MultiSelector/MultiSelector.disabledFocus.a11y.chromium.spec.js +48 -0
- package/dist/MultiSelector/MultiSelector.js +25 -18
- package/dist/Pagination/Pagination.d.ts.map +1 -1
- package/dist/Pagination/Pagination.js +5 -2
- package/dist/Switch/Switch.d.ts.map +1 -1
- package/dist/Switch/Switch.js +13 -3
- package/dist/TimeInput/TimeInput.d.ts +5 -5
- package/dist/TimeInput/TimeInput.d.ts.map +1 -1
- package/dist/TimeInput/TimeInput.js +37 -20
- package/dist/TimeInput/TouchTimeField.d.ts.map +1 -1
- package/dist/TimeInput/TouchTimeField.js +17 -6
- package/dist/Timestamp/Timestamp.d.ts.map +1 -1
- package/dist/Timestamp/Timestamp.js +41 -8
- package/dist/TreeList/TreeListItem.d.ts.map +1 -1
- package/dist/TreeList/TreeListItem.js +8 -3
- package/dist/hooks/useGridFocus.d.ts.map +1 -1
- package/dist/hooks/useGridFocus.js +10 -1
- package/package.json +4 -4
- package/scripts/agent-doc-state.mjs +24 -2
- package/src/BottomSheet/BottomSheet.spec.md +26 -53
- package/src/BottomSheet/BottomSheet.test.tsx +29 -0
- package/src/BottomSheet/BottomSheet.tsx +8 -4
- package/src/BottomSheet/BottomSheetSwitcher.spec.md +45 -48
- package/src/BottomSheet/BottomSheetSwitcher.test.tsx +26 -0
- package/src/BottomSheet/BottomSheetSwitcher.tsx +6 -0
- package/src/Button/Button.test.tsx +13 -2
- package/src/Button/Button.tsx +23 -2
- package/src/Button/__tests__/Button.a11y.states.ts +4 -3
- package/src/Calendar/Calendar.test.tsx +68 -24
- package/src/Calendar/Calendar.tsx +2 -1
- package/src/Carousel/Carousel.spec.md +146 -0
- package/src/Chat/ChatComposer.spec.md +26 -26
- package/src/Chat/ChatComposer.test.tsx +58 -2
- package/src/Chat/ChatComposer.tsx +25 -1
- package/src/Chat/ChatSystemMessage.doc.mjs +1 -1
- package/src/Chat/ChatSystemMessage.test.tsx +25 -3
- package/src/Chat/ChatSystemMessage.tsx +6 -4
- package/src/Chat/__tests__/ChatSystemMessage.a11y.chromium.spec.ts +8 -9
- package/src/CheckboxList/__tests__/CheckboxList.a11y.chromium.spec.ts +7 -6
- package/src/CodeBlock/CodeBlock.highlightStyles.test.tsx +87 -0
- package/src/CodeBlock/CodeBlock.tsx +11 -1
- package/src/DateRangeInput/DateRangeInput.doc.mjs +6 -5
- package/src/DateRangeInput/DateRangeInput.test.tsx +82 -16
- package/src/DateRangeInput/DateRangeInput.tsx +40 -20
- package/src/DateTimeInput/DateTimeInput.doc.mjs +9 -8
- package/src/DateTimeInput/DateTimeInput.test.tsx +126 -11
- package/src/DateTimeInput/DateTimeInput.tsx +42 -26
- package/src/DateTimeInput/DateTimeInputTouch.test.tsx +24 -0
- package/src/DateTimeInput/NativeDateSegment.tsx +9 -6
- package/src/DateTimeInput/NativePickerSegments.test.tsx +31 -0
- package/src/DateTimeInput/NativeTimeSegment.tsx +9 -6
- package/src/DateTimeInput/TouchDateTimeField.tsx +22 -7
- package/src/Dialog/Dialog.test.tsx +39 -0
- package/src/Dialog/Dialog.tsx +9 -4
- package/src/DropdownMenu/DropdownMenuItem.doc.mjs +66 -7
- package/src/DropdownMenu/DropdownMenuItem.spec.md +209 -0
- package/src/DropdownMenu/DropdownMenuRadioGroup.doc.mjs +62 -7
- package/src/DropdownMenu/DropdownMenuRadioGroup.spec.md +190 -0
- package/src/DropdownMenu/DropdownMenuRadioGroup.tsx +10 -4
- package/src/DropdownMenu/DropdownMenuSelectable.test.tsx +93 -0
- package/src/DropdownMenu/__tests__/DropdownMenuItemAudit.a11y.chromium.spec.ts +366 -0
- package/src/DropdownMenu/__tests__/DropdownMenuRadioGroupAudit.a11y.chromium.spec.ts +327 -0
- package/src/FieldStatus/FieldStatus.spec.md +4 -4
- package/src/FieldStatus/__tests__/StatusMessage.a11y.renders.tsx +0 -18
- package/src/FieldStatus/__tests__/StatusMessage.a11y.states.ts +3 -18
- package/src/FieldStatus/__tests__/StatusMessage.a11y.test.tsx +1 -2
- package/src/Markdown/Markdown.helpers.perf.test.ts +25 -19
- package/src/Markdown/Markdown.rerender.test.tsx +143 -0
- package/src/Markdown/Markdown.tsx +89 -35
- package/src/MultiSelector/MultiSelector.disabledFocus.a11y.chromium.spec.ts +71 -0
- package/src/MultiSelector/MultiSelector.doc.mjs +5 -5
- package/src/MultiSelector/MultiSelector.focusableDisabled.test.tsx +121 -0
- package/src/MultiSelector/MultiSelector.test.tsx +49 -7
- package/src/MultiSelector/MultiSelector.tsx +53 -29
- package/src/Pagination/Pagination.test.tsx +5 -1
- package/src/Pagination/Pagination.tsx +3 -1
- package/src/Switch/Switch.test.tsx +56 -0
- package/src/Switch/Switch.tsx +13 -2
- package/src/Table/plugins/columnResize/useTableColumnResize.spec.md +11 -11
- package/src/Table/plugins/rowStatus/useTableRowStatus.spec.md +4 -3
- package/src/TimeInput/NativeTimeInput.test.tsx +25 -1
- package/src/TimeInput/Presentation.test.tsx +23 -0
- package/src/TimeInput/TimeInput.doc.mjs +8 -7
- package/src/TimeInput/TimeInput.test.tsx +119 -7
- package/src/TimeInput/TimeInput.tsx +51 -23
- package/src/TimeInput/TouchTimeField.tsx +27 -5
- package/src/Timestamp/Timestamp.doc.mjs +1 -1
- package/src/Timestamp/Timestamp.test.tsx +168 -0
- package/src/Timestamp/Timestamp.tsx +62 -11
- package/src/TreeList/TreeList.test.tsx +28 -0
- package/src/TreeList/TreeListItem.tsx +6 -2
- package/src/__tests__/DisabledTriggerFocus.a11y.chromium.spec.ts +86 -0
- package/src/hooks/useGridFocus.ts +16 -1
|
@@ -71,7 +71,7 @@ export const docs = {
|
|
|
71
71
|
{
|
|
72
72
|
guidance: false,
|
|
73
73
|
description:
|
|
74
|
-
'Wrap a disabled TimeInput in Tooltip to explain why it is disabled
|
|
74
|
+
'Wrap a disabled TimeInput in Tooltip to explain why it is disabled. Use the disabledMessage prop instead, which ties the reason to the field.',
|
|
75
75
|
},
|
|
76
76
|
],
|
|
77
77
|
anatomy: [
|
|
@@ -144,14 +144,15 @@ export const docs = {
|
|
|
144
144
|
{
|
|
145
145
|
name: 'isDisabled',
|
|
146
146
|
type: 'boolean',
|
|
147
|
-
description:
|
|
147
|
+
description:
|
|
148
|
+
'Disables the input and suppresses interactions. The input stays focusable and exposes `aria-disabled`.',
|
|
148
149
|
default: 'false',
|
|
149
150
|
},
|
|
150
151
|
{
|
|
151
152
|
name: 'disabledMessage',
|
|
152
153
|
type: 'string',
|
|
153
154
|
description:
|
|
154
|
-
'Explains why the input is disabled. With isDisabled, shows a tooltip on hover/keyboard focus and
|
|
155
|
+
'Explains why the input is disabled. With isDisabled, shows a tooltip on hover/keyboard focus and describes the input (activation stays blocked). Use this instead of wrapping a disabled TimeInput in Tooltip.',
|
|
155
156
|
},
|
|
156
157
|
{
|
|
157
158
|
name: 'value',
|
|
@@ -329,14 +330,14 @@ export const docsZh = {
|
|
|
329
330
|
{
|
|
330
331
|
name: 'isDisabled',
|
|
331
332
|
type: 'boolean',
|
|
332
|
-
description: '
|
|
333
|
+
description: '禁用输入框并抑制交互。输入框仍可聚焦并暴露 `aria-disabled`。',
|
|
333
334
|
default: 'false',
|
|
334
335
|
},
|
|
335
336
|
{
|
|
336
337
|
name: 'disabledMessage',
|
|
337
338
|
type: 'string',
|
|
338
339
|
description:
|
|
339
|
-
'说明输入框为何被禁用。与 isDisabled
|
|
340
|
+
'说明输入框为何被禁用。与 isDisabled 一起使用时,在悬停/键盘聚焦时显示提示,并描述输入框(仍阻止输入和调整)。请使用此属性,而不是用 Tooltip 包裹已禁用的 TimeInput。',
|
|
340
341
|
},
|
|
341
342
|
{
|
|
342
343
|
name: 'value',
|
|
@@ -510,7 +511,7 @@ export const docsZh = {
|
|
|
510
511
|
{
|
|
511
512
|
guidance: false,
|
|
512
513
|
description:
|
|
513
|
-
'Wrap a disabled TimeInput in Tooltip to explain why it is disabled
|
|
514
|
+
'Wrap a disabled TimeInput in Tooltip to explain why it is disabled. Use the disabledMessage prop instead, which ties the reason to the field.',
|
|
514
515
|
},
|
|
515
516
|
],
|
|
516
517
|
anatomy: [
|
|
@@ -612,7 +613,7 @@ export const docsDense = {
|
|
|
612
613
|
'Marks required+sets aria-required. Mutually exclusive w/ isOptional.',
|
|
613
614
|
isDisabled: 'Disables input, suppresses interactions.',
|
|
614
615
|
disabledMessage:
|
|
615
|
-
'Reason shown in a tooltip on hover/focus when disabled;
|
|
616
|
+
'Reason shown in a tooltip on hover/focus when disabled; describes the input.',
|
|
616
617
|
value: 'Controlled time in ISO format (HH:MM or HH:MM:SS).',
|
|
617
618
|
onChange: 'Fired on time change. Receives undefined when cleared.',
|
|
618
619
|
changeAction:
|
|
@@ -10,8 +10,10 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import {describe, it, expect, vi, beforeEach, afterEach} from 'vitest';
|
|
13
|
-
import {render, screen, fireEvent, waitFor} from '@testing-library/react';
|
|
13
|
+
import {act, render, screen, fireEvent, waitFor} from '@testing-library/react';
|
|
14
14
|
import userEvent from '@testing-library/user-event';
|
|
15
|
+
import * as stylex from '@stylexjs/stylex';
|
|
16
|
+
import {colorVars} from '../theme/tokens.stylex';
|
|
15
17
|
import {TimeInput} from './TimeInput';
|
|
16
18
|
import {InputGroup, InputGroupText} from '../InputGroup';
|
|
17
19
|
import {InternationalizationProvider} from '../i18n';
|
|
@@ -141,9 +143,41 @@ describe('TimeInput', () => {
|
|
|
141
143
|
);
|
|
142
144
|
});
|
|
143
145
|
|
|
144
|
-
it('
|
|
146
|
+
it('keeps a disabled input focusable without native disabled (AST-085 FR1)', () => {
|
|
145
147
|
render(<TimeInput label="Time" isDisabled onChange={() => {}} />);
|
|
146
|
-
|
|
148
|
+
const input = screen.getByRole('textbox');
|
|
149
|
+
expect(input).not.toHaveAttribute('disabled');
|
|
150
|
+
expect(input).toHaveAttribute('aria-disabled', 'true');
|
|
151
|
+
expect(input).toHaveAttribute('readonly');
|
|
152
|
+
input.focus();
|
|
153
|
+
expect(input).toHaveFocus();
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('restores the focus ring on a focused disabled field (AST-085 FR6)', () => {
|
|
157
|
+
// The same declarations as the field's ring style compile to the same
|
|
158
|
+
// atomic classes; the shared disabled style alone drops them.
|
|
159
|
+
const ringClasses = stylex
|
|
160
|
+
.props(
|
|
161
|
+
stylex.create({
|
|
162
|
+
ring: {
|
|
163
|
+
borderColor: {
|
|
164
|
+
default: colorVars['--color-border-emphasized'],
|
|
165
|
+
':focus-within': colorVars['--color-accent'],
|
|
166
|
+
},
|
|
167
|
+
boxShadow: {
|
|
168
|
+
default: 'none',
|
|
169
|
+
':focus-within': `inset 0px 0px 0px 2px ${colorVars['--color-accent-muted']}`,
|
|
170
|
+
},
|
|
171
|
+
},
|
|
172
|
+
}).ring,
|
|
173
|
+
)
|
|
174
|
+
.className!.split(' ')
|
|
175
|
+
.filter(name => !name.includes('__'));
|
|
176
|
+
render(<TimeInput label="Time" isDisabled onChange={() => {}} />);
|
|
177
|
+
const wrapper = screen.getByRole('textbox').closest('.astryx-time-input')!;
|
|
178
|
+
for (const name of ringClasses) {
|
|
179
|
+
expect(wrapper).toHaveClass(name);
|
|
180
|
+
}
|
|
147
181
|
});
|
|
148
182
|
|
|
149
183
|
it('shows clear button when hasClear is true and value exists', () => {
|
|
@@ -645,11 +679,89 @@ describe('TimeInput', () => {
|
|
|
645
679
|
expect(onChange).not.toHaveBeenCalled();
|
|
646
680
|
});
|
|
647
681
|
|
|
648
|
-
it('
|
|
649
|
-
|
|
682
|
+
it('blocks typing, arrows and a caller onClick when disabled without a reason (AST-085 FR2)', async () => {
|
|
683
|
+
const user = userEvent.setup();
|
|
684
|
+
const onChange = vi.fn();
|
|
685
|
+
const onClick = vi.fn();
|
|
686
|
+
render(
|
|
687
|
+
<TimeInput
|
|
688
|
+
label="Time"
|
|
689
|
+
value={'09:00' as ISOTimeString}
|
|
690
|
+
onChange={onChange}
|
|
691
|
+
onClick={onClick}
|
|
692
|
+
isDisabled
|
|
693
|
+
/>,
|
|
694
|
+
);
|
|
695
|
+
const input = screen.getByLabelText('Time');
|
|
696
|
+
await user.click(input);
|
|
697
|
+
await user.type(input, '1');
|
|
698
|
+
await user.keyboard('{ArrowUp}');
|
|
699
|
+
await user.tab();
|
|
700
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
701
|
+
expect(onClick).not.toHaveBeenCalled();
|
|
702
|
+
});
|
|
703
|
+
|
|
704
|
+
it.each([
|
|
705
|
+
['text-input', false, {isDisabled: true}],
|
|
706
|
+
['native', true, {isDisabled: true}],
|
|
707
|
+
['bottom-sheet', true, {isDisabled: true}],
|
|
708
|
+
['bottom-sheet', true, {isLoading: true}],
|
|
709
|
+
] as const)(
|
|
710
|
+
'does not submit a surrounding form on Enter: %s (coarse %s) %o (AST-085 FR2)',
|
|
711
|
+
async (presentation, isCoarse, state) => {
|
|
712
|
+
vi.stubGlobal('matchMedia', (query: string) => ({
|
|
713
|
+
matches: /pointer:\s*coarse/.test(query)
|
|
714
|
+
? isCoarse
|
|
715
|
+
: /pointer:\s*fine/.test(query) && !isCoarse,
|
|
716
|
+
media: query,
|
|
717
|
+
addEventListener: () => {},
|
|
718
|
+
removeEventListener: () => {},
|
|
719
|
+
addListener: () => {},
|
|
720
|
+
removeListener: () => {},
|
|
721
|
+
}));
|
|
722
|
+
const user = userEvent.setup();
|
|
723
|
+
const onSubmit = vi.fn((e: React.FormEvent) => e.preventDefault());
|
|
724
|
+
render(
|
|
725
|
+
<form onSubmit={onSubmit}>
|
|
726
|
+
<TimeInput
|
|
727
|
+
label="Time"
|
|
728
|
+
presentation={presentation}
|
|
729
|
+
value={'09:00' as ISOTimeString}
|
|
730
|
+
onChange={() => {}}
|
|
731
|
+
{...state}
|
|
732
|
+
/>
|
|
733
|
+
<button type="submit">Save</button>
|
|
734
|
+
</form>,
|
|
735
|
+
);
|
|
736
|
+
try {
|
|
737
|
+
const input = document.querySelector<HTMLInputElement>(
|
|
738
|
+
'.astryx-time-input input:not([type=hidden])',
|
|
739
|
+
)!;
|
|
740
|
+
act(() => input.focus());
|
|
741
|
+
await user.keyboard('{Enter}');
|
|
742
|
+
expect(onSubmit).not.toHaveBeenCalled();
|
|
743
|
+
} finally {
|
|
744
|
+
vi.unstubAllGlobals();
|
|
745
|
+
}
|
|
746
|
+
},
|
|
747
|
+
);
|
|
748
|
+
|
|
749
|
+
it('drops a draft instead of committing it when the field becomes disabled (AST-085 FR2)', async () => {
|
|
750
|
+
const user = userEvent.setup();
|
|
751
|
+
const onChange = vi.fn();
|
|
752
|
+
const value = '09:00' as ISOTimeString;
|
|
753
|
+
const {rerender} = render(
|
|
754
|
+
<TimeInput label="Time" value={value} onChange={onChange} />,
|
|
755
|
+
);
|
|
650
756
|
const input = screen.getByLabelText('Time');
|
|
651
|
-
|
|
652
|
-
|
|
757
|
+
await user.click(input);
|
|
758
|
+
await user.clear(input);
|
|
759
|
+
onChange.mockClear();
|
|
760
|
+
rerender(
|
|
761
|
+
<TimeInput label="Time" value={value} onChange={onChange} isDisabled />,
|
|
762
|
+
);
|
|
763
|
+
await user.tab();
|
|
764
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
653
765
|
});
|
|
654
766
|
|
|
655
767
|
it('does not swap in the format-hint placeholder on focus while disabled', () => {
|
|
@@ -93,6 +93,18 @@ import {useDevWarning} from '../hooks/useDevWarning';
|
|
|
93
93
|
const TOUCH_POINTER_QUERY = '(pointer: coarse)';
|
|
94
94
|
|
|
95
95
|
const styles = stylex.create({
|
|
96
|
+
// The shared disabled style drops the focus ring; restore it while a
|
|
97
|
+
// control inside the field holds focus (spec:AST-085 FR6). Defaults repeat it.
|
|
98
|
+
disabledFocusRing: {
|
|
99
|
+
borderColor: {
|
|
100
|
+
default: colorVars['--color-border-emphasized'],
|
|
101
|
+
':focus-within': colorVars['--color-accent'],
|
|
102
|
+
},
|
|
103
|
+
boxShadow: {
|
|
104
|
+
default: 'none',
|
|
105
|
+
':focus-within': `inset 0px 0px 0px 2px ${colorVars['--color-accent-muted']}`,
|
|
106
|
+
},
|
|
107
|
+
},
|
|
96
108
|
icon: {
|
|
97
109
|
display: 'flex',
|
|
98
110
|
alignItems: 'center',
|
|
@@ -207,12 +219,12 @@ export interface TimeInputProps extends Omit<
|
|
|
207
219
|
/**
|
|
208
220
|
* Explains why the input is disabled. When set together with
|
|
209
221
|
* `isDisabled`, the input shows a tooltip with this text on hover and
|
|
210
|
-
* keyboard focus, and the
|
|
211
|
-
*
|
|
212
|
-
*
|
|
222
|
+
* keyboard focus, and the input is described by it, so the reason is
|
|
223
|
+
* discoverable by keyboard and assistive technology. Typing and arrow-key
|
|
224
|
+
* adjustment stay blocked.
|
|
213
225
|
*
|
|
214
|
-
* Use this instead of wrapping a disabled input in `Tooltip
|
|
215
|
-
*
|
|
226
|
+
* Use this instead of wrapping a disabled input in `Tooltip`, so the
|
|
227
|
+
* reason is tied to the field.
|
|
216
228
|
*
|
|
217
229
|
* @example
|
|
218
230
|
* ```
|
|
@@ -479,10 +491,10 @@ function TimeField({
|
|
|
479
491
|
}
|
|
480
492
|
}, [announce, inputGroup, status?.message, status?.type]);
|
|
481
493
|
|
|
482
|
-
//
|
|
483
|
-
//
|
|
484
|
-
//
|
|
485
|
-
//
|
|
494
|
+
// A disabled field never renders native `disabled` (spec:AST-085 FR1): the
|
|
495
|
+
// input stays focusable via aria-disabled, and readOnly plus the value
|
|
496
|
+
// mutation guards block typing. The disabled-reason tooltip's listeners
|
|
497
|
+
// attach to the input container.
|
|
486
498
|
const showsDisabledMessage = isDisabled && !!disabledMessage;
|
|
487
499
|
const disabledMessageTooltip = useTooltip({
|
|
488
500
|
placement: 'above',
|
|
@@ -577,8 +589,8 @@ function TimeField({
|
|
|
577
589
|
// Handle input text change - update immediately if valid
|
|
578
590
|
const handleInputChange = useCallback(
|
|
579
591
|
(e: React.ChangeEvent<HTMLInputElement>) => {
|
|
580
|
-
//
|
|
581
|
-
//
|
|
592
|
+
// The input is never natively disabled (spec:AST-085 FR1), so guard
|
|
593
|
+
// value mutation explicitly (readOnly also blocks typing).
|
|
582
594
|
if (isDisabled) {
|
|
583
595
|
return;
|
|
584
596
|
}
|
|
@@ -613,6 +625,11 @@ function TimeField({
|
|
|
613
625
|
if (pendingInput === null) {
|
|
614
626
|
return;
|
|
615
627
|
}
|
|
628
|
+
// Disabled: keep the value, drop the draft (spec:AST-085 FR2).
|
|
629
|
+
if (isDisabled) {
|
|
630
|
+
setPendingInput(null);
|
|
631
|
+
return;
|
|
632
|
+
}
|
|
616
633
|
|
|
617
634
|
if (!pendingInput.trim()) {
|
|
618
635
|
// Empty input clears the value
|
|
@@ -633,10 +650,17 @@ function TimeField({
|
|
|
633
650
|
// Clear pending input - display will revert to formatted value
|
|
634
651
|
setPendingInput(null);
|
|
635
652
|
},
|
|
636
|
-
[pendingInput, value, fireChange, hasSeconds, min, max],
|
|
653
|
+
[pendingInput, isDisabled, value, fireChange, hasSeconds, min, max],
|
|
637
654
|
);
|
|
638
655
|
|
|
639
656
|
// Handle keyboard navigation on input
|
|
657
|
+
// Enter in a focused disabled field would submit a surrounding form (FR2).
|
|
658
|
+
const preventDisabledEnter = (e: KeyboardEvent<HTMLInputElement>) => {
|
|
659
|
+
if (isDisabled && e.key === 'Enter') {
|
|
660
|
+
e.preventDefault();
|
|
661
|
+
}
|
|
662
|
+
};
|
|
663
|
+
|
|
640
664
|
const handleInputKeyDown = useCallback(
|
|
641
665
|
(e: KeyboardEvent<HTMLInputElement>) => {
|
|
642
666
|
// ArrowUp/ArrowDown step the time and preventDefault; an IME candidate
|
|
@@ -646,8 +670,7 @@ function TimeField({
|
|
|
646
670
|
if (isImeKeyEvent(e.nativeEvent)) {
|
|
647
671
|
return;
|
|
648
672
|
}
|
|
649
|
-
// Arrow-key adjustment mutates the value; block it while
|
|
650
|
-
// disabled reason (the input keeps focusability via aria-disabled).
|
|
673
|
+
// Arrow-key adjustment mutates the value; block it while disabled.
|
|
651
674
|
if (isDisabled) {
|
|
652
675
|
return;
|
|
653
676
|
}
|
|
@@ -744,6 +767,7 @@ function TimeField({
|
|
|
744
767
|
inputWrapperStyles.base,
|
|
745
768
|
sizeStyles[size],
|
|
746
769
|
isDisabled && inputWrapperStyles.disabled,
|
|
770
|
+
isDisabled && styles.disabledFocusRing,
|
|
747
771
|
status && inputStatusBorderStyles[status.type],
|
|
748
772
|
status && !isDisabled && inputStatusHoverShadowStyles[status.type],
|
|
749
773
|
status && inputStatusFocusWithinStyles[status.type],
|
|
@@ -778,7 +802,7 @@ function TimeField({
|
|
|
778
802
|
max={max}
|
|
779
803
|
hourFormat={hourFormat}
|
|
780
804
|
isEffectivelyDisabled={isDisabled}
|
|
781
|
-
|
|
805
|
+
isFocusableDisabled
|
|
782
806
|
isEffectivelyRequired={isEffectivelyRequired}
|
|
783
807
|
isBusy={isBusy}
|
|
784
808
|
statusType={status?.type}
|
|
@@ -787,7 +811,8 @@ function TimeField({
|
|
|
787
811
|
...controlProps,
|
|
788
812
|
onFocus: onFocusProp,
|
|
789
813
|
onBlur: onBlurProp,
|
|
790
|
-
onKeyDown: onKeyDownProp,
|
|
814
|
+
onKeyDown: chainEventHandlers(preventDisabledEnter, onKeyDownProp),
|
|
815
|
+
onClick: isDisabled ? undefined : controlProps.onClick,
|
|
791
816
|
}}
|
|
792
817
|
/>
|
|
793
818
|
) : (
|
|
@@ -804,14 +829,17 @@ function TimeField({
|
|
|
804
829
|
onChange={handleInputChange}
|
|
805
830
|
onFocus={chainEventHandlers(handleFocus, onFocusProp)}
|
|
806
831
|
onBlur={chainEventHandlers(handleBlur, onBlurProp)}
|
|
807
|
-
onKeyDown={chainEventHandlers(
|
|
832
|
+
onKeyDown={chainEventHandlers(
|
|
833
|
+
preventDisabledEnter,
|
|
834
|
+
handleInputKeyDown,
|
|
835
|
+
onKeyDownProp,
|
|
836
|
+
)}
|
|
808
837
|
placeholder={displayPlaceholder}
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
//
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
readOnly={showsDisabledMessage || undefined}
|
|
838
|
+
aria-disabled={isDisabled ? 'true' : undefined}
|
|
839
|
+
readOnly={isDisabled || undefined}
|
|
840
|
+
// The browser still dispatches a click on an aria-disabled input,
|
|
841
|
+
// so a caller's onClick is skipped too (spec:AST-085 FR2).
|
|
842
|
+
onClick={isDisabled ? undefined : controlProps.onClick}
|
|
815
843
|
autoFocus={hasAutoFocus}
|
|
816
844
|
data-autofocus={hasAutoFocus || undefined}
|
|
817
845
|
aria-describedby={ariaDescribedBy}
|
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
useRef,
|
|
25
25
|
useState,
|
|
26
26
|
useTransition,
|
|
27
|
+
type KeyboardEvent,
|
|
27
28
|
} from 'react';
|
|
28
29
|
import * as stylex from '@stylexjs/stylex';
|
|
29
30
|
import {BottomSheet} from '../BottomSheet';
|
|
@@ -45,7 +46,7 @@ import {useMergedRefs} from '../hooks/useMergedRefs';
|
|
|
45
46
|
import {useResolvedRequired} from '../hooks/useResolvedRequired';
|
|
46
47
|
import {Icon} from '../Icon';
|
|
47
48
|
import {useTranslator} from '../i18n';
|
|
48
|
-
import {sizeVars, spacingVars} from '../theme/tokens.stylex';
|
|
49
|
+
import {colorVars, sizeVars, spacingVars} from '../theme/tokens.stylex';
|
|
49
50
|
import {useSize} from '../SizeContext/SizeContext';
|
|
50
51
|
import {Spinner} from '../Spinner';
|
|
51
52
|
import {useTooltip} from '../Tooltip';
|
|
@@ -74,6 +75,17 @@ const sizeStyles = stylex.create({
|
|
|
74
75
|
});
|
|
75
76
|
|
|
76
77
|
const styles = stylex.create({
|
|
78
|
+
// Same as TimeInput's: the focus ring for a focused disabled field.
|
|
79
|
+
disabledFocusRing: {
|
|
80
|
+
borderColor: {
|
|
81
|
+
default: colorVars['--color-border-emphasized'],
|
|
82
|
+
':focus-within': colorVars['--color-accent'],
|
|
83
|
+
},
|
|
84
|
+
boxShadow: {
|
|
85
|
+
default: 'none',
|
|
86
|
+
':focus-within': `inset 0px 0px 0px 2px ${colorVars['--color-accent-muted']}`,
|
|
87
|
+
},
|
|
88
|
+
},
|
|
77
89
|
iconButton: {
|
|
78
90
|
display: 'flex',
|
|
79
91
|
alignItems: 'center',
|
|
@@ -447,6 +459,7 @@ export function TouchTimeField({
|
|
|
447
459
|
inputWrapperStyles.base,
|
|
448
460
|
sizeStyles[size],
|
|
449
461
|
isDisabled && inputWrapperStyles.disabled,
|
|
462
|
+
isDisabled && styles.disabledFocusRing,
|
|
450
463
|
status && inputStatusBorderStyles[status.type],
|
|
451
464
|
status && !isDisabled && inputStatusHoverShadowStyles[status.type],
|
|
452
465
|
status && inputStatusFocusWithinStyles[status.type],
|
|
@@ -459,7 +472,7 @@ export function TouchTimeField({
|
|
|
459
472
|
<button
|
|
460
473
|
type="button"
|
|
461
474
|
onClick={openSheet}
|
|
462
|
-
disabled={isEffectivelyDisabled}
|
|
475
|
+
aria-disabled={isEffectivelyDisabled || undefined}
|
|
463
476
|
aria-label={t('@astryx.timeInput.openPicker', {label})}
|
|
464
477
|
tabIndex={-1}
|
|
465
478
|
{...stylex.props(focusOutlineStyles.focusVisible, styles.iconButton)}>
|
|
@@ -475,10 +488,19 @@ export function TouchTimeField({
|
|
|
475
488
|
readOnly
|
|
476
489
|
inputMode="none"
|
|
477
490
|
onChange={() => {}}
|
|
478
|
-
onClick={
|
|
491
|
+
onClick={
|
|
492
|
+
isEffectivelyDisabled
|
|
493
|
+
? undefined
|
|
494
|
+
: chainEventHandlers(openSheet, onClickProp)
|
|
495
|
+
}
|
|
496
|
+
// Enter would submit a surrounding form while disabled (FR2).
|
|
497
|
+
onKeyDown={chainEventHandlers((e: KeyboardEvent<HTMLInputElement>) => {
|
|
498
|
+
if (isEffectivelyDisabled && e.key === 'Enter') {
|
|
499
|
+
e.preventDefault();
|
|
500
|
+
}
|
|
501
|
+
}, controlProps.onKeyDown)}
|
|
479
502
|
placeholder={placeholder}
|
|
480
|
-
disabled={isEffectivelyDisabled
|
|
481
|
-
aria-disabled={showsDisabledMessage ? 'true' : undefined}
|
|
503
|
+
aria-disabled={isEffectivelyDisabled ? 'true' : undefined}
|
|
482
504
|
aria-describedby={ariaDescribedBy}
|
|
483
505
|
aria-required={isEffectivelyRequired ? 'true' : undefined}
|
|
484
506
|
aria-invalid={status?.type === 'error' ? 'true' : undefined}
|
|
@@ -28,7 +28,7 @@ export const docs = {
|
|
|
28
28
|
name: 'format',
|
|
29
29
|
type: "'relative' | 'relative_short' | 'auto' | 'date' | 'date_long' | 'date_weekday' | 'date_time' | 'time' | 'system_date' | 'system_date_time' | 'system_time' | 'unix_seconds'",
|
|
30
30
|
description:
|
|
31
|
-
"Display format. 'relative' uses the locale's native long relative-time wording ('2 hours ago' in English), 'relative_short' uses its narrow pattern ('2h ago' in English) for compact surfaces, 'date' shows 'Mar 21, 2025', 'date_long' shows 'March 21, 2025', 'date_weekday' shows 'Wed, Mar 21, 2025', 'date_time' shows 'Mar 21, 2025, 2:51 PM', 'time' shows '2:51 PM', 'system_*' variants use ISO-style formatting, 'unix_seconds' shows the Unix time in whole seconds since the epoch (an absolute, zone-independent value), 'auto' switches from relative to date_time based on recency.",
|
|
31
|
+
"Display format. 'relative' uses the locale's native long relative-time wording ('2 hours ago' in English), 'relative_short' uses its narrow pattern ('2h ago' in English) for compact surfaces, 'date' shows 'Mar 21, 2025', 'date_long' shows 'March 21, 2025', 'date_weekday' shows 'Wed, Mar 21, 2025', 'date_time' shows 'Mar 21, 2025, 2:51 PM', 'time' shows '2:51 PM', 'system_*' variants use ISO-style formatting, 'unix_seconds' shows the Unix time in whole seconds since the epoch (an absolute, zone-independent value), 'auto' switches from relative to date_time based on recency. Absolute formats never read the current time. 'relative', 'relative_short' and 'auto' read it on the client only: a server render or static prerender shows the full date and time in UTC, labelled, and the client switches to the format's normal output after hydration.",
|
|
32
32
|
default: "'auto'",
|
|
33
33
|
},
|
|
34
34
|
{
|
|
@@ -11,6 +11,9 @@ import {
|
|
|
11
11
|
} from 'vitest';
|
|
12
12
|
import {render, screen, act, waitFor, fireEvent} from '@testing-library/react';
|
|
13
13
|
import userEvent from '@testing-library/user-event';
|
|
14
|
+
import {Profiler} from 'react';
|
|
15
|
+
import {hydrateRoot, type Root} from 'react-dom/client';
|
|
16
|
+
import {renderToString} from 'react-dom/server';
|
|
14
17
|
import {Timestamp} from './Timestamp';
|
|
15
18
|
import {formatInstant} from './formatInstant';
|
|
16
19
|
import {formatTooltipLines} from './tooltipEntries';
|
|
@@ -1466,3 +1469,168 @@ describe('Timestamp pass-through props', () => {
|
|
|
1466
1469
|
});
|
|
1467
1470
|
});
|
|
1468
1471
|
});
|
|
1472
|
+
|
|
1473
|
+
/**
|
|
1474
|
+
* Counts `new Date()` calls with no argument (reads of the current time) made
|
|
1475
|
+
* while `run` executes.
|
|
1476
|
+
*/
|
|
1477
|
+
function countClockReads(run: () => void): number {
|
|
1478
|
+
const ActiveDate = globalThis.Date;
|
|
1479
|
+
let reads = 0;
|
|
1480
|
+
class CountingDate extends ActiveDate {
|
|
1481
|
+
constructor(...args: [] | [string | number | Date]) {
|
|
1482
|
+
if (args.length === 0) {
|
|
1483
|
+
reads += 1;
|
|
1484
|
+
}
|
|
1485
|
+
super(...(args as [string | number | Date]));
|
|
1486
|
+
}
|
|
1487
|
+
}
|
|
1488
|
+
globalThis.Date = CountingDate as DateConstructor;
|
|
1489
|
+
try {
|
|
1490
|
+
run();
|
|
1491
|
+
} finally {
|
|
1492
|
+
globalThis.Date = ActiveDate;
|
|
1493
|
+
}
|
|
1494
|
+
return reads;
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1497
|
+
describe('Timestamp server rendering', () => {
|
|
1498
|
+
beforeEach(() => {
|
|
1499
|
+
vi.useFakeTimers();
|
|
1500
|
+
vi.setSystemTime(new Date('2026-03-25T12:00:00Z'));
|
|
1501
|
+
});
|
|
1502
|
+
|
|
1503
|
+
afterEach(() => {
|
|
1504
|
+
vi.useRealTimers();
|
|
1505
|
+
});
|
|
1506
|
+
|
|
1507
|
+
it.each(['date', 'date_time', 'time', 'unix_seconds'] as const)(
|
|
1508
|
+
'never reads the current time for the absolute %s format',
|
|
1509
|
+
format => {
|
|
1510
|
+
const element = (
|
|
1511
|
+
<Timestamp value="2026-03-25T10:00:00Z" format={format} />
|
|
1512
|
+
);
|
|
1513
|
+
expect(countClockReads(() => renderToString(element))).toBe(0);
|
|
1514
|
+
expect(countClockReads(() => render(element))).toBe(0);
|
|
1515
|
+
},
|
|
1516
|
+
);
|
|
1517
|
+
|
|
1518
|
+
it.each(['relative', 'relative_short', 'auto'] as const)(
|
|
1519
|
+
'renders the %s format as the full date and time in UTC on the server without reading the clock',
|
|
1520
|
+
format => {
|
|
1521
|
+
const value = '2026-03-25T10:00:00Z';
|
|
1522
|
+
let html = '';
|
|
1523
|
+
expect(
|
|
1524
|
+
countClockReads(() => {
|
|
1525
|
+
html = renderToString(<Timestamp value={value} format={format} />);
|
|
1526
|
+
}),
|
|
1527
|
+
).toBe(0);
|
|
1528
|
+
const host = document.createElement('div');
|
|
1529
|
+
host.innerHTML = html;
|
|
1530
|
+
expect(host.textContent).toBe(
|
|
1531
|
+
formatInstant(new Date(value), 'date_time', 'en-US', {
|
|
1532
|
+
timeZone: 'UTC',
|
|
1533
|
+
isTimezoneShown: true,
|
|
1534
|
+
}),
|
|
1535
|
+
);
|
|
1536
|
+
},
|
|
1537
|
+
);
|
|
1538
|
+
|
|
1539
|
+
it('never commits the UTC placeholder when a client Timestamp switches to a relative format', () => {
|
|
1540
|
+
const value = Date.now() / 1000 - 7200;
|
|
1541
|
+
const committed: string[] = [];
|
|
1542
|
+
function Spy({format}: {format: 'date' | 'relative'}) {
|
|
1543
|
+
return (
|
|
1544
|
+
<Profiler
|
|
1545
|
+
id="ts"
|
|
1546
|
+
onRender={() => {
|
|
1547
|
+
committed.push(document.querySelector('time')?.textContent ?? '');
|
|
1548
|
+
}}>
|
|
1549
|
+
<Timestamp value={value} format={format} hasTooltip={false} />
|
|
1550
|
+
</Profiler>
|
|
1551
|
+
);
|
|
1552
|
+
}
|
|
1553
|
+
const {rerender} = render(<Spy format="date" />);
|
|
1554
|
+
committed.length = 0;
|
|
1555
|
+
rerender(<Spy format="relative" />);
|
|
1556
|
+
expect(screen.getByText('2 hours ago')).toBeInTheDocument();
|
|
1557
|
+
expect(committed.some(text => text.includes('UTC'))).toBe(false);
|
|
1558
|
+
});
|
|
1559
|
+
|
|
1560
|
+
describe('across time zones', () => {
|
|
1561
|
+
const originalTZ = process.env.TZ;
|
|
1562
|
+
|
|
1563
|
+
afterEach(() => {
|
|
1564
|
+
if (originalTZ === undefined) {
|
|
1565
|
+
delete process.env.TZ;
|
|
1566
|
+
} else {
|
|
1567
|
+
process.env.TZ = originalTZ;
|
|
1568
|
+
}
|
|
1569
|
+
});
|
|
1570
|
+
|
|
1571
|
+
it('writes the same server markup whatever the server time zone', () => {
|
|
1572
|
+
const element = (
|
|
1573
|
+
<Timestamp value="2026-03-25T10:00:00Z" format="relative" />
|
|
1574
|
+
);
|
|
1575
|
+
process.env.TZ = 'UTC';
|
|
1576
|
+
const utc = renderToString(element);
|
|
1577
|
+
process.env.TZ = 'Asia/Tokyo';
|
|
1578
|
+
expect(renderToString(element)).toBe(utc);
|
|
1579
|
+
});
|
|
1580
|
+
|
|
1581
|
+
it('hydrates server markup from another time zone without a mismatch', () => {
|
|
1582
|
+
const value = Date.now() / 1000 - 7200;
|
|
1583
|
+
const element = (
|
|
1584
|
+
<Timestamp value={value} format="relative" hasTooltip={false} />
|
|
1585
|
+
);
|
|
1586
|
+
process.env.TZ = 'UTC';
|
|
1587
|
+
const host = document.createElement('div');
|
|
1588
|
+
document.body.appendChild(host);
|
|
1589
|
+
host.innerHTML = renderToString(element);
|
|
1590
|
+
|
|
1591
|
+
process.env.TZ = 'America/Los_Angeles';
|
|
1592
|
+
const errors = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
1593
|
+
let root: Root | undefined;
|
|
1594
|
+
try {
|
|
1595
|
+
act(() => {
|
|
1596
|
+
root = hydrateRoot(host, element, {
|
|
1597
|
+
onRecoverableError: error => {
|
|
1598
|
+
throw error;
|
|
1599
|
+
},
|
|
1600
|
+
});
|
|
1601
|
+
});
|
|
1602
|
+
expect(host.textContent).toBe('2 hours ago');
|
|
1603
|
+
expect(errors).not.toHaveBeenCalled();
|
|
1604
|
+
} finally {
|
|
1605
|
+
errors.mockRestore();
|
|
1606
|
+
act(() => root?.unmount());
|
|
1607
|
+
host.remove();
|
|
1608
|
+
}
|
|
1609
|
+
});
|
|
1610
|
+
});
|
|
1611
|
+
|
|
1612
|
+
it('hydrates the server markup without a mismatch, then shows the relative phrase', () => {
|
|
1613
|
+
const value = Date.now() / 1000 - 7200;
|
|
1614
|
+
const element = (
|
|
1615
|
+
<Timestamp value={value} format="relative" hasTooltip={false} />
|
|
1616
|
+
);
|
|
1617
|
+
const host = document.createElement('div');
|
|
1618
|
+
document.body.appendChild(host);
|
|
1619
|
+
host.innerHTML = renderToString(element);
|
|
1620
|
+
expect(host.textContent).not.toBe('2 hours ago');
|
|
1621
|
+
|
|
1622
|
+
const errors = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
1623
|
+
let root: Root | undefined;
|
|
1624
|
+
try {
|
|
1625
|
+
act(() => {
|
|
1626
|
+
root = hydrateRoot(host, element);
|
|
1627
|
+
});
|
|
1628
|
+
expect(host.textContent).toBe('2 hours ago');
|
|
1629
|
+
expect(errors).not.toHaveBeenCalled();
|
|
1630
|
+
} finally {
|
|
1631
|
+
errors.mockRestore();
|
|
1632
|
+
act(() => root?.unmount());
|
|
1633
|
+
host.remove();
|
|
1634
|
+
}
|
|
1635
|
+
});
|
|
1636
|
+
});
|