@astryxdesign/core 0.7.2 → 0.7.3-canary.1379cb7

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 (35) hide show
  1. package/dist/Selector/SelectorOption.d.ts.map +1 -1
  2. package/dist/Selector/SelectorOption.js +1 -0
  3. package/dist/TimeInput/TimeInput.d.ts +2 -1
  4. package/dist/TimeInput/TimeInput.d.ts.map +1 -1
  5. package/dist/Tooltip/useTooltip.d.ts.map +1 -1
  6. package/dist/Tooltip/useTooltip.js +11 -2
  7. package/dist/Typeahead/Typeahead.d.ts.map +1 -1
  8. package/dist/Typeahead/Typeahead.js +14 -2
  9. package/dist/astryx.css +1 -0
  10. package/dist/utils/timeParser.d.ts +4 -0
  11. package/dist/utils/timeParser.d.ts.map +1 -1
  12. package/dist/utils/timeParser.js +9 -2
  13. package/package.json +4 -4
  14. package/scripts/agent-doc-state.mjs +35 -8
  15. package/src/BottomSheet/BottomSheet.spec.md +13 -6
  16. package/src/BottomSheet/BottomSheetSwitcher.spec.md +121 -51
  17. package/src/DateRangeInput/DateRangeInput.spec.md +13 -13
  18. package/src/Selector/Selector.spec.md +11 -11
  19. package/src/Selector/Selector.test.tsx +16 -0
  20. package/src/Selector/SelectorOption.tsx +4 -0
  21. package/src/Slider/Slider.spec.md +11 -11
  22. package/src/Table/Table.spec.md +2 -0
  23. package/src/Table/plugins/infiniteScroll/useTableInfiniteScroll.spec.md +244 -0
  24. package/src/Table/plugins/virtualization/useTableVirtualization.spec.md +266 -0
  25. package/src/TimeInput/TimeInput.doc.mjs +5 -3
  26. package/src/TimeInput/TimeInput.test.tsx +20 -0
  27. package/src/TimeInput/TimeInput.tsx +2 -1
  28. package/src/Tokenizer/Tokenizer.spec.md +28 -28
  29. package/src/Tooltip/__tests__/TooltipHoverBridge.a11y.chromium.spec.ts +74 -0
  30. package/src/Tooltip/useTooltip.test.tsx +23 -0
  31. package/src/Tooltip/useTooltip.tsx +22 -9
  32. package/src/Typeahead/Typeahead.test.tsx +89 -0
  33. package/src/Typeahead/Typeahead.tsx +15 -2
  34. package/src/utils/timeParser.test.ts +11 -0
  35. package/src/utils/timeParser.ts +9 -2
@@ -212,7 +212,7 @@ export const docs = {
212
212
  name: 'increment',
213
213
  type: 'number',
214
214
  description:
215
- 'Number of minutes to add or subtract when the user presses the up or down arrow key.',
215
+ '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
216
  default: '1',
217
217
  },
