@astryxdesign/core 0.7.7-canary.f9ac85f → 0.7.7

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 (198) hide show
  1. package/CHANGELOG.md +104 -0
  2. package/dist/AppShell/useCloseMobileDrawerOnActivate.d.ts +13 -0
  3. package/dist/AppShell/useCloseMobileDrawerOnActivate.d.ts.map +1 -0
  4. package/dist/AppShell/useCloseMobileDrawerOnActivate.js +45 -0
  5. package/dist/BottomSheet/BottomSheet.d.ts.map +1 -1
  6. package/dist/BottomSheet/BottomSheet.js +79 -9
  7. package/dist/BottomSheet/BottomSheetPanel.d.ts.map +1 -1
  8. package/dist/BottomSheet/BottomSheetPanel.js +1 -1
  9. package/dist/BottomSheet/bottomSheetStack.d.ts.map +1 -1
  10. package/dist/BottomSheet/bottomSheetStack.js +5 -4
  11. package/dist/Breadcrumbs/BreadcrumbItem.d.ts +2 -1
  12. package/dist/Breadcrumbs/BreadcrumbItem.d.ts.map +1 -1
  13. package/dist/Breadcrumbs/BreadcrumbItem.js +7 -3
  14. package/dist/Button/Button.d.ts.map +1 -1
  15. package/dist/Button/Button.js +30 -2
  16. package/dist/CheckboxList/CheckboxList.d.ts.map +1 -1
  17. package/dist/CheckboxList/CheckboxList.js +7 -1
  18. package/dist/CheckboxList/CheckboxListContext.d.ts +6 -0
  19. package/dist/CheckboxList/CheckboxListContext.d.ts.map +1 -1
  20. package/dist/CheckboxList/CheckboxListItem.d.ts.map +1 -1
  21. package/dist/CheckboxList/CheckboxListItem.js +10 -2
  22. package/dist/DateInput/DateInput.d.ts.map +1 -1
  23. package/dist/DateInput/DateInput.js +5 -2
  24. package/dist/DateInput/MonthScroller.d.ts.map +1 -1
  25. package/dist/DateInput/MonthScroller.js +13 -27
  26. package/dist/DateInput/Wheel.d.ts.map +1 -1
  27. package/dist/DateInput/Wheel.js +22 -41
  28. package/dist/DateTimeInput/DateTimeInput.d.ts.map +1 -1
  29. package/dist/DateTimeInput/DateTimeInput.js +5 -2
  30. package/dist/Dialog/Dialog.d.ts.map +1 -1
  31. package/dist/Dialog/Dialog.js +4 -3
  32. package/dist/Drawer/Drawer.js +0 -1
  33. package/dist/Drawer/useDrawerDialogPresence.d.ts.map +1 -1
  34. package/dist/Drawer/useDrawerDialogPresence.js +5 -1
  35. package/dist/Field/PanelSearchInput.d.ts +9 -1
  36. package/dist/Field/PanelSearchInput.d.ts.map +1 -1
  37. package/dist/Field/PanelSearchInput.js +3 -1
  38. package/dist/InfoTip/InfoTip.d.ts +2 -2
  39. package/dist/InfoTip/InfoTip.js +4 -4
  40. package/dist/Layer/layerAnimations.stylex.d.ts +7 -8
  41. package/dist/Layer/layerAnimations.stylex.d.ts.map +1 -1
  42. package/dist/Layer/layerAnimations.stylex.js +25 -20
  43. package/dist/Layer/usePressPin.d.ts +3 -5
  44. package/dist/Layer/usePressPin.d.ts.map +1 -1
  45. package/dist/Layer/usePressPin.js +4 -6
  46. package/dist/MobileNav/MobileNav.js +0 -1
  47. package/dist/MultiSelector/hooks.d.ts.map +1 -1
  48. package/dist/MultiSelector/hooks.js +2 -4
  49. package/dist/Outline/Outline.d.ts +2 -4
  50. package/dist/Outline/Outline.d.ts.map +1 -1
  51. package/dist/Outline/Outline.js +4 -52
  52. package/dist/Outline/useOutlineFromMarkdown.d.ts.map +1 -1
  53. package/dist/Outline/useOutlineFromMarkdown.js +1 -0
  54. package/dist/Outline/useScrollSpy.js +3 -3
  55. package/dist/PowerSearch/PowerSearch.d.ts.map +1 -1
  56. package/dist/PowerSearch/PowerSearch.js +134 -179
  57. package/dist/RadioList/RadioList.d.ts.map +1 -1
  58. package/dist/RadioList/RadioList.js +5 -0
  59. package/dist/Resizable/ResizeHandle.d.ts.map +1 -1
  60. package/dist/Resizable/ResizeHandle.js +118 -260
  61. package/dist/Resizable/resizeGesture.d.ts +91 -0
  62. package/dist/Resizable/resizeGesture.d.ts.map +1 -0
  63. package/dist/Resizable/resizeGesture.js +251 -0
  64. package/dist/SideNav/SideNavItem.d.ts.map +1 -1
  65. package/dist/SideNav/SideNavItem.js +3 -9
  66. package/dist/Token/Token.d.ts +2 -2
  67. package/dist/Token/Token.d.ts.map +1 -1
  68. package/dist/Token/Token.js +12 -4
  69. package/dist/Token/TokenNamingContext.d.ts +14 -0
  70. package/dist/Token/TokenNamingContext.d.ts.map +1 -0
  71. package/dist/Token/TokenNamingContext.js +23 -0
  72. package/dist/Tokenizer/Tokenizer.d.ts +46 -59
  73. package/dist/Tokenizer/Tokenizer.d.ts.map +1 -1
  74. package/dist/Tokenizer/Tokenizer.js +35 -58
  75. package/dist/Tokenizer/TokenizerBottomSheet.d.ts +25 -28
  76. package/dist/Tokenizer/TokenizerBottomSheet.d.ts.map +1 -1
  77. package/dist/Tokenizer/TokenizerBottomSheet.js +157 -54
  78. package/dist/Tokenizer/TokenizerItemRemovableContext.d.ts.map +1 -1
  79. package/dist/Tokenizer/TokenizerItemRemovableContext.js +5 -3
  80. package/dist/Tooltip/Tooltip.d.ts +25 -30
  81. package/dist/Tooltip/Tooltip.d.ts.map +1 -1
  82. package/dist/Tooltip/Tooltip.js +24 -5
  83. package/dist/Tooltip/index.d.ts +1 -1
  84. package/dist/Tooltip/index.d.ts.map +1 -1
  85. package/dist/Tooltip/useTooltip.d.ts +28 -29
  86. package/dist/Tooltip/useTooltip.d.ts.map +1 -1
  87. package/dist/Tooltip/useTooltip.js +20 -8
  88. package/dist/TopNav/TopNavItem.d.ts.map +1 -1
  89. package/dist/TopNav/TopNavItem.js +4 -7
  90. package/dist/TopNav/TopNavMegaMenuItem.d.ts.map +1 -1
  91. package/dist/TopNav/TopNavMegaMenuItem.js +4 -7
  92. package/dist/TopNav/TopNavMenu.d.ts.map +1 -1
  93. package/dist/TopNav/TopNavMenu.js +4 -7
  94. package/dist/astryx.css +5 -5
  95. package/dist/gestures/GestureHost.d.ts +27 -1
  96. package/dist/gestures/GestureHost.d.ts.map +1 -1
  97. package/dist/gestures/GestureHost.js +90 -8
  98. package/dist/hooks/useHighlightedOptionScroll.d.ts +11 -1
  99. package/dist/hooks/useHighlightedOptionScroll.d.ts.map +1 -1
  100. package/dist/hooks/useHighlightedOptionScroll.js +103 -5
  101. package/dist/hooks/useMenuPress.d.ts.map +1 -1
  102. package/dist/hooks/useMenuPress.js +4 -2
  103. package/dist/i18n/generated-english/PowerSearch/PowerSearch.generated.d.ts.map +1 -1
  104. package/dist/i18n/generated-english/PowerSearch/PowerSearch.generated.js +1 -0
  105. package/dist/i18n/generated-english/Tokenizer/TokenizerBottomSheet.generated.d.ts.map +1 -1
  106. package/dist/i18n/generated-english/Tokenizer/TokenizerBottomSheet.generated.js +7 -1
  107. package/dist/i18n/generated-locales/en.generated.d.ts.map +1 -1
  108. package/dist/i18n/generated-locales/en.generated.js +1 -0
  109. package/dist/i18n/generated-locales/pseudo.generated.d.ts.map +1 -1
  110. package/dist/i18n/generated-locales/pseudo.generated.js +1 -0
  111. package/dist/internal/SheetConfirmation.d.ts +11 -0
  112. package/dist/internal/SheetConfirmation.d.ts.map +1 -0
  113. package/dist/internal/SheetConfirmation.js +92 -0
  114. package/dist/internal/sheetDraft.d.ts +35 -0
  115. package/dist/internal/sheetDraft.d.ts.map +1 -0
  116. package/dist/internal/sheetDraft.js +65 -0
  117. package/dist/internal/touch-sheet.d.ts +21 -0
  118. package/dist/internal/touch-sheet.d.ts.map +1 -0
  119. package/dist/internal/touch-sheet.js +19 -0
  120. package/dist/internal/useDismissSearchOnTouchDrag.d.ts +24 -0
  121. package/dist/internal/useDismissSearchOnTouchDrag.d.ts.map +1 -0
  122. package/dist/internal/useDismissSearchOnTouchDrag.js +56 -0
  123. package/dist/motion/reducedMotion.d.ts +24 -0
  124. package/dist/motion/reducedMotion.d.ts.map +1 -1
  125. package/dist/motion/reducedMotion.js +78 -1
  126. package/dist/utils/clickGuard.d.ts +8 -1
  127. package/dist/utils/clickGuard.d.ts.map +1 -1
  128. package/dist/utils/clickGuard.js +24 -1
  129. package/locales/en.json +4 -0
  130. package/locales/pseudo.json +3 -0
  131. package/package.json +9 -9
  132. package/src/AppShell/useCloseMobileDrawerOnActivate.ts +52 -0
  133. package/src/BottomSheet/BottomSheet.tsx +95 -10
  134. package/src/BottomSheet/BottomSheetPanel.tsx +7 -2
  135. package/src/BottomSheet/bottomSheetStack.ts +8 -4
  136. package/src/Breadcrumbs/BreadcrumbItem.tsx +13 -3
  137. package/src/Button/Button.tsx +37 -1
  138. package/src/CheckboxList/CheckboxList.doc.mjs +4 -2
  139. package/src/CheckboxList/CheckboxList.tsx +6 -0
  140. package/src/CheckboxList/CheckboxListContext.tsx +6 -0
  141. package/src/CheckboxList/CheckboxListItem.tsx +11 -2
  142. package/src/DateInput/DateInput.tsx +8 -2
  143. package/src/DateInput/MonthScroller.tsx +18 -22
  144. package/src/DateInput/Wheel.tsx +30 -32
  145. package/src/DateTimeInput/DateTimeInput.tsx +8 -2
  146. package/src/Dialog/Dialog.tsx +19 -4
  147. package/src/Drawer/Drawer.tsx +1 -1
  148. package/src/Drawer/useDrawerDialogPresence.ts +5 -1
  149. package/src/Field/PanelSearchInput.tsx +11 -0
  150. package/src/InfoTip/InfoTip.doc.mjs +3 -3
  151. package/src/InfoTip/InfoTip.tsx +5 -5
  152. package/src/Layer/layerAnimations.stylex.ts +24 -16
  153. package/src/Layer/usePressPin.ts +5 -11
  154. package/src/MobileNav/MobileNav.tsx +1 -1
  155. package/src/MultiSelector/hooks.ts +2 -4
  156. package/src/Outline/Outline.doc.mjs +177 -51
  157. package/src/Outline/Outline.tsx +5 -57
  158. package/src/Outline/useOutlineFromMarkdown.doc.mjs +157 -0
  159. package/src/Outline/useOutlineFromMarkdown.ts +1 -0
  160. package/src/Outline/useScrollSpy.ts +3 -3
  161. package/src/PowerSearch/PowerSearch.doc.mjs +3 -3
  162. package/src/PowerSearch/PowerSearch.tsx +196 -183
  163. package/src/RadioList/RadioList.doc.mjs +4 -2
  164. package/src/RadioList/RadioList.tsx +4 -0
  165. package/src/Resizable/ResizeHandle.tsx +133 -328
  166. package/src/Resizable/resizeGesture.ts +368 -0
  167. package/src/SideNav/SideNavItem.tsx +3 -7
  168. package/src/Token/Token.tsx +13 -4
  169. package/src/Token/TokenNamingContext.ts +28 -0
  170. package/src/Tokenizer/Tokenizer.doc.mjs +16 -0
  171. package/src/Tokenizer/Tokenizer.tsx +81 -86
  172. package/src/Tokenizer/TokenizerBottomSheet.tsx +219 -93
  173. package/src/Tokenizer/TokenizerItemRemovableContext.ts +5 -3
  174. package/src/Tooltip/Tooltip.doc.mjs +123 -54
  175. package/src/Tooltip/Tooltip.tsx +36 -39
  176. package/src/Tooltip/index.ts +0 -1
  177. package/src/Tooltip/useTooltip.doc.mjs +147 -33
  178. package/src/Tooltip/useTooltip.tsx +39 -32
  179. package/src/TopNav/TopNavItem.tsx +6 -5
  180. package/src/TopNav/TopNavMegaMenuItem.tsx +6 -5
  181. package/src/TopNav/TopNavMenu.tsx +6 -5
  182. package/src/gestures/GestureHost.ts +105 -10
  183. package/src/hooks/useHighlightedOptionScroll.ts +113 -9
  184. package/src/hooks/useMenuPress.ts +8 -2
  185. package/src/i18n/generated-english/PowerSearch/PowerSearch.generated.ts +1 -0
  186. package/src/i18n/generated-english/Tokenizer/TokenizerBottomSheet.generated.ts +7 -1
  187. package/src/i18n/generated-locales/en.generated.ts +1 -0
  188. package/src/i18n/generated-locales/pseudo.generated.ts +1 -0
  189. package/src/internal/SheetConfirmation.tsx +101 -0
  190. package/src/internal/sheetDraft.ts +80 -0
  191. package/src/internal/touch-sheet.ts +21 -0
  192. package/src/internal/useDismissSearchOnTouchDrag.ts +69 -0
  193. package/src/motion/reducedMotion.ts +79 -1
  194. package/src/utils/clickGuard.ts +37 -1
  195. package/dist/internal/tokenizer-presentation.d.ts +0 -21
  196. package/dist/internal/tokenizer-presentation.d.ts.map +0 -1
  197. package/dist/internal/tokenizer-presentation.js +0 -21
  198. package/src/internal/tokenizer-presentation.ts +0 -25
