@eifi1/ui-kit 0.51.5 → 0.51.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (129) hide show
  1. package/README.md +9 -7
  2. package/dist/admin/admin-action-confirm.js +2 -4
  3. package/dist/admin/admin-action-confirm.js.map +1 -1
  4. package/dist/auth/complete-name-dialog.d.ts +3 -2
  5. package/dist/auth/complete-name-dialog.js +1 -4
  6. package/dist/auth/complete-name-dialog.js.map +1 -1
  7. package/dist/billing/cancel-request.d.ts +11 -2
  8. package/dist/billing/cancel-request.js +15 -3
  9. package/dist/billing/cancel-request.js.map +1 -1
  10. package/dist/billing/cancel-subscription.d.ts +2 -2
  11. package/dist/billing/cancel-subscription.js +2 -7
  12. package/dist/billing/cancel-subscription.js.map +1 -1
  13. package/dist/billing/plan-switch.d.ts +2 -2
  14. package/dist/billing/plan-switch.js +2 -4
  15. package/dist/billing/plan-switch.js.map +1 -1
  16. package/dist/components/amount-input.d.ts +9 -4
  17. package/dist/components/amount-input.js +2 -3
  18. package/dist/components/amount-input.js.map +1 -1
  19. package/dist/components/calculator.d.ts +21 -2
  20. package/dist/components/calculator.js +53 -14
  21. package/dist/components/calculator.js.map +1 -1
  22. package/dist/components/combobox-core.d.ts +8 -5
  23. package/dist/components/combobox-core.js +3 -4
  24. package/dist/components/combobox-core.js.map +1 -1
  25. package/dist/components/combobox.js +2 -2
  26. package/dist/components/combobox.js.map +1 -1
  27. package/dist/components/data-table-mobile.d.ts +24 -0
  28. package/dist/components/data-table-mobile.js +20 -1
  29. package/dist/components/data-table-mobile.js.map +1 -1
  30. package/dist/components/data-table.d.ts +13 -5
  31. package/dist/components/data-table.js +35 -9
  32. package/dist/components/data-table.js.map +1 -1
  33. package/dist/components/dialog-frame.d.ts +2 -1
  34. package/dist/components/dialog-frame.js.map +1 -1
  35. package/dist/components/entity-combobox.js +4 -2
  36. package/dist/components/entity-combobox.js.map +1 -1
  37. package/dist/components/inline-entity-combobox.js +4 -3
  38. package/dist/components/inline-entity-combobox.js.map +1 -1
  39. package/dist/components/list.js +1 -1
  40. package/dist/components/list.js.map +1 -1
  41. package/dist/components/modal.d.ts +24 -0
  42. package/dist/components/modal.js +9 -3
  43. package/dist/components/modal.js.map +1 -1
  44. package/dist/components/money-draft.js +7 -5
  45. package/dist/components/money-draft.js.map +1 -1
  46. package/dist/components/money-field.d.ts +2 -2
  47. package/dist/components/money-field.js.map +1 -1
  48. package/dist/components/multi-entity-combobox.js +2 -1
  49. package/dist/components/multi-entity-combobox.js.map +1 -1
  50. package/dist/components/number-field.d.ts +6 -4
  51. package/dist/components/number-field.js +3 -2
  52. package/dist/components/number-field.js.map +1 -1
  53. package/dist/components/number-input.d.ts +4 -1
  54. package/dist/components/number-input.js +3 -2
  55. package/dist/components/number-input.js.map +1 -1
  56. package/dist/components/numpad-sheet.js.map +1 -1
  57. package/dist/components/reauth-dialog.d.ts +4 -5
  58. package/dist/components/reauth-dialog.js +1 -4
  59. package/dist/components/reauth-dialog.js.map +1 -1
  60. package/dist/components/series-chart-axes.d.ts +7 -0
  61. package/dist/components/series-chart-axes.js +1 -0
  62. package/dist/components/series-chart-axes.js.map +1 -1
  63. package/dist/components/tabs.js +12 -10
  64. package/dist/components/tabs.js.map +1 -1
  65. package/dist/components/treemap.d.ts +24 -2
  66. package/dist/components/treemap.js +40 -4
  67. package/dist/components/treemap.js.map +1 -1
  68. package/dist/eslint/index.js +1 -1
  69. package/dist/hooks/use-dismiss.js.map +1 -1
  70. package/dist/hooks/use-overlay-history.d.ts +7 -1
  71. package/dist/hooks/use-overlay-history.js +16 -2
  72. package/dist/hooks/use-overlay-history.js.map +1 -1
  73. package/dist/lib/calc.d.ts +7 -1
  74. package/dist/lib/calc.js +3 -1
  75. package/dist/lib/calc.js.map +1 -1
  76. package/dist/lib/clipboard-minus.d.ts +2 -2
  77. package/dist/lib/clipboard-minus.js +1 -1
  78. package/dist/lib/clipboard-minus.js.map +1 -1
  79. package/dist/lib/decimal-marks.d.ts +7 -1
  80. package/dist/lib/decimal-marks.js +5 -2
  81. package/dist/lib/decimal-marks.js.map +1 -1
  82. package/dist/lib/format.d.ts +5 -3
  83. package/dist/lib/format.js +12 -0
  84. package/dist/lib/format.js.map +1 -1
  85. package/dist/lib/text-match.d.ts +9 -0
  86. package/dist/lib/text-match.js +4 -0
  87. package/dist/lib/text-match.js.map +1 -1
  88. package/dist/theme/contrast.d.ts +2 -2
  89. package/dist/tour/tour.d.ts +3 -1
  90. package/dist/tour/tour.js +9 -3
  91. package/dist/tour/tour.js.map +1 -1
  92. package/dist/tour/use-tour-url-sync.js +2 -2
  93. package/dist/tour/use-tour-url-sync.js.map +1 -1
  94. package/package.json +3 -3
  95. package/src/admin/admin-action-confirm.tsx +4 -5
  96. package/src/auth/complete-name-dialog.tsx +5 -7
  97. package/src/billing/cancel-request.ts +28 -6
  98. package/src/billing/cancel-subscription.tsx +4 -11
  99. package/src/billing/plan-switch.tsx +5 -8
  100. package/src/components/amount-input.tsx +14 -7
  101. package/src/components/calculator.tsx +98 -17
  102. package/src/components/combobox-core.tsx +10 -7
  103. package/src/components/combobox.tsx +5 -6
  104. package/src/components/data-table-mobile.tsx +49 -1
  105. package/src/components/data-table.tsx +66 -11
  106. package/src/components/dialog-frame.tsx +2 -1
  107. package/src/components/entity-combobox.tsx +4 -1
  108. package/src/components/inline-entity-combobox.tsx +5 -3
  109. package/src/components/list.tsx +4 -1
  110. package/src/components/modal.tsx +39 -3
  111. package/src/components/money-draft.ts +16 -5
  112. package/src/components/money-field.tsx +2 -2
  113. package/src/components/multi-entity-combobox.tsx +1 -0
  114. package/src/components/number-field.tsx +14 -7
  115. package/src/components/number-input.tsx +9 -3
  116. package/src/components/numpad-sheet.tsx +4 -3
  117. package/src/components/reauth-dialog.tsx +6 -10
  118. package/src/components/series-chart-axes.ts +8 -0
  119. package/src/components/tabs.tsx +33 -17
  120. package/src/components/treemap.tsx +69 -7
  121. package/src/hooks/use-dismiss.ts +3 -2
  122. package/src/hooks/use-overlay-history.ts +38 -2
  123. package/src/lib/calc.ts +13 -2
  124. package/src/lib/clipboard-minus.ts +14 -4
  125. package/src/lib/decimal-marks.ts +12 -3
  126. package/src/lib/format.ts +30 -3
  127. package/src/lib/text-match.ts +12 -0
  128. package/src/tour/tour.tsx +21 -4
  129. package/src/tour/use-tour-url-sync.ts +7 -3
@@ -11,6 +11,18 @@ import { cn } from "../lib/cn.js";
11
11
  import { useTextSize } from "../theme/text-size.js";
12
12
  import { chartHeightProps } from "./chart-height.js";
13
13
  import { formatWithMinus } from "../lib/minus.js";
14
+ function subscribeContrast(onChange) {
15
+ if (typeof MutationObserver === "undefined" || typeof document === "undefined") return () => {
16
+ };
17
+ const observer = new MutationObserver(onChange);
18
+ observer.observe(document.documentElement, { attributes: true, attributeFilter: ["data-contrast"] });
19
+ return () => observer.disconnect();
20
+ }
21
+ const readMoreContrast = () => typeof document !== "undefined" && document.documentElement.getAttribute("data-contrast") === "more";
22
+ function useTileEdges(edges) {
23
+ const more = useSyncExternalStore(subscribeContrast, readMoreContrast, () => false);
24
+ return edges === "auto" ? more ? "contrast" : "uniform" : edges;
25
+ }
14
26
  const CHART_TOKENS = PALETTE_SIZE;
