@remit/ui 0.0.2 → 0.0.4

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.
@@ -0,0 +1,154 @@
1
+ /**
2
+ * The keyboard contract for the chips-plus-text search expression, as pure
3
+ * decisions over the field's state. Kept apart from the component so the rules
4
+ * are testable without a DOM — the component only maps a result onto focus and
5
+ * callbacks.
6
+ *
7
+ * Focus is roving: the whole field is one Tab stop, and focus sits either on
8
+ * the text input or on exactly one chip. Which one it sits on is what makes a
9
+ * keystroke mean "edit the query" or "act on a chip", so the two surfaces get
10
+ * one resolver each.
11
+ *
12
+ * Grounded in Material Design 3's chip accessibility guidance and Angular
13
+ * Material's `mat-chip-grid`, the closest documented chips-inside-a-field
14
+ * precedent. There is no W3C ARIA pattern for chips, so this is an adaptation
15
+ * rather than an implementation of a standard — it is settled by screen-reader
16
+ * testing, not by role names alone.
17
+ */
18
+
19
+ /** Where focus should land. `null` means the text input. */
20
+ export type ChipFocusTarget = number | null;
21
+
22
+ export type ChipInputAction =
23
+ /** Move focus off the text and onto a chip. */
24
+ | { type: "focusChip"; index: number }
25
+ /** Clear the typed text, leaving the chips and any open thread alone. */
26
+ | { type: "clearQuery" }
27
+ | { type: "blur" }
28
+ | { type: "none" };
29
+
30
+ export type ChipAction =
31
+ | { type: "removeChip"; index: number }
32
+ | { type: "focusChip"; index: number }
33
+ /** Return focus to the text input. */
34
+ | { type: "focusInput" }
35
+ /** Open the chip's own editor, where the host provides one. */
36
+ | { type: "activateChip"; index: number }
37
+ | { type: "none" };
38
+
39
+ export interface ChipInputKeyState {
40
+ key: string;
41
+ shiftKey?: boolean;
42
+ /**
43
+ * The keystroke came from the keyboard's auto-repeat, not a fresh press.
44
+ * Holding Backspace to clear the text must stop at the chips instead of
45
+ * running on into them, which would collapse the two-press rule into one
46
+ * held key.
47
+ */
48
+ repeat?: boolean;
49
+ /** Caret sits before all typed text, with nothing selected in the input. */
50
+ caretAtStart: boolean;
51
+ /** The field has typed text. */
52
+ hasValue: boolean;
53
+ chipCount: number;
54
+ }
55
+
56
+ export interface ChipKeyState {
57
+ key: string;
58
+ /** See `ChipInputKeyState.repeat`. */
59
+ repeat?: boolean;
60
+ /** Index of the chip that currently holds focus. */
61
+ index: number;
62
+ chipCount: number;
63
+ }
64
+
65
+ /**
66
+ * Keystrokes while the caret is in the text.
67
+ *
68
+ * Backspace at the very start of the text moves focus onto the preceding chip
69
+ * rather than removing it — the press that removes it is the next one, handled
70
+ * by `resolveChipKey`. Two presses, so a compound term is never destroyed by a
71
+ * stray keystroke.
72
+ */
73
+ export function resolveChipInputKey({
74
+ key,
75
+ shiftKey = false,
76
+ repeat = false,
77
+ caretAtStart,
78
+ hasValue,
79
+ chipCount,
80
+ }: ChipInputKeyState): ChipInputAction {
81
+ const lastChip = chipCount - 1;
82
+
83
+ if (key === "Escape") {
84
+ if (hasValue) return { type: "clearQuery" };
85
+ return { type: "blur" };
86
+ }
87
+
88
+ // Shift+Tab walks back into the chips instead of leaving the field, so the
89
+ // chips are reachable without a pointer.
90
+ if (key === "Tab" && shiftKey) {
91
+ if (chipCount === 0) return { type: "none" };
92
+ return { type: "focusChip", index: lastChip };
93
+ }
94
+
95
+ if (key === "Backspace" || key === "ArrowLeft") {
96
+ if (!caretAtStart || chipCount === 0) return { type: "none" };
97
+ // A held key that has just eaten the last character stops here: crossing
98
+ // into the chips has to be a deliberate press.
99
+ if (repeat) return { type: "none" };
100
+ return { type: "focusChip", index: lastChip };
101
+ }
102
+
103
+ return { type: "none" };
104
+ }
105
+
106
+ /** Keystrokes while a chip holds focus. */
107
+ export function resolveChipKey({
108
+ key,
109
+ repeat = false,
110
+ index,
111
+ chipCount,
112
+ }: ChipKeyState): ChipAction {
113
+ if (key === "Backspace" || key === "Delete") {
114
+ // One press, one chip. Holding the key must not walk the whole strip.
115
+ if (repeat) return { type: "none" };
116
+ return { type: "removeChip", index };
117
+ }
118
+
119
+ if (key === "ArrowLeft") {
120
+ if (index === 0) return { type: "none" };
121
+ return { type: "focusChip", index: index - 1 };
122
+ }
123
+
124
+ if (key === "ArrowRight") {
125
+ // Past the last chip is the text, caret at the start.
126
+ if (index >= chipCount - 1) return { type: "focusInput" };
127
+ return { type: "focusChip", index: index + 1 };
128
+ }
129
+
130
+ if (key === "Enter" || key === " ") {
131
+ return { type: "activateChip", index };
132
+ }
133
+
134
+ if (key === "Escape") {
135
+ return { type: "focusInput" };
136
+ }
137
+
138
+ return { type: "none" };
139
+ }
140
+
141
+ /**
142
+ * Where focus lands after the chip at `removedIndex` is removed: the chip that
143
+ * takes its place, else the one before it, else the text input. Never nowhere —
144
+ * a removal that drops focus to the document body strands keyboard users.
145
+ */
146
+ export function focusAfterRemoval(
147
+ removedIndex: number,
148
+ countBeforeRemoval: number,
149
+ ): ChipFocusTarget {
150
+ const remaining = countBeforeRemoval - 1;
151
+ if (remaining <= 0) return null;
152
+ if (removedIndex < remaining) return removedIndex;
153
+ return remaining - 1;
154
+ }
@@ -1,26 +1,44 @@
1
1
  import { X } from "lucide-react";
