@eifi1/ui-kit 0.15.1 → 0.15.2

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.
@@ -88,8 +88,9 @@ function AlertBanner({
88
88
  children: icon
89
89
  }
90
90
  ) : /* @__PURE__ */ jsx(Glyph, { "aria-hidden": true, className: cn(sm ? "size-3.5" : "size-4", "shrink-0", nudge, TONE_ICON[tone]) });
91
+ const boxWraps = !inline && !strip && action !== void 0 && action !== null;
91
92
  const row = cn(
92
- inlineRow ? "inline-flex items-center" : strip ? "flex flex-wrap items-center gap-y-1" : "flex items-start",
93
+ inlineRow ? "inline-flex items-center" : strip ? "flex flex-wrap items-center gap-y-1" : cn("flex items-start", boxWraps && "max-sm:flex-wrap max-sm:gap-y-2"),
93
94
  sm ? "gap-1.5 text-xs" : inline ? "gap-1.5 text-sm" : "gap-2 text-sm"
94
95
  );
95
96
  const grow = strip ? "flex-[1_1_12rem]" : "flex-1";
@@ -129,7 +130,19 @@ function AlertBanner({
129
130
  children: /* @__PURE__ */ jsx(X, { "aria-hidden": true, className: "size-4" })
130
131
  }
131
132
  ) : null;
