@astryxdesign/core 0.7.6 → 0.7.7-canary.0eb44a2

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 (272) hide show
  1. package/dist/AppShell/AppShell.d.ts.map +1 -1
  2. package/dist/AppShell/AppShell.js +23 -3
  3. package/dist/Avatar/Avatar.d.ts.map +1 -1
  4. package/dist/Avatar/Avatar.js +1 -1
  5. package/dist/BottomSheet/BottomSheetPanel.d.ts.map +1 -1
  6. package/dist/BottomSheet/BottomSheetPanel.js +16 -9
  7. package/dist/Breadcrumbs/BreadcrumbItem.d.ts.map +1 -1
  8. package/dist/Breadcrumbs/BreadcrumbItem.js +2 -1
  9. package/dist/Button/Button.d.ts.map +1 -1
  10. package/dist/Button/Button.js +3 -2
  11. package/dist/Chat/ChatComposerInput.d.ts +9 -0
  12. package/dist/Chat/ChatComposerInput.d.ts.map +1 -1
  13. package/dist/Chat/ChatComposerInput.js +2 -0
  14. package/dist/Chat/ChatMessage.js +2 -2
  15. package/dist/Chat/chatMessage.vars.stylex.d.ts +8 -8
  16. package/dist/Chat/chatMessage.vars.stylex.js +8 -8
  17. package/dist/CommandPalette/CommandPaletteInput.d.ts.map +1 -1
  18. package/dist/CommandPalette/CommandPaletteInput.js +8 -1
  19. package/dist/DateInput/MonthScroller.d.ts.map +1 -1
  20. package/dist/DateInput/MonthScroller.js +72 -2
  21. package/dist/DateInput/Wheel.d.ts +17 -1
  22. package/dist/DateInput/Wheel.d.ts.map +1 -1
  23. package/dist/DateInput/Wheel.js +179 -19
  24. package/dist/DateRangeInput/DateRangeInput.d.ts.map +1 -1
  25. package/dist/DateRangeInput/DateRangeInput.js +63 -49
  26. package/dist/Dialog/Dialog.d.ts.map +1 -1
  27. package/dist/Dialog/Dialog.js +37 -1
  28. package/dist/Drawer/Drawer.d.ts.map +1 -1
  29. package/dist/Drawer/Drawer.js +6 -0
  30. package/dist/Drawer/useDrawerDialogPresence.d.ts.map +1 -1
  31. package/dist/Drawer/useDrawerDialogPresence.js +4 -0
  32. package/dist/DropdownMenu/DropdownMenu.d.ts.map +1 -1
  33. package/dist/DropdownMenu/DropdownMenu.js +20 -4
  34. package/dist/DropdownMenu/useMenuOverflow.d.ts +13 -3
  35. package/dist/DropdownMenu/useMenuOverflow.d.ts.map +1 -1
  36. package/dist/DropdownMenu/useMenuOverflow.js +31 -8
  37. package/dist/Field/PanelSearchInput.d.ts.map +1 -1
  38. package/dist/Field/PanelSearchInput.js +8 -1
  39. package/dist/Field/textEntryTraits.d.ts +6 -6
  40. package/dist/Field/textEntryTraits.js +6 -6
  41. package/dist/Layer/layerClickBoundary.d.ts +7 -1
  42. package/dist/Layer/layerClickBoundary.d.ts.map +1 -1
  43. package/dist/Layer/layerClickBoundary.js +2 -2
  44. package/dist/Layer/usePressPin.d.ts +95 -0
  45. package/dist/Layer/usePressPin.d.ts.map +1 -0
  46. package/dist/Layer/usePressPin.js +365 -0
  47. package/dist/Layer/useTouchTrigger.d.ts +23 -1
  48. package/dist/Layer/useTouchTrigger.d.ts.map +1 -1
  49. package/dist/Layer/useTouchTrigger.js +45 -7
  50. package/dist/Lightbox/Lightbox.d.ts.map +1 -1
  51. package/dist/Lightbox/Lightbox.js +2 -1
  52. package/dist/Link/Link.d.ts.map +1 -1
  53. package/dist/Link/Link.js +1 -0
  54. package/dist/Popover/usePopover.d.ts.map +1 -1
  55. package/dist/Popover/usePopover.js +56 -7
  56. package/dist/PowerSearch/PowerSearch.d.ts.map +1 -1
  57. package/dist/PowerSearch/PowerSearch.js +38 -30
  58. package/dist/ProgressBar/ProgressBar.d.ts +1 -1
  59. package/dist/ProgressBar/ProgressBar.d.ts.map +1 -1
  60. package/dist/ProgressBar/ProgressBar.js +52 -14
  61. package/dist/ProgressBar/ProgressBarTouch.d.ts +29 -0
  62. package/dist/ProgressBar/ProgressBarTouch.d.ts.map +1 -0
  63. package/dist/ProgressBar/ProgressBarTouch.js +91 -0
  64. package/dist/SegmentedControl/SegmentedControlItem.d.ts.map +1 -1
  65. package/dist/SegmentedControl/SegmentedControlItem.js +2 -1
  66. package/dist/Selector/Selector.d.ts.map +1 -1
  67. package/dist/Selector/Selector.js +25 -13
  68. package/dist/Slider/Slider.d.ts.map +1 -1
  69. package/dist/Slider/Slider.js +404 -131
  70. package/dist/Slider/sliderTouchController.d.ts +189 -0
  71. package/dist/Slider/sliderTouchController.d.ts.map +1 -0
  72. package/dist/Slider/sliderTouchController.js +1017 -0
  73. package/dist/Slider/useSliderTouch.d.ts +22 -0
  74. package/dist/Slider/useSliderTouch.d.ts.map +1 -0
  75. package/dist/Slider/useSliderTouch.js +56 -0
  76. package/dist/Switch/Switch.d.ts +1 -1
  77. package/dist/Switch/Switch.d.ts.map +1 -1
  78. package/dist/Switch/Switch.js +200 -46
  79. package/dist/Switch/switchFill.d.ts +31 -0
  80. package/dist/Switch/switchFill.d.ts.map +1 -0
  81. package/dist/Switch/switchFill.js +88 -0
  82. package/dist/Switch/switchTouch.d.ts +81 -0
  83. package/dist/Switch/switchTouch.d.ts.map +1 -0
  84. package/dist/Switch/switchTouch.js +133 -0
  85. package/dist/Switch/useSwitchTouch.d.ts +285 -0
  86. package/dist/Switch/useSwitchTouch.d.ts.map +1 -0
  87. package/dist/Switch/useSwitchTouch.js +998 -0
  88. package/dist/TabList/Tab.d.ts.map +1 -1
  89. package/dist/TabList/Tab.js +4 -3
  90. package/dist/Tokenizer/Tokenizer.d.ts.map +1 -1
  91. package/dist/Tokenizer/Tokenizer.js +12 -5
  92. package/dist/Tokenizer/TokenizerItemRemovableContext.d.ts +3 -0
  93. package/dist/Tokenizer/TokenizerItemRemovableContext.d.ts.map +1 -0
  94. package/dist/Tokenizer/TokenizerItemRemovableContext.js +17 -0
  95. package/dist/Tooltip/Tooltip.d.ts +30 -6
  96. package/dist/Tooltip/Tooltip.d.ts.map +1 -1
  97. package/dist/Tooltip/Tooltip.js +5 -0
  98. package/dist/Tooltip/index.d.ts +1 -1
  99. package/dist/Tooltip/index.d.ts.map +1 -1
  100. package/dist/Tooltip/useTooltip.d.ts +31 -5
  101. package/dist/Tooltip/useTooltip.d.ts.map +1 -1
  102. package/dist/Tooltip/useTooltip.js +59 -18
  103. package/dist/Typeahead/BaseTypeahead.d.ts.map +1 -1
  104. package/dist/Typeahead/BaseTypeahead.js +19 -9
  105. package/dist/Typeahead/Typeahead.d.ts.map +1 -1
  106. package/dist/Typeahead/Typeahead.js +26 -15
  107. package/dist/astryx.css +91 -8
  108. package/dist/gestures/GestureArena.d.ts +21 -2
  109. package/dist/gestures/GestureArena.d.ts.map +1 -1
  110. package/dist/gestures/GestureArena.js +23 -3
  111. package/dist/gestures/GestureHost.d.ts +123 -3
  112. package/dist/gestures/GestureHost.d.ts.map +1 -1
  113. package/dist/gestures/GestureHost.js +797 -15
  114. package/dist/gestures/GestureRecognizer.d.ts +15 -1
  115. package/dist/gestures/GestureRecognizer.d.ts.map +1 -1
  116. package/dist/gestures/GestureRecognizer.js +12 -0
  117. package/dist/gestures/PanGestureRecognizer.d.ts +8 -0
  118. package/dist/gestures/PanGestureRecognizer.d.ts.map +1 -1
  119. package/dist/gestures/PinchGestureRecognizer.d.ts +5 -1
  120. package/dist/gestures/PinchGestureRecognizer.d.ts.map +1 -1
  121. package/dist/gestures/PinchGestureRecognizer.js +10 -0
  122. package/dist/gestures/PressTracker.d.ts +6 -0
  123. package/dist/gestures/PressTracker.d.ts.map +1 -1
  124. package/dist/gestures/PressTracker.js +3 -0
  125. package/dist/gestures/ScreenEdgePanGestureRecognizer.d.ts +3 -3
  126. package/dist/gestures/ScreenEdgePanGestureRecognizer.d.ts.map +1 -1
  127. package/dist/gestures/ScreenEdgePanGestureRecognizer.js +7 -5
  128. package/dist/gestures/nativeScroll.d.ts +16 -2
  129. package/dist/gestures/nativeScroll.d.ts.map +1 -1
  130. package/dist/gestures/nativeScroll.js +39 -8
  131. package/dist/gestures/recognizerInternals.d.ts +4 -2
  132. package/dist/gestures/recognizerInternals.d.ts.map +1 -1
  133. package/dist/gestures/types.d.ts +17 -0
  134. package/dist/gestures/types.d.ts.map +1 -1
  135. package/dist/gestures/types.js +7 -0
  136. package/dist/gestures/usePressTracker.d.ts.map +1 -1
  137. package/dist/gestures/usePressTracker.js +1 -0
  138. package/dist/gestures/wheelSegmenter.d.ts +56 -0
  139. package/dist/gestures/wheelSegmenter.d.ts.map +1 -0
  140. package/dist/gestures/wheelSegmenter.js +122 -0
  141. package/dist/hooks/useAttachment.d.ts +16 -8
  142. package/dist/hooks/useAttachment.d.ts.map +1 -1
  143. package/dist/hooks/useAttachment.js +35 -15
  144. package/dist/hooks/useFocusTrap.d.ts +15 -0
  145. package/dist/hooks/useFocusTrap.d.ts.map +1 -1
  146. package/dist/hooks/useFocusTrap.js +13 -2
  147. package/dist/hooks/usePager.d.ts +261 -0
  148. package/dist/hooks/usePager.d.ts.map +1 -0
  149. package/dist/hooks/usePager.js +1169 -0
  150. package/dist/motion/animator.d.ts.map +1 -1
  151. package/dist/motion/animator.js +140 -19
  152. package/dist/motion/paging.d.ts +69 -0
  153. package/dist/motion/paging.d.ts.map +1 -0
  154. package/dist/motion/paging.js +148 -0
  155. package/dist/motion/uikit/index.d.ts +8 -4
  156. package/dist/motion/uikit/index.d.ts.map +1 -1
  157. package/dist/motion/uikit/index.js +8 -4
  158. package/dist/motion/uikit/paging.d.ts +171 -0
  159. package/dist/motion/uikit/paging.d.ts.map +1 -0
  160. package/dist/motion/uikit/paging.js +192 -0
  161. package/dist/motion/uikit/progressBar.d.ts +79 -0
  162. package/dist/motion/uikit/progressBar.d.ts.map +1 -0
  163. package/dist/motion/uikit/progressBar.js +102 -0
  164. package/dist/motion/uikit/scroll.d.ts +0 -19
  165. package/dist/motion/uikit/scroll.d.ts.map +1 -1
  166. package/dist/motion/uikit/scroll.js +2 -21
  167. package/dist/motion/uikit/slider.d.ts +97 -0
  168. package/dist/motion/uikit/slider.d.ts.map +1 -0
  169. package/dist/motion/uikit/slider.js +111 -0
  170. package/dist/motion/uikit/springs.d.ts +59 -30
  171. package/dist/motion/uikit/springs.d.ts.map +1 -1
  172. package/dist/motion/uikit/springs.js +64 -34
  173. package/dist/motion/uikit/switch.d.ts +166 -0
  174. package/dist/motion/uikit/switch.d.ts.map +1 -0
  175. package/dist/motion/uikit/switch.js +186 -0
  176. package/dist/privateAttributes/pressHoldsDrag.stylex.d.ts +18 -0
  177. package/dist/privateAttributes/pressHoldsDrag.stylex.d.ts.map +1 -0
  178. package/dist/privateAttributes/pressHoldsDrag.stylex.js +28 -0
  179. package/dist/utils/pressFeedback.d.ts.map +1 -1
  180. package/dist/utils/pressFeedback.js +5 -0
  181. package/dist/utils/selectionLock.d.ts +43 -0
  182. package/dist/utils/selectionLock.d.ts.map +1 -1
  183. package/dist/utils/selectionLock.js +394 -6
  184. package/package.json +5 -5
  185. package/src/AppShell/AppShell.doc.mjs +10 -0
  186. package/src/AppShell/AppShell.tsx +48 -2
  187. package/src/Avatar/Avatar.tsx +3 -1
  188. package/src/BottomSheet/BottomSheetPanel.tsx +16 -9
  189. package/src/Breadcrumbs/BreadcrumbItem.tsx +4 -1
  190. package/src/Button/Button.tsx +3 -2
  191. package/src/Chat/Chat.doc.mjs +1 -0
  192. package/src/Chat/ChatComposerInput.doc.mjs +11 -0
  193. package/src/Chat/ChatComposerInput.tsx +11 -0
  194. package/src/Chat/ChatMessage.tsx +2 -2
  195. package/src/Chat/chatMessage.vars.stylex.ts +8 -8
  196. package/src/CommandPalette/CommandPaletteInput.tsx +6 -0
  197. package/src/DateInput/MonthScroller.tsx +63 -5
  198. package/src/DateInput/Wheel.tsx +174 -14
  199. package/src/DateRangeInput/DateRangeInput.doc.mjs +3 -2
  200. package/src/DateRangeInput/DateRangeInput.tsx +117 -71
  201. package/src/Dialog/Dialog.tsx +37 -1
  202. package/src/Drawer/Drawer.tsx +6 -0
  203. package/src/Drawer/useDrawerDialogPresence.ts +4 -0
  204. package/src/DropdownMenu/DropdownMenu.tsx +28 -6
  205. package/src/DropdownMenu/useMenuOverflow.ts +44 -8
  206. package/src/Field/PanelSearchInput.tsx +6 -0
  207. package/src/Field/textEntryTraits.ts +6 -6
  208. package/src/Layer/layerClickBoundary.ts +2 -2
  209. package/src/Layer/usePressPin.ts +516 -0
  210. package/src/Layer/useTouchTrigger.ts +56 -6
  211. package/src/Lightbox/Lightbox.tsx +2 -1
  212. package/src/Link/Link.tsx +3 -0
  213. package/src/Popover/usePopover.tsx +71 -9
  214. package/src/PowerSearch/PowerSearch.tsx +38 -31
  215. package/src/ProgressBar/ProgressBar.doc.mjs +7 -5
  216. package/src/ProgressBar/ProgressBar.tsx +147 -16
  217. package/src/ProgressBar/ProgressBarTouch.ts +109 -0
  218. package/src/SegmentedControl/SegmentedControlItem.tsx +2 -1
  219. package/src/Selector/Selector.tsx +29 -2
  220. package/src/Slider/Slider.doc.mjs +11 -7
  221. package/src/Slider/Slider.tsx +539 -132
  222. package/src/Slider/sliderTouchController.ts +1209 -0
  223. package/src/Slider/useSliderTouch.ts +78 -0
  224. package/src/Switch/Switch.doc.mjs +3 -3
  225. package/src/Switch/Switch.tsx +412 -86
  226. package/src/Switch/switchFill.ts +106 -0
  227. package/src/Switch/switchTouch.ts +194 -0
  228. package/src/Switch/useSwitchTouch.ts +1121 -0
  229. package/src/TabList/Tab.tsx +5 -3
  230. package/src/Tokenizer/Tokenizer.tsx +15 -5
  231. package/src/Tokenizer/TokenizerItemRemovableContext.ts +22 -0
  232. package/src/Tooltip/Tooltip.doc.mjs +21 -6
  233. package/src/Tooltip/Tooltip.tsx +42 -5
  234. package/src/Tooltip/index.ts +1 -0
  235. package/src/Tooltip/useTooltip.doc.mjs +4 -2
  236. package/src/Tooltip/useTooltip.tsx +84 -21
  237. package/src/Typeahead/BaseTypeahead.tsx +16 -8
  238. package/src/Typeahead/Typeahead.tsx +29 -15
  239. package/src/gestures/GestureArena.ts +45 -4
  240. package/src/gestures/GestureHost.ts +958 -15
  241. package/src/gestures/GestureRecognizer.ts +28 -0
  242. package/src/gestures/PanGestureRecognizer.ts +8 -0
  243. package/src/gestures/PinchGestureRecognizer.ts +16 -1
  244. package/src/gestures/PressTracker.ts +10 -0
  245. package/src/gestures/README.md +82 -3
  246. package/src/gestures/ScreenEdgePanGestureRecognizer.ts +7 -5
  247. package/src/gestures/nativeScroll.ts +49 -9
  248. package/src/gestures/recognizerInternals.ts +9 -2
  249. package/src/gestures/types.ts +18 -0
  250. package/src/gestures/usePressTracker.ts +2 -0
  251. package/src/gestures/wheelSegmenter.ts +143 -0
  252. package/src/hooks/useAttachment.ts +56 -14
  253. package/src/hooks/useFocusTrap.ts +26 -2
  254. package/src/hooks/usePager.ts +1446 -0
  255. package/src/motion/MEASUREMENTS.md +36 -27
  256. package/src/motion/README.md +7 -3
  257. package/src/motion/animator.ts +193 -25
  258. package/src/motion/paging.ts +161 -0
  259. package/src/motion/uikit/index.ts +7 -4
  260. package/src/motion/uikit/paging.ts +193 -0
  261. package/src/motion/uikit/progressBar.ts +102 -0
  262. package/src/motion/uikit/scroll.ts +2 -18
  263. package/src/motion/uikit/slider.ts +105 -0
  264. package/src/motion/uikit/springs.ts +53 -32
  265. package/src/motion/uikit/switch.ts +174 -0
  266. package/src/privateAttributes/pressHoldsDrag.stylex.ts +28 -0
  267. package/src/utils/pressFeedback.ts +5 -0
  268. package/src/utils/selectionLock.ts +501 -6
  269. package/dist/motion/selectPage.d.ts +0 -26
  270. package/dist/motion/selectPage.d.ts.map +0 -1
  271. package/dist/motion/selectPage.js +0 -55
  272. package/src/motion/selectPage.ts +0 -55
