@octane-xplat/sheet 0.9.0 → 0.11.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 +113 -3
- package/dist/native/octane-client-build.json +1 -1
- package/dist/web/BottomSheet.web.tsrx.js +115 -36
- package/dist/web/octane-client-build.json +1 -1
- package/dist/web/sheet.css +58 -0
- package/dist/web/web/index.js +1 -0
- package/package.json +4 -3
- package/src/AppKitSheet.macos.tsrx +1 -1
- package/src/BottomSheet.web.tsrx +197 -35
- package/src/web/index.ts +2 -0
- package/src/web/styles.css +57 -0
- package/src/web/types.ts +11 -0
- package/types/web/index.d.ts +11 -0
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
|
|
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:
|
|
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,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
|
|
5
|
-
|
|
6
|
-
if (
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
18
|
-
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
}, () =>
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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 };
|
|
@@ -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*/
|
package/dist/web/web/index.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@octane-xplat/sheet",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.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.
|
|
44
|
+
"tsrx-typegen": "0.2.2",
|
|
44
45
|
"typescript": "5.9.3",
|
|
45
46
|
"vite": "8.3.0"
|
|
46
47
|
},
|
package/src/BottomSheet.web.tsrx
CHANGED
|
@@ -1,48 +1,210 @@
|
|
|
1
1
|
/** @jsxImportSource octane */
|
|
2
|
-
import
|
|
2
|
+
import { useEffect, useRef } from 'octane'
|
|
3
|
+
import type { BottomSheetProps, WebFocusTarget } from './web/types'
|
|
3
4
|
|
|
4
|
-
|
|
5
|
-
|
|
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
|
|
8
|
-
const
|
|
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
|
-
|
|
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
|
-
<
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
@@ -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
|
package/types/web/index.d.ts
CHANGED
|
@@ -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
|