@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
package/dist/lab.css CHANGED
@@ -117,11 +117,12 @@
117
117
  .x1g06x3t:not(#\#):not(#\#){margin-block:var(--spacing-2)}
118
118
  .x1oqu0vw:not(#\#):not(#\#){margin-block:var(--x-marginBlock)}
119
119
  .xrxpjvj:not(#\#):not(#\#){margin-inline:0}
120
- .xvueqy4:not(#\#):not(#\#){margin-inline:auto}
121
120
  .x1a2a7pz:not(#\#):not(#\#){outline:none}
122
121
  .xysyzu8:not(#\#):not(#\#){overflow:auto}
123
122
  .xb3r6kr:not(#\#):not(#\#){overflow:hidden}
123
+ .x1rea2x4:not(#\#):not(#\#){overflow:visible}
124
124
  .xish69e:not(#\#):not(#\#){overscroll-behavior:contain}
125
+ .x8du1vd:not(#\#):not(#\#){overscroll-behavior:none}
125
126
  .xsplgde:not(#\#):not(#\#){padding-block:1px}
126
127
  .x13f7esw:not(#\#):not(#\#){padding-block:var(--spacing-0-5)}
127
128
  .xu0wf1k:not(#\#):not(#\#){padding-block:var(--spacing-1)}
@@ -234,10 +235,9 @@
234
235
  .x1t7ytsu:not(#\#):not(#\#):not(#\#){border-inline-start-style:solid}
235
236
  .xct1zlm:not(#\#):not(#\#):not(#\#){border-inline-start-width:3px}
236
237
  .xgbv0en:not(#\#):not(#\#):not(#\#){border-inline-start-width:var(--border-width)}
237
- .x663v1:not(#\#):not(#\#):not(#\#){border-start-end-radius:var(--radius-page)}
238
- .x183tx6i:not(#\#):not(#\#):not(#\#){border-start-start-radius:var(--radius-page)}
238
+ .x7hs6f1:not(#\#):not(#\#):not(#\#){border-start-end-radius:var(--radius-container)}
239
+ .x81l70g:not(#\#):not(#\#):not(#\#){border-start-start-radius:var(--radius-container)}
239
240
  .x18oyj42:not(#\#):not(#\#):not(#\#){box-shadow:0 0 0 1px var(--color-accent)}
240
- .x1gnnqk1:not(#\#):not(#\#):not(#\#){box-shadow:none}
241
241
  .x1kcpxr7:not(#\#):not(#\#):not(#\#){box-shadow:var(--shadow-high)}
242
242
  .x14hfi27:not(#\#):not(#\#):not(#\#){box-shadow:var(--shadow-med)}
243
243
  .x9f619:not(#\#):not(#\#):not(#\#){box-sizing:border-box}
@@ -453,6 +453,7 @@
453
453
  .x19991ni:not(#\#):not(#\#):not(#\#){transition-property:opacity}
454
454
  .xxnu56j:not(#\#):not(#\#):not(#\#){transition-property:stroke-dashoffset}
455
455
  .x1yw18vd:not(#\#):not(#\#):not(#\#){transition-property:transform,max-width,display}
456
+ .x11xpdln:not(#\#):not(#\#):not(#\#){transition-property:transform}
456
457
  .x9lcvmn:not(#\#):not(#\#):not(#\#){transition-timing-function:ease-out}
457
458
  .xlr8y92:not(#\#):not(#\#):not(#\#){transition-timing-function:var(--ease-standard)}
458
459
  .x87ps6o:not(#\#):not(#\#):not(#\#){user-select:none}
@@ -499,7 +500,6 @@
499
500
  .x1ey2m1c:not(#\#):not(#\#):not(#\#):not(#\#){bottom:0}
500
501
  .xdd4er5:not(#\#):not(#\#):not(#\#):not(#\#){bottom:auto}
501
502
  .x1hmuevu:not(#\#):not(#\#):not(#\#):not(#\#){bottom:var(--spacing-3)}
502
- .x5yr21d:not(#\#):not(#\#):not(#\#):not(#\#){height:100%}
503
503
  .xtdtrs8:not(#\#):not(#\#):not(#\#):not(#\#){height:100dvh}
504
504
  .x170jfvy:not(#\#):not(#\#):not(#\#):not(#\#){height:10px}
505
505
  .x1v9usgg:not(#\#):not(#\#):not(#\#):not(#\#){height:14px}
@@ -511,18 +511,22 @@
511
511
  .xqu0tyb:not(#\#):not(#\#):not(#\#):not(#\#){height:4px}
512
512
  .x1ycjhwn:not(#\#):not(#\#):not(#\#):not(#\#){height:5px}
513
513
  .x1d9x2de:not(#\#):not(#\#):not(#\#):not(#\#){height:640px}
514
+ .x1tpcejd:not(#\#):not(#\#):not(#\#):not(#\#){height:calc(var(--_sheet-budget) + 48px)}
514
515
  .xg7h5cd:not(#\#):not(#\#):not(#\#):not(#\#){height:fit-content}
515
516
  .xhigkf7:not(#\#):not(#\#):not(#\#):not(#\#){height:var(--icon-size)}
517
+ .x11c6zpc:not(#\#):not(#\#):not(#\#):not(#\#){height:var(--spacing-1)}
518
+ .x1k15mir:not(#\#):not(#\#):not(#\#):not(#\#){height:var(--spacing-12)}
516
519
  .x6b6gus:not(#\#):not(#\#):not(#\#):not(#\#){height:var(--spacing-4)}
517
520
  .x1grt7ep:not(#\#):not(#\#):not(#\#):not(#\#){height:var(--spacing-5)}
518
521
  .x16ye13r:not(#\#):not(#\#):not(#\#):not(#\#){height:var(--x-height)}
519
522
  .x1unci72:not(#\#):not(#\#):not(#\#):not(#\#){left:var(--spacing-4)}
523
+ .x1if0o47:not(#\#):not(#\#):not(#\#):not(#\#){margin-bottom:-48px}
520
524
  .xlstkdb:not(#\#):not(#\#):not(#\#):not(#\#){margin-bottom:var(--spacing-0-5)}
521
525
  .x160xvyo:not(#\#):not(#\#):not(#\#):not(#\#){margin-top:calc(var(--spacing-1) * -1)}
522
526
  .xcsaf9d:not(#\#):not(#\#):not(#\#):not(#\#){margin-top:var(--spacing-1)}
523
527
  .xtbrsbv:not(#\#):not(#\#):not(#\#):not(#\#){margin-top:var(--spacing-2)}
524
528
  .x1vhfslr:not(#\#):not(#\#):not(#\#):not(#\#){margin-top:var(--x-marginBlockStart)}
525
- .x1ismmbu:not(#\#):not(#\#):not(#\#):not(#\#){max-height:90dvh}
529
+ .x14bu9tk:not(#\#):not(#\#):not(#\#):not(#\#){max-height:calc(92dvh + 48px)}
526
530
  .x7ab17h:not(#\#):not(#\#):not(#\#):not(#\#){max-height:none}
527
531
  .x1jols5v:not(#\#):not(#\#):not(#\#):not(#\#){max-height:var(--x-maxHeight)}
528
532
  .x1k2d6hx:not(#\#):not(#\#):not(#\#):not(#\#){max-width:44px}
@@ -546,7 +550,7 @@
546
550
  .x6ikm8r:not(#\#):not(#\#):not(#\#):not(#\#){overflow-x:hidden}
547
551
  .x1odjw0f:not(#\#):not(#\#):not(#\#):not(#\#){overflow-y:auto}
548
552
  .x1j85h84:not(#\#):not(#\#):not(#\#):not(#\#){padding-bottom:1px}
549
- .x1a148e8:not(#\#):not(#\#):not(#\#):not(#\#){padding-bottom:env(safe-area-inset-bottom,0px)}
553
+ .x1wkw6tp:not(#\#):not(#\#):not(#\#):not(#\#){padding-bottom:calc(env(safe-area-inset-bottom,0px) + 48px)}
550
554
  .xy143xn:not(#\#):not(#\#):not(#\#):not(#\#){padding-bottom:var(--spacing-1)}
551
555
  .xpgmcrp:not(#\#):not(#\#):not(#\#):not(#\#){padding-top:var(--icon-block-offset)}
552
556
  .xbx876j:not(#\#):not(#\#):not(#\#):not(#\#){padding-top:var(--spacing-0-5)}
@@ -567,10 +571,10 @@
567
571
  .x1dz1jew:not(#\#):not(#\#):not(#\#):not(#\#){width:280px}
568
572
  .x1849jeq:not(#\#):not(#\#):not(#\#):not(#\#){width:30px}
569
573
  .x1td3qas:not(#\#):not(#\#):not(#\#):not(#\#){width:32px}
570
- .x14qfxbe:not(#\#):not(#\#):not(#\#):not(#\#){width:36px}
571
574
  .x51ohtg:not(#\#):not(#\#):not(#\#):not(#\#){width:4px}
572
575
  .x1ftt334:not(#\#):not(#\#):not(#\#):not(#\#){width:5px}
573
576
  .x1oai4fc:not(#\#):not(#\#):not(#\#):not(#\#){width:var(--icon-size)}
577
+ .x1m747yf:not(#\#):not(#\#):not(#\#):not(#\#){width:var(--size-element-lg)}
574
578
  .x12xnipv:not(#\#):not(#\#):not(#\#):not(#\#){width:var(--spacing-4)}
575
579
  .xfyiiit:not(#\#):not(#\#):not(#\#):not(#\#){width:var(--spacing-5)}
576
580
  .x1t1lzn6:last-child:not(#\#):not(#\#):not(#\#):not(#\#){border-bottom-width:0}
@@ -594,9 +598,12 @@
594
598
  .xh7j7mt:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#):not(#\#):not(#\#)::before{inset-inline-start:-6px}
595
599
  .xph5o2a:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#):not(#\#):not(#\#)::backdrop{opacity:0}
596
600
  .xb3n6bw:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#):not(#\#):not(#\#)::backdrop{opacity:1}
601
+ .xni466t:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#):not(#\#):not(#\#)::backdrop{opacity:var(--_sheet-scrim-opacity,1)}
597
602
  .x1hmns74:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#):not(#\#):not(#\#)::before{position:absolute}
598
603
  .x1j6awrg:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#):not(#\#):not(#\#)::after{position:absolute}
604
+ .x142juwg:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#):not(#\#):not(#\#)::backdrop{transition-behavior:allow-discrete}
599
605
  .xft5bk6:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#):not(#\#):not(#\#)::backdrop{transition-duration:var(--duration-medium)}
606
+ .xc0dz0a:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#):not(#\#):not(#\#)::backdrop{transition-property:opacity,display}
600
607
  .x167zut7:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#):not(#\#):not(#\#)::backdrop{transition-property:opacity}
601
608
  .x15h3t91:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#):not(#\#):not(#\#)::backdrop{transition-timing-function:var(--ease-standard)}
602
609
  @media (prefers-reduced-motion: reduce){.x1viac0w.x1viac0w:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#):not(#\#):not(#\#)::backdrop{transition-duration:.01s}}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astryxdesign/lab",
3
- "version": "0.2.0-canary.8d64648",
3
+ "version": "0.2.0-canary.8dfd7cf",
4
4
  "description": "Experimental Astryx components — published to npm only under the @canary dist-tag for early testing; never released as a stable (latest) version.",
5
5
  "author": "Meta Open Source",
6
6
  "license": "MIT",
@@ -45,7 +45,7 @@
45
45
  },
46
46
  "peerDependencies": {
47
47
  "@stylexjs/stylex": ">=0.10.0",
48
- "@astryxdesign/core": "0.2.0-canary.8d64648",
48
+ "@astryxdesign/core": "0.2.0-canary.8dfd7cf",
49
49
  "react": ">=19.0.0",
50
50
  "react-dom": ">=19.0.0",
51
51
  "lexical": "^0.46.0",
@@ -65,6 +65,7 @@
65
65
  "d3-shape": "^3.2.0"
66
66
  },
67
67
  "devDependencies": {
68
+ "@astryxdesign/cli": "0.2.0-canary.8dfd7cf",
68
69
  "@lexical/code": "^0.46.0",
69
70
  "@lexical/headless": "^0.46.0",
70
71
  "@lexical/html": "^0.46.0",
@@ -1,5 +1,5 @@
1
1
  // Copyright (c) Meta Platforms, Inc. and affiliates.
2
- /** @type {import('../../../core/src/docs-types').ComponentDoc} */
2
+ /** @type {import('@astryxdesign/cli/authoring').ComponentDoc} */
3
3
 
4
4
  export const docs = {
5
5
  name: 'BottomSheet',
@@ -12,7 +12,7 @@ export const docs = {
12
12
  {className: 'astryx-bottom-sheet', visualProps: []},
13
13
  ],
14
14
  },
15
- description: 'A mobile touch sheet that rises from the bottom edge, with a grab handle and swipe-to-dismiss. Built on the Drawer <dialog> engine.',
15
+ description: 'A mobile touch sheet that rises from the bottom edge, with a grab handle, drag-to-resize snap points, and swipe-to-dismiss. Built on a native modal <dialog>.',
16
16
  props: [
17
17
  {
18
18
  name: 'isOpen',
@@ -40,17 +40,17 @@ export const docs = {
40
40
  },
41
41
  {
42
42
  name: 'height',
43
- type: "'short' | 'medium' | 'tall' | 'auto'",
44
- description: "How tall the sheet is: 'short' is a fixed peek height, 'medium' is half the viewport, 'tall' is nearly the full viewport, and 'auto' fits its content up to the 'tall' budget. On shorter viewports the sheet fills the available height.",
45
- default: "'medium'",
43
+ type: "'hug' | 'capped' | 'tall' | number | string",
44
+ description: "How tall the sheet is. Named budgets: 'hug' fits its content up to 92% of the viewport, 'capped' is a scrolling mid-height panel (~62%), and 'tall' is a pinned near-full panel (~92%) for content that streams in. Or pass a number (px) / CSS length for a custom budget. The user can drag between snap points regardless. On shorter viewports the sheet fills the available height.",
45
+ default: "'capped'",
46
46
  },
47
47
  ],
48
48
  usage: {
49
- description: 'A mobile touch surface for filters, actions, and detail views that should rise from the bottom of the screen. The sheet is modal: focus is trapped while open and restored to the opener on close, and Escape dismisses, so the swipe gesture always has a keyboard equivalent. Content padding clears the home indicator via env(safe-area-inset-bottom).',
49
+ description: 'A mobile touch surface for filters, actions, and detail views that should rise from the bottom of the screen. Drag the grab handle to resize: a slow drag settles to the nearest snap point (a short peek, ~half, and ~full detent, filtered to those shorter than the sheet), a fast flick down dismisses, a fast flick up expands. Pulling down on the content when it is scrolled to the top also drags the sheet, giving a larger, more forgiving target. The scrim fades out as the sheet collapses onto its shortest "peek" detent, so a glance-height sheet reveals the content behind it (the sheet stays modal; the background is inert until dismissed). The sheet is modal: focus is trapped while open and restored to the opener on close, and Escape dismisses, so the swipe gesture always has a keyboard equivalent. Content padding clears the home indicator via env(safe-area-inset-bottom).',
50
50
  bestPractices: [
51
51
  { guidance: true, description: 'Use for mobile-first surfaces (filters, share sheets, quick actions) where the content should rise from the bottom edge.' },
52
52
  { guidance: true, description: 'Keep the caller as the source of truth: derive isOpen from state and clear it in onOpenChange.' },
53
- { guidance: true, description: "Pick a height that fits the content: 'auto' for short sheets, 'medium'/'tall' for lists and forms." },
53
+ { guidance: true, description: "Pick the starting height that fits the content: 'hug' for short bounded content, 'capped' for lists, 'tall' for streaming/resizing content; the user can then drag between snap points." },
54
54
  { guidance: false, description: 'Use a BottomSheet for desktop inspectors or master-detail; use Drawer (side="end", hasScrim={false}) instead.' },
55
55
  ],
56
56
  },
@@ -77,28 +77,28 @@ export const docs = {
77
77
  </BottomSheet>`,
78
78
  },
79
79
  {
80
- label: 'Auto height (fits content)',
80
+ label: 'Hug height (fits content)',
81
81
  code: `const [isOpen, setIsOpen] = useState(false);
82
82
  <BottomSheet
83
83
  isOpen={isOpen}
84
84
  onOpenChange={setIsOpen}
85
85
  label="Share"
86
- height="auto">
86
+ height="hug">
87
87
  <ShareActions />
88
88
  </BottomSheet>`,
89
89
  },
90
90
  ],
91
91
  };
92
92
 
93
- /** @type {import('../../../core/src/docs-types').TranslationDoc} */
93
+ /** @type {import('@astryxdesign/cli/authoring').ComponentTranslationDoc} */
94
94
  export const docsDense = {
95
- description: 'mobile touch sheet rising from the bottom edge (built on the Drawer <dialog> engine): grab handle, swipe-to-dismiss, named height scale',
95
+ description: 'mobile touch sheet rising from the bottom edge (native modal <dialog>): grab handle, drag-to-resize snap points, swipe-to-dismiss, named height scale',
96
96
  usage: {
97
- description: 'Mobile surface for filters, actions, and detail views. Modal: focus trap + restore, and Escape dismisses so swipe has a keyboard equivalent. Content clears the home indicator via safe-area inset.',
97
+ description: 'Mobile surface for filters, actions, and detail views. Drag the handle to resize between snap points; flick down to dismiss, up to expand. Modal: focus trap + restore, and Escape dismisses so swipe has a keyboard equivalent. Content clears the home indicator via safe-area inset.',
98
98
  bestPractices: [
99
99
  { guidance: true, description: 'Use for mobile-first bottom surfaces (filters, share sheets, quick actions).' },
100
100
  { guidance: true, description: 'Derive isOpen from state; clear it in onOpenChange.' },
101
- { guidance: true, description: "Pick a height that fits: 'auto' for short sheets, 'medium'/'tall' for lists and forms." },
101
+ { guidance: true, description: "Pick a height that fits: 'hug' for short content, 'capped' for lists, 'tall' for streaming content." },
102
102
  { guidance: false, description: 'Use for desktop inspectors or master-detail; use Drawer instead.' },
103
103
  ],
104
104
  },
@@ -51,10 +51,17 @@ afterEach(() => {
51
51
  vi.unstubAllGlobals();
52
52
  });
53
53
 
54
+ function getSheet(): HTMLElement {
55
+ const sheet = document.querySelector<HTMLElement>('.astryx-bottom-sheet');
56
+ if (!sheet) {
57
+ throw new Error('sheet panel not found');
58
+ }
59
+ return sheet;
60
+ }
61
+
62
+ // The grab handle is the panel's first child (decorative, aria-hidden).
54
63
  function getHandle(): HTMLElement {
55
- const handle = document.querySelector<HTMLElement>(
56
- '[data-astryx-sheet-handle]',
57
- );
64
+ const handle = getSheet().querySelector<HTMLElement>('[aria-hidden="true"]');
58
65
  if (!handle) {
59
66
  throw new Error('grab handle not found');
60
67
  }
@@ -127,6 +134,19 @@ describe('BottomSheet', () => {
127
134
  expect(onOpenChange).toHaveBeenCalledWith(false);
128
135
  });
129
136
 
137
+ it('does not dismiss when the sheet surface itself is clicked', () => {
138
+ const onOpenChange = vi.fn();
139
+ render(
140
+ <BottomSheet isOpen onOpenChange={onOpenChange} label="Filters">
141
+ Content
142
+ </BottomSheet>,
143
+ );
144
+ // Only a click that lands on the dialog (the transparent area) dismisses;
145
+ // clicks bubbling up from the sheet must not.
146
+ fireEvent.click(screen.getByText('Content'));
147
+ expect(onOpenChange).not.toHaveBeenCalled();
148
+ });
149
+
130
150
  describe('grab handle', () => {
131
151
  it('renders a decorative handle hidden from assistive tech', () => {
132
152
  render(
@@ -156,7 +176,23 @@ describe('BottomSheet', () => {
156
176
 
157
177
  describe('height', () => {
158
178
  it('renders for each named height without error', () => {
159
- for (const height of ['short', 'medium', 'tall', 'auto'] as const) {
179
+ for (const height of ['hug', 'capped', 'tall'] as const) {
180
+ const {unmount} = render(
181
+ <BottomSheet
182
+ isOpen
183
+ onOpenChange={() => {}}
184
+ label="Filters"
185
+ height={height}>
186
+ Content
187
+ </BottomSheet>,
188
+ );
189
+ expect(screen.getByRole('dialog')).toBeInTheDocument();
190
+ unmount();
191
+ }
192
+ });
193
+
194
+ it('accepts a freeform height (number or CSS length)', () => {
195
+ for (const height of [480, '70dvh'] as const) {
160
196
  const {unmount} = render(
161
197
  <BottomSheet
162
198
  isOpen
@@ -207,6 +243,47 @@ describe('BottomSheet', () => {
207
243
  });
208
244
  });
209
245
 
246
+ describe('initial focus', () => {
247
+ it('focuses the sheet panel on open, not the first control', () => {
248
+ render(
249
+ <BottomSheet isOpen onOpenChange={() => {}} label="Filters">
250
+ <button type="button">First action</button>
251
+ </BottomSheet>,
252
+ );
253
+ const panel = getSheet();
254
+ expect(document.activeElement).toBe(panel);
255
+ expect(document.activeElement).not.toBe(
256
+ screen.getByRole('button', {name: 'First action'}),
257
+ );
258
+ });
259
+
260
+ it('honors a descendant with data-autofocus', () => {
261
+ render(
262
+ <BottomSheet isOpen onOpenChange={() => {}} label="Filters">
263
+ <input data-autofocus aria-label="Search" />
264
+ </BottomSheet>,
265
+ );
266
+ expect(document.activeElement).toBe(
267
+ screen.getByRole('textbox', {name: 'Search'}),
268
+ );
269
+ });
270
+ });
271
+
272
+ describe('accessible name', () => {
273
+ it('warns in development when label is empty', () => {
274
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
275
+ render(
276
+ <BottomSheet isOpen onOpenChange={() => {}} label="">
277
+ Content
278
+ </BottomSheet>,
279
+ );
280
+ expect(
281
+ warn.mock.calls.some(args => String(args[0]).includes('BottomSheet')),
282
+ ).toBe(true);
283
+ warn.mockRestore();
284
+ });
285
+ });
286
+
210
287
  describe('reduced motion', () => {
211
288
  it('opens without throwing when prefers-reduced-motion is set', () => {
212
289
  vi.stubGlobal(