@eifi1/ui-kit 0.51.5 → 0.51.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (129) hide show
  1. package/README.md +9 -7
  2. package/dist/admin/admin-action-confirm.js +2 -4
  3. package/dist/admin/admin-action-confirm.js.map +1 -1
  4. package/dist/auth/complete-name-dialog.d.ts +3 -2
  5. package/dist/auth/complete-name-dialog.js +1 -4
  6. package/dist/auth/complete-name-dialog.js.map +1 -1
  7. package/dist/billing/cancel-request.d.ts +11 -2
  8. package/dist/billing/cancel-request.js +15 -3
  9. package/dist/billing/cancel-request.js.map +1 -1
  10. package/dist/billing/cancel-subscription.d.ts +2 -2
  11. package/dist/billing/cancel-subscription.js +2 -7
  12. package/dist/billing/cancel-subscription.js.map +1 -1
  13. package/dist/billing/plan-switch.d.ts +2 -2
  14. package/dist/billing/plan-switch.js +2 -4
  15. package/dist/billing/plan-switch.js.map +1 -1
  16. package/dist/components/amount-input.d.ts +9 -4
  17. package/dist/components/amount-input.js +2 -3
  18. package/dist/components/amount-input.js.map +1 -1
  19. package/dist/components/calculator.d.ts +21 -2
  20. package/dist/components/calculator.js +53 -14
  21. package/dist/components/calculator.js.map +1 -1
  22. package/dist/components/combobox-core.d.ts +8 -5
  23. package/dist/components/combobox-core.js +3 -4
  24. package/dist/components/combobox-core.js.map +1 -1
  25. package/dist/components/combobox.js +2 -2
  26. package/dist/components/combobox.js.map +1 -1
  27. package/dist/components/data-table-mobile.d.ts +24 -0
  28. package/dist/components/data-table-mobile.js +20 -1
  29. package/dist/components/data-table-mobile.js.map +1 -1
  30. package/dist/components/data-table.d.ts +13 -5
  31. package/dist/components/data-table.js +35 -9
  32. package/dist/components/data-table.js.map +1 -1
  33. package/dist/components/dialog-frame.d.ts +2 -1
  34. package/dist/components/dialog-frame.js.map +1 -1
  35. package/dist/components/entity-combobox.js +4 -2
  36. package/dist/components/entity-combobox.js.map +1 -1
  37. package/dist/components/inline-entity-combobox.js +4 -3
  38. package/dist/components/inline-entity-combobox.js.map +1 -1
  39. package/dist/components/list.js +1 -1
  40. package/dist/components/list.js.map +1 -1
  41. package/dist/components/modal.d.ts +24 -0
  42. package/dist/components/modal.js +9 -3
  43. package/dist/components/modal.js.map +1 -1
  44. package/dist/components/money-draft.js +7 -5
  45. package/dist/components/money-draft.js.map +1 -1
  46. package/dist/components/money-field.d.ts +2 -2
  47. package/dist/components/money-field.js.map +1 -1
  48. package/dist/components/multi-entity-combobox.js +2 -1
  49. package/dist/components/multi-entity-combobox.js.map +1 -1
  50. package/dist/components/number-field.d.ts +6 -4
  51. package/dist/components/number-field.js +3 -2
  52. package/dist/components/number-field.js.map +1 -1
  53. package/dist/components/number-input.d.ts +4 -1
  54. package/dist/components/number-input.js +3 -2
  55. package/dist/components/number-input.js.map +1 -1
  56. package/dist/components/numpad-sheet.js.map +1 -1
  57. package/dist/components/reauth-dialog.d.ts +4 -5
  58. package/dist/components/reauth-dialog.js +1 -4
  59. package/dist/components/reauth-dialog.js.map +1 -1
  60. package/dist/components/series-chart-axes.d.ts +7 -0
  61. package/dist/components/series-chart-axes.js +1 -0
  62. package/dist/components/series-chart-axes.js.map +1 -1
  63. package/dist/components/tabs.js +12 -10
  64. package/dist/components/tabs.js.map +1 -1
  65. package/dist/components/treemap.d.ts +24 -2
  66. package/dist/components/treemap.js +40 -4
  67. package/dist/components/treemap.js.map +1 -1
  68. package/dist/eslint/index.js +1 -1
  69. package/dist/hooks/use-dismiss.js.map +1 -1
  70. package/dist/hooks/use-overlay-history.d.ts +7 -1
  71. package/dist/hooks/use-overlay-history.js +16 -2
  72. package/dist/hooks/use-overlay-history.js.map +1 -1
  73. package/dist/lib/calc.d.ts +7 -1
  74. package/dist/lib/calc.js +3 -1
  75. package/dist/lib/calc.js.map +1 -1
  76. package/dist/lib/clipboard-minus.d.ts +2 -2
  77. package/dist/lib/clipboard-minus.js +1 -1
  78. package/dist/lib/clipboard-minus.js.map +1 -1
  79. package/dist/lib/decimal-marks.d.ts +7 -1
  80. package/dist/lib/decimal-marks.js +5 -2
  81. package/dist/lib/decimal-marks.js.map +1 -1
  82. package/dist/lib/format.d.ts +5 -3
  83. package/dist/lib/format.js +12 -0
  84. package/dist/lib/format.js.map +1 -1
  85. package/dist/lib/text-match.d.ts +9 -0
  86. package/dist/lib/text-match.js +4 -0
  87. package/dist/lib/text-match.js.map +1 -1
  88. package/dist/theme/contrast.d.ts +2 -2
  89. package/dist/tour/tour.d.ts +3 -1
  90. package/dist/tour/tour.js +9 -3
  91. package/dist/tour/tour.js.map +1 -1
  92. package/dist/tour/use-tour-url-sync.js +2 -2
  93. package/dist/tour/use-tour-url-sync.js.map +1 -1
  94. package/package.json +3 -3
  95. package/src/admin/admin-action-confirm.tsx +4 -5
  96. package/src/auth/complete-name-dialog.tsx +5 -7
  97. package/src/billing/cancel-request.ts +28 -6
  98. package/src/billing/cancel-subscription.tsx +4 -11
  99. package/src/billing/plan-switch.tsx +5 -8
  100. package/src/components/amount-input.tsx +14 -7
  101. package/src/components/calculator.tsx +98 -17
  102. package/src/components/combobox-core.tsx +10 -7
  103. package/src/components/combobox.tsx +5 -6
  104. package/src/components/data-table-mobile.tsx +49 -1
  105. package/src/components/data-table.tsx +66 -11
  106. package/src/components/dialog-frame.tsx +2 -1
  107. package/src/components/entity-combobox.tsx +4 -1
  108. package/src/components/inline-entity-combobox.tsx +5 -3
  109. package/src/components/list.tsx +4 -1
  110. package/src/components/modal.tsx +39 -3
  111. package/src/components/money-draft.ts +16 -5
  112. package/src/components/money-field.tsx +2 -2
  113. package/src/components/multi-entity-combobox.tsx +1 -0
  114. package/src/components/number-field.tsx +14 -7
  115. package/src/components/number-input.tsx +9 -3
  116. package/src/components/numpad-sheet.tsx +4 -3
  117. package/src/components/reauth-dialog.tsx +6 -10
  118. package/src/components/series-chart-axes.ts +8 -0
  119. package/src/components/tabs.tsx +33 -17
  120. package/src/components/treemap.tsx +69 -7
  121. package/src/hooks/use-dismiss.ts +3 -2
  122. package/src/hooks/use-overlay-history.ts +38 -2
  123. package/src/lib/calc.ts +13 -2
  124. package/src/lib/clipboard-minus.ts +14 -4
  125. package/src/lib/decimal-marks.ts +12 -3
  126. package/src/lib/format.ts +30 -3
  127. package/src/lib/text-match.ts +12 -0
  128. package/src/tour/tour.tsx +21 -4
  129. package/src/tour/use-tour-url-sync.ts +7 -3
