@astryxdesign/core 0.4.6 → 0.4.7-canary.4c3982f

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 (41) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/dist/BottomSheet/BottomSheet.d.ts +1 -0
  3. package/dist/BottomSheet/BottomSheet.d.ts.map +1 -1
  4. package/dist/BottomSheet/BottomSheet.js +37 -11
  5. package/dist/BottomSheet/BottomSheetEdgeTint.d.ts +6 -0
  6. package/dist/BottomSheet/BottomSheetEdgeTint.d.ts.map +1 -0
  7. package/dist/BottomSheet/BottomSheetEdgeTint.js +62 -0
  8. package/dist/BottomSheet/BottomSheetPanel.d.ts.map +1 -1
  9. package/dist/BottomSheet/BottomSheetPanel.js +1 -0
  10. package/dist/BottomSheet/BottomSheetSwitcher.d.ts +1 -0
  11. package/dist/BottomSheet/BottomSheetSwitcher.d.ts.map +1 -1
  12. package/dist/BottomSheet/BottomSheetSwitcher.js +10 -4
  13. package/dist/BottomSheet/useSheetGestures.d.ts.map +1 -1
  14. package/dist/BottomSheet/useSheetGestures.js +23 -5
  15. package/dist/DateInput/TouchDateField.d.ts.map +1 -1
  16. package/dist/DateInput/TouchDateField.js +34 -1
  17. package/dist/TabList/Tab.d.ts.map +1 -1
  18. package/dist/TabList/Tab.js +5 -1
  19. package/dist/astryx.css +4 -0
  20. package/dist/hooks/useListFocus.d.ts +5 -2
  21. package/dist/hooks/useListFocus.d.ts.map +1 -1
  22. package/dist/hooks/useListFocus.js +12 -6
  23. package/package.json +3 -3
  24. package/src/Avatar/Avatar.doc.mjs +2 -1
  25. package/src/BottomSheet/BottomSheet.tsx +19 -0
  26. package/src/BottomSheet/BottomSheetEdgeTint.test.tsx +225 -0
  27. package/src/BottomSheet/BottomSheetEdgeTint.tsx +82 -0
  28. package/src/BottomSheet/BottomSheetPanel.test.tsx +66 -0
  29. package/src/BottomSheet/BottomSheetPanel.tsx +19 -0
  30. package/src/BottomSheet/BottomSheetSwitcher.tsx +13 -0
  31. package/src/BottomSheet/useSheetGestures.test.ts +27 -0
  32. package/src/BottomSheet/useSheetGestures.ts +25 -5
  33. package/src/DateInput/DateInputTouch.test.tsx +36 -0
  34. package/src/DateInput/TouchDateField.tsx +35 -1
  35. package/src/Popover/Popover.test.tsx +27 -1
  36. package/src/TabList/Tab.tsx +5 -1
  37. package/src/TabList/TabList.test.tsx +21 -4
  38. package/src/hooks/useListFocus.doc.mjs +2 -2
  39. package/src/hooks/useListFocus.test.tsx +65 -3
  40. package/src/hooks/useListFocus.ts +15 -7
  41. package/src/theme/MediaTheme.doc.mjs +5 -5
