dowel-ui 0.15.0 → 0.17.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 +10 -7
- package/dist/registry.json +205 -26
- 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.17.0 - the theme, the scales, an accent per product, thirty-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. 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,12 +98,15 @@ 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
|
-
|
|
102
|
-
Badge, Chip, Kbd, Spinner, Truncate and Copyable;
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
101
|
+
Thirty-five of them so far. The everyday ones - Button, Input, Textarea, Panel,
|
|
102
|
+
Badge, Chip, Kbd, Spinner, Truncate and Copyable; Field and the three controls
|
|
103
|
+
that answer a question - Checkbox, RadioGroup and Switch; five for a number or
|
|
104
|
+
a judgement - NumberField, Slider, RatingScale, DurationField and
|
|
105
|
+
PasswordField; the six that float above the page - Dialog, ConfirmDialog,
|
|
106
|
+
Drawer, Popover, PreviewCard and Tooltip; four for choosing something - Menu,
|
|
107
|
+
ContextMenu, Select and Combobox; three for finding it - SearchField,
|
|
108
|
+
CommandPalette and the `useShortcut` behind them; and three for saying that
|
|
109
|
+
something happened: Toast, Alert and Banner.
|
|
107
110
|
|
|
108
111
|
Those last three are the ones products keep confusing, so each page names all
|
|
109
112
|
four options: a **toast** goes away, an **alert** is still true after a reload,
|
package/dist/registry.json
CHANGED
|
@@ -222,7 +222,7 @@
|
|
|
222
222
|
"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
223
|
"dependencies": [
|
|
224
224
|
"class-variance-authority",
|
|
225
|
-
"dowel-ui@^0.
|
|
225
|
+
"dowel-ui@^0.17.0"
|
|
226
226
|
],
|
|
227
227
|
"registryDependencies": [],
|
|
228
228
|
"files": [
|
|
@@ -241,7 +241,7 @@
|
|
|
241
241
|
"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
242
|
"dependencies": [
|
|
243
243
|
"class-variance-authority",
|
|
244
|
-
"dowel-ui@^0.
|
|
244
|
+
"dowel-ui@^0.17.0"
|
|
245
245
|
],
|
|
246
246
|
"registryDependencies": [],
|
|
247
247
|
"files": [
|
|
@@ -260,7 +260,7 @@
|
|
|
260
260
|
"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
261
|
"dependencies": [
|
|
262
262
|
"class-variance-authority",
|
|
263
|
-
"dowel-ui@^0.
|
|
263
|
+
"dowel-ui@^0.17.0"
|
|
264
264
|
],
|
|
265
265
|
"registryDependencies": [],
|
|
266
266
|
"files": [
|
|
@@ -280,7 +280,7 @@
|
|
|
280
280
|
"dependencies": [
|
|
281
281
|
"@base-ui/react",
|
|
282
282
|
"class-variance-authority",
|
|
283
|
-
"dowel-ui@^0.
|
|
283
|
+
"dowel-ui@^0.17.0"
|
|
284
284
|
],
|
|
285
285
|
"registryDependencies": [],
|
|
286
286
|
"files": [
|
|
@@ -292,6 +292,25 @@
|
|
|
292
292
|
}
|
|
293
293
|
]
|
|
294
294
|
},
|
|
295
|
+
{
|
|
296
|
+
"name": "checkbox",
|
|
297
|
+
"type": "registry:ui",
|
|
298
|
+
"title": "Checkbox",
|
|
299
|
+
"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.",
|
|
300
|
+
"dependencies": [
|
|
301
|
+
"@base-ui/react",
|
|
302
|
+
"dowel-ui@^0.17.0"
|
|
303
|
+
],
|
|
304
|
+
"registryDependencies": [],
|
|
305
|
+
"files": [
|
|
306
|
+
{
|
|
307
|
+
"path": "ui/checkbox.tsx",
|
|
308
|
+
"target": "@ui/checkbox.tsx",
|
|
309
|
+
"type": "registry:ui",
|
|
310
|
+
"content": "import type { ReactNode } from 'react'\nimport { Checkbox as Base } from '@base-ui/react/checkbox'\nimport { CheckboxGroup as BaseGroup } from '@base-ui/react/checkbox-group'\nimport { cn } from 'dowel-ui'\n\n/*\n * Checkbox - a box, its tick, and the words next to it.\n *\n * The interesting part is the words. A checkbox on its own is a nine-pixel\n * target that says nothing; wired to a label it is the whole row, and the row\n * is what a finger and a pointer both aim at. So the label is part of the\n * component rather than something a caller remembers to add - the commonest\n * bug in a hand-rolled checkbox is a `<label>` that is next to the input\n * instead of tied to it, which looks identical and does nothing.\n *\n * The box is drawn rather than native. `appearance: none` on a real\n * `<input type=checkbox>` is the other way, and it takes the indeterminate\n * state with it: the dash is not a character the input can be told to draw.\n * Base UI renders a button with the right role and state, and the tick and\n * the dash are ours, in the accent, sized to the text next to them.\n *\n * `indeterminate` is a real state, not a third value: it says \"some of the\n * things below are checked\", and clicking still means check-all. A tri-state\n * value would make every caller handle a case that does not exist.\n */\n\nexport interface CheckboxProps {\n /** The words next to the box. Omit only for a checkbox in a table cell,\n * and then give `aria-label` instead. */\n children?: ReactNode\n checked?: boolean\n defaultChecked?: boolean\n onCheckedChange?: (checked: boolean) => void\n /** Some but not all of what this stands for is checked. Draws a dash;\n * clicking still checks everything. */\n indeterminate?: boolean\n /** This is the box that stands for the whole group. Inside a\n * `CheckboxGroup` with `allValues`, its state follows the children - checked,\n * unchecked, or the dash in between - and clicking it sets all of them.\n * Working that out by hand is where the indeterminate state usually goes\n * wrong, so the group does it. */\n parent?: boolean\n disabled?: boolean\n required?: boolean\n name?: string\n value?: string\n 'aria-label'?: string\n className?: string\n}\n\nexport function Checkbox({\n children,\n indeterminate = false,\n className,\n ...props\n}: CheckboxProps) {\n const box = (\n <Base.Root\n indeterminate={indeterminate}\n className={cn(\n // `group` is what the two marks below hang their state off: the\n // indeterminate one belongs to the element, not to a prop.\n 'group flex size-4 shrink-0 items-center justify-center rounded-xs border border-line-2 bg-transparent',\n 'transition-colors',\n 'hover:border-accent',\n 'data-[checked]:border-accent data-[checked]:bg-accent',\n 'data-[indeterminate]:border-accent data-[indeterminate]:bg-accent',\n 'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent',\n 'disabled:cursor-not-allowed disabled:opacity-50',\n // Without a label the box is the whole control, so the caller's\n // classes land on it.\n children === undefined && className,\n )}\n {...props}\n >\n {/* Both marks are rendered and the state picks one, rather than the\n * `indeterminate` prop picking it. A parent checkbox never receives\n * that prop - the group works its state out from the children and says\n * so on the element - so choosing in JavaScript would leave the parent\n * drawing a tick while announcing `mixed`. */}\n <Base.Indicator\n className=\"flex text-on-accent group-data-[indeterminate]:hidden\"\n render={<span />}\n >\n <svg viewBox=\"0 0 16 16\" className=\"size-3\" aria-hidden>\n <path\n d=\"M3.5 8.5l3 3 6-6.5\"\n fill=\"none\"\n stroke=\"currentColor\"\n strokeWidth=\"2.5\"\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n />\n </svg>\n </Base.Indicator>\n <span className=\"hidden text-on-accent group-data-[indeterminate]:flex\" aria-hidden>\n <svg viewBox=\"0 0 16 16\" className=\"size-3\">\n <path d=\"M4 8h8\" stroke=\"currentColor\" strokeWidth=\"2.5\" strokeLinecap=\"round\" />\n </svg>\n </span>\n </Base.Root>\n )\n\n if (children === undefined) return box\n\n return (\n <label\n className={cn(\n 'flex cursor-pointer items-center gap-2 text-sm text-text',\n 'has-[:disabled]:cursor-not-allowed has-[:disabled]:opacity-50',\n className,\n )}\n >\n {box}\n {children}\n </label>\n )\n}\n\n/*\n * A group of checkboxes that share a name and a value.\n *\n * It exists for the parent checkbox: given `allValues`, Base UI works out\n * whether the parent is checked, unchecked or indeterminate, and clicking it\n * sets all of them. Doing that by hand is where the indeterminate state\n * usually goes wrong.\n */\nexport interface CheckboxGroupProps {\n children: ReactNode\n value?: string[]\n defaultValue?: string[]\n onValueChange?: (value: string[]) => void\n /** Every value in the group, which is what makes a parent checkbox work. */\n allValues?: string[]\n disabled?: boolean\n className?: string\n}\n\nexport function CheckboxGroup({ children, className, ...props }: CheckboxGroupProps) {\n return (\n <BaseGroup className={cn('flex flex-col gap-2', className)} {...props}>\n {children}\n </BaseGroup>\n )\n}\n"
|
|
311
|
+
}
|
|
312
|
+
]
|
|
313
|
+
},
|
|
295
314
|
{
|
|
296
315
|
"name": "chip",
|
|
297
316
|
"type": "registry:ui",
|
|
@@ -299,7 +318,7 @@
|
|
|
299
318
|
"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.",
|
|
300
319
|
"dependencies": [
|
|
301
320
|
"class-variance-authority",
|
|
302
|
-
"dowel-ui@^0.
|
|
321
|
+
"dowel-ui@^0.17.0"
|
|
303
322
|
],
|
|
304
323
|
"registryDependencies": [],
|
|
305
324
|
"files": [
|
|
@@ -319,7 +338,7 @@
|
|
|
319
338
|
"dependencies": [
|
|
320
339
|
"@base-ui/react",
|
|
321
340
|
"class-variance-authority",
|
|
322
|
-
"dowel-ui@^0.
|
|
341
|
+
"dowel-ui@^0.17.0"
|
|
323
342
|
],
|
|
324
343
|
"registryDependencies": [
|
|
325
344
|
"https://lacodda.github.io/dowel/r/input.json",
|
|
@@ -342,7 +361,7 @@
|
|
|
342
361
|
"dependencies": [
|
|
343
362
|
"@base-ui/react",
|
|
344
363
|
"class-variance-authority",
|
|
345
|
-
"dowel-ui@^0.
|
|
364
|
+
"dowel-ui@^0.17.0"
|
|
346
365
|
],
|
|
347
366
|
"registryDependencies": [
|
|
348
367
|
"https://lacodda.github.io/dowel/r/combobox.json",
|
|
@@ -365,7 +384,7 @@
|
|
|
365
384
|
"dependencies": [
|
|
366
385
|
"@base-ui/react",
|
|
367
386
|
"class-variance-authority",
|
|
368
|
-
"dowel-ui@^0.
|
|
387
|
+
"dowel-ui@^0.17.0"
|
|
369
388
|
],
|
|
370
389
|
"registryDependencies": [],
|
|
371
390
|
"files": [
|
|
@@ -384,7 +403,7 @@
|
|
|
384
403
|
"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>`.",
|
|
385
404
|
"dependencies": [
|
|
386
405
|
"@base-ui/react",
|
|
387
|
-
"dowel-ui@^0.
|
|
406
|
+
"dowel-ui@^0.17.0"
|
|
388
407
|
],
|
|
389
408
|
"registryDependencies": [
|
|
390
409
|
"https://lacodda.github.io/dowel/r/menu.json"
|
|
@@ -404,7 +423,7 @@
|
|
|
404
423
|
"title": "Copyable",
|
|
405
424
|
"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.",
|
|
406
425
|
"dependencies": [
|
|
407
|
-
"dowel-ui@^0.
|
|
426
|
+
"dowel-ui@^0.17.0"
|
|
408
427
|
],
|
|
409
428
|
"registryDependencies": [],
|
|
410
429
|
"files": [
|
|
@@ -424,7 +443,7 @@
|
|
|
424
443
|
"dependencies": [
|
|
425
444
|
"@base-ui/react",
|
|
426
445
|
"class-variance-authority",
|
|
427
|
-
"dowel-ui@^0.
|
|
446
|
+
"dowel-ui@^0.17.0"
|
|
428
447
|
],
|
|
429
448
|
"registryDependencies": [],
|
|
430
449
|
"files": [
|
|
@@ -444,7 +463,7 @@
|
|
|
444
463
|
"dependencies": [
|
|
445
464
|
"@base-ui/react",
|
|
446
465
|
"class-variance-authority",
|
|
447
|
-
"dowel-ui@^0.
|
|
466
|
+
"dowel-ui@^0.17.0"
|
|
448
467
|
],
|
|
449
468
|
"registryDependencies": [],
|
|
450
469
|
"files": [
|
|
@@ -456,13 +475,52 @@
|
|
|
456
475
|
}
|
|
457
476
|
]
|
|
458
477
|
},
|
|
478
|
+
{
|
|
479
|
+
"name": "duration-field",
|
|
480
|
+
"type": "registry:ui",
|
|
481
|
+
"title": "Duration-field",
|
|
482
|
+
"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.",
|
|
483
|
+
"dependencies": [
|
|
484
|
+
"dowel-ui@^0.17.0"
|
|
485
|
+
],
|
|
486
|
+
"registryDependencies": [
|
|
487
|
+
"https://lacodda.github.io/dowel/r/input.json"
|
|
488
|
+
],
|
|
489
|
+
"files": [
|
|
490
|
+
{
|
|
491
|
+
"path": "ui/duration-field.tsx",
|
|
492
|
+
"target": "@ui/duration-field.tsx",
|
|
493
|
+
"type": "registry:ui",
|
|
494
|
+
"content": "import { useState, type Ref } from 'react'\nimport { cn } from 'dowel-ui'\nimport { fieldClasses } from './input'\n\n/*\n * DurationField - a length of time, typed the way people say it.\n *\n * The alternative is what products keep building: two number boxes labelled\n * \"hours\" and \"minutes\", which means two tab stops, two validations, and a\n * reader who has to divide 90 minutes in their head before typing. Here they\n * write `1h 30m`, or `90m`, or `1.5h`, and it means the same thing.\n *\n * The value is **minutes**, a plain number. Not a string, not a Duration\n * object: the products of this line store durations as minutes already, and\n * a field whose value has to be parsed by its caller has moved the problem\n * rather than solved it.\n *\n * Parsing is deliberately generous and formatting is strict. Anything a\n * person plausibly types is accepted - `2h`, `2 h`, `2:30`, `150`, `2h30`,\n * with or without spaces - and what comes back on blur is always the one\n * canonical spelling. That asymmetry is the whole design: being strict on\n * input means rejecting people, being loose on output means the column of\n * values never lines up.\n *\n * Empty is `null`, like NumberField: \"no duration\" and \"zero minutes\" are\n * different answers, and a task with no estimate is not a task estimated at\n * nothing.\n */\n\n/** Minutes from whatever was typed, or `null` for empty, or `undefined` when\n * it cannot be read as a duration at all.\n *\n * Exported because the parsing *is* the component - a test that goes through\n * the DOM checks React's state handling, and what needs checking is this. */\nexport function parseDuration(text: string): number | null | undefined {\n const input = text.trim().toLowerCase()\n if (input === '') return null\n\n // `2:30` - the clock spelling, which is unambiguous and worth taking.\n const clock = /^(\\d+):([0-5]?\\d)$/.exec(input)\n if (clock) return Number(clock[1]) * 60 + Number(clock[2])\n\n // A bare number is minutes. `90` is an hour and a half, not ninety hours:\n // the field is most often used for something that takes minutes, and a\n // reader typing hours writes the `h`.\n if (/^\\d+(?:[.,]\\d+)?$/.test(input)) return Math.round(Number(input.replace(',', '.')))\n\n /* The general form: any number of `<number><unit>` pairs. A decimal is\n * allowed on the hours (`1.5h`) because people write it, and a comma counts\n * as a decimal point because half the world uses one. */\n const pattern = /(\\d+(?:[.,]\\d+)?)\\s*([hm])/g\n let total = 0\n let matched = false\n let consumed = 0\n\n for (const match of input.matchAll(pattern)) {\n matched = true\n consumed += match[0].length\n const amount = Number(match[1]!.replace(',', '.'))\n total += match[2] === 'h' ? amount * 60 : amount\n }\n\n // Everything that is not whitespace has to have been part of a pair -\n // otherwise `1h banana` would quietly parse as an hour.\n if (!matched || consumed !== input.replace(/\\s+/g, '').length) return undefined\n\n return Math.round(total)\n}\n\n/** The one spelling a duration is written back as: `1h 30m`, `45m`, `2h`.\n *\n * Zero is `0m` rather than blank, because a duration of zero is an answer -\n * blank is what `null` renders as. */\nexport function formatDuration(minutes: number): string {\n const whole = Math.max(0, Math.round(minutes))\n const hours = Math.floor(whole / 60)\n const rest = whole % 60\n if (hours === 0) return `${rest}m`\n if (rest === 0) return `${hours}h`\n return `${hours}h ${rest}m`\n}\n\nexport interface DurationFieldProps {\n /** Minutes, or `null` for empty. */\n value: number | null\n onValueChange: (value: number | null) => void\n /** What a reader sees before they type. A duration in the canonical\n * spelling is the best hint there is, so this is the product's to give. */\n placeholder?: string\n disabled?: boolean\n readOnly?: boolean\n required?: boolean\n name?: string\n id?: string\n ref?: Ref<HTMLInputElement>\n 'aria-label'?: string\n 'aria-describedby'?: string\n className?: string\n}\n\nexport function DurationField({\n value,\n onValueChange,\n className,\n ref,\n ...props\n}: DurationFieldProps) {\n /* The field holds text while it is being typed and a number the rest of the\n * time. Without the local copy, typing `1h 3` would reformat under the\n * cursor after every keystroke - the classic controlled-input-with-parsing\n * bug, where the field fights the person using it. */\n const [text, setText] = useState(() => (value === null ? '' : formatDuration(value)))\n const [editing, setEditing] = useState(false)\n\n /* The `value` this box last saw from outside, adjusted during render rather\n * than in an effect - React's own pattern for a state that has to follow a\n * prop, and the one the `set-state-in-effect` rule points at.\n *\n * The comparison has to be against what was last *seen*, not against\n * `value`. After a commit the two differ in exactly the case that must be\n * left alone: `commit` writes `1h 30m` and tells the parent, the parent\n * still holds `null` for a tick, and comparing with `value` would clear the\n * box under the reader. Asking \"has the outside changed?\" answers it. */\n const [seen, setSeen] = useState<number | null>(value)\n\n if (value !== seen) {\n setSeen(value)\n // A value that changed while someone is typing is remembered, not shown:\n // reformatting under the cursor is the bug the local copy exists for.\n if (!editing) setText(value === null ? '' : formatDuration(value))\n }\n\n const commit = () => {\n setEditing(false)\n const parsed = parseDuration(text)\n if (parsed === undefined) {\n // Unreadable: put back what the value actually is rather than leaving\n // the box saying something the form does not believe.\n setText(value === null ? '' : formatDuration(value))\n return\n }\n setText(parsed === null ? '' : formatDuration(parsed))\n if (parsed !== value) onValueChange(parsed)\n }\n\n return (\n <input\n {...props}\n ref={ref}\n type=\"text\"\n inputMode=\"text\"\n value={text}\n onFocus={() => setEditing(true)}\n onChange={(event) => setText(event.target.value)}\n onBlur={commit}\n onKeyDown={(event) => {\n if (event.key === 'Enter') {\n event.preventDefault()\n commit()\n }\n }}\n className={cn(fieldClasses, 'h-9 tabular-nums', className)}\n />\n )\n}\n"
|
|
495
|
+
}
|
|
496
|
+
]
|
|
497
|
+
},
|
|
498
|
+
{
|
|
499
|
+
"name": "field",
|
|
500
|
+
"type": "registry:ui",
|
|
501
|
+
"title": "Field",
|
|
502
|
+
"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.",
|
|
503
|
+
"dependencies": [
|
|
504
|
+
"@base-ui/react",
|
|
505
|
+
"dowel-ui@^0.17.0"
|
|
506
|
+
],
|
|
507
|
+
"registryDependencies": [],
|
|
508
|
+
"files": [
|
|
509
|
+
{
|
|
510
|
+
"path": "ui/field.tsx",
|
|
511
|
+
"target": "@ui/field.tsx",
|
|
512
|
+
"type": "registry:ui",
|
|
513
|
+
"content": "import type { ReactElement, ReactNode } from 'react'\nimport { Field as Base } from '@base-ui/react/field'\nimport { cn } from 'dowel-ui'\n\n/*\n * Field - the label, the control, and what the form has to say about it.\n *\n * Every form is the same four parts repeated: a name for the control, the\n * control, sometimes a hint, and sometimes an error. Written by hand each\n * time, they drift - the label loses its `htmlFor`, the hint is a `<div>` no\n * screen reader mentions, the error appears in red and is announced by\n * nothing at all. This is that arrangement, once.\n *\n * The wiring is the point, and Base UI does it: the label points at the\n * control, the description and the error are named by `aria-describedby`, and\n * the control is marked invalid while an error is showing. None of that is\n * visible when it works, which is exactly why a product stops doing it.\n *\n * `error` is a string this is given, not a rule this enforces. dowel has no\n * opinion about where the string came from - Base UI's own `validate`, a\n * schema, react-hook-form, or a server that just said no - because a design\n * system that picked a form library would be choosing for products that\n * already chose. The one thing it insists on is that an error, once it\n * exists, is announced and points at the field it belongs to.\n *\n * The label is always rendered. A field whose name is only a placeholder\n * loses that name the moment someone types, and a placeholder is not a label\n * to anything that reads the page aloud; `labelHidden` takes it off the\n * screen and leaves it in the accessibility tree.\n */\n\nexport interface FieldProps {\n /** What the control is called. Always rendered; `labelHidden` only takes it\n * off the screen. */\n label: ReactNode\n /** The control itself - Input, Textarea, Select, Checkbox, anything.\n *\n * A single element rather than arbitrary nodes, because it is handed to\n * Base UI to carry the field's id and its `aria-*` wiring. Two children, or\n * a bare string, would leave the label naming nothing. */\n children: ReactElement\n /** A hint under the control. Hidden while an error is showing: two lines of\n * small print under one field is one line too many, and the error is the\n * one that matters. */\n help?: ReactNode\n /** What is wrong, if anything. Its presence is what marks the control\n * invalid - there is no separate `invalid` prop to keep in step. */\n error?: ReactNode\n /** Keep the label for screen readers but take it off the screen. For a\n * field whose meaning is obvious in context - a search box in a toolbar. */\n labelHidden?: boolean\n className?: string\n /** Marks the field required, which is a statement about the form rather\n * than about validation: the label gets the mark a reader looks for. */\n required?: boolean\n /** The control's `name`, forwarded so a `Form` can attach a server error to\n * this field by name. */\n name?: string\n disabled?: boolean\n}\n\nexport function Field({\n label,\n children,\n help,\n error,\n labelHidden = false,\n className,\n required = false,\n name,\n disabled = false,\n}: FieldProps) {\n const invalid = error !== undefined && error !== null && error !== false\n\n return (\n <Base.Root\n name={name}\n disabled={disabled}\n // Base UI marks the control invalid from its own validity state; this\n // says so for an error that arrived from anywhere else.\n invalid={invalid || undefined}\n className={cn('flex flex-col gap-1.5', className)}\n >\n <Base.Label\n className={cn(\n 'text-2xs font-medium uppercase tracking-caption text-faint',\n // Off the screen, still in the accessibility tree. Not\n // `display: none`, which would take it out of both.\n labelHidden && 'sr-only',\n )}\n >\n {label}\n {required && (\n <span aria-hidden className=\"ml-0.5 text-bad\">\n *\n </span>\n )}\n </Base.Label>\n\n {/* The control is handed to Base UI rather than merely nested inside\n * it. This is the whole component: a plain child renders a label whose\n * `for` points at an id nothing carries, so the label looks wired and\n * names nothing - which is exactly the bug this exists to prevent, and\n * it is invisible in a screenshot. `render` gives the child the id,\n * `aria-labelledby` and `aria-describedby` instead. */}\n <Base.Control render={children} />\n\n {/* The error wins the one line under the control. `match` is not used:\n * the string is already the decision - this renders whatever it was\n * handed rather than asking the browser what is wrong. */}\n {invalid ? (\n <Base.Error className=\"text-xs text-bad\" match>\n {error}\n </Base.Error>\n ) : (\n help !== undefined && <Base.Description className=\"text-xs text-dim\">{help}</Base.Description>\n )}\n </Base.Root>\n )\n}\n"
|
|
514
|
+
}
|
|
515
|
+
]
|
|
516
|
+
},
|
|
459
517
|
{
|
|
460
518
|
"name": "input",
|
|
461
519
|
"type": "registry:ui",
|
|
462
520
|
"title": "Input",
|
|
463
521
|
"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.",
|
|
464
522
|
"dependencies": [
|
|
465
|
-
"dowel-ui@^0.
|
|
523
|
+
"dowel-ui@^0.17.0"
|
|
466
524
|
],
|
|
467
525
|
"registryDependencies": [],
|
|
468
526
|
"files": [
|
|
@@ -480,7 +538,7 @@
|
|
|
480
538
|
"title": "Kbd",
|
|
481
539
|
"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.",
|
|
482
540
|
"dependencies": [
|
|
483
|
-
"dowel-ui@^0.
|
|
541
|
+
"dowel-ui@^0.17.0"
|
|
484
542
|
],
|
|
485
543
|
"registryDependencies": [],
|
|
486
544
|
"files": [
|
|
@@ -500,7 +558,7 @@
|
|
|
500
558
|
"dependencies": [
|
|
501
559
|
"@base-ui/react",
|
|
502
560
|
"class-variance-authority",
|
|
503
|
-
"dowel-ui@^0.
|
|
561
|
+
"dowel-ui@^0.17.0"
|
|
504
562
|
],
|
|
505
563
|
"registryDependencies": [],
|
|
506
564
|
"files": [
|
|
@@ -512,6 +570,27 @@
|
|
|
512
570
|
}
|
|
513
571
|
]
|
|
514
572
|
},
|
|
573
|
+
{
|
|
574
|
+
"name": "number-field",
|
|
575
|
+
"type": "registry:ui",
|
|
576
|
+
"title": "Number-field",
|
|
577
|
+
"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.",
|
|
578
|
+
"dependencies": [
|
|
579
|
+
"@base-ui/react",
|
|
580
|
+
"dowel-ui@^0.17.0"
|
|
581
|
+
],
|
|
582
|
+
"registryDependencies": [
|
|
583
|
+
"https://lacodda.github.io/dowel/r/input.json"
|
|
584
|
+
],
|
|
585
|
+
"files": [
|
|
586
|
+
{
|
|
587
|
+
"path": "ui/number-field.tsx",
|
|
588
|
+
"target": "@ui/number-field.tsx",
|
|
589
|
+
"type": "registry:ui",
|
|
590
|
+
"content": "import type { ReactNode } from 'react'\nimport { NumberField as Base } from '@base-ui/react/number-field'\nimport { cn } from 'dowel-ui'\nimport { fieldClasses } from './input'\n\n/*\n * NumberField - a number, and the two ways of changing it.\n *\n * A number typed into a text input is a string that happens to look like a\n * number, and every product then writes the same four fixes: strip the\n * letters, clamp to a range, round to a step, and decide what an empty box\n * means. This is those four, once, plus the stepper - because a value with a\n * small range is faster nudged than typed.\n *\n * Base UI carries the parts that are genuinely hard: the arrow keys with\n * PageUp/PageDown for the large step, the parse of what a person actually\n * types (spaces, a comma for a decimal point, a pasted currency string), and\n * `Intl.NumberFormat` for how it reads back. That last one matters more than\n * it looks: a number field that shows `1234.5` where the reader writes\n * `1 234,5` is a field they have to translate in their head.\n *\n * `unit` is ours, and it is a label rather than part of the value. Putting\n * \"px\" inside the input makes it something to parse and something to delete\n * by accident; beside the input it is a caption that cannot be typed into.\n * The value stays a number.\n *\n * Empty is `null`, not zero. \"No number\" and \"the number zero\" are different\n * facts - a price of nothing and no price yet - and a field that returns 0 for\n * an empty box makes them the same the moment it is saved.\n */\n\nexport interface NumberFieldProps {\n value?: number | null\n defaultValue?: number\n onValueChange?: (value: number | null) => void\n min?: number\n max?: number\n /** What the arrows change it by. */\n step?: number\n /** What PageUp and PageDown change it by, when a single step is too slow. */\n largeStep?: number\n /** How the number reads: `Intl.NumberFormat` options, so a currency or a\n * percentage is a prop rather than a wrapper. */\n format?: Intl.NumberFormatOptions\n /** Which conventions `format` follows. Left alone it is the reader's own,\n * which is nearly always right; a product states one only when the figure\n * belongs to a place rather than to a person - a price in a fixed market. */\n locale?: Intl.LocalesArgument\n /** What the number is in - `px`, `kg`, `%`. A caption beside the field, not\n * part of the value. */\n unit?: ReactNode\n /** Hide the stepper. For a field with a wide range, where the buttons are\n * an invitation to click sixty times. */\n hideStepper?: boolean\n disabled?: boolean\n readOnly?: boolean\n required?: boolean\n name?: string\n placeholder?: string\n 'aria-label'?: string\n className?: string\n}\n\n/** The stepper's two buttons. Square, the height of the field, and marked\n * `aria-hidden` because the input they belong to already announces its value\n * and its range - a screen reader hearing \"increase, decrease\" as separate\n * controls learns nothing it did not have. */\nconst stepperButton = cn(\n 'flex w-7 shrink-0 items-center justify-center text-dim',\n 'transition-colors hover:bg-soft hover:text-text',\n 'disabled:pointer-events-none disabled:opacity-50',\n)\n\nexport function NumberField({\n unit,\n hideStepper = false,\n className,\n placeholder,\n 'aria-label': ariaLabel,\n ...props\n}: NumberFieldProps) {\n return (\n <Base.Root {...props} className={cn('inline-flex items-center gap-2', className)}>\n <Base.Group\n className={cn(\n fieldClasses,\n 'flex h-9 items-stretch overflow-hidden p-0',\n // The group carries the field's clothes, so the focus ring belongs\n // to the whole control rather than to the bare input inside it.\n 'focus-within:outline-2 focus-within:outline-offset-0 focus-within:outline-accent',\n )}\n >\n {!hideStepper && (\n <Base.Decrement className={cn(stepperButton, 'border-r border-line')} aria-hidden>\n <svg viewBox=\"0 0 16 16\" className=\"size-3.5\">\n <path d=\"M4 8h8\" stroke=\"currentColor\" strokeWidth=\"1.75\" strokeLinecap=\"round\" />\n </svg>\n </Base.Decrement>\n )}\n\n <Base.Input\n placeholder={placeholder}\n aria-label={ariaLabel}\n className={cn(\n 'w-full min-w-0 bg-transparent px-2.5 text-sm text-text placeholder:text-faint',\n 'outline-none',\n // Figures line up in a column, which is the whole reason a number\n // is in a field of its own.\n 'tabular-nums',\n hideStepper ? 'text-left' : 'text-center',\n )}\n />\n\n {!hideStepper && (\n <Base.Increment className={cn(stepperButton, 'border-l border-line')} aria-hidden>\n <svg viewBox=\"0 0 16 16\" className=\"size-3.5\">\n <path d=\"M8 4v8M4 8h8\" stroke=\"currentColor\" strokeWidth=\"1.75\" strokeLinecap=\"round\" />\n </svg>\n </Base.Increment>\n )}\n </Base.Group>\n\n {unit !== undefined && <span className=\"shrink-0 text-xs text-dim\">{unit}</span>}\n </Base.Root>\n )\n}\n"
|
|
591
|
+
}
|
|
592
|
+
]
|
|
593
|
+
},
|
|
515
594
|
{
|
|
516
595
|
"name": "panel",
|
|
517
596
|
"type": "registry:ui",
|
|
@@ -519,7 +598,7 @@
|
|
|
519
598
|
"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.",
|
|
520
599
|
"dependencies": [
|
|
521
600
|
"class-variance-authority",
|
|
522
|
-
"dowel-ui@^0.
|
|
601
|
+
"dowel-ui@^0.17.0"
|
|
523
602
|
],
|
|
524
603
|
"registryDependencies": [],
|
|
525
604
|
"files": [
|
|
@@ -531,6 +610,26 @@
|
|
|
531
610
|
}
|
|
532
611
|
]
|
|
533
612
|
},
|
|
613
|
+
{
|
|
614
|
+
"name": "password-field",
|
|
615
|
+
"type": "registry:ui",
|
|
616
|
+
"title": "Password-field",
|
|
617
|
+
"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.",
|
|
618
|
+
"dependencies": [
|
|
619
|
+
"dowel-ui@^0.17.0"
|
|
620
|
+
],
|
|
621
|
+
"registryDependencies": [
|
|
622
|
+
"https://lacodda.github.io/dowel/r/input.json"
|
|
623
|
+
],
|
|
624
|
+
"files": [
|
|
625
|
+
{
|
|
626
|
+
"path": "ui/password-field.tsx",
|
|
627
|
+
"target": "@ui/password-field.tsx",
|
|
628
|
+
"type": "registry:ui",
|
|
629
|
+
"content": "import { useId, useState, type Ref } from 'react'\nimport { cn } from 'dowel-ui'\nimport { fieldClasses } from './input'\n\n/*\n * PasswordField - a password, and the button that shows it.\n *\n * The reveal is the whole component, and it is not a convenience. A masked\n * field is the only one in a form where a typo cannot be seen, so people\n * either paste (fine) or type slowly and get it wrong anyway; the toggle is\n * what turns an unverifiable field into a checkable one, and it is why long\n * passphrases became usable at all.\n *\n * What it costs is a moment where the password is on the screen, so the\n * component states its two rules rather than leaving them to each product:\n *\n * - it always starts masked, and there is no prop to start it revealed;\n * - revealing is the reader's own action, never a default and never\n * something a form can turn on for them.\n *\n * The button is a real button with a real name, and the name changes with the\n * state - \"Show password\" / \"Hide password\". That is what a screen reader\n * announces, and it is the one place the component needs words, so they are\n * required props. A default here would ship English inside a primitive.\n *\n * `autoComplete` is not defaulted either. The right value is the product's\n * to know: `current-password` on a login, `new-password` on a sign-up, and\n * getting it wrong is how a password manager fills the wrong box.\n */\n\nexport interface PasswordFieldProps {\n value?: string\n defaultValue?: string\n onValueChange?: (value: string) => void\n /** What the reveal button is called while the password is hidden. */\n showLabel: string\n /** ...and while it is showing. */\n hideLabel: string\n /** `current-password` for a login, `new-password` for a sign-up. */\n autoComplete?: string\n placeholder?: string\n disabled?: boolean\n readOnly?: boolean\n required?: boolean\n name?: string\n id?: string\n ref?: Ref<HTMLInputElement>\n 'aria-label'?: string\n 'aria-describedby'?: string\n className?: string\n}\n\nexport function PasswordField({\n value,\n defaultValue,\n onValueChange,\n showLabel,\n hideLabel,\n className,\n disabled,\n ref,\n ...props\n}: PasswordFieldProps) {\n /* Always false to begin with. Deliberately local state with no prop to set\n * it: a password that arrives on screen without the reader asking is the\n * one failure this component must not have. */\n const [revealed, setRevealed] = useState(false)\n const inputId = useId()\n\n return (\n <div\n className={cn(\n fieldClasses,\n 'flex h-9 items-stretch overflow-hidden p-0',\n 'focus-within:outline-2 focus-within:outline-offset-0 focus-within:outline-accent',\n className,\n )}\n >\n <input\n {...props}\n ref={ref}\n id={props.id ?? inputId}\n type={revealed ? 'text' : 'password'}\n value={value}\n defaultValue={defaultValue}\n disabled={disabled}\n onChange={(event) => onValueChange?.(event.target.value)}\n className={cn(\n 'w-full min-w-0 bg-transparent px-2.5 text-sm text-text placeholder:text-faint',\n 'outline-none disabled:cursor-not-allowed',\n )}\n />\n\n <button\n type=\"button\"\n // Not a submit button, and not in the tab order ahead of the field it\n // belongs to - it sits after the input, which is where Tab reaches it.\n onClick={() => setRevealed((was) => !was)}\n disabled={disabled}\n aria-label={revealed ? hideLabel : showLabel}\n aria-pressed={revealed}\n aria-controls={props.id ?? inputId}\n className={cn(\n 'flex w-9 shrink-0 items-center justify-center text-dim',\n 'transition-colors hover:bg-soft hover:text-text',\n 'focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-accent',\n 'disabled:pointer-events-none disabled:opacity-50',\n )}\n >\n {revealed ? (\n /* An eye with a stroke through it: hiding is the action offered\n * while the password is visible. */\n <svg viewBox=\"0 0 20 20\" className=\"size-4\" fill=\"none\" aria-hidden>\n <path\n d=\"M4 4l12 12M8.5 8.7a2 2 0 002.8 2.8M6.3 6.4C4.4 7.5 3 9.2 2.5 10c1.2 2.2 4 5 7.5 5 1.3 0 2.5-.4 3.5-1M9 5.1c.3 0 .7-.1 1-.1 3.5 0 6.3 2.8 7.5 5-.3.5-.8 1.3-1.6 2.1\"\n stroke=\"currentColor\"\n strokeWidth=\"1.5\"\n strokeLinecap=\"round\"\n />\n </svg>\n ) : (\n <svg viewBox=\"0 0 20 20\" className=\"size-4\" fill=\"none\" aria-hidden>\n <path\n d=\"M2.5 10C3.7 7.8 6.5 5 10 5s6.3 2.8 7.5 5c-1.2 2.2-4 5-7.5 5s-6.3-2.8-7.5-5z\"\n stroke=\"currentColor\"\n strokeWidth=\"1.5\"\n />\n <circle cx=\"10\" cy=\"10\" r=\"2.2\" stroke=\"currentColor\" strokeWidth=\"1.5\" />\n </svg>\n )}\n </button>\n </div>\n )\n}\n"
|
|
630
|
+
}
|
|
631
|
+
]
|
|
632
|
+
},
|
|
534
633
|
{
|
|
535
634
|
"name": "popover",
|
|
536
635
|
"type": "registry:ui",
|
|
@@ -539,7 +638,7 @@
|
|
|
539
638
|
"dependencies": [
|
|
540
639
|
"@base-ui/react",
|
|
541
640
|
"class-variance-authority",
|
|
542
|
-
"dowel-ui@^0.
|
|
641
|
+
"dowel-ui@^0.17.0"
|
|
543
642
|
],
|
|
544
643
|
"registryDependencies": [],
|
|
545
644
|
"files": [
|
|
@@ -559,7 +658,7 @@
|
|
|
559
658
|
"dependencies": [
|
|
560
659
|
"@base-ui/react",
|
|
561
660
|
"class-variance-authority",
|
|
562
|
-
"dowel-ui@^0.
|
|
661
|
+
"dowel-ui@^0.17.0"
|
|
563
662
|
],
|
|
564
663
|
"registryDependencies": [],
|
|
565
664
|
"files": [
|
|
@@ -571,13 +670,51 @@
|
|
|
571
670
|
}
|
|
572
671
|
]
|
|
573
672
|
},
|
|
673
|
+
{
|
|
674
|
+
"name": "radio-group",
|
|
675
|
+
"type": "registry:ui",
|
|
676
|
+
"title": "Radio-group",
|
|
677
|
+
"description": "The rule for reaching for this rather than a Select is whether the options are worth the space: a handful of short choices read faster laid out than hidden behind a trigger, and each one becomes a target rather than a step.",
|
|
678
|
+
"dependencies": [
|
|
679
|
+
"@base-ui/react",
|
|
680
|
+
"class-variance-authority",
|
|
681
|
+
"dowel-ui@^0.17.0"
|
|
682
|
+
],
|
|
683
|
+
"registryDependencies": [],
|
|
684
|
+
"files": [
|
|
685
|
+
{
|
|
686
|
+
"path": "ui/radio-group.tsx",
|
|
687
|
+
"target": "@ui/radio-group.tsx",
|
|
688
|
+
"type": "registry:ui",
|
|
689
|
+
"content": "import type { ReactNode } from 'react'\nimport { Radio as Base } from '@base-ui/react/radio'\nimport { RadioGroup as BaseGroup } from '@base-ui/react/radio-group'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { cn } from 'dowel-ui'\n\n/*\n * RadioGroup - one of a few, all of them visible.\n *\n * The rule for reaching for this rather than a Select is whether the options\n * are worth the space: a handful of short choices read faster laid out than\n * hidden behind a trigger, and each one becomes a target rather than a step.\n * Past about five, or when the labels are long, a Select is the honest\n * choice - this is not a Select with more pixels.\n *\n * The group is the control, not the button. That is what the arrow keys\n * follow, what a screen reader announces as one thing with a position in it,\n * and why `Radio` on its own is not exported: a radio outside a group is a\n * checkbox that cannot be unchecked.\n *\n * The dot is drawn rather than native, for the same reason as the tick in\n * Checkbox: a real `<input type=radio>` cannot be styled without\n * `appearance: none`, and after that the dot has to be drawn anyway.\n */\n\nexport interface RadioGroupProps {\n children: ReactNode\n value?: string\n defaultValue?: string\n onValueChange?: (value: string) => void\n disabled?: boolean\n readOnly?: boolean\n required?: boolean\n name?: string\n /** Lay the options out in a row. For two or three short ones; a column is\n * the default because it stays readable as labels grow. */\n orientation?: VariantProps<typeof radioGroupVariants>['orientation']\n 'aria-label'?: string\n className?: string\n}\n\n/* The layout lives in `cva` rather than in a destructured default, which is\n * how every other primitive here states one. It also keeps the word out of\n * the component's signature: a string default in a props list is how a\n * primitive ends up shipping English, and the gate that watches for that\n * cannot tell an enum value from a label. */\nexport const radioGroupVariants = cva('flex', {\n variants: {\n orientation: {\n vertical: 'flex-col gap-2',\n horizontal: 'flex-row flex-wrap gap-4',\n },\n },\n defaultVariants: { orientation: 'vertical' },\n})\n\nexport function RadioGroup({ children, orientation, className, ...props }: RadioGroupProps) {\n return (\n <BaseGroup className={cn(radioGroupVariants({ orientation }), className)} {...props}>\n {children}\n </BaseGroup>\n )\n}\n\nexport interface RadioProps {\n value: string\n /** The words next to the dot. */\n children?: ReactNode\n disabled?: boolean\n className?: string\n}\n\nexport function Radio({ value, children, disabled, className }: RadioProps) {\n const dot = (\n <Base.Root\n value={value}\n disabled={disabled}\n className={cn(\n 'flex size-4 shrink-0 items-center justify-center rounded-full border border-line-2 bg-transparent',\n 'transition-colors',\n 'hover:border-accent',\n 'data-[checked]:border-accent',\n 'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent',\n 'disabled:cursor-not-allowed disabled:opacity-50',\n children === undefined && className,\n )}\n >\n <Base.Indicator className=\"size-2 rounded-full bg-accent\" render={<span />} />\n </Base.Root>\n )\n\n if (children === undefined) return dot\n\n return (\n <label\n className={cn(\n 'flex cursor-pointer items-center gap-2 text-sm text-text',\n 'has-[:disabled]:cursor-not-allowed has-[:disabled]:opacity-50',\n className,\n )}\n >\n {dot}\n {children}\n </label>\n )\n}\n"
|
|
690
|
+
}
|
|
691
|
+
]
|
|
692
|
+
},
|
|
693
|
+
{
|
|
694
|
+
"name": "rating-scale",
|
|
695
|
+
"type": "registry:ui",
|
|
696
|
+
"title": "Rating-scale",
|
|
697
|
+
"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\".",
|
|
698
|
+
"dependencies": [
|
|
699
|
+
"dowel-ui@^0.17.0"
|
|
700
|
+
],
|
|
701
|
+
"registryDependencies": [],
|
|
702
|
+
"files": [
|
|
703
|
+
{
|
|
704
|
+
"path": "ui/rating-scale.tsx",
|
|
705
|
+
"target": "@ui/rating-scale.tsx",
|
|
706
|
+
"type": "registry:ui",
|
|
707
|
+
"content": "import { useId, type KeyboardEvent } from 'react'\nimport { cn } from 'dowel-ui'\n\n/*\n * RatingScale - a judgement on a short scale, and the absence of one.\n *\n * Generalised from kilna, where it is how a work is scored on each of its\n * axes. The shape is a row of marks rather than stars: stars carry a meaning\n * of their own - a review, a public verdict - and this is as often \"how hard\n * was this\" or \"how finished is it\" as it is \"how good\".\n *\n * The part worth keeping from the donor, and the reason this is not a Slider\n * with a small range: **not judged yet is a state, not a zero.** \"I have not\n * scored this\" and \"I scored it nothing\" are different facts, and a control\n * that collapses them makes the difference unrecoverable the moment it is\n * saved. So `value` is `number | undefined`, clicking the current mark clears\n * it, and Backspace does the same from the keyboard.\n *\n * One tab stop, arrows within it - the arrangement a radio group has. The\n * marks are not buttons: the container is the control, and a `<button>` inside\n * an element with `role=\"slider\"` is a nested interactive control - axe calls\n * it out, and it is right, because assistive technology is not promised to\n * announce or reach the inner one. The donor had them as `aria-hidden`\n * buttons, which hides them from a reader without making them stop being\n * controls.\n *\n * So a mark is a plain element that happens to accept a click. Everything\n * that makes the control usable - the tab stop, the keyboard, the announced\n * value - belongs to the container, and the pointer is served by the marks.\n */\n\nexport interface RatingScaleProps {\n /** How many marks. */\n scale: number\n /** The score, or `undefined` for not judged yet. */\n value: number | undefined\n onValueChange: (value: number | undefined) => void\n /** What is being judged. Required: a bare row of marks names nothing, and\n * this is the only thing a screen reader has to go on. */\n label: string\n /** What a screen reader hears in place of a number when nothing is chosen.\n * Required rather than defaulted, because a default here would be English\n * shipped inside a primitive. */\n emptyLabel: string\n disabled?: boolean\n className?: string\n}\n\nexport function RatingScale({\n scale,\n value,\n onValueChange,\n label,\n emptyLabel,\n disabled = false,\n className,\n}: RatingScaleProps) {\n const id = useId()\n const marks = Math.max(1, Math.round(scale))\n\n const clamp = (next: number) => Math.min(Math.max(next, 0), marks)\n\n const step = (delta: number) => {\n if (disabled) return\n // From nothing, a step forward lands on the first mark and a step back on\n // the last - so either arrow starts scoring rather than doing nothing.\n const next = value === undefined ? (delta > 0 ? 1 : marks) : value + delta\n onValueChange(clamp(next))\n }\n\n const onKeyDown = (event: KeyboardEvent) => {\n if (disabled) return\n switch (event.key) {\n case 'ArrowRight':\n case 'ArrowUp':\n event.preventDefault()\n step(1)\n break\n case 'ArrowLeft':\n case 'ArrowDown':\n event.preventDefault()\n step(-1)\n break\n case 'Home':\n event.preventDefault()\n onValueChange(0)\n break\n case 'End':\n event.preventDefault()\n onValueChange(marks)\n break\n case 'Backspace':\n case 'Delete':\n // The keyboard's way back to not judged. Without it the state is\n // reachable only by clicking the mark that is already chosen.\n event.preventDefault()\n onValueChange(undefined)\n break\n default:\n break\n }\n }\n\n return (\n <div\n id={id}\n role=\"slider\"\n tabIndex={disabled ? -1 : 0}\n aria-label={label}\n aria-valuemin={0}\n aria-valuemax={marks}\n aria-valuenow={value}\n aria-valuetext={value === undefined ? emptyLabel : String(value)}\n aria-disabled={disabled || undefined}\n onKeyDown={onKeyDown}\n className={cn(\n 'flex gap-[3px] rounded-md',\n 'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent',\n disabled && 'cursor-not-allowed opacity-50',\n className,\n )}\n >\n {Array.from({ length: marks }, (_, index) => {\n const mark = index + 1\n const filled = value !== undefined && mark <= value\n return (\n <span\n key={mark}\n // Deliberately not a button. The container is the control; a\n // nested one would be a second interactive element inside a\n // `slider`, which assistive technology is not promised to handle.\n aria-hidden\n // Clicking the mark already chosen clears the score. That is the\n // pointer's way back to not judged, and it is why a rating is not\n // a five-option radio group.\n onClick={() => {\n if (disabled) return\n onValueChange(value === mark ? undefined : mark)\n }}\n className={cn(\n 'h-[22px] flex-1 rounded-sm transition-colors',\n disabled ? 'cursor-not-allowed' : 'cursor-pointer',\n filled ? 'bg-accent' : 'bg-soft',\n !disabled && (filled ? 'hover:bg-accent-2' : 'hover:bg-line-2'),\n )}\n />\n )\n })}\n </div>\n )\n}\n"
|
|
708
|
+
}
|
|
709
|
+
]
|
|
710
|
+
},
|
|
574
711
|
{
|
|
575
712
|
"name": "search-field",
|
|
576
713
|
"type": "registry:ui",
|
|
577
714
|
"title": "Search-field",
|
|
578
715
|
"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.",
|
|
579
716
|
"dependencies": [
|
|
580
|
-
"dowel-ui@^0.
|
|
717
|
+
"dowel-ui@^0.17.0"
|
|
581
718
|
],
|
|
582
719
|
"registryDependencies": [
|
|
583
720
|
"https://lacodda.github.io/dowel/r/input.json",
|
|
@@ -601,7 +738,7 @@
|
|
|
601
738
|
"dependencies": [
|
|
602
739
|
"@base-ui/react",
|
|
603
740
|
"class-variance-authority",
|
|
604
|
-
"dowel-ui@^0.
|
|
741
|
+
"dowel-ui@^0.17.0"
|
|
605
742
|
],
|
|
606
743
|
"registryDependencies": [
|
|
607
744
|
"https://lacodda.github.io/dowel/r/input.json"
|
|
@@ -631,6 +768,25 @@
|
|
|
631
768
|
}
|
|
632
769
|
]
|
|
633
770
|
},
|
|
771
|
+
{
|
|
772
|
+
"name": "slider",
|
|
773
|
+
"type": "registry:ui",
|
|
774
|
+
"title": "Slider",
|
|
775
|
+
"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\".",
|
|
776
|
+
"dependencies": [
|
|
777
|
+
"@base-ui/react",
|
|
778
|
+
"dowel-ui@^0.17.0"
|
|
779
|
+
],
|
|
780
|
+
"registryDependencies": [],
|
|
781
|
+
"files": [
|
|
782
|
+
{
|
|
783
|
+
"path": "ui/slider.tsx",
|
|
784
|
+
"target": "@ui/slider.tsx",
|
|
785
|
+
"type": "registry:ui",
|
|
786
|
+
"content": "import { Slider as Base } from '@base-ui/react/slider'\nimport { cn } from 'dowel-ui'\n\n/*\n * Slider - a value picked by position, and a range picked by two.\n *\n * The case for it over a NumberField is that the number does not matter much:\n * a volume, an opacity, a weight in a search filter. Where the exact figure\n * does matter, a slider is a worse field with more pixels - it cannot be\n * typed into, it cannot be pasted into, and it has no state for \"empty\".\n *\n * Range is the same component with an array. That is Base UI's arrangement\n * and it is the right one: a range slider is not a second control but the\n * same track with two thumbs, and splitting them would double the styling\n * and let the two drift apart. `value={[10, 40]}` is a range; `value={30}` is\n * a single.\n *\n * The parts are separate for a reason worth knowing. The Control is the whole\n * hit area - much taller than the visible track, so a pointer does not have\n * to find four pixels - while the Track is what is drawn, and the Indicator\n * is the filled part behind the thumb. Making the drawn track the hit area is\n * the commonest way a slider ends up hard to grab.\n */\n\nexport interface SliderProps {\n /** A number for one thumb, an array for a range. */\n value?: number | readonly number[]\n defaultValue?: number | readonly number[]\n /* `readonly` because that is what Base UI hands over, and narrowing it here\n * only moves the cast into every caller. */\n onValueChange?: (value: number | readonly number[]) => void\n /** Fires once when the drag ends, for the expensive thing a product does\n * not want to run on every pixel of movement. */\n onValueCommitted?: (value: number | readonly number[]) => void\n min?: number\n max?: number\n step?: number\n /** How close the thumbs of a range may come, in steps. */\n minStepsBetweenValues?: number\n /** How the value reads when it is shown: `Intl.NumberFormat` options. */\n format?: Intl.NumberFormatOptions\n orientation?: 'horizontal' | 'vertical'\n /** Show the current value beside the track. Off by default: on a row of\n * settings the numbers are noise, and where the figure matters a\n * NumberField is the better control. */\n showValue?: boolean\n /** What each thumb is called, by index.\n *\n * The thumb is the control - a hidden `<input type=\"range\">` - and\n * `aria-label` on the root names the *group* around it, which leaves every\n * thumb unnamed. axe reports it for a single slider as loudly as for a\n * range. Without this prop the group's own label is used for each thumb,\n * which is right for one and merely adequate for two: a range wants\n * \"Lowest price\" and \"Highest price\", not the same word twice. */\n getThumbLabel?: (index: number) => string\n disabled?: boolean\n name?: string\n 'aria-label'?: string\n className?: string\n}\n\nexport function Slider({ showValue = false, getThumbLabel, className, ...props }: SliderProps) {\n const vertical = props.orientation === 'vertical'\n\n /* Every thumb needs a name of its own, so the group's label is the fallback\n * rather than nothing. A slider whose only name sits on the group is one\n * where a reader lands on the control and is told a number. */\n const groupLabel = props['aria-label']\n const nameThumb = getThumbLabel ?? (groupLabel === undefined ? undefined : () => groupLabel)\n\n /* One thumb per value. Base UI addresses them by index, so a range needs as\n * many as the array is long - a single Thumb on a range renders one handle\n * that moves the first value and leaves the second unreachable. Read from\n * whichever of the two props is present, because the component is useful\n * both controlled and not. */\n const current = props.value ?? props.defaultValue ?? 0\n const thumbCount = Array.isArray(current) ? current.length : 1\n\n return (\n <Base.Root\n {...props}\n className={cn(\n 'flex items-center gap-3',\n vertical && 'h-40 flex-col',\n !vertical && 'w-full',\n className,\n )}\n >\n <Base.Control\n className={cn(\n // The hit area, deliberately larger than what is drawn: a four-pixel\n // target is a four-pixel target however pretty the track is.\n 'flex touch-none items-center',\n vertical ? 'h-full w-5 justify-center' : 'h-5 w-full flex-1',\n )}\n >\n <Base.Track\n className={cn(\n 'relative rounded-full bg-soft',\n vertical ? 'h-full w-1.5' : 'h-1.5 w-full',\n )}\n >\n <Base.Indicator className={cn('rounded-full bg-accent', vertical ? 'w-full' : 'h-full')} />\n {Array.from({ length: thumbCount }, (_, index) => (\n <Base.Thumb\n key={index}\n index={index}\n getAriaLabel={nameThumb}\n className={cn(\n 'size-4 rounded-full bg-accent shadow-lift',\n 'transition-[box-shadow,transform] duration-quick ease-out',\n 'hover:scale-110',\n 'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent',\n 'data-[dragging]:scale-110',\n )}\n />\n ))}\n </Base.Track>\n </Base.Control>\n\n {showValue && (\n <Base.Value className=\"shrink-0 text-xs tabular-nums text-dim\" />\n )}\n </Base.Root>\n )\n}\n"
|
|
787
|
+
}
|
|
788
|
+
]
|
|
789
|
+
},
|
|
634
790
|
{
|
|
635
791
|
"name": "spinner",
|
|
636
792
|
"type": "registry:ui",
|
|
@@ -638,7 +794,7 @@
|
|
|
638
794
|
"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.",
|
|
639
795
|
"dependencies": [
|
|
640
796
|
"class-variance-authority",
|
|
641
|
-
"dowel-ui@^0.
|
|
797
|
+
"dowel-ui@^0.17.0"
|
|
642
798
|
],
|
|
643
799
|
"registryDependencies": [],
|
|
644
800
|
"files": [
|
|
@@ -650,13 +806,32 @@
|
|
|
650
806
|
}
|
|
651
807
|
]
|
|
652
808
|
},
|
|
809
|
+
{
|
|
810
|
+
"name": "switch",
|
|
811
|
+
"type": "registry:ui",
|
|
812
|
+
"title": "Switch",
|
|
813
|
+
"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.",
|
|
814
|
+
"dependencies": [
|
|
815
|
+
"@base-ui/react",
|
|
816
|
+
"dowel-ui@^0.17.0"
|
|
817
|
+
],
|
|
818
|
+
"registryDependencies": [],
|
|
819
|
+
"files": [
|
|
820
|
+
{
|
|
821
|
+
"path": "ui/switch.tsx",
|
|
822
|
+
"target": "@ui/switch.tsx",
|
|
823
|
+
"type": "registry:ui",
|
|
824
|
+
"content": "import type { ReactNode } from 'react'\nimport { Switch as Base } from '@base-ui/react/switch'\nimport { cn } from 'dowel-ui'\n\n/*\n * Switch - a setting that takes effect when you flip it.\n *\n * The difference from Checkbox is not how it looks, and getting it wrong is\n * the commonest mistake in the pair. A checkbox is an answer collected now\n * and submitted later, with the rest of the form; a switch is a setting that\n * applies the moment it moves. Put a switch in a form with a Save button and\n * the reader cannot tell whether anything happened - they flipped it, and\n * nothing said so.\n *\n * The rule, then: if there is a Save button, it is a Checkbox. If the change\n * is the action, it is a Switch.\n *\n * The thumb slides with `--duration-quick`, and the track carries the accent\n * when on - the only colour in the control, so a row of settings reads as a\n * column of on-and-off rather than a field of decoration. Under reduced\n * motion the theme drops the transition; the position still changes, which is\n * the part that carries the meaning.\n */\n\nexport interface SwitchProps {\n /** The words next to the switch. A switch with no label is a light with no\n * caption - give `aria-label` if the meaning is genuinely in the context. */\n children?: ReactNode\n checked?: boolean\n defaultChecked?: boolean\n onCheckedChange?: (checked: boolean) => void\n disabled?: boolean\n required?: boolean\n readOnly?: boolean\n name?: string\n 'aria-label'?: string\n className?: string\n}\n\nexport function Switch({ children, className, ...props }: SwitchProps) {\n const control = (\n <Base.Root\n className={cn(\n 'relative inline-flex h-5 w-9 shrink-0 items-center rounded-full border border-line-2 bg-soft',\n 'transition-colors duration-quick ease-out',\n 'hover:border-accent',\n 'data-[checked]:border-accent data-[checked]:bg-accent',\n 'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent',\n 'disabled:cursor-not-allowed disabled:opacity-50',\n children === undefined && className,\n )}\n {...props}\n >\n <Base.Thumb\n className={cn(\n 'size-3.5 rounded-full bg-dim shadow-lift',\n 'transition-[transform,background-color] duration-quick ease-out',\n 'translate-x-0.5 data-[checked]:translate-x-[1.125rem]',\n 'data-[checked]:bg-on-accent',\n )}\n />\n </Base.Root>\n )\n\n if (children === undefined) return control\n\n return (\n <label\n className={cn(\n 'flex cursor-pointer items-center gap-2.5 text-sm text-text',\n 'has-[:disabled]:cursor-not-allowed has-[:disabled]:opacity-50',\n className,\n )}\n >\n {control}\n {children}\n </label>\n )\n}\n"
|
|
825
|
+
}
|
|
826
|
+
]
|
|
827
|
+
},
|
|
653
828
|
{
|
|
654
829
|
"name": "textarea",
|
|
655
830
|
"type": "registry:ui",
|
|
656
831
|
"title": "Textarea",
|
|
657
832
|
"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.",
|
|
658
833
|
"dependencies": [
|
|
659
|
-
"dowel-ui@^0.
|
|
834
|
+
"dowel-ui@^0.17.0"
|
|
660
835
|
],
|
|
661
836
|
"registryDependencies": [
|
|
662
837
|
"https://lacodda.github.io/dowel/r/input.json"
|
|
@@ -678,7 +853,7 @@
|
|
|
678
853
|
"dependencies": [
|
|
679
854
|
"@base-ui/react",
|
|
680
855
|
"class-variance-authority",
|
|
681
|
-
"dowel-ui@^0.
|
|
856
|
+
"dowel-ui@^0.17.0"
|
|
682
857
|
],
|
|
683
858
|
"registryDependencies": [],
|
|
684
859
|
"files": [
|
|
@@ -698,7 +873,7 @@
|
|
|
698
873
|
"dependencies": [
|
|
699
874
|
"@base-ui/react",
|
|
700
875
|
"class-variance-authority",
|
|
701
|
-
"dowel-ui@^0.
|
|
876
|
+
"dowel-ui@^0.17.0"
|
|
702
877
|
],
|
|
703
878
|
"registryDependencies": [],
|
|
704
879
|
"files": [
|
|
@@ -716,7 +891,7 @@
|
|
|
716
891
|
"title": "Truncate",
|
|
717
892
|
"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.",
|
|
718
893
|
"dependencies": [
|
|
719
|
-
"dowel-ui@^0.
|
|
894
|
+
"dowel-ui@^0.17.0"
|
|
720
895
|
],
|
|
721
896
|
"registryDependencies": [],
|
|
722
897
|
"files": [
|
|
@@ -759,13 +934,17 @@
|
|
|
759
934
|
"description": "What a form is made of: the fields, the two ways of choosing from a list, and the button that submits it.",
|
|
760
935
|
"registryDependencies": [
|
|
761
936
|
"https://lacodda.github.io/dowel/r/button.json",
|
|
937
|
+
"https://lacodda.github.io/dowel/r/checkbox.json",
|
|
762
938
|
"https://lacodda.github.io/dowel/r/chip.json",
|
|
763
939
|
"https://lacodda.github.io/dowel/r/combobox.json",
|
|
940
|
+
"https://lacodda.github.io/dowel/r/field.json",
|
|
764
941
|
"https://lacodda.github.io/dowel/r/input.json",
|
|
942
|
+
"https://lacodda.github.io/dowel/r/radio-group.json",
|
|
765
943
|
"https://lacodda.github.io/dowel/r/select.json",
|
|
944
|
+
"https://lacodda.github.io/dowel/r/switch.json",
|
|
766
945
|
"https://lacodda.github.io/dowel/r/textarea.json"
|
|
767
946
|
],
|
|
768
|
-
"docs": "Field
|
|
947
|
+
"docs": "Field wraps any of the controls: it is what ties a label, a hint and an error to the thing they belong to."
|
|
769
948
|
},
|
|
770
949
|
{
|
|
771
950
|
"extends": "none",
|
package/package.json
CHANGED