@eifi1/ui-kit 0.15.2 → 0.15.3
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/components/chart.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { jsx, jsxs } from "react/jsx-runtime";
|
|
3
|
-
import { createContext, useContext, useId, useLayoutEffect, useRef, useState } from "react";
|
|
3
|
+
import { createContext, useContext, useEffect, useId, useLayoutEffect, useRef, useState } from "react";
|
|
4
4
|
import {
|
|
5
5
|
Legend as RechartsLegend,
|
|
6
6
|
ResponsiveContainer,
|
|
@@ -113,12 +113,48 @@ function ChartTooltipContent({
|
|
|
113
113
|
const tipWidth = tipRef.current?.offsetWidth ?? 160;
|
|
114
114
|
setFlip(visibleX + tipWidth + 12 > boundary.clientWidth);
|
|
115
115
|
}, [visible, x, boundaryRef, payload, label]);
|
|
116
|
+
const [nudge, setNudge] = useState(0);
|
|
117
|
+
const nudgeRef = useRef(0);
|
|
118
|
+
const [settled, setSettled] = useState(0);
|
|
119
|
+
useEffect(() => {
|
|
120
|
+
const wrapper = tipRef.current?.closest(".recharts-tooltip-wrapper");
|
|
121
|
+
if (!visible || !wrapper) return;
|
|
122
|
+
const onEnd = () => setSettled((n) => n + 1);
|
|
123
|
+
wrapper.addEventListener("transitionend", onEnd);
|
|
124
|
+
return () => wrapper.removeEventListener("transitionend", onEnd);
|
|
125
|
+
}, [visible]);
|
|
126
|
+
useLayoutEffect(() => {
|
|
127
|
+
const el = tipRef.current;
|
|
128
|
+
if (!visible || !el) {
|
|
129
|
+
nudgeRef.current = 0;
|
|
130
|
+
setNudge(0);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
const rect = el.getBoundingClientRect();
|
|
134
|
+
if (rect.width === 0) return;
|
|
135
|
+
const margin = 8;
|
|
136
|
+
const viewport = document.documentElement.clientWidth || window.innerWidth;
|
|
137
|
+
const left = rect.left - nudgeRef.current;
|
|
138
|
+
const right = left + rect.width;
|
|
139
|
+
let next = 0;
|
|
140
|
+
if (right > viewport - margin) next = viewport - margin - right;
|
|
141
|
+
if (left + next < margin) next = margin - left;
|
|
142
|
+
if (next !== nudgeRef.current) {
|
|
143
|
+
nudgeRef.current = next;
|
|
144
|
+
setNudge(next);
|
|
145
|
+
}
|
|
146
|
+
}, [visible, x, payload, label, flip, settled]);
|
|
116
147
|
if (!visible || !items) return null;
|
|
117
148
|
return /* @__PURE__ */ jsxs(
|
|
118
149
|
"div",
|
|
119
150
|
{
|
|
120
151
|
ref: tipRef,
|
|
121
|
-
style: boundaryRef
|
|
152
|
+
style: boundaryRef || nudge ? {
|
|
153
|
+
transform: [
|
|
154
|
+
boundaryRef ? flip ? "translateX(calc(-100% - 12px))" : "translateX(12px)" : "",
|
|
155
|
+
nudge ? `translateX(${nudge}px)` : ""
|
|
156
|
+
].filter(Boolean).join(" ")
|
|
157
|
+
} : void 0,
|
|
122
158
|
className: "min-w-[9rem] max-w-[min(20rem,calc(100vw-2rem))] rounded-lg border border-[var(--border)] bg-[var(--bg-surface)] px-2.5 py-2 text-xs shadow-xl",
|
|
123
159
|
children: [
|
|
124
160
|
!hideLabel && label != null && /* @__PURE__ */ jsx("div", { className: "mb-1.5 font-medium text-[var(--text-primary)]", children: labelFormatter ? labelFormatter(label) : label }),
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/components/chart.tsx"],"sourcesContent":["// shadcn/ui chart kit — vendored (MIT) and adapted for this app:\n// - Recharts 3 instead of 2\n// - the original keys its styling off shadcn theme tokens (--muted-foreground,\n// --border, bg-background …). This app has no shadcn token layer, so those\n// are mapped onto this kit's own tokens (--text-muted, --border, --bg-surface\n// …), which follow the active palette preset and flip with the theme by\n// themselves — hence no `dark:` variants anywhere below.\n// The value on show is the *structure*: config-driven colors injected as CSS\n// vars, muted axis/grid styling, and a polished tooltip/legend — the things\n// that make shadcn charts read as \"designed\" rather than \"default Recharts\".\nimport { createContext, useContext, useId, useLayoutEffect, useRef, useState } from \"react\";\nimport type { ComponentProps, CSSProperties, ReactNode, RefObject } from \"react\";\nimport {\n Legend as RechartsLegend,\n ResponsiveContainer,\n Tooltip as RechartsTooltip,\n} from \"recharts\";\nimport { cn } from \"../lib/cn\";\nimport { isRtl } from \"../lib/direction\";\n\nexport interface ChartSeriesConfig {\n label: ReactNode;\n color: string;\n}\n\nexport type ChartConfig = Record<string, ChartSeriesConfig>;\n\nconst ChartContext = createContext<ChartConfig | null>(null);\n\nexport function useChart(): ChartConfig {\n const ctx = useContext(ChartContext);\n if (!ctx) throw new Error(\"useChart must be used within a <ChartContainer />\");\n return ctx;\n}\n\ninterface ChartContainerProps extends ComponentProps<\"div\"> {\n config: ChartConfig;\n children: ComponentProps<typeof ResponsiveContainer>[\"children\"];\n}\n\n/**\n * The shell every chart in the kit is drawn inside.\n *\n * **The plot is not mirrored in RTL.** An x axis is a number line or a timeline, and\n * both run left-to-right in Hebrew and Arabic charts as much as in English ones; a\n * time axis flipped to run right-to-left is the surprise, not the courtesy. So no\n * `reversed` on any axis, and the SVG is pinned to `direction: ltr` — without that it\n * inherits the page's `rtl`, which flips what `text-anchor: end` means, and the tick\n * labels of a left-hand y axis are drawn INTO the plot instead of beside it. Text\n * inside the SVG still shapes by its own script; only the anchoring is physical.\n * The HTML around the plot — legend, tooltip, the zoom reset button — follows the\n * page's direction like any other markup. A consumer who does want a mirrored\n * category axis passes `reversed` on its own `XAxis`.\n */\nexport function ChartContainer({ id, className, children, config, ...props }: ChartContainerProps) {\n const uid = useId();\n // The id is pasted into an UNQUOTED `[data-chart=…]` selector below, so it has to be\n // a CSS identifier: one stray space or `#` and the browser discards the whole rule,\n // taking every series colour with it — silently, because an invalid rule is not an\n // error anywhere. `useId()` gives `:r0:`, and a consumer-supplied id gives whatever\n // they typed, so both are filtered rather than just the colons.\n const chartId = `chart-${(id ?? uid).replace(/[^A-Za-z0-9_-]/g, \"\")}`;\n return (\n <ChartContext.Provider value={config}>\n <div\n data-chart={chartId}\n className={cn(\n // touch-pan-y lets a vertical finger drag still scroll the page while\n // a horizontal drag is handed to recharts for tooltip scrubbing,\n // instead of the page scrolling underneath the gesture (feedback #292).\n \"flex w-full touch-pan-y justify-center text-xs\",\n // physical anchoring for the unmirrored plot (see the doc comment above)\n \"[&_.recharts-surface]:[direction:ltr]\",\n // muted axis labels + gridlines; one class each, since the tokens\n // already carry the light/dark values\n \"[&_.recharts-cartesian-axis-tick_text]:fill-[var(--text-muted)]\",\n \"[&_.recharts-cartesian-grid_line]:stroke-[var(--border)]/70\",\n // hairline hover cursor instead of the heavy default\n \"[&_.recharts-curve.recharts-tooltip-cursor]:stroke-[var(--border-strong)]\",\n \"[&_.recharts-rectangle.recharts-tooltip-cursor]:fill-[var(--bg-hover)]\",\n // kill the focus outlines & white sector strokes Recharts adds\n \"[&_.recharts-sector]:outline-none [&_.recharts-sector[stroke='#fff']]:stroke-transparent\",\n \"[&_.recharts-layer]:outline-none [&_.recharts-surface]:outline-none [&_.recharts-dot[stroke='#fff']]:stroke-transparent\",\n className,\n )}\n {...props}\n >\n <ChartStyle id={chartId} config={config} />\n <ResponsiveContainer width=\"100%\" height=\"100%\">\n {children}\n </ResponsiveContainer>\n </div>\n </ChartContext.Provider>\n );\n}\n\n/**\n * A config key, which becomes the tail of a custom-property NAME.\n *\n * `dangerouslySetInnerHTML` means what it says: nothing below is escaped by React, and\n * a `ChartConfig` is not necessarily written by hand — kastlan builds one from category\n * rows in its database, so these keys are user data. A key containing `}` closes this\n * rule, and whatever follows it in that key becomes a NEW rule — one that applies to\n * the whole document rather than to this one chart.\n */\nconst CSS_IDENT = /^[A-Za-z_][A-Za-z0-9_-]*$/;\n\n/**\n * A config colour, which becomes a declaration VALUE.\n *\n * Every form a palette actually produces: both hex spellings, the functional notations\n * — with the argument list restricted to the characters those functions can legally\n * contain, so no `;`, no `}` and no nested `url(` — and `var(--token)`, which is what\n * this kit's own `paletteFor`/`CHART_COLORS` return. `var()`'s optional FALLBACK is\n * deliberately not accepted: its second argument is an arbitrary value, i.e. the same\n * hole in a shape that looks safe.\n */\nconst CSS_COLOR =\n /^(?:#[0-9a-fA-F]{3}|#[0-9a-fA-F]{6}|(?:rgba?|hsla?)\\([0-9A-Za-z.,%/ -]*\\)|var\\(--[A-Za-z0-9_-]+\\))$/;\n\n/**\n * The colour an entry is painted with, or `undefined` when the style block refuses it.\n *\n * The one gate both sides go through: {@link ChartStyle} emits exactly these, and the\n * legend and tooltip swatches show exactly these. They used to read `config[key].color`\n * directly, so an entry the style block had skipped still showed its configured colour\n * in the key — a swatch promising a colour the series was never painted in.\n */\nfunction paintedColor(key: string, entry: ChartSeriesConfig | undefined): string | undefined {\n if (!entry?.color) return undefined;\n return CSS_IDENT.test(key) && CSS_COLOR.test(entry.color) ? entry.color : undefined;\n}\n\n// Inject each series colour as a `--color-<key>` custom property scoped to this\n// chart, so chart JSX can refer to `var(--color-total)` and the legend/tooltip\n// stay in sync from one source of truth.\nfunction ChartStyle({ id, config }: { id: string; config: ChartConfig }) {\n const entries = Object.entries(config).filter(([key, c]) => {\n if (!c.color) return false;\n // Skipping the entry costs that one series its `var(--color-…)` and leaves the rest\n // of the chart painted; emitting it unchecked costs the page. The warning is so the\n // consumer finds out from their console rather than from a colourless bar.\n if (!CSS_IDENT.test(key)) {\n console.warn(`[ui-kit] chart config key is not a CSS identifier, skipped: ${key}`);\n return false;\n }\n if (!CSS_COLOR.test(c.color)) {\n console.warn(`[ui-kit] chart config colour for \"${key}\" is not a colour, skipped: ${c.color}`);\n return false;\n }\n return true;\n });\n if (!entries.length) return null;\n const css = `[data-chart=${id}] {\\n${entries\n .map(([key, c]) => ` --color-${key}: ${c.color};`)\n .join(\"\\n\")}\\n}`;\n return <style dangerouslySetInnerHTML={{ __html: css }} />;\n}\n\n/**\n * recharts' `Tooltip`, with `filterNull` defaulting to **false**.\n *\n * recharts drops every payload entry whose value is `null`/`undefined` before the\n * content sees it, so the missing-value path {@link ChartTooltipContent} documents —\n * the em dash, `formatValue(undefined)` — never ran under the default: a series with a\n * gap at the cursor simply vanished from the tooltip, which reads as \"this series does\n * not exist here\" rather than \"it has no value here\". Kept, the entry prints \"—\".\n *\n * The other half of that filter — dropping series drawn with `hide` — is redone by\n * `ChartTooltipContent` itself (honouring `includeHidden`), so a legend toggle still\n * takes a series out of the tooltip. Pass `filterNull` to get recharts' behaviour back.\n */\nexport function ChartTooltip(props: ComponentProps<typeof RechartsTooltip>) {\n return <RechartsTooltip filterNull={false} {...props} />;\n}\nexport const ChartLegend = RechartsLegend;\n\ninterface TooltipPayloadItem {\n dataKey?: string | number;\n name?: string | number;\n value?: number | string;\n color?: string;\n /** A series drawn with `hide` — dropped here, see {@link ChartTooltip}. */\n hide?: boolean;\n payload?: Record<string, unknown>;\n}\n\n/**\n * What a series has no value for at this point: a gap in a history, a projection that\n * has not happened yet, a field that did not exist at an earlier capture.\n *\n * An em dash rather than an empty cell, so the row still lines up with its siblings and\n * the reader can see that the series WAS asked and had nothing, rather than wondering\n * whether it was dropped. It is a mark, not a word, so there is nothing here to\n * translate and no label prop to resolve; a caller who wants words passes\n * `formatValue`, which is handed the `undefined` and decides.\n */\nconst NO_VALUE = \"—\";\n\n/**\n * The number this payload item carries, or `undefined` when it carries none.\n *\n * This was `item.value ?? 0`, so every one of the absences above printed a confident\n * \"0\" that appears nowhere in the data — a fabricated figure in a tooltip, which is the\n * one place a reader goes to check an exact number. keksdose found it and wrapped the\n * component rather than trust it. `\"\"` and a non-numeric string are the same absence\n * spelled differently (recharts hands a string value straight through from the data),\n * and are answered the same way.\n */\nfunction tooltipValue(value: number | string | undefined): number | undefined {\n if (value == null || value === \"\") return undefined;\n const n = typeof value === \"string\" ? Number(value) : value;\n return Number.isFinite(n) ? n : undefined;\n}\n\ninterface ChartTooltipContentProps {\n active?: boolean;\n payload?: TooltipPayloadItem[];\n label?: string | number;\n indicator?: \"dot\" | \"line\";\n hideLabel?: boolean;\n labelFormatter?: (label: string | number) => ReactNode;\n /** Formats one series' value for display. NOT called when the series has no value at\n * this point — {@link ChartTooltipContentProps.formatValue} is the one that is. */\n valueFormatter?: (value: number) => ReactNode;\n /** Formats one series' value INCLUDING its absence: handed `undefined` for a gap, a\n * projection that has not happened yet, or a field that did not exist at an earlier\n * capture, and whatever it returns is rendered as-is. Only the caller knows whether\n * that absence reads as a dash, a \"not yet\" or a genuine zero, so this is how it\n * says. Supplying it wins over `valueFormatter` for every value.\n *\n * It sits NEXT to `valueFormatter` rather than widening it because that prop's\n * parameter is contextually typed at every call site there is — `(v) => money(v)` —\n * so widening it to `number | undefined` is a compile error in all three consuming\n * apps, which is a breaking change this module does not get to make by itself. */\n formatValue?: (value: number | undefined) => ReactNode;\n /** Cursor position within the chart, injected by recharts. Used with\n * {@link boundaryRef} to decide whether to flip the tooltip. */\n coordinate?: { x?: number; y?: number };\n /** When set, the tooltip flips to the LEFT of the cursor if it would otherwise\n * spill past the right edge of this (usually horizontally-scrolling) container\n * — e.g. a wide chart whose rightmost bars sat off-screen (feedback #77). */\n boundaryRef?: RefObject<HTMLElement | null>;\n /** Injected by recharts: keep series drawn with `hide` in the tooltip. */\n includeHidden?: boolean;\n}\n\nexport function ChartTooltipContent({\n active,\n payload,\n label,\n indicator = \"dot\",\n hideLabel = false,\n labelFormatter,\n valueFormatter,\n formatValue,\n coordinate,\n boundaryRef,\n includeHidden = false,\n}: ChartTooltipContentProps) {\n const config = useChart();\n const tipRef = useRef<HTMLDivElement>(null);\n // `ChartTooltip` keeps null values (so they can print \"—\"), which also keeps the\n // series a legend toggle switched off with `hide`; those go here instead.\n const items = includeHidden ? payload : payload?.filter((item) => item.hide !== true);\n const visible = Boolean(active && items?.length);\n // Recharts anchors the tooltip at coordinate.x inside the full (scrolled) chart\n // width; subtract the container's scrollLeft to get its on-screen x, then flip\n // left if the tooltip's own width would run past the visible right edge.\n //\n // Both the cursor and the flip are PHYSICAL (the plot is never mirrored, see\n // `ChartContainer`), but an RTL scroller counts `scrollLeft` from its right edge —\n // 0 at the start, negative going left — so there the content's left edge sits\n // `scrollWidth - clientWidth + scrollLeft` px to the left of the visible one.\n //\n // Measured in a layout effect (the DOM is not readable during render), so it commits\n // before paint. `payload` and `label` are deps because they change the tooltip's own\n // width, and recharts hands a fresh `payload` on every tooltip render, so a scroll\n // followed by any re-render is re-measured just as it was when this ran in render.\n const [flip, setFlip] = useState(false);\n const x = coordinate?.x;\n useLayoutEffect(() => {\n const boundary = boundaryRef?.current;\n if (!visible || !boundary || x == null) {\n setFlip(false);\n return;\n }\n const offset = isRtl(boundary)\n ? boundary.scrollWidth - boundary.clientWidth + boundary.scrollLeft\n : boundary.scrollLeft;\n const visibleX = x - offset;\n const tipWidth = tipRef.current?.offsetWidth ?? 160;\n setFlip(visibleX + tipWidth + 12 > boundary.clientWidth);\n }, [visible, x, boundaryRef, payload, label]);\n if (!visible || !items) return null;\n return (\n <div\n ref={tipRef}\n style={boundaryRef ? { transform: flip ? \"translateX(calc(-100% - 12px))\" : \"translateX(12px)\" } : undefined}\n // Capped at the viewport: six long series names (lenkbank's German motion\n // labels) made the box wider than a 390px chart, and recharts, which keeps a box\n // inside the plot only when it fits, let it run off the edge with the values cut.\n // Names wrap inside the cap; the values never do.\n className=\"min-w-[9rem] max-w-[min(20rem,calc(100vw-2rem))] rounded-lg border border-[var(--border)] bg-[var(--bg-surface)] px-2.5 py-2 text-xs shadow-xl\"\n >\n {!hideLabel && label != null && (\n <div className=\"mb-1.5 font-medium text-[var(--text-primary)]\">\n {labelFormatter ? labelFormatter(label) : label}\n </div>\n )}\n <div className=\"grid gap-1.5\">\n {items.map((item, i) => {\n // Bars/lines/areas identify a series by dataKey; pies/treemaps key off\n // `name` (the nameKey value). Prefer whichever the config knows.\n const dk = item.dataKey != null ? String(item.dataKey) : undefined;\n const nm = item.name != null ? String(item.name) : undefined;\n const cfgKey = dk && config[dk] ? dk : nm && config[nm] ? nm : (dk ?? nm ?? String(i));\n const series = config[cfgKey];\n const fill = typeof item.payload?.fill === \"string\" ? item.payload.fill : undefined;\n // The data point's own `fill` before recharts' `item.color`: a treemap tile,\n // or a bar with a per-cell colour, IS the colour the reader is pointing at,\n // while `item.color` is the series-wide one — every tile's swatch came out\n // `--chart-1` whatever colour the tile was.\n const color = paintedColor(cfgKey, series) ?? fill ?? item.color ?? \"#64748b\";\n const name = series?.label ?? nm ?? cfgKey;\n const raw = tooltipValue(item.value);\n let shown: ReactNode;\n if (formatValue) shown = formatValue(raw);\n else if (raw == null) shown = NO_VALUE;\n else shown = valueFormatter ? valueFormatter(raw) : raw;\n return (\n <div key={`${cfgKey}-${i}`} className=\"flex items-center gap-2\">\n <span\n className={cn(\n \"shrink-0 rounded-[2px]\",\n indicator === \"dot\" ? \"h-2.5 w-2.5 rounded-full\" : \"h-2.5 w-1\",\n )}\n style={{ backgroundColor: color }}\n />\n <span className=\"min-w-0 break-words text-[var(--text-muted)]\">{name}</span>\n <span\n data-private\n className=\"ms-auto shrink-0 whitespace-nowrap font-mono font-medium tabular-nums text-[var(--text-primary)]\"\n >\n {shown}\n </span>\n </div>\n );\n })}\n </div>\n </div>\n );\n}\n\ninterface LegendPayloadItem {\n value?: string;\n dataKey?: string | number;\n color?: string;\n}\n\ninterface ChartLegendContentProps {\n payload?: LegendPayloadItem[];\n onItemClick?: (key: string) => void;\n /**\n * TOGGLE mode (recommended): the series currently switched off. Each entry is a\n * toggle button — pressed while its series is shown — and a hidden one is drawn\n * faded with its label struck through. Several can be off at once; pair with\n * `toggleHidden(hidden, key)` in `onItemClick` and `hide` on the chart's series.\n */\n hiddenKeys?: ReadonlySet<string> | readonly string[];\n /**\n * ISOLATE mode: the one series shown in full, every other entry dimmed. Kept for\n * the charts built on it; a legend where each entry switches its own series is\n * what readers expect from a legend, so prefer `hiddenKeys`.\n */\n activeKey?: string | null;\n}\n\nexport function ChartLegendContent({\n payload,\n onItemClick,\n hiddenKeys,\n activeKey,\n}: ChartLegendContentProps) {\n const hidden = hiddenKeys ? new Set(hiddenKeys) : null;\n const config = useChart();\n if (!payload?.length) return null;\n return (\n <div className=\"flex flex-wrap items-center justify-center gap-x-4 gap-y-1 pt-3\">\n {payload.map((item, i) => {\n const dk = item.dataKey != null ? String(item.dataKey) : undefined;\n const vv = item.value != null ? String(item.value) : undefined;\n const cfgKey = dk && config[dk] ? dk : vv && config[vv] ? vv : (dk ?? vv ?? String(i));\n const series = config[cfgKey];\n const color = paintedColor(cfgKey, series) ?? item.color ?? \"#64748b\";\n const label = series?.label ?? vv ?? cfgKey;\n const off = hidden?.has(cfgKey) ?? false;\n const dimmed = off || (activeKey != null && activeKey !== cfgKey);\n const className = cn(\n \"flex items-center gap-1.5 text-[var(--text-secondary)] transition-opacity\",\n onItemClick ? \"cursor-pointer hover:opacity-80\" : \"cursor-default\",\n dimmed && \"opacity-40\",\n );\n const swatch = (\n <span\n aria-hidden\n className=\"h-2.5 w-2.5 shrink-0 rounded-[3px]\"\n style={{ backgroundColor: color } as CSSProperties}\n />\n );\n // A legend with no `onItemClick` is a KEY, not a control — and that is every\n // legend in kastlan, where this rendered N focusable buttons per chart that did\n // nothing when activated. A keyboard user paid the tab stops for all of them,\n // and a screen reader announced each one as a button, promising an action there\n // was none of. The interactive branch is unchanged.\n return onItemClick ? (\n <button\n key={`${cfgKey}-${i}`}\n type=\"button\"\n onClick={() => onItemClick(cfgKey)}\n // Toggles report their state: in toggle mode pressed = the series is shown;\n // in isolate mode pressed = the isolated one. With neither supplied the\n // legend knows nothing about state, and a pressed=\"false\" it cannot vouch\n // for would lie — so it says nothing.\n aria-pressed={\n hidden ? !off : activeKey === undefined ? undefined : activeKey === cfgKey\n }\n className={className}\n >\n {swatch}\n <span className={cn(off && \"line-through\")}>{label}</span>\n </button>\n ) : (\n <span key={`${cfgKey}-${i}`} className={className}>\n {swatch}\n {label}\n </span>\n );\n })}\n </div>\n );\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../../src/components/chart.tsx"],"sourcesContent":["// shadcn/ui chart kit — vendored (MIT) and adapted for this app:\n// - Recharts 3 instead of 2\n// - the original keys its styling off shadcn theme tokens (--muted-foreground,\n// --border, bg-background …). This app has no shadcn token layer, so those\n// are mapped onto this kit's own tokens (--text-muted, --border, --bg-surface\n// …), which follow the active palette preset and flip with the theme by\n// themselves — hence no `dark:` variants anywhere below.\n// The value on show is the *structure*: config-driven colors injected as CSS\n// vars, muted axis/grid styling, and a polished tooltip/legend — the things\n// that make shadcn charts read as \"designed\" rather than \"default Recharts\".\nimport { createContext, useContext, useEffect, useId, useLayoutEffect, useRef, useState } from \"react\";\nimport type { ComponentProps, CSSProperties, ReactNode, RefObject } from \"react\";\nimport {\n Legend as RechartsLegend,\n ResponsiveContainer,\n Tooltip as RechartsTooltip,\n} from \"recharts\";\nimport { cn } from \"../lib/cn\";\nimport { isRtl } from \"../lib/direction\";\n\nexport interface ChartSeriesConfig {\n label: ReactNode;\n color: string;\n}\n\nexport type ChartConfig = Record<string, ChartSeriesConfig>;\n\nconst ChartContext = createContext<ChartConfig | null>(null);\n\nexport function useChart(): ChartConfig {\n const ctx = useContext(ChartContext);\n if (!ctx) throw new Error(\"useChart must be used within a <ChartContainer />\");\n return ctx;\n}\n\ninterface ChartContainerProps extends ComponentProps<\"div\"> {\n config: ChartConfig;\n children: ComponentProps<typeof ResponsiveContainer>[\"children\"];\n}\n\n/**\n * The shell every chart in the kit is drawn inside.\n *\n * **The plot is not mirrored in RTL.** An x axis is a number line or a timeline, and\n * both run left-to-right in Hebrew and Arabic charts as much as in English ones; a\n * time axis flipped to run right-to-left is the surprise, not the courtesy. So no\n * `reversed` on any axis, and the SVG is pinned to `direction: ltr` — without that it\n * inherits the page's `rtl`, which flips what `text-anchor: end` means, and the tick\n * labels of a left-hand y axis are drawn INTO the plot instead of beside it. Text\n * inside the SVG still shapes by its own script; only the anchoring is physical.\n * The HTML around the plot — legend, tooltip, the zoom reset button — follows the\n * page's direction like any other markup. A consumer who does want a mirrored\n * category axis passes `reversed` on its own `XAxis`.\n */\nexport function ChartContainer({ id, className, children, config, ...props }: ChartContainerProps) {\n const uid = useId();\n // The id is pasted into an UNQUOTED `[data-chart=…]` selector below, so it has to be\n // a CSS identifier: one stray space or `#` and the browser discards the whole rule,\n // taking every series colour with it — silently, because an invalid rule is not an\n // error anywhere. `useId()` gives `:r0:`, and a consumer-supplied id gives whatever\n // they typed, so both are filtered rather than just the colons.\n const chartId = `chart-${(id ?? uid).replace(/[^A-Za-z0-9_-]/g, \"\")}`;\n return (\n <ChartContext.Provider value={config}>\n <div\n data-chart={chartId}\n className={cn(\n // touch-pan-y lets a vertical finger drag still scroll the page while\n // a horizontal drag is handed to recharts for tooltip scrubbing,\n // instead of the page scrolling underneath the gesture (feedback #292).\n \"flex w-full touch-pan-y justify-center text-xs\",\n // physical anchoring for the unmirrored plot (see the doc comment above)\n \"[&_.recharts-surface]:[direction:ltr]\",\n // muted axis labels + gridlines; one class each, since the tokens\n // already carry the light/dark values\n \"[&_.recharts-cartesian-axis-tick_text]:fill-[var(--text-muted)]\",\n \"[&_.recharts-cartesian-grid_line]:stroke-[var(--border)]/70\",\n // hairline hover cursor instead of the heavy default\n \"[&_.recharts-curve.recharts-tooltip-cursor]:stroke-[var(--border-strong)]\",\n \"[&_.recharts-rectangle.recharts-tooltip-cursor]:fill-[var(--bg-hover)]\",\n // kill the focus outlines & white sector strokes Recharts adds\n \"[&_.recharts-sector]:outline-none [&_.recharts-sector[stroke='#fff']]:stroke-transparent\",\n \"[&_.recharts-layer]:outline-none [&_.recharts-surface]:outline-none [&_.recharts-dot[stroke='#fff']]:stroke-transparent\",\n className,\n )}\n {...props}\n >\n <ChartStyle id={chartId} config={config} />\n <ResponsiveContainer width=\"100%\" height=\"100%\">\n {children}\n </ResponsiveContainer>\n </div>\n </ChartContext.Provider>\n );\n}\n\n/**\n * A config key, which becomes the tail of a custom-property NAME.\n *\n * `dangerouslySetInnerHTML` means what it says: nothing below is escaped by React, and\n * a `ChartConfig` is not necessarily written by hand — kastlan builds one from category\n * rows in its database, so these keys are user data. A key containing `}` closes this\n * rule, and whatever follows it in that key becomes a NEW rule — one that applies to\n * the whole document rather than to this one chart.\n */\nconst CSS_IDENT = /^[A-Za-z_][A-Za-z0-9_-]*$/;\n\n/**\n * A config colour, which becomes a declaration VALUE.\n *\n * Every form a palette actually produces: both hex spellings, the functional notations\n * — with the argument list restricted to the characters those functions can legally\n * contain, so no `;`, no `}` and no nested `url(` — and `var(--token)`, which is what\n * this kit's own `paletteFor`/`CHART_COLORS` return. `var()`'s optional FALLBACK is\n * deliberately not accepted: its second argument is an arbitrary value, i.e. the same\n * hole in a shape that looks safe.\n */\nconst CSS_COLOR =\n /^(?:#[0-9a-fA-F]{3}|#[0-9a-fA-F]{6}|(?:rgba?|hsla?)\\([0-9A-Za-z.,%/ -]*\\)|var\\(--[A-Za-z0-9_-]+\\))$/;\n\n/**\n * The colour an entry is painted with, or `undefined` when the style block refuses it.\n *\n * The one gate both sides go through: {@link ChartStyle} emits exactly these, and the\n * legend and tooltip swatches show exactly these. They used to read `config[key].color`\n * directly, so an entry the style block had skipped still showed its configured colour\n * in the key — a swatch promising a colour the series was never painted in.\n */\nfunction paintedColor(key: string, entry: ChartSeriesConfig | undefined): string | undefined {\n if (!entry?.color) return undefined;\n return CSS_IDENT.test(key) && CSS_COLOR.test(entry.color) ? entry.color : undefined;\n}\n\n// Inject each series colour as a `--color-<key>` custom property scoped to this\n// chart, so chart JSX can refer to `var(--color-total)` and the legend/tooltip\n// stay in sync from one source of truth.\nfunction ChartStyle({ id, config }: { id: string; config: ChartConfig }) {\n const entries = Object.entries(config).filter(([key, c]) => {\n if (!c.color) return false;\n // Skipping the entry costs that one series its `var(--color-…)` and leaves the rest\n // of the chart painted; emitting it unchecked costs the page. The warning is so the\n // consumer finds out from their console rather than from a colourless bar.\n if (!CSS_IDENT.test(key)) {\n console.warn(`[ui-kit] chart config key is not a CSS identifier, skipped: ${key}`);\n return false;\n }\n if (!CSS_COLOR.test(c.color)) {\n console.warn(`[ui-kit] chart config colour for \"${key}\" is not a colour, skipped: ${c.color}`);\n return false;\n }\n return true;\n });\n if (!entries.length) return null;\n const css = `[data-chart=${id}] {\\n${entries\n .map(([key, c]) => ` --color-${key}: ${c.color};`)\n .join(\"\\n\")}\\n}`;\n return <style dangerouslySetInnerHTML={{ __html: css }} />;\n}\n\n/**\n * recharts' `Tooltip`, with `filterNull` defaulting to **false**.\n *\n * recharts drops every payload entry whose value is `null`/`undefined` before the\n * content sees it, so the missing-value path {@link ChartTooltipContent} documents —\n * the em dash, `formatValue(undefined)` — never ran under the default: a series with a\n * gap at the cursor simply vanished from the tooltip, which reads as \"this series does\n * not exist here\" rather than \"it has no value here\". Kept, the entry prints \"—\".\n *\n * The other half of that filter — dropping series drawn with `hide` — is redone by\n * `ChartTooltipContent` itself (honouring `includeHidden`), so a legend toggle still\n * takes a series out of the tooltip. Pass `filterNull` to get recharts' behaviour back.\n */\nexport function ChartTooltip(props: ComponentProps<typeof RechartsTooltip>) {\n return <RechartsTooltip filterNull={false} {...props} />;\n}\nexport const ChartLegend = RechartsLegend;\n\ninterface TooltipPayloadItem {\n dataKey?: string | number;\n name?: string | number;\n value?: number | string;\n color?: string;\n /** A series drawn with `hide` — dropped here, see {@link ChartTooltip}. */\n hide?: boolean;\n payload?: Record<string, unknown>;\n}\n\n/**\n * What a series has no value for at this point: a gap in a history, a projection that\n * has not happened yet, a field that did not exist at an earlier capture.\n *\n * An em dash rather than an empty cell, so the row still lines up with its siblings and\n * the reader can see that the series WAS asked and had nothing, rather than wondering\n * whether it was dropped. It is a mark, not a word, so there is nothing here to\n * translate and no label prop to resolve; a caller who wants words passes\n * `formatValue`, which is handed the `undefined` and decides.\n */\nconst NO_VALUE = \"—\";\n\n/**\n * The number this payload item carries, or `undefined` when it carries none.\n *\n * This was `item.value ?? 0`, so every one of the absences above printed a confident\n * \"0\" that appears nowhere in the data — a fabricated figure in a tooltip, which is the\n * one place a reader goes to check an exact number. keksdose found it and wrapped the\n * component rather than trust it. `\"\"` and a non-numeric string are the same absence\n * spelled differently (recharts hands a string value straight through from the data),\n * and are answered the same way.\n */\nfunction tooltipValue(value: number | string | undefined): number | undefined {\n if (value == null || value === \"\") return undefined;\n const n = typeof value === \"string\" ? Number(value) : value;\n return Number.isFinite(n) ? n : undefined;\n}\n\ninterface ChartTooltipContentProps {\n active?: boolean;\n payload?: TooltipPayloadItem[];\n label?: string | number;\n indicator?: \"dot\" | \"line\";\n hideLabel?: boolean;\n labelFormatter?: (label: string | number) => ReactNode;\n /** Formats one series' value for display. NOT called when the series has no value at\n * this point — {@link ChartTooltipContentProps.formatValue} is the one that is. */\n valueFormatter?: (value: number) => ReactNode;\n /** Formats one series' value INCLUDING its absence: handed `undefined` for a gap, a\n * projection that has not happened yet, or a field that did not exist at an earlier\n * capture, and whatever it returns is rendered as-is. Only the caller knows whether\n * that absence reads as a dash, a \"not yet\" or a genuine zero, so this is how it\n * says. Supplying it wins over `valueFormatter` for every value.\n *\n * It sits NEXT to `valueFormatter` rather than widening it because that prop's\n * parameter is contextually typed at every call site there is — `(v) => money(v)` —\n * so widening it to `number | undefined` is a compile error in all three consuming\n * apps, which is a breaking change this module does not get to make by itself. */\n formatValue?: (value: number | undefined) => ReactNode;\n /** Cursor position within the chart, injected by recharts. Used with\n * {@link boundaryRef} to decide whether to flip the tooltip. */\n coordinate?: { x?: number; y?: number };\n /** When set, the tooltip flips to the LEFT of the cursor if it would otherwise\n * spill past the right edge of this (usually horizontally-scrolling) container\n * — e.g. a wide chart whose rightmost bars sat off-screen (feedback #77). */\n boundaryRef?: RefObject<HTMLElement | null>;\n /** Injected by recharts: keep series drawn with `hide` in the tooltip. */\n includeHidden?: boolean;\n}\n\nexport function ChartTooltipContent({\n active,\n payload,\n label,\n indicator = \"dot\",\n hideLabel = false,\n labelFormatter,\n valueFormatter,\n formatValue,\n coordinate,\n boundaryRef,\n includeHidden = false,\n}: ChartTooltipContentProps) {\n const config = useChart();\n const tipRef = useRef<HTMLDivElement>(null);\n // `ChartTooltip` keeps null values (so they can print \"—\"), which also keeps the\n // series a legend toggle switched off with `hide`; those go here instead.\n const items = includeHidden ? payload : payload?.filter((item) => item.hide !== true);\n const visible = Boolean(active && items?.length);\n // Recharts anchors the tooltip at coordinate.x inside the full (scrolled) chart\n // width; subtract the container's scrollLeft to get its on-screen x, then flip\n // left if the tooltip's own width would run past the visible right edge.\n //\n // Both the cursor and the flip are PHYSICAL (the plot is never mirrored, see\n // `ChartContainer`), but an RTL scroller counts `scrollLeft` from its right edge —\n // 0 at the start, negative going left — so there the content's left edge sits\n // `scrollWidth - clientWidth + scrollLeft` px to the left of the visible one.\n //\n // Measured in a layout effect (the DOM is not readable during render), so it commits\n // before paint. `payload` and `label` are deps because they change the tooltip's own\n // width, and recharts hands a fresh `payload` on every tooltip render, so a scroll\n // followed by any re-render is re-measured just as it was when this ran in render.\n const [flip, setFlip] = useState(false);\n const x = coordinate?.x;\n useLayoutEffect(() => {\n const boundary = boundaryRef?.current;\n if (!visible || !boundary || x == null) {\n setFlip(false);\n return;\n }\n const offset = isRtl(boundary)\n ? boundary.scrollWidth - boundary.clientWidth + boundary.scrollLeft\n : boundary.scrollLeft;\n const visibleX = x - offset;\n const tipWidth = tipRef.current?.offsetWidth ?? 160;\n setFlip(visibleX + tipWidth + 12 > boundary.clientWidth);\n }, [visible, x, boundaryRef, payload, label]);\n\n // Whatever placed it — recharts' own rule or the boundary flip above — the box then\n // clamps itself into the viewport, 8px off each edge. The width cap alone did not\n // keep it on screen: on a 390px phone recharts opened a near-full-width tooltip to\n // the RIGHT of a pointer 35% into the plot, and the values ran off the edge\n // (lenkbank, on 0.15.2). Measured after placement, minus the shift it already has,\n // so a re-measure never compounds; an unmeasured box (no layout) is left alone.\n const [nudge, setNudge] = useState(0);\n const nudgeRef = useRef(0);\n // recharts slides its wrapper to each new position with a CSS transition, so the box\n // measured on commit is still where it WAS; measure again once the slide ends.\n const [settled, setSettled] = useState(0);\n useEffect(() => {\n const wrapper = tipRef.current?.closest<HTMLElement>(\".recharts-tooltip-wrapper\");\n if (!visible || !wrapper) return;\n const onEnd = () => setSettled((n) => n + 1);\n wrapper.addEventListener(\"transitionend\", onEnd);\n return () => wrapper.removeEventListener(\"transitionend\", onEnd);\n }, [visible]);\n useLayoutEffect(() => {\n const el = tipRef.current;\n if (!visible || !el) {\n nudgeRef.current = 0;\n setNudge(0);\n return;\n }\n const rect = el.getBoundingClientRect();\n if (rect.width === 0) return;\n const margin = 8;\n const viewport = document.documentElement.clientWidth || window.innerWidth;\n const left = rect.left - nudgeRef.current;\n const right = left + rect.width;\n let next = 0;\n if (right > viewport - margin) next = viewport - margin - right;\n if (left + next < margin) next = margin - left;\n if (next !== nudgeRef.current) {\n nudgeRef.current = next;\n setNudge(next);\n }\n }, [visible, x, payload, label, flip, settled]);\n if (!visible || !items) return null;\n return (\n <div\n ref={tipRef}\n style={\n boundaryRef || nudge\n ? {\n transform: [\n boundaryRef ? (flip ? \"translateX(calc(-100% - 12px))\" : \"translateX(12px)\") : \"\",\n nudge ? `translateX(${nudge}px)` : \"\",\n ]\n .filter(Boolean)\n .join(\" \"),\n }\n : undefined\n }\n // Capped at the viewport: six long series names (lenkbank's German motion\n // labels) made the box wider than a 390px chart, and recharts, which keeps a box\n // inside the plot only when it fits, let it run off the edge with the values cut.\n // Names wrap inside the cap; the values never do.\n className=\"min-w-[9rem] max-w-[min(20rem,calc(100vw-2rem))] rounded-lg border border-[var(--border)] bg-[var(--bg-surface)] px-2.5 py-2 text-xs shadow-xl\"\n >\n {!hideLabel && label != null && (\n <div className=\"mb-1.5 font-medium text-[var(--text-primary)]\">\n {labelFormatter ? labelFormatter(label) : label}\n </div>\n )}\n <div className=\"grid gap-1.5\">\n {items.map((item, i) => {\n // Bars/lines/areas identify a series by dataKey; pies/treemaps key off\n // `name` (the nameKey value). Prefer whichever the config knows.\n const dk = item.dataKey != null ? String(item.dataKey) : undefined;\n const nm = item.name != null ? String(item.name) : undefined;\n const cfgKey = dk && config[dk] ? dk : nm && config[nm] ? nm : (dk ?? nm ?? String(i));\n const series = config[cfgKey];\n const fill = typeof item.payload?.fill === \"string\" ? item.payload.fill : undefined;\n // The data point's own `fill` before recharts' `item.color`: a treemap tile,\n // or a bar with a per-cell colour, IS the colour the reader is pointing at,\n // while `item.color` is the series-wide one — every tile's swatch came out\n // `--chart-1` whatever colour the tile was.\n const color = paintedColor(cfgKey, series) ?? fill ?? item.color ?? \"#64748b\";\n const name = series?.label ?? nm ?? cfgKey;\n const raw = tooltipValue(item.value);\n let shown: ReactNode;\n if (formatValue) shown = formatValue(raw);\n else if (raw == null) shown = NO_VALUE;\n else shown = valueFormatter ? valueFormatter(raw) : raw;\n return (\n <div key={`${cfgKey}-${i}`} className=\"flex items-center gap-2\">\n <span\n className={cn(\n \"shrink-0 rounded-[2px]\",\n indicator === \"dot\" ? \"h-2.5 w-2.5 rounded-full\" : \"h-2.5 w-1\",\n )}\n style={{ backgroundColor: color }}\n />\n <span className=\"min-w-0 break-words text-[var(--text-muted)]\">{name}</span>\n <span\n data-private\n className=\"ms-auto shrink-0 whitespace-nowrap font-mono font-medium tabular-nums text-[var(--text-primary)]\"\n >\n {shown}\n </span>\n </div>\n );\n })}\n </div>\n </div>\n );\n}\n\ninterface LegendPayloadItem {\n value?: string;\n dataKey?: string | number;\n color?: string;\n}\n\ninterface ChartLegendContentProps {\n payload?: LegendPayloadItem[];\n onItemClick?: (key: string) => void;\n /**\n * TOGGLE mode (recommended): the series currently switched off. Each entry is a\n * toggle button — pressed while its series is shown — and a hidden one is drawn\n * faded with its label struck through. Several can be off at once; pair with\n * `toggleHidden(hidden, key)` in `onItemClick` and `hide` on the chart's series.\n */\n hiddenKeys?: ReadonlySet<string> | readonly string[];\n /**\n * ISOLATE mode: the one series shown in full, every other entry dimmed. Kept for\n * the charts built on it; a legend where each entry switches its own series is\n * what readers expect from a legend, so prefer `hiddenKeys`.\n */\n activeKey?: string | null;\n}\n\nexport function ChartLegendContent({\n payload,\n onItemClick,\n hiddenKeys,\n activeKey,\n}: ChartLegendContentProps) {\n const hidden = hiddenKeys ? new Set(hiddenKeys) : null;\n const config = useChart();\n if (!payload?.length) return null;\n return (\n <div className=\"flex flex-wrap items-center justify-center gap-x-4 gap-y-1 pt-3\">\n {payload.map((item, i) => {\n const dk = item.dataKey != null ? String(item.dataKey) : undefined;\n const vv = item.value != null ? String(item.value) : undefined;\n const cfgKey = dk && config[dk] ? dk : vv && config[vv] ? vv : (dk ?? vv ?? String(i));\n const series = config[cfgKey];\n const color = paintedColor(cfgKey, series) ?? item.color ?? \"#64748b\";\n const label = series?.label ?? vv ?? cfgKey;\n const off = hidden?.has(cfgKey) ?? false;\n const dimmed = off || (activeKey != null && activeKey !== cfgKey);\n const className = cn(\n \"flex items-center gap-1.5 text-[var(--text-secondary)] transition-opacity\",\n onItemClick ? \"cursor-pointer hover:opacity-80\" : \"cursor-default\",\n dimmed && \"opacity-40\",\n );\n const swatch = (\n <span\n aria-hidden\n className=\"h-2.5 w-2.5 shrink-0 rounded-[3px]\"\n style={{ backgroundColor: color } as CSSProperties}\n />\n );\n // A legend with no `onItemClick` is a KEY, not a control — and that is every\n // legend in kastlan, where this rendered N focusable buttons per chart that did\n // nothing when activated. A keyboard user paid the tab stops for all of them,\n // and a screen reader announced each one as a button, promising an action there\n // was none of. The interactive branch is unchanged.\n return onItemClick ? (\n <button\n key={`${cfgKey}-${i}`}\n type=\"button\"\n onClick={() => onItemClick(cfgKey)}\n // Toggles report their state: in toggle mode pressed = the series is shown;\n // in isolate mode pressed = the isolated one. With neither supplied the\n // legend knows nothing about state, and a pressed=\"false\" it cannot vouch\n // for would lie — so it says nothing.\n aria-pressed={\n hidden ? !off : activeKey === undefined ? undefined : activeKey === cfgKey\n }\n className={className}\n >\n {swatch}\n <span className={cn(off && \"line-through\")}>{label}</span>\n </button>\n ) : (\n <span key={`${cfgKey}-${i}`} className={className}>\n {swatch}\n {label}\n </span>\n );\n })}\n </div>\n );\n}\n"],"mappings":"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","names":[]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@eifi1/ui-kit",
|
|
3
|
-
"version": "0.15.
|
|
3
|
+
"version": "0.15.3",
|
|
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.",
|
package/src/components/chart.tsx
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// The value on show is the *structure*: config-driven colors injected as CSS
|
|
9
9
|
// vars, muted axis/grid styling, and a polished tooltip/legend — the things
|
|
10
10
|
// that make shadcn charts read as "designed" rather than "default Recharts".
|
|
11
|
-
import { createContext, useContext, useId, useLayoutEffect, useRef, useState } from "react";
|
|
11
|
+
import { createContext, useContext, useEffect, useId, useLayoutEffect, useRef, useState } from "react";
|
|
12
12
|
import type { ComponentProps, CSSProperties, ReactNode, RefObject } from "react";
|
|
13
13
|
import {
|
|
14
14
|
Legend as RechartsLegend,
|
|
@@ -292,11 +292,62 @@ export function ChartTooltipContent({
|
|
|
292
292
|
const tipWidth = tipRef.current?.offsetWidth ?? 160;
|
|
293
293
|
setFlip(visibleX + tipWidth + 12 > boundary.clientWidth);
|
|
294
294
|
}, [visible, x, boundaryRef, payload, label]);
|
|
295
|
+
|
|
296
|
+
// Whatever placed it — recharts' own rule or the boundary flip above — the box then
|
|
297
|
+
// clamps itself into the viewport, 8px off each edge. The width cap alone did not
|
|
298
|
+
// keep it on screen: on a 390px phone recharts opened a near-full-width tooltip to
|
|
299
|
+
// the RIGHT of a pointer 35% into the plot, and the values ran off the edge
|
|
300
|
+
// (lenkbank, on 0.15.2). Measured after placement, minus the shift it already has,
|
|
301
|
+
// so a re-measure never compounds; an unmeasured box (no layout) is left alone.
|
|
302
|
+
const [nudge, setNudge] = useState(0);
|
|
303
|
+
const nudgeRef = useRef(0);
|
|
304
|
+
// recharts slides its wrapper to each new position with a CSS transition, so the box
|
|
305
|
+
// measured on commit is still where it WAS; measure again once the slide ends.
|
|
306
|
+
const [settled, setSettled] = useState(0);
|
|
307
|
+
useEffect(() => {
|
|
308
|
+
const wrapper = tipRef.current?.closest<HTMLElement>(".recharts-tooltip-wrapper");
|
|
309
|
+
if (!visible || !wrapper) return;
|
|
310
|
+
const onEnd = () => setSettled((n) => n + 1);
|
|
311
|
+
wrapper.addEventListener("transitionend", onEnd);
|
|
312
|
+
return () => wrapper.removeEventListener("transitionend", onEnd);
|
|
313
|
+
}, [visible]);
|
|
314
|
+
useLayoutEffect(() => {
|
|
315
|
+
const el = tipRef.current;
|
|
316
|
+
if (!visible || !el) {
|
|
317
|
+
nudgeRef.current = 0;
|
|
318
|
+
setNudge(0);
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
const rect = el.getBoundingClientRect();
|
|
322
|
+
if (rect.width === 0) return;
|
|
323
|
+
const margin = 8;
|
|
324
|
+
const viewport = document.documentElement.clientWidth || window.innerWidth;
|
|
325
|
+
const left = rect.left - nudgeRef.current;
|
|
326
|
+
const right = left + rect.width;
|
|
327
|
+
let next = 0;
|
|
328
|
+
if (right > viewport - margin) next = viewport - margin - right;
|
|
329
|
+
if (left + next < margin) next = margin - left;
|
|
330
|
+
if (next !== nudgeRef.current) {
|
|
331
|
+
nudgeRef.current = next;
|
|
332
|
+
setNudge(next);
|
|
333
|
+
}
|
|
334
|
+
}, [visible, x, payload, label, flip, settled]);
|
|
295
335
|
if (!visible || !items) return null;
|
|
296
336
|
return (
|
|
297
337
|
<div
|
|
298
338
|
ref={tipRef}
|
|
299
|
-
style={
|
|
339
|
+
style={
|
|
340
|
+
boundaryRef || nudge
|
|
341
|
+
? {
|
|
342
|
+
transform: [
|
|
343
|
+
boundaryRef ? (flip ? "translateX(calc(-100% - 12px))" : "translateX(12px)") : "",
|
|
344
|
+
nudge ? `translateX(${nudge}px)` : "",
|
|
345
|
+
]
|
|
346
|
+
.filter(Boolean)
|
|
347
|
+
.join(" "),
|
|
348
|
+
}
|
|
349
|
+
: undefined
|
|
350
|
+
}
|
|
300
351
|
// Capped at the viewport: six long series names (lenkbank's German motion
|
|
301
352
|
// labels) made the box wider than a 390px chart, and recharts, which keeps a box
|
|
302
353
|
// inside the plot only when it fits, let it run off the edge with the values cut.
|