132
- const trailing = action !== void 0 && action !== null ? /* @__PURE__ */ jsx("div", { className: cn("relative z-10 flex shrink-0 items-center gap-2", !strip && !inlineRow && "self-center", strip && "ms-auto"), children: action }) : null;
133
+ const trailing = action !== void 0 && action !== null ? /* @__PURE__ */ jsx(
134
+ "div",
135
+ {
136
+ className: cn(
137
+ "relative z-10 flex shrink-0 items-center gap-2",
138
+ !strip && !inlineRow && "self-center",
139
+ strip && "ms-auto",
140
+ // Last, on a line of its own, lined up with the message (past the glyph).
141
+ boxWraps && cn("max-sm:order-last max-sm:basis-full", glyph && (sm ? "max-sm:ps-5" : "max-sm:ps-6"))
142
+ ),
143
+ children: action
144
+ }
145
+ ) : null;
133
146
  if (!interactive) {
134
147
  return /* @__PURE__ */ jsxs(
135
148
  "div",
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/components/alert-banner.tsx"],"sourcesContent":["import type { ComponentPropsWithoutRef, MouseEvent, ReactNode } from \"react\";\nimport { AlertTriangle, CheckCircle2, ChevronRight, Info, X } from \"lucide-react\";\nimport { cn } from \"../lib/cn\";\nimport { DEFAULT_COMMON_LABELS, useKitLabels } from \"../i18n/kit-labels\";\n\n/** `info` (0.8.0) is the sky family, for news that is neither good nor bad: keksdose's\n * preview and beta-performance banners, which borrowed `warning` for want of it and\n * so told every user of the preview build that something was wrong. */\nexport type AlertTone = \"danger\" | \"warning\" | \"info\" | \"success\" | \"neutral\";\n\n/** `sm` (0.10.0): 12px type and a 14px glyph — the hint line under a field or a total\n * that keksdose spells `text-xs text-amber-700 dark:text-amber-400` by hand\n * (accounts-page's missing-rate note, import-map-step, guest-key-control's two\n * key states, goal-form). At body size those read as a second heading. */\nexport type AlertSize = \"sm\" | \"md\";\n\n/** Border + surface per tone, WITHOUT a radius or padding, so a caller that owns\n * its own box (a Card, say) can take the tone alone. Single source for both\n * {@link alertFrameClass} and {@link toneFrameClass}.\n *\n * The colored tones are 2px, not 1px, and that is load-bearing rather than\n * decorative. A 1px border is 1.25 device pixels on a 125%-scaled display — the\n * usual Windows setting — so whether it renders as a solid line or as two\n * half-lit pixels depends on where the box happens to land in device-pixel space.\n * The same card then shows a heavy left edge and a right edge that fades out,\n * which is what feedback #264, #277, #491 and #498 all reported. A 2px border is\n * 2.5 device pixels: it always covers at least two pixels fully, on both edges,\n * at any offset. Callers compensate the extra pixel in their own padding so\n * switching tones never shifts the layout. */\nconst TONE_FRAME: Record<AlertTone, string> = {\n danger: \"border-2 border-[var(--danger-border)] bg-[var(--danger-bg)]\",\n warning: \"border-2 border-[var(--warning-border)] bg-[var(--warning-bg)]\",\n info: \"border-2 border-[var(--info-border)] bg-[var(--info-bg)]\",\n success: \"border-2 border-[var(--success-border)] bg-[var(--success-bg)]\",\n neutral: \"border border-[var(--border)]\",\n};\n\n/** `elevated`: the same frame on an OPAQUE surface. The dark tone washes are\n * translucent (a 20–25 % tint), which is right on a page and wrong on anything that\n * floats over one — keksdose's server-wake notice (#209) let the page's own text\n * show through the one message whose job is to be read when nothing else makes\n * sense. So the surface becomes `--bg-surface` and the tone's wash is layered on it\n * as a background IMAGE (a flat gradient), which keeps the exact tint in both themes\n * without a second, opaque copy of every `-bg` token. */\nconst TONE_ELEVATED: Record<AlertTone, string> = {\n danger: \"bg-[image:linear-gradient(var(--danger-bg),var(--danger-bg))]\",\n warning: \"bg-[image:linear-gradient(var(--warning-bg),var(--warning-bg))]\",\n info: \"bg-[image:linear-gradient(var(--info-bg),var(--info-bg))]\",\n success: \"bg-[image:linear-gradient(var(--success-bg),var(--success-bg))]\",\n neutral: \"\",\n};\nconst ELEVATED = \"bg-[var(--bg-surface)] shadow-lg\";\n\n/** The tone's border + surface on their own — for a caller that already has a box\n * with its own radius and padding (e.g. a Card). Remember to shave 1px off that\n * padding for the colored tones, whose border is 2px wide. */\nexport function toneFrameClass(tone: AlertTone): string {\n return TONE_FRAME[tone];\n}\n\n/** Single source for the warning-callout frame (feedback #277): the tone's border\n * and surface plus this component's own radius and padding, the latter\n * compensating the 2px colored border so toggling tones never shifts layout. */\nexport function alertFrameClass(tone: AlertTone, size: AlertSize = \"md\"): string {\n const box =\n size === \"sm\"\n ? tone === \"neutral\"\n ? \"rounded-md px-2.5 py-1.5\"\n : \"rounded-md px-[9px] py-[5px]\"\n : tone === \"neutral\"\n ? \"rounded-md p-3\"\n : \"rounded-md p-[11px]\";\n return `${box} ${TONE_FRAME[tone]}`;\n}\n\n/** The `strip` frame: the tone's surface edge to edge, no radius, and only the BOTTOM\n * border, 1px — a strip is a band across the top of a page, and its one visible edge\n * is the line it draws under itself. kastlan's trial, past-due and tour banners. */\nfunction stripFrameClass(tone: AlertTone, size: AlertSize): string {\n return cn(\n TONE_FRAME[tone],\n \"rounded-none border-0 border-b\",\n size === \"sm\" ? \"px-4 py-1.5\" : \"px-4 py-2\",\n );\n}\n\nconst TONE_TEXT: Record<AlertTone, string> = {\n danger: \"text-[var(--danger)]\",\n warning: \"text-[var(--warning)]\",\n info: \"text-[var(--info)]\",\n success: \"text-[var(--success)]\",\n neutral: \"text-[var(--text-secondary)]\",\n};\n\nconst TONE_ICON: Record<AlertTone, string> = {\n danger: \"text-[var(--danger)]\",\n warning: \"text-[var(--warning)]\",\n info: \"text-[var(--info)]\",\n success: \"text-[var(--success)]\",\n neutral: \"text-[var(--text-muted)]\",\n};\n\n/** Which glyph a tone draws when the caller names none. The triangle belongs to the\n * two tones that mean \"something is wrong\"; `info` and `neutral` get the ⓘ. That is\n * what makes `neutral` safe to offer at all: until 0.8.0 it was withheld because a\n * triangle with no colour is a warning that looks like a note — and a neutral note\n * that draws no triangle is simply a note (lenkbank's \"values are rounded\"). */\nconst TONE_GLYPH: Record<AlertTone, typeof AlertTriangle> = {\n danger: AlertTriangle,\n warning: AlertTriangle,\n info: Info,\n success: CheckCircle2,\n neutral: Info,\n};\n\ninterface AlertBannerBaseProps extends Omit<ComponentPropsWithoutRef<\"div\">, \"onClick\"> {\n /** Default `danger`. `info` for news, `neutral` for a plain note — see\n * {@link TONE_GLYPH} for why a neutral banner is now allowed. */\n tone?: AlertTone;\n /**\n * `box` (default): the framed callout. `inline`: the tone's colour, glyph and role\n * and NOTHING else — no border, no surface, no padding — so it fits inside a\n * toolbar row beside the controls it is about. Lenkbank's corner-motion toolbar\n * shows \"Radius below the minimum\" and \"Solving…\" there; a box would have doubled\n * the row's height every time either appeared.\n *\n * The inline variant is also a LIVE REGION by default — `role=\"alert\"` for\n * `danger`, `role=\"status\"` for the rest — because a message that appears in a\n * toolbar in answer to what the user just did is exactly the kind a screen reader\n * otherwise never hears. The box stays role-less as it always was: it is usually\n * page furniture present at load, and an alert that fires on every navigation is\n * noise. Either way a `role` you pass wins.\n *\n * `strip` (0.10.0): a band across the top of a page or a panel — edge to edge, no\n * radius, only a bottom border, content centred on one line, and usually an\n * {@link action}. kastlan's billing trial-banner and past-due-banner and its tours\n * tour-banner each built this by hand in amber or red Tailwind classes, with a\n * separate dark: spelling apiece. Role-less like the box: it is page furniture.\n */\n variant?: \"box\" | \"inline\" | \"strip\";\n /** See {@link AlertSize}. Default `md`. */\n size?: AlertSize;\n /**\n * `inline` only (0.11.0): lay the message out as a BLOCK — a full-width `flex` row,\n * the glyph on the text's FIRST line when it wraps — instead of the `inline-flex`,\n * centred line it is by default. keksdose writes `className=\"flex\"` on nearly every\n * inline banner it has (accounts-page:564, holdings-panel:631, import-map-step:66/314,\n * guest-key-control:84) to get a line of its own under a total or a field, and a\n * two-line hint there then centres its glyph between the lines.\n *\n * An option rather than the new default, because the inline-flex is load-bearing for\n * the callers the variant was made for: lenkbank's corner-motion toolbar\n * (corner-motion.tsx:178) sets it as a `shrink-0` item beside the controls, kastlan's\n * import page and keksdose's rules page place it in a run of content — a block would\n * take the whole row in the second kind, and top-align the first against a taller\n * control. Ignored by `box` and `strip`, which are blocks already.\n */\n block?: boolean;\n /**\n * `false` opts an inline banner out of its live region (no `role=\"alert\"` /\n * `role=\"status\"`), for a message that is static page content rather than the answer\n * to something the user just did: keksdose's CAMT review step (camt-review-step:214)\n * renders \"unresolved rows block the import\" as part of the step, and as\n * `role=\"alert\"` a screen reader interrupted with it on every mount. A `role` you pass\n * still wins. Default `true`; the box and the strip are role-less either way.\n */\n live?: boolean;\n /**\n * An opaque, raised surface (a shadow, and no translucency in dark mode) for a\n * notice that floats over the page — see {@link TONE_ELEVATED}. `box` and `strip`\n * only; an inline message has no surface to raise.\n */\n elevated?: boolean;\n /**\n * A trailing control — the \"Update payment method\" button on kastlan's trial and\n * past-due strips, the \"Exit\" on its tour strip. Sits at the end of the row,\n * vertically centred, and never shrinks. On a whole-row banner (`href`/`onClick`)\n * it is rendered BESIDE the row's own link or button, as the × is, because\n * interactive content may not nest.\n */\n action?: ReactNode;\n /** Replaces the tone's glyph — a `<Spinner label={null} />` for \"Solving…\", say.\n * `null` draws none. Always decorative; the text carries the message. */\n icon?: ReactNode;\n /** Renders an × at the end that calls this. The banner does not hide itself — the\n * caller removes it (and remembers that it did), as with every other kit control\n * whose state is app state. */\n onDismiss?: () => void;\n /** Accessible name of the ×. Default: `common.dismiss` from the\n * {@link UiKitProvider}, else \"Dismiss\". */\n dismissLabel?: string;\n children: ReactNode;\n}\n\n/**\n * The whole-row modes. keksdose's budget-summary card is a banner you tap to open\n * the budget; built from a `<div onClick>` it was a mouse-only target, and built as a\n * banner with a link inside it was a small target inside a big one that looked like\n * one. So `onClick` makes the banner itself ONE `<button>`, and `href` makes it one\n * `<a>`, with a trailing chevron that says so. Mutually exclusive, as on `Chip`.\n *\n * With `onDismiss` as well, the × cannot go inside the button (interactive content\n * may not nest), so the frame becomes a wrapper holding two siblings: the row's\n * button, stretched over the whole frame, and the × on top of it. It still looks and\n * clicks like one banner with an × in its corner, and the two are separate tab stops.\n */\nexport type AlertBannerProps = AlertBannerBaseProps &\n (\n | {\n /** Makes the whole banner one link. Mutually exclusive with `onClick`. */\n href?: string;\n onClick?: never;\n }\n | {\n href?: never;\n /** Makes the whole banner one button. Mutually exclusive with `href`. The\n * event's element is the `<button>`, not a `<div>`. */\n onClick?: (event: MouseEvent<HTMLElement>) => void;\n }\n );\n\n/** Warning/danger/info callout box with the shared frame and an icon — or, with\n * `variant=\"inline\"`, the same message as a frameless line of toned text. */\nexport function AlertBanner({\n tone = \"danger\",\n variant = \"box\",\n size = \"md\",\n block = false,\n live = true,\n elevated = false,\n action,\n icon,\n onDismiss,\n dismissLabel,\n href,\n onClick,\n className,\n children,\n role,\n ...rest\n}: AlertBannerProps) {\n const common = useKitLabels(\"common\", DEFAULT_COMMON_LABELS, { dismiss: dismissLabel });\n const inline = variant === \"inline\";\n const strip = variant === \"strip\";\n const sm = size === \"sm\";\n const interactive = href !== undefined || onClick !== undefined;\n // A block-laid inline banner behaves like a box for layout: a row that may wrap.\n const inlineRow = inline && !block;\n const resolvedRole = role ?? (inline && live ? (tone === \"danger\" ? \"alert\" : \"status\") : undefined);\n\n const Glyph = TONE_GLYPH[tone];\n // Onto the first line of a box's text: 2px at 14px/20px, 1px at 12px/16px.\n const nudge = inlineRow || strip ? undefined : sm ? \"mt-px\" : \"mt-0.5\";\n const glyph =\n icon === null ? null : icon !== undefined ? (\n <span\n aria-hidden\n className={cn(\"flex shrink-0\", sm ? \"[&_svg]:size-3.5\" : \"[&_svg]:size-4\", nudge, TONE_ICON[tone])}\n >\n {icon}\n </span>\n ) : (\n <Glyph aria-hidden className={cn(sm ? \"size-3.5\" : \"size-4\", \"shrink-0\", nudge, TONE_ICON[tone])} />\n );\n\n // The row layout, shared by every shape below. A strip centres, and WRAPS: on a\n // phone a long message plus an action would otherwise squeeze the text into a narrow\n // column, so the action drops under it instead. A box may wrap its text, so it\n // top-aligns and nudges the glyph onto the first line.\n const row = cn(\n inlineRow ? \"inline-flex items-center\" : strip ? \"flex flex-wrap items-center gap-y-1\" : \"flex items-start\",\n sm ? \"gap-1.5 text-xs\" : inline ? \"gap-1.5 text-sm\" : \"gap-2 text-sm\",\n );\n // The strip's message claims 12rem before anything shrinks. `flex-1` is a 0 basis,\n // which never makes a wrapping row wrap — the action and the × squeezed the message\n // into a three-word column on a phone instead. With a real basis the action drops to\n // a second line once message + action no longer fit, and the message keeps the width.\n const grow = strip ? \"flex-[1_1_12rem]\" : \"flex-1\";\n // Inside a whole-row banner's button or link, which then carries `grow` itself.\n const nested = interactive && (onDismiss !== undefined || (action !== undefined && action !== null));\n const frame = inline\n ? \"\"\n : cn(\n strip ? stripFrameClass(tone, size) : alertFrameClass(tone, size),\n elevated && cn(ELEVATED, TONE_ELEVATED[tone]),\n );\n const focusRing =\n \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--brand)] focus-visible:ring-offset-1 focus-visible:ring-offset-[var(--bg-surface)]\";\n\n const content = (\n <>\n {glyph}\n <span className={cn(!inlineRow && (nested ? \"flex-1\" : grow), \"min-w-0 text-start\")}>{children}</span>\n {interactive && (\n <ChevronRight\n aria-hidden\n className={cn(sm ? \"size-3.5\" : \"size-4\", \"shrink-0 opacity-70 rtl:-scale-x-100\", nudge)}\n />\n )}\n </>\n );\n\n const dismiss = onDismiss ? (\n <button\n type=\"button\"\n aria-label={common.dismiss}\n onClick={(e) => {\n // A dismiss inside a whole-row banner must not also follow the row.\n e.preventDefault();\n e.stopPropagation();\n onDismiss();\n }}\n className={cn(\n // Negative margins so the × does not make the box taller than its text; the\n // hover wash takes the tone's own hue, so it works on every tone.\n \"relative z-10 -my-0.5 -me-0.5 flex size-6 shrink-0 items-center justify-center rounded transition-colors hover:bg-current/10\",\n focusRing,\n )}\n >\n <X aria-hidden className=\"size-4\" />\n </button>\n ) : null;\n\n // `relative z-10` so it stays clickable over a whole-row banner's stretched target.\n const trailing =\n action !== undefined && action !== null ? (\n <div className={cn(\"relative z-10 flex shrink-0 items-center gap-2\", !strip && !inlineRow && \"self-center\", strip && \"ms-auto\")}>\n {action}\n </div>\n ) : null;\n\n if (!interactive) {\n return (\n <div\n // Arbitrary attributes first — a `data-tour` anchor, a test id, an\n // `aria-describedby` — then the layout and tone classes, which are merged\n // through `cn` rather than spread so a caller's `className` refines the box\n // instead of replacing it.\n {...rest}\n role={resolvedRole}\n className={cn(row, frame, TONE_TEXT[tone], className)}\n >\n {content}\n {trailing}\n {dismiss}\n </div>\n );\n }\n\n const hover = \"cursor-pointer transition-[filter] hover:brightness-[0.97] dark:hover:brightness-110\";\n // With a dismiss the frame is a wrapper and the row element is stretched over it;\n // without one the row element IS the frame.\n const split = dismiss !== null || trailing !== null;\n const actionClass = split\n ? cn(\n \"flex min-w-0 text-start\",\n grow,\n sm ? \"gap-1.5\" : \"gap-2\",\n inlineRow || strip ? \"items-center\" : \"items-start\",\n \"outline-none after:absolute after:inset-0 after:content-['']\",\n strip ? \"after:rounded-none\" : \"after:rounded-md\",\n \"focus-visible:after:ring-2 focus-visible:after:ring-[var(--brand)]\",\n )\n : cn(row, !inlineRow && \"w-full\", frame, TONE_TEXT[tone], hover, focusRing, className);\n // The div's attributes are the row element's: an `id`, a test id or an\n // `aria-describedby` belongs on the thing that takes focus. The live-region role is\n // NOT carried over — a button or a link that is also a `status` is neither.\n const actionRest = split ? {} : rest;\n\n const rowAction =\n href !== undefined ? (\n <a\n {...(actionRest as ComponentPropsWithoutRef<\"a\">)}\n href={href}\n className={actionClass}\n >\n {content}\n </a>\n ) : (\n <button\n {...(actionRest as ComponentPropsWithoutRef<\"button\">)}\n type=\"button\"\n onClick={onClick}\n className={actionClass}\n >\n {content}\n </button>\n );\n\n if (!split) return rowAction;\n return (\n <div\n {...rest}\n role={resolvedRole}\n className={cn(\"relative\", row, frame, TONE_TEXT[tone], hover, className)}\n >\n {rowAction}\n {trailing}\n {dismiss}\n </div>\n );\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../src/components/alert-banner.tsx"],"sourcesContent":["import type { ComponentPropsWithoutRef, MouseEvent, ReactNode } from \"react\";\nimport { AlertTriangle, CheckCircle2, ChevronRight, Info, X } from \"lucide-react\";\nimport { cn } from \"../lib/cn\";\nimport { DEFAULT_COMMON_LABELS, useKitLabels } from \"../i18n/kit-labels\";\n\n/** `info` (0.8.0) is the sky family, for news that is neither good nor bad: keksdose's\n * preview and beta-performance banners, which borrowed `warning` for want of it and\n * so told every user of the preview build that something was wrong. */\nexport type AlertTone = \"danger\" | \"warning\" | \"info\" | \"success\" | \"neutral\";\n\n/** `sm` (0.10.0): 12px type and a 14px glyph — the hint line under a field or a total\n * that keksdose spells `text-xs text-amber-700 dark:text-amber-400` by hand\n * (accounts-page's missing-rate note, import-map-step, guest-key-control's two\n * key states, goal-form). At body size those read as a second heading. */\nexport type AlertSize = \"sm\" | \"md\";\n\n/** Border + surface per tone, WITHOUT a radius or padding, so a caller that owns\n * its own box (a Card, say) can take the tone alone. Single source for both\n * {@link alertFrameClass} and {@link toneFrameClass}.\n *\n * The colored tones are 2px, not 1px, and that is load-bearing rather than\n * decorative. A 1px border is 1.25 device pixels on a 125%-scaled display — the\n * usual Windows setting — so whether it renders as a solid line or as two\n * half-lit pixels depends on where the box happens to land in device-pixel space.\n * The same card then shows a heavy left edge and a right edge that fades out,\n * which is what feedback #264, #277, #491 and #498 all reported. A 2px border is\n * 2.5 device pixels: it always covers at least two pixels fully, on both edges,\n * at any offset. Callers compensate the extra pixel in their own padding so\n * switching tones never shifts the layout. */\nconst TONE_FRAME: Record<AlertTone, string> = {\n danger: \"border-2 border-[var(--danger-border)] bg-[var(--danger-bg)]\",\n warning: \"border-2 border-[var(--warning-border)] bg-[var(--warning-bg)]\",\n info: \"border-2 border-[var(--info-border)] bg-[var(--info-bg)]\",\n success: \"border-2 border-[var(--success-border)] bg-[var(--success-bg)]\",\n neutral: \"border border-[var(--border)]\",\n};\n\n/** `elevated`: the same frame on an OPAQUE surface. The dark tone washes are\n * translucent (a 20–25 % tint), which is right on a page and wrong on anything that\n * floats over one — keksdose's server-wake notice (#209) let the page's own text\n * show through the one message whose job is to be read when nothing else makes\n * sense. So the surface becomes `--bg-surface` and the tone's wash is layered on it\n * as a background IMAGE (a flat gradient), which keeps the exact tint in both themes\n * without a second, opaque copy of every `-bg` token. */\nconst TONE_ELEVATED: Record<AlertTone, string> = {\n danger: \"bg-[image:linear-gradient(var(--danger-bg),var(--danger-bg))]\",\n warning: \"bg-[image:linear-gradient(var(--warning-bg),var(--warning-bg))]\",\n info: \"bg-[image:linear-gradient(var(--info-bg),var(--info-bg))]\",\n success: \"bg-[image:linear-gradient(var(--success-bg),var(--success-bg))]\",\n neutral: \"\",\n};\nconst ELEVATED = \"bg-[var(--bg-surface)] shadow-lg\";\n\n/** The tone's border + surface on their own — for a caller that already has a box\n * with its own radius and padding (e.g. a Card). Remember to shave 1px off that\n * padding for the colored tones, whose border is 2px wide. */\nexport function toneFrameClass(tone: AlertTone): string {\n return TONE_FRAME[tone];\n}\n\n/** Single source for the warning-callout frame (feedback #277): the tone's border\n * and surface plus this component's own radius and padding, the latter\n * compensating the 2px colored border so toggling tones never shifts layout. */\nexport function alertFrameClass(tone: AlertTone, size: AlertSize = \"md\"): string {\n const box =\n size === \"sm\"\n ? tone === \"neutral\"\n ? \"rounded-md px-2.5 py-1.5\"\n : \"rounded-md px-[9px] py-[5px]\"\n : tone === \"neutral\"\n ? \"rounded-md p-3\"\n : \"rounded-md p-[11px]\";\n return `${box} ${TONE_FRAME[tone]}`;\n}\n\n/** The `strip` frame: the tone's surface edge to edge, no radius, and only the BOTTOM\n * border, 1px — a strip is a band across the top of a page, and its one visible edge\n * is the line it draws under itself. kastlan's trial, past-due and tour banners. */\nfunction stripFrameClass(tone: AlertTone, size: AlertSize): string {\n return cn(\n TONE_FRAME[tone],\n \"rounded-none border-0 border-b\",\n size === \"sm\" ? \"px-4 py-1.5\" : \"px-4 py-2\",\n );\n}\n\nconst TONE_TEXT: Record<AlertTone, string> = {\n danger: \"text-[var(--danger)]\",\n warning: \"text-[var(--warning)]\",\n info: \"text-[var(--info)]\",\n success: \"text-[var(--success)]\",\n neutral: \"text-[var(--text-secondary)]\",\n};\n\nconst TONE_ICON: Record<AlertTone, string> = {\n danger: \"text-[var(--danger)]\",\n warning: \"text-[var(--warning)]\",\n info: \"text-[var(--info)]\",\n success: \"text-[var(--success)]\",\n neutral: \"text-[var(--text-muted)]\",\n};\n\n/** Which glyph a tone draws when the caller names none. The triangle belongs to the\n * two tones that mean \"something is wrong\"; `info` and `neutral` get the ⓘ. That is\n * what makes `neutral` safe to offer at all: until 0.8.0 it was withheld because a\n * triangle with no colour is a warning that looks like a note — and a neutral note\n * that draws no triangle is simply a note (lenkbank's \"values are rounded\"). */\nconst TONE_GLYPH: Record<AlertTone, typeof AlertTriangle> = {\n danger: AlertTriangle,\n warning: AlertTriangle,\n info: Info,\n success: CheckCircle2,\n neutral: Info,\n};\n\ninterface AlertBannerBaseProps extends Omit<ComponentPropsWithoutRef<\"div\">, \"onClick\"> {\n /** Default `danger`. `info` for news, `neutral` for a plain note — see\n * {@link TONE_GLYPH} for why a neutral banner is now allowed. */\n tone?: AlertTone;\n /**\n * `box` (default): the framed callout. `inline`: the tone's colour, glyph and role\n * and NOTHING else — no border, no surface, no padding — so it fits inside a\n * toolbar row beside the controls it is about. Lenkbank's corner-motion toolbar\n * shows \"Radius below the minimum\" and \"Solving…\" there; a box would have doubled\n * the row's height every time either appeared.\n *\n * The inline variant is also a LIVE REGION by default — `role=\"alert\"` for\n * `danger`, `role=\"status\"` for the rest — because a message that appears in a\n * toolbar in answer to what the user just did is exactly the kind a screen reader\n * otherwise never hears. The box stays role-less as it always was: it is usually\n * page furniture present at load, and an alert that fires on every navigation is\n * noise. Either way a `role` you pass wins.\n *\n * `strip` (0.10.0): a band across the top of a page or a panel — edge to edge, no\n * radius, only a bottom border, content centred on one line, and usually an\n * {@link action}. kastlan's billing trial-banner and past-due-banner and its tours\n * tour-banner each built this by hand in amber or red Tailwind classes, with a\n * separate dark: spelling apiece. Role-less like the box: it is page furniture.\n */\n variant?: \"box\" | \"inline\" | \"strip\";\n /** See {@link AlertSize}. Default `md`. */\n size?: AlertSize;\n /**\n * `inline` only (0.11.0): lay the message out as a BLOCK — a full-width `flex` row,\n * the glyph on the text's FIRST line when it wraps — instead of the `inline-flex`,\n * centred line it is by default. keksdose writes `className=\"flex\"` on nearly every\n * inline banner it has (accounts-page:564, holdings-panel:631, import-map-step:66/314,\n * guest-key-control:84) to get a line of its own under a total or a field, and a\n * two-line hint there then centres its glyph between the lines.\n *\n * An option rather than the new default, because the inline-flex is load-bearing for\n * the callers the variant was made for: lenkbank's corner-motion toolbar\n * (corner-motion.tsx:178) sets it as a `shrink-0` item beside the controls, kastlan's\n * import page and keksdose's rules page place it in a run of content — a block would\n * take the whole row in the second kind, and top-align the first against a taller\n * control. Ignored by `box` and `strip`, which are blocks already.\n */\n block?: boolean;\n /**\n * `false` opts an inline banner out of its live region (no `role=\"alert\"` /\n * `role=\"status\"`), for a message that is static page content rather than the answer\n * to something the user just did: keksdose's CAMT review step (camt-review-step:214)\n * renders \"unresolved rows block the import\" as part of the step, and as\n * `role=\"alert\"` a screen reader interrupted with it on every mount. A `role` you pass\n * still wins. Default `true`; the box and the strip are role-less either way.\n */\n live?: boolean;\n /**\n * An opaque, raised surface (a shadow, and no translucency in dark mode) for a\n * notice that floats over the page — see {@link TONE_ELEVATED}. `box` and `strip`\n * only; an inline message has no surface to raise.\n */\n elevated?: boolean;\n /**\n * A trailing control — the \"Update payment method\" button on kastlan's trial and\n * past-due strips, the \"Exit\" on its tour strip. Sits at the end of the row,\n * vertically centred, and never shrinks. On a whole-row banner (`href`/`onClick`)\n * it is rendered BESIDE the row's own link or button, as the × is, because\n * interactive content may not nest.\n */\n action?: ReactNode;\n /** Replaces the tone's glyph — a `<Spinner label={null} />` for \"Solving…\", say.\n * `null` draws none. Always decorative; the text carries the message. */\n icon?: ReactNode;\n /** Renders an × at the end that calls this. The banner does not hide itself — the\n * caller removes it (and remembers that it did), as with every other kit control\n * whose state is app state. */\n onDismiss?: () => void;\n /** Accessible name of the ×. Default: `common.dismiss` from the\n * {@link UiKitProvider}, else \"Dismiss\". */\n dismissLabel?: string;\n children: ReactNode;\n}\n\n/**\n * The whole-row modes. keksdose's budget-summary card is a banner you tap to open\n * the budget; built from a `<div onClick>` it was a mouse-only target, and built as a\n * banner with a link inside it was a small target inside a big one that looked like\n * one. So `onClick` makes the banner itself ONE `<button>`, and `href` makes it one\n * `<a>`, with a trailing chevron that says so. Mutually exclusive, as on `Chip`.\n *\n * With `onDismiss` as well, the × cannot go inside the button (interactive content\n * may not nest), so the frame becomes a wrapper holding two siblings: the row's\n * button, stretched over the whole frame, and the × on top of it. It still looks and\n * clicks like one banner with an × in its corner, and the two are separate tab stops.\n */\nexport type AlertBannerProps = AlertBannerBaseProps &\n (\n | {\n /** Makes the whole banner one link. Mutually exclusive with `onClick`. */\n href?: string;\n onClick?: never;\n }\n | {\n href?: never;\n /** Makes the whole banner one button. Mutually exclusive with `href`. The\n * event's element is the `<button>`, not a `<div>`. */\n onClick?: (event: MouseEvent<HTMLElement>) => void;\n }\n );\n\n/** Warning/danger/info callout box with the shared frame and an icon — or, with\n * `variant=\"inline\"`, the same message as a frameless line of toned text. */\nexport function AlertBanner({\n tone = \"danger\",\n variant = \"box\",\n size = \"md\",\n block = false,\n live = true,\n elevated = false,\n action,\n icon,\n onDismiss,\n dismissLabel,\n href,\n onClick,\n className,\n children,\n role,\n ...rest\n}: AlertBannerProps) {\n const common = useKitLabels(\"common\", DEFAULT_COMMON_LABELS, { dismiss: dismissLabel });\n const inline = variant === \"inline\";\n const strip = variant === \"strip\";\n const sm = size === \"sm\";\n const interactive = href !== undefined || onClick !== undefined;\n // A block-laid inline banner behaves like a box for layout: a row that may wrap.\n const inlineRow = inline && !block;\n const resolvedRole = role ?? (inline && live ? (tone === \"danger\" ? \"alert\" : \"status\") : undefined);\n\n const Glyph = TONE_GLYPH[tone];\n // Onto the first line of a box's text: 2px at 14px/20px, 1px at 12px/16px.\n const nudge = inlineRow || strip ? undefined : sm ? \"mt-px\" : \"mt-0.5\";\n const glyph =\n icon === null ? null : icon !== undefined ? (\n <span\n aria-hidden\n className={cn(\"flex shrink-0\", sm ? \"[&_svg]:size-3.5\" : \"[&_svg]:size-4\", nudge, TONE_ICON[tone])}\n >\n {icon}\n </span>\n ) : (\n <Glyph aria-hidden className={cn(sm ? \"size-3.5\" : \"size-4\", \"shrink-0\", nudge, TONE_ICON[tone])} />\n );\n\n // The row layout, shared by every shape below. A strip centres, and WRAPS: on a\n // phone a long message plus an action would otherwise squeeze the text into a narrow\n // column, so the action drops under it instead. A box may wrap its text, so it\n // top-aligns and nudges the glyph onto the first line.\n // A BOX with an action wraps too, below sm only (kastlan 53): the verify-email box\n // squeezed \"Please confirm your email address…\" into a ~100px column beside a\n // \"Resend verification email\" button. There the action takes a line of its own under\n // the message, and the × stays at the top end; from sm up the box is one row as before.\n const boxWraps = !inline && !strip && action !== undefined && action !== null;\n const row = cn(\n inlineRow\n ? \"inline-flex items-center\"\n : strip\n ? \"flex flex-wrap items-center gap-y-1\"\n : cn(\"flex items-start\", boxWraps && \"max-sm:flex-wrap max-sm:gap-y-2\"),\n sm ? \"gap-1.5 text-xs\" : inline ? \"gap-1.5 text-sm\" : \"gap-2 text-sm\",\n );\n // The strip's message claims 12rem before anything shrinks. `flex-1` is a 0 basis,\n // which never makes a wrapping row wrap — the action and the × squeezed the message\n // into a three-word column on a phone instead. With a real basis the action drops to\n // a second line once message + action no longer fit, and the message keeps the width.\n const grow = strip ? \"flex-[1_1_12rem]\" : \"flex-1\";\n // Inside a whole-row banner's button or link, which then carries `grow` itself.\n const nested = interactive && (onDismiss !== undefined || (action !== undefined && action !== null));\n const frame = inline\n ? \"\"\n : cn(\n strip ? stripFrameClass(tone, size) : alertFrameClass(tone, size),\n elevated && cn(ELEVATED, TONE_ELEVATED[tone]),\n );\n const focusRing =\n \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--brand)] focus-visible:ring-offset-1 focus-visible:ring-offset-[var(--bg-surface)]\";\n\n const content = (\n <>\n {glyph}\n <span className={cn(!inlineRow && (nested ? \"flex-1\" : grow), \"min-w-0 text-start\")}>{children}</span>\n {interactive && (\n <ChevronRight\n aria-hidden\n className={cn(sm ? \"size-3.5\" : \"size-4\", \"shrink-0 opacity-70 rtl:-scale-x-100\", nudge)}\n />\n )}\n </>\n );\n\n const dismiss = onDismiss ? (\n <button\n type=\"button\"\n aria-label={common.dismiss}\n onClick={(e) => {\n // A dismiss inside a whole-row banner must not also follow the row.\n e.preventDefault();\n e.stopPropagation();\n onDismiss();\n }}\n className={cn(\n // Negative margins so the × does not make the box taller than its text; the\n // hover wash takes the tone's own hue, so it works on every tone.\n \"relative z-10 -my-0.5 -me-0.5 flex size-6 shrink-0 items-center justify-center rounded transition-colors hover:bg-current/10\",\n focusRing,\n )}\n >\n <X aria-hidden className=\"size-4\" />\n </button>\n ) : null;\n\n // `relative z-10` so it stays clickable over a whole-row banner's stretched target.\n const trailing =\n action !== undefined && action !== null ? (\n <div\n className={cn(\n \"relative z-10 flex shrink-0 items-center gap-2\",\n !strip && !inlineRow && \"self-center\",\n strip && \"ms-auto\",\n // Last, on a line of its own, lined up with the message (past the glyph).\n boxWraps && cn(\"max-sm:order-last max-sm:basis-full\", glyph && (sm ? \"max-sm:ps-5\" : \"max-sm:ps-6\")),\n )}\n >\n {action}\n </div>\n ) : null;\n\n if (!interactive) {\n return (\n <div\n // Arbitrary attributes first — a `data-tour` anchor, a test id, an\n // `aria-describedby` — then the layout and tone classes, which are merged\n // through `cn` rather than spread so a caller's `className` refines the box\n // instead of replacing it.\n {...rest}\n role={resolvedRole}\n className={cn(row, frame, TONE_TEXT[tone], className)}\n >\n {content}\n {trailing}\n {dismiss}\n </div>\n );\n }\n\n const hover = \"cursor-pointer transition-[filter] hover:brightness-[0.97] dark:hover:brightness-110\";\n // With a dismiss the frame is a wrapper and the row element is stretched over it;\n // without one the row element IS the frame.\n const split = dismiss !== null || trailing !== null;\n const actionClass = split\n ? cn(\n \"flex min-w-0 text-start\",\n grow,\n sm ? \"gap-1.5\" : \"gap-2\",\n inlineRow || strip ? \"items-center\" : \"items-start\",\n \"outline-none after:absolute after:inset-0 after:content-['']\",\n strip ? \"after:rounded-none\" : \"after:rounded-md\",\n \"focus-visible:after:ring-2 focus-visible:after:ring-[var(--brand)]\",\n )\n : cn(row, !inlineRow && \"w-full\", frame, TONE_TEXT[tone], hover, focusRing, className);\n // The div's attributes are the row element's: an `id`, a test id or an\n // `aria-describedby` belongs on the thing that takes focus. The live-region role is\n // NOT carried over — a button or a link that is also a `status` is neither.\n const actionRest = split ? {} : rest;\n\n const rowAction =\n href !== undefined ? (\n <a\n {...(actionRest as ComponentPropsWithoutRef<\"a\">)}\n href={href}\n className={actionClass}\n >\n {content}\n </a>\n ) : (\n <button\n {...(actionRest as ComponentPropsWithoutRef<\"button\">)}\n type=\"button\"\n onClick={onClick}\n className={actionClass}\n >\n {content}\n </button>\n );\n\n if (!split) return rowAction;\n return (\n <div\n {...rest}\n role={resolvedRole}\n className={cn(\"relative\", row, frame, TONE_TEXT[tone], hover, className)}\n >\n {rowAction}\n {trailing}\n {dismiss}\n </div>\n );\n}\n"],"mappings":"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","names":[]}
@@ -119,7 +119,7 @@ function ChartTooltipContent({
119
119
  {
120
120
  ref: tipRef,
121
121
  style: boundaryRef ? { transform: flip ? "translateX(calc(-100% - 12px))" : "translateX(12px)" } : void 0,
122
- className: "min-w-[9rem] rounded-lg border border-[var(--border)] bg-[var(--bg-surface)] px-2.5 py-2 text-xs shadow-xl",
122
+ 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
123
  children: [
124
124
  !hideLabel && label != null && /* @__PURE__ */ jsx("div", { className: "mb-1.5 font-medium text-[var(--text-primary)]", children: labelFormatter ? labelFormatter(label) : label }),
125
125
  /* @__PURE__ */ jsx("div", { className: "grid gap-1.5", children: items.map((item, i) => {
@@ -146,8 +146,15 @@ function ChartTooltipContent({
146
146
  style: { backgroundColor: color }
147
147
  }
148
148
  ),
149
- /* @__PURE__ */ jsx("span", { className: "text-[var(--text-muted)]", children: name }),
150
- /* @__PURE__ */ jsx("span", { "data-private": true, className: "ms-auto font-mono font-medium tabular-nums text-[var(--text-primary)]", children: shown })
149
+ /* @__PURE__ */ jsx("span", { className: "min-w-0 break-words text-[var(--text-muted)]", children: name }),
150
+ /* @__PURE__ */ jsx(
151
+ "span",
152
+ {
153
+ "data-private": true,
154
+ className: "ms-auto shrink-0 whitespace-nowrap font-mono font-medium tabular-nums text-[var(--text-primary)]",
155
+ children: shown
156
+ }
157
+ )
151
158
  ] }, `${cfgKey}-${i}`);
152
159
  }) })
153
160
  ]
@@ -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 className=\"min-w-[9rem] 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=\"text-[var(--text-muted)]\">{name}</span>\n <span data-private className=\"ms-auto font-mono font-medium tabular-nums text-[var(--text-primary)]\">\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, 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":[]}
@@ -107,7 +107,10 @@ function visibleSeries(series, hidden) {
107
107
  }
108
108
  function withUnit(label, unit) {
109
109
  if (!unit) return label;
110
- if (typeof label === "string" || typeof label === "number") return `${label} (${unit})`;
110
+ if (typeof label === "string" || typeof label === "number") {
111
+ const text = String(label);
112
+ return text.trimEnd().endsWith(`(${unit})`) ? label : `${text} (${unit})`;
113
+ }
111
114
  return /* @__PURE__ */ jsxs(Fragment, { children: [
112
115
  label,
113
116
  " (",