@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.
Files changed (188) hide show
  1. package/package.json +50 -0
  2. package/src/components/address-display.render.test.ts +93 -0
  3. package/src/components/address-display.stories.tsx +55 -0
  4. package/src/components/address-display.tsx +132 -0
  5. package/src/components/address-tag.render.test.ts +39 -0
  6. package/src/components/address-tag.stories.tsx +41 -0
  7. package/src/components/address-tag.tsx +38 -0
  8. package/src/components/app-password-hint.render.test.ts +23 -0
  9. package/src/components/app-password-hint.stories.tsx +19 -0
  10. package/src/components/app-password-hint.tsx +24 -0
  11. package/src/components/app-shell-slotted.render.test.ts +128 -0
  12. package/src/components/app-shell-slotted.tsx +267 -0
  13. package/src/components/app-shell-types.ts +328 -0
  14. package/src/components/app-shell.render.test.ts +415 -0
  15. package/src/components/app-shell.tsx +277 -0
  16. package/src/components/auth-card.render.test.ts +24 -0
  17. package/src/components/auth-card.stories.tsx +50 -0
  18. package/src/components/auth-card.tsx +32 -0
  19. package/src/components/auth-footer.render.test.ts +17 -0
  20. package/src/components/auth-footer.stories.tsx +23 -0
  21. package/src/components/auth-footer.tsx +14 -0
  22. package/src/components/auth-hero.render.test.ts +31 -0
  23. package/src/components/auth-hero.stories.tsx +19 -0
  24. package/src/components/auth-hero.tsx +41 -0
  25. package/src/components/auto-moved-badge.render.test.ts +42 -0
  26. package/src/components/auto-moved-badge.stories.tsx +48 -0
  27. package/src/components/auto-moved-badge.tsx +57 -0
  28. package/src/components/avatar.tsx +60 -0
  29. package/src/components/badge.tsx +49 -0
  30. package/src/components/banner.render.test.ts +58 -0
  31. package/src/components/banner.stories.tsx +92 -0
  32. package/src/components/banner.tsx +81 -0
  33. package/src/components/bottom-sheet.render.test.ts +48 -0
  34. package/src/components/bottom-sheet.stories.tsx +71 -0
  35. package/src/components/bottom-sheet.tsx +146 -0
  36. package/src/components/brief-section.render.test.ts +98 -0
  37. package/src/components/brief-section.stories.tsx +68 -0
  38. package/src/components/brief-section.tsx +102 -0
  39. package/src/components/brief-sections.render.test.ts +72 -0
  40. package/src/components/brief-sections.stories.tsx +178 -0
  41. package/src/components/brief-sections.tsx +201 -0
  42. package/src/components/button.tsx +48 -0
  43. package/src/components/card.tsx +48 -0
  44. package/src/components/category-badge.render.test.ts +40 -0
  45. package/src/components/category-badge.stories.tsx +37 -0
  46. package/src/components/category-badge.tsx +71 -0
  47. package/src/components/checkbox.render.test.ts +65 -0
  48. package/src/components/checkbox.stories.tsx +77 -0
  49. package/src/components/checkbox.tsx +78 -0
  50. package/src/components/compose-action-bar.render.test.ts +47 -0
  51. package/src/components/compose-action-bar.stories.tsx +35 -0
  52. package/src/components/compose-action-bar.tsx +95 -0
  53. package/src/components/compose-form-shell.render.test.ts +54 -0
  54. package/src/components/compose-form-shell.stories.tsx +96 -0
  55. package/src/components/compose-form-shell.tsx +49 -0
  56. package/src/components/danger-zone-section.tsx +36 -0
  57. package/src/components/dialog.render.test.ts +55 -0
  58. package/src/components/dialog.tsx +104 -0
  59. package/src/components/email-frame-css.ts +137 -0
  60. package/src/components/field-label.render.test.ts +16 -0
  61. package/src/components/field-label.tsx +19 -0
  62. package/src/components/filter-sheet.render.test.ts +76 -0
  63. package/src/components/filter-sheet.stories.tsx +146 -0
  64. package/src/components/filter-sheet.tsx +316 -0
  65. package/src/components/folder-role.tsx +66 -0
  66. package/src/components/input.tsx +44 -0
  67. package/src/components/intelligence-panel.stories.tsx +149 -0
  68. package/src/components/intelligence-panel.tsx +546 -0
  69. package/src/components/isolated-email-frame.render.test.ts +147 -0
  70. package/src/components/isolated-email-frame.stories.tsx +192 -0
  71. package/src/components/isolated-email-frame.tsx +265 -0
  72. package/src/components/kbd.tsx +17 -0
  73. package/src/components/keyboard-hint-bar.tsx +58 -0
  74. package/src/components/list-item.tsx +39 -0
  75. package/src/components/mail-action-toolbar.render.test.ts +43 -0
  76. package/src/components/mail-action-toolbar.tsx +180 -0
  77. package/src/components/mail-header.render.test.ts +69 -0
  78. package/src/components/mail-header.stories.tsx +233 -0
  79. package/src/components/mail-header.tsx +123 -0
  80. package/src/components/message-body-view.render.test.ts +59 -0
  81. package/src/components/message-body-view.stories.tsx +146 -0
  82. package/src/components/message-body-view.tsx +150 -0
  83. package/src/components/message-header.render.test.ts +58 -0
  84. package/src/components/message-header.stories.tsx +79 -0
  85. package/src/components/message-header.tsx +59 -0
  86. package/src/components/message-list-pane.render.test.ts +136 -0
  87. package/src/components/message-list-pane.stories.tsx +246 -0
  88. package/src/components/message-list-pane.tsx +248 -0
  89. package/src/components/message-list-state.render.test.ts +72 -0
  90. package/src/components/message-list-state.tsx +97 -0
  91. package/src/components/message-row.render.test.ts +48 -0
  92. package/src/components/message-row.stories.tsx +121 -0
  93. package/src/components/message-row.tsx +231 -0
  94. package/src/components/mobile-message-action-bar.render.test.ts +87 -0
  95. package/src/components/mobile-message-action-bar.stories.tsx +57 -0
  96. package/src/components/mobile-message-action-bar.tsx +175 -0
  97. package/src/components/mobile-reading-pane.render.test.ts +102 -0
  98. package/src/components/mobile-reading-pane.tsx +188 -0
  99. package/src/components/mobile-search-view.stories.tsx +247 -0
  100. package/src/components/mobile-search-view.tsx +100 -0
  101. package/src/components/move-mailbox-picker.render.test.ts +113 -0
  102. package/src/components/move-mailbox-picker.stories.tsx +76 -0
  103. package/src/components/move-mailbox-picker.tsx +324 -0
  104. package/src/components/nav-sidebar.render.test.ts +351 -0
  105. package/src/components/nav-sidebar.stories.tsx +216 -0
  106. package/src/components/nav-sidebar.tsx +625 -0
  107. package/src/components/outbox-row.render.test.ts +61 -0
  108. package/src/components/outbox-row.stories.tsx +53 -0
  109. package/src/components/outbox-row.tsx +120 -0
  110. package/src/components/outbox-status-badge.tsx +60 -0
  111. package/src/components/pane-layout.render.test.ts +105 -0
  112. package/src/components/popover-menu.render.test.ts +32 -0
  113. package/src/components/popover-menu.stories.tsx +57 -0
  114. package/src/components/popover-menu.tsx +110 -0
  115. package/src/components/primitives.stories.tsx +173 -0
  116. package/src/components/pull-to-refresh.render.test.ts +46 -0
  117. package/src/components/pull-to-refresh.stories.tsx +71 -0
  118. package/src/components/pull-to-refresh.tsx +54 -0
  119. package/src/components/quoted-text.render.test.ts +39 -0
  120. package/src/components/quoted-text.stories.tsx +67 -0
  121. package/src/components/quoted-text.tsx +67 -0
  122. package/src/components/reading-pane-empty.tsx +44 -0
  123. package/src/components/reading-pane.render.test.ts +148 -0
  124. package/src/components/reading-pane.stories.tsx +194 -0
  125. package/src/components/reading-pane.tsx +351 -0
  126. package/src/components/rescue-banner.render.test.ts +32 -0
  127. package/src/components/rescue-banner.stories.tsx +31 -0
  128. package/src/components/rescue-banner.tsx +50 -0
  129. package/src/components/rescue-candidate-row.render.test.ts +68 -0
  130. package/src/components/rescue-candidate-row.stories.tsx +108 -0
  131. package/src/components/rescue-candidate-row.tsx +94 -0
  132. package/src/components/rescue-from-spam-flow.render.test.ts +100 -0
  133. package/src/components/rescue-from-spam-flow.stories.tsx +92 -0
  134. package/src/components/rescue-from-spam-flow.tsx +342 -0
  135. package/src/components/resizable.tsx +55 -0
  136. package/src/components/role-appointment-list.render.test.ts +97 -0
  137. package/src/components/role-appointment-list.stories.tsx +138 -0
  138. package/src/components/role-appointment-list.tsx +227 -0
  139. package/src/components/row-actions.render.test.ts +83 -0
  140. package/src/components/row-actions.stories.tsx +87 -0
  141. package/src/components/row-actions.tsx +139 -0
  142. package/src/components/search-bar.render.test.ts +55 -0
  143. package/src/components/search-bar.stories.tsx +46 -0
  144. package/src/components/search-bar.tsx +118 -0
  145. package/src/components/search-result-row.tsx +139 -0
  146. package/src/components/search-results.render.test.ts +89 -0
  147. package/src/components/search-results.stories.tsx +126 -0
  148. package/src/components/search-results.tsx +215 -0
  149. package/src/components/search-token-chip.render.test.ts +42 -0
  150. package/src/components/search-token-chip.stories.tsx +42 -0
  151. package/src/components/search-token-chip.tsx +66 -0
  152. package/src/components/security-select.render.test.ts +33 -0
  153. package/src/components/security-select.tsx +53 -0
  154. package/src/components/segmented-control.tsx +70 -0
  155. package/src/components/select.tsx +33 -0
  156. package/src/components/selection-top-bar.render.test.ts +67 -0
  157. package/src/components/selection-top-bar.stories.tsx +39 -0
  158. package/src/components/selection-top-bar.tsx +95 -0
  159. package/src/components/sender-group-switch.tsx +63 -0
  160. package/src/components/sender-trust-indicator.render.test.ts +67 -0
  161. package/src/components/sender-trust-indicator.stories.tsx +58 -0
  162. package/src/components/sender-trust-indicator.tsx +87 -0
  163. package/src/components/settings-primitives.render.test.ts +160 -0
  164. package/src/components/settings-primitives.stories.tsx +80 -0
  165. package/src/components/settings-screen.tsx +354 -0
  166. package/src/components/swipeable-row.render.test.ts +106 -0
  167. package/src/components/swipeable-row.stories.tsx +117 -0
  168. package/src/components/swipeable-row.tsx +268 -0
  169. package/src/components/touch-list-body.render.test.ts +66 -0
  170. package/src/components/touch-list-body.stories.tsx +76 -0
  171. package/src/components/touch-list.tsx +87 -0
  172. package/src/components/wizard.render.test.ts +49 -0
  173. package/src/components/wizard.stories.tsx +113 -0
  174. package/src/components/wizard.tsx +321 -0
  175. package/src/filter-presets.test.ts +93 -0
  176. package/src/filter-presets.ts +101 -0
  177. package/src/index.ts +378 -0
  178. package/src/lib/cid-resolver.ts +35 -0
  179. package/src/lib/cn.ts +7 -0
  180. package/src/lib/email-layout-clamp.test.ts +109 -0
  181. package/src/lib/email-layout-clamp.ts +64 -0
  182. package/src/lib/email-render-treatment.test.ts +82 -0
  183. package/src/lib/email-render-treatment.ts +53 -0
  184. package/src/lib/email-sanitizer.test.ts +362 -0
  185. package/src/lib/email-sanitizer.ts +298 -0
  186. package/src/lib/row-keyboard.test.ts +41 -0
  187. package/src/lib/row-keyboard.ts +20 -0
  188. 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
+ });