@estiva-app/ui 0.15.0 → 0.16.1
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/dist/Breadcrumb.d.ts.map +1 -1
- package/dist/CommandPalette.d.ts +121 -0
- package/dist/CommandPalette.d.ts.map +1 -0
- package/dist/Menu.d.ts +4 -0
- package/dist/Menu.d.ts.map +1 -1
- package/dist/Toast.d.ts.map +1 -1
- package/dist/eslint/has-a-page-and-a-story.d.ts +4 -0
- package/dist/eslint/has-a-page-and-a-story.d.ts.map +1 -0
- package/dist/eslint/index.d.ts +15 -1
- package/dist/eslint/index.d.ts.map +1 -1
- package/dist/eslint/index.js +176 -8
- package/dist/eslint/index.js.map +3 -3
- package/dist/eslint/no-hand-rolled-behaviour.d.ts +3 -0
- package/dist/eslint/no-hand-rolled-behaviour.d.ts.map +1 -0
- package/dist/eslint/raw-element-outside-a-wrapper.d.ts +29 -0
- package/dist/eslint/raw-element-outside-a-wrapper.d.ts.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +614 -287
- package/dist/index.js.map +4 -4
- package/package.json +5 -1
- package/src/Breadcrumb.tsx +6 -2
- package/src/CommandPalette.mdx +139 -0
- package/src/CommandPalette.stories.tsx +418 -0
- package/src/CommandPalette.test.tsx +415 -0
- package/src/CommandPalette.tsx +664 -0
- package/src/Menu.tsx +4 -2
- package/src/Toast.tsx +12 -24
- package/src/eslint/has-a-page-and-a-story.test.ts +57 -0
- package/src/eslint/has-a-page-and-a-story.ts +97 -0
- package/src/eslint/index.test.ts +35 -3
- package/src/eslint/index.ts +58 -13
- package/src/eslint/no-hand-rolled-behaviour.test.ts +70 -0
- package/src/eslint/no-hand-rolled-behaviour.ts +116 -0
- package/src/eslint/raw-element-outside-a-wrapper.test.ts +82 -0
- package/src/eslint/raw-element-outside-a-wrapper.ts +85 -0
- package/src/index.ts +17 -0
- 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.
|
|
3
|
+
"version": "0.16.1",
|
|
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
|
},
|
package/src/Breadcrumb.tsx
CHANGED
|
@@ -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
|
-
|
|
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
|
-
</
|
|
97
|
+
</Link>
|
|
94
98
|
) : (
|
|
95
99
|
<span ref={setLabelRef} className={text} aria-current={last ? 'page' : undefined}>
|
|
96
100
|
{item.label}
|
|
@@ -0,0 +1,139 @@
|
|
|
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
|
+
- **`notes` are what a person should know about the rows and cannot act on** —
|
|
80
|
+
"2 more where you cannot open them", "the search was refused, so this list
|
|
81
|
+
is incomplete". Quiet lines under the rows, never rows: the arrows do not stop
|
|
82
|
+
on them.
|
|
83
|
+
- **Rows that arrive late do not take the highlight you moved to.** Base UI keeps
|
|
84
|
+
the highlight's position rather than its row, so a group arriving above the
|
|
85
|
+
lit row would hand the highlight — and Enter — to something else. Once you have
|
|
86
|
+
moved the highlight on a level, the palette walks it back to the row you had.
|
|
87
|
+
Until you have, the first row is lit, whichever row that becomes. Typing starts
|
|
88
|
+
again from the first row.
|
|
89
|
+
- **Home and End move the text cursor, and nothing else.** Base UI would also
|
|
90
|
+
send the highlight to the first or last row; the palette stops that.
|
|
91
|
+
- **The field is named by its placeholder**, so say what it asks for.
|
|
92
|
+
- **A form's fields go in `Field`s.** Mark the missing ones with `error` inside
|
|
93
|
+
`onSubmit`, and the first of them takes focus. `working` locks the fields,
|
|
94
|
+
puts its words on the button and beside it, and holds focus on the form so
|
|
95
|
+
Esc still closes it; when it clears, focus goes to the first field that needs
|
|
96
|
+
something, or back where it was. `error` is what went wrong, beside the
|
|
97
|
+
button. `submitWaits` says why there is nothing to submit yet, on the button,
|
|
98
|
+
and Ctrl+Enter does nothing until it clears.
|
|
99
|
+
- **Focus never falls out of a level.** When the element that had it goes — a
|
|
100
|
+
row picked, a level swapped — focus goes to the level's first control.
|
|
101
|
+
|
|
102
|
+
## Keys
|
|
103
|
+
|
|
104
|
+
| Where | Input | What happens |
|
|
105
|
+
|---|---|---|
|
|
106
|
+
| a level of rows | ↑ / ↓ | move between rows, and stop at the ends |
|
|
107
|
+
| | Enter, or a click | what the lit row does |
|
|
108
|
+
| | Tab | goes in, on a row that leads somewhere; anywhere else nothing, and focus stays in the field |
|
|
109
|
+
| | → at the end of the text | goes in, on a row that leads somewhere; anywhere else it moves the cursor |
|
|
110
|
+
| | Ctrl+Backspace | forgets the lit row, if it can be forgotten; otherwise deletes a word |
|
|
111
|
+
| | Home / End | move the text cursor; the lit row stays |
|
|
112
|
+
| inside a level | Backspace at the start of the field, or the chip's ✕ | back, with focus in the field |
|
|
113
|
+
| a form | Tab / Shift+Tab | between the fields, never out of the window |
|
|
114
|
+
| | Ctrl+Enter | submits; if a field needs something, focus goes to the first |
|
|
115
|
+
| | 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 |
|
|
116
|
+
| | while working | nothing but Esc |
|
|
117
|
+
| | Esc in an open list | closes the list first |
|
|
118
|
+
| anywhere | Esc, or a click outside | closes; focus returns to whatever opened it |
|
|
119
|
+
|
|
120
|
+
## Props
|
|
121
|
+
|
|
122
|
+
<Controls of={CommandPaletteStories.FirstLevel} />
|
|
123
|
+
|
|
124
|
+
**`CommandPaletteSearch`** — `query` and `onQueryChange` (the field, controlled),
|
|
125
|
+
`placeholder` (also the field's name), `groups` (`{ label, rows }`, a group
|
|
126
|
+
with no rows is not drawn), `chip?` (`{ label, leading?, onBack }`), `pending?`, `notes?`,
|
|
127
|
+
`empty?`, and `children` for what sits above the rows.
|
|
128
|
+
|
|
129
|
+
**A row** — `id`, `label`, `description?`, `icon?` or `leading?`, `onSelect`,
|
|
130
|
+
`onGoIn?`, `onForget?`.
|
|
131
|
+
|
|
132
|
+
**`CommandPaletteForm`** — `chip`, `icon?`, `submitLabel`, `onSubmit`,
|
|
133
|
+
`submitWaits?`, `working?` (`{ button, line }`), `error?`, and the fields as
|
|
134
|
+
`children`.
|
|
135
|
+
|
|
136
|
+
**`CommandPaletteWorking`** — the words as `children`, `bars?` (0–3, default 2).
|
|
137
|
+
**`CommandPaletteAnswer`** — the text as `children` (a blank line starts a
|
|
138
|
+
paragraph; `[1]` is a mark), `note?`. **`CommandPaletteQuote`** — the text as
|
|
139
|
+
`children`, line breaks kept.
|
|
@@ -0,0 +1,418 @@
|
|
|
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
|
+
// What the later group left out: a line to read, not a row to pick.
|
|
369
|
+
notes={level.kind === 'first' && q && !searching && LATER.some((l) => l.toLowerCase().includes(q)) ? ['1 more in a place you cannot open.'] : undefined}
|
|
370
|
+
empty={
|
|
371
|
+
level.kind === 'first' && q && !searching
|
|
372
|
+
? `Nothing is called “${q}”.`
|
|
373
|
+
: level.kind === 'place'
|
|
374
|
+
? `${level.name} has no action called “${q}”.`
|
|
375
|
+
: undefined
|
|
376
|
+
}
|
|
377
|
+
>
|
|
378
|
+
{body}
|
|
379
|
+
</CommandPaletteSearch>
|
|
380
|
+
)}
|
|
381
|
+
</CommandPalette>
|
|
382
|
+
</div>
|
|
383
|
+
)
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const story = (start: Start): Story => ({
|
|
387
|
+
render: (args) => <Demo start={start} label={args.label} where={args.where} modKey={args.modKey} />,
|
|
388
|
+
})
|
|
389
|
+
|
|
390
|
+
/** 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. */
|
|
391
|
+
export const FirstLevel: Story = story('first')
|
|
392
|
+
|
|
393
|
+
/** Typing: the rows that match now, a line while a later group is on its way, that group arriving without moving the lit row, and a note about what it left out. */
|
|
394
|
+
export const WhileRowsArrive: Story = story('arriving')
|
|
395
|
+
|
|
396
|
+
/** Nothing matches: the empty line, where the first row would be. */
|
|
397
|
+
export const NothingFound: Story = story('nothing')
|
|
398
|
+
|
|
399
|
+
/** 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. */
|
|
400
|
+
export const InsideALevel: Story = story('place')
|
|
401
|
+
|
|
402
|
+
/** Something is being worked on: the line says what, the grey bars hold the place. */
|
|
403
|
+
export const Working: Story = story('working')
|
|
404
|
+
|
|
405
|
+
/** An answer, its marks, and the rows they point at. Change the question and an "Again" row comes first. */
|
|
406
|
+
export const Answer: Story = story('answer')
|
|
407
|
+
|
|
408
|
+
/** Text written to be used somewhere else, above what you can do with it. */
|
|
409
|
+
export const Quote: Story = story('draft')
|
|
410
|
+
|
|
411
|
+
/** 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. */
|
|
412
|
+
export const Form: Story = story('form')
|
|
413
|
+
|
|
414
|
+
/** Creating is refused: the fields unlock, the reason sits beside the button, and focus is back where it was. */
|
|
415
|
+
export const FormRefused: Story = story('refused')
|
|
416
|
+
|
|
417
|
+
/** A form that is one list. It waits, and says why, until the value changes; Backspace goes back from the list. */
|
|
418
|
+
export const FormThatWaits: Story = story('one-list')
|