@@ -19,7 +19,7 @@
19
19
  import React, {useCallback, useEffect, useRef, type ReactNode} from 'react';
20
20
  import * as stylex from '@stylexjs/stylex';
21
21
  import {useLayer, type ContextRenderProps} from '../Layer/useLayer';
22
- import {useFocusTrap} from '../hooks/useFocusTrap';
22
+ import {useFocusTrapInternal} from '../hooks/useFocusTrap';
23
23
  import {LayerDepthProvider} from '../Layer/LayerDepthContext';
24
24
  import type {StyleXStyles} from '@stylexjs/stylex';
25
25
  import {
@@ -34,6 +34,7 @@ import {rtlStyles} from '../utils';
34
34
  import {useDevWarning} from '../hooks/useDevWarning';
35
35
  import {focusOutlineProps} from '../utils/focusOutline.stylex';
36
36
  import {mergeProps} from '../utils/mergeProps';
37
+ import {isFocusDetached} from '../utils/focusReturn';
37
38
  import {themeProps} from '../utils/themeProps';
38
39
  import {paddingStyles} from '../Layout/padding.stylex';
39
40
  import type {SpacingStep} from '../utils/types';
@@ -422,28 +423,51 @@ function usePopoverImplementation(
422
423
 
423
424
  // Track whether to skip auto-focus for the current open event
424
425
  const skipAutoFocusRef = useRef(false);
426
+ // Whether `show` focused a content control within the opening event.
427
+ const hasFocusedInGestureRef = useRef(false);
428
+
429
+ // Set when the layer reports it has shown, so `show` can tell a popover
430
+ // that opened within the call from one whose content mounts later.
431
+ const hasShownRef = useRef(false);
432
+ const handleLayerShow = useCallback(() => {
433
+ hasShownRef.current = true;
434
+ onShow?.();
435
+ }, [onShow]);
425
436
 
426
437
  // Core layer for popover positioning
427
438
  const layer = useLayer({
428
439
  mode: 'context',
429
440
  lightDismiss: hasLightDismiss,
430
- onShow,
441
+ onShow: handleLayerShow,
431
442
  onHide,
432
443
  });
433
444
 
434
445
  // Focus trap for the popover content. Escape stays registered while light
435
446
  // dismiss is on (native popover="auto" closes on Escape regardless), so a
436
447
  // host Dialog keeps deferring to this trap instead of double-dismissing.
437
- const {containerRef: contentRef, focusFirst} = useFocusTrap<HTMLDivElement>({
438
- isActive: layer.isOpen,
439
- onEscape: hasEscapeDismiss || hasLightDismiss ? layer.hide : undefined,
440
- });
448
+ // The element focused when `show` ran: initial focus may move inside before
449
+ // the trap activates, so the trap restores focus here instead.
450
+ const openerRef = useRef<HTMLElement | null>(null);
451
+ const getRestoreTarget = useCallback(() => openerRef.current, []);
452
+ const {containerRef: contentRef, focusFirst} =
453
+ useFocusTrapInternal<HTMLDivElement>({
454
+ isActive: layer.isOpen,
455
+ onEscape: hasEscapeDismiss || hasLightDismiss ? layer.hide : undefined,
456
+ getRestoreTarget,
457
+ });
441
458
 
442
459
  const focusInitialTarget = useCallback(() => {
443
460
  const container = contentRef.current;
444
461
  if (!container) {
445
462
  return;
446
463
  }
464
+ // Focus already on a control inside (set in `onShow`, or a press in the
465
+ // content) is where the user or the caller wants it; moving it would
466
+ // undo that.
467
+ const active = container.ownerDocument.activeElement;
468
+ if (active != null && active !== container && container.contains(active)) {
469
+ return;
470
+ }
447
471
  if (focusFirstContentControl(container)) {
448
472
  return;
449
473
  }
@@ -459,16 +483,23 @@ function usePopoverImplementation(
459
483
  // Reset the per-open focus preference after the popover closes.
460
484
  if (!layer.isOpen) {
461
485
  skipAutoFocusRef.current = false;
486
+ hasFocusedInGestureRef.current = false;
462
487
  return;
463
488
  }
464
489
  if (hasAutoFocus && !skipAutoFocusRef.current) {
465
490
  // Wait a frame so the content is mounted. A popover that closes or
466
491
  // unmounts first cancels it: the frame would otherwise move focus into
467
492
  // content that is no longer shown, after the close had left it alone.
468
- const frame = requestAnimationFrame(focusInitialTarget);
493
+ const frame = requestAnimationFrame(() => {
494
+ const doc = contentRef.current?.ownerDocument ?? document;
495
+ if (hasFocusedInGestureRef.current && !isFocusDetached(doc)) {
496
+ return;
497
+ }
498
+ focusInitialTarget();
499
+ });
469
500
  return () => cancelAnimationFrame(frame);
470
501
  }
471
- }, [layer.isOpen, hasAutoFocus, focusInitialTarget]);
502
+ }, [layer.isOpen, hasAutoFocus, focusInitialTarget, contentRef]);
472
503
 
473
504
  // Combined ref for trigger element (layer anchor + our ref). It is keyed on
474
505
  // the layer's own ref, not the whole layer: the layer object changes with
@@ -490,9 +521,40 @@ function usePopoverImplementation(
490
521
  const show = useCallback(
491
522
  (showOptions?: {skipAutoFocus?: boolean}) => {
492
523
  skipAutoFocusRef.current = showOptions?.skipAutoFocus ?? false;
524
+ hasShownRef.current = false;
525
+ const activeElement = (
526
+ triggerElementRef.current?.ownerDocument ?? document
527
+ ).activeElement as HTMLElement | null;
528
+ openerRef.current =
529
+ activeElement != null && !contentRef.current?.contains(activeElement)
530
+ ? activeElement
531
+ : null;
493
532
  layer.show();
533
+ // When the content is already mounted, the layer shows it within this
534
+ // call, so initial focus lands within the event that opened it: iOS
535
+ // raises its keyboard only for a focus made while the tap is still
536
+ // being handled. The frame in the effect above stays for content that
537
+ // mounts later.
538
+ // Only a content control already rendered takes focus here; content
539
+ // that renders on open (and the dialog-surface fallback) waits for the
540
+ // effect's frame.
541
+ const container = contentRef.current;
542
+ if (
543
+ hasShownRef.current &&
544
+ hasAutoFocus &&
545
+ !skipAutoFocusRef.current &&
546
+ container != null &&
547
+ !container.contains(container.ownerDocument.activeElement) &&
548
+ focusFirstContentControl(container)
549
+ ) {
550
+ // Initial focus is done. The effect's frame stays only as a fallback
551
+ // for focus lost to the page (content swapped in the same handler
552
+ // unmounts the control just focused); it must not move focus the user
553
+ // has since moved elsewhere.
554
+ hasFocusedInGestureRef.current = true;
555
+ }
494
556
  },
495
- [layer],
557
+ [layer, hasAutoFocus, contentRef],
496
558
  );
497
559
 
498
560
  // Toggle function. Opening delegates to show so every route inherits the
@@ -31,6 +31,7 @@ import {
31
31
  type TokenizerOverflowBehavior,
32
32
  } from '../Tokenizer';
33
33
  import {TokenizerClearAllMessageContext} from '../Tokenizer/TokenizerClearAllMessageContext';
34
+ import {TokenizerItemRemovableContext} from '../Tokenizer/TokenizerItemRemovableContext';
34
35
  import {TypeaheadItem} from '../Typeahead/TypeaheadItem';
35
36
  import {Token} from '../Token';
36
37
  import {Avatar} from '../Avatar';
@@ -751,6 +752,10 @@ export function PowerSearch({
751
752
  [config, filters, onChange, setPopoverState],
752
753
  );
753
754
 
755
+ // Tokenizer's Backspace does not go through renderToken. This predicate
756
+ // locks every token of a read-only field.
757
+ const isItemRemovable = useCallback(() => !isReadOnly, [isReadOnly]);
758
+
754
759
  // Handle clicking a token to edit
755
760
  const handleTokenClick = useCallback(
756
761
  (index: number) => {
@@ -1117,37 +1122,39 @@ export function PowerSearch({
1117
1122
  {/* The field's Clear all announces PowerSearch's own words
1118
1123
  (component:PowerSearch; component:Tokenizer AR7). */}
1119
1124
  <TokenizerClearAllMessageContext value={clearAllMessage}>
1120
- <Tokenizer
1121
- handleRef={tokenizerRef}
1122
- label={label}
1123
- isLabelHidden={isLabelHidden}
1124
- searchSource={searchSource}
1125
- value={tokenizerValue}
1126
- onChange={handleTokenizerChange}
1127
- renderToken={renderToken}
1128
- renderItem={renderItem}
1129
- maxMenuItems={MAX_BROWSE_MENU_ITEMS}
1130
- menuWidth={menuWidth}
1131
- placeholder={filters.length === 0 ? placeholder : ''}
1132
- hasAutoFocus={hasAutoFocus}
1133
- hasClear={hasClear && !isReadOnly}
1134
- startIcon={startIcon}
1135
- endContent={combinedEndContent}
1136
- isDisabled={isDisabled}
1137
- disabledMessage={disabledMessage}
1138
- size={size}
1139
- tokenOverflowBehavior={tokenOverflowBehavior}
1140
- hasEntriesOnFocus
1141
- debounceMs={0}
1142
- status={status}
1143
- statusVariant={statusVariant}
1144
- onFocus={onFocus}
1145
- onBlur={onBlur}
1146
- containerXstyle={xstyle}
1147
- containerClassName={className}
1148
- containerStyle={style}
1149
- data-testid={testId}
1150
- />
1125
+ <TokenizerItemRemovableContext value={isItemRemovable}>
1126
+ <Tokenizer
1127
+ handleRef={tokenizerRef}
1128
+ label={label}
1129
+ isLabelHidden={isLabelHidden}
1130
+ searchSource={searchSource}
1131
+ value={tokenizerValue}
1132
+ onChange={handleTokenizerChange}
1133
+ renderToken={renderToken}
1134
+ renderItem={renderItem}
1135
+ maxMenuItems={MAX_BROWSE_MENU_ITEMS}
1136
+ menuWidth={menuWidth}
1137
+ placeholder={filters.length === 0 ? placeholder : ''}
1138
+ hasAutoFocus={hasAutoFocus}
1139
+ hasClear={hasClear && !isReadOnly}
1140
+ startIcon={startIcon}
1141
+ endContent={combinedEndContent}
1142
+ isDisabled={isDisabled}
1143
+ disabledMessage={disabledMessage}
1144
+ size={size}
1145
+ tokenOverflowBehavior={tokenOverflowBehavior}
1146
+ hasEntriesOnFocus
1147
+ debounceMs={0}
1148
+ status={status}
1149
+ statusVariant={statusVariant}
1150
+ onFocus={onFocus}
1151
+ onBlur={onBlur}
1152
+ containerXstyle={xstyle}
1153
+ containerClassName={className}
1154
+ containerStyle={style}
1155
+ data-testid={testId}
1156
+ />
1157
+ </TokenizerItemRemovableContext>
1151
1158
  </TokenizerClearAllMessageContext>
1152
1159
  </div>
1153
1160
  {popover.render(popoverContent, {
@@ -101,7 +101,8 @@ export const docs = {
101
101
  {
102
102
  name: 'isIndeterminate',
103
103
  type: 'boolean',
104
- description: 'Animated loading indicator for unknown progress.',
104
+ description:
105
+ 'Animated loading indicator for unknown progress. Under reduced motion it pulses in place instead of sliding.',
105
106
  default: 'false',
106
107
  },
107
108
  {
@@ -160,7 +161,7 @@ export const docs = {
160
161
  usage: {
161
162
  anatomy,
162
163
  description:
163
- "A horizontal bar showing the completion progress of a task. Use it for operations where the duration is known, or as an animated indicator when progress can't be calculated. Supports semantic color variants, value labels, and custom formatting.",
164
+ "A horizontal bar showing the completion progress of a task. Use it for operations where the duration is known, or as an animated indicator when progress can't be calculated. Supports semantic color variants, value labels, and custom formatting. Under a finger a new value animates as on iOS: linearly, in proportion to how far it moves (a full track takes one second at the theme's default tempo), and the fill keeps a minimum visible length above zero.",
164
165
  bestPractices: [
165
166
  {
166
167
  guidance: true,
@@ -304,7 +305,7 @@ export const docsZh = {
304
305
  usage: {
305
306
  anatomy,
306
307
  description:
307
- "A horizontal bar showing the completion progress of a task. Use it for operations where the duration is known, or as an animated indicator when progress can't be calculated. Supports semantic color variants, value labels, and custom formatting.",
308
+ "A horizontal bar showing the completion progress of a task. Use it for operations where the duration is known, or as an animated indicator when progress can't be calculated. Supports semantic color variants, value labels, and custom formatting. Under a finger a new value animates as on iOS: linearly, in proportion to how far it moves (a full track takes one second at the theme's default tempo), and the fill keeps a minimum visible length above zero.",
308
309
  bestPractices: [
309
310
  {
310
311
  guidance: true,
@@ -347,7 +348,7 @@ export const docsDense = {
347
348
  usage: {
348
349
  anatomy,
349
350
  description:
350
- "A horizontal bar showing the completion progress of a task. Use it for operations where the duration is known, or as an animated indicator when progress can't be calculated. Supports semantic color variants, value labels, and custom formatting.",
351
+ "A horizontal bar showing the completion progress of a task. Use it for operations where the duration is known, or as an animated indicator when progress can't be calculated. Supports semantic color variants, value labels, and custom formatting. Under a finger a new value animates as on iOS: linearly, in proportion to how far it moves (a full track takes one second at the theme's default tempo), and the fill keeps a minimum visible length above zero.",
351
352
  bestPractices: [
352
353
  {
353
354
  guidance: true,
@@ -390,7 +391,8 @@ export const docsDense = {
390
391
  formatValueLabel:
391
392
  'Custom value label formatter; defaults to percentage string.',
392
393
  variant: 'Semantic color variant.',
393
- isIndeterminate: 'Animated loading indicator for unknown progress.',
394
+ isIndeterminate:
395
+ 'Animated loading indicator for unknown progress. Under reduced motion it pulses in place instead of sliding.',
394
396
  marks:
395
397
  'Fixed target marks ({value, label?}) drawn on the track in the 0..max scale; stay visible past the fill. Marks inside the fill take the variant on-color; marks on the bare track take the primary text color (secondary when disabled). A label reveals a tooltip on hover/focus. Ignored when indeterminate.',
396
398
  isDisabled: 'Visually disabled: grays out fill and text.',
@@ -4,7 +4,7 @@
4
4
 
5
5
  /**
6
6
  * @file ProgressBar.tsx
7
- * @input Uses React, useId, stylex, color/spacing/radius/transition tokens
7
+ * @input Uses React, useId, stylex, tokens, ProgressBarTouch and resolveMotion
8
8
  * @output Exports ProgressBar component, ProgressBarProps, ProgressBarVariant types
9
9
  * @position Core implementation; consumed by index.ts
10
10
  *
@@ -35,6 +35,11 @@ import type {BaseProps} from '../BaseProps';
35
35
  import {themeProps} from '../utils/themeProps';
36
36
  import {VisuallyHidden} from '../VisuallyHidden';
37
37
  import type {ProgressBarVariantMap} from './index';
38
+ import {useProgressBarTouch} from './ProgressBarTouch';
39
+ import {formatMotionRoleValue} from '../motion/roles';
40
+ import {resolveMotion} from '../motion/resolveMotion';
41
+ import {progressBar} from '../motion/uikit/progressBar';
42
+ import {timing} from '../motion/uikit/timing';
38
43
 
39
44
  // Every mark is wrapped in a Tooltip (marks always carry a label), so load it
40
45
  // lazily: a ProgressBar with no marks never bundles the Tooltip chunk. While it
@@ -116,7 +121,7 @@ export interface ProgressBarProps extends BaseProps<HTMLDivElement> {
116
121
  * When true, renders an animated indeterminate progress indicator.
117
122
  * Use when the progress amount is unknown (e.g. loading, processing).
118
123
  * The `value` and `hasValueLabel` props are ignored in this mode.
119
- * Respects `prefers-reduced-motion` by slowing the animation.
124
+ * Under reduced motion it pulses in place instead of sliding.
120
125
  * @default false
121
126
  */
122
127
  isIndeterminate?: boolean;
@@ -169,6 +174,19 @@ const indeterminateSlideRtl = stylex.keyframes({
169
174
  },
170
175
  });
171
176
 
177
+ // Under reduced motion the indeterminate fill stops travelling and pulses in
178
+ // place. It goes from full opacity down to the pulse's floor
179
+ // (`--_progress-bar-pulse-floor`, the progress table's) and back. The
180
+ // animation alternates direction each pass.
181
+ const indeterminatePulse = stylex.keyframes({
182
+ from: {
183
+ opacity: 1,
184
+ },
185
+ to: {
186
+ opacity: 'var(--_progress-bar-pulse-floor)',
187
+ },
188
+ });
189
+
172
190
  // =============================================================================
173
191
  // Styles
174
192
  // =============================================================================
@@ -249,10 +267,25 @@ const styles = stylex.create({
249
267
  // no transform), as before: the track's clip would cut the CanvasText
250
268
  // outline's start cap off a slid fill, and that outline is what keeps the
251
269
  // fill's shape visible there (architecture:theme-tokens INV11).
270
+ //
271
+ // Under a finger (TOUCH_QUERY, on every touch device), the fill moves as
272
+ // UIProgressView's setProgress(_:animated: true) does. It moves linearly
273
+ // from wherever it is drawn. Its duration is the `progress.fillTrackMs` role
274
+ // times the share of the track the value moved (`--_progress-bar-move`).
275
+ // Its curve is the `progress.curves.fill` role (`--_progress-bar-curve`).
276
+ // The touch layer (ProgressBarTouch) writes both, and this rule scales the
277
+ // duration by the theme's tempo. Above zero, the fill never draws shorter
278
+ // than the touch minimum (`--_progress-bar-min`, 16px on the 8px track). At
279
+ // zero it paints nothing (`fillEmpty`). Under reduced motion it lands at
280
+ // once, as everywhere (below). A mouse or trackpad keeps the theme's timing.
252
281
  fill: {
253
282
  width: {
254
283
  default: '100%',
255
- '@media (forced-colors: active)': 'var(--_progress-bar-fill, 0%)',
284
+ '@media (forced-colors: active)': {
285
+ default: 'var(--_progress-bar-fill, 0%)',
286
+ '@media (pointer: coarse)':
287
+ 'calc(max(var(--_progress-bar-fill, 0%), var(--_progress-bar-min, 0px)) + var(--_progress-bar-flip, 0) * 0.01px)',
288
+ },
256
289
  },
257
290
  height: '100%',
258
291
  // Forced colors replace status fills with Canvas; this boundary keeps the
@@ -271,6 +304,19 @@ const styles = stylex.create({
271
304
  default: 'translateX(calc(100% - var(--_progress-bar-fill, 0%)))',
272
305
  '@media (forced-colors: active)': 'none',
273
306
  },
307
+ '@media (pointer: coarse)': {
308
+ // `--_progress-bar-flip` steps 0, 1, 2, 0… on every change. It moves
309
+ // the fill by that many hundredths of a pixel. So a change back to the
310
+ // value it just left is never the running transition's own start
311
+ // value. CSS shortens a transition that reverses to its start, which
312
+ // would halve a return mid-flight (10 → 90 → 10). It takes three
313
+ // values because two would repeat on that return.
314
+ default:
315
+ 'translateX(calc(max(var(--_progress-bar-fill, 0%), var(--_progress-bar-min, 0px)) - 100% + var(--_progress-bar-flip, 0) * 0.01px))',
316
+ ':dir(rtl)':
317
+ 'translateX(calc(min(calc(100% - var(--_progress-bar-fill, 0%)), calc(100% - var(--_progress-bar-min, 0px))) - var(--_progress-bar-flip, 0) * 0.01px))',
318
+ },
319
+ // After the touch key, so StyleX negates that key under forced colors.
274
320
  '@media (forced-colors: active)': 'none',
275
321
  },
276
322
  willChange: {
@@ -281,17 +327,36 @@ const styles = stylex.create({
281
327
  default: 'transform',
282
328
  '@media (forced-colors: active)': 'width',
283
329
  },
284
- // Under reduced motion a new value lands at once, on every pointer: the
285
- // fill never travels (design:reduced-motion DR1). The keys are the shared
286
- // reduced-motion queries, a fine pointer's and a coarse pointer's, spelled
287
- // as the record's other transitions spell them.
330
+ // Under reduced motion a new value lands at once, with a mouse as with a
331
+ // finger: the fill never travels. The keys are the shared reduced-motion
332
+ // queries, as the other transitions spell them. The coarse key comes after
333
+ // the touch key, so it wins there.
288
334
  transitionDuration: {
289
335
  default: durationVars['--duration-medium'],
336
+ // The touch layer's move (the `progress.fillTrackMs` role times the
337
+ // share moved), scaled by the theme's tempo.
338
+ '@media (pointer: coarse)': {
339
+ default:
340
+ 'calc(var(--_progress-bar-move, 0ms) * var(--_astryx-motion-tempo, 1))',
341
+ // Under forced colors the fill is sized by `width` instead. Touch
342
+ // motion never animates a layout property, so with a finger the new
343
+ // size lands at once there.
344
+ '@media (forced-colors: active)': '0s',
345
+ },
290
346
  '@media (prefers-reduced-motion: reduce) and (not (pointer: coarse))':
291
347
  '0s',
292
348
  '@media (prefers-reduced-motion: reduce) and (pointer: coarse)': '0s',
293
349
  },
294
- transitionTimingFunction: easeVars['--ease-standard'],
350
+ transitionTimingFunction: {
351
+ default: easeVars['--ease-standard'],
352
+ '@media (pointer: coarse)': 'var(--_progress-bar-curve, linear)',
353
+ },
354
+ },
355
+ fillEmpty: {
356
+ opacity: {
357
+ default: null,
358
+ '@media (pointer: coarse)': 0,
359
+ },
295
360
  },
296
361
  indeterminateFill: {
297
362
  height: '100%',
@@ -304,17 +369,37 @@ const styles = stylex.create({
304
369
  '@media (forced-colors: active)': 'CanvasText',
305
370
  },
306
371
  outlineOffset: {default: null, '@media (forced-colors: active)': '-1px'},
307
- width: '40%',
372
+ // Under reduced motion, on every pointer, the fill spans the whole track
373
+ // as one solid bar, and only its opacity pulses. Each way takes the
374
+ // `progress.reducedMotionPulseMs` role on the `timing.curves.move` curve
375
+ // (`--_progress-bar-pulse`, `--_progress-bar-pulse-curve`, set inline from
376
+ // the progress table). A busy loop: tempo does not scale it.
377
+ width: {
378
+ default: '40%',
379
+ '@media (prefers-reduced-motion: reduce)': '100%',
380
+ },
308
381
  borderRadius: radiusVars['--radius-full'],
309
382
  animationName: {
310
383
  default: indeterminateSlide,
311
- ':is([dir="rtl"] *)': indeterminateSlideRtl,
384
+ ':is([dir="rtl"] *)': {
385
+ default: indeterminateSlideRtl,
386
+ '@media (prefers-reduced-motion: reduce)': indeterminatePulse,
387
+ },
388
+ '@media (prefers-reduced-motion: reduce)': indeterminatePulse,
312
389
  },
313
390
  animationDuration: {
314
391
  default: '1.5s',
315
- '@media (prefers-reduced-motion: reduce)': '3s',
392
+ '@media (prefers-reduced-motion: reduce)': 'var(--_progress-bar-pulse)',
393
+ },
394
+ animationTimingFunction: {
395
+ default: 'ease-in-out',
396
+ '@media (prefers-reduced-motion: reduce)':
397
+ 'var(--_progress-bar-pulse-curve)',
398
+ },
399
+ animationDirection: {
400
+ default: null,
401
+ '@media (prefers-reduced-motion: reduce)': 'alternate',
316
402
  },
317
- animationTimingFunction: 'ease-in-out',
318
403
  animationIterationCount: 'infinite',
319
404
  },
320
405
  // A mark is a vertical tick centered on the track via their shared positioning container.
@@ -425,10 +510,41 @@ const markOnTrackStyles = stylex.create({
425
510
  },
426
511
  });
427
512
 
513
+ // The reduced-motion pulse's variables, from the progress table.
514
+ type PulseStyle = CSSProperties &
515
+ Record<
516
+ | '--_progress-bar-pulse'
517
+ | '--_progress-bar-pulse-curve'
518
+ | '--_progress-bar-pulse-floor',
519
+ string
520
+ >;
521
+
428
522
  // `CSSProperties` has no index signature for custom properties, so the fill's
429
523
  // inline style — which publishes the filled share as the private
430
524
  // `--_progress-bar-fill` — needs this augmentation to typecheck.
431
- type FillStyle = CSSProperties & Record<'--_progress-bar-fill', string>;
525
+ type FillStyle = CSSProperties &
526
+ Record<
527
+ | '--_progress-bar-fill'
528
+ | '--_progress-bar-move'
529
+ | '--_progress-bar-curve'
530
+ | '--_progress-bar-flip'
531
+ | '--_progress-bar-min',
532
+ string
533
+ >;
534
+
535
+ /**
536
+ * The reduced-motion pulse plays the `progress.reducedMotionPulseMs` role on
537
+ * the `timing.curves.move` curve, down to the progress table's floor.
538
+ */
539
+ function pulseStyle(element: Element | null): PulseStyle {
540
+ const pulseMs = resolveMotion(element, progressBar, 'reducedMotionPulseMs');
541
+ const curve = resolveMotion(element, timing.curves.move);
542
+ return {
543
+ '--_progress-bar-pulse': `${pulseMs}ms`,
544
+ '--_progress-bar-pulse-curve': formatMotionRoleValue('curve', curve),
545
+ '--_progress-bar-pulse-floor': String(progressBar.reducedMotionPulseFloor),
546
+ };
547
+ }
432
548
 
433
549
  function defaultFormatValueLabel(value: number, max: number): string {
434
550
  const pct = max > 0 ? Math.round((value / max) * 100) : 0;
@@ -509,7 +625,16 @@ export function ProgressBar({
509
625
  const showValueLabel = hasValueLabel && !isIndeterminate;
510
626
 
511
627
  const fillVariant = isDisabled ? 'disabled' : variant;
512
- const fillStyle: FillStyle = {'--_progress-bar-fill': `${percentage}%`};
628
+ // Under a finger the fill moves as UIProgressView's does and draws at least
629
+ // 16px; what that needs from script is the touch layer's (ProgressBarTouch).
630
+ const touch = useProgressBarTouch(
631
+ percentage,
632
+ !isIndeterminate && (marks?.length ?? 0) > 0,
633
+ );
634
+ const fillStyle: FillStyle = {
635
+ '--_progress-bar-fill': `${percentage}%`,
636
+ ...touch.fillVars,
637
+ };
513
638
 
514
639
  // Marks make no sense without a determinate value, so they are only drawn
515
640
  // in determinate mode. Non-finite mark values are dropped; the rest are
@@ -532,7 +657,7 @@ export function ProgressBar({
532
657
  value: mark.value,
533
658
  label: mark.label,
534
659
  pct,
535
- isOnFill: percentage > 0 && pct <= percentage,
660
+ isOnFill: percentage > 0 && pct <= touch.drawnPercentage,
536
661
  };
537
662
  })
538
663
  : [];
@@ -582,6 +707,7 @@ export function ProgressBar({
582
707
  aria-valuemax={isIndeterminate ? undefined : safeMax}
583
708
  aria-labelledby={labelId}
584
709
  aria-valuetext={isIndeterminate ? undefined : valueText}
710
+ ref={touch.trackRef}
585
711
  {...mergeProps(
586
712
  themeProps('progress-bar-track'),
587
713
  stylex.props(styles.track),
@@ -595,12 +721,17 @@ export function ProgressBar({
595
721
  variantStyles[fillVariant],
596
722
  ),
597
723
  )}
724
+ style={pulseStyle(null)}
598
725
  />
599
726
  ) : (
600
727
  <div
601
728
  {...mergeProps(
602
729
  themeProps('progress-bar-fill', {variant: fillVariant}),
603
- stylex.props(styles.fill, variantStyles[fillVariant]),
730
+ stylex.props(
731
+ styles.fill,
732
+ percentage === 0 && styles.fillEmpty,
733
+ variantStyles[fillVariant],
734
+ ),
604
735
  )}
605
736
  style={fillStyle}
606
737
  />
@@ -0,0 +1,109 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ 'use client';
4
+
5
+ /**
6
+ * @file ProgressBarTouch.ts
7
+ * @input Uses React, the touch presentation resolver, the shared resize
8
+ * observer, and the progress table's roles (through resolveMotion)
9
+ * @output useProgressBarTouch: the fill's touch variables (move length,
10
+ * curve, return offset, minimum length) and, under a finger with marks,
11
+ * the share of the track the fill draws
12
+ * @position ProgressBar's touch layer, imported only by ProgressBar.tsx
13
+ *
14
+ * SYNC: When modified, update /packages/core/src/ProgressBar/ProgressBar.tsx
15
+ * (its touch styles read these variables and the 16px minimum) and
16
+ * /packages/core/src/motion/uikit/progressBar.ts.
17
+ */
18
+
19
+ import {useEffect, useRef, useState, type RefObject} from 'react';
20
+ import {formatMotionRoleValue} from '../motion/roles';
21
+ import {resolveMotion} from '../motion/resolveMotion';
22
+ import {progressBar} from '../motion/uikit/progressBar';
23
+ import {observeResize} from '../utils/sharedResizeObserver';
24
+ import {useTouchPresentation} from '../utils/touchPresentation';
25
+
26
+ /** The track's height. */
27
+ const TRACK_HEIGHT_PX = 8;
28
+
29
+ /**
30
+ * Under a finger the fill never draws shorter than this above zero:
31
+ * UIProgressView's minimum, two track heights, on ProgressBar's 8px track.
32
+ */
33
+ export const TOUCH_MIN_FILL_PX =
34
+ progressBar.progressView.minimumFillHeights * TRACK_HEIGHT_PX;
35
+
36
+ export interface ProgressBarTouch {
37
+ /** The fill's touch timing variables, set inline on the fill. */
38
+ fillVars: Record<
39
+ | '--_progress-bar-move'
40
+ | '--_progress-bar-curve'
41
+ | '--_progress-bar-flip'
42
+ | '--_progress-bar-min',
43
+ string
44
+ >;
45
+ /** The track, measured under a finger when the bar has marks. */
46
+ trackRef: RefObject<HTMLDivElement | null>;
47
+ /** The share of the track the fill draws (0–100), for placing marks. */
48
+ drawnPercentage: number;
49
+ }
50
+
51
+ export function useProgressBarTouch(
52
+ percentage: number,
53
+ hasMarks: boolean,
54
+ ): ProgressBarTouch {
55
+ // The touch fill's duration comes from how far the value moved since the
56
+ // last value, the model change that UIKit measures. It holds until the next
57
+ // change, so a re-render with the same value keeps it.
58
+ const [motion, setMotion] = useState({percentage, change: 0, flip: 0});
59
+ const isNewValue = motion.percentage !== percentage;
60
+ // The share of the track the value moved (0–1). `flip` steps through three
61
+ // values on every change (see the fill's touch transform).
62
+ const change = isNewValue
63
+ ? Math.abs(percentage - motion.percentage) / 100
64
+ : motion.change;
65
+ const flip = isNewValue ? (motion.flip + 1) % 3 : motion.flip;
66
+ if (isNewValue) {
67
+ setMotion({percentage, change, flip});
68
+ }
69
+
70
+ // Under a finger the fill draws at least TOUCH_MIN_FILL_PX long, so a mark
71
+ // it covers there sits on the fill. That length is a share of the track's
72
+ // width, which only layout knows. So the track is measured, but only under
73
+ // a finger and only when the bar has marks.
74
+ const {isTouch} = useTouchPresentation();
75
+ const watchesTrack = isTouch && hasMarks;
76
+ const trackRef = useRef<HTMLDivElement | null>(null);
77
+ const [trackWidth, setTrackWidth] = useState(0);
78
+ useEffect(() => {
79
+ const element = trackRef.current;
80
+ if (!watchesTrack || element == null) {
81
+ return;
82
+ }
83
+ // The shared observer; its first call (on registration) carries no
84
+ // contentRect, so that one reads the box.
85
+ return observeResize(element, entry => {
86
+ setTrackWidth(
87
+ entry.contentRect?.width ?? element.getBoundingClientRect().width,
88
+ );
89
+ });
90
+ }, [watchesTrack]);
91
+ const drawnPercentage =
92
+ watchesTrack && percentage > 0 && trackWidth > 0
93
+ ? Math.max(percentage, (TOUCH_MIN_FILL_PX / trackWidth) * 100)
94
+ : percentage;
95
+
96
+ // CSS applies the theme's tempo to this duration.
97
+ const fillTrackMs = resolveMotion(null, progressBar, 'fillTrackMs');
98
+ const curve = resolveMotion(null, progressBar.curves.fill);
99
+ return {
100
+ fillVars: {
101
+ '--_progress-bar-move': `${Math.round(change * fillTrackMs * 1000) / 1000}ms`,
102
+ '--_progress-bar-curve': formatMotionRoleValue('curve', curve),
103
+ '--_progress-bar-flip': String(flip),
104
+ '--_progress-bar-min': `${TOUCH_MIN_FILL_PX}px`,
105
+ },
106
+ trackRef,
107
+ drawnPercentage,
108
+ };
109
+ }