@remit/ui 0.0.4 → 0.0.6
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/app-shell-types.ts +3 -0
- package/src/components/category-badge.tsx +7 -0
- package/src/components/slide-panel.render.test.ts +85 -0
- package/src/components/slide-panel.stories.tsx +129 -0
- package/src/components/slide-panel.tsx +92 -0
- package/src/index.ts +4 -0
- package/src/lib/email-render-treatment.ts +1 -0
package/package.json
CHANGED
|
@@ -161,6 +161,7 @@ export interface NavLinkRenderProps {
|
|
|
161
161
|
export type NavLinkComponent = (props: NavLinkRenderProps) => ReactElement;
|
|
162
162
|
|
|
163
163
|
export type ThreadCategory =
|
|
164
|
+
| "uncategorized"
|
|
164
165
|
| "personal"
|
|
165
166
|
| "newsletter"
|
|
166
167
|
| "marketing"
|
|
@@ -182,6 +183,7 @@ export const briefCategories: ReadonlyArray<{
|
|
|
182
183
|
}> = [
|
|
183
184
|
{ id: "all", label: "All" },
|
|
184
185
|
{ id: "personal", label: "Personal" },
|
|
186
|
+
{ id: "uncategorized", label: "Unclassified" },
|
|
185
187
|
{ id: "newsletter", label: "Newsletters" },
|
|
186
188
|
{ id: "marketing", label: "Marketing" },
|
|
187
189
|
{ id: "automated", label: "Automated" },
|
|
@@ -319,6 +321,7 @@ export const categoryTone: Record<
|
|
|
319
321
|
ThreadCategory,
|
|
320
322
|
"neutral" | "accent" | "positive" | "warning"
|
|
321
323
|
> = {
|
|
324
|
+
uncategorized: "neutral",
|
|
322
325
|
personal: "accent",
|
|
323
326
|
newsletter: "neutral",
|
|
324
327
|
marketing: "neutral",
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { cn } from "../lib/cn.js";
|
|
2
2
|
|
|
3
3
|
export type MessageCategory =
|
|
4
|
+
| "uncategorized"
|
|
4
5
|
| "personal"
|
|
5
6
|
| "newsletter"
|
|
6
7
|
| "marketing"
|
|
@@ -15,8 +16,14 @@ export type MessageCategory =
|
|
|
15
16
|
* so it has no entry here. `transactional` shows as "receipt" and `automated`
|
|
16
17
|
* shows as "notification" — wording chosen so the badge reads naturally next
|
|
17
18
|
* to a subject line.
|
|
19
|
+
*
|
|
20
|
+
* `uncategorized` does render a badge. It used to be displayed as `personal`,
|
|
21
|
+
* which made "the classifier never ran on this message" indistinguishable from
|
|
22
|
+
* "the classifier decided this is a person writing to you" — a classification
|
|
23
|
+
* gap presented as a full personal inbox (issue #45).
|
|
18
24
|
*/
|
|
19
25
|
const CATEGORY_LABELS: Record<Exclude<MessageCategory, "personal">, string> = {
|
|
26
|
+
uncategorized: "unclassified",
|
|
20
27
|
newsletter: "newsletter",
|
|
21
28
|
marketing: "marketing",
|
|
22
29
|
automated: "notification",
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import { createElement } from "react";
|
|
4
|
+
import { renderToStaticMarkup } from "react-dom/server";
|
|
5
|
+
import { SlidePanel, type SlidePanelProps } from "./slide-panel.js";
|
|
6
|
+
|
|
7
|
+
const render = (isOpen: boolean) =>
|
|
8
|
+
renderToStaticMarkup(
|
|
9
|
+
createElement(
|
|
10
|
+
SlidePanel,
|
|
11
|
+
// createElement never folds the children argument into the props type,
|
|
12
|
+
// so a component with required children needs the props cast.
|
|
13
|
+
{
|
|
14
|
+
isOpen,
|
|
15
|
+
onClose: () => undefined,
|
|
16
|
+
title: "Add Account",
|
|
17
|
+
} as SlidePanelProps,
|
|
18
|
+
"panel body",
|
|
19
|
+
),
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
/** The panel element itself, as distinct from the scrim behind it. */
|
|
23
|
+
const dialog = (html: string): string => {
|
|
24
|
+
const match = html.match(/<div[^>]*role="dialog"[^>]*>/);
|
|
25
|
+
assert.ok(match, "no dialog element rendered");
|
|
26
|
+
return match[0];
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/** The click-to-dismiss scrim: the first element, before the dialog. */
|
|
30
|
+
const scrim = (html: string): string => {
|
|
31
|
+
const match = html.match(/^<div[^>]*>/);
|
|
32
|
+
assert.ok(match, "no scrim element rendered");
|
|
33
|
+
return match[0];
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
describe("SlidePanel (#57)", () => {
|
|
37
|
+
it("is a fixed right-edge column, never a full-viewport takeover above sm", () => {
|
|
38
|
+
const html = render(true);
|
|
39
|
+
assert.match(html, /fixed top-0 right-0/);
|
|
40
|
+
assert.match(html, /sm:w-\[400px\]/);
|
|
41
|
+
assert.match(html, /translate-x-0/);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("a closed panel is off-canvas and takes no pointer events", () => {
|
|
45
|
+
const html = render(false);
|
|
46
|
+
assert.match(html, /translate-x-full/);
|
|
47
|
+
assert.match(html, /pointer-events-none/);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("a closed panel is inert and hidden from assistive tech", () => {
|
|
51
|
+
const tag = dialog(render(false));
|
|
52
|
+
assert.match(tag, /inert=""/);
|
|
53
|
+
assert.match(tag, /aria-hidden="true"/);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("an open panel is reachable", () => {
|
|
57
|
+
const tag = dialog(render(true));
|
|
58
|
+
assert.doesNotMatch(tag, /inert=""/);
|
|
59
|
+
assert.match(tag, /aria-hidden="false"/);
|
|
60
|
+
assert.match(tag, /role="dialog"/);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("scrolls its body rather than the page", () => {
|
|
64
|
+
const html = render(true);
|
|
65
|
+
assert.match(html, /overflow-auto/);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The scrim is a pointer shortcut for the header's Close button, not a
|
|
70
|
+
* control of its own: posing as a focusable button while answering only
|
|
71
|
+
* Escape strands a keyboard user on a thing that looks activatable.
|
|
72
|
+
*/
|
|
73
|
+
it("the scrim never poses as a focusable control", () => {
|
|
74
|
+
for (const html of [render(true), render(false)]) {
|
|
75
|
+
const tag = scrim(html);
|
|
76
|
+
assert.doesNotMatch(tag, /role="button"/);
|
|
77
|
+
assert.doesNotMatch(tag, /tabindex=/);
|
|
78
|
+
assert.match(tag, /aria-hidden="true"/);
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("always offers a labelled close control in the header", () => {
|
|
83
|
+
assert.match(render(true), /aria-label="Close"/);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
import { Button } from "./button.js";
|
|
4
|
+
import { FieldLabel } from "./field-label.js";
|
|
5
|
+
import { Input } from "./input.js";
|
|
6
|
+
import { SlidePanel } from "./slide-panel.js";
|
|
7
|
+
|
|
8
|
+
const meta: Meta<typeof SlidePanel> = {
|
|
9
|
+
title: "Components/SlidePanel",
|
|
10
|
+
component: SlidePanel,
|
|
11
|
+
parameters: { layout: "fullscreen" },
|
|
12
|
+
};
|
|
13
|
+
export default meta;
|
|
14
|
+
|
|
15
|
+
type Story = StoryObj<typeof SlidePanel>;
|
|
16
|
+
|
|
17
|
+
const backdropRows = Array.from({ length: 12 }, (_, i) => `Row ${i + 1}`);
|
|
18
|
+
|
|
19
|
+
const Backdrop = () => (
|
|
20
|
+
<div className="h-dvh space-y-3 bg-canvas p-6">
|
|
21
|
+
<h1 className="text-md font-semibold text-fg">Screen behind the panel</h1>
|
|
22
|
+
{backdropRows.map((row) => (
|
|
23
|
+
<div
|
|
24
|
+
key={row}
|
|
25
|
+
className="rounded-sm border border-line bg-surface px-4 py-3 text-sm text-fg-muted"
|
|
26
|
+
>
|
|
27
|
+
{row}
|
|
28
|
+
</div>
|
|
29
|
+
))}
|
|
30
|
+
</div>
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
const Body = () => (
|
|
34
|
+
<div className="space-y-4">
|
|
35
|
+
<div>
|
|
36
|
+
<FieldLabel htmlFor="slide-panel-email">Email address</FieldLabel>
|
|
37
|
+
<Input id="slide-panel-email" placeholder="alice@example.com" />
|
|
38
|
+
</div>
|
|
39
|
+
<div>
|
|
40
|
+
<FieldLabel htmlFor="slide-panel-name">Display name</FieldLabel>
|
|
41
|
+
<Input id="slide-panel-name" placeholder="Alice" />
|
|
42
|
+
</div>
|
|
43
|
+
</div>
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
const Footer = ({ onClose }: { onClose: () => void }) => (
|
|
47
|
+
<>
|
|
48
|
+
<Button variant="secondary" size="sm" onClick={onClose}>
|
|
49
|
+
Cancel
|
|
50
|
+
</Button>
|
|
51
|
+
<Button variant="primary" size="sm">
|
|
52
|
+
Save
|
|
53
|
+
</Button>
|
|
54
|
+
</>
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
/** Open: a fixed-width column at the right edge, the screen behind it dimmed. */
|
|
58
|
+
export const Open: Story = {
|
|
59
|
+
globals: { viewport: { value: "desktop" } },
|
|
60
|
+
render: () => (
|
|
61
|
+
<>
|
|
62
|
+
<Backdrop />
|
|
63
|
+
<SlidePanel isOpen onClose={() => {}} title="Add Account" footer={null}>
|
|
64
|
+
<Body />
|
|
65
|
+
</SlidePanel>
|
|
66
|
+
</>
|
|
67
|
+
),
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Closed. The panel stays mounted so it can animate, so this is the state that
|
|
72
|
+
* has to be provably invisible: a closed panel that is not pushed off-canvas
|
|
73
|
+
* takes over the whole screen (#57).
|
|
74
|
+
*/
|
|
75
|
+
export const Closed: Story = {
|
|
76
|
+
globals: { viewport: { value: "desktop" } },
|
|
77
|
+
render: () => (
|
|
78
|
+
<>
|
|
79
|
+
<Backdrop />
|
|
80
|
+
<SlidePanel
|
|
81
|
+
isOpen={false}
|
|
82
|
+
onClose={() => {}}
|
|
83
|
+
title="Add Account"
|
|
84
|
+
footer={null}
|
|
85
|
+
>
|
|
86
|
+
<Body />
|
|
87
|
+
</SlidePanel>
|
|
88
|
+
</>
|
|
89
|
+
),
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
/** On a phone the panel owns the full width. */
|
|
93
|
+
export const Phone: Story = {
|
|
94
|
+
globals: { viewport: { value: "mobile" } },
|
|
95
|
+
render: () => (
|
|
96
|
+
<>
|
|
97
|
+
<Backdrop />
|
|
98
|
+
<SlidePanel isOpen onClose={() => {}} title="Add Account" footer={null}>
|
|
99
|
+
<Body />
|
|
100
|
+
</SlidePanel>
|
|
101
|
+
</>
|
|
102
|
+
),
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
/** Opening and closing from the screen behind it. */
|
|
106
|
+
export const Interactive: Story = {
|
|
107
|
+
globals: { viewport: { value: "desktop" } },
|
|
108
|
+
render: function Render() {
|
|
109
|
+
const [open, setOpen] = useState(false);
|
|
110
|
+
return (
|
|
111
|
+
<>
|
|
112
|
+
<div className="h-dvh space-y-3 bg-canvas p-6">
|
|
113
|
+
<Button variant="primary" size="sm" onClick={() => setOpen(true)}>
|
|
114
|
+
Add account
|
|
115
|
+
</Button>
|
|
116
|
+
<Backdrop />
|
|
117
|
+
</div>
|
|
118
|
+
<SlidePanel
|
|
119
|
+
isOpen={open}
|
|
120
|
+
onClose={() => setOpen(false)}
|
|
121
|
+
title="Add Account"
|
|
122
|
+
footer={<Footer onClose={() => setOpen(false)} />}
|
|
123
|
+
>
|
|
124
|
+
<Body />
|
|
125
|
+
</SlidePanel>
|
|
126
|
+
</>
|
|
127
|
+
);
|
|
128
|
+
},
|
|
129
|
+
};
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { X } from "lucide-react";
|
|
2
|
+
import { type ReactNode, useEffect } from "react";
|
|
3
|
+
import { cn } from "../lib/cn.js";
|
|
4
|
+
|
|
5
|
+
/* ------------------------------------------------------------------ */
|
|
6
|
+
/* SlidePanel: right-edge slide-over for a focused sub-task (editing */
|
|
7
|
+
/* an account) without leaving the screen behind it. Full width on */
|
|
8
|
+
/* phones, a fixed-width column from `sm` up. */
|
|
9
|
+
/* */
|
|
10
|
+
/* A closed panel stays mounted so it can animate, so it must be inert */
|
|
11
|
+
/* in every sense that is not visual: no pointer events, out of the */
|
|
12
|
+
/* tab order, hidden from assistive technology. */
|
|
13
|
+
/* ------------------------------------------------------------------ */
|
|
14
|
+
|
|
15
|
+
export interface SlidePanelProps {
|
|
16
|
+
isOpen: boolean;
|
|
17
|
+
onClose: () => void;
|
|
18
|
+
title: string;
|
|
19
|
+
children: ReactNode;
|
|
20
|
+
footer?: ReactNode;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function SlidePanel({
|
|
24
|
+
isOpen,
|
|
25
|
+
onClose,
|
|
26
|
+
title,
|
|
27
|
+
children,
|
|
28
|
+
footer,
|
|
29
|
+
}: SlidePanelProps) {
|
|
30
|
+
// Escape closes the panel from anywhere inside it, which is what a dialog
|
|
31
|
+
// owes the keyboard. The scrim is a pointer affordance only.
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
if (!isOpen) return;
|
|
34
|
+
const onKeyDown = (event: KeyboardEvent) => {
|
|
35
|
+
if (event.key === "Escape") onClose();
|
|
36
|
+
};
|
|
37
|
+
document.addEventListener("keydown", onKeyDown);
|
|
38
|
+
return () => document.removeEventListener("keydown", onKeyDown);
|
|
39
|
+
}, [isOpen, onClose]);
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<>
|
|
43
|
+
{/* Click-to-dismiss scrim: a pointer shortcut for the header's Close
|
|
44
|
+
button, never the only way out, so it stays out of the tab order and
|
|
45
|
+
the a11y tree rather than posing as a control. */}
|
|
46
|
+
<div
|
|
47
|
+
className={cn(
|
|
48
|
+
"fixed inset-0 z-40 bg-black/30 transition-opacity",
|
|
49
|
+
isOpen ? "opacity-100" : "pointer-events-none opacity-0",
|
|
50
|
+
)}
|
|
51
|
+
onClick={onClose}
|
|
52
|
+
aria-hidden="true"
|
|
53
|
+
/>
|
|
54
|
+
|
|
55
|
+
<div
|
|
56
|
+
className={cn(
|
|
57
|
+
"fixed top-0 right-0 z-50 h-full w-full border-l border-line bg-canvas shadow-xl sm:w-[400px] sm:max-w-[90vw]",
|
|
58
|
+
"transform transition-transform duration-200 ease-out",
|
|
59
|
+
isOpen ? "translate-x-0" : "pointer-events-none translate-x-full",
|
|
60
|
+
)}
|
|
61
|
+
role="dialog"
|
|
62
|
+
aria-modal="true"
|
|
63
|
+
aria-hidden={!isOpen}
|
|
64
|
+
inert={!isOpen}
|
|
65
|
+
aria-labelledby="slide-panel-title"
|
|
66
|
+
>
|
|
67
|
+
<div className="flex h-14 items-center justify-between border-b border-line px-4">
|
|
68
|
+
<h2 id="slide-panel-title" className="font-semibold">
|
|
69
|
+
{title}
|
|
70
|
+
</h2>
|
|
71
|
+
<button
|
|
72
|
+
type="button"
|
|
73
|
+
onClick={onClose}
|
|
74
|
+
className="rounded-md p-1.5 transition-colors hover:bg-surface-raised"
|
|
75
|
+
aria-label="Close"
|
|
76
|
+
>
|
|
77
|
+
<X className="size-5" />
|
|
78
|
+
</button>
|
|
79
|
+
</div>
|
|
80
|
+
|
|
81
|
+
<div className="flex h-[calc(100%-3.5rem)] flex-col">
|
|
82
|
+
<div className="flex-1 overflow-auto p-4">{children}</div>
|
|
83
|
+
{footer && (
|
|
84
|
+
<div className="flex justify-end gap-3 border-t border-line bg-canvas p-4">
|
|
85
|
+
{footer}
|
|
86
|
+
</div>
|
|
87
|
+
)}
|
|
88
|
+
</div>
|
|
89
|
+
</div>
|
|
90
|
+
</>
|
|
91
|
+
);
|
|
92
|
+
}
|
package/src/index.ts
CHANGED