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

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 (131) hide show
  1. package/dist/AppShell/AppShell.d.ts.map +1 -1
  2. package/dist/AppShell/AppShell.js +23 -3
  3. package/dist/BottomSheet/BottomSheetPanel.d.ts.map +1 -1
  4. package/dist/BottomSheet/BottomSheetPanel.js +16 -9
  5. package/dist/Chat/ChatComposerInput.d.ts +9 -0
  6. package/dist/Chat/ChatComposerInput.d.ts.map +1 -1
  7. package/dist/Chat/ChatComposerInput.js +2 -0
  8. package/dist/Chat/ChatMessage.js +2 -2
  9. package/dist/Chat/chatMessage.vars.stylex.d.ts +8 -8
  10. package/dist/Chat/chatMessage.vars.stylex.js +8 -8
  11. package/dist/CommandPalette/CommandPaletteInput.d.ts.map +1 -1
  12. package/dist/CommandPalette/CommandPaletteInput.js +8 -1
  13. package/dist/DateInput/MonthScroller.d.ts.map +1 -1
  14. package/dist/DateInput/MonthScroller.js +72 -2
  15. package/dist/Drawer/Drawer.d.ts.map +1 -1
  16. package/dist/Drawer/Drawer.js +6 -0
  17. package/dist/DropdownMenu/useMenuOverflow.d.ts +13 -3
  18. package/dist/DropdownMenu/useMenuOverflow.d.ts.map +1 -1
  19. package/dist/DropdownMenu/useMenuOverflow.js +31 -8
  20. package/dist/Field/PanelSearchInput.d.ts.map +1 -1
  21. package/dist/Field/PanelSearchInput.js +8 -1
  22. package/dist/Field/textEntryTraits.d.ts +6 -6
  23. package/dist/Field/textEntryTraits.js +6 -6
  24. package/dist/Link/Link.d.ts.map +1 -1
  25. package/dist/Link/Link.js +1 -0
  26. package/dist/Selector/Selector.d.ts.map +1 -1
  27. package/dist/Selector/Selector.js +25 -13
  28. package/dist/Slider/Slider.d.ts.map +1 -1
  29. package/dist/Slider/Slider.js +404 -131
  30. package/dist/Slider/sliderTouchController.d.ts +189 -0
  31. package/dist/Slider/sliderTouchController.d.ts.map +1 -0
  32. package/dist/Slider/sliderTouchController.js +1017 -0
  33. package/dist/Slider/useSliderTouch.d.ts +22 -0
  34. package/dist/Slider/useSliderTouch.d.ts.map +1 -0
  35. package/dist/Slider/useSliderTouch.js +56 -0
  36. package/dist/Typeahead/BaseTypeahead.d.ts.map +1 -1
  37. package/dist/Typeahead/BaseTypeahead.js +19 -9
  38. package/dist/astryx.css +33 -6
  39. package/dist/gestures/GestureArena.d.ts.map +1 -1
  40. package/dist/gestures/GestureArena.js +4 -1
  41. package/dist/gestures/GestureHost.d.ts +72 -2
  42. package/dist/gestures/GestureHost.d.ts.map +1 -1
  43. package/dist/gestures/GestureHost.js +643 -5
  44. package/dist/gestures/GestureRecognizer.d.ts +15 -1
  45. package/dist/gestures/GestureRecognizer.d.ts.map +1 -1
  46. package/dist/gestures/GestureRecognizer.js +12 -0
  47. package/dist/gestures/PanGestureRecognizer.d.ts +8 -0
  48. package/dist/gestures/PanGestureRecognizer.d.ts.map +1 -1
  49. package/dist/gestures/PinchGestureRecognizer.d.ts +5 -1
  50. package/dist/gestures/PinchGestureRecognizer.d.ts.map +1 -1
  51. package/dist/gestures/PinchGestureRecognizer.js +10 -0
  52. package/dist/gestures/ScreenEdgePanGestureRecognizer.d.ts +3 -3
  53. package/dist/gestures/ScreenEdgePanGestureRecognizer.d.ts.map +1 -1
  54. package/dist/gestures/ScreenEdgePanGestureRecognizer.js +7 -5
  55. package/dist/gestures/nativeScroll.d.ts +16 -2
  56. package/dist/gestures/nativeScroll.d.ts.map +1 -1
  57. package/dist/gestures/nativeScroll.js +39 -8
  58. package/dist/gestures/recognizerInternals.d.ts +4 -2
  59. package/dist/gestures/recognizerInternals.d.ts.map +1 -1
  60. package/dist/gestures/types.d.ts +11 -0
  61. package/dist/gestures/types.d.ts.map +1 -1
  62. package/dist/gestures/types.js +7 -0
  63. package/dist/gestures/wheelSegmenter.d.ts +45 -0
  64. package/dist/gestures/wheelSegmenter.d.ts.map +1 -0
  65. package/dist/gestures/wheelSegmenter.js +98 -0
  66. package/dist/motion/animator.d.ts.map +1 -1
  67. package/dist/motion/animator.js +140 -19
  68. package/dist/motion/paging.d.ts +58 -0
  69. package/dist/motion/paging.d.ts.map +1 -0
  70. package/dist/motion/paging.js +128 -0
  71. package/dist/motion/uikit/index.d.ts +2 -1
  72. package/dist/motion/uikit/index.d.ts.map +1 -1
  73. package/dist/motion/uikit/index.js +2 -1
  74. package/dist/motion/uikit/paging.d.ts +160 -0
  75. package/dist/motion/uikit/paging.d.ts.map +1 -0
  76. package/dist/motion/uikit/paging.js +162 -0
  77. package/dist/motion/uikit/scroll.d.ts +0 -19
  78. package/dist/motion/uikit/scroll.d.ts.map +1 -1
  79. package/dist/motion/uikit/scroll.js +2 -21
  80. package/dist/motion/uikit/slider.d.ts +97 -0
  81. package/dist/motion/uikit/slider.d.ts.map +1 -0
  82. package/dist/motion/uikit/slider.js +111 -0
  83. package/dist/motion/uikit/springs.d.ts +46 -1
  84. package/dist/motion/uikit/springs.d.ts.map +1 -1
  85. package/dist/motion/uikit/springs.js +48 -4
  86. package/package.json +5 -5
  87. package/src/AppShell/AppShell.doc.mjs +10 -0
  88. package/src/AppShell/AppShell.tsx +48 -2
  89. package/src/BottomSheet/BottomSheetPanel.tsx +16 -9
  90. package/src/Chat/Chat.doc.mjs +1 -0
  91. package/src/Chat/ChatComposerInput.doc.mjs +11 -0
  92. package/src/Chat/ChatComposerInput.tsx +11 -0
  93. package/src/Chat/ChatMessage.tsx +2 -2
  94. package/src/Chat/chatMessage.vars.stylex.ts +8 -8
  95. package/src/CommandPalette/CommandPaletteInput.tsx +6 -0
  96. package/src/DateInput/MonthScroller.tsx +63 -5
  97. package/src/Drawer/Drawer.tsx +6 -0
  98. package/src/DropdownMenu/useMenuOverflow.ts +44 -8
  99. package/src/Field/PanelSearchInput.tsx +6 -0
  100. package/src/Field/textEntryTraits.ts +6 -6
  101. package/src/Link/Link.tsx +3 -0
  102. package/src/Selector/Selector.tsx +29 -2
  103. package/src/Slider/Slider.doc.mjs +11 -7
  104. package/src/Slider/Slider.tsx +539 -132
  105. package/src/Slider/sliderTouchController.ts +1209 -0
  106. package/src/Slider/useSliderTouch.ts +78 -0
  107. package/src/Typeahead/BaseTypeahead.tsx +16 -8
  108. package/src/gestures/GestureArena.ts +1 -0
  109. package/src/gestures/GestureHost.ts +765 -5
  110. package/src/gestures/GestureRecognizer.ts +28 -0
  111. package/src/gestures/PanGestureRecognizer.ts +8 -0
  112. package/src/gestures/PinchGestureRecognizer.ts +16 -1
  113. package/src/gestures/README.md +63 -0
  114. package/src/gestures/ScreenEdgePanGestureRecognizer.ts +7 -5
  115. package/src/gestures/nativeScroll.ts +49 -9
  116. package/src/gestures/recognizerInternals.ts +9 -2
  117. package/src/gestures/types.ts +12 -0
  118. package/src/gestures/wheelSegmenter.ts +114 -0
  119. package/src/motion/MEASUREMENTS.md +8 -1
  120. package/src/motion/README.md +3 -2
  121. package/src/motion/animator.ts +193 -25
  122. package/src/motion/paging.ts +138 -0
  123. package/src/motion/uikit/index.ts +2 -1
  124. package/src/motion/uikit/paging.ts +162 -0
  125. package/src/motion/uikit/scroll.ts +2 -18
  126. package/src/motion/uikit/slider.ts +105 -0
  127. package/src/motion/uikit/springs.ts +37 -2
  128. package/dist/motion/selectPage.d.ts +0 -26
  129. package/dist/motion/selectPage.d.ts.map +0 -1
  130. package/dist/motion/selectPage.js +0 -55
  131. package/src/motion/selectPage.ts +0 -55
