@baseworks/ui 0.0.0-stage → 0.2.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.
- package/README.md +183 -2
- package/package.json +29 -3
- package/src/assistant.tsx +135 -0
- package/src/button.tsx +31 -0
- package/src/card.tsx +85 -0
- package/src/command-palette.tsx +59 -0
- package/src/commands.ts +27 -0
- package/src/dialog.tsx +96 -0
- package/src/feedback.tsx +37 -0
- package/src/form.tsx +40 -0
- package/src/format.ts +53 -0
- package/src/frame.css +214 -0
- package/src/frame.tsx +293 -0
- package/src/gate.tsx +27 -0
- package/src/index.ts +61 -0
- package/src/menu.tsx +62 -0
- package/src/nav.ts +47 -0
- package/src/primitives.css +239 -0
- package/src/slide-over.tsx +66 -0
- package/src/styles.css +7 -0
- package/src/table.tsx +53 -0
- package/src/theme-toggle.tsx +33 -0
- package/src/theme.ts +55 -0
- package/src/tokens.css +143 -0
- package/src/use-resource.ts +70 -0
package/README.md
CHANGED
|
@@ -1,3 +1,184 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @baseworks/ui
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The design system: **tokens**, **primitives**, and the **application frame**.
|
|
4
|
+
Plain CSS and React — no utility framework, no runtime styling.
|
|
5
|
+
|
|
6
|
+
```tsx
|
|
7
|
+
import '@baseworks/ui/styles.css' // once, in the root layout
|
|
8
|
+
import { Button, Card, Table, Pill } from '@baseworks/ui'
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Three layers
|
|
12
|
+
|
|
13
|
+
| File | What is in it |
|
|
14
|
+
|---|---|
|
|
15
|
+
| `tokens.css` | Colour, type, shadow. One palette, light and dark. The only place a value is written. |
|
|
16
|
+
| `primitives.css` | Buttons, cards, tables, forms, dialogs, palette, toast. |
|
|
17
|
+
| `frame.css` | Rail, sub panel, top bar, assistant, and the centered gate. |
|
|
18
|
+
|
|
19
|
+
`styles.css` imports all three. A product that wants the look without the frame
|
|
20
|
+
imports the first two.
|
|
21
|
+
|
|
22
|
+
## The palette
|
|
23
|
+
|
|
24
|
+
**Warm paper, ultramarine ink.** One palette, in a light and a dark form.
|
|
25
|
+
`prefers-color-scheme` chooses until the viewer does, with `ThemeToggle` in the
|
|
26
|
+
top bar. There is no brand switcher — a second palette is a second design to
|
|
27
|
+
keep correct in every state.
|
|
28
|
+
|
|
29
|
+
| | Light | Dark |
|
|
30
|
+
|---|---|---|
|
|
31
|
+
| Page | `#FBFAF8` | `#1A1815` |
|
|
32
|
+
| Card | `#FFFFFF` | `#221F1B` |
|
|
33
|
+
| Text | `#1A1916` | `#EDE9E1` |
|
|
34
|
+
| Secondary | `#67635B` | `#A5A096` |
|
|
35
|
+
| Accent | `#2B44CE` | `#A9B5FF` |
|
|
36
|
+
| ok · warn · crit | `#16794F` `#8A6112` `#B3362F` | `#4FC28C` `#DFB255` `#F0736B` |
|
|
37
|
+
|
|
38
|
+
Two decisions are worth knowing, because they are the reason it looks like this.
|
|
39
|
+
|
|
40
|
+
**The neutrals are warm, not grey.** A console that is mostly tables and text is
|
|
41
|
+
sat in front of for hours, and a paper ground is easier to read than the cool
|
|
42
|
+
grey every admin panel ships with. Closest shadcn base: `stone`.
|
|
43
|
+
|
|
44
|
+
**The accent is 231° and that is functional.** The interface is full of status,
|
|
45
|
+
and ok/warn/crit own green, amber and red. An accent near any of them turns
|
|
46
|
+
"this is a link" into "this is a problem". Ultramarine sits 76° from ok, 169°
|
|
47
|
+
from warn and 132° from crit, so an accented thing never reads as a state. The
|
|
48
|
+
palette this replaced used a vermilion accent that sat beside its own crit red.
|
|
49
|
+
|
|
50
|
+
Every pairing the interface actually makes was measured rather than eyeballed:
|
|
51
|
+
body text ≥ 14:1, secondary ≥ 5.7:1, the accent as a link ≥ 7.4:1, a button
|
|
52
|
+
label on the accent ≥ 7.4:1, each status pill against its own tinted background
|
|
53
|
+
≥ 4.7:1.
|
|
54
|
+
|
|
55
|
+
`tokens.css` also carries shadcn/ui's names (`--background`, `--primary`,
|
|
56
|
+
`--border`, …) as aliases, so a component copied in from there inherits this
|
|
57
|
+
palette. They are raw colour values — the form shadcn uses with Tailwind v4, not
|
|
58
|
+
the older `hsl(var(--background))` triplets.
|
|
59
|
+
|
|
60
|
+
### Light and dark
|
|
61
|
+
|
|
62
|
+
```tsx
|
|
63
|
+
<script dangerouslySetInnerHTML={{ __html: themeInitScript() }} /> // in <head>
|
|
64
|
+
<ThemeToggle /> // in the top bar
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
`data-theme="light|dark"` on `<html>` overrides the system in both directions —
|
|
68
|
+
a forced dark on a light machine, and a forced light on a dark one. The script
|
|
69
|
+
in `<head>` restores a stored choice before the first paint; without it the page
|
|
70
|
+
renders the system's answer and then flips.
|
|
71
|
+
|
|
72
|
+
`ThemeToggle` renders both icons and lets CSS hide one, under the same
|
|
73
|
+
conditions that pick the palette. Asking JavaScript which theme is on cannot be
|
|
74
|
+
answered until after hydration, so a viewer on a dark machine would see the
|
|
75
|
+
wrong icon for a frame on every load.
|
|
76
|
+
|
|
77
|
+
`resolvedTheme()` is the honest answer to "which theme is on": `currentTheme()`
|
|
78
|
+
can say `system`, which is a setting rather than a colour.
|
|
79
|
+
|
|
80
|
+
## Type
|
|
81
|
+
|
|
82
|
+
Instrument Sans for the interface, IBM Plex Mono for anything literal (an id, a
|
|
83
|
+
token, a path), and **Instrument Serif for page titles only**. One serif, at the
|
|
84
|
+
top of a document, and nowhere else — it gives the console a voice without
|
|
85
|
+
making a table harder to read.
|
|
86
|
+
|
|
87
|
+
## Components
|
|
88
|
+
|
|
89
|
+
The components are thin. They put the right class on the right element and hold
|
|
90
|
+
the behaviour that would otherwise be retyped in every screen: the outside-click
|
|
91
|
+
that closes a menu, where focus lands in a confirm dialog, the highlight in the
|
|
92
|
+
palette. They never know what the product is.
|
|
93
|
+
|
|
94
|
+
| | |
|
|
95
|
+
|---|---|
|
|
96
|
+
| Layout | `PageHead` `Card` `CardBody` `CardGrid` `Stats` `Stat` `KeyValue` |
|
|
97
|
+
| Data | `Table` `Pill` `CellName` `CellSub` |
|
|
98
|
+
| Forms | `Field` `FormRow` `FormError` `Switch` `Button` `IconButton` |
|
|
99
|
+
| Telling | `Attention` `Toast` `CodeBox` `Chips` |
|
|
100
|
+
| Overlays | `ConfirmDialog` `SlideOver` `Popover` `MenuItem` `CommandPalette` `Gate` |
|
|
101
|
+
| Chrome | `ThemeToggle` |
|
|
102
|
+
|
|
103
|
+
### The shape of a list screen
|
|
104
|
+
|
|
105
|
+
A screen that shows a list *is* the list. What you can do to it lives in the
|
|
106
|
+
card's header — `<Card actions={<Button small>Add a customer</Button>}>` — and
|
|
107
|
+
the form it opens is a `SlideOver`, not another card above the table. The list
|
|
108
|
+
stays on screen while you type, which matters because you are usually adding to
|
|
109
|
+
something you are looking at, and a form that pushes the list away makes you
|
|
110
|
+
close it to check what is already there.
|
|
111
|
+
|
|
112
|
+
The module's own menu lists places, not actions. An "Add a customer" entry there
|
|
113
|
+
would be a menu item whose job is to scroll you to a button.
|
|
114
|
+
|
|
115
|
+
Three of them carry a rule rather than a look:
|
|
116
|
+
|
|
117
|
+
- **`ConfirmDialog`** puts focus on Cancel, never on the destructive button, so
|
|
118
|
+
a reflex Enter destroys nothing; `typeToConfirm` makes the person write the
|
|
119
|
+
resource's name, which is the only guard against acting on the wrong row.
|
|
120
|
+
- **`Table`** takes `columns` and a `note`, and renders the note as one
|
|
121
|
+
full-width row when there are no rows — loading, error, or empty. Screens used
|
|
122
|
+
to hand-roll that row with a `colSpan` they had to keep in step with the
|
|
123
|
+
header.
|
|
124
|
+
- **`SlideOver`** stays in the DOM and moves off-screen, which is what makes it
|
|
125
|
+
animate and what it then has to compensate for: it focuses its first field
|
|
126
|
+
when it opens, because an `autoFocus` inside fires at mount while the panel is
|
|
127
|
+
closed, and it is `inert` while closed, so a keyboard does not tab into a
|
|
128
|
+
panel nobody can see. That focus effect depends on `open` alone and reads
|
|
129
|
+
`onClose` through a ref — depending on the callback made it re-run on every
|
|
130
|
+
render, since callers write `onClose={() => setOpen(false)}`, and focus then
|
|
131
|
+
jumped back to the first field on every keystroke in any other one.
|
|
132
|
+
|
|
133
|
+
## The frame
|
|
134
|
+
|
|
135
|
+
`Frame` is the whole shell — rail, sub panel, top bar, assistant, ⌘K, toast,
|
|
136
|
+
confirm — and it is **presentational**. It knows how a surface is laid out and
|
|
137
|
+
nothing about what the surface is for:
|
|
138
|
+
|
|
139
|
+
```tsx
|
|
140
|
+
<Frame
|
|
141
|
+
modules={modules} pathname={pathname} onNavigate={(href) => router.push(href)}
|
|
142
|
+
counts={counts} icons={ICONS}
|
|
143
|
+
railTop={<OrgSwitcher …/>} railBottom={<AccountMenu …/>}
|
|
144
|
+
topbarLeft={<ModeTag/>} topbarRight={<ThemeToggle/>}
|
|
145
|
+
assistant={(s) => <AssistantPanel {...s} />}
|
|
146
|
+
>
|
|
147
|
+
{children}
|
|
148
|
+
</Frame>
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
Everything that knows what a product *is* — which modules exist, who is signed
|
|
152
|
+
in, which organization is active, what the counts count — arrives as props or
|
|
153
|
+
slots. What the frame owns is the state that belongs to the layout: which panel
|
|
154
|
+
is open, whether the assistant has taken the middle column, what the palette is
|
|
155
|
+
showing, the toast that is up. Screens reach the last two through `useFrame()`.
|
|
156
|
+
|
|
157
|
+
A surface supplies its own `Module[]`; `activeModule(pathname, modules)` decides
|
|
158
|
+
which is current, longest-prefix wins. `navigationCommands` turns the same list
|
|
159
|
+
into ⌘K entries, so a new module is reachable the moment it is added.
|
|
160
|
+
|
|
161
|
+
`AssistantPanel` takes `onAsk` — that is where a surface binds a real agent.
|
|
162
|
+
Without it the panel says it is a preview, which keeps the frame demonstrable
|
|
163
|
+
before the tools exist. It **expands into the middle column**: an answer that is
|
|
164
|
+
a table needs the room a table needs, and the expand state lives in `Frame`
|
|
165
|
+
because widening the panel takes space from the content beside it.
|
|
166
|
+
|
|
167
|
+
Also here, because every surface was otherwise writing them again:
|
|
168
|
+
`useResource` (loading, error, refresh, `enabled`), and the formatters `age`,
|
|
169
|
+
`until`, `untilEpoch`, `money`, `amount`.
|
|
170
|
+
|
|
171
|
+
## How it is consumed
|
|
172
|
+
|
|
173
|
+
The package ships **source**, not a build: consumers list it in
|
|
174
|
+
`transpilePackages`. It has no repository of its own yet, so it is tracked in
|
|
175
|
+
the baseworks-ts root alongside `packages/tsconfig`. When it is published it
|
|
176
|
+
gets `dotlabshq/baseworks-ui`, a `tsup` build, and a line in PACKAGES.md §2.
|
|
177
|
+
|
|
178
|
+
## Where it came from
|
|
179
|
+
|
|
180
|
+
Ported from the zone9 console (`apps/web`), which is where this shell was
|
|
181
|
+
designed and proven. What came across is what no product owns: tokens, frame,
|
|
182
|
+
primitives. What stayed behind is everything that knows about servers, regions
|
|
183
|
+
or domains. `apps/console` is the reference consumer; zone9 becomes one when it
|
|
184
|
+
drops its copy.
|
package/package.json
CHANGED
|
@@ -1,6 +1,32 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@baseworks/ui",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "The baseworks design system: tokens, primitives and the application frame. Plain CSS and React, no utility framework.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"publishConfig": {
|
|
7
|
+
"access": "public"
|
|
8
|
+
},
|
|
9
|
+
"exports": {
|
|
10
|
+
".": "./src/index.ts",
|
|
11
|
+
"./styles.css": "./src/styles.css",
|
|
12
|
+
"./tokens.css": "./src/tokens.css",
|
|
13
|
+
"./primitives.css": "./src/primitives.css",
|
|
14
|
+
"./frame.css": "./src/frame.css"
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"src"
|
|
18
|
+
],
|
|
19
|
+
"peerDependencies": {
|
|
20
|
+
"react": ">=19",
|
|
21
|
+
"react-dom": ">=19"
|
|
22
|
+
},
|
|
23
|
+
"devDependencies": {
|
|
24
|
+
"@types/react": "^19.1.0",
|
|
25
|
+
"@types/react-dom": "^19.1.0",
|
|
26
|
+
"typescript": "^5.9.3",
|
|
27
|
+
"@baseworks/tsconfig": "0.1.0"
|
|
28
|
+
},
|
|
29
|
+
"scripts": {
|
|
30
|
+
"typecheck": "tsc --noEmit"
|
|
31
|
+
}
|
|
6
32
|
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The assistant, sliding in from the right.
|
|
5
|
+
*
|
|
6
|
+
* The panel is generic; what it is connected to is not. `onAsk` is where a
|
|
7
|
+
* surface binds a real agent — given none, the panel answers with a note that
|
|
8
|
+
* says so, which is honest and keeps the frame demonstrable before the tools
|
|
9
|
+
* exist. Nothing here computes an answer.
|
|
10
|
+
*
|
|
11
|
+
* `expanded` is owned by the frame, not by this component: widening the panel
|
|
12
|
+
* takes room from the content beside it, so the decision belongs where both
|
|
13
|
+
* are laid out.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { useEffect, useRef, useState, type ReactNode } from 'react'
|
|
17
|
+
|
|
18
|
+
type Msg = { id: number; kind: 'user' | 'bot' | 'typing'; body?: ReactNode }
|
|
19
|
+
let nextId = 1
|
|
20
|
+
|
|
21
|
+
export interface AssistantPanelProps {
|
|
22
|
+
open: boolean
|
|
23
|
+
onClose: () => void
|
|
24
|
+
/** Whether the panel has taken the middle column as well. */
|
|
25
|
+
expanded?: boolean
|
|
26
|
+
onToggleExpand?: () => void
|
|
27
|
+
/** The two-letter mark in the panel's head. */
|
|
28
|
+
mark?: ReactNode
|
|
29
|
+
title?: string
|
|
30
|
+
subtitle?: string
|
|
31
|
+
/** The first thing it says. */
|
|
32
|
+
greeting?: ReactNode
|
|
33
|
+
/**
|
|
34
|
+
* Answer a question. Return the reply's body; throw to show an error as a
|
|
35
|
+
* reply. Absent means the panel is a design preview and says so.
|
|
36
|
+
*/
|
|
37
|
+
onAsk?: (text: string) => Promise<ReactNode>
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const PREVIEW = 'This is a design preview — the assistant is not connected yet. It will use the same tools the screens and the CLI use.'
|
|
41
|
+
|
|
42
|
+
export function AssistantPanel({
|
|
43
|
+
open,
|
|
44
|
+
onClose,
|
|
45
|
+
expanded = false,
|
|
46
|
+
onToggleExpand,
|
|
47
|
+
mark = 'bw',
|
|
48
|
+
title = 'Assistant',
|
|
49
|
+
subtitle = 'does what the screens do',
|
|
50
|
+
greeting = 'Hello. I can answer questions about what is here and, once the tools are connected, act on your behalf — always asking before a write.',
|
|
51
|
+
onAsk,
|
|
52
|
+
}: AssistantPanelProps) {
|
|
53
|
+
const [msgs, setMsgs] = useState<Msg[]>([{ id: 0, kind: 'bot', body: greeting }])
|
|
54
|
+
const [text, setText] = useState('')
|
|
55
|
+
const [busy, setBusy] = useState(false)
|
|
56
|
+
const scrollRef = useRef<HTMLDivElement>(null)
|
|
57
|
+
useEffect(() => {
|
|
58
|
+
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight })
|
|
59
|
+
}, [msgs])
|
|
60
|
+
|
|
61
|
+
const add = (m: Omit<Msg, 'id'>) => {
|
|
62
|
+
const id = nextId++
|
|
63
|
+
setMsgs((p) => [...p, { ...m, id }])
|
|
64
|
+
return id
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const send = async () => {
|
|
68
|
+
const asked = text.trim()
|
|
69
|
+
if (!asked || busy) return
|
|
70
|
+
add({ kind: 'user', body: asked })
|
|
71
|
+
setText('')
|
|
72
|
+
const typing = add({ kind: 'typing' })
|
|
73
|
+
setBusy(true)
|
|
74
|
+
try {
|
|
75
|
+
const body = onAsk ? await onAsk(asked) : await new Promise<ReactNode>((r) => setTimeout(() => r(PREVIEW), 700))
|
|
76
|
+
setMsgs((p) => p.filter((m) => m.id !== typing))
|
|
77
|
+
add({ kind: 'bot', body })
|
|
78
|
+
} catch (err) {
|
|
79
|
+
setMsgs((p) => p.filter((m) => m.id !== typing))
|
|
80
|
+
add({ kind: 'bot', body: err instanceof Error && err.message ? err.message : 'That did not answer.' })
|
|
81
|
+
} finally {
|
|
82
|
+
setBusy(false)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return (
|
|
87
|
+
<aside className={'chat' + (open ? ' open' : '') + (expanded ? ' expanded' : '')} aria-label={title}>
|
|
88
|
+
<div className="chat-inner">
|
|
89
|
+
<div className="chat-head">
|
|
90
|
+
<div className="brand-mark" style={{ width: 26, height: 26, fontSize: 13, margin: 0 }}>{mark}</div>
|
|
91
|
+
<div>
|
|
92
|
+
<div className="ttl">{title}</div>
|
|
93
|
+
<div className="sub">{subtitle}</div>
|
|
94
|
+
</div>
|
|
95
|
+
{onToggleExpand && (
|
|
96
|
+
<button
|
|
97
|
+
className="icon-btn chat-expand"
|
|
98
|
+
onClick={onToggleExpand}
|
|
99
|
+
aria-label={expanded ? 'Narrow the assistant' : 'Widen the assistant'}
|
|
100
|
+
aria-pressed={expanded}
|
|
101
|
+
title={expanded ? 'Narrow' : 'Widen'}
|
|
102
|
+
>
|
|
103
|
+
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.7">
|
|
104
|
+
{expanded ? <path d="M9.5 2v4.5H14M6.5 14V9.5H2" /> : <path d="M14 2H9.5v4.5M2 14h4.5V9.5" />}
|
|
105
|
+
</svg>
|
|
106
|
+
</button>
|
|
107
|
+
)}
|
|
108
|
+
<button className="icon-btn close" onClick={onClose} aria-label="Close">✕</button>
|
|
109
|
+
</div>
|
|
110
|
+
<div className="chat-scroll" ref={scrollRef}>
|
|
111
|
+
{msgs.map((m) =>
|
|
112
|
+
m.kind === 'typing' ? (
|
|
113
|
+
<div key={m.id} className="typing"><i /><i /><i /></div>
|
|
114
|
+
) : (
|
|
115
|
+
<div key={m.id} className={'msg ' + m.kind}>{m.body}</div>
|
|
116
|
+
),
|
|
117
|
+
)}
|
|
118
|
+
</div>
|
|
119
|
+
<div className="chat-input">
|
|
120
|
+
<input
|
|
121
|
+
type="text"
|
|
122
|
+
placeholder="Ask for something…"
|
|
123
|
+
aria-label="Message"
|
|
124
|
+
value={text}
|
|
125
|
+
onChange={(e) => setText(e.target.value)}
|
|
126
|
+
onKeyDown={(e) => e.key === 'Enter' && void send()}
|
|
127
|
+
/>
|
|
128
|
+
<button className="send" onClick={() => void send()} aria-label="Send" disabled={busy}>
|
|
129
|
+
<svg width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.8"><path d="M2 8h11M9 4l4 4-4 4" /></svg>
|
|
130
|
+
</button>
|
|
131
|
+
</div>
|
|
132
|
+
</div>
|
|
133
|
+
</aside>
|
|
134
|
+
)
|
|
135
|
+
}
|
package/src/button.tsx
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { ButtonHTMLAttributes, ReactNode } from 'react'
|
|
2
|
+
|
|
3
|
+
export type ButtonTone = 'primary' | 'ghost' | 'danger'
|
|
4
|
+
|
|
5
|
+
export interface ButtonProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'children'> {
|
|
6
|
+
/** `danger` is for the irreversible one; it is never the default. */
|
|
7
|
+
tone?: ButtonTone
|
|
8
|
+
small?: boolean
|
|
9
|
+
children: ReactNode
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* One button shape for the whole system.
|
|
14
|
+
*
|
|
15
|
+
* `type` defaults to "button": inside a form the HTML default is "submit", so a
|
|
16
|
+
* secondary button that only opens a menu would submit the form instead.
|
|
17
|
+
*/
|
|
18
|
+
export function Button({ tone = 'primary', small, className, type, children, ...rest }: ButtonProps) {
|
|
19
|
+
const cls = ['btn', tone === 'ghost' && 'ghost', tone === 'danger' && 'danger', small && 'sm', className]
|
|
20
|
+
.filter(Boolean).join(' ')
|
|
21
|
+
return <button type={type ?? 'button'} className={cls} {...rest}>{children}</button>
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** A square icon-only button, as used in the top bar. Always needs a label. */
|
|
25
|
+
export function IconButton({ label, className, type, children, ...rest }:
|
|
26
|
+
Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'children'> & { label: string; children: ReactNode }) {
|
|
27
|
+
return (
|
|
28
|
+
<button type={type ?? 'button'} className={['icon-btn', className].filter(Boolean).join(' ')}
|
|
29
|
+
title={label} aria-label={label} {...rest}>{children}</button>
|
|
30
|
+
)
|
|
31
|
+
}
|
package/src/card.tsx
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
|
|
3
|
+
/** The title of a screen, and one line saying what it is. */
|
|
4
|
+
export function PageHead({ title, children, actions }: { title: ReactNode; children?: ReactNode; actions?: ReactNode }) {
|
|
5
|
+
return (
|
|
6
|
+
<div className="page-head">
|
|
7
|
+
<div><h1>{title}</h1>{children && <p>{children}</p>}</div>
|
|
8
|
+
{actions && <><div className="spacer" />{actions}</>}
|
|
9
|
+
</div>
|
|
10
|
+
)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* A card. `title` gives it a header; `hint` is the one-sentence explanation
|
|
15
|
+
* that sits on the right of that header — the place to say what the card is
|
|
16
|
+
* for, so the screen does not need a paragraph above it.
|
|
17
|
+
*
|
|
18
|
+
* `actions` is what you can do to the thing in the card, in its own header:
|
|
19
|
+
* "Add a customer" above the directory it will appear in. That is where a
|
|
20
|
+
* reader looks for it, and it keeps the form that follows out of the page —
|
|
21
|
+
* see `SlideOver`.
|
|
22
|
+
*/
|
|
23
|
+
export function Card({ id, title, hint, actions, children, style, className }: {
|
|
24
|
+
id?: string
|
|
25
|
+
title?: ReactNode
|
|
26
|
+
hint?: ReactNode
|
|
27
|
+
actions?: ReactNode
|
|
28
|
+
children: ReactNode
|
|
29
|
+
style?: React.CSSProperties
|
|
30
|
+
/**
|
|
31
|
+
* Added to `card`, for the few things a card can be that are not about its
|
|
32
|
+
* contents — `no-print`, most of all. Wrapping a card in a div to carry such
|
|
33
|
+
* a class looks harmless and is not: `.card:last-child` drops the bottom
|
|
34
|
+
* margin, so the wrapped card loses the gap under it and the next one appears
|
|
35
|
+
* welded to it.
|
|
36
|
+
*/
|
|
37
|
+
className?: string
|
|
38
|
+
}) {
|
|
39
|
+
return (
|
|
40
|
+
<div className={className ? `card ${className}` : 'card'} id={id} style={style}>
|
|
41
|
+
{title && (
|
|
42
|
+
<div className="card-head">
|
|
43
|
+
<h2>{title}</h2>
|
|
44
|
+
{hint && <span className="cell-sub">{hint}</span>}
|
|
45
|
+
{actions && <div className="card-actions">{actions}</div>}
|
|
46
|
+
</div>
|
|
47
|
+
)}
|
|
48
|
+
{children}
|
|
49
|
+
</div>
|
|
50
|
+
)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Padded body for a card whose content is not a table. */
|
|
54
|
+
export function CardBody({ children, style }: { children: ReactNode; style?: React.CSSProperties }) {
|
|
55
|
+
return <div className="card-pad" style={style}>{children}</div>
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Two columns on a wide screen, one on a narrow one. */
|
|
59
|
+
export function CardGrid({ children }: { children: ReactNode }) {
|
|
60
|
+
return <div className="grid2">{children}</div>
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The strip of numbers at the top of an overview. */
|
|
64
|
+
export function Stats({ children }: { children: ReactNode }) {
|
|
65
|
+
return <div className="stats">{children}</div>
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* One number. `value` is undefined until its source answers — the dash says
|
|
70
|
+
* "not known yet" rather than showing a zero that would read as a fact.
|
|
71
|
+
*/
|
|
72
|
+
export function Stat({ label, value, note }: { label: ReactNode; value: ReactNode; note?: ReactNode }) {
|
|
73
|
+
return (
|
|
74
|
+
<div className="stat">
|
|
75
|
+
<div className="k">{label}</div>
|
|
76
|
+
<div className="v num">{value ?? '–'}</div>
|
|
77
|
+
{note && <div className="s">{note}</div>}
|
|
78
|
+
</div>
|
|
79
|
+
)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** A labelled row inside a card: what it is on the left, the value on the right. */
|
|
83
|
+
export function KeyValue({ label, children }: { label: ReactNode; children: ReactNode }) {
|
|
84
|
+
return <div className="kv"><span>{label}</span><b>{children}</b></div>
|
|
85
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useEffect, useMemo, useRef, useState } from 'react'
|
|
4
|
+
|
|
5
|
+
export type PaletteItem = {
|
|
6
|
+
label: string
|
|
7
|
+
/** The intent name, shown on the right. The palette, the API and the assistant share it. */
|
|
8
|
+
intent: string
|
|
9
|
+
run: () => void
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* ⌘K. Presentational: it filters, moves the highlight and calls `run`. What is
|
|
14
|
+
* in the list is the application's business, never the palette's.
|
|
15
|
+
*/
|
|
16
|
+
export function CommandPalette({ open, onClose, items, placeholder = 'What do you want to do?' }: {
|
|
17
|
+
open: boolean
|
|
18
|
+
onClose: () => void
|
|
19
|
+
items: PaletteItem[]
|
|
20
|
+
placeholder?: string
|
|
21
|
+
}) {
|
|
22
|
+
const inputRef = useRef<HTMLInputElement>(null)
|
|
23
|
+
const [q, setQ] = useState('')
|
|
24
|
+
const [hl, setHl] = useState(0)
|
|
25
|
+
|
|
26
|
+
useEffect(() => { if (open) { setQ(''); setHl(0); inputRef.current?.focus() } }, [open])
|
|
27
|
+
|
|
28
|
+
const shown = useMemo(
|
|
29
|
+
() => items.filter((it) => !q || `${it.label} ${it.intent}`.toLowerCase().includes(q.toLowerCase())),
|
|
30
|
+
[items, q],
|
|
31
|
+
)
|
|
32
|
+
if (!open) return null
|
|
33
|
+
|
|
34
|
+
const pick = (it: PaletteItem) => { onClose(); it.run() }
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<div className="cmdk on" onClick={(e) => e.target === e.currentTarget && onClose()}>
|
|
38
|
+
<div className="cmdk-box">
|
|
39
|
+
<input ref={inputRef} type="text" placeholder={placeholder} value={q}
|
|
40
|
+
onChange={(e) => { setQ(e.target.value); setHl(0) }}
|
|
41
|
+
onKeyDown={(e) => {
|
|
42
|
+
if (e.key === 'ArrowDown') { e.preventDefault(); setHl((h) => Math.min(h + 1, shown.length - 1)) }
|
|
43
|
+
if (e.key === 'ArrowUp') { e.preventDefault(); setHl((h) => Math.max(h - 1, 0)) }
|
|
44
|
+
if (e.key === 'Escape') onClose()
|
|
45
|
+
if (e.key === 'Enter' && shown[hl]) pick(shown[hl])
|
|
46
|
+
}} />
|
|
47
|
+
<div className="cmdk-list">
|
|
48
|
+
{shown.map((it, i) => (
|
|
49
|
+
<button key={it.intent + it.label} className={'cmdk-item' + (i === hl ? ' hl' : '')}
|
|
50
|
+
onMouseEnter={() => setHl(i)} onClick={() => pick(it)}>
|
|
51
|
+
<span>{it.label}</span><span className="k">{it.intent}</span>
|
|
52
|
+
</button>
|
|
53
|
+
))}
|
|
54
|
+
{shown.length === 0 && <div className="cell-sub" style={{ padding: '10px 14px' }}>Nothing matches.</div>}
|
|
55
|
+
</div>
|
|
56
|
+
</div>
|
|
57
|
+
</div>
|
|
58
|
+
)
|
|
59
|
+
}
|
package/src/commands.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* What ⌘K can do, derived from the module list so a new module is reachable
|
|
5
|
+
* the moment it is added. A screen's own commands (create, revoke, switch) are
|
|
6
|
+
* appended by whoever owns the action.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { PaletteItem } from './command-palette'
|
|
10
|
+
import type { Module } from './nav'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The palette offers what the rail offers. A command for something the surface
|
|
14
|
+
* has filtered out would fail on the way in, and offering it is a worse answer
|
|
15
|
+
* than not knowing about it.
|
|
16
|
+
*/
|
|
17
|
+
export function navigationCommands(modules: Module[], go: (href: string) => void): PaletteItem[] {
|
|
18
|
+
const out: PaletteItem[] = []
|
|
19
|
+
for (const m of modules) {
|
|
20
|
+
for (const it of m.items) {
|
|
21
|
+
if (!it.href) continue
|
|
22
|
+
const slug = it.label.toLowerCase().replace(/\s+/g, '-')
|
|
23
|
+
out.push({ label: `→ ${m.title} · ${it.label}`, intent: `${m.key}.${slug}`, run: () => go(it.href!) })
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return out
|
|
27
|
+
}
|
package/src/dialog.tsx
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef, useState } from 'react'
|
|
4
|
+
|
|
5
|
+
export type ConfirmRequest = {
|
|
6
|
+
title: string
|
|
7
|
+
/** What is about to happen, in one sentence. */
|
|
8
|
+
intro: string
|
|
9
|
+
/** What disappears for good. */
|
|
10
|
+
losses?: string[]
|
|
11
|
+
/** A footnote: a grace period, where the data goes. */
|
|
12
|
+
note?: string
|
|
13
|
+
confirmLabel: string
|
|
14
|
+
/** When set, the confirm button stays closed until this is typed exactly. */
|
|
15
|
+
typeToConfirm?: string
|
|
16
|
+
onConfirm: () => Promise<void>
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The confirmation for a destructive action.
|
|
21
|
+
*
|
|
22
|
+
* Two things here are deliberate. Focus lands on Cancel (or on the text box),
|
|
23
|
+
* never on the destructive button, so a reflex Enter destroys nothing. And
|
|
24
|
+
* `typeToConfirm` is the only guard against clicking the right button on the
|
|
25
|
+
* wrong row — deletion has no undo, so the name has to be written out.
|
|
26
|
+
*
|
|
27
|
+
* A failure keeps the dialog open and shows why: closing it would leave the
|
|
28
|
+
* person believing the thing happened.
|
|
29
|
+
*/
|
|
30
|
+
export function ConfirmDialog({ request, onClose }: { request: ConfirmRequest | null; onClose: () => void }) {
|
|
31
|
+
const [typed, setTyped] = useState('')
|
|
32
|
+
const [busy, setBusy] = useState(false)
|
|
33
|
+
const [error, setError] = useState<string | null>(null)
|
|
34
|
+
const inputRef = useRef<HTMLInputElement>(null)
|
|
35
|
+
const cancelRef = useRef<HTMLButtonElement>(null)
|
|
36
|
+
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
if (!request) return
|
|
39
|
+
setTyped(''); setError(null); setBusy(false)
|
|
40
|
+
const t = setTimeout(() => (request.typeToConfirm ? inputRef.current : cancelRef.current)?.focus(), 30)
|
|
41
|
+
return () => clearTimeout(t)
|
|
42
|
+
}, [request])
|
|
43
|
+
|
|
44
|
+
useEffect(() => {
|
|
45
|
+
if (!request) return
|
|
46
|
+
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape' && !busy) onClose() }
|
|
47
|
+
document.addEventListener('keydown', onKey)
|
|
48
|
+
return () => document.removeEventListener('keydown', onKey)
|
|
49
|
+
}, [request, busy, onClose])
|
|
50
|
+
|
|
51
|
+
if (!request) return null
|
|
52
|
+
const ready = !request.typeToConfirm || typed.trim() === request.typeToConfirm
|
|
53
|
+
|
|
54
|
+
const run = async () => {
|
|
55
|
+
if (!ready || busy) return
|
|
56
|
+
setBusy(true); setError(null)
|
|
57
|
+
try {
|
|
58
|
+
await request.onConfirm()
|
|
59
|
+
onClose()
|
|
60
|
+
} catch (err) {
|
|
61
|
+
setError(err instanceof Error ? err.message : 'The action failed.')
|
|
62
|
+
setBusy(false)
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return (
|
|
67
|
+
<div className="dialog-wrap" onMouseDown={() => !busy && onClose()}>
|
|
68
|
+
<div className="dialog" role="alertdialog" aria-modal="true" aria-label={request.title}
|
|
69
|
+
onMouseDown={(e) => e.stopPropagation()}>
|
|
70
|
+
<div className="dialog-head"><span className="sev" /><h3>{request.title}</h3></div>
|
|
71
|
+
<div className="dialog-body">
|
|
72
|
+
<p>{request.intro}</p>
|
|
73
|
+
{request.losses && request.losses.length > 0 && (
|
|
74
|
+
<ul className="dialog-losses">{request.losses.map((l) => <li key={l}>{l}</li>)}</ul>
|
|
75
|
+
)}
|
|
76
|
+
{request.note && <p className="cell-sub">{request.note}</p>}
|
|
77
|
+
{request.typeToConfirm && (
|
|
78
|
+
<div className="field">
|
|
79
|
+
<label htmlFor="confirm-name">Type <span className="mono">{request.typeToConfirm}</span> to confirm</label>
|
|
80
|
+
<input id="confirm-name" ref={inputRef} type="text" autoComplete="off" spellCheck={false}
|
|
81
|
+
value={typed} disabled={busy} onChange={(e) => setTyped(e.target.value)}
|
|
82
|
+
onKeyDown={(e) => e.key === 'Enter' && run()} />
|
|
83
|
+
</div>
|
|
84
|
+
)}
|
|
85
|
+
{error && <div className="form-error">{error}</div>}
|
|
86
|
+
</div>
|
|
87
|
+
<div className="dialog-foot">
|
|
88
|
+
<button className="btn ghost" ref={cancelRef} onClick={onClose} disabled={busy}>Cancel</button>
|
|
89
|
+
<button className="btn danger" onClick={run} disabled={!ready || busy}>
|
|
90
|
+
{busy ? 'Working…' : request.confirmLabel}
|
|
91
|
+
</button>
|
|
92
|
+
</div>
|
|
93
|
+
</div>
|
|
94
|
+
</div>
|
|
95
|
+
)
|
|
96
|
+
}
|