@@ -1 +1 @@
1
- {"version":3,"file":"useListFocus.d.ts","sourceRoot":"","sources":["../../src/hooks/useListFocus.ts"],"names":[],"mappings":"AAsBA;;;;;GAKG;AACH,MAAM,MAAM,oBAAoB,GAAG,YAAY,GAAG,UAAU,GAAG,MAAM,CAAC;AAEtE;;GAEG;AACH,MAAM,WAAW,mBAAmB;IAClC;;;OAGG;IACH,YAAY,CAAC,EAAE,MAAM,CAAC;IAEtB;;;;;;;;;;;;;;;;OAgBG;IACH,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAE1B;;;OAGG;IACH,IAAI,CAAC,EAAE,OAAO,CAAC;IAEf;;OAEG;IACH,QAAQ,CAAC,EAAE,MAAM,IAAI,CAAC;IAEtB;;;;OAIG;IACH,WAAW,CAAC,EAAE,oBAAoB,CAAC;IAEnC;;;OAGG;IACH,UAAU,CAAC,EAAE,OAAO,CAAC;IAErB;;;;;;;;;;;OAWG;IACH,KAAK,CAAC,EAAE,OAAO,CAAC;IAEhB;;;;;;;;;;;;OAYG;IACH,iBAAiB,CAAC,EAAE,OAAO,CAAC;IAE5B;;;;;;;;OAQG;IACH,aAAa,CAAC,EAAE,OAAO,CAAC;CACzB;AAED;;GAEG;AACH,MAAM,WAAW,kBAAkB,CAAC,CAAC,SAAS,WAAW,GAAG,WAAW;IACrE;;OAEG;IACH,OAAO,EAAE,KAAK,CAAC,SAAS,CAAC,CAAC,GAAG,IAAI,CAAC,CAAC;IAEnC;;OAEG;IACH,aAAa,EAAE,CAAC,CAAC,EAAE,KAAK,CAAC,aAAa,KAAK,IAAI,CAAC;IAEhD;;;;OAIG;IACH,WAAW,EAAE,CAAC,CAAC,EAAE,KAAK,CAAC,UAAU,KAAK,IAAI,CAAC;IAE3C;;OAEG;IACH,SAAS,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IAEnC;;OAEG;IACH,UAAU,EAAE,MAAM,OAAO,CAAC;IAE1B;;OAEG;IACH,SAAS,EAAE,MAAM,OAAO,CAAC;IAEzB;;;;;;OAMG;IACH,SAAS,EAAE,CAAC,CAAC,EAAE,KAAK,CAAC,aAAa,KAAK,OAAO,CAAC;IAE/C;;;;;OAKG;IACH,QAAQ,EAAE,MAAM,WAAW,EAAE,CAAC;CAC/B;AA+FD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA0CG;AACH,wBAAgB,YAAY,CAAC,CAAC,SAAS,WAAW,GAAG,WAAW,EAC9D,OAAO,GAAE,mBAAwB,GAChC,kBAAkB,CAAC,CAAC,CAAC,CAwVvB"}
1
+ {"version":3,"file":"useListFocus.d.ts","sourceRoot":"","sources":["../../src/hooks/useListFocus.ts"],"names":[],"mappings":"AAwBA;;;;;GAKG;AACH,MAAM,MAAM,oBAAoB,GAAG,YAAY,GAAG,UAAU,GAAG,MAAM,CAAC;AAEtE;;GAEG;AACH,MAAM,WAAW,mBAAmB;IAClC;;;OAGG;IACH,YAAY,CAAC,EAAE,MAAM,CAAC;IAEtB;;;;;;;;;;;;;;;;OAgBG;IACH,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAE1B;;;OAGG;IACH,IAAI,CAAC,EAAE,OAAO,CAAC;IAEf;;;;OAIG;IACH,QAAQ,CAAC,EAAE,MAAM,IAAI,CAAC;IAEtB;;;;OAIG;IACH,WAAW,CAAC,EAAE,oBAAoB,CAAC;IAEnC;;;OAGG;IACH,UAAU,CAAC,EAAE,OAAO,CAAC;IAErB;;;;;;;;;;;OAWG;IACH,KAAK,CAAC,EAAE,OAAO,CAAC;IAEhB;;;;;;;;;;;;OAYG;IACH,iBAAiB,CAAC,EAAE,OAAO,CAAC;IAE5B;;;;;;;;OAQG;IACH,aAAa,CAAC,EAAE,OAAO,CAAC;CACzB;AAED;;GAEG;AACH,MAAM,WAAW,kBAAkB,CAAC,CAAC,SAAS,WAAW,GAAG,WAAW;IACrE;;OAEG;IACH,OAAO,EAAE,KAAK,CAAC,SAAS,CAAC,CAAC,GAAG,IAAI,CAAC,CAAC;IAEnC;;OAEG;IACH,aAAa,EAAE,CAAC,CAAC,EAAE,KAAK,CAAC,aAAa,KAAK,IAAI,CAAC;IAEhD;;;;OAIG;IACH,WAAW,EAAE,CAAC,CAAC,EAAE,KAAK,CAAC,UAAU,KAAK,IAAI,CAAC;IAE3C;;OAEG;IACH,SAAS,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IAEnC;;OAEG;IACH,UAAU,EAAE,MAAM,OAAO,CAAC;IAE1B;;OAEG;IACH,SAAS,EAAE,MAAM,OAAO,CAAC;IAEzB;;;;;;OAMG;IACH,SAAS,EAAE,CAAC,CAAC,EAAE,KAAK,CAAC,aAAa,KAAK,OAAO,CAAC;IAE/C;;;;;OAKG;IACH,QAAQ,EAAE,MAAM,WAAW,EAAE,CAAC;CAC/B;AA+FD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2CG;AACH,wBAAgB,YAAY,CAAC,CAAC,SAAS,WAAW,GAAG,WAAW,EAC9D,OAAO,GAAE,mBAAwB,GAChC,kBAAkB,CAAC,CAAC,CAAC,CA2VvB"}
@@ -14,6 +14,8 @@
14
14
  *
15
15
  * SYNC: When modified, update:
16
16
  * - /packages/core/src/hooks/index.ts
17
+ * - /packages/core/src/hooks/useListFocus.doc.mjs
18
+ * - /packages/core/src/hooks/useListFocus.test.tsx
17
19
  */
18
20
  import { useCallback, useRef } from 'react';
