dowel-ui 0.25.0 → 0.26.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 +42 -177
- package/dist/registry.json +217 -65
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -2,17 +2,19 @@
|
|
|
2
2
|
<img src="https://raw.githubusercontent.com/lacodda/dowel/main/assets/banner.svg" width="720" alt="dowel">
|
|
3
3
|
</p>
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
> The lacodda line design system: theme tokens and React primitives, distributed as a shadcn-compatible registry.
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
<p align="center">
|
|
8
|
+
<a href="https://www.npmjs.com/package/dowel-ui"><img src="https://img.shields.io/npm/v/dowel-ui?style=flat-square" alt="npm"></a>
|
|
9
|
+
<a href="https://github.com/lacodda/dowel/actions"><img src="https://img.shields.io/github/actions/workflow/status/lacodda/dowel/ci.yml?style=flat-square" alt="CI"></a>
|
|
10
|
+
<a href="https://github.com/lacodda/dowel/blob/main/LICENSE"><img src="https://img.shields.io/github/license/lacodda/dowel?style=flat-square" alt="License"></a>
|
|
11
|
+
</p>
|
|
8
12
|
|
|
9
13
|
A dowel is the hidden peg that joins two boards so the seam does not show. That is what this does for the products of the line: they look made by one hand, and nobody sees the joint.
|
|
10
14
|
|
|
11
15
|
**[Documentation](https://lacodda.github.io/dowel/)** — what everything is and why it is that way.
|
|
12
16
|
**[The stand](https://lacodda.github.io/dowel/stand/)** — every component, live, in either theme and in the accent of any product of the line.
|
|
13
17
|
|
|
14
|
-
**Status:** v0.25.0 - the theme, the scales, an accent per product, seventy-six components - overlays, menus, the command palette, the table, a hundred thousand rows, the screens between asking for data and showing it, the three ways of saying something happened, charts, and now text and code: rendered markdown through one stylesheet, a code block whose highlighter is yours, a comparison whose two columns stay in step, and a JSON document read rather than parsed by eye - and the gates each one passes: axe, the keyboard, a dependency budget and a picture in both themes. Every component has a page of its own on the stand, which remembers the theme and the accent you left it in. Components install from a versioned registry, the docs are served in the form an agent reads, and `dowel check` tells a project what stands between it and the vocabulary. Two products of the line live on it. See the [roadmap](#roadmap).
|
|
15
|
-
|
|
16
18
|
## The theme
|
|
17
19
|
|
|
18
20
|
One import, and a product has the vocabulary of the line:
|
|
@@ -32,58 +34,21 @@ npm install dowel-ui
|
|
|
32
34
|
}
|
|
33
35
|
```
|
|
34
36
|
|
|
35
|
-
That single line moves the accent and both of its partners, the accent's soft
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
<html class="light"> <!-- pinned light -->
|
|
42
|
-
<html class="dark"> <!-- pinned dark -->
|
|
43
|
-
```
|
|
44
|
-
|
|
45
|
-
With Tailwind 4 the tokens are utilities, because the theme declares them in a `@theme` block:
|
|
46
|
-
|
|
47
|
-
```html
|
|
48
|
-
<div class="bg-raise text-text border border-line">
|
|
49
|
-
<button class="bg-accent text-on-accent">Save</button>
|
|
50
|
-
</div>
|
|
51
|
-
```
|
|
52
|
-
|
|
53
|
-
The stock palette is dropped deliberately, so a stray `bg-zinc-800` does not compile. If a colour is worth using, it is worth a name in the vocabulary.
|
|
54
|
-
|
|
55
|
-
### What makes it different
|
|
56
|
-
|
|
57
|
-
- **The accent is derived, not configured.** A product states one hue and the theme works out the rest, including what colour text has to be to sit on top of it. A light accent takes dark glyphs, a dark one white - checked against every colour in the line rather than left to each product to get right.
|
|
58
|
-
- **The greys belong to the product.** They carry a trace of its hue, so the chrome of one product is not the chrome of another with a different button colour.
|
|
59
|
-
- **Contrast is a test, not an intention.** Every accent of the line is measured against WCAG AA in CI, in both themes, as a fill and as text.
|
|
60
|
-
- **The scales were read, not invented.** Radius, type, elevation and stacking order come from what the line's products already draw, so existing code fits them - and the places where those products disagreed with themselves are settled rather than preserved.
|
|
61
|
-
|
|
62
|
-
The same vocabulary also ships as [DTCG](https://www.designtokens.org/tr/2025.10/format/) JSON at `dowel-ui/tokens.json`, generated from the stylesheet so the two cannot drift.
|
|
63
|
-
|
|
64
|
-
A product of the line states one thing about its appearance - which product it is:
|
|
65
|
-
|
|
66
|
-
```css
|
|
67
|
-
@import 'dowel-ui/theme.css';
|
|
68
|
-
@import 'dowel-ui/accents/kilna.css';
|
|
69
|
-
```
|
|
70
|
-
|
|
71
|
-
Text a product does not write by hand - rendered markdown, a description from a CMS, a model's reply - is the one thing a component cannot style, because the tags arrive already made. That is a second stylesheet, imported the same way and applied with one class:
|
|
72
|
-
|
|
73
|
-
```css
|
|
74
|
-
@import 'dowel-ui/prose.css';
|
|
75
|
-
```
|
|
76
|
-
|
|
77
|
-
```tsx
|
|
78
|
-
<div className="prose" dangerouslySetInnerHTML={{ __html: sanitised }} />
|
|
79
|
-
```
|
|
37
|
+
That single line moves the accent and both of its partners, the accent's soft
|
|
38
|
+
fill, the focus ring, the scrollbars and the tint in the greys. **The accent is
|
|
39
|
+
derived, not configured**: a product states one hue and the theme works out the
|
|
40
|
+
rest, including what colour text has to be to sit on top of it - checked
|
|
41
|
+
against WCAG AA in CI, in both themes. Dark is the default; light arrives with
|
|
42
|
+
the reader's system preference.
|
|
80
43
|
|
|
81
|
-
|
|
44
|
+
With Tailwind 4 the tokens are utilities, and the stock palette is dropped
|
|
45
|
+
deliberately, so a stray `bg-zinc-800` does not compile. If a colour is worth
|
|
46
|
+
using, it is worth a name in the vocabulary - a convention the package also
|
|
47
|
+
ships as a lint rule.
|
|
82
48
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
```
|
|
49
|
+
Full vocabulary, shown rather than tabulated:
|
|
50
|
+
**[colours](https://lacodda.github.io/dowel/reference/tokens/)** and
|
|
51
|
+
**[the scales](https://lacodda.github.io/dowel/reference/scales/)**.
|
|
87
52
|
|
|
88
53
|
## Primitives
|
|
89
54
|
|
|
@@ -92,122 +57,28 @@ your code:
|
|
|
92
57
|
|
|
93
58
|
```bash
|
|
94
59
|
npx shadcn@latest add https://lacodda.github.io/dowel/r/button.json
|
|
60
|
+
npx shadcn@latest add https://lacodda.github.io/dowel/r/app.json # or a whole set
|
|
95
61
|
```
|
|
96
62
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
installs you could have typed, so nothing of it survives in your project and
|
|
106
|
-
there is no membership to leave. Each minor of the registry is also served
|
|
107
|
-
frozen at `r/v0.25/…`, for an install that has to be repeatable - inside a
|
|
108
|
-
snapshot the cross-references point into the same snapshot, so a component and
|
|
109
|
-
the sibling it reuses are the pair that shipped together. See
|
|
110
|
-
[installing from the registry](https://lacodda.github.io/dowel/guides/registry/).
|
|
111
|
-
|
|
112
|
-
Seventy-six of them so far. The everyday ones - Button, Input, Textarea, Panel,
|
|
113
|
-
Badge, Chip, Kbd, Spinner, Truncate and Copyable; Field and the three controls
|
|
114
|
-
that answer a question - Checkbox, RadioGroup and Switch; five for a number or
|
|
115
|
-
a judgement - NumberField, Slider, RatingScale, DurationField and
|
|
116
|
-
PasswordField; the harder half of a form - TagInput, FileDrop, ColorField,
|
|
117
|
-
ActionBar and SaveState; five for a date or a time - Calendar, DatePicker,
|
|
118
|
-
DateRangePicker, TimeField and the `calendar-math` under them; the six that
|
|
119
|
-
float above the page - Dialog, ConfirmDialog, Drawer, Popover, PreviewCard and
|
|
120
|
-
Tooltip; four for choosing something - Menu, ContextMenu, Select and Combobox;
|
|
121
|
-
three for finding it - SearchField, CommandPalette and the `useShortcut` behind
|
|
122
|
-
them; three for saying that something happened - Toast, Alert and Banner; six
|
|
123
|
-
for showing rows of data - Table, Pagination, PageSize, NumberFormat,
|
|
124
|
-
RelativeTime and the `table-sort` with no React in it; four for a long list or
|
|
125
|
-
a deep one - VirtualList, TreeView with the `tree-rows` under it, and KeyValue;
|
|
126
|
-
StatTile, for one figure and what it is a figure of, with Sparkline for the
|
|
127
|
-
shape of its history, and Track with its `track-segments` for a bar divided
|
|
128
|
-
into stretches; ActivityHeatmap with its legend and its `activity-weeks`, for a
|
|
129
|
-
year of days; BarChart for a period at a time, LineChart with its `line-scale` for a level
|
|
130
|
-
between the readings; and five for the screens
|
|
131
|
-
between asking for data and showing it: Skeleton, EmptyState, Progress,
|
|
132
|
-
QueryState and ErrorBoundary. And six for text and code: the `prose`
|
|
133
|
-
stylesheet for markdown a product did not write by hand, CodeBlock with
|
|
134
|
-
CopyButton beside it, DiffView with the `diff-lines` that keeps its two
|
|
135
|
-
columns in step, and JsonViewer over its `json-rows`.
|
|
136
|
-
|
|
137
|
-
Those last three are the ones products keep confusing, so each page names all
|
|
138
|
-
four options: a **toast** goes away, an **alert** is still true after a reload,
|
|
139
|
-
a **banner** is true on every screen, and anything that needs an answer is a
|
|
140
|
-
**dialog**.
|
|
141
|
-
|
|
142
|
-
The palette is a Combobox rather than a Dialog with a field in it, which is
|
|
143
|
-
Base UI's own arrangement: put the input inside the popup and the popup becomes
|
|
144
|
-
a dialog on its own, with the input still the combobox that owns the list. So
|
|
145
|
-
the filtering, the highlight and the arrow keys are the ones Combobox already
|
|
146
|
-
has - there is no second implementation of any of it.
|
|
147
|
-
|
|
148
|
-
`Select` is the one the line's oldest rule is about. It renders
|
|
149
|
-
`<button role="combobox">` and no native `<select>` at all - the browser draws
|
|
150
|
-
that popup in the operating system's own chrome, where no stylesheet reaches
|
|
151
|
-
it, and on a screen of the product's own controls it reads as a foreign object.
|
|
152
|
-
`multiple` is a prop on Select and on Combobox rather than a component of its
|
|
153
|
-
own.
|
|
154
|
-
|
|
155
|
-
The overlays are built on [Base UI](https://base-ui.com), which supplies the
|
|
156
|
-
part that is genuinely hard and invisible when it works: the focus trap, the
|
|
157
|
-
return of focus to whatever opened the thing, `Escape`, the scroll lock, and
|
|
158
|
-
the `aria-labelledby` that names a popup by its own title. Choosing between
|
|
159
|
-
them is the harder question, and [a guide](https://lacodda.github.io/dowel/guides/overlays/) covers it.
|
|
160
|
-
|
|
161
|
-
Each is written in the vocabulary - no raw colours, no `dark:` utilities - so
|
|
162
|
-
the same component is correct in both themes and in every product's accent.
|
|
163
|
-
Every one of them is on [the stand](https://lacodda.github.io/dowel/stand/), live - change the theme and the accent and watch what follows.
|
|
164
|
-
|
|
165
|
-
That convention is a lint rule, and it ships with the package:
|
|
166
|
-
|
|
167
|
-
```js
|
|
168
|
-
// eslint.config.js
|
|
169
|
-
import dowel from 'dowel-ui/eslint'
|
|
170
|
-
|
|
171
|
-
export default [...dowel.configs.recommended]
|
|
172
|
-
```
|
|
173
|
-
|
|
174
|
-
Two rules come with it. One reports a hex, an `rgb()`, a stock Tailwind colour,
|
|
175
|
-
`bg-white` and any `dark:` utility, in the file that wrote one. The other
|
|
176
|
-
forbids the native `<select>`, whose popup the browser draws in the operating
|
|
177
|
-
system's own chrome where no CSS reaches it - see [the guide](https://lacodda.github.io/dowel/guides/linting/).
|
|
63
|
+
Eighty-four primitives so far - the everyday controls, forms, overlays built on
|
|
64
|
+
[Base UI](https://base-ui.com), menus and selection, the command palette,
|
|
65
|
+
tables and long lists, charts, the screens between asking for data and showing
|
|
66
|
+
it, markdown, code and diffs, a desktop window's own chrome and the three
|
|
67
|
+
abilities of a catalogue table. Each is
|
|
68
|
+
written in the vocabulary - no raw
|
|
69
|
+
colours, no `dark:` utilities - and runs its own gate before it ships: axe, the
|
|
70
|
+
keyboard, a dependency budget and a picture in both themes.
|
|
178
71
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
against a baseline, and a check that it carries no word of its own to translate
|
|
183
|
-
- [what a component has to pass](https://lacodda.github.io/dowel/guides/gates/).
|
|
184
|
-
|
|
185
|
-
Full vocabulary, shown rather than tabulated: **[colours](https://lacodda.github.io/dowel/reference/tokens/)** in both themes, **[the scales](https://lacodda.github.io/dowel/reference/scales/)** - radius, type, motion, elevation and stacking order - and **[the accents](https://lacodda.github.io/dowel/reference/accents/)**, where the same screen is drawn in every colour of the line.
|
|
72
|
+
The full catalogue, live: **[the stand](https://lacodda.github.io/dowel/stand/)**.
|
|
73
|
+
One page per component: **[components](https://lacodda.github.io/dowel/components/button/)**.
|
|
74
|
+
Installing, sets and frozen versions: **[the registry guide](https://lacodda.github.io/dowel/guides/registry/)**.
|
|
186
75
|
|
|
187
76
|
## Moving an existing project over
|
|
188
77
|
|
|
189
78
|
A product arriving at dowel almost never arrives from nothing - it arrives from
|
|
190
|
-
stock shadcn/ui, whose theme names colours by their role in a page
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
```console
|
|
195
|
-
$ npx dowel check # what is not on the dowel vocabulary yet
|
|
196
|
-
$ npx dowel codemod --write # rewrite the names that can be rewritten
|
|
197
|
-
$ npx dowel doctor # whether the installation itself is wired right
|
|
198
|
-
$ npx dowel diff dialog # what you changed since you copied it in
|
|
199
|
-
```
|
|
200
|
-
|
|
201
|
-
`check`, `doctor` and `diff` only read; `codemod` writes, and only when asked
|
|
202
|
-
twice. Everything reads the catalogue inside the installed package, so none of
|
|
203
|
-
it needs the network.
|
|
204
|
-
|
|
205
|
-
Two things are deliberately never rewritten, and the tools say so rather than
|
|
206
|
-
guessing: a colour, because which token it was reaching for is a decision; and
|
|
207
|
-
`--accent`, because it is the one name both vocabularies use for opposite
|
|
208
|
-
things - stock's hover fill and dowel's product hue. The first run of `check`
|
|
209
|
-
against dowel's own stand reported twenty-four violations that were all correct
|
|
210
|
-
code, which is exactly how that rule was learned - see
|
|
79
|
+
stock shadcn/ui, whose theme names colours by their role in a page where dowel
|
|
80
|
+
names them by what they are on a screen. Four commands read a project, rewrite
|
|
81
|
+
the names that can be rewritten, and say what is left for a person to decide:
|
|
211
82
|
[the migration guide](https://lacodda.github.io/dowel/guides/migration/).
|
|
212
83
|
|
|
213
84
|
## A day in the life
|
|
@@ -242,8 +113,7 @@ every dowel component targets:
|
|
|
242
113
|
}
|
|
243
114
|
```
|
|
244
115
|
|
|
245
|
-
|
|
246
|
-
|
|
116
|
+
> Copy in what the screen needs.
|
|
247
117
|
```console
|
|
248
118
|
$ npx shadcn@latest add https://lacodda.github.io/dowel/r/app.json
|
|
249
119
|
```
|
|
@@ -281,19 +151,14 @@ fixed: the hand-written theme pinned dark ink on the accent for both themes,
|
|
|
281
151
|
which was right on gold and wrong on the darkened gold the light theme uses,
|
|
282
152
|
where it measured 3.49:1. Derived, it is white there, at 6.01:1.
|
|
283
153
|
|
|
284
|
-
##
|
|
154
|
+
## Status
|
|
285
155
|
|
|
286
|
-
|
|
156
|
+
The theme, the scales, an accent per product, and eighty-four primitives are
|
|
157
|
+
in daily use across two products of the line. Every component installs from a
|
|
158
|
+
versioned registry and passes its own gate - axe, the keyboard, a dependency
|
|
159
|
+
budget and a picture in both themes - before it ships.
|
|
287
160
|
|
|
288
|
-
|
|
289
|
-
| --- | --- |
|
|
290
|
-
| **0.1 - 0.3** | The vocabulary: colours and modes, scales and motion, the accents of the line |
|
|
291
|
-
| **0.4 - 0.7** | The primitive pipeline, the base components, the first consumer, the quality gates |
|
|
292
|
-
| **0.8 - 0.11** | Overlays, menus and selection, the command palette, feedback |
|
|
293
|
-
| **0.12 - 0.15** | The registry as a product, AI-readiness, migration tooling, the second consumer |
|
|
294
|
-
| **0.16 - 0.24** | Forms, data and charts |
|
|
295
|
-
| **0.25 - 0.33** | Frame and navigation, blocks, resilience and docs |
|
|
296
|
-
| **1.0** | Four web products of the line on dowel; the token vocabulary and the registry format frozen |
|
|
161
|
+
Released versions and what landed in each: [CHANGELOG](https://github.com/lacodda/dowel/blob/main/CHANGELOG.md).
|
|
297
162
|
|
|
298
163
|
## Documentation
|
|
299
164
|
|
|
@@ -321,4 +186,4 @@ same sources the site is built from:
|
|
|
321
186
|
|
|
322
187
|
## License
|
|
323
188
|
|
|
324
|
-
MIT
|
|
189
|
+
MIT (c) [Kirill Lakhtachev](https://lacodda.com)
|
package/dist/registry.json
CHANGED
|
@@ -309,7 +309,7 @@
|
|
|
309
309
|
"dependencies": [
|
|
310
310
|
"@base-ui/react",
|
|
311
311
|
"class-variance-authority",
|
|
312
|
-
"dowel-ui@^0.
|
|
312
|
+
"dowel-ui@^0.26.0"
|
|
313
313
|
],
|
|
314
314
|
"registryDependencies": [],
|
|
315
315
|
"files": [
|
|
@@ -328,7 +328,7 @@
|
|
|
328
328
|
"description": "The shape everyone recognises: weeks as columns, weekdays as rows, time running left to right, and a value carried by how dark a square is. Two products of the line asked for it by name before it existed.",
|
|
329
329
|
"dependencies": [
|
|
330
330
|
"class-variance-authority",
|
|
331
|
-
"dowel-ui@^0.
|
|
331
|
+
"dowel-ui@^0.26.0"
|
|
332
332
|
],
|
|
333
333
|
"registryDependencies": [
|
|
334
334
|
"https://lacodda.github.io/dowel/r/activity-weeks.json"
|
|
@@ -348,7 +348,7 @@
|
|
|
348
348
|
"title": "Activity-legend",
|
|
349
349
|
"description": "Separate from the grid because a caller showing three grids on one screen wants one legend, and because the words in it are the product's.",
|
|
350
350
|
"dependencies": [
|
|
351
|
-
"dowel-ui@^0.
|
|
351
|
+
"dowel-ui@^0.26.0"
|
|
352
352
|
],
|
|
353
353
|
"registryDependencies": [
|
|
354
354
|
"https://lacodda.github.io/dowel/r/activity-heatmap.json",
|
|
@@ -386,7 +386,7 @@
|
|
|
386
386
|
"description": "A message that stays on the screen, in the flow of the page, about the thing next to it: this field could not be saved, this profile has no axes yet, this export is out of date.",
|
|
387
387
|
"dependencies": [
|
|
388
388
|
"class-variance-authority",
|
|
389
|
-
"dowel-ui@^0.
|
|
389
|
+
"dowel-ui@^0.26.0"
|
|
390
390
|
],
|
|
391
391
|
"registryDependencies": [],
|
|
392
392
|
"files": [
|
|
@@ -405,7 +405,7 @@
|
|
|
405
405
|
"description": "A small piece of state attached to something else: a count, a status, a label. It is not a button and never was - if it can be clicked it is a Chip.",
|
|
406
406
|
"dependencies": [
|
|
407
407
|
"class-variance-authority",
|
|
408
|
-
"dowel-ui@^0.
|
|
408
|
+
"dowel-ui@^0.26.0"
|
|
409
409
|
],
|
|
410
410
|
"registryDependencies": [],
|
|
411
411
|
"files": [
|
|
@@ -424,7 +424,7 @@
|
|
|
424
424
|
"description": "A strip across the top of the application, about the application: you are offline, this build is a preview, your licence expires on Friday, a new version is ready to install.",
|
|
425
425
|
"dependencies": [
|
|
426
426
|
"class-variance-authority",
|
|
427
|
-
"dowel-ui@^0.
|
|
427
|
+
"dowel-ui@^0.26.0"
|
|
428
428
|
],
|
|
429
429
|
"registryDependencies": [],
|
|
430
430
|
"files": [
|
|
@@ -443,7 +443,7 @@
|
|
|
443
443
|
"description": "Bars rather than a line, and the distinction is the data's not the drawing's: a line says the value exists between the points, a column says each period is its own sum. Hours worked in a week is a sum - there is no \"Wednesday afternoon\" reading between two weeks - so it is a column.",
|
|
444
444
|
"dependencies": [
|
|
445
445
|
"class-variance-authority",
|
|
446
|
-
"dowel-ui@^0.
|
|
446
|
+
"dowel-ui@^0.26.0"
|
|
447
447
|
],
|
|
448
448
|
"registryDependencies": [],
|
|
449
449
|
"files": [
|
|
@@ -463,7 +463,7 @@
|
|
|
463
463
|
"dependencies": [
|
|
464
464
|
"@base-ui/react",
|
|
465
465
|
"class-variance-authority",
|
|
466
|
-
"dowel-ui@^0.
|
|
466
|
+
"dowel-ui@^0.26.0"
|
|
467
467
|
],
|
|
468
468
|
"registryDependencies": [],
|
|
469
469
|
"files": [
|
|
@@ -497,7 +497,7 @@
|
|
|
497
497
|
"title": "Calendar",
|
|
498
498
|
"description": "The sums live next door in `calendar-math`, which has no React in it; this is the grid that draws them and the keyboard that moves around it.",
|
|
499
499
|
"dependencies": [
|
|
500
|
-
"dowel-ui@^0.
|
|
500
|
+
"dowel-ui@^0.26.0"
|
|
501
501
|
],
|
|
502
502
|
"registryDependencies": [
|
|
503
503
|
"https://lacodda.github.io/dowel/r/calendar-math.json"
|
|
@@ -518,7 +518,7 @@
|
|
|
518
518
|
"description": "The interesting part is the words. A checkbox on its own is a nine-pixel target that says nothing; wired to a label it is the whole row, and the row is what a finger and a pointer both aim at. So the label is part of the component rather than something a caller remembers to add - the commonest bug in a hand-rolled checkbox is a `<label>` that is next to the input instead of tied to it, which looks identical and does nothing.",
|
|
519
519
|
"dependencies": [
|
|
520
520
|
"@base-ui/react",
|
|
521
|
-
"dowel-ui@^0.
|
|
521
|
+
"dowel-ui@^0.26.0"
|
|
522
522
|
],
|
|
523
523
|
"registryDependencies": [],
|
|
524
524
|
"files": [
|
|
@@ -537,7 +537,7 @@
|
|
|
537
537
|
"description": "A badge you can act on: a filter that can be removed, a tag with a count, a selected value in a field. The difference from a Badge is entirely about whether something happens when you click it - and if something does, that part is a real `<button>` with a real label, not a decorative cross.",
|
|
538
538
|
"dependencies": [
|
|
539
539
|
"class-variance-authority",
|
|
540
|
-
"dowel-ui@^0.
|
|
540
|
+
"dowel-ui@^0.26.0"
|
|
541
541
|
],
|
|
542
542
|
"registryDependencies": [],
|
|
543
543
|
"files": [
|
|
@@ -556,7 +556,7 @@
|
|
|
556
556
|
"description": "The frame around a piece of code is the same everywhere and is written again in every product: the scroll that must not wrap, the gutter of line numbers that must not be selectable, the copy button, the caption saying which file this is, and the marking of the lines the reader was sent here to look at.",
|
|
557
557
|
"dependencies": [
|
|
558
558
|
"class-variance-authority",
|
|
559
|
-
"dowel-ui@^0.
|
|
559
|
+
"dowel-ui@^0.26.0"
|
|
560
560
|
],
|
|
561
561
|
"registryDependencies": [
|
|
562
562
|
"https://lacodda.github.io/dowel/r/copy-button.json"
|
|
@@ -576,7 +576,7 @@
|
|
|
576
576
|
"title": "Color-field",
|
|
577
577
|
"description": "Picking a colour for something the product stores: a tag, a project, a calendar. Note what that is *not* - it is not choosing the appearance of the interface. The theme decides that, from one accent, and a field that let a reader repaint the chrome would undo the argument the whole system rests on.",
|
|
578
578
|
"dependencies": [
|
|
579
|
-
"dowel-ui@^0.
|
|
579
|
+
"dowel-ui@^0.26.0"
|
|
580
580
|
],
|
|
581
581
|
"registryDependencies": [
|
|
582
582
|
"https://lacodda.github.io/dowel/r/input.json"
|
|
@@ -590,6 +590,24 @@
|
|
|
590
590
|
}
|
|
591
591
|
]
|
|
592
592
|
},
|
|
593
|
+
{
|
|
594
|
+
"name": "column-resize-handle",
|
|
595
|
+
"type": "registry:ui",
|
|
596
|
+
"title": "Column-resize-handle",
|
|
597
|
+
"description": "The handle, and the hook that keeps the widths it produces. Pointer events rather than HTML5 drag-and-drop: a desktop shell that takes file drops for itself never lets a `dragstart` reach the page, so the native API is a handle that does nothing there; pointer capture on the handle also keeps the drag alive when the pointer runs ahead of the cell, which at any speed above a crawl it does.",
|
|
598
|
+
"dependencies": [
|
|
599
|
+
"dowel-ui@^0.26.0"
|
|
600
|
+
],
|
|
601
|
+
"registryDependencies": [],
|
|
602
|
+
"files": [
|
|
603
|
+
{
|
|
604
|
+
"path": "ui/column-resize-handle.tsx",
|
|
605
|
+
"target": "@ui/column-resize-handle.tsx",
|
|
606
|
+
"type": "registry:ui",
|
|
607
|
+
"content": "import { useCallback, useRef, useState, type PointerEvent as ReactPointerEvent } from 'react'\nimport { cn } from 'dowel-ui'\n\n/*\n * The strip on a header cell's right edge that a column is dragged wider or narrower by.\n *\n * The handle, and the hook that keeps the widths it produces. Pointer events\n * rather than HTML5 drag-and-drop: a desktop shell that takes file drops for\n * itself never lets a `dragstart` reach the page, so the native API is a\n * handle that does nothing there; pointer capture on the handle also keeps\n * the drag alive when the pointer runs ahead of the cell, which at any speed\n * above a crawl it does.\n *\n * The starting width is measured from the cell rather than taken as a prop:\n * on pointerdown the handle reads its parent's box, and every move reports\n * that width plus the distance travelled. So the handle has no `width` to be\n * handed and cannot disagree with what is on screen. Double-click is the\n * reset, because a handle two pixels wide has no room for a second control.\n *\n * The widths themselves are the hook's, and there are two maps in it rather\n * than one. A table with any hand-set width has to switch to\n * `table-layout: fixed`, and under fixed layout every column needs a width\n * or the browser shares the free space equally between those without one -\n * which is how a date column ends up as wide as the title. So beside the\n * widths that were dragged, the hook keeps the widths every other column\n * measured the moment the first drag began, and `widthOf` answers from one\n * or the other. The caller draws one `<col>` per column from it and leaves\n * the one column meant to take the remaining space without a width.\n */\n\nexport interface ColumnResizeHandleProps {\n /** Names the column for assistive technology: \"Resize the Tier column\". */\n label: string\n /** Shown on hover: how to drag and how to reset. */\n hint?: string\n /** The width while dragging, and once more with `done` when the pointer is\n * let go - the moment to persist. */\n onResize: (width: number, done: boolean) => void\n /** Double-click: the column goes back to its natural width. */\n onReset: () => void\n /** Called on pointerdown, before the first `onResize` - the moment for the\n * caller to measure what every column is before the layout goes fixed. */\n onStart?: () => void\n /** Narrower than this and the column is a stripe with nothing in it. */\n minWidth?: number\n className?: string\n}\n\nexport function ColumnResizeHandle({\n label,\n hint,\n onResize,\n onReset,\n onStart,\n minWidth = 56,\n className,\n}: ColumnResizeHandleProps) {\n // Where the drag began, in both senses: the pointer's x and the cell's width.\n const origin = useRef<{ x: number; width: number } | null>(null)\n const [dragging, setDragging] = useState(false)\n\n const widthAt = (event: ReactPointerEvent<HTMLElement>): number | null => {\n const from = origin.current\n if (from === null) return null\n return Math.max(minWidth, Math.round(from.width + event.clientX - from.x))\n }\n\n const begin = (event: ReactPointerEvent<HTMLElement>) => {\n // The primary button only: a right-click on the edge is the context\n // menu's, and a middle one is nobody's.\n if (event.button !== 0) return\n const cell = event.currentTarget.parentElement\n if (cell === null) return\n event.preventDefault()\n event.stopPropagation()\n onStart?.()\n origin.current = { x: event.clientX, width: cell.getBoundingClientRect().width }\n event.currentTarget.setPointerCapture(event.pointerId)\n setDragging(true)\n }\n\n const move = (event: ReactPointerEvent<HTMLElement>) => {\n const width = widthAt(event)\n if (width !== null) onResize(width, false)\n }\n\n const end = (event: ReactPointerEvent<HTMLElement>) => {\n const width = widthAt(event)\n origin.current = null\n setDragging(false)\n if (event.currentTarget.hasPointerCapture(event.pointerId)) {\n event.currentTarget.releasePointerCapture(event.pointerId)\n }\n if (width !== null) onResize(width, true)\n }\n\n return (\n // A span, not a button: a button in a header cell would be one more tab\n // stop per column for something a keyboard cannot usefully drive. The\n // separator role says what it is; the label says which column.\n <span\n role=\"separator\"\n aria-orientation=\"vertical\"\n aria-label={label}\n title={hint}\n data-dragging={dragging ? '' : undefined}\n onPointerDown={begin}\n onPointerMove={move}\n onPointerUp={end}\n onPointerCancel={end}\n onDoubleClick={(event) => {\n event.stopPropagation()\n onReset()\n }}\n // The click after the drag must not reach the header: on a sortable\n // column it would flip the sort every time a width was set.\n onClick={(event) => event.stopPropagation()}\n className={cn(\n // Wider to hit than to see: the visible line is the inner pixel, the\n // grab zone is the whole strip. `touch-action: none` is what lets a\n // touch drag the column instead of scrolling the table.\n 'absolute inset-y-0 right-0 z-10 w-2 cursor-col-resize touch-none select-none',\n 'after:absolute after:inset-y-1.5 after:right-0.5 after:w-px after:bg-line after:transition-colors',\n 'hover:after:bg-accent data-[dragging]:after:bg-accent',\n className,\n )}\n />\n )\n}\n\n/** The widths of the columns the hook is asked about, by column id. */\nexport type Widths<K extends string> = Partial<Record<K, number>>\n\n/** What the hook is given. */\nexport interface ColumnWidthsOptions<K extends string> {\n /** What was dragged before - from storage, or nothing. A function is\n * called once, like `useState`'s. */\n initial: Widths<K> | (() => (Widths<K>))\n /** Told the hand-set widths whenever one settles: persist them here. */\n onChange?: (widths: Widths<K>) => void\n /** The width of a column that was not on screen when the others were\n * measured - one turned on after the first drag. */\n fallback?: (id: K) => number\n minWidth?: number\n}\n\nexport interface ColumnWidths<K extends string> {\n /** True while any width is hand-set: the table is in fixed layout. */\n sized: boolean\n /** The widths that were dragged. */\n hand: Widths<K>\n /** Whether this column's width is the person's rather than measured. */\n isHandSized: (id: K) => boolean\n /** A width for the `<col>`: dragged, else measured, else the fallback. */\n widthOf: (id: K) => number | undefined\n /** A width from the handle; `done` on the last one persists. */\n resize: (id: K, width: number, done?: boolean) => void\n /** Back to the natural width; with nothing else hand-set, back to auto layout. */\n reset: (id: K) => void\n /** The natural widths, taken once before the layout goes fixed. Ignored\n * once it has, because what is measured then is the fixed width. */\n measure: (cells: Iterable<[K, number]>) => void\n}\n\nexport function useColumnWidths<K extends string>({\n initial,\n onChange,\n fallback,\n minWidth = 56,\n}: ColumnWidthsOptions<K>): ColumnWidths<K> {\n const [hand, setHand] = useState<Widths<K>>(initial)\n const [natural, setNatural] = useState<Widths<K>>({})\n // A mirror the callbacks read, so a resize that settles in the same tick as\n // its last move persists the value that was just set and not the one from\n // the render before.\n const held = useRef(hand)\n const sized = Object.keys(hand).length > 0\n\n const commit = useCallback(\n (next: Widths<K>, persist: boolean) => {\n held.current = next\n setHand(next)\n if (persist) onChange?.(next)\n },\n [onChange],\n )\n\n const resize = useCallback(\n (id: K, width: number, done = false) => {\n commit({ ...held.current, [id]: Math.max(minWidth, Math.round(width)) }, done)\n },\n [commit, minWidth],\n )\n\n const reset = useCallback(\n (id: K) => {\n const rest: Widths<K> = { ...held.current }\n delete rest[id]\n commit(rest, true)\n // With nothing hand-set the layout goes back to auto, and the next drag\n // measures afresh - the natural widths may have changed with the data.\n if (Object.keys(rest).length === 0) setNatural({})\n },\n [commit],\n )\n\n const measure = useCallback((cells: Iterable<[K, number]>) => {\n if (Object.keys(held.current).length > 0) return\n const measured: Widths<K> = {}\n for (const [id, width] of cells) measured[id] = Math.round(width)\n setNatural(measured)\n }, [])\n\n return {\n sized,\n hand,\n isHandSized: (id) => hand[id] !== undefined,\n widthOf: (id) => hand[id] ?? natural[id] ?? fallback?.(id),\n resize,\n reset,\n measure,\n }\n}\n\n/** The widths of a header row's cells, read off the screen.\n *\n * Each cell names its column in `data-column`; a cell without one - a\n * checkbox column, a row menu - is not a column the hook is asked about and\n * is skipped. Border-box widths, because that is what a `<col>` sets. */\nexport function measureColumns<K extends string>(row: HTMLElement, attribute = 'data-column'): [K, number][] {\n const measured: [K, number][] = []\n for (const cell of row.querySelectorAll<HTMLElement>(`[${attribute}]`)) {\n const id = cell.getAttribute(attribute)\n if (id !== null) measured.push([id as K, cell.getBoundingClientRect().width])\n }\n return measured\n}\n"
|
|
608
|
+
}
|
|
609
|
+
]
|
|
610
|
+
},
|
|
593
611
|
{
|
|
594
612
|
"name": "combobox",
|
|
595
613
|
"type": "registry:ui",
|
|
@@ -598,7 +616,7 @@
|
|
|
598
616
|
"dependencies": [
|
|
599
617
|
"@base-ui/react",
|
|
600
618
|
"class-variance-authority",
|
|
601
|
-
"dowel-ui@^0.
|
|
619
|
+
"dowel-ui@^0.26.0"
|
|
602
620
|
],
|
|
603
621
|
"registryDependencies": [
|
|
604
622
|
"https://lacodda.github.io/dowel/r/input.json",
|
|
@@ -621,7 +639,7 @@
|
|
|
621
639
|
"dependencies": [
|
|
622
640
|
"@base-ui/react",
|
|
623
641
|
"class-variance-authority",
|
|
624
|
-
"dowel-ui@^0.
|
|
642
|
+
"dowel-ui@^0.26.0"
|
|
625
643
|
],
|
|
626
644
|
"registryDependencies": [
|
|
627
645
|
"https://lacodda.github.io/dowel/r/combobox.json",
|
|
@@ -644,7 +662,7 @@
|
|
|
644
662
|
"dependencies": [
|
|
645
663
|
"@base-ui/react",
|
|
646
664
|
"class-variance-authority",
|
|
647
|
-
"dowel-ui@^0.
|
|
665
|
+
"dowel-ui@^0.26.0"
|
|
648
666
|
],
|
|
649
667
|
"registryDependencies": [],
|
|
650
668
|
"files": [
|
|
@@ -663,7 +681,7 @@
|
|
|
663
681
|
"description": "The same list of actions as Menu, opened the other way round: by right click, or by a long press on a touch screen, over an *area* rather than from a button. So the trigger is not a control - it is the region the menu belongs to, a row, a canvas, a file tile - and it renders a `<div>`.",
|
|
664
682
|
"dependencies": [
|
|
665
683
|
"@base-ui/react",
|
|
666
|
-
"dowel-ui@^0.
|
|
684
|
+
"dowel-ui@^0.26.0"
|
|
667
685
|
],
|
|
668
686
|
"registryDependencies": [
|
|
669
687
|
"https://lacodda.github.io/dowel/r/menu.json"
|
|
@@ -683,7 +701,7 @@
|
|
|
683
701
|
"title": "Copy-button",
|
|
684
702
|
"description": "Whatever a product shows in a panel - code, a payload, a log, one side of a comparison - somebody eventually wants to take it away, and the button that lets them is written again every time with the same three things missed.",
|
|
685
703
|
"dependencies": [
|
|
686
|
-
"dowel-ui@^0.
|
|
704
|
+
"dowel-ui@^0.26.0"
|
|
687
705
|
],
|
|
688
706
|
"registryDependencies": [],
|
|
689
707
|
"files": [
|
|
@@ -701,7 +719,7 @@
|
|
|
701
719
|
"title": "Copyable",
|
|
702
720
|
"description": "Any text that someone will eventually want to copy - an id, a path, a hash, a token - copied with one click. The rule comes from nitid: if a value is worth showing, it is worth being able to take away, and selecting a monospaced id by hand is a small daily tax.",
|
|
703
721
|
"dependencies": [
|
|
704
|
-
"dowel-ui@^0.
|
|
722
|
+
"dowel-ui@^0.26.0"
|
|
705
723
|
],
|
|
706
724
|
"registryDependencies": [],
|
|
707
725
|
"files": [
|
|
@@ -719,7 +737,7 @@
|
|
|
719
737
|
"title": "Date-picker",
|
|
720
738
|
"description": "The trigger is a button rather than a text input, and that is the decision worth stating. A typable date field has to answer \"what does `03/04/26` mean\" in a locale it cannot be sure of, and it answers wrong for half the world; a button showing the date spelled out has no such question. Where typing genuinely matters - a birth date, forty years back - the calendar is the wrong control anyway and a product should reach for a plain field.",
|
|
721
739
|
"dependencies": [
|
|
722
|
-
"dowel-ui@^0.
|
|
740
|
+
"dowel-ui@^0.26.0"
|
|
723
741
|
],
|
|
724
742
|
"registryDependencies": [
|
|
725
743
|
"https://lacodda.github.io/dowel/r/calendar.json",
|
|
@@ -742,7 +760,7 @@
|
|
|
742
760
|
"title": "Date-range-picker",
|
|
743
761
|
"description": "The interesting part is the state between them. After the first click there is a start and no end, and that is not an incomplete range to be hidden or a range of one day - it is the normal middle of the interaction, and the calendar has to show it: the first day marked, the days under the pointer shading as the reader moves, the popup staying open. Products that skip it end up with a picker that seems to do nothing until the second click.",
|
|
744
762
|
"dependencies": [
|
|
745
|
-
"dowel-ui@^0.
|
|
763
|
+
"dowel-ui@^0.26.0"
|
|
746
764
|
],
|
|
747
765
|
"registryDependencies": [
|
|
748
766
|
"https://lacodda.github.io/dowel/r/calendar.json",
|
|
@@ -767,7 +785,7 @@
|
|
|
767
785
|
"dependencies": [
|
|
768
786
|
"@base-ui/react",
|
|
769
787
|
"class-variance-authority",
|
|
770
|
-
"dowel-ui@^0.
|
|
788
|
+
"dowel-ui@^0.26.0"
|
|
771
789
|
],
|
|
772
790
|
"registryDependencies": [],
|
|
773
791
|
"files": [
|
|
@@ -802,7 +820,7 @@
|
|
|
802
820
|
"description": "The question this answers is \"how did this read before, and how does it read now\" - a version against the one before it, a proposal against what is there, a file against what is on disk. Not a code review: there is no staging, no comment, nothing to accept. It is for looking.",
|
|
803
821
|
"dependencies": [
|
|
804
822
|
"class-variance-authority",
|
|
805
|
-
"dowel-ui@^0.
|
|
823
|
+
"dowel-ui@^0.26.0"
|
|
806
824
|
],
|
|
807
825
|
"registryDependencies": [
|
|
808
826
|
"https://lacodda.github.io/dowel/r/copy-button.json",
|
|
@@ -825,7 +843,7 @@
|
|
|
825
843
|
"dependencies": [
|
|
826
844
|
"@base-ui/react",
|
|
827
845
|
"class-variance-authority",
|
|
828
|
-
"dowel-ui@^0.
|
|
846
|
+
"dowel-ui@^0.26.0"
|
|
829
847
|
],
|
|
830
848
|
"registryDependencies": [],
|
|
831
849
|
"files": [
|
|
@@ -843,7 +861,7 @@
|
|
|
843
861
|
"title": "Duration-field",
|
|
844
862
|
"description": "The alternative is what products keep building: two number boxes labelled \"hours\" and \"minutes\", which means two tab stops, two validations, and a reader who has to divide 90 minutes in their head before typing. Here they write `1h 30m`, or `90m`, or `1.5h`, and it means the same thing.",
|
|
845
863
|
"dependencies": [
|
|
846
|
-
"dowel-ui@^0.
|
|
864
|
+
"dowel-ui@^0.26.0"
|
|
847
865
|
],
|
|
848
866
|
"registryDependencies": [
|
|
849
867
|
"https://lacodda.github.io/dowel/r/input.json"
|
|
@@ -864,7 +882,7 @@
|
|
|
864
882
|
"description": "Three kinds of nothing, and a product that draws the same panel for all three is telling the reader the wrong thing twice:\n * **empty** - there is nothing here yet, and that is normal. The panel says what would be here and offers the one action that makes it appear.",
|
|
865
883
|
"dependencies": [
|
|
866
884
|
"class-variance-authority",
|
|
867
|
-
"dowel-ui@^0.
|
|
885
|
+
"dowel-ui@^0.26.0"
|
|
868
886
|
],
|
|
869
887
|
"registryDependencies": [],
|
|
870
888
|
"files": [
|
|
@@ -902,7 +920,7 @@
|
|
|
902
920
|
"description": "Every form is the same four parts repeated: a name for the control, the control, sometimes a hint, and sometimes an error. Written by hand each time, they drift - the label loses its `htmlFor`, the hint is a `<div>` no screen reader mentions, the error appears in red and is announced by nothing at all. This is that arrangement, once.",
|
|
903
921
|
"dependencies": [
|
|
904
922
|
"@base-ui/react",
|
|
905
|
-
"dowel-ui@^0.
|
|
923
|
+
"dowel-ui@^0.26.0"
|
|
906
924
|
],
|
|
907
925
|
"registryDependencies": [],
|
|
908
926
|
"files": [
|
|
@@ -920,7 +938,7 @@
|
|
|
920
938
|
"title": "File-drop",
|
|
921
939
|
"description": "A place to put files: drag them onto it, or press it and pick them. It takes files and hands them over - it does not upload them. Where they go, with which credentials, retried how - that is the product's transport, and a primitive that owned it would be wrong for every product whose upload does not look like the one it guessed.",
|
|
922
940
|
"dependencies": [
|
|
923
|
-
"dowel-ui@^0.
|
|
941
|
+
"dowel-ui@^0.26.0"
|
|
924
942
|
],
|
|
925
943
|
"registryDependencies": [],
|
|
926
944
|
"files": [
|
|
@@ -932,13 +950,34 @@
|
|
|
932
950
|
}
|
|
933
951
|
]
|
|
934
952
|
},
|
|
953
|
+
{
|
|
954
|
+
"name": "filter-popover",
|
|
955
|
+
"type": "registry:ui",
|
|
956
|
+
"title": "Filter-popover",
|
|
957
|
+
"description": "A text box, a handful of checkboxes - with one way to clear it. The shell only: what goes in the panel is the caller's, since a stage is ticked and a title is typed and the popover has no opinion.",
|
|
958
|
+
"dependencies": [
|
|
959
|
+
"dowel-ui@^0.26.0"
|
|
960
|
+
],
|
|
961
|
+
"registryDependencies": [
|
|
962
|
+
"https://lacodda.github.io/dowel/r/button.json",
|
|
963
|
+
"https://lacodda.github.io/dowel/r/popover.json"
|
|
964
|
+
],
|
|
965
|
+
"files": [
|
|
966
|
+
{
|
|
967
|
+
"path": "ui/filter-popover.tsx",
|
|
968
|
+
"target": "@ui/filter-popover.tsx",
|
|
969
|
+
"type": "registry:ui",
|
|
970
|
+
"content": "import type { ReactNode } from 'react'\nimport { cn } from 'dowel-ui'\nimport { Button } from './button'\nimport { Popover, PopoverPopup, PopoverTitle, PopoverTrigger } from './popover'\n\n/*\n * A funnel in a column header that opens a small panel for narrowing by that column.\n *\n * A text box, a handful of checkboxes - with one way to clear it. The shell\n * only: what goes in the panel is the caller's, since a stage is ticked and\n * a title is typed and the popover has no opinion.\n *\n * The funnel is drawn filled while the column's filter holds something, and\n * that is the whole of the state it shows. A column with a filter on it has\n * to say so from the header, or a table narrowed by a funnel opened last\n * week looks like a table with fewer rows in it. Whether the funnel is\n * visible at rest or only on hover is left to the caller's classes: a\n * header with five funnels always showing is a header nobody can read, but\n * hiding the active one would hide the one thing that matters, so the\n * `data-active` attribute is there for a rule to key off.\n *\n * \"Clear\" is inside the panel rather than a second control beside the\n * funnel, because unticking three boxes one by one is the failure this\n * exists to prevent, and a panel is the place a person is already looking.\n */\n\nexport interface FilterPopoverProps {\n /** The heading inside the panel: the column's name. */\n title: string\n /** What the funnel says to assistive technology and on hover. */\n label: string\n /** Whether the column's filter holds anything; fills the funnel. */\n active: boolean\n clearLabel: string\n onClear: () => void\n /** The controls: a text box, checkboxes, whatever narrows this column. */\n children: ReactNode\n /** On the trigger, for showing it on hover or always. */\n className?: string\n align?: 'start' | 'center' | 'end'\n}\n\nexport function FilterPopover({\n title,\n label,\n active,\n clearLabel,\n onClear,\n children,\n className,\n align,\n}: FilterPopoverProps) {\n return (\n <Popover>\n <PopoverTrigger\n aria-label={label}\n title={label}\n data-active={active ? '' : undefined}\n className={cn(\n 'inline-flex size-5 shrink-0 cursor-pointer items-center justify-center rounded-sm',\n 'text-faint transition-colors hover:bg-soft hover:text-text',\n 'focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-accent',\n 'data-[active]:text-accent data-[popup-open]:text-text',\n className,\n )}\n >\n <svg\n viewBox=\"0 0 16 16\"\n className={cn('size-3', active && 'fill-current')}\n fill=\"none\"\n stroke=\"currentColor\"\n strokeWidth=\"1.4\"\n strokeLinejoin=\"round\"\n aria-hidden\n >\n <path d=\"M2 3h12l-4.5 5.5V13l-3-1.5V8.5z\" />\n </svg>\n </PopoverTrigger>\n <PopoverPopup size=\"sm\" align={align ?? 'start'} arrow={false} className=\"p-3\">\n <div className=\"flex items-center gap-2\">\n {/* Sentence case rather than the header's uppercase: the panel is\n read, the header is scanned. */}\n <PopoverTitle className=\"normal-case tracking-normal\">{title}</PopoverTitle>\n <Button size=\"sm\" variant=\"ghost\" className=\"ml-auto\" disabled={!active} onClick={onClear}>\n {clearLabel}\n </Button>\n </div>\n <div className=\"mt-2 flex flex-col gap-1.5 text-sm normal-case tracking-normal\">{children}</div>\n </PopoverPopup>\n </Popover>\n )\n}\n"
|
|
971
|
+
}
|
|
972
|
+
]
|
|
973
|
+
},
|
|
935
974
|
{
|
|
936
975
|
"name": "input",
|
|
937
976
|
"type": "registry:ui",
|
|
938
977
|
"title": "Input",
|
|
939
978
|
"description": "A single-line field. It is a plain `<input>` with the line's clothes on, so everything a browser gives an input for free - autofill, spellcheck, the right keyboard on a phone, `type=\"email\"` validation - still works.",
|
|
940
979
|
"dependencies": [
|
|
941
|
-
"dowel-ui@^0.
|
|
980
|
+
"dowel-ui@^0.26.0"
|
|
942
981
|
],
|
|
943
982
|
"registryDependencies": [],
|
|
944
983
|
"files": [
|
|
@@ -972,7 +1011,7 @@
|
|
|
972
1011
|
"title": "Json-viewer",
|
|
973
1012
|
"description": "What a product reaches for when it has to show a response, a settings file, a webhook payload - data the reader needs to understand, not edit. The alternative it replaces is `JSON.stringify(value, null, 2)` inside a `<pre>`, which is fine for twenty lines and useless for two hundred: nothing folds, nothing is findable, and the shape of the document is somewhere inside the indentation.",
|
|
974
1013
|
"dependencies": [
|
|
975
|
-
"dowel-ui@^0.
|
|
1014
|
+
"dowel-ui@^0.26.0"
|
|
976
1015
|
],
|
|
977
1016
|
"registryDependencies": [
|
|
978
1017
|
"https://lacodda.github.io/dowel/r/json-rows.json"
|
|
@@ -992,7 +1031,7 @@
|
|
|
992
1031
|
"title": "Kbd",
|
|
993
1032
|
"description": "A key, as printed in a menu or a hint: `Ctrl` `K`. It is a `<kbd>` element because that is what the element is for - a screen reader announces it as keyboard input rather than reading a stray capital letter.",
|
|
994
1033
|
"dependencies": [
|
|
995
|
-
"dowel-ui@^0.
|
|
1034
|
+
"dowel-ui@^0.26.0"
|
|
996
1035
|
],
|
|
997
1036
|
"registryDependencies": [],
|
|
998
1037
|
"files": [
|
|
@@ -1011,7 +1050,7 @@
|
|
|
1011
1050
|
"description": "The shape every product builds out of two `<div>`s in a flex row, and the reason it is worth having once: it is a `<dl>`, and the pairing is what a screen reader announces. Two divs read as four unrelated pieces of text - \"Created\", \"2 hours ago\", \"Owner\", \"Ines\" - and nothing says which value belongs to which name. The right element says it for free.",
|
|
1012
1051
|
"dependencies": [
|
|
1013
1052
|
"class-variance-authority",
|
|
1014
|
-
"dowel-ui@^0.
|
|
1053
|
+
"dowel-ui@^0.26.0"
|
|
1015
1054
|
],
|
|
1016
1055
|
"registryDependencies": [],
|
|
1017
1056
|
"files": [
|
|
@@ -1030,7 +1069,7 @@
|
|
|
1030
1069
|
"description": "The distinction against its neighbours is the data's, not the drawing's. A column says each period is its own sum - hours worked in a week, and there is no Wednesday-afternoon figure between two weeks. A line says the value existed the whole time and was sampled: an account balance, a price, a temperature. Drawing a sum as a line claims readings nobody took; drawing a level as columns throws away the thing being watched.",
|
|
1031
1070
|
"dependencies": [
|
|
1032
1071
|
"class-variance-authority",
|
|
1033
|
-
"dowel-ui@^0.
|
|
1072
|
+
"dowel-ui@^0.26.0"
|
|
1034
1073
|
],
|
|
1035
1074
|
"registryDependencies": [
|
|
1036
1075
|
"https://lacodda.github.io/dowel/r/line-scale.json"
|
|
@@ -1060,6 +1099,24 @@
|
|
|
1060
1099
|
}
|
|
1061
1100
|
]
|
|
1062
1101
|
},
|
|
1102
|
+
{
|
|
1103
|
+
"name": "marked-text",
|
|
1104
|
+
"type": "registry:ui",
|
|
1105
|
+
"title": "Marked-text",
|
|
1106
|
+
"description": "A textarea cannot colour a word. The way round it is older than React: draw the same text twice, once as marked-up HTML underneath and once as the textarea on top with its own text transparent, so the caret and the selection are the browser's and the colours are ours. The two have to agree on every metric - font, size, line height, padding, wrapping - or the marks slide off the words they mark. So both take ONE class list, given by the caller, and the textarea adds only what makes it invisible.",
|
|
1107
|
+
"dependencies": [
|
|
1108
|
+
"dowel-ui@^0.26.0"
|
|
1109
|
+
],
|
|
1110
|
+
"registryDependencies": [],
|
|
1111
|
+
"files": [
|
|
1112
|
+
{
|
|
1113
|
+
"path": "ui/marked-text.tsx",
|
|
1114
|
+
"target": "@ui/marked-text.tsx",
|
|
1115
|
+
"type": "registry:ui",
|
|
1116
|
+
"content": "import {\n useCallback,\n useMemo,\n type ChangeEvent,\n type CSSProperties,\n type ReactNode,\n type Ref,\n type TextareaHTMLAttributes,\n} from 'react'\nimport { cn } from 'dowel-ui'\n\n/*\n * Text with marks on it - read, or typed into.\n *\n * A textarea cannot colour a word. The way round it is older than React: draw\n * the same text twice, once as marked-up HTML underneath and once as the\n * textarea on top with its own text transparent, so the caret and the\n * selection are the browser's and the colours are ours. The two have to agree\n * on every metric - font, size, line height, padding, wrapping - or the marks\n * slide off the words they mark. So both take ONE class list, given by the\n * caller, and the textarea adds only what makes it invisible.\n *\n * The mirror is also what sizes the box. It sits in the flow and the textarea\n * is stretched over it, which means the field grows with its text without a\n * `scrollHeight` measurement and never scrolls inside itself - the box around\n * it scrolls, and the marks scroll with the words. A trailing space on the\n * last line keeps a final newline from being a line the mirror forgot.\n *\n * Two kinds of mark: a span of characters (a repeated word, a misspelling)\n * and a whole line (a line that is new since the other version). Lines are\n * drawn as blocks rather than separated by newlines so a line mark can paint\n * the full width; a block per line wraps exactly as the textarea's line does,\n * since it is the same text in the same width with the same font.\n */\n\n/** A span of characters, as offsets into the whole text. */\nexport interface Mark {\n start: number\n end: number\n className?: string\n style?: CSSProperties\n}\n\n/** A whole line. */\nexport interface LineMark {\n /** Zero-based line index. */\n line: number\n className?: string\n}\n\nexport interface MarkedLinesProps {\n text: string\n marks?: readonly Mark[]\n lineMarks?: readonly LineMark[]\n /** Transparent text: the textarea above shows the letters. */\n ghost?: boolean\n}\n\n/** One line cut into runs, each run under at most one mark. */\nfunction runs(line: string, offset: number, marks: readonly Mark[]): ReactNode[] {\n const out: ReactNode[] = []\n let at = 0\n for (const mark of marks) {\n const start = Math.max(mark.start - offset, 0)\n const end = Math.min(mark.end - offset, line.length)\n if (end <= at || start >= line.length) continue\n if (start > at) out.push(line.slice(at, start))\n out.push(\n <mark\n key={offset + start}\n // `<mark>` for the semantics; the browser's yellow is dropped so the\n // caller's class is the colour, in whichever tone the mark means.\n className={cn('rounded-sm bg-transparent text-inherit', mark.className)}\n style={mark.style}\n >\n {line.slice(start, end)}\n </mark>,\n )\n at = end\n }\n if (at < line.length) out.push(line.slice(at))\n return out\n}\n\n/** The marked-up text, line by line. The layer both the readable and the\n * typeable form are made of. */\nexport function MarkedLines({ text, marks = [], lineMarks = [], ghost = false }: MarkedLinesProps) {\n // Each line with where it starts in the text, so a mark given as an offset\n // into the whole can be cut to the line it falls on.\n const lines = useMemo(() => {\n const out: { line: string; start: number }[] = []\n let offset = 0\n for (const line of text.split('\\n')) {\n out.push({ line, start: offset })\n offset += line.length + 1\n }\n return out\n }, [text])\n const byLine = useMemo(() => {\n const map = new Map<number, string | undefined>()\n for (const mark of lineMarks) map.set(mark.line, mark.className)\n return map\n }, [lineMarks])\n\n return (\n <>\n {lines.map(({ line, start }, index) => {\n // Only the marks that touch this line, in order; a text has few marks\n // and few lines, so a filter per line is cheaper than an index.\n const own = marks.filter((mark) => mark.end > start && mark.start < start + line.length)\n return (\n <div\n key={index}\n data-line={index}\n className={cn(\n 'min-h-[1lh] whitespace-pre-wrap [overflow-wrap:anywhere]',\n ghost && 'text-transparent',\n byLine.get(index),\n )}\n >\n {/* A blank line still needs its height, and a trailing line needs\n a character to exist at all. */}\n {line === '' ? ' ' : runs(line, start, own)}\n {index === lines.length - 1 && ' '}\n </div>\n )\n })}\n </>\n )\n}\n\nexport interface MarkedTextProps extends MarkedLinesProps {\n className?: string\n}\n\n/** Marked text to read. */\nexport function MarkedText({ className, ...lines }: MarkedTextProps) {\n return (\n <div className={cn('select-text', className)}>\n <MarkedLines {...lines} />\n </div>\n )\n}\n\nexport interface MarkedTextareaProps\n extends Omit<TextareaHTMLAttributes<HTMLTextAreaElement>, 'value' | 'onChange' | 'className'> {\n ref?: Ref<HTMLTextAreaElement>\n value: string\n /** The new value, not the event: the event is the textarea's business. */\n onChange: (value: string) => void\n marks?: readonly Mark[]\n lineMarks?: readonly LineMark[]\n /** The metrics both layers share: font, size, line height, padding. */\n className?: string\n}\n\n/** Marked text to type into. */\nexport function MarkedTextarea({\n value,\n onChange,\n marks,\n lineMarks,\n className,\n ref,\n ...props\n}: MarkedTextareaProps) {\n const change = useCallback(\n (event: ChangeEvent<HTMLTextAreaElement>) => onChange(event.target.value),\n [onChange],\n )\n\n return (\n <div className=\"relative\">\n <div aria-hidden data-mirror className={cn('pointer-events-none', className)}>\n <MarkedLines text={value} marks={marks} lineMarks={lineMarks} ghost />\n </div>\n <textarea\n ref={ref}\n value={value}\n onChange={change}\n spellCheck={false}\n className={cn(\n // The same metrics, and nothing that would draw: the letters, the\n // caret and the selection are what this layer is for.\n 'absolute inset-0 h-full w-full resize-none overflow-hidden text-text outline-none',\n '[overflow-wrap:anywhere] whitespace-pre-wrap',\n className,\n // After the caller's classes, on purpose. A background in the\n // metrics is meant for the box and lands on both layers; on this\n // one it would paint over every mark. The mirror keeps it, the\n // field never does.\n 'bg-transparent',\n )}\n {...props}\n />\n </div>\n )\n}\n"
|
|
1117
|
+
}
|
|
1118
|
+
]
|
|
1119
|
+
},
|
|
1063
1120
|
{
|
|
1064
1121
|
"name": "menu",
|
|
1065
1122
|
"type": "registry:ui",
|
|
@@ -1068,7 +1125,7 @@
|
|
|
1068
1125
|
"dependencies": [
|
|
1069
1126
|
"@base-ui/react",
|
|
1070
1127
|
"class-variance-authority",
|
|
1071
|
-
"dowel-ui@^0.
|
|
1128
|
+
"dowel-ui@^0.26.0"
|
|
1072
1129
|
],
|
|
1073
1130
|
"registryDependencies": [],
|
|
1074
1131
|
"files": [
|
|
@@ -1080,6 +1137,27 @@
|
|
|
1080
1137
|
}
|
|
1081
1138
|
]
|
|
1082
1139
|
},
|
|
1140
|
+
{
|
|
1141
|
+
"name": "notification-bell",
|
|
1142
|
+
"type": "registry:ui",
|
|
1143
|
+
"title": "Notification-bell",
|
|
1144
|
+
"description": "A bell that is always lit is a bell nobody reads, so the count is the product's decision and this draws it: nothing at zero, the number past that, `9+` past nine. Pressing it does not leave the screen - the last few entries open under it and the whole history is one more click, which is the shape every product converged on once the first one tried a page.",
|
|
1145
|
+
"dependencies": [
|
|
1146
|
+
"dowel-ui@^0.26.0"
|
|
1147
|
+
],
|
|
1148
|
+
"registryDependencies": [
|
|
1149
|
+
"https://lacodda.github.io/dowel/r/button.json",
|
|
1150
|
+
"https://lacodda.github.io/dowel/r/popover.json"
|
|
1151
|
+
],
|
|
1152
|
+
"files": [
|
|
1153
|
+
{
|
|
1154
|
+
"path": "ui/notification-bell.tsx",
|
|
1155
|
+
"target": "@ui/notification-bell.tsx",
|
|
1156
|
+
"type": "registry:ui",
|
|
1157
|
+
"content": "import { Children, useState, type ReactNode } from 'react'\nimport { cn } from 'dowel-ui'\nimport { Button } from './button'\nimport { Popover, PopoverPopup, PopoverTrigger } from './popover'\n\n/*\n * The bell in the title bar, lit by what asks to be looked at.\n *\n * A bell that is always lit is a bell nobody reads, so the count is the\n * product's decision and this draws it: nothing at zero, the number past\n * that, `9+` past nine. Pressing it does not leave the screen - the last few\n * entries open under it and the whole history is one more click, which is\n * the shape every product converged on once the first one tried a page.\n *\n * What is in the list is the product's: the rows are `children`, because a\n * journal entry, a failed upload and a comment are three different lines and\n * one component cannot draw them. What is shared is the frame around them -\n * the trigger with its badge, the heading with the button that clears the\n * count, the empty line, and the footer that leads to everything.\n *\n * Built on Popover and Button rather than on a `<div>` of its own, so the\n * panel opens, positions and closes the way every other panel does.\n */\n\nexport interface NotificationBellProps {\n /** How many things ask to be looked at. Nothing is drawn at zero. */\n count: number\n /** What the bell is called, for a screen reader and the tooltip. */\n label: string\n /** The heading of the panel. */\n title: string\n /** Open, controlled. Left out, the bell manages itself. */\n open?: boolean\n onOpenChange?: (open: boolean) => void\n /** The button that clears the count, shown only while there is one. */\n markAllLabel?: string\n onMarkAll?: () => void\n /** The footer button, which closes the panel and hands over. */\n seeAllLabel?: string\n onSeeAll?: () => void\n /** What the list says when there are no rows. */\n emptyLabel: string\n /** The rows. */\n children?: ReactNode\n /** While the product is clearing the count: the button waits. */\n busy?: boolean\n className?: string\n}\n\nexport function NotificationBell({\n count,\n label,\n title,\n open,\n onOpenChange,\n markAllLabel,\n onMarkAll,\n seeAllLabel,\n onSeeAll,\n emptyLabel,\n children,\n busy = false,\n className,\n}: NotificationBellProps) {\n const [own, setOwn] = useState(false)\n const isOpen = open ?? own\n const setOpen = (next: boolean) => {\n setOwn(next)\n onOpenChange?.(next)\n }\n const empty = Children.count(children) === 0\n\n return (\n <Popover open={isOpen} onOpenChange={setOpen}>\n <PopoverTrigger\n render={\n <Button variant=\"icon\" size=\"icon-sm\" className={cn('relative', className)} title={label} aria-label={label} />\n }\n >\n <Bell />\n {count > 0 ? (\n <span\n data-badge\n className={cn(\n 'absolute -right-0.5 -top-0.5 min-w-3.5 rounded-full bg-warn px-1',\n 'text-center font-mono text-[9px] leading-[14px] text-on-warn',\n )}\n >\n {count > 9 ? '9+' : count}\n </span>\n ) : null}\n </PopoverTrigger>\n <PopoverPopup align=\"end\" arrow={false} size=\"lg\" className=\"p-0\">\n <header className=\"flex items-center gap-2 border-b border-line px-3 py-2\">\n <h3 className=\"text-sm font-semibold\">{title}</h3>\n {count > 0 && markAllLabel ? (\n <Button variant=\"ghost\" size=\"sm\" className=\"ml-auto\" disabled={busy} onClick={onMarkAll}>\n {markAllLabel}\n </Button>\n ) : null}\n </header>\n <div className=\"max-h-80 overflow-y-auto px-3\">\n {empty ? <p className=\"py-3 text-sm text-dim\">{emptyLabel}</p> : children}\n </div>\n {seeAllLabel ? (\n <footer className=\"border-t border-line p-2\">\n <Button\n variant=\"ghost\"\n size=\"sm\"\n className=\"w-full\"\n onClick={() => {\n setOpen(false)\n onSeeAll?.()\n }}\n >\n {seeAllLabel}\n </Button>\n </footer>\n ) : null}\n </PopoverPopup>\n </Popover>\n )\n}\n\nfunction Bell() {\n return (\n <svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.4\" aria-hidden>\n <path d=\"M4 11V7.5a4 4 0 0 1 8 0V11l1 1.5H3z\" strokeLinejoin=\"round\" />\n <path d=\"M6.5 13.5a1.5 1.5 0 0 0 3 0\" strokeLinecap=\"round\" />\n </svg>\n )\n}\n"
|
|
1158
|
+
}
|
|
1159
|
+
]
|
|
1160
|
+
},
|
|
1083
1161
|
{
|
|
1084
1162
|
"name": "number-field",
|
|
1085
1163
|
"type": "registry:ui",
|
|
@@ -1087,7 +1165,7 @@
|
|
|
1087
1165
|
"description": "A number typed into a text input is a string that happens to look like a number, and every product then writes the same four fixes: strip the letters, clamp to a range, round to a step, and decide what an empty box means. This is those four, once, plus the stepper - because a value with a small range is faster nudged than typed.",
|
|
1088
1166
|
"dependencies": [
|
|
1089
1167
|
"@base-ui/react",
|
|
1090
|
-
"dowel-ui@^0.
|
|
1168
|
+
"dowel-ui@^0.26.0"
|
|
1091
1169
|
],
|
|
1092
1170
|
"registryDependencies": [
|
|
1093
1171
|
"https://lacodda.github.io/dowel/r/input.json"
|
|
@@ -1107,7 +1185,7 @@
|
|
|
1107
1185
|
"title": "Number-format",
|
|
1108
1186
|
"description": "Two things, and the second is the reason this is a component rather than a call to `toLocaleString` at each site.",
|
|
1109
1187
|
"dependencies": [
|
|
1110
|
-
"dowel-ui@^0.
|
|
1188
|
+
"dowel-ui@^0.26.0"
|
|
1111
1189
|
],
|
|
1112
1190
|
"registryDependencies": [],
|
|
1113
1191
|
"files": [
|
|
@@ -1125,7 +1203,7 @@
|
|
|
1125
1203
|
"title": "Page-size",
|
|
1126
1204
|
"description": "Its own file rather than a part of `Pagination`, because the two are needed apart often enough: a list that scrolls for ever wants \"how many to load at a time\" and no page buttons, and a table with a fixed page size wants the buttons and no choice. Together they were also over the size gate, which asked the right question.",
|
|
1127
1205
|
"dependencies": [
|
|
1128
|
-
"dowel-ui@^0.
|
|
1206
|
+
"dowel-ui@^0.26.0"
|
|
1129
1207
|
],
|
|
1130
1208
|
"registryDependencies": [
|
|
1131
1209
|
"https://lacodda.github.io/dowel/r/select.json"
|
|
@@ -1145,7 +1223,7 @@
|
|
|
1145
1223
|
"title": "Pagination",
|
|
1146
1224
|
"description": "The arithmetic is exported separately from the component for the same reason `table-sort` is a file of its own: a product that pages on the server needs the page numbers and not the buttons, and computing them a second time in a different place is how the two disagree about where the last page ends.",
|
|
1147
1225
|
"dependencies": [
|
|
1148
|
-
"dowel-ui@^0.
|
|
1226
|
+
"dowel-ui@^0.26.0"
|
|
1149
1227
|
],
|
|
1150
1228
|
"registryDependencies": [
|
|
1151
1229
|
"https://lacodda.github.io/dowel/r/button.json"
|
|
@@ -1166,7 +1244,7 @@
|
|
|
1166
1244
|
"description": "The raised surface everything else sits on. It is the one place a screen gets its structure from, so it stays deliberately plain: a ground, a hairline, a corner.",
|
|
1167
1245
|
"dependencies": [
|
|
1168
1246
|
"class-variance-authority",
|
|
1169
|
-
"dowel-ui@^0.
|
|
1247
|
+
"dowel-ui@^0.26.0"
|
|
1170
1248
|
],
|
|
1171
1249
|
"registryDependencies": [],
|
|
1172
1250
|
"files": [
|
|
@@ -1184,7 +1262,7 @@
|
|
|
1184
1262
|
"title": "Password-field",
|
|
1185
1263
|
"description": "The reveal is the whole component, and it is not a convenience. A masked field is the only one in a form where a typo cannot be seen, so people either paste (fine) or type slowly and get it wrong anyway; the toggle is what turns an unverifiable field into a checkable one, and it is why long passphrases became usable at all.",
|
|
1186
1264
|
"dependencies": [
|
|
1187
|
-
"dowel-ui@^0.
|
|
1265
|
+
"dowel-ui@^0.26.0"
|
|
1188
1266
|
],
|
|
1189
1267
|
"registryDependencies": [
|
|
1190
1268
|
"https://lacodda.github.io/dowel/r/input.json"
|
|
@@ -1206,7 +1284,7 @@
|
|
|
1206
1284
|
"dependencies": [
|
|
1207
1285
|
"@base-ui/react",
|
|
1208
1286
|
"class-variance-authority",
|
|
1209
|
-
"dowel-ui@^0.
|
|
1287
|
+
"dowel-ui@^0.26.0"
|
|
1210
1288
|
],
|
|
1211
1289
|
"registryDependencies": [],
|
|
1212
1290
|
"files": [
|
|
@@ -1226,7 +1304,7 @@
|
|
|
1226
1304
|
"dependencies": [
|
|
1227
1305
|
"@base-ui/react",
|
|
1228
1306
|
"class-variance-authority",
|
|
1229
|
-
"dowel-ui@^0.
|
|
1307
|
+
"dowel-ui@^0.26.0"
|
|
1230
1308
|
],
|
|
1231
1309
|
"registryDependencies": [],
|
|
1232
1310
|
"files": [
|
|
@@ -1246,7 +1324,7 @@
|
|
|
1246
1324
|
"dependencies": [
|
|
1247
1325
|
"@base-ui/react",
|
|
1248
1326
|
"class-variance-authority",
|
|
1249
|
-
"dowel-ui@^0.
|
|
1327
|
+
"dowel-ui@^0.26.0"
|
|
1250
1328
|
],
|
|
1251
1329
|
"registryDependencies": [],
|
|
1252
1330
|
"files": [
|
|
@@ -1285,7 +1363,7 @@
|
|
|
1285
1363
|
"dependencies": [
|
|
1286
1364
|
"@base-ui/react",
|
|
1287
1365
|
"class-variance-authority",
|
|
1288
|
-
"dowel-ui@^0.
|
|
1366
|
+
"dowel-ui@^0.26.0"
|
|
1289
1367
|
],
|
|
1290
1368
|
"registryDependencies": [],
|
|
1291
1369
|
"files": [
|
|
@@ -1303,7 +1381,7 @@
|
|
|
1303
1381
|
"title": "Rating-scale",
|
|
1304
1382
|
"description": "Generalised from kilna, where it is how a work is scored on each of its axes. The shape is a row of marks rather than stars: stars carry a meaning of their own - a review, a public verdict - and this is as often \"how hard was this\" or \"how finished is it\" as it is \"how good\".",
|
|
1305
1383
|
"dependencies": [
|
|
1306
|
-
"dowel-ui@^0.
|
|
1384
|
+
"dowel-ui@^0.26.0"
|
|
1307
1385
|
],
|
|
1308
1386
|
"registryDependencies": [],
|
|
1309
1387
|
"files": [
|
|
@@ -1321,7 +1399,7 @@
|
|
|
1321
1399
|
"title": "Relative-time",
|
|
1322
1400
|
"description": "The relative-time primitive.",
|
|
1323
1401
|
"dependencies": [
|
|
1324
|
-
"dowel-ui@^0.
|
|
1402
|
+
"dowel-ui@^0.26.0"
|
|
1325
1403
|
],
|
|
1326
1404
|
"registryDependencies": [],
|
|
1327
1405
|
"files": [
|
|
@@ -1333,13 +1411,31 @@
|
|
|
1333
1411
|
}
|
|
1334
1412
|
]
|
|
1335
1413
|
},
|
|
1414
|
+
{
|
|
1415
|
+
"name": "reorderable-list",
|
|
1416
|
+
"type": "registry:ui",
|
|
1417
|
+
"title": "Reorderable-list",
|
|
1418
|
+
"description": "The columns in a column picker, the stops of a dial, the roles of a profile. A hook and a grip rather than a list component: the rows are already something else's - a menu's items, a form's fields - and a component wrapping them would have to reproduce whatever that something else does.",
|
|
1419
|
+
"dependencies": [
|
|
1420
|
+
"dowel-ui@^0.26.0"
|
|
1421
|
+
],
|
|
1422
|
+
"registryDependencies": [],
|
|
1423
|
+
"files": [
|
|
1424
|
+
{
|
|
1425
|
+
"path": "ui/reorderable-list.tsx",
|
|
1426
|
+
"target": "@ui/reorderable-list.tsx",
|
|
1427
|
+
"type": "registry:ui",
|
|
1428
|
+
"content": "import {\n useCallback,\n useEffect,\n useRef,\n useState,\n type HTMLAttributes,\n type PointerEvent as ReactPointerEvent,\n} from 'react'\nimport { cn } from 'dowel-ui'\n\n/*\n * Drag a row of a vertical list up or down to put it somewhere else.\n *\n * The columns in a column picker, the stops of a dial, the roles of a\n * profile. A hook and a grip rather than a list component: the rows are\n * already something else's - a menu's items, a form's fields - and a\n * component wrapping them would have to reproduce whatever that something\n * else does.\n *\n * Pointer events, not HTML5 drag-and-drop, for the reason given at\n * ColumnResizeHandle: a desktop shell that takes file drops never lets a\n * `dragstart` reach the page. The grip takes pointer capture on pointerdown,\n * so the rows underneath never see the drag and a menu's own highlighting\n * does not flicker down the list as the pointer crosses it.\n *\n * Nothing moves until the pointer is let go. Live reordering looks better\n * for a second and costs a write per crossed row - to a profile, that is a\n * request per row - and the row being dragged has already been picked up,\n * so the reader is watching the line that says where it lands. The hook\n * reports that line as `slot`, and the caller draws it.\n *\n * The keyboard's way is Alt with an arrow on the focused row. Plain arrows\n * are how a list is walked, and taking them for moving would leave no way\n * to walk it; the modifier is the one screen readers and editors already\n * use for \"move this line\".\n */\n\nexport interface ReorderOptions<K extends string> {\n /** The ids in their current order. Every row carries its id in\n * `data-reorder-id`, which `rowProps` sets. */\n order: readonly K[]\n /** Put `id` at index `to` of the resulting list. Called once per drop or\n * per keypress, never during a drag. */\n onMove: (id: K, to: number) => void\n disabled?: boolean\n}\n\nexport interface Reorder<K extends string> {\n /** Spread on the element holding the rows: the rows are found under it.\n * A callback ref rather than a ref object, so that nothing ref-shaped is\n * handed back and the compiler does not take the whole result for one. */\n listProps: { ref: (element: HTMLElement | null) => void }\n /** The row being dragged, or null. */\n dragging: K | null\n /** Where a drop would go, as an insertion point: between the rows at\n * `slot - 1` and `slot`, in the order as it is. Null while nothing is\n * dragged, or while the drop would change nothing. */\n slot: number | null\n /** The insertion point's distance from the top of the list element, in\n * pixels, for drawing the line. */\n slotOffset: number | null\n /** Spread on the grip: the pointer path. */\n gripProps: (id: K) => (HTMLAttributes<HTMLElement>)\n /** Spread on the row: the id and the keyboard path. The `ref` is a\n * callback that returns its cleanup, as React 19 allows; a host that merges\n * refs and drops the cleanup leaves a listener on a node that is gone,\n * which is harmless. */\n rowProps: (id: K) => {\n 'data-reorder-id': K\n ref: (row: HTMLElement | null) => void | (() => void)\n }\n}\n\nexport function useReorder<K extends string>({ order, onMove, disabled }: ReorderOptions<K>): Reorder<K> {\n const listRef = useRef<HTMLElement | null>(null)\n const [dragging, setDragging] = useState<K | null>(null)\n // The slot and where to draw it, settled together in the move handler: the\n // boxes it is read from are refs, and refs are not for reading in render.\n const [drop, setDrop] = useState<{ slot: number; offset: number } | null>(null)\n // The rows' boxes as they were when the drag began; nothing moves during\n // it, so reading them once is reading them right.\n const boxes = useRef<{ top: number; bottom: number }[]>([])\n const listTop = useRef(0)\n\n // The slot under a pointer: how many rows have their middle above it.\n const slotAt = (y: number): number => {\n let count = 0\n for (const box of boxes.current) if ((box.top + box.bottom) / 2 < y) count += 1\n return count\n }\n\n // A slot is an insertion point in the list as drawn; the move wants the\n // index in the list as it will be, with the row gone from where it was.\n const destination = (from: number, at: number): number => (at > from ? at - 1 : at)\n\n const begin = (id: K, event: ReactPointerEvent<HTMLElement>) => {\n if (disabled || event.button !== 0) return\n const list = listRef.current\n if (list === null) return\n event.preventDefault()\n event.stopPropagation()\n\n listTop.current = list.getBoundingClientRect().top\n // By attribute rather than by selector, so an id needs no escaping.\n const rows = [...list.querySelectorAll<HTMLElement>('[data-reorder-id]')]\n boxes.current = order.map((rowId) => {\n const box = rows.find((row) => row.getAttribute('data-reorder-id') === rowId)?.getBoundingClientRect()\n return box ? { top: box.top, bottom: box.bottom } : { top: 0, bottom: 0 }\n })\n event.currentTarget.setPointerCapture(event.pointerId)\n setDragging(id)\n setDrop(null)\n }\n\n // The insertion point's distance from the top of the list: the top of the\n // row it goes before, or the bottom of the last row.\n const offsetOf = (at: number): number => {\n const rows = boxes.current\n const edge = at < rows.length ? rows[at]?.top : rows[rows.length - 1]?.bottom\n return (edge ?? 0) - listTop.current\n }\n\n const move = (id: K, event: ReactPointerEvent<HTMLElement>) => {\n if (dragging !== id) return\n const from = order.indexOf(id)\n const at = slotAt(event.clientY)\n // Dropping a row back where it is - the slot just above or just below\n // itself - is no move, and drawing a line there would promise one.\n setDrop(destination(from, at) === from ? null : { slot: at, offset: offsetOf(at) })\n }\n\n const end = (id: K, event: ReactPointerEvent<HTMLElement>) => {\n if (dragging !== id) return\n if (event.currentTarget.hasPointerCapture(event.pointerId)) {\n event.currentTarget.releasePointerCapture(event.pointerId)\n }\n const from = order.indexOf(id)\n const at = slotAt(event.clientY)\n const to = destination(from, at)\n setDragging(null)\n setDrop(null)\n if (event.type !== 'pointercancel' && to !== from) onMove(id, to)\n }\n\n // Rebuilt every render on purpose: they close over the drag state and the\n // order, and memoising them would mean listing both and getting one wrong.\n const gripProps = (id: K): HTMLAttributes<HTMLElement> => ({\n onPointerDown: (event) => begin(id, event),\n onPointerMove: (event) => move(id, event),\n onPointerUp: (event) => end(id, event),\n onPointerCancel: (event) => end(id, event),\n // The click the drop leaves behind must not reach the row: in a menu it\n // would toggle the item that was only meant to be moved.\n onClick: (event) => event.stopPropagation(),\n })\n\n // The keyboard path is a native listener on the row, not a React one. A\n // menu popup handles the arrow keys itself and stops them on the way up,\n // so a React `onKeyDown` on the item - dispatched from the root, above the\n // popup - never hears them; a listener on the row itself fires at the\n // target first, whatever the ancestors do afterwards. The listener reads\n // the row's id and the latest order off refs rather than closing over\n // them, so one stable callback serves every row and nothing is re-bound\n // on each render.\n const latest = useRef({ order, onMove, disabled })\n useEffect(() => {\n latest.current = { order, onMove, disabled }\n })\n\n const listen = useCallback((row: HTMLElement | null) => {\n if (row === null) return\n const onKeyDown = (event: KeyboardEvent) => {\n const { order: current, onMove: put, disabled: off } = latest.current\n if (off || !event.altKey) return\n const step = event.key === 'ArrowUp' ? -1 : event.key === 'ArrowDown' ? 1 : 0\n if (step === 0) return\n const id = row.getAttribute('data-reorder-id') as K | null\n if (id === null) return\n const to = current.indexOf(id) + step\n if (to < 0 || to >= current.length) return\n // Stopped here so the list's own arrow handling does not also walk the\n // focus off the row that was just moved.\n event.preventDefault()\n event.stopPropagation()\n put(id, to)\n }\n row.addEventListener('keydown', onKeyDown)\n return () => row.removeEventListener('keydown', onKeyDown)\n }, [])\n\n const rowProps = (id: K) => ({\n 'data-reorder-id': id,\n ref: listen,\n })\n\n return {\n listProps: {\n ref: (element) => {\n listRef.current = element\n },\n },\n dragging,\n slot: drop?.slot ?? null,\n slotOffset: drop?.offset ?? null,\n gripProps,\n rowProps,\n }\n}\n\n/** The handle a row is picked up by. Decorative to a screen reader - the\n * keyboard path is on the row itself - so it carries no role and no label. */\nexport function ReorderGrip({ className, ...props }: HTMLAttributes<HTMLElement>) {\n return (\n <span\n aria-hidden\n data-reorder-grip\n className={cn(\n 'inline-flex shrink-0 cursor-grab touch-none select-none text-faint',\n 'active:cursor-grabbing [&_svg]:size-3.5',\n className,\n )}\n {...props}\n >\n <svg viewBox=\"0 0 16 16\" fill=\"currentColor\" aria-hidden>\n <circle cx=\"6\" cy=\"3.5\" r=\"1.2\" />\n <circle cx=\"10\" cy=\"3.5\" r=\"1.2\" />\n <circle cx=\"6\" cy=\"8\" r=\"1.2\" />\n <circle cx=\"10\" cy=\"8\" r=\"1.2\" />\n <circle cx=\"6\" cy=\"12.5\" r=\"1.2\" />\n <circle cx=\"10\" cy=\"12.5\" r=\"1.2\" />\n </svg>\n </span>\n )\n}\n\n/** The line where a dragged row would land. Positioned by the caller from\n * `slotOffset`, inside the element `listProps` is on - which has to be\n * positioned itself. */\nexport function ReorderIndicator({ offset, className }: { offset: number | null; className?: string }) {\n if (offset === null) return null\n return (\n <div\n aria-hidden\n data-reorder-indicator\n className={cn('pointer-events-none absolute inset-x-1 h-0.5 -translate-y-px rounded-full bg-accent', className)}\n style={{ top: offset }}\n />\n )\n}\n"
|
|
1429
|
+
}
|
|
1430
|
+
]
|
|
1431
|
+
},
|
|
1336
1432
|
{
|
|
1337
1433
|
"name": "save-state",
|
|
1338
1434
|
"type": "registry:ui",
|
|
1339
1435
|
"title": "Save-state",
|
|
1340
1436
|
"description": "The quiet line beside a field that saves itself: \"saving…\", then a tick that fades. It exists because a form without a Save button has to say what it did anyway - otherwise the reader is left guessing whether their edit survived, and the usual answer to that guess is to press Ctrl+S at a page that has no such thing.",
|
|
1341
1437
|
"dependencies": [
|
|
1342
|
-
"dowel-ui@^0.
|
|
1438
|
+
"dowel-ui@^0.26.0"
|
|
1343
1439
|
],
|
|
1344
1440
|
"registryDependencies": [
|
|
1345
1441
|
"https://lacodda.github.io/dowel/r/spinner.json"
|
|
@@ -1359,7 +1455,7 @@
|
|
|
1359
1455
|
"title": "Search-field",
|
|
1360
1456
|
"description": "An Input that knows it is a search box, which is three small things the products kept not doing:\n * - a magnifier, so the field is recognisable before it is read; - a way to clear it that is not \"select all and delete\" - and one that a keyboard can reach, which a decorative `<span>` cannot; - the shortcut that focuses it, shown in the field rather than learned.",
|
|
1361
1457
|
"dependencies": [
|
|
1362
|
-
"dowel-ui@^0.
|
|
1458
|
+
"dowel-ui@^0.26.0"
|
|
1363
1459
|
],
|
|
1364
1460
|
"registryDependencies": [
|
|
1365
1461
|
"https://lacodda.github.io/dowel/r/input.json",
|
|
@@ -1375,6 +1471,25 @@
|
|
|
1375
1471
|
}
|
|
1376
1472
|
]
|
|
1377
1473
|
},
|
|
1474
|
+
{
|
|
1475
|
+
"name": "section-nav",
|
|
1476
|
+
"type": "registry:ui",
|
|
1477
|
+
"title": "Section-nav",
|
|
1478
|
+
"description": "A settings screen is the usual case: five or six sections, each its own address so it can be linked to and the back button walks between them, listed down the left with the current one tinted. Every product draws the same column, and every product draws the active row a little differently - which is exactly the drift a shared list exists to stop.",
|
|
1479
|
+
"dependencies": [
|
|
1480
|
+
"@base-ui/react",
|
|
1481
|
+
"dowel-ui@^0.26.0"
|
|
1482
|
+
],
|
|
1483
|
+
"registryDependencies": [],
|
|
1484
|
+
"files": [
|
|
1485
|
+
{
|
|
1486
|
+
"path": "ui/section-nav.tsx",
|
|
1487
|
+
"target": "@ui/section-nav.tsx",
|
|
1488
|
+
"type": "registry:ui",
|
|
1489
|
+
"content": "import { useId, type HTMLAttributes, type ReactNode } from 'react'\nimport { useRender } from '@base-ui/react/use-render'\nimport { cn } from 'dowel-ui'\n\n/*\n * The left-hand list of a screen with sections, and the heading each opens.\n *\n * A settings screen is the usual case: five or six sections, each its own\n * address so it can be linked to and the back button walks between them,\n * listed down the left with the current one tinted. Every product draws the\n * same column, and every product draws the active row a little differently -\n * which is exactly the drift a shared list exists to stop.\n *\n * The rows are the product's links, not the component's. `render` takes the\n * item and answers with the element to draw it as - a router's `NavLink`, an\n * `<a>`, whatever the product navigates with - and the component puts the\n * row's clothes, its icon and its `aria-current` on it. Without `render` a\n * row is a button and `onSelect` says which one was pressed, for a screen\n * whose sections are state rather than routes.\n *\n * `SectionHeading` is the other half: the title and the one-line hint above\n * a section's body, so the column and the page it opens are set in the same\n * type.\n */\n\nexport interface SectionNavItem {\n id: string\n label: ReactNode\n icon?: ReactNode\n}\n\nexport interface SectionNavProps extends Omit<HTMLAttributes<HTMLElement>, 'children' | 'onSelect'> {\n /** What the list is called: the caption above it, and the name a screen\n * reader gives the landmark. */\n label: string\n items: readonly SectionNavItem[]\n /** Which item is the current page. */\n activeId?: string\n /** The element a row is drawn as - `render={(item) => <NavLink to={…} />}`.\n * The row's props are merged onto it, the way Button takes a `render`. */\n render?: (item: SectionNavItem) => useRender.RenderProp\n /** Pressed, whatever the row is drawn as. */\n onSelect?: (id: string) => void\n}\n\nexport function SectionNav({ label, items, activeId, render, onSelect, className, ...props }: SectionNavProps) {\n const captionId = useId()\n return (\n <nav aria-labelledby={captionId} className={cn('flex flex-col gap-0.5', className)} {...props}>\n <h2 id={captionId} className=\"px-2.5 pb-2 text-2xs font-medium uppercase tracking-caption text-faint\">\n {label}\n </h2>\n {items.map((item) => (\n <SectionNavRow\n key={item.id}\n item={item}\n active={item.id === activeId}\n render={render?.(item)}\n onSelect={onSelect}\n />\n ))}\n </nav>\n )\n}\n\nfunction SectionNavRow({\n item,\n active,\n render,\n onSelect,\n}: {\n item: SectionNavItem\n active: boolean\n render?: useRender.RenderProp\n onSelect?: (id: string) => void\n}) {\n return useRender({\n render,\n defaultTagName: 'button',\n props: {\n ...(render === undefined ? { type: 'button' } : {}),\n // Named as the current page for a screen reader, which cannot see that\n // it is the tinted one.\n 'aria-current': active ? 'page' : undefined,\n onClick: () => onSelect?.(item.id),\n className: cn(\n 'flex w-full items-center gap-2.5 rounded-md px-2.5 py-1.5 text-left text-sm text-dim no-underline transition-colors',\n 'hover:bg-soft hover:text-text [&_svg]:size-4 [&_svg]:shrink-0',\n 'focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-accent',\n active && 'bg-accent-soft text-text [&_svg]:text-accent',\n ),\n children: (\n <>\n {item.icon ? <span aria-hidden className=\"contents\">{item.icon}</span> : null}\n {item.label}\n </>\n ),\n },\n })\n}\n\nexport interface SectionHeadingProps extends Omit<HTMLAttributes<HTMLDivElement>, 'title'> {\n title: ReactNode\n description?: ReactNode\n}\n\n/** The title of the open section and the line under it. */\nexport function SectionHeading({ title, description, className, ...props }: SectionHeadingProps) {\n return (\n <div className={cn('mb-4 flex flex-col gap-1', className)} {...props}>\n <h2 className=\"text-base font-semibold text-text\">{title}</h2>\n {description ? <p className=\"text-sm text-dim\">{description}</p> : null}\n </div>\n )\n}\n"
|
|
1490
|
+
}
|
|
1491
|
+
]
|
|
1492
|
+
},
|
|
1378
1493
|
{
|
|
1379
1494
|
"name": "select",
|
|
1380
1495
|
"type": "registry:ui",
|
|
@@ -1383,7 +1498,7 @@
|
|
|
1383
1498
|
"dependencies": [
|
|
1384
1499
|
"@base-ui/react",
|
|
1385
1500
|
"class-variance-authority",
|
|
1386
|
-
"dowel-ui@^0.
|
|
1501
|
+
"dowel-ui@^0.26.0"
|
|
1387
1502
|
],
|
|
1388
1503
|
"registryDependencies": [
|
|
1389
1504
|
"https://lacodda.github.io/dowel/r/input.json"
|
|
@@ -1419,7 +1534,7 @@
|
|
|
1419
1534
|
"title": "Skeleton",
|
|
1420
1535
|
"description": "The rule the component is built on, and the reason it takes a shape rather than filling the space:\n * **A skeleton of the wrong shape is worse than no skeleton.**\n * It promises something the content does not keep, and the promise is paid for in a jump: the page settles, the scrollbar appears, and whatever the reader was about to click has moved. Measured rather than assumed - the line's own calendar showed a list of four short lines where a six-row month grid was about to land, and the skeleton was itself the jump it existed to prevent.",
|
|
1421
1536
|
"dependencies": [
|
|
1422
|
-
"dowel-ui@^0.
|
|
1537
|
+
"dowel-ui@^0.26.0"
|
|
1423
1538
|
],
|
|
1424
1539
|
"registryDependencies": [],
|
|
1425
1540
|
"files": [
|
|
@@ -1438,7 +1553,7 @@
|
|
|
1438
1553
|
"description": "The case for it over a NumberField is that the number does not matter much: a volume, an opacity, a weight in a search filter. Where the exact figure does matter, a slider is a worse field with more pixels - it cannot be typed into, it cannot be pasted into, and it has no state for \"empty\".",
|
|
1439
1554
|
"dependencies": [
|
|
1440
1555
|
"@base-ui/react",
|
|
1441
|
-
"dowel-ui@^0.
|
|
1556
|
+
"dowel-ui@^0.26.0"
|
|
1442
1557
|
],
|
|
1443
1558
|
"registryDependencies": [],
|
|
1444
1559
|
"files": [
|
|
@@ -1457,7 +1572,7 @@
|
|
|
1457
1572
|
"description": "The shape of a history, not a chart of it: no axes, no gridlines, no ticks.",
|
|
1458
1573
|
"dependencies": [
|
|
1459
1574
|
"class-variance-authority",
|
|
1460
|
-
"dowel-ui@^0.
|
|
1575
|
+
"dowel-ui@^0.26.0"
|
|
1461
1576
|
],
|
|
1462
1577
|
"registryDependencies": [],
|
|
1463
1578
|
"files": [
|
|
@@ -1476,7 +1591,7 @@
|
|
|
1476
1591
|
"description": "Something is happening and the answer has not arrived. It carries no text of its own - what is loading is the product's word, not the system's - but it does have to say *something* to a screen reader, or a page that is busy is silently identical to a page that is empty.",
|
|
1477
1592
|
"dependencies": [
|
|
1478
1593
|
"class-variance-authority",
|
|
1479
|
-
"dowel-ui@^0.
|
|
1594
|
+
"dowel-ui@^0.26.0"
|
|
1480
1595
|
],
|
|
1481
1596
|
"registryDependencies": [],
|
|
1482
1597
|
"files": [
|
|
@@ -1488,6 +1603,24 @@
|
|
|
1488
1603
|
}
|
|
1489
1604
|
]
|
|
1490
1605
|
},
|
|
1606
|
+
{
|
|
1607
|
+
"name": "splash",
|
|
1608
|
+
"type": "registry:ui",
|
|
1609
|
+
"title": "Splash",
|
|
1610
|
+
"description": "A desktop product has a second or two between the window appearing and the first screen being ready - a workspace to open, a database to migrate, a plugin to start - and a blank window for that long reads as a crash. So the window shows the product instead: the mark, the name, the promise, the version, and a bar that sweeps until there is something to draw.",
|
|
1611
|
+
"dependencies": [
|
|
1612
|
+
"dowel-ui@^0.26.0"
|
|
1613
|
+
],
|
|
1614
|
+
"registryDependencies": [],
|
|
1615
|
+
"files": [
|
|
1616
|
+
{
|
|
1617
|
+
"path": "ui/splash.tsx",
|
|
1618
|
+
"target": "@ui/splash.tsx",
|
|
1619
|
+
"type": "registry:ui",
|
|
1620
|
+
"content": "import type { HTMLAttributes, ReactNode } from 'react'\nimport { cn } from 'dowel-ui'\n\n/*\n * What the window shows while the application is opening.\n *\n * A desktop product has a second or two between the window appearing and\n * the first screen being ready - a workspace to open, a database to migrate,\n * a plugin to start - and a blank window for that long reads as a crash. So\n * the window shows the product instead: the mark, the name, the promise, the\n * version, and a bar that sweeps until there is something to draw.\n *\n * It is the second half of a pattern, and the first half is not React. The\n * page paints the same picture in inline CSS before the bundle arrives, so\n * that the window is never blank at all; this component takes over from it\n * on the first render, with the same geometry so nothing jumps, and adds the\n * two lines only the application can say - what it is doing, in the person's\n * language, and one thing worth knowing. The static half is in the docs.\n *\n * It is a status region, not a dialog: the reader is told what is happening\n * and cannot act on it. The sweep is a `<style>` of its own rather than a\n * theme keyframe, because a product installs the theme on its first day and\n * this once; and under reduced motion the theme stops every animation dead,\n * which would leave the sweep parked off the end of its track - so the bar\n * is drawn full and still instead.\n */\n\nexport interface SplashProps extends Omit<HTMLAttributes<HTMLDivElement>, 'children'> {\n /** The product's mark, drawn at 56px. */\n mark?: ReactNode\n /** The product's name. */\n name: string\n /** The line under the name. */\n tagline?: string\n /** The version, drawn in the mono face; the `v` is the product's to add. */\n version?: string\n /** What the application is doing right now. */\n status?: string\n /** One thing worth knowing while it does it. */\n tip?: string\n /** Whether the bar is sweeping. Still and full when the application is\n * waiting on something that has no progress, such as a person. */\n busy?: boolean\n}\n\n/** The sweep, named with a prefix so a product's own `sweep` cannot collide\n * with it in the one document both end up in.\n *\n * The class below writes the name out rather than interpolating this\n * constant, and that is not carelessness: Tailwind finds its classes by\n * scanning the source text, and a class assembled from a template is one it\n * never sees - the bar rendered with the right class name and no CSS behind\n * it, and the stand showed an empty track. The test compiles the class to\n * make sure the two spellings agree. */\nconst SWEEP = 'dowel-splash-sweep'\nconst SWEEPING = 'animate-[dowel-splash-sweep_1.1s_ease-in-out_infinite]'\n\nexport function Splash({\n mark,\n name,\n tagline,\n version,\n status,\n tip,\n busy = true,\n className,\n ...props\n}: SplashProps) {\n return (\n <div\n role=\"status\"\n aria-live=\"polite\"\n className={cn(\n 'fixed inset-0 flex flex-col items-center justify-center gap-2.5 bg-bg text-text select-none',\n className,\n )}\n {...props}\n >\n <style>{`@keyframes ${SWEEP} { to { left: 100%; } }`}</style>\n {mark ? <div className=\"mb-1.5 size-14 [&>svg]:size-full\">{mark}</div> : null}\n <div className=\"text-[26px] leading-none font-semibold tracking-[0.02em]\">{name}</div>\n {tagline ? <div className=\"text-sm text-dim\">{tagline}</div> : null}\n {version ? <div className=\"mt-1.5 font-mono text-2xs text-faint\">{version}</div> : null}\n <div className=\"relative mt-4 h-0.5 w-40 overflow-hidden rounded-full bg-line\">\n <div\n data-sweep={busy ? 'on' : 'off'}\n className={cn(\n 'absolute top-0 h-full rounded-full bg-accent',\n busy\n ? cn(\n '-left-2/5 w-2/5',\n SWEEPING,\n 'motion-reduce:left-0 motion-reduce:w-full motion-reduce:animate-none',\n )\n : 'left-0 w-full',\n )}\n />\n </div>\n {status ? <p className=\"mt-3 text-xs text-dim\">{status}</p> : null}\n {tip ? <p className=\"text-2xs text-faint\">{tip}</p> : null}\n </div>\n )\n}\n"
|
|
1621
|
+
}
|
|
1622
|
+
]
|
|
1623
|
+
},
|
|
1491
1624
|
{
|
|
1492
1625
|
"name": "stat-tile",
|
|
1493
1626
|
"type": "registry:ui",
|
|
@@ -1495,7 +1628,7 @@
|
|
|
1495
1628
|
"description": "The smallest thing on a dashboard and the one every product writes itself: a label above, a number below, sometimes a word about which way it moved.",
|
|
1496
1629
|
"dependencies": [
|
|
1497
1630
|
"class-variance-authority",
|
|
1498
|
-
"dowel-ui@^0.
|
|
1631
|
+
"dowel-ui@^0.26.0"
|
|
1499
1632
|
],
|
|
1500
1633
|
"registryDependencies": [],
|
|
1501
1634
|
"files": [
|
|
@@ -1514,7 +1647,7 @@
|
|
|
1514
1647
|
"description": "The difference from Checkbox is not how it looks, and getting it wrong is the commonest mistake in the pair. A checkbox is an answer collected now and submitted later, with the rest of the form; a switch is a setting that applies the moment it moves. Put a switch in a form with a Save button and the reader cannot tell whether anything happened - they flipped it, and nothing said so.",
|
|
1515
1648
|
"dependencies": [
|
|
1516
1649
|
"@base-ui/react",
|
|
1517
|
-
"dowel-ui@^0.
|
|
1650
|
+
"dowel-ui@^0.26.0"
|
|
1518
1651
|
],
|
|
1519
1652
|
"registryDependencies": [],
|
|
1520
1653
|
"files": [
|
|
@@ -1549,7 +1682,7 @@
|
|
|
1549
1682
|
"description": "Parts rather than a `columns` prop, and that is the decision worth stating: a `<DataTable columns={…} rows={…} />` is quicker to write for the first table and then owns every cell in the product forever. The moment one column needs a Badge, another a link, and a third the row's own menu, the prop grows a `render` for each - at which point it is JSX with extra steps, spelt in a shape only this component understands.",
|
|
1550
1683
|
"dependencies": [
|
|
1551
1684
|
"class-variance-authority",
|
|
1552
|
-
"dowel-ui@^0.
|
|
1685
|
+
"dowel-ui@^0.26.0"
|
|
1553
1686
|
],
|
|
1554
1687
|
"registryDependencies": [
|
|
1555
1688
|
"https://lacodda.github.io/dowel/r/table-sort.json"
|
|
@@ -1570,7 +1703,7 @@
|
|
|
1570
1703
|
"description": "Free text turned into a list: type a word, press Enter, it becomes a chip.",
|
|
1571
1704
|
"dependencies": [
|
|
1572
1705
|
"class-variance-authority",
|
|
1573
|
-
"dowel-ui@^0.
|
|
1706
|
+
"dowel-ui@^0.26.0"
|
|
1574
1707
|
],
|
|
1575
1708
|
"registryDependencies": [
|
|
1576
1709
|
"https://lacodda.github.io/dowel/r/chip.json",
|
|
@@ -1591,7 +1724,7 @@
|
|
|
1591
1724
|
"title": "Textarea",
|
|
1592
1725
|
"description": "A multi-line field that can grow with what is typed into it, which is the only interesting part: a fixed box makes someone scroll inside a scroll, and a box that grows without limit pushes the button they are trying to reach off the screen. `autoResize` grows it; `maxRows` says when to stop and let it scroll after all.",
|
|
1593
1726
|
"dependencies": [
|
|
1594
|
-
"dowel-ui@^0.
|
|
1727
|
+
"dowel-ui@^0.26.0"
|
|
1595
1728
|
],
|
|
1596
1729
|
"registryDependencies": [
|
|
1597
1730
|
"https://lacodda.github.io/dowel/r/input.json"
|
|
@@ -1611,7 +1744,7 @@
|
|
|
1611
1744
|
"title": "Time-field",
|
|
1612
1745
|
"description": "No donor for this one: neither product of the line had a time field, so this is written from the same shape as DurationField, and for the same reason. Anything a person plausibly types is accepted - `9`, `9:30`, `930`, `9.30`, `9pm`, `21:30` - and what comes back is always `HH:MM`.",
|
|
1613
1746
|
"dependencies": [
|
|
1614
|
-
"dowel-ui@^0.
|
|
1747
|
+
"dowel-ui@^0.26.0"
|
|
1615
1748
|
],
|
|
1616
1749
|
"registryDependencies": [
|
|
1617
1750
|
"https://lacodda.github.io/dowel/r/input.json"
|
|
@@ -1633,7 +1766,7 @@
|
|
|
1633
1766
|
"dependencies": [
|
|
1634
1767
|
"@base-ui/react",
|
|
1635
1768
|
"class-variance-authority",
|
|
1636
|
-
"dowel-ui@^0.
|
|
1769
|
+
"dowel-ui@^0.26.0"
|
|
1637
1770
|
],
|
|
1638
1771
|
"registryDependencies": [],
|
|
1639
1772
|
"files": [
|
|
@@ -1653,7 +1786,7 @@
|
|
|
1653
1786
|
"dependencies": [
|
|
1654
1787
|
"@base-ui/react",
|
|
1655
1788
|
"class-variance-authority",
|
|
1656
|
-
"dowel-ui@^0.
|
|
1789
|
+
"dowel-ui@^0.26.0"
|
|
1657
1790
|
],
|
|
1658
1791
|
"registryDependencies": [],
|
|
1659
1792
|
"files": [
|
|
@@ -1688,7 +1821,7 @@
|
|
|
1688
1821
|
"description": "Two products had written this independently and arrived at the same construction - a rounded track, segments positioned absolutely by percent, a floor under the segment width so a short one does not vanish - differing only in what a segment meant. One drew the tiers of a rubric with the score standing among them; the other drew a working day as alternating work and breaks. Neither could be built from the other, and each knew something the other did not: the tiers had the marker and the three-state reading of a segment (passed, standing in, still ahead), the day had the minimum width and the difference between an empty track and an unknown one.",
|
|
1689
1822
|
"dependencies": [
|
|
1690
1823
|
"class-variance-authority",
|
|
1691
|
-
"dowel-ui@^0.
|
|
1824
|
+
"dowel-ui@^0.26.0"
|
|
1692
1825
|
],
|
|
1693
1826
|
"registryDependencies": [
|
|
1694
1827
|
"https://lacodda.github.io/dowel/r/track-segments.json"
|
|
@@ -1724,7 +1857,7 @@
|
|
|
1724
1857
|
"title": "Tree-view",
|
|
1725
1858
|
"description": "The shape products reach for and then get wrong in the same place every time. A tree is not a nest of lists with click handlers - it is one control with a cursor in it, and the difference is the whole component:\n * **One tab stop, not one per node.** A tree of four hundred files with a `tabIndex` on each is four hundred stops between the sidebar and the editor. The container is what the keyboard reaches, and the arrows move a cursor inside it - the arrangement a `RadioGroup` has, for the same reason.",
|
|
1726
1859
|
"dependencies": [
|
|
1727
|
-
"dowel-ui@^0.
|
|
1860
|
+
"dowel-ui@^0.26.0"
|
|
1728
1861
|
],
|
|
1729
1862
|
"registryDependencies": [
|
|
1730
1863
|
"https://lacodda.github.io/dowel/r/tree-rows.json"
|
|
@@ -1744,7 +1877,7 @@
|
|
|
1744
1877
|
"title": "Truncate",
|
|
1745
1878
|
"description": "Text that does not fit, cut with an ellipsis - and, importantly, still readable in full: the element carries its own text as a `title`, so hovering shows what was cut. Every product wrote the one-line version of this and none of them remembered the title.",
|
|
1746
1879
|
"dependencies": [
|
|
1747
|
-
"dowel-ui@^0.
|
|
1880
|
+
"dowel-ui@^0.26.0"
|
|
1748
1881
|
],
|
|
1749
1882
|
"registryDependencies": [],
|
|
1750
1883
|
"files": [
|
|
@@ -1762,7 +1895,7 @@
|
|
|
1762
1895
|
"title": "Virtual-list",
|
|
1763
1896
|
"description": "The browser is fine with long lists until it is not: a hundred thousand `<div>`s is a layout the machine recomputes on every change, and the page stops responding while it does. What is drawn instead is the window the reader can actually see, held in place by a tall spacer, so the scrollbar still says how much there is.",
|
|
1764
1897
|
"dependencies": [
|
|
1765
|
-
"dowel-ui@^0.
|
|
1898
|
+
"dowel-ui@^0.26.0"
|
|
1766
1899
|
],
|
|
1767
1900
|
"registryDependencies": [],
|
|
1768
1901
|
"files": [
|
|
@@ -1774,6 +1907,25 @@
|
|
|
1774
1907
|
}
|
|
1775
1908
|
]
|
|
1776
1909
|
},
|
|
1910
|
+
{
|
|
1911
|
+
"name": "window-frame",
|
|
1912
|
+
"type": "registry:ui",
|
|
1913
|
+
"title": "Window-frame",
|
|
1914
|
+
"description": "With `decorations: false` the system draws nothing, so everything it used to do is the page's: dragging the window by its title bar, double-click to maximise, the three buttons, and the edges you grab to resize. Each is small; the reason to take them on at all is that a system title bar over an application title bar costs a strip of every laptop screen for nothing.",
|
|
1915
|
+
"dependencies": [
|
|
1916
|
+
"@tauri-apps/api",
|
|
1917
|
+
"dowel-ui@^0.26.0"
|
|
1918
|
+
],
|
|
1919
|
+
"registryDependencies": [],
|
|
1920
|
+
"files": [
|
|
1921
|
+
{
|
|
1922
|
+
"path": "ui/window-frame.tsx",
|
|
1923
|
+
"target": "@ui/window-frame.tsx",
|
|
1924
|
+
"type": "registry:ui",
|
|
1925
|
+
"content": "import {\n useCallback,\n useEffect,\n useState,\n type CSSProperties,\n type MouseEvent,\n type PointerEvent,\n type ReactNode,\n} from 'react'\nimport { getCurrentWindow } from '@tauri-apps/api/window'\nimport { cn } from 'dowel-ui'\n\n/** The eight compass names Tauri resizes by. Read off the method rather than\n * imported: the package declares the type without exporting it. */\ntype ResizeDirection = Parameters<ReturnType<typeof getCurrentWindow>['startResizeDragging']>[0]\n\n/*\n * The window's own frame, for a window that has no system frame.\n *\n * With `decorations: false` the system draws nothing, so everything it used\n * to do is the page's: dragging the window by its title bar, double-click to\n * maximise, the three buttons, and the edges you grab to resize. Each is\n * small; the reason to take them on at all is that a system title bar over an\n * application title bar costs a strip of every laptop screen for nothing.\n * scheda made the trade first and kilna copied it, which is the second\n * consumer the line asks for before anything becomes shared.\n *\n * Four exports, and they are used together: `WindowButtons` in the bar,\n * `useTitleBarGestures()` spread on the bar, `ResizeEdges` once at the root,\n * and `useMaximized()` for anything else that changes shape with the window.\n *\n * Outside Tauri - a browser, a test, the stand - there is no window to drive.\n * Every call goes through `currentWindow()`, which answers null when the\n * Tauri bridge is absent, so the chrome renders and does nothing rather than\n * throwing on the first click. A product's own storybook runs in a browser\n * too, and a title bar that crashes it is a title bar nobody previews.\n */\n\n/** The Tauri window, or null where there is none to drive. The bridge is what\n * `getCurrentWindow` reads its label from, so its absence is the test. */\nfunction currentWindow() {\n return '__TAURI_INTERNALS__' in window ? getCurrentWindow() : null\n}\n\n/** Whether the window is maximised, kept current as the window changes.\n *\n * The window can be maximised without our buttons - a drag to the top edge,\n * the keyboard, a snap layout - so the answer follows the window rather than\n * our own last click. */\nexport function useMaximized(): boolean {\n const [maximized, setMaximized] = useState(false)\n\n useEffect(() => {\n const target = currentWindow()\n if (!target) return\n const read = () => {\n target.isMaximized().then(setMaximized).catch(() => undefined)\n }\n read()\n const unlisten = target.onResized(read)\n return () => {\n unlisten.then((stop) => stop()).catch(() => undefined)\n }\n }, [])\n\n return maximized\n}\n\nexport interface WindowButtonsProps {\n /** What each button is called. Required, and deliberately without a\n * default: a string this component invents is a string the product cannot\n * translate. `restore` replaces `maximize` while the window is maximised. */\n labels: { minimize: string; maximize: string; restore: string; close: string }\n className?: string\n}\n\ntype Control = keyof WindowButtonsProps['labels']\n\n/** The four glyphs, drawn in one stroke on a ten-pixel grid - the size the\n * system's own were, so the bar reads as the window's and not as a toolbar. */\nconst GLYPH: Record<Control, ReactNode> = {\n minimize: <path d=\"M0 5h10\" />,\n maximize: <rect x=\"0.5\" y=\"0.5\" width=\"9\" height=\"9\" />,\n restore: <path d=\"M2.5 2.5V0.5h7v7h-2M0.5 2.5h7v7h-7z\" />,\n close: <path d=\"M0 0l10 10M10 0L0 10\" />,\n}\n\n/** The window controls, in the order Windows puts them. */\nexport function WindowButtons({ labels, className }: WindowButtonsProps) {\n const maximized = useMaximized()\n const controls: [Control, () => unknown][] = [\n ['minimize', () => currentWindow()?.minimize()],\n [maximized ? 'restore' : 'maximize', () => currentWindow()?.toggleMaximize()],\n ['close', () => currentWindow()?.close()],\n ]\n\n return (\n <div className={cn('flex h-full shrink-0 items-stretch', className)}>\n {controls.map(([name, act]) => (\n <button\n key={name}\n type=\"button\"\n aria-label={labels[name]}\n title={labels[name]}\n onClick={() => void act()}\n className={cn(\n 'flex h-full w-[46px] cursor-default items-center justify-center text-dim transition-colors',\n 'hover:bg-soft hover:text-text',\n // The close button is the one that must not be mistaken for its\n // neighbours: it goes red under the pointer, as on every desktop.\n name === 'close' && 'hover:bg-bad hover:text-on-bad',\n )}\n >\n <svg width=\"10\" height=\"10\" viewBox=\"0 0 10 10\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1\" aria-hidden>\n {GLYPH[name]}\n </svg>\n </button>\n ))}\n </div>\n )\n}\n\n/** A press on a control has already been handled by the control. */\nconst shouldHandle = (target: EventTarget | null) =>\n !(target as HTMLElement | null)?.closest(\n 'button, a, input, textarea, [role=\"menu\"], [role=\"menuitem\"], [role=\"tab\"], [role=\"dialog\"]',\n )\n\n/** How far the pointer moves before a press becomes a drag, in pixels. */\nconst THRESHOLD = 4\n\n/** Makes an element behave like a title bar: drag to move, double-click to\n * maximise. Both are what the system used to do for free. Spread the result\n * on the bar: `<header {...useTitleBarGestures()}>`.\n *\n * Two handlers rather than one. A `pointerdown` cannot recognise a double\n * click: its `detail` counts clicks of the *mouse* event sequence, and the\n * second press still arrives as 1 - reading it there fired `startDragging`\n * three times over a double click and toggled nothing. So the press starts a\n * drag, and `dblclick`, which the browser is the one qualified to detect,\n * maximises.\n *\n * Dragging starts on the first movement, not on the press. `startDragging`\n * hands the window over to the system - which is what keeps snap layouts and\n * drag-to-edge working - but from that moment the webview stops seeing the\n * mouse. Calling it on `pointerdown` ate the second click of every double\n * click, and maximising never happened. */\nexport function useTitleBarGestures() {\n const onPointerDown = useCallback((event: PointerEvent) => {\n if (event.button !== 0 || !shouldHandle(event.target)) return\n\n const start = { x: event.clientX, y: event.clientY }\n const onMove = (move: globalThis.PointerEvent) => {\n if (Math.abs(move.clientX - start.x) < THRESHOLD && Math.abs(move.clientY - start.y) < THRESHOLD) {\n return\n }\n stop()\n void currentWindow()?.startDragging()\n }\n const stop = () => {\n window.removeEventListener('pointermove', onMove)\n window.removeEventListener('pointerup', stop)\n window.removeEventListener('pointercancel', stop)\n }\n\n window.addEventListener('pointermove', onMove)\n window.addEventListener('pointerup', stop)\n window.addEventListener('pointercancel', stop)\n }, [])\n\n const onDoubleClick = useCallback((event: MouseEvent) => {\n if (event.button !== 0 || !shouldHandle(event.target)) return\n void currentWindow()?.toggleMaximize()\n }, [])\n\n return { onPointerDown, onDoubleClick }\n}\n\n/** The eight edges and corners a frameless window still has to offer. */\nconst RESIZE_HANDLES: readonly ResizeDirection[] = [\n 'North',\n 'South',\n 'East',\n 'West',\n 'NorthEast',\n 'NorthWest',\n 'SouthEast',\n 'SouthWest',\n]\n\nconst EDGE = 5\nconst CORNER = 10\n\n/** Where each strip sits and which cursor it shows. Inline styles rather than\n * classes: eight positions of a few pixels each are geometry, not design. */\nconst EDGE_STYLE: Record<ResizeDirection, CSSProperties> = {\n North: { top: 0, left: CORNER, right: CORNER, height: EDGE, cursor: 'ns-resize' },\n South: { bottom: 0, left: CORNER, right: CORNER, height: EDGE, cursor: 'ns-resize' },\n East: { top: CORNER, bottom: CORNER, right: 0, width: EDGE, cursor: 'ew-resize' },\n West: { top: CORNER, bottom: CORNER, left: 0, width: EDGE, cursor: 'ew-resize' },\n NorthEast: { top: 0, right: 0, width: CORNER, height: CORNER, cursor: 'nesw-resize' },\n NorthWest: { top: 0, left: 0, width: CORNER, height: CORNER, cursor: 'nwse-resize' },\n SouthEast: { bottom: 0, right: 0, width: CORNER, height: CORNER, cursor: 'nwse-resize' },\n SouthWest: { bottom: 0, left: 0, width: CORNER, height: CORNER, cursor: 'nesw-resize' },\n}\n\nexport interface ResizeEdgesProps {\n /** Merged into every strip. `fixed` to the viewport by default, which is\n * where a window's edges are; `absolute` puts them on the nearest\n * positioned box instead, for a frame drawn inside a page. */\n className?: string\n}\n\n/** Invisible strips along the window's edges.\n *\n * A frameless window has no border to grab, so these put one back. They sit\n * outside the flow, above everything, and are only a few pixels wide -\n * enough to hit, not enough to steal a click meant for the text. A maximised\n * window has no edges to drag, and leaving the strips in place would mean\n * the top few pixels of the title bar stop taking clicks. */\nexport function ResizeEdges({ className }: ResizeEdgesProps) {\n const maximized = useMaximized()\n if (maximized) return null\n\n return (\n <>\n {RESIZE_HANDLES.map((direction) => (\n <div\n key={direction}\n aria-hidden\n data-resize-edge={direction}\n className={cn('fixed [z-index:var(--z-floating)]', className)}\n style={EDGE_STYLE[direction]}\n onPointerDown={(event) => {\n if (event.button !== 0) return\n event.preventDefault()\n void currentWindow()?.startResizeDragging(direction)\n }}\n />\n ))}\n </>\n )\n}\n"
|
|
1926
|
+
}
|
|
1927
|
+
]
|
|
1928
|
+
},
|
|
1777
1929
|
{
|
|
1778
1930
|
"extends": "none",
|
|
1779
1931
|
"name": "app",
|
package/package.json
CHANGED