@astryxdesign/core 0.7.7-canary.ae97fa8 → 0.7.7-canary.b377b21

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 (228) hide show
  1. package/dist/AlertDialog/AlertDialog.d.ts +4 -1
  2. package/dist/AlertDialog/AlertDialog.d.ts.map +1 -1
  3. package/dist/AlertDialog/AlertDialog.js +36 -6
  4. package/dist/BottomSheet/BottomSheetPanel.d.ts.map +1 -1
  5. package/dist/BottomSheet/BottomSheetPanel.js +22 -3
  6. package/dist/BottomSheet/useSheetGestures.d.ts.map +1 -1
  7. package/dist/BottomSheet/useSheetGestures.js +3 -1
  8. package/dist/Button/Button.d.ts.map +1 -1
  9. package/dist/Button/Button.js +17 -1
  10. package/dist/CheckboxInput/CheckboxInput.d.ts.map +1 -1
  11. package/dist/CheckboxInput/CheckboxInput.js +13 -2
  12. package/dist/ComplexSelector/ComplexSelector.d.ts +4 -1
  13. package/dist/ComplexSelector/ComplexSelector.d.ts.map +1 -1
  14. package/dist/ComplexSelector/ComplexSelector.js +14 -4
  15. package/dist/ContextMenu/ContextMenu.js +2 -2
  16. package/dist/Dialog/Dialog.d.ts.map +1 -1
  17. package/dist/Dialog/Dialog.js +7 -31
  18. package/dist/Field/Field.js +2 -2
  19. package/dist/HoverCard/HoverCard.d.ts +4 -3
  20. package/dist/HoverCard/HoverCard.d.ts.map +1 -1
  21. package/dist/HoverCard/useHoverCard.d.ts +5 -4
  22. package/dist/HoverCard/useHoverCard.d.ts.map +1 -1
  23. package/dist/HoverCard/useHoverCard.js +17 -31
  24. package/dist/Icon/Icon.d.ts +5 -1
  25. package/dist/Icon/Icon.d.ts.map +1 -1
  26. package/dist/Icon/Icon.js +5 -2
  27. package/dist/Layer/usePressPin.d.ts +17 -5
  28. package/dist/Layer/usePressPin.d.ts.map +1 -1
  29. package/dist/Layer/usePressPin.js +62 -12
  30. package/dist/Lightbox/Lightbox.d.ts.map +1 -1
  31. package/dist/Lightbox/Lightbox.js +18 -0
  32. package/dist/PowerSearch/PowerSearch.d.ts +36 -3
  33. package/dist/PowerSearch/PowerSearch.d.ts.map +1 -1
  34. package/dist/PowerSearch/PowerSearch.js +433 -127
  35. package/dist/PowerSearch/PowerSearchBottomSheet.d.ts +100 -0
  36. package/dist/PowerSearch/PowerSearchBottomSheet.d.ts.map +1 -0
  37. package/dist/PowerSearch/PowerSearchBottomSheet.js +1913 -0
  38. package/dist/PowerSearch/PowerSearchEditPopover.d.ts +32 -1
  39. package/dist/PowerSearch/PowerSearchEditPopover.d.ts.map +1 -1
  40. package/dist/PowerSearch/PowerSearchEditPopover.js +17 -6
  41. package/dist/PowerSearch/PowerSearchValueEditor.d.ts +38 -3
  42. package/dist/PowerSearch/PowerSearchValueEditor.d.ts.map +1 -1
  43. package/dist/PowerSearch/PowerSearchValueEditor.js +170 -61
  44. package/dist/PowerSearch/formatFilterValue.d.ts +7 -0
  45. package/dist/PowerSearch/formatFilterValue.d.ts.map +1 -1
  46. package/dist/PowerSearch/formatFilterValue.js +11 -1
  47. package/dist/PowerSearch/formatTokenValue.d.ts +28 -0
  48. package/dist/PowerSearch/formatTokenValue.d.ts.map +1 -0
  49. package/dist/PowerSearch/formatTokenValue.js +160 -0
  50. package/dist/PowerSearch/powerSearchSheetUtils.d.ts +65 -0
  51. package/dist/PowerSearch/powerSearchSheetUtils.d.ts.map +1 -0
  52. package/dist/PowerSearch/powerSearchSheetUtils.js +168 -0
  53. package/dist/RadioList/RadioList.d.ts.map +1 -1
  54. package/dist/RadioList/RadioList.js +3 -2
  55. package/dist/RadioList/RadioListItem.d.ts +1 -1
  56. package/dist/RadioList/RadioListItem.d.ts.map +1 -1
  57. package/dist/RadioList/RadioListItem.js +5 -3
  58. package/dist/Resizable/ResizeHandle.d.ts +2 -1
  59. package/dist/Resizable/ResizeHandle.d.ts.map +1 -1
  60. package/dist/Resizable/ResizeHandle.js +99 -77
  61. package/dist/SegmentedControl/useSegmentedSelectionMotion.d.ts.map +1 -1
  62. package/dist/SegmentedControl/useSegmentedSelectionMotion.js +3 -3
  63. package/dist/Slider/sliderTouchController.js +2 -2
  64. package/dist/Spinner/Spinner.d.ts +1 -1
  65. package/dist/Spinner/Spinner.d.ts.map +1 -1
  66. package/dist/Spinner/Spinner.js +60 -58
  67. package/dist/Switch/useSwitchTouch.js +4 -4
  68. package/dist/Table/BaseTable.d.ts.map +1 -1
  69. package/dist/Table/BaseTable.js +29 -8
  70. package/dist/TextInput/TextInput.d.ts +2 -2
  71. package/dist/TextInput/TextInput.d.ts.map +1 -1
  72. package/dist/TextInput/TextInput.js +10 -7
  73. package/dist/Toast/Toast.d.ts +11 -1
  74. package/dist/Toast/Toast.d.ts.map +1 -1
  75. package/dist/Toast/Toast.js +25 -15
  76. package/dist/Toast/ToastViewport.d.ts.map +1 -1
  77. package/dist/Toast/ToastViewport.js +94 -34
  78. package/dist/Toast/types.d.ts +9 -3
  79. package/dist/Toast/types.d.ts.map +1 -1
  80. package/dist/Tooltip/useTooltip.d.ts +19 -1
  81. package/dist/Tooltip/useTooltip.d.ts.map +1 -1
  82. package/dist/Tooltip/useTooltip.js +34 -53
  83. package/dist/Typeahead/TypeaheadItem.d.ts +2 -1
  84. package/dist/Typeahead/TypeaheadItem.d.ts.map +1 -1
  85. package/dist/animator/createAnimator.d.ts +98 -0
  86. package/dist/animator/createAnimator.d.ts.map +1 -0
  87. package/dist/animator/createAnimator.js +259 -0
  88. package/dist/astryx.css +35 -6
  89. package/dist/gestures/PressTracker.d.ts.map +1 -1
  90. package/dist/gestures/PressTracker.js +4 -3
  91. package/dist/hooks/actionGroupTracker.d.ts +4 -1
  92. package/dist/hooks/actionGroupTracker.d.ts.map +1 -1
  93. package/dist/hooks/actionGroupTracker.js +13 -3
  94. package/dist/hooks/index.d.ts +1 -0
  95. package/dist/hooks/index.d.ts.map +1 -1
  96. package/dist/hooks/index.js +1 -0
  97. package/dist/hooks/pressGroupContext.d.ts +6 -0
  98. package/dist/hooks/pressGroupContext.d.ts.map +1 -0
  99. package/dist/hooks/pressGroupContext.js +20 -0
  100. package/dist/hooks/useActionGroupTracking.d.ts +53 -0
  101. package/dist/hooks/useActionGroupTracking.d.ts.map +1 -0
  102. package/dist/hooks/useActionGroupTracking.js +174 -0
  103. package/dist/hooks/useAnimator.d.ts +41 -0
  104. package/dist/hooks/useAnimator.d.ts.map +1 -0
  105. package/dist/hooks/useAnimator.js +70 -0
  106. package/dist/hooks/useAttachment.d.ts +1 -1
  107. package/dist/hooks/useAttachment.js +1 -1
  108. package/dist/hooks/useInputStatusIcon.d.ts +1 -1
  109. package/dist/hooks/useInputStatusIcon.d.ts.map +1 -1
  110. package/dist/hooks/useInputStatusIcon.js +2 -2
  111. package/dist/hooks/useLongPress.d.ts +6 -2
  112. package/dist/hooks/useLongPress.d.ts.map +1 -1
  113. package/dist/hooks/useLongPress.js +95 -15
  114. package/dist/i18n/generated-english/PowerSearch/PowerSearchBottomSheet.generated.d.ts +8 -0
  115. package/dist/i18n/generated-english/PowerSearch/PowerSearchBottomSheet.generated.d.ts.map +1 -0
  116. package/dist/i18n/generated-english/PowerSearch/PowerSearchBottomSheet.generated.js +42 -0
  117. package/dist/i18n/generated-english/PowerSearch/PowerSearchValueEditor.generated.d.ts.map +1 -1
  118. package/dist/i18n/generated-english/PowerSearch/PowerSearchValueEditor.generated.js +6 -0
  119. package/dist/i18n/generated-english/PowerSearch/formatTokenValue.generated.d.ts +8 -0
  120. package/dist/i18n/generated-english/PowerSearch/formatTokenValue.generated.d.ts.map +1 -0
  121. package/dist/i18n/generated-english/PowerSearch/formatTokenValue.generated.js +14 -0
  122. package/dist/i18n/generated-locales/en.generated.d.ts.map +1 -1
  123. package/dist/i18n/generated-locales/en.generated.js +30 -0
  124. package/dist/i18n/generated-locales/pseudo.generated.d.ts.map +1 -1
  125. package/dist/i18n/generated-locales/pseudo.generated.js +30 -0
  126. package/dist/index.d.ts +1 -0
  127. package/dist/index.d.ts.map +1 -1
  128. package/dist/index.js +4 -0
  129. package/dist/internal/indicatorPress.stylex.d.ts +20 -0
  130. package/dist/internal/indicatorPress.stylex.d.ts.map +1 -0
  131. package/dist/internal/indicatorPress.stylex.js +26 -0
  132. package/dist/motion/animator.d.ts +14 -14
  133. package/dist/motion/animator.d.ts.map +1 -1
  134. package/dist/motion/animator.js +41 -18
  135. package/dist/motion/uikit/alert.d.ts +41 -0
  136. package/dist/motion/uikit/alert.d.ts.map +1 -0
  137. package/dist/motion/uikit/alert.js +43 -0
  138. package/dist/privateAttributes/pressGroupItem.stylex.d.ts +15 -0
  139. package/dist/privateAttributes/pressGroupItem.stylex.d.ts.map +1 -0
  140. package/dist/privateAttributes/pressGroupItem.stylex.js +23 -0
  141. package/dist/utils/focusReturn.d.ts +15 -0
  142. package/dist/utils/focusReturn.d.ts.map +1 -1
  143. package/dist/utils/focusReturn.js +28 -0
  144. package/dist/utils/interactionOverlay.stylex.d.ts +3 -2
  145. package/dist/utils/interactionOverlay.stylex.d.ts.map +1 -1
  146. package/dist/utils/interactionOverlay.stylex.js +3 -2
  147. package/dist/utils/pressFeedback.d.ts.map +1 -1
  148. package/dist/utils/pressFeedback.js +19 -6
  149. package/dist/utils/triggerFocus.d.ts +36 -0
  150. package/dist/utils/triggerFocus.d.ts.map +1 -0
  151. package/dist/utils/triggerFocus.js +65 -0
  152. package/locales/en.json +120 -0
  153. package/locales/pseudo.json +90 -0
  154. package/package.json +9 -4
  155. package/src/AlertDialog/AlertDialog.doc.mjs +2 -2
  156. package/src/AlertDialog/AlertDialog.tsx +37 -5
  157. package/src/BottomSheet/BottomSheetPanel.tsx +33 -3
  158. package/src/BottomSheet/useSheetGestures.ts +3 -1
  159. package/src/Button/Button.tsx +26 -4
  160. package/src/CheckboxInput/CheckboxInput.doc.mjs +18 -0
  161. package/src/CheckboxInput/CheckboxInput.tsx +36 -9
  162. package/src/ComplexSelector/ComplexSelector.tsx +20 -5
  163. package/src/ContextMenu/ContextMenu.tsx +2 -2
  164. package/src/Dialog/Dialog.tsx +10 -31
  165. package/src/Field/Field.tsx +2 -2
  166. package/src/HoverCard/HoverCard.doc.mjs +4 -2
  167. package/src/HoverCard/HoverCard.tsx +4 -3
  168. package/src/HoverCard/useHoverCard.doc.mjs +178 -36
  169. package/src/HoverCard/useHoverCard.tsx +46 -52
  170. package/src/Icon/Icon.doc.mjs +49 -8
  171. package/src/Icon/Icon.tsx +15 -7
  172. package/src/Layer/usePressPin.ts +81 -15
  173. package/src/Lightbox/Lightbox.tsx +18 -0
  174. package/src/PowerSearch/PowerSearch.doc.mjs +54 -5
  175. package/src/PowerSearch/PowerSearch.tsx +609 -155
  176. package/src/PowerSearch/PowerSearchBottomSheet.tsx +2515 -0
  177. package/src/PowerSearch/PowerSearchEditPopover.tsx +28 -6
  178. package/src/PowerSearch/PowerSearchValueEditor.tsx +203 -38
  179. package/src/PowerSearch/formatFilterValue.ts +18 -1
  180. package/src/PowerSearch/formatTokenValue.ts +215 -0
  181. package/src/PowerSearch/powerSearchSheetUtils.ts +224 -0
  182. package/src/RadioList/RadioList.tsx +3 -2
  183. package/src/RadioList/RadioListItem.tsx +5 -3
  184. package/src/Resizable/Resizable.doc.mjs +5 -4
  185. package/src/Resizable/ResizeHandle.tsx +167 -109
  186. package/src/SegmentedControl/useSegmentedSelectionMotion.ts +6 -3
  187. package/src/Slider/sliderTouchController.ts +2 -2
  188. package/src/Spinner/Spinner.tsx +77 -63
  189. package/src/Switch/useSwitchTouch.ts +4 -4
  190. package/src/Table/BaseTable.tsx +46 -12
  191. package/src/TextInput/TextInput.tsx +11 -10
  192. package/src/Toast/Toast.doc.mjs +4 -3
  193. package/src/Toast/Toast.tsx +52 -32
  194. package/src/Toast/ToastViewport.tsx +121 -35
  195. package/src/Toast/types.ts +9 -3
  196. package/src/Tooltip/useTooltip.doc.mjs +24 -0
  197. package/src/Tooltip/useTooltip.tsx +61 -56
  198. package/src/Typeahead/TypeaheadItem.doc.mjs +6 -3
  199. package/src/Typeahead/TypeaheadItem.tsx +2 -1
  200. package/src/animator/createAnimator.doc.mjs +153 -0
  201. package/src/animator/createAnimator.ts +324 -0
  202. package/src/gestures/PressTracker.ts +4 -3
  203. package/src/gestures/README.md +7 -1
  204. package/src/hooks/actionGroupTracker.ts +20 -5
  205. package/src/hooks/index.ts +2 -0
  206. package/src/hooks/pressGroupContext.ts +21 -0
  207. package/src/hooks/useActionGroupTracking.ts +224 -0
  208. package/src/hooks/useAnimator.doc.mjs +109 -0
  209. package/src/hooks/useAnimator.ts +67 -0
  210. package/src/hooks/useAttachment.ts +1 -1
  211. package/src/hooks/useInputStatusIcon.tsx +3 -2
  212. package/src/hooks/useLongPress.doc.mjs +2 -2
  213. package/src/hooks/useLongPress.ts +136 -15
  214. package/src/i18n/generated-english/PowerSearch/PowerSearchBottomSheet.generated.ts +43 -0
  215. package/src/i18n/generated-english/PowerSearch/PowerSearchValueEditor.generated.ts +6 -0
  216. package/src/i18n/generated-english/PowerSearch/formatTokenValue.generated.ts +15 -0
  217. package/src/i18n/generated-locales/en.generated.ts +41 -0
  218. package/src/i18n/generated-locales/pseudo.generated.ts +42 -0
  219. package/src/index.ts +13 -0
  220. package/src/internal/indicatorPress.stylex.ts +24 -0
  221. package/src/motion/README.md +8 -5
  222. package/src/motion/animator.ts +61 -29
  223. package/src/motion/uikit/alert.ts +43 -0
  224. package/src/privateAttributes/pressGroupItem.stylex.ts +23 -0
  225. package/src/utils/focusReturn.ts +30 -0
  226. package/src/utils/interactionOverlay.stylex.ts +3 -2
  227. package/src/utils/pressFeedback.ts +19 -6
  228. package/src/utils/triggerFocus.ts +80 -0