19
21
  import { isRtlElement } from "./isRtlElement.js";
@@ -127,7 +129,8 @@ function shouldDeferToCaret(target, key) {
127
129
  * - ArrowUp/ArrowLeft: Move to previous item (wraps to last)
128
130
  * - Home: Move to first item
129
131
  * - End: Move to last item
130
- * - Escape: Custom callback (e.g., close menu)
132
+ * - Escape: runs `onEscape` and consumes the key. With no `onEscape` the key
133
+ * is left alone, so a surrounding layer can still dismiss on it.
131
134
  *
132
135
  * By default the hook only *moves* focus and leaves `tabindex` management to
133
136
  * the caller. Opt into {@link UseListFocusOptions.hasRovingTabIndex} for a hook
@@ -380,12 +383,15 @@ export function useListFocus(options = {}) {
380
383
  return;
381
384
  }
382
385
 
383
- // Escape is handled regardless of orientation. Preserve the historical
384
- // behavior of always consuming Escape here (preventDefault) so consumers
385
- // that relied on it are unaffected.
386
+ // Escape is handled regardless of orientation, but only *consumed* when
387
+ // a handler asked for it: a list with no dismissal to perform must leave
388
+ // the key to whatever host layer does have one, and those defer to
389
+ // `defaultPrevented` (see `useFocusTrap`) or to the native popover.
386
390
  if (e.key === 'Escape') {
387
- e.preventDefault();
388
- onEscape?.();
391
+ if (onEscape) {
392
+ e.preventDefault();
393
+ onEscape();
394
+ }
389
395
  return;
390
396
  }
391
397
  const horizontal = orientation === 'horizontal' || orientation === 'both';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astryxdesign/core",
3
- "version": "0.4.6",
3
+ "version": "0.4.7-canary.4c3982f",
4
4
  "displayName": "Astryx Core",
5
5
  "description": "The component library. Accessible, themeable React components with built-in spacing, dark mode, and StyleX styling.",
6
6
  "author": "Meta Open Source",
@@ -656,6 +656,7 @@
656
656
  "react-dom": ">=19.0.0"
657
657
  },
658
658
  "devDependencies": {
659
+ "@astryxdesign/cli": "0.4.7-canary.4c3982f",
659
660
  "@babel/cli": "^8.0.4",
660
661
  "@babel/core": "^7.29.7",
661
662
  "@babel/preset-react": "^8.0.1",
@@ -664,8 +665,7 @@
664
665
  "@testing-library/dom": "^10.0.0",
665
666
  "@testing-library/jest-dom": "^6.6.0",
666
667
  "@testing-library/react": "^16.3.2",
667
- "rimraf": "^6.0.1",
668
- "@astryxdesign/cli": "0.4.6"
668
+ "rimraf": "^6.0.1"
669
669
  },