@@ -19,6 +19,7 @@
19
19
  */
20
20
 
21
21
  import {
22
+ isValidElement,
22
23
  use,
23
24
  useEffect,
24
25
  useRef,
@@ -44,6 +45,7 @@ import {
44
45
  focusVars,
45
46
  } from '../theme/tokens.stylex';
46
47
  import {Spinner} from '../Spinner/Spinner';
48
+ import {Icon} from '../Icon/Icon';
47
49
  import {VisuallyHidden} from '../VisuallyHidden/VisuallyHidden';
48
50
  import {IconDefaultSizeProvider} from '../Icon/IconDefaultSizeContext';
49
51
  import {iconBoxSizeStyles, type IconSize} from '../Icon/IconSize.stylex';
@@ -559,6 +561,21 @@ const groupStyles = stylex.create({
559
561
  },
560
562
  });
561
563
 
564
+ /**
565
+ * Whether `node` adds nothing to a content name: nothing renders, or an Icon
566
+ * with no label. Any other node can add text.
567
+ */
568
+ function addsNoName(node: ReactNode): boolean {
569
+ if (!isRenderable(node)) {
570
+ return true;
571
+ }
572
+ if (!isValidElement<{label?: unknown}>(node) || node.type !== Icon) {
573
+ return false;
574
+ }
575
+ const {label} = node.props;
576
+ return typeof label !== 'string' || label.trim() === '';
577
+ }
578
+
562
579
  /**
563
580
  * A versatile button component with multiple variants.
564
581
  *
@@ -836,10 +853,29 @@ export function Button({
836
853
  (children != null && children !== label);
837
854
  const ariaLabelProp = needsAriaLabel ? {'aria-label': label} : null;
838
855
 
856
+ // The Button's accessible name when props decide it: the derived
857
+ // aria-label, else a caller aria-label, else the label text alone. It is
858
+ // not known here when a caller aria-labelledby names the Button from other
859
+ // elements, or when `icon` or `endContent` can add text to its content
860
+ // name (a count, for example). A decorative Icon in either slot adds none.
861
+ const accessibleName =
862
+ props['aria-labelledby'] != null
863
+ ? undefined
864
+ : needsAriaLabel
865
+ ? label
866
+ : (props['aria-label'] ??
867
+ (addsNoName(icon) && addsNoName(endContent) ? label : undefined));
868
+ // A tooltip that repeats the name adds nothing to the description, so a
869
+ // screen reader does not read the same words twice.
870
+ const isTooltipName =
871
+ tooltip != null &&
872
+ accessibleName != null &&
873
+ tooltip.trim() === accessibleName.trim();
874
+
839
875
  // When a tooltip is attached via the hook, point aria-describedby at the
840
876
  // tooltip content (composing with any consumer-provided value).
841
877
  const describedByProp =
842
- tooltip != null
878
+ tooltip != null && !isTooltipName
843
879
  ? {
844
880
  'aria-describedby':
845
881
  [props['aria-describedby'], tooltipHook.describedBy]
@@ -178,12 +178,14 @@ export const docs = {
178
178
  {
179
179
  name: 'containerClassName',
180
180
  type: 'string',
181
- description: 'Class name for the field container. Same targeting as `containerXstyle`.',
181
+ description:
182
+ 'Class name for the field container. Same targeting as `containerXstyle`.',
182
183
  },
183
184
  {
184
185
  name: 'containerStyle',
185
186
  type: 'CSSProperties',
186
- description: 'Inline styles for the field container. Same targeting as `containerXstyle`.',
187
+ description:
188
+ 'Inline styles for the field container. Same targeting as `containerXstyle`.',
187
189
  },
188
190
  {
189
191
  name: 'controlXstyle',
@@ -246,6 +246,7 @@ export function CheckboxList({
246
246
  ? disabledMessageTooltip.describedBy
247
247
  : undefined,
248
248
  isReadOnly,
249
+ isInvalid: status?.type === 'error',
249
250
  loadingValues,
250
251
  }),
251
252
  [
@@ -256,6 +257,7 @@ export function CheckboxList({
256
257
  showsDisabledMessage,
257
258
  disabledMessageTooltip.describedBy,
258
259
  isReadOnly,
260
+ status?.type,
259
261
  loadingValues,
260
262
  ],
261
263
  );
@@ -298,6 +300,10 @@ export function CheckboxList({
298
300
  controlStyle,
299
301
  )}
300
302
  role="group"
303
+ // Screen readers announce the whole group as unavailable. Its dimmed
304
+ // label then labels a disabled control. An enabled list keeps a
305
+ // caller's own value.
306
+ aria-disabled={isDisabled || rest['aria-disabled']}
301
307
  // A caller aria-labelledby names the group together with its label.
302
308
  aria-labelledby={joinAriaIDs(labelID, ariaLabelledByProp)}
303
309
  aria-describedby={
@@ -37,6 +37,12 @@ export interface CheckboxListContextValue {
37
37
  */
