@astryxdesign/lab 0.2.0-canary.8d64648 → 0.2.0-canary.8dfd7cf

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 (63) hide show
  1. package/dist/BottomSheet/BottomSheet.d.ts +41 -35
  2. package/dist/BottomSheet/BottomSheet.d.ts.map +1 -1
  3. package/dist/BottomSheet/BottomSheet.js +222 -75
  4. package/dist/BottomSheet/snapOffsets.d.ts +54 -0
  5. package/dist/BottomSheet/snapOffsets.d.ts.map +1 -0
  6. package/dist/BottomSheet/snapOffsets.js +101 -0
  7. package/dist/BottomSheet/useSheetGestures.d.ts +55 -12
  8. package/dist/BottomSheet/useSheetGestures.d.ts.map +1 -1
  9. package/dist/BottomSheet/useSheetGestures.js +383 -40
  10. package/dist/Chart/index.d.ts +1 -1
  11. package/dist/Chart/index.d.ts.map +1 -1
  12. package/dist/Chart/index.js +1 -1
  13. package/dist/Stepper/Step.d.ts +1 -1
  14. package/dist/Stepper/Step.js +1 -1
  15. package/dist/Stepper/Stepper.d.ts +1 -1
  16. package/dist/Stepper/Stepper.js +1 -1
  17. package/dist/index.js +1 -1
  18. package/dist/lab.css +15 -8
  19. package/package.json +3 -2
  20. package/src/BottomSheet/BottomSheet.doc.mjs +13 -13
  21. package/src/BottomSheet/BottomSheet.test.tsx +81 -4
  22. package/src/BottomSheet/BottomSheet.tsx +333 -97
  23. package/src/BottomSheet/snapOffsets.test.ts +146 -0
  24. package/src/BottomSheet/snapOffsets.ts +126 -0
  25. package/src/BottomSheet/useSheetGestures.doc.mjs +7 -6
  26. package/src/BottomSheet/useSheetGestures.test.ts +315 -65
  27. package/src/BottomSheet/useSheetGestures.ts +515 -55
  28. package/src/Chart/ChartArea.doc.mjs +1 -1
  29. package/src/Chart/ChartBar.doc.mjs +1 -1
  30. package/src/Chart/ChartCandlestick.doc.mjs +1 -1
  31. package/src/Chart/ChartDot.doc.mjs +1 -1
  32. package/src/Chart/ChartDotGL.doc.mjs +1 -1
  33. package/src/Chart/ChartDotGLInteractive.doc.mjs +1 -1
  34. package/src/Chart/ChartHeatmapGL.doc.mjs +1 -1
  35. package/src/Chart/ChartLine.doc.mjs +1 -1
  36. package/src/Chart/ChartStreamGL.doc.mjs +1 -1
  37. package/src/Chart/index.ts +0 -1
  38. package/src/Chat/ChatEmojiPicker.doc.mjs +1 -1
  39. package/src/Chat/ChatReactionBar.doc.mjs +1 -1
  40. package/src/Chat/ChatTypingIndicator.doc.mjs +1 -1
  41. package/src/Chat/ChatUnreadDivider.doc.mjs +1 -1
  42. package/src/ChatReasoning/ChatReasoning.doc.mjs +1 -1
  43. package/src/CircularProgress/CircularProgress.doc.mjs +3 -3
  44. package/src/CodeEditor/CodeEditor.doc.mjs +1 -1
  45. package/src/Drawer/Drawer.doc.mjs +3 -3
  46. package/src/InfoTip/InfoTip.doc.mjs +3 -3
  47. package/src/LogStream/LogStream.doc.mjs +1 -1
  48. package/src/Radial/RadialChart.doc.mjs +1 -1
  49. package/src/RichTextEditor/RichTextEditor.doc.mjs +1 -1
  50. package/src/SVGIcon/SVGIcon.doc.mjs +1 -1
  51. package/src/Sankey/SankeyChart.doc.mjs +1 -1
  52. package/src/Schedule/Schedule.doc.mjs +1 -1
  53. package/src/Stat/Stat.doc.mjs +3 -3
  54. package/src/Stepper/Step.doc.mjs +1 -1
  55. package/src/Stepper/Step.tsx +1 -1
  56. package/src/Stepper/Stepper.doc.mjs +3 -3
  57. package/src/Stepper/Stepper.tsx +1 -1
  58. package/src/ThreeD/3DBar.doc.mjs +1 -1
  59. package/src/ThreeD/3DChart.doc.mjs +1 -1
  60. package/src/ThreeD/3DScatter.doc.mjs +1 -1
  61. package/src/ThreeD/3DScatterGL.doc.mjs +1 -1
  62. package/src/ThreeD/3DSurface.doc.mjs +1 -1
  63. package/src/index.ts +1 -1
