@astryxdesign/core 0.7.8-canary.ffd29c5 → 0.7.8

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 (189) hide show
  1. package/CHANGELOG.md +132 -0
  2. package/dist/BottomSheet/BottomSheetPanel.d.ts +1 -1
  3. package/dist/BottomSheet/BottomSheetPanel.d.ts.map +1 -1
  4. package/dist/BottomSheet/BottomSheetPanel.js +30 -14
  5. package/dist/BottomSheet/bottomSheetStack.js +1 -1
  6. package/dist/BottomSheet/useSheetGestures.d.ts +3 -1
  7. package/dist/BottomSheet/useSheetGestures.d.ts.map +1 -1
  8. package/dist/BottomSheet/useSheetGestures.js +4 -0
  9. package/dist/CheckboxList/CheckboxList.d.ts +13 -2
  10. package/dist/CheckboxList/CheckboxList.d.ts.map +1 -1
  11. package/dist/CheckboxList/CheckboxList.js +10 -4
  12. package/dist/CheckboxList/CheckboxListContext.d.ts +7 -0
  13. package/dist/CheckboxList/CheckboxListContext.d.ts.map +1 -1
  14. package/dist/CheckboxList/CheckboxListContext.js +2 -1
  15. package/dist/CheckboxList/CheckboxListItem.d.ts.map +1 -1
  16. package/dist/CheckboxList/CheckboxListItem.js +14 -2
  17. package/dist/CheckboxList/requiredGroup.d.ts +22 -0
  18. package/dist/CheckboxList/requiredGroup.d.ts.map +1 -0
  19. package/dist/CheckboxList/requiredGroup.js +52 -0
  20. package/dist/DateInput/PickerWheel.d.ts +1 -1
  21. package/dist/DateInput/PickerWheel.d.ts.map +1 -1
  22. package/dist/DateInput/PickerWheel.js +27 -6
  23. package/dist/DateInput/Wheel.d.ts +2 -2
  24. package/dist/DateInput/Wheel.d.ts.map +1 -1
  25. package/dist/DateInput/Wheel.js +20 -6
  26. package/dist/DateInput/datePickerTouch.d.ts +13 -0
  27. package/dist/DateInput/datePickerTouch.d.ts.map +1 -0
  28. package/dist/DateInput/datePickerTouch.js +14 -0
  29. package/dist/DateInput/wheelOptions.d.ts +22 -2
  30. package/dist/DateInput/wheelOptions.d.ts.map +1 -1
  31. package/dist/DateInput/wheelOptions.js +14 -2
  32. package/dist/DateTimeInput/TouchDateTimeField.d.ts.map +1 -1
  33. package/dist/DateTimeInput/TouchDateTimeField.js +62 -119
  34. package/dist/HoverCard/useHoverCard.d.ts.map +1 -1
  35. package/dist/HoverCard/useHoverCard.js +6 -1
  36. package/dist/Overlay/Overlay.d.ts.map +1 -1
  37. package/dist/Overlay/Overlay.js +7 -2
  38. package/dist/PowerSearch/PowerSearch.d.ts +4 -2
  39. package/dist/PowerSearch/PowerSearch.d.ts.map +1 -1
  40. package/dist/PowerSearch/PowerSearchBottomSheet.d.ts +24 -1
  41. package/dist/PowerSearch/PowerSearchBottomSheet.d.ts.map +1 -1
  42. package/dist/PowerSearch/PowerSearchBottomSheet.js +172 -50
  43. package/dist/Resizable/ResizeHandle.d.ts.map +1 -1
  44. package/dist/Resizable/ResizeHandle.js +15 -15
  45. package/dist/Resizable/createResizable.d.ts +70 -16
  46. package/dist/Resizable/createResizable.d.ts.map +1 -1
  47. package/dist/Resizable/createResizable.js +180 -22
  48. package/dist/Resizable/separatorAttributes.d.ts +45 -0
  49. package/dist/Resizable/separatorAttributes.d.ts.map +1 -0
  50. package/dist/Resizable/separatorAttributes.js +35 -0
  51. package/dist/Resizable/useResizable.js +2 -2
  52. package/dist/TimeInput/TimeWheels.d.ts +32 -0
  53. package/dist/TimeInput/TimeWheels.d.ts.map +1 -0
  54. package/dist/TimeInput/TimeWheels.js +144 -0
  55. package/dist/TimeInput/TouchTimeField.d.ts.map +1 -1
  56. package/dist/TimeInput/TouchTimeField.js +63 -118
  57. package/dist/TimeInput/timeWheelRules.d.ts +124 -0
  58. package/dist/TimeInput/timeWheelRules.d.ts.map +1 -0
  59. package/dist/TimeInput/timeWheelRules.js +277 -0
  60. package/dist/Tokenizer/TokenizerBottomSheet.js +8 -12
  61. package/dist/Tooltip/useTooltip.d.ts.map +1 -1
  62. package/dist/Tooltip/useTooltip.js +6 -0
  63. package/dist/astryx.css +3 -1
  64. package/dist/gestures/GestureHost.d.ts.map +1 -1
  65. package/dist/gestures/GestureHost.js +3 -0
  66. package/dist/i18n/generated-english/PowerSearch/PowerSearchBottomSheet.generated.d.ts.map +1 -1
  67. package/dist/i18n/generated-english/PowerSearch/PowerSearchBottomSheet.generated.js +5 -2
  68. package/dist/i18n/generated-english/Resizable/createResizable.generated.d.ts +8 -0
  69. package/dist/i18n/generated-english/Resizable/createResizable.generated.d.ts.map +1 -0
  70. package/dist/i18n/generated-english/Resizable/createResizable.generated.js +13 -0
  71. package/dist/i18n/generated-english/TimeInput/TimeWheels.generated.d.ts +8 -0
  72. package/dist/i18n/generated-english/TimeInput/TimeWheels.generated.d.ts.map +1 -0
  73. package/dist/i18n/generated-english/TimeInput/TimeWheels.generated.js +17 -0
  74. package/dist/i18n/generated-locales/en.generated.d.ts.map +1 -1
  75. package/dist/i18n/generated-locales/en.generated.js +5 -2
  76. package/dist/i18n/generated-locales/pseudo.generated.d.ts.map +1 -1
  77. package/dist/i18n/generated-locales/pseudo.generated.js +5 -2
  78. package/dist/keyboard/index.d.ts +17 -0
  79. package/dist/keyboard/index.d.ts.map +1 -0
  80. package/dist/keyboard/index.js +19 -0
  81. package/dist/keyboard/keyboardHeight.d.ts +149 -0
  82. package/dist/keyboard/keyboardHeight.d.ts.map +1 -0
  83. package/dist/keyboard/keyboardHeight.js +432 -0
  84. package/dist/keyboard/keyboardLayoutGuide.d.ts +59 -0
  85. package/dist/keyboard/keyboardLayoutGuide.d.ts.map +1 -0
  86. package/dist/keyboard/keyboardLayoutGuide.js +207 -0
  87. package/dist/keyboard/keyboardModel.d.ts +93 -0
  88. package/dist/keyboard/keyboardModel.d.ts.map +1 -0
  89. package/dist/keyboard/keyboardModel.js +220 -0
  90. package/dist/keyboard/rideKeyboard.d.ts +42 -0
  91. package/dist/keyboard/rideKeyboard.d.ts.map +1 -0
  92. package/dist/keyboard/rideKeyboard.js +77 -0
  93. package/dist/keyboard/useMobileKeyboard.d.ts +61 -0
  94. package/dist/keyboard/useMobileKeyboard.d.ts.map +1 -0
  95. package/dist/keyboard/useMobileKeyboard.js +699 -0
  96. package/dist/motion/android/index.d.ts +16 -0
  97. package/dist/motion/android/index.d.ts.map +1 -0
  98. package/dist/motion/android/index.js +18 -0
  99. package/dist/motion/android/keyboard.d.ts +83 -0
  100. package/dist/motion/android/keyboard.d.ts.map +1 -0
  101. package/dist/motion/android/keyboard.js +96 -0
  102. package/dist/motion/uikit/datePicker.d.ts +58 -0
  103. package/dist/motion/uikit/datePicker.d.ts.map +1 -0
  104. package/dist/motion/uikit/datePicker.js +60 -0
  105. package/dist/motion/uikit/index.d.ts +4 -3
  106. package/dist/motion/uikit/index.d.ts.map +1 -1
  107. package/dist/motion/uikit/index.js +4 -3
  108. package/dist/utils/interactionOverlay.stylex.d.ts +3 -2
  109. package/dist/utils/interactionOverlay.stylex.d.ts.map +1 -1
  110. package/dist/utils/interactionOverlay.stylex.js +3 -2
  111. package/dist/utils/layoutViewport.d.ts +25 -0
  112. package/dist/utils/layoutViewport.d.ts.map +1 -0
  113. package/dist/utils/layoutViewport.js +102 -0
  114. package/dist/utils/pressFeedback.d.ts.map +1 -1
  115. package/dist/utils/pressFeedback.js +74 -9
  116. package/dist/utils/pressGesture.d.ts +4 -2
  117. package/dist/utils/pressGesture.d.ts.map +1 -1
  118. package/dist/utils/pressGesture.js +3 -1
  119. package/dist/utils/selectionLock.d.ts +10 -1
  120. package/dist/utils/selectionLock.d.ts.map +1 -1
  121. package/dist/utils/selectionLock.js +31 -5
  122. package/dist/utils/sharedMutationObserver.d.ts +2 -1
  123. package/dist/utils/sharedMutationObserver.d.ts.map +1 -1
  124. package/dist/utils/sharedMutationObserver.js +2 -1
  125. package/dist/utils/timeParser.d.ts +2 -1
  126. package/dist/utils/timeParser.d.ts.map +1 -1
  127. package/dist/utils/timeParser.js +29 -3
  128. package/locales/en.json +21 -9
  129. package/locales/pseudo.json +15 -6
  130. package/package.json +5 -5
  131. package/src/BottomSheet/BottomSheet.doc.mjs +1 -1
  132. package/src/BottomSheet/BottomSheetPanel.tsx +41 -13
  133. package/src/BottomSheet/BottomSheetSwitcher.doc.mjs +5 -0
  134. package/src/BottomSheet/bottomSheetStack.ts +1 -1
  135. package/src/BottomSheet/useSheetGestures.ts +6 -0
  136. package/src/CheckboxList/CheckboxList.doc.mjs +7 -0
  137. package/src/CheckboxList/CheckboxList.tsx +34 -2
  138. package/src/CheckboxList/CheckboxListContext.tsx +9 -1
  139. package/src/CheckboxList/CheckboxListItem.tsx +23 -2
  140. package/src/CheckboxList/requiredGroup.ts +77 -0
  141. package/src/DateInput/PickerWheel.tsx +37 -4
  142. package/src/DateInput/Wheel.tsx +30 -3
  143. package/src/DateInput/datePickerTouch.ts +15 -0
  144. package/src/DateInput/wheelOptions.ts +31 -2
  145. package/src/DateTimeInput/TouchDateTimeField.tsx +94 -178
  146. package/src/HoverCard/useHoverCard.tsx +6 -1
  147. package/src/Overlay/Overlay.tsx +7 -2
  148. package/src/PowerSearch/PowerSearch.doc.mjs +2 -2
  149. package/src/PowerSearch/PowerSearch.tsx +4 -2
  150. package/src/PowerSearch/PowerSearchBottomSheet.tsx +237 -62
  151. package/src/Resizable/ResizeHandle.tsx +18 -26
  152. package/src/Resizable/createResizable.ts +242 -27
  153. package/src/Resizable/separatorAttributes.ts +73 -0
  154. package/src/Resizable/useResizable.ts +2 -2
  155. package/src/TimeInput/TimeWheels.tsx +218 -0
  156. package/src/TimeInput/TouchTimeField.tsx +93 -157
  157. package/src/TimeInput/timeWheelRules.ts +352 -0
  158. package/src/Tokenizer/TokenizerBottomSheet.tsx +8 -12
  159. package/src/Tooltip/useTooltip.tsx +6 -0
  160. package/src/gestures/GestureHost.ts +5 -4
  161. package/src/i18n/generated-english/PowerSearch/PowerSearchBottomSheet.generated.ts +5 -2
  162. package/src/i18n/generated-english/Resizable/createResizable.generated.ts +14 -0
  163. package/src/i18n/generated-english/TimeInput/TimeWheels.generated.ts +18 -0
  164. package/src/i18n/generated-locales/en.generated.ts +6 -4
  165. package/src/i18n/generated-locales/pseudo.generated.ts +6 -4
  166. package/src/keyboard/README.md +197 -0
  167. package/src/keyboard/index.ts +48 -0
  168. package/src/keyboard/keyboardHeight.ts +543 -0
  169. package/src/keyboard/keyboardLayoutGuide.ts +266 -0
  170. package/src/keyboard/keyboardModel.ts +301 -0
  171. package/src/keyboard/rideKeyboard.ts +100 -0
  172. package/src/keyboard/useMobileKeyboard.ts +866 -0
  173. package/src/motion/MEASUREMENTS.md +10 -0
  174. package/src/motion/README.md +2 -0
  175. package/src/motion/android/index.ts +18 -0
  176. package/src/motion/android/keyboard.ts +96 -0
  177. package/src/motion/uikit/datePicker.ts +60 -0
  178. package/src/motion/uikit/index.ts +4 -3
  179. package/src/utils/interactionOverlay.stylex.ts +3 -2
  180. package/src/utils/layoutViewport.ts +104 -0
  181. package/src/utils/pressFeedback.ts +99 -21
  182. package/src/utils/pressGesture.ts +4 -2
  183. package/src/utils/selectionLock.ts +40 -6
  184. package/src/utils/sharedMutationObserver.ts +2 -1
  185. package/src/utils/timeParser.ts +40 -3
  186. package/dist/BottomSheet/useMobileKeyboard.d.ts +0 -21
  187. package/dist/BottomSheet/useMobileKeyboard.d.ts.map +0 -1
  188. package/dist/BottomSheet/useMobileKeyboard.js +0 -413
  189. package/src/BottomSheet/useMobileKeyboard.ts +0 -542
