dowel-ui 0.32.0 → 0.33.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 -2
- package/dist/eslint/index.d.ts +2 -0
- package/dist/eslint/index.d.ts.map +1 -1
- package/dist/eslint/index.js +24 -4
- package/dist/eslint/index.js.map +1 -1
- package/dist/eslint/no-arbitrary-scale.d.ts +20 -0
- package/dist/eslint/no-arbitrary-scale.d.ts.map +1 -0
- package/dist/eslint/no-arbitrary-scale.js +261 -0
- package/dist/eslint/no-arbitrary-scale.js.map +1 -0
- package/dist/eslint/no-raw-button.d.ts +3 -0
- package/dist/eslint/no-raw-button.d.ts.map +1 -0
- package/dist/eslint/no-raw-button.js +51 -0
- package/dist/eslint/no-raw-button.js.map +1 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/palettes/atlas.json +1 -1
- package/dist/palettes/austeris.json +1 -1
- package/dist/palettes/dowel.json +1 -1
- package/dist/palettes/efema.json +1 -1
- package/dist/palettes/furca.json +1 -1
- package/dist/palettes/hilvan.json +1 -1
- package/dist/palettes/kasl-server.json +1 -1
- package/dist/palettes/kasl.json +1 -1
- package/dist/palettes/kilna.json +1 -1
- package/dist/palettes/lyrid.json +1 -1
- package/dist/palettes/lyrn.json +1 -1
- package/dist/palettes/midda.json +1 -1
- package/dist/palettes/nitid.json +1 -1
- package/dist/palettes/nooma.json +1 -1
- package/dist/palettes/rhapsod.json +1 -1
- package/dist/palettes/rigger.json +1 -1
- package/dist/palettes/scheda.json +1 -1
- package/dist/palettes/sefy.json +1 -1
- package/dist/palettes/turnout.json +1 -1
- package/dist/registry.json +203 -125
- package/dist/theme.css +29 -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 /*\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 * The window's own chrome: the strip at the top, the rail at the left, and\n * the buttons that close the window.\n *\n * Four products of the line draw their own title bar - the trade is that a\n * system bar over an application bar costs a strip of every laptop screen\n * for nothing - and by the time the fourth one did it, the strip was 40px\n * in one and 2.4rem in another. Neither number is wrong; having two is. A\n * window's chrome is the part of a product that is supposed to look like\n * the operating system rather than like the product, so it is the last\n * place where two of them should differ.\n *\n * 40px is not a taste either. The window buttons have to clear the pointer\n * target floor with room for the hover fill to read as a button, and the\n * strip has to hold a mark, a trail and a search box at one line of text\n * without the trail wrapping. 40 is where both stop being tight.\n *\n * `--spacing-window-button` is 46px because that is what Windows draws, and\n * a bar whose buttons are narrower than the system's has a gap the pointer\n * falls into on the way to the corner. It is deliberately wider than it is\n * tall: the close button is the one that must be hit without aiming, and on\n * a maximised window it is the screen's corner.\n *\n * These are in the spacing namespace rather than beside `--size-target`,\n * and the reason is mechanical rather than tidy. `--size-*` yields exactly\n * one utility, `size-*`, which sets both axes - so `w-window-button` off a\n * `--size-` token compiles to NOTHING, and a button written that way loses\n * its width with no error anywhere. Every measurement here is one axis: the\n * bar has a height, the rail a width, the button a width unequal to its\n * height. `--spacing-*` is the namespace that feeds `w-`, `h-`, `min-h-`\n * and the padding utilities, so it is the one that can say so. Caught by\n * compiling the utility rather than by reading the stylesheet, which is\n * what `tailwind.test.ts` exists for.\n */\n --spacing-titlebar: 40px;\n --spacing-rail: 216px;\n --spacing-window-button: 46px;\n\n /*\n * How much of a frameless window's edge can be grabbed to resize it.\n *\n * The system used to own this and gave about 4 pixels plus whatever the\n * border was; a frameless window has no border, so the whole of it has to\n * be put back by hand. Five is the smallest that can be hit reliably with a\n * mouse and the largest that does not steal a click meant for the first\n * line of text - the strips sit above everything, so anything they cover\n * they cover completely.\n *\n * The corner is double, because a corner is aimed at less precisely than an\n * edge and is the one that resizes both axes at once.\n */\n --spacing-resize-edge: 5px;\n --spacing-resize-corner: 10px;\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 /* The base step, so text nobody gave a class reads at the scale's own body\n * size. Without it that text inherited the browser's 16px - an empty\n * state's heading and a card's name came out a step and a half above the\n * 12px beside them, in every product of the line.\n *\n * The line height is the base step's own 20 over 14, written as a ratio\n * rather than read from `--text-base--line-height`, and that is deliberate.\n * A length is inherited as a length: with 20px here, every element that\n * sets a size of its own without a line height - a 10px chart label -\n * inherited 20px and stood five pixels taller, found in the pictures of\n * the stand. A ratio is inherited as a ratio. The theme test holds the two\n * numbers to the tokens. */\n font-size: var(--text-base);\n line-height: calc(20 / 14);\n -webkit-font-smoothing: antialiased;\n}\n\n/*\n * How heavy an icon's line is.\n *\n * lucide draws at 2 by default, which is tuned for its own 24px; at the 14 and\n * 16 the line actually uses, a 2px stroke reads heavier than the text beside\n * it. 1.75 is the owner's call for the whole line (24.09), made here once\n * rather than as a `strokeWidth` at every one of a few hundred call sites.\n *\n * Only an icon still on lucide's default is changed. `[stroke-width='2']` is\n * how lucide writes the default, and an icon given another width - by a call\n * site, or by `absoluteStrokeWidth`, which computes one - keeps it. In\n * `@layer base` so a `stroke-*` utility on the icon wins too. A product that\n * draws with some other icon set is untouched: the rule keys off lucide's own\n * class.\n */\n:root {\n --icon-stroke: 1.75;\n}\n\n@layer base {\n svg.lucide[stroke-width='2'] {\n stroke-width: var(--icon-stroke);\n }\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 * The window's own chrome: the strip at the top, the rail at the left, and\n * the buttons that close the window.\n *\n * Four products of the line draw their own title bar - the trade is that a\n * system bar over an application bar costs a strip of every laptop screen\n * for nothing - and by the time the fourth one did it, the strip was 40px\n * in one and 2.4rem in another. Neither number is wrong; having two is. A\n * window's chrome is the part of a product that is supposed to look like\n * the operating system rather than like the product, so it is the last\n * place where two of them should differ.\n *\n * 40px is not a taste either. The window buttons have to clear the pointer\n * target floor with room for the hover fill to read as a button, and the\n * strip has to hold a mark, a trail and a search box at one line of text\n * without the trail wrapping. 40 is where both stop being tight.\n *\n * `--spacing-window-button` is 46px because that is what Windows draws, and\n * a bar whose buttons are narrower than the system's has a gap the pointer\n * falls into on the way to the corner. It is deliberately wider than it is\n * tall: the close button is the one that must be hit without aiming, and on\n * a maximised window it is the screen's corner.\n *\n * These are in the spacing namespace rather than beside `--size-target`,\n * and the reason is mechanical rather than tidy. `--size-*` yields exactly\n * one utility, `size-*`, which sets both axes - so `w-window-button` off a\n * `--size-` token compiles to NOTHING, and a button written that way loses\n * its width with no error anywhere. Every measurement here is one axis: the\n * bar has a height, the rail a width, the button a width unequal to its\n * height. `--spacing-*` is the namespace that feeds `w-`, `h-`, `min-h-`\n * and the padding utilities, so it is the one that can say so. Caught by\n * compiling the utility rather than by reading the stylesheet, which is\n * what `tailwind.test.ts` exists for.\n */\n --spacing-titlebar: 40px;\n --spacing-rail: 216px;\n --spacing-window-button: 46px;\n\n /*\n * The rail with only its icons: `NavRail collapsed`. 56 is a 36px entry -\n * the size of an icon button in the bar above it - with 10px of air on each\n * side, which is the same inset the wide rail gives its entries. Narrower,\n * and the active entry's tint touches the rail's border; wider, and the\n * column reads as a sidebar that forgot its words.\n */\n --spacing-rail-compact: 56px;\n\n /*\n * How much of a frameless window's edge can be grabbed to resize it.\n *\n * The system used to own this and gave about 4 pixels plus whatever the\n * border was; a frameless window has no border, so the whole of it has to\n * be put back by hand. Five is the smallest that can be hit reliably with a\n * mouse and the largest that does not steal a click meant for the first\n * line of text - the strips sit above everything, so anything they cover\n * they cover completely.\n *\n * The corner is double, because a corner is aimed at less precisely than an\n * edge and is the one that resizes both axes at once.\n */\n --spacing-resize-edge: 5px;\n --spacing-resize-corner: 10px;\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/*\n * `caption` - the small uppercase label: a field's name, a panel's section, a\n * group of menu items, a rail's group.\n *\n * One role, and the set wrote it seven ways: with and without `font-medium`,\n * with and without a weight at all, so a Field's label and the SectionLabel\n * above it were two weights of the same thing. kilna's screens added\n * thirty-one more by hand. The weight is 600, the mockup's `.lbl` - at 10px\n * a caption lighter than that reads as a smudge rather than a word.\n *\n * A utility rather than a component because it is a role text plays inside\n * things that are already components: a `<legend>`, a `<th>`, a `<label>`.\n * Its colour is `faint` - verified against the grounds at body size; at 10px\n * it is below AA in the light theme, which is a finding about `faint` at every\n * small size and is owned by the contrast work, not by this recipe.\n */\n@utility caption {\n @apply text-2xs font-semibold uppercase tracking-caption text-faint;\n}\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 /* The base step, so text nobody gave a class reads at the scale's own body\n * size. Without it that text inherited the browser's 16px - an empty\n * state's heading and a card's name came out a step and a half above the\n * 12px beside them, in every product of the line.\n *\n * The line height is the base step's own 20 over 14, written as a ratio\n * rather than read from `--text-base--line-height`, and that is deliberate.\n * A length is inherited as a length: with 20px here, every element that\n * sets a size of its own without a line height - a 10px chart label -\n * inherited 20px and stood five pixels taller, found in the pictures of\n * the stand. A ratio is inherited as a ratio. The theme test holds the two\n * numbers to the tokens. */\n font-size: var(--text-base);\n line-height: calc(20 / 14);\n -webkit-font-smoothing: antialiased;\n}\n\n/*\n * How heavy an icon's line is.\n *\n * lucide draws at 2 by default, which is tuned for its own 24px; at the 14 and\n * 16 the line actually uses, a 2px stroke reads heavier than the text beside\n * it. 1.75 is the owner's call for the whole line (24.09), made here once\n * rather than as a `strokeWidth` at every one of a few hundred call sites.\n *\n * Only an icon still on lucide's default is changed. `[stroke-width='2']` is\n * how lucide writes the default, and an icon given another width - by a call\n * site, or by `absoluteStrokeWidth`, which computes one - keeps it. In\n * `@layer base` so a `stroke-*` utility on the icon wins too. A product that\n * draws with some other icon set is untouched: the rule keys off lucide's own\n * class.\n */\n:root {\n --icon-stroke: 1.75;\n}\n\n@layer base {\n svg.lucide[stroke-width='2'] {\n stroke-width: var(--icon-stroke);\n }\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; }"
|
|
@@ -307,7 +307,7 @@
|
|
|
307
307
|
"title": "AboutPlate",
|
|
308
308
|
"description": "The about-plate primitive.",
|
|
309
309
|
"dependencies": [
|
|
310
|
-
"dowel-ui@^0.
|
|
310
|
+
"dowel-ui@^0.33.0"
|
|
311
311
|
],
|
|
312
312
|
"registryDependencies": [
|
|
313
313
|
"https://lacodda.github.io/dowel/r/product-mark.json"
|
|
@@ -328,7 +328,7 @@
|
|
|
328
328
|
"description": "A stack of collapsible sections that share one rule about how many can be open at once - an FAQ, the groups in a long settings page, the filters in a sidebar. Each section is `Collapsible`'s shape (a header, a trigger, a panel) repeated inside a group that also decides `single` or `multiple`, which is the one thing a lone `Collapsible` has no opinion about because it has nothing to be exclusive with.",
|
|
329
329
|
"dependencies": [
|
|
330
330
|
"@base-ui/react",
|
|
331
|
-
"dowel-ui@^0.
|
|
331
|
+
"dowel-ui@^0.33.0"
|
|
332
332
|
],
|
|
333
333
|
"registryDependencies": [
|
|
334
334
|
"https://lacodda.github.io/dowel/r/collapsible.json"
|
|
@@ -350,7 +350,7 @@
|
|
|
350
350
|
"dependencies": [
|
|
351
351
|
"@base-ui/react",
|
|
352
352
|
"class-variance-authority",
|
|
353
|
-
"dowel-ui@^0.
|
|
353
|
+
"dowel-ui@^0.33.0"
|
|
354
354
|
],
|
|
355
355
|
"registryDependencies": [],
|
|
356
356
|
"files": [
|
|
@@ -369,7 +369,7 @@
|
|
|
369
369
|
"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.",
|
|
370
370
|
"dependencies": [
|
|
371
371
|
"class-variance-authority",
|
|
372
|
-
"dowel-ui@^0.
|
|
372
|
+
"dowel-ui@^0.33.0"
|
|
373
373
|
],
|
|
374
374
|
"registryDependencies": [
|
|
375
375
|
"https://lacodda.github.io/dowel/r/activity-weeks.json"
|
|
@@ -379,7 +379,7 @@
|
|
|
379
379
|
"path": "ui/activity-heatmap.tsx",
|
|
380
380
|
"target": "@ui/activity-heatmap.tsx",
|
|
381
381
|
"type": "registry:ui",
|
|
382
|
-
"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-
|
|
382
|
+
"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-2xs 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"
|
|
383
383
|
}
|
|
384
384
|
]
|
|
385
385
|
},
|
|
@@ -389,7 +389,7 @@
|
|
|
389
389
|
"title": "ActivityLegend",
|
|
390
390
|
"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.",
|
|
391
391
|
"dependencies": [
|
|
392
|
-
"dowel-ui@^0.
|
|
392
|
+
"dowel-ui@^0.33.0"
|
|
393
393
|
],
|
|
394
394
|
"registryDependencies": [
|
|
395
395
|
"https://lacodda.github.io/dowel/r/activity-heatmap.json",
|
|
@@ -400,7 +400,7 @@
|
|
|
400
400
|
"path": "ui/activity-legend.tsx",
|
|
401
401
|
"target": "@ui/activity-legend.tsx",
|
|
402
402
|
"type": "registry:ui",
|
|
403
|
-
"content": "import type { HTMLAttributes, ReactNode } from 'react'\nimport { cn } from 'dowel-ui'\nimport { activityCellVariants } from './activity-heatmap'\nimport { STEPS } from './activity-weeks'\n\n/*\n * What the shades mean, in the same tokens the grid paints with.\n *\n * Separate from the grid because a caller showing three grids on one screen\n * wants one legend, and because the words in it are the product's.\n */\nexport interface ActivityLegendProps extends HTMLAttributes<HTMLDivElement> {\n /** The word at the faint end - \"less\", usually. */\n less: ReactNode\n /** The word at the dark end. */\n more: ReactNode\n /** What the darkest square stands for, in words: the scale is relative, and\n * without this the shades read as an absolute measure. Omitted when there is\n * no value in the grid to be busiest. */\n busiest?: ReactNode\n /** The other three meanings, each named. Omitted one at a time by a caller\n * whose data cannot produce that kind. */\n none?: ReactNode\n partial?: ReactNode\n}\n\nexport function ActivityLegend({\n less,\n more,\n busiest,\n none,\n partial,\n className,\n ...props\n}: ActivityLegendProps) {\n return (\n <div\n className={cn('flex flex-wrap items-center gap-x-5 gap-y-2 text-
|
|
403
|
+
"content": "import type { HTMLAttributes, ReactNode } from 'react'\nimport { cn } from 'dowel-ui'\nimport { activityCellVariants } from './activity-heatmap'\nimport { STEPS } from './activity-weeks'\n\n/*\n * What the shades mean, in the same tokens the grid paints with.\n *\n * Separate from the grid because a caller showing three grids on one screen\n * wants one legend, and because the words in it are the product's.\n */\nexport interface ActivityLegendProps extends HTMLAttributes<HTMLDivElement> {\n /** The word at the faint end - \"less\", usually. */\n less: ReactNode\n /** The word at the dark end. */\n more: ReactNode\n /** What the darkest square stands for, in words: the scale is relative, and\n * without this the shades read as an absolute measure. Omitted when there is\n * no value in the grid to be busiest. */\n busiest?: ReactNode\n /** The other three meanings, each named. Omitted one at a time by a caller\n * whose data cannot produce that kind. */\n none?: ReactNode\n partial?: ReactNode\n}\n\nexport function ActivityLegend({\n less,\n more,\n busiest,\n none,\n partial,\n className,\n ...props\n}: ActivityLegendProps) {\n return (\n <div\n className={cn('flex flex-wrap items-center gap-x-5 gap-y-2 text-xs text-faint', className)}\n {...props}\n >\n <span className=\"flex items-center gap-1.5\">\n <span>{less}</span>\n {Array.from({ length: STEPS }, (_, index) => (\n <span\n key={index}\n className={cn('size-3', activityCellVariants({ kind: 'value', step: (index + 1) as 1 | 2 | 3 | 4 | 5 }))}\n />\n ))}\n <span>{more}</span>\n {busiest !== undefined && <span className=\"ml-1\">· {busiest}</span>}\n </span>\n\n {none !== undefined && (\n <span className=\"flex items-center gap-1.5\">\n <span className={cn('size-3', activityCellVariants({ kind: 'none' }))} />\n {none}\n </span>\n )}\n\n {partial !== undefined && (\n <span className=\"flex items-center gap-1.5\">\n <span className={cn('size-3', activityCellVariants({ kind: 'partial' }))} />\n {partial}\n </span>\n )}\n </div>\n )\n}\n"
|
|
404
404
|
}
|
|
405
405
|
]
|
|
406
406
|
},
|
|
@@ -427,7 +427,7 @@
|
|
|
427
427
|
"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.",
|
|
428
428
|
"dependencies": [
|
|
429
429
|
"class-variance-authority",
|
|
430
|
-
"dowel-ui@^0.
|
|
430
|
+
"dowel-ui@^0.33.0"
|
|
431
431
|
],
|
|
432
432
|
"registryDependencies": [],
|
|
433
433
|
"files": [
|
|
@@ -446,7 +446,7 @@
|
|
|
446
446
|
"description": "The frame a product draws once and then never thinks about: a bar across the top, a rail down the left, and the screen in the corner they leave. It is three boxes and a grid, which is exactly why every product wrote its own - and why every one of them got the same two things wrong before getting them right.",
|
|
447
447
|
"dependencies": [
|
|
448
448
|
"class-variance-authority",
|
|
449
|
-
"dowel-ui@^0.
|
|
449
|
+
"dowel-ui@^0.33.0"
|
|
450
450
|
],
|
|
451
451
|
"registryDependencies": [],
|
|
452
452
|
"files": [
|
|
@@ -465,7 +465,7 @@
|
|
|
465
465
|
"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.",
|
|
466
466
|
"dependencies": [
|
|
467
467
|
"class-variance-authority",
|
|
468
|
-
"dowel-ui@^0.
|
|
468
|
+
"dowel-ui@^0.33.0"
|
|
469
469
|
],
|
|
470
470
|
"registryDependencies": [],
|
|
471
471
|
"files": [
|
|
@@ -484,7 +484,7 @@
|
|
|
484
484
|
"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.",
|
|
485
485
|
"dependencies": [
|
|
486
486
|
"class-variance-authority",
|
|
487
|
-
"dowel-ui@^0.
|
|
487
|
+
"dowel-ui@^0.33.0"
|
|
488
488
|
],
|
|
489
489
|
"registryDependencies": [],
|
|
490
490
|
"files": [
|
|
@@ -503,7 +503,7 @@
|
|
|
503
503
|
"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.",
|
|
504
504
|
"dependencies": [
|
|
505
505
|
"class-variance-authority",
|
|
506
|
-
"dowel-ui@^0.
|
|
506
|
+
"dowel-ui@^0.33.0"
|
|
507
507
|
],
|
|
508
508
|
"registryDependencies": [],
|
|
509
509
|
"files": [
|
|
@@ -522,7 +522,7 @@
|
|
|
522
522
|
"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.",
|
|
523
523
|
"dependencies": [
|
|
524
524
|
"class-variance-authority",
|
|
525
|
-
"dowel-ui@^0.
|
|
525
|
+
"dowel-ui@^0.33.0"
|
|
526
526
|
],
|
|
527
527
|
"registryDependencies": [],
|
|
528
528
|
"files": [
|
|
@@ -530,7 +530,7 @@
|
|
|
530
530
|
"path": "ui/bar-chart.tsx",
|
|
531
531
|
"target": "@ui/bar-chart.tsx",
|
|
532
532
|
"type": "registry:ui",
|
|
533
|
-
"content": "import type { HTMLAttributes, ReactNode } from 'react'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { cn } from 'dowel-ui'\n\n/*\n * Columns over a baseline: one period, one bar.\n *\n * Bars rather than a line, and the distinction is the data's not the\n * drawing's: a line says the value exists between the points, a column says\n * each period is its own sum. Hours worked in a week is a sum - there is no\n * \"Wednesday afternoon\" reading between two weeks - so it is a column.\n *\n * **The chart owns its height in pixels.** That is not a style choice, it is\n * the defect this component exists to prevent. The first consumer drew its\n * bars as a percentage of the parent, inside a flex row sized from its own\n * content: the child had no base to be a percentage of, every bar computed to\n * zero, and the chart shipped as a row of bare axis labels. No test and no API\n * check could see it - the owner found it by looking, and it cost a patch\n * release. Here the plot is a stated number of pixels and a bar is a\n * percentage of *that*.\n *\n * **An absent period is not a short one.** A week with nothing recorded and a\n * week of twenty minutes are different facts, and a bar of no height says\n * neither - it reads as a bar that failed to render. So a gap keeps its place\n * in the row and is drawn as a mark of its own.\n *\n * Interaction is one `title` per column rather than a tooltip layer: this is a\n * small chart that sits inside a panel, and the numbers it holds also belong\n * in the list beside it. A reader who needs them exactly should not have to\n * hover to find out.\n */\n\nexport const barChartVariants = cva('flex items-end gap-1 border-b border-chart-axis', {\n variants: {\n size: {\n /* Inside a panel, beside other things. */\n sm: '[--plot:72px]',\n /* On its own, where the shape is the subject. */\n md: '[--plot:112px]',\n },\n },\n defaultVariants: { size: 'md' },\n})\n\nexport const barVariants = cva('mx-auto w-3/5 max-w-6 rounded-t-
|
|
533
|
+
"content": "import type { HTMLAttributes, ReactNode } from 'react'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { cn } from 'dowel-ui'\n\n/*\n * Columns over a baseline: one period, one bar.\n *\n * Bars rather than a line, and the distinction is the data's not the\n * drawing's: a line says the value exists between the points, a column says\n * each period is its own sum. Hours worked in a week is a sum - there is no\n * \"Wednesday afternoon\" reading between two weeks - so it is a column.\n *\n * **The chart owns its height in pixels.** That is not a style choice, it is\n * the defect this component exists to prevent. The first consumer drew its\n * bars as a percentage of the parent, inside a flex row sized from its own\n * content: the child had no base to be a percentage of, every bar computed to\n * zero, and the chart shipped as a row of bare axis labels. No test and no API\n * check could see it - the owner found it by looking, and it cost a patch\n * release. Here the plot is a stated number of pixels and a bar is a\n * percentage of *that*.\n *\n * **An absent period is not a short one.** A week with nothing recorded and a\n * week of twenty minutes are different facts, and a bar of no height says\n * neither - it reads as a bar that failed to render. So a gap keeps its place\n * in the row and is drawn as a mark of its own.\n *\n * Interaction is one `title` per column rather than a tooltip layer: this is a\n * small chart that sits inside a panel, and the numbers it holds also belong\n * in the list beside it. A reader who needs them exactly should not have to\n * hover to find out.\n */\n\nexport const barChartVariants = cva('flex items-end gap-1 border-b border-chart-axis', {\n variants: {\n size: {\n /* Inside a panel, beside other things. */\n sm: '[--plot:72px]',\n /* On its own, where the shape is the subject. */\n md: '[--plot:112px]',\n },\n },\n defaultVariants: { size: 'md' },\n})\n\nexport const barVariants = cva('mx-auto w-3/5 max-w-6 rounded-t-xs', {\n variants: {\n tone: {\n accent: 'bg-accent',\n /* For a bar that is one of several series, or one the caller is\n * highlighting against the rest. */\n series: 'bg-series-1',\n good: 'bg-good',\n warn: 'bg-warn',\n bad: 'bg-bad',\n /* The rest of the field, when one bar is the story: emphasis is the\n * most underused form in a chart of eight equal colours. */\n muted: 'bg-line-2',\n },\n },\n defaultVariants: { tone: 'accent' },\n})\n\nexport interface BarDatum {\n /** Distinguishes this column from its neighbours. */\n key: string\n /** The period's own sum. `null` is a period with nothing recorded, which is\n * not the same as a sum of zero and is not drawn as a bar. */\n value: number | null\n /** What goes under the column. Kept short - these labels sit at a width the\n * chart does not control. */\n label: ReactNode\n tone?: NonNullable<VariantProps<typeof barVariants>['tone']>\n /** What the column says, for a reader hovering it and for a screen reader.\n * Required per bar, because a rectangle announces nothing. */\n title: string\n}\n\nexport interface BarChartProps\n extends Omit<HTMLAttributes<HTMLDivElement>, 'children'>,\n VariantProps<typeof barChartVariants> {\n bars: BarDatum[]\n /** The top of the scale. Defaults to the tallest bar present; state it to\n * compare two charts, or to hold a scale still while the data moves. */\n max?: number\n /** What the whole chart is, in words. */\n label: string\n}\n\n/** The shortest a bar may be drawn, as a percentage of the plot.\n *\n * A twenty-minute week against a forty-hour one is half a percent, which\n * rounds to nothing: the period was recorded and would simply not be there. */\nconst MIN_BAR = 3\n\nexport function BarChart({ bars, max, label, size, className, ...props }: BarChartProps) {\n const values = bars.map((bar) => bar.value).filter((value): value is number => value !== null)\n /* A ceiling of at least one, so a chart of nothing but zeroes divides\n * safely and draws a row of floors rather than nothing at all. */\n const ceiling = Math.max(max ?? 0, ...values, 1)\n\n return (\n <div className={cn(barChartVariants({ size }), className)} role=\"img\" aria-label={label} {...props}>\n {bars.map((bar) => (\n <div key={bar.key} className=\"flex min-w-0 flex-1 flex-col items-center gap-1.5\">\n {/* The track carries the height itself. See the note above: a\n * percentage against a parent that has no height of its own\n * resolves to zero, and the chart disappears without failing. */}\n <div className=\"flex h-[var(--plot)] w-full items-end\" title={bar.title}>\n {bar.value === null ? (\n /* A gap, drawn as one. A bar of no height is indistinguishable\n * from a bar that did not render, and closing the gap up would\n * turn an absence into continuity - the one thing a trend must\n * not do. */\n <div className=\"mx-auto h-1 w-3/5 max-w-6 rounded-t-xs border-x border-t border-line-2\" />\n ) : (\n <div\n className={cn(barVariants({ tone: bar.tone }))}\n style={{ height: `${Math.max((bar.value / ceiling) * 100, MIN_BAR)}%` }}\n />\n )}\n </div>\n <span className=\"w-full truncate text-center text-2xs text-faint tabular-nums\">\n {bar.label}\n </span>\n </div>\n ))}\n </div>\n )\n}\n\n/*\n * A line across the plot, at a value on the same scale.\n *\n * For the number every bar is read against - a median, a target, an agreed\n * norm. A chart without its baseline invites the reader to invent one, and the\n * one they invent is usually the tallest bar.\n *\n * Drawn as a solid hairline rather than a dash: a dashed rule reads as\n * \"projected\" or \"threshold\" when it is neither, and the doctrine is explicit\n * that grid and axis lines are solid.\n */\nexport interface BaselineProps extends HTMLAttributes<HTMLDivElement> {\n /** Where it sits, on the same scale as the bars. */\n value: number\n /** The same ceiling the chart is drawn against. */\n max: number\n /** What the line is, beside it. */\n children?: ReactNode\n}\n\nexport function Baseline({ value, max, children, className, ...props }: BaselineProps) {\n if (!(max > 0) || value < 0 || value > max) return null\n\n return (\n <div\n className={cn('pointer-events-none absolute inset-x-0 flex items-center gap-2', className)}\n style={{ bottom: `${(value / max) * 100}%` }}\n {...props}\n >\n {/* The rule stops short of the label rather than running under it: a\n * hairline crossing its own caption reads as a strikethrough. */}\n <div className=\"h-px flex-1 bg-chart-grid\" />\n {children !== undefined && (\n <span className=\"shrink-0 text-2xs leading-none text-faint tabular-nums\">{children}</span>\n )}\n </div>\n )\n}\n\n/*\n * The box a chart and its baseline share.\n *\n * Two things it does, and both are the component's job rather than the\n * caller's. It establishes the positioning context the baseline needs - left\n * to the caller that is a `relative` remembered or forgotten, and forgotten it\n * puts the rule at the bottom of the page. And it keeps a gutter on the right\n * for the baseline's label, which otherwise sits on top of the last columns:\n * the label is outside the plot, so the plot has to end before it starts.\n */\nexport interface ChartFrameProps extends HTMLAttributes<HTMLDivElement> {\n /** Room on the right for the baseline's label. Omit it where there is no\n * baseline, or where the label is short enough to live in the panel's own\n * padding. */\n gutter?: boolean\n children: ReactNode\n}\n\nexport function ChartFrame({ gutter = true, className, ...props }: ChartFrameProps) {\n return <div className={cn('relative', gutter && 'pr-16', className)} {...props} />\n}\n"
|
|
534
534
|
}
|
|
535
535
|
]
|
|
536
536
|
},
|
|
@@ -541,7 +541,7 @@
|
|
|
541
541
|
"description": "Where you are, as a trail rather than as a word. On a list screen the screen's own name is enough; on something opened from it, it is not - \"Works\" says nothing about which work, and the way back to the list is otherwise the browser's back button alone, which a desktop window does not visibly have.",
|
|
542
542
|
"dependencies": [
|
|
543
543
|
"@base-ui/react",
|
|
544
|
-
"dowel-ui@^0.
|
|
544
|
+
"dowel-ui@^0.33.0"
|
|
545
545
|
],
|
|
546
546
|
"registryDependencies": [],
|
|
547
547
|
"files": [
|
|
@@ -557,11 +557,11 @@
|
|
|
557
557
|
"name": "button",
|
|
558
558
|
"type": "registry:ui",
|
|
559
559
|
"title": "Button",
|
|
560
|
-
"description": "
|
|
560
|
+
"description": "Six variants, because that is what the line's products actually reach for: one primary action per screen, a quiet default, a soft accent for something selected, a destructive one, an icon-only, and one that reads as a link.",
|
|
561
561
|
"dependencies": [
|
|
562
562
|
"@base-ui/react",
|
|
563
563
|
"class-variance-authority",
|
|
564
|
-
"dowel-ui@^0.
|
|
564
|
+
"dowel-ui@^0.33.0"
|
|
565
565
|
],
|
|
566
566
|
"registryDependencies": [],
|
|
567
567
|
"files": [
|
|
@@ -569,7 +569,7 @@
|
|
|
569
569
|
"path": "ui/button.tsx",
|
|
570
570
|
"target": "@ui/button.tsx",
|
|
571
571
|
"type": "registry:ui",
|
|
572
|
-
"content": "import type
|
|
572
|
+
"content": "import { useId, type ButtonHTMLAttributes, type MouseEvent } from 'react'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { useRender } from '@base-ui/react/use-render'\n// `cn` comes from the package rather than being copied in beside the\n// component (ADR 0002): a helper every primitive shares should update\n// centrally, and a project installing a component already has the package for\n// the theme. shadcn's own components import it from `@/lib/utils`; that is a\n// per-project alias, and a copied file cannot know what it points at.\nimport { cn } from 'dowel-ui'\n\n/*\n * Button.\n *\n * Six variants, because that is what the line's products actually reach for:\n * one primary action per screen, a quiet default, a soft accent for something\n * selected, a destructive one, an icon-only, and one that reads as a link.\n *\n * Every colour and every size is a token. There are no `dark:` utilities and\n * no raw values - the theme swaps underneath, so the same class list is\n * correct in both themes and in every product's accent.\n *\n * **Height is a control row, not a number.** `md` and `sm` stand on\n * `h-control` and `h-control-sm`, the rows every field of the set stands on,\n * so a button beside an input of the same size is the same height - and\n * `data-density` on a container reaches the button along with the field. They\n * used to say `h-9` and `h-7` literally, and a compact form came out with 32px\n * fields beside 36px buttons. `xs` is below the rows on purpose: it is for a\n * button inside something - a chat line, a chip, a table cell - and it grows\n * its hit area to the pointer floor rather than its box.\n *\n * **Every size says how big an icon is.** A text button used to size nothing,\n * so a lucide icon inside one drew at its own 24px - taller than the text,\n * across the whole line, and fixed at each call site by a `size-4` that the\n * next call site forgot. Each size now sizes an svg inside it, and only one\n * that has no size of its own: `[&_svg:not([class*=size-])]`. The guard is\n * not a nicety. The unguarded form the icon sizes used to have is a\n * descendant selector, one class and one element, and it outranks the single\n * class `size-5` written on the icon - so an explicit size at a call site was\n * silently overruled, and a `+` meant to be 10px drew at 14. The value is\n * unquoted (`size-` is an identifier) so the class sits in a plain string.\n *\n * **A disabled button can say why.** It used to be `pointer-events-none`, so a\n * `title` on it was dead: kilna's trash explained why a row could not be\n * restored in a tooltip nobody could ever raise. Disabled is now a look\n * (`data-disabled`) with the hover scoped away from it, and `disabledReason`\n * goes further: the button stays reachable by Tab and by the pointer, a press\n * does nothing, and the reason is its `title` and its accessible description.\n * A disabled control with no word for why is a wall; one that answers is an\n * instruction.\n */\nexport const buttonVariants = cva(\n [\n 'inline-flex cursor-pointer items-center justify-center gap-1.5 whitespace-nowrap',\n 'font-medium transition-colors',\n // Disabled is a state, not a colour: the button keeps its own hue and\n // fades, which reads the same whatever the accent is. Drawn from\n // `data-disabled`, which both kinds of disabled set, and the hovers below\n // are scoped away from it - so the pointer still reaches the button (and\n // its `title`) without the button lighting up to say \"press me\".\n 'data-disabled:cursor-not-allowed data-disabled:opacity-50',\n '[&_svg]:shrink-0',\n ],\n {\n variants: {\n variant: {\n primary: 'rounded-md bg-accent font-semibold text-on-accent not-data-disabled:hover:bg-accent-2',\n ghost:\n 'rounded-md border border-line text-dim not-data-disabled:hover:border-line-2 not-data-disabled:hover:text-text',\n soft: 'rounded-md bg-accent-soft text-accent not-data-disabled:hover:bg-accent-soft/60',\n danger: 'rounded-md text-bad not-data-disabled:hover:bg-bad-soft',\n icon: 'rounded-md text-dim not-data-disabled:hover:bg-soft not-data-disabled:hover:text-text',\n /*\n * A button that reads as a link: \"Open\", \"Show all\", \"Undo\" inside a\n * line of text or a widget's header. Not an `<a>` - it acts rather\n * than goes, and `render={<a href />}` is there for the one that\n * goes. It takes no size: it sits in text, so it takes the text's\n * size and height and has no box of its own, and its icon is an em\n * so it scales with whatever line it is in.\n */\n link: [\n 'h-auto rounded-xs px-0 text-accent underline-offset-2 not-data-disabled:hover:underline',\n '[&_svg:not([class*=size-])]:size-[1em]',\n ],\n },\n size: {\n xs: 'target-min h-6 gap-1 px-2 text-xs [&_svg:not([class*=size-])]:size-3',\n sm: 'h-control-sm px-2.5 text-xs [&_svg:not([class*=size-])]:size-3.5',\n md: 'h-control px-3.5 text-sm [&_svg:not([class*=size-])]:size-4',\n 'icon-xs': 'target-min size-5 [&_svg:not([class*=size-])]:size-3',\n 'icon-sm': 'size-7 [&_svg:not([class*=size-])]:size-3.5',\n 'icon-md': 'size-8 [&_svg:not([class*=size-])]:size-4',\n },\n },\n defaultVariants: { variant: 'ghost', size: 'md' },\n },\n)\n\nexport interface ButtonProps\n extends ButtonHTMLAttributes<HTMLButtonElement>,\n VariantProps<typeof buttonVariants> {\n /**\n * Why the button cannot be pressed, in the product's words - read only\n * while `disabled`.\n *\n * With it, the button stays in the tab order and under the pointer\n * (`aria-disabled` rather than `disabled`), a press does nothing, and the\n * reason is both its `title` and its accessible description. Without it a\n * disabled button is a native disabled one: skipped by Tab, and silent.\n */\n disabledReason?: string\n /**\n * Render something else with the button's clothes on - a link, most often.\n *\n * Takes the element itself rather than a boolean: `render={<a href=\"…\" />}`.\n * A function is also accepted, for the rare case that needs the props\n * before deciding what to build with them.\n */\n render?: useRender.RenderProp\n}\n\nexport function Button({\n variant,\n size,\n render,\n className,\n type,\n disabled = false,\n disabledReason,\n onClick,\n ...props\n}: ButtonProps) {\n const reasonId = useId()\n const explained = disabled && disabledReason !== undefined && disabledReason !== ''\n\n const button = useRender({\n render,\n defaultTagName: 'button',\n props: {\n // A `<button>` inside a form submits it unless told otherwise, which\n // surprises everyone once. When rendering as something else the\n // attribute is meaningless and would land on an `<a>`, so it is only\n // set for the element that has it - `render` is what says which.\n ...(render === undefined && type === undefined ? { type: 'button' } : { type }),\n // A link has no size: it takes the size of the text it sits in.\n className: cn(buttonVariants({ variant, size: variant === 'link' ? null : size }), className),\n 'data-disabled': disabled ? '' : undefined,\n ...(explained\n ? {\n // Reachable, and inert. `aria-disabled` keeps the button in the tab\n // order so the reason can be heard; the click is swallowed here,\n // which also stops a submit button submitting its form.\n 'aria-disabled': true,\n 'aria-describedby': reasonId,\n title: disabledReason,\n onClick: (event: MouseEvent<HTMLButtonElement>) => event.preventDefault(),\n }\n : { disabled: disabled || undefined, onClick }),\n ...props,\n },\n })\n\n if (!explained) return button\n\n // The description lives beside the button rather than inside it: text\n // inside a button is part of its name, and \"Restore - the work it belonged\n // to is gone\" is not what the button is called. `hidden` takes it off the\n // screen and out of the flow; `aria-describedby` still reads it.\n return (\n <>\n {button}\n <span id={reasonId} hidden>\n {disabledReason}\n </span>\n </>\n )\n}\n"
|
|
573
573
|
}
|
|
574
574
|
]
|
|
575
575
|
},
|
|
@@ -595,7 +595,7 @@
|
|
|
595
595
|
"title": "Calendar",
|
|
596
596
|
"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.",
|
|
597
597
|
"dependencies": [
|
|
598
|
-
"dowel-ui@^0.
|
|
598
|
+
"dowel-ui@^0.33.0"
|
|
599
599
|
],
|
|
600
600
|
"registryDependencies": [
|
|
601
601
|
"https://lacodda.github.io/dowel/r/calendar-math.json"
|
|
@@ -605,7 +605,7 @@
|
|
|
605
605
|
"path": "ui/calendar.tsx",
|
|
606
606
|
"target": "@ui/calendar.tsx",
|
|
607
607
|
"type": "registry:ui",
|
|
608
|
-
"content": "import { useMemo, useState, type KeyboardEvent } from 'react'\nimport { cn, useLocale } from 'dowel-ui'\nimport {\n addDays,\n addMonths,\n firstDayOfWeek,\n monthGrid,\n parts,\n today,\n weekday,\n weekdayNames,\n type IsoDate,\n} from './calendar-math'\n\n/*\n * Calendar - a month of days.\n *\n * The sums live next door in `calendar-math`, which has no React in it; this\n * is the grid that draws them and the keyboard that moves around it.\n *\n * One tab stop for the whole grid, arrows within - the arrangement a radio\n * group has, and the reason a calendar is usable at all: forty-two tab stops\n * is not a control. Arrows move a cursor and only Enter chooses, so a product\n * listening for a change does not receive five dates on the way to the sixth.\n */\n\nexport interface CalendarProps {\n /** The selected day, or `undefined` for none. */\n value?: IsoDate\n onValueChange?: (value: IsoDate) => void\n /** Which month is shown. Uncontrolled unless given. */\n month?: IsoDate\n onMonthChange?: (month: IsoDate) => void\n /** Bounds, inclusive. A day outside them cannot be chosen. */\n min?: IsoDate\n max?: IsoDate\n /** For a range: the other end, so the days between can be shaded. */\n rangeEnd?: IsoDate\n /** Formats the names. The application's language by default - see `useLocale`. */\n locale?: string\n /** What the grid is called, for a screen reader. */\n 'aria-label'?: string\n /** Names the buttons that page the months. Required: they are icons, and an\n * icon with no name is a button that announces nothing. */\n previousMonthLabel: string\n nextMonthLabel: string\n className?: string\n}\n\nexport function Calendar({\n value,\n onValueChange,\n month,\n onMonthChange,\n min,\n max,\n rangeEnd,\n locale,\n previousMonthLabel,\n nextMonthLabel,\n className,\n 'aria-label': ariaLabel,\n}: CalendarProps) {\n const [ownMonth, setOwnMonth] = useState<IsoDate>(() => value ?? today())\n const shown = month ?? ownMonth\n\n /* Which day the keyboard is on. It is not the selection: arrowing around a\n * calendar moves a cursor, and only Enter chooses - otherwise every arrow\n * key would fire `onValueChange` and a product listening for it would save\n * five dates on the way to the sixth. */\n const [focused, setFocused] = useState<IsoDate>(() => value ?? today())\n\n const language = useLocale(locale)\n const weeks = useMemo(() => monthGrid(shown, language), [shown, language])\n const start = firstDayOfWeek(language)\n const names = useMemo(() => weekdayNames(language, start), [language, start])\n const heading = useMemo(\n () => new Intl.DateTimeFormat(language, { month: 'long', year: 'numeric' }).format(\n new Date(parts(shown).year, parts(shown).month - 1, 1),\n ),\n [shown, language],\n )\n const dayNumber = useMemo(() => new Intl.DateTimeFormat(language, { day: 'numeric' }), [language])\n const fullDate = useMemo(\n () => new Intl.DateTimeFormat(language, { dateStyle: 'long' }),\n [language],\n )\n\n const outOfBounds = (date: IsoDate) =>\n (min !== undefined && date < min) || (max !== undefined && date > max)\n\n const goToMonth = (next: IsoDate) => {\n if (month === undefined) setOwnMonth(next)\n onMonthChange?.(next)\n }\n\n const moveFocus = (next: IsoDate) => {\n setFocused(next)\n // Paging follows the cursor: arrowing off the end of a month shows the\n // next one rather than moving to a day nobody can see.\n if (parts(next).month !== parts(shown).month || parts(next).year !== parts(shown).year) {\n goToMonth(next)\n }\n }\n\n const onKeyDown = (event: KeyboardEvent) => {\n const jump: Record<string, () => IsoDate> = {\n ArrowRight: () => addDays(focused, 1),\n ArrowLeft: () => addDays(focused, -1),\n ArrowDown: () => addDays(focused, 7),\n ArrowUp: () => addDays(focused, -7),\n PageDown: () => addMonths(focused, 1),\n PageUp: () => addMonths(focused, -1),\n Home: () => addDays(focused, -((weekday(focused) - start + 7) % 7)),\n End: () => addDays(focused, 6 - ((weekday(focused) - start + 7) % 7)),\n }\n\n const move = jump[event.key]\n if (move) {\n event.preventDefault()\n moveFocus(move())\n return\n }\n\n if (event.key === 'Enter' || event.key === ' ') {\n event.preventDefault()\n if (!outOfBounds(focused)) onValueChange?.(focused)\n }\n }\n\n const now = today()\n\n return (\n <div className={cn('w-64 select-none', className)}>\n <div className=\"mb-2 flex items-center justify-between gap-1\">\n <button\n type=\"button\"\n aria-label={previousMonthLabel}\n onClick={() => goToMonth(addMonths(shown, -1))}\n className={cn(\n 'flex size-7 items-center justify-center rounded-md 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 )}\n >\n <svg viewBox=\"0 0 16 16\" className=\"size-4\" aria-hidden>\n <path\n d=\"M10 3L5 8l5 5\"\n fill=\"none\"\n stroke=\"currentColor\"\n strokeWidth=\"1.75\"\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n />\n </svg>\n </button>\n\n {/* The month is announced when it changes, so paging with the arrows\n * says where you have arrived rather than moving silently. */}\n <div aria-live=\"polite\" className=\"text-sm font-medium text-text\">\n {heading}\n </div>\n\n <button\n type=\"button\"\n aria-label={nextMonthLabel}\n onClick={() => goToMonth(addMonths(shown, 1))}\n className={cn(\n 'flex size-7 items-center justify-center rounded-md 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 )}\n >\n <svg viewBox=\"0 0 16 16\" className=\"size-4\" aria-hidden>\n <path\n d=\"M6 3l5 5-5 5\"\n fill=\"none\"\n stroke=\"currentColor\"\n strokeWidth=\"1.75\"\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n />\n </svg>\n </button>\n </div>\n\n {/* One tab stop for the whole grid, and the arrows move within it - the\n * arrangement a radio group has, and the reason a calendar is usable at\n * all: forty-two tab stops is not a control. */}\n <div\n role=\"grid\"\n aria-label={ariaLabel}\n tabIndex={0}\n onKeyDown={onKeyDown}\n className={cn(\n 'rounded-md',\n 'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent',\n )}\n >\n <div role=\"row\" className=\"mb-1 grid grid-cols-7\">\n {names.map((name) => (\n <div\n key={name}\n role=\"columnheader\"\n aria-label={name}\n className=\"py-1 text-center
|
|
608
|
+
"content": "import { useMemo, useState, type KeyboardEvent } from 'react'\nimport { cn, useLocale } from 'dowel-ui'\nimport {\n addDays,\n addMonths,\n firstDayOfWeek,\n monthGrid,\n parts,\n today,\n weekday,\n weekdayNames,\n type IsoDate,\n} from './calendar-math'\n\n/*\n * Calendar - a month of days.\n *\n * The sums live next door in `calendar-math`, which has no React in it; this\n * is the grid that draws them and the keyboard that moves around it.\n *\n * One tab stop for the whole grid, arrows within - the arrangement a radio\n * group has, and the reason a calendar is usable at all: forty-two tab stops\n * is not a control. Arrows move a cursor and only Enter chooses, so a product\n * listening for a change does not receive five dates on the way to the sixth.\n */\n\nexport interface CalendarProps {\n /** The selected day, or `undefined` for none. */\n value?: IsoDate\n onValueChange?: (value: IsoDate) => void\n /** Which month is shown. Uncontrolled unless given. */\n month?: IsoDate\n onMonthChange?: (month: IsoDate) => void\n /** Bounds, inclusive. A day outside them cannot be chosen. */\n min?: IsoDate\n max?: IsoDate\n /** For a range: the other end, so the days between can be shaded. */\n rangeEnd?: IsoDate\n /** Formats the names. The application's language by default - see `useLocale`. */\n locale?: string\n /** What the grid is called, for a screen reader. */\n 'aria-label'?: string\n /** Names the buttons that page the months. Required: they are icons, and an\n * icon with no name is a button that announces nothing. */\n previousMonthLabel: string\n nextMonthLabel: string\n className?: string\n}\n\nexport function Calendar({\n value,\n onValueChange,\n month,\n onMonthChange,\n min,\n max,\n rangeEnd,\n locale,\n previousMonthLabel,\n nextMonthLabel,\n className,\n 'aria-label': ariaLabel,\n}: CalendarProps) {\n const [ownMonth, setOwnMonth] = useState<IsoDate>(() => value ?? today())\n const shown = month ?? ownMonth\n\n /* Which day the keyboard is on. It is not the selection: arrowing around a\n * calendar moves a cursor, and only Enter chooses - otherwise every arrow\n * key would fire `onValueChange` and a product listening for it would save\n * five dates on the way to the sixth. */\n const [focused, setFocused] = useState<IsoDate>(() => value ?? today())\n\n const language = useLocale(locale)\n const weeks = useMemo(() => monthGrid(shown, language), [shown, language])\n const start = firstDayOfWeek(language)\n const names = useMemo(() => weekdayNames(language, start), [language, start])\n const heading = useMemo(\n () => new Intl.DateTimeFormat(language, { month: 'long', year: 'numeric' }).format(\n new Date(parts(shown).year, parts(shown).month - 1, 1),\n ),\n [shown, language],\n )\n const dayNumber = useMemo(() => new Intl.DateTimeFormat(language, { day: 'numeric' }), [language])\n const fullDate = useMemo(\n () => new Intl.DateTimeFormat(language, { dateStyle: 'long' }),\n [language],\n )\n\n const outOfBounds = (date: IsoDate) =>\n (min !== undefined && date < min) || (max !== undefined && date > max)\n\n const goToMonth = (next: IsoDate) => {\n if (month === undefined) setOwnMonth(next)\n onMonthChange?.(next)\n }\n\n const moveFocus = (next: IsoDate) => {\n setFocused(next)\n // Paging follows the cursor: arrowing off the end of a month shows the\n // next one rather than moving to a day nobody can see.\n if (parts(next).month !== parts(shown).month || parts(next).year !== parts(shown).year) {\n goToMonth(next)\n }\n }\n\n const onKeyDown = (event: KeyboardEvent) => {\n const jump: Record<string, () => IsoDate> = {\n ArrowRight: () => addDays(focused, 1),\n ArrowLeft: () => addDays(focused, -1),\n ArrowDown: () => addDays(focused, 7),\n ArrowUp: () => addDays(focused, -7),\n PageDown: () => addMonths(focused, 1),\n PageUp: () => addMonths(focused, -1),\n Home: () => addDays(focused, -((weekday(focused) - start + 7) % 7)),\n End: () => addDays(focused, 6 - ((weekday(focused) - start + 7) % 7)),\n }\n\n const move = jump[event.key]\n if (move) {\n event.preventDefault()\n moveFocus(move())\n return\n }\n\n if (event.key === 'Enter' || event.key === ' ') {\n event.preventDefault()\n if (!outOfBounds(focused)) onValueChange?.(focused)\n }\n }\n\n const now = today()\n\n return (\n <div className={cn('w-64 select-none', className)}>\n <div className=\"mb-2 flex items-center justify-between gap-1\">\n <button\n type=\"button\"\n aria-label={previousMonthLabel}\n onClick={() => goToMonth(addMonths(shown, -1))}\n className={cn(\n 'flex size-7 items-center justify-center rounded-md 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 )}\n >\n <svg viewBox=\"0 0 16 16\" className=\"size-4\" aria-hidden>\n <path\n d=\"M10 3L5 8l5 5\"\n fill=\"none\"\n stroke=\"currentColor\"\n strokeWidth=\"1.75\"\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n />\n </svg>\n </button>\n\n {/* The month is announced when it changes, so paging with the arrows\n * says where you have arrived rather than moving silently. */}\n <div aria-live=\"polite\" className=\"text-sm font-medium text-text\">\n {heading}\n </div>\n\n <button\n type=\"button\"\n aria-label={nextMonthLabel}\n onClick={() => goToMonth(addMonths(shown, 1))}\n className={cn(\n 'flex size-7 items-center justify-center rounded-md 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 )}\n >\n <svg viewBox=\"0 0 16 16\" className=\"size-4\" aria-hidden>\n <path\n d=\"M6 3l5 5-5 5\"\n fill=\"none\"\n stroke=\"currentColor\"\n strokeWidth=\"1.75\"\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n />\n </svg>\n </button>\n </div>\n\n {/* One tab stop for the whole grid, and the arrows move within it - the\n * arrangement a radio group has, and the reason a calendar is usable at\n * all: forty-two tab stops is not a control. */}\n <div\n role=\"grid\"\n aria-label={ariaLabel}\n tabIndex={0}\n onKeyDown={onKeyDown}\n className={cn(\n 'rounded-md',\n 'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent',\n )}\n >\n <div role=\"row\" className=\"mb-1 grid grid-cols-7\">\n {names.map((name) => (\n <div\n key={name}\n role=\"columnheader\"\n aria-label={name}\n className=\"caption py-1 text-center\"\n >\n {name}\n </div>\n ))}\n </div>\n\n {weeks.map((week) => (\n <div role=\"row\" key={week[0]} className=\"grid grid-cols-7\">\n {week.map((date) => {\n const outside = parts(date).month !== parts(shown).month\n const disabled = outOfBounds(date)\n const selected =\n value !== undefined &&\n (rangeEnd === undefined\n ? date === value\n : date === value || date === rangeEnd)\n const inRange =\n value !== undefined && rangeEnd !== undefined && date > value && date < rangeEnd\n\n return (\n <div role=\"gridcell\" key={date} aria-selected={selected || undefined}>\n <button\n type=\"button\"\n // Not a tab stop: the grid is the control. Announced with\n // its full date, because \"14\" on its own is not a date.\n tabIndex={-1}\n disabled={disabled}\n aria-label={fullDate.format(new Date(parts(date).year, parts(date).month - 1, parts(date).day))}\n aria-current={date === now ? 'date' : undefined}\n onClick={() => {\n setFocused(date)\n if (!disabled) onValueChange?.(date)\n }}\n className={cn(\n 'flex h-8 w-full items-center justify-center rounded-md text-sm tabular-nums',\n 'transition-colors',\n outside ? 'text-faint' : 'text-text',\n inRange && 'bg-accent-soft',\n selected && 'bg-accent font-medium text-on-accent',\n !selected && !disabled && 'hover:bg-soft',\n date === now && !selected && 'font-medium text-accent',\n date === focused && 'ring-1 ring-line-2',\n disabled && 'cursor-not-allowed opacity-40',\n )}\n >\n {dayNumber.format(new Date(parts(date).year, parts(date).month - 1, parts(date).day))}\n </button>\n </div>\n )\n })}\n </div>\n ))}\n </div>\n </div>\n )\n}\n"
|
|
609
609
|
}
|
|
610
610
|
]
|
|
611
611
|
},
|
|
@@ -616,7 +616,7 @@
|
|
|
616
616
|
"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.",
|
|
617
617
|
"dependencies": [
|
|
618
618
|
"@base-ui/react",
|
|
619
|
-
"dowel-ui@^0.
|
|
619
|
+
"dowel-ui@^0.33.0"
|
|
620
620
|
],
|
|
621
621
|
"registryDependencies": [],
|
|
622
622
|
"files": [
|
|
@@ -632,10 +632,11 @@
|
|
|
632
632
|
"name": "chip",
|
|
633
633
|
"type": "registry:ui",
|
|
634
634
|
"title": "Chip",
|
|
635
|
-
"description": "A badge you can act on
|
|
635
|
+
"description": "A badge you can act on - taken away, or switched - and a row of them that switch together.",
|
|
636
636
|
"dependencies": [
|
|
637
|
+
"@base-ui/react",
|
|
637
638
|
"class-variance-authority",
|
|
638
|
-
"dowel-ui@^0.
|
|
639
|
+
"dowel-ui@^0.33.0"
|
|
639
640
|
],
|
|
640
641
|
"registryDependencies": [],
|
|
641
642
|
"files": [
|
|
@@ -643,7 +644,7 @@
|
|
|
643
644
|
"path": "ui/chip.tsx",
|
|
644
645
|
"target": "@ui/chip.tsx",
|
|
645
646
|
"type": "registry:ui",
|
|
646
|
-
"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
|
|
647
|
+
"content": "import type { HTMLAttributes, MouseEvent, ReactNode } from 'react'\nimport { Toggle } from '@base-ui/react/toggle'\nimport { ToggleGroup } from '@base-ui/react/toggle-group'\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 - taken away, or switched - and a row of them that\n * switch together.\n *\n * A filter that can be removed, a tag with a count, a selected value in a\n * field - and a filter that is on or off, one of a row of kinds. The difference\n * from a Badge is entirely about whether something happens when you click it,\n * and if something does, that part is a real `<button>` with a real name, not a\n * decorative cross or a `<span>` with a click handler.\n *\n * `onRemove` is the reason this existed 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 *\n * `pressed` is the reason it grew. kilna alone drew twenty-three switchable\n * chips by hand, in three sizes and five recipes, because the set's Chip had\n * no state - and not one of them told a screen reader whether it was on. A\n * pressed chip is Base UI's Toggle: a `<button>` with `aria-pressed`, and the\n * fill that says \"on\" is drawn from that attribute, so the look and the\n * announcement cannot disagree.\n *\n * The two are exclusive, and the type says so. A chip that switches when\n * pressed and also holds a cross is a button inside a button: the cross is a\n * nested interactive element no reader can reach on its own, and a press on it\n * would switch the chip as well.\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 /*\n * What the chip says before anyone presses it.\n *\n * `outline` is the plain chip. The five tones are the status vocabulary\n * and the accent, in the same fill-and-ink pairs Badge uses, so a chip\n * and a badge side by side in one row do not disagree about what \"warn\"\n * looks like. `dashed` is the outline with a broken border: a chip that\n * stands for something not there yet - \"+ tag\", \"any kind\" - which a\n * solid border would present as one more value.\n */\n variant: {\n outline: 'border-line text-dim',\n dashed: 'border-dashed border-line-2 text-dim',\n soft: 'border-transparent bg-soft text-dim',\n accent: 'border-transparent bg-accent-soft font-medium text-accent',\n good: 'border-transparent bg-good-soft font-medium text-good',\n warn: 'border-transparent bg-warn-soft font-medium text-warn',\n bad: 'border-transparent bg-bad-soft font-medium text-bad',\n info: 'border-transparent bg-info-soft font-medium text-info',\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\n/*\n * The clothes a chip wears when it is a switch.\n *\n * \"On\" is the accent fill in semibold - the mockup's `.chip.on` - and it wins\n * over whatever tone the chip wears while off, because the question a row of\n * filters answers is \"which of these is on\", and it has to read the same for\n * every chip in the row. Drawn from `data-pressed`, which Base UI sets from the\n * same state that sets `aria-pressed`.\n *\n * `target-min`, because a chip is 21 pixels tall by design and a switch is a\n * pointer target: the hit area grows to the floor, the chip does not.\n */\nconst pressableClasses = cn(\n 'target-min cursor-pointer',\n 'hover:border-line-2 hover:text-text',\n 'data-[pressed]:border-transparent data-[pressed]:bg-accent-soft data-[pressed]:font-semibold data-[pressed]:text-accent',\n 'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent',\n 'data-[disabled]:cursor-not-allowed data-[disabled]:opacity-50',\n)\n\ninterface ChipOwnProps extends Omit<VariantProps<typeof chipVariants>, 'removable'> {\n /** Shown after the label, for a count. */\n count?: number\n className?: string\n children?: ReactNode\n}\n\n/*\n * Removable, switchable, or neither - and never both.\n *\n * `onRemove` travels with `removeLabel` as a union rather than as two\n * optionals, so a remove button exists only when there is a word for it.\n * There is no default word on purpose: a string the component invents is a\n * string the product cannot translate.\n *\n * A switch is a chip given `pressed`, `defaultPressed` or `onPressedChange` -\n * or a `value`, which is how a chip says which member of a ChipGroup it is.\n */\ntype Removable = {\n onRemove: () => void\n removeLabel: string\n pressed?: never\n defaultPressed?: never\n onPressedChange?: never\n value?: never\n disabled?: never\n}\n\ntype Pressable = {\n onRemove?: never\n removeLabel?: never\n /** Whether it is on. Controlled; pair it with `onPressedChange`. */\n pressed?: boolean\n /** Whether it starts on, for a chip nobody else needs to know about. */\n defaultPressed?: boolean\n onPressedChange?: (pressed: boolean) => void\n /** Which member of a ChipGroup this is. Inside a group the group owns the\n * state, and `pressed` is not read. */\n value?: string\n disabled?: boolean\n}\n\ntype Plain = {\n onRemove?: never\n removeLabel?: never\n pressed?: never\n defaultPressed?: never\n onPressedChange?: never\n value?: never\n disabled?: never\n}\n\nexport type ChipProps = ChipOwnProps &\n Omit<HTMLAttributes<HTMLElement>, 'onSelect' | 'defaultValue' | 'color'> &\n (Removable | Pressable | Plain)\n\nexport function Chip(props: ChipProps) {\n const {\n variant,\n count,\n onRemove,\n removeLabel,\n pressed,\n defaultPressed,\n onPressedChange,\n value,\n disabled,\n className,\n children,\n ...rest\n } = props\n\n const content = (\n <>\n {children}\n {count !== undefined && <span className=\"font-normal text-faint tabular-nums\">{count}</span>}\n </>\n )\n\n const switchable =\n pressed !== undefined || defaultPressed !== undefined || onPressedChange !== undefined || value !== undefined\n\n if (switchable) {\n return (\n <Toggle\n pressed={pressed}\n defaultPressed={defaultPressed}\n onPressedChange={onPressedChange ? (next) => onPressedChange(next) : undefined}\n value={value}\n disabled={disabled}\n className={cn(chipVariants({ variant, removable: false }), pressableClasses, className)}\n {...rest}\n >\n {content}\n </Toggle>\n )\n }\n\n return (\n <span className={cn(chipVariants({ variant, removable: Boolean(onRemove) }), className)} {...rest}>\n {content}\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\nexport interface ChipGroupProps extends Omit<HTMLAttributes<HTMLDivElement>, 'defaultValue' | 'onChange'> {\n /** What the row of chips is: \"Kind\", \"Status\". A group without a name is\n * a row of buttons a reader meets one at a time with no idea what they\n * choose between. */\n 'aria-label'?: string\n 'aria-labelledby'?: string\n /** The values of the chips that are on. An array in both modes, because\n * that is Base UI's shape and a single mode is a group that holds at most\n * one. */\n value?: readonly string[]\n defaultValue?: readonly string[]\n onValueChange?: (value: string[]) => void\n /** Several on at once - filters - rather than one at a time - a kind. */\n multiple?: boolean\n disabled?: boolean\n children: ReactNode\n}\n\n/**\n * A row of chips that switch together.\n *\n * Base UI's ToggleGroup underneath, so the row is one stop on the Tab key and\n * the arrows walk the chips - a filter bar of eight kinds is not eight Tab\n * presses long - and `multiple` decides whether pressing one lets go of the\n * others. The chips inside are ordinary Chips with a `value`.\n */\nexport function ChipGroup({ value, defaultValue, onValueChange, multiple = false, className, children, ...props }: ChipGroupProps) {\n return (\n <ToggleGroup\n value={value}\n defaultValue={defaultValue}\n onValueChange={onValueChange ? (next) => onValueChange(next as string[]) : undefined}\n multiple={multiple}\n className={cn('flex flex-wrap items-center gap-1.5', className)}\n {...props}\n >\n {children}\n </ToggleGroup>\n )\n}\n"
|
|
647
648
|
}
|
|
648
649
|
]
|
|
649
650
|
},
|
|
@@ -654,7 +655,7 @@
|
|
|
654
655
|
"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.",
|
|
655
656
|
"dependencies": [
|
|
656
657
|
"class-variance-authority",
|
|
657
|
-
"dowel-ui@^0.
|
|
658
|
+
"dowel-ui@^0.33.0"
|
|
658
659
|
],
|
|
659
660
|
"registryDependencies": [
|
|
660
661
|
"https://lacodda.github.io/dowel/r/copy-button.json"
|
|
@@ -675,7 +676,7 @@
|
|
|
675
676
|
"description": "One region of a screen that a reader shows or hides by choice - release notes under a version number, a filter panel, the raw payload under a log line. It differs from Accordion in the way a single light switch differs from a panel of them: there is one trigger and one panel, no group holding several and no rule about how many can be open, because there is only ever one to be open or not.",
|
|
676
677
|
"dependencies": [
|
|
677
678
|
"@base-ui/react",
|
|
678
|
-
"dowel-ui@^0.
|
|
679
|
+
"dowel-ui@^0.33.0"
|
|
679
680
|
],
|
|
680
681
|
"registryDependencies": [],
|
|
681
682
|
"files": [
|
|
@@ -693,7 +694,7 @@
|
|
|
693
694
|
"title": "ColorField",
|
|
694
695
|
"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.",
|
|
695
696
|
"dependencies": [
|
|
696
|
-
"dowel-ui@^0.
|
|
697
|
+
"dowel-ui@^0.33.0"
|
|
697
698
|
],
|
|
698
699
|
"registryDependencies": [
|
|
699
700
|
"https://lacodda.github.io/dowel/r/input.json"
|
|
@@ -713,7 +714,7 @@
|
|
|
713
714
|
"title": "ColumnResizeHandle",
|
|
714
715
|
"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.",
|
|
715
716
|
"dependencies": [
|
|
716
|
-
"dowel-ui@^0.
|
|
717
|
+
"dowel-ui@^0.33.0"
|
|
717
718
|
],
|
|
718
719
|
"registryDependencies": [],
|
|
719
720
|
"files": [
|
|
@@ -733,7 +734,7 @@
|
|
|
733
734
|
"dependencies": [
|
|
734
735
|
"@base-ui/react",
|
|
735
736
|
"class-variance-authority",
|
|
736
|
-
"dowel-ui@^0.
|
|
737
|
+
"dowel-ui@^0.33.0"
|
|
737
738
|
],
|
|
738
739
|
"registryDependencies": [
|
|
739
740
|
"https://lacodda.github.io/dowel/r/input.json",
|
|
@@ -745,7 +746,7 @@
|
|
|
745
746
|
"path": "ui/combobox.tsx",
|
|
746
747
|
"target": "@ui/combobox.tsx",
|
|
747
748
|
"type": "registry:ui",
|
|
748
|
-
"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 { usePopupContainer } from './layer'\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 raised host of the overlay this is\n * opened inside (`layer.tsx`), and to the document body when there is none -\n * either way not the element it was opened from, whose `overflow` would clip\n * it. Pass an element to put it somewhere else, such as a container being\n * screenshotted. */\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 // Inside an overlay, the overlay's raised host rather than the body - or\n // this popup draws under the dialog, drawer or popover that opened it. See\n // `layer.tsx`. Outside every overlay the hook gives `undefined`: the body.\n const host = usePopupContainer()\n\n return (\n <Base.Portal container={container ?? host}>\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"
|
|
749
|
+
"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 { usePopupContainer } from './layer'\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 raised host of the overlay this is\n * opened inside (`layer.tsx`), and to the document body when there is none -\n * either way not the element it was opened from, whose `overflow` would clip\n * it. Pass an element to put it somewhere else, such as a container being\n * screenshotted. */\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 // Inside an overlay, the overlay's raised host rather than the body - or\n // this popup draws under the dialog, drawer or popover that opened it. See\n // `layer.tsx`. Outside every overlay the hook gives `undefined`: the body.\n const host = usePopupContainer()\n\n return (\n <Base.Portal container={container ?? host}>\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('caption px-2 py-1.5', className)}\n {...props}\n />\n )\n}\n"
|
|
749
750
|
}
|
|
750
751
|
]
|
|
751
752
|
},
|
|
@@ -757,7 +758,7 @@
|
|
|
757
758
|
"dependencies": [
|
|
758
759
|
"@base-ui/react",
|
|
759
760
|
"class-variance-authority",
|
|
760
|
-
"dowel-ui@^0.
|
|
761
|
+
"dowel-ui@^0.33.0"
|
|
761
762
|
],
|
|
762
763
|
"registryDependencies": [
|
|
763
764
|
"https://lacodda.github.io/dowel/r/combobox.json",
|
|
@@ -769,7 +770,7 @@
|
|
|
769
770
|
"path": "ui/command-palette.tsx",
|
|
770
771
|
"target": "@ui/command-palette.tsx",
|
|
771
772
|
"type": "registry:ui",
|
|
772
|
-
"content": "import { useRef, type ReactNode } 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 { comboboxItemVariants } from './combobox'\nimport { Kbd } from './kbd'\nimport { LayerProvider } from './layer'\n\n/*\n * CommandPalette.\n *\n * One box that finds anything: the shortcut opens it, typing narrows a list,\n * Enter runs what is highlighted.\n *\n * It is a Combobox rather than a Dialog with a field in it, and that is Base\n * UI's own arrangement rather than a shortcut taken here: put the input\n * *inside* the popup and the popup becomes `role=\"dialog\"` on its own, with\n * the input still announced as the combobox that owns the list. The filtering,\n * the highlight, the arrow keys and the type-ahead are the ones Combobox\n * already has - there is no second implementation of any of it.\n *\n * What is left for the product is everything that makes a palette that\n * product's: what the items are, how they are grouped, what running one does.\n * `items` is deliberately `unknown[]` - a palette lists commands, works,\n * settings and recent files in the same box, and a type that admitted only\n * strings would push every product into the same stringly-typed workaround.\n */\n\nexport const commandPalettePopupVariants = cva(\n [\n 'flex w-[min(36rem,calc(100vw-2rem))] flex-col overflow-hidden',\n 'rounded-xl border border-line bg-raise text-text shadow-float',\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 md: 'max-h-
|
|
773
|
+
"content": "import { useRef, type ReactNode } 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 { comboboxItemVariants } from './combobox'\nimport { Kbd } from './kbd'\nimport { LayerProvider } from './layer'\n\n/*\n * CommandPalette.\n *\n * One box that finds anything: the shortcut opens it, typing narrows a list,\n * Enter runs what is highlighted.\n *\n * It is a Combobox rather than a Dialog with a field in it, and that is Base\n * UI's own arrangement rather than a shortcut taken here: put the input\n * *inside* the popup and the popup becomes `role=\"dialog\"` on its own, with\n * the input still announced as the combobox that owns the list. The filtering,\n * the highlight, the arrow keys and the type-ahead are the ones Combobox\n * already has - there is no second implementation of any of it.\n *\n * What is left for the product is everything that makes a palette that\n * product's: what the items are, how they are grouped, what running one does.\n * `items` is deliberately `unknown[]` - a palette lists commands, works,\n * settings and recent files in the same box, and a type that admitted only\n * strings would push every product into the same stringly-typed workaround.\n */\n\nexport const commandPalettePopupVariants = cva(\n [\n 'flex w-[min(36rem,calc(100vw-2rem))] flex-col overflow-hidden',\n 'rounded-xl border border-line bg-raise text-text shadow-float',\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 md: 'max-h-96',\n lg: 'max-h-128',\n },\n },\n defaultVariants: { size: 'md' },\n },\n)\n\n/** The root. Controlled by `open`/`onOpenChange`, because what opens a palette\n * is a shortcut somewhere else in the application. */\nexport const CommandPalette = Base.Root\n\n/** A row. The same clothes as a Combobox row, on purpose: a palette is a list\n * of choices, and two lists of choices in one product should not differ.\n *\n * They did differ, for as long as this was a bare re-export: the comment said\n * \"the same clothes\" and the component wore none, so the rows inherited the\n * popup's 16px and stood a third taller than every other list in the set. A\n * live run caught it - the palette looked like a different product. */\nexport function CommandPaletteItem({ className, ...props }: Base.Item.Props) {\n return <Base.Item className={cn(comboboxItemVariants(), className)} {...props} />\n}\n\n/** The list. Takes a render function over the filtered items. */\nexport function CommandPaletteList({ className, ...props }: Base.List.Props) {\n return <Base.List className={cn('overflow-y-auto p-1', className)} {...props} />\n}\n\n/** Shown when nothing matches. The words are the product's.\n *\n * Base UI keeps it mounted so the announcement fires, which means its padding\n * is spent whether or not it has anything to say - and a palette with six\n * results had a 48px hole under the field. It collapses when empty instead. */\nexport function CommandPaletteEmpty({ className, ...props }: Base.Empty.Props) {\n return (\n <Base.Empty\n className={cn('px-2 py-3 text-center text-sm text-faint empty:hidden empty:p-0', className)}\n {...props}\n />\n )\n}\n\n/** A labelled group, for a palette that lists more than one kind of thing. */\nexport const CommandPaletteGroup = Base.Group\n\n/** The caption above a group. */\nexport const CommandPaletteGroupLabel = Base.GroupLabel\n\n/** The rows of one group, as a render function over that group's items.\n *\n * A palette that lists works, versions and notes together is a `List` over the\n * groups with a `Collection` inside each. Mapping a group's rows by hand also\n * works, but then the palette has to be told how to match an item to a value -\n * and for rows fetched fresh from a server, identity comparison never does. */\nexport const CommandPaletteCollection = Base.Collection\n\nexport interface CommandPalettePopupProps\n extends Omit<Base.Popup.Props, 'aria-label'>,\n VariantProps<typeof commandPalettePopupVariants> {\n /**\n * What the palette is called, for a screen reader. Required, and required\n * for a reason particular to this component: the popup is a dialog, and a\n * dialog is named by its own visible title - which a palette does not have,\n * because the field is the first thing in it.\n *\n * So the name has to come from outside, it has to be the product's word, and\n * nothing else can supply it. A palette without one is announced as \"dialog\"\n * and nothing more; making the prop required is what stops that shipping.\n */\n 'aria-label': string\n /** Where to portal to. Defaults to the document body. */\n container?: Base.Portal.Props['container']\n}\n\n/** The palette itself: a dim over the page, and the box in the upper third of\n * it - where the eye already is, rather than dead centre. */\nexport function CommandPalettePopup({\n size,\n container,\n className,\n children,\n ...props\n}: CommandPalettePopupProps) {\n const portal = useRef<HTMLDivElement>(null)\n\n return (\n <Base.Portal ref={portal} container={container}>\n <Base.Backdrop\n className={cn(\n 'fixed inset-0 bg-black/55 backdrop-blur-[2px]',\n '[z-index:var(--z-overlay)]',\n '[transition:opacity_var(--duration-quick)_var(--ease-out)]',\n 'data-[closed]:opacity-0 data-[starting-style]:opacity-0',\n )}\n />\n <Base.Positioner\n className=\"[z-index:var(--z-palette)]\"\n /* The anchor is a point at the top of the viewport, given explicitly.\n *\n * A positioner places a popup against an anchor and hides itself with\n * an inline `opacity: 0` until it has measured one. A palette has no\n * trigger to point at - it is opened by a keystroke - so without this\n * the measure never resolves: the popup sits in the DOM at the right\n * size, fully transparent, rendering nothing and reporting no error.\n * Found by reading the computed style off the positioner rather than\n * the popup, which was opaque the whole time.\n *\n * A zero-height rectangle a fifth of the way down puts the palette\n * where the eye already is rather than dead centre. */\n anchor={{\n getBoundingClientRect: () => {\n const width = typeof window === 'undefined' ? 0 : window.innerWidth\n const top = typeof window === 'undefined' ? 0 : window.innerHeight * 0.18\n return new DOMRect(width / 2, top, 0, 0)\n },\n }}\n positionMethod=\"fixed\"\n side=\"bottom\"\n align=\"center\"\n sideOffset={0}\n alignOffset={0}\n >\n <Base.Popup\n className={cn(commandPalettePopupVariants({ size }), className)}\n {...props}\n >\n {/* A tooltip or a menu opened from a result rides above the palette,\n * which stands above every modal. See `layer.tsx`. */}\n <LayerProvider above=\"palette\" mount={portal}>{children}</LayerProvider>\n </Base.Popup>\n </Base.Positioner>\n </Base.Portal>\n )\n}\n\nexport interface CommandPaletteInputProps extends Base.Input.Props {\n /** Shown at the right of the field, as `['Esc']`. Decorative. */\n hint?: string[]\n}\n\n/** The field. Sits inside the popup, which is what makes the popup a dialog\n * and the field its combobox. */\nexport function CommandPaletteInput({ hint, className, ...props }: CommandPaletteInputProps) {\n return (\n <div className=\"flex items-center gap-2 border-b border-line px-3\">\n <MagnifierIcon />\n <Base.Input\n className={cn(\n 'h-11 w-full bg-transparent text-sm text-text placeholder:text-faint',\n 'focus-visible:outline-none',\n className,\n )}\n {...props}\n />\n {hint && <Kbd keys={hint} aria-hidden className=\"shrink-0\" />}\n </div>\n )\n}\n\n/** The row's own layout: an icon, what it is, and where it lives. */\nexport function CommandPaletteRow({\n icon,\n hint,\n className,\n children,\n ...props\n}: {\n icon?: ReactNode\n hint?: ReactNode\n className?: string\n children: ReactNode\n} & Omit<React.HTMLAttributes<HTMLDivElement>, 'children'>) {\n return (\n <div className={cn('flex w-full items-center gap-2.5', className)} {...props}>\n {icon}\n <span className=\"min-w-0 flex-1 truncate\">{children}</span>\n {hint !== undefined && <span className=\"shrink-0 text-2xs text-faint\">{hint}</span>}\n </div>\n )\n}\n\nexport { comboboxItemVariants as commandPaletteItemVariants }\n\nfunction MagnifierIcon() {\n return (\n <svg\n viewBox=\"0 0 16 16\"\n width=\"15\"\n height=\"15\"\n fill=\"none\"\n stroke=\"currentColor\"\n strokeWidth=\"1.6\"\n aria-hidden\n className=\"shrink-0 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"
|
|
773
774
|
}
|
|
774
775
|
]
|
|
775
776
|
},
|
|
@@ -781,7 +782,7 @@
|
|
|
781
782
|
"dependencies": [
|
|
782
783
|
"@base-ui/react",
|
|
783
784
|
"class-variance-authority",
|
|
784
|
-
"dowel-ui@^0.
|
|
785
|
+
"dowel-ui@^0.33.0"
|
|
785
786
|
],
|
|
786
787
|
"registryDependencies": [
|
|
787
788
|
"https://lacodda.github.io/dowel/r/dialog.json",
|
|
@@ -803,7 +804,7 @@
|
|
|
803
804
|
"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>`.",
|
|
804
805
|
"dependencies": [
|
|
805
806
|
"@base-ui/react",
|
|
806
|
-
"dowel-ui@^0.
|
|
807
|
+
"dowel-ui@^0.33.0"
|
|
807
808
|
],
|
|
808
809
|
"registryDependencies": [
|
|
809
810
|
"https://lacodda.github.io/dowel/r/layer.json",
|
|
@@ -814,7 +815,7 @@
|
|
|
814
815
|
"path": "ui/context-menu.tsx",
|
|
815
816
|
"target": "@ui/context-menu.tsx",
|
|
816
817
|
"type": "registry:ui",
|
|
817
|
-
"content": "import { ContextMenu as Base } from '@base-ui/react/context-menu'\nimport { menuItemVariants, menuPopupVariants } from './menu'\nimport { cn } from 'dowel-ui'\nimport { usePopupContainer } from './layer'\n\n/*\n * ContextMenu.\n *\n * The same list of actions as Menu, opened the other way round: by right\n * click, or by a long press on a touch screen, over an *area* rather than\n * from a button. So the trigger is not a control - it is the region the menu\n * belongs to, a row, a canvas, a file tile - and it renders a `<div>`.\n *\n * That difference is the whole component. Everything below the Root is Menu's\n * own: Base UI re-exports the Portal, Positioner, Popup, Item and the rest\n * from the menu package, so the popup that opens here is literally the same\n * popup, with the same keyboard, the same type-ahead and the same submenus.\n *\n * Which is why the clothes are imported rather than copied. Two `cva` calls\n * that started identical drift within a release - one gets a padding fix, the\n * other does not - and then a product has two menus that are almost the same,\n * which is worse than two that differ on purpose.\n *\n * What Base UI does *not* give this one is `openOnHover`, `modal` or a\n * `handle`: a context menu is opened by a gesture over an anchor point, not\n * by a trigger element it can be attached to from elsewhere.\n */\n\n/** The root. It positions against the pointer, so there is nothing to anchor\n * and nothing to control but `onOpenChange`. */\nexport const ContextMenu = Base.Root\n\n/** The area that opens it. A `<div>`, not a button: give it `render` to make\n * it the row or the canvas it belongs to rather than a wrapper. */\nexport const ContextMenuTrigger = Base.Trigger\n\n/** A labelled group of items, for a menu long enough to need headings. */\nexport const ContextMenuGroup = Base.Group\n\n/** A submenu, opened from a `ContextMenuSubTrigger` inside the parent. */\nexport const ContextMenuSub = Base.SubmenuRoot\n\nexport interface ContextMenuPopupProps extends Base.Popup.Props {\n /** How wide the popup starts. The same three as Menu's. */\n size?: 'sm' | 'md' | 'lg'\n /** Where to portal to. Defaults to the raised host of the overlay this is\n * opened inside (`layer.tsx`), and to the document body when there is none -\n * either way not the element it was opened from, whose `overflow` would clip\n * it. Pass an element to put it somewhere else, such as a container being\n * screenshotted. */\n container?: Base.Portal.Props['container']\n}\n\n/** The panel. Portalled, and positioned against the point that was clicked\n * rather than against an element. */\nexport function ContextMenuPopup({\n size,\n container,\n className,\n children,\n ...props\n}: ContextMenuPopupProps) {\n // Inside an overlay, the overlay's raised host rather than the body - or\n // this popup draws under the dialog, drawer or popover that opened it. See\n // `layer.tsx`. Outside every overlay the hook gives `undefined`: the body.\n const host = usePopupContainer()\n\n return (\n <Base.Portal container={container ?? host}>\n <Base.Positioner className=\"[z-index:var(--z-menu)]\">\n <Base.Popup className={cn(menuPopupVariants({ size }), className)} {...props}>\n {children}\n </Base.Popup>\n </Base.Positioner>\n </Base.Portal>\n )\n}\n\nexport interface ContextMenuItemProps extends Base.Item.Props {\n /** `danger` draws the destructive one apart, in the colour of something\n * that cannot be undone. */\n tone?: 'default' | 'danger'\n}\n\n/** An action. */\nexport function ContextMenuItem({ tone, className, ...props }: ContextMenuItemProps) {\n return <Base.Item className={cn(menuItemVariants({ tone }), className)} {...props} />\n}\n\n/** An item that opens a submenu. Drawn like any other, because it is one. */\nexport function ContextMenuSubTrigger({ tone, className, ...props }: ContextMenuItemProps) {\n return <Base.SubmenuTrigger className={cn(menuItemVariants({ tone }), className)} {...props} />\n}\n\n/** An item that carries a tick. The state is the caller's. */\nexport function ContextMenuCheckboxItem({ tone, className, ...props }: ContextMenuItemProps) {\n return <Base.CheckboxItem className={cn(menuItemVariants({ tone }), className)} {...props} />\n}\n\n/** The tick itself, drawn only when the item is checked. */\nexport const ContextMenuCheckboxIndicator = Base.CheckboxItemIndicator\n\n/** A line between groups of items. Decorative, and marked as such. */\nexport function ContextMenuSeparator({ className, ...props }: Base.Separator.Props) {\n return <Base.Separator className={cn('-mx-1 my-1 h-px bg-line', className)} {...props} />\n}\n\n/** The caption above a group. */\nexport function ContextMenuGroupLabel({ className, ...props }: Base.GroupLabel.Props) {\n return (\n <Base.GroupLabel\n className={cn('px-2 py-1.5
|
|
818
|
+
"content": "import { ContextMenu as Base } from '@base-ui/react/context-menu'\nimport { menuItemVariants, menuPopupVariants } from './menu'\nimport { cn } from 'dowel-ui'\nimport { usePopupContainer } from './layer'\n\n/*\n * ContextMenu.\n *\n * The same list of actions as Menu, opened the other way round: by right\n * click, or by a long press on a touch screen, over an *area* rather than\n * from a button. So the trigger is not a control - it is the region the menu\n * belongs to, a row, a canvas, a file tile - and it renders a `<div>`.\n *\n * That difference is the whole component. Everything below the Root is Menu's\n * own: Base UI re-exports the Portal, Positioner, Popup, Item and the rest\n * from the menu package, so the popup that opens here is literally the same\n * popup, with the same keyboard, the same type-ahead and the same submenus.\n *\n * Which is why the clothes are imported rather than copied. Two `cva` calls\n * that started identical drift within a release - one gets a padding fix, the\n * other does not - and then a product has two menus that are almost the same,\n * which is worse than two that differ on purpose.\n *\n * What Base UI does *not* give this one is `openOnHover`, `modal` or a\n * `handle`: a context menu is opened by a gesture over an anchor point, not\n * by a trigger element it can be attached to from elsewhere.\n */\n\n/** The root. It positions against the pointer, so there is nothing to anchor\n * and nothing to control but `onOpenChange`. */\nexport const ContextMenu = Base.Root\n\n/** The area that opens it. A `<div>`, not a button: give it `render` to make\n * it the row or the canvas it belongs to rather than a wrapper. */\nexport const ContextMenuTrigger = Base.Trigger\n\n/** A labelled group of items, for a menu long enough to need headings. */\nexport const ContextMenuGroup = Base.Group\n\n/** A submenu, opened from a `ContextMenuSubTrigger` inside the parent. */\nexport const ContextMenuSub = Base.SubmenuRoot\n\nexport interface ContextMenuPopupProps extends Base.Popup.Props {\n /** How wide the popup starts. The same three as Menu's. */\n size?: 'sm' | 'md' | 'lg'\n /** Where to portal to. Defaults to the raised host of the overlay this is\n * opened inside (`layer.tsx`), and to the document body when there is none -\n * either way not the element it was opened from, whose `overflow` would clip\n * it. Pass an element to put it somewhere else, such as a container being\n * screenshotted. */\n container?: Base.Portal.Props['container']\n}\n\n/** The panel. Portalled, and positioned against the point that was clicked\n * rather than against an element. */\nexport function ContextMenuPopup({\n size,\n container,\n className,\n children,\n ...props\n}: ContextMenuPopupProps) {\n // Inside an overlay, the overlay's raised host rather than the body - or\n // this popup draws under the dialog, drawer or popover that opened it. See\n // `layer.tsx`. Outside every overlay the hook gives `undefined`: the body.\n const host = usePopupContainer()\n\n return (\n <Base.Portal container={container ?? host}>\n <Base.Positioner className=\"[z-index:var(--z-menu)]\">\n <Base.Popup className={cn(menuPopupVariants({ size }), className)} {...props}>\n {children}\n </Base.Popup>\n </Base.Positioner>\n </Base.Portal>\n )\n}\n\nexport interface ContextMenuItemProps extends Base.Item.Props {\n /** `danger` draws the destructive one apart, in the colour of something\n * that cannot be undone. */\n tone?: 'default' | 'danger'\n}\n\n/** An action. */\nexport function ContextMenuItem({ tone, className, ...props }: ContextMenuItemProps) {\n return <Base.Item className={cn(menuItemVariants({ tone }), className)} {...props} />\n}\n\n/** An item that opens a submenu. Drawn like any other, because it is one. */\nexport function ContextMenuSubTrigger({ tone, className, ...props }: ContextMenuItemProps) {\n return <Base.SubmenuTrigger className={cn(menuItemVariants({ tone }), className)} {...props} />\n}\n\n/** An item that carries a tick. The state is the caller's. */\nexport function ContextMenuCheckboxItem({ tone, className, ...props }: ContextMenuItemProps) {\n return <Base.CheckboxItem className={cn(menuItemVariants({ tone }), className)} {...props} />\n}\n\n/** The tick itself, drawn only when the item is checked. */\nexport const ContextMenuCheckboxIndicator = Base.CheckboxItemIndicator\n\n/** A line between groups of items. Decorative, and marked as such. */\nexport function ContextMenuSeparator({ className, ...props }: Base.Separator.Props) {\n return <Base.Separator className={cn('-mx-1 my-1 h-px bg-line', className)} {...props} />\n}\n\n/** The caption above a group. */\nexport function ContextMenuGroupLabel({ className, ...props }: Base.GroupLabel.Props) {\n return (\n <Base.GroupLabel\n className={cn('caption px-2 py-1.5', className)}\n {...props}\n />\n )\n}\n"
|
|
818
819
|
}
|
|
819
820
|
]
|
|
820
821
|
},
|
|
@@ -824,7 +825,7 @@
|
|
|
824
825
|
"title": "CopyButton",
|
|
825
826
|
"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.",
|
|
826
827
|
"dependencies": [
|
|
827
|
-
"dowel-ui@^0.
|
|
828
|
+
"dowel-ui@^0.33.0"
|
|
828
829
|
],
|
|
829
830
|
"registryDependencies": [],
|
|
830
831
|
"files": [
|
|
@@ -842,7 +843,7 @@
|
|
|
842
843
|
"title": "Copyable",
|
|
843
844
|
"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.",
|
|
844
845
|
"dependencies": [
|
|
845
|
-
"dowel-ui@^0.
|
|
846
|
+
"dowel-ui@^0.33.0"
|
|
846
847
|
],
|
|
847
848
|
"registryDependencies": [],
|
|
848
849
|
"files": [
|
|
@@ -860,7 +861,7 @@
|
|
|
860
861
|
"title": "DatePicker",
|
|
861
862
|
"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.",
|
|
862
863
|
"dependencies": [
|
|
863
|
-
"dowel-ui@^0.
|
|
864
|
+
"dowel-ui@^0.33.0"
|
|
864
865
|
],
|
|
865
866
|
"registryDependencies": [
|
|
866
867
|
"https://lacodda.github.io/dowel/r/calendar.json",
|
|
@@ -883,7 +884,7 @@
|
|
|
883
884
|
"title": "DateRangePicker",
|
|
884
885
|
"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.",
|
|
885
886
|
"dependencies": [
|
|
886
|
-
"dowel-ui@^0.
|
|
887
|
+
"dowel-ui@^0.33.0"
|
|
887
888
|
],
|
|
888
889
|
"registryDependencies": [
|
|
889
890
|
"https://lacodda.github.io/dowel/r/calendar.json",
|
|
@@ -908,7 +909,7 @@
|
|
|
908
909
|
"dependencies": [
|
|
909
910
|
"@base-ui/react",
|
|
910
911
|
"class-variance-authority",
|
|
911
|
-
"dowel-ui@^0.
|
|
912
|
+
"dowel-ui@^0.33.0"
|
|
912
913
|
],
|
|
913
914
|
"registryDependencies": [
|
|
914
915
|
"https://lacodda.github.io/dowel/r/layer.json"
|
|
@@ -945,7 +946,7 @@
|
|
|
945
946
|
"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.",
|
|
946
947
|
"dependencies": [
|
|
947
948
|
"class-variance-authority",
|
|
948
|
-
"dowel-ui@^0.
|
|
949
|
+
"dowel-ui@^0.33.0"
|
|
949
950
|
],
|
|
950
951
|
"registryDependencies": [
|
|
951
952
|
"https://lacodda.github.io/dowel/r/copy-button.json",
|
|
@@ -967,7 +968,7 @@
|
|
|
967
968
|
"description": "The line between one part of a screen and the next. The set already had four of them - in the menu, the select, the context menu and the action bar - and each was written inside the thing it divided, so a screen that wanted a rule between two sections had nothing and reached for a bare `<hr>` or a `div` with a background.",
|
|
968
969
|
"dependencies": [
|
|
969
970
|
"class-variance-authority",
|
|
970
|
-
"dowel-ui@^0.
|
|
971
|
+
"dowel-ui@^0.33.0"
|
|
971
972
|
],
|
|
972
973
|
"registryDependencies": [],
|
|
973
974
|
"files": [
|
|
@@ -975,7 +976,7 @@
|
|
|
975
976
|
"path": "ui/divider.tsx",
|
|
976
977
|
"target": "@ui/divider.tsx",
|
|
977
978
|
"type": "registry:ui",
|
|
978
|
-
"content": "import type { HTMLAttributes, ReactNode } from 'react'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { cn } from 'dowel-ui'\n\n/*\n * Divider.\n *\n * The line between one part of a screen and the next. The set already had\n * four of them - in the menu, the select, the context menu and the action bar\n * - and each was written inside the thing it divided, so a screen that wanted\n * a rule between two sections had nothing and reached for a bare `<hr>` or a\n * `div` with a background.\n *\n * Those two are not the same as this one, and the difference is the reason\n * this component is not a `<div className=\"h-px bg-line\" />`. A rule between\n * sections is a semantic boundary: `role=\"separator\"` is what tells a reader\n * that the content after it is a different thing, and a `div` tells them\n * nothing. A rule that is only decoration - a hairline inside a card, a tick\n * between two numbers - is the opposite case, and `decorative` takes it back\n * out of the accessibility tree, because a reader announcing \"separator\" six\n * times in one row is being read the styling.\n *\n * With a `label`, the line becomes a captioned break: the rule runs to the\n * caption, the caption sits in it, and the rule continues. It is a heading\n * for a run of content that does not deserve a heading - \"Today\", \"Archived\",\n * \"or\".\n *\n * A `div` of its own rather than Base UI's Separator, which was the obvious\n * dependency and does not do the one thing this is for: it renders a `div`\n * with `aria-orientation` and no `role`, so nothing is announced as a\n * separator at all - measured, not assumed, by rendering it. And there is no\n * way to take the role back off for a decorative rule, because there was\n * never one on. Two elements and two attributes are not worth borrowing\n * anyway; the decision here is which attributes, and that is the part a\n * dependency was not making.\n */\n\nexport const dividerVariants = cva('shrink-0 bg-line', {\n variants: {\n orientation: {\n horizontal: 'h-px w-full',\n /* `self-stretch` rather than `h-full`: a vertical rule in a flex row of\n * buttons has no height of its own to be a percentage of, and `h-full`\n * resolved to zero - a rule that was there in the markup and invisible\n * on the screen. */\n vertical: 'w-px self-stretch',\n },\n /* How much air it has. `none` is for a rule that is already inside\n * something padded - a list's rows, a table. */\n spacing: {\n none: '',\n sm: '',\n md: '',\n lg: '',\n },\n },\n compoundVariants: [\n { orientation: 'horizontal', spacing: 'sm', className: 'my-2' },\n { orientation: 'horizontal', spacing: 'md', className: 'my-4' },\n { orientation: 'horizontal', spacing: 'lg', className: 'my-8' },\n { orientation: 'vertical', spacing: 'sm', className: 'mx-2' },\n { orientation: 'vertical', spacing: 'md', className: 'mx-4' },\n { orientation: 'vertical', spacing: 'lg', className: 'mx-8' },\n ],\n defaultVariants: { orientation: 'horizontal', spacing: 'none' },\n})\n\nexport interface DividerProps\n extends Omit<HTMLAttributes<HTMLDivElement>, 'children'>,\n Omit<VariantProps<typeof dividerVariants>, 'orientation'> {\n /** Declared here rather than taken from the variants: cva types a variant\n * as nullable, and `aria-orientation` is not - a `null` passed through\n * would be a type error at the call site and an attribute React drops. */\n orientation?: 'horizontal' | 'vertical'\n /** A caption sitting in the line: \"Today\", \"Archived\", \"or\". Horizontal\n * only - there is no reading direction that puts a word inside a vertical\n * rule. */\n label?: ReactNode\n /** A rule that is styling rather than structure. Taken out of the\n * accessibility tree, so a reader is not told about it. */\n decorative?: boolean\n}\n\nexport function Divider({\n orientation = 'horizontal',\n spacing,\n label,\n decorative = false,\n className,\n ...props\n}: DividerProps) {\n if (label !== undefined && orientation === 'horizontal') {\n return (\n <div\n // The row is what carries the separator's meaning; the two rules\n // inside it are drawing. Without `aria-hidden` on them a reader hears\n // the break announced twice with a word in the middle.\n role={decorative ? 'presentation' : 'separator'}\n aria-orientation={decorative ? undefined : 'horizontal'}\n className={cn(\n 'flex w-full items-center gap-3',\n dividerVariants({ orientation, spacing }),\n // The row is not itself the line: it holds two. The variant is\n // still asked for its spacing, so a captioned break sits in the\n // same rhythm as a plain one.\n 'h-auto bg-transparent',\n className,\n )}\n {...props}\n >\n <span aria-hidden className=\"h-px flex-1 bg-line\" />\n <span className=\"shrink-0
|
|
979
|
+
"content": "import type { HTMLAttributes, ReactNode } from 'react'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { cn } from 'dowel-ui'\n\n/*\n * Divider.\n *\n * The line between one part of a screen and the next. The set already had\n * four of them - in the menu, the select, the context menu and the action bar\n * - and each was written inside the thing it divided, so a screen that wanted\n * a rule between two sections had nothing and reached for a bare `<hr>` or a\n * `div` with a background.\n *\n * Those two are not the same as this one, and the difference is the reason\n * this component is not a `<div className=\"h-px bg-line\" />`. A rule between\n * sections is a semantic boundary: `role=\"separator\"` is what tells a reader\n * that the content after it is a different thing, and a `div` tells them\n * nothing. A rule that is only decoration - a hairline inside a card, a tick\n * between two numbers - is the opposite case, and `decorative` takes it back\n * out of the accessibility tree, because a reader announcing \"separator\" six\n * times in one row is being read the styling.\n *\n * With a `label`, the line becomes a captioned break: the rule runs to the\n * caption, the caption sits in it, and the rule continues. It is a heading\n * for a run of content that does not deserve a heading - \"Today\", \"Archived\",\n * \"or\".\n *\n * A `div` of its own rather than Base UI's Separator, which was the obvious\n * dependency and does not do the one thing this is for: it renders a `div`\n * with `aria-orientation` and no `role`, so nothing is announced as a\n * separator at all - measured, not assumed, by rendering it. And there is no\n * way to take the role back off for a decorative rule, because there was\n * never one on. Two elements and two attributes are not worth borrowing\n * anyway; the decision here is which attributes, and that is the part a\n * dependency was not making.\n */\n\nexport const dividerVariants = cva('shrink-0 bg-line', {\n variants: {\n orientation: {\n horizontal: 'h-px w-full',\n /* `self-stretch` rather than `h-full`: a vertical rule in a flex row of\n * buttons has no height of its own to be a percentage of, and `h-full`\n * resolved to zero - a rule that was there in the markup and invisible\n * on the screen. */\n vertical: 'w-px self-stretch',\n },\n /* How much air it has. `none` is for a rule that is already inside\n * something padded - a list's rows, a table. */\n spacing: {\n none: '',\n sm: '',\n md: '',\n lg: '',\n },\n },\n compoundVariants: [\n { orientation: 'horizontal', spacing: 'sm', className: 'my-2' },\n { orientation: 'horizontal', spacing: 'md', className: 'my-4' },\n { orientation: 'horizontal', spacing: 'lg', className: 'my-8' },\n { orientation: 'vertical', spacing: 'sm', className: 'mx-2' },\n { orientation: 'vertical', spacing: 'md', className: 'mx-4' },\n { orientation: 'vertical', spacing: 'lg', className: 'mx-8' },\n ],\n defaultVariants: { orientation: 'horizontal', spacing: 'none' },\n})\n\nexport interface DividerProps\n extends Omit<HTMLAttributes<HTMLDivElement>, 'children'>,\n Omit<VariantProps<typeof dividerVariants>, 'orientation'> {\n /** Declared here rather than taken from the variants: cva types a variant\n * as nullable, and `aria-orientation` is not - a `null` passed through\n * would be a type error at the call site and an attribute React drops. */\n orientation?: 'horizontal' | 'vertical'\n /** A caption sitting in the line: \"Today\", \"Archived\", \"or\". Horizontal\n * only - there is no reading direction that puts a word inside a vertical\n * rule. */\n label?: ReactNode\n /** A rule that is styling rather than structure. Taken out of the\n * accessibility tree, so a reader is not told about it. */\n decorative?: boolean\n}\n\nexport function Divider({\n orientation = 'horizontal',\n spacing,\n label,\n decorative = false,\n className,\n ...props\n}: DividerProps) {\n if (label !== undefined && orientation === 'horizontal') {\n return (\n <div\n // The row is what carries the separator's meaning; the two rules\n // inside it are drawing. Without `aria-hidden` on them a reader hears\n // the break announced twice with a word in the middle.\n role={decorative ? 'presentation' : 'separator'}\n aria-orientation={decorative ? undefined : 'horizontal'}\n className={cn(\n 'flex w-full items-center gap-3',\n dividerVariants({ orientation, spacing }),\n // The row is not itself the line: it holds two. The variant is\n // still asked for its spacing, so a captioned break sits in the\n // same rhythm as a plain one.\n 'h-auto bg-transparent',\n className,\n )}\n {...props}\n >\n <span aria-hidden className=\"h-px flex-1 bg-line\" />\n <span className=\"caption shrink-0\">\n {label}\n </span>\n <span aria-hidden className=\"h-px flex-1 bg-line\" />\n </div>\n )\n }\n\n return (\n <div\n // `presentation` rather than no role at all: an element with no role is\n // still a `div`, which a reader may announce as a group when it carries\n // other attributes. This says outright that there is nothing here.\n role={decorative ? 'presentation' : 'separator'}\n // Only on the one that has a role to orient. A presentational element\n // carrying an aria attribute is itself a violation - the attribute\n // describes a role the element has just disclaimed.\n aria-orientation={decorative ? undefined : orientation}\n className={cn(dividerVariants({ orientation, spacing }), className)}\n {...props}\n />\n )\n}\n\nexport interface SectionHeaderProps extends Omit<HTMLAttributes<HTMLDivElement>, 'title'> {\n title: ReactNode\n description?: ReactNode\n /** Buttons belonging to this section rather than to the screen - Add,\n * Collapse, a link out. */\n actions?: ReactNode\n}\n\n/**\n * The heading over a part of a screen, with what belongs to that part at the\n * far end of the same row.\n *\n * An `h2`: `PageHeader` holds the screen's `h1`, and a section is one level\n * inside it, so a reader moving by heading walks the screen's actual\n * structure. It is the between-size heading the products kept writing by\n * hand, above `SectionLabel`'s uppercase caption and below the page's title.\n */\nexport function SectionHeader({\n title,\n description,\n actions,\n className,\n ...props\n}: SectionHeaderProps) {\n return (\n <div\n className={cn('mb-3 flex flex-wrap items-start justify-between gap-x-4 gap-y-1', className)}\n {...props}\n >\n <div className=\"flex min-w-0 flex-col gap-0.5\">\n <h2 className=\"text-base font-semibold text-text\">{title}</h2>\n {description === undefined ? null : <p className=\"text-sm text-dim\">{description}</p>}\n </div>\n {actions === undefined ? null : (\n <div className=\"flex shrink-0 items-center gap-2\">{actions}</div>\n )}\n </div>\n )\n}\n"
|
|
979
980
|
}
|
|
980
981
|
]
|
|
981
982
|
},
|
|
@@ -987,7 +988,7 @@
|
|
|
987
988
|
"dependencies": [
|
|
988
989
|
"@base-ui/react",
|
|
989
990
|
"class-variance-authority",
|
|
990
|
-
"dowel-ui@^0.
|
|
991
|
+
"dowel-ui@^0.33.0"
|
|
991
992
|
],
|
|
992
993
|
"registryDependencies": [
|
|
993
994
|
"https://lacodda.github.io/dowel/r/dialog.json",
|
|
@@ -1008,7 +1009,7 @@
|
|
|
1008
1009
|
"title": "DurationField",
|
|
1009
1010
|
"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.",
|
|
1010
1011
|
"dependencies": [
|
|
1011
|
-
"dowel-ui@^0.
|
|
1012
|
+
"dowel-ui@^0.33.0"
|
|
1012
1013
|
],
|
|
1013
1014
|
"registryDependencies": [
|
|
1014
1015
|
"https://lacodda.github.io/dowel/r/input.json"
|
|
@@ -1029,7 +1030,7 @@
|
|
|
1029
1030
|
"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.",
|
|
1030
1031
|
"dependencies": [
|
|
1031
1032
|
"class-variance-authority",
|
|
1032
|
-
"dowel-ui@^0.
|
|
1033
|
+
"dowel-ui@^0.33.0"
|
|
1033
1034
|
],
|
|
1034
1035
|
"registryDependencies": [],
|
|
1035
1036
|
"files": [
|
|
@@ -1037,7 +1038,7 @@
|
|
|
1037
1038
|
"path": "ui/empty-state.tsx",
|
|
1038
1039
|
"target": "@ui/empty-state.tsx",
|
|
1039
1040
|
"type": "registry:ui",
|
|
1040
|
-
"content": "import type { HTMLAttributes, ReactNode } from 'react'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { cn } from 'dowel-ui'\n\n/*\n * What a screen says when there is nothing on it.\n *\n * Three kinds of nothing, and a product that draws the same panel for all\n * three is telling the reader the wrong thing twice:\n *\n * **empty** - there is nothing here yet, and that is normal. The panel says\n * what would be here and offers the one action that makes it appear.\n *\n * **filtered** - there is plenty here, just none of it matching. The way out\n * is to widen the filter, not to create anything.\n *\n * **error** - it could not be fetched. Nothing is missing; something failed,\n * and the way out is to try again.\n *\n * They differ in what the reader should do next, which is exactly what an\n * empty screen is for, so they are a variant rather than three components.\n *\n * `action` is the whole point of the component: **an empty screen with no way\n * out is a dead end.** It is optional in the type because a panel inside a\n * larger screen can borrow the way out from its surroundings - but a full-page\n * empty state without one is a bug the reader cannot report.\n *\n * The mark is the line's hexagon, drawn in the current text colour rather than\n * a product's accent. A full-strength logo in an empty panel shouts; this is a\n * watermark, and it is `aria-hidden` because it says nothing a reader needs.\n */\n\nexport const emptyStateVariants = cva(
|
|
1041
|
+
"content": "import type { HTMLAttributes, ReactNode } from 'react'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { cn } from 'dowel-ui'\n\n/*\n * What a screen says when there is nothing on it.\n *\n * Three kinds of nothing, and a product that draws the same panel for all\n * three is telling the reader the wrong thing twice:\n *\n * **empty** - there is nothing here yet, and that is normal. The panel says\n * what would be here and offers the one action that makes it appear.\n *\n * **filtered** - there is plenty here, just none of it matching. The way out\n * is to widen the filter, not to create anything.\n *\n * **error** - it could not be fetched. Nothing is missing; something failed,\n * and the way out is to try again.\n *\n * They differ in what the reader should do next, which is exactly what an\n * empty screen is for, so they are a variant rather than three components.\n *\n * `action` is the whole point of the component: **an empty screen with no way\n * out is a dead end.** It is optional in the type because a panel inside a\n * larger screen can borrow the way out from its surroundings - but a full-page\n * empty state without one is a bug the reader cannot report.\n *\n * The mark is the line's hexagon, drawn in the current text colour rather than\n * a product's accent. A full-strength logo in an empty panel shouts; this is a\n * watermark, and it is `aria-hidden` because it says nothing a reader needs.\n *\n * **`plain` is the same three kinds with no frame.** The framed panel is for\n * a screen or a region that is empty; inside something that already has a\n * frame - a widget, a card, a panel's list - a dashed box and a watermark are\n * a frame inside a frame, and kilna drew seven such empties as bare text\n * instead, without the way out. Plain keeps the words and the action and\n * drops the rest: no border, no mark, set in the flow on the left, where the\n * content it stands in for would have started.\n */\n\nexport const emptyStateVariants = cva('flex flex-col gap-3', {\n variants: {\n variant: {\n empty: '',\n filtered: '',\n error: '',\n },\n plain: {\n false: 'items-center justify-center rounded-xl p-8 text-center',\n true: 'items-start gap-2 text-left',\n },\n },\n compoundVariants: [\n { plain: false, variant: 'empty', class: 'border border-dashed border-line-2' },\n { plain: false, variant: 'filtered', class: 'border border-dashed border-line-2' },\n // Solid rather than dashed: a dashed border reads as a placeholder for\n // something that belongs there, and a failure is not that.\n { plain: false, variant: 'error', class: 'border border-bad/40 bg-bad-soft/30' },\n ],\n defaultVariants: { variant: 'empty', plain: false },\n})\n\n/** The line's hexagon, at whatever size the caller asks for.\n *\n * Inlined rather than fetched: it is decorative, so it should not cost a\n * request, and it has to take the theme's colour - a file could not. */\nexport function EmptyMark({ className }: { className?: string }) {\n return (\n <svg viewBox=\"0 0 100 100\" aria-hidden className={cn('size-14', className)}>\n <polygon\n points=\"50,5 89,27.5 89,72.5 50,95 11,72.5 11,27.5\"\n fill=\"none\"\n stroke=\"currentColor\"\n strokeWidth={6}\n strokeLinejoin=\"round\"\n />\n </svg>\n )\n}\n\n/** The same hexagon, cut by a slash. For a filter that matched nothing: the\n * shape is there, the contents are not. */\nexport function FilteredMark({ className }: { className?: string }) {\n return (\n <svg viewBox=\"0 0 100 100\" aria-hidden className={cn('size-14', className)}>\n <polygon\n points=\"50,5 89,27.5 89,72.5 50,95 11,72.5 11,27.5\"\n fill=\"none\"\n stroke=\"currentColor\"\n strokeWidth={6}\n strokeLinejoin=\"round\"\n />\n <line x1=\"22\" y1=\"78\" x2=\"78\" y2=\"22\" stroke=\"currentColor\" strokeWidth={6} strokeLinecap=\"round\" />\n </svg>\n )\n}\n\n/** A hexagon with a corner broken out of it. For something that failed. */\nexport function ErrorMark({ className }: { className?: string }) {\n return (\n <svg viewBox=\"0 0 100 100\" aria-hidden className={cn('size-14', className)}>\n <polyline\n points=\"50,5 89,27.5 89,72.5 50,95 11,72.5 11,27.5 50,5\"\n fill=\"none\"\n stroke=\"currentColor\"\n strokeWidth={6}\n strokeLinejoin=\"round\"\n strokeLinecap=\"round\"\n // The gap is the break: the outline is drawn as a line rather than a\n // closed shape so one edge can be missing.\n strokeDasharray=\"150 34\"\n />\n </svg>\n )\n}\n\nexport interface EmptyStateProps\n extends Omit<HTMLAttributes<HTMLDivElement>, 'title'>,\n VariantProps<typeof emptyStateVariants> {\n /** What is not here, in the product's words. */\n title: ReactNode\n /** Why, or what to do about it. */\n body?: ReactNode\n /** The one thing worth doing here. An empty screen with no way out is a\n * dead end. */\n action?: ReactNode\n /** Something other than the default mark - a product's own illustration.\n * A plain empty state draws no mark, its own or the default. */\n mark?: ReactNode\n}\n\nexport function EmptyState({\n variant = 'empty',\n plain = false,\n title,\n body,\n action,\n mark,\n className,\n ...props\n}: EmptyStateProps) {\n const defaultMark =\n variant === 'error' ? (\n <ErrorMark className=\"text-bad/60\" />\n ) : variant === 'filtered' ? (\n <FilteredMark className=\"text-line-2\" />\n ) : (\n <EmptyMark className=\"text-line-2\" />\n )\n\n if (plain) {\n return (\n <div className={cn(emptyStateVariants({ variant, plain }), className)} {...props}>\n <div>\n <p className={cn('text-sm', variant === 'error' ? 'text-bad' : 'text-dim')}>{title}</p>\n {body !== undefined && <p className=\"mt-0.5 text-xs text-faint\">{body}</p>}\n </div>\n {action}\n </div>\n )\n }\n\n return (\n <div className={cn(emptyStateVariants({ variant, plain }), className)} {...props}>\n {mark ?? defaultMark}\n <div>\n <p className={cn('font-medium', variant === 'error' && 'text-bad')}>{title}</p>\n {body !== undefined && <p className=\"mt-1 text-sm text-dim\">{body}</p>}\n </div>\n {action}\n </div>\n )\n}\n"
|
|
1041
1042
|
}
|
|
1042
1043
|
]
|
|
1043
1044
|
},
|
|
@@ -1060,6 +1061,22 @@
|
|
|
1060
1061
|
}
|
|
1061
1062
|
]
|
|
1062
1063
|
},
|
|
1064
|
+
{
|
|
1065
|
+
"name": "field-draft",
|
|
1066
|
+
"type": "registry:ui",
|
|
1067
|
+
"title": "FieldDraft",
|
|
1068
|
+
"description": "The rules between a value stored somewhere and the text someone is typing over it, as a pure step and a hook to spread on an input.",
|
|
1069
|
+
"dependencies": [],
|
|
1070
|
+
"registryDependencies": [],
|
|
1071
|
+
"files": [
|
|
1072
|
+
{
|
|
1073
|
+
"path": "ui/field-draft.tsx",
|
|
1074
|
+
"target": "@ui/field-draft.tsx",
|
|
1075
|
+
"type": "registry:ui",
|
|
1076
|
+
"content": "import { useState, type KeyboardEvent } from 'react'\n\n/*\n * FieldDraft.\n *\n * The rules between a value stored somewhere and the text someone is typing\n * over it, as a pure step and a hook to spread on an input.\n *\n * A field that writes to a database, a file or a server holds two things at\n * once - what is stored and what is in the box - and every product that wrote\n * one by hand broke a different rule between them. kilna broke all of them,\n * one screen at a time, which is why they are written down here:\n *\n * - **Leaving writes only a change.** The Overview wrote on every blur, so\n * tabbing through twelve fields was twelve operations in the history and\n * twelve toasts.\n * - **While nobody is typing, the box follows the stored value.** The fields\n * were uncontrolled, so a value a plugin wrote kept showing the old one -\n * and the next blur wrote the old one back over it.\n * - **While someone is typing, their words win until they leave.** A refetch\n * in the middle of a sentence does not take it away from them.\n * - **Escape puts back what is stored and writes nothing.**\n * - **Enter on something unreadable keeps the words and says so.** \"1:75\" is\n * a typo one key from right; throwing it away teaches nothing. Leaving the\n * field with it is different: the box goes back to what is stored, because\n * a box showing a value that was not saved is a second truth.\n *\n * `stepDraft` is the whole of it, pure, so it is tested without a DOM; the\n * hook only wires it to an input. The donor is kilna's `lib/fieldDraft.ts`.\n * InlineField is built on this, and a product's own field can use it directly:\n * spread `useFieldDraft(...)` onto an Input or a Textarea.\n */\n\nexport interface DraftState {\n /** What the box shows. */\n draft: string\n /** What is stored, as last seen, in the same spelling the box uses. */\n stored: string\n /** Whether someone is in the field. */\n editing: boolean\n /** Whether the last attempt to commit was refused as unreadable. */\n invalid: boolean\n}\n\nexport type DraftEvent =\n | { type: 'stored'; value: string }\n | { type: 'focus' }\n | { type: 'type'; text: string }\n | { type: 'refuse' }\n | { type: 'escape' }\n | { type: 'leave' }\n\nexport function startDraft(stored: string): DraftState {\n return { draft: stored, stored, editing: false, invalid: false }\n}\n\n/** The next state, and the text to write when the event commits one. */\nexport function stepDraft(state: DraftState, event: DraftEvent): { state: DraftState; commit: string | null } {\n switch (event.type) {\n case 'stored':\n return {\n // Mid-sentence, the new stored value is noted but the box is left\n // alone; otherwise the box takes it at once.\n state: state.editing\n ? { ...state, stored: event.value }\n : { draft: event.value, stored: event.value, editing: false, invalid: false },\n commit: null,\n }\n case 'focus':\n return { state: { ...state, editing: true }, commit: null }\n case 'type':\n return { state: { ...state, draft: event.text, editing: true, invalid: false }, commit: null }\n case 'refuse':\n return { state: { ...state, invalid: true }, commit: null }\n case 'escape':\n return { state: { ...state, draft: state.stored, editing: false, invalid: false }, commit: null }\n case 'leave':\n return {\n state: { ...state, editing: false, invalid: false },\n commit: state.draft !== state.stored ? state.draft : null,\n }\n }\n}\n\ntype Editable = HTMLInputElement | HTMLTextAreaElement\n\nexport interface FieldDraftOptions {\n /** Keep Enter for new lines, for a Textarea. Otherwise Enter leaves the\n * field, which writes it. */\n multiline?: boolean\n /** Whether the typed text can be written at all. Unreadable text is never\n * handed to `onCommit`: on Enter the field keeps it and is marked invalid,\n * on leaving it goes back to what is stored. */\n readable?: (text: string) => boolean\n}\n\n/**\n * An input's props for a stored value - `value`, `aria-invalid` and the\n * handlers, nothing else - to spread onto an Input or a Textarea.\n *\n * `value` is the stored value already spelled as text. `onCommit` receives\n * the typed text when it differs, and may refuse it by returning `false` - a\n * server that said no, a number past the end of the board - and the box goes\n * back to what is stored.\n */\nexport function useFieldDraft(\n value: string,\n onCommit: (text: string) => boolean | void,\n { multiline = false, readable }: FieldDraftOptions = {},\n) {\n const [state, setState] = useState(() => startDraft(value))\n\n // The stored value moved underneath - a refetch, a plugin, an undo. Taken in\n // during render, the way a controlled value is, so the first paint after it\n // already shows it.\n if (value !== state.stored) {\n setState(stepDraft(state, { type: 'stored', value }).state)\n }\n\n const send = (event: DraftEvent) => {\n const next = stepDraft(state, event)\n if (next.commit === null) {\n setState(next.state)\n return\n }\n const refused = (readable !== undefined && !readable(next.commit)) || onCommit(next.commit) === false\n setState(refused ? stepDraft(next.state, { type: 'escape' }).state : next.state)\n }\n\n return {\n value: state.draft,\n 'aria-invalid': state.invalid || undefined,\n onFocus: () => send({ type: 'focus' }),\n onChange: (event: { target: { value: string } }) => send({ type: 'type', text: event.target.value }),\n onBlur: () => send({ type: 'leave' }),\n onKeyDown: (event: KeyboardEvent<Editable>) => {\n if (event.key === 'Escape') {\n // Taken here, so a screen-level Escape - closing a panel, a dialog -\n // does not also fire while the field is only putting its text back.\n event.stopPropagation()\n send({ type: 'escape' })\n return\n }\n // Not while an input method is composing: Enter there picks the\n // candidate, and leaving the field would commit half a word.\n if (event.key !== 'Enter' || multiline || event.nativeEvent.isComposing) return\n event.preventDefault()\n if (readable !== undefined && state.draft !== state.stored && !readable(state.draft)) {\n send({ type: 'refuse' })\n return\n }\n event.currentTarget.blur()\n },\n }\n}\n"
|
|
1077
|
+
}
|
|
1078
|
+
]
|
|
1079
|
+
},
|
|
1063
1080
|
{
|
|
1064
1081
|
"name": "field",
|
|
1065
1082
|
"type": "registry:ui",
|
|
@@ -1067,7 +1084,7 @@
|
|
|
1067
1084
|
"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.",
|
|
1068
1085
|
"dependencies": [
|
|
1069
1086
|
"@base-ui/react",
|
|
1070
|
-
"dowel-ui@^0.
|
|
1087
|
+
"dowel-ui@^0.33.0"
|
|
1071
1088
|
],
|
|
1072
1089
|
"registryDependencies": [],
|
|
1073
1090
|
"files": [
|
|
@@ -1075,7 +1092,7 @@
|
|
|
1075
1092
|
"path": "ui/field.tsx",
|
|
1076
1093
|
"target": "@ui/field.tsx",
|
|
1077
1094
|
"type": "registry:ui",
|
|
1078
|
-
"content": "import type
|
|
1095
|
+
"content": "import { useId, type ReactElement, type ReactNode } from 'react'\nimport { Field as Base } from '@base-ui/react/field'\nimport { Fieldset } from '@base-ui/react/fieldset'\nimport { cn } from 'dowel-ui'\n\n/*\n * Field - the label, the control, and what the form has to say about it.\n *\n * Every form is the same four parts repeated: a name for the control, the\n * control, sometimes a hint, and sometimes an error. Written by hand each\n * time, they drift - the label loses its `htmlFor`, the hint is a `<div>` no\n * screen reader mentions, the error appears in red and is announced by\n * nothing at all. This is that arrangement, once.\n *\n * The wiring is the point, and Base UI does it: the label points at the\n * control, the description and the error are named by `aria-describedby`, and\n * the control is marked invalid while an error is showing. None of that is\n * visible when it works, which is exactly why a product stops doing it.\n *\n * `error` is a string this is given, not a rule this enforces. dowel has no\n * opinion about where the string came from - Base UI's own `validate`, a\n * schema, react-hook-form, or a server that just said no - because a design\n * system that picked a form library would be choosing for products that\n * already chose. The one thing it insists on is that an error, once it\n * exists, is announced and points at the field it belongs to.\n *\n * The label is always rendered. A field whose name is only a placeholder\n * loses that name the moment someone types, and a placeholder is not a label\n * to anything that reads the page aloud; `labelHidden` takes it off the\n * screen and leaves it in the accessibility tree.\n */\n\nexport interface FieldProps {\n /** What the control is called. Always rendered; `labelHidden` only takes it\n * off the screen. */\n label: ReactNode\n /** The control itself - Input, Textarea, Select, Checkbox, anything.\n *\n * A single element rather than arbitrary nodes, because it is handed to\n * Base UI to carry the field's id and its `aria-*` wiring. Two children, or\n * a bare string, would leave the label naming nothing. */\n children: ReactElement\n /** A hint under the control. Hidden while an error is showing: two lines of\n * small print under one field is one line too many, and the error is the\n * one that matters. */\n help?: ReactNode\n /** What is wrong, if anything. Its presence is what marks the control\n * invalid - there is no separate `invalid` prop to keep in step. */\n error?: ReactNode\n /** Keep the label for screen readers but take it off the screen. For a\n * field whose meaning is obvious in context - a search box in a toolbar. */\n labelHidden?: boolean\n className?: string\n /** Marks the field required, which is a statement about the form rather\n * than about validation: the label gets the mark a reader looks for. */\n required?: boolean\n /** The control's `name`, forwarded so a `Form` can attach a server error to\n * this field by name. */\n name?: string\n disabled?: boolean\n}\n\nexport function Field({\n label,\n children,\n help,\n error,\n labelHidden = false,\n className,\n required = false,\n name,\n disabled = false,\n}: FieldProps) {\n const invalid = error !== undefined && error !== null && error !== false\n\n return (\n <Base.Root\n name={name}\n disabled={disabled}\n // Base UI marks the control invalid from its own validity state; this\n // says so for an error that arrived from anywhere else.\n invalid={invalid || undefined}\n className={cn('flex flex-col gap-1.5', className)}\n >\n <Base.Label\n className={cn(\n 'caption',\n // Off the screen, still in the accessibility tree. Not\n // `display: none`, which would take it out of both.\n labelHidden && 'sr-only',\n )}\n >\n {label}\n {required && (\n <span aria-hidden className=\"ml-0.5 text-bad\">\n *\n </span>\n )}\n </Base.Label>\n\n {/* The control is handed to Base UI rather than merely nested inside\n * it. This is the whole component: a plain child renders a label whose\n * `for` points at an id nothing carries, so the label looks wired and\n * names nothing - which is exactly the bug this exists to prevent, and\n * it is invisible in a screenshot. `render` gives the child the id,\n * `aria-labelledby` and `aria-describedby` instead. */}\n <Base.Control render={children} />\n\n {/* The error wins the one line under the control. `match` is not used:\n * the string is already the decision - this renders whatever it was\n * handed rather than asking the browser what is wrong. */}\n {invalid ? (\n <Base.Error className=\"text-xs text-bad\" match>\n {error}\n </Base.Error>\n ) : (\n help !== undefined && <Base.Description className=\"text-xs text-dim\">{help}</Base.Description>\n )}\n </Base.Root>\n )\n}\n\nexport interface FieldGroupProps {\n /** What the group is called - \"Type\", \"References\". Always rendered;\n * `labelHidden` only takes it off the screen. */\n label: ReactNode\n /** The controls: a row of chips, a segment, a grid of pictures, several\n * checkboxes. Anything - a group names its contents rather than handing\n * them an id. */\n children: ReactNode\n /** A hint under the group, read as the group's description. Hidden while\n * an error is showing, as in Field. */\n help?: ReactNode\n /** What is wrong with the group as a whole - \"choose at least one\". */\n error?: ReactNode\n labelHidden?: boolean\n required?: boolean\n /** Disables every native control inside, which is what a `<fieldset>`\n * does on its own. */\n disabled?: boolean\n className?: string\n}\n\n/**\n * FieldGroup - one name for several controls.\n *\n * Field labels exactly one control, and it has to: the label points at an id\n * the control carries. A row of chips, a segment or a grid of pictures has no\n * single control to point at, and the obvious workaround - a `<label>`\n * wrapped around the row - is a trap. A label with no `for` labels the first\n * labelable element inside it, and a click on any plain part of it is\n * forwarded there. kilna measured what that does in its style dialog: a click\n * on the caption \"Type\" silently set the first type, and a click on\n * \"References\" pressed the hidden remove cross of the first picture, which\n * deleted it for good.\n *\n * So a group is a `<fieldset>` with a legend, through Base UI's Fieldset: the\n * group is announced by its name as a reader enters it, and a caption has\n * nothing a click could be forwarded to. The caption is the same `caption` a\n * Field's label is, so the two kinds of field sit in one form as one kind.\n */\nexport function FieldGroup({\n label,\n children,\n help,\n error,\n labelHidden = false,\n required = false,\n disabled = false,\n className,\n}: FieldGroupProps) {\n const noteId = useId()\n const invalid = error !== undefined && error !== null && error !== false\n const note = invalid ? error : help\n\n return (\n <Fieldset.Root\n disabled={disabled}\n aria-describedby={note === undefined ? undefined : noteId}\n data-invalid={invalid ? '' : undefined}\n // `min-w-0`: a fieldset's intrinsic minimum is its content, so a row of\n // chips inside a grid cell refuses to wrap and pushes the grid apart.\n className={cn('m-0 flex min-w-0 flex-col gap-1.5 border-0 p-0', className)}\n >\n <Fieldset.Legend className={cn('caption', labelHidden && 'sr-only')}>\n {label}\n {required && (\n <span aria-hidden className=\"ml-0.5 text-bad\">\n *\n </span>\n )}\n </Fieldset.Legend>\n\n {children}\n\n {note === undefined ? null : (\n <span id={noteId} className={cn('text-xs', invalid ? 'text-bad' : 'text-dim')}>\n {note}\n </span>\n )}\n </Fieldset.Root>\n )\n}\n"
|
|
1079
1096
|
}
|
|
1080
1097
|
]
|
|
1081
1098
|
},
|
|
@@ -1085,7 +1102,7 @@
|
|
|
1085
1102
|
"title": "FileDrop",
|
|
1086
1103
|
"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.",
|
|
1087
1104
|
"dependencies": [
|
|
1088
|
-
"dowel-ui@^0.
|
|
1105
|
+
"dowel-ui@^0.33.0"
|
|
1089
1106
|
],
|
|
1090
1107
|
"registryDependencies": [],
|
|
1091
1108
|
"files": [
|
|
@@ -1103,7 +1120,7 @@
|
|
|
1103
1120
|
"title": "FilterPopover",
|
|
1104
1121
|
"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.",
|
|
1105
1122
|
"dependencies": [
|
|
1106
|
-
"dowel-ui@^0.
|
|
1123
|
+
"dowel-ui@^0.33.0"
|
|
1107
1124
|
],
|
|
1108
1125
|
"registryDependencies": [
|
|
1109
1126
|
"https://lacodda.github.io/dowel/r/button.json",
|
|
@@ -1125,7 +1142,7 @@
|
|
|
1125
1142
|
"description": "The layout jump a loading picture leaves behind is not a styling defect, it is a missing number: the browser has nowhere to put the box until the file arrives and tells it the pixel size, so everything below the picture slides down and back up. `AspectRatio` is that number, given up front by the caller rather than discovered by the browser - a plain box on CSS `aspect-ratio` that reserves its space the instant it is in the tree, and lets any child (an `<img>`, a map, a video) fill it with `size-full`.",
|
|
1126
1143
|
"dependencies": [
|
|
1127
1144
|
"class-variance-authority",
|
|
1128
|
-
"dowel-ui@^0.
|
|
1145
|
+
"dowel-ui@^0.33.0"
|
|
1129
1146
|
],
|
|
1130
1147
|
"registryDependencies": [],
|
|
1131
1148
|
"files": [
|
|
@@ -1137,13 +1154,33 @@
|
|
|
1137
1154
|
}
|
|
1138
1155
|
]
|
|
1139
1156
|
},
|
|
1157
|
+
{
|
|
1158
|
+
"name": "inline-field",
|
|
1159
|
+
"type": "registry:ui",
|
|
1160
|
+
"title": "InlineField",
|
|
1161
|
+
"description": "A value that is edited where it is shown: click it, type, Enter or leave to keep, Escape to take it back.",
|
|
1162
|
+
"dependencies": [
|
|
1163
|
+
"dowel-ui@^0.33.0"
|
|
1164
|
+
],
|
|
1165
|
+
"registryDependencies": [
|
|
1166
|
+
"https://lacodda.github.io/dowel/r/field-draft.json"
|
|
1167
|
+
],
|
|
1168
|
+
"files": [
|
|
1169
|
+
{
|
|
1170
|
+
"path": "ui/inline-field.tsx",
|
|
1171
|
+
"target": "@ui/inline-field.tsx",
|
|
1172
|
+
"type": "registry:ui",
|
|
1173
|
+
"content": "import { useId, type ReactNode } from 'react'\nimport { cn } from 'dowel-ui'\nimport { useFieldDraft } from './field-draft'\n\n/*\n * InlineField.\n *\n * A value that is edited where it is shown: click it, type, Enter or leave to\n * keep, Escape to take it back.\n *\n * A board of fields - the numbers on a work's overview, the cells of a\n * storyboard - is read far more often than it is edited, so every value there\n * sits as text until it is touched. A modal to change one BPM is a lost\n * context and a click too many, and a column of always-on boxes is a form\n * nobody asked to fill in. kilna had nine of these editors, each with its own\n * idea of what Enter and Escape mean; this is one idea, and its rules are\n * FieldDraft's.\n *\n * **It is always an `<input>`.** Not text that swaps to an input on click: a\n * swap loses the caret position, steals a render, and leaves the value\n * unreachable by Tab. The box is simply undressed until it is hovered or\n * focused - the mockup's `.inl .v`: a border appears under the pointer, the\n * accent when you are in it.\n *\n * **What a value is, is a codec.** `parse` reads what was typed and `format`\n * spells the stored value, so the box can never show one thing and store\n * another. Text needs neither. `numberCodec` and `timecodeCodec` are the two\n * the line reached for; a product's own - a key signature, a tempo range - is\n * two functions. `parse` answers `null` for an empty box (a value cleared,\n * not a value of zero) and `undefined` for text it cannot read, which the\n * field refuses rather than guesses at.\n */\n\nexport interface Codec<T> {\n /** The value in the text, `null` for none, `undefined` when it cannot be\n * read. */\n parse: (text: string) => T | null | undefined\n /** The one spelling the value is shown in. */\n format: (value: T) => string\n /** Set in the monospaced face with tabular figures, so a column of them\n * lines up. */\n mono?: boolean\n /** The keyboard a phone offers. */\n inputMode?: 'text' | 'decimal' | 'numeric'\n}\n\n/** Text as it is typed. Nothing to read, so nothing to refuse. */\nexport const textCodec: Codec<string> = {\n parse: (text) => text,\n format: (value) => value,\n}\n\n/** A number, with a comma accepted as the decimal point - half the world\n * writes one. Empty is `null`, and anything else that is not a number is\n * refused. */\nexport const numberCodec: Codec<number> = {\n parse: (text) => {\n const trimmed = text.trim().replace(',', '.')\n if (trimmed === '') return null\n if (!/^-?\\d+(?:\\.\\d+)?$/.test(trimmed)) return undefined\n return Number(trimmed)\n },\n format: (value) => String(value),\n mono: true,\n inputMode: 'decimal',\n}\n\n/**\n * Seconds as a person types them on a timeline: `83`, `1:23`, `0:04.5`,\n * `1:00:00`. A minute of seventy-five seconds is a typo and is refused, not\n * read as a minute and a quarter. Written back as `m:ss`, with a tenth only\n * when there is one - hours are not split out, because a clip is minutes long\n * and `60:00` reads better beside `0:04` than `1:00:00` does.\n */\nexport const timecodeCodec: Codec<number> = {\n parse: (text) => {\n const trimmed = text.trim()\n if (trimmed === '') return null\n const parts = trimmed.split(':')\n if (parts.length > 3) return undefined\n let seconds = 0\n for (const [index, part] of parts.entries()) {\n // Only the last part may carry a fraction; the others are whole units.\n const last = index === parts.length - 1\n if (!(last ? /^\\d+(?:\\.\\d+)?$/ : /^\\d+$/).test(part)) return undefined\n const amount = Number(part)\n if (index > 0 && amount >= 60) return undefined\n seconds = seconds * 60 + amount\n }\n return seconds\n },\n format: (seconds) => {\n if (!Number.isFinite(seconds) || seconds < 0) return ''\n // Rounded to a tenth before splitting, so 59.96 does not read as 0:60.0.\n const tenths = Math.round(seconds * 10)\n const minutes = Math.floor(tenths / 600)\n const rest = tenths - minutes * 600\n const whole = String(Math.floor(rest / 10)).padStart(2, '0')\n const fraction = rest % 10\n return fraction === 0 ? `${minutes}:${whole}` : `${minutes}:${whole}.${fraction}`\n },\n mono: true,\n}\n\ninterface InlineFieldBaseProps {\n /** What the value is called: the caption above it. */\n label: ReactNode\n /** Keep the caption for a screen reader and take it off the screen - for a\n * cell whose column header already names it. */\n labelHidden?: boolean\n /** What an empty value shows. A dash reads as \"nothing yet\"; a blank box\n * reads as a rendering fault. */\n placeholder?: string\n disabled?: boolean\n className?: string\n}\n\nexport interface InlineFieldProps<T> extends InlineFieldBaseProps {\n /** The stored value; `null` for none. */\n value: T | null\n /** Called once, with the parsed value, when a change is kept. Return\n * `false` to refuse it - a server that said no - and the field goes back\n * to the stored value. */\n onCommit: (value: T | null) => boolean | void\n codec: Codec<T>\n}\n\nexport function InlineField(\n props: InlineFieldBaseProps & {\n value: string | null\n onCommit: (value: string | null) => boolean | void\n codec?: Codec<string>\n },\n): ReactNode\nexport function InlineField<T>(props: InlineFieldProps<T>): ReactNode\nexport function InlineField<T>({\n label,\n labelHidden = false,\n placeholder,\n disabled = false,\n className,\n value,\n onCommit,\n // Text is the default, and only for a value that is text: the overloads\n // above refuse a number without a codec at compile time.\n codec = textCodec as unknown as Codec<T>,\n}: Omit<InlineFieldProps<T>, 'codec'> & { codec?: Codec<T> }) {\n const id = useId()\n const stored = value === null ? '' : codec.format(value)\n\n const draft = useFieldDraft(\n stored,\n (text) => {\n const parsed = codec.parse(text)\n // `readable` has already turned away `undefined`; this is the type\n // system's copy of the same fact.\n if (parsed === undefined) return false\n return onCommit(parsed)\n },\n { readable: (text) => codec.parse(text) !== undefined },\n )\n\n return (\n <div className={cn('flex min-w-0 flex-col gap-0.5', className)}>\n <label htmlFor={id} className={cn('caption', labelHidden && 'sr-only')}>\n {label}\n </label>\n <input\n id={id}\n type=\"text\"\n disabled={disabled}\n placeholder={placeholder}\n inputMode={codec.inputMode}\n autoComplete=\"off\"\n spellCheck={codec.mono ? false : undefined}\n {...draft}\n className={cn(\n // Pulled left by its own padding, so the text lines up with the\n // caption above it and with plain text beside it - the box is\n // there only when it is wanted.\n '-ml-1.5 h-control-sm w-full min-w-0 rounded-sm border border-transparent bg-transparent px-1.5',\n 'text-sm text-ellipsis text-text placeholder:text-faint',\n 'cursor-text transition-colors outline-none',\n 'hover:border-line-2 hover:bg-soft',\n 'focus:border-accent focus:bg-soft',\n 'aria-invalid:border-bad aria-invalid:focus:border-bad',\n 'disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:border-transparent disabled:hover:bg-transparent',\n codec.mono && 'font-mono tabular-nums',\n )}\n />\n </div>\n )\n}\n"
|
|
1174
|
+
}
|
|
1175
|
+
]
|
|
1176
|
+
},
|
|
1140
1177
|
{
|
|
1141
1178
|
"name": "input",
|
|
1142
1179
|
"type": "registry:ui",
|
|
1143
1180
|
"title": "Input",
|
|
1144
1181
|
"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.",
|
|
1145
1182
|
"dependencies": [
|
|
1146
|
-
"dowel-ui@^0.
|
|
1183
|
+
"dowel-ui@^0.33.0"
|
|
1147
1184
|
],
|
|
1148
1185
|
"registryDependencies": [],
|
|
1149
1186
|
"files": [
|
|
@@ -1177,7 +1214,7 @@
|
|
|
1177
1214
|
"title": "JsonViewer",
|
|
1178
1215
|
"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.",
|
|
1179
1216
|
"dependencies": [
|
|
1180
|
-
"dowel-ui@^0.
|
|
1217
|
+
"dowel-ui@^0.33.0"
|
|
1181
1218
|
],
|
|
1182
1219
|
"registryDependencies": [
|
|
1183
1220
|
"https://lacodda.github.io/dowel/r/json-rows.json"
|
|
@@ -1197,7 +1234,7 @@
|
|
|
1197
1234
|
"title": "Kbd",
|
|
1198
1235
|
"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.",
|
|
1199
1236
|
"dependencies": [
|
|
1200
|
-
"dowel-ui@^0.
|
|
1237
|
+
"dowel-ui@^0.33.0"
|
|
1201
1238
|
],
|
|
1202
1239
|
"registryDependencies": [],
|
|
1203
1240
|
"files": [
|
|
@@ -1216,7 +1253,7 @@
|
|
|
1216
1253
|
"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.",
|
|
1217
1254
|
"dependencies": [
|
|
1218
1255
|
"class-variance-authority",
|
|
1219
|
-
"dowel-ui@^0.
|
|
1256
|
+
"dowel-ui@^0.33.0"
|
|
1220
1257
|
],
|
|
1221
1258
|
"registryDependencies": [],
|
|
1222
1259
|
"files": [
|
|
@@ -1251,7 +1288,7 @@
|
|
|
1251
1288
|
"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.",
|
|
1252
1289
|
"dependencies": [
|
|
1253
1290
|
"class-variance-authority",
|
|
1254
|
-
"dowel-ui@^0.
|
|
1291
|
+
"dowel-ui@^0.33.0"
|
|
1255
1292
|
],
|
|
1256
1293
|
"registryDependencies": [
|
|
1257
1294
|
"https://lacodda.github.io/dowel/r/line-scale.json"
|
|
@@ -1261,7 +1298,7 @@
|
|
|
1261
1298
|
"path": "ui/line-chart.tsx",
|
|
1262
1299
|
"target": "@ui/line-chart.tsx",
|
|
1263
1300
|
"type": "registry:ui",
|
|
1264
|
-
"content": "import type { HTMLAttributes, ReactNode } from 'react'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { cn } from 'dowel-ui'\nimport { boundsOf, pathOf, runs, ticksFor, yOf, type Bounds, type Point } from './line-scale'\n\n/*\n * A quantity over time, where it continues between the readings.\n *\n * The distinction against its neighbours is the data's, not the drawing's. A\n * column says each period is its own sum - hours worked in a week, and there\n * is no Wednesday-afternoon figure between two weeks. A line says the value\n * existed the whole time and was sampled: an account balance, a price, a\n * temperature. Drawing a sum as a line claims readings nobody took; drawing a\n * level as columns throws away the thing being watched.\n *\n * Sparkline is the same shape with the axes taken away, for beside a figure.\n * This one has them, because a balance without a scale is a squiggle.\n *\n * **A hole breaks the line.** Interpolating across invents a reading; closing\n * the gap up moves every later point and makes the axis lie about when things\n * happened. Both are quieter than the truth, which is why the truth has to be\n * drawn deliberately.\n *\n * **The floor is not zero unless the caller says so**, and that is a departure\n * from BarChart on purpose. A bar's length is the quantity, so its baseline\n * has to be zero. A line's subject is change, and a balance between 4,900 and\n * 5,100 on a zero-based axis is a flat rule. The ticks are what keep this\n * honest: they say where the bottom is.\n *\n * The plot owns its height in pixels, for the reason BarChart does - a\n * percentage against a parent with no height of its own resolves to zero, and\n * the chart disappears without failing.\n */\n\nexport const lineChartVariants = cva('relative w-full', {\n variants: {\n size: {\n sm: '[--plot:96px]',\n md: '[--plot:160px]',\n },\n /* Room on the right for the tick labels. Drawn inside the plot they sit on\n * top of whatever the line is doing there - found by looking, with `$5,200`\n * crossed out by its own series. A gutter costs a little width and cannot\n * collide. */\n gutter: {\n true: 'pr-12',\n false: '',\n },\n },\n defaultVariants: { size: 'md', gutter: true },\n})\n\nexport const lineVariants = cva('fill-none', {\n variants: {\n tone: {\n accent: 'stroke-accent',\n series: 'stroke-series-1',\n good: 'stroke-good',\n bad: 'stroke-bad',\n muted: 'stroke-dim',\n },\n },\n defaultVariants: { tone: 'accent' },\n})\n\nexport interface LineChartProps\n extends Omit<HTMLAttributes<HTMLDivElement>, 'children'>,\n VariantProps<typeof lineChartVariants>,\n VariantProps<typeof lineVariants> {\n /** The readings, in order. `value: null` is a measurement not taken. */\n points: Point[]\n /** Override any edge of the plot. `min: 0` for a count, where zero is the\n * truth rather than a flattening. */\n bounds?: Partial<Bounds>\n /** What the chart as a whole says, for a reader who cannot see it. */\n label: string\n /** How many value ticks to aim for. They land on round numbers, so the count\n * is a wish rather than a promise. `0` draws none. */\n ticks?: number\n /** Turns a tick into its label. Without it the number is drawn as it is -\n * which is right for a count and wrong for money or a duration. */\n formatTick?: (value: number) => string\n /** The labels under the axis - usually the first and last reading. Two or\n * three, not one per point: the axis is not a place for a list. */\n footer?: ReactNode\n}\n\nexport function LineChart({\n points,\n bounds: stated,\n label,\n ticks = 4,\n formatTick,\n footer,\n tone,\n size,\n className,\n ...props\n}: LineChartProps) {\n const bounds = boundsOf(points, stated)\n\n /* Nothing measured is not a chart of zeroes: an empty plot with its axis is\n * the honest drawing, and the caller says so in words beside it. */\n if (bounds === null) {\n return (\n <div\n className={cn(lineChartVariants({ size, gutter: false }), className)}\n role=\"img\"\n aria-label={label}\n {...props}\n >\n <div className=\"h-[var(--plot)] w-full border-b border-chart-axis\" />\n </div>\n )\n }\n\n const drawn = runs(points, bounds)\n const rules = ticks > 0 ? ticksFor(bounds, ticks) : []\n\n return (\n <div\n className={cn(lineChartVariants({ size, gutter: rules.length > 0 }), className)}\n role=\"img\"\n aria-label={label}\n {...props}\n >\n <div className=\"relative h-[var(--plot)] w-full border-b border-chart-axis\">\n {rules.map((tick) => {\n const y = yOf(tick, bounds)\n if (y === null) return null\n return (\n <div key={tick} className=\"pointer-events-none absolute inset-x-0\" style={{ top: `${y}%` }}>\n {/* Solid hairline: a dashed rule reads as a threshold or a\n * projection, and this is neither. */}\n <div className=\"h-px w-full bg-chart-grid\" />\n {/* Outside the plot, in the gutter the variant reserves: a label\n * drawn over the series is a label crossed out by it. */}\n <span className=\"absolute -top-1.5 left-full pl-1.5 text-
|
|
1301
|
+
"content": "import type { HTMLAttributes, ReactNode } from 'react'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { cn } from 'dowel-ui'\nimport { boundsOf, pathOf, runs, ticksFor, yOf, type Bounds, type Point } from './line-scale'\n\n/*\n * A quantity over time, where it continues between the readings.\n *\n * The distinction against its neighbours is the data's, not the drawing's. A\n * column says each period is its own sum - hours worked in a week, and there\n * is no Wednesday-afternoon figure between two weeks. A line says the value\n * existed the whole time and was sampled: an account balance, a price, a\n * temperature. Drawing a sum as a line claims readings nobody took; drawing a\n * level as columns throws away the thing being watched.\n *\n * Sparkline is the same shape with the axes taken away, for beside a figure.\n * This one has them, because a balance without a scale is a squiggle.\n *\n * **A hole breaks the line.** Interpolating across invents a reading; closing\n * the gap up moves every later point and makes the axis lie about when things\n * happened. Both are quieter than the truth, which is why the truth has to be\n * drawn deliberately.\n *\n * **The floor is not zero unless the caller says so**, and that is a departure\n * from BarChart on purpose. A bar's length is the quantity, so its baseline\n * has to be zero. A line's subject is change, and a balance between 4,900 and\n * 5,100 on a zero-based axis is a flat rule. The ticks are what keep this\n * honest: they say where the bottom is.\n *\n * The plot owns its height in pixels, for the reason BarChart does - a\n * percentage against a parent with no height of its own resolves to zero, and\n * the chart disappears without failing.\n */\n\nexport const lineChartVariants = cva('relative w-full', {\n variants: {\n size: {\n sm: '[--plot:96px]',\n md: '[--plot:160px]',\n },\n /* Room on the right for the tick labels. Drawn inside the plot they sit on\n * top of whatever the line is doing there - found by looking, with `$5,200`\n * crossed out by its own series. A gutter costs a little width and cannot\n * collide. */\n gutter: {\n true: 'pr-12',\n false: '',\n },\n },\n defaultVariants: { size: 'md', gutter: true },\n})\n\nexport const lineVariants = cva('fill-none', {\n variants: {\n tone: {\n accent: 'stroke-accent',\n series: 'stroke-series-1',\n good: 'stroke-good',\n bad: 'stroke-bad',\n muted: 'stroke-dim',\n },\n },\n defaultVariants: { tone: 'accent' },\n})\n\nexport interface LineChartProps\n extends Omit<HTMLAttributes<HTMLDivElement>, 'children'>,\n VariantProps<typeof lineChartVariants>,\n VariantProps<typeof lineVariants> {\n /** The readings, in order. `value: null` is a measurement not taken. */\n points: Point[]\n /** Override any edge of the plot. `min: 0` for a count, where zero is the\n * truth rather than a flattening. */\n bounds?: Partial<Bounds>\n /** What the chart as a whole says, for a reader who cannot see it. */\n label: string\n /** How many value ticks to aim for. They land on round numbers, so the count\n * is a wish rather than a promise. `0` draws none. */\n ticks?: number\n /** Turns a tick into its label. Without it the number is drawn as it is -\n * which is right for a count and wrong for money or a duration. */\n formatTick?: (value: number) => string\n /** The labels under the axis - usually the first and last reading. Two or\n * three, not one per point: the axis is not a place for a list. */\n footer?: ReactNode\n}\n\nexport function LineChart({\n points,\n bounds: stated,\n label,\n ticks = 4,\n formatTick,\n footer,\n tone,\n size,\n className,\n ...props\n}: LineChartProps) {\n const bounds = boundsOf(points, stated)\n\n /* Nothing measured is not a chart of zeroes: an empty plot with its axis is\n * the honest drawing, and the caller says so in words beside it. */\n if (bounds === null) {\n return (\n <div\n className={cn(lineChartVariants({ size, gutter: false }), className)}\n role=\"img\"\n aria-label={label}\n {...props}\n >\n <div className=\"h-[var(--plot)] w-full border-b border-chart-axis\" />\n </div>\n )\n }\n\n const drawn = runs(points, bounds)\n const rules = ticks > 0 ? ticksFor(bounds, ticks) : []\n\n return (\n <div\n className={cn(lineChartVariants({ size, gutter: rules.length > 0 }), className)}\n role=\"img\"\n aria-label={label}\n {...props}\n >\n <div className=\"relative h-[var(--plot)] w-full border-b border-chart-axis\">\n {rules.map((tick) => {\n const y = yOf(tick, bounds)\n if (y === null) return null\n return (\n <div key={tick} className=\"pointer-events-none absolute inset-x-0\" style={{ top: `${y}%` }}>\n {/* Solid hairline: a dashed rule reads as a threshold or a\n * projection, and this is neither. */}\n <div className=\"h-px w-full bg-chart-grid\" />\n {/* Outside the plot, in the gutter the variant reserves: a label\n * drawn over the series is a label crossed out by it. */}\n <span className=\"absolute -top-1.5 left-full pl-1.5 text-2xs leading-none text-faint tabular-nums\">\n {formatTick ? formatTick(tick) : tick}\n </span>\n </div>\n )\n })}\n\n {/* `preserveAspectRatio=\"none\"` is what lets the plot be a box of the\n * caller's shape while the maths stays in percentages: x and y scale\n * independently, which would bend a shape but cannot bend a line\n * whose every segment is straight. The stroke is drawn in absolute\n * units so it does not stretch with the box. */}\n <svg\n viewBox=\"0 0 100 100\"\n preserveAspectRatio=\"none\"\n className=\"absolute inset-0 h-full w-full overflow-visible\"\n aria-hidden\n >\n {drawn.map((run) => (\n <path\n key={run[0]!.at}\n d={pathOf(run)}\n vectorEffect=\"non-scaling-stroke\"\n strokeWidth={2}\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n className={cn(lineVariants({ tone }))}\n />\n ))}\n </svg>\n </div>\n\n {footer !== undefined && (\n <div className=\"mt-1 flex justify-between text-2xs text-faint tabular-nums\">{footer}</div>\n )}\n </div>\n )\n}\n"
|
|
1265
1302
|
}
|
|
1266
1303
|
]
|
|
1267
1304
|
},
|
|
@@ -1281,13 +1318,32 @@
|
|
|
1281
1318
|
}
|
|
1282
1319
|
]
|
|
1283
1320
|
},
|
|
1321
|
+
{
|
|
1322
|
+
"name": "list-row",
|
|
1323
|
+
"type": "registry:ui",
|
|
1324
|
+
"title": "ListRow",
|
|
1325
|
+
"description": "A row of a list, one thing said in a line: ListRow for a fact, RowButton for a choice.",
|
|
1326
|
+
"dependencies": [
|
|
1327
|
+
"@base-ui/react",
|
|
1328
|
+
"dowel-ui@^0.33.0"
|
|
1329
|
+
],
|
|
1330
|
+
"registryDependencies": [],
|
|
1331
|
+
"files": [
|
|
1332
|
+
{
|
|
1333
|
+
"path": "ui/list-row.tsx",
|
|
1334
|
+
"target": "@ui/list-row.tsx",
|
|
1335
|
+
"type": "registry:ui",
|
|
1336
|
+
"content": "import type { HTMLAttributes, ReactNode } from 'react'\nimport { useRender } from '@base-ui/react/use-render'\nimport { cn } from 'dowel-ui'\n\n/*\n * ListRow and RowButton.\n *\n * A row of a list, one thing said in a line: ListRow for a fact, RowButton for\n * a choice.\n *\n * Every screen of every product has a list of something - versions, releases,\n * a queue, a journal - and each drew its own row: kilna alone had six\n * list-and-detail layouts with five different row recipes, and the selected\n * row was tinted four different ways. The row is three slots and a rule\n * about truncation, which is small enough to get wrong every time and\n * exactly why it is here.\n *\n * Two components, because a row is either a fact or a choice:\n *\n * - `ListRow` is a fact - \"Clip, 02.09\" in a list of releases. It is not\n * pressable, has no hover, and rows in a run are divided by a hairline.\n * - `RowButton` is a choice - a version in the list beside the editor, an\n * item in a queue. It is a real `<button>` (or the product's link, through\n * `render`), it answers the pointer, and the one that is open is tinted on\n * `accent-soft` - the mockup's `.vrow.on` - and named as current, because\n * the tint alone says it to nobody who cannot see it.\n *\n * The slots are the same in both: `start` for the thing that identifies the\n * row at a glance (an icon, a status dot, a cover), the row's words with an\n * optional `description` under them, and `end` for what is measured about it\n * (a date, a count, a chip). The words truncate on one line and the slots do\n * not shrink: a list whose rows grow to two lines in one language and not in\n * the other stops being a list you can scan.\n */\n\nconst slots = {\n start: 'flex shrink-0 items-center text-dim [&_svg:not([class*=size-])]:size-4',\n end: 'ml-auto flex shrink-0 items-center gap-1.5 text-xs text-faint tabular-nums',\n} as const\n\ninterface RowSlots {\n /** What identifies the row at a glance: an icon, a status dot, a cover. */\n start?: ReactNode\n /** A second line under the words, quieter and truncated the same way. */\n description?: ReactNode\n /** What is measured about the row: a date, a count, a chip. */\n end?: ReactNode\n /** The row's words. */\n children: ReactNode\n}\n\nfunction RowBody({ start, description, end, children, strong }: RowSlots & { strong?: boolean }) {\n return (\n <>\n {start === undefined ? null : (\n <span data-slot=\"start\" className={slots.start}>\n {start}\n </span>\n )}\n {/* `min-w-0` because a flex child refuses to shrink below its content\n without it - and a row that does not shrink pushes its `end` out of\n the list instead of truncating its words. */}\n <span className=\"flex min-w-0 flex-1 flex-col\">\n <span className={cn('truncate', strong && 'font-semibold')}>{children}</span>\n {description === undefined ? null : <span className=\"truncate text-xs text-faint\">{description}</span>}\n </span>\n {end === undefined ? null : <span className={slots.end}>{end}</span>}\n </>\n )\n}\n\nexport interface ListRowProps extends RowSlots, Omit<HTMLAttributes<HTMLElement>, 'children'> {\n /** Something other than a `div` - an `<li>` in a `<ul>`, most often:\n * `render={<li />}`. */\n render?: useRender.RenderProp\n}\n\n/**\n * A row that states a fact. Rows in a run are divided by a hairline that the\n * last one does not draw, so a list inside a panel ends at the panel's edge\n * rather than on a line of its own.\n *\n * Its height comes from `py-row`, so a list follows `data-density` with the\n * table beside it.\n */\nexport function ListRow({ start, description, end, children, render, className, ...props }: ListRowProps) {\n return useRender({\n render,\n defaultTagName: 'div',\n props: {\n className: cn(\n 'flex min-w-0 items-center gap-2.5 border-b border-line px-3 py-row text-sm text-text last:border-b-0',\n className,\n ),\n children: (\n <RowBody start={start} description={description} end={end}>\n {children}\n </RowBody>\n ),\n ...props,\n },\n })\n}\n\nexport interface RowButtonProps extends RowSlots, Omit<HTMLAttributes<HTMLElement>, 'children'> {\n /** The open one - tinted, and named as current. */\n selected?: boolean\n /** The element to draw the row as - a router's link, most often:\n * `render={<NavLink to={…} />}`. Without it the row is a `<button>`. */\n render?: useRender.RenderProp\n disabled?: boolean\n}\n\n/**\n * A row that is a choice.\n *\n * `aria-current` says which one is open, the way a list beside an editor\n * works: the row is where you are, not a value you are submitting. A product\n * that puts rows in a `listbox` passes `aria-selected` instead, and the\n * attribute it passes wins.\n */\nexport function RowButton({\n start,\n description,\n end,\n children,\n selected = false,\n render,\n disabled,\n className,\n ...props\n}: RowButtonProps) {\n return useRender({\n render,\n defaultTagName: 'button',\n props: {\n // A `<button>` inside a form submits it unless told otherwise, and a\n // list of rows inside a settings form is where that bites.\n ...(render === undefined ? { type: 'button', disabled } : {}),\n 'aria-current': selected ? 'true' : undefined,\n className: cn(\n 'flex w-full min-w-0 cursor-pointer items-center gap-2.5 rounded-md px-2.5 py-1.5 text-left text-sm text-text no-underline',\n 'transition-colors hover:bg-soft',\n 'focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-accent',\n 'disabled:cursor-not-allowed disabled:opacity-50',\n // The open row's icon takes the accent, as a NavRail entry's does.\n selected && 'bg-accent-soft hover:bg-accent-soft [&>[data-slot=start]]:text-accent',\n className,\n ),\n children: (\n <RowBody start={start} description={description} end={end} strong>\n {children}\n </RowBody>\n ),\n ...props,\n },\n })\n}\n"
|
|
1337
|
+
}
|
|
1338
|
+
]
|
|
1339
|
+
},
|
|
1284
1340
|
{
|
|
1285
1341
|
"name": "marked-text",
|
|
1286
1342
|
"type": "registry:ui",
|
|
1287
1343
|
"title": "MarkedText",
|
|
1288
1344
|
"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.",
|
|
1289
1345
|
"dependencies": [
|
|
1290
|
-
"dowel-ui@^0.
|
|
1346
|
+
"dowel-ui@^0.33.0"
|
|
1291
1347
|
],
|
|
1292
1348
|
"registryDependencies": [],
|
|
1293
1349
|
"files": [
|
|
@@ -1307,7 +1363,7 @@
|
|
|
1307
1363
|
"dependencies": [
|
|
1308
1364
|
"@base-ui/react",
|
|
1309
1365
|
"class-variance-authority",
|
|
1310
|
-
"dowel-ui@^0.
|
|
1366
|
+
"dowel-ui@^0.33.0"
|
|
1311
1367
|
],
|
|
1312
1368
|
"registryDependencies": [
|
|
1313
1369
|
"https://lacodda.github.io/dowel/r/layer.json"
|
|
@@ -1317,7 +1373,7 @@
|
|
|
1317
1373
|
"path": "ui/menu.tsx",
|
|
1318
1374
|
"target": "@ui/menu.tsx",
|
|
1319
1375
|
"type": "registry:ui",
|
|
1320
|
-
"content": "import { Menu as Base } from '@base-ui/react/menu'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { cn } from 'dowel-ui'\nimport { usePopupContainer } from './layer'\n\n/*\n * Menu.\n *\n * A list of actions that opens from a button - the row menu, the overflow\n * menu, the one behind the three dots. The products all wrote this by hand,\n * and all of them wrote the same three hundred lines: a click-outside\n * listener, an Escape handler, and a `stopPropagation` on every item so that\n * choosing an action does not also open the row underneath.\n *\n * None of that is what makes a menu hard. What makes it hard is the keyboard:\n * arrows that wrap, Home and End, type-ahead that finds an item by its first\n * letters, a submenu that opens on the right key and closes when the pointer\n * leaves diagonally. Base UI has all of it.\n *\n * The items are exposed rather than taken as an array of `{ label, onSelect }`.\n * An array is enough until the first separator, the first checkbox item and\n * the first submenu - and each of those arrives as another field on the object\n * rather than as the JSX it obviously is.\n */\n\nexport const menuPopupVariants = cva(\n [\n 'min-w-40 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 sm: 'min-w-32',\n md: 'min-w-40',\n lg: 'min-w-56',\n },\n },\n defaultVariants: { size: 'md' },\n },\n)\n\n/** One row of the menu. `danger` draws the destructive one apart from the\n * rest - in the colour of something that cannot be undone. */\nexport const menuItemVariants = cva(\n [\n 'flex cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-1.5 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 'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',\n '[&_svg:not([class*=size-])]:size-3.5 [&_svg]:shrink-0',\n ],\n {\n variants: {\n tone: {\n default: 'text-dim data-[highlighted]:text-text',\n danger: 'text-bad data-[highlighted]:bg-bad-soft data-[highlighted]:text-bad',\n },\n },\n defaultVariants: { tone: 'default' },\n },\n)\n\n/** The root. Uncontrolled by default; `open` and `onOpenChange` make it\n * controlled. */\nexport const Menu = Base.Root\n\n/** What opens it. Give it `render` to use your own button. */\nexport const MenuTrigger = Base.Trigger\n\n/** A labelled group of items, for a menu long enough to need headings. */\nexport const MenuGroup = Base.Group\n\n/** A submenu, opened from a `MenuSubTrigger` inside the parent. */\nexport const MenuSub = Base.SubmenuRoot\n\nexport interface MenuPopupProps\n extends Base.Popup.Props,\n VariantProps<typeof menuPopupVariants> {\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 raised host of the overlay this is\n * opened inside (`layer.tsx`), and to the document body when there is none -\n * either way not the element it was opened from, whose `overflow` would clip\n * it. Pass an element to put it somewhere else, such as a container being\n * screenshotted. */\n container?: Base.Portal.Props['container']\n}\n\n/** The panel. Portalled and positioned against the trigger. */\nexport function MenuPopup({\n size,\n side,\n align,\n sideOffset = 4,\n container,\n className,\n children,\n ...props\n}: MenuPopupProps) {\n // Inside an overlay, the overlay's raised host rather than the body - or\n // this popup draws under the dialog, drawer or popover that opened it. See\n // `layer.tsx`. Outside every overlay the hook gives `undefined`: the body.\n const host = usePopupContainer()\n\n return (\n <Base.Portal container={container ?? host}>\n <Base.Positioner\n side={side}\n align={align}\n sideOffset={sideOffset}\n className=\"[z-index:var(--z-menu)]\"\n >\n <Base.Popup className={cn(menuPopupVariants({ size }), className)} {...props}>\n {children}\n </Base.Popup>\n </Base.Positioner>\n </Base.Portal>\n )\n}\n\nexport interface MenuItemProps extends Base.Item.Props, VariantProps<typeof menuItemVariants> {}\n\n/** An action. Closing the menu afterwards is Base UI's default, which is\n * almost always right - `closeOnClick={false}` is for the one that is not. */\nexport function MenuItem({ tone, className, ...props }: MenuItemProps) {\n return <Base.Item className={cn(menuItemVariants({ tone }), className)} {...props} />\n}\n\n/** An item that opens a submenu. Drawn like any other, because it is one. */\nexport function MenuSubTrigger({ tone, className, ...props }: MenuItemProps) {\n return <Base.SubmenuTrigger className={cn(menuItemVariants({ tone }), className)} {...props} />\n}\n\nexport interface MenuCheckboxItemProps\n extends Base.CheckboxItem.Props,\n VariantProps<typeof menuItemVariants> {}\n\n/** An item that carries a tick. The state is the caller's - a menu does not\n * remember anything.\n *\n * Typed from `CheckboxItem` rather than `Item`: it was declared with the plain\n * item's props while rendering a checkbox, so `checked` and `onCheckedChange` -\n * the two things it exists for - were rejected by the compiler. Nothing had\n * called it until kilna's column picker did. */\nexport function MenuCheckboxItem({ tone, className, ...props }: MenuCheckboxItemProps) {\n return <Base.CheckboxItem className={cn(menuItemVariants({ tone }), className)} {...props} />\n}\n\n/** The tick itself, drawn only when the item is checked. */\nexport const MenuCheckboxIndicator = Base.CheckboxItemIndicator\n\n/** A line between groups of items. Decorative, and marked as such: a screen\n * reader announcing \"separator\" between every pair of actions is noise. */\nexport function MenuSeparator({ className, ...props }: Base.Separator.Props) {\n return <Base.Separator className={cn('-mx-1 my-1 h-px bg-line', className)} {...props} />\n}\n\n/** The caption above a group. */\nexport function MenuGroupLabel({ className, ...props }: Base.GroupLabel.Props) {\n return (\n <Base.GroupLabel\n className={cn('px-2 py-1.5
|
|
1376
|
+
"content": "import { Menu as Base } from '@base-ui/react/menu'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { cn } from 'dowel-ui'\nimport { usePopupContainer } from './layer'\n\n/*\n * Menu.\n *\n * A list of actions that opens from a button - the row menu, the overflow\n * menu, the one behind the three dots. The products all wrote this by hand,\n * and all of them wrote the same three hundred lines: a click-outside\n * listener, an Escape handler, and a `stopPropagation` on every item so that\n * choosing an action does not also open the row underneath.\n *\n * None of that is what makes a menu hard. What makes it hard is the keyboard:\n * arrows that wrap, Home and End, type-ahead that finds an item by its first\n * letters, a submenu that opens on the right key and closes when the pointer\n * leaves diagonally. Base UI has all of it.\n *\n * The items are exposed rather than taken as an array of `{ label, onSelect }`.\n * An array is enough until the first separator, the first checkbox item and\n * the first submenu - and each of those arrives as another field on the object\n * rather than as the JSX it obviously is.\n */\n\nexport const menuPopupVariants = cva(\n [\n 'min-w-40 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 sm: 'min-w-32',\n md: 'min-w-40',\n lg: 'min-w-56',\n },\n },\n defaultVariants: { size: 'md' },\n },\n)\n\n/** One row of the menu. `danger` draws the destructive one apart from the\n * rest - in the colour of something that cannot be undone. */\nexport const menuItemVariants = cva(\n [\n 'flex cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-1.5 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 'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',\n '[&_svg:not([class*=size-])]:size-3.5 [&_svg]:shrink-0',\n ],\n {\n variants: {\n tone: {\n default: 'text-dim data-[highlighted]:text-text',\n danger: 'text-bad data-[highlighted]:bg-bad-soft data-[highlighted]:text-bad',\n },\n },\n defaultVariants: { tone: 'default' },\n },\n)\n\n/** The root. Uncontrolled by default; `open` and `onOpenChange` make it\n * controlled. */\nexport const Menu = Base.Root\n\n/** What opens it. Give it `render` to use your own button. */\nexport const MenuTrigger = Base.Trigger\n\n/** A labelled group of items, for a menu long enough to need headings. */\nexport const MenuGroup = Base.Group\n\n/** A submenu, opened from a `MenuSubTrigger` inside the parent. */\nexport const MenuSub = Base.SubmenuRoot\n\nexport interface MenuPopupProps\n extends Base.Popup.Props,\n VariantProps<typeof menuPopupVariants> {\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 raised host of the overlay this is\n * opened inside (`layer.tsx`), and to the document body when there is none -\n * either way not the element it was opened from, whose `overflow` would clip\n * it. Pass an element to put it somewhere else, such as a container being\n * screenshotted. */\n container?: Base.Portal.Props['container']\n}\n\n/** The panel. Portalled and positioned against the trigger. */\nexport function MenuPopup({\n size,\n side,\n align,\n sideOffset = 4,\n container,\n className,\n children,\n ...props\n}: MenuPopupProps) {\n // Inside an overlay, the overlay's raised host rather than the body - or\n // this popup draws under the dialog, drawer or popover that opened it. See\n // `layer.tsx`. Outside every overlay the hook gives `undefined`: the body.\n const host = usePopupContainer()\n\n return (\n <Base.Portal container={container ?? host}>\n <Base.Positioner\n side={side}\n align={align}\n sideOffset={sideOffset}\n className=\"[z-index:var(--z-menu)]\"\n >\n <Base.Popup className={cn(menuPopupVariants({ size }), className)} {...props}>\n {children}\n </Base.Popup>\n </Base.Positioner>\n </Base.Portal>\n )\n}\n\nexport interface MenuItemProps extends Base.Item.Props, VariantProps<typeof menuItemVariants> {}\n\n/** An action. Closing the menu afterwards is Base UI's default, which is\n * almost always right - `closeOnClick={false}` is for the one that is not. */\nexport function MenuItem({ tone, className, ...props }: MenuItemProps) {\n return <Base.Item className={cn(menuItemVariants({ tone }), className)} {...props} />\n}\n\n/** An item that opens a submenu. Drawn like any other, because it is one. */\nexport function MenuSubTrigger({ tone, className, ...props }: MenuItemProps) {\n return <Base.SubmenuTrigger className={cn(menuItemVariants({ tone }), className)} {...props} />\n}\n\nexport interface MenuCheckboxItemProps\n extends Base.CheckboxItem.Props,\n VariantProps<typeof menuItemVariants> {}\n\n/** An item that carries a tick. The state is the caller's - a menu does not\n * remember anything.\n *\n * Typed from `CheckboxItem` rather than `Item`: it was declared with the plain\n * item's props while rendering a checkbox, so `checked` and `onCheckedChange` -\n * the two things it exists for - were rejected by the compiler. Nothing had\n * called it until kilna's column picker did. */\nexport function MenuCheckboxItem({ tone, className, ...props }: MenuCheckboxItemProps) {\n return <Base.CheckboxItem className={cn(menuItemVariants({ tone }), className)} {...props} />\n}\n\n/** The tick itself, drawn only when the item is checked. */\nexport const MenuCheckboxIndicator = Base.CheckboxItemIndicator\n\n/** A line between groups of items. Decorative, and marked as such: a screen\n * reader announcing \"separator\" between every pair of actions is noise. */\nexport function MenuSeparator({ className, ...props }: Base.Separator.Props) {\n return <Base.Separator className={cn('-mx-1 my-1 h-px bg-line', className)} {...props} />\n}\n\n/** The caption above a group. */\nexport function MenuGroupLabel({ className, ...props }: Base.GroupLabel.Props) {\n return (\n <Base.GroupLabel\n className={cn('caption px-2 py-1.5', className)}\n {...props}\n />\n )\n}\n"
|
|
1321
1377
|
}
|
|
1322
1378
|
]
|
|
1323
1379
|
},
|
|
@@ -1329,15 +1385,17 @@
|
|
|
1329
1385
|
"dependencies": [
|
|
1330
1386
|
"@base-ui/react",
|
|
1331
1387
|
"class-variance-authority",
|
|
1332
|
-
"dowel-ui@^0.
|
|
1388
|
+
"dowel-ui@^0.33.0"
|
|
1389
|
+
],
|
|
1390
|
+
"registryDependencies": [
|
|
1391
|
+
"https://lacodda.github.io/dowel/r/tooltip.json"
|
|
1333
1392
|
],
|
|
1334
|
-
"registryDependencies": [],
|
|
1335
1393
|
"files": [
|
|
1336
1394
|
{
|
|
1337
1395
|
"path": "ui/nav-rail.tsx",
|
|
1338
1396
|
"target": "@ui/nav-rail.tsx",
|
|
1339
1397
|
"type": "registry:ui",
|
|
1340
|
-
"content": "import type { HTMLAttributes, ReactNode } from 'react'\nimport { useRender } from '@base-ui/react/use-render'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { cn } from 'dowel-ui'\n\n/*\n * NavRail.\n *\n * The product's own destinations: the screens it has, drawn as a column down\n * the left, a row of tabs in a header, or a bar along the bottom of a phone.\n * One list, three shapes, because they are the same list - and when they were\n * three components in two products they drew the current entry three\n * different ways.\n *\n * The entries are the product's links, not this component's. `render` takes\n * an item and answers with the element to draw it as - a router's `NavLink`,\n * a plain `<a>`, whatever the product navigates with - and the rail puts the\n * clothes, the icon and the `aria-current` on it. That keeps the router out\n * of the registry entirely: a set that imported `react-router` would install\n * one in a product that had chosen another, and there is no version of that\n * which is the design system's business.\n *\n * `NavGroup` is the small uppercase caption between runs of entries, and\n * `NavSpacer` pushes what follows to the far end - the settings, the theme\n * switch, the profile, which every rail in the line keeps at its foot.\n */\n\nexport const navRailVariants = cva('flex', {\n variants: {\n /*\n * Which of the three shapes this is.\n *\n * Not a guess from the width: a rail that changed shape when its\n * container narrowed would change shape in the middle of a drag.\n */\n layout: {\n /* Down the left, the desktop shape. `h-full` so the rail runs the\n * height of the window and its foot sits at the bottom because the nav\n * reaches it, not because the content does. */\n column: 'h-full flex-col gap-0.5 overflow-y-auto border-r border-line px-2.5 pt-2.5 pb-3',\n /* A row of tabs, in a header beside the product's name. */\n row: 'flex-row items-center gap-1',\n /* Along the bottom, where a thumb already is. The safe-area inset is\n * the phone's home indicator: without it the last row of entries sits\n * under it with no way to scroll out. */\n bar: 'flex-row border-t border-line bg-raise pb-[env(safe-area-inset-bottom)]',\n },\n },\n defaultVariants: { layout: 'column' },\n})\n\nexport interface NavRailItem {\n id: string\n label: ReactNode\n icon?: ReactNode\n /** A count, a dot, a version chip - whatever sits at the end of the row.\n * Drawn only in `column`, where there is width for it. */\n end?: ReactNode\n /** A destination that exists on the roadmap but not in the build. It is\n * drawn greyed and is not a link: the point is to say the screen is coming,\n * which an entry that navigates nowhere would say by doing nothing. */\n soon?: boolean\n}\n\nexport interface NavRailProps\n extends Omit<HTMLAttributes<HTMLElement>, 'children' | 'onSelect'>,\n VariantProps<typeof navRailVariants> {\n /** What this set of destinations is called. Not drawn - it names the\n * landmark, so a reader meeting two navs on one page can tell which is\n * which. A product with a rail AND a bottom bar has exactly that. */\n label: string\n items: readonly NavRailItem[]\n /** Which destination is the open screen. */\n activeId?: string\n /** The element an entry is drawn as - `render={(item) => <NavLink to={…} />}`. */\n render?: (item: NavRailItem) => useRender.RenderProp\n /** Pressed, whatever the entry is drawn as. */\n onSelect?: (id: string) => void\n}\n\nexport function NavRail({\n layout,\n label,\n items,\n activeId,\n render,\n onSelect,\n className,\n ...props\n}: NavRailProps) {\n return (\n <nav aria-label={label} className={cn(navRailVariants({ layout }), className)} {...props}>\n {items.map((item) => (\n <NavRailEntry\n key={item.id}\n item={item}\n layout={layout ?? 'column'}\n active={item.id === activeId}\n render={item.soon === true ? undefined : render?.(item)}\n onSelect={onSelect}\n />\n ))}\n </nav>\n )\n}\n\n/** The clothes of one entry, by shape.\n *\n * `target-min` on the two narrow shapes rather than on all three: a column\n * entry is already taller than the floor, and the utility establishes a\n * containing block, which is not free on every row of a list. */\nconst entryClasses = {\n column:\n 'flex w-full items-center gap-2.5 rounded-md px-2.5 py-1.5 text-left text-sm no-underline transition-colors',\n /* `flex` here for the same reason as the other two, and it was missing: a\n * tab is an icon beside a word, and without it the entry is a block, the\n * icon and the label stack, and the header grows to twice its height. The\n * class-list tests did not see it - they read the string, not the layout -\n * and the stand did, in the first screenshot. */\n row: 'target-min flex items-center gap-2 rounded-md px-2.5 py-1 text-sm no-underline transition-colors',\n bar: 'target-min flex min-h-14 min-w-0 flex-1 flex-col items-center justify-center gap-1 px-1 py-2 text-2xs no-underline transition-colors',\n} as const\n\nfunction NavRailEntry({\n item,\n layout,\n active,\n render,\n onSelect,\n}: {\n item: NavRailItem\n layout: NonNullable<NavRailProps['layout']>\n active: boolean\n render?: useRender.RenderProp\n onSelect?: (id: string) => void\n}) {\n const soon = item.soon === true\n\n return useRender({\n render,\n // A destination is a link when the product gives it one and a button when\n // it does not; one that is not built yet is neither, and `span` says so -\n // a disabled button is still in the tab order on some browsers, and\n // tabbing onto a screen that does not exist is a dead end.\n defaultTagName: soon ? 'span' : 'button',\n props: {\n ...(render === undefined && !soon ? { type: 'button' } : {}),\n // Named as the current page, which is how a reader learns which of six\n // identical links is where they stand. Colour alone says it to nobody\n // who cannot see it.\n 'aria-current': active ? 'page' : undefined,\n ...(soon ? { 'aria-disabled': true } : {}),\n onClick: soon ? undefined : () => onSelect?.(item.id),\n className: cn(\n entryClasses[layout],\n '[&_svg:not([class*=size-])]:size-4 [&_svg]:shrink-0',\n 'focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-accent',\n soon\n ? 'cursor-default text-faint'\n : cn(\n 'cursor-pointer text-dim hover:bg-soft hover:text-text',\n // The bottom bar tints the entry itself rather than filling the\n // cell: a filled cell in a four-across bar reads as a pressed\n // button, and it is a location, not an action.\n active &&\n (layout === 'bar'\n ? 'text-accent-2 hover:bg-transparent'\n : 'bg-accent-soft text-text [&_svg]:text-accent'),\n ),\n ),\n children: (\n <>\n {item.icon === undefined ? null : (\n <span aria-hidden className=\"contents\">\n {item.icon}\n </span>\n )}\n {/* Truncated, in one line. A rail is a fixed-width column, and a\n label that wraps onto a second line makes the row taller than\n every other - which in a two-language product happens to one\n entry and not the rest. `min-w-0` because a flex child refuses\n to shrink below its content without it. */}\n <span className={cn('min-w-0 truncate', layout === 'bar' ? 'w-full text-center' : 'flex-1')}>\n {item.label}\n </span>\n {item.end === undefined || layout !== 'column' ? null : (\n <span className=\"ml-auto shrink-0 text-2xs text-faint\">{item.end}</span>\n )}\n </>\n ),\n },\n })\n}\n\n/** The caption over a run of entries: Library, Team, Admin.\n *\n * A `div` rather than a heading: a rail's groups are not the page's outline,\n * and six `h3`s inside a nav put six entries into a reader's document map\n * that lead nowhere. */\nexport function NavGroup({ className, ...props }: HTMLAttributes<HTMLDivElement>) {\n return (\n <div\n className={cn(\n 'px-2.5 pt-3 pb-1 text-2xs font-medium uppercase tracking-caption text-faint',\n className,\n )}\n {...props}\n />\n )\n}\n\n/** Pushes everything after it to the far end of the rail. */\nexport function NavSpacer({ className, ...props }: HTMLAttributes<HTMLDivElement>) {\n return <div className={cn('mt-auto flex flex-col gap-0.5', className)} {...props} />\n}\n"
|
|
1398
|
+
"content": "import { Fragment, type HTMLAttributes, type ReactNode, type Ref } from 'react'\nimport { mergeProps } from '@base-ui/react/merge-props'\nimport { useRender } from '@base-ui/react/use-render'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { cn } from 'dowel-ui'\nimport { Tooltip, TooltipPopup, TooltipTrigger } from './tooltip'\n\n/*\n * NavRail.\n *\n * The product's own destinations: the screens it has, drawn as a column down\n * the left, a row of tabs in a header, or a bar along the bottom of a phone.\n * One list, three shapes, because they are the same list - and when they were\n * three components in two products they drew the current entry three\n * different ways.\n *\n * The entries are the product's links, not this component's. `render` takes\n * an item and answers with the element to draw it as - a router's `NavLink`,\n * a plain `<a>`, whatever the product navigates with - and the rail puts the\n * clothes, the icon and the `aria-current` on it. That keeps the router out\n * of the registry entirely: a set that imported `react-router` would install\n * one in a product that had chosen another, and there is no version of that\n * which is the design system's business.\n *\n * `NavGroup` is the small uppercase caption between runs of entries, and\n * `NavSpacer` pushes what follows to the far end - the settings, the theme\n * switch, the profile, which every rail in the line keeps at its foot.\n *\n * **`collapsed` is the column with only its icons.** A work open on screen\n * wants the width, and the destinations are still needed - so the rail keeps\n * them as a column of icons at `--spacing-rail-compact`, and each one's name\n * moves into a tooltip beside it. The name does not leave the entry: it stays\n * in the accessibility tree, off the screen, so a reader hears \"Catalogue,\n * current page\" whether the rail is wide or narrow. The product passes the\n * same `collapsed` to every NavRail and NavGroup it assembles its rail from,\n * and sets the shell's rail width to the compact token.\n */\n\nexport const navRailVariants = cva('flex', {\n variants: {\n /*\n * Which of the three shapes this is.\n *\n * Not a guess from the width: a rail that changed shape when its\n * container narrowed would change shape in the middle of a drag.\n */\n layout: {\n /* Down the left, the desktop shape. `h-full` so the rail runs the\n * height of the window and its foot sits at the bottom because the nav\n * reaches it, not because the content does. */\n column: 'h-full flex-col gap-0.5 overflow-y-auto border-r border-line px-2.5 pt-2.5 pb-3',\n /* A row of tabs, in a header beside the product's name. */\n row: 'flex-row items-center gap-1',\n /* Along the bottom, where a thumb already is. The safe-area inset is\n * the phone's home indicator: without it the last row of entries sits\n * under it with no way to scroll out. */\n bar: 'flex-row border-t border-line bg-raise pb-[env(safe-area-inset-bottom)]',\n },\n },\n defaultVariants: { layout: 'column' },\n})\n\nexport interface NavRailItem {\n id: string\n label: ReactNode\n icon?: ReactNode\n /** A count, a dot, a version chip - whatever sits at the end of the row.\n * Drawn only in `column`, where there is width for it. */\n end?: ReactNode\n /** A destination that exists on the roadmap but not in the build. It is\n * drawn greyed and is not a link: the point is to say the screen is coming,\n * which an entry that navigates nowhere would say by doing nothing. */\n soon?: boolean\n}\n\nexport interface NavRailProps\n extends Omit<HTMLAttributes<HTMLElement>, 'children' | 'onSelect'>,\n VariantProps<typeof navRailVariants> {\n /** What this set of destinations is called. Not drawn - it names the\n * landmark, so a reader meeting two navs on one page can tell which is\n * which. A product with a rail AND a bottom bar has exactly that. */\n label: string\n items: readonly NavRailItem[]\n /** Which destination is the open screen. */\n activeId?: string\n /** The element an entry is drawn as - `render={(item) => <NavLink to={…} />}`. */\n render?: (item: NavRailItem) => useRender.RenderProp\n /** Pressed, whatever the entry is drawn as. */\n onSelect?: (id: string) => void\n /** Icons only, with each name in a tooltip. The column shape only - a row\n * of tabs and a bottom bar have no narrow form, because their names are\n * what they are made of. */\n collapsed?: boolean\n}\n\nexport function NavRail({\n layout,\n label,\n items,\n activeId,\n render,\n onSelect,\n collapsed = false,\n className,\n ...props\n}: NavRailProps) {\n const shape = layout ?? 'column'\n const narrow = collapsed && shape === 'column'\n\n return (\n <nav\n aria-label={label}\n className={cn(navRailVariants({ layout }), narrow && 'items-center px-2', className)}\n {...props}\n >\n {items.map((item) => {\n const entry = (\n <NavRailEntry\n item={item}\n layout={shape}\n collapsed={narrow}\n active={item.id === activeId}\n render={item.soon === true ? undefined : render?.(item)}\n onSelect={onSelect}\n />\n )\n if (!narrow) return <Fragment key={item.id}>{entry}</Fragment>\n // The name, beside the icon it no longer sits next to. The trigger is\n // the entry itself, so the tooltip opens on hover and on keyboard\n // focus alike - a name only the pointer can find is a name half the\n // rail's readers never get.\n return (\n <Tooltip key={item.id}>\n <TooltipTrigger render={entry} />\n <TooltipPopup side=\"right\">{item.label}</TooltipPopup>\n </Tooltip>\n )\n })}\n </nav>\n )\n}\n\n/** The clothes of one entry, by shape.\n *\n * `target-min` on the two narrow shapes rather than on all three: a column\n * entry is already taller than the floor, and the utility establishes a\n * containing block, which is not free on every row of a list. */\nconst entryClasses = {\n column:\n 'flex w-full items-center gap-2.5 rounded-md px-2.5 py-1.5 text-left text-sm no-underline transition-colors',\n /* `flex` here for the same reason as the other two, and it was missing: a\n * tab is an icon beside a word, and without it the entry is a block, the\n * icon and the label stack, and the header grows to twice its height. The\n * class-list tests did not see it - they read the string, not the layout -\n * and the stand did, in the first screenshot. */\n row: 'target-min flex items-center gap-2 rounded-md px-2.5 py-1 text-sm no-underline transition-colors',\n bar: 'target-min flex min-h-14 min-w-0 flex-1 flex-col items-center justify-center gap-1 px-1 py-2 text-2xs no-underline transition-colors',\n} as const\n\n/** A collapsed entry is a square around its icon, sized like an icon button\n * in the title bar so the two strips of chrome read as one set. */\nconst collapsedEntry = 'size-9 shrink-0 justify-center gap-0 p-0'\n\nfunction NavRailEntry({\n item,\n layout,\n active,\n render,\n onSelect,\n collapsed = false,\n ref,\n ...trigger\n}: {\n item: NavRailItem\n layout: NonNullable<NavRailProps['layout']>\n active: boolean\n render?: useRender.RenderProp\n onSelect?: (id: string) => void\n collapsed?: boolean\n ref?: Ref<HTMLElement>\n} & Omit<HTMLAttributes<HTMLElement>, 'onSelect'>) {\n const soon = item.soon === true\n\n return useRender({\n render,\n ref,\n // A destination is a link when the product gives it one and a button when\n // it does not; one that is not built yet is neither, and `span` says so -\n // a disabled button is still in the tab order on some browsers, and\n // tabbing onto a screen that does not exist is a dead end.\n defaultTagName: soon ? 'span' : 'button',\n // What a tooltip trigger hands down when the entry is wrapped in one -\n // its hover and focus handlers - merged under the entry's own, so the\n // entry's click and clothes are never overridden by the wrapper's.\n props: mergeProps<'button'>(trigger, {\n ...(render === undefined && !soon ? { type: 'button' } : {}),\n // Named as the current page, which is how a reader learns which of six\n // identical links is where they stand. Colour alone says it to nobody\n // who cannot see it.\n 'aria-current': active ? 'page' : undefined,\n ...(soon ? { 'aria-disabled': true } : {}),\n onClick: soon ? undefined : () => onSelect?.(item.id),\n className: cn(\n entryClasses[layout],\n collapsed && collapsedEntry,\n '[&_svg:not([class*=size-])]:size-4 [&_svg]:shrink-0',\n 'focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-accent',\n soon\n ? 'cursor-default text-faint'\n : cn(\n 'cursor-pointer text-dim hover:bg-soft hover:text-text',\n // The bottom bar tints the entry itself rather than filling the\n // cell: a filled cell in a four-across bar reads as a pressed\n // button, and it is a location, not an action.\n active &&\n (layout === 'bar'\n ? 'text-accent-2 hover:bg-transparent'\n : 'bg-accent-soft text-text [&_svg]:text-accent'),\n ),\n ),\n children: (\n <>\n {item.icon !== undefined ? (\n <span aria-hidden className=\"contents\">\n {item.icon}\n </span>\n ) : collapsed ? (\n // Collapsed, an entry with no icon would be an empty square - a\n // button nobody can see. Its initial stands in, the way an avatar\n // stands in for a face; the name is still the entry's, off screen.\n <span aria-hidden className=\"text-xs font-semibold uppercase\">\n {typeof item.label === 'string' ? item.label.charAt(0) : '•'}\n </span>\n ) : null}\n {/* Truncated, in one line. A rail is a fixed-width column, and a\n label that wraps onto a second line makes the row taller than\n every other - which in a two-language product happens to one\n entry and not the rest. `min-w-0` because a flex child refuses\n to shrink below its content without it. */}\n <span\n className={cn(\n 'min-w-0 truncate',\n layout === 'bar' ? 'w-full text-center' : 'flex-1',\n // Off the screen, still the entry's name.\n collapsed && 'sr-only',\n )}\n >\n {item.label}\n </span>\n {item.end === undefined || layout !== 'column' || collapsed ? null : (\n <span className=\"ml-auto shrink-0 text-2xs text-faint\">{item.end}</span>\n )}\n </>\n ),\n }),\n })\n}\n\n/** The caption over a run of entries: Library, Team, Admin.\n *\n * A `div` rather than a heading: a rail's groups are not the page's outline,\n * and six `h3`s inside a nav put six entries into a reader's document map\n * that lead nowhere.\n *\n * Collapsed, a caption has no room, and a word cut to three letters is worse\n * than none - so the run is marked by a short hairline instead, and the word\n * stays for a reader. */\nexport function NavGroup({\n collapsed = false,\n className,\n children,\n ...props\n}: HTMLAttributes<HTMLDivElement> & { collapsed?: boolean }) {\n if (collapsed) {\n return (\n <div className={cn('mx-auto my-2 h-px w-5 shrink-0 bg-line', className)} {...props}>\n <span className=\"sr-only\">{children}</span>\n </div>\n )\n }\n\n return (\n <div className={cn('caption px-2.5 pt-3 pb-1', className)} {...props}>\n {children}\n </div>\n )\n}\n\n/** Pushes everything after it to the far end of the rail. */\nexport function NavSpacer({ className, ...props }: HTMLAttributes<HTMLDivElement>) {\n return <div className={cn('mt-auto flex flex-col gap-0.5', className)} {...props} />\n}\n"
|
|
1341
1399
|
}
|
|
1342
1400
|
]
|
|
1343
1401
|
},
|
|
@@ -1347,7 +1405,7 @@
|
|
|
1347
1405
|
"title": "NotificationBell",
|
|
1348
1406
|
"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.",
|
|
1349
1407
|
"dependencies": [
|
|
1350
|
-
"dowel-ui@^0.
|
|
1408
|
+
"dowel-ui@^0.33.0"
|
|
1351
1409
|
],
|
|
1352
1410
|
"registryDependencies": [
|
|
1353
1411
|
"https://lacodda.github.io/dowel/r/button.json",
|
|
@@ -1358,7 +1416,7 @@
|
|
|
1358
1416
|
"path": "ui/notification-bell.tsx",
|
|
1359
1417
|
"target": "@ui/notification-bell.tsx",
|
|
1360
1418
|
"type": "registry:ui",
|
|
1361
|
-
"content": "import { Children, useState, type ReactNode } from 'react'\nimport { cn } from 'dowel-ui'\nimport { Button } from './button'\nimport { Popover, PopoverPopup, PopoverTrigger } from './popover'\n\n/*\n * The bell in the title bar, lit by what asks to be looked at.\n *\n * A bell that is always lit is a bell nobody reads, so the count is the\n * product's decision and this draws it: nothing at zero, the number past\n * that, `9+` past nine. Pressing it does not leave the screen - the last few\n * entries open under it and the whole history is one more click, which is\n * the shape every product converged on once the first one tried a page.\n *\n * What is in the list is the product's: the rows are `children`, because a\n * journal entry, a failed upload and a comment are three different lines and\n * one component cannot draw them. What is shared is the frame around them -\n * the trigger with its badge, the heading with the button that clears the\n * count, the empty line, and the footer that leads to everything.\n *\n * Built on Popover and Button rather than on a `<div>` of its own, so the\n * panel opens, positions and closes the way every other panel does.\n */\n\nexport interface NotificationBellProps {\n /** How many things ask to be looked at. Nothing is drawn at zero. */\n count: number\n /** What the bell is called, for a screen reader and the tooltip. */\n label: string\n /** The heading of the panel. */\n title: string\n /** Open, controlled. Left out, the bell manages itself. */\n open?: boolean\n onOpenChange?: (open: boolean) => void\n /** The button that clears the count, shown only while there is one. */\n markAllLabel?: string\n onMarkAll?: () => void\n /** The footer button, which closes the panel and hands over. */\n seeAllLabel?: string\n onSeeAll?: () => void\n /** What the list says when there are no rows. */\n emptyLabel: string\n /** The rows. */\n children?: ReactNode\n /** While the product is clearing the count: the button waits. */\n busy?: boolean\n className?: string\n}\n\nexport function NotificationBell({\n count,\n label,\n title,\n open,\n onOpenChange,\n markAllLabel,\n onMarkAll,\n seeAllLabel,\n onSeeAll,\n emptyLabel,\n children,\n busy = false,\n className,\n}: NotificationBellProps) {\n const [own, setOwn] = useState(false)\n const isOpen = open ?? own\n const setOpen = (next: boolean) => {\n setOwn(next)\n onOpenChange?.(next)\n }\n const empty = Children.count(children) === 0\n\n return (\n <Popover open={isOpen} onOpenChange={setOpen}>\n <PopoverTrigger\n render={\n <Button variant=\"icon\" size=\"icon-sm\" className={cn('relative', className)} title={label} aria-label={label} />\n }\n >\n <Bell />\n {count > 0 ? (\n <span\n data-badge\n className={cn(\n 'absolute -right-0.5 -top-0.5 min-w-3.5 rounded-full bg-warn px-1',\n 'text-center font-mono text-
|
|
1419
|
+
"content": "import { Children, useState, type ReactNode } from 'react'\nimport { cn } from 'dowel-ui'\nimport { Button } from './button'\nimport { Popover, PopoverPopup, PopoverTrigger } from './popover'\n\n/*\n * The bell in the title bar, lit by what asks to be looked at.\n *\n * A bell that is always lit is a bell nobody reads, so the count is the\n * product's decision and this draws it: nothing at zero, the number past\n * that, `9+` past nine. Pressing it does not leave the screen - the last few\n * entries open under it and the whole history is one more click, which is\n * the shape every product converged on once the first one tried a page.\n *\n * What is in the list is the product's: the rows are `children`, because a\n * journal entry, a failed upload and a comment are three different lines and\n * one component cannot draw them. What is shared is the frame around them -\n * the trigger with its badge, the heading with the button that clears the\n * count, the empty line, and the footer that leads to everything.\n *\n * Built on Popover and Button rather than on a `<div>` of its own, so the\n * panel opens, positions and closes the way every other panel does.\n */\n\nexport interface NotificationBellProps {\n /** How many things ask to be looked at. Nothing is drawn at zero. */\n count: number\n /** What the bell is called, for a screen reader and the tooltip. */\n label: string\n /** The heading of the panel. */\n title: string\n /** Open, controlled. Left out, the bell manages itself. */\n open?: boolean\n onOpenChange?: (open: boolean) => void\n /** The button that clears the count, shown only while there is one. */\n markAllLabel?: string\n onMarkAll?: () => void\n /** The footer button, which closes the panel and hands over. */\n seeAllLabel?: string\n onSeeAll?: () => void\n /** What the list says when there are no rows. */\n emptyLabel: string\n /** The rows. */\n children?: ReactNode\n /** While the product is clearing the count: the button waits. */\n busy?: boolean\n className?: string\n}\n\nexport function NotificationBell({\n count,\n label,\n title,\n open,\n onOpenChange,\n markAllLabel,\n onMarkAll,\n seeAllLabel,\n onSeeAll,\n emptyLabel,\n children,\n busy = false,\n className,\n}: NotificationBellProps) {\n const [own, setOwn] = useState(false)\n const isOpen = open ?? own\n const setOpen = (next: boolean) => {\n setOwn(next)\n onOpenChange?.(next)\n }\n const empty = Children.count(children) === 0\n\n return (\n <Popover open={isOpen} onOpenChange={setOpen}>\n <PopoverTrigger\n render={\n <Button variant=\"icon\" size=\"icon-sm\" className={cn('relative', className)} title={label} aria-label={label} />\n }\n >\n <Bell />\n {count > 0 ? (\n <span\n data-badge\n className={cn(\n 'absolute -right-0.5 -top-0.5 min-w-3.5 rounded-full bg-warn px-1',\n 'text-center font-mono text-2xs leading-3.5 text-on-warn',\n )}\n >\n {count > 9 ? '9+' : count}\n </span>\n ) : null}\n </PopoverTrigger>\n <PopoverPopup align=\"end\" arrow={false} size=\"lg\" className=\"p-0\">\n <header className=\"flex items-center gap-2 border-b border-line px-3 py-2\">\n <h3 className=\"text-sm font-semibold\">{title}</h3>\n {count > 0 && markAllLabel ? (\n <Button variant=\"ghost\" size=\"sm\" className=\"ml-auto\" disabled={busy} onClick={onMarkAll}>\n {markAllLabel}\n </Button>\n ) : null}\n </header>\n <div className=\"max-h-80 overflow-y-auto px-3\">\n {empty ? <p className=\"py-3 text-sm text-dim\">{emptyLabel}</p> : children}\n </div>\n {seeAllLabel ? (\n <footer className=\"border-t border-line p-2\">\n <Button\n variant=\"ghost\"\n size=\"sm\"\n className=\"w-full\"\n onClick={() => {\n setOpen(false)\n onSeeAll?.()\n }}\n >\n {seeAllLabel}\n </Button>\n </footer>\n ) : null}\n </PopoverPopup>\n </Popover>\n )\n}\n\nfunction Bell() {\n return (\n <svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.4\" aria-hidden>\n <path d=\"M4 11V7.5a4 4 0 0 1 8 0V11l1 1.5H3z\" strokeLinejoin=\"round\" />\n <path d=\"M6.5 13.5a1.5 1.5 0 0 0 3 0\" strokeLinecap=\"round\" />\n </svg>\n )\n}\n"
|
|
1362
1420
|
}
|
|
1363
1421
|
]
|
|
1364
1422
|
},
|
|
@@ -1369,7 +1427,7 @@
|
|
|
1369
1427
|
"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.",
|
|
1370
1428
|
"dependencies": [
|
|
1371
1429
|
"@base-ui/react",
|
|
1372
|
-
"dowel-ui@^0.
|
|
1430
|
+
"dowel-ui@^0.33.0"
|
|
1373
1431
|
],
|
|
1374
1432
|
"registryDependencies": [
|
|
1375
1433
|
"https://lacodda.github.io/dowel/r/input.json"
|
|
@@ -1389,7 +1447,7 @@
|
|
|
1389
1447
|
"title": "NumberFormat",
|
|
1390
1448
|
"description": "Two things, and the second is the reason this is a component rather than a call to `toLocaleString` at each site.",
|
|
1391
1449
|
"dependencies": [
|
|
1392
|
-
"dowel-ui@^0.
|
|
1450
|
+
"dowel-ui@^0.33.0"
|
|
1393
1451
|
],
|
|
1394
1452
|
"registryDependencies": [],
|
|
1395
1453
|
"files": [
|
|
@@ -1408,7 +1466,7 @@
|
|
|
1408
1466
|
"description": "What a screen says it is, and the buttons that act on the whole of it: a title, a line under it, and the actions at the far end of the same row.",
|
|
1409
1467
|
"dependencies": [
|
|
1410
1468
|
"class-variance-authority",
|
|
1411
|
-
"dowel-ui@^0.
|
|
1469
|
+
"dowel-ui@^0.33.0"
|
|
1412
1470
|
],
|
|
1413
1471
|
"registryDependencies": [],
|
|
1414
1472
|
"files": [
|
|
@@ -1426,7 +1484,7 @@
|
|
|
1426
1484
|
"title": "PageSize",
|
|
1427
1485
|
"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.",
|
|
1428
1486
|
"dependencies": [
|
|
1429
|
-
"dowel-ui@^0.
|
|
1487
|
+
"dowel-ui@^0.33.0"
|
|
1430
1488
|
],
|
|
1431
1489
|
"registryDependencies": [
|
|
1432
1490
|
"https://lacodda.github.io/dowel/r/select.json"
|
|
@@ -1446,7 +1504,7 @@
|
|
|
1446
1504
|
"title": "Pagination",
|
|
1447
1505
|
"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.",
|
|
1448
1506
|
"dependencies": [
|
|
1449
|
-
"dowel-ui@^0.
|
|
1507
|
+
"dowel-ui@^0.33.0"
|
|
1450
1508
|
],
|
|
1451
1509
|
"registryDependencies": [
|
|
1452
1510
|
"https://lacodda.github.io/dowel/r/button.json"
|
|
@@ -1467,7 +1525,7 @@
|
|
|
1467
1525
|
"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.",
|
|
1468
1526
|
"dependencies": [
|
|
1469
1527
|
"class-variance-authority",
|
|
1470
|
-
"dowel-ui@^0.
|
|
1528
|
+
"dowel-ui@^0.33.0"
|
|
1471
1529
|
],
|
|
1472
1530
|
"registryDependencies": [],
|
|
1473
1531
|
"files": [
|
|
@@ -1475,7 +1533,7 @@
|
|
|
1475
1533
|
"path": "ui/panel.tsx",
|
|
1476
1534
|
"target": "@ui/panel.tsx",
|
|
1477
1535
|
"type": "registry:ui",
|
|
1478
|
-
"content": "import type { HTMLAttributes } from 'react'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { cn } from 'dowel-ui'\n\n/*\n * Panel.\n *\n * The raised surface everything else sits on. It is the one place a screen\n * gets its structure from, so it stays deliberately plain: a ground, a\n * hairline, a corner.\n *\n * The two products it came from disagreed about that corner - one used 16px,\n * the other 12px, for the component with the same name and the same purpose.\n * It is `lg` here, and `SectionLabel` is included because a panel almost\n * always has one and every product wrote its own.\n */\nexport const panelVariants = cva('bg-raise', {\n variants: {\n variant: {\n /* The default: a surface that sits on the page. */\n raised: 'rounded-lg border border-line',\n /* For something that has left the page - a menu, a popover. The shadow\n * is what says how far away it is. */\n floating: 'rounded-lg border border-line shadow-raise',\n /* Inside another panel, where a second border would be a box in a box. */\n inset: 'rounded-inner bg-soft',\n },\n },\n defaultVariants: { variant: 'raised' },\n})\n\nexport interface PanelProps\n extends HTMLAttributes<HTMLDivElement>,\n VariantProps<typeof panelVariants> {}\n\nexport function Panel({ variant, className, ...props }: PanelProps) {\n return <div className={cn(panelVariants({ variant }), className)} {...props} />\n}\n\n/**\n * The small uppercase caption above a block of content.\n *\n * Its tracking is the one the products argued about - 0.08em in six files and\n * 0.09em in three - and it is a token now, so the argument cannot recur.\n */\nexport function SectionLabel({ className, ...props }: HTMLAttributes<HTMLDivElement>) {\n return (\n <div\n className={cn(\n 'flex items-center gap-2
|
|
1536
|
+
"content": "import type { HTMLAttributes } from 'react'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { cn } from 'dowel-ui'\n\n/*\n * Panel.\n *\n * The raised surface everything else sits on. It is the one place a screen\n * gets its structure from, so it stays deliberately plain: a ground, a\n * hairline, a corner.\n *\n * The two products it came from disagreed about that corner - one used 16px,\n * the other 12px, for the component with the same name and the same purpose.\n * It is `lg` here, and `SectionLabel` is included because a panel almost\n * always has one and every product wrote its own.\n */\nexport const panelVariants = cva('bg-raise', {\n variants: {\n variant: {\n /* The default: a surface that sits on the page. */\n raised: 'rounded-lg border border-line',\n /* For something that has left the page - a menu, a popover. The shadow\n * is what says how far away it is. */\n floating: 'rounded-lg border border-line shadow-raise',\n /* Inside another panel, where a second border would be a box in a box. */\n inset: 'rounded-inner bg-soft',\n },\n },\n defaultVariants: { variant: 'raised' },\n})\n\nexport interface PanelProps\n extends HTMLAttributes<HTMLDivElement>,\n VariantProps<typeof panelVariants> {}\n\nexport function Panel({ variant, className, ...props }: PanelProps) {\n return <div className={cn(panelVariants({ variant }), className)} {...props} />\n}\n\n/**\n * The small uppercase caption above a block of content.\n *\n * Its tracking is the one the products argued about - 0.08em in six files and\n * 0.09em in three - and it is a token now, so the argument cannot recur.\n */\nexport function SectionLabel({ className, ...props }: HTMLAttributes<HTMLDivElement>) {\n return (\n <div\n className={cn(\n 'caption flex items-center gap-2',\n className,\n )}\n {...props}\n />\n )\n}\n"
|
|
1479
1537
|
}
|
|
1480
1538
|
]
|
|
1481
1539
|
},
|
|
@@ -1485,7 +1543,7 @@
|
|
|
1485
1543
|
"title": "PasswordField",
|
|
1486
1544
|
"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.",
|
|
1487
1545
|
"dependencies": [
|
|
1488
|
-
"dowel-ui@^0.
|
|
1546
|
+
"dowel-ui@^0.33.0"
|
|
1489
1547
|
],
|
|
1490
1548
|
"registryDependencies": [
|
|
1491
1549
|
"https://lacodda.github.io/dowel/r/input.json"
|
|
@@ -1507,7 +1565,7 @@
|
|
|
1507
1565
|
"dependencies": [
|
|
1508
1566
|
"@base-ui/react",
|
|
1509
1567
|
"class-variance-authority",
|
|
1510
|
-
"dowel-ui@^0.
|
|
1568
|
+
"dowel-ui@^0.33.0"
|
|
1511
1569
|
],
|
|
1512
1570
|
"registryDependencies": [
|
|
1513
1571
|
"https://lacodda.github.io/dowel/r/layer.json"
|
|
@@ -1529,7 +1587,7 @@
|
|
|
1529
1587
|
"dependencies": [
|
|
1530
1588
|
"@base-ui/react",
|
|
1531
1589
|
"class-variance-authority",
|
|
1532
|
-
"dowel-ui@^0.
|
|
1590
|
+
"dowel-ui@^0.33.0"
|
|
1533
1591
|
],
|
|
1534
1592
|
"registryDependencies": [
|
|
1535
1593
|
"https://lacodda.github.io/dowel/r/layer.json"
|
|
@@ -1549,7 +1607,7 @@
|
|
|
1549
1607
|
"title": "ProductMark",
|
|
1550
1608
|
"description": "The product-mark primitive.",
|
|
1551
1609
|
"dependencies": [
|
|
1552
|
-
"dowel-ui@^0.
|
|
1610
|
+
"dowel-ui@^0.33.0"
|
|
1553
1611
|
],
|
|
1554
1612
|
"registryDependencies": [],
|
|
1555
1613
|
"files": [
|
|
@@ -1567,7 +1625,7 @@
|
|
|
1567
1625
|
"title": "ProductSwitcher",
|
|
1568
1626
|
"description": "The line's products are meant to be used together: kasl-server reads what kasl records, rigger plans what furca commits, a note in scheda cites a track in lyrid. A person moving between them was typing addresses from memory. This is the one place that knows where the others are - the current product's small tile and name on the trigger, and the others in a menu, each with its own tile, so the colour a person already knows the product by is what they look for.",
|
|
1569
1627
|
"dependencies": [
|
|
1570
|
-
"dowel-ui@^0.
|
|
1628
|
+
"dowel-ui@^0.33.0"
|
|
1571
1629
|
],
|
|
1572
1630
|
"registryDependencies": [
|
|
1573
1631
|
"https://lacodda.github.io/dowel/r/menu.json",
|
|
@@ -1590,7 +1648,7 @@
|
|
|
1590
1648
|
"dependencies": [
|
|
1591
1649
|
"@base-ui/react",
|
|
1592
1650
|
"class-variance-authority",
|
|
1593
|
-
"dowel-ui@^0.
|
|
1651
|
+
"dowel-ui@^0.33.0"
|
|
1594
1652
|
],
|
|
1595
1653
|
"registryDependencies": [],
|
|
1596
1654
|
"files": [
|
|
@@ -1609,6 +1667,7 @@
|
|
|
1609
1667
|
"description": "Every list in every product writes the same ladder - loading, then failed, then nothing found, then the content - and writes it slightly differently each time. What differs is never deliberate: one screen forgets the empty case, another shows a spinner where the shape was known, a third prints the raw error object. This is that ladder, once.",
|
|
1610
1668
|
"dependencies": [],
|
|
1611
1669
|
"registryDependencies": [
|
|
1670
|
+
"https://lacodda.github.io/dowel/r/button.json",
|
|
1612
1671
|
"https://lacodda.github.io/dowel/r/empty-state.json",
|
|
1613
1672
|
"https://lacodda.github.io/dowel/r/skeleton.json"
|
|
1614
1673
|
],
|
|
@@ -1617,7 +1676,7 @@
|
|
|
1617
1676
|
"path": "ui/query-state.tsx",
|
|
1618
1677
|
"target": "@ui/query-state.tsx",
|
|
1619
1678
|
"type": "registry:ui",
|
|
1620
|
-
"content": "import type { ReactNode } from 'react'\nimport { EmptyState } from './empty-state'\nimport { SkeletonList } from './skeleton'\n\n/*\n * The three screens between asking for data and showing it.\n *\n * Every list in every product writes the same ladder - loading, then failed,\n * then nothing found, then the content - and writes it slightly differently\n * each time. What differs is never deliberate: one screen forgets the empty\n * case, another shows a spinner where the shape was known, a third prints the\n * raw error object. This is that ladder, once.\n *\n * **It takes values, not a query.** `useQuery` from TanStack Query hands back\n * `isPending` and `error`, and those are ordinary values - so this component\n * asks for them rather than for the query result, and works the same with SWR,\n * with a reducer, or with two `useState` calls. Taking the result object would\n * put a library in the registry and therefore in every product that installs\n * this primitive, to save one line at the call site:\n *\n * const works = useQuery({ queryKey, queryFn })\n * <QueryState pending={works.isPending} error={works.error} empty={!works.data?.length} …>\n *\n * **The order of the cases is the component**, and it is the part that goes\n * wrong by hand: pending first, because a refetch that already has data should\n * not blank the screen; then error, because an error with stale data is still\n * an error; then empty, which is only knowable once something arrived.\n *\n * What it deliberately does not do is fetch, retry or cache. Those belong to\n * whatever owns the data - and a component that guessed at them would be\n * wrong for the product that owns them differently.\n */\n\nexport
|
|
1679
|
+
"content": "import type { ReactNode } from 'react'\nimport { Button } from './button'\nimport { EmptyState } from './empty-state'\nimport { SkeletonList } from './skeleton'\n\n/*\n * The three screens between asking for data and showing it.\n *\n * Every list in every product writes the same ladder - loading, then failed,\n * then nothing found, then the content - and writes it slightly differently\n * each time. What differs is never deliberate: one screen forgets the empty\n * case, another shows a spinner where the shape was known, a third prints the\n * raw error object. This is that ladder, once.\n *\n * **It takes values, not a query.** `useQuery` from TanStack Query hands back\n * `isPending` and `error`, and those are ordinary values - so this component\n * asks for them rather than for the query result, and works the same with SWR,\n * with a reducer, or with two `useState` calls. Taking the result object would\n * put a library in the registry and therefore in every product that installs\n * this primitive, to save one line at the call site:\n *\n * const works = useQuery({ queryKey, queryFn })\n * <QueryState pending={works.isPending} error={works.error} empty={!works.data?.length} …>\n *\n * **The order of the cases is the component**, and it is the part that goes\n * wrong by hand: pending first, because a refetch that already has data should\n * not blank the screen; then error, because an error with stale data is still\n * an error; then empty, which is only knowable once something arrived.\n *\n * What it deliberately does not do is fetch, retry or cache. Those belong to\n * whatever owns the data - and a component that guessed at them would be\n * wrong for the product that owns them differently.\n *\n * **But it offers the retry it is handed.** A failed load with no way to try\n * again is a dead end, and kilna had nineteen of them - every one wrote\n * \"could not load\" and none called the `refetch` it already had. `onRetry`\n * puts a button on the default error screen, and hands itself to a custom\n * one, so a product passes `works.refetch` once and every failure has a way\n * out.\n */\n\n/*\n * A retry, with the word for it.\n *\n * The two travel together as a union, the way Chip's remove does: a retry\n * button exists only where there is a word for it, and the word is the\n * product's - a string invented here could not be translated.\n */\ntype Retry =\n | { onRetry: () => void; retryLabel: string }\n | { onRetry?: never; retryLabel?: never }\n\nexport type QueryStateProps = QueryStateOwnProps & Retry\n\ninterface QueryStateOwnProps {\n /** Nothing has arrived yet. */\n pending?: boolean\n /** It failed. Anything with a `message`, which is what every error library\n * agrees on - or a string, for a product that carries its own. */\n error?: { message: string } | string | null\n /** Something arrived, and it was nothing. Computed by the caller, because\n * only the caller knows whether empty means `[]`, `null` or a count of\n * zero. */\n empty?: boolean\n /** What stands in while pending. A shape, ideally - the default is a list,\n * because most screens are waiting for one, but a screen waiting for a card\n * should say so. */\n skeleton?: ReactNode\n /** What is shown when nothing came back. */\n emptyState?: ReactNode\n /** What is shown when it failed. Given the message, so a product can put it\n * where it likes - or ignore it - and the retry, when there is one. */\n errorState?: (message: string, retry?: () => void) => ReactNode\n /** The words on the default error screen. Required only in the sense that\n * the default screen needs them; pass `errorState` instead and they are\n * never read. */\n errorLabels?: { title: string; body?: string }\n /** Draw the default error screen without its frame, for a list inside a\n * panel or a widget that already has one. The empty screen is the\n * caller's, and says for itself whether it is plain. */\n plain?: boolean\n children: ReactNode\n}\n\nexport function QueryState({\n pending = false,\n error = null,\n empty = false,\n skeleton,\n emptyState,\n errorState,\n errorLabels,\n plain = false,\n onRetry,\n retryLabel,\n children,\n}: QueryStateProps) {\n /* `aria-busy` on the wrapper, in every state.\n *\n * This is what tells a screen reader that the region is working, and it is\n * why the Skeletons themselves are `aria-hidden`: the fact belongs to the\n * region, said once, rather than to a dozen empty boxes announced as\n * content. */\n const wrap = (content: ReactNode) => <div aria-busy={pending || undefined}>{content}</div>\n\n // Pending first: a refetch that still holds data should not blank the screen\n // it is refreshing, and the caller decides that by passing `pending` only\n // when there is nothing to show.\n if (pending) return wrap(skeleton ?? <SkeletonList />)\n\n if (error) {\n const message = typeof error === 'string' ? error : error.message\n if (errorState) return wrap(errorState(message, onRetry))\n return wrap(\n <EmptyState\n variant=\"error\"\n plain={plain}\n title={errorLabels?.title ?? message}\n // The message is shown as the body when there is a title above it, and\n // as the title when there is not - so it is never lost, and never\n // printed twice.\n body={errorLabels?.title ? (errorLabels.body ?? message) : errorLabels?.body}\n action={\n onRetry === undefined ? undefined : (\n <Button size=\"sm\" onClick={onRetry}>\n {retryLabel}\n </Button>\n )\n }\n />,\n )\n }\n\n // Empty last of the three: it is the only one that cannot be known until\n // something has arrived.\n if (empty && emptyState) return wrap(emptyState)\n\n return wrap(children)\n}\n"
|
|
1621
1680
|
}
|
|
1622
1681
|
]
|
|
1623
1682
|
},
|
|
@@ -1629,7 +1688,7 @@
|
|
|
1629
1688
|
"dependencies": [
|
|
1630
1689
|
"@base-ui/react",
|
|
1631
1690
|
"class-variance-authority",
|
|
1632
|
-
"dowel-ui@^0.
|
|
1691
|
+
"dowel-ui@^0.33.0"
|
|
1633
1692
|
],
|
|
1634
1693
|
"registryDependencies": [],
|
|
1635
1694
|
"files": [
|
|
@@ -1647,7 +1706,7 @@
|
|
|
1647
1706
|
"title": "RatingScale",
|
|
1648
1707
|
"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\".",
|
|
1649
1708
|
"dependencies": [
|
|
1650
|
-
"dowel-ui@^0.
|
|
1709
|
+
"dowel-ui@^0.33.0"
|
|
1651
1710
|
],
|
|
1652
1711
|
"registryDependencies": [],
|
|
1653
1712
|
"files": [
|
|
@@ -1665,7 +1724,7 @@
|
|
|
1665
1724
|
"title": "RelativeTime",
|
|
1666
1725
|
"description": "The relative-time primitive.",
|
|
1667
1726
|
"dependencies": [
|
|
1668
|
-
"dowel-ui@^0.
|
|
1727
|
+
"dowel-ui@^0.33.0"
|
|
1669
1728
|
],
|
|
1670
1729
|
"registryDependencies": [],
|
|
1671
1730
|
"files": [
|
|
@@ -1683,7 +1742,7 @@
|
|
|
1683
1742
|
"title": "ReorderableList",
|
|
1684
1743
|
"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.",
|
|
1685
1744
|
"dependencies": [
|
|
1686
|
-
"dowel-ui@^0.
|
|
1745
|
+
"dowel-ui@^0.33.0"
|
|
1687
1746
|
],
|
|
1688
1747
|
"registryDependencies": [],
|
|
1689
1748
|
"files": [
|
|
@@ -1701,7 +1760,7 @@
|
|
|
1701
1760
|
"title": "SaveState",
|
|
1702
1761
|
"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.",
|
|
1703
1762
|
"dependencies": [
|
|
1704
|
-
"dowel-ui@^0.
|
|
1763
|
+
"dowel-ui@^0.33.0"
|
|
1705
1764
|
],
|
|
1706
1765
|
"registryDependencies": [
|
|
1707
1766
|
"https://lacodda.github.io/dowel/r/spinner.json"
|
|
@@ -1723,7 +1782,7 @@
|
|
|
1723
1782
|
"dependencies": [
|
|
1724
1783
|
"@base-ui/react",
|
|
1725
1784
|
"class-variance-authority",
|
|
1726
|
-
"dowel-ui@^0.
|
|
1785
|
+
"dowel-ui@^0.33.0"
|
|
1727
1786
|
],
|
|
1728
1787
|
"registryDependencies": [],
|
|
1729
1788
|
"files": [
|
|
@@ -1741,7 +1800,7 @@
|
|
|
1741
1800
|
"title": "SearchField",
|
|
1742
1801
|
"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.",
|
|
1743
1802
|
"dependencies": [
|
|
1744
|
-
"dowel-ui@^0.
|
|
1803
|
+
"dowel-ui@^0.33.0"
|
|
1745
1804
|
],
|
|
1746
1805
|
"registryDependencies": [
|
|
1747
1806
|
"https://lacodda.github.io/dowel/r/input.json",
|
|
@@ -1764,7 +1823,7 @@
|
|
|
1764
1823
|
"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.",
|
|
1765
1824
|
"dependencies": [
|
|
1766
1825
|
"@base-ui/react",
|
|
1767
|
-
"dowel-ui@^0.
|
|
1826
|
+
"dowel-ui@^0.33.0"
|
|
1768
1827
|
],
|
|
1769
1828
|
"registryDependencies": [],
|
|
1770
1829
|
"files": [
|
|
@@ -1772,7 +1831,26 @@
|
|
|
1772
1831
|
"path": "ui/section-nav.tsx",
|
|
1773
1832
|
"target": "@ui/section-nav.tsx",
|
|
1774
1833
|
"type": "registry:ui",
|
|
1775
|
-
"content": "import { useId, type HTMLAttributes, type ReactNode } from 'react'\nimport { useRender } from '@base-ui/react/use-render'\nimport { cn } from 'dowel-ui'\n\n/*\n * The left-hand list of a screen with sections, and the heading each opens.\n *\n * A settings screen is the usual case: five or six sections, each its own\n * address so it can be linked to and the back button walks between them,\n * listed down the left with the current one tinted. Every product draws the\n * same column, and every product draws the active row a little differently -\n * which is exactly the drift a shared list exists to stop.\n *\n * The rows are the product's links, not the component's. `render` takes the\n * item and answers with the element to draw it as - a router's `NavLink`, an\n * `<a>`, whatever the product navigates with - and the component puts the\n * row's clothes, its icon and its `aria-current` on it. Without `render` a\n * row is a button and `onSelect` says which one was pressed, for a screen\n * whose sections are state rather than routes.\n *\n * `SectionHeading` is the other half: the title and the one-line hint above\n * a section's body, so the column and the page it opens are set in the same\n * type.\n */\n\nexport interface SectionNavItem {\n id: string\n label: ReactNode\n icon?: ReactNode\n}\n\nexport interface SectionNavProps extends Omit<HTMLAttributes<HTMLElement>, 'children' | 'onSelect'> {\n /** What the list is called: the caption above it, and the name a screen\n * reader gives the landmark. */\n label: string\n items: readonly SectionNavItem[]\n /** Which item is the current page. */\n activeId?: string\n /** The element a row is drawn as - `render={(item) => <NavLink to={…} />}`.\n * The row's props are merged onto it, the way Button takes a `render`. */\n render?: (item: SectionNavItem) => useRender.RenderProp\n /** Pressed, whatever the row is drawn as. */\n onSelect?: (id: string) => void\n}\n\nexport function SectionNav({
|
|
1834
|
+
"content": "import { useId, type HTMLAttributes, type ReactNode } from 'react'\nimport { useRender } from '@base-ui/react/use-render'\nimport { cn } from 'dowel-ui'\n\n/*\n * The left-hand list of a screen with sections, and the heading each opens.\n *\n * A settings screen is the usual case: five or six sections, each its own\n * address so it can be linked to and the back button walks between them,\n * listed down the left with the current one tinted. Every product draws the\n * same column, and every product draws the active row a little differently -\n * which is exactly the drift a shared list exists to stop.\n *\n * The rows are the product's links, not the component's. `render` takes the\n * item and answers with the element to draw it as - a router's `NavLink`, an\n * `<a>`, whatever the product navigates with - and the component puts the\n * row's clothes, its icon and its `aria-current` on it. Without `render` a\n * row is a button and `onSelect` says which one was pressed, for a screen\n * whose sections are state rather than routes.\n *\n * `SectionHeading` is the other half: the title and the one-line hint above\n * a section's body, so the column and the page it opens are set in the same\n * type.\n */\n\nexport interface SectionNavItem {\n id: string\n label: ReactNode\n icon?: ReactNode\n /** A line under the label saying what the section holds - \"Theme,\n * language, start screen\" - so a reader chooses a section by what is in\n * it rather than by guessing from one word. At most two lines; past that\n * it is cut, because a column of rows that grow unevenly stops being a\n * list. */\n description?: ReactNode\n}\n\nexport interface SectionNavProps extends Omit<HTMLAttributes<HTMLElement>, 'children' | 'onSelect'> {\n /** What the list is called: the caption above it, and the name a screen\n * reader gives the landmark. */\n label: string\n items: readonly SectionNavItem[]\n /** Which item is the current page. */\n activeId?: string\n /** The element a row is drawn as - `render={(item) => <NavLink to={…} />}`.\n * The row's props are merged onto it, the way Button takes a `render`. */\n render?: (item: SectionNavItem) => useRender.RenderProp\n /** Pressed, whatever the row is drawn as. */\n onSelect?: (id: string) => void\n /** Keep the caption as the landmark's name and take it off the screen. For\n * a screen whose own title already says it - a settings screen headed\n * \"Settings\" with a list captioned \"Settings\" names itself twice, which is\n * what kilna's did. */\n labelHidden?: boolean\n}\n\nexport function SectionNav({\n label,\n items,\n activeId,\n render,\n onSelect,\n labelHidden = false,\n className,\n ...props\n}: SectionNavProps) {\n const captionId = useId()\n return (\n <nav aria-labelledby={captionId} className={cn('flex flex-col gap-0.5', className)} {...props}>\n <h2 id={captionId} className={cn('caption px-2.5 pb-2', labelHidden && 'sr-only')}>\n {label}\n </h2>\n {items.map((item) => (\n <SectionNavRow\n key={item.id}\n item={item}\n active={item.id === activeId}\n render={render?.(item)}\n onSelect={onSelect}\n />\n ))}\n </nav>\n )\n}\n\nfunction SectionNavRow({\n item,\n active,\n render,\n onSelect,\n}: {\n item: SectionNavItem\n active: boolean\n render?: useRender.RenderProp\n onSelect?: (id: string) => void\n}) {\n return useRender({\n render,\n defaultTagName: 'button',\n props: {\n ...(render === undefined ? { type: 'button' } : {}),\n // Named as the current page for a screen reader, which cannot see that\n // it is the tinted one.\n 'aria-current': active ? 'page' : undefined,\n onClick: () => onSelect?.(item.id),\n // The whole label on hover, since the row may have cut it. Only a\n // string can be a title; a label that is an element says itself.\n title: typeof item.label === 'string' ? item.label : undefined,\n className: cn(\n 'flex w-full gap-2.5 rounded-md px-2.5 py-1.5 text-left text-sm text-dim no-underline transition-colors',\n item.description === undefined ? 'items-center' : 'items-start',\n 'hover:bg-soft hover:text-text [&_svg:not([class*=size-])]:size-4 [&_svg]:shrink-0',\n 'focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-accent',\n active && 'bg-accent-soft text-text [&_svg]:text-accent',\n ),\n children: (\n <>\n {item.icon ? <span aria-hidden className=\"contents\">{item.icon}</span> : null}\n {/* One line, truncated, like a NavRail entry. A section column is a\n fixed width, and a label that wraps makes one row taller than\n every other - which in a two-language product happens to one\n section and not the rest, so the column reads as ragged and the\n line height stops meaning anything. `min-w-0` because a flex\n child will not shrink below its content without it. */}\n {item.description === undefined ? (\n <span className=\"min-w-0 flex-1 truncate\">{item.label}</span>\n ) : (\n <span className=\"flex min-w-0 flex-1 flex-col\">\n <span className={cn('truncate', active && 'font-semibold')}>{item.label}</span>\n <span className=\"line-clamp-2 text-2xs text-faint\">{item.description}</span>\n </span>\n )}\n </>\n ),\n },\n })\n}\n\nexport interface SectionHeadingProps extends Omit<HTMLAttributes<HTMLDivElement>, 'title'> {\n title: ReactNode\n description?: ReactNode\n}\n\n/** The title of the open section and the line under it. */\nexport function SectionHeading({ title, description, className, ...props }: SectionHeadingProps) {\n return (\n <div className={cn('mb-4 flex flex-col gap-1', className)} {...props}>\n <h2 className=\"text-base font-semibold text-text\">{title}</h2>\n {description ? <p className=\"text-sm text-dim\">{description}</p> : null}\n </div>\n )\n}\n"
|
|
1835
|
+
}
|
|
1836
|
+
]
|
|
1837
|
+
},
|
|
1838
|
+
{
|
|
1839
|
+
"name": "segmented-control",
|
|
1840
|
+
"type": "registry:ui",
|
|
1841
|
+
"title": "SegmentedControl",
|
|
1842
|
+
"description": "Two to five options, one of them chosen, all of them in view - a radio group in a segment's clothes.",
|
|
1843
|
+
"dependencies": [
|
|
1844
|
+
"@base-ui/react",
|
|
1845
|
+
"dowel-ui@^0.33.0"
|
|
1846
|
+
],
|
|
1847
|
+
"registryDependencies": [],
|
|
1848
|
+
"files": [
|
|
1849
|
+
{
|
|
1850
|
+
"path": "ui/segmented-control.tsx",
|
|
1851
|
+
"target": "@ui/segmented-control.tsx",
|
|
1852
|
+
"type": "registry:ui",
|
|
1853
|
+
"content": "import type { ReactNode } from 'react'\nimport { Radio } from '@base-ui/react/radio'\nimport { RadioGroup } from '@base-ui/react/radio-group'\nimport { cn } from 'dowel-ui'\n\n/*\n * SegmentedControl.\n *\n * Two to five options, one of them chosen, all of them in view - a radio group\n * in a segment's clothes.\n *\n * The theme picker, a list shown as rows or cards, \"all / mine / shared\". The\n * shape settles three things a Select cannot: every option is on screen, the\n * choice is one press rather than two, and switching back and forth is the\n * same press again. kilna put a Select where a segment belongs - the theme in\n * its settings - and drew the segment by hand where it had one, which is the\n * gap this closes.\n *\n * **It is a radio group, not a row of toggles.** Exactly one option is ever\n * chosen, which is what a radio says and a toggle does not: a toggle group\n * lets the last pressed button go, and \"no theme\" is not a theme. Base UI's\n * RadioGroup underneath gives the rest - one stop on the Tab key, the arrows\n * move and choose at once, and a reader hears \"Dark, radio button, 2 of 3\" -\n * so the segment is only the clothes.\n *\n * The clothes are the mockup's `.seg`: a sunken track, and the chosen segment\n * lifted out of it on the raised surface. Lifted rather than filled with the\n * accent, because a segment is a setting and not an action - an accent fill\n * reads as \"press me\", which the chosen option is the one thing you need not.\n *\n * Its height is `h-control-sm`, so the control sits level with the small\n * buttons and fields beside it and follows `data-density` with them.\n */\n\nexport interface SegmentedControlProps {\n /** What is being chosen: \"Theme\", \"View\". Required - a row of words with\n * no name for the question is a riddle to anyone who cannot see the\n * heading above it. */\n 'aria-label'?: string\n 'aria-labelledby'?: string\n value?: string\n defaultValue?: string\n onValueChange?: (value: string) => void\n disabled?: boolean\n /** The field name, for a form that posts it. */\n name?: string\n className?: string\n children: ReactNode\n}\n\nexport function SegmentedControl({ onValueChange, className, children, ...props }: SegmentedControlProps) {\n return (\n <RadioGroup\n onValueChange={onValueChange ? (next) => onValueChange(next as string) : undefined}\n // `w-fit`: in a column - a FieldGroup, a form - a flex child is stretched\n // to the column's width, and the track ran on past its last segment.\n className={cn('inline-flex h-control-sm w-fit shrink-0 items-stretch gap-0.5 rounded-md bg-soft p-0.5', className)}\n {...props}\n >\n {children}\n </RadioGroup>\n )\n}\n\nexport interface SegmentProps {\n value: string\n /** The option's words. An icon alone needs `aria-label` beside it. */\n children: ReactNode\n 'aria-label'?: string\n disabled?: boolean\n className?: string\n}\n\n/** One option of a SegmentedControl. */\nexport function Segment({ value, children, className, ...props }: SegmentProps) {\n return (\n <Radio.Root\n value={value}\n className={cn(\n 'inline-flex cursor-pointer items-center justify-center gap-1.5 rounded-sm px-3 text-sm whitespace-nowrap text-dim',\n 'transition-colors hover:text-text',\n '[&_svg]:shrink-0 [&_svg:not([class*=size-])]:size-3.5',\n // Chosen: lifted out of the track. The weight carries it too, so the\n // choice survives a screen with no shadows and a reader who does not\n // see the difference between two greys.\n 'data-[checked]:bg-raise data-[checked]:font-semibold data-[checked]:text-text data-[checked]:shadow-lift',\n 'focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-accent',\n 'data-[disabled]:cursor-not-allowed data-[disabled]:opacity-50',\n className,\n )}\n {...props}\n >\n {children}\n </Radio.Root>\n )\n}\n"
|
|
1776
1854
|
}
|
|
1777
1855
|
]
|
|
1778
1856
|
},
|
|
@@ -1784,7 +1862,7 @@
|
|
|
1784
1862
|
"dependencies": [
|
|
1785
1863
|
"@base-ui/react",
|
|
1786
1864
|
"class-variance-authority",
|
|
1787
|
-
"dowel-ui@^0.
|
|
1865
|
+
"dowel-ui@^0.33.0"
|
|
1788
1866
|
],
|
|
1789
1867
|
"registryDependencies": [
|
|
1790
1868
|
"https://lacodda.github.io/dowel/r/input.json",
|
|
@@ -1795,7 +1873,7 @@
|
|
|
1795
1873
|
"path": "ui/select.tsx",
|
|
1796
1874
|
"target": "@ui/select.tsx",
|
|
1797
1875
|
"type": "registry:ui",
|
|
1798
|
-
"content": "import { Select as Base } from '@base-ui/react/select'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { cn } from 'dowel-ui'\nimport { usePopupContainer } from './layer'\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:not([class*=size-])]: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 raised host of the overlay this is\n * opened inside (`layer.tsx`), and to the document body when there is none -\n * either way not the element it was opened from, whose `overflow` would clip\n * it. Pass an element to put it somewhere else, such as a container being\n * screenshotted. */\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 // Inside an overlay, the overlay's raised host rather than the body - or\n // this popup draws under the dialog, drawer or popover that opened it. See\n // `layer.tsx`. Outside every overlay the hook gives `undefined`: the body.\n const host = usePopupContainer()\n\n return (\n <Base.Portal container={container ?? host}>\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
|
|
1876
|
+
"content": "import { Select as Base } from '@base-ui/react/select'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { cn } from 'dowel-ui'\nimport { usePopupContainer } from './layer'\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:not([class*=size-])]: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 raised host of the overlay this is\n * opened inside (`layer.tsx`), and to the document body when there is none -\n * either way not the element it was opened from, whose `overflow` would clip\n * it. Pass an element to put it somewhere else, such as a container being\n * screenshotted. */\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 // Inside an overlay, the overlay's raised host rather than the body - or\n // this popup draws under the dialog, drawer or popover that opened it. See\n // `layer.tsx`. Outside every overlay the hook gives `undefined`: the body.\n const host = usePopupContainer()\n\n return (\n <Base.Portal container={container ?? host}>\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('caption px-2 py-1.5', 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"
|
|
1799
1877
|
}
|
|
1800
1878
|
]
|
|
1801
1879
|
},
|
|
@@ -1821,7 +1899,7 @@
|
|
|
1821
1899
|
"title": "SkeletonOf",
|
|
1822
1900
|
"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.",
|
|
1823
1901
|
"dependencies": [
|
|
1824
|
-
"dowel-ui@^0.
|
|
1902
|
+
"dowel-ui@^0.33.0"
|
|
1825
1903
|
],
|
|
1826
1904
|
"registryDependencies": [],
|
|
1827
1905
|
"files": [
|
|
@@ -1839,7 +1917,7 @@
|
|
|
1839
1917
|
"title": "Skeleton",
|
|
1840
1918
|
"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.",
|
|
1841
1919
|
"dependencies": [
|
|
1842
|
-
"dowel-ui@^0.
|
|
1920
|
+
"dowel-ui@^0.33.0"
|
|
1843
1921
|
],
|
|
1844
1922
|
"registryDependencies": [],
|
|
1845
1923
|
"files": [
|
|
@@ -1858,7 +1936,7 @@
|
|
|
1858
1936
|
"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\".",
|
|
1859
1937
|
"dependencies": [
|
|
1860
1938
|
"@base-ui/react",
|
|
1861
|
-
"dowel-ui@^0.
|
|
1939
|
+
"dowel-ui@^0.33.0"
|
|
1862
1940
|
],
|
|
1863
1941
|
"registryDependencies": [],
|
|
1864
1942
|
"files": [
|
|
@@ -1877,7 +1955,7 @@
|
|
|
1877
1955
|
"description": "The shape of a history, not a chart of it: no axes, no gridlines, no ticks.",
|
|
1878
1956
|
"dependencies": [
|
|
1879
1957
|
"class-variance-authority",
|
|
1880
|
-
"dowel-ui@^0.
|
|
1958
|
+
"dowel-ui@^0.33.0"
|
|
1881
1959
|
],
|
|
1882
1960
|
"registryDependencies": [],
|
|
1883
1961
|
"files": [
|
|
@@ -1885,7 +1963,7 @@
|
|
|
1885
1963
|
"path": "ui/sparkline.tsx",
|
|
1886
1964
|
"target": "@ui/sparkline.tsx",
|
|
1887
1965
|
"type": "registry:ui",
|
|
1888
|
-
"content": "import type { SVGAttributes } from 'react'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { cn } from 'dowel-ui'\n\n/*\n * A line small enough to sit beside the number it belongs to.\n *\n * The shape of a history, not a chart of it: no axes, no gridlines, no ticks.\n * \"61 → 74 → 82\" reads perfectly at three points and stops working at ten; a\n * line holds both, and the exact figures stay in the list underneath.\n *\n * Three things here are not free choices.\n *\n * **The box is drawn at the size it is shown at.** A `viewBox` wider than the\n * element scales x and y by different factors: the line bends away from the\n * data and the end dot stretches into a wedge. So the size is a variant rather\n * than a `className`, and each variant states the same numbers twice on\n * purpose - once for the geometry, once for the element - from one place. The\n * donor took a `size` object *and* a class, and every call site repeated\n * itself:
|
|
1966
|
+
"content": "import type { SVGAttributes } from 'react'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { cn } from 'dowel-ui'\n\n/*\n * A line small enough to sit beside the number it belongs to.\n *\n * The shape of a history, not a chart of it: no axes, no gridlines, no ticks.\n * \"61 → 74 → 82\" reads perfectly at three points and stops working at ten; a\n * line holds both, and the exact figures stay in the list underneath.\n *\n * Three things here are not free choices.\n *\n * **The box is drawn at the size it is shown at.** A `viewBox` wider than the\n * element scales x and y by different factors: the line bends away from the\n * data and the end dot stretches into a wedge. So the size is a variant rather\n * than a `className`, and each variant states the same numbers twice on\n * purpose - once for the geometry, once for the element - from one place. The\n * donor took a `size` object *and* a class, and every call site repeated\n * itself: a size of 52 by 16, and then a class saying the same width again.\n *\n * **The scale comes from outside.** `max` is what the axis allows, not what\n * this line happens to reach, so two works can be compared by eye. Normalised\n * to itself, a line that moved 61 → 63 would climb the whole box and read as a\n * transformation.\n *\n * **The line does not judge.** The donor coloured it green when it ended\n * higher and red when it ended lower, which is a claim the component cannot\n * support: for time-to-answer or error rate, down is the good direction. So\n * the line is drawn in the de-emphasis tone and the newest point in the accent\n * - the eye goes to \"where it is now\", and what that means is said in words\n * next to it, usually by a StatTile's delta. `tone` is there for a caller who\n * genuinely knows the direction's meaning.\n *\n * Not interactive, and that is the form rather than an omission: a sparkline\n * has no room for a hover target that is not the whole of it. A reader who\n * needs the exact figures gets them from the table beside it.\n */\n\nconst GEOMETRY = {\n sm: { width: 52, height: 16, dot: 1.75, stroke: 1.25 },\n md: { width: 120, height: 28, dot: 2.5, stroke: 1.5 },\n} as const\n\nexport type SparklineSize = keyof typeof GEOMETRY\n\nexport const sparklineVariants = cva('shrink-0 overflow-visible', {\n variants: {\n size: {\n /* Beside a figure in a row - an axis of a rubric, a cell in a table. */\n sm: 'h-4 w-13',\n /* Beside a total, where the shape is meant to be read rather than\n * glanced at. */\n md: 'h-7 w-30',\n },\n },\n defaultVariants: { size: 'md' },\n})\n\nexport const sparklineLineVariants = cva('fill-none', {\n variants: {\n tone: {\n /* The default, and the one a caller should almost always leave alone:\n * the line is context for the number beside it. */\n muted: 'stroke-dim',\n /* For a line that IS the subject - one chart on a page, nothing else to\n * defer to. */\n accent: 'stroke-accent',\n /* Only where the caller knows what the direction means. A component\n * cannot: for time-to-answer, down is the good news. */\n good: 'stroke-good',\n bad: 'stroke-bad',\n },\n },\n defaultVariants: { tone: 'muted' },\n})\n\nexport const sparklineDotVariants = cva('', {\n variants: {\n tone: {\n /* The newest point carries the accent even under the muted line: it is\n * the one the eye is looking for, and one dot of colour is enough to\n * find it without the line making a claim. */\n muted: 'fill-accent',\n accent: 'fill-accent',\n good: 'fill-good',\n bad: 'fill-bad',\n },\n },\n defaultVariants: { tone: 'muted' },\n})\n\n/** Breathing room, so the stroke and the end dot are not clipped by the box. */\nconst PAD = 3\n\nexport interface SparklineProps\n extends Omit<SVGAttributes<SVGSVGElement>, 'values'>,\n VariantProps<typeof sparklineLineVariants> {\n /** Oldest first. Fewer than two points is not a line, and draws nothing. */\n values: number[]\n /** The top of the scale, so two lines can be compared by eye. Defaults to\n * the highest value present, which makes the line self-scaled - fine for one\n * line alone, wrong for a column of them. */\n max?: number\n /** What the line says, for a reader who cannot see it. Required: an unlabelled\n * `img` is an unlabelled image, and this one carries information. */\n label: string\n size?: SparklineSize\n}\n\nexport function Sparkline({ values, max, label, size, tone, className, ...props }: SparklineProps) {\n // One point is a dot with no direction and no point; zero is nothing at all.\n if (values.length < 2) return null\n\n /* The default lives in `sparklineVariants` like every other variant in the\n * library; this resolves the same word for the geometry, so the numbers and\n * the classes cannot disagree about which size is being drawn. */\n const { width, height, dot, stroke } = GEOMETRY[size ?? 'md']\n\n /* The ceiling never sits below the data: a value above `max` would otherwise\n * be drawn outside the box. Clamped rather than rejected, because a score\n * that overshoots its stated scale is the caller's problem to notice, not a\n * reason to draw nothing. */\n const top = Math.max(...values, max ?? 0)\n /* A flat line at zero, or a flat line anywhere with no stated scale, would\n * divide by zero. It is drawn along the bottom, which is where it belongs. */\n const span = width - PAD * 2\n const rise = height - PAD * 2\n\n const points = values.map((value, index) => {\n const x = PAD + (span * index) / (values.length - 1)\n // SVG's y grows downward, so a larger value has to sit higher up.\n const y = top === 0 ? PAD + rise : PAD + rise - (rise * value) / top\n return [x, y] as const\n })\n\n const path = points\n .map(([x, y], index) => `${index === 0 ? 'M' : 'L'}${x.toFixed(1)} ${y.toFixed(1)}`)\n .join(' ')\n const [lastX, lastY] = points.at(-1)!\n\n return (\n <svg\n viewBox={`0 0 ${width} ${height}`}\n className={cn(sparklineVariants({ size }), className)}\n role=\"img\"\n aria-label={label}\n {...props}\n >\n <path\n d={path}\n strokeWidth={stroke}\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n className={cn(sparklineLineVariants({ tone }))}\n />\n <circle cx={lastX} cy={lastY} r={dot} className={cn(sparklineDotVariants({ tone }))} />\n </svg>\n )\n}\n"
|
|
1889
1967
|
}
|
|
1890
1968
|
]
|
|
1891
1969
|
},
|
|
@@ -1896,7 +1974,7 @@
|
|
|
1896
1974
|
"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.",
|
|
1897
1975
|
"dependencies": [
|
|
1898
1976
|
"class-variance-authority",
|
|
1899
|
-
"dowel-ui@^0.
|
|
1977
|
+
"dowel-ui@^0.33.0"
|
|
1900
1978
|
],
|
|
1901
1979
|
"registryDependencies": [],
|
|
1902
1980
|
"files": [
|
|
@@ -1914,7 +1992,7 @@
|
|
|
1914
1992
|
"title": "Splash",
|
|
1915
1993
|
"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.",
|
|
1916
1994
|
"dependencies": [
|
|
1917
|
-
"dowel-ui@^0.
|
|
1995
|
+
"dowel-ui@^0.33.0"
|
|
1918
1996
|
],
|
|
1919
1997
|
"registryDependencies": [],
|
|
1920
1998
|
"files": [
|
|
@@ -1933,7 +2011,7 @@
|
|
|
1933
2011
|
"description": "The splitter primitive.",
|
|
1934
2012
|
"dependencies": [
|
|
1935
2013
|
"class-variance-authority",
|
|
1936
|
-
"dowel-ui@^0.
|
|
2014
|
+
"dowel-ui@^0.33.0"
|
|
1937
2015
|
],
|
|
1938
2016
|
"registryDependencies": [],
|
|
1939
2017
|
"files": [
|
|
@@ -1952,7 +2030,7 @@
|
|
|
1952
2030
|
"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.",
|
|
1953
2031
|
"dependencies": [
|
|
1954
2032
|
"class-variance-authority",
|
|
1955
|
-
"dowel-ui@^0.
|
|
2033
|
+
"dowel-ui@^0.33.0"
|
|
1956
2034
|
],
|
|
1957
2035
|
"registryDependencies": [],
|
|
1958
2036
|
"files": [
|
|
@@ -1971,7 +2049,7 @@
|
|
|
1971
2049
|
"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.",
|
|
1972
2050
|
"dependencies": [
|
|
1973
2051
|
"class-variance-authority",
|
|
1974
|
-
"dowel-ui@^0.
|
|
2052
|
+
"dowel-ui@^0.33.0"
|
|
1975
2053
|
],
|
|
1976
2054
|
"registryDependencies": [],
|
|
1977
2055
|
"files": [
|
|
@@ -1990,7 +2068,7 @@
|
|
|
1990
2068
|
"description": "The steps of a task too long for one screen, and where you stand in them: set up a workspace, import a library, configure a connection. It is a picture of progress on its own, and the top of a Wizard when the steps are a form - a product can show how far along something is without owning the form that moves it, which is why the two are separate components. Base UI has no stepper, so this one is written here rather than wrapped.",
|
|
1991
2069
|
"dependencies": [
|
|
1992
2070
|
"class-variance-authority",
|
|
1993
|
-
"dowel-ui@^0.
|
|
2071
|
+
"dowel-ui@^0.33.0"
|
|
1994
2072
|
],
|
|
1995
2073
|
"registryDependencies": [],
|
|
1996
2074
|
"files": [
|
|
@@ -2009,7 +2087,7 @@
|
|
|
2009
2087
|
"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.",
|
|
2010
2088
|
"dependencies": [
|
|
2011
2089
|
"@base-ui/react",
|
|
2012
|
-
"dowel-ui@^0.
|
|
2090
|
+
"dowel-ui@^0.33.0"
|
|
2013
2091
|
],
|
|
2014
2092
|
"registryDependencies": [],
|
|
2015
2093
|
"files": [
|
|
@@ -2017,7 +2095,7 @@
|
|
|
2017
2095
|
"path": "ui/switch.tsx",
|
|
2018
2096
|
"target": "@ui/switch.tsx",
|
|
2019
2097
|
"type": "registry:ui",
|
|
2020
|
-
"content": "import type { ReactNode } from 'react'\nimport { Switch as Base } from '@base-ui/react/switch'\nimport { cn } from 'dowel-ui'\n\n/*\n * Switch - a setting that takes effect when you flip it.\n *\n * The difference from Checkbox is not how it looks, and getting it wrong is\n * the commonest mistake in the pair. A checkbox is an answer collected now\n * and submitted later, with the rest of the form; a switch is a setting that\n * applies the moment it moves. Put a switch in a form with a Save button and\n * the reader cannot tell whether anything happened - they flipped it, and\n * nothing said so.\n *\n * The rule, then: if there is a Save button, it is a Checkbox. If the change\n * is the action, it is a Switch.\n *\n * The thumb slides with `--duration-quick`, and the track carries the accent\n * when on - the only colour in the control, so a row of settings reads as a\n * column of on-and-off rather than a field of decoration. Under reduced\n * motion the theme drops the transition; the position still changes, which is\n * the part that carries the meaning.\n */\n\nexport interface SwitchProps {\n /** The words next to the switch. A switch with no label is a light with no\n * caption - give `aria-label` if the meaning is genuinely in the context. */\n children?: ReactNode\n checked?: boolean\n defaultChecked?: boolean\n onCheckedChange?: (checked: boolean) => void\n disabled?: boolean\n required?: boolean\n readOnly?: boolean\n name?: string\n 'aria-label'?: string\n className?: string\n}\n\nexport function Switch({ children, className, ...props }: SwitchProps) {\n const control = (\n <Base.Root\n className={cn(\n 'relative inline-flex h-5 w-9 shrink-0 items-center rounded-full border border-line-2 bg-soft',\n 'transition-colors duration-quick ease-out',\n 'hover:border-accent',\n 'data-[checked]:border-accent data-[checked]:bg-accent',\n 'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent',\n 'disabled:cursor-not-allowed disabled:opacity-50',\n children === undefined && className,\n )}\n {...props}\n >\n <Base.Thumb\n className={cn(\n 'size-3.5 rounded-full bg-dim shadow-lift',\n 'transition-[transform,background-color] duration-quick ease-out',\n 'translate-x-0.5 data-[checked]:translate-x-
|
|
2098
|
+
"content": "import type { ReactNode } from 'react'\nimport { Switch as Base } from '@base-ui/react/switch'\nimport { cn } from 'dowel-ui'\n\n/*\n * Switch - a setting that takes effect when you flip it.\n *\n * The difference from Checkbox is not how it looks, and getting it wrong is\n * the commonest mistake in the pair. A checkbox is an answer collected now\n * and submitted later, with the rest of the form; a switch is a setting that\n * applies the moment it moves. Put a switch in a form with a Save button and\n * the reader cannot tell whether anything happened - they flipped it, and\n * nothing said so.\n *\n * The rule, then: if there is a Save button, it is a Checkbox. If the change\n * is the action, it is a Switch.\n *\n * The thumb slides with `--duration-quick`, and the track carries the accent\n * when on - the only colour in the control, so a row of settings reads as a\n * column of on-and-off rather than a field of decoration. Under reduced\n * motion the theme drops the transition; the position still changes, which is\n * the part that carries the meaning.\n */\n\nexport interface SwitchProps {\n /** The words next to the switch. A switch with no label is a light with no\n * caption - give `aria-label` if the meaning is genuinely in the context. */\n children?: ReactNode\n checked?: boolean\n defaultChecked?: boolean\n onCheckedChange?: (checked: boolean) => void\n disabled?: boolean\n required?: boolean\n readOnly?: boolean\n name?: string\n 'aria-label'?: string\n className?: string\n}\n\nexport function Switch({ children, className, ...props }: SwitchProps) {\n const control = (\n <Base.Root\n className={cn(\n 'relative inline-flex h-5 w-9 shrink-0 items-center rounded-full border border-line-2 bg-soft',\n 'transition-colors duration-quick ease-out',\n 'hover:border-accent',\n 'data-[checked]:border-accent data-[checked]:bg-accent',\n 'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent',\n 'disabled:cursor-not-allowed disabled:opacity-50',\n children === undefined && className,\n )}\n {...props}\n >\n <Base.Thumb\n className={cn(\n 'size-3.5 rounded-full bg-dim shadow-lift',\n 'transition-[transform,background-color] duration-quick ease-out',\n 'translate-x-0.5 data-[checked]:translate-x-4.5',\n 'data-[checked]:bg-on-accent',\n )}\n />\n </Base.Root>\n )\n\n if (children === undefined) return control\n\n return (\n <label\n className={cn(\n 'flex cursor-pointer items-center gap-2.5 text-sm text-text',\n 'has-[:disabled]:cursor-not-allowed has-[:disabled]:opacity-50',\n className,\n )}\n >\n {control}\n {children}\n </label>\n )\n}\n"
|
|
2021
2099
|
}
|
|
2022
2100
|
]
|
|
2023
2101
|
},
|
|
@@ -2044,7 +2122,7 @@
|
|
|
2044
2122
|
"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.",
|
|
2045
2123
|
"dependencies": [
|
|
2046
2124
|
"class-variance-authority",
|
|
2047
|
-
"dowel-ui@^0.
|
|
2125
|
+
"dowel-ui@^0.33.0"
|
|
2048
2126
|
],
|
|
2049
2127
|
"registryDependencies": [
|
|
2050
2128
|
"https://lacodda.github.io/dowel/r/table-sort.json"
|
|
@@ -2066,7 +2144,7 @@
|
|
|
2066
2144
|
"dependencies": [
|
|
2067
2145
|
"@base-ui/react",
|
|
2068
2146
|
"class-variance-authority",
|
|
2069
|
-
"dowel-ui@^0.
|
|
2147
|
+
"dowel-ui@^0.33.0"
|
|
2070
2148
|
],
|
|
2071
2149
|
"registryDependencies": [],
|
|
2072
2150
|
"files": [
|
|
@@ -2085,7 +2163,7 @@
|
|
|
2085
2163
|
"description": "Free text turned into a list: type a word, press Enter, it becomes a chip.",
|
|
2086
2164
|
"dependencies": [
|
|
2087
2165
|
"class-variance-authority",
|
|
2088
|
-
"dowel-ui@^0.
|
|
2166
|
+
"dowel-ui@^0.33.0"
|
|
2089
2167
|
],
|
|
2090
2168
|
"registryDependencies": [
|
|
2091
2169
|
"https://lacodda.github.io/dowel/r/chip.json",
|
|
@@ -2106,7 +2184,7 @@
|
|
|
2106
2184
|
"title": "Textarea",
|
|
2107
2185
|
"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.",
|
|
2108
2186
|
"dependencies": [
|
|
2109
|
-
"dowel-ui@^0.
|
|
2187
|
+
"dowel-ui@^0.33.0"
|
|
2110
2188
|
],
|
|
2111
2189
|
"registryDependencies": [
|
|
2112
2190
|
"https://lacodda.github.io/dowel/r/input.json"
|
|
@@ -2127,7 +2205,7 @@
|
|
|
2127
2205
|
"description": "The tier primitive.",
|
|
2128
2206
|
"dependencies": [
|
|
2129
2207
|
"class-variance-authority",
|
|
2130
|
-
"dowel-ui@^0.
|
|
2208
|
+
"dowel-ui@^0.33.0"
|
|
2131
2209
|
],
|
|
2132
2210
|
"registryDependencies": [],
|
|
2133
2211
|
"files": [
|
|
@@ -2135,7 +2213,7 @@
|
|
|
2135
2213
|
"path": "ui/tier.tsx",
|
|
2136
2214
|
"target": "@ui/tier.tsx",
|
|
2137
2215
|
"type": "registry:ui",
|
|
2138
|
-
"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"
|
|
2216
|
+
"content": "import { useId, type HTMLAttributes, type KeyboardEvent, type 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 /** The mark under the pointer, and `undefined` when the pointer leaves -\n * so a product can say what a mark means while it is being weighed, before\n * anyone commits to it. The question while scoring is about the mark being\n * considered, not the one already given. */\n onPreview?: (mark: number | undefined) => void\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 onPreview,\n className,\n ...props\n}: AxisBarProps) {\n const marks = Math.max(1, Math.round(scale))\n const interactive = onChange !== undefined\n const thresholdId = useId()\n // Drawn only where it is true, and said only where it is drawn: a mark\n // past the end of the scale is no threshold at all.\n const reachable = threshold !== undefined && threshold.mark >= 1 && threshold.mark <= marks\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 // The threshold is a ring on a segment and a `title` on it, and both\n // are pointer-only: the segments are `aria-hidden`, and a title on a\n // span is never shown to the keyboard. So the row itself carries it as\n // its description, and a reader hears \"a clip from 8\" with the value.\n aria-describedby={reachable ? thresholdId : undefined}\n onKeyDown={interactive ? onKeyDown : undefined}\n onPointerLeave={onPreview ? () => onPreview(undefined) : 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 = reachable && threshold.mark === mark\n const preview = onPreview ? () => onPreview(mark) : undefined\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 onPointerEnter={preview}\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 onPointerEnter={preview} className={shared} />\n )\n })}\n {reachable ? (\n <span id={thresholdId} hidden>\n {threshold.label}\n </span>\n ) : null}\n </div>\n )\n}\n"
|
|
2139
2217
|
}
|
|
2140
2218
|
]
|
|
2141
2219
|
},
|
|
@@ -2145,7 +2223,7 @@
|
|
|
2145
2223
|
"title": "TimeField",
|
|
2146
2224
|
"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`.",
|
|
2147
2225
|
"dependencies": [
|
|
2148
|
-
"dowel-ui@^0.
|
|
2226
|
+
"dowel-ui@^0.33.0"
|
|
2149
2227
|
],
|
|
2150
2228
|
"registryDependencies": [
|
|
2151
2229
|
"https://lacodda.github.io/dowel/r/input.json"
|
|
@@ -2166,7 +2244,7 @@
|
|
|
2166
2244
|
"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.",
|
|
2167
2245
|
"dependencies": [
|
|
2168
2246
|
"class-variance-authority",
|
|
2169
|
-
"dowel-ui@^0.
|
|
2247
|
+
"dowel-ui@^0.33.0"
|
|
2170
2248
|
],
|
|
2171
2249
|
"registryDependencies": [],
|
|
2172
2250
|
"files": [
|
|
@@ -2174,7 +2252,7 @@
|
|
|
2174
2252
|
"path": "ui/timeline.tsx",
|
|
2175
2253
|
"target": "@ui/timeline.tsx",
|
|
2176
2254
|
"type": "registry:ui",
|
|
2177
|
-
"content": "import type { HTMLAttributes, ReactNode } from 'react'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { cn } from 'dowel-ui'\n\n/*\n * Timeline.\n *\n * What happened, in the order it happened: a release history, an audit trail,\n * the steps a job went through. Four products of the line draw one, and all\n * four drew it the same way - a list with a border on the left and a dot\n * positioned over it by hand.\n *\n * Three things that version gets wrong, and which are the reason this is a\n * primitive:\n *\n * - **The rail runs past the last entry.** A border on the container ends\n * where the container does, which is below the last dot - so the history\n * trails off into a line going nowhere, and a reader cannot tell a finished\n * list from one still loading. Here the rail is drawn per entry and the last\n * one does not draw it.\n * - **It is a list, and says so.** A stack of `<div>`s is announced as nothing;\n * an `<ol>` tells a reader how many entries there are and which one they are\n * on, which is the whole navigational value of a history.\n * - **The state is a colour.** A red dot for a failed step is invisible to the\n * reader it matters most to. The marker takes the same `Status` vocabulary as\n * `StatusDot`, and the same rule applies: the colour is emphasis and the\n * entry's own words are the message.\n *\n * The rail is drawn with a `<span>` rather than a border on the item, because\n * the segment has to start below the dot and run to the next one - a border\n * would start at the item's top edge and cut through the marker.\n */\n\nexport type TimelineStatus = 'good' | 'warn' | 'bad' | 'info' | 'neutral' | 'accent'\n\nexport const timelineMarkerVariants = cva(\n 'relative z-1 flex shrink-0 items-center justify-center rounded-full',\n {\n variants: {\n status: {\n accent: 'bg-accent text-on-accent',\n good: 'bg-good text-on-good',\n warn: 'bg-warn text-on-warn',\n bad: 'bg-bad text-on-bad',\n info: 'bg-info text-on-info',\n neutral: 'bg-line-2 text-dim',\n },\n /** A step not taken yet: the ring of a dot with nothing in it, which is\n * how every other tool draws \"not done\" and is worth borrowing. */\n pending: {\n true: 'border-2 border-line-2 bg-bg',\n false: '',\n },\n },\n defaultVariants: { status: 'neutral', pending: false },\n },\n)\n\nexport interface TimelineProps extends HTMLAttributes<HTMLOListElement> {\n /** Draw the rail and the markers on the right of the text, for a right-to-left\n * reading or a history beside a wider body of content. */\n side?: 'start' | 'end'\n}\n\n/**\n * The list itself.\n *\n * An ordered list, because the order is the content. A product that shows the\n * newest first hands them over in that order - the component does not sort,\n * since \"newest first\" and \"the order it happened\" are both right and only the\n * product knows which it means.\n */\nexport function Timeline({ side, className, children, ...props }: TimelineProps) {\n // Defaulted here rather than in the parameter list: a string default in the\n // props reads to the word gate as English shipped inside a primitive, and\n // the rule it enforces is worth more than the shorter spelling.\n const on = side ?? 'start'\n return (\n <ol\n className={cn('flex flex-col', on === 'end' && 'items-end', className)}\n data-side={on}\n {...props}\n >\n {children}\n </ol>\n )\n}\n\nexport interface TimelineItemProps\n extends Omit<HTMLAttributes<HTMLLIElement>, 'title'>,\n VariantProps<typeof timelineMarkerVariants> {\n /** The headline of the entry - what happened. */\n title: ReactNode\n /** When it happened. Drawn quieter, and given as a node so the product can\n * pass its own `RelativeTime` rather than a string it formatted early. */\n time?: ReactNode\n /** A shape inside the marker - a tick, a cross, a number. The second\n * channel, for a reader who does not separate the hues. */\n icon?: ReactNode\n /** The last entry does not draw a rail below it. Set by the product, because\n * only it knows whether the history ends here or merely stops being shown. */\n last?: boolean\n /** Names the state in the product's words, for a reader who does not see the\n * colour. Required whenever `status` carries meaning the title does not\n * already say. */\n statusLabel?: string\n}\n\n/**\n * One entry: a marker, a rail down to the next, and what happened.\n *\n * The rail belongs to this entry rather than to the list, so a history can end\n * cleanly - and so an entry can be rendered on its own in a test or a preview\n * without a container to inherit a border from.\n */\nexport function TimelineItem({\n title,\n time,\n icon,\n status,\n pending,\n last = false,\n statusLabel,\n className,\n children,\n ...props\n}: TimelineItemProps) {\n return (\n <li className={cn('flex gap-3', className)} {...props}>\n {/* The column runs the full height of the entry, gap included. The\n spacing between entries is on the text beside it rather than on the\n `<li>`, and that is not a detail of taste: with the padding on the\n item, the column stops above it and the rail stops with it - leaving\n a gap of exactly the padding between one rail and the next marker.\n Measured on the stand at 18-22px per entry, and invisible to a test\n that counts rails rather than asking where they reach. */}\n <div className=\"relative flex flex-col items-center self-stretch\">\n <span\n className={cn(\n timelineMarkerVariants({ status, pending }),\n // Room for a glyph when there is one; a bare dot stays small\n // enough to read as a point on a line rather than as a bullet.\n icon ? 'mt-0.5 size-5 text-
|
|
2255
|
+
"content": "import type { HTMLAttributes, ReactNode } from 'react'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { cn } from 'dowel-ui'\n\n/*\n * Timeline.\n *\n * What happened, in the order it happened: a release history, an audit trail,\n * the steps a job went through. Four products of the line draw one, and all\n * four drew it the same way - a list with a border on the left and a dot\n * positioned over it by hand.\n *\n * Three things that version gets wrong, and which are the reason this is a\n * primitive:\n *\n * - **The rail runs past the last entry.** A border on the container ends\n * where the container does, which is below the last dot - so the history\n * trails off into a line going nowhere, and a reader cannot tell a finished\n * list from one still loading. Here the rail is drawn per entry and the last\n * one does not draw it.\n * - **It is a list, and says so.** A stack of `<div>`s is announced as nothing;\n * an `<ol>` tells a reader how many entries there are and which one they are\n * on, which is the whole navigational value of a history.\n * - **The state is a colour.** A red dot for a failed step is invisible to the\n * reader it matters most to. The marker takes the same `Status` vocabulary as\n * `StatusDot`, and the same rule applies: the colour is emphasis and the\n * entry's own words are the message.\n *\n * The rail is drawn with a `<span>` rather than a border on the item, because\n * the segment has to start below the dot and run to the next one - a border\n * would start at the item's top edge and cut through the marker.\n */\n\nexport type TimelineStatus = 'good' | 'warn' | 'bad' | 'info' | 'neutral' | 'accent'\n\nexport const timelineMarkerVariants = cva(\n 'relative z-1 flex shrink-0 items-center justify-center rounded-full',\n {\n variants: {\n status: {\n accent: 'bg-accent text-on-accent',\n good: 'bg-good text-on-good',\n warn: 'bg-warn text-on-warn',\n bad: 'bg-bad text-on-bad',\n info: 'bg-info text-on-info',\n neutral: 'bg-line-2 text-dim',\n },\n /** A step not taken yet: the ring of a dot with nothing in it, which is\n * how every other tool draws \"not done\" and is worth borrowing. */\n pending: {\n true: 'border-2 border-line-2 bg-bg',\n false: '',\n },\n },\n defaultVariants: { status: 'neutral', pending: false },\n },\n)\n\nexport interface TimelineProps extends HTMLAttributes<HTMLOListElement> {\n /** Draw the rail and the markers on the right of the text, for a right-to-left\n * reading or a history beside a wider body of content. */\n side?: 'start' | 'end'\n}\n\n/**\n * The list itself.\n *\n * An ordered list, because the order is the content. A product that shows the\n * newest first hands them over in that order - the component does not sort,\n * since \"newest first\" and \"the order it happened\" are both right and only the\n * product knows which it means.\n */\nexport function Timeline({ side, className, children, ...props }: TimelineProps) {\n // Defaulted here rather than in the parameter list: a string default in the\n // props reads to the word gate as English shipped inside a primitive, and\n // the rule it enforces is worth more than the shorter spelling.\n const on = side ?? 'start'\n return (\n <ol\n className={cn('flex flex-col', on === 'end' && 'items-end', className)}\n data-side={on}\n {...props}\n >\n {children}\n </ol>\n )\n}\n\nexport interface TimelineItemProps\n extends Omit<HTMLAttributes<HTMLLIElement>, 'title'>,\n VariantProps<typeof timelineMarkerVariants> {\n /** The headline of the entry - what happened. */\n title: ReactNode\n /** When it happened. Drawn quieter, and given as a node so the product can\n * pass its own `RelativeTime` rather than a string it formatted early. */\n time?: ReactNode\n /** A shape inside the marker - a tick, a cross, a number. The second\n * channel, for a reader who does not separate the hues. */\n icon?: ReactNode\n /** The last entry does not draw a rail below it. Set by the product, because\n * only it knows whether the history ends here or merely stops being shown. */\n last?: boolean\n /** Names the state in the product's words, for a reader who does not see the\n * colour. Required whenever `status` carries meaning the title does not\n * already say. */\n statusLabel?: string\n}\n\n/**\n * One entry: a marker, a rail down to the next, and what happened.\n *\n * The rail belongs to this entry rather than to the list, so a history can end\n * cleanly - and so an entry can be rendered on its own in a test or a preview\n * without a container to inherit a border from.\n */\nexport function TimelineItem({\n title,\n time,\n icon,\n status,\n pending,\n last = false,\n statusLabel,\n className,\n children,\n ...props\n}: TimelineItemProps) {\n return (\n <li className={cn('flex gap-3', className)} {...props}>\n {/* The column runs the full height of the entry, gap included. The\n spacing between entries is on the text beside it rather than on the\n `<li>`, and that is not a detail of taste: with the padding on the\n item, the column stops above it and the rail stops with it - leaving\n a gap of exactly the padding between one rail and the next marker.\n Measured on the stand at 18-22px per entry, and invisible to a test\n that counts rails rather than asking where they reach. */}\n <div className=\"relative flex flex-col items-center self-stretch\">\n <span\n className={cn(\n timelineMarkerVariants({ status, pending }),\n // Room for a glyph when there is one; a bare dot stays small\n // enough to read as a point on a line rather than as a bullet.\n icon ? 'mt-0.5 size-5 text-2xs' : 'mt-1.5 size-2.5',\n )}\n // The marker is decorative when the state is not named. When it is,\n // the name is what a reader gets in place of the colour.\n role={statusLabel ? 'img' : undefined}\n aria-label={statusLabel}\n aria-hidden={statusLabel ? undefined : true}\n >\n {icon}\n </span>\n {!last && (\n // From just under the marker to the bottom of the entry, which is\n // where the next marker begins.\n <span aria-hidden className=\"mt-1 w-px flex-1 bg-line\" />\n )}\n </div>\n\n <div className={cn('flex min-w-0 flex-1 flex-col gap-0.5', last ? 'pb-0.5' : 'pb-4')}>\n <div className=\"flex flex-wrap items-baseline gap-x-2 gap-y-0.5\">\n <span className=\"text-sm text-text\">{title}</span>\n {time && <span className=\"text-2xs text-faint\">{time}</span>}\n </div>\n {children && <div className=\"text-xs text-dim\">{children}</div>}\n </div>\n </li>\n )\n}\n"
|
|
2178
2256
|
}
|
|
2179
2257
|
]
|
|
2180
2258
|
},
|
|
@@ -2186,7 +2264,7 @@
|
|
|
2186
2264
|
"dependencies": [
|
|
2187
2265
|
"@base-ui/react",
|
|
2188
2266
|
"class-variance-authority",
|
|
2189
|
-
"dowel-ui@^0.
|
|
2267
|
+
"dowel-ui@^0.33.0"
|
|
2190
2268
|
],
|
|
2191
2269
|
"registryDependencies": [],
|
|
2192
2270
|
"files": [
|
|
@@ -2206,7 +2284,7 @@
|
|
|
2206
2284
|
"dependencies": [
|
|
2207
2285
|
"@base-ui/react",
|
|
2208
2286
|
"class-variance-authority",
|
|
2209
|
-
"dowel-ui@^0.
|
|
2287
|
+
"dowel-ui@^0.33.0"
|
|
2210
2288
|
],
|
|
2211
2289
|
"registryDependencies": [
|
|
2212
2290
|
"https://lacodda.github.io/dowel/r/layer.json"
|
|
@@ -2216,7 +2294,7 @@
|
|
|
2216
2294
|
"path": "ui/tooltip.tsx",
|
|
2217
2295
|
"target": "@ui/tooltip.tsx",
|
|
2218
2296
|
"type": "registry:ui",
|
|
2219
|
-
"content": "import { Tooltip as Base } from '@base-ui/react/tooltip'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { cn } from 'dowel-ui'\nimport { usePopupContainer } from './layer'\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-
|
|
2297
|
+
"content": "import { Tooltip as Base } from '@base-ui/react/tooltip'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { cn } from 'dowel-ui'\nimport { usePopupContainer } from './layer'\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-64',\n wide: 'max-w-96',\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 raised host of the overlay this is\n * opened inside (`layer.tsx`), and to the document body when there is none -\n * either way not the element it was opened from, whose `overflow` would clip\n * it. Pass an element to put it somewhere else, such as a container being\n * 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 // Inside an overlay, the overlay's raised host rather than the body - or\n // this popup draws under the dialog, drawer or popover that opened it. See\n // `layer.tsx`. Outside every overlay the hook gives `undefined`: the body.\n const host = usePopupContainer()\n\n return (\n <Base.Portal container={container ?? host}>\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"
|
|
2220
2298
|
}
|
|
2221
2299
|
]
|
|
2222
2300
|
},
|
|
@@ -2243,7 +2321,7 @@
|
|
|
2243
2321
|
"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.",
|
|
2244
2322
|
"dependencies": [
|
|
2245
2323
|
"class-variance-authority",
|
|
2246
|
-
"dowel-ui@^0.
|
|
2324
|
+
"dowel-ui@^0.33.0"
|
|
2247
2325
|
],
|
|
2248
2326
|
"registryDependencies": [
|
|
2249
2327
|
"https://lacodda.github.io/dowel/r/track-segments.json"
|
|
@@ -2253,7 +2331,7 @@
|
|
|
2253
2331
|
"path": "ui/track.tsx",
|
|
2254
2332
|
"target": "@ui/track.tsx",
|
|
2255
2333
|
"type": "registry:ui",
|
|
2256
|
-
"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-
|
|
2334
|
+
"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-2xs text-faint', className)}\n {...props}\n />\n )\n}\n"
|
|
2257
2335
|
}
|
|
2258
2336
|
]
|
|
2259
2337
|
},
|
|
@@ -2279,7 +2357,7 @@
|
|
|
2279
2357
|
"title": "TreeView",
|
|
2280
2358
|
"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.",
|
|
2281
2359
|
"dependencies": [
|
|
2282
|
-
"dowel-ui@^0.
|
|
2360
|
+
"dowel-ui@^0.33.0"
|
|
2283
2361
|
],
|
|
2284
2362
|
"registryDependencies": [
|
|
2285
2363
|
"https://lacodda.github.io/dowel/r/tree-rows.json"
|
|
@@ -2299,7 +2377,7 @@
|
|
|
2299
2377
|
"title": "Truncate",
|
|
2300
2378
|
"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.",
|
|
2301
2379
|
"dependencies": [
|
|
2302
|
-
"dowel-ui@^0.
|
|
2380
|
+
"dowel-ui@^0.33.0"
|
|
2303
2381
|
],
|
|
2304
2382
|
"registryDependencies": [],
|
|
2305
2383
|
"files": [
|
|
@@ -2317,7 +2395,7 @@
|
|
|
2317
2395
|
"title": "VirtualList",
|
|
2318
2396
|
"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.",
|
|
2319
2397
|
"dependencies": [
|
|
2320
|
-
"dowel-ui@^0.
|
|
2398
|
+
"dowel-ui@^0.33.0"
|
|
2321
2399
|
],
|
|
2322
2400
|
"registryDependencies": [],
|
|
2323
2401
|
"files": [
|
|
@@ -2336,7 +2414,7 @@
|
|
|
2336
2414
|
"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.",
|
|
2337
2415
|
"dependencies": [
|
|
2338
2416
|
"@tauri-apps/api",
|
|
2339
|
-
"dowel-ui@^0.
|
|
2417
|
+
"dowel-ui@^0.33.0"
|
|
2340
2418
|
],
|
|
2341
2419
|
"registryDependencies": [],
|
|
2342
2420
|
"files": [
|
|
@@ -2344,7 +2422,7 @@
|
|
|
2344
2422
|
"path": "ui/window-frame.tsx",
|
|
2345
2423
|
"target": "@ui/window-frame.tsx",
|
|
2346
2424
|
"type": "registry:ui",
|
|
2347
|
-
"content": "import {\n useCallback,\n useEffect,\n useState,\n type CSSProperties,\n type MouseEvent,\n type PointerEvent,\n type ReactNode,\n} from 'react'\nimport { getCurrentWindow } from '@tauri-apps/api/window'\nimport { cn } from 'dowel-ui'\n\n/** The eight compass names Tauri resizes by. Read off the method rather than\n * imported: the package declares the type without exporting it. */\ntype ResizeDirection = Parameters<ReturnType<typeof getCurrentWindow>['startResizeDragging']>[0]\n\n/*\n * The window's own frame, for a window that has no system frame.\n *\n * With `decorations: false` the system draws nothing, so everything it used\n * to do is the page's: dragging the window by its title bar, double-click to\n * maximise, the three buttons, and the edges you grab to resize. Each is\n * small; the reason to take them on at all is that a system title bar over an\n * application title bar costs a strip of every laptop screen for nothing.\n * scheda made the trade first and kilna copied it, which is the second\n * consumer the line asks for before anything becomes shared.\n *\n * `TitleBar` is the bar itself, assembled: the product's mark, whatever the\n * window shows at the top (its open documents as `Tabs variant=\"bar\"`, or a\n * trail), a stretch that exists only to be grabbed, the product's own actions,\n * and the three buttons. `ResizeEdges` goes once at the root beside it. The\n * parts are exported too - `WindowButtons`, `useTitleBarGestures()` spread on\n * a bar of your own, and `useMaximized()` for anything else that changes shape\n * with the window - for a bar the assembled one does not fit.\n *\n * Outside Tauri - a browser, a test, the stand - there is no window to drive.\n * Every call goes through `currentWindow()`, which answers null when the\n * Tauri bridge is absent, so the chrome renders and does nothing rather than\n * throwing on the first click. A product's own storybook runs in a browser\n * too, and a title bar that crashes it is a title bar nobody previews.\n */\n\n/** The Tauri window, or null where there is none to drive. The bridge is what\n * `getCurrentWindow` reads its label from, so its absence is the test. */\nfunction currentWindow() {\n return '__TAURI_INTERNALS__' in window ? getCurrentWindow() : null\n}\n\n/** Whether the window is maximised, kept current as the window changes.\n *\n * The window can be maximised without our buttons - a drag to the top edge,\n * the keyboard, a snap layout - so the answer follows the window rather than\n * our own last click. */\nexport function useMaximized(): boolean {\n const [maximized, setMaximized] = useState(false)\n\n useEffect(() => {\n const target = currentWindow()\n if (!target) return\n const read = () => {\n target.isMaximized().then(setMaximized).catch(() => undefined)\n }\n read()\n const unlisten = target.onResized(read)\n return () => {\n unlisten.then((stop) => stop()).catch(() => undefined)\n }\n }, [])\n\n return maximized\n}\n\nexport interface WindowButtonsProps {\n /** What each button is called. Required, and deliberately without a\n * default: a string this component invents is a string the product cannot\n * translate. `restore` replaces `maximize` while the window is maximised. */\n labels: { minimize: string; maximize: string; restore: string; close: string }\n className?: string\n}\n\ntype Control = keyof WindowButtonsProps['labels']\n\n/** The four glyphs, drawn in one stroke on a ten-pixel grid - the size the\n * system's own were, so the bar reads as the window's and not as a toolbar. */\nconst GLYPH: Record<Control, ReactNode> = {\n minimize: <path d=\"M0 5h10\" />,\n maximize: <rect x=\"0.5\" y=\"0.5\" width=\"9\" height=\"9\" />,\n restore: <path d=\"M2.5 2.5V0.5h7v7h-2M0.5 2.5h7v7h-7z\" />,\n close: <path d=\"M0 0l10 10M10 0L0 10\" />,\n}\n\n/** The window controls, in the order Windows puts them.\n *\n * Close asks rather than closes: Tauri's `close()` emits `closeRequested`\n * before anything happens, so a product's unsaved-work guard listening for\n * that request sees this button exactly as it sees the system's own close.\n * scheda once took an `onClose` of its own to get that; it was never needed,\n * and a second way to close is a second place for the guard to be missed. */\nexport function WindowButtons({ labels, className }: WindowButtonsProps) {\n const maximized = useMaximized()\n const controls: [Control, () => unknown][] = [\n ['minimize', () => currentWindow()?.minimize()],\n [maximized ? 'restore' : 'maximize', () => currentWindow()?.toggleMaximize()],\n ['close', () => currentWindow()?.close()],\n ]\n\n return (\n <div className={cn('flex h-full shrink-0 items-stretch', className)}>\n {controls.map(([name, act]) => (\n <button\n key={name}\n type=\"button\"\n aria-label={labels[name]}\n title={labels[name]}\n onClick={() => void act()}\n className={cn(\n 'flex h-full w-window-button cursor-default items-center justify-center text-dim transition-colors',\n 'hover:bg-soft hover:text-text',\n // The close button is the one that must not be mistaken for its\n // neighbours: it goes red under the pointer, as on every desktop.\n name === 'close' && 'hover:bg-bad hover:text-on-bad',\n )}\n >\n <svg width=\"10\" height=\"10\" viewBox=\"0 0 10 10\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1\" aria-hidden>\n {GLYPH[name]}\n </svg>\n </button>\n ))}\n </div>\n )\n}\n\n/** A press on a control has already been handled by the control. */\nconst shouldHandle = (target: EventTarget | null) =>\n !(target as HTMLElement | null)?.closest(\n 'button, a, input, textarea, [role=\"menu\"], [role=\"menuitem\"], [role=\"tab\"], [role=\"dialog\"]',\n )\n\n/** How far the pointer moves before a press becomes a drag, in pixels. */\nconst THRESHOLD = 4\n\n/** Makes an element behave like a title bar: drag to move, double-click to\n * maximise. Both are what the system used to do for free. Spread the result\n * on the bar: `<header {...useTitleBarGestures()}>`.\n *\n * Two handlers rather than one. A `pointerdown` cannot recognise a double\n * click: its `detail` counts clicks of the *mouse* event sequence, and the\n * second press still arrives as 1 - reading it there fired `startDragging`\n * three times over a double click and toggled nothing. So the press starts a\n * drag, and `dblclick`, which the browser is the one qualified to detect,\n * maximises.\n *\n * Dragging starts on the first movement, not on the press. `startDragging`\n * hands the window over to the system - which is what keeps snap layouts and\n * drag-to-edge working - but from that moment the webview stops seeing the\n * mouse. Calling it on `pointerdown` ate the second click of every double\n * click, and maximising never happened. */\nexport function useTitleBarGestures() {\n const onPointerDown = useCallback((event: PointerEvent) => {\n if (event.button !== 0 || !shouldHandle(event.target)) return\n\n const start = { x: event.clientX, y: event.clientY }\n const onMove = (move: globalThis.PointerEvent) => {\n if (Math.abs(move.clientX - start.x) < THRESHOLD && Math.abs(move.clientY - start.y) < THRESHOLD) {\n return\n }\n stop()\n void currentWindow()?.startDragging()\n }\n const stop = () => {\n window.removeEventListener('pointermove', onMove)\n window.removeEventListener('pointerup', stop)\n window.removeEventListener('pointercancel', stop)\n }\n\n window.addEventListener('pointermove', onMove)\n window.addEventListener('pointerup', stop)\n window.addEventListener('pointercancel', stop)\n }, [])\n\n const onDoubleClick = useCallback((event: MouseEvent) => {\n if (event.button !== 0 || !shouldHandle(event.target)) return\n void currentWindow()?.toggleMaximize()\n }, [])\n\n return { onPointerDown, onDoubleClick }\n}\n\n/** The eight edges and corners a frameless window still has to offer. */\nconst RESIZE_HANDLES: readonly ResizeDirection[] = [\n 'North',\n 'South',\n 'East',\n 'West',\n 'NorthEast',\n 'NorthWest',\n 'SouthEast',\n 'SouthWest',\n]\n\n/* The width of a strip and of a corner, as the theme states them. Read off\n * the tokens rather than written here: a window's chrome is shared with the\n * products that draw the rest of their own frame, and two numbers for one\n * edge is how the title bar ended up 40px in one product and 2.4rem in the\n * next. */\nconst EDGE = 'var(--spacing-resize-edge)'\nconst CORNER = 'var(--spacing-resize-corner)'\n\n/** Where each strip sits and which cursor it shows. Inline styles rather than\n * classes: eight positions of a few pixels each are geometry, not design. */\nconst EDGE_STYLE: Record<ResizeDirection, CSSProperties> = {\n North: { top: 0, left: CORNER, right: CORNER, height: EDGE, cursor: 'ns-resize' },\n South: { bottom: 0, left: CORNER, right: CORNER, height: EDGE, cursor: 'ns-resize' },\n East: { top: CORNER, bottom: CORNER, right: 0, width: EDGE, cursor: 'ew-resize' },\n West: { top: CORNER, bottom: CORNER, left: 0, width: EDGE, cursor: 'ew-resize' },\n NorthEast: { top: 0, right: 0, width: CORNER, height: CORNER, cursor: 'nesw-resize' },\n NorthWest: { top: 0, left: 0, width: CORNER, height: CORNER, cursor: 'nwse-resize' },\n SouthEast: { bottom: 0, right: 0, width: CORNER, height: CORNER, cursor: 'nwse-resize' },\n SouthWest: { bottom: 0, left: 0, width: CORNER, height: CORNER, cursor: 'nesw-resize' },\n}\n\nexport interface ResizeEdgesProps {\n /** Merged into every strip. `fixed` to the viewport by default, which is\n * where a window's edges are; `absolute` puts them on the nearest\n * positioned box instead, for a frame drawn inside a page. */\n className?: string\n}\n\n/** Invisible strips along the window's edges.\n *\n * A frameless window has no border to grab, so these put one back. They sit\n * outside the flow, above everything, and are only a few pixels wide -\n * enough to hit, not enough to steal a click meant for the text. A maximised\n * window has no edges to drag, and leaving the strips in place would mean\n * the top few pixels of the title bar stop taking clicks. */\nexport function ResizeEdges({ className }: ResizeEdgesProps) {\n const maximized = useMaximized()\n if (maximized) return null\n\n return (\n <>\n {RESIZE_HANDLES.map((direction) => (\n <div\n key={direction}\n aria-hidden\n data-resize-edge={direction}\n className={cn('fixed [z-index:var(--z-floating)]', className)}\n style={EDGE_STYLE[direction]}\n onPointerDown={(event) => {\n if (event.button !== 0) return\n event.preventDefault()\n void currentWindow()?.startResizeDragging(direction)\n }}\n />\n ))}\n </>\n )\n}\n\nexport interface TitleBarProps {\n /** The window buttons' names; see `WindowButtons`. */\n labels: WindowButtonsProps['labels']\n /** The product's mark, at the left edge, where the system put the icon. */\n mark?: ReactNode\n /** What the window shows at the top: its open documents as\n * `<Tabs><TabsList variant=\"bar\">`, a trail, or a title. */\n children?: ReactNode\n /** The product's own controls, between the handle and the window buttons. */\n actions?: ReactNode\n className?: string\n}\n\n/** A frameless window's title bar, assembled.\n *\n * Its height is `--spacing-titlebar`, and everything in it that is not a\n * control is a handle. The stretch between the content and the actions is\n * there for that alone: a window with twenty documents open would otherwise\n * have no bar left to drag by, so it never shrinks below a minimum, and the\n * content scrolls instead. */\nexport function TitleBar({ labels, mark, children, actions, className }: TitleBarProps) {\n const gestures = useTitleBarGestures()\n\n return (\n <header\n className={cn('flex h-titlebar shrink-0 items-stretch border-b border-line bg-raise select-none', className)}\n {...gestures}\n >\n {mark && <span className=\"flex shrink-0 items-center pr-2 pl-3\">{mark}</span>}\n <div className=\"flex min-w-0 items-stretch\">{children}</div>\n <div aria-hidden data-titlebar-handle className=\"min-w-4 flex-1\" />\n {actions && <div className=\"flex shrink-0 items-center gap-1 px-1\">{actions}</div>}\n <WindowButtons labels={labels} />\n </header>\n )\n}\n"
|
|
2425
|
+
"content": "import {\n useCallback,\n useEffect,\n useState,\n type CSSProperties,\n type MouseEvent,\n type PointerEvent,\n type ReactNode,\n} from 'react'\nimport { getCurrentWindow } from '@tauri-apps/api/window'\nimport { cn } from 'dowel-ui'\n\n/** The eight compass names Tauri resizes by. Read off the method rather than\n * imported: the package declares the type without exporting it. */\ntype ResizeDirection = Parameters<ReturnType<typeof getCurrentWindow>['startResizeDragging']>[0]\n\n/*\n * The window's own frame, for a window that has no system frame.\n *\n * With `decorations: false` the system draws nothing, so everything it used\n * to do is the page's: dragging the window by its title bar, double-click to\n * maximise, the three buttons, and the edges you grab to resize. Each is\n * small; the reason to take them on at all is that a system title bar over an\n * application title bar costs a strip of every laptop screen for nothing.\n * scheda made the trade first and kilna copied it, which is the second\n * consumer the line asks for before anything becomes shared.\n *\n * `TitleBar` is the bar itself, assembled: the product's mark, whatever the\n * window shows at the top (its open documents as `Tabs variant=\"bar\"`, or a\n * trail), a stretch that exists only to be grabbed, the product's own actions,\n * and the three buttons. `ResizeEdges` goes once at the root beside it. The\n * parts are exported too - `WindowButtons`, `useTitleBarGestures()` spread on\n * a bar of your own, and `useMaximized()` for anything else that changes shape\n * with the window - for a bar the assembled one does not fit.\n *\n * Outside Tauri - a browser, a test, the stand - there is no window to drive.\n * Every call goes through `currentWindow()`, which answers null when the\n * Tauri bridge is absent, so the chrome renders and does nothing rather than\n * throwing on the first click. A product's own storybook runs in a browser\n * too, and a title bar that crashes it is a title bar nobody previews.\n */\n\n/** The Tauri window, or null where there is none to drive. The bridge is what\n * `getCurrentWindow` reads its label from, so its absence is the test. */\nfunction currentWindow() {\n return '__TAURI_INTERNALS__' in window ? getCurrentWindow() : null\n}\n\n/** Whether the window is maximised, kept current as the window changes.\n *\n * The window can be maximised without our buttons - a drag to the top edge,\n * the keyboard, a snap layout - so the answer follows the window rather than\n * our own last click. */\nexport function useMaximized(): boolean {\n const [maximized, setMaximized] = useState(false)\n\n useEffect(() => {\n const target = currentWindow()\n if (!target) return\n const read = () => {\n target.isMaximized().then(setMaximized).catch(() => undefined)\n }\n read()\n const unlisten = target.onResized(read)\n return () => {\n unlisten.then((stop) => stop()).catch(() => undefined)\n }\n }, [])\n\n return maximized\n}\n\nexport interface WindowButtonsProps {\n /** What each button is called. Required, and deliberately without a\n * default: a string this component invents is a string the product cannot\n * translate. `restore` replaces `maximize` while the window is maximised. */\n labels: { minimize: string; maximize: string; restore: string; close: string }\n className?: string\n}\n\ntype Control = keyof WindowButtonsProps['labels']\n\n/** The four glyphs, drawn in one stroke on a ten-pixel grid - the size the\n * system's own were, so the bar reads as the window's and not as a toolbar. */\nconst GLYPH: Record<Control, ReactNode> = {\n minimize: <path d=\"M0 5h10\" />,\n maximize: <rect x=\"0.5\" y=\"0.5\" width=\"9\" height=\"9\" />,\n restore: <path d=\"M2.5 2.5V0.5h7v7h-2M0.5 2.5h7v7h-7z\" />,\n close: <path d=\"M0 0l10 10M10 0L0 10\" />,\n}\n\n/** The window controls, in the order Windows puts them.\n *\n * Close asks rather than closes: Tauri's `close()` emits `closeRequested`\n * before anything happens, so a product's unsaved-work guard listening for\n * that request sees this button exactly as it sees the system's own close.\n * scheda once took an `onClose` of its own to get that; it was never needed,\n * and a second way to close is a second place for the guard to be missed. */\nexport function WindowButtons({ labels, className }: WindowButtonsProps) {\n const maximized = useMaximized()\n const controls: [Control, () => unknown][] = [\n ['minimize', () => currentWindow()?.minimize()],\n [maximized ? 'restore' : 'maximize', () => currentWindow()?.toggleMaximize()],\n ['close', () => currentWindow()?.close()],\n ]\n\n return (\n <div className={cn('flex h-full shrink-0 items-stretch', className)}>\n {controls.map(([name, act]) => (\n <button\n key={name}\n type=\"button\"\n aria-label={labels[name]}\n title={labels[name]}\n onClick={() => void act()}\n className={cn(\n 'flex h-full w-window-button cursor-default items-center justify-center text-dim transition-colors',\n 'hover:bg-soft hover:text-text',\n // The close button is the one that must not be mistaken for its\n // neighbours: it goes red under the pointer, as on every desktop.\n name === 'close' && 'hover:bg-bad hover:text-on-bad',\n )}\n >\n <svg width=\"10\" height=\"10\" viewBox=\"0 0 10 10\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1\" aria-hidden>\n {GLYPH[name]}\n </svg>\n </button>\n ))}\n </div>\n )\n}\n\n/** A press on a control has already been handled by the control. */\nconst shouldHandle = (target: EventTarget | null) =>\n !(target as HTMLElement | null)?.closest(\n 'button, a, input, textarea, [role=\"menu\"], [role=\"menuitem\"], [role=\"tab\"], [role=\"dialog\"]',\n )\n\n/** How far the pointer moves before a press becomes a drag, in pixels. */\nconst THRESHOLD = 4\n\n/** Makes an element behave like a title bar: drag to move, double-click to\n * maximise. Both are what the system used to do for free. Spread the result\n * on the bar: `<header {...useTitleBarGestures()}>`.\n *\n * Two handlers rather than one. A `pointerdown` cannot recognise a double\n * click: its `detail` counts clicks of the *mouse* event sequence, and the\n * second press still arrives as 1 - reading it there fired `startDragging`\n * three times over a double click and toggled nothing. So the press starts a\n * drag, and `dblclick`, which the browser is the one qualified to detect,\n * maximises.\n *\n * Dragging starts on the first movement, not on the press. `startDragging`\n * hands the window over to the system - which is what keeps snap layouts and\n * drag-to-edge working - but from that moment the webview stops seeing the\n * mouse. Calling it on `pointerdown` ate the second click of every double\n * click, and maximising never happened. */\nexport function useTitleBarGestures() {\n const onPointerDown = useCallback((event: PointerEvent) => {\n if (event.button !== 0 || !shouldHandle(event.target)) return\n\n const start = { x: event.clientX, y: event.clientY }\n const onMove = (move: globalThis.PointerEvent) => {\n if (Math.abs(move.clientX - start.x) < THRESHOLD && Math.abs(move.clientY - start.y) < THRESHOLD) {\n return\n }\n stop()\n void currentWindow()?.startDragging()\n }\n const stop = () => {\n window.removeEventListener('pointermove', onMove)\n window.removeEventListener('pointerup', stop)\n window.removeEventListener('pointercancel', stop)\n }\n\n window.addEventListener('pointermove', onMove)\n window.addEventListener('pointerup', stop)\n window.addEventListener('pointercancel', stop)\n }, [])\n\n const onDoubleClick = useCallback((event: MouseEvent) => {\n if (event.button !== 0 || !shouldHandle(event.target)) return\n void currentWindow()?.toggleMaximize()\n }, [])\n\n return { onPointerDown, onDoubleClick }\n}\n\n/** The eight edges and corners a frameless window still has to offer. */\nconst RESIZE_HANDLES: readonly ResizeDirection[] = [\n 'North',\n 'South',\n 'East',\n 'West',\n 'NorthEast',\n 'NorthWest',\n 'SouthEast',\n 'SouthWest',\n]\n\n/* The width of a strip and of a corner, as the theme states them. Read off\n * the tokens rather than written here: a window's chrome is shared with the\n * products that draw the rest of their own frame, and two numbers for one\n * edge is how the title bar ended up 40px in one product and 2.4rem in the\n * next. */\nconst EDGE = 'var(--spacing-resize-edge)'\nconst CORNER = 'var(--spacing-resize-corner)'\n\n/** Where each strip sits and which cursor it shows. Inline styles rather than\n * classes: eight positions of a few pixels each are geometry, not design. */\nconst EDGE_STYLE: Record<ResizeDirection, CSSProperties> = {\n North: { top: 0, left: CORNER, right: CORNER, height: EDGE, cursor: 'ns-resize' },\n South: { bottom: 0, left: CORNER, right: CORNER, height: EDGE, cursor: 'ns-resize' },\n East: { top: CORNER, bottom: CORNER, right: 0, width: EDGE, cursor: 'ew-resize' },\n West: { top: CORNER, bottom: CORNER, left: 0, width: EDGE, cursor: 'ew-resize' },\n NorthEast: { top: 0, right: 0, width: CORNER, height: CORNER, cursor: 'nesw-resize' },\n NorthWest: { top: 0, left: 0, width: CORNER, height: CORNER, cursor: 'nwse-resize' },\n SouthEast: { bottom: 0, right: 0, width: CORNER, height: CORNER, cursor: 'nwse-resize' },\n SouthWest: { bottom: 0, left: 0, width: CORNER, height: CORNER, cursor: 'nesw-resize' },\n}\n\nexport interface ResizeEdgesProps {\n /** Merged into every strip. `fixed` to the viewport by default, which is\n * where a window's edges are; `absolute` puts them on the nearest\n * positioned box instead, for a frame drawn inside a page. */\n className?: string\n}\n\n/** Invisible strips along the window's edges.\n *\n * A frameless window has no border to grab, so these put one back. They sit\n * outside the flow, above everything, and are only a few pixels wide -\n * enough to hit, not enough to steal a click meant for the text. A maximised\n * window has no edges to drag, and leaving the strips in place would mean\n * the top few pixels of the title bar stop taking clicks. */\nexport function ResizeEdges({ className }: ResizeEdgesProps) {\n const maximized = useMaximized()\n if (maximized) return null\n\n return (\n <>\n {RESIZE_HANDLES.map((direction) => (\n <div\n key={direction}\n aria-hidden\n data-resize-edge={direction}\n className={cn('fixed [z-index:var(--z-floating)]', className)}\n style={EDGE_STYLE[direction]}\n onPointerDown={(event) => {\n if (event.button !== 0) return\n event.preventDefault()\n void currentWindow()?.startResizeDragging(direction)\n }}\n />\n ))}\n </>\n )\n}\n\nexport interface TitleBarProps {\n /** The window buttons' names; see `WindowButtons`. */\n labels: WindowButtonsProps['labels']\n /** The product's mark, at the left edge, where the system put the icon. */\n mark?: ReactNode\n /** What the window shows at the top: its open documents as\n * `<Tabs><TabsList variant=\"bar\">`, a trail, or a title. */\n children?: ReactNode\n /** The product's own controls, between the handle and the window buttons. */\n actions?: ReactNode\n /** Something that belongs to the whole window rather than to either end -\n * the search box, most often - held at the window's centre. */\n center?: ReactNode\n className?: string\n}\n\n/** A frameless window's title bar, assembled.\n *\n * Its height is `--spacing-titlebar`, and everything in it that is not a\n * control is a handle. The stretch between the content and the actions is\n * there for that alone: a window with twenty documents open would otherwise\n * have no bar left to drag by, so it never shrinks below a minimum, and the\n * content scrolls instead.\n *\n * **`center` is held at the window's centre, not at the centre of what is\n * left.** A search box placed between the two ends of a flex row moves every\n * time a tab opens, and a thing the hand reaches for without looking has to\n * stay where it was. So with a centre the bar is three columns - two equal\n * sides around the middle - and each side keeps a handle of its own, so the\n * bar can still be grabbed on either side of the search. Without one the bar\n * stays a single row, and the window's own content keeps the whole width it\n * had. */\nexport function TitleBar({ labels, mark, children, actions, center, className }: TitleBarProps) {\n const gestures = useTitleBarGestures()\n const bar = 'h-titlebar shrink-0 items-stretch border-b border-line bg-raise select-none'\n\n const start = (\n <>\n {mark && <span className=\"flex shrink-0 items-center pr-2 pl-3\">{mark}</span>}\n <div className=\"flex min-w-0 items-stretch\">{children}</div>\n </>\n )\n const end = (\n <>\n {actions && <div className=\"flex shrink-0 items-center gap-1 px-1\">{actions}</div>}\n <WindowButtons labels={labels} />\n </>\n )\n\n if (center === undefined) {\n return (\n <header className={cn('flex', bar, className)} {...gestures}>\n {start}\n <div aria-hidden data-titlebar-handle className=\"min-w-4 flex-1\" />\n {end}\n </header>\n )\n }\n\n return (\n <header className={cn('grid grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)]', bar, className)} {...gestures}>\n <div className=\"flex min-w-0 items-stretch\">\n {start}\n <div aria-hidden data-titlebar-handle className=\"min-w-4 flex-1\" />\n </div>\n <div data-titlebar-center className=\"flex items-center\">\n {center}\n </div>\n <div className=\"flex min-w-0 items-stretch\">\n <div aria-hidden data-titlebar-handle className=\"min-w-4 flex-1\" />\n {end}\n </div>\n </header>\n )\n}\n"
|
|
2348
2426
|
}
|
|
2349
2427
|
]
|
|
2350
2428
|
},
|
|
@@ -2354,7 +2432,7 @@
|
|
|
2354
2432
|
"title": "Wizard",
|
|
2355
2433
|
"description": "A Stepper with the current step's content under it and the Back / Next / Finish row that walks through the steps - checking each one before it lets the user past. The Stepper alone shows progress; this is the form that makes it, and a product that only needs the picture should not install the form.",
|
|
2356
2434
|
"dependencies": [
|
|
2357
|
-
"dowel-ui@^0.
|
|
2435
|
+
"dowel-ui@^0.33.0"
|
|
2358
2436
|
],
|
|
2359
2437
|
"registryDependencies": [
|
|
2360
2438
|
"https://lacodda.github.io/dowel/r/button.json",
|