@estiva-app/ui 0.15.0 → 0.16.0

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 (38) hide show
  1. package/dist/Breadcrumb.d.ts.map +1 -1
  2. package/dist/CommandPalette.d.ts +114 -0
  3. package/dist/CommandPalette.d.ts.map +1 -0
  4. package/dist/Menu.d.ts +4 -0
  5. package/dist/Menu.d.ts.map +1 -1
  6. package/dist/Toast.d.ts.map +1 -1
  7. package/dist/eslint/has-a-page-and-a-story.d.ts +4 -0
  8. package/dist/eslint/has-a-page-and-a-story.d.ts.map +1 -0
  9. package/dist/eslint/index.d.ts +15 -1
  10. package/dist/eslint/index.d.ts.map +1 -1
  11. package/dist/eslint/index.js +176 -8
  12. package/dist/eslint/index.js.map +3 -3
  13. package/dist/eslint/no-hand-rolled-behaviour.d.ts +3 -0
  14. package/dist/eslint/no-hand-rolled-behaviour.d.ts.map +1 -0
  15. package/dist/eslint/raw-element-outside-a-wrapper.d.ts +29 -0
  16. package/dist/eslint/raw-element-outside-a-wrapper.d.ts.map +1 -0
  17. package/dist/index.d.ts +1 -0
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +609 -287
  20. package/dist/index.js.map +4 -4
  21. package/package.json +5 -1
  22. package/src/Breadcrumb.tsx +6 -2
  23. package/src/CommandPalette.mdx +133 -0
  24. package/src/CommandPalette.stories.tsx +416 -0
  25. package/src/CommandPalette.test.tsx +392 -0
  26. package/src/CommandPalette.tsx +643 -0
  27. package/src/Menu.tsx +4 -2
  28. package/src/Toast.tsx +12 -24
  29. package/src/eslint/has-a-page-and-a-story.test.ts +57 -0
  30. package/src/eslint/has-a-page-and-a-story.ts +97 -0
  31. package/src/eslint/index.test.ts +35 -3
  32. package/src/eslint/index.ts +58 -13
  33. package/src/eslint/no-hand-rolled-behaviour.test.ts +70 -0
  34. package/src/eslint/no-hand-rolled-behaviour.ts +116 -0
  35. package/src/eslint/raw-element-outside-a-wrapper.test.ts +82 -0
  36. package/src/eslint/raw-element-outside-a-wrapper.ts +85 -0
  37. package/src/index.ts +17 -0
  38. package/stories/Choosing.mdx +1 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@estiva-app/ui",
3
- "version": "0.15.0",
3
+ "version": "0.16.0",
4
4
  "description": "Estiva's design tokens (the contract) and a small set of primitives (a convenience) for every Estiva app.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -36,7 +36,11 @@
36
36
  "test:watch": "vitest --project unit",
37
37
  "test:a11y": "vitest run --project storybook-signal --project storybook-ship",
38
38
  "typecheck": "tsc --noEmit",
39
+ "prelint": "node build.mjs",
39
40
  "lint": "eslint .",
41
+ "prelint:rules": "node build.mjs",
42
+ "lint:rules": "eslint --config eslint.gates.config.js .",
43
+ "postlint:rules": "node scripts/gates-count.mjs",
40
44
  "gates:status": "node scripts/gates-status.mjs",
41
45
  "prepublishOnly": "npm run build"
42
46
  },
@@ -1,5 +1,6 @@
1
1
  import { Fragment, useCallback, useLayoutEffect, useRef, useState, type MouseEventHandler, type ReactNode } from 'react'
2
2
  import { cn } from './cn'
3
+ import { Link } from './Link'
3
4
  import { WithTooltip } from './Tooltip'
4
5
 