38
38
  disabledMessageID?: string;
39
39
  isReadOnly: boolean;
40
+ /**
41
+ * True when the list has an error `status`. Each checkbox is then
42
+ * `aria-invalid`. Some screen readers skip a group's invalid state when a
43
+ * checkbox takes focus.
44
+ */
45
+ isInvalid?: boolean;
40
46
  /**
41
47
  * Values of the items whose `changeAction` is pending. Each matching item
42
48
  * renders an in-checkbox spinner and blocks re-toggling; the list is empty
@@ -24,6 +24,7 @@ import {colorVars} from '../theme/tokens.stylex';
24
24
  import type {BaseProps} from '../BaseProps';
25
25
  import {CheckboxInput} from '../CheckboxInput/CheckboxInput';
26
26
  import type {CheckboxInputProps} from '../CheckboxInput/CheckboxInput';
27
+ import type {InputStatus} from '../Field/types';
27
28
  import {ListItem} from '../List/ListItem';
28
29
  import {ListContext} from '../List/ListContext';
29
30
  import {CheckboxListContext} from './CheckboxListContext';
@@ -149,6 +150,8 @@ export interface CheckboxListItemProps extends BaseProps<HTMLLIElement> {
149
150
  // Component
150
151
  // =============================================================================
151
152
 
153
+ const ERROR_STATUS: InputStatus = {type: 'error'};
154
+
152
155
  /**
153
156
  * The row's visible description is the checkbox's accessible description. Item
154
157
  * renders that element and owns its id, and publishes the id through
@@ -167,11 +170,17 @@ function DescribedCheckboxInput(
167
170
  props: Omit<CheckboxInputProps, 'aria-describedby'>,
168
171
  ) {
169
172
  const describedBy = use(ItemDescriptionContext);
170
- const reasonID = use(CheckboxListContext)?.disabledMessageID;
173
+ const listContext = use(CheckboxListContext);
171
174
  return (
172
175
  <CheckboxInput
173
176
  {...props}
174
- aria-describedby={joinAriaIDs(describedBy ?? undefined, reasonID)}
177
+ aria-describedby={joinAriaIDs(
178
+ describedBy ?? undefined,
179
+ listContext?.disabledMessageID,
180
+ )}
181
+ // The status has no message, so CheckboxInput renders nothing. The list
182
+ // renders the message.
183
+ status={listContext?.isInvalid ? ERROR_STATUS : undefined}
175
184
  />
176
185
  );
177
186
  }
@@ -884,12 +884,18 @@ function PointerDateField({
884
884
  if (!isEffectivelyDisabled) {
885
885
  popover.show({skipAutoFocus: true});
886
886
  }
887
- } else if (e.key === 'Enter') {
887
+ } else if (
888
+ e.key === 'Enter' &&
889
+ (pendingInput !== null || isEffectivelyDisabled)
890
+ ) {
891
+ // Typed text holds Enter back, or a form would submit the value from
892
+ // before the commit. A disabled or busy field must not submit its
893
+ // form either. Otherwise Enter keeps its default, as in any text field.
888
894
  e.preventDefault();
889
895
  commitPendingInput();
890
896
  }
891
897
  },
892
- [popover, commitPendingInput, isEffectivelyDisabled],
898
+ [popover, commitPendingInput, isEffectivelyDisabled, pendingInput],
893
899
  );
894
900
 
895
901
  const inputWrapper = (
@@ -38,6 +38,7 @@ import {
38
38
  useRef,
39
39
  useState,
40
40
  } from 'react';
41
+ import {flushSync} from 'react-dom';
41
42
  import * as stylex from '@stylexjs/stylex';
42
43
  import {useCalendarDays} from '../Calendar';
43
44
  import {useDirection, useLocale} from '../i18n';
@@ -62,7 +63,11 @@ import {
62
63
  plainDateToISO,
63
64
  DATE_FORMAT_WITH_WEEKDAY,
64
65
  } from '../utils';
65
- import {reducedMotionFade} from '../motion/reducedMotion';
66
+ import {
67
+ playReducedMotionDip,
68
+ reducedMotionFade,
69
+ type ReducedMotionDip,
70
+ } from '../motion/reducedMotion';
66
71
  import {dateInputTouchSizes, dateInputTouchGeometry} from './tokens.stylex';
67
72
  import {useOwnScrollGesture} from './useOwnScrollGesture';
68
73
  import {useScrollSettle} from './useScrollSettle';
@@ -434,17 +439,12 @@ export function MonthScroller({
434
439
 
435
440
  // The Reduced Motion dip of a step on a touch screen, owned here so a
436
441
  // second step or the unmount clears it.
437
- const dipRef = useRef<{animation: Animation; timer: number} | null>(null);
442
+ const dipRef = useRef<ReducedMotionDip | null>(null);
438
443
  const stopDip = useCallback(() => {
439
- const dip = dipRef.current;
440
- if (dip != null) {
441
- window.clearTimeout(dip.timer);
442
- dip.animation.cancel();
443
- dipRef.current = null;
444
- }
444
+ dipRef.current?.cancel();
445
+ dipRef.current = null;
445
446
  }, []);
446
- // Synchronizes with the dip's timer and animation: neither outlives the
447
- // scroller.
447
+ // Synchronizes with the dip's animations: they do not outlive the scroller.
448
448
  useEffect(() => stopDip, [stopDip]);
449
449
 
450
450
  const scrollToMonth = useCallback(
@@ -505,18 +505,14 @@ export function MonthScroller({
505
505
  land();
506
506
  return;
507
507
  }
508
- // A second step within the dip has replaced the first (above): only
509
- // its scroll lands, at its own dip's middle.
510
- dipRef.current = {
511
- animation: scroller.animate(
512
- [{opacity: 1}, {opacity: 0}, {opacity: 1}],
513
- {duration: fade.duration, easing: fade.easing},
514
- ),
515
- timer: window.setTimeout(() => {
516
- dipRef.current = null;
517
- land();
518
- }, fade.duration / 2),
519
- };
508
+ // A newer step replaces this one: only its scroll lands.
509
+ dipRef.current = playReducedMotionDip(scroller, fade, () => {
510
+ dipRef.current = null;
511
+ // The fade-in starts as soon as this returns. So render now: the
512
+ // scroll lands in the layout effect, before the fade-in.
513
+ // eslint-disable-next-line @eslint-react/dom-no-flush-sync -- the month must change before the dip's fade-in starts
514
+ flushSync(land);
515
+ });
520
516
  return;
521
517
  }
522
518
  steeredRowRef.current = row;
@@ -49,7 +49,11 @@ import {
49
49
  durationVars,
50
50
  } from '../theme/tokens.stylex';
51
51
  import {focusOutlineStyles} from '../utils';
52
- import {reducedMotionFade} from '../motion/reducedMotion';
52
+ import {
53
+ playReducedMotionDip,
54
+ reducedMotionFade,
55
+ type ReducedMotionDip,
56
+ } from '../motion/reducedMotion';
53
57
  import {dateInputTouchSizes, dateInputTouchGeometry} from './tokens.stylex';
54
58
  import {useOwnScrollGesture} from './useOwnScrollGesture';
55
59
  import {usePointerDragScroll} from './usePointerDragScroll';
@@ -331,19 +335,19 @@ export function Wheel({
331
335
  // once on a fine one.
332
336
  const lastPointerRef = useRef<string | null>(null);
333
337
 
334
- // A reduced-motion dip whose rows have not landed yet: its timer and the
335
- // row it lands on. While one is pending the rows stay put for it, so they
336
- // never move at full opacity, and a further step retargets it.
337
- const pendingFadeRef = useRef<{timer: number; top: number} | null>(null);
338
+ // A reduced-motion dip whose rows have not landed yet: the row they land
339
+ // on. While one is pending the rows stay put for it, so they never move at
340
+ // full opacity, and a further step retargets it.
341
+ const pendingFadeRef = useRef<{top: number} | null>(null);
338
342
  // The running dip, so a step during its fade-in starts the next one where
339
343
  // the column is.
340
- const dipRef = useRef<Animation | null>(null);
344
+ const dipRef = useRef<ReducedMotionDip | null>(null);
345
+ // Synchronizes with the dip's animations: an unmounted wheel lands nothing.
341
346
  useEffect(
342
347
  () => () => {
343
- if (pendingFadeRef.current != null) {
344
- window.clearTimeout(pendingFadeRef.current.timer);
345
- pendingFadeRef.current = null;
346
- }
348
+ pendingFadeRef.current = null;
349
+ dipRef.current?.cancel();
350
+ dipRef.current = null;
347
351
  },
348
352
  [],
349
353
  );
@@ -368,19 +372,15 @@ export function Wheel({
368
372
  ? reducedMotionFade(scroller, {touchRelease})
369
373
  : null;
370
374
  const pending = pendingFadeRef.current;
375
+ // A change that lands now replaces a pending landing. Its dip goes on
376
+ // and fades back in.
371
377
  if (fadeTiming == null) {
372
- if (pending != null) {
373
- window.clearTimeout(pending.timer);
374
- pendingFadeRef.current = null;
375
- }
378
+ pendingFadeRef.current = null;
376
379
  scroller.scrollTo({top, behavior});
377
380
  return;
378
381
  }
379
382
  if (fadeTiming.duration === 0 || typeof scroller.animate !== 'function') {
380
- if (pending != null) {
381
- window.clearTimeout(pending.timer);
382
- pendingFadeRef.current = null;
383
- }
383
+ pendingFadeRef.current = null;
384
384
  scroller.scrollTo({top, behavior: 'auto'});
385
385
  return;
386
386
  }
@@ -389,26 +389,24 @@ export function Wheel({
389
389
  pending.top = top;
390
390
  return;
391
391
  }
392
- const {duration, easing} = fadeTiming;
393
392
  // A step during the last dip's fade-in starts the new dip at the
394
393
  // column's current opacity, not at full opacity. A leaving element fades
395
394
  // out from where it is.
396
395
  const shown = Number.parseFloat(getComputedStyle(scroller).opacity);
397
396
  dipRef.current?.cancel();
398
- dipRef.current = scroller.animate(
399
- [
400
- {opacity: Number.isFinite(shown) ? shown : 1, easing},
401
- {opacity: 0, easing},
402
- {opacity: 1},
403
- ],
404
- {duration, easing: 'linear'},
405
- );
406
- const landing = {top, timer: 0};
407
- landing.timer = window.setTimeout(() => {
408
- pendingFadeRef.current = null;
409
- scroller.scrollTo({top: landing.top, behavior: 'auto'});
410
- }, duration / 2);
397
+ const landing = {top};
411
398
  pendingFadeRef.current = landing;
399
+ dipRef.current = playReducedMotionDip(
400
+ scroller,
401
+ fadeTiming,
402
+ () => {
403
+ if (pendingFadeRef.current === landing) {
404
+ pendingFadeRef.current = null;
405
+ scroller.scrollTo({top: landing.top, behavior: 'auto'});
406
+ }
407
+ },
408
+ Number.isFinite(shown) ? shown : 1,
409
+ );
412
410
  },
413
411
  [itemBlockSize],
414
412
  );
@@ -1143,12 +1143,18 @@ function PointerDateTimeField({
1143
1143
  if (!isEffectivelyDisabled) {
1144
1144
  popover.show({skipAutoFocus: true});
1145
1145
  }
1146
- } else if (e.key === 'Enter') {
1146
+ } else if (
1147
+ e.key === 'Enter' &&
1148
+ (datePendingInput !== null || isEffectivelyDisabled)
1149
+ ) {
1150
+ // Typed text holds Enter back, or a form would submit the value from
1151
+ // before the commit. A disabled or busy field must not submit its
1152
+ // form either. Otherwise Enter keeps its default, as in any text field.
1147
1153
  e.preventDefault();
1148
1154
  commitDatePendingInput();
1149
1155
  }
1150
1156
  },
1151
- [popover, commitDatePendingInput, isEffectivelyDisabled],
1157
+ [popover, commitDatePendingInput, isEffectivelyDisabled, datePendingInput],
1152
1158
  );
1153
1159
 
1154
1160
  // --- Time-option popover (#2727) ---
@@ -189,8 +189,22 @@ const styles = stylex.create({
189
189
  position: 'fixed',
190
190
  margin: 'auto',
191
191
  padding: 0,
192
- borderWidth: 0,
193
- borderStyle: 'none',
192
+ // The backdrop does not blur the page. Forced colors override the
193
+ // backdrop's colour, so a 1px border keeps the modal separate from the
194
+ // page. It is a border, not an outline: the focus ring
195
+ // (focusOutlineProps) owns the outline longhands on this element.
196
+ borderWidth: {
197
+ default: 0,
198
+ '@media (forced-colors: active)': 1,
199
+ },
200
+ borderStyle: {
201
+ default: 'none',
202
+ '@media (forced-colors: active)': 'solid',
203
+ },
204
+ borderColor: {
205
+ default: null,
206
+ '@media (forced-colors: active)': 'CanvasText',
207
+ },
194
208
  backgroundColor: colorVars['--color-background-surface'],
195
209
  '--_dialog-radius': radiusVars['--radius-container'],
196
210
  borderRadius: 'var(--_dialog-radius)',
@@ -236,11 +250,10 @@ const styles = stylex.create({
236
250
  openSettled: {
237
251
  animationName: 'none',
238
252
  },
239
- // Backdrop using ::backdrop pseudo-element
253
+ // No backdrop-filter: a full-viewport blur is costly to repaint.
240
254
  backdrop: {
241
255
  '::backdrop': {
242
256
  backgroundColor: colorVars['--color-overlay'],
243
- backdropFilter: 'blur(2px)',
244
257
  },
245
258
  },
246
259
  fullscreen: {
@@ -249,6 +262,8 @@ const styles = stylex.create({
249
262
  maxWidth: '100dvw',
250
263
  maxHeight: '100dvh',
251
264
  borderRadius: 0,
265
+ // Nothing of the page shows around a full-screen dialog: no edge.
266
+ borderWidth: 0,
252
267
  margin: 0,
253
268
  inset: 0,
254
269
  },
@@ -222,8 +222,8 @@ const styles = stylex.create({
222
222
  // Scrim via the browser's ::backdrop pseudo-element (top layer).
223
223
  scrim: {
224
224
  '::backdrop': {
225
+ // No backdrop-filter: a full-viewport blur is costly to repaint.
225
226
  backgroundColor: colorVars['--color-overlay'],
226
- backdropFilter: 'blur(2px)',
227
227
  opacity: 0,
228
228
  transitionProperty: 'opacity',
229
229
  transitionDuration: durationVars['--duration-medium'],
@@ -33,6 +33,7 @@ import {
33
33
  } from 'react';
34
34
  import {flushSync} from 'react-dom';
35
35
  import {notifyTopLayerEntry} from '../Layer/modalOutlet';
36
+ import {runPopoverOperation} from '../Layer/popoverOperations';
36
37
 
37
38
  /** Slack past the computed transition before the backstop gives up waiting. */