2
+ import { forwardRef } from "react";
2
3
  import { cn } from "../lib/cn.js";
3
4
 
5
+ /**
6
+ * What the chip represents. A `scope` chip is the view the user is already in
7
+ * (the folder they navigated to) rather than a filter they typed, so it is
8
+ * visually differentiated — same removability, different provenance.
9
+ */
10
+ export type SearchChipTone = "filter" | "scope";
11
+
4
12
  export interface SearchTokenChipProps {
5
13
  label: string;
6
14
  onRemove: () => void;
15
+ tone?: SearchChipTone;
7
16
  className?: string;
8
17
  }
9
18
 
19
+ const toneClass: Record<SearchChipTone, string> = {
20
+ filter: "border-line bg-surface-sunken text-fg-muted",
21
+ scope: "border-accent-2/40 bg-accent-2-soft text-accent-2",
22
+ };
23
+
10
24
  /**
11
- * One removable filter-token chip (`from:`, `has:attachment`, …) shown under
12
- * the search field once the field's typed text parses a recognized token.
13
- * Same dismissible-pill treatment as `AddressTag`, generalized to a plain
25
+ * One removable filter-token chip (`from:`, `has:attachment`, …) as a static
26
+ * pill. Same dismissible treatment as `AddressTag`, generalized to a plain
14
27
  * label since a token chip carries no email identity.
28
+ *
29
+ * For chips inside the search field — where they are focusable and the
30
+ * keyboard can remove them — use `SearchChipRow` instead.
15
31
  */