@@ -4,8 +4,8 @@
4
4
 
5
5
  /**
6
6
  * @file Slider.tsx
7
- * @input Uses React, useId, useRef, useCallback, useEffect, Field, Tooltip, useTooltip, VisuallyHidden
8
- * @output Exports Slider and its props; unfilled marks share the track token; modifier-only key presses do not restore the thumb focus ring; a drag belongs to the pointer that started it; two coincident range thumbs separate toward the press or the first movement
7
+ * @input Uses React, useId, useRef, useCallback, useEffect, Field, useTooltip, VisuallyHidden, the touch presentation resolver and useSliderTouch (UISlider's touch tracking)
8
+ * @output Exports Slider and its props. Unfilled marks share the track token. Modifier-only key presses do not restore the thumb focus ring. A drag belongs to the pointer that started it. The thumbs and the filled range travel by `translate` in container units, so moving them never lays out. Padding on the control moves no part. The value tooltip anchors to a box that layout places at the value. A drag on a thumb is a pan for every pointer, tracked as UISlider does. Under a coarse primary pointer, each thumb's `::before` is its touch target. A finger's tap on the track sets the value on its lift. A mouse press on the track keeps the pointer handlers. Two coincident range thumbs separate toward the press or the first movement
9
9
  * @position Core implementation; consumed by index.ts, tested by Slider.test.tsx
10
10
  *
11
11
  * SYNC: When modified, update these files to stay in sync:
@@ -23,10 +23,15 @@ import {
23
23
  useRef,
24
24
  useState,
25
25
  useCallback,
26
+ type CSSProperties,
26
27
  type FocusEvent,
27
28
  type KeyboardEvent,
28
29
  type PointerEvent,
30
+ type ReactElement,
31
+ type ReactNode,
32
+ type Ref,
29
33
  } from 'react';
34
+ import {flushSync} from 'react-dom';
30
35
  import * as stylex from '@stylexjs/stylex';
31
36
  import {
32
37
  colorVars,
@@ -38,7 +43,6 @@ import {
38
43
  typeScaleVars,
39
44
  } from '../theme/tokens.stylex';
40
45
  import {Field} from '../Field/Field';
41
- import {Tooltip} from '../Tooltip/Tooltip';
42
46
  import {useTooltip} from '../Tooltip';
43
47
  import {VisuallyHidden} from '../VisuallyHidden';
44
48
  import type {InputStatus} from '../Field/types';
@@ -53,6 +57,14 @@ import {
53
57
  } from '../Field/fieldParts';
54
58
  import type {SizeValue} from '../utils/types';
55
59
  import {themeProps} from '../utils/themeProps';
60
+ import {isPrimaryClick} from '../utils/primaryClick';
61
+ import {useTouchPresentation} from '../utils/touchPresentation';
62
+ import {slider as UIKIT_SLIDER} from '../motion/uikit/slider';
63
+ import {
64
+ useSliderTouch,
65
+ type SliderTouchModel,
66
+ type SliderTouchParts,
67
+ } from './useSliderTouch';
56
68
 
57
69
  import {useMergedRefs} from '../hooks/useMergedRefs';
58
70
  import {useResolvedRequired} from '../hooks/useResolvedRequired';
@@ -169,6 +181,35 @@ export type SliderProps = SliderSingleProps | SliderRangeProps;
169
181
  const TRACK_SIZE = 4;
170
182
  const THUMB_SIZE = 20;
171
183
 
184
+ /**
185
+ * Where a thumb sits, as its progress along the travel (0 at `min`, 1 at
186
+ * `max`), written inline on the thumb. CSS computes its translate from it
187
+ * against the control's size, in container units. So moving the thumb
188
+ * restyles and composites but never lays out, and the server renders the
189
+ * right place. A value tooltip or text display still lays out as its text
190
+ * changes.
191
+ */
192
+ const THUMB_PROGRESS = '--_slider-progress';
193
+ /** A thumb's touch-target insets under a finger, which its `::before` draws. */
194
+ const HIT_INSET = {
195
+ top: '--_slider-hit-top',
196
+ bottom: '--_slider-hit-bottom',
197
+ inlineStart: '--_slider-hit-start',
198
+ inlineEnd: '--_slider-hit-end',
199
+ } as const;
200
+ /** Half a thumb: the inset of the thumb's travel at each end of the track. */
201
+ const THUMB_INSET_PX = THUMB_SIZE / 2;
202
+ /** The filled range's ends as progress, written inline on the fill parts. */
203
+ const FILL_START = '--_slider-fill-start';
204
+ const FILL_END = '--_slider-fill-end';
205
+
206
+ // The private progress properties a thumb and the fill parts publish inline.
207
+ type ThumbStyle = React.CSSProperties &
208
+ Record<typeof THUMB_PROGRESS, number> &
209
+ Partial<Record<(typeof HIT_INSET)[keyof typeof HIT_INSET], string>>;
210
+ type FillStyle = React.CSSProperties &
211
+ Record<typeof FILL_START | typeof FILL_END, number>;
212
+
172
213
  // =============================================================================
