dowel-ui 0.19.0 → 0.20.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 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.19.0 - the theme, the scales, an accent per product, forty components - overlays, menus, the command palette 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).
14
+ **Status:** v0.20.0 - the theme, the scales, an accent per product, forty-five components - overlays, menus, the command palette 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
- Forty of them so far. The everyday ones - Button, Input, Textarea, Panel,
101
+ Forty-five 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
@@ -215,6 +215,26 @@
215
215
  }
216
216
  ]
217
217
  },
218
+ {
219
+ "name": "action-bar",
220
+ "type": "registry:ui",
221
+ "title": "Action-bar",
222
+ "description": "The strip of actions that belongs to what is on screen: Save and Cancel at the foot of a form, the bulk actions above a table, the formatting buttons over an editor. `sticky` is the point of it - a long form whose Save button is a thousand pixels below the field being edited has a Save button the reader has to go looking for.",
223
+ "dependencies": [
224
+ "@base-ui/react",
225
+ "class-variance-authority",
226
+ "dowel-ui@^0.20.0"
227
+ ],
228
+ "registryDependencies": [],
229
+ "files": [
230
+ {
231
+ "path": "ui/action-bar.tsx",
232
+ "target": "@ui/action-bar.tsx",
233
+ "type": "registry:ui",
234
+ "content": "import { Toolbar as Base } from '@base-ui/react/toolbar'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { cn } from 'dowel-ui'\n\n/*\n * ActionBar.\n *\n * The strip of actions that belongs to what is on screen: Save and Cancel at\n * the foot of a form, the bulk actions above a table, the formatting buttons\n * over an editor. `sticky` is the point of it - a long form whose Save button\n * is a thousand pixels below the field being edited has a Save button the\n * reader has to go looking for.\n *\n * Built on Base UI's Toolbar, which supplies the part that is easy to get\n * wrong: `role=\"toolbar\"` and arrow-key navigation, so the whole bar is one\n * tab stop and the arrows move between its buttons. That matters more here\n * than anywhere else - an action bar sits at the end of a form, and without it\n * a reader tabbing out of the last field lands in a queue of five buttons\n * instead of on Save.\n *\n * The buttons are the product's own: this draws the strip, not what is in it.\n * `ActionBarButton` exists only to join a `Button` to the toolbar's keyboard\n * handling, via `render`.\n */\n\nexport const actionBarVariants = cva('flex items-center gap-2', {\n variants: {\n /* Where it sits, and therefore which edge grows a border.\n *\n * The border is on the side facing the content, so the bar reads as\n * attached to the page rather than floating over it - and only when it is\n * stuck, because a bar in the middle of a page needs no seam. */\n position: {\n bottom: 'sticky bottom-0 border-t border-line bg-bg/95 py-3 backdrop-blur',\n top: 'sticky top-0 border-b border-line bg-bg/95 py-3 backdrop-blur',\n /* Not stuck at all: a strip in the flow of the page. */\n static: '',\n },\n justify: {\n start: 'justify-start',\n end: 'justify-end',\n between: 'justify-between',\n },\n },\n defaultVariants: { position: 'static', justify: 'start' },\n})\n\nexport interface ActionBarProps extends Base.Root.Props, VariantProps<typeof actionBarVariants> {}\n\n/**\n * The bar.\n *\n * `aria-label` is worth passing when a page has more than one: \"Formatting\"\n * and \"Bulk actions\" are different toolbars, and a screen reader announcing\n * \"toolbar\" twice tells the reader nothing about which one they are in.\n */\nexport function ActionBar({ position, justify, className, ...props }: ActionBarProps) {\n return (\n <Base.Root\n className={cn(actionBarVariants({ position, justify }), className)}\n // A stuck bar has to clear what scrolls under it; the theme's own\n // stacking order is what says how high, rather than a number invented\n // here that would disagree with every overlay in the set.\n style={position === 'bottom' || position === 'top' ? { zIndex: 'var(--z-sticky)' } : undefined}\n {...props}\n />\n )\n}\n\n/** A button in the bar. Give it the set's own `Button` through `render`, so\n * the toolbar's arrow keys reach it and the button keeps its variants. */\nexport const ActionBarButton = Base.Button\n\n/** A rule between groups of actions. Decorative, and announced as a separator\n * rather than as a control. */\nexport function ActionBarSeparator({ className, ...props }: Base.Separator.Props) {\n return <Base.Separator className={cn('mx-1 h-5 w-px shrink-0 bg-line', className)} {...props} />\n}\n\n/** A group of related actions inside the bar - the three alignment buttons, a\n * pair of toggles. Groups the arrows walk as one. */\nexport function ActionBarGroup({ className, ...props }: Base.Group.Props) {\n return <Base.Group className={cn('flex items-center gap-1', className)} {...props} />\n}\n\n/** Pushes what follows to the far end. A plain spacer, so a bar can put Cancel\n * beside Save and everything else to the left without a wrapper. */\nexport function ActionBarSpacer() {\n return <div className=\"flex-1\" aria-hidden />\n}\n"
235
+ }
236
+ ]
237
+ },
218
238
  {
219
239
  "name": "alert",
220
240
  "type": "registry:ui",
@@ -222,7 +242,7 @@
222
242
  "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.",
223
243
  "dependencies": [
224
244
  "class-variance-authority",
225
- "dowel-ui@^0.19.0"
245
+ "dowel-ui@^0.20.0"
226
246
  ],
227
247
  "registryDependencies": [],
228
248
  "files": [
@@ -241,7 +261,7 @@
241
261
  "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.",
242
262
  "dependencies": [
243
263
  "class-variance-authority",
244
- "dowel-ui@^0.19.0"
264
+ "dowel-ui@^0.20.0"
245
265
  ],
246
266
  "registryDependencies": [],
247
267
  "files": [
@@ -260,7 +280,7 @@
260
280
  "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.",
261
281
  "dependencies": [
262
282
  "class-variance-authority",
263
- "dowel-ui@^0.19.0"
283
+ "dowel-ui@^0.20.0"
264
284
  ],
265
285
  "registryDependencies": [],
266
286
  "files": [
@@ -280,7 +300,7 @@
280
300
  "dependencies": [
281
301
  "@base-ui/react",
282
302
  "class-variance-authority",
283
- "dowel-ui@^0.19.0"
303
+ "dowel-ui@^0.20.0"
284
304
  ],
285
305
  "registryDependencies": [],
286
306
  "files": [
@@ -314,7 +334,7 @@
314
334
  "title": "Calendar",
315
335
  "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.",
316
336
  "dependencies": [
317
- "dowel-ui@^0.19.0"
337
+ "dowel-ui@^0.20.0"
318
338
  ],
319
339
  "registryDependencies": [
320
340
  "https://lacodda.github.io/dowel/r/calendar-math.json"
@@ -335,7 +355,7 @@
335
355
  "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.",
336
356
  "dependencies": [
337
357
  "@base-ui/react",
338
- "dowel-ui@^0.19.0"
358
+ "dowel-ui@^0.20.0"
339
359
  ],
340
360
  "registryDependencies": [],
341
361
  "files": [
@@ -354,7 +374,7 @@
354
374
  "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.",
355
375
  "dependencies": [
356
376
  "class-variance-authority",
357
- "dowel-ui@^0.19.0"
377
+ "dowel-ui@^0.20.0"
358
378
  ],
359
379
  "registryDependencies": [],
360
380
  "files": [
@@ -366,6 +386,26 @@
366
386
  }
367
387
  ]
368
388
  },
389
+ {
390
+ "name": "color-field",
391
+ "type": "registry:ui",
392
+ "title": "Color-field",
393
+ "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.",
394
+ "dependencies": [
395
+ "dowel-ui@^0.20.0"
396
+ ],
397
+ "registryDependencies": [
398
+ "https://lacodda.github.io/dowel/r/input.json"
399
+ ],
400
+ "files": [
401
+ {
402
+ "path": "ui/color-field.tsx",
403
+ "target": "@ui/color-field.tsx",
404
+ "type": "registry:ui",
405
+ "content": "import { useId, useState, type ReactNode } from 'react'\nimport { cn, lineProducts } from 'dowel-ui'\nimport { fieldClasses } from './input'\n\n/*\n * ColorField.\n *\n * Picking a colour for something the product stores: a tag, a project, a\n * calendar. Note what that is *not* - it is not choosing the appearance of the\n * interface. The theme decides that, from one accent, and a field that let a\n * reader repaint the chrome would undo the argument the whole system rests on.\n *\n * So the palette comes first and the free field second. The swatches are the\n * line's own fourteen accents plus the four semantic tokens, which is the\n * vocabulary a product already speaks; a value picked there is a *name*\n * (`kilna`, `good`), not a hex, and it keeps meaning the right thing when the\n * theme changes underneath it.\n *\n * Free hex is available because the owner asked for it, and it is worth\n * saying why that does not contradict \"no raw colours in components\": the rule\n * is about a component hardcoding its own appearance. A colour the reader\n * chose for their own tag is the product's *data* - the same kind of thing as\n * the tag's name - and data does not come from a token vocabulary.\n *\n * The swatch is a real `<input type=\"radio\">` per colour, in a `radiogroup`.\n * That is what makes the arrows walk the palette, what makes the choice\n * announce itself, and what makes the whole group one tab stop.\n */\n\n/** The four semantic colours, which mean the same thing in every product. */\nconst SEMANTIC = ['good', 'warn', 'bad', 'info'] as const\n\n/** Whether a string is a colour this field would accept as free input. Six\n * digits or three, with the hash - the two forms a reader actually types. */\nexport function isHexColor(value: string): boolean {\n return /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(value)\n}\n\nexport interface ColorFieldProps {\n /** The chosen colour: a token name, or a hex string. */\n value: string\n onValueChange: (value: string) => void\n /** Whether the reader may type a colour of their own. */\n allowCustom?: boolean\n /** Names the palette for a screen reader. */\n 'aria-label'?: string\n 'aria-labelledby'?: string\n /** How a swatch is named. Takes the colour's name, so the product can say\n * \"Lime\" in its own language rather than shipping ours. */\n swatchLabel?: (name: string) => string\n /** The label on the free-entry box, when `allowCustom` is on. */\n customLabel?: ReactNode\n disabled?: boolean\n className?: string\n}\n\nexport function ColorField({\n value,\n onValueChange,\n allowCustom,\n swatchLabel,\n customLabel,\n disabled,\n className,\n ...aria\n}: ColorFieldProps) {\n const name = useId()\n const customId = useId()\n\n /* What is in the free box while it is being typed.\n *\n * It is not `value`: half of `#3fa9` is not a colour, and pushing every\n * keystroke up would either reject the reader mid-word or paint the product\n * a colour they were passing through. The box commits when what is in it is\n * a colour. */\n const [draft, setDraft] = useState(isHexColor(value) ? value : '')\n\n const swatches = [\n ...lineProducts.map((product) => ({ token: product.name, css: product.accent })),\n ...SEMANTIC.map((token) => ({ token, css: `var(--${token})` })),\n ]\n\n return (\n <div className={cn('flex flex-col gap-2', disabled && 'pointer-events-none opacity-50', className)}>\n <div role=\"radiogroup\" {...aria} className=\"flex flex-wrap gap-1.5\">\n {swatches.map((swatch) => {\n const chosen = value === swatch.token\n return (\n <label\n key={swatch.token}\n className={cn(\n 'relative grid size-7 cursor-pointer place-items-center rounded-md border transition-colors',\n chosen ? 'border-accent' : 'border-line hover:border-dim',\n // The ring has to be on the label: the input itself is hidden,\n // so `focus-visible` on it would never be seen.\n 'focus-within:outline-2 focus-within:outline-offset-1 focus-within:outline-accent',\n )}\n >\n <input\n type=\"radio\"\n name={name}\n value={swatch.token}\n checked={chosen}\n disabled={disabled}\n onChange={() => onValueChange(swatch.token)}\n className=\"sr-only\"\n />\n <span\n aria-hidden\n className=\"size-5 rounded\"\n /* The one place a colour is set from a value rather than a\n * class, because the value *is* the colour - a palette that\n * could not show its own colours would be a list of words. */\n style={{ background: swatch.css }}\n />\n <span className=\"sr-only\">{swatchLabel?.(swatch.token) ?? swatch.token}</span>\n </label>\n )\n })}\n </div>\n\n {allowCustom && (\n <div className=\"flex items-center gap-2\">\n <label htmlFor={customId} className=\"text-xs text-dim\">\n {customLabel}\n </label>\n <input\n id={customId}\n value={draft}\n disabled={disabled}\n /* No placeholder of its own.\n *\n * The obvious one is a specimen hex, and the lint rule that\n * forbids raw colours flags it - correctly, by the letter. Rather\n * than widen a gate that catches real violations for the sake of\n * one string, the format is shown by the monospace box and named\n * by `customLabel`, which is the product's word anyway. */\n spellCheck={false}\n onChange={(event) => {\n const next = event.target.value\n setDraft(next)\n // Committed only once it is a colour. Anything else is a word in\n // progress, and the product should not be repainted by one.\n if (isHexColor(next)) onValueChange(next)\n }}\n className={cn(fieldClasses, 'h-8 w-28 font-mono text-xs')}\n />\n <span\n aria-hidden\n className=\"size-5 shrink-0 rounded border border-line\"\n style={{ background: isHexColor(draft) ? draft : 'transparent' }}\n />\n </div>\n )}\n </div>\n )\n}\n"
406
+ }
407
+ ]
408
+ },
369
409
  {
370
410
  "name": "combobox",
371
411
  "type": "registry:ui",
@@ -374,7 +414,7 @@
374
414
  "dependencies": [
375
415
  "@base-ui/react",
376
416
  "class-variance-authority",
377
- "dowel-ui@^0.19.0"
417
+ "dowel-ui@^0.20.0"
378
418
  ],
379
419
  "registryDependencies": [
380
420
  "https://lacodda.github.io/dowel/r/input.json",
@@ -397,7 +437,7 @@
397
437
  "dependencies": [
398
438
  "@base-ui/react",
399
439
  "class-variance-authority",
400
- "dowel-ui@^0.19.0"
440
+ "dowel-ui@^0.20.0"
401
441
  ],
402
442
  "registryDependencies": [
403
443
  "https://lacodda.github.io/dowel/r/combobox.json",
@@ -420,7 +460,7 @@
420
460
  "dependencies": [
421
461
  "@base-ui/react",
422
462
  "class-variance-authority",
423
- "dowel-ui@^0.19.0"
463
+ "dowel-ui@^0.20.0"
424
464
  ],
425
465
  "registryDependencies": [],
426
466
  "files": [
@@ -439,7 +479,7 @@
439
479
  "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>`.",
440
480
  "dependencies": [
441
481
  "@base-ui/react",
442
- "dowel-ui@^0.19.0"
482
+ "dowel-ui@^0.20.0"
443
483
  ],
444
484
  "registryDependencies": [
445
485
  "https://lacodda.github.io/dowel/r/menu.json"
@@ -459,7 +499,7 @@
459
499
  "title": "Copyable",
460
500
  "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.",
461
501
  "dependencies": [
462
- "dowel-ui@^0.19.0"
502
+ "dowel-ui@^0.20.0"
463
503
  ],
464
504
  "registryDependencies": [],
465
505
  "files": [
@@ -477,7 +517,7 @@
477
517
  "title": "Date-picker",
478
518
  "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.",
479
519
  "dependencies": [
480
- "dowel-ui@^0.19.0"
520
+ "dowel-ui@^0.20.0"
481
521
  ],
482
522
  "registryDependencies": [
483
523
  "https://lacodda.github.io/dowel/r/calendar.json",
@@ -500,7 +540,7 @@
500
540
  "title": "Date-range-picker",
501
541
  "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.",
502
542
  "dependencies": [
503
- "dowel-ui@^0.19.0"
543
+ "dowel-ui@^0.20.0"
504
544
  ],
505
545
  "registryDependencies": [
506
546
  "https://lacodda.github.io/dowel/r/calendar.json",
@@ -525,7 +565,7 @@
525
565
  "dependencies": [
526
566
  "@base-ui/react",
527
567
  "class-variance-authority",
528
- "dowel-ui@^0.19.0"
568
+ "dowel-ui@^0.20.0"
529
569
  ],
530
570
  "registryDependencies": [],
531
571
  "files": [
@@ -545,7 +585,7 @@
545
585
  "dependencies": [
546
586
  "@base-ui/react",
547
587
  "class-variance-authority",
548
- "dowel-ui@^0.19.0"
588
+ "dowel-ui@^0.20.0"
549
589
  ],
550
590
  "registryDependencies": [],
551
591
  "files": [
@@ -563,7 +603,7 @@
563
603
  "title": "Duration-field",
564
604
  "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.",
565
605
  "dependencies": [
566
- "dowel-ui@^0.19.0"
606
+ "dowel-ui@^0.20.0"
567
607
  ],
568
608
  "registryDependencies": [
569
609
  "https://lacodda.github.io/dowel/r/input.json"
@@ -584,7 +624,7 @@
584
624
  "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.",
585
625
  "dependencies": [
586
626
  "@base-ui/react",
587
- "dowel-ui@^0.19.0"
627
+ "dowel-ui@^0.20.0"
588
628
  ],
589
629
  "registryDependencies": [],
590
630
  "files": [
@@ -596,13 +636,31 @@
596
636
  }
597
637
  ]
598
638
  },
639
+ {
640
+ "name": "file-drop",
641
+ "type": "registry:ui",
642
+ "title": "File-drop",
643
+ "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.",
644
+ "dependencies": [
645
+ "dowel-ui@^0.20.0"
646
+ ],
647
+ "registryDependencies": [],
648
+ "files": [
649
+ {
650
+ "path": "ui/file-drop.tsx",
651
+ "target": "@ui/file-drop.tsx",
652
+ "type": "registry:ui",
653
+ "content": "import { useCallback, useId, useRef, useState, type DragEvent, type ReactNode } from 'react'\nimport { cn } from 'dowel-ui'\n\n/*\n * FileDrop.\n *\n * A place to put files: drag them onto it, or press it and pick them. It takes\n * files and hands them over - it does not upload them. Where they go, with\n * which credentials, retried how - that is the product's transport, and a\n * primitive that owned it would be wrong for every product whose upload does\n * not look like the one it guessed.\n *\n * The boundary is the same one `Field` draws around validation: the component\n * knows the shape of the interaction, the product knows what the interaction\n * means.\n *\n * The parts that are easy to get wrong, and are therefore here:\n *\n * - **A real `<input type=\"file\">` underneath.** Not a div with a click\n * handler. The native input is what makes the keyboard work, what the\n * operating system's file picker attaches to, and what a screen reader\n * announces as a file field. It is visually hidden, not `display: none` -\n * hidden that way it is unfocusable, and the label stops reaching it.\n * - **The drag counter.** `dragleave` fires when the pointer crosses onto a\n * *child* of the drop zone, so a zone that toggles on leave flickers as the\n * pointer moves over its own text. Counting enters and leaves is the fix,\n * and it is the single commonest defect in hand-written drop zones.\n * - **`dragover` must be prevented.** Without it the browser navigates to the\n * file instead of dropping it - the drop appears to do nothing, and the page\n * is replaced by a PDF.\n * - **Rejected files are reported, not swallowed.** A file dropped and\n * silently ignored looks like a broken page. What is wrong with it is the\n * product's word, so `onReject` hands back the file and the reason.\n */\n\n/** Why a file was not accepted. The product turns this into a sentence. */\nexport type FileRejection = {\n file: File\n reason: 'type' | 'size' | 'count'\n}\n\nexport interface FileDropProps {\n /** Called with the files that passed the filters. */\n onFiles: (files: File[]) => void\n /** Called with the ones that did not, so the product can say why. */\n onReject?: (rejections: FileRejection[]) => void\n /** What to take, in the form `<input accept>` uses: `image/*`, `.pdf,.docx`. */\n accept?: string\n /** Largest file, in bytes. */\n maxSize?: number\n /** How many at once. Without it, any number. */\n maxFiles?: number\n /** Whether several may be chosen in the picker. */\n multiple?: boolean\n disabled?: boolean\n /** What the zone says. The product's words, in the product's language. */\n children?: ReactNode\n className?: string\n id?: string\n /** Names the field for a screen reader, when there is no visible label. */\n 'aria-label'?: string\n 'aria-labelledby'?: string\n 'aria-describedby'?: string\n}\n\n/** Whether a file matches an `accept` list. Handles the three forms the\n * attribute takes: an extension, a full type, and a wildcard type. */\nfunction matchesAccept(file: File, accept: string | undefined): boolean {\n if (!accept) return true\n return accept\n .split(',')\n .map((entry) => entry.trim().toLowerCase())\n .filter(Boolean)\n .some((entry) => {\n if (entry.startsWith('.')) return file.name.toLowerCase().endsWith(entry)\n if (entry.endsWith('/*')) return file.type.toLowerCase().startsWith(entry.slice(0, -1))\n return file.type.toLowerCase() === entry\n })\n}\n\nexport function FileDrop({\n onFiles,\n onReject,\n accept,\n maxSize,\n maxFiles,\n multiple,\n disabled,\n children,\n className,\n id,\n ...aria\n}: FileDropProps) {\n const inputRef = useRef<HTMLInputElement>(null)\n const generatedId = useId()\n const inputId = id ?? generatedId\n\n /* Nested elements each fire their own enter and leave as the pointer crosses\n * them, so a boolean would flicker while the pointer moves across the zone's\n * own label. The depth counter is what makes \"still inside\" answerable. */\n const [depth, setDepth] = useState(0)\n const over = depth > 0\n\n const take = useCallback(\n (list: FileList | null) => {\n if (!list) return\n const incoming = Array.from(list)\n const accepted: File[] = []\n const rejected: FileRejection[] = []\n\n for (const file of incoming) {\n if (!matchesAccept(file, accept)) rejected.push({ file, reason: 'type' })\n else if (maxSize !== undefined && file.size > maxSize) rejected.push({ file, reason: 'size' })\n else if (maxFiles !== undefined && accepted.length >= maxFiles)\n rejected.push({ file, reason: 'count' })\n else accepted.push(file)\n }\n\n if (accepted.length) onFiles(accepted)\n if (rejected.length) onReject?.(rejected)\n },\n [accept, maxFiles, maxSize, onFiles, onReject],\n )\n\n const onDrop = (event: DragEvent<HTMLDivElement>) => {\n event.preventDefault()\n setDepth(0)\n if (disabled) return\n take(event.dataTransfer.files)\n }\n\n return (\n <div\n onDragEnter={(event) => {\n event.preventDefault()\n setDepth((d) => d + 1)\n }}\n onDragLeave={(event) => {\n event.preventDefault()\n setDepth((d) => Math.max(0, d - 1))\n }}\n // Without preventing this, the browser opens the dropped file instead,\n // and the page the reader was filling in is simply gone.\n onDragOver={(event) => event.preventDefault()}\n onDrop={onDrop}\n // Presentational: the input inside is the control, and wrapping it in a\n // second interactive element would give a screen reader two.\n className={cn(\n 'rounded-lg border border-dashed border-line bg-raise p-6 text-center transition-colors',\n over && !disabled && 'border-accent bg-accent-soft',\n disabled && 'pointer-events-none opacity-50',\n className,\n )}\n >\n <label htmlFor={inputId} className={cn('block', !disabled && 'cursor-pointer')}>\n {children}\n </label>\n <input\n ref={inputRef}\n id={inputId}\n type=\"file\"\n accept={accept}\n multiple={multiple}\n disabled={disabled}\n {...aria}\n onChange={(event) => {\n take(event.target.files)\n // Cleared so that choosing the same file twice in a row still fires\n // a change - otherwise the second attempt silently does nothing.\n event.target.value = ''\n }}\n /* Visually hidden, not `hidden`: it stays focusable, keeps its place\n * in the tab order, and the label above still points at a real\n * control. `display: none` would take all three away. */\n className=\"sr-only\"\n />\n </div>\n )\n}\n"
654
+ }
655
+ ]
656
+ },
599
657
  {
600
658
  "name": "input",
601
659
  "type": "registry:ui",
602
660
  "title": "Input",
603
661
  "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.",
604
662
  "dependencies": [
605
- "dowel-ui@^0.19.0"
663
+ "dowel-ui@^0.20.0"
606
664
  ],
607
665
  "registryDependencies": [],
608
666
  "files": [
@@ -620,7 +678,7 @@
620
678
  "title": "Kbd",
621
679
  "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.",
622
680
  "dependencies": [
623
- "dowel-ui@^0.19.0"
681
+ "dowel-ui@^0.20.0"
624
682
  ],
625
683
  "registryDependencies": [],
626
684
  "files": [
@@ -640,7 +698,7 @@
640
698
  "dependencies": [
641
699
  "@base-ui/react",
642
700
  "class-variance-authority",
643
- "dowel-ui@^0.19.0"
701
+ "dowel-ui@^0.20.0"
644
702
  ],
645
703
  "registryDependencies": [],
646
704
  "files": [
@@ -659,7 +717,7 @@
659
717
  "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.",
660
718
  "dependencies": [
661
719
  "@base-ui/react",
662
- "dowel-ui@^0.19.0"
720
+ "dowel-ui@^0.20.0"
663
721
  ],
664
722
  "registryDependencies": [
665
723
  "https://lacodda.github.io/dowel/r/input.json"
@@ -680,7 +738,7 @@
680
738
  "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.",
681
739
  "dependencies": [
682
740
  "class-variance-authority",
683
- "dowel-ui@^0.19.0"
741
+ "dowel-ui@^0.20.0"
684
742
  ],
685
743
  "registryDependencies": [],
686
744
  "files": [
@@ -698,7 +756,7 @@
698
756
  "title": "Password-field",
699
757
  "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.",
700
758
  "dependencies": [
701
- "dowel-ui@^0.19.0"
759
+ "dowel-ui@^0.20.0"
702
760
  ],
703
761
  "registryDependencies": [
704
762
  "https://lacodda.github.io/dowel/r/input.json"
@@ -720,7 +778,7 @@
720
778
  "dependencies": [
721
779
  "@base-ui/react",
722
780
  "class-variance-authority",
723
- "dowel-ui@^0.19.0"
781
+ "dowel-ui@^0.20.0"
724
782
  ],
725
783
  "registryDependencies": [],
726
784
  "files": [
@@ -740,7 +798,7 @@
740
798
  "dependencies": [
741
799
  "@base-ui/react",
742
800
  "class-variance-authority",
743
- "dowel-ui@^0.19.0"
801
+ "dowel-ui@^0.20.0"
744
802
  ],
745
803
  "registryDependencies": [],
746
804
  "files": [
@@ -760,7 +818,7 @@
760
818
  "dependencies": [
761
819
  "@base-ui/react",
762
820
  "class-variance-authority",
763
- "dowel-ui@^0.19.0"
821
+ "dowel-ui@^0.20.0"
764
822
  ],
765
823
  "registryDependencies": [],
766
824
  "files": [
@@ -778,7 +836,7 @@
778
836
  "title": "Rating-scale",
779
837
  "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\".",
780
838
  "dependencies": [
781
- "dowel-ui@^0.19.0"
839
+ "dowel-ui@^0.20.0"
782
840
  ],
783
841
  "registryDependencies": [],
784
842
  "files": [
@@ -790,13 +848,33 @@
790
848
  }
791
849
  ]
792
850
  },
851
+ {
852
+ "name": "save-state",
853
+ "type": "registry:ui",
854
+ "title": "Save-state",
855
+ "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.",
856
+ "dependencies": [
857
+ "dowel-ui@^0.20.0"
858
+ ],
859
+ "registryDependencies": [
860
+ "https://lacodda.github.io/dowel/r/spinner.json"
861
+ ],
862
+ "files": [
863
+ {
864
+ "path": "ui/save-state.tsx",
865
+ "target": "@ui/save-state.tsx",
866
+ "type": "registry:ui",
867
+ "content": "import { useEffect, useState, type ReactNode } from 'react'\nimport { cn } from 'dowel-ui'\nimport { spinnerVariants } from './spinner'\n\n/*\n * SaveState.\n *\n * The quiet line beside a field that saves itself: \"saving…\", then a tick that\n * fades. It exists because a form without a Save button has to say what it did\n * anyway - otherwise the reader is left guessing whether their edit survived,\n * and the usual answer to that guess is to press Ctrl+S at a page that has no\n * such thing.\n *\n * Taken from kilna, where it has been in production, with the two things a\n * primitive may not keep: its words (they came from the product's i18n, and a\n * component with a string of its own cannot be translated) and its icons.\n *\n * The rules it already got right, and which are the whole reason not to write\n * this fresh:\n *\n * - **The tick decays.** One that never leaves stops meaning \"just now\" and\n * becomes furniture.\n * - **Only a real save earns one.** Mounting next to an idle mutation must not\n * flash a tick for a save that happened before the reader arrived.\n * - **A failure is not a save.** It is announced elsewhere - a toast, an error\n * on the field - and saying \"saved\" underneath that is worse than silence.\n * - **The width is held.** The line sits next to a field; if it grew and shrank\n * with its own text, the layout would twitch on every keystroke's worth of\n * saving.\n */\n\n/** What the indicator is showing. `idle` is invisible but still occupies its\n * width. */\nexport type SaveStatus = 'idle' | 'saving' | 'saved'\n\n/**\n * Turns \"is a mutation in flight\" into the three states this shows.\n *\n * Only the tick is state: `saving` is read straight off the mutation, and the\n * tick decays on its own.\n *\n * @param isPending whether the save is in flight right now\n * @param isError whether the save that just finished failed\n * @param linger how long the tick stays, in milliseconds\n */\nexport function useSaveStatus(isPending: boolean, isError = false, linger = 2000): SaveStatus {\n const [justSaved, setJustSaved] = useState(false)\n // The falling edge of a real save is the only thing worth a tick. Without\n // this, mounting beside an idle mutation would show one immediately, for a\n // save the reader was not there for.\n const [wasPending, setWasPending] = useState(isPending)\n\n // Adjusted during render rather than in an effect: this is state that\n // follows a prop, and `react-hooks/set-state-in-effect` is right to say so.\n if (wasPending !== isPending) {\n setWasPending(isPending)\n setJustSaved(!isPending && !isError)\n }\n\n useEffect(() => {\n if (!justSaved) return\n const timer = setTimeout(() => setJustSaved(false), linger)\n return () => clearTimeout(timer)\n }, [justSaved, linger])\n\n if (isPending) return 'saving'\n return justSaved ? 'saved' : 'idle'\n}\n\nexport interface SaveStateProps {\n /** What to show. Usually from `useSaveStatus`. */\n status: SaveStatus\n /** What to say while saving. The product's word, in the product's language. */\n savingLabel?: ReactNode\n /** What to say once it is saved. */\n savedLabel?: ReactNode\n className?: string\n}\n\n/**\n * The indicator itself.\n *\n * `aria-live=\"polite\"` rather than `assertive`: this is a background fact, and\n * interrupting someone mid-sentence to tell them a field saved is precisely\n * the kind of announcement that makes people turn a screen reader's verbosity\n * down.\n */\nexport function SaveState({ status, savingLabel, savedLabel, className }: SaveStateProps) {\n return (\n <span\n aria-live=\"polite\"\n className={cn(\n 'inline-flex min-w-16 items-center gap-1 text-xs text-faint transition-opacity',\n // Kept in the layout when idle, so the row does not reflow around it.\n status === 'idle' && 'opacity-0',\n className,\n )}\n >\n {status === 'saving' && (\n <>\n {/* The ring, not `Spinner`.\n *\n * `Spinner` carries its own `role=\"status\"` and `aria-live`, which\n * is right when it stands alone and wrong inside this: nesting one\n * live region in another gives a screen reader two things to\n * announce for one event. So the visual is reused through the\n * variants and the announcing is left to the span around it. */}\n <span aria-hidden className={cn(spinnerVariants({ size: 'sm', tone: 'current' }), 'border-r-transparent')} />\n {savingLabel}\n </>\n )}\n {status === 'saved' && (\n <>\n {/* The tick is the only place this component names a colour, and it\n names the token for \"this went well\" rather than a green. */}\n <svg\n viewBox=\"0 0 16 16\"\n aria-hidden=\"true\"\n className=\"size-3 text-good\"\n fill=\"none\"\n stroke=\"currentColor\"\n strokeWidth=\"2\"\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n >\n <path d=\"M3 8.5 6.5 12 13 4.5\" />\n </svg>\n {savedLabel}\n </>\n )}\n </span>\n )\n}\n"
868
+ }
869
+ ]
870
+ },
793
871
  {
794
872
  "name": "search-field",
795
873
  "type": "registry:ui",
796
874
  "title": "Search-field",
797
875
  "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.",
798
876
  "dependencies": [
799
- "dowel-ui@^0.19.0"
877
+ "dowel-ui@^0.20.0"
800
878
  ],
801
879
  "registryDependencies": [
802
880
  "https://lacodda.github.io/dowel/r/input.json",
@@ -820,7 +898,7 @@
820
898
  "dependencies": [
821
899
  "@base-ui/react",
822
900
  "class-variance-authority",
823
- "dowel-ui@^0.19.0"
901
+ "dowel-ui@^0.20.0"
824
902
  ],
825
903
  "registryDependencies": [
826
904
  "https://lacodda.github.io/dowel/r/input.json"
@@ -857,7 +935,7 @@
857
935
  "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\".",
858
936
  "dependencies": [
859
937
  "@base-ui/react",
860
- "dowel-ui@^0.19.0"
938
+ "dowel-ui@^0.20.0"
861
939
  ],
862
940
  "registryDependencies": [],
863
941
  "files": [
@@ -876,7 +954,7 @@
876
954
  "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.",
877
955
  "dependencies": [
878
956
  "class-variance-authority",
879
- "dowel-ui@^0.19.0"
957
+ "dowel-ui@^0.20.0"
880
958
  ],
881
959
  "registryDependencies": [],
882
960
  "files": [
@@ -895,7 +973,7 @@
895
973
  "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.",
896
974
  "dependencies": [
897
975
  "@base-ui/react",
898
- "dowel-ui@^0.19.0"
976
+ "dowel-ui@^0.20.0"
899
977
  ],
900
978
  "registryDependencies": [],
901
979
  "files": [
@@ -907,13 +985,35 @@
907
985
  }
908
986
  ]
909
987
  },
988
+ {
989
+ "name": "tag-input",
990
+ "type": "registry:ui",
991
+ "title": "Tag-input",
992
+ "description": "Free text turned into a list: type a word, press Enter, it becomes a chip.",
993
+ "dependencies": [
994
+ "class-variance-authority",
995
+ "dowel-ui@^0.20.0"
996
+ ],
997
+ "registryDependencies": [
998
+ "https://lacodda.github.io/dowel/r/chip.json",
999
+ "https://lacodda.github.io/dowel/r/input.json"
1000
+ ],
1001
+ "files": [
1002
+ {
1003
+ "path": "ui/tag-input.tsx",
1004
+ "target": "@ui/tag-input.tsx",
1005
+ "type": "registry:ui",
1006
+ "content": "import { useCallback, useState, type KeyboardEvent, type ReactNode } from 'react'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { cn } from 'dowel-ui'\nimport { fieldClasses } from './input'\nimport { Chip } from './chip'\n\n/*\n * TagInput.\n *\n * Free text turned into a list: type a word, press Enter, it becomes a chip.\n * This is the field for labels, keywords, recipients - anywhere the set of\n * values is not known in advance, which is exactly what separates it from\n * `Combobox multiple`. Choosing from a list is that component's job; this one\n * is for values that do not exist until someone types them.\n *\n * Base UI has no part for this, and the reason is worth stating: its Combobox\n * chips are for values chosen from a collection, and \"create the thing I just\n * typed\" is a decision about the product's data, not about the widget. So the\n * behaviour here is written, and written narrowly.\n *\n * What it does, and why each one is not optional:\n *\n * - **Enter commits.** The obvious one, and the reason it is a field at all.\n * - **Backspace on an empty input removes the last tag.** Without it the only\n * way back is the mouse, and a list built by typing should be unbuildable\n * by typing too.\n * - **Blur commits what was typed.** A word left in the box when the reader\n * clicks Save is a word they believe they entered. Losing it silently is\n * the single commonest complaint about fields of this shape.\n * - **Duplicates are refused, not stacked.** A tag list is a set.\n * - **Whitespace is trimmed and empties dropped**, so Enter on a blank input\n * does nothing rather than adding a tag nobody can see.\n *\n * What it deliberately does not do: validate. What makes a tag acceptable -\n * length, charset, whether it must already exist - is the product's rule, and\n * a component that guessed would be wrong in both directions. Reject a value\n * by not putting it in `value`.\n */\n\nexport const tagInputVariants = cva(\n cn(\n fieldClasses,\n 'flex flex-wrap items-center gap-1 py-1',\n 'focus-within:outline-2 focus-within:outline-offset-0 focus-within:outline-accent',\n ),\n {\n variants: {\n size: {\n sm: 'min-h-8 text-xs',\n md: 'min-h-9',\n lg: 'min-h-10 text-base',\n },\n },\n defaultVariants: { size: 'md' },\n },\n)\n\n/* The box inside the box.\n *\n * `min-h-*` on the container sets a floor; the input inside it has a height of\n * its own, and that is what a reader actually sees. A size that moved only the\n * container moved nothing - measured, `sm` and `md` both came out 38px tall,\n * because the input was `h-7` in all three and its padding decided the rest.\n *\n * `tailwind-merge` does not rescue this either: `h-7` and `min-h-8` are not\n * the same property, so both survive and the fixed height wins. The two have\n * to be declared together.\n */\nconst tagInputBoxVariants = cva(\n 'w-auto min-w-24 flex-1 bg-transparent px-1 text-text outline-none placeholder:text-faint',\n {\n variants: {\n size: {\n sm: 'h-6 text-xs',\n md: 'h-7 text-sm',\n lg: 'h-8 text-base',\n },\n },\n defaultVariants: { size: 'md' },\n },\n)\n\nexport interface TagInputProps extends VariantProps<typeof tagInputVariants> {\n /** The tags. Controlled: the product owns the list. */\n value: string[]\n /** Called with the next list. */\n onValueChange: (value: string[]) => void\n /** Placeholder for the text box, shown when there is room for it. */\n placeholder?: string\n /** Names the field. Required - a box of chips with no label names nothing. */\n 'aria-label'?: string\n /** Or points at a label element, when there is a visible one. */\n 'aria-labelledby'?: string\n /** How a tag's remove button is named, for a screen reader. Takes the tag,\n * so the product can say \"Remove documentation\" in its own language.\n *\n * Required, like `Chip`'s own: a remove button exists only when there is a\n * word for it, and a word this component invented would ship in English to\n * every reader who does not read English. */\n removeLabel: (tag: string) => string\n /** Cap on how many tags may be added. */\n max?: number\n disabled?: boolean\n /** Drawn after the input, inside the box - a counter, a hint. */\n children?: ReactNode\n className?: string\n id?: string\n}\n\nexport function TagInput({\n value,\n onValueChange,\n placeholder,\n removeLabel,\n max,\n disabled,\n size,\n children,\n className,\n id,\n ...aria\n}: TagInputProps) {\n const [draft, setDraft] = useState('')\n\n const full = max !== undefined && value.length >= max\n\n const commit = useCallback(\n (raw: string) => {\n const tag = raw.trim()\n // Empty, duplicate, or past the cap: nothing to add. The draft is\n // cleared either way, so Enter always leaves the box ready for the next\n // word rather than leaving a rejected one sitting there.\n if (tag && !value.includes(tag) && !full) onValueChange([...value, tag])\n setDraft('')\n },\n [full, onValueChange, value],\n )\n\n const onKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {\n if (event.key === 'Enter') {\n // Only when there is something to commit: otherwise Enter belongs to the\n // form around this, and swallowing it would break submitting by keyboard.\n if (draft.trim()) {\n event.preventDefault()\n commit(draft)\n }\n return\n }\n if (event.key === 'Backspace' && draft === '' && value.length > 0) {\n onValueChange(value.slice(0, -1))\n }\n }\n\n return (\n <div\n className={cn(tagInputVariants({ size }), disabled && 'pointer-events-none opacity-50', className)}\n >\n {value.map((tag) => (\n <Chip\n key={tag}\n removeLabel={removeLabel(tag)}\n onRemove={() => onValueChange(value.filter((entry) => entry !== tag))}\n >\n {tag}\n </Chip>\n ))}\n <input\n id={id}\n value={draft}\n disabled={disabled}\n // The list is the value; the box is how you add to it. Announcing the\n // box as the field would name the wrong thing, so the label goes here\n // where a screen reader meets it.\n {...aria}\n placeholder={full ? undefined : placeholder}\n onChange={(event) => setDraft(event.target.value)}\n onKeyDown={onKeyDown}\n // A word typed and not committed is a word the reader thinks they\n // entered. Blur is where that belief meets the form's Save button.\n onBlur={() => commit(draft)}\n className={tagInputBoxVariants({ size })}\n />\n {children}\n </div>\n )\n}\n"
1007
+ }
1008
+ ]
1009
+ },
910
1010
  {
911
1011
  "name": "textarea",
912
1012
  "type": "registry:ui",
913
1013
  "title": "Textarea",
914
1014
  "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.",
915
1015
  "dependencies": [
916
- "dowel-ui@^0.19.0"
1016
+ "dowel-ui@^0.20.0"
917
1017
  ],
918
1018
  "registryDependencies": [
919
1019
  "https://lacodda.github.io/dowel/r/input.json"
@@ -933,7 +1033,7 @@
933
1033
  "title": "Time-field",
934
1034
  "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`.",
935
1035
  "dependencies": [
936
- "dowel-ui@^0.19.0"
1036
+ "dowel-ui@^0.20.0"
937
1037
  ],
938
1038
  "registryDependencies": [
939
1039
  "https://lacodda.github.io/dowel/r/input.json"
@@ -955,7 +1055,7 @@
955
1055
  "dependencies": [
956
1056
  "@base-ui/react",
957
1057
  "class-variance-authority",
958
- "dowel-ui@^0.19.0"
1058
+ "dowel-ui@^0.20.0"
959
1059
  ],
960
1060
  "registryDependencies": [],
961
1061
  "files": [
@@ -975,7 +1075,7 @@
975
1075
  "dependencies": [
976
1076
  "@base-ui/react",
977
1077
  "class-variance-authority",
978
- "dowel-ui@^0.19.0"
1078
+ "dowel-ui@^0.20.0"
979
1079
  ],
980
1080
  "registryDependencies": [],
981
1081
  "files": [
@@ -993,7 +1093,7 @@
993
1093
  "title": "Truncate",
994
1094
  "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.",
995
1095
  "dependencies": [
996
- "dowel-ui@^0.19.0"
1096
+ "dowel-ui@^0.20.0"
997
1097
  ],
998
1098
  "registryDependencies": [],
999
1099
  "files": [
@@ -1035,18 +1135,24 @@
1035
1135
  "title": "dowel forms",
1036
1136
  "description": "What a form is made of: the fields, the two ways of choosing from a list, and the button that submits it.",
1037
1137
  "registryDependencies": [
1138
+ "https://lacodda.github.io/dowel/r/action-bar.json",
1038
1139
  "https://lacodda.github.io/dowel/r/button.json",
1039
1140
  "https://lacodda.github.io/dowel/r/checkbox.json",
1040
1141
  "https://lacodda.github.io/dowel/r/chip.json",
1142
+ "https://lacodda.github.io/dowel/r/color-field.json",
1041
1143
  "https://lacodda.github.io/dowel/r/combobox.json",
1042
1144
  "https://lacodda.github.io/dowel/r/duration-field.json",
1043
1145
  "https://lacodda.github.io/dowel/r/field.json",
1146
+ "https://lacodda.github.io/dowel/r/file-drop.json",
1044
1147
  "https://lacodda.github.io/dowel/r/input.json",
1045
1148
  "https://lacodda.github.io/dowel/r/number-field.json",
1046
1149
  "https://lacodda.github.io/dowel/r/password-field.json",
1047
1150
  "https://lacodda.github.io/dowel/r/radio-group.json",
1151
+ "https://lacodda.github.io/dowel/r/save-state.json",
1048
1152
  "https://lacodda.github.io/dowel/r/select.json",
1153
+ "https://lacodda.github.io/dowel/r/spinner.json",
1049
1154
  "https://lacodda.github.io/dowel/r/switch.json",
1155
+ "https://lacodda.github.io/dowel/r/tag-input.json",
1050
1156
  "https://lacodda.github.io/dowel/r/textarea.json"
1051
1157
  ],
1052
1158
  "docs": "Field wraps any of the controls: it is what ties a label, a hint and an error to the thing they belong to. Slider and RatingScale are not in this set - install them where the question calls for one."
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dowel-ui",
3
- "version": "0.19.0",
3
+ "version": "0.20.0",
4
4
  "description": "The lacodda line design system: theme tokens and React primitives, distributed as a shadcn-compatible registry.",
5
5
  "license": "MIT",
6
6
  "type": "module",