@remit/ui 0.0.3 → 0.0.5
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 +1 -1
- package/src/components/app-top-bar.render.test.ts +54 -0
- package/src/components/app-top-bar.stories.tsx +136 -0
- package/src/components/app-top-bar.tsx +62 -0
- package/src/components/mobile-search-view.stories.tsx +23 -0
- package/src/components/mobile-search-view.tsx +14 -0
- package/src/components/search-bar.render.test.ts +17 -0
- package/src/components/search-bar.stories.tsx +3 -1
- package/src/components/search-bar.tsx +49 -81
- package/src/components/search-chip-input.render.test.ts +212 -0
- package/src/components/search-chip-input.stories.tsx +171 -0
- package/src/components/search-chip-input.tsx +364 -0
- package/src/components/search-chip-keys.test.ts +206 -0
- package/src/components/search-chip-keys.ts +154 -0
- package/src/components/search-token-chip.tsx +111 -4
- package/src/components/slide-panel.render.test.ts +85 -0
- package/src/components/slide-panel.stories.tsx +129 -0
- package/src/components/slide-panel.tsx +92 -0
- package/src/index.ts +13 -0
|
@@ -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`, …)
|
|
12
|
-
*
|
|
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
|
|
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;
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import { createElement } from "react";
|
|
4
|
+
import { renderToStaticMarkup } from "react-dom/server";
|
|
5
|
+
import { SlidePanel, type SlidePanelProps } from "./slide-panel.js";
|
|
6
|
+
|
|
7
|
+
const render = (isOpen: boolean) =>
|
|
8
|
+
renderToStaticMarkup(
|
|
9
|
+
createElement(
|
|
10
|
+
SlidePanel,
|
|
11
|
+
// createElement never folds the children argument into the props type,
|
|
12
|
+
// so a component with required children needs the props cast.
|
|
13
|
+
{
|
|
14
|
+
isOpen,
|
|
15
|
+
onClose: () => undefined,
|
|
16
|
+
title: "Add Account",
|
|
17
|
+
} as SlidePanelProps,
|
|
18
|
+
"panel body",
|
|
19
|
+
),
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
/** The panel element itself, as distinct from the scrim behind it. */
|
|
23
|
+
const dialog = (html: string): string => {
|
|
24
|
+
const match = html.match(/<div[^>]*role="dialog"[^>]*>/);
|
|
25
|
+
assert.ok(match, "no dialog element rendered");
|
|
26
|
+
return match[0];
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/** The click-to-dismiss scrim: the first element, before the dialog. */
|
|
30
|
+
const scrim = (html: string): string => {
|
|
31
|
+
const match = html.match(/^<div[^>]*>/);
|
|
32
|
+
assert.ok(match, "no scrim element rendered");
|
|
33
|
+
return match[0];
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
describe("SlidePanel (#57)", () => {
|
|
37
|
+
it("is a fixed right-edge column, never a full-viewport takeover above sm", () => {
|
|
38
|
+
const html = render(true);
|
|
39
|
+
assert.match(html, /fixed top-0 right-0/);
|
|
40
|
+
assert.match(html, /sm:w-\[400px\]/);
|
|
41
|
+
assert.match(html, /translate-x-0/);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("a closed panel is off-canvas and takes no pointer events", () => {
|
|
45
|
+
const html = render(false);
|
|
46
|
+
assert.match(html, /translate-x-full/);
|
|
47
|
+
assert.match(html, /pointer-events-none/);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("a closed panel is inert and hidden from assistive tech", () => {
|
|
51
|
+
const tag = dialog(render(false));
|
|
52
|
+
assert.match(tag, /inert=""/);
|
|
53
|
+
assert.match(tag, /aria-hidden="true"/);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("an open panel is reachable", () => {
|
|
57
|
+
const tag = dialog(render(true));
|
|
58
|
+
assert.doesNotMatch(tag, /inert=""/);
|
|
59
|
+
assert.match(tag, /aria-hidden="false"/);
|
|
60
|
+
assert.match(tag, /role="dialog"/);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("scrolls its body rather than the page", () => {
|
|
64
|
+
const html = render(true);
|
|
65
|
+
assert.match(html, /overflow-auto/);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The scrim is a pointer shortcut for the header's Close button, not a
|
|
70
|
+
* control of its own: posing as a focusable button while answering only
|
|
71
|
+
* Escape strands a keyboard user on a thing that looks activatable.
|
|
72
|
+
*/
|
|
73
|
+
it("the scrim never poses as a focusable control", () => {
|
|
74
|
+
for (const html of [render(true), render(false)]) {
|
|
75
|
+
const tag = scrim(html);
|
|
76
|
+
assert.doesNotMatch(tag, /role="button"/);
|
|
77
|
+
assert.doesNotMatch(tag, /tabindex=/);
|
|
78
|
+
assert.match(tag, /aria-hidden="true"/);
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("always offers a labelled close control in the header", () => {
|
|
83
|
+
assert.match(render(true), /aria-label="Close"/);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
import { Button } from "./button.js";
|
|
4
|
+
import { FieldLabel } from "./field-label.js";
|
|
5
|
+
import { Input } from "./input.js";
|
|
6
|
+
import { SlidePanel } from "./slide-panel.js";
|
|
7
|
+
|
|
8
|
+
const meta: Meta<typeof SlidePanel> = {
|
|
9
|
+
title: "Components/SlidePanel",
|
|
10
|
+
component: SlidePanel,
|
|
11
|
+
parameters: { layout: "fullscreen" },
|
|
12
|
+
};
|
|
13
|
+
export default meta;
|
|
14
|
+
|
|
15
|
+
type Story = StoryObj<typeof SlidePanel>;
|
|
16
|
+
|
|
17
|
+
const backdropRows = Array.from({ length: 12 }, (_, i) => `Row ${i + 1}`);
|
|
18
|
+
|
|
19
|
+
const Backdrop = () => (
|
|
20
|
+
<div className="h-dvh space-y-3 bg-canvas p-6">
|
|
21
|
+
<h1 className="text-md font-semibold text-fg">Screen behind the panel</h1>
|
|
22
|
+
{backdropRows.map((row) => (
|
|
23
|
+
<div
|
|
24
|
+
key={row}
|
|
25
|
+
className="rounded-sm border border-line bg-surface px-4 py-3 text-sm text-fg-muted"
|
|
26
|
+
>
|
|
27
|
+
{row}
|
|
28
|
+
</div>
|
|
29
|
+
))}
|
|
30
|
+
</div>
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
const Body = () => (
|
|
34
|
+
<div className="space-y-4">
|
|
35
|
+
<div>
|
|
36
|
+
<FieldLabel htmlFor="slide-panel-email">Email address</FieldLabel>
|
|
37
|
+
<Input id="slide-panel-email" placeholder="alice@example.com" />
|
|
38
|
+
</div>
|
|
39
|
+
<div>
|
|
40
|
+
<FieldLabel htmlFor="slide-panel-name">Display name</FieldLabel>
|
|
41
|
+
<Input id="slide-panel-name" placeholder="Alice" />
|
|
42
|
+
</div>
|
|
43
|
+
</div>
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
const Footer = ({ onClose }: { onClose: () => void }) => (
|
|
47
|
+
<>
|
|
48
|
+
<Button variant="secondary" size="sm" onClick={onClose}>
|
|
49
|
+
Cancel
|
|
50
|
+
</Button>
|
|
51
|
+
<Button variant="primary" size="sm">
|
|
52
|
+
Save
|
|
53
|
+
</Button>
|
|
54
|
+
</>
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
/** Open: a fixed-width column at the right edge, the screen behind it dimmed. */
|
|
58
|
+
export const Open: Story = {
|
|
59
|
+
globals: { viewport: { value: "desktop" } },
|
|
60
|
+
render: () => (
|
|
61
|
+
<>
|
|
62
|
+
<Backdrop />
|
|
63
|
+
<SlidePanel isOpen onClose={() => {}} title="Add Account" footer={null}>
|
|
64
|
+
<Body />
|
|
65
|
+
</SlidePanel>
|
|
66
|
+
</>
|
|
67
|
+
),
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Closed. The panel stays mounted so it can animate, so this is the state that
|
|
72
|
+
* has to be provably invisible: a closed panel that is not pushed off-canvas
|
|
73
|
+
* takes over the whole screen (#57).
|
|
74
|
+
*/
|
|
75
|
+
export const Closed: Story = {
|
|
76
|
+
globals: { viewport: { value: "desktop" } },
|
|
77
|
+
render: () => (
|
|
78
|
+
<>
|
|
79
|
+
<Backdrop />
|
|
80
|
+
<SlidePanel
|
|
81
|
+
isOpen={false}
|
|
82
|
+
onClose={() => {}}
|
|
83
|
+
title="Add Account"
|
|
84
|
+
footer={null}
|
|
85
|
+
>
|
|
86
|
+
<Body />
|
|
87
|
+
</SlidePanel>
|
|
88
|
+
</>
|
|
89
|
+
),
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
/** On a phone the panel owns the full width. */
|
|
93
|
+
export const Phone: Story = {
|
|
94
|
+
globals: { viewport: { value: "mobile" } },
|
|
95
|
+
render: () => (
|
|
96
|
+
<>
|
|
97
|
+
<Backdrop />
|
|
98
|
+
<SlidePanel isOpen onClose={() => {}} title="Add Account" footer={null}>
|
|
99
|
+
<Body />
|
|
100
|
+
</SlidePanel>
|
|
101
|
+
</>
|
|
102
|
+
),
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
/** Opening and closing from the screen behind it. */
|
|
106
|
+
export const Interactive: Story = {
|
|
107
|
+
globals: { viewport: { value: "desktop" } },
|
|
108
|
+
render: function Render() {
|
|
109
|
+
const [open, setOpen] = useState(false);
|
|
110
|
+
return (
|
|
111
|
+
<>
|
|
112
|
+
<div className="h-dvh space-y-3 bg-canvas p-6">
|
|
113
|
+
<Button variant="primary" size="sm" onClick={() => setOpen(true)}>
|
|
114
|
+
Add account
|
|
115
|
+
</Button>
|
|
116
|
+
<Backdrop />
|
|
117
|
+
</div>
|
|
118
|
+
<SlidePanel
|
|
119
|
+
isOpen={open}
|
|
120
|
+
onClose={() => setOpen(false)}
|
|
121
|
+
title="Add Account"
|
|
122
|
+
footer={<Footer onClose={() => setOpen(false)} />}
|
|
123
|
+
>
|
|
124
|
+
<Body />
|
|
125
|
+
</SlidePanel>
|
|
126
|
+
</>
|
|
127
|
+
);
|
|
128
|
+
},
|
|
129
|
+
};
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { X } from "lucide-react";
|
|
2
|
+
import { type ReactNode, useEffect } from "react";
|
|
3
|
+
import { cn } from "../lib/cn.js";
|
|
4
|
+
|
|
5
|
+
/* ------------------------------------------------------------------ */
|
|
6
|
+
/* SlidePanel: right-edge slide-over for a focused sub-task (editing */
|
|
7
|
+
/* an account) without leaving the screen behind it. Full width on */
|
|
8
|
+
/* phones, a fixed-width column from `sm` up. */
|
|
9
|
+
/* */
|
|
10
|
+
/* A closed panel stays mounted so it can animate, so it must be inert */
|
|
11
|
+
/* in every sense that is not visual: no pointer events, out of the */
|
|
12
|
+
/* tab order, hidden from assistive technology. */
|
|
13
|
+
/* ------------------------------------------------------------------ */
|
|
14
|
+
|
|
15
|
+
export interface SlidePanelProps {
|
|
16
|
+
isOpen: boolean;
|
|
17
|
+
onClose: () => void;
|
|
18
|
+
title: string;
|
|
19
|
+
children: ReactNode;
|
|
20
|
+
footer?: ReactNode;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function SlidePanel({
|
|
24
|
+
isOpen,
|
|
25
|
+
onClose,
|
|
26
|
+
title,
|
|
27
|
+
children,
|
|
28
|
+
footer,
|
|
29
|
+
}: SlidePanelProps) {
|
|
30
|
+
// Escape closes the panel from anywhere inside it, which is what a dialog
|
|
31
|
+
// owes the keyboard. The scrim is a pointer affordance only.
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
if (!isOpen) return;
|
|
34
|
+
const onKeyDown = (event: KeyboardEvent) => {
|
|
35
|
+
if (event.key === "Escape") onClose();
|
|
36
|
+
};
|
|
37
|
+
document.addEventListener("keydown", onKeyDown);
|
|
38
|
+
return () => document.removeEventListener("keydown", onKeyDown);
|
|
39
|
+
}, [isOpen, onClose]);
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<>
|
|
43
|
+
{/* Click-to-dismiss scrim: a pointer shortcut for the header's Close
|
|
44
|
+
button, never the only way out, so it stays out of the tab order and
|
|
45
|
+
the a11y tree rather than posing as a control. */}
|
|
46
|
+
<div
|
|
47
|
+
className={cn(
|
|
48
|
+
"fixed inset-0 z-40 bg-black/30 transition-opacity",
|
|
49
|
+
isOpen ? "opacity-100" : "pointer-events-none opacity-0",
|
|
50
|
+
)}
|
|
51
|
+
onClick={onClose}
|
|
52
|
+
aria-hidden="true"
|
|
53
|
+
/>
|
|
54
|
+
|
|
55
|
+
<div
|
|
56
|
+
className={cn(
|
|
57
|
+
"fixed top-0 right-0 z-50 h-full w-full border-l border-line bg-canvas shadow-xl sm:w-[400px] sm:max-w-[90vw]",
|
|
58
|
+
"transform transition-transform duration-200 ease-out",
|
|
59
|
+
isOpen ? "translate-x-0" : "pointer-events-none translate-x-full",
|
|
60
|
+
)}
|
|
61
|
+
role="dialog"
|
|
62
|
+
aria-modal="true"
|
|
63
|
+
aria-hidden={!isOpen}
|
|
64
|
+
inert={!isOpen}
|
|
65
|
+
aria-labelledby="slide-panel-title"
|
|
66
|
+
>
|
|
67
|
+
<div className="flex h-14 items-center justify-between border-b border-line px-4">
|
|
68
|
+
<h2 id="slide-panel-title" className="font-semibold">
|
|
69
|
+
{title}
|
|
70
|
+
</h2>
|
|
71
|
+
<button
|
|
72
|
+
type="button"
|
|
73
|
+
onClick={onClose}
|
|
74
|
+
className="rounded-md p-1.5 transition-colors hover:bg-surface-raised"
|
|
75
|
+
aria-label="Close"
|
|
76
|
+
>
|
|
77
|
+
<X className="size-5" />
|
|
78
|
+
</button>
|
|
79
|
+
</div>
|
|
80
|
+
|
|
81
|
+
<div className="flex h-[calc(100%-3.5rem)] flex-col">
|
|
82
|
+
<div className="flex-1 overflow-auto p-4">{children}</div>
|
|
83
|
+
{footer && (
|
|
84
|
+
<div className="flex justify-end gap-3 border-t border-line bg-canvas p-4">
|
|
85
|
+
{footer}
|
|
86
|
+
</div>
|
|
87
|
+
)}
|
|
88
|
+
</div>
|
|
89
|
+
</div>
|
|
90
|
+
</>
|
|
91
|
+
);
|
|
92
|
+
}
|
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,
|
|
@@ -333,6 +342,10 @@ export {
|
|
|
333
342
|
SettingsShell,
|
|
334
343
|
type SettingsShellProps,
|
|
335
344
|
} from "./components/settings-screen.js";
|
|
345
|
+
export {
|
|
346
|
+
SlidePanel,
|
|
347
|
+
type SlidePanelProps,
|
|
348
|
+
} from "./components/slide-panel.js";
|
|
336
349
|
export {
|
|
337
350
|
commitPeek,
|
|
338
351
|
SwipeableRow,
|