218
218
  {
@@ -392,7 +392,8 @@ export const docsZh = {
392
392
  {
393
393
  name: 'increment',
394
394
  type: 'number',
395
- description: '用户按上或下方向键时增加或减少的分钟数。',
395
+ description:
396
+ '用户按上或下方向键时增加或减少的分钟数。步长为整分钟;传入小数时时间保持不变。',
396
397
  default: '1',
397
398
  },
398
399
  {
@@ -623,7 +624,8 @@ export const docsDense = {
623
624
  hasClear: 'Shows clear button when value set+not disabled.',
624
625
  hourFormat:
625
626
  "Display format. '12h' shows AM/PM; '24h' uses 24-hour notation.",
626
- increment: 'Minutes to add/subtract on arrow up/down.',
627
+ increment:
628
+ 'Minutes to add/subtract on arrow up/down. Whole minutes only; a fractional value leaves the time unchanged.',
627
629
  presentation:
628
630
  "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
631
  nativePicker:
@@ -65,6 +65,26 @@ describe('TimeInput', () => {
65
65
  expect(onChange).toHaveBeenCalledWith('14:31');
66
66
  });
67
67
 
68
+ it('does not step into a fractional minute when increment is fractional', () => {
69
+ const onChange = vi.fn();
70
+ render(
71
+ <TimeInput
72
+ label="Time"
73
+ value={'14:30' as ISOTimeString}
74
+ increment={0.5}
75
+ onChange={onChange}
76
+ />,
77
+ );
78
+ const input = screen.getByLabelText('Time');
79
+
80
+ // `increment` is documented as minutes and typed `number`. A fractional one
81
+ // used to reach formatISOTime unrounded, which pads "30.5" to two
82
+ // characters and leaves it, so the arrow key handed the consumer a value
83
+ // that is no longer a time at all.
84
+ fireEvent.keyDown(input, {key: 'ArrowUp'});
85
+ expect(onChange).toHaveBeenCalledWith('14:30');
86
+ });
87
+
68
88
  it('displays formatted time in 12h format', () => {
69
89
  render(
70
90
  <TimeInput
@@ -286,7 +286,8 @@ export interface TimeInputProps extends Omit<
286
286
  hourFormat?: TimeInputHourFormat;
287
287
 
288
288
  /**
289
- * Increment in minutes when using arrow keys.
289
+ * Increment in minutes when using arrow keys. Steps are whole minutes: a
290
+ * fractional value leaves the time unchanged.
290
291
  * @default 1
291
292
  */
292
293
  increment?: number;
@@ -166,23 +166,23 @@ pending.
166
166
 
167
167
  ### Representative states
168
168
 
169
- | State | Required invariant | Allowed variation |
170
- | ------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- |
171
- | Empty field | The mounted inner input is the opening control; a full-width menu stays aligned to the outer field. | Placeholder and bootstrap results may vary. |
172
- | Focus bootstrap, choices remain | A committed selection is removed from the displayed cohort; the input and popup remain active on the next valid remaining choice. | Internal cohort storage and projection may vary. |
173
- | Focus bootstrap, selected choice was last | The nearest preceding remaining choice becomes active without changing the displayed order. | Option identity representation may vary. |
174
- | Focus bootstrap, no choices or max reached | The popup closes and no active descendant remains; pending work cannot reopen it. | Source cancellation mechanism may vary. |
175
- | Source pending or replaced | Busy semantics identify pending work; stale work cannot add choices or reopen a terminal popup. | A settled current cohort may be retained internally. |
176
- | Disabled | No choice can be committed; an open popup closes and clears its active descendant while disabled-reason focusability remains available where configured. | Native-disabled or focusable-disabled representation follows current Tokenizer behavior. |
177
- | One or many tokens | The same input remains after tokens and moves with wrapping; full-width placement does not follow that movement. | Token labels, wrapping rows, and custom content may vary. |
178
- | At `maxEntries` | The input identity remains mounted but its zero-width/hidden box is unusable as geometry; opening falls back to the outer field. | Search remains unavailable under current behavior. |
179
- | Token removal or clear | Input identity survives and current refocus behavior remains. | Token count and input position change. |
180
- | `unfocusedInline` | Blurred OverflowList and focused direct-token layouts resolve the same live input identity; a collapsed input is not used as geometry. | Visible token count may vary with width. |
181
- | `unfocusedLayer`, focused | The live wrapper/input may be reparented into the context layer without descriptor churn. | Corrective portal location may vary. |
182
- | `unfocusedLayer`, unfocused | The hidden-layer input is unusable for a new open; the ordinary-flow outer-field fallback is used. | Placeholder token count may vary. |
183
- | Default or custom token edit | The actual activated token/control is captured before edit state changes; if no usable descendant control exists, input then outer-field fallback applies. | Custom token markup may vary. |
184
- | LTR or RTL | Physical proximity selects an edge only for a narrower surface; logical start wins ties and full-width placement. | Collision may move the final rendered surface. |
185
- | SSR, hydration, or unmount | No server geometry read occurs; first client open prepares normally; unmount clears identities and pending show work. | Hydration timing may vary. |
169
+ | State | Required invariant | Allowed variation |
170
+ | ------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------- |
171
+ | Empty field | The mounted inner input is the opening control; a full-width menu stays aligned to the outer field. | Placeholder and bootstrap results may vary. |
172
+ | Focus bootstrap, choices remain | A committed selection is removed from the displayed cohort; the input and popup remain active on the next valid remaining choice. | Internal cohort storage and projection may vary. |
173
+ | Focus bootstrap, selected choice was last | The nearest preceding remaining choice becomes active without changing the displayed order. | Option identity representation may vary. |
174
+ | Focus bootstrap, no choices or max reached | The popup closes and no active descendant remains; pending work cannot reopen it. | Source cancellation mechanism may vary. |
175
+ | Source pending or replaced | Busy semantics identify pending work; stale work cannot add choices or reopen a terminal popup. | A settled current cohort may be retained internally. |
176
+ | Disabled | No choice can be committed; an open popup closes and clears its active descendant while disabled-reason focusability remains available where configured. | The disabled representation follows `spec:AST-085`. |
177
+ | One or many tokens | The same input remains after tokens and moves with wrapping; full-width placement does not follow that movement. | Token labels, wrapping rows, and custom content may vary. |
178
+ | At `maxEntries` | The input identity remains mounted but its zero-width/hidden box is unusable as geometry; opening falls back to the outer field. | Search remains unavailable under current behavior. |
179
+ | Token removal or clear | Input identity survives and current refocus behavior remains. | Token count and input position change. |
180
+ | `unfocusedInline` | Blurred OverflowList and focused direct-token layouts resolve the same live input identity; a collapsed input is not used as geometry. | Visible token count may vary with width. |
181
+ | `unfocusedLayer`, focused | The live wrapper/input may be reparented into the context layer without descriptor churn. | Corrective portal location may vary. |
182
+ | `unfocusedLayer`, unfocused | The hidden-layer input is unusable for a new open; the ordinary-flow outer-field fallback is used. | Placeholder token count may vary. |
183
+ | Default or custom token edit | The actual activated token/control is captured before edit state changes; if no usable descendant control exists, input then outer-field fallback applies. | Custom token markup may vary. |
184
+ | LTR or RTL | Physical proximity selects an edge only for a narrower surface; logical start wins ties and full-width placement. | Collision may move the final rendered surface. |
185
+ | SSR, hydration, or unmount | No server geometry read occurs; first client open prepares normally; unmount clears identities and pending show work. | Hydration timing may vary. |
186
186
 
187
187
  ### Transformation and precedence order
188
188
 
@@ -282,8 +282,8 @@ consumer docs, and Changeset together. Exact-head browser evidence must exercise
282
282
  next remaining option active.
283
283
  2. The fallback when the committed option was last, plus terminal closure for no
284
284
  remaining choice and `maxEntries`.
285
- 3. Escape, Tab or outside focus departure, transition to native-disabled and
286
- focusable-disabled states, and pending or stale source settlement without a
285
+ 3. Escape, Tab or outside focus departure, transition to the disabled state,
286
+ and pending or stale source settlement without a
287
287
  later reopen.
288
288
  4. The unchanged path when `hasEntriesOnFocus` is off and direct single-select
289
289
  BaseTypeahead selection still closes.
@@ -297,15 +297,15 @@ become Tokenizer policy.
297
297
 
298
298
  ## Verification map
299
299
 
300
- | Contract | Verification | Representative states | Mutation or failure expectation | Audit section |
301
- | ---------------- | ---------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------- |
302
- | FR1–FR3 | Existing Tokenizer/BaseTypeahead unit tests plus source inspection | empty, tokens, remove, clear, max, wrapping, both overflow modes | Input remounts, suggestion menu changes anchor/width semantics, or overflow ownership is misstated. | `audit:Tokenizer/current` |
303
- | FR4–FR8 | Focused descriptor identity/capture tests when implemented | add, default/custom token edit, max, hidden layer, reparent, stale node, unmount, SSR | Descriptor leaks publicly, captures a transient result/pointer, misses custom controls, or uses unusable geometry. | `audit:Tokenizer/opening-anchor` |
304
- | FR9–FR12 | Layer unit tests and real-Chromium geometry checks when implemented | full/equal/narrow surface, start/end/tie, LTR/RTL, collision, stale fallback | Full-width follows inner input, tie is physical, collision runs early, or stale identity flashes. | `audit:Tokenizer/placement` |
305
- | FR13–FR15, AR5 | Focused Tokenizer/BaseTypeahead tests and exact-head Chromium interaction evidence | pointer/keyboard commit, first/middle/last choice, rejected/deferred control, loading, disabled, max, exhaustion, Escape, Tab, outside focus | Selected values remain available, order changes, focus leaves the combobox, active descendant names no option, stale work reopens, or a terminal state stays interactive. | `audit:Tokenizer/multi-select` |
306
- | PR1–PR5 | Instrumented reads, pending-frame cleanup tests, and first-frame Chromium capture | open, close before frame, reopen opposite side, resize while open | Render-time/repeated reads, observer/pointer tracking, uncancelled frame, or first-frame jump occurs. | `audit:Tokenizer/performance` |
307
- | AR1–AR5 | Existing interaction suites plus focused pointer/keyboard/browser checks | add/edit, focus/refocus, overflow, disabled, dismissal, retained bootstrap selection | Modality changes placement, semantics/focus regress, active descendant becomes invalid, or visible placement is wrong before fallback. | `audit:Tokenizer/accessibility` |
308
- | Record structure | `scripts/check-knowledge.mjs` | Template-v3 current record and current-only frontmatter links | Invalid metadata, authority, or relationship references fail repository validation. | `audit:Tokenizer/knowledge` |
300
+ | Contract | Verification | Representative states | Mutation or failure expectation | Audit section |
301
+ | ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------- |
302
+ | FR1–FR3 | Existing Tokenizer/BaseTypeahead unit tests plus source inspection | empty, tokens, remove, clear, max, wrapping, both overflow modes | Input remounts, suggestion menu changes anchor/width semantics, or overflow ownership is misstated. | `audit:Tokenizer/current` |
303
+ | FR4–FR8 | Focused descriptor identity/capture tests when implemented | add, default/custom token edit, max, hidden layer, reparent, stale node, unmount, SSR | Descriptor leaks publicly, captures a transient result/pointer, misses custom controls, or uses unusable geometry. | `audit:Tokenizer/opening-anchor` |
304
+ | FR9–FR12 | Layer unit tests and real-Chromium geometry checks when implemented | full/equal/narrow surface, start/end/tie, LTR/RTL, collision, stale fallback | Full-width follows inner input, tie is physical, collision runs early, or stale identity flashes. | `audit:Tokenizer/placement` |
305
+ | FR13–FR15, AR5 | Focused Tokenizer/BaseTypeahead tests and exact-head Chromium interaction evidence | pointer/keyboard commit, first/middle/last choice, rejected/deferred control, loading, disabled, max, exhaustion, Escape, Tab, outside focus | Selected values remain available, order changes, focus leaves the combobox, active descendant names no option, stale work reopens, or a terminal state stays interactive. | `audit:Tokenizer/multi-select` |
306
+ | PR1–PR5 | Instrumented reads, pending-frame cleanup tests, and first-frame Chromium capture | open, close before frame, reopen opposite side, resize while open | Render-time/repeated reads, observer/pointer tracking, uncancelled frame, or first-frame jump occurs. | `audit:Tokenizer/performance` |
307
+ | AR1–AR5 | Existing interaction suites plus focused pointer/keyboard/browser checks; native-disabled assertions pending conformance (`spec:AST-085`) | add/edit, focus/refocus, overflow, disabled, dismissal, retained bootstrap selection | Modality changes placement, semantics/focus regress, active descendant becomes invalid, or visible placement is wrong before fallback. | `audit:Tokenizer/accessibility` |
308
+ | Record structure | `scripts/check-knowledge.mjs` | Template-v3 current record and current-only frontmatter links | Invalid metadata, authority, or relationship references fail repository validation. | `audit:Tokenizer/knowledge` |
309
309
 
310
310
  ## Decision log
311
311
 
@@ -0,0 +1,74 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file TooltipHoverBridge.a11y.chromium.spec.ts
5
+ * @input The `Core/Tooltip` Default story in a built Storybook and real mouse
6
+ * input.
7
+ * @output Real-engine proof that a pointer moving from the trigger onto the
8
+ * tooltip in one step keeps the tooltip open (WCAG 2.2 1.4.13 Content on
9
+ * Hover or Focus, hoverable), and that leaving the tooltip still hides it.
10
+ * @position Run with `pnpm test:a11y-contract`. The defect lives in the order
11
+ * the engine dispatches mouseout and mouseleave on one pointer step, which
12
+ * only a real engine produces.
13
+ */
14
+
15
+ import {expect, test, type Page} from '@playwright/test';
16
+ import {holdMotionStill} from '@astryxdesign/a11y-spec/chromium';
17
+ import {
18
+ DEFAULT_STORYBOOK_DIR,
19
+ serveStorybook,
20
+ type StaticServer,
21
+ } from '@astryxdesign/a11y-spec/storybook';
22
+
23
+ let storybook: StaticServer;
24
+
25
+ test.beforeAll(async () => {
26
+ storybook = await serveStorybook(
27
+ process.env.ASTRYX_STORYBOOK_DIR ?? DEFAULT_STORYBOOK_DIR,
28
+ );
29
+ });
30
+
31
+ test.afterAll(async () => {
32
+ await storybook?.close();
33
+ });
34
+
35
+ const STORY = 'core-tooltip--default';
36
+
37
+ async function openTooltip(page: Page) {
38
+ await page.setViewportSize({width: 900, height: 600});
39
+ await page.goto(`${storybook.origin}/iframe.html?id=${STORY}&viewMode=story`);
40
+ await holdMotionStill(page);
41
+ const trigger = page.getByRole('button', {name: 'Hover me'});
42
+ await trigger.hover();
43
+ const tooltip = page.getByRole('tooltip');
44
+ await expect(tooltip).toBeVisible();
45
+ const t = await trigger.boundingBox();
46
+ const s = await tooltip.boundingBox();
47
+ if (t == null || s == null) {
48
+ throw new Error('the trigger or the tooltip has no layout box');
49
+ }
50
+ return {tooltip, s};
51
+ }
52
+
53
+ test('a one-step move from the trigger onto the tooltip keeps it open', async ({
54
+ page,
55
+ }) => {
56
+ const {tooltip, s} = await openTooltip(page);
57
+
58
+ // One mousemove event straight onto the tooltip, as a quick hand makes.
59
+ await page.mouse.move(s.x + s.width / 2, s.y + s.height / 2);
60
+ // Well past the 100ms hover bridge.
61
+ await page.waitForTimeout(400);
62
+
63
+ await expect(tooltip).toBeVisible();
64
+ });
65
+
66
+ test('leaving the tooltip for the page still hides it', async ({page}) => {
67
+ const {tooltip, s} = await openTooltip(page);
68
+
69
+ await page.mouse.move(s.x + s.width / 2, s.y + s.height / 2);
70
+ await page.waitForTimeout(200);
71
+ await page.mouse.move(5, 5);
72
+
73
+ await expect(tooltip).toBeHidden();
74
+ });
@@ -214,6 +214,29 @@ describe('useTooltip — hide delay', () => {
214
214
  expect(onHide).toHaveBeenCalledTimes(1);
215
215
  });
216
216
 
217
+ it('stays open when the pointer crosses onto the tooltip in one step', () => {
218
+ const onHide = vi.fn();
219
+ showTooltip(onHide);
220
+ const surface = tooltipElement();
221
+
222
+ // A one-step crossing dispatches in browser order: the trigger's mouseout
223
+ // (React derives the surface's onMouseEnter from it), then the trigger's
224
+ // native mouseleave, both pointing at the surface. fireEvent.mouseLeave
225
+ // would add a mouseout after the leave, so the leave is dispatched raw.
226
+ fireEvent.mouseOut(trigger(), {relatedTarget: surface});
227
+ act(() => {
228
+ trigger().dispatchEvent(
229
+ new MouseEvent('mouseleave', {relatedTarget: surface}),
230
+ );
231
+ });
232
+ tick(500);
233
+ expect(onHide).not.toHaveBeenCalled();
234
+
235
+ fireEvent.mouseOut(surface, {relatedTarget: document.body});
236
+ tick(100);
237
+ expect(onHide).toHaveBeenCalledTimes(1);
238
+ });
239
+
217
240
  it('uses an explicit hideDelay instead of the hover bridge', () => {
218
241
  const onHide = vi.fn();
219
242
  showTooltip(onHide, {hideDelay: 500});
@@ -397,15 +397,28 @@ export function useTooltip(options: TooltipOptions = {}): TooltipReturn {
397
397
  scheduleShow();
398
398
  }, [isTouchPointerRef, scheduleShow]);
399
399
 
400
- const handleMouseLeave = useCallback(() => {
401
- // On touch the synthesized mouseleave arrives with the next tap elsewhere,
402
- // which the outside-tap dismissal already handles — and handling it here
403
- // too would close the tooltip behind the tap-open bookkeeping's back.
404
- if (isTouchPointerRef.current) {
405
- return;
406
- }
407
- scheduleHide();
408
- }, [isTouchPointerRef, scheduleHide]);
400
+ const handleMouseLeave = useCallback(
401
+ (event: Event) => {
402
+ // On touch the synthesized mouseleave arrives with the next tap elsewhere,
403
+ // which the outside-tap dismissal already handles — and handling it here
404
+ // too would close the tooltip behind the tap-open bookkeeping's back.
405
+ if (isTouchPointerRef.current) {
406
+ return;
407
+ }
408
+ // Leaving straight onto the tooltip: the surface's onMouseEnter already
409
+ // ran, because React dispatches it from the mouseout that precedes this
410
+ // mouseleave, so a hide scheduled now would never be cancelled. The
411
+ // surface's own mouse leave schedules the hide instead.
412
+ const next = (event as MouseEvent).relatedTarget as Node | null;
413
+ const surface =
414
+ next == null ? null : next.ownerDocument?.getElementById(layerId);
415
+ if (surface != null && surface.contains(next)) {
416
+ return;
417
+ }
418
+ scheduleHide();
419
+ },
420
+ [isTouchPointerRef, layerId, scheduleHide],
421
+ );
409
422
 
410
423
  const handleFocusIn = useCallback(
411
424
  (e: Event) => {
@@ -1637,6 +1637,95 @@ describe('Typeahead edit mode', () => {
1637
1637
  expect(onChange).not.toHaveBeenCalled();
1638
1638
  });
1639
1639
 
1640
+ it('empties the input when cleared while editing the selected value (#7038)', async () => {
1641
+ const onChangeQuery = vi.fn();
1642
+ function Controlled() {
1643
+ const [value, setValue] = useState<SearchableItem | null>(fruits[0]);
1644
+ return (
1645
+ <Typeahead
1646
+ label="Fruit"
1647
+ searchSource={fruitSource}
1648
+ value={value}
1649
+ onChange={setValue}
1650
+ onChangeQuery={onChangeQuery}
1651
+ hasClear
1652
+ />
1653
+ );
1654
+ }
1655
+ render(<Controlled />);
1656
+ const input = screen.getByRole<HTMLInputElement>('combobox');
1657
+
1658
+ // Enter edit mode: the token gives way to the label as editable text
1659
+ fireEvent.click(screen.getByText(fruits[0].label).closest('div')!);
1660
+ await waitFor(() => expect(input.value).toBe(fruits[0].label));
1661
+
1662
+ fireEvent.click(screen.getByRole('button', {name: 'Clear selection'}));
1663
+
1664
+ // The value and the visible text agree: nothing selected, nothing typed
1665
+ await waitFor(() => expect(input.value).toBe(''));
1666
+ expect(onChangeQuery).toHaveBeenLastCalledWith('');
1667
+ expect(input).toHaveFocus();
1668
+ });
1669
+
1670
+ // Escape and blur restore the token but leave the edit-mode label in the
1671
+ // base's query, so a clear from the restored token faces the same stale text.
1672
+ it.each([
1673
+ [
1674
+ 'Escape',
1675
+ (input: HTMLElement) => fireEvent.keyDown(input, {key: 'Escape'}),
1676
+ ],
1677
+ ['blur', (input: HTMLElement) => fireEvent.blur(input)],
1678
+ ])(
1679
+ 'empties the input when cleared after %s restored the token (#7038)',
1680
+ async (_name, leaveEditMode) => {
1681
+ const onChangeQuery = vi.fn();
1682
+ function Controlled() {
1683
+ const [value, setValue] = useState<SearchableItem | null>(fruits[0]);
1684
+ return (
1685
+ <Typeahead
1686
+ label="Fruit"
1687
+ searchSource={fruitSource}
1688
+ value={value}
1689
+ onChange={setValue}
1690
+ onChangeQuery={onChangeQuery}
1691
+ hasClear
1692
+ />
1693
+ );
1694
+ }
1695
+ render(<Controlled />);
1696
+ const input = screen.getByRole<HTMLInputElement>('combobox');
1697
+
1698
+ fireEvent.click(screen.getByText(fruits[0].label).closest('div')!);
1699
+ await waitFor(() => expect(input.value).toBe(fruits[0].label));
1700
+
1701
+ leaveEditMode(input);
1702
+ await screen.findByRole('button', {name: 'Clear selection'});
1703
+
1704
+ fireEvent.click(screen.getByRole('button', {name: 'Clear selection'}));
1705
+
1706
+ await waitFor(() => expect(input.value).toBe(''));
1707
+ expect(onChangeQuery).toHaveBeenLastCalledWith('');
1708
+ },
1709
+ );
1710
+
1711
+ it('does not report a query change when cleared outside edit mode', () => {
1712
+ const onChangeQuery = vi.fn();
1713
+ render(
1714
+ <Typeahead
1715
+ label="Fruit"
1716
+ searchSource={fruitSource}
1717
+ value={fruits[0]}
1718
+ onChange={vi.fn()}
1719
+ onChangeQuery={onChangeQuery}
1720
+ hasClear
1721
+ />,
1722
+ );
1723
+
1724
+ fireEvent.click(screen.getByRole('button', {name: 'Clear selection'}));
1725
+
1726
+ expect(onChangeQuery).not.toHaveBeenCalled();
1727
+ });
1728
+
1640
1729
  it('restores token on blur without action', async () => {
1641
1730
  const onChange = vi.fn();
1642
1731
  render(
@@ -519,12 +519,25 @@ export function Typeahead<T extends SearchableItem>({
519
519
  [onChange],
520
520
  );
521
521
 
522
- // Handle clear (explicit X button on token)
522
+ // Handle clear (explicit X button on token). Edit mode copied the label into
523
+ // the base's query, and the base only resets that query on a selection, so
524
+ // clearing the value has to empty it the way entering edit mode filled it
525
+ // (#7038): a native set plus a synthetic input event, which reaches the
526
+ // base's own change handler and its onChangeQuery.
523
527
  const handleClear = useCallback(() => {
528
+ const input = inputRef.current;
529
+ if (input && input.value !== '') {
530
+ const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
531
+ window.HTMLInputElement.prototype,
532
+ 'value',
533
+ )?.set;
534
+ nativeInputValueSetter?.call(input, '');
535
+ input.dispatchEvent(new Event('input', {bubbles: true}));
536
+ }
524
537
  setIsEditing(false);
525
538
  setEditingValue(null);
526
539
  onChange(null);
527
- inputRef.current?.focus();
540
+ input?.focus();
528
541
  }, [onChange]);
529
542
 
530
543
  // Handle Escape during edit mode — restore token
@@ -260,6 +260,17 @@ describe('adjustTime', () => {
260
260
  expect(adjustTime('10:00' as ISOTimeString, NaN)).toBe('10:00');
261
261
  });
262
262
 
263
+ it('returns the input unchanged for a fractional delta', () => {
264
+ // `increment` is documented as minutes and typed `number`, so a caller can
265
+ // hand ArrowUp a fraction. A fractional minute reaching formatISOTime is
266
+ // not rounded: it pads to "30.5" and the field is left holding a string
267
+ // that is no longer a parseable time.
268
+ expect(adjustTime('10:30' as ISOTimeString, 0.5)).toBe('10:30');
269
+ expect(adjustTime('10:30:45' as ISOTimeString, -0.5, true)).toBe(
270
+ '10:30:45',
271
+ );
272
+ });
273
+
263
274
  it('wraps large negative deltas in constant time', () => {
264
275
  expect(adjustTime('10:00' as ISOTimeString, -1440 * 1e7)).toBe('10:00');
265
276
  expect(adjustTime('10:00' as ISOTimeString, -1440 * 1e7 - 30)).toBe(
@@ -372,6 +372,10 @@ export function clampTime(
372
372
 
373
373
  /**
374
374
  * Adjusts a time by adding/subtracting minutes.
375
+ *
376
+ * `deltaMinutes` is a whole number of minutes. A fractional delta leaves the
377
+ * time unchanged rather than returning a value that is no longer a time —
378
+ * `HH:MM` has no place for a fraction of a minute.
375
379
  */
376
380
  export function adjustTime(
377
381
  time: ISOTimeString,
@@ -384,8 +388,11 @@ export function adjustTime(
384
388
  }
385
389
 
386
390
  // A non-finite delta would spin the wrap-around forever (-Infinity) or
387
- // produce "NaN:NaN" (NaN) — return the input unchanged instead.
388
- if (!Number.isFinite(deltaMinutes)) {
391
+ // produce "NaN:NaN" (NaN), and a fractional one is never rounded on the way
392
+ // out — formatISOTime pads "30.5" to two characters and leaves it, so the
393
+ // caller gets back a value that is no longer a time. Steps are whole
394
+ // minutes: return the input unchanged instead.
395
+ if (!Number.isInteger(deltaMinutes)) {
389
396
  return time;
390
397
  }
391
398