@remit/ui 0.0.33 → 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
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) => {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Check, Paperclip, ShieldAlert, Star } from "lucide-react";
|
|
2
|
-
import type { ComponentType, ReactNode } from "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";
|
|
@@ -177,11 +177,17 @@ export function ComfortableRowTextContent({
|
|
|
177
177
|
* multi-selected (the brief and Flagged before they gained selection), which
|
|
178
178
|
* renders the avatar alone.
|
|
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
|
+
|
|
180
186
|
export interface RowSelection {
|
|
181
187
|
checked: boolean;
|
|
182
188
|
/** Keep the checkbox visible instead of revealing it on hover (mobile). */
|
|
183
189
|
alwaysVisible?: boolean;
|
|
184
|
-
onToggle: (event:
|
|
190
|
+
onToggle: (event: RowToggleEvent) => void;
|
|
185
191
|
}
|
|
186
192
|
|
|
187
193
|
/**
|
|
@@ -211,13 +217,24 @@ export function ComfortableRowLeading({
|
|
|
211
217
|
(checked || alwaysVisible) && "opacity-0",
|
|
212
218
|
)}
|
|
213
219
|
/>
|
|
214
|
-
<button
|
|
215
|
-
|
|
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"
|
|
216
227
|
// Out of the tab order: the row is the list's single tab stop, and this
|
|
217
228
|
// control is `opacity-0` until hover, so a tabbable one would put focus
|
|
218
229
|
// on something invisible on every row.
|
|
219
230
|
tabIndex={-1}
|
|
220
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
|
+
}}
|
|
221
238
|
className={cn(
|
|
222
239
|
"absolute inset-0 size-7 items-center justify-center rounded-full border transition-opacity",
|
|
223
240
|
alwaysVisible ? "flex" : "hidden sm:flex",
|
|
@@ -230,7 +247,7 @@ export function ComfortableRowLeading({
|
|
|
230
247
|
aria-label={checked ? "Deselect message" : "Select message"}
|
|
231
248
|
>
|
|
232
249
|
{checked && <Check className="size-3" />}
|
|
233
|
-
</
|
|
250
|
+
</span>
|
|
234
251
|
</span>
|
|
235
252
|
);
|
|
236
253
|
}
|
package/src/index.ts
CHANGED
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
|
|