@eifi1/ui-kit 0.47.0 → 0.48.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (152) hide show
  1. package/README.md +5 -3
  2. package/dist/admin/admin-sections.d.ts +8 -0
  3. package/dist/admin/admin-sections.js +2 -1
  4. package/dist/admin/admin-sections.js.map +1 -1
  5. package/dist/auth/verify-email.d.ts +9 -1
  6. package/dist/auth/verify-email.js +22 -13
  7. package/dist/auth/verify-email.js.map +1 -1
  8. package/dist/components/alert-banner.d.ts +29 -1
  9. package/dist/components/alert-banner.js +84 -7
  10. package/dist/components/alert-banner.js.map +1 -1
  11. package/dist/components/button-look.d.ts +4 -0
  12. package/dist/components/button-look.js +2 -0
  13. package/dist/components/button-look.js.map +1 -1
  14. package/dist/components/combobox-core.js +5 -1
  15. package/dist/components/combobox-core.js.map +1 -1
  16. package/dist/components/data-table-column-settings.d.ts +6 -1
  17. package/dist/components/data-table-column-settings.js +12 -2
  18. package/dist/components/data-table-column-settings.js.map +1 -1
  19. package/dist/components/data-table-fit.d.ts +44 -0
  20. package/dist/components/data-table-fit.js +103 -0
  21. package/dist/components/data-table-fit.js.map +1 -0
  22. package/dist/components/data-table-groups.d.ts +95 -0
  23. package/dist/components/data-table-groups.js +165 -0
  24. package/dist/components/data-table-groups.js.map +1 -0
  25. package/dist/components/data-table-head.js +3 -1
  26. package/dist/components/data-table-head.js.map +1 -1
  27. package/dist/components/data-table-labels.d.ts +7 -0
  28. package/dist/components/data-table-labels.js +3 -0
  29. package/dist/components/data-table-labels.js.map +1 -1
  30. package/dist/components/data-table-leading-row.d.ts +17 -0
  31. package/dist/components/data-table-leading-row.js +41 -0
  32. package/dist/components/data-table-leading-row.js.map +1 -0
  33. package/dist/components/data-table-resize.js +1 -1
  34. package/dist/components/data-table-resize.js.map +1 -1
  35. package/dist/components/data-table.d.ts +95 -2
  36. package/dist/components/data-table.js +360 -267
  37. package/dist/components/data-table.js.map +1 -1
  38. package/dist/components/disclosure.d.ts +3 -1
  39. package/dist/components/disclosure.js +10 -4
  40. package/dist/components/disclosure.js.map +1 -1
  41. package/dist/components/dropdown.js +8 -1
  42. package/dist/components/dropdown.js.map +1 -1
  43. package/dist/components/floating-panel.d.ts +14 -1
  44. package/dist/components/floating-panel.js +7 -2
  45. package/dist/components/floating-panel.js.map +1 -1
  46. package/dist/components/hover-menu.js +5 -1
  47. package/dist/components/hover-menu.js.map +1 -1
  48. package/dist/components/icon-button.js +2 -1
  49. package/dist/components/icon-button.js.map +1 -1
  50. package/dist/components/popover.js +5 -1
  51. package/dist/components/popover.js.map +1 -1
  52. package/dist/components/table-skeleton.d.ts +35 -0
  53. package/dist/components/table-skeleton.js +75 -0
  54. package/dist/components/table-skeleton.js.map +1 -0
  55. package/dist/components/tabs.d.ts +10 -12
  56. package/dist/components/tabs.js +20 -10
  57. package/dist/components/tabs.js.map +1 -1
  58. package/dist/components/use-table-state.d.ts +8 -7
  59. package/dist/components/use-table-state.js +37 -3
  60. package/dist/components/use-table-state.js.map +1 -1
  61. package/dist/data-table.d.ts +1 -0
  62. package/dist/data-table.js.map +1 -1
  63. package/dist/eslint/index.js +1 -1
  64. package/dist/feedback/feedback-table.d.ts +5 -0
  65. package/dist/feedback/feedback-table.js +30 -7
  66. package/dist/feedback/feedback-table.js.map +1 -1
  67. package/dist/hooks/use-dismiss.d.ts +30 -1
  68. package/dist/hooks/use-dismiss.js +30 -2
  69. package/dist/hooks/use-dismiss.js.map +1 -1
  70. package/dist/i18n/german.js +6 -1
  71. package/dist/i18n/german.js.map +1 -1
  72. package/dist/i18n/kit-labels.d.ts +4 -0
  73. package/dist/i18n/kit-labels.js.map +1 -1
  74. package/dist/i18n/locales/en.js +6 -1
  75. package/dist/i18n/locales/en.js.map +2 -2
  76. package/dist/i18n/locales/es.js +6 -1
  77. package/dist/i18n/locales/es.js.map +1 -1
  78. package/dist/i18n/locales/fr.js +6 -1
  79. package/dist/i18n/locales/fr.js.map +1 -1
  80. package/dist/i18n/locales/hu.js +6 -1
  81. package/dist/i18n/locales/hu.js.map +1 -1
  82. package/dist/i18n/locales/it.js +6 -1
  83. package/dist/i18n/locales/it.js.map +1 -1
  84. package/dist/i18n/locales/zh.js +6 -1
  85. package/dist/i18n/locales/zh.js.map +1 -1
  86. package/dist/i18n/shared-labels.js +3 -1
  87. package/dist/i18n/shared-labels.js.map +1 -1
  88. package/dist/index.d.ts +2 -0
  89. package/dist/index.js +2 -0
  90. package/dist/index.js.map +1 -1
  91. package/dist/lib/float-clearance.d.ts +8 -0
  92. package/dist/lib/float-clearance.js +29 -0
  93. package/dist/lib/float-clearance.js.map +1 -0
  94. package/dist/settings/settings-catalogue.d.ts +10 -0
  95. package/dist/settings/settings-catalogue.js.map +1 -1
  96. package/dist/settings/settings-layout.js +10 -3
  97. package/dist/settings/settings-layout.js.map +1 -1
  98. package/dist/shell/app-shell.d.ts +5 -0
  99. package/dist/shell/app-shell.js +5 -3
  100. package/dist/shell/app-shell.js.map +1 -1
  101. package/dist/shell/option-switcher-menu.d.ts +2 -0
  102. package/dist/shell/option-switcher-menu.js +6 -2
  103. package/dist/shell/option-switcher-menu.js.map +1 -1
  104. package/dist/shell/topbar-action-menu.js +2 -1
  105. package/dist/shell/topbar-action-menu.js.map +1 -1
  106. package/dist/shell/topbar-controls.js +5 -3
  107. package/dist/shell/topbar-controls.js.map +1 -1
  108. package/dist/tour/tour.js +1 -0
  109. package/dist/tour/tour.js.map +1 -1
  110. package/package.json +1 -1
  111. package/src/admin/admin-sections.tsx +8 -0
  112. package/src/auth/verify-email.tsx +34 -9
  113. package/src/components/alert-banner.tsx +131 -7
  114. package/src/components/button-look.ts +6 -0
  115. package/src/components/combobox-core.tsx +7 -1
  116. package/src/components/data-table-column-settings.tsx +23 -2
  117. package/src/components/data-table-fit.ts +187 -0
  118. package/src/components/data-table-groups.tsx +275 -0
  119. package/src/components/data-table-head.tsx +4 -1
  120. package/src/components/data-table-labels.ts +13 -0
  121. package/src/components/data-table-leading-row.tsx +55 -0
  122. package/src/components/data-table-resize.ts +2 -1
  123. package/src/components/data-table.tsx +279 -54
  124. package/src/components/disclosure.tsx +22 -5
  125. package/src/components/dropdown.tsx +11 -1
  126. package/src/components/floating-panel.tsx +24 -1
  127. package/src/components/hover-menu.tsx +6 -1
  128. package/src/components/icon-button.tsx +3 -3
  129. package/src/components/popover.tsx +7 -1
  130. package/src/components/table-skeleton.tsx +133 -0
  131. package/src/components/tabs.tsx +67 -37
  132. package/src/components/use-table-state.ts +54 -3
  133. package/src/data-table.ts +2 -0
  134. package/src/feedback/feedback-table.tsx +33 -1
  135. package/src/hooks/use-dismiss.ts +65 -3
  136. package/src/i18n/german.ts +5 -0
  137. package/src/i18n/kit-labels.tsx +4 -0
  138. package/src/i18n/locales/es.ts +5 -0
  139. package/src/i18n/locales/fr.ts +5 -0
  140. package/src/i18n/locales/hu.ts +5 -0
  141. package/src/i18n/locales/it.ts +5 -0
  142. package/src/i18n/locales/zh.ts +5 -0
  143. package/src/i18n/shared-labels.ts +2 -0
  144. package/src/index.ts +3 -0
  145. package/src/lib/float-clearance.ts +54 -0
  146. package/src/settings/settings-catalogue.ts +10 -0
  147. package/src/settings/settings-layout.tsx +18 -4
  148. package/src/shell/app-shell.tsx +17 -3
  149. package/src/shell/option-switcher-menu.tsx +16 -4
  150. package/src/shell/topbar-action-menu.tsx +22 -10
  151. package/src/shell/topbar-controls.tsx +18 -12
  152. package/src/tour/tour.tsx +5 -0
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/components/icon-button.tsx"],"sourcesContent":["import { useContext, useId } from \"react\";\nimport type {\n AnchorHTMLAttributes,\n ButtonHTMLAttributes,\n CSSProperties,\n KeyboardEvent,\n MouseEvent,\n ReactElement,\n ReactNode,\n Ref,\n} from \"react\";\nimport { cn } from \"../lib/cn\";\nimport { FOCUS_RING_WIDTH } from \"./focus-ring\";\nimport { useLargeText } from \"../hooks/use-large-text\";\nimport { Tooltip, type TooltipSide } from \"./tooltip\";\nimport { DEFAULT_COMMON_LABELS, useKitLabels, useKitLink } from \"../i18n/kit-labels\";\nimport type { KitLinkComponent, KitLinkProps } from \"../i18n/kit-labels\";\nimport { pickLinkRenderer, replacingClick, routerLinkNavigation } from \"./text-link\";\nimport { useCommitReason } from \"./write-lock\";\nimport type { CommitScope } from \"./write-lock\";\nimport { hasContent } from \"../lib/presence\";\nimport { CompactControlsContext, LockedReason, type DisabledReasonDisplay } from \"./field-parts\";\nimport { Spinner } from \"./spinner\";\nimport {\n HOVER_INK,\n ICON_BUTTON_PRESSED,\n RenderedButtonLink,\n TOUCH_TARGET_LARGE,\n buttonVariantClasses,\n} from \"./button-look\";\nimport type { ButtonVariant } from \"./button-look\";\n\n// Canonical square icon-only button. <Button> carries TEXT geometry — px-3 py-2\n// gap-2 — so rendering a bare glyph through it gives a small icon in a wide,\n// text-shaped box. This fixes a square box instead and, crucially, forces the\n// child icon to a size set by the BOX (20px at md/sm) via `[&_svg]:size-*` so a\n// caller cannot under-size it. Use it wherever an action is a bare icon\n// (edit/delete/tools) so they all match the top-bar icon buttons and can never\n// drift apart again.\n//\n// Draws its colours from the same `buttonVariantClasses` map as <Button>, so the\n// two re-skin together with the palette.\nconst ICON_BUTTON_BASE = cn(\n \"inline-flex items-center justify-center rounded-md transition-colors focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed\",\n FOCUS_RING_WIDTH,\n);\n\nexport type IconButtonSize = \"2xl\" | \"xl\" | \"lg\" | \"md\" | \"sm\" | \"xs\" | \"2xs\";\n\n// Box and glyph together, so a 24px chip action cannot end up holding a 20px icon\n// that touches its edges. The two small steps are lenkbank's list-row (28px) and\n// chip (24px) actions, which it had hand-rolled beside the kit's 32/36px ones.\nconst ICON_BUTTON_SIZES: Record<IconButtonSize, string> = {\n // 64px with a 28px glyph: a camera's shutter (keksdose invoices/camera-capture.tsx:248,\n // `size-16` by hand) — the one control of a full-screen capture view, thumb-sized.\n \"2xl\": \"size-16 [&_svg]:size-7\",\n // 48px with a 24px glyph: the one primary action of a phone screen or sheet, a step\n // past the 44px minimum rather than on it.\n xl: \"size-12 [&_svg]:size-6\",\n // The 44px touch target (WCAG 2.5.5's size) with the same 20px glyph — keksdose's\n // bulk-action bars write `size-11` by hand over an `md` button to get it on phones.\n lg: \"size-11 [&_svg]:size-5\",\n md: \"size-9 [&_svg]:size-5\",\n sm: \"size-8 [&_svg]:size-5\",\n xs: \"size-7 rounded [&_svg]:size-4\",\n \"2xs\": \"size-6 rounded [&_svg]:size-3.5\",\n};\n\n// `stretch`: the same WIDTH and glyph, and a height taken from the row instead of\n// stated — `size-*` sets a height that `self-stretch` cannot override, so the box is\n// written as a width plus a minimum height (the square it would otherwise be).\nconst ICON_BUTTON_STRETCH_SIZES: Record<IconButtonSize, string> = {\n \"2xl\": \"w-16 min-h-16 self-stretch [&_svg]:size-7\",\n xl: \"w-12 min-h-12 self-stretch [&_svg]:size-6\",\n lg: \"w-11 min-h-11 self-stretch [&_svg]:size-5\",\n md: \"w-9 min-h-9 self-stretch [&_svg]:size-5\",\n sm: \"w-8 min-h-8 self-stretch [&_svg]:size-5\",\n xs: \"w-7 min-h-7 self-stretch rounded [&_svg]:size-4\",\n \"2xs\": \"w-6 min-h-6 self-stretch rounded [&_svg]:size-3.5\",\n};\n\n// The label as visible text beside the glyph (0.32, §4: at Large \"IconButton shows its\n// label\"): the same height and glyph as the square, a width taken from the words. A\n// map of its own rather than `w-auto` over `size-*`, which tailwind-merge cannot undo\n// in one direction.\nconst ICON_BUTTON_TEXT_SIZES: Record<IconButtonSize, string> = {\n \"2xl\": \"h-16 gap-2 px-4 text-base [&_svg]:size-7\",\n xl: \"h-12 gap-2 px-3.5 text-sm [&_svg]:size-6\",\n lg: \"h-11 gap-2 px-3 text-sm [&_svg]:size-5\",\n md: \"h-9 gap-1.5 px-2.5 text-sm [&_svg]:size-5\",\n sm: \"h-8 gap-1.5 px-2 text-sm [&_svg]:size-5\",\n xs: \"h-7 gap-1 rounded px-1.5 text-xs [&_svg]:size-4\",\n \"2xs\": \"h-6 gap-1 rounded px-1.5 text-xs [&_svg]:size-3.5\",\n};\nconst ICON_BUTTON_TEXT_STRETCH_SIZES: Record<IconButtonSize, string> = {\n \"2xl\": \"min-h-16 self-stretch gap-2 px-4 text-base [&_svg]:size-7\",\n xl: \"min-h-12 self-stretch gap-2 px-3.5 text-sm [&_svg]:size-6\",\n lg: \"min-h-11 self-stretch gap-2 px-3 text-sm [&_svg]:size-5\",\n md: \"min-h-9 self-stretch gap-1.5 px-2.5 text-sm [&_svg]:size-5\",\n sm: \"min-h-8 self-stretch gap-1.5 px-2 text-sm [&_svg]:size-5\",\n xs: \"min-h-7 self-stretch gap-1 rounded px-1.5 text-xs [&_svg]:size-4\",\n \"2xs\": \"min-h-6 self-stretch gap-1 rounded px-1.5 text-xs [&_svg]:size-3.5\",\n};\n\n// The 48 px touch target at Large (§4), per size. `lg` and up are past it already\n// (2.75rem is 55 px at Large). `md` … `xs` grow their box to it. `2xs` is the action ON\n// a chip or a tab, whose box cannot grow without growing the chip: it keeps its box and\n// gets a 48 px hit area instead — an invisible `::after` centred on it, which takes the\n// taps a finger lands round the glyph.\nconst ICON_BUTTON_TOUCH_LARGE: Record<IconButtonSize, string> = {\n \"2xl\": \"\",\n xl: \"\",\n lg: \"\",\n md: TOUCH_TARGET_LARGE,\n sm: TOUCH_TARGET_LARGE,\n xs: TOUCH_TARGET_LARGE,\n \"2xs\":\n \"relative large:after:absolute large:after:inset-[calc((100%-48px)/2)] large:after:content-['']\",\n};\n\n// A tone re-colours the glyph without changing what the variant draws around it.\n// Each coloured tone comes in two resting looks, picked by `quiet` (see the prop):\n// QUIET is placeholder grey until the pointer or focus arrives, then the tone's\n// family; TONED wears the tone's colour at rest. The defaults are the looks each tone\n// had before `quiet` existed: `danger` is quiet — an action in every row of a list\n// must not shout from every row, so the red arrives only on the one row you are about\n// to act on — while `warning` and `info` are toned, because they mark the ONE thing\n// on the row to notice (keksdose's \"needs review\" flag, its reconcile action at\n// accounts-page:867), and quiet grey would hide the very thing they point out.\n// `muted` is quiet by definition and has no toned look; `default` has no tone.\n//\n// `success` is toned at rest too: the green is the news (keksdose's \"in sync\" chip).\n// `custom` paints from `--icon-button-tone`, which `toneColor` sets — for a glyph whose\n// colour FOLLOWS STATE across tones (keksdose's sync chip: rose, amber, sky, green as\n// the sync moves), where swapping `tone` would do but the colour is one variable.\nexport type IconButtonTone = \"default\" | \"muted\" | \"danger\" | \"warning\" | \"info\" | \"success\" | \"custom\";\n\ntype ColouredTone = \"danger\" | \"warning\" | \"info\" | \"success\" | \"custom\";\n\nconst ICON_BUTTON_TONES: Record<ColouredTone, { quiet: string; toned: string }> = {\n danger: {\n quiet:\n \"text-placeholder hover:bg-danger-soft hover:text-danger focus-visible:ring-danger-strong\",\n toned: \"text-danger hover:bg-danger-soft focus-visible:ring-danger-strong\",\n },\n warning: {\n quiet:\n \"text-placeholder hover:bg-warning-soft hover:text-warning focus-visible:ring-warning-strong\",\n // The ring is warning's loud line (0.33, k27): `--warning-border`, amber-300, was a\n // focus ring at 1.1:1 on the light surfaces.\n toned: \"text-warning hover:bg-warning-soft focus-visible:ring-warning-strong\",\n },\n info: {\n quiet:\n \"text-placeholder hover:bg-info-soft hover:text-info focus-visible:ring-[var(--info-border)]\",\n toned: \"text-info hover:bg-info-soft focus-visible:ring-[var(--info-border)]\",\n },\n success: {\n quiet:\n \"text-placeholder hover:bg-success-soft hover:text-success focus-visible:ring-[var(--success-border)]\",\n toned: \"text-success hover:bg-success-soft focus-visible:ring-[var(--success-border)]\",\n },\n // The hover fill and the ring are MIXED from the one colour, since a custom tone\n // brings no `-bg` / `-border` pair of its own. The fallback is the body colour, so a\n // `custom` tone with no colour set is the default look rather than invisible.\n custom: {\n quiet:\n \"text-placeholder hover:bg-[color-mix(in_srgb,var(--icon-button-tone,var(--text-primary))_12%,transparent)] hover:text-[var(--icon-button-tone,var(--text-primary))] focus-visible:ring-[color-mix(in_srgb,var(--icon-button-tone,var(--text-primary))_40%,transparent)]\",\n toned:\n \"text-[var(--icon-button-tone,var(--text-primary))] hover:bg-[color-mix(in_srgb,var(--icon-button-tone,var(--text-primary))_12%,transparent)] focus-visible:ring-[color-mix(in_srgb,var(--icon-button-tone,var(--text-primary))_40%,transparent)]\",\n },\n};\n\n// The translucent ink on hover, as Button's secondary/ghost (B′, see HOVER_INK).\nconst ICON_BUTTON_MUTED = `text-placeholder ${HOVER_INK} hover:text-primary`;\n\n/** Which tones sit quiet at rest when `quiet` is left out. */\nconst QUIET_BY_DEFAULT: Record<ColouredTone, boolean> = {\n danger: true,\n warning: false,\n info: false,\n success: false,\n custom: false,\n};\n\nfunction iconButtonToneClass(tone: IconButtonTone, quiet: boolean | undefined): string {\n if (tone === \"default\") return \"\";\n if (tone === \"muted\") return cn(ICON_BUTTON_MUTED, ICON_BUTTON_QUIET_DISABLED_REST);\n const isQuiet = quiet ?? QUIET_BY_DEFAULT[tone];\n const look = ICON_BUTTON_TONES[tone];\n return isQuiet ? cn(look.quiet, ICON_BUTTON_QUIET_DISABLED_REST) : look.toned;\n}\n\n// A disabled button must not answer the pointer. The hover classes above are plain\n// `hover:` (so a caller's `className=\"hover:…\"` still replaces them through\n// tailwind-merge), which means they fire on a disabled button too — the old grey\n// icon lit up on hover while refusing the click. Rather than rewrite every hover as\n// `enabled:hover:` (which never matches the `<a>` that `buttonClasses` also styles,\n// and would out-rank a caller's plain `hover:` override), each variant pins its\n// RESTING look under `disabled:hover:`, which out-ranks any `hover:` by specificity\n// and only ever matches a disabled button.\nconst ICON_BUTTON_DISABLED_REST: Record<IconButtonVariant, string> = {\n primary: \"disabled:hover:bg-surface-2\",\n secondary: \"disabled:hover:bg-transparent\",\n ghost: \"disabled:hover:bg-transparent\",\n danger: \"disabled:hover:bg-danger\",\n brand: \"disabled:hover:bg-brand\",\n link: \"disabled:hover:bg-transparent disabled:hover:no-underline\",\n overlay: \"disabled:hover:bg-[color-mix(in_srgb,var(--bg-inverse)_60%,transparent)]\",\n shutter: \"disabled:hover:bg-media-scrim\",\n};\n\n// The quiet looks change the glyph on hover, so they pin their resting glyph the\n// same way. A toned look keeps its glyph on hover and needs no pin.\nconst ICON_BUTTON_QUIET_DISABLED_REST = \"disabled:hover:text-placeholder\";\n\n// `variant=\"overlay\"`: a round, translucent disc for a control that sits ON a photo\n// (keksdose's receipt-scan preview: close, rotate, retake over the camera image).\n// The inverse pair, not the surface one: what has to hold is the contrast between the\n// disc and its glyph, whatever the picture underneath is, and `--bg-inverse` /\n// `--text-inverse` are the one token pair defined as each other's opposite in both\n// themes. The disc is the inverse at 60% (`color-mix`, so it stays a token a palette\n// can re-point); the blur keeps a busy background from breaking the glyph's edge.\nconst ICON_BUTTON_OVERLAY =\n \"rounded-full bg-[color-mix(in_srgb,var(--bg-inverse)_60%,transparent)] text-inverse backdrop-blur-sm hover:bg-[color-mix(in_srgb,var(--bg-inverse)_75%,transparent)] focus-visible:ring-[var(--text-inverse)]\";\n\n// `variant=\"shutter\"`: the camera's release — keksdose's camera-capture.tsx:248 draws\n// it by hand as a 64px circle with a thick white ring round a translucent fill. It sits\n// on a live camera picture, which is dark whatever the theme, so it uses the MEDIA\n// tokens (tokens.css), white ink on a dark scrim in both themes — the inverse pair\n// `overlay` uses would give a dark ring on a dark viewfinder in dark mode. The focus ring stands off by 2px, or it would\n// merge into the ring that is part of the look.\nconst ICON_BUTTON_SHUTTER =\n \"rounded-full border-4 border-media-ink bg-media-scrim text-media-ink backdrop-blur-sm hover:bg-media-scrim-hover focus-visible:ring-media-ink focus-visible:ring-offset-2 focus-visible:ring-offset-media-scrim-hover\";\n\nexport type IconButtonVariant = ButtonVariant | \"overlay\" | \"shutter\";\n\n/**\n * The glyph's size in px, over the one the box size implies — see\n * {@link IconButtonProps.glyphSize}.\n */\nexport type IconButtonGlyphSize = 12 | 14 | 16 | 20 | 24 | 28;\n\nconst ICON_BUTTON_GLYPH_SIZES: Record<IconButtonGlyphSize, string> = {\n 12: \"[&_svg]:size-3\",\n 14: \"[&_svg]:size-3.5\",\n 16: \"[&_svg]:size-4\",\n 20: \"[&_svg]:size-5\",\n 24: \"[&_svg]:size-6\",\n 28: \"[&_svg]:size-7\",\n};\n\n// `pending`'s spinner, the size of the glyph it replaces (the `[&_svg]` rules above\n// size only an svg, and the spinner is a bordered span).\nconst ICON_BUTTON_SPINNER: Record<IconButtonSize, string> = {\n \"2xl\": \"size-7\",\n xl: \"size-6\",\n lg: \"size-5\",\n md: \"size-5\",\n sm: \"size-5\",\n xs: \"size-4\",\n \"2xs\": \"size-3.5\",\n};\nconst ICON_BUTTON_SPINNER_GLYPH: Record<IconButtonGlyphSize, string> = {\n 12: \"size-3\",\n 14: \"size-3.5\",\n 16: \"size-4\",\n 20: \"size-5\",\n 24: \"size-6\",\n 28: \"size-7\",\n};\n\nexport interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {\n /** Any {@link ButtonVariant}; `overlay` — a round translucent disc for use over\n * an image (see `ICON_BUTTON_OVERLAY`); or `shutter` — a camera's release, a ringed\n * disc over the viewfinder (see `ICON_BUTTON_SHUTTER`; pair it with `size=\"2xl\"`). */\n variant?: IconButtonVariant;\n /** Box size: 2xl = 64px with a 28px icon (a camera shutter), xl = 48px with a 24px\n * icon, lg = 44px (a phone's touch target), md = 36px (matches the top bar),\n * sm = 32px — all three with a 20px icon;\n * xs = 28px with a 16px icon (an action in a list row), 2xs = 24px with a 14px\n * icon (an action on a chip or a tab). `glyphSize` overrides the icon. */\n size?: IconButtonSize;\n /**\n * The icon's size in px, when the box's own is not the one wanted. keksdose's sync\n * chip (app/sync-status-indicator.tsx:142) is a 24px round chip round a 20px cloud —\n * `size=\"2xs\" glyphSize={20} shape=\"round\"` — where `2xs` alone draws 14px. A number\n * over a new size step because the box and the glyph are two independent choices; a\n * step per combination (`\"sm-lg\"`) would have to be named for every one.\n */\n glyphSize?: IconButtonGlyphSize;\n /**\n * A small mark on the button's corner — the sync chip's 12px check over its cloud\n * (\"all good\", feedback #67). Rendered OUTSIDE the `<button>`, in a wrapper beside\n * it, so the box's `[&_svg]:size-*` rule that sizes the glyph does not blow the badge\n * up to the glyph's size; a bare `<Check />` comes out 12px, on a\n * `--bg-surface` disc that keeps it legible over the glyph. Decorative\n * (`aria-hidden`): the name (`label` / `aria-label`) must already carry the state.\n * With a badge, `className` still lands on the button; the wrapper is the flex item.\n */\n badge?: ReactNode;\n /**\n * `keep`: a disabled button keeps its full colour (no 50% drop) and an ordinary\n * cursor. For a STATUS that is also an action — the sync chip shows \"in sync\" in\n * green and refreshes on click, and is disabled while offline or syncing, when the\n * state it shows matters most; dimmed it read as broken (keksdose wrote\n * `disabled:cursor-default` by hand and left out the dimming). Default `dim`.\n */\n disabledStyle?: \"dim\" | \"keep\";\n /**\n * Fill the height of the flex row it stands in, keeping its width — the delete at\n * the end of keksdose's split line (transaction-fields:162), level with the labelled\n * fields beside it rather than floating at half their height. {@link Button}'s\n * `stretch`; no effect outside a flex row. With a `label`, the tooltip's wrapper is\n * what stretches.\n */\n stretch?: boolean;\n /** `round`: a circle instead of the rounded square — a compact status chip in a top\n * bar (keksdose's sync indicator). `overlay` is always round. */\n shape?: \"square\" | \"round\";\n /**\n * The colour of `tone=\"custom\"`, as any CSS colour — a token (`var(--warning)`),\n * ideally. Sets `--icon-button-tone` on the button, so passing a different one per\n * state re-colours the glyph, its hover fill and its focus ring together. Passing it\n * implies `tone=\"custom\"`. Or leave it out and set the variable yourself from a class\n * (`className=\"[--icon-button-tone:var(--success)]\"`).\n *\n * Or a `{ light, dark }` pair, one colour per theme — keksdose: a raw colour that reads\n * on the light surface (`#be123c`) is too dark on the dark one, and an inline style\n * cannot say \"in dark mode\". See {@link IconButtonToneColor}.\n */\n toneColor?: IconButtonToneColor;\n /** Glyph colour over the variant. `muted`: placeholder grey, full text colour on\n * hover. `danger`: the same grey at rest, `--danger` on hover and focus — for a\n * remove/delete that repeats down a list. `warning`: amber at rest — a flag that\n * wants attention. `info`: sky at rest — a notice-worthy but harmless action\n * (keksdose's reconcile). `success`: green at rest — a good state worth seeing.\n * `custom`: the colour of `toneColor` (see there). Default: the variant's own colours. See `quiet` for\n * turning a coloured tone's resting look the other way. */\n tone?: IconButtonTone;\n /**\n * Whether a coloured tone (`danger`, `warning`, `info`) waits for the pointer:\n * `true` is placeholder grey at rest and the tone's colour on hover and focus;\n * `false` wears the tone's colour at rest. Left out, each tone keeps its own\n * default — `danger` quiet, `warning` and `info` not. Ignored for `muted` (quiet by\n * definition) and `default` (no tone).\n *\n * `quiet={false}` on `danger` is for a destructive action that stands ALONE, where\n * hover-to-reveal hides it: keksdose's phone bulk bar (mobile-bulk-bar.tsx) has one\n * \"delete all\" and a touch screen that never hovers, so it painted rose by hand.\n * `quiet` on `warning`/`info` is the same switch the other way, for a flag repeated\n * down a list. A boolean over the tone rather than a new tone (`danger-solid`) or an\n * `emphasis` scale: there are exactly two resting looks, every coloured tone has\n * both, and which one fits is a question about the SITE (alone or repeated, touch or\n * pointer), not about the tone — so it is one switch the family shares.\n */\n quiet?: boolean;\n /**\n * Make it a toggle button. `true` sets `aria-pressed=\"true\"` and draws the \"on\" look\n * (brand glyph on the quiet brand fill); `false` sets `aria-pressed=\"false\"` with the\n * normal look, so a screen reader still hears a toggle that is off. Left out, it is\n * an ordinary button with no `aria-pressed` — or whatever `aria-pressed` the caller\n * passes. For keksdose's budget share toggle (budgets-page:291), which paints its own\n * brand colour over a ghost button today. Keep the `aria-label` the same in both\n * states (\"Share budget\"): the pressed state already says whether it is on.\n */\n pressed?: boolean;\n /** Keep the click (and the Enter/Space that produces it) from reaching an\n * ancestor's handler — for an action inside a clickable table row or card.\n *\n * **Not a licence to put this inside another `<button>`.** HTML forbids any\n * interactive content, and any element with a `tabindex`, inside a button — so\n * the `<span role=\"button\" tabIndex={0}>` workaround is invalid too — and ARIA\n * makes a button's children presentational, so a screen reader flattens the\n * inner one into the outer one's name and it cannot be reached at all. Make the\n * row's main action a button that fills the row and put this one BESIDE it, on\n * top:\n *\n * ```tsx\n * <div className=\"relative\">\n * <button className=\"w-full pe-16 …\" onClick={open}>…row…</button>\n * <div className=\"absolute inset-y-0 end-2 flex items-center gap-0.5\">\n * <IconButton size=\"xs\" tone=\"danger\" aria-label=\"Delete\" onClick={remove}>\n * <Trash2 />\n * </IconButton>\n * </div>\n * </div>\n * ```\n *\n * The pair look exactly like the nested version, click exactly like it, and\n * are two tab stops a screen reader can tell apart. */\n stopPropagation?: boolean;\n /**\n * The button's name, said once: it becomes the `aria-label` AND the text of a kit\n * {@link Tooltip} round the button.\n *\n * Every app writes the pair by hand. lenkbank's nine icon actions all carry\n * `aria-label={t(x)} title={t(x)}` (shared/lib/table-columns.tsx:103,\n * projects-page.tsx:83, setpoint/segment-list.tsx:198/207, profile-bar.tsx:128/142,\n * shortcut-dialog.tsx:815/857/1007) — and `title` is the browser's own tooltip,\n * which shows late, never on focus and never on touch, and looks like no other\n * label in the kit. kastlan's RowAction (shared/components/data-table/row-action.tsx)\n * and keksdose's icon wrappers each exist to put a Tooltip round an IconButton.\n *\n * A caller's own `aria-label` still wins, for the rare name that should be longer\n * than the bubble. The bubble is visual only: it would otherwise describe the button\n * with its own name, and a screen reader would read the same word twice. Tooltip's\n * default placement applies — in place, or portalled inside a scroll container —\n * unless `tooltipPortal` says otherwise.\n */\n label?: string;\n /**\n * Show `label` as visible text beside the icon at Large and Extra large (0.32,\n * docs/text-size-harmonization.md §4, §10.8). A reader who asked for bigger type is\n * the one an unlabelled glyph fails, and on touch the tooltip never opens. Then there\n * is no tooltip: the words are on the button.\n *\n * Default `true` — except for `variant=\"overlay\"` and `\"shutter\"`, which sit ON a\n * picture, and inside {@link CompactControls} (AppShell's top bar, `RowActions`'\n * inline icons). `false`: the icon alone at every size, for an icon on content — a\n * receipt overlay, a map's zoom, a viewfinder, a photo thumbnail. That must be rare;\n * a dense row's actions belong in `RowActions`, which collapses them into a \"⋯\" menu\n * at Large instead (§10.8). Normal never shows it. `tooltip={false}` (a universally\n * read glyph, a dialog's ✕) keeps the icon alone too.\n */\n labelVisible?: boolean;\n /** Show `label` as a tooltip. Default `true`; `false` keeps `label` as the\n * accessible name only — for a button whose glyph is universally read (a close ✕ in\n * a dialog header) or that already sits under a tooltip of its own. It also keeps the\n * label from showing as text at Large (see `labelVisible`). */\n tooltip?: boolean;\n /** Where the `label` tooltip opens. See {@link Tooltip}'s `side`. */\n tooltipSide?: TooltipSide;\n /** Passed to the `label` tooltip's `portal`. Left out, Tooltip decides (see there). */\n tooltipPortal?: boolean;\n /**\n * Mount the `label` bubble only while it is up — {@link Tooltip}'s `lazy`. Default\n * `true` since 0.16.0; `false` is the always-mounted in-place bubble of before.\n *\n * Why the label bubble can be lazy when Tooltip's default is not: this bubble\n * describes nothing. It is wrapped round the button in a fragment precisely so that\n * it is NOT the button's `aria-describedby` (it would repeat the button's own name),\n * so there is no description for a lazy mount to take away — the name is the\n * `aria-label` either way, read in browse mode and on focus alike. What the\n * always-mounted bubble did do was sit in the DOM as a `role=\"tooltip\"` node full of\n * text: every IconButton in a row added one to `getAllByRole(\"tooltip\")` and its label\n * to the row's `textContent`, and two keksdose tests tripped over that (0.15.5 P6).\n * The cost is the one {@link Tooltip}'s \"Lazy in place\" note names — a test that\n * looked the bubble up without hovering now has to hover (or focus) first; pass\n * `false` while it is migrated. A `disabledReason` bubble is unaffected: that one\n * stays mounted as it was.\n */\n tooltipLazy?: boolean;\n /**\n * Busy — the action is in flight: the glyph is swapped for a spinner of the same\n * size, the button is `aria-busy`, and clicks (and the Enter/Space and form\n * submission they stand for) are swallowed, so a second press cannot start the\n * action twice. {@link Button}'s `pending`, with the same contract: `aria-disabled`\n * rather than `disabled`, so the button that was pressed keeps its focus. The name\n * (`label` / `aria-label`) stays — `aria-busy` is what says it is working. A SWAP\n * rather than Button's overlay: the box is square and fixed, so there is no width\n * for the glyph to hold, and a spinner drawn over a glyph would be two marks in one\n * 20px square.\n */\n pending?: boolean;\n /**\n * Why the action is not available — {@link Button}'s `disabledReason`, on the icon\n * button: keksdose's accounts page cannot hide an account with a balance or delete\n * one with bookings (accounts-page:879/913 — \"hide requires zero\", \"delete blocked\"),\n * nor its budgets page delete the only budget (budgets-page:330), and each wrapped a\n * natively disabled IconButton in a Tooltip that swaps its label for the reason — a\n * bubble a keyboard never opens, since `disabled` leaves the tab order, and a reason\n * a screen reader never hears.\n *\n * The same contract as Button's: `aria-disabled` instead of `disabled`, so it stays\n * focusable and hoverable; clicks (and the Enter/Space they stand for) are swallowed\n * — `stopPropagation` still applies, so a locked action in a clickable row does not\n * open the row either; the reason shows in the kit {@link Tooltip} and is attached\n * through `aria-describedby` as a `hidden` copy. The NAME stays `aria-label` /\n * `label` — \"Delete\", described by \"The active budget cannot be deleted\" — and the\n * bubble shows the reason in place of the label, since the glyph already says what\n * the button is and the reason is the news. Shown even with `tooltip={false}`: a\n * reason nobody can see is not one. Wins over `disabled`, as on Button.\n *\n * 0.32: at Large and on a touch screen the reason is a line under the button rather\n * than a bubble — see `disabledReasonDisplay`.\n */\n disabledReason?: ReactNode;\n /** Where `disabledReason` shows — {@link Button}'s `disabledReasonDisplay`. */\n disabledReasonDisplay?: DisabledReasonDisplay;\n /** This action COMMITS — {@link Button}'s `commit`: under a locked\n * {@link WriteLockProvider} it takes the `disabledReason` path with the lock's\n * reason. No effect without a lock. A {@link CommitScope} since 0.33. */\n commit?: CommitScope;\n /** The `<button>` element — a prop in React 19, as on {@link Button}. */\n ref?: Ref<HTMLButtonElement>;\n /** Only on the link form — see {@link IconButtonLinkProps}. */\n href?: never;\n renderLink?: never;\n external?: never;\n replace?: never;\n reloadDocument?: never;\n}\n\n/**\n * `<IconButton href>`: an icon-only LINK — keksdose F2 and kastlan 45, whose icon\n * actions that navigate (a row's \"open\", a card's \"edit\" that goes to the edit page, a\n * top bar's settings cog) were `<IconButton onClick={() => navigate(…)}>`: a button that\n * changes the page, with no middle click, no \"open in new tab\" and no URL on hover.\n *\n * The same rule as {@link Button}'s `href` (see {@link ButtonLinkProps}): an in-app\n * `href` goes through `renderLink`, else the provider's `linkComponent`; an external one\n * or an in-page `#anchor` stays a plain `<a>`; `external` opens a new tab and says so to\n * a screen reader; `replace` and `reloadDocument` as on Button. The look, the `label` →\n * `aria-label` + Tooltip, the badge, `toneColor` and `stopPropagation` are the button's.\n *\n * `disabled` renders an inert `<a>` with no `href` — `role=\"link\"`, `aria-disabled`, out\n * of the tab order, dimmed — like Button's disabled link. `pressed`, `disabledReason` and\n * `disabledStyle` are a button's and are typed out: a link is not a toggle, and a link\n * that explains why it cannot be followed is a job for a disabled BUTTON.\n */\nexport interface IconButtonLinkProps\n extends Omit<AnchorHTMLAttributes<HTMLAnchorElement>, \"type\" | \"href\">,\n Pick<\n IconButtonProps,\n | \"variant\"\n | \"size\"\n | \"glyphSize\"\n | \"badge\"\n | \"stretch\"\n | \"shape\"\n | \"toneColor\"\n | \"tone\"\n | \"quiet\"\n | \"stopPropagation\"\n | \"label\"\n | \"labelVisible\"\n | \"tooltip\"\n | \"tooltipSide\"\n | \"tooltipPortal\"\n | \"tooltipLazy\"\n > {\n href: string;\n /** See {@link ButtonLinkProps.renderLink}. */\n renderLink?: KitLinkComponent;\n /** See {@link ButtonLinkProps.external}. */\n external?: boolean;\n /** See {@link ButtonLinkProps.replace}. */\n replace?: boolean;\n /** See {@link ButtonLinkProps.reloadDocument}. */\n reloadDocument?: boolean;\n /** See {@link ButtonLinkProps.disabled}. */\n disabled?: boolean;\n ref?: Ref<HTMLAnchorElement>;\n type?: never;\n form?: never;\n pressed?: never;\n disabledReason?: never;\n disabledReasonDisplay?: never;\n commit?: never;\n disabledStyle?: never;\n pending?: never;\n}\n\n/**\n * Whether an IconButton draws its `label` as text (see `labelVisible`): at Large and\n * above, with a label to draw, not switched off by the caller, the variant (`overlay`,\n * `shutter`), `tooltip={false}` or a {@link CompactControls} region.\n */\nfunction useIconButtonText(\n label: string | undefined,\n labelVisible: boolean | undefined,\n variant: IconButtonVariant,\n tooltip: boolean,\n): boolean {\n const large = useLargeText();\n const compact = useContext(CompactControlsContext);\n if (!large || label === undefined || label === \"\" || !tooltip) return false;\n const visible = labelVisible ?? (!compact && variant !== \"overlay\" && variant !== \"shutter\");\n return visible;\n}\n\n/** The visible label (see `labelVisible`). */\nfunction IconButtonText({ label }: { label: string }) {\n return <span data-slot=\"icon-button-label\">{label}</span>;\n}\n\n/**\n * `toneColor`: one CSS colour, or one per theme.\n *\n * The pair is two custom properties on the element — `--icon-button-tone-light` and\n * `--icon-button-tone-dark` — and a class that points `--icon-button-tone` at one or the\n * other by the kit's `dark` variant (`.dark` on an ancestor, tokens.css), so the colour\n * follows a theme switch in CSS alone, with no re-render and no JS reading the theme.\n * Both values are any CSS colour; a token that already flips (`var(--warning)`) needs no\n * pair.\n */\nexport type IconButtonToneColor = string | { light: string; dark: string };\n\nconst ICON_BUTTON_TONE_PAIR =\n \"[--icon-button-tone:var(--icon-button-tone-light)] dark:[--icon-button-tone:var(--icon-button-tone-dark)]\";\n\n/** The inline custom properties `toneColor` sets over the caller's `style`. */\nfunction toneColorStyle(toneColor: IconButtonToneColor | undefined, style: CSSProperties | undefined) {\n if (toneColor === undefined) return style;\n const vars: Record<string, string> =\n typeof toneColor === \"string\"\n ? { \"--icon-button-tone\": toneColor }\n : { \"--icon-button-tone-light\": toneColor.light, \"--icon-button-tone-dark\": toneColor.dark };\n return { ...style, ...vars } as CSSProperties;\n}\n\n/** Everything but the element: the look both forms of IconButton share. */\nfunction iconButtonLook({\n variant,\n size,\n stretch,\n glyphSize,\n shape,\n tone,\n quiet,\n toneColor,\n withText = false,\n}: {\n variant: IconButtonVariant;\n size: IconButtonSize;\n stretch: boolean | undefined;\n glyphSize: IconButtonGlyphSize | undefined;\n shape: \"square\" | \"round\";\n tone: IconButtonTone;\n quiet: boolean | undefined;\n toneColor: IconButtonToneColor | undefined;\n /** The label is drawn beside the glyph (see `labelVisible`). */\n withText?: boolean;\n}) {\n return cn(\n ICON_BUTTON_BASE,\n withText\n ? cn(\"font-medium whitespace-nowrap\", stretch ? ICON_BUTTON_TEXT_STRETCH_SIZES[size] : ICON_BUTTON_TEXT_SIZES[size])\n : stretch\n ? ICON_BUTTON_STRETCH_SIZES[size]\n : ICON_BUTTON_SIZES[size],\n // With text the pill is a 48 px target at any size; the square follows its size.\n withText ? TOUCH_TARGET_LARGE : ICON_BUTTON_TOUCH_LARGE[size],\n glyphSize !== undefined && ICON_BUTTON_GLYPH_SIZES[glyphSize],\n // After the size, so the overlay's `rounded-full` beats the small sizes' `rounded`.\n variant === \"overlay\"\n ? ICON_BUTTON_OVERLAY\n : variant === \"shutter\"\n ? ICON_BUTTON_SHUTTER\n : buttonVariantClasses[variant],\n shape === \"round\" && \"rounded-full\",\n ICON_BUTTON_DISABLED_REST[variant],\n iconButtonToneClass(tone, quiet),\n toneColor !== undefined && typeof toneColor !== \"string\" && ICON_BUTTON_TONE_PAIR,\n );\n}\n\n/** The badge beside the control, not in it — see `badge`. The wrapper is only a\n * positioning box: the control inside it keeps its own focus ring and hit area. */\nfunction withIconButtonBadge(control: ReactElement, badge: ReactNode, stretch: boolean | undefined) {\n if (!hasContent(badge)) return control;\n return (\n <span className={cn(\"relative inline-flex shrink-0\", stretch && \"self-stretch\")}>\n {control}\n <span\n aria-hidden\n data-slot=\"icon-button-badge\"\n className=\"pointer-events-none absolute -end-0.5 -bottom-0.5 inline-flex rounded-full bg-surface [&_svg]:size-3\"\n >\n {badge}\n </span>\n </span>\n );\n}\n\n// Declared link-first, as Button is, so `<IconButton href>` picks the link props and\n// `ComponentProps<typeof IconButton>` (the LAST signature) is still the button's.\nexport function IconButton(props: IconButtonLinkProps): ReactElement;\nexport function IconButton(props: IconButtonProps): ReactElement;\nexport function IconButton(props: IconButtonProps | IconButtonLinkProps): ReactElement {\n if (props.href !== undefined) return <IconButtonLink {...(props as IconButtonLinkProps)} />;\n return <IconButtonElement {...(props as IconButtonProps)} />;\n}\n\nfunction IconButtonLink({\n href,\n renderLink,\n external,\n replace,\n reloadDocument,\n disabled,\n variant = \"ghost\",\n size = \"md\",\n tone: toneProp,\n quiet,\n stopPropagation,\n stretch,\n shape = \"square\",\n toneColor,\n label,\n labelVisible,\n tooltip = true,\n tooltipSide,\n tooltipPortal,\n tooltipLazy = true,\n glyphSize,\n badge,\n className,\n style,\n target,\n rel,\n onClick,\n onKeyDown,\n children: glyph,\n ref,\n ...rest\n}: IconButtonLinkProps) {\n const withText = useIconButtonText(label, labelVisible, variant, tooltip);\n const children = withText ? (\n <>\n {glyph}\n <IconButtonText label={label!} />\n </>\n ) : (\n glyph\n );\n const kitLink = useKitLink();\n const common = useKitLabels(\"common\", DEFAULT_COMMON_LABELS);\n const tone = toneProp ?? (toneColor !== undefined ? \"custom\" : \"default\");\n // Button's rule, word for word: `reloadDocument` bypasses the router, an external\n // href or an in-page `#anchor` never reaches it.\n const Link = reloadDocument ? undefined : pickLinkRenderer<KitLinkProps>(renderLink, kitLink, href);\n const newTab = !disabled && (external || target === \"_blank\");\n const name = rest[\"aria-label\"] ?? label;\n // \"(opens in a new tab)\" joins the NAME here, not the body: an icon link has no text\n // for an sr-only span to follow, and `aria-label` would override one anyway.\n const ariaLabel = name !== undefined && newTab ? `${name} (${common.opensInNewTab})` : name;\n const handleClick = (e: MouseEvent<HTMLAnchorElement>) => {\n if (stopPropagation) e.stopPropagation();\n onClick?.(e);\n };\n const handleKeyDown = (e: KeyboardEvent<HTMLAnchorElement>) => {\n // A link activates on Enter only; that key still bubbles to a clickable row.\n if (stopPropagation && e.key === \"Enter\") e.stopPropagation();\n onKeyDown?.(e);\n };\n const cls = cn(\n iconButtonLook({ variant, size, stretch, glyphSize, shape, tone, quiet, toneColor, withText }),\n // BASE's `disabled:` classes never match an `<a>` — Button's disabled link look.\n disabled && \"pointer-events-none cursor-not-allowed opacity-50\",\n className,\n );\n const look = { style: toneColorStyle(toneColor, style), className: cls };\n let control: ReactElement;\n if (disabled) {\n control = (\n <a {...rest} {...look} ref={ref} role=\"link\" aria-disabled=\"true\" aria-label={ariaLabel}>\n {children}\n </a>\n );\n } else if (external || !Link) {\n control = (\n <a\n {...rest}\n {...look}\n ref={ref}\n href={href}\n aria-label={ariaLabel}\n target={external ? \"_blank\" : target}\n rel={external ? (rel ?? \"noopener noreferrer\") : rel}\n onClick={external ? handleClick : replacingClick(handleClick, href, replace, target)}\n onKeyDown={handleKeyDown}\n >\n {children}\n </a>\n );\n } else {\n control = (\n <RenderedButtonLink\n render={Link}\n {...rest}\n {...look}\n {...routerLinkNavigation(replace)}\n ref={ref}\n href={href}\n aria-label={ariaLabel}\n target={target}\n rel={rel}\n onClick={handleClick}\n onKeyDown={handleKeyDown}\n >\n {children}\n </RenderedButtonLink>\n );\n }\n control = withIconButtonBadge(control, badge, stretch);\n // The words are on the link at Large: a bubble would only repeat them.\n if (!tooltip || withText || label === undefined || label === \"\") return control;\n return (\n // A fragment, as on the button: the bubble says what `aria-label` already does.\n <Tooltip\n label={label}\n side={tooltipSide}\n portal={tooltipPortal}\n lazy={tooltipLazy}\n className={stretch ? \"self-stretch\" : undefined}\n >\n <>{control}</>\n </Tooltip>\n );\n}\n\nfunction IconButtonElement(\n {\n variant = \"ghost\",\n size = \"md\",\n tone: toneProp,\n quiet,\n pressed,\n stopPropagation,\n stretch,\n shape = \"square\",\n toneColor,\n label,\n labelVisible,\n tooltip = true,\n tooltipSide,\n tooltipPortal,\n tooltipLazy = true,\n disabledReason: ownDisabledReason,\n disabledReasonDisplay,\n commit,\n pending,\n glyphSize,\n badge,\n disabledStyle = \"dim\",\n className,\n style,\n onClick,\n onKeyDown,\n children,\n href: _href,\n renderLink: _renderLink,\n external: _external,\n replace: _replace,\n reloadDocument: _reloadDocument,\n ref,\n ...rest\n }: IconButtonProps,\n) {\n const tone = toneProp ?? (toneColor !== undefined ? \"custom\" : \"default\");\n const keep = disabledStyle === \"keep\";\n const withText = useIconButtonText(label, labelVisible, variant, tooltip);\n const reasonId = useId();\n const disabledReason = useCommitReason(commit, ownDisabledReason);\n const locked = hasContent(disabledReason);\n // Pending is inert like a locked button — focusable, every activation swallowed —\n // without the locked look: the spinner is the state.\n const inert = locked || Boolean(pending);\n const ownDescribedBy = rest[\"aria-describedby\"];\n const button = (\n <button\n ref={ref}\n {...rest}\n disabled={inert ? undefined : rest.disabled}\n aria-disabled={inert || rest[\"aria-disabled\"]}\n aria-busy={pending || rest[\"aria-busy\"]}\n aria-describedby={locked ? (ownDescribedBy ? `${ownDescribedBy} ${reasonId}` : reasonId) : ownDescribedBy}\n aria-label={rest[\"aria-label\"] ?? label}\n aria-pressed={pressed ?? rest[\"aria-pressed\"]}\n style={toneColorStyle(toneColor, style)}\n onClick={(e) => {\n if (stopPropagation) e.stopPropagation();\n // `preventDefault` too: a locked (or pending) submit must not submit its form.\n if (inert) {\n e.preventDefault();\n return;\n }\n onClick?.(e);\n }}\n onKeyDown={(e) => {\n // A button turns Enter/Space into a click of its own, but the KEY still\n // bubbles — and a clickable row listening for Enter would act on it too.\n if (stopPropagation && (e.key === \"Enter\" || e.key === \" \")) e.stopPropagation();\n onKeyDown?.(e);\n }}\n className={cn(\n iconButtonLook({ variant, size, stretch, glyphSize, shape, tone, quiet, toneColor, withText }),\n pressed && ICON_BUTTON_PRESSED,\n // The disabled look for the focusable kind of disabled, as on Button.\n locked && (keep ? \"cursor-default\" : \"cursor-not-allowed opacity-50\"),\n keep && \"disabled:cursor-default disabled:opacity-100\",\n pending && \"cursor-progress\",\n className,\n )}\n >\n {pending ? (\n // Decorative, as on Button: `aria-busy` says it. Sized to the glyph it stands\n // in for, in the glyph's colour, so the swap does not change the button's look.\n <Spinner\n label={null}\n className={cn(\n \"border-current/30 border-t-current\",\n glyphSize !== undefined ? ICON_BUTTON_SPINNER_GLYPH[glyphSize] : ICON_BUTTON_SPINNER[size],\n )}\n />\n ) : (\n children\n )}\n {withText && <IconButtonText label={label!} />}\n </button>\n );\n const control = withIconButtonBadge(button, badge, stretch);\n if (locked) {\n return (\n // Button's shape: the reason as a line under it, or the bubble with a hidden copy\n // for the description (see LockedReason).\n <LockedReason\n lock={{ reason: disabledReason, reasonId }}\n display={disabledReasonDisplay}\n side={tooltipSide}\n portal={tooltipPortal}\n className={stretch ? \"self-stretch\" : undefined}\n >\n {control}\n </LockedReason>\n );\n }\n // The words are on the button at Large: a bubble would only repeat them.\n if (!tooltip || withText || label === undefined || label === \"\") return control;\n return (\n // A fragment, so Tooltip leaves the button's description alone: the bubble says\n // exactly what `aria-label` already does. Lazy by default — see `tooltipLazy`.\n <Tooltip\n label={label}\n side={tooltipSide}\n portal={tooltipPortal}\n lazy={tooltipLazy}\n className={stretch ? \"self-stretch\" : undefined}\n >\n <>{control}</>\n </Tooltip>\n );\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../src/components/icon-button.tsx"],"sourcesContent":["import { useContext, useId } from \"react\";\nimport type {\n AnchorHTMLAttributes,\n ButtonHTMLAttributes,\n CSSProperties,\n KeyboardEvent,\n MouseEvent,\n ReactElement,\n ReactNode,\n Ref,\n} from \"react\";\nimport { cn } from \"../lib/cn\";\nimport { FOCUS_RING_WIDTH } from \"./focus-ring\";\nimport { useLargeText } from \"../hooks/use-large-text\";\nimport { Tooltip, type TooltipSide } from \"./tooltip\";\nimport { DEFAULT_COMMON_LABELS, useKitLabels, useKitLink } from \"../i18n/kit-labels\";\nimport type { KitLinkComponent, KitLinkProps } from \"../i18n/kit-labels\";\nimport { pickLinkRenderer, replacingClick, routerLinkNavigation } from \"./text-link\";\nimport { useCommitReason } from \"./write-lock\";\nimport type { CommitScope } from \"./write-lock\";\nimport { hasContent } from \"../lib/presence\";\nimport { CompactControlsContext, LockedReason, type DisabledReasonDisplay } from \"./field-parts\";\nimport { Spinner } from \"./spinner\";\nimport {\n HOVER_INK,\n ICON_BUTTON_PRESSED,\n RenderedButtonLink,\n HIT_AREA_LARGE,\n TOUCH_TARGET_LARGE,\n buttonVariantClasses,\n} from \"./button-look\";\nimport type { ButtonVariant } from \"./button-look\";\n\n// Canonical square icon-only button. <Button> carries TEXT geometry — px-3 py-2\n// gap-2 — so rendering a bare glyph through it gives a small icon in a wide,\n// text-shaped box. This fixes a square box instead and, crucially, forces the\n// child icon to a size set by the BOX (20px at md/sm) via `[&_svg]:size-*` so a\n// caller cannot under-size it. Use it wherever an action is a bare icon\n// (edit/delete/tools) so they all match the top-bar icon buttons and can never\n// drift apart again.\n//\n// Draws its colours from the same `buttonVariantClasses` map as <Button>, so the\n// two re-skin together with the palette.\nconst ICON_BUTTON_BASE = cn(\n \"inline-flex items-center justify-center rounded-md transition-colors focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed\",\n FOCUS_RING_WIDTH,\n);\n\nexport type IconButtonSize = \"2xl\" | \"xl\" | \"lg\" | \"md\" | \"sm\" | \"xs\" | \"2xs\";\n\n// Box and glyph together, so a 24px chip action cannot end up holding a 20px icon\n// that touches its edges. The two small steps are lenkbank's list-row (28px) and\n// chip (24px) actions, which it had hand-rolled beside the kit's 32/36px ones.\nconst ICON_BUTTON_SIZES: Record<IconButtonSize, string> = {\n // 64px with a 28px glyph: a camera's shutter (keksdose invoices/camera-capture.tsx:248,\n // `size-16` by hand) — the one control of a full-screen capture view, thumb-sized.\n \"2xl\": \"size-16 [&_svg]:size-7\",\n // 48px with a 24px glyph: the one primary action of a phone screen or sheet, a step\n // past the 44px minimum rather than on it.\n xl: \"size-12 [&_svg]:size-6\",\n // The 44px touch target (WCAG 2.5.5's size) with the same 20px glyph — keksdose's\n // bulk-action bars write `size-11` by hand over an `md` button to get it on phones.\n lg: \"size-11 [&_svg]:size-5\",\n md: \"size-9 [&_svg]:size-5\",\n sm: \"size-8 [&_svg]:size-5\",\n xs: \"size-7 rounded [&_svg]:size-4\",\n \"2xs\": \"size-6 rounded [&_svg]:size-3.5\",\n};\n\n// `stretch`: the same WIDTH and glyph, and a height taken from the row instead of\n// stated — `size-*` sets a height that `self-stretch` cannot override, so the box is\n// written as a width plus a minimum height (the square it would otherwise be).\nconst ICON_BUTTON_STRETCH_SIZES: Record<IconButtonSize, string> = {\n \"2xl\": \"w-16 min-h-16 self-stretch [&_svg]:size-7\",\n xl: \"w-12 min-h-12 self-stretch [&_svg]:size-6\",\n lg: \"w-11 min-h-11 self-stretch [&_svg]:size-5\",\n md: \"w-9 min-h-9 self-stretch [&_svg]:size-5\",\n sm: \"w-8 min-h-8 self-stretch [&_svg]:size-5\",\n xs: \"w-7 min-h-7 self-stretch rounded [&_svg]:size-4\",\n \"2xs\": \"w-6 min-h-6 self-stretch rounded [&_svg]:size-3.5\",\n};\n\n// The label as visible text beside the glyph (0.32, §4: at Large \"IconButton shows its\n// label\"): the same height and glyph as the square, a width taken from the words. A\n// map of its own rather than `w-auto` over `size-*`, which tailwind-merge cannot undo\n// in one direction.\nconst ICON_BUTTON_TEXT_SIZES: Record<IconButtonSize, string> = {\n \"2xl\": \"h-16 gap-2 px-4 text-base [&_svg]:size-7\",\n xl: \"h-12 gap-2 px-3.5 text-sm [&_svg]:size-6\",\n lg: \"h-11 gap-2 px-3 text-sm [&_svg]:size-5\",\n md: \"h-9 gap-1.5 px-2.5 text-sm [&_svg]:size-5\",\n sm: \"h-8 gap-1.5 px-2 text-sm [&_svg]:size-5\",\n xs: \"h-7 gap-1 rounded px-1.5 text-xs [&_svg]:size-4\",\n \"2xs\": \"h-6 gap-1 rounded px-1.5 text-xs [&_svg]:size-3.5\",\n};\nconst ICON_BUTTON_TEXT_STRETCH_SIZES: Record<IconButtonSize, string> = {\n \"2xl\": \"min-h-16 self-stretch gap-2 px-4 text-base [&_svg]:size-7\",\n xl: \"min-h-12 self-stretch gap-2 px-3.5 text-sm [&_svg]:size-6\",\n lg: \"min-h-11 self-stretch gap-2 px-3 text-sm [&_svg]:size-5\",\n md: \"min-h-9 self-stretch gap-1.5 px-2.5 text-sm [&_svg]:size-5\",\n sm: \"min-h-8 self-stretch gap-1.5 px-2 text-sm [&_svg]:size-5\",\n xs: \"min-h-7 self-stretch gap-1 rounded px-1.5 text-xs [&_svg]:size-4\",\n \"2xs\": \"min-h-6 self-stretch gap-1 rounded px-1.5 text-xs [&_svg]:size-3.5\",\n};\n\n// The 48 px touch target at Large (§4), per size. `lg` and up are past it already\n// (2.75rem is 55 px at Large). `md` … `xs` grow their box to it. `2xs` is the action ON\n// a chip or a tab, whose box cannot grow without growing the chip: it keeps its box and\n// gets a 48 px hit area instead — an invisible `::after` centred on it, which takes the\n// taps a finger lands round the glyph (`HIT_AREA_LARGE`).\nconst ICON_BUTTON_TOUCH_LARGE: Record<IconButtonSize, string> = {\n \"2xl\": \"\",\n xl: \"\",\n lg: \"\",\n md: TOUCH_TARGET_LARGE,\n sm: TOUCH_TARGET_LARGE,\n xs: TOUCH_TARGET_LARGE,\n \"2xs\": HIT_AREA_LARGE,\n};\n\n// A tone re-colours the glyph without changing what the variant draws around it.\n// Each coloured tone comes in two resting looks, picked by `quiet` (see the prop):\n// QUIET is placeholder grey until the pointer or focus arrives, then the tone's\n// family; TONED wears the tone's colour at rest. The defaults are the looks each tone\n// had before `quiet` existed: `danger` is quiet — an action in every row of a list\n// must not shout from every row, so the red arrives only on the one row you are about\n// to act on — while `warning` and `info` are toned, because they mark the ONE thing\n// on the row to notice (keksdose's \"needs review\" flag, its reconcile action at\n// accounts-page:867), and quiet grey would hide the very thing they point out.\n// `muted` is quiet by definition and has no toned look; `default` has no tone.\n//\n// `success` is toned at rest too: the green is the news (keksdose's \"in sync\" chip).\n// `custom` paints from `--icon-button-tone`, which `toneColor` sets — for a glyph whose\n// colour FOLLOWS STATE across tones (keksdose's sync chip: rose, amber, sky, green as\n// the sync moves), where swapping `tone` would do but the colour is one variable.\nexport type IconButtonTone = \"default\" | \"muted\" | \"danger\" | \"warning\" | \"info\" | \"success\" | \"custom\";\n\ntype ColouredTone = \"danger\" | \"warning\" | \"info\" | \"success\" | \"custom\";\n\nconst ICON_BUTTON_TONES: Record<ColouredTone, { quiet: string; toned: string }> = {\n danger: {\n quiet:\n \"text-placeholder hover:bg-danger-soft hover:text-danger focus-visible:ring-danger-strong\",\n toned: \"text-danger hover:bg-danger-soft focus-visible:ring-danger-strong\",\n },\n warning: {\n quiet:\n \"text-placeholder hover:bg-warning-soft hover:text-warning focus-visible:ring-warning-strong\",\n // The ring is warning's loud line (0.33, k27): `--warning-border`, amber-300, was a\n // focus ring at 1.1:1 on the light surfaces.\n toned: \"text-warning hover:bg-warning-soft focus-visible:ring-warning-strong\",\n },\n info: {\n quiet:\n \"text-placeholder hover:bg-info-soft hover:text-info focus-visible:ring-[var(--info-border)]\",\n toned: \"text-info hover:bg-info-soft focus-visible:ring-[var(--info-border)]\",\n },\n success: {\n quiet:\n \"text-placeholder hover:bg-success-soft hover:text-success focus-visible:ring-[var(--success-border)]\",\n toned: \"text-success hover:bg-success-soft focus-visible:ring-[var(--success-border)]\",\n },\n // The hover fill and the ring are MIXED from the one colour, since a custom tone\n // brings no `-bg` / `-border` pair of its own. The fallback is the body colour, so a\n // `custom` tone with no colour set is the default look rather than invisible.\n custom: {\n quiet:\n \"text-placeholder hover:bg-[color-mix(in_srgb,var(--icon-button-tone,var(--text-primary))_12%,transparent)] hover:text-[var(--icon-button-tone,var(--text-primary))] focus-visible:ring-[color-mix(in_srgb,var(--icon-button-tone,var(--text-primary))_40%,transparent)]\",\n toned:\n \"text-[var(--icon-button-tone,var(--text-primary))] hover:bg-[color-mix(in_srgb,var(--icon-button-tone,var(--text-primary))_12%,transparent)] focus-visible:ring-[color-mix(in_srgb,var(--icon-button-tone,var(--text-primary))_40%,transparent)]\",\n },\n};\n\n// The translucent ink on hover, as Button's secondary/ghost (B′, see HOVER_INK).\nconst ICON_BUTTON_MUTED = `text-placeholder ${HOVER_INK} hover:text-primary`;\n\n/** Which tones sit quiet at rest when `quiet` is left out. */\nconst QUIET_BY_DEFAULT: Record<ColouredTone, boolean> = {\n danger: true,\n warning: false,\n info: false,\n success: false,\n custom: false,\n};\n\nfunction iconButtonToneClass(tone: IconButtonTone, quiet: boolean | undefined): string {\n if (tone === \"default\") return \"\";\n if (tone === \"muted\") return cn(ICON_BUTTON_MUTED, ICON_BUTTON_QUIET_DISABLED_REST);\n const isQuiet = quiet ?? QUIET_BY_DEFAULT[tone];\n const look = ICON_BUTTON_TONES[tone];\n return isQuiet ? cn(look.quiet, ICON_BUTTON_QUIET_DISABLED_REST) : look.toned;\n}\n\n// A disabled button must not answer the pointer. The hover classes above are plain\n// `hover:` (so a caller's `className=\"hover:…\"` still replaces them through\n// tailwind-merge), which means they fire on a disabled button too — the old grey\n// icon lit up on hover while refusing the click. Rather than rewrite every hover as\n// `enabled:hover:` (which never matches the `<a>` that `buttonClasses` also styles,\n// and would out-rank a caller's plain `hover:` override), each variant pins its\n// RESTING look under `disabled:hover:`, which out-ranks any `hover:` by specificity\n// and only ever matches a disabled button.\nconst ICON_BUTTON_DISABLED_REST: Record<IconButtonVariant, string> = {\n primary: \"disabled:hover:bg-surface-2\",\n secondary: \"disabled:hover:bg-transparent\",\n ghost: \"disabled:hover:bg-transparent\",\n danger: \"disabled:hover:bg-danger\",\n brand: \"disabled:hover:bg-brand\",\n link: \"disabled:hover:bg-transparent disabled:hover:no-underline\",\n overlay: \"disabled:hover:bg-[color-mix(in_srgb,var(--bg-inverse)_60%,transparent)]\",\n shutter: \"disabled:hover:bg-media-scrim\",\n};\n\n// The quiet looks change the glyph on hover, so they pin their resting glyph the\n// same way. A toned look keeps its glyph on hover and needs no pin.\nconst ICON_BUTTON_QUIET_DISABLED_REST = \"disabled:hover:text-placeholder\";\n\n// `variant=\"overlay\"`: a round, translucent disc for a control that sits ON a photo\n// (keksdose's receipt-scan preview: close, rotate, retake over the camera image).\n// The inverse pair, not the surface one: what has to hold is the contrast between the\n// disc and its glyph, whatever the picture underneath is, and `--bg-inverse` /\n// `--text-inverse` are the one token pair defined as each other's opposite in both\n// themes. The disc is the inverse at 60% (`color-mix`, so it stays a token a palette\n// can re-point); the blur keeps a busy background from breaking the glyph's edge.\nconst ICON_BUTTON_OVERLAY =\n \"rounded-full bg-[color-mix(in_srgb,var(--bg-inverse)_60%,transparent)] text-inverse backdrop-blur-sm hover:bg-[color-mix(in_srgb,var(--bg-inverse)_75%,transparent)] focus-visible:ring-[var(--text-inverse)]\";\n\n// `variant=\"shutter\"`: the camera's release — keksdose's camera-capture.tsx:248 draws\n// it by hand as a 64px circle with a thick white ring round a translucent fill. It sits\n// on a live camera picture, which is dark whatever the theme, so it uses the MEDIA\n// tokens (tokens.css), white ink on a dark scrim in both themes — the inverse pair\n// `overlay` uses would give a dark ring on a dark viewfinder in dark mode. The focus ring stands off by 2px, or it would\n// merge into the ring that is part of the look.\nconst ICON_BUTTON_SHUTTER =\n \"rounded-full border-4 border-media-ink bg-media-scrim text-media-ink backdrop-blur-sm hover:bg-media-scrim-hover focus-visible:ring-media-ink focus-visible:ring-offset-2 focus-visible:ring-offset-media-scrim-hover\";\n\nexport type IconButtonVariant = ButtonVariant | \"overlay\" | \"shutter\";\n\n/**\n * The glyph's size in px, over the one the box size implies — see\n * {@link IconButtonProps.glyphSize}.\n */\nexport type IconButtonGlyphSize = 12 | 14 | 16 | 20 | 24 | 28;\n\nconst ICON_BUTTON_GLYPH_SIZES: Record<IconButtonGlyphSize, string> = {\n 12: \"[&_svg]:size-3\",\n 14: \"[&_svg]:size-3.5\",\n 16: \"[&_svg]:size-4\",\n 20: \"[&_svg]:size-5\",\n 24: \"[&_svg]:size-6\",\n 28: \"[&_svg]:size-7\",\n};\n\n// `pending`'s spinner, the size of the glyph it replaces (the `[&_svg]` rules above\n// size only an svg, and the spinner is a bordered span).\nconst ICON_BUTTON_SPINNER: Record<IconButtonSize, string> = {\n \"2xl\": \"size-7\",\n xl: \"size-6\",\n lg: \"size-5\",\n md: \"size-5\",\n sm: \"size-5\",\n xs: \"size-4\",\n \"2xs\": \"size-3.5\",\n};\nconst ICON_BUTTON_SPINNER_GLYPH: Record<IconButtonGlyphSize, string> = {\n 12: \"size-3\",\n 14: \"size-3.5\",\n 16: \"size-4\",\n 20: \"size-5\",\n 24: \"size-6\",\n 28: \"size-7\",\n};\n\nexport interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {\n /** Any {@link ButtonVariant}; `overlay` — a round translucent disc for use over\n * an image (see `ICON_BUTTON_OVERLAY`); or `shutter` — a camera's release, a ringed\n * disc over the viewfinder (see `ICON_BUTTON_SHUTTER`; pair it with `size=\"2xl\"`). */\n variant?: IconButtonVariant;\n /** Box size: 2xl = 64px with a 28px icon (a camera shutter), xl = 48px with a 24px\n * icon, lg = 44px (a phone's touch target), md = 36px (matches the top bar),\n * sm = 32px — all three with a 20px icon;\n * xs = 28px with a 16px icon (an action in a list row), 2xs = 24px with a 14px\n * icon (an action on a chip or a tab). `glyphSize` overrides the icon. */\n size?: IconButtonSize;\n /**\n * The icon's size in px, when the box's own is not the one wanted. keksdose's sync\n * chip (app/sync-status-indicator.tsx:142) is a 24px round chip round a 20px cloud —\n * `size=\"2xs\" glyphSize={20} shape=\"round\"` — where `2xs` alone draws 14px. A number\n * over a new size step because the box and the glyph are two independent choices; a\n * step per combination (`\"sm-lg\"`) would have to be named for every one.\n */\n glyphSize?: IconButtonGlyphSize;\n /**\n * A small mark on the button's corner — the sync chip's 12px check over its cloud\n * (\"all good\", feedback #67). Rendered OUTSIDE the `<button>`, in a wrapper beside\n * it, so the box's `[&_svg]:size-*` rule that sizes the glyph does not blow the badge\n * up to the glyph's size; a bare `<Check />` comes out 12px, on a\n * `--bg-surface` disc that keeps it legible over the glyph. Decorative\n * (`aria-hidden`): the name (`label` / `aria-label`) must already carry the state.\n * With a badge, `className` still lands on the button; the wrapper is the flex item.\n */\n badge?: ReactNode;\n /**\n * `keep`: a disabled button keeps its full colour (no 50% drop) and an ordinary\n * cursor. For a STATUS that is also an action — the sync chip shows \"in sync\" in\n * green and refreshes on click, and is disabled while offline or syncing, when the\n * state it shows matters most; dimmed it read as broken (keksdose wrote\n * `disabled:cursor-default` by hand and left out the dimming). Default `dim`.\n */\n disabledStyle?: \"dim\" | \"keep\";\n /**\n * Fill the height of the flex row it stands in, keeping its width — the delete at\n * the end of keksdose's split line (transaction-fields:162), level with the labelled\n * fields beside it rather than floating at half their height. {@link Button}'s\n * `stretch`; no effect outside a flex row. With a `label`, the tooltip's wrapper is\n * what stretches.\n */\n stretch?: boolean;\n /** `round`: a circle instead of the rounded square — a compact status chip in a top\n * bar (keksdose's sync indicator). `overlay` is always round. */\n shape?: \"square\" | \"round\";\n /**\n * The colour of `tone=\"custom\"`, as any CSS colour — a token (`var(--warning)`),\n * ideally. Sets `--icon-button-tone` on the button, so passing a different one per\n * state re-colours the glyph, its hover fill and its focus ring together. Passing it\n * implies `tone=\"custom\"`. Or leave it out and set the variable yourself from a class\n * (`className=\"[--icon-button-tone:var(--success)]\"`).\n *\n * Or a `{ light, dark }` pair, one colour per theme — keksdose: a raw colour that reads\n * on the light surface (`#be123c`) is too dark on the dark one, and an inline style\n * cannot say \"in dark mode\". See {@link IconButtonToneColor}.\n */\n toneColor?: IconButtonToneColor;\n /** Glyph colour over the variant. `muted`: placeholder grey, full text colour on\n * hover. `danger`: the same grey at rest, `--danger` on hover and focus — for a\n * remove/delete that repeats down a list. `warning`: amber at rest — a flag that\n * wants attention. `info`: sky at rest — a notice-worthy but harmless action\n * (keksdose's reconcile). `success`: green at rest — a good state worth seeing.\n * `custom`: the colour of `toneColor` (see there). Default: the variant's own colours. See `quiet` for\n * turning a coloured tone's resting look the other way. */\n tone?: IconButtonTone;\n /**\n * Whether a coloured tone (`danger`, `warning`, `info`) waits for the pointer:\n * `true` is placeholder grey at rest and the tone's colour on hover and focus;\n * `false` wears the tone's colour at rest. Left out, each tone keeps its own\n * default — `danger` quiet, `warning` and `info` not. Ignored for `muted` (quiet by\n * definition) and `default` (no tone).\n *\n * `quiet={false}` on `danger` is for a destructive action that stands ALONE, where\n * hover-to-reveal hides it: keksdose's phone bulk bar (mobile-bulk-bar.tsx) has one\n * \"delete all\" and a touch screen that never hovers, so it painted rose by hand.\n * `quiet` on `warning`/`info` is the same switch the other way, for a flag repeated\n * down a list. A boolean over the tone rather than a new tone (`danger-solid`) or an\n * `emphasis` scale: there are exactly two resting looks, every coloured tone has\n * both, and which one fits is a question about the SITE (alone or repeated, touch or\n * pointer), not about the tone — so it is one switch the family shares.\n */\n quiet?: boolean;\n /**\n * Make it a toggle button. `true` sets `aria-pressed=\"true\"` and draws the \"on\" look\n * (brand glyph on the quiet brand fill); `false` sets `aria-pressed=\"false\"` with the\n * normal look, so a screen reader still hears a toggle that is off. Left out, it is\n * an ordinary button with no `aria-pressed` — or whatever `aria-pressed` the caller\n * passes. For keksdose's budget share toggle (budgets-page:291), which paints its own\n * brand colour over a ghost button today. Keep the `aria-label` the same in both\n * states (\"Share budget\"): the pressed state already says whether it is on.\n */\n pressed?: boolean;\n /** Keep the click (and the Enter/Space that produces it) from reaching an\n * ancestor's handler — for an action inside a clickable table row or card.\n *\n * **Not a licence to put this inside another `<button>`.** HTML forbids any\n * interactive content, and any element with a `tabindex`, inside a button — so\n * the `<span role=\"button\" tabIndex={0}>` workaround is invalid too — and ARIA\n * makes a button's children presentational, so a screen reader flattens the\n * inner one into the outer one's name and it cannot be reached at all. Make the\n * row's main action a button that fills the row and put this one BESIDE it, on\n * top:\n *\n * ```tsx\n * <div className=\"relative\">\n * <button className=\"w-full pe-16 …\" onClick={open}>…row…</button>\n * <div className=\"absolute inset-y-0 end-2 flex items-center gap-0.5\">\n * <IconButton size=\"xs\" tone=\"danger\" aria-label=\"Delete\" onClick={remove}>\n * <Trash2 />\n * </IconButton>\n * </div>\n * </div>\n * ```\n *\n * The pair look exactly like the nested version, click exactly like it, and\n * are two tab stops a screen reader can tell apart. */\n stopPropagation?: boolean;\n /**\n * The button's name, said once: it becomes the `aria-label` AND the text of a kit\n * {@link Tooltip} round the button.\n *\n * Every app writes the pair by hand. lenkbank's nine icon actions all carry\n * `aria-label={t(x)} title={t(x)}` (shared/lib/table-columns.tsx:103,\n * projects-page.tsx:83, setpoint/segment-list.tsx:198/207, profile-bar.tsx:128/142,\n * shortcut-dialog.tsx:815/857/1007) — and `title` is the browser's own tooltip,\n * which shows late, never on focus and never on touch, and looks like no other\n * label in the kit. kastlan's RowAction (shared/components/data-table/row-action.tsx)\n * and keksdose's icon wrappers each exist to put a Tooltip round an IconButton.\n *\n * A caller's own `aria-label` still wins, for the rare name that should be longer\n * than the bubble. The bubble is visual only: it would otherwise describe the button\n * with its own name, and a screen reader would read the same word twice. Tooltip's\n * default placement applies — in place, or portalled inside a scroll container —\n * unless `tooltipPortal` says otherwise.\n */\n label?: string;\n /**\n * Show `label` as visible text beside the icon at Large and Extra large (0.32,\n * docs/text-size-harmonization.md §4, §10.8). A reader who asked for bigger type is\n * the one an unlabelled glyph fails, and on touch the tooltip never opens. Then there\n * is no tooltip: the words are on the button.\n *\n * Default `true` — except for `variant=\"overlay\"` and `\"shutter\"`, which sit ON a\n * picture, and inside {@link CompactControls} (AppShell's top bar, `RowActions`'\n * inline icons). `false`: the icon alone at every size, for an icon on content — a\n * receipt overlay, a map's zoom, a viewfinder, a photo thumbnail. That must be rare;\n * a dense row's actions belong in `RowActions`, which collapses them into a \"⋯\" menu\n * at Large instead (§10.8). Normal never shows it. `tooltip={false}` (a universally\n * read glyph, a dialog's ✕) keeps the icon alone too.\n */\n labelVisible?: boolean;\n /** Show `label` as a tooltip. Default `true`; `false` keeps `label` as the\n * accessible name only — for a button whose glyph is universally read (a close ✕ in\n * a dialog header) or that already sits under a tooltip of its own. It also keeps the\n * label from showing as text at Large (see `labelVisible`). */\n tooltip?: boolean;\n /** Where the `label` tooltip opens. See {@link Tooltip}'s `side`. */\n tooltipSide?: TooltipSide;\n /** Passed to the `label` tooltip's `portal`. Left out, Tooltip decides (see there). */\n tooltipPortal?: boolean;\n /**\n * Mount the `label` bubble only while it is up — {@link Tooltip}'s `lazy`. Default\n * `true` since 0.16.0; `false` is the always-mounted in-place bubble of before.\n *\n * Why the label bubble can be lazy when Tooltip's default is not: this bubble\n * describes nothing. It is wrapped round the button in a fragment precisely so that\n * it is NOT the button's `aria-describedby` (it would repeat the button's own name),\n * so there is no description for a lazy mount to take away — the name is the\n * `aria-label` either way, read in browse mode and on focus alike. What the\n * always-mounted bubble did do was sit in the DOM as a `role=\"tooltip\"` node full of\n * text: every IconButton in a row added one to `getAllByRole(\"tooltip\")` and its label\n * to the row's `textContent`, and two keksdose tests tripped over that (0.15.5 P6).\n * The cost is the one {@link Tooltip}'s \"Lazy in place\" note names — a test that\n * looked the bubble up without hovering now has to hover (or focus) first; pass\n * `false` while it is migrated. A `disabledReason` bubble is unaffected: that one\n * stays mounted as it was.\n */\n tooltipLazy?: boolean;\n /**\n * Busy — the action is in flight: the glyph is swapped for a spinner of the same\n * size, the button is `aria-busy`, and clicks (and the Enter/Space and form\n * submission they stand for) are swallowed, so a second press cannot start the\n * action twice. {@link Button}'s `pending`, with the same contract: `aria-disabled`\n * rather than `disabled`, so the button that was pressed keeps its focus. The name\n * (`label` / `aria-label`) stays — `aria-busy` is what says it is working. A SWAP\n * rather than Button's overlay: the box is square and fixed, so there is no width\n * for the glyph to hold, and a spinner drawn over a glyph would be two marks in one\n * 20px square.\n */\n pending?: boolean;\n /**\n * Why the action is not available — {@link Button}'s `disabledReason`, on the icon\n * button: keksdose's accounts page cannot hide an account with a balance or delete\n * one with bookings (accounts-page:879/913 — \"hide requires zero\", \"delete blocked\"),\n * nor its budgets page delete the only budget (budgets-page:330), and each wrapped a\n * natively disabled IconButton in a Tooltip that swaps its label for the reason — a\n * bubble a keyboard never opens, since `disabled` leaves the tab order, and a reason\n * a screen reader never hears.\n *\n * The same contract as Button's: `aria-disabled` instead of `disabled`, so it stays\n * focusable and hoverable; clicks (and the Enter/Space they stand for) are swallowed\n * — `stopPropagation` still applies, so a locked action in a clickable row does not\n * open the row either; the reason shows in the kit {@link Tooltip} and is attached\n * through `aria-describedby` as a `hidden` copy. The NAME stays `aria-label` /\n * `label` — \"Delete\", described by \"The active budget cannot be deleted\" — and the\n * bubble shows the reason in place of the label, since the glyph already says what\n * the button is and the reason is the news. Shown even with `tooltip={false}`: a\n * reason nobody can see is not one. Wins over `disabled`, as on Button.\n *\n * 0.32: at Large and on a touch screen the reason is a line under the button rather\n * than a bubble — see `disabledReasonDisplay`.\n */\n disabledReason?: ReactNode;\n /** Where `disabledReason` shows — {@link Button}'s `disabledReasonDisplay`. */\n disabledReasonDisplay?: DisabledReasonDisplay;\n /** This action COMMITS — {@link Button}'s `commit`: under a locked\n * {@link WriteLockProvider} it takes the `disabledReason` path with the lock's\n * reason. No effect without a lock. A {@link CommitScope} since 0.33. */\n commit?: CommitScope;\n /** The `<button>` element — a prop in React 19, as on {@link Button}. */\n ref?: Ref<HTMLButtonElement>;\n /** Only on the link form — see {@link IconButtonLinkProps}. */\n href?: never;\n renderLink?: never;\n external?: never;\n replace?: never;\n reloadDocument?: never;\n}\n\n/**\n * `<IconButton href>`: an icon-only LINK — keksdose F2 and kastlan 45, whose icon\n * actions that navigate (a row's \"open\", a card's \"edit\" that goes to the edit page, a\n * top bar's settings cog) were `<IconButton onClick={() => navigate(…)}>`: a button that\n * changes the page, with no middle click, no \"open in new tab\" and no URL on hover.\n *\n * The same rule as {@link Button}'s `href` (see {@link ButtonLinkProps}): an in-app\n * `href` goes through `renderLink`, else the provider's `linkComponent`; an external one\n * or an in-page `#anchor` stays a plain `<a>`; `external` opens a new tab and says so to\n * a screen reader; `replace` and `reloadDocument` as on Button. The look, the `label` →\n * `aria-label` + Tooltip, the badge, `toneColor` and `stopPropagation` are the button's.\n *\n * `disabled` renders an inert `<a>` with no `href` — `role=\"link\"`, `aria-disabled`, out\n * of the tab order, dimmed — like Button's disabled link. `pressed`, `disabledReason` and\n * `disabledStyle` are a button's and are typed out: a link is not a toggle, and a link\n * that explains why it cannot be followed is a job for a disabled BUTTON.\n */\nexport interface IconButtonLinkProps\n extends Omit<AnchorHTMLAttributes<HTMLAnchorElement>, \"type\" | \"href\">,\n Pick<\n IconButtonProps,\n | \"variant\"\n | \"size\"\n | \"glyphSize\"\n | \"badge\"\n | \"stretch\"\n | \"shape\"\n | \"toneColor\"\n | \"tone\"\n | \"quiet\"\n | \"stopPropagation\"\n | \"label\"\n | \"labelVisible\"\n | \"tooltip\"\n | \"tooltipSide\"\n | \"tooltipPortal\"\n | \"tooltipLazy\"\n > {\n href: string;\n /** See {@link ButtonLinkProps.renderLink}. */\n renderLink?: KitLinkComponent;\n /** See {@link ButtonLinkProps.external}. */\n external?: boolean;\n /** See {@link ButtonLinkProps.replace}. */\n replace?: boolean;\n /** See {@link ButtonLinkProps.reloadDocument}. */\n reloadDocument?: boolean;\n /** See {@link ButtonLinkProps.disabled}. */\n disabled?: boolean;\n ref?: Ref<HTMLAnchorElement>;\n type?: never;\n form?: never;\n pressed?: never;\n disabledReason?: never;\n disabledReasonDisplay?: never;\n commit?: never;\n disabledStyle?: never;\n pending?: never;\n}\n\n/**\n * Whether an IconButton draws its `label` as text (see `labelVisible`): at Large and\n * above, with a label to draw, not switched off by the caller, the variant (`overlay`,\n * `shutter`), `tooltip={false}` or a {@link CompactControls} region.\n */\nfunction useIconButtonText(\n label: string | undefined,\n labelVisible: boolean | undefined,\n variant: IconButtonVariant,\n tooltip: boolean,\n): boolean {\n const large = useLargeText();\n const compact = useContext(CompactControlsContext);\n if (!large || label === undefined || label === \"\" || !tooltip) return false;\n const visible = labelVisible ?? (!compact && variant !== \"overlay\" && variant !== \"shutter\");\n return visible;\n}\n\n/** The visible label (see `labelVisible`). */\nfunction IconButtonText({ label }: { label: string }) {\n return <span data-slot=\"icon-button-label\">{label}</span>;\n}\n\n/**\n * `toneColor`: one CSS colour, or one per theme.\n *\n * The pair is two custom properties on the element — `--icon-button-tone-light` and\n * `--icon-button-tone-dark` — and a class that points `--icon-button-tone` at one or the\n * other by the kit's `dark` variant (`.dark` on an ancestor, tokens.css), so the colour\n * follows a theme switch in CSS alone, with no re-render and no JS reading the theme.\n * Both values are any CSS colour; a token that already flips (`var(--warning)`) needs no\n * pair.\n */\nexport type IconButtonToneColor = string | { light: string; dark: string };\n\nconst ICON_BUTTON_TONE_PAIR =\n \"[--icon-button-tone:var(--icon-button-tone-light)] dark:[--icon-button-tone:var(--icon-button-tone-dark)]\";\n\n/** The inline custom properties `toneColor` sets over the caller's `style`. */\nfunction toneColorStyle(toneColor: IconButtonToneColor | undefined, style: CSSProperties | undefined) {\n if (toneColor === undefined) return style;\n const vars: Record<string, string> =\n typeof toneColor === \"string\"\n ? { \"--icon-button-tone\": toneColor }\n : { \"--icon-button-tone-light\": toneColor.light, \"--icon-button-tone-dark\": toneColor.dark };\n return { ...style, ...vars } as CSSProperties;\n}\n\n/** Everything but the element: the look both forms of IconButton share. */\nfunction iconButtonLook({\n variant,\n size,\n stretch,\n glyphSize,\n shape,\n tone,\n quiet,\n toneColor,\n withText = false,\n}: {\n variant: IconButtonVariant;\n size: IconButtonSize;\n stretch: boolean | undefined;\n glyphSize: IconButtonGlyphSize | undefined;\n shape: \"square\" | \"round\";\n tone: IconButtonTone;\n quiet: boolean | undefined;\n toneColor: IconButtonToneColor | undefined;\n /** The label is drawn beside the glyph (see `labelVisible`). */\n withText?: boolean;\n}) {\n return cn(\n ICON_BUTTON_BASE,\n withText\n ? cn(\"font-medium whitespace-nowrap\", stretch ? ICON_BUTTON_TEXT_STRETCH_SIZES[size] : ICON_BUTTON_TEXT_SIZES[size])\n : stretch\n ? ICON_BUTTON_STRETCH_SIZES[size]\n : ICON_BUTTON_SIZES[size],\n // With text the pill is a 48 px target at any size; the square follows its size.\n withText ? TOUCH_TARGET_LARGE : ICON_BUTTON_TOUCH_LARGE[size],\n glyphSize !== undefined && ICON_BUTTON_GLYPH_SIZES[glyphSize],\n // After the size, so the overlay's `rounded-full` beats the small sizes' `rounded`.\n variant === \"overlay\"\n ? ICON_BUTTON_OVERLAY\n : variant === \"shutter\"\n ? ICON_BUTTON_SHUTTER\n : buttonVariantClasses[variant],\n shape === \"round\" && \"rounded-full\",\n ICON_BUTTON_DISABLED_REST[variant],\n iconButtonToneClass(tone, quiet),\n toneColor !== undefined && typeof toneColor !== \"string\" && ICON_BUTTON_TONE_PAIR,\n );\n}\n\n/** The badge beside the control, not in it — see `badge`. The wrapper is only a\n * positioning box: the control inside it keeps its own focus ring and hit area. */\nfunction withIconButtonBadge(control: ReactElement, badge: ReactNode, stretch: boolean | undefined) {\n if (!hasContent(badge)) return control;\n return (\n <span className={cn(\"relative inline-flex shrink-0\", stretch && \"self-stretch\")}>\n {control}\n <span\n aria-hidden\n data-slot=\"icon-button-badge\"\n className=\"pointer-events-none absolute -end-0.5 -bottom-0.5 inline-flex rounded-full bg-surface [&_svg]:size-3\"\n >\n {badge}\n </span>\n </span>\n );\n}\n\n// Declared link-first, as Button is, so `<IconButton href>` picks the link props and\n// `ComponentProps<typeof IconButton>` (the LAST signature) is still the button's.\nexport function IconButton(props: IconButtonLinkProps): ReactElement;\nexport function IconButton(props: IconButtonProps): ReactElement;\nexport function IconButton(props: IconButtonProps | IconButtonLinkProps): ReactElement {\n if (props.href !== undefined) return <IconButtonLink {...(props as IconButtonLinkProps)} />;\n return <IconButtonElement {...(props as IconButtonProps)} />;\n}\n\nfunction IconButtonLink({\n href,\n renderLink,\n external,\n replace,\n reloadDocument,\n disabled,\n variant = \"ghost\",\n size = \"md\",\n tone: toneProp,\n quiet,\n stopPropagation,\n stretch,\n shape = \"square\",\n toneColor,\n label,\n labelVisible,\n tooltip = true,\n tooltipSide,\n tooltipPortal,\n tooltipLazy = true,\n glyphSize,\n badge,\n className,\n style,\n target,\n rel,\n onClick,\n onKeyDown,\n children: glyph,\n ref,\n ...rest\n}: IconButtonLinkProps) {\n const withText = useIconButtonText(label, labelVisible, variant, tooltip);\n const children = withText ? (\n <>\n {glyph}\n <IconButtonText label={label!} />\n </>\n ) : (\n glyph\n );\n const kitLink = useKitLink();\n const common = useKitLabels(\"common\", DEFAULT_COMMON_LABELS);\n const tone = toneProp ?? (toneColor !== undefined ? \"custom\" : \"default\");\n // Button's rule, word for word: `reloadDocument` bypasses the router, an external\n // href or an in-page `#anchor` never reaches it.\n const Link = reloadDocument ? undefined : pickLinkRenderer<KitLinkProps>(renderLink, kitLink, href);\n const newTab = !disabled && (external || target === \"_blank\");\n const name = rest[\"aria-label\"] ?? label;\n // \"(opens in a new tab)\" joins the NAME here, not the body: an icon link has no text\n // for an sr-only span to follow, and `aria-label` would override one anyway.\n const ariaLabel = name !== undefined && newTab ? `${name} (${common.opensInNewTab})` : name;\n const handleClick = (e: MouseEvent<HTMLAnchorElement>) => {\n if (stopPropagation) e.stopPropagation();\n onClick?.(e);\n };\n const handleKeyDown = (e: KeyboardEvent<HTMLAnchorElement>) => {\n // A link activates on Enter only; that key still bubbles to a clickable row.\n if (stopPropagation && e.key === \"Enter\") e.stopPropagation();\n onKeyDown?.(e);\n };\n const cls = cn(\n iconButtonLook({ variant, size, stretch, glyphSize, shape, tone, quiet, toneColor, withText }),\n // BASE's `disabled:` classes never match an `<a>` — Button's disabled link look.\n disabled && \"pointer-events-none cursor-not-allowed opacity-50\",\n className,\n );\n const look = { style: toneColorStyle(toneColor, style), className: cls };\n let control: ReactElement;\n if (disabled) {\n control = (\n <a {...rest} {...look} ref={ref} role=\"link\" aria-disabled=\"true\" aria-label={ariaLabel}>\n {children}\n </a>\n );\n } else if (external || !Link) {\n control = (\n <a\n {...rest}\n {...look}\n ref={ref}\n href={href}\n aria-label={ariaLabel}\n target={external ? \"_blank\" : target}\n rel={external ? (rel ?? \"noopener noreferrer\") : rel}\n onClick={external ? handleClick : replacingClick(handleClick, href, replace, target)}\n onKeyDown={handleKeyDown}\n >\n {children}\n </a>\n );\n } else {\n control = (\n <RenderedButtonLink\n render={Link}\n {...rest}\n {...look}\n {...routerLinkNavigation(replace)}\n ref={ref}\n href={href}\n aria-label={ariaLabel}\n target={target}\n rel={rel}\n onClick={handleClick}\n onKeyDown={handleKeyDown}\n >\n {children}\n </RenderedButtonLink>\n );\n }\n control = withIconButtonBadge(control, badge, stretch);\n // The words are on the link at Large: a bubble would only repeat them.\n if (!tooltip || withText || label === undefined || label === \"\") return control;\n return (\n // A fragment, as on the button: the bubble says what `aria-label` already does.\n <Tooltip\n label={label}\n side={tooltipSide}\n portal={tooltipPortal}\n lazy={tooltipLazy}\n className={stretch ? \"self-stretch\" : undefined}\n >\n <>{control}</>\n </Tooltip>\n );\n}\n\nfunction IconButtonElement(\n {\n variant = \"ghost\",\n size = \"md\",\n tone: toneProp,\n quiet,\n pressed,\n stopPropagation,\n stretch,\n shape = \"square\",\n toneColor,\n label,\n labelVisible,\n tooltip = true,\n tooltipSide,\n tooltipPortal,\n tooltipLazy = true,\n disabledReason: ownDisabledReason,\n disabledReasonDisplay,\n commit,\n pending,\n glyphSize,\n badge,\n disabledStyle = \"dim\",\n className,\n style,\n onClick,\n onKeyDown,\n children,\n href: _href,\n renderLink: _renderLink,\n external: _external,\n replace: _replace,\n reloadDocument: _reloadDocument,\n ref,\n ...rest\n }: IconButtonProps,\n) {\n const tone = toneProp ?? (toneColor !== undefined ? \"custom\" : \"default\");\n const keep = disabledStyle === \"keep\";\n const withText = useIconButtonText(label, labelVisible, variant, tooltip);\n const reasonId = useId();\n const disabledReason = useCommitReason(commit, ownDisabledReason);\n const locked = hasContent(disabledReason);\n // Pending is inert like a locked button — focusable, every activation swallowed —\n // without the locked look: the spinner is the state.\n const inert = locked || Boolean(pending);\n const ownDescribedBy = rest[\"aria-describedby\"];\n const button = (\n <button\n ref={ref}\n {...rest}\n disabled={inert ? undefined : rest.disabled}\n aria-disabled={inert || rest[\"aria-disabled\"]}\n aria-busy={pending || rest[\"aria-busy\"]}\n aria-describedby={locked ? (ownDescribedBy ? `${ownDescribedBy} ${reasonId}` : reasonId) : ownDescribedBy}\n aria-label={rest[\"aria-label\"] ?? label}\n aria-pressed={pressed ?? rest[\"aria-pressed\"]}\n style={toneColorStyle(toneColor, style)}\n onClick={(e) => {\n if (stopPropagation) e.stopPropagation();\n // `preventDefault` too: a locked (or pending) submit must not submit its form.\n if (inert) {\n e.preventDefault();\n return;\n }\n onClick?.(e);\n }}\n onKeyDown={(e) => {\n // A button turns Enter/Space into a click of its own, but the KEY still\n // bubbles — and a clickable row listening for Enter would act on it too.\n if (stopPropagation && (e.key === \"Enter\" || e.key === \" \")) e.stopPropagation();\n onKeyDown?.(e);\n }}\n className={cn(\n iconButtonLook({ variant, size, stretch, glyphSize, shape, tone, quiet, toneColor, withText }),\n pressed && ICON_BUTTON_PRESSED,\n // The disabled look for the focusable kind of disabled, as on Button.\n locked && (keep ? \"cursor-default\" : \"cursor-not-allowed opacity-50\"),\n keep && \"disabled:cursor-default disabled:opacity-100\",\n pending && \"cursor-progress\",\n className,\n )}\n >\n {pending ? (\n // Decorative, as on Button: `aria-busy` says it. Sized to the glyph it stands\n // in for, in the glyph's colour, so the swap does not change the button's look.\n <Spinner\n label={null}\n className={cn(\n \"border-current/30 border-t-current\",\n glyphSize !== undefined ? ICON_BUTTON_SPINNER_GLYPH[glyphSize] : ICON_BUTTON_SPINNER[size],\n )}\n />\n ) : (\n children\n )}\n {withText && <IconButtonText label={label!} />}\n </button>\n );\n const control = withIconButtonBadge(button, badge, stretch);\n if (locked) {\n return (\n // Button's shape: the reason as a line under it, or the bubble with a hidden copy\n // for the description (see LockedReason).\n <LockedReason\n lock={{ reason: disabledReason, reasonId }}\n display={disabledReasonDisplay}\n side={tooltipSide}\n portal={tooltipPortal}\n className={stretch ? \"self-stretch\" : undefined}\n >\n {control}\n </LockedReason>\n );\n }\n // The words are on the button at Large: a bubble would only repeat them.\n if (!tooltip || withText || label === undefined || label === \"\") return control;\n return (\n // A fragment, so Tooltip leaves the button's description alone: the bubble says\n // exactly what `aria-label` already does. Lazy by default — see `tooltipLazy`.\n <Tooltip\n label={label}\n side={tooltipSide}\n portal={tooltipPortal}\n lazy={tooltipLazy}\n className={stretch ? \"self-stretch\" : undefined}\n >\n <>{control}</>\n </Tooltip>\n );\n}\n"],"mappings":"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","names":[]}
@@ -49,7 +49,7 @@ function Popover({
49
49
  bottom: placement.above ? placement.bottom : void 0,
50
50
  left: anchoredInline(rect, panelWidth, { dir, align: "end", edge: EDGE, viewportWidth: glass }).left
51
51
  } : null;
52
- useOutsideClick([triggerRef, panelRef], close, open);
52
+ const markInside = useOutsideClick([triggerRef, panelRef], close, open);
53
53
  const layer = useDismissLayer(open, close);
54
54
  useFocusTrap(panelRef, {
55
55
  active: open && pos !== null,
@@ -85,6 +85,10 @@ function Popover({
85
85
  dir: rest.dir ?? dir,
86
86
  tabIndex: -1,
87
87
  onKeyDown: handleKeyDown,
88
+ onPointerDownCapture: (e) => {
89
+ rest.onPointerDownCapture?.(e);
90
+ markInside(e);
91
+ },
88
92
  style: {
89
93
  // Merged over the caller's, never replaced by it: these are measured
90
94
  // against the trigger every scroll and resize, and a panel that dropped
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/components/popover.tsx"],"sourcesContent":["import { useMemo, useRef, useState } from \"react\";\nimport type { ComponentPropsWithoutRef, KeyboardEvent, ReactNode, Ref, RefObject } from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { anchoredInline, useAnchoredPanel, viewportSize } from \"../hooks/use-anchored-panel\";\nimport { runOwnKeyDown, takeEscape, useDismissLayer, useOutsideClick } from \"../hooks/use-dismiss\";\nimport { useFocusTrap } from \"../hooks/use-focus-trap\";\nimport { lengthPx, useRemPx, type RemLength } from \"../hooks/use-breakpoint\";\nimport { cn } from \"../lib/cn\";\nimport { dirOf } from \"../lib/direction\";\nimport type { Direction } from \"../lib/direction\";\nimport { useKitLabels, type LabelOverride } from \"../i18n/kit-labels\";\nimport { DEFAULT_POPOVER_LABELS } from \"./popover-labels\";\n// From ../lib/refs, not field-parts (round 0.36): field-parts renders a HelpPopover for a\n// field's `help`, so importing from there would close a cycle, as it did for Tooltip.\nimport { mergeRefs } from \"../lib/refs\";\n\n/** The panel's accessible name. A `role=\"dialog\"` with no name announces as \"dialog\"\n * and nothing else, which is worse than the unlabelled div it replaced — so it is a\n * prop with an English default, like every other string this package renders. Callers\n * should pass the control's own name (\"Filter\", \"Choose a date\"); the default is only\n * there so an unmigrated call site is still named. */\nexport interface PopoverLabels {\n panel: string;\n}\n\n// Defined in ./popover-labels, which holds no component code (0.34, KS U8).\nexport { DEFAULT_POPOVER_LABELS };\n\n/**\n * Exported, and extending the `<div>` attributes, so a consumer wrapping this — which is\n * what `DateField` and `FilterPopover` in this very package do — can hand anything\n * through to the PANEL: a `data-tour` anchor for the kit's own guided tour, a test id,\n * an `aria-describedby`, or an `aria-label` naming this particular popover.\n *\n * `children` is omitted from those attributes rather than inherited: here it is a render\n * prop taking the panel's `close`, not a `ReactNode`, so the two cannot be reconciled.\n */\nexport interface PopoverProps extends Omit<ComponentPropsWithoutRef<\"div\">, \"children\"> {\n trigger: (state: { open: boolean; toggle: () => void; ref: RefObject<HTMLButtonElement | null> }) => ReactNode;\n children: (close: () => void) => ReactNode;\n /**\n * The panel's width: `\"18rem\"` (the default) grows with the text size — the panel\n * holds text, and a 288 px calendar at 150 % cut its own month off (0.32,\n * docs/text-size-harmonization.md §3.2). A plain number is px, fixed, as before.\n * Either way the panel is never wider than the viewport less 8 px a side: 18rem is\n * 432 px at Extra large, more than a 360 px phone has.\n */\n width?: number | RemLength;\n labels?: LabelOverride<PopoverLabels>;\n /** Id for the panel, so a trigger wearing `role=\"combobox\"` can `aria-controls` it.\n * Required by ARIA on that role, and the panel is rendered here rather than by the\n * caller, so the id has to come in from outside. */\n panelId?: string;\n /** Extra classes for the PANEL. The trigger is the caller's own element. */\n className?: string;\n /** The PANEL, while it is open — where the rest of the props go (0.34, API-2, refactor\n * audit 2026-10). Merged with the panel's own ref (focus trap, outside click), so\n * neither loses it. The trigger is the caller's own element, ref and all. */\n ref?: Ref<HTMLDivElement>;\n}\n\n/** 288 px at Normal, in rem so it follows the text size (§3.2). */\nconst POPOVER_WIDTH: RemLength = \"18rem\";\n\n/** The gap the panel keeps from either edge of the viewport, in px. */\nconst EDGE = 8;\n\n/**\n * Portal-rendered popover anchored under its trigger button. The panel is fixed\n * to the viewport (so table/overflow ancestors can't clip it) and re-aligns on\n * scroll/resize; it closes on outside-click and Escape.\n *\n * ## Trapped, not merely focused — and why, because it is the closer call\n *\n * The audit's finding was that this panel *\"portals out of the tab order with no role\n * and no focus move\"*: Tab from the trigger went straight past the panel into the page\n * behind it, so the calendar, the calculator keypad and every column filter in the kit\n * were mouse-only. Two shapes fix that, and a popover is not automatically a dialog, so\n * this was chosen rather than inherited:\n *\n * - **The light one** — move focus in on open, hand it back on close, and let Tab walk\n * OUT of the panel into whatever follows the trigger. That is the right shape for a\n * non-modal popover sitting inline in the document.\n * - **The trap** — Tab cycles inside the panel; Escape and a click outside are the\n * ways out. What is here.\n *\n * The deciding fact is the portal. This panel is rendered to the END of `<body>`, not\n * beside its trigger, so \"let Tab walk out\" does not mean \"carry on from the trigger\" —\n * it means land at the end of the document, several hundred controls from where the\n * user was. Tabbing out of it would be a worse place to arrive than the one the finding\n * complains about, and fixing that properly needs a sentinel-and-return dance that\n * nothing in this package has. The trap gets the user back to the trigger every time,\n * because `useFocusTrap` restores focus on close.\n *\n * And the panels this kit actually opens are self-contained: pick a date, tap out a\n * sum, narrow a column. Each of them commits and closes, and each already has two\n * documented ways out wired above — Escape and an outside press — so containment costs\n * a keyboard user nothing they had.\n *\n * **It stays non-modal in every other respect**, deliberately: no backdrop, no body\n * scroll lock, and no `aria-modal`, so the page behind is still readable by a screen\n * reader, still scrollable, and still clickable — clicking it is what dismisses this.\n * Only Tab is contained. A popover that took the page away as well would be a\n * {@link Modal}, and there is one of those.\n */\nexport function Popover({\n trigger,\n children,\n width = POPOVER_WIDTH,\n labels,\n panelId,\n className,\n style,\n \"aria-label\": ariaLabel,\n onKeyDown: onKeyDownProp,\n ref,\n ...rest\n}: PopoverProps) {\n const triggerRef = useRef<HTMLButtonElement>(null);\n const panelRef = useRef<HTMLDivElement>(null);\n const setPanel = useMemo(() => mergeRefs(panelRef, ref), [ref]);\n const [open, setOpen] = useState(false);\n // The trigger's reading direction, read when it is pressed. The panel is portalled to\n // <body>, out of any `dir=\"rtl\"` subtree the trigger sits in, so without carrying it\n // across a right-to-left calendar or filter rendered left-to-right.\n const [dir, setDir] = useState<Direction>(\"ltr\");\n const close = () => setOpen(false);\n const toggle = () => {\n setDir(dirOf(triggerRef.current));\n setOpen((v) => !v);\n };\n // prop > `<UiKitProvider labels={{ popover }}>` > English. The provider's\n // `popover.panel` is only the last-resort name for a panel no caller named.\n const text = useKitLabels(\"popover\", DEFAULT_POPOVER_LABELS, labels);\n\n // Align the panel's END edge to the trigger's — right in LTR, left in RTL; it was\n // always `rect.right`, so an RTL panel hung off the wrong side — and clamp it\n // horizontally; the\n // hook owns the vertical half — below by default, flipped above (and height-capped)\n // when that is where the room is, e.g. once a mobile keyboard has eaten the bottom\n // of the screen (feedback #135). It re-measures on scroll/resize.\n const placement = useAnchoredPanel(triggerRef, open);\n const rect = placement.rect;\n // In px for the arithmetic below, at the text size in force; capped at the glass — the\n // root's client width, not `innerWidth`, which on a phone is the layout viewport grown\n // by whatever overflows (live #381, Wave 2 DUP-7).\n const remPx = useRemPx();\n const glass = rect ? viewportSize().width : 0;\n const panelWidth = rect ? Math.min(lengthPx(width, remPx), Math.max(0, glass - 2 * EDGE)) : 0;\n const pos = rect\n ? {\n // Flipped above, the panel hangs from its bottom edge, so one shorter than its\n // cap still meets the trigger (keksdose K1); the edge not in use is cleared.\n top: placement.above ? undefined : placement.top,\n bottom: placement.above ? placement.bottom : undefined,\n left: anchoredInline(rect, panelWidth, { dir, align: \"end\", edge: EDGE, viewportWidth: glass }).left,\n }\n : null;\n\n useOutsideClick([triggerRef, panelRef], close, open);\n // A layer on the Escape stack while open (Wave 2, DUP-1): a list opened inside closes\n // first, and the dialog this popover sits in only after it — the calculator on an\n // amount field in a Modal is exactly that.\n const layer = useDismissLayer(open, close);\n\n useFocusTrap(panelRef, {\n active: open && pos !== null,\n // The panel keeps a focus one of its own children has already taken. `FilterPopover`\n // autofocuses its text box on mount — that is what the press on \"Filter\" was FOR,\n // and it is a prop there precisely so the popover keeps it — and a trap that\n // unconditionally focused the container would take it straight back off again,\n // one effect later and invisibly.\n initialFocus: () => {\n const panel = panelRef.current;\n const active = document.activeElement;\n return panel?.contains(active) ? (active as HTMLElement) : panel;\n },\n });\n\n const handleKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {\n // A caller's own handler first (0.32.2: RowActions stops Enter / Space at the panel);\n // it used to be dropped, overwritten by this one. A `preventDefault()` on Escape\n // there keeps the popover open — the owner's claim, as on the dialogs. And as there,\n // the handler sees Escape only while this popover is the layer on top: a tooltip or a\n // menu open over it takes that press first (round 0.35 R2, the dialogs' rule).\n runOwnKeyDown(e, onKeyDownProp, layer);\n // Focus is in here, the normal case: the key is handed to the top layer and STOPPED,\n // so it does not travel on up the React tree to a dialog around the popover.\n takeEscape(e);\n };\n\n return (\n <>\n {trigger({ open, toggle, ref: triggerRef })}\n {open && pos &&\n createPortal(\n /* eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions -- the\n dialog container, per the APG dialog pattern: `onKeyDown` is Escape-to-close for\n whatever inside it holds focus. */\n <div\n // `...rest` first: the role, the id the trigger's `aria-controls` points at,\n // the `tabIndex` the trap needs and the measured position are what make this\n // a popover, and a caller reaching for a `data-tour` anchor must not be able\n // to remove one of them by accident.\n {...rest}\n ref={setPanel}\n role=\"dialog\"\n id={panelId}\n // The caller's own `aria-label` wins over {@link PopoverLabels.panel}, which\n // is the package's English fallback for an unnamed panel. Both name the same\n // element; the DOM spelling is the one being standardised on, so where both\n // are present it is the one that survives.\n aria-label={ariaLabel ?? text.panel}\n dir={rest.dir ?? dir}\n // Without it the panel cannot take focus at all, and the trap above would\n // silently do nothing on a panel whose children are not yet tabbable.\n tabIndex={-1}\n onKeyDown={handleKeyDown}\n style={{\n // Merged over the caller's, never replaced by it: these are measured\n // against the trigger every scroll and resize, and a panel that dropped\n // them would paint at the top-left of the viewport.\n ...style,\n position: \"fixed\",\n top: pos.top,\n bottom: pos.bottom,\n left: pos.left,\n width: panelWidth,\n maxHeight: placement.maxHeight,\n }}\n className={cn(\n \"z-50 overflow-y-auto rounded-md border border-subtle bg-surface p-3 shadow-lg outline-none\",\n className,\n )}\n >\n {children(close)}\n </div>,\n document.body,\n )}\n </>\n );\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../src/components/popover.tsx"],"sourcesContent":["import { useMemo, useRef, useState } from \"react\";\nimport type { ComponentPropsWithoutRef, KeyboardEvent, ReactNode, Ref, RefObject } from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { anchoredInline, useAnchoredPanel, viewportSize } from \"../hooks/use-anchored-panel\";\nimport { runOwnKeyDown, takeEscape, useDismissLayer, useOutsideClick } from \"../hooks/use-dismiss\";\nimport { useFocusTrap } from \"../hooks/use-focus-trap\";\nimport { lengthPx, useRemPx, type RemLength } from \"../hooks/use-breakpoint\";\nimport { cn } from \"../lib/cn\";\nimport { dirOf } from \"../lib/direction\";\nimport type { Direction } from \"../lib/direction\";\nimport { useKitLabels, type LabelOverride } from \"../i18n/kit-labels\";\nimport { DEFAULT_POPOVER_LABELS } from \"./popover-labels\";\n// From ../lib/refs, not field-parts (round 0.36): field-parts renders a HelpPopover for a\n// field's `help`, so importing from there would close a cycle, as it did for Tooltip.\nimport { mergeRefs } from \"../lib/refs\";\n\n/** The panel's accessible name. A `role=\"dialog\"` with no name announces as \"dialog\"\n * and nothing else, which is worse than the unlabelled div it replaced — so it is a\n * prop with an English default, like every other string this package renders. Callers\n * should pass the control's own name (\"Filter\", \"Choose a date\"); the default is only\n * there so an unmigrated call site is still named. */\nexport interface PopoverLabels {\n panel: string;\n}\n\n// Defined in ./popover-labels, which holds no component code (0.34, KS U8).\nexport { DEFAULT_POPOVER_LABELS };\n\n/**\n * Exported, and extending the `<div>` attributes, so a consumer wrapping this — which is\n * what `DateField` and `FilterPopover` in this very package do — can hand anything\n * through to the PANEL: a `data-tour` anchor for the kit's own guided tour, a test id,\n * an `aria-describedby`, or an `aria-label` naming this particular popover.\n *\n * `children` is omitted from those attributes rather than inherited: here it is a render\n * prop taking the panel's `close`, not a `ReactNode`, so the two cannot be reconciled.\n */\nexport interface PopoverProps extends Omit<ComponentPropsWithoutRef<\"div\">, \"children\"> {\n trigger: (state: { open: boolean; toggle: () => void; ref: RefObject<HTMLButtonElement | null> }) => ReactNode;\n children: (close: () => void) => ReactNode;\n /**\n * The panel's width: `\"18rem\"` (the default) grows with the text size — the panel\n * holds text, and a 288 px calendar at 150 % cut its own month off (0.32,\n * docs/text-size-harmonization.md §3.2). A plain number is px, fixed, as before.\n * Either way the panel is never wider than the viewport less 8 px a side: 18rem is\n * 432 px at Extra large, more than a 360 px phone has.\n */\n width?: number | RemLength;\n labels?: LabelOverride<PopoverLabels>;\n /** Id for the panel, so a trigger wearing `role=\"combobox\"` can `aria-controls` it.\n * Required by ARIA on that role, and the panel is rendered here rather than by the\n * caller, so the id has to come in from outside. */\n panelId?: string;\n /** Extra classes for the PANEL. The trigger is the caller's own element. */\n className?: string;\n /** The PANEL, while it is open — where the rest of the props go (0.34, API-2, refactor\n * audit 2026-10). Merged with the panel's own ref (focus trap, outside click), so\n * neither loses it. The trigger is the caller's own element, ref and all. */\n ref?: Ref<HTMLDivElement>;\n}\n\n/** 288 px at Normal, in rem so it follows the text size (§3.2). */\nconst POPOVER_WIDTH: RemLength = \"18rem\";\n\n/** The gap the panel keeps from either edge of the viewport, in px. */\nconst EDGE = 8;\n\n/**\n * Portal-rendered popover anchored under its trigger button. The panel is fixed\n * to the viewport (so table/overflow ancestors can't clip it) and re-aligns on\n * scroll/resize; it closes on outside-click and Escape.\n *\n * ## Trapped, not merely focused — and why, because it is the closer call\n *\n * The audit's finding was that this panel *\"portals out of the tab order with no role\n * and no focus move\"*: Tab from the trigger went straight past the panel into the page\n * behind it, so the calendar, the calculator keypad and every column filter in the kit\n * were mouse-only. Two shapes fix that, and a popover is not automatically a dialog, so\n * this was chosen rather than inherited:\n *\n * - **The light one** — move focus in on open, hand it back on close, and let Tab walk\n * OUT of the panel into whatever follows the trigger. That is the right shape for a\n * non-modal popover sitting inline in the document.\n * - **The trap** — Tab cycles inside the panel; Escape and a click outside are the\n * ways out. What is here.\n *\n * The deciding fact is the portal. This panel is rendered to the END of `<body>`, not\n * beside its trigger, so \"let Tab walk out\" does not mean \"carry on from the trigger\" —\n * it means land at the end of the document, several hundred controls from where the\n * user was. Tabbing out of it would be a worse place to arrive than the one the finding\n * complains about, and fixing that properly needs a sentinel-and-return dance that\n * nothing in this package has. The trap gets the user back to the trigger every time,\n * because `useFocusTrap` restores focus on close.\n *\n * And the panels this kit actually opens are self-contained: pick a date, tap out a\n * sum, narrow a column. Each of them commits and closes, and each already has two\n * documented ways out wired above — Escape and an outside press — so containment costs\n * a keyboard user nothing they had.\n *\n * **It stays non-modal in every other respect**, deliberately: no backdrop, no body\n * scroll lock, and no `aria-modal`, so the page behind is still readable by a screen\n * reader, still scrollable, and still clickable — clicking it is what dismisses this.\n * Only Tab is contained. A popover that took the page away as well would be a\n * {@link Modal}, and there is one of those.\n */\nexport function Popover({\n trigger,\n children,\n width = POPOVER_WIDTH,\n labels,\n panelId,\n className,\n style,\n \"aria-label\": ariaLabel,\n onKeyDown: onKeyDownProp,\n ref,\n ...rest\n}: PopoverProps) {\n const triggerRef = useRef<HTMLButtonElement>(null);\n const panelRef = useRef<HTMLDivElement>(null);\n const setPanel = useMemo(() => mergeRefs(panelRef, ref), [ref]);\n const [open, setOpen] = useState(false);\n // The trigger's reading direction, read when it is pressed. The panel is portalled to\n // <body>, out of any `dir=\"rtl\"` subtree the trigger sits in, so without carrying it\n // across a right-to-left calendar or filter rendered left-to-right.\n const [dir, setDir] = useState<Direction>(\"ltr\");\n const close = () => setOpen(false);\n const toggle = () => {\n setDir(dirOf(triggerRef.current));\n setOpen((v) => !v);\n };\n // prop > `<UiKitProvider labels={{ popover }}>` > English. The provider's\n // `popover.panel` is only the last-resort name for a panel no caller named.\n const text = useKitLabels(\"popover\", DEFAULT_POPOVER_LABELS, labels);\n\n // Align the panel's END edge to the trigger's — right in LTR, left in RTL; it was\n // always `rect.right`, so an RTL panel hung off the wrong side — and clamp it\n // horizontally; the\n // hook owns the vertical half — below by default, flipped above (and height-capped)\n // when that is where the room is, e.g. once a mobile keyboard has eaten the bottom\n // of the screen (feedback #135). It re-measures on scroll/resize.\n const placement = useAnchoredPanel(triggerRef, open);\n const rect = placement.rect;\n // In px for the arithmetic below, at the text size in force; capped at the glass — the\n // root's client width, not `innerWidth`, which on a phone is the layout viewport grown\n // by whatever overflows (live #381, Wave 2 DUP-7).\n const remPx = useRemPx();\n const glass = rect ? viewportSize().width : 0;\n const panelWidth = rect ? Math.min(lengthPx(width, remPx), Math.max(0, glass - 2 * EDGE)) : 0;\n const pos = rect\n ? {\n // Flipped above, the panel hangs from its bottom edge, so one shorter than its\n // cap still meets the trigger (keksdose K1); the edge not in use is cleared.\n top: placement.above ? undefined : placement.top,\n bottom: placement.above ? placement.bottom : undefined,\n left: anchoredInline(rect, panelWidth, { dir, align: \"end\", edge: EDGE, viewportWidth: glass }).left,\n }\n : null;\n\n // A press in a list opened from inside — a CurrencySelect's, a combobox's, another\n // popover's — reaches the panel through the React tree and is inside (dev#623).\n const markInside = useOutsideClick([triggerRef, panelRef], close, open);\n // A layer on the Escape stack while open (Wave 2, DUP-1): a list opened inside closes\n // first, and the dialog this popover sits in only after it — the calculator on an\n // amount field in a Modal is exactly that.\n const layer = useDismissLayer(open, close);\n\n useFocusTrap(panelRef, {\n active: open && pos !== null,\n // The panel keeps a focus one of its own children has already taken. `FilterPopover`\n // autofocuses its text box on mount — that is what the press on \"Filter\" was FOR,\n // and it is a prop there precisely so the popover keeps it — and a trap that\n // unconditionally focused the container would take it straight back off again,\n // one effect later and invisibly.\n initialFocus: () => {\n const panel = panelRef.current;\n const active = document.activeElement;\n return panel?.contains(active) ? (active as HTMLElement) : panel;\n },\n });\n\n const handleKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {\n // A caller's own handler first (0.32.2: RowActions stops Enter / Space at the panel);\n // it used to be dropped, overwritten by this one. A `preventDefault()` on Escape\n // there keeps the popover open — the owner's claim, as on the dialogs. And as there,\n // the handler sees Escape only while this popover is the layer on top: a tooltip or a\n // menu open over it takes that press first (round 0.35 R2, the dialogs' rule).\n runOwnKeyDown(e, onKeyDownProp, layer);\n // Focus is in here, the normal case: the key is handed to the top layer and STOPPED,\n // so it does not travel on up the React tree to a dialog around the popover.\n takeEscape(e);\n };\n\n return (\n <>\n {trigger({ open, toggle, ref: triggerRef })}\n {open && pos &&\n createPortal(\n /* eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions -- the\n dialog container, per the APG dialog pattern: `onKeyDown` is Escape-to-close for\n whatever inside it holds focus. */\n <div\n // `...rest` first: the role, the id the trigger's `aria-controls` points at,\n // the `tabIndex` the trap needs and the measured position are what make this\n // a popover, and a caller reaching for a `data-tour` anchor must not be able\n // to remove one of them by accident.\n {...rest}\n ref={setPanel}\n role=\"dialog\"\n id={panelId}\n // The caller's own `aria-label` wins over {@link PopoverLabels.panel}, which\n // is the package's English fallback for an unnamed panel. Both name the same\n // element; the DOM spelling is the one being standardised on, so where both\n // are present it is the one that survives.\n aria-label={ariaLabel ?? text.panel}\n dir={rest.dir ?? dir}\n // Without it the panel cannot take focus at all, and the trap above would\n // silently do nothing on a panel whose children are not yet tabbable.\n tabIndex={-1}\n onKeyDown={handleKeyDown}\n onPointerDownCapture={(e) => {\n rest.onPointerDownCapture?.(e);\n markInside(e);\n }}\n style={{\n // Merged over the caller's, never replaced by it: these are measured\n // against the trigger every scroll and resize, and a panel that dropped\n // them would paint at the top-left of the viewport.\n ...style,\n position: \"fixed\",\n top: pos.top,\n bottom: pos.bottom,\n left: pos.left,\n width: panelWidth,\n maxHeight: placement.maxHeight,\n }}\n className={cn(\n \"z-50 overflow-y-auto rounded-md border border-subtle bg-surface p-3 shadow-lg outline-none\",\n className,\n )}\n >\n {children(close)}\n </div>,\n document.body,\n )}\n </>\n );\n}\n"],"mappings":";AAkMI,mBAOM,KAPN;AAlMJ,SAAS,SAAS,QAAQ,gBAAgB;AAE1C,SAAS,oBAAoB;AAC7B,SAAS,gBAAgB,kBAAkB,oBAAoB;AAC/D,SAAS,eAAe,YAAY,iBAAiB,uBAAuB;AAC5E,SAAS,oBAAoB;AAC7B,SAAS,UAAU,gBAAgC;AACnD,SAAS,UAAU;AACnB,SAAS,aAAa;AAEtB,SAAS,oBAAwC;AACjD,SAAS,8BAA8B;AAGvC,SAAS,iBAAiB;AAgD1B,MAAM,gBAA2B;AAGjC,MAAM,OAAO;AAwCN,SAAS,QAAQ;AAAA,EACtB;AAAA,EACA;AAAA,EACA,QAAQ;AAAA,EACR;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,cAAc;AAAA,EACd,WAAW;AAAA,EACX;AAAA,EACA,GAAG;AACL,GAAiB;AACf,QAAM,aAAa,OAA0B,IAAI;AACjD,QAAM,WAAW,OAAuB,IAAI;AAC5C,QAAM,WAAW,QAAQ,MAAM,UAAU,UAAU,GAAG,GAAG,CAAC,GAAG,CAAC;AAC9D,QAAM,CAAC,MAAM,OAAO,IAAI,SAAS,KAAK;AAItC,QAAM,CAAC,KAAK,MAAM,IAAI,SAAoB,KAAK;AAC/C,QAAM,QAAQ,MAAM,QAAQ,KAAK;AACjC,QAAM,SAAS,MAAM;AACnB,WAAO,MAAM,WAAW,OAAO,CAAC;AAChC,YAAQ,CAAC,MAAM,CAAC,CAAC;AAAA,EACnB;AAGA,QAAM,OAAO,aAAa,WAAW,wBAAwB,MAAM;AAQnE,QAAM,YAAY,iBAAiB,YAAY,IAAI;AACnD,QAAM,OAAO,UAAU;AAIvB,QAAM,QAAQ,SAAS;AACvB,QAAM,QAAQ,OAAO,aAAa,EAAE,QAAQ;AAC5C,QAAM,aAAa,OAAO,KAAK,IAAI,SAAS,OAAO,KAAK,GAAG,KAAK,IAAI,GAAG,QAAQ,IAAI,IAAI,CAAC,IAAI;AAC5F,QAAM,MAAM,OACR;AAAA;AAAA;AAAA,IAGE,KAAK,UAAU,QAAQ,SAAY,UAAU;AAAA,IAC7C,QAAQ,UAAU,QAAQ,UAAU,SAAS;AAAA,IAC7C,MAAM,eAAe,MAAM,YAAY,EAAE,KAAK,OAAO,OAAO,MAAM,MAAM,eAAe,MAAM,CAAC,EAAE;AAAA,EAClG,IACA;AAIJ,QAAM,aAAa,gBAAgB,CAAC,YAAY,QAAQ,GAAG,OAAO,IAAI;AAItE,QAAM,QAAQ,gBAAgB,MAAM,KAAK;AAEzC,eAAa,UAAU;AAAA,IACrB,QAAQ,QAAQ,QAAQ;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,IAMxB,cAAc,MAAM;AAClB,YAAM,QAAQ,SAAS;AACvB,YAAM,SAAS,SAAS;AACxB,aAAO,OAAO,SAAS,MAAM,IAAK,SAAyB;AAAA,IAC7D;AAAA,EACF,CAAC;AAED,QAAM,gBAAgB,CAAC,MAAqC;AAM1D,kBAAc,GAAG,eAAe,KAAK;AAGrC,eAAW,CAAC;AAAA,EACd;AAEA,SACE,iCACG;AAAA,YAAQ,EAAE,MAAM,QAAQ,KAAK,WAAW,CAAC;AAAA,IACzC,QAAQ,OACP;AAAA;AAAA;AAAA;AAAA,MAIE;AAAA,QAAC;AAAA;AAAA,UAKE,GAAG;AAAA,UACJ,KAAK;AAAA,UACL,MAAK;AAAA,UACL,IAAI;AAAA,UAKJ,cAAY,aAAa,KAAK;AAAA,UAC9B,KAAK,KAAK,OAAO;AAAA,UAGjB,UAAU;AAAA,UACV,WAAW;AAAA,UACX,sBAAsB,CAAC,MAAM;AAC3B,iBAAK,uBAAuB,CAAC;AAC7B,uBAAW,CAAC;AAAA,UACd;AAAA,UACA,OAAO;AAAA;AAAA;AAAA;AAAA,YAIL,GAAG;AAAA,YACH,UAAU;AAAA,YACV,KAAK,IAAI;AAAA,YACT,QAAQ,IAAI;AAAA,YACZ,MAAM,IAAI;AAAA,YACV,OAAO;AAAA,YACP,WAAW,UAAU;AAAA,UACvB;AAAA,UACA,WAAW;AAAA,YACT;AAAA,YACA;AAAA,UACF;AAAA,UAEC,mBAAS,KAAK;AAAA;AAAA,MACjB;AAAA,MACA,SAAS;AAAA,IACX;AAAA,KACJ;AAEJ;","names":[]}
@@ -0,0 +1,35 @@
1
+ import type { ComponentPropsWithRef } from "react";
2
+ export interface TableSkeletonProps extends Omit<ComponentPropsWithRef<"div">, "children"> {
3
+ /** Rows per group (or in all, without `groups`). Default 5. */
4
+ rows?: number;
5
+ /** Columns, the name column included. Default 3. */
6
+ columns?: number;
7
+ /**
8
+ * `list` (default): a name bar of varying width, then the other columns as figures at
9
+ * the row's end — from three columns up they go under the name on a phone, as a card
10
+ * list draws them. `grid`: a label column, then one equal block per column, clipped
11
+ * at the edge — a heatmap or a matrix.
12
+ */
13
+ variant?: "list" | "grid";
14
+ /** This many group header bars, each over `rows` rows (a budget's groups). Default none. */
15
+ groups?: number;
16
+ /** A row of this many stat-tile outlines above the panel. Default none. */
17
+ tiles?: number;
18
+ /** A title and a hint line at the top of the panel, as a card's head. */
19
+ heading?: boolean;
20
+ /** The panel's border and surface. Default `true`; `false` for a panel the caller frames. */
21
+ framed?: boolean;
22
+ /** What is said, once. Default `common.loading` ("Loading…") from the provider. */
23
+ label?: string;
24
+ }
25
+ /**
26
+ * The outline of a table or a list while its data loads (0.48, keksdose dev #628) — so a
27
+ * page does not jump from a centred spinner to a long list when the data lands.
28
+ * keksdose hand-built three of these (TabSkeleton, BudgetSkeleton,
29
+ * VarianceHeatmapSkeleton), each a stack of `Skeleton`s with its own sr-only line.
30
+ *
31
+ * ONE `role="status"` region says "Loading…" once; every placeholder inside is
32
+ * `aria-hidden` — not one announcement per row. The pulse stops under reduced motion
33
+ * (`Skeleton`'s own rule); the shapes stay. Props and `ref` go to the region.
34
+ */
35
+ export declare function TableSkeleton({ rows, columns, variant, groups, tiles, heading, framed, label, className, ...rest }: TableSkeletonProps): import("react").JSX.Element;
@@ -0,0 +1,75 @@
1
+ "use client";
2
+ import { jsx, jsxs } from "react/jsx-runtime";
3
+ import { cn } from "../lib/cn.js";
4
+ import { DEFAULT_COMMON_LABELS, useKitLabels } from "../i18n/kit-labels.js";
5
+ import { Skeleton } from "./skeleton.js";
6
+ const NAME_WIDTHS = ["w-2/5", "w-1/2", "w-1/3", "w-5/12"];
7
+ function TableSkeleton({
8
+ rows = 5,
9
+ columns = 3,
10
+ variant = "list",
11
+ groups = 0,
12
+ tiles = 0,
13
+ heading = false,
14
+ framed = true,
15
+ label,
16
+ className,
17
+ ...rest
18
+ }) {
19
+ const common = useKitLabels("common", DEFAULT_COMMON_LABELS);
20
+ const figures = Math.max(0, columns - 1);
21
+ const grid = variant === "grid";
22
+ const stack = !grid && figures >= 2;
23
+ const row = (key) => grid ? /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-1", "data-skeleton-row": "", children: [
24
+ /* @__PURE__ */ jsx(Skeleton, { className: "h-7 w-28 shrink-0" }),
25
+ Array.from({ length: figures }, (_, c) => /* @__PURE__ */ jsx(Skeleton, { className: "h-7 w-12 shrink-0" }, c))
26
+ ] }, key) : /* @__PURE__ */ jsxs(
27
+ "div",
28
+ {
29
+ className: cn("flex items-center gap-4 px-4 py-3", stack && "max-sm:flex-col max-sm:items-stretch max-sm:gap-2"),
30
+ "data-skeleton-row": "",
31
+ children: [
32
+ /* @__PURE__ */ jsx(Skeleton, { className: cn("h-4", NAME_WIDTHS[key % NAME_WIDTHS.length]) }),
33
+ figures > 0 && /* @__PURE__ */ jsx("div", { className: cn("ms-auto flex gap-6", stack && "max-sm:ms-0 max-sm:justify-between max-sm:gap-2"), children: Array.from({ length: figures }, (_, f) => /* @__PURE__ */ jsx(Skeleton, { className: "h-4 w-16 md:w-24" }, f)) })
34
+ ]
35
+ },
36
+ key
37
+ );
38
+ const block = (offset) => /* @__PURE__ */ jsx("div", { className: grid ? "space-y-1 overflow-hidden" : "divide-y divide-subtle", children: Array.from({ length: rows }, (_, r) => row(offset + r)) });
39
+ return (
40
+ // `relative`: the containing block for the sr-only text (sr-only-containment.test).
41
+ /* @__PURE__ */ jsxs("div", { ...rest, role: "status", className: cn("relative", className), "data-skeleton-preset": "table", children: [
42
+ /* @__PURE__ */ jsx("span", { className: "sr-only", children: label ?? common.loading }),
43
+ /* @__PURE__ */ jsxs("div", { "aria-hidden": "true", className: "flex flex-col gap-4", children: [
44
+ tiles > 0 && /* @__PURE__ */ jsx("div", { className: "grid grid-cols-[repeat(auto-fit,minmax(9rem,1fr))] gap-3", children: Array.from({ length: tiles }, (_, i) => /* @__PURE__ */ jsxs("div", { className: "rounded-lg border border-subtle bg-surface p-4", "data-skeleton-tile": "", children: [
45
+ /* @__PURE__ */ jsx(Skeleton, { className: "h-3 w-24 max-w-full" }),
46
+ /* @__PURE__ */ jsx(Skeleton, { className: "mt-3 h-7 w-32 max-w-full" })
47
+ ] }, i)) }),
48
+ /* @__PURE__ */ jsxs(
49
+ "div",
50
+ {
51
+ className: cn("overflow-clip", framed && "rounded-lg border border-subtle bg-surface", grid && "p-3"),
52
+ "data-skeleton-panel": "",
53
+ children: [
54
+ heading && /* @__PURE__ */ jsxs("div", { className: cn(grid ? "mb-3" : "px-4 pt-4 pb-2"), "data-skeleton-heading": "", children: [
55
+ /* @__PURE__ */ jsx(Skeleton, { className: "h-4 w-40 max-w-full" }),
56
+ /* @__PURE__ */ jsx(Skeleton, { className: "mt-2 h-3 w-64 max-w-full" })
57
+ ] }),
58
+ groups > 0 ? Array.from({ length: groups }, (_, g) => /* @__PURE__ */ jsxs("div", { className: cn(g > 0 && "border-t border-subtle"), children: [
59
+ /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-3 bg-surface-2 px-4 py-2.5", "data-skeleton-group": "", children: [
60
+ /* @__PURE__ */ jsx(Skeleton, { className: cn("h-4", g % 2 ? "w-24" : "w-28") }),
61
+ /* @__PURE__ */ jsx(Skeleton, { className: "ms-auto h-4 w-12" })
62
+ ] }),
63
+ block(g * rows)
64
+ ] }, g)) : block(0)
65
+ ]
66
+ }
67
+ )
68
+ ] })
69
+ ] })
70
+ );
71
+ }
72
+ export {
73
+ TableSkeleton
74
+ };
75
+ //# sourceMappingURL=table-skeleton.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/components/table-skeleton.tsx"],"sourcesContent":["import type { ComponentPropsWithRef } from \"react\";\nimport { cn } from \"../lib/cn\";\nimport { DEFAULT_COMMON_LABELS, useKitLabels } from \"../i18n/kit-labels\";\nimport { Skeleton } from \"./skeleton\";\n\nexport interface TableSkeletonProps extends Omit<ComponentPropsWithRef<\"div\">, \"children\"> {\n /** Rows per group (or in all, without `groups`). Default 5. */\n rows?: number;\n /** Columns, the name column included. Default 3. */\n columns?: number;\n /**\n * `list` (default): a name bar of varying width, then the other columns as figures at\n * the row's end — from three columns up they go under the name on a phone, as a card\n * list draws them. `grid`: a label column, then one equal block per column, clipped\n * at the edge — a heatmap or a matrix.\n */\n variant?: \"list\" | \"grid\";\n /** This many group header bars, each over `rows` rows (a budget's groups). Default none. */\n groups?: number;\n /** A row of this many stat-tile outlines above the panel. Default none. */\n tiles?: number;\n /** A title and a hint line at the top of the panel, as a card's head. */\n heading?: boolean;\n /** The panel's border and surface. Default `true`; `false` for a panel the caller frames. */\n framed?: boolean;\n /** What is said, once. Default `common.loading` (\"Loading…\") from the provider. */\n label?: string;\n}\n\n/** Name widths that differ from row to row, so the outline reads as a list rather than\n * a striped block. */\nconst NAME_WIDTHS = [\"w-2/5\", \"w-1/2\", \"w-1/3\", \"w-5/12\"];\n\n/**\n * The outline of a table or a list while its data loads (0.48, keksdose dev #628) — so a\n * page does not jump from a centred spinner to a long list when the data lands.\n * keksdose hand-built three of these (TabSkeleton, BudgetSkeleton,\n * VarianceHeatmapSkeleton), each a stack of `Skeleton`s with its own sr-only line.\n *\n * ONE `role=\"status\"` region says \"Loading…\" once; every placeholder inside is\n * `aria-hidden` — not one announcement per row. The pulse stops under reduced motion\n * (`Skeleton`'s own rule); the shapes stay. Props and `ref` go to the region.\n */\nexport function TableSkeleton({\n rows = 5,\n columns = 3,\n variant = \"list\",\n groups = 0,\n tiles = 0,\n heading = false,\n framed = true,\n label,\n className,\n ...rest\n}: TableSkeletonProps) {\n const common = useKitLabels(\"common\", DEFAULT_COMMON_LABELS);\n const figures = Math.max(0, columns - 1);\n const grid = variant === \"grid\";\n // Three figures don't fit beside a name on a phone; one or two do.\n const stack = !grid && figures >= 2;\n\n const row = (key: number) =>\n grid ? (\n <div key={key} className=\"flex items-center gap-1\" data-skeleton-row=\"\">\n <Skeleton className=\"h-7 w-28 shrink-0\" />\n {Array.from({ length: figures }, (_, c) => (\n <Skeleton key={c} className=\"h-7 w-12 shrink-0\" />\n ))}\n </div>\n ) : (\n <div\n key={key}\n className={cn(\"flex items-center gap-4 px-4 py-3\", stack && \"max-sm:flex-col max-sm:items-stretch max-sm:gap-2\")}\n data-skeleton-row=\"\"\n >\n <Skeleton className={cn(\"h-4\", NAME_WIDTHS[key % NAME_WIDTHS.length])} />\n {figures > 0 && (\n <div className={cn(\"ms-auto flex gap-6\", stack && \"max-sm:ms-0 max-sm:justify-between max-sm:gap-2\")}>\n {Array.from({ length: figures }, (_, f) => (\n <Skeleton key={f} className=\"h-4 w-16 md:w-24\" />\n ))}\n </div>\n )}\n </div>\n );\n\n const block = (offset: number) => (\n <div className={grid ? \"space-y-1 overflow-hidden\" : \"divide-y divide-subtle\"}>\n {Array.from({ length: rows }, (_, r) => row(offset + r))}\n </div>\n );\n\n return (\n // `relative`: the containing block for the sr-only text (sr-only-containment.test).\n <div {...rest} role=\"status\" className={cn(\"relative\", className)} data-skeleton-preset=\"table\">\n <span className=\"sr-only\">{label ?? common.loading}</span>\n <div aria-hidden=\"true\" className=\"flex flex-col gap-4\">\n {tiles > 0 && (\n <div className=\"grid grid-cols-[repeat(auto-fit,minmax(9rem,1fr))] gap-3\">\n {Array.from({ length: tiles }, (_, i) => (\n <div key={i} className=\"rounded-lg border border-subtle bg-surface p-4\" data-skeleton-tile=\"\">\n <Skeleton className=\"h-3 w-24 max-w-full\" />\n <Skeleton className=\"mt-3 h-7 w-32 max-w-full\" />\n </div>\n ))}\n </div>\n )}\n <div\n className={cn(\"overflow-clip\", framed && \"rounded-lg border border-subtle bg-surface\", grid && \"p-3\")}\n data-skeleton-panel=\"\"\n >\n {heading && (\n <div className={cn(grid ? \"mb-3\" : \"px-4 pt-4 pb-2\")} data-skeleton-heading=\"\">\n <Skeleton className=\"h-4 w-40 max-w-full\" />\n <Skeleton className=\"mt-2 h-3 w-64 max-w-full\" />\n </div>\n )}\n {groups > 0\n ? Array.from({ length: groups }, (_, g) => (\n <div key={g} className={cn(g > 0 && \"border-t border-subtle\")}>\n <div className=\"flex items-center gap-3 bg-surface-2 px-4 py-2.5\" data-skeleton-group=\"\">\n <Skeleton className={cn(\"h-4\", g % 2 ? \"w-24\" : \"w-28\")} />\n <Skeleton className=\"ms-auto h-4 w-12\" />\n </div>\n {block(g * rows)}\n </div>\n ))\n : block(0)}\n </div>\n </div>\n </div>\n );\n}\n"],"mappings":"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","names":[]}
@@ -36,16 +36,10 @@ export interface TabsLabels {
36
36
  add: string;
37
37
  /** Accessible name of a tab's × — "Remove <tab>". */
38
38
  remove: (tab: string) => string;
39
- /** The `overflowCue` › over the strip's end, while tabs lie past it: "More tabs". */
39
+ /** The `overflowCue` › after the strip's end, while tabs lie past it: "More tabs". */
40
40
  more: string;
41
- /** The `overflowCue` ‹ over the strip's start, while tabs lie before it: "Previous tabs". */
41
+ /** The `overflowCue` ‹ before the strip's start, while tabs lie before it: "Previous tabs". */
42
42
  previous: string;
43
- /**
44
- * @deprecated 0.47: not drawn any more — the caret that turned into "back to the
45
- * first tab" at the strip's end is now a ‹ of its own (`previous`). Accepted and
46
- * ignored; removed in 0.48.
47
- */
48
- backToFirst?: string;
49
43
  }
