@eifi1/ui-kit 0.14.2 → 0.15.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.
Files changed (118) hide show
  1. package/dist/chart.d.ts +2 -1
  2. package/dist/components/alert-banner.js +5 -2
  3. package/dist/components/alert-banner.js.map +1 -1
  4. package/dist/components/amount-input.d.ts +1 -1
  5. package/dist/components/autocomplete.js +9 -2
  6. package/dist/components/autocomplete.js.map +1 -1
  7. package/dist/components/bulk-action-bar.js +6 -3
  8. package/dist/components/bulk-action-bar.js.map +1 -1
  9. package/dist/components/button-group.d.ts +1 -1
  10. package/dist/components/calculator.d.ts +1 -1
  11. package/dist/components/calendar-heatmap.js +5 -5
  12. package/dist/components/calendar-heatmap.js.map +1 -1
  13. package/dist/components/copy-button.d.ts +1 -1
  14. package/dist/components/data-table-cells.d.ts +1 -1
  15. package/dist/components/data-table-filter-popover.d.ts +1 -1
  16. package/dist/components/data-table-filter-popover.js +3 -3
  17. package/dist/components/data-table-filter-popover.js.map +1 -1
  18. package/dist/components/data-table-filters.d.ts +1 -1
  19. package/dist/components/data-table.d.ts +1 -1
  20. package/dist/components/data-table.js +12 -4
  21. package/dist/components/data-table.js.map +1 -1
  22. package/dist/components/facing-pair.d.ts +1 -0
  23. package/dist/components/field.js +4 -1
  24. package/dist/components/field.js.map +1 -1
  25. package/dist/components/file-button.d.ts +1 -1
  26. package/dist/components/file-dropzone.d.ts +1 -1
  27. package/dist/components/form-actions.d.ts +1 -1
  28. package/dist/components/image-grid.d.ts +8 -1
  29. package/dist/components/image-grid.js +16 -3
  30. package/dist/components/image-grid.js.map +1 -1
  31. package/dist/components/measured-grid.js +2 -2
  32. package/dist/components/measured-grid.js.map +1 -1
  33. package/dist/components/money-field.d.ts +1 -1
  34. package/dist/components/multi-select.js +3 -0
  35. package/dist/components/multi-select.js.map +1 -1
  36. package/dist/components/number-field.d.ts +1 -1
  37. package/dist/components/number-input.d.ts +5 -2
  38. package/dist/components/number-input.js +11 -3
  39. package/dist/components/number-input.js.map +1 -1
  40. package/dist/components/numpad-sheet.d.ts +1 -1
  41. package/dist/components/series-chart-budget.d.ts +60 -0
  42. package/dist/components/series-chart-budget.js +56 -0
  43. package/dist/components/series-chart-budget.js.map +1 -0
  44. package/dist/components/series-chart.d.ts +54 -2
  45. package/dist/components/series-chart.js +104 -20
  46. package/dist/components/series-chart.js.map +1 -1
  47. package/dist/components/settings-fields.d.ts +1 -1
  48. package/dist/components/slider.js +6 -2
  49. package/dist/components/slider.js.map +1 -1
  50. package/dist/components/stat-tile.js +3 -3
  51. package/dist/components/stat-tile.js.map +1 -1
  52. package/dist/components/text-link.d.ts +1 -1
  53. package/dist/components/time-input.d.ts +1 -1
  54. package/dist/components/treemap.d.ts +3 -2
  55. package/dist/components/treemap.js +14 -3
  56. package/dist/components/treemap.js.map +1 -1
  57. package/dist/components/ui.d.ts +1 -1
  58. package/dist/components/ui.js +44 -10
  59. package/dist/components/ui.js.map +1 -1
  60. package/dist/components/use-table-state.d.ts +1 -1
  61. package/dist/{data-table-Drun8Fk9.d.ts → data-table-CfVXtCeG.d.ts} +4 -3
  62. package/dist/data-table.d.ts +1 -1
  63. package/dist/feedback/feedback-thread.js +31 -12
  64. package/dist/feedback/feedback-thread.js.map +1 -1
  65. package/dist/hooks/use-file-drop.d.ts +1 -1
  66. package/dist/i18n/defaults.d.ts +1 -1
  67. package/dist/i18n/kit-labels.d.ts +1 -1
  68. package/dist/i18n/locales/de-CH-informal.d.ts +1 -1
  69. package/dist/i18n/locales/de-CH.d.ts +1 -1
  70. package/dist/i18n/locales/de-informal.d.ts +1 -1
  71. package/dist/i18n/locales/de.d.ts +1 -1
  72. package/dist/i18n/locales/es.d.ts +1 -1
  73. package/dist/i18n/locales/fr.d.ts +1 -1
  74. package/dist/i18n/locales/hu.d.ts +1 -1
  75. package/dist/i18n/locales/it.d.ts +1 -1
  76. package/dist/i18n/locales/zh.d.ts +1 -1
  77. package/dist/index.d.ts +5 -4
  78. package/dist/{kit-labels-Mz8zZHIF.d.ts → kit-labels-BHfRBbHs.d.ts} +5 -1
  79. package/dist/lib/strip-fade.d.ts +31 -0
  80. package/dist/lib/strip-fade.js +54 -0
  81. package/dist/lib/strip-fade.js.map +1 -0
  82. package/dist/rhf/fields.d.ts +1 -1
  83. package/dist/rhf/form.d.ts +1 -1
  84. package/dist/rhf/form.js +1 -1
  85. package/dist/rhf/form.js.map +1 -1
  86. package/dist/rhf.d.ts +1 -1
  87. package/dist/shell/app-shell.d.ts +11 -2
  88. package/dist/shell/app-shell.js +20 -4
  89. package/dist/shell/app-shell.js.map +1 -1
  90. package/dist/shell/top-bar-brand.d.ts +1 -1
  91. package/dist/shell.d.ts +1 -1
  92. package/dist/wizard/stepper-nav.d.ts +1 -1
  93. package/dist/wizard/stepper-nav.js +14 -2
  94. package/dist/wizard/stepper-nav.js.map +1 -1
  95. package/dist/wizard.d.ts +1 -1
  96. package/package.json +1 -1
  97. package/src/components/alert-banner.tsx +10 -2
  98. package/src/components/autocomplete.tsx +10 -1
  99. package/src/components/bulk-action-bar.tsx +12 -3
  100. package/src/components/calendar-heatmap.tsx +10 -5
  101. package/src/components/data-table-filter-popover.tsx +6 -3
  102. package/src/components/data-table.tsx +21 -7
  103. package/src/components/field.tsx +4 -1
  104. package/src/components/image-grid.tsx +30 -2
  105. package/src/components/measured-grid.tsx +3 -2
  106. package/src/components/multi-select.tsx +3 -0
  107. package/src/components/number-input.tsx +23 -4
  108. package/src/components/series-chart-budget.ts +121 -0
  109. package/src/components/series-chart.tsx +201 -15
  110. package/src/components/slider.tsx +6 -2
  111. package/src/components/stat-tile.tsx +8 -3
  112. package/src/components/treemap.tsx +30 -5
  113. package/src/components/ui.tsx +91 -11
  114. package/src/feedback/feedback-thread.tsx +36 -13
  115. package/src/lib/strip-fade.ts +82 -0
  116. package/src/rhf/form.tsx +3 -1
  117. package/src/shell/app-shell.tsx +34 -7
  118. package/src/wizard/stepper-nav.tsx +23 -2
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/components/slider.tsx"],"sourcesContent":["import { forwardRef, useId } from \"react\";\nimport type { CSSProperties, ChangeEvent, InputHTMLAttributes, ReactNode } from \"react\";\nimport { cn } from \"../lib/cn\";\n\n/**\n * A range control on a native `<input type=\"range\">`.\n *\n * Lenkbank wrote the first one (`shared/ui/slider-field.tsx`) and then the case for\n * moving it here (`docs/ui-kit-slider-proposal.md`). This is that proposal's \"stage 1\"\n * primitive — the track, the thumb, the scale and the accessibility — plus the label\n * line and tick marks its SpeedSlider needed. The four decisions it argues should be\n * made once are made here, and each carries its note below:\n *\n * 1. a logarithmic scale whose far-left stop means exactly zero ({@link SliderProps.zeroStop});\n * 2. `aria-valuetext`, so a log track does not announce its 0–1 position as the value;\n * 3. a 24px pointer target, which needs the track and thumb drawn separately;\n * 4. the number is the truth and the track a view of it: a value outside `min`–`max`\n * is kept and the thumb pins at the end — nothing here clamps what it was given.\n *\n * Controlled only, like `ToggleGroup` and `NumberInput`: there is no uncontrolled mode\n * to get wrong, and neither app wants one. The package does no number formatting and\n * holds no locale — `formatValue` is how the caller's `Intl` result gets in.\n *\n * Not here, deliberately (the proposal's list): two thumbs, vertical orientation, and\n * a number box. A `NumberInput` goes in the `readout` slot when a typed value is\n * wanted beside the track.\n */\n\n/** On a log scale with {@link SliderProps.zeroStop}, the share of the track that is\n * the \"off\" stop. The first position to its right is `min`. */\nconst ZERO_STOP = 0.02;\n\n/** Position resolution on a log track — 1000 stops, fine enough that a drag reads as\n * continuous. PageUp/PageDown still cross a tenth of the track at a time. */\nconst LOG_STEP = 0.001;\n\n/**\n * Where a value sits on a logarithmic track, 0–1.\n *\n * With `zeroStop`, zero and below is 0 and the decades between `min` and `max` are\n * mapped onto the rest; without it, `min` is the far left. A value past either end\n * pins there — that is the DISPLAY clamping, never the value's.\n */\nexport function toLogPosition(value: number, min: number, max: number, zeroStop = true): number {\n if (value <= 0) return 0;\n const share = Math.log(Math.max(value, min) / min) / Math.log(max / min);\n const clamped = Math.min(1, Math.max(0, share));\n return zeroStop ? ZERO_STOP + (1 - ZERO_STOP) * clamped : clamped;\n}\n\n/** The inverse of {@link toLogPosition}: the value at a track position. */\nexport function fromLogPosition(position: number, min: number, max: number, zeroStop = true): number {\n if (zeroStop) {\n if (position < ZERO_STOP) return 0;\n return min * Math.pow(max / min, (position - ZERO_STOP) / (1 - ZERO_STOP));\n }\n return min * Math.pow(max / min, position);\n}\n\n// The input is the pointer target, so its box is `h-6` (24px, WCAG 2.5.8) and the\n// track that LOOKS like a track is a 6px strip drawn inside it. Once the box is taller\n// than the track its background cannot be the track, and with `appearance-none`\n// nothing else draws one — measured in Chromium by Lenkbank, that is a bare thumb\n// floating on nothing, in the browser's blue. Hence the vendor pseudo-elements: the\n// two engines share no selector for either half, so each half is spelt twice.\n//\n// The fill up to the thumb: WebKit has no progress pseudo-element, so its track is a\n// two-stop gradient that breaks at `--slider-fill` (set inline from the position), and\n// the gradient runs the other way in a right-to-left document, where the native range\n// runs the other way too. Firefox draws the fill itself with `::-moz-range-progress`,\n// which follows the direction on its own.\n//\n// The thumb's `-mt-[5px]` centres a 16px thumb on a 6px track: WebKit lays the thumb\n// out from the track's top edge, Firefox centres it, so only one carries the offset.\n//\n// The focus ring goes on the THUMB, not the box: the box is the full width of the\n// row, and a ring round all of it says \"this row is focused\" rather than \"the handle\n// you are about to move is\". Offset in the surface colour, because the thumb is\n// --brand and a --brand ring touching it would be invisible.\nconst TRACK = [\n \"block h-6 w-full cursor-pointer appearance-none bg-transparent focus:outline-none disabled:cursor-not-allowed\",\n // WebKit / Blink\n \"[&::-webkit-slider-runnable-track]:h-1.5 [&::-webkit-slider-runnable-track]:rounded-full\",\n \"[&::-webkit-slider-runnable-track]:bg-[linear-gradient(to_right,var(--brand)_var(--slider-fill),var(--border-strong)_var(--slider-fill))]\",\n \"rtl:[&::-webkit-slider-runnable-track]:bg-[linear-gradient(to_left,var(--brand)_var(--slider-fill),var(--border-strong)_var(--slider-fill))]\",\n \"[&::-webkit-slider-thumb]:-mt-[5px] [&::-webkit-slider-thumb]:size-4 [&::-webkit-slider-thumb]:appearance-none\",\n \"[&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-[var(--brand)] [&::-webkit-slider-thumb]:shadow\",\n \"focus-visible:[&::-webkit-slider-thumb]:ring-2 focus-visible:[&::-webkit-slider-thumb]:ring-[var(--brand)]\",\n \"focus-visible:[&::-webkit-slider-thumb]:ring-offset-2 focus-visible:[&::-webkit-slider-thumb]:ring-offset-[var(--bg-surface)]\",\n // Firefox\n \"[&::-moz-range-track]:h-1.5 [&::-moz-range-track]:rounded-full [&::-moz-range-track]:bg-[var(--border-strong)]\",\n \"[&::-moz-range-progress]:h-1.5 [&::-moz-range-progress]:rounded-full [&::-moz-range-progress]:bg-[var(--brand)]\",\n \"[&::-moz-range-thumb]:size-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-0\",\n \"[&::-moz-range-thumb]:bg-[var(--brand)] [&::-moz-range-thumb]:shadow\",\n \"focus-visible:[&::-moz-range-thumb]:ring-2 focus-visible:[&::-moz-range-thumb]:ring-[var(--brand)]\",\n \"focus-visible:[&::-moz-range-thumb]:ring-offset-2 focus-visible:[&::-moz-range-thumb]:ring-offset-[var(--bg-surface)]\",\n].join(\" \");\n\n/** A tick under the track. A bare number is a tick with no words. */\nexport interface SliderMark {\n value: number;\n /** Shown under the tick — \"30 km/h\", \"measured\". Visual only: the value itself is\n * announced through `aria-valuetext`, so the marks row is hidden from assistive\n * technology rather than read out as a list of numbers after the slider. */\n label?: ReactNode;\n}\n\nexport interface SliderProps\n extends Omit<\n InputHTMLAttributes<HTMLInputElement>,\n \"type\" | \"value\" | \"defaultValue\" | \"onChange\" | \"min\" | \"max\" | \"step\" | \"children\"\n > {\n /** The value. On a log scale this is the real value, not the track position. */\n value: number;\n /** The new value — a number, not the DOM event, like `ToggleGroup`. Fired on every\n * movement; debounce at the call site if each one costs a request. */\n onChange: (value: number) => void;\n /** The track's ends. Not a validity range: a `value` outside it is kept and the\n * thumb pins at the end (see the module note). On a log scale `min` must be\n * above zero — zero is what {@link zeroStop} is for. */\n min: number;\n max: number;\n /** Step on a linear scale (the native default is 1). Ignored on a log scale, which\n * steps by track position — a fixed step is wrong at one end of every decade. */\n step?: number;\n /**\n * How the track maps to the value. `\"log\"` for quantities that run over decades — a\n * proportional gain of 0.5 and one of 200 are both ordinary, and on a linear track\n * everything under a tenth of the range is the first pixel. Falls back to linear if\n * `min` is not above zero or `max` not above `min`, where a logarithm has no answer.\n */\n scale?: \"linear\" | \"log\";\n /**\n * Log scale only: the far-left stop means exactly 0 (\"off\"), and the first step to\n * its right is `min`. A logarithm cannot hold zero, and zero is often the question —\n * should there be an integral term at all? Default `true`; turn it off for a scale\n * where zero is meaningless (a zoom factor, a playback rate).\n */\n zeroStop?: boolean;\n /**\n * What a screen reader says for the value — \"12.5 mm\", \"Off\". Sets\n * `aria-valuetext`. Without it a log track would announce its 0–1 POSITION (\"0.63\"\n * for a gain of 12.5), so on a log scale the raw value is announced instead; pass\n * this for anything with a unit or a locale.\n */\n formatValue?: (value: number) => string;\n /** The words above the track, as a `<label for>`. Without it, pass `aria-label`. */\n label?: ReactNode;\n /** Something that belongs to the label rather than the value — in practice a\n * `FieldHint` \"?\". Rendered beside the label, outside the `<label>` element, so it\n * never becomes part of the slider's accessible name. */\n hint?: ReactNode;\n /** The value as the reader sees it, at the end of the label line — a formatted\n * number, or a `NumberInput` for typing one. Rendered as given. */\n readout?: ReactNode;\n /** Ticks under the track, at these values. Marks outside `min`–`max` are dropped. */\n marks?: ReadonlyArray<number | SliderMark>;\n /** Classes for the `<input>`. `className` styles the outer wrapper. */\n inputClassName?: string;\n}\n\n/**\n * A native range input with the kit's track and thumb, an optional label line\n * (`label`, `hint`, `readout`), tick `marks`, and a logarithmic `scale`.\n *\n * Every other prop reaches the `<input>` — `name`, `id`, `aria-*`, `data-*`, `onBlur`,\n * `onPointerUp` for commit-on-release — and `disabled` also fades the label line.\n */\nexport const Slider = forwardRef<HTMLInputElement, SliderProps>(function Slider(\n {\n value,\n onChange,\n min,\n max,\n step,\n scale = \"linear\",\n zeroStop = true,\n formatValue,\n label,\n hint,\n readout,\n marks,\n className,\n inputClassName,\n id,\n disabled,\n style,\n ...rest\n },\n ref,\n) {\n const generated = useId();\n const inputId = id ?? generated;\n const log = scale === \"log\" && min > 0 && max > min;\n\n // Everything the native element sees is a POSITION; everything the caller sees is a\n // value. On a linear track the two are the same number.\n const trackMin = log ? 0 : min;\n const trackMax = log ? 1 : max;\n const position = (v: number) => (log ? toLogPosition(v, min, max, zeroStop) : Math.min(max, Math.max(min, v)));\n const current = position(value);\n // 0–1 share of the track, for the fill and the marks. A zero-width range (min ===\n // max) has no share to speak of; call it empty rather than divide by zero.\n const share = (p: number) => (trackMax > trackMin ? (p - trackMin) / (trackMax - trackMin) : 0);\n const outOfRange = value < min || value > max;\n\n // `aria-valuetext` replaces the announced NUMBER and leaves the announced name\n // alone. The caller's words first (`formatValue`, then a hand-written\n // `aria-valuetext`); failing those, the real value wherever the\n // native one would lie — every log position, and a linear value the thumb is only\n // pinned at. `toPrecision(4)` keeps a log value from being read out to sixteen\n // digits; it is not formatting, and `formatValue` is how to get a locale.\n const valueText = formatValue\n ? formatValue(value)\n : (rest[\"aria-valuetext\"] ??\n (log || outOfRange ? String(Number(value.toPrecision(4))) : undefined));\n\n const handleChange = (e: ChangeEvent<HTMLInputElement>) => {\n const raw = Number(e.target.value);\n if (!log) return onChange(raw);\n // The gap between the zero stop and `min` has to be crossable by keyboard. An\n // arrow key moves one LOG_STEP, which from zero lands inside the gap — and a gap\n // that rounded to the nearer end would send it straight back to zero, trapping\n // a keyboard user at \"off\". So inside the gap, moving up from zero means `min`\n // and anything else means zero.\n if (zeroStop && raw > 0 && raw < ZERO_STOP) return onChange(value <= 0 ? min : 0);\n onChange(fromLogPosition(raw, min, max, zeroStop));\n };\n\n const normalizedMarks = (marks ?? [])\n .map((m) => (typeof m === \"number\" ? { value: m } : m))\n .filter((m) => m.value >= min && m.value <= max);\n const hasMarkLabels = normalizedMarks.some((m) => m.label !== undefined);\n\n const hasHeader = label !== undefined || hint !== undefined || readout !== undefined;\n const plainReadout = typeof readout === \"string\" || typeof readout === \"number\";\n\n return (\n <div className={cn(\"w-full\", disabled && \"opacity-60\", className)}>\n {hasHeader && (\n // `select-none` on the words is not a nicety (Lenkbank feedback #93): a\n // mousedown on text beside a range input starts Chrome's native text drag, the\n // cursor turns to no-drop and the thumb stops following the pointer for the\n // rest of the gesture. The readout is only made unselectable when it is plain\n // text — a NumberInput in that slot has to stay selectable to be editable.\n <div className=\"flex items-center gap-2\">\n {label !== undefined && (\n <label\n htmlFor={inputId}\n className=\"min-w-0 select-none text-xs font-medium text-[var(--text-secondary)]\"\n >\n {label}\n </label>\n )}\n {hint !== undefined && <span className=\"flex shrink-0 items-center\">{hint}</span>}\n {readout !== undefined && (\n <span\n className={cn(\n \"ms-auto shrink-0 text-xs tabular-nums text-[var(--text-primary)]\",\n plainReadout && \"select-none\",\n )}\n >\n {readout}\n </span>\n )}\n </div>\n )}\n <input\n ref={ref}\n id={label !== undefined ? inputId : id}\n disabled={disabled}\n {...rest}\n type=\"range\"\n min={trackMin}\n max={trackMax}\n step={log ? LOG_STEP : step}\n value={current}\n aria-valuetext={valueText}\n onChange={handleChange}\n // The fill's break point, merged under the caller's own style.\n style={{ ...style, \"--slider-fill\": `${share(current) * 100}%` } as CSSProperties}\n className={cn(TRACK, inputClassName)}\n />\n {normalizedMarks.length > 0 && (\n // `mx-2` insets the row by half a thumb on each side, which is exactly the\n // range the thumb's CENTRE travels — so a tick at 0% sits under the thumb at\n // `min`, not under the track's rounded end. Each tick is a zero-width column\n // centred on its point, which centres its label on it in either direction\n // without a `translate` that would need flipping for right-to-left.\n <div\n aria-hidden\n className={cn(\"relative mx-2 -mt-1.5\", hasMarkLabels ? \"h-5\" : \"h-1.5\")}\n >\n {normalizedMarks.map((m) => (\n <span\n key={m.value}\n className=\"absolute top-0 flex w-0 flex-col items-center\"\n style={{ insetInlineStart: `${share(position(m.value)) * 100}%` }}\n >\n <span className=\"h-1.5 w-px shrink-0 bg-[var(--border-strong)]\" />\n {m.label !== undefined && (\n <span className=\"mt-0.5 select-none whitespace-nowrap text-[11px] leading-tight text-[var(--text-muted)]\">\n {m.label}\n </span>\n )}\n </span>\n ))}\n </div>\n )}\n </div>\n );\n});\nSlider.displayName = \"Slider\";\n"],"mappings":"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","names":["Slider"]}
1
+ {"version":3,"sources":["../../src/components/slider.tsx"],"sourcesContent":["import { forwardRef, useId } from \"react\";\nimport type { CSSProperties, ChangeEvent, InputHTMLAttributes, ReactNode } from \"react\";\nimport { cn } from \"../lib/cn\";\n\n/**\n * A range control on a native `<input type=\"range\">`.\n *\n * Lenkbank wrote the first one (`shared/ui/slider-field.tsx`) and then the case for\n * moving it here (`docs/ui-kit-slider-proposal.md`). This is that proposal's \"stage 1\"\n * primitive — the track, the thumb, the scale and the accessibility — plus the label\n * line and tick marks its SpeedSlider needed. The four decisions it argues should be\n * made once are made here, and each carries its note below:\n *\n * 1. a logarithmic scale whose far-left stop means exactly zero ({@link SliderProps.zeroStop});\n * 2. `aria-valuetext`, so a log track does not announce its 0–1 position as the value;\n * 3. a 24px pointer target, which needs the track and thumb drawn separately;\n * 4. the number is the truth and the track a view of it: a value outside `min`–`max`\n * is kept and the thumb pins at the end — nothing here clamps what it was given.\n *\n * Controlled only, like `ToggleGroup` and `NumberInput`: there is no uncontrolled mode\n * to get wrong, and neither app wants one. The package does no number formatting and\n * holds no locale — `formatValue` is how the caller's `Intl` result gets in.\n *\n * Not here, deliberately (the proposal's list): two thumbs, vertical orientation, and\n * a number box. A `NumberInput` goes in the `readout` slot when a typed value is\n * wanted beside the track.\n */\n\n/** On a log scale with {@link SliderProps.zeroStop}, the share of the track that is\n * the \"off\" stop. The first position to its right is `min`. */\nconst ZERO_STOP = 0.02;\n\n/** Position resolution on a log track — 1000 stops, fine enough that a drag reads as\n * continuous. PageUp/PageDown still cross a tenth of the track at a time. */\nconst LOG_STEP = 0.001;\n\n/**\n * Where a value sits on a logarithmic track, 0–1.\n *\n * With `zeroStop`, zero and below is 0 and the decades between `min` and `max` are\n * mapped onto the rest; without it, `min` is the far left. A value past either end\n * pins there — that is the DISPLAY clamping, never the value's.\n */\nexport function toLogPosition(value: number, min: number, max: number, zeroStop = true): number {\n if (value <= 0) return 0;\n const share = Math.log(Math.max(value, min) / min) / Math.log(max / min);\n const clamped = Math.min(1, Math.max(0, share));\n return zeroStop ? ZERO_STOP + (1 - ZERO_STOP) * clamped : clamped;\n}\n\n/** The inverse of {@link toLogPosition}: the value at a track position. */\nexport function fromLogPosition(position: number, min: number, max: number, zeroStop = true): number {\n if (zeroStop) {\n if (position < ZERO_STOP) return 0;\n return min * Math.pow(max / min, (position - ZERO_STOP) / (1 - ZERO_STOP));\n }\n return min * Math.pow(max / min, position);\n}\n\n// The input is the pointer target, so its box is `h-6` (24px, WCAG 2.5.8) and the\n// track that LOOKS like a track is a 6px strip drawn inside it. Once the box is taller\n// than the track its background cannot be the track, and with `appearance-none`\n// nothing else draws one — measured in Chromium by Lenkbank, that is a bare thumb\n// floating on nothing, in the browser's blue. Hence the vendor pseudo-elements: the\n// two engines share no selector for either half, so each half is spelt twice.\n//\n// The fill up to the thumb: WebKit has no progress pseudo-element, so its track is a\n// two-stop gradient that breaks at `--slider-fill` (set inline from the position), and\n// the gradient runs the other way in a right-to-left document, where the native range\n// runs the other way too. Firefox draws the fill itself with `::-moz-range-progress`,\n// which follows the direction on its own.\n//\n// The thumb's `-mt-[5px]` centres a 16px thumb on a 6px track: WebKit lays the thumb\n// out from the track's top edge, Firefox centres it, so only one carries the offset.\n//\n// The focus ring goes on the THUMB, not the box: the box is the full width of the\n// row, and a ring round all of it says \"this row is focused\" rather than \"the handle\n// you are about to move is\". Offset in the surface colour, because the thumb is\n// --brand and a --brand ring touching it would be invisible.\nconst TRACK = [\n \"block h-6 w-full cursor-pointer appearance-none bg-transparent focus:outline-none disabled:cursor-not-allowed\",\n // WebKit / Blink\n \"[&::-webkit-slider-runnable-track]:h-1.5 [&::-webkit-slider-runnable-track]:rounded-full\",\n \"[&::-webkit-slider-runnable-track]:bg-[linear-gradient(to_right,var(--brand)_var(--slider-fill),var(--border-strong)_var(--slider-fill))]\",\n \"rtl:[&::-webkit-slider-runnable-track]:bg-[linear-gradient(to_left,var(--brand)_var(--slider-fill),var(--border-strong)_var(--slider-fill))]\",\n \"[&::-webkit-slider-thumb]:-mt-[5px] [&::-webkit-slider-thumb]:size-4 [&::-webkit-slider-thumb]:appearance-none\",\n \"[&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-[var(--brand)] [&::-webkit-slider-thumb]:shadow\",\n \"focus-visible:[&::-webkit-slider-thumb]:ring-2 focus-visible:[&::-webkit-slider-thumb]:ring-[var(--brand)]\",\n \"focus-visible:[&::-webkit-slider-thumb]:ring-offset-2 focus-visible:[&::-webkit-slider-thumb]:ring-offset-[var(--bg-surface)]\",\n // Firefox\n \"[&::-moz-range-track]:h-1.5 [&::-moz-range-track]:rounded-full [&::-moz-range-track]:bg-[var(--border-strong)]\",\n \"[&::-moz-range-progress]:h-1.5 [&::-moz-range-progress]:rounded-full [&::-moz-range-progress]:bg-[var(--brand)]\",\n \"[&::-moz-range-thumb]:size-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-0\",\n \"[&::-moz-range-thumb]:bg-[var(--brand)] [&::-moz-range-thumb]:shadow\",\n \"focus-visible:[&::-moz-range-thumb]:ring-2 focus-visible:[&::-moz-range-thumb]:ring-[var(--brand)]\",\n \"focus-visible:[&::-moz-range-thumb]:ring-offset-2 focus-visible:[&::-moz-range-thumb]:ring-offset-[var(--bg-surface)]\",\n].join(\" \");\n\n/** A tick under the track. A bare number is a tick with no words. */\nexport interface SliderMark {\n value: number;\n /** Shown under the tick — \"30 km/h\", \"measured\". Visual only: the value itself is\n * announced through `aria-valuetext`, so the marks row is hidden from assistive\n * technology rather than read out as a list of numbers after the slider. */\n label?: ReactNode;\n}\n\nexport interface SliderProps\n extends Omit<\n InputHTMLAttributes<HTMLInputElement>,\n \"type\" | \"value\" | \"defaultValue\" | \"onChange\" | \"min\" | \"max\" | \"step\" | \"children\"\n > {\n /** The value. On a log scale this is the real value, not the track position. */\n value: number;\n /** The new value — a number, not the DOM event, like `ToggleGroup`. Fired on every\n * movement; debounce at the call site if each one costs a request. */\n onChange: (value: number) => void;\n /** The track's ends. Not a validity range: a `value` outside it is kept and the\n * thumb pins at the end (see the module note). On a log scale `min` must be\n * above zero — zero is what {@link zeroStop} is for. */\n min: number;\n max: number;\n /** Step on a linear scale (the native default is 1). Ignored on a log scale, which\n * steps by track position — a fixed step is wrong at one end of every decade. */\n step?: number;\n /**\n * How the track maps to the value. `\"log\"` for quantities that run over decades — a\n * proportional gain of 0.5 and one of 200 are both ordinary, and on a linear track\n * everything under a tenth of the range is the first pixel. Falls back to linear if\n * `min` is not above zero or `max` not above `min`, where a logarithm has no answer.\n */\n scale?: \"linear\" | \"log\";\n /**\n * Log scale only: the far-left stop means exactly 0 (\"off\"), and the first step to\n * its right is `min`. A logarithm cannot hold zero, and zero is often the question —\n * should there be an integral term at all? Default `true`; turn it off for a scale\n * where zero is meaningless (a zoom factor, a playback rate).\n */\n zeroStop?: boolean;\n /**\n * What a screen reader says for the value — \"12.5 mm\", \"Off\". Sets\n * `aria-valuetext`. Without it a log track would announce its 0–1 POSITION (\"0.63\"\n * for a gain of 12.5), so on a log scale the raw value is announced instead; pass\n * this for anything with a unit or a locale.\n */\n formatValue?: (value: number) => string;\n /** The words above the track, as a `<label for>`. Without it, pass `aria-label`. */\n label?: ReactNode;\n /** Something that belongs to the label rather than the value — in practice a\n * `FieldHint` \"?\". Rendered beside the label, outside the `<label>` element, so it\n * never becomes part of the slider's accessible name. */\n hint?: ReactNode;\n /** The value as the reader sees it, at the end of the label line — a formatted\n * number, or a `NumberInput` for typing one. Rendered as given. */\n readout?: ReactNode;\n /** Ticks under the track, at these values. Marks outside `min`–`max` are dropped. */\n marks?: ReadonlyArray<number | SliderMark>;\n /** Classes for the `<input>`. `className` styles the outer wrapper. */\n inputClassName?: string;\n}\n\n/**\n * A native range input with the kit's track and thumb, an optional label line\n * (`label`, `hint`, `readout`), tick `marks`, and a logarithmic `scale`.\n *\n * Every other prop reaches the `<input>` — `name`, `id`, `aria-*`, `data-*`, `onBlur`,\n * `onPointerUp` for commit-on-release — and `disabled` also fades the label line.\n */\nexport const Slider = forwardRef<HTMLInputElement, SliderProps>(function Slider(\n {\n value,\n onChange,\n min,\n max,\n step,\n scale = \"linear\",\n zeroStop = true,\n formatValue,\n label,\n hint,\n readout,\n marks,\n className,\n inputClassName,\n id,\n disabled,\n style,\n ...rest\n },\n ref,\n) {\n const generated = useId();\n const inputId = id ?? generated;\n const log = scale === \"log\" && min > 0 && max > min;\n\n // Everything the native element sees is a POSITION; everything the caller sees is a\n // value. On a linear track the two are the same number.\n const trackMin = log ? 0 : min;\n const trackMax = log ? 1 : max;\n const position = (v: number) => (log ? toLogPosition(v, min, max, zeroStop) : Math.min(max, Math.max(min, v)));\n const current = position(value);\n // 0–1 share of the track, for the fill and the marks. A zero-width range (min ===\n // max) has no share to speak of; call it empty rather than divide by zero.\n const share = (p: number) => (trackMax > trackMin ? (p - trackMin) / (trackMax - trackMin) : 0);\n const outOfRange = value < min || value > max;\n\n // `aria-valuetext` replaces the announced NUMBER and leaves the announced name\n // alone. The caller's words first (`formatValue`, then a hand-written\n // `aria-valuetext`); failing those, the real value wherever the\n // native one would lie — every log position, and a linear value the thumb is only\n // pinned at. `toPrecision(4)` keeps a log value from being read out to sixteen\n // digits; it is not formatting, and `formatValue` is how to get a locale.\n const valueText = formatValue\n ? formatValue(value)\n : (rest[\"aria-valuetext\"] ??\n (log || outOfRange ? String(Number(value.toPrecision(4))) : undefined));\n\n const handleChange = (e: ChangeEvent<HTMLInputElement>) => {\n const raw = Number(e.target.value);\n if (!log) return onChange(raw);\n // The gap between the zero stop and `min` has to be crossable by keyboard. An\n // arrow key moves one LOG_STEP, which from zero lands inside the gap — and a gap\n // that rounded to the nearer end would send it straight back to zero, trapping\n // a keyboard user at \"off\". So inside the gap, moving up from zero means `min`\n // and anything else means zero.\n if (zeroStop && raw > 0 && raw < ZERO_STOP) return onChange(value <= 0 ? min : 0);\n onChange(fromLogPosition(raw, min, max, zeroStop));\n };\n\n const normalizedMarks = (marks ?? [])\n .map((m) => (typeof m === \"number\" ? { value: m } : m))\n .filter((m) => m.value >= min && m.value <= max);\n const hasMarkLabels = normalizedMarks.some((m) => m.label !== undefined);\n\n const hasHeader = label !== undefined || hint !== undefined || readout !== undefined;\n const plainReadout = typeof readout === \"string\" || typeof readout === \"number\";\n\n return (\n <div className={cn(\"w-full\", disabled && \"opacity-60\", className)}>\n {hasHeader && (\n // `select-none` on the words is not a nicety (Lenkbank feedback #93): a\n // mousedown on text beside a range input starts Chrome's native text drag, the\n // cursor turns to no-drop and the thumb stops following the pointer for the\n // rest of the gesture. The readout is only made unselectable when it is plain\n // text — a NumberInput in that slot has to stay selectable to be editable.\n // `flex-wrap`: a readout wider than the room beside the label (a value plus a\n // link button, \"0 / 60 / 120 km/h · Alle Geschwindigkeiten\") used to print on\n // top of it at 390px (lenkbank L1). Each keeps its own width, so the readout\n // drops to a line of its own, still at the end, only when the two don't fit.\n <div className=\"flex flex-wrap items-center gap-x-2 gap-y-0.5\">\n {label !== undefined && (\n <label\n htmlFor={inputId}\n className=\"min-w-0 select-none text-xs font-medium text-[var(--text-secondary)]\"\n >\n {label}\n </label>\n )}\n {hint !== undefined && <span className=\"flex shrink-0 items-center\">{hint}</span>}\n {readout !== undefined && (\n <span\n className={cn(\n \"ms-auto max-w-full text-xs tabular-nums text-[var(--text-primary)]\",\n plainReadout && \"select-none\",\n )}\n >\n {readout}\n </span>\n )}\n </div>\n )}\n <input\n ref={ref}\n id={label !== undefined ? inputId : id}\n disabled={disabled}\n {...rest}\n type=\"range\"\n min={trackMin}\n max={trackMax}\n step={log ? LOG_STEP : step}\n value={current}\n aria-valuetext={valueText}\n onChange={handleChange}\n // The fill's break point, merged under the caller's own style.\n style={{ ...style, \"--slider-fill\": `${share(current) * 100}%` } as CSSProperties}\n className={cn(TRACK, inputClassName)}\n />\n {normalizedMarks.length > 0 && (\n // `mx-2` insets the row by half a thumb on each side, which is exactly the\n // range the thumb's CENTRE travels — so a tick at 0% sits under the thumb at\n // `min`, not under the track's rounded end. Each tick is a zero-width column\n // centred on its point, which centres its label on it in either direction\n // without a `translate` that would need flipping for right-to-left.\n <div\n aria-hidden\n className={cn(\"relative mx-2 -mt-1.5\", hasMarkLabels ? \"h-5\" : \"h-1.5\")}\n >\n {normalizedMarks.map((m) => (\n <span\n key={m.value}\n className=\"absolute top-0 flex w-0 flex-col items-center\"\n style={{ insetInlineStart: `${share(position(m.value)) * 100}%` }}\n >\n <span className=\"h-1.5 w-px shrink-0 bg-[var(--border-strong)]\" />\n {m.label !== undefined && (\n <span className=\"mt-0.5 select-none whitespace-nowrap text-[11px] leading-tight text-[var(--text-muted)]\">\n {m.label}\n </span>\n )}\n </span>\n ))}\n </div>\n )}\n </div>\n );\n});\nSlider.displayName = \"Slider\";\n"],"mappings":"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","names":["Slider"]}
@@ -224,13 +224,13 @@ function FigureRow({
224
224
  }) {
225
225
  const own = figure.tone ?? (tileTone === "neutral" ? void 0 : tileTone);
226
226
  const svTone = own === "none" ? void 0 : own;
227
- return /* @__PURE__ */ jsxs("div", { className: "flex flex-wrap items-baseline gap-x-2 text-xs tabular-nums text-[var(--text-muted)]", children: [
228
- /* @__PURE__ */ jsx("dt", { className: "uppercase tracking-wide", children: figure.label }),
227
+ return /* @__PURE__ */ jsxs("div", { className: "flex items-baseline gap-x-2 text-xs tabular-nums text-[var(--text-muted)]", children: [
228
+ /* @__PURE__ */ jsx("dt", { className: "min-w-0 uppercase tracking-wide [overflow-wrap:anywhere]", children: figure.label }),
229
229
  /* @__PURE__ */ jsx(
230
230
  "dd",
231
231
  {
232
232
  "data-private": priv,
233
- className: cn("ms-auto whitespace-nowrap", svTone && [toneClass(svTone, figure.value), "opacity-75"]),
233
+ className: cn("ms-auto shrink-0 whitespace-nowrap", svTone && [toneClass(svTone, figure.value), "opacity-75"]),
234
234
  children: show(figure.value)
235
235
  }
236
236
  )
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/components/stat-tile.tsx"],"sourcesContent":["import { isValidElement, useId, useLayoutEffect, useState } from \"react\";\nimport type { ComponentPropsWithoutRef, CSSProperties, ReactElement, ReactNode } from \"react\";\nimport { ArrowDown, ArrowUp, Minus } from \"lucide-react\";\nimport { cn } from \"../lib/cn\";\nimport { useKitLabels, useKitLink, useKitLocale } from \"../i18n/kit-labels\";\nimport { pickLinkRenderer, RenderedKitLink } from \"./text-link\";\nimport { Card, FieldHint } from \"./ui\";\nimport { Sparkline } from \"./sparkline\";\nimport { Tooltip } from \"./tooltip\";\n// The kit's one placeholder look, so a loading tile and the Skeleton list beside it\n// shimmer as one surface — and both stop under `prefers-reduced-motion`, which this\n// tile's private copy of the string did not.\nimport { SKELETON_CLASS } from \"./skeleton\";\n\n/**\n * The words a stat tile speaks. The delta's arrow is an icon, and an icon is not a\n * sentence: a screen reader gets the direction (and, under `goodDirection`, the\n * verdict) as words, and a sighted reader who cannot tell the two delta colours apart\n * still has the arrow — neither judgement rides on colour alone.\n */\nexport interface StatTileLabels {\n /** The delta, rising. `amount` is already formatted and unsigned. */\n increase: (amount: string) => string;\n decrease: (amount: string) => string;\n unchanged: string;\n /** Wraps the direction sentence when `goodDirection` says the change is good. */\n better: (change: string) => string;\n worse: (change: string) => string;\n /** Spoken in place of a missing value (the tile shows a dash). */\n noValue: string;\n /** Spoken while `loading`. */\n loading: string;\n}\n\nexport const DEFAULT_STAT_TILE_LABELS: StatTileLabels = {\n increase: (amount) => `Up ${amount}`,\n decrease: (amount) => `Down ${amount}`,\n unchanged: \"No change\",\n better: (change) => `${change} (better)`,\n worse: (change) => `${change} (worse)`,\n noValue: \"No data\",\n loading: \"Loading…\",\n};\n\n/**\n * What colours a figure. Token-backed like everything else in the kit.\n *\n * - `income` / `expense` / `net` — the money trio (teal / amber / violet, CVD-safe).\n * - `signed` — a money figure read by its own sign: above zero `income`, below zero\n * `expense`, exactly zero neutral (a zero balance is neither news). Needs a NUMBER\n * value; a pre-formatted string cannot be read and stays neutral.\n * - `success` / `warning` / `danger` / `info` — a threshold verdict (\"phase margin\n * below 30°\"). Say it in the hint or description as well; colour alone is not one.\n */\nexport type StatTileTone =\n | \"neutral\"\n | \"brand\"\n | \"income\"\n | \"expense\"\n | \"net\"\n | \"signed\"\n | \"success\"\n | \"warning\"\n | \"danger\"\n | \"info\";\n\nconst TONE_CLASS: Record<Exclude<StatTileTone, \"signed\">, string> = {\n neutral: \"text-[var(--text-primary)]\",\n brand: \"text-[var(--brand)]\",\n income: \"text-[var(--money-income)]\",\n expense: \"text-[var(--money-expense)]\",\n net: \"text-[var(--money-net)]\",\n success: \"text-[var(--success)]\",\n warning: \"text-[var(--warning)]\",\n danger: \"text-[var(--danger)]\",\n info: \"text-[var(--info)]\",\n};\n\nfunction toneClass(tone: StatTileTone, value: unknown): string {\n if (tone !== \"signed\") return TONE_CLASS[tone];\n if (typeof value !== \"number\" || value === 0) return TONE_CLASS.neutral;\n return value < 0 ? TONE_CLASS.expense : TONE_CLASS.income;\n}\n\n/** A change since the previous period. A bare number is shorthand for `{ value }`. */\nexport interface StatTileDelta {\n /** Signed: above zero is \"up\". */\n value: number;\n /** `\"value\"` (default) formats it like the headline — same currency, same\n * notation. `\"percent\"` reads it as a RATIO: `0.12` is shown as 12 %. */\n unit?: \"value\" | \"percent\";\n /** What it is measured against, shown after it: \"vs last month\". */\n label?: ReactNode;\n}\n\n/** A smaller figure under the headline — a prior period, a projection, a split. */\nexport interface StatTileSubValue {\n label: ReactNode;\n /** A number is formatted like the headline; anything else is rendered as given. */\n value: ReactNode;\n /** Defaults to the tile's own `tone`, read per figure (so a `signed` tile shows a\n * teal September above an amber August). Sub-values are always the muted weight\n * of their tone: they are history, and must not outshout the figure above them.\n *\n * `\"none\"` leaves the figure untinted — the row's own muted text, like its label —\n * even on a toned tile: keksdose's report KpiCard (reports/charts/kpi-card.tsx),\n * whose `negative` convention tints only a figure below zero and leaves the rest\n * plain. (`\"neutral\"` is not that: it is the headline's full text colour, at the\n * sub-value's 75 %.) */\n tone?: StatTileTone | \"none\";\n}\n\n/** What {@link StatTileProps.renderLink} is handed. Spread it onto your router's\n * link: the class is what stretches the link over the whole tile. */\nexport interface StatTileLinkProps {\n href: string;\n className: string;\n \"aria-describedby\": string;\n children: ReactNode;\n}\n\nexport interface StatTileProps\n extends Omit<ComponentPropsWithoutRef<\"div\">, \"children\" | \"onClick\"> {\n /** What is measured. Also the tile's accessible name when it is a link/button. */\n label: ReactNode;\n /**\n * The headline. A number is formatted with `Intl.NumberFormat` in the kit's locale\n * (see `currency`, `compact`, `numberFormat`, `format`); a string or element is\n * rendered as given. `null`/`undefined` shows a dash and says \"No data\".\n */\n value?: ReactNode;\n /** ISO 4217 code — formats a number value (and a `\"value\"` delta, and numeric\n * sub-values) as money. */\n currency?: string;\n /** Compact notation: 12 400 → \"12K\". */\n compact?: boolean;\n /** Any further `Intl.NumberFormat` options, merged over the two above. */\n numberFormat?: Intl.NumberFormatOptions;\n /** Formats every NUMBER the tile shows, replacing the `Intl` default outright. */\n format?: (value: number) => string;\n locale?: string;\n /** A short unit after the value that `Intl` has no name for: \"ms\", \"dB\", \"Hz\". */\n unit?: ReactNode;\n /** The period the headline belongs to, shown to its left (\"SEP\"). */\n valueLabel?: ReactNode;\n tone?: StatTileTone;\n delta?: number | StatTileDelta;\n /** Which way is good news. Unset, the delta is stated and not judged (users up and\n * cost up are the same arrow); set, it is coloured and its sentence says so. */\n goodDirection?: \"up\" | \"down\";\n /** The explanation behind a \"?\" beside the label (a {@link FieldHint}) — hidden\n * until asked for, so it is for what a reader MIGHT want to know (\"excludes\n * transfers between your own accounts\"). For a line everyone should read, use\n * {@link StatTileProps.description} instead. */\n hint?: string;\n /**\n * Keep the label to ONE line, cut with an ellipsis, and show the whole of it in a\n * {@link Tooltip} on hover and focus — but only while it is actually cut, so a\n * label that fits shows no bubble repeating itself. keksdose's admin MetricTile\n * wall: twenty tiles whose German labels (\"Aktive Abonnements (30 Tage)\") wrapped\n * onto two or three lines and pushed each tile's figure to a different height.\n *\n * The label element keeps the FULL text — the ellipsis is paint — so it is still\n * the tile's accessible name (and the stretched link's, on a link tile) exactly as\n * without this. Off by default: a wrapping label is what every existing tile does,\n * and a tile standing alone has room to.\n *\n * This is the tooltip: pass the label as a plain STRING rather than wrapping it in\n * your own `<Tooltip>` for the full text (keksdose's admin metrics-panel.tsx:134\n * does, from before this prop). A wrapped label shows a bubble even when nothing is\n * cut, and nests its trigger inside this one's.\n */\n truncateLabel?: boolean;\n /**\n * The tile's name as plain text, for the places that need a string rather than a\n * node — today the {@link StatTileProps.trend} sparkline's accessible name. Only\n * needed when `label` is not a string AND its text cannot be read from it: a label\n * element's text children are found on their own (a `<Tooltip label=\"…\">Spend\n * </Tooltip>` label still names its sparkline \"Spend\"), an icon or a formatted\n * message component's are not.\n */\n name?: string;\n /** A visible line under the value: \"Last 12 months\", \"3 estates / 9 buildings\" —\n * always shown, so it is for what every reader needs. An explanation only some\n * will want belongs in {@link StatTileProps.hint}, the \"?\" beside the label. */\n description?: ReactNode;\n /**\n * One figure that belongs WITH the headline rather than under it: keksdose's\n * \"Projected €1,850\" under a to-date \"€420\" (KpiCard, feedback #51), which it\n * currently smuggles into `description` with hand-set margins so the prior-month\n * sub-values do not come between the two. It is drawn directly under the value\n * (before the delta, the description and the sparkline) as a sub-value row, with\n * only a hair of space above it.\n *\n * A slot of its own rather than a placement switch for `subValues`: the history\n * rows (last month, last year) should stay at the bottom where they are, and moving\n * the whole list up to bring one row with it would put them between the headline\n * and its delta. Same shape as a sub-value, so the `tone` rules are the same.\n * Withheld while `loading`, like the sub-values.\n */\n projection?: StatTileSubValue;\n subValues?: StatTileSubValue[];\n /** Shorthand for a fluid {@link Sparkline} named after the tile and speaking in\n * the tile's number format. The name is the label's text; see\n * {@link StatTileProps.name} for a label that has none. */\n trend?: ReadonlyArray<number | null | undefined>;\n /** A custom sparkline (or any small graphic) in the same place. Wins over `trend`. */\n sparkline?: ReactNode;\n /** A decorative icon at the top end of the tile. */\n icon?: ReactNode;\n /** Anything below the rest — a progress bar, a link row. */\n footer?: ReactNode;\n /** Tag every figure `data-private`, so the host's demo-mode rule blurs it — the\n * same attribute `Tooltip redact` and the chart tooltip's value cell carry. */\n sensitive?: boolean;\n /** Skeleton in place of the value; delta, sub-values and trend are withheld. */\n loading?: boolean;\n /** `\"sm\"` for dense grids (a metrics wall); `\"md\"` the default KPI. */\n size?: \"sm\" | \"md\";\n /** Makes the tile a link. See {@link renderLink} for a router. */\n href?: string;\n /** Renders the link for `href` — pass your router's `<Link>` here, since a plain\n * `<a>` reloads a single-page app. Default: the `<UiKitProvider linkComponent>`\n * (for an in-app `href`), then `<a>`. */\n renderLink?: (props: StatTileLinkProps) => ReactElement;\n /** Makes the tile a button (ignored when `href` is given). */\n onClick?: () => void;\n labels?: Partial<StatTileLabels>;\n}\n\n// One formatter per option set: a metrics wall is twenty of these.\nconst formatters = new Map<string, Intl.NumberFormat>();\nfunction numberFormatter(locale: string | undefined, options: Intl.NumberFormatOptions): Intl.NumberFormat {\n const key = `${locale ?? \"\"}|${JSON.stringify(options)}`;\n let f = formatters.get(key);\n if (!f) {\n f = new Intl.NumberFormat(locale, options);\n formatters.set(key, f);\n }\n return f;\n}\n\n// The headline sizes are keyed on the TILE's width (it is a @container), not the\n// viewport: a grid beside a sidebar makes tiles narrowest exactly where a viewport\n// breakpoint would hand them the larger font. `sm` is keksdose's compact headline,\n// sized so a worst-case \"CHF 123,456.78\" still fits a 320px phone's 2-up tiles.\nconst VALUE_TEXT = {\n md: \"text-xl @[13rem]:text-2xl\",\n sm: \"text-xs @[8.5rem]:text-sm @[13rem]:text-base\",\n} as const;\nconst PADDING = { md: \"p-3 sm:p-4\", sm: \"px-2.5 py-2\" } as const;\n\n/**\n * The KPI card: a label, one number, and optionally how it moved, what it is made of\n * and which way it has been going.\n *\n * Replaces keksdose's `KpiCard` and admin `Tile`, lenkbank's metrics tiles and\n * kastlan's `KpiCard` — the same card four times, with four answers to the same\n * questions (how a currency is formatted, whether a delta is judged, what happens to\n * the figures in demo mode).\n *\n * Interactive tiles use a STRETCHED link: the label is the `<a>`/`<button>`, and its\n * `::after` covers the card. Making the card itself the link would nest the hint's\n * `<button>` inside it, which is invalid HTML and an unreachable control; this way\n * the hint sits above the overlay and stays its own tab stop. The link's name is the\n * label, and it is described by the value and the delta.\n */\nexport function StatTile({\n label,\n value,\n currency,\n compact = false,\n numberFormat,\n format,\n locale,\n unit,\n valueLabel,\n tone = \"neutral\",\n delta,\n goodDirection,\n hint,\n truncateLabel = false,\n name,\n description,\n projection,\n subValues,\n trend,\n sparkline,\n icon,\n footer,\n sensitive = false,\n loading = false,\n size = \"md\",\n href,\n renderLink,\n onClick,\n labels,\n className,\n ...rest\n}: StatTileProps) {\n const text = useKitLabels(\"statTile\", DEFAULT_STAT_TILE_LABELS, labels);\n const kitLink = useKitLink();\n const [labelEl, setLabelEl] = useState<HTMLElement | null>(null);\n const labelCut = useIsTruncated(truncateLabel ? labelEl : null);\n const kitLocale = useKitLocale(locale);\n const id = useId();\n const valueId = `${id}-value`;\n const deltaId = `${id}-delta`;\n const priv = sensitive ? \"\" : undefined;\n\n const fmt =\n format ??\n ((n: number) =>\n numberFormatter(kitLocale, {\n ...(currency ? { style: \"currency\", currency } : null),\n ...(compact ? { notation: \"compact\" } : null),\n ...numberFormat,\n }).format(n));\n const show = (v: ReactNode) => (typeof v === \"number\" ? fmt(v) : v);\n\n const d = typeof delta === \"number\" ? { value: delta } : delta;\n const hasValue = value !== null && value !== undefined && value !== \"\";\n const labelText = name ?? plainText(label);\n\n // ── The label, which is also the link/button when the tile is interactive ──\n const stretched = cn(\n \"text-start uppercase tracking-wide\",\n \"after:absolute after:inset-0 after:rounded-lg after:content-['']\",\n \"focus-visible:outline-none focus-visible:after:ring-2 focus-visible:after:ring-inset focus-visible:after:ring-[var(--brand)]\",\n );\n const describedBy = d ? `${valueId} ${deltaId}` : valueId;\n let labelNode: ReactNode = label;\n if (href !== undefined) {\n const linkProps: StatTileLinkProps = {\n href,\n className: stretched,\n \"aria-describedby\": describedBy,\n children: label,\n };\n const kit = pickLinkRenderer<StatTileLinkProps>(undefined, kitLink, href);\n labelNode = renderLink ? (\n renderLink(linkProps)\n ) : kit ? (\n <RenderedKitLink render={kit} props={linkProps} />\n ) : (\n <a href={href} className={stretched} aria-describedby={describedBy}>\n {label}\n </a>\n );\n } else if (onClick) {\n labelNode = (\n <button type=\"button\" onClick={onClick} aria-describedby={describedBy} className={stretched}>\n {label}\n </button>\n );\n }\n const interactive = href !== undefined || onClick !== undefined;\n\n // ── Delta ──\n let deltaNode: ReactNode = null;\n if (d && !loading && Number.isFinite(d.value)) {\n const magnitude = Math.abs(d.value);\n const amount =\n d.unit === \"percent\"\n ? numberFormatter(kitLocale, { style: \"percent\", maximumFractionDigits: 1 }).format(magnitude)\n : fmt(magnitude);\n const dir = d.value > 0 ? \"up\" : d.value < 0 ? \"down\" : \"flat\";\n const Icon = dir === \"up\" ? ArrowUp : dir === \"down\" ? ArrowDown : Minus;\n let spoken = dir === \"up\" ? text.increase(amount) : dir === \"down\" ? text.decrease(amount) : text.unchanged;\n let deltaTone = \"text-[var(--text-muted)]\";\n if (goodDirection && dir !== \"flat\") {\n const good = dir === goodDirection;\n spoken = good ? text.better(spoken) : text.worse(spoken);\n deltaTone = good ? \"text-[var(--success)]\" : \"text-[var(--danger)]\";\n }\n deltaNode = (\n // `relative`: the containing block for the sr-only sentence (see the\n // sr-only-containment test for what an escaped one does to the page).\n <div className=\"relative mt-1 flex flex-wrap items-center gap-x-1.5 text-xs tabular-nums\">\n <span id={deltaId} data-private={priv} className={cn(\"inline-flex items-center gap-0.5 font-medium\", deltaTone)}>\n <Icon className=\"size-3 shrink-0\" aria-hidden />\n {/* The figure at zero too, like Delta (kastlan 48): \"0 %\" says the change was\n measured; a bare dash reads like a missing value. */}\n <span aria-hidden>{amount}</span>\n <span className=\"sr-only\">{spoken}</span>\n </span>\n {d.label != null && <span className=\"text-[var(--text-muted)]\">{d.label}</span>}\n </div>\n );\n }\n\n // ── Headline ──\n const valueClass = cn(\n \"whitespace-nowrap font-semibold tabular-nums\",\n VALUE_TEXT[size],\n toneClass(tone, value),\n );\n const headline = loading ? (\n <span id={valueId} className=\"relative block py-0.5\">\n <span aria-hidden className={cn(SKELETON_CLASS, \"block h-6 w-24 max-w-full\")} />\n <span className=\"sr-only\">{text.loading}</span>\n </span>\n ) : hasValue ? (\n <span id={valueId} data-private={priv} className={cn(valueClass, valueLabel != null && \"ms-auto\")}>\n {show(value)}\n {unit != null && (\n <span className=\"ms-1 text-[0.7em] font-medium text-[var(--text-muted)]\">{unit}</span>\n )}\n </span>\n ) : (\n <span id={valueId} className={cn(\"relative\", valueClass, \"text-[var(--text-muted)]\")}>\n <span aria-hidden>—</span>\n <span className=\"sr-only\">{text.noValue}</span>\n </span>\n );\n\n const graphic =\n loading ? null : sparkline ??\n (trend && (\n <Sparkline\n data={trend}\n fluid\n height={size === \"sm\" ? 20 : 28}\n label={labelText}\n formatValue={fmt}\n locale={kitLocale}\n tone={tone === \"signed\" ? \"signed\" : tone === \"neutral\" ? \"chart\" : tone}\n data-private={priv}\n />\n ));\n\n return (\n <Card\n {...rest}\n aria-busy={loading || undefined}\n className={cn(\n // @container: the headline sizes read the tile's width. relative: the stretched\n // link's overlay. overflow-hidden: at pathological widths a figure clips inside\n // the border instead of painting over the neighbouring tile.\n \"@container relative flex min-w-0 flex-col overflow-hidden\",\n PADDING[size],\n interactive && \"transition-colors hover:border-[var(--border-strong)] hover:bg-[var(--bg-hover)]\",\n className,\n )}\n >\n <div className=\"flex items-start gap-2\">\n <div className=\"flex min-w-0 flex-1 items-center gap-1 break-words text-xs uppercase tracking-wide text-[var(--text-muted)]\">\n {truncateLabel && labelCut ? (\n // Portalled: the tile is `overflow-hidden`, and a bubble inside it would be\n // clipped by the very box whose edge cut the label.\n <Tooltip label={label} side=\"top\" portal className=\"flex min-w-0\">\n <span ref={setLabelEl} className=\"block min-w-0 truncate\">\n {labelNode}\n </span>\n </Tooltip>\n ) : (\n <span ref={setLabelEl} className={cn(\"min-w-0\", truncateLabel && \"block truncate\")}>\n {labelNode}\n </span>\n )}\n {/* Above the stretched link's overlay, so it stays hoverable and focusable. */}\n {hint && <FieldHint label={hint} side=\"top\" className=\"relative z-10 shrink-0\" />}\n </div>\n {icon != null && (\n <span aria-hidden className=\"shrink-0 text-[var(--text-muted)] [&_svg]:size-4\">\n {icon}\n </span>\n )}\n </div>\n\n {valueLabel != null ? (\n // Period-left / value-right, matching the sub-rows below it. flex-wrap lets a\n // too-long amount drop to its own end-aligned line instead of overflowing.\n <div className=\"mt-1 flex flex-wrap items-baseline gap-x-1.5\">\n <span className=\"shrink-0 text-xs uppercase tracking-wide text-[var(--text-muted)]\">{valueLabel}</span>\n {headline}\n </div>\n ) : (\n <div className=\"mt-1\">{headline}</div>\n )}\n\n {!loading && projection && (\n <dl className=\"mt-0.5\">\n <FigureRow figure={projection} tileTone={tone} show={show} priv={priv} />\n </dl>\n )}\n\n {deltaNode}\n {description != null && <div className=\"mt-1 text-xs text-[var(--text-muted)]\">{description}</div>}\n {graphic && <div className=\"mt-2\">{graphic}</div>}\n\n {!loading && subValues && subValues.length > 0 && (\n <dl className=\"mt-1.5 space-y-0.5\">\n {subValues.map((sv, i) => (\n <FigureRow key={i} figure={sv} tileTone={tone} show={show} priv={priv} />\n ))}\n </dl>\n )}\n\n {footer != null && <div className=\"mt-2\">{footer}</div>}\n </Card>\n );\n}\n\n/** One label/value row of a `<dl>`: a sub-value, or the projection. */\nfunction FigureRow({\n figure,\n tileTone,\n show,\n priv,\n}: {\n figure: StatTileSubValue;\n tileTone: StatTileTone;\n show: (v: ReactNode) => ReactNode;\n priv: \"\" | undefined;\n}) {\n const own = figure.tone ?? (tileTone === \"neutral\" ? undefined : tileTone);\n const svTone = own === \"none\" ? undefined : own;\n return (\n <div className=\"flex flex-wrap items-baseline gap-x-2 text-xs tabular-nums text-[var(--text-muted)]\">\n <dt className=\"uppercase tracking-wide\">{figure.label}</dt>\n <dd\n data-private={priv}\n className={cn(\"ms-auto whitespace-nowrap\", svTone && [toneClass(svTone, figure.value), \"opacity-75\"])}\n >\n {show(figure.value)}\n </dd>\n </div>\n );\n}\n\n/**\n * The text of a label node, for the sparkline's name: a string or number as is, an\n * element by its children, recursively. A `<Tooltip label=\"…\">Spend</Tooltip>` label\n * yields \"Spend\" — the text the reader sees, not the bubble's. `undefined` when there\n * is no text to find (an icon), so the sparkline falls back to its unnamed summary.\n */\nfunction plainText(node: ReactNode): string | undefined {\n const walk = (n: ReactNode): string => {\n if (typeof n === \"string\" || typeof n === \"number\") return String(n);\n if (Array.isArray(n)) return n.map(walk).join(\"\");\n if (isValidElement<{ children?: ReactNode }>(n)) return walk(n.props.children);\n return \"\";\n };\n const text = walk(node).replace(/\\s+/g, \" \").trim();\n return text === \"\" ? undefined : text;\n}\n\n/**\n * Whether `el`'s text is wider than its box — i.e. whether `truncate` is cutting it.\n * Re-measured whenever the box resizes (a grid reflowing, a translation loading), not\n * only on mount. Keyed on the ELEMENT (a callback ref), because wrapping the label in\n * its tooltip remounts it, and an observer on the old node would watch nothing.\n */\nfunction useIsTruncated(el: HTMLElement | null): boolean {\n const [cut, setCut] = useState(false);\n useLayoutEffect(() => {\n // No reset while there is no node: between the unwrapped label unmounting and the\n // wrapped one mounting there is none, and a reset there would unwrap it again.\n if (!el) return;\n const measure = () => setCut(el.scrollWidth > el.clientWidth);\n measure();\n if (typeof ResizeObserver === \"undefined\") return;\n const observer = new ResizeObserver(measure);\n observer.observe(el);\n return () => observer.disconnect();\n }, [el]);\n return cut;\n}\n\nexport interface StatTileGridProps extends ComponentPropsWithoutRef<\"div\"> {\n /** The narrowest a tile may get before the grid drops a column. The default fits\n * two tiles on a 320px phone. */\n minTileWidth?: string;\n}\n\n/**\n * The row of tiles every dashboard in the three apps lays out by hand, each with its\n * own breakpoint ladder (`grid-cols-2 md:grid-cols-4 min-[2400px]:grid-cols-8` …).\n * Those ladders read the VIEWPORT and so ignored the sidebar: 6-up tiles at 155px in\n * the band where the sidebar was open. This fits as many columns as `minTileWidth`\n * allows in the space the grid actually has, and needs no breakpoint at all.\n */\nexport function StatTileGrid({ minTileWidth = \"9rem\", className, style, ...rest }: StatTileGridProps) {\n const columns: CSSProperties = {\n gridTemplateColumns: `repeat(auto-fit, minmax(min(100%, ${minTileWidth}), 1fr))`,\n };\n return <div {...rest} className={cn(\"grid gap-3\", className)} style={{ ...columns, ...style }} />;\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../src/components/stat-tile.tsx"],"sourcesContent":["import { isValidElement, useId, useLayoutEffect, useState } from \"react\";\nimport type { ComponentPropsWithoutRef, CSSProperties, ReactElement, ReactNode } from \"react\";\nimport { ArrowDown, ArrowUp, Minus } from \"lucide-react\";\nimport { cn } from \"../lib/cn\";\nimport { useKitLabels, useKitLink, useKitLocale } from \"../i18n/kit-labels\";\nimport { pickLinkRenderer, RenderedKitLink } from \"./text-link\";\nimport { Card, FieldHint } from \"./ui\";\nimport { Sparkline } from \"./sparkline\";\nimport { Tooltip } from \"./tooltip\";\n// The kit's one placeholder look, so a loading tile and the Skeleton list beside it\n// shimmer as one surface — and both stop under `prefers-reduced-motion`, which this\n// tile's private copy of the string did not.\nimport { SKELETON_CLASS } from \"./skeleton\";\n\n/**\n * The words a stat tile speaks. The delta's arrow is an icon, and an icon is not a\n * sentence: a screen reader gets the direction (and, under `goodDirection`, the\n * verdict) as words, and a sighted reader who cannot tell the two delta colours apart\n * still has the arrow — neither judgement rides on colour alone.\n */\nexport interface StatTileLabels {\n /** The delta, rising. `amount` is already formatted and unsigned. */\n increase: (amount: string) => string;\n decrease: (amount: string) => string;\n unchanged: string;\n /** Wraps the direction sentence when `goodDirection` says the change is good. */\n better: (change: string) => string;\n worse: (change: string) => string;\n /** Spoken in place of a missing value (the tile shows a dash). */\n noValue: string;\n /** Spoken while `loading`. */\n loading: string;\n}\n\nexport const DEFAULT_STAT_TILE_LABELS: StatTileLabels = {\n increase: (amount) => `Up ${amount}`,\n decrease: (amount) => `Down ${amount}`,\n unchanged: \"No change\",\n better: (change) => `${change} (better)`,\n worse: (change) => `${change} (worse)`,\n noValue: \"No data\",\n loading: \"Loading…\",\n};\n\n/**\n * What colours a figure. Token-backed like everything else in the kit.\n *\n * - `income` / `expense` / `net` — the money trio (teal / amber / violet, CVD-safe).\n * - `signed` — a money figure read by its own sign: above zero `income`, below zero\n * `expense`, exactly zero neutral (a zero balance is neither news). Needs a NUMBER\n * value; a pre-formatted string cannot be read and stays neutral.\n * - `success` / `warning` / `danger` / `info` — a threshold verdict (\"phase margin\n * below 30°\"). Say it in the hint or description as well; colour alone is not one.\n */\nexport type StatTileTone =\n | \"neutral\"\n | \"brand\"\n | \"income\"\n | \"expense\"\n | \"net\"\n | \"signed\"\n | \"success\"\n | \"warning\"\n | \"danger\"\n | \"info\";\n\nconst TONE_CLASS: Record<Exclude<StatTileTone, \"signed\">, string> = {\n neutral: \"text-[var(--text-primary)]\",\n brand: \"text-[var(--brand)]\",\n income: \"text-[var(--money-income)]\",\n expense: \"text-[var(--money-expense)]\",\n net: \"text-[var(--money-net)]\",\n success: \"text-[var(--success)]\",\n warning: \"text-[var(--warning)]\",\n danger: \"text-[var(--danger)]\",\n info: \"text-[var(--info)]\",\n};\n\nfunction toneClass(tone: StatTileTone, value: unknown): string {\n if (tone !== \"signed\") return TONE_CLASS[tone];\n if (typeof value !== \"number\" || value === 0) return TONE_CLASS.neutral;\n return value < 0 ? TONE_CLASS.expense : TONE_CLASS.income;\n}\n\n/** A change since the previous period. A bare number is shorthand for `{ value }`. */\nexport interface StatTileDelta {\n /** Signed: above zero is \"up\". */\n value: number;\n /** `\"value\"` (default) formats it like the headline — same currency, same\n * notation. `\"percent\"` reads it as a RATIO: `0.12` is shown as 12 %. */\n unit?: \"value\" | \"percent\";\n /** What it is measured against, shown after it: \"vs last month\". */\n label?: ReactNode;\n}\n\n/** A smaller figure under the headline — a prior period, a projection, a split. */\nexport interface StatTileSubValue {\n label: ReactNode;\n /** A number is formatted like the headline; anything else is rendered as given. */\n value: ReactNode;\n /** Defaults to the tile's own `tone`, read per figure (so a `signed` tile shows a\n * teal September above an amber August). Sub-values are always the muted weight\n * of their tone: they are history, and must not outshout the figure above them.\n *\n * `\"none\"` leaves the figure untinted — the row's own muted text, like its label —\n * even on a toned tile: keksdose's report KpiCard (reports/charts/kpi-card.tsx),\n * whose `negative` convention tints only a figure below zero and leaves the rest\n * plain. (`\"neutral\"` is not that: it is the headline's full text colour, at the\n * sub-value's 75 %.) */\n tone?: StatTileTone | \"none\";\n}\n\n/** What {@link StatTileProps.renderLink} is handed. Spread it onto your router's\n * link: the class is what stretches the link over the whole tile. */\nexport interface StatTileLinkProps {\n href: string;\n className: string;\n \"aria-describedby\": string;\n children: ReactNode;\n}\n\nexport interface StatTileProps\n extends Omit<ComponentPropsWithoutRef<\"div\">, \"children\" | \"onClick\"> {\n /** What is measured. Also the tile's accessible name when it is a link/button. */\n label: ReactNode;\n /**\n * The headline. A number is formatted with `Intl.NumberFormat` in the kit's locale\n * (see `currency`, `compact`, `numberFormat`, `format`); a string or element is\n * rendered as given. `null`/`undefined` shows a dash and says \"No data\".\n */\n value?: ReactNode;\n /** ISO 4217 code — formats a number value (and a `\"value\"` delta, and numeric\n * sub-values) as money. */\n currency?: string;\n /** Compact notation: 12 400 → \"12K\". */\n compact?: boolean;\n /** Any further `Intl.NumberFormat` options, merged over the two above. */\n numberFormat?: Intl.NumberFormatOptions;\n /** Formats every NUMBER the tile shows, replacing the `Intl` default outright. */\n format?: (value: number) => string;\n locale?: string;\n /** A short unit after the value that `Intl` has no name for: \"ms\", \"dB\", \"Hz\". */\n unit?: ReactNode;\n /** The period the headline belongs to, shown to its left (\"SEP\"). */\n valueLabel?: ReactNode;\n tone?: StatTileTone;\n delta?: number | StatTileDelta;\n /** Which way is good news. Unset, the delta is stated and not judged (users up and\n * cost up are the same arrow); set, it is coloured and its sentence says so. */\n goodDirection?: \"up\" | \"down\";\n /** The explanation behind a \"?\" beside the label (a {@link FieldHint}) — hidden\n * until asked for, so it is for what a reader MIGHT want to know (\"excludes\n * transfers between your own accounts\"). For a line everyone should read, use\n * {@link StatTileProps.description} instead. */\n hint?: string;\n /**\n * Keep the label to ONE line, cut with an ellipsis, and show the whole of it in a\n * {@link Tooltip} on hover and focus — but only while it is actually cut, so a\n * label that fits shows no bubble repeating itself. keksdose's admin MetricTile\n * wall: twenty tiles whose German labels (\"Aktive Abonnements (30 Tage)\") wrapped\n * onto two or three lines and pushed each tile's figure to a different height.\n *\n * The label element keeps the FULL text — the ellipsis is paint — so it is still\n * the tile's accessible name (and the stretched link's, on a link tile) exactly as\n * without this. Off by default: a wrapping label is what every existing tile does,\n * and a tile standing alone has room to.\n *\n * This is the tooltip: pass the label as a plain STRING rather than wrapping it in\n * your own `<Tooltip>` for the full text (keksdose's admin metrics-panel.tsx:134\n * does, from before this prop). A wrapped label shows a bubble even when nothing is\n * cut, and nests its trigger inside this one's.\n */\n truncateLabel?: boolean;\n /**\n * The tile's name as plain text, for the places that need a string rather than a\n * node — today the {@link StatTileProps.trend} sparkline's accessible name. Only\n * needed when `label` is not a string AND its text cannot be read from it: a label\n * element's text children are found on their own (a `<Tooltip label=\"…\">Spend\n * </Tooltip>` label still names its sparkline \"Spend\"), an icon or a formatted\n * message component's are not.\n */\n name?: string;\n /** A visible line under the value: \"Last 12 months\", \"3 estates / 9 buildings\" —\n * always shown, so it is for what every reader needs. An explanation only some\n * will want belongs in {@link StatTileProps.hint}, the \"?\" beside the label. */\n description?: ReactNode;\n /**\n * One figure that belongs WITH the headline rather than under it: keksdose's\n * \"Projected €1,850\" under a to-date \"€420\" (KpiCard, feedback #51), which it\n * currently smuggles into `description` with hand-set margins so the prior-month\n * sub-values do not come between the two. It is drawn directly under the value\n * (before the delta, the description and the sparkline) as a sub-value row, with\n * only a hair of space above it.\n *\n * A slot of its own rather than a placement switch for `subValues`: the history\n * rows (last month, last year) should stay at the bottom where they are, and moving\n * the whole list up to bring one row with it would put them between the headline\n * and its delta. Same shape as a sub-value, so the `tone` rules are the same.\n * Withheld while `loading`, like the sub-values.\n */\n projection?: StatTileSubValue;\n subValues?: StatTileSubValue[];\n /** Shorthand for a fluid {@link Sparkline} named after the tile and speaking in\n * the tile's number format. The name is the label's text; see\n * {@link StatTileProps.name} for a label that has none. */\n trend?: ReadonlyArray<number | null | undefined>;\n /** A custom sparkline (or any small graphic) in the same place. Wins over `trend`. */\n sparkline?: ReactNode;\n /** A decorative icon at the top end of the tile. */\n icon?: ReactNode;\n /** Anything below the rest — a progress bar, a link row. */\n footer?: ReactNode;\n /** Tag every figure `data-private`, so the host's demo-mode rule blurs it — the\n * same attribute `Tooltip redact` and the chart tooltip's value cell carry. */\n sensitive?: boolean;\n /** Skeleton in place of the value; delta, sub-values and trend are withheld. */\n loading?: boolean;\n /** `\"sm\"` for dense grids (a metrics wall); `\"md\"` the default KPI. */\n size?: \"sm\" | \"md\";\n /** Makes the tile a link. See {@link renderLink} for a router. */\n href?: string;\n /** Renders the link for `href` — pass your router's `<Link>` here, since a plain\n * `<a>` reloads a single-page app. Default: the `<UiKitProvider linkComponent>`\n * (for an in-app `href`), then `<a>`. */\n renderLink?: (props: StatTileLinkProps) => ReactElement;\n /** Makes the tile a button (ignored when `href` is given). */\n onClick?: () => void;\n labels?: Partial<StatTileLabels>;\n}\n\n// One formatter per option set: a metrics wall is twenty of these.\nconst formatters = new Map<string, Intl.NumberFormat>();\nfunction numberFormatter(locale: string | undefined, options: Intl.NumberFormatOptions): Intl.NumberFormat {\n const key = `${locale ?? \"\"}|${JSON.stringify(options)}`;\n let f = formatters.get(key);\n if (!f) {\n f = new Intl.NumberFormat(locale, options);\n formatters.set(key, f);\n }\n return f;\n}\n\n// The headline sizes are keyed on the TILE's width (it is a @container), not the\n// viewport: a grid beside a sidebar makes tiles narrowest exactly where a viewport\n// breakpoint would hand them the larger font. `sm` is keksdose's compact headline,\n// sized so a worst-case \"CHF 123,456.78\" still fits a 320px phone's 2-up tiles.\nconst VALUE_TEXT = {\n md: \"text-xl @[13rem]:text-2xl\",\n sm: \"text-xs @[8.5rem]:text-sm @[13rem]:text-base\",\n} as const;\nconst PADDING = { md: \"p-3 sm:p-4\", sm: \"px-2.5 py-2\" } as const;\n\n/**\n * The KPI card: a label, one number, and optionally how it moved, what it is made of\n * and which way it has been going.\n *\n * Replaces keksdose's `KpiCard` and admin `Tile`, lenkbank's metrics tiles and\n * kastlan's `KpiCard` — the same card four times, with four answers to the same\n * questions (how a currency is formatted, whether a delta is judged, what happens to\n * the figures in demo mode).\n *\n * Interactive tiles use a STRETCHED link: the label is the `<a>`/`<button>`, and its\n * `::after` covers the card. Making the card itself the link would nest the hint's\n * `<button>` inside it, which is invalid HTML and an unreachable control; this way\n * the hint sits above the overlay and stays its own tab stop. The link's name is the\n * label, and it is described by the value and the delta.\n */\nexport function StatTile({\n label,\n value,\n currency,\n compact = false,\n numberFormat,\n format,\n locale,\n unit,\n valueLabel,\n tone = \"neutral\",\n delta,\n goodDirection,\n hint,\n truncateLabel = false,\n name,\n description,\n projection,\n subValues,\n trend,\n sparkline,\n icon,\n footer,\n sensitive = false,\n loading = false,\n size = \"md\",\n href,\n renderLink,\n onClick,\n labels,\n className,\n ...rest\n}: StatTileProps) {\n const text = useKitLabels(\"statTile\", DEFAULT_STAT_TILE_LABELS, labels);\n const kitLink = useKitLink();\n const [labelEl, setLabelEl] = useState<HTMLElement | null>(null);\n const labelCut = useIsTruncated(truncateLabel ? labelEl : null);\n const kitLocale = useKitLocale(locale);\n const id = useId();\n const valueId = `${id}-value`;\n const deltaId = `${id}-delta`;\n const priv = sensitive ? \"\" : undefined;\n\n const fmt =\n format ??\n ((n: number) =>\n numberFormatter(kitLocale, {\n ...(currency ? { style: \"currency\", currency } : null),\n ...(compact ? { notation: \"compact\" } : null),\n ...numberFormat,\n }).format(n));\n const show = (v: ReactNode) => (typeof v === \"number\" ? fmt(v) : v);\n\n const d = typeof delta === \"number\" ? { value: delta } : delta;\n const hasValue = value !== null && value !== undefined && value !== \"\";\n const labelText = name ?? plainText(label);\n\n // ── The label, which is also the link/button when the tile is interactive ──\n const stretched = cn(\n \"text-start uppercase tracking-wide\",\n \"after:absolute after:inset-0 after:rounded-lg after:content-['']\",\n \"focus-visible:outline-none focus-visible:after:ring-2 focus-visible:after:ring-inset focus-visible:after:ring-[var(--brand)]\",\n );\n const describedBy = d ? `${valueId} ${deltaId}` : valueId;\n let labelNode: ReactNode = label;\n if (href !== undefined) {\n const linkProps: StatTileLinkProps = {\n href,\n className: stretched,\n \"aria-describedby\": describedBy,\n children: label,\n };\n const kit = pickLinkRenderer<StatTileLinkProps>(undefined, kitLink, href);\n labelNode = renderLink ? (\n renderLink(linkProps)\n ) : kit ? (\n <RenderedKitLink render={kit} props={linkProps} />\n ) : (\n <a href={href} className={stretched} aria-describedby={describedBy}>\n {label}\n </a>\n );\n } else if (onClick) {\n labelNode = (\n <button type=\"button\" onClick={onClick} aria-describedby={describedBy} className={stretched}>\n {label}\n </button>\n );\n }\n const interactive = href !== undefined || onClick !== undefined;\n\n // ── Delta ──\n let deltaNode: ReactNode = null;\n if (d && !loading && Number.isFinite(d.value)) {\n const magnitude = Math.abs(d.value);\n const amount =\n d.unit === \"percent\"\n ? numberFormatter(kitLocale, { style: \"percent\", maximumFractionDigits: 1 }).format(magnitude)\n : fmt(magnitude);\n const dir = d.value > 0 ? \"up\" : d.value < 0 ? \"down\" : \"flat\";\n const Icon = dir === \"up\" ? ArrowUp : dir === \"down\" ? ArrowDown : Minus;\n let spoken = dir === \"up\" ? text.increase(amount) : dir === \"down\" ? text.decrease(amount) : text.unchanged;\n let deltaTone = \"text-[var(--text-muted)]\";\n if (goodDirection && dir !== \"flat\") {\n const good = dir === goodDirection;\n spoken = good ? text.better(spoken) : text.worse(spoken);\n deltaTone = good ? \"text-[var(--success)]\" : \"text-[var(--danger)]\";\n }\n deltaNode = (\n // `relative`: the containing block for the sr-only sentence (see the\n // sr-only-containment test for what an escaped one does to the page).\n <div className=\"relative mt-1 flex flex-wrap items-center gap-x-1.5 text-xs tabular-nums\">\n <span id={deltaId} data-private={priv} className={cn(\"inline-flex items-center gap-0.5 font-medium\", deltaTone)}>\n <Icon className=\"size-3 shrink-0\" aria-hidden />\n {/* The figure at zero too, like Delta (kastlan 48): \"0 %\" says the change was\n measured; a bare dash reads like a missing value. */}\n <span aria-hidden>{amount}</span>\n <span className=\"sr-only\">{spoken}</span>\n </span>\n {d.label != null && <span className=\"text-[var(--text-muted)]\">{d.label}</span>}\n </div>\n );\n }\n\n // ── Headline ──\n const valueClass = cn(\n \"whitespace-nowrap font-semibold tabular-nums\",\n VALUE_TEXT[size],\n toneClass(tone, value),\n );\n const headline = loading ? (\n <span id={valueId} className=\"relative block py-0.5\">\n <span aria-hidden className={cn(SKELETON_CLASS, \"block h-6 w-24 max-w-full\")} />\n <span className=\"sr-only\">{text.loading}</span>\n </span>\n ) : hasValue ? (\n <span id={valueId} data-private={priv} className={cn(valueClass, valueLabel != null && \"ms-auto\")}>\n {show(value)}\n {unit != null && (\n <span className=\"ms-1 text-[0.7em] font-medium text-[var(--text-muted)]\">{unit}</span>\n )}\n </span>\n ) : (\n <span id={valueId} className={cn(\"relative\", valueClass, \"text-[var(--text-muted)]\")}>\n <span aria-hidden>—</span>\n <span className=\"sr-only\">{text.noValue}</span>\n </span>\n );\n\n const graphic =\n loading ? null : sparkline ??\n (trend && (\n <Sparkline\n data={trend}\n fluid\n height={size === \"sm\" ? 20 : 28}\n label={labelText}\n formatValue={fmt}\n locale={kitLocale}\n tone={tone === \"signed\" ? \"signed\" : tone === \"neutral\" ? \"chart\" : tone}\n data-private={priv}\n />\n ));\n\n return (\n <Card\n {...rest}\n aria-busy={loading || undefined}\n className={cn(\n // @container: the headline sizes read the tile's width. relative: the stretched\n // link's overlay. overflow-hidden: at pathological widths a figure clips inside\n // the border instead of painting over the neighbouring tile.\n \"@container relative flex min-w-0 flex-col overflow-hidden\",\n PADDING[size],\n interactive && \"transition-colors hover:border-[var(--border-strong)] hover:bg-[var(--bg-hover)]\",\n className,\n )}\n >\n <div className=\"flex items-start gap-2\">\n <div className=\"flex min-w-0 flex-1 items-center gap-1 break-words text-xs uppercase tracking-wide text-[var(--text-muted)]\">\n {truncateLabel && labelCut ? (\n // Portalled: the tile is `overflow-hidden`, and a bubble inside it would be\n // clipped by the very box whose edge cut the label.\n <Tooltip label={label} side=\"top\" portal className=\"flex min-w-0\">\n <span ref={setLabelEl} className=\"block min-w-0 truncate\">\n {labelNode}\n </span>\n </Tooltip>\n ) : (\n <span ref={setLabelEl} className={cn(\"min-w-0\", truncateLabel && \"block truncate\")}>\n {labelNode}\n </span>\n )}\n {/* Above the stretched link's overlay, so it stays hoverable and focusable. */}\n {hint && <FieldHint label={hint} side=\"top\" className=\"relative z-10 shrink-0\" />}\n </div>\n {icon != null && (\n <span aria-hidden className=\"shrink-0 text-[var(--text-muted)] [&_svg]:size-4\">\n {icon}\n </span>\n )}\n </div>\n\n {valueLabel != null ? (\n // Period-left / value-right, matching the sub-rows below it. flex-wrap lets a\n // too-long amount drop to its own end-aligned line instead of overflowing.\n <div className=\"mt-1 flex flex-wrap items-baseline gap-x-1.5\">\n <span className=\"shrink-0 text-xs uppercase tracking-wide text-[var(--text-muted)]\">{valueLabel}</span>\n {headline}\n </div>\n ) : (\n <div className=\"mt-1\">{headline}</div>\n )}\n\n {!loading && projection && (\n <dl className=\"mt-0.5\">\n <FigureRow figure={projection} tileTone={tone} show={show} priv={priv} />\n </dl>\n )}\n\n {deltaNode}\n {description != null && <div className=\"mt-1 text-xs text-[var(--text-muted)]\">{description}</div>}\n {graphic && <div className=\"mt-2\">{graphic}</div>}\n\n {!loading && subValues && subValues.length > 0 && (\n <dl className=\"mt-1.5 space-y-0.5\">\n {subValues.map((sv, i) => (\n <FigureRow key={i} figure={sv} tileTone={tone} show={show} priv={priv} />\n ))}\n </dl>\n )}\n\n {footer != null && <div className=\"mt-2\">{footer}</div>}\n </Card>\n );\n}\n\n/** One label/value row of a `<dl>`: a sub-value, or the projection. */\nfunction FigureRow({\n figure,\n tileTone,\n show,\n priv,\n}: {\n figure: StatTileSubValue;\n tileTone: StatTileTone;\n show: (v: ReactNode) => ReactNode;\n priv: \"\" | undefined;\n}) {\n const own = figure.tone ?? (tileTone === \"neutral\" ? undefined : tileTone);\n const svTone = own === \"none\" ? undefined : own;\n // The LABEL gives way, never the figure. This row used to wrap, so on a narrow tile a\n // long label (\"AUG (SIGNED)\") dropped its amount onto a line of its own while the\n // next row stayed on one — two layouts in one list, and a figure no longer beside\n // the period it belongs to. Now the label breaks within itself and the amount stays\n // on the label's first line, end-aligned like every other row.\n return (\n <div className=\"flex items-baseline gap-x-2 text-xs tabular-nums text-[var(--text-muted)]\">\n <dt className=\"min-w-0 uppercase tracking-wide [overflow-wrap:anywhere]\">{figure.label}</dt>\n <dd\n data-private={priv}\n className={cn(\"ms-auto shrink-0 whitespace-nowrap\", svTone && [toneClass(svTone, figure.value), \"opacity-75\"])}\n >\n {show(figure.value)}\n </dd>\n </div>\n );\n}\n\n/**\n * The text of a label node, for the sparkline's name: a string or number as is, an\n * element by its children, recursively. A `<Tooltip label=\"…\">Spend</Tooltip>` label\n * yields \"Spend\" — the text the reader sees, not the bubble's. `undefined` when there\n * is no text to find (an icon), so the sparkline falls back to its unnamed summary.\n */\nfunction plainText(node: ReactNode): string | undefined {\n const walk = (n: ReactNode): string => {\n if (typeof n === \"string\" || typeof n === \"number\") return String(n);\n if (Array.isArray(n)) return n.map(walk).join(\"\");\n if (isValidElement<{ children?: ReactNode }>(n)) return walk(n.props.children);\n return \"\";\n };\n const text = walk(node).replace(/\\s+/g, \" \").trim();\n return text === \"\" ? undefined : text;\n}\n\n/**\n * Whether `el`'s text is wider than its box — i.e. whether `truncate` is cutting it.\n * Re-measured whenever the box resizes (a grid reflowing, a translation loading), not\n * only on mount. Keyed on the ELEMENT (a callback ref), because wrapping the label in\n * its tooltip remounts it, and an observer on the old node would watch nothing.\n */\nfunction useIsTruncated(el: HTMLElement | null): boolean {\n const [cut, setCut] = useState(false);\n useLayoutEffect(() => {\n // No reset while there is no node: between the unwrapped label unmounting and the\n // wrapped one mounting there is none, and a reset there would unwrap it again.\n if (!el) return;\n const measure = () => setCut(el.scrollWidth > el.clientWidth);\n measure();\n if (typeof ResizeObserver === \"undefined\") return;\n const observer = new ResizeObserver(measure);\n observer.observe(el);\n return () => observer.disconnect();\n }, [el]);\n return cut;\n}\n\nexport interface StatTileGridProps extends ComponentPropsWithoutRef<\"div\"> {\n /** The narrowest a tile may get before the grid drops a column. The default fits\n * two tiles on a 320px phone. */\n minTileWidth?: string;\n}\n\n/**\n * The row of tiles every dashboard in the three apps lays out by hand, each with its\n * own breakpoint ladder (`grid-cols-2 md:grid-cols-4 min-[2400px]:grid-cols-8` …).\n * Those ladders read the VIEWPORT and so ignored the sidebar: 6-up tiles at 155px in\n * the band where the sidebar was open. This fits as many columns as `minTileWidth`\n * allows in the space the grid actually has, and needs no breakpoint at all.\n */\nexport function StatTileGrid({ minTileWidth = \"9rem\", className, style, ...rest }: StatTileGridProps) {\n const columns: CSSProperties = {\n gridTemplateColumns: `repeat(auto-fit, minmax(min(100%, ${minTileWidth}), 1fr))`,\n };\n return <div {...rest} className={cn(\"grid gap-3\", className)} style={{ ...columns, ...style }} />;\n}\n"],"mappings":"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","names":[]}
@@ -1,7 +1,7 @@
1
1
  import * as react from 'react';
2
2
  import { ReactElement, ComponentPropsWithoutRef, ReactNode, Ref, MouseEvent, KeyboardEvent } from 'react';
3
3
  import { LucideIcon } from 'lucide-react';
4
- import { az as KitLinkComponent } from '../kit-labels-Mz8zZHIF.js';
4
+ import { az as KitLinkComponent } from '../kit-labels-BHfRBbHs.js';
5
5
  import './data-table-labels.js';
6
6
  import './mini-calendar.js';
7
7
  import './calendar-heatmap.js';
@@ -1,5 +1,5 @@
1
1
  import * as react from 'react';
2
- import { ay as InputProps } from '../kit-labels-Mz8zZHIF.js';
2
+ import { ay as InputProps } from '../kit-labels-BHfRBbHs.js';
3
3
  import './data-table-labels.js';
4
4
  import './mini-calendar.js';
5
5
  import './calendar-heatmap.js';
@@ -22,8 +22,9 @@ interface TreemapNode {
22
22
  value: number;
23
23
  /** Overrides the by-index palette colour for this tile — e.g. to paint every child
24
24
  * in its parent group's colour, so the groups stay legible in one picture. A
25
- * concrete `#rrggbb` keeps the label ink measured; anything else (a CSS var) gets
26
- * `currentColor` ink, because there is nothing to measure — set `labelColor` then. */
25
+ * concrete `#rrggbb` or a bare `var(--chart-N)` keeps the label ink measured;
26
+ * anything else (another var, a `color-mix()`) gets `currentColor` ink, because
27
+ * there is nothing to measure — set `labelColor` then. */
27
28
  fill?: string;
28
29
  /** The label's ink, for a `fill` the kit cannot measure (a `var()` or `color-mix()`):
29
30
  * keksdose's recency plot fills with `color-mix(in srgb, var(--chart-1) N%,
@@ -29,6 +29,14 @@ function useChartTokenHex() {
29
29
  const joined = useSyncExternalStore(subscribeChartTokens, readChartTokens, () => SERVER_TOKENS);
30
30
  return joined.split(",");
31
31
  }
32
+ const CHART_TOKEN_REF = /^var\(\s*--chart-(\d+)\s*\)$/;
33
+ function measurableFill(fill, tokens) {
34
+ if (parseHex(fill)) return fill;
35
+ const m = CHART_TOKEN_REF.exec(fill.trim());
36
+ if (!m) return null;
37
+ const hex = tokens[Number(m[1]) - 1];
38
+ return hex && parseHex(hex) ? hex : null;
39
+ }
32
40
  const LABEL_FONT_SIZE = 12;
33
41
  const NOTE_FONT_SIZE = 11;
34
42
  function fitLabel(name, boxWidth, fontSize) {
@@ -51,13 +59,16 @@ function TreemapCell({
51
59
  nodeNote,
52
60
  dir = "ltr"
53
61
  }) {
62
+ const tokenHex = useChartTokenHex();
54
63
  if (!depth) return null;
55
64
  const px = Math.round(x);
56
65
  const py = Math.round(y);
57
66
  const pw = Math.round(width);
58
67
  const ph = Math.round(height);
59
68
  const tileFill = fill ?? "var(--chart-1)";
60
- const ink = labelColor ?? textOn(tileFill);
69
+ const measured = measurableFill(tileFill, tokenHex);
70
+ const ink = labelColor ?? (measured ? textOn(measured) : "currentColor");
71
+ const halo = measured ? tileFill : "none";
61
72
  const label = pw >= 44 && ph >= 20 ? fitLabel(name ?? "", pw, LABEL_FONT_SIZE) : null;
62
73
  const rawNote = id != null && nodeNote ? nodeNote(id) : void 0;
63
74
  const note = label != null && rawNote != null && ph >= 36 ? fitLabel(rawNote, pw, NOTE_FONT_SIZE) : null;
@@ -101,7 +112,7 @@ function TreemapCell({
101
112
  y: py + 15,
102
113
  direction: dir,
103
114
  fill: ink,
104
- stroke: tileFill,
115
+ stroke: halo,
105
116
  strokeWidth: 2.5,
106
117
  strokeLinejoin: "round",
107
118
  paintOrder: "stroke",
@@ -119,7 +130,7 @@ function TreemapCell({
119
130
  direction: dir,
120
131
  fill: ink,
121
132
  fillOpacity: 0.85,
122
- stroke: tileFill,
133
+ stroke: halo,
123
134
  strokeWidth: 2.5,
124
135
  strokeLinejoin: "round",
125
136
  paintOrder: "stroke",
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/components/treemap.tsx"],"sourcesContent":["// A tile chart (\"treemap\"): one rectangle per node, its AREA the node's share of what\n// is drawn. Lifted out of keksdose's reports, where five cards drew it (spending by\n// group and by category, net worth by account and by type, allocation, payees, food\n// groups) — every domain word is gone and every string it could print is a prop.\n//\n// It uses the kit's own chart shell (`ChartContainer`, `ChartTooltipContent`) and the\n// kit's own ink rule (`textOn`), so it re-skins with the palette like every other\n// chart. The one thing it cannot take from a CSS var is the tile colour: the label ink\n// is MEASURED against the fill, and a `var(--chart-3)` string measures as nothing — see\n// `useChartTokenHex` below for how the concrete hex is found instead.\nimport { useLayoutEffect, useRef, useState, useSyncExternalStore } from \"react\";\nimport type { ComponentProps, ComponentPropsWithoutRef, KeyboardEvent, ReactNode } from \"react\";\nimport { Treemap as RechartsTreemap } from \"recharts\";\nimport { ChartContainer, ChartTooltip, ChartTooltipContent, type ChartConfig } from \"./chart\";\nimport { PALETTE_HEX, textOn } from \"../theme/chart-palette\";\nimport { parseHex } from \"../theme/color\";\nimport { dirOf, type Direction } from \"../lib/direction\";\n\n/**\n * One tile.\n *\n * `value`, not keksdose's `total`: a treemap's areas are not always money (keksdose\n * already sizes one by transaction COUNT), and `value` is the word every other chart\n * in recharts uses for the number a mark is sized by. Migrating is a rename at the\n * mapping step — `{ id, name, value: row.total }`.\n */\nexport interface TreemapNode {\n /** Stable key, handed back by {@link TreemapProps.onNodeClick} and\n * {@link TreemapProps.nodeNote}. Names can collide; ids must not. */\n id: string;\n name: string;\n /** The tile's area. A node whose value is not a positive finite number is NOT\n * drawn — an area cannot be negative, and recharts lays a negative size out as\n * nonsense rather than refusing it. Saying so is the caller's job: a group that\n * vanishes silently between two views of the same data is a bug report. */\n value: number;\n /** Overrides the by-index palette colour for this tile — e.g. to paint every child\n * in its parent group's colour, so the groups stay legible in one picture. A\n * concrete `#rrggbb` keeps the label ink measured; anything else (a CSS var) gets\n * `currentColor` ink, because there is nothing to measure — set `labelColor` then. */\n fill?: string;\n /** The label's ink, for a `fill` the kit cannot measure (a `var()` or `color-mix()`):\n * keksdose's recency plot fills with `color-mix(in srgb, var(--chart-1) N%,\n * transparent)`, where `currentColor` put dark page text on full-strength indigo\n * (~1.5:1). Wins over the measured ink when given. */\n labelColor?: string;\n}\n\n/**\n * Everything a `<div>` takes reaches the chart's root (`ChartContainer`), so a test id\n * or a `data-tour` anchor can find it. `children` is the chart's own.\n */\nexport interface TreemapProps extends Omit<ComponentPropsWithoutRef<\"div\">, \"children\"> {\n data: TreemapNode[];\n /**\n * Formats a node's value in the tooltip. Handed the node's SHARE of what is drawn\n * (0–1) as a second argument, because a tile is read for how big it is relative to\n * the rest, and \"€412.30\" alone makes the reader estimate that ratio off the areas.\n * The share is `undefined` when the drawn total is not positive.\n *\n * Default: the bare number through `Intl.NumberFormat` in the runtime's locale. A\n * host that has a currency or a locale passes this — the kit knows neither.\n */\n valueFormatter?: (value: number, share: number | undefined) => ReactNode;\n /** Chart height in px. Default 320. */\n height?: number;\n /** A tile was activated — by click, or by Enter/Space on a focused tile. Setting\n * this makes every tile a focusable `role=\"button\"` named by its node name. */\n onNodeClick?: (id: string, name: string) => void;\n /**\n * The node names are the user's own data (payees, not categories): the tile labels\n * and the whole tooltip bubble get `data-private`, the same attribute the kit's\n * `Tooltip redact` and `ChartTooltipContent`'s value cell carry, so the host's one\n * privacy rule reaches them. Off by default — a category name is a label of the app.\n */\n redactNames?: boolean;\n /**\n * A second, smaller line under a tile's label — one extra figure per node that the\n * name cannot carry (a year-on-year `+12%`, `new`). Return `undefined` for a node\n * with nothing to add. Never tagged `data-private`: a ratio reveals no amount.\n *\n * ON THE TILE rather than in the tooltip, for two reasons. The tooltip identifies a\n * node by name or by raw value, so a per-node note would be keyed on an amount (two\n * nodes of equal size would swap notes). And a tooltip is a pointer affordance: a\n * phone never hovers, so a figure that only exists on hover does not exist there.\n */\n nodeNote?: (id: string) => string | undefined;\n /**\n * Draw at most this many tiles (the first N drawable nodes, in the order given —\n * sort first). Unbounded by default. A tile under ~44x20px has no room for a label,\n * and a map made of unlabelled rectangles answers nothing; around 24 is where the\n * smallest tile of a skewed series reaches that floor on a phone-width card. The\n * caller owns the \"N more not shown\" line, since it knows how many it passed.\n */\n maxTiles?: number;\n /**\n * The categorical ramp tiles take by index, as concrete hex. Default: the page's\n * live `--chart-1…9` tokens (see `useChartTokenHex`). Pass one when the host\n * already holds the active palette — e.g. `createPaletteStore`'s `useChartHex()` —\n * so a caller colouring a legend beside the map is guaranteed the same list.\n */\n colors?: readonly string[];\n}\n\n// ── The categorical ramp, as concrete hex ──────────────────────────────────\n//\n// The label ink is measured against the tile's fill, so the fill has to be a colour\n// and not a reference to one. keksdose read it from its palette STORE, which is an\n// app object the kit cannot import. What the kit does own is the token layer: whether\n// a palette arrives through `applyTokenSet` (inline properties on <html>) or through\n// tokens.css (`:root` / `.dark`), the resolved `--chart-N` on <html> is the colour\n// every other chart on the page is being painted with. So that is what is read, and\n// re-read whenever <html>'s class or style changes — which is exactly when a theme\n// toggle or a palette switch lands.\n\nconst CHART_TOKENS = 9;\n\n/** The resolved ramp, joined into ONE string so `useSyncExternalStore` compares a\n * primitive: a fresh array per read would never be `Object.is`-equal to the last and\n * would re-render forever. */\nfunction readChartTokens(): string {\n const root = document.documentElement;\n const fallback = root.classList.contains(\"dark\") ? PALETTE_HEX.dark : PALETTE_HEX.light;\n const style = getComputedStyle(root);\n return Array.from({ length: CHART_TOKENS }, (_, i) => {\n const v = style.getPropertyValue(`--chart-${i + 1}`).trim();\n // An unresolved token (a host that never loaded tokens.css, a test DOM) falls\n // back to the built-in ramp for the current theme rather than to no colour.\n return parseHex(v) ? v : fallback[i];\n }).join(\",\");\n}\n\nfunction subscribeChartTokens(onChange: () => void): () => void {\n const observer = new MutationObserver(onChange);\n observer.observe(document.documentElement, {\n attributes: true,\n attributeFilter: [\"class\", \"style\", \"data-theme\"],\n });\n return () => observer.disconnect();\n}\n\nconst SERVER_TOKENS = PALETTE_HEX.light.join(\",\");\n\nfunction useChartTokenHex(): string[] {\n const joined = useSyncExternalStore(subscribeChartTokens, readChartTokens, () => SERVER_TOKENS);\n return joined.split(\",\");\n}\n\n// ── Tile ──────────────────────────────────────────────────────────────────\n\nconst LABEL_FONT_SIZE = 12;\n/** The optional second line, a step down from the name so the tile reads name-first. */\nconst NOTE_FONT_SIZE = 11;\n\n/**\n * Fit a label to its tile: the text itself when it fits, an ellipsised head when it\n * does not, `null` when not even three characters would. Without this a long name ran\n * on across its neighbours and collided with THEIR labels. 0.58em is the average\n * advance of a UI sans at this weight; it only needs to be close, since the tile keeps\n * 6px of padding either side. The full name stays in the tooltip.\n */\nexport function fitLabel(name: string, boxWidth: number, fontSize: number): string | null {\n const max = Math.floor((boxWidth - 12) / (fontSize * 0.58));\n if (max < 3) return null;\n return name.length <= max ? name : `${name.slice(0, max - 1).trimEnd()}…`;\n}\n\n/** What recharts hands a `content` element for each node, plus the three options the\n * chart forwards. Everything optional because recharts clones the element it was\n * given and fills the geometry in — the element as written carries only the options. */\nexport interface TreemapCellProps {\n depth?: number;\n x?: number;\n y?: number;\n width?: number;\n height?: number;\n name?: string;\n fill?: string;\n /** See {@link TreemapNode.labelColor}; recharts hands each node's fields to its cell. */\n labelColor?: string;\n id?: string;\n onNodeClick?: (id: string, name: string) => void;\n redactNames?: boolean;\n nodeNote?: (id: string) => string | undefined;\n /** The page's reading direction, which `Treemap` reads off its own root. In `rtl`\n * the label stands in the tile's top RIGHT corner and is shaped right-to-left, so a\n * clipped name ends in its ellipsis on the reading end. Default `ltr`. */\n dir?: Direction;\n}\n\n/**\n * One tile. Exported so a caller drawing its own recharts `<Treemap>` gets the same\n * label rules.\n *\n * recharts' default cell inherits the chart-level `stroke`, an SVG presentation\n * attribute that lands on the `<svg>` root and cascades into the label — so the glyphs\n * were painted with a 1px outline ON TOP of themselves, which reads as blur. This cell\n * draws its own label with a halo in the TILE colour painted BEHIND the fill\n * (`paint-order: stroke`), which defeats any inherited stroke and keeps the text solid\n * where it overlaps a lighter or darker part of its own tile.\n */\nexport function TreemapCell({\n depth,\n x = 0,\n y = 0,\n width = 0,\n height = 0,\n name,\n fill,\n labelColor,\n id,\n onNodeClick,\n redactNames,\n nodeNote,\n dir = \"ltr\",\n}: TreemapCellProps) {\n // Depth 0 is recharts' synthetic root, which spans the whole chart.\n if (!depth) return null;\n // Whole pixels: a rect on a half pixel renders its edge across two half-lit\n // columns, and the text baseline inherits that blur.\n const px = Math.round(x);\n const py = Math.round(y);\n const pw = Math.round(width);\n const ph = Math.round(height);\n const tileFill = fill ?? \"var(--chart-1)\";\n const ink = labelColor ?? textOn(tileFill);\n // Below ~44x20 there is no room for a readable word, so no label at all rather than\n // a one-letter stub bleeding over the tile edge.\n const label = pw >= 44 && ph >= 20 ? fitLabel(name ?? \"\", pw, LABEL_FONT_SIZE) : null;\n // Only under a label that is itself drawn — a stray \"+12%\" on an unnamed rectangle\n // names nothing — and only where a second baseline fits: the note's baseline sits\n // 29px down and an 11px glyph hangs ~2px below it, so 36 leaves the same ~5px under\n // the note that the label keeps under itself at the 20px floor.\n const rawNote = id != null && nodeNote ? nodeNote(id) : undefined;\n const note =\n label != null && rawNote != null && ph >= 36 ? fitLabel(rawNote, pw, NOTE_FONT_SIZE) : null;\n // The label's START edge, 6px in from the tile's. The tiles themselves are laid out\n // physically (the plot is not mirrored, see `ChartContainer`) and the chart's SVG is\n // pinned `ltr`, so the direction is set on each <text> explicitly: `start` is then\n // the right end in RTL, and the label grows leftwards from the tile's right edge.\n const textX = dir === \"rtl\" ? px + pw - 6 : px + 6;\n const activate = id != null && onNodeClick ? () => onNodeClick(id, name ?? \"\") : undefined;\n const onKeyDown = activate\n ? (e: KeyboardEvent<SVGGElement>) => {\n if (e.key !== \"Enter\" && e.key !== \" \") return;\n e.preventDefault();\n activate();\n }\n : undefined;\n return (\n <g\n onClick={activate}\n onKeyDown={onKeyDown}\n // A clickable tile is a button to a keyboard and a screen reader too; the\n // drilldown it opens was mouse-only in the app this came from. Named by the node\n // name, which is also why the group carries `data-private` under `redactNames`.\n role={activate ? \"button\" : undefined}\n tabIndex={activate ? 0 : undefined}\n aria-label={activate ? name : undefined}\n data-private={activate && redactNames ? \"\" : undefined}\n cursor={activate ? \"pointer\" : undefined}\n // CSS beats a presentation attribute, so this recolours the rect's own stroke\n // for as long as the tile has keyboard focus.\n className={activate ? \"outline-none [&:focus-visible>rect]:stroke-[var(--brand)]\" : undefined}\n >\n {/* The 2px gutter is the card showing through, not a white grid — on a dark\n surface white lines glare harder than the tiles. */}\n <rect\n x={px}\n y={py}\n width={pw}\n height={ph}\n fill={tileFill}\n stroke=\"var(--bg-surface)\"\n strokeWidth={2}\n rx={3}\n />\n {label != null && (\n <text\n // A plain attribute, so the host's `[data-private]` rule reaches an SVG\n // <text> exactly as it reaches a table cell.\n data-private={redactNames ? \"\" : undefined}\n x={textX}\n y={py + 15}\n direction={dir}\n fill={ink}\n stroke={tileFill}\n strokeWidth={2.5}\n strokeLinejoin=\"round\"\n paintOrder=\"stroke\"\n fontSize={LABEL_FONT_SIZE}\n fontWeight={600}\n style={{ pointerEvents: \"none\" }}\n >\n {label}\n </text>\n )}\n {note != null && (\n <text\n x={textX}\n y={py + 29}\n direction={dir}\n fill={ink}\n fillOpacity={0.85}\n stroke={tileFill}\n strokeWidth={2.5}\n strokeLinejoin=\"round\"\n paintOrder=\"stroke\"\n fontSize={NOTE_FONT_SIZE}\n fontWeight={500}\n style={{ pointerEvents: \"none\" }}\n >\n {note}\n </text>\n )}\n </g>\n );\n}\n\n// ── Chart ─────────────────────────────────────────────────────────────────\n\n// Nodes carry their own `fill`, and `ChartTooltipContent` falls back to it for the\n// swatch, so no series config is needed.\nconst TREEMAP_CONFIG: ChartConfig = {};\n\n/**\n * The shared tooltip with the whole bubble tagged `data-private`, for names that are\n * the user's own data.\n *\n * The bubble, not just the name: `ChartTooltipContent` prints the name either from\n * `item.name` or from a config `label`, and only the config route could be tagged — but\n * a config entry keyed by a payee name is not a CSS identifier, and `ChartStyle` would\n * (rightly) refuse it. The bubble holds the name and the value, and the value cell is\n * already `data-private`, so tagging the wrapper hides nothing extra.\n *\n * recharts CLONES the `content` element to inject `active`/`payload`, so this has to BE\n * that element — a `<div>` wrapped around `<ChartTooltipContent />` in the JSX would\n * take those props itself and hand the tooltip nothing.\n */\nfunction RedactedTooltipContent(props: ComponentProps<typeof ChartTooltipContent>) {\n return (\n <div data-private>\n <ChartTooltipContent {...props} />\n </div>\n );\n}\n\nconst DEFAULT_NUMBER = new Intl.NumberFormat();\n\n/**\n * Tile chart: one rectangle per {@link TreemapNode}, sized by `value`, coloured by\n * index from the chart ramp (or by the node's own `fill`), labelled in whichever of the\n * kit's two inks measures better against it. Hover shows the name and the formatted\n * value; `onNodeClick` turns the tiles into buttons.\n *\n * Renders nothing when no node has a positive value — an empty box is a hole in the\n * card, and the caller's own sentence is the better answer there.\n *\n * Needs `recharts`, like everything behind `@eifi1/ui-kit/chart`.\n */\nexport function Treemap({\n data,\n valueFormatter,\n height = 320,\n onNodeClick,\n redactNames = false,\n nodeNote,\n maxTiles,\n colors,\n style,\n ...rest\n}: TreemapProps) {\n const tokenHex = useChartTokenHex();\n // Read off the DOM after mount: the `dir` a treemap sits under is an ancestor's\n // attribute, and SVG text anchoring cannot follow it by CSS (see TreemapCell). Again\n // on new data, because a map that had nothing to draw rendered no root to read.\n const rootRef = useRef<HTMLDivElement>(null);\n const [dir, setDir] = useState<Direction>(\"ltr\");\n useLayoutEffect(() => {\n setDir(dirOf(rootRef.current));\n }, [data]);\n const ramp = colors && colors.length > 0 ? colors : tokenHex;\n // Coloured by index into `data` AS GIVEN, before anything is dropped: a caller\n // painting a legend or a second map from the same list must land on the same hue\n // for the same row, and filtering first would shift every colour after a gap.\n const nodes = data\n .map((d, idx) => ({\n id: d.id,\n name: d.name,\n size: d.value,\n fill: d.fill ?? ramp[idx % ramp.length],\n // recharts hands each node's fields to its cell; a field left out of this map\n // never reaches `TreemapCell` (0.8.0 shipped `labelColor` without it — keksdose).\n labelColor: d.labelColor,\n }))\n .filter((n) => Number.isFinite(n.size) && n.size > 0)\n .slice(0, maxTiles ?? undefined);\n if (nodes.length === 0) return null;\n\n // The share is of what is DRAWN, so it always reads as \"of this picture\".\n const total = nodes.reduce((acc, n) => acc + n.size, 0);\n const format = (v: number): ReactNode => {\n const share = total > 0 ? v / total : undefined;\n return valueFormatter ? valueFormatter(v, share) : DEFAULT_NUMBER.format(v);\n };\n const Content = redactNames ? RedactedTooltipContent : ChartTooltipContent;\n return (\n <ChartContainer {...rest} ref={rootRef} config={TREEMAP_CONFIG} style={{ ...style, height }}>\n {/* No chart-level `stroke`: recharts spreads it onto the <svg> root, from where\n it inherits into every label (see TreemapCell). Each cell strokes its own rect. */}\n <RechartsTreemap\n data={nodes}\n dataKey=\"size\"\n fill=\"var(--chart-1)\"\n isAnimationActive={false}\n content={\n <TreemapCell onNodeClick={onNodeClick} redactNames={redactNames} nodeNote={nodeNote} dir={dir} />\n }\n >\n <ChartTooltip content={<Content hideLabel valueFormatter={format} />} />\n </RechartsTreemap>\n </ChartContainer>\n );\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../src/components/treemap.tsx"],"sourcesContent":["// A tile chart (\"treemap\"): one rectangle per node, its AREA the node's share of what\n// is drawn. Lifted out of keksdose's reports, where five cards drew it (spending by\n// group and by category, net worth by account and by type, allocation, payees, food\n// groups) — every domain word is gone and every string it could print is a prop.\n//\n// It uses the kit's own chart shell (`ChartContainer`, `ChartTooltipContent`) and the\n// kit's own ink rule (`textOn`), so it re-skins with the palette like every other\n// chart. The one thing it cannot take from a CSS var is the tile colour: the label ink\n// is MEASURED against the fill, and a `var(--chart-3)` string measures as nothing — see\n// `useChartTokenHex` below for how the concrete hex is found instead.\nimport { useLayoutEffect, useRef, useState, useSyncExternalStore } from \"react\";\nimport type { ComponentProps, ComponentPropsWithoutRef, KeyboardEvent, ReactNode } from \"react\";\nimport { Treemap as RechartsTreemap } from \"recharts\";\nimport { ChartContainer, ChartTooltip, ChartTooltipContent, type ChartConfig } from \"./chart\";\nimport { PALETTE_HEX, textOn } from \"../theme/chart-palette\";\nimport { parseHex } from \"../theme/color\";\nimport { dirOf, type Direction } from \"../lib/direction\";\n\n/**\n * One tile.\n *\n * `value`, not keksdose's `total`: a treemap's areas are not always money (keksdose\n * already sizes one by transaction COUNT), and `value` is the word every other chart\n * in recharts uses for the number a mark is sized by. Migrating is a rename at the\n * mapping step — `{ id, name, value: row.total }`.\n */\nexport interface TreemapNode {\n /** Stable key, handed back by {@link TreemapProps.onNodeClick} and\n * {@link TreemapProps.nodeNote}. Names can collide; ids must not. */\n id: string;\n name: string;\n /** The tile's area. A node whose value is not a positive finite number is NOT\n * drawn — an area cannot be negative, and recharts lays a negative size out as\n * nonsense rather than refusing it. Saying so is the caller's job: a group that\n * vanishes silently between two views of the same data is a bug report. */\n value: number;\n /** Overrides the by-index palette colour for this tile — e.g. to paint every child\n * in its parent group's colour, so the groups stay legible in one picture. A\n * concrete `#rrggbb` or a bare `var(--chart-N)` keeps the label ink measured;\n * anything else (another var, a `color-mix()`) gets `currentColor` ink, because\n * there is nothing to measure — set `labelColor` then. */\n fill?: string;\n /** The label's ink, for a `fill` the kit cannot measure (a `var()` or `color-mix()`):\n * keksdose's recency plot fills with `color-mix(in srgb, var(--chart-1) N%,\n * transparent)`, where `currentColor` put dark page text on full-strength indigo\n * (~1.5:1). Wins over the measured ink when given. */\n labelColor?: string;\n}\n\n/**\n * Everything a `<div>` takes reaches the chart's root (`ChartContainer`), so a test id\n * or a `data-tour` anchor can find it. `children` is the chart's own.\n */\nexport interface TreemapProps extends Omit<ComponentPropsWithoutRef<\"div\">, \"children\"> {\n data: TreemapNode[];\n /**\n * Formats a node's value in the tooltip. Handed the node's SHARE of what is drawn\n * (0–1) as a second argument, because a tile is read for how big it is relative to\n * the rest, and \"€412.30\" alone makes the reader estimate that ratio off the areas.\n * The share is `undefined` when the drawn total is not positive.\n *\n * Default: the bare number through `Intl.NumberFormat` in the runtime's locale. A\n * host that has a currency or a locale passes this — the kit knows neither.\n */\n valueFormatter?: (value: number, share: number | undefined) => ReactNode;\n /** Chart height in px. Default 320. */\n height?: number;\n /** A tile was activated — by click, or by Enter/Space on a focused tile. Setting\n * this makes every tile a focusable `role=\"button\"` named by its node name. */\n onNodeClick?: (id: string, name: string) => void;\n /**\n * The node names are the user's own data (payees, not categories): the tile labels\n * and the whole tooltip bubble get `data-private`, the same attribute the kit's\n * `Tooltip redact` and `ChartTooltipContent`'s value cell carry, so the host's one\n * privacy rule reaches them. Off by default — a category name is a label of the app.\n */\n redactNames?: boolean;\n /**\n * A second, smaller line under a tile's label — one extra figure per node that the\n * name cannot carry (a year-on-year `+12%`, `new`). Return `undefined` for a node\n * with nothing to add. Never tagged `data-private`: a ratio reveals no amount.\n *\n * ON THE TILE rather than in the tooltip, for two reasons. The tooltip identifies a\n * node by name or by raw value, so a per-node note would be keyed on an amount (two\n * nodes of equal size would swap notes). And a tooltip is a pointer affordance: a\n * phone never hovers, so a figure that only exists on hover does not exist there.\n */\n nodeNote?: (id: string) => string | undefined;\n /**\n * Draw at most this many tiles (the first N drawable nodes, in the order given —\n * sort first). Unbounded by default. A tile under ~44x20px has no room for a label,\n * and a map made of unlabelled rectangles answers nothing; around 24 is where the\n * smallest tile of a skewed series reaches that floor on a phone-width card. The\n * caller owns the \"N more not shown\" line, since it knows how many it passed.\n */\n maxTiles?: number;\n /**\n * The categorical ramp tiles take by index, as concrete hex. Default: the page's\n * live `--chart-1…9` tokens (see `useChartTokenHex`). Pass one when the host\n * already holds the active palette — e.g. `createPaletteStore`'s `useChartHex()` —\n * so a caller colouring a legend beside the map is guaranteed the same list.\n */\n colors?: readonly string[];\n}\n\n// ── The categorical ramp, as concrete hex ──────────────────────────────────\n//\n// The label ink is measured against the tile's fill, so the fill has to be a colour\n// and not a reference to one. keksdose read it from its palette STORE, which is an\n// app object the kit cannot import. What the kit does own is the token layer: whether\n// a palette arrives through `applyTokenSet` (inline properties on <html>) or through\n// tokens.css (`:root` / `.dark`), the resolved `--chart-N` on <html> is the colour\n// every other chart on the page is being painted with. So that is what is read, and\n// re-read whenever <html>'s class or style changes — which is exactly when a theme\n// toggle or a palette switch lands.\n\nconst CHART_TOKENS = 9;\n\n/** The resolved ramp, joined into ONE string so `useSyncExternalStore` compares a\n * primitive: a fresh array per read would never be `Object.is`-equal to the last and\n * would re-render forever. */\nfunction readChartTokens(): string {\n const root = document.documentElement;\n const fallback = root.classList.contains(\"dark\") ? PALETTE_HEX.dark : PALETTE_HEX.light;\n const style = getComputedStyle(root);\n return Array.from({ length: CHART_TOKENS }, (_, i) => {\n const v = style.getPropertyValue(`--chart-${i + 1}`).trim();\n // An unresolved token (a host that never loaded tokens.css, a test DOM) falls\n // back to the built-in ramp for the current theme rather than to no colour.\n return parseHex(v) ? v : fallback[i];\n }).join(\",\");\n}\n\nfunction subscribeChartTokens(onChange: () => void): () => void {\n const observer = new MutationObserver(onChange);\n observer.observe(document.documentElement, {\n attributes: true,\n attributeFilter: [\"class\", \"style\", \"data-theme\"],\n });\n return () => observer.disconnect();\n}\n\nconst SERVER_TOKENS = PALETTE_HEX.light.join(\",\");\n\nfunction useChartTokenHex(): string[] {\n const joined = useSyncExternalStore(subscribeChartTokens, readChartTokens, () => SERVER_TOKENS);\n return joined.split(\",\");\n}\n\nconst CHART_TOKEN_REF = /^var\\(\\s*--chart-(\\d+)\\s*\\)$/;\n\n/**\n * The hex a tile fill measures as: the fill itself when it is hex, the live token when\n * it is a bare `var(--chart-N)` (what `paletteFor` returns), otherwise null. Without\n * the token case a hand-built recharts map coloured with the kit's own `paletteFor`\n * got `currentColor` ink — dark page text on dark indigo in the light theme.\n */\nfunction measurableFill(fill: string, tokens: readonly string[]): string | null {\n if (parseHex(fill)) return fill;\n const m = CHART_TOKEN_REF.exec(fill.trim());\n if (!m) return null;\n const hex = tokens[Number(m[1]) - 1];\n return hex && parseHex(hex) ? hex : null;\n}\n\n// ── Tile ──────────────────────────────────────────────────────────────────\n\nconst LABEL_FONT_SIZE = 12;\n/** The optional second line, a step down from the name so the tile reads name-first. */\nconst NOTE_FONT_SIZE = 11;\n\n/**\n * Fit a label to its tile: the text itself when it fits, an ellipsised head when it\n * does not, `null` when not even three characters would. Without this a long name ran\n * on across its neighbours and collided with THEIR labels. 0.58em is the average\n * advance of a UI sans at this weight; it only needs to be close, since the tile keeps\n * 6px of padding either side. The full name stays in the tooltip.\n */\nexport function fitLabel(name: string, boxWidth: number, fontSize: number): string | null {\n const max = Math.floor((boxWidth - 12) / (fontSize * 0.58));\n if (max < 3) return null;\n return name.length <= max ? name : `${name.slice(0, max - 1).trimEnd()}…`;\n}\n\n/** What recharts hands a `content` element for each node, plus the three options the\n * chart forwards. Everything optional because recharts clones the element it was\n * given and fills the geometry in — the element as written carries only the options. */\nexport interface TreemapCellProps {\n depth?: number;\n x?: number;\n y?: number;\n width?: number;\n height?: number;\n name?: string;\n fill?: string;\n /** See {@link TreemapNode.labelColor}; recharts hands each node's fields to its cell. */\n labelColor?: string;\n id?: string;\n onNodeClick?: (id: string, name: string) => void;\n redactNames?: boolean;\n nodeNote?: (id: string) => string | undefined;\n /** The page's reading direction, which `Treemap` reads off its own root. In `rtl`\n * the label stands in the tile's top RIGHT corner and is shaped right-to-left, so a\n * clipped name ends in its ellipsis on the reading end. Default `ltr`. */\n dir?: Direction;\n}\n\n/**\n * One tile. Exported so a caller drawing its own recharts `<Treemap>` gets the same\n * label rules.\n *\n * recharts' default cell inherits the chart-level `stroke`, an SVG presentation\n * attribute that lands on the `<svg>` root and cascades into the label — so the glyphs\n * were painted with a 1px outline ON TOP of themselves, which reads as blur. This cell\n * draws its own label with a halo in the TILE colour painted BEHIND the fill\n * (`paint-order: stroke`), which defeats any inherited stroke and keeps the text solid\n * where it overlaps a lighter or darker part of its own tile.\n */\nexport function TreemapCell({\n depth,\n x = 0,\n y = 0,\n width = 0,\n height = 0,\n name,\n fill,\n labelColor,\n id,\n onNodeClick,\n redactNames,\n nodeNote,\n dir = \"ltr\",\n}: TreemapCellProps) {\n // Before the early return: a hook has to run on every render of the cell.\n const tokenHex = useChartTokenHex();\n // Depth 0 is recharts' synthetic root, which spans the whole chart.\n if (!depth) return null;\n // Whole pixels: a rect on a half pixel renders its edge across two half-lit\n // columns, and the text baseline inherits that blur.\n const px = Math.round(x);\n const py = Math.round(y);\n const pw = Math.round(width);\n const ph = Math.round(height);\n const tileFill = fill ?? \"var(--chart-1)\";\n const measured = measurableFill(tileFill, tokenHex);\n const ink = labelColor ?? (measured ? textOn(measured) : \"currentColor\");\n // The halo only on an OPAQUE fill we could measure. A translucent fill (a\n // `color-mix(… transparent)`) composited twice — once as the tile, once as the halo\n // over it — draws a darker ring round every glyph, which read as doubled text on the\n // faded tiles. `none` still overrides the inherited chart `stroke`.\n const halo = measured ? tileFill : \"none\";\n // Below ~44x20 there is no room for a readable word, so no label at all rather than\n // a one-letter stub bleeding over the tile edge.\n const label = pw >= 44 && ph >= 20 ? fitLabel(name ?? \"\", pw, LABEL_FONT_SIZE) : null;\n // Only under a label that is itself drawn — a stray \"+12%\" on an unnamed rectangle\n // names nothing — and only where a second baseline fits: the note's baseline sits\n // 29px down and an 11px glyph hangs ~2px below it, so 36 leaves the same ~5px under\n // the note that the label keeps under itself at the 20px floor.\n const rawNote = id != null && nodeNote ? nodeNote(id) : undefined;\n const note =\n label != null && rawNote != null && ph >= 36 ? fitLabel(rawNote, pw, NOTE_FONT_SIZE) : null;\n // The label's START edge, 6px in from the tile's. The tiles themselves are laid out\n // physically (the plot is not mirrored, see `ChartContainer`) and the chart's SVG is\n // pinned `ltr`, so the direction is set on each <text> explicitly: `start` is then\n // the right end in RTL, and the label grows leftwards from the tile's right edge.\n const textX = dir === \"rtl\" ? px + pw - 6 : px + 6;\n const activate = id != null && onNodeClick ? () => onNodeClick(id, name ?? \"\") : undefined;\n const onKeyDown = activate\n ? (e: KeyboardEvent<SVGGElement>) => {\n if (e.key !== \"Enter\" && e.key !== \" \") return;\n e.preventDefault();\n activate();\n }\n : undefined;\n return (\n <g\n onClick={activate}\n onKeyDown={onKeyDown}\n // A clickable tile is a button to a keyboard and a screen reader too; the\n // drilldown it opens was mouse-only in the app this came from. Named by the node\n // name, which is also why the group carries `data-private` under `redactNames`.\n role={activate ? \"button\" : undefined}\n tabIndex={activate ? 0 : undefined}\n aria-label={activate ? name : undefined}\n data-private={activate && redactNames ? \"\" : undefined}\n cursor={activate ? \"pointer\" : undefined}\n // CSS beats a presentation attribute, so this recolours the rect's own stroke\n // for as long as the tile has keyboard focus.\n className={activate ? \"outline-none [&:focus-visible>rect]:stroke-[var(--brand)]\" : undefined}\n >\n {/* The 2px gutter is the card showing through, not a white grid — on a dark\n surface white lines glare harder than the tiles. */}\n <rect\n x={px}\n y={py}\n width={pw}\n height={ph}\n fill={tileFill}\n stroke=\"var(--bg-surface)\"\n strokeWidth={2}\n rx={3}\n />\n {label != null && (\n <text\n // A plain attribute, so the host's `[data-private]` rule reaches an SVG\n // <text> exactly as it reaches a table cell.\n data-private={redactNames ? \"\" : undefined}\n x={textX}\n y={py + 15}\n direction={dir}\n fill={ink}\n stroke={halo}\n strokeWidth={2.5}\n strokeLinejoin=\"round\"\n paintOrder=\"stroke\"\n fontSize={LABEL_FONT_SIZE}\n fontWeight={600}\n style={{ pointerEvents: \"none\" }}\n >\n {label}\n </text>\n )}\n {note != null && (\n <text\n x={textX}\n y={py + 29}\n direction={dir}\n fill={ink}\n fillOpacity={0.85}\n stroke={halo}\n strokeWidth={2.5}\n strokeLinejoin=\"round\"\n paintOrder=\"stroke\"\n fontSize={NOTE_FONT_SIZE}\n fontWeight={500}\n style={{ pointerEvents: \"none\" }}\n >\n {note}\n </text>\n )}\n </g>\n );\n}\n\n// ── Chart ─────────────────────────────────────────────────────────────────\n\n// Nodes carry their own `fill`, and `ChartTooltipContent` falls back to it for the\n// swatch, so no series config is needed.\nconst TREEMAP_CONFIG: ChartConfig = {};\n\n/**\n * The shared tooltip with the whole bubble tagged `data-private`, for names that are\n * the user's own data.\n *\n * The bubble, not just the name: `ChartTooltipContent` prints the name either from\n * `item.name` or from a config `label`, and only the config route could be tagged — but\n * a config entry keyed by a payee name is not a CSS identifier, and `ChartStyle` would\n * (rightly) refuse it. The bubble holds the name and the value, and the value cell is\n * already `data-private`, so tagging the wrapper hides nothing extra.\n *\n * recharts CLONES the `content` element to inject `active`/`payload`, so this has to BE\n * that element — a `<div>` wrapped around `<ChartTooltipContent />` in the JSX would\n * take those props itself and hand the tooltip nothing.\n */\nfunction RedactedTooltipContent(props: ComponentProps<typeof ChartTooltipContent>) {\n return (\n <div data-private>\n <ChartTooltipContent {...props} />\n </div>\n );\n}\n\nconst DEFAULT_NUMBER = new Intl.NumberFormat();\n\n/**\n * Tile chart: one rectangle per {@link TreemapNode}, sized by `value`, coloured by\n * index from the chart ramp (or by the node's own `fill`), labelled in whichever of the\n * kit's two inks measures better against it. Hover shows the name and the formatted\n * value; `onNodeClick` turns the tiles into buttons.\n *\n * Renders nothing when no node has a positive value — an empty box is a hole in the\n * card, and the caller's own sentence is the better answer there.\n *\n * Needs `recharts`, like everything behind `@eifi1/ui-kit/chart`.\n */\nexport function Treemap({\n data,\n valueFormatter,\n height = 320,\n onNodeClick,\n redactNames = false,\n nodeNote,\n maxTiles,\n colors,\n style,\n ...rest\n}: TreemapProps) {\n const tokenHex = useChartTokenHex();\n // Read off the DOM after mount: the `dir` a treemap sits under is an ancestor's\n // attribute, and SVG text anchoring cannot follow it by CSS (see TreemapCell). Again\n // on new data, because a map that had nothing to draw rendered no root to read.\n const rootRef = useRef<HTMLDivElement>(null);\n const [dir, setDir] = useState<Direction>(\"ltr\");\n useLayoutEffect(() => {\n setDir(dirOf(rootRef.current));\n }, [data]);\n const ramp = colors && colors.length > 0 ? colors : tokenHex;\n // Coloured by index into `data` AS GIVEN, before anything is dropped: a caller\n // painting a legend or a second map from the same list must land on the same hue\n // for the same row, and filtering first would shift every colour after a gap.\n const nodes = data\n .map((d, idx) => ({\n id: d.id,\n name: d.name,\n size: d.value,\n fill: d.fill ?? ramp[idx % ramp.length],\n // recharts hands each node's fields to its cell; a field left out of this map\n // never reaches `TreemapCell` (0.8.0 shipped `labelColor` without it — keksdose).\n labelColor: d.labelColor,\n }))\n .filter((n) => Number.isFinite(n.size) && n.size > 0)\n .slice(0, maxTiles ?? undefined);\n if (nodes.length === 0) return null;\n\n // The share is of what is DRAWN, so it always reads as \"of this picture\".\n const total = nodes.reduce((acc, n) => acc + n.size, 0);\n const format = (v: number): ReactNode => {\n const share = total > 0 ? v / total : undefined;\n return valueFormatter ? valueFormatter(v, share) : DEFAULT_NUMBER.format(v);\n };\n const Content = redactNames ? RedactedTooltipContent : ChartTooltipContent;\n return (\n <ChartContainer {...rest} ref={rootRef} config={TREEMAP_CONFIG} style={{ ...style, height }}>\n {/* No chart-level `stroke`: recharts spreads it onto the <svg> root, from where\n it inherits into every label (see TreemapCell). Each cell strokes its own rect. */}\n <RechartsTreemap\n data={nodes}\n dataKey=\"size\"\n fill=\"var(--chart-1)\"\n isAnimationActive={false}\n content={\n <TreemapCell onNodeClick={onNodeClick} redactNames={redactNames} nodeNote={nodeNote} dir={dir} />\n }\n >\n <ChartTooltip content={<Content hideLabel valueFormatter={format} />} />\n </RechartsTreemap>\n </ChartContainer>\n );\n}\n"],"mappings":"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","names":[]}
@@ -1,6 +1,6 @@
1
1
  import 'react';
2
2
  import './tooltip.js';
3
- export { B as Button, a as ButtonClassesOptions, b as ButtonLinkProps, c as ButtonProps, d as ButtonSize, e as ButtonTone, f as ButtonVariant, g as Card, h as CardAction, i as CardActionProps, j as CardContent, k as CardContentProps, l as CardDescription, m as CardDescriptionProps, n as CardFooter, o as CardFooterProps, p as CardHeader, q as CardHeaderProps, r as CardProps, s as CardTitle, t as CardTitleProps, u as CardTone, P as DEFAULT_PASSWORD_REVEAL_LABELS, S as DEFAULT_TABS_LABELS, V as EmptyState, W as EmptyStateProps, X as FIELD_BASE, Y as FIELD_DISPLAY, Z as FIELD_FLOATING_PAD, _ as FIELD_INVALID, $ as FIELD_TRIGGER, a0 as FIELD_WRITABLE_LOOK, a1 as FLOATING_INPUT_CLASS, a2 as FLOATING_LABEL_CLASS, a3 as FLOATING_LABEL_STATIC, a4 as FieldChevron, a5 as FieldChevronProps, a6 as FieldHint, a7 as FieldHintProps, a8 as FieldLabel, a9 as FieldLabelProps, ah as FloatingField, ai as FloatingFieldProps, ap as IconButton, aq as IconButtonGlyphSize, ar as IconButtonLinkProps, as as IconButtonProps, at as IconButtonSize, au as IconButtonTone, av as IconButtonToneColor, aw as IconButtonVariant, ax as Input, ay as InputProps, aB as Label, aD as LabelProps, aF as PHONE_QUERY, aG as PasswordRevealLabels, aI as Select, aJ as SelectProps, aK as Spinner, aL as SpinnerProps, aN as TabItem, aO as Tabs, aP as TabsLabels, aQ as TabsProps, aR as Textarea, aS as TextareaProps, a_ as buttonClasses, b2 as resolvePasswordRevealLabels } from '../kit-labels-Mz8zZHIF.js';
3
+ export { B as Button, a as ButtonClassesOptions, b as ButtonLinkProps, c as ButtonProps, d as ButtonSize, e as ButtonTone, f as ButtonVariant, g as Card, h as CardAction, i as CardActionProps, j as CardContent, k as CardContentProps, l as CardDescription, m as CardDescriptionProps, n as CardFooter, o as CardFooterProps, p as CardHeader, q as CardHeaderProps, r as CardProps, s as CardTitle, t as CardTitleProps, u as CardTone, P as DEFAULT_PASSWORD_REVEAL_LABELS, S as DEFAULT_TABS_LABELS, V as EmptyState, W as EmptyStateProps, X as FIELD_BASE, Y as FIELD_DISPLAY, Z as FIELD_FLOATING_PAD, _ as FIELD_INVALID, $ as FIELD_TRIGGER, a0 as FIELD_WRITABLE_LOOK, a1 as FLOATING_INPUT_CLASS, a2 as FLOATING_LABEL_CLASS, a3 as FLOATING_LABEL_STATIC, a4 as FieldChevron, a5 as FieldChevronProps, a6 as FieldHint, a7 as FieldHintProps, a8 as FieldLabel, a9 as FieldLabelProps, ah as FloatingField, ai as FloatingFieldProps, ap as IconButton, aq as IconButtonGlyphSize, ar as IconButtonLinkProps, as as IconButtonProps, at as IconButtonSize, au as IconButtonTone, av as IconButtonToneColor, aw as IconButtonVariant, ax as Input, ay as InputProps, aB as Label, aD as LabelProps, aF as PHONE_QUERY, aG as PasswordRevealLabels, aI as Select, aJ as SelectProps, aK as Spinner, aL as SpinnerProps, aN as TabItem, aO as Tabs, aP as TabsLabels, aQ as TabsProps, aR as Textarea, aS as TextareaProps, a_ as buttonClasses, b2 as resolvePasswordRevealLabels } from '../kit-labels-BHfRBbHs.js';
4
4
  import '../hooks/use-anchored-rect.js';
5
5
  import '../lib/clipping.js';
6
6
  import './data-table-labels.js';