dowel-ui 0.27.0 → 0.28.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 +2 -1
- package/dist/index.d.ts +13 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +18 -0
- package/dist/index.js.map +1 -1
- package/dist/registry.json +99 -99
- package/dist/theme.css +161 -0
- package/package.json +1 -1
package/dist/registry.json
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
"path": "dowel/theme.css",
|
|
15
15
|
"target": "~/dowel/theme.css",
|
|
16
16
|
"type": "registry:file",
|
|
17
|
-
"content": "/*\n * dowel theme - the token vocabulary every product of the lacodda line shares.\n *\n * The vocabulary comes from the products themselves: kilna and kasl-server\n * already ship the same token names (bg / raise / soft / line / text / dim /\n * accent / good / warn / bad / info) and differ only in values. That is the\n * contract this file freezes. Names are the mockup's own words rather than\n * stock component-library names, so a screen can be checked against a mockup\n * in the mockup's words.\n *\n * Two things are parametric, and everything else is derived from them:\n *\n * --accent-base the product's hue from the brand-line registry\n * --neutral-base the hue the greys are tinted with (the accent, by default)\n *\n * Tinting the neutrals is not decoration - it is what the two live products do\n * by hand: kilna's greys lean magenta, kasl-server's lean gold. Here that lean\n * is one declaration instead of thirty hand-picked hex values.\n *\n * Soft variants are mixed from their own base with `color-mix`, so a product\n * that overrides `--accent-base` gets a matching `--accent-soft` for free and\n * cannot pick one that disagrees with it.\n *\n * Theme selection: no class on the root element follows the operating system,\n * an explicit `light` or `dark` class pins the theme. Components never use\n * `dark:` utilities - every colour goes through a token, and the theme swaps\n * the token underneath.\n */\n\n:root {\n /* The two parameters. `--accent-base` is overridden per product by an accent\n * file; `--neutral-base` follows it unless a product says otherwise. */\n --accent-base: #e8862d;\n --neutral-base: var(--accent-base);\n\n /* Ink and ground of the dark theme, before the neutral tint is mixed in.\n * Kept as their own tokens so the tint amount is the only thing that\n * changes when a product wants greyer or warmer chrome. */\n --ground: #131316;\n --ink: #ece9ef;\n\n /* How much of `--neutral-base` bleeds into the greys. The live products sit\n * at roughly this much: enough that the chrome belongs to the product,\n * little enough that it still reads as grey. */\n --neutral-tint: 6%;\n --neutral-tint-strong: 9%;\n\n color-scheme: dark;\n\n --bg: color-mix(in oklab, var(--neutral-base) var(--neutral-tint), var(--ground));\n --raise: color-mix(in oklab, var(--neutral-base) var(--neutral-tint), #1c1c21);\n\n /* Surfaces that lift by translucency rather than by their own colour: they\n * must work over `--bg` and over `--raise` alike. */\n --soft: rgb(255 255 255 / 0.045);\n --softer: rgb(255 255 255 / 0.025);\n --line: rgb(255 255 255 / 0.08);\n --line-2: rgb(255 255 255 / 0.15);\n\n --text: color-mix(in oklab, var(--neutral-base) var(--neutral-tint), var(--ink));\n --dim: color-mix(in oklab, var(--neutral-base) var(--neutral-tint-strong), #a7a2ad);\n --faint: color-mix(in oklab, var(--neutral-base) var(--neutral-tint-strong), #6e6a76);\n\n --accent: var(--accent-base);\n /* The hover/active partner: lighter on dark, where the ground is what the\n * accent has to separate from. */\n --accent-2: color-mix(in oklab, white 22%, var(--accent-base));\n --accent-soft: color-mix(in oklab, var(--accent-base) 16%, transparent);\n\n /* Status hues are the line's own and do not follow the product accent: a\n * green that shifted per product would stop meaning \"good\". Meaning never\n * rests on colour alone - a badge carries an icon and a word - so these\n * exist for emphasis, not as the message. */\n --good: #45d18f;\n --warn: #e8b13f;\n --bad: #ef6a6a;\n --info: #4cc4e0;\n --good-soft: color-mix(in oklab, var(--good) 14%, transparent);\n --warn-soft: color-mix(in oklab, var(--warn) 14%, transparent);\n --bad-soft: color-mix(in oklab, var(--bad) 14%, transparent);\n --info-soft: color-mix(in oklab, var(--info) 15%, transparent);\n\n /* Series colours, for charts.\n *\n * Like the status hues and unlike everything else here, these do NOT follow\n * the product accent - and that is the whole decision. A series is a\n * property of the data, not of the product showing it; a palette derived\n * from the accent would paint the same series differently in kilna and in\n * kasl-server, which is the one thing a series colour must never do.\n *\n * Deriving them from the accent was measured, not guessed: rotating the hue\n * by fixed angles put the best candidate at Delta-E 12.1 from the product's\n * own accent with adjacent pairs at 9.8, and threw 46 of 152 colours outside\n * sRGB. The fixed palette does better on both counts.\n *\n * The cost of fixing them is stated plainly: 13 of the line's 19 accents sit\n * within Delta-E 8 of some slot (lyrid 2.7, atlas 3.1, turnout 3.2). A chart\n * shows one accent, so this is survivable - but it is exactly why identity is\n * carried by the legend and by direct labels, never by hue on its own. The\n * components of this block enforce that; it is not advice.\n *\n * Assigned in order, 1..8, and never cycled: a ninth series folds into\n * \"other\", or the chart becomes small multiples. The order is the\n * colour-blind-safety mechanism - the worst adjacent pair is Delta-E 8.4\n * under simulated protanopia - so re-ordering these breaks a gate.\n *\n * Slot 8 is the line's own value rather than the reference palette's. The\n * reference red sat Delta-E 1.9 from `--bad`, which would have let a series\n * impersonate a status; this one clears every status by 15 and costs\n * nothing, the worst adjacent pair being unchanged.\n *\n * Some slots sit below 3:1 against the surface. That is allowed only because\n * the relief ships with them: visible values, or the table view.\n */\n --series-1: #3987e5;\n --series-2: #d95926;\n --series-3: #199e70;\n --series-4: #c98500;\n --series-5: #d55181;\n --series-6: #008300;\n --series-7: #9085e9;\n --series-8: #a3215a;\n\n /* Magnitude rather than identity: one hue, light to dark. A heatmap cell\n * reads this. The lightest step is allowed to recede toward the surface,\n * because \"near zero\" should; an ordered-but-discrete scale (tiers, funnel\n * stages) starts at 300 instead, where the contrast still holds. */\n --scale-100: #0d366b;\n --scale-200: #184f95;\n --scale-300: #256abf;\n --scale-400: #2a78d6;\n --scale-500: #3987e5;\n --scale-600: #5598e7;\n --scale-700: #86b6ef;\n\n /* The chart's own furniture. A gridline that competes with the data is drawn\n * wrong, so it is quieter than `--line`; the axis is the one that may be\n * seen. */\n --chart-grid: rgb(255 255 255 / 0.06);\n --chart-axis: rgb(255 255 255 / 0.14);\n\n /* Heat, for a grid of cells where colour is the only thing carrying the\n * value - a year of activity, a month of a team's hours.\n *\n * Five discrete steps rather than the seven of `--scale-*`, and a separate\n * set rather than a slice of them, because the two answer different\n * questions. `--scale-*` is sequential: a continuous magnitude, where the\n * lightest step means \"near zero\" and may recede toward the surface.\n * Heat is ordinal: five shades a reader tells apart at a glance and matches\n * against a legend, which needs wider gaps between them (no slice of\n * `--scale-*` clears the 0.06 lightness step - they sit 0.047 apart) and a\n * bottom step that stays distinct from an empty cell.\n *\n * That last one is the whole point. A heatmap cell has more meanings than a\n * number: nothing recorded, a day still running, a day outside the data, and\n * a value. If the faintest value looks like the empty cell, the grid says\n * somebody did nothing on a day nobody reported - and `--heat-1` clears the\n * empty square by 2.1:1 so it cannot.\n *\n * Fixed, like the series and unlike almost everything else here. Derived\n * from the accent - as the first consumer did - the faintest step sat 1.3:1\n * from the empty cell for half the line, and pinning lightness instead ran\n * out of sRGB: the darkest accents cannot reach the top of the ramp at their\n * own chroma.\n */\n --heat-1: #28528e;\n --heat-2: #3a65a3;\n --heat-3: #4d78b7;\n --heat-4: #608ccd;\n --heat-5: #73a0e2;\n\n /* Syntax, for code shown inside a product.\n *\n * Eight kinds rather than the fifty a highlighter's theme names, because\n * these are the distinctions that survive across languages: a keyword, a\n * literal string, a number, a comment, an identifier, a type, punctuation,\n * and the metadata around the code (a decorator, an attribute, a prompt).\n * Anything finer is a grammar's own vocabulary and does not travel.\n *\n * Fixed, like the series and the heat ramps, and for the same reason: code\n * is not a property of the product showing it, and `if` should not be\n * magenta in kilna and cobalt in kasl-server.\n *\n * Chosen against the surface code actually sits on - `--soft` over `--bg`,\n * which is what CodeBlock and `.prose pre` draw - and every slot clears\n * 4.5:1 AS TEXT in both themes. That threshold, not the 3:1 the series are\n * held to, is the whole reason these exist separately: the series palette\n * was measured here first and five of its eight light values sat under 3:1,\n * one at 1.90:1. A filled bar can be pale; a 12px glyph cannot.\n *\n * These are deliberately NOT held to the colour-blind floor the series are.\n * A series colour is the identity of its mark - lose the hue and the data is\n * gone. Syntax colour is a second reading of what the text already says in\n * full: a keyword is a keyword by its spelling, and CodeBlock's default\n * renders no colour at all and stays readable. `syntax.test.ts` says so in\n * place, so the omission is not mistaken for an oversight.\n *\n * `comment` is the quietest on purpose - it is the one kind a reader is\n * meant to be able to skip - and still clears the threshold.\n */\n --syntax-keyword: #c792ea;\n --syntax-string: #9ccc65;\n --syntax-number: #f78c6c;\n --syntax-comment: #9e99a6;\n --syntax-name: #82aaff;\n --syntax-type: #4dd0b1;\n --syntax-punctuation: #c4bfcb;\n --syntax-meta: #f0a868;\n\n /* Elevation, three steps. The products had one shadow and used it for\n * everything that leaves the flow - a toast, a dropdown and a modal all\n * floated by the same amount, so a modal never felt further away than the\n * menu it covered. The three are the steps.\n *\n * Two layers each, and that is what makes them read as depth on a dark\n * surface rather than as a stain. One big soft blur at high opacity is what\n * these were - `0 24px 60px` of 55% black - and against a near-black page\n * the wide skirt never resolves into shade: it just darkens a ring of\n * background around the panel, which a pilot photographed and called an ugly\n * dark contour. Depth is read from the CONTACT shadow, the tight one right\n * under the edge; the ambient layer only has to hint that there is room\n * under the thing. So each step is a tight, slightly stronger layer plus a\n * wider, much weaker one, and the totals come down. */\n --shadow-lift: 0 1px 2px rgb(0 0 0 / 0.4), 0 2px 6px rgb(0 0 0 / 0.22);\n --shadow-raise: 0 2px 4px rgb(0 0 0 / 0.36), 0 8px 20px rgb(0 0 0 / 0.26);\n --shadow-float: 0 4px 8px rgb(0 0 0 / 0.36), 0 18px 44px rgb(0 0 0 / 0.32);\n}\n\n/*\n * Light theme, twice: once for the operating system's preference, once for the\n * explicit `light` class. The declarations are identical - only the selector\n * differs - so that a product can pin a theme against the system setting.\n */\n@media (prefers-color-scheme: light) {\n :root:not(.dark) {\n color-scheme: light;\n\n --ground: #f6f5f7;\n --ink: #232027;\n\n --bg: color-mix(in oklab, var(--neutral-base) var(--neutral-tint), var(--ground));\n --raise: #ffffff;\n\n /* Tinted, and translucent - which took two goes to get right.\n *\n * `color-mix` mixes the alpha along with the colour, so mixing 60% of an\n * opaque accent into a 5%-opaque grey gives a surface 62% opaque: twelve\n * times denser than the hairline it was meant to be. It went unnoticed for\n * ten versions because `bg-soft` was only ever used for a hover, where a\n * flash of colour reads as feedback rather than as a mistake. The first\n * component to sit on it permanently - Alert - made it obvious.\n *\n * `oklch(from … / alpha)` keeps the alpha out of the mix: the hue comes\n * from the tinted colour, the transparency is stated. */\n --soft: oklch(from color-mix(in oklab, var(--neutral-base) 45%, #181420) l c h / 0.05);\n --softer: oklch(from color-mix(in oklab, var(--neutral-base) 45%, #181420) l c h / 0.03);\n --line: oklch(from color-mix(in oklab, var(--neutral-base) 30%, #181420) l c h / 0.11);\n --line-2: oklch(from color-mix(in oklab, var(--neutral-base) 30%, #181420) l c h / 0.2);\n\n --text: color-mix(in oklab, var(--neutral-base) var(--neutral-tint), var(--ink));\n --dim: color-mix(in oklab, var(--neutral-base) var(--neutral-tint-strong), #63606b);\n --faint: color-mix(in oklab, var(--neutral-base) var(--neutral-tint-strong), #8a8692);\n\n /* On a light ground the accent has to darken to stay legible as text and\n * as a fill. It darkens *to* a lightness rather than *by* an amount: how\n * far a hue has to travel depends on where it starts, and a fixed step\n * that suits magenta leaves lime and gold short. Pinning the lightness and\n * keeping the hue and chroma clears 5:1 for every accent in the line. */\n --accent: oklch(from var(--accent-base) 0.5 c h);\n --accent-2: oklch(from var(--accent-base) 0.4 c h);\n --accent-soft: color-mix(in oklab, var(--accent-base) 12%, transparent);\n\n --good: #0c8554;\n --warn: #9a6b0c;\n --bad: #c93b3b;\n --info: #0d7f9c;\n --good-soft: color-mix(in oklab, var(--good) 12%, transparent);\n --warn-soft: color-mix(in oklab, var(--warn) 14%, transparent);\n --bad-soft: color-mix(in oklab, var(--bad) 12%, transparent);\n --info-soft: color-mix(in oklab, var(--info) 11%, transparent);\n\n /* Series and scale, stepped for the light surface - the same eight hues\n * chosen again against this ground, not the dark values lightened. The dark\n * block says why these do not follow the product accent. */\n --series-1: #2a78d6;\n --series-2: #eb6834;\n --series-3: #1baf7a;\n --series-4: #eda100;\n --series-5: #e87ba4;\n --series-6: #008300;\n --series-7: #4a3aa7;\n --series-8: #85284d;\n\n --scale-100: #cde2fb;\n --scale-200: #9ec5f4;\n --scale-300: #6da7ec;\n --scale-400: #3987e5;\n --scale-500: #2a78d6;\n --scale-600: #1c5cab;\n --scale-700: #104281;\n\n --chart-grid: oklch(from color-mix(in oklab, var(--neutral-base) 30%, #181420) l c h / 0.08);\n --chart-axis: oklch(from color-mix(in oklab, var(--neutral-base) 30%, #181420) l c h / 0.18);\n\n /* The same five steps chosen again for the light ground, running the other\n * way: here \"more\" is darker. See the dark block for why heat is its own\n * ramp and not a slice of `--scale-*`. */\n --heat-1: #84a8fd;\n --heat-2: #7193e7;\n --heat-3: #5e7fd1;\n --heat-4: #4c6cbb;\n --heat-5: #3b58a6;\n\n /* The same eight kinds chosen again for the light ground - not the dark\n * values darkened. Each clears 4.5:1 as text against `--soft` over this\n * theme's `--bg`; the dark block says why syntax is its own palette and\n * why it is not held to the colour-blind floor. */\n --syntax-keyword: #7c3aad;\n --syntax-string: #276c2b;\n --syntax-number: #b5451b;\n --syntax-comment: #6f6b78;\n --syntax-name: #1a5fb4;\n --syntax-type: #00695c;\n --syntax-punctuation: #4a4753;\n --syntax-meta: #9c4a00;\n\n --shadow-lift: 0 2px 8px rgb(30 24 38 / 0.08);\n --shadow-raise: 0 10px 30px rgb(30 24 38 / 0.14);\n --shadow-float: 0 24px 60px rgb(30 24 38 / 0.18);\n }\n}\n\n:root.light {\n color-scheme: light;\n\n --ground: #f6f5f7;\n --ink: #232027;\n\n --bg: color-mix(in oklab, var(--neutral-base) var(--neutral-tint), var(--ground));\n --raise: #ffffff;\n\n /* The same surfaces as above; see the note there. */\n --soft: oklch(from color-mix(in oklab, var(--neutral-base) 45%, #181420) l c h / 0.05);\n --softer: oklch(from color-mix(in oklab, var(--neutral-base) 45%, #181420) l c h / 0.03);\n --line: oklch(from color-mix(in oklab, var(--neutral-base) 30%, #181420) l c h / 0.11);\n --line-2: oklch(from color-mix(in oklab, var(--neutral-base) 30%, #181420) l c h / 0.2);\n\n --text: color-mix(in oklab, var(--neutral-base) var(--neutral-tint), var(--ink));\n --dim: color-mix(in oklab, var(--neutral-base) var(--neutral-tint-strong), #63606b);\n --faint: color-mix(in oklab, var(--neutral-base) var(--neutral-tint-strong), #8a8692);\n\n /* Darkened to a lightness, not by an amount - see the note above. */\n --accent: oklch(from var(--accent-base) 0.5 c h);\n --accent-2: oklch(from var(--accent-base) 0.4 c h);\n --accent-soft: color-mix(in oklab, var(--accent-base) 12%, transparent);\n\n --good: #0c8554;\n --warn: #9a6b0c;\n --bad: #c93b3b;\n --info: #0d7f9c;\n --good-soft: color-mix(in oklab, var(--good) 12%, transparent);\n --warn-soft: color-mix(in oklab, var(--warn) 14%, transparent);\n --bad-soft: color-mix(in oklab, var(--bad) 12%, transparent);\n --info-soft: color-mix(in oklab, var(--info) 11%, transparent);\n\n /* Series and scale, stepped for the light surface - the same eight hues\n * chosen again against this ground, not the dark values lightened. The dark\n * block says why these do not follow the product accent. */\n --series-1: #2a78d6;\n --series-2: #eb6834;\n --series-3: #1baf7a;\n --series-4: #eda100;\n --series-5: #e87ba4;\n --series-6: #008300;\n --series-7: #4a3aa7;\n --series-8: #85284d;\n\n --scale-100: #cde2fb;\n --scale-200: #9ec5f4;\n --scale-300: #6da7ec;\n --scale-400: #3987e5;\n --scale-500: #2a78d6;\n --scale-600: #1c5cab;\n --scale-700: #104281;\n\n --chart-grid: oklch(from color-mix(in oklab, var(--neutral-base) 30%, #181420) l c h / 0.08);\n --chart-axis: oklch(from color-mix(in oklab, var(--neutral-base) 30%, #181420) l c h / 0.18);\n\n /* The same five steps chosen again for the light ground, running the other\n * way: here \"more\" is darker. See the dark block for why heat is its own\n * ramp and not a slice of `--scale-*`. */\n --heat-1: #84a8fd;\n --heat-2: #7193e7;\n --heat-3: #5e7fd1;\n --heat-4: #4c6cbb;\n --heat-5: #3b58a6;\n\n /* The same eight kinds chosen again for the light ground - not the dark\n * values darkened. Each clears 4.5:1 as text against `--soft` over this\n * theme's `--bg`; the dark block says why syntax is its own palette and\n * why it is not held to the colour-blind floor. */\n --syntax-keyword: #7c3aad;\n --syntax-string: #276c2b;\n --syntax-number: #b5451b;\n --syntax-comment: #6f6b78;\n --syntax-name: #1a5fb4;\n --syntax-type: #00695c;\n --syntax-punctuation: #4a4753;\n --syntax-meta: #9c4a00;\n\n --shadow-lift: 0 2px 8px rgb(30 24 38 / 0.08);\n --shadow-raise: 0 10px 30px rgb(30 24 38 / 0.14);\n --shadow-float: 0 24px 60px rgb(30 24 38 / 0.18);\n}\n\n/*\n * `--on-accent` - what sits on top of an accent fill.\n *\n * A light accent (gold, lime, amber) needs dark glyphs; a dark one needs\n * white. The live products picked this by hand and wrote the answer into the\n * theme; here the theme works it out, by the same rule the brand-line S tile\n * uses.\n *\n * `contrast-color()` is the direct way to say it and is used where supported.\n * The fallback covers browsers that lack it. Relative colour syntax exposes\n * the accent's own lightness as `l`; `clamp()` turns that into a hard switch,\n * because the multiplication drives the middle term far past either bound\n * everywhere except within a hair of the threshold. Below it the accent is\n * dark and the result is 1 (white); above it, 0 (black). Chroma is dropped to\n * zero, so what comes out is neutral rather than a tinted grey.\n *\n * The threshold is 0.58, and it is deliberately far below the midpoint an eye\n * would guess. Contrast is not symmetric about it: a mid-lightness colour is\n * still much closer to white than to black in luminance, so black wins well\n * before the colour looks light. Checked against all fourteen accents of the\n * line - every one of them reads better with dark glyphs, the closest being\n * cobalt at 4.86:1 against 4.32:1 for white. A higher threshold is what puts\n * white text on magenta at 3.6:1, which is the defect this rule exists to\n * prevent.\n */\n:root {\n --on-accent: oklch(from var(--accent) clamp(0, (0.58 - l) * 1000, 1) 0 0);\n\n /*\n * The same question for the status fills, and it has to be asked separately:\n * `--on-accent` is derived from the accent, so using it on a `--warn` fill\n * is only ever right by coincidence. The line's first consumer did exactly\n * that - a count on a yellow badge, drawn in white at 1.95:1 - and it read\n * as correct for as long as the product happened to pin white.\n *\n * The status hues do not follow the product accent, so these four are the\n * same for every product; they are still derived rather than written down,\n * because the status colours themselves change between the themes.\n */\n --on-good: oklch(from var(--good) clamp(0, (0.58 - l) * 1000, 1) 0 0);\n --on-warn: oklch(from var(--warn) clamp(0, (0.58 - l) * 1000, 1) 0 0);\n --on-bad: oklch(from var(--bad) clamp(0, (0.58 - l) * 1000, 1) 0 0);\n --on-info: oklch(from var(--info) clamp(0, (0.58 - l) * 1000, 1) 0 0);\n}\n\n@supports (color: contrast-color(red)) {\n :root {\n --on-accent: contrast-color(var(--accent));\n --on-good: contrast-color(var(--good));\n --on-warn: contrast-color(var(--warn));\n --on-bad: contrast-color(var(--bad));\n --on-info: contrast-color(var(--info));\n }\n}\n\n/*\n * The Tailwind 4 surface. `--color-*: initial` drops the stock palette on\n * purpose: a raw `bg-zinc-800` in a product should not compile, because the\n * only colours that exist here are the line's own.\n */\n@theme inline {\n --color-*: initial;\n --color-bg: var(--bg);\n --color-raise: var(--raise);\n --color-soft: var(--soft);\n --color-softer: var(--softer);\n --color-line: var(--line);\n --color-line-2: var(--line-2);\n --color-text: var(--text);\n --color-dim: var(--dim);\n --color-faint: var(--faint);\n --color-accent: var(--accent);\n --color-accent-2: var(--accent-2);\n --color-accent-soft: var(--accent-soft);\n --color-on-accent: var(--on-accent);\n --color-on-good: var(--on-good);\n --color-on-warn: var(--on-warn);\n --color-on-bad: var(--on-bad);\n --color-on-info: var(--on-info);\n --color-good: var(--good);\n --color-good-soft: var(--good-soft);\n --color-warn: var(--warn);\n --color-warn-soft: var(--warn-soft);\n --color-bad: var(--bad);\n --color-bad-soft: var(--bad-soft);\n --color-info: var(--info);\n --color-info-soft: var(--info-soft);\n\n /* Charts. `bg-series-3`, `fill-series-3`, `stroke-scale-500` - the same\n * utilities the rest of the vocabulary gets, so a chart is written in\n * tokens like everything else and `dowel/no-raw-color` can hold it to\n * that. */\n --color-series-1: var(--series-1);\n --color-series-2: var(--series-2);\n --color-series-3: var(--series-3);\n --color-series-4: var(--series-4);\n --color-series-5: var(--series-5);\n --color-series-6: var(--series-6);\n --color-series-7: var(--series-7);\n --color-series-8: var(--series-8);\n --color-scale-100: var(--scale-100);\n --color-scale-200: var(--scale-200);\n --color-scale-300: var(--scale-300);\n --color-scale-400: var(--scale-400);\n --color-scale-500: var(--scale-500);\n --color-scale-600: var(--scale-600);\n --color-scale-700: var(--scale-700);\n --color-chart-grid: var(--chart-grid);\n --color-chart-axis: var(--chart-axis);\n --color-heat-1: var(--heat-1);\n --color-heat-2: var(--heat-2);\n --color-heat-3: var(--heat-3);\n --color-heat-4: var(--heat-4);\n --color-heat-5: var(--heat-5);\n\n /* Syntax, so a highlighted token is written `text-syntax-keyword` like every\n * other colour in the system, and `dowel/no-raw-color` can hold it to that.\n * A highlighter's own stylesheet - which writes hex values into class names\n * of its own choosing - is what this replaces. */\n --color-syntax-keyword: var(--syntax-keyword);\n --color-syntax-string: var(--syntax-string);\n --color-syntax-number: var(--syntax-number);\n --color-syntax-comment: var(--syntax-comment);\n --color-syntax-name: var(--syntax-name);\n --color-syntax-type: var(--syntax-type);\n --color-syntax-punctuation: var(--syntax-punctuation);\n --color-syntax-meta: var(--syntax-meta);\n\n /* Kept because they are not palette choices: a hairline is `transparent`,\n * an SVG follows `currentColor`, and pure black and white are what an\n * overlay scrim and a print sheet are made of. */\n --color-transparent: transparent;\n --color-current: currentColor;\n --color-white: #fff;\n --color-black: #000;\n\n /*\n * Type. System stacks on purpose: a downloaded face costs a network round\n * trip before the first word appears, and the line's products are desktop\n * tools where the operating system's own face is the one the user already\n * reads everything else in.\n */\n --font-sans: 'Segoe UI Variable Text', 'Segoe UI', system-ui, -apple-system, sans-serif;\n --font-mono: ui-monospace, 'Cascadia Code', 'SF Mono', Consolas, monospace;\n\n /*\n * Radius. Taken from what the products actually draw, not from a ratio:\n * `rounded-[9px]` appears twenty times and `rounded-[10px]` twelve, because\n * a control and the primary button were tuned by eye and then copied. The\n * scale keeps the cluster they landed in and gives it names.\n *\n * `md` is the control radius - inputs, buttons, list rows. That the primary\n * button was one pixel rounder than every other variant is not preserved:\n * the products differ from themselves there, and buttons of the same size\n * sitting side by side should not have mismatched corners.\n */\n --radius-xs: 4px;\n --radius-sm: 6px;\n --radius-md: 9px;\n --radius-lg: 12px;\n --radius-xl: 16px;\n --radius-2xl: 20px;\n\n /*\n * A radius nested inside another has to be smaller by the gap between them,\n * or the inner corner looks wrong against the outer one. The products did\n * this by hand once - 18px outside, 17px inside - and nowhere else.\n */\n --radius-inner: calc(var(--radius-lg) - 1px);\n\n /*\n * Type scale. The products live between 10px and 14px: `text-sm` and\n * `text-xs` together account for nine tenths of every size in both, and the\n * rest scattered across 9, 9.5, 10, 10.5, 11, 11.5, 12.5 and 13 - nine steps\n * inside four pixels, which no eye distinguishes and no reason justifies.\n * This is the same range with the noise removed.\n */\n --text-2xs: 10px;\n --text-2xs--line-height: 14px;\n --text-xs: 11px;\n --text-xs--line-height: 15px;\n --text-sm: 12px;\n --text-sm--line-height: 16px;\n --text-base: 14px;\n --text-base--line-height: 20px;\n --text-lg: 16px;\n --text-lg--line-height: 22px;\n --text-xl: 18px;\n --text-xl--line-height: 24px;\n --text-2xl: 21px;\n --text-2xl--line-height: 28px;\n\n /* Weights. `semibold` is what both products use for anything emphasised;\n * `bold` appears in neither, and the one `font-[650]` in a page title is the\n * kind of value a scale exists to absorb. */\n --font-weight-normal: 400;\n --font-weight-medium: 500;\n --font-weight-semibold: 600;\n\n /* Tracking. The uppercase caption is the only place the products track at\n * all - and they do it at 0.08em in six files and 0.09em in three, a\n * difference nobody can see. One name settles it. */\n --tracking-caption: 0.085em;\n --tracking-tight: -0.01em;\n\n /* Easing. `out` for anything the user asked for - it arrives fast and\n * settles, which reads as responsive. `in-out` for something moving on its\n * own. `in` is deliberately absent: it starts slowly, which on a control\n * reads as lag. */\n --ease-out: cubic-bezier(0.2, 0, 0, 1);\n --ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);\n\n /*\n * Elevation. Three steps, because the products had one and used it for a\n * toast, a dropdown and a modal alike - so a modal never sat further from\n * the page than the menu it covered.\n */\n --shadow-lift: var(--shadow-lift);\n --shadow-raise: var(--shadow-raise);\n --shadow-float: var(--shadow-float);\n}\n\n/*\n * Stacking order.\n *\n * Not in `@theme`: Tailwind has no z-index namespace, so `z-50` is a literal\n * fifty and a named step would not compile. These are custom properties a\n * component reads directly - `z-index: var(--z-modal)`.\n *\n * The order is the products' own, with the gaps closed. They ran 10 for an\n * in-flow popup, 20 sticky, 30 menu, 40 for a floating button, 50 for modals\n * and drawers, then jumped to 70 and 80 for the command palette - which had to\n * clear the modal layer and had no name to do it with.\n */\n:root {\n /*\n * Motion. One duration existed before this - 160ms on a route change - and\n * everything else rode Tailwind's default. These are the steps around it:\n * `quick` for a colour or an opacity that should feel immediate, `base` for\n * something that moves, `slow` for something arriving from off-screen.\n *\n * Not in `@theme`: Tailwind's `duration-*` utility takes a literal number,\n * not a named step, so these are read directly - `transition-duration:\n * var(--duration-base)`. The easing curves opposite them ARE a namespace,\n * so `ease-out` is a class.\n *\n * Every duration here is for people who want motion: `prefers-reduced-\n * motion` cuts them to nothing further down.\n */\n --duration-quick: 120ms;\n --duration-base: 160ms;\n --duration-slow: 240ms;\n\n --z-popup: 10;\n --z-sticky: 20;\n --z-menu: 30;\n --z-floating: 40;\n --z-overlay: 50;\n --z-modal: 60;\n --z-palette: 70;\n --z-toast: 80;\n}\n\n/*\n * Base layer: what every product would otherwise write again. Scoped to\n * elements and to `:focus-visible`, never to a class, so nothing here can\n * collide with a component.\n */\nbody {\n margin: 0;\n background-color: var(--bg);\n color: var(--text);\n font-family: var(--font-sans);\n -webkit-font-smoothing: antialiased;\n}\n\n/*\n * In `@layer base`, so a component can turn it off.\n *\n * Unlayered, this rule has the same specificity as `focus-visible:outline-none`\n * from Tailwind - both are one pseudo-class - and wins on source order alone,\n * because the theme is imported before the utilities. Every component that\n * draws its own focus ring got this one on top of it: the command palette's\n * field had an accent outline it had explicitly opted out of, and a combobox\n * with chips drew two rings, one around the box and one around the input\n * inside it.\n *\n * A layered rule loses to any unlayered one regardless of specificity, which\n * is the whole point of cascade layers - the base layer states a default and\n * a component overrides it by saying so.\n */\n@layer base {\n :focus-visible {\n outline: 2px solid var(--accent);\n outline-offset: 2px;\n }\n}\n\n@media (prefers-reduced-motion: reduce) {\n *,\n *::before,\n *::after {\n transition-duration: 0.01ms !important;\n animation-duration: 0.01ms !important;\n animation-iteration-count: 1 !important;\n scroll-behavior: auto !important;\n }\n}\n\n/*\n * Scrollbars. A browser's default bar is a piece of someone else's chrome\n * sitting in the middle of the product - full width, with step arrows. These\n * are the line's own: thin, in the palette, drawn only where something\n * actually scrolls.\n */\n* {\n scrollbar-width: thin;\n scrollbar-color: var(--line-2) transparent;\n}\n\n*::-webkit-scrollbar {\n width: 10px;\n height: 10px;\n}\n\n*::-webkit-scrollbar-track {\n background: transparent;\n}\n\n*::-webkit-scrollbar-thumb {\n border: 3px solid transparent;\n border-radius: 999px;\n background: var(--line-2);\n background-clip: content-box;\n /* The thumb is drawn proportional to how much of the document fits on\n * screen, so a long one collapses it to a few pixels: still visible, no\n * longer catchable by a pointer. This is the floor below which it stops\n * being a control. */\n min-height: 2.5rem;\n}\n\n*::-webkit-scrollbar-thumb:hover {\n background: var(--dim);\n background-clip: content-box;\n}\n\n*::-webkit-scrollbar-corner {\n background: transparent;\n}\n\n*::-webkit-scrollbar-button {\n display: none;\n}\n"
|
|
17
|
+
"content": "/*\n * dowel theme - the token vocabulary every product of the lacodda line shares.\n *\n * The vocabulary comes from the products themselves: kilna and kasl-server\n * already ship the same token names (bg / raise / soft / line / text / dim /\n * accent / good / warn / bad / info) and differ only in values. That is the\n * contract this file freezes. Names are the mockup's own words rather than\n * stock component-library names, so a screen can be checked against a mockup\n * in the mockup's words.\n *\n * Two things are parametric, and everything else is derived from them:\n *\n * --accent-base the product's hue from the brand-line registry\n * --neutral-base the hue the greys are tinted with (the accent, by default)\n *\n * Tinting the neutrals is not decoration - it is what the two live products do\n * by hand: kilna's greys lean magenta, kasl-server's lean gold. Here that lean\n * is one declaration instead of thirty hand-picked hex values.\n *\n * Soft variants are mixed from their own base with `color-mix`, so a product\n * that overrides `--accent-base` gets a matching `--accent-soft` for free and\n * cannot pick one that disagrees with it.\n *\n * Theme selection: no class on the root element follows the operating system,\n * an explicit `light` or `dark` class pins the theme. Components never use\n * `dark:` utilities - every colour goes through a token, and the theme swaps\n * the token underneath.\n */\n\n:root {\n /* The two parameters. `--accent-base` is overridden per product by an accent\n * file; `--neutral-base` follows it unless a product says otherwise. */\n --accent-base: #e8862d;\n --neutral-base: var(--accent-base);\n\n /* Ink and ground of the dark theme, before the neutral tint is mixed in.\n * Kept as their own tokens so the tint amount is the only thing that\n * changes when a product wants greyer or warmer chrome. */\n --ground: #131316;\n --ink: #ece9ef;\n\n /* How much of `--neutral-base` bleeds into the greys. The live products sit\n * at roughly this much: enough that the chrome belongs to the product,\n * little enough that it still reads as grey. */\n --neutral-tint: 6%;\n --neutral-tint-strong: 9%;\n\n color-scheme: dark;\n\n --bg: color-mix(in oklab, var(--neutral-base) var(--neutral-tint), var(--ground));\n --raise: color-mix(in oklab, var(--neutral-base) var(--neutral-tint), #1c1c21);\n\n /* Surfaces that lift by translucency rather than by their own colour: they\n * must work over `--bg` and over `--raise` alike. */\n --soft: rgb(255 255 255 / 0.045);\n --softer: rgb(255 255 255 / 0.025);\n --line: rgb(255 255 255 / 0.08);\n --line-2: rgb(255 255 255 / 0.15);\n\n --text: color-mix(in oklab, var(--neutral-base) var(--neutral-tint), var(--ink));\n --dim: color-mix(in oklab, var(--neutral-base) var(--neutral-tint-strong), #a7a2ad);\n --faint: color-mix(in oklab, var(--neutral-base) var(--neutral-tint-strong), #6e6a76);\n\n --accent: var(--accent-base);\n /* The hover/active partner: lighter on dark, where the ground is what the\n * accent has to separate from. */\n --accent-2: color-mix(in oklab, white 22%, var(--accent-base));\n --accent-soft: color-mix(in oklab, var(--accent-base) 16%, transparent);\n\n /* Status hues are the line's own and do not follow the product accent: a\n * green that shifted per product would stop meaning \"good\". Meaning never\n * rests on colour alone - a badge carries an icon and a word - so these\n * exist for emphasis, not as the message. */\n --good: #45d18f;\n --warn: #e8b13f;\n --bad: #ef6a6a;\n --info: #4cc4e0;\n --good-soft: color-mix(in oklab, var(--good) 14%, transparent);\n --warn-soft: color-mix(in oklab, var(--warn) 14%, transparent);\n --bad-soft: color-mix(in oklab, var(--bad) 14%, transparent);\n --info-soft: color-mix(in oklab, var(--info) 15%, transparent);\n\n /* Series colours, for charts.\n *\n * Like the status hues and unlike everything else here, these do NOT follow\n * the product accent - and that is the whole decision. A series is a\n * property of the data, not of the product showing it; a palette derived\n * from the accent would paint the same series differently in kilna and in\n * kasl-server, which is the one thing a series colour must never do.\n *\n * Deriving them from the accent was measured, not guessed: rotating the hue\n * by fixed angles put the best candidate at Delta-E 12.1 from the product's\n * own accent with adjacent pairs at 9.8, and threw 46 of 152 colours outside\n * sRGB. The fixed palette does better on both counts.\n *\n * The cost of fixing them is stated plainly: 13 of the line's 19 accents sit\n * within Delta-E 8 of some slot (lyrid 2.7, atlas 3.1, turnout 3.2). A chart\n * shows one accent, so this is survivable - but it is exactly why identity is\n * carried by the legend and by direct labels, never by hue on its own. The\n * components of this block enforce that; it is not advice.\n *\n * Assigned in order, 1..8, and never cycled: a ninth series folds into\n * \"other\", or the chart becomes small multiples. The order is the\n * colour-blind-safety mechanism - the worst adjacent pair is Delta-E 8.4\n * under simulated protanopia - so re-ordering these breaks a gate.\n *\n * Slot 8 is the line's own value rather than the reference palette's. The\n * reference red sat Delta-E 1.9 from `--bad`, which would have let a series\n * impersonate a status; this one clears every status by 15 and costs\n * nothing, the worst adjacent pair being unchanged.\n *\n * Some slots sit below 3:1 against the surface. That is allowed only because\n * the relief ships with them: visible values, or the table view.\n */\n --series-1: #3987e5;\n --series-2: #d95926;\n --series-3: #199e70;\n --series-4: #c98500;\n --series-5: #d55181;\n --series-6: #008300;\n --series-7: #9085e9;\n --series-8: #a3215a;\n\n /* Magnitude rather than identity: one hue, light to dark. A heatmap cell\n * reads this. The lightest step is allowed to recede toward the surface,\n * because \"near zero\" should; an ordered-but-discrete scale (tiers, funnel\n * stages) starts at 300 instead, where the contrast still holds. */\n --scale-100: #0d366b;\n --scale-200: #184f95;\n --scale-300: #256abf;\n --scale-400: #2a78d6;\n --scale-500: #3987e5;\n --scale-600: #5598e7;\n --scale-700: #86b6ef;\n\n /* The chart's own furniture. A gridline that competes with the data is drawn\n * wrong, so it is quieter than `--line`; the axis is the one that may be\n * seen. */\n --chart-grid: rgb(255 255 255 / 0.06);\n --chart-axis: rgb(255 255 255 / 0.14);\n\n /* Heat, for a grid of cells where colour is the only thing carrying the\n * value - a year of activity, a month of a team's hours.\n *\n * Five discrete steps rather than the seven of `--scale-*`, and a separate\n * set rather than a slice of them, because the two answer different\n * questions. `--scale-*` is sequential: a continuous magnitude, where the\n * lightest step means \"near zero\" and may recede toward the surface.\n * Heat is ordinal: five shades a reader tells apart at a glance and matches\n * against a legend, which needs wider gaps between them (no slice of\n * `--scale-*` clears the 0.06 lightness step - they sit 0.047 apart) and a\n * bottom step that stays distinct from an empty cell.\n *\n * That last one is the whole point. A heatmap cell has more meanings than a\n * number: nothing recorded, a day still running, a day outside the data, and\n * a value. If the faintest value looks like the empty cell, the grid says\n * somebody did nothing on a day nobody reported - and `--heat-1` clears the\n * empty square by 2.1:1 so it cannot.\n *\n * Fixed, like the series and unlike almost everything else here. Derived\n * from the accent - as the first consumer did - the faintest step sat 1.3:1\n * from the empty cell for half the line, and pinning lightness instead ran\n * out of sRGB: the darkest accents cannot reach the top of the ramp at their\n * own chroma.\n */\n --heat-1: #28528e;\n --heat-2: #3a65a3;\n --heat-3: #4d78b7;\n --heat-4: #608ccd;\n --heat-5: #73a0e2;\n\n /* Syntax, for code shown inside a product.\n *\n * Eight kinds rather than the fifty a highlighter's theme names, because\n * these are the distinctions that survive across languages: a keyword, a\n * literal string, a number, a comment, an identifier, a type, punctuation,\n * and the metadata around the code (a decorator, an attribute, a prompt).\n * Anything finer is a grammar's own vocabulary and does not travel.\n *\n * Fixed, like the series and the heat ramps, and for the same reason: code\n * is not a property of the product showing it, and `if` should not be\n * magenta in kilna and cobalt in kasl-server.\n *\n * Chosen against the surface code actually sits on - `--soft` over `--bg`,\n * which is what CodeBlock and `.prose pre` draw - and every slot clears\n * 4.5:1 AS TEXT in both themes. That threshold, not the 3:1 the series are\n * held to, is the whole reason these exist separately: the series palette\n * was measured here first and five of its eight light values sat under 3:1,\n * one at 1.90:1. A filled bar can be pale; a 12px glyph cannot.\n *\n * These are deliberately NOT held to the colour-blind floor the series are.\n * A series colour is the identity of its mark - lose the hue and the data is\n * gone. Syntax colour is a second reading of what the text already says in\n * full: a keyword is a keyword by its spelling, and CodeBlock's default\n * renders no colour at all and stays readable. `syntax.test.ts` says so in\n * place, so the omission is not mistaken for an oversight.\n *\n * `comment` is the quietest on purpose - it is the one kind a reader is\n * meant to be able to skip - and still clears the threshold.\n */\n --syntax-keyword: #c792ea;\n --syntax-string: #9ccc65;\n --syntax-number: #f78c6c;\n --syntax-comment: #9e99a6;\n --syntax-name: #82aaff;\n --syntax-type: #4dd0b1;\n --syntax-punctuation: #c4bfcb;\n --syntax-meta: #f0a868;\n\n /* Elevation, three steps. The products had one shadow and used it for\n * everything that leaves the flow - a toast, a dropdown and a modal all\n * floated by the same amount, so a modal never felt further away than the\n * menu it covered. The three are the steps.\n *\n * Two layers each, and that is what makes them read as depth on a dark\n * surface rather than as a stain. One big soft blur at high opacity is what\n * these were - `0 24px 60px` of 55% black - and against a near-black page\n * the wide skirt never resolves into shade: it just darkens a ring of\n * background around the panel, which a pilot photographed and called an ugly\n * dark contour. Depth is read from the CONTACT shadow, the tight one right\n * under the edge; the ambient layer only has to hint that there is room\n * under the thing. So each step is a tight, slightly stronger layer plus a\n * wider, much weaker one, and the totals come down. */\n --shadow-lift: 0 1px 2px rgb(0 0 0 / 0.4), 0 2px 6px rgb(0 0 0 / 0.22);\n --shadow-raise: 0 2px 4px rgb(0 0 0 / 0.36), 0 8px 20px rgb(0 0 0 / 0.26);\n --shadow-float: 0 4px 8px rgb(0 0 0 / 0.36), 0 18px 44px rgb(0 0 0 / 0.32);\n}\n\n/*\n * Light theme, twice: once for the operating system's preference, once for the\n * explicit `light` class. The declarations are identical - only the selector\n * differs - so that a product can pin a theme against the system setting.\n */\n@media (prefers-color-scheme: light) {\n :root:not(.dark) {\n color-scheme: light;\n\n --ground: #f6f5f7;\n --ink: #232027;\n\n --bg: color-mix(in oklab, var(--neutral-base) var(--neutral-tint), var(--ground));\n --raise: #ffffff;\n\n /* Tinted, and translucent - which took two goes to get right.\n *\n * `color-mix` mixes the alpha along with the colour, so mixing 60% of an\n * opaque accent into a 5%-opaque grey gives a surface 62% opaque: twelve\n * times denser than the hairline it was meant to be. It went unnoticed for\n * ten versions because `bg-soft` was only ever used for a hover, where a\n * flash of colour reads as feedback rather than as a mistake. The first\n * component to sit on it permanently - Alert - made it obvious.\n *\n * `oklch(from … / alpha)` keeps the alpha out of the mix: the hue comes\n * from the tinted colour, the transparency is stated. */\n --soft: oklch(from color-mix(in oklab, var(--neutral-base) 45%, #181420) l c h / 0.05);\n --softer: oklch(from color-mix(in oklab, var(--neutral-base) 45%, #181420) l c h / 0.03);\n --line: oklch(from color-mix(in oklab, var(--neutral-base) 30%, #181420) l c h / 0.11);\n --line-2: oklch(from color-mix(in oklab, var(--neutral-base) 30%, #181420) l c h / 0.2);\n\n --text: color-mix(in oklab, var(--neutral-base) var(--neutral-tint), var(--ink));\n --dim: color-mix(in oklab, var(--neutral-base) var(--neutral-tint-strong), #63606b);\n --faint: color-mix(in oklab, var(--neutral-base) var(--neutral-tint-strong), #8a8692);\n\n /* On a light ground the accent has to darken to stay legible as text and\n * as a fill. It darkens *to* a lightness rather than *by* an amount: how\n * far a hue has to travel depends on where it starts, and a fixed step\n * that suits magenta leaves lime and gold short. Pinning the lightness and\n * keeping the hue and chroma clears 5:1 for every accent in the line. */\n --accent: oklch(from var(--accent-base) 0.5 c h);\n --accent-2: oklch(from var(--accent-base) 0.4 c h);\n --accent-soft: color-mix(in oklab, var(--accent-base) 12%, transparent);\n\n --good: #0c8554;\n --warn: #9a6b0c;\n --bad: #c93b3b;\n --info: #0d7f9c;\n --good-soft: color-mix(in oklab, var(--good) 12%, transparent);\n --warn-soft: color-mix(in oklab, var(--warn) 14%, transparent);\n --bad-soft: color-mix(in oklab, var(--bad) 12%, transparent);\n --info-soft: color-mix(in oklab, var(--info) 11%, transparent);\n\n /* Series and scale, stepped for the light surface - the same eight hues\n * chosen again against this ground, not the dark values lightened. The dark\n * block says why these do not follow the product accent. */\n --series-1: #2a78d6;\n --series-2: #eb6834;\n --series-3: #1baf7a;\n --series-4: #eda100;\n --series-5: #e87ba4;\n --series-6: #008300;\n --series-7: #4a3aa7;\n --series-8: #85284d;\n\n --scale-100: #cde2fb;\n --scale-200: #9ec5f4;\n --scale-300: #6da7ec;\n --scale-400: #3987e5;\n --scale-500: #2a78d6;\n --scale-600: #1c5cab;\n --scale-700: #104281;\n\n --chart-grid: oklch(from color-mix(in oklab, var(--neutral-base) 30%, #181420) l c h / 0.08);\n --chart-axis: oklch(from color-mix(in oklab, var(--neutral-base) 30%, #181420) l c h / 0.18);\n\n /* The same five steps chosen again for the light ground, running the other\n * way: here \"more\" is darker. See the dark block for why heat is its own\n * ramp and not a slice of `--scale-*`. */\n --heat-1: #84a8fd;\n --heat-2: #7193e7;\n --heat-3: #5e7fd1;\n --heat-4: #4c6cbb;\n --heat-5: #3b58a6;\n\n /* The same eight kinds chosen again for the light ground - not the dark\n * values darkened. Each clears 4.5:1 as text against `--soft` over this\n * theme's `--bg`; the dark block says why syntax is its own palette and\n * why it is not held to the colour-blind floor. */\n --syntax-keyword: #7c3aad;\n --syntax-string: #276c2b;\n --syntax-number: #b5451b;\n --syntax-comment: #6f6b78;\n --syntax-name: #1a5fb4;\n --syntax-type: #00695c;\n --syntax-punctuation: #4a4753;\n --syntax-meta: #9c4a00;\n\n --shadow-lift: 0 2px 8px rgb(30 24 38 / 0.08);\n --shadow-raise: 0 10px 30px rgb(30 24 38 / 0.14);\n --shadow-float: 0 24px 60px rgb(30 24 38 / 0.18);\n }\n}\n\n:root.light {\n color-scheme: light;\n\n --ground: #f6f5f7;\n --ink: #232027;\n\n --bg: color-mix(in oklab, var(--neutral-base) var(--neutral-tint), var(--ground));\n --raise: #ffffff;\n\n /* The same surfaces as above; see the note there. */\n --soft: oklch(from color-mix(in oklab, var(--neutral-base) 45%, #181420) l c h / 0.05);\n --softer: oklch(from color-mix(in oklab, var(--neutral-base) 45%, #181420) l c h / 0.03);\n --line: oklch(from color-mix(in oklab, var(--neutral-base) 30%, #181420) l c h / 0.11);\n --line-2: oklch(from color-mix(in oklab, var(--neutral-base) 30%, #181420) l c h / 0.2);\n\n --text: color-mix(in oklab, var(--neutral-base) var(--neutral-tint), var(--ink));\n --dim: color-mix(in oklab, var(--neutral-base) var(--neutral-tint-strong), #63606b);\n --faint: color-mix(in oklab, var(--neutral-base) var(--neutral-tint-strong), #8a8692);\n\n /* Darkened to a lightness, not by an amount - see the note above. */\n --accent: oklch(from var(--accent-base) 0.5 c h);\n --accent-2: oklch(from var(--accent-base) 0.4 c h);\n --accent-soft: color-mix(in oklab, var(--accent-base) 12%, transparent);\n\n --good: #0c8554;\n --warn: #9a6b0c;\n --bad: #c93b3b;\n --info: #0d7f9c;\n --good-soft: color-mix(in oklab, var(--good) 12%, transparent);\n --warn-soft: color-mix(in oklab, var(--warn) 14%, transparent);\n --bad-soft: color-mix(in oklab, var(--bad) 12%, transparent);\n --info-soft: color-mix(in oklab, var(--info) 11%, transparent);\n\n /* Series and scale, stepped for the light surface - the same eight hues\n * chosen again against this ground, not the dark values lightened. The dark\n * block says why these do not follow the product accent. */\n --series-1: #2a78d6;\n --series-2: #eb6834;\n --series-3: #1baf7a;\n --series-4: #eda100;\n --series-5: #e87ba4;\n --series-6: #008300;\n --series-7: #4a3aa7;\n --series-8: #85284d;\n\n --scale-100: #cde2fb;\n --scale-200: #9ec5f4;\n --scale-300: #6da7ec;\n --scale-400: #3987e5;\n --scale-500: #2a78d6;\n --scale-600: #1c5cab;\n --scale-700: #104281;\n\n --chart-grid: oklch(from color-mix(in oklab, var(--neutral-base) 30%, #181420) l c h / 0.08);\n --chart-axis: oklch(from color-mix(in oklab, var(--neutral-base) 30%, #181420) l c h / 0.18);\n\n /* The same five steps chosen again for the light ground, running the other\n * way: here \"more\" is darker. See the dark block for why heat is its own\n * ramp and not a slice of `--scale-*`. */\n --heat-1: #84a8fd;\n --heat-2: #7193e7;\n --heat-3: #5e7fd1;\n --heat-4: #4c6cbb;\n --heat-5: #3b58a6;\n\n /* The same eight kinds chosen again for the light ground - not the dark\n * values darkened. Each clears 4.5:1 as text against `--soft` over this\n * theme's `--bg`; the dark block says why syntax is its own palette and\n * why it is not held to the colour-blind floor. */\n --syntax-keyword: #7c3aad;\n --syntax-string: #276c2b;\n --syntax-number: #b5451b;\n --syntax-comment: #6f6b78;\n --syntax-name: #1a5fb4;\n --syntax-type: #00695c;\n --syntax-punctuation: #4a4753;\n --syntax-meta: #9c4a00;\n\n --shadow-lift: 0 2px 8px rgb(30 24 38 / 0.08);\n --shadow-raise: 0 10px 30px rgb(30 24 38 / 0.14);\n --shadow-float: 0 24px 60px rgb(30 24 38 / 0.18);\n}\n\n/*\n * `--on-accent` - what sits on top of an accent fill.\n *\n * A light accent (gold, lime, amber) needs dark glyphs; a dark one needs\n * white. The live products picked this by hand and wrote the answer into the\n * theme; here the theme works it out, by the same rule the brand-line S tile\n * uses.\n *\n * `contrast-color()` is the direct way to say it and is used where supported.\n * The fallback covers browsers that lack it. Relative colour syntax exposes\n * the accent's own lightness as `l`; `clamp()` turns that into a hard switch,\n * because the multiplication drives the middle term far past either bound\n * everywhere except within a hair of the threshold. Below it the accent is\n * dark and the result is 1 (white); above it, 0 (black). Chroma is dropped to\n * zero, so what comes out is neutral rather than a tinted grey.\n *\n * The threshold is 0.58, and it is deliberately far below the midpoint an eye\n * would guess. Contrast is not symmetric about it: a mid-lightness colour is\n * still much closer to white than to black in luminance, so black wins well\n * before the colour looks light. Checked against all fourteen accents of the\n * line - every one of them reads better with dark glyphs, the closest being\n * cobalt at 4.86:1 against 4.32:1 for white. A higher threshold is what puts\n * white text on magenta at 3.6:1, which is the defect this rule exists to\n * prevent.\n */\n:root {\n --on-accent: oklch(from var(--accent) clamp(0, (0.58 - l) * 1000, 1) 0 0);\n\n /*\n * The same question for the status fills, and it has to be asked separately:\n * `--on-accent` is derived from the accent, so using it on a `--warn` fill\n * is only ever right by coincidence. The line's first consumer did exactly\n * that - a count on a yellow badge, drawn in white at 1.95:1 - and it read\n * as correct for as long as the product happened to pin white.\n *\n * The status hues do not follow the product accent, so these four are the\n * same for every product; they are still derived rather than written down,\n * because the status colours themselves change between the themes.\n */\n --on-good: oklch(from var(--good) clamp(0, (0.58 - l) * 1000, 1) 0 0);\n --on-warn: oklch(from var(--warn) clamp(0, (0.58 - l) * 1000, 1) 0 0);\n --on-bad: oklch(from var(--bad) clamp(0, (0.58 - l) * 1000, 1) 0 0);\n --on-info: oklch(from var(--info) clamp(0, (0.58 - l) * 1000, 1) 0 0);\n}\n\n@supports (color: contrast-color(red)) {\n :root {\n --on-accent: contrast-color(var(--accent));\n --on-good: contrast-color(var(--good));\n --on-warn: contrast-color(var(--warn));\n --on-bad: contrast-color(var(--bad));\n --on-info: contrast-color(var(--info));\n }\n}\n\n/*\n * The Tailwind 4 surface. `--color-*: initial` drops the stock palette on\n * purpose: a raw `bg-zinc-800` in a product should not compile, because the\n * only colours that exist here are the line's own.\n */\n@theme inline {\n --color-*: initial;\n --color-bg: var(--bg);\n --color-raise: var(--raise);\n --color-soft: var(--soft);\n --color-softer: var(--softer);\n --color-line: var(--line);\n --color-line-2: var(--line-2);\n --color-text: var(--text);\n --color-dim: var(--dim);\n --color-faint: var(--faint);\n --color-accent: var(--accent);\n --color-accent-2: var(--accent-2);\n --color-accent-soft: var(--accent-soft);\n --color-on-accent: var(--on-accent);\n --color-on-good: var(--on-good);\n --color-on-warn: var(--on-warn);\n --color-on-bad: var(--on-bad);\n --color-on-info: var(--on-info);\n --color-good: var(--good);\n --color-good-soft: var(--good-soft);\n --color-warn: var(--warn);\n --color-warn-soft: var(--warn-soft);\n --color-bad: var(--bad);\n --color-bad-soft: var(--bad-soft);\n --color-info: var(--info);\n --color-info-soft: var(--info-soft);\n\n /* Charts. `bg-series-3`, `fill-series-3`, `stroke-scale-500` - the same\n * utilities the rest of the vocabulary gets, so a chart is written in\n * tokens like everything else and `dowel/no-raw-color` can hold it to\n * that. */\n --color-series-1: var(--series-1);\n --color-series-2: var(--series-2);\n --color-series-3: var(--series-3);\n --color-series-4: var(--series-4);\n --color-series-5: var(--series-5);\n --color-series-6: var(--series-6);\n --color-series-7: var(--series-7);\n --color-series-8: var(--series-8);\n --color-scale-100: var(--scale-100);\n --color-scale-200: var(--scale-200);\n --color-scale-300: var(--scale-300);\n --color-scale-400: var(--scale-400);\n --color-scale-500: var(--scale-500);\n --color-scale-600: var(--scale-600);\n --color-scale-700: var(--scale-700);\n --color-chart-grid: var(--chart-grid);\n --color-chart-axis: var(--chart-axis);\n --color-heat-1: var(--heat-1);\n --color-heat-2: var(--heat-2);\n --color-heat-3: var(--heat-3);\n --color-heat-4: var(--heat-4);\n --color-heat-5: var(--heat-5);\n\n /* Syntax, so a highlighted token is written `text-syntax-keyword` like every\n * other colour in the system, and `dowel/no-raw-color` can hold it to that.\n * A highlighter's own stylesheet - which writes hex values into class names\n * of its own choosing - is what this replaces. */\n --color-syntax-keyword: var(--syntax-keyword);\n --color-syntax-string: var(--syntax-string);\n --color-syntax-number: var(--syntax-number);\n --color-syntax-comment: var(--syntax-comment);\n --color-syntax-name: var(--syntax-name);\n --color-syntax-type: var(--syntax-type);\n --color-syntax-punctuation: var(--syntax-punctuation);\n --color-syntax-meta: var(--syntax-meta);\n\n /* Kept because they are not palette choices: a hairline is `transparent`,\n * an SVG follows `currentColor`, and pure black and white are what an\n * overlay scrim and a print sheet are made of. */\n --color-transparent: transparent;\n --color-current: currentColor;\n --color-white: #fff;\n --color-black: #000;\n\n /*\n * Type. System stacks on purpose: a downloaded face costs a network round\n * trip before the first word appears, and the line's products are desktop\n * tools where the operating system's own face is the one the user already\n * reads everything else in.\n */\n --font-sans: 'Segoe UI Variable Text', 'Segoe UI', system-ui, -apple-system, sans-serif;\n --font-mono: ui-monospace, 'Cascadia Code', 'SF Mono', Consolas, monospace;\n\n /*\n * Radius. Taken from what the products actually draw, not from a ratio:\n * `rounded-[9px]` appears twenty times and `rounded-[10px]` twelve, because\n * a control and the primary button were tuned by eye and then copied. The\n * scale keeps the cluster they landed in and gives it names.\n *\n * `md` is the control radius - inputs, buttons, list rows. That the primary\n * button was one pixel rounder than every other variant is not preserved:\n * the products differ from themselves there, and buttons of the same size\n * sitting side by side should not have mismatched corners.\n */\n --radius-xs: 4px;\n --radius-sm: 6px;\n --radius-md: 9px;\n --radius-lg: 12px;\n --radius-xl: 16px;\n --radius-2xl: 20px;\n\n /*\n * A radius nested inside another has to be smaller by the gap between them,\n * or the inner corner looks wrong against the outer one. The products did\n * this by hand once - 18px outside, 17px inside - and nowhere else.\n */\n --radius-inner: calc(var(--radius-lg) - 1px);\n\n /*\n * Type scale. The products live between 10px and 14px: `text-sm` and\n * `text-xs` together account for nine tenths of every size in both, and the\n * rest scattered across 9, 9.5, 10, 10.5, 11, 11.5, 12.5 and 13 - nine steps\n * inside four pixels, which no eye distinguishes and no reason justifies.\n * This is the same range with the noise removed.\n */\n --text-2xs: 10px;\n --text-2xs--line-height: 14px;\n --text-xs: 11px;\n --text-xs--line-height: 15px;\n --text-sm: 12px;\n --text-sm--line-height: 16px;\n --text-base: 14px;\n --text-base--line-height: 20px;\n --text-lg: 16px;\n --text-lg--line-height: 22px;\n --text-xl: 18px;\n --text-xl--line-height: 24px;\n --text-2xl: 21px;\n --text-2xl--line-height: 28px;\n\n /* Weights. `semibold` is what both products use for anything emphasised;\n * `bold` appears in neither, and the one `font-[650]` in a page title is the\n * kind of value a scale exists to absorb. */\n --font-weight-normal: 400;\n --font-weight-medium: 500;\n --font-weight-semibold: 600;\n\n /* Tracking. The uppercase caption is the only place the products track at\n * all - and they do it at 0.08em in six files and 0.09em in three, a\n * difference nobody can see. One name settles it. */\n --tracking-caption: 0.085em;\n --tracking-tight: -0.01em;\n\n /*\n * A step between the steps.\n *\n * The spacing scale runs in fours - 2, 4, 6, 8 - and four different\n * primitives independently reached past it for three pixels: the gap\n * between segments of a rating and of an axis, the gap between cells of a\n * heatmap, and how far a tooltip's arrow tucks under its popup. Four hands\n * arriving at the same number is not four accidents; it is a step the scale\n * was missing, and each of them wrote `[3px]` because there was nothing to\n * name.\n *\n * It exists for hairline gaps between things that are themselves small -\n * where two pixels reads as touching and four as separate objects. Nothing\n * larger belongs here: this is the bottom of the scale, not a licence to\n * measure by eye.\n */\n --spacing-hair: 3px;\n\n /*\n /* Easing. `out` for anything the user asked for - it arrives fast and\n * settles, which reads as responsive. `in-out` for something moving on its\n * own. `in` is deliberately absent: it starts slowly, which on a control\n * reads as lag. */\n --ease-out: cubic-bezier(0.2, 0, 0, 1);\n --ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);\n\n /*\n * Elevation. Three steps, because the products had one and used it for a\n * toast, a dropdown and a modal alike - so a modal never sat further from\n * the page than the menu it covered.\n */\n --shadow-lift: var(--shadow-lift);\n --shadow-raise: var(--shadow-raise);\n --shadow-float: var(--shadow-float);\n\n /*\n * The smallest a pointer target may be.\n *\n * 24 CSS pixels, which is WCAG 2.2's 2.5.8 at AA. Not a matter of taste, and\n * measured rather than assumed: a live run of the stand found four different\n * answers to the same question in one set - a chip's cross at 16, Copyable\n * at 19, CopyButton at 21, a search field's clear at 24. Three of the four\n * fail, and the failure is silent. A cross missed by a thumb on a tablet, or\n * by anyone whose hand is not steady, does not delete the tag and does not\n * say anything either.\n *\n * It is a size token rather than a spacing step because it is a floor, not a\n * rhythm: nothing is ever `--target-min` tall on purpose, things are at\n * least that big. `size-target` and `min-size-target` come free from the\n * namespace, but the way a primitive usually reaches it is `target-min`\n * below, which grows the hit area without growing the glyph.\n */\n --size-target: 24px;\n}\n\n/*\n * `target-min` - visually smaller, clickably larger.\n *\n * The obvious fix for a 16px cross is to make it 24px, and it is the wrong\n * one: a chip is a small thing by design and a cross a third of its height\n * reads as a button with a chip around it. The set's appearance is a\n * decision, and an accessibility floor should not overturn it.\n *\n * So the target grows and the glyph does not. The pseudo-element is centred on\n * the control, takes its size, and refuses to go under the floor - so a\n * control already large enough is untouched, and a smaller one gains an\n * invisible margin of hit area on every side.\n *\n * `position: relative` comes with it rather than being left to the caller: a\n * target that silently does nothing because its parent forgot to establish a\n * containing block is exactly the failure this exists to prevent.\n *\n * Not `padding`, which was the other candidate: padding moves the glyph inside\n * its box and changes how the control sits in a flex row, so every call site\n * would need a compensating negative margin. This changes nothing about\n * layout.\n */\n@utility target-min {\n position: relative;\n\n &::after {\n content: '';\n position: absolute;\n top: 50%;\n left: 50%;\n translate: -50% -50%;\n width: 100%;\n height: 100%;\n min-width: var(--size-target);\n min-height: var(--size-target);\n }\n}\n\n/*\n * How tall a control is, and how much room a screen gives each row.\n *\n * `h-9` appeared in nine primitives - every field the set has - each spelling\n * it out, which is why `Input` could not be made compact without editing\n * `Input`. One decision made nine times gets one name.\n *\n * **Not in `@theme`, and that is the whole mechanism.** A token declared there\n * is inlined by the compiler: `h-control` came out as `height: 36px`, the\n * override on a container had nothing to bind to, and the density attribute\n * did exactly nothing. Measured in the browser rather than assumed - the\n * container's `--row-control` read 32px while the field it contained stayed\n * 36. Tailwind also writes a fallback for any `var()` it recognises from the\n * theme, so the name has to live outside the theme's namespaces entirely.\n *\n * Declared here as plain custom properties and turned into utilities by hand\n * below, the reference survives, and an override on an ancestor reaches every\n * control under it - which is what makes density a property of a region\n * rather than of a component.\n */\n:root {\n --row-control: 36px;\n --row-control-sm: 32px;\n --row-control-lg: 40px;\n\n /* The same question for a table, whose rows are sized by what is above and\n * below the text rather than by a height. `Table` had its own `density`\n * prop for this, with its own words - `base` and `dense` - which meant the\n * set used one word for two mechanisms and a product had to set both. */\n --row-cell: 10px;\n}\n\n@utility h-control {\n height: var(--row-control);\n}\n\n@utility py-row {\n padding-block: var(--row-cell);\n}\n\n@utility h-control-sm {\n height: var(--row-control-sm);\n}\n\n@utility h-control-lg {\n height: var(--row-control-lg);\n}\n\n/*\n * Density.\n *\n * Two answers to one question, and the set had neither: a product wanting a\n * tighter table wrote `h-8` at every call site, which is the drift the scale\n * exists to stop, one screen at a time.\n *\n * **It is an attribute on a container, not a prop on a component.** A prop\n * would have to be added to every primitive, threaded through every wrapper a\n * product writes, and passed by hand at each call site - and a primitive\n * written next year would not have it. An attribute is inherited:\n *\n * <form data-density=\"compact\"> …every field inside it…\n *\n * That reaches components which did not exist when the product was built, and\n * a product's own components too, as long as they measure in `h-control` like\n * everything else.\n *\n * The floor does not move with it. `--size-target` stays 24px at every\n * density, because WCAG does not care how tight a table is - which is exactly\n * why `target-min` grows the hit area rather than the control: a compact row\n * is still one a hand can hit.\n */\n[data-density='compact'] {\n --row-control: 32px;\n --row-control-sm: 28px;\n --row-control-lg: 36px;\n --row-cell: 6px;\n}\n\n[data-density='comfortable'] {\n --row-control: 40px;\n --row-control-sm: 36px;\n --row-control-lg: 44px;\n --row-cell: 14px;\n}\n\n/*\n * Stacking order.\n *\n * Not in `@theme`: Tailwind has no z-index namespace, so `z-50` is a literal\n * fifty and a named step would not compile. These are custom properties a\n * component reads directly - `z-index: var(--z-modal)`.\n *\n * The order is the products' own, with the gaps closed. They ran 10 for an\n * in-flow popup, 20 sticky, 30 menu, 40 for a floating button, 50 for modals\n * and drawers, then jumped to 70 and 80 for the command palette - which had to\n * clear the modal layer and had no name to do it with.\n */\n:root {\n /*\n * Motion. One duration existed before this - 160ms on a route change - and\n * everything else rode Tailwind's default. These are the steps around it:\n * `quick` for a colour or an opacity that should feel immediate, `base` for\n * something that moves, `slow` for something arriving from off-screen.\n *\n * Not in `@theme`: Tailwind's `duration-*` utility takes a literal number,\n * not a named step, so these are read directly - `transition-duration:\n * var(--duration-base)`. The easing curves opposite them ARE a namespace,\n * so `ease-out` is a class.\n *\n * Every duration here is for people who want motion: `prefers-reduced-\n * motion` cuts them to nothing further down.\n */\n --duration-quick: 120ms;\n --duration-base: 160ms;\n --duration-slow: 240ms;\n\n --z-popup: 10;\n --z-sticky: 20;\n --z-menu: 30;\n --z-floating: 40;\n --z-overlay: 50;\n --z-modal: 60;\n --z-palette: 70;\n --z-toast: 80;\n}\n\n/*\n * Base layer: what every product would otherwise write again. Scoped to\n * elements and to `:focus-visible`, never to a class, so nothing here can\n * collide with a component.\n */\nbody {\n margin: 0;\n background-color: var(--bg);\n color: var(--text);\n font-family: var(--font-sans);\n -webkit-font-smoothing: antialiased;\n}\n\n/*\n * In `@layer base`, so a component can turn it off.\n *\n * Unlayered, this rule has the same specificity as `focus-visible:outline-none`\n * from Tailwind - both are one pseudo-class - and wins on source order alone,\n * because the theme is imported before the utilities. Every component that\n * draws its own focus ring got this one on top of it: the command palette's\n * field had an accent outline it had explicitly opted out of, and a combobox\n * with chips drew two rings, one around the box and one around the input\n * inside it.\n *\n * A layered rule loses to any unlayered one regardless of specificity, which\n * is the whole point of cascade layers - the base layer states a default and\n * a component overrides it by saying so.\n */\n@layer base {\n :focus-visible {\n outline: 2px solid var(--accent);\n outline-offset: 2px;\n }\n}\n\n@media (prefers-reduced-motion: reduce) {\n *,\n *::before,\n *::after {\n transition-duration: 0.01ms !important;\n animation-duration: 0.01ms !important;\n animation-iteration-count: 1 !important;\n scroll-behavior: auto !important;\n }\n}\n\n/*\n * Scrollbars. A browser's default bar is a piece of someone else's chrome\n * sitting in the middle of the product - full width, with step arrows. These\n * are the line's own: thin, in the palette, drawn only where something\n * actually scrolls.\n */\n* {\n scrollbar-width: thin;\n scrollbar-color: var(--line-2) transparent;\n}\n\n*::-webkit-scrollbar {\n width: 10px;\n height: 10px;\n}\n\n*::-webkit-scrollbar-track {\n background: transparent;\n}\n\n*::-webkit-scrollbar-thumb {\n border: 3px solid transparent;\n border-radius: 999px;\n background: var(--line-2);\n background-clip: content-box;\n /* The thumb is drawn proportional to how much of the document fits on\n * screen, so a long one collapses it to a few pixels: still visible, no\n * longer catchable by a pointer. This is the floor below which it stops\n * being a control. */\n min-height: 2.5rem;\n}\n\n*::-webkit-scrollbar-thumb:hover {\n background: var(--dim);\n background-clip: content-box;\n}\n\n*::-webkit-scrollbar-corner {\n background: transparent;\n}\n\n*::-webkit-scrollbar-button {\n display: none;\n}\n"
|
|
18
18
|
}
|
|
19
19
|
],
|
|
20
20
|
"docs": "Import the theme, then your product accent:\n\n @import './dowel/theme.css';\n @import './dowel/accents/kilna.css';\n\nOutside the line, set the colour directly instead:\n\n :root { --accent-base: #2f7d6b; }"
|
|
@@ -309,7 +309,7 @@
|
|
|
309
309
|
"dependencies": [
|
|
310
310
|
"@base-ui/react",
|
|
311
311
|
"class-variance-authority",
|
|
312
|
-
"dowel-ui@^0.
|
|
312
|
+
"dowel-ui@^0.28.0"
|
|
313
313
|
],
|
|
314
314
|
"registryDependencies": [],
|
|
315
315
|
"files": [
|
|
@@ -328,7 +328,7 @@
|
|
|
328
328
|
"description": "The shape everyone recognises: weeks as columns, weekdays as rows, time running left to right, and a value carried by how dark a square is. Two products of the line asked for it by name before it existed.",
|
|
329
329
|
"dependencies": [
|
|
330
330
|
"class-variance-authority",
|
|
331
|
-
"dowel-ui@^0.
|
|
331
|
+
"dowel-ui@^0.28.0"
|
|
332
332
|
],
|
|
333
333
|
"registryDependencies": [
|
|
334
334
|
"https://lacodda.github.io/dowel/r/activity-weeks.json"
|
|
@@ -338,7 +338,7 @@
|
|
|
338
338
|
"path": "ui/activity-heatmap.tsx",
|
|
339
339
|
"target": "@ui/activity-heatmap.tsx",
|
|
340
340
|
"type": "registry:ui",
|
|
341
|
-
"content": "import type { HTMLAttributes, ReactNode } from 'react'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { cn } from 'dowel-ui'\nimport {\n weekdayRows,\n weeks,\n type ActivityEntry,\n type Cell,\n type Weekday,\n} from './activity-weeks'\n\n/*\n * A year of activity, one square per day.\n *\n * The shape everyone recognises: weeks as columns, weekdays as rows, time\n * running left to right, and a value carried by how dark a square is. Two\n * products of the line asked for it by name before it existed.\n *\n * **A cell has four meanings and only one of them is a number.** Nothing\n * recorded, a value, something still under way, and a date outside the range\n * asked for are four different facts. A grid that paints the first as the\n * palest shade of the second tells the reader somebody did nothing on a day\n * nobody reported - a claim invented by the drawing. So each has its own\n * treatment: a value is filled from the heat ramp, nothing recorded is the\n * bare empty square, something under way is outlined rather than filled\n * (there is no figure to shade, and any fill would be a number nobody gave),\n * and a padding square is drawn faintest of all, because the caller never\n * asked about it.\n *\n * **The legend is not decoration.** The scale is relative - the darkest square\n * is the busiest day in *this* grid, not a standard - so the grid says what\n * its own ceiling is. Without that, five shades look like an absolute measure\n * of a full day, which is a thing this component has no opinion about.\n *\n * **Colour is never the only channel.** Every square carries its date and its\n * figure in words, on a `title` and as its accessible name: a grid of five\n * shades says nothing to a screen reader, and little to anyone who does not\n * separate five blues.\n */\n\nexport const activityHeatmapVariants = cva('inline-flex flex-col gap-2', {\n variants: {\n size: {\n /* The year view, where a square is small enough that 53 columns fit. */\n sm: '[--cell:10px] [--gap:2px]',\n /* A quarter or a month, where there is room to hover comfortably. */\n md: '[--cell:14px] [--gap:
|
|
341
|
+
"content": "import type { HTMLAttributes, ReactNode } from 'react'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { cn } from 'dowel-ui'\nimport {\n weekdayRows,\n weeks,\n type ActivityEntry,\n type Cell,\n type Weekday,\n} from './activity-weeks'\n\n/*\n * A year of activity, one square per day.\n *\n * The shape everyone recognises: weeks as columns, weekdays as rows, time\n * running left to right, and a value carried by how dark a square is. Two\n * products of the line asked for it by name before it existed.\n *\n * **A cell has four meanings and only one of them is a number.** Nothing\n * recorded, a value, something still under way, and a date outside the range\n * asked for are four different facts. A grid that paints the first as the\n * palest shade of the second tells the reader somebody did nothing on a day\n * nobody reported - a claim invented by the drawing. So each has its own\n * treatment: a value is filled from the heat ramp, nothing recorded is the\n * bare empty square, something under way is outlined rather than filled\n * (there is no figure to shade, and any fill would be a number nobody gave),\n * and a padding square is drawn faintest of all, because the caller never\n * asked about it.\n *\n * **The legend is not decoration.** The scale is relative - the darkest square\n * is the busiest day in *this* grid, not a standard - so the grid says what\n * its own ceiling is. Without that, five shades look like an absolute measure\n * of a full day, which is a thing this component has no opinion about.\n *\n * **Colour is never the only channel.** Every square carries its date and its\n * figure in words, on a `title` and as its accessible name: a grid of five\n * shades says nothing to a screen reader, and little to anyone who does not\n * separate five blues.\n */\n\nexport const activityHeatmapVariants = cva('inline-flex flex-col gap-2', {\n variants: {\n size: {\n /* The year view, where a square is small enough that 53 columns fit. */\n sm: '[--cell:10px] [--gap:2px]',\n /* A quarter or a month, where there is room to hover comfortably. */\n md: '[--cell:14px] [--gap:var(--spacing-hair)]',\n },\n },\n defaultVariants: { size: 'md' },\n})\n\nexport const activityCellVariants = cva('rounded-[3px]', {\n variants: {\n kind: {\n /* The bare square. Not the faintest step of the ramp - that is a value,\n * and this is the absence of one. */\n none: 'bg-soft',\n value: '',\n /* Outlined rather than filled: under way has no total to shade. */\n partial: 'border border-dashed border-accent/60',\n /* Drawn, because the column has to be seven tall, but never as data:\n * the caller did not ask about this date. */\n outside: 'bg-softer/40',\n },\n step: {\n 1: 'bg-heat-1',\n 2: 'bg-heat-2',\n 3: 'bg-heat-3',\n 4: 'bg-heat-4',\n 5: 'bg-heat-5',\n },\n },\n defaultVariants: { kind: 'none' },\n})\n\nexport interface ActivityHeatmapProps\n extends Omit<HTMLAttributes<HTMLDivElement>, 'children'>,\n VariantProps<typeof activityHeatmapVariants> {\n /** What is known about each date. Dates with no entry are drawn as nothing\n * recorded, which is a different fact from a value of zero. */\n entries: ActivityEntry[]\n /** First date of the range, `YYYY-MM-DD`. */\n from: string\n /** Last date. */\n to: string\n /** The day a week starts on, Sunday-first like `Date#getUTCDay`. Monday by\n * default. It decides which row a date lands on, so it is stated rather than\n * guessed from a locale this cannot see. */\n weekStartsOn?: Weekday\n /** The ceiling the shades are measured against. Defaults to the largest\n * value present; state it to compare two grids by eye. */\n busiest?: number\n /** What the grid as a whole is, for a reader who cannot see it. */\n label: string\n /** What one square says. Given the cell, returns the sentence that becomes\n * its hover title and its accessible name - the caller owns it because only\n * the caller knows whether a value is hours, words or commits. */\n describe: (cell: Cell) => string\n /** Row labels, one per weekday in drawing order. Omitted entirely rather\n * than defaulted: a weekday name is a word in a language this cannot pick. */\n weekdayLabel?: (weekday: Weekday) => ReactNode\n}\n\nexport function ActivityHeatmap({\n entries,\n from,\n to,\n weekStartsOn = 1,\n busiest,\n label,\n describe,\n weekdayLabel,\n size,\n className,\n ...props\n}: ActivityHeatmapProps) {\n const columns = weeks(entries, { from, to, weekStartsOn, busiest })\n const rows = weekdayRows(weekStartsOn)\n\n return (\n <div className={cn(activityHeatmapVariants({ size }), className)} {...props}>\n <div className=\"flex gap-[var(--gap)]\" role=\"img\" aria-label={label}>\n {weekdayLabel !== undefined && (\n <div\n className=\"mr-1 flex flex-col gap-[var(--gap)] text-[10px] leading-[var(--cell)] text-faint\"\n aria-hidden\n >\n {rows.map((weekday) => (\n <span key={weekday} className=\"h-[var(--cell)]\">\n {weekdayLabel(weekday)}\n </span>\n ))}\n </div>\n )}\n\n {columns.map((column) => (\n <div key={column[0]!.date} className=\"flex flex-col gap-[var(--gap)]\">\n {column.map((cell) => (\n <span\n key={cell.date}\n title={describe(cell)}\n className={cn(\n 'size-[var(--cell)]',\n activityCellVariants({\n kind: cell.kind,\n step: cell.step as 1 | 2 | 3 | 4 | 5 | undefined,\n }),\n )}\n />\n ))}\n </div>\n ))}\n </div>\n </div>\n )\n}\n"
|
|
342
342
|
}
|
|
343
343
|
]
|
|
344
344
|
},
|
|
@@ -348,7 +348,7 @@
|
|
|
348
348
|
"title": "Activity-legend",
|
|
349
349
|
"description": "Separate from the grid because a caller showing three grids on one screen wants one legend, and because the words in it are the product's.",
|
|
350
350
|
"dependencies": [
|
|
351
|
-
"dowel-ui@^0.
|
|
351
|
+
"dowel-ui@^0.28.0"
|
|
352
352
|
],
|
|
353
353
|
"registryDependencies": [
|
|
354
354
|
"https://lacodda.github.io/dowel/r/activity-heatmap.json",
|
|
@@ -386,7 +386,7 @@
|
|
|
386
386
|
"description": "A message that stays on the screen, in the flow of the page, about the thing next to it: this field could not be saved, this profile has no axes yet, this export is out of date.",
|
|
387
387
|
"dependencies": [
|
|
388
388
|
"class-variance-authority",
|
|
389
|
-
"dowel-ui@^0.
|
|
389
|
+
"dowel-ui@^0.28.0"
|
|
390
390
|
],
|
|
391
391
|
"registryDependencies": [],
|
|
392
392
|
"files": [
|
|
@@ -405,7 +405,7 @@
|
|
|
405
405
|
"description": "A person, in the space of a word. Every screen that lists people needs one, and the three things that go wrong with it are always the same:\n * - **The picture fails to load** and a broken-image glyph appears where a face was. The fallback is not a nicety; it is the state this component spends most of its life in, because half the people in any list have no picture at all.",
|
|
406
406
|
"dependencies": [
|
|
407
407
|
"class-variance-authority",
|
|
408
|
-
"dowel-ui@^0.
|
|
408
|
+
"dowel-ui@^0.28.0"
|
|
409
409
|
],
|
|
410
410
|
"registryDependencies": [],
|
|
411
411
|
"files": [
|
|
@@ -424,7 +424,7 @@
|
|
|
424
424
|
"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.",
|
|
425
425
|
"dependencies": [
|
|
426
426
|
"class-variance-authority",
|
|
427
|
-
"dowel-ui@^0.
|
|
427
|
+
"dowel-ui@^0.28.0"
|
|
428
428
|
],
|
|
429
429
|
"registryDependencies": [],
|
|
430
430
|
"files": [
|
|
@@ -443,7 +443,7 @@
|
|
|
443
443
|
"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.",
|
|
444
444
|
"dependencies": [
|
|
445
445
|
"class-variance-authority",
|
|
446
|
-
"dowel-ui@^0.
|
|
446
|
+
"dowel-ui@^0.28.0"
|
|
447
447
|
],
|
|
448
448
|
"registryDependencies": [],
|
|
449
449
|
"files": [
|
|
@@ -462,7 +462,7 @@
|
|
|
462
462
|
"description": "Bars rather than a line, and the distinction is the data's not the drawing's: a line says the value exists between the points, a column says each period is its own sum. Hours worked in a week is a sum - there is no \"Wednesday afternoon\" reading between two weeks - so it is a column.",
|
|
463
463
|
"dependencies": [
|
|
464
464
|
"class-variance-authority",
|
|
465
|
-
"dowel-ui@^0.
|
|
465
|
+
"dowel-ui@^0.28.0"
|
|
466
466
|
],
|
|
467
467
|
"registryDependencies": [],
|
|
468
468
|
"files": [
|
|
@@ -482,7 +482,7 @@
|
|
|
482
482
|
"dependencies": [
|
|
483
483
|
"@base-ui/react",
|
|
484
484
|
"class-variance-authority",
|
|
485
|
-
"dowel-ui@^0.
|
|
485
|
+
"dowel-ui@^0.28.0"
|
|
486
486
|
],
|
|
487
487
|
"registryDependencies": [],
|
|
488
488
|
"files": [
|
|
@@ -516,7 +516,7 @@
|
|
|
516
516
|
"title": "Calendar",
|
|
517
517
|
"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.",
|
|
518
518
|
"dependencies": [
|
|
519
|
-
"dowel-ui@^0.
|
|
519
|
+
"dowel-ui@^0.28.0"
|
|
520
520
|
],
|
|
521
521
|
"registryDependencies": [
|
|
522
522
|
"https://lacodda.github.io/dowel/r/calendar-math.json"
|
|
@@ -537,7 +537,7 @@
|
|
|
537
537
|
"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.",
|
|
538
538
|
"dependencies": [
|
|
539
539
|
"@base-ui/react",
|
|
540
|
-
"dowel-ui@^0.
|
|
540
|
+
"dowel-ui@^0.28.0"
|
|
541
541
|
],
|
|
542
542
|
"registryDependencies": [],
|
|
543
543
|
"files": [
|
|
@@ -556,7 +556,7 @@
|
|
|
556
556
|
"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.",
|
|
557
557
|
"dependencies": [
|
|
558
558
|
"class-variance-authority",
|
|
559
|
-
"dowel-ui@^0.
|
|
559
|
+
"dowel-ui@^0.28.0"
|
|
560
560
|
],
|
|
561
561
|
"registryDependencies": [],
|
|
562
562
|
"files": [
|
|
@@ -564,7 +564,7 @@
|
|
|
564
564
|
"path": "ui/chip.tsx",
|
|
565
565
|
"target": "@ui/chip.tsx",
|
|
566
566
|
"type": "registry:ui",
|
|
567
|
-
"content": "import type { HTMLAttributes, MouseEvent } from 'react'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { cn } from 'dowel-ui'\n\n/*\n * Chip.\n *\n * A badge you can act on: a filter that can be removed, a tag with a count, a\n * selected value in a field. The difference from a Badge is entirely about\n * whether something happens when you click it - and if something does, that\n * part is a real `<button>` with a real label, not a decorative cross.\n *\n * `onRemove` is the reason this exists separately. Every product wrote the\n * same removable tag and every one of them made the cross a `<span>`, which\n * the keyboard cannot reach and a screen reader does not announce.\n */\nexport const chipVariants = cva(\n 'inline-flex items-center gap-1 rounded-full border py-0.5 pl-2.5 text-xs whitespace-nowrap transition-colors',\n {\n variants: {\n variant: {\n outline: 'border-line text-dim',\n accent: 'border-transparent bg-accent-soft text-accent',\n soft: 'border-transparent bg-soft text-dim',\n },\n /** Padding on the right depends on whether a remove button sits there. */\n removable: {\n true: 'pr-1',\n false: 'pr-2.5',\n },\n },\n defaultVariants: { variant: 'outline', removable: false },\n },\n)\n\nexport interface ChipProps\n extends Omit<HTMLAttributes<HTMLSpanElement>, 'onSelect'>,\n Omit<VariantProps<typeof chipVariants>, 'removable'> {\n /** Shown after the label, for a count. */\n count?: number\n}\n\n/*\n * Removable, or not - and if removable, named.\n *\n * The two props travel together as a union rather than as two optionals, so\n * the type says what the component means: a remove button exists only when\n * there is a word for it. There is no default word on purpose. A string the\n * component invents is a string the product cannot translate, and it would\n * ship in English to every reader who does not read English.\n */\ntype Removable =\n | { onRemove: () => void; removeLabel: string }\n | { onRemove?: never; removeLabel?: never }\n\nexport function Chip({\n variant,\n count,\n onRemove,\n removeLabel,\n className,\n children,\n ...props\n}: ChipProps & Removable) {\n return (\n <span className={cn(chipVariants({ variant, removable: Boolean(onRemove) }), className)} {...props}>\n {children}\n\n {count !== undefined && <span className=\"text-faint tabular-nums\">{count}</span>}\n\n {onRemove && (\n <button\n type=\"button\"\n aria-label={removeLabel}\n onClick={(event: MouseEvent<HTMLButtonElement>) => {\n // A chip is often inside something else that is also clickable.\n event.stopPropagation()\n onRemove()\n }}\n className={cn(\n 'grid size-4 place-items-center rounded-full text-faint transition-colors',\n 'hover:bg-line hover:text-text',\n 'focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-accent',\n )}\n >\n <svg viewBox=\"0 0 16 16\" width=\"10\" height=\"10\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\" aria-hidden>\n <path d=\"M4 4l8 8M12 4l-8 8\" strokeLinecap=\"round\" />\n </svg>\n </button>\n )}\n </span>\n )\n}\n"
|
|
567
|
+
"content": "import type { HTMLAttributes, MouseEvent } from 'react'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { cn } from 'dowel-ui'\n\n/*\n * Chip.\n *\n * A badge you can act on: a filter that can be removed, a tag with a count, a\n * selected value in a field. The difference from a Badge is entirely about\n * whether something happens when you click it - and if something does, that\n * part is a real `<button>` with a real label, not a decorative cross.\n *\n * `onRemove` is the reason this exists separately. Every product wrote the\n * same removable tag and every one of them made the cross a `<span>`, which\n * the keyboard cannot reach and a screen reader does not announce.\n */\nexport const chipVariants = cva(\n 'inline-flex items-center gap-1 rounded-full border py-0.5 pl-2.5 text-xs whitespace-nowrap transition-colors',\n {\n variants: {\n variant: {\n outline: 'border-line text-dim',\n accent: 'border-transparent bg-accent-soft text-accent',\n soft: 'border-transparent bg-soft text-dim',\n },\n /** Padding on the right depends on whether a remove button sits there. */\n removable: {\n true: 'pr-1',\n false: 'pr-2.5',\n },\n },\n defaultVariants: { variant: 'outline', removable: false },\n },\n)\n\nexport interface ChipProps\n extends Omit<HTMLAttributes<HTMLSpanElement>, 'onSelect'>,\n Omit<VariantProps<typeof chipVariants>, 'removable'> {\n /** Shown after the label, for a count. */\n count?: number\n}\n\n/*\n * Removable, or not - and if removable, named.\n *\n * The two props travel together as a union rather than as two optionals, so\n * the type says what the component means: a remove button exists only when\n * there is a word for it. There is no default word on purpose. A string the\n * component invents is a string the product cannot translate, and it would\n * ship in English to every reader who does not read English.\n */\ntype Removable =\n | { onRemove: () => void; removeLabel: string }\n | { onRemove?: never; removeLabel?: never }\n\nexport function Chip({\n variant,\n count,\n onRemove,\n removeLabel,\n className,\n children,\n ...props\n}: ChipProps & Removable) {\n return (\n <span className={cn(chipVariants({ variant, removable: Boolean(onRemove) }), className)} {...props}>\n {children}\n\n {count !== undefined && <span className=\"text-faint tabular-nums\">{count}</span>}\n\n {onRemove && (\n <button\n type=\"button\"\n aria-label={removeLabel}\n onClick={(event: MouseEvent<HTMLButtonElement>) => {\n // A chip is often inside something else that is also clickable.\n event.stopPropagation()\n onRemove()\n }}\n className={cn(\n // `target-min` keeps the cross the size it looks - a chip is a\n // small thing and a cross a third of its height reads as a button\n // with a chip around it - while the area a pointer has to find\n // grows to the floor the theme sets.\n 'grid size-4 place-items-center rounded-full text-faint transition-colors target-min',\n 'hover:bg-line hover:text-text',\n 'focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-accent',\n )}\n >\n <svg viewBox=\"0 0 16 16\" width=\"10\" height=\"10\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\" aria-hidden>\n <path d=\"M4 4l8 8M12 4l-8 8\" strokeLinecap=\"round\" />\n </svg>\n </button>\n )}\n </span>\n )\n}\n"
|
|
568
568
|
}
|
|
569
569
|
]
|
|
570
570
|
},
|
|
@@ -575,7 +575,7 @@
|
|
|
575
575
|
"description": "The frame around a piece of code is the same everywhere and is written again in every product: the scroll that must not wrap, the gutter of line numbers that must not be selectable, the copy button, the caption saying which file this is, and the marking of the lines the reader was sent here to look at.",
|
|
576
576
|
"dependencies": [
|
|
577
577
|
"class-variance-authority",
|
|
578
|
-
"dowel-ui@^0.
|
|
578
|
+
"dowel-ui@^0.28.0"
|
|
579
579
|
],
|
|
580
580
|
"registryDependencies": [
|
|
581
581
|
"https://lacodda.github.io/dowel/r/copy-button.json"
|
|
@@ -595,7 +595,7 @@
|
|
|
595
595
|
"title": "Color-field",
|
|
596
596
|
"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.",
|
|
597
597
|
"dependencies": [
|
|
598
|
-
"dowel-ui@^0.
|
|
598
|
+
"dowel-ui@^0.28.0"
|
|
599
599
|
],
|
|
600
600
|
"registryDependencies": [
|
|
601
601
|
"https://lacodda.github.io/dowel/r/input.json"
|
|
@@ -615,7 +615,7 @@
|
|
|
615
615
|
"title": "Column-resize-handle",
|
|
616
616
|
"description": "The handle, and the hook that keeps the widths it produces. Pointer events rather than HTML5 drag-and-drop: a desktop shell that takes file drops for itself never lets a `dragstart` reach the page, so the native API is a handle that does nothing there; pointer capture on the handle also keeps the drag alive when the pointer runs ahead of the cell, which at any speed above a crawl it does.",
|
|
617
617
|
"dependencies": [
|
|
618
|
-
"dowel-ui@^0.
|
|
618
|
+
"dowel-ui@^0.28.0"
|
|
619
619
|
],
|
|
620
620
|
"registryDependencies": [],
|
|
621
621
|
"files": [
|
|
@@ -635,7 +635,7 @@
|
|
|
635
635
|
"dependencies": [
|
|
636
636
|
"@base-ui/react",
|
|
637
637
|
"class-variance-authority",
|
|
638
|
-
"dowel-ui@^0.
|
|
638
|
+
"dowel-ui@^0.28.0"
|
|
639
639
|
],
|
|
640
640
|
"registryDependencies": [
|
|
641
641
|
"https://lacodda.github.io/dowel/r/input.json",
|
|
@@ -646,7 +646,7 @@
|
|
|
646
646
|
"path": "ui/combobox.tsx",
|
|
647
647
|
"target": "@ui/combobox.tsx",
|
|
648
648
|
"type": "registry:ui",
|
|
649
|
-
"content": "import type { Ref } from 'react'\nimport { Combobox as Base } from '@base-ui/react/combobox'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { cn } from 'dowel-ui'\nimport { fieldClasses } from './input'\nimport { selectItemVariants, selectPopupVariants } from './select'\n\n/*\n * Combobox.\n *\n * A Select you can type in. The list narrows as the query is typed, which is\n * the only difference that matters and the reason to reach for this one: a\n * Select stops being usable somewhere around thirty options, and a country\n * picker or a tag field is well past that.\n *\n * Everything the Select comment says about the native element applies here\n * too - there is no `<select>` under it, and the input is a real `<input\n * role=\"combobox\">` so autofill, spellcheck and the phone keyboard still\n * work.\n *\n * Filtering is Base UI's: give the root an `items` array and it matches the\n * query against them with `Intl.Collator`, so accents and case behave the way\n * a reader in that language expects rather than the way `toLowerCase` does.\n * `filter` replaces the comparison; `filter={null}` turns it off for a list\n * that is filtered on a server.\n *\n * The chips are Base UI's too - Chips, Chip, ChipRemove - and that is worth\n * saying because inventing them is the obvious move and it goes wrong in one\n * specific way: hand-made chips end up as `<div>`s with an X that only a\n * pointer can reach, and the multi-select becomes keyboard-inaccessible at\n * exactly the point where it holds the most state. Base UI's are focusable,\n * walk with the arrows, and delete with Backspace.\n *\n * `Empty` renders only when nothing matched, and announces itself politely.\n * Its element stays mounted for that announcement to work, so it must not be\n * hidden with `display: none` or removed conditionally - which is why it is a\n * component here rather than a `{items.length === 0 && …}` in the product.\n */\n\n/* Two bases, chosen by `bare`, rather than one base and an override.\n *\n * Inside `ComboboxChips` the container is the field, so the input has no\n * border, no background and no focus ring of its own - a bordered box inside a\n * bordered box reads as two controls, and two focus rings appear as one thick\n * one. The obvious way to write that is `fieldClasses` plus a few `-none`\n * classes, and it does not work: `tailwind-merge` does not treat\n * `focus-visible:outline-none` as conflicting with\n * `focus-visible:outline-2 … outline-accent`, so both survive and the later\n * one in the stylesheet wins. The same trap took `w-full` versus `w-auto`\n * earlier in this file.\n *\n * So the variant picks which set applies instead of trying to subtract from\n * one - nothing is left to a merge that has no opinion. */\nexport const comboboxInputVariants = cva('', {\n variants: {\n size: {\n sm: 'h-8 text-xs',\n md: 'h-9',\n lg: 'h-10 text-base',\n },\n bare: {\n true: 'h-7 w-auto min-w-24 flex-1 bg-transparent px-1 text-sm text-text placeholder:text-faint outline-none',\n false: fieldClasses,\n },\n },\n defaultVariants: { size: 'md', bare: false },\n})\n\n/** The list, and a row in it, are Select's - imported rather than copied.\n *\n * The two popups are the same object seen twice: a dropdown of options, one\n * of which can be chosen. A reader who uses both on one screen should not be\n * able to tell which is which until they type. Two `cva` calls that started\n * identical do not stay that way - one gets the padding fix - and then the\n * form has two dropdowns that are almost the same. */\nexport const comboboxPopupVariants = selectPopupVariants\nexport const comboboxItemVariants = selectItemVariants\n\n/** The root. `items` is what gets filtered; `multiple` turns the value into an\n * array and makes the chips meaningful. */\nexport const Combobox = Base.Root\n\n/** The wrapper for an input with something beside it - a clear button, an\n * icon, the chips. */\nexport const ComboboxInputGroup = Base.InputGroup\n\n/** The button that opens the list without typing, for a reader who wants to\n * see everything there is. */\nexport const ComboboxTrigger = Base.Trigger\n\n/** The chevron. Decorative. */\nexport const ComboboxIcon = Base.Icon\n\n/** A labelled group of rows. */\nexport const ComboboxGroup = Base.Group\n\n/** The rows of one group, as a render function over that group's items.\n *\n * A `List` is the listbox and there is one per combobox, so a grouped list is\n * a `List` over the groups with a `Collection` inside each - not a `List`\n * inside a `List`. Mapping by hand instead works, but the component then has\n * to be told how to match an item to a value, which is a second place for that\n * knowledge to live. */\nexport const ComboboxCollection = Base.Collection\n\n/** The tick, drawn only on a chosen row. */\nexport const ComboboxItemIndicator = Base.ItemIndicator\n\n/** A polite live region for the state of an asynchronous list. Stays mounted,\n * like `Empty`, so the announcement actually fires. */\nexport const ComboboxStatus = Base.Status\n\n/** The container the chips sit in. Its children are plain nodes, not a render\n * function - the chosen values are mapped by `ComboboxValue` inside it.\n *\n * It wears the field's clothes and lays the chips out in a row that wraps,\n * which is the whole difference between a control and a list: unstyled, the\n * chips stack one per line and the box grows into a column of pills with the\n * input stranded underneath. The input sits on the same line as the last\n * chip and takes the rest of the width, so a half-filled field still looks\n * like a field. */\nexport function ComboboxChips({\n ref,\n className,\n ...props\n}: Base.Chips.Props & { ref?: Ref<HTMLDivElement> }) {\n return (\n <Base.Chips\n // Taken out of `...props` and passed on deliberately: a product needs a\n // handle on this box to anchor the list to it, because the input inside\n // is only as wide as what has been typed.\n ref={ref}\n className={cn(\n fieldClasses,\n 'flex min-h-9 flex-wrap items-center gap-1 py-1',\n 'focus-within:outline-2 focus-within:outline-offset-0 focus-within:outline-accent',\n className,\n )}\n {...props}\n />\n )\n}\n\n/** The current value, as a render function of it. This is what turns a\n * `multiple` value into one chip per entry. */\nexport const ComboboxValue = Base.Value\n\n/** `size` is taken from the native `<input size>` - a width in characters,\n * which nothing here wants - and given to the variant instead. */\nexport interface ComboboxInputProps\n extends Omit<Base.Input.Props, 'size'>,\n VariantProps<typeof comboboxInputVariants> {}\n\n/** Where the query is typed. A real `<input role=\"combobox\">`.\n *\n * Inside `ComboboxChips` it drops its own border and background: the\n * container is the field there, and a bordered input inside a bordered box\n * reads as two controls. */\nexport function ComboboxInput({ size, bare, className, ...props }: ComboboxInputProps) {\n // `bare` is pulled out and handed to `cva`. Left in `...props` it would be\n // spread onto the `<input>` as an unknown attribute and change nothing -\n // which is exactly what it did: the variant existed, the prop was passed,\n // and the class list came out without a trace of either.\n return <Base.Input className={cn(comboboxInputVariants({ size, bare }), className)} {...props} />\n}\n\nconst iconButtonClasses = cn(\n 'rounded-sm p-1 text-faint transition-colors hover:text-text',\n 'focus-visible:outline-2 focus-visible:outline-accent',\n 'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',\n)\n\n/** Empties the value. Base UI hides it while there is nothing to clear. */\nexport function ComboboxClear({ className, ...props }: Base.Clear.Props) {\n return <Base.Clear className={cn(iconButtonClasses, className)} {...props} />\n}\n\nexport interface ComboboxPopupProps\n extends Base.Popup.Props,\n VariantProps<typeof comboboxPopupVariants> {\n /** Preferred side of the input. Base UI flips it when it does not fit. */\n side?: Base.Positioner.Props['side']\n /** Alignment along that side. */\n align?: Base.Positioner.Props['align']\n /** Distance from the input, in pixels. */\n sideOffset?: Base.Positioner.Props['sideOffset']\n /**\n * What to line the list up with. Defaults to the input that owns it.\n *\n * Pass the `ComboboxChips` box when there is one: with chips, the input is\n * only as wide as what has been typed - an empty one measured 214px inside\n * a 288px field - so a list anchored to it hangs short of the box a reader\n * sees. Base UI publishes the anchor's width as `--anchor-width`, which is\n * how the mismatch is visible from outside.\n */\n anchor?: Base.Positioner.Props['anchor']\n /** Where to portal to. Defaults to the document body. */\n container?: Base.Portal.Props['container']\n}\n\n/** The list. Portalled and positioned against the input, or the given anchor. */\nexport function ComboboxPopup({\n size,\n side,\n align,\n sideOffset = 4,\n anchor,\n container,\n className,\n children,\n ...props\n}: ComboboxPopupProps) {\n return (\n <Base.Portal container={container}>\n <Base.Positioner\n side={side}\n align={align}\n sideOffset={sideOffset}\n anchor={anchor}\n className=\"[z-index:var(--z-menu)]\"\n >\n <Base.Popup className={cn(comboboxPopupVariants({ size }), className)} {...props}>\n {children}\n </Base.Popup>\n </Base.Positioner>\n </Base.Portal>\n )\n}\n\n/** The rows, as a list. Undressed: it is a wrapper, and the popup around it\n * already carries the border and the padding. */\nexport const ComboboxList = Base.List\n\n/** A row. */\nexport function ComboboxItem({ className, ...props }: Base.Item.Props) {\n return <Base.Item className={cn(comboboxItemVariants(), className)} {...props} />\n}\n\n/** What is shown when nothing matched. The words are the product's. */\nexport function ComboboxEmpty({ className, ...props }: Base.Empty.Props) {\n return <Base.Empty className={cn('px-2 py-3 text-center text-sm text-faint', className)} {...props} />\n}\n\n/** One chosen value, in a multiple combobox. Focusable, so it can be reached\n * and removed without a pointer. */\nexport function ComboboxChip({ className, ...props }: Base.Chip.Props) {\n return (\n <Base.Chip\n className={cn(\n 'flex items-center gap-1 rounded-sm bg-soft px-1.5 py-0.5 text-xs text-text',\n 'outline-none data-[highlighted]:bg-accent-soft data-[highlighted]:text-accent',\n className,\n )}\n {...props}\n />\n )\n}\n\n/** The X on a chip. A real button, which is what makes Backspace and Enter\n * both work on it. */\nexport function ComboboxChipRemove({ className, ...props }: Base.ChipRemove.Props) {\n return <Base.ChipRemove className={cn(iconButtonClasses, 'p-0', className)} {...props} />\n}\n\n/** The caption above a group. */\nexport function ComboboxGroupLabel({ className, ...props }: Base.GroupLabel.Props) {\n return (\n <Base.GroupLabel\n className={cn('px-2 py-1.5 text-2xs uppercase tracking-caption text-faint', className)}\n {...props}\n />\n )\n}\n"
|
|
649
|
+
"content": "import type { Ref } from 'react'\nimport { Combobox as Base } from '@base-ui/react/combobox'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { cn } from 'dowel-ui'\nimport { fieldClasses } from './input'\nimport { selectItemVariants, selectPopupVariants } from './select'\n\n/*\n * Combobox.\n *\n * A Select you can type in. The list narrows as the query is typed, which is\n * the only difference that matters and the reason to reach for this one: a\n * Select stops being usable somewhere around thirty options, and a country\n * picker or a tag field is well past that.\n *\n * Everything the Select comment says about the native element applies here\n * too - there is no `<select>` under it, and the input is a real `<input\n * role=\"combobox\">` so autofill, spellcheck and the phone keyboard still\n * work.\n *\n * Filtering is Base UI's: give the root an `items` array and it matches the\n * query against them with `Intl.Collator`, so accents and case behave the way\n * a reader in that language expects rather than the way `toLowerCase` does.\n * `filter` replaces the comparison; `filter={null}` turns it off for a list\n * that is filtered on a server.\n *\n * The chips are Base UI's too - Chips, Chip, ChipRemove - and that is worth\n * saying because inventing them is the obvious move and it goes wrong in one\n * specific way: hand-made chips end up as `<div>`s with an X that only a\n * pointer can reach, and the multi-select becomes keyboard-inaccessible at\n * exactly the point where it holds the most state. Base UI's are focusable,\n * walk with the arrows, and delete with Backspace.\n *\n * `Empty` renders only when nothing matched, and announces itself politely.\n * Its element stays mounted for that announcement to work, so it must not be\n * hidden with `display: none` or removed conditionally - which is why it is a\n * component here rather than a `{items.length === 0 && …}` in the product.\n */\n\n/* Two bases, chosen by `bare`, rather than one base and an override.\n *\n * Inside `ComboboxChips` the container is the field, so the input has no\n * border, no background and no focus ring of its own - a bordered box inside a\n * bordered box reads as two controls, and two focus rings appear as one thick\n * one. The obvious way to write that is `fieldClasses` plus a few `-none`\n * classes, and it does not work: `tailwind-merge` does not treat\n * `focus-visible:outline-none` as conflicting with\n * `focus-visible:outline-2 … outline-accent`, so both survive and the later\n * one in the stylesheet wins. The same trap took `w-full` versus `w-auto`\n * earlier in this file.\n *\n * So the variant picks which set applies instead of trying to subtract from\n * one - nothing is left to a merge that has no opinion. */\nexport const comboboxInputVariants = cva('', {\n variants: {\n size: {\n sm: 'h-control-sm text-xs',\n md: 'h-control',\n lg: 'h-control-lg text-base',\n },\n bare: {\n true: 'h-7 w-auto min-w-24 flex-1 bg-transparent px-1 text-sm text-text placeholder:text-faint outline-none',\n false: fieldClasses,\n },\n },\n defaultVariants: { size: 'md', bare: false },\n})\n\n/** The list, and a row in it, are Select's - imported rather than copied.\n *\n * The two popups are the same object seen twice: a dropdown of options, one\n * of which can be chosen. A reader who uses both on one screen should not be\n * able to tell which is which until they type. Two `cva` calls that started\n * identical do not stay that way - one gets the padding fix - and then the\n * form has two dropdowns that are almost the same. */\nexport const comboboxPopupVariants = selectPopupVariants\nexport const comboboxItemVariants = selectItemVariants\n\n/** The root. `items` is what gets filtered; `multiple` turns the value into an\n * array and makes the chips meaningful. */\nexport const Combobox = Base.Root\n\n/** The wrapper for an input with something beside it - a clear button, an\n * icon, the chips. */\nexport const ComboboxInputGroup = Base.InputGroup\n\n/** The button that opens the list without typing, for a reader who wants to\n * see everything there is. */\nexport const ComboboxTrigger = Base.Trigger\n\n/** The chevron. Decorative. */\nexport const ComboboxIcon = Base.Icon\n\n/** A labelled group of rows. */\nexport const ComboboxGroup = Base.Group\n\n/** The rows of one group, as a render function over that group's items.\n *\n * A `List` is the listbox and there is one per combobox, so a grouped list is\n * a `List` over the groups with a `Collection` inside each - not a `List`\n * inside a `List`. Mapping by hand instead works, but the component then has\n * to be told how to match an item to a value, which is a second place for that\n * knowledge to live. */\nexport const ComboboxCollection = Base.Collection\n\n/** The tick, drawn only on a chosen row. */\nexport const ComboboxItemIndicator = Base.ItemIndicator\n\n/** A polite live region for the state of an asynchronous list. Stays mounted,\n * like `Empty`, so the announcement actually fires. */\nexport const ComboboxStatus = Base.Status\n\n/** The container the chips sit in. Its children are plain nodes, not a render\n * function - the chosen values are mapped by `ComboboxValue` inside it.\n *\n * It wears the field's clothes and lays the chips out in a row that wraps,\n * which is the whole difference between a control and a list: unstyled, the\n * chips stack one per line and the box grows into a column of pills with the\n * input stranded underneath. The input sits on the same line as the last\n * chip and takes the rest of the width, so a half-filled field still looks\n * like a field. */\nexport function ComboboxChips({\n ref,\n className,\n ...props\n}: Base.Chips.Props & { ref?: Ref<HTMLDivElement> }) {\n return (\n <Base.Chips\n // Taken out of `...props` and passed on deliberately: a product needs a\n // handle on this box to anchor the list to it, because the input inside\n // is only as wide as what has been typed.\n ref={ref}\n className={cn(\n fieldClasses,\n 'flex min-h-9 flex-wrap items-center gap-1 py-1',\n 'focus-within:outline-2 focus-within:outline-offset-0 focus-within:outline-accent',\n className,\n )}\n {...props}\n />\n )\n}\n\n/** The current value, as a render function of it. This is what turns a\n * `multiple` value into one chip per entry. */\nexport const ComboboxValue = Base.Value\n\n/** `size` is taken from the native `<input size>` - a width in characters,\n * which nothing here wants - and given to the variant instead. */\nexport interface ComboboxInputProps\n extends Omit<Base.Input.Props, 'size'>,\n VariantProps<typeof comboboxInputVariants> {}\n\n/** Where the query is typed. A real `<input role=\"combobox\">`.\n *\n * Inside `ComboboxChips` it drops its own border and background: the\n * container is the field there, and a bordered input inside a bordered box\n * reads as two controls. */\nexport function ComboboxInput({ size, bare, className, ...props }: ComboboxInputProps) {\n // `bare` is pulled out and handed to `cva`. Left in `...props` it would be\n // spread onto the `<input>` as an unknown attribute and change nothing -\n // which is exactly what it did: the variant existed, the prop was passed,\n // and the class list came out without a trace of either.\n return <Base.Input className={cn(comboboxInputVariants({ size, bare }), className)} {...props} />\n}\n\nconst iconButtonClasses = cn(\n 'rounded-sm p-1 text-faint transition-colors hover:text-text',\n 'focus-visible:outline-2 focus-visible:outline-accent',\n 'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',\n)\n\n/** Empties the value. Base UI hides it while there is nothing to clear. */\nexport function ComboboxClear({ className, ...props }: Base.Clear.Props) {\n return <Base.Clear className={cn(iconButtonClasses, className)} {...props} />\n}\n\nexport interface ComboboxPopupProps\n extends Base.Popup.Props,\n VariantProps<typeof comboboxPopupVariants> {\n /** Preferred side of the input. Base UI flips it when it does not fit. */\n side?: Base.Positioner.Props['side']\n /** Alignment along that side. */\n align?: Base.Positioner.Props['align']\n /** Distance from the input, in pixels. */\n sideOffset?: Base.Positioner.Props['sideOffset']\n /**\n * What to line the list up with. Defaults to the input that owns it.\n *\n * Pass the `ComboboxChips` box when there is one: with chips, the input is\n * only as wide as what has been typed - an empty one measured 214px inside\n * a 288px field - so a list anchored to it hangs short of the box a reader\n * sees. Base UI publishes the anchor's width as `--anchor-width`, which is\n * how the mismatch is visible from outside.\n */\n anchor?: Base.Positioner.Props['anchor']\n /** Where to portal to. Defaults to the document body. */\n container?: Base.Portal.Props['container']\n}\n\n/** The list. Portalled and positioned against the input, or the given anchor. */\nexport function ComboboxPopup({\n size,\n side,\n align,\n sideOffset = 4,\n anchor,\n container,\n className,\n children,\n ...props\n}: ComboboxPopupProps) {\n return (\n <Base.Portal container={container}>\n <Base.Positioner\n side={side}\n align={align}\n sideOffset={sideOffset}\n anchor={anchor}\n className=\"[z-index:var(--z-menu)]\"\n >\n <Base.Popup className={cn(comboboxPopupVariants({ size }), className)} {...props}>\n {children}\n </Base.Popup>\n </Base.Positioner>\n </Base.Portal>\n )\n}\n\n/** The rows, as a list. Undressed: it is a wrapper, and the popup around it\n * already carries the border and the padding. */\nexport const ComboboxList = Base.List\n\n/** A row. */\nexport function ComboboxItem({ className, ...props }: Base.Item.Props) {\n return <Base.Item className={cn(comboboxItemVariants(), className)} {...props} />\n}\n\n/** What is shown when nothing matched. The words are the product's. */\nexport function ComboboxEmpty({ className, ...props }: Base.Empty.Props) {\n return <Base.Empty className={cn('px-2 py-3 text-center text-sm text-faint', className)} {...props} />\n}\n\n/** One chosen value, in a multiple combobox. Focusable, so it can be reached\n * and removed without a pointer. */\nexport function ComboboxChip({ className, ...props }: Base.Chip.Props) {\n return (\n <Base.Chip\n className={cn(\n 'flex items-center gap-1 rounded-sm bg-soft px-1.5 py-0.5 text-xs text-text',\n 'outline-none data-[highlighted]:bg-accent-soft data-[highlighted]:text-accent',\n className,\n )}\n {...props}\n />\n )\n}\n\n/** The X on a chip. A real button, which is what makes Backspace and Enter\n * both work on it. */\nexport function ComboboxChipRemove({ className, ...props }: Base.ChipRemove.Props) {\n return <Base.ChipRemove className={cn(iconButtonClasses, 'p-0', className)} {...props} />\n}\n\n/** The caption above a group. */\nexport function ComboboxGroupLabel({ className, ...props }: Base.GroupLabel.Props) {\n return (\n <Base.GroupLabel\n className={cn('px-2 py-1.5 text-2xs uppercase tracking-caption text-faint', className)}\n {...props}\n />\n )\n}\n"
|
|
650
650
|
}
|
|
651
651
|
]
|
|
652
652
|
},
|
|
@@ -658,7 +658,7 @@
|
|
|
658
658
|
"dependencies": [
|
|
659
659
|
"@base-ui/react",
|
|
660
660
|
"class-variance-authority",
|
|
661
|
-
"dowel-ui@^0.
|
|
661
|
+
"dowel-ui@^0.28.0"
|
|
662
662
|
],
|
|
663
663
|
"registryDependencies": [
|
|
664
664
|
"https://lacodda.github.io/dowel/r/combobox.json",
|
|
@@ -681,7 +681,7 @@
|
|
|
681
681
|
"dependencies": [
|
|
682
682
|
"@base-ui/react",
|
|
683
683
|
"class-variance-authority",
|
|
684
|
-
"dowel-ui@^0.
|
|
684
|
+
"dowel-ui@^0.28.0"
|
|
685
685
|
],
|
|
686
686
|
"registryDependencies": [],
|
|
687
687
|
"files": [
|
|
@@ -700,7 +700,7 @@
|
|
|
700
700
|
"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>`.",
|
|
701
701
|
"dependencies": [
|
|
702
702
|
"@base-ui/react",
|
|
703
|
-
"dowel-ui@^0.
|
|
703
|
+
"dowel-ui@^0.28.0"
|
|
704
704
|
],
|
|
705
705
|
"registryDependencies": [
|
|
706
706
|
"https://lacodda.github.io/dowel/r/menu.json"
|
|
@@ -720,7 +720,7 @@
|
|
|
720
720
|
"title": "Copy-button",
|
|
721
721
|
"description": "Whatever a product shows in a panel - code, a payload, a log, one side of a comparison - somebody eventually wants to take it away, and the button that lets them is written again every time with the same three things missed.",
|
|
722
722
|
"dependencies": [
|
|
723
|
-
"dowel-ui@^0.
|
|
723
|
+
"dowel-ui@^0.28.0"
|
|
724
724
|
],
|
|
725
725
|
"registryDependencies": [],
|
|
726
726
|
"files": [
|
|
@@ -728,7 +728,7 @@
|
|
|
728
728
|
"path": "ui/copy-button.tsx",
|
|
729
729
|
"target": "@ui/copy-button.tsx",
|
|
730
730
|
"type": "registry:ui",
|
|
731
|
-
"content": "import { useCallback, useEffect, useRef, useState, type ButtonHTMLAttributes } from 'react'\nimport { cn } from 'dowel-ui'\n\n/*\n * The copy affordance in the corner of a block.\n *\n * Whatever a product shows in a panel - code, a payload, a log, one side of a\n * comparison - somebody eventually wants to take it away, and the button that\n * lets them is written again every time with the same three things missed.\n *\n * **It confirms only after the clipboard does.** The write can be refused:\n * it needs a secure context and, in some browsers, a permission. A tick\n * drawn on click is a lie in exactly the case the reader most needs the\n * truth.\n *\n * **It says so as well as showing it.** A tick that appears silently tells a\n * sighted reader it worked and tells nobody else. The live region is the\n * part that actually reports.\n *\n * **It stays reachable without a pointer.** Revealed on hover, which is\n * right - a permanent button in the corner of every block is clutter - and\n * on its own that makes it unreachable by keyboard. It is visible whenever\n * it has focus too, and that pairing is the whole trick.\n *\n * Copyable is the other shape of this, and the two are not interchangeable:\n * that one is a value sitting in a sentence - inline, truncating, showing the\n * text it copies - and this one is a control beside content already on screen,\n * so it carries an icon and no words.\n */\n\nexport interface CopyButtonProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'onCopy'> {\n /** What lands on the clipboard. */\n value: string\n /** What the button is called, for a screen reader. Required, and\n * deliberately without a default: a string this component invents is a\n * string the product cannot translate, and it would ship in English to\n * every reader who does not read English. */\n label: string\n /** What is announced after a successful copy. Required for the same\n * reason. */\n copiedLabel: string\n /** Told what happened, for a product that wants its own toast. `false` means\n * the clipboard refused. */\n onCopy?: (ok: boolean) => void\n}\n\nexport function CopyButton({\n value,\n label,\n copiedLabel,\n onCopy,\n className,\n ...props\n}: CopyButtonProps) {\n const [copied, setCopied] = useState(false)\n const timer = useRef<ReturnType<typeof setTimeout>>(undefined)\n\n // A component that sets state on a timer has to stop when it goes away, or\n // it wakes up in a tree that no longer exists.\n useEffect(() => () => clearTimeout(timer.current), [])\n\n const copy = useCallback(async () => {\n try {\n await navigator.clipboard.writeText(value)\n setCopied(true)\n onCopy?.(true)\n clearTimeout(timer.current)\n timer.current = setTimeout(() => setCopied(false), 1600)\n } catch {\n onCopy?.(false)\n }\n }, [value, onCopy])\n\n return (\n <>\n <button\n type=\"button\"\n onClick={copy}\n aria-label={copied ? copiedLabel : label}\n className={cn(\n 'shrink-0 rounded-sm p-1 text-faint transition-colors',\n 'hover:bg-soft hover:text-text',\n /* `group-hover` rather than a hover of its own: the button is in the\n * corner of a block, and it has to appear when the pointer is\n * anywhere over that block rather than only once it has found the\n * button. The container carries `group`. */\n 'opacity-0 group-hover:opacity-100 focus-visible:opacity-100',\n 'focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-accent',\n copied && 'text-good opacity-100',\n className,\n )}\n {...props}\n >\n {copied ? <Tick /> : <Clipboard />}\n </button>\n\n {/* The drawn tick is invisible to a screen reader; this is the part that\n * reports the copy. Outside the button, because its content changes and\n * a live region inside a labelled control is announced twice. */}\n <span role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n {copied ? copiedLabel : ''}\n </span>\n </>\n )\n}\n\nfunction Clipboard() {\n return (\n <svg viewBox=\"0 0 16 16\" width=\"13\" height=\"13\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.4\" aria-hidden>\n <rect x=\"5.5\" y=\"2.5\" width=\"8\" height=\"10\" rx=\"1.5\" />\n <path d=\"M10.5 2.5v-.5a1 1 0 0 0-1-1h-6a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h.5\" />\n </svg>\n )\n}\n\nfunction Tick() {\n return (\n <svg viewBox=\"0 0 16 16\" width=\"13\" height=\"13\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\" aria-hidden>\n <path d=\"M3 8.5l3.5 3.5L13 5\" strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n </svg>\n )\n}\n"
|
|
731
|
+
"content": "import { useCallback, useEffect, useRef, useState, type ButtonHTMLAttributes } from 'react'\nimport { cn } from 'dowel-ui'\n\n/*\n * The copy affordance in the corner of a block.\n *\n * Whatever a product shows in a panel - code, a payload, a log, one side of a\n * comparison - somebody eventually wants to take it away, and the button that\n * lets them is written again every time with the same three things missed.\n *\n * **It confirms only after the clipboard does.** The write can be refused:\n * it needs a secure context and, in some browsers, a permission. A tick\n * drawn on click is a lie in exactly the case the reader most needs the\n * truth.\n *\n * **It says so as well as showing it.** A tick that appears silently tells a\n * sighted reader it worked and tells nobody else. The live region is the\n * part that actually reports.\n *\n * **It stays reachable without a pointer.** Revealed on hover, which is\n * right - a permanent button in the corner of every block is clutter - and\n * on its own that makes it unreachable by keyboard. It is visible whenever\n * it has focus too, and that pairing is the whole trick.\n *\n * Copyable is the other shape of this, and the two are not interchangeable:\n * that one is a value sitting in a sentence - inline, truncating, showing the\n * text it copies - and this one is a control beside content already on screen,\n * so it carries an icon and no words.\n */\n\nexport interface CopyButtonProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'onCopy'> {\n /** What lands on the clipboard. */\n value: string\n /** What the button is called, for a screen reader. Required, and\n * deliberately without a default: a string this component invents is a\n * string the product cannot translate, and it would ship in English to\n * every reader who does not read English. */\n label: string\n /** What is announced after a successful copy. Required for the same\n * reason. */\n copiedLabel: string\n /** Told what happened, for a product that wants its own toast. `false` means\n * the clipboard refused. */\n onCopy?: (ok: boolean) => void\n}\n\nexport function CopyButton({\n value,\n label,\n copiedLabel,\n onCopy,\n className,\n ...props\n}: CopyButtonProps) {\n const [copied, setCopied] = useState(false)\n const timer = useRef<ReturnType<typeof setTimeout>>(undefined)\n\n // A component that sets state on a timer has to stop when it goes away, or\n // it wakes up in a tree that no longer exists.\n useEffect(() => () => clearTimeout(timer.current), [])\n\n const copy = useCallback(async () => {\n try {\n await navigator.clipboard.writeText(value)\n setCopied(true)\n onCopy?.(true)\n clearTimeout(timer.current)\n timer.current = setTimeout(() => setCopied(false), 1600)\n } catch {\n onCopy?.(false)\n }\n }, [value, onCopy])\n\n return (\n <>\n <button\n type=\"button\"\n onClick={copy}\n aria-label={copied ? copiedLabel : label}\n className={cn(\n 'shrink-0 rounded-sm p-1 text-faint transition-colors target-min',\n 'hover:bg-soft hover:text-text',\n /* `group-hover` rather than a hover of its own: the button is in the\n * corner of a block, and it has to appear when the pointer is\n * anywhere over that block rather than only once it has found the\n * button. The container carries `group`. */\n 'opacity-0 group-hover:opacity-100 focus-visible:opacity-100',\n 'focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-accent',\n copied && 'text-good opacity-100',\n className,\n )}\n {...props}\n >\n {copied ? <Tick /> : <Clipboard />}\n </button>\n\n {/* The drawn tick is invisible to a screen reader; this is the part that\n * reports the copy. Outside the button, because its content changes and\n * a live region inside a labelled control is announced twice. */}\n <span role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n {copied ? copiedLabel : ''}\n </span>\n </>\n )\n}\n\nfunction Clipboard() {\n return (\n <svg viewBox=\"0 0 16 16\" width=\"13\" height=\"13\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.4\" aria-hidden>\n <rect x=\"5.5\" y=\"2.5\" width=\"8\" height=\"10\" rx=\"1.5\" />\n <path d=\"M10.5 2.5v-.5a1 1 0 0 0-1-1h-6a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h.5\" />\n </svg>\n )\n}\n\nfunction Tick() {\n return (\n <svg viewBox=\"0 0 16 16\" width=\"13\" height=\"13\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\" aria-hidden>\n <path d=\"M3 8.5l3.5 3.5L13 5\" strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n </svg>\n )\n}\n"
|
|
732
732
|
}
|
|
733
733
|
]
|
|
734
734
|
},
|
|
@@ -738,7 +738,7 @@
|
|
|
738
738
|
"title": "Copyable",
|
|
739
739
|
"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.",
|
|
740
740
|
"dependencies": [
|
|
741
|
-
"dowel-ui@^0.
|
|
741
|
+
"dowel-ui@^0.28.0"
|
|
742
742
|
],
|
|
743
743
|
"registryDependencies": [],
|
|
744
744
|
"files": [
|
|
@@ -746,7 +746,7 @@
|
|
|
746
746
|
"path": "ui/copyable.tsx",
|
|
747
747
|
"target": "@ui/copyable.tsx",
|
|
748
748
|
"type": "registry:ui",
|
|
749
|
-
"content": "import { useCallback, useEffect, useRef, useState, type HTMLAttributes } from 'react'\nimport { cn } from 'dowel-ui'\n\n/*\n * Copyable.\n *\n * Any text that someone will eventually want to copy - an id, a path, a hash,\n * a token - copied with one click. The rule comes from nitid: if a value is\n * worth showing, it is worth being able to take away, and selecting a\n * monospaced id by hand is a small daily tax.\n *\n * Two things this gets right that the hand-written version usually does not.\n * It is a `<button>`, so the keyboard can reach it and a screen reader says\n * what it does. And the confirmation is announced, not only drawn: a tick that\n * appears silently tells a sighted user it worked and tells nobody else.\n *\n * The clipboard can refuse - it needs a secure context and, in some browsers,\n * a permission. A refusal is reported rather than swallowed, because a button\n * that looks like it worked and did not is worse than one that says it failed.\n */\nexport interface CopyableProps extends Omit<HTMLAttributes<HTMLButtonElement>, 'onCopy'> {\n /** What lands on the clipboard. Defaults to the visible text. */\n value?: string\n /** The visible text. */\n children: string\n /** What the button is called, for a screen reader. Required, and\n * deliberately without a default: a string the component invents is a\n * string the product cannot translate, and it would ship in English to\n * every reader who does not read English. */\n label: string\n /** What is announced after a successful copy. Required for the same\n * reason. */\n copiedLabel: string\n /** Told what happened, for a product that wants its own toast. */\n onCopy?: (ok: boolean) => void\n}\n\nexport function Copyable({\n value,\n children,\n label,\n copiedLabel,\n onCopy,\n className,\n ...props\n}: CopyableProps) {\n const [copied, setCopied] = useState(false)\n const timer = useRef<ReturnType<typeof setTimeout>>(undefined)\n\n // A component that sets state on a timer has to stop when it goes away, or\n // it wakes up in a tree that no longer exists.\n useEffect(() => () => clearTimeout(timer.current), [])\n\n const copy = useCallback(async () => {\n try {\n await navigator.clipboard.writeText(value ?? children)\n setCopied(true)\n onCopy?.(true)\n clearTimeout(timer.current)\n timer.current = setTimeout(() => setCopied(false), 1600)\n } catch {\n // No secure context, or permission refused. Say so rather than pretend.\n onCopy?.(false)\n }\n }, [value, children, onCopy])\n\n return (\n <button\n type=\"button\"\n onClick={copy}\n aria-label={copied ? copiedLabel : label}\n className={cn(\n 'group inline-flex max-w-full items-center gap-1.5 rounded-sm px-1 py-0.5 text-left',\n 'font-mono text-xs text-dim transition-colors',\n 'hover:bg-soft hover:text-text',\n 'focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-accent',\n className,\n )}\n {...props}\n >\n <span className=\"truncate\">{children}</span>\n\n <span className={cn('shrink-0 transition-colors', copied ? 'text-good' : 'text-faint')} aria-hidden>\n {copied ? <Tick /> : <Clipboard />}\n </span>\n\n {/* Drawn confirmation is invisible to a screen reader; this is the part\n that actually says it worked. */}\n <span role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n {copied ? copiedLabel : ''}\n </span>\n </button>\n )\n}\n\nfunction Clipboard() {\n return (\n <svg viewBox=\"0 0 16 16\" width=\"12\" height=\"12\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.4\" aria-hidden>\n <rect x=\"5.5\" y=\"2.5\" width=\"8\" height=\"10\" rx=\"1.5\" />\n <path d=\"M10.5 2.5v-.5a1 1 0 0 0-1-1h-6a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h.5\" />\n </svg>\n )\n}\n\nfunction Tick() {\n return (\n <svg viewBox=\"0 0 16 16\" width=\"12\" height=\"12\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\" aria-hidden>\n <path d=\"M3 8.5l3.5 3.5L13 5\" strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n </svg>\n )\n}\n"
|
|
749
|
+
"content": "import { useCallback, useEffect, useRef, useState, type HTMLAttributes } from 'react'\nimport { cn } from 'dowel-ui'\n\n/*\n * Copyable.\n *\n * Any text that someone will eventually want to copy - an id, a path, a hash,\n * a token - copied with one click. The rule comes from nitid: if a value is\n * worth showing, it is worth being able to take away, and selecting a\n * monospaced id by hand is a small daily tax.\n *\n * Two things this gets right that the hand-written version usually does not.\n * It is a `<button>`, so the keyboard can reach it and a screen reader says\n * what it does. And the confirmation is announced, not only drawn: a tick that\n * appears silently tells a sighted user it worked and tells nobody else.\n *\n * The clipboard can refuse - it needs a secure context and, in some browsers,\n * a permission. A refusal is reported rather than swallowed, because a button\n * that looks like it worked and did not is worse than one that says it failed.\n */\nexport interface CopyableProps extends Omit<HTMLAttributes<HTMLButtonElement>, 'onCopy'> {\n /** What lands on the clipboard. Defaults to the visible text. */\n value?: string\n /** The visible text. */\n children: string\n /** What the button is called, for a screen reader. Required, and\n * deliberately without a default: a string the component invents is a\n * string the product cannot translate, and it would ship in English to\n * every reader who does not read English. */\n label: string\n /** What is announced after a successful copy. Required for the same\n * reason. */\n copiedLabel: string\n /** Told what happened, for a product that wants its own toast. */\n onCopy?: (ok: boolean) => void\n}\n\nexport function Copyable({\n value,\n children,\n label,\n copiedLabel,\n onCopy,\n className,\n ...props\n}: CopyableProps) {\n const [copied, setCopied] = useState(false)\n const timer = useRef<ReturnType<typeof setTimeout>>(undefined)\n\n // A component that sets state on a timer has to stop when it goes away, or\n // it wakes up in a tree that no longer exists.\n useEffect(() => () => clearTimeout(timer.current), [])\n\n const copy = useCallback(async () => {\n try {\n await navigator.clipboard.writeText(value ?? children)\n setCopied(true)\n onCopy?.(true)\n clearTimeout(timer.current)\n timer.current = setTimeout(() => setCopied(false), 1600)\n } catch {\n // No secure context, or permission refused. Say so rather than pretend.\n onCopy?.(false)\n }\n }, [value, children, onCopy])\n\n return (\n <button\n type=\"button\"\n onClick={copy}\n aria-label={copied ? copiedLabel : label}\n className={cn(\n 'group inline-flex max-w-full items-center gap-1.5 rounded-sm px-1 py-0.5 text-left target-min',\n 'font-mono text-xs text-dim transition-colors',\n 'hover:bg-soft hover:text-text',\n 'focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-accent',\n className,\n )}\n {...props}\n >\n <span className=\"truncate\">{children}</span>\n\n <span className={cn('shrink-0 transition-colors', copied ? 'text-good' : 'text-faint')} aria-hidden>\n {copied ? <Tick /> : <Clipboard />}\n </span>\n\n {/* Drawn confirmation is invisible to a screen reader; this is the part\n that actually says it worked. */}\n <span role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n {copied ? copiedLabel : ''}\n </span>\n </button>\n )\n}\n\nfunction Clipboard() {\n return (\n <svg viewBox=\"0 0 16 16\" width=\"12\" height=\"12\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.4\" aria-hidden>\n <rect x=\"5.5\" y=\"2.5\" width=\"8\" height=\"10\" rx=\"1.5\" />\n <path d=\"M10.5 2.5v-.5a1 1 0 0 0-1-1h-6a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h.5\" />\n </svg>\n )\n}\n\nfunction Tick() {\n return (\n <svg viewBox=\"0 0 16 16\" width=\"12\" height=\"12\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\" aria-hidden>\n <path d=\"M3 8.5l3.5 3.5L13 5\" strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n </svg>\n )\n}\n"
|
|
750
750
|
}
|
|
751
751
|
]
|
|
752
752
|
},
|
|
@@ -756,7 +756,7 @@
|
|
|
756
756
|
"title": "Date-picker",
|
|
757
757
|
"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.",
|
|
758
758
|
"dependencies": [
|
|
759
|
-
"dowel-ui@^0.
|
|
759
|
+
"dowel-ui@^0.28.0"
|
|
760
760
|
],
|
|
761
761
|
"registryDependencies": [
|
|
762
762
|
"https://lacodda.github.io/dowel/r/calendar.json",
|
|
@@ -769,7 +769,7 @@
|
|
|
769
769
|
"path": "ui/date-picker.tsx",
|
|
770
770
|
"target": "@ui/date-picker.tsx",
|
|
771
771
|
"type": "registry:ui",
|
|
772
|
-
"content": "import { useMemo, useState } from 'react'\nimport { cn } from 'dowel-ui'\nimport { fieldClasses } from './input'\nimport { Popover, PopoverPopup, PopoverTrigger } from './popover'\nimport { Calendar } from './calendar'\nimport { isIsoDate, type IsoDate } from './calendar-math'\n\n/*\n * DatePicker - a field that opens a month.\n *\n * The trigger is a button rather than a text input, and that is the decision\n * worth stating. A typable date field has to answer \"what does `03/04/26`\n * mean\" in a locale it cannot be sure of, and it answers wrong for half the\n * world; a button showing the date spelled out has no such question. Where\n * typing genuinely matters - a birth date, forty years back - the calendar is\n * the wrong control anyway and a product should reach for a plain field.\n *\n * The value is a calendar date as a string, `YYYY-MM-DD`, for the reasons the\n * Calendar states: a date with a timezone is a moment, and moments cross\n * midnight when they are serialised.\n *\n * What is shown is `Intl`'s own long form - \"2 September 2026\" here, \"September\n * 2, 2026\" in the United States - because a date written the reader's way is\n * one they do not have to decode.\n */\n\nexport interface DatePickerProps {\n /** The chosen day, or `undefined` for none. */\n value?: IsoDate\n onValueChange?: (value: IsoDate) => void\n /** Bounds, inclusive. */\n min?: IsoDate\n max?: IsoDate\n /** What the trigger says when nothing is chosen. The product's word, since\n * a default here would be English inside a primitive. */\n placeholder: string\n /** Names the two month-paging buttons inside the calendar. */\n previousMonthLabel: string\n nextMonthLabel: string\n /** How the date is written and which day starts the week. The reader's own\n * unless stated. */\n locale?: string\n disabled?: boolean\n name?: string\n 'aria-label'?: string\n className?: string\n}\n\nexport function DatePicker({\n value,\n onValueChange,\n min,\n max,\n placeholder,\n previousMonthLabel,\n nextMonthLabel,\n locale,\n disabled = false,\n name,\n className,\n 'aria-label': ariaLabel,\n}: DatePickerProps) {\n const [open, setOpen] = useState(false)\n\n const shown = useMemo(() => {\n if (value === undefined || !isIsoDate(value)) return undefined\n const [year, month, day] = value.split('-').map(Number) as [number, number, number]\n return new Intl.DateTimeFormat(locale, { dateStyle: 'long' }).format(\n new Date(year, month - 1, day),\n )\n }, [value, locale])\n\n return (\n <Popover open={open} onOpenChange={setOpen}>\n <PopoverTrigger\n disabled={disabled}\n aria-label={ariaLabel}\n className={cn(\n fieldClasses,\n 'flex h-
|
|
772
|
+
"content": "import { useMemo, useState } from 'react'\nimport { cn } from 'dowel-ui'\nimport { fieldClasses } from './input'\nimport { Popover, PopoverPopup, PopoverTrigger } from './popover'\nimport { Calendar } from './calendar'\nimport { isIsoDate, type IsoDate } from './calendar-math'\n\n/*\n * DatePicker - a field that opens a month.\n *\n * The trigger is a button rather than a text input, and that is the decision\n * worth stating. A typable date field has to answer \"what does `03/04/26`\n * mean\" in a locale it cannot be sure of, and it answers wrong for half the\n * world; a button showing the date spelled out has no such question. Where\n * typing genuinely matters - a birth date, forty years back - the calendar is\n * the wrong control anyway and a product should reach for a plain field.\n *\n * The value is a calendar date as a string, `YYYY-MM-DD`, for the reasons the\n * Calendar states: a date with a timezone is a moment, and moments cross\n * midnight when they are serialised.\n *\n * What is shown is `Intl`'s own long form - \"2 September 2026\" here, \"September\n * 2, 2026\" in the United States - because a date written the reader's way is\n * one they do not have to decode.\n */\n\nexport interface DatePickerProps {\n /** The chosen day, or `undefined` for none. */\n value?: IsoDate\n onValueChange?: (value: IsoDate) => void\n /** Bounds, inclusive. */\n min?: IsoDate\n max?: IsoDate\n /** What the trigger says when nothing is chosen. The product's word, since\n * a default here would be English inside a primitive. */\n placeholder: string\n /** Names the two month-paging buttons inside the calendar. */\n previousMonthLabel: string\n nextMonthLabel: string\n /** How the date is written and which day starts the week. The reader's own\n * unless stated. */\n locale?: string\n disabled?: boolean\n name?: string\n 'aria-label'?: string\n className?: string\n}\n\nexport function DatePicker({\n value,\n onValueChange,\n min,\n max,\n placeholder,\n previousMonthLabel,\n nextMonthLabel,\n locale,\n disabled = false,\n name,\n className,\n 'aria-label': ariaLabel,\n}: DatePickerProps) {\n const [open, setOpen] = useState(false)\n\n const shown = useMemo(() => {\n if (value === undefined || !isIsoDate(value)) return undefined\n const [year, month, day] = value.split('-').map(Number) as [number, number, number]\n return new Intl.DateTimeFormat(locale, { dateStyle: 'long' }).format(\n new Date(year, month - 1, day),\n )\n }, [value, locale])\n\n return (\n <Popover open={open} onOpenChange={setOpen}>\n <PopoverTrigger\n disabled={disabled}\n aria-label={ariaLabel}\n className={cn(\n fieldClasses,\n 'flex h-control cursor-pointer items-center gap-2 text-left',\n 'disabled:cursor-not-allowed',\n className,\n )}\n >\n <svg viewBox=\"0 0 16 16\" className=\"size-4 shrink-0 text-faint\" fill=\"none\" aria-hidden>\n <rect x=\"2\" y=\"3\" width=\"12\" height=\"11\" rx=\"2\" stroke=\"currentColor\" strokeWidth=\"1.3\" />\n <path d=\"M2 6.5h12M5.5 2v2M10.5 2v2\" stroke=\"currentColor\" strokeWidth=\"1.3\" strokeLinecap=\"round\" />\n </svg>\n <span className={cn('truncate', shown === undefined && 'text-faint')}>\n {shown ?? placeholder}\n </span>\n </PopoverTrigger>\n\n {/* The value also goes into a form, because a button is not a field and\n * a form submitting the screen would otherwise lose the date. */}\n {name !== undefined && <input type=\"hidden\" name={name} value={value ?? ''} />}\n\n <PopoverPopup arrow={false} className=\"w-auto p-3\">\n <Calendar\n value={value}\n min={min}\n max={max}\n locale={locale}\n aria-label={ariaLabel ?? placeholder}\n previousMonthLabel={previousMonthLabel}\n nextMonthLabel={nextMonthLabel}\n onValueChange={(next) => {\n onValueChange?.(next)\n // Choosing a day is the whole errand: the popup closes rather\n // than waiting for a second dismissing click.\n setOpen(false)\n }}\n />\n </PopoverPopup>\n </Popover>\n )\n}\n"
|
|
773
773
|
}
|
|
774
774
|
]
|
|
775
775
|
},
|
|
@@ -779,7 +779,7 @@
|
|
|
779
779
|
"title": "Date-range-picker",
|
|
780
780
|
"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.",
|
|
781
781
|
"dependencies": [
|
|
782
|
-
"dowel-ui@^0.
|
|
782
|
+
"dowel-ui@^0.28.0"
|
|
783
783
|
],
|
|
784
784
|
"registryDependencies": [
|
|
785
785
|
"https://lacodda.github.io/dowel/r/calendar.json",
|
|
@@ -792,7 +792,7 @@
|
|
|
792
792
|
"path": "ui/date-range-picker.tsx",
|
|
793
793
|
"target": "@ui/date-range-picker.tsx",
|
|
794
794
|
"type": "registry:ui",
|
|
795
|
-
"content": "import { useMemo, useState } from 'react'\nimport { cn } from 'dowel-ui'\nimport { fieldClasses } from './input'\nimport { Popover, PopoverPopup, PopoverTrigger } from './popover'\nimport { Calendar } from './calendar'\nimport { isIsoDate, type IsoDate } from './calendar-math'\n\n/*\n * DateRangePicker - two days, chosen in two clicks.\n *\n * The interesting part is the state between them. After the first click there\n * is a start and no end, and that is not an incomplete range to be hidden or\n * a range of one day - it is the normal middle of the interaction, and the\n * calendar has to show it: the first day marked, the days under the pointer\n * shading as the reader moves, the popup staying open. Products that skip it\n * end up with a picker that seems to do nothing until the second click.\n *\n * So the value is a pair where either end may be absent, and the component is\n * explicit about which half it is waiting for. `onValueChange` fires on both\n * clicks - a product watching it sees the half-made range, which is what lets\n * it show \"from 2 September\" while the reader is still deciding.\n *\n * The second click can land before the first. Clicking the 20th and then the\n * 10th means the 10th to the 20th, because that is plainly what was meant;\n * refusing it would be correct and unhelpful.\n */\n\nexport interface DateRange {\n /** The first day, inclusive. */\n start?: IsoDate\n /** The last day, inclusive. Absent while the range is half made. */\n end?: IsoDate\n}\n\nexport interface DateRangePickerProps {\n value?: DateRange\n onValueChange?: (value: DateRange) => void\n min?: IsoDate\n max?: IsoDate\n /** What the trigger says when nothing is chosen. */\n placeholder: string\n /** Names the two month-paging buttons inside the calendar. */\n previousMonthLabel: string\n nextMonthLabel: string\n locale?: string\n disabled?: boolean\n 'aria-label'?: string\n className?: string\n}\n\nexport function DateRangePicker({\n value,\n onValueChange,\n min,\n max,\n placeholder,\n previousMonthLabel,\n nextMonthLabel,\n locale,\n disabled = false,\n className,\n 'aria-label': ariaLabel,\n}: DateRangePickerProps) {\n const [open, setOpen] = useState(false)\n\n const range = value ?? {}\n const waitingForEnd = range.start !== undefined && range.end === undefined\n\n const shown = useMemo(() => {\n const write = (date: IsoDate) => {\n const [year, month, day] = date.split('-').map(Number) as [number, number, number]\n return new Intl.DateTimeFormat(locale, { dateStyle: 'medium' }).format(\n new Date(year, month - 1, day),\n )\n }\n if (range.start === undefined || !isIsoDate(range.start)) return undefined\n if (range.end === undefined) return write(range.start)\n // An en dash rather than a hyphen: this is a span, and the two read\n // differently at a glance in a row of dates.\n return `${write(range.start)} – ${write(range.end)}`\n }, [range.start, range.end, locale])\n\n const choose = (date: IsoDate) => {\n // A fresh click starts a new range whenever there is nothing waiting -\n // including right after a completed one, which is what a reader means by\n // clicking again.\n if (!waitingForEnd) {\n onValueChange?.({ start: date })\n return\n }\n\n const start = range.start!\n // Backwards is fine: the reader plainly meant the span between them.\n const next: DateRange = date < start ? { start: date, end: start } : { start, end: date }\n onValueChange?.(next)\n setOpen(false)\n }\n\n return (\n <Popover open={open} onOpenChange={setOpen}>\n <PopoverTrigger\n disabled={disabled}\n aria-label={ariaLabel}\n className={cn(\n fieldClasses,\n 'flex h-
|
|
795
|
+
"content": "import { useMemo, useState } from 'react'\nimport { cn } from 'dowel-ui'\nimport { fieldClasses } from './input'\nimport { Popover, PopoverPopup, PopoverTrigger } from './popover'\nimport { Calendar } from './calendar'\nimport { isIsoDate, type IsoDate } from './calendar-math'\n\n/*\n * DateRangePicker - two days, chosen in two clicks.\n *\n * The interesting part is the state between them. After the first click there\n * is a start and no end, and that is not an incomplete range to be hidden or\n * a range of one day - it is the normal middle of the interaction, and the\n * calendar has to show it: the first day marked, the days under the pointer\n * shading as the reader moves, the popup staying open. Products that skip it\n * end up with a picker that seems to do nothing until the second click.\n *\n * So the value is a pair where either end may be absent, and the component is\n * explicit about which half it is waiting for. `onValueChange` fires on both\n * clicks - a product watching it sees the half-made range, which is what lets\n * it show \"from 2 September\" while the reader is still deciding.\n *\n * The second click can land before the first. Clicking the 20th and then the\n * 10th means the 10th to the 20th, because that is plainly what was meant;\n * refusing it would be correct and unhelpful.\n */\n\nexport interface DateRange {\n /** The first day, inclusive. */\n start?: IsoDate\n /** The last day, inclusive. Absent while the range is half made. */\n end?: IsoDate\n}\n\nexport interface DateRangePickerProps {\n value?: DateRange\n onValueChange?: (value: DateRange) => void\n min?: IsoDate\n max?: IsoDate\n /** What the trigger says when nothing is chosen. */\n placeholder: string\n /** Names the two month-paging buttons inside the calendar. */\n previousMonthLabel: string\n nextMonthLabel: string\n locale?: string\n disabled?: boolean\n 'aria-label'?: string\n className?: string\n}\n\nexport function DateRangePicker({\n value,\n onValueChange,\n min,\n max,\n placeholder,\n previousMonthLabel,\n nextMonthLabel,\n locale,\n disabled = false,\n className,\n 'aria-label': ariaLabel,\n}: DateRangePickerProps) {\n const [open, setOpen] = useState(false)\n\n const range = value ?? {}\n const waitingForEnd = range.start !== undefined && range.end === undefined\n\n const shown = useMemo(() => {\n const write = (date: IsoDate) => {\n const [year, month, day] = date.split('-').map(Number) as [number, number, number]\n return new Intl.DateTimeFormat(locale, { dateStyle: 'medium' }).format(\n new Date(year, month - 1, day),\n )\n }\n if (range.start === undefined || !isIsoDate(range.start)) return undefined\n if (range.end === undefined) return write(range.start)\n // An en dash rather than a hyphen: this is a span, and the two read\n // differently at a glance in a row of dates.\n return `${write(range.start)} – ${write(range.end)}`\n }, [range.start, range.end, locale])\n\n const choose = (date: IsoDate) => {\n // A fresh click starts a new range whenever there is nothing waiting -\n // including right after a completed one, which is what a reader means by\n // clicking again.\n if (!waitingForEnd) {\n onValueChange?.({ start: date })\n return\n }\n\n const start = range.start!\n // Backwards is fine: the reader plainly meant the span between them.\n const next: DateRange = date < start ? { start: date, end: start } : { start, end: date }\n onValueChange?.(next)\n setOpen(false)\n }\n\n return (\n <Popover open={open} onOpenChange={setOpen}>\n <PopoverTrigger\n disabled={disabled}\n aria-label={ariaLabel}\n className={cn(\n fieldClasses,\n 'flex h-control cursor-pointer items-center gap-2 text-left',\n 'disabled:cursor-not-allowed',\n className,\n )}\n >\n <svg viewBox=\"0 0 16 16\" className=\"size-4 shrink-0 text-faint\" fill=\"none\" aria-hidden>\n <rect x=\"2\" y=\"3\" width=\"12\" height=\"11\" rx=\"2\" stroke=\"currentColor\" strokeWidth=\"1.3\" />\n <path d=\"M2 6.5h12M5.5 2v2M10.5 2v2\" stroke=\"currentColor\" strokeWidth=\"1.3\" strokeLinecap=\"round\" />\n </svg>\n <span className={cn('truncate', shown === undefined && 'text-faint')}>\n {shown ?? placeholder}\n </span>\n </PopoverTrigger>\n\n <PopoverPopup arrow={false} className=\"w-auto p-3\">\n <Calendar\n value={range.start}\n rangeEnd={range.end}\n min={min}\n max={max}\n locale={locale}\n aria-label={ariaLabel ?? placeholder}\n previousMonthLabel={previousMonthLabel}\n nextMonthLabel={nextMonthLabel}\n onValueChange={choose}\n />\n </PopoverPopup>\n </Popover>\n )\n}\n"
|
|
796
796
|
}
|
|
797
797
|
]
|
|
798
798
|
},
|
|
@@ -804,7 +804,7 @@
|
|
|
804
804
|
"dependencies": [
|
|
805
805
|
"@base-ui/react",
|
|
806
806
|
"class-variance-authority",
|
|
807
|
-
"dowel-ui@^0.
|
|
807
|
+
"dowel-ui@^0.28.0"
|
|
808
808
|
],
|
|
809
809
|
"registryDependencies": [],
|
|
810
810
|
"files": [
|
|
@@ -839,7 +839,7 @@
|
|
|
839
839
|
"description": "The question this answers is \"how did this read before, and how does it read now\" - a version against the one before it, a proposal against what is there, a file against what is on disk. Not a code review: there is no staging, no comment, nothing to accept. It is for looking.",
|
|
840
840
|
"dependencies": [
|
|
841
841
|
"class-variance-authority",
|
|
842
|
-
"dowel-ui@^0.
|
|
842
|
+
"dowel-ui@^0.28.0"
|
|
843
843
|
],
|
|
844
844
|
"registryDependencies": [
|
|
845
845
|
"https://lacodda.github.io/dowel/r/copy-button.json",
|
|
@@ -862,7 +862,7 @@
|
|
|
862
862
|
"dependencies": [
|
|
863
863
|
"@base-ui/react",
|
|
864
864
|
"class-variance-authority",
|
|
865
|
-
"dowel-ui@^0.
|
|
865
|
+
"dowel-ui@^0.28.0"
|
|
866
866
|
],
|
|
867
867
|
"registryDependencies": [],
|
|
868
868
|
"files": [
|
|
@@ -880,7 +880,7 @@
|
|
|
880
880
|
"title": "Duration-field",
|
|
881
881
|
"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.",
|
|
882
882
|
"dependencies": [
|
|
883
|
-
"dowel-ui@^0.
|
|
883
|
+
"dowel-ui@^0.28.0"
|
|
884
884
|
],
|
|
885
885
|
"registryDependencies": [
|
|
886
886
|
"https://lacodda.github.io/dowel/r/input.json"
|
|
@@ -890,7 +890,7 @@
|
|
|
890
890
|
"path": "ui/duration-field.tsx",
|
|
891
891
|
"target": "@ui/duration-field.tsx",
|
|
892
892
|
"type": "registry:ui",
|
|
893
|
-
"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-
|
|
893
|
+
"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-control tabular-nums', className)}\n />\n )\n}\n"
|
|
894
894
|
}
|
|
895
895
|
]
|
|
896
896
|
},
|
|
@@ -901,7 +901,7 @@
|
|
|
901
901
|
"description": "Three kinds of nothing, and a product that draws the same panel for all three is telling the reader the wrong thing twice:\n * **empty** - there is nothing here yet, and that is normal. The panel says what would be here and offers the one action that makes it appear.",
|
|
902
902
|
"dependencies": [
|
|
903
903
|
"class-variance-authority",
|
|
904
|
-
"dowel-ui@^0.
|
|
904
|
+
"dowel-ui@^0.28.0"
|
|
905
905
|
],
|
|
906
906
|
"registryDependencies": [],
|
|
907
907
|
"files": [
|
|
@@ -939,7 +939,7 @@
|
|
|
939
939
|
"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.",
|
|
940
940
|
"dependencies": [
|
|
941
941
|
"@base-ui/react",
|
|
942
|
-
"dowel-ui@^0.
|
|
942
|
+
"dowel-ui@^0.28.0"
|
|
943
943
|
],
|
|
944
944
|
"registryDependencies": [],
|
|
945
945
|
"files": [
|
|
@@ -957,7 +957,7 @@
|
|
|
957
957
|
"title": "File-drop",
|
|
958
958
|
"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.",
|
|
959
959
|
"dependencies": [
|
|
960
|
-
"dowel-ui@^0.
|
|
960
|
+
"dowel-ui@^0.28.0"
|
|
961
961
|
],
|
|
962
962
|
"registryDependencies": [],
|
|
963
963
|
"files": [
|
|
@@ -975,7 +975,7 @@
|
|
|
975
975
|
"title": "Filter-popover",
|
|
976
976
|
"description": "A text box, a handful of checkboxes - with one way to clear it. The shell only: what goes in the panel is the caller's, since a stage is ticked and a title is typed and the popover has no opinion.",
|
|
977
977
|
"dependencies": [
|
|
978
|
-
"dowel-ui@^0.
|
|
978
|
+
"dowel-ui@^0.28.0"
|
|
979
979
|
],
|
|
980
980
|
"registryDependencies": [
|
|
981
981
|
"https://lacodda.github.io/dowel/r/button.json",
|
|
@@ -996,7 +996,7 @@
|
|
|
996
996
|
"title": "Input",
|
|
997
997
|
"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.",
|
|
998
998
|
"dependencies": [
|
|
999
|
-
"dowel-ui@^0.
|
|
999
|
+
"dowel-ui@^0.28.0"
|
|
1000
1000
|
],
|
|
1001
1001
|
"registryDependencies": [],
|
|
1002
1002
|
"files": [
|
|
@@ -1004,7 +1004,7 @@
|
|
|
1004
1004
|
"path": "ui/input.tsx",
|
|
1005
1005
|
"target": "@ui/input.tsx",
|
|
1006
1006
|
"type": "registry:ui",
|
|
1007
|
-
"content": "import type { InputHTMLAttributes, Ref } from 'react'\nimport { cn } from 'dowel-ui'\n\n/*\n * Input.\n *\n * A single-line field. It is a plain `<input>` with the line's clothes on, so\n * everything a browser gives an input for free - autofill, spellcheck, the\n * right keyboard on a phone, `type=\"email\"` validation - still works.\n *\n * The focus ring is the accent, drawn outside the border rather than replacing\n * it: a field that only changes colour on focus is invisible to anyone who\n * cannot distinguish those two colours.\n */\nexport const fieldClasses = cn(\n 'w-full rounded-md border border-line bg-transparent px-2.5 py-1.5',\n 'text-sm text-text placeholder:text-faint',\n 'transition-colors hover:border-line-2',\n 'focus-visible:outline-2 focus-visible:outline-offset-0 focus-visible:outline-accent',\n // A field nobody can type in should look like one.\n 'disabled:cursor-not-allowed disabled:opacity-50',\n // `aria-invalid` rather than a prop: the attribute is what a screen reader\n // reads, so making it the source of the colour keeps the two in step.\n 'aria-invalid:border-bad aria-invalid:focus-visible:outline-bad',\n)\n\nexport interface InputProps extends InputHTMLAttributes<HTMLInputElement> {\n /** React 19 passes `ref` as a plain prop; it is declared so callers can\n * reach the element to focus it or read its selection. */\n ref?: Ref<HTMLInputElement>\n}\n\nexport function Input({ className, ref, ...props }: InputProps) {\n return <input ref={ref} className={cn(fieldClasses, 'h-
|
|
1007
|
+
"content": "import type { InputHTMLAttributes, Ref } from 'react'\nimport { cn } from 'dowel-ui'\n\n/*\n * Input.\n *\n * A single-line field. It is a plain `<input>` with the line's clothes on, so\n * everything a browser gives an input for free - autofill, spellcheck, the\n * right keyboard on a phone, `type=\"email\"` validation - still works.\n *\n * The focus ring is the accent, drawn outside the border rather than replacing\n * it: a field that only changes colour on focus is invisible to anyone who\n * cannot distinguish those two colours.\n */\nexport const fieldClasses = cn(\n 'w-full rounded-md border border-line bg-transparent px-2.5 py-1.5',\n 'text-sm text-text placeholder:text-faint',\n 'transition-colors hover:border-line-2',\n 'focus-visible:outline-2 focus-visible:outline-offset-0 focus-visible:outline-accent',\n // A field nobody can type in should look like one.\n 'disabled:cursor-not-allowed disabled:opacity-50',\n // `aria-invalid` rather than a prop: the attribute is what a screen reader\n // reads, so making it the source of the colour keeps the two in step.\n 'aria-invalid:border-bad aria-invalid:focus-visible:outline-bad',\n)\n\nexport interface InputProps extends InputHTMLAttributes<HTMLInputElement> {\n /** React 19 passes `ref` as a plain prop; it is declared so callers can\n * reach the element to focus it or read its selection. */\n ref?: Ref<HTMLInputElement>\n}\n\nexport function Input({ className, ref, ...props }: InputProps) {\n return <input ref={ref} className={cn(fieldClasses, 'h-control', className)} {...props} />\n}\n"
|
|
1008
1008
|
}
|
|
1009
1009
|
]
|
|
1010
1010
|
},
|
|
@@ -1030,7 +1030,7 @@
|
|
|
1030
1030
|
"title": "Json-viewer",
|
|
1031
1031
|
"description": "What a product reaches for when it has to show a response, a settings file, a webhook payload - data the reader needs to understand, not edit. The alternative it replaces is `JSON.stringify(value, null, 2)` inside a `<pre>`, which is fine for twenty lines and useless for two hundred: nothing folds, nothing is findable, and the shape of the document is somewhere inside the indentation.",
|
|
1032
1032
|
"dependencies": [
|
|
1033
|
-
"dowel-ui@^0.
|
|
1033
|
+
"dowel-ui@^0.28.0"
|
|
1034
1034
|
],
|
|
1035
1035
|
"registryDependencies": [
|
|
1036
1036
|
"https://lacodda.github.io/dowel/r/json-rows.json"
|
|
@@ -1050,7 +1050,7 @@
|
|
|
1050
1050
|
"title": "Kbd",
|
|
1051
1051
|
"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.",
|
|
1052
1052
|
"dependencies": [
|
|
1053
|
-
"dowel-ui@^0.
|
|
1053
|
+
"dowel-ui@^0.28.0"
|
|
1054
1054
|
],
|
|
1055
1055
|
"registryDependencies": [],
|
|
1056
1056
|
"files": [
|
|
@@ -1069,7 +1069,7 @@
|
|
|
1069
1069
|
"description": "The shape every product builds out of two `<div>`s in a flex row, and the reason it is worth having once: it is a `<dl>`, and the pairing is what a screen reader announces. Two divs read as four unrelated pieces of text - \"Created\", \"2 hours ago\", \"Owner\", \"Ines\" - and nothing says which value belongs to which name. The right element says it for free.",
|
|
1070
1070
|
"dependencies": [
|
|
1071
1071
|
"class-variance-authority",
|
|
1072
|
-
"dowel-ui@^0.
|
|
1072
|
+
"dowel-ui@^0.28.0"
|
|
1073
1073
|
],
|
|
1074
1074
|
"registryDependencies": [],
|
|
1075
1075
|
"files": [
|
|
@@ -1088,7 +1088,7 @@
|
|
|
1088
1088
|
"description": "The distinction against its neighbours is the data's, not the drawing's. A column says each period is its own sum - hours worked in a week, and there is no Wednesday-afternoon figure between two weeks. A line says the value existed the whole time and was sampled: an account balance, a price, a temperature. Drawing a sum as a line claims readings nobody took; drawing a level as columns throws away the thing being watched.",
|
|
1089
1089
|
"dependencies": [
|
|
1090
1090
|
"class-variance-authority",
|
|
1091
|
-
"dowel-ui@^0.
|
|
1091
|
+
"dowel-ui@^0.28.0"
|
|
1092
1092
|
],
|
|
1093
1093
|
"registryDependencies": [
|
|
1094
1094
|
"https://lacodda.github.io/dowel/r/line-scale.json"
|
|
@@ -1124,7 +1124,7 @@
|
|
|
1124
1124
|
"title": "Marked-text",
|
|
1125
1125
|
"description": "A textarea cannot colour a word. The way round it is older than React: draw the same text twice, once as marked-up HTML underneath and once as the textarea on top with its own text transparent, so the caret and the selection are the browser's and the colours are ours. The two have to agree on every metric - font, size, line height, padding, wrapping - or the marks slide off the words they mark. So both take ONE class list, given by the caller, and the textarea adds only what makes it invisible.",
|
|
1126
1126
|
"dependencies": [
|
|
1127
|
-
"dowel-ui@^0.
|
|
1127
|
+
"dowel-ui@^0.28.0"
|
|
1128
1128
|
],
|
|
1129
1129
|
"registryDependencies": [],
|
|
1130
1130
|
"files": [
|
|
@@ -1144,7 +1144,7 @@
|
|
|
1144
1144
|
"dependencies": [
|
|
1145
1145
|
"@base-ui/react",
|
|
1146
1146
|
"class-variance-authority",
|
|
1147
|
-
"dowel-ui@^0.
|
|
1147
|
+
"dowel-ui@^0.28.0"
|
|
1148
1148
|
],
|
|
1149
1149
|
"registryDependencies": [],
|
|
1150
1150
|
"files": [
|
|
@@ -1162,7 +1162,7 @@
|
|
|
1162
1162
|
"title": "Notification-bell",
|
|
1163
1163
|
"description": "A bell that is always lit is a bell nobody reads, so the count is the product's decision and this draws it: nothing at zero, the number past that, `9+` past nine. Pressing it does not leave the screen - the last few entries open under it and the whole history is one more click, which is the shape every product converged on once the first one tried a page.",
|
|
1164
1164
|
"dependencies": [
|
|
1165
|
-
"dowel-ui@^0.
|
|
1165
|
+
"dowel-ui@^0.28.0"
|
|
1166
1166
|
],
|
|
1167
1167
|
"registryDependencies": [
|
|
1168
1168
|
"https://lacodda.github.io/dowel/r/button.json",
|
|
@@ -1184,7 +1184,7 @@
|
|
|
1184
1184
|
"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.",
|
|
1185
1185
|
"dependencies": [
|
|
1186
1186
|
"@base-ui/react",
|
|
1187
|
-
"dowel-ui@^0.
|
|
1187
|
+
"dowel-ui@^0.28.0"
|
|
1188
1188
|
],
|
|
1189
1189
|
"registryDependencies": [
|
|
1190
1190
|
"https://lacodda.github.io/dowel/r/input.json"
|
|
@@ -1194,7 +1194,7 @@
|
|
|
1194
1194
|
"path": "ui/number-field.tsx",
|
|
1195
1195
|
"target": "@ui/number-field.tsx",
|
|
1196
1196
|
"type": "registry:ui",
|
|
1197
|
-
"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-
|
|
1197
|
+
"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-control 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"
|
|
1198
1198
|
}
|
|
1199
1199
|
]
|
|
1200
1200
|
},
|
|
@@ -1204,7 +1204,7 @@
|
|
|
1204
1204
|
"title": "Number-format",
|
|
1205
1205
|
"description": "Two things, and the second is the reason this is a component rather than a call to `toLocaleString` at each site.",
|
|
1206
1206
|
"dependencies": [
|
|
1207
|
-
"dowel-ui@^0.
|
|
1207
|
+
"dowel-ui@^0.28.0"
|
|
1208
1208
|
],
|
|
1209
1209
|
"registryDependencies": [],
|
|
1210
1210
|
"files": [
|
|
@@ -1222,7 +1222,7 @@
|
|
|
1222
1222
|
"title": "Page-size",
|
|
1223
1223
|
"description": "Its own file rather than a part of `Pagination`, because the two are needed apart often enough: a list that scrolls for ever wants \"how many to load at a time\" and no page buttons, and a table with a fixed page size wants the buttons and no choice. Together they were also over the size gate, which asked the right question.",
|
|
1224
1224
|
"dependencies": [
|
|
1225
|
-
"dowel-ui@^0.
|
|
1225
|
+
"dowel-ui@^0.28.0"
|
|
1226
1226
|
],
|
|
1227
1227
|
"registryDependencies": [
|
|
1228
1228
|
"https://lacodda.github.io/dowel/r/select.json"
|
|
@@ -1242,7 +1242,7 @@
|
|
|
1242
1242
|
"title": "Pagination",
|
|
1243
1243
|
"description": "The arithmetic is exported separately from the component for the same reason `table-sort` is a file of its own: a product that pages on the server needs the page numbers and not the buttons, and computing them a second time in a different place is how the two disagree about where the last page ends.",
|
|
1244
1244
|
"dependencies": [
|
|
1245
|
-
"dowel-ui@^0.
|
|
1245
|
+
"dowel-ui@^0.28.0"
|
|
1246
1246
|
],
|
|
1247
1247
|
"registryDependencies": [
|
|
1248
1248
|
"https://lacodda.github.io/dowel/r/button.json"
|
|
@@ -1263,7 +1263,7 @@
|
|
|
1263
1263
|
"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.",
|
|
1264
1264
|
"dependencies": [
|
|
1265
1265
|
"class-variance-authority",
|
|
1266
|
-
"dowel-ui@^0.
|
|
1266
|
+
"dowel-ui@^0.28.0"
|
|
1267
1267
|
],
|
|
1268
1268
|
"registryDependencies": [],
|
|
1269
1269
|
"files": [
|
|
@@ -1281,7 +1281,7 @@
|
|
|
1281
1281
|
"title": "Password-field",
|
|
1282
1282
|
"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.",
|
|
1283
1283
|
"dependencies": [
|
|
1284
|
-
"dowel-ui@^0.
|
|
1284
|
+
"dowel-ui@^0.28.0"
|
|
1285
1285
|
],
|
|
1286
1286
|
"registryDependencies": [
|
|
1287
1287
|
"https://lacodda.github.io/dowel/r/input.json"
|
|
@@ -1291,7 +1291,7 @@
|
|
|
1291
1291
|
"path": "ui/password-field.tsx",
|
|
1292
1292
|
"target": "@ui/password-field.tsx",
|
|
1293
1293
|
"type": "registry:ui",
|
|
1294
|
-
"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-
|
|
1294
|
+
"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-control 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"
|
|
1295
1295
|
}
|
|
1296
1296
|
]
|
|
1297
1297
|
},
|
|
@@ -1303,7 +1303,7 @@
|
|
|
1303
1303
|
"dependencies": [
|
|
1304
1304
|
"@base-ui/react",
|
|
1305
1305
|
"class-variance-authority",
|
|
1306
|
-
"dowel-ui@^0.
|
|
1306
|
+
"dowel-ui@^0.28.0"
|
|
1307
1307
|
],
|
|
1308
1308
|
"registryDependencies": [],
|
|
1309
1309
|
"files": [
|
|
@@ -1323,7 +1323,7 @@
|
|
|
1323
1323
|
"dependencies": [
|
|
1324
1324
|
"@base-ui/react",
|
|
1325
1325
|
"class-variance-authority",
|
|
1326
|
-
"dowel-ui@^0.
|
|
1326
|
+
"dowel-ui@^0.28.0"
|
|
1327
1327
|
],
|
|
1328
1328
|
"registryDependencies": [],
|
|
1329
1329
|
"files": [
|
|
@@ -1343,7 +1343,7 @@
|
|
|
1343
1343
|
"dependencies": [
|
|
1344
1344
|
"@base-ui/react",
|
|
1345
1345
|
"class-variance-authority",
|
|
1346
|
-
"dowel-ui@^0.
|
|
1346
|
+
"dowel-ui@^0.28.0"
|
|
1347
1347
|
],
|
|
1348
1348
|
"registryDependencies": [],
|
|
1349
1349
|
"files": [
|
|
@@ -1382,7 +1382,7 @@
|
|
|
1382
1382
|
"dependencies": [
|
|
1383
1383
|
"@base-ui/react",
|
|
1384
1384
|
"class-variance-authority",
|
|
1385
|
-
"dowel-ui@^0.
|
|
1385
|
+
"dowel-ui@^0.28.0"
|
|
1386
1386
|
],
|
|
1387
1387
|
"registryDependencies": [],
|
|
1388
1388
|
"files": [
|
|
@@ -1400,7 +1400,7 @@
|
|
|
1400
1400
|
"title": "Rating-scale",
|
|
1401
1401
|
"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\".",
|
|
1402
1402
|
"dependencies": [
|
|
1403
|
-
"dowel-ui@^0.
|
|
1403
|
+
"dowel-ui@^0.28.0"
|
|
1404
1404
|
],
|
|
1405
1405
|
"registryDependencies": [],
|
|
1406
1406
|
"files": [
|
|
@@ -1408,7 +1408,7 @@
|
|
|
1408
1408
|
"path": "ui/rating-scale.tsx",
|
|
1409
1409
|
"target": "@ui/rating-scale.tsx",
|
|
1410
1410
|
"type": "registry:ui",
|
|
1411
|
-
"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-
|
|
1411
|
+
"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-hair 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-5 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"
|
|
1412
1412
|
}
|
|
1413
1413
|
]
|
|
1414
1414
|
},
|
|
@@ -1418,7 +1418,7 @@
|
|
|
1418
1418
|
"title": "Relative-time",
|
|
1419
1419
|
"description": "The relative-time primitive.",
|
|
1420
1420
|
"dependencies": [
|
|
1421
|
-
"dowel-ui@^0.
|
|
1421
|
+
"dowel-ui@^0.28.0"
|
|
1422
1422
|
],
|
|
1423
1423
|
"registryDependencies": [],
|
|
1424
1424
|
"files": [
|
|
@@ -1436,7 +1436,7 @@
|
|
|
1436
1436
|
"title": "Reorderable-list",
|
|
1437
1437
|
"description": "The columns in a column picker, the stops of a dial, the roles of a profile. A hook and a grip rather than a list component: the rows are already something else's - a menu's items, a form's fields - and a component wrapping them would have to reproduce whatever that something else does.",
|
|
1438
1438
|
"dependencies": [
|
|
1439
|
-
"dowel-ui@^0.
|
|
1439
|
+
"dowel-ui@^0.28.0"
|
|
1440
1440
|
],
|
|
1441
1441
|
"registryDependencies": [],
|
|
1442
1442
|
"files": [
|
|
@@ -1454,7 +1454,7 @@
|
|
|
1454
1454
|
"title": "Save-state",
|
|
1455
1455
|
"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.",
|
|
1456
1456
|
"dependencies": [
|
|
1457
|
-
"dowel-ui@^0.
|
|
1457
|
+
"dowel-ui@^0.28.0"
|
|
1458
1458
|
],
|
|
1459
1459
|
"registryDependencies": [
|
|
1460
1460
|
"https://lacodda.github.io/dowel/r/spinner.json"
|
|
@@ -1474,7 +1474,7 @@
|
|
|
1474
1474
|
"title": "Search-field",
|
|
1475
1475
|
"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.",
|
|
1476
1476
|
"dependencies": [
|
|
1477
|
-
"dowel-ui@^0.
|
|
1477
|
+
"dowel-ui@^0.28.0"
|
|
1478
1478
|
],
|
|
1479
1479
|
"registryDependencies": [
|
|
1480
1480
|
"https://lacodda.github.io/dowel/r/input.json",
|
|
@@ -1486,7 +1486,7 @@
|
|
|
1486
1486
|
"path": "ui/search-field.tsx",
|
|
1487
1487
|
"target": "@ui/search-field.tsx",
|
|
1488
1488
|
"type": "registry:ui",
|
|
1489
|
-
"content": "import { useCallback, useRef, type InputHTMLAttributes, type Ref } from 'react'\nimport { cn } from 'dowel-ui'\nimport { fieldClasses } from './input'\nimport { Kbd } from './kbd'\nimport { useShortcut } from './shortcut'\n\n/*\n * SearchField.\n *\n * An Input that knows it is a search box, which is three small things the\n * products kept not doing:\n *\n * - a magnifier, so the field is recognisable before it is read;\n * - a way to clear it that is not \"select all and delete\" - and one that a\n * keyboard can reach, which a decorative `<span>` cannot;\n * - the shortcut that focuses it, shown in the field rather than learned.\n *\n * `type=\"search\"` is deliberate: it is what tells a browser to offer previous\n * queries, and what makes Escape clear the field on the platforms where that\n * is the convention. The browser's own clear button is hidden, because it is\n * drawn in the operating system's chrome and cannot be made to match - the\n * same reason the line does not use a native `<select>`.\n */\n\nexport interface SearchFieldProps\n extends Omit<InputHTMLAttributes<HTMLInputElement>, 'type' | 'value' | 'onChange'> {\n /** The query. Controlled, because a search box that owns its own text\n * cannot be cleared by the thing that owns the results. */\n value: string\n /** Told the new query on every keystroke. */\n onValueChange: (value: string) => void\n /**\n * What the clear button is called, for a screen reader. No default: a word\n * the component invents is a word the product cannot translate.\n *\n * Leave it out and no clear button is drawn - which is the right shape for a\n * field that filters as you type and is cleared by other means.\n */\n clearLabel?: string\n /**\n * The shortcut that focuses the field, as `['Mod', 'K']`. Shown at the right\n * of the field, and bound: pressing it focuses and selects, from anywhere\n * that is not already a field.\n */\n shortcut?: string[]\n ref?: Ref<HTMLInputElement>\n}\n\nexport function SearchField({\n value,\n onValueChange,\n clearLabel,\n shortcut,\n className,\n ref,\n ...props\n}: SearchFieldProps) {\n const own = useRef<HTMLInputElement>(null)\n\n const setRefs = useCallback(\n (element: HTMLInputElement | null) => {\n own.current = element\n if (typeof ref === 'function') ref(element)\n else if (ref) ref.current = element\n },\n [ref],\n )\n\n // Focus and select, so the shortcut replaces a stale query rather than\n // appending to it. Not while someone is typing elsewhere - that is\n // `useShortcut`'s default, and it is the half of this people forget.\n const focusAndSelect = useCallback(() => {\n own.current?.focus()\n own.current?.select()\n }, [])\n useShortcut(shortcut ?? [], focusAndSelect, { enabled: shortcut !== undefined })\n\n const showClear = clearLabel !== undefined && value !== ''\n\n return (\n <div className={cn('relative', className)}>\n <MagnifierIcon />\n\n <input\n ref={setRefs}\n type=\"search\"\n value={value}\n onChange={(event) => onValueChange(event.target.value)}\n className={cn(\n fieldClasses,\n 'h-
|
|
1489
|
+
"content": "import { useCallback, useRef, type InputHTMLAttributes, type Ref } from 'react'\nimport { cn } from 'dowel-ui'\nimport { fieldClasses } from './input'\nimport { Kbd } from './kbd'\nimport { useShortcut } from './shortcut'\n\n/*\n * SearchField.\n *\n * An Input that knows it is a search box, which is three small things the\n * products kept not doing:\n *\n * - a magnifier, so the field is recognisable before it is read;\n * - a way to clear it that is not \"select all and delete\" - and one that a\n * keyboard can reach, which a decorative `<span>` cannot;\n * - the shortcut that focuses it, shown in the field rather than learned.\n *\n * `type=\"search\"` is deliberate: it is what tells a browser to offer previous\n * queries, and what makes Escape clear the field on the platforms where that\n * is the convention. The browser's own clear button is hidden, because it is\n * drawn in the operating system's chrome and cannot be made to match - the\n * same reason the line does not use a native `<select>`.\n */\n\nexport interface SearchFieldProps\n extends Omit<InputHTMLAttributes<HTMLInputElement>, 'type' | 'value' | 'onChange'> {\n /** The query. Controlled, because a search box that owns its own text\n * cannot be cleared by the thing that owns the results. */\n value: string\n /** Told the new query on every keystroke. */\n onValueChange: (value: string) => void\n /**\n * What the clear button is called, for a screen reader. No default: a word\n * the component invents is a word the product cannot translate.\n *\n * Leave it out and no clear button is drawn - which is the right shape for a\n * field that filters as you type and is cleared by other means.\n */\n clearLabel?: string\n /**\n * The shortcut that focuses the field, as `['Mod', 'K']`. Shown at the right\n * of the field, and bound: pressing it focuses and selects, from anywhere\n * that is not already a field.\n */\n shortcut?: string[]\n ref?: Ref<HTMLInputElement>\n}\n\nexport function SearchField({\n value,\n onValueChange,\n clearLabel,\n shortcut,\n className,\n ref,\n ...props\n}: SearchFieldProps) {\n const own = useRef<HTMLInputElement>(null)\n\n const setRefs = useCallback(\n (element: HTMLInputElement | null) => {\n own.current = element\n if (typeof ref === 'function') ref(element)\n else if (ref) ref.current = element\n },\n [ref],\n )\n\n // Focus and select, so the shortcut replaces a stale query rather than\n // appending to it. Not while someone is typing elsewhere - that is\n // `useShortcut`'s default, and it is the half of this people forget.\n const focusAndSelect = useCallback(() => {\n own.current?.focus()\n own.current?.select()\n }, [])\n useShortcut(shortcut ?? [], focusAndSelect, { enabled: shortcut !== undefined })\n\n const showClear = clearLabel !== undefined && value !== ''\n\n return (\n <div className={cn('relative', className)}>\n <MagnifierIcon />\n\n <input\n ref={setRefs}\n type=\"search\"\n value={value}\n onChange={(event) => onValueChange(event.target.value)}\n className={cn(\n fieldClasses,\n 'h-control pl-8',\n // Room on the right for whatever sits there, and none when nothing\n // does - a field with a permanent gap looks broken.\n showClear && 'pr-8',\n !showClear && shortcut && 'pr-14',\n // The browser's own clear affordance, in the operating system's\n // chrome. Ours is below.\n '[&::-webkit-search-cancel-button]:appearance-none',\n )}\n {...props}\n />\n\n {showClear && (\n <button\n type=\"button\"\n aria-label={clearLabel}\n onClick={() => {\n onValueChange('')\n own.current?.focus()\n }}\n className={cn(\n 'absolute right-1.5 top-1/2 grid size-6 -translate-y-1/2 place-items-center',\n 'rounded-sm text-faint transition-colors hover:bg-soft hover:text-text',\n 'focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-accent',\n )}\n >\n <CrossIcon />\n </button>\n )}\n\n {!showClear && shortcut && (\n // Decorative: the shortcut works whether or not it is read out, and a\n // screen reader announcing \"Control K\" inside a search box is noise.\n <Kbd\n keys={shortcut}\n aria-hidden\n className=\"pointer-events-none absolute right-2 top-1/2 -translate-y-1/2\"\n />\n )}\n </div>\n )\n}\n\nfunction MagnifierIcon() {\n return (\n <svg\n viewBox=\"0 0 16 16\"\n width=\"14\"\n height=\"14\"\n fill=\"none\"\n stroke=\"currentColor\"\n strokeWidth=\"1.6\"\n aria-hidden\n className=\"pointer-events-none absolute left-2.5 top-1/2 -translate-y-1/2 text-faint\"\n >\n <circle cx=\"7\" cy=\"7\" r=\"4.5\" />\n <path d=\"M10.5 10.5L14 14\" strokeLinecap=\"round\" />\n </svg>\n )\n}\n\nfunction CrossIcon() {\n return (\n <svg viewBox=\"0 0 16 16\" width=\"10\" height=\"10\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\" aria-hidden>\n <path d=\"M4 4l8 8M12 4l-8 8\" strokeLinecap=\"round\" />\n </svg>\n )\n}\n"
|
|
1490
1490
|
}
|
|
1491
1491
|
]
|
|
1492
1492
|
},
|
|
@@ -1497,7 +1497,7 @@
|
|
|
1497
1497
|
"description": "A settings screen is the usual case: five or six sections, each its own address so it can be linked to and the back button walks between them, listed down the left with the current one tinted. Every product draws the same column, and every product draws the active row a little differently - which is exactly the drift a shared list exists to stop.",
|
|
1498
1498
|
"dependencies": [
|
|
1499
1499
|
"@base-ui/react",
|
|
1500
|
-
"dowel-ui@^0.
|
|
1500
|
+
"dowel-ui@^0.28.0"
|
|
1501
1501
|
],
|
|
1502
1502
|
"registryDependencies": [],
|
|
1503
1503
|
"files": [
|
|
@@ -1517,7 +1517,7 @@
|
|
|
1517
1517
|
"dependencies": [
|
|
1518
1518
|
"@base-ui/react",
|
|
1519
1519
|
"class-variance-authority",
|
|
1520
|
-
"dowel-ui@^0.
|
|
1520
|
+
"dowel-ui@^0.28.0"
|
|
1521
1521
|
],
|
|
1522
1522
|
"registryDependencies": [
|
|
1523
1523
|
"https://lacodda.github.io/dowel/r/input.json"
|
|
@@ -1527,7 +1527,7 @@
|
|
|
1527
1527
|
"path": "ui/select.tsx",
|
|
1528
1528
|
"target": "@ui/select.tsx",
|
|
1529
1529
|
"type": "registry:ui",
|
|
1530
|
-
"content": "import { Select as Base } from '@base-ui/react/select'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { cn } from 'dowel-ui'\nimport { fieldClasses } from './input'\n\n/*\n * Select.\n *\n * The component the oldest rule in the line is about. A native `<select>`\n * cannot be dressed: the browser draws its popup itself, in the operating\n * system's chrome, and no CSS reaches inside. One native dropdown on a screen\n * of the product's own controls reads as a foreign object, and on Windows it\n * reads as a foreign object from 1998.\n *\n * So this renders `<button role=\"combobox\">` and a portalled list of\n * `role=\"option\"` - zero native elements, which is asserted in the test,\n * because it is the entire reason the component exists.\n *\n * What that costs is everything the browser was doing for free: the keyboard,\n * type-ahead, the announcement of the selected value, the scroll into view,\n * and on a phone the whole native picker. Base UI does all of it, which is\n * the only reason this trade is worth making - a hand-rolled dropdown is how\n * a product ships a control that a screen reader cannot see.\n *\n * The trigger wears Input's `fieldClasses`, imported rather than copied. A\n * select and a text field sit next to each other in every form there has ever\n * been, and two class lists that started the same drift within a release.\n *\n * `multiple` is a prop on the Root: it changes what `value` means - an array\n * rather than a single value - so it belongs where the value lives and not on\n * the trigger.\n */\n\nexport const selectTriggerVariants = cva([fieldClasses, 'flex items-center justify-between gap-2'], {\n variants: {\n size: {\n sm: 'h-
|
|
1530
|
+
"content": "import { Select as Base } from '@base-ui/react/select'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { cn } from 'dowel-ui'\nimport { fieldClasses } from './input'\n\n/*\n * Select.\n *\n * The component the oldest rule in the line is about. A native `<select>`\n * cannot be dressed: the browser draws its popup itself, in the operating\n * system's chrome, and no CSS reaches inside. One native dropdown on a screen\n * of the product's own controls reads as a foreign object, and on Windows it\n * reads as a foreign object from 1998.\n *\n * So this renders `<button role=\"combobox\">` and a portalled list of\n * `role=\"option\"` - zero native elements, which is asserted in the test,\n * because it is the entire reason the component exists.\n *\n * What that costs is everything the browser was doing for free: the keyboard,\n * type-ahead, the announcement of the selected value, the scroll into view,\n * and on a phone the whole native picker. Base UI does all of it, which is\n * the only reason this trade is worth making - a hand-rolled dropdown is how\n * a product ships a control that a screen reader cannot see.\n *\n * The trigger wears Input's `fieldClasses`, imported rather than copied. A\n * select and a text field sit next to each other in every form there has ever\n * been, and two class lists that started the same drift within a release.\n *\n * `multiple` is a prop on the Root: it changes what `value` means - an array\n * rather than a single value - so it belongs where the value lives and not on\n * the trigger.\n */\n\nexport const selectTriggerVariants = cva([fieldClasses, 'flex items-center justify-between gap-2'], {\n variants: {\n size: {\n sm: 'h-control-sm text-xs',\n md: 'h-control',\n lg: 'h-control-lg text-base',\n },\n },\n defaultVariants: { size: 'md' },\n})\n\nexport const selectPopupVariants = cva(\n [\n 'max-h-[min(24rem,var(--available-height))] overflow-y-auto',\n 'rounded-md border border-line bg-raise p-1 text-text shadow-raise',\n 'focus-visible:outline-none',\n '[transition:opacity_var(--duration-quick)_var(--ease-out),transform_var(--duration-quick)_var(--ease-out)]',\n 'data-[closed]:scale-[0.98] data-[closed]:opacity-0',\n 'data-[starting-style]:scale-[0.98] data-[starting-style]:opacity-0',\n ],\n {\n variants: {\n size: {\n // The popup matches the trigger's width by default, which is what a\n // dropdown should do; the sizes are a floor for a narrow one.\n sm: 'min-w-[max(8rem,var(--anchor-width))]',\n md: 'min-w-[max(10rem,var(--anchor-width))]',\n lg: 'min-w-[max(14rem,var(--anchor-width))]',\n },\n },\n defaultVariants: { size: 'md' },\n },\n)\n\n/** One option. */\nexport const selectItemVariants = cva([\n 'relative flex cursor-pointer select-none items-center gap-2 rounded-sm py-1.5 pl-2 pr-7 text-sm',\n 'outline-none transition-colors',\n // Base UI marks the item under the pointer or the keyboard the same way,\n // so one rule covers both and they cannot disagree.\n 'data-[highlighted]:bg-soft data-[highlighted]:text-text',\n // What is already chosen has to be visible in the list, and colour alone\n // will not do it: `text-text` on an item that is already `text-text` says\n // nothing. The tick below is the state; this is the emphasis that goes with\n // it, so the row reads as chosen at a glance and not only under the eye.\n 'data-[selected]:font-medium data-[selected]:text-accent',\n 'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',\n '[&_svg]:size-3.5 [&_svg]:shrink-0',\n])\n\n/** The root. `multiple` turns `value` into an array; otherwise controlled with\n * `value` and `onValueChange`, or left to manage itself. */\nexport const Select = Base.Root\n\n/** What the trigger shows: the selected item's label, and the `placeholder`\n * the product gives it until there is one.\n *\n * Two traps, both Base UI's and both quiet. Its `children` is a *function* of\n * the value, not a node - passing a node pins the trigger to that node\n * forever and the selection never appears, so the placeholder goes in\n * `placeholder`. And what it shows is the raw value, `plum` rather than\n * `Plum`, unless the root is given an `items` map to look the label up in.\n *\n * It truncates, and that matters most for `multiple`: nine chosen fruits are\n * one long string, and without this the trigger either grows into a paragraph\n * or spills its text past its own border. One line, an ellipsis, and the full\n * set is still in the list where the ticks are. */\nexport function SelectValue({ className, ...props }: Base.Value.Props) {\n return <Base.Value className={cn('min-w-0 flex-1 truncate text-left', className)} {...props} />\n}\n\n/** The chevron, or whatever the product puts there. Marked decorative by Base\n * UI, since the button is already named by its value. */\nexport const SelectIcon = Base.Icon\n\n/** A labelled group of options. */\nexport const SelectGroup = Base.Group\n\n/** The text of an option, which is what the trigger echoes when it is chosen. */\nexport const SelectItemText = Base.ItemText\n\n/** The tick, drawn only on the chosen option. */\nexport const SelectItemIndicator = Base.ItemIndicator\n\nexport interface SelectTriggerProps\n extends Base.Trigger.Props,\n VariantProps<typeof selectTriggerVariants> {}\n\n/** The control. A `<button role=\"combobox\">` - never a `<select>`. */\nexport function SelectTrigger({ size, className, ...props }: SelectTriggerProps) {\n return <Base.Trigger className={cn(selectTriggerVariants({ size }), className)} {...props} />\n}\n\nexport interface SelectPopupProps\n extends Base.Popup.Props,\n VariantProps<typeof selectPopupVariants> {\n /** Preferred side of the trigger. Base UI flips it when it does not fit. */\n side?: Base.Positioner.Props['side']\n /** Alignment along that side. */\n align?: Base.Positioner.Props['align']\n /** Distance from the trigger, in pixels. */\n sideOffset?: Base.Positioner.Props['sideOffset']\n /** Where to portal to. Defaults to the document body, which keeps the list\n * from being clipped by a form with `overflow: hidden`. */\n container?: Base.Portal.Props['container']\n}\n\n/** The list. Portalled and positioned against the trigger.\n *\n * `alignItemWithTrigger` is off: Base UI's default lifts the popup so the\n * selected option sits over the button, which is the native macOS behaviour\n * and is disorienting in a web form - the list jumps to a different place\n * depending on what is already chosen. */\nexport function SelectPopup({\n size,\n side,\n align,\n sideOffset = 4,\n container,\n className,\n children,\n ...props\n}: SelectPopupProps) {\n return (\n <Base.Portal container={container}>\n <Base.Positioner\n side={side}\n align={align}\n sideOffset={sideOffset}\n alignItemWithTrigger={false}\n className=\"[z-index:var(--z-menu)]\"\n >\n <Base.Popup className={cn(selectPopupVariants({ size }), className)} {...props}>\n <Base.List>{children}</Base.List>\n </Base.Popup>\n </Base.Positioner>\n </Base.Portal>\n )\n}\n\n/** An option, with the tick that says it is the chosen one.\n *\n * The indicator is built in rather than left to the caller. The item already\n * reserves the room for it (`pr-7`), and a dropdown that does not show what is\n * currently selected is the commonest complaint about a styled select: it\n * opens, and the reader has to remember what they picked last time. Passing\n * `indicator={false}` turns it off for a list where the choice is obvious\n * some other way. */\nexport function SelectItem({\n indicator = true,\n className,\n children,\n ...props\n}: Base.Item.Props & { indicator?: boolean }) {\n return (\n <Base.Item className={cn(selectItemVariants(), className)} {...props}>\n {children}\n {indicator && (\n <Base.ItemIndicator className=\"absolute right-2 flex text-accent\">\n <svg viewBox=\"0 0 16 16\" className=\"size-3.5\" aria-hidden>\n <path\n d=\"M3.5 8.5l3 3 6-6.5\"\n fill=\"none\"\n stroke=\"currentColor\"\n strokeWidth=\"2\"\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n />\n </svg>\n </Base.ItemIndicator>\n )}\n </Base.Item>\n )\n}\n\n/** The caption above a group. */\nexport function SelectGroupLabel({ className, ...props }: Base.GroupLabel.Props) {\n return (\n <Base.GroupLabel\n className={cn('px-2 py-1.5 text-2xs uppercase tracking-caption text-faint', className)}\n {...props}\n />\n )\n}\n\n/** A line between groups of options. */\nexport function SelectSeparator({ className, ...props }: Base.Separator.Props) {\n return <Base.Separator className={cn('-mx-1 my-1 h-px bg-line', className)} {...props} />\n}\n"
|
|
1531
1531
|
}
|
|
1532
1532
|
]
|
|
1533
1533
|
},
|
|
@@ -1553,7 +1553,7 @@
|
|
|
1553
1553
|
"title": "Skeleton-of",
|
|
1554
1554
|
"description": "`Skeleton` and its shapes solved half the problem: they gave a product a list, a card and a grid to reach for instead of a spinner. The half left over is the one that actually causes the jump, and it is a human one - somebody has to look at the real thing, judge how many rows it has and how tall they are, and type that in. The judgement is made once, the screen changes a month later, and the placeholder goes on promising the old shape.",
|
|
1555
1555
|
"dependencies": [
|
|
1556
|
-
"dowel-ui@^0.
|
|
1556
|
+
"dowel-ui@^0.28.0"
|
|
1557
1557
|
],
|
|
1558
1558
|
"registryDependencies": [],
|
|
1559
1559
|
"files": [
|
|
@@ -1571,7 +1571,7 @@
|
|
|
1571
1571
|
"title": "Skeleton",
|
|
1572
1572
|
"description": "The rule the component is built on, and the reason it takes a shape rather than filling the space:\n * **A skeleton of the wrong shape is worse than no skeleton.**\n * It promises something the content does not keep, and the promise is paid for in a jump: the page settles, the scrollbar appears, and whatever the reader was about to click has moved. Measured rather than assumed - the line's own calendar showed a list of four short lines where a six-row month grid was about to land, and the skeleton was itself the jump it existed to prevent.",
|
|
1573
1573
|
"dependencies": [
|
|
1574
|
-
"dowel-ui@^0.
|
|
1574
|
+
"dowel-ui@^0.28.0"
|
|
1575
1575
|
],
|
|
1576
1576
|
"registryDependencies": [],
|
|
1577
1577
|
"files": [
|
|
@@ -1590,7 +1590,7 @@
|
|
|
1590
1590
|
"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\".",
|
|
1591
1591
|
"dependencies": [
|
|
1592
1592
|
"@base-ui/react",
|
|
1593
|
-
"dowel-ui@^0.
|
|
1593
|
+
"dowel-ui@^0.28.0"
|
|
1594
1594
|
],
|
|
1595
1595
|
"registryDependencies": [],
|
|
1596
1596
|
"files": [
|
|
@@ -1609,7 +1609,7 @@
|
|
|
1609
1609
|
"description": "The shape of a history, not a chart of it: no axes, no gridlines, no ticks.",
|
|
1610
1610
|
"dependencies": [
|
|
1611
1611
|
"class-variance-authority",
|
|
1612
|
-
"dowel-ui@^0.
|
|
1612
|
+
"dowel-ui@^0.28.0"
|
|
1613
1613
|
],
|
|
1614
1614
|
"registryDependencies": [],
|
|
1615
1615
|
"files": [
|
|
@@ -1628,7 +1628,7 @@
|
|
|
1628
1628
|
"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.",
|
|
1629
1629
|
"dependencies": [
|
|
1630
1630
|
"class-variance-authority",
|
|
1631
|
-
"dowel-ui@^0.
|
|
1631
|
+
"dowel-ui@^0.28.0"
|
|
1632
1632
|
],
|
|
1633
1633
|
"registryDependencies": [],
|
|
1634
1634
|
"files": [
|
|
@@ -1646,7 +1646,7 @@
|
|
|
1646
1646
|
"title": "Splash",
|
|
1647
1647
|
"description": "A desktop product has a second or two between the window appearing and the first screen being ready - a workspace to open, a database to migrate, a plugin to start - and a blank window for that long reads as a crash. So the window shows the product instead: the mark, the name, the promise, the version, and a bar that sweeps until there is something to draw.",
|
|
1648
1648
|
"dependencies": [
|
|
1649
|
-
"dowel-ui@^0.
|
|
1649
|
+
"dowel-ui@^0.28.0"
|
|
1650
1650
|
],
|
|
1651
1651
|
"registryDependencies": [],
|
|
1652
1652
|
"files": [
|
|
@@ -1665,7 +1665,7 @@
|
|
|
1665
1665
|
"description": "The smallest thing on a dashboard and the one every product writes itself: a label above, a number below, sometimes a word about which way it moved.",
|
|
1666
1666
|
"dependencies": [
|
|
1667
1667
|
"class-variance-authority",
|
|
1668
|
-
"dowel-ui@^0.
|
|
1668
|
+
"dowel-ui@^0.28.0"
|
|
1669
1669
|
],
|
|
1670
1670
|
"registryDependencies": [],
|
|
1671
1671
|
"files": [
|
|
@@ -1673,7 +1673,7 @@
|
|
|
1673
1673
|
"path": "ui/stat-tile.tsx",
|
|
1674
1674
|
"target": "@ui/stat-tile.tsx",
|
|
1675
1675
|
"type": "registry:ui",
|
|
1676
|
-
"content": "import type { HTMLAttributes, ReactNode } from 'react'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { cn } from 'dowel-ui'\n\n/*\n * One figure, and what it is a figure of.\n *\n * The smallest thing on a dashboard and the one every product writes itself:\n * a label above, a number below, sometimes a word about which way it moved.\n * It is here because the first consumer had written it twice - once on the\n * personal page, once on the team one - and the copies had already drifted:\n * one had grown a warning tone the other lacked, and the tone classes in it\n * were concatenated without a space, so a figure that was both accented and\n * warning would have emitted `text-accent-2text-warn` and been styled by\n * neither. Nothing had gone wrong on screen yet; the two flags were simply\n * never passed together.\n *\n * A `<dl>` rather than two divs, for the reason KeyValue is one: the pairing\n * is what a screen reader announces. Loose divs read as two unrelated pieces\n * of text and nothing says the number belongs to the label.\n *\n * Numbers are set in the mono face with tabular figures, so a column of tiles\n * lines up and a value that ticks does not shuffle its neighbours sideways.\n * That matters more than it sounds: a live figure redrawn every few seconds in\n * proportional digits makes the whole row twitch.\n *\n * The delta is a second, quieter line rather than a colour on the value. A\n * number that turns red is a number whose colour has to be explained, and the\n * explanation is never on the screen; a delta says \"+12% vs last week\" and\n * needs nothing. Its tone is stated by the caller rather than inferred from\n * the sign, because down is good for a figure like \"time to first response\",\n * and a component cannot know which figure it is holding.\n */\n\nexport const statTileVariants = cva('min-w-0', {\n variants: {\n size: {\n /* The dashboard default: a row of these under a heading. */\n md: '',\n /* For a tile that leads a page rather than sitting in a row of six. */\n lg: '',\n },\n },\n defaultVariants: { size: 'md' },\n})\n\nexport const statTileValueVariants = cva('mt-1 font-mono tabular-nums', {\n variants: {\n size: {\n md: 'text-lg',\n lg: 'text-2xl',\n },\n tone: {\n /* The reading tone: what this figure is, not how it is doing. `accent`\n * marks the one figure a panel is really about; `warn` and `bad` are for\n * a figure that is itself a problem - people with no agent reporting,\n * a queue that is backing up. */\n default: 'text-text',\n accent: 'text-accent-2',\n warn: 'text-warn',\n bad: 'text-bad',\n },\n },\n defaultVariants: { size: 'md', tone: 'default' },\n})\n\nexport const statTileDeltaVariants = cva('mt-1 text-xs', {\n variants: {\n tone: {\n /* Neutral by default, because most movement is just movement. */\n default: 'text-dim',\n good: 'text-good',\n bad: 'text-bad',\n },\n },\n defaultVariants: { tone: 'default' },\n})\n\
|
|
1676
|
+
"content": "import type { HTMLAttributes, ReactNode } from 'react'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { cn } from 'dowel-ui'\n\n/*\n * One figure, and what it is a figure of.\n *\n * The smallest thing on a dashboard and the one every product writes itself:\n * a label above, a number below, sometimes a word about which way it moved.\n * It is here because the first consumer had written it twice - once on the\n * personal page, once on the team one - and the copies had already drifted:\n * one had grown a warning tone the other lacked, and the tone classes in it\n * were concatenated without a space, so a figure that was both accented and\n * warning would have emitted `text-accent-2text-warn` and been styled by\n * neither. Nothing had gone wrong on screen yet; the two flags were simply\n * never passed together.\n *\n * A `<dl>` rather than two divs, for the reason KeyValue is one: the pairing\n * is what a screen reader announces. Loose divs read as two unrelated pieces\n * of text and nothing says the number belongs to the label.\n *\n * Numbers are set in the mono face with tabular figures, so a column of tiles\n * lines up and a value that ticks does not shuffle its neighbours sideways.\n * That matters more than it sounds: a live figure redrawn every few seconds in\n * proportional digits makes the whole row twitch.\n *\n * The delta is a second, quieter line rather than a colour on the value. A\n * number that turns red is a number whose colour has to be explained, and the\n * explanation is never on the screen; a delta says \"+12% vs last week\" and\n * needs nothing. Its tone is stated by the caller rather than inferred from\n * the sign, because down is good for a figure like \"time to first response\",\n * and a component cannot know which figure it is holding.\n */\n\nexport const statTileVariants = cva('min-w-0', {\n variants: {\n size: {\n /* The dashboard default: a row of these under a heading. */\n md: '',\n /* For a tile that leads a page rather than sitting in a row of six. */\n lg: '',\n },\n },\n defaultVariants: { size: 'md' },\n})\n\nexport const statTileValueVariants = cva('mt-1 font-mono tabular-nums', {\n variants: {\n size: {\n md: 'text-lg',\n lg: 'text-2xl',\n },\n tone: {\n /* The reading tone: what this figure is, not how it is doing. `accent`\n * marks the one figure a panel is really about; `warn` and `bad` are for\n * a figure that is itself a problem - people with no agent reporting,\n * a queue that is backing up. */\n default: 'text-text',\n accent: 'text-accent-2',\n warn: 'text-warn',\n bad: 'text-bad',\n },\n },\n defaultVariants: { size: 'md', tone: 'default' },\n})\n\nexport const statTileDeltaVariants = cva('mt-1 text-xs', {\n variants: {\n tone: {\n /* Neutral by default, because most movement is just movement. */\n default: 'text-dim',\n good: 'text-good',\n bad: 'text-bad',\n },\n },\n defaultVariants: { tone: 'default' },\n})\n\n/*\n * A tone that is a judgement has to bring a mark with it.\n *\n * `warn` and `bad` say this figure is a problem, and on a dashboard that claim\n * is carried by colour alone - which is exactly where it fails. The line's own\n * accents sit close to the status hues: measured in OKLab, the first\n * consumer's gold is Delta-E 3.7 from `--warn`, hilvan's is 2.5 from `--bad`,\n * and four more products are inside 8.3. One tile accented and another warning\n * are, to a reader glancing down a row, the same colour.\n *\n * Neither side can move. The status hues are the line's shared language and\n * the accents come from the brand registry, so the fix is the rule the theme\n * already states in prose: meaning never rests on colour alone. Here it is\n * structure instead - the type will not let a judgement be drawn without a\n * mark beside it.\n *\n * The first consumer had already worked this out by hand, wrapping its value\n * in a flex row with a warning triangle. That is the proof the requirement is\n * real, and the reason it belongs in the primitive rather than in a guideline\n * nobody reads twice.\n */\ntype ToneWithMark =\n | {\n /** The reading tone. `default` and `accent` carry no judgement, so they\n * need no mark. */\n tone?: 'default' | 'accent'\n /** A glyph before the figure. Optional here. */\n icon?: ReactNode\n }\n | {\n /** A judgement: this figure is itself the problem. */\n tone: 'warn' | 'bad'\n /** Required with a judgement, and not decoration - it is what tells the\n * figure apart from an accented one for a reader who does not separate\n * the two hues, or is looking at a projector. */\n icon: ReactNode\n }\n\nexport type StatTileProps = Omit<HTMLAttributes<HTMLDListElement>, 'title'> &\n VariantProps<typeof statTileVariants> &\n ToneWithMark & {\n /** What the figure is. */\n label: ReactNode\n /** The figure. Already formatted - a duration, a count, a percentage: this\n * component decides how a number looks, never what it says. */\n value: ReactNode\n /** Which way it moved, in words the caller chooses: `+12% vs last week`,\n * `3 fewer than yesterday`. Omitted when there is nothing to compare to -\n * an empty line here reads as \"unchanged\", which is a claim. */\n delta?: ReactNode\n /** Whether that movement is good news. Stated rather than read off the\n * sign, because for a figure like time-to-answer a fall is the good\n * direction. */\n deltaTone?: NonNullable<VariantProps<typeof statTileDeltaVariants>['tone']>\n }\n\nexport function StatTile({\n label,\n value,\n tone,\n icon,\n delta,\n deltaTone,\n size,\n className,\n ...props\n}: StatTileProps) {\n return (\n <dl className={cn(statTileVariants({ size }), className)} {...props}>\n <dt className=\"text-xs font-medium text-dim\">{label}</dt>\n <dd className={cn(statTileValueVariants({ size, tone }), icon && 'flex items-center gap-1.5')}>\n {/* Hidden from the reader: the mark restates the tone, and the tone is\n * already in the words of the label. Announcing \"warning\" before the\n * number would be the screen reader saying twice what the sighted\n * reader sees once. */}\n {icon && <span className=\"shrink-0\" aria-hidden>{icon}</span>}\n {value}\n </dd>\n {/* A second `dd` for the same term: the spec allows several, and this is\n * what they are for - one fact with two parts. A `<div>` here would end\n * the description list's pairing, and the delta would be read as loose\n * text next to the number rather than as part of it. */}\n {delta !== undefined && delta !== null && (\n <dd className={cn(statTileDeltaVariants({ tone: deltaTone }))}>{delta}</dd>\n )}\n </dl>\n )\n}\n\n/*\n * A row of tiles.\n *\n * Both donors wrote the identical container - `flex flex-wrap items-baseline\n * gap-x-8 gap-y-3` inside a Panel - and both had to get `items-baseline`\n * right, which is the part that is easy to miss: without it, a tile carrying a\n * delta is taller than its neighbours and the whole row's numbers stop sharing\n * a line.\n *\n * Wrapping rather than a grid, because the number of figures is decided at\n * runtime (one donor hides two of its five until there is something to say),\n * and a grid with a fixed column count leaves a hole where a hidden tile was.\n */\nexport type StatRowProps = HTMLAttributes<HTMLDivElement>\n\nexport function StatRow({ className, ...props }: StatRowProps) {\n return <div className={cn('flex flex-wrap items-baseline gap-x-8 gap-y-3', className)} {...props} />\n}\n"
|
|
1677
1677
|
}
|
|
1678
1678
|
]
|
|
1679
1679
|
},
|
|
@@ -1684,7 +1684,7 @@
|
|
|
1684
1684
|
"description": "The smallest thing a screen can say about something's condition: a server is up, a job failed, a person is away. Every product of the line drew its own coloured circle, and every one of them drew it the same way - a `<span>` with a background - which means the condition existed for exactly the readers who could see it.",
|
|
1685
1685
|
"dependencies": [
|
|
1686
1686
|
"class-variance-authority",
|
|
1687
|
-
"dowel-ui@^0.
|
|
1687
|
+
"dowel-ui@^0.28.0"
|
|
1688
1688
|
],
|
|
1689
1689
|
"registryDependencies": [],
|
|
1690
1690
|
"files": [
|
|
@@ -1703,7 +1703,7 @@
|
|
|
1703
1703
|
"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.",
|
|
1704
1704
|
"dependencies": [
|
|
1705
1705
|
"@base-ui/react",
|
|
1706
|
-
"dowel-ui@^0.
|
|
1706
|
+
"dowel-ui@^0.28.0"
|
|
1707
1707
|
],
|
|
1708
1708
|
"registryDependencies": [],
|
|
1709
1709
|
"files": [
|
|
@@ -1738,7 +1738,7 @@
|
|
|
1738
1738
|
"description": "Parts rather than a `columns` prop, and that is the decision worth stating: a `<DataTable columns={…} rows={…} />` is quicker to write for the first table and then owns every cell in the product forever. The moment one column needs a Badge, another a link, and a third the row's own menu, the prop grows a `render` for each - at which point it is JSX with extra steps, spelt in a shape only this component understands.",
|
|
1739
1739
|
"dependencies": [
|
|
1740
1740
|
"class-variance-authority",
|
|
1741
|
-
"dowel-ui@^0.
|
|
1741
|
+
"dowel-ui@^0.28.0"
|
|
1742
1742
|
],
|
|
1743
1743
|
"registryDependencies": [
|
|
1744
1744
|
"https://lacodda.github.io/dowel/r/table-sort.json"
|
|
@@ -1748,7 +1748,7 @@
|
|
|
1748
1748
|
"path": "ui/table.tsx",
|
|
1749
1749
|
"target": "@ui/table.tsx",
|
|
1750
1750
|
"type": "registry:ui",
|
|
1751
|
-
"content": "import type { HTMLAttributes, TdHTMLAttributes, ThHTMLAttributes } from 'react'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { cn } from 'dowel-ui'\nimport { ariaSort, type Sort } from './table-sort'\n\n/*\n * A table of rows, and the heading that reorders them.\n *\n * Parts rather than a `columns` prop, and that is the decision worth stating:\n * a `<DataTable columns={…} rows={…} />` is quicker to write for the first\n * table and then owns every cell in the product forever. The moment one column\n * needs a Badge, another a link, and a third the row's own menu, the prop\n * grows a `render` for each - at which point it is JSX with extra steps, spelt\n * in a shape only this component understands.\n *\n * So the parts are the ones HTML already has, dressed: a caller writes\n * `<TableRow>` and `<TableCell>` and puts whatever it likes inside. What is\n * bought by having them here rather than in the product is that every table of\n * the line has the same row height, the same heading, the same hairline and\n * the same behaviour when a column is sorted - and that a heading that sorts\n * announces it, which is the part hand-rolled tables get wrong.\n *\n * The sorting arithmetic is next door in `table-sort`, with no React in it -\n * a product that sorts on the server imports that and never this.\n *\n * `<TableScroll>` exists because a table cannot scroll itself: `overflow` on a\n * `<table>` does nothing, so the wrapper is not decoration but the only place\n * a scrollbar can live. It is also what makes the sticky heading work, since\n * `position: sticky` needs a scroll container to stick inside.\n */\n\
|
|
1751
|
+
"content": "import type { HTMLAttributes, TdHTMLAttributes, ThHTMLAttributes } from 'react'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { cn } from 'dowel-ui'\nimport { ariaSort, type Sort } from './table-sort'\n\n/*\n * A table of rows, and the heading that reorders them.\n *\n * Parts rather than a `columns` prop, and that is the decision worth stating:\n * a `<DataTable columns={…} rows={…} />` is quicker to write for the first\n * table and then owns every cell in the product forever. The moment one column\n * needs a Badge, another a link, and a third the row's own menu, the prop\n * grows a `render` for each - at which point it is JSX with extra steps, spelt\n * in a shape only this component understands.\n *\n * So the parts are the ones HTML already has, dressed: a caller writes\n * `<TableRow>` and `<TableCell>` and puts whatever it likes inside. What is\n * bought by having them here rather than in the product is that every table of\n * the line has the same row height, the same heading, the same hairline and\n * the same behaviour when a column is sorted - and that a heading that sorts\n * announces it, which is the part hand-rolled tables get wrong.\n *\n * The sorting arithmetic is next door in `table-sort`, with no React in it -\n * a product that sorts on the server imports that and never this.\n *\n * `<TableScroll>` exists because a table cannot scroll itself: `overflow` on a\n * `<table>` does nothing, so the wrapper is not decoration but the only place\n * a scrollbar can live. It is also what makes the sticky heading work, since\n * `position: sticky` needs a scroll container to stick inside.\n */\n\n/*\n * Row height follows the density of the region, like every other control.\n *\n * This had a `density` prop of its own, with its own words - `base` and\n * `dense` - which made \"density\" mean two different things in one set: an\n * attribute on a container for fields, a prop on this one element for rows. A\n * product wanting a tight screen had to know both and set both, and a table\n * inside a compact form stayed comfortable unless somebody remembered.\n *\n * Now it reads `--row-cell`, so `data-density=\"compact\"` on anything above it\n * tightens the rows with everything else. The font does not shrink with it:\n * shrinking the text too is how a dense table becomes an unreadable one.\n */\nexport const tableVariants = cva(\n 'w-full border-collapse text-left text-sm [&_td]:py-row [&_th]:py-row',\n)\n\nexport interface TableProps\n extends HTMLAttributes<HTMLTableElement>,\n VariantProps<typeof tableVariants> {}\n\nexport function Table({ className, ...props }: TableProps) {\n return <table className={cn(tableVariants(), className)} {...props} />\n}\n\n/** The scroll container a table needs, and the one a sticky heading sticks in.\n *\n * The scrollbar is the theme's - an overlay that takes no room in the layout,\n * so a table that grows past the fold does not shift the column beside it. */\nexport function TableScroll({ className, ...props }: HTMLAttributes<HTMLDivElement>) {\n return <div className={cn('w-full overflow-x-auto', className)} {...props} />\n}\n\nexport interface TableHeadProps extends HTMLAttributes<HTMLTableSectionElement> {\n /** Keep the heading in view while the body scrolls under it.\n *\n * Off by default. A sticky heading needs a container with a height to stick\n * inside; switched on by default it would silently do nothing in the common\n * case - a table that scrolls with the page - and look broken in the other. */\n sticky?: boolean\n}\n\nexport function TableHead({ sticky = false, className, ...props }: TableHeadProps) {\n return (\n <thead\n className={cn(\n 'text-xs text-dim',\n // The heading needs its own ground when it is sticky: transparent, it\n // would have the body's rows sliding visibly beneath its text.\n sticky && 'sticky top-0 z-sticky bg-bg',\n className,\n )}\n {...props}\n />\n )\n}\n\nexport function TableBody({ className, ...props }: HTMLAttributes<HTMLTableSectionElement>) {\n return <tbody className={cn('', className)} {...props} />\n}\n\nexport interface TableRowProps extends HTMLAttributes<HTMLTableRowElement> {\n /** The row the reader has picked out - the one open in the panel beside the\n * table, the one a keyboard cursor is on. Not a hover state. */\n selected?: boolean\n}\n\nexport function TableRow({ selected = false, className, ...props }: TableRowProps) {\n return (\n <tr\n // Announced rather than only drawn: a row picked out by colour alone is\n // a row nobody using a screen reader knows about.\n aria-selected={selected || undefined}\n className={cn(\n 'border-b border-line transition-colors last:border-b-0',\n selected ? 'bg-accent-soft' : 'hover:bg-soft',\n className,\n )}\n {...props}\n />\n )\n}\n\nexport interface TableCellProps extends TdHTMLAttributes<HTMLTableCellElement> {\n /** Right-align and use the lining figures. For a column of numbers, and the\n * reason it is a prop rather than a class the caller adds: a column of\n * numbers that is not aligned is the most common defect in a table, and the\n * one nobody files a bug about. */\n numeric?: boolean\n}\n\nexport function TableCell({ numeric = false, className, ...props }: TableCellProps) {\n return (\n <td\n className={cn('px-3 align-middle', numeric && 'text-right tabular-nums', className)}\n {...props}\n />\n )\n}\n\nexport interface TableHeaderProps extends ThHTMLAttributes<HTMLTableCellElement> {\n numeric?: boolean\n}\n\n/** A plain heading, for a column that does not sort. */\nexport function TableHeader({ numeric = false, className, ...props }: TableHeaderProps) {\n return (\n <th\n scope=\"col\"\n className={cn(\n 'px-3 font-medium',\n numeric && 'text-right tabular-nums',\n className,\n )}\n {...props}\n />\n )\n}\n\nexport interface TableSortHeaderProps extends Omit<TableHeaderProps, 'onClick'> {\n /** This column's id - the key the accessor sorts by. */\n column: string\n /** What is sorted now, across the whole table. */\n sort: Sort | undefined\n onSortChange: (column: string) => void\n}\n\n/** A heading that reorders the table when clicked.\n *\n * A real `<button>` inside the `<th>`, not a click handler on the cell: the\n * cell is not focusable, gets no keyboard, and announces nothing. This is the\n * part a hand-rolled table almost always gets wrong, and it is invisible to\n * everyone who reorders with a mouse.\n *\n * The arrow is `aria-hidden`, because `aria-sort` on the cell already says\n * which way the column points - a reader would otherwise hear the direction\n * twice, once as a word and once as a character nobody meant to publish. */\nexport function TableSortHeader({\n column,\n sort,\n onSortChange,\n numeric = false,\n className,\n children,\n ...props\n}: TableSortHeaderProps) {\n const active = sort?.column === column\n const direction = sort?.direction\n\n return (\n <th\n scope=\"col\"\n // Only on the sorted column. `aria-sort=\"none\"` on every other heading\n // is valid and gets announced by some readers on every cell.\n aria-sort={ariaSort(sort, column)}\n className={cn('px-3 font-medium', numeric && 'tabular-nums', className)}\n {...props}\n >\n <button\n type=\"button\"\n onClick={() => onSortChange(column)}\n className={cn(\n 'group -mx-1 inline-flex w-[calc(100%+0.5rem)] cursor-pointer items-center gap-1 rounded-sm px-1',\n 'transition-colors hover:text-text',\n 'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent',\n active && 'text-text',\n numeric && 'justify-end',\n )}\n >\n {children}\n <span\n aria-hidden\n className={cn(\n 'text-2xs transition-opacity',\n // The arrow of the unsorted column appears on hover rather than\n // never: a heading that gives no sign it can be clicked is one\n // nobody clicks. It stays out of the way until the pointer is on\n // it, so a table of eight columns is not a row of eight arrows.\n active ? 'opacity-100' : 'opacity-0 group-hover:opacity-40',\n )}\n >\n {direction === 'desc' && active ? '▼' : '▲'}\n </span>\n </button>\n </th>\n )\n}\n\n/** What the table says when it has no rows.\n *\n * A row spanning every column rather than a block below the table, so the\n * heading stays where it is and the table does not change shape between having\n * rows and not. `colSpan` is required: without it the message sits under the\n * first column and the rest of the header hangs over nothing. */\nexport interface TableEmptyProps extends TdHTMLAttributes<HTMLTableCellElement> {\n colSpan: number\n}\n\nexport function TableEmpty({ colSpan, className, ...props }: TableEmptyProps) {\n return (\n <tr>\n <td\n colSpan={colSpan}\n className={cn('px-3 py-10 text-center text-sm text-dim', className)}\n {...props}\n />\n </tr>\n )\n}\n"
|
|
1752
1752
|
}
|
|
1753
1753
|
]
|
|
1754
1754
|
},
|
|
@@ -1759,7 +1759,7 @@
|
|
|
1759
1759
|
"description": "Free text turned into a list: type a word, press Enter, it becomes a chip.",
|
|
1760
1760
|
"dependencies": [
|
|
1761
1761
|
"class-variance-authority",
|
|
1762
|
-
"dowel-ui@^0.
|
|
1762
|
+
"dowel-ui@^0.28.0"
|
|
1763
1763
|
],
|
|
1764
1764
|
"registryDependencies": [
|
|
1765
1765
|
"https://lacodda.github.io/dowel/r/chip.json",
|
|
@@ -1780,7 +1780,7 @@
|
|
|
1780
1780
|
"title": "Textarea",
|
|
1781
1781
|
"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.",
|
|
1782
1782
|
"dependencies": [
|
|
1783
|
-
"dowel-ui@^0.
|
|
1783
|
+
"dowel-ui@^0.28.0"
|
|
1784
1784
|
],
|
|
1785
1785
|
"registryDependencies": [
|
|
1786
1786
|
"https://lacodda.github.io/dowel/r/input.json"
|
|
@@ -1801,7 +1801,7 @@
|
|
|
1801
1801
|
"description": "The tier primitive.",
|
|
1802
1802
|
"dependencies": [
|
|
1803
1803
|
"class-variance-authority",
|
|
1804
|
-
"dowel-ui@^0.
|
|
1804
|
+
"dowel-ui@^0.28.0"
|
|
1805
1805
|
],
|
|
1806
1806
|
"registryDependencies": [],
|
|
1807
1807
|
"files": [
|
|
@@ -1809,7 +1809,7 @@
|
|
|
1809
1809
|
"path": "ui/tier.tsx",
|
|
1810
1810
|
"target": "@ui/tier.tsx",
|
|
1811
1811
|
"type": "registry:ui",
|
|
1812
|
-
"content": "import type { HTMLAttributes, KeyboardEvent, ReactNode } from 'react'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { cn } from 'dowel-ui'\n\n/*\n * Tiers: the band a number falls in, the road of bands, and one axis of the\n * score that produced it.\n *\n * The shape comes from kilna, where a work is scored on several axes and the\n * total lands in a named band - \"draft\", \"publishable\", \"a clip\". Two more\n * products of the line grade things the same way (a health score, a review\n * verdict), which is what makes this a primitive rather than a screen.\n *\n * The three pieces answer three different questions and are separate for that\n * reason:\n *\n * - `TierBadge` - *which band is this in?* A verdict, in one word.\n * - `TierRuler` - *how far into it, and how far to the next?* The verdict\n * stops being a pronouncement out of nowhere: \"nearly a clip\" is the thing\n * someone wants to know while they are still moving marks around.\n * - `AxisBar` - *what did one axis contribute?* A row of segments read left to\n * right, which a column of numerals is not.\n *\n * The defect corrected on the way in, and the reason not to copy the donor as\n * it stands: its ruler drew the bands to scale and then laid the labels out\n * with `justify-between`, which spaces them evenly. With bands at 0, 50, 78\n * and 90 the label \"78\" sat a fifth of the bar away from the boundary it\n * named. Here the labels are placed by the same arithmetic as the bands, so\n * the distance you read is the distance there is.\n */\n\n/** A band: where it starts, and what it is called.\n *\n * `min` is on the same scale as the value - 0-100 in every consumer so far,\n * but the components do not require it: `TierRuler` takes the ends explicitly.\n * The band runs from its `min` to the next band's, and the last one runs to\n * the end. */\nexport interface Tier {\n /** Stable key, for React and for the caller's own lookups. */\n key: string\n /** What it is called, in the product's words. */\n label: string\n /** The lowest value in this band. */\n min: number\n /** How the band is coloured. `accent` is the default - a band is a position\n * on the product's own scale, not a judgement in the status vocabulary. A\n * product that means \"this one is bad\" says so. */\n status?: 'accent' | 'good' | 'warn' | 'bad' | 'info' | 'neutral'\n}\n\n/**\n * The bands in order, with the ones that cannot be drawn dropped.\n *\n * Exported because a product that has tiers usually needs them sorted\n * somewhere else too, and because sorting inside a render is the kind of thing\n * that quietly becomes three different sorts.\n */\nexport function orderedTiers(tiers: Tier[], min = 0, max = 100): Tier[] {\n return tiers\n .filter((tier) => Number.isFinite(tier.min) && tier.min >= min && tier.min <= max)\n .sort((left, right) => left.min - right.min)\n}\n\n/**\n * Which band a value is standing in.\n *\n * The highest band whose `min` the value has reached. Below every band - which\n * a caller can reach by scoring under the first `min` - the answer is\n * `undefined`, and that is a real state rather than a reason to clamp: a\n * product that shows a badge for a band nothing is in would be inventing one.\n */\nexport function tierAt(tiers: Tier[], value: number): Tier | undefined {\n const ordered = orderedTiers(tiers)\n let standing: Tier | undefined\n for (const tier of ordered) {\n if (value >= tier.min) standing = tier\n else break\n }\n return standing\n}\n\nconst tierFill: Record<NonNullable<Tier['status']>, string> = {\n accent: 'bg-accent',\n good: 'bg-good',\n warn: 'bg-warn',\n bad: 'bg-bad',\n info: 'bg-info',\n neutral: 'bg-line-2',\n}\n\n/** The same bands at a quarter strength, for the stretch already passed.\n *\n * Written out rather than composed with an opacity utility, because a fill and\n * an opacity are two properties and a caller's `className` can only win\n * against one of them. */\nconst tierPassedFill: Record<NonNullable<Tier['status']>, string> = {\n accent: 'bg-accent/40',\n good: 'bg-good/40',\n warn: 'bg-warn/40',\n bad: 'bg-bad/40',\n info: 'bg-info/40',\n neutral: 'bg-line-2/40',\n}\n\nexport const tierBadgeVariants = cva(\n 'inline-flex items-center gap-1.5 rounded-md px-2 py-0.5 text-xs font-medium whitespace-nowrap',\n {\n variants: {\n status: {\n accent: 'bg-accent-soft text-accent',\n good: 'bg-good-soft text-good',\n warn: 'bg-warn-soft text-warn',\n bad: 'bg-bad-soft text-bad',\n info: 'bg-info-soft text-info',\n neutral: 'bg-soft text-dim',\n },\n },\n defaultVariants: { status: 'accent' },\n },\n)\n\nexport interface TierBadgeProps\n extends Omit<HTMLAttributes<HTMLSpanElement>, 'children'>,\n VariantProps<typeof tierBadgeVariants> {\n /** The band's name. */\n label: ReactNode\n /** The number behind the verdict, if the product shows it. Drawn quieter\n * than the name: the band is the answer and the score is the evidence. */\n value?: ReactNode\n}\n\n/**\n * The band, as a verdict.\n *\n * Square-cornered rather than a pill, which is what separates it from\n * `StatusBadge` at a glance: a tier is a position on a scale the product owns,\n * a status is a condition from the line's fixed vocabulary, and a screen that\n * shows both should not make the reader compare colours to tell which is\n * which.\n */\nexport function TierBadge({ label, value, status, className, ...props }: TierBadgeProps) {\n return (\n <span className={cn(tierBadgeVariants({ status }), className)} {...props}>\n {label}\n {value !== undefined && <span className=\"font-normal opacity-70\">{value}</span>}\n </span>\n )\n}\n\nexport interface TierRulerProps extends Omit<HTMLAttributes<HTMLDivElement>, 'children'> {\n tiers: Tier[]\n /** Where the thing stands now. */\n value: number\n /** The ends of the road. */\n min?: number\n max?: number\n /** What the ruler as a whole is called, for a screen reader, and what to say\n * the value is. The product's words - the ruler has none. */\n label: string\n /** The value spoken instead of the bare number: \"7.8 out of 10, publishable\"\n * is what a reader needs, and only the product can phrase it. */\n valueText?: string\n /** Print the band names under the road. */\n showLabels?: boolean\n}\n\n/**\n * The bands as a road, with the value standing somewhere along it.\n *\n * Drawn to scale, which is the whole point: a band that starts at 78 sits\n * nearly four fifths along, and the gap you are looking at is the gap you have\n * to close.\n *\n * Three states per band, not two - passed, standing in, still ahead. A flat\n * wash of \"reached\" over half the bar says only that the value is not at zero;\n * picking out the band being stood in is what carries the eye to where the\n * thing actually is.\n *\n * Reported as a `meter`: it is a measurement inside a known range, which is\n * exactly what that role is for, and it means a screen reader says the value\n * without the product building a sentence out of `aria-label`.\n */\nexport function TierRuler({\n tiers,\n value,\n min = 0,\n max = 100,\n label,\n valueText,\n showLabels = true,\n className,\n ...props\n}: TierRulerProps) {\n const ordered = orderedTiers(tiers, min, max)\n const span = max - min\n\n // A road needs at least a start and one boundary on it; below that there is\n // nothing to show that a bare number would not say better.\n if (ordered.length < 2 || span <= 0) return null\n\n const at = Math.min(Math.max(value, min), max)\n /** Where a value falls along the road, as a percentage of its length. */\n const placeOf = (point: number) => ((point - min) / span) * 100\n\n return (\n <div className={cn('flex flex-col gap-1', className)} {...props}>\n <div\n role=\"meter\"\n aria-label={label}\n aria-valuemin={min}\n aria-valuemax={max}\n aria-valuenow={at}\n aria-valuetext={valueText}\n className=\"relative h-1.5 w-full overflow-hidden rounded-full bg-soft\"\n >\n {ordered.map((tier, index) => {\n const next = ordered[index + 1]\n const end = next === undefined ? max : next.min\n const width = placeOf(end) - placeOf(tier.min)\n if (width <= 0) return null\n\n const reached = value >= tier.min\n const standing = reached && (next === undefined || value < next.min)\n const status = tier.status ?? 'accent'\n\n return (\n <span\n key={tier.key}\n style={{ left: `${placeOf(tier.min)}%`, width: `${width}%` }}\n className={cn(\n 'absolute top-0 h-full',\n standing ? tierFill[status] : reached ? tierPassedFill[status] : 'bg-line-2',\n // A hairline of the page's own ground, so two adjacent bands of\n // the same colour still read as two.\n index > 0 && 'border-l border-bg',\n )}\n />\n )\n })}\n\n {/* Where the value stands. A pale core inside a dark sheath, so the\n mark keeps its contrast over a filled band as well as over the\n empty road ahead. */}\n <span\n aria-hidden\n style={{ left: `${placeOf(at)}%` }}\n className=\"absolute top-1/2 h-2.5 w-1.5 -translate-x-1/2 -translate-y-1/2 rounded-full bg-bg ring-2 ring-text\"\n />\n </div>\n\n {showLabels && (\n <div aria-hidden className=\"relative h-4\">\n {ordered.map((tier, index) => {\n const place = placeOf(tier.min)\n return (\n <span\n key={tier.key}\n style={{\n left: `${place}%`,\n // The first label would hang off the left edge and the last\n // off the right, so the ends align to their edge and\n // everything between is centred on its boundary.\n transform:\n index === 0\n ? 'none'\n : index === ordered.length - 1 && place >= 100\n ? 'translateX(-100%)'\n : 'translateX(-50%)',\n }}\n className={cn(\n 'absolute top-0 text-2xs',\n value >= tier.min ? 'font-medium text-dim' : 'text-faint',\n )}\n >\n {tier.label}\n </span>\n )\n })}\n </div>\n )}\n </div>\n )\n}\n\nexport interface AxisBarProps extends Omit<HTMLAttributes<HTMLDivElement>, 'onChange'> {\n /** What this axis is called. */\n label: string\n /** The highest mark; one segment is drawn per whole point. */\n scale: number\n /** The mark, or `undefined` while the axis is unjudged. Not the same as\n * zero: zero is a verdict, and a blank axis is the absence of one. */\n value?: number\n /** Given, the row becomes a control. Omitted, it is a read-only reading of a\n * score someone else set. */\n onChange?: (value: number | undefined) => void\n /** The mark from which the total crosses into the next band, and what to\n * call it. Absent when no mark on this axis gets there - and then nothing is\n * drawn, because a line promising a band the axis cannot deliver is worse\n * than no line. */\n threshold?: { mark: number; label: string }\n /** What to say instead of the bare number - \"unjudged\", in the product's\n * word, when there is no mark. */\n valueText?: string\n}\n\n/**\n * One axis of a score, as a row of segments.\n *\n * Scoring is a judgement, not data entry: the useful question is \"is this a\n * seven or an eight\", and a row you click answers it in one movement where a\n * spin box asks you to read, aim and type. The filled length is also readable\n * down a column of axes, which numerals are not.\n *\n * When it takes `onChange` it reports itself as a slider rather than as a row\n * of buttons, so the arrow keys, Home and End work the way they do everywhere\n * else, and a reader hears one value in a range instead of ten unlabelled\n * buttons. The row owns the keyboard; the segments are pointer targets only,\n * or tabbing past one axis would take ten presses.\n *\n * Read-only, it is a `meter` and not a disabled slider: nothing here is\n * disabled, the number is simply a fact.\n */\nexport function AxisBar({\n label,\n scale,\n value,\n onChange,\n threshold,\n valueText,\n className,\n ...props\n}: AxisBarProps) {\n const marks = Math.max(1, Math.round(scale))\n const interactive = onChange !== undefined\n\n const step = (delta: number) => {\n if (!onChange) return\n // An unjudged axis steps onto the first or last mark rather than through\n // zero: zero is a verdict of its own, and arrowing into it by accident\n // would be one.\n const next = value === undefined ? (delta > 0 ? 1 : marks) : value + delta\n onChange(Math.min(Math.max(next, 0), marks))\n }\n\n const onKeyDown = (event: KeyboardEvent) => {\n if (!onChange) 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 onChange(0)\n break\n case 'End':\n event.preventDefault()\n onChange(marks)\n break\n // Back to unjudged, which no arrow key can reach - and which is not the\n // same as scoring the axis zero.\n case 'Backspace':\n case 'Delete':\n event.preventDefault()\n onChange(undefined)\n break\n default:\n break\n }\n }\n\n return (\n <div\n role={interactive ? 'slider' : 'meter'}\n tabIndex={interactive ? 0 : undefined}\n aria-label={label}\n aria-valuemin={0}\n aria-valuemax={marks}\n aria-valuenow={value}\n aria-valuetext={valueText}\n onKeyDown={interactive ? onKeyDown : undefined}\n className={cn(\n 'flex gap-[3px] rounded-md focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent',\n className,\n )}\n {...props}\n >\n {Array.from({ length: marks }, (_, index) => {\n const mark = index + 1\n const filled = value !== undefined && mark <= value\n // The first mark that would carry the total over. The ring goes around\n // the segment rather than on its leading edge: a rule beside the last\n // mark reads as the end of the scale, which is exactly when the\n // threshold matters most.\n const crosses = threshold !== undefined && threshold.mark === mark\n\n const shared = cn(\n 'relative h-5 flex-1 rounded-sm transition-colors',\n filled ? 'bg-accent' : 'bg-soft',\n crosses && 'ring-2 ring-inset ring-good',\n )\n\n return interactive ? (\n // A `<span>` and not a `<button>`, which is what the donor used and\n // what the accessibility gate rejected: a focusable control inside a\n // `role=\"slider\"` is a nested interactive element, and `tabindex=-1`\n // with `aria-hidden` does not undo that - assistive technology can\n // still land on it, and WCAG 4.1.2 says so.\n //\n // Nothing is lost. The row already owns the keyboard, so the segment\n // was never a keyboard target; it is a pointer target, which a span\n // with a click handler is.\n <span\n key={mark}\n aria-hidden\n // Clicking the mark already set clears the axis, which is the only\n // way back to unjudged with a pointer.\n onClick={() => onChange?.(value === mark ? undefined : mark)}\n title={crosses ? threshold.label : undefined}\n className={cn(shared, 'cursor-pointer', filled ? 'hover:bg-accent-2' : 'hover:bg-line-2')}\n />\n ) : (\n <span key={mark} aria-hidden className={shared} />\n )\n })}\n </div>\n )\n}\n"
|
|
1812
|
+
"content": "import type { HTMLAttributes, KeyboardEvent, ReactNode } from 'react'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { cn } from 'dowel-ui'\n\n/*\n * Tiers: the band a number falls in, the road of bands, and one axis of the\n * score that produced it.\n *\n * The shape comes from kilna, where a work is scored on several axes and the\n * total lands in a named band - \"draft\", \"publishable\", \"a clip\". Two more\n * products of the line grade things the same way (a health score, a review\n * verdict), which is what makes this a primitive rather than a screen.\n *\n * The three pieces answer three different questions and are separate for that\n * reason:\n *\n * - `TierBadge` - *which band is this in?* A verdict, in one word.\n * - `TierRuler` - *how far into it, and how far to the next?* The verdict\n * stops being a pronouncement out of nowhere: \"nearly a clip\" is the thing\n * someone wants to know while they are still moving marks around.\n * - `AxisBar` - *what did one axis contribute?* A row of segments read left to\n * right, which a column of numerals is not.\n *\n * The defect corrected on the way in, and the reason not to copy the donor as\n * it stands: its ruler drew the bands to scale and then laid the labels out\n * with `justify-between`, which spaces them evenly. With bands at 0, 50, 78\n * and 90 the label \"78\" sat a fifth of the bar away from the boundary it\n * named. Here the labels are placed by the same arithmetic as the bands, so\n * the distance you read is the distance there is.\n */\n\n/** A band: where it starts, and what it is called.\n *\n * `min` is on the same scale as the value - 0-100 in every consumer so far,\n * but the components do not require it: `TierRuler` takes the ends explicitly.\n * The band runs from its `min` to the next band's, and the last one runs to\n * the end. */\nexport interface Tier {\n /** Stable key, for React and for the caller's own lookups. */\n key: string\n /** What it is called, in the product's words. */\n label: string\n /** The lowest value in this band. */\n min: number\n /** How the band is coloured. `accent` is the default - a band is a position\n * on the product's own scale, not a judgement in the status vocabulary. A\n * product that means \"this one is bad\" says so. */\n status?: 'accent' | 'good' | 'warn' | 'bad' | 'info' | 'neutral'\n}\n\n/**\n * The bands in order, with the ones that cannot be drawn dropped.\n *\n * Exported because a product that has tiers usually needs them sorted\n * somewhere else too, and because sorting inside a render is the kind of thing\n * that quietly becomes three different sorts.\n */\nexport function orderedTiers(tiers: Tier[], min = 0, max = 100): Tier[] {\n return tiers\n .filter((tier) => Number.isFinite(tier.min) && tier.min >= min && tier.min <= max)\n .sort((left, right) => left.min - right.min)\n}\n\n/**\n * Which band a value is standing in.\n *\n * The highest band whose `min` the value has reached. Below every band - which\n * a caller can reach by scoring under the first `min` - the answer is\n * `undefined`, and that is a real state rather than a reason to clamp: a\n * product that shows a badge for a band nothing is in would be inventing one.\n */\nexport function tierAt(tiers: Tier[], value: number): Tier | undefined {\n const ordered = orderedTiers(tiers)\n let standing: Tier | undefined\n for (const tier of ordered) {\n if (value >= tier.min) standing = tier\n else break\n }\n return standing\n}\n\nconst tierFill: Record<NonNullable<Tier['status']>, string> = {\n accent: 'bg-accent',\n good: 'bg-good',\n warn: 'bg-warn',\n bad: 'bg-bad',\n info: 'bg-info',\n neutral: 'bg-line-2',\n}\n\n/** The same bands at a quarter strength, for the stretch already passed.\n *\n * Written out rather than composed with an opacity utility, because a fill and\n * an opacity are two properties and a caller's `className` can only win\n * against one of them. */\nconst tierPassedFill: Record<NonNullable<Tier['status']>, string> = {\n accent: 'bg-accent/40',\n good: 'bg-good/40',\n warn: 'bg-warn/40',\n bad: 'bg-bad/40',\n info: 'bg-info/40',\n neutral: 'bg-line-2/40',\n}\n\nexport const tierBadgeVariants = cva(\n 'inline-flex items-center gap-1.5 rounded-md px-2 py-0.5 text-xs font-medium whitespace-nowrap',\n {\n variants: {\n status: {\n accent: 'bg-accent-soft text-accent',\n good: 'bg-good-soft text-good',\n warn: 'bg-warn-soft text-warn',\n bad: 'bg-bad-soft text-bad',\n info: 'bg-info-soft text-info',\n neutral: 'bg-soft text-dim',\n },\n },\n defaultVariants: { status: 'accent' },\n },\n)\n\nexport interface TierBadgeProps\n extends Omit<HTMLAttributes<HTMLSpanElement>, 'children'>,\n VariantProps<typeof tierBadgeVariants> {\n /** The band's name. */\n label: ReactNode\n /** The number behind the verdict, if the product shows it. Drawn quieter\n * than the name: the band is the answer and the score is the evidence. */\n value?: ReactNode\n}\n\n/**\n * The band, as a verdict.\n *\n * Square-cornered rather than a pill, which is what separates it from\n * `StatusBadge` at a glance: a tier is a position on a scale the product owns,\n * a status is a condition from the line's fixed vocabulary, and a screen that\n * shows both should not make the reader compare colours to tell which is\n * which.\n */\nexport function TierBadge({ label, value, status, className, ...props }: TierBadgeProps) {\n return (\n <span className={cn(tierBadgeVariants({ status }), className)} {...props}>\n {label}\n {value !== undefined && <span className=\"font-normal opacity-70\">{value}</span>}\n </span>\n )\n}\n\nexport interface TierRulerProps extends Omit<HTMLAttributes<HTMLDivElement>, 'children'> {\n tiers: Tier[]\n /** Where the thing stands now. */\n value: number\n /** The ends of the road. */\n min?: number\n max?: number\n /** What the ruler as a whole is called, for a screen reader, and what to say\n * the value is. The product's words - the ruler has none. */\n label: string\n /** The value spoken instead of the bare number: \"7.8 out of 10, publishable\"\n * is what a reader needs, and only the product can phrase it. */\n valueText?: string\n /** Print the band names under the road. */\n showLabels?: boolean\n}\n\n/**\n * The bands as a road, with the value standing somewhere along it.\n *\n * Drawn to scale, which is the whole point: a band that starts at 78 sits\n * nearly four fifths along, and the gap you are looking at is the gap you have\n * to close.\n *\n * Three states per band, not two - passed, standing in, still ahead. A flat\n * wash of \"reached\" over half the bar says only that the value is not at zero;\n * picking out the band being stood in is what carries the eye to where the\n * thing actually is.\n *\n * Reported as a `meter`: it is a measurement inside a known range, which is\n * exactly what that role is for, and it means a screen reader says the value\n * without the product building a sentence out of `aria-label`.\n */\nexport function TierRuler({\n tiers,\n value,\n min = 0,\n max = 100,\n label,\n valueText,\n showLabels = true,\n className,\n ...props\n}: TierRulerProps) {\n const ordered = orderedTiers(tiers, min, max)\n const span = max - min\n\n // A road needs at least a start and one boundary on it; below that there is\n // nothing to show that a bare number would not say better.\n if (ordered.length < 2 || span <= 0) return null\n\n const at = Math.min(Math.max(value, min), max)\n /** Where a value falls along the road, as a percentage of its length. */\n const placeOf = (point: number) => ((point - min) / span) * 100\n\n return (\n <div className={cn('flex flex-col gap-1', className)} {...props}>\n <div\n role=\"meter\"\n aria-label={label}\n aria-valuemin={min}\n aria-valuemax={max}\n aria-valuenow={at}\n aria-valuetext={valueText}\n className=\"relative h-1.5 w-full overflow-hidden rounded-full bg-soft\"\n >\n {ordered.map((tier, index) => {\n const next = ordered[index + 1]\n const end = next === undefined ? max : next.min\n const width = placeOf(end) - placeOf(tier.min)\n if (width <= 0) return null\n\n const reached = value >= tier.min\n const standing = reached && (next === undefined || value < next.min)\n const status = tier.status ?? 'accent'\n\n return (\n <span\n key={tier.key}\n style={{ left: `${placeOf(tier.min)}%`, width: `${width}%` }}\n className={cn(\n 'absolute top-0 h-full',\n standing ? tierFill[status] : reached ? tierPassedFill[status] : 'bg-line-2',\n // A hairline of the page's own ground, so two adjacent bands of\n // the same colour still read as two.\n index > 0 && 'border-l border-bg',\n )}\n />\n )\n })}\n\n {/* Where the value stands. A pale core inside a dark sheath, so the\n mark keeps its contrast over a filled band as well as over the\n empty road ahead. */}\n <span\n aria-hidden\n style={{ left: `${placeOf(at)}%` }}\n className=\"absolute top-1/2 h-2.5 w-1.5 -translate-x-1/2 -translate-y-1/2 rounded-full bg-bg ring-2 ring-text\"\n />\n </div>\n\n {showLabels && (\n <div aria-hidden className=\"relative h-4\">\n {ordered.map((tier, index) => {\n const place = placeOf(tier.min)\n return (\n <span\n key={tier.key}\n style={{\n left: `${place}%`,\n // The first label would hang off the left edge and the last\n // off the right, so the ends align to their edge and\n // everything between is centred on its boundary.\n transform:\n index === 0\n ? 'none'\n : index === ordered.length - 1 && place >= 100\n ? 'translateX(-100%)'\n : 'translateX(-50%)',\n }}\n className={cn(\n 'absolute top-0 text-2xs',\n value >= tier.min ? 'font-medium text-dim' : 'text-faint',\n )}\n >\n {tier.label}\n </span>\n )\n })}\n </div>\n )}\n </div>\n )\n}\n\nexport interface AxisBarProps extends Omit<HTMLAttributes<HTMLDivElement>, 'onChange'> {\n /** What this axis is called. */\n label: string\n /** The highest mark; one segment is drawn per whole point. */\n scale: number\n /** The mark, or `undefined` while the axis is unjudged. Not the same as\n * zero: zero is a verdict, and a blank axis is the absence of one. */\n value?: number\n /** Given, the row becomes a control. Omitted, it is a read-only reading of a\n * score someone else set. */\n onChange?: (value: number | undefined) => void\n /** The mark from which the total crosses into the next band, and what to\n * call it. Absent when no mark on this axis gets there - and then nothing is\n * drawn, because a line promising a band the axis cannot deliver is worse\n * than no line. */\n threshold?: { mark: number; label: string }\n /** What to say instead of the bare number - \"unjudged\", in the product's\n * word, when there is no mark. */\n valueText?: string\n}\n\n/**\n * One axis of a score, as a row of segments.\n *\n * Scoring is a judgement, not data entry: the useful question is \"is this a\n * seven or an eight\", and a row you click answers it in one movement where a\n * spin box asks you to read, aim and type. The filled length is also readable\n * down a column of axes, which numerals are not.\n *\n * When it takes `onChange` it reports itself as a slider rather than as a row\n * of buttons, so the arrow keys, Home and End work the way they do everywhere\n * else, and a reader hears one value in a range instead of ten unlabelled\n * buttons. The row owns the keyboard; the segments are pointer targets only,\n * or tabbing past one axis would take ten presses.\n *\n * Read-only, it is a `meter` and not a disabled slider: nothing here is\n * disabled, the number is simply a fact.\n */\nexport function AxisBar({\n label,\n scale,\n value,\n onChange,\n threshold,\n valueText,\n className,\n ...props\n}: AxisBarProps) {\n const marks = Math.max(1, Math.round(scale))\n const interactive = onChange !== undefined\n\n const step = (delta: number) => {\n if (!onChange) return\n // An unjudged axis steps onto the first or last mark rather than through\n // zero: zero is a verdict of its own, and arrowing into it by accident\n // would be one.\n const next = value === undefined ? (delta > 0 ? 1 : marks) : value + delta\n onChange(Math.min(Math.max(next, 0), marks))\n }\n\n const onKeyDown = (event: KeyboardEvent) => {\n if (!onChange) 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 onChange(0)\n break\n case 'End':\n event.preventDefault()\n onChange(marks)\n break\n // Back to unjudged, which no arrow key can reach - and which is not the\n // same as scoring the axis zero.\n case 'Backspace':\n case 'Delete':\n event.preventDefault()\n onChange(undefined)\n break\n default:\n break\n }\n }\n\n return (\n <div\n role={interactive ? 'slider' : 'meter'}\n tabIndex={interactive ? 0 : undefined}\n aria-label={label}\n aria-valuemin={0}\n aria-valuemax={marks}\n aria-valuenow={value}\n aria-valuetext={valueText}\n onKeyDown={interactive ? onKeyDown : undefined}\n className={cn(\n 'flex gap-hair rounded-md focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent',\n className,\n )}\n {...props}\n >\n {Array.from({ length: marks }, (_, index) => {\n const mark = index + 1\n const filled = value !== undefined && mark <= value\n // The first mark that would carry the total over. The ring goes around\n // the segment rather than on its leading edge: a rule beside the last\n // mark reads as the end of the scale, which is exactly when the\n // threshold matters most.\n const crosses = threshold !== undefined && threshold.mark === mark\n\n const shared = cn(\n 'relative h-5 flex-1 rounded-sm transition-colors',\n filled ? 'bg-accent' : 'bg-soft',\n crosses && 'ring-2 ring-inset ring-good',\n )\n\n return interactive ? (\n // A `<span>` and not a `<button>`, which is what the donor used and\n // what the accessibility gate rejected: a focusable control inside a\n // `role=\"slider\"` is a nested interactive element, and `tabindex=-1`\n // with `aria-hidden` does not undo that - assistive technology can\n // still land on it, and WCAG 4.1.2 says so.\n //\n // Nothing is lost. The row already owns the keyboard, so the segment\n // was never a keyboard target; it is a pointer target, which a span\n // with a click handler is.\n <span\n key={mark}\n aria-hidden\n // Clicking the mark already set clears the axis, which is the only\n // way back to unjudged with a pointer.\n onClick={() => onChange?.(value === mark ? undefined : mark)}\n title={crosses ? threshold.label : undefined}\n className={cn(shared, 'cursor-pointer', filled ? 'hover:bg-accent-2' : 'hover:bg-line-2')}\n />\n ) : (\n <span key={mark} aria-hidden className={shared} />\n )\n })}\n </div>\n )\n}\n"
|
|
1813
1813
|
}
|
|
1814
1814
|
]
|
|
1815
1815
|
},
|
|
@@ -1819,7 +1819,7 @@
|
|
|
1819
1819
|
"title": "Time-field",
|
|
1820
1820
|
"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`.",
|
|
1821
1821
|
"dependencies": [
|
|
1822
|
-
"dowel-ui@^0.
|
|
1822
|
+
"dowel-ui@^0.28.0"
|
|
1823
1823
|
],
|
|
1824
1824
|
"registryDependencies": [
|
|
1825
1825
|
"https://lacodda.github.io/dowel/r/input.json"
|
|
@@ -1829,7 +1829,7 @@
|
|
|
1829
1829
|
"path": "ui/time-field.tsx",
|
|
1830
1830
|
"target": "@ui/time-field.tsx",
|
|
1831
1831
|
"type": "registry:ui",
|
|
1832
|
-
"content": "import { useState, type Ref } from 'react'\nimport { cn } from 'dowel-ui'\nimport { fieldClasses } from './input'\n\n/*\n * TimeField - a time of day, typed the way people say it.\n *\n * No donor for this one: neither product of the line had a time field, so\n * this is written from the same shape as DurationField, and for the same\n * reason. Anything a person plausibly types is accepted - `9`, `9:30`, `930`,\n * `9.30`, `9pm`, `21:30` - and what comes back is always `HH:MM`.\n *\n * The value is `HH:MM` in twenty-four hours, always, no matter how it was\n * typed or how it is shown. That is what a database column holds and what\n * sorts correctly as a string; whether the reader sees `9:30 PM` or `21:30`\n * is a matter of where they live, and `Intl` answers it.\n *\n * Not `<input type=\"time\">`, and the reason is the same as NumberField's: the\n * browser draws its own control, its own spinner and its own clock popup,\n * none of which a stylesheet reaches - so a form of the product's own fields\n * gets one that is visibly not.\n *\n * Empty is `null`, like the other fields here: no time is not midnight.\n */\n\n/** Minutes since midnight from whatever was typed, or `null` for empty, or\n * `undefined` when it cannot be read as a time.\n *\n * Exported because the parsing is the component - a test that types into the\n * box checks React's state handling, and what has to be right is this. */\nexport function parseTime(text: string): string | null | undefined {\n const input = text.trim().toLowerCase().replace(/\\s+/g, '')\n if (input === '') return null\n\n // `pm` means add twelve hours, `am` means midnight is 12. Stripped first so\n // the rest of the parsing does not have to know about them.\n const meridiem = /(am|pm)$/.exec(input)?.[1]\n const body = meridiem ? input.slice(0, -2) : input\n\n let hours: number\n let minutes: number\n\n const separated = /^(\\d{1,2})[:.](\\d{2})$/.exec(body)\n if (separated) {\n hours = Number(separated[1])\n minutes = Number(separated[2])\n } else if (/^\\d{1,2}$/.test(body)) {\n // A bare number is an hour: `9` is nine o'clock, not nine minutes past\n // midnight - which is what someone typing a time means.\n hours = Number(body)\n minutes = 0\n } else if (/^\\d{3,4}$/.test(body)) {\n // `930` and `0930`, which is how a time gets typed when the colon is a\n // reach on a phone keyboard.\n hours = Number(body.slice(0, body.length - 2))\n minutes = Number(body.slice(-2))\n } else {\n return undefined\n }\n\n if (minutes > 59) return undefined\n\n if (meridiem) {\n if (hours < 1 || hours > 12) return undefined\n if (meridiem === 'pm' && hours !== 12) hours += 12\n if (meridiem === 'am' && hours === 12) hours = 0\n } else if (hours > 23) {\n return undefined\n }\n\n return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}`\n}\n\n/** How a time reads here: `21:30` in most of the world, `9:30 PM` in some of\n * it. The stored value does not change - only what is shown. */\nexport function formatTime(time: string, locale?: string): string {\n const [hours, minutes] = time.split(':').map(Number) as [number, number]\n return new Intl.DateTimeFormat(locale, { hour: 'numeric', minute: '2-digit' }).format(\n new Date(2024, 0, 1, hours, minutes),\n )\n}\n\nexport interface TimeFieldProps {\n /** `HH:MM` in twenty-four hours, or `null` for empty. */\n value: string | null\n onValueChange: (value: string | null) => void\n /** How the time is shown while the field is not being typed into. The\n * reader's own unless stated. */\n locale?: 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 TimeField({\n value,\n onValueChange,\n locale,\n className,\n ref,\n ...props\n}: TimeFieldProps) {\n const display = (time: string | null) => (time === null ? '' : formatTime(time, locale))\n\n /* Text while it is being typed, a formatted time the rest of the time -\n * the same arrangement as DurationField, and for the same reason: a field\n * that reformats on every keystroke fights the person using it. */\n const [text, setText] = useState(() => display(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. Comparing against `value` would be wrong in exactly\n * the case that matters: after a commit the parent may still hold the old\n * one for a tick, and the box would clear itself under the reader. */\n const [seen, setSeen] = useState<string | null>(value)\n\n if (value !== seen) {\n setSeen(value)\n if (!editing) setText(display(value))\n }\n\n const commit = () => {\n setEditing(false)\n const parsed = parseTime(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(display(value))\n return\n }\n setText(display(parsed))\n if (parsed !== value) onValueChange(parsed)\n }\n\n return (\n <input\n {...props}\n ref={ref}\n type=\"text\"\n inputMode=\"numeric\"\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-
|
|
1832
|
+
"content": "import { useState, type Ref } from 'react'\nimport { cn } from 'dowel-ui'\nimport { fieldClasses } from './input'\n\n/*\n * TimeField - a time of day, typed the way people say it.\n *\n * No donor for this one: neither product of the line had a time field, so\n * this is written from the same shape as DurationField, and for the same\n * reason. Anything a person plausibly types is accepted - `9`, `9:30`, `930`,\n * `9.30`, `9pm`, `21:30` - and what comes back is always `HH:MM`.\n *\n * The value is `HH:MM` in twenty-four hours, always, no matter how it was\n * typed or how it is shown. That is what a database column holds and what\n * sorts correctly as a string; whether the reader sees `9:30 PM` or `21:30`\n * is a matter of where they live, and `Intl` answers it.\n *\n * Not `<input type=\"time\">`, and the reason is the same as NumberField's: the\n * browser draws its own control, its own spinner and its own clock popup,\n * none of which a stylesheet reaches - so a form of the product's own fields\n * gets one that is visibly not.\n *\n * Empty is `null`, like the other fields here: no time is not midnight.\n */\n\n/** Minutes since midnight from whatever was typed, or `null` for empty, or\n * `undefined` when it cannot be read as a time.\n *\n * Exported because the parsing is the component - a test that types into the\n * box checks React's state handling, and what has to be right is this. */\nexport function parseTime(text: string): string | null | undefined {\n const input = text.trim().toLowerCase().replace(/\\s+/g, '')\n if (input === '') return null\n\n // `pm` means add twelve hours, `am` means midnight is 12. Stripped first so\n // the rest of the parsing does not have to know about them.\n const meridiem = /(am|pm)$/.exec(input)?.[1]\n const body = meridiem ? input.slice(0, -2) : input\n\n let hours: number\n let minutes: number\n\n const separated = /^(\\d{1,2})[:.](\\d{2})$/.exec(body)\n if (separated) {\n hours = Number(separated[1])\n minutes = Number(separated[2])\n } else if (/^\\d{1,2}$/.test(body)) {\n // A bare number is an hour: `9` is nine o'clock, not nine minutes past\n // midnight - which is what someone typing a time means.\n hours = Number(body)\n minutes = 0\n } else if (/^\\d{3,4}$/.test(body)) {\n // `930` and `0930`, which is how a time gets typed when the colon is a\n // reach on a phone keyboard.\n hours = Number(body.slice(0, body.length - 2))\n minutes = Number(body.slice(-2))\n } else {\n return undefined\n }\n\n if (minutes > 59) return undefined\n\n if (meridiem) {\n if (hours < 1 || hours > 12) return undefined\n if (meridiem === 'pm' && hours !== 12) hours += 12\n if (meridiem === 'am' && hours === 12) hours = 0\n } else if (hours > 23) {\n return undefined\n }\n\n return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}`\n}\n\n/** How a time reads here: `21:30` in most of the world, `9:30 PM` in some of\n * it. The stored value does not change - only what is shown. */\nexport function formatTime(time: string, locale?: string): string {\n const [hours, minutes] = time.split(':').map(Number) as [number, number]\n return new Intl.DateTimeFormat(locale, { hour: 'numeric', minute: '2-digit' }).format(\n new Date(2024, 0, 1, hours, minutes),\n )\n}\n\nexport interface TimeFieldProps {\n /** `HH:MM` in twenty-four hours, or `null` for empty. */\n value: string | null\n onValueChange: (value: string | null) => void\n /** How the time is shown while the field is not being typed into. The\n * reader's own unless stated. */\n locale?: 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 TimeField({\n value,\n onValueChange,\n locale,\n className,\n ref,\n ...props\n}: TimeFieldProps) {\n const display = (time: string | null) => (time === null ? '' : formatTime(time, locale))\n\n /* Text while it is being typed, a formatted time the rest of the time -\n * the same arrangement as DurationField, and for the same reason: a field\n * that reformats on every keystroke fights the person using it. */\n const [text, setText] = useState(() => display(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. Comparing against `value` would be wrong in exactly\n * the case that matters: after a commit the parent may still hold the old\n * one for a tick, and the box would clear itself under the reader. */\n const [seen, setSeen] = useState<string | null>(value)\n\n if (value !== seen) {\n setSeen(value)\n if (!editing) setText(display(value))\n }\n\n const commit = () => {\n setEditing(false)\n const parsed = parseTime(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(display(value))\n return\n }\n setText(display(parsed))\n if (parsed !== value) onValueChange(parsed)\n }\n\n return (\n <input\n {...props}\n ref={ref}\n type=\"text\"\n inputMode=\"numeric\"\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-control tabular-nums', className)}\n />\n )\n}\n"
|
|
1833
1833
|
}
|
|
1834
1834
|
]
|
|
1835
1835
|
},
|
|
@@ -1840,7 +1840,7 @@
|
|
|
1840
1840
|
"description": "What happened, in the order it happened: a release history, an audit trail, the steps a job went through. Four products of the line draw one, and all four drew it the same way - a list with a border on the left and a dot positioned over it by hand.",
|
|
1841
1841
|
"dependencies": [
|
|
1842
1842
|
"class-variance-authority",
|
|
1843
|
-
"dowel-ui@^0.
|
|
1843
|
+
"dowel-ui@^0.28.0"
|
|
1844
1844
|
],
|
|
1845
1845
|
"registryDependencies": [],
|
|
1846
1846
|
"files": [
|
|
@@ -1860,7 +1860,7 @@
|
|
|
1860
1860
|
"dependencies": [
|
|
1861
1861
|
"@base-ui/react",
|
|
1862
1862
|
"class-variance-authority",
|
|
1863
|
-
"dowel-ui@^0.
|
|
1863
|
+
"dowel-ui@^0.28.0"
|
|
1864
1864
|
],
|
|
1865
1865
|
"registryDependencies": [],
|
|
1866
1866
|
"files": [
|
|
@@ -1880,7 +1880,7 @@
|
|
|
1880
1880
|
"dependencies": [
|
|
1881
1881
|
"@base-ui/react",
|
|
1882
1882
|
"class-variance-authority",
|
|
1883
|
-
"dowel-ui@^0.
|
|
1883
|
+
"dowel-ui@^0.28.0"
|
|
1884
1884
|
],
|
|
1885
1885
|
"registryDependencies": [],
|
|
1886
1886
|
"files": [
|
|
@@ -1888,7 +1888,7 @@
|
|
|
1888
1888
|
"path": "ui/tooltip.tsx",
|
|
1889
1889
|
"target": "@ui/tooltip.tsx",
|
|
1890
1890
|
"type": "registry:ui",
|
|
1891
|
-
"content": "import { Tooltip as Base } from '@base-ui/react/tooltip'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { cn } from 'dowel-ui'\n\n/*\n * Tooltip.\n *\n * A label for a control that has no room for one - an icon button, a truncated\n * cell, a symbol whose meaning is not obvious. A phrase, not a panel.\n *\n * The line between this and Popover is not size, it is whether anything inside\n * can be reached. A tooltip holds no links, no buttons and no fields, because\n * there is no way to get into it: it is tied to a trigger that is hovered or\n * focused, and it disappears the moment attention moves. Content that has to\n * be clicked belongs in a Popover, whatever its length.\n *\n * The half everyone forgets is the keyboard. A tooltip that only opens on\n * hover is invisible to anyone who tabs, which is precisely the person reading\n * an unlabelled icon button. Base UI opens it on focus as well, and `Escape`\n * dismisses it - so it is tested here rather than assumed.\n *\n * The thing to know before using it: this is a VISUAL label, and nothing else.\n * Base UI puts no `role=\"tooltip\"` on the popup and no `aria-describedby` on\n * the trigger, and that is deliberate rather than an oversight - a tooltip is\n * unreachable on a touch screen and unreliable for a screen reader, so\n * pretending otherwise would be worse than not trying. What follows from that\n * is a rule, not a suggestion: THE TRIGGER MUST CARRY ITS OWN `aria-label`,\n * saying roughly what the tooltip says. The tooltip helps a sighted mouse or\n * keyboard user; the `aria-label` is what everybody else gets.\n *\n * So if the words are load-bearing - if not reading them means not\n * understanding the control - this is the wrong component. Put them inline, or\n * in a Popover with `openOnHover` on the trigger, which touch and screen\n * readers can actually reach.\n *\n * `Provider` is optional and shared: once one tooltip in a group has opened,\n * the next opens instantly instead of waiting out its delay again. A toolbar\n * of icon buttons without it feels broken in a way nobody can name.\n */\n\nexport const tooltipPopupVariants = cva(\n [\n 'rounded-md border border-line bg-raise px-2 py-1 text-xs text-text shadow-raise',\n 'select-none',\n // The enter and the leave. `duration-*` reads the token directly because\n // Tailwind's own utility takes a literal number.\n '[transition:opacity_var(--duration-quick)_var(--ease-out),transform_var(--duration-quick)_var(--ease-out)]',\n 'data-[closed]:scale-[0.96] data-[closed]:opacity-0',\n 'data-[starting-style]:scale-[0.96] data-[starting-style]:opacity-0',\n // Grow out of the edge it is anchored to rather than out of its own\n // middle, so the motion points back at the trigger.\n 'origin-[var(--transform-origin)]',\n ],\n {\n variants: {\n /*\n * Two, and no more. A tooltip is a phrase; the only real decision is\n * whether it is allowed to wrap.\n *\n * `wide` exists because the alternative people reach for is a Popover\n * that nothing can be clicked in, which loses the hover and focus\n * behaviour to gain a width.\n */\n size: {\n sm: 'max-w-[16rem]',\n wide: 'max-w-[24rem]',\n },\n },\n defaultVariants: { size: 'sm' },\n },\n)\n\n/** A shared delay for a group of tooltips. Wrap a toolbar in it and the second\n * icon button explains itself instantly rather than making the reader wait\n * again. Optional - a lone tooltip works without one. */\nexport const TooltipProvider = Base.Provider\n\n/** The root. Takes `disabled`, and the controlled `open`/`onOpenChange`.\n *\n * Note that `delay` is NOT here - it is a prop of the trigger, the same as on\n * PreviewCard. Passing it to the root is silently ignored at runtime, which is\n * exactly the kind of mistake only the type checker catches. */\nexport const Tooltip = Base.Root\n\n/** What it labels. Give it `render` to use your own button - and give that\n * button an `aria-label` saying what the tooltip says, because the tooltip\n * itself reaches nobody using a screen reader. */\nexport const TooltipTrigger = Base.Trigger\n\nexport interface TooltipPopupProps\n extends Base.Popup.Props,\n VariantProps<typeof tooltipPopupVariants> {\n /** Preferred side of the trigger. Base UI flips it when it does not fit,\n * and defaults it to the top. */\n side?: Base.Positioner.Props['side']\n /** Alignment along that side. Base UI centres it by default. */\n align?: Base.Positioner.Props['align']\n /** Distance from the trigger, in pixels. */\n sideOffset?: Base.Positioner.Props['sideOffset']\n /** Whether to draw the arrow pointing back at the trigger. */\n arrow?: boolean\n /** Where to portal to. Defaults to the document body, which is what keeps\n * the popup from being clipped by an ancestor. Pass an element to put it\n * somewhere else - inside an overlay that is already open, or into a\n * container being screenshotted. */\n container?: Base.Portal.Props['container']\n}\n\n/** The label itself. Portalled and positioned, so it is not clipped by an\n * ancestor with `overflow: hidden`.\n *\n * `--z-popup` rather than `--z-floating`: a tooltip labels whatever is on top\n * of it, including the contents of a popover or a dialog, so it has to be able\n * to sit above them. */\nexport function TooltipPopup({\n size,\n side,\n align,\n sideOffset = 6,\n arrow = true,\n container,\n className,\n children,\n ...props\n}: TooltipPopupProps) {\n return (\n <Base.Portal container={container}>\n <Base.Positioner\n side={side}\n align={align}\n sideOffset={sideOffset}\n className=\"[z-index:var(--z-popup)]\"\n >\n <Base.Popup className={cn(tooltipPopupVariants({ size }), className)} {...props}>\n {arrow ? <TooltipArrow /> : null}\n {children}\n </Base.Popup>\n </Base.Positioner>\n </Base.Portal>\n )\n}\n\n/** The notch pointing back at the trigger. Base UI rotates it to whatever side\n * the tooltip landed on, which is why the placement is keyed off `data-side`\n * rather than off the `side` that was asked for. */\nexport function TooltipArrow({ className, ...props }: Base.Arrow.Props) {\n return (\n <Base.Arrow\n className={cn(\n 'h-1.5 w-1.5 rotate-45 border border-line bg-raise',\n /* Two of the four borders are dropped per side, which Popover has done\n * all along and this did not.\n *\n * A square rotated 45 degrees shows two of its edges outside the\n * popup: the pair facing the trigger, which is the notch, and the\n * pair behind it, which is a stray line hanging off the far side. It\n * reads as a second arrow pointing the wrong way - and it is small\n * enough that it looked like a rendering artefact rather than a rule\n * nobody wrote. */\n 'data-[side=bottom]:-top-
|
|
1891
|
+
"content": "import { Tooltip as Base } from '@base-ui/react/tooltip'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { cn } from 'dowel-ui'\n\n/*\n * Tooltip.\n *\n * A label for a control that has no room for one - an icon button, a truncated\n * cell, a symbol whose meaning is not obvious. A phrase, not a panel.\n *\n * The line between this and Popover is not size, it is whether anything inside\n * can be reached. A tooltip holds no links, no buttons and no fields, because\n * there is no way to get into it: it is tied to a trigger that is hovered or\n * focused, and it disappears the moment attention moves. Content that has to\n * be clicked belongs in a Popover, whatever its length.\n *\n * The half everyone forgets is the keyboard. A tooltip that only opens on\n * hover is invisible to anyone who tabs, which is precisely the person reading\n * an unlabelled icon button. Base UI opens it on focus as well, and `Escape`\n * dismisses it - so it is tested here rather than assumed.\n *\n * The thing to know before using it: this is a VISUAL label, and nothing else.\n * Base UI puts no `role=\"tooltip\"` on the popup and no `aria-describedby` on\n * the trigger, and that is deliberate rather than an oversight - a tooltip is\n * unreachable on a touch screen and unreliable for a screen reader, so\n * pretending otherwise would be worse than not trying. What follows from that\n * is a rule, not a suggestion: THE TRIGGER MUST CARRY ITS OWN `aria-label`,\n * saying roughly what the tooltip says. The tooltip helps a sighted mouse or\n * keyboard user; the `aria-label` is what everybody else gets.\n *\n * So if the words are load-bearing - if not reading them means not\n * understanding the control - this is the wrong component. Put them inline, or\n * in a Popover with `openOnHover` on the trigger, which touch and screen\n * readers can actually reach.\n *\n * `Provider` is optional and shared: once one tooltip in a group has opened,\n * the next opens instantly instead of waiting out its delay again. A toolbar\n * of icon buttons without it feels broken in a way nobody can name.\n */\n\nexport const tooltipPopupVariants = cva(\n [\n 'rounded-md border border-line bg-raise px-2 py-1 text-xs text-text shadow-raise',\n 'select-none',\n // The enter and the leave. `duration-*` reads the token directly because\n // Tailwind's own utility takes a literal number.\n '[transition:opacity_var(--duration-quick)_var(--ease-out),transform_var(--duration-quick)_var(--ease-out)]',\n 'data-[closed]:scale-[0.96] data-[closed]:opacity-0',\n 'data-[starting-style]:scale-[0.96] data-[starting-style]:opacity-0',\n // Grow out of the edge it is anchored to rather than out of its own\n // middle, so the motion points back at the trigger.\n 'origin-[var(--transform-origin)]',\n ],\n {\n variants: {\n /*\n * Two, and no more. A tooltip is a phrase; the only real decision is\n * whether it is allowed to wrap.\n *\n * `wide` exists because the alternative people reach for is a Popover\n * that nothing can be clicked in, which loses the hover and focus\n * behaviour to gain a width.\n */\n size: {\n sm: 'max-w-[16rem]',\n wide: 'max-w-[24rem]',\n },\n },\n defaultVariants: { size: 'sm' },\n },\n)\n\n/** A shared delay for a group of tooltips. Wrap a toolbar in it and the second\n * icon button explains itself instantly rather than making the reader wait\n * again. Optional - a lone tooltip works without one. */\nexport const TooltipProvider = Base.Provider\n\n/** The root. Takes `disabled`, and the controlled `open`/`onOpenChange`.\n *\n * Note that `delay` is NOT here - it is a prop of the trigger, the same as on\n * PreviewCard. Passing it to the root is silently ignored at runtime, which is\n * exactly the kind of mistake only the type checker catches. */\nexport const Tooltip = Base.Root\n\n/** What it labels. Give it `render` to use your own button - and give that\n * button an `aria-label` saying what the tooltip says, because the tooltip\n * itself reaches nobody using a screen reader. */\nexport const TooltipTrigger = Base.Trigger\n\nexport interface TooltipPopupProps\n extends Base.Popup.Props,\n VariantProps<typeof tooltipPopupVariants> {\n /** Preferred side of the trigger. Base UI flips it when it does not fit,\n * and defaults it to the top. */\n side?: Base.Positioner.Props['side']\n /** Alignment along that side. Base UI centres it by default. */\n align?: Base.Positioner.Props['align']\n /** Distance from the trigger, in pixels. */\n sideOffset?: Base.Positioner.Props['sideOffset']\n /** Whether to draw the arrow pointing back at the trigger. */\n arrow?: boolean\n /** Where to portal to. Defaults to the document body, which is what keeps\n * the popup from being clipped by an ancestor. Pass an element to put it\n * somewhere else - inside an overlay that is already open, or into a\n * container being screenshotted. */\n container?: Base.Portal.Props['container']\n}\n\n/** The label itself. Portalled and positioned, so it is not clipped by an\n * ancestor with `overflow: hidden`.\n *\n * `--z-popup` rather than `--z-floating`: a tooltip labels whatever is on top\n * of it, including the contents of a popover or a dialog, so it has to be able\n * to sit above them. */\nexport function TooltipPopup({\n size,\n side,\n align,\n sideOffset = 6,\n arrow = true,\n container,\n className,\n children,\n ...props\n}: TooltipPopupProps) {\n return (\n <Base.Portal container={container}>\n <Base.Positioner\n side={side}\n align={align}\n sideOffset={sideOffset}\n className=\"[z-index:var(--z-popup)]\"\n >\n <Base.Popup className={cn(tooltipPopupVariants({ size }), className)} {...props}>\n {arrow ? <TooltipArrow /> : null}\n {children}\n </Base.Popup>\n </Base.Positioner>\n </Base.Portal>\n )\n}\n\n/** The notch pointing back at the trigger. Base UI rotates it to whatever side\n * the tooltip landed on, which is why the placement is keyed off `data-side`\n * rather than off the `side` that was asked for. */\nexport function TooltipArrow({ className, ...props }: Base.Arrow.Props) {\n return (\n <Base.Arrow\n className={cn(\n 'h-1.5 w-1.5 rotate-45 border border-line bg-raise',\n /* Two of the four borders are dropped per side, which Popover has done\n * all along and this did not.\n *\n * A square rotated 45 degrees shows two of its edges outside the\n * popup: the pair facing the trigger, which is the notch, and the\n * pair behind it, which is a stray line hanging off the far side. It\n * reads as a second arrow pointing the wrong way - and it is small\n * enough that it looked like a rendering artefact rather than a rule\n * nobody wrote. */\n 'data-[side=bottom]:-top-hair data-[side=bottom]:border-r-0 data-[side=bottom]:border-b-0',\n 'data-[side=top]:-bottom-hair data-[side=top]:border-t-0 data-[side=top]:border-l-0',\n 'data-[side=left]:-right-hair data-[side=left]:border-b-0 data-[side=left]:border-l-0',\n 'data-[side=right]:-left-hair data-[side=right]:border-r-0 data-[side=right]:border-t-0',\n className,\n )}\n {...props}\n />\n )\n}\n"
|
|
1892
1892
|
}
|
|
1893
1893
|
]
|
|
1894
1894
|
},
|
|
@@ -1915,7 +1915,7 @@
|
|
|
1915
1915
|
"description": "Two products had written this independently and arrived at the same construction - a rounded track, segments positioned absolutely by percent, a floor under the segment width so a short one does not vanish - differing only in what a segment meant. One drew the tiers of a rubric with the score standing among them; the other drew a working day as alternating work and breaks. Neither could be built from the other, and each knew something the other did not: the tiers had the marker and the three-state reading of a segment (passed, standing in, still ahead), the day had the minimum width and the difference between an empty track and an unknown one.",
|
|
1916
1916
|
"dependencies": [
|
|
1917
1917
|
"class-variance-authority",
|
|
1918
|
-
"dowel-ui@^0.
|
|
1918
|
+
"dowel-ui@^0.28.0"
|
|
1919
1919
|
],
|
|
1920
1920
|
"registryDependencies": [
|
|
1921
1921
|
"https://lacodda.github.io/dowel/r/track-segments.json"
|
|
@@ -1925,7 +1925,7 @@
|
|
|
1925
1925
|
"path": "ui/track.tsx",
|
|
1926
1926
|
"target": "@ui/track.tsx",
|
|
1927
1927
|
"type": "registry:ui",
|
|
1928
|
-
"content": "import type { HTMLAttributes, ReactNode } from 'react'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { cn } from 'dowel-ui'\nimport { markerAt, place, type SegmentInput } from './track-segments'\n\n/*\n * A bar divided into stretches, with something standing somewhere along it.\n *\n * Two products had written this independently and arrived at the same\n * construction - a rounded track, segments positioned absolutely by percent, a\n * floor under the segment width so a short one does not vanish - differing\n * only in what a segment meant. One drew the tiers of a rubric with the score\n * standing among them; the other drew a working day as alternating work and\n * breaks. Neither could be built from the other, and each knew something the\n * other did not: the tiers had the marker and the three-state reading of a\n * segment (passed, standing in, still ahead), the day had the minimum width\n * and the difference between an empty track and an unknown one.\n *\n * So it is one component, and the two are its two shapes:\n *\n * **spans** - stretches of a whole, each meaning something in its own right:\n * work and breaks, phases, occupancy. Adjacent or separated; gaps are the\n * track showing through.\n *\n * **thresholds** - a scale cut into bands, with a position on it. Here the\n * segments are contiguous by construction and the point is not the bands but\n * where you stand among them: \"nearly a clip\" is what the reader wants, and\n * a badge saying which band you are in cannot say it.\n *\n * The arithmetic is in `track-segments`, importable without React.\n *\n * What this does NOT do is own its own height in pixels, and that is\n * deliberate: it is `h-1.5` in one donor and `h-2.5` in the other because the\n * bar carries different weight on the two screens. What it does own is the\n * geometry inside itself - percentages of its own box, never of a parent's -\n * which is the part that broke when a consumer put a percentage-height chart\n * inside a flex row and every bar resolved to zero.\n */\n\nexport const trackVariants = cva('relative w-full overflow-hidden rounded-full', {\n variants: {\n size: {\n /* Beside text, where the bar is a detail of a line. */\n sm: 'h-1.5',\n /* On its own row, where the bar is the thing being read. */\n md: 'h-2.5',\n },\n },\n defaultVariants: { size: 'md' },\n})\n\nexport const trackSegmentVariants = cva('absolute top-0 h-full', {\n variants: {\n tone: {\n /* The subject: work done, the band you are standing in. */\n accent: 'bg-accent',\n /* Behind you, or secondary: a band already passed. Dimmed so the\n * current one carries the eye - a flat wash of \"reached\" over half the\n * bar says only that you are not at zero. */\n past: 'bg-accent/40',\n /* Ahead, or simply not the subject: a break, a band not yet reached. */\n idle: 'bg-line-2',\n /* Status, for a stretch that is itself a state rather than a quantity. */\n good: 'bg-good',\n warn: 'bg-warn',\n bad: 'bg-bad',\n },\n },\n defaultVariants: { tone: 'accent' },\n})\n\nexport type TrackTone = NonNullable<VariantProps<typeof trackSegmentVariants>['tone']>\n\nexport interface TrackSegment extends SegmentInput {\n /** Distinguishes this segment from its neighbours in the DOM. */\n key: string\n tone?: TrackTone\n /** What this stretch is, in words. Shown on hover, and the only place the\n * segment's meaning exists for a reader who cannot see the colours. */\n label?: string\n}\n\nexport interface TrackProps\n extends Omit<HTMLAttributes<HTMLDivElement>, 'children'>,\n VariantProps<typeof trackVariants> {\n segments: TrackSegment[]\n /** The value the left edge stands for. Defaults to the earliest segment. */\n from?: number\n /** The value the right edge stands for. Defaults to the latest segment. */\n to?: number\n /** Where the position marker stands, on the same scale as the segments.\n * Omitted when there is no such thing - a day of work has no \"you are here\". */\n marker?: number\n /** What the whole bar says, for a reader who cannot see it. Required: the\n * segments are decoration to a screen reader, and their titles are not\n * announced in order. */\n label: string\n /** Override the floor under a segment's width - `0` draws everything exactly\n * to scale.\n *\n * The floor is right for a day of work, where a short break is a fact worth\n * seeing. It is wrong wherever the widths are being compared to each other,\n * because a widened segment is no longer to scale and a reader measuring by\n * eye would be measuring the floor. */\n minWidth?: number\n /** Separate adjacent segments with a hairline of the ground.\n *\n * On for thresholds, where the bands touch and the boundary between two\n * reached ones would otherwise be invisible. Off for spans, where a gap in\n * the data is meant to look different from a gap between two stretches. */\n divided?: boolean\n}\n\nexport function Track({\n segments,\n from,\n to,\n marker,\n label,\n minWidth,\n divided = false,\n size,\n className,\n ...props\n}: TrackProps) {\n const placed = place(segments, { from, to, minWidth })\n\n /* The marker rides the same scale as the segments, so it is resolved against\n * the same bounds rather than against its own reading of them. */\n const bounds = {\n from: from ?? Math.min(...segments.map((s) => s.start), Infinity),\n to: to ?? Math.max(...segments.map((s) => s.end), -Infinity),\n }\n const at = marker === undefined ? null : markerAt(marker, bounds.from, bounds.to)\n\n return (\n <div\n role=\"img\"\n aria-label={label}\n className={cn(trackVariants({ size }), 'bg-soft', className)}\n {...props}\n >\n {placed.map((geometry, index) => {\n const segment = segments[index]!\n return (\n <span\n key={segment.key}\n title={segment.label}\n style={{ left: `${geometry.left}%`, width: `${geometry.width}%` }}\n className={cn(\n trackSegmentVariants({ tone: segment.tone }),\n // The hairline is drawn in the page's own ground rather than in\n // a border colour, so it reads as a cut between two fills\n // instead of a third colour of its own.\n divided && index > 0 && 'border-l border-bg',\n )}\n />\n )\n })}\n\n {at !== null && (\n /* A dark core inside a light sheath, so the mark keeps its contrast\n * over the accent band it usually stands on as well as over the empty\n * road ahead. Centred on its position rather than starting at it: the\n * mark says \"here\", and a mark whose left edge is the position reads\n * as half a step further along than it is. */\n <span\n style={{ left: `${at}%` }}\n className=\"absolute top-1/2 h-
|
|
1928
|
+
"content": "import type { HTMLAttributes, ReactNode } from 'react'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { cn } from 'dowel-ui'\nimport { markerAt, place, type SegmentInput } from './track-segments'\n\n/*\n * A bar divided into stretches, with something standing somewhere along it.\n *\n * Two products had written this independently and arrived at the same\n * construction - a rounded track, segments positioned absolutely by percent, a\n * floor under the segment width so a short one does not vanish - differing\n * only in what a segment meant. One drew the tiers of a rubric with the score\n * standing among them; the other drew a working day as alternating work and\n * breaks. Neither could be built from the other, and each knew something the\n * other did not: the tiers had the marker and the three-state reading of a\n * segment (passed, standing in, still ahead), the day had the minimum width\n * and the difference between an empty track and an unknown one.\n *\n * So it is one component, and the two are its two shapes:\n *\n * **spans** - stretches of a whole, each meaning something in its own right:\n * work and breaks, phases, occupancy. Adjacent or separated; gaps are the\n * track showing through.\n *\n * **thresholds** - a scale cut into bands, with a position on it. Here the\n * segments are contiguous by construction and the point is not the bands but\n * where you stand among them: \"nearly a clip\" is what the reader wants, and\n * a badge saying which band you are in cannot say it.\n *\n * The arithmetic is in `track-segments`, importable without React.\n *\n * What this does NOT do is own its own height in pixels, and that is\n * deliberate: it is `h-1.5` in one donor and `h-2.5` in the other because the\n * bar carries different weight on the two screens. What it does own is the\n * geometry inside itself - percentages of its own box, never of a parent's -\n * which is the part that broke when a consumer put a percentage-height chart\n * inside a flex row and every bar resolved to zero.\n */\n\nexport const trackVariants = cva('relative w-full overflow-hidden rounded-full', {\n variants: {\n size: {\n /* Beside text, where the bar is a detail of a line. */\n sm: 'h-1.5',\n /* On its own row, where the bar is the thing being read. */\n md: 'h-2.5',\n },\n },\n defaultVariants: { size: 'md' },\n})\n\nexport const trackSegmentVariants = cva('absolute top-0 h-full', {\n variants: {\n tone: {\n /* The subject: work done, the band you are standing in. */\n accent: 'bg-accent',\n /* Behind you, or secondary: a band already passed. Dimmed so the\n * current one carries the eye - a flat wash of \"reached\" over half the\n * bar says only that you are not at zero. */\n past: 'bg-accent/40',\n /* Ahead, or simply not the subject: a break, a band not yet reached. */\n idle: 'bg-line-2',\n /* Status, for a stretch that is itself a state rather than a quantity. */\n good: 'bg-good',\n warn: 'bg-warn',\n bad: 'bg-bad',\n },\n },\n defaultVariants: { tone: 'accent' },\n})\n\nexport type TrackTone = NonNullable<VariantProps<typeof trackSegmentVariants>['tone']>\n\nexport interface TrackSegment extends SegmentInput {\n /** Distinguishes this segment from its neighbours in the DOM. */\n key: string\n tone?: TrackTone\n /** What this stretch is, in words. Shown on hover, and the only place the\n * segment's meaning exists for a reader who cannot see the colours. */\n label?: string\n}\n\nexport interface TrackProps\n extends Omit<HTMLAttributes<HTMLDivElement>, 'children'>,\n VariantProps<typeof trackVariants> {\n segments: TrackSegment[]\n /** The value the left edge stands for. Defaults to the earliest segment. */\n from?: number\n /** The value the right edge stands for. Defaults to the latest segment. */\n to?: number\n /** Where the position marker stands, on the same scale as the segments.\n * Omitted when there is no such thing - a day of work has no \"you are here\". */\n marker?: number\n /** What the whole bar says, for a reader who cannot see it. Required: the\n * segments are decoration to a screen reader, and their titles are not\n * announced in order. */\n label: string\n /** Override the floor under a segment's width - `0` draws everything exactly\n * to scale.\n *\n * The floor is right for a day of work, where a short break is a fact worth\n * seeing. It is wrong wherever the widths are being compared to each other,\n * because a widened segment is no longer to scale and a reader measuring by\n * eye would be measuring the floor. */\n minWidth?: number\n /** Separate adjacent segments with a hairline of the ground.\n *\n * On for thresholds, where the bands touch and the boundary between two\n * reached ones would otherwise be invisible. Off for spans, where a gap in\n * the data is meant to look different from a gap between two stretches. */\n divided?: boolean\n}\n\nexport function Track({\n segments,\n from,\n to,\n marker,\n label,\n minWidth,\n divided = false,\n size,\n className,\n ...props\n}: TrackProps) {\n const placed = place(segments, { from, to, minWidth })\n\n /* The marker rides the same scale as the segments, so it is resolved against\n * the same bounds rather than against its own reading of them. */\n const bounds = {\n from: from ?? Math.min(...segments.map((s) => s.start), Infinity),\n to: to ?? Math.max(...segments.map((s) => s.end), -Infinity),\n }\n const at = marker === undefined ? null : markerAt(marker, bounds.from, bounds.to)\n\n return (\n <div\n role=\"img\"\n aria-label={label}\n className={cn(trackVariants({ size }), 'bg-soft', className)}\n {...props}\n >\n {placed.map((geometry, index) => {\n const segment = segments[index]!\n return (\n <span\n key={segment.key}\n title={segment.label}\n style={{ left: `${geometry.left}%`, width: `${geometry.width}%` }}\n className={cn(\n trackSegmentVariants({ tone: segment.tone }),\n // The hairline is drawn in the page's own ground rather than in\n // a border colour, so it reads as a cut between two fills\n // instead of a third colour of its own.\n divided && index > 0 && 'border-l border-bg',\n )}\n />\n )\n })}\n\n {at !== null && (\n /* A dark core inside a light sheath, so the mark keeps its contrast\n * over the accent band it usually stands on as well as over the empty\n * road ahead. Centred on its position rather than starting at it: the\n * mark says \"here\", and a mark whose left edge is the position reads\n * as half a step further along than it is. */\n <span\n style={{ left: `${at}%` }}\n className=\"absolute top-1/2 h-2.5 w-1.5 -translate-x-1/2 -translate-y-1/2 rounded-full bg-bg ring-2 ring-text\"\n />\n )}\n </div>\n )\n}\n\n/*\n * The labels under a track.\n *\n * Separate from the track because the two donors disagreed about whether there\n * are any - the day had none, the tiers had one per band - and because a caller\n * with three bands and a narrow column will want to drop them without giving\n * up the bar.\n */\nexport interface TrackScaleProps extends HTMLAttributes<HTMLDivElement> {\n children: ReactNode\n}\n\nexport function TrackScale({ className, ...props }: TrackScaleProps) {\n return (\n <div\n className={cn('flex justify-between text-[10px] text-faint', className)}\n {...props}\n />\n )\n}\n"
|
|
1929
1929
|
}
|
|
1930
1930
|
]
|
|
1931
1931
|
},
|
|
@@ -1951,7 +1951,7 @@
|
|
|
1951
1951
|
"title": "Tree-view",
|
|
1952
1952
|
"description": "The shape products reach for and then get wrong in the same place every time. A tree is not a nest of lists with click handlers - it is one control with a cursor in it, and the difference is the whole component:\n * **One tab stop, not one per node.** A tree of four hundred files with a `tabIndex` on each is four hundred stops between the sidebar and the editor. The container is what the keyboard reaches, and the arrows move a cursor inside it - the arrangement a `RadioGroup` has, for the same reason.",
|
|
1953
1953
|
"dependencies": [
|
|
1954
|
-
"dowel-ui@^0.
|
|
1954
|
+
"dowel-ui@^0.28.0"
|
|
1955
1955
|
],
|
|
1956
1956
|
"registryDependencies": [
|
|
1957
1957
|
"https://lacodda.github.io/dowel/r/tree-rows.json"
|
|
@@ -1971,7 +1971,7 @@
|
|
|
1971
1971
|
"title": "Truncate",
|
|
1972
1972
|
"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.",
|
|
1973
1973
|
"dependencies": [
|
|
1974
|
-
"dowel-ui@^0.
|
|
1974
|
+
"dowel-ui@^0.28.0"
|
|
1975
1975
|
],
|
|
1976
1976
|
"registryDependencies": [],
|
|
1977
1977
|
"files": [
|
|
@@ -1989,7 +1989,7 @@
|
|
|
1989
1989
|
"title": "Virtual-list",
|
|
1990
1990
|
"description": "The browser is fine with long lists until it is not: a hundred thousand `<div>`s is a layout the machine recomputes on every change, and the page stops responding while it does. What is drawn instead is the window the reader can actually see, held in place by a tall spacer, so the scrollbar still says how much there is.",
|
|
1991
1991
|
"dependencies": [
|
|
1992
|
-
"dowel-ui@^0.
|
|
1992
|
+
"dowel-ui@^0.28.0"
|
|
1993
1993
|
],
|
|
1994
1994
|
"registryDependencies": [],
|
|
1995
1995
|
"files": [
|
|
@@ -2008,7 +2008,7 @@
|
|
|
2008
2008
|
"description": "With `decorations: false` the system draws nothing, so everything it used to do is the page's: dragging the window by its title bar, double-click to maximise, the three buttons, and the edges you grab to resize. Each is small; the reason to take them on at all is that a system title bar over an application title bar costs a strip of every laptop screen for nothing.",
|
|
2009
2009
|
"dependencies": [
|
|
2010
2010
|
"@tauri-apps/api",
|
|
2011
|
-
"dowel-ui@^0.
|
|
2011
|
+
"dowel-ui@^0.28.0"
|
|
2012
2012
|
],
|
|
2013
2013
|
"registryDependencies": [],
|
|
2014
2014
|
"files": [
|