dowel-ui 0.33.0 → 0.34.1
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 +5 -4
- 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 +147 -106
- package/package.json +2 -2
package/dist/registry.json
CHANGED
|
@@ -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.34.1"
|
|
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.34.1"
|
|
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.34.1"
|
|
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.34.1"
|
|
373
373
|
],
|
|
374
374
|
"registryDependencies": [
|
|
375
375
|
"https://lacodda.github.io/dowel/r/activity-weeks.json"
|
|
@@ -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.34.1"
|
|
393
393
|
],
|
|
394
394
|
"registryDependencies": [
|
|
395
395
|
"https://lacodda.github.io/dowel/r/activity-heatmap.json",
|
|
@@ -416,7 +416,7 @@
|
|
|
416
416
|
"path": "ui/activity-weeks.tsx",
|
|
417
417
|
"target": "@ui/activity-weeks.tsx",
|
|
418
418
|
"type": "registry:ui",
|
|
419
|
-
"content": "/*\n * Laying a run of dates out in weeks, with no React in it.\n *\n * Split out for the reason `table-sort` and `track-segments` are - a product\n * that draws this somewhere other than the DOM needs the arithmetic, not a\n * component. One of the line's consumers draws it in a terminal.\n *\n * The rule the whole grid rests on, and the one worth stating loudest:\n * **a cell has four meanings and only one of them is a number.** Nothing\n * recorded, a value, a day still in progress, and a date outside the range\n * asked for are four different facts. A grid that paints \"nothing recorded\" as\n * the palest shade of \"a value\" tells the reader somebody did nothing on a day\n * nobody reported - which is a claim invented by the drawing.\n */\n\n/** What one square of the grid is. */\nexport type CellKind =\n /** No entry for this date. Not zero - no data at all. */\n | 'none'\n /** An entry with a figure behind it. */\n | 'value'\n /** Under way, with no total yet: today's row, a day still open. */\n | 'partial'\n /** A date the grid draws to keep its shape, outside the range asked for. */\n | 'outside'\n\n/** What the caller knows about one date. */\nexport interface ActivityEntry {\n /** `YYYY-MM-DD`. A label, never a moment: no zone shifts a date here. */\n date: string\n /** The figure. `null` for a day under way, which has no total yet. */\n value: number | null\n}\n\nexport interface Cell {\n date: string\n kind: CellKind\n value: number | null\n /** Which of the five steps this lands on, 1-5; `null` unless `kind` is\n * `value`. Discrete rather than continuous, because five shades can be told\n * apart and matched against a legend where a smooth gradient can only say\n * \"more\" and \"less\". */\n step: number | null\n /** Saturday or Sunday, read from the date itself.\n *\n * Only the weekend, never \"a day off\": which days someone actually rests is\n * a calendar this does not have, and guessing would mark an ordinary\n * Saturday shift as unusual. */\n weekend: boolean\n}\n\n/** Five steps, and the reason there are five: a legend a reader can count. */\nexport const STEPS = 5\n\n/** Sunday-first, because that is what `Date#getUTCDay` counts from. */\nexport type Weekday = 0 | 1 | 2 | 3 | 4 | 5 | 6\n\nconst DAY = 86_400_000\n\nfunction parse(date: string): number {\n return Date.parse(`${date}T00:00:00Z`)\n}\n\nfunction format(time: number): string {\n return new Date(time).toISOString().slice(0, 10)\n}\n\n/** Every date from `from` to `to` inclusive, as `YYYY-MM-DD`.\n *\n * Stepped through UTC so no local zone can shift a day: a date here is a\n * label, not a moment. */\nexport function datesBetween(from: string, to: string): string[] {\n const start = parse(from)\n const end = parse(to)\n if (!Number.isFinite(start) || !Number.isFinite(end) || end < start) return []\n\n const dates: string[] = []\n for (let time = start; time <= end; time += DAY) dates.push(format(time))\n return dates\n}\n\nexport function isWeekend(date: string): boolean {\n const day = new Date(parse(date)).getUTCDay()\n return day === 0 || day === 6\n}\n\nexport interface WeeksOptions {\n /** First date of the range the caller asked for. */\n from: string\n /** Last date. */\n to: string\n /** The day a week starts on. Monday in most of the world, Sunday in the US -\n * and it changes which column a date lands in, so it is stated rather than\n * guessed from a locale the grid cannot see. */\n weekStartsOn?: Weekday\n /** The ceiling the steps are measured against. Defaults to the largest value\n * present.\n *\n * Shared rather than per-column, so two grids can be compared: scaled to\n * itself, a quiet month and a heavy one each get their own darkest square,\n * which is the one thing a heatmap is for. */\n busiest?: number\n}\n\n/**\n * The range as columns of weeks, each column seven cells from the top.\n *\n * Columns rather than rows because that is the shape the grid is read in: a\n * week is a column, a weekday is a row, and time runs left to right.\n *\n * The first and last columns are padded to seven with `outside` cells, so\n * every column is the same height and a weekday stays on its own row. They are\n * not \"no data\" - the caller never asked about them - and drawing them as\n * empty squares would add days to the range that the reader did not request.\n */\nexport function weeks(entries: ActivityEntry[], options: WeeksOptions): Cell[][] {\n const { from, to, weekStartsOn = 1 } = options\n const dates = datesBetween(from, to)\n if (dates.length === 0) return []\n\n const byDate = new Map(entries.map((entry) => [entry.date, entry]))\n\n const values = entries\n .map((entry) => entry.value)\n .filter((value): value is number => value !== null && Number.isFinite(value))\n const busiest = options.busiest ?? (values.length > 0 ? Math.max(...values) : 0)\n\n const cellFor = (date: string): Cell => {\n const entry = byDate.get(date)\n const weekend = isWeekend(date)\n\n if (entry === undefined) return { date, kind: 'none', value: null, step: null, weekend }\n if (entry.value === null) return { date, kind: 'partial', value: null, step: null, weekend }\n\n return { date, kind: 'value', value: entry.value, step: stepFor(entry.value, busiest), weekend }\n }\n\n const outside = (date: string): Cell => ({\n date,\n kind: 'outside',\n value: null,\n step: null,\n weekend: isWeekend(date),\n })\n\n /* Where the first date sits in its own week, so the run starts on the right\n * row rather than at the top of the first column. */\n const columnOf = (date: string) => (new Date(parse(date)).getUTCDay() - weekStartsOn + 7) % 7\n\n const columns: Cell[][] = []\n let column: Cell[] = []\n\n // Pad the head, back-dating the squares so each one is the date it stands\n // for - a reader hovering the first column should not find a blank.\n const lead = columnOf(dates[0]!)\n for (let i = lead; i > 0; i--) column.push(outside(format(parse(dates[0]!) - i * DAY)))\n\n for (const date of dates) {\n column.push(cellFor(date))\n if (column.length === 7) {\n columns.push(column)\n column = []\n }\n }\n\n if (column.length > 0) {\n const last = parse(dates.at(-1)!)\n for (let i = 1; column.length < 7; i++) column.push(outside(format(last + i * DAY)))\n columns.push(column)\n }\n\n return columns\n}\n\n/**\n * Which step a value lands on, 1 to `STEPS`.\n *\n * The floor is 1 rather than 0: any value at all is a value, and a square\n * indistinguishable from an empty one would file a twenty-minute day under\n * \"nothing recorded\" - the confusion this module exists to prevent. A value of\n * zero is the exception, and it is the caller's own statement: zero measured is\n * not the same as nothing measured, and it still earns the faintest step.\n */\nexport function stepFor(value: number, busiest: number): number {\n /* No ceiling to measure against. That happens two ways and they mean\n * opposite things: a caller who stated no `busiest` and whose only values\n * are zero (every day measured, every day empty - the faintest step, because\n * that is what they are), or a caller who stated a ceiling of zero while\n * sending a real figure (the ceiling is wrong, and the figure is all the\n * grid has - the darkest, so it is not hidden). */\n if (!(busiest > 0)) return value > 0 ? STEPS : 1\n const share = Math.min(1, Math.max(0, value / busiest))\n return Math.max(1, Math.ceil(share * STEPS))\n}\n\n/** The weekday rows, in the order the grid draws them, as offsets from Sunday.\n *\n * Returned rather than assumed so a caller labelling the rows and a grid\n * drawing them cannot disagree about which row is Monday. */\nexport function weekdayRows(weekStartsOn: Weekday = 1): Weekday[] {\n return [0, 1, 2, 3, 4, 5, 6].map((offset) => ((weekStartsOn + offset) % 7) as Weekday)\n}\n"
|
|
419
|
+
"content": "export {}\n// Code first, so the description below survives `shadcn add`: the CLI writes\n// a file from its first token on and drops every comment above it.\n\n/*\n * Laying a run of dates out in weeks, with no React in it.\n *\n * Split out for the reason `table-sort` and `track-segments` are - a product\n * that draws this somewhere other than the DOM needs the arithmetic, not a\n * component. One of the line's consumers draws it in a terminal.\n *\n * The rule the whole grid rests on, and the one worth stating loudest:\n * **a cell has four meanings and only one of them is a number.** Nothing\n * recorded, a value, a day still in progress, and a date outside the range\n * asked for are four different facts. A grid that paints \"nothing recorded\" as\n * the palest shade of \"a value\" tells the reader somebody did nothing on a day\n * nobody reported - which is a claim invented by the drawing.\n */\n\n/** What one square of the grid is. */\nexport type CellKind =\n /** No entry for this date. Not zero - no data at all. */\n | 'none'\n /** An entry with a figure behind it. */\n | 'value'\n /** Under way, with no total yet: today's row, a day still open. */\n | 'partial'\n /** A date the grid draws to keep its shape, outside the range asked for. */\n | 'outside'\n\n/** What the caller knows about one date. */\nexport interface ActivityEntry {\n /** `YYYY-MM-DD`. A label, never a moment: no zone shifts a date here. */\n date: string\n /** The figure. `null` for a day under way, which has no total yet. */\n value: number | null\n}\n\nexport interface Cell {\n date: string\n kind: CellKind\n value: number | null\n /** Which of the five steps this lands on, 1-5; `null` unless `kind` is\n * `value`. Discrete rather than continuous, because five shades can be told\n * apart and matched against a legend where a smooth gradient can only say\n * \"more\" and \"less\". */\n step: number | null\n /** Saturday or Sunday, read from the date itself.\n *\n * Only the weekend, never \"a day off\": which days someone actually rests is\n * a calendar this does not have, and guessing would mark an ordinary\n * Saturday shift as unusual. */\n weekend: boolean\n}\n\n/** Five steps, and the reason there are five: a legend a reader can count. */\nexport const STEPS = 5\n\n/** Sunday-first, because that is what `Date#getUTCDay` counts from. */\nexport type Weekday = 0 | 1 | 2 | 3 | 4 | 5 | 6\n\nconst DAY = 86_400_000\n\nfunction parse(date: string): number {\n return Date.parse(`${date}T00:00:00Z`)\n}\n\nfunction format(time: number): string {\n return new Date(time).toISOString().slice(0, 10)\n}\n\n/** Every date from `from` to `to` inclusive, as `YYYY-MM-DD`.\n *\n * Stepped through UTC so no local zone can shift a day: a date here is a\n * label, not a moment. */\nexport function datesBetween(from: string, to: string): string[] {\n const start = parse(from)\n const end = parse(to)\n if (!Number.isFinite(start) || !Number.isFinite(end) || end < start) return []\n\n const dates: string[] = []\n for (let time = start; time <= end; time += DAY) dates.push(format(time))\n return dates\n}\n\nexport function isWeekend(date: string): boolean {\n const day = new Date(parse(date)).getUTCDay()\n return day === 0 || day === 6\n}\n\nexport interface WeeksOptions {\n /** First date of the range the caller asked for. */\n from: string\n /** Last date. */\n to: string\n /** The day a week starts on. Monday in most of the world, Sunday in the US -\n * and it changes which column a date lands in, so it is stated rather than\n * guessed from a locale the grid cannot see. */\n weekStartsOn?: Weekday\n /** The ceiling the steps are measured against. Defaults to the largest value\n * present.\n *\n * Shared rather than per-column, so two grids can be compared: scaled to\n * itself, a quiet month and a heavy one each get their own darkest square,\n * which is the one thing a heatmap is for. */\n busiest?: number\n}\n\n/**\n * The range as columns of weeks, each column seven cells from the top.\n *\n * Columns rather than rows because that is the shape the grid is read in: a\n * week is a column, a weekday is a row, and time runs left to right.\n *\n * The first and last columns are padded to seven with `outside` cells, so\n * every column is the same height and a weekday stays on its own row. They are\n * not \"no data\" - the caller never asked about them - and drawing them as\n * empty squares would add days to the range that the reader did not request.\n */\nexport function weeks(entries: ActivityEntry[], options: WeeksOptions): Cell[][] {\n const { from, to, weekStartsOn = 1 } = options\n const dates = datesBetween(from, to)\n if (dates.length === 0) return []\n\n const byDate = new Map(entries.map((entry) => [entry.date, entry]))\n\n const values = entries\n .map((entry) => entry.value)\n .filter((value): value is number => value !== null && Number.isFinite(value))\n const busiest = options.busiest ?? (values.length > 0 ? Math.max(...values) : 0)\n\n const cellFor = (date: string): Cell => {\n const entry = byDate.get(date)\n const weekend = isWeekend(date)\n\n if (entry === undefined) return { date, kind: 'none', value: null, step: null, weekend }\n if (entry.value === null) return { date, kind: 'partial', value: null, step: null, weekend }\n\n return { date, kind: 'value', value: entry.value, step: stepFor(entry.value, busiest), weekend }\n }\n\n const outside = (date: string): Cell => ({\n date,\n kind: 'outside',\n value: null,\n step: null,\n weekend: isWeekend(date),\n })\n\n /* Where the first date sits in its own week, so the run starts on the right\n * row rather than at the top of the first column. */\n const columnOf = (date: string) => (new Date(parse(date)).getUTCDay() - weekStartsOn + 7) % 7\n\n const columns: Cell[][] = []\n let column: Cell[] = []\n\n // Pad the head, back-dating the squares so each one is the date it stands\n // for - a reader hovering the first column should not find a blank.\n const lead = columnOf(dates[0]!)\n for (let i = lead; i > 0; i--) column.push(outside(format(parse(dates[0]!) - i * DAY)))\n\n for (const date of dates) {\n column.push(cellFor(date))\n if (column.length === 7) {\n columns.push(column)\n column = []\n }\n }\n\n if (column.length > 0) {\n const last = parse(dates.at(-1)!)\n for (let i = 1; column.length < 7; i++) column.push(outside(format(last + i * DAY)))\n columns.push(column)\n }\n\n return columns\n}\n\n/**\n * Which step a value lands on, 1 to `STEPS`.\n *\n * The floor is 1 rather than 0: any value at all is a value, and a square\n * indistinguishable from an empty one would file a twenty-minute day under\n * \"nothing recorded\" - the confusion this module exists to prevent. A value of\n * zero is the exception, and it is the caller's own statement: zero measured is\n * not the same as nothing measured, and it still earns the faintest step.\n */\nexport function stepFor(value: number, busiest: number): number {\n /* No ceiling to measure against. That happens two ways and they mean\n * opposite things: a caller who stated no `busiest` and whose only values\n * are zero (every day measured, every day empty - the faintest step, because\n * that is what they are), or a caller who stated a ceiling of zero while\n * sending a real figure (the ceiling is wrong, and the figure is all the\n * grid has - the darkest, so it is not hidden). */\n if (!(busiest > 0)) return value > 0 ? STEPS : 1\n const share = Math.min(1, Math.max(0, value / busiest))\n return Math.max(1, Math.ceil(share * STEPS))\n}\n\n/** The weekday rows, in the order the grid draws them, as offsets from Sunday.\n *\n * Returned rather than assumed so a caller labelling the rows and a grid\n * drawing them cannot disagree about which row is Monday. */\nexport function weekdayRows(weekStartsOn: Weekday = 1): Weekday[] {\n return [0, 1, 2, 3, 4, 5, 6].map((offset) => ((weekStartsOn + offset) % 7) as Weekday)\n}\n"
|
|
420
420
|
}
|
|
421
421
|
]
|
|
422
422
|
},
|
|
@@ -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.34.1"
|
|
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.34.1"
|
|
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.34.1"
|
|
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.34.1"
|
|
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.34.1"
|
|
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.34.1"
|
|
526
526
|
],
|
|
527
527
|
"registryDependencies": [],
|
|
528
528
|
"files": [
|
|
@@ -534,6 +534,47 @@
|
|
|
534
534
|
}
|
|
535
535
|
]
|
|
536
536
|
},
|
|
537
|
+
{
|
|
538
|
+
"name": "big-calendar-item",
|
|
539
|
+
"type": "registry:ui",
|
|
540
|
+
"title": "BigCalendarItem",
|
|
541
|
+
"description": "One thing on a day of a month view, in one line: a release, a lesson, a payment that falls due.",
|
|
542
|
+
"dependencies": [
|
|
543
|
+
"@base-ui/react",
|
|
544
|
+
"dowel-ui@^0.34.1"
|
|
545
|
+
],
|
|
546
|
+
"registryDependencies": [],
|
|
547
|
+
"files": [
|
|
548
|
+
{
|
|
549
|
+
"path": "ui/big-calendar-item.tsx",
|
|
550
|
+
"target": "@ui/big-calendar-item.tsx",
|
|
551
|
+
"type": "registry:ui",
|
|
552
|
+
"content": "import { createContext, useContext, type CSSProperties, type HTMLAttributes, type ReactNode } from 'react'\nimport { useRender } from '@base-ui/react/use-render'\nimport { cn } from 'dowel-ui'\n\n/*\n * BigCalendarItem.\n *\n * One thing on a day of a month view, in one line: a release, a lesson, a\n * payment that falls due.\n *\n * One line is the rule the month rests on. BigCalendar folds what a day has\n * no room for into a count, and the count is arithmetic - lines of twenty\n * pixels, four apart - so an item that wraps to two lines on a narrow day\n * breaks the sum and pushes the last line out of the day. The words truncate\n * instead; the whole of them is in `title`, or wherever the product puts a\n * hover card.\n *\n * The colour is the item's own, not the product's: the work it belongs to, the\n * category it is in. Any CSS colour is taken - `var(--series-3)` for a\n * category, the work's own colour where a product keeps one - and drawn as a\n * soft fill with ink leaning towards the theme's text, so the same colour\n * reads on the dark theme and the light one. Without a colour the item is\n * neutral.\n *\n * Two states change how it is drawn, because both are said by kilna's month\n * every day: `tentative` is a plan rather than a booking - a dashed outline,\n * so a suggestion never reads as a fact - and `done` has happened, drawn\n * quieter and still openable, and still readable: a weaker fill and dim ink,\n * not a faded line.\n *\n * It stands outside the month as well: the queue of things waiting for a date\n * beside it, and the copy that travels under the pointer while one is dragged,\n * are the same line.\n */\n\n/**\n * Whether a line stands in the day the month's keyboard is on: `true` there,\n * `false` in every other day, `null` outside a month.\n *\n * The month is one tab stop and the arrows walk its days; Tab from a day goes\n * into that day and then out of the month. So only the lines of the day under\n * the cursor are in the tab order - a month holding forty releases is not\n * forty tab stops. BigCalendarItem reads this itself; a product drawing its\n * own line reads it for the line's `tabIndex`.\n */\nexport const BigCalendarDayContext = createContext<boolean | null>(null)\n\nexport interface BigCalendarItemProps extends Omit<HTMLAttributes<HTMLElement>, 'color'> {\n /** The item's own colour - any CSS colour, `var(--series-3)` for a\n * category. Neutral without one. */\n color?: string\n /** A plan rather than a booking: a dashed outline. */\n tentative?: boolean\n /** It has happened: drawn quieter, still pressable. */\n done?: boolean\n /** Before the words - a cover, a dot. */\n start?: ReactNode\n /** After the words - the glyphs that mark it: a kind, a lock, a status.\n * They do not shrink; the words truncate. */\n end?: ReactNode\n /** The element to draw: the product's link, a hover card's trigger.\n * Without it the item is a `<button>` when it has `onClick`, and a `<div>`\n * when it has nothing to do. */\n render?: useRender.RenderProp\n}\n\nconst slot = 'flex shrink-0 items-center gap-0.5 [&_svg:not([class*=size-])]:size-3'\n\n/* Quieter is a weaker fill and an ink halfway from the text to dim - never\n * opacity. kilna drew a released chip at 60% and its words measured 2.7:1 in\n * the light theme, 1.7:1 on a day of the next month, which was dimmed again;\n * the series mixed into `--dim` alone still came to 3.55. Halfway clears AA\n * in every series of every product (`tests/palettes.test.ts` holds it). */\nconst QUIET_INK = 'color-mix(in oklab, var(--dim), var(--text))'\n\nfunction tint(color: string | undefined, tentative: boolean, done: boolean): CSSProperties | undefined {\n if (tentative) return color === undefined ? undefined : { borderColor: color }\n if (color === undefined) return done ? { color: QUIET_INK } : undefined\n return {\n background: `color-mix(in oklab, ${color} ${done ? 15 : 30}%, transparent)`,\n color: `color-mix(in oklab, ${color} 30%, ${done ? QUIET_INK : 'var(--text)'})`,\n }\n}\n\nexport function BigCalendarItem({\n color,\n tentative = false,\n done = false,\n start,\n end,\n render,\n className,\n style,\n children,\n onClick,\n ...props\n}: BigCalendarItemProps) {\n const pressable = onClick !== undefined\n const inCursorDay = useContext(BigCalendarDayContext)\n return useRender({\n render,\n defaultTagName: pressable ? 'button' : 'div',\n props: {\n ...(render === undefined && pressable ? { type: 'button' } : {}),\n ...(inCursorDay === false ? { tabIndex: -1 } : {}),\n onClick,\n 'data-tentative': tentative ? '' : undefined,\n 'data-done': done ? '' : undefined,\n className: cn(\n // `target-min`: the line is twenty pixels and the gap four, so the\n // invisible target reaches the floor of twenty-four and stops exactly\n // where the next line's begins.\n 'target-min flex h-5 w-full min-w-0 shrink-0 items-center gap-1 rounded-sm px-1 text-left text-xs',\n 'focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-accent',\n tentative\n ? 'border border-dashed border-line-2 font-medium text-dim'\n : cn(\n done ? 'font-medium' : 'font-semibold',\n color === undefined && (done ? 'bg-softer' : 'bg-soft text-text'),\n ),\n pressable && 'cursor-pointer hover:inset-ring hover:inset-ring-line-2',\n className,\n ),\n style: { ...tint(color, tentative, done), ...style },\n children: (\n <>\n {start === undefined ? null : <span className={slot}>{start}</span>}\n <span className=\"min-w-0 flex-1 truncate\">{children}</span>\n {end === undefined ? null : <span className={slot}>{end}</span>}\n </>\n ),\n ...props,\n },\n })\n}\n"
|
|
553
|
+
}
|
|
554
|
+
]
|
|
555
|
+
},
|
|
556
|
+
{
|
|
557
|
+
"name": "big-calendar",
|
|
558
|
+
"type": "registry:ui",
|
|
559
|
+
"title": "BigCalendar",
|
|
560
|
+
"description": "A month at a time, with what is on each day of it, filling the box it is given.",
|
|
561
|
+
"dependencies": [
|
|
562
|
+
"dowel-ui@^0.34.1"
|
|
563
|
+
],
|
|
564
|
+
"registryDependencies": [
|
|
565
|
+
"https://lacodda.github.io/dowel/r/big-calendar-item.json",
|
|
566
|
+
"https://lacodda.github.io/dowel/r/calendar-math.json",
|
|
567
|
+
"https://lacodda.github.io/dowel/r/scroll-area.json"
|
|
568
|
+
],
|
|
569
|
+
"files": [
|
|
570
|
+
{
|
|
571
|
+
"path": "ui/big-calendar.tsx",
|
|
572
|
+
"target": "@ui/big-calendar.tsx",
|
|
573
|
+
"type": "registry:ui",
|
|
574
|
+
"content": "import {\n useContext,\n useEffect,\n useId,\n useMemo,\n useRef,\n useState,\n type HTMLAttributes,\n type KeyboardEvent,\n type MouseEvent,\n type ReactNode,\n} from 'react'\nimport { cn, useLocale } from 'dowel-ui'\nimport { BigCalendarDayContext } from './big-calendar-item'\nimport { firstDayOfWeek, keyStep, monthWeeks, parts, today, weekdayNames, type IsoDate } from './calendar-math'\nimport { ScrollArea } from './scroll-area'\n\n/*\n * BigCalendar.\n *\n * A month at a time, with what is on each day of it, filling the box it is\n * given.\n *\n * The question a month view is read for is not \"what is on the 14th\" - a\n * list answers that - but \"is that week empty\". So the month holds its box:\n * the weeks share the height, a day never grows past its share, and what a\n * day has no room for folds into a count. The count opens the day in place,\n * at the same height with its lines scrolling inside it, so a busy Friday does\n * not push the following week off the screen. One day open at a time: several\n * and the grid stops being a month at a glance, which is the only reason the\n * days fold.\n *\n * What a day holds is the product's: `renderDay` returns its lines, one node\n * each - BigCalendarItem draws one - and the grid decides how many show. The\n * sum is exact rather than estimated, because a line is twenty pixels and two\n * are four apart: the grid measures one day's room and divides.\n *\n * Only the weeks the month touches are drawn, four to six, and the days of the\n * neighbouring months in them are real days on a quieter ground - a release\n * can be dragged onto the 31st of August from the September page. Today is\n * filled with the accent rather than coloured: on a grid of thirty-five cells\n * a coloured digit is not where the eye starts.\n *\n * The month's name and the arrows that turn it are not here. They belong to\n * the screen's head, in one row with its filters and actions, which is where\n * kilna moved them; the grid asks for a new month only when the keyboard walks\n * off its edge.\n *\n * The keyboard is the Calendar's: the grid is one tab stop, the arrows move a\n * cursor through the days, PageUp and PageDown turn the month, and Enter\n * presses the day. Tab from a day goes into it - its add button, then its\n * lines - and then out of the month, because the lines of every other day are\n * out of the tab order: a month holding forty releases is not forty stops.\n *\n * Dragging is the product's. Every day carries `data-day`, so whatever library\n * moves things finds the day under the pointer with `dayOf`, and the handlers\n * go on the calendar itself rather than on forty-two cells. `dropTarget` lights\n * the day something would land on, and `dropHint` says what landing there\n * would mean.\n */\n\n/** How tall a line of a day is, in pixels: BigCalendarItem's `h-5`. */\nexport const LINE_HEIGHT = 20\n\n/** The gap between two lines, in pixels: the day's `gap-1`. With the line it\n * makes the pitch of twenty-four, the floor of a pointer target. */\nexport const LINE_GAP = 4\n\n/* What a day is assumed to hold before it has been measured - the first frame,\n * and every test, since jsdom lays nothing out. Three lines are what a week\n * is at its least. */\nconst UNMEASURED_LINES = 3\n\n/** How many lines fit in `height` pixels of a day. */\nexport function linesThatFit(height: number): number {\n if (!Number.isFinite(height) || height <= 0) return 0\n return Math.floor((height + LINE_GAP) / (LINE_HEIGHT + LINE_GAP))\n}\n\n/** What a day of `count` lines draws in `lines` of room: the first `shown`,\n * then `more` folded into a count. The count takes a line of its own, so a day\n * that cannot show everything gives up one more than the overflow - five in\n * four lines is three and \"2 more\", never four and a count cut off at the\n * foot of the day. */\nexport function foldDay(count: number, lines: number): { shown: number; more: number } {\n if (count <= lines) return { shown: count, more: 0 }\n const shown = Math.max(lines - 1, 0)\n return { shown, more: count - shown }\n}\n\n/** The day an element is in, or `null` - for the target of a drop, or for\n * `document.elementFromPoint` under a pointer that is carrying something. */\nexport function dayOf(target: EventTarget | null): IsoDate | null {\n if (!(target instanceof Element)) return null\n return target.closest<HTMLElement>('[data-day]')?.dataset.day ?? null\n}\n\ntype AddDay =\n | {\n /** Add something on this day: a plus in the corner of the day under\n * the pointer, and the first stop of Tab from the day the cursor is on. */\n onAddDay: (date: IsoDate) => void\n /** Names the plus. Required with it: an icon with no name announces\n * nothing. */\n addDayLabel: string\n }\n | { onAddDay?: undefined; addDayLabel?: undefined }\n\nexport type BigCalendarProps = AddDay &\n Omit<HTMLAttributes<HTMLDivElement>, 'children' | 'onSelect'> & {\n /** Any day of the month to show. */\n month: IsoDate\n /** Asked for when the keyboard walks off the grid; any day of the new\n * month. Without it the cursor stops at the edge. */\n onMonthChange?: (month: IsoDate) => void\n /** What a day holds, one node per line, each with a `key`. */\n renderDay: (date: IsoDate) => readonly ReactNode[]\n /** The words of the count a full day folds into, given how many are\n * folded: \"3 more\". */\n moreLabel: (count: number) => string\n /** The words that fold an open day again. */\n fewerLabel: string\n /** Pressing a day - a click on its ground, or Enter on the cursor. */\n onDaySelect?: (date: IsoDate) => void\n /** The day something being carried would land on. */\n dropTarget?: IsoDate | null\n /** What landing on `dropTarget` would mean, said at the foot of it. */\n dropHint?: ReactNode\n /** Names the months and the weekdays, and picks the first day of the week:\n * `en-GB` starts on Monday, `en-US` on Sunday, `en-u-fw-mon` on Monday\n * whatever the region. The application's language by default. */\n locale?: string\n }\n\n/* The controls inside a day, which press themselves; the day's own press is\n * for its ground. */\nconst CONTROL = 'button, a, input, select, textarea, [role=\"button\"]'\n\nexport function BigCalendar({\n month,\n onMonthChange,\n renderDay,\n moreLabel,\n fewerLabel,\n onDaySelect,\n onAddDay,\n addDayLabel,\n dropTarget = null,\n dropHint,\n locale,\n className,\n onKeyDown,\n 'aria-label': ariaLabel,\n ...props\n}: BigCalendarProps) {\n const language = useLocale(locale)\n const start = firstDayOfWeek(language)\n const weeks = useMemo(() => monthWeeks(month, language), [month, language])\n const names = useMemo(() => weekdayNames(language, start), [language, start])\n const format = useMemo(\n () => ({\n heading: new Intl.DateTimeFormat(language, { month: 'long', year: 'numeric' }),\n day: new Intl.DateTimeFormat(language, { dateStyle: 'full' }),\n }),\n [language],\n )\n const asDate = (date: IsoDate) => new Date(parts(date).year, parts(date).month - 1, parts(date).day)\n\n const shown = parts(month)\n const inMonth = (date: IsoDate) => parts(date).year === shown.year && parts(date).month === shown.month\n const days = weeks.flat()\n const now = today()\n const id = useId()\n\n /* The keyboard's cursor. When the product turns the month with its own\n * arrows the old cursor is off the grid, and the keyboard starts again on\n * today if today is in this month, or on the day `month` names. */\n const [cursor, setCursor] = useState<IsoDate>(now)\n const active = days.includes(cursor) ? cursor : inMonth(now) ? now : month\n\n const grid = useRef<HTMLDivElement>(null)\n const refocus = useRef(false)\n useEffect(() => {\n if (!refocus.current) return\n refocus.current = false\n grid.current?.querySelector<HTMLElement>(`[data-day=\"${active}\"]`)?.focus()\n })\n\n /* How many lines a day has room for. Every day is the same height, so one\n * measurement answers for all of them, taken again whenever the box\n * changes. jsdom has no ResizeObserver, and lays nothing out to measure. */\n const [lines, setLines] = useState(UNMEASURED_LINES)\n useEffect(() => {\n const element = grid.current\n if (element === null || typeof ResizeObserver === 'undefined') return\n const observer = new ResizeObserver(() => {\n const room = element.querySelector<HTMLElement>('[data-day-lines]')\n if (room !== null) setLines(linesThatFit(room.clientHeight))\n })\n observer.observe(element)\n return () => observer.disconnect()\n }, [])\n\n const [open, setOpen] = useState<IsoDate | null>(null)\n\n const moveTo = (date: IsoDate) => {\n setCursor(date)\n refocus.current = true\n }\n\n const keyDown = (event: KeyboardEvent<HTMLDivElement>) => {\n onKeyDown?.(event)\n if (event.defaultPrevented) return\n\n // Escape folds the open day from anywhere inside it.\n if (event.key === 'Escape' && open !== null && dayOf(event.target) === open) {\n event.preventDefault()\n setOpen(null)\n moveTo(open)\n return\n }\n\n // The rest belongs to the day itself, not a line inside it: a line may\n // want the arrows.\n const from = (event.target as HTMLElement).getAttribute('data-day')\n if (from === null) return\n if ((event.key === 'Enter' || event.key === ' ') && onDaySelect) {\n event.preventDefault()\n onDaySelect(from)\n return\n }\n\n const next = keyStep(event.key, from, start)\n if (next === undefined) return\n event.preventDefault()\n if (!days.includes(next)) {\n // Off the grid. The month is the product's, so it is asked to turn.\n if (!onMonthChange) return\n onMonthChange(next)\n }\n moveTo(next)\n }\n\n const pressDay = (date: IsoDate) => (event: MouseEvent<HTMLDivElement>) => {\n const control = (event.target as Element).closest(CONTROL)\n if (control !== null && event.currentTarget.contains(control)) return\n onDaySelect?.(date)\n }\n\n return (\n <div\n ref={grid}\n role=\"grid\"\n aria-label={ariaLabel ?? format.heading.format(asDate(month))}\n onKeyDown={keyDown}\n className={cn(\n 'flex min-h-0 min-w-0 flex-col overflow-hidden rounded-lg border border-line bg-raise',\n className,\n )}\n {...props}\n >\n <div role=\"rowgroup\">\n <div role=\"row\" className=\"grid grid-cols-7 border-b border-line\">\n {names.map((name) => (\n <div key={name} role=\"columnheader\" className=\"caption truncate px-2 py-1.5\">\n {name}\n </div>\n ))}\n </div>\n </div>\n\n <div role=\"rowgroup\" className=\"grid min-h-0 flex-1 auto-rows-fr\">\n {weeks.map((week, row) => (\n <div key={week[0]} role=\"row\" className=\"grid min-h-0 grid-cols-7\">\n {week.map((date, column) => {\n const items = renderDay(date)\n const fold = foldDay(items.length, lines)\n const isOpen = open === date && fold.more > 0\n const outside = !inMonth(date)\n const target = dropTarget === date\n\n return (\n <div\n key={date}\n role=\"gridcell\"\n data-day={date}\n data-outside={outside ? '' : undefined}\n data-drop-target={target ? '' : undefined}\n aria-current={date === now ? 'date' : undefined}\n // The date and what the day holds. Named from its whole\n // content, every day would also say \"Add a release on this\n // day\", thirty-five times over.\n aria-labelledby={`${id}${date}n ${id}${date}l`}\n tabIndex={date === active ? 0 : -1}\n onFocus={() => setCursor(date)}\n onClick={onDaySelect && pressDay(date)}\n className={cn(\n 'group/day relative flex min-h-0 min-w-0 flex-col gap-1 overflow-hidden p-1 transition-colors',\n 'focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-accent',\n column < 6 && 'border-r border-line',\n row < weeks.length - 1 && 'border-b border-line',\n outside && 'bg-softer',\n onDaySelect && 'cursor-pointer hover:bg-soft',\n target && 'bg-accent-soft hover:bg-accent-soft',\n )}\n >\n {/* The day in full for whoever cannot see the grid: \"14\" on\n * its own is not a date. */}\n <span id={`${id}${date}n`} className=\"sr-only\">\n {format.day.format(asDate(date))}\n </span>\n\n <div className=\"flex h-5 shrink-0 items-center justify-between gap-1\">\n <span\n aria-hidden\n className={cn(\n 'rounded-xs px-1 font-mono text-2xs tabular-nums',\n date === now\n ? 'bg-accent font-semibold text-on-accent'\n : outside\n ? 'text-faint'\n : 'text-dim',\n )}\n >\n {parts(date).day}\n </span>\n\n {/* Under the pointer, and on the day the keyboard is in: a\n * plus on every cell is thirty-five plus signs to read\n * past. Transparent rather than absent, so the row does\n * not reflow as the pointer crosses the month. */}\n {onAddDay && (\n <button\n type=\"button\"\n tabIndex={date === active ? 0 : -1}\n aria-label={addDayLabel}\n title={addDayLabel}\n onClick={() => onAddDay(date)}\n className={cn(\n 'target-min flex size-5 items-center justify-center rounded-sm text-dim opacity-0 transition-opacity',\n 'hover:bg-soft hover:text-text group-hover/day:opacity-100 group-focus-within/day:opacity-100',\n 'focus-visible:outline-2 focus-visible:outline-accent',\n )}\n >\n <svg viewBox=\"0 0 16 16\" width=\"12\" height=\"12\" aria-hidden>\n <path d=\"M8 3v10M3 8h10\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.75\" strokeLinecap=\"round\" />\n </svg>\n </button>\n )}\n </div>\n\n <BigCalendarDayContext.Provider value={date === active}>\n {isOpen ? (\n <ScrollArea label={format.day.format(asDate(date))} orientation=\"vertical\" className=\"flex-1\">\n <div id={`${id}${date}l`} className=\"flex flex-col gap-1\">\n {items}\n <Fold onClick={() => { setOpen(null); moveTo(date) }}>{fewerLabel}</Fold>\n </div>\n </ScrollArea>\n ) : (\n <div\n // The room `lines` is measured from: the same in every day.\n data-day-lines\n id={`${id}${date}l`}\n className=\"flex min-h-0 flex-1 flex-col gap-1 overflow-hidden\"\n >\n {items.slice(0, fold.shown)}\n {fold.more > 0 && (\n <Fold onClick={() => { setOpen(date); moveTo(date) }}>{moreLabel(fold.more)}</Fold>\n )}\n </div>\n )}\n </BigCalendarDayContext.Provider>\n\n {/* Over the foot of the day rather than under its lines: the\n * day does not grow, and the hint is only up while something\n * is carried over it. */}\n {target && dropHint != null && (\n <p className=\"pointer-events-none absolute inset-x-1 bottom-1 rounded-sm bg-raise px-1 py-0.5 text-2xs leading-tight text-dim shadow-lift\">\n {dropHint}\n </p>\n )}\n </div>\n )\n })}\n </div>\n ))}\n </div>\n </div>\n )\n}\n\n/** The line that folds a day or opens it: a line's height, so the sum holds. */\nfunction Fold({ onClick, children }: { onClick: () => void; children: string }) {\n const inCursorDay = useContext(BigCalendarDayContext)\n return (\n <button\n type=\"button\"\n tabIndex={inCursorDay === false ? -1 : 0}\n onClick={onClick}\n className={cn(\n 'target-min h-5 shrink-0 self-start rounded-sm px-1 text-xs text-dim hover:text-text',\n 'focus-visible:outline-2 focus-visible:outline-accent',\n )}\n >\n {children}\n </button>\n )\n}\n"
|
|
575
|
+
}
|
|
576
|
+
]
|
|
577
|
+
},
|
|
537
578
|
{
|
|
538
579
|
"name": "breadcrumbs",
|
|
539
580
|
"type": "registry:ui",
|
|
@@ -541,7 +582,7 @@
|
|
|
541
582
|
"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
583
|
"dependencies": [
|
|
543
584
|
"@base-ui/react",
|
|
544
|
-
"dowel-ui@^0.
|
|
585
|
+
"dowel-ui@^0.34.1"
|
|
545
586
|
],
|
|
546
587
|
"registryDependencies": [],
|
|
547
588
|
"files": [
|
|
@@ -561,7 +602,7 @@
|
|
|
561
602
|
"dependencies": [
|
|
562
603
|
"@base-ui/react",
|
|
563
604
|
"class-variance-authority",
|
|
564
|
-
"dowel-ui@^0.
|
|
605
|
+
"dowel-ui@^0.34.1"
|
|
565
606
|
],
|
|
566
607
|
"registryDependencies": [],
|
|
567
608
|
"files": [
|
|
@@ -585,7 +626,7 @@
|
|
|
585
626
|
"path": "ui/calendar-math.tsx",
|
|
586
627
|
"target": "@ui/calendar-math.tsx",
|
|
587
628
|
"type": "registry:ui",
|
|
588
|
-
"content": "/*\n * The arithmetic a calendar runs on, with no React in it.\n *\n * Split out of the Calendar because the size gate asked the right question:\n * the file was two and a half times over its ceiling, and the reason was that\n * it held two things - the sums, and the grid that draws them. These are the\n * sums, and they are what DatePicker, DateRangePicker and any product doing\n * its own date work import.\n *\n * Everything here takes and returns `YYYY-MM-DD`, and never a `Date`. A\n * birthday has no timezone; a release date has no hour. Put one in a `Date`\n * and it becomes a moment - and moments cross midnight when they are\n * serialised, which is how a date reaches a server a day early. The string is\n * what a database column holds and what JSON carries.\n *\n * `Date` appears inside, in two places only: to ask `Intl` for a name, and to\n * add days. Both are wrapped here, so no caller ever holds one.\n *\n * No date library, deliberately. `react-day-picker` is good and would bring\n * `date-fns` and `@date-fns/tz` behind it - the first heavy dependency in a\n * set that is otherwise Base UI or nothing. `Intl` already knows the part a\n * library would be consulted for: which day the week starts on here, and what\n * the months are called. The rest is the arithmetic below, and it is only\n * hard when a date is stored as a moment.\n */\n\n/** A calendar date: `2026-09-02`. Not a moment - no time, no zone. */\nexport type IsoDate = string\n\n/** Whether a string is a calendar date this component can work with, and one\n * that actually exists. `2026-02-31` parses arithmetically and is not a day. */\nexport function isIsoDate(value: string): value is IsoDate {\n if (!/^\\d{4}-\\d{2}-\\d{2}$/.test(value)) return false\n const [year, month, day] = value.split('-').map(Number) as [number, number, number]\n if (month < 1 || month > 12 || day < 1) return false\n return day <= daysInMonth(year, month)\n}\n\n/** How many days that month has. The leap rule in full, because the\n * hundred-year exception is the part that gets left out. */\nexport function daysInMonth(year: number, month: number): number {\n if (month === 2) {\n const leap = (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0\n return leap ? 29 : 28\n }\n return [4, 6, 9, 11].includes(month) ? 30 : 31\n}\n\n/** Today, as a calendar date in the reader's own timezone.\n *\n * Deliberately not `new Date().toISOString().slice(0, 10)`, which is the\n * common spelling and is wrong: that converts to UTC first, so anyone east of\n * Greenwich late in the evening gets tomorrow. */\nexport function today(): IsoDate {\n const now = new Date()\n return format(now.getFullYear(), now.getMonth() + 1, now.getDate())\n}\n\nfunction format(year: number, month: number, day: number): IsoDate {\n return `${String(year).padStart(4, '0')}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`\n}\n\ninterface Parts {\n year: number\n month: number\n day: number\n}\n\nexport function parts(date: IsoDate): Parts {\n const [year, month, day] = date.split('-').map(Number) as [number, number, number]\n return { year, month, day }\n}\n\n/** The same date shifted by whole days. Goes through a `Date` at noon rather\n * than midnight: a shift over a daylight-saving boundary at midnight can land\n * on the same calendar day it started from. */\nexport function addDays(date: IsoDate, days: number): IsoDate {\n const { year, month, day } = parts(date)\n const moved = new Date(year, month - 1, day, 12)\n moved.setDate(moved.getDate() + days)\n return format(moved.getFullYear(), moved.getMonth() + 1, moved.getDate())\n}\n\n/** The same day-of-month in another month, clamped when it does not exist\n * there: a step back from 31 March lands on 28 February, not on 3 March. */\nexport function addMonths(date: IsoDate, months: number): IsoDate {\n const { year, month, day } = parts(date)\n const zero = year * 12 + (month - 1) + months\n const nextYear = Math.floor(zero / 12)\n const nextMonth = (zero % 12) + 1\n return format(nextYear, nextMonth, Math.min(day, daysInMonth(nextYear, nextMonth)))\n}\n\n/** Which weekday a date falls on, as `Intl` numbers them: 1 is Monday, 7 is\n * Sunday. `Date` numbers Sunday 0, which does not sort and does not match\n * what `getWeekInfo` returns. */\nexport function weekday(date: IsoDate): number {\n const { year, month, day } = parts(date)\n const js = new Date(year, month - 1, day, 12).getDay()\n return js === 0 ? 7 : js\n}\n\n/** Which day the week starts on here: 1 Monday, 7 Sunday.\n *\n * `getWeekInfo` is the current spelling and `weekInfo` the older one; some\n * engines have neither, and Monday is the majority answer worldwide. */\nexport function firstDayOfWeek(locale: string): number {\n try {\n const info = new Intl.Locale(locale) as Intl.Locale & {\n getWeekInfo?: () => { firstDay: number }\n weekInfo?: { firstDay: number }\n }\n return info.getWeekInfo?.().firstDay ?? info.weekInfo?.firstDay ?? 1\n } catch {\n return 1\n }\n}\n\n/** The grid of a month: whole weeks, starting on the locale's first day, with\n * the days either side included so every row has seven.\n *\n * Returned as dates rather than as numbers, so a cell never has to be told\n * which month it belongs to - it knows, and a click on a trailing day works\n * without a special case. */\nexport function monthGrid(month: IsoDate, locale: string): IsoDate[][] {\n const { year, month: monthNumber } = parts(month)\n const first = format(year, monthNumber, 1)\n const start = firstDayOfWeek(locale)\n\n // How far back the grid starts: the distance from the first of the month\n // back to the most recent week start.\n const lead = (weekday(first) - start + 7) % 7\n const origin = addDays(first, -lead)\n\n const weeks: IsoDate[][] = []\n let cursor = origin\n // Six rows always, so the calendar does not change height between months -\n // a popup that resizes as you page through it is one that moves under the\n // pointer.\n for (let week = 0; week < 6; week += 1) {\n const row: IsoDate[] = []\n for (let day = 0; day < 7; day += 1) {\n row.push(cursor)\n cursor = addDays(cursor, 1)\n }\n weeks.push(row)\n }\n return weeks\n}\n\n/** The weekday initials, in the order this locale lays them out. */\nexport function weekdayNames(locale: string, start: number): string[] {\n const names = new Intl.DateTimeFormat(locale, { weekday: 'short' })\n // Any week works; this one begins on a Monday.\n const monday = Date.UTC(2024, 0, 1)\n return Array.from({ length: 7 }, (_, index) => {\n const offset = (start - 1 + index) % 7\n return names.format(new Date(monday + offset * 86_400_000))\n })\n}\n"
|
|
629
|
+
"content": "export {}\n// Code first, so the description below survives `shadcn add`: the CLI writes\n// a file from its first token on and drops every comment above it.\n\n/*\n * The arithmetic a calendar runs on, with no React in it.\n *\n * Split out of the Calendar because the size gate asked the right question:\n * the file was two and a half times over its ceiling, and the reason was that\n * it held two things - the sums, and the grid that draws them. These are the\n * sums, and they are what DatePicker, DateRangePicker and any product doing\n * its own date work import.\n *\n * Everything here takes and returns `YYYY-MM-DD`, and never a `Date`. A\n * birthday has no timezone; a release date has no hour. Put one in a `Date`\n * and it becomes a moment - and moments cross midnight when they are\n * serialised, which is how a date reaches a server a day early. The string is\n * what a database column holds and what JSON carries.\n *\n * `Date` appears inside, in two places only: to ask `Intl` for a name, and to\n * add days. Both are wrapped here, so no caller ever holds one.\n *\n * No date library, deliberately. `react-day-picker` is good and would bring\n * `date-fns` and `@date-fns/tz` behind it - the first heavy dependency in a\n * set that is otherwise Base UI or nothing. `Intl` already knows the part a\n * library would be consulted for: which day the week starts on here, and what\n * the months are called. The rest is the arithmetic below, and it is only\n * hard when a date is stored as a moment.\n */\n\n/** A calendar date: `2026-09-02`. Not a moment - no time, no zone. */\nexport type IsoDate = string\n\n/** Whether a string is a calendar date this component can work with, and one\n * that actually exists. `2026-02-31` parses arithmetically and is not a day. */\nexport function isIsoDate(value: string): value is IsoDate {\n if (!/^\\d{4}-\\d{2}-\\d{2}$/.test(value)) return false\n const [year, month, day] = value.split('-').map(Number) as [number, number, number]\n if (month < 1 || month > 12 || day < 1) return false\n return day <= daysInMonth(year, month)\n}\n\n/** How many days that month has. The leap rule in full, because the\n * hundred-year exception is the part that gets left out. */\nexport function daysInMonth(year: number, month: number): number {\n if (month === 2) {\n const leap = (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0\n return leap ? 29 : 28\n }\n return [4, 6, 9, 11].includes(month) ? 30 : 31\n}\n\n/** Today, as a calendar date in the reader's own timezone.\n *\n * Deliberately not `new Date().toISOString().slice(0, 10)`, which is the\n * common spelling and is wrong: that converts to UTC first, so anyone east of\n * Greenwich late in the evening gets tomorrow. */\nexport function today(): IsoDate {\n const now = new Date()\n return format(now.getFullYear(), now.getMonth() + 1, now.getDate())\n}\n\nfunction format(year: number, month: number, day: number): IsoDate {\n return `${String(year).padStart(4, '0')}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`\n}\n\ninterface Parts {\n year: number\n month: number\n day: number\n}\n\nexport function parts(date: IsoDate): Parts {\n const [year, month, day] = date.split('-').map(Number) as [number, number, number]\n return { year, month, day }\n}\n\n/** The same date shifted by whole days. Goes through a `Date` at noon rather\n * than midnight: a shift over a daylight-saving boundary at midnight can land\n * on the same calendar day it started from. */\nexport function addDays(date: IsoDate, days: number): IsoDate {\n const { year, month, day } = parts(date)\n const moved = new Date(year, month - 1, day, 12)\n moved.setDate(moved.getDate() + days)\n return format(moved.getFullYear(), moved.getMonth() + 1, moved.getDate())\n}\n\n/** The same day-of-month in another month, clamped when it does not exist\n * there: a step back from 31 March lands on 28 February, not on 3 March. */\nexport function addMonths(date: IsoDate, months: number): IsoDate {\n const { year, month, day } = parts(date)\n const zero = year * 12 + (month - 1) + months\n const nextYear = Math.floor(zero / 12)\n const nextMonth = (zero % 12) + 1\n return format(nextYear, nextMonth, Math.min(day, daysInMonth(nextYear, nextMonth)))\n}\n\n/** Which weekday a date falls on, as `Intl` numbers them: 1 is Monday, 7 is\n * Sunday. `Date` numbers Sunday 0, which does not sort and does not match\n * what `getWeekInfo` returns. */\nexport function weekday(date: IsoDate): number {\n const { year, month, day } = parts(date)\n const js = new Date(year, month - 1, day, 12).getDay()\n return js === 0 ? 7 : js\n}\n\n/** Which day the week starts on here: 1 Monday, 7 Sunday.\n *\n * `getWeekInfo` is the current spelling and `weekInfo` the older one; some\n * engines have neither, and Monday is the majority answer worldwide. */\nexport function firstDayOfWeek(locale: string): number {\n try {\n const info = new Intl.Locale(locale) as Intl.Locale & {\n getWeekInfo?: () => { firstDay: number }\n weekInfo?: { firstDay: number }\n }\n return info.getWeekInfo?.().firstDay ?? info.weekInfo?.firstDay ?? 1\n } catch {\n return 1\n }\n}\n\n/** The grid of a month: whole weeks, starting on the locale's first day, with\n * the days either side included so every row has seven.\n *\n * Returned as dates rather than as numbers, so a cell never has to be told\n * which month it belongs to - it knows, and a click on a trailing day works\n * without a special case. */\nexport function monthGrid(month: IsoDate, locale: string): IsoDate[][] {\n const { year, month: monthNumber } = parts(month)\n const first = format(year, monthNumber, 1)\n const start = firstDayOfWeek(locale)\n\n // How far back the grid starts: the distance from the first of the month\n // back to the most recent week start.\n const lead = (weekday(first) - start + 7) % 7\n const origin = addDays(first, -lead)\n\n const weeks: IsoDate[][] = []\n let cursor = origin\n // Six rows always, so the calendar does not change height between months -\n // a popup that resizes as you page through it is one that moves under the\n // pointer.\n for (let week = 0; week < 6; week += 1) {\n const row: IsoDate[] = []\n for (let day = 0; day < 7; day += 1) {\n row.push(cursor)\n cursor = addDays(cursor, 1)\n }\n weeks.push(row)\n }\n return weeks\n}\n\n/** The weeks a month actually touches: the rows of `monthGrid` holding at\n * least one of its days - four to six of them.\n *\n * The Calendar keeps six rows always, because a popup that changes height\n * moves under the pointer. A month filling a screen has the opposite problem:\n * a sixth row made entirely of the next month makes every month look six\n * weeks long, and takes a sixth of the height from the days that are in it. */\nexport function monthWeeks(month: IsoDate, locale: string): IsoDate[][] {\n const { year, month: monthNumber } = parts(month)\n return monthGrid(month, locale).filter((week) =>\n week.some((date) => {\n const day = parts(date)\n return day.year === year && day.month === monthNumber\n }),\n )\n}\n\n/** Where a calendar's cursor goes for a key, or `undefined` for a key that\n * does not move it. `weekStart` is the locale's first day, which is where\n * Home lands.\n *\n * One map for every grid of days in the set, so the Calendar and the month\n * view cannot come to disagree about what PageDown means. */\nexport function keyStep(key: string, from: IsoDate, weekStart: number): IsoDate | undefined {\n const intoWeek = (weekday(from) - weekStart + 7) % 7\n const steps: Record<string, () => IsoDate> = {\n ArrowRight: () => addDays(from, 1),\n ArrowLeft: () => addDays(from, -1),\n ArrowDown: () => addDays(from, 7),\n ArrowUp: () => addDays(from, -7),\n PageDown: () => addMonths(from, 1),\n PageUp: () => addMonths(from, -1),\n Home: () => addDays(from, -intoWeek),\n End: () => addDays(from, 6 - intoWeek),\n }\n return steps[key]?.()\n}\n\n/** The weekday initials, in the order this locale lays them out.\n *\n * The week below is midnight in UTC, so it is named in UTC. Named in the\n * reader's own zone, as it was until v0.34, every reader west of Greenwich saw\n * each midnight as the evening before: a Monday-first grid headed \"Sun Mon\n * Tue\", one column off from the days under it. The tests ran in UTC, where\n * the two agree, and passed. */\nexport function weekdayNames(locale: string, start: number): string[] {\n const names = new Intl.DateTimeFormat(locale, { weekday: 'short', timeZone: 'UTC' })\n // Any week works; this one begins on a Monday.\n const monday = Date.UTC(2024, 0, 1)\n return Array.from({ length: 7 }, (_, index) => {\n const offset = (start - 1 + index) % 7\n return names.format(new Date(monday + offset * 86_400_000))\n })\n}\n"
|
|
589
630
|
}
|
|
590
631
|
]
|
|
591
632
|
},
|
|
@@ -595,7 +636,7 @@
|
|
|
595
636
|
"title": "Calendar",
|
|
596
637
|
"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
638
|
"dependencies": [
|
|
598
|
-
"dowel-ui@^0.
|
|
639
|
+
"dowel-ui@^0.34.1"
|
|
599
640
|
],
|
|
600
641
|
"registryDependencies": [
|
|
601
642
|
"https://lacodda.github.io/dowel/r/calendar-math.json"
|
|
@@ -605,7 +646,7 @@
|
|
|
605
646
|
"path": "ui/calendar.tsx",
|
|
606
647
|
"target": "@ui/calendar.tsx",
|
|
607
648
|
"type": "registry:ui",
|
|
608
|
-
"content": "import { useMemo, useState, type KeyboardEvent } from 'react'\nimport { cn, useLocale } from 'dowel-ui'\nimport {\n
|
|
649
|
+
"content": "import { useMemo, useState, type KeyboardEvent } from 'react'\nimport { cn, useLocale } from 'dowel-ui'\nimport {\n addMonths,\n firstDayOfWeek,\n keyStep,\n monthGrid,\n parts,\n today,\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 next = keyStep(event.key, focused, start)\n if (next !== undefined) {\n event.preventDefault()\n moveFocus(next)\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
650
|
}
|
|
610
651
|
]
|
|
611
652
|
},
|
|
@@ -616,7 +657,7 @@
|
|
|
616
657
|
"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
658
|
"dependencies": [
|
|
618
659
|
"@base-ui/react",
|
|
619
|
-
"dowel-ui@^0.
|
|
660
|
+
"dowel-ui@^0.34.1"
|
|
620
661
|
],
|
|
621
662
|
"registryDependencies": [],
|
|
622
663
|
"files": [
|
|
@@ -636,7 +677,7 @@
|
|
|
636
677
|
"dependencies": [
|
|
637
678
|
"@base-ui/react",
|
|
638
679
|
"class-variance-authority",
|
|
639
|
-
"dowel-ui@^0.
|
|
680
|
+
"dowel-ui@^0.34.1"
|
|
640
681
|
],
|
|
641
682
|
"registryDependencies": [],
|
|
642
683
|
"files": [
|
|
@@ -655,7 +696,7 @@
|
|
|
655
696
|
"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.",
|
|
656
697
|
"dependencies": [
|
|
657
698
|
"class-variance-authority",
|
|
658
|
-
"dowel-ui@^0.
|
|
699
|
+
"dowel-ui@^0.34.1"
|
|
659
700
|
],
|
|
660
701
|
"registryDependencies": [
|
|
661
702
|
"https://lacodda.github.io/dowel/r/copy-button.json"
|
|
@@ -676,7 +717,7 @@
|
|
|
676
717
|
"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.",
|
|
677
718
|
"dependencies": [
|
|
678
719
|
"@base-ui/react",
|
|
679
|
-
"dowel-ui@^0.
|
|
720
|
+
"dowel-ui@^0.34.1"
|
|
680
721
|
],
|
|
681
722
|
"registryDependencies": [],
|
|
682
723
|
"files": [
|
|
@@ -694,7 +735,7 @@
|
|
|
694
735
|
"title": "ColorField",
|
|
695
736
|
"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.",
|
|
696
737
|
"dependencies": [
|
|
697
|
-
"dowel-ui@^0.
|
|
738
|
+
"dowel-ui@^0.34.1"
|
|
698
739
|
],
|
|
699
740
|
"registryDependencies": [
|
|
700
741
|
"https://lacodda.github.io/dowel/r/input.json"
|
|
@@ -714,7 +755,7 @@
|
|
|
714
755
|
"title": "ColumnResizeHandle",
|
|
715
756
|
"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.",
|
|
716
757
|
"dependencies": [
|
|
717
|
-
"dowel-ui@^0.
|
|
758
|
+
"dowel-ui@^0.34.1"
|
|
718
759
|
],
|
|
719
760
|
"registryDependencies": [],
|
|
720
761
|
"files": [
|
|
@@ -734,7 +775,7 @@
|
|
|
734
775
|
"dependencies": [
|
|
735
776
|
"@base-ui/react",
|
|
736
777
|
"class-variance-authority",
|
|
737
|
-
"dowel-ui@^0.
|
|
778
|
+
"dowel-ui@^0.34.1"
|
|
738
779
|
],
|
|
739
780
|
"registryDependencies": [
|
|
740
781
|
"https://lacodda.github.io/dowel/r/input.json",
|
|
@@ -758,7 +799,7 @@
|
|
|
758
799
|
"dependencies": [
|
|
759
800
|
"@base-ui/react",
|
|
760
801
|
"class-variance-authority",
|
|
761
|
-
"dowel-ui@^0.
|
|
802
|
+
"dowel-ui@^0.34.1"
|
|
762
803
|
],
|
|
763
804
|
"registryDependencies": [
|
|
764
805
|
"https://lacodda.github.io/dowel/r/combobox.json",
|
|
@@ -782,7 +823,7 @@
|
|
|
782
823
|
"dependencies": [
|
|
783
824
|
"@base-ui/react",
|
|
784
825
|
"class-variance-authority",
|
|
785
|
-
"dowel-ui@^0.
|
|
826
|
+
"dowel-ui@^0.34.1"
|
|
786
827
|
],
|
|
787
828
|
"registryDependencies": [
|
|
788
829
|
"https://lacodda.github.io/dowel/r/dialog.json",
|
|
@@ -804,7 +845,7 @@
|
|
|
804
845
|
"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>`.",
|
|
805
846
|
"dependencies": [
|
|
806
847
|
"@base-ui/react",
|
|
807
|
-
"dowel-ui@^0.
|
|
848
|
+
"dowel-ui@^0.34.1"
|
|
808
849
|
],
|
|
809
850
|
"registryDependencies": [
|
|
810
851
|
"https://lacodda.github.io/dowel/r/layer.json",
|
|
@@ -825,7 +866,7 @@
|
|
|
825
866
|
"title": "CopyButton",
|
|
826
867
|
"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.",
|
|
827
868
|
"dependencies": [
|
|
828
|
-
"dowel-ui@^0.
|
|
869
|
+
"dowel-ui@^0.34.1"
|
|
829
870
|
],
|
|
830
871
|
"registryDependencies": [],
|
|
831
872
|
"files": [
|
|
@@ -843,7 +884,7 @@
|
|
|
843
884
|
"title": "Copyable",
|
|
844
885
|
"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.",
|
|
845
886
|
"dependencies": [
|
|
846
|
-
"dowel-ui@^0.
|
|
887
|
+
"dowel-ui@^0.34.1"
|
|
847
888
|
],
|
|
848
889
|
"registryDependencies": [],
|
|
849
890
|
"files": [
|
|
@@ -861,7 +902,7 @@
|
|
|
861
902
|
"title": "DatePicker",
|
|
862
903
|
"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.",
|
|
863
904
|
"dependencies": [
|
|
864
|
-
"dowel-ui@^0.
|
|
905
|
+
"dowel-ui@^0.34.1"
|
|
865
906
|
],
|
|
866
907
|
"registryDependencies": [
|
|
867
908
|
"https://lacodda.github.io/dowel/r/calendar.json",
|
|
@@ -884,7 +925,7 @@
|
|
|
884
925
|
"title": "DateRangePicker",
|
|
885
926
|
"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.",
|
|
886
927
|
"dependencies": [
|
|
887
|
-
"dowel-ui@^0.
|
|
928
|
+
"dowel-ui@^0.34.1"
|
|
888
929
|
],
|
|
889
930
|
"registryDependencies": [
|
|
890
931
|
"https://lacodda.github.io/dowel/r/calendar.json",
|
|
@@ -909,7 +950,7 @@
|
|
|
909
950
|
"dependencies": [
|
|
910
951
|
"@base-ui/react",
|
|
911
952
|
"class-variance-authority",
|
|
912
|
-
"dowel-ui@^0.
|
|
953
|
+
"dowel-ui@^0.34.1"
|
|
913
954
|
],
|
|
914
955
|
"registryDependencies": [
|
|
915
956
|
"https://lacodda.github.io/dowel/r/layer.json"
|
|
@@ -935,7 +976,7 @@
|
|
|
935
976
|
"path": "ui/diff-lines.tsx",
|
|
936
977
|
"target": "@ui/diff-lines.tsx",
|
|
937
978
|
"type": "registry:ui",
|
|
938
|
-
"content": "/*\n * The comparison behind DiffView, with no React in it.\n *\n * Split out like `line-scale` and `table-sort`: a product that wants to know\n * how much moved between two drafts - to put a number in a list, to decide\n * whether to offer the comparison at all - should not have to render a\n * component to find out.\n *\n * Lines rather than words, and that is a choice about the subject. The text\n * being compared here is prose someone wrote and revised - a draft, a note, a\n * configuration file - and prose is revised BY THE LINE. A word-level diff of\n * a rewritten paragraph is confetti: technically accurate, and it answers a\n * question nobody asked. Where the subject really is word-level - a title, a\n * single sentence - a product compares the two strings itself.\n *\n * Plain longest-common-subsequence. The bodies are a page or two, so the exact\n * O(n·m) answer costs nothing and there is no reason to reach for a heuristic.\n *\n * Taken from kilna, which had it first, with the arithmetic unchanged and the\n * pairing added: `rows` is the part the donor did not have, and its absence is\n * what made the donor's two columns drift out of step.\n */\n\n/** One step through the comparison. */\nexport type Change =\n | { kind: 'same'; text: string }\n | { kind: 'added'; text: string }\n | { kind: 'removed'; text: string }\n\n/**\n * One row of a side-by-side comparison: what stands on each side of it.\n *\n * `null` is a side with nothing there - the gap opposite an inserted line -\n * and it is deliberately not an empty string. An empty string is a line\n * somebody wrote that happens to have no characters on it, and a comparison\n * that cannot tell those apart draws a blank line as a deletion.\n */\nexport interface DiffRow {\n before: string | null\n after: string | null\n kind: Change['kind']\n /** Line numbers in each text, 1-based, for a gutter. `null` on the side that\n * has nothing. */\n beforeLine: number | null\n afterLine: number | null\n}\n\n/** How many lines each side may have before the comparison gives up. A table\n * of 2000×2000 is four million cells; past that the honest answer is \"too long\n * to compare\", not a frozen window. */\nexport const LIMIT = 2000\n\nexport function diffLines(before: string, after: string): Change[] {\n const a = before.split('\\n')\n const b = after.split('\\n')\n\n if (a.length > LIMIT || b.length > LIMIT) {\n return [\n { kind: 'removed', text: before },\n { kind: 'added', text: after },\n ]\n }\n\n // lcs[i][j] - the length of the longest common subsequence of a[i..] and\n // b[j..]. Filled backwards so the walk forwards can be greedy.\n const lcs: number[][] = Array.from({ length: a.length + 1 }, () =>\n Array.from({ length: b.length + 1 }, () => 0),\n )\n\n for (let i = a.length - 1; i >= 0; i -= 1) {\n for (let j = b.length - 1; j >= 0; j -= 1) {\n lcs[i]![j] = a[i] === b[j] ? lcs[i + 1]![j + 1]! + 1 : Math.max(lcs[i + 1]![j]!, lcs[i]![j + 1]!)\n }\n }\n\n const changes: Change[] = []\n let i = 0\n let j = 0\n\n while (i < a.length && j < b.length) {\n if (a[i] === b[j]) {\n changes.push({ kind: 'same', text: a[i]! })\n i += 1\n j += 1\n } else if (lcs[i + 1]![j]! >= lcs[i]![j + 1]!) {\n changes.push({ kind: 'removed', text: a[i]! })\n i += 1\n } else {\n changes.push({ kind: 'added', text: b[j]! })\n j += 1\n }\n }\n\n while (i < a.length) {\n changes.push({ kind: 'removed', text: a[i]! })\n i += 1\n }\n while (j < b.length) {\n changes.push({ kind: 'added', text: b[j]! })\n j += 1\n }\n\n return changes\n}\n\n/**\n * The changes as rows of two columns - the thing that makes side-by-side mean\n * anything.\n *\n * The obvious way to draw two columns is to filter the change list twice: keep\n * everything that is not `added` on the left, everything that is not `removed`\n * on the right. Both columns come out individually correct and they stop\n * lining up at the first insertion, because from there on they hold different\n * numbers of rows. The reader then compares line 4 against line 3 for the rest\n * of the screen, and nothing about the drawing looks wrong - which is why the\n * defect survived in the product this was taken from.\n *\n * Pairing instead makes the alignment structural: a row is one object with two\n * sides, so the columns are the same height by construction rather than by two\n * filters happening to agree.\n *\n * A removal immediately followed by an insertion is paired into ONE row rather\n * than two. That is the common shape of an edit - a line was rewritten - and\n * showing the old and the new opposite each other is the whole point of the\n * comparison. Left as separate rows, a rewritten line reads as a deletion\n * followed by an unrelated addition, with a gap opposite each.\n */\nexport function rows(changes: readonly Change[]): DiffRow[] {\n const out: DiffRow[] = []\n let beforeLine = 1\n let afterLine = 1\n\n for (let at = 0; at < changes.length; at += 1) {\n const change = changes[at]!\n\n if (change.kind === 'same') {\n out.push({\n before: change.text,\n after: change.text,\n kind: 'same',\n beforeLine: beforeLine++,\n afterLine: afterLine++,\n })\n continue\n }\n\n if (change.kind === 'removed') {\n // A removal with an insertion right behind it is a rewrite: pair them,\n // so the reader sees what the line became rather than two separate\n // events with a gap opposite each.\n const next = changes[at + 1]\n if (next?.kind === 'added') {\n out.push({\n before: change.text,\n after: next.text,\n kind: 'removed',\n beforeLine: beforeLine++,\n afterLine: afterLine++,\n })\n at += 1\n continue\n }\n out.push({\n before: change.text,\n after: null,\n kind: 'removed',\n beforeLine: beforeLine++,\n afterLine: null,\n })\n continue\n }\n\n out.push({\n before: null,\n after: change.text,\n kind: 'added',\n beforeLine: null,\n afterLine: afterLine++,\n })\n }\n\n return out\n}\n\n/** How much moved, for the one line above a comparison - or for a list that\n * wants to say \"12 lines changed\" without drawing anything. */\nexport function countChanges(changes: readonly Change[]): { added: number; removed: number } {\n let added = 0\n let removed = 0\n for (const change of changes) {\n if (change.kind === 'added') added += 1\n else if (change.kind === 'removed') removed += 1\n }\n return { added, removed }\n}\n"
|
|
979
|
+
"content": "export {}\n// Code first, so the description below survives `shadcn add`: the CLI writes\n// a file from its first token on and drops every comment above it.\n\n/*\n * The comparison behind DiffView, with no React in it.\n *\n * Split out like `line-scale` and `table-sort`: a product that wants to know\n * how much moved between two drafts - to put a number in a list, to decide\n * whether to offer the comparison at all - should not have to render a\n * component to find out.\n *\n * Lines rather than words, and that is a choice about the subject. The text\n * being compared here is prose someone wrote and revised - a draft, a note, a\n * configuration file - and prose is revised BY THE LINE. A word-level diff of\n * a rewritten paragraph is confetti: technically accurate, and it answers a\n * question nobody asked. Where the subject really is word-level - a title, a\n * single sentence - a product compares the two strings itself.\n *\n * Plain longest-common-subsequence. The bodies are a page or two, so the exact\n * O(n·m) answer costs nothing and there is no reason to reach for a heuristic.\n *\n * Taken from kilna, which had it first, with the arithmetic unchanged and the\n * pairing added: `rows` is the part the donor did not have, and its absence is\n * what made the donor's two columns drift out of step.\n */\n\n/** One step through the comparison. */\nexport type Change =\n | { kind: 'same'; text: string }\n | { kind: 'added'; text: string }\n | { kind: 'removed'; text: string }\n\n/**\n * One row of a side-by-side comparison: what stands on each side of it.\n *\n * `null` is a side with nothing there - the gap opposite an inserted line -\n * and it is deliberately not an empty string. An empty string is a line\n * somebody wrote that happens to have no characters on it, and a comparison\n * that cannot tell those apart draws a blank line as a deletion.\n */\nexport interface DiffRow {\n before: string | null\n after: string | null\n kind: Change['kind']\n /** Line numbers in each text, 1-based, for a gutter. `null` on the side that\n * has nothing. */\n beforeLine: number | null\n afterLine: number | null\n}\n\n/** How many lines each side may have before the comparison gives up. A table\n * of 2000×2000 is four million cells; past that the honest answer is \"too long\n * to compare\", not a frozen window. */\nexport const LIMIT = 2000\n\nexport function diffLines(before: string, after: string): Change[] {\n const a = before.split('\\n')\n const b = after.split('\\n')\n\n if (a.length > LIMIT || b.length > LIMIT) {\n return [\n { kind: 'removed', text: before },\n { kind: 'added', text: after },\n ]\n }\n\n // lcs[i][j] - the length of the longest common subsequence of a[i..] and\n // b[j..]. Filled backwards so the walk forwards can be greedy.\n const lcs: number[][] = Array.from({ length: a.length + 1 }, () =>\n Array.from({ length: b.length + 1 }, () => 0),\n )\n\n for (let i = a.length - 1; i >= 0; i -= 1) {\n for (let j = b.length - 1; j >= 0; j -= 1) {\n lcs[i]![j] = a[i] === b[j] ? lcs[i + 1]![j + 1]! + 1 : Math.max(lcs[i + 1]![j]!, lcs[i]![j + 1]!)\n }\n }\n\n const changes: Change[] = []\n let i = 0\n let j = 0\n\n while (i < a.length && j < b.length) {\n if (a[i] === b[j]) {\n changes.push({ kind: 'same', text: a[i]! })\n i += 1\n j += 1\n } else if (lcs[i + 1]![j]! >= lcs[i]![j + 1]!) {\n changes.push({ kind: 'removed', text: a[i]! })\n i += 1\n } else {\n changes.push({ kind: 'added', text: b[j]! })\n j += 1\n }\n }\n\n while (i < a.length) {\n changes.push({ kind: 'removed', text: a[i]! })\n i += 1\n }\n while (j < b.length) {\n changes.push({ kind: 'added', text: b[j]! })\n j += 1\n }\n\n return changes\n}\n\n/**\n * The changes as rows of two columns - the thing that makes side-by-side mean\n * anything.\n *\n * The obvious way to draw two columns is to filter the change list twice: keep\n * everything that is not `added` on the left, everything that is not `removed`\n * on the right. Both columns come out individually correct and they stop\n * lining up at the first insertion, because from there on they hold different\n * numbers of rows. The reader then compares line 4 against line 3 for the rest\n * of the screen, and nothing about the drawing looks wrong - which is why the\n * defect survived in the product this was taken from.\n *\n * Pairing instead makes the alignment structural: a row is one object with two\n * sides, so the columns are the same height by construction rather than by two\n * filters happening to agree.\n *\n * A removal immediately followed by an insertion is paired into ONE row rather\n * than two. That is the common shape of an edit - a line was rewritten - and\n * showing the old and the new opposite each other is the whole point of the\n * comparison. Left as separate rows, a rewritten line reads as a deletion\n * followed by an unrelated addition, with a gap opposite each.\n */\nexport function rows(changes: readonly Change[]): DiffRow[] {\n const out: DiffRow[] = []\n let beforeLine = 1\n let afterLine = 1\n\n for (let at = 0; at < changes.length; at += 1) {\n const change = changes[at]!\n\n if (change.kind === 'same') {\n out.push({\n before: change.text,\n after: change.text,\n kind: 'same',\n beforeLine: beforeLine++,\n afterLine: afterLine++,\n })\n continue\n }\n\n if (change.kind === 'removed') {\n // A removal with an insertion right behind it is a rewrite: pair them,\n // so the reader sees what the line became rather than two separate\n // events with a gap opposite each.\n const next = changes[at + 1]\n if (next?.kind === 'added') {\n out.push({\n before: change.text,\n after: next.text,\n kind: 'removed',\n beforeLine: beforeLine++,\n afterLine: afterLine++,\n })\n at += 1\n continue\n }\n out.push({\n before: change.text,\n after: null,\n kind: 'removed',\n beforeLine: beforeLine++,\n afterLine: null,\n })\n continue\n }\n\n out.push({\n before: null,\n after: change.text,\n kind: 'added',\n beforeLine: null,\n afterLine: afterLine++,\n })\n }\n\n return out\n}\n\n/** How much moved, for the one line above a comparison - or for a list that\n * wants to say \"12 lines changed\" without drawing anything. */\nexport function countChanges(changes: readonly Change[]): { added: number; removed: number } {\n let added = 0\n let removed = 0\n for (const change of changes) {\n if (change.kind === 'added') added += 1\n else if (change.kind === 'removed') removed += 1\n }\n return { added, removed }\n}\n"
|
|
939
980
|
}
|
|
940
981
|
]
|
|
941
982
|
},
|
|
@@ -946,7 +987,7 @@
|
|
|
946
987
|
"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.",
|
|
947
988
|
"dependencies": [
|
|
948
989
|
"class-variance-authority",
|
|
949
|
-
"dowel-ui@^0.
|
|
990
|
+
"dowel-ui@^0.34.1"
|
|
950
991
|
],
|
|
951
992
|
"registryDependencies": [
|
|
952
993
|
"https://lacodda.github.io/dowel/r/copy-button.json",
|
|
@@ -968,7 +1009,7 @@
|
|
|
968
1009
|
"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.",
|
|
969
1010
|
"dependencies": [
|
|
970
1011
|
"class-variance-authority",
|
|
971
|
-
"dowel-ui@^0.
|
|
1012
|
+
"dowel-ui@^0.34.1"
|
|
972
1013
|
],
|
|
973
1014
|
"registryDependencies": [],
|
|
974
1015
|
"files": [
|
|
@@ -988,7 +1029,7 @@
|
|
|
988
1029
|
"dependencies": [
|
|
989
1030
|
"@base-ui/react",
|
|
990
1031
|
"class-variance-authority",
|
|
991
|
-
"dowel-ui@^0.
|
|
1032
|
+
"dowel-ui@^0.34.1"
|
|
992
1033
|
],
|
|
993
1034
|
"registryDependencies": [
|
|
994
1035
|
"https://lacodda.github.io/dowel/r/dialog.json",
|
|
@@ -1009,7 +1050,7 @@
|
|
|
1009
1050
|
"title": "DurationField",
|
|
1010
1051
|
"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.",
|
|
1011
1052
|
"dependencies": [
|
|
1012
|
-
"dowel-ui@^0.
|
|
1053
|
+
"dowel-ui@^0.34.1"
|
|
1013
1054
|
],
|
|
1014
1055
|
"registryDependencies": [
|
|
1015
1056
|
"https://lacodda.github.io/dowel/r/input.json"
|
|
@@ -1030,7 +1071,7 @@
|
|
|
1030
1071
|
"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.",
|
|
1031
1072
|
"dependencies": [
|
|
1032
1073
|
"class-variance-authority",
|
|
1033
|
-
"dowel-ui@^0.
|
|
1074
|
+
"dowel-ui@^0.34.1"
|
|
1034
1075
|
],
|
|
1035
1076
|
"registryDependencies": [],
|
|
1036
1077
|
"files": [
|
|
@@ -1084,7 +1125,7 @@
|
|
|
1084
1125
|
"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.",
|
|
1085
1126
|
"dependencies": [
|
|
1086
1127
|
"@base-ui/react",
|
|
1087
|
-
"dowel-ui@^0.
|
|
1128
|
+
"dowel-ui@^0.34.1"
|
|
1088
1129
|
],
|
|
1089
1130
|
"registryDependencies": [],
|
|
1090
1131
|
"files": [
|
|
@@ -1102,7 +1143,7 @@
|
|
|
1102
1143
|
"title": "FileDrop",
|
|
1103
1144
|
"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.",
|
|
1104
1145
|
"dependencies": [
|
|
1105
|
-
"dowel-ui@^0.
|
|
1146
|
+
"dowel-ui@^0.34.1"
|
|
1106
1147
|
],
|
|
1107
1148
|
"registryDependencies": [],
|
|
1108
1149
|
"files": [
|
|
@@ -1120,7 +1161,7 @@
|
|
|
1120
1161
|
"title": "FilterPopover",
|
|
1121
1162
|
"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.",
|
|
1122
1163
|
"dependencies": [
|
|
1123
|
-
"dowel-ui@^0.
|
|
1164
|
+
"dowel-ui@^0.34.1"
|
|
1124
1165
|
],
|
|
1125
1166
|
"registryDependencies": [
|
|
1126
1167
|
"https://lacodda.github.io/dowel/r/button.json",
|
|
@@ -1142,7 +1183,7 @@
|
|
|
1142
1183
|
"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`.",
|
|
1143
1184
|
"dependencies": [
|
|
1144
1185
|
"class-variance-authority",
|
|
1145
|
-
"dowel-ui@^0.
|
|
1186
|
+
"dowel-ui@^0.34.1"
|
|
1146
1187
|
],
|
|
1147
1188
|
"registryDependencies": [],
|
|
1148
1189
|
"files": [
|
|
@@ -1160,7 +1201,7 @@
|
|
|
1160
1201
|
"title": "InlineField",
|
|
1161
1202
|
"description": "A value that is edited where it is shown: click it, type, Enter or leave to keep, Escape to take it back.",
|
|
1162
1203
|
"dependencies": [
|
|
1163
|
-
"dowel-ui@^0.
|
|
1204
|
+
"dowel-ui@^0.34.1"
|
|
1164
1205
|
],
|
|
1165
1206
|
"registryDependencies": [
|
|
1166
1207
|
"https://lacodda.github.io/dowel/r/field-draft.json"
|
|
@@ -1180,7 +1221,7 @@
|
|
|
1180
1221
|
"title": "Input",
|
|
1181
1222
|
"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.",
|
|
1182
1223
|
"dependencies": [
|
|
1183
|
-
"dowel-ui@^0.
|
|
1224
|
+
"dowel-ui@^0.34.1"
|
|
1184
1225
|
],
|
|
1185
1226
|
"registryDependencies": [],
|
|
1186
1227
|
"files": [
|
|
@@ -1204,7 +1245,7 @@
|
|
|
1204
1245
|
"path": "ui/json-rows.tsx",
|
|
1205
1246
|
"target": "@ui/json-rows.tsx",
|
|
1206
1247
|
"type": "registry:ui",
|
|
1207
|
-
"content": "/*\n * What a JSON value looks like as a list of rows, with no React in it.\n *\n * The sibling of `tree-rows`, built the same way and for the same reason: a\n * product that wants to count the rows before drawing any of them - to put a\n * viewer inside a VirtualList, to say \"1,204 entries\" - imports this and never\n * the component.\n *\n * Flattening is also what makes the keyboard simple. Down is the next row of\n * this list and Up the previous, whatever the nesting; a recursive walk at\n * every keystroke asks the same question and answers it differently at each\n * depth.\n */\n\n/** What a value is, for drawing and for deciding whether it opens.\n *\n * `null` is its own kind rather than an absence: in JSON it is a value\n * somebody wrote, and a viewer that shows it as an empty cell says the key is\n * missing when it is present and null - a distinction that decides bugs. */\nexport type JsonKind = 'object' | 'array' | 'string' | 'number' | 'boolean' | 'null'\n\n/** Any value `JSON.parse` can return. */\nexport type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\nexport interface JsonRow {\n /**\n * Where this sits, in JSONPath: `$.items[3].name`.\n *\n * The identity of a row, and deliberately not its index: a row's index\n * changes when a branch above it opens, and anything remembered by index -\n * which rows are open, which is selected - would jump to a different value\n * the moment something above it moved. It is also what a reader copies when\n * they want to point at this value from somewhere else.\n */\n path: string\n /** The key, or the index as written in the path. `null` only for the root. */\n key: string | null\n /** True when the key is an array index rather than an object's name - drawn\n * differently, because `0` as a name and `0` as a position are not the same\n * thing. */\n index: boolean\n kind: JsonKind\n /** The value itself, for a leaf. A branch has none: what it holds is in the\n * rows below it. */\n value?: string | number | boolean | null\n depth: number\n /** How many entries a branch holds, so a closed one can say so without being\n * opened. */\n size?: number\n /** The path of the branch this row sits in, if any. What Left uses to get\n * out of a deep branch in one press. */\n parent?: string\n}\n\nexport function kindOf(value: JsonValue): JsonKind {\n if (value === null) return 'null'\n if (Array.isArray(value)) return 'array'\n return typeof value as JsonKind\n}\n\nexport function isBranch(kind: JsonKind): boolean {\n return kind === 'object' || kind === 'array'\n}\n\n/*\n * A key as it appears in a path.\n *\n * Dot notation where the key is an ordinary identifier, brackets otherwise -\n * which is not decoration. `$.user name` is not a path anything can resolve,\n * and a key containing a dot (`$.a.b` for the single key `\"a.b\"`) is a path\n * that resolves to the WRONG value silently. Both are common in real data:\n * configuration files and anything exported from a spreadsheet.\n */\nfunction step(key: string): string {\n return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(key)\n ? `.${key}`\n : `[${JSON.stringify(key)}]`\n}\n\n/**\n * The rows a value shows, in the order the eye and the keyboard travel.\n *\n * Only what is visible: a closed branch contributes its own row and nothing\n * below it. That is what keeps a viewer of a large document cheap - a\n * thousand-entry array that nobody opened costs one row.\n */\nexport function visibleRows(\n value: JsonValue,\n open: ReadonlySet<string>,\n { root = '$' }: { root?: string } = {},\n): JsonRow[] {\n const rows: JsonRow[] = []\n\n const walk = (\n node: JsonValue,\n path: string,\n key: string | null,\n index: boolean,\n depth: number,\n parent?: string,\n ): void => {\n const kind = kindOf(node)\n\n if (!isBranch(kind)) {\n rows.push({ path, key, index, kind, value: node as string | number | boolean | null, depth, parent })\n return\n }\n\n const entries: [string, JsonValue][] = Array.isArray(node)\n ? node.map((item, at) => [String(at), item])\n : Object.entries(node as { [key: string]: JsonValue })\n\n rows.push({ path, key, index, kind, depth, size: entries.length, parent })\n\n if (!open.has(path)) return\n\n for (const [childKey, child] of entries) {\n walk(\n child,\n Array.isArray(node) ? `${path}[${childKey}]` : `${path}${step(childKey)}`,\n childKey,\n Array.isArray(node),\n depth + 1,\n path,\n )\n }\n }\n\n walk(value, root, null, false, 0)\n return rows\n}\n\n/**\n * The paths to open so a document arrives readable.\n *\n * Bounded twice, by depth AND by size, and the second bound is the one that\n * does the work. A depth bound alone reads as sufficient and is not: the cost\n * of opening is measured in ROWS, while depth counts LEVELS, and those track\n * each other only while the branches are small - which is the case nobody\n * needed protecting from.\n *\n * Measured, not reasoned: a payload holding `assets: [1204 entries]` at its\n * top level rendered 1216 rows and 24,000 pixels of scroll on arrival under a\n * depth-2 bound, because the array sits AT depth 2. That is exactly the freeze\n * the bound exists to prevent, produced by the bound itself. The shape that\n * hurts is one huge branch near the surface, and a level count is blindest to\n * precisely that shape.\n *\n * So a branch opens when it is shallow enough AND holds fewer than `size`\n * entries. A branch left shut is not descended through either - what is inside\n * something the reader cannot see does not need deciding about.\n */\nexport function branchPaths(\n value: JsonValue,\n {\n root = '$',\n depth = 2,\n /** How many entries a branch may hold and still open by itself. Twenty is\n * about a screen: enough that a settings file arrives open, few enough\n * that a list of records arrives as a list of records. */\n size = 20,\n }: { root?: string; depth?: number; size?: number } = {},\n): Set<string> {\n const paths = new Set<string>()\n\n const walk = (node: JsonValue, path: string, level: number): void => {\n const kind = kindOf(node)\n if (!isBranch(kind) || level > depth) return\n\n const entries: [string, JsonValue][] = Array.isArray(node)\n ? node.map((item, at) => [String(at), item])\n : Object.entries(node as { [key: string]: JsonValue })\n\n // Too big to open, so it stays shut - and nothing below it is considered:\n // those rows are not going to be drawn either way.\n if (entries.length > size) return\n\n paths.add(path)\n\n for (const [key, child] of entries) {\n walk(child, Array.isArray(node) ? `${path}[${key}]` : `${path}${step(key)}`, level + 1)\n }\n }\n\n walk(value, root, 1)\n return paths\n}\n\n/**\n * What a closed branch says about itself: `{ 4 }` or `[ 1204 ]`.\n *\n * The count rather than a preview of the contents. A preview of the first\n * entries reads as though those are all of them, which is the one thing a\n * closed branch must not imply.\n */\nexport function summarise(row: JsonRow): string {\n if (row.kind === 'array') return `[ ${row.size ?? 0} ]`\n return `{ ${row.size ?? 0} }`\n}\n"
|
|
1248
|
+
"content": "export {}\n// Code first, so the description below survives `shadcn add`: the CLI writes\n// a file from its first token on and drops every comment above it.\n\n/*\n * What a JSON value looks like as a list of rows, with no React in it.\n *\n * The sibling of `tree-rows`, built the same way and for the same reason: a\n * product that wants to count the rows before drawing any of them - to put a\n * viewer inside a VirtualList, to say \"1,204 entries\" - imports this and never\n * the component.\n *\n * Flattening is also what makes the keyboard simple. Down is the next row of\n * this list and Up the previous, whatever the nesting; a recursive walk at\n * every keystroke asks the same question and answers it differently at each\n * depth.\n */\n\n/** What a value is, for drawing and for deciding whether it opens.\n *\n * `null` is its own kind rather than an absence: in JSON it is a value\n * somebody wrote, and a viewer that shows it as an empty cell says the key is\n * missing when it is present and null - a distinction that decides bugs. */\nexport type JsonKind = 'object' | 'array' | 'string' | 'number' | 'boolean' | 'null'\n\n/** Any value `JSON.parse` can return. */\nexport type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\nexport interface JsonRow {\n /**\n * Where this sits, in JSONPath: `$.items[3].name`.\n *\n * The identity of a row, and deliberately not its index: a row's index\n * changes when a branch above it opens, and anything remembered by index -\n * which rows are open, which is selected - would jump to a different value\n * the moment something above it moved. It is also what a reader copies when\n * they want to point at this value from somewhere else.\n */\n path: string\n /** The key, or the index as written in the path. `null` only for the root. */\n key: string | null\n /** True when the key is an array index rather than an object's name - drawn\n * differently, because `0` as a name and `0` as a position are not the same\n * thing. */\n index: boolean\n kind: JsonKind\n /** The value itself, for a leaf. A branch has none: what it holds is in the\n * rows below it. */\n value?: string | number | boolean | null\n depth: number\n /** How many entries a branch holds, so a closed one can say so without being\n * opened. */\n size?: number\n /** The path of the branch this row sits in, if any. What Left uses to get\n * out of a deep branch in one press. */\n parent?: string\n}\n\nexport function kindOf(value: JsonValue): JsonKind {\n if (value === null) return 'null'\n if (Array.isArray(value)) return 'array'\n return typeof value as JsonKind\n}\n\nexport function isBranch(kind: JsonKind): boolean {\n return kind === 'object' || kind === 'array'\n}\n\n/*\n * A key as it appears in a path.\n *\n * Dot notation where the key is an ordinary identifier, brackets otherwise -\n * which is not decoration. `$.user name` is not a path anything can resolve,\n * and a key containing a dot (`$.a.b` for the single key `\"a.b\"`) is a path\n * that resolves to the WRONG value silently. Both are common in real data:\n * configuration files and anything exported from a spreadsheet.\n */\nfunction step(key: string): string {\n return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(key)\n ? `.${key}`\n : `[${JSON.stringify(key)}]`\n}\n\n/**\n * The rows a value shows, in the order the eye and the keyboard travel.\n *\n * Only what is visible: a closed branch contributes its own row and nothing\n * below it. That is what keeps a viewer of a large document cheap - a\n * thousand-entry array that nobody opened costs one row.\n */\nexport function visibleRows(\n value: JsonValue,\n open: ReadonlySet<string>,\n { root = '$' }: { root?: string } = {},\n): JsonRow[] {\n const rows: JsonRow[] = []\n\n const walk = (\n node: JsonValue,\n path: string,\n key: string | null,\n index: boolean,\n depth: number,\n parent?: string,\n ): void => {\n const kind = kindOf(node)\n\n if (!isBranch(kind)) {\n rows.push({ path, key, index, kind, value: node as string | number | boolean | null, depth, parent })\n return\n }\n\n const entries: [string, JsonValue][] = Array.isArray(node)\n ? node.map((item, at) => [String(at), item])\n : Object.entries(node as { [key: string]: JsonValue })\n\n rows.push({ path, key, index, kind, depth, size: entries.length, parent })\n\n if (!open.has(path)) return\n\n for (const [childKey, child] of entries) {\n walk(\n child,\n Array.isArray(node) ? `${path}[${childKey}]` : `${path}${step(childKey)}`,\n childKey,\n Array.isArray(node),\n depth + 1,\n path,\n )\n }\n }\n\n walk(value, root, null, false, 0)\n return rows\n}\n\n/**\n * The paths to open so a document arrives readable.\n *\n * Bounded twice, by depth AND by size, and the second bound is the one that\n * does the work. A depth bound alone reads as sufficient and is not: the cost\n * of opening is measured in ROWS, while depth counts LEVELS, and those track\n * each other only while the branches are small - which is the case nobody\n * needed protecting from.\n *\n * Measured, not reasoned: a payload holding `assets: [1204 entries]` at its\n * top level rendered 1216 rows and 24,000 pixels of scroll on arrival under a\n * depth-2 bound, because the array sits AT depth 2. That is exactly the freeze\n * the bound exists to prevent, produced by the bound itself. The shape that\n * hurts is one huge branch near the surface, and a level count is blindest to\n * precisely that shape.\n *\n * So a branch opens when it is shallow enough AND holds fewer than `size`\n * entries. A branch left shut is not descended through either - what is inside\n * something the reader cannot see does not need deciding about.\n */\nexport function branchPaths(\n value: JsonValue,\n {\n root = '$',\n depth = 2,\n /** How many entries a branch may hold and still open by itself. Twenty is\n * about a screen: enough that a settings file arrives open, few enough\n * that a list of records arrives as a list of records. */\n size = 20,\n }: { root?: string; depth?: number; size?: number } = {},\n): Set<string> {\n const paths = new Set<string>()\n\n const walk = (node: JsonValue, path: string, level: number): void => {\n const kind = kindOf(node)\n if (!isBranch(kind) || level > depth) return\n\n const entries: [string, JsonValue][] = Array.isArray(node)\n ? node.map((item, at) => [String(at), item])\n : Object.entries(node as { [key: string]: JsonValue })\n\n // Too big to open, so it stays shut - and nothing below it is considered:\n // those rows are not going to be drawn either way.\n if (entries.length > size) return\n\n paths.add(path)\n\n for (const [key, child] of entries) {\n walk(child, Array.isArray(node) ? `${path}[${key}]` : `${path}${step(key)}`, level + 1)\n }\n }\n\n walk(value, root, 1)\n return paths\n}\n\n/**\n * What a closed branch says about itself: `{ 4 }` or `[ 1204 ]`.\n *\n * The count rather than a preview of the contents. A preview of the first\n * entries reads as though those are all of them, which is the one thing a\n * closed branch must not imply.\n */\nexport function summarise(row: JsonRow): string {\n if (row.kind === 'array') return `[ ${row.size ?? 0} ]`\n return `{ ${row.size ?? 0} }`\n}\n"
|
|
1208
1249
|
}
|
|
1209
1250
|
]
|
|
1210
1251
|
},
|
|
@@ -1214,7 +1255,7 @@
|
|
|
1214
1255
|
"title": "JsonViewer",
|
|
1215
1256
|
"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.",
|
|
1216
1257
|
"dependencies": [
|
|
1217
|
-
"dowel-ui@^0.
|
|
1258
|
+
"dowel-ui@^0.34.1"
|
|
1218
1259
|
],
|
|
1219
1260
|
"registryDependencies": [
|
|
1220
1261
|
"https://lacodda.github.io/dowel/r/json-rows.json"
|
|
@@ -1234,7 +1275,7 @@
|
|
|
1234
1275
|
"title": "Kbd",
|
|
1235
1276
|
"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.",
|
|
1236
1277
|
"dependencies": [
|
|
1237
|
-
"dowel-ui@^0.
|
|
1278
|
+
"dowel-ui@^0.34.1"
|
|
1238
1279
|
],
|
|
1239
1280
|
"registryDependencies": [],
|
|
1240
1281
|
"files": [
|
|
@@ -1253,7 +1294,7 @@
|
|
|
1253
1294
|
"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.",
|
|
1254
1295
|
"dependencies": [
|
|
1255
1296
|
"class-variance-authority",
|
|
1256
|
-
"dowel-ui@^0.
|
|
1297
|
+
"dowel-ui@^0.34.1"
|
|
1257
1298
|
],
|
|
1258
1299
|
"registryDependencies": [],
|
|
1259
1300
|
"files": [
|
|
@@ -1288,7 +1329,7 @@
|
|
|
1288
1329
|
"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.",
|
|
1289
1330
|
"dependencies": [
|
|
1290
1331
|
"class-variance-authority",
|
|
1291
|
-
"dowel-ui@^0.
|
|
1332
|
+
"dowel-ui@^0.34.1"
|
|
1292
1333
|
],
|
|
1293
1334
|
"registryDependencies": [
|
|
1294
1335
|
"https://lacodda.github.io/dowel/r/line-scale.json"
|
|
@@ -1314,7 +1355,7 @@
|
|
|
1314
1355
|
"path": "ui/line-scale.tsx",
|
|
1315
1356
|
"target": "@ui/line-scale.tsx",
|
|
1316
1357
|
"type": "registry:ui",
|
|
1317
|
-
"content": "/*\n * The arithmetic behind LineChart, with no React in it.\n *\n * Split out like `track-segments` and `activity-weeks`: a product labelling\n * its own points, or checking its own domain sums, should not import a\n * component to get at the numbers.\n *\n * Two things live here and neither is obvious. Turning a series with holes in\n * it into drawable runs, and choosing the ticks on an axis - which is a\n * question about what reads as a round number, not about dividing a range into\n * equal parts.\n */\n\n/** One reading. `value: null` is a measurement that was not taken - which is\n * not a reading of zero, and not a reason to move the ones after it. */\nexport interface Point {\n /** Position along the axis. Same units throughout: a timestamp, an index. */\n at: number\n value: number | null\n}\n\nexport interface Bounds {\n /** The value the left edge stands for. */\n from: number\n /** The right edge. */\n to: number\n /** The bottom of the plot. */\n min: number\n /** The top. */\n max: number\n}\n\n/** A point placed in the box, in percentages: x from the left, y from the top\n * (SVG's own direction, so a larger value has a smaller y). */\nexport interface Placed {\n x: number\n y: number\n at: number\n value: number\n}\n\n/**\n * The bounds a series is drawn against.\n *\n * The y range does NOT start at zero by default, and that is a deliberate\n * departure from the bar's rule. A bar's length *is* the quantity, so its\n * baseline has to be zero or the length lies. A line's job is the shape of a\n * change, and a balance moving between 4,900 and 5,100 flattens into a\n * horizontal rule on a zero-based axis - the very thing the reader opened the\n * chart to see. What keeps it honest is that the axis is labelled: the ticks\n * say where the bottom is, so nobody reads the floor as nothing.\n *\n * A caller who wants zero states it, and for a quantity that is a count rather\n * than a level - requests, errors - they should.\n */\nexport function boundsOf(points: Point[], stated: Partial<Bounds> = {}): Bounds | null {\n const drawn = points.filter((point): point is Point & { value: number } => point.value !== null)\n if (drawn.length === 0) return null\n\n const values = drawn.map((point) => point.value)\n const positions = points.map((point) => point.at)\n\n const from = stated.from ?? Math.min(...positions)\n const to = stated.to ?? Math.max(...positions)\n\n let min = stated.min ?? Math.min(...values)\n let max = stated.max ?? Math.max(...values)\n\n /* A flat series has no range to scale against, and dividing by it would put\n * every point at the same y - or at NaN. Given a band around the value, the\n * line sits in the middle of the plot and reads as what it is: unchanging. */\n if (!(max > min)) {\n const pad = Math.abs(max) > 0 ? Math.abs(max) * 0.1 : 1\n min = max - pad\n max = max + pad\n }\n\n return { from, to, min, max }\n}\n\n/**\n * The series as runs of consecutive readings.\n *\n * A hole breaks the line rather than being drawn through or closed up, and the\n * three options are worth naming because two of them lie:\n *\n * **Interpolating across** invents a reading nobody took - the one thing a\n * chart of measurements must never do.\n *\n * **Dropping the point** keeps the line whole and moves every later reading\n * to the left, so the axis stops matching the data: a chart that says the\n * value was 40 in March when it was 40 in April.\n *\n * **Breaking the line** leaves the gap visible, keeps every other point\n * where it belongs, and invents nothing. So that is what happens here.\n *\n * A run of one point is kept: it has no line, but it has a dot, and dropping\n * it would hide a reading that exists.\n */\nexport function runs(points: Point[], bounds: Bounds): Placed[][] {\n const span = bounds.to - bounds.from\n const height = bounds.max - bounds.min\n if (!(span > 0) || !(height > 0)) return []\n\n const out: Placed[][] = []\n let run: Placed[] = []\n\n for (const point of points) {\n if (point.value === null) {\n if (run.length > 0) out.push(run)\n run = []\n continue\n }\n\n const clamped = Math.min(Math.max(point.value, bounds.min), bounds.max)\n run.push({\n x: ((point.at - bounds.from) / span) * 100,\n // SVG's y grows downward, so the largest value sits at the top.\n y: ((bounds.max - clamped) / height) * 100,\n at: point.at,\n value: point.value,\n })\n }\n\n if (run.length > 0) out.push(run)\n return out\n}\n\n/** A run as an SVG path, in the same percentages.\n *\n * Straight segments rather than a curve: a spline through measured points\n * overshoots between them, inventing highs and lows that were never recorded -\n * the same lie as interpolating across a gap, drawn more prettily. */\nexport function pathOf(run: Placed[]): string {\n return run\n .map((point, index) => `${index === 0 ? 'M' : 'L'}${point.x.toFixed(2)} ${point.y.toFixed(2)}`)\n .join(' ')\n}\n\n/**\n * Ticks for the value axis, on round numbers.\n *\n * Not the range cut into equal parts: 4,900 to 5,100 in four gives 4,950 and\n * 5,050, which nobody reads as a landmark. A tick's job is to be recognised\n * instantly, so the step is the nearest 1, 2, 5 or 10 above what the range\n * needs, and the ticks are the multiples of it inside the range.\n *\n * Returns nothing when a step would not fit - a range too narrow for a round\n * number is better with no ticks than with invented ones.\n */\nexport function ticksFor(bounds: Bounds, wanted = 4): number[] {\n const height = bounds.max - bounds.min\n if (!(height > 0) || wanted < 1) return []\n\n const rough = height / wanted\n const magnitude = 10 ** Math.floor(Math.log10(rough))\n const step = [1, 2, 5, 10].map((n) => n * magnitude).find((candidate) => candidate >= rough)\n if (step === undefined) return []\n\n const ticks: number[] = []\n for (let tick = Math.ceil(bounds.min / step) * step; tick <= bounds.max; tick += step) {\n // Floating point leaves 0.30000000000000004 where 0.3 was meant; the step\n // is what decides how many decimals are real.\n const decimals = Math.max(0, -Math.floor(Math.log10(step)))\n ticks.push(Number(tick.toFixed(decimals)))\n }\n\n return ticks\n}\n\n/** Where a value sits in the plot, as a percentage from the top - for a tick's\n * rule, or a threshold drawn across the line. */\nexport function yOf(value: number, bounds: Bounds): number | null {\n const height = bounds.max - bounds.min\n if (!(height > 0)) return null\n if (value < bounds.min || value > bounds.max) return null\n return ((bounds.max - value) / height) * 100\n}\n"
|
|
1358
|
+
"content": "export {}\n// Code first, so the description below survives `shadcn add`: the CLI writes\n// a file from its first token on and drops every comment above it.\n\n/*\n * The arithmetic behind LineChart, with no React in it.\n *\n * Split out like `track-segments` and `activity-weeks`: a product labelling\n * its own points, or checking its own domain sums, should not import a\n * component to get at the numbers.\n *\n * Two things live here and neither is obvious. Turning a series with holes in\n * it into drawable runs, and choosing the ticks on an axis - which is a\n * question about what reads as a round number, not about dividing a range into\n * equal parts.\n */\n\n/** One reading. `value: null` is a measurement that was not taken - which is\n * not a reading of zero, and not a reason to move the ones after it. */\nexport interface Point {\n /** Position along the axis. Same units throughout: a timestamp, an index. */\n at: number\n value: number | null\n}\n\nexport interface Bounds {\n /** The value the left edge stands for. */\n from: number\n /** The right edge. */\n to: number\n /** The bottom of the plot. */\n min: number\n /** The top. */\n max: number\n}\n\n/** A point placed in the box, in percentages: x from the left, y from the top\n * (SVG's own direction, so a larger value has a smaller y). */\nexport interface Placed {\n x: number\n y: number\n at: number\n value: number\n}\n\n/**\n * The bounds a series is drawn against.\n *\n * The y range does NOT start at zero by default, and that is a deliberate\n * departure from the bar's rule. A bar's length *is* the quantity, so its\n * baseline has to be zero or the length lies. A line's job is the shape of a\n * change, and a balance moving between 4,900 and 5,100 flattens into a\n * horizontal rule on a zero-based axis - the very thing the reader opened the\n * chart to see. What keeps it honest is that the axis is labelled: the ticks\n * say where the bottom is, so nobody reads the floor as nothing.\n *\n * A caller who wants zero states it, and for a quantity that is a count rather\n * than a level - requests, errors - they should.\n */\nexport function boundsOf(points: Point[], stated: Partial<Bounds> = {}): Bounds | null {\n const drawn = points.filter((point): point is Point & { value: number } => point.value !== null)\n if (drawn.length === 0) return null\n\n const values = drawn.map((point) => point.value)\n const positions = points.map((point) => point.at)\n\n const from = stated.from ?? Math.min(...positions)\n const to = stated.to ?? Math.max(...positions)\n\n let min = stated.min ?? Math.min(...values)\n let max = stated.max ?? Math.max(...values)\n\n /* A flat series has no range to scale against, and dividing by it would put\n * every point at the same y - or at NaN. Given a band around the value, the\n * line sits in the middle of the plot and reads as what it is: unchanging. */\n if (!(max > min)) {\n const pad = Math.abs(max) > 0 ? Math.abs(max) * 0.1 : 1\n min = max - pad\n max = max + pad\n }\n\n return { from, to, min, max }\n}\n\n/**\n * The series as runs of consecutive readings.\n *\n * A hole breaks the line rather than being drawn through or closed up, and the\n * three options are worth naming because two of them lie:\n *\n * **Interpolating across** invents a reading nobody took - the one thing a\n * chart of measurements must never do.\n *\n * **Dropping the point** keeps the line whole and moves every later reading\n * to the left, so the axis stops matching the data: a chart that says the\n * value was 40 in March when it was 40 in April.\n *\n * **Breaking the line** leaves the gap visible, keeps every other point\n * where it belongs, and invents nothing. So that is what happens here.\n *\n * A run of one point is kept: it has no line, but it has a dot, and dropping\n * it would hide a reading that exists.\n */\nexport function runs(points: Point[], bounds: Bounds): Placed[][] {\n const span = bounds.to - bounds.from\n const height = bounds.max - bounds.min\n if (!(span > 0) || !(height > 0)) return []\n\n const out: Placed[][] = []\n let run: Placed[] = []\n\n for (const point of points) {\n if (point.value === null) {\n if (run.length > 0) out.push(run)\n run = []\n continue\n }\n\n const clamped = Math.min(Math.max(point.value, bounds.min), bounds.max)\n run.push({\n x: ((point.at - bounds.from) / span) * 100,\n // SVG's y grows downward, so the largest value sits at the top.\n y: ((bounds.max - clamped) / height) * 100,\n at: point.at,\n value: point.value,\n })\n }\n\n if (run.length > 0) out.push(run)\n return out\n}\n\n/** A run as an SVG path, in the same percentages.\n *\n * Straight segments rather than a curve: a spline through measured points\n * overshoots between them, inventing highs and lows that were never recorded -\n * the same lie as interpolating across a gap, drawn more prettily. */\nexport function pathOf(run: Placed[]): string {\n return run\n .map((point, index) => `${index === 0 ? 'M' : 'L'}${point.x.toFixed(2)} ${point.y.toFixed(2)}`)\n .join(' ')\n}\n\n/**\n * Ticks for the value axis, on round numbers.\n *\n * Not the range cut into equal parts: 4,900 to 5,100 in four gives 4,950 and\n * 5,050, which nobody reads as a landmark. A tick's job is to be recognised\n * instantly, so the step is the nearest 1, 2, 5 or 10 above what the range\n * needs, and the ticks are the multiples of it inside the range.\n *\n * Returns nothing when a step would not fit - a range too narrow for a round\n * number is better with no ticks than with invented ones.\n */\nexport function ticksFor(bounds: Bounds, wanted = 4): number[] {\n const height = bounds.max - bounds.min\n if (!(height > 0) || wanted < 1) return []\n\n const rough = height / wanted\n const magnitude = 10 ** Math.floor(Math.log10(rough))\n const step = [1, 2, 5, 10].map((n) => n * magnitude).find((candidate) => candidate >= rough)\n if (step === undefined) return []\n\n const ticks: number[] = []\n for (let tick = Math.ceil(bounds.min / step) * step; tick <= bounds.max; tick += step) {\n // Floating point leaves 0.30000000000000004 where 0.3 was meant; the step\n // is what decides how many decimals are real.\n const decimals = Math.max(0, -Math.floor(Math.log10(step)))\n ticks.push(Number(tick.toFixed(decimals)))\n }\n\n return ticks\n}\n\n/** Where a value sits in the plot, as a percentage from the top - for a tick's\n * rule, or a threshold drawn across the line. */\nexport function yOf(value: number, bounds: Bounds): number | null {\n const height = bounds.max - bounds.min\n if (!(height > 0)) return null\n if (value < bounds.min || value > bounds.max) return null\n return ((bounds.max - value) / height) * 100\n}\n"
|
|
1318
1359
|
}
|
|
1319
1360
|
]
|
|
1320
1361
|
},
|
|
@@ -1325,7 +1366,7 @@
|
|
|
1325
1366
|
"description": "A row of a list, one thing said in a line: ListRow for a fact, RowButton for a choice.",
|
|
1326
1367
|
"dependencies": [
|
|
1327
1368
|
"@base-ui/react",
|
|
1328
|
-
"dowel-ui@^0.
|
|
1369
|
+
"dowel-ui@^0.34.1"
|
|
1329
1370
|
],
|
|
1330
1371
|
"registryDependencies": [],
|
|
1331
1372
|
"files": [
|
|
@@ -1343,7 +1384,7 @@
|
|
|
1343
1384
|
"title": "MarkedText",
|
|
1344
1385
|
"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.",
|
|
1345
1386
|
"dependencies": [
|
|
1346
|
-
"dowel-ui@^0.
|
|
1387
|
+
"dowel-ui@^0.34.1"
|
|
1347
1388
|
],
|
|
1348
1389
|
"registryDependencies": [],
|
|
1349
1390
|
"files": [
|
|
@@ -1363,7 +1404,7 @@
|
|
|
1363
1404
|
"dependencies": [
|
|
1364
1405
|
"@base-ui/react",
|
|
1365
1406
|
"class-variance-authority",
|
|
1366
|
-
"dowel-ui@^0.
|
|
1407
|
+
"dowel-ui@^0.34.1"
|
|
1367
1408
|
],
|
|
1368
1409
|
"registryDependencies": [
|
|
1369
1410
|
"https://lacodda.github.io/dowel/r/layer.json"
|
|
@@ -1385,7 +1426,7 @@
|
|
|
1385
1426
|
"dependencies": [
|
|
1386
1427
|
"@base-ui/react",
|
|
1387
1428
|
"class-variance-authority",
|
|
1388
|
-
"dowel-ui@^0.
|
|
1429
|
+
"dowel-ui@^0.34.1"
|
|
1389
1430
|
],
|
|
1390
1431
|
"registryDependencies": [
|
|
1391
1432
|
"https://lacodda.github.io/dowel/r/tooltip.json"
|
|
@@ -1405,7 +1446,7 @@
|
|
|
1405
1446
|
"title": "NotificationBell",
|
|
1406
1447
|
"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.",
|
|
1407
1448
|
"dependencies": [
|
|
1408
|
-
"dowel-ui@^0.
|
|
1449
|
+
"dowel-ui@^0.34.1"
|
|
1409
1450
|
],
|
|
1410
1451
|
"registryDependencies": [
|
|
1411
1452
|
"https://lacodda.github.io/dowel/r/button.json",
|
|
@@ -1427,7 +1468,7 @@
|
|
|
1427
1468
|
"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.",
|
|
1428
1469
|
"dependencies": [
|
|
1429
1470
|
"@base-ui/react",
|
|
1430
|
-
"dowel-ui@^0.
|
|
1471
|
+
"dowel-ui@^0.34.1"
|
|
1431
1472
|
],
|
|
1432
1473
|
"registryDependencies": [
|
|
1433
1474
|
"https://lacodda.github.io/dowel/r/input.json"
|
|
@@ -1447,7 +1488,7 @@
|
|
|
1447
1488
|
"title": "NumberFormat",
|
|
1448
1489
|
"description": "Two things, and the second is the reason this is a component rather than a call to `toLocaleString` at each site.",
|
|
1449
1490
|
"dependencies": [
|
|
1450
|
-
"dowel-ui@^0.
|
|
1491
|
+
"dowel-ui@^0.34.1"
|
|
1451
1492
|
],
|
|
1452
1493
|
"registryDependencies": [],
|
|
1453
1494
|
"files": [
|
|
@@ -1466,7 +1507,7 @@
|
|
|
1466
1507
|
"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.",
|
|
1467
1508
|
"dependencies": [
|
|
1468
1509
|
"class-variance-authority",
|
|
1469
|
-
"dowel-ui@^0.
|
|
1510
|
+
"dowel-ui@^0.34.1"
|
|
1470
1511
|
],
|
|
1471
1512
|
"registryDependencies": [],
|
|
1472
1513
|
"files": [
|
|
@@ -1484,7 +1525,7 @@
|
|
|
1484
1525
|
"title": "PageSize",
|
|
1485
1526
|
"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.",
|
|
1486
1527
|
"dependencies": [
|
|
1487
|
-
"dowel-ui@^0.
|
|
1528
|
+
"dowel-ui@^0.34.1"
|
|
1488
1529
|
],
|
|
1489
1530
|
"registryDependencies": [
|
|
1490
1531
|
"https://lacodda.github.io/dowel/r/select.json"
|
|
@@ -1504,7 +1545,7 @@
|
|
|
1504
1545
|
"title": "Pagination",
|
|
1505
1546
|
"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.",
|
|
1506
1547
|
"dependencies": [
|
|
1507
|
-
"dowel-ui@^0.
|
|
1548
|
+
"dowel-ui@^0.34.1"
|
|
1508
1549
|
],
|
|
1509
1550
|
"registryDependencies": [
|
|
1510
1551
|
"https://lacodda.github.io/dowel/r/button.json"
|
|
@@ -1525,7 +1566,7 @@
|
|
|
1525
1566
|
"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.",
|
|
1526
1567
|
"dependencies": [
|
|
1527
1568
|
"class-variance-authority",
|
|
1528
|
-
"dowel-ui@^0.
|
|
1569
|
+
"dowel-ui@^0.34.1"
|
|
1529
1570
|
],
|
|
1530
1571
|
"registryDependencies": [],
|
|
1531
1572
|
"files": [
|
|
@@ -1543,7 +1584,7 @@
|
|
|
1543
1584
|
"title": "PasswordField",
|
|
1544
1585
|
"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.",
|
|
1545
1586
|
"dependencies": [
|
|
1546
|
-
"dowel-ui@^0.
|
|
1587
|
+
"dowel-ui@^0.34.1"
|
|
1547
1588
|
],
|
|
1548
1589
|
"registryDependencies": [
|
|
1549
1590
|
"https://lacodda.github.io/dowel/r/input.json"
|
|
@@ -1565,7 +1606,7 @@
|
|
|
1565
1606
|
"dependencies": [
|
|
1566
1607
|
"@base-ui/react",
|
|
1567
1608
|
"class-variance-authority",
|
|
1568
|
-
"dowel-ui@^0.
|
|
1609
|
+
"dowel-ui@^0.34.1"
|
|
1569
1610
|
],
|
|
1570
1611
|
"registryDependencies": [
|
|
1571
1612
|
"https://lacodda.github.io/dowel/r/layer.json"
|
|
@@ -1587,7 +1628,7 @@
|
|
|
1587
1628
|
"dependencies": [
|
|
1588
1629
|
"@base-ui/react",
|
|
1589
1630
|
"class-variance-authority",
|
|
1590
|
-
"dowel-ui@^0.
|
|
1631
|
+
"dowel-ui@^0.34.1"
|
|
1591
1632
|
],
|
|
1592
1633
|
"registryDependencies": [
|
|
1593
1634
|
"https://lacodda.github.io/dowel/r/layer.json"
|
|
@@ -1607,7 +1648,7 @@
|
|
|
1607
1648
|
"title": "ProductMark",
|
|
1608
1649
|
"description": "The product-mark primitive.",
|
|
1609
1650
|
"dependencies": [
|
|
1610
|
-
"dowel-ui@^0.
|
|
1651
|
+
"dowel-ui@^0.34.1"
|
|
1611
1652
|
],
|
|
1612
1653
|
"registryDependencies": [],
|
|
1613
1654
|
"files": [
|
|
@@ -1625,7 +1666,7 @@
|
|
|
1625
1666
|
"title": "ProductSwitcher",
|
|
1626
1667
|
"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.",
|
|
1627
1668
|
"dependencies": [
|
|
1628
|
-
"dowel-ui@^0.
|
|
1669
|
+
"dowel-ui@^0.34.1"
|
|
1629
1670
|
],
|
|
1630
1671
|
"registryDependencies": [
|
|
1631
1672
|
"https://lacodda.github.io/dowel/r/menu.json",
|
|
@@ -1648,7 +1689,7 @@
|
|
|
1648
1689
|
"dependencies": [
|
|
1649
1690
|
"@base-ui/react",
|
|
1650
1691
|
"class-variance-authority",
|
|
1651
|
-
"dowel-ui@^0.
|
|
1692
|
+
"dowel-ui@^0.34.1"
|
|
1652
1693
|
],
|
|
1653
1694
|
"registryDependencies": [],
|
|
1654
1695
|
"files": [
|
|
@@ -1688,7 +1729,7 @@
|
|
|
1688
1729
|
"dependencies": [
|
|
1689
1730
|
"@base-ui/react",
|
|
1690
1731
|
"class-variance-authority",
|
|
1691
|
-
"dowel-ui@^0.
|
|
1732
|
+
"dowel-ui@^0.34.1"
|
|
1692
1733
|
],
|
|
1693
1734
|
"registryDependencies": [],
|
|
1694
1735
|
"files": [
|
|
@@ -1706,7 +1747,7 @@
|
|
|
1706
1747
|
"title": "RatingScale",
|
|
1707
1748
|
"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\".",
|
|
1708
1749
|
"dependencies": [
|
|
1709
|
-
"dowel-ui@^0.
|
|
1750
|
+
"dowel-ui@^0.34.1"
|
|
1710
1751
|
],
|
|
1711
1752
|
"registryDependencies": [],
|
|
1712
1753
|
"files": [
|
|
@@ -1724,7 +1765,7 @@
|
|
|
1724
1765
|
"title": "RelativeTime",
|
|
1725
1766
|
"description": "The relative-time primitive.",
|
|
1726
1767
|
"dependencies": [
|
|
1727
|
-
"dowel-ui@^0.
|
|
1768
|
+
"dowel-ui@^0.34.1"
|
|
1728
1769
|
],
|
|
1729
1770
|
"registryDependencies": [],
|
|
1730
1771
|
"files": [
|
|
@@ -1742,7 +1783,7 @@
|
|
|
1742
1783
|
"title": "ReorderableList",
|
|
1743
1784
|
"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.",
|
|
1744
1785
|
"dependencies": [
|
|
1745
|
-
"dowel-ui@^0.
|
|
1786
|
+
"dowel-ui@^0.34.1"
|
|
1746
1787
|
],
|
|
1747
1788
|
"registryDependencies": [],
|
|
1748
1789
|
"files": [
|
|
@@ -1760,7 +1801,7 @@
|
|
|
1760
1801
|
"title": "SaveState",
|
|
1761
1802
|
"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.",
|
|
1762
1803
|
"dependencies": [
|
|
1763
|
-
"dowel-ui@^0.
|
|
1804
|
+
"dowel-ui@^0.34.1"
|
|
1764
1805
|
],
|
|
1765
1806
|
"registryDependencies": [
|
|
1766
1807
|
"https://lacodda.github.io/dowel/r/spinner.json"
|
|
@@ -1782,7 +1823,7 @@
|
|
|
1782
1823
|
"dependencies": [
|
|
1783
1824
|
"@base-ui/react",
|
|
1784
1825
|
"class-variance-authority",
|
|
1785
|
-
"dowel-ui@^0.
|
|
1826
|
+
"dowel-ui@^0.34.1"
|
|
1786
1827
|
],
|
|
1787
1828
|
"registryDependencies": [],
|
|
1788
1829
|
"files": [
|
|
@@ -1790,7 +1831,7 @@
|
|
|
1790
1831
|
"path": "ui/scroll-area.tsx",
|
|
1791
1832
|
"target": "@ui/scroll-area.tsx",
|
|
1792
1833
|
"type": "registry:ui",
|
|
1793
|
-
"content": "import type { ReactNode, Ref } from 'react'\nimport { ScrollArea as Base } from '@base-ui/react/scroll-area'\nimport { cva } from 'class-variance-authority'\nimport { cn } from 'dowel-ui'\n\n/*\n * A box that scrolls, with the line's scrollbar drawn over its content.\n *\n * The line has one rule for scrollbars: a thin bar laid over the content,\n * never a gutter that takes width. A gutter is a column of the layout that\n * comes and goes with the length of the content, so a list that grows past\n * the fold pushes everything beside it sideways by the width of a bar - and\n * on a platform that draws a classic bar, the product wears someone else's\n * chrome in the middle of its own screen. The theme's global scrollbar rules\n * make native bars thin, but thin is still a gutter wherever the platform\n * draws one; only a bar that is not the browser's can promise to take no room.\n *\n * So the viewport hides its native bars and Base UI draws the thumb, sized and\n * placed from the scroll position. The bar is invisible at rest and appears\n * while the pointer is over the area or while it scrolls, because a bar that\n * is always there is a line across the content that nobody asked for. The\n * track is wider than the thumb and the thumb thickens under the pointer: a\n * four-pixel thumb reads as a hint and is hopeless as a grip, and the grip is\n * what somebody reaching for it wants. Both axes are handled, and Base UI\n * mounts a bar only for an axis that actually overflows.\n *\n * The keyboard is the part a hand-drawn scrollbar usually loses. A box that\n * scrolls must be reachable, or a reader without a pointer cannot see what is\n * below the fold (axe's `scrollable-region-focusable`), so Base UI makes the\n * viewport a tab stop exactly when it overflows. A tab stop has to be called\n * something, and the viewport is then a named group - which is why `label` is\n * required: whether the content will overflow is not known when the product\n * is written, and a name added only for the long case is a name forgotten for\n * it.\n *\n * `fade` softens the edges where there is more to see. It reads Base UI's\n * overflow distances, so an edge that is already at its end is left sharp -\n * a fade on both ends of a list scrolled to the top would say there is\n * something above when there is not.\n */\n\nexport const scrollAreaViewportVariants = cva(\n cn(\n 'size-full rounded-[inherit] outline-none',\n 'focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-accent',\n ),\n {\n variants: {\n /* A mask, not an overlay gradient: a gradient would have to be painted\n * in the colour of whatever is behind the area, which the component\n * cannot know. A mask fades the content itself, over any ground. Each\n * edge fades by at most `--fade`, and by less while the content is\n * nearer its end than that - so the fade arrives as scrolling starts\n * rather than switching on. */\n fade: {\n true: cn(\n '[--fade:calc(var(--spacing)*6)] [mask-composite:intersect]',\n '[mask-image:linear-gradient(to_bottom,transparent,var(--color-black)_min(var(--fade),var(--scroll-area-overflow-y-start,0px)),var(--color-black)_calc(100%_-_min(var(--fade),var(--scroll-area-overflow-y-end,0px))),transparent),linear-gradient(to_right,transparent,var(--color-black)_min(var(--fade),var(--scroll-area-overflow-x-start,0px)),var(--color-black)_calc(100%_-_min(var(--fade),var(--scroll-area-overflow-x-end,0px))),transparent)]',\n ),\n false: '',\n },\n },\n defaultVariants: { fade: false },\n },\n)\n\n/* The track is the grab zone and the thumb sits at its outer edge. Hidden at\n * rest; shown while the pointer is over the area or it scrolls, and held\n * while the thumb is being dragged, since the pointer may leave the area\n * mid-drag. */\nconst scrollbar = cn(\n 'group/bar flex p-0.5 opacity-0 [transition:opacity_var(--duration-slow)_var(--ease-out)]',\n 'data-[hovering]:opacity-100 data-[scrolling]:opacity-100 active:opacity-100',\n 'data-[orientation=vertical]:w-2.5 data-[orientation=vertical]:justify-end',\n 'data-[orientation=horizontal]:h-2.5 data-[orientation=horizontal]:flex-col data-[orientation=horizontal]:justify-end',\n)\n\nconst thumb = cn(\n 'rounded-full bg-line-2 group-hover/bar:bg-dim',\n '[transition:width_var(--duration-quick)_var(--ease-out),height_var(--duration-quick)_var(--ease-out),background-color_var(--duration-quick)_var(--ease-out)]',\n 'data-[orientation=vertical]:w-1 group-hover/bar:data-[orientation=vertical]:w-full',\n 'data-[orientation=horizontal]:h-1 group-hover/bar:data-[orientation=horizontal]:h-full',\n)\n\nexport interface ScrollAreaProps {\n /** Names the viewport, which becomes a tab stop when its content overflows:\n * \"Recent activity\", \"Diff of main.rs\". No default - it is the product's\n * word. */\n label: string\n /** Fade the edges that have more content past them. */\n fade?: boolean\n /** The scrolling element itself, for a product that sets or reads its\n * position - a virtual list, a scroll-to-latest. */\n viewportRef?: Ref<HTMLDivElement>\n /** On the root: its size is the area's size, so the height goes here. */\n className?: string\n viewportClassName?: string\n children: ReactNode\n}\n\nexport function ScrollArea({\n label,\n fade = false,\n viewportRef,\n className,\n viewportClassName,\n children,\n}: ScrollAreaProps) {\n return (\n <Base.Root className={cn('relative min-h-0 min-w-0 overflow-hidden', className)}>\n <Base.Viewport\n ref={viewportRef}\n // Base UI gives the viewport `presentation`, which is right for a box\n // nobody can focus and wrong for one that is a tab stop: the role is\n // dropped the moment it is focusable, and the name goes with it.\n role=\"group\"\n aria-label={label}\n className={cn(scrollAreaViewportVariants({ fade }), viewportClassName)}\n >\n {/* The content box is what the horizontal overflow is measured on:\n * without it a wide child is squeezed to the viewport's width and\n * nothing ever overflows sideways. */}\n <Base.Content>{children}</Base.Content>\n </Base.Viewport>\n <Base.Scrollbar orientation=\"vertical\" className={scrollbar}>\n
|
|
1834
|
+
"content": "import type { CSSProperties, ReactNode, Ref } from 'react'\nimport { ScrollArea as Base } from '@base-ui/react/scroll-area'\nimport { cva } from 'class-variance-authority'\nimport { cn } from 'dowel-ui'\n\n/*\n * A box that scrolls, with the line's scrollbar drawn over its content.\n *\n * The line has one rule for scrollbars: a thin bar laid over the content,\n * never a gutter that takes width. A gutter is a column of the layout that\n * comes and goes with the length of the content, so a list that grows past\n * the fold pushes everything beside it sideways by the width of a bar - and\n * on a platform that draws a classic bar, the product wears someone else's\n * chrome in the middle of its own screen. The theme's global scrollbar rules\n * make native bars thin, but thin is still a gutter wherever the platform\n * draws one; only a bar that is not the browser's can promise to take no room.\n *\n * So the viewport hides its native bars and Base UI draws the thumb, sized and\n * placed from the scroll position. The bar is invisible at rest and appears\n * while the pointer is over the area or while it scrolls, because a bar that\n * is always there is a line across the content that nobody asked for. The\n * track is wider than the thumb and the thumb thickens under the pointer: a\n * four-pixel thumb reads as a hint and is hopeless as a grip, and the grip is\n * what somebody reaching for it wants. Both axes are handled, and Base UI\n * mounts a bar only for an axis that actually overflows.\n *\n * The keyboard is the part a hand-drawn scrollbar usually loses. A box that\n * scrolls must be reachable, or a reader without a pointer cannot see what is\n * below the fold (axe's `scrollable-region-focusable`), so Base UI makes the\n * viewport a tab stop exactly when it overflows. A tab stop has to be called\n * something, and the viewport is then a named group - which is why `label` is\n * required: whether the content will overflow is not known when the product\n * is written, and a name added only for the long case is a name forgotten for\n * it.\n *\n * `fade` softens the edges where there is more to see. It reads Base UI's\n * overflow distances, so an edge that is already at its end is left sharp -\n * a fade on both ends of a list scrolled to the top would say there is\n * something above when there is not.\n *\n * `orientation` says which way the content may move. Base UI sizes its content\n * box to `fit-content`, which is right for a table that scrolls sideways and\n * wrong for a list: a row that should truncate to the column instead widens\n * the box, and the list scrolls sideways by the length of its longest title.\n * `vertical` holds the content to the viewport's width; `horizontal` holds it\n * to the viewport's height.\n */\n\ntype Orientation = NonNullable<ScrollAreaProps['orientation']>\n\n/* Inline, because the style Base UI writes is inline too - `min-width:\n * fit-content` on the content and `overflow: scroll` on the viewport - and a\n * class loses to it. */\nconst contentStyle: Record<Orientation, CSSProperties | undefined> = {\n both: undefined,\n vertical: { minWidth: 0 },\n horizontal: undefined,\n}\nconst viewportStyle: Record<Orientation, CSSProperties | undefined> = {\n both: undefined,\n vertical: { overflowX: 'hidden' },\n horizontal: { overflowY: 'hidden' },\n}\n\nexport const scrollAreaViewportVariants = cva(\n cn(\n 'size-full rounded-[inherit] outline-none',\n 'focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-accent',\n ),\n {\n variants: {\n /* A mask, not an overlay gradient: a gradient would have to be painted\n * in the colour of whatever is behind the area, which the component\n * cannot know. A mask fades the content itself, over any ground. Each\n * edge fades by at most `--fade`, and by less while the content is\n * nearer its end than that - so the fade arrives as scrolling starts\n * rather than switching on. */\n fade: {\n true: cn(\n '[--fade:calc(var(--spacing)*6)] [mask-composite:intersect]',\n '[mask-image:linear-gradient(to_bottom,transparent,var(--color-black)_min(var(--fade),var(--scroll-area-overflow-y-start,0px)),var(--color-black)_calc(100%_-_min(var(--fade),var(--scroll-area-overflow-y-end,0px))),transparent),linear-gradient(to_right,transparent,var(--color-black)_min(var(--fade),var(--scroll-area-overflow-x-start,0px)),var(--color-black)_calc(100%_-_min(var(--fade),var(--scroll-area-overflow-x-end,0px))),transparent)]',\n ),\n false: '',\n },\n },\n defaultVariants: { fade: false },\n },\n)\n\n/* The track is the grab zone and the thumb sits at its outer edge. Hidden at\n * rest; shown while the pointer is over the area or it scrolls, and held\n * while the thumb is being dragged, since the pointer may leave the area\n * mid-drag. */\nconst scrollbar = cn(\n 'group/bar flex p-0.5 opacity-0 [transition:opacity_var(--duration-slow)_var(--ease-out)]',\n 'data-[hovering]:opacity-100 data-[scrolling]:opacity-100 active:opacity-100',\n 'data-[orientation=vertical]:w-2.5 data-[orientation=vertical]:justify-end',\n 'data-[orientation=horizontal]:h-2.5 data-[orientation=horizontal]:flex-col data-[orientation=horizontal]:justify-end',\n)\n\nconst thumb = cn(\n 'rounded-full bg-line-2 group-hover/bar:bg-dim',\n '[transition:width_var(--duration-quick)_var(--ease-out),height_var(--duration-quick)_var(--ease-out),background-color_var(--duration-quick)_var(--ease-out)]',\n 'data-[orientation=vertical]:w-1 group-hover/bar:data-[orientation=vertical]:w-full',\n 'data-[orientation=horizontal]:h-1 group-hover/bar:data-[orientation=horizontal]:h-full',\n)\n\nexport interface ScrollAreaProps {\n /** Names the viewport, which becomes a tab stop when its content overflows:\n * \"Recent activity\", \"Diff of main.rs\". No default - it is the product's\n * word. */\n label: string\n /** Fade the edges that have more content past them. */\n fade?: boolean\n /** Which way the content may move. `vertical` for a list whose rows\n * truncate to the column; `both` for a table or a picture. */\n orientation?: 'both' | 'vertical' | 'horizontal'\n /** The scrolling element itself, for a product that sets or reads its\n * position - a virtual list, a scroll-to-latest. */\n viewportRef?: Ref<HTMLDivElement>\n /** On the root: its size is the area's size, so the height goes here. */\n className?: string\n viewportClassName?: string\n children: ReactNode\n}\n\nexport function ScrollArea({\n label,\n fade = false,\n orientation = 'both',\n viewportRef,\n className,\n viewportClassName,\n children,\n}: ScrollAreaProps) {\n return (\n <Base.Root className={cn('relative min-h-0 min-w-0 overflow-hidden', className)}>\n <Base.Viewport\n ref={viewportRef}\n // Base UI gives the viewport `presentation`, which is right for a box\n // nobody can focus and wrong for one that is a tab stop: the role is\n // dropped the moment it is focusable, and the name goes with it.\n role=\"group\"\n aria-label={label}\n className={cn(scrollAreaViewportVariants({ fade }), viewportClassName)}\n style={viewportStyle[orientation]}\n >\n {/* The content box is what the horizontal overflow is measured on:\n * without it a wide child is squeezed to the viewport's width and\n * nothing ever overflows sideways. */}\n <Base.Content style={contentStyle[orientation]}>{children}</Base.Content>\n </Base.Viewport>\n {orientation === 'horizontal' ? null : (\n <Base.Scrollbar orientation=\"vertical\" className={scrollbar}>\n <Base.Thumb className={thumb} />\n </Base.Scrollbar>\n )}\n {orientation === 'vertical' ? null : (\n <Base.Scrollbar orientation=\"horizontal\" className={scrollbar}>\n <Base.Thumb className={thumb} />\n </Base.Scrollbar>\n )}\n </Base.Root>\n )\n}\n"
|
|
1794
1835
|
}
|
|
1795
1836
|
]
|
|
1796
1837
|
},
|
|
@@ -1800,7 +1841,7 @@
|
|
|
1800
1841
|
"title": "SearchField",
|
|
1801
1842
|
"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.",
|
|
1802
1843
|
"dependencies": [
|
|
1803
|
-
"dowel-ui@^0.
|
|
1844
|
+
"dowel-ui@^0.34.1"
|
|
1804
1845
|
],
|
|
1805
1846
|
"registryDependencies": [
|
|
1806
1847
|
"https://lacodda.github.io/dowel/r/input.json",
|
|
@@ -1823,7 +1864,7 @@
|
|
|
1823
1864
|
"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.",
|
|
1824
1865
|
"dependencies": [
|
|
1825
1866
|
"@base-ui/react",
|
|
1826
|
-
"dowel-ui@^0.
|
|
1867
|
+
"dowel-ui@^0.34.1"
|
|
1827
1868
|
],
|
|
1828
1869
|
"registryDependencies": [],
|
|
1829
1870
|
"files": [
|
|
@@ -1842,7 +1883,7 @@
|
|
|
1842
1883
|
"description": "Two to five options, one of them chosen, all of them in view - a radio group in a segment's clothes.",
|
|
1843
1884
|
"dependencies": [
|
|
1844
1885
|
"@base-ui/react",
|
|
1845
|
-
"dowel-ui@^0.
|
|
1886
|
+
"dowel-ui@^0.34.1"
|
|
1846
1887
|
],
|
|
1847
1888
|
"registryDependencies": [],
|
|
1848
1889
|
"files": [
|
|
@@ -1862,7 +1903,7 @@
|
|
|
1862
1903
|
"dependencies": [
|
|
1863
1904
|
"@base-ui/react",
|
|
1864
1905
|
"class-variance-authority",
|
|
1865
|
-
"dowel-ui@^0.
|
|
1906
|
+
"dowel-ui@^0.34.1"
|
|
1866
1907
|
],
|
|
1867
1908
|
"registryDependencies": [
|
|
1868
1909
|
"https://lacodda.github.io/dowel/r/input.json",
|
|
@@ -1899,7 +1940,7 @@
|
|
|
1899
1940
|
"title": "SkeletonOf",
|
|
1900
1941
|
"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.",
|
|
1901
1942
|
"dependencies": [
|
|
1902
|
-
"dowel-ui@^0.
|
|
1943
|
+
"dowel-ui@^0.34.1"
|
|
1903
1944
|
],
|
|
1904
1945
|
"registryDependencies": [],
|
|
1905
1946
|
"files": [
|
|
@@ -1917,7 +1958,7 @@
|
|
|
1917
1958
|
"title": "Skeleton",
|
|
1918
1959
|
"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.",
|
|
1919
1960
|
"dependencies": [
|
|
1920
|
-
"dowel-ui@^0.
|
|
1961
|
+
"dowel-ui@^0.34.1"
|
|
1921
1962
|
],
|
|
1922
1963
|
"registryDependencies": [],
|
|
1923
1964
|
"files": [
|
|
@@ -1936,7 +1977,7 @@
|
|
|
1936
1977
|
"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\".",
|
|
1937
1978
|
"dependencies": [
|
|
1938
1979
|
"@base-ui/react",
|
|
1939
|
-
"dowel-ui@^0.
|
|
1980
|
+
"dowel-ui@^0.34.1"
|
|
1940
1981
|
],
|
|
1941
1982
|
"registryDependencies": [],
|
|
1942
1983
|
"files": [
|
|
@@ -1955,7 +1996,7 @@
|
|
|
1955
1996
|
"description": "The shape of a history, not a chart of it: no axes, no gridlines, no ticks.",
|
|
1956
1997
|
"dependencies": [
|
|
1957
1998
|
"class-variance-authority",
|
|
1958
|
-
"dowel-ui@^0.
|
|
1999
|
+
"dowel-ui@^0.34.1"
|
|
1959
2000
|
],
|
|
1960
2001
|
"registryDependencies": [],
|
|
1961
2002
|
"files": [
|
|
@@ -1974,7 +2015,7 @@
|
|
|
1974
2015
|
"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.",
|
|
1975
2016
|
"dependencies": [
|
|
1976
2017
|
"class-variance-authority",
|
|
1977
|
-
"dowel-ui@^0.
|
|
2018
|
+
"dowel-ui@^0.34.1"
|
|
1978
2019
|
],
|
|
1979
2020
|
"registryDependencies": [],
|
|
1980
2021
|
"files": [
|
|
@@ -1992,7 +2033,7 @@
|
|
|
1992
2033
|
"title": "Splash",
|
|
1993
2034
|
"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.",
|
|
1994
2035
|
"dependencies": [
|
|
1995
|
-
"dowel-ui@^0.
|
|
2036
|
+
"dowel-ui@^0.34.1"
|
|
1996
2037
|
],
|
|
1997
2038
|
"registryDependencies": [],
|
|
1998
2039
|
"files": [
|
|
@@ -2011,7 +2052,7 @@
|
|
|
2011
2052
|
"description": "The splitter primitive.",
|
|
2012
2053
|
"dependencies": [
|
|
2013
2054
|
"class-variance-authority",
|
|
2014
|
-
"dowel-ui@^0.
|
|
2055
|
+
"dowel-ui@^0.34.1"
|
|
2015
2056
|
],
|
|
2016
2057
|
"registryDependencies": [],
|
|
2017
2058
|
"files": [
|
|
@@ -2030,7 +2071,7 @@
|
|
|
2030
2071
|
"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.",
|
|
2031
2072
|
"dependencies": [
|
|
2032
2073
|
"class-variance-authority",
|
|
2033
|
-
"dowel-ui@^0.
|
|
2074
|
+
"dowel-ui@^0.34.1"
|
|
2034
2075
|
],
|
|
2035
2076
|
"registryDependencies": [],
|
|
2036
2077
|
"files": [
|
|
@@ -2049,7 +2090,7 @@
|
|
|
2049
2090
|
"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.",
|
|
2050
2091
|
"dependencies": [
|
|
2051
2092
|
"class-variance-authority",
|
|
2052
|
-
"dowel-ui@^0.
|
|
2093
|
+
"dowel-ui@^0.34.1"
|
|
2053
2094
|
],
|
|
2054
2095
|
"registryDependencies": [],
|
|
2055
2096
|
"files": [
|
|
@@ -2068,7 +2109,7 @@
|
|
|
2068
2109
|
"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.",
|
|
2069
2110
|
"dependencies": [
|
|
2070
2111
|
"class-variance-authority",
|
|
2071
|
-
"dowel-ui@^0.
|
|
2112
|
+
"dowel-ui@^0.34.1"
|
|
2072
2113
|
],
|
|
2073
2114
|
"registryDependencies": [],
|
|
2074
2115
|
"files": [
|
|
@@ -2087,7 +2128,7 @@
|
|
|
2087
2128
|
"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.",
|
|
2088
2129
|
"dependencies": [
|
|
2089
2130
|
"@base-ui/react",
|
|
2090
|
-
"dowel-ui@^0.
|
|
2131
|
+
"dowel-ui@^0.34.1"
|
|
2091
2132
|
],
|
|
2092
2133
|
"registryDependencies": [],
|
|
2093
2134
|
"files": [
|
|
@@ -2111,7 +2152,7 @@
|
|
|
2111
2152
|
"path": "ui/table-sort.tsx",
|
|
2112
2153
|
"target": "@ui/table-sort.tsx",
|
|
2113
2154
|
"type": "registry:ui",
|
|
2114
|
-
"content": "/*\n * How a column is ordered, with no React in it.\n *\n * Split out of the Table for the reason `calendar-math` was split out of the\n * Calendar: these are the sums, and the component is the thing that draws\n * them. A product sorting its own rows - on the server, in a worker, before\n * the data ever reaches a component - imports this and nothing else.\n *\n * No table library, deliberately. The obvious choice here is TanStack Table,\n * and it would be the first dependency a product has to install beyond Base\n * UI. What it offers is a model of columns, pages and sorting state; what the\n * line's one real table needed was the rule below, which the model does not\n * have. So the model is the part that is written here, and it is small.\n *\n * The rule, and the reason this file exists at all:\n *\n * **Absence sorts last, whichever way the column points.**\n *\n * A row with no value in this column is not the smallest - it is unknown, and\n * the two are different facts. Rank absence with the rest and flip the sign,\n * and a descending sort floats every empty row to the top: the reader asks for\n * \"highest first\" and is handed the rows that have no value at all. This is\n * measured rather than assumed - it is what the first version of the line's\n * catalogue did, and the fix is the shape below, where presence is settled\n * before the direction is applied.\n *\n * It is the same fact `RatingScale` is built on: not judged yet is a state,\n * not a zero. A table that sorts them together loses it on the first click.\n */\n\n/** Which way a column points. */\nexport type SortDirection = 'asc' | 'desc'\n\n/** A column, and which way it points. `column` is the caller's own key - the\n * id it gave the column, not an index, so reordering columns cannot silently\n * change what is sorted. */\nexport interface Sort<Column extends string = string> {\n column: Column\n direction: SortDirection\n}\n\n/** What can be compared. `null` and `undefined` both mean absent - a product\n * gets whichever its data uses, and having to normalise them before sorting\n * is the kind of step that gets forgotten in one branch of a switch. */\nexport type SortValue = string | number | boolean | null | undefined\n\n/** Reads the value of a column out of a row.\n *\n * Given rather than inferred: a column id is not always a field name, and a\n * sortable column is often a computed one - a duration that is stored as two\n * timestamps, a name that sorts by surname. */\nexport type SortAccessor<Row, Column extends string = string> = (\n row: Row,\n column: Column,\n) => SortValue\n\nexport interface SortOptions<Row> {\n /** The tiebreaker: what decides the order of rows that compare equal.\n *\n * Without one, `Array.prototype.sort` is stable and therefore leaves ties in\n * input order - which sounds fine until the input is re-fetched and arrives\n * in a different order, and a column of ties reshuffles under the reader\n * with no click. Pass the row's id. */\n tiebreak?: (row: Row) => SortValue\n /** The language text is compared in - the application's, from\n * `useLocale()`. Passed to `Intl.Collator`, so `ä` sorts where the reader\n * expects rather than after `z`. Required: left out, the collator used the\n * browser's language, which is not the one the table is written in. */\n locale: string\n}\n\n/** Order the rows. Returns a new array; the input is not touched, because a\n * component that sorts its own prop in place mutates the caller's state. */\nexport function sortRows<Row, Column extends string = string>(\n rows: readonly Row[],\n sort: Sort<Column>,\n accessor: SortAccessor<Row, Column>,\n options: SortOptions<Row>,\n): Row[] {\n const sign = sort.direction === 'asc' ? 1 : -1\n // One collator for the whole sort rather than one `localeCompare` per\n // comparison: building it is the expensive half, and a sort of n rows asks\n // for it n log n times.\n const collator = new Intl.Collator(options.locale)\n\n return [...rows].sort((a, b) => {\n const left = accessor(a, sort.column)\n const right = accessor(b, sort.column)\n\n // Presence first, and outside the sign. This is the whole point of the\n // file: absence is last in both directions.\n const absent = presence(left, right)\n if (absent !== 0) return absent\n\n const ranked = compareValues(left, right, collator) * sign\n if (ranked !== 0) return ranked\n\n if (options.tiebreak) {\n const tied = compareValues(options.tiebreak(a), options.tiebreak(b), collator)\n // Deliberately not multiplied by the sign. The tiebreaker is there to\n // make the order stable, and an order that reverses with the column is\n // not stable - the rows that tie would swap places on every click.\n if (tied !== 0) return tied\n }\n return 0\n })\n}\n\n/** Whether a value counts as absent.\n *\n * Both spellings of nothing, and neither `0` nor `''` nor `false`, which are\n * values a row genuinely has.\n *\n * `NaN` counts too, and that is not tidiness. Subtraction with it returns\n * `NaN`, which `sort` reads as \"these two are equal\" - so a row whose number\n * is not a number takes whatever place the input happened to give it, and the\n * order changes when the data is re-fetched. A number that is not a number is\n * not a small number; it is unknown, which is what this file already has a\n * place for. */\nexport function isAbsent(value: SortValue): boolean {\n if (value === null || value === undefined) return true\n return typeof value === 'number' && Number.isNaN(value)\n}\n\n/** Which of the two lacks a value; absent sorts last, always. */\nfunction presence(left: SortValue, right: SortValue): number {\n const leftHas = !isAbsent(left)\n const rightHas = !isAbsent(right)\n if (leftHas === rightHas) return 0\n return leftHas ? -1 : 1\n}\n\n/** Compare two present values of the same column.\n *\n * Numbers by subtraction, text by collator, and booleans as false-then-true.\n * A column whose values are of mixed type compares as text: that is a data\n * problem the table cannot fix, and ordering by `String` at least gives the\n * same answer twice. */\nfunction compareValues(left: SortValue, right: SortValue, collator: Intl.Collator): number {\n if (isAbsent(left) || isAbsent(right)) return presence(left, right)\n\n // NaN never reaches here: `isAbsent` counts it as absent, so `presence`\n // settles it above, outside the sign.\n if (typeof left === 'number' && typeof right === 'number') return left - right\n if (typeof left === 'boolean' && typeof right === 'boolean') {\n return Number(left) - Number(right)\n }\n return collator.compare(String(left), String(right))\n}\n\n/** What clicking a column heading does.\n *\n * Three states rather than two, and the third is the reason this is a function\n * and not `direction === 'asc' ? 'desc' : 'asc'`: clicking a *different*\n * column starts it ascending rather than inheriting the direction of the one\n * before. Inheriting is what a two-state toggle does, and it means the first\n * click on a new column can hand back an order nobody asked for. */\nexport function toggleSort<Column extends string>(\n sort: Sort<Column>,\n column: Column,\n): Sort<Column> {\n if (sort.column !== column) return { column, direction: 'asc' }\n return { column, direction: sort.direction === 'asc' ? 'desc' : 'asc' }\n}\n\n/** What a column heading announces: `ascending`, `descending`, or nothing.\n *\n * The value belongs in `aria-sort` on the `<th>`, and only on the one that is\n * sorted - `aria-sort=\"none\"` on every other heading is allowed by the spec\n * and read out by some screen readers on every cell, which turns a table into\n * a recital. `undefined` removes the attribute. */\nexport function ariaSort(\n sort: Sort<string> | undefined,\n column: string,\n): 'ascending' | 'descending' | undefined {\n if (!sort || sort.column !== column) return undefined\n return sort.direction === 'asc' ? 'ascending' : 'descending'\n}\n"
|
|
2155
|
+
"content": "export {}\n// Code first, so the description below survives `shadcn add`: the CLI writes\n// a file from its first token on and drops every comment above it.\n\n/*\n * How a column is ordered, with no React in it.\n *\n * Split out of the Table for the reason `calendar-math` was split out of the\n * Calendar: these are the sums, and the component is the thing that draws\n * them. A product sorting its own rows - on the server, in a worker, before\n * the data ever reaches a component - imports this and nothing else.\n *\n * No table library, deliberately. The obvious choice here is TanStack Table,\n * and it would be the first dependency a product has to install beyond Base\n * UI. What it offers is a model of columns, pages and sorting state; what the\n * line's one real table needed was the rule below, which the model does not\n * have. So the model is the part that is written here, and it is small.\n *\n * The rule, and the reason this file exists at all:\n *\n * **Absence sorts last, whichever way the column points.**\n *\n * A row with no value in this column is not the smallest - it is unknown, and\n * the two are different facts. Rank absence with the rest and flip the sign,\n * and a descending sort floats every empty row to the top: the reader asks for\n * \"highest first\" and is handed the rows that have no value at all. This is\n * measured rather than assumed - it is what the first version of the line's\n * catalogue did, and the fix is the shape below, where presence is settled\n * before the direction is applied.\n *\n * It is the same fact `RatingScale` is built on: not judged yet is a state,\n * not a zero. A table that sorts them together loses it on the first click.\n */\n\n/** Which way a column points. */\nexport type SortDirection = 'asc' | 'desc'\n\n/** A column, and which way it points. `column` is the caller's own key - the\n * id it gave the column, not an index, so reordering columns cannot silently\n * change what is sorted. */\nexport interface Sort<Column extends string = string> {\n column: Column\n direction: SortDirection\n}\n\n/** What can be compared. `null` and `undefined` both mean absent - a product\n * gets whichever its data uses, and having to normalise them before sorting\n * is the kind of step that gets forgotten in one branch of a switch. */\nexport type SortValue = string | number | boolean | null | undefined\n\n/** Reads the value of a column out of a row.\n *\n * Given rather than inferred: a column id is not always a field name, and a\n * sortable column is often a computed one - a duration that is stored as two\n * timestamps, a name that sorts by surname. */\nexport type SortAccessor<Row, Column extends string = string> = (\n row: Row,\n column: Column,\n) => SortValue\n\nexport interface SortOptions<Row> {\n /** The tiebreaker: what decides the order of rows that compare equal.\n *\n * Without one, `Array.prototype.sort` is stable and therefore leaves ties in\n * input order - which sounds fine until the input is re-fetched and arrives\n * in a different order, and a column of ties reshuffles under the reader\n * with no click. Pass the row's id. */\n tiebreak?: (row: Row) => SortValue\n /** The language text is compared in - the application's, from\n * `useLocale()`. Passed to `Intl.Collator`, so `ä` sorts where the reader\n * expects rather than after `z`. Required: left out, the collator used the\n * browser's language, which is not the one the table is written in. */\n locale: string\n}\n\n/** Order the rows. Returns a new array; the input is not touched, because a\n * component that sorts its own prop in place mutates the caller's state. */\nexport function sortRows<Row, Column extends string = string>(\n rows: readonly Row[],\n sort: Sort<Column>,\n accessor: SortAccessor<Row, Column>,\n options: SortOptions<Row>,\n): Row[] {\n const sign = sort.direction === 'asc' ? 1 : -1\n // One collator for the whole sort rather than one `localeCompare` per\n // comparison: building it is the expensive half, and a sort of n rows asks\n // for it n log n times.\n const collator = new Intl.Collator(options.locale)\n\n return [...rows].sort((a, b) => {\n const left = accessor(a, sort.column)\n const right = accessor(b, sort.column)\n\n // Presence first, and outside the sign. This is the whole point of the\n // file: absence is last in both directions.\n const absent = presence(left, right)\n if (absent !== 0) return absent\n\n const ranked = compareValues(left, right, collator) * sign\n if (ranked !== 0) return ranked\n\n if (options.tiebreak) {\n const tied = compareValues(options.tiebreak(a), options.tiebreak(b), collator)\n // Deliberately not multiplied by the sign. The tiebreaker is there to\n // make the order stable, and an order that reverses with the column is\n // not stable - the rows that tie would swap places on every click.\n if (tied !== 0) return tied\n }\n return 0\n })\n}\n\n/** Whether a value counts as absent.\n *\n * Both spellings of nothing, and neither `0` nor `''` nor `false`, which are\n * values a row genuinely has.\n *\n * `NaN` counts too, and that is not tidiness. Subtraction with it returns\n * `NaN`, which `sort` reads as \"these two are equal\" - so a row whose number\n * is not a number takes whatever place the input happened to give it, and the\n * order changes when the data is re-fetched. A number that is not a number is\n * not a small number; it is unknown, which is what this file already has a\n * place for. */\nexport function isAbsent(value: SortValue): boolean {\n if (value === null || value === undefined) return true\n return typeof value === 'number' && Number.isNaN(value)\n}\n\n/** Which of the two lacks a value; absent sorts last, always. */\nfunction presence(left: SortValue, right: SortValue): number {\n const leftHas = !isAbsent(left)\n const rightHas = !isAbsent(right)\n if (leftHas === rightHas) return 0\n return leftHas ? -1 : 1\n}\n\n/** Compare two present values of the same column.\n *\n * Numbers by subtraction, text by collator, and booleans as false-then-true.\n * A column whose values are of mixed type compares as text: that is a data\n * problem the table cannot fix, and ordering by `String` at least gives the\n * same answer twice. */\nfunction compareValues(left: SortValue, right: SortValue, collator: Intl.Collator): number {\n if (isAbsent(left) || isAbsent(right)) return presence(left, right)\n\n // NaN never reaches here: `isAbsent` counts it as absent, so `presence`\n // settles it above, outside the sign.\n if (typeof left === 'number' && typeof right === 'number') return left - right\n if (typeof left === 'boolean' && typeof right === 'boolean') {\n return Number(left) - Number(right)\n }\n return collator.compare(String(left), String(right))\n}\n\n/** What clicking a column heading does.\n *\n * Three states rather than two, and the third is the reason this is a function\n * and not `direction === 'asc' ? 'desc' : 'asc'`: clicking a *different*\n * column starts it ascending rather than inheriting the direction of the one\n * before. Inheriting is what a two-state toggle does, and it means the first\n * click on a new column can hand back an order nobody asked for. */\nexport function toggleSort<Column extends string>(\n sort: Sort<Column>,\n column: Column,\n): Sort<Column> {\n if (sort.column !== column) return { column, direction: 'asc' }\n return { column, direction: sort.direction === 'asc' ? 'desc' : 'asc' }\n}\n\n/** What a column heading announces: `ascending`, `descending`, or nothing.\n *\n * The value belongs in `aria-sort` on the `<th>`, and only on the one that is\n * sorted - `aria-sort=\"none\"` on every other heading is allowed by the spec\n * and read out by some screen readers on every cell, which turns a table into\n * a recital. `undefined` removes the attribute. */\nexport function ariaSort(\n sort: Sort<string> | undefined,\n column: string,\n): 'ascending' | 'descending' | undefined {\n if (!sort || sort.column !== column) return undefined\n return sort.direction === 'asc' ? 'ascending' : 'descending'\n}\n"
|
|
2115
2156
|
}
|
|
2116
2157
|
]
|
|
2117
2158
|
},
|
|
@@ -2122,7 +2163,7 @@
|
|
|
2122
2163
|
"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.",
|
|
2123
2164
|
"dependencies": [
|
|
2124
2165
|
"class-variance-authority",
|
|
2125
|
-
"dowel-ui@^0.
|
|
2166
|
+
"dowel-ui@^0.34.1"
|
|
2126
2167
|
],
|
|
2127
2168
|
"registryDependencies": [
|
|
2128
2169
|
"https://lacodda.github.io/dowel/r/table-sort.json"
|
|
@@ -2144,7 +2185,7 @@
|
|
|
2144
2185
|
"dependencies": [
|
|
2145
2186
|
"@base-ui/react",
|
|
2146
2187
|
"class-variance-authority",
|
|
2147
|
-
"dowel-ui@^0.
|
|
2188
|
+
"dowel-ui@^0.34.1"
|
|
2148
2189
|
],
|
|
2149
2190
|
"registryDependencies": [],
|
|
2150
2191
|
"files": [
|
|
@@ -2163,7 +2204,7 @@
|
|
|
2163
2204
|
"description": "Free text turned into a list: type a word, press Enter, it becomes a chip.",
|
|
2164
2205
|
"dependencies": [
|
|
2165
2206
|
"class-variance-authority",
|
|
2166
|
-
"dowel-ui@^0.
|
|
2207
|
+
"dowel-ui@^0.34.1"
|
|
2167
2208
|
],
|
|
2168
2209
|
"registryDependencies": [
|
|
2169
2210
|
"https://lacodda.github.io/dowel/r/chip.json",
|
|
@@ -2184,7 +2225,7 @@
|
|
|
2184
2225
|
"title": "Textarea",
|
|
2185
2226
|
"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.",
|
|
2186
2227
|
"dependencies": [
|
|
2187
|
-
"dowel-ui@^0.
|
|
2228
|
+
"dowel-ui@^0.34.1"
|
|
2188
2229
|
],
|
|
2189
2230
|
"registryDependencies": [
|
|
2190
2231
|
"https://lacodda.github.io/dowel/r/input.json"
|
|
@@ -2205,7 +2246,7 @@
|
|
|
2205
2246
|
"description": "The tier primitive.",
|
|
2206
2247
|
"dependencies": [
|
|
2207
2248
|
"class-variance-authority",
|
|
2208
|
-
"dowel-ui@^0.
|
|
2249
|
+
"dowel-ui@^0.34.1"
|
|
2209
2250
|
],
|
|
2210
2251
|
"registryDependencies": [],
|
|
2211
2252
|
"files": [
|
|
@@ -2223,7 +2264,7 @@
|
|
|
2223
2264
|
"title": "TimeField",
|
|
2224
2265
|
"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`.",
|
|
2225
2266
|
"dependencies": [
|
|
2226
|
-
"dowel-ui@^0.
|
|
2267
|
+
"dowel-ui@^0.34.1"
|
|
2227
2268
|
],
|
|
2228
2269
|
"registryDependencies": [
|
|
2229
2270
|
"https://lacodda.github.io/dowel/r/input.json"
|
|
@@ -2244,7 +2285,7 @@
|
|
|
2244
2285
|
"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.",
|
|
2245
2286
|
"dependencies": [
|
|
2246
2287
|
"class-variance-authority",
|
|
2247
|
-
"dowel-ui@^0.
|
|
2288
|
+
"dowel-ui@^0.34.1"
|
|
2248
2289
|
],
|
|
2249
2290
|
"registryDependencies": [],
|
|
2250
2291
|
"files": [
|
|
@@ -2264,7 +2305,7 @@
|
|
|
2264
2305
|
"dependencies": [
|
|
2265
2306
|
"@base-ui/react",
|
|
2266
2307
|
"class-variance-authority",
|
|
2267
|
-
"dowel-ui@^0.
|
|
2308
|
+
"dowel-ui@^0.34.1"
|
|
2268
2309
|
],
|
|
2269
2310
|
"registryDependencies": [],
|
|
2270
2311
|
"files": [
|
|
@@ -2284,7 +2325,7 @@
|
|
|
2284
2325
|
"dependencies": [
|
|
2285
2326
|
"@base-ui/react",
|
|
2286
2327
|
"class-variance-authority",
|
|
2287
|
-
"dowel-ui@^0.
|
|
2328
|
+
"dowel-ui@^0.34.1"
|
|
2288
2329
|
],
|
|
2289
2330
|
"registryDependencies": [
|
|
2290
2331
|
"https://lacodda.github.io/dowel/r/layer.json"
|
|
@@ -2310,7 +2351,7 @@
|
|
|
2310
2351
|
"path": "ui/track-segments.tsx",
|
|
2311
2352
|
"target": "@ui/track-segments.tsx",
|
|
2312
2353
|
"type": "registry:ui",
|
|
2313
|
-
"content": "/*\n * The arithmetic behind Track, with no React in it.\n *\n * Split out for the reason `table-sort` and `tree-rows` are: a product that\n * needs the numbers - to label a segment, to test its own domain code, to draw\n * the same shape somewhere that is not the DOM - should not import a component\n * to get them.\n *\n * What lives here is only the geometry. Turning a day of work into segments,\n * or a set of tiers into them, is the product's own arithmetic and stays with\n * the product: this knows about spans and percentages and nothing else.\n */\n\n/** A segment as the caller states it, in whatever units the caller counts in. */\nexport interface SegmentInput {\n /** Where it starts. Same units as `end` and as the track's span. */\n start: number\n /** Where it ends. A segment ending before it starts is empty, not backwards. */\n end: number\n}\n\n/** A segment as it is drawn: percentages of the track. */\nexport interface Placed {\n /** Distance from the left edge, 0-100. */\n left: number\n /** Width, 0-100. */\n width: number\n /** Whether the width is the real one, or the floor standing in for it.\n *\n * Worth knowing rather than hiding: a caller labelling segments may want to\n * say \"under a minute\" instead of a duration the bar is no longer drawing to\n * scale. */\n widened: boolean\n}\n\n/** The smallest a segment may be drawn, in percent.\n *\n * A ten-second pause in an eight-hour day is 0.03% of the track, which rounds\n * to no pixels at all: the segment is real, was measured, and would simply not\n * be there. The floor is the width at which a sliver is still visible on a\n * track a few hundred pixels wide. */\nexport const MIN_SEGMENT_WIDTH = 0.6\n\nexport interface PlaceOptions {\n /** The value the left edge stands for. Defaults to the first segment's start. */\n from?: number\n /** The value the right edge stands for. Defaults to the last segment's end. */\n to?: number\n /** Override the floor - `0` to draw every segment exactly to scale. */\n minWidth?: number\n}\n\n/**\n * Lay segments out along a track, as percentages.\n *\n * The scale is stated by `from` and `to` rather than inferred, because the two\n * readings differ and both are wanted: a day of work is read against itself\n * (an eight-hour day drawn across a third of the width wastes the space where\n * the breaks are), while a set of tiers is read against the whole 0-100 scale,\n * where the distance to the next tier is the distance you have to close.\n *\n * Widening a sliver to the floor is what makes this worth having once. It also\n * introduces the only subtlety here: a widened segment can run into the one\n * after it, and two segments drawn overlapping is a worse lie than a segment\n * drawn slightly too wide. So the pass is done in order, and each segment is\n * held back to where the next one starts - a floor is a request, not a\n * guarantee, and the last one may be trimmed by the end of the track.\n */\nexport function place(segments: SegmentInput[], options: PlaceOptions = {}): Placed[] {\n if (segments.length === 0) return []\n\n const from = options.from ?? Math.min(...segments.map((s) => s.start))\n const to = options.to ?? Math.max(...segments.map((s) => s.end))\n const span = to - from\n\n // A track with no span has nothing to scale against: every segment would be\n // at the same place with the same width, which is not a drawing of anything.\n if (!(span > 0)) return []\n\n const floor = options.minWidth ?? MIN_SEGMENT_WIDTH\n\n const raw = segments.map((segment) => {\n const start = Math.min(Math.max(segment.start, from), to)\n const end = Math.min(Math.max(segment.end, start), to)\n return { left: ((start - from) / span) * 100, width: ((end - start) / span) * 100 }\n })\n\n if (floor <= 0) return raw.map((segment) => ({ ...segment, widened: false }))\n\n /*\n * Widening is a layout pass, not a per-segment decision, and the first\n * version of this got it wrong in a way worth recording: it capped each\n * sliver at \"where the next segment starts\", which protects against overlap\n * and also means a segment that touches its neighbour can never grow at all.\n * Both shapes this component exists for are contiguous - work then break\n * then work, one tier after another - so the floor did nothing for either of\n * them, and the tests missed it because their crowded case had a gap.\n *\n * So a widened segment pushes what follows instead. That borrows room the\n * track does not have, and the debt is paid back at the end by the segments\n * wide enough to afford it - never by another sliver, which would undo the\n * widening we just did.\n */\n const widened: Placed[] = []\n let shift = 0\n\n for (const segment of raw) {\n const left = segment.left + shift\n if (segment.width >= floor) {\n widened.push({ left, width: segment.width, widened: false })\n continue\n }\n shift += floor - segment.width\n widened.push({ left, width: floor, widened: true })\n }\n\n if (shift === 0) return widened\n\n /* The debt is only owed if the track has actually overflowed. A sliver on an\n * otherwise empty track - one segment, or several with gaps between them -\n * grows into room nobody was using, and nothing needs to give way. */\n const end = widened.at(-1)!\n const overflow = end.left + end.width - 100\n if (overflow <= 0) return widened\n\n /* Who can pay: everything above the floor, by however much it has to spare.\n * Taken in proportion, so one long stretch is not singled out to absorb the\n * whole debt while its neighbour keeps its exact width. */\n const spare = widened.reduce((sum, s) => sum + (s.widened ? 0 : Math.max(0, s.width - floor)), 0)\n\n /* Nobody can pay: every segment is at or under the floor, and the track is\n * genuinely too crowded to draw honestly at this width. The floor loses -\n * a bar running off its own end is worse than slivers too thin to see. */\n if (spare <= 0) return raw.map((segment) => ({ ...segment, widened: false }))\n\n let paid = 0\n return widened.map((segment) => {\n const left = segment.left - paid\n if (segment.widened) return { ...segment, left }\n\n const contribution = (Math.max(0, segment.width - floor) / spare) * overflow\n paid += contribution\n return { left, width: segment.width - contribution, widened: false }\n })\n}\n\n/** Where a marker sits on the same scale, as a percentage, or `null` when it\n * falls outside the track.\n *\n * Outside rather than clamped: a marker pinned to the edge says \"here, at the\n * very end\", which is a different statement from \"not on this track at all\". */\nexport function markerAt(value: number, from: number, to: number): number | null {\n const span = to - from\n if (!(span > 0)) return null\n if (value < from || value > to) return null\n return ((value - from) / span) * 100\n}\n"
|
|
2354
|
+
"content": "export {}\n// Code first, so the description below survives `shadcn add`: the CLI writes\n// a file from its first token on and drops every comment above it.\n\n/*\n * The arithmetic behind Track, with no React in it.\n *\n * Split out for the reason `table-sort` and `tree-rows` are: a product that\n * needs the numbers - to label a segment, to test its own domain code, to draw\n * the same shape somewhere that is not the DOM - should not import a component\n * to get them.\n *\n * What lives here is only the geometry. Turning a day of work into segments,\n * or a set of tiers into them, is the product's own arithmetic and stays with\n * the product: this knows about spans and percentages and nothing else.\n */\n\n/** A segment as the caller states it, in whatever units the caller counts in. */\nexport interface SegmentInput {\n /** Where it starts. Same units as `end` and as the track's span. */\n start: number\n /** Where it ends. A segment ending before it starts is empty, not backwards. */\n end: number\n}\n\n/** A segment as it is drawn: percentages of the track. */\nexport interface Placed {\n /** Distance from the left edge, 0-100. */\n left: number\n /** Width, 0-100. */\n width: number\n /** Whether the width is the real one, or the floor standing in for it.\n *\n * Worth knowing rather than hiding: a caller labelling segments may want to\n * say \"under a minute\" instead of a duration the bar is no longer drawing to\n * scale. */\n widened: boolean\n}\n\n/** The smallest a segment may be drawn, in percent.\n *\n * A ten-second pause in an eight-hour day is 0.03% of the track, which rounds\n * to no pixels at all: the segment is real, was measured, and would simply not\n * be there. The floor is the width at which a sliver is still visible on a\n * track a few hundred pixels wide. */\nexport const MIN_SEGMENT_WIDTH = 0.6\n\nexport interface PlaceOptions {\n /** The value the left edge stands for. Defaults to the first segment's start. */\n from?: number\n /** The value the right edge stands for. Defaults to the last segment's end. */\n to?: number\n /** Override the floor - `0` to draw every segment exactly to scale. */\n minWidth?: number\n}\n\n/**\n * Lay segments out along a track, as percentages.\n *\n * The scale is stated by `from` and `to` rather than inferred, because the two\n * readings differ and both are wanted: a day of work is read against itself\n * (an eight-hour day drawn across a third of the width wastes the space where\n * the breaks are), while a set of tiers is read against the whole 0-100 scale,\n * where the distance to the next tier is the distance you have to close.\n *\n * Widening a sliver to the floor is what makes this worth having once. It also\n * introduces the only subtlety here: a widened segment can run into the one\n * after it, and two segments drawn overlapping is a worse lie than a segment\n * drawn slightly too wide. So the pass is done in order, and each segment is\n * held back to where the next one starts - a floor is a request, not a\n * guarantee, and the last one may be trimmed by the end of the track.\n */\nexport function place(segments: SegmentInput[], options: PlaceOptions = {}): Placed[] {\n if (segments.length === 0) return []\n\n const from = options.from ?? Math.min(...segments.map((s) => s.start))\n const to = options.to ?? Math.max(...segments.map((s) => s.end))\n const span = to - from\n\n // A track with no span has nothing to scale against: every segment would be\n // at the same place with the same width, which is not a drawing of anything.\n if (!(span > 0)) return []\n\n const floor = options.minWidth ?? MIN_SEGMENT_WIDTH\n\n const raw = segments.map((segment) => {\n const start = Math.min(Math.max(segment.start, from), to)\n const end = Math.min(Math.max(segment.end, start), to)\n return { left: ((start - from) / span) * 100, width: ((end - start) / span) * 100 }\n })\n\n if (floor <= 0) return raw.map((segment) => ({ ...segment, widened: false }))\n\n /*\n * Widening is a layout pass, not a per-segment decision, and the first\n * version of this got it wrong in a way worth recording: it capped each\n * sliver at \"where the next segment starts\", which protects against overlap\n * and also means a segment that touches its neighbour can never grow at all.\n * Both shapes this component exists for are contiguous - work then break\n * then work, one tier after another - so the floor did nothing for either of\n * them, and the tests missed it because their crowded case had a gap.\n *\n * So a widened segment pushes what follows instead. That borrows room the\n * track does not have, and the debt is paid back at the end by the segments\n * wide enough to afford it - never by another sliver, which would undo the\n * widening we just did.\n */\n const widened: Placed[] = []\n let shift = 0\n\n for (const segment of raw) {\n const left = segment.left + shift\n if (segment.width >= floor) {\n widened.push({ left, width: segment.width, widened: false })\n continue\n }\n shift += floor - segment.width\n widened.push({ left, width: floor, widened: true })\n }\n\n if (shift === 0) return widened\n\n /* The debt is only owed if the track has actually overflowed. A sliver on an\n * otherwise empty track - one segment, or several with gaps between them -\n * grows into room nobody was using, and nothing needs to give way. */\n const end = widened.at(-1)!\n const overflow = end.left + end.width - 100\n if (overflow <= 0) return widened\n\n /* Who can pay: everything above the floor, by however much it has to spare.\n * Taken in proportion, so one long stretch is not singled out to absorb the\n * whole debt while its neighbour keeps its exact width. */\n const spare = widened.reduce((sum, s) => sum + (s.widened ? 0 : Math.max(0, s.width - floor)), 0)\n\n /* Nobody can pay: every segment is at or under the floor, and the track is\n * genuinely too crowded to draw honestly at this width. The floor loses -\n * a bar running off its own end is worse than slivers too thin to see. */\n if (spare <= 0) return raw.map((segment) => ({ ...segment, widened: false }))\n\n let paid = 0\n return widened.map((segment) => {\n const left = segment.left - paid\n if (segment.widened) return { ...segment, left }\n\n const contribution = (Math.max(0, segment.width - floor) / spare) * overflow\n paid += contribution\n return { left, width: segment.width - contribution, widened: false }\n })\n}\n\n/** Where a marker sits on the same scale, as a percentage, or `null` when it\n * falls outside the track.\n *\n * Outside rather than clamped: a marker pinned to the edge says \"here, at the\n * very end\", which is a different statement from \"not on this track at all\". */\nexport function markerAt(value: number, from: number, to: number): number | null {\n const span = to - from\n if (!(span > 0)) return null\n if (value < from || value > to) return null\n return ((value - from) / span) * 100\n}\n"
|
|
2314
2355
|
}
|
|
2315
2356
|
]
|
|
2316
2357
|
},
|
|
@@ -2321,7 +2362,7 @@
|
|
|
2321
2362
|
"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.",
|
|
2322
2363
|
"dependencies": [
|
|
2323
2364
|
"class-variance-authority",
|
|
2324
|
-
"dowel-ui@^0.
|
|
2365
|
+
"dowel-ui@^0.34.1"
|
|
2325
2366
|
],
|
|
2326
2367
|
"registryDependencies": [
|
|
2327
2368
|
"https://lacodda.github.io/dowel/r/track-segments.json"
|
|
@@ -2357,7 +2398,7 @@
|
|
|
2357
2398
|
"title": "TreeView",
|
|
2358
2399
|
"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.",
|
|
2359
2400
|
"dependencies": [
|
|
2360
|
-
"dowel-ui@^0.
|
|
2401
|
+
"dowel-ui@^0.34.1"
|
|
2361
2402
|
],
|
|
2362
2403
|
"registryDependencies": [
|
|
2363
2404
|
"https://lacodda.github.io/dowel/r/tree-rows.json"
|
|
@@ -2377,7 +2418,7 @@
|
|
|
2377
2418
|
"title": "Truncate",
|
|
2378
2419
|
"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.",
|
|
2379
2420
|
"dependencies": [
|
|
2380
|
-
"dowel-ui@^0.
|
|
2421
|
+
"dowel-ui@^0.34.1"
|
|
2381
2422
|
],
|
|
2382
2423
|
"registryDependencies": [],
|
|
2383
2424
|
"files": [
|
|
@@ -2395,7 +2436,7 @@
|
|
|
2395
2436
|
"title": "VirtualList",
|
|
2396
2437
|
"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.",
|
|
2397
2438
|
"dependencies": [
|
|
2398
|
-
"dowel-ui@^0.
|
|
2439
|
+
"dowel-ui@^0.34.1"
|
|
2399
2440
|
],
|
|
2400
2441
|
"registryDependencies": [],
|
|
2401
2442
|
"files": [
|
|
@@ -2414,7 +2455,7 @@
|
|
|
2414
2455
|
"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.",
|
|
2415
2456
|
"dependencies": [
|
|
2416
2457
|
"@tauri-apps/api",
|
|
2417
|
-
"dowel-ui@^0.
|
|
2458
|
+
"dowel-ui@^0.34.1"
|
|
2418
2459
|
],
|
|
2419
2460
|
"registryDependencies": [],
|
|
2420
2461
|
"files": [
|
|
@@ -2432,7 +2473,7 @@
|
|
|
2432
2473
|
"title": "Wizard",
|
|
2433
2474
|
"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.",
|
|
2434
2475
|
"dependencies": [
|
|
2435
|
-
"dowel-ui@^0.
|
|
2476
|
+
"dowel-ui@^0.34.1"
|
|
2436
2477
|
],
|
|
2437
2478
|
"registryDependencies": [
|
|
2438
2479
|
"https://lacodda.github.io/dowel/r/button.json",
|