@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.
- package/package.json +1 -1
- package/src/components/app-shell-types.ts +8 -1
- package/src/components/auto-moved-badge.stories.tsx +3 -21
- package/src/components/auto-moved-badge.tsx +8 -17
- package/src/components/brief-empty.stories.tsx +51 -0
- package/src/components/brief-empty.tsx +60 -0
- package/src/components/dialog.tsx +10 -5
- package/src/components/filter-clause-chip.tsx +39 -1
- package/src/components/filter-rule-editor.stories.tsx +163 -1
- package/src/components/filter-rule-editor.tsx +47 -0
- package/src/components/filter-rule.render.test.ts +33 -0
- package/src/components/filter-rule.ts +141 -0
- package/src/components/input.tsx +7 -0
- package/src/components/mail-header.tsx +10 -0
- package/src/components/message-row.tsx +41 -3
- package/src/components/mobile-search-view.render.test.ts +31 -0
- package/src/components/mobile-search-view.stories.tsx +115 -6
- package/src/components/mobile-search-view.tsx +34 -8
- package/src/components/password-input.render.test.ts +149 -0
- package/src/components/password-input.tsx +45 -0
- package/src/components/primitives.stories.tsx +28 -0
- package/src/components/search-bar.tsx +9 -0
- package/src/components/search-chip-input.tsx +85 -3
- package/src/components/search-result-row.tsx +68 -82
- package/src/components/search-results.stories.tsx +12 -0
- package/src/components/search-results.tsx +20 -8
- package/src/components/suggest-list.render.test.ts +59 -0
- package/src/components/suggest-list.tsx +96 -0
- package/src/components/swipeable-row.gesture.test.ts +128 -2
- package/src/components/swipeable-row.stories.tsx +54 -0
- package/src/components/swipeable-row.tsx +60 -31
- package/src/index.ts +37 -0
- package/src/lib/suggest-keys.test.ts +101 -0
- package/src/lib/suggest-keys.ts +63 -0
- package/src/lib/use-long-press.ts +70 -37
- package/src/lib/use-suggest-list.test.ts +169 -0
- package/src/lib/use-suggest-list.ts +124 -0
|
@@ -5,10 +5,12 @@ import {
|
|
|
5
5
|
type FilterAccount,
|
|
6
6
|
inboxFilterConfig,
|
|
7
7
|
} from "../filter-presets.js";
|
|
8
|
+
import { useSuggestList } from "../lib/use-suggest-list.js";
|
|
8
9
|
import { MobileSearchView } from "./mobile-search-view.js";
|
|
9
10
|
import type { SearchChip } from "./search-chip-input.js";
|
|
10
11
|
import type { SearchResult } from "./search-result-row.js";
|
|
11
12
|
import type { SearchResultSection, SearchScope } from "./search-results.js";
|
|
13
|
+
import { type Suggestion, SuggestList } from "./suggest-list.js";
|
|
12
14
|
|
|
13
15
|
const phoneFrame: Decorator = (Story) => (
|
|
14
16
|
<div
|
|
@@ -30,6 +32,7 @@ const topMatches: SearchResult[] = [
|
|
|
30
32
|
{
|
|
31
33
|
id: "r1",
|
|
32
34
|
sender: "Stripe",
|
|
35
|
+
senderEmail: "receipts@stripe.com",
|
|
33
36
|
subject: "Your invoice for March is ready",
|
|
34
37
|
snippet: "Invoice #4821 — €149.00 paid on Visa ending 4242.",
|
|
35
38
|
date: "9:42",
|
|
@@ -39,6 +42,7 @@ const topMatches: SearchResult[] = [
|
|
|
39
42
|
{
|
|
40
43
|
id: "r2",
|
|
41
44
|
sender: "Hetzner Online",
|
|
45
|
+
senderEmail: "billing@hetzner.com",
|
|
42
46
|
subject: "Invoice 2026-03 available in your account",
|
|
43
47
|
snippet: "Dear customer, your invoice for the period is attached.",
|
|
44
48
|
date: "Mar 3",
|
|
@@ -47,6 +51,7 @@ const topMatches: SearchResult[] = [
|
|
|
47
51
|
{
|
|
48
52
|
id: "r3",
|
|
49
53
|
sender: "Anna de Vries",
|
|
54
|
+
senderEmail: "anna@devries.nl",
|
|
50
55
|
subject: "Re: Q1 invoice approval",
|
|
51
56
|
snippet: "Approved — can you forward the PDF invoice to finance?",
|
|
52
57
|
date: "Mar 1",
|
|
@@ -55,6 +60,7 @@ const topMatches: SearchResult[] = [
|
|
|
55
60
|
{
|
|
56
61
|
id: "r4",
|
|
57
62
|
sender: "AWS Billing",
|
|
63
|
+
senderEmail: "no-reply@aws.amazon.com",
|
|
58
64
|
subject: "Your invoice is now available",
|
|
59
65
|
snippet: "Your total for February was $312.55 across 6 services.",
|
|
60
66
|
date: "Feb 28",
|
|
@@ -104,6 +110,7 @@ const crossFolderMatches: SearchResult[] = [
|
|
|
104
110
|
{
|
|
105
111
|
id: "x1",
|
|
106
112
|
sender: "Mollie",
|
|
113
|
+
senderEmail: "info@mollie.com",
|
|
107
114
|
subject: "Invoice 2026-02 — archived",
|
|
108
115
|
snippet: "Filed last month; payment already settled.",
|
|
109
116
|
date: "Feb 24",
|
|
@@ -112,6 +119,7 @@ const crossFolderMatches: SearchResult[] = [
|
|
|
112
119
|
{
|
|
113
120
|
id: "x2",
|
|
114
121
|
sender: "Accountant",
|
|
122
|
+
senderEmail: "jan@boekhouding.example",
|
|
115
123
|
subject: "Invoices for the quarter",
|
|
116
124
|
snippet: "The quarterly set, filed with the rest of the bookkeeping.",
|
|
117
125
|
date: "Jan 30",
|
|
@@ -124,6 +132,7 @@ const spamMatches: SearchResult[] = [
|
|
|
124
132
|
{
|
|
125
133
|
id: "s1",
|
|
126
134
|
sender: "billing@unknown-vendor.test",
|
|
135
|
+
senderEmail: "billing@unknown-vendor.test",
|
|
127
136
|
subject: "URGENT invoice attached",
|
|
128
137
|
snippet: "Wire the amount below within 24 hours to avoid suspension.",
|
|
129
138
|
date: "Feb 11",
|
|
@@ -132,6 +141,7 @@ const spamMatches: SearchResult[] = [
|
|
|
132
141
|
{
|
|
133
142
|
id: "s2",
|
|
134
143
|
sender: "invoices@pay-now.test",
|
|
144
|
+
senderEmail: "invoices@pay-now.test",
|
|
135
145
|
subject: "Outstanding invoice — final notice",
|
|
136
146
|
snippet: "Your account is overdue. Settle immediately.",
|
|
137
147
|
date: "Feb 4",
|
|
@@ -147,6 +157,26 @@ const acrossFoldersSections: SearchResultSection[] = [
|
|
|
147
157
|
},
|
|
148
158
|
];
|
|
149
159
|
|
|
160
|
+
/** The folders `in:` can name, as the app's search vocabulary offers them. */
|
|
161
|
+
const folderSuggestions: Suggestion[] = [
|
|
162
|
+
{ value: "in:Archive", label: "Archive", hint: "matthijs@ischen.nl" },
|
|
163
|
+
{ value: "in:Inbox", label: "Inbox", hint: "matthijs@ischen.nl" },
|
|
164
|
+
{ value: 'in:"Sent Items"', label: "Sent Items", hint: "work@acme.test" },
|
|
165
|
+
{ value: "in:Spam", label: "Spam", hint: "matthijs@ischen.nl" },
|
|
166
|
+
];
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Replace the last term with the completion. The app does this over the caret
|
|
170
|
+
* and the parsed query (`lib/search-suggestions.ts`); the harness keeps the
|
|
171
|
+
* shape so a story shows what picking a row does.
|
|
172
|
+
*/
|
|
173
|
+
const completeTerm = (value: string, suggestion: Suggestion): string => {
|
|
174
|
+
const head = value.slice(0, value.search(/\S*$/));
|
|
175
|
+
return suggestion.value.endsWith(":")
|
|
176
|
+
? head + suggestion.value
|
|
177
|
+
: `${head + suggestion.value} `;
|
|
178
|
+
};
|
|
179
|
+
|
|
150
180
|
type Preset = "brief" | "inbox";
|
|
151
181
|
|
|
152
182
|
function Harness({
|
|
@@ -156,6 +186,8 @@ function Harness({
|
|
|
156
186
|
sections,
|
|
157
187
|
preset,
|
|
158
188
|
scope,
|
|
189
|
+
makeFilterDisabledReason,
|
|
190
|
+
suggestions = [],
|
|
159
191
|
}: {
|
|
160
192
|
initialValue?: string;
|
|
161
193
|
initialChips?: SearchChip[];
|
|
@@ -163,6 +195,10 @@ function Harness({
|
|
|
163
195
|
sections?: SearchResultSection[];
|
|
164
196
|
preset: Preset;
|
|
165
197
|
scope?: SearchScope;
|
|
198
|
+
/** Renders the conversion inert with a reason; it is offered either way. */
|
|
199
|
+
makeFilterDisabledReason?: string;
|
|
200
|
+
/** Completions for the term being typed. */
|
|
201
|
+
suggestions?: Suggestion[];
|
|
166
202
|
}) {
|
|
167
203
|
const [value, setValue] = useState(initialValue);
|
|
168
204
|
const [chips, setChips] = useState<SearchChip[]>(initialChips);
|
|
@@ -172,6 +208,14 @@ function Harness({
|
|
|
172
208
|
const [expanded, setExpanded] = useState(false);
|
|
173
209
|
const [opened, setOpened] = useState<SearchResult | null>(null);
|
|
174
210
|
|
|
211
|
+
const suggest = useSuggestList({
|
|
212
|
+
count: suggestions.length,
|
|
213
|
+
onAccept: (index) => {
|
|
214
|
+
const suggestion = suggestions[index];
|
|
215
|
+
if (suggestion) setValue((current) => completeTerm(current, suggestion));
|
|
216
|
+
},
|
|
217
|
+
});
|
|
218
|
+
|
|
175
219
|
const base =
|
|
176
220
|
preset === "brief"
|
|
177
221
|
? briefFilterConfig(
|
|
@@ -236,6 +280,31 @@ function Harness({
|
|
|
236
280
|
loading={loading}
|
|
237
281
|
onSelectResult={setOpened}
|
|
238
282
|
scope={scope}
|
|
283
|
+
makeFilter={{
|
|
284
|
+
onClick: () => undefined,
|
|
285
|
+
disabledReason: makeFilterDisabledReason,
|
|
286
|
+
}}
|
|
287
|
+
suggest={{
|
|
288
|
+
comboboxProps: suggest.comboboxProps,
|
|
289
|
+
onKeyDown: suggest.handleKeyDown,
|
|
290
|
+
onCaretChange: () => undefined,
|
|
291
|
+
}}
|
|
292
|
+
suggestList={
|
|
293
|
+
suggest.open ? (
|
|
294
|
+
<SuggestList
|
|
295
|
+
id={suggest.listId}
|
|
296
|
+
suggestions={suggestions}
|
|
297
|
+
activeIndex={suggest.activeIndex}
|
|
298
|
+
optionId={suggest.optionId}
|
|
299
|
+
onPick={(suggestion) =>
|
|
300
|
+
setValue((current) => completeTerm(current, suggestion))
|
|
301
|
+
}
|
|
302
|
+
onHighlight={suggest.setActiveIndex}
|
|
303
|
+
label="Search suggestions"
|
|
304
|
+
className="mx-row-inset mt-1 shrink-0"
|
|
305
|
+
/>
|
|
306
|
+
) : null
|
|
307
|
+
}
|
|
239
308
|
/>
|
|
240
309
|
);
|
|
241
310
|
}
|
|
@@ -251,9 +320,10 @@ export default meta;
|
|
|
251
320
|
type Story = StoryObj<typeof MobileSearchView>;
|
|
252
321
|
|
|
253
322
|
/**
|
|
254
|
-
* Global search — the daily-brief preset
|
|
255
|
-
*
|
|
256
|
-
* The header carries a single X that clears the query AND
|
|
323
|
+
* Global search — the daily-brief preset. The filter chrome is not up: a query
|
|
324
|
+
* supersedes it, and the row above the results belongs to the conversion the
|
|
325
|
+
* search itself offers. The header carries a single X that clears the query AND
|
|
326
|
+
* dismisses the takeover.
|
|
257
327
|
*/
|
|
258
328
|
export const GlobalSearch: Story = {
|
|
259
329
|
render: () => (
|
|
@@ -262,8 +332,9 @@ export const GlobalSearch: Story = {
|
|
|
262
332
|
};
|
|
263
333
|
|
|
264
334
|
/**
|
|
265
|
-
* Scoped search — a single inbox
|
|
266
|
-
*
|
|
335
|
+
* Scoped search — a single inbox. It reads exactly like the global one above:
|
|
336
|
+
* search is scoped by where the user is and differs in nothing else, so the
|
|
337
|
+
* conversion is offered here too.
|
|
267
338
|
*/
|
|
268
339
|
export const ScopedSearch: Story = {
|
|
269
340
|
render: () => (
|
|
@@ -271,7 +342,45 @@ export const ScopedSearch: Story = {
|
|
|
271
342
|
),
|
|
272
343
|
};
|
|
273
344
|
|
|
274
|
-
/**
|
|
345
|
+
/**
|
|
346
|
+
* A query with nothing a filter could match on: the conversion is offered inert
|
|
347
|
+
* with the reason, rather than withheld and leaving the row to appear and vanish
|
|
348
|
+
* as the user types.
|
|
349
|
+
*/
|
|
350
|
+
export const NothingToConvert: Story = {
|
|
351
|
+
render: () => (
|
|
352
|
+
<Harness
|
|
353
|
+
initialValue="has:attachment"
|
|
354
|
+
sections={resultSections}
|
|
355
|
+
preset="inbox"
|
|
356
|
+
makeFilterDisabledReason="Add a sender or words to filter on"
|
|
357
|
+
/>
|
|
358
|
+
),
|
|
359
|
+
};
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* A committed token name: the folders `in:` can name, offered under the field.
|
|
363
|
+
*
|
|
364
|
+
* The list takes its own space between the field and the results rather than
|
|
365
|
+
* floating over them — a soft keyboard owns the lower half of this screen, and a
|
|
366
|
+
* list over the field would cover the query it is completing. Arrows move the
|
|
367
|
+
* highlight, Enter takes it, Escape closes the list and leaves the query.
|
|
368
|
+
*/
|
|
369
|
+
export const SuggestingFolders: Story = {
|
|
370
|
+
render: () => (
|
|
371
|
+
<Harness
|
|
372
|
+
initialValue="in:"
|
|
373
|
+
sections={resultSections}
|
|
374
|
+
preset="inbox"
|
|
375
|
+
suggestions={folderSuggestions}
|
|
376
|
+
/>
|
|
377
|
+
),
|
|
378
|
+
};
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Empty query: recent searches under the brief filter chrome. Nothing is being
|
|
382
|
+
* searched, so the filter sheet has the row — typing hands it to the results.
|
|
383
|
+
*/
|
|
275
384
|
export const Idle: Story = {
|
|
276
385
|
render: () => <Harness preset="brief" />,
|
|
277
386
|
};
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { X } from "lucide-react";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
2
3
|
import { Button } from "./button.js";
|
|
3
4
|
import { FilterSheet, type FilterSheetProps } from "./filter-sheet.js";
|
|
4
5
|
import { SearchBar } from "./search-bar.js";
|
|
5
|
-
import type { SearchChip } from "./search-chip-input.js";
|
|
6
|
+
import type { SearchChip, SearchFieldSuggest } from "./search-chip-input.js";
|
|
6
7
|
import type { SearchResult } from "./search-result-row.js";
|
|
7
8
|
import {
|
|
9
|
+
type MakeFilterActionProps,
|
|
8
10
|
type SearchResultSection,
|
|
9
11
|
SearchResults,
|
|
10
12
|
type SearchScope,
|
|
@@ -22,8 +24,10 @@ export interface MobileSearchViewProps {
|
|
|
22
24
|
onCancel: () => void;
|
|
23
25
|
/**
|
|
24
26
|
* The shared FilterSheet config (categories + Unread/Flagged/attachment, plus
|
|
25
|
-
* the brief-only account source row)
|
|
26
|
-
* `
|
|
27
|
+
* the brief-only account source row), shown over the recent searches while the
|
|
28
|
+
* field is empty. Feed it `briefFilterConfig(accounts)` or `inboxFilterConfig()`
|
|
29
|
+
* from `filter-presets`. Omit to drop the filter chrome. A query supersedes it —
|
|
30
|
+
* see the component doc.
|
|
27
31
|
*/
|
|
28
32
|
filter?: Omit<FilterSheetProps, "children">;
|
|
29
33
|
/** Recent searches shown when the query is empty. */
|
|
@@ -47,7 +51,16 @@ export interface MobileSearchViewProps {
|
|
|
47
51
|
/** What the search covers; see `SearchResultsProps`. Defaults to global. */
|
|
48
52
|
scope?: SearchScope;
|
|
49
53
|
/** "Make this a filter" affordance; see `SearchResultsProps`. */
|
|
50
|
-
makeFilter?:
|
|
54
|
+
makeFilter?: MakeFilterActionProps;
|
|
55
|
+
/** Completions for what is being typed; see `SearchChipInput`. */
|
|
56
|
+
suggest?: SearchFieldSuggest;
|
|
57
|
+
/**
|
|
58
|
+
* The suggestion list itself, rendered directly under the field and above
|
|
59
|
+
* the results. In flow rather than over them: on a phone the soft keyboard
|
|
60
|
+
* takes the lower half of the screen, and a list floating over the field
|
|
61
|
+
* would hide the query it is completing.
|
|
62
|
+
*/
|
|
63
|
+
suggestList?: ReactNode;
|
|
51
64
|
}
|
|
52
65
|
|
|
53
66
|
/**
|
|
@@ -57,9 +70,17 @@ export interface MobileSearchViewProps {
|
|
|
57
70
|
* there is exactly one X). Below the bar the shared `SearchResults` body rides
|
|
58
71
|
* inside the shared `FilterSheet` (the
|
|
59
72
|
* same categories, Unread/Flagged/attachment toggles, and brief-only account row
|
|
60
|
-
* the inboxes use)
|
|
61
|
-
*
|
|
62
|
-
*
|
|
73
|
+
* the inboxes use); pass no `filter` to drop the chrome. Desktop reuses the same
|
|
74
|
+
* `SearchResults` body in the list pane. Presentational and prop-driven.
|
|
75
|
+
*
|
|
76
|
+
* A query supersedes the filter row. The two narrow the same list by the same
|
|
77
|
+
* intent, and the row and the search's own "Make this a filter" affordance sit in
|
|
78
|
+
* the same place, so the filter chrome belongs to the empty field: it covers the
|
|
79
|
+
* recent searches, and the moment something is typed the results and their
|
|
80
|
+
* affordance take the space. Clearing the field brings it back.
|
|
81
|
+
*
|
|
82
|
+
* Completions for what is being typed sit between the field and the body, so
|
|
83
|
+
* the list takes its own space rather than covering either of them.
|
|
63
84
|
*
|
|
64
85
|
* Search scope passes straight through, so the phone tier holds spam out,
|
|
65
86
|
* offers it and labels provenance on exactly the same terms as desktop.
|
|
@@ -80,6 +101,8 @@ export function MobileSearchView({
|
|
|
80
101
|
onRemoveChip,
|
|
81
102
|
scope,
|
|
82
103
|
makeFilter,
|
|
104
|
+
suggest,
|
|
105
|
+
suggestList,
|
|
83
106
|
}: MobileSearchViewProps) {
|
|
84
107
|
const body = (
|
|
85
108
|
<SearchResults
|
|
@@ -107,6 +130,7 @@ export function MobileSearchView({
|
|
|
107
130
|
onRemoveChip={onRemoveChip}
|
|
108
131
|
globalFocusKey={false}
|
|
109
132
|
showClearButton={false}
|
|
133
|
+
suggest={suggest}
|
|
110
134
|
/>
|
|
111
135
|
</div>
|
|
112
136
|
<Button
|
|
@@ -119,7 +143,9 @@ export function MobileSearchView({
|
|
|
119
143
|
/>
|
|
120
144
|
</header>
|
|
121
145
|
|
|
122
|
-
{
|
|
146
|
+
{suggestList}
|
|
147
|
+
|
|
148
|
+
{filter && value.trim().length === 0 ? (
|
|
123
149
|
<FilterSheet {...filter}>{body}</FilterSheet>
|
|
124
150
|
) : (
|
|
125
151
|
<div className="flex-1 overflow-y-auto">{body}</div>
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The reveal toggle: it starts hidden, flips only the input's `type`, and
|
|
3
|
+
* renames itself for the state it will produce next. Mounted against jsdom
|
|
4
|
+
* since the toggle is internal state driven by a real click. React is imported
|
|
5
|
+
* after the jsdom globals are installed so the controlled-input value tracker
|
|
6
|
+
* binds to jsdom's prototypes.
|
|
7
|
+
*/
|
|
8
|
+
import assert from "node:assert/strict";
|
|
9
|
+
import { after, afterEach, before, beforeEach, describe, it } from "node:test";
|
|
10
|
+
import type { JSDOM } from "jsdom";
|
|
11
|
+
import type {
|
|
12
|
+
act as reactAct,
|
|
13
|
+
createElement as reactCreateElement,
|
|
14
|
+
} from "react";
|
|
15
|
+
import type { Root, createRoot as reactCreateRoot } from "react-dom/client";
|
|
16
|
+
import type { PasswordInput as PasswordInputType } from "./password-input.js";
|
|
17
|
+
|
|
18
|
+
let dom: JSDOM;
|
|
19
|
+
let container: HTMLElement;
|
|
20
|
+
let root: Root;
|
|
21
|
+
let act: typeof reactAct;
|
|
22
|
+
let createElement: typeof reactCreateElement;
|
|
23
|
+
let createRoot: typeof reactCreateRoot;
|
|
24
|
+
let PasswordInput: typeof PasswordInputType;
|
|
25
|
+
|
|
26
|
+
before(async () => {
|
|
27
|
+
const { JSDOM: JSDOMCtor } = await import("jsdom");
|
|
28
|
+
dom = new JSDOMCtor(
|
|
29
|
+
"<!doctype html><html><body><div id=root></div></body></html>",
|
|
30
|
+
{ url: "http://localhost/", pretendToBeVisual: true },
|
|
31
|
+
);
|
|
32
|
+
globalThis.window = dom.window as unknown as typeof globalThis.window;
|
|
33
|
+
globalThis.document = dom.window.document;
|
|
34
|
+
globalThis.HTMLElement = dom.window.HTMLElement;
|
|
35
|
+
globalThis.Element = dom.window.Element;
|
|
36
|
+
globalThis.Event = dom.window.Event;
|
|
37
|
+
Object.defineProperty(globalThis, "navigator", {
|
|
38
|
+
value: dom.window.navigator,
|
|
39
|
+
configurable: true,
|
|
40
|
+
});
|
|
41
|
+
(
|
|
42
|
+
globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
|
43
|
+
).IS_REACT_ACT_ENVIRONMENT = true;
|
|
44
|
+
|
|
45
|
+
const react = await import("react");
|
|
46
|
+
act = react.act;
|
|
47
|
+
createElement = react.createElement;
|
|
48
|
+
({ createRoot } = await import("react-dom/client"));
|
|
49
|
+
({ PasswordInput } = await import("./password-input.js"));
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
after(() => {
|
|
53
|
+
dom.window.close();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
beforeEach(() => {
|
|
57
|
+
container = dom.window.document.getElementById(
|
|
58
|
+
"root",
|
|
59
|
+
) as unknown as HTMLElement;
|
|
60
|
+
container.innerHTML = "";
|
|
61
|
+
root = createRoot(container);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
afterEach(() => {
|
|
65
|
+
act(() => {
|
|
66
|
+
root.unmount();
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
function field(): HTMLInputElement {
|
|
71
|
+
return container.querySelector("input") as HTMLInputElement;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function toggle(): HTMLButtonElement {
|
|
75
|
+
return container.querySelector("button") as HTMLButtonElement;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function click(button: HTMLButtonElement) {
|
|
79
|
+
act(() => {
|
|
80
|
+
button.click();
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
describe("PasswordInput", () => {
|
|
85
|
+
it("hides the value and offers to show it", () => {
|
|
86
|
+
act(() => {
|
|
87
|
+
root.render(createElement(PasswordInput, { id: "pw" }));
|
|
88
|
+
});
|
|
89
|
+
assert.equal(field().getAttribute("type"), "password");
|
|
90
|
+
assert.equal(toggle().getAttribute("aria-label"), "Show password");
|
|
91
|
+
assert.equal(toggle().getAttribute("aria-pressed"), "false");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("reveals the value and offers to hide it again", () => {
|
|
95
|
+
act(() => {
|
|
96
|
+
root.render(createElement(PasswordInput, { id: "pw" }));
|
|
97
|
+
});
|
|
98
|
+
click(toggle());
|
|
99
|
+
assert.equal(field().getAttribute("type"), "text");
|
|
100
|
+
assert.equal(toggle().getAttribute("aria-label"), "Hide password");
|
|
101
|
+
assert.equal(toggle().getAttribute("aria-pressed"), "true");
|
|
102
|
+
|
|
103
|
+
click(toggle());
|
|
104
|
+
assert.equal(field().getAttribute("type"), "password");
|
|
105
|
+
assert.equal(toggle().getAttribute("aria-label"), "Show password");
|
|
106
|
+
assert.equal(toggle().getAttribute("aria-pressed"), "false");
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("is a button that never submits its form", () => {
|
|
110
|
+
let submits = 0;
|
|
111
|
+
act(() => {
|
|
112
|
+
root.render(
|
|
113
|
+
createElement(
|
|
114
|
+
"form",
|
|
115
|
+
{ onSubmit: () => submits++ },
|
|
116
|
+
createElement(PasswordInput, { id: "pw" }),
|
|
117
|
+
),
|
|
118
|
+
);
|
|
119
|
+
});
|
|
120
|
+
assert.equal(toggle().getAttribute("type"), "button");
|
|
121
|
+
click(toggle());
|
|
122
|
+
assert.equal(submits, 0);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("leaves the attributes password managers key off untouched", () => {
|
|
126
|
+
act(() => {
|
|
127
|
+
root.render(
|
|
128
|
+
createElement(PasswordInput, {
|
|
129
|
+
id: "pw",
|
|
130
|
+
name: "password",
|
|
131
|
+
autoComplete: "current-password",
|
|
132
|
+
required: true,
|
|
133
|
+
}),
|
|
134
|
+
);
|
|
135
|
+
});
|
|
136
|
+
click(toggle());
|
|
137
|
+
assert.equal(field().getAttribute("name"), "password");
|
|
138
|
+
assert.equal(field().getAttribute("autocomplete"), "current-password");
|
|
139
|
+
assert.equal(field().hasAttribute("required"), true);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("does not take focus from the field", () => {
|
|
143
|
+
act(() => {
|
|
144
|
+
root.render(createElement(PasswordInput, { id: "pw" }));
|
|
145
|
+
});
|
|
146
|
+
assert.notEqual(dom.window.document.activeElement, toggle());
|
|
147
|
+
assert.equal(toggle().hasAttribute("autofocus"), false);
|
|
148
|
+
});
|
|
149
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { Eye, EyeOff } from "lucide-react";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
import { Input, type InputProps } from "./input.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Everything `Input` takes except the field chrome this component owns:
|
|
7
|
+
* `type` flips between "password" and "text", `trailing` holds the toggle, and
|
|
8
|
+
* the reveal button's inset assumes the boxed `default` variant.
|
|
9
|
+
*/
|
|
10
|
+
export type PasswordInputProps = Omit<
|
|
11
|
+
InputProps,
|
|
12
|
+
"type" | "trailing" | "variant"
|
|
13
|
+
>;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* A password field with an in-field reveal toggle. Only `type` changes when
|
|
17
|
+
* revealed — `name`, `autoComplete` and the rest reach the input untouched, so
|
|
18
|
+
* password managers and autofill behave exactly as they do on a bare field.
|
|
19
|
+
*/
|
|
20
|
+
export function PasswordInput(props: PasswordInputProps) {
|
|
21
|
+
const [revealed, setRevealed] = useState(false);
|
|
22
|
+
const RevealIcon = revealed ? EyeOff : Eye;
|
|
23
|
+
const label = revealed ? "Hide password" : "Show password";
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
<Input
|
|
27
|
+
{...props}
|
|
28
|
+
type={revealed ? "text" : "password"}
|
|
29
|
+
trailing={
|
|
30
|
+
// Negative margins grow the hit area to 44px square without
|
|
31
|
+
// changing the field's height.
|
|
32
|
+
<button
|
|
33
|
+
type="button"
|
|
34
|
+
aria-label={label}
|
|
35
|
+
aria-pressed={revealed}
|
|
36
|
+
title={label}
|
|
37
|
+
onClick={() => setRevealed((shown) => !shown)}
|
|
38
|
+
className="-my-1 -mr-2 flex size-11 shrink-0 cursor-pointer items-center justify-center rounded-md text-fg-subtle transition-colors hover:text-fg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
39
|
+
>
|
|
40
|
+
<RevealIcon className="size-4" aria-hidden />
|
|
41
|
+
</button>
|
|
42
|
+
}
|
|
43
|
+
/>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
@@ -7,6 +7,7 @@ import { Card, CardBody, CardHeader, CardTitle } from "./card.js";
|
|
|
7
7
|
import { FieldLabel } from "./field-label.js";
|
|
8
8
|
import { Input } from "./input.js";
|
|
9
9
|
import { ListItem } from "./list-item.js";
|
|
10
|
+
import { PasswordInput } from "./password-input.js";
|
|
10
11
|
import { SecuritySelect } from "./security-select.js";
|
|
11
12
|
import { Select } from "./select.js";
|
|
12
13
|
|
|
@@ -74,6 +75,33 @@ export const Inputs: Story = {
|
|
|
74
75
|
),
|
|
75
76
|
};
|
|
76
77
|
|
|
78
|
+
export const PasswordInputs: Story = {
|
|
79
|
+
render: () => (
|
|
80
|
+
<div className="max-w-sm space-y-3 p-8">
|
|
81
|
+
<div>
|
|
82
|
+
<FieldLabel htmlFor="pw-empty">Password</FieldLabel>
|
|
83
|
+
<PasswordInput
|
|
84
|
+
id="pw-empty"
|
|
85
|
+
autoComplete="current-password"
|
|
86
|
+
placeholder="Your password"
|
|
87
|
+
/>
|
|
88
|
+
</div>
|
|
89
|
+
<div>
|
|
90
|
+
<FieldLabel htmlFor="pw-filled">Password or app password</FieldLabel>
|
|
91
|
+
<PasswordInput
|
|
92
|
+
id="pw-filled"
|
|
93
|
+
autoComplete="current-password"
|
|
94
|
+
defaultValue="hunter2-app-password"
|
|
95
|
+
/>
|
|
96
|
+
</div>
|
|
97
|
+
<p className="text-xs text-fg-subtle">
|
|
98
|
+
The toggle starts hidden. Its hit area is 44px square and sits beside
|
|
99
|
+
the value, never over it.
|
|
100
|
+
</p>
|
|
101
|
+
</div>
|
|
102
|
+
),
|
|
103
|
+
};
|
|
104
|
+
|
|
77
105
|
export const InlineInputs: Story = {
|
|
78
106
|
render: () => (
|
|
79
107
|
<div className="max-w-sm space-y-3 p-8">
|
|
@@ -2,6 +2,7 @@ import {
|
|
|
2
2
|
type SearchChip,
|
|
3
3
|
SearchChipInput,
|
|
4
4
|
type SearchChipInputProps,
|
|
5
|
+
type SearchFieldSuggest,
|
|
5
6
|
} from "./search-chip-input.js";
|
|
6
7
|
|
|
7
8
|
export type { SearchChip };
|
|
@@ -46,6 +47,12 @@ export interface SearchBarProps {
|
|
|
46
47
|
* enclosing label.
|
|
47
48
|
*/
|
|
48
49
|
inputId?: string;
|
|
50
|
+
/**
|
|
51
|
+
* Completions for what is being typed; see `SearchChipInput`. The host
|
|
52
|
+
* renders the list under the field, so the field itself takes only the
|
|
53
|
+
* caret, the keys and the ARIA.
|
|
54
|
+
*/
|
|
55
|
+
suggest?: SearchFieldSuggest;
|
|
49
56
|
className?: string;
|
|
50
57
|
}
|
|
51
58
|
|
|
@@ -67,6 +74,7 @@ export const SearchBar = ({
|
|
|
67
74
|
showClearButton = true,
|
|
68
75
|
size,
|
|
69
76
|
inputId,
|
|
77
|
+
suggest,
|
|
70
78
|
className,
|
|
71
79
|
}: SearchBarProps) => (
|
|
72
80
|
<SearchChipInput
|
|
@@ -81,6 +89,7 @@ export const SearchBar = ({
|
|
|
81
89
|
showClearButton={showClearButton}
|
|
82
90
|
size={size}
|
|
83
91
|
inputId={inputId}
|
|
92
|
+
suggest={suggest}
|
|
84
93
|
className={className}
|
|
85
94
|
/>
|
|
86
95
|
);
|