@remit/ui 0.0.55 → 0.0.57

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.
Files changed (37) hide show
  1. package/package.json +1 -1
  2. package/src/components/app-shell-types.ts +8 -1
  3. package/src/components/auto-moved-badge.stories.tsx +3 -21
  4. package/src/components/auto-moved-badge.tsx +8 -17
  5. package/src/components/brief-empty.stories.tsx +51 -0
  6. package/src/components/brief-empty.tsx +60 -0
  7. package/src/components/dialog.tsx +10 -5
  8. package/src/components/filter-clause-chip.tsx +39 -1
  9. package/src/components/filter-rule-editor.stories.tsx +163 -1
  10. package/src/components/filter-rule-editor.tsx +47 -0
  11. package/src/components/filter-rule.render.test.ts +33 -0
  12. package/src/components/filter-rule.ts +141 -0
  13. package/src/components/input.tsx +7 -0
  14. package/src/components/mail-header.tsx +10 -0
  15. package/src/components/message-row.tsx +41 -3
  16. package/src/components/mobile-search-view.render.test.ts +31 -0
  17. package/src/components/mobile-search-view.stories.tsx +115 -6
  18. package/src/components/mobile-search-view.tsx +34 -8
  19. package/src/components/password-input.render.test.ts +149 -0
  20. package/src/components/password-input.tsx +45 -0
  21. package/src/components/primitives.stories.tsx +28 -0
  22. package/src/components/search-bar.tsx +9 -0
  23. package/src/components/search-chip-input.tsx +85 -3
  24. package/src/components/search-result-row.tsx +68 -82
  25. package/src/components/search-results.stories.tsx +12 -0
  26. package/src/components/search-results.tsx +20 -8
  27. package/src/components/suggest-list.render.test.ts +59 -0
  28. package/src/components/suggest-list.tsx +96 -0
  29. package/src/components/swipeable-row.gesture.test.ts +128 -2
  30. package/src/components/swipeable-row.stories.tsx +54 -0
  31. package/src/components/swipeable-row.tsx +60 -31
  32. package/src/index.ts +37 -0
  33. package/src/lib/suggest-keys.test.ts +101 -0
  34. package/src/lib/suggest-keys.ts +63 -0
  35. package/src/lib/use-long-press.ts +70 -37
  36. package/src/lib/use-suggest-list.test.ts +169 -0
  37. package/src/lib/use-suggest-list.ts +124 -0
@@ -1,7 +1,59 @@
1
1
  import type { DOMAttributes } from "@react-types/shared";
2
- import { type PointerEvent, useCallback, useRef } from "react";
2
+ import { type PointerEvent, useCallback } from "react";
3
3
  import { mergeProps, useLongPress as useAriaLongPress } from "react-aria";
4
4
 