@@ -0,0 +1,146 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file snapOffsets.test.ts
5
+ * @input Uses vitest, snapOffsets pure helpers
6
+ * @output Unit tests for the snap-point -> translateY offset geometry
7
+ * @position Testing; validates snapOffsets.ts (no DOM needed)
8
+ *
9
+ * SYNC: When snapOffsets.ts changes, update these tests to match.
10
+ */
11
+
12
+ import {describe, it, expect} from 'vitest';
13
+ import {
14
+ snapFractionsToHeights,
15
+ computeDetentOffsets,
16
+ nearestOffset,
17
+ resolveSettleOffset,
18
+ scrimOpacityForOffset,
19
+ DETENT_DEDUP_PX,
20
+ } from './snapOffsets';
21
+
22
+ describe('snapFractionsToHeights', () => {
23
+ it('scales in-range fractions to px', () => {
24
+ expect(snapFractionsToHeights([0.5, 0.92], 800)).toEqual([400, 736]);
25
+ });
26
+ it('drops out-of-range fractions', () => {
27
+ expect(snapFractionsToHeights([0, 0.5, 1, 1.2, -0.3], 1000)).toEqual([
28
+ 500, 1000,
29
+ ]);
30
+ });
31
+ });
32
+
33
+ describe('computeDetentOffsets', () => {
34
+ it('always includes fully-open (0) first, ascending', () => {
35
+ // sheet 800; detent visible heights 400, 640 -> offsets 400, 160 -> sorted
36
+ expect(computeDetentOffsets(800, [400, 640])).toEqual([0, 160, 400]);
37
+ });
38
+
39
+ it('drops detents at or taller than the sheet (cannot translate up)', () => {
40
+ // 800 and 900 are >= sheet 800 -> dropped; only 500 (offset 300) remains
41
+ expect(computeDetentOffsets(800, [500, 800, 900])).toEqual([0, 300]);
42
+ });
43
+
44
+ it('de-dupes near-equal detents, keeping the taller (smaller offset)', () => {
45
+ // sheet 800; heights 760 (offset 40) and 400 (offset 400). 40 is within
46
+ // DETENT_DEDUP_PX of the fully-open 0 -> collapses to just [0, 400].
47
+ expect(computeDetentOffsets(800, [760, 400])).toEqual([0, 400]);
48
+ });
49
+
50
+ it('keeps detents that are farther apart than the dedup threshold', () => {
51
+ // offsets 0, 100, 400 with default 48px dedup -> all kept
52
+ expect(computeDetentOffsets(800, [700, 400])).toEqual([0, 100, 400]);
53
+ });
54
+
55
+ it('respects a custom dedup threshold', () => {
56
+ // offsets 0, 100, 400; dedup 150 -> 100 collapses into 0
57
+ expect(computeDetentOffsets(800, [700, 400], 150)).toEqual([0, 400]);
58
+ });
59
+
60
+ it('a single-height sheet has just the fully-open detent', () => {
61
+ expect(computeDetentOffsets(600, [])).toEqual([0]);
62
+ });
63
+ });
64
+
65
+ describe('nearestOffset', () => {
66
+ it('picks the closest offset', () => {
67
+ expect(nearestOffset(180, [0, 200, 400])).toBe(200);
68
+ expect(nearestOffset(90, [0, 200, 400])).toBe(0);
69
+ });
70
+ });
71
+
72
+ describe('resolveSettleOffset', () => {
73
+ const offsets = [0, 200, 400]; // full, mid, short
74
+
75
+ it('a down-drag never settles above the starting detent', () => {
76
+ // start at mid (200), drag down to ~240 -> nearest at/below is 200 (mid),
77
+ // never back up to 0.
78
+ expect(resolveSettleOffset(240, offsets, 1, 200)).toBe(200);
79
+ });
80
+
81
+ it('a down-drag past the midpoint moves to the next lower detent', () => {
82
+ // start mid (200), drag to 320 -> nearest at/below is 400 (short)
83
+ expect(resolveSettleOffset(320, offsets, 1, 200)).toBe(400);
84
+ });
85
+
86
+ it('an up-drag never settles below the starting detent', () => {
87
+ // start mid (200), drag up to ~160 -> nearest at/above is 0 or 200; 160 is
88
+ // closer to 200, so it holds mid rather than dropping to short.
89
+ expect(resolveSettleOffset(160, offsets, -1, 200)).toBe(200);
90
+ });
91
+
92
+ it('an up-drag past the midpoint expands to the next higher detent', () => {
93
+ // start mid (200), drag up to 80 -> nearest at/above is 0 (full)
94
+ expect(resolveSettleOffset(80, offsets, -1, 200)).toBe(0);
95
+ });
96
+
97
+ it('with no direction, picks the plain nearest', () => {
98
+ expect(resolveSettleOffset(180, offsets, 0, 200)).toBe(200);
99
+ });
100
+ });
101
+
102
+ describe('scrimOpacityForOffset', () => {
103
+ describe('with a peek detent (>= 2 detents)', () => {
104
+ const offsets = [0, 100, 200]; // full, mid, peek
105
+ const dismissOffset = 280;
106
+
107
+ it('is full at or above the mid detent', () => {
108
+ expect(scrimOpacityForOffset(0, offsets, dismissOffset)).toBe(1);
109
+ expect(scrimOpacityForOffset(100, offsets, dismissOffset)).toBe(1);
110
+ expect(scrimOpacityForOffset(60, offsets, dismissOffset)).toBe(1);
111
+ });
112
+
113
+ it('fades linearly from the mid detent to the peek', () => {
114
+ expect(scrimOpacityForOffset(150, offsets, dismissOffset)).toBeCloseTo(
115
+ 0.5,
116
+ );
117
+ });
118
+
119
+ it('is hidden at or below the peek detent', () => {
120
+ expect(scrimOpacityForOffset(200, offsets, dismissOffset)).toBe(0);
121
+ expect(scrimOpacityForOffset(240, offsets, dismissOffset)).toBe(0);
122
+ });
123
+ });
124
+
125
+ describe('with a single detent (no peek)', () => {
126
+ const offsets = [0];
127
+ const dismissOffset = 160;
128
+
129
+ it('stays full until the drag begins collapsing', () => {
130
+ expect(scrimOpacityForOffset(0, offsets, dismissOffset)).toBe(1);
131
+ });
132
+
133
+ it('fades across the dismiss overshoot toward the threshold', () => {
134
+ expect(scrimOpacityForOffset(80, offsets, dismissOffset)).toBeCloseTo(
135
+ 0.5,
136
+ );
137
+ expect(scrimOpacityForOffset(160, offsets, dismissOffset)).toBe(0);
138
+ });
139
+ });
140
+ });
141
+
142
+ describe('DETENT_DEDUP_PX', () => {
143
+ it('is a sane positive threshold', () => {
144
+ expect(DETENT_DEDUP_PX).toBeGreaterThan(0);
145
+ });
146
+ });
@@ -0,0 +1,126 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file snapOffsets.ts
5
+ * @input Pure geometry helpers — no React, no DOM.
6
+ * @output Converts snap points to translateY offsets for the sheet surface.
7
+ * @position Internal to BottomSheet; consumed by useSheetGestures, tested by
8
+ * snapOffsets.test.ts.
9
+ *
10
+ * The sheet is rendered at its full (fully-open) height and *translated down*
11
+ * to express shorter detents — translate is GPU-composited, so this is cheaper
12
+ * than animating layout height. A detent is therefore a translateY offset in
13
+ * px from the fully-open position (0 = fully open, larger = more collapsed).
14
+ * These helpers turn snap points into that offset list; they are pure so the
15
+ * geometry can be unit-tested without a DOM.
16
+ */
17
+
18
+ /**
19
+ * Detents whose resting offsets land within this many px of each other are
20
+ * treated as the same stop (the taller one wins). Stops the sheet from having
21
+ * two near-identical rest positions — e.g. when a content-hugging height sits
22
+ * a hair away from a fractional snap point.
23
+ */
24
+ export const DETENT_DEDUP_PX = 48;
25
+
26
+ /** Resolve snap points given as viewport fractions (0, 1] to px heights. */
27
+ export function snapFractionsToHeights(
28
+ fractions: readonly number[],
29
+ viewportPx: number,
30
+ ): number[] {
31
+ return fractions.filter(f => f > 0 && f <= 1).map(f => f * viewportPx);
32
+ }
33
+
34
+ /**
35
+ * Given the sheet's full height (px, as rendered fully open) and a set of
36
+ * candidate detent *visible heights* (px), return the resting translateY
37
+ * offsets from fully-open, ascending and de-duplicated.
38
+ *
39
+ * - `0` (fully open) is always the first detent.
40
+ * - Only heights strictly shorter than the sheet become collapsed detents; a
41
+ * height >= the sheet can't be shown by translating down, so it's dropped.
42
+ * - Offsets closer than `dedupPx` collapse to the smaller (taller) offset, so
43
+ * near-identical detents — e.g. a hug height ≈ a snap point — become one stop.
44
+ */
45
+ export function computeDetentOffsets(
46
+ sheetHeight: number,
47
+ detentHeights: readonly number[],
48
+ dedupPx: number = DETENT_DEDUP_PX,
49
+ ): number[] {
50
+ const collapsed = detentHeights
51
+ .filter(h => h > 0 && h < sheetHeight)
52
+ .map(h => sheetHeight - h);
53
+ const ascending = [0, ...collapsed].sort((a, b) => a - b);
54
+
55
+ const deduped: number[] = [];
56
+ for (const offset of ascending) {
57
+ const last = deduped[deduped.length - 1];
58
+ if (last === undefined || offset - last >= dedupPx) {
59
+ deduped.push(offset);
60
+ }
61
+ }
62
+ return deduped;
63
+ }
64
+
65
+ /** Nearest value in `offsets` to `value` (offsets must be non-empty). */
66
+ export function nearestOffset(
67
+ value: number,
68
+ offsets: readonly number[],
69
+ ): number {
70
+ return offsets.reduce(
71
+ (best, o) => (Math.abs(o - value) < Math.abs(best - value) ? o : best),
72
+ offsets[0],
73
+ );
74
+ }
75
+
76
+ /**
77
+ * Scrim opacity (1 = fully visible, 0 = hidden) for a drag/settle `offset`.
78
+ * The scrim stays full while the sheet is at or above its second-shortest
79
+ * detent, then fades to 0 as it collapses onto the shortest ("peek") detent —
80
+ * a glance state that reveals the content behind — and stays hidden below it.
81
+ * A single-detent sheet has no peek, so it instead fades across the dismiss
82
+ * overshoot toward `dismissOffset`.
83
+ */
84
+ export function scrimOpacityForOffset(
85
+ offset: number,
86
+ offsets: readonly number[],
87
+ dismissOffset: number,
88
+ ): number {
89
+ const shortest = offsets[offsets.length - 1];
90
+ const fadeStart = offsets.length >= 2 ? offsets[offsets.length - 2] : 0;
91
+ const fadeEnd = offsets.length >= 2 ? shortest : dismissOffset;
92
+ if (offset <= fadeStart) {
93
+ return 1;
94
+ }
95
+ if (offset >= fadeEnd) {
96
+ return 0;
97
+ }
98
+ return 1 - (offset - fadeStart) / (fadeEnd - fadeStart);
99
+ }
100
+
101
+ /**
102
+ * Settle target for a released drag. Restricts candidates to the drag
103
+ * direction so a committed drag never snaps *back past* where it started
104
+ * (a down-drag settles at/below the start, an up-drag at/above), then picks
105
+ * the nearest remaining detent. `dir`: 1 = dragged down, -1 = up, 0 = neither.
106
+ */
107
+ export function resolveSettleOffset(
108
+ value: number,
109
+ offsets: readonly number[],
110
+ dir: number,
111
+ baseOffset: number,
112
+ ): number {
113
+ let candidates = offsets;
114
+ if (dir > 0) {
115
+ const downward = offsets.filter(o => o >= baseOffset);
116
+ if (downward.length > 0) {
117
+ candidates = downward;
118
+ }
119
+ } else if (dir < 0) {
120
+ const upward = offsets.filter(o => o <= baseOffset);
121
+ if (upward.length > 0) {
122
+ candidates = upward;
123
+ }
124
+ }
125
+ return nearestOffset(value, candidates);
126
+ }
@@ -1,15 +1,15 @@
1
1
  // Copyright (c) Meta Platforms, Inc. and affiliates.
