@remit/ui 0.0.92 → 0.0.93

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.92",
3
+ "version": "0.0.93",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src"
@@ -104,6 +104,21 @@ export interface MessageListKeyboard {
104
104
  ref: (element: HTMLElement | null) => void;
105
105
  }
106
106
 
107
+ /**
108
+ * Whether a layer answers the cursor keys, and so walks the rows itself. A list
109
+ * under one stands its own roving-focus group down: both own the arrows, and
110
+ * the group stops the press before the layer above hears it.
111
+ */
112
+ export function keyboardWalksRows(
113
+ keyboard: MessageListKeyboard | undefined,
114
+ ): keyboard is MessageListKeyboard {
115
+ if (keyboard === undefined) return false;
116
+ return (
117
+ keyboard.handlers.focusNext !== undefined &&
118
+ keyboard.handlers.focusPrevious !== undefined
119
+ );
120
+ }
121
+
107
122
  /**
108
123
  * Measures an element's OWN width via ResizeObserver — a container query, not a
109
124
  * viewport one. The shell reflows by the space it actually occupies (so it works
@@ -5,9 +5,13 @@
5
5
  import assert from "node:assert/strict";
6
6
  import { after, afterEach, before, beforeEach, describe, it } from "node:test";
7
7
  import type { JSDOM } from "jsdom";
8
- import { act, createElement } from "react";
8
+ import { act, createElement, useMemo } from "react";
9
9
  import { createRoot, type Root } from "react-dom/client";
10
10
  import { LIST_ROW_SELECTOR } from "../lib/roving-focus.js";
11
+ import {
12
+ type ListKeyboard,
13
+ useListKeyboard,
14
+ } from "../lib/use-list-keyboard.js";
11
15
  import type { ThreadSection } from "./app-shell-types.js";
12
16
  import { BriefSections } from "./brief-sections.js";
13
17
  import { ComfortableRow } from "./message-row.js";
@@ -99,9 +103,9 @@ afterEach(() => {
99
103
  });
100
104
  });
101
105
 
102
- function pressKey(target: Element, key: string) {
106
+ function pressKey(target: Element, key: string, shiftKey = false) {
103
107
  target.dispatchEvent(
104
- new dom.window.KeyboardEvent("keydown", { key, bubbles: true }),
108
+ new dom.window.KeyboardEvent("keydown", { key, bubbles: true, shiftKey }),
105
109
  );
106
110
  }
107
111
 
@@ -173,3 +177,87 @@ describe("BriefSections arrow-key traversal", () => {
173
177
  assert.equal(dom.window.document.activeElement, items[2]);
174
178
  });
175
179
  });
180
+
181
+ const orderedIds = sections.flatMap((section) =>
182
+ section.threads.map((thread) => thread.id),
183
+ );
184
+
185
+ let list: ListKeyboard | undefined;
186
+
187
+ function BriefUnderLayer() {
188
+ const ids = useMemo(() => orderedIds, []);
189
+ const keyboard = useListKeyboard({
190
+ orderedIds: ids,
191
+ isDesktop: true,
192
+ initialFocusedId: "t1",
193
+ });
194
+ list = keyboard;
195
+ return createElement(
196
+ "div",
197
+ { ref: keyboard.keyboard.ref, tabIndex: -1 },
198
+ createElement(BriefSections, {
199
+ sections,
200
+ Row: ComfortableRow,
201
+ keyboard: keyboard.keyboard,
202
+ onSelectThread: () => undefined,
203
+ onSelectBriefCategory: () => undefined,
204
+ }),
205
+ );
206
+ }
207
+
208
+ function mountUnderLayer() {
209
+ act(() => {
210
+ root.render(createElement(BriefUnderLayer));
211
+ });
212
+ }
213
+
214
+ function selectedIds(): string[] {
215
+ return Array.from(list?.selection.selectedIds ?? []).sort();
216
+ }
217
+
218
+ describe("BriefSections under a keyboard layer", () => {
219
+ it("Shift+ArrowDown extends the selection down the rows", () => {
220
+ mountUnderLayer();
221
+ const items = rows();
222
+
223
+ act(() => items[0]?.focus());
224
+ act(() => pressKey(items[0] as Element, "ArrowDown", true));
225
+ act(() => pressKey(items[0] as Element, "ArrowDown", true));
226
+
227
+ assert.deepEqual(selectedIds(), ["t2", "t3"]);
228
+ });
229
+
230
+ it("Shift+ArrowUp extends the selection back up the rows", () => {
231
+ mountUnderLayer();
232
+ const items = rows();
233
+
234
+ act(() => items[0]?.focus());
235
+ act(() => pressKey(items[0] as Element, "ArrowDown"));
236
+ act(() => pressKey(items[0] as Element, "ArrowDown"));
237
+ act(() => pressKey(items[0] as Element, "ArrowUp", true));
238
+ act(() => pressKey(items[0] as Element, "ArrowUp", true));
239
+
240
+ assert.deepEqual(selectedIds(), ["t1", "t2"]);
241
+ });
242
+
243
+ it("Shift+ArrowDown selects the rows Shift+J does", () => {
244
+ mountUnderLayer();
245
+ const items = rows();
246
+
247
+ act(() => items[0]?.focus());
248
+ act(() => pressKey(items[0] as Element, "j", true));
249
+ act(() => pressKey(items[0] as Element, "j", true));
250
+
251
+ assert.deepEqual(selectedIds(), ["t2", "t3"]);
252
+ });
253
+
254
+ it("hands the bare arrows to the layer instead of walking the rows itself", () => {
255
+ mountUnderLayer();
256
+ const items = rows();
257
+
258
+ act(() => items[0]?.focus());
259
+ act(() => pressKey(items[0] as Element, "ArrowDown"));
260
+
261
+ assert.equal(list?.cursor.focusedMessageId, "t2");
262
+ });
263
+ });
@@ -2,10 +2,15 @@ import { useRef, useState } from "react";
2
2
  import { LIST_ROW_SELECTOR, useRovingFocus } from "../lib/roving-focus.js";
3
3
  import type {
4
4
  BriefCategoryFilter,
5
+ MessageListKeyboard,
5
6
  ThreadRowData,
6
7
  ThreadSection,
7
8
  } from "./app-shell-types.js";
8
- import { briefCategories, categoryTone } from "./app-shell-types.js";
9
+ import {
10
+ briefCategories,
11
+ categoryTone,
12
+ keyboardWalksRows,
13
+ } from "./app-shell-types.js";
9
14
  import { BriefSection } from "./brief-section.js";
10
15
  import {
11
16
  FilterSheet,
@@ -135,6 +140,13 @@ interface BriefSectionsBaseProps
135
140
  sections: ThreadSection[];
136
141
  selectedThreadId?: string;
137
142
  Row: BriefRowComponent;
143
+ /**
144
+ * The keyboard layer walking the rows, when the caller mounts one. The rows
145
+ * hand it the cursor keys rather than traversing with a roving group of their
146
+ * own, so the ring and the cursor name one row and a Shift+arrow range
147
+ * reaches the layer that extends it.
148
+ */
149
+ keyboard?: MessageListKeyboard;
138
150
  onSelectThread?: (id: string) => void;
