@remit/ui 0.0.38 → 0.0.39
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/package.json +1 -1
- package/src/components/message-list-pane.tsx +10 -1
- package/src/components/selection-sheet.render.test.ts +139 -0
- package/src/components/selection-sheet.stories.tsx +158 -0
- package/src/components/selection-sheet.test.ts +88 -0
- package/src/components/selection-sheet.tsx +483 -0
- package/src/index.ts +10 -0
package/package.json
CHANGED
|
@@ -40,6 +40,7 @@ export function MessageListPane({
|
|
|
40
40
|
isDesktop,
|
|
41
41
|
initialTouchState,
|
|
42
42
|
selectionBar,
|
|
43
|
+
selectionSheet,
|
|
43
44
|
listBody,
|
|
44
45
|
hideHeader = false,
|
|
45
46
|
}: Pick<
|
|
@@ -72,6 +73,13 @@ export function MessageListPane({
|
|
|
72
73
|
* When omitted the pane's built-in touch-triage selection bar is used.
|
|
73
74
|
*/
|
|
74
75
|
selectionBar?: ReactNode;
|
|
76
|
+
/**
|
|
77
|
+
* The mobile multi-select surface: a peeking bottom sheet overlaid on the
|
|
78
|
+
* list, distinct from `selectionBar` (which replaces the header at the top).
|
|
79
|
+
* Rendered as the last child so it sits above the rows; the caller pads the
|
|
80
|
+
* list's own bottom so no row hides behind the teaser.
|
|
81
|
+
*/
|
|
82
|
+
selectionSheet?: ReactNode;
|
|
75
83
|
/**
|
|
76
84
|
* Overrides the row-rendering section of the non-brief list — the whole
|
|
77
85
|
* scrollable body including virtualization, swipe-triage and any load-more
|
|
@@ -142,7 +150,7 @@ export function MessageListPane({
|
|
|
142
150
|
!selectionBar && touchTriage && selectionMode && checkedIds.size > 0;
|
|
143
151
|
|
|
144
152
|
return (
|
|
145
|
-
<section className="flex h-full w-full flex-col bg-surface">
|
|
153
|
+
<section className="relative flex h-full w-full flex-col bg-surface">
|
|
146
154
|
{selectionBar ??
|
|
147
155
|
(inBuiltinSelection ? (
|
|
148
156
|
<SelectionTopBar
|
|
@@ -249,6 +257,7 @@ export function MessageListPane({
|
|
|
249
257
|
)}
|
|
250
258
|
|
|
251
259
|
{isDesktop && <KeyboardHintBar />}
|
|
260
|
+
{selectionSheet}
|
|
252
261
|
</section>
|
|
253
262
|
);
|
|
254
263
|
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import { createElement } from "react";
|
|
4
|
+
import { renderToString } from "react-dom/server";
|
|
5
|
+
import { SelectionSheet, type SelectionSheetProps } from "./selection-sheet.js";
|
|
6
|
+
|
|
7
|
+
const noop = () => {};
|
|
8
|
+
|
|
9
|
+
const base: SelectionSheetProps = {
|
|
10
|
+
count: 3,
|
|
11
|
+
onCancel: noop,
|
|
12
|
+
onDelete: noop,
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
describe("SelectionSheet", () => {
|
|
16
|
+
it("teases collapsed with the count and the swipe hint", () => {
|
|
17
|
+
const html = renderToString(createElement(SelectionSheet, base));
|
|
18
|
+
assert.match(html, /3 messages selected/);
|
|
19
|
+
assert.match(html, /Swipe up for actions/);
|
|
20
|
+
assert.match(html, /role="slider"/);
|
|
21
|
+
// Collapsed: no cancel/mark-read in the header yet.
|
|
22
|
+
assert.doesNotMatch(html, /aria-label="Cancel selection"/);
|
|
23
|
+
// The clipped body is inert while collapsed, so its offscreen verbs stay
|
|
24
|
+
// out of the tab order and the a11y tree.
|
|
25
|
+
assert.match(html, /inert=""/);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("expanded shows the quick actions, cancel, and the smart-flow rows", () => {
|
|
29
|
+
const html = renderToString(
|
|
30
|
+
createElement(SelectionSheet, {
|
|
31
|
+
...base,
|
|
32
|
+
startExpanded: true,
|
|
33
|
+
onMarkRead: noop,
|
|
34
|
+
onJunk: noop,
|
|
35
|
+
onSelectSimilar: noop,
|
|
36
|
+
onSomethingElse: noop,
|
|
37
|
+
moveSlot: createElement("span", null, "move-here"),
|
|
38
|
+
}),
|
|
39
|
+
);
|
|
40
|
+
assert.match(html, /aria-label="Move selected messages to Trash"/);
|
|
41
|
+
assert.match(html, /aria-label="Move selected messages to Junk"/);
|
|
42
|
+
assert.match(html, /aria-label="Mark as read"/);
|
|
43
|
+
assert.match(html, /aria-label="Cancel selection"/);
|
|
44
|
+
assert.match(html, /move-here/);
|
|
45
|
+
assert.match(html, /Select similar messages/);
|
|
46
|
+
assert.match(html, /Something else/);
|
|
47
|
+
// Expanded: the body is live, not inert.
|
|
48
|
+
assert.doesNotMatch(html, /inert=""/);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("names the loaded scope and renders the select-all control", () => {
|
|
52
|
+
const html = renderToString(
|
|
53
|
+
createElement(SelectionSheet, {
|
|
54
|
+
...base,
|
|
55
|
+
count: 47,
|
|
56
|
+
startExpanded: true,
|
|
57
|
+
selectAll: { checked: true, indeterminate: false, onChange: noop },
|
|
58
|
+
}),
|
|
59
|
+
);
|
|
60
|
+
assert.match(html, /All 47 loaded selected/);
|
|
61
|
+
assert.match(html, /aria-label="Select all"/);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("counting replaces the quick actions with the status and a Stop", () => {
|
|
65
|
+
const html = renderToString(
|
|
66
|
+
createElement(SelectionSheet, {
|
|
67
|
+
...base,
|
|
68
|
+
count: 0,
|
|
69
|
+
mode: "counting",
|
|
70
|
+
statusLabel: "Counting… 1,900 so far",
|
|
71
|
+
notice: {
|
|
72
|
+
tone: "info",
|
|
73
|
+
text: "",
|
|
74
|
+
action: { label: "Stop", onClick: noop },
|
|
75
|
+
},
|
|
76
|
+
}),
|
|
77
|
+
);
|
|
78
|
+
assert.match(html, /Counting… 1,900 so far/);
|
|
79
|
+
assert.match(html, /Stop/);
|
|
80
|
+
// No quick actions while counting.
|
|
81
|
+
assert.doesNotMatch(html, /aria-label="Move selected messages to Trash"/);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("running shows a progress bar and no quick actions", () => {
|
|
85
|
+
const html = renderToString(
|
|
86
|
+
createElement(SelectionSheet, {
|
|
87
|
+
...base,
|
|
88
|
+
count: 3412,
|
|
89
|
+
mode: "running",
|
|
90
|
+
isBusy: true,
|
|
91
|
+
statusLabel: "Deleting 1,200 of 3,412…",
|
|
92
|
+
progress: { value: 1200, max: 3412 },
|
|
93
|
+
}),
|
|
94
|
+
);
|
|
95
|
+
assert.match(html, /Deleting 1,200 of 3,412…/);
|
|
96
|
+
assert.match(html, /role="progressbar"/);
|
|
97
|
+
assert.doesNotMatch(html, /Select similar messages/);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("escalated keeps the verbs and a clear-selection notice", () => {
|
|
101
|
+
const html = renderToString(
|
|
102
|
+
createElement(SelectionSheet, {
|
|
103
|
+
...base,
|
|
104
|
+
count: 3412,
|
|
105
|
+
mode: "escalated",
|
|
106
|
+
startExpanded: true,
|
|
107
|
+
statusLabel: 'All 3,412 matching "npm" selected',
|
|
108
|
+
moveSlot: createElement("span", null, "move-here"),
|
|
109
|
+
notice: {
|
|
110
|
+
tone: "info",
|
|
111
|
+
text: "",
|
|
112
|
+
action: { label: "Clear selection", onClick: noop },
|
|
113
|
+
},
|
|
114
|
+
}),
|
|
115
|
+
);
|
|
116
|
+
assert.match(html, /All 3,412 matching/);
|
|
117
|
+
assert.match(html, /aria-label="Move selected messages to Trash"/);
|
|
118
|
+
assert.match(html, /Clear selection/);
|
|
119
|
+
// Not a bounded selection, so no select-similar entry.
|
|
120
|
+
assert.doesNotMatch(html, /Select similar messages/);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("renders a partial-failure notice with a Retry action", () => {
|
|
124
|
+
const html = renderToString(
|
|
125
|
+
createElement(SelectionSheet, {
|
|
126
|
+
...base,
|
|
127
|
+
count: 340,
|
|
128
|
+
startExpanded: true,
|
|
129
|
+
notice: {
|
|
130
|
+
tone: "danger",
|
|
131
|
+
text: "3,072 moved to Trash. 340 couldn't be deleted.",
|
|
132
|
+
action: { label: "Retry 340", onClick: noop },
|
|
133
|
+
},
|
|
134
|
+
}),
|
|
135
|
+
);
|
|
136
|
+
assert.match(html, /3,072 moved to Trash/);
|
|
137
|
+
assert.match(html, /Retry 340/);
|
|
138
|
+
});
|
|
139
|
+
});
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
+
import { FolderInput } from "lucide-react";
|
|
3
|
+
import { SelectionSheet } from "./selection-sheet.js";
|
|
4
|
+
|
|
5
|
+
const meta: Meta<typeof SelectionSheet> = {
|
|
6
|
+
title: "Screens/Kit/SelectionSheet",
|
|
7
|
+
component: SelectionSheet,
|
|
8
|
+
parameters: { layout: "fullscreen" },
|
|
9
|
+
args: {
|
|
10
|
+
count: 3,
|
|
11
|
+
onCancel: () => undefined,
|
|
12
|
+
onDelete: () => undefined,
|
|
13
|
+
onMarkRead: () => undefined,
|
|
14
|
+
onJunk: () => undefined,
|
|
15
|
+
onSelectSimilar: () => undefined,
|
|
16
|
+
onSomethingElse: () => undefined,
|
|
17
|
+
},
|
|
18
|
+
decorators: [
|
|
19
|
+
(Story) => (
|
|
20
|
+
<div className="relative mx-auto h-dvh w-full shrink-0 overflow-hidden bg-surface sm:my-6 sm:h-[720px] sm:w-[390px] sm:rounded-[2rem] sm:border sm:border-line sm:shadow-sm">
|
|
21
|
+
{/* Inbox backdrop, so the peeking sheet reads against a list. */}
|
|
22
|
+
<div className="divide-y divide-line opacity-50">
|
|
23
|
+
{Array.from({ length: 11 }).map((_, i) => (
|
|
24
|
+
<div
|
|
25
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: static skeleton rows
|
|
26
|
+
key={i}
|
|
27
|
+
className="flex items-start gap-3 px-row-inset py-2.5"
|
|
28
|
+
>
|
|
29
|
+
<div className="mt-0.5 size-7 shrink-0 rounded-full bg-surface-sunken" />
|
|
30
|
+
<div className="min-w-0 flex-1 space-y-1">
|
|
31
|
+
<div className="h-2.5 w-1/3 rounded bg-surface-sunken" />
|
|
32
|
+
<div className="h-2 w-2/3 rounded bg-surface-sunken" />
|
|
33
|
+
</div>
|
|
34
|
+
</div>
|
|
35
|
+
))}
|
|
36
|
+
</div>
|
|
37
|
+
<Story />
|
|
38
|
+
</div>
|
|
39
|
+
),
|
|
40
|
+
],
|
|
41
|
+
};
|
|
42
|
+
export default meta;
|
|
43
|
+
|
|
44
|
+
type Story = StoryObj<typeof SelectionSheet>;
|
|
45
|
+
|
|
46
|
+
/** Stand-in for the caller's move-to-folder trigger (an icon button that opens
|
|
47
|
+
* a folder picker). The sheet only reserves the slot. */
|
|
48
|
+
const MoveSlot = () => (
|
|
49
|
+
<button
|
|
50
|
+
type="button"
|
|
51
|
+
aria-label="Move selected messages"
|
|
52
|
+
className="inline-flex size-11 shrink-0 items-center justify-center rounded text-fg-muted hover:bg-surface-raised"
|
|
53
|
+
>
|
|
54
|
+
<FolderInput className="size-4" />
|
|
55
|
+
</button>
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
/** Collapsed — the slim ~56px teaser that rises at 2+ selected. */
|
|
59
|
+
export const Teaser: Story = {
|
|
60
|
+
args: { moveSlot: <MoveSlot /> },
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/** Expanded — quick actions (Delete / Move / Junk) plus the select-similar and
|
|
64
|
+
* "Something else" entries. */
|
|
65
|
+
export const Expanded: Story = {
|
|
66
|
+
args: { startExpanded: true, moveSlot: <MoveSlot /> },
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
/** While a search result set pages to its total: the count isn't known, the
|
|
70
|
+
* quick actions are replaced by the running total and an explicit Stop. */
|
|
71
|
+
export const Counting: Story = {
|
|
72
|
+
args: {
|
|
73
|
+
count: 0,
|
|
74
|
+
mode: "counting",
|
|
75
|
+
startExpanded: true,
|
|
76
|
+
statusLabel: "Counting… 1,900 so far",
|
|
77
|
+
selectAll: {
|
|
78
|
+
checked: true,
|
|
79
|
+
indeterminate: false,
|
|
80
|
+
onChange: () => undefined,
|
|
81
|
+
},
|
|
82
|
+
notice: {
|
|
83
|
+
tone: "info",
|
|
84
|
+
text: "",
|
|
85
|
+
action: { label: "Stop", onClick: () => undefined },
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
/** A bulk delete in progress — a running total and a determinate progress bar,
|
|
91
|
+
* the delete busy, no quick actions to act mid-run. */
|
|
92
|
+
export const RunningProgress: Story = {
|
|
93
|
+
args: {
|
|
94
|
+
count: 3412,
|
|
95
|
+
mode: "running",
|
|
96
|
+
startExpanded: true,
|
|
97
|
+
isBusy: true,
|
|
98
|
+
statusLabel: "Deleting 1,200 of 3,412…",
|
|
99
|
+
progress: { value: 1200, max: 3412 },
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Every loaded row checked and the search has more matches: the sheet offers to
|
|
105
|
+
* escalate the selection to the whole result set.
|
|
106
|
+
*/
|
|
107
|
+
export const EscalationAvailable: Story = {
|
|
108
|
+
args: {
|
|
109
|
+
count: 47,
|
|
110
|
+
startExpanded: true,
|
|
111
|
+
moveSlot: <MoveSlot />,
|
|
112
|
+
selectAll: {
|
|
113
|
+
checked: true,
|
|
114
|
+
indeterminate: false,
|
|
115
|
+
onChange: () => undefined,
|
|
116
|
+
},
|
|
117
|
+
notice: {
|
|
118
|
+
tone: "info",
|
|
119
|
+
text: "",
|
|
120
|
+
action: {
|
|
121
|
+
label: 'Select all matching "npm"',
|
|
122
|
+
onClick: () => undefined,
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
/** Selection escalated to the search predicate: the count names the query's
|
|
129
|
+
* total, every verb still acts, and the notice offers a way back. */
|
|
130
|
+
export const Escalated: Story = {
|
|
131
|
+
args: {
|
|
132
|
+
count: 3412,
|
|
133
|
+
mode: "escalated",
|
|
134
|
+
startExpanded: true,
|
|
135
|
+
moveSlot: <MoveSlot />,
|
|
136
|
+
statusLabel: 'All 3,412 matching "npm" selected',
|
|
137
|
+
notice: {
|
|
138
|
+
tone: "info",
|
|
139
|
+
text: "",
|
|
140
|
+
action: { label: "Clear selection", onClick: () => undefined },
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
/** After a bulk delete with some batches failed: the count reflects only what
|
|
146
|
+
* is still selected — the failures — and Retry names how many. */
|
|
147
|
+
export const PartialFailure: Story = {
|
|
148
|
+
args: {
|
|
149
|
+
count: 340,
|
|
150
|
+
startExpanded: true,
|
|
151
|
+
moveSlot: <MoveSlot />,
|
|
152
|
+
notice: {
|
|
153
|
+
tone: "danger",
|
|
154
|
+
text: "3,072 moved to Trash. 340 couldn't be deleted.",
|
|
155
|
+
action: { label: "Retry 340", onClick: () => undefined },
|
|
156
|
+
},
|
|
157
|
+
},
|
|
158
|
+
};
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import { resolveSheetSnap } from "./selection-sheet.js";
|
|
4
|
+
|
|
5
|
+
const base = {
|
|
6
|
+
expandedHeight: 320,
|
|
7
|
+
teaserHeight: 56,
|
|
8
|
+
flickVelocity: 0.5,
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
describe("resolveSheetSnap", () => {
|
|
12
|
+
it("flicks up expand regardless of how far the drag travelled", () => {
|
|
13
|
+
assert.equal(
|
|
14
|
+
resolveSheetSnap({ ...base, expanded: false, delta: -4, velocity: -1.2 }),
|
|
15
|
+
true,
|
|
16
|
+
);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("flicks down collapse regardless of how far the drag travelled", () => {
|
|
20
|
+
assert.equal(
|
|
21
|
+
resolveSheetSnap({ ...base, expanded: true, delta: 4, velocity: 1.2 }),
|
|
22
|
+
false,
|
|
23
|
+
);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("a slow upward drag past the midpoint expands from the teaser", () => {
|
|
27
|
+
// midpoint = (320 - 56) / 2 = 132; -140 crosses it going up.
|
|
28
|
+
assert.equal(
|
|
29
|
+
resolveSheetSnap({
|
|
30
|
+
...base,
|
|
31
|
+
expanded: false,
|
|
32
|
+
delta: -140,
|
|
33
|
+
velocity: 0.1,
|
|
34
|
+
}),
|
|
35
|
+
true,
|
|
36
|
+
);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("a slow upward drag short of the midpoint settles back to the teaser", () => {
|
|
40
|
+
assert.equal(
|
|
41
|
+
resolveSheetSnap({
|
|
42
|
+
...base,
|
|
43
|
+
expanded: false,
|
|
44
|
+
delta: -100,
|
|
45
|
+
velocity: 0.1,
|
|
46
|
+
}),
|
|
47
|
+
false,
|
|
48
|
+
);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("a slow downward drag past the midpoint collapses from expanded", () => {
|
|
52
|
+
assert.equal(
|
|
53
|
+
resolveSheetSnap({ ...base, expanded: true, delta: 140, velocity: 0.1 }),
|
|
54
|
+
false,
|
|
55
|
+
);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("a slow downward drag short of the midpoint stays expanded", () => {
|
|
59
|
+
assert.equal(
|
|
60
|
+
resolveSheetSnap({ ...base, expanded: true, delta: 100, velocity: 0.1 }),
|
|
61
|
+
true,
|
|
62
|
+
);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("a barely-moved release keeps the current snap state", () => {
|
|
66
|
+
assert.equal(
|
|
67
|
+
resolveSheetSnap({ ...base, expanded: true, delta: 0, velocity: 0 }),
|
|
68
|
+
true,
|
|
69
|
+
);
|
|
70
|
+
assert.equal(
|
|
71
|
+
resolveSheetSnap({ ...base, expanded: false, delta: 0, velocity: 0 }),
|
|
72
|
+
false,
|
|
73
|
+
);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("defaults the flick threshold when omitted", () => {
|
|
77
|
+
assert.equal(
|
|
78
|
+
resolveSheetSnap({
|
|
79
|
+
expanded: false,
|
|
80
|
+
delta: -4,
|
|
81
|
+
velocity: -1,
|
|
82
|
+
expandedHeight: 320,
|
|
83
|
+
teaserHeight: 56,
|
|
84
|
+
}),
|
|
85
|
+
true,
|
|
86
|
+
);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
@@ -0,0 +1,483 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ChevronUp,
|
|
3
|
+
Loader2,
|
|
4
|
+
MailOpen,
|
|
5
|
+
ShieldAlert,
|
|
6
|
+
Trash2,
|
|
7
|
+
X,
|
|
8
|
+
} from "lucide-react";
|
|
9
|
+
import type { ReactNode } from "react";
|
|
10
|
+
import {
|
|
11
|
+
useCallback,
|
|
12
|
+
useEffect,
|
|
13
|
+
useLayoutEffect,
|
|
14
|
+
useRef,
|
|
15
|
+
useState,
|
|
16
|
+
} from "react";
|
|
17
|
+
import { cn } from "../lib/cn.js";
|
|
18
|
+
import { Banner, type BannerTone } from "./banner.js";
|
|
19
|
+
import { Button } from "./button.js";
|
|
20
|
+
import { Checkbox } from "./checkbox.js";
|
|
21
|
+
import { ProgressBar } from "./progress-bar.js";
|
|
22
|
+
|
|
23
|
+
const formatCount = (n: number): string => n.toLocaleString();
|
|
24
|
+
|
|
25
|
+
/** The peek height of the collapsed teaser row (px). */
|
|
26
|
+
export const SELECTION_SHEET_TEASER_HEIGHT = 56;
|
|
27
|
+
/** Ceiling on the expanded sheet height (px); CSS clamps to a third of the
|
|
28
|
+
* viewport below this. */
|
|
29
|
+
const EXPANDED_MAX = 320;
|
|
30
|
+
|
|
31
|
+
const SNAP_MS = 320;
|
|
32
|
+
const SNAP_EASE = "cubic-bezier(0.32, 0.9, 0.3, 1)";
|
|
33
|
+
const FLICK_VELOCITY = 0.5; // px/ms
|
|
34
|
+
|
|
35
|
+
function rubberBand(overshoot: number): number {
|
|
36
|
+
return Math.sign(overshoot) * Math.sqrt(Math.abs(overshoot)) * 4;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface SheetSnapInput {
|
|
40
|
+
/** The snap state the drag started from. */
|
|
41
|
+
expanded: boolean;
|
|
42
|
+
/** Net vertical travel over the drag (px); positive is downward. */
|
|
43
|
+
delta: number;
|
|
44
|
+
/** Terminal pointer velocity (px/ms); positive is downward. */
|
|
45
|
+
velocity: number;
|
|
46
|
+
/** Measured full height of the expanded sheet (px). */
|
|
47
|
+
expandedHeight: number;
|
|
48
|
+
/** Peek height of the collapsed teaser (px). */
|
|
49
|
+
teaserHeight: number;
|
|
50
|
+
/** Speed past which a drag is a flick, snapping in its direction. */
|
|
51
|
+
flickVelocity?: number;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The two-snap decision the sheet makes when a drag ends: a flick snaps in its
|
|
56
|
+
* own direction; otherwise the sheet settles to whichever snap point the drag
|
|
57
|
+
* crossed the midpoint toward. Pure so the snap behaviour is testable without a
|
|
58
|
+
* pointer or a DOM.
|
|
59
|
+
*/
|
|
60
|
+
export function resolveSheetSnap({
|
|
61
|
+
expanded,
|
|
62
|
+
delta,
|
|
63
|
+
velocity,
|
|
64
|
+
expandedHeight,
|
|
65
|
+
teaserHeight,
|
|
66
|
+
flickVelocity = FLICK_VELOCITY,
|
|
67
|
+
}: SheetSnapInput): boolean {
|
|
68
|
+
if (Math.abs(velocity) > flickVelocity) return velocity < 0;
|
|
69
|
+
const midpoint = (expandedHeight - teaserHeight) / 2;
|
|
70
|
+
return expanded ? delta < midpoint : delta < -midpoint;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export type SelectionSheetMode = "idle" | "counting" | "running" | "escalated";
|
|
74
|
+
|
|
75
|
+
export interface SelectionSheetNoticeAction {
|
|
76
|
+
label: string;
|
|
77
|
+
onClick: () => void;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface SelectionSheetNotice {
|
|
81
|
+
tone: BannerTone;
|
|
82
|
+
text: string;
|
|
83
|
+
action?: SelectionSheetNoticeAction;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface SelectionSheetProps {
|
|
87
|
+
count: number;
|
|
88
|
+
/**
|
|
89
|
+
* Which content the sheet routes to. `idle` shows the quick actions and the
|
|
90
|
+
* smart-flow rows; `counting`/`running` replace them with the paging status,
|
|
91
|
+
* progress and notice; `escalated` keeps the quick actions over the whole
|
|
92
|
+
* predicate. Defaults to `idle`.
|
|
93
|
+
*/
|
|
94
|
+
mode?: SelectionSheetMode;
|
|
95
|
+
/** The X / stop control — exits selection, or stops a run in progress. */
|
|
96
|
+
onCancel: () => void;
|
|
97
|
+
onDelete: () => void;
|
|
98
|
+
/** Move to the Junk mailbox. Omitted (hidden) in the Junk folder itself, or
|
|
99
|
+
* when no Junk folder is appointed. */
|
|
100
|
+
onJunk?: () => void;
|
|
101
|
+
/** Optional — hidden while a run is in flight or the total is still counting. */
|
|
102
|
+
onMarkRead?: () => void;
|
|
103
|
+
/** Widen the selection to similar mail, then open Organize. */
|
|
104
|
+
onSelectSimilar?: () => void;
|
|
105
|
+
/** Open Organize with the current selection to choose an action. */
|
|
106
|
+
onSomethingElse?: () => void;
|
|
107
|
+
/**
|
|
108
|
+
* Move-to-folder trigger, rendered as the middle quick action. Kept as a
|
|
109
|
+
* render prop so the caller owns the folder-picker data and API deps.
|
|
110
|
+
*/
|
|
111
|
+
moveSlot?: ReactNode;
|
|
112
|
+
/** True while a delete or move mutation is in flight. */
|
|
113
|
+
isBusy?: boolean;
|
|
114
|
+
/** Select-all-loaded control, rendered above the quick actions when present. */
|
|
115
|
+
selectAll?: {
|
|
116
|
+
checked: boolean;
|
|
117
|
+
indeterminate?: boolean;
|
|
118
|
+
onChange: () => void;
|
|
119
|
+
};
|
|
120
|
+
/** Overrides the default "{count} messages selected" status text. */
|
|
121
|
+
statusLabel?: string;
|
|
122
|
+
/** Determinate progress for a bulk run in flight. */
|
|
123
|
+
progress?: { value: number; max: number; tone?: BannerTone };
|
|
124
|
+
/** At-most-one toned status line: an escalation offer, a Stop, a Retry, or a
|
|
125
|
+
* cross-account move restriction. */
|
|
126
|
+
notice?: SelectionSheetNotice;
|
|
127
|
+
/** Start expanded rather than at the teaser — for stories and the counting /
|
|
128
|
+
* running states, which need their status visible. */
|
|
129
|
+
startExpanded?: boolean;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* The mobile multi-select surface: a peeking bottom sheet that teases at ~56px
|
|
134
|
+
* with the selection count, and expands by drag or tap to a third-height sheet
|
|
135
|
+
* carrying the bulk verbs (Delete / Move / Junk), the select-similar → organize
|
|
136
|
+
* entries, and every escalation state (counting, running progress,
|
|
137
|
+
* partial-failure) the selection can be in. Drag or tap the grabber to collapse
|
|
138
|
+
* back to the teaser; the selection is untouched.
|
|
139
|
+
*
|
|
140
|
+
* Sits absolutely against the bottom of the nearest positioned ancestor, so the
|
|
141
|
+
* list it belongs to must be a `relative` container and pad its own bottom by
|
|
142
|
+
* {@link SELECTION_SHEET_TEASER_HEIGHT} so no row hides behind the teaser.
|
|
143
|
+
*/
|
|
144
|
+
export function SelectionSheet({
|
|
145
|
+
count,
|
|
146
|
+
mode = "idle",
|
|
147
|
+
onCancel,
|
|
148
|
+
onDelete,
|
|
149
|
+
onJunk,
|
|
150
|
+
onMarkRead,
|
|
151
|
+
onSelectSimilar,
|
|
152
|
+
onSomethingElse,
|
|
153
|
+
moveSlot,
|
|
154
|
+
isBusy = false,
|
|
155
|
+
selectAll,
|
|
156
|
+
statusLabel,
|
|
157
|
+
progress,
|
|
158
|
+
notice,
|
|
159
|
+
startExpanded = false,
|
|
160
|
+
}: SelectionSheetProps) {
|
|
161
|
+
const [expanded, setExpanded] = useState(startExpanded);
|
|
162
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
163
|
+
const [expandedHeight, setExpandedHeight] = useState(EXPANDED_MAX);
|
|
164
|
+
|
|
165
|
+
// A run or a live count owns the sheet: it stays open so the progress and
|
|
166
|
+
// status can't be dragged out of sight mid-operation.
|
|
167
|
+
const locked = mode === "counting" || mode === "running";
|
|
168
|
+
useEffect(() => {
|
|
169
|
+
if (locked) setExpanded(true);
|
|
170
|
+
}, [locked]);
|
|
171
|
+
|
|
172
|
+
useLayoutEffect(() => {
|
|
173
|
+
const el = containerRef.current;
|
|
174
|
+
if (!el) return;
|
|
175
|
+
const measure = () => setExpandedHeight(el.offsetHeight);
|
|
176
|
+
measure();
|
|
177
|
+
const ro = new ResizeObserver(measure);
|
|
178
|
+
ro.observe(el);
|
|
179
|
+
return () => ro.disconnect();
|
|
180
|
+
}, []);
|
|
181
|
+
|
|
182
|
+
// Offset from the current snap position (positive = dragged down).
|
|
183
|
+
const [dragOffset, setDragOffset] = useState<number | null>(null);
|
|
184
|
+
const pointer = useRef<{
|
|
185
|
+
startY: number;
|
|
186
|
+
lastY: number;
|
|
187
|
+
lastT: number;
|
|
188
|
+
velocity: number;
|
|
189
|
+
} | null>(null);
|
|
190
|
+
// True once a pointer-down has actually moved, so the click the browser fires
|
|
191
|
+
// on pointer-up after a drag doesn't also toggle the snap state and undo it.
|
|
192
|
+
const movedRef = useRef(false);
|
|
193
|
+
|
|
194
|
+
const onPointerDown = useCallback(
|
|
195
|
+
(e: React.PointerEvent) => {
|
|
196
|
+
if (locked) return;
|
|
197
|
+
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
|
198
|
+
pointer.current = {
|
|
199
|
+
startY: e.clientY,
|
|
200
|
+
lastY: e.clientY,
|
|
201
|
+
lastT: e.timeStamp,
|
|
202
|
+
velocity: 0,
|
|
203
|
+
};
|
|
204
|
+
movedRef.current = false;
|
|
205
|
+
setDragOffset(0);
|
|
206
|
+
},
|
|
207
|
+
[locked],
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
const onPointerMove = useCallback(
|
|
211
|
+
(e: React.PointerEvent) => {
|
|
212
|
+
const p = pointer.current;
|
|
213
|
+
if (!p) return;
|
|
214
|
+
const dt = e.timeStamp - p.lastT;
|
|
215
|
+
if (dt > 0) p.velocity = (e.clientY - p.lastY) / dt;
|
|
216
|
+
p.lastY = e.clientY;
|
|
217
|
+
p.lastT = e.timeStamp;
|
|
218
|
+
const delta = e.clientY - p.startY;
|
|
219
|
+
if (Math.abs(delta) > 4) movedRef.current = true;
|
|
220
|
+
const range = expandedHeight - SELECTION_SHEET_TEASER_HEIGHT;
|
|
221
|
+
const clamped = expanded
|
|
222
|
+
? delta < 0
|
|
223
|
+
? rubberBand(delta)
|
|
224
|
+
: Math.min(delta, range + rubberBand(Math.max(0, delta - range)))
|
|
225
|
+
: delta > 0
|
|
226
|
+
? rubberBand(delta)
|
|
227
|
+
: Math.max(delta, -range + rubberBand(Math.min(0, delta + range)));
|
|
228
|
+
setDragOffset(clamped);
|
|
229
|
+
},
|
|
230
|
+
[expanded, expandedHeight],
|
|
231
|
+
);
|
|
232
|
+
|
|
233
|
+
const finishDrag = useCallback(() => {
|
|
234
|
+
const p = pointer.current;
|
|
235
|
+
pointer.current = null;
|
|
236
|
+
if (!p) return;
|
|
237
|
+
setDragOffset(null);
|
|
238
|
+
setExpanded(
|
|
239
|
+
resolveSheetSnap({
|
|
240
|
+
expanded,
|
|
241
|
+
delta: p.lastY - p.startY,
|
|
242
|
+
velocity: p.velocity,
|
|
243
|
+
expandedHeight,
|
|
244
|
+
teaserHeight: SELECTION_SHEET_TEASER_HEIGHT,
|
|
245
|
+
}),
|
|
246
|
+
);
|
|
247
|
+
}, [expanded, expandedHeight]);
|
|
248
|
+
|
|
249
|
+
const collapsedTranslate = expandedHeight - SELECTION_SHEET_TEASER_HEIGHT;
|
|
250
|
+
const baseTranslate = expanded ? 0 : collapsedTranslate;
|
|
251
|
+
const dragging = dragOffset !== null;
|
|
252
|
+
const translate = baseTranslate + (dragOffset ?? 0);
|
|
253
|
+
const transition = dragging ? "none" : `transform ${SNAP_MS}ms ${SNAP_EASE}`;
|
|
254
|
+
|
|
255
|
+
const defaultLabel = selectAll?.checked
|
|
256
|
+
? `All ${formatCount(count)} loaded selected`
|
|
257
|
+
: `${formatCount(count)} ${count === 1 ? "message" : "messages"} selected`;
|
|
258
|
+
|
|
259
|
+
const showQuickActions = mode === "idle" || mode === "escalated";
|
|
260
|
+
const showSmartRows = mode === "idle";
|
|
261
|
+
|
|
262
|
+
return (
|
|
263
|
+
<div
|
|
264
|
+
ref={containerRef}
|
|
265
|
+
data-selection-sheet=""
|
|
266
|
+
className="absolute inset-x-0 bottom-0 z-30 flex select-none flex-col rounded-t-2xl border-t border-line bg-surface shadow-2xl shadow-black/40"
|
|
267
|
+
style={{
|
|
268
|
+
maxHeight: `min(${EXPANDED_MAX}px, 38dvh)`,
|
|
269
|
+
minHeight: `${SELECTION_SHEET_TEASER_HEIGHT}px`,
|
|
270
|
+
transform: `translateY(${translate}px)`,
|
|
271
|
+
transition,
|
|
272
|
+
}}
|
|
273
|
+
>
|
|
274
|
+
{/* Grabber / teaser — always visible at the peek. Tapping toggles the
|
|
275
|
+
snap state; dragging snaps between the two heights. */}
|
|
276
|
+
{/* biome-ignore lint/a11y/useKeyWithClickEvents: keyboard users reach every action via the buttons below; the grabber is a pointer-drag affordance */}
|
|
277
|
+
<div
|
|
278
|
+
role="slider"
|
|
279
|
+
aria-label={
|
|
280
|
+
expanded ? "Collapse selection actions" : "Expand selection actions"
|
|
281
|
+
}
|
|
282
|
+
aria-valuemin={0}
|
|
283
|
+
aria-valuemax={1}
|
|
284
|
+
aria-valuenow={expanded ? 1 : 0}
|
|
285
|
+
tabIndex={0}
|
|
286
|
+
onPointerDown={onPointerDown}
|
|
287
|
+
onPointerMove={onPointerMove}
|
|
288
|
+
onPointerUp={finishDrag}
|
|
289
|
+
onPointerCancel={finishDrag}
|
|
290
|
+
onClick={() => {
|
|
291
|
+
// A drag already settled the snap in finishDrag; swallow the click
|
|
292
|
+
// the browser fires after it so it doesn't toggle straight back.
|
|
293
|
+
if (movedRef.current) {
|
|
294
|
+
movedRef.current = false;
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
if (!dragging && !locked) setExpanded((v) => !v);
|
|
298
|
+
}}
|
|
299
|
+
className={cn(
|
|
300
|
+
"flex touch-none flex-col items-center pt-2",
|
|
301
|
+
locked ? "" : "cursor-grab active:cursor-grabbing",
|
|
302
|
+
)}
|
|
303
|
+
>
|
|
304
|
+
<div className="mb-1.5 h-1 w-10 rounded-full bg-fg-subtle/40" />
|
|
305
|
+
<div className="flex w-full items-center gap-2 px-4 pb-3">
|
|
306
|
+
<span
|
|
307
|
+
className="min-w-0 flex-1 truncate text-sm font-semibold text-fg"
|
|
308
|
+
role="status"
|
|
309
|
+
aria-live="polite"
|
|
310
|
+
>
|
|
311
|
+
{statusLabel ?? defaultLabel}
|
|
312
|
+
</span>
|
|
313
|
+
{expanded ? (
|
|
314
|
+
<>
|
|
315
|
+
{onMarkRead && !isBusy && mode !== "counting" && (
|
|
316
|
+
<Button
|
|
317
|
+
variant="ghost"
|
|
318
|
+
size="touch"
|
|
319
|
+
icon={<MailOpen className="size-4" />}
|
|
320
|
+
// These buttons sit inside the grabber's drag surface. Its
|
|
321
|
+
// pointer-down capture would otherwise swallow the button's
|
|
322
|
+
// own click (the grabber ends up the click target and just
|
|
323
|
+
// toggles the snap), so stop the pointer-down here — the tap
|
|
324
|
+
// then lands on the button and runs its action.
|
|
325
|
+
onPointerDown={(e) => e.stopPropagation()}
|
|
326
|
+
onClick={(e) => {
|
|
327
|
+
e.stopPropagation();
|
|
328
|
+
onMarkRead();
|
|
329
|
+
}}
|
|
330
|
+
aria-label="Mark as read"
|
|
331
|
+
className="-my-2 shrink-0"
|
|
332
|
+
/>
|
|
333
|
+
)}
|
|
334
|
+
<Button
|
|
335
|
+
variant="ghost"
|
|
336
|
+
size="touch"
|
|
337
|
+
icon={<X className="size-4" />}
|
|
338
|
+
onPointerDown={(e) => e.stopPropagation()}
|
|
339
|
+
onClick={(e) => {
|
|
340
|
+
e.stopPropagation();
|
|
341
|
+
onCancel();
|
|
342
|
+
}}
|
|
343
|
+
aria-label="Cancel selection"
|
|
344
|
+
className="-my-2 -mr-2 shrink-0"
|
|
345
|
+
/>
|
|
346
|
+
</>
|
|
347
|
+
) : (
|
|
348
|
+
<span className="flex shrink-0 items-center gap-1 text-xs text-fg-subtle">
|
|
349
|
+
<span>Swipe up for actions</span>
|
|
350
|
+
<ChevronUp className="size-4" />
|
|
351
|
+
</span>
|
|
352
|
+
)}
|
|
353
|
+
</div>
|
|
354
|
+
</div>
|
|
355
|
+
|
|
356
|
+
{/* Expanded content — clipped by the translate when collapsed. It stays
|
|
357
|
+
in the DOM at the teaser, so `inert` when collapsed keeps its offscreen
|
|
358
|
+
verbs out of the tab order and the a11y tree until the sheet opens. */}
|
|
359
|
+
<div
|
|
360
|
+
inert={!expanded ? true : undefined}
|
|
361
|
+
className="flex min-h-0 flex-1 flex-col overflow-hidden px-4 pb-4"
|
|
362
|
+
>
|
|
363
|
+
{progress && (
|
|
364
|
+
<div className="mb-3">
|
|
365
|
+
<ProgressBar
|
|
366
|
+
value={progress.value}
|
|
367
|
+
max={progress.max}
|
|
368
|
+
tone={progress.tone}
|
|
369
|
+
/>
|
|
370
|
+
</div>
|
|
371
|
+
)}
|
|
372
|
+
|
|
373
|
+
{selectAll && mode !== "running" && (
|
|
374
|
+
// biome-ignore lint/a11y/noLabelWithoutControl: the label wraps Checkbox's own input, giving the 20px control a 44px hit area
|
|
375
|
+
<label className="mb-3 flex min-h-11 cursor-pointer items-center gap-3 text-sm font-medium text-fg-muted">
|
|
376
|
+
<Checkbox
|
|
377
|
+
aria-label="Select all"
|
|
378
|
+
checked={selectAll.checked}
|
|
379
|
+
indeterminate={selectAll.indeterminate}
|
|
380
|
+
onChange={selectAll.onChange}
|
|
381
|
+
/>
|
|
382
|
+
Select all loaded
|
|
383
|
+
</label>
|
|
384
|
+
)}
|
|
385
|
+
|
|
386
|
+
{showQuickActions && (
|
|
387
|
+
<div className="mb-3 flex items-stretch justify-around gap-1 border-b border-line pb-3">
|
|
388
|
+
<Button
|
|
389
|
+
variant="ghost"
|
|
390
|
+
onClick={onDelete}
|
|
391
|
+
icon={
|
|
392
|
+
isBusy ? (
|
|
393
|
+
<Loader2 className="size-5 animate-spin" />
|
|
394
|
+
) : (
|
|
395
|
+
<Trash2 className="size-5 text-danger" />
|
|
396
|
+
)
|
|
397
|
+
}
|
|
398
|
+
aria-label="Move selected messages to Trash"
|
|
399
|
+
aria-busy={isBusy || undefined}
|
|
400
|
+
className="h-auto flex-1 flex-col gap-1 px-0 py-1.5 text-[11px]"
|
|
401
|
+
>
|
|
402
|
+
Delete
|
|
403
|
+
</Button>
|
|
404
|
+
{moveSlot && (
|
|
405
|
+
<div className="flex flex-1 flex-col items-center gap-1">
|
|
406
|
+
{moveSlot}
|
|
407
|
+
<span aria-hidden="true" className="text-[11px] text-fg-muted">
|
|
408
|
+
Move
|
|
409
|
+
</span>
|
|
410
|
+
</div>
|
|
411
|
+
)}
|
|
412
|
+
{onJunk && (
|
|
413
|
+
<Button
|
|
414
|
+
variant="ghost"
|
|
415
|
+
onClick={onJunk}
|
|
416
|
+
icon={<ShieldAlert className="size-5" />}
|
|
417
|
+
aria-label="Move selected messages to Junk"
|
|
418
|
+
className="h-auto flex-1 flex-col gap-1 px-0 py-1.5 text-[11px]"
|
|
419
|
+
>
|
|
420
|
+
Junk
|
|
421
|
+
</Button>
|
|
422
|
+
)}
|
|
423
|
+
</div>
|
|
424
|
+
)}
|
|
425
|
+
|
|
426
|
+
{showSmartRows && (onSelectSimilar || onSomethingElse) && (
|
|
427
|
+
<div className="flex flex-col gap-2">
|
|
428
|
+
{onSelectSimilar && (
|
|
429
|
+
<Button
|
|
430
|
+
variant="primary"
|
|
431
|
+
onClick={onSelectSimilar}
|
|
432
|
+
className="h-auto flex-col items-start gap-0 px-4 py-2.5 text-left"
|
|
433
|
+
>
|
|
434
|
+
<span className="text-sm font-semibold leading-tight">
|
|
435
|
+
Select similar messages
|
|
436
|
+
</span>
|
|
437
|
+
<span className="text-xs opacity-80">find more like these</span>
|
|
438
|
+
</Button>
|
|
439
|
+
)}
|
|
440
|
+
{onSomethingElse && (
|
|
441
|
+
<Button
|
|
442
|
+
variant="secondary"
|
|
443
|
+
onClick={onSomethingElse}
|
|
444
|
+
className="h-auto flex-col items-start gap-0 px-4 py-2.5 text-left"
|
|
445
|
+
>
|
|
446
|
+
<span className="text-sm font-medium leading-tight">
|
|
447
|
+
Something else
|
|
448
|
+
</span>
|
|
449
|
+
<span className="text-xs text-fg-subtle">
|
|
450
|
+
just deal with these
|
|
451
|
+
</span>
|
|
452
|
+
</Button>
|
|
453
|
+
)}
|
|
454
|
+
</div>
|
|
455
|
+
)}
|
|
456
|
+
|
|
457
|
+
{notice && (
|
|
458
|
+
<Banner
|
|
459
|
+
tone={notice.tone}
|
|
460
|
+
variant="soft"
|
|
461
|
+
role="status"
|
|
462
|
+
aria-live="polite"
|
|
463
|
+
className={cn(showQuickActions || progress ? "mt-1" : "mt-0")}
|
|
464
|
+
>
|
|
465
|
+
<div className="flex items-center justify-between gap-2">
|
|
466
|
+
{notice.text && <span>{notice.text}</span>}
|
|
467
|
+
{notice.action && (
|
|
468
|
+
<Button
|
|
469
|
+
variant="ghost"
|
|
470
|
+
size="md"
|
|
471
|
+
onClick={notice.action.onClick}
|
|
472
|
+
className="-my-1 min-h-11 shrink-0"
|
|
473
|
+
>
|
|
474
|
+
{notice.action.label}
|
|
475
|
+
</Button>
|
|
476
|
+
)}
|
|
477
|
+
</div>
|
|
478
|
+
</Banner>
|
|
479
|
+
)}
|
|
480
|
+
</div>
|
|
481
|
+
</div>
|
|
482
|
+
);
|
|
483
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -376,6 +376,16 @@ export {
|
|
|
376
376
|
type SegmentedOption,
|
|
377
377
|
} from "./components/segmented-control.js";
|
|
378
378
|
export { Select, type SelectProps } from "./components/select.js";
|
|
379
|
+
export {
|
|
380
|
+
resolveSheetSnap,
|
|
381
|
+
SELECTION_SHEET_TEASER_HEIGHT,
|
|
382
|
+
SelectionSheet,
|
|
383
|
+
type SelectionSheetMode,
|
|
384
|
+
type SelectionSheetNotice,
|
|
385
|
+
type SelectionSheetNoticeAction,
|
|
386
|
+
type SelectionSheetProps,
|
|
387
|
+
type SheetSnapInput,
|
|
388
|
+
} from "./components/selection-sheet.js";
|
|
379
389
|
export {
|
|
380
390
|
SelectionTopBar,
|
|
381
391
|
type SelectionTopBarNotice,
|