@@ -354,6 +354,8 @@ export interface UseSheetGesturesOptions {
354
354
  snapHeights?: () => number[];
355
355
  /** Notified when the settled visible detent height (px) changes. */
356
356
  onSnap?: (heightPx: number) => void;
357
+ /** Called once a drag first moves the sheet off its detent. */
358
+ onTravelStart?: () => void;
357
359
  /**
358
360
  * Called with the scrim opacity the sheet should show (1 = fully visible,
359
361
  * 0 = hidden) as the drag moves and on settle. Full while the sheet is at
@@ -508,6 +510,7 @@ export function useSheetGestures({
508
510
  onDismiss,
509
511
  snapHeights,
510
512
  onSnap,
513
+ onTravelStart,
511
514
  onScrimOpacity,
512
515
  hasFixedHeight = false,
513
516
  }: UseSheetGesturesOptions): UseSheetGesturesResult {
@@ -654,6 +657,8 @@ export function useSheetGestures({
654
657
  // stops by the time the re-anchor read it.
655
658
  const snapHeightsRef = useRef(snapHeights);
656
659
  snapHeightsRef.current = snapHeights;
660
+ const onTravelStartRef = useRef(onTravelStart);
661
+ onTravelStartRef.current = onTravelStart;
657
662
 
658
663
  const onDismissRef = useRef(onDismiss);
659
664
  const canDismissRef = useRef(canDismiss);
@@ -1312,6 +1317,7 @@ export function useSheetGestures({
1312
1317
  if (!state.hasTraveled && next !== state.baseOffset) {
1313
1318
  state.hasTraveled = true;
1314
1319
  setIsTraveling(true);
1320
+ onTravelStartRef.current?.();
1315
1321
  }
1316
1322
 
1317
1323
  // Mirror the scrim to the live drag: full at/above the mid detent,
@@ -134,6 +134,13 @@ export const docs = {
134
134
  'Whether the group is optional. Shows the optional indicator after the group label, unless a surrounding FormLayout already makes fields optional by default.',
135
135
  default: 'false',
136
136
  },
137
+ {
138
+ name: 'isRequired',
139
+ type: 'boolean',
140
+ description:
141
+ 'Whether the group needs at least one checked box. Shows the required indicator after the group label by the same rules as every input field. While no box is checked, the browser blocks submit and moves focus to the first box that is neither disabled nor read-only, with its own message. A disabled or read-only group requires nothing. Show your own error text through status. Mutually exclusive with isOptional.',
142
+ default: 'false',
143
+ },
137
144
  {
138
145
  name: 'density',
139
146
  type: "'compact' | 'balanced' | 'spacious'",
@@ -4,7 +4,7 @@
4
4
 
5
5
  /**
6
6
  * @file CheckboxList.tsx
7
- * @input Uses React, useId, useOptimistic, useTransition, Field, List, CheckboxListContext
7
+ * @input Uses React, useId, useOptimistic, useTransition, Field, List, CheckboxListContext, requiredGroup
8
8
  * @output Exports CheckboxList component, CheckboxListProps
9
9
  * @position Core implementation; consumed by index.ts, tested by CheckboxList.test.tsx
10
10
  *
@@ -43,6 +43,7 @@ import {
43
43
  CheckboxListContext,
44
44
  type CheckboxListContextValue,
45
45
  } from './CheckboxListContext';
46
+ import {createRequiredGroup} from './requiredGroup';
46
47
 
47
48
  const EMPTY_ARRAY: string[] = [];
48
49
 
@@ -72,6 +73,17 @@ export interface CheckboxListProps extends Omit<
72
73
  * @default false
73
74
  */
74
75
  isOptional?: boolean;
76
+ /**
77
+ * Whether the group needs at least one checked box. Shows the required
78
+ * indicator after the group label by the same rules as every input field.
79
+ * While no box is checked, the browser blocks submit and moves focus to
80
+ * the first box that is neither disabled nor read-only. A disabled or
81
+ * read-only group requires nothing. Show your own error text through
82
+ * `status`.
83
+ * Mutually exclusive with isOptional.
84
+ * @default false
85
+ */
86
+ isRequired?: boolean;
75
87
  /**
76
88
  * Status indicator for the checkbox group.
77
89
  * When set with a message, displays a colored message box below the group.
@@ -161,6 +173,7 @@ export function CheckboxList({
161
173
  isLabelHidden = false,
162
174
  description,
163
175
  isOptional = false,
176
+ isRequired = false,
164
177
  status,
165
178
  value,
166
179
  onChange,
@@ -193,6 +206,18 @@ export function CheckboxList({
193
206
  const descriptionID = useId();
194
207
  const statusMessageID = useId();
195
208
 
209
+ const requiredGroup = useMemo(
210
+ () => (isRequired ? createRequiredGroup() : undefined),
211
+ [isRequired],
212
+ );
213
+ const observeRequiredGroup = useCallback(
214
+ (container: HTMLDivElement | null) =>
215
+ container != null && requiredGroup != null
216
+ ? requiredGroup.observe(container)
217
+ : undefined,
218
+ [requiredGroup],
219
+ );
220
+
196
221
  const [, startTransition] = useTransition();
197
222
  const isCollectionMode = value !== undefined;
198
223
  const effectiveValue = value ?? EMPTY_ARRAY;
@@ -256,6 +281,7 @@ export function CheckboxList({
256
281
  isReadOnly,
257
282
  isInvalid: status?.type === 'error',
258
283
  loadingValues,
284
+ requiredGroup,
259
285
  }),
260
286
  [
261
287
  isCollectionMode,
@@ -267,6 +293,7 @@ export function CheckboxList({
267
293
  isReadOnly,
268
294
  status?.type,
269
295
  loadingValues,
296
+ requiredGroup,
270
297
  ],
271
298
  );
272
299
 
@@ -279,6 +306,7 @@ export function CheckboxList({
279
306
  labelID={labelID}
280
307
  isGroupLabel
281
308
  isOptional={isOptional}
309
+ isRequired={isRequired}
282
310
  descriptionID={description ? descriptionID : undefined}
283
311
  isDisabled={isDisabled}
284
312
  status={
@@ -302,7 +330,11 @@ export function CheckboxList({
302
330
  {...rest}
303
331
  // The disabled-message tooltip anchors here; its handlers are gated
304
332
  // internally by isEnabled, so attaching unconditionally is safe.
305
- ref={useMergedRefs(ref, disabledMessageTooltip.ref)}
333
+ ref={useMergedRefs(
334
+ ref,
335
+ disabledMessageTooltip.ref,
336
+ observeRequiredGroup,
337
+ )}
306
338
  {...mergeProps(
307
339
  stylex.props(controlXstyle),
308
340
  controlClassName,
@@ -4,12 +4,14 @@
4
4
 
5
5
  /**
6
6
  * @file CheckboxListContext.tsx
7
- * @input Uses the private layer-scoped context factory
7
+ * @input Uses the private layer-scoped context factory and the RequiredGroup
8
+ * type
8
9
  * @output Exports CheckboxListContext for parent-child communication
9
10
  * @position Internal context; consumed by CheckboxList.tsx and CheckboxListItem.tsx
10
11
  */
11
12
 
12
13
  import {createLayerScopedContext} from '../Layer/layerScopedContext';
14
+ import type {RequiredGroup} from './requiredGroup';
13
15
 
14
16
  export interface CheckboxListContextValue {
15
17
  value?: string[];
@@ -49,6 +51,12 @@ export interface CheckboxListContextValue {
49
51
  * when idle.
50
52
  */
51
53
  loadingValues?: ReadonlyArray<string>;
54
+ /**
55
+ * Present when the list is `isRequired`. Each item registers its checkbox,
56
+ * so that while no box is checked the first enabled box carries native
57
+ * `required`.
58
+ */
59
+ requiredGroup?: RequiredGroup;
52
60
  }
53
61
 
54
62
  export const CheckboxListContext =
@@ -17,7 +17,7 @@
17
17
  * - /packages/cli/assets/templates/blocks/components/CheckboxList/ (showcase blocks)
18
18
  */
19
19
 
20
- import {use, useId, useRef, type ReactNode} from 'react';
20
+ import {use, useCallback, useId, useRef, type ReactNode} from 'react';
21
21
  import * as stylex from '@stylexjs/stylex';
22
22
  import type {StyleXStyles} from '@stylexjs/stylex';
23
23
  import {colorVars} from '../theme/tokens.stylex';
@@ -30,6 +30,7 @@ import {ListContext} from '../List/ListContext';
30
30
  import {CheckboxListContext} from './CheckboxListContext';
31
31
  import {ItemDescriptionContext} from '../Item/ItemDescriptionContext';
32
32
  import {joinAriaIDs} from '../utils/inputAria';
33
+ import {useMergedRefs} from '../hooks/useMergedRefs';
33
34
  import type {ItemAlign} from '../Item';
34
35
  import {
35
36
  registerEnglishFallback,
@@ -284,6 +285,26 @@ export function CheckboxListItem({
284
285
  const checkboxRef = useRef<HTMLInputElement | null>(null);
285
286
  const hasRowInteraction = isInteractive || onClickProp != null;
286
287
 
288
+ // A required group decides which box carries native `required` from the
289
+ // state of every box. A change to this box's state registers it again.
290
+ const requiredGroup = ctx?.requiredGroup;
291
+ const isBoxChecked = resolvedChecked === true;
292
+ const isBoxEnabled = !effectiveDisabled && !effectiveReadOnly;
293
+ const registerWithRequiredGroup = useCallback(
294
+ (input: HTMLInputElement | null) =>
295
+ input != null && requiredGroup != null
296
+ ? requiredGroup.register(input, {
297
+ isChecked: isBoxChecked,
298
+ isEnabled: isBoxEnabled,
299
+ })
300
+ : undefined,
301
+ [requiredGroup, isBoxChecked, isBoxEnabled],
302
+ );
303
+ const checkboxInputRef = useMergedRefs(
304
+ checkboxRef,
305
+ registerWithRequiredGroup,
306
+ );
307
+
287
308
  // A read-only checkbox carries aria-readonly, which the row's clickable
288
309
  // container treats as a non-interactive target, so a click on it would be
289
310
  // delegated straight back to the checkbox without end. Stop that click at
@@ -349,7 +370,7 @@ export function CheckboxListItem({
349
370
  style={style}
350
371
  startContent={
351
372
  <DescribedCheckboxInput
352
- ref={checkboxRef}
373
+ ref={checkboxInputRef}
353
374
  label={checkboxLabel}
354
375
  aria-labelledby={namesFromVisibleLabel ? labelID : undefined}
355
376
  isLabelHidden
@@ -0,0 +1,77 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file requiredGroup.ts
5
+ * @input The checkbox inputs of a required CheckboxList and each box's state;
6
+ * Core's shared mutation observer
7
+ * @output Exports createRequiredGroup, RequiredGroup, and RequiredGroupBox
8
+ * @position Internal to CheckboxList; CheckboxList creates one for an
9
+ * `isRequired` group and CheckboxListItem registers its checkbox with it
10
+ *
11
+ * The browser has no required state for a group of checkboxes. While no box
12
+ * is checked, the first enabled box carries native `required`. The browser
13
+ * then blocks submit and moves focus to that box with its own message. Once
14
+ * any box is checked, no box carries `required`.
15
+ */
16
+
17
+ import {observeMutations} from '../utils/sharedMutationObserver';
18
+
19
+ /** The state of one box that decides which box carries `required`. */
20
+ export interface RequiredGroupBox {
21
+ isChecked: boolean;
22
+ /** False for a disabled or read-only box, which the user cannot check. */
23
+ isEnabled: boolean;
24
+ }
25
+
26
+ export interface RequiredGroup {
27
+ /**
28
+ * Adds a checkbox with its state and updates `required` on every box.
29
+ * Returns a function that removes the checkbox, clears its `required`, and
30
+ * updates the rest. An item registers again when its state changes.
31
+ */
32
+ register: (input: HTMLInputElement, box: RequiredGroupBox) => () => void;
33
+ /**
34
+ * Updates `required` when the group's options move, for example after a
35
+ * keyed reorder, which registers no box again. Returns a function that
36
+ * stops it.
37
+ */
38
+ observe: (container: HTMLElement) => () => void;
39
+ }
40
+
41
+ const MOVE_OPTIONS: MutationObserverInit = {childList: true, subtree: true};
42
+
43
+ function byDocumentOrder(a: HTMLInputElement, b: HTMLInputElement): number {
44
+ return a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING
45
+ ? -1
46
+ : 1;
47
+ }
48
+
49
+ export function createRequiredGroup(): RequiredGroup {
50
+ const boxes = new Map<HTMLInputElement, RequiredGroupBox>();
51
+
52
+ function update() {
53
+ const ordered = [...boxes.keys()].sort(byDocumentOrder);
54
+ const isEmpty = ![...boxes.values()].some(box => box.isChecked);
55
+ const target = isEmpty
56
+ ? ordered.find(input => boxes.get(input)?.isEnabled === true)
57
+ : undefined;
58
+ for (const input of ordered) {
59
+ input.required = input === target;
60
+ }
61
+ }
62
+
63
+ return {
64
+ register(input, box) {
65
+ boxes.set(input, box);
66
+ update();
67
+ return () => {
68
+ boxes.delete(input);
69
+ input.required = false;
70
+ update();
71
+ };
72
+ },
73
+ observe(container) {
74
+ return observeMutations(container, MOVE_OPTIONS, update);
75
+ },
76
+ };
77
+ }
@@ -4,7 +4,8 @@
4
4
 
5
5
  /**
6
6
  * @file PickerWheel.tsx
7
- * @input Wheel's props (wheelOptions.ts): options, value and change callback
7
+ * @input Wheel's props (wheelOptions.ts): options, value and change callback;
8
+ * optionally a ref for programmatic turns
8
9
  * @output PickerWheel: one picker column that moves like UIPickerView
9
10
  * @position Internal component; the Wheel under a finger (Wheel.tsx picks it)
10
11
  *
@@ -15,7 +16,14 @@
15
16
  * DateInputTouch.test.tsx, DateInput.doc.mjs and TimeInput.doc.mjs.
16
17
  */
17
18
 
18
- import {useCallback, useEffect, useMemo, useRef, useState} from 'react';
19
+ import {
20
+ useCallback,
21
+ useEffect,
22
+ useImperativeHandle,
23
+ useMemo,
24
+ useRef,
25
+ useState,
26
+ } from 'react';
19
27
  import * as stylex from '@stylexjs/stylex';
20
28
  import {scrollTypeOf} from '../gestures/GestureHost';
21
29
  import {PanGestureRecognizer} from '../gestures/PanGestureRecognizer';
@@ -50,6 +58,7 @@ import {
50
58
  type WheelLayout,
51
59
  } from './wheelPhysics';
52
60
  import {
61
+ canSpinTo,
53
62
  endIndex,
54
63
  nearestEnabledIndex,
55
64
  steppedIndex,
@@ -197,6 +206,7 @@ export function PickerWheel({
197
206
  value,
198
207
  onChange,
199
208
  isActive = true,
209
+ ref,
200
210
  }: WheelProps) {
201
211
  const columnRef = useRef<HTMLDivElement | null>(null);
202
212
  const rowsRef = useRef<HTMLDivElement | null>(null);
@@ -584,8 +594,31 @@ export function PickerWheel({
584
594
  isPointerKept: true,
585
595
  });
586
596
 
587
- // A key commits the row at `position` at once and turns the wheel there
588
- // from `offset`.
597
+ // A spin turns only onto an enabled row (canSpinTo). So its arrival commits
598
+ // that row, and nothing turns on past it. It reports the row it was asked
599
+ // for, as the scrolling column does.
600
+ const spinTo = useCallback(
601
+ async (index: number): Promise<boolean> =>
602
+ new Promise(resolve => {
603
+ if (!canSpinTo(latestRef.current.options, index)) {
604
+ resolve(false);
605
+ return;
606
+ }
607
+ const offset = motion.offset();
608
+ move(turnCurve(columnRef.current, offset, index * ROW_HEIGHT), {
609
+ onEnd: (outcome, end) => {
610
+ if (outcome === 'arrived') {
611
+ arrive(end);
612
+ }
613
+ resolve(outcome === 'arrived');
614
+ },
615
+ });
616
+ }),
617
+ [motion, move, arrive],
618
+ );
619
+
620
+ useImperativeHandle(ref, () => ({spinTo}), [spinTo]);
621
+
589
622
  const turnToPosition = useCallback(
590
623
  (position: number, offset: number) => {
591
624
  const current = latestRef.current;
@@ -4,7 +4,8 @@
4
4
 
5
5
  /**
6
6
  * @file Wheel.tsx
7
- * @input Option list, selected value and change callback
7
+ * @input Option list, selected value and change callback; a ref for
8
+ * programmatic turns
8
9
  * @output Exports Wheel (one picker column), ScrollWheel (the scrolling
9
10
  * column) and the shared types of wheelOptions.ts
10
11
  * @position Internal component; consumed by MonthYearWheels.tsx,
@@ -27,7 +28,13 @@
27
28
  * - /packages/core/src/DateInput/DateInputTouch.test.tsx
28
29
  */
29
30
 
30
- import {useCallback, useEffect, useRef, useState} from 'react';
31
+ import {
32
+ useCallback,
33
+ useEffect,
34
+ useImperativeHandle,
35
+ useRef,
36
+ useState,
37
+ } from 'react';
31
38
  import * as stylex from '@stylexjs/stylex';
32
39
  import {
33
40
  colorVars,
@@ -51,6 +58,7 @@ import {usePointerDragScroll} from './usePointerDragScroll';
51
58
  import {useScrollSettle} from './useScrollSettle';
52
59
  import {useWheelListbox} from './useWheelListbox';
53
60
  import {
61
+ canSpinTo,
54
62
  endIndex,
55
63
  nearestEnabledIndex,
56
64
  steppedIndex,
@@ -59,6 +67,7 @@ import {
59
67
 
60
68
  export {
61
69
  nearestEnabledIndex,
70
+ type WheelHandle,
62
71
  type WheelOption,
63
72
  type WheelProps,
64
73
  } from './wheelOptions';
@@ -299,7 +308,26 @@ export function ScrollWheel({
299
308
  value,
300
309
  onChange,
301
310
  isActive = true,
311
+ ref,
302
312
  }: WheelProps) {
313
+ // A spin commits the row at once. The new value then scrolls the column
314
+ // there, as any new value does.
315
+ useImperativeHandle(
316
+ ref,
317
+ () => ({
318
+ spinTo: async index => {
319
+ if (!canSpinTo(options, index)) {
320
+ return false;
321
+ }
322
+ const option = options[index];
323
+ if (option.value !== value) {
324
+ onChange(option.value);
325
+ }
326
+ return true;
327
+ },
328
+ }),
329
+ [options, value, onChange],
330
+ );
303
331
  const scrollerRef = useRef<HTMLDivElement | null>(null);
304
332
  const selectedIndex = Math.max(
305
333
  0,
@@ -532,7 +560,6 @@ export function ScrollWheel({
532
560
  // there is no way to share — it takes every touch that lands on it.
533
561
  useOwnScrollGesture(scrollerRef, 'all', {isEnabled: isActive});
534
562
 
535
- // A key commits the row it moves to and scrolls the column there.
536
563
  const moveTo = useCallback(
537
564
  (row: number | null) => {
538
565
  const option = row == null ? undefined : options[row];
@@ -0,0 +1,15 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file datePickerTouch.ts
5
+ * @input The date and time pickers' touch-only pieces
6
+ * @output Re-exports what the date and time sheets use only under a finger:
7
+ * TimeWheels
8
+ * @position The pickers' touch layer. The sheets import touch behaviour from
9
+ * here only, so a desktop build can replace this one module.
10
+ *
11
+ * SYNC: When modified, update TouchTimeField.tsx and TouchDateTimeField.tsx.
12
+ */
13
+
14
+ export {TimeWheels} from '../TimeInput/TimeWheels';
15
+ export type {TimeWheelsProps} from '../TimeInput/TimeWheels';
@@ -3,8 +3,8 @@
3
3
  /**
4
4
  * @file wheelOptions.ts
5
5
  * @input Nothing; types, the row height and pure functions
6
- * @output WheelOption, WheelProps, WHEEL_ROW_HEIGHT, nearestEnabledIndex,
7
- * steppedIndex and endIndex: what both wheels share
6
+ * @output WheelOption, WheelProps, WheelHandle, WHEEL_ROW_HEIGHT, canSpinTo,
7
+ * nearestEnabledIndex, steppedIndex and endIndex: what both wheels share
8
8
  * @position Internal; read by Wheel.tsx (which re-exports all of it) and
9
9
  * PickerWheel.tsx
10
10
  *
@@ -13,6 +13,8 @@
13
13
  * - /packages/core/src/DateInput/PickerWheel.tsx
14
14
  */
15
15
 
16
+ import type {Ref} from 'react';
17
+
16
18
  /**
17
19
  * The row height, px (`dateInputTouchSizes.wheelItemSize`): the pitch of both
18
20
  * columns' rows, and the unit of the touch wheel's offset.
@@ -37,6 +39,19 @@ export interface WheelOption {
37
39
  order?: number;
38
40
  }
39
41
 
42
+ /** What a Wheel's `ref` exposes. */
43
+ export interface WheelHandle {
44
+ /**
45
+ * Turn the wheel onto `index` (an index into `options`) on the tap spin, then
46
+ * commit it as a rest would. Resolves true once that row is the committed
47
+ * one, false if a finger or another turn interrupted it or the wheel went
48
+ * away first. An index outside `options` or a disabled row resolves false at
49
+ * once, and nothing turns. Under a fine pointer the row commits at once. The
50
+ * column then scrolls to it as for any new value, on the same rule.
51
+ */
52
+ spinTo(index: number): Promise<boolean>;
53
+ }
54
+
40
55
  export interface WheelProps {
41
56
  /** Accessible name for the column, e.g. "Month". */
42
57
  label: string;
@@ -53,6 +68,20 @@ export interface WheelProps {
53
68
  * motion.
54
69
  */
55
70
  isActive?: boolean;
71
+ /** Programmatic turns: `spinTo(index)`. */
72
+ ref?: Ref<WheelHandle>;
73
+ }
74
+
75
+ /**
76
+ * Whether `spinTo(index)` may turn onto `index`: an enabled row of `options`.
77
+ * The touch wheel and the scrolling column answer a spin by the same rule.
78
+ */
79
+ export function canSpinTo(
80
+ options: ReadonlyArray<WheelOption>,
81
+ index: number,
82
+ ): boolean {
83
+ const option = options[index];
84
+ return option != null && !option.isDisabled;
56
85
  }
57
86
 
58
87
  /**