5
+ /**
6
+ * Suppression lives on the document, not on the row.
7
+ *
8
+ * There is one pointer, and the element under it does not survive the press: a
9
+ * long press on a mailbox row enters selection mode, which swaps the swipeable
10
+ * row for the plain one *while the finger is still down*. A handler on the row
11
+ * that armed the press is torn down with it, and Android Chrome then raises its
12
+ * link menu over the selection the press just made. A capture-phase listener on
13
+ * the document sees the menu whichever node ends up under the finger.
14
+ *
15
+ * Set while a touch or pen press is down. A press that never delivers its
16
+ * `pointerup` — the browser took the gesture, the tab went to the background —
17
+ * would leave suppression armed forever, so arming is bounded by this timer as
18
+ * well as by the release.
19
+ */
20
+ let armedTimer: ReturnType<typeof setTimeout> | undefined;
21
+
22
+ /** Longer than any press a human holds before the browser ends the gesture. */
23
+ const MAX_ARMED_MS = 5_000;
24
+
25
+ /**
26
+ * One press raises at most one menu, so suppression is spent on use. The
27
+ * release disarms too, which is what keeps a keyboard-invoked menu
28
+ * (Context-Menu key / Shift+F10) raised later from inheriting a press that is
29
+ * long over. Deliberately keyed to `pointerup` and not to `pointercancel`: on
30
+ * Android the browser — and react-aria's own long-press timer — can cancel the
31
+ * pointer before the `contextmenu` it raised arrives, which would race the
32
+ * suppression away.
33
+ */
34
+ function suppressContextMenu(event: Event): void {
35
+ event.preventDefault();
36
+ disarm();
37
+ }
38
+
39
+ function disarm(): void {
40
+ if (armedTimer === undefined) return;
41
+ clearTimeout(armedTimer);
42
+ armedTimer = undefined;
43
+ document.removeEventListener("contextmenu", suppressContextMenu, true);
44
+ document.removeEventListener("pointerup", disarm, true);
45
+ }
46
+
47
+ function arm(): void {
48
+ if (armedTimer === undefined) {
49
+ document.addEventListener("contextmenu", suppressContextMenu, true);
50
+ document.addEventListener("pointerup", disarm, true);
51
+ } else {
52
+ clearTimeout(armedTimer);
53
+ }
54
+ armedTimer = setTimeout(disarm, MAX_ARMED_MS);
55
+ }
56
+
5
57
  export interface UseLongPressOptions {
6
58
  /** Called once the threshold elapses while the press stays over the target. */
7
59
  onLongPress: () => void;
@@ -32,14 +84,17 @@ export interface UseLongPressResult {
32
84
  * `-webkit-touch-callout: none` in CSS at the call site, since iOS fires no
33
85
  * cancelable event for it.
34
86
  *
35
- * The `contextmenu` suppression is keyed to the active pointer's type, tracked
36
- * off `pointerdown` on the same element: a touch or pen press suppresses the
37
- * menu Android Chrome and iOS Safari raise on a long press over a link, while a
38
- * mouse right-click is left alone so the desktop context menu keeps working. It
39
- * does not delegate this to react-aria's own suppression that listener is
40
- * transient (added on press start, scoped to the touched node, and torn down
41
- * shortly after pointerup), so a press ended early by the swipe gesture's axis
42
- * arbitration, or a menu raised over a descendant node, slips past it.
87
+ * The `contextmenu` suppression is armed by a touch or pen `pointerdown` and
88
+ * runs on the document in the capture phase: a touch press suppresses the menu
89
+ * Android Chrome and iOS Safari raise on a long press over a link, while a mouse
90
+ * right-click is left alone so the desktop context menu keeps working. Two
91
+ * reasons it is neither react-aria's own suppression nor a handler on the row.
92
+ * react-aria's listener is transient added on press start, scoped to the
93
+ * touched node, torn down shortly after pointerup so a press ended early by
94
+ * the swipe gesture's axis arbitration slips past it. And the row itself does
95
+ * not survive the press: the long press enters selection mode, which replaces
96
+ * the swipeable row with the plain one while the finger is still down, so a
97
+ * handler bound to the pressed node is gone by the time the menu arrives.
43
98
  *
44
99
  * Single source of truth for the app's long-press threshold — both mobile
45
100
  * row consumers (the plain row and the swipeable row) go through this hook
@@ -58,37 +113,15 @@ export function useLongPress({
58
113
  onLongPress,
59
114
  });
60
115
 
61
- const pointerTypeRef = useRef<string>("");
62
-
116
+ // Only a touch or pen press arms it: a mouse right-click, and a keyboard menu
117
+ // (Context-Menu key / Shift+F10) that is preceded by no press at all, keep
118
+ // the native menu.
63
119
  const onPointerDown = useCallback((event: PointerEvent) => {
64
- pointerTypeRef.current = event.pointerType;
65
- }, []);
66
-
67
- // A press that lifts without raising a menu disarms suppression, so a later
68
- // keyboard-invoked menu can't inherit its pointer type. Not cleared on
69
- // pointercancel: on Android the browser (and react-aria's own long-press
70
- // timer) can fire pointercancel before the long-press contextmenu, which
71
- // would race the suppression away.
72
- const onPointerUp = useCallback(() => {
73
- pointerTypeRef.current = "";
74
- }, []);
75
-
76
- const onContextMenu = useCallback((event: { preventDefault: () => void }) => {
77
- // Consume the armed pointer type. A keyboard-invoked menu (Context-Menu
78
- // key / Shift+F10) fires no pointerdown, so without spending the type on
79
- // use it would inherit the last touch press's and be wrongly suppressed.
80
- const pointerType = pointerTypeRef.current;
81
- pointerTypeRef.current = "";
82
- if (pointerType === "touch" || pointerType === "pen") {
83
- event.preventDefault();
84
- }
120
+ if (event.pointerType !== "touch" && event.pointerType !== "pen") return;
121
+ arm();
85
122
  }, []);
86
123
 
87
124
  return {
88
- longPressProps: mergeProps(longPressProps, {
89
- onPointerDown,
90
- onPointerUp,
91
- onContextMenu,
92
- }),
125
+ longPressProps: mergeProps(longPressProps, { onPointerDown }),
93
126
  };
94
127
  }
@@ -0,0 +1,169 @@
1
+ /**
2
+ * use-suggest-list — drives the real hook on a jsdom-mounted input, because what
3
+ * matters is the interaction between its state and the keystrokes the field
4
+ * receives: an accepted suggestion, a dismissal that leaves the typed value
5
+ * alone, a result set that changes under a stale highlight. The key rules
6
+ * themselves are pure and tested in `suggest-keys.test.ts`.
7
+ */
8
+
9
+ import assert from "node:assert/strict";
10
+ import { after, afterEach, before, beforeEach, describe, it } from "node:test";
11
+ import type { JSDOM } from "jsdom";
12
+ import { act, createElement, useState } from "react";
13
+ import { createRoot, type Root } from "react-dom/client";
14
+ import { useSuggestList } from "./use-suggest-list.js";
15
+
16
+ let dom: JSDOM;
17
+ let container: HTMLElement;
18
+ let root: Root;
19
+
20
+ const accepted: string[] = [];
21
+
22
+ function Field(props: { options: string[] }) {
23
+ const [value, setValue] = useState("typed");
24
+ const suggest = useSuggestList({
25
+ count: props.options.length,
26
+ onAccept: (index) => accepted.push(props.options[index]),
27
+ });
28
+ return createElement("input", {
29
+ id: "field",
30
+ value,
31
+ onChange: (event: { target: { value: string } }) => {
32
+ suggest.reopen();
33
+ setValue(event.target.value);
34
+ },
35
+ onKeyDown: suggest.handleKeyDown,
36
+ "data-open": String(suggest.open),
37
+ "data-active": String(suggest.activeIndex),
38
+ ...suggest.comboboxProps,
39
+ });
40
+ }
41
+
42
+ function mount(options: string[]) {
43
+ act(() => {
44
+ root.render(createElement(Field, { options }));
45
+ });
46
+ const field = dom.window.document.getElementById("field");
47
+ assert.ok(field, "field did not mount");
48
+ // React's change-event polyfill tracks the focused input across keystrokes;
49
+ // keys arriving at an unfocused field are not a state this ever sees.
50
+ act(() => {
51
+ (field as HTMLInputElement).focus();
52
+ });
53
+ return field;
54
+ }
55
+
56
+ function press(field: Element, key: string) {
57
+ const event = new dom.window.KeyboardEvent("keydown", {
58
+ key,
59
+ bubbles: true,
60
+ cancelable: true,
61
+ });
62
+ act(() => {
63
+ field.dispatchEvent(event);
64
+ });
65
+ return event;
66
+ }
67
+
68
+ before(async () => {
69
+ const { JSDOM: JSDOMCtor } = await import("jsdom");
70
+ dom = new JSDOMCtor(
71
+ "<!doctype html><html><body><div id=root></div></body></html>",
72
+ { url: "http://localhost/", pretendToBeVisual: true },
73
+ );
74
+ globalThis.window = dom.window as unknown as typeof globalThis.window;
75
+ globalThis.document = dom.window.document;
76
+ globalThis.HTMLElement = dom.window.HTMLElement;
77
+ globalThis.Element = dom.window.Element;
78
+ globalThis.SVGElement = dom.window.SVGElement;
79
+ globalThis.KeyboardEvent = dom.window.KeyboardEvent;
80
+ (
81
+ globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
82
+ ).IS_REACT_ACT_ENVIRONMENT = true;
83
+ });
84
+
85
+ after(() => {
86
+ dom.window.close();
87
+ });
88
+
89
+ beforeEach(() => {
90
+ accepted.length = 0;
91
+ container = dom.window.document.getElementById(
92
+ "root",
93
+ ) as unknown as HTMLElement;
94
+ container.innerHTML = "";
95
+ root = createRoot(container);
96
+ });
97
+
98
+ afterEach(() => {
99
+ act(() => {
100
+ root.unmount();
101
+ });
102
+ });
103
+
104
+ const OPTIONS = ["one", "two", "three"];
105
+
106
+ describe("useSuggestList", () => {
107
+ it("opens only when there is something to suggest", () => {
108
+ assert.equal(mount([]).getAttribute("data-open"), "false");
109
+ assert.equal(mount(OPTIONS).getAttribute("data-open"), "true");
110
+ });
111
+
112
+ it("wires the combobox for a screen reader", () => {
113
+ const field = mount(OPTIONS);
114
+ assert.equal(field.getAttribute("role"), "combobox");
115
+ assert.equal(field.getAttribute("aria-expanded"), "true");
116
+ assert.equal(field.getAttribute("aria-autocomplete"), "list");
117
+ assert.ok(field.getAttribute("aria-controls"));
118
+ assert.equal(field.getAttribute("aria-activedescendant"), null);
119
+ });
120
+
121
+ it("points at the highlighted option once one is highlighted", () => {
122
+ const field = mount(OPTIONS);
123
+ press(field, "ArrowDown");
124
+ assert.equal(field.getAttribute("data-active"), "0");
125
+ assert.equal(
126
+ field.getAttribute("aria-activedescendant"),
127
+ `${field.getAttribute("aria-controls")}-option-0`,
128
+ );
129
+ });
130
+
131
+ it("takes the highlighted suggestion on Enter and leaves Enter alone otherwise", () => {
132
+ const field = mount(OPTIONS);
133
+ const bare = press(field, "Enter");
134
+ assert.deepEqual(accepted, []);
135
+ assert.equal(bare.defaultPrevented, false, "the field's own Enter stands");
136
+
137
+ press(field, "ArrowDown");
138
+ press(field, "ArrowDown");
139
+ press(field, "Enter");
140
+ assert.deepEqual(accepted, ["two"]);
141
+ });
142
+
143
+ it("closes on Escape without touching what was typed, and owns the key while open", () => {
144
+ const field = mount(OPTIONS);
145
+ assert.equal(field.getAttribute("data-escape-owner"), "");
146
+ press(field, "Escape");
147
+ assert.equal(field.getAttribute("data-open"), "false");
148
+ assert.equal(field.getAttribute("data-escape-owner"), null);
149
+ assert.equal((field as HTMLInputElement).value, "typed");
150
+ });
151
+
152
+ it("leaves Escape to the surrounding surface once the list is closed", () => {
153
+ const field = mount(OPTIONS);
154
+ press(field, "Escape");
155
+ const second = press(field, "Escape");
156
+ assert.equal(second.defaultPrevented, false);
157
+ });
158
+
159
+ it("offers the list again for a changed result set, with no highlight carried over", () => {
160
+ const field = mount(OPTIONS);
161
+ press(field, "ArrowDown");
162
+ press(field, "Escape");
163
+ act(() => {
164
+ root.render(createElement(Field, { options: ["only"] }));
165
+ });
166
+ assert.equal(field.getAttribute("data-open"), "true");
167
+ assert.equal(field.getAttribute("data-active"), "-1");
168
+ });
169
+ });
@@ -0,0 +1,124 @@
1
+ import {
2
+ type KeyboardEvent,
3
+ useCallback,
4
+ useEffect,
5
+ useId,
6
+ useState,
7
+ } from "react";
8
+ import { suggestKeyAction } from "./suggest-keys.js";
9
+
10
+ export interface UseSuggestListInput {
11
+ /** How many suggestions are on offer right now. */
12
+ count: number;
13
+ /** Take the suggestion at this index. */
14
+ onAccept: (index: number) => void;
15
+ /** Extra keys that take the highlighted suggestion — see `suggestKeyAction`. */
16
+ acceptKeys?: readonly string[];
17
+ }
18
+
19
+ /** The ARIA wiring a combobox input spreads onto itself. */
20
+ export interface ComboboxProps {
21
+ role: "combobox";
22
+ "aria-expanded": boolean;
23
+ "aria-controls": string;
24
+ "aria-autocomplete": "list";
25
+ "aria-activedescendant"?: string;
26
+ /**
27
+ * Marks the field as owning Escape while its list is open, so a surrounding
28
+ * dialog closes on Escape only when the list is not the thing to close.
29
+ */
30
+ "data-escape-owner"?: "";
31
+ }
32
+
33
+ export interface SuggestListState {
34
+ /** Whether the list should render. */
35
+ open: boolean;
36
+ /** The highlighted option, `-1` when the typed value is what stands. */
37
+ activeIndex: number;
38
+ setActiveIndex: (index: number) => void;
39
+ /** Close the list, keeping whatever is typed. */
40
+ dismiss: () => void;
41
+ /** Offer the list again — what a fresh keystroke does after a dismissal. */
42
+ reopen: () => void;
43
+ /** Returns true when the list consumed the key and the caller should stop. */
44
+ handleKeyDown: (event: KeyboardEvent) => boolean;
45
+ listId: string;
46
+ optionId: (index: number) => string;
47
+ comboboxProps: ComboboxProps;
48
+ }
49
+
50
+ /**
51
+ * The open/highlight state, ARIA wiring, and keyboard handling behind a
52
+ * suggestion list, so every typeahead in the app behaves the same way and there
53
+ * is one place the behaviour is defined. The caller owns the data, the markup,
54
+ * and what a picked suggestion means; this owns only which option is highlighted
55
+ * and whether the list is showing.
56
+ *
57
+ * Nothing here writes the field's value — a dismissal, a re-render, or an empty
58
+ * result leaves what the user typed exactly as it is.
59
+ */
60
+ export function useSuggestList({
61
+ count,
62
+ onAccept,
63
+ acceptKeys,
64
+ }: UseSuggestListInput): SuggestListState {
65
+ const [dismissed, setDismissed] = useState(false);
66
+ const [activeIndex, setActiveIndex] = useState(-1);
67
+ const base = useId().replace(/[^a-zA-Z0-9_-]/g, "");
68
+ const listId = `suggest-${base}`;
69
+ const optionId = useCallback(
70
+ (index: number) => `suggest-${base}-option-${index}`,
71
+ [base],
72
+ );
73
+
74
+ // A changed result set is a new list: no highlight carries over onto an option
75
+ // the user never saw, and a list dismissed for the previous query is offered
76
+ // again for this one.
77
+ // biome-ignore lint/correctness/useExhaustiveDependencies: `count` is the trigger, not a value the effect reads — a different result set is what resets the list
78
+ useEffect(() => {
79
+ setActiveIndex(-1);
80
+ setDismissed(false);
81
+ }, [count]);
82
+
83
+ const open = count > 0 && !dismissed;
84
+
85
+ const handleKeyDown = useCallback(
86
+ (event: KeyboardEvent): boolean => {
87
+ const action = suggestKeyAction({
88
+ key: event.key,
89
+ open,
90
+ count,
91
+ activeIndex,
92
+ acceptKeys,
93
+ });
94
+ if (action.type === "none") return false;
95
+ event.preventDefault();
96
+ if (action.type === "move") setActiveIndex(action.index);
97
+ if (action.type === "dismiss") setDismissed(true);
98
+ if (action.type === "accept") onAccept(action.index);
99
+ return true;
100
+ },
101
+ [open, count, activeIndex, acceptKeys, onAccept],
102
+ );
103
+
104
+ return {
105
+ open,
106
+ activeIndex,
107
+ setActiveIndex,
108
+ dismiss: useCallback(() => setDismissed(true), []),
109
+ reopen: useCallback(() => setDismissed(false), []),
110
+ handleKeyDown,
111
+ listId,
112
+ optionId,
113
+ comboboxProps: {
114
+ role: "combobox",
115
+ "aria-expanded": open,
116
+ "aria-controls": listId,
117
+ "aria-autocomplete": "list",
118
+ ...(activeIndex >= 0
119
+ ? { "aria-activedescendant": optionId(activeIndex) }
120
+ : {}),
121
+ ...(open ? { "data-escape-owner": "" as const } : {}),
122
+ },
123
+ };
124
+ }