50
44
  export { DEFAULT_TABS_LABELS };
51
45
  /**
@@ -68,8 +62,8 @@ export interface TabsProps<T extends string> extends Omit<ComponentPropsWithoutR
68
62
  onChange: (id: T) => void;
69
63
  className?: string;
70
64
  /**
71
- * Carets over the ends of a SCROLLING strip (0.40, kk run 79 gap 1; 0.47, keksdose
72
- * live #410 round 4): a › over the strip's end while tabs lie past it, and a ‹ over
65
+ * Carets at the ends of a SCROLLING strip (0.40, kk run 79 gap 1; 0.47, keksdose
66
+ * live #410 round 4): a › after the strip's end while tabs lie past it, and a ‹ before
73
67
  * its start while tabs lie before it — each there only while something is hidden
74
68
  * past ITS edge (1 px slack, so a few cut-off pixels count), each a step of three
75
69
  * quarters of the strip's width toward its own side. Neither ever changes direction:
@@ -78,8 +72,12 @@ export interface TabsProps<T extends string> extends Omit<ComponentPropsWithoutR
78
72
  *
79
73
  * The edge fade alone could read as the end of the row: the last visible tab can
80
74
  * finish exactly where the fade begins, so nothing looks cut off (keksdose live #410).
81
- * The carets sit OVER the strip's ends (positioned, on the page's surface), not beside
82
- * it, so the strip's width never changes as they come and go. Out of the tab order —
75
+ * Since 0.48 (live #410 rounds 5–6) the carets are bare glyphs BESIDE the strip — the ‹
76
+ * before it, the › after it — centred on the labels, muted and primary on hover, with
77
+ * no frame, fill or shadow; until 0.47 they were framed buttons laid over the strip's
78
+ * last visible tabs. While the strip overflows at all it keeps a 24px gutter at both
79
+ * ends, and each caret sits in its own, so scrolling never changes the strip's width
80
+ * or moves its labels; a strip that fits has no gutters. Out of the tab order —
83
81
  * the keyboard walks the strip with the arrow keys, which bring the focused tab into
84
82
  * view. At every width. Ignored on a vertical strip (a side nav is a column, not a
85
83
  * row). Names: `tabs.more` / `tabs.previous`. keksdose's `scroll-edge-cues.tsx` is