@octane-xplat/sheet 0.9.0 → 0.10.0

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.
package/README.md CHANGED
@@ -2,9 +2,8 @@
2
2
 
3
3
  An install boundary for platform-specific modal bottom sheets: a SwiftUI
4
4
  `.sheet` presentation on iOS, a Material 3 `ModalBottomSheet` on Android,
5
- a fixed-position DOM panel on web, and a real `NSWindow` sheet
5
+ a native modal `<dialog>` on web, and a real `NSWindow` sheet
6
6
  (`beginSheet`) on macOS.
7
-
8
7
  Install the package in the app that renders a control. Import
9
8
  `SwiftUIBottomSheet` from `@octane-xplat/sheet/ios`,
10
9
  `MaterialBottomSheet` from `@octane-xplat/sheet/android`, `BottomSheet`
@@ -12,12 +11,119 @@ from `@octane-xplat/sheet/web`, or `AppKitSheet` from
12
11
  `@octane-xplat/sheet/macos` in the matching platform-suffixed file.
13
12
  There is no shared sheet API at the package root.
14
13
 
14
+ ```tsx
15
+ /** @jsxImportSource @nativescript-community/octane */
16
+ // SwiftUIBottomSheetExample.ios.tsx
17
+ import { SwiftUIBottomSheet } from '@octane-xplat/sheet/ios'
18
+ import { useState } from 'octane'
19
+ import { Button, Text } from '@octane-xplat/ui'
20
+
21
+ export function Details() {
22
+ const [open, setOpen] = useState(false)
23
+ return (
24
+ <>
25
+ <Button onPress={() => setOpen(true)}>Trip details</Button>
26
+ <SwiftUIBottomSheet
27
+ open={open}
28
+ detents={['medium', 'large']}
29
+ showDragIndicator
30
+ onDismissed={() => setOpen(false)}
31
+ content={() => <Text>Two bags packed</Text>}
32
+ />
33
+ </>
34
+ )
35
+ }
36
+ ```
37
+
38
+ ```tsx
39
+ /** @jsxImportSource @nativescript-community/octane */
40
+ // MaterialBottomSheetExample.android.tsx
41
+ import { MaterialBottomSheet } from '@octane-xplat/sheet/android'
42
+ import { useState } from 'octane'
43
+ import { Button, Text } from '@octane-xplat/ui'
44
+
45
+ export function Details() {
46
+ const [open, setOpen] = useState(false)
47
+ return (
48
+ <>
49
+ <Button onPress={() => setOpen(true)}>Trip details</Button>
50
+ <MaterialBottomSheet
51
+ open={open}
52
+ skipPartiallyExpanded
53
+ showDragHandle
54
+ onDismissed={() => setOpen(false)}
55
+ content={() => <Text>Two bags packed</Text>}
56
+ />
57
+ </>
58
+ )
59
+ }
60
+ ```
61
+
62
+ ```tsx
63
+ // BottomSheetExample.web.tsx
64
+ import { BottomSheet } from '@octane-xplat/sheet/web'
65
+ import { useState } from 'octane'
66
+ import { Button, Text } from '@octane-xplat/ui'
67
+
68
+ export function Details() {
69
+ const [open, setOpen] = useState(false)
70
+ return (
71
+ <>
72
+ <Button onPress={() => setOpen(true)}>Trip details</Button>
73
+ <BottomSheet
74
+ open={open}
75
+ onDismissed={() => setOpen(false)}
76
+ content={() => <Text>Two bags packed</Text>}
77
+ />
78
+ </>
79
+ )
80
+ }
81
+ ```
82
+
83
+ ```tsx
84
+ // AppKitSheetExample.macos.tsx
85
+ import { AppKitSheet } from '@octane-xplat/sheet/macos'
86
+ import { useState } from 'octane'
87
+ import { Button, Text } from '@octane-xplat/ui'
88
+
89
+ export function Details() {
90
+ const [open, setOpen] = useState(false)
91
+ return (
92
+ <>
93
+ <Button onPress={() => setOpen(true)}>Trip details</Button>
94
+ <AppKitSheet
95
+ open={open}
96
+ onDismissed={() => setOpen(false)}
97
+ content={() => <Text>Two bags packed</Text>}
98
+ />
99
+ </>
100
+ )
101
+ }
102
+ ```
103
+
104
+ Web apps must also import the sheet stylesheet from their app entry:
105
+
106
+ ```ts
107
+ import '@octane-xplat/sheet/web/styles.css'
108
+ ```
109
+
15
110
  Sheet content is a render fn whose octane output mounts into a detached