670
670
  "dependencies": {
671
671
  "intl-messageformat": "^11.2.9"
@@ -148,7 +148,8 @@ export const docsDense = {
148
148
  {guidance: true, description: 'Match size to context: xsm/sm inline, md/lg in lists, xl for profiles.'},
149
149
  {guidance: true, description: 'Add a status dot in chat or team views where availability matters.'},
150
150
  {guidance: true, description: 'When wrapping Avatar in your own Tooltip or HoverCard, set tooltip={false} so the built-in name tooltip does not overlap yours.'},
151
- {guidance: true, description: 'Interactive avatars (href/onClick) need name or alt; without one they warn in development. A status label is not a name.'},
151
+ {guidance: true, description: 'Interactive avatars (href/onClick) need name or alt; without one they warn in development.'},
152
+ {guidance: false, description: 'Rely on a status label to name an interactive avatar. "Online" says nothing about where the link goes.'},
152
153
  {guidance: false, description: 'Use for logos or product images. Use an image or icon instead.'},
153
154
  {guidance: false, description: 'Force a square or custom shape. Avatars are always circular.'},
154
155
  ],
@@ -16,6 +16,7 @@
16
16
  *
17
17
  * SYNC: When modified, update these files to stay in sync:
18
18
  * - /packages/core/src/BottomSheet/BottomSheetPanel.tsx
19
+ * - /packages/core/src/BottomSheet/BottomSheetEdgeTint.tsx
19
20
  * - /packages/core/src/BottomSheet/BottomSheet.doc.mjs
20
21
  * - /packages/core/src/BottomSheet/BottomSheet.test.tsx
21
22
  * - /packages/core/src/BottomSheet/BottomSheetSwitcher.tsx
@@ -43,6 +44,7 @@ import {
43
44
  type BottomSheetPanelMotion,
44
45
  type BottomSheetPanelState,
45
46
  } from './BottomSheetPanel';
47
+ import {BottomSheetEdgeTint} from './BottomSheetEdgeTint';
46
48
  import {
47
49
  BottomSheetSwitcherContext,
48
50
  type BottomSheetSwitcherContextValue,
@@ -93,6 +95,20 @@ const styles = stylex.create({
93
95
  },
94
96
  },
95
97
  },
98
+ /**
99
+ * The dim leaves with the sheet, on a curve that matches.
100
+ *
101
+ * A fade covers no distance, so the decelerate token front-loads its
102
+ * progress and simply ends it early: `--ease-standard` puts the scrim at 90%
103
+ * faded in 163ms of a 410ms close, leaving an undimmed page under a sheet
104
+ * that is still sliding across it. `linear` spends the duration it is given.
105
+ * Same reasoning the touch date picker's surface swap already carries.
106
+ */
107
+ scrimClosing: {
108
+ '::backdrop': {
109
+ transitionTimingFunction: 'linear',
110
+ },
111
+ },
96
112
  positioner: {
97
113
  position: 'absolute',
98
114
  insetInline: 0,
@@ -327,6 +343,7 @@ function StandaloneBottomSheet({
327
343
  styles.dialog,
328
344
  shouldPresent && styles.dialogOpen,
329
345
  hasScrim && styles.scrim,
346
+ hasScrim && !isOpen && isPresented && styles.scrimClosing,
330
347
  !hasScrim && styles.dialogNonModal,
331
348
  )}
332
349
  ref={dialogRef}
@@ -355,6 +372,8 @@ function StandaloneBottomSheet({
355
372
  {children}
356
373
  </BottomSheetPanel>
357
374
  </div>
375
+ {/* A modal sheet's ::backdrop already answers Safari's edge sampler. */}
376
+ {hasScrim ? null : <BottomSheetEdgeTint />}
358
377
  </dialog>
359
378
  );
360
379
  }
@@ -0,0 +1,225 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file BottomSheetEdgeTint.test.tsx
5
+ * @input Uses vitest, @testing-library/react, BottomSheet, BottomSheetSwitcher
6
+ * @output Tests which sheets carry the iOS Safari bottom edge tint, and pins
7
+ * the declarations WebKit's edge sampler reads
8
+ * @position Core testing; validates BottomSheetEdgeTint.tsx and its two hosts
9
+ *
10
+ * SYNC: When modified, update these files to stay in sync:
11
+ * - /packages/core/src/BottomSheet/BottomSheetEdgeTint.tsx
12
+ */
13
+
14
+ import {describe, it, expect, vi, beforeEach, afterEach} from 'vitest';
15
+ import {render} from '@testing-library/react';
16
+ import {BottomSheet} from './BottomSheet';
17
+ import {BottomSheetSwitcher} from './BottomSheetSwitcher';
18
+
19
+ // jsdom doesn't implement <dialog> open/close or pointer capture; stub them.
20
+ beforeEach(() => {
21
+ HTMLDialogElement.prototype.showModal = vi.fn(function (
22
+ this: HTMLDialogElement,
23
+ ) {
24
+ this.setAttribute('open', '');
25
+ });
26
+ HTMLDialogElement.prototype.show = vi.fn(function (this: HTMLDialogElement) {
27
+ this.setAttribute('open', '');
28
+ });
29
+ HTMLDialogElement.prototype.close = vi.fn(function (this: HTMLDialogElement) {
30
+ this.removeAttribute('open');
31
+ });
32
+ if (!Element.prototype.setPointerCapture) {
33
+ Element.prototype.setPointerCapture = vi.fn();
34
+ Element.prototype.releasePointerCapture = vi.fn();
35
+ }
36
+ vi.stubGlobal(
37
+ 'matchMedia',
38
+ vi.fn().mockReturnValue({
39
+ matches: false,
40
+ media: '',
41
+ onchange: null,
42
+ addEventListener: vi.fn(),
43
+ removeEventListener: vi.fn(),
44
+ addListener: vi.fn(),
45
+ removeListener: vi.fn(),
46
+ dispatchEvent: vi.fn(),
47
+ }),
48
+ );
49
+ vi.stubGlobal(
50
+ 'requestAnimationFrame',
51
+ vi.fn((callback: FrameRequestCallback) => {
52
+ callback(0);
53
+ return 1;
54
+ }),
55
+ );
56
+ vi.stubGlobal('cancelAnimationFrame', vi.fn());
57
+ });
58
+
59
+ afterEach(() => {
60
+ vi.unstubAllGlobals();
61
+ });
62
+
63
+ function tints(): ReadonlyArray<Element> {
64
+ return Array.from(document.querySelectorAll('[data-sheet-edge-tint]'));
65
+ }
66
+
67
+ async function edgeTintSource(): Promise<string> {
68
+ const tint = (await fullSource()).match(/\n {2}tint: \{([\s\S]*?)\n {2}\},/);
69
+ expect(tint).not.toBeNull();
70
+ return tint![1];
71
+ }
72
+
73
+ /** The tint's declarations with its explanatory comments stripped out. */
74
+ async function edgeTintDeclarations(): Promise<string> {
75
+ return (await edgeTintSource())
76
+ .split('\n')
77
+ .filter(line => !line.trim().startsWith('//'))
78
+ .join('\n');
79
+ }
80
+
81
+ async function fullSource(): Promise<string> {
82
+ const fs = await import('fs');
83
+ const path = await import('path');
84
+ return fs.readFileSync(
85
+ path.resolve(__dirname, './BottomSheetEdgeTint.tsx'),
86
+ 'utf-8',
87
+ );
88
+ }
89
+
90
+ describe('BottomSheetEdgeTint', () => {
91
+ it('gives a non-modal sheet an edge tint to colour the iOS toolbar strip', () => {
92
+ render(
93
+ <BottomSheet
94
+ isOpen
95
+ onOpenChange={() => {}}
96
+ hasScrim={false}
97
+ label="Place details">
98
+ Content
99
+ </BottomSheet>,
100
+ );
101
+ expect(tints()).toHaveLength(1);
102
+ });
103
+
104
+ // A modal sheet's ::backdrop is a case WebKit's sampler handles on its own,
105
+ // so a second sampling target there would only fight it.
106
+ it('leaves a modal sheet to its ::backdrop', () => {
107
+ render(
108
+ <BottomSheet isOpen onOpenChange={() => {}} label="Place details">
109
+ Content
110
+ </BottomSheet>,
111
+ );
112
+ expect(tints()).toHaveLength(0);
113
+ });
114
+
115
+ it('renders the tint inside the dialog, so it unmounts with the sheet', () => {
116
+ const {rerender} = render(
117
+ <BottomSheet
118
+ isOpen
119
+ onOpenChange={() => {}}
120
+ hasScrim={false}
121
+ label="Place details">
122
+ Content
123
+ </BottomSheet>,
124
+ );
125
+ const [tint] = tints();
126
+ expect(tint?.closest('dialog')).not.toBeNull();
127
+
128
+ rerender(<div />);
129
+ expect(tints()).toHaveLength(0);
130
+ });
131
+
132
+ // The switcher owns one shared dialog for the whole flow, so the tint
133
+ // belongs to that dialog and must not be minted per child sheet.
134
+ it('gives a non-modal switcher flow exactly one tint', () => {
135
+ render(
136
+ <BottomSheetSwitcher
137
+ activeSheet="comment"
138
+ hasScrim={false}
139
+ onActiveSheetChange={() => {}}>
140
+ <BottomSheet sheetId="comment" label="Add a comment">
141
+ Comment
142
+ </BottomSheet>
143
+ <BottomSheet sheetId="confirmation" label="Confirmation">
144
+ Confirmation
145
+ </BottomSheet>
146
+ </BottomSheetSwitcher>,
147
+ );
148
+ expect(tints()).toHaveLength(1);
149
+ });
150
+
151
+ it('leaves a modal switcher flow to its ::backdrop', () => {
152
+ render(
153
+ <BottomSheetSwitcher activeSheet="comment" onActiveSheetChange={() => {}}>
154
+ <BottomSheet sheetId="comment" label="Add a comment">
155
+ Comment
156
+ </BottomSheet>
157
+ </BottomSheetSwitcher>,
158
+ );
159
+ expect(tints()).toHaveLength(0);
160
+ });
161
+
162
+ it('keeps the tint out of the accessibility tree and out of hit testing', async () => {
163
+ render(
164
+ <BottomSheet
165
+ isOpen
166
+ onOpenChange={() => {}}
167
+ hasScrim={false}
168
+ label="Place details">
169
+ Content
170
+ </BottomSheet>,
171
+ );
172
+ const [tint] = tints();
173
+ expect(tint?.getAttribute('aria-hidden')).toBe('true');
174
+ expect(tint?.textContent).toBe('');
175
+ expect(await edgeTintSource()).toContain("pointerEvents: 'none'");
176
+ });
177
+
178
+ // Every declaration below is load-bearing for a heuristic that lives in
179
+ // WebKit, not in this repo, and none of it is observable in jsdom or in any
180
+ // engine without retractable browser chrome — so it is asserted on the style
181
+ // definition, the way BottomSheetPanel pins its handle gradient.
182
+ describe('the declarations WebKit samples', () => {
183
+ it('is fixed and flush with the bottom edge of the viewport', async () => {
184
+ const tint = await edgeTintSource();
185
+ // Only a fixed or sticky box is a candidate, and only a box flush with
186
+ // the edge is the one Safari hit tests.
187
+ expect(tint).toContain("position: 'fixed'");
188
+ expect(tint).toContain('insetBlockEnd: 0');
189
+ expect(tint).toContain('insetInline: 0');
190
+ });
191
+
192
+ it('clears the 10px floor below which WebKit ignores the declared colour', async () => {
193
+ const height = (await fullSource()).match(
194
+ /const SAMPLE_HEIGHT_PX = (\d+);/,
195
+ );
196
+ expect(height).not.toBeNull();
197
+ expect(Number(height![1])).toBeGreaterThan(10);
198
+ expect(await edgeTintSource()).toContain(
199
+ 'height: `${SAMPLE_HEIGHT_PX}px`',
200
+ );
201
+ });
202
+
203
+ it('declares the sheet surface colour, so the strip matches the sheet', async () => {
204
+ expect(await edgeTintSource()).toContain(
205
+ "backgroundColor: colorVars['--color-background-surface']",
206
+ );
207
+ });
208
+
209
+ // visibility: hidden, display: none and a low opacity all disqualify the
210
+ // element from sampling. A mask does not, which is the only reason the
211
+ // strip can be both readable by Safari and invisible to the user.
212
+ it('hides itself with a mask rather than with visibility or opacity', async () => {
213
+ const tint = await edgeTintDeclarations();
214
+ expect(tint).toContain(
215
+ "maskImage: 'linear-gradient(transparent, transparent)'",
216
+ );
217
+ expect(tint).toContain(
218
+ "WebkitMaskImage: 'linear-gradient(transparent, transparent)'",
219
+ );
220
+ expect(tint).not.toContain('visibility');
221
+ expect(tint).not.toContain('opacity');
222
+ expect(tint).not.toContain("display: 'none'");
223
+ });
224
+ });
225
+ });
@@ -0,0 +1,82 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ 'use client';
4
+
5
+ /**
6
+ * @file BottomSheetEdgeTint.tsx
7
+ * @input Uses StyleX and core color tokens
8
+ * @output Exports BottomSheetEdgeTint, an internal decorative element
9
+ * @position Private helper rendered inside a non-modal BottomSheet dialog
10
+ *
11
+ * iOS 26 Safari dropped `<meta name="theme-color">` and instead derives the
12
+ * colour it paints behind its translucent toolbars by sampling the page: it
13
+ * hit tests a point just inside each viewport edge, walks up to the nearest
14
+ * `fixed`/`sticky` ancestor, and extends that element's declared
15
+ * `background-color` into the browser chrome.
16
+ *
17
+ * A modal sheet is served by that heuristic already — WebKit has a dedicated
18
+ * branch for a dialog's `::backdrop`. A non-modal sheet is not: the nearest
19
+ * fixed ancestor of the panel is the sheet's own full-viewport `<dialog>`,
20
+ * which is transparent and viewport-sized, and WebKit answers a viewport-sized
21
+ * candidate by *keeping the colour it already had* — the host page's. The page
22
+ * then shows through behind the address bar while the sheet covers the screen
23
+ * above it.
24
+ *
25
+ * This element gives the heuristic something unambiguous to sample: fixed,
26
+ * full width, flush with the bottom edge, taller than WebKit's 10px minimum
27
+ * for reading a declared colour, and painted in the sheet's own surface
28
+ * colour. It is masked out so it never renders for the user; Safari reads the
29
+ * computed style, not the painted pixels.
30
+ *
31
+ * Everywhere else it is inert decoration.
32
+ *
33
+ * SYNC: When modified, update these files to stay in sync:
34
+ * - /packages/core/src/BottomSheet/BottomSheet.tsx
35
+ * - /packages/core/src/BottomSheet/BottomSheetSwitcher.tsx
36
+ * - /packages/core/src/BottomSheet/BottomSheetEdgeTint.test.tsx
37
+ */
38
+
39
+ import * as stylex from '@stylexjs/stylex';
40
+ import {colorVars} from '../theme/tokens.stylex';
41
+
42
+ /**
43
+ * WebKit ignores the declared `background-color` of a sampled box thinner than
44
+ * 10px and falls back to sampling painted pixels, which a masked element has
45
+ * none of. 12px clears that floor with room to spare and is still far below
46
+ * the ~40px of chrome it colours.
47
+ */
48
+ const SAMPLE_HEIGHT_PX = 12;
49
+
50
+ const styles = stylex.create({
51
+ tint: {
52
+ position: 'fixed',
53
+ insetInline: 0,
54
+ insetBlockEnd: 0,
55
+ height: `${SAMPLE_HEIGHT_PX}px`,
56
+ backgroundColor: colorVars['--color-background-surface'],
57
+ // Above the panel so the edge hit test lands here, and never in the way of
58
+ // a touch that was meant for the sheet.
59
+ zIndex: 2,
60
+ pointerEvents: 'none',
61
+ // Invisible to the user. WebKit's sampler checks `visibility` and
62
+ // `opacity` — either would disqualify the element — but not `mask`, so
63
+ // this hides the strip while leaving the colour readable. Without it the
64
+ // strip would show as a hairline under the panel as the sheet slides out.
65
+ maskImage: 'linear-gradient(transparent, transparent)',
66
+ WebkitMaskImage: 'linear-gradient(transparent, transparent)',
67
+ },
68
+ });
69
+
70
+ /**
71
+ * Colours the iOS Safari toolbar strip below a non-modal sheet. Renders
72
+ * nothing visible; see the file header for why it exists.
73
+ */
74
+ export function BottomSheetEdgeTint() {
75
+ return (
76
+ <div
77
+ {...stylex.props(styles.tint)}
78
+ data-sheet-edge-tint=""
79
+ aria-hidden="true"
80
+ />
81
+ );
82
+ }
@@ -328,4 +328,70 @@ describe('BottomSheetPanel', () => {
328
328
  "backgroundColor: colorVars['--color-background-surface']",
329
329
  );
330
330
  });
331
+
332
+ /**
333
+ * The exit is not the entrance played backwards.
334
+ *
335
+ * `--ease-standard` is a decelerate curve: on it the sheet was half gone in
336
+ * 59ms of a 410ms transition and 90% gone in 163ms, so the close was over
337
+ * before it could be seen. The closing state therefore carries its own
338
+ * accelerating curve, and only the closing state does.
339
+ */
340
+ it('closes on an accelerating curve of its own, not the entrance timing', () => {
341
+ const declarationsFor = (element: HTMLElement) => {
342
+ const classes = new Set(element.className.split(/\s+/));
343
+ const out: string[] = [];
344
+ for (const styleSheet of Array.from(document.styleSheets)) {
345
+ let rules: CSSRuleList;
346
+ try {
347
+ rules = styleSheet.cssRules;
348
+ } catch {
349
+ continue;
350
+ }
351
+ for (const rule of Array.from(rules)) {
352
+ const selector = (rule as CSSStyleRule).selectorText;
353
+ const owner = selector?.match(/^\.([\w-]+)/)?.[1];
354
+ if (owner != null && classes.has(owner)) {
355
+ out.push(rule.cssText);
356
+ }
357
+ }
358
+ }
359
+ return out.join('\n');
360
+ };
361
+
362
+ const {container: closing} = render(
363
+ <BottomSheetPanel
364
+ state={{kind: 'exiting'}}
365
+ height="hug"
366
+ onDismiss={() => {}}
367
+ onScrimOpacity={() => {}}>
368
+ Panel content
369
+ </BottomSheetPanel>,
370
+ );
371
+ const closingRules = declarationsFor(
372
+ closing.querySelector('.astryx-bottom-sheet') as HTMLElement,
373
+ );
374
+ // An accelerating curve: no vertical rise at the start (y1 = 0), so the
375
+ // travel lands inside the duration instead of ahead of it.
376
+ expect(closingRules).toContain(
377
+ 'transition-timing-function: cubic-bezier(.3,0,.6,.6)',
378
+ );
379
+
380
+ const {container: resting} = render(
381
+ <BottomSheetPanel
382
+ state={{kind: 'open', entering: false}}
383
+ height="hug"
384
+ onDismiss={() => {}}
385
+ onScrimOpacity={() => {}}>
386
+ Panel content
387
+ </BottomSheetPanel>,
388
+ );
389
+ const restingRules = declarationsFor(
390
+ resting.querySelector('.astryx-bottom-sheet') as HTMLElement,
391
+ );
392
+ expect(restingRules).toContain(
393
+ 'transition-timing-function: var(--ease-standard)',
394
+ );
395
+ expect(restingRules).not.toContain('cubic-bezier(.3,0,.6,.6)');
396
+ });
331
397
  });
