@eifi1/ui-kit 0.15.0 → 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.
- package/dist/chart.d.ts +2 -1
- package/dist/components/facing-pair.d.ts +1 -0
- package/dist/components/series-chart-budget.d.ts +60 -0
- package/dist/components/series-chart-budget.js +56 -0
- package/dist/components/series-chart-budget.js.map +1 -0
- package/dist/components/series-chart.d.ts +54 -2
- package/dist/components/series-chart.js +68 -13
- package/dist/components/series-chart.js.map +1 -1
- package/dist/components/slider.js +6 -2
- package/dist/components/slider.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/package.json +1 -1
- package/src/components/series-chart-budget.ts +121 -0
- package/src/components/series-chart.tsx +146 -8
- package/src/components/slider.tsx +6 -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"]}
|
package/dist/index.d.ts
CHANGED
|
@@ -70,7 +70,7 @@ export { Autocomplete, AutocompleteProps } from './components/autocomplete.js';
|
|
|
70
70
|
export { DEFAULT_MEASURED_GRID_LABELS, MeasuredGrid, MeasuredGridColumn, MeasuredGridLabels, MeasuredGridProps, MeasuredGridView, MeasuredRows, UseMeasuredRowsOptions, useMeasuredRows } from './components/measured-grid.js';
|
|
71
71
|
export { WindowedRows, useWindowedRows } from './hooks/use-windowed-rows.js';
|
|
72
72
|
export { Treemap, TreemapCell, TreemapCellProps, TreemapNode, TreemapProps, fitLabel } from './components/treemap.js';
|
|
73
|
-
export { AXIS_TICK_WIDTH, AXIS_TITLE_STRIP, SeriesChart, SeriesChartAxis, SeriesChartHit, SeriesChartMarker, SeriesChartPoint, SeriesChartProps, SeriesChartReference, SeriesChartRow, SeriesChartSeries, SeriesChartSpan, SeriesChartTickValues, SeriesChartTone, SeriesChartTooltip, SeriesChartType, SeriesChartX, SeriesChartXTick, SeriesChartXValue, SeriesSource, StaticSeriesChart, anchoredBand, axisBandWidth, mergeSeries, oneAxis, padBand, paddedDomain, seriesKey, seriesLegendEntries, soleSeriesColor, visibleSeries } from './components/series-chart.js';
|
|
73
|
+
export { AXIS_TICK_WIDTH, AXIS_TITLE_STRIP, SeriesChart, SeriesChartAxis, SeriesChartHit, SeriesChartMarker, SeriesChartPoint, SeriesChartProps, SeriesChartReference, SeriesChartRow, SeriesChartSeries, SeriesChartSpan, SeriesChartTickValues, SeriesChartTone, SeriesChartTooltip, SeriesChartType, SeriesChartX, SeriesChartXTick, SeriesChartXValue, SeriesLegendAxes, SeriesSource, StaticSeriesChart, anchoredBand, axisBandWidth, mergeSeries, oneAxis, padBand, paddedDomain, seriesKey, seriesLegendEntries, soleSeriesColor, visibleSeries } from './components/series-chart.js';
|
|
74
74
|
export { DEFAULT_Y_AXIS, NO_ZOOM, SharedXZoom, ZOOM_MIN_DRAG, ZOOM_SQUARE_ENOUGH, ZoomAxes, ZoomAxesSetting, ZoomBinding, ZoomDrag, ZoomFitSeries, ZoomFitSource, ZoomRow, ZoomSelection, ZoomState, ZoomTarget, axisExtent, defaultZoomAxes, fitXToY, fitYToX, selectionFromDrag, withChartZoom, zoomAfter, zoomAxesFor, zoomDomains } from './components/chart-zoom.js';
|
|
75
75
|
export { LegendColumn, LegendEntry, LegendGroup, STEP_DASH, STROKE_PATTERNS, StaticLegend, StaticLegendProps, ToggleLegend, ToggleLegendProps, strokeDash, toggleHidden } from './components/toggle-legend.js';
|
|
76
76
|
export { FACING_SIDES, FacingSide, facingAxes, facingBand, facingHeadingPad } from './components/facing-pair.js';
|
|
@@ -151,6 +151,7 @@ export { DEFAULT_UI_KIT_LABELS } from './i18n/defaults.js';
|
|
|
151
151
|
export { AccountSettingsLabels, DEFAULT_ACCOUNT_SETTINGS_LABELS, PasskeysSettingLabels, PasswordSettingLabels, ProfileSettingLabels, TwoFactorSettingLabels } from './components/account-settings-labels.js';
|
|
152
152
|
export { CLIPS_ATTRIBUTE } from './lib/clipping.js';
|
|
153
153
|
export { ComboClearValue, ComboOption } from './components/combobox-core.js';
|
|
154
|
+
export { SeriesChartAxisBudget } from './components/series-chart-budget.js';
|
|
154
155
|
export { TimeTickUnit } from './components/series-chart-ticks.js';
|
|
155
156
|
import 'clsx';
|
|
156
157
|
import 'zustand';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@eifi1/ui-kit",
|
|
3
|
-
"version": "0.15.
|
|
3
|
+
"version": "0.15.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "App-agnostic shared design system: domain-free React components, theme and tokens.",
|
|
6
6
|
"_sideEffects": "The barrel (src/index.ts) re-exports EVERY component, including components/chart, which statically imports recharts. Without this field a bundler must assume each of those modules has side effects, so it cannot drop the unused ones: importing a Button from '@eifi1/ui-kit' dragged all 433KB of recharts into the consumer's ENTRY chunk, and Keksdose was preloading it on every page load, login included. Nothing in src/ imports for side effects (no bare imports, no CSS imports from JS, no module-level global writes) — the token stylesheet is its own './tokens.css' export — so this is safe. CSS is still listed so any future stylesheet survives. Re-checked when `flag-icons` left `dependencies` and tsup started emitting a `\"use client\"` banner: neither touches this. The flag stylesheet was never imported from JS in the first place (that was the finding), and a directive prologue is a string literal every bundler lifts out before it decides whether a module is inert.",
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// The axis budget of a series chart: which of its y axes are drawn, when there are more
|
|
2
|
+
// than the width can carry. Pure, and apart from the chart, so the rule is tested on
|
|
3
|
+
// numbers rather than on a jsdom that has no layout to measure.
|
|
4
|
+
//
|
|
5
|
+
// lenkbank's curve plot declares four visible axes — velocity, position and acceleration
|
|
6
|
+
// on the left, loads on the right — and every one reserves its band of ticks and title.
|
|
7
|
+
// At 1280 px that is a quarter of the card; at 390 px it is all of it, and the plot the
|
|
8
|
+
// axes are for is ten pixels wide. An axis over budget is drawn the way a `hide: true`
|
|
9
|
+
// one always was: it still scales its lines (and the zoom still refits it), but it draws
|
|
10
|
+
// no ticks, no title, and reserves no width.
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* How many y axes a chart draws. A number caps the TOTAL; `{ left, right }` caps each
|
|
14
|
+
* side (a side left out is not capped). Axes over the cap are hidden in declaration
|
|
15
|
+
* order — the first of each side is the last to go, see {@link budgetedAxes}.
|
|
16
|
+
*/
|
|
17
|
+
export type SeriesChartAxisBudget = number | { left?: number; right?: number };
|
|
18
|
+
|
|
19
|
+
/** The part of an axis the budget reads. */
|
|
20
|
+
interface BudgetAxis {
|
|
21
|
+
id: string;
|
|
22
|
+
orientation?: "left" | "right";
|
|
23
|
+
hide?: boolean;
|
|
24
|
+
title?: string;
|
|
25
|
+
width?: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The narrowest the plot may get before the automatic budget steps in, in px.
|
|
30
|
+
*
|
|
31
|
+
* About a phone's plot with one axis a side (390 px viewport, card padding, two bands
|
|
32
|
+
* of 48 + 16), where a curve's shape still reads. It is the point below which the chart
|
|
33
|
+
* is already broken today — a plot the width of a thumb between four columns of numbers
|
|
34
|
+
* — so the rule changes nothing that was working.
|
|
35
|
+
*/
|
|
36
|
+
export const MIN_PLOT_WIDTH = 160;
|
|
37
|
+
|
|
38
|
+
/** The budget the automatic rule applies: one axis a side. */
|
|
39
|
+
export const NARROW_AXIS_BUDGET: SeriesChartAxisBudget = { left: 1, right: 1 };
|
|
40
|
+
|
|
41
|
+
/** The plot's margin on a side no axis band covers — `SeriesPlot`'s own. */
|
|
42
|
+
const BARE_SIDE_MARGIN = 10;
|
|
43
|
+
|
|
44
|
+
const sideOf = (axis: BudgetAxis) => axis.orientation ?? "left";
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The ids of the VISIBLE axes a budget hides — axes already `hide: true` are neither
|
|
48
|
+
* counted nor returned. Empty for no budget.
|
|
49
|
+
*
|
|
50
|
+
* Kept, in order of priority: the first visible axis of each side (in declaration order,
|
|
51
|
+
* so the axis a caller lists first — the one the grid hangs its rules off — always
|
|
52
|
+
* stays), then the rest in declaration order, until the cap. Per side, that is simply the
|
|
53
|
+
* first `left` left-hand axes and the first `right` right-hand ones.
|
|
54
|
+
*/
|
|
55
|
+
export function budgetedAxes(
|
|
56
|
+
axes: readonly BudgetAxis[],
|
|
57
|
+
budget: SeriesChartAxisBudget | undefined,
|
|
58
|
+
): string[] {
|
|
59
|
+
if (budget === undefined) return [];
|
|
60
|
+
const visible = axes.filter((axis) => !axis.hide);
|
|
61
|
+
if (typeof budget === "number") {
|
|
62
|
+
const cap = Math.max(0, Math.floor(budget));
|
|
63
|
+
const firsts = new Set<string>();
|
|
64
|
+
const seen = new Set<string>();
|
|
65
|
+
for (const axis of visible) {
|
|
66
|
+
if (!seen.has(sideOf(axis))) firsts.add(axis.id);
|
|
67
|
+
seen.add(sideOf(axis));
|
|
68
|
+
}
|
|
69
|
+
const priority = [
|
|
70
|
+
...visible.filter((axis) => firsts.has(axis.id)),
|
|
71
|
+
...visible.filter((axis) => !firsts.has(axis.id)),
|
|
72
|
+
];
|
|
73
|
+
const kept = new Set(priority.slice(0, cap).map((axis) => axis.id));
|
|
74
|
+
return visible.filter((axis) => !kept.has(axis.id)).map((axis) => axis.id);
|
|
75
|
+
}
|
|
76
|
+
const used = { left: 0, right: 0 };
|
|
77
|
+
const out: string[] = [];
|
|
78
|
+
for (const axis of visible) {
|
|
79
|
+
const side = sideOf(axis);
|
|
80
|
+
const cap = budget[side];
|
|
81
|
+
if (cap !== undefined && used[side] >= Math.max(0, Math.floor(cap))) out.push(axis.id);
|
|
82
|
+
else used[side] += 1;
|
|
83
|
+
}
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The automatic budget for a chart `width` px wide: {@link NARROW_AXIS_BUDGET} when the
|
|
89
|
+
* visible axes' bands would leave the plot under {@link MIN_PLOT_WIDTH} AND some side
|
|
90
|
+
* draws more than one axis — the only case one-a-side gives anything back. `undefined`
|
|
91
|
+
* otherwise, and for a width that is not known (no layout, no `ResizeObserver`).
|
|
92
|
+
*
|
|
93
|
+
* Decided on the CHART's width and the axes as declared, never on the plot the budget
|
|
94
|
+
* produced, so hiding an axis cannot widen the plot back over the line and bring it
|
|
95
|
+
* back: the rule has no feedback to oscillate on.
|
|
96
|
+
*/
|
|
97
|
+
export function autoAxisBudget(
|
|
98
|
+
axes: readonly BudgetAxis[],
|
|
99
|
+
width: number | undefined,
|
|
100
|
+
bandWidth: (axis: BudgetAxis) => number,
|
|
101
|
+
): SeriesChartAxisBudget | undefined {
|
|
102
|
+
if (width === undefined || !(width > 0)) return undefined;
|
|
103
|
+
const visible = axes.filter((axis) => !axis.hide);
|
|
104
|
+
const left = visible.filter((axis) => sideOf(axis) === "left");
|
|
105
|
+
const right = visible.filter((axis) => sideOf(axis) === "right");
|
|
106
|
+
if (left.length <= 1 && right.length <= 1) return undefined;
|
|
107
|
+
const bands = visible.reduce((sum, axis) => sum + bandWidth(axis), 0);
|
|
108
|
+
const margins = (left.length ? 0 : BARE_SIDE_MARGIN) + (right.length ? 0 : BARE_SIDE_MARGIN);
|
|
109
|
+
return width - bands - margins < MIN_PLOT_WIDTH ? NARROW_AXIS_BUDGET : undefined;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* The unit an axis' series say when the axis is not drawn: its `unit`, or else the
|
|
114
|
+
* parenthesised tail of its title — "Load (N)" says "N", which is how the apps write
|
|
115
|
+
* every axis title. `undefined` when neither says one.
|
|
116
|
+
*/
|
|
117
|
+
export function axisUnit(axis: { unit?: string; title?: string }): string | undefined {
|
|
118
|
+
if (axis.unit !== undefined) return axis.unit || undefined;
|
|
119
|
+
const tail = /\(([^()]+)\)\s*$/.exec(axis.title ?? "");
|
|
120
|
+
return tail?.[1].trim() || undefined;
|
|
121
|
+
}
|
|
@@ -26,7 +26,13 @@
|
|
|
26
26
|
// "category"`) or real dates (`x.type: "time"`) instead of a number the app had to
|
|
27
27
|
// invent. What each of those does to the zoom is decided in one place,
|
|
28
28
|
// `defaultZoomAxes` in `chart-zoom.tsx`.
|
|
29
|
-
|
|
29
|
+
//
|
|
30
|
+
// **An axis budget (0.15.1).** Every visible axis reserves its band, and four of them
|
|
31
|
+
// leave a phone nothing to plot. A chart draws at most `maxVisibleAxes`, and — on its
|
|
32
|
+
// own, when it is too narrow for the ones it has — one a side; the rest go hidden,
|
|
33
|
+
// still scaling their lines, and their series say the unit the title no longer does.
|
|
34
|
+
// The rule is `series-chart-budget.ts`.
|
|
35
|
+
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
|
30
36
|
import type { KeyboardEvent, ReactNode, RefObject } from "react";
|
|
31
37
|
import {
|
|
32
38
|
Area,
|
|
@@ -58,6 +64,9 @@ import { DEFAULT_SERIES_CHART_LABELS, type SeriesChartLabels } from "./series-ch
|
|
|
58
64
|
import { categoryTicks, integerTicks, niceTicks, timeTicksWithUnit, type TimeTickUnit } from "./series-chart-ticks";
|
|
59
65
|
// The tick module stays internal; the one type of it a public prop names is re-exported.
|
|
60
66
|
export type { TimeTickUnit } from "./series-chart-ticks";
|
|
67
|
+
import { autoAxisBudget, axisUnit, budgetedAxes, type SeriesChartAxisBudget } from "./series-chart-budget";
|
|
68
|
+
// The rule stays internal, like the ticks; the one type a public prop names is re-exported.
|
|
69
|
+
export type { SeriesChartAxisBudget } from "./series-chart-budget";
|
|
61
70
|
import { paletteFor } from "../theme/chart-palette";
|
|
62
71
|
import { useKitLabels, useKitLocale } from "../i18n/kit-labels";
|
|
63
72
|
import { cn } from "../lib/cn";
|
|
@@ -193,6 +202,15 @@ interface SeriesChartAxisShape {
|
|
|
193
202
|
color?: string;
|
|
194
203
|
/** What the TICKS need, in px. The title's strip is added on top. Default 48. */
|
|
195
204
|
width?: number;
|
|
205
|
+
/**
|
|
206
|
+
* The unit, bare — `"mm/s"`. The title says it while the axis is drawn; when it is not
|
|
207
|
+
* (`hide`, or over the chart's axis budget — see {@link SeriesChartProps.maxVisibleAxes})
|
|
208
|
+
* the series on it say it instead, as "Velocity (mm/s)": in the tooltip of a chart
|
|
209
|
+
* whose budget hid the axis, and in the legend {@link seriesLegendEntries} builds when
|
|
210
|
+
* handed the axes. Default: the parenthesised tail of `title` ("Load (N)" → "N").
|
|
211
|
+
* `""` for a quantity with no unit whose title merely ends in brackets.
|
|
212
|
+
*/
|
|
213
|
+
unit?: string;
|
|
196
214
|
/**
|
|
197
215
|
* Pin the scale instead of fitting it to this chart's data. For a ROW of charts
|
|
198
216
|
* read against each other: auto-fitted, a 200 N loop and a 2000 N one draw the
|
|
@@ -511,6 +529,37 @@ export interface SeriesChartProps {
|
|
|
511
529
|
/** Where the tooltip may go: for a chart inside a scroll wrapper. See
|
|
512
530
|
* {@link SeriesChartTooltip}. */
|
|
513
531
|
tooltip?: SeriesChartTooltip;
|
|
532
|
+
/**
|
|
533
|
+
* The most y axes the chart DRAWS: a total, or `{ left, right }` per side. Axes over it
|
|
534
|
+
* are drawn as `hide: true` ones are — they still scale their lines and the zoom still
|
|
535
|
+
* refits them, but they draw no ticks or title and reserve no width — in declaration
|
|
536
|
+
* order, so the first axis of each side is the last to go (`series-chart-budget.ts`).
|
|
537
|
+
* The series on a hidden axis say its unit instead (see {@link SeriesChartAxis.unit}).
|
|
538
|
+
* Default: no cap — only the automatic one of `axisBudget`.
|
|
539
|
+
*/
|
|
540
|
+
maxVisibleAxes?: SeriesChartAxisBudget;
|
|
541
|
+
/**
|
|
542
|
+
* `"auto"` (the default): a chart so narrow that its axes' bands would leave the plot
|
|
543
|
+
* under 160 px (`MIN_PLOT_WIDTH`) draws ONE axis a side — lenkbank's four-axis curve
|
|
544
|
+
* plot on a phone, whose plot was 10 px wide. Measured on the chart's own box
|
|
545
|
+
* (`ResizeObserver`), and applied on top of `maxVisibleAxes`.
|
|
546
|
+
*
|
|
547
|
+
* On by default because it cannot touch a layout that works: it needs a side with two
|
|
548
|
+
* or more axes AND a plot that would otherwise be under 160 px — a four-axis chart
|
|
549
|
+
* below about 440 px, a two-left-axis one below about 300. A chart with at most one
|
|
550
|
+
* axis a side (every facing pair, every single-axis chart) is never budgeted, at any
|
|
551
|
+
* width. `"off"` keeps every declared axis whatever the width: for a stack of
|
|
552
|
+
* multi-axis charts that must keep identical bands, or a caller budgeting itself.
|
|
553
|
+
* Without layout (jsdom, SSR) nothing is measured and nothing is hidden.
|
|
554
|
+
*/
|
|
555
|
+
axisBudget?: "auto" | "off";
|
|
556
|
+
/**
|
|
557
|
+
* The ids of the axes the budget hid (not those declared `hide`), every time that set
|
|
558
|
+
* changes — for a legend that says their units: pass it to {@link seriesLegendEntries}
|
|
559
|
+
* as `budgeted`. Called after the render that hid them; empty once the chart is wide
|
|
560
|
+
* again.
|
|
561
|
+
*/
|
|
562
|
+
onAxisBudget?: (hidden: readonly string[]) => void;
|
|
514
563
|
/** Supplied by `withChartZoom` and by nothing else. */
|
|
515
564
|
zoom?: ZoomBinding;
|
|
516
565
|
/** Per-chart strings over `<UiKitProvider labels={{ seriesChart }}>`. */
|
|
@@ -695,13 +744,43 @@ export function visibleSeries(
|
|
|
695
744
|
.filter((entry) => !hidden.has(entry.key));
|
|
696
745
|
}
|
|
697
746
|
|
|
747
|
+
/** A series' name with its unit after it: "Velocity (mm/s)". */
|
|
748
|
+
function withUnit(label: ReactNode, unit: string | undefined): ReactNode {
|
|
749
|
+
if (!unit) return label;
|
|
750
|
+
if (typeof label === "string" || typeof label === "number") return `${label} (${unit})`;
|
|
751
|
+
return (
|
|
752
|
+
<>
|
|
753
|
+
{label} ({unit})
|
|
754
|
+
</>
|
|
755
|
+
);
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
/** Where a legend learns which axes are not drawn — see {@link seriesLegendEntries}. */
|
|
759
|
+
export interface SeriesLegendAxes {
|
|
760
|
+
/** The chart's axes. A series on one that is `hide`, or `budgeted`, says its unit. */
|
|
761
|
+
axes?: readonly SeriesChartAxis[];
|
|
762
|
+
/** What the chart's `onAxisBudget` last said. */
|
|
763
|
+
budgeted?: readonly string[];
|
|
764
|
+
}
|
|
765
|
+
|
|
698
766
|
/**
|
|
699
767
|
* The `ToggleLegend` entries for a chart's FULL series list — colours resolved the way
|
|
700
768
|
* the chart resolves them, and a stroke mark for a line drawn in a pattern (dashed,
|
|
701
769
|
* step) so the key promises the stroke the plot draws. A bar or an area is a swatch.
|
|
702
770
|
* Pair with {@link visibleSeries} for the chart itself.
|
|
771
|
+
*
|
|
772
|
+
* Handed the chart's `axes` (and the `budgeted` ids its `onAxisBudget` reports), a
|
|
773
|
+
* series whose axis is not drawn says the unit that axis would have: "Velocity (mm/s)"
|
|
774
|
+
* — see {@link SeriesChartAxis.unit}. Without them, the labels are as given.
|
|
703
775
|
*/
|
|
704
|
-
export function seriesLegendEntries(
|
|
776
|
+
export function seriesLegendEntries(
|
|
777
|
+
series: readonly SeriesChartSeries[],
|
|
778
|
+
{ axes, budgeted }: SeriesLegendAxes = {},
|
|
779
|
+
): LegendEntry[] {
|
|
780
|
+
const undrawn = new Map<string, string | undefined>();
|
|
781
|
+
for (const axis of axes ?? []) {
|
|
782
|
+
if (axis.hide || budgeted?.includes(axis.id)) undrawn.set(axis.id, axisUnit(axis));
|
|
783
|
+
}
|
|
705
784
|
return series.map((entry, index) => {
|
|
706
785
|
// A custom dash array goes to the legend as it is, so the swatch draws what the plot does.
|
|
707
786
|
const own = entry.dash ?? (entry.dashed ? 1 : 0);
|
|
@@ -709,7 +788,7 @@ export function seriesLegendEntries(series: readonly SeriesChartSeries[]): Legen
|
|
|
709
788
|
const line = (entry.type ?? "line") === "line";
|
|
710
789
|
return {
|
|
711
790
|
key: entry.key,
|
|
712
|
-
label: entry.label,
|
|
791
|
+
label: withUnit(entry.label, undrawn.get(entry.axis ?? DEFAULT_Y_AXIS)),
|
|
713
792
|
color: entry.color ?? paletteFor(index),
|
|
714
793
|
...(line && dash !== 0 ? { marker: "stroke" as const, dash } : {}),
|
|
715
794
|
};
|
|
@@ -1077,6 +1156,9 @@ function SeriesPlot({
|
|
|
1077
1156
|
locale: localeProp,
|
|
1078
1157
|
className,
|
|
1079
1158
|
minBarLength,
|
|
1159
|
+
maxVisibleAxes,
|
|
1160
|
+
axisBudget = "auto",
|
|
1161
|
+
onAxisBudget,
|
|
1080
1162
|
}: PlotProps) {
|
|
1081
1163
|
const labels = useKitLabels("seriesChart", DEFAULT_SERIES_CHART_LABELS, labelsProp);
|
|
1082
1164
|
const locale = useKitLocale(localeProp);
|
|
@@ -1086,6 +1168,44 @@ function SeriesPlot({
|
|
|
1086
1168
|
const [keyStop, setKeyStop] = useState(0);
|
|
1087
1169
|
const [keyFocus, setKeyFocus] = useState<number | undefined>(undefined);
|
|
1088
1170
|
|
|
1171
|
+
// The chart's own width, for the automatic axis budget. Its box, not the plot's: the
|
|
1172
|
+
// box does not change when an axis is hidden, so the rule cannot oscillate. Nothing
|
|
1173
|
+
// measured (jsdom, the first frame) is no automatic budget.
|
|
1174
|
+
const [box, setBox] = useState<HTMLDivElement | null>(null);
|
|
1175
|
+
const [width, setWidth] = useState<number | undefined>(undefined);
|
|
1176
|
+
useLayoutEffect(() => {
|
|
1177
|
+
if (!box || axisBudget === "off" || typeof ResizeObserver === "undefined") return;
|
|
1178
|
+
const measure = () => setWidth(box.getBoundingClientRect().width || undefined);
|
|
1179
|
+
measure();
|
|
1180
|
+
const observer = new ResizeObserver(measure);
|
|
1181
|
+
observer.observe(box);
|
|
1182
|
+
return () => observer.disconnect();
|
|
1183
|
+
}, [box, axisBudget]);
|
|
1184
|
+
// The caller's cap first, then — on what is left — the automatic one.
|
|
1185
|
+
const capped = budgetedAxes(axes, maxVisibleAxes);
|
|
1186
|
+
const afterCap = capped.length
|
|
1187
|
+
? axes.map((axis) => (capped.includes(axis.id) ? { ...axis, hide: true as const } : axis))
|
|
1188
|
+
: axes;
|
|
1189
|
+
const auto =
|
|
1190
|
+
axisBudget === "off"
|
|
1191
|
+
? []
|
|
1192
|
+
: budgetedAxes(
|
|
1193
|
+
afterCap,
|
|
1194
|
+
autoAxisBudget(afterCap, width, (axis) =>
|
|
1195
|
+
axisBandWidth(axis.width, Boolean(axis.title)),
|
|
1196
|
+
),
|
|
1197
|
+
);
|
|
1198
|
+
const budgeted = [...capped, ...auto];
|
|
1199
|
+
const budgetKey = budgeted.join("\n");
|
|
1200
|
+
// Reported when the SET changes, not on every render — an inline callback is a new
|
|
1201
|
+
// function each time, and the guard keeps it from being called again for nothing.
|
|
1202
|
+
const reported = useRef("");
|
|
1203
|
+
useEffect(() => {
|
|
1204
|
+
if (budgetKey === reported.current) return;
|
|
1205
|
+
reported.current = budgetKey;
|
|
1206
|
+
onAxisBudget?.(budgetKey ? budgetKey.split("\n") : []);
|
|
1207
|
+
}, [budgetKey, onAxisBudget]);
|
|
1208
|
+
|
|
1089
1209
|
// A number is pixels, set inline; a string is a class. Either way the one value
|
|
1090
1210
|
// sizes both the chart and its empty state.
|
|
1091
1211
|
const heightClass = typeof height === "string" ? height : undefined;
|
|
@@ -1113,18 +1233,30 @@ function SeriesPlot({
|
|
|
1113
1233
|
const xKey = model.plotKey;
|
|
1114
1234
|
const plotted = model.rows;
|
|
1115
1235
|
const rows = model.source;
|
|
1236
|
+
// What the budget took off the chart: an axis drawn as `hide` — and its unit, which
|
|
1237
|
+
// the tooltip names now that no title says it. Axes the caller hid keep their series'
|
|
1238
|
+
// labels as given, as they always did.
|
|
1239
|
+
const drawnAxes = budgeted.length
|
|
1240
|
+
? axes.map((axis) => (budgeted.includes(axis.id) ? { ...axis, hide: true as const } : axis))
|
|
1241
|
+
: axes;
|
|
1242
|
+
const lostUnit = new Map(
|
|
1243
|
+
axes.filter((axis) => budgeted.includes(axis.id)).map((axis) => [axis.id, axisUnit(axis)]),
|
|
1244
|
+
);
|
|
1116
1245
|
const config: ChartConfig = Object.fromEntries(
|
|
1117
1246
|
series.map((entry, index) => [
|
|
1118
1247
|
entry.key,
|
|
1119
|
-
{
|
|
1248
|
+
{
|
|
1249
|
+
label: withUnit(entry.label, lostUnit.get(entry.axis ?? DEFAULT_Y_AXIS)),
|
|
1250
|
+
color: entry.color ?? paletteFor(index),
|
|
1251
|
+
},
|
|
1120
1252
|
]),
|
|
1121
1253
|
);
|
|
1122
|
-
const visible =
|
|
1254
|
+
const visible = drawnAxes.filter((axis) => !axis.hide);
|
|
1123
1255
|
// The grid hangs its horizontal rules off ONE y axis, and recharts looks for the one
|
|
1124
1256
|
// whose id is its own default (`0`). Every axis here is named, so without saying
|
|
1125
1257
|
// which, it finds none and draws no horizontal rules at all. The first visible axis,
|
|
1126
1258
|
// because those are the ticks a reader puts a ruler on.
|
|
1127
|
-
const gridAxis = (visible[0] ??
|
|
1259
|
+
const gridAxis = (visible[0] ?? drawnAxes[0])?.id ?? DEFAULT_Y_AXIS;
|
|
1128
1260
|
const onLeft = visible.some((axis) => (axis.orientation ?? "left") === "left");
|
|
1129
1261
|
const onRight = visible.some((axis) => axis.orientation === "right");
|
|
1130
1262
|
// The margins are what is left once the axes have their bands: an axis band IS the
|
|
@@ -1359,6 +1491,8 @@ function SeriesPlot({
|
|
|
1359
1491
|
|
|
1360
1492
|
return (
|
|
1361
1493
|
<ChartContainer
|
|
1494
|
+
ref={setBox}
|
|
1495
|
+
data-axis-budget={budgeted.length ? budgetKey.replace(/\n/g, " ") : undefined}
|
|
1362
1496
|
config={config}
|
|
1363
1497
|
className={cn("w-full", heightClass, className, onPointClick && "cursor-pointer")}
|
|
1364
1498
|
style={heightStyle}
|
|
@@ -1422,7 +1556,7 @@ function SeriesPlot({
|
|
|
1422
1556
|
/>
|
|
1423
1557
|
)}
|
|
1424
1558
|
</XAxis>
|
|
1425
|
-
{
|
|
1559
|
+
{drawnAxes.map((axis) => {
|
|
1426
1560
|
const domain = fittedY(axis);
|
|
1427
1561
|
const zoomed = anchored(axis)
|
|
1428
1562
|
? zoom?.xDomain !== undefined
|
|
@@ -1439,7 +1573,11 @@ function SeriesPlot({
|
|
|
1439
1573
|
}
|
|
1440
1574
|
allowDataOverflow={zoomed}
|
|
1441
1575
|
orientation={axis.orientation ?? "left"}
|
|
1442
|
-
|
|
1576
|
+
// Nothing for a hidden axis. An axis that turns hidden on a MOUNTED chart —
|
|
1577
|
+
// what the budget does on every resize across its threshold — kept its old
|
|
1578
|
+
// width in recharts' stacking of that side, and pushed the axes still drawn
|
|
1579
|
+
// off the chart's edge. A zero width leaves nothing stale to count.
|
|
1580
|
+
width={axis.hide ? 0 : axisBandWidth(axis.width, Boolean(axis.title))}
|
|
1443
1581
|
tickLine={false}
|
|
1444
1582
|
axisLine={false}
|
|
1445
1583
|
tickFormatter={axis.format ?? number}
|
|
@@ -243,7 +243,11 @@ export const Slider = forwardRef<HTMLInputElement, SliderProps>(function Slider(
|
|
|
243
243
|
// cursor turns to no-drop and the thumb stops following the pointer for the
|
|
244
244
|
// rest of the gesture. The readout is only made unselectable when it is plain
|
|
245
245
|
// text — a NumberInput in that slot has to stay selectable to be editable.
|
|
246
|
-
|
|
246
|
+
// `flex-wrap`: a readout wider than the room beside the label (a value plus a
|
|
247
|
+
// link button, "0 / 60 / 120 km/h · Alle Geschwindigkeiten") used to print on
|
|
248
|
+
// top of it at 390px (lenkbank L1). Each keeps its own width, so the readout
|
|
249
|
+
// drops to a line of its own, still at the end, only when the two don't fit.
|
|
250
|
+
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5">
|
|
247
251
|
{label !== undefined && (
|
|
248
252
|
<label
|
|
249
253
|
htmlFor={inputId}
|
|
@@ -256,7 +260,7 @@ export const Slider = forwardRef<HTMLInputElement, SliderProps>(function Slider(
|
|
|
256
260
|
{readout !== undefined && (
|
|
257
261
|
<span
|
|
258
262
|
className={cn(
|
|
259
|
-
"ms-auto
|
|
263
|
+
"ms-auto max-w-full text-xs tabular-nums text-[var(--text-primary)]",
|
|
260
264
|
plainReadout && "select-none",
|
|
261
265
|
)}
|
|
262
266
|
>
|