16
32
  export const SearchTokenChip = ({
17
33
  label,
18
34
  onRemove,
35
+ tone = "filter",
19
36
  className,
20
37
  }: SearchTokenChipProps) => (
21
38
  <span
22
39
  className={cn(
23
- "inline-flex items-center gap-1 rounded-full border border-line bg-surface-sunken px-2 py-0.5 text-2xs text-fg-muted",
40
+ "inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-2xs",
41
+ toneClass[tone],
24
42
  className,
25
43
  )}
26
44
  >
@@ -36,6 +54,95 @@ export const SearchTokenChip = ({
36
54
  </span>
37
55
  );
38
56
 
57
+ export interface SearchChipRowProps {
58
+ label: string;
59
+ onRemove: () => void;
60
+ /** Opens the chip's own editor, where the host offers one. */
61
+ onActivate?: () => void;
62
+ tone?: SearchChipTone;
63
+ /** True for the one chip in the roving tab order (see `SearchChipInput`). */
64
+ focused?: boolean;
65
+ onKeyDown?: (event: React.KeyboardEvent) => void;
66
+ onFocusLabel?: () => void;
67
+ className?: string;
68
+ }
69
+
70
+ /**
71
+ * One chip inside the search field, as a `row` of the enclosing `grid`.
72
+ *
73
+ * The label and the remove button are separate `gridcell`s so a screen reader
74
+ * announces the chip and its removal as two distinct actions — M3's
75
+ * requirement for a chip that carries a remove affordance. Only the label cell
76
+ * takes part in the roving tab order; the remove button stays reachable by
77
+ * pointer and by the Backspace/Delete route on the focused chip, which keeps
78
+ * the field a single Tab stop.
79
+ */
80
+ export const SearchChipRow = forwardRef<HTMLButtonElement, SearchChipRowProps>(
81
+ (
82
+ {
83
+ label,
84
+ onRemove,
85
+ onActivate,
86
+ tone = "filter",
87
+ focused = false,
88
+ onKeyDown,
89
+ onFocusLabel,
90
+ className,
91
+ },
92
+ ref,
93
+ ) => (
94
+ // biome-ignore lint/a11y/useSemanticElements: grid pattern, not tabular data
95
+ <span
96
+ role="row"
97
+ // Not a tab stop itself — the cells inside it are. Present so the row is
98
+ // programmatically focusable, as its role implies.
99
+ tabIndex={-1}
100
+ className={cn(
101
+ "inline-flex max-w-48 items-center gap-1 rounded-full border px-2 py-0.5 text-2xs transition-colors",
102
+ toneClass[tone],
103
+ focused && "ring-2 ring-ring",
104
+ className,
105
+ )}
106
+ >
107
+ {/* biome-ignore lint/a11y/useSemanticElements: see the row */}
108
+ <button
109
+ ref={ref}
110
+ type="button"
111
+ role="gridcell"
112
+ // Roving tabindex: exactly one chip (or the text input) is in the tab
113
+ // order at a time, so the whole field is one Tab stop.
114
+ tabIndex={focused ? 0 : -1}
115
+ onClick={onActivate}
116
+ onFocus={onFocusLabel}
117
+ onKeyDown={onKeyDown}
118
+ className={cn(
119
+ "block min-w-0 max-w-full truncate rounded-full outline-none",
120
+ onActivate ? "cursor-pointer" : "cursor-default",
121
+ )}
122
+ >
123
+ {label}
124
+ </button>
125
+ {/* biome-ignore lint/a11y/useSemanticElements: see the row */}
126
+ <button
127
+ type="button"
128
+ role="gridcell"
129
+ tabIndex={-1}
130
+ onClick={onRemove}
131
+ // Deliberately not wired to the roving handler. Should this button take
132
+ // focus — a click, or assistive tech moving to it — that handler would
133
+ // read Enter/Space as "activate the chip" and preventDefault them,
134
+ // swallowing the button's own click. Its native activation is the
135
+ // remove action already.
136
+ className="block shrink-0 rounded-full p-0.5 transition-colors hover:bg-fg-muted/20"
137
+ aria-label={`Remove filter: ${label}`}
138
+ >
139
+ <X className="size-3" />
140
+ </button>
141
+ </span>
142
+ ),
143
+ );
144
+ SearchChipRow.displayName = "SearchChipRow";
145
+
39
146
  export interface SearchTokenChipsProps {
40
147
  tokens: { label: string; onRemove: () => void }[];
41
148
  className?: string;
@@ -60,13 +60,20 @@ describe("inboxFilterConfig", () => {
60
60
  );
61
61
  });
62
62
 
63
- it("adds Has attachment to Unread and Flagged", () => {
63
+ it("adds Has attachment to Unread and Starred", () => {
64
64
  assert.deepEqual(
65
65
  inboxFilterConfig().filters.map((f) => f.id),
66
66
  ["unread", "flagged", "attachment"],
67
67
  );
68
68
  });
69
69
 
70
+ it("labels the IMAP \\Flagged filter 'Starred'", () => {
71
+ assert.deepEqual(
72
+ inboxFilterConfig().filters.map((f) => f.label),
73
+ ["Unread", "Starred", "Has attachment"],
74
+ );
75
+ });
76
+
70
77
  it("never offers an accounts source group", () => {
71
78
  assert.equal(inboxFilterConfig().sources, undefined);
72
79
  });
@@ -43,7 +43,8 @@ const MESSAGE_CATEGORIES: FilterSheetCategory[] = [
43
43
  ];
44
44
 
45
45
  const UNREAD: FilterSheetFilter = { id: "unread", label: "Unread" };
46
- const FLAGGED: FilterSheetFilter = { id: "flagged", label: "Flagged" };
46
+ // `flagged` is the wire name (IMAP \Flagged); the user-facing label is "Starred".
47
+ const FLAGGED: FilterSheetFilter = { id: "flagged", label: "Starred" };
47
48
  const HAS_ATTACHMENT: FilterSheetFilter = {
48
49
  id: "attachment",
49
50
  label: "Has attachment",
package/src/index.ts CHANGED
@@ -42,6 +42,7 @@ export {
42
42
  type TouchSeed,
43
43
  useContainerWidth,
44
44
  } from "./components/app-shell-types.js";
45
+ export { AppTopBar, type AppTopBarProps } from "./components/app-top-bar.js";
45
46
  export { AuthCard, type AuthCardProps } from "./components/auth-card.js";
46
47
  export {
47
48
  AuthFooter,
@@ -279,6 +280,11 @@ export {
279
280
  type RowDestructiveAction,
280
281
  } from "./components/row-actions.js";
281
282
  export { SearchBar, type SearchBarProps } from "./components/search-bar.js";
283
+ export {
284
+ type SearchChip,
285
+ SearchChipInput,
286
+ type SearchChipInputProps,
287
+ } from "./components/search-chip-input.js";
282
288
  export {
283
289
  type SearchResult,
284
290
  SearchResultRow,
@@ -291,6 +297,9 @@ export {
291
297
  type SearchResultsProps,
292
298
  } from "./components/search-results.js";
293
299
  export {
300
+ SearchChipRow,
301
+ type SearchChipRowProps,
302
+ type SearchChipTone,
294
303
  SearchTokenChip,
295
304
  type SearchTokenChipProps,
296
305
  SearchTokenChips,