@@ -149,8 +149,27 @@ const styles = stylex.create({
149
149
  transitionDuration: '0.01s',
150
150
  },
151
151
  },
152
+ /**
153
+ * The exit is its own motion, not the entrance played backwards.
154
+ *
155
+ * `--ease-standard` is `cubic-bezier(0.24, 1, 0.4, 1)`, a decelerate curve:
156
+ * it spends its speed immediately and coasts. That is right for an entrance,
157
+ * where the sheet arrives fast and settles, and wrong for an exit. Measured
158
+ * on device, it put the sheet half off-screen in 59ms of a 410ms close and
159
+ * 90% off in 163ms -- the travel is over before the eye has followed it, and
160
+ * the rest of the duration moves pixels already below the fold.
161
+ *
162
+ * An exit accelerates instead: away from rest, gathering speed, quickest as
163
+ * it leaves the screen. The curve is deliberately gentle rather than a hard
164
+ * `ease-in` -- it is moving within ~50ms, so the close reads as one
165
+ * departure rather than a hesitation and a snap. The duration is the
166
+ * entrance's own band, so only the curve differs between the two
167
+ * directions; a shorter band leaves too little visible travel once a theme
168
+ * scales the motion scale down (neutral's medium is 300ms against 410ms).
169
+ */
152
170
  sheetClosing: {
153
171
  transform: 'translateY(100%)',
172
+ transitionTimingFunction: 'cubic-bezier(0.3, 0, 0.6, 0.6)',
154
173
  },
