@remit/ui 0.0.33 → 0.0.35

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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/ui",
3
- "version": "0.0.33",
3
+ "version": "0.0.35",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src"
@@ -12,7 +12,7 @@
12
12
  "scripts": {
13
13
  "test": "npm run test:typecheck && npm run test:run",
14
14
  "test:typecheck": "tsgo --noEmit",
15
- "test:run": "node --import tsx --experimental-test-coverage --test-coverage-include='src/**' --test-coverage-exclude='src/**/*.test.ts' --test-coverage-exclude='src/**/*.test.tsx' --test-coverage-lines=50 --test 'src/**/*.test.ts'"
15
+ "test:run": "node --import tsx --experimental-test-coverage --test-coverage-include='src/**' --test-coverage-exclude='src/**/*.test.ts' --test-coverage-exclude='src/**/*.test.tsx' --test-coverage-lines=70 --test 'src/**/*.test.ts'"
16
16
  },
17
17
  "dependencies": {
18
18
  "@fontsource-variable/geist": "^5",
@@ -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({ containerRef: listRef, itemSelector: LIST_ROW_SELECTOR });
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: React.MouseEvent) => void;
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
- type="button"
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
- </button>
250
+ </span>
234
251
  </span>
235
252
  );
236
253
  }
package/src/index.ts CHANGED
@@ -210,6 +210,7 @@ export {
210
210
  comfortableRowClass,
211
211
  compactRowClass,
212
212
  type RowSelection,
213
+ type RowToggleEvent,
213
214
  } from "./components/message-row.js";
214
215
  export {
215
216
  type MobileMessageAction,
@@ -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