@eifi1/ui-kit 0.55.0 → 0.56.0

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.
@@ -9,7 +9,7 @@ import type { BillingOverview } from "./billing-overview.js";
9
9
  * knowledge laid over it: kastlan says "Ends on …" the moment its in-app cancel is asked
10
10
  * (`{ ...overview, cancel_at_period_end: view.cancelScheduled }`, `useCancelRequest`).
11
11
  */
12
- export type SubscriptionStandingOverview = Pick<BillingOverview, "status" | "in_good_standing" | "trial_ends_at" | "comped_until" | "current_period_end" | "cancel_at_period_end">;
12
+ export type SubscriptionStandingOverview = Pick<BillingOverview, "status" | "in_good_standing" | "cancel_at_period_end"> & Partial<Pick<BillingOverview, "trial_ends_at" | "comped_until" | "current_period_end">>;
13
13
  /**
14
14
  * The subscription's standing in one line — or `null` when nothing applies (kastlan reuse
15
15
  * audit 2026-11-02, proposal 3; billing §7, Marcel 2026-11-04). The FIRST that applies:
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/billing/subscription-standing.tsx"],"sourcesContent":["import type { ComponentPropsWithRef } from \"react\";\n\nimport type { LabelOverride } from \"../i18n/kit-labels\";\nimport { cn } from \"../lib/cn\";\nimport { useDayFormatter } from \"../lib/day-format\";\nimport { formatDate } from \"../lib/format\";\nimport type { DateInput } from \"../lib/format\";\nimport { useBillingLabels } from \"./billing-labels\";\nimport type { BillingLabels } from \"./billing-labels\";\nimport type { BillingOverview } from \"./billing-overview\";\n\n/**\n * What the standing line reads of `GET /billing/overview` (docs/billing-harmonization.md\n * §4), under the wire names. Pass the overview as it came — or with the app's own\n * knowledge laid over it: kastlan says \"Ends on …\" the moment its in-app cancel is asked\n * (`{ ...overview, cancel_at_period_end: view.cancelScheduled }`, `useCancelRequest`).\n */\nexport type SubscriptionStandingOverview = Pick<\n BillingOverview,\n \"status\" | \"in_good_standing\" | \"trial_ends_at\" | \"comped_until\" | \"current_period_end\" | \"cancel_at_period_end\"\n>;\n\n/** Which of `billing.standing`'s lines applies, and the date it names. */\ntype Standing =\n | { kind: \"ended\" | \"pastDue\" | \"free\" }\n | { kind: \"trialUntil\" | \"freeUntil\" | \"endsOn\" | \"renewsOn\"; date: DateInput };\n\nconst given = (value: DateInput): boolean => value !== null && value !== undefined && value !== \"\";\n\n/** Marcel's order (billing §7, 2026-11-04): the first that applies, or none. */\nfunction standingOf(o: SubscriptionStandingOverview): Standing | null {\n // The server's word beats the status: `canceled` and `expired` are never in good\n // standing, and a lapsed `past_due` is no longer \"retrying\" (§3.3).\n if (!o.in_good_standing) return { kind: \"ended\" };\n // Still in good standing, and no date: a renewal the retries may not reach.\n if (o.status === \"past_due\") return { kind: \"pastDue\" };\n if (o.status === \"trialing\") return given(o.trial_ends_at) ? { kind: \"trialUntil\", date: o.trial_ends_at } : null;\n if (o.status === \"comped\") {\n return given(o.comped_until) ? { kind: \"freeUntil\", date: o.comped_until } : { kind: \"free\" };\n }\n if (!given(o.current_period_end)) return null;\n if (o.cancel_at_period_end) return { kind: \"endsOn\", date: o.current_period_end };\n if (o.status === \"active\") return { kind: \"renewsOn\", date: o.current_period_end };\n return null;\n}\n\nfunction standingText(s: Standing, words: BillingLabels[\"standing\"], day: (value: DateInput) => string): string {\n switch (s.kind) {\n case \"ended\":\n case \"pastDue\":\n case \"free\":\n return words[s.kind];\n default:\n return words[s.kind](day(s.date));\n }\n}\n\n/**\n * The subscription's standing in one line — or `null` when nothing applies (kastlan reuse\n * audit 2026-11-02, proposal 3; billing §7, Marcel 2026-11-04). The FIRST that applies:\n *\n * 1. not in good standing → \"Your plan has ended.\" (`billing.standing.ended`, the first\n * sentence of the plan-ended banner's);\n * 2. `past_due` → \"Payment failed — we’re retrying. Your plan stays active meanwhile.\",\n * with NO renewal date: the payment-failed banner keeps the action;\n * 3. `trialing` → \"Trial until {trial_ends_at}\" (a pure guest's trial that has not\n * started, §12.9, has no date and says nothing);\n * 4. `comped` → \"Free until {comped_until}\", or \"Free, with no end date\";\n * 5. set to cancel at the period's end → \"Ends on {current_period_end}\";\n * 6. `active` → \"Renews on {current_period_end}\";\n * 7. otherwise nothing.\n *\n * kastlan and keksdose each wrote this line, and they drifted: kastlan said nothing for\n * an ended plan or a failed payment, and keksdose's `past_due` said \"Renews on …\".\n *\n * `format` prints a date in a sentence; default the kit's `formatDate` (`\"medium\"`) in\n * the runtime's locale. Inside a component, `<SubscriptionStanding>` prints it the\n * provider's way — its `formatDate` (the app's date preference), else its locale and\n * time zone.\n */\nexport function subscriptionStanding(\n overview: SubscriptionStandingOverview,\n labels: Pick<BillingLabels, \"standing\">,\n format: (value: DateInput) => string = (value) => formatDate(value),\n): string | null {\n const s = standingOf(overview);\n return s && standingText(s, labels.standing, format);\n}\n\nexport interface SubscriptionStandingProps extends Omit<ComponentPropsWithRef<\"p\">, \"children\"> {\n /** The overview, or the fields of it the line reads. */\n overview: SubscriptionStandingOverview;\n /** The date's locale. Default: the provider's. */\n locale?: string;\n /** Prop > `<UiKitProvider labels={{ billing: { standing } }}>` > English. */\n labels?: LabelOverride<BillingLabels>;\n}\n\n/**\n * {@link subscriptionStanding} as the muted line under the plan's name on the\n * subscription page (billing §7) — nothing at all when no line applies. The date is a\n * day in a sentence, without its weekday: the `<UiKitProvider formatDate>` (the app's\n * date preference) when it set one, else the kit's `formatDate`, `\"medium\"`, in the\n * provider's locale and `timeZone` — as `CancelSubscriptionConfirm` names the same day.\n *\n * Carries `data-standing` with the line's key (`ended`, `pastDue`, `trialUntil`,\n * `freeUntil`, `free`, `endsOn`, `renewsOn`), for a test or a style hook.\n *\n * <SubscriptionStanding overview={overview} />\n *\n * Back from a checkout whose webhook has not landed (§12.21), the ended line is the stale\n * one: render it only when `useCheckoutProcessing`'s `processing` is false, and the\n * processing banner speaks meanwhile.\n */\nexport function SubscriptionStanding({\n overview,\n locale: localeProp,\n labels: labelsProp,\n className,\n ...rest\n}: SubscriptionStandingProps) {\n const labels = useBillingLabels(labelsProp);\n const day = useDayFormatter(undefined, localeProp);\n const s = standingOf(overview);\n if (!s) return null;\n return (\n <p {...rest} data-standing={s.kind} className={cn(\"text-sm text-muted\", className)}>\n {standingText(s, labels.standing, (value) => day(value))}\n </p>\n );\n}\n"],"mappings":";AA8HI;AA3HJ,SAAS,UAAU;AACnB,SAAS,uBAAuB;AAChC,SAAS,kBAAkB;AAE3B,SAAS,wBAAwB;AAoBjC,MAAM,QAAQ,CAAC,UAA8B,UAAU,QAAQ,UAAU,UAAa,UAAU;AAGhG,SAAS,WAAW,GAAkD;AAGpE,MAAI,CAAC,EAAE,iBAAkB,QAAO,EAAE,MAAM,QAAQ;AAEhD,MAAI,EAAE,WAAW,WAAY,QAAO,EAAE,MAAM,UAAU;AACtD,MAAI,EAAE,WAAW,WAAY,QAAO,MAAM,EAAE,aAAa,IAAI,EAAE,MAAM,cAAc,MAAM,EAAE,cAAc,IAAI;AAC7G,MAAI,EAAE,WAAW,UAAU;AACzB,WAAO,MAAM,EAAE,YAAY,IAAI,EAAE,MAAM,aAAa,MAAM,EAAE,aAAa,IAAI,EAAE,MAAM,OAAO;AAAA,EAC9F;AACA,MAAI,CAAC,MAAM,EAAE,kBAAkB,EAAG,QAAO;AACzC,MAAI,EAAE,qBAAsB,QAAO,EAAE,MAAM,UAAU,MAAM,EAAE,mBAAmB;AAChF,MAAI,EAAE,WAAW,SAAU,QAAO,EAAE,MAAM,YAAY,MAAM,EAAE,mBAAmB;AACjF,SAAO;AACT;AAEA,SAAS,aAAa,GAAa,OAAkC,KAA2C;AAC9G,UAAQ,EAAE,MAAM;AAAA,IACd,KAAK;AAAA,IACL,KAAK;AAAA,IACL,KAAK;AACH,aAAO,MAAM,EAAE,IAAI;AAAA,IACrB;AACE,aAAO,MAAM,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,CAAC;AAAA,EACpC;AACF;AAyBO,SAAS,qBACd,UACA,QACA,SAAuC,CAAC,UAAU,WAAW,KAAK,GACnD;AACf,QAAM,IAAI,WAAW,QAAQ;AAC7B,SAAO,KAAK,aAAa,GAAG,OAAO,UAAU,MAAM;AACrD;AA2BO,SAAS,qBAAqB;AAAA,EACnC;AAAA,EACA,QAAQ;AAAA,EACR,QAAQ;AAAA,EACR;AAAA,EACA,GAAG;AACL,GAA8B;AAC5B,QAAM,SAAS,iBAAiB,UAAU;AAC1C,QAAM,MAAM,gBAAgB,QAAW,UAAU;AACjD,QAAM,IAAI,WAAW,QAAQ;AAC7B,MAAI,CAAC,EAAG,QAAO;AACf,SACE,oBAAC,OAAG,GAAG,MAAM,iBAAe,EAAE,MAAM,WAAW,GAAG,sBAAsB,SAAS,GAC9E,uBAAa,GAAG,OAAO,UAAU,CAAC,UAAU,IAAI,KAAK,CAAC,GACzD;AAEJ;","names":[]}
1
+ {"version":3,"sources":["../../src/billing/subscription-standing.tsx"],"sourcesContent":["import type { ComponentPropsWithRef } from \"react\";\n\nimport type { LabelOverride } from \"../i18n/kit-labels\";\nimport { cn } from \"../lib/cn\";\nimport { useDayFormatter } from \"../lib/day-format\";\nimport { formatDate } from \"../lib/format\";\nimport type { DateInput } from \"../lib/format\";\nimport { useBillingLabels } from \"./billing-labels\";\nimport type { BillingLabels } from \"./billing-labels\";\nimport type { BillingOverview } from \"./billing-overview\";\n\n/**\n * What the standing line reads of `GET /billing/overview` (docs/billing-harmonization.md\n * §4), under the wire names. Pass the overview as it came — or with the app's own\n * knowledge laid over it: kastlan says \"Ends on …\" the moment its in-app cancel is asked\n * (`{ ...overview, cancel_at_period_end: view.cancelScheduled }`, `useCancelRequest`).\n */\nexport type SubscriptionStandingOverview = Pick<\n BillingOverview,\n \"status\" | \"in_good_standing\" | \"cancel_at_period_end\"\n> &\n // The dates may be left out, as `null` (0.55.1): an app's generated overview has them\n // optional with a default, and keksdose had to pass all three by name.\n Partial<Pick<BillingOverview, \"trial_ends_at\" | \"comped_until\" | \"current_period_end\">>;\n\n/** Which of `billing.standing`'s lines applies, and the date it names. */\ntype Standing =\n | { kind: \"ended\" | \"pastDue\" | \"free\" }\n | { kind: \"trialUntil\" | \"freeUntil\" | \"endsOn\" | \"renewsOn\"; date: DateInput };\n\nconst given = (value: DateInput): boolean => value !== null && value !== undefined && value !== \"\";\n\n/** Marcel's order (billing §7, 2026-11-04): the first that applies, or none. */\nfunction standingOf(o: SubscriptionStandingOverview): Standing | null {\n // The server's word beats the status: `canceled` and `expired` are never in good\n // standing, and a lapsed `past_due` is no longer \"retrying\" (§3.3).\n if (!o.in_good_standing) return { kind: \"ended\" };\n // Still in good standing, and no date: a renewal the retries may not reach.\n if (o.status === \"past_due\") return { kind: \"pastDue\" };\n if (o.status === \"trialing\") return given(o.trial_ends_at) ? { kind: \"trialUntil\", date: o.trial_ends_at } : null;\n if (o.status === \"comped\") {\n return given(o.comped_until) ? { kind: \"freeUntil\", date: o.comped_until } : { kind: \"free\" };\n }\n if (!given(o.current_period_end)) return null;\n if (o.cancel_at_period_end) return { kind: \"endsOn\", date: o.current_period_end };\n if (o.status === \"active\") return { kind: \"renewsOn\", date: o.current_period_end };\n return null;\n}\n\nfunction standingText(s: Standing, words: BillingLabels[\"standing\"], day: (value: DateInput) => string): string {\n switch (s.kind) {\n case \"ended\":\n case \"pastDue\":\n case \"free\":\n return words[s.kind];\n default:\n return words[s.kind](day(s.date));\n }\n}\n\n/**\n * The subscription's standing in one line — or `null` when nothing applies (kastlan reuse\n * audit 2026-11-02, proposal 3; billing §7, Marcel 2026-11-04). The FIRST that applies:\n *\n * 1. not in good standing → \"Your plan has ended.\" (`billing.standing.ended`, the first\n * sentence of the plan-ended banner's);\n * 2. `past_due` → \"Payment failed — we’re retrying. Your plan stays active meanwhile.\",\n * with NO renewal date: the payment-failed banner keeps the action;\n * 3. `trialing` → \"Trial until {trial_ends_at}\" (a pure guest's trial that has not\n * started, §12.9, has no date and says nothing);\n * 4. `comped` → \"Free until {comped_until}\", or \"Free, with no end date\";\n * 5. set to cancel at the period's end → \"Ends on {current_period_end}\";\n * 6. `active` → \"Renews on {current_period_end}\";\n * 7. otherwise nothing.\n *\n * kastlan and keksdose each wrote this line, and they drifted: kastlan said nothing for\n * an ended plan or a failed payment, and keksdose's `past_due` said \"Renews on …\".\n *\n * `format` prints a date in a sentence; default the kit's `formatDate` (`\"medium\"`) in\n * the runtime's locale. Inside a component, `<SubscriptionStanding>` prints it the\n * provider's way — its `formatDate` (the app's date preference), else its locale and\n * time zone.\n */\nexport function subscriptionStanding(\n overview: SubscriptionStandingOverview,\n labels: Pick<BillingLabels, \"standing\">,\n format: (value: DateInput) => string = (value) => formatDate(value),\n): string | null {\n const s = standingOf(overview);\n return s && standingText(s, labels.standing, format);\n}\n\nexport interface SubscriptionStandingProps extends Omit<ComponentPropsWithRef<\"p\">, \"children\"> {\n /** The overview, or the fields of it the line reads. */\n overview: SubscriptionStandingOverview;\n /** The date's locale. Default: the provider's. */\n locale?: string;\n /** Prop > `<UiKitProvider labels={{ billing: { standing } }}>` > English. */\n labels?: LabelOverride<BillingLabels>;\n}\n\n/**\n * {@link subscriptionStanding} as the muted line under the plan's name on the\n * subscription page (billing §7) — nothing at all when no line applies. The date is a\n * day in a sentence, without its weekday: the `<UiKitProvider formatDate>` (the app's\n * date preference) when it set one, else the kit's `formatDate`, `\"medium\"`, in the\n * provider's locale and `timeZone` — as `CancelSubscriptionConfirm` names the same day.\n *\n * Carries `data-standing` with the line's key (`ended`, `pastDue`, `trialUntil`,\n * `freeUntil`, `free`, `endsOn`, `renewsOn`), for a test or a style hook.\n *\n * <SubscriptionStanding overview={overview} />\n *\n * Back from a checkout whose webhook has not landed (§12.21), the ended line is the stale\n * one: render it only when `useCheckoutProcessing`'s `processing` is false, and the\n * processing banner speaks meanwhile.\n */\nexport function SubscriptionStanding({\n overview,\n locale: localeProp,\n labels: labelsProp,\n className,\n ...rest\n}: SubscriptionStandingProps) {\n const labels = useBillingLabels(labelsProp);\n const day = useDayFormatter(undefined, localeProp);\n const s = standingOf(overview);\n if (!s) return null;\n return (\n <p {...rest} data-standing={s.kind} className={cn(\"text-sm text-muted\", className)}>\n {standingText(s, labels.standing, (value) => day(value))}\n </p>\n );\n}\n"],"mappings":";AAiII;AA9HJ,SAAS,UAAU;AACnB,SAAS,uBAAuB;AAChC,SAAS,kBAAkB;AAE3B,SAAS,wBAAwB;AAuBjC,MAAM,QAAQ,CAAC,UAA8B,UAAU,QAAQ,UAAU,UAAa,UAAU;AAGhG,SAAS,WAAW,GAAkD;AAGpE,MAAI,CAAC,EAAE,iBAAkB,QAAO,EAAE,MAAM,QAAQ;AAEhD,MAAI,EAAE,WAAW,WAAY,QAAO,EAAE,MAAM,UAAU;AACtD,MAAI,EAAE,WAAW,WAAY,QAAO,MAAM,EAAE,aAAa,IAAI,EAAE,MAAM,cAAc,MAAM,EAAE,cAAc,IAAI;AAC7G,MAAI,EAAE,WAAW,UAAU;AACzB,WAAO,MAAM,EAAE,YAAY,IAAI,EAAE,MAAM,aAAa,MAAM,EAAE,aAAa,IAAI,EAAE,MAAM,OAAO;AAAA,EAC9F;AACA,MAAI,CAAC,MAAM,EAAE,kBAAkB,EAAG,QAAO;AACzC,MAAI,EAAE,qBAAsB,QAAO,EAAE,MAAM,UAAU,MAAM,EAAE,mBAAmB;AAChF,MAAI,EAAE,WAAW,SAAU,QAAO,EAAE,MAAM,YAAY,MAAM,EAAE,mBAAmB;AACjF,SAAO;AACT;AAEA,SAAS,aAAa,GAAa,OAAkC,KAA2C;AAC9G,UAAQ,EAAE,MAAM;AAAA,IACd,KAAK;AAAA,IACL,KAAK;AAAA,IACL,KAAK;AACH,aAAO,MAAM,EAAE,IAAI;AAAA,IACrB;AACE,aAAO,MAAM,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,CAAC;AAAA,EACpC;AACF;AAyBO,SAAS,qBACd,UACA,QACA,SAAuC,CAAC,UAAU,WAAW,KAAK,GACnD;AACf,QAAM,IAAI,WAAW,QAAQ;AAC7B,SAAO,KAAK,aAAa,GAAG,OAAO,UAAU,MAAM;AACrD;AA2BO,SAAS,qBAAqB;AAAA,EACnC;AAAA,EACA,QAAQ;AAAA,EACR,QAAQ;AAAA,EACR;AAAA,EACA,GAAG;AACL,GAA8B;AAC5B,QAAM,SAAS,iBAAiB,UAAU;AAC1C,QAAM,MAAM,gBAAgB,QAAW,UAAU;AACjD,QAAM,IAAI,WAAW,QAAQ;AAC7B,MAAI,CAAC,EAAG,QAAO;AACf,SACE,oBAAC,OAAG,GAAG,MAAM,iBAAe,EAAE,MAAM,WAAW,GAAG,sBAAsB,SAAS,GAC9E,uBAAa,GAAG,OAAO,UAAU,CAAC,UAAU,IAAI,KAAK,CAAC,GACzD;AAEJ;","names":[]}
@@ -118,6 +118,7 @@ function ButtonElement({
118
118
  onClick: (e) => {
119
119
  if (inert) {
120
120
  e.preventDefault();
121
+ e.stopPropagation();
121
122
  return;
122
123
  }
123
124
  onClick?.(e);
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/components/button.tsx"],"sourcesContent":["import type { AnchorHTMLAttributes, ButtonHTMLAttributes, ReactElement, ReactNode, Ref } from \"react\";\nimport { cn } from \"../lib/cn\";\nimport { DEFAULT_COMMON_LABELS, useKitLabels, useKitLink } from \"../i18n/kit-labels\";\nimport type { KitLinkComponent, KitLinkProps } from \"../i18n/kit-labels\";\nimport { pickLinkRenderer, replacingClick, routerLinkNavigation } from \"./text-link\";\nimport type { CommitScope } from \"./write-lock\";\nimport { LockedReason, useRefusal, type DisabledReasonDisplay } from \"./field-parts\";\nimport { Spinner } from \"./spinner\";\nimport { BUTTON_LINK_PRESSED, ICON_BUTTON_PRESSED, RenderedButtonLink, buttonLook } from \"./button-look\";\nimport type { ButtonSize, ButtonTone, ButtonVariant } from \"./button-look\";\n\n// The kit's text button, and `<Button href>`, the link that looks like one. Its look —\n// the variant, size and tone maps, and `buttonClasses` — is ./button-look, which\n// IconButton shares (0.34, MOD-2, refactor audit 2026-10).\n\n/**\n * ⚠️ No default `type`: like a native `<button>`, a `Button` inside a `<form>` SUBMITS\n * it unless you pass `type=\"button\"`. Kept native on purpose — a form's own submit\n * button relies on it, and changing the default would silently stop those forms from\n * submitting. Pass `type=\"button\"` for every other action in a form (keksdose had six\n * link buttons submitting their form).\n */\nexport interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {\n /** `default` (the quiet action, what a Button without `variant` draws), `accent` (the\n * strong call to action, solid brand), `secondary`, `ghost`, `danger` or `link`. See\n * {@link ButtonVariant}. */\n variant?: ButtonVariant;\n /** `md` (default) is the page's action button. `sm` is the compact one — 12px text\n * and `px-2 py-1` — for the secondary actions in a toolbar, a card header or a\n * table's header row (keksdose writes `px-2 py-1 text-xs` over `secondary` by hand\n * there). Every variant takes either size. */\n size?: ButtonSize;\n /** In a flex row next to a taller labelled field, fill the field's height so the\n * two line up. No effect outside a flex row. */\n stretch?: boolean;\n /** The `<button>` element. React 19 passes `ref` to a function component as an\n * ordinary prop, so it rides `...rest` onto the element with no `forwardRef` —\n * declared here only because `ButtonHTMLAttributes` does not carry it. */\n ref?: Ref<HTMLButtonElement>;\n /** Text colour for `link` and `ghost`: `muted` (quiet, body colour on hover) or\n * `danger` (quiet, `--danger` on hover). `danger` also makes `secondary` an\n * outlined destructive button and `default` a soft one; ignored by the other\n * variants. See {@link ButtonTone}. */\n tone?: ButtonTone;\n /**\n * Make it a toggle button, as {@link IconButton}'s `pressed` does. `true` sets\n * `aria-pressed=\"true\"` and draws the \"on\" look — on a `link`, the brand colour and a\n * medium weight; on the boxed variants, the quiet brand fill IconButton uses — and\n * `false` sets `aria-pressed=\"false\"` with the ordinary look. Left out, no\n * `aria-pressed` (or the caller's own). For lenkbank's \"All speeds\" text toggle\n * (gear/hysteresis-charts.tsx:482), which is a hand-rolled `<button aria-pressed>`\n * swapping two class strings; with `variant=\"link\" tone=\"muted\"` it is this.\n */\n pressed?: boolean;\n /**\n * Why the action is not available — the button's half of DangerConfirm's\n * `lockedReason` (kastlan handover-detail-page.tsx:197 locks a signed handover).\n *\n * A disabled button that cannot say why is a dead end: the native `disabled` takes it\n * out of the tab order, so a keyboard user never lands on it, and a pointer gets a\n * `not-allowed` cursor and nothing else. With a reason the button is `aria-disabled`\n * instead — still focusable, still hoverable — clicks (and the Enter/Space and form\n * submission they stand for) do nothing, and the reason is shown in the kit\n * {@link Tooltip} and attached with `aria-describedby`, so a screen reader hears it\n * on focus. It wins over `disabled`: passing both keeps the button reachable, which is\n * the point of giving a reason.\n *\n * The description is a `hidden` copy of the reason rather than the bubble itself: the\n * bubble exists only while hovered or focused once it is portalled, and a description\n * that comes and goes is read inconsistently. The bubble is kept visual, the way\n * DangerConfirm keeps its own.\n *\n * `refused={reason}` (0.55) is the same refusal by the button's own name for it, with\n * a form for a reason said elsewhere — see `refused`.\n */\n disabledReason?: ReactNode;\n /**\n * Refuse the action and stay where the user can learn why — no permission, a\n * read-only plan, a held lock, an end already reached (0.52 code review 2026-10-31,\n * held to 0.55). Shown with `disabled`, a refused action leaves the tab order and says\n * nothing; an app's way round that was a Tooltip over the disabled button, which a\n * keyboard never opens (Kurvenschmiede's owner-only delete,\n * shared/lib/table-columns.tsx), and the kit's own arrows at an end hand-wrote\n * `aria-disabled` and a guard in their `onClick`.\n *\n * Refused, the button is `aria-disabled=\"true\"` — never the native `disabled`, which\n * it wins over — and stays focusable and hoverable, with the disabled look; every\n * activation is swallowed: the click, the Enter and Space that stand for it, and the\n * submission a submit button would make.\n *\n * - **Words** (`refused={t(\"sharing.owner_only\")}`): the reason, said the\n * `disabledReason` way — the kit {@link Tooltip}, or a line at Large and on touch\n * (`disabledReasonDisplay`) — and attached with `aria-describedby`, so a screen\n * reader hears it on focus. One path with `disabledReason`; these words win over its.\n * - **`true`**: refused with no words of its own, for a reason the screen already\n * says — a banner over the form, a line under it — which the button's own\n * `aria-describedby` names ({@link DisabledReasonLine} draws the line with its id),\n * or one that needs no saying (› on the last picture). No bubble.\n * - `false`, `null`, `undefined` or `\"\"`: not refused, as `cond && reason` gives.\n *\n * Under a locked {@link WriteLockProvider} a `commit` button says the lock's reason\n * instead, as it does over `disabledReason`.\n */\n refused?: ReactNode | boolean;\n /**\n * Where `disabledReason` shows (0.32, §4 \"No fact only in a tooltip\"): by default a\n * line under the button at Large and on a touch screen, the tooltip otherwise. See\n * {@link DisabledReasonDisplay}. A worded `refused` shows the same way.\n */\n disabledReasonDisplay?: DisabledReasonDisplay;\n /**\n * This button COMMITS — it saves, creates, deletes. Under a locked\n * {@link WriteLockProvider} it is disabled the `disabledReason` way, with the lock's\n * reason (which wins over a `disabledReason` of its own). No provider, or an\n * unlocked one: no effect. See {@link WriteLockProvider}; a {@link CommitScope} since\n * 0.33 (`COMMIT_EXCEPT_BILLING`: every lock but a lapsed plan's).\n */\n commit?: CommitScope;\n /**\n * Busy — the save is in flight: a spinner, `aria-busy`, and no second submit.\n *\n * kastlan's FormActions (form-actions.tsx:50) disables its submit and swaps in a\n * `submittingLabel`, so the button jumps width mid-click and a screen reader hears\n * the name change instead of \"busy\". Here the label STAYS — it is still the\n * accessible name and still holds the button's width — and the spinner is drawn over\n * it in the text colour, so nothing beside the button moves.\n *\n * The same prop {@link FileButton} has (`pending`), with one difference: this one is\n * `aria-disabled`, not `disabled`. The button being pressed is the one that has focus,\n * and a native `disabled` drops that focus to `<body>` the moment the save starts;\n * clicks, and the Enter that submits a form through it, are swallowed instead.\n */\n pending?: boolean;\n /** Only on the link form — see {@link ButtonLinkProps}. */\n href?: never;\n renderLink?: never;\n external?: never;\n replace?: never;\n reloadDocument?: never;\n}\n\n/**\n * `<Button href>`: a link that looks like a button — for an action that NAVIGATES.\n *\n * kastlan paints `buttonClasses(…)` onto router Links by hand in six places (the\n * invoice / lease / unit preview dialogs' \"Open\" at invoice-preview-dialog.tsx:36,\n * platform-companies-page.tsx:111, verify-email-page.tsx:86's \"Log in\",\n * billing-history-page.tsx:85's PDF download), and has 26 `<Button onClick={() =>\n * navigate(…)}>` that are links in all but markup: no middle-click, no \"open in new\n * tab\", no URL on hover, and a screen reader announces a button that then changes the\n * page. With `href` it is an `<a>`: the provider's router link\n * ({@link UiKitProvider}'s `linkComponent`), or `renderLink` if given, else a plain one.\n *\n * `type` and `form` are typed out: they are a `<button>`'s, and a link never submits.\n */\nexport interface ButtonLinkProps extends Omit<AnchorHTMLAttributes<HTMLAnchorElement>, \"type\" | \"href\"> {\n href: string;\n variant?: ButtonVariant;\n size?: ButtonSize;\n stretch?: boolean;\n tone?: ButtonTone;\n /** The in-app router link, over the provider's `linkComponent` (see\n * {@link KitLinkComponent}). Ignored when `external`. */\n renderLink?: KitLinkComponent;\n /** Leaves the app: a plain `<a target=\"_blank\" rel=\"noopener noreferrer\">`, never the\n * router link, and \"(opens in a new tab)\" read after the label — billing-history's\n * Stripe PDF. A `target=\"_blank\"` of your own is announced the same way. */\n external?: boolean;\n /**\n * Navigate by replacing the current history entry, so Back does not return here —\n * keksdose F1: the \"Continue\" out of a one-shot page (an emailed confirmation link, a\n * payment return URL) that must not be landed on again. Handed to the router link as\n * `replace` (see `KitLinkProps.replace` for mapping it); on a plain `<a>` a plain click\n * becomes `location.replace(href)`. Ignored when `external`.\n */\n replace?: boolean;\n /**\n * A plain `<a>` for an in-app `href`, so the browser loads the whole document instead\n * of the router swapping the view — keksdose F1: a route the SPA does not own (a\n * server-rendered export, a logout that must drop every in-memory cache). Wins over\n * `renderLink` and the provider's `linkComponent`.\n */\n reloadDocument?: boolean;\n /** A link cannot be `disabled`, so this renders an `<a>` with NO `href` —\n * `role=\"link\"` and `aria-disabled`, out of the tab order like a disabled button,\n * with the disabled look. The router link is not used: it needs somewhere to go. */\n disabled?: boolean;\n ref?: Ref<HTMLAnchorElement>;\n type?: never;\n form?: never;\n pending?: never;\n pressed?: never;\n disabledReason?: never;\n /** A link that explains why it cannot be followed is a refused BUTTON — see\n * {@link ButtonProps.refused}. */\n refused?: never;\n disabledReasonDisplay?: never;\n commit?: never;\n}\n\n// Declared link-first so `<Button href>` picks the link props; the LAST signature is\n// what `ComponentProps<typeof Button>` reads, so wrappers (FileButton) still see the\n// `<button>` props they always did.\nexport function Button(props: ButtonLinkProps): ReactElement;\nexport function Button(props: ButtonProps): ReactElement;\nexport function Button(props: ButtonProps | ButtonLinkProps): ReactElement {\n if (props.href !== undefined) return <ButtonLink {...(props as ButtonLinkProps)} />;\n return <ButtonElement {...(props as ButtonProps)} />;\n}\n\nfunction ButtonLink({\n href,\n renderLink,\n external,\n replace,\n reloadDocument,\n disabled,\n variant = \"default\",\n size = \"md\",\n stretch,\n tone = \"default\",\n className,\n children,\n target,\n rel,\n onClick,\n ...rest\n}: ButtonLinkProps) {\n const kitLink = useKitLink();\n const common = useKitLabels(\"common\", DEFAULT_COMMON_LABELS);\n // The same rule as every kit link (`pickLinkRenderer`): an external `href` or an\n // in-page `#anchor` is never handed to the provider's router link, even without\n // `external`.\n // `reloadDocument` is the caller saying \"not the router\" for this one link.\n const Link = reloadDocument ? undefined : pickLinkRenderer<KitLinkProps>(renderLink, kitLink, href);\n const newTab = !disabled && (external || target === \"_blank\");\n const cls = cn(\n buttonLook(variant, size, stretch, tone),\n // `relative` holds the sr-only note (see sr-only-containment.test).\n newTab && \"relative\",\n // BUTTON_BASE's `disabled:` classes never match an `<a>`; no pointer events also\n // keeps the hover fill from lighting up on something that refuses the click.\n disabled && \"pointer-events-none cursor-not-allowed opacity-50\",\n className,\n );\n const body = (\n <>\n {children}\n {/* The space OUTSIDE the sr-only span: a name computation trims each child's\n text, so one inside it was lost (\"Download(opens…\"). In a flex box a bare\n space draws nothing. */}\n {newTab && \" \"}\n {newTab && <span className=\"sr-only\">({common.opensInNewTab})</span>}\n </>\n );\n if (disabled) {\n return (\n <a {...rest} role=\"link\" aria-disabled=\"true\" className={cls}>\n {body}\n </a>\n );\n }\n if (external || !Link) {\n return (\n <a\n {...rest}\n href={href}\n target={external ? \"_blank\" : target}\n rel={external ? (rel ?? \"noopener noreferrer\") : rel}\n onClick={external ? onClick : replacingClick(onClick, href, replace, target)}\n className={cls}\n >\n {body}\n </a>\n );\n }\n return (\n <RenderedButtonLink\n render={Link}\n {...rest}\n {...routerLinkNavigation(replace)}\n href={href}\n target={target}\n rel={rel}\n onClick={onClick}\n className={cls}\n >\n {body}\n </RenderedButtonLink>\n );\n}\n\nfunction ButtonElement({\n variant = \"default\",\n size = \"md\",\n stretch,\n tone = \"default\",\n pressed,\n refused: refusedProp,\n disabledReason: ownDisabledReason,\n disabledReasonDisplay,\n commit,\n pending,\n className,\n onClick,\n children,\n href: _href,\n renderLink: _renderLink,\n external: _external,\n replace: _replace,\n reloadDocument: _reloadDocument,\n ...rest\n}: ButtonProps) {\n // `locked`: refused WITH words, which describe it; `refused` also covers `true`.\n const { reason: disabledReason, locked, reasonId, refused } = useRefusal(refusedProp, ownDisabledReason, commit);\n const inert = refused || Boolean(pending);\n const ownDescribedBy = rest[\"aria-describedby\"];\n const button = (\n <button\n {...rest}\n disabled={inert ? undefined : rest.disabled}\n aria-disabled={inert || rest[\"aria-disabled\"]}\n aria-busy={pending || rest[\"aria-busy\"]}\n aria-describedby={locked ? (ownDescribedBy ? `${ownDescribedBy} ${reasonId}` : reasonId) : ownDescribedBy}\n aria-pressed={pressed ?? rest[\"aria-pressed\"]}\n onClick={(e) => {\n // `preventDefault` as well as not calling through: a locked submit button must\n // not submit its form, and the browser does that after this handler returns.\n if (inert) {\n e.preventDefault();\n return;\n }\n onClick?.(e);\n }}\n className={cn(\n buttonLook(variant, size, stretch, tone),\n pressed && (variant === \"link\" ? BUTTON_LINK_PRESSED : ICON_BUTTON_PRESSED),\n // The disabled look for the focusable kind of disabled.\n refused && \"cursor-not-allowed opacity-50\",\n // No dimming while pending: the spinner IS the state, and at half opacity it\n // would be the faintest thing on the button. `relative` anchors it.\n pending && \"relative cursor-progress\",\n className,\n )}\n >\n {pending ? (\n <>\n {/* Transparent, not hidden: it keeps the width AND stays the accessible\n name (`visibility: hidden` would drop it from the name). `gap-[inherit]`\n keeps an icon + text spaced as the button spaces them. */}\n <span className=\"inline-flex items-center justify-center gap-[inherit] opacity-0\">{children}</span>\n {/* Decorative: `aria-busy` says it, as on FileButton. */}\n <Spinner label={null} className=\"absolute inset-0 m-auto size-4 border-current/30 border-t-current\" />\n </>\n ) : (\n children\n )}\n </button>\n );\n if (!locked) return button;\n return (\n <LockedReason\n lock={{ reason: disabledReason, reasonId }}\n display={disabledReasonDisplay}\n className={stretch ? \"self-stretch\" : undefined}\n >\n {button}\n </LockedReason>\n );\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../src/components/button.tsx"],"sourcesContent":["import type { AnchorHTMLAttributes, ButtonHTMLAttributes, ReactElement, ReactNode, Ref } from \"react\";\nimport { cn } from \"../lib/cn\";\nimport { DEFAULT_COMMON_LABELS, useKitLabels, useKitLink } from \"../i18n/kit-labels\";\nimport type { KitLinkComponent, KitLinkProps } from \"../i18n/kit-labels\";\nimport { pickLinkRenderer, replacingClick, routerLinkNavigation } from \"./text-link\";\nimport type { CommitScope } from \"./write-lock\";\nimport { LockedReason, useRefusal, type DisabledReasonDisplay } from \"./field-parts\";\nimport { Spinner } from \"./spinner\";\nimport { BUTTON_LINK_PRESSED, ICON_BUTTON_PRESSED, RenderedButtonLink, buttonLook } from \"./button-look\";\nimport type { ButtonSize, ButtonTone, ButtonVariant } from \"./button-look\";\n\n// The kit's text button, and `<Button href>`, the link that looks like one. Its look —\n// the variant, size and tone maps, and `buttonClasses` — is ./button-look, which\n// IconButton shares (0.34, MOD-2, refactor audit 2026-10).\n\n/**\n * ⚠️ No default `type`: like a native `<button>`, a `Button` inside a `<form>` SUBMITS\n * it unless you pass `type=\"button\"`. Kept native on purpose — a form's own submit\n * button relies on it, and changing the default would silently stop those forms from\n * submitting. Pass `type=\"button\"` for every other action in a form (keksdose had six\n * link buttons submitting their form).\n */\nexport interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {\n /** `default` (the quiet action, what a Button without `variant` draws), `accent` (the\n * strong call to action, solid brand), `secondary`, `ghost`, `danger` or `link`. See\n * {@link ButtonVariant}. */\n variant?: ButtonVariant;\n /** `md` (default) is the page's action button. `sm` is the compact one — 12px text\n * and `px-2 py-1` — for the secondary actions in a toolbar, a card header or a\n * table's header row (keksdose writes `px-2 py-1 text-xs` over `secondary` by hand\n * there). Every variant takes either size. */\n size?: ButtonSize;\n /** In a flex row next to a taller labelled field, fill the field's height so the\n * two line up. No effect outside a flex row. */\n stretch?: boolean;\n /** The `<button>` element. React 19 passes `ref` to a function component as an\n * ordinary prop, so it rides `...rest` onto the element with no `forwardRef` —\n * declared here only because `ButtonHTMLAttributes` does not carry it. */\n ref?: Ref<HTMLButtonElement>;\n /** Text colour for `link` and `ghost`: `muted` (quiet, body colour on hover) or\n * `danger` (quiet, `--danger` on hover). `danger` also makes `secondary` an\n * outlined destructive button and `default` a soft one; ignored by the other\n * variants. See {@link ButtonTone}. */\n tone?: ButtonTone;\n /**\n * Make it a toggle button, as {@link IconButton}'s `pressed` does. `true` sets\n * `aria-pressed=\"true\"` and draws the \"on\" look — on a `link`, the brand colour and a\n * medium weight; on the boxed variants, the quiet brand fill IconButton uses — and\n * `false` sets `aria-pressed=\"false\"` with the ordinary look. Left out, no\n * `aria-pressed` (or the caller's own). For lenkbank's \"All speeds\" text toggle\n * (gear/hysteresis-charts.tsx:482), which is a hand-rolled `<button aria-pressed>`\n * swapping two class strings; with `variant=\"link\" tone=\"muted\"` it is this.\n */\n pressed?: boolean;\n /**\n * Why the action is not available — the button's half of DangerConfirm's\n * `lockedReason` (kastlan handover-detail-page.tsx:197 locks a signed handover).\n *\n * A disabled button that cannot say why is a dead end: the native `disabled` takes it\n * out of the tab order, so a keyboard user never lands on it, and a pointer gets a\n * `not-allowed` cursor and nothing else. With a reason the button is `aria-disabled`\n * instead — still focusable, still hoverable — clicks (and the Enter/Space and form\n * submission they stand for) do nothing, and the reason is shown in the kit\n * {@link Tooltip} and attached with `aria-describedby`, so a screen reader hears it\n * on focus. It wins over `disabled`: passing both keeps the button reachable, which is\n * the point of giving a reason.\n *\n * The description is a `hidden` copy of the reason rather than the bubble itself: the\n * bubble exists only while hovered or focused once it is portalled, and a description\n * that comes and goes is read inconsistently. The bubble is kept visual, the way\n * DangerConfirm keeps its own.\n *\n * `refused={reason}` (0.55) is the same refusal by the button's own name for it, with\n * a form for a reason said elsewhere — see `refused`.\n */\n disabledReason?: ReactNode;\n /**\n * Refuse the action and stay where the user can learn why — no permission, a\n * read-only plan, a held lock, an end already reached (0.52 code review 2026-10-31,\n * held to 0.55). Shown with `disabled`, a refused action leaves the tab order and says\n * nothing; an app's way round that was a Tooltip over the disabled button, which a\n * keyboard never opens (Kurvenschmiede's owner-only delete,\n * shared/lib/table-columns.tsx), and the kit's own arrows at an end hand-wrote\n * `aria-disabled` and a guard in their `onClick`.\n *\n * Refused, the button is `aria-disabled=\"true\"` — never the native `disabled`, which\n * it wins over — and stays focusable and hoverable, with the disabled look; every\n * activation is swallowed: the click, the Enter and Space that stand for it, and the\n * submission a submit button would make.\n *\n * - **Words** (`refused={t(\"sharing.owner_only\")}`): the reason, said the\n * `disabledReason` way — the kit {@link Tooltip}, or a line at Large and on touch\n * (`disabledReasonDisplay`) — and attached with `aria-describedby`, so a screen\n * reader hears it on focus. One path with `disabledReason`; these words win over its.\n * - **`true`**: refused with no words of its own, for a reason the screen already\n * says — a banner over the form, a line under it — which the button's own\n * `aria-describedby` names ({@link DisabledReasonLine} draws the line with its id),\n * or one that needs no saying (› on the last picture). No bubble.\n * - `false`, `null`, `undefined` or `\"\"`: not refused, as `cond && reason` gives.\n *\n * Under a locked {@link WriteLockProvider} a `commit` button says the lock's reason\n * instead, as it does over `disabledReason`.\n */\n refused?: ReactNode | boolean;\n /**\n * Where `disabledReason` shows (0.32, §4 \"No fact only in a tooltip\"): by default a\n * line under the button at Large and on a touch screen, the tooltip otherwise. See\n * {@link DisabledReasonDisplay}. A worded `refused` shows the same way.\n */\n disabledReasonDisplay?: DisabledReasonDisplay;\n /**\n * This button COMMITS — it saves, creates, deletes. Under a locked\n * {@link WriteLockProvider} it is disabled the `disabledReason` way, with the lock's\n * reason (which wins over a `disabledReason` of its own). No provider, or an\n * unlocked one: no effect. See {@link WriteLockProvider}; a {@link CommitScope} since\n * 0.33 (`COMMIT_EXCEPT_BILLING`: every lock but a lapsed plan's).\n */\n commit?: CommitScope;\n /**\n * Busy — the save is in flight: a spinner, `aria-busy`, and no second submit.\n *\n * kastlan's FormActions (form-actions.tsx:50) disables its submit and swaps in a\n * `submittingLabel`, so the button jumps width mid-click and a screen reader hears\n * the name change instead of \"busy\". Here the label STAYS — it is still the\n * accessible name and still holds the button's width — and the spinner is drawn over\n * it in the text colour, so nothing beside the button moves.\n *\n * The same prop {@link FileButton} has (`pending`), with one difference: this one is\n * `aria-disabled`, not `disabled`. The button being pressed is the one that has focus,\n * and a native `disabled` drops that focus to `<body>` the moment the save starts;\n * clicks, and the Enter that submits a form through it, are swallowed instead.\n */\n pending?: boolean;\n /** Only on the link form — see {@link ButtonLinkProps}. */\n href?: never;\n renderLink?: never;\n external?: never;\n replace?: never;\n reloadDocument?: never;\n}\n\n/**\n * `<Button href>`: a link that looks like a button — for an action that NAVIGATES.\n *\n * kastlan paints `buttonClasses(…)` onto router Links by hand in six places (the\n * invoice / lease / unit preview dialogs' \"Open\" at invoice-preview-dialog.tsx:36,\n * platform-companies-page.tsx:111, verify-email-page.tsx:86's \"Log in\",\n * billing-history-page.tsx:85's PDF download), and has 26 `<Button onClick={() =>\n * navigate(…)}>` that are links in all but markup: no middle-click, no \"open in new\n * tab\", no URL on hover, and a screen reader announces a button that then changes the\n * page. With `href` it is an `<a>`: the provider's router link\n * ({@link UiKitProvider}'s `linkComponent`), or `renderLink` if given, else a plain one.\n *\n * `type` and `form` are typed out: they are a `<button>`'s, and a link never submits.\n */\nexport interface ButtonLinkProps extends Omit<AnchorHTMLAttributes<HTMLAnchorElement>, \"type\" | \"href\"> {\n href: string;\n variant?: ButtonVariant;\n size?: ButtonSize;\n stretch?: boolean;\n tone?: ButtonTone;\n /** The in-app router link, over the provider's `linkComponent` (see\n * {@link KitLinkComponent}). Ignored when `external`. */\n renderLink?: KitLinkComponent;\n /** Leaves the app: a plain `<a target=\"_blank\" rel=\"noopener noreferrer\">`, never the\n * router link, and \"(opens in a new tab)\" read after the label — billing-history's\n * Stripe PDF. A `target=\"_blank\"` of your own is announced the same way. */\n external?: boolean;\n /**\n * Navigate by replacing the current history entry, so Back does not return here —\n * keksdose F1: the \"Continue\" out of a one-shot page (an emailed confirmation link, a\n * payment return URL) that must not be landed on again. Handed to the router link as\n * `replace` (see `KitLinkProps.replace` for mapping it); on a plain `<a>` a plain click\n * becomes `location.replace(href)`. Ignored when `external`.\n */\n replace?: boolean;\n /**\n * A plain `<a>` for an in-app `href`, so the browser loads the whole document instead\n * of the router swapping the view — keksdose F1: a route the SPA does not own (a\n * server-rendered export, a logout that must drop every in-memory cache). Wins over\n * `renderLink` and the provider's `linkComponent`.\n */\n reloadDocument?: boolean;\n /** A link cannot be `disabled`, so this renders an `<a>` with NO `href` —\n * `role=\"link\"` and `aria-disabled`, out of the tab order like a disabled button,\n * with the disabled look. The router link is not used: it needs somewhere to go. */\n disabled?: boolean;\n ref?: Ref<HTMLAnchorElement>;\n type?: never;\n form?: never;\n pending?: never;\n pressed?: never;\n disabledReason?: never;\n /** A link that explains why it cannot be followed is a refused BUTTON — see\n * {@link ButtonProps.refused}. */\n refused?: never;\n disabledReasonDisplay?: never;\n commit?: never;\n}\n\n// Declared link-first so `<Button href>` picks the link props; the LAST signature is\n// what `ComponentProps<typeof Button>` reads, so wrappers (FileButton) still see the\n// `<button>` props they always did.\nexport function Button(props: ButtonLinkProps): ReactElement;\nexport function Button(props: ButtonProps): ReactElement;\nexport function Button(props: ButtonProps | ButtonLinkProps): ReactElement {\n if (props.href !== undefined) return <ButtonLink {...(props as ButtonLinkProps)} />;\n return <ButtonElement {...(props as ButtonProps)} />;\n}\n\nfunction ButtonLink({\n href,\n renderLink,\n external,\n replace,\n reloadDocument,\n disabled,\n variant = \"default\",\n size = \"md\",\n stretch,\n tone = \"default\",\n className,\n children,\n target,\n rel,\n onClick,\n ...rest\n}: ButtonLinkProps) {\n const kitLink = useKitLink();\n const common = useKitLabels(\"common\", DEFAULT_COMMON_LABELS);\n // The same rule as every kit link (`pickLinkRenderer`): an external `href` or an\n // in-page `#anchor` is never handed to the provider's router link, even without\n // `external`.\n // `reloadDocument` is the caller saying \"not the router\" for this one link.\n const Link = reloadDocument ? undefined : pickLinkRenderer<KitLinkProps>(renderLink, kitLink, href);\n const newTab = !disabled && (external || target === \"_blank\");\n const cls = cn(\n buttonLook(variant, size, stretch, tone),\n // `relative` holds the sr-only note (see sr-only-containment.test).\n newTab && \"relative\",\n // BUTTON_BASE's `disabled:` classes never match an `<a>`; no pointer events also\n // keeps the hover fill from lighting up on something that refuses the click.\n disabled && \"pointer-events-none cursor-not-allowed opacity-50\",\n className,\n );\n const body = (\n <>\n {children}\n {/* The space OUTSIDE the sr-only span: a name computation trims each child's\n text, so one inside it was lost (\"Download(opens…\"). In a flex box a bare\n space draws nothing. */}\n {newTab && \" \"}\n {newTab && <span className=\"sr-only\">({common.opensInNewTab})</span>}\n </>\n );\n if (disabled) {\n return (\n <a {...rest} role=\"link\" aria-disabled=\"true\" className={cls}>\n {body}\n </a>\n );\n }\n if (external || !Link) {\n return (\n <a\n {...rest}\n href={href}\n target={external ? \"_blank\" : target}\n rel={external ? (rel ?? \"noopener noreferrer\") : rel}\n onClick={external ? onClick : replacingClick(onClick, href, replace, target)}\n className={cls}\n >\n {body}\n </a>\n );\n }\n return (\n <RenderedButtonLink\n render={Link}\n {...rest}\n {...routerLinkNavigation(replace)}\n href={href}\n target={target}\n rel={rel}\n onClick={onClick}\n className={cls}\n >\n {body}\n </RenderedButtonLink>\n );\n}\n\nfunction ButtonElement({\n variant = \"default\",\n size = \"md\",\n stretch,\n tone = \"default\",\n pressed,\n refused: refusedProp,\n disabledReason: ownDisabledReason,\n disabledReasonDisplay,\n commit,\n pending,\n className,\n onClick,\n children,\n href: _href,\n renderLink: _renderLink,\n external: _external,\n replace: _replace,\n reloadDocument: _reloadDocument,\n ...rest\n}: ButtonProps) {\n // `locked`: refused WITH words, which describe it; `refused` also covers `true`.\n const { reason: disabledReason, locked, reasonId, refused } = useRefusal(refusedProp, ownDisabledReason, commit);\n const inert = refused || Boolean(pending);\n const ownDescribedBy = rest[\"aria-describedby\"];\n const button = (\n <button\n {...rest}\n disabled={inert ? undefined : rest.disabled}\n aria-disabled={inert || rest[\"aria-disabled\"]}\n aria-busy={pending || rest[\"aria-busy\"]}\n aria-describedby={locked ? (ownDescribedBy ? `${ownDescribedBy} ${reasonId}` : reasonId) : ownDescribedBy}\n aria-pressed={pressed ?? rest[\"aria-pressed\"]}\n onClick={(e) => {\n // `preventDefault` as well as not calling through: a locked submit button must\n // not submit its form, and the browser does that after this handler returns.\n // `stopPropagation` too: a natively disabled button gets no click at all, so a\n // clickable row around it never heard one, and the focusable refusal keeps that\n // (Kurvenschmiede's 0.55 adoption: a refused delete opened its row).\n if (inert) {\n e.preventDefault();\n e.stopPropagation();\n return;\n }\n onClick?.(e);\n }}\n className={cn(\n buttonLook(variant, size, stretch, tone),\n pressed && (variant === \"link\" ? BUTTON_LINK_PRESSED : ICON_BUTTON_PRESSED),\n // The disabled look for the focusable kind of disabled.\n refused && \"cursor-not-allowed opacity-50\",\n // No dimming while pending: the spinner IS the state, and at half opacity it\n // would be the faintest thing on the button. `relative` anchors it.\n pending && \"relative cursor-progress\",\n className,\n )}\n >\n {pending ? (\n <>\n {/* Transparent, not hidden: it keeps the width AND stays the accessible\n name (`visibility: hidden` would drop it from the name). `gap-[inherit]`\n keeps an icon + text spaced as the button spaces them. */}\n <span className=\"inline-flex items-center justify-center gap-[inherit] opacity-0\">{children}</span>\n {/* Decorative: `aria-busy` says it, as on FileButton. */}\n <Spinner label={null} className=\"absolute inset-0 m-auto size-4 border-current/30 border-t-current\" />\n </>\n ) : (\n children\n )}\n </button>\n );\n if (!locked) return button;\n return (\n <LockedReason\n lock={{ reason: disabledReason, reasonId }}\n display={disabledReasonDisplay}\n className={stretch ? \"self-stretch\" : undefined}\n >\n {button}\n </LockedReason>\n );\n}\n"],"mappings":"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","names":[]}
@@ -232,9 +232,9 @@ export interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement>
232
232
  /**
233
233
  * Refuse the action and stay where the user can learn why — {@link Button}'s
234
234
  * `refused` (0.52 code review 2026-10-31, held to 0.55), on the icon button:
235
- * `aria-disabled="true"`, focusable, the disabled look, every activation swallowed
236
- * (`stopPropagation` still applies, so a refused action in a clickable row does not
237
- * open the row either). Kurvenschmiede's owner-only delete was a Tooltip round a
235
+ * `aria-disabled="true"`, focusable, the disabled look, every activation swallowed and
236
+ * kept from a clickable row around it, as a natively disabled button's (0.55.1; no
237
+ * `stopPropagation` needed). Kurvenschmiede's owner-only delete was a Tooltip round a
238
238
  * natively disabled IconButton (shared/lib/table-columns.tsx); the Lightbox's arrows
239
239
  * at an end write `aria-disabled` and guard their own `onClick`.
240
240
  *
@@ -395,6 +395,7 @@ function IconButtonElement({
395
395
  if (stopPropagation) e.stopPropagation();
396
396
  if (inert) {
397
397
  e.preventDefault();
398
+ e.stopPropagation();
398
399
  return;
399
400
  }
400
401
  onClick?.(e);
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/components/icon-button.tsx"],"sourcesContent":["import { useContext } from \"react\";\nimport type {\n AnchorHTMLAttributes,\n ButtonHTMLAttributes,\n CSSProperties,\n KeyboardEvent,\n MouseEvent,\n ReactElement,\n ReactNode,\n Ref,\n} from \"react\";\nimport { cn } from \"../lib/cn\";\nimport { FOCUS_RING_WIDTH } from \"./focus-ring\";\nimport { useLargeText } from \"../hooks/use-large-text\";\nimport { Tooltip, type TooltipSide } from \"./tooltip\";\nimport { DEFAULT_COMMON_LABELS, useKitLabels, useKitLink } from \"../i18n/kit-labels\";\nimport type { KitLinkComponent, KitLinkProps } from \"../i18n/kit-labels\";\nimport { pickLinkRenderer, replacingClick, routerLinkNavigation } from \"./text-link\";\nimport type { CommitScope } from \"./write-lock\";\nimport { hasContent } from \"../lib/presence\";\nimport { CompactControlsContext, LockedReason, useRefusal, type DisabledReasonDisplay } from \"./field-parts\";\nimport { Spinner } from \"./spinner\";\nimport {\n HOVER_INK,\n ICON_BUTTON_PRESSED,\n RenderedButtonLink,\n HIT_AREA_LARGE,\n TOUCH_TARGET_LARGE,\n buttonVariantClasses,\n} from \"./button-look\";\nimport type { ButtonVariant } from \"./button-look\";\n\n// Canonical square icon-only button. <Button> carries TEXT geometry — px-3 py-2\n// gap-2 — so rendering a bare glyph through it gives a small icon in a wide,\n// text-shaped box. This fixes a square box instead and, crucially, forces the\n// child icon to a size set by the BOX (20px at md/sm) via `[&_svg]:size-*` so a\n// caller cannot under-size it. Use it wherever an action is a bare icon\n// (edit/delete/tools) so they all match the top-bar icon buttons and can never\n// drift apart again.\n//\n// Draws its colours from the same `buttonVariantClasses` map as <Button>, so the\n// two re-skin together with the palette.\nconst ICON_BUTTON_BASE = cn(\n \"inline-flex items-center justify-center rounded-md transition-colors focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed\",\n FOCUS_RING_WIDTH,\n);\n\nexport type IconButtonSize = \"2xl\" | \"xl\" | \"lg\" | \"md\" | \"sm\" | \"xs\" | \"2xs\";\n\n// Box and glyph together, so a 24px chip action cannot end up holding a 20px icon\n// that touches its edges. The two small steps are lenkbank's list-row (28px) and\n// chip (24px) actions, which it had hand-rolled beside the kit's 32/36px ones.\nconst ICON_BUTTON_SIZES: Record<IconButtonSize, string> = {\n // 64px with a 28px glyph: a camera's shutter (keksdose invoices/camera-capture.tsx:248,\n // `size-16` by hand) — the one control of a full-screen capture view, thumb-sized.\n \"2xl\": \"size-16 [&_svg]:size-7\",\n // 48px with a 24px glyph: the one primary action of a phone screen or sheet, a step\n // past the 44px minimum rather than on it.\n xl: \"size-12 [&_svg]:size-6\",\n // The 44px touch target (WCAG 2.5.5's size) with the same 20px glyph — keksdose's\n // bulk-action bars write `size-11` by hand over an `md` button to get it on phones.\n lg: \"size-11 [&_svg]:size-5\",\n md: \"size-9 [&_svg]:size-5\",\n sm: \"size-8 [&_svg]:size-5\",\n xs: \"size-7 rounded [&_svg]:size-4\",\n \"2xs\": \"size-6 rounded [&_svg]:size-3.5\",\n};\n\n// `stretch`: the same WIDTH and glyph, and a height taken from the row instead of\n// stated — `size-*` sets a height that `self-stretch` cannot override, so the box is\n// written as a width plus a minimum height (the square it would otherwise be).\nconst ICON_BUTTON_STRETCH_SIZES: Record<IconButtonSize, string> = {\n \"2xl\": \"w-16 min-h-16 self-stretch [&_svg]:size-7\",\n xl: \"w-12 min-h-12 self-stretch [&_svg]:size-6\",\n lg: \"w-11 min-h-11 self-stretch [&_svg]:size-5\",\n md: \"w-9 min-h-9 self-stretch [&_svg]:size-5\",\n sm: \"w-8 min-h-8 self-stretch [&_svg]:size-5\",\n xs: \"w-7 min-h-7 self-stretch rounded [&_svg]:size-4\",\n \"2xs\": \"w-6 min-h-6 self-stretch rounded [&_svg]:size-3.5\",\n};\n\n// The label as visible text beside the glyph (0.32, §4: at Large \"IconButton shows its\n// label\"): the same height and glyph as the square, a width taken from the words. A\n// map of its own rather than `w-auto` over `size-*`, which tailwind-merge cannot undo\n// in one direction.\nconst ICON_BUTTON_TEXT_SIZES: Record<IconButtonSize, string> = {\n \"2xl\": \"h-16 gap-2 px-4 text-base [&_svg]:size-7\",\n xl: \"h-12 gap-2 px-3.5 text-sm [&_svg]:size-6\",\n lg: \"h-11 gap-2 px-3 text-sm [&_svg]:size-5\",\n md: \"h-9 gap-1.5 px-2.5 text-sm [&_svg]:size-5\",\n sm: \"h-8 gap-1.5 px-2 text-sm [&_svg]:size-5\",\n xs: \"h-7 gap-1 rounded px-1.5 text-xs [&_svg]:size-4\",\n \"2xs\": \"h-6 gap-1 rounded px-1.5 text-xs [&_svg]:size-3.5\",\n};\nconst ICON_BUTTON_TEXT_STRETCH_SIZES: Record<IconButtonSize, string> = {\n \"2xl\": \"min-h-16 self-stretch gap-2 px-4 text-base [&_svg]:size-7\",\n xl: \"min-h-12 self-stretch gap-2 px-3.5 text-sm [&_svg]:size-6\",\n lg: \"min-h-11 self-stretch gap-2 px-3 text-sm [&_svg]:size-5\",\n md: \"min-h-9 self-stretch gap-1.5 px-2.5 text-sm [&_svg]:size-5\",\n sm: \"min-h-8 self-stretch gap-1.5 px-2 text-sm [&_svg]:size-5\",\n xs: \"min-h-7 self-stretch gap-1 rounded px-1.5 text-xs [&_svg]:size-4\",\n \"2xs\": \"min-h-6 self-stretch gap-1 rounded px-1.5 text-xs [&_svg]:size-3.5\",\n};\n\n// The 48 px touch target at Large (§4), per size. `lg` and up are past it already\n// (2.75rem is 55 px at Large). `md` … `xs` grow their box to it. `2xs` is the action ON\n// a chip or a tab, whose box cannot grow without growing the chip: it keeps its box and\n// gets a 48 px hit area instead — an invisible `::after` centred on it, which takes the\n// taps a finger lands round the glyph (`HIT_AREA_LARGE`).\nconst ICON_BUTTON_TOUCH_LARGE: Record<IconButtonSize, string> = {\n \"2xl\": \"\",\n xl: \"\",\n lg: \"\",\n md: TOUCH_TARGET_LARGE,\n sm: TOUCH_TARGET_LARGE,\n xs: TOUCH_TARGET_LARGE,\n \"2xs\": HIT_AREA_LARGE,\n};\n\n// A tone re-colours the glyph without changing what the variant draws around it.\n// Each coloured tone comes in two resting looks, picked by `quiet` (see the prop):\n// QUIET is placeholder grey until the pointer or focus arrives, then the tone's\n// family; TONED wears the tone's colour at rest. The defaults are the looks each tone\n// had before `quiet` existed: `danger` is quiet — an action in every row of a list\n// must not shout from every row, so the red arrives only on the one row you are about\n// to act on — while `warning` and `info` are toned, because they mark the ONE thing\n// on the row to notice (keksdose's \"needs review\" flag, its reconcile action at\n// accounts-page:867), and quiet grey would hide the very thing they point out.\n// `muted` is quiet by definition and has no toned look; `default` has no tone.\n//\n// `success` is toned at rest too: the green is the news (keksdose's \"in sync\" chip).\n// `custom` paints from `--icon-button-tone`, which `toneColor` sets — for a glyph whose\n// colour FOLLOWS STATE across tones (keksdose's sync chip: rose, amber, sky, green as\n// the sync moves), where swapping `tone` would do but the colour is one variable.\nexport type IconButtonTone = \"default\" | \"muted\" | \"danger\" | \"warning\" | \"info\" | \"success\" | \"custom\";\n\ntype ColouredTone = \"danger\" | \"warning\" | \"info\" | \"success\" | \"custom\";\n\nconst ICON_BUTTON_TONES: Record<ColouredTone, { quiet: string; toned: string }> = {\n danger: {\n quiet: \"text-placeholder hover:bg-danger-soft hover:text-danger focus-visible:ring-danger-strong\",\n toned: \"text-danger hover:bg-danger-soft focus-visible:ring-danger-strong\",\n },\n warning: {\n quiet: \"text-placeholder hover:bg-warning-soft hover:text-warning focus-visible:ring-warning-strong\",\n // The ring is warning's loud line (0.33, k27): `--warning-border`, amber-300, was a\n // focus ring at 1.1:1 on the light surfaces.\n toned: \"text-warning hover:bg-warning-soft focus-visible:ring-warning-strong\",\n },\n // The ring is the tone's own text colour (0.52, refactor audit 2026-10-30, DSP-6):\n // `--info-border` and `--success-border` rang at 1.2–1.4:1 on the light grounds and\n // about 2:1 in dark, and More contrast re-solves neither — while `--info` / `--success`\n // clear 4.5:1 and are re-solved there. The sync chip wears these two on every page.\n info: {\n quiet: \"text-placeholder hover:bg-info-soft hover:text-info focus-visible:ring-[var(--info)]\",\n toned: \"text-info hover:bg-info-soft focus-visible:ring-[var(--info)]\",\n },\n success: {\n quiet: \"text-placeholder hover:bg-success-soft hover:text-success focus-visible:ring-[var(--success)]\",\n toned: \"text-success hover:bg-success-soft focus-visible:ring-[var(--success)]\",\n },\n // The hover fill is MIXED from the one colour, since a custom tone brings no `-bg` of\n // its own; the ring is the colour at full strength (0.52, DSP-6: at 40 % it was a ring\n // nobody could see). The fallback is the body colour, so a `custom` tone with no colour\n // set is the default look rather than invisible.\n custom: {\n quiet:\n \"text-placeholder hover:bg-[color-mix(in_srgb,var(--icon-button-tone,var(--text-primary))_12%,transparent)] hover:text-[var(--icon-button-tone,var(--text-primary))] focus-visible:ring-[var(--icon-button-tone,var(--text-primary))]\",\n toned:\n \"text-[var(--icon-button-tone,var(--text-primary))] hover:bg-[color-mix(in_srgb,var(--icon-button-tone,var(--text-primary))_12%,transparent)] focus-visible:ring-[var(--icon-button-tone,var(--text-primary))]\",\n },\n};\n\n// The translucent ink on hover, as Button's secondary/ghost (B′, see HOVER_INK).\nconst ICON_BUTTON_MUTED = `text-placeholder ${HOVER_INK} hover:text-primary`;\n\n/** Which tones sit quiet at rest when `quiet` is left out. */\nconst QUIET_BY_DEFAULT: Record<ColouredTone, boolean> = {\n danger: true,\n warning: false,\n info: false,\n success: false,\n custom: false,\n};\n\nfunction iconButtonToneClass(tone: IconButtonTone, quiet: boolean | undefined): string {\n if (tone === \"default\") return \"\";\n if (tone === \"muted\") return cn(ICON_BUTTON_MUTED, ICON_BUTTON_QUIET_DISABLED_REST);\n const isQuiet = quiet ?? QUIET_BY_DEFAULT[tone];\n const look = ICON_BUTTON_TONES[tone];\n return isQuiet ? cn(look.quiet, ICON_BUTTON_QUIET_DISABLED_REST) : look.toned;\n}\n\n// A disabled button must not answer the pointer. The hover classes above are plain\n// `hover:` (so a caller's `className=\"hover:…\"` still replaces them through\n// tailwind-merge), which means they fire on a disabled button too — the old grey\n// icon lit up on hover while refusing the click. Rather than rewrite every hover as\n// `enabled:hover:` (which never matches the `<a>` that `buttonClasses` also styles,\n// and would out-rank a caller's plain `hover:` override), each variant pins its\n// RESTING look under `disabled:hover:`, which out-ranks any `hover:` by specificity\n// and only ever matches a disabled button.\nconst ICON_BUTTON_DISABLED_REST: Record<ButtonVariant | \"overlay\" | \"shutter\", string> = {\n default: \"disabled:hover:bg-surface-2\",\n secondary: \"disabled:hover:bg-transparent\",\n ghost: \"disabled:hover:bg-transparent\",\n danger: \"disabled:hover:bg-danger\",\n accent: \"disabled:hover:bg-brand\",\n link: \"disabled:hover:bg-transparent disabled:hover:no-underline\",\n overlay: \"disabled:hover:bg-[color-mix(in_srgb,var(--bg-inverse)_60%,transparent)]\",\n shutter: \"disabled:hover:bg-media-scrim\",\n};\n\n// The quiet looks change the glyph on hover, so they pin their resting glyph the\n// same way. A toned look keeps its glyph on hover and needs no pin.\nconst ICON_BUTTON_QUIET_DISABLED_REST = \"disabled:hover:text-placeholder\";\n\n// `variant=\"overlay\"`: a round, translucent disc for a control that sits ON a photo\n// (keksdose's receipt-scan preview: close, rotate, retake over the camera image).\n// The inverse pair, not the surface one: what has to hold is the contrast between the\n// disc and its glyph, whatever the picture underneath is, and `--bg-inverse` /\n// `--text-inverse` are the one token pair defined as each other's opposite in both\n// themes. The disc is the inverse at 60% (`color-mix`, so it stays a token a palette\n// can re-point); the blur keeps a busy background from breaking the glyph's edge.\nconst ICON_BUTTON_OVERLAY =\n \"rounded-full bg-[color-mix(in_srgb,var(--bg-inverse)_60%,transparent)] text-inverse backdrop-blur-sm hover:bg-[color-mix(in_srgb,var(--bg-inverse)_75%,transparent)] focus-visible:ring-[var(--text-inverse)]\";\n\n// `variant=\"shutter\"`: the camera's release — keksdose's camera-capture.tsx:248 draws\n// it by hand as a 64px circle with a thick white ring round a translucent fill. It sits\n// on a live camera picture, which is dark whatever the theme, so it uses the MEDIA\n// tokens (tokens.css), white ink on a dark scrim in both themes — the inverse pair\n// `overlay` uses would give a dark ring on a dark viewfinder in dark mode. The focus ring stands off by 2px, or it would\n// merge into the ring that is part of the look.\nconst ICON_BUTTON_SHUTTER =\n \"rounded-full border-4 border-media-ink bg-media-scrim text-media-ink backdrop-blur-sm hover:bg-media-scrim-hover focus-visible:ring-media-ink focus-visible:ring-offset-2 focus-visible:ring-offset-media-scrim-hover\";\n\nexport type IconButtonVariant = ButtonVariant | \"overlay\" | \"shutter\";\n\n/**\n * The glyph's size in px, over the one the box size implies — see\n * {@link IconButtonProps.glyphSize}.\n */\nexport type IconButtonGlyphSize = 12 | 14 | 16 | 20 | 24 | 28;\n\nconst ICON_BUTTON_GLYPH_SIZES: Record<IconButtonGlyphSize, string> = {\n 12: \"[&_svg]:size-3\",\n 14: \"[&_svg]:size-3.5\",\n 16: \"[&_svg]:size-4\",\n 20: \"[&_svg]:size-5\",\n 24: \"[&_svg]:size-6\",\n 28: \"[&_svg]:size-7\",\n};\n\n// `pending`'s spinner, the size of the glyph it replaces (the `[&_svg]` rules above\n// size only an svg, and the spinner is a bordered span).\nconst ICON_BUTTON_SPINNER: Record<IconButtonSize, string> = {\n \"2xl\": \"size-7\",\n xl: \"size-6\",\n lg: \"size-5\",\n md: \"size-5\",\n sm: \"size-5\",\n xs: \"size-4\",\n \"2xs\": \"size-3.5\",\n};\nconst ICON_BUTTON_SPINNER_GLYPH: Record<IconButtonGlyphSize, string> = {\n 12: \"size-3\",\n 14: \"size-3.5\",\n 16: \"size-4\",\n 20: \"size-5\",\n 24: \"size-6\",\n 28: \"size-7\",\n};\n\nexport interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {\n /** Any {@link ButtonVariant}; `overlay` — a round translucent disc for use over\n * an image (see `ICON_BUTTON_OVERLAY`); or `shutter` — a camera's release, a ringed\n * disc over the viewfinder (see `ICON_BUTTON_SHUTTER`; pair it with `size=\"2xl\"`). */\n variant?: IconButtonVariant;\n /** Box size: 2xl = 64px with a 28px icon (a camera shutter), xl = 48px with a 24px\n * icon, lg = 44px (a phone's touch target), md = 36px (matches the top bar),\n * sm = 32px — all three with a 20px icon;\n * xs = 28px with a 16px icon (an action in a list row), 2xs = 24px with a 14px\n * icon (an action on a chip or a tab). `glyphSize` overrides the icon. */\n size?: IconButtonSize;\n /**\n * The icon's size in px, when the box's own is not the one wanted. keksdose's sync\n * chip (app/sync-status-indicator.tsx:142) is a 24px round chip round a 20px cloud —\n * `size=\"2xs\" glyphSize={20} shape=\"round\"` — where `2xs` alone draws 14px. A number\n * over a new size step because the box and the glyph are two independent choices; a\n * step per combination (`\"sm-lg\"`) would have to be named for every one.\n */\n glyphSize?: IconButtonGlyphSize;\n /**\n * A small mark on the button's corner — the sync chip's 12px check over its cloud\n * (\"all good\", feedback #67). Rendered OUTSIDE the `<button>`, in a wrapper beside\n * it, so the box's `[&_svg]:size-*` rule that sizes the glyph does not blow the badge\n * up to the glyph's size; a bare `<Check />` comes out 12px, on a\n * `--bg-surface` disc that keeps it legible over the glyph. Decorative\n * (`aria-hidden`): the name (`label` / `aria-label`) must already carry the state.\n * With a badge, `className` still lands on the button; the wrapper is the flex item.\n */\n badge?: ReactNode;\n /**\n * `keep`: a disabled button keeps its full colour (no 50% drop) and an ordinary\n * cursor. For a STATUS that is also an action — the sync chip shows \"in sync\" in\n * green and refreshes on click, and is disabled while offline or syncing, when the\n * state it shows matters most; dimmed it read as broken (keksdose wrote\n * `disabled:cursor-default` by hand and left out the dimming). Default `dim`.\n */\n disabledStyle?: \"dim\" | \"keep\";\n /**\n * Fill the height of the flex row it stands in, keeping its width — the delete at\n * the end of keksdose's split line (transaction-fields:162), level with the labelled\n * fields beside it rather than floating at half their height. {@link Button}'s\n * `stretch`; no effect outside a flex row. With a `label`, the tooltip's wrapper is\n * what stretches.\n */\n stretch?: boolean;\n /** `round`: a circle instead of the rounded square — a compact status chip in a top\n * bar (keksdose's sync indicator). `overlay` is always round. */\n shape?: \"square\" | \"round\";\n /**\n * The colour of `tone=\"custom\"`, as any CSS colour — a token (`var(--warning)`),\n * ideally. Sets `--icon-button-tone` on the button, so passing a different one per\n * state re-colours the glyph, its hover fill and its focus ring together. Passing it\n * implies `tone=\"custom\"`. Or leave it out and set the variable yourself from a class\n * (`className=\"[--icon-button-tone:var(--success)]\"`).\n *\n * Or a `{ light, dark }` pair, one colour per theme — keksdose: a raw colour that reads\n * on the light surface (`#be123c`) is too dark on the dark one, and an inline style\n * cannot say \"in dark mode\". See {@link IconButtonToneColor}.\n */\n toneColor?: IconButtonToneColor;\n /** Glyph colour over the variant. `muted`: placeholder grey, full text colour on\n * hover. `danger`: the same grey at rest, `--danger` on hover and focus — for a\n * remove/delete that repeats down a list. `warning`: amber at rest — a flag that\n * wants attention. `info`: sky at rest — a notice-worthy but harmless action\n * (keksdose's reconcile). `success`: green at rest — a good state worth seeing.\n * `custom`: the colour of `toneColor` (see there). Default: the variant's own colours. See `quiet` for\n * turning a coloured tone's resting look the other way. */\n tone?: IconButtonTone;\n /**\n * Whether a coloured tone (`danger`, `warning`, `info`) waits for the pointer:\n * `true` is placeholder grey at rest and the tone's colour on hover and focus;\n * `false` wears the tone's colour at rest. Left out, each tone keeps its own\n * default — `danger` quiet, `warning` and `info` not. Ignored for `muted` (quiet by\n * definition) and `default` (no tone).\n *\n * `quiet={false}` on `danger` is for a destructive action that stands ALONE, where\n * hover-to-reveal hides it: keksdose's phone bulk bar (mobile-bulk-bar.tsx) has one\n * \"delete all\" and a touch screen that never hovers, so it painted rose by hand.\n * `quiet` on `warning`/`info` is the same switch the other way, for a flag repeated\n * down a list. A boolean over the tone rather than a new tone (`danger-solid`) or an\n * `emphasis` scale: there are exactly two resting looks, every coloured tone has\n * both, and which one fits is a question about the SITE (alone or repeated, touch or\n * pointer), not about the tone — so it is one switch the family shares.\n */\n quiet?: boolean;\n /**\n * Make it a toggle button. `true` sets `aria-pressed=\"true\"` and draws the \"on\" look\n * (brand glyph on the quiet brand fill); `false` sets `aria-pressed=\"false\"` with the\n * normal look, so a screen reader still hears a toggle that is off. Left out, it is\n * an ordinary button with no `aria-pressed` — or whatever `aria-pressed` the caller\n * passes. For keksdose's budget share toggle (budgets-page:291), which paints its own\n * brand colour over a ghost button today. Keep the `aria-label` the same in both\n * states (\"Share budget\"): the pressed state already says whether it is on.\n */\n pressed?: boolean;\n /** Keep the click (and the Enter/Space that produces it) from reaching an\n * ancestor's handler — for an action inside a clickable table row or card.\n *\n * **Not a licence to put this inside another `<button>`.** HTML forbids any\n * interactive content, and any element with a `tabindex`, inside a button — so\n * the `<span role=\"button\" tabIndex={0}>` workaround is invalid too — and ARIA\n * makes a button's children presentational, so a screen reader flattens the\n * inner one into the outer one's name and it cannot be reached at all. Make the\n * row's main action a button that fills the row and put this one BESIDE it, on\n * top:\n *\n * ```tsx\n * <div className=\"relative\">\n * <button className=\"w-full pe-16 …\" onClick={open}>…row…</button>\n * <div className=\"absolute inset-y-0 end-2 flex items-center gap-0.5\">\n * <IconButton size=\"xs\" tone=\"danger\" aria-label=\"Delete\" onClick={remove}>\n * <Trash2 />\n * </IconButton>\n * </div>\n * </div>\n * ```\n *\n * The pair look exactly like the nested version, click exactly like it, and\n * are two tab stops a screen reader can tell apart. */\n stopPropagation?: boolean;\n /**\n * The button's name, said once: it becomes the `aria-label` AND the text of a kit\n * {@link Tooltip} round the button.\n *\n * Every app writes the pair by hand. lenkbank's nine icon actions all carry\n * `aria-label={t(x)} title={t(x)}` (shared/lib/table-columns.tsx:103,\n * projects-page.tsx:83, setpoint/segment-list.tsx:198/207, profile-bar.tsx:128/142,\n * shortcut-dialog.tsx:815/857/1007) — and `title` is the browser's own tooltip,\n * which shows late, never on focus and never on touch, and looks like no other\n * label in the kit. kastlan's RowAction (shared/components/data-table/row-action.tsx)\n * and keksdose's icon wrappers each exist to put a Tooltip round an IconButton.\n *\n * A caller's own `aria-label` still wins, for the rare name that should be longer\n * than the bubble. The bubble is visual only: it would otherwise describe the button\n * with its own name, and a screen reader would read the same word twice. Tooltip's\n * default placement applies — in place, or portalled inside a scroll container —\n * unless `tooltipPortal` says otherwise.\n */\n label?: string;\n /**\n * Show `label` as visible text beside the icon at Large and Extra large (0.32,\n * docs/text-size-harmonization.md §4, §10.8). A reader who asked for bigger type is\n * the one an unlabelled glyph fails, and on touch the tooltip never opens. Then there\n * is no tooltip: the words are on the button.\n *\n * Default `true` — except for `variant=\"overlay\"` and `\"shutter\"`, which sit ON a\n * picture, and inside {@link CompactControls} (AppShell's top bar, `RowActions`'\n * inline icons). `false`: the icon alone at every size, for an icon on content — a\n * receipt overlay, a map's zoom, a viewfinder, a photo thumbnail. That must be rare;\n * a dense row's actions belong in `RowActions`, which collapses them into a \"⋯\" menu\n * at Large instead (§10.8). Normal never shows it. `tooltip={false}` (a universally\n * read glyph, a dialog's ✕) keeps the icon alone too.\n */\n labelVisible?: boolean;\n /** Show `label` as a tooltip. Default `true`; `false` keeps `label` as the\n * accessible name only — for a button whose glyph is universally read (a close ✕ in\n * a dialog header) or that already sits under a tooltip of its own. It also keeps the\n * label from showing as text at Large (see `labelVisible`). */\n tooltip?: boolean;\n /** Where the `label` tooltip opens. See {@link Tooltip}'s `side`. */\n tooltipSide?: TooltipSide;\n /** Passed to the `label` tooltip's `portal`. Left out, Tooltip decides (see there). */\n tooltipPortal?: boolean;\n /**\n * Mount the `label` bubble only while it is up — {@link Tooltip}'s `lazy`. Default\n * `true` since 0.16.0; `false` is the always-mounted in-place bubble of before.\n *\n * Why the label bubble can be lazy when Tooltip's default is not: this bubble\n * describes nothing. It is wrapped round the button in a fragment precisely so that\n * it is NOT the button's `aria-describedby` (it would repeat the button's own name),\n * so there is no description for a lazy mount to take away — the name is the\n * `aria-label` either way, read in browse mode and on focus alike. What the\n * always-mounted bubble did do was sit in the DOM as a `role=\"tooltip\"` node full of\n * text: every IconButton in a row added one to `getAllByRole(\"tooltip\")` and its label\n * to the row's `textContent`, and two keksdose tests tripped over that (0.15.5 P6).\n * The cost is the one {@link Tooltip}'s \"Lazy in place\" note names — a test that\n * looked the bubble up without hovering now has to hover (or focus) first; pass\n * `false` while it is migrated. A `disabledReason` bubble is unaffected: that one\n * stays mounted as it was.\n */\n tooltipLazy?: boolean;\n /**\n * Busy — the action is in flight: the glyph is swapped for a spinner of the same\n * size, the button is `aria-busy`, and clicks (and the Enter/Space and form\n * submission they stand for) are swallowed, so a second press cannot start the\n * action twice. {@link Button}'s `pending`, with the same contract: `aria-disabled`\n * rather than `disabled`, so the button that was pressed keeps its focus. The name\n * (`label` / `aria-label`) stays — `aria-busy` is what says it is working. A SWAP\n * rather than Button's overlay: the box is square and fixed, so there is no width\n * for the glyph to hold, and a spinner drawn over a glyph would be two marks in one\n * 20px square.\n */\n pending?: boolean;\n /**\n * Why the action is not available — {@link Button}'s `disabledReason`, on the icon\n * button: keksdose's accounts page cannot hide an account with a balance or delete\n * one with bookings (accounts-page:879/913 — \"hide requires zero\", \"delete blocked\"),\n * nor its budgets page delete the only budget (budgets-page:330), and each wrapped a\n * natively disabled IconButton in a Tooltip that swaps its label for the reason — a\n * bubble a keyboard never opens, since `disabled` leaves the tab order, and a reason\n * a screen reader never hears.\n *\n * The same contract as Button's: `aria-disabled` instead of `disabled`, so it stays\n * focusable and hoverable; clicks (and the Enter/Space they stand for) are swallowed\n * — `stopPropagation` still applies, so a locked action in a clickable row does not\n * open the row either; the reason shows in the kit {@link Tooltip} and is attached\n * through `aria-describedby` as a `hidden` copy. The NAME stays `aria-label` /\n * `label` — \"Delete\", described by \"The active budget cannot be deleted\" — and the\n * bubble shows the reason in place of the label, since the glyph already says what\n * the button is and the reason is the news. Shown even with `tooltip={false}`: a\n * reason nobody can see is not one. Wins over `disabled`, as on Button.\n *\n * 0.32: at Large and on a touch screen the reason is a line under the button rather\n * than a bubble — see `disabledReasonDisplay`. 0.55: `refused={reason}` is the same\n * refusal — see `refused`.\n */\n disabledReason?: ReactNode;\n /**\n * Refuse the action and stay where the user can learn why — {@link Button}'s\n * `refused` (0.52 code review 2026-10-31, held to 0.55), on the icon button:\n * `aria-disabled=\"true\"`, focusable, the disabled look, every activation swallowed\n * (`stopPropagation` still applies, so a refused action in a clickable row does not\n * open the row either). Kurvenschmiede's owner-only delete was a Tooltip round a\n * natively disabled IconButton (shared/lib/table-columns.tsx); the Lightbox's arrows\n * at an end write `aria-disabled` and guard their own `onClick`.\n *\n * Words are the reason, the `disabledReason` way: the bubble shows them in place of\n * the label, even with `tooltip={false}`, and they describe the button. `true` refuses\n * with none of its own — the screen says it, and the button's `aria-describedby`\n * names where — and keeps the label's bubble as it was.\n */\n refused?: ReactNode | boolean;\n /** Where `disabledReason` (or a worded `refused`) shows — {@link Button}'s\n * `disabledReasonDisplay`. */\n disabledReasonDisplay?: DisabledReasonDisplay;\n /** This action COMMITS — {@link Button}'s `commit`: under a locked\n * {@link WriteLockProvider} it takes the `disabledReason` path with the lock's\n * reason. No effect without a lock. A {@link CommitScope} since 0.33. */\n commit?: CommitScope;\n /** The `<button>` element — a prop in React 19, as on {@link Button}. */\n ref?: Ref<HTMLButtonElement>;\n /** Only on the link form — see {@link IconButtonLinkProps}. */\n href?: never;\n renderLink?: never;\n external?: never;\n replace?: never;\n reloadDocument?: never;\n}\n\n/**\n * `<IconButton href>`: an icon-only LINK — keksdose F2 and kastlan 45, whose icon\n * actions that navigate (a row's \"open\", a card's \"edit\" that goes to the edit page, a\n * top bar's settings cog) were `<IconButton onClick={() => navigate(…)}>`: a button that\n * changes the page, with no middle click, no \"open in new tab\" and no URL on hover.\n *\n * The same rule as {@link Button}'s `href` (see {@link ButtonLinkProps}): an in-app\n * `href` goes through `renderLink`, else the provider's `linkComponent`; an external one\n * or an in-page `#anchor` stays a plain `<a>`; `external` opens a new tab and says so to\n * a screen reader; `replace` and `reloadDocument` as on Button. The look, the `label` →\n * `aria-label` + Tooltip, the badge, `toneColor` and `stopPropagation` are the button's.\n *\n * `disabled` renders an inert `<a>` with no `href` — `role=\"link\"`, `aria-disabled`, out\n * of the tab order, dimmed — like Button's disabled link. `pressed`, `disabledReason`,\n * `refused` and `disabledStyle` are a button's and are typed out: a link is not a toggle,\n * and a link that explains why it cannot be followed is a job for a refused BUTTON.\n */\nexport interface IconButtonLinkProps\n extends\n Omit<AnchorHTMLAttributes<HTMLAnchorElement>, \"type\" | \"href\">,\n Pick<\n IconButtonProps,\n | \"variant\"\n | \"size\"\n | \"glyphSize\"\n | \"badge\"\n | \"stretch\"\n | \"shape\"\n | \"toneColor\"\n | \"tone\"\n | \"quiet\"\n | \"stopPropagation\"\n | \"label\"\n | \"labelVisible\"\n | \"tooltip\"\n | \"tooltipSide\"\n | \"tooltipPortal\"\n | \"tooltipLazy\"\n > {\n href: string;\n /** See {@link ButtonLinkProps.renderLink}. */\n renderLink?: KitLinkComponent;\n /** See {@link ButtonLinkProps.external}. */\n external?: boolean;\n /** See {@link ButtonLinkProps.replace}. */\n replace?: boolean;\n /** See {@link ButtonLinkProps.reloadDocument}. */\n reloadDocument?: boolean;\n /** See {@link ButtonLinkProps.disabled}. */\n disabled?: boolean;\n ref?: Ref<HTMLAnchorElement>;\n type?: never;\n form?: never;\n pressed?: never;\n disabledReason?: never;\n refused?: never;\n disabledReasonDisplay?: never;\n commit?: never;\n disabledStyle?: never;\n pending?: never;\n}\n\n/**\n * Whether an IconButton draws its `label` as text (see `labelVisible`): at Large and\n * above, with a label to draw, not switched off by the caller, the variant (`overlay`,\n * `shutter`), `tooltip={false}` or a {@link CompactControls} region.\n */\nfunction useIconButtonText(\n label: string | undefined,\n labelVisible: boolean | undefined,\n variant: IconButtonVariant,\n tooltip: boolean,\n): boolean {\n const large = useLargeText();\n const compact = useContext(CompactControlsContext);\n if (!large || label === undefined || label === \"\" || !tooltip) return false;\n const visible = labelVisible ?? (!compact && variant !== \"overlay\" && variant !== \"shutter\");\n return visible;\n}\n\n/** The visible label (see `labelVisible`). */\nfunction IconButtonText({ label }: { label: string }) {\n return <span data-slot=\"icon-button-label\">{label}</span>;\n}\n\n/**\n * `toneColor`: one CSS colour, or one per theme.\n *\n * The pair is two custom properties on the element — `--icon-button-tone-light` and\n * `--icon-button-tone-dark` — and a class that points `--icon-button-tone` at one or the\n * other by the kit's `dark` variant (`.dark` on an ancestor, tokens.css), so the colour\n * follows a theme switch in CSS alone, with no re-render and no JS reading the theme.\n * Both values are any CSS colour; a token that already flips (`var(--warning)`) needs no\n * pair.\n */\nexport type IconButtonToneColor = string | { light: string; dark: string };\n\nconst ICON_BUTTON_TONE_PAIR =\n \"[--icon-button-tone:var(--icon-button-tone-light)] dark:[--icon-button-tone:var(--icon-button-tone-dark)]\";\n\n/** The inline custom properties `toneColor` sets over the caller's `style`. */\nfunction toneColorStyle(toneColor: IconButtonToneColor | undefined, style: CSSProperties | undefined) {\n if (toneColor === undefined) return style;\n const vars: Record<string, string> =\n typeof toneColor === \"string\"\n ? { \"--icon-button-tone\": toneColor }\n : { \"--icon-button-tone-light\": toneColor.light, \"--icon-button-tone-dark\": toneColor.dark };\n return { ...style, ...vars } as CSSProperties;\n}\n\n/** Everything but the element: the look both forms of IconButton share. */\nfunction iconButtonLook({\n variant,\n size,\n stretch,\n glyphSize,\n shape,\n tone,\n quiet,\n toneColor,\n withText = false,\n}: {\n variant: IconButtonVariant;\n size: IconButtonSize;\n stretch: boolean | undefined;\n glyphSize: IconButtonGlyphSize | undefined;\n shape: \"square\" | \"round\";\n tone: IconButtonTone;\n quiet: boolean | undefined;\n toneColor: IconButtonToneColor | undefined;\n /** The label is drawn beside the glyph (see `labelVisible`). */\n withText?: boolean;\n}) {\n return cn(\n ICON_BUTTON_BASE,\n withText\n ? cn(\n \"font-medium whitespace-nowrap\",\n stretch ? ICON_BUTTON_TEXT_STRETCH_SIZES[size] : ICON_BUTTON_TEXT_SIZES[size],\n )\n : stretch\n ? ICON_BUTTON_STRETCH_SIZES[size]\n : ICON_BUTTON_SIZES[size],\n // With text the pill is a 48 px target at any size; the square follows its size.\n withText ? TOUCH_TARGET_LARGE : ICON_BUTTON_TOUCH_LARGE[size],\n glyphSize !== undefined && ICON_BUTTON_GLYPH_SIZES[glyphSize],\n // After the size, so the overlay's `rounded-full` beats the small sizes' `rounded`.\n variant === \"overlay\"\n ? ICON_BUTTON_OVERLAY\n : variant === \"shutter\"\n ? ICON_BUTTON_SHUTTER\n : buttonVariantClasses[variant],\n shape === \"round\" && \"rounded-full\",\n ICON_BUTTON_DISABLED_REST[variant],\n iconButtonToneClass(tone, quiet),\n toneColor !== undefined && typeof toneColor !== \"string\" && ICON_BUTTON_TONE_PAIR,\n );\n}\n\n/** The badge beside the control, not in it — see `badge`. The wrapper is only a\n * positioning box: the control inside it keeps its own focus ring and hit area. */\nfunction withIconButtonBadge(control: ReactElement, badge: ReactNode, stretch: boolean | undefined) {\n if (!hasContent(badge)) return control;\n return (\n <span className={cn(\"relative inline-flex shrink-0\", stretch && \"self-stretch\")}>\n {control}\n <span\n aria-hidden\n data-slot=\"icon-button-badge\"\n className=\"pointer-events-none absolute -end-0.5 -bottom-0.5 inline-flex rounded-full bg-surface [&_svg]:size-3\"\n >\n {badge}\n </span>\n </span>\n );\n}\n\n// Declared link-first, as Button is, so `<IconButton href>` picks the link props and\n// `ComponentProps<typeof IconButton>` (the LAST signature) is still the button's.\n/**\n * An icon-only button whose `label` is its name and its tooltip.\n *\n * Destructive actions (0.49 KIT-12): a delete, revoke or erase is NOT an icon repeated in\n * every row of a list — one stray tap away, and a bin per row draws the eye to the one\n * thing nobody should do by accident. Put it in the row's menu, `RowActions` (where a\n * `tone: \"danger\"` action goes by default and opens a `ConfirmDialog` first), or in the\n * selection bar (\"2 selected\" → Delete), and confirm it through `useConfirm`. A single\n * standalone delete — a card's own, a bulk bar's — may be an IconButton\n * `tone=\"danger\"`; it still asks first.\n */\nexport function IconButton(props: IconButtonLinkProps): ReactElement;\nexport function IconButton(props: IconButtonProps): ReactElement;\nexport function IconButton(props: IconButtonProps | IconButtonLinkProps): ReactElement {\n if (props.href !== undefined) return <IconButtonLink {...(props as IconButtonLinkProps)} />;\n return <IconButtonElement {...(props as IconButtonProps)} />;\n}\n\nfunction IconButtonLink({\n href,\n renderLink,\n external,\n replace,\n reloadDocument,\n disabled,\n variant = \"ghost\",\n size = \"md\",\n tone: toneProp,\n quiet,\n stopPropagation,\n stretch,\n shape = \"square\",\n toneColor,\n label,\n labelVisible,\n tooltip = true,\n tooltipSide,\n tooltipPortal,\n tooltipLazy = true,\n glyphSize,\n badge,\n className,\n style,\n target,\n rel,\n onClick,\n onKeyDown,\n children: glyph,\n ref,\n ...rest\n}: IconButtonLinkProps) {\n const withText = useIconButtonText(label, labelVisible, variant, tooltip);\n const children = withText ? (\n <>\n {glyph}\n <IconButtonText label={label!} />\n </>\n ) : (\n glyph\n );\n const kitLink = useKitLink();\n const common = useKitLabels(\"common\", DEFAULT_COMMON_LABELS);\n const tone = toneProp ?? (toneColor !== undefined ? \"custom\" : \"default\");\n // Button's rule, word for word: `reloadDocument` bypasses the router, an external\n // href or an in-page `#anchor` never reaches it.\n const Link = reloadDocument ? undefined : pickLinkRenderer<KitLinkProps>(renderLink, kitLink, href);\n const newTab = !disabled && (external || target === \"_blank\");\n const name = rest[\"aria-label\"] ?? label;\n // \"(opens in a new tab)\" joins the NAME here, not the body: an icon link has no text\n // for an sr-only span to follow, and `aria-label` would override one anyway.\n const ariaLabel = name !== undefined && newTab ? `${name} (${common.opensInNewTab})` : name;\n const handleClick = (e: MouseEvent<HTMLAnchorElement>) => {\n if (stopPropagation) e.stopPropagation();\n onClick?.(e);\n };\n const handleKeyDown = (e: KeyboardEvent<HTMLAnchorElement>) => {\n // A link activates on Enter only; that key still bubbles to a clickable row.\n if (stopPropagation && e.key === \"Enter\") e.stopPropagation();\n onKeyDown?.(e);\n };\n const cls = cn(\n iconButtonLook({ variant, size, stretch, glyphSize, shape, tone, quiet, toneColor, withText }),\n // BASE's `disabled:` classes never match an `<a>` — Button's disabled link look.\n disabled && \"pointer-events-none cursor-not-allowed opacity-50\",\n className,\n );\n const look = { style: toneColorStyle(toneColor, style), className: cls };\n let control: ReactElement;\n if (disabled) {\n control = (\n <a {...rest} {...look} ref={ref} role=\"link\" aria-disabled=\"true\" aria-label={ariaLabel}>\n {children}\n </a>\n );\n } else if (external || !Link) {\n control = (\n <a\n {...rest}\n {...look}\n ref={ref}\n href={href}\n aria-label={ariaLabel}\n target={external ? \"_blank\" : target}\n rel={external ? (rel ?? \"noopener noreferrer\") : rel}\n onClick={external ? handleClick : replacingClick(handleClick, href, replace, target)}\n onKeyDown={handleKeyDown}\n >\n {children}\n </a>\n );\n } else {\n control = (\n <RenderedButtonLink\n render={Link}\n {...rest}\n {...look}\n {...routerLinkNavigation(replace)}\n ref={ref}\n href={href}\n aria-label={ariaLabel}\n target={target}\n rel={rel}\n onClick={handleClick}\n onKeyDown={handleKeyDown}\n >\n {children}\n </RenderedButtonLink>\n );\n }\n control = withIconButtonBadge(control, badge, stretch);\n // The words are on the link at Large: a bubble would only repeat them.\n if (!tooltip || withText || label === undefined || label === \"\") return control;\n return (\n // A fragment, as on the button: the bubble says what `aria-label` already does.\n <Tooltip\n label={label}\n side={tooltipSide}\n portal={tooltipPortal}\n lazy={tooltipLazy}\n className={stretch ? \"self-stretch\" : undefined}\n >\n <>{control}</>\n </Tooltip>\n );\n}\n\nfunction IconButtonElement({\n variant = \"ghost\",\n size = \"md\",\n tone: toneProp,\n quiet,\n pressed,\n stopPropagation,\n stretch,\n shape = \"square\",\n toneColor,\n label,\n labelVisible,\n tooltip = true,\n tooltipSide,\n tooltipPortal,\n tooltipLazy = true,\n refused: refusedProp,\n disabledReason: ownDisabledReason,\n disabledReasonDisplay,\n commit,\n pending,\n glyphSize,\n badge,\n disabledStyle = \"dim\",\n className,\n style,\n onClick,\n onKeyDown,\n children,\n href: _href,\n renderLink: _renderLink,\n external: _external,\n replace: _replace,\n reloadDocument: _reloadDocument,\n ref,\n ...rest\n}: IconButtonProps) {\n const tone = toneProp ?? (toneColor !== undefined ? \"custom\" : \"default\");\n const keep = disabledStyle === \"keep\";\n const withText = useIconButtonText(label, labelVisible, variant, tooltip);\n // `locked`: refused WITH words, which take the bubble; `refused` also covers `true`,\n // which keeps the label's.\n const { reason: disabledReason, locked, reasonId, refused } = useRefusal(refusedProp, ownDisabledReason, commit);\n // Pending is inert like a refused button — focusable, every activation swallowed —\n // without the refused look: the spinner is the state.\n const inert = refused || Boolean(pending);\n const ownDescribedBy = rest[\"aria-describedby\"];\n const button = (\n <button\n ref={ref}\n {...rest}\n disabled={inert ? undefined : rest.disabled}\n aria-disabled={inert || rest[\"aria-disabled\"]}\n aria-busy={pending || rest[\"aria-busy\"]}\n aria-describedby={locked ? (ownDescribedBy ? `${ownDescribedBy} ${reasonId}` : reasonId) : ownDescribedBy}\n aria-label={rest[\"aria-label\"] ?? label}\n aria-pressed={pressed ?? rest[\"aria-pressed\"]}\n style={toneColorStyle(toneColor, style)}\n onClick={(e) => {\n if (stopPropagation) e.stopPropagation();\n // `preventDefault` too: a locked (or pending) submit must not submit its form.\n if (inert) {\n e.preventDefault();\n return;\n }\n onClick?.(e);\n }}\n onKeyDown={(e) => {\n // A button turns Enter/Space into a click of its own, but the KEY still\n // bubbles — and a clickable row listening for Enter would act on it too.\n if (stopPropagation && (e.key === \"Enter\" || e.key === \" \")) e.stopPropagation();\n onKeyDown?.(e);\n }}\n className={cn(\n iconButtonLook({ variant, size, stretch, glyphSize, shape, tone, quiet, toneColor, withText }),\n pressed && ICON_BUTTON_PRESSED,\n // The disabled look for the focusable kind of disabled, as on Button.\n refused && (keep ? \"cursor-default\" : \"cursor-not-allowed opacity-50\"),\n keep && \"disabled:cursor-default disabled:opacity-100\",\n pending && \"cursor-progress\",\n className,\n )}\n >\n {pending ? (\n // Decorative, as on Button: `aria-busy` says it. Sized to the glyph it stands\n // in for, in the glyph's colour, so the swap does not change the button's look.\n <Spinner\n label={null}\n className={cn(\n \"border-current/30 border-t-current\",\n glyphSize !== undefined ? ICON_BUTTON_SPINNER_GLYPH[glyphSize] : ICON_BUTTON_SPINNER[size],\n )}\n />\n ) : (\n children\n )}\n {withText && <IconButtonText label={label!} />}\n </button>\n );\n const control = withIconButtonBadge(button, badge, stretch);\n if (locked) {\n return (\n // Button's shape: the reason as a line under it, or the bubble with a hidden copy\n // for the description (see LockedReason).\n <LockedReason\n lock={{ reason: disabledReason, reasonId }}\n display={disabledReasonDisplay}\n side={tooltipSide}\n portal={tooltipPortal}\n className={stretch ? \"self-stretch\" : undefined}\n >\n {control}\n </LockedReason>\n );\n }\n // The words are on the button at Large: a bubble would only repeat them.\n if (!tooltip || withText || label === undefined || label === \"\") return control;\n return (\n // A fragment, so Tooltip leaves the button's description alone: the bubble says\n // exactly what `aria-label` already does. Lazy by default — see `tooltipLazy`.\n <Tooltip\n label={label}\n side={tooltipSide}\n portal={tooltipPortal}\n lazy={tooltipLazy}\n className={stretch ? \"self-stretch\" : undefined}\n >\n <>{control}</>\n </Tooltip>\n );\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../src/components/icon-button.tsx"],"sourcesContent":["import { useContext } from \"react\";\nimport type {\n AnchorHTMLAttributes,\n ButtonHTMLAttributes,\n CSSProperties,\n KeyboardEvent,\n MouseEvent,\n ReactElement,\n ReactNode,\n Ref,\n} from \"react\";\nimport { cn } from \"../lib/cn\";\nimport { FOCUS_RING_WIDTH } from \"./focus-ring\";\nimport { useLargeText } from \"../hooks/use-large-text\";\nimport { Tooltip, type TooltipSide } from \"./tooltip\";\nimport { DEFAULT_COMMON_LABELS, useKitLabels, useKitLink } from \"../i18n/kit-labels\";\nimport type { KitLinkComponent, KitLinkProps } from \"../i18n/kit-labels\";\nimport { pickLinkRenderer, replacingClick, routerLinkNavigation } from \"./text-link\";\nimport type { CommitScope } from \"./write-lock\";\nimport { hasContent } from \"../lib/presence\";\nimport { CompactControlsContext, LockedReason, useRefusal, type DisabledReasonDisplay } from \"./field-parts\";\nimport { Spinner } from \"./spinner\";\nimport {\n HOVER_INK,\n ICON_BUTTON_PRESSED,\n RenderedButtonLink,\n HIT_AREA_LARGE,\n TOUCH_TARGET_LARGE,\n buttonVariantClasses,\n} from \"./button-look\";\nimport type { ButtonVariant } from \"./button-look\";\n\n// Canonical square icon-only button. <Button> carries TEXT geometry — px-3 py-2\n// gap-2 — so rendering a bare glyph through it gives a small icon in a wide,\n// text-shaped box. This fixes a square box instead and, crucially, forces the\n// child icon to a size set by the BOX (20px at md/sm) via `[&_svg]:size-*` so a\n// caller cannot under-size it. Use it wherever an action is a bare icon\n// (edit/delete/tools) so they all match the top-bar icon buttons and can never\n// drift apart again.\n//\n// Draws its colours from the same `buttonVariantClasses` map as <Button>, so the\n// two re-skin together with the palette.\nconst ICON_BUTTON_BASE = cn(\n \"inline-flex items-center justify-center rounded-md transition-colors focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed\",\n FOCUS_RING_WIDTH,\n);\n\nexport type IconButtonSize = \"2xl\" | \"xl\" | \"lg\" | \"md\" | \"sm\" | \"xs\" | \"2xs\";\n\n// Box and glyph together, so a 24px chip action cannot end up holding a 20px icon\n// that touches its edges. The two small steps are lenkbank's list-row (28px) and\n// chip (24px) actions, which it had hand-rolled beside the kit's 32/36px ones.\nconst ICON_BUTTON_SIZES: Record<IconButtonSize, string> = {\n // 64px with a 28px glyph: a camera's shutter (keksdose invoices/camera-capture.tsx:248,\n // `size-16` by hand) — the one control of a full-screen capture view, thumb-sized.\n \"2xl\": \"size-16 [&_svg]:size-7\",\n // 48px with a 24px glyph: the one primary action of a phone screen or sheet, a step\n // past the 44px minimum rather than on it.\n xl: \"size-12 [&_svg]:size-6\",\n // The 44px touch target (WCAG 2.5.5's size) with the same 20px glyph — keksdose's\n // bulk-action bars write `size-11` by hand over an `md` button to get it on phones.\n lg: \"size-11 [&_svg]:size-5\",\n md: \"size-9 [&_svg]:size-5\",\n sm: \"size-8 [&_svg]:size-5\",\n xs: \"size-7 rounded [&_svg]:size-4\",\n \"2xs\": \"size-6 rounded [&_svg]:size-3.5\",\n};\n\n// `stretch`: the same WIDTH and glyph, and a height taken from the row instead of\n// stated — `size-*` sets a height that `self-stretch` cannot override, so the box is\n// written as a width plus a minimum height (the square it would otherwise be).\nconst ICON_BUTTON_STRETCH_SIZES: Record<IconButtonSize, string> = {\n \"2xl\": \"w-16 min-h-16 self-stretch [&_svg]:size-7\",\n xl: \"w-12 min-h-12 self-stretch [&_svg]:size-6\",\n lg: \"w-11 min-h-11 self-stretch [&_svg]:size-5\",\n md: \"w-9 min-h-9 self-stretch [&_svg]:size-5\",\n sm: \"w-8 min-h-8 self-stretch [&_svg]:size-5\",\n xs: \"w-7 min-h-7 self-stretch rounded [&_svg]:size-4\",\n \"2xs\": \"w-6 min-h-6 self-stretch rounded [&_svg]:size-3.5\",\n};\n\n// The label as visible text beside the glyph (0.32, §4: at Large \"IconButton shows its\n// label\"): the same height and glyph as the square, a width taken from the words. A\n// map of its own rather than `w-auto` over `size-*`, which tailwind-merge cannot undo\n// in one direction.\nconst ICON_BUTTON_TEXT_SIZES: Record<IconButtonSize, string> = {\n \"2xl\": \"h-16 gap-2 px-4 text-base [&_svg]:size-7\",\n xl: \"h-12 gap-2 px-3.5 text-sm [&_svg]:size-6\",\n lg: \"h-11 gap-2 px-3 text-sm [&_svg]:size-5\",\n md: \"h-9 gap-1.5 px-2.5 text-sm [&_svg]:size-5\",\n sm: \"h-8 gap-1.5 px-2 text-sm [&_svg]:size-5\",\n xs: \"h-7 gap-1 rounded px-1.5 text-xs [&_svg]:size-4\",\n \"2xs\": \"h-6 gap-1 rounded px-1.5 text-xs [&_svg]:size-3.5\",\n};\nconst ICON_BUTTON_TEXT_STRETCH_SIZES: Record<IconButtonSize, string> = {\n \"2xl\": \"min-h-16 self-stretch gap-2 px-4 text-base [&_svg]:size-7\",\n xl: \"min-h-12 self-stretch gap-2 px-3.5 text-sm [&_svg]:size-6\",\n lg: \"min-h-11 self-stretch gap-2 px-3 text-sm [&_svg]:size-5\",\n md: \"min-h-9 self-stretch gap-1.5 px-2.5 text-sm [&_svg]:size-5\",\n sm: \"min-h-8 self-stretch gap-1.5 px-2 text-sm [&_svg]:size-5\",\n xs: \"min-h-7 self-stretch gap-1 rounded px-1.5 text-xs [&_svg]:size-4\",\n \"2xs\": \"min-h-6 self-stretch gap-1 rounded px-1.5 text-xs [&_svg]:size-3.5\",\n};\n\n// The 48 px touch target at Large (§4), per size. `lg` and up are past it already\n// (2.75rem is 55 px at Large). `md` … `xs` grow their box to it. `2xs` is the action ON\n// a chip or a tab, whose box cannot grow without growing the chip: it keeps its box and\n// gets a 48 px hit area instead — an invisible `::after` centred on it, which takes the\n// taps a finger lands round the glyph (`HIT_AREA_LARGE`).\nconst ICON_BUTTON_TOUCH_LARGE: Record<IconButtonSize, string> = {\n \"2xl\": \"\",\n xl: \"\",\n lg: \"\",\n md: TOUCH_TARGET_LARGE,\n sm: TOUCH_TARGET_LARGE,\n xs: TOUCH_TARGET_LARGE,\n \"2xs\": HIT_AREA_LARGE,\n};\n\n// A tone re-colours the glyph without changing what the variant draws around it.\n// Each coloured tone comes in two resting looks, picked by `quiet` (see the prop):\n// QUIET is placeholder grey until the pointer or focus arrives, then the tone's\n// family; TONED wears the tone's colour at rest. The defaults are the looks each tone\n// had before `quiet` existed: `danger` is quiet — an action in every row of a list\n// must not shout from every row, so the red arrives only on the one row you are about\n// to act on — while `warning` and `info` are toned, because they mark the ONE thing\n// on the row to notice (keksdose's \"needs review\" flag, its reconcile action at\n// accounts-page:867), and quiet grey would hide the very thing they point out.\n// `muted` is quiet by definition and has no toned look; `default` has no tone.\n//\n// `success` is toned at rest too: the green is the news (keksdose's \"in sync\" chip).\n// `custom` paints from `--icon-button-tone`, which `toneColor` sets — for a glyph whose\n// colour FOLLOWS STATE across tones (keksdose's sync chip: rose, amber, sky, green as\n// the sync moves), where swapping `tone` would do but the colour is one variable.\nexport type IconButtonTone = \"default\" | \"muted\" | \"danger\" | \"warning\" | \"info\" | \"success\" | \"custom\";\n\ntype ColouredTone = \"danger\" | \"warning\" | \"info\" | \"success\" | \"custom\";\n\nconst ICON_BUTTON_TONES: Record<ColouredTone, { quiet: string; toned: string }> = {\n danger: {\n quiet: \"text-placeholder hover:bg-danger-soft hover:text-danger focus-visible:ring-danger-strong\",\n toned: \"text-danger hover:bg-danger-soft focus-visible:ring-danger-strong\",\n },\n warning: {\n quiet: \"text-placeholder hover:bg-warning-soft hover:text-warning focus-visible:ring-warning-strong\",\n // The ring is warning's loud line (0.33, k27): `--warning-border`, amber-300, was a\n // focus ring at 1.1:1 on the light surfaces.\n toned: \"text-warning hover:bg-warning-soft focus-visible:ring-warning-strong\",\n },\n // The ring is the tone's own text colour (0.52, refactor audit 2026-10-30, DSP-6):\n // `--info-border` and `--success-border` rang at 1.2–1.4:1 on the light grounds and\n // about 2:1 in dark, and More contrast re-solves neither — while `--info` / `--success`\n // clear 4.5:1 and are re-solved there. The sync chip wears these two on every page.\n info: {\n quiet: \"text-placeholder hover:bg-info-soft hover:text-info focus-visible:ring-[var(--info)]\",\n toned: \"text-info hover:bg-info-soft focus-visible:ring-[var(--info)]\",\n },\n success: {\n quiet: \"text-placeholder hover:bg-success-soft hover:text-success focus-visible:ring-[var(--success)]\",\n toned: \"text-success hover:bg-success-soft focus-visible:ring-[var(--success)]\",\n },\n // The hover fill is MIXED from the one colour, since a custom tone brings no `-bg` of\n // its own; the ring is the colour at full strength (0.52, DSP-6: at 40 % it was a ring\n // nobody could see). The fallback is the body colour, so a `custom` tone with no colour\n // set is the default look rather than invisible.\n custom: {\n quiet:\n \"text-placeholder hover:bg-[color-mix(in_srgb,var(--icon-button-tone,var(--text-primary))_12%,transparent)] hover:text-[var(--icon-button-tone,var(--text-primary))] focus-visible:ring-[var(--icon-button-tone,var(--text-primary))]\",\n toned:\n \"text-[var(--icon-button-tone,var(--text-primary))] hover:bg-[color-mix(in_srgb,var(--icon-button-tone,var(--text-primary))_12%,transparent)] focus-visible:ring-[var(--icon-button-tone,var(--text-primary))]\",\n },\n};\n\n// The translucent ink on hover, as Button's secondary/ghost (B′, see HOVER_INK).\nconst ICON_BUTTON_MUTED = `text-placeholder ${HOVER_INK} hover:text-primary`;\n\n/** Which tones sit quiet at rest when `quiet` is left out. */\nconst QUIET_BY_DEFAULT: Record<ColouredTone, boolean> = {\n danger: true,\n warning: false,\n info: false,\n success: false,\n custom: false,\n};\n\nfunction iconButtonToneClass(tone: IconButtonTone, quiet: boolean | undefined): string {\n if (tone === \"default\") return \"\";\n if (tone === \"muted\") return cn(ICON_BUTTON_MUTED, ICON_BUTTON_QUIET_DISABLED_REST);\n const isQuiet = quiet ?? QUIET_BY_DEFAULT[tone];\n const look = ICON_BUTTON_TONES[tone];\n return isQuiet ? cn(look.quiet, ICON_BUTTON_QUIET_DISABLED_REST) : look.toned;\n}\n\n// A disabled button must not answer the pointer. The hover classes above are plain\n// `hover:` (so a caller's `className=\"hover:…\"` still replaces them through\n// tailwind-merge), which means they fire on a disabled button too — the old grey\n// icon lit up on hover while refusing the click. Rather than rewrite every hover as\n// `enabled:hover:` (which never matches the `<a>` that `buttonClasses` also styles,\n// and would out-rank a caller's plain `hover:` override), each variant pins its\n// RESTING look under `disabled:hover:`, which out-ranks any `hover:` by specificity\n// and only ever matches a disabled button.\nconst ICON_BUTTON_DISABLED_REST: Record<ButtonVariant | \"overlay\" | \"shutter\", string> = {\n default: \"disabled:hover:bg-surface-2\",\n secondary: \"disabled:hover:bg-transparent\",\n ghost: \"disabled:hover:bg-transparent\",\n danger: \"disabled:hover:bg-danger\",\n accent: \"disabled:hover:bg-brand\",\n link: \"disabled:hover:bg-transparent disabled:hover:no-underline\",\n overlay: \"disabled:hover:bg-[color-mix(in_srgb,var(--bg-inverse)_60%,transparent)]\",\n shutter: \"disabled:hover:bg-media-scrim\",\n};\n\n// The quiet looks change the glyph on hover, so they pin their resting glyph the\n// same way. A toned look keeps its glyph on hover and needs no pin.\nconst ICON_BUTTON_QUIET_DISABLED_REST = \"disabled:hover:text-placeholder\";\n\n// `variant=\"overlay\"`: a round, translucent disc for a control that sits ON a photo\n// (keksdose's receipt-scan preview: close, rotate, retake over the camera image).\n// The inverse pair, not the surface one: what has to hold is the contrast between the\n// disc and its glyph, whatever the picture underneath is, and `--bg-inverse` /\n// `--text-inverse` are the one token pair defined as each other's opposite in both\n// themes. The disc is the inverse at 60% (`color-mix`, so it stays a token a palette\n// can re-point); the blur keeps a busy background from breaking the glyph's edge.\nconst ICON_BUTTON_OVERLAY =\n \"rounded-full bg-[color-mix(in_srgb,var(--bg-inverse)_60%,transparent)] text-inverse backdrop-blur-sm hover:bg-[color-mix(in_srgb,var(--bg-inverse)_75%,transparent)] focus-visible:ring-[var(--text-inverse)]\";\n\n// `variant=\"shutter\"`: the camera's release — keksdose's camera-capture.tsx:248 draws\n// it by hand as a 64px circle with a thick white ring round a translucent fill. It sits\n// on a live camera picture, which is dark whatever the theme, so it uses the MEDIA\n// tokens (tokens.css), white ink on a dark scrim in both themes — the inverse pair\n// `overlay` uses would give a dark ring on a dark viewfinder in dark mode. The focus ring stands off by 2px, or it would\n// merge into the ring that is part of the look.\nconst ICON_BUTTON_SHUTTER =\n \"rounded-full border-4 border-media-ink bg-media-scrim text-media-ink backdrop-blur-sm hover:bg-media-scrim-hover focus-visible:ring-media-ink focus-visible:ring-offset-2 focus-visible:ring-offset-media-scrim-hover\";\n\nexport type IconButtonVariant = ButtonVariant | \"overlay\" | \"shutter\";\n\n/**\n * The glyph's size in px, over the one the box size implies — see\n * {@link IconButtonProps.glyphSize}.\n */\nexport type IconButtonGlyphSize = 12 | 14 | 16 | 20 | 24 | 28;\n\nconst ICON_BUTTON_GLYPH_SIZES: Record<IconButtonGlyphSize, string> = {\n 12: \"[&_svg]:size-3\",\n 14: \"[&_svg]:size-3.5\",\n 16: \"[&_svg]:size-4\",\n 20: \"[&_svg]:size-5\",\n 24: \"[&_svg]:size-6\",\n 28: \"[&_svg]:size-7\",\n};\n\n// `pending`'s spinner, the size of the glyph it replaces (the `[&_svg]` rules above\n// size only an svg, and the spinner is a bordered span).\nconst ICON_BUTTON_SPINNER: Record<IconButtonSize, string> = {\n \"2xl\": \"size-7\",\n xl: \"size-6\",\n lg: \"size-5\",\n md: \"size-5\",\n sm: \"size-5\",\n xs: \"size-4\",\n \"2xs\": \"size-3.5\",\n};\nconst ICON_BUTTON_SPINNER_GLYPH: Record<IconButtonGlyphSize, string> = {\n 12: \"size-3\",\n 14: \"size-3.5\",\n 16: \"size-4\",\n 20: \"size-5\",\n 24: \"size-6\",\n 28: \"size-7\",\n};\n\nexport interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {\n /** Any {@link ButtonVariant}; `overlay` — a round translucent disc for use over\n * an image (see `ICON_BUTTON_OVERLAY`); or `shutter` — a camera's release, a ringed\n * disc over the viewfinder (see `ICON_BUTTON_SHUTTER`; pair it with `size=\"2xl\"`). */\n variant?: IconButtonVariant;\n /** Box size: 2xl = 64px with a 28px icon (a camera shutter), xl = 48px with a 24px\n * icon, lg = 44px (a phone's touch target), md = 36px (matches the top bar),\n * sm = 32px — all three with a 20px icon;\n * xs = 28px with a 16px icon (an action in a list row), 2xs = 24px with a 14px\n * icon (an action on a chip or a tab). `glyphSize` overrides the icon. */\n size?: IconButtonSize;\n /**\n * The icon's size in px, when the box's own is not the one wanted. keksdose's sync\n * chip (app/sync-status-indicator.tsx:142) is a 24px round chip round a 20px cloud —\n * `size=\"2xs\" glyphSize={20} shape=\"round\"` — where `2xs` alone draws 14px. A number\n * over a new size step because the box and the glyph are two independent choices; a\n * step per combination (`\"sm-lg\"`) would have to be named for every one.\n */\n glyphSize?: IconButtonGlyphSize;\n /**\n * A small mark on the button's corner — the sync chip's 12px check over its cloud\n * (\"all good\", feedback #67). Rendered OUTSIDE the `<button>`, in a wrapper beside\n * it, so the box's `[&_svg]:size-*` rule that sizes the glyph does not blow the badge\n * up to the glyph's size; a bare `<Check />` comes out 12px, on a\n * `--bg-surface` disc that keeps it legible over the glyph. Decorative\n * (`aria-hidden`): the name (`label` / `aria-label`) must already carry the state.\n * With a badge, `className` still lands on the button; the wrapper is the flex item.\n */\n badge?: ReactNode;\n /**\n * `keep`: a disabled button keeps its full colour (no 50% drop) and an ordinary\n * cursor. For a STATUS that is also an action — the sync chip shows \"in sync\" in\n * green and refreshes on click, and is disabled while offline or syncing, when the\n * state it shows matters most; dimmed it read as broken (keksdose wrote\n * `disabled:cursor-default` by hand and left out the dimming). Default `dim`.\n */\n disabledStyle?: \"dim\" | \"keep\";\n /**\n * Fill the height of the flex row it stands in, keeping its width — the delete at\n * the end of keksdose's split line (transaction-fields:162), level with the labelled\n * fields beside it rather than floating at half their height. {@link Button}'s\n * `stretch`; no effect outside a flex row. With a `label`, the tooltip's wrapper is\n * what stretches.\n */\n stretch?: boolean;\n /** `round`: a circle instead of the rounded square — a compact status chip in a top\n * bar (keksdose's sync indicator). `overlay` is always round. */\n shape?: \"square\" | \"round\";\n /**\n * The colour of `tone=\"custom\"`, as any CSS colour — a token (`var(--warning)`),\n * ideally. Sets `--icon-button-tone` on the button, so passing a different one per\n * state re-colours the glyph, its hover fill and its focus ring together. Passing it\n * implies `tone=\"custom\"`. Or leave it out and set the variable yourself from a class\n * (`className=\"[--icon-button-tone:var(--success)]\"`).\n *\n * Or a `{ light, dark }` pair, one colour per theme — keksdose: a raw colour that reads\n * on the light surface (`#be123c`) is too dark on the dark one, and an inline style\n * cannot say \"in dark mode\". See {@link IconButtonToneColor}.\n */\n toneColor?: IconButtonToneColor;\n /** Glyph colour over the variant. `muted`: placeholder grey, full text colour on\n * hover. `danger`: the same grey at rest, `--danger` on hover and focus — for a\n * remove/delete that repeats down a list. `warning`: amber at rest — a flag that\n * wants attention. `info`: sky at rest — a notice-worthy but harmless action\n * (keksdose's reconcile). `success`: green at rest — a good state worth seeing.\n * `custom`: the colour of `toneColor` (see there). Default: the variant's own colours. See `quiet` for\n * turning a coloured tone's resting look the other way. */\n tone?: IconButtonTone;\n /**\n * Whether a coloured tone (`danger`, `warning`, `info`) waits for the pointer:\n * `true` is placeholder grey at rest and the tone's colour on hover and focus;\n * `false` wears the tone's colour at rest. Left out, each tone keeps its own\n * default — `danger` quiet, `warning` and `info` not. Ignored for `muted` (quiet by\n * definition) and `default` (no tone).\n *\n * `quiet={false}` on `danger` is for a destructive action that stands ALONE, where\n * hover-to-reveal hides it: keksdose's phone bulk bar (mobile-bulk-bar.tsx) has one\n * \"delete all\" and a touch screen that never hovers, so it painted rose by hand.\n * `quiet` on `warning`/`info` is the same switch the other way, for a flag repeated\n * down a list. A boolean over the tone rather than a new tone (`danger-solid`) or an\n * `emphasis` scale: there are exactly two resting looks, every coloured tone has\n * both, and which one fits is a question about the SITE (alone or repeated, touch or\n * pointer), not about the tone — so it is one switch the family shares.\n */\n quiet?: boolean;\n /**\n * Make it a toggle button. `true` sets `aria-pressed=\"true\"` and draws the \"on\" look\n * (brand glyph on the quiet brand fill); `false` sets `aria-pressed=\"false\"` with the\n * normal look, so a screen reader still hears a toggle that is off. Left out, it is\n * an ordinary button with no `aria-pressed` — or whatever `aria-pressed` the caller\n * passes. For keksdose's budget share toggle (budgets-page:291), which paints its own\n * brand colour over a ghost button today. Keep the `aria-label` the same in both\n * states (\"Share budget\"): the pressed state already says whether it is on.\n */\n pressed?: boolean;\n /** Keep the click (and the Enter/Space that produces it) from reaching an\n * ancestor's handler — for an action inside a clickable table row or card.\n *\n * **Not a licence to put this inside another `<button>`.** HTML forbids any\n * interactive content, and any element with a `tabindex`, inside a button — so\n * the `<span role=\"button\" tabIndex={0}>` workaround is invalid too — and ARIA\n * makes a button's children presentational, so a screen reader flattens the\n * inner one into the outer one's name and it cannot be reached at all. Make the\n * row's main action a button that fills the row and put this one BESIDE it, on\n * top:\n *\n * ```tsx\n * <div className=\"relative\">\n * <button className=\"w-full pe-16 …\" onClick={open}>…row…</button>\n * <div className=\"absolute inset-y-0 end-2 flex items-center gap-0.5\">\n * <IconButton size=\"xs\" tone=\"danger\" aria-label=\"Delete\" onClick={remove}>\n * <Trash2 />\n * </IconButton>\n * </div>\n * </div>\n * ```\n *\n * The pair look exactly like the nested version, click exactly like it, and\n * are two tab stops a screen reader can tell apart. */\n stopPropagation?: boolean;\n /**\n * The button's name, said once: it becomes the `aria-label` AND the text of a kit\n * {@link Tooltip} round the button.\n *\n * Every app writes the pair by hand. lenkbank's nine icon actions all carry\n * `aria-label={t(x)} title={t(x)}` (shared/lib/table-columns.tsx:103,\n * projects-page.tsx:83, setpoint/segment-list.tsx:198/207, profile-bar.tsx:128/142,\n * shortcut-dialog.tsx:815/857/1007) — and `title` is the browser's own tooltip,\n * which shows late, never on focus and never on touch, and looks like no other\n * label in the kit. kastlan's RowAction (shared/components/data-table/row-action.tsx)\n * and keksdose's icon wrappers each exist to put a Tooltip round an IconButton.\n *\n * A caller's own `aria-label` still wins, for the rare name that should be longer\n * than the bubble. The bubble is visual only: it would otherwise describe the button\n * with its own name, and a screen reader would read the same word twice. Tooltip's\n * default placement applies — in place, or portalled inside a scroll container —\n * unless `tooltipPortal` says otherwise.\n */\n label?: string;\n /**\n * Show `label` as visible text beside the icon at Large and Extra large (0.32,\n * docs/text-size-harmonization.md §4, §10.8). A reader who asked for bigger type is\n * the one an unlabelled glyph fails, and on touch the tooltip never opens. Then there\n * is no tooltip: the words are on the button.\n *\n * Default `true` — except for `variant=\"overlay\"` and `\"shutter\"`, which sit ON a\n * picture, and inside {@link CompactControls} (AppShell's top bar, `RowActions`'\n * inline icons). `false`: the icon alone at every size, for an icon on content — a\n * receipt overlay, a map's zoom, a viewfinder, a photo thumbnail. That must be rare;\n * a dense row's actions belong in `RowActions`, which collapses them into a \"⋯\" menu\n * at Large instead (§10.8). Normal never shows it. `tooltip={false}` (a universally\n * read glyph, a dialog's ✕) keeps the icon alone too.\n */\n labelVisible?: boolean;\n /** Show `label` as a tooltip. Default `true`; `false` keeps `label` as the\n * accessible name only — for a button whose glyph is universally read (a close ✕ in\n * a dialog header) or that already sits under a tooltip of its own. It also keeps the\n * label from showing as text at Large (see `labelVisible`). */\n tooltip?: boolean;\n /** Where the `label` tooltip opens. See {@link Tooltip}'s `side`. */\n tooltipSide?: TooltipSide;\n /** Passed to the `label` tooltip's `portal`. Left out, Tooltip decides (see there). */\n tooltipPortal?: boolean;\n /**\n * Mount the `label` bubble only while it is up — {@link Tooltip}'s `lazy`. Default\n * `true` since 0.16.0; `false` is the always-mounted in-place bubble of before.\n *\n * Why the label bubble can be lazy when Tooltip's default is not: this bubble\n * describes nothing. It is wrapped round the button in a fragment precisely so that\n * it is NOT the button's `aria-describedby` (it would repeat the button's own name),\n * so there is no description for a lazy mount to take away — the name is the\n * `aria-label` either way, read in browse mode and on focus alike. What the\n * always-mounted bubble did do was sit in the DOM as a `role=\"tooltip\"` node full of\n * text: every IconButton in a row added one to `getAllByRole(\"tooltip\")` and its label\n * to the row's `textContent`, and two keksdose tests tripped over that (0.15.5 P6).\n * The cost is the one {@link Tooltip}'s \"Lazy in place\" note names — a test that\n * looked the bubble up without hovering now has to hover (or focus) first; pass\n * `false` while it is migrated. A `disabledReason` bubble is unaffected: that one\n * stays mounted as it was.\n */\n tooltipLazy?: boolean;\n /**\n * Busy — the action is in flight: the glyph is swapped for a spinner of the same\n * size, the button is `aria-busy`, and clicks (and the Enter/Space and form\n * submission they stand for) are swallowed, so a second press cannot start the\n * action twice. {@link Button}'s `pending`, with the same contract: `aria-disabled`\n * rather than `disabled`, so the button that was pressed keeps its focus. The name\n * (`label` / `aria-label`) stays — `aria-busy` is what says it is working. A SWAP\n * rather than Button's overlay: the box is square and fixed, so there is no width\n * for the glyph to hold, and a spinner drawn over a glyph would be two marks in one\n * 20px square.\n */\n pending?: boolean;\n /**\n * Why the action is not available — {@link Button}'s `disabledReason`, on the icon\n * button: keksdose's accounts page cannot hide an account with a balance or delete\n * one with bookings (accounts-page:879/913 — \"hide requires zero\", \"delete blocked\"),\n * nor its budgets page delete the only budget (budgets-page:330), and each wrapped a\n * natively disabled IconButton in a Tooltip that swaps its label for the reason — a\n * bubble a keyboard never opens, since `disabled` leaves the tab order, and a reason\n * a screen reader never hears.\n *\n * The same contract as Button's: `aria-disabled` instead of `disabled`, so it stays\n * focusable and hoverable; clicks (and the Enter/Space they stand for) are swallowed\n * — `stopPropagation` still applies, so a locked action in a clickable row does not\n * open the row either; the reason shows in the kit {@link Tooltip} and is attached\n * through `aria-describedby` as a `hidden` copy. The NAME stays `aria-label` /\n * `label` — \"Delete\", described by \"The active budget cannot be deleted\" — and the\n * bubble shows the reason in place of the label, since the glyph already says what\n * the button is and the reason is the news. Shown even with `tooltip={false}`: a\n * reason nobody can see is not one. Wins over `disabled`, as on Button.\n *\n * 0.32: at Large and on a touch screen the reason is a line under the button rather\n * than a bubble — see `disabledReasonDisplay`. 0.55: `refused={reason}` is the same\n * refusal — see `refused`.\n */\n disabledReason?: ReactNode;\n /**\n * Refuse the action and stay where the user can learn why — {@link Button}'s\n * `refused` (0.52 code review 2026-10-31, held to 0.55), on the icon button:\n * `aria-disabled=\"true\"`, focusable, the disabled look, every activation swallowed and\n * kept from a clickable row around it, as a natively disabled button's (0.55.1; no\n * `stopPropagation` needed). Kurvenschmiede's owner-only delete was a Tooltip round a\n * natively disabled IconButton (shared/lib/table-columns.tsx); the Lightbox's arrows\n * at an end write `aria-disabled` and guard their own `onClick`.\n *\n * Words are the reason, the `disabledReason` way: the bubble shows them in place of\n * the label, even with `tooltip={false}`, and they describe the button. `true` refuses\n * with none of its own — the screen says it, and the button's `aria-describedby`\n * names where — and keeps the label's bubble as it was.\n */\n refused?: ReactNode | boolean;\n /** Where `disabledReason` (or a worded `refused`) shows — {@link Button}'s\n * `disabledReasonDisplay`. */\n disabledReasonDisplay?: DisabledReasonDisplay;\n /** This action COMMITS — {@link Button}'s `commit`: under a locked\n * {@link WriteLockProvider} it takes the `disabledReason` path with the lock's\n * reason. No effect without a lock. A {@link CommitScope} since 0.33. */\n commit?: CommitScope;\n /** The `<button>` element — a prop in React 19, as on {@link Button}. */\n ref?: Ref<HTMLButtonElement>;\n /** Only on the link form — see {@link IconButtonLinkProps}. */\n href?: never;\n renderLink?: never;\n external?: never;\n replace?: never;\n reloadDocument?: never;\n}\n\n/**\n * `<IconButton href>`: an icon-only LINK — keksdose F2 and kastlan 45, whose icon\n * actions that navigate (a row's \"open\", a card's \"edit\" that goes to the edit page, a\n * top bar's settings cog) were `<IconButton onClick={() => navigate(…)}>`: a button that\n * changes the page, with no middle click, no \"open in new tab\" and no URL on hover.\n *\n * The same rule as {@link Button}'s `href` (see {@link ButtonLinkProps}): an in-app\n * `href` goes through `renderLink`, else the provider's `linkComponent`; an external one\n * or an in-page `#anchor` stays a plain `<a>`; `external` opens a new tab and says so to\n * a screen reader; `replace` and `reloadDocument` as on Button. The look, the `label` →\n * `aria-label` + Tooltip, the badge, `toneColor` and `stopPropagation` are the button's.\n *\n * `disabled` renders an inert `<a>` with no `href` — `role=\"link\"`, `aria-disabled`, out\n * of the tab order, dimmed — like Button's disabled link. `pressed`, `disabledReason`,\n * `refused` and `disabledStyle` are a button's and are typed out: a link is not a toggle,\n * and a link that explains why it cannot be followed is a job for a refused BUTTON.\n */\nexport interface IconButtonLinkProps\n extends\n Omit<AnchorHTMLAttributes<HTMLAnchorElement>, \"type\" | \"href\">,\n Pick<\n IconButtonProps,\n | \"variant\"\n | \"size\"\n | \"glyphSize\"\n | \"badge\"\n | \"stretch\"\n | \"shape\"\n | \"toneColor\"\n | \"tone\"\n | \"quiet\"\n | \"stopPropagation\"\n | \"label\"\n | \"labelVisible\"\n | \"tooltip\"\n | \"tooltipSide\"\n | \"tooltipPortal\"\n | \"tooltipLazy\"\n > {\n href: string;\n /** See {@link ButtonLinkProps.renderLink}. */\n renderLink?: KitLinkComponent;\n /** See {@link ButtonLinkProps.external}. */\n external?: boolean;\n /** See {@link ButtonLinkProps.replace}. */\n replace?: boolean;\n /** See {@link ButtonLinkProps.reloadDocument}. */\n reloadDocument?: boolean;\n /** See {@link ButtonLinkProps.disabled}. */\n disabled?: boolean;\n ref?: Ref<HTMLAnchorElement>;\n type?: never;\n form?: never;\n pressed?: never;\n disabledReason?: never;\n refused?: never;\n disabledReasonDisplay?: never;\n commit?: never;\n disabledStyle?: never;\n pending?: never;\n}\n\n/**\n * Whether an IconButton draws its `label` as text (see `labelVisible`): at Large and\n * above, with a label to draw, not switched off by the caller, the variant (`overlay`,\n * `shutter`), `tooltip={false}` or a {@link CompactControls} region.\n */\nfunction useIconButtonText(\n label: string | undefined,\n labelVisible: boolean | undefined,\n variant: IconButtonVariant,\n tooltip: boolean,\n): boolean {\n const large = useLargeText();\n const compact = useContext(CompactControlsContext);\n if (!large || label === undefined || label === \"\" || !tooltip) return false;\n const visible = labelVisible ?? (!compact && variant !== \"overlay\" && variant !== \"shutter\");\n return visible;\n}\n\n/** The visible label (see `labelVisible`). */\nfunction IconButtonText({ label }: { label: string }) {\n return <span data-slot=\"icon-button-label\">{label}</span>;\n}\n\n/**\n * `toneColor`: one CSS colour, or one per theme.\n *\n * The pair is two custom properties on the element — `--icon-button-tone-light` and\n * `--icon-button-tone-dark` — and a class that points `--icon-button-tone` at one or the\n * other by the kit's `dark` variant (`.dark` on an ancestor, tokens.css), so the colour\n * follows a theme switch in CSS alone, with no re-render and no JS reading the theme.\n * Both values are any CSS colour; a token that already flips (`var(--warning)`) needs no\n * pair.\n */\nexport type IconButtonToneColor = string | { light: string; dark: string };\n\nconst ICON_BUTTON_TONE_PAIR =\n \"[--icon-button-tone:var(--icon-button-tone-light)] dark:[--icon-button-tone:var(--icon-button-tone-dark)]\";\n\n/** The inline custom properties `toneColor` sets over the caller's `style`. */\nfunction toneColorStyle(toneColor: IconButtonToneColor | undefined, style: CSSProperties | undefined) {\n if (toneColor === undefined) return style;\n const vars: Record<string, string> =\n typeof toneColor === \"string\"\n ? { \"--icon-button-tone\": toneColor }\n : { \"--icon-button-tone-light\": toneColor.light, \"--icon-button-tone-dark\": toneColor.dark };\n return { ...style, ...vars } as CSSProperties;\n}\n\n/** Everything but the element: the look both forms of IconButton share. */\nfunction iconButtonLook({\n variant,\n size,\n stretch,\n glyphSize,\n shape,\n tone,\n quiet,\n toneColor,\n withText = false,\n}: {\n variant: IconButtonVariant;\n size: IconButtonSize;\n stretch: boolean | undefined;\n glyphSize: IconButtonGlyphSize | undefined;\n shape: \"square\" | \"round\";\n tone: IconButtonTone;\n quiet: boolean | undefined;\n toneColor: IconButtonToneColor | undefined;\n /** The label is drawn beside the glyph (see `labelVisible`). */\n withText?: boolean;\n}) {\n return cn(\n ICON_BUTTON_BASE,\n withText\n ? cn(\n \"font-medium whitespace-nowrap\",\n stretch ? ICON_BUTTON_TEXT_STRETCH_SIZES[size] : ICON_BUTTON_TEXT_SIZES[size],\n )\n : stretch\n ? ICON_BUTTON_STRETCH_SIZES[size]\n : ICON_BUTTON_SIZES[size],\n // With text the pill is a 48 px target at any size; the square follows its size.\n withText ? TOUCH_TARGET_LARGE : ICON_BUTTON_TOUCH_LARGE[size],\n glyphSize !== undefined && ICON_BUTTON_GLYPH_SIZES[glyphSize],\n // After the size, so the overlay's `rounded-full` beats the small sizes' `rounded`.\n variant === \"overlay\"\n ? ICON_BUTTON_OVERLAY\n : variant === \"shutter\"\n ? ICON_BUTTON_SHUTTER\n : buttonVariantClasses[variant],\n shape === \"round\" && \"rounded-full\",\n ICON_BUTTON_DISABLED_REST[variant],\n iconButtonToneClass(tone, quiet),\n toneColor !== undefined && typeof toneColor !== \"string\" && ICON_BUTTON_TONE_PAIR,\n );\n}\n\n/** The badge beside the control, not in it — see `badge`. The wrapper is only a\n * positioning box: the control inside it keeps its own focus ring and hit area. */\nfunction withIconButtonBadge(control: ReactElement, badge: ReactNode, stretch: boolean | undefined) {\n if (!hasContent(badge)) return control;\n return (\n <span className={cn(\"relative inline-flex shrink-0\", stretch && \"self-stretch\")}>\n {control}\n <span\n aria-hidden\n data-slot=\"icon-button-badge\"\n className=\"pointer-events-none absolute -end-0.5 -bottom-0.5 inline-flex rounded-full bg-surface [&_svg]:size-3\"\n >\n {badge}\n </span>\n </span>\n );\n}\n\n// Declared link-first, as Button is, so `<IconButton href>` picks the link props and\n// `ComponentProps<typeof IconButton>` (the LAST signature) is still the button's.\n/**\n * An icon-only button whose `label` is its name and its tooltip.\n *\n * Destructive actions (0.49 KIT-12): a delete, revoke or erase is NOT an icon repeated in\n * every row of a list — one stray tap away, and a bin per row draws the eye to the one\n * thing nobody should do by accident. Put it in the row's menu, `RowActions` (where a\n * `tone: \"danger\"` action goes by default and opens a `ConfirmDialog` first), or in the\n * selection bar (\"2 selected\" → Delete), and confirm it through `useConfirm`. A single\n * standalone delete — a card's own, a bulk bar's — may be an IconButton\n * `tone=\"danger\"`; it still asks first.\n */\nexport function IconButton(props: IconButtonLinkProps): ReactElement;\nexport function IconButton(props: IconButtonProps): ReactElement;\nexport function IconButton(props: IconButtonProps | IconButtonLinkProps): ReactElement {\n if (props.href !== undefined) return <IconButtonLink {...(props as IconButtonLinkProps)} />;\n return <IconButtonElement {...(props as IconButtonProps)} />;\n}\n\nfunction IconButtonLink({\n href,\n renderLink,\n external,\n replace,\n reloadDocument,\n disabled,\n variant = \"ghost\",\n size = \"md\",\n tone: toneProp,\n quiet,\n stopPropagation,\n stretch,\n shape = \"square\",\n toneColor,\n label,\n labelVisible,\n tooltip = true,\n tooltipSide,\n tooltipPortal,\n tooltipLazy = true,\n glyphSize,\n badge,\n className,\n style,\n target,\n rel,\n onClick,\n onKeyDown,\n children: glyph,\n ref,\n ...rest\n}: IconButtonLinkProps) {\n const withText = useIconButtonText(label, labelVisible, variant, tooltip);\n const children = withText ? (\n <>\n {glyph}\n <IconButtonText label={label!} />\n </>\n ) : (\n glyph\n );\n const kitLink = useKitLink();\n const common = useKitLabels(\"common\", DEFAULT_COMMON_LABELS);\n const tone = toneProp ?? (toneColor !== undefined ? \"custom\" : \"default\");\n // Button's rule, word for word: `reloadDocument` bypasses the router, an external\n // href or an in-page `#anchor` never reaches it.\n const Link = reloadDocument ? undefined : pickLinkRenderer<KitLinkProps>(renderLink, kitLink, href);\n const newTab = !disabled && (external || target === \"_blank\");\n const name = rest[\"aria-label\"] ?? label;\n // \"(opens in a new tab)\" joins the NAME here, not the body: an icon link has no text\n // for an sr-only span to follow, and `aria-label` would override one anyway.\n const ariaLabel = name !== undefined && newTab ? `${name} (${common.opensInNewTab})` : name;\n const handleClick = (e: MouseEvent<HTMLAnchorElement>) => {\n if (stopPropagation) e.stopPropagation();\n onClick?.(e);\n };\n const handleKeyDown = (e: KeyboardEvent<HTMLAnchorElement>) => {\n // A link activates on Enter only; that key still bubbles to a clickable row.\n if (stopPropagation && e.key === \"Enter\") e.stopPropagation();\n onKeyDown?.(e);\n };\n const cls = cn(\n iconButtonLook({ variant, size, stretch, glyphSize, shape, tone, quiet, toneColor, withText }),\n // BASE's `disabled:` classes never match an `<a>` — Button's disabled link look.\n disabled && \"pointer-events-none cursor-not-allowed opacity-50\",\n className,\n );\n const look = { style: toneColorStyle(toneColor, style), className: cls };\n let control: ReactElement;\n if (disabled) {\n control = (\n <a {...rest} {...look} ref={ref} role=\"link\" aria-disabled=\"true\" aria-label={ariaLabel}>\n {children}\n </a>\n );\n } else if (external || !Link) {\n control = (\n <a\n {...rest}\n {...look}\n ref={ref}\n href={href}\n aria-label={ariaLabel}\n target={external ? \"_blank\" : target}\n rel={external ? (rel ?? \"noopener noreferrer\") : rel}\n onClick={external ? handleClick : replacingClick(handleClick, href, replace, target)}\n onKeyDown={handleKeyDown}\n >\n {children}\n </a>\n );\n } else {\n control = (\n <RenderedButtonLink\n render={Link}\n {...rest}\n {...look}\n {...routerLinkNavigation(replace)}\n ref={ref}\n href={href}\n aria-label={ariaLabel}\n target={target}\n rel={rel}\n onClick={handleClick}\n onKeyDown={handleKeyDown}\n >\n {children}\n </RenderedButtonLink>\n );\n }\n control = withIconButtonBadge(control, badge, stretch);\n // The words are on the link at Large: a bubble would only repeat them.\n if (!tooltip || withText || label === undefined || label === \"\") return control;\n return (\n // A fragment, as on the button: the bubble says what `aria-label` already does.\n <Tooltip\n label={label}\n side={tooltipSide}\n portal={tooltipPortal}\n lazy={tooltipLazy}\n className={stretch ? \"self-stretch\" : undefined}\n >\n <>{control}</>\n </Tooltip>\n );\n}\n\nfunction IconButtonElement({\n variant = \"ghost\",\n size = \"md\",\n tone: toneProp,\n quiet,\n pressed,\n stopPropagation,\n stretch,\n shape = \"square\",\n toneColor,\n label,\n labelVisible,\n tooltip = true,\n tooltipSide,\n tooltipPortal,\n tooltipLazy = true,\n refused: refusedProp,\n disabledReason: ownDisabledReason,\n disabledReasonDisplay,\n commit,\n pending,\n glyphSize,\n badge,\n disabledStyle = \"dim\",\n className,\n style,\n onClick,\n onKeyDown,\n children,\n href: _href,\n renderLink: _renderLink,\n external: _external,\n replace: _replace,\n reloadDocument: _reloadDocument,\n ref,\n ...rest\n}: IconButtonProps) {\n const tone = toneProp ?? (toneColor !== undefined ? \"custom\" : \"default\");\n const keep = disabledStyle === \"keep\";\n const withText = useIconButtonText(label, labelVisible, variant, tooltip);\n // `locked`: refused WITH words, which take the bubble; `refused` also covers `true`,\n // which keeps the label's.\n const { reason: disabledReason, locked, reasonId, refused } = useRefusal(refusedProp, ownDisabledReason, commit);\n // Pending is inert like a refused button — focusable, every activation swallowed —\n // without the refused look: the spinner is the state.\n const inert = refused || Boolean(pending);\n const ownDescribedBy = rest[\"aria-describedby\"];\n const button = (\n <button\n ref={ref}\n {...rest}\n disabled={inert ? undefined : rest.disabled}\n aria-disabled={inert || rest[\"aria-disabled\"]}\n aria-busy={pending || rest[\"aria-busy\"]}\n aria-describedby={locked ? (ownDescribedBy ? `${ownDescribedBy} ${reasonId}` : reasonId) : ownDescribedBy}\n aria-label={rest[\"aria-label\"] ?? label}\n aria-pressed={pressed ?? rest[\"aria-pressed\"]}\n style={toneColorStyle(toneColor, style)}\n onClick={(e) => {\n if (stopPropagation) e.stopPropagation();\n // `preventDefault` too: a locked (or pending) submit must not submit its form.\n // And kept from a clickable row around it, as a natively disabled button's click\n // never reaches one (0.55.1, Kurvenschmiede's refused delete opened its row).\n if (inert) {\n e.preventDefault();\n e.stopPropagation();\n return;\n }\n onClick?.(e);\n }}\n onKeyDown={(e) => {\n // A button turns Enter/Space into a click of its own, but the KEY still\n // bubbles — and a clickable row listening for Enter would act on it too.\n if (stopPropagation && (e.key === \"Enter\" || e.key === \" \")) e.stopPropagation();\n onKeyDown?.(e);\n }}\n className={cn(\n iconButtonLook({ variant, size, stretch, glyphSize, shape, tone, quiet, toneColor, withText }),\n pressed && ICON_BUTTON_PRESSED,\n // The disabled look for the focusable kind of disabled, as on Button.\n refused && (keep ? \"cursor-default\" : \"cursor-not-allowed opacity-50\"),\n keep && \"disabled:cursor-default disabled:opacity-100\",\n pending && \"cursor-progress\",\n className,\n )}\n >\n {pending ? (\n // Decorative, as on Button: `aria-busy` says it. Sized to the glyph it stands\n // in for, in the glyph's colour, so the swap does not change the button's look.\n <Spinner\n label={null}\n className={cn(\n \"border-current/30 border-t-current\",\n glyphSize !== undefined ? ICON_BUTTON_SPINNER_GLYPH[glyphSize] : ICON_BUTTON_SPINNER[size],\n )}\n />\n ) : (\n children\n )}\n {withText && <IconButtonText label={label!} />}\n </button>\n );\n const control = withIconButtonBadge(button, badge, stretch);\n if (locked) {\n return (\n // Button's shape: the reason as a line under it, or the bubble with a hidden copy\n // for the description (see LockedReason).\n <LockedReason\n lock={{ reason: disabledReason, reasonId }}\n display={disabledReasonDisplay}\n side={tooltipSide}\n portal={tooltipPortal}\n className={stretch ? \"self-stretch\" : undefined}\n >\n {control}\n </LockedReason>\n );\n }\n // The words are on the button at Large: a bubble would only repeat them.\n if (!tooltip || withText || label === undefined || label === \"\") return control;\n return (\n // A fragment, so Tooltip leaves the button's description alone: the bubble says\n // exactly what `aria-label` already does. Lazy by default — see `tooltipLazy`.\n <Tooltip\n label={label}\n side={tooltipSide}\n portal={tooltipPortal}\n lazy={tooltipLazy}\n className={stretch ? \"self-stretch\" : undefined}\n >\n <>{control}</>\n </Tooltip>\n );\n}\n"],"mappings":"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","names":[]}
@@ -50,7 +50,10 @@ export type LegendedSeriesChartProps<TRow extends object = object> = Omit<Series
50
50
  * marker, a reference line). A switch reports its entry's `key`.