16
111
  root — the same embedding mechanism as `@octane-xplat/context-menu`'s
17
112
  trigger. On iOS the registered view resolves through
18
113
  `NativeScriptViewFactory`; on Android through the leaf's
19
114
  `XplatViewRegistry` + `AndroidView` inside the sheet's dialog window.
20
115
 
116
+ ```tsx
117
+ /** @jsxImportSource @nativescript-community/octane */
118
+ // HostedContent.android.tsx
119
+ import { MaterialBottomSheet } from '@octane-xplat/sheet/android'
120
+ import { Text } from '@octane-xplat/ui'
121
+
122
+ export function HostedContent({ open }: { open: boolean }) {
123
+ return <MaterialBottomSheet open={open} content={() => <Text>Trip details</Text>} />
124
+ }
125
+ ```
126
+
21
127
  - iOS: `open` (controlled `isPresented`), `onDismissed`,
22
128
  `onPresentedChange`, `fitToContents` (ported size-reader →
23
129
  `.presentationDetents([.height])`), `detents`
@@ -29,7 +135,11 @@ trigger. On iOS the registered view resolves through
29
135
  `shouldDismissOnClickOutside`, and `containerColor`/`contentColor`/
30
136
  `scrimColor` hex strings. Activity-owned key events are forwarded while
31
137
  the sheet's dialog is open (ported from Expo).
32
- - Web: scrim + bottom panel, scrim-click dismiss.
138
+ - Web: native modal `<dialog>` with a named panel, focus entry/Tab
139
+ containment, trigger restoration (`finalFocusRef`), Escape and scrim
140
+ dismissal controls, and drag-handle dismissal. `label` supplies the dialog
141
+ name; pass `finalFocusRef` when pointer-opening so focus returns to the
142
+ invoking control across browsers.
33
143
 
34
144
  The native implementations are adapted from `@expo/ui` (MIT,
35
145
  `packages/expo-ui` sdk-57): `ios/BottomSheetView.swift` and
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "version": 1,
3
- "buildId": "a708cea8-c79d-46a5-ae17-04112d1be0ac",
3
+ "buildId": "ea2c2633-b2c7-487d-a7b8-9061f5e34343",
4
4
  "mode": "production",