38
39
  const EXIT_BACKSTOP_BUFFER_MS = 50;
@@ -71,7 +72,10 @@ function showDrawerHost(dialog: HTMLDialogElement, isModal: boolean): void {
71
72
  if (isModal) {
72
73
  dialog.showModal();
73
74
  } else if (typeof dialog.showPopover === 'function') {
74
- dialog.showPopover();
75
+ // The dialog's show focuses its first control. A tooltip on that control
76
+ // then asks to show. The browser refuses a show inside another show.
77
+ // Counting this show makes the tooltip wait until it ends.
78
+ runPopoverOperation(dialog.ownerDocument, () => dialog.showPopover());
75
79
  // The drawer is now the newest top-layer entry: global hosts shown before
76
80
  // it (the toast viewport) re-enter above it, as they do above a modal.
77
81
  notifyTopLayerEntry();
@@ -58,6 +58,7 @@ import {
58
58
  } from '../theme/tokens.stylex';
59
59
  import type {BaseProps} from '../BaseProps';
60
60
  import {textEntryTraitAttributes} from './textEntryTraits';
61
+ import {inputWrapperStyles} from './inputStyles.stylex';
61
62
 
62
63
  const styles = stylex.create({
63
64
  // Gutter only. Matches the option list's own padding so the field below
@@ -185,6 +186,14 @@ export interface PanelSearchInputProps extends Omit<
185
186
  * the field to the same height as its rows.
186
187
  */
187
188
  fieldXstyle?: StyleXStyles;
189
+ /**
190
+ * Draw the field as the shared input box — the input border, accent while
191
+ * focused, surface and radius — instead of the borderless panel row. A
192
+ * touch sheet is a full-width surface, not a bordered panel, so its field
193
+ * reads as a form input.
194
+ * @default false
195
+ */
196
+ hasInputBox?: boolean;
188
197
  }
189
198
 
190
199
  /**
@@ -208,6 +217,7 @@ export function PanelSearchInput({
208
217
  onBlur,
209
218
  onContainerKeyDown,
210
219
  fieldXstyle,
220
+ hasInputBox = false,
211
221
  xstyle,
212
222
  className,
213
223
  style,
@@ -268,6 +278,7 @@ export function PanelSearchInput({
268
278
  {...stylex.props(
269
279
  styles.field,
270
280
  isKeyboardFocus && styles.fieldKeyboardFocus,
281
+ hasInputBox && inputWrapperStyles.base,
271
282
  fieldXstyle,
272
283
  )}>
273
284
  <Icon icon="search" size="sm" color="secondary" xstyle={styles.icon} />
@@ -66,7 +66,7 @@ export const docs = {
66
66
  {
67
67
  guidance: true,
68
68
  description:
69
- 'Rely on InfoTip rather than a hand-rolled info button on touch: it sets Tooltip triggerBehavior to toggle, so a tap, a click, Enter, or Space pins the tooltip even though the trigger is a button, and a tap outside dismisses it.',
69
+ 'Rely on InfoTip rather than a hand-rolled info button on touch: it pins its help on a press, so a tap, a click, Enter, or Space pins the tooltip even though the trigger is a button, and a tap outside dismisses it.',
70
70
  },
71
71
  {
72
72
  guidance: false,
@@ -131,7 +131,7 @@ export const docsZh = {
131
131
  {
132
132
  guidance: true,
133
133
  description:
134
- 'Rely on InfoTip rather than a hand-rolled info button on touch: it sets Tooltip triggerBehavior to toggle, so a tap, a click, Enter, or Space pins the tooltip even though the trigger is a button, and a tap outside dismisses it.',
134
+ 'Rely on InfoTip rather than a hand-rolled info button on touch: it pins its help on a press, so a tap, a click, Enter, or Space pins the tooltip even though the trigger is a button, and a tap outside dismisses it.',
135
135
  },
136
136
  {
137
137
  guidance: false,
@@ -168,7 +168,7 @@ export const docsDense = {
168
168
  {
169
169
  guidance: true,
170
170
  description:
171
- 'Rely on InfoTip rather than a hand-rolled info button on touch: it sets Tooltip triggerBehavior to toggle, so a tap, a click, Enter, or Space pins the tooltip even though the trigger is a button, and a tap outside dismisses it.',
171
+ 'Rely on InfoTip rather than a hand-rolled info button on touch: it pins its help on a press, so a tap, a click, Enter, or Space pins the tooltip even though the trigger is a button, and a tap outside dismisses it.',
172
172
  },
173
173
  {
174
174
  guidance: false,
@@ -23,7 +23,7 @@ import * as stylex from '@stylexjs/stylex';
23
23
  import type {BaseProps} from '../BaseProps';
24
24
  import {useMergedRefs} from '../hooks/useMergedRefs';
25
25
  import {Icon, type IconSize} from '../Icon/Icon';
26
- import {Tooltip} from '../Tooltip/Tooltip';
26
+ import {TooltipWithTriggerBehavior} from '../Tooltip/Tooltip';
27
27
  import {
28
28
  registerEnglishFallback,
29
29
  useComponentTranslator,
@@ -122,8 +122,8 @@ const styles = stylex.create({
122
122
  * labels, values, and metrics for permission notes, metric definitions, and
123
123
  * field help.
124
124
  *
125
- * InfoTip is Tooltip with `triggerBehavior="toggle"` on a named button: Tooltip
126
- * opens, pins, announces, and closes the help. The info icon resolves from
125
+ * InfoTip is a press-to-pin Tooltip on a named button: Tooltip opens, pins,
126
+ * announces, and closes the help. The info icon resolves from
127
127
  * the icon registry, so themes can replace it.
128
128
  *
129
129
  * @example
@@ -157,7 +157,7 @@ export function InfoTip({
157
157
  // stack takes Escape, so one press never closes both the help and an
158
158
  // enclosing Dialog.
159
159
  return (
160
- <Tooltip
160
+ <TooltipWithTriggerBehavior
161
161
  content={content}
162
162
  // The tooltip is the button's only purpose. A click, a tap, Enter, or
163
163
  // Space pins it open, and the press stops at the button. So an InfoTip's
@@ -179,7 +179,7 @@ export function InfoTip({
179
179
  )}>
180
180
  <Icon icon="info" size={size} />
181
181
  </button>
182
- </Tooltip>
182
+ </TooltipWithTriggerBehavior>
183
183
  );
184
184
  }
185
185