dowel-ui 0.21.0 → 0.23.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 +8 -5
- package/dist/registry.json +216 -48
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -11,7 +11,7 @@ A dowel is the hidden peg that joins two boards so the seam does not show. That
|
|
|
11
11
|
**[Documentation](https://lacodda.github.io/dowel/)** — what everything is and why it is that way.
|
|
12
12
|
**[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
13
|
|
|
14
|
-
**Status:** v0.
|
|
14
|
+
**Status:** v0.23.0 - the theme, the scales, an accent per product, sixty components - overlays, menus, the command palette, the table, a hundred thousand rows, the screens between asking for data and showing it, and the three ways of saying something happened - 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
15
|
|
|
16
16
|
## The theme
|
|
17
17
|
|
|
@@ -98,7 +98,7 @@ snapshot the cross-references point into the same snapshot, so a component and
|
|
|
98
98
|
the sibling it reuses are the pair that shipped together. See
|
|
99
99
|
[installing from the registry](https://lacodda.github.io/dowel/guides/registry/).
|
|
100
100
|
|
|
101
|
-
|
|
101
|
+
Sixty of them so far. The everyday ones - Button, Input, Textarea, Panel,
|
|
102
102
|
Badge, Chip, Kbd, Spinner, Truncate and Copyable; Field and the three controls
|
|
103
103
|
that answer a question - Checkbox, RadioGroup and Switch; five for a number or
|
|
104
104
|
a judgement - NumberField, Slider, RatingScale, DurationField and
|
|
@@ -108,9 +108,12 @@ DateRangePicker, TimeField and the `calendar-math` under them; the six that
|
|
|
108
108
|
float above the page - Dialog, ConfirmDialog, Drawer, Popover, PreviewCard and
|
|
109
109
|
Tooltip; four for choosing something - Menu, ContextMenu, Select and Combobox;
|
|
110
110
|
three for finding it - SearchField, CommandPalette and the `useShortcut` behind
|
|
111
|
-
them; three for saying that something happened - Toast, Alert and Banner;
|
|
112
|
-
|
|
113
|
-
RelativeTime and the `table-sort` with no React in it
|
|
111
|
+
them; three for saying that something happened - Toast, Alert and Banner; six
|
|
112
|
+
for showing rows of data - Table, Pagination, PageSize, NumberFormat,
|
|
113
|
+
RelativeTime and the `table-sort` with no React in it; four for a long list or
|
|
114
|
+
a deep one - VirtualList, TreeView with the `tree-rows` under it, and KeyValue;
|
|
115
|
+
and five for the screens between asking for data and showing it: Skeleton,
|
|
116
|
+
EmptyState, Progress, QueryState and ErrorBoundary.
|
|
114
117
|
|
|
115
118
|
Those last three are the ones products keep confusing, so each page names all
|
|
116
119
|
four options: a **toast** goes away, an **alert** is still true after a reload,
|
package/dist/registry.json
CHANGED
|
@@ -293,7 +293,7 @@
|
|
|
293
293
|
"dependencies": [
|
|
294
294
|
"@base-ui/react",
|
|
295
295
|
"class-variance-authority",
|
|
296
|
-
"dowel-ui@^0.
|
|
296
|
+
"dowel-ui@^0.23.0"
|
|
297
297
|
],
|
|
298
298
|
"registryDependencies": [],
|
|
299
299
|
"files": [
|
|
@@ -312,7 +312,7 @@
|
|
|
312
312
|
"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.",
|
|
313
313
|
"dependencies": [
|
|
314
314
|
"class-variance-authority",
|
|
315
|
-
"dowel-ui@^0.
|
|
315
|
+
"dowel-ui@^0.23.0"
|
|
316
316
|
],
|
|
317
317
|
"registryDependencies": [],
|
|
318
318
|
"files": [
|
|
@@ -331,7 +331,7 @@
|
|
|
331
331
|
"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.",
|
|
332
332
|
"dependencies": [
|
|
333
333
|
"class-variance-authority",
|
|
334
|
-
"dowel-ui@^0.
|
|
334
|
+
"dowel-ui@^0.23.0"
|
|
335
335
|
],
|
|
336
336
|
"registryDependencies": [],
|
|
337
337
|
"files": [
|
|
@@ -350,7 +350,7 @@
|
|
|
350
350
|
"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.",
|
|
351
351
|
"dependencies": [
|
|
352
352
|
"class-variance-authority",
|
|
353
|
-
"dowel-ui@^0.
|
|
353
|
+
"dowel-ui@^0.23.0"
|
|
354
354
|
],
|
|
355
355
|
"registryDependencies": [],
|
|
356
356
|
"files": [
|
|
@@ -370,7 +370,7 @@
|
|
|
370
370
|
"dependencies": [
|
|
371
371
|
"@base-ui/react",
|
|
372
372
|
"class-variance-authority",
|
|
373
|
-
"dowel-ui@^0.
|
|
373
|
+
"dowel-ui@^0.23.0"
|
|
374
374
|
],
|
|
375
375
|
"registryDependencies": [],
|
|
376
376
|
"files": [
|
|
@@ -404,7 +404,7 @@
|
|
|
404
404
|
"title": "Calendar",
|
|
405
405
|
"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.",
|
|
406
406
|
"dependencies": [
|
|
407
|
-
"dowel-ui@^0.
|
|
407
|
+
"dowel-ui@^0.23.0"
|
|
408
408
|
],
|
|
409
409
|
"registryDependencies": [
|
|
410
410
|
"https://lacodda.github.io/dowel/r/calendar-math.json"
|
|
@@ -425,7 +425,7 @@
|
|
|
425
425
|
"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.",
|
|
426
426
|
"dependencies": [
|
|
427
427
|
"@base-ui/react",
|
|
428
|
-
"dowel-ui@^0.
|
|
428
|
+
"dowel-ui@^0.23.0"
|
|
429
429
|
],
|
|
430
430
|
"registryDependencies": [],
|
|
431
431
|
"files": [
|
|
@@ -444,7 +444,7 @@
|
|
|
444
444
|
"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.",
|
|
445
445
|
"dependencies": [
|
|
446
446
|
"class-variance-authority",
|
|
447
|
-
"dowel-ui@^0.
|
|
447
|
+
"dowel-ui@^0.23.0"
|
|
448
448
|
],
|
|
449
449
|
"registryDependencies": [],
|
|
450
450
|
"files": [
|
|
@@ -462,7 +462,7 @@
|
|
|
462
462
|
"title": "Color-field",
|
|
463
463
|
"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.",
|
|
464
464
|
"dependencies": [
|
|
465
|
-
"dowel-ui@^0.
|
|
465
|
+
"dowel-ui@^0.23.0"
|
|
466
466
|
],
|
|
467
467
|
"registryDependencies": [
|
|
468
468
|
"https://lacodda.github.io/dowel/r/input.json"
|
|
@@ -484,7 +484,7 @@
|
|
|
484
484
|
"dependencies": [
|
|
485
485
|
"@base-ui/react",
|
|
486
486
|
"class-variance-authority",
|
|
487
|
-
"dowel-ui@^0.
|
|
487
|
+
"dowel-ui@^0.23.0"
|
|
488
488
|
],
|
|
489
489
|
"registryDependencies": [
|
|
490
490
|
"https://lacodda.github.io/dowel/r/input.json",
|
|
@@ -507,7 +507,7 @@
|
|
|
507
507
|
"dependencies": [
|
|
508
508
|
"@base-ui/react",
|
|
509
509
|
"class-variance-authority",
|
|
510
|
-
"dowel-ui@^0.
|
|
510
|
+
"dowel-ui@^0.23.0"
|
|
511
511
|
],
|
|
512
512
|
"registryDependencies": [
|
|
513
513
|
"https://lacodda.github.io/dowel/r/combobox.json",
|
|
@@ -530,7 +530,7 @@
|
|
|
530
530
|
"dependencies": [
|
|
531
531
|
"@base-ui/react",
|
|
532
532
|
"class-variance-authority",
|
|
533
|
-
"dowel-ui@^0.
|
|
533
|
+
"dowel-ui@^0.23.0"
|
|
534
534
|
],
|
|
535
535
|
"registryDependencies": [],
|
|
536
536
|
"files": [
|
|
@@ -549,7 +549,7 @@
|
|
|
549
549
|
"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>`.",
|
|
550
550
|
"dependencies": [
|
|
551
551
|
"@base-ui/react",
|
|
552
|
-
"dowel-ui@^0.
|
|
552
|
+
"dowel-ui@^0.23.0"
|
|
553
553
|
],
|
|
554
554
|
"registryDependencies": [
|
|
555
555
|
"https://lacodda.github.io/dowel/r/menu.json"
|
|
@@ -569,7 +569,7 @@
|
|
|
569
569
|
"title": "Copyable",
|
|
570
570
|
"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.",
|
|
571
571
|
"dependencies": [
|
|
572
|
-
"dowel-ui@^0.
|
|
572
|
+
"dowel-ui@^0.23.0"
|
|
573
573
|
],
|
|
574
574
|
"registryDependencies": [],
|
|
575
575
|
"files": [
|
|
@@ -587,7 +587,7 @@
|
|
|
587
587
|
"title": "Date-picker",
|
|
588
588
|
"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.",
|
|
589
589
|
"dependencies": [
|
|
590
|
-
"dowel-ui@^0.
|
|
590
|
+
"dowel-ui@^0.23.0"
|
|
591
591
|
],
|
|
592
592
|
"registryDependencies": [
|
|
593
593
|
"https://lacodda.github.io/dowel/r/calendar.json",
|
|
@@ -610,7 +610,7 @@
|
|
|
610
610
|
"title": "Date-range-picker",
|
|
611
611
|
"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.",
|
|
612
612
|
"dependencies": [
|
|
613
|
-
"dowel-ui@^0.
|
|
613
|
+
"dowel-ui@^0.23.0"
|
|
614
614
|
],
|
|
615
615
|
"registryDependencies": [
|
|
616
616
|
"https://lacodda.github.io/dowel/r/calendar.json",
|
|
@@ -635,7 +635,7 @@
|
|
|
635
635
|
"dependencies": [
|
|
636
636
|
"@base-ui/react",
|
|
637
637
|
"class-variance-authority",
|
|
638
|
-
"dowel-ui@^0.
|
|
638
|
+
"dowel-ui@^0.23.0"
|
|
639
639
|
],
|
|
640
640
|
"registryDependencies": [],
|
|
641
641
|
"files": [
|
|
@@ -655,7 +655,7 @@
|
|
|
655
655
|
"dependencies": [
|
|
656
656
|
"@base-ui/react",
|
|
657
657
|
"class-variance-authority",
|
|
658
|
-
"dowel-ui@^0.
|
|
658
|
+
"dowel-ui@^0.23.0"
|
|
659
659
|
],
|
|
660
660
|
"registryDependencies": [],
|
|
661
661
|
"files": [
|
|
@@ -673,7 +673,7 @@
|
|
|
673
673
|
"title": "Duration-field",
|
|
674
674
|
"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.",
|
|
675
675
|
"dependencies": [
|
|
676
|
-
"dowel-ui@^0.
|
|
676
|
+
"dowel-ui@^0.23.0"
|
|
677
677
|
],
|
|
678
678
|
"registryDependencies": [
|
|
679
679
|
"https://lacodda.github.io/dowel/r/input.json"
|
|
@@ -687,6 +687,44 @@
|
|
|
687
687
|
}
|
|
688
688
|
]
|
|
689
689
|
},
|
|
690
|
+
{
|
|
691
|
+
"name": "empty-state",
|
|
692
|
+
"type": "registry:ui",
|
|
693
|
+
"title": "Empty-state",
|
|
694
|
+
"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.",
|
|
695
|
+
"dependencies": [
|
|
696
|
+
"class-variance-authority",
|
|
697
|
+
"dowel-ui@^0.23.0"
|
|
698
|
+
],
|
|
699
|
+
"registryDependencies": [],
|
|
700
|
+
"files": [
|
|
701
|
+
{
|
|
702
|
+
"path": "ui/empty-state.tsx",
|
|
703
|
+
"target": "@ui/empty-state.tsx",
|
|
704
|
+
"type": "registry:ui",
|
|
705
|
+
"content": "import type { HTMLAttributes, ReactNode } from 'react'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { cn } from 'dowel-ui'\n\n/*\n * What a screen says when there is nothing on it.\n *\n * Three kinds of nothing, and a product that draws the same panel for all\n * three is telling the reader the wrong thing twice:\n *\n * **empty** - there is nothing here yet, and that is normal. The panel says\n * what would be here and offers the one action that makes it appear.\n *\n * **filtered** - there is plenty here, just none of it matching. The way out\n * is to widen the filter, not to create anything.\n *\n * **error** - it could not be fetched. Nothing is missing; something failed,\n * and the way out is to try again.\n *\n * They differ in what the reader should do next, which is exactly what an\n * empty screen is for, so they are a variant rather than three components.\n *\n * `action` is the whole point of the component: **an empty screen with no way\n * out is a dead end.** It is optional in the type because a panel inside a\n * larger screen can borrow the way out from its surroundings - but a full-page\n * empty state without one is a bug the reader cannot report.\n *\n * The mark is the line's hexagon, drawn in the current text colour rather than\n * a product's accent. A full-strength logo in an empty panel shouts; this is a\n * watermark, and it is `aria-hidden` because it says nothing a reader needs.\n */\n\nexport const emptyStateVariants = cva(\n 'flex flex-col items-center justify-center gap-3 rounded-xl p-8 text-center',\n {\n variants: {\n variant: {\n empty: 'border border-dashed border-line-2',\n filtered: 'border border-dashed border-line-2',\n // Solid rather than dashed: a dashed border reads as a placeholder for\n // something that belongs there, and a failure is not that.\n error: 'border border-bad/40 bg-bad-soft/30',\n },\n },\n defaultVariants: { variant: 'empty' },\n },\n)\n\n/** The line's hexagon, at whatever size the caller asks for.\n *\n * Inlined rather than fetched: it is decorative, so it should not cost a\n * request, and it has to take the theme's colour - a file could not. */\nexport function EmptyMark({ className }: { className?: string }) {\n return (\n <svg viewBox=\"0 0 100 100\" aria-hidden className={cn('size-14', className)}>\n <polygon\n points=\"50,5 89,27.5 89,72.5 50,95 11,72.5 11,27.5\"\n fill=\"none\"\n stroke=\"currentColor\"\n strokeWidth={6}\n strokeLinejoin=\"round\"\n />\n </svg>\n )\n}\n\n/** The same hexagon, cut by a slash. For a filter that matched nothing: the\n * shape is there, the contents are not. */\nexport function FilteredMark({ className }: { className?: string }) {\n return (\n <svg viewBox=\"0 0 100 100\" aria-hidden className={cn('size-14', className)}>\n <polygon\n points=\"50,5 89,27.5 89,72.5 50,95 11,72.5 11,27.5\"\n fill=\"none\"\n stroke=\"currentColor\"\n strokeWidth={6}\n strokeLinejoin=\"round\"\n />\n <line x1=\"22\" y1=\"78\" x2=\"78\" y2=\"22\" stroke=\"currentColor\" strokeWidth={6} strokeLinecap=\"round\" />\n </svg>\n )\n}\n\n/** A hexagon with a corner broken out of it. For something that failed. */\nexport function ErrorMark({ className }: { className?: string }) {\n return (\n <svg viewBox=\"0 0 100 100\" aria-hidden className={cn('size-14', className)}>\n <polyline\n points=\"50,5 89,27.5 89,72.5 50,95 11,72.5 11,27.5 50,5\"\n fill=\"none\"\n stroke=\"currentColor\"\n strokeWidth={6}\n strokeLinejoin=\"round\"\n strokeLinecap=\"round\"\n // The gap is the break: the outline is drawn as a line rather than a\n // closed shape so one edge can be missing.\n strokeDasharray=\"150 34\"\n />\n </svg>\n )\n}\n\nexport interface EmptyStateProps\n extends Omit<HTMLAttributes<HTMLDivElement>, 'title'>,\n VariantProps<typeof emptyStateVariants> {\n /** What is not here, in the product's words. */\n title: ReactNode\n /** Why, or what to do about it. */\n body?: ReactNode\n /** The one thing worth doing here. An empty screen with no way out is a\n * dead end. */\n action?: ReactNode\n /** Something other than the default mark - a product's own illustration. */\n mark?: ReactNode\n}\n\nexport function EmptyState({\n variant = 'empty',\n title,\n body,\n action,\n mark,\n className,\n ...props\n}: EmptyStateProps) {\n const defaultMark =\n variant === 'error' ? (\n <ErrorMark className=\"text-bad/60\" />\n ) : variant === 'filtered' ? (\n <FilteredMark className=\"text-line-2\" />\n ) : (\n <EmptyMark className=\"text-line-2\" />\n )\n\n return (\n <div className={cn(emptyStateVariants({ variant }), className)} {...props}>\n {mark ?? defaultMark}\n <div>\n <p className={cn('font-medium', variant === 'error' && 'text-bad')}>{title}</p>\n {body !== undefined && <p className=\"mt-1 text-sm text-dim\">{body}</p>}\n </div>\n {action}\n </div>\n )\n}\n"
|
|
706
|
+
}
|
|
707
|
+
]
|
|
708
|
+
},
|
|
709
|
+
{
|
|
710
|
+
"name": "error-boundary",
|
|
711
|
+
"type": "registry:ui",
|
|
712
|
+
"title": "Error-boundary",
|
|
713
|
+
"description": "A class component, and the only one in the set - not a style choice: React gives no hook for catching a render error, and `componentDidCatch` exists nowhere else. Anything that claims otherwise catches events, not renders.",
|
|
714
|
+
"dependencies": [],
|
|
715
|
+
"registryDependencies": [
|
|
716
|
+
"https://lacodda.github.io/dowel/r/button.json",
|
|
717
|
+
"https://lacodda.github.io/dowel/r/empty-state.json"
|
|
718
|
+
],
|
|
719
|
+
"files": [
|
|
720
|
+
{
|
|
721
|
+
"path": "ui/error-boundary.tsx",
|
|
722
|
+
"target": "@ui/error-boundary.tsx",
|
|
723
|
+
"type": "registry:ui",
|
|
724
|
+
"content": "import { Component, type ErrorInfo, type ReactNode } from 'react'\nimport { Button } from './button'\nimport { EmptyState } from './empty-state'\n\n/*\n * The screen that appears instead of a crash.\n *\n * A class component, and the only one in the set - not a style choice: React\n * gives no hook for catching a render error, and `componentDidCatch` exists\n * nowhere else. Anything that claims otherwise catches events, not renders.\n *\n * What it is for is narrow and worth stating, because products reach for it as\n * a general error handler and it is not one: it catches errors **thrown while\n * rendering**, below itself. A failed fetch is not that - it is a value the\n * component receives and shows, which is `QueryState`. An error in an event\n * handler is not that either; nothing catches those but the handler.\n *\n * So this is the last line: something that should never have thrown did, and\n * the alternative is a white page with the product's own name at the top.\n *\n * The fallback is deliberately plain and deliberately says the message. The\n * reader cannot fix it, but the reader is who files it - and a screen that\n * hides the one string worth quoting turns a bug report into a guess. It is\n * folded away behind a summary, because it is evidence rather than an\n * instruction.\n */\n\nexport interface ErrorBoundaryProps {\n children: ReactNode\n /** Draw something else instead of the default screen.\n *\n * Given the error and a way to clear it, because a fallback that cannot\n * retry is a dead end with extra steps. */\n fallback?: (error: Error, reset: () => void) => ReactNode\n /** Change this to clear the error - a route, usually.\n *\n * Without it a boundary that has caught once stays caught: the reader\n * navigates away from the broken screen and the crash follows them, because\n * nothing told the boundary the reason had gone. */\n resetKey?: unknown\n /** Somewhere to send it. The product owns whether that is a log, a file or a\n * service - the boundary only knows it happened. */\n onError?: (error: Error, info: ErrorInfo) => void\n /** The words on the default screen. Required, and not defaulted: they are\n * the only text here, and English inside a primitive is text no product can\n * translate. */\n labels: ErrorBoundaryLabels\n}\n\nexport interface ErrorBoundaryLabels {\n /** \"Something went wrong.\" */\n title: string\n /** What the reader can do about it. */\n body: string\n /** The summary that unfolds the message, e.g. \"Details\". */\n details: string\n /** The retry button, e.g. \"Try again\". */\n retry: string\n}\n\ninterface State {\n error: Error | null\n}\n\nexport class ErrorBoundary extends Component<ErrorBoundaryProps, State> {\n state: State = { error: null }\n\n static getDerivedStateFromError(error: Error): State {\n return { error }\n }\n\n componentDidCatch(error: Error, info: ErrorInfo): void {\n this.props.onError?.(error, info)\n }\n\n componentDidUpdate(previous: ErrorBoundaryProps): void {\n // Clearing on a changed key, rather than on any re-render: a boundary that\n // resets whenever its parent renders re-runs the throwing child\n // immediately, and the screen flickers between the crash and the fallback\n // for as long as the cause is there.\n if (this.state.error !== null && previous.resetKey !== this.props.resetKey) {\n this.setState({ error: null })\n }\n }\n\n private reset = (): void => {\n this.setState({ error: null })\n }\n\n render(): ReactNode {\n const { error } = this.state\n if (error === null) return this.props.children\n\n const { fallback, labels } = this.props\n if (fallback) return fallback(error, this.reset)\n\n return (\n <EmptyState\n variant=\"error\"\n title={labels.title}\n body={labels.body}\n action={\n <div className=\"flex w-full flex-col items-center gap-3\">\n <details className=\"w-full text-left\">\n <summary className=\"cursor-pointer text-xs text-faint\">{labels.details}</summary>\n {/* Selectable and monospaced, because its job is to be copied\n * into a bug report verbatim. */}\n <pre className=\"mt-2 max-h-40 overflow-auto rounded-md bg-softer p-3 font-mono text-xs text-dim\">\n {error.message}\n </pre>\n </details>\n <Button variant=\"primary\" onClick={this.reset}>\n {labels.retry}\n </Button>\n </div>\n }\n />\n )\n }\n}\n"
|
|
725
|
+
}
|
|
726
|
+
]
|
|
727
|
+
},
|
|
690
728
|
{
|
|
691
729
|
"name": "field",
|
|
692
730
|
"type": "registry:ui",
|
|
@@ -694,7 +732,7 @@
|
|
|
694
732
|
"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.",
|
|
695
733
|
"dependencies": [
|
|
696
734
|
"@base-ui/react",
|
|
697
|
-
"dowel-ui@^0.
|
|
735
|
+
"dowel-ui@^0.23.0"
|
|
698
736
|
],
|
|
699
737
|
"registryDependencies": [],
|
|
700
738
|
"files": [
|
|
@@ -712,7 +750,7 @@
|
|
|
712
750
|
"title": "File-drop",
|
|
713
751
|
"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.",
|
|
714
752
|
"dependencies": [
|
|
715
|
-
"dowel-ui@^0.
|
|
753
|
+
"dowel-ui@^0.23.0"
|
|
716
754
|
],
|
|
717
755
|
"registryDependencies": [],
|
|
718
756
|
"files": [
|
|
@@ -730,7 +768,7 @@
|
|
|
730
768
|
"title": "Input",
|
|
731
769
|
"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.",
|
|
732
770
|
"dependencies": [
|
|
733
|
-
"dowel-ui@^0.
|
|
771
|
+
"dowel-ui@^0.23.0"
|
|
734
772
|
],
|
|
735
773
|
"registryDependencies": [],
|
|
736
774
|
"files": [
|
|
@@ -748,7 +786,7 @@
|
|
|
748
786
|
"title": "Kbd",
|
|
749
787
|
"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.",
|
|
750
788
|
"dependencies": [
|
|
751
|
-
"dowel-ui@^0.
|
|
789
|
+
"dowel-ui@^0.23.0"
|
|
752
790
|
],
|
|
753
791
|
"registryDependencies": [],
|
|
754
792
|
"files": [
|
|
@@ -760,6 +798,25 @@
|
|
|
760
798
|
}
|
|
761
799
|
]
|
|
762
800
|
},
|
|
801
|
+
{
|
|
802
|
+
"name": "key-value",
|
|
803
|
+
"type": "registry:ui",
|
|
804
|
+
"title": "Key-value",
|
|
805
|
+
"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.",
|
|
806
|
+
"dependencies": [
|
|
807
|
+
"class-variance-authority",
|
|
808
|
+
"dowel-ui@^0.23.0"
|
|
809
|
+
],
|
|
810
|
+
"registryDependencies": [],
|
|
811
|
+
"files": [
|
|
812
|
+
{
|
|
813
|
+
"path": "ui/key-value.tsx",
|
|
814
|
+
"target": "@ui/key-value.tsx",
|
|
815
|
+
"type": "registry:ui",
|
|
816
|
+
"content": "import { createContext, useContext, type HTMLAttributes, type ReactNode } from 'react'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { cn } from 'dowel-ui'\n\n/*\n * A list of facts: a name, and what it is.\n *\n * The shape every product builds out of two `<div>`s in a flex row, and the\n * reason it is worth having once: it is a `<dl>`, and the pairing is what a\n * screen reader announces. Two divs read as four unrelated pieces of text -\n * \"Created\", \"2 hours ago\", \"Owner\", \"Ines\" - and nothing says which value\n * belongs to which name. The right element says it for free.\n *\n * Two layouts, and both exist because the same list sits in two places: beside\n * something (a panel of properties, names in one column) and under a heading\n * (a card, name above value). A product moving a panel should not have to\n * change component.\n *\n * The layouts need *different markup*, and that is why the row reads the\n * layout from context rather than taking a prop:\n *\n * **rows** puts `<dt>`/`<dd>` directly in the grid, so every value lines up\n * in one column. Wrap each pair in a `<div>` and each pair becomes its own\n * box - the values then sit at a different place on every line, which is\n * exactly the ragged look this component exists to stop.\n *\n * **stacked** wraps each pair, because without a wrapper the column gaps\n * fall between `dt` and `dd` as readily as between pairs, and a list of six\n * facts reads as twelve unrelated lines.\n *\n * Both are valid `<dl>`: the spec allows `dt`/`dd` as direct children, and\n * allows a `<div>` per pair. What it does not allow is any other element\n * between them, which is why the pair is a component rather than something the\n * caller assembles.\n */\n\nexport const keyValueVariants = cva('text-sm', {\n variants: {\n layout: {\n rows: 'grid grid-cols-[minmax(0,auto)_minmax(0,1fr)] items-baseline gap-x-4 gap-y-2',\n stacked: 'flex flex-col gap-3',\n },\n },\n defaultVariants: { layout: 'rows' },\n})\n\ntype Layout = NonNullable<VariantProps<typeof keyValueVariants>['layout']>\n\n/* The row has to know the layout to know whether to wrap. Context rather than\n * a prop on every row: a list whose rows disagree with it is not a thing\n * anyone wants, and repeating the value at each row is how they come to. */\nconst LayoutContext = createContext<Layout>('rows')\n\nexport interface KeyValueProps\n extends HTMLAttributes<HTMLDListElement>,\n VariantProps<typeof keyValueVariants> {}\n\nexport function KeyValue({ layout, className, ...props }: KeyValueProps) {\n return (\n <LayoutContext.Provider value={layout ?? 'rows'}>\n <dl className={cn(keyValueVariants({ layout }), className)} {...props} />\n </LayoutContext.Provider>\n )\n}\n\nexport interface KeyValueRowProps {\n /** The name of the fact. */\n label: ReactNode\n /** What it is. A node rather than a string: half the values in a real panel\n * are a Badge, a RelativeTime or a link. */\n children: ReactNode\n className?: string\n}\n\n/** One pair. */\nexport function KeyValueRow({ label, children, className }: KeyValueRowProps) {\n const layout = useContext(LayoutContext)\n\n const value = <dd className=\"min-w-0 text-text\">{children}</dd>\n\n // Wrapped, so the column's gap falls between pairs rather than inside one,\n // and the caller's class dresses the pair as a whole.\n if (layout === 'stacked') {\n return (\n <div className={cn('flex flex-col gap-0.5', className)}>\n <dt className=\"text-dim\">{label}</dt>\n {value}\n </div>\n )\n }\n\n /* Directly in the grid, so every value shares one column - there is no\n * wrapper here to carry the caller's class, so it dresses the term. That is\n * the half a caller styles: the value's own appearance comes from whatever\n * it renders. */\n return (\n <>\n <dt className={cn('text-dim', className)}>{label}</dt>\n {value}\n </>\n )\n}\n"
|
|
817
|
+
}
|
|
818
|
+
]
|
|
819
|
+
},
|
|
763
820
|
{
|
|
764
821
|
"name": "menu",
|
|
765
822
|
"type": "registry:ui",
|
|
@@ -768,7 +825,7 @@
|
|
|
768
825
|
"dependencies": [
|
|
769
826
|
"@base-ui/react",
|
|
770
827
|
"class-variance-authority",
|
|
771
|
-
"dowel-ui@^0.
|
|
828
|
+
"dowel-ui@^0.23.0"
|
|
772
829
|
],
|
|
773
830
|
"registryDependencies": [],
|
|
774
831
|
"files": [
|
|
@@ -787,7 +844,7 @@
|
|
|
787
844
|
"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.",
|
|
788
845
|
"dependencies": [
|
|
789
846
|
"@base-ui/react",
|
|
790
|
-
"dowel-ui@^0.
|
|
847
|
+
"dowel-ui@^0.23.0"
|
|
791
848
|
],
|
|
792
849
|
"registryDependencies": [
|
|
793
850
|
"https://lacodda.github.io/dowel/r/input.json"
|
|
@@ -807,7 +864,7 @@
|
|
|
807
864
|
"title": "Number-format",
|
|
808
865
|
"description": "Two things, and the second is the reason this is a component rather than a call to `toLocaleString` at each site.",
|
|
809
866
|
"dependencies": [
|
|
810
|
-
"dowel-ui@^0.
|
|
867
|
+
"dowel-ui@^0.23.0"
|
|
811
868
|
],
|
|
812
869
|
"registryDependencies": [],
|
|
813
870
|
"files": [
|
|
@@ -825,7 +882,7 @@
|
|
|
825
882
|
"title": "Page-size",
|
|
826
883
|
"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.",
|
|
827
884
|
"dependencies": [
|
|
828
|
-
"dowel-ui@^0.
|
|
885
|
+
"dowel-ui@^0.23.0"
|
|
829
886
|
],
|
|
830
887
|
"registryDependencies": [
|
|
831
888
|
"https://lacodda.github.io/dowel/r/select.json"
|
|
@@ -845,7 +902,7 @@
|
|
|
845
902
|
"title": "Pagination",
|
|
846
903
|
"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.",
|
|
847
904
|
"dependencies": [
|
|
848
|
-
"dowel-ui@^0.
|
|
905
|
+
"dowel-ui@^0.23.0"
|
|
849
906
|
],
|
|
850
907
|
"registryDependencies": [
|
|
851
908
|
"https://lacodda.github.io/dowel/r/button.json"
|
|
@@ -866,7 +923,7 @@
|
|
|
866
923
|
"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.",
|
|
867
924
|
"dependencies": [
|
|
868
925
|
"class-variance-authority",
|
|
869
|
-
"dowel-ui@^0.
|
|
926
|
+
"dowel-ui@^0.23.0"
|
|
870
927
|
],
|
|
871
928
|
"registryDependencies": [],
|
|
872
929
|
"files": [
|
|
@@ -884,7 +941,7 @@
|
|
|
884
941
|
"title": "Password-field",
|
|
885
942
|
"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.",
|
|
886
943
|
"dependencies": [
|
|
887
|
-
"dowel-ui@^0.
|
|
944
|
+
"dowel-ui@^0.23.0"
|
|
888
945
|
],
|
|
889
946
|
"registryDependencies": [
|
|
890
947
|
"https://lacodda.github.io/dowel/r/input.json"
|
|
@@ -906,7 +963,7 @@
|
|
|
906
963
|
"dependencies": [
|
|
907
964
|
"@base-ui/react",
|
|
908
965
|
"class-variance-authority",
|
|
909
|
-
"dowel-ui@^0.
|
|
966
|
+
"dowel-ui@^0.23.0"
|
|
910
967
|
],
|
|
911
968
|
"registryDependencies": [],
|
|
912
969
|
"files": [
|
|
@@ -926,7 +983,7 @@
|
|
|
926
983
|
"dependencies": [
|
|
927
984
|
"@base-ui/react",
|
|
928
985
|
"class-variance-authority",
|
|
929
|
-
"dowel-ui@^0.
|
|
986
|
+
"dowel-ui@^0.23.0"
|
|
930
987
|
],
|
|
931
988
|
"registryDependencies": [],
|
|
932
989
|
"files": [
|
|
@@ -938,6 +995,45 @@
|
|
|
938
995
|
}
|
|
939
996
|
]
|
|
940
997
|
},
|
|
998
|
+
{
|
|
999
|
+
"name": "progress",
|
|
1000
|
+
"type": "registry:ui",
|
|
1001
|
+
"title": "Progress",
|
|
1002
|
+
"description": "The distinction the component is built on, and the one products collapse:\n * **determinate** - the fraction is known. The bar fills to it, and a reader can tell how long is left.",
|
|
1003
|
+
"dependencies": [
|
|
1004
|
+
"@base-ui/react",
|
|
1005
|
+
"class-variance-authority",
|
|
1006
|
+
"dowel-ui@^0.23.0"
|
|
1007
|
+
],
|
|
1008
|
+
"registryDependencies": [],
|
|
1009
|
+
"files": [
|
|
1010
|
+
{
|
|
1011
|
+
"path": "ui/progress.tsx",
|
|
1012
|
+
"target": "@ui/progress.tsx",
|
|
1013
|
+
"type": "registry:ui",
|
|
1014
|
+
"content": "import { Progress as Base } from '@base-ui/react/progress'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { cn } from 'dowel-ui'\n\n/*\n * How far along something is.\n *\n * The distinction the component is built on, and the one products collapse:\n *\n * **determinate** - the fraction is known. The bar fills to it, and a reader\n * can tell how long is left.\n *\n * **indeterminate** - something is happening and nobody knows how much is\n * left. The bar says exactly that, by moving without filling.\n *\n * Collapsing them means picking a number that is not true - a bar that creeps\n * to 90% and waits there is the commonest version, and it is a lie the reader\n * learns to distrust, after which no progress bar in the product means\n * anything. `value={undefined}` is the honest answer, and it is the default.\n *\n * Base UI carries the role, the announcement and the value clamping. What is\n * here is the clothes, and the rule about which of the two states is drawn.\n *\n * Not a Spinner. A spinner says \"working\" in a corner; this says \"working, and\n * here is the shape of it\" across a width. Where the shape of what is coming\n * is known, a Skeleton says more than either.\n */\n\nexport const progressVariants = cva('w-full overflow-hidden rounded-full bg-soft', {\n variants: {\n size: {\n sm: 'h-1',\n md: 'h-2',\n },\n tone: {\n accent: '',\n good: '',\n warn: '',\n bad: '',\n },\n },\n defaultVariants: { size: 'md', tone: 'accent' },\n})\n\nconst fillVariants = cva('h-full rounded-full transition-[width] duration-base', {\n variants: {\n tone: {\n accent: 'bg-accent',\n good: 'bg-good',\n warn: 'bg-warn',\n bad: 'bg-bad',\n },\n },\n defaultVariants: { tone: 'accent' },\n})\n\n/* The stripes take their colour from `currentColor`, so the tone arrives as a\n * text colour rather than a background - the same four names, said the other\n * way round. */\nconst stripeVariants = cva('h-full w-full rounded-full opacity-40', {\n variants: {\n tone: {\n accent: 'text-accent',\n good: 'text-good',\n warn: 'text-warn',\n bad: 'text-bad',\n },\n },\n defaultVariants: { tone: 'accent' },\n})\n\nexport interface ProgressProps\n extends Omit<Base.Root.Props, 'className' | 'value'>,\n VariantProps<typeof progressVariants> {\n /** The fraction done, 0 to `max`. Leave it out when it is not known - that\n * is not a missing value but a different, honest state. */\n value?: number | null\n max?: number\n /** What is progressing, for a screen reader. Required: a bar with no name is\n * announced as a percentage of nothing, and the word belongs to the product. */\n label: string\n className?: string\n /** A visible label beside the bar, when there is room. */\n children?: React.ReactNode\n}\n\nexport function Progress({\n value,\n max = 100,\n size,\n tone,\n label,\n className,\n children,\n ...props\n}: ProgressProps) {\n const indeterminate = value === undefined || value === null\n\n return (\n <Base.Root\n value={indeterminate ? null : value}\n max={max}\n aria-label={label}\n className={cn('flex w-full flex-col gap-1.5', className)}\n {...props}\n >\n {children !== undefined && (\n <div className=\"flex items-baseline justify-between text-xs text-dim\">\n {children}\n {/* The number only where there is one. Showing \"0%\" for an unknown\n * amount is the same lie as a bar that creeps to 90%. */}\n {!indeterminate && (\n <span className=\"tabular-nums\">{Math.round((value / max) * 100)}%</span>\n )}\n </div>\n )}\n\n <Base.Track className={cn(progressVariants({ size, tone }))}>\n {indeterminate ? (\n /* Stripes across the whole track, not a filled bar.\n *\n * A full-width fill was the first version and it was wrong in the\n * one way that matters: measured on the stand, it drew 384px of a\n * 384px track - a reader glancing at it sees \"done\", which is the\n * opposite of what the state means. Pulsing did not rescue it, and\n * under `prefers-reduced-motion` the pulse stops and a solid,\n * complete-looking bar is all that remains.\n *\n * Stripes cannot be read as a fraction at all: there is no edge to\n * take for a boundary. They are drawn with a gradient rather than a\n * `@keyframes` of their own, because a name in the theme is a\n * contract the line carries forever - and this needs no animation to\n * say what it says. */\n <div\n className={cn(stripeVariants({ tone }))}\n style={{\n backgroundImage:\n 'repeating-linear-gradient(45deg, currentColor 0 6px, transparent 6px 12px)',\n }}\n />\n ) : (\n <Base.Indicator className={cn(fillVariants({ tone }))} />\n )}\n </Base.Track>\n </Base.Root>\n )\n}\n"
|
|
1015
|
+
}
|
|
1016
|
+
]
|
|
1017
|
+
},
|
|
1018
|
+
{
|
|
1019
|
+
"name": "query-state",
|
|
1020
|
+
"type": "registry:ui",
|
|
1021
|
+
"title": "Query-state",
|
|
1022
|
+
"description": "Every list in every product writes the same ladder - loading, then failed, then nothing found, then the content - and writes it slightly differently each time. What differs is never deliberate: one screen forgets the empty case, another shows a spinner where the shape was known, a third prints the raw error object. This is that ladder, once.",
|
|
1023
|
+
"dependencies": [],
|
|
1024
|
+
"registryDependencies": [
|
|
1025
|
+
"https://lacodda.github.io/dowel/r/empty-state.json",
|
|
1026
|
+
"https://lacodda.github.io/dowel/r/skeleton.json"
|
|
1027
|
+
],
|
|
1028
|
+
"files": [
|
|
1029
|
+
{
|
|
1030
|
+
"path": "ui/query-state.tsx",
|
|
1031
|
+
"target": "@ui/query-state.tsx",
|
|
1032
|
+
"type": "registry:ui",
|
|
1033
|
+
"content": "import type { ReactNode } from 'react'\nimport { EmptyState } from './empty-state'\nimport { SkeletonList } from './skeleton'\n\n/*\n * The three screens between asking for data and showing it.\n *\n * Every list in every product writes the same ladder - loading, then failed,\n * then nothing found, then the content - and writes it slightly differently\n * each time. What differs is never deliberate: one screen forgets the empty\n * case, another shows a spinner where the shape was known, a third prints the\n * raw error object. This is that ladder, once.\n *\n * **It takes values, not a query.** `useQuery` from TanStack Query hands back\n * `isPending` and `error`, and those are ordinary values - so this component\n * asks for them rather than for the query result, and works the same with SWR,\n * with a reducer, or with two `useState` calls. Taking the result object would\n * put a library in the registry and therefore in every product that installs\n * this primitive, to save one line at the call site:\n *\n * const works = useQuery({ queryKey, queryFn })\n * <QueryState pending={works.isPending} error={works.error} empty={!works.data?.length} …>\n *\n * **The order of the cases is the component**, and it is the part that goes\n * wrong by hand: pending first, because a refetch that already has data should\n * not blank the screen; then error, because an error with stale data is still\n * an error; then empty, which is only knowable once something arrived.\n *\n * What it deliberately does not do is fetch, retry or cache. Those belong to\n * whatever owns the data - and a component that guessed at them would be\n * wrong for the product that owns them differently.\n */\n\nexport interface QueryStateProps {\n /** Nothing has arrived yet. */\n pending?: boolean\n /** It failed. Anything with a `message`, which is what every error library\n * agrees on - or a string, for a product that carries its own. */\n error?: { message: string } | string | null\n /** Something arrived, and it was nothing. Computed by the caller, because\n * only the caller knows whether empty means `[]`, `null` or a count of\n * zero. */\n empty?: boolean\n /** What stands in while pending. A shape, ideally - the default is a list,\n * because most screens are waiting for one, but a screen waiting for a card\n * should say so. */\n skeleton?: ReactNode\n /** What is shown when nothing came back. */\n emptyState?: ReactNode\n /** What is shown when it failed. Given the message, so a product can put it\n * where it likes - or ignore it. */\n errorState?: (message: string) => ReactNode\n /** The words on the default error screen. Required only in the sense that\n * the default screen needs them; pass `errorState` instead and they are\n * never read. */\n errorLabels?: { title: string; body?: string }\n children: ReactNode\n}\n\nexport function QueryState({\n pending = false,\n error = null,\n empty = false,\n skeleton,\n emptyState,\n errorState,\n errorLabels,\n children,\n}: QueryStateProps) {\n /* `aria-busy` on the wrapper, in every state.\n *\n * This is what tells a screen reader that the region is working, and it is\n * why the Skeletons themselves are `aria-hidden`: the fact belongs to the\n * region, said once, rather than to a dozen empty boxes announced as\n * content. */\n const wrap = (content: ReactNode) => <div aria-busy={pending || undefined}>{content}</div>\n\n // Pending first: a refetch that still holds data should not blank the screen\n // it is refreshing, and the caller decides that by passing `pending` only\n // when there is nothing to show.\n if (pending) return wrap(skeleton ?? <SkeletonList />)\n\n if (error) {\n const message = typeof error === 'string' ? error : error.message\n if (errorState) return wrap(errorState(message))\n return wrap(\n <EmptyState\n variant=\"error\"\n title={errorLabels?.title ?? message}\n // The message is shown as the body when there is a title above it, and\n // as the title when there is not - so it is never lost, and never\n // printed twice.\n body={errorLabels?.title ? (errorLabels.body ?? message) : errorLabels?.body}\n />,\n )\n }\n\n // Empty last of the three: it is the only one that cannot be known until\n // something has arrived.\n if (empty && emptyState) return wrap(emptyState)\n\n return wrap(children)\n}\n"
|
|
1034
|
+
}
|
|
1035
|
+
]
|
|
1036
|
+
},
|
|
941
1037
|
{
|
|
942
1038
|
"name": "radio-group",
|
|
943
1039
|
"type": "registry:ui",
|
|
@@ -946,7 +1042,7 @@
|
|
|
946
1042
|
"dependencies": [
|
|
947
1043
|
"@base-ui/react",
|
|
948
1044
|
"class-variance-authority",
|
|
949
|
-
"dowel-ui@^0.
|
|
1045
|
+
"dowel-ui@^0.23.0"
|
|
950
1046
|
],
|
|
951
1047
|
"registryDependencies": [],
|
|
952
1048
|
"files": [
|
|
@@ -964,7 +1060,7 @@
|
|
|
964
1060
|
"title": "Rating-scale",
|
|
965
1061
|
"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\".",
|
|
966
1062
|
"dependencies": [
|
|
967
|
-
"dowel-ui@^0.
|
|
1063
|
+
"dowel-ui@^0.23.0"
|
|
968
1064
|
],
|
|
969
1065
|
"registryDependencies": [],
|
|
970
1066
|
"files": [
|
|
@@ -982,7 +1078,7 @@
|
|
|
982
1078
|
"title": "Relative-time",
|
|
983
1079
|
"description": "The relative-time primitive.",
|
|
984
1080
|
"dependencies": [
|
|
985
|
-
"dowel-ui@^0.
|
|
1081
|
+
"dowel-ui@^0.23.0"
|
|
986
1082
|
],
|
|
987
1083
|
"registryDependencies": [],
|
|
988
1084
|
"files": [
|
|
@@ -1000,7 +1096,7 @@
|
|
|
1000
1096
|
"title": "Save-state",
|
|
1001
1097
|
"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.",
|
|
1002
1098
|
"dependencies": [
|
|
1003
|
-
"dowel-ui@^0.
|
|
1099
|
+
"dowel-ui@^0.23.0"
|
|
1004
1100
|
],
|
|
1005
1101
|
"registryDependencies": [
|
|
1006
1102
|
"https://lacodda.github.io/dowel/r/spinner.json"
|
|
@@ -1020,7 +1116,7 @@
|
|
|
1020
1116
|
"title": "Search-field",
|
|
1021
1117
|
"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.",
|
|
1022
1118
|
"dependencies": [
|
|
1023
|
-
"dowel-ui@^0.
|
|
1119
|
+
"dowel-ui@^0.23.0"
|
|
1024
1120
|
],
|
|
1025
1121
|
"registryDependencies": [
|
|
1026
1122
|
"https://lacodda.github.io/dowel/r/input.json",
|
|
@@ -1044,7 +1140,7 @@
|
|
|
1044
1140
|
"dependencies": [
|
|
1045
1141
|
"@base-ui/react",
|
|
1046
1142
|
"class-variance-authority",
|
|
1047
|
-
"dowel-ui@^0.
|
|
1143
|
+
"dowel-ui@^0.23.0"
|
|
1048
1144
|
],
|
|
1049
1145
|
"registryDependencies": [
|
|
1050
1146
|
"https://lacodda.github.io/dowel/r/input.json"
|
|
@@ -1074,6 +1170,24 @@
|
|
|
1074
1170
|
}
|
|
1075
1171
|
]
|
|
1076
1172
|
},
|
|
1173
|
+
{
|
|
1174
|
+
"name": "skeleton",
|
|
1175
|
+
"type": "registry:ui",
|
|
1176
|
+
"title": "Skeleton",
|
|
1177
|
+
"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.",
|
|
1178
|
+
"dependencies": [
|
|
1179
|
+
"dowel-ui@^0.23.0"
|
|
1180
|
+
],
|
|
1181
|
+
"registryDependencies": [],
|
|
1182
|
+
"files": [
|
|
1183
|
+
{
|
|
1184
|
+
"path": "ui/skeleton.tsx",
|
|
1185
|
+
"target": "@ui/skeleton.tsx",
|
|
1186
|
+
"type": "registry:ui",
|
|
1187
|
+
"content": "import type { HTMLAttributes } from 'react'\nimport { cn } from 'dowel-ui'\n\n/*\n * A placeholder shaped like the thing that is still loading.\n *\n * The rule the component is built on, and the reason it takes a shape rather\n * than filling the space:\n *\n * **A skeleton of the wrong shape is worse than no skeleton.**\n *\n * It promises something the content does not keep, and the promise is paid for\n * in a jump: the page settles, the scrollbar appears, and whatever the reader\n * was about to click has moved. Measured rather than assumed - the line's own\n * calendar showed a list of four short lines where a six-row month grid was\n * about to land, and the skeleton was itself the jump it existed to prevent.\n *\n * So the useful thing here is not `<Skeleton />` - that is four lines anyone\n * can write - but the shapes: a list, a card, a grid. They are what a product\n * reaches for at the call site, and what keeps the placeholder honest.\n *\n * A spinner is the right answer when the shape is *not* known. A skeleton\n * claims to know; if it does not, say less rather than more.\n *\n * Everything here is `aria-hidden`. A screen reader is told the region is busy\n * by whatever owns the loading state - `QueryState` does it with `aria-busy` -\n * and announcing a dozen empty boxes as content would be noise on top of a\n * fact the reader already has.\n */\n\nexport type SkeletonProps = HTMLAttributes<HTMLDivElement>\n\n/** One block. Size it with `className` - a skeleton is a shape, and the shape\n * belongs to whatever it stands in for. */\nexport function Skeleton({ className, ...props }: SkeletonProps) {\n return (\n <div\n aria-hidden\n className={cn('animate-pulse rounded-md bg-soft', className)}\n {...props}\n />\n )\n}\n\nexport interface SkeletonTextProps extends HTMLAttributes<HTMLDivElement> {\n /** How many lines of text stand here. */\n lines?: number\n}\n\n/** A paragraph's worth of lines.\n *\n * The widths vary, and that is the whole point: a stack of equal bars reads as\n * a loading indicator, while ragged ones read as text. The last line is short,\n * because the last line of a paragraph is. */\nexport function SkeletonText({ lines = 3, className, ...props }: SkeletonTextProps) {\n const widths = ['w-full', 'w-11/12', 'w-4/5', 'w-full', 'w-3/4']\n return (\n <div aria-hidden className={cn('flex flex-col gap-2', className)} {...props}>\n {Array.from({ length: Math.max(1, lines) }, (_, line) => (\n <Skeleton\n key={line}\n className={cn(\n 'h-3',\n line === lines - 1 ? 'w-1/2' : widths[line % widths.length],\n )}\n />\n ))}\n </div>\n )\n}\n\nexport interface SkeletonListProps extends HTMLAttributes<HTMLDivElement> {\n rows?: number\n /** Draw a second, shorter line under each row - for a list whose rows carry\n * a title and something beneath it. */\n secondary?: boolean\n}\n\n/** Rows of a list, which is the shape most screens are waiting for. */\nexport function SkeletonList({\n rows = 5,\n secondary = true,\n className,\n ...props\n}: SkeletonListProps) {\n // Three widths in rotation rather than random: a placeholder that differs\n // between renders is a placeholder that flickers when anything re-renders.\n const widths = ['w-2/3', 'w-4/5', 'w-1/2']\n return (\n <div aria-hidden className={cn('flex flex-col gap-1', className)} {...props}>\n {Array.from({ length: Math.max(1, rows) }, (_, row) => (\n <div key={row} className=\"flex flex-col gap-1.5 px-3 py-2\">\n <Skeleton className={cn('h-3.5', widths[row % widths.length])} />\n {secondary && <Skeleton className=\"h-2.5 w-1/3\" />}\n </div>\n ))}\n </div>\n )\n}\n\nexport interface SkeletonGridProps extends HTMLAttributes<HTMLDivElement> {\n /** How many cells. */\n cells?: number\n /** How many per row. */\n columns?: number\n /** The aspect of one cell, as a Tailwind class - a gallery of covers is not\n * shaped like a grid of tiles. */\n cellClassName?: string\n}\n\n/** A grid of cells: a gallery, a board, a month.\n *\n * Given a cell count and a column count rather than a shape of its own,\n * because the grids a product waits for differ in both and agree in neither. */\nexport function SkeletonGrid({\n cells = 12,\n columns = 4,\n cellClassName = 'aspect-square',\n className,\n ...props\n}: SkeletonGridProps) {\n return (\n <div\n aria-hidden\n // The column count is a style rather than a class, because a class would\n // have to be one of a fixed set - and Tailwind cannot generate\n // `grid-cols-${n}` from a value it never sees.\n style={{ gridTemplateColumns: `repeat(${Math.max(1, columns)}, minmax(0, 1fr))` }}\n className={cn('grid gap-2', className)}\n {...props}\n >\n {Array.from({ length: Math.max(1, cells) }, (_, cell) => (\n <Skeleton key={cell} className={cellClassName} />\n ))}\n </div>\n )\n}\n"
|
|
1188
|
+
}
|
|
1189
|
+
]
|
|
1190
|
+
},
|
|
1077
1191
|
{
|
|
1078
1192
|
"name": "slider",
|
|
1079
1193
|
"type": "registry:ui",
|
|
@@ -1081,7 +1195,7 @@
|
|
|
1081
1195
|
"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\".",
|
|
1082
1196
|
"dependencies": [
|
|
1083
1197
|
"@base-ui/react",
|
|
1084
|
-
"dowel-ui@^0.
|
|
1198
|
+
"dowel-ui@^0.23.0"
|
|
1085
1199
|
],
|
|
1086
1200
|
"registryDependencies": [],
|
|
1087
1201
|
"files": [
|
|
@@ -1100,7 +1214,7 @@
|
|
|
1100
1214
|
"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.",
|
|
1101
1215
|
"dependencies": [
|
|
1102
1216
|
"class-variance-authority",
|
|
1103
|
-
"dowel-ui@^0.
|
|
1217
|
+
"dowel-ui@^0.23.0"
|
|
1104
1218
|
],
|
|
1105
1219
|
"registryDependencies": [],
|
|
1106
1220
|
"files": [
|
|
@@ -1119,7 +1233,7 @@
|
|
|
1119
1233
|
"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.",
|
|
1120
1234
|
"dependencies": [
|
|
1121
1235
|
"@base-ui/react",
|
|
1122
|
-
"dowel-ui@^0.
|
|
1236
|
+
"dowel-ui@^0.23.0"
|
|
1123
1237
|
],
|
|
1124
1238
|
"registryDependencies": [],
|
|
1125
1239
|
"files": [
|
|
@@ -1154,7 +1268,7 @@
|
|
|
1154
1268
|
"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.",
|
|
1155
1269
|
"dependencies": [
|
|
1156
1270
|
"class-variance-authority",
|
|
1157
|
-
"dowel-ui@^0.
|
|
1271
|
+
"dowel-ui@^0.23.0"
|
|
1158
1272
|
],
|
|
1159
1273
|
"registryDependencies": [
|
|
1160
1274
|
"https://lacodda.github.io/dowel/r/table-sort.json"
|
|
@@ -1175,7 +1289,7 @@
|
|
|
1175
1289
|
"description": "Free text turned into a list: type a word, press Enter, it becomes a chip.",
|
|
1176
1290
|
"dependencies": [
|
|
1177
1291
|
"class-variance-authority",
|
|
1178
|
-
"dowel-ui@^0.
|
|
1292
|
+
"dowel-ui@^0.23.0"
|
|
1179
1293
|
],
|
|
1180
1294
|
"registryDependencies": [
|
|
1181
1295
|
"https://lacodda.github.io/dowel/r/chip.json",
|
|
@@ -1196,7 +1310,7 @@
|
|
|
1196
1310
|
"title": "Textarea",
|
|
1197
1311
|
"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.",
|
|
1198
1312
|
"dependencies": [
|
|
1199
|
-
"dowel-ui@^0.
|
|
1313
|
+
"dowel-ui@^0.23.0"
|
|
1200
1314
|
],
|
|
1201
1315
|
"registryDependencies": [
|
|
1202
1316
|
"https://lacodda.github.io/dowel/r/input.json"
|
|
@@ -1216,7 +1330,7 @@
|
|
|
1216
1330
|
"title": "Time-field",
|
|
1217
1331
|
"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`.",
|
|
1218
1332
|
"dependencies": [
|
|
1219
|
-
"dowel-ui@^0.
|
|
1333
|
+
"dowel-ui@^0.23.0"
|
|
1220
1334
|
],
|
|
1221
1335
|
"registryDependencies": [
|
|
1222
1336
|
"https://lacodda.github.io/dowel/r/input.json"
|
|
@@ -1238,7 +1352,7 @@
|
|
|
1238
1352
|
"dependencies": [
|
|
1239
1353
|
"@base-ui/react",
|
|
1240
1354
|
"class-variance-authority",
|
|
1241
|
-
"dowel-ui@^0.
|
|
1355
|
+
"dowel-ui@^0.23.0"
|
|
1242
1356
|
],
|
|
1243
1357
|
"registryDependencies": [],
|
|
1244
1358
|
"files": [
|
|
@@ -1258,7 +1372,7 @@
|
|
|
1258
1372
|
"dependencies": [
|
|
1259
1373
|
"@base-ui/react",
|
|
1260
1374
|
"class-variance-authority",
|
|
1261
|
-
"dowel-ui@^0.
|
|
1375
|
+
"dowel-ui@^0.23.0"
|
|
1262
1376
|
],
|
|
1263
1377
|
"registryDependencies": [],
|
|
1264
1378
|
"files": [
|
|
@@ -1270,13 +1384,49 @@
|
|
|
1270
1384
|
}
|
|
1271
1385
|
]
|
|
1272
1386
|
},
|
|
1387
|
+
{
|
|
1388
|
+
"name": "tree-rows",
|
|
1389
|
+
"type": "registry:ui",
|
|
1390
|
+
"title": "Tree-rows",
|
|
1391
|
+
"description": "Split out of the TreeView for the reason `table-sort` and `calendar-math` were split out of their components: these are the sums, and the component is the thing that draws them. A product that windows a large tree needs to know how many rows it has before rendering any of them - that is this file, and it never touches the DOM.",
|
|
1392
|
+
"dependencies": [],
|
|
1393
|
+
"registryDependencies": [],
|
|
1394
|
+
"files": [
|
|
1395
|
+
{
|
|
1396
|
+
"path": "ui/tree-rows.tsx",
|
|
1397
|
+
"target": "@ui/tree-rows.tsx",
|
|
1398
|
+
"type": "registry:ui",
|
|
1399
|
+
"content": "import type { ReactNode } from 'react'\n\n/*\n * What a tree shows, with no React in it.\n *\n * Split out of the TreeView for the reason `table-sort` and `calendar-math`\n * were split out of their components: these are the sums, and the component is\n * the thing that draws them. A product that windows a large tree needs to know\n * how many rows it has before rendering any of them - that is this file, and\n * it never touches the DOM.\n */\n\nexport interface TreeNode {\n /** Stable identity. Not the label: two folders can hold a file of the same\n * name, and keying by label collapses them into one. */\n id: string\n label: ReactNode\n /** Absent or empty means a leaf. An empty array is deliberately *not* an\n * empty folder - a folder with nothing in it still opens, and saying so\n * needs a real value rather than the absence of one. */\n children?: TreeNode[]\n /** An empty folder: opens, and shows nothing. */\n empty?: boolean\n}\n\n/** One visible row: the node, how deep it sits, and the branch it belongs to. */\nexport interface TreeRow {\n node: TreeNode\n depth: number\n /** The id of the folder this row is inside, if any. What Left uses to get\n * out of a deep branch in one press. */\n parent?: string\n}\n\n/** The rows a tree shows, in the order the eye and the keyboard travel.\n *\n * Flattening is what makes the keyboard simple: Down is the next row of this\n * list, Up the previous, and neither has to know about nesting. A recursive\n * walk at every keystroke would ask the same question - what is visually next\n * - and answer it differently at each depth.\n *\n * It is also the answer to \"how many rows is this tree\", which is what a\n * product needs to put a tree inside a VirtualList. */\nexport function visibleRows(\n nodes: readonly TreeNode[],\n open: ReadonlySet<string>,\n depth = 0,\n parent?: string,\n): TreeRow[] {\n const rows: TreeRow[] = []\n for (const node of nodes) {\n rows.push({ node, depth, parent })\n if (node.children?.length && open.has(node.id)) {\n rows.push(...visibleRows(node.children, open, depth + 1, node.id))\n }\n }\n return rows\n}\n\n/** Whether this node is a folder - something that opens, even if empty. */\nexport function isBranch(node: TreeNode): boolean {\n return Boolean(node.empty) || Boolean(node.children?.length)\n}\n"
|
|
1400
|
+
}
|
|
1401
|
+
]
|
|
1402
|
+
},
|
|
1403
|
+
{
|
|
1404
|
+
"name": "tree-view",
|
|
1405
|
+
"type": "registry:ui",
|
|
1406
|
+
"title": "Tree-view",
|
|
1407
|
+
"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.",
|
|
1408
|
+
"dependencies": [
|
|
1409
|
+
"dowel-ui@^0.23.0"
|
|
1410
|
+
],
|
|
1411
|
+
"registryDependencies": [
|
|
1412
|
+
"https://lacodda.github.io/dowel/r/tree-rows.json"
|
|
1413
|
+
],
|
|
1414
|
+
"files": [
|
|
1415
|
+
{
|
|
1416
|
+
"path": "ui/tree-view.tsx",
|
|
1417
|
+
"target": "@ui/tree-view.tsx",
|
|
1418
|
+
"type": "registry:ui",
|
|
1419
|
+
"content": "import { useMemo, useState, type KeyboardEvent } from 'react'\nimport { cn } from 'dowel-ui'\nimport { isBranch, visibleRows, type TreeNode } from './tree-rows'\n\n/*\n * A tree: folders that open, one tab stop, arrows to move.\n *\n * The shape products reach for and then get wrong in the same place every\n * time. A tree is not a nest of lists with click handlers - it is one control\n * with a cursor in it, and the difference is the whole component:\n *\n * **One tab stop, not one per node.** A tree of four hundred files with a\n * `tabIndex` on each is four hundred stops between the sidebar and the\n * editor. The container is what the keyboard reaches, and the arrows move a\n * cursor inside it - the arrangement a `RadioGroup` has, for the same\n * reason.\n *\n * **Right and left do different things depending on where you are.** Right\n * on a closed folder opens it; on an open one it steps into the first child;\n * on a leaf it does nothing. Left closes an open folder, and on a leaf or a\n * closed one it jumps to the parent - which is how a reader gets out of a\n * deep branch without walking back up through every sibling.\n *\n * Both come from the ARIA tree pattern, and neither is guessable from looking\n * at a finished tree, which is why every hand-rolled one implements Up and\n * Down and stops there.\n *\n * The nodes are given, not discovered: a tree that loads its children when a\n * folder opens is a different component with a different problem (a spinner\n * per node, a request per open, an error state inside a row). This one is\n * handed everything and decides what to show.\n *\n * Which rows are visible is next door in `tree-rows`, with no React in it - a\n * product windowing a large tree imports that and never this.\n */\n\nexport interface TreeViewProps {\n nodes: readonly TreeNode[]\n /** Which folders are open. Uncontrolled if omitted. */\n open?: ReadonlySet<string>\n onOpenChange?: (open: Set<string>) => void\n /** Which row is chosen - the file being edited, not the one the cursor is\n * on. The two move apart: a reader walks the tree with arrows without\n * opening anything until Enter. */\n selected?: string\n onSelect?: (id: string) => void\n /** What a screen reader calls the tree. Required: a bare tree announces\n * \"tree\" and nothing else, and the word belongs to the product. */\n label: string\n className?: string\n}\n\nexport function TreeView({\n nodes,\n open: openProp,\n onOpenChange,\n selected,\n onSelect,\n label,\n className,\n}: TreeViewProps) {\n const [openState, setOpenState] = useState<ReadonlySet<string>>(() => new Set())\n const open = openProp ?? openState\n\n const setOpen = (next: Set<string>) => {\n if (openProp === undefined) setOpenState(next)\n onOpenChange?.(next)\n }\n\n const rows = useMemo(() => visibleRows(nodes, open), [nodes, open])\n\n /* Where the keyboard is, which is not where the selection is. The cursor\n * starts on the selected row when there is one, so arrowing into a tree\n * continues from what is open rather than from the top. */\n const [cursor, setCursor] = useState<string | undefined>(undefined)\n const active = rows.find((row) => row.node.id === cursor)\n ? cursor\n : (rows.find((row) => row.node.id === selected)?.node.id ?? rows[0]?.node.id)\n\n const index = rows.findIndex((row) => row.node.id === active)\n\n const move = (to: number) => {\n const row = rows[Math.min(Math.max(0, to), rows.length - 1)]\n if (row) setCursor(row.node.id)\n }\n\n const toggle = (id: string, next: boolean) => {\n const copy = new Set(open)\n if (next) copy.add(id)\n else copy.delete(id)\n setOpen(copy)\n }\n\n const onKeyDown = (event: KeyboardEvent) => {\n const current = rows[index]\n if (!current) return\n const branch = isBranch(current.node)\n const isOpen = open.has(current.node.id)\n\n switch (event.key) {\n case 'ArrowDown':\n event.preventDefault()\n move(index + 1)\n break\n case 'ArrowUp':\n event.preventDefault()\n move(index - 1)\n break\n case 'ArrowRight':\n event.preventDefault()\n // Open, then step in. Two presses rather than one that does both,\n // because opening a folder and moving into it are different intents -\n // and a reader who wanted only to look inside would have lost the row\n // they were on.\n if (branch && !isOpen) toggle(current.node.id, true)\n else if (branch && isOpen) move(index + 1)\n break\n case 'ArrowLeft': {\n event.preventDefault()\n if (branch && isOpen) {\n toggle(current.node.id, false)\n break\n }\n // Out of the branch rather than up one row. This is the escape from a\n // deep folder, and the part hand-rolled trees leave out: without it\n // the only way back to the parent is through every sibling below.\n const parent = current.parent\n if (parent) setCursor(parent)\n break\n }\n case 'Home':\n event.preventDefault()\n move(0)\n break\n case 'End':\n event.preventDefault()\n move(rows.length - 1)\n break\n case 'Enter':\n case ' ':\n event.preventDefault()\n if (branch) toggle(current.node.id, !isOpen)\n else onSelect?.(current.node.id)\n break\n default:\n break\n }\n }\n\n return (\n <div\n role=\"tree\"\n aria-label={label}\n // One stop for the whole tree. A `tabIndex` per node is how a sidebar of\n // four hundred files becomes four hundred stops before the editor.\n tabIndex={0}\n onKeyDown={onKeyDown}\n // The cursor is published rather than only drawn: this is what a screen\n // reader follows, and what makes the tree one control instead of a\n // collection of rows.\n aria-activedescendant={active ? `tree-${active}` : undefined}\n className={cn(\n 'text-sm',\n 'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent',\n className,\n )}\n >\n {rows.map(({ node, depth }) => {\n const branch = isBranch(node)\n const isOpen = open.has(node.id)\n return (\n <div\n key={node.id}\n id={`tree-${node.id}`}\n role=\"treeitem\"\n // Only on folders. On a leaf it would announce \"collapsed\" for\n // something that never opens.\n aria-expanded={branch ? isOpen : undefined}\n aria-selected={node.id === selected || undefined}\n // One-based, and the level a reader is told - the indent is the\n // sighted half of the same fact.\n aria-level={depth + 1}\n onClick={() => {\n setCursor(node.id)\n if (branch) toggle(node.id, !isOpen)\n else onSelect?.(node.id)\n }}\n className={cn(\n 'flex cursor-pointer items-center gap-1 rounded-sm py-1 pr-2 transition-colors',\n node.id === selected ? 'bg-accent-soft text-text' : 'hover:bg-soft',\n node.id === active && node.id !== selected && 'bg-softer',\n )}\n // Indent by depth. A padding rather than a nested box, because the\n // rows are one flat list - which is what lets the keyboard treat\n // Down as \"the next row\" without knowing about nesting.\n style={{ paddingLeft: `${depth * 16 + 8}px` }}\n >\n <span aria-hidden className={cn('w-3 shrink-0 text-2xs text-dim', !branch && 'opacity-0')}>\n {isOpen ? '▾' : '▸'}\n </span>\n <span className=\"truncate\">{node.label}</span>\n </div>\n )\n })}\n </div>\n )\n}\n"
|
|
1420
|
+
}
|
|
1421
|
+
]
|
|
1422
|
+
},
|
|
1273
1423
|
{
|
|
1274
1424
|
"name": "truncate",
|
|
1275
1425
|
"type": "registry:ui",
|
|
1276
1426
|
"title": "Truncate",
|
|
1277
1427
|
"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.",
|
|
1278
1428
|
"dependencies": [
|
|
1279
|
-
"dowel-ui@^0.
|
|
1429
|
+
"dowel-ui@^0.23.0"
|
|
1280
1430
|
],
|
|
1281
1431
|
"registryDependencies": [],
|
|
1282
1432
|
"files": [
|
|
@@ -1288,6 +1438,24 @@
|
|
|
1288
1438
|
}
|
|
1289
1439
|
]
|
|
1290
1440
|
},
|
|
1441
|
+
{
|
|
1442
|
+
"name": "virtual-list",
|
|
1443
|
+
"type": "registry:ui",
|
|
1444
|
+
"title": "Virtual-list",
|
|
1445
|
+
"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.",
|
|
1446
|
+
"dependencies": [
|
|
1447
|
+
"dowel-ui@^0.23.0"
|
|
1448
|
+
],
|
|
1449
|
+
"registryDependencies": [],
|
|
1450
|
+
"files": [
|
|
1451
|
+
{
|
|
1452
|
+
"path": "ui/virtual-list.tsx",
|
|
1453
|
+
"target": "@ui/virtual-list.tsx",
|
|
1454
|
+
"type": "registry:ui",
|
|
1455
|
+
"content": "import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react'\nimport { cn } from 'dowel-ui'\n\n/*\n * A list of a hundred thousand rows that puts a screenful in the DOM.\n *\n * The browser is fine with long lists until it is not: a hundred thousand\n * `<div>`s is a layout the machine recomputes on every change, and the page\n * stops responding while it does. What is drawn instead is the window the\n * reader can actually see, held in place by a tall spacer, so the scrollbar\n * still says how much there is.\n *\n * No virtualisation library. `react-window`, `react-virtuoso` and\n * `@tanstack/react-virtual` all solve the general problem - variable heights\n * measured at runtime, horizontal windows, grids - and the general problem is\n * not the one a design system has: every long list in a product is a column of\n * rows of one height. That case is the arithmetic below, and it is small\n * enough to read.\n *\n * The arithmetic is exported separately, and that is the part worth stating.\n * A window computed inside an effect can only be tested by a browser, and\n * jsdom gives every element a height of zero - so a test would assert against\n * a list that believes it is invisible, and pass whatever the code did. As\n * plain functions the rules are checkable: given a scroll offset and a height,\n * these rows are drawn.\n *\n * Variable row heights are deliberately not supported. Measuring them means\n * rendering to find out, which means a second pass, a cache, and a scrollbar\n * that changes length as the reader travels. When a product needs that, it\n * needs a library rather than a bigger version of this.\n */\n\nexport interface Window {\n /** Index of the first row to render. */\n start: number\n /** Index one past the last row to render. */\n end: number\n /** The height of the whole list, drawn or not - what makes the scrollbar\n * honest. */\n totalHeight: number\n /** How far down the first drawn row sits. Applied as a translation rather\n * than as a top margin, so the rows are not re-laid-out on every scroll. */\n offsetTop: number\n}\n\nexport interface WindowInput {\n /** How many rows there are in total. */\n count: number\n /** How tall one row is, in pixels. One height for every row. */\n rowHeight: number\n /** How tall the scrolling box is. */\n viewportHeight: number\n /** How far the box is scrolled. */\n scrollTop: number\n /** How many extra rows to draw beyond each edge.\n *\n * Not decoration: without it a fast scroll paints blank where rows have not\n * been drawn yet, because the scroll event arrives after the pixels. Three\n * rows is enough to cover a frame at ordinary speeds and cheap enough not to\n * matter. */\n overscan?: number\n}\n\n/** Which rows to draw. Pure arithmetic, so it can be checked without a browser.\n *\n * Every edge case here is one that shows as a blank list rather than as an\n * error: a viewport of zero (the box has not been measured yet), a scroll\n * position past the end (the list shrank under the reader), a negative one\n * (elastic scrolling on a trackpad). */\nexport function windowFor({\n count,\n rowHeight,\n viewportHeight,\n scrollTop,\n overscan = 3,\n}: WindowInput): Window {\n const rows = Math.max(0, Math.floor(count))\n const height = Math.max(1, rowHeight)\n const totalHeight = rows * height\n\n if (rows === 0) return { start: 0, end: 0, totalHeight: 0, offsetTop: 0 }\n\n // Clamped rather than trusted. A negative scrollTop is what a trackpad hands\n // over at the top of an elastic scroll, and `Math.floor` of it walks the\n // start index backwards into rows that do not exist.\n const top = Math.min(Math.max(0, scrollTop), Math.max(0, totalHeight - 1))\n\n const first = Math.floor(top / height)\n /* One more row than fits, so the row cut by the bottom edge is drawn rather\n * than appearing as the reader scrolls into it.\n *\n * The `+ 1` is also what keeps the first frame from being empty: before the\n * effect measures the box, `viewportHeight` is 0 and this is 1. A\n * `Math.max(1, ...)` around it looks like the guard for that and is dead\n * code - the expression cannot reach zero. Mutation found it: removing the\n * floor changed nothing at all. */\n const visible = Math.ceil(Math.max(0, viewportHeight) / height) + 1\n\n const start = Math.max(0, first - overscan)\n const end = Math.min(rows, first + visible + overscan)\n\n return { start, end, totalHeight, offsetTop: start * height }\n}\n\nexport interface VirtualListProps<Row> {\n rows: readonly Row[]\n /** How tall one row is. Fixed, and the component enforces it - a row that\n * grows past this height overlaps its neighbour, which is why the value is\n * required rather than measured. */\n rowHeight: number\n children: (row: Row, index: number) => ReactNode\n /** A stable identity per row. Without it React keys by index, and a list\n * that is sorted or filtered reuses the wrong DOM node - the row moves but\n * its input keeps the text that was typed into a different row. */\n rowKey: (row: Row, index: number) => string | number\n overscan?: number\n /** What a screen reader calls the list. Required: a scrolling box of rows\n * with no name is announced as \"list\" and nothing else, and the word is the\n * product's to give. */\n label: string\n className?: string\n}\n\nexport function VirtualList<Row>({\n rows,\n rowHeight,\n children,\n rowKey,\n overscan = 3,\n label,\n className,\n}: VirtualListProps<Row>) {\n const viewport = useRef<HTMLDivElement>(null)\n const [scrollTop, setScrollTop] = useState(0)\n const [viewportHeight, setViewportHeight] = useState(0)\n\n const measure = useCallback(() => {\n const element = viewport.current\n if (!element) return\n setViewportHeight(element.clientHeight)\n setScrollTop(element.scrollTop)\n }, [])\n\n useEffect(() => {\n const element = viewport.current\n if (!element) return\n measure()\n\n /* The height is watched, not read once. A list inside a panel that opens,\n * a window the reader resizes, a font that loads late - all change the\n * viewport after mount, and a list that measured once draws the wrong\n * number of rows until something scrolls. */\n if (typeof ResizeObserver === 'undefined') return\n const observer = new ResizeObserver(measure)\n observer.observe(element)\n return () => observer.disconnect()\n }, [measure])\n\n const { start, end, totalHeight, offsetTop } = windowFor({\n count: rows.length,\n rowHeight,\n viewportHeight,\n scrollTop,\n overscan,\n })\n\n return (\n <div\n ref={viewport}\n onScroll={(event) => setScrollTop(event.currentTarget.scrollTop)}\n // `tabIndex` because a box that scrolls has to be reachable: a keyboard\n // user with no focusable row inside cannot otherwise page through it.\n tabIndex={0}\n role=\"list\"\n aria-label={label}\n className={cn(\n 'relative overflow-y-auto',\n 'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent',\n className,\n )}\n >\n {/* The spacer is what the scrollbar measures. Without it the box is only\n * as tall as the rows drawn, and the reader can never scroll past the\n * first screenful. */}\n <div style={{ height: totalHeight }} className=\"relative\">\n <div\n // Translated rather than positioned with `top`: a transform is\n // composited, while changing `top` re-lays-out the subtree on every\n // scroll event.\n style={{ transform: `translateY(${offsetTop}px)` }}\n className=\"absolute inset-x-0 top-0\"\n >\n {rows.slice(start, end).map((row, offset) => {\n const index = start + offset\n return (\n <div\n key={rowKey(row, index)}\n role=\"listitem\"\n /* The pair that makes a window honest to a screen reader.\n *\n * Without them the reader counts the rows it can see and says\n * \"3 of 20\" in a list of a hundred thousand - the rows in the\n * DOM are a screenful, and that is exactly what a virtual list\n * hides. `aria-setsize` is the real total, `aria-posinset` the\n * real position.\n *\n * Deliberately not `aria-rowcount`/`aria-rowindex`, which were\n * the obvious names and are wrong: those belong to `grid`,\n * `table` and `treegrid`. On a `list` they are unsupported -\n * axe rejects them, and a reader would simply ignore them,\n * which is the worse half: the markup would look like it said\n * something. */\n aria-setsize={rows.length}\n aria-posinset={index + 1}\n style={{ height: rowHeight }}\n >\n {children(row, index)}\n </div>\n )\n })}\n </div>\n </div>\n </div>\n )\n}\n"
|
|
1456
|
+
}
|
|
1457
|
+
]
|
|
1458
|
+
},
|
|
1291
1459
|
{
|
|
1292
1460
|
"extends": "none",
|
|
1293
1461
|
"name": "app",
|
package/package.json
CHANGED