15
27
  function readChartTokens() {
16
28
  const root = document.documentElement;
@@ -63,11 +75,13 @@ function TreemapCell({
63
75
  onNodeClick,
64
76
  redactNames,
65
77
  nodeNote,
66
- dir = "ltr"
78
+ dir = "ltr",
79
+ edges: edgesProp = "auto"
67
80
  }) {
68
81
  const tokenHex = useChartTokenHex();
69
82
  const { scale } = useTextSize();
70
83
  const edgeFor = useChartEdge();
84
+ const edges = useTileEdges(edgesProp);
71
85
  if (!depth) return null;
72
86
  const px = Math.round(x);
73
87
  const py = Math.round(y);
@@ -76,7 +90,7 @@ function TreemapCell({
76
90
  const tileFill = fill ?? "var(--chart-1)";
77
91
  const measured = measurableFill(tileFill, tokenHex);
78
92
  const ink = labelColor ?? (measured ? textOn(measured) : "currentColor");
79
- const edge = measured ? edgeFor(measured) : void 0;
93
+ const edge = edges === "contrast" && measured ? edgeFor(measured) : void 0;
80
94
  const halo = measured ? tileFill : "none";
81
95
  const labelSize = LABEL_FONT_SIZE * scale;
82
96
  const noteSize = NOTE_FONT_SIZE * scale;
@@ -102,7 +116,19 @@ function TreemapCell({
102
116
  cursor: activate ? "pointer" : void 0,
103
117
  className: activate ? "outline-none [&:focus-visible>rect]:stroke-[var(--brand)]" : void 0,
104
118
  children: [
105
- /* @__PURE__ */ jsx("rect", { x: px, y: py, width: pw, height: ph, fill: tileFill, stroke: "var(--bg-surface)", strokeWidth: 2, rx: 3 }),
119
+ /* @__PURE__ */ jsx(
120
+ "rect",
121
+ {
122
+ x: px,
123
+ y: py,
124
+ width: pw,
125
+ height: ph,
126
+ fill: tileFill,
127
+ stroke: edges === "contrast" ? "var(--bg-surface)" : "var(--bg-page)",
128
+ strokeWidth: 2,
129
+ rx: 3
130
+ }
131
+ ),
106
132
  edge && pw > 3 && ph > 3 && /* @__PURE__ */ jsx(
107
133
  "rect",
108
134
  {
@@ -172,6 +198,7 @@ function Treemap({
172
198
  nodeNote,
173
199
  maxTiles,
174
200
  colors,
201
+ edges = "auto",
175
202
  style,
176
203
  ...rest
177
204
  }) {
@@ -214,7 +241,16 @@ function Treemap({
214
241
  dataKey: "size",
215
242
  fill: "var(--chart-1)",
216
243
  isAnimationActive: false,
217
- content: /* @__PURE__ */ jsx(TreemapCell, { onNodeClick, redactNames, nodeNote, dir }),
244
+ content: /* @__PURE__ */ jsx(
245
+ TreemapCell,
246
+ {
247
+ onNodeClick,
248
+ redactNames,
249
+ nodeNote,
250
+ dir,
251
+ edges
252
+ }
253
+ ),
218
254
  children: /* @__PURE__ */ jsx(ChartTooltip, { content: /* @__PURE__ */ jsx(Content, { hideLabel: true, valueFormatter: format }) })
219
255
  }
220
256
  )
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/components/treemap.tsx"],"sourcesContent":["// A tile chart (\"treemap\"): one rectangle per node, its AREA the node's share of what\n// is drawn. Lifted out of keksdose's reports, where five cards drew it (spending by\n// group and by category, net worth by account and by type, allocation, payees, food\n// groups) — every domain word is gone and every string it could print is a prop.\n//\n// It uses the kit's own chart shell (`ChartContainer`, `ChartTooltipContent`) and the\n// kit's own ink rule (`textOn`), so it re-skins with the palette like every other\n// chart. The one thing it cannot take from a CSS var is the tile colour: the label ink\n// is MEASURED against the fill, and a `var(--chart-3)` string measures as nothing — see\n// `useChartTokenHex` below for how the concrete hex is found instead.\nimport { useLayoutEffect, useRef, useState, useSyncExternalStore } from \"react\";\nimport type { ComponentProps, ComponentPropsWithoutRef, KeyboardEvent, ReactNode } from \"react\";\nimport { Treemap as RechartsTreemap } from \"recharts\";\nimport { ChartContainer, ChartTooltip, ChartTooltipContent, type ChartConfig } from \"./chart\";\nimport { PALETTE_HEX, PALETTE_SIZE, textOn } from \"../theme/chart-palette\";\nimport { resolveChartColor, useChartEdge } from \"./chart-edge\";\nimport { parseHex } from \"../theme/color\";\nimport { dirOf, type Direction } from \"../lib/direction\";\nimport { cn } from \"../lib/cn\";\nimport { useTextSize } from \"../theme/text-size\";\nimport { chartHeightProps, type ChartHeight } from \"./chart-height\";\nimport { formatWithMinus } from \"../lib/minus\";\n\n/**\n * One tile.\n *\n * `value`, not keksdose's `total`: a treemap's areas are not always money (keksdose\n * already sizes one by transaction COUNT), and `value` is the word every other chart\n * in recharts uses for the number a mark is sized by. Migrating is a rename at the\n * mapping step — `{ id, name, value: row.total }`.\n */\nexport interface TreemapNode {\n /** Stable key, handed back by {@link TreemapProps.onNodeClick} and\n * {@link TreemapProps.nodeNote}. Names can collide; ids must not. */\n id: string;\n name: string;\n /** The tile's area. A node whose value is not a positive finite number is NOT\n * drawn — an area cannot be negative, and recharts lays a negative size out as\n * nonsense rather than refusing it. Saying so is the caller's job: a group that\n * vanishes silently between two views of the same data is a bug report. */\n value: number;\n /** Overrides the by-index palette colour for this tile — e.g. to paint every child\n * in its parent group's colour, so the groups stay legible in one picture. A\n * concrete `#rrggbb` or a bare `var(--chart-N)` keeps the label ink measured — so\n * does `CHART_ROLES.other` (`var(--chart-muted)`), the colour for a \"rest\" bucket,\n * which should not wrap round to a hue the reader matches to a real node;\n * anything else (another var, a `color-mix()`) gets `currentColor` ink, because\n * there is nothing to measure — set `labelColor` then. */\n fill?: string;\n /** The label's ink, for a `fill` the kit cannot measure (a `var()` or `color-mix()`):\n * keksdose's recency plot fills with `color-mix(in srgb, var(--chart-1) N%,\n * transparent)`, where `currentColor` put dark page text on full-strength indigo\n * (~1.5:1). Wins over the measured ink when given. */\n labelColor?: string;\n}\n\n/**\n * Everything a `<div>` takes reaches the chart's root (`ChartContainer`), so a test id\n * or a `data-tour` anchor can find it. `children` is the chart's own.\n */\nexport interface TreemapProps extends Omit<ComponentPropsWithoutRef<\"div\">, \"children\"> {\n data: TreemapNode[];\n /**\n * Formats a node's value in the tooltip. Handed the node's SHARE of what is drawn\n * (0–1) as a second argument, because a tile is read for how big it is relative to\n * the rest, and \"€412.30\" alone makes the reader estimate that ratio off the areas.\n * The share is `undefined` when the drawn total is not positive.\n *\n * Default: the bare number through `Intl.NumberFormat` in the runtime's locale. A\n * host that has a currency or a locale passes this — the kit knows neither.\n */\n valueFormatter?: (value: number, share: number | undefined) => ReactNode;\n /** Chart height: a CSS length (`\"20rem\"`, `\"min(20rem, 50dvh)\"`), a Tailwind class,\n * or px. Default `\"20rem\"` — 320 px at Normal, growing with the text size like the\n * labels in the tiles (0.32, docs/text-size-harmonization.md §10.10). */\n height?: ChartHeight;\n /** A tile was activated — by click, or by Enter/Space on a focused tile. Setting\n * this makes every tile a focusable `role=\"button\"` named by its node name. */\n onNodeClick?: (id: string, name: string) => void;\n /**\n * The node names are the user's own data (payees, not categories): the tile labels\n * and the whole tooltip bubble get `data-private`, the same attribute the kit's\n * `Tooltip redact` and `ChartTooltipContent`'s value cell carry, so the host's one\n * privacy rule reaches them. Off by default — a category name is a label of the app.\n */\n redactNames?: boolean;\n /**\n * A second, smaller line under a tile's label — one extra figure per node that the\n * name cannot carry (a year-on-year `+12%`, `new`). Return `undefined` for a node\n * with nothing to add. Never tagged `data-private`: a ratio reveals no amount.\n *\n * ON THE TILE rather than in the tooltip, for two reasons. The tooltip identifies a\n * node by name or by raw value, so a per-node note would be keyed on an amount (two\n * nodes of equal size would swap notes). And a tooltip is a pointer affordance: a\n * phone never hovers, so a figure that only exists on hover does not exist there.\n */\n nodeNote?: (id: string) => string | undefined;\n /**\n * Draw at most this many tiles (the first N drawable nodes, in the order given —\n * sort first). Unbounded by default. A tile under ~44x20px has no room for a label,\n * and a map made of unlabelled rectangles answers nothing; around 24 is where the\n * smallest tile of a skewed series reaches that floor on a phone-width card. The\n * caller owns the \"N more not shown\" line, since it knows how many it passed.\n */\n maxTiles?: number;\n /**\n * The categorical ramp tiles take by index, as concrete hex. Default: the page's\n * live `--chart-1…8` tokens (see `useChartTokenHex`), cycled after eight like\n * `paletteFor` (0.49). Pass one when the host\n * already holds the active palette — e.g. `createPaletteStore`'s `useChartHex()` —\n * so a caller colouring a legend beside the map is guaranteed the same list.\n */\n colors?: readonly string[];\n}\n\n// ── The categorical ramp, as concrete hex ──────────────────────────────────\n//\n// The label ink is measured against the tile's fill, so the fill has to be a colour\n// and not a reference to one. keksdose read it from its palette STORE, which is an\n// app object the kit cannot import. What the kit does own is the token layer: whether\n// a palette arrives through `applyTokenSet` (inline properties on <html>) or through\n// tokens.css (`:root` / `.dark`), the resolved `--chart-N` on <html> is the colour\n// every other chart on the page is being painted with. So that is what is read, and\n// re-read whenever <html>'s class or style changes — which is exactly when a theme\n// toggle or a palette switch lands.\n\nconst CHART_TOKENS = PALETTE_SIZE;\n\n/** The resolved ramp, joined into ONE string so `useSyncExternalStore` compares a\n * primitive: a fresh array per read would never be `Object.is`-equal to the last and\n * would re-render forever. */\nfunction readChartTokens(): string {\n const root = document.documentElement;\n const fallback = root.classList.contains(\"dark\") ? PALETTE_HEX.dark : PALETTE_HEX.light;\n const style = getComputedStyle(root);\n return Array.from({ length: CHART_TOKENS }, (_, i) => {\n const v = style.getPropertyValue(`--chart-${i + 1}`).trim();\n // An unresolved token (a host that never loaded tokens.css, a test DOM) falls\n // back to the built-in ramp for the current theme rather than to no colour.\n return parseHex(v) ? v : fallback[i];\n }).join(\",\");\n}\n\nfunction subscribeChartTokens(onChange: () => void): () => void {\n const observer = new MutationObserver(onChange);\n observer.observe(document.documentElement, {\n attributes: true,\n attributeFilter: [\"class\", \"style\", \"data-theme\"],\n });\n return () => observer.disconnect();\n}\n\nconst SERVER_TOKENS = PALETTE_HEX.light.join(\",\");\n\nfunction useChartTokenHex(): string[] {\n const joined = useSyncExternalStore(subscribeChartTokens, readChartTokens, () => SERVER_TOKENS);\n return joined.split(\",\");\n}\n\nconst CHART_TOKEN_REF = /^var\\(\\s*--chart-(\\d+)\\s*\\)$/;\n\n/**\n * The hex a tile fill measures as: the fill itself when it is hex, the live token when\n * it is a bare `var(--chart-N)` (what `paletteFor` returns), otherwise null. Without\n * the token case a hand-built recharts map coloured with the kit's own `paletteFor`\n * got `currentColor` ink — dark page text on dark indigo in the light theme.\n */\nfunction measurableFill(fill: string, tokens: readonly string[]): string | null {\n if (parseHex(fill)) return fill;\n const m = CHART_TOKEN_REF.exec(fill.trim());\n const hex = m ? tokens[Number(m[1]) - 1] : undefined;\n if (hex && parseHex(hex)) return hex;\n // Any other bare token the page declares — `var(--chart-muted)` for a rest bucket,\n // `var(--chart-9)`, a money colour — read off <html> as it resolves.\n if (typeof document === \"undefined\") return null;\n return resolveChartColor(fill, getComputedStyle(document.documentElement));\n}\n\n// ── Tile ──────────────────────────────────────────────────────────────────\n\n/** The name's size in px at Normal; a tile draws it × the text size's scale (§10.10), and\n * every distance below that is measured in lines grows with it. */\nconst LABEL_FONT_SIZE = 12;\n/** The optional second line, a step down from the name so the tile reads name-first. */\nconst NOTE_FONT_SIZE = 11;\n\n/**\n * Fit a label to its tile: the text itself when it fits, an ellipsised head when it\n * does not, `null` when not even three characters would. Without this a long name ran\n * on across its neighbours and collided with THEIR labels. 0.58em is the average\n * advance of a UI sans at this weight; it only needs to be close, since the tile keeps\n * 6px of padding either side. The full name stays in the tooltip.\n */\nexport function fitLabel(name: string, boxWidth: number, fontSize: number): string | null {\n const max = Math.floor((boxWidth - 12) / (fontSize * 0.58));\n if (max < 3) return null;\n return name.length <= max ? name : `${name.slice(0, max - 1).trimEnd()}…`;\n}\n\n/** What recharts hands a `content` element for each node, plus the three options the\n * chart forwards. Everything optional because recharts clones the element it was\n * given and fills the geometry in — the element as written carries only the options. */\nexport interface TreemapCellProps {\n depth?: number;\n x?: number;\n y?: number;\n width?: number;\n height?: number;\n name?: string;\n fill?: string;\n /** See {@link TreemapNode.labelColor}; recharts hands each node's fields to its cell. */\n labelColor?: string;\n id?: string;\n onNodeClick?: (id: string, name: string) => void;\n redactNames?: boolean;\n nodeNote?: (id: string) => string | undefined;\n /** The page's reading direction, which `Treemap` reads off its own root. In `rtl`\n * the label stands in the tile's top RIGHT corner and is shaped right-to-left, so a\n * clipped name ends in its ellipsis on the reading end. Default `ltr`. */\n dir?: Direction;\n}\n\n/**\n * One tile. Exported so a caller drawing its own recharts `<Treemap>` gets the same\n * label rules.\n *\n * recharts' default cell inherits the chart-level `stroke`, an SVG presentation\n * attribute that lands on the `<svg>` root and cascades into the label — so the glyphs\n * were painted with a 1px outline ON TOP of themselves, which reads as blur. This cell\n * draws its own label with a halo in the TILE colour painted BEHIND the fill\n * (`paint-order: stroke`), which defeats any inherited stroke and keeps the text solid\n * where it overlaps a lighter or darker part of its own tile.\n */\nexport function TreemapCell({\n depth,\n x = 0,\n y = 0,\n width = 0,\n height = 0,\n name,\n fill,\n labelColor,\n id,\n onNodeClick,\n redactNames,\n nodeNote,\n dir = \"ltr\",\n}: TreemapCellProps) {\n // Before the early return: a hook has to run on every render of the cell.\n const tokenHex = useChartTokenHex();\n const { scale } = useTextSize();\n const edgeFor = useChartEdge();\n // Depth 0 is recharts' synthetic root, which spans the whole chart.\n if (!depth) return null;\n // Whole pixels: a rect on a half pixel renders its edge across two half-lit\n // columns, and the text baseline inherits that blur.\n const px = Math.round(x);\n const py = Math.round(y);\n const pw = Math.round(width);\n const ph = Math.round(height);\n const tileFill = fill ?? \"var(--chart-1)\";\n const measured = measurableFill(tileFill, tokenHex);\n const ink = labelColor ?? (measured ? textOn(measured) : \"currentColor\");\n // A tile in a hue under 3:1 against the card gets a 1px ink edge inside the gutter\n // (WCAG 1.4.11, anyplot's rule); a hue that clears it stays bare.\n const edge = measured ? edgeFor(measured) : undefined;\n // The halo only on an OPAQUE fill we could measure. A translucent fill (a\n // `color-mix(… transparent)`) composited twice — once as the tile, once as the halo\n // over it — draws a darker ring round every glyph, which read as doubled text on the\n // faded tiles. `none` still overrides the inherited chart `stroke`.\n const halo = measured ? tileFill : \"none\";\n // Below ~44x20 there is no room for a readable word, so no label at all rather than\n // a one-letter stub bleeding over the tile edge.\n const labelSize = LABEL_FONT_SIZE * scale;\n const noteSize = NOTE_FONT_SIZE * scale;\n const label = pw >= 44 * scale && ph >= 20 * scale ? fitLabel(name ?? \"\", pw, labelSize) : null;\n // Only under a label that is itself drawn — a stray \"+12%\" on an unnamed rectangle\n // names nothing — and only where a second baseline fits: the note's baseline sits\n // 29px down and an 11px glyph hangs ~2px below it, so 36 leaves the same ~5px under\n // the note that the label keeps under itself at the 20px floor.\n const rawNote = id != null && nodeNote ? nodeNote(id) : undefined;\n const note = label != null && rawNote != null && ph >= 36 * scale ? fitLabel(rawNote, pw, noteSize) : null;\n // The label's START edge, 6px in from the tile's. The tiles themselves are laid out\n // physically (the plot is not mirrored, see `ChartContainer`) and the chart's SVG is\n // pinned `ltr`, so the direction is set on each <text> explicitly: `start` is then\n // the right end in RTL, and the label grows leftwards from the tile's right edge.\n const textX = dir === \"rtl\" ? px + pw - 6 : px + 6;\n const activate = id != null && onNodeClick ? () => onNodeClick(id, name ?? \"\") : undefined;\n const onKeyDown = activate\n ? (e: KeyboardEvent<SVGGElement>) => {\n if (e.key !== \"Enter\" && e.key !== \" \") return;\n e.preventDefault();\n activate();\n }\n : undefined;\n return (\n <g\n onClick={activate}\n onKeyDown={onKeyDown}\n // A clickable tile is a button to a keyboard and a screen reader too; the\n // drilldown it opens was mouse-only in the app this came from. Named by the node\n // name, which is also why the group carries `data-private` under `redactNames`.\n role={activate ? \"button\" : undefined}\n tabIndex={activate ? 0 : undefined}\n aria-label={activate ? name : undefined}\n data-private={activate && redactNames ? \"\" : undefined}\n cursor={activate ? \"pointer\" : undefined}\n // CSS beats a presentation attribute, so this recolours the rect's own stroke\n // for as long as the tile has keyboard focus.\n className={activate ? \"outline-none [&:focus-visible>rect]:stroke-[var(--brand)]\" : undefined}\n >\n {/* The 2px gutter is the card showing through, not a white grid — on a dark\n surface white lines glare harder than the tiles. */}\n <rect x={px} y={py} width={pw} height={ph} fill={tileFill} stroke=\"var(--bg-surface)\" strokeWidth={2} rx={3} />\n {edge && pw > 3 && ph > 3 && (\n <rect\n data-edge=\"\"\n x={px + 1.5}\n y={py + 1.5}\n width={pw - 3}\n height={ph - 3}\n fill=\"none\"\n stroke={edge}\n strokeWidth={1}\n rx={2}\n style={{ pointerEvents: \"none\" }}\n />\n )}\n {label != null && (\n <text\n // A plain attribute, so the host's `[data-private]` rule reaches an SVG\n // <text> exactly as it reaches a table cell.\n data-private={redactNames ? \"\" : undefined}\n x={textX}\n y={py + 15 * scale}\n direction={dir}\n fill={ink}\n stroke={halo}\n strokeWidth={2.5}\n strokeLinejoin=\"round\"\n paintOrder=\"stroke\"\n fontSize={labelSize}\n fontWeight={600}\n style={{ pointerEvents: \"none\" }}\n >\n {label}\n </text>\n )}\n {note != null && (\n <text\n x={textX}\n y={py + 29 * scale}\n direction={dir}\n fill={ink}\n fillOpacity={0.85}\n stroke={halo}\n strokeWidth={2.5}\n strokeLinejoin=\"round\"\n paintOrder=\"stroke\"\n fontSize={noteSize}\n fontWeight={500}\n style={{ pointerEvents: \"none\" }}\n >\n {note}\n </text>\n )}\n </g>\n );\n}\n\n// ── Chart ─────────────────────────────────────────────────────────────────\n\n// Nodes carry their own `fill`, and `ChartTooltipContent` falls back to it for the\n// swatch, so no series config is needed.\nconst TREEMAP_CONFIG: ChartConfig = {};\n\n/**\n * The shared tooltip with the whole bubble tagged `data-private`, for names that are\n * the user's own data.\n *\n * The bubble, not just the name: `ChartTooltipContent` prints the name either from\n * `item.name` or from a config `label`, and only the config route could be tagged — but\n * a config entry keyed by a payee name is not a CSS identifier, and `ChartStyle` would\n * (rightly) refuse it. The bubble holds the name and the value, and the value cell is\n * already `data-private`, so tagging the wrapper hides nothing extra.\n *\n * recharts CLONES the `content` element to inject `active`/`payload`, so this has to BE\n * that element — a `<div>` wrapped around `<ChartTooltipContent />` in the JSX would\n * take those props itself and hand the tooltip nothing.\n */\nfunction RedactedTooltipContent(props: ComponentProps<typeof ChartTooltipContent>) {\n return (\n <div data-private>\n <ChartTooltipContent {...props} />\n </div>\n );\n}\n\nconst DEFAULT_NUMBER = new Intl.NumberFormat();\n\n/**\n * Tile chart: one rectangle per {@link TreemapNode}, sized by `value`, coloured by\n * index from the chart ramp (or by the node's own `fill`), labelled in whichever of the\n * kit's two inks measures better against it. Hover shows the name and the formatted\n * value; `onNodeClick` turns the tiles into buttons.\n *\n * Renders nothing when no node has a positive value — an empty box is a hole in the\n * card, and the caller's own sentence is the better answer there.\n *\n * Needs `recharts`, like everything behind `@eifi1/ui-kit/chart`.\n */\nexport function Treemap({\n data,\n valueFormatter,\n height = \"20rem\",\n onNodeClick,\n redactNames = false,\n nodeNote,\n maxTiles,\n colors,\n style,\n ...rest\n}: TreemapProps) {\n const tokenHex = useChartTokenHex();\n // Read off the DOM after mount: the `dir` a treemap sits under is an ancestor's\n // attribute, and SVG text anchoring cannot follow it by CSS (see TreemapCell). Again\n // on new data, because a map that had nothing to draw rendered no root to read.\n const rootRef = useRef<HTMLDivElement>(null);\n const [dir, setDir] = useState<Direction>(\"ltr\");\n useLayoutEffect(() => {\n setDir(dirOf(rootRef.current));\n }, [data]);\n const ramp = colors && colors.length > 0 ? colors : tokenHex;\n // Coloured by index into `data` AS GIVEN, before anything is dropped: a caller\n // painting a legend or a second map from the same list must land on the same hue\n // for the same row, and filtering first would shift every colour after a gap.\n const nodes = data\n .map((d, idx) => ({\n id: d.id,\n name: d.name,\n size: d.value,\n fill: d.fill ?? ramp[idx % ramp.length],\n // recharts hands each node's fields to its cell; a field left out of this map\n // never reaches `TreemapCell` (0.8.0 shipped `labelColor` without it — keksdose).\n labelColor: d.labelColor,\n }))\n .filter((n) => Number.isFinite(n.size) && n.size > 0)\n .slice(0, maxTiles ?? undefined);\n if (nodes.length === 0) return null;\n const box = chartHeightProps(height);\n\n // The share is of what is DRAWN, so it always reads as \"of this picture\".\n const total = nodes.reduce((acc, n) => acc + n.size, 0);\n const format = (v: number): ReactNode => {\n const share = total > 0 ? v / total : undefined;\n return valueFormatter ? valueFormatter(v, share) : formatWithMinus(DEFAULT_NUMBER, v);\n };\n const Content = redactNames ? RedactedTooltipContent : ChartTooltipContent;\n return (\n <ChartContainer\n {...rest}\n ref={rootRef}\n config={TREEMAP_CONFIG}\n className={cn(box.className, rest.className)}\n style={{ ...style, ...box.style }}\n >\n {/* No chart-level `stroke`: recharts spreads it onto the <svg> root, from where\n it inherits into every label (see TreemapCell). Each cell strokes its own rect. */}\n <RechartsTreemap\n data={nodes}\n dataKey=\"size\"\n fill=\"var(--chart-1)\"\n isAnimationActive={false}\n content={<TreemapCell onNodeClick={onNodeClick} redactNames={redactNames} nodeNote={nodeNote} dir={dir} />}\n >\n <ChartTooltip content={<Content hideLabel valueFormatter={format} />} />\n </RechartsTreemap>\n </ChartContainer>\n );\n}\n"],"mappings":";AAwSI,SAiBE,KAjBF;AA9RJ,SAAS,iBAAiB,QAAQ,UAAU,4BAA4B;AAExE,SAAS,WAAW,uBAAuB;AAC3C,SAAS,gBAAgB,cAAc,2BAA6C;AACpF,SAAS,aAAa,cAAc,cAAc;AAClD,SAAS,mBAAmB,oBAAoB;AAChD,SAAS,gBAAgB;AACzB,SAAS,aAA6B;AACtC,SAAS,UAAU;AACnB,SAAS,mBAAmB;AAC5B,SAAS,wBAA0C;AACnD,SAAS,uBAAuB;AAyGhC,MAAM,eAAe;AAKrB,SAAS,kBAA0B;AACjC,QAAM,OAAO,SAAS;AACtB,QAAM,WAAW,KAAK,UAAU,SAAS,MAAM,IAAI,YAAY,OAAO,YAAY;AAClF,QAAM,QAAQ,iBAAiB,IAAI;AACnC,SAAO,MAAM,KAAK,EAAE,QAAQ,aAAa,GAAG,CAAC,GAAG,MAAM;AACpD,UAAM,IAAI,MAAM,iBAAiB,WAAW,IAAI,CAAC,EAAE,EAAE,KAAK;AAG1D,WAAO,SAAS,CAAC,IAAI,IAAI,SAAS,CAAC;AAAA,EACrC,CAAC,EAAE,KAAK,GAAG;AACb;AAEA,SAAS,qBAAqB,UAAkC;AAC9D,QAAM,WAAW,IAAI,iBAAiB,QAAQ;AAC9C,WAAS,QAAQ,SAAS,iBAAiB;AAAA,IACzC,YAAY;AAAA,IACZ,iBAAiB,CAAC,SAAS,SAAS,YAAY;AAAA,EAClD,CAAC;AACD,SAAO,MAAM,SAAS,WAAW;AACnC;AAEA,MAAM,gBAAgB,YAAY,MAAM,KAAK,GAAG;AAEhD,SAAS,mBAA6B;AACpC,QAAM,SAAS,qBAAqB,sBAAsB,iBAAiB,MAAM,aAAa;AAC9F,SAAO,OAAO,MAAM,GAAG;AACzB;AAEA,MAAM,kBAAkB;AAQxB,SAAS,eAAe,MAAc,QAA0C;AAC9E,MAAI,SAAS,IAAI,EAAG,QAAO;AAC3B,QAAM,IAAI,gBAAgB,KAAK,KAAK,KAAK,CAAC;AAC1C,QAAM,MAAM,IAAI,OAAO,OAAO,EAAE,CAAC,CAAC,IAAI,CAAC,IAAI;AAC3C,MAAI,OAAO,SAAS,GAAG,EAAG,QAAO;AAGjC,MAAI,OAAO,aAAa,YAAa,QAAO;AAC5C,SAAO,kBAAkB,MAAM,iBAAiB,SAAS,eAAe,CAAC;AAC3E;AAMA,MAAM,kBAAkB;AAExB,MAAM,iBAAiB;AAShB,SAAS,SAAS,MAAc,UAAkB,UAAiC;AACxF,QAAM,MAAM,KAAK,OAAO,WAAW,OAAO,WAAW,KAAK;AAC1D,MAAI,MAAM,EAAG,QAAO;AACpB,SAAO,KAAK,UAAU,MAAM,OAAO,GAAG,KAAK,MAAM,GAAG,MAAM,CAAC,EAAE,QAAQ,CAAC;AACxE;AAoCO,SAAS,YAAY;AAAA,EAC1B;AAAA,EACA,IAAI;AAAA,EACJ,IAAI;AAAA,EACJ,QAAQ;AAAA,EACR,SAAS;AAAA,EACT;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,MAAM;AACR,GAAqB;AAEnB,QAAM,WAAW,iBAAiB;AAClC,QAAM,EAAE,MAAM,IAAI,YAAY;AAC9B,QAAM,UAAU,aAAa;AAE7B,MAAI,CAAC,MAAO,QAAO;AAGnB,QAAM,KAAK,KAAK,MAAM,CAAC;AACvB,QAAM,KAAK,KAAK,MAAM,CAAC;AACvB,QAAM,KAAK,KAAK,MAAM,KAAK;AAC3B,QAAM,KAAK,KAAK,MAAM,MAAM;AAC5B,QAAM,WAAW,QAAQ;AACzB,QAAM,WAAW,eAAe,UAAU,QAAQ;AAClD,QAAM,MAAM,eAAe,WAAW,OAAO,QAAQ,IAAI;AAGzD,QAAM,OAAO,WAAW,QAAQ,QAAQ,IAAI;AAK5C,QAAM,OAAO,WAAW,WAAW;AAGnC,QAAM,YAAY,kBAAkB;AACpC,QAAM,WAAW,iBAAiB;AAClC,QAAM,QAAQ,MAAM,KAAK,SAAS,MAAM,KAAK,QAAQ,SAAS,QAAQ,IAAI,IAAI,SAAS,IAAI;AAK3F,QAAM,UAAU,MAAM,QAAQ,WAAW,SAAS,EAAE,IAAI;AACxD,QAAM,OAAO,SAAS,QAAQ,WAAW,QAAQ,MAAM,KAAK,QAAQ,SAAS,SAAS,IAAI,QAAQ,IAAI;AAKtG,QAAM,QAAQ,QAAQ,QAAQ,KAAK,KAAK,IAAI,KAAK;AACjD,QAAM,WAAW,MAAM,QAAQ,cAAc,MAAM,YAAY,IAAI,QAAQ,EAAE,IAAI;AACjF,QAAM,YAAY,WACd,CAAC,MAAkC;AACjC,QAAI,EAAE,QAAQ,WAAW,EAAE,QAAQ,IAAK;AACxC,MAAE,eAAe;AACjB,aAAS;AAAA,EACX,IACA;AACJ,SACE;AAAA,IAAC;AAAA;AAAA,MACC,SAAS;AAAA,MACT;AAAA,MAIA,MAAM,WAAW,WAAW;AAAA,MAC5B,UAAU,WAAW,IAAI;AAAA,MACzB,cAAY,WAAW,OAAO;AAAA,MAC9B,gBAAc,YAAY,cAAc,KAAK;AAAA,MAC7C,QAAQ,WAAW,YAAY;AAAA,MAG/B,WAAW,WAAW,8DAA8D;AAAA,MAIpF;AAAA,4BAAC,UAAK,GAAG,IAAI,GAAG,IAAI,OAAO,IAAI,QAAQ,IAAI,MAAM,UAAU,QAAO,qBAAoB,aAAa,GAAG,IAAI,GAAG;AAAA,QAC5G,QAAQ,KAAK,KAAK,KAAK,KACtB;AAAA,UAAC;AAAA;AAAA,YACC,aAAU;AAAA,YACV,GAAG,KAAK;AAAA,YACR,GAAG,KAAK;AAAA,YACR,OAAO,KAAK;AAAA,YACZ,QAAQ,KAAK;AAAA,YACb,MAAK;AAAA,YACL,QAAQ;AAAA,YACR,aAAa;AAAA,YACb,IAAI;AAAA,YACJ,OAAO,EAAE,eAAe,OAAO;AAAA;AAAA,QACjC;AAAA,QAED,SAAS,QACR;AAAA,UAAC;AAAA;AAAA,YAGC,gBAAc,cAAc,KAAK;AAAA,YACjC,GAAG;AAAA,YACH,GAAG,KAAK,KAAK;AAAA,YACb,WAAW;AAAA,YACX,MAAM;AAAA,YACN,QAAQ;AAAA,YACR,aAAa;AAAA,YACb,gBAAe;AAAA,YACf,YAAW;AAAA,YACX,UAAU;AAAA,YACV,YAAY;AAAA,YACZ,OAAO,EAAE,eAAe,OAAO;AAAA,YAE9B;AAAA;AAAA,QACH;AAAA,QAED,QAAQ,QACP;AAAA,UAAC;AAAA;AAAA,YACC,GAAG;AAAA,YACH,GAAG,KAAK,KAAK;AAAA,YACb,WAAW;AAAA,YACX,MAAM;AAAA,YACN,aAAa;AAAA,YACb,QAAQ;AAAA,YACR,aAAa;AAAA,YACb,gBAAe;AAAA,YACf,YAAW;AAAA,YACX,UAAU;AAAA,YACV,YAAY;AAAA,YACZ,OAAO,EAAE,eAAe,OAAO;AAAA,YAE9B;AAAA;AAAA,QACH;AAAA;AAAA;AAAA,EAEJ;AAEJ;AAMA,MAAM,iBAA8B,CAAC;AAgBrC,SAAS,uBAAuB,OAAmD;AACjF,SACE,oBAAC,SAAI,gBAAY,MACf,8BAAC,uBAAqB,GAAG,OAAO,GAClC;AAEJ;AAEA,MAAM,iBAAiB,IAAI,KAAK,aAAa;AAatC,SAAS,QAAQ;AAAA,EACtB;AAAA,EACA;AAAA,EACA,SAAS;AAAA,EACT;AAAA,EACA,cAAc;AAAA,EACd;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,GAAG;AACL,GAAiB;AACf,QAAM,WAAW,iBAAiB;AAIlC,QAAM,UAAU,OAAuB,IAAI;AAC3C,QAAM,CAAC,KAAK,MAAM,IAAI,SAAoB,KAAK;AAC/C,kBAAgB,MAAM;AACpB,WAAO,MAAM,QAAQ,OAAO,CAAC;AAAA,EAC/B,GAAG,CAAC,IAAI,CAAC;AACT,QAAM,OAAO,UAAU,OAAO,SAAS,IAAI,SAAS;AAIpD,QAAM,QAAQ,KACX,IAAI,CAAC,GAAG,SAAS;AAAA,IAChB,IAAI,EAAE;AAAA,IACN,MAAM,EAAE;AAAA,IACR,MAAM,EAAE;AAAA,IACR,MAAM,EAAE,QAAQ,KAAK,MAAM,KAAK,MAAM;AAAA;AAAA;AAAA,IAGtC,YAAY,EAAE;AAAA,EAChB,EAAE,EACD,OAAO,CAAC,MAAM,OAAO,SAAS,EAAE,IAAI,KAAK,EAAE,OAAO,CAAC,EACnD,MAAM,GAAG,YAAY,MAAS;AACjC,MAAI,MAAM,WAAW,EAAG,QAAO;AAC/B,QAAM,MAAM,iBAAiB,MAAM;AAGnC,QAAM,QAAQ,MAAM,OAAO,CAAC,KAAK,MAAM,MAAM,EAAE,MAAM,CAAC;AACtD,QAAM,SAAS,CAAC,MAAyB;AACvC,UAAM,QAAQ,QAAQ,IAAI,IAAI,QAAQ;AACtC,WAAO,iBAAiB,eAAe,GAAG,KAAK,IAAI,gBAAgB,gBAAgB,CAAC;AAAA,EACtF;AACA,QAAM,UAAU,cAAc,yBAAyB;AACvD,SACE;AAAA,IAAC;AAAA;AAAA,MACE,GAAG;AAAA,MACJ,KAAK;AAAA,MACL,QAAQ;AAAA,MACR,WAAW,GAAG,IAAI,WAAW,KAAK,SAAS;AAAA,MAC3C,OAAO,EAAE,GAAG,OAAO,GAAG,IAAI,MAAM;AAAA,MAIhC;AAAA,QAAC;AAAA;AAAA,UACC,MAAM;AAAA,UACN,SAAQ;AAAA,UACR,MAAK;AAAA,UACL,mBAAmB;AAAA,UACnB,SAAS,oBAAC,eAAY,aAA0B,aAA0B,UAAoB,KAAU;AAAA,UAExG,8BAAC,gBAAa,SAAS,oBAAC,WAAQ,WAAS,MAAC,gBAAgB,QAAQ,GAAI;AAAA;AAAA,MACxE;AAAA;AAAA,EACF;AAEJ;","names":[]}
1
+ {"version":3,"sources":["../../src/components/treemap.tsx"],"sourcesContent":["// A tile chart (\"treemap\"): one rectangle per node, its AREA the node's share of what\n// is drawn. Lifted out of keksdose's reports, where five cards drew it (spending by\n// group and by category, net worth by account and by type, allocation, payees, food\n// groups) — every domain word is gone and every string it could print is a prop.\n//\n// It uses the kit's own chart shell (`ChartContainer`, `ChartTooltipContent`) and the\n// kit's own ink rule (`textOn`), so it re-skins with the palette like every other\n// chart. The one thing it cannot take from a CSS var is the tile colour: the label ink\n// is MEASURED against the fill, and a `var(--chart-3)` string measures as nothing — see\n// `useChartTokenHex` below for how the concrete hex is found instead.\nimport { useLayoutEffect, useRef, useState, useSyncExternalStore } from \"react\";\nimport type { ComponentProps, ComponentPropsWithoutRef, KeyboardEvent, ReactNode } from \"react\";\nimport { Treemap as RechartsTreemap } from \"recharts\";\nimport { ChartContainer, ChartTooltip, ChartTooltipContent, type ChartConfig } from \"./chart\";\nimport { PALETTE_HEX, PALETTE_SIZE, textOn } from \"../theme/chart-palette\";\nimport { resolveChartColor, useChartEdge } from \"./chart-edge\";\nimport { parseHex } from \"../theme/color\";\nimport { dirOf, type Direction } from \"../lib/direction\";\nimport { cn } from \"../lib/cn\";\nimport { useTextSize } from \"../theme/text-size\";\nimport { chartHeightProps, type ChartHeight } from \"./chart-height\";\nimport { formatWithMinus } from \"../lib/minus\";\n\n/**\n * One tile.\n *\n * `value`, not keksdose's `total`: a treemap's areas are not always money (keksdose\n * already sizes one by transaction COUNT), and `value` is the word every other chart\n * in recharts uses for the number a mark is sized by. Migrating is a rename at the\n * mapping step — `{ id, name, value: row.total }`.\n */\nexport interface TreemapNode {\n /** Stable key, handed back by {@link TreemapProps.onNodeClick} and\n * {@link TreemapProps.nodeNote}. Names can collide; ids must not. */\n id: string;\n name: string;\n /** The tile's area. A node whose value is not a positive finite number is NOT\n * drawn — an area cannot be negative, and recharts lays a negative size out as\n * nonsense rather than refusing it. Saying so is the caller's job: a group that\n * vanishes silently between two views of the same data is a bug report. */\n value: number;\n /** Overrides the by-index palette colour for this tile — e.g. to paint every child\n * in its parent group's colour, so the groups stay legible in one picture. A\n * concrete `#rrggbb` or a bare `var(--chart-N)` keeps the label ink measured — so\n * does `CHART_ROLES.other` (`var(--chart-muted)`), the colour for a \"rest\" bucket,\n * which should not wrap round to a hue the reader matches to a real node;\n * anything else (another var, a `color-mix()`) gets `currentColor` ink, because\n * there is nothing to measure — set `labelColor` then. */\n fill?: string;\n /** The label's ink, for a `fill` the kit cannot measure (a `var()` or `color-mix()`):\n * keksdose's recency plot fills with `color-mix(in srgb, var(--chart-1) N%,\n * transparent)`, where `currentColor` put dark page text on full-strength indigo\n * (~1.5:1). Wins over the measured ink when given. */\n labelColor?: string;\n}\n\n/**\n * Everything a `<div>` takes reaches the chart's root (`ChartContainer`), so a test id\n * or a `data-tour` anchor can find it. `children` is the chart's own.\n */\nexport interface TreemapProps extends Omit<ComponentPropsWithoutRef<\"div\">, \"children\"> {\n data: TreemapNode[];\n /**\n * Formats a node's value in the tooltip. Handed the node's SHARE of what is drawn\n * (0–1) as a second argument, because a tile is read for how big it is relative to\n * the rest, and \"€412.30\" alone makes the reader estimate that ratio off the areas.\n * The share is `undefined` when the drawn total is not positive.\n *\n * Default: the bare number through `Intl.NumberFormat` in the runtime's locale. A\n * host that has a currency or a locale passes this — the kit knows neither.\n */\n valueFormatter?: (value: number, share: number | undefined) => ReactNode;\n /** Chart height: a CSS length (`\"20rem\"`, `\"min(20rem, 50dvh)\"`), a Tailwind class,\n * or px. Default `\"20rem\"` — 320 px at Normal, growing with the text size like the\n * labels in the tiles (0.32, docs/text-size-harmonization.md §10.10). */\n height?: ChartHeight;\n /** A tile was activated — by click, or by Enter/Space on a focused tile. Setting\n * this makes every tile a focusable `role=\"button\"` named by its node name. */\n onNodeClick?: (id: string, name: string) => void;\n /**\n * The node names are the user's own data (payees, not categories): the tile labels\n * and the whole tooltip bubble get `data-private`, the same attribute the kit's\n * `Tooltip redact` and `ChartTooltipContent`'s value cell carry, so the host's one\n * privacy rule reaches them. Off by default — a category name is a label of the app.\n */\n redactNames?: boolean;\n /**\n * A second, smaller line under a tile's label — one extra figure per node that the\n * name cannot carry (a year-on-year `+12%`, `new`). Return `undefined` for a node\n * with nothing to add. Never tagged `data-private`: a ratio reveals no amount.\n *\n * ON THE TILE rather than in the tooltip, for two reasons. The tooltip identifies a\n * node by name or by raw value, so a per-node note would be keyed on an amount (two\n * nodes of equal size would swap notes). And a tooltip is a pointer affordance: a\n * phone never hovers, so a figure that only exists on hover does not exist there.\n */\n nodeNote?: (id: string) => string | undefined;\n /**\n * Draw at most this many tiles (the first N drawable nodes, in the order given —\n * sort first). Unbounded by default. A tile under ~44x20px has no room for a label,\n * and a map made of unlabelled rectangles answers nothing; around 24 is where the\n * smallest tile of a skewed series reaches that floor on a phone-width card. The\n * caller owns the \"N more not shown\" line, since it knows how many it passed.\n */\n maxTiles?: number;\n /**\n * The categorical ramp tiles take by index, as concrete hex. Default: the page's\n * live `--chart-1…8` tokens (see `useChartTokenHex`), cycled after eight like\n * `paletteFor` (0.49). Pass one when the host\n * already holds the active palette — e.g. `createPaletteStore`'s `useChartHex()` —\n * so a caller colouring a legend beside the map is guaranteed the same list.\n */\n colors?: readonly string[];\n /**\n * How tiles are parted (0.51.6, keksdose dev #642, Marcel: \"The borders do not look\n * nice when changing from tile to tile. Can this be uniform black?\" — and then, on the\n * contrast rule that would go with it: follow the contrast setting).\n *\n * - `\"auto\"` (default): `\"uniform\"` at Standard contrast, `\"contrast\"` while More\n * contrast is in force (`<html data-contrast=\"more\">`: the reader chose it, or chose\n * System on a device that asks for more). Followed live.\n * - `\"uniform\"`: one 2px gutter in the page's ground colour (`--bg-page`, near-black\n * in the dark theme) round every tile, and nothing else. Tiles always abut, so the\n * gutter parts them; each tile is named by its label.\n * - `\"contrast\"`: the 0.49 rule. The gutter is the card's colour (`--bg-surface`),\n * and a tile whose hue is under 3:1 against the card gets a 1px ink edge inside it\n * (WCAG 1.4.11, `useChartEdge`). In the dark theme that ink is light, so some tiles\n * carry a white outline and their neighbours none — the unevenness #642 saw, and\n * the price of the edge for a reader who asked for it.\n */\n edges?: TreemapEdges;\n}\n\n/** {@link TreemapProps.edges}. */\nexport type TreemapEdges = \"auto\" | \"uniform\" | \"contrast\";\n\n// `<html data-contrast>`, followed live: what `edges=\"auto\"` keys on. Read here rather\n// than through theme/contrast, whose store would come into the chart entry with it.\nfunction subscribeContrast(onChange: () => void): () => void {\n if (typeof MutationObserver === \"undefined\" || typeof document === \"undefined\") return () => {};\n const observer = new MutationObserver(onChange);\n observer.observe(document.documentElement, { attributes: true, attributeFilter: [\"data-contrast\"] });\n return () => observer.disconnect();\n}\nconst readMoreContrast = () =>\n typeof document !== \"undefined\" && document.documentElement.getAttribute(\"data-contrast\") === \"more\";\n\n/** The edges a tile draws: `auto` resolved against the contrast in force. */\nfunction useTileEdges(edges: TreemapEdges): \"uniform\" | \"contrast\" {\n const more = useSyncExternalStore(subscribeContrast, readMoreContrast, () => false);\n return edges === \"auto\" ? (more ? \"contrast\" : \"uniform\") : edges;\n}\n\n// ── The categorical ramp, as concrete hex ──────────────────────────────────\n//\n// The label ink is measured against the tile's fill, so the fill has to be a colour\n// and not a reference to one. keksdose read it from its palette STORE, which is an\n// app object the kit cannot import. What the kit does own is the token layer: whether\n// a palette arrives through `applyTokenSet` (inline properties on <html>) or through\n// tokens.css (`:root` / `.dark`), the resolved `--chart-N` on <html> is the colour\n// every other chart on the page is being painted with. So that is what is read, and\n// re-read whenever <html>'s class or style changes — which is exactly when a theme\n// toggle or a palette switch lands.\n\nconst CHART_TOKENS = PALETTE_SIZE;\n\n/** The resolved ramp, joined into ONE string so `useSyncExternalStore` compares a\n * primitive: a fresh array per read would never be `Object.is`-equal to the last and\n * would re-render forever. */\nfunction readChartTokens(): string {\n const root = document.documentElement;\n const fallback = root.classList.contains(\"dark\") ? PALETTE_HEX.dark : PALETTE_HEX.light;\n const style = getComputedStyle(root);\n return Array.from({ length: CHART_TOKENS }, (_, i) => {\n const v = style.getPropertyValue(`--chart-${i + 1}`).trim();\n // An unresolved token (a host that never loaded tokens.css, a test DOM) falls\n // back to the built-in ramp for the current theme rather than to no colour.\n return parseHex(v) ? v : fallback[i];\n }).join(\",\");\n}\n\nfunction subscribeChartTokens(onChange: () => void): () => void {\n const observer = new MutationObserver(onChange);\n observer.observe(document.documentElement, {\n attributes: true,\n attributeFilter: [\"class\", \"style\", \"data-theme\"],\n });\n return () => observer.disconnect();\n}\n\nconst SERVER_TOKENS = PALETTE_HEX.light.join(\",\");\n\nfunction useChartTokenHex(): string[] {\n const joined = useSyncExternalStore(subscribeChartTokens, readChartTokens, () => SERVER_TOKENS);\n return joined.split(\",\");\n}\n\nconst CHART_TOKEN_REF = /^var\\(\\s*--chart-(\\d+)\\s*\\)$/;\n\n/**\n * The hex a tile fill measures as: the fill itself when it is hex, the live token when\n * it is a bare `var(--chart-N)` (what `paletteFor` returns), otherwise null. Without\n * the token case a hand-built recharts map coloured with the kit's own `paletteFor`\n * got `currentColor` ink — dark page text on dark indigo in the light theme.\n */\nfunction measurableFill(fill: string, tokens: readonly string[]): string | null {\n if (parseHex(fill)) return fill;\n const m = CHART_TOKEN_REF.exec(fill.trim());\n const hex = m ? tokens[Number(m[1]) - 1] : undefined;\n if (hex && parseHex(hex)) return hex;\n // Any other bare token the page declares — `var(--chart-muted)` for a rest bucket,\n // `var(--chart-9)`, a money colour — read off <html> as it resolves.\n if (typeof document === \"undefined\") return null;\n return resolveChartColor(fill, getComputedStyle(document.documentElement));\n}\n\n// ── Tile ──────────────────────────────────────────────────────────────────\n\n/** The name's size in px at Normal; a tile draws it × the text size's scale (§10.10), and\n * every distance below that is measured in lines grows with it. */\nconst LABEL_FONT_SIZE = 12;\n/** The optional second line, a step down from the name so the tile reads name-first. */\nconst NOTE_FONT_SIZE = 11;\n\n/**\n * Fit a label to its tile: the text itself when it fits, an ellipsised head when it\n * does not, `null` when not even three characters would. Without this a long name ran\n * on across its neighbours and collided with THEIR labels. 0.58em is the average\n * advance of a UI sans at this weight; it only needs to be close, since the tile keeps\n * 6px of padding either side. The full name stays in the tooltip.\n */\nexport function fitLabel(name: string, boxWidth: number, fontSize: number): string | null {\n const max = Math.floor((boxWidth - 12) / (fontSize * 0.58));\n if (max < 3) return null;\n return name.length <= max ? name : `${name.slice(0, max - 1).trimEnd()}…`;\n}\n\n/** What recharts hands a `content` element for each node, plus the three options the\n * chart forwards. Everything optional because recharts clones the element it was\n * given and fills the geometry in — the element as written carries only the options. */\nexport interface TreemapCellProps {\n depth?: number;\n x?: number;\n y?: number;\n width?: number;\n height?: number;\n name?: string;\n fill?: string;\n /** See {@link TreemapNode.labelColor}; recharts hands each node's fields to its cell. */\n labelColor?: string;\n id?: string;\n onNodeClick?: (id: string, name: string) => void;\n redactNames?: boolean;\n nodeNote?: (id: string) => string | undefined;\n /** The page's reading direction, which `Treemap` reads off its own root. In `rtl`\n * the label stands in the tile's top RIGHT corner and is shaped right-to-left, so a\n * clipped name ends in its ellipsis on the reading end. Default `ltr`. */\n dir?: Direction;\n /** See {@link TreemapProps.edges}. Default `auto`. */\n edges?: TreemapEdges;\n}\n\n/**\n * One tile. Exported so a caller drawing its own recharts `<Treemap>` gets the same\n * label rules.\n *\n * recharts' default cell inherits the chart-level `stroke`, an SVG presentation\n * attribute that lands on the `<svg>` root and cascades into the label — so the glyphs\n * were painted with a 1px outline ON TOP of themselves, which reads as blur. This cell\n * draws its own label with a halo in the TILE colour painted BEHIND the fill\n * (`paint-order: stroke`), which defeats any inherited stroke and keeps the text solid\n * where it overlaps a lighter or darker part of its own tile.\n */\nexport function TreemapCell({\n depth,\n x = 0,\n y = 0,\n width = 0,\n height = 0,\n name,\n fill,\n labelColor,\n id,\n onNodeClick,\n redactNames,\n nodeNote,\n dir = \"ltr\",\n edges: edgesProp = \"auto\",\n}: TreemapCellProps) {\n // Before the early return: a hook has to run on every render of the cell.\n const tokenHex = useChartTokenHex();\n const { scale } = useTextSize();\n const edgeFor = useChartEdge();\n const edges = useTileEdges(edgesProp);\n // Depth 0 is recharts' synthetic root, which spans the whole chart.\n if (!depth) return null;\n // Whole pixels: a rect on a half pixel renders its edge across two half-lit\n // columns, and the text baseline inherits that blur.\n const px = Math.round(x);\n const py = Math.round(y);\n const pw = Math.round(width);\n const ph = Math.round(height);\n const tileFill = fill ?? \"var(--chart-1)\";\n const measured = measurableFill(tileFill, tokenHex);\n const ink = labelColor ?? (measured ? textOn(measured) : \"currentColor\");\n // `contrast`: a tile in a hue under 3:1 against the card gets a 1px ink edge inside\n // the gutter (WCAG 1.4.11, anyplot's rule); a hue that clears it stays bare.\n // `uniform`: no per-tile edge; every tile is parted by the same gutter.\n const edge = edges === \"contrast\" && measured ? edgeFor(measured) : undefined;\n // The halo only on an OPAQUE fill we could measure. A translucent fill (a\n // `color-mix(… transparent)`) composited twice — once as the tile, once as the halo\n // over it — draws a darker ring round every glyph, which read as doubled text on the\n // faded tiles. `none` still overrides the inherited chart `stroke`.\n const halo = measured ? tileFill : \"none\";\n // Below ~44x20 there is no room for a readable word, so no label at all rather than\n // a one-letter stub bleeding over the tile edge.\n const labelSize = LABEL_FONT_SIZE * scale;\n const noteSize = NOTE_FONT_SIZE * scale;\n const label = pw >= 44 * scale && ph >= 20 * scale ? fitLabel(name ?? \"\", pw, labelSize) : null;\n // Only under a label that is itself drawn — a stray \"+12%\" on an unnamed rectangle\n // names nothing — and only where a second baseline fits: the note's baseline sits\n // 29px down and an 11px glyph hangs ~2px below it, so 36 leaves the same ~5px under\n // the note that the label keeps under itself at the 20px floor.\n const rawNote = id != null && nodeNote ? nodeNote(id) : undefined;\n const note = label != null && rawNote != null && ph >= 36 * scale ? fitLabel(rawNote, pw, noteSize) : null;\n // The label's START edge, 6px in from the tile's. The tiles themselves are laid out\n // physically (the plot is not mirrored, see `ChartContainer`) and the chart's SVG is\n // pinned `ltr`, so the direction is set on each <text> explicitly: `start` is then\n // the right end in RTL, and the label grows leftwards from the tile's right edge.\n const textX = dir === \"rtl\" ? px + pw - 6 : px + 6;\n const activate = id != null && onNodeClick ? () => onNodeClick(id, name ?? \"\") : undefined;\n const onKeyDown = activate\n ? (e: KeyboardEvent<SVGGElement>) => {\n if (e.key !== \"Enter\" && e.key !== \" \") return;\n e.preventDefault();\n activate();\n }\n : undefined;\n return (\n <g\n onClick={activate}\n onKeyDown={onKeyDown}\n // A clickable tile is a button to a keyboard and a screen reader too; the\n // drilldown it opens was mouse-only in the app this came from. Named by the node\n // name, which is also why the group carries `data-private` under `redactNames`.\n role={activate ? \"button\" : undefined}\n tabIndex={activate ? 0 : undefined}\n aria-label={activate ? name : undefined}\n data-private={activate && redactNames ? \"\" : undefined}\n cursor={activate ? \"pointer\" : undefined}\n // CSS beats a presentation attribute, so this recolours the rect's own stroke\n // for as long as the tile has keyboard focus.\n className={activate ? \"outline-none [&:focus-visible>rect]:stroke-[var(--brand)]\" : undefined}\n >\n {/* The 2px gutter: the page's ground (`uniform`, the same dark line round every\n tile in the dark theme) or the card showing through (`contrast`). Never a white\n grid: on a dark surface white lines glare harder than the tiles. */}\n <rect\n x={px}\n y={py}\n width={pw}\n height={ph}\n fill={tileFill}\n stroke={edges === \"contrast\" ? \"var(--bg-surface)\" : \"var(--bg-page)\"}\n strokeWidth={2}\n rx={3}\n />\n {edge && pw > 3 && ph > 3 && (\n <rect\n data-edge=\"\"\n x={px + 1.5}\n y={py + 1.5}\n width={pw - 3}\n height={ph - 3}\n fill=\"none\"\n stroke={edge}\n strokeWidth={1}\n rx={2}\n style={{ pointerEvents: \"none\" }}\n />\n )}\n {label != null && (\n <text\n // A plain attribute, so the host's `[data-private]` rule reaches an SVG\n // <text> exactly as it reaches a table cell.\n data-private={redactNames ? \"\" : undefined}\n x={textX}\n y={py + 15 * scale}\n direction={dir}\n fill={ink}\n stroke={halo}\n strokeWidth={2.5}\n strokeLinejoin=\"round\"\n paintOrder=\"stroke\"\n fontSize={labelSize}\n fontWeight={600}\n style={{ pointerEvents: \"none\" }}\n >\n {label}\n </text>\n )}\n {note != null && (\n <text\n x={textX}\n y={py + 29 * scale}\n direction={dir}\n fill={ink}\n fillOpacity={0.85}\n stroke={halo}\n strokeWidth={2.5}\n strokeLinejoin=\"round\"\n paintOrder=\"stroke\"\n fontSize={noteSize}\n fontWeight={500}\n style={{ pointerEvents: \"none\" }}\n >\n {note}\n </text>\n )}\n </g>\n );\n}\n\n// ── Chart ─────────────────────────────────────────────────────────────────\n\n// Nodes carry their own `fill`, and `ChartTooltipContent` falls back to it for the\n// swatch, so no series config is needed.\nconst TREEMAP_CONFIG: ChartConfig = {};\n\n/**\n * The shared tooltip with the whole bubble tagged `data-private`, for names that are\n * the user's own data.\n *\n * The bubble, not just the name: `ChartTooltipContent` prints the name either from\n * `item.name` or from a config `label`, and only the config route could be tagged — but\n * a config entry keyed by a payee name is not a CSS identifier, and `ChartStyle` would\n * (rightly) refuse it. The bubble holds the name and the value, and the value cell is\n * already `data-private`, so tagging the wrapper hides nothing extra.\n *\n * recharts CLONES the `content` element to inject `active`/`payload`, so this has to BE\n * that element — a `<div>` wrapped around `<ChartTooltipContent />` in the JSX would\n * take those props itself and hand the tooltip nothing.\n */\nfunction RedactedTooltipContent(props: ComponentProps<typeof ChartTooltipContent>) {\n return (\n <div data-private>\n <ChartTooltipContent {...props} />\n </div>\n );\n}\n\nconst DEFAULT_NUMBER = new Intl.NumberFormat();\n\n/**\n * Tile chart: one rectangle per {@link TreemapNode}, sized by `value`, coloured by\n * index from the chart ramp (or by the node's own `fill`), labelled in whichever of the\n * kit's two inks measures better against it. Hover shows the name and the formatted\n * value; `onNodeClick` turns the tiles into buttons.\n *\n * Renders nothing when no node has a positive value — an empty box is a hole in the\n * card, and the caller's own sentence is the better answer there.\n *\n * Needs `recharts`, like everything behind `@eifi1/ui-kit/chart`.\n */\nexport function Treemap({\n data,\n valueFormatter,\n height = \"20rem\",\n onNodeClick,\n redactNames = false,\n nodeNote,\n maxTiles,\n colors,\n edges = \"auto\",\n style,\n ...rest\n}: TreemapProps) {\n const tokenHex = useChartTokenHex();\n // Read off the DOM after mount: the `dir` a treemap sits under is an ancestor's\n // attribute, and SVG text anchoring cannot follow it by CSS (see TreemapCell). Again\n // on new data, because a map that had nothing to draw rendered no root to read.\n const rootRef = useRef<HTMLDivElement>(null);\n const [dir, setDir] = useState<Direction>(\"ltr\");\n useLayoutEffect(() => {\n setDir(dirOf(rootRef.current));\n }, [data]);\n const ramp = colors && colors.length > 0 ? colors : tokenHex;\n // Coloured by index into `data` AS GIVEN, before anything is dropped: a caller\n // painting a legend or a second map from the same list must land on the same hue\n // for the same row, and filtering first would shift every colour after a gap.\n const nodes = data\n .map((d, idx) => ({\n id: d.id,\n name: d.name,\n size: d.value,\n fill: d.fill ?? ramp[idx % ramp.length],\n // recharts hands each node's fields to its cell; a field left out of this map\n // never reaches `TreemapCell` (0.8.0 shipped `labelColor` without it — keksdose).\n labelColor: d.labelColor,\n }))\n .filter((n) => Number.isFinite(n.size) && n.size > 0)\n .slice(0, maxTiles ?? undefined);\n if (nodes.length === 0) return null;\n const box = chartHeightProps(height);\n\n // The share is of what is DRAWN, so it always reads as \"of this picture\".\n const total = nodes.reduce((acc, n) => acc + n.size, 0);\n const format = (v: number): ReactNode => {\n const share = total > 0 ? v / total : undefined;\n return valueFormatter ? valueFormatter(v, share) : formatWithMinus(DEFAULT_NUMBER, v);\n };\n const Content = redactNames ? RedactedTooltipContent : ChartTooltipContent;\n return (\n <ChartContainer\n {...rest}\n ref={rootRef}\n config={TREEMAP_CONFIG}\n className={cn(box.className, rest.className)}\n style={{ ...style, ...box.style }}\n >\n {/* No chart-level `stroke`: recharts spreads it onto the <svg> root, from where\n it inherits into every label (see TreemapCell). Each cell strokes its own rect. */}\n <RechartsTreemap\n data={nodes}\n dataKey=\"size\"\n fill=\"var(--chart-1)\"\n isAnimationActive={false}\n content={\n <TreemapCell\n onNodeClick={onNodeClick}\n redactNames={redactNames}\n nodeNote={nodeNote}\n dir={dir}\n edges={edges}\n />\n }\n >\n <ChartTooltip content={<Content hideLabel valueFormatter={format} />} />\n </RechartsTreemap>\n </ChartContainer>\n );\n}\n"],"mappings":"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","names":[]}
@@ -47,7 +47,7 @@ const roleUtilities = {
47
47
  };
48
48
  }
49
49
  };
50
- const VERSION = true ? "0.51.5" : "0.0.0-source";
50
+ const VERSION = true ? "0.51.7" : "0.0.0-source";
51
51
  const recommended = {
52
52
  name: "@eifi1/ui-kit/recommended",
53
53
  // Not the tests, as check:tokens skips the kit's: a test names the old class to assert it
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/hooks/use-dismiss.ts"],"sourcesContent":["import { useCallback, useEffect, useRef, useSyncExternalStore } from \"react\";\nimport type { KeyboardEvent as ReactKeyboardEvent, RefObject } from \"react\";\nimport { isComposing } from \"../lib/ime\";\n\n/*\n * # One Escape stack (round 0.34 Wave 2, DUP-1)\n *\n * Escape closes the TOPMOST thing on screen, and only that: a list open in a dialog\n * closes and the dialog stays; the next press closes the dialog.\n *\n * \"Who owns Escape\" used to be decided five ways. The anchored pickers listened on the\n * document; Modal, FullBleedDialog, PickerSheet and Popover closed in their own panel's\n * `onKeyDown` and stopped the key; CommandPalette closed without stopping it. A dialog's\n * handler sits earlier on the key's path than any document listener, so Escape in an\n * open list inside a Modal closed the MODAL and threw the form away (BUG-1, sev 4,\n * kastlan's lease dialog), and HoverMenu, the numpad and a tooltip could not be\n * dismissed inside a dialog at all. And Modal and FullBleedDialog disagreed on when a\n * child had \"claimed\" the key: a ChipInput clearing its draft kept a Modal open and\n * closed a FullBleedDialog.\n *\n * Now every overlay and picker pushes a layer while it is open ({@link useDismissLayer}),\n * last in, first out, the way `use-overlay-history` stacks the Back gesture. One\n * capture-phase listener on the document notes which layer is on top as each Escape\n * goes down — before any handler on the page runs, so a layer that closes on the way\n * cannot pass the key on to the one beneath it. That layer, and only that one, is\n * handed the key:\n *\n * - at the first layer element the key passes on its way back up ({@link takeEscape},\n * which Modal's panel, a picker's search box and the rest call from their\n * `onKeyDown`). The key is stopped there, so nothing around the layers — a\n * FloatingPanel, an app's own panel — closes with it;\n * - or, when focus is in none of them (a hover menu, a tooltip, the numpad over its\n * field, a press on the page), at the document once the key has been everywhere else.\n *\n * Layers stack in the order they OPEN, which is the order their effects run. Two that\n * open in the same commit stack child first — the one case that order gets wrong, and\n * one no overlay in the kit does: each opens on a press, after whatever it sits in.\n *\n * ## Claiming the key: one rule\n *\n * An Escape that means \"undo this step\" is not a dismissal: ChipInput clearing its\n * draft, InlineEditField cancelling its edit, the date picker's month grid going back to\n * the days. Those call {@link claimEscape}, and no layer is handed the key. The only\n * other claim is the layer owner's OWN handler — a dialog's `onKeyDown` prop calling\n * `preventDefault()` on Escape (a busy reauth dialog holding itself open, Kurvenschmiede's\n * gear wizard asking first), through {@link runOwnKeyDown}. A `preventDefault()`\n * anywhere else claims nothing: a search box keeping Chromium from clearing itself\n * must not hold a dialog open (FullBleedDialog's 0.8.0 fix, which Modal never had).\n *\n * The owner's handler hears Escape only while its own layer is on top (0.35, KS). It\n * runs before the stack is asked, so with a tooltip, a hover menu or a list open over\n * the dialog and focus on the panel, the gear wizard asked \"Discard this gear?\" and the\n * tooltip stayed. Now the layer above takes that press, and the next one is the\n * owner's. Every other key reaches the handler as before.\n *\n * A handler that STOPS the key before it reaches a layer element has taken it too —\n * that is the DOM, and it is what the kit's cell editors and confirm buttons do.\n */\n\n/** A layer's handler, read through a ref so the stack holds one stable entry per open. */\ninterface Layer {\n escape: (e: KeyboardEvent) => void;\n /** Whether it covers the page — a dialog, a sheet, a popover, a list — rather than\n * annotating it (a tooltip). Counted by {@link useOverlayOpen}. */\n overlay: boolean;\n}\n\n/** What {@link useDismissLayer} hands back: its layer while open, for\n * {@link runOwnKeyDown} to tell whether that layer is the one on top. */\nexport type DismissLayer = RefObject<Layer | null>;\n\n/** Open layers, oldest first. Module-level, because the order is ACROSS instances. */\nconst layers: Layer[] = [];\n\n/** Escapes a claimant has taken — see {@link claimEscape}. Weak: an event is garbage\n * once its dispatch is over. */\nconst claimed = new WeakSet<Event>();\n\n/** The Escape on its way through the page: the layer that was topmost as it went down,\n * and whether that layer has been handed it yet. */\nlet inFlight: { event: KeyboardEvent; layer: Layer; done: boolean } | null = null;\n\nfunction nativeOf(e: KeyboardEvent | ReactKeyboardEvent): KeyboardEvent {\n return \"nativeEvent\" in e ? e.nativeEvent : e;\n}\n\nfunction onCapture(e: KeyboardEvent): void {\n if (e.key !== \"Escape\") return;\n // An Escape inside an IME composition cancels the composition and nothing else.\n const top = isComposing(e) ? undefined : layers[layers.length - 1];\n inFlight = top ? { event: e, layer: top, done: false } : null;\n}\n\n/** The fallback: no layer element was on the key's path, or none took it. */\nfunction onBubble(e: KeyboardEvent): void {\n handOver(e, false);\n}\n\nfunction handOver(e: KeyboardEvent | ReactKeyboardEvent, stop: boolean): boolean {\n const native = nativeOf(e);\n const flight = inFlight;\n if (native.key !== \"Escape\" || !flight || flight.event !== native || flight.done) return false;\n if (claimed.has(native)) return false;\n flight.done = true;\n // Consumed: a type=\"search\" box does not clear itself as well, and a handler further\n // out that checks `defaultPrevented` (FloatingPanel, an app's own panel) leaves it.\n e.preventDefault();\n if (stop) e.stopPropagation();\n // A layer that closed on the way, by a handler of its own, has had its Escape.\n if (layers.includes(flight.layer)) flight.layer.escape(native);\n return true;\n}\n\nfunction push(layer: Layer): void {\n if (layers.length === 0) {\n document.addEventListener(\"keydown\", onCapture, true);\n document.addEventListener(\"keydown\", onBubble);\n }\n layers.push(layer);\n if (layer.overlay) notifyOverlays();\n}\n\nfunction pop(layer: Layer): void {\n const at = layers.indexOf(layer);\n if (at >= 0) layers.splice(at, 1);\n if (at >= 0 && layer.overlay) notifyOverlays();\n if (layers.length > 0) return;\n document.removeEventListener(\"keydown\", onCapture, true);\n document.removeEventListener(\"keydown\", onBubble);\n inFlight = null;\n}\n\n/**\n * An Escape layer on the stack while `open`: the topmost layer is handed the next\n * Escape and calls `onEscape`, every layer under it waits its turn. A list opened inside\n * a dialog is above the dialog; a popover opened from a list, above the list.\n *\n * Where focus can be INSIDE the layer, its element's `onKeyDown` calls\n * {@link takeEscape} too, so the key stops there — see the module note. `onEscape` is\n * read live, so it need not be memoised; the layer is pushed and popped only when\n * `open` flips. The layer it returns is for the owner's {@link runOwnKeyDown}.\n */\nexport function useDismissLayer(\n open: boolean,\n onEscape: (e: KeyboardEvent) => void,\n { overlay = true }: { overlay?: boolean } = {},\n): DismissLayer {\n const handlerRef = useRef(onEscape);\n const layerRef = useRef<Layer | null>(null);\n // Written in an effect, not during render, so it satisfies react-hooks/refs: a\n // render that React starts and then discards — a transition, a Suspense retry —\n // would otherwise leave this holding a handler from a tree that never committed,\n // and the stack would go on to call it. Keydown events fire after the commit, so\n // the ref is always current by the time one arrives.\n useEffect(() => {\n handlerRef.current = onEscape;\n });\n useEffect(() => {\n if (!open || typeof document === \"undefined\") return;\n const layer: Layer = { escape: (e) => handlerRef.current(e), overlay };\n layerRef.current = layer;\n push(layer);\n return () => {\n pop(layer);\n layerRef.current = null;\n };\n }, [open, overlay]);\n return layerRef;\n}\n\n/*\n * Whether an overlay is open (0.49 KIT-11): every layer on the stack but a tooltip's is a\n * dialog, a sheet, a popover, a hover menu or a list covering part of the page, so the\n * stack is also the one place that knows. The floating launchers (`hideWhenOverlay`) step\n * aside while one is open instead of floating over it or beside it.\n */\nconst overlayListeners = new Set<() => void>();\n\nfunction notifyOverlays(): void {\n for (const listener of overlayListeners) listener();\n}\n\nfunction subscribeOverlays(listener: () => void): () => void {\n overlayListeners.add(listener);\n return () => {\n overlayListeners.delete(listener);\n };\n}\n\nfunction anyOverlayOpen(): boolean {\n return layers.some((layer) => layer.overlay);\n}\n\n/** True while a kit dialog, sheet, popover, hover menu or list is open (any layer of the\n * Escape stack but a tooltip's). Internal. */\nexport function useOverlayOpen(enabled = true): boolean {\n const open = useSyncExternalStore(subscribeOverlays, anyOverlayOpen, () => false);\n return enabled && open;\n}\n\n/**\n * For a layer's own element, in its `onKeyDown`: hands an Escape on its way up to the\n * topmost layer — this one, unless something opened over it without taking focus (a\n * tooltip, the numpad) — and stops it here. True when it did; false for any other key,\n * a claimed Escape, or one already handed over.\n */\nexport function takeEscape(e: KeyboardEvent | ReactKeyboardEvent): boolean {\n return handOver(e, true);\n}\n\n/**\n * For an Escape that means \"undo this step\", not \"close\": the key is the caller's, and\n * no layer is handed it. `preventDefault()`s it as well, so a handler further out that\n * checks `defaultPrevented` leaves it alone, as before.\n */\nexport function claimEscape(e: KeyboardEvent | ReactKeyboardEvent): void {\n claimed.add(nativeOf(e));\n e.preventDefault();\n}\n\n/**\n * Runs a layer owner's own key handler — a dialog's `onKeyDown` prop — and turns a\n * `preventDefault()` it makes on Escape into a claim: the one way, beside\n * {@link claimEscape}, to keep a layer open.\n *\n * Given the owner's `layer` (what {@link useDismissLayer} returned), an Escape that went\n * down while another layer was above that one skips the handler: the press is the\n * layer's on top, and the owner hears the next (0.35, KS). Without it, or while the\n * owner's layer is not open, the handler sees every key, as before.\n */\nexport function runOwnKeyDown<E extends ReactKeyboardEvent>(\n e: E,\n handler: ((e: E) => void) | undefined,\n layer?: DismissLayer,\n): void {\n if (!handler) return;\n if (layer && aboveOwner(e, layer.current)) return;\n const before = e.defaultPrevented;\n handler(e);\n if (e.key === \"Escape\" && !before && e.defaultPrevented) claimed.add(nativeOf(e));\n}\n\n/** Whether this Escape went down with a layer above `owner` — the topmost one then was\n * another, and `owner` is on the stack. An Escape the stack did not note (one inside an\n * IME composition) is nobody else's. */\nfunction aboveOwner(e: ReactKeyboardEvent, owner: Layer | null): boolean {\n const flight = inFlight;\n if (e.key !== \"Escape\" || !owner || !flight || flight.event !== nativeOf(e)) return false;\n return flight.layer !== owner && layers.includes(owner);\n}\n\n/**\n * Calls `handler` on an Escape while `enabled` — as an Escape layer. Since 0.34 this is\n * {@link useDismissLayer} with its arguments the other way round: it hears the key only\n * while nothing opened above it, and inside a dialog it no longer loses the key to the\n * dialog (Wave 2, DUP-1). `handler` is read live, so it need not be memoised.\n */\nexport function useEscapeKey(handler: () => void, enabled = true): void {\n useDismissLayer(enabled, handler);\n}\n\n/** Called with each native press an owner's React tree saw — see {@link useOutsideClick}. */\ntype PressMark = (press: Event) => void;\n\n/** The outside-click hooks watching each ref, for {@link pressInside}. */\nconst watchers = new WeakMap<RefObject<HTMLElement | null>, Set<PressMark>>();\n\n/** What an `onPointerDownCapture` hands over: a React event, its press inside. */\ntype ReactPress = { nativeEvent: Event };\n\n/**\n * An `onPointerDownCapture` for a panel whose `ref` an outside-click hook watches but\n * whose element the hook's owner does not render — {@link DropdownPanel} and\n * `ComboboxPanel` take `panelRef` from `useDropdown` / `useComboboxCore`. Every press\n * that reaches the panel through the React tree, a portalled list opened inside it\n * included, is marked inside for each hook watching `ref`. See {@link useOutsideClick}.\n */\nexport function pressInside(ref: RefObject<HTMLElement | null> | undefined): ((e: ReactPress) => void) | undefined {\n if (!ref) return undefined;\n return (e) => {\n watchers.get(ref)?.forEach((mark) => mark(e.nativeEvent));\n };\n}\n\n/**\n * Calls `handler` on a press that lands outside every supplied element, while\n * `enabled` — the outside-click-to-close half of the same dismissal story. Pass one\n * ref or several (trigger + panel); a press inside any of them is ignored. Refs and\n * `handler` are read live, so an inline `[triggerRef, panelRef]` array is fine and\n * won't churn the listener.\n *\n * ## A panel opened from inside is inside (0.48, keksdose dev#623)\n *\n * A CurrencySelect in a Popover: the currency list is portalled to `<body>`, so in the\n * DOM it is outside the popover's panel, and the first press on a currency closed the\n * POPOVER at pointerdown — the list went with it and the choice never landed. The same\n * for a combobox, a menu, a date picker's popover, a dropdown, at any depth.\n *\n * In the React tree the portal is still where it was opened from, and React passes a\n * press up that tree, through portals. So the hook hands back an\n * `onPointerDownCapture`: put it on the element the press should count as inside of —\n * the kit's Popover panel, HoverMenu, DropdownPanel and ComboboxPanel do — and every\n * press that travels through that element in React, portalled panels opened inside it\n * included, is not outside. React's listeners sit on the root and on each portal's\n * container, so they hear the press before this document listener does. No owner chain\n * to keep: depth is free, and an app's own portal inside a kit panel is covered too. A\n * panel the hook's owner does not render takes {@link pressInside} with the watched ref.\n *\n * **`pointerdown`, not `mousedown`** — one event covering mouse, touch and pen.\n * This listened for `mousedown` alone, which on a touch platform is a SYNTHESISED\n * event: the browser emits it for most taps, which is why nothing obviously broke,\n * but the synthesis is not guaranteed. A tap that begins a scroll, or one that lands\n * on an element which preventDefaults the touch sequence, may never produce one — and\n * on a phone-first PWA whose pickers, menus and flyouts all dismiss through this hook,\n * inheriting that is worse than choosing it.\n *\n * The one behaviour this changes deliberately: a touch-drag that starts outside an\n * open panel now dismisses it at touch-down, where before it did not dismiss at all\n * (a drag synthesises no mousedown). That matches every other platform surface, and\n * the anchored panels re-measure on scroll precisely because the page moves under\n * them.\n */\nexport function useOutsideClick(\n refs: RefObject<HTMLElement | null> | RefObject<HTMLElement | null>[],\n handler: () => void,\n enabled = true,\n): (e: ReactPress) => void {\n const refsRef = useRef(refs);\n const handlerRef = useRef(handler);\n // The last press this owner's React tree saw: the one the document listener is about\n // to hear, when it went through a panel the owner marked.\n const insideRef = useRef<Event | null>(null);\n // In an effect rather than during render — see the note in useDismissLayer.\n useEffect(() => {\n refsRef.current = refs;\n handlerRef.current = handler;\n });\n const markInside = useCallback((e: ReactPress) => {\n insideRef.current = e.nativeEvent;\n }, []);\n useEffect(() => {\n if (!enabled) return;\n const mark: PressMark = (press) => {\n insideRef.current = press;\n };\n const watched = Array.isArray(refsRef.current) ? refsRef.current : [refsRef.current];\n for (const r of watched) {\n const set = watchers.get(r) ?? new Set<PressMark>();\n set.add(mark);\n watchers.set(r, set);\n }\n const onPointerDown = (e: Event) => {\n const inside = insideRef.current === e;\n insideRef.current = null;\n if (inside) return;\n const target = e.target as Node;\n const list = Array.isArray(refsRef.current) ? refsRef.current : [refsRef.current];\n for (const r of list) {\n if (r.current?.contains(target)) return;\n }\n handlerRef.current();\n };\n document.addEventListener(\"pointerdown\", onPointerDown);\n return () => {\n document.removeEventListener(\"pointerdown\", onPointerDown);\n for (const r of watched) watchers.get(r)?.delete(mark);\n };\n }, [enabled]);\n return markInside;\n}\n"],"mappings":";AAAA,SAAS,aAAa,WAAW,QAAQ,4BAA4B;AAErE,SAAS,mBAAmB;AAsE5B,MAAM,SAAkB,CAAC;AAIzB,MAAM,UAAU,oBAAI,QAAe;AAInC,IAAI,WAAyE;AAE7E,SAAS,SAAS,GAAsD;AACtE,SAAO,iBAAiB,IAAI,EAAE,cAAc;AAC9C;AAEA,SAAS,UAAU,GAAwB;AACzC,MAAI,EAAE,QAAQ,SAAU;AAExB,QAAM,MAAM,YAAY,CAAC,IAAI,SAAY,OAAO,OAAO,SAAS,CAAC;AACjE,aAAW,MAAM,EAAE,OAAO,GAAG,OAAO,KAAK,MAAM,MAAM,IAAI;AAC3D;AAGA,SAAS,SAAS,GAAwB;AACxC,WAAS,GAAG,KAAK;AACnB;AAEA,SAAS,SAAS,GAAuC,MAAwB;AAC/E,QAAM,SAAS,SAAS,CAAC;AACzB,QAAM,SAAS;AACf,MAAI,OAAO,QAAQ,YAAY,CAAC,UAAU,OAAO,UAAU,UAAU,OAAO,KAAM,QAAO;AACzF,MAAI,QAAQ,IAAI,MAAM,EAAG,QAAO;AAChC,SAAO,OAAO;AAGd,IAAE,eAAe;AACjB,MAAI,KAAM,GAAE,gBAAgB;AAE5B,MAAI,OAAO,SAAS,OAAO,KAAK,EAAG,QAAO,MAAM,OAAO,MAAM;AAC7D,SAAO;AACT;AAEA,SAAS,KAAK,OAAoB;AAChC,MAAI,OAAO,WAAW,GAAG;AACvB,aAAS,iBAAiB,WAAW,WAAW,IAAI;AACpD,aAAS,iBAAiB,WAAW,QAAQ;AAAA,EAC/C;AACA,SAAO,KAAK,KAAK;AACjB,MAAI,MAAM,QAAS,gBAAe;AACpC;AAEA,SAAS,IAAI,OAAoB;AAC/B,QAAM,KAAK,OAAO,QAAQ,KAAK;AAC/B,MAAI,MAAM,EAAG,QAAO,OAAO,IAAI,CAAC;AAChC,MAAI,MAAM,KAAK,MAAM,QAAS,gBAAe;AAC7C,MAAI,OAAO,SAAS,EAAG;AACvB,WAAS,oBAAoB,WAAW,WAAW,IAAI;AACvD,WAAS,oBAAoB,WAAW,QAAQ;AAChD,aAAW;AACb;AAYO,SAAS,gBACd,MACA,UACA,EAAE,UAAU,KAAK,IAA2B,CAAC,GAC/B;AACd,QAAM,aAAa,OAAO,QAAQ;AAClC,QAAM,WAAW,OAAqB,IAAI;AAM1C,YAAU,MAAM;AACd,eAAW,UAAU;AAAA,EACvB,CAAC;AACD,YAAU,MAAM;AACd,QAAI,CAAC,QAAQ,OAAO,aAAa,YAAa;AAC9C,UAAM,QAAe,EAAE,QAAQ,CAAC,MAAM,WAAW,QAAQ,CAAC,GAAG,QAAQ;AACrE,aAAS,UAAU;AACnB,SAAK,KAAK;AACV,WAAO,MAAM;AACX,UAAI,KAAK;AACT,eAAS,UAAU;AAAA,IACrB;AAAA,EACF,GAAG,CAAC,MAAM,OAAO,CAAC;AAClB,SAAO;AACT;AAQA,MAAM,mBAAmB,oBAAI,IAAgB;AAE7C,SAAS,iBAAuB;AAC9B,aAAW,YAAY,iBAAkB,UAAS;AACpD;AAEA,SAAS,kBAAkB,UAAkC;AAC3D,mBAAiB,IAAI,QAAQ;AAC7B,SAAO,MAAM;AACX,qBAAiB,OAAO,QAAQ;AAAA,EAClC;AACF;AAEA,SAAS,iBAA0B;AACjC,SAAO,OAAO,KAAK,CAAC,UAAU,MAAM,OAAO;AAC7C;AAIO,SAAS,eAAe,UAAU,MAAe;AACtD,QAAM,OAAO,qBAAqB,mBAAmB,gBAAgB,MAAM,KAAK;AAChF,SAAO,WAAW;AACpB;AAQO,SAAS,WAAW,GAAgD;AACzE,SAAO,SAAS,GAAG,IAAI;AACzB;AAOO,SAAS,YAAY,GAA6C;AACvE,UAAQ,IAAI,SAAS,CAAC,CAAC;AACvB,IAAE,eAAe;AACnB;AAYO,SAAS,cACd,GACA,SACA,OACM;AACN,MAAI,CAAC,QAAS;AACd,MAAI,SAAS,WAAW,GAAG,MAAM,OAAO,EAAG;AAC3C,QAAM,SAAS,EAAE;AACjB,UAAQ,CAAC;AACT,MAAI,EAAE,QAAQ,YAAY,CAAC,UAAU,EAAE,iBAAkB,SAAQ,IAAI,SAAS,CAAC,CAAC;AAClF;AAKA,SAAS,WAAW,GAAuB,OAA8B;AACvE,QAAM,SAAS;AACf,MAAI,EAAE,QAAQ,YAAY,CAAC,SAAS,CAAC,UAAU,OAAO,UAAU,SAAS,CAAC,EAAG,QAAO;AACpF,SAAO,OAAO,UAAU,SAAS,OAAO,SAAS,KAAK;AACxD;AAQO,SAAS,aAAa,SAAqB,UAAU,MAAY;AACtE,kBAAgB,SAAS,OAAO;AAClC;AAMA,MAAM,WAAW,oBAAI,QAAuD;AAYrE,SAAS,YAAY,KAAuF;AACjH,MAAI,CAAC,IAAK,QAAO;AACjB,SAAO,CAAC,MAAM;AACZ,aAAS,IAAI,GAAG,GAAG,QAAQ,CAAC,SAAS,KAAK,EAAE,WAAW,CAAC;AAAA,EAC1D;AACF;AAwCO,SAAS,gBACd,MACA,SACA,UAAU,MACe;AACzB,QAAM,UAAU,OAAO,IAAI;AAC3B,QAAM,aAAa,OAAO,OAAO;AAGjC,QAAM,YAAY,OAAqB,IAAI;AAE3C,YAAU,MAAM;AACd,YAAQ,UAAU;AAClB,eAAW,UAAU;AAAA,EACvB,CAAC;AACD,QAAM,aAAa,YAAY,CAAC,MAAkB;AAChD,cAAU,UAAU,EAAE;AAAA,EACxB,GAAG,CAAC,CAAC;AACL,YAAU,MAAM;AACd,QAAI,CAAC,QAAS;AACd,UAAM,OAAkB,CAAC,UAAU;AACjC,gBAAU,UAAU;AAAA,IACtB;AACA,UAAM,UAAU,MAAM,QAAQ,QAAQ,OAAO,IAAI,QAAQ,UAAU,CAAC,QAAQ,OAAO;AACnF,eAAW,KAAK,SAAS;AACvB,YAAM,MAAM,SAAS,IAAI,CAAC,KAAK,oBAAI,IAAe;AAClD,UAAI,IAAI,IAAI;AACZ,eAAS,IAAI,GAAG,GAAG;AAAA,IACrB;AACA,UAAM,gBAAgB,CAAC,MAAa;AAClC,YAAM,SAAS,UAAU,YAAY;AACrC,gBAAU,UAAU;AACpB,UAAI,OAAQ;AACZ,YAAM,SAAS,EAAE;AACjB,YAAM,OAAO,MAAM,QAAQ,QAAQ,OAAO,IAAI,QAAQ,UAAU,CAAC,QAAQ,OAAO;AAChF,iBAAW,KAAK,MAAM;AACpB,YAAI,EAAE,SAAS,SAAS,MAAM,EAAG;AAAA,MACnC;AACA,iBAAW,QAAQ;AAAA,IACrB;AACA,aAAS,iBAAiB,eAAe,aAAa;AACtD,WAAO,MAAM;AACX,eAAS,oBAAoB,eAAe,aAAa;AACzD,iBAAW,KAAK,QAAS,UAAS,IAAI,CAAC,GAAG,OAAO,IAAI;AAAA,IACvD;AAAA,EACF,GAAG,CAAC,OAAO,CAAC;AACZ,SAAO;AACT;","names":[]}
1
+ {"version":3,"sources":["../../src/hooks/use-dismiss.ts"],"sourcesContent":["import { useCallback, useEffect, useRef, useSyncExternalStore } from \"react\";\nimport type { KeyboardEvent as ReactKeyboardEvent, RefObject } from \"react\";\nimport { isComposing } from \"../lib/ime\";\n\n/*\n * # One Escape stack (round 0.34 Wave 2, DUP-1)\n *\n * Escape closes the TOPMOST thing on screen, and only that: a list open in a dialog\n * closes and the dialog stays; the next press closes the dialog.\n *\n * \"Who owns Escape\" used to be decided five ways. The anchored pickers listened on the\n * document; Modal, FullBleedDialog, PickerSheet and Popover closed in their own panel's\n * `onKeyDown` and stopped the key; CommandPalette closed without stopping it. A dialog's\n * handler sits earlier on the key's path than any document listener, so Escape in an\n * open list inside a Modal closed the MODAL and threw the form away (BUG-1, sev 4,\n * kastlan's lease dialog), and HoverMenu, the numpad and a tooltip could not be\n * dismissed inside a dialog at all. And Modal and FullBleedDialog disagreed on when a\n * child had \"claimed\" the key: a ChipInput clearing its draft kept a Modal open and\n * closed a FullBleedDialog.\n *\n * Now every overlay and picker pushes a layer while it is open ({@link useDismissLayer}),\n * last in, first out, the way `use-overlay-history` stacks the Back gesture. One\n * capture-phase listener on the document notes which layer is on top as each Escape\n * goes down — before any handler on the page runs, so a layer that closes on the way\n * cannot pass the key on to the one beneath it. That layer, and only that one, is\n * handed the key:\n *\n * - at the first layer element the key passes on its way back up ({@link takeEscape},\n * which Modal's panel, a picker's search box and the rest call from their\n * `onKeyDown`). The key is stopped there, so nothing around the layers — a\n * FloatingPanel, an app's own panel — closes with it;\n * - or, when focus is in none of them (a hover menu, a tooltip, the numpad over its\n * field, a press on the page), at the document once the key has been everywhere else.\n *\n * Layers stack in the order they OPEN, which is the order their effects run. Two that\n * open in the same commit stack child first — the one case that order gets wrong, and\n * one no overlay in the kit does: each opens on a press, after whatever it sits in.\n *\n * ## Claiming the key: one rule\n *\n * An Escape that means \"undo this step\" is not a dismissal: ChipInput clearing its\n * draft, InlineEditField cancelling its edit, the date picker's month grid going back to\n * the days. Those call {@link claimEscape}, and no layer is handed the key. The only\n * other claim is the layer owner's OWN handler — a dialog's `onKeyDown` prop calling\n * `preventDefault()` on Escape (Kurvenschmiede's gear wizard asking first), through\n * {@link runOwnKeyDown}. A dialog holding itself open while its request runs passes\n * Modal's `busy` instead (0.51.7, DUP-3), which holds the backdrop and Back as well. A `preventDefault()`\n * anywhere else claims nothing: a search box keeping Chromium from clearing itself\n * must not hold a dialog open (FullBleedDialog's 0.8.0 fix, which Modal never had).\n *\n * The owner's handler hears Escape only while its own layer is on top (0.35, KS). It\n * runs before the stack is asked, so with a tooltip, a hover menu or a list open over\n * the dialog and focus on the panel, the gear wizard asked \"Discard this gear?\" and the\n * tooltip stayed. Now the layer above takes that press, and the next one is the\n * owner's. Every other key reaches the handler as before.\n *\n * A handler that STOPS the key before it reaches a layer element has taken it too —\n * that is the DOM, and it is what the kit's cell editors and confirm buttons do.\n */\n\n/** A layer's handler, read through a ref so the stack holds one stable entry per open. */\ninterface Layer {\n escape: (e: KeyboardEvent) => void;\n /** Whether it covers the page — a dialog, a sheet, a popover, a list — rather than\n * annotating it (a tooltip). Counted by {@link useOverlayOpen}. */\n overlay: boolean;\n}\n\n/** What {@link useDismissLayer} hands back: its layer while open, for\n * {@link runOwnKeyDown} to tell whether that layer is the one on top. */\nexport type DismissLayer = RefObject<Layer | null>;\n\n/** Open layers, oldest first. Module-level, because the order is ACROSS instances. */\nconst layers: Layer[] = [];\n\n/** Escapes a claimant has taken — see {@link claimEscape}. Weak: an event is garbage\n * once its dispatch is over. */\nconst claimed = new WeakSet<Event>();\n\n/** The Escape on its way through the page: the layer that was topmost as it went down,\n * and whether that layer has been handed it yet. */\nlet inFlight: { event: KeyboardEvent; layer: Layer; done: boolean } | null = null;\n\nfunction nativeOf(e: KeyboardEvent | ReactKeyboardEvent): KeyboardEvent {\n return \"nativeEvent\" in e ? e.nativeEvent : e;\n}\n\nfunction onCapture(e: KeyboardEvent): void {\n if (e.key !== \"Escape\") return;\n // An Escape inside an IME composition cancels the composition and nothing else.\n const top = isComposing(e) ? undefined : layers[layers.length - 1];\n inFlight = top ? { event: e, layer: top, done: false } : null;\n}\n\n/** The fallback: no layer element was on the key's path, or none took it. */\nfunction onBubble(e: KeyboardEvent): void {\n handOver(e, false);\n}\n\nfunction handOver(e: KeyboardEvent | ReactKeyboardEvent, stop: boolean): boolean {\n const native = nativeOf(e);\n const flight = inFlight;\n if (native.key !== \"Escape\" || !flight || flight.event !== native || flight.done) return false;\n if (claimed.has(native)) return false;\n flight.done = true;\n // Consumed: a type=\"search\" box does not clear itself as well, and a handler further\n // out that checks `defaultPrevented` (FloatingPanel, an app's own panel) leaves it.\n e.preventDefault();\n if (stop) e.stopPropagation();\n // A layer that closed on the way, by a handler of its own, has had its Escape.\n if (layers.includes(flight.layer)) flight.layer.escape(native);\n return true;\n}\n\nfunction push(layer: Layer): void {\n if (layers.length === 0) {\n document.addEventListener(\"keydown\", onCapture, true);\n document.addEventListener(\"keydown\", onBubble);\n }\n layers.push(layer);\n if (layer.overlay) notifyOverlays();\n}\n\nfunction pop(layer: Layer): void {\n const at = layers.indexOf(layer);\n if (at >= 0) layers.splice(at, 1);\n if (at >= 0 && layer.overlay) notifyOverlays();\n if (layers.length > 0) return;\n document.removeEventListener(\"keydown\", onCapture, true);\n document.removeEventListener(\"keydown\", onBubble);\n inFlight = null;\n}\n\n/**\n * An Escape layer on the stack while `open`: the topmost layer is handed the next\n * Escape and calls `onEscape`, every layer under it waits its turn. A list opened inside\n * a dialog is above the dialog; a popover opened from a list, above the list.\n *\n * Where focus can be INSIDE the layer, its element's `onKeyDown` calls\n * {@link takeEscape} too, so the key stops there — see the module note. `onEscape` is\n * read live, so it need not be memoised; the layer is pushed and popped only when\n * `open` flips. The layer it returns is for the owner's {@link runOwnKeyDown}.\n */\nexport function useDismissLayer(\n open: boolean,\n onEscape: (e: KeyboardEvent) => void,\n { overlay = true }: { overlay?: boolean } = {},\n): DismissLayer {\n const handlerRef = useRef(onEscape);\n const layerRef = useRef<Layer | null>(null);\n // Written in an effect, not during render, so it satisfies react-hooks/refs: a\n // render that React starts and then discards — a transition, a Suspense retry —\n // would otherwise leave this holding a handler from a tree that never committed,\n // and the stack would go on to call it. Keydown events fire after the commit, so\n // the ref is always current by the time one arrives.\n useEffect(() => {\n handlerRef.current = onEscape;\n });\n useEffect(() => {\n if (!open || typeof document === \"undefined\") return;\n const layer: Layer = { escape: (e) => handlerRef.current(e), overlay };\n layerRef.current = layer;\n push(layer);\n return () => {\n pop(layer);\n layerRef.current = null;\n };\n }, [open, overlay]);\n return layerRef;\n}\n\n/*\n * Whether an overlay is open (0.49 KIT-11): every layer on the stack but a tooltip's is a\n * dialog, a sheet, a popover, a hover menu or a list covering part of the page, so the\n * stack is also the one place that knows. The floating launchers (`hideWhenOverlay`) step\n * aside while one is open instead of floating over it or beside it.\n */\nconst overlayListeners = new Set<() => void>();\n\nfunction notifyOverlays(): void {\n for (const listener of overlayListeners) listener();\n}\n\nfunction subscribeOverlays(listener: () => void): () => void {\n overlayListeners.add(listener);\n return () => {\n overlayListeners.delete(listener);\n };\n}\n\nfunction anyOverlayOpen(): boolean {\n return layers.some((layer) => layer.overlay);\n}\n\n/** True while a kit dialog, sheet, popover, hover menu or list is open (any layer of the\n * Escape stack but a tooltip's). Internal. */\nexport function useOverlayOpen(enabled = true): boolean {\n const open = useSyncExternalStore(subscribeOverlays, anyOverlayOpen, () => false);\n return enabled && open;\n}\n\n/**\n * For a layer's own element, in its `onKeyDown`: hands an Escape on its way up to the\n * topmost layer — this one, unless something opened over it without taking focus (a\n * tooltip, the numpad) — and stops it here. True when it did; false for any other key,\n * a claimed Escape, or one already handed over.\n */\nexport function takeEscape(e: KeyboardEvent | ReactKeyboardEvent): boolean {\n return handOver(e, true);\n}\n\n/**\n * For an Escape that means \"undo this step\", not \"close\": the key is the caller's, and\n * no layer is handed it. `preventDefault()`s it as well, so a handler further out that\n * checks `defaultPrevented` leaves it alone, as before.\n */\nexport function claimEscape(e: KeyboardEvent | ReactKeyboardEvent): void {\n claimed.add(nativeOf(e));\n e.preventDefault();\n}\n\n/**\n * Runs a layer owner's own key handler — a dialog's `onKeyDown` prop — and turns a\n * `preventDefault()` it makes on Escape into a claim: the one way, beside\n * {@link claimEscape}, to keep a layer open.\n *\n * Given the owner's `layer` (what {@link useDismissLayer} returned), an Escape that went\n * down while another layer was above that one skips the handler: the press is the\n * layer's on top, and the owner hears the next (0.35, KS). Without it, or while the\n * owner's layer is not open, the handler sees every key, as before.\n */\nexport function runOwnKeyDown<E extends ReactKeyboardEvent>(\n e: E,\n handler: ((e: E) => void) | undefined,\n layer?: DismissLayer,\n): void {\n if (!handler) return;\n if (layer && aboveOwner(e, layer.current)) return;\n const before = e.defaultPrevented;\n handler(e);\n if (e.key === \"Escape\" && !before && e.defaultPrevented) claimed.add(nativeOf(e));\n}\n\n/** Whether this Escape went down with a layer above `owner` — the topmost one then was\n * another, and `owner` is on the stack. An Escape the stack did not note (one inside an\n * IME composition) is nobody else's. */\nfunction aboveOwner(e: ReactKeyboardEvent, owner: Layer | null): boolean {\n const flight = inFlight;\n if (e.key !== \"Escape\" || !owner || !flight || flight.event !== nativeOf(e)) return false;\n return flight.layer !== owner && layers.includes(owner);\n}\n\n/**\n * Calls `handler` on an Escape while `enabled` — as an Escape layer. Since 0.34 this is\n * {@link useDismissLayer} with its arguments the other way round: it hears the key only\n * while nothing opened above it, and inside a dialog it no longer loses the key to the\n * dialog (Wave 2, DUP-1). `handler` is read live, so it need not be memoised.\n */\nexport function useEscapeKey(handler: () => void, enabled = true): void {\n useDismissLayer(enabled, handler);\n}\n\n/** Called with each native press an owner's React tree saw — see {@link useOutsideClick}. */\ntype PressMark = (press: Event) => void;\n\n/** The outside-click hooks watching each ref, for {@link pressInside}. */\nconst watchers = new WeakMap<RefObject<HTMLElement | null>, Set<PressMark>>();\n\n/** What an `onPointerDownCapture` hands over: a React event, its press inside. */\ntype ReactPress = { nativeEvent: Event };\n\n/**\n * An `onPointerDownCapture` for a panel whose `ref` an outside-click hook watches but\n * whose element the hook's owner does not render — {@link DropdownPanel} and\n * `ComboboxPanel` take `panelRef` from `useDropdown` / `useComboboxCore`. Every press\n * that reaches the panel through the React tree, a portalled list opened inside it\n * included, is marked inside for each hook watching `ref`. See {@link useOutsideClick}.\n */\nexport function pressInside(ref: RefObject<HTMLElement | null> | undefined): ((e: ReactPress) => void) | undefined {\n if (!ref) return undefined;\n return (e) => {\n watchers.get(ref)?.forEach((mark) => mark(e.nativeEvent));\n };\n}\n\n/**\n * Calls `handler` on a press that lands outside every supplied element, while\n * `enabled` — the outside-click-to-close half of the same dismissal story. Pass one\n * ref or several (trigger + panel); a press inside any of them is ignored. Refs and\n * `handler` are read live, so an inline `[triggerRef, panelRef]` array is fine and\n * won't churn the listener.\n *\n * ## A panel opened from inside is inside (0.48, keksdose dev#623)\n *\n * A CurrencySelect in a Popover: the currency list is portalled to `<body>`, so in the\n * DOM it is outside the popover's panel, and the first press on a currency closed the\n * POPOVER at pointerdown — the list went with it and the choice never landed. The same\n * for a combobox, a menu, a date picker's popover, a dropdown, at any depth.\n *\n * In the React tree the portal is still where it was opened from, and React passes a\n * press up that tree, through portals. So the hook hands back an\n * `onPointerDownCapture`: put it on the element the press should count as inside of —\n * the kit's Popover panel, HoverMenu, DropdownPanel and ComboboxPanel do — and every\n * press that travels through that element in React, portalled panels opened inside it\n * included, is not outside. React's listeners sit on the root and on each portal's\n * container, so they hear the press before this document listener does. No owner chain\n * to keep: depth is free, and an app's own portal inside a kit panel is covered too. A\n * panel the hook's owner does not render takes {@link pressInside} with the watched ref.\n *\n * **`pointerdown`, not `mousedown`** — one event covering mouse, touch and pen.\n * This listened for `mousedown` alone, which on a touch platform is a SYNTHESISED\n * event: the browser emits it for most taps, which is why nothing obviously broke,\n * but the synthesis is not guaranteed. A tap that begins a scroll, or one that lands\n * on an element which preventDefaults the touch sequence, may never produce one — and\n * on a phone-first PWA whose pickers, menus and flyouts all dismiss through this hook,\n * inheriting that is worse than choosing it.\n *\n * The one behaviour this changes deliberately: a touch-drag that starts outside an\n * open panel now dismisses it at touch-down, where before it did not dismiss at all\n * (a drag synthesises no mousedown). That matches every other platform surface, and\n * the anchored panels re-measure on scroll precisely because the page moves under\n * them.\n */\nexport function useOutsideClick(\n refs: RefObject<HTMLElement | null> | RefObject<HTMLElement | null>[],\n handler: () => void,\n enabled = true,\n): (e: ReactPress) => void {\n const refsRef = useRef(refs);\n const handlerRef = useRef(handler);\n // The last press this owner's React tree saw: the one the document listener is about\n // to hear, when it went through a panel the owner marked.\n const insideRef = useRef<Event | null>(null);\n // In an effect rather than during render — see the note in useDismissLayer.\n useEffect(() => {\n refsRef.current = refs;\n handlerRef.current = handler;\n });\n const markInside = useCallback((e: ReactPress) => {\n insideRef.current = e.nativeEvent;\n }, []);\n useEffect(() => {\n if (!enabled) return;\n const mark: PressMark = (press) => {\n insideRef.current = press;\n };\n const watched = Array.isArray(refsRef.current) ? refsRef.current : [refsRef.current];\n for (const r of watched) {\n const set = watchers.get(r) ?? new Set<PressMark>();\n set.add(mark);\n watchers.set(r, set);\n }\n const onPointerDown = (e: Event) => {\n const inside = insideRef.current === e;\n insideRef.current = null;\n if (inside) return;\n const target = e.target as Node;\n const list = Array.isArray(refsRef.current) ? refsRef.current : [refsRef.current];\n for (const r of list) {\n if (r.current?.contains(target)) return;\n }\n handlerRef.current();\n };\n document.addEventListener(\"pointerdown\", onPointerDown);\n return () => {\n document.removeEventListener(\"pointerdown\", onPointerDown);\n for (const r of watched) watchers.get(r)?.delete(mark);\n };\n }, [enabled]);\n return markInside;\n}\n"],"mappings":"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","names":[]}
@@ -4,5 +4,11 @@
4
4
  * @param open whether the overlay is currently showing. Components that mount
5
5
  * only while open (the common case) pass `true`.
6
6
  * @param onClose the overlay's own close handler — the same one Escape calls.
7
+ * @param options.held the overlay refuses to close right now (a `Modal` whose request
8
+ * is in flight, DUP-3): Back does not call `onClose`, and the entry the
9
+ * press took is put back, so the overlay still has one for the next
10
+ * Back. Read when Back is pressed. Default false.
7
11
  */
8
- export declare function useOverlayHistory(open: boolean, onClose: () => void): void;
12
+ export declare function useOverlayHistory(open: boolean, onClose: () => void, { held }?: {
13
+ held?: boolean;
14
+ }): void;
@@ -170,6 +170,10 @@ function closeOnPop() {
170
170
  const top = stack[stack.length - 1];
171
171
  if (!top) return;
172
172
  if (currentSentinel() === top.id) return;
173
+ if (top.held()) {
174
+ putBack(top.id);
175
+ return;
176
+ }
173
177
  stack.pop();
174
178
  consumed.add(top.id);
175
179
  const record = pushed.find((p) => p.id === top.id);
@@ -177,6 +181,14 @@ function closeOnPop() {
177
181
  if (record && !record.dead) carryOnto(record.seen, record);
178
182
  top.close();
179
183
  }
184
+ function putBack(id) {
185
+ const record = pushed.find((p) => p.id === id);
186
+ if (record && !record.dead) carryOnto(record.seen, record);
187
+ const prev = window.history.state;
188
+ window.history.pushState({ ...prev && typeof prev === "object" ? prev : {}, [SENTINEL_KEY]: id }, "");
189
+ standing = id;
190
+ if (record) record.href = record.seen = currentHref();
191
+ }
180
192
  function ensureListening() {
181
193
  if (listening || typeof window === "undefined") return;
182
194
  window.addEventListener("popstate", handlePop);
@@ -185,16 +197,18 @@ function ensureListening() {
185
197
  for (const type of ["click", "keyup", "change", "input"]) window.addEventListener(type, noteSeenNowAndLater);
186
198
  listening = true;
187
199
  }
188
- function useOverlayHistory(open, onClose) {
200
+ function useOverlayHistory(open, onClose, { held = false } = {}) {
189
201
  const closeRef = useRef(onClose);
202
+ const heldRef = useRef(held);
190
203
  useEffect(() => {
191
204
  closeRef.current = onClose;
205
+ heldRef.current = held;
192
206
  });
193
207
  useEffect(() => {
194
208
  if (!open || typeof window === "undefined") return;
195
209
  ensureListening();
196
210
  const id = `overlay-${nextId += 1}`;
197
- const entry = { id, close: () => closeRef.current() };
211
+ const entry = { id, close: () => closeRef.current(), held: () => heldRef.current };
198
212
  stack.push(entry);
199
213
  const prev = window.history.state;
200
214
  const marked = { ...prev && typeof prev === "object" ? prev : {}, [SENTINEL_KEY]: id };