51
51
  */
52
52
  legendEntries?: readonly LegendEntry[];
53
- /** Onto the legend: its row is centred under the chart, wrapping. */
53
+ /** Where the legend's row sits under the chart: centred (the default), or at the
54
+ * start, under the plot's edge (keksdose's cash-buffer key; 0.55.1). */
55
+ legendAlign?: "center" | "start";
56
+ /** Onto the legend: its row wraps under the chart, as `legendAlign` places it. */
54
57
  legendClassName?: string;
55
58
  /** Wraps the chart and the chart alone — a horizontal scroller the legend has to stay
56
59
  * out of, so it is in view however far the chart is scrolled (keksdose's budget
@@ -77,4 +80,4 @@ export type LegendedSeriesChartProps<TRow extends object = object> = Omit<Series
77
80
  * the hidden set changes, so a render of the page costs it nothing (see SeriesChart's
78
81
  * memoised plot) — as long as the caller keeps its own the same.
79
82
  */
80
- export declare function LegendedSeriesChart<TRow extends object = object>({ series, axes, legend, legendEntries, legendClassName, frame, hidden: hiddenProp, onToggle, onAxisBudget, ...chartProps }: LegendedSeriesChartProps<TRow>): import("react").JSX.Element;
83
+ export declare function LegendedSeriesChart<TRow extends object = object>({ series, axes, legend, legendEntries, legendAlign, legendClassName, frame, hidden: hiddenProp, onToggle, onAxisBudget, ...chartProps }: LegendedSeriesChartProps<TRow>): import("react").JSX.Element;
@@ -14,6 +14,7 @@ function LegendedSeriesChart({
14
14
  axes,
15
15
  legend = "toggle",
16
16
  legendEntries,
17
+ legendAlign = "center",
17
18
  legendClassName,
18
19
  frame,
19
20
  hidden: hiddenProp,
@@ -52,14 +53,20 @@ function LegendedSeriesChart({
52
53
  );
53
54
  return /* @__PURE__ */ jsxs("div", { children: [
54
55
  frame ? frame(chart) : chart,
55
- legend === "key" ? /* @__PURE__ */ jsx(StaticLegend, { entries, className: cn("justify-center", legendClassName) }) : /* @__PURE__ */ jsx(
56
+ legend === "key" ? /* @__PURE__ */ jsx(
57
+ StaticLegend,
58
+ {
59
+ entries,
60
+ className: cn(legendAlign === "start" ? "justify-start" : "justify-center", legendClassName)
61
+ }
62
+ ) : /* @__PURE__ */ jsx(
56
63
  ToggleLegend,
57
64
  {
58
65
  entries,
59
66
  hidden,
60
67
  onToggle: toggle,
61
68
  showSingle: entries.some((entry) => hidden.has(entry.key)),
62
- className: cn("justify-center", legendClassName)
69
+ className: cn(legendAlign === "start" ? "justify-start" : "justify-center", legendClassName)
63
70
  }
64
71
  )
65
72
  ] });
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/components/legended-series-chart.tsx"],"sourcesContent":["// A series chart with its legend under it, as one part (0.55). Lifted out of keksdose's\n// reports, where `LegendedSeriesChart` (features/reports/charts/legended-series-chart.tsx)\n// wired six report charts — net worth, investments, income and expense, the grouped and\n// stacked payees, budget performance, the price history — and out of kastlan's reporting\n// and dashboard charts and keksdose's metrics panel, which wrote the same pairing by hand:\n// a hidden set, `visibleSeries` for the chart, the FULL list for the legend through\n// `seriesLegendEntries`, and a `ToggleLegend` centred under the plot. The kit exported the\n// parts and left the pairing to every app; this is the pairing.\nimport { useCallback, useMemo, useState } from \"react\";\nimport type { ReactNode } from \"react\";\nimport {\n SeriesChart,\n seriesLegendEntries,\n visibleSeries,\n type SeriesChartAxis,\n type SeriesChartProps,\n type SeriesChartSeries,\n} from \"./series-chart\";\nimport { StaticLegend, ToggleLegend, toggleHidden, type LegendEntry } from \"./toggle-legend\";\nimport { cn } from \"../lib/cn\";\n\n/**\n * What the legend is, and who keeps the series it has switched off.\n *\n * - `legend: \"toggle\"` (the default): every entry a switch that takes its series off the\n * chart and brings it back (`ToggleLegend`). The chart keeps what is off — or the caller\n * does, with `hidden` and `onToggle` together: for a choice that has to outlive the chart\n * (a tab that shows its skeleton while the next month loads), or one kept by something\n * steadier than the series key (keksdose's shop names, whose keys re-slot when the markets\n * narrow).\n * - `legend: \"key\"`: a key, not a control (`StaticLegend`) — a list to a screen reader, no\n * tab stops, every series drawn. For a legend that says what a mark means without\n * letting it be switched: keksdose's cash buffer, whose dashed projection and amber\n * marker are named, not toggled.\n */\nexport type LegendedSeriesChartLegend =\n | {\n legend?: \"toggle\";\n hidden?: undefined;\n onToggle?: undefined;\n }\n | {\n legend?: \"toggle\";\n /** The keys switched off, kept by the caller. */\n hidden: ReadonlySet<string>;\n /** An entry was switched: its series' key. */\n onToggle: (key: string) => void;\n }\n | { legend: \"key\"; hidden?: undefined; onToggle?: undefined };\n\n/**\n * The props of {@link LegendedSeriesChart}: a {@link SeriesChartProps} for rows of type\n * `TRow`, whose `series` is the FULL list and whose `axes` may follow what is switched off.\n */\nexport type LegendedSeriesChartProps<TRow extends object = object> = Omit<SeriesChartProps<TRow>, \"series\" | \"axes\"> &\n LegendedSeriesChartLegend & {\n /** EVERY series, the switched-off ones included: the legend lists them all, and the\n * chart draws the rest through `visibleSeries`, which settles each colour on this full\n * list first, so a switch never repaints a neighbour. */\n series: readonly SeriesChartSeries<TRow>[];\n /** The axes, or a function of the switched-off keys, for an axis drawn only while its\n * series is: keksdose's running sum, whose axis hands the bars its room back when the\n * line is switched off. */\n axes?: readonly SeriesChartAxis[] | ((hidden: ReadonlySet<string>) => readonly SeriesChartAxis[]);\n /**\n * The legend's entries, when not the kit's own for `series` (`seriesLegendEntries`,\n * handed the axes drawn and those the axis budget hid, so a series on an undrawn axis\n * says its unit): a stroke for every line, or a key's marks that are not series (a\n * marker, a reference line). A switch reports its entry's `key`.\n */\n legendEntries?: readonly LegendEntry[];\n /** Onto the legend: its row is centred under the chart, wrapping. */\n legendClassName?: string;\n /** Wraps the chart and the chart alone — a horizontal scroller the legend has to stay\n * out of, so it is in view however far the chart is scrolled (keksdose's budget\n * performance, with `tooltip.boundary` on the scroller). */\n frame?: (chart: ReactNode) => ReactNode;\n };\n\n/** Nothing switched off, one set for every chart that keeps its own. */\nconst NONE: ReadonlySet<string> = new Set();\n\n/**\n * A {@link SeriesChart} and its legend, the legend centred under it: the kit's layout of the\n * two, which keksdose, kastlan and the kit's own showcase each assembled from the parts.\n *\n * With the default `legend: \"toggle\"` each entry is a switch: the chart is drawn with\n * `visibleSeries(series, hidden)`, the legend lists every series (`seriesLegendEntries`),\n * and a series that is off keeps its place and its colour in the legend. A legend of one\n * series is drawn only while that series is off — the one way to bring it back. With\n * `legend: \"key\"` it is a {@link StaticLegend} of the same entries, and every series is\n * drawn.\n *\n * Everything else is the chart's: rows, axes, zoom, tooltip, `onPointClick`, `aria-label`,\n * all as {@link SeriesChartProps} take them. Generic in its rows as SeriesChart is (0.53):\n * `hit.row` and a `dot`'s `point.row` are typed as `rows` is. A plain function, so the\n * generic survives.\n *\n * The chart is handed the same `series` and `axes` objects while neither the caller's nor\n * the hidden set changes, so a render of the page costs it nothing (see SeriesChart's\n * memoised plot) — as long as the caller keeps its own the same.\n */\nexport function LegendedSeriesChart<TRow extends object = object>({\n series,\n axes,\n legend = \"toggle\",\n legendEntries,\n legendClassName,\n frame,\n hidden: hiddenProp,\n onToggle,\n onAxisBudget,\n ...chartProps\n}: LegendedSeriesChartProps<TRow>) {\n const [ownHidden, setOwnHidden] = useState<ReadonlySet<string>>(NONE);\n // A key switches nothing: every series is drawn.\n const hidden = legend === \"key\" ? NONE : (hiddenProp ?? ownHidden);\n const toggle = useCallback(\n (key: string) => (onToggle ? onToggle(key) : setOwnHidden((was) => toggleHidden(was, key))),\n [onToggle],\n );\n // What the axis budget hid, for the legend to say those axes' units (SeriesChart's\n // `onAxisBudget`); the caller's own callback is still told.\n const [budgeted, setBudgeted] = useState<readonly string[]>([]);\n const reportBudget = useCallback(\n (ids: readonly string[]) => {\n setBudgeted(ids);\n onAxisBudget?.(ids);\n },\n [onAxisBudget],\n );\n const drawn = useMemo(() => visibleSeries(series, hidden), [series, hidden]);\n const drawnAxes = useMemo(() => (typeof axes === \"function\" ? axes(hidden) : axes), [axes, hidden]);\n const entries = useMemo(\n () => legendEntries ?? seriesLegendEntries(series, { axes: drawnAxes, budgeted }),\n [legendEntries, series, drawnAxes, budgeted],\n );\n // SeriesChart's own props, with `TRow` as the caller's: the rest is passed through as is.\n const chart = (\n <SeriesChart<TRow>\n {...(chartProps as Omit<SeriesChartProps<TRow>, \"series\" | \"axes\" | \"onAxisBudget\">)}\n series={drawn}\n axes={drawnAxes}\n onAxisBudget={reportBudget}\n />\n );\n return (\n <div>\n {frame ? frame(chart) : chart}\n {legend === \"key\" ? (\n <StaticLegend entries={entries} className={cn(\"justify-center\", legendClassName)} />\n ) : (\n <ToggleLegend\n entries={entries}\n hidden={hidden}\n onToggle={toggle}\n // One series' legend is noise — until that series is off, and its entry is the\n // only way back.\n showSingle={entries.some((entry) => hidden.has(entry.key))}\n className={cn(\"justify-center\", legendClassName)}\n />\n )}\n </div>\n );\n}\n"],"mappings":";AA2II,cAQA,YARA;AAnIJ,SAAS,aAAa,SAAS,gBAAgB;AAE/C;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,OAIK;AACP,SAAS,cAAc,cAAc,oBAAsC;AAC3E,SAAS,UAAU;AA6DnB,MAAM,OAA4B,oBAAI,IAAI;AAsBnC,SAAS,oBAAkD;AAAA,EAChE;AAAA,EACA;AAAA,EACA,SAAS;AAAA,EACT;AAAA,EACA;AAAA,EACA;AAAA,EACA,QAAQ;AAAA,EACR;AAAA,EACA;AAAA,EACA,GAAG;AACL,GAAmC;AACjC,QAAM,CAAC,WAAW,YAAY,IAAI,SAA8B,IAAI;AAEpE,QAAM,SAAS,WAAW,QAAQ,OAAQ,cAAc;AACxD,QAAM,SAAS;AAAA,IACb,CAAC,QAAiB,WAAW,SAAS,GAAG,IAAI,aAAa,CAAC,QAAQ,aAAa,KAAK,GAAG,CAAC;AAAA,IACzF,CAAC,QAAQ;AAAA,EACX;AAGA,QAAM,CAAC,UAAU,WAAW,IAAI,SAA4B,CAAC,CAAC;AAC9D,QAAM,eAAe;AAAA,IACnB,CAAC,QAA2B;AAC1B,kBAAY,GAAG;AACf,qBAAe,GAAG;AAAA,IACpB;AAAA,IACA,CAAC,YAAY;AAAA,EACf;AACA,QAAM,QAAQ,QAAQ,MAAM,cAAc,QAAQ,MAAM,GAAG,CAAC,QAAQ,MAAM,CAAC;AAC3E,QAAM,YAAY,QAAQ,MAAO,OAAO,SAAS,aAAa,KAAK,MAAM,IAAI,MAAO,CAAC,MAAM,MAAM,CAAC;AAClG,QAAM,UAAU;AAAA,IACd,MAAM,iBAAiB,oBAAoB,QAAQ,EAAE,MAAM,WAAW,SAAS,CAAC;AAAA,IAChF,CAAC,eAAe,QAAQ,WAAW,QAAQ;AAAA,EAC7C;AAEA,QAAM,QACJ;AAAA,IAAC;AAAA;AAAA,MACE,GAAI;AAAA,MACL,QAAQ;AAAA,MACR,MAAM;AAAA,MACN,cAAc;AAAA;AAAA,EAChB;AAEF,SACE,qBAAC,SACE;AAAA,YAAQ,MAAM,KAAK,IAAI;AAAA,IACvB,WAAW,QACV,oBAAC,gBAAa,SAAkB,WAAW,GAAG,kBAAkB,eAAe,GAAG,IAElF;AAAA,MAAC;AAAA;AAAA,QACC;AAAA,QACA;AAAA,QACA,UAAU;AAAA,QAGV,YAAY,QAAQ,KAAK,CAAC,UAAU,OAAO,IAAI,MAAM,GAAG,CAAC;AAAA,QACzD,WAAW,GAAG,kBAAkB,eAAe;AAAA;AAAA,IACjD;AAAA,KAEJ;AAEJ;","names":[]}
1
+ {"version":3,"sources":["../../src/components/legended-series-chart.tsx"],"sourcesContent":["// A series chart with its legend under it, as one part (0.55). Lifted out of keksdose's\n// reports, where `LegendedSeriesChart` (features/reports/charts/legended-series-chart.tsx)\n// wired six report charts — net worth, investments, income and expense, the grouped and\n// stacked payees, budget performance, the price history — and out of kastlan's reporting\n// and dashboard charts and keksdose's metrics panel, which wrote the same pairing by hand:\n// a hidden set, `visibleSeries` for the chart, the FULL list for the legend through\n// `seriesLegendEntries`, and a `ToggleLegend` centred under the plot. The kit exported the\n// parts and left the pairing to every app; this is the pairing.\nimport { useCallback, useMemo, useState } from \"react\";\nimport type { ReactNode } from \"react\";\nimport {\n SeriesChart,\n seriesLegendEntries,\n visibleSeries,\n type SeriesChartAxis,\n type SeriesChartProps,\n type SeriesChartSeries,\n} from \"./series-chart\";\nimport { StaticLegend, ToggleLegend, toggleHidden, type LegendEntry } from \"./toggle-legend\";\nimport { cn } from \"../lib/cn\";\n\n/**\n * What the legend is, and who keeps the series it has switched off.\n *\n * - `legend: \"toggle\"` (the default): every entry a switch that takes its series off the\n * chart and brings it back (`ToggleLegend`). The chart keeps what is off — or the caller\n * does, with `hidden` and `onToggle` together: for a choice that has to outlive the chart\n * (a tab that shows its skeleton while the next month loads), or one kept by something\n * steadier than the series key (keksdose's shop names, whose keys re-slot when the markets\n * narrow).\n * - `legend: \"key\"`: a key, not a control (`StaticLegend`) — a list to a screen reader, no\n * tab stops, every series drawn. For a legend that says what a mark means without\n * letting it be switched: keksdose's cash buffer, whose dashed projection and amber\n * marker are named, not toggled.\n */\nexport type LegendedSeriesChartLegend =\n | {\n legend?: \"toggle\";\n hidden?: undefined;\n onToggle?: undefined;\n }\n | {\n legend?: \"toggle\";\n /** The keys switched off, kept by the caller. */\n hidden: ReadonlySet<string>;\n /** An entry was switched: its series' key. */\n onToggle: (key: string) => void;\n }\n | { legend: \"key\"; hidden?: undefined; onToggle?: undefined };\n\n/**\n * The props of {@link LegendedSeriesChart}: a {@link SeriesChartProps} for rows of type\n * `TRow`, whose `series` is the FULL list and whose `axes` may follow what is switched off.\n */\nexport type LegendedSeriesChartProps<TRow extends object = object> = Omit<SeriesChartProps<TRow>, \"series\" | \"axes\"> &\n LegendedSeriesChartLegend & {\n /** EVERY series, the switched-off ones included: the legend lists them all, and the\n * chart draws the rest through `visibleSeries`, which settles each colour on this full\n * list first, so a switch never repaints a neighbour. */\n series: readonly SeriesChartSeries<TRow>[];\n /** The axes, or a function of the switched-off keys, for an axis drawn only while its\n * series is: keksdose's running sum, whose axis hands the bars its room back when the\n * line is switched off. */\n axes?: readonly SeriesChartAxis[] | ((hidden: ReadonlySet<string>) => readonly SeriesChartAxis[]);\n /**\n * The legend's entries, when not the kit's own for `series` (`seriesLegendEntries`,\n * handed the axes drawn and those the axis budget hid, so a series on an undrawn axis\n * says its unit): a stroke for every line, or a key's marks that are not series (a\n * marker, a reference line). A switch reports its entry's `key`.\n */\n legendEntries?: readonly LegendEntry[];\n /** Where the legend's row sits under the chart: centred (the default), or at the\n * start, under the plot's edge (keksdose's cash-buffer key; 0.55.1). */\n legendAlign?: \"center\" | \"start\";\n /** Onto the legend: its row wraps under the chart, as `legendAlign` places it. */\n legendClassName?: string;\n /** Wraps the chart and the chart alone — a horizontal scroller the legend has to stay\n * out of, so it is in view however far the chart is scrolled (keksdose's budget\n * performance, with `tooltip.boundary` on the scroller). */\n frame?: (chart: ReactNode) => ReactNode;\n };\n\n/** Nothing switched off, one set for every chart that keeps its own. */\nconst NONE: ReadonlySet<string> = new Set();\n\n/**\n * A {@link SeriesChart} and its legend, the legend centred under it: the kit's layout of the\n * two, which keksdose, kastlan and the kit's own showcase each assembled from the parts.\n *\n * With the default `legend: \"toggle\"` each entry is a switch: the chart is drawn with\n * `visibleSeries(series, hidden)`, the legend lists every series (`seriesLegendEntries`),\n * and a series that is off keeps its place and its colour in the legend. A legend of one\n * series is drawn only while that series is off — the one way to bring it back. With\n * `legend: \"key\"` it is a {@link StaticLegend} of the same entries, and every series is\n * drawn.\n *\n * Everything else is the chart's: rows, axes, zoom, tooltip, `onPointClick`, `aria-label`,\n * all as {@link SeriesChartProps} take them. Generic in its rows as SeriesChart is (0.53):\n * `hit.row` and a `dot`'s `point.row` are typed as `rows` is. A plain function, so the\n * generic survives.\n *\n * The chart is handed the same `series` and `axes` objects while neither the caller's nor\n * the hidden set changes, so a render of the page costs it nothing (see SeriesChart's\n * memoised plot) — as long as the caller keeps its own the same.\n */\nexport function LegendedSeriesChart<TRow extends object = object>({\n series,\n axes,\n legend = \"toggle\",\n legendEntries,\n legendAlign = \"center\",\n legendClassName,\n frame,\n hidden: hiddenProp,\n onToggle,\n onAxisBudget,\n ...chartProps\n}: LegendedSeriesChartProps<TRow>) {\n const [ownHidden, setOwnHidden] = useState<ReadonlySet<string>>(NONE);\n // A key switches nothing: every series is drawn.\n const hidden = legend === \"key\" ? NONE : (hiddenProp ?? ownHidden);\n const toggle = useCallback(\n (key: string) => (onToggle ? onToggle(key) : setOwnHidden((was) => toggleHidden(was, key))),\n [onToggle],\n );\n // What the axis budget hid, for the legend to say those axes' units (SeriesChart's\n // `onAxisBudget`); the caller's own callback is still told.\n const [budgeted, setBudgeted] = useState<readonly string[]>([]);\n const reportBudget = useCallback(\n (ids: readonly string[]) => {\n setBudgeted(ids);\n onAxisBudget?.(ids);\n },\n [onAxisBudget],\n );\n const drawn = useMemo(() => visibleSeries(series, hidden), [series, hidden]);\n const drawnAxes = useMemo(() => (typeof axes === \"function\" ? axes(hidden) : axes), [axes, hidden]);\n const entries = useMemo(\n () => legendEntries ?? seriesLegendEntries(series, { axes: drawnAxes, budgeted }),\n [legendEntries, series, drawnAxes, budgeted],\n );\n // SeriesChart's own props, with `TRow` as the caller's: the rest is passed through as is.\n const chart = (\n <SeriesChart<TRow>\n {...(chartProps as Omit<SeriesChartProps<TRow>, \"series\" | \"axes\" | \"onAxisBudget\">)}\n series={drawn}\n axes={drawnAxes}\n onAxisBudget={reportBudget}\n />\n );\n return (\n <div>\n {frame ? frame(chart) : chart}\n {legend === \"key\" ? (\n <StaticLegend\n entries={entries}\n className={cn(legendAlign === \"start\" ? \"justify-start\" : \"justify-center\", legendClassName)}\n />\n ) : (\n <ToggleLegend\n entries={entries}\n hidden={hidden}\n onToggle={toggle}\n // One series' legend is noise — until that series is off, and its entry is the\n // only way back.\n showSingle={entries.some((entry) => hidden.has(entry.key))}\n className={cn(legendAlign === \"start\" ? \"justify-start\" : \"justify-center\", legendClassName)}\n />\n )}\n </div>\n );\n}\n"],"mappings":";AA+II,cAQA,YARA;AAvIJ,SAAS,aAAa,SAAS,gBAAgB;AAE/C;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,OAIK;AACP,SAAS,cAAc,cAAc,oBAAsC;AAC3E,SAAS,UAAU;AAgEnB,MAAM,OAA4B,oBAAI,IAAI;AAsBnC,SAAS,oBAAkD;AAAA,EAChE;AAAA,EACA;AAAA,EACA,SAAS;AAAA,EACT;AAAA,EACA,cAAc;AAAA,EACd;AAAA,EACA;AAAA,EACA,QAAQ;AAAA,EACR;AAAA,EACA;AAAA,EACA,GAAG;AACL,GAAmC;AACjC,QAAM,CAAC,WAAW,YAAY,IAAI,SAA8B,IAAI;AAEpE,QAAM,SAAS,WAAW,QAAQ,OAAQ,cAAc;AACxD,QAAM,SAAS;AAAA,IACb,CAAC,QAAiB,WAAW,SAAS,GAAG,IAAI,aAAa,CAAC,QAAQ,aAAa,KAAK,GAAG,CAAC;AAAA,IACzF,CAAC,QAAQ;AAAA,EACX;AAGA,QAAM,CAAC,UAAU,WAAW,IAAI,SAA4B,CAAC,CAAC;AAC9D,QAAM,eAAe;AAAA,IACnB,CAAC,QAA2B;AAC1B,kBAAY,GAAG;AACf,qBAAe,GAAG;AAAA,IACpB;AAAA,IACA,CAAC,YAAY;AAAA,EACf;AACA,QAAM,QAAQ,QAAQ,MAAM,cAAc,QAAQ,MAAM,GAAG,CAAC,QAAQ,MAAM,CAAC;AAC3E,QAAM,YAAY,QAAQ,MAAO,OAAO,SAAS,aAAa,KAAK,MAAM,IAAI,MAAO,CAAC,MAAM,MAAM,CAAC;AAClG,QAAM,UAAU;AAAA,IACd,MAAM,iBAAiB,oBAAoB,QAAQ,EAAE,MAAM,WAAW,SAAS,CAAC;AAAA,IAChF,CAAC,eAAe,QAAQ,WAAW,QAAQ;AAAA,EAC7C;AAEA,QAAM,QACJ;AAAA,IAAC;AAAA;AAAA,MACE,GAAI;AAAA,MACL,QAAQ;AAAA,MACR,MAAM;AAAA,MACN,cAAc;AAAA;AAAA,EAChB;AAEF,SACE,qBAAC,SACE;AAAA,YAAQ,MAAM,KAAK,IAAI;AAAA,IACvB,WAAW,QACV;AAAA,MAAC;AAAA;AAAA,QACC;AAAA,QACA,WAAW,GAAG,gBAAgB,UAAU,kBAAkB,kBAAkB,eAAe;AAAA;AAAA,IAC7F,IAEA;AAAA,MAAC;AAAA;AAAA,QACC;AAAA,QACA;AAAA,QACA,UAAU;AAAA,QAGV,YAAY,QAAQ,KAAK,CAAC,UAAU,OAAO,IAAI,MAAM,GAAG,CAAC;AAAA,QACzD,WAAW,GAAG,gBAAgB,UAAU,kBAAkB,kBAAkB,eAAe;AAAA;AAAA,IAC7F;AAAA,KAEJ;AAEJ;","names":[]}
@@ -47,7 +47,7 @@ const roleUtilities = {
47
47
  };
48
48
  }
49
49
  };
50
- const VERSION = true ? "0.55.0" : "0.0.0-source";
50
+ const VERSION = true ? "0.56.0" : "0.0.0-source";
51
51
  const recommended = {
52
52
  name: "@eifi1/ui-kit/recommended",
53
53
  // Not the tests, as check:tokens skips the kit's: a test names the old class to assert it
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@eifi1/ui-kit",
3
- "version": "0.55.0",
3
+ "version": "0.56.0",
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.",
@@ -103,6 +103,7 @@
103
103
  "test:coverage": "vitest run --coverage",
104
104
  "check:graph": "node scripts/check-module-graph.mjs",
105
105
  "check:package": "node scripts/verify-package.mjs --quiet",
106
+ "check:consumer-types": "node scripts/check-consumer-types.mjs",
106
107
  "check:api": "node scripts/check-api-compat.mjs",
107
108
  "check:tailwind": "node scripts/check-tailwind-scan.mjs",
108
109
  "check:security": "npm audit --audit-level=high",
@@ -113,7 +114,7 @@
113
114
  "format:check": "prettier --check . --cache --cache-location node_modules/.cache/prettier/.prettier-cache --cache-strategy content",
114
115
  "check:static": "npm run format:check && npm run typecheck && npm run lint && npm run check:tokens && npm run check:security && npm run check:commit-rules && npm run check:commits",
115
116
  "check:test": "npm run test:coverage",
116
- "check:build": "npm run build && npm run check:graph && npm run check:package && node scripts/gen-export-inventory.mjs --check && node scripts/gen-styling-inventory.mjs --check && npm run build:showcase && npm run check:tailwind",
117
+ "check:build": "npm run build && npm run check:graph && npm run check:package && npm run check:consumer-types && node scripts/gen-export-inventory.mjs --check && node scripts/gen-styling-inventory.mjs --check && npm run build:showcase && npm run check:tailwind",
117
118
  "check": "node scripts/check.mjs",
118
119
  "test": "vitest run",
119
120
  "test:watch": "vitest",
@@ -192,7 +193,7 @@
192
193
  "@testing-library/jest-dom": "^7.0.1",
193
194
  "@testing-library/react": "^16.3.0",
194
195
  "@testing-library/user-event": "^14.6.7",
195
- "@types/node": "^24.13.6",
196
+ "@types/node": "^26.6.6",
196
197
  "@types/react": "^19.2.14",
197
198
  "@types/react-dom": "^19.2.3",
198
199
  "@vitejs/plugin-react": "^6.1.1",
@@ -223,7 +224,7 @@
223
224
  "sonner": "^2.0.7",
224
225
  "tailwindcss": "^4.3.3",
225
226
  "tsup": "^9.0.0",
226
- "typescript": "~5.9.3",
227
+ "typescript": "~6.0.3",
227
228
  "typescript-eslint": "^8.70.1",
228
229
  "vite": "^8.3.4",
229
230
  "vitest": "^5.0.2"