5
6
  /**
@@ -88,9 +89,12 @@ export function Breadcrumb({ items, className }: BreadcrumbProps) {
88
89
  labelRefs.current[index] = el
89
90
  }
90
91
  const crumb = item.href ? (
91
- <a ref={setLabelRef} href={item.href} onClick={item.onClick} className={cn(text, 'hover:text-text-primary')}>
92
+ // `plain` adds no look of its own: a crumb keeps the trail's size and
93
+ // tone, and brightens on hover. The package's own Link, so a trail
94
+ // follows whatever a link learns to do (UIG-5).
95
+ <Link ref={setLabelRef} variant="plain" href={item.href} onClick={item.onClick} className={cn(text, 'hover:text-text-primary')}>
92
96
  {item.label}
93
- </a>
97
+ </Link>
94
98
  ) : (
95
99
  <span ref={setLabelRef} className={text} aria-current={last ? 'page' : undefined}>
96
100
  {item.label}
@@ -0,0 +1,133 @@
1
+ import { Meta, Canvas, Controls } from '@storybook/addon-docs/blocks'
2
+ import * as CommandPaletteStories from './CommandPalette.stories'
3
+
4
+ <Meta of={CommandPaletteStories} />
5
+
6
+ # CommandPalette
7
+
8
+ A window that searches and runs things: a field, rows in groups, and a footer
9
+ naming the keys that work right now. It shows one level at a time — rows, or a
10
+ form — and the app keeps the levels.
11
+
12
+ <Canvas of={CommandPaletteStories.FirstLevel} />
13
+
14
+ ## When
15
+
16
+ - **One place to find something and act on it**, from anywhere in an app,
17
+ opened by a shortcut the app binds.
18
+ - **`CommandPaletteSearch`** — a level of rows. Without a `chip` it is the
19
+ first level; with one, you are inside something, and the chip names it.
20
+ - **`CommandPaletteForm`** — a level that is a form: what a row creates or
21
+ changes, without leaving the palette.
22
+ - **`CommandPaletteWorking`, `CommandPaletteAnswer`, `CommandPaletteQuote`** —
23
+ above the rows, for a result that is written rather than listed: a line while
24
+ it is on its way, an answer with marks pointing at the rows it came from, and
25
+ text written to be used somewhere else.
26
+
27
+ <Canvas of={CommandPaletteStories.Answer} />
28
+
29
+ <Canvas of={CommandPaletteStories.Form} />
30
+
31
+ ## When not
32
+
33
+ - A short list of verbs from a trigger → **Menu**.
34
+ - One value for a field → **Select**; several → **ChipInput**.
35
+ - A form with a title and buttons of its own, not reached from a palette →
36
+ **DialogShell**. The palette is not built on it: a palette has no title bar,
37
+ no close button and no button row, and on the shell it would be the shell with
38
+ every slot switched off.
39
+ - Filtering a list that stays on the page → **SearchInput**.
40
+
41
+ ## How
42
+
43
+ ```tsx
44
+ import { CommandPalette, CommandPaletteSearch } from '@estiva-app/ui'
45
+
46
+ <CommandPalette open={open} onOpenChange={setOpen} label="Command palette" where="In Item one" modKey="Ctrl">
47
+ <CommandPaletteSearch
48
+ query={query}
49
+ onQueryChange={setQuery}
50
+ placeholder="Search, or choose a place"
51
+ groups={[
52
+ { label: 'Recent', rows: [{ id: 'item-one', label: 'Item one', icon: <IconSquareRounded size={16} stroke={1.5} />, onSelect: open, onForget: forget }] },
53
+ { label: 'Places', rows: [{ id: 'place-one', label: 'Place one', icon, onSelect: goIn, onGoIn: goIn }] },
54
+ ]}
55
+ />
56
+ </CommandPalette>
57
+ ```
58
+
59
+ - **The app opens it.** The palette listens to nothing outside its own window:
60
+ bind the shortcut in the app, pass `open`, and give `modKey` the platform's
61
+ spelling (`Cmd` on a Mac). The keys answer to Ctrl and Cmd either way.
62
+ - **One level at a time, and the app keeps the stack.** Render a
63
+ `CommandPaletteSearch` or a `CommandPaletteForm` as the only child, and keep
64
+ each level's query yourself, so going back puts back the text that was there.
65
+ A level inside another passes `chip` — its label, and `onBack`.
66
+ - **A row says what it can do, and the keys and the footer follow.** `onSelect`
67
+ is Enter and a click. `onGoIn` makes Tab, and → at the end of the text, go in,
68
+ and draws the chevron. `onForget` makes Ctrl+Backspace forget it — for rows
69
+ that are the person's own history. The footer names only what the lit row
70
+ answers to.
71
+ - **`icon` sits on the row's 32px tile; `leading` takes the same 32px with no
72
+ tile**, for something with a look of its own — a face, a status mark.
73
+ - **`id` is unique in the level.** The lit row is followed by it, so a caller
74
+ that builds its rows again on every render still runs the handler of the row
75
+ on screen.
76
+ - **`pending` while rows are still on their way; `empty` only once none are.**
77
+ Pass `empty` while a search is running and the page says "nothing" before it
78
+ knows.
79
+ - **Rows that arrive late do not take the highlight.** Base UI keeps the
80
+ highlight's position rather than its row, so a group arriving above the lit
81
+ row would hand the highlight — and Enter — to something else. The palette
82
+ walks it back to the row you had. Typing starts again from the first row.
83
+ - **Home and End move the text cursor, and nothing else.** Base UI would also
84
+ send the highlight to the first or last row; the palette stops that.
85
+ - **The field is named by its placeholder**, so say what it asks for.
86
+ - **A form's fields go in `Field`s.** Mark the missing ones with `error` inside
87
+ `onSubmit`, and the first of them takes focus. `working` locks the fields,
88
+ puts its words on the button and beside it, and holds focus on the form so
89
+ Esc still closes it; when it clears, focus goes to the first field that needs
90
+ something, or back where it was. `error` is what went wrong, beside the
91
+ button. `submitWaits` says why there is nothing to submit yet, on the button,
92
+ and Ctrl+Enter does nothing until it clears.
93
+ - **Focus never falls out of a level.** When the element that had it goes — a
94
+ row picked, a level swapped — focus goes to the level's first control.
95
+
96
+ ## Keys
97
+
98
+ | Where | Input | What happens |
99
+ |---|---|---|
100
+ | a level of rows | ↑ / ↓ | move between rows, and stop at the ends |
101
+ | | Enter, or a click | what the lit row does |
102
+ | | Tab | goes in, on a row that leads somewhere; anywhere else nothing, and focus stays in the field |
103
+ | | → at the end of the text | goes in, on a row that leads somewhere; anywhere else it moves the cursor |
104
+ | | Ctrl+Backspace | forgets the lit row, if it can be forgotten; otherwise deletes a word |
105
+ | | Home / End | move the text cursor; the lit row stays |
106
+ | inside a level | Backspace at the start of the field, or the chip's ✕ | back, with focus in the field |
107
+ | a form | Tab / Shift+Tab | between the fields, never out of the window |
108
+ | | Ctrl+Enter | submits; if a field needs something, focus goes to the first |
109
+ | | Backspace in an empty text field | back. In a form that is one list, Backspace anywhere; in a list inside a form with text fields, nothing |
110
+ | | while working | nothing but Esc |
111
+ | | Esc in an open list | closes the list first |
112
+ | anywhere | Esc, or a click outside | closes; focus returns to whatever opened it |
113
+
114
+ ## Props
115
+
116
+ <Controls of={CommandPaletteStories.FirstLevel} />
117
+
118
+ **`CommandPaletteSearch`** — `query` and `onQueryChange` (the field, controlled),
119
+ `placeholder` (also the field's name), `groups` (`{ label, rows }`, a group
120
+ with no rows is not drawn), `chip?` (`{ label, leading?, onBack }`), `pending?`,
121
+ `empty?`, and `children` for what sits above the rows.
122
+
123
+ **A row** — `id`, `label`, `description?`, `icon?` or `leading?`, `onSelect`,
124
+ `onGoIn?`, `onForget?`.
125
+
126
+ **`CommandPaletteForm`** — `chip`, `icon?`, `submitLabel`, `onSubmit`,
127
+ `submitWaits?`, `working?` (`{ button, line }`), `error?`, and the fields as
128
+ `children`.
129
+
130
+ **`CommandPaletteWorking`** — the words as `children`, `bars?` (0–3, default 2).
131
+ **`CommandPaletteAnswer`** — the text as `children` (a blank line starts a
132
+ paragraph; `[1]` is a mark), `note?`. **`CommandPaletteQuote`** — the text as
133
+ `children`, line breaks kept.
@@ -0,0 +1,416 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { useEffect, useMemo, useState } from 'react'
3
+ import { IconSquareRounded } from '@tabler/icons-react'
4
+ import { Avatar } from './Avatar'
5
+ import { Button } from './Button'
6
+ import {
7
+ CommandPalette,
8
+ CommandPaletteAnswer,
9
+ CommandPaletteForm,
10
+ CommandPaletteQuote,
11
+ CommandPaletteSearch,
12
+ CommandPaletteWorking,
13
+ type CommandPaletteGroup,
14
+ type CommandPaletteRow,
15
+ } from './CommandPalette'
16
+ import { Field } from './Field'
17
+ import { Select } from './Select'
18
+ import { TextInput } from './TextInput'
19
+ import { Textarea } from './Textarea'
20
+
21
+ /**
22
+ * A window that searches and runs things. It shows one level at a time — a
23
+ * level of rows, or a form — and the caller keeps the levels. Every story
24
+ * below is the same small caller, started at a different place, so every key
25
+ * works in all of them.
26
+ *
27
+ * Nothing here is fetched: what "arrives later" is a timer.
28
+ */
29
+ const meta = {
30
+ title: 'Overlays/CommandPalette',
31
+ component: CommandPalette,
32
+ parameters: {
33
+ layout: 'fullscreen',
34
+ // Portals a fixed overlay to document.body — an iframe on the Docs page,
35
+ // so it does not escape over the docs content.
36
+ docs: { story: { inline: false, height: '640px' } },
37
+ // axe color-contrast is off here until PLAN.md stage 0.10 is ruled: the
38
+ // footer and the "Searching…" and empty lines are muted text, 3.49:1 on
39
+ // --bg-elevated in signal (AA 4.5:1), measured by CI on PR #43; ship passes.
40
+ a11y: { config: { rules: [{ id: 'color-contrast', enabled: false }] } },
41
+ },
42
+ args: { open: true, onOpenChange: () => {}, label: 'Command palette', where: 'In Item one', modKey: 'Ctrl', children: null },
43
+ argTypes: { children: { control: false }, onOpenChange: { control: false }, open: { control: false } },
44
+ } satisfies Meta<typeof CommandPalette>
45
+
46
+ export default meta
47
+ type Story = StoryObj<typeof meta>
48
+
49
+ const icon = <IconSquareRounded size={16} stroke={1.5} />
50
+ const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
51
+
52
+ type Level =
53
+ | { kind: 'first' }
54
+ | { kind: 'place'; name: string }
55
+ | { kind: 'ask' }
56
+ | { kind: 'draft' }
57
+ | { kind: 'form' }
58
+ | { kind: 'one-list' }
59
+
60
+ interface Frame {
61
+ level: Level
62
+ query: string
63
+ }
64
+
65
+ type Start = 'first' | 'arriving' | 'nothing' | 'place' | 'working' | 'answer' | 'draft' | 'form' | 'refused' | 'one-list'
66
+
67
+ const PLACES = ['Place one', 'Place two']
68
+ const ACTIONS = [
69
+ { id: 'action-one', label: 'Action one', description: 'Opens a form' },
70
+ { id: 'action-two', label: 'Action two', description: 'Opens a form' },
71
+ ]
72
+ const LATER = ['Item one, as it was on Monday', 'Item three, where item one is named']
73
+ const KINDS = [
74
+ { value: 'one', label: 'Kind one' },
75
+ { value: 'two', label: 'Kind two' },
76
+ { value: 'three', label: 'Kind three' },
77
+ ]
78
+ const GROUPS = [
79
+ { value: 'first', label: 'Group one' },
80
+ { value: 'second', label: 'Group two' },
81
+ ]
82
+ const ANSWER = 'Item one moved on Monday [1], and item two is waiting on a reply [2].\n\nNothing else changed this week.'
83
+ const DRAFT = 'Thanks, both. I will look at item one today\nand reply on item two tomorrow.'
84
+
85
+ function startStack(start: Start): Frame[] {
86
+ const first: Frame = { level: { kind: 'first' }, query: '' }
87
+ switch (start) {
88
+ case 'arriving':
89
+ return [{ ...first, query: 'item' }]
90
+ case 'nothing':
91
+ return [{ ...first, query: 'nothing matches this' }]
92
+ case 'place':
93
+ return [first, { level: { kind: 'place', name: 'Place one' }, query: '' }]
94
+ case 'working':
95
+ case 'answer':
96
+ return [first, { level: { kind: 'ask' }, query: 'What changed this week?' }]
97
+ case 'draft':
98
+ return [first, { level: { kind: 'draft' }, query: 'Say I will look today' }]
99
+ case 'form':
100
+ case 'refused':
101
+ return [first, { level: { kind: 'place', name: 'Place one' }, query: '' }, { level: { kind: 'form' }, query: '' }]
102
+ case 'one-list':
103
+ return [first, { level: { kind: 'one-list' }, query: '' }]
104
+ default:
105
+ return [first]
106
+ }
107
+ }
108
+
109
+ /** The caller every story uses: the levels, the rows, and what each one does. */
110
+ function Demo({ start, where, modKey, label }: { start: Start; where?: string; modKey?: string; label: string }) {
111
+ const [open, setOpen] = useState(true)
112
+ const [stack, setStack] = useState<Frame[]>(() => startStack(start))
113
+ const [last, setLast] = useState<string>('Nothing chosen yet.')
114
+ const frame = stack[stack.length - 1]
115
+ const level = frame.level
116
+
117
+ const setQuery = (query: string) => setStack((s) => [...s.slice(0, -1), { ...s[s.length - 1], query }])
118
+ const push = (next: Level, query = '') => setStack((s) => [...s, { level: next, query }])
119
+ const back = () => setStack((s) => (s.length > 1 ? s.slice(0, -1) : s))
120
+ const close = (what: string) => {
121
+ setLast(what)
122
+ setOpen(false)
123
+ }
124
+ const reopen = () => {
125
+ setStack([{ level: { kind: 'first' }, query: '' }])
126
+ setOpen(true)
127
+ }
128
+
129
+ // ── A late group: arrives 600ms after the typing stops (slower in "arriving").
130
+ const term = level.kind === 'first' ? frame.query.trim().toLowerCase() : ''
131
+ const [searched, setSearched] = useState('')
132
+ useEffect(() => {
133
+ if (!term) return
134
+ const timer = setTimeout(() => setSearched(term), start === 'arriving' ? 2500 : 600)
135
+ return () => clearTimeout(timer)
136
+ }, [term, start])
137
+ const searching = !!term && searched !== term
138
+
139
+ // ── An answer that takes a while ("working" never finishes).
140
+ const [asked, setAsked] = useState<{ question: string; done: boolean } | null>(null)
141
+ const ask = async (question: string) => {
142
+ setAsked({ question, done: false })
143
+ if (start === 'working') return
144
+ await wait(1200)
145
+ setAsked((a) => (a?.question === question ? { question, done: true } : a))
146
+ }
147
+ useEffect(() => {
148
+ if (level.kind === 'ask' || level.kind === 'draft') void ask(frame.query)
149
+ // Once per level: the question it was opened with.
150
+ // (The stack's length changes exactly when a level opens.)
151
+ }, [stack.length])
152
+
153
+ // ── Recent rows are the person's own, and can be forgotten.
154
+ const [recent, setRecent] = useState(['Item one', 'Item two', 'Person one'])
155
+
156
+ // ── The form keeps what was typed while the palette is open.
157
+ const [draft, setDraft] = useState({ title: '', notes: '', kind: 'one', group: '' })
158
+ const [missing, setMissing] = useState<{ title?: string; group?: string }>({})
159
+ const [working, setWorking] = useState(false)
160
+ const [refused, setRefused] = useState(false)
161
+ const [kind, setKind] = useState('one')
162
+
163
+ const groups = useMemo<CommandPaletteGroup[]>(() => {
164
+ const q = frame.query.trim().toLowerCase()
165
+ if (level.kind === 'place') {
166
+ return [
167
+ {
168
+ label: `${level.name} actions`,
169
+ rows: ACTIONS.filter((a) => a.label.toLowerCase().includes(q)).map((a) => ({
170
+ id: a.id,
171
+ label: a.label,
172
+ description: a.description,
173
+ icon,
174
+ onSelect: () => push({ kind: 'form' }),
175
+ })),
176
+ },
177
+ ]
178
+ }
179
+ if (level.kind === 'ask' || level.kind === 'draft') {
180
+ if (!asked?.done) return []
181
+ const changed = frame.query.trim() !== asked.question.trim()
182
+ const again: CommandPaletteRow = { id: 'again', label: `Again: “${frame.query.trim()}”`, icon, onSelect: () => void ask(frame.query) }
183
+ if (level.kind === 'ask') {
184
+ return [
185
+ { label: 'Ask again', rows: changed ? [again] : [] },
186
+ {
187
+ label: 'Sources',
188
+ rows: ['Note one', 'Note two'].map((note, i) => ({
189
+ id: note,
190
+ label: note,
191
+ description: `[${i + 1}] Person ${i === 0 ? 'one' : 'two'} · Monday`,
192
+ leading: <Avatar name={`Person ${i === 0 ? 'one' : 'two'}`} size={20} />,
193
+ onSelect: () => close(note),
194
+ })),
195
+ },
196
+ { label: 'Next', rows: [{ id: 'use-answer', label: 'Use the answer', icon, onSelect: () => close('Use the answer') }] },
197
+ ]
198
+ }
199
+ return [
200
+ { label: 'Write again', rows: changed ? [again] : [] },
201
+ {
202
+ label: 'Next',
203
+ rows: [
204
+ { id: 'use-draft', label: 'Use it', description: 'Nothing is sent. Edit it first.', icon, onSelect: () => close('Use it') },
205
+ { id: 'draft-again', label: 'Write it again', icon, onSelect: () => void ask(asked.question) },
206
+ ],
207
+ },
208
+ ]
209
+ }
210
+
211
+ const place = (name: string): CommandPaletteRow => ({
212
+ id: `place-${name}`,
213
+ label: name,
214
+ description: 'Has actions',
215
+ icon,
216
+ onSelect: () => push({ kind: 'place', name }),
217
+ onGoIn: () => push({ kind: 'place', name }),
218
+ })
219
+ const recentRow = (name: string): CommandPaletteRow => ({
220
+ id: `recent-${name}`,
221
+ label: name,
222
+ description: name.startsWith('Person') ? 'Role' : 'Opened today',
223
+ ...(name.startsWith('Person') ? { leading: <Avatar name={name} size={24} /> } : { icon }),
224
+ onSelect: () => close(name),
225
+ onForget: () => setRecent((r) => r.filter((x) => x !== name)),
226
+ })
227
+
228
+ if (!q) {
229
+ return [
230
+ {
231
+ label: 'This page',
232
+ rows: [
233
+ { id: 'ask', label: 'Ask about this page', icon, onSelect: () => push({ kind: 'ask' }) },
234
+ { id: 'draft', label: 'Write a draft', icon, onSelect: () => push({ kind: 'draft' }) },
235
+ { id: 'one-list', label: 'Change kind', description: 'Item one', icon, onSelect: () => push({ kind: 'one-list' }) },
236
+ ],
237
+ },
238
+ {
239
+ label: 'Recent',
240
+ rows: recent.length ? [...recent.map(recentRow), { id: 'clear', label: 'Clear recent', icon, onSelect: () => setRecent([]) }] : [],
241
+ },
242
+ { label: 'Places', rows: PLACES.map(place) },
243
+ ]
244
+ }
245
+ const matches = (s: string) => s.toLowerCase().includes(q)
246
+ return [
247
+ { label: 'Places', rows: PLACES.filter(matches).map(place) },
248
+ { label: 'Recent', rows: recent.filter(matches).map(recentRow) },
249
+ {
250
+ label: 'Actions',
251
+ rows: ACTIONS.filter((a) => matches(a.label)).map((a) => ({
252
+ id: a.id,
253
+ label: a.label,
254
+ description: 'Place one',
255
+ icon,
256
+ onSelect: () => setStack((s) => [...s, { level: { kind: 'place', name: 'Place one' }, query: '' }, { level: { kind: 'form' }, query: '' }]),
257
+ })),
258
+ },
259
+ {
260
+ label: 'Later',
261
+ rows: searching ? [] : LATER.filter(matches).map((text) => ({ id: text, label: text, description: 'Person two · Monday', icon, onSelect: () => close(text) })),
262
+ },
263
+ ]
264
+ }, [frame, level, asked, recent, searching])
265
+
266
+ const submitForm = async () => {
267
+ const need = { title: draft.title.trim() ? undefined : 'Give it a title.', group: draft.group ? undefined : 'Choose a group.' }
268
+ setMissing(need)
269
+ setRefused(false)
270
+ if (need.title || need.group) return
271
+ setWorking(true)
272
+ await wait(1200)
273
+ setWorking(false)
274
+ if (start === 'refused') {
275
+ setRefused(true)
276
+ return
277
+ }
278
+ close(`Created “${draft.title.trim()}”`)
279
+ }
280
+
281
+ const submitKind = async () => {
282
+ setWorking(true)
283
+ await wait(900)
284
+ setWorking(false)
285
+ close(`Kind set to ${KINDS.find((k) => k.value === kind)?.label}`)
286
+ }
287
+
288
+ const body = (() => {
289
+ if (level.kind !== 'ask' && level.kind !== 'draft') return undefined
290
+ if (!asked || !asked.done) return <CommandPaletteWorking>Reading 12 notes…</CommandPaletteWorking>
291
+ const changed = frame.query.trim() !== asked.question.trim()
292
+ return level.kind === 'ask' ? (
293
+ <CommandPaletteAnswer note={changed ? 'You changed the question. Press Enter to ask again.' : undefined}>{ANSWER}</CommandPaletteAnswer>
294
+ ) : (
295
+ <CommandPaletteQuote>{DRAFT}</CommandPaletteQuote>
296
+ )
297
+ })()
298
+
299
+ const q = frame.query.trim()
300
+
301
+ return (
302
+ <div className="flex h-screen flex-col items-start gap-3 bg-bg-base p-6">
303
+ <Button variant="outlined" onClick={reopen}>
304
+ Open the palette
305
+ </Button>
306
+ <p className="text-body-2 text-text-secondary">{last}</p>
307
+
308
+ <CommandPalette open={open} onOpenChange={setOpen} label={label} where={where} modKey={modKey}>
309
+ {level.kind === 'form' ? (
310
+ <CommandPaletteForm
311
+ chip={{ label: 'Place one · Action one', onBack: back }}
312
+ icon={icon}
313
+ submitLabel="Create item"
314
+ onSubmit={() => void submitForm()}
315
+ working={working ? { button: 'Creating…', line: 'Creating the item…' } : undefined}
316
+ error={refused ? 'Group two is archived. Choose another one.' : undefined}
317
+ >
318
+ <Field label="Title" required error={missing.title}>
319
+ <TextInput value={draft.title} placeholder="A few words" onChange={(e) => setDraft({ ...draft, title: e.target.value })} />
320
+ </Field>
321
+ <Field label="Notes">
322
+ <Textarea rows={3} value={draft.notes} placeholder="Anything else" onChange={(e) => setDraft({ ...draft, notes: e.target.value })} />
323
+ </Field>
324
+ <Field label="Kind">
325
+ <Select value={draft.kind} onChange={(value) => setDraft({ ...draft, kind: value })} options={KINDS} />
326
+ </Field>
327
+ <Field label="Group" required error={missing.group}>
328
+ <Select value={draft.group} onChange={(value) => setDraft({ ...draft, group: value })} options={GROUPS} placeholder="Choose a group…" />
329
+ </Field>
330
+ </CommandPaletteForm>
331
+ ) : level.kind === 'one-list' ? (
332
+ <CommandPaletteForm
333
+ chip={{ label: 'Item one · Change kind', onBack: back }}
334
+ icon={icon}
335
+ submitLabel="Change kind"
336
+ onSubmit={() => void submitKind()}
337
+ submitWaits={kind === 'one' ? 'Choose a different kind first' : undefined}
338
+ working={working ? { button: 'Saving…', line: 'Saving the kind…' } : undefined}
339
+ >
340
+ <Field label="Kind">
341
+ <Select value={kind} onChange={setKind} options={KINDS} />
342
+ </Field>
343
+ </CommandPaletteForm>
344
+ ) : (
345
+ <CommandPaletteSearch
346
+ query={frame.query}
347
+ onQueryChange={setQuery}
348
+ placeholder={
349
+ level.kind === 'place'
350
+ ? `Search ${level.name}'s actions`
351
+ : level.kind === 'ask'
352
+ ? 'Ask anything about this page'
353
+ : level.kind === 'draft'
354
+ ? 'What should it say?'
355
+ : 'Search, or choose a place'
356
+ }
357
+ groups={groups}
358
+ chip={
359
+ level.kind === 'place'
360
+ ? { label: level.name, leading: icon, onBack: back }
361
+ : level.kind === 'ask'
362
+ ? { label: 'Ask', onBack: back }
363
+ : level.kind === 'draft'
364
+ ? { label: 'Draft', onBack: back }
365
+ : undefined
366
+ }
367
+ pending={level.kind === 'first' && searching ? 'Searching…' : undefined}
368
+ empty={
369
+ level.kind === 'first' && q && !searching
370
+ ? `Nothing is called “${q}”.`
371
+ : level.kind === 'place'
372
+ ? `${level.name} has no action called “${q}”.`
373
+ : undefined
374
+ }
375
+ >
376
+ {body}
377
+ </CommandPaletteSearch>
378
+ )}
379
+ </CommandPalette>
380
+ </div>
381
+ )
382
+ }
383
+
384
+ const story = (start: Start): Story => ({
385
+ render: (args) => <Demo start={start} label={args.label} where={args.where} modKey={args.modKey} />,
386
+ })
387
+
388
+ /** The first level: rows in groups, the first one lit. ↑ ↓ stop at the ends, Tab goes into a place, Ctrl+Backspace forgets a recent row. */
389
+ export const FirstLevel: Story = story('first')
390
+
391
+ /** Typing: the rows that match now, a line while a later group is on its way, and that group arriving without moving the lit row. */
392
+ export const WhileRowsArrive: Story = story('arriving')
393
+
394
+ /** Nothing matches: the empty line, where the first row would be. */
395
+ export const NothingFound: Story = story('nothing')
396
+
397
+ /** Inside a level: the chip names it. Backspace at the start of the field, or the chip's ✕, goes back with the text you had. */
398
+ export const InsideALevel: Story = story('place')
399
+
400
+ /** Something is being worked on: the line says what, the grey bars hold the place. */
401
+ export const Working: Story = story('working')
402
+
403
+ /** An answer, its marks, and the rows they point at. Change the question and an "Again" row comes first. */
404
+ export const Answer: Story = story('answer')
405
+
406
+ /** Text written to be used somewhere else, above what you can do with it. */
407
+ export const Quote: Story = story('draft')
408
+
409
+ /** A form. Ctrl+Enter creates; a missing field says so and takes focus; Backspace in an empty field goes back and keeps what was typed. */
410
+ export const Form: Story = story('form')
411
+
412
+ /** Creating is refused: the fields unlock, the reason sits beside the button, and focus is back where it was. */
413
+ export const FormRefused: Story = story('refused')
414
+
415
+ /** A form that is one list. It waits, and says why, until the value changes; Backspace goes back from the list. */
416
+ export const FormThatWaits: Story = story('one-list')