155
174
  sheetFading: {
156
175
  opacity: 0,
@@ -22,6 +22,7 @@
22
22
  *
23
23
  * SYNC: When modified, update these files to stay in sync:
24
24
  * - /packages/core/src/BottomSheet/BottomSheet.tsx
25
+ * - /packages/core/src/BottomSheet/BottomSheetEdgeTint.tsx
25
26
  * - /packages/core/src/BottomSheet/BottomSheetSwitcher.doc.mjs
26
27
  * - /packages/core/src/BottomSheet/BottomSheetSwitcher.test.tsx
27
28
  * - /packages/core/src/BottomSheet/index.ts
@@ -51,6 +52,7 @@ import {
51
52
  useScrollLock,
52
53
  } from '../hooks';
53
54
  import {composeEventHandlers, mergeProps, mergeRefs} from '../utils';
55
+ import {BottomSheetEdgeTint} from './BottomSheetEdgeTint';
54
56
  import {
55
57
  BottomSheetSwitcherContext,
56
58
  type BottomSheetSwitcherContextValue,
@@ -103,6 +105,14 @@ const styles = stylex.create({
103
105
  },
104
106
  },
105
107
  },
108
+ // The flow's dim leaves with its last panel. Same rule, same reasoning, as
109
+ // a standalone sheet's -- see `scrimClosing` in BottomSheet.tsx. A handoff
110
+ // between two sheets is not a close, and keeps the entrance curve.
111
+ scrimClosing: {
112
+ '::backdrop': {
113
+ transitionTimingFunction: 'linear',
114
+ },
115
+ },
106
116
  });