173
214
  // Styles
174
215
  // =============================================================================
@@ -184,10 +225,43 @@ const styles = stylex.create({
184
225
  display: 'flex',
185
226
  alignItems: 'center',
186
227
  flexGrow: 1,
187
- touchAction: 'none',
228
+ // Under a finger (a coarse primary pointer) the control leaves the browser
229
+ // its pans. So a swipe from the track or across a thumb scrolls the page.
230
+ // The gesture host narrows that per element from the recognizers attached
231
+ // to it (a thumb's pan keeps its own axis) and never widens it. A held
232
+ // thumb's pan locks text selection and the callout until the lift. A
233
+ // mouse or a trackpad keeps the control's `none`.
234
+ touchAction: {
235
+ default: 'none',
236
+ '@media (pointer: coarse)': 'manipulation',
237
+ },
238
+ // The control's text is never selectable. WebKit applies only the
239
+ // prefixed property. So both are written. A held finger on the track
240
+ // then starts no selection either. The gesture host's lock leaves this
241
+ // always-on suppression as it is.
242
+ WebkitUserSelect: 'none',
188
243
  userSelect: 'none',
189
244
  isolation: 'isolate',
190
245
  },
246
+ // Every part of the control sits in this box, which fills the control's
247
+ // padding box. So padding on the control (a theme rule, `controlStyle`)
248
+ // moves no part. The thumbs and the fill travel in container units of it
249
+ // (`cqi` / `cqb`). Its size never depends on its content.
250
+ travelBox: {
251
+ position: 'absolute',
252
+ inset: 0,
253
+ containerType: 'size',
254
+ },
255
+ // Where layout places a thumb at its value: the value tooltip's position
256
+ // anchor. CSS anchor positioning reads the anchor's layout box, which the
257
+ // thumb's `translate` does not move in every engine. So the tooltip anchors
258
+ // here while hover and focus stay on the thumb.
259
+ valueAnchor: {
260
+ position: 'absolute',
261
+ width: THUMB_SIZE,
262
+ height: THUMB_SIZE,
263
+ pointerEvents: 'none',
264
+ },
191
265
  trackContainerHorizontal: {
192
266
  height: THUMB_SIZE,
193
267
  // The whole track is the tap target (a click anywhere on it moves the
@@ -246,26 +320,95 @@ const styles = stylex.create({
246
320
  bottom: 0,
247
321
  width: TRACK_SIZE,
248
322
  },
249
- filledTrack: {
323
+ // The filled range is a full-length bar moved into place inside two
324
+ // clips, so it travels by translate alone. The clip has the track's shape
325
+ // and rounds the start of a single slider's fill. The mover's start edge
326
+ // cuts the fill at the range's start, and the fill's own end sits at the
327
+ // range's end. Both ends of a range hide under its thumbs.
328
+ fillClip: {
250
329
  position: 'absolute',
251
- backgroundColor: colorVars['--color-accent'],
330
+ overflow: 'hidden',
252
331
  borderRadius: radiusVars['--radius-full'],
332
+ pointerEvents: 'none',
253
333
  },
254
- filledTrackHorizontal: {
334
+ fillClipHorizontal: {
335
+ insetInlineStart: 0,
336
+ insetInlineEnd: 0,
255
337
  height: TRACK_SIZE,
256
338
  top: '50%',
257
339
  transform: 'translateY(-50%)',
258
340
  },
259
- filledTrackVertical: {
341
+ fillClipVertical: {
342
+ top: 0,
343
+ bottom: 0,
260
344
  width: TRACK_SIZE,
261
345
  },
346
+ fillMover: {
347
+ position: 'absolute',
348
+ inset: 0,
349
+ overflow: 'hidden',
350
+ },
351
+ // A range's start is round, like the track's ends (the clip already
352
+ // rounds a single slider's start). It shows through a disabled thumb's
353
+ // translucent fill.
354
+ fillMoverRangeStartHorizontal: {
355
+ borderStartStartRadius: radiusVars['--radius-full'],
356
+ borderEndStartRadius: radiusVars['--radius-full'],
357
+ },
358
+ fillMoverRangeStartVertical: {
359
+ borderEndStartRadius: radiusVars['--radius-full'],
360
+ borderEndEndRadius: radiusVars['--radius-full'],
361
+ },
362
+ fillMoverRangeHorizontal: {
363
+ translate: {
364
+ default: `calc(${THUMB_INSET_PX}px + var(${FILL_START}) * (100cqi - ${THUMB_SIZE}px)) 0`,
365
+ ':dir(rtl)': `calc(-${THUMB_INSET_PX}px - var(${FILL_START}) * (100cqi - ${THUMB_SIZE}px)) 0`,
366
+ },
367
+ },
368
+ fillMoverRangeVertical: {
369
+ translate: `0 calc(-${THUMB_INSET_PX}px - var(${FILL_START}) * (100cqb - ${THUMB_SIZE}px))`,
370
+ },
371
+ filledTrack: {
372
+ position: 'absolute',
373
+ inset: 0,
374
+ backgroundColor: colorVars['--color-accent'],
375
+ },
376
+ // The fill's end is round too, under the thumb.
377
+ filledTrackHorizontal: {
378
+ borderStartEndRadius: radiusVars['--radius-full'],
379
+ borderEndEndRadius: radiusVars['--radius-full'],
380
+ },
381
+ filledTrackVertical: {
382
+ borderStartStartRadius: radiusVars['--radius-full'],
383
+ borderStartEndRadius: radiusVars['--radius-full'],
384
+ },
385
+ // A single slider's fill runs from the track's start to the thumb's center.
386
+ filledTrackSingleHorizontal: {
387
+ translate: {
388
+ default: `calc(${THUMB_INSET_PX}px + var(${FILL_END}) * (100cqi - ${THUMB_SIZE}px) - 100cqi) 0`,
389
+ ':dir(rtl)': `calc(100cqi - ${THUMB_INSET_PX}px - var(${FILL_END}) * (100cqi - ${THUMB_SIZE}px)) 0`,
390
+ },
391
+ },
392
+ filledTrackSingleVertical: {
393
+ translate: `0 calc(100cqb - ${THUMB_INSET_PX}px - var(${FILL_END}) * (100cqb - ${THUMB_SIZE}px))`,
394
+ },
395
+ // A range's fill runs from the lower thumb's center to the upper's. The
396
+ // mover already sits at the lower one.
397
+ filledTrackRangeHorizontal: {
398
+ translate: {
399
+ default: `calc((var(${FILL_END}) - var(${FILL_START})) * (100cqi - ${THUMB_SIZE}px) - 100cqi) 0`,
400
+ ':dir(rtl)': `calc(100cqi - (var(${FILL_END}) - var(${FILL_START})) * (100cqi - ${THUMB_SIZE}px)) 0`,
401
+ },
402
+ },
403
+ filledTrackRangeVertical: {
404
+ translate: `0 calc(100cqb - (var(${FILL_END}) - var(${FILL_START})) * (100cqb - ${THUMB_SIZE}px))`,
405
+ },
262
406
  thumb: {
263
407
  position: 'absolute',
264
408
  width: THUMB_SIZE,
265
409
  height: THUMB_SIZE,
266
410
  borderRadius: radiusVars['--radius-full'],
267
411
  backgroundColor: colorVars['--color-accent'],
268
- transform: 'translate(-50%, -50%)',
269
412
  transitionProperty: 'background-color, box-shadow',
270
413
  transitionDuration: {
271
414
  default: durationVars['--duration-fast'],
@@ -280,16 +423,42 @@ const styles = stylex.create({
280
423
  zIndex: 1,
281
424
  },
282
425
  thumbHorizontal: {
283
- top: '50%',
284
- // The thumb is positioned via logical `insetInlineStart`, which resolves
285
- // from the right edge under RTL. The centering translate is a physical
286
- // (screen-space) transform, so it must flip its X direction under RTL to
287
- // keep the thumb centered on the value point.
288
- transform: {
289
- default: 'translate(-50%, -50%)',
290
- ':is([dir="rtl"] *)': 'translate(50%, -50%)',
426
+ // Centered by its offset, not by a transform: the touch lift scales the
427
+ // thumb (`scale`), and CSS applies `transform` inside `scale`. So a
428
+ // centering translate would grow with it and lift the thumb off the
429
+ // track. UISlider's lens grows around its center.
430
+ top: `calc(50% - ${THUMB_SIZE / 2}px)`,
431
+ insetInlineStart: 0,
432
+ // The thumb starts at the track's inline start (the right edge under
433
+ // RTL). It travels by its progress along the track minus its own size,
434
+ // so its center lands where `insetPosition` places the fill and the
435
+ // marks. `translate` is physical, so it flips its sign under RTL.
436
+ translate: {
437
+ default: `calc(var(${THUMB_PROGRESS}) * (100cqi - ${THUMB_SIZE}px)) 0`,
438
+ ':dir(rtl)': `calc(var(${THUMB_PROGRESS}) * (${THUMB_SIZE}px - 100cqi)) 0`,
439
+ },
440
+ },
441
+ // A thumb's touch target under a finger (UISlider's hit area): the thumb's
442
+ // own `::before`, invisible and centered on it, larger than it. So a touch
443
+ // presentation renders no more elements than a mouse one. Each side grows
444
+ // by the inset `thumbHitInsets` sets on the thumb.
445
+ thumbHit: {
446
+ '::before': {
447
+ content: '""',
448
+ position: 'absolute',
449
+ top: `var(${HIT_INSET.top})`,
450
+ bottom: `var(${HIT_INSET.bottom})`,
451
+ insetInlineStart: `var(${HIT_INSET.inlineStart})`,
452
+ insetInlineEnd: `var(${HIT_INSET.inlineEnd})`,
291
453
  },
292
454
  },
455
+ thumbVertical: {
456
+ bottom: 0,
457
+ // Centered by its offset (see thumbHorizontal): the same in both
458
+ // directions, since the thumb is symmetric.
459
+ insetInlineStart: `calc(50% - ${THUMB_SIZE / 2}px)`,
460
+ translate: `0 calc(var(${THUMB_PROGRESS}) * (${THUMB_SIZE}px - 100cqb))`,
461
+ },
293
462
  thumbHover: {
294
463
  backgroundColor: {
295
464
  default: colorVars['--color-accent'],
@@ -439,12 +608,6 @@ function insetPosition(percent: number): string {
439
608
  return cssLength(percent, THUMB_INSET - (percent / 100) * THUMB_SIZE);
440
609
  }
441
610
 
442
- /** Distance between two inset positions; the inset itself cancels out. */
443
- function insetSpan(fromPercent: number, toPercent: number): string {
444
- const delta = toPercent - fromPercent;
445
- return cssLength(delta, -(delta / 100) * THUMB_SIZE);
446
- }
447
-
448
611
  /** Inverse of `insetPosition`: an offset from the box start back to 0–1. */
449
612
  function travelFraction(offset: number, size: number): number {
450
613
  const travel = size - THUMB_SIZE;
@@ -463,6 +626,51 @@ function cssLength(percent: number, px: number): string {
463
626
  return `calc(${round(percent)}% ${px < 0 ? '-' : '+'} ${Math.abs(round(px))}px)`;
464
627
  }
465
628
 
629
+ /**
630
+ * A thumb's value tooltip: hover and focus on the thumb, position from a
631
+ * thumb-sized anchor that layout places at the value (styles.valueAnchor).
632
+ */
633
+ function SliderValueTooltip({
634
+ content,
635
+ placement,
636
+ isOpen,
637
+ anchorStyle,
638
+ thumbRef,
639
+ children,
640
+ }: {
641
+ content: ReactNode;
642
+ placement: 'above' | 'start';
643
+ isOpen: boolean | undefined;
644
+ anchorStyle: CSSProperties;
645
+ thumbRef: Ref<HTMLDivElement> | undefined;
646
+ children: (trigger: {
647
+ ref: Ref<HTMLDivElement>;
648
+ describedBy: string;
649
+ }) => ReactElement;
650
+ }): ReactElement {
651
+ const tooltip = useTooltip({
652
+ placement,
653
+ delay: 0,
654
+ focusTrigger: 'always',
655
+ isOpen,
656
+ });
657
+ const triggerRef = useMergedRefs<HTMLDivElement>(
658
+ thumbRef,
659
+ tooltip.interactionRef,
660
+ );
661
+ return (
662
+ <>
663
+ <span
664
+ aria-hidden="true"
665
+ ref={tooltip.positionRef}
666
+ {...mergeProps(stylex.props(styles.valueAnchor), {style: anchorStyle})}
667
+ />
668
+ {children({ref: triggerRef, describedBy: tooltip.describedBy})}
669
+ {tooltip.renderTooltip(content)}
670
+ </>
671
+ );
672
+ }
673
+
466
674
  // =============================================================================
467
675
  // Component
468
676
  // =============================================================================
@@ -551,9 +759,19 @@ export function Slider({ref, ...props}: SliderProps) {
551
759
  const requiredID = useId();
552
760
 
553
761
  const trackRef = useRef<HTMLDivElement>(null);
762
+ const fillMoverRef = useRef<HTMLDivElement>(null);
763
+ const fillRef = useRef<HTMLDivElement>(null);
554
764
  const draggingThumbRef = useRef<number | null>(null);
555
765
  const [draggingThumb, setDraggingThumb] = useState<number | null>(null);
556
766
 
767
+ // The thumbs track as UISlider's for every pointer (useSliderTouch): a
768
+ // drag on a thumb is a pan. Their touch targets show only under a coarse
769
+ // primary pointer, after hydration: one touch model on every touch
770
+ // device. A mouse press on the track keeps the pointer
771
+ // handlers below.
772
+ const isTouchPresentation = useTouchPresentation().isTouch;
773
+ const hasTouchTargets = isTouchPresentation && !isDisabled;
774
+
557
775
  // A thumb is a div[role="slider"], and `handlePointerDown` focuses it from
558
776
  // script after preventDefault — which Chromium treats as focus-visible, so
559
777
  // dragging with a mouse drew the keyboard ring (measured: `:focus-visible`
@@ -578,7 +796,7 @@ export function Slider({ref, ...props}: SliderProps) {
578
796
  }, []);
579
797
 
580
798
  // Disabled-reason tooltip. This is a *separate* useTooltip instance from the
581
- // per-thumb value bubble (the `<Tooltip>` component below): it anchors to the
799
+ // per-thumb value bubble (SliderValueTooltip below): it anchors to the
582
800
  // track container and fires on hover/focus of the whole control. Disabled
583
801
  // controls swallow pointer events, so the thumb stays perceivable via
584
802
  // aria-disabled while pointer/keyboard handlers early-return on isDisabled.
@@ -799,6 +1017,27 @@ export function Slider({ref, ...props}: SliderProps) {
799
1017
  if (isDisabled) {
800
1018
  return;
801
1019
  }
1020
+ // Only a primary button presses: a right click or a macOS ctrl-click
1021
+ // is the platform's context request, and a pen's
1022
+ // barrel button presses nothing either.
1023
+ if (!isPrimaryClick(e)) {
1024
+ return;
1025
+ }
1026
+ // A press on a thumb belongs to the slider's gestures (useSliderTouch),
1027
+ // which pan it for every pointer. The thumb still takes focus from any
1028
+ // press, as it did. A finger's or a pen's tap on the track sets the
1029
+ // value when it lifts. So only a mouse press on the track goes on.
1030
+ const thumb = (e.target as Element).closest<HTMLElement>(
1031
+ '[role="slider"]',
1032
+ );
1033
+ if (thumb != null) {
1034
+ thumb.focus();
1035
+ setKeyboardFocusThumb(null);
1036
+ return;
1037
+ }
1038
+ if (e.pointerType === 'touch' || e.pointerType === 'pen') {
1039
+ return;
1040
+ }
802
1041
  e.preventDefault();
803
1042
  if (draggingThumbRef.current !== null) {
804
1043
  return;
@@ -959,6 +1198,105 @@ export function Slider({ref, ...props}: SliderProps) {
959
1198
  [endDrag],
960
1199
  );
961
1200
 
1201
+ // The touch presentation's value model (useSliderTouch reads it fresh).
1202
+ const updateValueRef = useRef(updateValue);
1203
+ updateValueRef.current = updateValue;
1204
+ const touchModel: SliderTouchModel = {
1205
+ min,
1206
+ max,
1207
+ isRange,
1208
+ isHorizontal,
1209
+ isDisabled: isDisabled ?? false,
1210
+ thumbSize: THUMB_SIZE,
1211
+ values: () => valuesRef.current,
1212
+ // The value updateValue would report for this thumb.
1213
+ constrain: (index, raw) => {
1214
+ const next = clamp(snapToStep(raw, min, step), min, max);
1215
+ if (!isRange) {
1216
+ return next;
1217
+ }
1218
+ const minGap = minStepsBetweenThumbs * step;
1219
+ const [v0, v1] = valuesRef.current;
1220
+ return index === 0
1221
+ ? clamp(Math.min(next, v1 - minGap), min, max)
1222
+ : clamp(Math.max(next, v0 + minGap), min, max);
1223
+ },
1224
+ bounds: index => {
1225
+ if (!isRange) {
1226
+ return [min, max];
1227
+ }
1228
+ const minGap = minStepsBetweenThumbs * step;
1229
+ const [v0, v1] = valuesRef.current;
1230
+ return index === 0
1231
+ ? [min, clamp(v1 - minGap, min, max)]
1232
+ : [clamp(v0 + minGap, min, max), max];
1233
+ },
1234
+ change: (index, next, from) =>
1235
+ updateValueRef.current(index, next, true, from),
1236
+ // A finger paints the thumb pressed and opens its value tooltip.
1237
+ // `handlePointerDown` already focused it.
1238
+ press: index => {
1239
+ setDraggingThumb(index);
1240
+ // When a thumb of this slider holds focus, a drag handed to the other
1241
+ // thumb takes the focus along. Every press on a thumb focuses it.
1242
+ const thumbs =
1243
+ trackRef.current?.querySelectorAll<HTMLElement>('[role="slider"]');
1244
+ const active = trackRef.current?.ownerDocument.activeElement;
1245
+ if (index != null && thumbs != null && active != null) {
1246
+ const list = Array.from(thumbs);
1247
+ if (list.includes(active as HTMLElement) && list[index] !== active) {
1248
+ list[index]?.focus();
1249
+ }
1250
+ }
1251
+ },
1252
+ // The touch interaction ended, in the handler that ended it: the lift, a
1253
+ // cancel or the coast's last frame. The owner renders the last value
1254
+ // now, from that same handler. Then the controller can hand the parts
1255
+ // back to the stylesheet, and onChangeEnd carries the rendered value.
1256
+ commit: (index, next) => {
1257
+ // eslint-disable-next-line @eslint-react/dom-no-flush-sync -- the owner must render the interaction's last value before the parts return to the stylesheet and onChangeEnd reads it
1258
+ flushSync(() => {
1259
+ if (next !== null) {
1260
+ updateValueRef.current(index, next);
1261
+ }
1262
+ setDraggingThumb(null);
1263
+ });
1264
+ },
1265
+ reportEnd: () => fireChangeEnd(),
1266
+ // A tap on the track, from useSliderTouch. It takes a mark's value on a
1267
+ // mark, else the point along the travel. It moves the thumb a track
1268
+ // press moves.
1269
+ valueAt: (x, y) => {
1270
+ const mark = trackRef.current?.ownerDocument
1271
+ .elementFromPoint?.(x, y)
1272
+ ?.closest<HTMLElement>('[data-mark-value]');
1273
+ return mark != null
1274
+ ? Number(mark.dataset.markValue)
1275
+ : getValueFromPosition(x, y);
1276
+ },
1277
+ thumbFor: value => getClosestThumb(value),
1278
+ };
1279
+ useSliderTouch({
1280
+ isEnabled: !isDisabled,
1281
+ model: touchModel,
1282
+ thumbCount: values.length,
1283
+ thumbsWrapped: valueDisplay === 'tooltip' && !showsDisabledMessage,
1284
+ getParts: (): SliderTouchParts | null => {
1285
+ const control = trackRef.current;
1286
+ if (control == null) {
1287
+ return null;
1288
+ }
1289
+ return {
1290
+ control,
1291
+ thumbs: Array.from(
1292
+ control.querySelectorAll<HTMLElement>('[role="slider"]'),
1293
+ ),
1294
+ fillMover: fillMoverRef.current,
1295
+ fill: fillRef.current,
1296
+ };
1297
+ },
1298
+ });
1299
+
962
1300
  // Keyboard handler
963
1301
  const handleKeyDown = useCallback(
964
1302
  (thumbIndex: number, e: KeyboardEvent<HTMLDivElement>) => {
@@ -1069,13 +1407,42 @@ export function Slider({ref, ...props}: SliderProps) {
1069
1407
  };
1070
1408
 
1071
1409
  // Render a thumb
1410
+ // Each thumb's touch target grows to UISlider's 48 pt around the thumb.
1411
+ // A range's two targets never reach into each other: on the side facing
1412
+ // the other thumb, the growth stops at half the gap. So a finger takes the
1413
+ // nearer thumb. Container units measure the travel, so the gap needs no
1414
+ // layout read.
1415
+ const hitGrowth = (UIKIT_SLIDER.thumbHitTarget - THUMB_SIZE) / 2;
1416
+ const thumbHitInsets = (thumbIndex: number) => {
1417
+ const full = `${-hitGrowth}px`;
1418
+ let facing = full;
1419
+ if (isRange && max > min) {
1420
+ const spread = (values[1] - values[0]) / (max - min);
1421
+ const travel = isHorizontal ? '100cqi' : '100cqb';
1422
+ facing = `calc(-1 * clamp(0px, (${spread} * (${travel} - ${THUMB_SIZE}px) - ${THUMB_SIZE}px) / 2, ${hitGrowth}px))`;
1423
+ }
1424
+ // Values grow toward the inline end, and upward on a vertical slider.
1425
+ const towardLower = isRange && thumbIndex === 1 ? facing : full;
1426
+ const towardUpper = isRange && thumbIndex === 0 ? facing : full;
1427
+ const [top, bottom, inlineStart, inlineEnd] = isHorizontal
1428
+ ? [full, full, towardLower, towardUpper]
1429
+ : [towardUpper, towardLower, full, full];
1430
+ return {
1431
+ [HIT_INSET.top]: top,
1432
+ [HIT_INSET.bottom]: bottom,
1433
+ [HIT_INSET.inlineStart]: inlineStart,
1434
+ [HIT_INSET.inlineEnd]: inlineEnd,
1435
+ };
1436
+ };
1437
+
1072
1438
  const renderThumb = (thumbIndex: number) => {
1073
1439
  const val = values[thumbIndex];
1074
1440
  const percent = getPercent(val, min, max);
1075
1441
 
1076
- const positionStyle = isHorizontal
1077
- ? {insetInlineStart: insetPosition(percent)}
1078
- : {bottom: insetPosition(percent), left: '50%'};
1442
+ const positionStyle: ThumbStyle = {
1443
+ [THUMB_PROGRESS]: percent / 100,
1444
+ ...(hasTouchTargets ? thumbHitInsets(thumbIndex) : null),
1445
+ };
1079
1446
 
1080
1447
  // In range mode each thumb keeps a short individual name that composes
1081
1448
  // with the group label (announced via the group's aria-labelledby), per
@@ -1104,10 +1471,12 @@ export function Slider({ref, ...props}: SliderProps) {
1104
1471
  const tooltipPlacement = isHorizontal ? 'above' : 'start';
1105
1472
 
1106
1473
  const thumbElement = (
1474
+ trigger: {ref: Ref<HTMLDivElement>; describedBy: string} | null,
1475
+ ) => (
1107
1476
  <div
1108
1477
  key={thumbIndex}
1109
1478
  {...(!isRange ? rest : undefined)}
1110
- ref={!isRange ? ref : undefined}
1479
+ ref={trigger != null ? trigger.ref : !isRange ? ref : undefined}
1111
1480
  id={!isRange ? id : undefined}
1112
1481
  role="slider"
1113
1482
  // A disabled thumb keeps its Tab stop (spec:AST-085 FR1).
@@ -1125,7 +1494,11 @@ export function Slider({ref, ...props}: SliderProps) {
1125
1494
  !isRange ? joinAriaIDs(labelID, ariaLabelledByProp) : undefined
1126
1495
  }
1127
1496
  aria-describedby={
1128
- [!isRange ? ariaDescribedByProp : undefined, ariaDescribedBy]
1497
+ [
1498
+ !isRange ? ariaDescribedByProp : undefined,
1499
+ ariaDescribedBy,
1500
+ trigger?.describedBy,
1501
+ ]
1129
1502
  .filter(Boolean)
1130
1503
  .join(' ') || undefined
1131
1504
  }
@@ -1145,14 +1518,13 @@ export function Slider({ref, ...props}: SliderProps) {
1145
1518
  }),
1146
1519
  stylex.props(
1147
1520
  styles.thumb,
1148
- isHorizontal
1149
- ? styles.thumbHorizontal
1150
- : rtlStyles.centerInline('50%'),
1521
+ isHorizontal ? styles.thumbHorizontal : styles.thumbVertical,
1151
1522
  !isDisabled && styles.thumbHover,
1152
1523
  !isDisabled && draggingThumb === thumbIndex && styles.thumbPressed,
1153
1524
  keyboardFocusThumb === thumbIndex &&
1154
1525
  focusOutlineStyles.focusVisible,
1155
1526
  isDisabled && styles.thumbDisabled,
1527
+ hasTouchTargets && styles.thumbHit,
1156
1528
  ),
1157
1529
  undefined,
1158
1530
  positionStyle,
@@ -1161,44 +1533,42 @@ export function Slider({ref, ...props}: SliderProps) {
1161
1533
  );
1162
1534
 
1163
1535
  if (useValueTooltip) {
1536
+ const anchorAt = `calc(${insetPosition(percent)} - ${THUMB_SIZE / 2}px)`;
1164
1537
  return (
1165
- <Tooltip
1538
+ <SliderValueTooltip
1166
1539
  key={thumbIndex}
1167
1540
  content={displayValue(val)}
1168
1541
  placement={tooltipPlacement}
1169
- delay={0}
1170
- focusTrigger="always"
1171
1542
  // A drag keeps its own thumb's value showing and closes the other
1172
1543
  // thumb's, which a drag handed over from it may have forced open.
1173
1544
  isOpen={
1174
1545
  draggingThumb === null ? undefined : draggingThumb === thumbIndex
1175
- }>
1546
+ }
1547
+ anchorStyle={
1548
+ isHorizontal
1549
+ ? {
1550
+ insetInlineStart: anchorAt,
1551
+ top: `calc(50% - ${THUMB_SIZE / 2}px)`,
1552
+ }
1553
+ : {
1554
+ bottom: anchorAt,
1555
+ insetInlineStart: `calc(50% - ${THUMB_SIZE / 2}px)`,
1556
+ }
1557
+ }
1558
+ thumbRef={!isRange ? ref : undefined}>
1176
1559
  {thumbElement}
1177
- </Tooltip>
1560
+ </SliderValueTooltip>
1178
1561
  );
1179
1562
  }
1180
1563
 
1181
- return thumbElement;
1564
+ return thumbElement(null);
1182
1565
  };
1183
1566
 
1184
- // Filled track position — ends at the thumb centre, so it uses the same
1185
- // inset mapping as the thumb.
1186
- const filledStyle = (() => {
1187
- if (isRange) {
1188
- const [v0, v1] = values;
1189
- const p0 = getPercent(v0, min, max);
1190
- const p1 = getPercent(v1, min, max);
1191
- if (isHorizontal) {
1192
- return {insetInlineStart: insetPosition(p0), width: insetSpan(p0, p1)};
1193
- }
1194
- return {bottom: insetPosition(p0), height: insetSpan(p0, p1)};
1195
- }
1196
- const p = getPercent(values[0], min, max);
1197
- if (isHorizontal) {
1198
- return {insetInlineStart: '0%', width: insetPosition(p)};
1199
- }
1200
- return {bottom: '0%', height: insetPosition(p)};
1201
- })();
1567
+ // The filled range's ends as progress: it ends at the thumb centers. So it
1568
+ // uses the same inset travel as the thumbs (see styles.fillClip).
1569
+ const fillStart = isRange ? getPercent(values[0], min, max) / 100 : 0;
1570
+ const fillEnd = getPercent(values[isRange ? 1 : 0], min, max) / 100;
1571
+ const fillStyle: FillStyle = {[FILL_START]: fillStart, [FILL_END]: fillEnd};
1202
1572
 
1203
1573
  // Text value display
1204
1574
  const textDisplay =
@@ -1305,95 +1675,132 @@ export function Slider({ref, ...props}: SliderProps) {
1305
1675
  controlClassName,
1306
1676
  controlStyle,
1307
1677
  )}>
1308
- {/* Background track */}
1309
- <div
1310
- aria-hidden="true"
1311
- {...mergeProps(
1312
- themeProps('slider-track', {orientation}),
1313
- stylex.props(
1314
- styles.track,
1315
- isHorizontal
1316
- ? styles.trackHorizontal
1317
- : [styles.trackVertical, rtlStyles.centerInline('0px')],
1318
- ),
1319
- )}
1320
- />
1321
-
1322
- {/* Filled track */}
1323
- <div
1324
- aria-hidden="true"
1325
- {...mergeProps(
1326
- stylex.props(
1327
- styles.filledTrack,
1328
- isHorizontal
1329
- ? styles.filledTrackHorizontal
1330
- : [styles.filledTrackVertical, rtlStyles.centerInline('0px')],
1331
- ),
1332
- {style: filledStyle},
1333
- )}
1334
- />
1678
+ <div {...stylex.props(styles.travelBox)}>
1679
+ {/* Background track */}
1680
+ <div
1681
+ aria-hidden="true"
1682
+ {...mergeProps(
1683
+ themeProps('slider-track', {orientation}),
1684
+ stylex.props(
1685
+ styles.track,
1686
+ isHorizontal
1687
+ ? styles.trackHorizontal
1688
+ : [styles.trackVertical, rtlStyles.centerInline('0px')],
1689
+ ),
1690
+ )}
1691
+ />
1335
1692
 
1336
- {/* Marks */}
1337
- {marks && (
1693
+ {/* Filled track */}
1338
1694
  <div
1339
1695
  aria-hidden="true"
1340
1696
  {...stylex.props(
1341
- styles.marksContainer,
1697
+ styles.fillClip,
1342
1698
  isHorizontal
1343
- ? styles.marksContainerHorizontal
1344
- : styles.marksContainerVertical,
1699
+ ? styles.fillClipHorizontal
1700
+ : [styles.fillClipVertical, rtlStyles.centerInline('0px')],
1345
1701
  )}>
1346
- {marks.map(mark => {
1347
- const percent = getPercent(mark.value, min, max);
1348
- const markPos = isHorizontal
1349
- ? {insetInlineStart: insetPosition(percent)}
1350
- : {bottom: insetPosition(percent)};
1351
- // Marks at or inside the filled region take the fill color:
1352
- // at or behind the thumb in single mode, between the thumbs
1353
- // in range mode.
1354
- const isFilled = isRange
1355
- ? mark.value >= values[0] && mark.value <= values[1]
1356
- : mark.value <= values[0];
1357
- return (
1358
- <div key={mark.value}>
1359
- <div
1360
- data-testid="slider-mark"
1361
- data-mark-value={mark.value}
1362
- {...mergeProps(
1363
- stylex.props(
1364
- styles.mark,
1365
- isHorizontal
1366
- ? styles.markHorizontal
1367
- : styles.markVertical,
1368
- isFilled && styles.markFilled,
1369
- ),
1370
- {style: markPos},
1371
- )}
1372
- />
1373
- {mark.label && (
1374
- <span
1375
- data-testid="slider-mark-label"
1702
+ <div
1703
+ ref={fillMoverRef}
1704
+ {...mergeProps(
1705
+ stylex.props(
1706
+ styles.fillMover,
1707
+ isRange &&
1708
+ (isHorizontal
1709
+ ? [
1710
+ styles.fillMoverRangeHorizontal,
1711
+ styles.fillMoverRangeStartHorizontal,
1712
+ ]
1713
+ : [
1714
+ styles.fillMoverRangeVertical,
1715
+ styles.fillMoverRangeStartVertical,
1716
+ ]),
1717
+ ),
1718
+ {style: fillStyle},
1719
+ )}>
1720
+ <div
1721
+ ref={fillRef}
1722
+ {...mergeProps(
1723
+ stylex.props(
1724
+ styles.filledTrack,
1725
+ isHorizontal
1726
+ ? styles.filledTrackHorizontal
1727
+ : styles.filledTrackVertical,
1728
+ isRange
1729
+ ? isHorizontal
1730
+ ? styles.filledTrackRangeHorizontal
1731
+ : styles.filledTrackRangeVertical
1732
+ : isHorizontal
1733
+ ? styles.filledTrackSingleHorizontal
1734
+ : styles.filledTrackSingleVertical,
1735
+ ),
1736
+ {style: fillStyle},
1737
+ )}
1738
+ />
1739
+ </div>
1740
+ </div>
1741
+
1742
+ {/* Marks */}
1743
+ {marks && (
1744
+ <div
1745
+ aria-hidden="true"
1746
+ {...stylex.props(
1747
+ styles.marksContainer,
1748
+ isHorizontal
1749
+ ? styles.marksContainerHorizontal
1750
+ : styles.marksContainerVertical,
1751
+ )}>
1752
+ {marks.map(mark => {
1753
+ const percent = getPercent(mark.value, min, max);
1754
+ const markPos = isHorizontal
1755
+ ? {insetInlineStart: insetPosition(percent)}
1756
+ : {bottom: insetPosition(percent)};
1757
+ // Marks at or inside the filled region take the fill color:
1758
+ // at or behind the thumb in single mode, between the thumbs
1759
+ // in range mode.
1760
+ const isFilled = isRange
1761
+ ? mark.value >= values[0] && mark.value <= values[1]
1762
+ : mark.value <= values[0];
1763
+ return (
1764
+ <div key={mark.value}>
1765
+ <div
1766
+ data-testid="slider-mark"
1376
1767
  data-mark-value={mark.value}
1377
1768
  {...mergeProps(
1378
1769
  stylex.props(
1379
- styles.markLabel,
1770
+ styles.mark,
1380
1771
  isHorizontal
1381
- ? styles.markLabelHorizontal
1382
- : styles.markLabelVertical,
1772
+ ? styles.markHorizontal
1773
+ : styles.markVertical,
1774
+ isFilled && styles.markFilled,
1383
1775
  ),
1384
1776
  {style: markPos},
1385
- )}>
1386
- {mark.label}
1387
- </span>
1388
- )}
1389
- </div>
1390
- );
1391
- })}
1392
- </div>
1393
- )}
1777
+ )}
1778
+ />
1779
+ {mark.label && (
1780
+ <span
1781
+ data-testid="slider-mark-label"
1782
+ data-mark-value={mark.value}
1783
+ {...mergeProps(
1784
+ stylex.props(
1785
+ styles.markLabel,
1786
+ isHorizontal
1787
+ ? styles.markLabelHorizontal
1788
+ : styles.markLabelVertical,
1789
+ ),
1790
+ {style: markPos},
1791
+ )}>
1792
+ {mark.label}
1793
+ </span>
1794
+ )}
1795
+ </div>
1796
+ );
1797
+ })}
1798
+ </div>
1799
+ )}
1394
1800
 
1395
- {/* Thumbs */}
1396
- {values.map((_, i) => renderThumb(i))}
1801
+ {/* Thumbs */}
1802
+ {values.map((_, i) => renderThumb(i))}
1803
+ </div>
1397
1804
  </div>
1398
1805
 
1399
1806
  {textDisplay}