@astryxdesign/core 0.7.2 → 0.7.3-canary.04c7468

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (234) hide show
  1. package/dist/BottomSheet/BottomSheet.d.ts.map +1 -1
  2. package/dist/BottomSheet/BottomSheet.js +8 -4
  3. package/dist/BottomSheet/BottomSheetSwitcher.d.ts.map +1 -1
  4. package/dist/BottomSheet/BottomSheetSwitcher.js +7 -0
  5. package/dist/Calendar/Calendar.js +3 -2
  6. package/dist/CheckboxInput/CheckboxInput.d.ts.map +1 -1
  7. package/dist/CheckboxInput/CheckboxInput.js +19 -24
  8. package/dist/ClickableCard/ClickableCard.d.ts +2 -2
  9. package/dist/ClickableCard/ClickableCard.d.ts.map +1 -1
  10. package/dist/ClickableCard/ClickableCard.js +11 -9
  11. package/dist/CodeBlock/CodeBlock.d.ts.map +1 -1
  12. package/dist/CodeBlock/CodeBlock.js +11 -1
  13. package/dist/ComplexSelector/ComplexSelector.d.ts.map +1 -1
  14. package/dist/ComplexSelector/ComplexSelector.js +23 -5
  15. package/dist/DateInput/DateInput.d.ts +4 -4
  16. package/dist/DateInput/DateInput.d.ts.map +1 -1
  17. package/dist/DateInput/DateInput.js +27 -20
  18. package/dist/DateInput/NativeDateField.d.ts.map +1 -1
  19. package/dist/DateInput/NativeDateField.js +14 -8
  20. package/dist/DateInput/TouchDateField.d.ts.map +1 -1
  21. package/dist/DateInput/TouchDateField.js +14 -10
  22. package/dist/DateRangeInput/DateRangeInput.d.ts +5 -5
  23. package/dist/DateRangeInput/DateRangeInput.d.ts.map +1 -1
  24. package/dist/DateRangeInput/DateRangeInput.js +27 -18
  25. package/dist/DateTimeInput/DateTimeInput.d.ts +5 -5
  26. package/dist/DateTimeInput/DateTimeInput.d.ts.map +1 -1
  27. package/dist/DateTimeInput/DateTimeInput.js +25 -27
  28. package/dist/DateTimeInput/NativeDateSegment.d.ts +3 -2
  29. package/dist/DateTimeInput/NativeDateSegment.d.ts.map +1 -1
  30. package/dist/DateTimeInput/NativeDateSegment.js +5 -5
  31. package/dist/DateTimeInput/NativeTimeSegment.d.ts +3 -2
  32. package/dist/DateTimeInput/NativeTimeSegment.d.ts.map +1 -1
  33. package/dist/DateTimeInput/NativeTimeSegment.js +5 -5
  34. package/dist/DateTimeInput/TouchDateTimeField.d.ts.map +1 -1
  35. package/dist/DateTimeInput/TouchDateTimeField.js +12 -9
  36. package/dist/Dialog/Dialog.d.ts.map +1 -1
  37. package/dist/Dialog/Dialog.js +9 -4
  38. package/dist/DropdownMenu/DropdownMenu.d.ts.map +1 -1
  39. package/dist/DropdownMenu/DropdownMenu.js +52 -10
  40. package/dist/DropdownMenu/DropdownMenuContext.d.ts +34 -1
  41. package/dist/DropdownMenu/DropdownMenuContext.d.ts.map +1 -1
  42. package/dist/DropdownMenu/DropdownMenuContext.js +15 -0
  43. package/dist/DropdownMenu/DropdownMenuSubMenu.d.ts +21 -2
  44. package/dist/DropdownMenu/DropdownMenuSubMenu.d.ts.map +1 -1
  45. package/dist/DropdownMenu/DropdownMenuSubMenu.js +375 -42
  46. package/dist/DropdownMenu/menuRegionStops.d.ts +2 -2
  47. package/dist/DropdownMenu/menuRegionStops.js +2 -2
  48. package/dist/FileInput/FileInput.d.ts.map +1 -1
  49. package/dist/FileInput/FileInput.js +14 -12
  50. package/dist/Link/Link.d.ts +2 -3
  51. package/dist/Link/Link.d.ts.map +1 -1
  52. package/dist/Link/Link.js +11 -19
  53. package/dist/Markdown/Markdown.d.ts.map +1 -1
  54. package/dist/Markdown/Markdown.js +48 -15
  55. package/dist/MultiSelector/MultiSelector.d.ts +4 -4
  56. package/dist/MultiSelector/MultiSelector.d.ts.map +1 -1
  57. package/dist/MultiSelector/MultiSelector.disabledFocus.a11y.chromium.spec.d.ts +2 -0
  58. package/dist/MultiSelector/MultiSelector.disabledFocus.a11y.chromium.spec.d.ts.map +1 -0
  59. package/dist/MultiSelector/MultiSelector.disabledFocus.a11y.chromium.spec.js +48 -0
  60. package/dist/MultiSelector/MultiSelector.js +25 -18
  61. package/dist/Pagination/Pagination.d.ts.map +1 -1
  62. package/dist/Pagination/Pagination.js +5 -2
  63. package/dist/Popover/Popover.d.ts.map +1 -1
  64. package/dist/Popover/Popover.js +8 -4
  65. package/dist/Selector/Selector.d.ts +4 -4
  66. package/dist/Selector/Selector.d.ts.map +1 -1
  67. package/dist/Selector/Selector.js +23 -14
  68. package/dist/Selector/SelectorOption.d.ts.map +1 -1
  69. package/dist/Selector/SelectorOption.js +1 -0
  70. package/dist/Slider/Slider.d.ts.map +1 -1
  71. package/dist/Slider/Slider.js +7 -14
  72. package/dist/Switch/Switch.d.ts.map +1 -1
  73. package/dist/Switch/Switch.js +25 -17
  74. package/dist/Switch/switch.markers.stylex.d.ts +2 -0
  75. package/dist/Switch/switch.markers.stylex.d.ts.map +1 -1
  76. package/dist/Switch/switch.markers.stylex.js +6 -0
  77. package/dist/TimeInput/TimeInput.d.ts +7 -6
  78. package/dist/TimeInput/TimeInput.d.ts.map +1 -1
  79. package/dist/TimeInput/TimeInput.js +37 -20
  80. package/dist/TimeInput/TouchTimeField.d.ts.map +1 -1
  81. package/dist/TimeInput/TouchTimeField.js +17 -6
  82. package/dist/Timestamp/Timestamp.d.ts.map +1 -1
  83. package/dist/Timestamp/Timestamp.js +41 -8
  84. package/dist/Token/Token.d.ts.map +1 -1
  85. package/dist/Token/Token.js +8 -3
  86. package/dist/Tooltip/Tooltip.d.ts.map +1 -1
  87. package/dist/Tooltip/Tooltip.js +36 -14
  88. package/dist/Tooltip/useTooltip.d.ts.map +1 -1
  89. package/dist/Tooltip/useTooltip.js +11 -2
  90. package/dist/TopNav/TopNav.d.ts.map +1 -1
  91. package/dist/TopNav/TopNav.js +2 -2
  92. package/dist/TopNav/TopNavHeading.d.ts.map +1 -1
  93. package/dist/TopNav/TopNavHeading.js +1 -0
  94. package/dist/TreeList/TreeListItem.d.ts.map +1 -1
  95. package/dist/TreeList/TreeListItem.js +8 -3
  96. package/dist/Typeahead/Typeahead.d.ts.map +1 -1
  97. package/dist/Typeahead/Typeahead.js +14 -2
  98. package/dist/astryx.css +8 -2
  99. package/dist/hooks/useGridFocus.d.ts.map +1 -1
  100. package/dist/hooks/useGridFocus.js +10 -1
  101. package/dist/hooks/useMenuPress.d.ts +11 -1
  102. package/dist/hooks/useMenuPress.d.ts.map +1 -1
  103. package/dist/hooks/useMenuPress.js +43 -5
  104. package/dist/utils/disabledInteraction.d.ts +21 -0
  105. package/dist/utils/disabledInteraction.d.ts.map +1 -0
  106. package/dist/utils/disabledInteraction.js +33 -0
  107. package/dist/utils/timeParser.d.ts +4 -0
  108. package/dist/utils/timeParser.d.ts.map +1 -1
  109. package/dist/utils/timeParser.js +9 -2
  110. package/package.json +4 -4
  111. package/scripts/agent-doc-state.mjs +58 -9
  112. package/src/Banner/Banner.spec.md +37 -32
  113. package/src/BottomSheet/BottomSheet.spec.md +34 -54
  114. package/src/BottomSheet/BottomSheet.test.tsx +29 -0
  115. package/src/BottomSheet/BottomSheet.tsx +8 -4
  116. package/src/BottomSheet/BottomSheetSwitcher.spec.md +125 -58
  117. package/src/BottomSheet/BottomSheetSwitcher.test.tsx +26 -0
  118. package/src/BottomSheet/BottomSheetSwitcher.tsx +6 -0
  119. package/src/Calendar/Calendar.test.tsx +68 -24
  120. package/src/Calendar/Calendar.tsx +2 -1
  121. package/src/CheckboxInput/CheckboxInput.test.tsx +44 -5
  122. package/src/CheckboxInput/CheckboxInput.tsx +16 -26
  123. package/src/CheckboxInput/__tests__/Checkbox.a11y.states.ts +3 -3
  124. package/src/CheckboxList/CheckboxList.test.tsx +9 -6
  125. package/src/ClickableCard/ClickableCard.doc.mjs +2 -2
  126. package/src/ClickableCard/ClickableCard.test.tsx +5 -4
  127. package/src/ClickableCard/ClickableCard.tsx +9 -9
  128. package/src/CodeBlock/CodeBlock.highlightStyles.test.tsx +87 -0
  129. package/src/CodeBlock/CodeBlock.tsx +11 -1
  130. package/src/ComplexSelector/ComplexSelector.focusableDisabled.test.tsx +76 -0
  131. package/src/ComplexSelector/ComplexSelector.tsx +21 -4
  132. package/src/ContextMenu/ContextMenu.test.tsx +41 -0
  133. package/src/DateInput/DateInput.doc.mjs +9 -8
  134. package/src/DateInput/DateInput.test.tsx +88 -13
  135. package/src/DateInput/DateInput.tsx +49 -21
  136. package/src/DateInput/DateInputTouch.test.tsx +22 -1
  137. package/src/DateInput/NativeDateField.test.tsx +29 -3
  138. package/src/DateInput/NativeDateField.tsx +20 -7
  139. package/src/DateInput/TouchDateField.tsx +24 -9
  140. package/src/DateRangeInput/DateRangeInput.doc.mjs +6 -5
  141. package/src/DateRangeInput/DateRangeInput.spec.md +13 -13
  142. package/src/DateRangeInput/DateRangeInput.test.tsx +82 -16
  143. package/src/DateRangeInput/DateRangeInput.tsx +40 -20
  144. package/src/DateTimeInput/DateTimeInput.doc.mjs +9 -8
  145. package/src/DateTimeInput/DateTimeInput.test.tsx +126 -11
  146. package/src/DateTimeInput/DateTimeInput.tsx +42 -26
  147. package/src/DateTimeInput/DateTimeInputTouch.test.tsx +24 -0
  148. package/src/DateTimeInput/NativeDateSegment.tsx +9 -6
  149. package/src/DateTimeInput/NativePickerSegments.test.tsx +31 -0
  150. package/src/DateTimeInput/NativeTimeSegment.tsx +9 -6
  151. package/src/DateTimeInput/TouchDateTimeField.tsx +22 -7
  152. package/src/Dialog/Dialog.test.tsx +39 -0
  153. package/src/Dialog/Dialog.tsx +9 -4
  154. package/src/DropdownMenu/DropdownMenu.spec.md +33 -2
  155. package/src/DropdownMenu/DropdownMenu.tsx +79 -8
  156. package/src/DropdownMenu/DropdownMenuContext.public.test.ts +22 -0
  157. package/src/DropdownMenu/DropdownMenuContext.tsx +39 -1
  158. package/src/DropdownMenu/DropdownMenuSubMenu.doc.mjs +16 -1
  159. package/src/DropdownMenu/DropdownMenuSubMenu.test.tsx +623 -1
  160. package/src/DropdownMenu/DropdownMenuSubMenu.tsx +556 -99
  161. package/src/DropdownMenu/__tests__/SubMenuHeaderFooter.a11y.chromium.spec.ts +267 -0
  162. package/src/DropdownMenu/menuRegionStops.ts +2 -2
  163. package/src/DropdownMenu/modules/useMenuPress.spec.md +9 -0
  164. package/src/FieldStatus/FieldStatus.spec.md +3 -2
  165. package/src/FileInput/FileInput.test.tsx +30 -7
  166. package/src/FileInput/FileInput.tsx +21 -11
  167. package/src/Link/Link.test.tsx +21 -7
  168. package/src/Link/Link.tsx +16 -24
  169. package/src/Link/__tests__/Link.a11y.states.ts +1 -1
  170. package/src/Markdown/Markdown.rerender.test.tsx +143 -0
  171. package/src/Markdown/Markdown.tsx +89 -35
  172. package/src/MultiSelector/MultiSelector.disabledFocus.a11y.chromium.spec.ts +71 -0
  173. package/src/MultiSelector/MultiSelector.doc.mjs +5 -5
  174. package/src/MultiSelector/MultiSelector.focusableDisabled.test.tsx +121 -0
  175. package/src/MultiSelector/MultiSelector.test.tsx +49 -7
  176. package/src/MultiSelector/MultiSelector.tsx +53 -29
  177. package/src/Pagination/Pagination.test.tsx +5 -1
  178. package/src/Pagination/Pagination.tsx +3 -1
  179. package/src/Popover/Popover.test.tsx +15 -0
  180. package/src/Popover/Popover.tsx +19 -8
  181. package/src/PowerSearch/PowerSearchFilterEditor.test.tsx +3 -2
  182. package/src/PowerSearch/PowerSearchToken.test.tsx +7 -5
  183. package/src/Selector/Selector.doc.mjs +4 -4
  184. package/src/Selector/Selector.focusableDisabled.test.tsx +127 -0
  185. package/src/Selector/Selector.spec.md +11 -11
  186. package/src/Selector/Selector.test.tsx +64 -6
  187. package/src/Selector/Selector.tsx +51 -21
  188. package/src/Selector/SelectorOption.tsx +4 -0
  189. package/src/SideNav/SideNav.test.tsx +35 -0
  190. package/src/Slider/Slider.spec.md +11 -11
  191. package/src/Slider/Slider.test.tsx +3 -3
  192. package/src/Slider/Slider.tsx +8 -15
  193. package/src/Switch/Switch.test.tsx +85 -8
  194. package/src/Switch/Switch.tsx +26 -16
  195. package/src/Switch/__tests__/Switch.a11y.states.ts +2 -2
  196. package/src/Switch/switch.markers.stylex.ts +4 -0
  197. package/src/Table/Table.spec.md +2 -0
  198. package/src/Table/plugins/infiniteScroll/useTableInfiniteScroll.spec.md +244 -0
  199. package/src/Table/plugins/selection/useTableSelection.test.tsx +5 -3
  200. package/src/Table/plugins/virtualization/useTableVirtualization.spec.md +266 -0
  201. package/src/TimeInput/NativeTimeInput.test.tsx +25 -1
  202. package/src/TimeInput/Presentation.test.tsx +23 -0
  203. package/src/TimeInput/TimeInput.doc.mjs +13 -10
  204. package/src/TimeInput/TimeInput.test.tsx +139 -7
  205. package/src/TimeInput/TimeInput.tsx +53 -24
  206. package/src/TimeInput/TouchTimeField.tsx +27 -5
  207. package/src/Timestamp/Timestamp.doc.mjs +1 -1
  208. package/src/Timestamp/Timestamp.test.tsx +168 -0
  209. package/src/Timestamp/Timestamp.tsx +62 -11
  210. package/src/Token/Token.test.tsx +26 -4
  211. package/src/Token/Token.tsx +6 -2
  212. package/src/Tokenizer/Tokenizer.spec.md +28 -28
  213. package/src/Tooltip/Tooltip.test.tsx +51 -6
  214. package/src/Tooltip/Tooltip.tsx +41 -18
  215. package/src/Tooltip/__tests__/Tooltip.a11y.chromium.spec.ts +129 -0
  216. package/src/Tooltip/__tests__/Tooltip.a11y.renders.tsx +86 -0
  217. package/src/Tooltip/__tests__/Tooltip.a11y.states.ts +132 -0
  218. package/src/Tooltip/__tests__/Tooltip.a11y.test.tsx +126 -0
  219. package/src/Tooltip/__tests__/TooltipHoverBridge.a11y.chromium.spec.ts +74 -0
  220. package/src/Tooltip/useTooltip.test.tsx +23 -0
  221. package/src/Tooltip/useTooltip.tsx +22 -9
  222. package/src/TopNav/TopNav.tsx +11 -1
  223. package/src/TopNav/TopNavHeading.tsx +1 -0
  224. package/src/TopNav/__tests__/TopNavMobileLongHeading.a11y.chromium.spec.ts +74 -0
  225. package/src/TreeList/TreeList.test.tsx +28 -0
  226. package/src/TreeList/TreeListItem.tsx +6 -2
  227. package/src/Typeahead/Typeahead.test.tsx +89 -0
  228. package/src/Typeahead/Typeahead.tsx +15 -2
  229. package/src/__tests__/DisabledTriggerFocus.a11y.chromium.spec.ts +86 -0
  230. package/src/hooks/useGridFocus.ts +16 -1
  231. package/src/hooks/useMenuPress.ts +45 -7
  232. package/src/utils/disabledInteraction.ts +40 -0
  233. package/src/utils/timeParser.test.ts +11 -0
  234. package/src/utils/timeParser.ts +9 -2