107
117
 
108
118
  type RetainedSheetPhase = 'covered' | 'aligning' | 'fading' | 'exiting';
@@ -593,6 +603,7 @@ export function BottomSheetSwitcher({
593
603
  styles.dialog,
594
604
  isFlowVisible && styles.dialogOpen,
595
605
  hasScrim && styles.scrim,
606
+ hasScrim && isFlowVisible && activeSheet == null && styles.scrimClosing,
596
607
  !hasScrim && styles.dialogNonModal,
597
608
  xstyle,
598
609
  );
@@ -620,6 +631,8 @@ export function BottomSheetSwitcher({
620
631
  ? {role: 'alertdialog'}
621
632
  : undefined)}>
622
633
  {children}
634
+ {/* A modal flow's ::backdrop already answers Safari's edge sampler. */}
635
+ {hasScrim ? null : <BottomSheetEdgeTint />}
623
636
  </dialog>
624
637
  </BottomSheetSwitcherContext>
625
638
  );
@@ -530,6 +530,33 @@ describe('useSheetGestures', () => {
530
530
  expect(hook.result.current.dragOffset).toBe(40);
531
531
  });
532
532
 
533
+ /**
534
+ * A finger is never still. Tapping a control inside the sheet drifts a
535
+ * pixel or two, and with no slop that drift promoted to a sheet drag —
536
+ * which wrote `transition: none` onto the panel across the release, so the
537
+ * close the tap triggered cut instead of animating. That is the "the sheet
538
+ * closes with no animation" report: only on a tap, and only on a tap
539
+ * inside the sheet (the scrim never arms a drag).
540
+ */
541
+ it('treats a few pixels of finger drift as a tap, not a drag', () => {
542
+ const {hook} = setup({snapHeights: () => [200]});
543
+ const body = makeBody(0); // at the top, where a pull-down would promote
544
+ bodyDown(hook, 0, 0, body);
545
+ bodyMove(hook, 3, 40, body); // the drift of a finger tapping a button
546
+ expect(hook.result.current.isDragging).toBe(false);
547
+ // and nothing suppressed the panel's transition, so a close that lands
548
+ // now still animates
549
+ expect(hook.result.current.contentProps.style.transition).toBeUndefined();
550
+ });
551
+
552
+ it('still promotes once the pull passes the tap slop', () => {
553
+ const {hook} = setup({snapHeights: () => [200]});
554
+ const body = makeBody(0);
555
+ bodyDown(hook, 0, 0, body);
556
+ bodyMove(hook, 9, 40, body); // just past the 8px slop
557
+ expect(hook.result.current.isDragging).toBe(true);
558
+ });
559
+
533
560
  it('does not hijack scrolling when the body is scrolled down', () => {
534
561
  const {hook} = setup({snapHeights: () => [200]});
535
562
  const body = makeBody(120); // scrolled, not at the top