2
2
 
3
- /** @type {import('../../../core/src/docs-types').HookDoc} */
3
+ /** @type {import('@astryxdesign/cli/authoring').HookDoc} */
4
4
  export const docs = {
5
5
  name: 'useSheetGestures',
6
6
  displayName: 'useSheetGestures',
7
- keywords: ['sheet', 'drag', 'swipe', 'dismiss', 'gesture', 'pointer', 'touch', 'bottom sheet'],
7
+ keywords: ['sheet', 'drag', 'swipe', 'snap', 'detent', 'dismiss', 'gesture', 'pointer', 'touch', 'bottom sheet'],
8
8
  params: [
9
9
  {
10
10
  name: 'options',
11
11
  type: 'UseSheetGesturesOptions',
12
- description: 'isOpen and onDismiss. Internal to BottomSheet; not exported from the lab entry point.',
12
+ description: 'isOpen, onDismiss, optional snapHeights (candidate detent heights in px), onSnap, and onScrimOpacity (the scrim opacity to show, 1->0 as the sheet collapses onto its peek detent, for a glance affordance). Internal to BottomSheet, not exported from the lab entry point.',
13
13
  required: true,
14
14
  },
15
15
  ],
@@ -17,15 +17,16 @@ export const docs = {
17
17
  {
18
18
  name: 'result',
19
19
  type: 'UseSheetGesturesResult',
20
- description: 'contentProps (spread on the sliding surface for the live translate + touch-action guard), handleProps (spread on the grab handle for the pointer drag), plus dragOffset and isDragging for callers that want to drive their own animation.',
20
+ description: 'contentProps (spread on the sliding surface for the live translate + touch-action guard), handleProps (spread on the grab handle for the pointer drag), plus dragOffset, settledOffset, and isDragging for callers that want to drive their own animation.',
21
21
  },
22
22
  ],
23
23
  usage: {
24
24
  description:
25
- 'Drag-to-dismiss machinery for the bottom sheet. Tracks a pointer drag down the block axis, translates the sliding surface live, and on release either dismisses (past a distance or velocity threshold) or springs back to fully open. Pointer-events based (one path for mouse + touch), SSR-safe, and respects prefers-reduced-motion. Escape (routed by the owning dialog) provides the keyboard equivalent of the swipe.',
25
+ 'Drag + snap machinery for the bottom sheet. A slow drag on the handle resizes the sheet live and, on release, settles to the nearest snap detent; a fast downward flick dismisses (swipe-to-close); a fast upward flick expands to the tallest detent. The measured sheet height is always the tallest detent, and snapHeights add shorter rest points beneath it. Pointer-events based (one path for mouse + touch), SSR-safe, and respects prefers-reduced-motion. Escape (routed by the owning dialog) provides the keyboard equivalent of the dismiss swipe.',
26
26
  bestPractices: [
27
27
  { guidance: true, description: 'Spread handleProps on the grab-handle element and contentProps on the sliding surface (the panel that carries the translate).' },
28
- { guidance: false, description: 'Wire onDismiss to anything other than the owning sheet close; it fires when a swipe crosses the dismiss threshold.' },
28
+ { guidance: true, description: 'Pass snapHeights in px relative to the viewport; the hook filters out any that are >= the measured sheet height.' },
29
+ { guidance: false, description: 'Wire onDismiss to anything other than the owning sheet close; it fires on a swipe-to-close or a drag past the shortest detent.' },
29
30
  ],
30
31
  },
31
32
  };