@remit/ui 0.0.32 → 0.0.34
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/brief-sections.stories.tsx +40 -0
- package/src/components/brief-sections.tsx +17 -1
- package/src/components/message-row.render.test.ts +26 -0
- package/src/components/message-row.stories.tsx +32 -2
- package/src/components/message-row.tsx +106 -12
- package/src/index.ts +3 -0
- package/src/lib/roving-focus.ts +7 -0
package/package.json
CHANGED
|
@@ -176,3 +176,43 @@ export const AccountSources: Story = {
|
|
|
176
176
|
);
|
|
177
177
|
},
|
|
178
178
|
};
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* (d) Multi-select and the keyboard cursor in the brief. The rows are the same
|
|
182
|
+
* `Row` the mailbox list renders, so a checked row carries the checkbox and the
|
|
183
|
+
* selected tint, and the keyboard cursor shows its left accent rail on the row
|
|
184
|
+
* it sits on — one row implementation across the brief, Flagged and the inbox.
|
|
185
|
+
*/
|
|
186
|
+
export const Selection: Story = {
|
|
187
|
+
render: (args) => {
|
|
188
|
+
const [checked, setChecked] = useState<ReadonlySet<string>>(
|
|
189
|
+
new Set(["p1", "f1"]),
|
|
190
|
+
);
|
|
191
|
+
const toggle = (id: string) =>
|
|
192
|
+
setChecked((prev) => {
|
|
193
|
+
const next = new Set(prev);
|
|
194
|
+
if (next.has(id)) next.delete(id);
|
|
195
|
+
else next.add(id);
|
|
196
|
+
return next;
|
|
197
|
+
});
|
|
198
|
+
return (
|
|
199
|
+
<div className="flex h-screen w-96 flex-col border-r border-line">
|
|
200
|
+
<BriefSections
|
|
201
|
+
{...args}
|
|
202
|
+
Row={({ thread, active, onClick }) => (
|
|
203
|
+
<ComfortableRow
|
|
204
|
+
thread={thread}
|
|
205
|
+
active={active}
|
|
206
|
+
focused={thread.id === "p1"}
|
|
207
|
+
selection={{
|
|
208
|
+
checked: checked.has(thread.id),
|
|
209
|
+
onToggle: () => toggle(thread.id),
|
|
210
|
+
}}
|
|
211
|
+
onClick={onClick}
|
|
212
|
+
/>
|
|
213
|
+
)}
|
|
214
|
+
/>
|
|
215
|
+
</div>
|
|
216
|
+
);
|
|
217
|
+
},
|
|
218
|
+
};
|
|
@@ -75,6 +75,17 @@ export interface BriefSectionsProps {
|
|
|
75
75
|
onSelectSource?: (id: string) => void;
|
|
76
76
|
/** Seeds the filter panel open on first render (stories / deep links). */
|
|
77
77
|
defaultExpanded?: boolean;
|
|
78
|
+
/**
|
|
79
|
+
* Whether this component owns arrow-key traversal and the roving tabindex
|
|
80
|
+
* over its rows. On by default so a consumer that only passes rows (the
|
|
81
|
+
* Storybook prototype) still has a keyboard.
|
|
82
|
+
*
|
|
83
|
+
* The web client turns it off: its triage layer routes ↑/↓/Home/End through
|
|
84
|
+
* one window-level dispatcher, and a container handler here would swallow
|
|
85
|
+
* them first — taking Shift+↑/↓ with them, which the dispatcher needs for
|
|
86
|
+
* range selection and `rovingNextIndex` ignores.
|
|
87
|
+
*/
|
|
88
|
+
manageFocus?: boolean;
|
|
78
89
|
}
|
|
79
90
|
|
|
80
91
|
/**
|
|
@@ -96,11 +107,16 @@ export function BriefSections({
|
|
|
96
107
|
sourcesNote,
|
|
97
108
|
onSelectSource,
|
|
98
109
|
defaultExpanded = false,
|
|
110
|
+
manageFocus = true,
|
|
99
111
|
}: BriefSectionsProps) {
|
|
100
112
|
const [active, setActive] = useState<ReadonlySet<BriefFilterId>>(new Set());
|
|
101
113
|
const [sheetExpanded, setSheetExpanded] = useState(defaultExpanded);
|
|
102
114
|
const listRef = useRef<HTMLDivElement>(null);
|
|
103
|
-
useRovingFocus({
|
|
115
|
+
useRovingFocus({
|
|
116
|
+
containerRef: listRef,
|
|
117
|
+
itemSelector: LIST_ROW_SELECTOR,
|
|
118
|
+
enabled: manageFocus,
|
|
119
|
+
});
|
|
104
120
|
|
|
105
121
|
const toggleFilter = (id: BriefFilterId) => {
|
|
106
122
|
setActive((prev) => {
|
|
@@ -37,6 +37,32 @@ describe("ComfortableRow", () => {
|
|
|
37
37
|
});
|
|
38
38
|
});
|
|
39
39
|
|
|
40
|
+
describe("ComfortableRow selection slot", () => {
|
|
41
|
+
it("renders no checkbox without a selection (non-selectable mode)", () => {
|
|
42
|
+
const html = renderToString(
|
|
43
|
+
createElement(ComfortableRow, { thread: { ...base, isRead: true } }),
|
|
44
|
+
);
|
|
45
|
+
assert.doesNotMatch(html, /Select message/);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("renders a checkbox reflecting the checked state", () => {
|
|
49
|
+
const unchecked = renderToString(
|
|
50
|
+
createElement(ComfortableRow, {
|
|
51
|
+
thread: { ...base, isRead: true },
|
|
52
|
+
selection: { checked: false, onToggle: () => undefined },
|
|
53
|
+
}),
|
|
54
|
+
);
|
|
55
|
+
const checked = renderToString(
|
|
56
|
+
createElement(ComfortableRow, {
|
|
57
|
+
thread: { ...base, isRead: true },
|
|
58
|
+
selection: { checked: true, onToggle: () => undefined },
|
|
59
|
+
}),
|
|
60
|
+
);
|
|
61
|
+
assert.match(unchecked, /Select message/);
|
|
62
|
+
assert.match(checked, /Deselect message/);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
40
66
|
describe("CompactRow", () => {
|
|
41
67
|
it("renders fromName and subject", () => {
|
|
42
68
|
const html = renderToString(
|
|
@@ -112,10 +112,40 @@ export const Compact: Story = {
|
|
|
112
112
|
export const States: Story = {
|
|
113
113
|
render: () => (
|
|
114
114
|
<List>
|
|
115
|
-
<ComfortableRow thread={unread} />
|
|
116
|
-
<ComfortableRow thread={read} />
|
|
115
|
+
<ComfortableRow thread={unread} active />
|
|
116
|
+
<ComfortableRow thread={read} focused />
|
|
117
117
|
<ComfortableRow thread={starred} />
|
|
118
118
|
<ComfortableRow thread={suspicious} />
|
|
119
119
|
</List>
|
|
120
120
|
),
|
|
121
121
|
};
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Selectable rows. The checkbox layers over the avatar: hidden until hover
|
|
125
|
+
* while unchecked, pinned visible once checked or while the list is in
|
|
126
|
+
* multi-select mode. A row rendered without `selection` — the brief and
|
|
127
|
+
* Flagged before they gained selection — shows the avatar alone.
|
|
128
|
+
*/
|
|
129
|
+
export const Selectable: Story = {
|
|
130
|
+
render: () => (
|
|
131
|
+
<List>
|
|
132
|
+
<ComfortableRow
|
|
133
|
+
thread={unread}
|
|
134
|
+
selection={{ checked: true, onToggle: () => undefined }}
|
|
135
|
+
/>
|
|
136
|
+
<ComfortableRow
|
|
137
|
+
thread={read}
|
|
138
|
+
selection={{ checked: false, onToggle: () => undefined }}
|
|
139
|
+
/>
|
|
140
|
+
<ComfortableRow
|
|
141
|
+
thread={starred}
|
|
142
|
+
selection={{
|
|
143
|
+
checked: false,
|
|
144
|
+
alwaysVisible: true,
|
|
145
|
+
onToggle: () => undefined,
|
|
146
|
+
}}
|
|
147
|
+
/>
|
|
148
|
+
<ComfortableRow thread={withCategory} />
|
|
149
|
+
</List>
|
|
150
|
+
),
|
|
151
|
+
};
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { Paperclip, ShieldAlert, Star } from "lucide-react";
|
|
2
|
-
import type { ReactNode } from "react";
|
|
1
|
+
import { Check, Paperclip, ShieldAlert, Star } from "lucide-react";
|
|
2
|
+
import type { ComponentType, ReactNode, SyntheticEvent } from "react";
|
|
3
3
|
import { cn } from "../lib/cn.js";
|
|
4
4
|
import { LIST_ROW_ATTRIBUTE } from "../lib/roving-focus.js";
|
|
5
5
|
import { categoryTone, type ThreadRowData } from "./app-shell-types.js";
|
|
@@ -173,18 +173,106 @@ export function ComfortableRowTextContent({
|
|
|
173
173
|
}
|
|
174
174
|
|
|
175
175
|
/**
|
|
176
|
-
*
|
|
177
|
-
*
|
|
176
|
+
* Checkbox state for a selectable row. Absent on a row that cannot be
|
|
177
|
+
* multi-selected (the brief and Flagged before they gained selection), which
|
|
178
|
+
* renders the avatar alone.
|
|
178
179
|
*/
|
|
179
|
-
|
|
180
|
+
/** What the toggle handler needs off the click or keypress that triggered it. */
|
|
181
|
+
export type RowToggleEvent = Pick<
|
|
182
|
+
SyntheticEvent,
|
|
183
|
+
"preventDefault" | "stopPropagation"
|
|
184
|
+
>;
|
|
185
|
+
|
|
186
|
+
export interface RowSelection {
|
|
187
|
+
checked: boolean;
|
|
188
|
+
/** Keep the checkbox visible instead of revealing it on hover (mobile). */
|
|
189
|
+
alwaysVisible?: boolean;
|
|
190
|
+
onToggle: (event: RowToggleEvent) => void;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Leading slot of a comfortable row: the avatar, with a checkbox layered over
|
|
195
|
+
* it when the row is selectable. Fixed 28px so the row never reflows as the
|
|
196
|
+
* checkbox appears.
|
|
197
|
+
*/
|
|
198
|
+
export function ComfortableRowLeading({
|
|
199
|
+
thread,
|
|
200
|
+
selection,
|
|
201
|
+
}: {
|
|
202
|
+
thread: ThreadRowData;
|
|
203
|
+
selection?: RowSelection;
|
|
204
|
+
}) {
|
|
205
|
+
if (!selection) {
|
|
206
|
+
return <Avatar name={thread.fromName} email={thread.fromEmail} size="sm" />;
|
|
207
|
+
}
|
|
208
|
+
const { checked, alwaysVisible } = selection;
|
|
209
|
+
return (
|
|
210
|
+
<span className="relative size-7 shrink-0">
|
|
211
|
+
<Avatar
|
|
212
|
+
name={thread.fromName}
|
|
213
|
+
email={thread.fromEmail}
|
|
214
|
+
size="sm"
|
|
215
|
+
className={cn(
|
|
216
|
+
"absolute inset-0 transition-opacity sm:group-hover:opacity-0",
|
|
217
|
+
(checked || alwaysVisible) && "opacity-0",
|
|
218
|
+
)}
|
|
219
|
+
/>
|
|
220
|
+
{/* A span, not a <button>: the row itself is a button (the brief,
|
|
221
|
+
Flagged) or a link (the mailbox list), and neither may nest an
|
|
222
|
+
interactive element. The button role and label are what the user and
|
|
223
|
+
the test suite address, so both stay. */}
|
|
224
|
+
{/* biome-ignore lint/a11y/useSemanticElements: a nested <button> inside the row's own button/link is invalid HTML */}
|
|
225
|
+
<span
|
|
226
|
+
role="button"
|
|
227
|
+
// Out of the tab order: the row is the list's single tab stop, and this
|
|
228
|
+
// control is `opacity-0` until hover, so a tabbable one would put focus
|
|
229
|
+
// on something invisible on every row.
|
|
230
|
+
tabIndex={-1}
|
|
231
|
+
onClick={selection.onToggle}
|
|
232
|
+
onKeyDown={(event) => {
|
|
233
|
+
if (event.key !== "Enter" && event.key !== " ") return;
|
|
234
|
+
event.preventDefault();
|
|
235
|
+
event.stopPropagation();
|
|
236
|
+
selection.onToggle(event);
|
|
237
|
+
}}
|
|
238
|
+
className={cn(
|
|
239
|
+
"absolute inset-0 size-7 items-center justify-center rounded-full border transition-opacity",
|
|
240
|
+
alwaysVisible ? "flex" : "hidden sm:flex",
|
|
241
|
+
checked
|
|
242
|
+
? "bg-accent border-accent text-accent-fg opacity-100"
|
|
243
|
+
: alwaysVisible
|
|
244
|
+
? "border-fg-subtle/40 bg-canvas opacity-100"
|
|
245
|
+
: "border-fg-subtle/40 bg-canvas opacity-0 group-hover:opacity-100",
|
|
246
|
+
)}
|
|
247
|
+
aria-label={checked ? "Deselect message" : "Select message"}
|
|
248
|
+
>
|
|
249
|
+
{checked && <Check className="size-3" />}
|
|
250
|
+
</span>
|
|
251
|
+
</span>
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Full inner body of a comfortable row (unread dot + leading slot + text
|
|
257
|
+
* content). Place inside any wrapper element that uses `comfortableRowClass()`.
|
|
258
|
+
*/
|
|
259
|
+
export function ComfortableRowBody({
|
|
260
|
+
thread,
|
|
261
|
+
selection,
|
|
262
|
+
badge,
|
|
263
|
+
}: {
|
|
264
|
+
thread: ThreadRowData;
|
|
265
|
+
selection?: RowSelection;
|
|
266
|
+
badge?: ReactNode;
|
|
267
|
+
}) {
|
|
180
268
|
const unread = !thread.isRead;
|
|
181
269
|
return (
|
|
182
270
|
<>
|
|
183
271
|
{unread && (
|
|
184
272
|
<span className="absolute left-1.5 top-1/2 size-1.5 -translate-y-1/2 rounded-full bg-accent" />
|
|
185
273
|
)}
|
|
186
|
-
<
|
|
187
|
-
<ComfortableRowTextContent thread={thread} />
|
|
274
|
+
<ComfortableRowLeading thread={thread} selection={selection} />
|
|
275
|
+
<ComfortableRowTextContent thread={thread} badge={badge} />
|
|
188
276
|
</>
|
|
189
277
|
);
|
|
190
278
|
}
|
|
@@ -192,10 +280,12 @@ export function ComfortableRowBody({ thread }: { thread: ThreadRowData }) {
|
|
|
192
280
|
export function CompactRow({
|
|
193
281
|
thread,
|
|
194
282
|
active,
|
|
283
|
+
focused,
|
|
195
284
|
onClick,
|
|
196
285
|
}: {
|
|
197
286
|
thread: ThreadRowData;
|
|
198
287
|
active?: boolean;
|
|
288
|
+
focused?: boolean;
|
|
199
289
|
onClick?: () => void;
|
|
200
290
|
}) {
|
|
201
291
|
return (
|
|
@@ -203,7 +293,7 @@ export function CompactRow({
|
|
|
203
293
|
type="button"
|
|
204
294
|
{...LIST_ROW_ATTRIBUTE}
|
|
205
295
|
onClick={onClick}
|
|
206
|
-
className={compactRowClass({ active })}
|
|
296
|
+
className={compactRowClass({ active, focused })}
|
|
207
297
|
>
|
|
208
298
|
<CompactRowBody thread={thread} />
|
|
209
299
|
</button>
|
|
@@ -213,10 +303,14 @@ export function CompactRow({
|
|
|
213
303
|
export function ComfortableRow({
|
|
214
304
|
thread,
|
|
215
305
|
active,
|
|
306
|
+
focused,
|
|
307
|
+
selection,
|
|
216
308
|
onClick,
|
|
217
309
|
}: {
|
|
218
310
|
thread: ThreadRowData;
|
|
219
311
|
active?: boolean;
|
|
312
|
+
focused?: boolean;
|
|
313
|
+
selection?: RowSelection;
|
|
220
314
|
onClick?: () => void;
|
|
221
315
|
}) {
|
|
222
316
|
return (
|
|
@@ -224,17 +318,17 @@ export function ComfortableRow({
|
|
|
224
318
|
type="button"
|
|
225
319
|
{...LIST_ROW_ATTRIBUTE}
|
|
226
320
|
onClick={onClick}
|
|
227
|
-
className={comfortableRowClass({ active })}
|
|
321
|
+
className={cn("group", comfortableRowClass({ active, focused }))}
|
|
228
322
|
>
|
|
229
|
-
<ComfortableRowBody thread={thread} />
|
|
323
|
+
<ComfortableRowBody thread={thread} selection={selection} />
|
|
230
324
|
</button>
|
|
231
325
|
);
|
|
232
326
|
}
|
|
233
327
|
|
|
234
328
|
/** A row renderer the brief drives — Comfortable/Compact rows or a consumer's
|
|
235
329
|
* own (e.g. the web client's navigation-aware row) all satisfy this shape. */
|
|
236
|
-
export type BriefRowComponent =
|
|
330
|
+
export type BriefRowComponent = ComponentType<{
|
|
237
331
|
thread: ThreadRowData;
|
|
238
332
|
active?: boolean;
|
|
239
333
|
onClick?: () => void;
|
|
240
|
-
}
|
|
334
|
+
}>;
|
package/src/index.ts
CHANGED
|
@@ -203,11 +203,14 @@ export {
|
|
|
203
203
|
type BriefRowComponent,
|
|
204
204
|
ComfortableRow,
|
|
205
205
|
ComfortableRowBody,
|
|
206
|
+
ComfortableRowLeading,
|
|
206
207
|
ComfortableRowTextContent,
|
|
207
208
|
CompactRow,
|
|
208
209
|
CompactRowBody,
|
|
209
210
|
comfortableRowClass,
|
|
210
211
|
compactRowClass,
|
|
212
|
+
type RowSelection,
|
|
213
|
+
type RowToggleEvent,
|
|
211
214
|
} from "./components/message-row.js";
|
|
212
215
|
export {
|
|
213
216
|
type MobileMessageAction,
|
package/src/lib/roving-focus.ts
CHANGED
|
@@ -43,6 +43,11 @@ export interface UseRovingFocusOptions {
|
|
|
43
43
|
/** CSS selector, scoped to the container, matching every roving item. */
|
|
44
44
|
itemSelector: string;
|
|
45
45
|
orientation?: RovingOrientation;
|
|
46
|
+
/**
|
|
47
|
+
* Off when a keyboard layer above the group owns the same keys — the hook
|
|
48
|
+
* binds nothing and leaves the tabindex to whoever does. Defaults on.
|
|
49
|
+
*/
|
|
50
|
+
enabled?: boolean;
|
|
46
51
|
}
|
|
47
52
|
|
|
48
53
|
function rovingItems(
|
|
@@ -70,10 +75,12 @@ export function useRovingFocus({
|
|
|
70
75
|
containerRef,
|
|
71
76
|
itemSelector,
|
|
72
77
|
orientation = "vertical",
|
|
78
|
+
enabled = true,
|
|
73
79
|
}: UseRovingFocusOptions): void {
|
|
74
80
|
// No dependency array: items appear and disappear as sections expand, rows
|
|
75
81
|
// load, or filters change, none of which this hook's own inputs describe.
|
|
76
82
|
useEffect(() => {
|
|
83
|
+
if (!enabled) return;
|
|
77
84
|
const container = containerRef.current;
|
|
78
85
|
if (!container) return;
|
|
79
86
|
|