@@ -8,29 +8,12 @@
8
8
  * @output Styled drag handle with WAI-ARIA separator role and keyboard support
9
9
  * @position Between resizable panels; consumed directly by builders
10
10
  *
11
- * The handle element is 1px wide (the divider line itself), with an
12
- * absolutely-positioned wider hit area for pointer interaction.
13
- * Pill grip indicator can sit on either side of the divider (or centered)
14
- * via pillPlacement. Default 'auto' places the pill on the panel side and
15
- * flips when the panel collapses to 0px so it stays accessible.
16
- *
17
- * Pill placement uses a single stylex dynamic style that accepts a direction
18
- * multiplier (-1 or 1). The pill element has its own themeProps
19
- * ('resize-handle-pill') so themes can target size/shape directly.
20
- *
21
- * While the panel is collapsed, aria-valuenow is clamped to aria-valuemin
22
- * (a value below the minimum is invalid per WCAG 4.1.2) and a localized
23
- * "Collapsed" aria-valuetext announces the real state.
24
- *
25
- * The handle's input (the pointer drag, the keys, the double-click) is
26
- * resizeGesture.ts. ResizeHandle forwards its React events to it, over the
27
- * region its `resizable` prop describes. A drag takes pointer capture on the grab zone.
28
- * The gesture's move, up and cancel events then go there, as in Slider, even
29
- * over an embedded frame.
11
+ * A drag takes pointer capture on the element it starts on, so moves over an
12
+ * embedded frame still arrive.
30
13
  */
