@remit/ui 0.0.1
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 +50 -0
- package/src/components/address-display.render.test.ts +93 -0
- package/src/components/address-display.stories.tsx +55 -0
- package/src/components/address-display.tsx +132 -0
- package/src/components/address-tag.render.test.ts +39 -0
- package/src/components/address-tag.stories.tsx +41 -0
- package/src/components/address-tag.tsx +38 -0
- package/src/components/app-password-hint.render.test.ts +23 -0
- package/src/components/app-password-hint.stories.tsx +19 -0
- package/src/components/app-password-hint.tsx +24 -0
- package/src/components/app-shell-slotted.render.test.ts +128 -0
- package/src/components/app-shell-slotted.tsx +267 -0
- package/src/components/app-shell-types.ts +328 -0
- package/src/components/app-shell.render.test.ts +415 -0
- package/src/components/app-shell.tsx +277 -0
- package/src/components/auth-card.render.test.ts +24 -0
- package/src/components/auth-card.stories.tsx +50 -0
- package/src/components/auth-card.tsx +32 -0
- package/src/components/auth-footer.render.test.ts +17 -0
- package/src/components/auth-footer.stories.tsx +23 -0
- package/src/components/auth-footer.tsx +14 -0
- package/src/components/auth-hero.render.test.ts +31 -0
- package/src/components/auth-hero.stories.tsx +19 -0
- package/src/components/auth-hero.tsx +41 -0
- package/src/components/auto-moved-badge.render.test.ts +42 -0
- package/src/components/auto-moved-badge.stories.tsx +48 -0
- package/src/components/auto-moved-badge.tsx +57 -0
- package/src/components/avatar.tsx +60 -0
- package/src/components/badge.tsx +49 -0
- package/src/components/banner.render.test.ts +58 -0
- package/src/components/banner.stories.tsx +92 -0
- package/src/components/banner.tsx +81 -0
- package/src/components/bottom-sheet.render.test.ts +48 -0
- package/src/components/bottom-sheet.stories.tsx +71 -0
- package/src/components/bottom-sheet.tsx +146 -0
- package/src/components/brief-section.render.test.ts +98 -0
- package/src/components/brief-section.stories.tsx +68 -0
- package/src/components/brief-section.tsx +102 -0
- package/src/components/brief-sections.render.test.ts +72 -0
- package/src/components/brief-sections.stories.tsx +178 -0
- package/src/components/brief-sections.tsx +201 -0
- package/src/components/button.tsx +48 -0
- package/src/components/card.tsx +48 -0
- package/src/components/category-badge.render.test.ts +40 -0
- package/src/components/category-badge.stories.tsx +37 -0
- package/src/components/category-badge.tsx +71 -0
- package/src/components/checkbox.render.test.ts +65 -0
- package/src/components/checkbox.stories.tsx +77 -0
- package/src/components/checkbox.tsx +78 -0
- package/src/components/compose-action-bar.render.test.ts +47 -0
- package/src/components/compose-action-bar.stories.tsx +35 -0
- package/src/components/compose-action-bar.tsx +95 -0
- package/src/components/compose-form-shell.render.test.ts +54 -0
- package/src/components/compose-form-shell.stories.tsx +96 -0
- package/src/components/compose-form-shell.tsx +49 -0
- package/src/components/danger-zone-section.tsx +36 -0
- package/src/components/dialog.render.test.ts +55 -0
- package/src/components/dialog.tsx +104 -0
- package/src/components/email-frame-css.ts +137 -0
- package/src/components/field-label.render.test.ts +16 -0
- package/src/components/field-label.tsx +19 -0
- package/src/components/filter-sheet.render.test.ts +76 -0
- package/src/components/filter-sheet.stories.tsx +146 -0
- package/src/components/filter-sheet.tsx +316 -0
- package/src/components/folder-role.tsx +66 -0
- package/src/components/input.tsx +44 -0
- package/src/components/intelligence-panel.stories.tsx +149 -0
- package/src/components/intelligence-panel.tsx +546 -0
- package/src/components/isolated-email-frame.render.test.ts +147 -0
- package/src/components/isolated-email-frame.stories.tsx +192 -0
- package/src/components/isolated-email-frame.tsx +265 -0
- package/src/components/kbd.tsx +17 -0
- package/src/components/keyboard-hint-bar.tsx +58 -0
- package/src/components/list-item.tsx +39 -0
- package/src/components/mail-action-toolbar.render.test.ts +43 -0
- package/src/components/mail-action-toolbar.tsx +180 -0
- package/src/components/mail-header.render.test.ts +69 -0
- package/src/components/mail-header.stories.tsx +233 -0
- package/src/components/mail-header.tsx +123 -0
- package/src/components/message-body-view.render.test.ts +59 -0
- package/src/components/message-body-view.stories.tsx +146 -0
- package/src/components/message-body-view.tsx +150 -0
- package/src/components/message-header.render.test.ts +58 -0
- package/src/components/message-header.stories.tsx +79 -0
- package/src/components/message-header.tsx +59 -0
- package/src/components/message-list-pane.render.test.ts +136 -0
- package/src/components/message-list-pane.stories.tsx +246 -0
- package/src/components/message-list-pane.tsx +248 -0
- package/src/components/message-list-state.render.test.ts +72 -0
- package/src/components/message-list-state.tsx +97 -0
- package/src/components/message-row.render.test.ts +48 -0
- package/src/components/message-row.stories.tsx +121 -0
- package/src/components/message-row.tsx +231 -0
- package/src/components/mobile-message-action-bar.render.test.ts +87 -0
- package/src/components/mobile-message-action-bar.stories.tsx +57 -0
- package/src/components/mobile-message-action-bar.tsx +175 -0
- package/src/components/mobile-reading-pane.render.test.ts +102 -0
- package/src/components/mobile-reading-pane.tsx +188 -0
- package/src/components/mobile-search-view.stories.tsx +247 -0
- package/src/components/mobile-search-view.tsx +100 -0
- package/src/components/move-mailbox-picker.render.test.ts +113 -0
- package/src/components/move-mailbox-picker.stories.tsx +76 -0
- package/src/components/move-mailbox-picker.tsx +324 -0
- package/src/components/nav-sidebar.render.test.ts +351 -0
- package/src/components/nav-sidebar.stories.tsx +216 -0
- package/src/components/nav-sidebar.tsx +625 -0
- package/src/components/outbox-row.render.test.ts +61 -0
- package/src/components/outbox-row.stories.tsx +53 -0
- package/src/components/outbox-row.tsx +120 -0
- package/src/components/outbox-status-badge.tsx +60 -0
- package/src/components/pane-layout.render.test.ts +105 -0
- package/src/components/popover-menu.render.test.ts +32 -0
- package/src/components/popover-menu.stories.tsx +57 -0
- package/src/components/popover-menu.tsx +110 -0
- package/src/components/primitives.stories.tsx +173 -0
- package/src/components/pull-to-refresh.render.test.ts +46 -0
- package/src/components/pull-to-refresh.stories.tsx +71 -0
- package/src/components/pull-to-refresh.tsx +54 -0
- package/src/components/quoted-text.render.test.ts +39 -0
- package/src/components/quoted-text.stories.tsx +67 -0
- package/src/components/quoted-text.tsx +67 -0
- package/src/components/reading-pane-empty.tsx +44 -0
- package/src/components/reading-pane.render.test.ts +148 -0
- package/src/components/reading-pane.stories.tsx +194 -0
- package/src/components/reading-pane.tsx +351 -0
- package/src/components/rescue-banner.render.test.ts +32 -0
- package/src/components/rescue-banner.stories.tsx +31 -0
- package/src/components/rescue-banner.tsx +50 -0
- package/src/components/rescue-candidate-row.render.test.ts +68 -0
- package/src/components/rescue-candidate-row.stories.tsx +108 -0
- package/src/components/rescue-candidate-row.tsx +94 -0
- package/src/components/rescue-from-spam-flow.render.test.ts +100 -0
- package/src/components/rescue-from-spam-flow.stories.tsx +92 -0
- package/src/components/rescue-from-spam-flow.tsx +342 -0
- package/src/components/resizable.tsx +55 -0
- package/src/components/role-appointment-list.render.test.ts +97 -0
- package/src/components/role-appointment-list.stories.tsx +138 -0
- package/src/components/role-appointment-list.tsx +227 -0
- package/src/components/row-actions.render.test.ts +83 -0
- package/src/components/row-actions.stories.tsx +87 -0
- package/src/components/row-actions.tsx +139 -0
- package/src/components/search-bar.render.test.ts +55 -0
- package/src/components/search-bar.stories.tsx +46 -0
- package/src/components/search-bar.tsx +118 -0
- package/src/components/search-result-row.tsx +139 -0
- package/src/components/search-results.render.test.ts +89 -0
- package/src/components/search-results.stories.tsx +126 -0
- package/src/components/search-results.tsx +215 -0
- package/src/components/search-token-chip.render.test.ts +42 -0
- package/src/components/search-token-chip.stories.tsx +42 -0
- package/src/components/search-token-chip.tsx +66 -0
- package/src/components/security-select.render.test.ts +33 -0
- package/src/components/security-select.tsx +53 -0
- package/src/components/segmented-control.tsx +70 -0
- package/src/components/select.tsx +33 -0
- package/src/components/selection-top-bar.render.test.ts +67 -0
- package/src/components/selection-top-bar.stories.tsx +39 -0
- package/src/components/selection-top-bar.tsx +95 -0
- package/src/components/sender-group-switch.tsx +63 -0
- package/src/components/sender-trust-indicator.render.test.ts +67 -0
- package/src/components/sender-trust-indicator.stories.tsx +58 -0
- package/src/components/sender-trust-indicator.tsx +87 -0
- package/src/components/settings-primitives.render.test.ts +160 -0
- package/src/components/settings-primitives.stories.tsx +80 -0
- package/src/components/settings-screen.tsx +354 -0
- package/src/components/swipeable-row.render.test.ts +106 -0
- package/src/components/swipeable-row.stories.tsx +117 -0
- package/src/components/swipeable-row.tsx +268 -0
- package/src/components/touch-list-body.render.test.ts +66 -0
- package/src/components/touch-list-body.stories.tsx +76 -0
- package/src/components/touch-list.tsx +87 -0
- package/src/components/wizard.render.test.ts +49 -0
- package/src/components/wizard.stories.tsx +113 -0
- package/src/components/wizard.tsx +321 -0
- package/src/filter-presets.test.ts +93 -0
- package/src/filter-presets.ts +101 -0
- package/src/index.ts +378 -0
- package/src/lib/cid-resolver.ts +35 -0
- package/src/lib/cn.ts +7 -0
- package/src/lib/email-layout-clamp.test.ts +109 -0
- package/src/lib/email-layout-clamp.ts +64 -0
- package/src/lib/email-render-treatment.test.ts +82 -0
- package/src/lib/email-render-treatment.ts +53 -0
- package/src/lib/email-sanitizer.test.ts +362 -0
- package/src/lib/email-sanitizer.ts +298 -0
- package/src/lib/row-keyboard.test.ts +41 -0
- package/src/lib/row-keyboard.ts +20 -0
- package/src/tokens.css +217 -0
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { X } from "lucide-react";
|
|
2
|
+
import type { HTMLAttributes } from "react";
|
|
3
|
+
import { cn } from "../lib/cn.js";
|
|
4
|
+
import { Button } from "./button.js";
|
|
5
|
+
|
|
6
|
+
export type BannerTone = "info" | "success" | "warning" | "danger";
|
|
7
|
+
export type BannerVariant = "framed" | "soft";
|
|
8
|
+
|
|
9
|
+
export interface BannerProps extends HTMLAttributes<HTMLDivElement> {
|
|
10
|
+
tone?: BannerTone;
|
|
11
|
+
variant?: BannerVariant;
|
|
12
|
+
onDismiss?: () => void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const framedTones: Record<BannerTone, string> = {
|
|
16
|
+
info: "bg-accent-2/15 text-accent-2 border-accent-2/40",
|
|
17
|
+
success: "bg-positive/15 text-positive border-positive/40",
|
|
18
|
+
warning: "bg-warning/15 text-warning border-warning/40",
|
|
19
|
+
danger: "bg-danger/15 text-danger border-danger/40",
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const softTones: Record<BannerTone, string> = {
|
|
23
|
+
info: "bg-surface-sunken text-fg-muted",
|
|
24
|
+
success: "bg-positive/10 text-positive",
|
|
25
|
+
warning: "bg-warning/10 text-warning",
|
|
26
|
+
danger: "bg-danger-soft text-danger",
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const roles: Record<BannerTone, "status" | "alert"> = {
|
|
30
|
+
info: "status",
|
|
31
|
+
success: "status",
|
|
32
|
+
warning: "alert",
|
|
33
|
+
danger: "alert",
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export function Banner({
|
|
37
|
+
tone = "info",
|
|
38
|
+
variant = "framed",
|
|
39
|
+
onDismiss,
|
|
40
|
+
role,
|
|
41
|
+
className,
|
|
42
|
+
children,
|
|
43
|
+
...props
|
|
44
|
+
}: BannerProps) {
|
|
45
|
+
const soft = variant === "soft";
|
|
46
|
+
return (
|
|
47
|
+
<div
|
|
48
|
+
role={role ?? roles[tone]}
|
|
49
|
+
className={cn(
|
|
50
|
+
"flex gap-2 px-3 py-2 text-sm",
|
|
51
|
+
soft
|
|
52
|
+
? cn("items-start rounded-md", softTones[tone])
|
|
53
|
+
: cn("items-center border", framedTones[tone]),
|
|
54
|
+
className,
|
|
55
|
+
)}
|
|
56
|
+
{...props}
|
|
57
|
+
>
|
|
58
|
+
<div className="flex-1 min-w-0">{children}</div>
|
|
59
|
+
{onDismiss &&
|
|
60
|
+
(soft ? (
|
|
61
|
+
<Button
|
|
62
|
+
variant="ghost"
|
|
63
|
+
size="sm"
|
|
64
|
+
icon={<X className="size-3.5" />}
|
|
65
|
+
onClick={onDismiss}
|
|
66
|
+
aria-label="Dismiss"
|
|
67
|
+
className="-mr-1 shrink-0"
|
|
68
|
+
/>
|
|
69
|
+
) : (
|
|
70
|
+
<button
|
|
71
|
+
type="button"
|
|
72
|
+
onClick={onDismiss}
|
|
73
|
+
aria-label="Dismiss"
|
|
74
|
+
className="shrink-0 rounded-md p-1 hover:bg-fg/10 transition-colors"
|
|
75
|
+
>
|
|
76
|
+
<X className="size-4" aria-hidden="true" />
|
|
77
|
+
</button>
|
|
78
|
+
))}
|
|
79
|
+
</div>
|
|
80
|
+
);
|
|
81
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
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 { BottomSheet } from "./bottom-sheet.js";
|
|
6
|
+
|
|
7
|
+
const noop = () => {};
|
|
8
|
+
|
|
9
|
+
describe("BottomSheet", () => {
|
|
10
|
+
it("renders its children and a drag-to-dismiss grabber when open", () => {
|
|
11
|
+
const html = renderToString(
|
|
12
|
+
createElement(BottomSheet, {
|
|
13
|
+
open: true,
|
|
14
|
+
onClose: noop,
|
|
15
|
+
// biome-ignore lint/correctness/noChildrenProp: React 19 types require children in props object when using createElement
|
|
16
|
+
children: "Sheet body",
|
|
17
|
+
}),
|
|
18
|
+
);
|
|
19
|
+
assert.match(html, /Sheet body/);
|
|
20
|
+
assert.match(html, /role="slider"/);
|
|
21
|
+
assert.match(html, /Drag down to dismiss/);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("uses the provided dismiss label on the scrim", () => {
|
|
25
|
+
const html = renderToString(
|
|
26
|
+
createElement(BottomSheet, {
|
|
27
|
+
open: true,
|
|
28
|
+
onClose: noop,
|
|
29
|
+
dismissLabel: "Close rescue",
|
|
30
|
+
// biome-ignore lint/correctness/noChildrenProp: React 19 types require children in props object when using createElement
|
|
31
|
+
children: "x",
|
|
32
|
+
}),
|
|
33
|
+
);
|
|
34
|
+
assert.match(html, /aria-label="Close rescue"/);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("renders translated off-screen when closed", () => {
|
|
38
|
+
const html = renderToString(
|
|
39
|
+
createElement(BottomSheet, {
|
|
40
|
+
open: false,
|
|
41
|
+
onClose: noop,
|
|
42
|
+
// biome-ignore lint/correctness/noChildrenProp: React 19 types require children in props object when using createElement
|
|
43
|
+
children: "x",
|
|
44
|
+
}),
|
|
45
|
+
);
|
|
46
|
+
assert.match(html, /pointer-events-none/);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
import { BottomSheet } from "./bottom-sheet.js";
|
|
4
|
+
import { Button } from "./button.js";
|
|
5
|
+
|
|
6
|
+
const meta: Meta<typeof BottomSheet> = {
|
|
7
|
+
title: "Components/BottomSheet",
|
|
8
|
+
component: BottomSheet,
|
|
9
|
+
parameters: { layout: "fullscreen" },
|
|
10
|
+
decorators: [
|
|
11
|
+
(Story) => (
|
|
12
|
+
<div className="relative mx-auto h-dvh w-full shrink-0 overflow-hidden bg-surface sm:my-6 sm:h-[640px] sm:w-[390px] sm:rounded-[2rem] sm:border sm:border-line sm:shadow-sm">
|
|
13
|
+
<Story />
|
|
14
|
+
</div>
|
|
15
|
+
),
|
|
16
|
+
],
|
|
17
|
+
};
|
|
18
|
+
export default meta;
|
|
19
|
+
|
|
20
|
+
type Story = StoryObj<typeof BottomSheet>;
|
|
21
|
+
|
|
22
|
+
function Demo() {
|
|
23
|
+
const [open, setOpen] = useState(true);
|
|
24
|
+
return (
|
|
25
|
+
<div className="relative h-full overflow-hidden bg-surface">
|
|
26
|
+
<div className="divide-y divide-line opacity-50">
|
|
27
|
+
{Array.from({ length: 9 }).map((_, i) => (
|
|
28
|
+
<div
|
|
29
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: static skeleton rows
|
|
30
|
+
key={i}
|
|
31
|
+
className="flex items-start gap-3 px-row-inset py-2.5"
|
|
32
|
+
>
|
|
33
|
+
<div className="mt-0.5 size-7 shrink-0 rounded-full bg-surface-sunken" />
|
|
34
|
+
<div className="min-w-0 flex-1 space-y-1">
|
|
35
|
+
<div className="h-2.5 w-1/3 rounded bg-surface-sunken" />
|
|
36
|
+
<div className="h-2 w-2/3 rounded bg-surface-sunken" />
|
|
37
|
+
</div>
|
|
38
|
+
</div>
|
|
39
|
+
))}
|
|
40
|
+
</div>
|
|
41
|
+
{!open && (
|
|
42
|
+
<Button
|
|
43
|
+
variant="primary"
|
|
44
|
+
onClick={() => setOpen(true)}
|
|
45
|
+
className="absolute inset-x-0 bottom-0 m-3 h-11 font-semibold"
|
|
46
|
+
>
|
|
47
|
+
Open sheet
|
|
48
|
+
</Button>
|
|
49
|
+
)}
|
|
50
|
+
<BottomSheet open={open} onClose={() => setOpen(false)}>
|
|
51
|
+
<div className="px-row-inset py-6">
|
|
52
|
+
<h2 className="text-sm font-semibold text-fg">Action sheet</h2>
|
|
53
|
+
<p className="mt-1 text-xs text-fg-subtle">
|
|
54
|
+
Drag the grabber down or tap outside to dismiss.
|
|
55
|
+
</p>
|
|
56
|
+
<Button
|
|
57
|
+
variant="primary"
|
|
58
|
+
onClick={() => setOpen(false)}
|
|
59
|
+
className="mt-4 h-11 w-full font-semibold"
|
|
60
|
+
>
|
|
61
|
+
Got it
|
|
62
|
+
</Button>
|
|
63
|
+
</div>
|
|
64
|
+
</BottomSheet>
|
|
65
|
+
</div>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export const Default: Story = {
|
|
70
|
+
render: () => <Demo />,
|
|
71
|
+
};
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { useCallback, useLayoutEffect, useRef, useState } from "react";
|
|
3
|
+
import { cn } from "../lib/cn.js";
|
|
4
|
+
|
|
5
|
+
const SNAP_MS = 320;
|
|
6
|
+
const SNAP_EASE = "cubic-bezier(0.32, 0.9, 0.3, 1)";
|
|
7
|
+
const FLICK_VELOCITY = 0.5;
|
|
8
|
+
const DISMISS_FRACTION = 0.4;
|
|
9
|
+
const HEIGHT_FALLBACK = 360;
|
|
10
|
+
|
|
11
|
+
function rubberBand(overshoot: number): number {
|
|
12
|
+
return Math.sign(overshoot) * Math.sqrt(Math.abs(overshoot)) * 4;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface BottomSheetProps {
|
|
16
|
+
open: boolean;
|
|
17
|
+
onClose: () => void;
|
|
18
|
+
children: ReactNode;
|
|
19
|
+
/** Accessible label for the drag-to-dismiss scrim and grabber. */
|
|
20
|
+
dismissLabel?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Native-feeling action sheet that slides up from the bottom of its positioned
|
|
25
|
+
* container. Drag the grabber down (or flick) past ~40% to dismiss; tapping the
|
|
26
|
+
* scrim closes it too. Sits absolutely inside the nearest positioned ancestor,
|
|
27
|
+
* so wrap it in a `relative` container (e.g. a phone frame).
|
|
28
|
+
*/
|
|
29
|
+
export function BottomSheet({
|
|
30
|
+
open,
|
|
31
|
+
onClose,
|
|
32
|
+
children,
|
|
33
|
+
dismissLabel = "Dismiss",
|
|
34
|
+
}: BottomSheetProps) {
|
|
35
|
+
const sheetRef = useRef<HTMLDivElement>(null);
|
|
36
|
+
const [height, setHeight] = useState(HEIGHT_FALLBACK);
|
|
37
|
+
const [drag, setDrag] = useState<number | null>(null);
|
|
38
|
+
|
|
39
|
+
useLayoutEffect(() => {
|
|
40
|
+
const el = sheetRef.current;
|
|
41
|
+
if (!el) return;
|
|
42
|
+
const measure = () => setHeight(el.offsetHeight);
|
|
43
|
+
measure();
|
|
44
|
+
const ro = new ResizeObserver(measure);
|
|
45
|
+
ro.observe(el);
|
|
46
|
+
return () => ro.disconnect();
|
|
47
|
+
}, []);
|
|
48
|
+
|
|
49
|
+
const pointer = useRef<{
|
|
50
|
+
startY: number;
|
|
51
|
+
lastY: number;
|
|
52
|
+
lastT: number;
|
|
53
|
+
velocity: number;
|
|
54
|
+
moved: boolean;
|
|
55
|
+
} | null>(null);
|
|
56
|
+
|
|
57
|
+
const onPointerDown = useCallback((e: React.PointerEvent) => {
|
|
58
|
+
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
|
59
|
+
pointer.current = {
|
|
60
|
+
startY: e.clientY,
|
|
61
|
+
lastY: e.clientY,
|
|
62
|
+
lastT: e.timeStamp,
|
|
63
|
+
velocity: 0,
|
|
64
|
+
moved: false,
|
|
65
|
+
};
|
|
66
|
+
setDrag(0);
|
|
67
|
+
}, []);
|
|
68
|
+
|
|
69
|
+
const onPointerMove = useCallback((e: React.PointerEvent) => {
|
|
70
|
+
const p = pointer.current;
|
|
71
|
+
if (!p) return;
|
|
72
|
+
const dt = e.timeStamp - p.lastT;
|
|
73
|
+
if (dt > 0) p.velocity = (e.clientY - p.lastY) / dt;
|
|
74
|
+
p.lastY = e.clientY;
|
|
75
|
+
p.lastT = e.timeStamp;
|
|
76
|
+
|
|
77
|
+
const delta = e.clientY - p.startY;
|
|
78
|
+
if (Math.abs(delta) > 4) p.moved = true;
|
|
79
|
+
const next = delta < 0 ? rubberBand(delta) : delta;
|
|
80
|
+
setDrag(next);
|
|
81
|
+
}, []);
|
|
82
|
+
|
|
83
|
+
const finishDrag = useCallback(() => {
|
|
84
|
+
const p = pointer.current;
|
|
85
|
+
pointer.current = null;
|
|
86
|
+
if (!p) return;
|
|
87
|
+
const delta = p.lastY - p.startY;
|
|
88
|
+
const flick = p.velocity > FLICK_VELOCITY;
|
|
89
|
+
const dismiss = flick || delta > height * DISMISS_FRACTION;
|
|
90
|
+
setDrag(null);
|
|
91
|
+
if (dismiss) onClose();
|
|
92
|
+
}, [height, onClose]);
|
|
93
|
+
|
|
94
|
+
const dragging = drag !== null;
|
|
95
|
+
const offset = open ? (drag ?? 0) : height;
|
|
96
|
+
const openness = Math.min(Math.max(1 - offset / height, 0), 1);
|
|
97
|
+
const transition = dragging ? "none" : `transform ${SNAP_MS}ms ${SNAP_EASE}`;
|
|
98
|
+
|
|
99
|
+
return (
|
|
100
|
+
<div
|
|
101
|
+
className={cn(
|
|
102
|
+
"absolute inset-0 z-40 select-none overflow-hidden",
|
|
103
|
+
open || dragging ? "" : "pointer-events-none",
|
|
104
|
+
)}
|
|
105
|
+
>
|
|
106
|
+
<button
|
|
107
|
+
type="button"
|
|
108
|
+
aria-label={dismissLabel}
|
|
109
|
+
tabIndex={-1}
|
|
110
|
+
onClick={onClose}
|
|
111
|
+
className={cn(
|
|
112
|
+
"absolute inset-0 bg-black/40",
|
|
113
|
+
openness > 0 ? "pointer-events-auto" : "pointer-events-none",
|
|
114
|
+
)}
|
|
115
|
+
style={{
|
|
116
|
+
opacity: openness * 0.6,
|
|
117
|
+
transition: transition.replace("transform", "opacity"),
|
|
118
|
+
}}
|
|
119
|
+
/>
|
|
120
|
+
<div
|
|
121
|
+
ref={sheetRef}
|
|
122
|
+
className="absolute inset-x-0 bottom-0 flex max-h-[92%] flex-col rounded-t-3xl border-t border-line bg-surface shadow-2xl shadow-black/30"
|
|
123
|
+
style={{ transform: `translateY(${offset}px)`, transition }}
|
|
124
|
+
>
|
|
125
|
+
<div
|
|
126
|
+
role="slider"
|
|
127
|
+
aria-label="Drag down to dismiss"
|
|
128
|
+
aria-valuemin={0}
|
|
129
|
+
aria-valuemax={100}
|
|
130
|
+
aria-valuenow={Math.round(openness * 100)}
|
|
131
|
+
tabIndex={0}
|
|
132
|
+
onPointerDown={onPointerDown}
|
|
133
|
+
onPointerMove={onPointerMove}
|
|
134
|
+
onPointerUp={finishDrag}
|
|
135
|
+
onPointerCancel={finishDrag}
|
|
136
|
+
className="flex cursor-grab touch-none justify-center pb-1 pt-2.5 active:cursor-grabbing"
|
|
137
|
+
>
|
|
138
|
+
<div className="h-1 w-10 rounded-full bg-fg-subtle/40" />
|
|
139
|
+
</div>
|
|
140
|
+
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
|
141
|
+
{children}
|
|
142
|
+
</div>
|
|
143
|
+
</div>
|
|
144
|
+
</div>
|
|
145
|
+
);
|
|
146
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
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 type { ThreadRowData, ThreadSection } from "./app-shell-types.js";
|
|
6
|
+
import { BriefSection, SECTION_ROW_CAP } from "./brief-section.js";
|
|
7
|
+
import { ComfortableRow } from "./message-row.js";
|
|
8
|
+
|
|
9
|
+
function makeRow(i: number): ThreadRowData {
|
|
10
|
+
return {
|
|
11
|
+
id: `t${i}`,
|
|
12
|
+
accountId: "a1",
|
|
13
|
+
fromName: `Sender ${i}`,
|
|
14
|
+
fromEmail: `sender${i}@example.com`,
|
|
15
|
+
subject: `Subject ${i}`,
|
|
16
|
+
snippet: "Preview",
|
|
17
|
+
timeLabel: "9:00",
|
|
18
|
+
isRead: false,
|
|
19
|
+
category: "personal",
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function section(count: number): ThreadSection {
|
|
24
|
+
return {
|
|
25
|
+
id: "personal",
|
|
26
|
+
label: "Personal",
|
|
27
|
+
threads: Array.from({ length: count }, (_, i) => makeRow(i + 1)),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function render(props: {
|
|
32
|
+
count: number;
|
|
33
|
+
initialExpanded?: boolean;
|
|
34
|
+
initialCollapsed?: boolean;
|
|
35
|
+
}): string {
|
|
36
|
+
return renderToString(
|
|
37
|
+
createElement(BriefSection, {
|
|
38
|
+
section: section(props.count),
|
|
39
|
+
Row: ComfortableRow,
|
|
40
|
+
initialExpanded: props.initialExpanded,
|
|
41
|
+
initialCollapsed: props.initialCollapsed,
|
|
42
|
+
onSelectThread: () => undefined,
|
|
43
|
+
}),
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function rowCount(html: string): number {
|
|
48
|
+
return (html.match(/Subject \d+/g) ?? []).length;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
describe("BriefSection", () => {
|
|
52
|
+
it("renders the section label and a row count", () => {
|
|
53
|
+
const html = render({ count: 3 });
|
|
54
|
+
assert.match(html, /Personal/);
|
|
55
|
+
assert.match(html, />3</);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("shows every row and no expander below the cap", () => {
|
|
59
|
+
const html = render({ count: SECTION_ROW_CAP });
|
|
60
|
+
assert.strictEqual(rowCount(html), SECTION_ROW_CAP);
|
|
61
|
+
assert.doesNotMatch(html, /Show \d+ more/);
|
|
62
|
+
assert.doesNotMatch(html, /Show less/);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("caps at SECTION_ROW_CAP with a 'Show N more' control over the cap", () => {
|
|
66
|
+
const html = render({ count: 18 });
|
|
67
|
+
assert.strictEqual(rowCount(html), SECTION_ROW_CAP);
|
|
68
|
+
assert.match(html, new RegExp(`Show ${18 - SECTION_ROW_CAP} more`));
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("reveals every row and offers 'Show less' when expanded", () => {
|
|
72
|
+
const html = render({ count: 18, initialExpanded: true });
|
|
73
|
+
assert.strictEqual(rowCount(html), 18);
|
|
74
|
+
assert.match(html, /Show less/);
|
|
75
|
+
assert.doesNotMatch(html, /Show \d+ more/);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("renders rows by default — sections start expanded", () => {
|
|
79
|
+
const html = render({ count: 3 });
|
|
80
|
+
assert.strictEqual(rowCount(html), 3);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("shows only the header and count when initially collapsed", () => {
|
|
84
|
+
const html = render({ count: 18, initialCollapsed: true });
|
|
85
|
+
assert.match(html, /Personal/);
|
|
86
|
+
assert.match(html, />18</);
|
|
87
|
+
assert.strictEqual(rowCount(html), 0);
|
|
88
|
+
assert.doesNotMatch(html, /Show \d+ more/);
|
|
89
|
+
assert.doesNotMatch(html, /Show less/);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("makes the header a button with aria-expanded so it toggles the section", () => {
|
|
93
|
+
const expandedHtml = render({ count: 3 });
|
|
94
|
+
assert.match(expandedHtml, /<button[^>]*aria-expanded="true"/);
|
|
95
|
+
const collapsedHtml = render({ count: 3, initialCollapsed: true });
|
|
96
|
+
assert.match(collapsedHtml, /<button[^>]*aria-expanded="false"/);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import type { ThreadRowData, ThreadSection } from "./app-shell-types.js";
|
|
3
|
+
import { BriefSection } from "./brief-section.js";
|
|
4
|
+
import { ComfortableRow } from "./message-row.js";
|
|
5
|
+
|
|
6
|
+
function makeRow(i: number): ThreadRowData {
|
|
7
|
+
return {
|
|
8
|
+
id: `t${i}`,
|
|
9
|
+
accountId: "a1",
|
|
10
|
+
fromName: `Sender ${i}`,
|
|
11
|
+
fromEmail: `sender${i}@example.com`,
|
|
12
|
+
subject: `Subject line ${i}`,
|
|
13
|
+
snippet: "A short preview of the message body.",
|
|
14
|
+
timeLabel: `9:0${i % 10}`,
|
|
15
|
+
isRead: i % 2 === 0,
|
|
16
|
+
category: "personal",
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const shortSection: ThreadSection = {
|
|
21
|
+
id: "transactional",
|
|
22
|
+
label: "Transactional",
|
|
23
|
+
threads: Array.from({ length: 3 }, (_, i) => makeRow(i + 1)),
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const longSection: ThreadSection = {
|
|
27
|
+
id: "newsletter",
|
|
28
|
+
label: "Newsletter",
|
|
29
|
+
threads: Array.from({ length: 18 }, (_, i) => makeRow(i + 1)),
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const meta: Meta<typeof BriefSection> = {
|
|
33
|
+
title: "Screens/Kit/BriefSection",
|
|
34
|
+
component: BriefSection,
|
|
35
|
+
parameters: { layout: "fullscreen" },
|
|
36
|
+
args: {
|
|
37
|
+
Row: ComfortableRow,
|
|
38
|
+
onSelectThread: () => undefined,
|
|
39
|
+
},
|
|
40
|
+
render: (args) => (
|
|
41
|
+
<div className="flex h-screen w-96 flex-col border-r border-line">
|
|
42
|
+
<BriefSection {...args} />
|
|
43
|
+
</div>
|
|
44
|
+
),
|
|
45
|
+
};
|
|
46
|
+
export default meta;
|
|
47
|
+
|
|
48
|
+
type Story = StoryObj<typeof BriefSection>;
|
|
49
|
+
|
|
50
|
+
/** Fewer than the cap — no expander. */
|
|
51
|
+
export const Short: Story = {
|
|
52
|
+
args: { section: shortSection },
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/** Over the cap — shows the first 10 rows and a "Show N more" control. */
|
|
56
|
+
export const CollapsedAtCap: Story = {
|
|
57
|
+
args: { section: longSection },
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/** The same section after expanding — every row visible, "Show less" to collapse. */
|
|
61
|
+
export const Expanded: Story = {
|
|
62
|
+
args: { section: longSection, initialExpanded: true },
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/** Section collapsed by its header — only the label + count show, every row hidden. */
|
|
66
|
+
export const SectionCollapsed: Story = {
|
|
67
|
+
args: { section: longSection, initialCollapsed: true },
|
|
68
|
+
};
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { ChevronDown } from "lucide-react";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
import { cn } from "../lib/cn.js";
|
|
4
|
+
import type { ThreadSection } from "./app-shell-types.js";
|
|
5
|
+
import type { BriefRowComponent } from "./message-row.js";
|
|
6
|
+
|
|
7
|
+
/** Rows shown before the "Show N more" expander kicks in. */
|
|
8
|
+
export const SECTION_ROW_CAP = 10;
|
|
9
|
+
|
|
10
|
+
export interface BriefSectionProps {
|
|
11
|
+
section: ThreadSection;
|
|
12
|
+
Row: BriefRowComponent;
|
|
13
|
+
selectedThreadId?: string;
|
|
14
|
+
/** Seed the in-section "Show N more" state — lets a story / SSR render the expanded view. */
|
|
15
|
+
initialExpanded?: boolean;
|
|
16
|
+
/** Seed the section-collapse state — lets a story / SSR render the header-only view. */
|
|
17
|
+
initialCollapsed?: boolean;
|
|
18
|
+
onSelectThread?: (id: string) => void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* One brief section: a sticky category label, the first {@link SECTION_ROW_CAP}
|
|
23
|
+
* rows, and a "Show N more" control that reveals the rest in place (toggling
|
|
24
|
+
* back to "Show less"). The control is always tappable — it never disables.
|
|
25
|
+
*
|
|
26
|
+
* The header is itself a toggle: tapping it collapses the whole section to just
|
|
27
|
+
* the label + count (hiding every row and the "Show N more" control) and
|
|
28
|
+
* expands it again. Sections start expanded so the default brief render is
|
|
29
|
+
* unchanged.
|
|
30
|
+
*
|
|
31
|
+
* Owns only its own expand/collapse state; the parent supplies the grouped
|
|
32
|
+
* section and a `Row` renderer so the live brief and the Storybook prototype
|
|
33
|
+
* stay in lockstep.
|
|
34
|
+
*/
|
|
35
|
+
export function BriefSection({
|
|
36
|
+
section,
|
|
37
|
+
Row,
|
|
38
|
+
selectedThreadId,
|
|
39
|
+
initialExpanded = false,
|
|
40
|
+
initialCollapsed = false,
|
|
41
|
+
onSelectThread,
|
|
42
|
+
}: BriefSectionProps) {
|
|
43
|
+
const [expanded, setExpanded] = useState(initialExpanded);
|
|
44
|
+
const [collapsed, setCollapsed] = useState(initialCollapsed);
|
|
45
|
+
|
|
46
|
+
const overCap = section.threads.length > SECTION_ROW_CAP;
|
|
47
|
+
const capped = !expanded && overCap;
|
|
48
|
+
const visible = capped
|
|
49
|
+
? section.threads.slice(0, SECTION_ROW_CAP)
|
|
50
|
+
: section.threads;
|
|
51
|
+
const hiddenCount = section.threads.length - visible.length;
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<div>
|
|
55
|
+
{section.label && (
|
|
56
|
+
<button
|
|
57
|
+
type="button"
|
|
58
|
+
aria-expanded={!collapsed}
|
|
59
|
+
onClick={() => setCollapsed((v) => !v)}
|
|
60
|
+
className="sticky top-0 z-10 flex h-section-row w-full items-center gap-1.5 border-b border-line bg-surface-sunken px-row-inset text-left transition-colors hover:bg-surface"
|
|
61
|
+
>
|
|
62
|
+
<span className="flex-1 text-2xs font-semibold uppercase tracking-wider text-fg-subtle">
|
|
63
|
+
{section.label}
|
|
64
|
+
</span>
|
|
65
|
+
<span className="text-2xs text-fg-subtle tabular-nums">
|
|
66
|
+
{section.threads.length}
|
|
67
|
+
</span>
|
|
68
|
+
<ChevronDown
|
|
69
|
+
className={cn(
|
|
70
|
+
"size-3 shrink-0 text-fg-subtle transition-transform duration-200",
|
|
71
|
+
collapsed ? "rotate-0" : "rotate-180",
|
|
72
|
+
)}
|
|
73
|
+
/>
|
|
74
|
+
</button>
|
|
75
|
+
)}
|
|
76
|
+
{!collapsed && (
|
|
77
|
+
<>
|
|
78
|
+
<div className="divide-y divide-line">
|
|
79
|
+
{visible.map((thread) => (
|
|
80
|
+
<Row
|
|
81
|
+
key={thread.id}
|
|
82
|
+
thread={thread}
|
|
83
|
+
active={thread.id === selectedThreadId}
|
|
84
|
+
onClick={() => onSelectThread?.(thread.id)}
|
|
85
|
+
/>
|
|
86
|
+
))}
|
|
87
|
+
</div>
|
|
88
|
+
{overCap && (
|
|
89
|
+
<button
|
|
90
|
+
type="button"
|
|
91
|
+
onClick={() => setExpanded((v) => !v)}
|
|
92
|
+
className="flex w-full items-center justify-center border-b border-line px-row-inset py-1.5 text-2xs font-medium text-accent transition-colors hover:bg-surface"
|
|
93
|
+
>
|
|
94
|
+
{expanded ? "Show less" : `Show ${hiddenCount} more`}
|
|
95
|
+
{!expanded && <ChevronDown className="ml-1 size-3" />}
|
|
96
|
+
</button>
|
|
97
|
+
)}
|
|
98
|
+
</>
|
|
99
|
+
)}
|
|
100
|
+
</div>
|
|
101
|
+
);
|
|
102
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
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 type { BriefCategoryFilter, ThreadSection } from "./app-shell-types.js";
|
|
6
|
+
import { BriefSections } from "./brief-sections.js";
|
|
7
|
+
import { ComfortableRow } from "./message-row.js";
|
|
8
|
+
|
|
9
|
+
const sections: ThreadSection[] = [
|
|
10
|
+
{
|
|
11
|
+
id: "personal",
|
|
12
|
+
label: "Personal",
|
|
13
|
+
threads: [
|
|
14
|
+
{
|
|
15
|
+
id: "t1",
|
|
16
|
+
accountId: "a1",
|
|
17
|
+
fromName: "Priya Nair",
|
|
18
|
+
fromEmail: "priya@example.com",
|
|
19
|
+
subject: "Design review tomorrow",
|
|
20
|
+
snippet: "Can we move it to 2pm?",
|
|
21
|
+
timeLabel: "8:15",
|
|
22
|
+
isRead: false,
|
|
23
|
+
category: "personal",
|
|
24
|
+
},
|
|
25
|
+
],
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
id: "newsletter",
|
|
29
|
+
label: "Newsletter",
|
|
30
|
+
threads: [
|
|
31
|
+
{
|
|
32
|
+
id: "t2",
|
|
33
|
+
accountId: "a1",
|
|
34
|
+
fromName: "The Weekly Brief",
|
|
35
|
+
fromEmail: "hello@weekly.example",
|
|
36
|
+
subject: "This week in product",
|
|
37
|
+
snippet: "Five stories you missed.",
|
|
38
|
+
timeLabel: "Thu",
|
|
39
|
+
isRead: true,
|
|
40
|
+
category: "newsletter",
|
|
41
|
+
},
|
|
42
|
+
],
|
|
43
|
+
},
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
function render(briefCategory: BriefCategoryFilter) {
|
|
47
|
+
return renderToString(
|
|
48
|
+
createElement(BriefSections, {
|
|
49
|
+
sections,
|
|
50
|
+
Row: ComfortableRow,
|
|
51
|
+
briefCategory,
|
|
52
|
+
onSelectThread: () => undefined,
|
|
53
|
+
onSelectBriefCategory: () => undefined,
|
|
54
|
+
}),
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
describe("BriefSections", () => {
|
|
59
|
+
it("renders section labels and rows", () => {
|
|
60
|
+
const html = render("all");
|
|
61
|
+
assert.match(html, /Personal/);
|
|
62
|
+
assert.match(html, /Newsletter/);
|
|
63
|
+
assert.match(html, /Priya Nair/);
|
|
64
|
+
assert.match(html, /Weekly Brief/);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("filters rows by briefCategory", () => {
|
|
68
|
+
const html = render("newsletter");
|
|
69
|
+
assert.match(html, /Weekly Brief/);
|
|
70
|
+
assert.doesNotMatch(html, /Priya Nair/);
|
|
71
|
+
});
|
|
72
|
+
});
|