5
5
  "capabilities": {
6
6
  "independentHydration": false
@@ -1,45 +1,124 @@
1
- import { Fragment, createElement, createScopedElement, createScopedValue, positionalChildren } from "octane";
1
+ import { Fragment, createElement, createScopedElement, createScopedValue, hookSlots, positionalChildren, useEffect, useRef } from "octane";
2
+ import { compilerMemoRegion, hookMemoCreate, hookMemoPublishInvariant } from "octane/internal/client";
2
3
  //#region src/BottomSheet.web.tsrx
4
+ var _hs$ = /* @__PURE__ */ hookSlots(5);
5
+ function tabStops(panel) {
6
+ return Array.from(panel.querySelectorAll("a[href],button,input:not([type=\"hidden\"]),textarea,select,[tabindex],[contenteditable=\"true\"]")).filter((element) => {
7
+ if (element.tabIndex < 0 || element.matches(":disabled") || element.closest("[inert],[hidden],[aria-hidden=\"true\"]")) return false;
8
+ for (let parent = element; parent; parent = parent.parentElement) {
9
+ const style = getComputedStyle(parent);
10
+ if (style.display === "none" || style.visibility === "hidden") return false;
11
+ }
12
+ return true;
13
+ });
14
+ }
3
15
  var BottomSheet = function BottomSheet(props, __s, __extra) {
4
- const open = props.open ?? false;
5
- (props.shouldDismissOnClickOutside ?? true) || props.shouldDismissOnBackPress;
6
- if (!open) return positionalChildren([]);
7
- return createScopedElement(Fragment, {}, () => positionalChildren([createScopedValue(() => createElement("div", {
8
- style: {
9
- position: "fixed",
10
- inset: 0,
11
- background: "rgb(0 0 0 / 0.4)",
12
- zIndex: 999
13
- },
14
- onClick: () => {
15
- if (props.shouldDismissOnClickOutside ?? true) props.onDismissed?.();
16
+ const __memoRegion = compilerMemoRegion(__s, _hs$ + 0);
17
+ let __hk = __memoRegion.hooks;
18
+ if (__hk === void 0) __memoRegion.hooks = __hk = hookMemoCreate(3);
19
+ const dialog = useRef(null, _hs$ + 0);
20
+ const panel = useRef(null, _hs$ + 1);
21
+ const drag = useRef(null, _hs$ + 2);
22
+ useEffect(() => {
23
+ const current = dialog.current;
24
+ if (!props.open || !current || current.open) return;
25
+ const trigger = props.finalFocusRef?.current ?? current.ownerDocument.activeElement;
26
+ const previousTabIndex = current.getAttribute("tabindex");
27
+ current.showModal();
28
+ const stops = panel.current ? tabStops(panel.current) : [];
29
+ const autofocus = current.querySelector("[autofocus]");
30
+ const initialFocus = (autofocus && !autofocus.matches(":disabled,[hidden],[inert]") ? autofocus : null) ?? stops[0] ?? current;
31
+ if (initialFocus === current && previousTabIndex === null) current.tabIndex = -1;
32
+ initialFocus.focus({ preventScroll: true });
33
+ return () => {
34
+ if (current.open) current.close();
35
+ if (previousTabIndex === null && initialFocus === current) current.removeAttribute("tabindex");
36
+ requestAnimationFrame(() => {
37
+ if (trigger?.isConnected && !trigger.closest("[inert]")) trigger.focus({ preventScroll: true });
38
+ });
39
+ };
40
+ }, [props.open], _hs$ + 3);
41
+ if (!props.open || typeof document === "undefined") return positionalChildren([]);
42
+ const resetDrag = __hk[0] ?? hookMemoPublishInvariant(__hk, 0, () => {
43
+ drag.current = null;
44
+ panel.current?.style.removeProperty("--xplat-sheet-drag-offset");
45
+ panel.current?.style.removeProperty("transition");
46
+ });
47
+ const onPointerDown = (event) => {
48
+ if (props.sheetGesturesEnabled === false || !event.isPrimary || event.button !== 0) return;
49
+ drag.current = {
50
+ pointerId: event.pointerId,
51
+ startY: event.clientY,
52
+ offsetY: 0
53
+ };
54
+ panel.current?.style.setProperty("transition", "none");
55
+ event.currentTarget.setPointerCapture(event.pointerId);
56
+ };
57
+ const onPointerMove = __hk[1] ?? hookMemoPublishInvariant(__hk, 1, (event) => {
58
+ const active = drag.current;
59
+ if (!active || active.pointerId !== event.pointerId) return;
60
+ active.offsetY = Math.max(0, event.clientY - active.startY);
61
+ panel.current?.style.setProperty("--xplat-sheet-drag-offset", `${active.offsetY}px`);
62
+ });
63
+ const onPointerUp = (event) => {
64
+ const active = drag.current;
65
+ if (!active || active.pointerId !== event.pointerId) return;
66
+ const shouldDismiss = active.offsetY >= Math.min(120, window.innerHeight * .18);
67
+ resetDrag();
68
+ if (shouldDismiss && props.sheetGesturesEnabled !== false) props.onDismissed?.();
69
+ };
70
+ const onCancel = (event) => {
71
+ event.preventDefault();
72
+ if (props.shouldDismissOnBackPress ?? true) props.onDismissed?.();
73
+ };
74
+ const onKeyDown = __hk[2] ?? hookMemoPublishInvariant(__hk, 2, (event) => {
75
+ if (event.key !== "Tab") return;
76
+ const current = dialog.current;
77
+ const active = current?.ownerDocument.activeElement;
78
+ if (!current?.open || active !== current && !panel.current?.contains(active)) return;
79
+ const stops = panel.current ? tabStops(panel.current) : [];
80
+ const index = stops.indexOf(active);
81
+ event.preventDefault();
82
+ if (!stops.length) {
83
+ dialog.current?.focus({ preventScroll: true });
84
+ return;
16
85
  }
17
- })), createScopedValue(() => createScopedElement("div", {
18
- role: "dialog",
86
+ stops[event.shiftKey ? index <= 0 ? stops.length - 1 : index - 1 : index < 0 || index === stops.length - 1 ? 0 : index + 1].focus({ preventScroll: true });
87
+ });
88
+ useEffect(() => {
89
+ const current = dialog.current;
90
+ if (!props.open || !current) return;
91
+ current.ownerDocument.addEventListener("keydown", onKeyDown, true);
92
+ return () => current.ownerDocument.removeEventListener("keydown", onKeyDown, true);
93
+ }, [props.open], _hs$ + 4);
94
+ return createScopedElement(Fragment, {}, () => positionalChildren([createScopedValue(() => createScopedElement("dialog", {
95
+ ref: dialog,
96
+ className: "xplat-web-sheet",
97
+ "aria-label": props.label ?? "Bottom sheet",
19
98
  "aria-modal": "true",
20
99
  style: {
21
- position: "fixed",
22
- left: 0,
23
- right: 0,
24
- bottom: 0,
25
- zIndex: 1e3,
26
- background: "var(--sheet-bg, Canvas)",
27
- color: "var(--sheet-fg, CanvasText)",
28
- borderRadius: "16px 16px 0 0",
29
- boxShadow: "0 -4px 24px rgb(0 0 0 / 0.2)",
30
- maxHeight: props.skipPartiallyExpanded ? "90vh" : "60vh",
31
- overflowY: "auto"
100
+ "--xplat-sheet-scrim": props.scrimColor ?? "rgb(0 0 0 / 0.4)",
101
+ "--xplat-sheet-container": props.containerColor ?? "var(--sheet-bg, Canvas)",
102
+ "--xplat-sheet-content": props.contentColor ?? "var(--sheet-fg, CanvasText)",
103
+ "--xplat-sheet-max-height": props.skipPartiallyExpanded ? "90vh" : "60vh"
104
+ },
105
+ onCancel,
106
+ onClick: (event) => {
107
+ if (event.target === event.currentTarget && (props.shouldDismissOnClickOutside ?? true)) props.onDismissed?.();
32
108
  }
33
- }, () => positionalChildren([(props.showDragHandle ?? true) && createScopedValue(() => createScopedElement("div", { style: {
34
- display: "flex",
35
- justifyContent: "center",
36
- padding: "8px 0"
37
- } }, () => createScopedValue(() => createElement("div", { style: {
38
- width: 36,
39
- height: 4,
40
- borderRadius: 2,
41
- background: "color-mix(in srgb, CanvasText 30%, transparent)"
42
- } })))), props.content?.()])))]));
109
+ }, () => createScopedValue(() => createScopedElement("div", {
110
+ ref: panel,
111
+ id: props.id,
112
+ className: "xplat-web-sheet-panel " + (props.className ?? ""),
113
+ style: props.style
114
+ }, () => positionalChildren([(props.showDragHandle ?? true) && createScopedValue(() => createElement("div", {
115
+ className: "xplat-web-sheet-handle",
116
+ "aria-hidden": "true",
117
+ onPointerDown,
118
+ onPointerMove,
119
+ onPointerUp,
120
+ onPointerCancel: resetDrag
121
+ }, createElement("div", { className: "xplat-web-sheet-handle-mark" }))), props.content?.()])))))]));
43
122
  };
44
123
  //#endregion
45
124
  export { BottomSheet };
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "version": 1,
3
- "buildId": "e60f4b8c-acc0-404d-89f0-5ee4a3e3365b",
3
+ "buildId": "6220e54f-fce6-443b-8aa6-2f815351b5b7",
4
4
  "mode": "production",
5
5
  "capabilities": {
6
6
  "independentHydration": false
@@ -0,0 +1,58 @@
1
+ /* xplat-web-only:start */
2
+ .xplat-web-sheet {
3
+ position: fixed;
4
+ inset: 0;
5
+ width: 100%;
6
+ height: 100%;
7
+ max-width: none;
8
+ max-height: none;
9
+ margin: 0;
10
+ padding: 0;
11
+ border: 0;
12
+ background: transparent;
13
+ color: inherit;
14
+ overflow: visible;
15
+ display: flex;
16
+ align-items: flex-end;
17
+ justify-content: center;
18
+ }
19
+ .xplat-web-sheet::backdrop {
20
+ background: var(--xplat-sheet-scrim, rgb(0 0 0 / 0.4));
21
+ }
22
+
23
+ .xplat-web-sheet-panel {
24
+ box-sizing: border-box;
25
+ width: 100%;
26
+ max-height: var(--xplat-sheet-max-height, 60vh);
27
+ overflow-y: auto;
28
+ overscroll-behavior: contain;
29
+ background: var(--xplat-sheet-container, var(--sheet-bg, Canvas));
30
+ color: var(--xplat-sheet-content, var(--sheet-fg, CanvasText));
31
+ border-radius: 16px 16px 0 0;
32
+ box-shadow: 0 -4px 24px rgb(0 0 0 / 0.2);
33
+ transform: translateY(var(--xplat-sheet-drag-offset, 0px));
34
+ transition: transform 180ms ease-out;
35
+ }
36
+
37
+ .xplat-web-sheet-handle {
38
+ display: flex;
39
+ justify-content: center;
40
+ padding: 8px 0;
41
+ cursor: grab;
42
+ touch-action: none;
43
+ }
44
+
45
+ .xplat-web-sheet-handle-mark {
46
+ width: 36px;
47
+ height: 4px;
48
+ border-radius: 2px;
49
+ background: color-mix(in srgb, CanvasText 30%, transparent);
50
+ }
51
+
52
+ @media (prefers-reduced-motion: reduce) {
53
+ .xplat-web-sheet-panel {
54
+ transition: none;
55
+ }
56
+ }
57
+ /* xplat-web-only:end */
58
+ /*$vite$:1*/
@@ -1,2 +1,3 @@
1
+ /* empty css */
1
2
  import { BottomSheet } from "../BottomSheet.web.tsrx.js";
2
3
  export { BottomSheet };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@octane-xplat/sheet",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "Platform-specific modal bottom sheet controls for Octane xplat",
5
5
  "repository": {
6
6
  "type": "git",
@@ -26,7 +26,8 @@
26
26
  "./web": {
27
27
  "types": "./types/web/index.d.ts",
28
28
  "web": "./dist/web/web/index.js"
29
- }
29
+ },
30
+ "./web/styles.css": "./dist/web/sheet.css"
30
31
  },
31
32
  "publishConfig": {
32
33
  "access": "public"
@@ -40,7 +41,7 @@
40
41
  "@nativescript/core": "9.1.2",
41
42
  "@octanejs/vite-plugin": "0.1.60",
42
43
  "octane": "0.5.0",
43
- "tsrx-typegen": "0.2.1",
44
+ "tsrx-typegen": "0.2.2",
44
45
  "typescript": "5.9.3",
45
46
  "vite": "8.3.0"
46
47
  },
@@ -1,4 +1,4 @@
1
- /** @jsxImportSource @xplat/macos/renderer */
1
+ /** @jsxImportSource @octane-xplat/macos-renderer */
2
2
  import { useEffect, useRef } from 'octane'
3
3
  import type { AppKitSheetProps } from './macos/types'
4
4
 
@@ -1,48 +1,210 @@
1
1
  /** @jsxImportSource octane */
2
- import type { BottomSheetProps } from './web/types'
2
+ import { useEffect, useRef } from 'octane'
3
+ import type { BottomSheetProps, WebFocusTarget } from './web/types'
3
4
 
4
- /** Web bottom sheet: a fixed-position panel over a scrim. `open` controls
5
- * visibility; `onDismissed` fires on scrim click / Escape. */
5
+ function tabStops(panel: HTMLElement): HTMLElement[] {
6
+ return Array.from(
7
+ panel.querySelectorAll<HTMLElement>(
8
+ 'a[href],button,input:not([type="hidden"]),textarea,select,[tabindex],[contenteditable="true"]',
9
+ ),
10
+ ).filter((element) => {
11
+ if (
12
+ element.tabIndex < 0 ||
13
+ element.matches(':disabled') ||
14
+ element.closest('[inert],[hidden],[aria-hidden="true"]')
15
+ ) {
16
+ return false
17
+ }
18
+
19
+ for (let parent: HTMLElement | null = element; parent; parent = parent.parentElement) {
20
+ const style = getComputedStyle(parent)
21
+ if (style.display === 'none' || style.visibility === 'hidden') {
22
+ return false
23
+ }
24
+ }
25
+
26
+ return true
27
+ })
28
+ }
29
+
30
+ /** Web bottom sheet: a modal browser dialog. `open` controls visibility;
31
+ * `onDismissed` fires for enabled Escape, scrim, and drag dismissals. */
6
32
  export function BottomSheet(props: BottomSheetProps) @{
7
- const open = props.open ?? false
8
- const dismissable = (props.shouldDismissOnClickOutside ?? true) || (props.shouldDismissOnBackPress ?? true)
33
+ const dialog = useRef<HTMLDialogElement | null>(null)
34
+ const panel = useRef<HTMLDivElement | null>(null)
35
+ const drag = useRef<{ pointerId: number; startY: number; offsetY: number } | null>(null)
36
+
37
+ useEffect(() => {
38
+ const current = dialog.current
39
+ if (!props.open || !current || current.open) {
40
+ return
41
+ }
42
+
43
+ const trigger =
44
+ props.finalFocusRef?.current ?? (current.ownerDocument.activeElement as WebFocusTarget | null)
45
+
46
+ const previousTabIndex = current.getAttribute('tabindex')
47
+ current.showModal()
48
+ const stops = panel.current ? tabStops(panel.current) : []
49
+ const autofocus = current.querySelector<HTMLElement>('[autofocus]')
50
+ const initialFocus =
51
+ (autofocus && !autofocus.matches(':disabled,[hidden],[inert]') ? autofocus : null) ??
52
+ stops[0] ??
53
+ current
54
+
55
+ if (initialFocus === current && previousTabIndex === null) {
56
+ current.tabIndex = -1
57
+ }
58
+
59
+ initialFocus.focus({ preventScroll: true })
60
+
61
+ return () => {
62
+ if (current.open) {
63
+ current.close()
64
+ }
65
+
66
+ if (previousTabIndex === null && initialFocus === current) {
67
+ current.removeAttribute('tabindex')
68
+ }
9
69
 
10
- if (!open) {
70
+ requestAnimationFrame(() => {
71
+ if (trigger?.isConnected && !trigger.closest('[inert]')) {
72
+ trigger.focus({ preventScroll: true })
73
+ }
74
+ })
75
+ }
76
+ }, [props.open])
77
+
78
+ if (!props.open || typeof document === 'undefined') {
11
79
  return <></>
12
80
  }
13
81
 
82
+ const resetDrag = () => {
83
+ drag.current = null
84
+ panel.current?.style.removeProperty('--xplat-sheet-drag-offset')
85
+ panel.current?.style.removeProperty('transition')
86
+ }
87
+
88
+ const onPointerDown = (event: PointerEvent) => {
89
+ if (props.sheetGesturesEnabled === false || !event.isPrimary || event.button !== 0) {
90
+ return
91
+ }
92
+
93
+ drag.current = { pointerId: event.pointerId, startY: event.clientY, offsetY: 0 }
94
+ panel.current?.style.setProperty('transition', 'none')
95
+
96
+ ;(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId)
97
+ }
98
+
99
+ const onPointerMove = (event: PointerEvent) => {
100
+ const active = drag.current
101
+ if (!active || active.pointerId !== event.pointerId) {
102
+ return
103
+ }
104
+
105
+ active.offsetY = Math.max(0, event.clientY - active.startY)
106
+ panel.current?.style.setProperty('--xplat-sheet-drag-offset', `${active.offsetY}px`)
107
+ }
108
+
109
+ const onPointerUp = (event: PointerEvent) => {
110
+ const active = drag.current
111
+ if (!active || active.pointerId !== event.pointerId) {
112
+ return
113
+ }
114
+
115
+ const shouldDismiss = active.offsetY >= Math.min(120, window.innerHeight * 0.18)
116
+ resetDrag()
117
+
118
+ if (shouldDismiss && props.sheetGesturesEnabled !== false) {
119
+ props.onDismissed?.()
120
+ }
121
+ }
122
+
123
+ const onCancel = (event: Event) => {
124
+ event.preventDefault()
125
+ if (props.shouldDismissOnBackPress ?? true) {
126
+ props.onDismissed?.()
127
+ }
128
+ }
129
+
130
+ const onKeyDown = (event: KeyboardEvent) => {
131
+ if (event.key !== 'Tab') {
132
+ return
133
+ }
134
+
135
+ const current = dialog.current
136
+ const active = current?.ownerDocument.activeElement
137
+ if (!current?.open || (active !== current && !panel.current?.contains(active))) {
138
+ return
139
+ }
140
+
141
+ const stops = panel.current ? tabStops(panel.current) : []
142
+ const index = stops.indexOf(active as HTMLElement)
143
+ event.preventDefault()
144
+ if (!stops.length) {
145
+ dialog.current?.focus({ preventScroll: true })
146
+ return
147
+ }
148
+
149
+ const nextIndex = event.shiftKey
150
+ ? index <= 0 ? stops.length - 1 : index - 1
151
+ : index < 0 || index === stops.length - 1 ? 0 : index + 1
152
+
153
+ stops[nextIndex].focus({ preventScroll: true })
154
+ }
155
+
156
+ useEffect(() => {
157
+ const current = dialog.current
158
+ if (!props.open || !current) {
159
+ return
160
+ }
161
+
162
+ current.ownerDocument.addEventListener('keydown', onKeyDown, true)
163
+ return () => current.ownerDocument.removeEventListener('keydown', onKeyDown, true)
164
+ }, [props.open])
165
+
14
166
  <>
15
- <div
16
- style={{ position: 'fixed', inset: 0, background: 'rgb(0 0 0 / 0.4)', zIndex: 999 }}
17
- onClick={() => {
18
- if (props.shouldDismissOnClickOutside ?? true) {
19
- props.onDismissed?.()
20
- }
21
- }}
22
- />
23
- <div
24
- role="dialog"
167
+ <dialog
168
+ ref={dialog}
169
+ className="xplat-web-sheet"
170
+ aria-label={props.label ?? 'Bottom sheet'}
25
171
  aria-modal="true"
26
172
  style={{
27
- position: 'fixed',
28
- left: 0,
29
- right: 0,
30
- bottom: 0,
31
- zIndex: 1000,
32
- background: 'var(--sheet-bg, Canvas)',
33
- color: 'var(--sheet-fg, CanvasText)',
34
- borderRadius: '16px 16px 0 0',
35
- boxShadow: '0 -4px 24px rgb(0 0 0 / 0.2)',
36
- maxHeight: props.skipPartiallyExpanded ? '90vh' : '60vh',
37
- overflowY: 'auto',
173
+ '--xplat-sheet-scrim': props.scrimColor ?? 'rgb(0 0 0 / 0.4)',
174
+ '--xplat-sheet-container': props.containerColor ?? 'var(--sheet-bg, Canvas)',
175
+ '--xplat-sheet-content': props.contentColor ?? 'var(--sheet-fg, CanvasText)',
176
+ '--xplat-sheet-max-height': props.skipPartiallyExpanded ? '90vh' : '60vh',
177
+ }}
178
+ onCancel={onCancel}
179
+ onClick={(event: MouseEvent) => {
180
+ if (
181
+ event.target === event.currentTarget &&
182
+ (props.shouldDismissOnClickOutside ?? true)
183
+ ) {
184
+ props.onDismissed?.()
185
+ }
38
186
  }}
39
187
  >
40
- {(props.showDragHandle ?? true) && (
41
- <div style={{ display: 'flex', justifyContent: 'center', padding: '8px 0' }}>
42
- <div style={{ width: 36, height: 4, borderRadius: 2, background: 'color-mix(in srgb, CanvasText 30%, transparent)' }} />
43
- </div>
44
- )}
45
- {props.content?.()}
46
- </div>
47
- </>;
188
+ <div
189
+ ref={panel}
190
+ id={props.id}
191
+ className={'xplat-web-sheet-panel ' + (props.className ?? '')}
192
+ style={props.style}
193
+ >
194
+ {(props.showDragHandle ?? true) && (
195
+ <div
196
+ className="xplat-web-sheet-handle"
197
+ aria-hidden="true"
198
+ onPointerDown={onPointerDown}
199
+ onPointerMove={onPointerMove}
200
+ onPointerUp={onPointerUp}
201
+ onPointerCancel={resetDrag}
202
+ >
203
+ <div className="xplat-web-sheet-handle-mark" />
204
+ </div>
205
+ )}
206
+ {props.content?.()}
207
+ </div>
208
+ </dialog>
209
+ </>
48
210
  }
package/src/web/index.ts CHANGED
@@ -1,2 +1,4 @@
1
+ import './styles.css'
2
+
1
3
  export { BottomSheet } from '../BottomSheet.web.tsrx'
2
4
  export type { BottomSheetProps } from './types'
@@ -0,0 +1,57 @@
1
+ /* xplat-web-only:start */
2
+ .xplat-web-sheet {
3
+ position: fixed;
4
+ inset: 0;
5
+ width: 100%;
6
+ height: 100%;
7
+ max-width: none;
8
+ max-height: none;
9
+ margin: 0;
10
+ padding: 0;
11
+ border: 0;
12
+ background: transparent;
13
+ color: inherit;
14
+ overflow: visible;
15
+ display: flex;
16
+ align-items: flex-end;
17
+ justify-content: center;
18
+ }
19
+ .xplat-web-sheet::backdrop {
20
+ background: var(--xplat-sheet-scrim, rgb(0 0 0 / 0.4));
21
+ }
22
+
23
+ .xplat-web-sheet-panel {
24
+ box-sizing: border-box;
25
+ width: 100%;
26
+ max-height: var(--xplat-sheet-max-height, 60vh);
27
+ overflow-y: auto;
28
+ overscroll-behavior: contain;
29
+ background: var(--xplat-sheet-container, var(--sheet-bg, Canvas));
30
+ color: var(--xplat-sheet-content, var(--sheet-fg, CanvasText));
31
+ border-radius: 16px 16px 0 0;
32
+ box-shadow: 0 -4px 24px rgb(0 0 0 / 0.2);
33
+ transform: translateY(var(--xplat-sheet-drag-offset, 0px));
34
+ transition: transform 180ms ease-out;
35
+ }
36
+
37
+ .xplat-web-sheet-handle {
38
+ display: flex;
39
+ justify-content: center;
40
+ padding: 8px 0;
41
+ cursor: grab;
42
+ touch-action: none;
43
+ }
44
+
45
+ .xplat-web-sheet-handle-mark {
46
+ width: 36px;
47
+ height: 4px;
48
+ border-radius: 2px;
49
+ background: color-mix(in srgb, CanvasText 30%, transparent);
50
+ }
51
+
52
+ @media (prefers-reduced-motion: reduce) {
53
+ .xplat-web-sheet-panel {
54
+ transition: none;
55
+ }
56
+ }
57
+ /* xplat-web-only:end */
package/src/web/types.ts CHANGED
@@ -1,5 +1,16 @@
1
+ /** Minimal element shape used by the Web focus-restoration ref. */
2
+ export type WebFocusTarget = {
3
+ focus(options?: { preventScroll?: boolean }): void
4
+ isConnected: boolean
5
+ closest(selector: string): object | null
6
+ }
7
+
1
8
  export interface BottomSheetProps {
2
9
  id?: string
10
+ /** Accessible name for the modal dialog. */
11
+ label?: string
12
+ /** Element that should regain focus when the sheet closes. */
13
+ finalFocusRef?: { current: WebFocusTarget | null }
3
14
  className?: any
4
15
  style?: any
5
16
  open?: boolean
@@ -1,5 +1,16 @@
1
+ /** Minimal element shape used by the Web focus-restoration ref. */
2
+ type WebFocusTarget = {
3
+ focus(options?: { preventScroll?: boolean }): void
4
+ isConnected: boolean
5
+ closest(selector: string): object | null
6
+ }
7
+
1
8
  export interface BottomSheetProps {
2
9
  id?: string
10
+ /** Accessible name for the modal dialog. */
11
+ label?: string
12
+ /** Element that should regain focus when the sheet closes. */
13
+ finalFocusRef?: { current: WebFocusTarget | null }
3
14
  className?: any
4
15
  style?: any
5
16
  open?: boolean