31
14
 
32
15
  import {useEffect, useRef, useState} from 'react';
33
- import type {ReactNode} from 'react';
16
+ import type {HTMLAttributes, ReactNode} from 'react';
34
17
  import * as stylex from '@stylexjs/stylex';
35
18
  import type {BaseProps} from '../BaseProps';
36
19
  import {
@@ -81,30 +64,6 @@ function resolveEffectiveSide(
81
64
  return panelSide;
82
65
  }
83
66
 
84
- /**
85
- * Hit-area inline bias, expressed as a fraction of the pill's per-side offset.
86
- *
87
- * The visible pill is placed by `pillOffsetX`/`pillOffsetY`: anchored at the
88
- * divider's inline-start edge and pushed one grip-width-plus-gap toward the
89
- * panel side via a PHYSICAL translate (dir −1 = start, +1 = end). Because that
90
- * offset is physical, the pill sits on the same physical side of the divider in
91
- * both LTR and RTL. The grab zone must sit OVER that pill, so it has to use the
92
- * exact same anchor + physical-offset construction as the pill (just wider) —
93
- * anchoring the hit area at the divider centre (`insetInlineStart: 50%`) and
94
- * biasing with a percentage translate mixes a logical anchor (which flips under
95
- * RTL) with a physical translate (which doesn't), leaving the grab zone stranded
96
- * to one side under RTL. Returning the pill's `dir` here lets the hit area reuse
97
- * `pillOffsetX`/`pillOffsetY` so the two elements are positioned identically.
98
- */
99
- function hitAreaBiasDir(
100
- effectiveSide: 'start' | 'end' | 'center',
101
- ): number | null {
102
- if (effectiveSide === 'center') {
103
- return null;
104
- }
105
- return effectiveSide === 'start' ? -1 : 1;
106
- }
107
-
108
67
  const styles = stylex.create({
109
68
  handle: {
110
69
  position: 'relative',
@@ -137,6 +96,12 @@ const styles = stylex.create({
137
96
  insetInlineEnd: 0,
138
97
  height: 'var(--resize-handle-hit-area, 16px)',
139
98
  },
99
+ overlayHorizontalOnShortPane: {
100
+ width: 'min(var(--resize-handle-hit-area, 16px), 50%)',
101
+ },
102
+ overlayVerticalOnShortPane: {
103
+ height: 'min(var(--resize-handle-hit-area, 16px), 50%)',
104
+ },
140
105
  horizontal: {
141
106
  width: 1,
142
107
  height: '100%',
@@ -196,19 +161,27 @@ const styles = stylex.create({
196
161
  ':is(:disabled,[aria-disabled="true"])': 'default',
197
162
  },
198
163
  },
199
- // Centered grab zone (pillPlacement 'center' / no bias): sit the hit area on
200
- // the divider itself. Inline centering comes from rtlStyles.centerInline at
201
- // the call site (correct in LTR and RTL); this block owns only the block axis.
164
+ // The grab zone sits centred on the Handle whatever `pillPlacement` says,
165
+ // which only places the pill. Inline centering comes from
166
+ // rtlStyles.centerInline at the call site (correct in LTR and RTL); this
167
+ // block owns only the block axis.
202
168
  hitAreaCenteredY: {
203
169
  insetBlockStart: '50%',
204
170
  transform: 'translateY(-50%)',
205
171
  },
172
+ hitAreaWithinHandleX: {
173
+ width: `min(${spacingVars['--spacing-4']}, 100%)`,
174
+ },
175
+ hitAreaWithinHandleY: {
176
+ height: `min(${spacingVars['--spacing-4']}, 100%)`,
177
+ },
206
178
 
207
179
  // Pill base — themes target .astryx-resize-handle-pill for size/shape.
208
180
  pill: {
209
181
  position: 'absolute',
210
182
  zIndex: 2,
211
- pointerEvents: 'none',
183
+ touchAction: 'none',
184
+ userSelect: 'none',
212
185
  borderRadius: radiusVars['--radius-full'],
213
186
  backgroundColor: colorVars['--color-border'],
214
187
  transitionProperty: 'opacity, background-color, transform',
@@ -216,6 +189,18 @@ const styles = stylex.create({
216
189
  transitionTimingFunction: easeVars['--ease-standard'],
217
190
  top: '50%',
218
191
  },
192
+ pillGrabHorizontal: {
193
+ cursor: {
194
+ default: 'col-resize',
195
+ ':is(:disabled,[aria-disabled="true"])': 'default',
196
+ },
197
+ },
198
+ pillGrabVertical: {
199
+ cursor: {
200
+ default: 'row-resize',
201
+ ':is(:disabled,[aria-disabled="true"])': 'default',
202
+ },
203
+ },
219
204
  pillHorizontal: {
220
205
  width: 3,
221
206
  height: spacingVars['--spacing-8'],
@@ -224,59 +209,93 @@ const styles = stylex.create({
224
209
  width: spacingVars['--spacing-8'],
225
210
  height: 3,
226
211
  },
227
- pillHidden: {opacity: 0},
212
+ // A hidden pill paints nothing, so it takes no pointer either.
213
+ pillHidden: {opacity: 0, pointerEvents: 'none'},
228
214
  pillVisible: {opacity: 1},
229
215
  pillHover: {
230
216
  opacity: 1,
217
+ pointerEvents: 'auto',
231
218
  backgroundColor: colorVars['--color-border'],
232
219
  },
233
220
  pillActive: {
234
221
  opacity: 1,
222
+ pointerEvents: 'auto',
235
223
  backgroundColor: colorVars['--color-border-emphasized'],
236
224
  },
225
+ // A pill shown on hover can sit outside the Grab zone. This invisible
226
+ // extension spans the gap toward the Handle, so the pointer keeps the hover
227
+ // on its way to the pill. It reaches no further than the pill, and it takes
228
+ // the pointer only while the pill does. The pill's offset is a physical
229
+ // translate, so the side toward the Handle is physical too.
230
+ pillBridgeRight: {
231
+ '::before': {
232
+ content: '""',
233
+ position: 'absolute',
234
+ insetBlock: 0,
235
+ insetInlineStart: {
236
+ default: 0,
237
+ ':is([dir="rtl"] *)': `calc(-1 * ${spacingVars['--spacing-1']} - 1px)`,
238
+ },
239
+ insetInlineEnd: {
240
+ default: `calc(-1 * ${spacingVars['--spacing-1']} - 1px)`,
241
+ ':is([dir="rtl"] *)': 0,
242
+ },
243
+ },
244
+ },
245
+ pillBridgeLeft: {
246
+ '::before': {
247
+ content: '""',
248
+ position: 'absolute',
249
+ insetBlock: 0,
250
+ insetInlineStart: {
251
+ default: `calc(-1 * ${spacingVars['--spacing-1']} - 1px)`,
252
+ ':is([dir="rtl"] *)': 0,
253
+ },
254
+ insetInlineEnd: {
255
+ default: 0,
256
+ ':is([dir="rtl"] *)': `calc(-1 * ${spacingVars['--spacing-1']} - 1px)`,
257
+ },
258
+ },
259
+ },
260
+ pillBridgeDown: {
261
+ '::before': {
262
+ content: '""',
263
+ position: 'absolute',
264
+ insetInline: 0,
265
+ insetBlockStart: 0,
266
+ insetBlockEnd: `calc(-1 * ${spacingVars['--spacing-1']} - 1px)`,
267
+ },
268
+ },
269
+ pillBridgeUp: {
270
+ '::before': {
271
+ content: '""',
272
+ position: 'absolute',
273
+ insetInline: 0,
274
+ insetBlockStart: `calc(-1 * ${spacingVars['--spacing-1']} - 1px)`,
275
+ insetBlockEnd: 0,
276
+ },
277
+ },
237
278
  });
238
279
 
239
280
  // Dynamic styles — avoids inline style overrides.
240
281
  // Each axis gets its own function since StyleX requires static structure.
241
282
  const dynamicStyles = stylex.create({
242
- // Hit-area inline offset — mirrors the pill's `pillOffsetX`/`pillOffsetY`
243
- // construction so the grab zone sits centred on the visible pill in BOTH
244
- // directions. Both anchor at the divider's inline-start edge
245
- // (`insetInlineStart: 0`) and travel toward the panel side with the SAME
246
- // physical translate `dir * (gripWidth + gap)`, so they share the pill's
247
- // near edge. The hit area is wider, so it also shifts by half the width
248
- // difference `(16px − 3px) / 2 = 6.5px` to align the two CENTRES. That
249
- // centring shift is along the inline axis (a box grows from its inline-start
250
- // edge toward inline-end), so it must flip physical sign under RTL — unlike
251
- // the `dir * (...)` travel, which is physical and identical in both
252
- // directions because the pill's own offset is physical. Mixing the previous
253
- // divider-relative `50%` anchor with a percentage translate stranded the grab
254
- // zone to one side under RTL; this construction keeps it on the pill.
255
- //
256
- // Only the offset axis is translated. Unlike the pill — a small box anchored
257
- // at the divider's midpoint, so it needs a −50% self-shift to centre — the
258
- // grab zone is STRETCHED along the handle by hitAreaHorizontal/Vertical's
259
- // 0/0 insets, so it is already in place on that axis. A percentage there
260
- // would displace it by half the handle's own length, growing with the panel.
261
- hitAreaOffsetX: (dir: number) => ({
262
- insetInlineStart: 0,
263
- transform: {
264
- default: `translateX(calc(${dir} * (3px + ${spacingVars['--spacing-1']}) - 6.5px))`,
265
- ':is([dir="rtl"] *)': `translateX(calc(${dir} * (3px + ${spacingVars['--spacing-1']}) + 6.5px))`,
266
- },
283
+ // An inline Handle's zone is centred on its line, so half of it reaches into
284
+ // the pane.
285
+ hitAreaLengthX: (pane: number) => ({
286
+ width: `min(${spacingVars['--spacing-4']}, ${pane}px)`,
267
287
  }),
268
- hitAreaOffsetY: (dir: number) => ({
269
- insetBlockStart: 0,
270
- transform: `translateY(calc(${dir} * (3px + ${spacingVars['--spacing-1']}) - 6.5px))`,
288
+ hitAreaLengthY: (pane: number) => ({
289
+ height: `min(${spacingVars['--spacing-4']}, ${pane}px)`,
271
290
  }),
272
- pillOffsetX: (dir: number) => ({
291
+ pillOffsetX: (dir: -1 | 1) => ({
273
292
  insetInlineStart: 0,
274
293
  transform: `translate(calc(${dir} * (100% + ${spacingVars['--spacing-1']})), -50%)`,
275
294
  }),
276
295
  // Vertical offset: no rotation — use explicit landscape dimensions.
277
296
  // Rotation + offset creates confusing coordinate math since translate
278
297
  // operates in pre-rotation local space.
279
- pillOffsetY: (dir: number) => ({
298
+ pillOffsetY: (dir: -1 | 1) => ({
280
299
  top: 0,
281
300
  transform: `translate(-50%, calc(${dir} * (100% + ${spacingVars['--spacing-1']})))`,
282
301
  }),
@@ -333,7 +352,8 @@ export interface ResizeHandleProps extends Omit<
333
352
  isAlwaysVisible?: boolean;
334
353
 
335
354
  /**
336
- * Which side of the divider line the pill sits on.
355
+ * Which side of the divider line the pill sits on. It moves only the pill:
356
+ * the grab zone stays centred on the divider.
337
357
  * - `'auto'` — panel side by default, flips when panel is collapsed to 0px
338
358
  * - `'start'` — left (horizontal) or top (vertical)
339
359
  * - `'end'` — right (horizontal) or bottom (vertical)
@@ -468,9 +488,12 @@ export function ResizeHandle({
468
488
  isReversed,
469
489
  isCollapsed,
470
490
  );
471
- // Physical offset direction shared by the pill and the grab zone (null =
472
- // centered / no bias). Keeps the two positioned identically in LTR and RTL.
473
- const hitBiasDir = hitAreaBiasDir(effectiveSide);
491
+ // An inline zone reaches at most half of the pane the Handle resizes. An
492
+ // overlay Handle takes at most half of it. A collapsed pane, or one at 0px,
493
+ // has nothing to protect and keeps the whole zone.
494
+ const paneSize = resizable?._size;
495
+ const paneLength =
496
+ isCollapsed || paneSize == null || paneSize <= 0 ? undefined : paneSize;
474
497
  const isInteracting = isHovered || isFocused;
475
498
 
476
499
  // A handle whose axis disagrees with its region drags along one axis while
@@ -533,6 +556,35 @@ export function ResizeHandle({
533
556
  ? t('@astryx.resizable.collapsed')
534
557
  : undefined;
535
558
 
559
+ // The Grip pill takes the pointer as the Grab zone does, so a pill drawn
560
+ // outside the zone still resizes.
561
+ const grabHandlers: Pick<
562
+ HTMLAttributes<HTMLDivElement>,
563
+ | 'onPointerDown'
564
+ | 'onPointerMove'
565
+ | 'onPointerUp'
566
+ | 'onPointerCancel'
567
+ | 'onLostPointerCapture'
568
+ | 'onPointerEnter'
569
+ | 'onPointerLeave'
570
+ > = {
571
+ onPointerDown: e => {
572
+ if (!isDisabled) {
573
+ gesture?.pointerDown(e);
574
+ }
575
+ },
576
+ onPointerMove: e => gesture?.pointerMove(e),
577
+ onPointerUp: e => gesture?.pointerUp(e),
578
+ onPointerCancel: e => gesture?.pointerCancel(e),
579
+ onLostPointerCapture: e => gesture?.pointerCancel(e),
580
+ onPointerEnter: () => setIsHovered(true),
581
+ onPointerLeave: () => {
582
+ if (!isDragging) {
583
+ setIsHovered(false);
584
+ }
585
+ },
586
+ };
587
+
536
588
  return (
537
589
  <div
538
590
  ref={useMergedRefs(ref, handleRef)}
@@ -560,6 +612,11 @@ export function ResizeHandle({
560
612
  isOverlay && styles.overlay,
561
613
  isOverlay &&
562
614
  (isHorizontal ? styles.overlayHorizontal : styles.overlayVertical),
615
+ isOverlay &&
616
+ paneLength != null &&
617
+ (isHorizontal
618
+ ? styles.overlayHorizontalOnShortPane
619
+ : styles.overlayVerticalOnShortPane),
563
620
  !isOverlay && (isHorizontal ? styles.horizontal : styles.vertical),
564
621
  !isOverlay &&
565
622
  !hasDivider &&
@@ -594,34 +651,21 @@ export function ResizeHandle({
594
651
  {...stylex.props(
595
652
  styles.hitArea,
596
653
  isHorizontal ? styles.hitAreaHorizontal : styles.hitAreaVertical,
597
- // Bias the grab zone to sit over the visible pill. When the pill is
598
- // centered (no bias) just center the hit area on the divider; when
599
- // it's offset to a panel side, reuse the pill's physical-offset
600
- // construction so the two stay aligned in LTR and RTL alike.
601
- hitBiasDir == null
602
- ? isHorizontal
603
- ? rtlStyles.centerInline('0px')
604
- : styles.hitAreaCenteredY
605
- : isHorizontal
606
- ? dynamicStyles.hitAreaOffsetX(hitBiasDir)
607
- : dynamicStyles.hitAreaOffsetY(hitBiasDir),
654
+ isHorizontal
655
+ ? rtlStyles.centerInline('0px')
656
+ : styles.hitAreaCenteredY,
657
+ isOverlay &&
658
+ (isHorizontal
659
+ ? styles.hitAreaWithinHandleX
660
+ : styles.hitAreaWithinHandleY),
661
+ !isOverlay &&
662
+ paneLength != null &&
663
+ (isHorizontal
664
+ ? dynamicStyles.hitAreaLengthX(paneLength)
665
+ : dynamicStyles.hitAreaLengthY(paneLength)),
608
666
  isDisabled && styles.disabled,
609
667
  )}
610
- onPointerDown={e => {
611
- if (!isDisabled) {
612
- gesture?.pointerDown(e);
613
- }
614
- }}
615
- onPointerMove={e => gesture?.pointerMove(e)}
616
- onPointerUp={e => gesture?.pointerUp(e)}
617
- onPointerCancel={e => gesture?.pointerCancel(e)}
618
- onLostPointerCapture={e => gesture?.pointerCancel(e)}
619
- onPointerEnter={() => setIsHovered(true)}
620
- onPointerLeave={() => {
621
- if (!isDragging) {
622
- setIsHovered(false);
623
- }
624
- }}
668
+ {...grabHandlers}
625
669
  />
626
670
  {/* Pill grip indicator — themed via .astryx-resize-handle-pill */}
627
671
  {children ?? (
@@ -640,11 +684,25 @@ export function ResizeHandle({
640
684
  : dynamicStyles.pillOffsetY(
641
685
  effectiveSide === 'start' ? -1 : 1,
642
686
  ),
687
+ isHorizontal
688
+ ? styles.pillGrabHorizontal
689
+ : styles.pillGrabVertical,
643
690
  isAlwaysVisible ? styles.pillVisible : styles.pillHidden,
691
+ !isAlwaysVisible &&
692
+ effectiveSide !== 'center' &&
693
+ (isHorizontal
694
+ ? effectiveSide === 'start'
695
+ ? styles.pillBridgeRight
696
+ : styles.pillBridgeLeft
697
+ : effectiveSide === 'start'
698
+ ? styles.pillBridgeDown
699
+ : styles.pillBridgeUp),
644
700
  isInteracting && !isDragging && styles.pillHover,
645
701
  isDragging && styles.pillActive,
702
+ isDisabled && styles.disabled,
646
703
  ),
647
704
  )}
705
+ {...grabHandlers}
648
706
  />
649
707
  )}
650
708
  </div>
@@ -25,7 +25,7 @@ import {
25
25
  type RefObject,
26
26
  } from 'react';
27
27
  import {
28
- createAnimator,
28
+ createMotionAnimator,
29
29
  type AnimateOptions,
30
30
  type Animator,
31
31
  type MotionCurve,
@@ -539,7 +539,7 @@ export class SegmentedSelectionMotion {
539
539
  private readonly after: Animator;
540
540
 
541
541
  constructor(
542
- private readonly animator: Animator = createAnimator(null, {
542
+ private readonly animator: Animator = createMotionAnimator(null, {
543
543
  pseudoElement: '::before',
544
544
  }),
545
545
  options: SegmentedSelectionMotionOptions = {},
@@ -552,7 +552,10 @@ export class SegmentedSelectionMotion {
552
552
  this.now = options.now ?? defaultNow;
553
553
  this.after =
554
554
  options.afterAnimator ??
555
- createAnimator(null, {pseudoElement: '::after', initial: {opacity: 0}});
555
+ createMotionAnimator(null, {
556
+ pseudoElement: '::after',
557
+ initial: {opacity: 0},
558
+ });
556
559
  }
557
560
 
558
561
  /** Attaches the radiogroup whose pseudo-elements are the lens (null detaches). */
@@ -29,7 +29,7 @@ import {TapGestureRecognizer} from '../gestures/TapGestureRecognizer';
29
29
  import type {PointerKind} from '../gestures/types';
30
30
  import {isRtlElement} from '../hooks/isRtlElement';
31
31
  import {
32
- createAnimator,
32
+ createMotionAnimator,
33
33
  type Animator,
34
34
  type MotionCurve,
35
35
  type MotionHandle,
@@ -1186,7 +1186,7 @@ export class SliderTouchController {
1186
1186
  private animatorFor(element: HTMLElement): Animator {
1187
1187
  let animator = this.animators.get(element);
1188
1188
  if (animator == null) {
1189
- animator = createAnimator(element);
1189
+ animator = createMotionAnimator(element);
1190
1190
  this.animators.set(element, animator);
1191
1191
  }
1192
1192
  return animator;
@@ -10,7 +10,7 @@
10
10
  *
11
11
  * SYNC: When modified, update these files to stay in sync:
12
12
  * - /packages/core/src/Spinner/Spinner.doc.mjs
13
- * - /packages/core/src/Spinner/Spinner.test.tsx
13
+ * - /packages/core/src/Spinner/Spinner.test.tsx and __tests__/SpinnerMotion.a11y.chromium.spec.ts (rotor timing)
14
14
  * - /packages/core/src/Spinner/index.ts
15
15
  * - /apps/storybook/stories/Spinner.stories.tsx
16
16
  * - /packages/cli/assets/templates/blocks/components/Spinner/ (showcase blocks)
@@ -153,60 +153,57 @@ function registerSpinnerVars(): void {
153
153
  registerSpinnerVars();
154
154
 
155
155
  /**
156
- * Pin every ring's dash motion to the document timeline's origin instead of
157
- * its own start time, so spinners mounted seconds apart stay in phase.
156
+ * Pin every ring's rotation to the document timeline's origin instead of its
157
+ * own start time, so spinners mounted seconds apart stay in phase.
158
158
  *
159
- * Setting `startTime` is exact where arithmetic on a clock read is not: a
159
+ * Setting `startTime` is exact where arithmetic on a clock read is not. A
160
160
  * negative `animation-delay` computed at mount is only as good as the gap
161
- * between reading the clock and the frame the animation starts in, which at
162
- * 10x CPU throttling measured 116deg of drift (from when this animated a
163
- * literal rotation; the same drift risk applies to the dash offset now).
161
+ * between the clock read and the frame the animation starts in. At 10x CPU
162
+ * throttling that gap measured 116 degrees of drift.
164
163
  *
165
- * Rings are collected and pinned in one frame because `getAnimations()`
166
- * resolves style and `startTime` dirties it again, so pinning them one at a
167
- * time makes each mount re-force what the previous one invalidated — 53 style
168
- * recalcs for 38 spinners against 19 batched.
164
+ * Rotors are collected and pinned in one frame. `getAnimations()` resolves
165
+ * style, and `startTime` dirties it again. So pinning them one at a time makes
166
+ * each mount re-force what the previous one invalidated: 53 style recalcs for
167
+ * 38 spinners, against 19 batched.
169
168
  */
170
- const pendingRings = new Set<SVGSVGElement>();
169
+ const pendingRotors = new Set<HTMLSpanElement>();
171
170
  let flushScheduled = false;
172
171
 
173
- function pinRingsToTimelineOrigin(): void {
172
+ function pinRotorsToTimelineOrigin(): void {
174
173
  flushScheduled = false;
175
174
  const animations: Animation[] = [];
176
- for (const svg of pendingRings) {
177
- // The animation now runs on the arc <circle>, a descendant of the ring
178
- // this ref sits on (#6253), so it needs subtree:true to be found here.
179
- animations.push(...svg.getAnimations({subtree: true}));
175
+ for (const rotor of pendingRotors) {
176
+ animations.push(...rotor.getAnimations());
180
177
  }
181
- pendingRings.clear();
178
+ pendingRotors.clear();
182
179
  for (const animation of animations) {
183
180
  animation.startTime = 0;
184
181
  }
185
182
  }
186
183
 
187
184
  /**
188
- * Ref callback for the ring: the one place a mounted ring touches the DOM.
185
+ * Ref callback for the rotor: the one place a mounted ring touches the DOM.
189
186
  *
190
187
  * It reads nothing back — the geometry is resolved by the cascade, not in JS.
191
188
  */
192
189
  function syncRotationPhase(
193
- svg: SVGSVGElement | null,
190
+ rotor: HTMLSpanElement | null,
194
191
  ): (() => void) | undefined {
195
- if (svg == null) {
192
+ if (rotor == null) {
196
193
  return undefined;
197
194
  }
198
195
  // jsdom implements no Web Animations, and this runs in every consumer's
199
196
  // component tests.
200
- if (typeof svg.getAnimations !== 'function') {
197
+ if (typeof rotor.getAnimations !== 'function') {
201
198
  return undefined;
202
199
  }
203
- pendingRings.add(svg);
200
+ pendingRotors.add(rotor);
204
201
  if (!flushScheduled) {
205
202
  flushScheduled = true;
206
- requestAnimationFrame(pinRingsToTimelineOrigin);
203
+ requestAnimationFrame(pinRotorsToTimelineOrigin);
207
204
  }
208
205
  return () => {
209
- pendingRings.delete(svg);
206
+ pendingRotors.delete(rotor);
210
207
  };
211
208
  }
212
209
 
@@ -215,19 +212,13 @@ function syncRotationPhase(
215
212
  // =============================================================================
216
213
 
217
214
  /**
218
- * Moves the dash around a stationary circle instead of rotating the whole
219
- * ring. WebKit re-antialiases a rotating stroked shape's rounded cap
220
- * (`strokeLinecap: 'round'`, below) slightly differently at each intermediate
221
- * angle, which reads as a visible wobble on iOS Safari independent of
222
- * compositing (#6253) — confirmed by a real-device test against this exact
223
- * fix, since `willChange`/layer promotion alone (the earlier fix for #3617)
224
- * only smooths the rotation's motion, not the per-frame stroke rasterization.
225
- * A static circle with an animated `stroke-dashoffset` never rotates, so
226
- * WebKit never re-rasterizes the cap at an intermediate angle at all.
215
+ * A `transform` animation runs on the compositor, so the ring keeps turning
216
+ * while a script runs. A `stroke-dashoffset` animation would repaint the arc
217
+ * on the main thread every frame, so it would stop while a script runs.
227
218
  */
228
- const dashMotion = stylex.keyframes({
229
- '0%': {strokeDashoffset: '0px'},
230
- '100%': {strokeDashoffset: `calc(var(${RESOLVED_DIAMETER}) * -${PI})`},
219
+ const rotation = stylex.keyframes({
220
+ '0%': {transform: 'rotate(0deg)'},
221
+ '100%': {transform: 'rotate(360deg)'},
231
222
  });
232
223
 
233
224
  // =============================================================================
@@ -283,6 +274,8 @@ const styles = stylex.create({
283
274
  ring: {
284
275
  backfaceVisibility: 'hidden',
285
276
  display: 'block',
277
+ // The track and the rotor share the one grid cell, stacked and centred.
278
+ gridArea: '1 / 1',
286
279
  // The frame is the box, read from the same composed var the span is sized
287
280
  // from — not from the size constant, which a theme cannot reach, and not
288
281
  // from a percentage, which needs the grid area to be a definite size in
@@ -300,6 +293,27 @@ const styles = stylex.create({
300
293
  // which put the ring 1.5-3.3px off across the four sizes.
301
294
  overflow: 'visible',
302
295
  },
296
+ // The rotor is the frame's size, so it turns about the ring's centre.
297
+ rotor: {
298
+ display: 'block',
299
+ gridArea: '1 / 1',
300
+ width: `var(${BOX_SIZE})`,
301
+ height: `var(${BOX_SIZE})`,
302
+ // Mobile Safari keeps a re-timed animation off the main thread only on an
303
+ // element with its own layer. So the rotor declares one before the start
304
+ // time is set (see `pinRotorsToTimelineOrigin`).
305
+ willChange: 'transform',
306
+ // Reduced motion slows the turn but never stops it: a still ring reads as
307
+ // broken. The status role and its name still say that work is busy.
308
+ animationDuration: {
309
+ // The loop reads the duration before a theme's tempo scales it.
310
+ default: `var(--_astryx-pre-tempo-duration-slow-min, ${durationVars['--duration-slow-min']})`,
311
+ '@media (prefers-reduced-motion: reduce)': '3s',
312
+ },
313
+ animationIterationCount: 'infinite',
314
+ animationName: rotation,
315
+ animationTimingFunction: 'linear',
316
+ },
303
317
  circle: {
304
318
  fill: 'none',
305
319
  // The ring is centred on the box (cx/cy 50%), so the arc's start offset
@@ -336,18 +350,6 @@ const styles = stylex.create({
336
350
  // `0` the way the registered length pair guards against, so it needs no
337
351
  // registration.
338
352
  arc: {
339
- // Slow the motion dramatically under reduced-motion rather than freezing
340
- // it (a frozen spinner reads as broken), matching ProgressBar's approach.
341
- // The role="status" + "Loading" label still convey busy state (obs-6).
342
- animationDuration: {
343
- // The loop reads the duration before a theme's tempo scales it
344
- // (spec:AST-108 FR4).
345
- default: `var(--_astryx-pre-tempo-duration-slow-min, ${durationVars['--duration-slow-min']})`,
346
- '@media (prefers-reduced-motion: reduce)': '3s',
347
- },
348
- animationIterationCount: 'infinite',
349
- animationName: dashMotion,
350
- animationTimingFunction: 'linear',
351
353
  stroke: 'var(--spinner-color)',
352
354
  transform: 'rotate(-90deg)',
353
355
  strokeDasharray: `calc(var(${RESOLVED_DIAMETER}) * ${PI} * var(--spinner-arc-fraction)) calc(var(${RESOLVED_DIAMETER}) * ${PI} * (1 - var(--spinner-arc-fraction)))`,
@@ -569,10 +571,12 @@ export function Spinner({
569
571
  height: `var(${BOX_SIZE}, ${frameSize}px)`,
570
572
  },
571
573
  )}>
574
+ {/* The track is zero-sized by attribute. With no stylesheet, the
575
+ rotor's arc alone fills the box, instead of two discs side by side.
576
+ `ring` sizes both to the box. */}
572
577
  <svg
573
- ref={syncRotationPhase}
574
- width={frameSize}
575
- height={frameSize}
578
+ width={0}
579
+ height={0}
576
580
  aria-hidden="true"
577
581
  {...stylex.props(styles.ring)}>
578
582
  <circle
@@ -586,18 +590,28 @@ export function Spinner({
586
590
  trackOpacityStyles[shade],
587
591
  )}
588
592
  />
589
- <circle
590
- cx="50%"
591
- cy="50%"
592
- r={diameter / 2}
593
- strokeWidth={border}
594
- // The size's own dash, for the render with no stylesheet. The rule
595
- // above composes the same lengths from the resolved diameter, so a
596
- // themed ring keeps this fraction of arc rather than this length.
597
- strokeDasharray={`${arcLength} ${circumference - arcLength}`}
598
- {...stylex.props(styles.circle, styles.arc)}
599
- />
600
593
  </svg>
594
+ <span
595
+ ref={syncRotationPhase}
596
+ aria-hidden="true"
597
+ {...stylex.props(styles.rotor)}>
598
+ <svg
599
+ width={frameSize}
600
+ height={frameSize}
601
+ {...stylex.props(styles.ring)}>
602
+ <circle
603
+ cx="50%"
604
+ cy="50%"
605
+ r={diameter / 2}
606
+ strokeWidth={border}
607
+ // The size's own dash, for the render with no stylesheet. The rule
608
+ // above composes the same lengths from the resolved diameter, so a
609
+ // themed ring keeps this fraction of arc rather than this length.
610
+ strokeDasharray={`${arcLength} ${circumference - arcLength}`}
611
+ {...stylex.props(styles.circle, styles.arc)}
612
+ />
613
+ </svg>
614
+ </span>
601
615
  </span>
602
616
  );
603
617