@@ -231,14 +231,38 @@ describe('TimeInput nativePicker', () => {
231
231
  expect(input).not.toBeDisabled();
232
232
  expect(input).toHaveAttribute('aria-disabled', 'true');
233
233
  expect(input).toHaveAttribute('readonly');
234
+ // The shared native time segment's picker button is focusable-disabled
235
+ // (spec:AST-085 FR1) and still blocks opening.
234
236
  expect(
235
237
  screen.getByRole('button', {name: 'Open Start time'}),
236
- ).toBeDisabled();
238
+ ).toHaveAttribute('aria-disabled', 'true');
237
239
  const tooltip = document.querySelector<HTMLElement>('[role="tooltip"]');
238
240
  expect(tooltip).not.toBeNull();
239
241
  expect(input.getAttribute('aria-describedby')).toContain(tooltip?.id);
240
242
  });
241
243
 
244
+ it('keeps a disabled native field focusable without native disabled (AST-085 FR1, FR2)', () => {
245
+ stubPointer(true);
246
+ const onClick = vi.fn();
247
+ render(
248
+ <TimeInput
249
+ label="Start time"
250
+ nativePicker="always"
251
+ isDisabled
252
+ onClick={onClick}
253
+ onChange={() => {}}
254
+ />,
255
+ );
256
+ const input = getNativeTimeInput();
257
+ expect(input).not.toHaveAttribute('disabled');
258
+ expect(input).toHaveAttribute('aria-disabled', 'true');
259
+ expect(input).toHaveAttribute('readonly');
260
+ input.focus();
261
+ expect(input).toHaveFocus();
262
+ fireEvent.click(input);
263
+ expect(onClick).not.toHaveBeenCalled();
264
+ });
265
+
242
266
  it('forwards refs and autofocus to the native input', () => {
243
267
  stubPointer(true);
244
268
  const ref = vi.fn();
@@ -96,6 +96,29 @@ describe('TimeInput presentation', () => {
96
96
  expect(screen.getByRole('listbox', {name: 'Minute'})).toBeInTheDocument();
97
97
  });
98
98
 
99
+ it('bottom-sheet: a disabled field stays focusable and inert (AST-085 FR1, FR2)', () => {
100
+ stubPointer(false);
101
+ const onClick = vi.fn();
102
+ render(
103
+ <TimeInput
104
+ label="Start"
105
+ presentation="bottom-sheet"
106
+ isDisabled
107
+ onClick={onClick}
108
+ onChange={() => {}}
109
+ />,
110
+ );
111
+ const input = screen.getByRole('combobox', {name: 'Start'});
112
+ expect(input).not.toHaveAttribute('disabled');
113
+ expect(input).toHaveAttribute('aria-disabled', 'true');
114
+ input.focus();
115
+ expect(input).toHaveFocus();
116
+ fireEvent.click(input);
117
+ expect(input).toHaveAttribute('aria-expanded', 'false');
118
+ expect(screen.queryByRole('listbox', {name: 'Hour'})).toBeNull();
119
+ expect(onClick).not.toHaveBeenCalled();
120
+ });
121
+
99
122
  it('adaptive-bottom-sheet: typed on fine, sheet on coarse', () => {
100
123
  stubPointer(false);
101
124
  const {unmount} = render(
@@ -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; disabled triggers swallow the hover events the wrapper needs. Use the disabledMessage prop instead.',
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: 'Disables the input and suppresses interactions.',
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 keeps the field focusable via aria-disabled (activation stays blocked). Use this instead of wrapping a disabled TimeInput in Tooltip. Disabled controls swallow the hover events an external Tooltip needs.',
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',
@@ -212,7 +213,7 @@ export const docs = {
212
213
  name: 'increment',
213
214
  type: 'number',
214
215
  description:
215
- 'Number of minutes to add or subtract when the user presses the up or down arrow key.',
216
+ 'Number of minutes to add or subtract when the user presses the up or down arrow key. Steps are whole minutes; a fractional value leaves the time unchanged.',
216
217
  default: '1',
217
218
  },
218
219
  {
@@ -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 一起使用时,在悬停/键盘聚焦时显示提示,并通过 aria-disabled 保持输入框可聚焦(仍阻止输入和调整)。请使用此属性,而不是用 Tooltip 包裹已禁用的 TimeInput。',
340
+ '说明输入框为何被禁用。与 isDisabled 一起使用时,在悬停/键盘聚焦时显示提示,并描述输入框(仍阻止输入和调整)。请使用此属性,而不是用 Tooltip 包裹已禁用的 TimeInput。',
340
341
  },
341
342
  {
342
343
  name: 'value',
@@ -392,7 +393,8 @@ export const docsZh = {
392
393
  {
393
394
  name: 'increment',
394
395
  type: 'number',
395
- description: '用户按上或下方向键时增加或减少的分钟数。',
396
+ description:
397
+ '用户按上或下方向键时增加或减少的分钟数。步长为整分钟;传入小数时时间保持不变。',
396
398
  default: '1',
397
399
  },
398
400
  {
@@ -509,7 +511,7 @@ export const docsZh = {
509
511
  {
510
512
  guidance: false,
511
513
  description:
512
- 'Wrap a disabled TimeInput in Tooltip to explain why it is disabled; disabled triggers swallow the hover events the wrapper needs. Use the disabledMessage prop instead.',
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.',
513
515
  },
514
516
  ],
515
517
  anatomy: [
@@ -611,7 +613,7 @@ export const docsDense = {
611
613
  'Marks required+sets aria-required. Mutually exclusive w/ isOptional.',
612
614
  isDisabled: 'Disables input, suppresses interactions.',
613
615
  disabledMessage:
614
- 'Reason shown in a tooltip on hover/focus when disabled; keeps input focusable via aria-disabled.',
616
+ 'Reason shown in a tooltip on hover/focus when disabled; describes the input.',
615
617
  value: 'Controlled time in ISO format (HH:MM or HH:MM:SS).',
616
618
  onChange: 'Fired on time change. Receives undefined when cleared.',
617
619
  changeAction:
@@ -623,7 +625,8 @@ export const docsDense = {
623
625
  hasClear: 'Shows clear button when value set+not disabled.',
624
626
  hourFormat:
625
627
  "Display format. '12h' shows AM/PM; '24h' uses 24-hour notation.",
626
- increment: 'Minutes to add/subtract on arrow up/down.',
628
+ increment:
629
+ 'Minutes to add/subtract on arrow up/down. Whole minutes only; a fractional value leaves the time unchanged.',
627
630
  presentation:
628
631
  "picker surface: 'adaptive-native' (default) = typed field fine / browser/OS coarse (hasSeconds or increment!=1 retains typed); 'native' = browser/OS always, no fallback; 'adaptive-bottom-sheet' = typed fine / Astryx wheel sheet coarse; 'bottom-sheet' = sheet always; 'text-input'/'popover' = typed field only.",
629
632
  nativePicker:
@@ -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';
@@ -65,6 +67,26 @@ describe('TimeInput', () => {
65
67
  expect(onChange).toHaveBeenCalledWith('14:31');
66
68
  });
67
69
 
70
+ it('does not step into a fractional minute when increment is fractional', () => {
71
+ const onChange = vi.fn();
72
+ render(
73
+ <TimeInput
74
+ label="Time"
75
+ value={'14:30' as ISOTimeString}
76
+ increment={0.5}
77
+ onChange={onChange}
78
+ />,
79
+ );
80
+ const input = screen.getByLabelText('Time');
81
+
82
+ // `increment` is documented as minutes and typed `number`. A fractional one
83
+ // used to reach formatISOTime unrounded, which pads "30.5" to two
84
+ // characters and leaves it, so the arrow key handed the consumer a value
85
+ // that is no longer a time at all.
86
+ fireEvent.keyDown(input, {key: 'ArrowUp'});
87
+ expect(onChange).toHaveBeenCalledWith('14:30');
88
+ });
89
+
68
90
  it('displays formatted time in 12h format', () => {
69
91
  render(
70
92
  <TimeInput
@@ -121,9 +143,41 @@ describe('TimeInput', () => {
121
143
  );
122
144
  });
123
145
 
124
- it('disables input when isDisabled is true', () => {
146
+ it('keeps a disabled input focusable without native disabled (AST-085 FR1)', () => {
147
+ render(<TimeInput label="Time" isDisabled onChange={() => {}} />);
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('__'));
125
176
  render(<TimeInput label="Time" isDisabled onChange={() => {}} />);
126
- expect(screen.getByRole('textbox')).toBeDisabled();
177
+ const wrapper = screen.getByRole('textbox').closest('.astryx-time-input')!;
178
+ for (const name of ringClasses) {
179
+ expect(wrapper).toHaveClass(name);
180
+ }
127
181
  });
128
182
 
129
183
  it('shows clear button when hasClear is true and value exists', () => {
@@ -625,11 +679,89 @@ describe('TimeInput', () => {
625
679
  expect(onChange).not.toHaveBeenCalled();
626
680
  });
627
681
 
628
- it('remains natively disabled when disabled without a reason', () => {
629
- render(<TimeInput label="Time" isDisabled />);
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
+ );
630
756
  const input = screen.getByLabelText('Time');
631
- expect(input).toBeDisabled();
632
- expect(input).not.toHaveAttribute('aria-disabled');
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();
633
765
  });
634
766
 
635
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 field stays focusable (via `aria-disabled`)
211
- * so the reason is discoverable by keyboard and assistive technology.
212
- * Typing and arrow-key adjustment stay blocked.
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` — disabled
215
- * controls don't emit the pointer events an external tooltip needs.
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
  * ```
@@ -286,7 +298,8 @@ export interface TimeInputProps extends Omit<
286
298
  hourFormat?: TimeInputHourFormat;
287
299
 
288
300
  /**
289
- * Increment in minutes when using arrow keys.
301
+ * Increment in minutes when using arrow keys. Steps are whole minutes: a
302
+ * fractional value leaves the time unchanged.
290
303
  * @default 1
291
304
  */
292
305
  increment?: number;
@@ -478,10 +491,10 @@ function TimeField({
478
491
  }
479
492
  }, [announce, inputGroup, status?.message, status?.type]);
480
493
 
481
- // Disabled-reason tooltip. Disabled controls swallow pointer events, so the
482
- // tooltip listeners attach to the input container (which already exists) and
483
- // the input stays perceivable via aria-disabled instead of the disabled
484
- // attribute. Typing is blocked with readOnly and value mutation guards.
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.
485
498
  const showsDisabledMessage = isDisabled && !!disabledMessage;
486
499
  const disabledMessageTooltip = useTooltip({
487
500
  placement: 'above',
@@ -576,8 +589,8 @@ function TimeField({
576
589
  // Handle input text change - update immediately if valid
577
590
  const handleInputChange = useCallback(
578
591
  (e: React.ChangeEvent<HTMLInputElement>) => {
579
- // With a disabledMessage the input drops `disabled` for focusability, so
580
- // guard value mutation explicitly (readOnly also blocks typing).
592
+ // The input is never natively disabled (spec:AST-085 FR1), so guard
593
+ // value mutation explicitly (readOnly also blocks typing).
581
594
  if (isDisabled) {
582
595
  return;
583
596
  }
@@ -612,6 +625,11 @@ function TimeField({
612
625
  if (pendingInput === null) {
613
626
  return;
614
627
  }
628
+ // Disabled: keep the value, drop the draft (spec:AST-085 FR2).
629
+ if (isDisabled) {
630
+ setPendingInput(null);
631
+ return;
632
+ }
615
633
 
616
634
  if (!pendingInput.trim()) {
617
635
  // Empty input clears the value
@@ -632,10 +650,17 @@ function TimeField({
632
650
  // Clear pending input - display will revert to formatted value
633
651
  setPendingInput(null);
634
652
  },
635
- [pendingInput, value, fireChange, hasSeconds, min, max],
653
+ [pendingInput, isDisabled, value, fireChange, hasSeconds, min, max],
636
654
  );
637
655
 
638
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
+
639
664
  const handleInputKeyDown = useCallback(
640
665
  (e: KeyboardEvent<HTMLInputElement>) => {
641
666
  // ArrowUp/ArrowDown step the time and preventDefault; an IME candidate
@@ -645,8 +670,7 @@ function TimeField({
645
670
  if (isImeKeyEvent(e.nativeEvent)) {
646
671
  return;
647
672
  }
648
- // Arrow-key adjustment mutates the value; block it while showing a
649
- // disabled reason (the input keeps focusability via aria-disabled).
673
+ // Arrow-key adjustment mutates the value; block it while disabled.
650
674
  if (isDisabled) {
651
675
  return;
652
676
  }
@@ -743,6 +767,7 @@ function TimeField({
743
767
  inputWrapperStyles.base,
744
768
  sizeStyles[size],
745
769
  isDisabled && inputWrapperStyles.disabled,
770
+ isDisabled && styles.disabledFocusRing,
746
771
  status && inputStatusBorderStyles[status.type],
747
772
  status && !isDisabled && inputStatusHoverShadowStyles[status.type],
748
773
  status && inputStatusFocusWithinStyles[status.type],
@@ -777,7 +802,7 @@ function TimeField({
777
802
  max={max}
778
803
  hourFormat={hourFormat}
779
804
  isEffectivelyDisabled={isDisabled}
780
- hasDisabledMessage={showsDisabledMessage}
805
+ isFocusableDisabled
781
806
  isEffectivelyRequired={isEffectivelyRequired}
782
807
  isBusy={isBusy}
783
808
  statusType={status?.type}
@@ -786,7 +811,8 @@ function TimeField({
786
811
  ...controlProps,
787
812
  onFocus: onFocusProp,
788
813
  onBlur: onBlurProp,
789
- onKeyDown: onKeyDownProp,
814
+ onKeyDown: chainEventHandlers(preventDisabledEnter, onKeyDownProp),
815
+ onClick: isDisabled ? undefined : controlProps.onClick,
790
816
  }}
791
817
  />
792
818
  ) : (
@@ -803,14 +829,17 @@ function TimeField({
803
829
  onChange={handleInputChange}
804
830
  onFocus={chainEventHandlers(handleFocus, onFocusProp)}
805
831
  onBlur={chainEventHandlers(handleBlur, onBlurProp)}
806
- onKeyDown={chainEventHandlers(handleInputKeyDown, onKeyDownProp)}
832
+ onKeyDown={chainEventHandlers(
833
+ preventDisabledEnter,
834
+ handleInputKeyDown,
835
+ onKeyDownProp,
836
+ )}
807
837
  placeholder={displayPlaceholder}
808
- // With a disabledMessage the input keeps focusability via
809
- // aria-disabled so the reason is focus-discoverable; typing and
810
- // arrow-key adjustment are blocked with readOnly and the guards.
811
- disabled={isDisabled && !showsDisabledMessage}
812
- aria-disabled={showsDisabledMessage ? 'true' : undefined}
813
- 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}
814
843
  autoFocus={hasAutoFocus}
815
844
  data-autofocus={hasAutoFocus || undefined}
816
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={chainEventHandlers(openSheet, onClickProp)}
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 && !showsDisabledMessage}
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
  {