139
151
  /**
140
152
  * Drop the filter row and its panel, keeping the rows where they are. See
@@ -160,6 +172,7 @@ export function BriefSections({
160
172
  briefCategory = "all",
161
173
  selectedThreadId,
162
174
  Row,
175
+ keyboard,
163
176
  onSelectThread,
164
177
  onSelectBriefCategory,
165
178
  sources,
@@ -179,6 +192,7 @@ export function BriefSections({
179
192
  useRovingFocus({
180
193
  containerRef: listRef,
181
194
  itemSelector: LIST_ROW_SELECTOR,
195
+ enabled: !keyboardWalksRows(keyboard),
182
196
  });
183
197
 
184
198
  const active = activeFilters ?? ownFilters;
@@ -4,11 +4,12 @@ import { useCallback, useEffect, useRef, useState } from "react";
4
4
  import { defaultKeyboardHints, keyboardHintsFor } from "../lib/keymap.js";
5
5
  import { LIST_ROW_SELECTOR, useRovingFocus } from "../lib/roving-focus.js";
6
6
  import { deriveIsMultiSelectMode, modifiersOf } from "../lib/use-selection.js";
7
- import type {
8
- AppShellProps,
9
- MessageListKeyboard,
10
- MessageListSelection,
11
- TouchSeed,
7
+ import {
8
+ type AppShellProps,
9
+ keyboardWalksRows,
10
+ type MessageListKeyboard,
11
+ type MessageListSelection,
12
+ type TouchSeed,
12
13
  } from "./app-shell-types.js";
13
14
  import { type BriefFilterSurface, BriefSections } from "./brief-sections.js";
14
15
  import { Button } from "./button.js";
@@ -149,10 +150,7 @@ export function MessageListPane({
149
150
  // The layer answers the arrows only if it registered them. Anything else it
150
151
  // hands over — a layer with no cursor keys, or no layer at all — leaves the
151
152
  // rows their own traversal and their own single tab stop.
152
- const walksRows =
153
- keyboard !== undefined &&
154
- keyboard.handlers.focusNext !== undefined &&
155
- keyboard.handlers.focusPrevious !== undefined;
153
+ const walksRows = keyboardWalksRows(keyboard);
156
154
  useRovingFocus({
157
155
  containerRef: flatListRef,
158
156
  itemSelector: LIST_ROW_SELECTOR,
@@ -327,6 +325,7 @@ export function MessageListPane({
327
325
  sections={sections}
328
326
  selectedThreadId={selectedThreadId}
329
327
  Row={BriefRow}
328
+ keyboard={keyboard}
330
329
  onSelectThread={onSelectThread}
331
330
  />
332
331
  ) : listBody != null ? (
@@ -93,9 +93,9 @@ afterEach(() => {
93
93
  });
94
94
  });
95
95
 
96
- function pressKey(target: Element, key: string) {
96
+ function pressKey(target: Element, key: string, shiftKey = false) {
97
97
  target.dispatchEvent(
98
- new dom.window.KeyboardEvent("keydown", { key, bubbles: true }),
98
+ new dom.window.KeyboardEvent("keydown", { key, bubbles: true, shiftKey }),
99
99
  );
100
100
  }
101
101
 
@@ -222,6 +222,23 @@ describe("useRovingFocus", () => {
222
222
  assert.equal(dom.window.document.activeElement, rows()[0]);
223
223
  });
224
224
 
225
+ it("leaves Shift+Arrow to the layer above instead of moving the cursor", () => {
226
+ mount({ count: 3 });
227
+ let seen = 0;
228
+ const spy = () => {
229
+ seen += 1;
230
+ };
231
+ dom.window.addEventListener("keydown", spy);
232
+ const items = rows();
233
+ act(() => items[0]?.focus());
234
+ act(() => pressKey(items[0] as Element, "ArrowDown", true));
235
+ act(() => pressKey(items[0] as Element, "ArrowUp", true));
236
+ dom.window.removeEventListener("keydown", spy);
237
+
238
+ assert.equal(dom.window.document.activeElement, items[0]);
239
+ assert.equal(seen, 2);
240
+ });
241
+
225
242
  it("keeps a handled key from reaching a window-level listener", () => {
226
243
  mount({ count: 3 });
227
244
  let seen = 0;
@@ -69,7 +69,8 @@ function rovingItems(
69
69
  * consumer-supplied row component, so neither has a flat array to index into.
70
70
  *
71
71
  * A handled key stops propagating, so a window-level keyboard layer above the
72
- * group does not act on the same press.
72
+ * group does not act on the same press. Only the bare keys are handled — a
73
+ * modified arrow is a different binding, and it belongs to that layer.
73
74
  */
74
75
  export function useRovingFocus({
75
76
  containerRef,
@@ -95,6 +96,11 @@ export function useRovingFocus({
95
96
  };
96
97
 
97
98
  const onKeyDown = (event: KeyboardEvent) => {
99
+ // Shift+Arrow extends a selection; the group traverses on the bare key
100
+ // and leaves every modified stroke to the layer that binds it.
101
+ if (event.shiftKey || event.metaKey || event.ctrlKey || event.altKey) {
102
+ return;
103
+ }
98
104
  const items = rovingItems(container, itemSelector);
99
105
  const currentIndex = items.indexOf(document.activeElement as HTMLElement);
100
106
  const nextIndex = rovingNextIndex(