@@ -66,10 +66,7 @@ function ReauthDialog({
66
66
  title: title ?? labels.title,
67
67
  description: description ?? labels.description,
68
68
  onClose: () => onClose(confirmed.current),
69
- onKeyDown: (e) => {
70
- if (busy && e.key === "Escape") e.preventDefault();
71
- },
72
- "aria-busy": busy || void 0,
69
+ busy,
73
70
  actions: /* @__PURE__ */ jsx(
74
71
  ReauthActions,
75
72
  {
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/components/reauth-dialog.tsx"],"sourcesContent":["import { useContext, useEffect, useId, useRef, useState } from \"react\";\nimport type { KeyboardEvent, ReactNode } from \"react\";\n\nimport { useKitLabels, type LabelOverride } from \"../i18n/kit-labels\";\nimport { CurrentPasswordInput, usePromisePending } from \"./danger-confirm\";\nimport { DialogFrame } from \"./dialog-frame\";\nimport { ModalCloseContext } from \"./modal\";\nimport { Button } from \"./button\";\nimport { Spinner } from \"./spinner\";\nimport { DEFAULT_REAUTH_DIALOG_LABELS } from \"./reauth-dialog-labels\";\n\n/**\n * Every string the dialog renders — the `reauthDialog` namespace of\n * `<UiKitProvider labels>`, and overridable per instance through `labels`. The title,\n * description and submit button are usually worded per action (\"Add a passkey\"),\n * which is what the `title` / `description` / `submitLabel` props are for; these are\n * the fallbacks.\n */\nexport interface ReauthDialogLabels {\n title: string;\n description: string;\n /** Label of the password field. */\n password: string;\n submit: string;\n cancel: string;\n}\n\n// Defined in ./reauth-dialog-labels, which holds no component code (0.34, KS U8).\nexport { DEFAULT_REAUTH_DIALOG_LABELS };\n\nexport interface ReauthDialogProps {\n /**\n * Verifies the password — the app's call, e.g. the request the password guards.\n *\n * Return a promise and the dialog manages itself: busy until it settles, closes\n * (`onClose(true)`) when it resolves, STAYS OPEN with the password kept and selected\n * when it rejects — a wrong password is a typo to correct, not a sign-out. Pass the\n * reason as `error`. Return nothing and the app owns the rest: `busy` for the\n * pending state, unmounting the dialog once it is done.\n */\n onSubmit: (password: string) => void | Promise<unknown>;\n /**\n * The dialog has closed: `true` after a resolved `onSubmit`, `false` for Cancel,\n * Escape, the backdrop and Back. A dismissal while `onSubmit` is still running also\n * answers `false`, and that call's late result is then ignored.\n */\n onClose: (confirmed: boolean) => void;\n /**\n * Why the last attempt failed (\"Wrong password\"), shown under the field, which turns\n * invalid (`aria-invalid`, `--danger-border-strong`). The app decides the wording —\n * a 400 is a wrong password, a 500 is not. Hidden again as soon as the user edits\n * the field (the message is about the password they had typed), and shown again by\n * the next failed submit.\n */\n error?: ReactNode;\n /** The check is running, for a caller that tracks it itself (a mutation's\n * `isPending`); a promise returned from `onSubmit` does the same on its own. */\n busy?: boolean;\n /** Default: `labels.title` — \"Confirm it’s you\". */\n title?: ReactNode;\n /** The line under the title. Default: `labels.description`. */\n description?: ReactNode;\n /** Text of the submit button. Default: `labels.submit` — \"Continue\". */\n submitLabel?: ReactNode;\n /** `\"danger\"` when what the password unlocks cannot be undone: the submit is then the\n * destructive button. Default `\"neutral\"`. */\n tone?: \"neutral\" | \"danger\";\n /** Extra body above the field — what is about to happen, a warning. */\n children?: ReactNode;\n /** Kept-mounted mode, as on `Modal`: left out, the dialog is open while mounted. A\n * flip to `false` animates it out and wipes the password. */\n open?: boolean;\n /** User-facing strings; see {@link ReauthDialogLabels}. */\n labels?: LabelOverride<ReauthDialogLabels>;\n}\n\n/**\n * \"Enter your current password to continue\" — the step-up check in front of an\n * action that changes how an account signs in (adding a passkey) or what it can\n * reach. Kurvenschmiede asked for the password before registering a passkey in a\n * dialog of its own; keksdose asks for it inline in `DangerConfirm`'s\n * `requirePassword` tile. Both are the same three facts — the field is the CURRENT\n * password (the password manager fills it, never generates one), a wrong one stays in\n * the dialog with its error, and the APP verifies it — so the promise handling is\n * `DangerConfirm`'s own, shared, the field is its `CurrentPasswordInput` with an\n * `error`, and this is the modal form of them.\n *\n * A component rather than a `useReauth()` promise: the step that follows the check\n * (Kurvenschmiede's WebAuthn ceremony) needs what the server answered, and the error\n * wording needs the app's own status codes — both already live in the app's mutation,\n * which this dialog only has to be handed (`onSubmit={(pw) => begin.mutateAsync(pw)}`,\n * `error={…}`).\n *\n * The field is a `<form>`, so Enter submits once something is typed. Focus starts in\n * the field and returns to the trigger through `Modal`'s trap. While a check runs,\n * the field is read-only, Cancel is disabled and Escape is held: a request that has\n * left cannot be cancelled by closing the dialog, and the dialog should not pretend it\n * can.\n */\nexport function ReauthDialog({\n onSubmit,\n onClose,\n error,\n busy: busyProp,\n title,\n description,\n submitLabel,\n tone = \"neutral\",\n children,\n open,\n labels: labelsProp,\n}: ReauthDialogProps) {\n const labels = useKitLabels(\"reauthDialog\", DEFAULT_REAUTH_DIALOG_LABELS, labelsProp);\n const [password, setPassword] = useState(\"\");\n // The error describes the password that was submitted; once the user edits it, it\n // describes nothing on screen. Cleared by the next submit, so a second failure with\n // the same message shows again.\n const [edited, setEdited] = useState(false);\n const { pending, run } = usePromisePending();\n const busy = Boolean(busyProp) || pending;\n const inputRef = useRef<HTMLInputElement>(null);\n // `onSubmit` resolved: the actions row lowers the panel (state, so it re-renders),\n // and `onClose` answers true (a ref, because `Modal` may call the `onClose` it saw\n // BEFORE that re-render — its latest-callback ref is refreshed in an effect that runs\n // after the actions row's).\n const [done, setDone] = useState(false);\n const confirmed = useRef(false);\n const formId = useId();\n const isOpen = open ?? true;\n\n // A close (kept-mounted) wipes the password: it must not sit in a hidden dialog.\n // During render, like DangerConfirm's disarm, so no frame holds it.\n const [wasOpen, setWasOpen] = useState(isOpen);\n if (wasOpen !== isOpen) {\n setWasOpen(isOpen);\n if (!isOpen) {\n setPassword(\"\");\n setEdited(false);\n setDone(false);\n }\n }\n\n // After `Modal`'s own effect (a parent's effects run after its children's), so the\n // trap has recorded the trigger as its restore target before focus moves in.\n useEffect(() => {\n if (!isOpen) return;\n confirmed.current = false;\n inputRef.current?.focus();\n }, [isOpen]);\n\n const submit = () => {\n if (busy || password === \"\") return;\n setEdited(false);\n run(onSubmit(password), () => {\n confirmed.current = true;\n setDone(true);\n });\n };\n\n // A rejection leaves focus where it was (the field, or the submit button) — select\n // the password there so the retry replaces it rather than appending to it.\n const wasPending = useRef(false);\n useEffect(() => {\n if (wasPending.current && !pending && !done) inputRef.current?.select();\n wasPending.current = pending;\n }, [pending, done]);\n\n const shownError = edited ? undefined : error;\n\n return (\n <DialogFrame\n open={open}\n title={title ?? labels.title}\n description={description ?? labels.description}\n onClose={() => onClose(confirmed.current)}\n onKeyDown={(e: KeyboardEvent<HTMLDivElement>) => {\n if (busy && e.key === \"Escape\") e.preventDefault();\n }}\n aria-busy={busy || undefined}\n actions={\n <ReauthActions\n formId={formId}\n done={done}\n busy={busy}\n canSubmit={password !== \"\"}\n danger={tone === \"danger\"}\n cancelLabel={labels.cancel}\n submitLabel={submitLabel ?? labels.submit}\n />\n }\n >\n {children}\n <form\n id={formId}\n noValidate\n onSubmit={(e) => {\n e.preventDefault();\n submit();\n }}\n >\n {/* The kit's CurrentPasswordInput (0.23; it drew the same field by hand until\n then): the password manager fills it with the CURRENT password and never\n offers to generate one; `busy` makes it `readOnly`, not `disabled`, because\n disabling the field that has focus drops focus to <body>. Labelled by this\n namespace's `password` (\"Current password\"), not `dangerConfirm.password`.\n The `error` is Input's own: it paints the field, sits under it and is its\n `aria-describedby`; passing the prop on every render keeps the field's box\n steady, so the message going away on the first keystroke of a retry does not\n remount the <input> under the typist. */}\n <CurrentPasswordInput\n ref={inputRef}\n label={labels.password}\n value={password}\n busy={busy}\n error={shownError}\n onValueChange={(next) => {\n setPassword(next);\n setEdited(true);\n }}\n />\n </form>\n </DialogFrame>\n );\n}\n\n/**\n * The actions row. A component of its own because it renders INSIDE the `Modal`, where\n * the panel's animated close is in context: a resolved `onSubmit` lowers the panel\n * through it, the way Cancel and Escape do, and `Modal` then calls `onClose`.\n */\nfunction ReauthActions({\n formId,\n done,\n busy,\n canSubmit,\n danger,\n cancelLabel,\n submitLabel,\n}: {\n formId: string;\n done: boolean;\n busy: boolean;\n canSubmit: boolean;\n danger: boolean;\n cancelLabel: ReactNode;\n submitLabel: ReactNode;\n}) {\n const close = useContext(ModalCloseContext);\n useEffect(() => {\n if (done) close?.();\n }, [done, close]);\n return (\n <>\n <Button type=\"button\" variant=\"ghost\" disabled={busy} onClick={() => close?.()}>\n {cancelLabel}\n </Button>\n <Button\n type=\"submit\"\n form={formId}\n variant={danger ? \"danger\" : \"default\"}\n disabled={!canSubmit || busy}\n aria-busy={busy || undefined}\n >\n {busy && <Spinner label={null} className=\"h-4 w-4\" />}\n {submitLabel}\n </Button>\n </>\n );\n}\n"],"mappings":";AA0KI,SAkFA,UAxEI,KAVJ;AA1KJ,SAAS,YAAY,WAAW,OAAO,QAAQ,gBAAgB;AAG/D,SAAS,oBAAwC;AACjD,SAAS,sBAAsB,yBAAyB;AACxD,SAAS,mBAAmB;AAC5B,SAAS,yBAAyB;AAClC,SAAS,cAAc;AACvB,SAAS,eAAe;AACxB,SAAS,oCAAoC;AA0FtC,SAAS,aAAa;AAAA,EAC3B;AAAA,EACA;AAAA,EACA;AAAA,EACA,MAAM;AAAA,EACN;AAAA,EACA;AAAA,EACA;AAAA,EACA,OAAO;AAAA,EACP;AAAA,EACA;AAAA,EACA,QAAQ;AACV,GAAsB;AACpB,QAAM,SAAS,aAAa,gBAAgB,8BAA8B,UAAU;AACpF,QAAM,CAAC,UAAU,WAAW,IAAI,SAAS,EAAE;AAI3C,QAAM,CAAC,QAAQ,SAAS,IAAI,SAAS,KAAK;AAC1C,QAAM,EAAE,SAAS,IAAI,IAAI,kBAAkB;AAC3C,QAAM,OAAO,QAAQ,QAAQ,KAAK;AAClC,QAAM,WAAW,OAAyB,IAAI;AAK9C,QAAM,CAAC,MAAM,OAAO,IAAI,SAAS,KAAK;AACtC,QAAM,YAAY,OAAO,KAAK;AAC9B,QAAM,SAAS,MAAM;AACrB,QAAM,SAAS,QAAQ;AAIvB,QAAM,CAAC,SAAS,UAAU,IAAI,SAAS,MAAM;AAC7C,MAAI,YAAY,QAAQ;AACtB,eAAW,MAAM;AACjB,QAAI,CAAC,QAAQ;AACX,kBAAY,EAAE;AACd,gBAAU,KAAK;AACf,cAAQ,KAAK;AAAA,IACf;AAAA,EACF;AAIA,YAAU,MAAM;AACd,QAAI,CAAC,OAAQ;AACb,cAAU,UAAU;AACpB,aAAS,SAAS,MAAM;AAAA,EAC1B,GAAG,CAAC,MAAM,CAAC;AAEX,QAAM,SAAS,MAAM;AACnB,QAAI,QAAQ,aAAa,GAAI;AAC7B,cAAU,KAAK;AACf,QAAI,SAAS,QAAQ,GAAG,MAAM;AAC5B,gBAAU,UAAU;AACpB,cAAQ,IAAI;AAAA,IACd,CAAC;AAAA,EACH;AAIA,QAAM,aAAa,OAAO,KAAK;AAC/B,YAAU,MAAM;AACd,QAAI,WAAW,WAAW,CAAC,WAAW,CAAC,KAAM,UAAS,SAAS,OAAO;AACtE,eAAW,UAAU;AAAA,EACvB,GAAG,CAAC,SAAS,IAAI,CAAC;AAElB,QAAM,aAAa,SAAS,SAAY;AAExC,SACE;AAAA,IAAC;AAAA;AAAA,MACC;AAAA,MACA,OAAO,SAAS,OAAO;AAAA,MACvB,aAAa,eAAe,OAAO;AAAA,MACnC,SAAS,MAAM,QAAQ,UAAU,OAAO;AAAA,MACxC,WAAW,CAAC,MAAqC;AAC/C,YAAI,QAAQ,EAAE,QAAQ,SAAU,GAAE,eAAe;AAAA,MACnD;AAAA,MACA,aAAW,QAAQ;AAAA,MACnB,SACE;AAAA,QAAC;AAAA;AAAA,UACC;AAAA,UACA;AAAA,UACA;AAAA,UACA,WAAW,aAAa;AAAA,UACxB,QAAQ,SAAS;AAAA,UACjB,aAAa,OAAO;AAAA,UACpB,aAAa,eAAe,OAAO;AAAA;AAAA,MACrC;AAAA,MAGD;AAAA;AAAA,QACD;AAAA,UAAC;AAAA;AAAA,YACC,IAAI;AAAA,YACJ,YAAU;AAAA,YACV,UAAU,CAAC,MAAM;AACf,gBAAE,eAAe;AACjB,qBAAO;AAAA,YACT;AAAA,YAWA;AAAA,cAAC;AAAA;AAAA,gBACC,KAAK;AAAA,gBACL,OAAO,OAAO;AAAA,gBACd,OAAO;AAAA,gBACP;AAAA,gBACA,OAAO;AAAA,gBACP,eAAe,CAAC,SAAS;AACvB,8BAAY,IAAI;AAChB,4BAAU,IAAI;AAAA,gBAChB;AAAA;AAAA,YACF;AAAA;AAAA,QACF;AAAA;AAAA;AAAA,EACF;AAEJ;AAOA,SAAS,cAAc;AAAA,EACrB;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,GAQG;AACD,QAAM,QAAQ,WAAW,iBAAiB;AAC1C,YAAU,MAAM;AACd,QAAI,KAAM,SAAQ;AAAA,EACpB,GAAG,CAAC,MAAM,KAAK,CAAC;AAChB,SACE,iCACE;AAAA,wBAAC,UAAO,MAAK,UAAS,SAAQ,SAAQ,UAAU,MAAM,SAAS,MAAM,QAAQ,GAC1E,uBACH;AAAA,IACA;AAAA,MAAC;AAAA;AAAA,QACC,MAAK;AAAA,QACL,MAAM;AAAA,QACN,SAAS,SAAS,WAAW;AAAA,QAC7B,UAAU,CAAC,aAAa;AAAA,QACxB,aAAW,QAAQ;AAAA,QAElB;AAAA,kBAAQ,oBAAC,WAAQ,OAAO,MAAM,WAAU,WAAU;AAAA,UAClD;AAAA;AAAA;AAAA,IACH;AAAA,KACF;AAEJ;","names":[]}
1
+ {"version":3,"sources":["../../src/components/reauth-dialog.tsx"],"sourcesContent":["import { useContext, useEffect, useId, useRef, useState } from \"react\";\nimport type { ReactNode } from \"react\";\n\nimport { useKitLabels, type LabelOverride } from \"../i18n/kit-labels\";\nimport { CurrentPasswordInput, usePromisePending } from \"./danger-confirm\";\nimport { DialogFrame } from \"./dialog-frame\";\nimport { ModalCloseContext } from \"./modal\";\nimport { Button } from \"./button\";\nimport { Spinner } from \"./spinner\";\nimport { DEFAULT_REAUTH_DIALOG_LABELS } from \"./reauth-dialog-labels\";\n\n/**\n * Every string the dialog renders — the `reauthDialog` namespace of\n * `<UiKitProvider labels>`, and overridable per instance through `labels`. The title,\n * description and submit button are usually worded per action (\"Add a passkey\"),\n * which is what the `title` / `description` / `submitLabel` props are for; these are\n * the fallbacks.\n */\nexport interface ReauthDialogLabels {\n title: string;\n description: string;\n /** Label of the password field. */\n password: string;\n submit: string;\n cancel: string;\n}\n\n// Defined in ./reauth-dialog-labels, which holds no component code (0.34, KS U8).\nexport { DEFAULT_REAUTH_DIALOG_LABELS };\n\nexport interface ReauthDialogProps {\n /**\n * Verifies the password — the app's call, e.g. the request the password guards.\n *\n * Return a promise and the dialog manages itself: busy until it settles, closes\n * (`onClose(true)`) when it resolves, STAYS OPEN with the password kept and selected\n * when it rejects — a wrong password is a typo to correct, not a sign-out. Pass the\n * reason as `error`. Return nothing and the app owns the rest: `busy` for the\n * pending state, unmounting the dialog once it is done.\n */\n onSubmit: (password: string) => void | Promise<unknown>;\n /**\n * The dialog has closed: `true` after a resolved `onSubmit`, `false` for Cancel,\n * Escape, the backdrop and Back — none of which closes it while the check runs.\n */\n onClose: (confirmed: boolean) => void;\n /**\n * Why the last attempt failed (\"Wrong password\"), shown under the field, which turns\n * invalid (`aria-invalid`, `--danger-border-strong`). The app decides the wording —\n * a 400 is a wrong password, a 500 is not. Hidden again as soon as the user edits\n * the field (the message is about the password they had typed), and shown again by\n * the next failed submit.\n */\n error?: ReactNode;\n /** The check is running, for a caller that tracks it itself (a mutation's\n * `isPending`); a promise returned from `onSubmit` does the same on its own. */\n busy?: boolean;\n /** Default: `labels.title` — \"Confirm it’s you\". */\n title?: ReactNode;\n /** The line under the title. Default: `labels.description`. */\n description?: ReactNode;\n /** Text of the submit button. Default: `labels.submit` — \"Continue\". */\n submitLabel?: ReactNode;\n /** `\"danger\"` when what the password unlocks cannot be undone: the submit is then the\n * destructive button. Default `\"neutral\"`. */\n tone?: \"neutral\" | \"danger\";\n /** Extra body above the field — what is about to happen, a warning. */\n children?: ReactNode;\n /** Kept-mounted mode, as on `Modal`: left out, the dialog is open while mounted. A\n * flip to `false` animates it out and wipes the password. */\n open?: boolean;\n /** User-facing strings; see {@link ReauthDialogLabels}. */\n labels?: LabelOverride<ReauthDialogLabels>;\n}\n\n/**\n * \"Enter your current password to continue\" — the step-up check in front of an\n * action that changes how an account signs in (adding a passkey) or what it can\n * reach. Kurvenschmiede asked for the password before registering a passkey in a\n * dialog of its own; keksdose asks for it inline in `DangerConfirm`'s\n * `requirePassword` tile. Both are the same three facts — the field is the CURRENT\n * password (the password manager fills it, never generates one), a wrong one stays in\n * the dialog with its error, and the APP verifies it — so the promise handling is\n * `DangerConfirm`'s own, shared, the field is its `CurrentPasswordInput` with an\n * `error`, and this is the modal form of them.\n *\n * A component rather than a `useReauth()` promise: the step that follows the check\n * (Kurvenschmiede's WebAuthn ceremony) needs what the server answered, and the error\n * wording needs the app's own status codes — both already live in the app's mutation,\n * which this dialog only has to be handed (`onSubmit={(pw) => begin.mutateAsync(pw)}`,\n * `error={…}`).\n *\n * The field is a `<form>`, so Enter submits once something is typed. Focus starts in\n * the field and returns to the trigger through `Modal`'s trap. While a check runs,\n * the field is read-only, Cancel is disabled and the dialog is `busy` (Escape, the\n * backdrop and Back are held, DUP-3): a request that has left cannot be cancelled by\n * closing the dialog, and the dialog should not pretend it can.\n */\nexport function ReauthDialog({\n onSubmit,\n onClose,\n error,\n busy: busyProp,\n title,\n description,\n submitLabel,\n tone = \"neutral\",\n children,\n open,\n labels: labelsProp,\n}: ReauthDialogProps) {\n const labels = useKitLabels(\"reauthDialog\", DEFAULT_REAUTH_DIALOG_LABELS, labelsProp);\n const [password, setPassword] = useState(\"\");\n // The error describes the password that was submitted; once the user edits it, it\n // describes nothing on screen. Cleared by the next submit, so a second failure with\n // the same message shows again.\n const [edited, setEdited] = useState(false);\n const { pending, run } = usePromisePending();\n const busy = Boolean(busyProp) || pending;\n const inputRef = useRef<HTMLInputElement>(null);\n // `onSubmit` resolved: the actions row lowers the panel (state, so it re-renders),\n // and `onClose` answers true (a ref, because `Modal` may call the `onClose` it saw\n // BEFORE that re-render — its latest-callback ref is refreshed in an effect that runs\n // after the actions row's).\n const [done, setDone] = useState(false);\n const confirmed = useRef(false);\n const formId = useId();\n const isOpen = open ?? true;\n\n // A close (kept-mounted) wipes the password: it must not sit in a hidden dialog.\n // During render, like DangerConfirm's disarm, so no frame holds it.\n const [wasOpen, setWasOpen] = useState(isOpen);\n if (wasOpen !== isOpen) {\n setWasOpen(isOpen);\n if (!isOpen) {\n setPassword(\"\");\n setEdited(false);\n setDone(false);\n }\n }\n\n // After `Modal`'s own effect (a parent's effects run after its children's), so the\n // trap has recorded the trigger as its restore target before focus moves in.\n useEffect(() => {\n if (!isOpen) return;\n confirmed.current = false;\n inputRef.current?.focus();\n }, [isOpen]);\n\n const submit = () => {\n if (busy || password === \"\") return;\n setEdited(false);\n run(onSubmit(password), () => {\n confirmed.current = true;\n setDone(true);\n });\n };\n\n // A rejection leaves focus where it was (the field, or the submit button) — select\n // the password there so the retry replaces it rather than appending to it.\n const wasPending = useRef(false);\n useEffect(() => {\n if (wasPending.current && !pending && !done) inputRef.current?.select();\n wasPending.current = pending;\n }, [pending, done]);\n\n const shownError = edited ? undefined : error;\n\n return (\n <DialogFrame\n open={open}\n title={title ?? labels.title}\n description={description ?? labels.description}\n onClose={() => onClose(confirmed.current)}\n busy={busy}\n actions={\n <ReauthActions\n formId={formId}\n done={done}\n busy={busy}\n canSubmit={password !== \"\"}\n danger={tone === \"danger\"}\n cancelLabel={labels.cancel}\n submitLabel={submitLabel ?? labels.submit}\n />\n }\n >\n {children}\n <form\n id={formId}\n noValidate\n onSubmit={(e) => {\n e.preventDefault();\n submit();\n }}\n >\n {/* The kit's CurrentPasswordInput (0.23; it drew the same field by hand until\n then): the password manager fills it with the CURRENT password and never\n offers to generate one; `busy` makes it `readOnly`, not `disabled`, because\n disabling the field that has focus drops focus to <body>. Labelled by this\n namespace's `password` (\"Current password\"), not `dangerConfirm.password`.\n The `error` is Input's own: it paints the field, sits under it and is its\n `aria-describedby`; passing the prop on every render keeps the field's box\n steady, so the message going away on the first keystroke of a retry does not\n remount the <input> under the typist. */}\n <CurrentPasswordInput\n ref={inputRef}\n label={labels.password}\n value={password}\n busy={busy}\n error={shownError}\n onValueChange={(next) => {\n setPassword(next);\n setEdited(true);\n }}\n />\n </form>\n </DialogFrame>\n );\n}\n\n/**\n * The actions row. A component of its own because it renders INSIDE the `Modal`, where\n * the panel's animated close is in context: a resolved `onSubmit` lowers the panel\n * through it, the way Cancel and Escape do, and `Modal` then calls `onClose`.\n */\nfunction ReauthActions({\n formId,\n done,\n busy,\n canSubmit,\n danger,\n cancelLabel,\n submitLabel,\n}: {\n formId: string;\n done: boolean;\n busy: boolean;\n canSubmit: boolean;\n danger: boolean;\n cancelLabel: ReactNode;\n submitLabel: ReactNode;\n}) {\n const close = useContext(ModalCloseContext);\n useEffect(() => {\n if (done) close?.();\n }, [done, close]);\n return (\n <>\n <Button type=\"button\" variant=\"ghost\" disabled={busy} onClick={() => close?.()}>\n {cancelLabel}\n </Button>\n <Button\n type=\"submit\"\n form={formId}\n variant={danger ? \"danger\" : \"default\"}\n disabled={!canSubmit || busy}\n aria-busy={busy || undefined}\n >\n {busy && <Spinner label={null} className=\"h-4 w-4\" />}\n {submitLabel}\n </Button>\n </>\n );\n}\n"],"mappings":"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","names":[]}
@@ -80,6 +80,13 @@ export declare function seriesKey(...parts: (string | number)[]): string;
80
80
  * padded only on the sides that are not zero — bars standing on a frame's bottom edge,
81
81
  * not floating four per cent above it on a band of air nobody asked for.
82
82
  * `undefined` when there is nothing to fit.
83
+ *
84
+ * All-zero data (a quiet period, a zoom window over empty months) is `[0, 1]`: one unit
85
+ * on the free side, the unit {@link padBand} pads a zero extent by. d3 maps every value
86
+ * of a zero-height domain to the range's midpoint, so `[0, 0]` stood the bars on a
87
+ * baseline in the middle of the plot, the "0" tick beside it (audit 2026-10-30 DSP-10;
88
+ * keksdose's weekday pattern drew seven 4 px stubs mid-card for a period with no
89
+ * spending).
83
90
  */
84
91
  export declare function anchoredBand(extent: readonly [number, number] | undefined): [number, number] | undefined;
85
92
  /**
@@ -66,6 +66,7 @@ function anchoredBand(extent) {
66
66
  if (!extent) return void 0;
67
67
  const low = Math.min(0, extent[0]);
68
68
  const high = Math.max(0, extent[1]);
69
+ if (low === 0 && high === 0) return [0, 1];
69
70
  const pad = (high - low) * AUTO_PAD || 1;
70
71
  return [low < 0 ? low - pad : 0, high > 0 ? high + pad : 0];
71
72
  }
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/components/series-chart-axes.ts"],"sourcesContent":["// The series chart's pure half: the axis bands, the padded and anchored domains, the\n// series keys and colours, and the abscissa reduced to a number line. No React, no\n// recharts and no JSX here (chart-zoom-core lends the one name `DEFAULT_Y_AXIS`) —\n// arithmetic over rows and axes, which is why apps import some of it in\n// files that draw no chart (Kurvenschmiede's curve-chart-model.ts builds its series\n// keys with `seriesKey`).\n//\n// Moved out of series-chart.tsx as it was (MOD-9, refactor audit 2026-10). The public\n// names are still exported from series-chart.tsx, so `/chart` and the barrel are\n// unchanged; this module is not an entry of its own. The names below that series-chart\n// does not re-export (the tick-band constants, `xModel`, `finite`, …) stay internal.\nimport { DEFAULT_Y_AXIS } from \"./chart-zoom-core\";\nimport type { TimeTickUnit } from \"./series-chart-ticks\";\nimport { paletteFor } from \"../theme/chart-palette\";\nimport type {\n SeriesChartAxis,\n SeriesChartRow,\n SeriesChartSeries,\n SeriesChartTickValues,\n SeriesChartX,\n SeriesChartXValue,\n} from \"./series-chart\";\n\n/** How much of an axis band the rotated title takes, in px at Normal — × the text size's\n * scale on the chart (§3.2), since the title is text. Reserved rather than shared with\n * the ticks: a title merely offset inside the band prints on top of a five-figure tick,\n * and which gives way depends on the data. */\nexport const AXIS_TITLE_STRIP = 16;\n\n/** Where inside its strip the rotated title sits. */\nexport const AXIS_TITLE_OFFSET = 10;\n\n/** The tallest band tilted x ticks reserve, in px at Normal (× the scale). A category\n * name longer than this at its angle is clipped rather than squeezing the plot down to\n * a strip. */\nconst MAX_TILTED_TICK_BAND = 120;\n\n/** What one character of a tick label is taken to be, in px at Normal, for the tilted\n * band — the shell's `text-xs`. An estimate: the SVG is not laid out yet when the band\n * is decided. Both grow with the text size, as `text-xs` does (§3.2). */\nconst TICK_CHAR_WIDTH = 7;\nconst TICK_LINE_HEIGHT = 12;\n/** recharts' gap between the axis line and a bottom tick's text — px, at every size:\n * recharts draws it, not the type. */\nconst TICK_GAP = 8;\n/** recharts' own band under level x ticks. */\nconst LEVEL_TICK_BAND = 30;\n/** The least room recharts' x ticks keep between labels, in px at Normal. */\nexport const MIN_TICK_GAP = 32;\n\n/**\n * The height the x axis reserves for its ticks at a text `scale`: tilted by `angle`\n * degrees, the rotated box of the longest label, from its length; level, recharts' own\n * 30 px grown with the labels. `undefined` for level ticks at Normal, where recharts'\n * default is exactly that.\n */\nexport function tiltedTickBand(labels: readonly string[], angle: number, scale = 1): number | undefined {\n if (!angle) return scale === 1 ? undefined : Math.ceil(LEVEL_TICK_BAND * scale);\n const rad = (Math.abs(angle) * Math.PI) / 180;\n const longest = Math.max(0, ...labels.map((label) => label.length)) * TICK_CHAR_WIDTH * scale;\n const band = Math.sin(rad) * longest + Math.cos(rad) * TICK_LINE_HEIGHT * scale + TICK_GAP;\n return Math.min(\n Math.round(MAX_TILTED_TICK_BAND * scale),\n Math.max(Math.ceil(LEVEL_TICK_BAND * scale), Math.ceil(band)),\n );\n}\n\n/** The width the ticks of a y axis get when a caller does not say, in px at Normal. */\nexport const AXIS_TICK_WIDTH = 48;\n\n/**\n * How much air a FITTED axis leaves between the data and its frame, as a fraction of\n * the range. Recharts fits `[dataMin, dataMax]`, which draws a peak half a stroke\n * outside the plot and a curve running to its limit indistinguishable from the frame.\n */\nconst AUTO_PAD = 0.04;\n\n/** The band a fitted axis draws, padded — and a real band for constant data, which is\n * a zero-height domain recharts cannot scale into. `undefined` when nothing finite. */\nexport function paddedDomain(values: Iterable<number>): [number, number] | undefined {\n let low = Infinity;\n let high = -Infinity;\n for (const value of values) {\n if (!Number.isFinite(value)) continue;\n if (value < low) low = value;\n if (value > high) high = value;\n }\n return padBand(low, high);\n}\n\n/**\n * {@link paddedDomain} for a caller that has already walked its own data — so a row\n * of charts can share one scale without building an array to find two numbers in, and\n * without a second padding rule. `low > high` means \"nothing was seen\": `undefined`.\n */\nexport function padBand(low: number, high: number): [number, number] | undefined {\n if (!(low <= high)) return undefined;\n const pad = (high - low) * AUTO_PAD || Math.abs(high) * AUTO_PAD || 1;\n return [low - pad, high + pad];\n}\n\n/**\n * The whole band a y axis occupies: its ticks, and its title when it draws one. For\n * lining a heading up over a PLOT rather than over its column.\n *\n * `scale` is the text size's (`useTextSize().scale`, §3.2, §10.10): the chart draws every\n * band — the default 48 px and a caller's own `width` alike (keksdose's\n * `MONEY_AXIS_WIDTH`) — this many times wider, because the ticks in it are `text-xs`\n * and grow with the text. A heading lined up over the plot has to pass the same scale.\n * One factor for both sides of a facing pair, so the mirrored bands stay equal.\n */\nexport function axisBandWidth(tickWidth: number | undefined, titled: boolean, scale = 1): number {\n const band = (tickWidth ?? AXIS_TICK_WIDTH) + (titled ? AXIS_TITLE_STRIP : 0);\n return scale === 1 ? band : Math.round(band * scale);\n}\n\n/**\n * The colour an axis TITLE takes, or `undefined` for the neutral one.\n *\n * An axis measuring exactly one line is that line's, and saying so in the title lets a\n * reader find a channel's scale without counting axes. An axis measuring several is\n * nobody's — a title in the first one's colour would be claiming it. Counted off the\n * series, so toggling a second line onto an axis gives the colour up in the same render.\n */\nexport function soleSeriesColor(\n axis: Pick<SeriesChartAxis, \"id\" | \"color\">,\n series: readonly Pick<SeriesChartSeries, \"axis\">[],\n): string | undefined {\n const mine = series.filter((entry) => (entry.axis ?? DEFAULT_Y_AXIS) === axis.id);\n return mine.length === 1 ? axis.color : undefined;\n}\n\n/** The single-axis case, which is most charts. `width` is what the TICKS need;\n * `orientation` is which side they stand on. */\nexport function oneAxis(\n format: ((value: number) => string) | undefined,\n title: string,\n width?: number,\n orientation?: \"left\" | \"right\",\n): SeriesChartAxis[] {\n return [{ id: DEFAULT_Y_AXIS, format, title, width, orientation }];\n}\n\n/** One source's samples: an abscissa and the channels measured against it. */\nexport interface SeriesSource {\n x: number[];\n channels: Record<string, number[]>;\n}\n\n/**\n * Merge sources sampled on different grids into rows the chart can draw.\n *\n * Merged ON THE ABSCISSA VALUE ITSELF, never interpolated onto a common grid: a line\n * through invented points looks exactly like one through measured ones. Where a source\n * has no sample the key is absent, and `connectNulls` bridges the hole with the same\n * straight segment an interpolation would have drawn — without claiming a measurement.\n */\nexport function mergeSeries(sources: SeriesSource[]): Record<string, number>[] {\n const byAbscissa = new Map<number, Record<string, number>>();\n for (const source of sources) {\n source.x.forEach((value, index) => {\n const row = byAbscissa.get(value) ?? { x: value };\n for (const [key, channel] of Object.entries(source.channels)) row[key] = channel[index];\n byAbscissa.set(value, row);\n });\n }\n return [...byAbscissa.values()].sort((a, b) => a.x - b.x);\n}\n\n/**\n * A series key built out of parts — as a CSS identifier.\n *\n * The chart shell paints each series through `--color-<key>`, and skips (with a\n * console warning) any key that is not `[A-Za-z_][A-Za-z0-9_-]*`. A line whose colour\n * was skipped has no stroke at all: an axis, a grid and nothing between them. So a key\n * assembled out of DATA — an id, an enum, a side — goes through here: every other\n * character becomes `_`, the parts are joined with `_`, and a key that would start with\n * a digit or a hyphen gets a leading `_`.\n */\nexport function seriesKey(...parts: (string | number)[]): string {\n const key = parts.map((part) => String(part).replace(/[^A-Za-z0-9_-]/g, \"_\")).join(\"_\");\n return /^[A-Za-z_]/.test(key) ? key : `_${key}`;\n}\n\n/**\n * The band an axis carrying bars or areas draws: the data's extent with ZERO in it,\n * padded only on the sides that are not zero — bars standing on a frame's bottom edge,\n * not floating four per cent above it on a band of air nobody asked for.\n * `undefined` when there is nothing to fit.\n */\nexport function anchoredBand(extent: readonly [number, number] | undefined): [number, number] | undefined {\n if (!extent) return undefined;\n const low = Math.min(0, extent[0]);\n const high = Math.max(0, extent[1]);\n const pad = (high - low) * AUTO_PAD || 1;\n return [low < 0 ? low - pad : 0, high > 0 ? high + pad : 0];\n}\n\n/**\n * Series with their colours settled BEFORE any are switched off.\n *\n * A series with no `color` takes `paletteFor(its position)` — and a legend toggle that\n * hands the chart a shorter list moves every later series one position up, repainting\n * it in its neighbour's colour on each click. Resolving the colours on the full list\n * and filtering after keeps each series in the colour its legend entry shows.\n */\nexport function visibleSeries(series: readonly SeriesChartSeries[], hidden: ReadonlySet<string>): SeriesChartSeries[] {\n return series\n .map((entry, index) => ({ ...entry, color: entry.color ?? paletteFor(index) }))\n .filter((entry) => !hidden.has(entry.key));\n}\n\n/** Where a category or time chart keeps each row's plotted position. Not the caller's\n * key: that one still holds their own value, which `format` and a hit hand back. */\nconst PLOTTED_X = \"__seriesChartX\";\n\n/** A finite number, or `undefined` — the only thing a row cell counts as a sample. */\nexport const finite = (value: unknown): number | undefined =>\n typeof value === \"number\" && Number.isFinite(value) ? value : undefined;\n\n/** A time value as epoch ms. A date-only ISO string is LOCAL midnight (see\n * `SeriesChartX.type`); anything unreadable is `undefined`, i.e. not plotted. */\nfunction toTime(value: unknown): number | undefined {\n if (value instanceof Date) return finite(value.getTime());\n if (typeof value === \"number\") return finite(value);\n if (typeof value !== \"string\") return undefined;\n const day = /^(\\d{4})-(\\d{2})(?:-(\\d{2}))?$/.exec(value);\n if (day) return new Date(Number(day[1]), Number(day[2]) - 1, Number(day[3] ?? 1)).getTime();\n return finite(Date.parse(value));\n}\n\n/** A category's identity, so a Date matches another Date of the same instant. */\nconst categoryId = (value: unknown) =>\n value instanceof Date ? `d:${value.getTime()}` : `${typeof value}:${String(value)}`;\n\n/**\n * The abscissa, reduced to a number line — which is all the plot, the ticks and the\n * zoom ever deal in. A number stays itself; a category becomes its slot index; a time\n * its epoch ms. Everything the caller sees (ticks, tooltip, hits) is turned back.\n */\nexport interface XModel {\n kind: \"number\" | \"category\" | \"time\";\n /** The caller's key, still holding their value. */\n sourceKey: string;\n /** The key the plot reads. The same as `sourceKey` on a number line. */\n plotKey: string;\n /** The rows as plotted. The caller's own on a number line. */\n rows: readonly SeriesChartRow[];\n /** The caller's rows, which dots and hits hand back. */\n source: readonly SeriesChartRow[];\n position: (value: unknown) => number | undefined;\n valueAt: (position: number) => SeriesChartXValue;\n}\n\nexport function xModel(rows: readonly SeriesChartRow[], x: SeriesChartX): XModel {\n const sourceKey = x.key ?? \"x\";\n if (x.type === \"category\") {\n const slots = new Map<string, number>();\n rows.forEach((row, index) => {\n const id = categoryId(row[sourceKey]);\n if (!slots.has(id)) slots.set(id, index);\n });\n return {\n kind: \"category\",\n sourceKey,\n plotKey: PLOTTED_X,\n rows: rows.map((row, index) => ({ ...row, [PLOTTED_X]: index })),\n source: rows,\n position: (value) => slots.get(categoryId(value)),\n valueAt: (position) => rows[Math.round(position)]?.[sourceKey] as SeriesChartXValue,\n };\n }\n if (x.type === \"time\") {\n return {\n kind: \"time\",\n sourceKey,\n plotKey: PLOTTED_X,\n rows: rows.map((row) => ({ ...row, [PLOTTED_X]: toTime(row[sourceKey]) })),\n source: rows,\n position: toTime,\n valueAt: (position) => new Date(position),\n };\n }\n return {\n kind: \"number\",\n sourceKey,\n plotKey: sourceKey,\n rows,\n source: rows,\n position: finite,\n valueAt: (position) => position,\n };\n}\n\n/** Caller ticks, as positions inside the domain on show. */\nexport function resolveTicks<T>(\n values: SeriesChartTickValues<T> | undefined,\n domain: [number, number] | undefined,\n position: (value: T) => number | undefined,\n): number[] | undefined {\n if (!values || !domain) return undefined;\n const list = typeof values === \"function\" ? values(domain) : values;\n if (!list) return undefined;\n const slack = (domain[1] - domain[0]) * 1e-9;\n return list\n .map(position)\n .filter((at): at is number => at !== undefined && at >= domain[0] - slack && at <= domain[1] + slack);\n}\n\n/** A low/high pair widened by some values, or `undefined` if there is still none. */\nexport function extend(\n span: readonly [number, number] | undefined,\n values: readonly (number | undefined)[],\n): [number, number] | undefined {\n let out: [number, number] | undefined = span ? [span[0], span[1]] : undefined;\n for (const value of values) {\n if (value === undefined || !Number.isFinite(value)) continue;\n out = out ? [Math.min(out[0], value), Math.max(out[1], value)] : [value, value];\n }\n return out;\n}\n\n/** How a time tick is written, by what one step of the ticks is. */\nexport const TIME_TICK_FORMAT: Record<TimeTickUnit, Intl.DateTimeFormatOptions> = {\n hour: { hour: \"2-digit\", minute: \"2-digit\" },\n day: { day: \"numeric\", month: \"short\" },\n month: { month: \"short\", year: \"2-digit\" },\n year: { year: \"numeric\" },\n};\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../src/components/series-chart-axes.ts"],"sourcesContent":["// The series chart's pure half: the axis bands, the padded and anchored domains, the\n// series keys and colours, and the abscissa reduced to a number line. No React, no\n// recharts and no JSX here (chart-zoom-core lends the one name `DEFAULT_Y_AXIS`) —\n// arithmetic over rows and axes, which is why apps import some of it in\n// files that draw no chart (Kurvenschmiede's curve-chart-model.ts builds its series\n// keys with `seriesKey`).\n//\n// Moved out of series-chart.tsx as it was (MOD-9, refactor audit 2026-10). The public\n// names are still exported from series-chart.tsx, so `/chart` and the barrel are\n// unchanged; this module is not an entry of its own. The names below that series-chart\n// does not re-export (the tick-band constants, `xModel`, `finite`, …) stay internal.\nimport { DEFAULT_Y_AXIS } from \"./chart-zoom-core\";\nimport type { TimeTickUnit } from \"./series-chart-ticks\";\nimport { paletteFor } from \"../theme/chart-palette\";\nimport type {\n SeriesChartAxis,\n SeriesChartRow,\n SeriesChartSeries,\n SeriesChartTickValues,\n SeriesChartX,\n SeriesChartXValue,\n} from \"./series-chart\";\n\n/** How much of an axis band the rotated title takes, in px at Normal — × the text size's\n * scale on the chart (§3.2), since the title is text. Reserved rather than shared with\n * the ticks: a title merely offset inside the band prints on top of a five-figure tick,\n * and which gives way depends on the data. */\nexport const AXIS_TITLE_STRIP = 16;\n\n/** Where inside its strip the rotated title sits. */\nexport const AXIS_TITLE_OFFSET = 10;\n\n/** The tallest band tilted x ticks reserve, in px at Normal (× the scale). A category\n * name longer than this at its angle is clipped rather than squeezing the plot down to\n * a strip. */\nconst MAX_TILTED_TICK_BAND = 120;\n\n/** What one character of a tick label is taken to be, in px at Normal, for the tilted\n * band — the shell's `text-xs`. An estimate: the SVG is not laid out yet when the band\n * is decided. Both grow with the text size, as `text-xs` does (§3.2). */\nconst TICK_CHAR_WIDTH = 7;\nconst TICK_LINE_HEIGHT = 12;\n/** recharts' gap between the axis line and a bottom tick's text — px, at every size:\n * recharts draws it, not the type. */\nconst TICK_GAP = 8;\n/** recharts' own band under level x ticks. */\nconst LEVEL_TICK_BAND = 30;\n/** The least room recharts' x ticks keep between labels, in px at Normal. */\nexport const MIN_TICK_GAP = 32;\n\n/**\n * The height the x axis reserves for its ticks at a text `scale`: tilted by `angle`\n * degrees, the rotated box of the longest label, from its length; level, recharts' own\n * 30 px grown with the labels. `undefined` for level ticks at Normal, where recharts'\n * default is exactly that.\n */\nexport function tiltedTickBand(labels: readonly string[], angle: number, scale = 1): number | undefined {\n if (!angle) return scale === 1 ? undefined : Math.ceil(LEVEL_TICK_BAND * scale);\n const rad = (Math.abs(angle) * Math.PI) / 180;\n const longest = Math.max(0, ...labels.map((label) => label.length)) * TICK_CHAR_WIDTH * scale;\n const band = Math.sin(rad) * longest + Math.cos(rad) * TICK_LINE_HEIGHT * scale + TICK_GAP;\n return Math.min(\n Math.round(MAX_TILTED_TICK_BAND * scale),\n Math.max(Math.ceil(LEVEL_TICK_BAND * scale), Math.ceil(band)),\n );\n}\n\n/** The width the ticks of a y axis get when a caller does not say, in px at Normal. */\nexport const AXIS_TICK_WIDTH = 48;\n\n/**\n * How much air a FITTED axis leaves between the data and its frame, as a fraction of\n * the range. Recharts fits `[dataMin, dataMax]`, which draws a peak half a stroke\n * outside the plot and a curve running to its limit indistinguishable from the frame.\n */\nconst AUTO_PAD = 0.04;\n\n/** The band a fitted axis draws, padded — and a real band for constant data, which is\n * a zero-height domain recharts cannot scale into. `undefined` when nothing finite. */\nexport function paddedDomain(values: Iterable<number>): [number, number] | undefined {\n let low = Infinity;\n let high = -Infinity;\n for (const value of values) {\n if (!Number.isFinite(value)) continue;\n if (value < low) low = value;\n if (value > high) high = value;\n }\n return padBand(low, high);\n}\n\n/**\n * {@link paddedDomain} for a caller that has already walked its own data — so a row\n * of charts can share one scale without building an array to find two numbers in, and\n * without a second padding rule. `low > high` means \"nothing was seen\": `undefined`.\n */\nexport function padBand(low: number, high: number): [number, number] | undefined {\n if (!(low <= high)) return undefined;\n const pad = (high - low) * AUTO_PAD || Math.abs(high) * AUTO_PAD || 1;\n return [low - pad, high + pad];\n}\n\n/**\n * The whole band a y axis occupies: its ticks, and its title when it draws one. For\n * lining a heading up over a PLOT rather than over its column.\n *\n * `scale` is the text size's (`useTextSize().scale`, §3.2, §10.10): the chart draws every\n * band — the default 48 px and a caller's own `width` alike (keksdose's\n * `MONEY_AXIS_WIDTH`) — this many times wider, because the ticks in it are `text-xs`\n * and grow with the text. A heading lined up over the plot has to pass the same scale.\n * One factor for both sides of a facing pair, so the mirrored bands stay equal.\n */\nexport function axisBandWidth(tickWidth: number | undefined, titled: boolean, scale = 1): number {\n const band = (tickWidth ?? AXIS_TICK_WIDTH) + (titled ? AXIS_TITLE_STRIP : 0);\n return scale === 1 ? band : Math.round(band * scale);\n}\n\n/**\n * The colour an axis TITLE takes, or `undefined` for the neutral one.\n *\n * An axis measuring exactly one line is that line's, and saying so in the title lets a\n * reader find a channel's scale without counting axes. An axis measuring several is\n * nobody's — a title in the first one's colour would be claiming it. Counted off the\n * series, so toggling a second line onto an axis gives the colour up in the same render.\n */\nexport function soleSeriesColor(\n axis: Pick<SeriesChartAxis, \"id\" | \"color\">,\n series: readonly Pick<SeriesChartSeries, \"axis\">[],\n): string | undefined {\n const mine = series.filter((entry) => (entry.axis ?? DEFAULT_Y_AXIS) === axis.id);\n return mine.length === 1 ? axis.color : undefined;\n}\n\n/** The single-axis case, which is most charts. `width` is what the TICKS need;\n * `orientation` is which side they stand on. */\nexport function oneAxis(\n format: ((value: number) => string) | undefined,\n title: string,\n width?: number,\n orientation?: \"left\" | \"right\",\n): SeriesChartAxis[] {\n return [{ id: DEFAULT_Y_AXIS, format, title, width, orientation }];\n}\n\n/** One source's samples: an abscissa and the channels measured against it. */\nexport interface SeriesSource {\n x: number[];\n channels: Record<string, number[]>;\n}\n\n/**\n * Merge sources sampled on different grids into rows the chart can draw.\n *\n * Merged ON THE ABSCISSA VALUE ITSELF, never interpolated onto a common grid: a line\n * through invented points looks exactly like one through measured ones. Where a source\n * has no sample the key is absent, and `connectNulls` bridges the hole with the same\n * straight segment an interpolation would have drawn — without claiming a measurement.\n */\nexport function mergeSeries(sources: SeriesSource[]): Record<string, number>[] {\n const byAbscissa = new Map<number, Record<string, number>>();\n for (const source of sources) {\n source.x.forEach((value, index) => {\n const row = byAbscissa.get(value) ?? { x: value };\n for (const [key, channel] of Object.entries(source.channels)) row[key] = channel[index];\n byAbscissa.set(value, row);\n });\n }\n return [...byAbscissa.values()].sort((a, b) => a.x - b.x);\n}\n\n/**\n * A series key built out of parts — as a CSS identifier.\n *\n * The chart shell paints each series through `--color-<key>`, and skips (with a\n * console warning) any key that is not `[A-Za-z_][A-Za-z0-9_-]*`. A line whose colour\n * was skipped has no stroke at all: an axis, a grid and nothing between them. So a key\n * assembled out of DATA — an id, an enum, a side — goes through here: every other\n * character becomes `_`, the parts are joined with `_`, and a key that would start with\n * a digit or a hyphen gets a leading `_`.\n */\nexport function seriesKey(...parts: (string | number)[]): string {\n const key = parts.map((part) => String(part).replace(/[^A-Za-z0-9_-]/g, \"_\")).join(\"_\");\n return /^[A-Za-z_]/.test(key) ? key : `_${key}`;\n}\n\n/**\n * The band an axis carrying bars or areas draws: the data's extent with ZERO in it,\n * padded only on the sides that are not zero — bars standing on a frame's bottom edge,\n * not floating four per cent above it on a band of air nobody asked for.\n * `undefined` when there is nothing to fit.\n *\n * All-zero data (a quiet period, a zoom window over empty months) is `[0, 1]`: one unit\n * on the free side, the unit {@link padBand} pads a zero extent by. d3 maps every value\n * of a zero-height domain to the range's midpoint, so `[0, 0]` stood the bars on a\n * baseline in the middle of the plot, the \"0\" tick beside it (audit 2026-10-30 DSP-10;\n * keksdose's weekday pattern drew seven 4 px stubs mid-card for a period with no\n * spending).\n */\nexport function anchoredBand(extent: readonly [number, number] | undefined): [number, number] | undefined {\n if (!extent) return undefined;\n const low = Math.min(0, extent[0]);\n const high = Math.max(0, extent[1]);\n if (low === 0 && high === 0) return [0, 1];\n const pad = (high - low) * AUTO_PAD || 1;\n return [low < 0 ? low - pad : 0, high > 0 ? high + pad : 0];\n}\n\n/**\n * Series with their colours settled BEFORE any are switched off.\n *\n * A series with no `color` takes `paletteFor(its position)` — and a legend toggle that\n * hands the chart a shorter list moves every later series one position up, repainting\n * it in its neighbour's colour on each click. Resolving the colours on the full list\n * and filtering after keeps each series in the colour its legend entry shows.\n */\nexport function visibleSeries(series: readonly SeriesChartSeries[], hidden: ReadonlySet<string>): SeriesChartSeries[] {\n return series\n .map((entry, index) => ({ ...entry, color: entry.color ?? paletteFor(index) }))\n .filter((entry) => !hidden.has(entry.key));\n}\n\n/** Where a category or time chart keeps each row's plotted position. Not the caller's\n * key: that one still holds their own value, which `format` and a hit hand back. */\nconst PLOTTED_X = \"__seriesChartX\";\n\n/** A finite number, or `undefined` — the only thing a row cell counts as a sample. */\nexport const finite = (value: unknown): number | undefined =>\n typeof value === \"number\" && Number.isFinite(value) ? value : undefined;\n\n/** A time value as epoch ms. A date-only ISO string is LOCAL midnight (see\n * `SeriesChartX.type`); anything unreadable is `undefined`, i.e. not plotted. */\nfunction toTime(value: unknown): number | undefined {\n if (value instanceof Date) return finite(value.getTime());\n if (typeof value === \"number\") return finite(value);\n if (typeof value !== \"string\") return undefined;\n const day = /^(\\d{4})-(\\d{2})(?:-(\\d{2}))?$/.exec(value);\n if (day) return new Date(Number(day[1]), Number(day[2]) - 1, Number(day[3] ?? 1)).getTime();\n return finite(Date.parse(value));\n}\n\n/** A category's identity, so a Date matches another Date of the same instant. */\nconst categoryId = (value: unknown) =>\n value instanceof Date ? `d:${value.getTime()}` : `${typeof value}:${String(value)}`;\n\n/**\n * The abscissa, reduced to a number line — which is all the plot, the ticks and the\n * zoom ever deal in. A number stays itself; a category becomes its slot index; a time\n * its epoch ms. Everything the caller sees (ticks, tooltip, hits) is turned back.\n */\nexport interface XModel {\n kind: \"number\" | \"category\" | \"time\";\n /** The caller's key, still holding their value. */\n sourceKey: string;\n /** The key the plot reads. The same as `sourceKey` on a number line. */\n plotKey: string;\n /** The rows as plotted. The caller's own on a number line. */\n rows: readonly SeriesChartRow[];\n /** The caller's rows, which dots and hits hand back. */\n source: readonly SeriesChartRow[];\n position: (value: unknown) => number | undefined;\n valueAt: (position: number) => SeriesChartXValue;\n}\n\nexport function xModel(rows: readonly SeriesChartRow[], x: SeriesChartX): XModel {\n const sourceKey = x.key ?? \"x\";\n if (x.type === \"category\") {\n const slots = new Map<string, number>();\n rows.forEach((row, index) => {\n const id = categoryId(row[sourceKey]);\n if (!slots.has(id)) slots.set(id, index);\n });\n return {\n kind: \"category\",\n sourceKey,\n plotKey: PLOTTED_X,\n rows: rows.map((row, index) => ({ ...row, [PLOTTED_X]: index })),\n source: rows,\n position: (value) => slots.get(categoryId(value)),\n valueAt: (position) => rows[Math.round(position)]?.[sourceKey] as SeriesChartXValue,\n };\n }\n if (x.type === \"time\") {\n return {\n kind: \"time\",\n sourceKey,\n plotKey: PLOTTED_X,\n rows: rows.map((row) => ({ ...row, [PLOTTED_X]: toTime(row[sourceKey]) })),\n source: rows,\n position: toTime,\n valueAt: (position) => new Date(position),\n };\n }\n return {\n kind: \"number\",\n sourceKey,\n plotKey: sourceKey,\n rows,\n source: rows,\n position: finite,\n valueAt: (position) => position,\n };\n}\n\n/** Caller ticks, as positions inside the domain on show. */\nexport function resolveTicks<T>(\n values: SeriesChartTickValues<T> | undefined,\n domain: [number, number] | undefined,\n position: (value: T) => number | undefined,\n): number[] | undefined {\n if (!values || !domain) return undefined;\n const list = typeof values === \"function\" ? values(domain) : values;\n if (!list) return undefined;\n const slack = (domain[1] - domain[0]) * 1e-9;\n return list\n .map(position)\n .filter((at): at is number => at !== undefined && at >= domain[0] - slack && at <= domain[1] + slack);\n}\n\n/** A low/high pair widened by some values, or `undefined` if there is still none. */\nexport function extend(\n span: readonly [number, number] | undefined,\n values: readonly (number | undefined)[],\n): [number, number] | undefined {\n let out: [number, number] | undefined = span ? [span[0], span[1]] : undefined;\n for (const value of values) {\n if (value === undefined || !Number.isFinite(value)) continue;\n out = out ? [Math.min(out[0], value), Math.max(out[1], value)] : [value, value];\n }\n return out;\n}\n\n/** How a time tick is written, by what one step of the ticks is. */\nexport const TIME_TICK_FORMAT: Record<TimeTickUnit, Intl.DateTimeFormatOptions> = {\n hour: { hour: \"2-digit\", minute: \"2-digit\" },\n day: { day: \"numeric\", month: \"short\" },\n month: { month: \"short\", year: \"2-digit\" },\n year: { year: \"numeric\" },\n};\n"],"mappings":"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","names":[]}
@@ -3,7 +3,7 @@ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
3
3
  import { useLayoutEffect, useMemo, useRef } from "react";
4
4
  import { ChevronLeft, ChevronRight, Plus, X } from "lucide-react";
5
5
  import { cn } from "../lib/cn.js";
6
- import { FOCUS_RING_WIDTH } from "./focus-ring.js";
6
+ import { FOCUS_RING } from "./focus-ring.js";
7
7
  import { scrollIntoStrip, useStripFade } from "../lib/strip-fade.js";
8
8
  import { horizontalStep } from "../lib/direction.js";
9
9
  import { usePhoneLayout } from "../hooks/use-breakpoint.js";
@@ -15,15 +15,15 @@ import { IconButton } from "./icon-button.js";
15
15
  import { HIT_AREA_LARGE } from "./button-look.js";
16
16
  const TABLIST_CLASSES = "flex gap-1 overflow-x-auto overflow-y-hidden border-b border-subtle";
17
17
  const TABLIST_VERTICAL_CLASSES = "flex flex-col gap-0.5";
18
- const TAB_VERTICAL_CLASSES = FOCUS_RING_WIDTH + " flex w-full items-center rounded-md px-3 py-2 text-start text-sm font-medium transition-colors focus:outline-none focus-visible:ring-strong";
18
+ const TAB_VERTICAL_CLASSES = `flex w-full items-center rounded-md px-3 py-2 text-start text-sm font-medium transition-colors focus:outline-none ${FOCUS_RING}`;
19
19
  const TAB_VERTICAL_ACTIVE_CLASSES = "bg-active text-primary";
20
20
  const TAB_VERTICAL_INACTIVE_CLASSES = "text-muted hover:bg-hover hover:text-primary";
21
- const TAB_CLASSES = FOCUS_RING_WIDTH + " max-w-full whitespace-nowrap px-3 py-2 text-sm font-medium transition-colors border-b-2 -mb-px focus:outline-none focus-visible:ring-strong";
21
+ const TAB_CLASSES = `max-w-full whitespace-nowrap px-3 py-2 text-sm font-medium transition-colors border-b-2 -mb-px focus:outline-none ${FOCUS_RING} focus-visible:ring-inset`;
22
22
  const TAB_ACTIVE_CLASSES = "border-[var(--text-primary)] text-primary";
23
23
  const TAB_INACTIVE_CLASSES = "border-transparent text-muted hover:text-secondary hover:border-strong";
24
24
  const TAB_REMOVABLE_CLASSES = "pe-8";
25
25
  const TAB_EMPTY_CLASSES = "-mx-1.5 rounded border border-dashed border-strong px-1.5";
26
- const TAB_ADD_CLASSES = FOCUS_RING_WIDTH + " inline-flex items-center gap-1 whitespace-nowrap rounded-md px-3 py-2 text-sm font-medium text-muted transition-colors hover:text-primary focus:outline-none focus-visible:ring-strong disabled:cursor-not-allowed disabled:opacity-50 [&_svg]:size-4";
26
+ const TAB_ADD_CLASSES = `inline-flex items-center gap-1 whitespace-nowrap rounded-md px-3 py-2 text-sm font-medium text-muted transition-colors hover:text-primary focus:outline-none ${FOCUS_RING} disabled:cursor-not-allowed disabled:opacity-50 [&_svg]:size-4`;
27
27
  const TAB_ADD_PHONE_CLASSES = "relative max-md:px-2";
28
28
  function Tabs({
29
29
  tabs,
@@ -52,6 +52,7 @@ function Tabs({
52
52
  const cue = overflowCue && !vertical;
53
53
  const cueStart = cue && (fade.overflow === "start" || fade.overflow === "both");
54
54
  const cueEnd = cue && (fade.overflow === "end" || fade.overflow === "both");
55
+ const gutters = cue && fade.overflow !== void 0;
55
56
  const onCue = (side) => {
56
57
  const strip = stripRef.current;
57
58
  if (!strip) return;
@@ -93,10 +94,11 @@ function Tabs({
93
94
  const strip = stripRef.current;
94
95
  const tab = strip?.querySelector('[role="tab"][aria-selected="true"]');
95
96
  if (strip && tab) scrollIntoStrip(strip, tab);
96
- }, [active]);
97
+ }, [active, gutters]);
97
98
  const isRemovable = (tab) => onRemove !== void 0 && tab.removable !== false;
98
99
  const deletesOnKey = (tab) => isRemovable(tab) && (removeOn === "every" || tab.id === active);
99
100
  const nameOf = (tab) => tab.name ?? (typeof tab.label === "string" ? tab.label : tab.id);
101
+ const stop = tabs.some((t) => t.id === active) ? active : tabs[0]?.id;
100
102
  const pendingFocus = useRef(null);
101
103
  const removeRef = useRef(null);
102
104
  const requestRemove = (id) => {
@@ -116,10 +118,11 @@ function Tabs({
116
118
  if (!strip) return;
117
119
  const focused = document.activeElement;
118
120
  if (focused && focused !== document.body && !strip.contains(focused) && focused !== removeRef.current) return;
119
- const target = !pending.wasActive && tabs.some((t) => t.id === pending.neighbour) ? pending.neighbour : active;
121
+ const present = (id) => tabs.some((t) => t.id === id);
122
+ const target = pending.wasActive && present(active) ? active : present(pending.neighbour) ? pending.neighbour : stop;
120
123
  const index = tabs.findIndex((t) => t.id === target);
121
124
  strip.querySelectorAll('[role="tab"]')[index]?.focus();
122
- }, [tabs, active]);
125
+ }, [tabs, active, stop]);
123
126
  const onTabKeyDown = (e, tab) => {
124
127
  if (e.key === "Delete" && deletesOnKey(tab)) {
125
128
  e.preventDefault();
@@ -168,8 +171,8 @@ function Tabs({
168
171
  // strip back on the next visit in a state they never chose. The cost is that
169
172
  // Tab out and back returns to the open tab rather than to the last one
170
173
  // looked at; the ARIA pattern allows either, and this one needs no second
171
- // piece of state shadowing `active`.
172
- tabIndex: isActive ? 0 : -1,
174
+ // piece of state shadowing `active`. With nothing open, the first tab (`stop`).
175
+ tabIndex: tab.id === stop ? 0 : -1,
173
176
  // Only the open tab: see `panelId`.
174
177
  "aria-controls": isActive ? panelId : void 0,
175
178
  id: isActive && panelId ? `${panelId}-tab` : void 0,
@@ -268,7 +271,6 @@ function Tabs({
268
271
  children: text.remove(nameOf(activeTab))
269
272
  }
270
273
  ) : null;
271
- const gutters = cue && fade.overflow !== void 0;
272
274
  const stripBox = cue ? /* @__PURE__ */ jsxs("div", { className: cn("relative flex min-w-0", !onAdd && "flex-1", gutters && "px-[24px]"), children: [
273
275
  tablist,
274
276
  gutters && gutterAt("start", cueStart),
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/components/tabs.tsx"],"sourcesContent":["import { useLayoutEffect, useMemo, useRef } from \"react\";\nimport { ChevronLeft, ChevronRight, Plus, X } from \"lucide-react\";\nimport type { ComponentPropsWithoutRef, KeyboardEvent, ReactNode, Ref } from \"react\";\nimport { cn } from \"../lib/cn\";\nimport { FOCUS_RING_WIDTH } from \"./focus-ring\";\nimport { scrollIntoStrip, useStripFade } from \"../lib/strip-fade\";\nimport { horizontalStep } from \"../lib/direction\";\nimport { usePhoneLayout } from \"../hooks/use-breakpoint\";\nimport { prefersReducedMotion } from \"../lib/reduced-motion\";\nimport { useKitLabels, type LabelOverride } from \"../i18n/kit-labels\";\nimport { mergeRefs } from \"./field-parts\";\nimport { DEFAULT_TABS_LABELS } from \"./ui-labels\";\nimport { IconButton } from \"./icon-button\";\nimport { HIT_AREA_LARGE } from \"./button-look\";\n\n/** One tab of a {@link Tabs} strip. */\nexport interface TabItem<T extends string> {\n id: T;\n /** A ReactNode so a tab can pair an icon with text. */\n label: ReactNode;\n /** Optional trailing node (e.g. a count pill). */\n badge?: ReactNode;\n /** Marks a tab that IS a route — see {@link TabsProps}. */\n href?: string;\n /** Leading decoration: an icon, or the colour swatches of the lines this tab\n * draws in a chart above the strip. Hidden from assistive tech — it repeats\n * what the label says, or it says something the label must say instead. */\n icon?: ReactNode;\n /** A second, smaller line under the label — the one fact that tells two tabs\n * with similar labels apart (\"80 km/h\", \"Level 3\"). Part of the tab's name. */\n detail?: ReactNode;\n /** A tab that exists as a slot with nothing in it yet, drawn with a dashed\n * outline so a gap in a sequence looks like a gap. Visual only: say \"empty\" in\n * the label or `detail` if a screen reader needs to know. */\n empty?: boolean;\n /** `false` keeps this one tab when the strip has `onRemove` — the one sheet a\n * workbook cannot be without. Default: removable. */\n removable?: boolean;\n /** Plain-text name for the labels the strip composes about this tab (\"Remove\n * …\"), for when `label` is not a string. Default: `label` if it is a string,\n * else the id. */\n name?: string;\n}\n\n/** The strings the strip renders on its own behalf — only when it can add or\n * remove tabs. */\nexport interface TabsLabels {\n /** The \"add\" button's visible text when the caller gives no `addLabel`. */\n add: string;\n /** Accessible name of a tab's × — \"Remove <tab>\". */\n remove: (tab: string) => string;\n /** The `overflowCue` › after the strip's end, while tabs lie past it: \"More tabs\". */\n more: string;\n /** The `overflowCue` ‹ before the strip's start, while tabs lie before it: \"Previous tabs\". */\n previous: string;\n}\n\n// Defined in ./ui-labels, which holds no component code (0.34, KS U8).\nexport { DEFAULT_TABS_LABELS };\n\n/**\n * The strip's own props sit on a `<div>`: the tablist IS the root element, so anything\n * a caller hangs on it — a `data-tour` anchor for the kit's guided tour, a test id, an\n * `aria-describedby` — lands there. `onChange` is omitted from the div's props because\n * this component's `onChange` hands over the chosen TAB ID, not a DOM event, and\n * `children` because the strip renders `tabs` — a caller who wants a PANEL wires it up\n * through `panelId`, which is the whole point of that prop.\n */\nexport interface TabsProps<T extends string> extends Omit<ComponentPropsWithoutRef<\"div\">, \"onChange\" | \"children\"> {\n /** `label` is a ReactNode so tabs can pair an icon with text; `badge` is an\n * optional trailing node (e.g. a count pill). `href` marks a tab that IS a route:\n * it renders as an anchor so it can be middle-/⌘-clicked into a new tab (Keksdose\n * feedback #451), while a plain click still goes through `onChange` and stays\n * client-side. Tabs that only flip local state leave it unset — a link to a URL\n * that does not select the tab would be worse than no link. */\n tabs: TabItem<T>[];\n active: T;\n onChange: (id: T) => void;\n className?: string;\n /**\n * Carets at the ends of a SCROLLING strip (0.40, kk run 79 gap 1; 0.47, keksdose\n * live #410 round 4): a › after the strip's end while tabs lie past it, and a ‹ before\n * its start while tabs lie before it — each there only while something is hidden\n * past ITS edge (1 px slack, so a few cut-off pixels count), each a step of three\n * quarters of the strip's width toward its own side. Neither ever changes direction:\n * until 0.46 one caret turned round into \"back to the first\" at the end, and read as\n * an arrow pointing the wrong way. Start and end follow the reading direction.\n *\n * The edge fade alone could read as the end of the row: the last visible tab can\n * finish exactly where the fade begins, so nothing looks cut off (keksdose live #410).\n * Since 0.48 (live #410 rounds 5–6) the carets are bare glyphs BESIDE the strip — the ‹\n * before it, the › after it — centred on the labels, muted and primary on hover, with\n * no frame, fill or shadow; until 0.47 they were framed buttons laid over the strip's\n * last visible tabs. While the strip overflows at all it keeps a 24px gutter at both\n * ends, and each caret sits in its own, so scrolling never changes the strip's width\n * or moves its labels; a strip that fits has no gutters. Out of the tab order —\n * the keyboard walks the strip with the arrow keys, which bring the focused tab into\n * view. At every width. Ignored on a vertical strip (a side nav is a column, not a\n * row). Names: `tabs.more` / `tabs.previous`. keksdose's `scroll-edge-cues.tsx` is\n * the reference this replaces.\n */\n overflowCue?: boolean;\n /**\n * `id` of the element the caller renders the open tab's content into — the missing\n * half of `role=\"tab\"`. A tab that controls nothing is a tab in name only: a screen\n * reader announces \"tab, 2 of 5\" and then has no way to take the user to what it\n * opened, and no way back.\n *\n * **This component does not render the panel, on purpose.** `Tabs` is the STRIP; the\n * content lives wherever the caller put it — three Keksdose pages render it in a\n * sibling `<Card>`, one renders it through a router outlet, and a tab can BE a route\n * (`href`), in which case the panel is a whole page this component never sees.\n * Wrapping `children` here would mean either moving that content into the strip's\n * subtree — a layout change on every page that uses tabs — or shipping a wrapper\n * that only some callers could use. So the two halves are joined by an id instead.\n *\n * Wire the other end yourself:\n *\n * ```tsx\n * <Tabs tabs={tabs} active={active} onChange={setActive} panelId=\"report-panel\" />\n * <div id=\"report-panel\" role=\"tabpanel\" aria-labelledby=\"report-panel-tab\" tabIndex={-1}>\n * ```\n *\n * `aria-controls` goes on the OPEN tab only, and `${panelId}-tab` is its id — the\n * closed tabs' panels are not in the document, and a dangling `aria-controls`\n * describes a tab as opening something that is not there.\n */\n panelId?: string;\n /**\n * Makes the tabs removable: an × on the OPEN tab, and Delete on a focused tab\n * (only on the open one with `removeOn=\"active\"`).\n *\n * The × is on the open tab only — a row of tabs each carrying one is a row of\n * things to hit by accident, and the tab you can remove should be the one you\n * are looking at — but its room is reserved on every removable tab, so opening a\n * tab never widens it and never pushes the rest of the strip along (lenkbank\n * feedback #72). It is not a tab stop of its own: the strip stays one stop, and the\n * keyboard path is Delete (announced through `aria-keyshortcuts`), which removes\n * whichever tab has focus (see `removeOn`).\n *\n * Assistive tech meets the remove as a \"Remove <tab>\" button right AFTER the strip,\n * not as the × (0.34): a tablist may own tabs only, so the drawn × is the pointer's\n * and `aria-hidden`, and the named button beside the tablist is visually hidden.\n *\n * The strip only ASKS: it calls this, and the tab goes when the caller drops it\n * from `tabs` — after a confirmation, a request, whatever it needs. When it does,\n * focus that was on the removed tab (or its ×) moves to the open tab if the\n * removed one was open, else to its neighbour, instead of falling to the page.\n */\n onRemove?: (id: T) => void;\n /**\n * Which tabs the Delete key removes.\n *\n * - `\"every\"` (default): any focused removable tab — the ARIA pattern's reading.\n * - `\"active\"`: only the OPEN tab, the same one that wears the ×. For a strip whose\n * remove is immediate and unconfirmed (lenkbank's sheet tabs delete a measurement\n * sheet at once), so arrowing across the strip and pressing Delete cannot take\n * out a sheet the user is not looking at. Delete on a closed tab does nothing,\n * and only the open tab announces the shortcut (`aria-keyshortcuts`).\n *\n * Either way the × is drawn on the open tab only and its room stays reserved on\n * every removable tab, so the strip never relayouts when the selection moves.\n */\n removeOn?: \"every\" | \"active\";\n /** Renders an \"add\" button after the last tab, outside the tablist — it is an\n * action, not a tab, and a tablist may own only tabs. */\n onAdd?: () => void;\n /** The add button's text. Say what is added — \"Add loop\", \"Add report\" says\n * more than \"Add\". Default: `tabs.add` from the {@link UiKitProvider}. */\n addLabel?: string;\n /** Overrides for this strip's strings. See {@link TabsLabels}. */\n labels?: LabelOverride<TabsLabels>;\n /** An add or a remove is in flight: the × and the add button are disabled and\n * Delete is ignored, so a double click cannot remove two tabs. Nothing moves. */\n busy?: boolean;\n /**\n * `\"vertical\"`: the same strip as a SIDE NAV — one item per row, the open one\n * filled, ↑/↓ walking it (Home/End as before) and `aria-orientation=\"vertical\"` on\n * the tablist. For keksdose's admin page and settings page, which each hand-built\n * the identical sidebar with none of this strip's keyboard, roving tab stop or\n * routed-`href` handling. Everything else — `href` tabs, `badge`, `panelId`,\n * `onRemove`, `onAdd` — means exactly what it means on the horizontal strip; the\n * badge moves to the row's end.\n *\n * **On a phone (`usePhoneLayout()`) it becomes the horizontal strip**, rather than\n * staying a column. A side nav only works beside its content; on\n * a phone it has to go ABOVE it, and eight full-width rows there push the panel\n * the user picked below the fold on every visit — the two keksdose pages both\n * collapsed theirs into a scrolling row by hand for exactly that reason. The\n * orientation is switched in JavaScript, not with `md:` classes, so the\n * `aria-orientation` a screen reader hears and the arrow keys that work always\n * match the layout on screen. The caller's own two-column layout has to stack at\n * the same breakpoint (`md:grid-cols-[14rem_1fr]`).\n */\n orientation?: \"horizontal\" | \"vertical\";\n /** The `role=\"tablist\"` strip, where the rest of the props go (0.34, API-2, refactor\n * audit 2026-10). Merged with the strip's own ref, which scrolls the open tab into\n * view and measures the fade, so neither loses the element. */\n ref?: Ref<HTMLDivElement>;\n}\n\nconst TABLIST_CLASSES = \"flex gap-1 overflow-x-auto overflow-y-hidden border-b border-subtle\";\n// The side nav. A column of full-width rows with the open one FILLED rather than\n// underlined: an underline under one row of a list reads as a separator, not as a\n// selection. `--bg-active` is the kit's own selected-row fill (the sidebar's), so the\n// admin page's nav and the app's own sidebar mark \"you are here\" the same way.\nconst TABLIST_VERTICAL_CLASSES = \"flex flex-col gap-0.5\";\nconst TAB_VERTICAL_CLASSES =\n FOCUS_RING_WIDTH +\n \" flex w-full items-center rounded-md px-3 py-2 text-start text-sm font-medium transition-colors focus:outline-none focus-visible:ring-strong\";\nconst TAB_VERTICAL_ACTIVE_CLASSES = \"bg-active text-primary\";\nconst TAB_VERTICAL_INACTIVE_CLASSES = \"text-muted hover:bg-hover hover:text-primary\";\n\n// `max-w-full`: no tab outgrows its strip. A sheet's `detail` line (\"Spurstangenkraft\n// links · … · 31 Punkte\") made one tab 448px wide in a 390px strip, and a tab wider\n// than the strip cannot be scrolled into view — its end was simply off screen\n// (lenkbank L3). The label and detail truncate inside it instead; see `inner`.\nconst TAB_CLASSES =\n FOCUS_RING_WIDTH +\n \" max-w-full whitespace-nowrap px-3 py-2 text-sm font-medium transition-colors border-b-2 -mb-px focus:outline-none focus-visible:ring-strong\";\nconst TAB_ACTIVE_CLASSES = \"border-[var(--text-primary)] text-primary\";\nconst TAB_INACTIVE_CLASSES = \"border-transparent text-muted hover:text-secondary hover:border-strong\";\n\n// The add/remove chrome. The × reserves its gutter on every removable tab (see\n// `onRemove`).\nconst TAB_REMOVABLE_CLASSES = \"pe-8\";\nconst TAB_EMPTY_CLASSES = \"-mx-1.5 rounded border border-dashed border-strong px-1.5\";\nconst TAB_ADD_CLASSES =\n FOCUS_RING_WIDTH +\n \" inline-flex items-center gap-1 whitespace-nowrap rounded-md px-3 py-2 text-sm font-medium text-muted transition-colors hover:text-primary focus:outline-none focus-visible:ring-strong disabled:cursor-not-allowed disabled:opacity-50 [&_svg]:size-4\";\n// `relative`: the phone's visually hidden label needs a positioned ancestor (see the\n// sr-only rule in CONTRIBUTING); `max-md:px-2` squares the lone \"+\".\nconst TAB_ADD_PHONE_CLASSES = \"relative max-md:px-2\";\n\nexport function Tabs<T extends string>({\n tabs,\n active,\n onChange,\n className,\n overflowCue = false,\n panelId,\n onRemove,\n removeOn = \"every\",\n onAdd,\n addLabel,\n labels,\n busy = false,\n orientation = \"horizontal\",\n \"aria-label\": ariaLabel,\n ref,\n ...rest\n}: TabsProps<T>) {\n const text = useKitLabels(\"tabs\", DEFAULT_TABS_LABELS, labels);\n // See `orientation`: a vertical strip is the horizontal one on a phone.\n const phone = usePhoneLayout();\n const vertical = orientation === \"vertical\" && !phone;\n const stripRef = useRef<HTMLDivElement>(null);\n const setStrip = useMemo(() => mergeRefs(stripRef, ref), [ref]);\n const fade = useStripFade(stripRef);\n // See `overflowCue`: a ‹ before the strip while tabs lie before it, a › after it\n // while tabs lie past it — `fade.overflow` names the cut ends in reading-direction terms.\n const cue = overflowCue && !vertical;\n const cueStart = cue && (fade.overflow === \"start\" || fade.overflow === \"both\");\n const cueEnd = cue && (fade.overflow === \"end\" || fade.overflow === \"both\");\n // Three quarters of the visible width toward `side`; an RTL strip runs leftward.\n const onCue = (side: \"start\" | \"end\") => {\n const strip = stripRef.current;\n if (!strip) return;\n const behavior: ScrollBehavior = prefersReducedMotion() ? \"auto\" : \"smooth\";\n const rtl = getComputedStyle(strip).direction === \"rtl\";\n const toward = (side === \"end\" ? 1 : -1) * (rtl ? -1 : 1);\n strip.scrollBy({ left: toward * Math.round(strip.clientWidth * 0.75), behavior });\n };\n // In a GUTTER of its own at the strip's end (0.48, keksdose live #410 rounds 5–6): a\n // bare glyph — no frame, fill or shadow — muted until the pointer comes. Round 5 put\n // each cue in the flow beside the strip, so it took 24px the moment it appeared and the\n // labels jumped sideways at every edge the scroll crossed (\"It has problems with moving\n // to left and right especially when moving the scroll bar\"). Now the strip box keeps a\n // 24px gutter at BOTH ends for as long as the strip overflows at all (`px-[24px]`, see\n // `stripBox`), and each gutter is absolute in that padding, so a cue coming or going\n // never moves the strip, its labels or the scroll position under the thumb. A strip\n // that fits has no gutters: its width changes only when it starts or stops overflowing.\n // The gutter carries the strip's 1px rule on beneath the glyph, and lifts the glyph\n // onto the LABELS' centre rather than the box's: a tab's 2px underline (`border-b-2`)\n // sits on the rule (`-mb-px`), so the labels are centred 2px above the box's bottom —\n // the gutter's 1px rule (`border-b`) plus 1px more (`pb-px`).\n // 24px wide at every text size (KS N3): at Large `sm`'s 48px minimum made each cue\n // 48px, and at 360px the two left a two-tab strip 100px. 24 PIXELS, not `w-6`: the rem\n // grows with the text size (24 / 30 / 36px), and at Extra large on a 360px phone the two\n // gutters took 72px of a 226px strip and a sheet tab wrapped to seven lines (0.48 build\n // review 2, KS N1). The 48px touch target is an\n // invisible hit area round the glyph instead (`HIT_AREA_LARGE`, as `2xs`), over the\n // strip's faded edge, not room taken from the row. The wrapper inside the gutter is\n // ours because IconButton puts its tooltip's span around the button.\n const gutterAt = (side: \"start\" | \"end\", shown: boolean) => (\n <div\n data-tabs-cue-gutter={side}\n className={cn(\n \"absolute inset-y-0 flex w-[24px] items-center justify-center border-b border-subtle pb-px\",\n side === \"start\" ? \"start-0\" : \"end-0\",\n )}\n >\n {shown && (\n <div data-tabs-overflow-cue={side} className=\"flex\">\n <IconButton\n size=\"sm\"\n variant=\"ghost\"\n className={cn(\n \"w-[24px] large:min-w-0 bg-transparent text-muted hover:bg-transparent hover:text-primary\",\n // Over the strip's faded edge, for ‹ too.\n HIT_AREA_LARGE,\n \"large:after:z-[1]\",\n )}\n tabIndex={-1}\n type=\"button\"\n label={side === \"start\" ? text.previous : text.more}\n labelVisible={false}\n onClick={() => onCue(side)}\n >\n {side === \"start\" ? (\n <ChevronLeft className=\"rtl:-scale-x-100\" />\n ) : (\n <ChevronRight className=\"rtl:-scale-x-100\" />\n )}\n </IconButton>\n </div>\n )}\n </div>\n );\n // The OPEN tab is scrolled into the strip whenever it changes — on mount too, for a\n // deep link to the seventh report. A selection behind the cut edge was the worst\n // case of the overflow above: the one tab the panel below belongs to, sliced to\n // \"80 km,\" under the add button. Only the strip scrolls (`scrollBy` on it, not\n // `scrollIntoView`, which would scroll the page as well), and only as far as needed\n // to clear the fade.\n useLayoutEffect(() => {\n const strip = stripRef.current;\n const tab = strip?.querySelector<HTMLElement>('[role=\"tab\"][aria-selected=\"true\"]');\n if (strip && tab) scrollIntoStrip(strip, tab);\n }, [active]);\n const isRemovable = (tab: TabItem<T>) => onRemove !== undefined && tab.removable !== false;\n // Whether Delete reaches this tab: every removable tab, or only the open one.\n const deletesOnKey = (tab: TabItem<T>) => isRemovable(tab) && (removeOn === \"every\" || tab.id === active);\n const nameOf = (tab: TabItem<T>) => tab.name ?? (typeof tab.label === \"string\" ? tab.label : tab.id);\n\n // Where focus goes once a removal the strip asked for has happened. The caller\n // owns `tabs` and may take its time (a confirmation, a request), so this waits\n // for the id to actually leave the list rather than guessing at the moment.\n const pendingFocus = useRef<{ removed: T; wasActive: boolean; neighbour?: T } | null>(null);\n const removeRef = useRef<HTMLButtonElement>(null);\n const requestRemove = (id: T) => {\n const index = tabs.findIndex((t) => t.id === id);\n pendingFocus.current = {\n removed: id,\n wasActive: id === active,\n neighbour: (tabs[index + 1] ?? tabs[index - 1])?.id,\n };\n onRemove?.(id);\n };\n useLayoutEffect(() => {\n const pending = pendingFocus.current;\n if (!pending || tabs.some((t) => t.id === pending.removed)) return;\n pendingFocus.current = null;\n const strip = stripRef.current;\n if (!strip) return;\n // Only focus that fell out of the strip with the removed tab is ours to place —\n // or that sits on the remove button after it, which a pick removed through.\n // If the user has moved on — into the panel, into a dialog — leave it there.\n const focused = document.activeElement;\n if (focused && focused !== document.body && !strip.contains(focused) && focused !== removeRef.current) return;\n // The removed tab was open: focus follows the caller's new selection, which is\n // also the strip's tab stop. It was not: its neighbour, as the ARIA pattern has it.\n const target = !pending.wasActive && tabs.some((t) => t.id === pending.neighbour) ? pending.neighbour : active;\n const index = tabs.findIndex((t) => t.id === target);\n strip.querySelectorAll<HTMLElement>('[role=\"tab\"]')[index]?.focus();\n }, [tabs, active]);\n\n // Arrow keys walk the strip in DOM order (ARIA tabs pattern). Home/End jump\n // to the ends. Focus only — activation stays on click/Enter/Space, because a tab\n // here can be a real route and moving focus must not navigate.\n //\n // On the TABS, not on the tablist. The container carried it, which left a `<div>`\n // wearing an interactive role and a key handler while being unfocusable —\n // `jsx-a11y/interactive-supports-focus`, and the only instance of it left in `src/`.\n // The fix that rule wants is `tabIndex` on the div; the fix the pattern wants is\n // the handler on the elements that are natively focusable and actually receive the\n // keystroke. Those are the tabs, so the warning goes away by being right rather\n // than by being satisfied.\n const onTabKeyDown = (e: KeyboardEvent<HTMLElement>, tab: TabItem<T>) => {\n // Delete removes the FOCUSED tab (the ARIA pattern's optional key for deletable\n // tabs) — the keyboard's way to the × that is not a tab stop. `busy` swallows it\n // rather than letting it fall through to the page. With `removeOn=\"active\"` only\n // the open tab answers; a closed one lets the key pass untouched.\n if (e.key === \"Delete\" && deletesOnKey(tab)) {\n e.preventDefault();\n if (!busy) requestRemove(tab.id);\n return;\n }\n // Along the reading direction: the strip is a flex row, so in RTL the NEXT tab sits\n // to the left, and ArrowLeft has to reach it. A vertical strip answers ↑/↓ instead\n // — and ONLY those, as the pattern has it: ←/→ are left to the page, where a side\n // nav's neighbour (the panel) may well want them.\n const step: 1 | -1 | 0 = vertical\n ? e.key === \"ArrowDown\"\n ? 1\n : e.key === \"ArrowUp\"\n ? -1\n : 0\n : horizontalStep(e.key, e.currentTarget);\n if (step === 0 && e.key !== \"Home\" && e.key !== \"End\") return;\n const strip = e.currentTarget.closest('[role=\"tablist\"]');\n if (!strip) return;\n const items = Array.from(strip.querySelectorAll<HTMLElement>('[role=\"tab\"]'));\n const from = items.indexOf(e.currentTarget);\n if (from === -1) return;\n e.preventDefault();\n const to = e.key === \"Home\" ? 0 : e.key === \"End\" ? items.length - 1 : (from + step + items.length) % items.length;\n items[to]?.focus();\n };\n const tablist = (\n <div\n // Before the role and the name: a caller's arbitrary attribute is welcome on the\n // strip, but a `role` or an `aria-label` arriving through a spread props object\n // must not be able to unmake the tablist the tabs below are registered against.\n {...rest}\n ref={setStrip}\n role=\"tablist\"\n // Names the GROUP — every tab carries its own text — so a strip under a visible\n // heading that already says it can leave it unset.\n aria-label={ariaLabel}\n // Only when vertical: `horizontal` is the tablist's implicit value, and the\n // attribute on every existing strip would be noise in every snapshot of them.\n aria-orientation={vertical ? \"vertical\" : undefined}\n // Merged over a caller's own style rather than replacing it; see useStripFade.\n // `data-overflow` names the cut ends in reading-direction terms, for a caller's\n // own affordance (a scroll button) and for tests, which cannot read the mask.\n style={fade.mask ? { ...rest.style, maskImage: fade.mask, WebkitMaskImage: fade.mask } : rest.style}\n data-overflow={fade.overflow}\n // With an add button the strip gains an outer box, and `className` goes there\n // — it is the box a caller's margin or width is meant for.\n className={cn(\n vertical ? TABLIST_VERTICAL_CLASSES : TABLIST_CLASSES,\n cue ? \"min-w-0 flex-1\" : onAdd ? \"min-w-0\" : className,\n )}\n >\n {tabs.map((tab) => {\n const isActive = tab.id === active;\n const removable = isRemovable(tab);\n // Written once and worn by either tag below, so a routed tab and a\n // state-only tab stay indistinguishable to the eye and to a screen reader.\n const shared = {\n role: \"tab\" as const,\n \"aria-selected\": isActive,\n // Roving tabindex: the whole strip is ONE stop in the page's tab order, and\n // it is the open tab. Ten report tabs otherwise cost ten Tab presses to step\n // over on the way to the table below them (live #262's strip is the extreme\n // case, but every strip in the app paid it).\n //\n // The stop follows the SELECTION, not the focus. Arrowing is a look around —\n // activation here is manual, because a tab can be a real route — so re-\n // pointing the stop at a report the user only arrowed past would hand the\n // strip back on the next visit in a state they never chose. The cost is that\n // Tab out and back returns to the open tab rather than to the last one\n // looked at; the ARIA pattern allows either, and this one needs no second\n // piece of state shadowing `active`.\n tabIndex: isActive ? 0 : -1,\n // Only the open tab: see `panelId`.\n \"aria-controls\": isActive ? panelId : undefined,\n id: isActive && panelId ? `${panelId}-tab` : undefined,\n \"aria-keyshortcuts\": deletesOnKey(tab) ? \"Delete\" : undefined,\n onKeyDown: (e: KeyboardEvent<HTMLElement>) => onTabKeyDown(e, tab),\n className: vertical\n ? cn(\n TAB_VERTICAL_CLASSES,\n isActive ? TAB_VERTICAL_ACTIVE_CLASSES : TAB_VERTICAL_INACTIVE_CLASSES,\n removable && \"pe-8\",\n )\n : cn(TAB_CLASSES, isActive ? TAB_ACTIVE_CLASSES : TAB_INACTIVE_CLASSES, removable && TAB_REMOVABLE_CLASSES),\n };\n const inner = (\n <span\n className={cn(\n // In a side nav the row is the full width, so the label takes it and a\n // badge lands at the row's end, where a column of counts lines up.\n vertical\n ? \"flex min-w-0 flex-1 items-center gap-1.5\"\n : \"inline-flex max-w-full min-w-0 items-center gap-1.5\",\n tab.empty && TAB_EMPTY_CLASSES,\n )}\n >\n {tab.icon != null && (\n <span aria-hidden className=\"inline-flex shrink-0 items-center gap-0.5\">\n {tab.icon}\n </span>\n )}\n {tab.detail != null ? (\n // `min-w-0` + `truncate-until-large` on each line: capped by the tab's\n // `max-w-full`, a long detail ends in an ellipsis at Normal and wraps from\n // Large up (text size §10.17; round 0.34 T1), where the tab grows a line\n // rather than cut the words. The whole text stays the tab's name.\n <span className=\"flex min-w-0 flex-col items-start text-start\">\n <span className=\"max-w-full truncate-until-large\">{tab.label}</span>\n {/* A separator for the NAME only — whitespace between flex items is\n not drawn, and without it the two lines ran together into one\n word for a screen reader (\"80 km/hloop 2\"). */}{\" \"}\n <span className=\"max-w-full truncate-until-large text-caption font-normal leading-tight text-muted\">\n {tab.detail}\n </span>\n </span>\n ) : (\n tab.label\n )}\n {tab.badge != null ? (\n vertical ? (\n <span className=\"ms-auto flex shrink-0 items-center\">{tab.badge}</span>\n ) : (\n tab.badge\n )\n ) : null}\n </span>\n );\n const element = tab.href ? (\n <a\n key={tab.id}\n {...shared}\n href={tab.href}\n draggable={false}\n onClick={(e) => {\n // Middle-, ⌘/Ctrl-, Shift- and Alt-click belong to the browser\n // (feedback #451); only the plain left click is ours to cancel and\n // route through `onChange`, which keeps the switch client-side.\n if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;\n e.preventDefault();\n onChange(tab.id);\n }}\n // A <button> tab activated on Space; an anchor does not, so becoming a\n // link (feedback #451) would have quietly cost keyboard users the tab\n // strip. Enter is deliberately untouched — it is the anchor's own\n // default and already arrives at `onChange` through the click handler\n // above, so handling it here too would switch tabs twice. Same three\n // lines the mobile row card carries, for the same reason; the rejected\n // alternative — keeping a button and faking the middle click with\n // window.open — is what the whole item is moving away from.\n //\n // Composed with the strip's arrow-key walk rather than replacing it: this\n // prop overrides the one in `shared`, and an anchor tab that lost the\n // arrows would be a strip that is navigable only where it is not a link.\n onKeyDown={(e) => {\n onTabKeyDown(e, tab);\n if (e.defaultPrevented || e.key !== \" \") return;\n e.preventDefault();\n onChange(tab.id);\n }}\n >\n {inner}\n </a>\n ) : (\n <button key={tab.id} {...shared} onClick={() => onChange(tab.id)}>\n {inner}\n </button>\n );\n if (!removable) return element;\n return (\n // The × cannot go INSIDE the tab: a button in a button is invalid HTML, and\n // a tab's children are presentational, so a reader would flatten it into the\n // tab's name. It sits beside the tab, over the gutter the tab reserves.\n <div key={tab.id} className={cn(\"relative flex max-w-full\", !vertical && \"shrink-0\")}>\n {element}\n {isActive && (\n <IconButton\n size=\"2xs\"\n tone=\"danger\"\n // The POINTER's ×, hidden from assistive tech (round 0.34, the showcase's\n // axe check): a tablist may own tabs only, and this button sat in it. The\n // named \"Remove <tab>\" a screen reader's cursor and a touch reader's swipe\n // reach is `removeButton`, after the strip. Not a tab stop either way:\n // the strip is one, and Delete is the keyboard's path.\n aria-hidden\n data-tab-remove=\"\"\n tabIndex={-1}\n type=\"button\"\n disabled={busy}\n onClick={() => requestRemove(tab.id)}\n className=\"absolute inset-y-0 end-1 my-auto\"\n >\n <X />\n </IconButton>\n )}\n </div>\n );\n })}\n </div>\n );\n // The open tab's remove action as assistive tech meets it: right after the strip,\n // OUTSIDE the tablist, which may own tabs only (round 0.34, the showcase's axe check).\n // Visually hidden, since the × drawn on the tab is the pointer's, and not a tab stop,\n // since Delete is the keyboard's. `sr-only-fixed`: it sits wherever the caller put the\n // strip, with no positioned box of the kit's around it.\n const activeTab = tabs.find((tab) => tab.id === active);\n const removeButton =\n activeTab && isRemovable(activeTab) ? (\n <button\n ref={removeRef}\n type=\"button\"\n tabIndex={-1}\n disabled={busy}\n onClick={() => requestRemove(activeTab.id)}\n className=\"sr-only-fixed\"\n >\n {text.remove(nameOf(activeTab))}\n </button>\n ) : null;\n // The box the cues sit in, ‹ in the gutter before the strip and › in the one after it\n // (see `gutterAt`). There whether or not the strip overflows right now, so a resize\n // never re-parents it; the gutters only while it overflows at all.\n const gutters = cue && fade.overflow !== undefined;\n const stripBox = cue ? (\n <div className={cn(\"relative flex min-w-0\", !onAdd && \"flex-1\", gutters && \"px-[24px]\")}>\n {tablist}\n {gutters && gutterAt(\"start\", cueStart)}\n {gutters && gutterAt(\"end\", cueEnd)}\n </div>\n ) : (\n tablist\n );\n if (!onAdd && cue) {\n // `className` goes on the row around the strip — it is the box a caller's margin or\n // width is meant for, as with an add button — and stays there at every scroll position.\n return (\n <div className={cn(\"flex items-end\", className)}>\n {stripBox}\n {removeButton}\n </div>\n );\n }\n if (!onAdd) {\n // No outer box: the strip stays the root, as callers' `className` and tests expect.\n return removeButton ? (\n <>\n {tablist}\n {removeButton}\n </>\n ) : (\n tablist\n );\n }\n if (vertical) {\n // Under the column, not beside it, and with no rule to carry on: a side nav has none.\n return (\n <div className={cn(\"flex flex-col gap-0.5\", className)}>\n {tablist}\n {removeButton}\n <button type=\"button\" onClick={onAdd} disabled={busy} className={TAB_ADD_CLASSES}>\n <Plus aria-hidden />\n {addLabel ?? text.add}\n </button>\n </div>\n );\n }\n return (\n <div className={cn(\"flex items-end\", className)}>\n {stripBox}\n {removeButton}\n {/* Outside the tablist: an action, not a tab — and a tablist may own only\n tabs. The rule under the strip carries on beneath it. */}\n <div className=\"flex flex-1 items-center self-stretch border-b border-subtle\">\n <button type=\"button\" onClick={onAdd} disabled={busy} className={cn(TAB_ADD_CLASSES, TAB_ADD_PHONE_CLASSES)}>\n <Plus aria-hidden />\n {/* Below md a scrolling strip keeps the room for tabs: the label is visually\n hidden and the \"+\" carries it as its name. Beside \"+ Add loop\" a 390px strip\n showed the open tab and a sliver of the next (showcase audit). */}\n <span className=\"max-md:sr-only\">{addLabel ?? text.add}</span>\n </button>\n </div>\n </div>\n );\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../src/components/tabs.tsx"],"sourcesContent":["import { useLayoutEffect, useMemo, useRef } from \"react\";\nimport { ChevronLeft, ChevronRight, Plus, X } from \"lucide-react\";\nimport type { ComponentPropsWithoutRef, KeyboardEvent, ReactNode, Ref } from \"react\";\nimport { cn } from \"../lib/cn\";\nimport { FOCUS_RING } from \"./focus-ring\";\nimport { scrollIntoStrip, useStripFade } from \"../lib/strip-fade\";\nimport { horizontalStep } from \"../lib/direction\";\nimport { usePhoneLayout } from \"../hooks/use-breakpoint\";\nimport { prefersReducedMotion } from \"../lib/reduced-motion\";\nimport { useKitLabels, type LabelOverride } from \"../i18n/kit-labels\";\nimport { mergeRefs } from \"./field-parts\";\nimport { DEFAULT_TABS_LABELS } from \"./ui-labels\";\nimport { IconButton } from \"./icon-button\";\nimport { HIT_AREA_LARGE } from \"./button-look\";\n\n/** One tab of a {@link Tabs} strip. */\nexport interface TabItem<T extends string> {\n id: T;\n /** A ReactNode so a tab can pair an icon with text. */\n label: ReactNode;\n /** Optional trailing node (e.g. a count pill). */\n badge?: ReactNode;\n /** Marks a tab that IS a route — see {@link TabsProps}. */\n href?: string;\n /** Leading decoration: an icon, or the colour swatches of the lines this tab\n * draws in a chart above the strip. Hidden from assistive tech — it repeats\n * what the label says, or it says something the label must say instead. */\n icon?: ReactNode;\n /** A second, smaller line under the label — the one fact that tells two tabs\n * with similar labels apart (\"80 km/h\", \"Level 3\"). Part of the tab's name. */\n detail?: ReactNode;\n /** A tab that exists as a slot with nothing in it yet, drawn with a dashed\n * outline so a gap in a sequence looks like a gap. Visual only: say \"empty\" in\n * the label or `detail` if a screen reader needs to know. */\n empty?: boolean;\n /** `false` keeps this one tab when the strip has `onRemove` — the one sheet a\n * workbook cannot be without. Default: removable. */\n removable?: boolean;\n /** Plain-text name for the labels the strip composes about this tab (\"Remove\n * …\"), for when `label` is not a string. Default: `label` if it is a string,\n * else the id. */\n name?: string;\n}\n\n/** The strings the strip renders on its own behalf — only when it can add or\n * remove tabs. */\nexport interface TabsLabels {\n /** The \"add\" button's visible text when the caller gives no `addLabel`. */\n add: string;\n /** Accessible name of a tab's × — \"Remove <tab>\". */\n remove: (tab: string) => string;\n /** The `overflowCue` › after the strip's end, while tabs lie past it: \"More tabs\". */\n more: string;\n /** The `overflowCue` ‹ before the strip's start, while tabs lie before it: \"Previous tabs\". */\n previous: string;\n}\n\n// Defined in ./ui-labels, which holds no component code (0.34, KS U8).\nexport { DEFAULT_TABS_LABELS };\n\n/**\n * The strip's own props sit on a `<div>`: the tablist IS the root element, so anything\n * a caller hangs on it — a `data-tour` anchor for the kit's guided tour, a test id, an\n * `aria-describedby` — lands there. `onChange` is omitted from the div's props because\n * this component's `onChange` hands over the chosen TAB ID, not a DOM event, and\n * `children` because the strip renders `tabs` — a caller who wants a PANEL wires it up\n * through `panelId`, which is the whole point of that prop.\n */\nexport interface TabsProps<T extends string> extends Omit<ComponentPropsWithoutRef<\"div\">, \"onChange\" | \"children\"> {\n /** `label` is a ReactNode so tabs can pair an icon with text; `badge` is an\n * optional trailing node (e.g. a count pill). `href` marks a tab that IS a route:\n * it renders as an anchor so it can be middle-/⌘-clicked into a new tab (Keksdose\n * feedback #451), while a plain click still goes through `onChange` and stays\n * client-side. Tabs that only flip local state leave it unset — a link to a URL\n * that does not select the tab would be worse than no link. */\n tabs: TabItem<T>[];\n active: T;\n onChange: (id: T) => void;\n className?: string;\n /**\n * Carets at the ends of a SCROLLING strip (0.40, kk run 79 gap 1; 0.47, keksdose\n * live #410 round 4): a › after the strip's end while tabs lie past it, and a ‹ before\n * its start while tabs lie before it — each there only while something is hidden\n * past ITS edge (1 px slack, so a few cut-off pixels count), each a step of three\n * quarters of the strip's width toward its own side. Neither ever changes direction:\n * until 0.46 one caret turned round into \"back to the first\" at the end, and read as\n * an arrow pointing the wrong way. Start and end follow the reading direction.\n *\n * The edge fade alone could read as the end of the row: the last visible tab can\n * finish exactly where the fade begins, so nothing looks cut off (keksdose live #410).\n * Since 0.48 (live #410 rounds 5–6) the carets are bare glyphs BESIDE the strip — the ‹\n * before it, the › after it — centred on the labels, muted and primary on hover, with\n * no frame, fill or shadow; until 0.47 they were framed buttons laid over the strip's\n * last visible tabs. While the strip overflows at all it keeps a 24px gutter at both\n * ends, and each caret sits in its own, so scrolling never changes the strip's width\n * or moves its labels; a strip that fits has no gutters. Out of the tab order —\n * the keyboard walks the strip with the arrow keys, which bring the focused tab into\n * view. At every width. Ignored on a vertical strip (a side nav is a column, not a\n * row). Names: `tabs.more` / `tabs.previous`. keksdose's `scroll-edge-cues.tsx` is\n * the reference this replaces.\n */\n overflowCue?: boolean;\n /**\n * `id` of the element the caller renders the open tab's content into — the missing\n * half of `role=\"tab\"`. A tab that controls nothing is a tab in name only: a screen\n * reader announces \"tab, 2 of 5\" and then has no way to take the user to what it\n * opened, and no way back.\n *\n * **This component does not render the panel, on purpose.** `Tabs` is the STRIP; the\n * content lives wherever the caller put it — three Keksdose pages render it in a\n * sibling `<Card>`, one renders it through a router outlet, and a tab can BE a route\n * (`href`), in which case the panel is a whole page this component never sees.\n * Wrapping `children` here would mean either moving that content into the strip's\n * subtree — a layout change on every page that uses tabs — or shipping a wrapper\n * that only some callers could use. So the two halves are joined by an id instead.\n *\n * Wire the other end yourself:\n *\n * ```tsx\n * <Tabs tabs={tabs} active={active} onChange={setActive} panelId=\"report-panel\" />\n * <div id=\"report-panel\" role=\"tabpanel\" aria-labelledby=\"report-panel-tab\" tabIndex={-1}>\n * ```\n *\n * `aria-controls` goes on the OPEN tab only, and `${panelId}-tab` is its id — the\n * closed tabs' panels are not in the document, and a dangling `aria-controls`\n * describes a tab as opening something that is not there.\n */\n panelId?: string;\n /**\n * Makes the tabs removable: an × on the OPEN tab, and Delete on a focused tab\n * (only on the open one with `removeOn=\"active\"`).\n *\n * The × is on the open tab only — a row of tabs each carrying one is a row of\n * things to hit by accident, and the tab you can remove should be the one you\n * are looking at — but its room is reserved on every removable tab, so opening a\n * tab never widens it and never pushes the rest of the strip along (lenkbank\n * feedback #72). It is not a tab stop of its own: the strip stays one stop, and the\n * keyboard path is Delete (announced through `aria-keyshortcuts`), which removes\n * whichever tab has focus (see `removeOn`).\n *\n * Assistive tech meets the remove as a \"Remove <tab>\" button right AFTER the strip,\n * not as the × (0.34): a tablist may own tabs only, so the drawn × is the pointer's\n * and `aria-hidden`, and the named button beside the tablist is visually hidden.\n *\n * The strip only ASKS: it calls this, and the tab goes when the caller drops it\n * from `tabs` — after a confirmation, a request, whatever it needs. When it does,\n * focus that was on the removed tab (or its ×) moves to the open tab if the\n * removed one was open, else to its neighbour, instead of falling to the page.\n */\n onRemove?: (id: T) => void;\n /**\n * Which tabs the Delete key removes.\n *\n * - `\"every\"` (default): any focused removable tab — the ARIA pattern's reading.\n * - `\"active\"`: only the OPEN tab, the same one that wears the ×. For a strip whose\n * remove is immediate and unconfirmed (lenkbank's sheet tabs delete a measurement\n * sheet at once), so arrowing across the strip and pressing Delete cannot take\n * out a sheet the user is not looking at. Delete on a closed tab does nothing,\n * and only the open tab announces the shortcut (`aria-keyshortcuts`).\n *\n * Either way the × is drawn on the open tab only and its room stays reserved on\n * every removable tab, so the strip never relayouts when the selection moves.\n */\n removeOn?: \"every\" | \"active\";\n /** Renders an \"add\" button after the last tab, outside the tablist — it is an\n * action, not a tab, and a tablist may own only tabs. */\n onAdd?: () => void;\n /** The add button's text. Say what is added — \"Add loop\", \"Add report\" says\n * more than \"Add\". Default: `tabs.add` from the {@link UiKitProvider}. */\n addLabel?: string;\n /** Overrides for this strip's strings. See {@link TabsLabels}. */\n labels?: LabelOverride<TabsLabels>;\n /** An add or a remove is in flight: the × and the add button are disabled and\n * Delete is ignored, so a double click cannot remove two tabs. Nothing moves. */\n busy?: boolean;\n /**\n * `\"vertical\"`: the same strip as a SIDE NAV — one item per row, the open one\n * filled, ↑/↓ walking it (Home/End as before) and `aria-orientation=\"vertical\"` on\n * the tablist. For keksdose's admin page and settings page, which each hand-built\n * the identical sidebar with none of this strip's keyboard, roving tab stop or\n * routed-`href` handling. Everything else — `href` tabs, `badge`, `panelId`,\n * `onRemove`, `onAdd` — means exactly what it means on the horizontal strip; the\n * badge moves to the row's end.\n *\n * **On a phone (`usePhoneLayout()`) it becomes the horizontal strip**, rather than\n * staying a column. A side nav only works beside its content; on\n * a phone it has to go ABOVE it, and eight full-width rows there push the panel\n * the user picked below the fold on every visit — the two keksdose pages both\n * collapsed theirs into a scrolling row by hand for exactly that reason. The\n * orientation is switched in JavaScript, not with `md:` classes, so the\n * `aria-orientation` a screen reader hears and the arrow keys that work always\n * match the layout on screen. The caller's own two-column layout has to stack at\n * the same breakpoint (`md:grid-cols-[14rem_1fr]`).\n */\n orientation?: \"horizontal\" | \"vertical\";\n /** The `role=\"tablist\"` strip, where the rest of the props go (0.34, API-2, refactor\n * audit 2026-10). Merged with the strip's own ref, which scrolls the open tab into\n * view and measures the fade, so neither loses the element. */\n ref?: Ref<HTMLDivElement>;\n}\n\nconst TABLIST_CLASSES = \"flex gap-1 overflow-x-auto overflow-y-hidden border-b border-subtle\";\n// The side nav. A column of full-width rows with the open one FILLED rather than\n// underlined: an underline under one row of a list reads as a separator, not as a\n// selection. `--bg-active` is the kit's own selected-row fill (the sidebar's), so the\n// admin page's nav and the app's own sidebar mark \"you are here\" the same way.\nconst TABLIST_VERTICAL_CLASSES = \"flex flex-col gap-0.5\";\nconst TAB_VERTICAL_CLASSES = `flex w-full items-center rounded-md px-3 py-2 text-start text-sm font-medium transition-colors focus:outline-none ${FOCUS_RING}`;\nconst TAB_VERTICAL_ACTIVE_CLASSES = \"bg-active text-primary\";\nconst TAB_VERTICAL_INACTIVE_CLASSES = \"text-muted hover:bg-hover hover:text-primary\";\n\n// `max-w-full`: no tab outgrows its strip. A sheet's `detail` line (\"Spurstangenkraft\n// links · … · 31 Punkte\") made one tab 448px wide in a 390px strip, and a tab wider\n// than the strip cannot be scrolled into view — its end was simply off screen\n// (lenkbank L3). The label and detail truncate inside it instead; see `inner`.\n//\n// Keyboard focus on a tab, a side-nav row and the add button is the kit's one focus frame\n// (text-size §5), in brand. Until 0.51.7 it rang in `ring-strong`: 2.54:1 on the light\n// page and 2.76:1 on a card, under WCAG 1.4.11's 3:1 (refactor audit 2026-10-30, DSP-8;\n// NavPills made the same move in 0.34, DOC-7). INSET on a tab: the strip scrolls\n// (`overflow-x-auto overflow-y-hidden`), so a ring outside the tab is cut off along its\n// top and bottom, and at a cut end.\nconst TAB_CLASSES = `max-w-full whitespace-nowrap px-3 py-2 text-sm font-medium transition-colors border-b-2 -mb-px focus:outline-none ${FOCUS_RING} focus-visible:ring-inset`;\nconst TAB_ACTIVE_CLASSES = \"border-[var(--text-primary)] text-primary\";\nconst TAB_INACTIVE_CLASSES = \"border-transparent text-muted hover:text-secondary hover:border-strong\";\n\n// The add/remove chrome. The × reserves its gutter on every removable tab (see\n// `onRemove`).\nconst TAB_REMOVABLE_CLASSES = \"pe-8\";\nconst TAB_EMPTY_CLASSES = \"-mx-1.5 rounded border border-dashed border-strong px-1.5\";\nconst TAB_ADD_CLASSES = `inline-flex items-center gap-1 whitespace-nowrap rounded-md px-3 py-2 text-sm font-medium text-muted transition-colors hover:text-primary focus:outline-none ${FOCUS_RING} disabled:cursor-not-allowed disabled:opacity-50 [&_svg]:size-4`;\n// `relative`: the phone's visually hidden label needs a positioned ancestor (see the\n// sr-only rule in CONTRIBUTING); `max-md:px-2` squares the lone \"+\".\nconst TAB_ADD_PHONE_CLASSES = \"relative max-md:px-2\";\n\nexport function Tabs<T extends string>({\n tabs,\n active,\n onChange,\n className,\n overflowCue = false,\n panelId,\n onRemove,\n removeOn = \"every\",\n onAdd,\n addLabel,\n labels,\n busy = false,\n orientation = \"horizontal\",\n \"aria-label\": ariaLabel,\n ref,\n ...rest\n}: TabsProps<T>) {\n const text = useKitLabels(\"tabs\", DEFAULT_TABS_LABELS, labels);\n // See `orientation`: a vertical strip is the horizontal one on a phone.\n const phone = usePhoneLayout();\n const vertical = orientation === \"vertical\" && !phone;\n const stripRef = useRef<HTMLDivElement>(null);\n const setStrip = useMemo(() => mergeRefs(stripRef, ref), [ref]);\n const fade = useStripFade(stripRef);\n // See `overflowCue`: a ‹ before the strip while tabs lie before it, a › after it\n // while tabs lie past it — `fade.overflow` names the cut ends in reading-direction terms.\n const cue = overflowCue && !vertical;\n const cueStart = cue && (fade.overflow === \"start\" || fade.overflow === \"both\");\n const cueEnd = cue && (fade.overflow === \"end\" || fade.overflow === \"both\");\n // The cue gutters (see `stripBox`): there only while the strip overflows at all.\n const gutters = cue && fade.overflow !== undefined;\n // Three quarters of the visible width toward `side`; an RTL strip runs leftward.\n const onCue = (side: \"start\" | \"end\") => {\n const strip = stripRef.current;\n if (!strip) return;\n const behavior: ScrollBehavior = prefersReducedMotion() ? \"auto\" : \"smooth\";\n const rtl = getComputedStyle(strip).direction === \"rtl\";\n const toward = (side === \"end\" ? 1 : -1) * (rtl ? -1 : 1);\n strip.scrollBy({ left: toward * Math.round(strip.clientWidth * 0.75), behavior });\n };\n // In a GUTTER of its own at the strip's end (0.48, keksdose live #410 rounds 5–6): a\n // bare glyph — no frame, fill or shadow — muted until the pointer comes. Round 5 put\n // each cue in the flow beside the strip, so it took 24px the moment it appeared and the\n // labels jumped sideways at every edge the scroll crossed (\"It has problems with moving\n // to left and right especially when moving the scroll bar\"). Now the strip box keeps a\n // 24px gutter at BOTH ends for as long as the strip overflows at all (`px-[24px]`, see\n // `stripBox`), and each gutter is absolute in that padding, so a cue coming or going\n // never moves the strip, its labels or the scroll position under the thumb. A strip\n // that fits has no gutters: its width changes only when it starts or stops overflowing.\n // The gutter carries the strip's 1px rule on beneath the glyph, and lifts the glyph\n // onto the LABELS' centre rather than the box's: a tab's 2px underline (`border-b-2`)\n // sits on the rule (`-mb-px`), so the labels are centred 2px above the box's bottom —\n // the gutter's 1px rule (`border-b`) plus 1px more (`pb-px`).\n // 24px wide at every text size (KS N3): at Large `sm`'s 48px minimum made each cue\n // 48px, and at 360px the two left a two-tab strip 100px. 24 PIXELS, not `w-6`: the rem\n // grows with the text size (24 / 30 / 36px), and at Extra large on a 360px phone the two\n // gutters took 72px of a 226px strip and a sheet tab wrapped to seven lines (0.48 build\n // review 2, KS N1). The 48px touch target is an\n // invisible hit area round the glyph instead (`HIT_AREA_LARGE`, as `2xs`), over the\n // strip's faded edge, not room taken from the row. The wrapper inside the gutter is\n // ours because IconButton puts its tooltip's span around the button.\n const gutterAt = (side: \"start\" | \"end\", shown: boolean) => (\n <div\n data-tabs-cue-gutter={side}\n className={cn(\n \"absolute inset-y-0 flex w-[24px] items-center justify-center border-b border-subtle pb-px\",\n side === \"start\" ? \"start-0\" : \"end-0\",\n )}\n >\n {shown && (\n <div data-tabs-overflow-cue={side} className=\"flex\">\n <IconButton\n size=\"sm\"\n variant=\"ghost\"\n className={cn(\n \"w-[24px] large:min-w-0 bg-transparent text-muted hover:bg-transparent hover:text-primary\",\n // Over the strip's faded edge, for ‹ too.\n HIT_AREA_LARGE,\n \"large:after:z-[1]\",\n )}\n tabIndex={-1}\n type=\"button\"\n label={side === \"start\" ? text.previous : text.more}\n labelVisible={false}\n onClick={() => onCue(side)}\n >\n {side === \"start\" ? (\n <ChevronLeft className=\"rtl:-scale-x-100\" />\n ) : (\n <ChevronRight className=\"rtl:-scale-x-100\" />\n )}\n </IconButton>\n </div>\n )}\n </div>\n );\n // The OPEN tab is scrolled into the strip whenever it changes — on mount too, for a\n // deep link to the seventh report. A selection behind the cut edge was the worst\n // case of the overflow above: the one tab the panel below belongs to, sliced to\n // \"80 km,\" under the add button. Only the strip scrolls (`scrollBy` on it, not\n // `scrollIntoView`, which would scroll the page as well), and only as far as needed\n // to clear the fade.\n //\n // And again when the cue gutters appear (refactor audit 2026-10-30, DSP-16). They come a\n // render after the strip is first measured as overflowing — on mount, or with the very\n // tab that tipped it over — and take 24px off each end, so a tab scrolled clear before\n // them ended 24px past the visible end, under the fade and the ›.\n useLayoutEffect(() => {\n const strip = stripRef.current;\n const tab = strip?.querySelector<HTMLElement>('[role=\"tab\"][aria-selected=\"true\"]');\n if (strip && tab) scrollIntoStrip(strip, tab);\n }, [active, gutters]);\n const isRemovable = (tab: TabItem<T>) => onRemove !== undefined && tab.removable !== false;\n // Whether Delete reaches this tab: every removable tab, or only the open one.\n const deletesOnKey = (tab: TabItem<T>) => isRemovable(tab) && (removeOn === \"every\" || tab.id === active);\n const nameOf = (tab: TabItem<T>) => tab.name ?? (typeof tab.label === \"string\" ? tab.label : tab.id);\n // The strip's one tab stop (see `tabIndex` below): the open tab, or the first while\n // `active` names none. \"No sheet open\" is a real state — KS's sheet strips pass `\"\"` —\n // and with the stop on the selection alone every tab was -1, so the keyboard could not\n // reach the one control that opens a sheet (refactor audit 2026-10-30, DSP-1).\n const stop = tabs.some((t) => t.id === active) ? active : tabs[0]?.id;\n\n // Where focus goes once a removal the strip asked for has happened. The caller\n // owns `tabs` and may take its time (a confirmation, a request), so this waits\n // for the id to actually leave the list rather than guessing at the moment.\n const pendingFocus = useRef<{ removed: T; wasActive: boolean; neighbour?: T } | null>(null);\n const removeRef = useRef<HTMLButtonElement>(null);\n const requestRemove = (id: T) => {\n const index = tabs.findIndex((t) => t.id === id);\n pendingFocus.current = {\n removed: id,\n wasActive: id === active,\n neighbour: (tabs[index + 1] ?? tabs[index - 1])?.id,\n };\n onRemove?.(id);\n };\n useLayoutEffect(() => {\n const pending = pendingFocus.current;\n if (!pending || tabs.some((t) => t.id === pending.removed)) return;\n pendingFocus.current = null;\n const strip = stripRef.current;\n if (!strip) return;\n // Only focus that fell out of the strip with the removed tab is ours to place —\n // or that sits on the remove button after it, which a pick removed through.\n // If the user has moved on — into the panel, into a dialog — leave it there.\n const focused = document.activeElement;\n if (focused && focused !== document.body && !strip.contains(focused) && focused !== removeRef.current) return;\n // The removed tab was open: focus follows the caller's new selection, which is\n // also the strip's tab stop. It was not: its neighbour, as the ARIA pattern has it.\n // The caller opened nothing in its place (KS drops the sheet and the selection in one\n // render): the neighbour too, else the stop — not an index of -1 and the page (DSP-1).\n const present = (id: T | undefined) => tabs.some((t) => t.id === id);\n const target =\n pending.wasActive && present(active) ? active : present(pending.neighbour) ? pending.neighbour : stop;\n const index = tabs.findIndex((t) => t.id === target);\n strip.querySelectorAll<HTMLElement>('[role=\"tab\"]')[index]?.focus();\n }, [tabs, active, stop]);\n\n // Arrow keys walk the strip in DOM order (ARIA tabs pattern). Home/End jump\n // to the ends. Focus only — activation stays on click/Enter/Space, because a tab\n // here can be a real route and moving focus must not navigate.\n //\n // On the TABS, not on the tablist. The container carried it, which left a `<div>`\n // wearing an interactive role and a key handler while being unfocusable —\n // `jsx-a11y/interactive-supports-focus`, and the only instance of it left in `src/`.\n // The fix that rule wants is `tabIndex` on the div; the fix the pattern wants is\n // the handler on the elements that are natively focusable and actually receive the\n // keystroke. Those are the tabs, so the warning goes away by being right rather\n // than by being satisfied.\n const onTabKeyDown = (e: KeyboardEvent<HTMLElement>, tab: TabItem<T>) => {\n // Delete removes the FOCUSED tab (the ARIA pattern's optional key for deletable\n // tabs) — the keyboard's way to the × that is not a tab stop. `busy` swallows it\n // rather than letting it fall through to the page. With `removeOn=\"active\"` only\n // the open tab answers; a closed one lets the key pass untouched.\n if (e.key === \"Delete\" && deletesOnKey(tab)) {\n e.preventDefault();\n if (!busy) requestRemove(tab.id);\n return;\n }\n // Along the reading direction: the strip is a flex row, so in RTL the NEXT tab sits\n // to the left, and ArrowLeft has to reach it. A vertical strip answers ↑/↓ instead\n // — and ONLY those, as the pattern has it: ←/→ are left to the page, where a side\n // nav's neighbour (the panel) may well want them.\n const step: 1 | -1 | 0 = vertical\n ? e.key === \"ArrowDown\"\n ? 1\n : e.key === \"ArrowUp\"\n ? -1\n : 0\n : horizontalStep(e.key, e.currentTarget);\n if (step === 0 && e.key !== \"Home\" && e.key !== \"End\") return;\n const strip = e.currentTarget.closest('[role=\"tablist\"]');\n if (!strip) return;\n const items = Array.from(strip.querySelectorAll<HTMLElement>('[role=\"tab\"]'));\n const from = items.indexOf(e.currentTarget);\n if (from === -1) return;\n e.preventDefault();\n const to = e.key === \"Home\" ? 0 : e.key === \"End\" ? items.length - 1 : (from + step + items.length) % items.length;\n items[to]?.focus();\n };\n const tablist = (\n <div\n // Before the role and the name: a caller's arbitrary attribute is welcome on the\n // strip, but a `role` or an `aria-label` arriving through a spread props object\n // must not be able to unmake the tablist the tabs below are registered against.\n {...rest}\n ref={setStrip}\n role=\"tablist\"\n // Names the GROUP — every tab carries its own text — so a strip under a visible\n // heading that already says it can leave it unset.\n aria-label={ariaLabel}\n // Only when vertical: `horizontal` is the tablist's implicit value, and the\n // attribute on every existing strip would be noise in every snapshot of them.\n aria-orientation={vertical ? \"vertical\" : undefined}\n // Merged over a caller's own style rather than replacing it; see useStripFade.\n // `data-overflow` names the cut ends in reading-direction terms, for a caller's\n // own affordance (a scroll button) and for tests, which cannot read the mask.\n style={fade.mask ? { ...rest.style, maskImage: fade.mask, WebkitMaskImage: fade.mask } : rest.style}\n data-overflow={fade.overflow}\n // With an add button the strip gains an outer box, and `className` goes there\n // — it is the box a caller's margin or width is meant for.\n className={cn(\n vertical ? TABLIST_VERTICAL_CLASSES : TABLIST_CLASSES,\n cue ? \"min-w-0 flex-1\" : onAdd ? \"min-w-0\" : className,\n )}\n >\n {tabs.map((tab) => {\n const isActive = tab.id === active;\n const removable = isRemovable(tab);\n // Written once and worn by either tag below, so a routed tab and a\n // state-only tab stay indistinguishable to the eye and to a screen reader.\n const shared = {\n role: \"tab\" as const,\n \"aria-selected\": isActive,\n // Roving tabindex: the whole strip is ONE stop in the page's tab order, and\n // it is the open tab. Ten report tabs otherwise cost ten Tab presses to step\n // over on the way to the table below them (live #262's strip is the extreme\n // case, but every strip in the app paid it).\n //\n // The stop follows the SELECTION, not the focus. Arrowing is a look around —\n // activation here is manual, because a tab can be a real route — so re-\n // pointing the stop at a report the user only arrowed past would hand the\n // strip back on the next visit in a state they never chose. The cost is that\n // Tab out and back returns to the open tab rather than to the last one\n // looked at; the ARIA pattern allows either, and this one needs no second\n // piece of state shadowing `active`. With nothing open, the first tab (`stop`).\n tabIndex: tab.id === stop ? 0 : -1,\n // Only the open tab: see `panelId`.\n \"aria-controls\": isActive ? panelId : undefined,\n id: isActive && panelId ? `${panelId}-tab` : undefined,\n \"aria-keyshortcuts\": deletesOnKey(tab) ? \"Delete\" : undefined,\n onKeyDown: (e: KeyboardEvent<HTMLElement>) => onTabKeyDown(e, tab),\n className: vertical\n ? cn(\n TAB_VERTICAL_CLASSES,\n isActive ? TAB_VERTICAL_ACTIVE_CLASSES : TAB_VERTICAL_INACTIVE_CLASSES,\n removable && \"pe-8\",\n )\n : cn(TAB_CLASSES, isActive ? TAB_ACTIVE_CLASSES : TAB_INACTIVE_CLASSES, removable && TAB_REMOVABLE_CLASSES),\n };\n const inner = (\n <span\n className={cn(\n // In a side nav the row is the full width, so the label takes it and a\n // badge lands at the row's end, where a column of counts lines up.\n vertical\n ? \"flex min-w-0 flex-1 items-center gap-1.5\"\n : \"inline-flex max-w-full min-w-0 items-center gap-1.5\",\n tab.empty && TAB_EMPTY_CLASSES,\n )}\n >\n {tab.icon != null && (\n <span aria-hidden className=\"inline-flex shrink-0 items-center gap-0.5\">\n {tab.icon}\n </span>\n )}\n {tab.detail != null ? (\n // `min-w-0` + `truncate-until-large` on each line: capped by the tab's\n // `max-w-full`, a long detail ends in an ellipsis at Normal and wraps from\n // Large up (text size §10.17; round 0.34 T1), where the tab grows a line\n // rather than cut the words. The whole text stays the tab's name.\n <span className=\"flex min-w-0 flex-col items-start text-start\">\n <span className=\"max-w-full truncate-until-large\">{tab.label}</span>\n {/* A separator for the NAME only — whitespace between flex items is\n not drawn, and without it the two lines ran together into one\n word for a screen reader (\"80 km/hloop 2\"). */}{\" \"}\n <span className=\"max-w-full truncate-until-large text-caption font-normal leading-tight text-muted\">\n {tab.detail}\n </span>\n </span>\n ) : (\n tab.label\n )}\n {tab.badge != null ? (\n vertical ? (\n <span className=\"ms-auto flex shrink-0 items-center\">{tab.badge}</span>\n ) : (\n tab.badge\n )\n ) : null}\n </span>\n );\n const element = tab.href ? (\n <a\n key={tab.id}\n {...shared}\n href={tab.href}\n draggable={false}\n onClick={(e) => {\n // Middle-, ⌘/Ctrl-, Shift- and Alt-click belong to the browser\n // (feedback #451); only the plain left click is ours to cancel and\n // route through `onChange`, which keeps the switch client-side.\n if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;\n e.preventDefault();\n onChange(tab.id);\n }}\n // A <button> tab activated on Space; an anchor does not, so becoming a\n // link (feedback #451) would have quietly cost keyboard users the tab\n // strip. Enter is deliberately untouched — it is the anchor's own\n // default and already arrives at `onChange` through the click handler\n // above, so handling it here too would switch tabs twice. Same three\n // lines the mobile row card carries, for the same reason; the rejected\n // alternative — keeping a button and faking the middle click with\n // window.open — is what the whole item is moving away from.\n //\n // Composed with the strip's arrow-key walk rather than replacing it: this\n // prop overrides the one in `shared`, and an anchor tab that lost the\n // arrows would be a strip that is navigable only where it is not a link.\n onKeyDown={(e) => {\n onTabKeyDown(e, tab);\n if (e.defaultPrevented || e.key !== \" \") return;\n e.preventDefault();\n onChange(tab.id);\n }}\n >\n {inner}\n </a>\n ) : (\n <button key={tab.id} {...shared} onClick={() => onChange(tab.id)}>\n {inner}\n </button>\n );\n if (!removable) return element;\n return (\n // The × cannot go INSIDE the tab: a button in a button is invalid HTML, and\n // a tab's children are presentational, so a reader would flatten it into the\n // tab's name. It sits beside the tab, over the gutter the tab reserves.\n <div key={tab.id} className={cn(\"relative flex max-w-full\", !vertical && \"shrink-0\")}>\n {element}\n {isActive && (\n <IconButton\n size=\"2xs\"\n tone=\"danger\"\n // The POINTER's ×, hidden from assistive tech (round 0.34, the showcase's\n // axe check): a tablist may own tabs only, and this button sat in it. The\n // named \"Remove <tab>\" a screen reader's cursor and a touch reader's swipe\n // reach is `removeButton`, after the strip. Not a tab stop either way:\n // the strip is one, and Delete is the keyboard's path.\n aria-hidden\n data-tab-remove=\"\"\n tabIndex={-1}\n type=\"button\"\n disabled={busy}\n onClick={() => requestRemove(tab.id)}\n className=\"absolute inset-y-0 end-1 my-auto\"\n >\n <X />\n </IconButton>\n )}\n </div>\n );\n })}\n </div>\n );\n // The open tab's remove action as assistive tech meets it: right after the strip,\n // OUTSIDE the tablist, which may own tabs only (round 0.34, the showcase's axe check).\n // Visually hidden, since the × drawn on the tab is the pointer's, and not a tab stop,\n // since Delete is the keyboard's. `sr-only-fixed`: it sits wherever the caller put the\n // strip, with no positioned box of the kit's around it.\n const activeTab = tabs.find((tab) => tab.id === active);\n const removeButton =\n activeTab && isRemovable(activeTab) ? (\n <button\n ref={removeRef}\n type=\"button\"\n tabIndex={-1}\n disabled={busy}\n onClick={() => requestRemove(activeTab.id)}\n className=\"sr-only-fixed\"\n >\n {text.remove(nameOf(activeTab))}\n </button>\n ) : null;\n // The box the cues sit in, ‹ in the gutter before the strip and › in the one after it\n // (see `gutterAt`). There whether or not the strip overflows right now, so a resize\n // never re-parents it; the gutters only while it overflows at all (`gutters`).\n const stripBox = cue ? (\n <div className={cn(\"relative flex min-w-0\", !onAdd && \"flex-1\", gutters && \"px-[24px]\")}>\n {tablist}\n {gutters && gutterAt(\"start\", cueStart)}\n {gutters && gutterAt(\"end\", cueEnd)}\n </div>\n ) : (\n tablist\n );\n if (!onAdd && cue) {\n // `className` goes on the row around the strip — it is the box a caller's margin or\n // width is meant for, as with an add button — and stays there at every scroll position.\n return (\n <div className={cn(\"flex items-end\", className)}>\n {stripBox}\n {removeButton}\n </div>\n );\n }\n if (!onAdd) {\n // No outer box: the strip stays the root, as callers' `className` and tests expect.\n return removeButton ? (\n <>\n {tablist}\n {removeButton}\n </>\n ) : (\n tablist\n );\n }\n if (vertical) {\n // Under the column, not beside it, and with no rule to carry on: a side nav has none.\n return (\n <div className={cn(\"flex flex-col gap-0.5\", className)}>\n {tablist}\n {removeButton}\n <button type=\"button\" onClick={onAdd} disabled={busy} className={TAB_ADD_CLASSES}>\n <Plus aria-hidden />\n {addLabel ?? text.add}\n </button>\n </div>\n );\n }\n return (\n <div className={cn(\"flex items-end\", className)}>\n {stripBox}\n {removeButton}\n {/* Outside the tablist: an action, not a tab — and a tablist may own only\n tabs. The rule under the strip carries on beneath it. */}\n <div className=\"flex flex-1 items-center self-stretch border-b border-subtle\">\n <button type=\"button\" onClick={onAdd} disabled={busy} className={cn(TAB_ADD_CLASSES, TAB_ADD_PHONE_CLASSES)}>\n <Plus aria-hidden />\n {/* Below md a scrolling strip keeps the room for tabs: the label is visually\n hidden and the \"+\" carries it as its name. Beside \"+ Add loop\" a 390px strip\n showed the open tab and a sliver of the next (showcase audit). */}\n <span className=\"max-md:sr-only\">{addLabel ?? text.add}</span>\n </button>\n </div>\n </div>\n );\n}\n"],"mappings":"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","names":[]}
@@ -90,7 +90,27 @@ export interface TreemapProps extends Omit<ComponentPropsWithoutRef<"div">, "chi
90
90
  * so a caller colouring a legend beside the map is guaranteed the same list.
91
91
  */
92
92
  colors?: readonly string[];
93
+ /**
94
+ * How tiles are parted (0.51.6, keksdose dev #642, Marcel: "The borders do not look
95
+ * nice when changing from tile to tile. Can this be uniform black?" — and then, on the
96
+ * contrast rule that would go with it: follow the contrast setting).
97
+ *
98
+ * - `"auto"` (default): `"uniform"` at Standard contrast, `"contrast"` while More
99
+ * contrast is in force (`<html data-contrast="more">`: the reader chose it, or chose
100
+ * System on a device that asks for more). Followed live.
101
+ * - `"uniform"`: one 2px gutter in the page's ground colour (`--bg-page`, near-black
102
+ * in the dark theme) round every tile, and nothing else. Tiles always abut, so the
103
+ * gutter parts them; each tile is named by its label.
104
+ * - `"contrast"`: the 0.49 rule. The gutter is the card's colour (`--bg-surface`),
105
+ * and a tile whose hue is under 3:1 against the card gets a 1px ink edge inside it
106
+ * (WCAG 1.4.11, `useChartEdge`). In the dark theme that ink is light, so some tiles
107
+ * carry a white outline and their neighbours none — the unevenness #642 saw, and
108
+ * the price of the edge for a reader who asked for it.
109
+ */
110
+ edges?: TreemapEdges;
93
111
  }
112
+ /** {@link TreemapProps.edges}. */
113
+ export type TreemapEdges = "auto" | "uniform" | "contrast";
94
114
  /**
95
115
  * Fit a label to its tile: the text itself when it fits, an ellipsised head when it
96
116
  * does not, `null` when not even three characters would. Without this a long name ran
@@ -120,6 +140,8 @@ export interface TreemapCellProps {
120
140
  * the label stands in the tile's top RIGHT corner and is shaped right-to-left, so a
121
141
  * clipped name ends in its ellipsis on the reading end. Default `ltr`. */
122
142
  dir?: Direction;
143
+ /** See {@link TreemapProps.edges}. Default `auto`. */
144
+ edges?: TreemapEdges;
123
145
  }
124
146
  /**
125
147
  * One tile. Exported so a caller drawing its own recharts `<Treemap>` gets the same
@@ -132,7 +154,7 @@ export interface TreemapCellProps {
132
154
  * (`paint-order: stroke`), which defeats any inherited stroke and keeps the text solid
133
155
  * where it overlaps a lighter or darker part of its own tile.
134
156
  */
135
- export declare function TreemapCell({ depth, x, y, width, height, name, fill, labelColor, id, onNodeClick, redactNames, nodeNote, dir, }: TreemapCellProps): import("react").JSX.Element | null;
157
+ export declare function TreemapCell({ depth, x, y, width, height, name, fill, labelColor, id, onNodeClick, redactNames, nodeNote, dir, edges: edgesProp, }: TreemapCellProps): import("react").JSX.Element | null;
136
158
  /**
137
159
  * Tile chart: one rectangle per {@link TreemapNode}, sized by `value`, coloured by
138
160
  * index from the chart ramp (or by the node's own `fill`), labelled in whichever of the
@@ -144,4 +166,4 @@ export declare function TreemapCell({ depth, x, y, width, height, name, fill, la
144
166
  *
145
167
  * Needs `recharts`, like everything behind `@eifi1/ui-kit/chart`.
146
168
  */
147
- export declare function Treemap({ data, valueFormatter, height, onNodeClick, redactNames, nodeNote, maxTiles, colors, style, ...rest }: TreemapProps): import("react").JSX.Element | null;
169
+ export declare function Treemap({ data, valueFormatter, height, onNodeClick, redactNames, nodeNote, maxTiles, colors, edges, style, ...rest }: TreemapProps): import("react").JSX.Element | null;