@godxjp/ui 32.13.1 → 32.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/README.md +4 -4
  2. package/agent/START-HERE.md +7 -7
  3. package/agent/components/HeatmapChart.json +97 -0
  4. package/agent/components/ListRow.json +1 -1
  5. package/agent/components/PageContainer.json +2 -2
  6. package/agent/components/Segmented.json +1 -0
  7. package/agent/components/Tabs.json +3 -2
  8. package/agent/components-index.json +5 -0
  9. package/agent/components.json +104 -5
  10. package/agent/index.json +7 -7
  11. package/agent/llms.txt +6 -6
  12. package/agent/patterns/inertia-list-page.json +1 -1
  13. package/agent/patterns.json +1 -1
  14. package/agent/tokens.json +116 -2
  15. package/dist/components/charts/chart-summary.d.ts +32 -1
  16. package/dist/components/charts/chart-summary.js +49 -0
  17. package/dist/components/charts/heatmap-chart.d.ts +19 -0
  18. package/dist/components/charts/heatmap-chart.js +293 -0
  19. package/dist/components/charts/index.d.ts +3 -0
  20. package/dist/components/charts/index.js +2 -0
  21. package/dist/components/feedback/tooltip.d.ts +9 -0
  22. package/dist/components/feedback/tooltip.js +4 -1
  23. package/dist/contracts/measurement.json +1 -1
  24. package/dist/i18n/messages/en.json +8 -1
  25. package/dist/i18n/messages/ja.json +8 -1
  26. package/dist/i18n/messages/vi.json +8 -1
  27. package/dist/props/components/charts.prop.d.ts +58 -0
  28. package/dist/props/registry.d.ts +37 -0
  29. package/dist/props/registry.js +36 -0
  30. package/dist/styles/base.css +19 -19
  31. package/dist/styles/chart-layout.css +161 -0
  32. package/dist/styles/data-display-layout.css +3 -3
  33. package/dist/styles/layers.json +8 -1
  34. package/dist/styles/layout.css +1 -1
  35. package/dist/styles.css +1 -1
  36. package/dist/tokens/components/chart.css +27 -0
  37. package/dist/tokens/foundation.css +12 -0
  38. package/dist/tokens/semantic/layout.css +1 -1
  39. package/package.json +9 -2
  40. package/scripts/_agent-setup.mjs +118 -0
  41. package/scripts/agent-guard.mjs +176 -0
  42. package/scripts/cli.mjs +25 -2
  43. package/scripts/codemod.mjs +313 -42
  44. package/scripts/init-agent-kit.mjs +14 -0
  45. package/scripts/mcp-call.mjs +40 -0
  46. package/scripts/ui-audit.mjs +77 -0
  47. package/scripts/user-guard.mjs +78 -0
@@ -151,7 +151,7 @@
151
151
  ]
152
152
  },
153
153
  {
154
- "code": "import { Head, router } from \"@inertiajs/react\"\nimport { useMemo, useState } from \"react\"\nimport { PageContainer, Flex } from \"@godxjp/ui/layout\"\nimport { Card, CardContent, DataTable, EmptyState, Badge, type ColumnDef } from \"@godxjp/ui/data-display\"\nimport { SearchInput, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from \"@godxjp/ui/data-entry\"\nimport { Toolbar, ToolbarGroup, Pagination } from \"@godxjp/ui/navigation\"\nimport { formatDate } from \"@godxjp/ui/datetime\"\nimport { withCrmLayout } from \"@/layouts/crm-layout\" // see \"inertia-persistent-layout\"\n\ntype Coupon = { id: string; name: string; status: string; scope: string; validFrom: string; validTo: string; usage: number }\nconst PAGE_SIZE = 10\n\nfunction Coupons({ coupons }: { coupons: Coupon[] }) {\n const [q, setQ] = useState(\"\")\n const [status, setStatus] = useState(\"all\")\n const [page, setPage] = useState(1)\n\n const filtered = useMemo(() => coupons.filter((c) => {\n if (q && !c.name.toLowerCase().includes(q.toLowerCase())) return false\n if (status !== \"all\" && c.status !== status) return false\n return true\n }), [coupons, q, status])\n const paged = filtered.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE)\n\n // ColumnDef = { key, header, render?, align?: \"left\"|\"center\"|\"right\", sortable?, width? }\n const columns: ColumnDef<Coupon>[] = [\n { key: \"name\", header: \"クーポン名\", render: (c) => <span className=\"font-medium\">{c.name}</span> },\n { key: \"scope\", header: \"スコープ\" }, // a category is plain text; Badge is for the status column only\n { key: \"status\", header: \"ステータス\", render: (c) => <Badge status={c.status} /> },\n { key: \"valid\", header: \"有効期間\", render: (c) => `${formatDate(c.validFrom)} 〜 ${formatDate(c.validTo)}` },\n { key: \"usage\", header: \"利用数\", align: \"right\", render: (c) => c.usage.toLocaleString() },\n ]\n\n return (\n <>\n <Head title=\"クーポン管理\" />\n {/* RULE: every page wraps in PageContainer; vertical rhythm via Flex direction=\"col\"/ResponsiveGrid, never p-* or gap-* */}\n {/* No subtitle that restates the title, no StatCard row: the total is Pagination's showTotal. */}\n <PageContainer title=\"クーポン管理\">\n <Flex direction=\"col\" gap=\"lg\">\n {/* Toolbar is the filter-bar primitive (standalone, above the table surface). */}\n <Toolbar hasActiveFilters={q !== \"\" || status !== \"all\"} onClear={() => { setQ(\"\"); setStatus(\"all\"); setPage(1) }}>\n {/* SearchInput is value + onSearch(v) — NOT onChange */}\n <SearchInput placeholder=\"クーポン名で検索\" value={q} onSearch={(v) => { setQ(v); setPage(1) }} />\n <ToolbarGroup label=\"ステータス\">\n <Select value={status} onValueChange={(v) => { setStatus(v); setPage(1) }}>\n <SelectTrigger><SelectValue /></SelectTrigger>\n <SelectContent>\n <SelectItem value=\"all\">全ステータス</SelectItem>\n <SelectItem value=\"公開中\">公開中</SelectItem>\n <SelectItem value=\"下書き\">下書き</SelectItem>\n </SelectContent>\n </Select>\n </ToolbarGroup>\n </Toolbar>\n\n <Card>\n <CardContent flush>\n {filtered.length === 0\n ? <EmptyState title=\"該当するクーポンがありません\" description=\"検索条件を変更してください。\" /> /* one sentence, no icon */\n : <DataTable data={paged} columns={columns} getRowId={(c) => c.id} onRowClick={(c) => router.visit(`/coupons/${c.id}`)} />}\n </CardContent>\n </Card>\n\n {/* Pagination ONLY for real multi-page data — hide it while empty or single-page. */}\n {filtered.length > PAGE_SIZE && (\n <Pagination value={page} total={filtered.length} pageSize={PAGE_SIZE} showTotal onValueChange={(p) => setPage(p)} />\n )}\n </Flex>\n </PageContainer>\n </>\n )\n}\n\nCoupons.layout = withCrmLayout\nexport default Coupons",
154
+ "code": "import { Head, router } from \"@inertiajs/react\"\nimport { useMemo, useState } from \"react\"\nimport { PageContainer, Flex } from \"@godxjp/ui/layout\"\nimport { Card, CardContent, DataTable, EmptyState, Badge, type ColumnDef } from \"@godxjp/ui/data-display\"\nimport { SearchInput, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from \"@godxjp/ui/data-entry\"\nimport { Toolbar, ToolbarGroup, Pagination } from \"@godxjp/ui/navigation\"\nimport { formatDate } from \"@godxjp/ui/datetime\"\nimport { withCrmLayout } from \"@/layouts/crm-layout\" // see \"inertia-persistent-layout\"\n\ntype Coupon = { id: string; name: string; status: string; scope: string; validFrom: string; validTo: string; usage: number }\nconst PAGE_SIZE = 10\n\nfunction Coupons({ coupons }: { coupons: Coupon[] }) {\n const [q, setQ] = useState(\"\")\n const [status, setStatus] = useState(\"all\")\n const [page, setPage] = useState(1)\n\n const filtered = useMemo(() => coupons.filter((c) => {\n if (q && !c.name.toLowerCase().includes(q.toLowerCase())) return false\n if (status !== \"all\" && c.status !== status) return false\n return true\n }), [coupons, q, status])\n const paged = filtered.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE)\n\n // ColumnDef = { key, header, render?, align?: \"left\"|\"center\"|\"right\", sortable?, width? }\n const columns: ColumnDef<Coupon>[] = [\n { key: \"name\", header: \"クーポン名\", render: (c) => <span className=\"font-medium\">{c.name}</span> },\n { key: \"scope\", header: \"スコープ\" }, // a category is plain text; Badge is for the status column only\n { key: \"status\", header: \"ステータス\", render: (c) => <Badge status={c.status} /> },\n { key: \"valid\", header: \"有効期間\", render: (c) => `${formatDate(c.validFrom)} 〜 ${formatDate(c.validTo)}` },\n { key: \"usage\", header: \"利用数\", align: \"right\", render: (c) => c.usage.toLocaleString() },\n ]\n\n return (\n <>\n <Head title=\"クーポン管理\" />\n {/* RULE: every page wraps in PageContainer; vertical rhythm via Flex direction=\"col\"/ResponsiveGrid, never p-* or gap-* */}\n {/* No subtitle that restates the title, no StatCard row: the total is Pagination's showTotal. */}\n <PageContainer title=\"クーポン管理\">\n <Flex direction=\"col\" gap=\"lg\">\n {/* Toolbar is the filter-bar primitive (standalone, above the table surface). */}\n <Toolbar hasActiveFilters={q !== \"\" || status !== \"all\"} onClear={() => { setQ(\"\"); setStatus(\"all\"); setPage(1) }}>\n {/* SearchInput is value + onSearch(v) — NOT onChange */}\n <SearchInput placeholder=\"クーポン名で検索\" value={q} onSearch={(v) => { setQ(v); setPage(1) }} />\n {/* Status view of the SAME table = a filter: Segmented with count for 2-4 statuses, this\n Select past 4. Never Tabs: Tabs switch panels with their own columns (gh#1324). */}\n <ToolbarGroup label=\"ステータス\">\n <Select value={status} onValueChange={(v) => { setStatus(v); setPage(1) }}>\n <SelectTrigger><SelectValue /></SelectTrigger>\n <SelectContent>\n <SelectItem value=\"all\">全ステータス</SelectItem>\n <SelectItem value=\"公開中\">公開中</SelectItem>\n <SelectItem value=\"下書き\">下書き</SelectItem>\n </SelectContent>\n </Select>\n </ToolbarGroup>\n </Toolbar>\n\n <Card>\n <CardContent flush>\n {filtered.length === 0\n ? <EmptyState title=\"該当するクーポンがありません\" description=\"検索条件を変更してください。\" /> /* one sentence, no icon */\n : <DataTable data={paged} columns={columns} getRowId={(c) => c.id} onRowClick={(c) => router.visit(`/coupons/${c.id}`)} />}\n </CardContent>\n </Card>\n\n {/* Pagination ONLY for real multi-page data — hide it while empty or single-page. */}\n {filtered.length > PAGE_SIZE && (\n <Pagination value={page} total={filtered.length} pageSize={PAGE_SIZE} showTotal onValueChange={(p) => setPage(p)} />\n )}\n </Flex>\n </PageContainer>\n </>\n )\n}\n\nCoupons.layout = withCrmLayout\nexport default Coupons",
155
155
  "name": "inertia-list-page",
156
156
  "tagline": "Inertia + @godxjp/ui list page — PageContainer + Toolbar (above the table) + DataTable + Badge + Pagination. No KPI row, no restating subtitle.",
157
157
  "tags": [
package/agent/tokens.json CHANGED
@@ -443,6 +443,36 @@
443
443
  "tier": "foundation",
444
444
  "value": "#eb6101"
445
445
  },
446
+ {
447
+ "description": "Sequential heat ramp — HeatmapChart (gh#1330). ONE hue (the --chart-1 blue, 204°) stepped by lightness alone, so the order of the steps survives every colour-vision deficiency: CVD shifts hue, and a single-hue ramp carries its meaning in luminance, which protan/deutan/tritan vision all keep. Relative luminance falls monotonically 0.805 → 0.541 → 0.330 → 0.173 → 0.064; against the light --card that is 1.21 / 1.74 / 2.72 / 4.62 / 9.03:1, each step ≥1.45:1 from the last. Step 1 is deliberately quiet (it is \"low\", not \"absent\"): an EMPTY cell is told apart by its dashed outline and its text, never by this fill. A service retunes the whole ramp here.",
448
+ "name": "--chart-heat-1",
449
+ "tier": "foundation",
450
+ "value": "#d6ebf8"
451
+ },
452
+ {
453
+ "description": "Foundation tokens: color, typography, raw spacing, ratio, radius, shadow.",
454
+ "name": "--chart-heat-2",
455
+ "tier": "foundation",
456
+ "value": "#94c9ec"
457
+ },
458
+ {
459
+ "description": "Foundation tokens: color, typography, raw spacing, ratio, radius, shadow.",
460
+ "name": "--chart-heat-3",
461
+ "tier": "foundation",
462
+ "value": "#4ea3dc"
463
+ },
464
+ {
465
+ "description": "Foundation tokens: color, typography, raw spacing, ratio, radius, shadow.",
466
+ "name": "--chart-heat-4",
467
+ "tier": "foundation",
468
+ "value": "#0077c7"
469
+ },
470
+ {
471
+ "description": "Foundation tokens: color, typography, raw spacing, ratio, radius, shadow.",
472
+ "name": "--chart-heat-5",
473
+ "tier": "foundation",
474
+ "value": "#004a7f"
475
+ },
446
476
  {
447
477
  "description": "Disabled affordance opacity — single knob (was referenced but undefined → silent failure).",
448
478
  "name": "--disabled-opacity",
@@ -1638,7 +1668,7 @@
1638
1668
  "value": "initial"
1639
1669
  },
1640
1670
  {
1641
- "description": "PageContainer `toolbar` — the fixed chrome band between the header and the (scrolling) body. ALL THREE knobs are quiet by default (rule #44): background — `transparent`, i.e. the band shows the page ground exactly as it did before the knob existed. It is bound at `:root` and NOT `initial`, unlike the divider below, because the default is a plain CSS keyword rather than another role token: there is nothing for a scoped [data-tenant]/.dark override to re-resolve, so the `initial` + call-site-fallback dance would buy nothing and only hide the default from anyone reading this file. A service that wants the band to read as its own surface — the design that asked for this knob puts a chat channel's workflow rail on the card ground — sets `--page-toolbar-background: hsl(var(--card));` ONCE in its theme. Never `className=\"bg-card\"` at the call site: that is hand-laid page chrome, it paints only under `variant=\"flush\"` gutters correctly by accident, and it cannot be re-themed per tenant. pad-block 0 — the band's ONLY breathing room, and the only one it should ever have. The band is chrome, so it sits FLUSH against the header above and the body below (src/styles/layout.css cancels the container's --page-band-gap from the band itself); there is deliberately no outside space to tune, because a ruled, painted band floating in a void divides nothing. A service that opts into the background or the divider gives the band its inset HERE (`--page-toolbar-pad-block: var(--space-2)`) instead of padding the strip at the call site. Still 0 by default: a transparent band is not a surface and has no inside to breathe, and under `fill` every pixel of it comes straight off the scroll viewport the slot exists to protect. divider — declared `initial` so the CALL SITE can fall back to --page-header-divider (`var(--page-toolbar-divider, var(--page-header-divider))`). One theme declaration therefore rules the whole page chrome consistently, and a scoped [data-tenant]/.dark override of the header divider still reaches the band — a `:root` binding would freeze it at the :root value (docs/TOKENS.md · \"Role-mirror knobs MUST be `initial`\"). Set `--page-toolbar-divider: none` to silence just this band.",
1671
+ "description": "PageContainer `toolbar` — the fixed chrome band between the header and the (scrolling) body. ALL THREE knobs are quiet by default (rule #44): background — `transparent`, i.e. the band shows the page ground exactly as it did before the knob existed. It is bound at `:root` and NOT `initial`, unlike the divider below, because the default is a plain CSS keyword rather than another role token: there is nothing for a scoped [data-tenant]/.dark override to re-resolve, so the `initial` + call-site-fallback dance would buy nothing and only hide the default from anyone reading this file. A service that wants the band to read as its own surface — the design that asked for this knob puts a chat channel's workflow rail on the card ground — sets `--page-toolbar-background: hsl(var(--card));` ONCE in its theme. Never `className=\"bg-card\"` at the call site: that is hand-laid page chrome, it paints only under `variant=\"flush\"` gutters correctly by accident, and it cannot be re-themed per tenant. pad-block — `initial`; the call site falls back to the container's `--page-band-gap` (`var(--page-toolbar-pad-block, var(--page-band-gap))`), which is scoped on `.ui-page-container` and so cannot be bound here. The band's BOX sits FLUSH against the header above and the body below (src/styles/layout.css cancels the container's gap from the band itself — a painted, ruled band floating in a void divides nothing), and this inset gives the band's CONTENT the page's band rhythm back: a plain filter strip sits one band gap from the header and from the body, the same as any other band. At the old `0px` default a transparent strip touched both surfaces, 0px (gh#1323). A service that paints or rules the band sets a tighter inset HERE (`--page-toolbar-pad-block: var(--space-2)`) instead of padding the strip at the call site; `0px` restores the flush strip. divider — declared `initial` so the CALL SITE can fall back to --page-header-divider (`var(--page-toolbar-divider, var(--page-header-divider))`). One theme declaration therefore rules the whole page chrome consistently, and a scoped [data-tenant]/.dark override of the header divider still reaches the band — a `:root` binding would freeze it at the :root value (docs/TOKENS.md · \"Role-mirror knobs MUST be `initial`\"). Set `--page-toolbar-divider: none` to silence just this band.",
1642
1672
  "name": "--page-toolbar-background",
1643
1673
  "tier": "semantic",
1644
1674
  "value": "transparent"
@@ -1647,7 +1677,7 @@
1647
1677
  "description": "Layout primitive tokens: page, section, stack, inline.",
1648
1678
  "name": "--page-toolbar-pad-block",
1649
1679
  "tier": "semantic",
1650
- "value": "0px"
1680
+ "value": "initial"
1651
1681
  },
1652
1682
  {
1653
1683
  "description": "Layout primitive tokens: page, section, stack, inline.",
@@ -2855,6 +2885,90 @@
2855
2885
  "tier": "component",
2856
2886
  "value": "3 3"
2857
2887
  },
2888
+ {
2889
+ "description": "Minimum cell edge per tier. `xs` is still 24px — a cell is a hover/focus target (WCAG 2.5.8).",
2890
+ "name": "--chart-heatmap-cell-size-xs",
2891
+ "tier": "component",
2892
+ "value": "1.5rem"
2893
+ },
2894
+ {
2895
+ "description": "Chart component tokens — the dependency-free `CompactBarTrend` primitive (`@godxjp/ui/charts`). Every piece of its geometry and every fill is a public knob so a service theme can match its own design grid WITHOUT page-local CSS or inline heights (cardinal rules #44/#45). The recharts-backed charts draw their own SVG from the foundation `--chart-1..6` palette, plus the cartesian series knobs at the bottom of this file.",
2896
+ "name": "--chart-heatmap-cell-size-sm",
2897
+ "tier": "component",
2898
+ "value": "1.75rem"
2899
+ },
2900
+ {
2901
+ "description": "Chart component tokens — the dependency-free `CompactBarTrend` primitive (`@godxjp/ui/charts`). Every piece of its geometry and every fill is a public knob so a service theme can match its own design grid WITHOUT page-local CSS or inline heights (cardinal rules #44/#45). The recharts-backed charts draw their own SVG from the foundation `--chart-1..6` palette, plus the cartesian series knobs at the bottom of this file.",
2902
+ "name": "--chart-heatmap-cell-size-md",
2903
+ "tier": "component",
2904
+ "value": "2rem"
2905
+ },
2906
+ {
2907
+ "description": "Chart component tokens — the dependency-free `CompactBarTrend` primitive (`@godxjp/ui/charts`). Every piece of its geometry and every fill is a public knob so a service theme can match its own design grid WITHOUT page-local CSS or inline heights (cardinal rules #44/#45). The recharts-backed charts draw their own SVG from the foundation `--chart-1..6` palette, plus the cartesian series knobs at the bottom of this file.",
2908
+ "name": "--chart-heatmap-cell-size-lg",
2909
+ "tier": "component",
2910
+ "value": "2.5rem"
2911
+ },
2912
+ {
2913
+ "description": "Active tier — chart-layout.css re-points this per `data-size`.",
2914
+ "name": "--chart-heatmap-cell-size",
2915
+ "tier": "component",
2916
+ "value": "var(--chart-heatmap-cell-size-md)"
2917
+ },
2918
+ {
2919
+ "description": "Surface gap between neighbouring cells (the table's border-spacing).",
2920
+ "name": "--chart-heatmap-cell-gap",
2921
+ "tier": "component",
2922
+ "value": "var(--stroke-md)"
2923
+ },
2924
+ {
2925
+ "description": "default = calc(var(--radius) / var(--radius-ratio) / var(--radius-ratio)), resolved at the call site",
2926
+ "name": "--chart-heatmap-cell-radius",
2927
+ "tier": "component",
2928
+ "value": "initial"
2929
+ },
2930
+ {
2931
+ "description": "EMPTY cell outline — the non-colour cue that tells \"no data\" from a zero (WCAG 1.4.1). Role-mirror knob, `initial` at :root; default at the call site = `var(--stroke-hairline) dashed hsl(var(--muted-foreground))`.",
2932
+ "name": "--chart-heatmap-empty-border",
2933
+ "tier": "component",
2934
+ "value": "initial"
2935
+ },
2936
+ {
2937
+ "description": "Top-N emphasis ring — a SHAPE cue, never colour alone. Colour is a role-mirror knob (default `hsl(var(--foreground))` at the call site); width is a plain constant.",
2938
+ "name": "--chart-heatmap-emphasis-border",
2939
+ "tier": "component",
2940
+ "value": "initial"
2941
+ },
2942
+ {
2943
+ "description": "Chart component tokens — the dependency-free `CompactBarTrend` primitive (`@godxjp/ui/charts`). Every piece of its geometry and every fill is a public knob so a service theme can match its own design grid WITHOUT page-local CSS or inline heights (cardinal rules #44/#45). The recharts-backed charts draw their own SVG from the foundation `--chart-1..6` palette, plus the cartesian series knobs at the bottom of this file.",
2944
+ "name": "--chart-heatmap-emphasis-width",
2945
+ "tier": "component",
2946
+ "value": "var(--stroke-md)"
2947
+ },
2948
+ {
2949
+ "description": "The pinned row-header column paints over cells scrolled beneath it, so it needs a ground. Role-mirror knob; default = `hsl(var(--card))` at the call site.",
2950
+ "name": "--chart-heatmap-row-header-background",
2951
+ "tier": "component",
2952
+ "value": "initial"
2953
+ },
2954
+ {
2955
+ "description": "Chart component tokens — the dependency-free `CompactBarTrend` primitive (`@godxjp/ui/charts`). Every piece of its geometry and every fill is a public knob so a service theme can match its own design grid WITHOUT page-local CSS or inline heights (cardinal rules #44/#45). The recharts-backed charts draw their own SVG from the foundation `--chart-1..6` palette, plus the cartesian series knobs at the bottom of this file.",
2956
+ "name": "--chart-heatmap-tick-font-size",
2957
+ "tier": "component",
2958
+ "value": "var( --font-size-xs, calc(var(--font-size-base) / var(--font-size-ratio)) )"
2959
+ },
2960
+ {
2961
+ "description": "Legend rhythm: the swatch edge and the gap between legend items / plot ↔ legend.",
2962
+ "name": "--chart-heatmap-legend-swatch-size",
2963
+ "tier": "component",
2964
+ "value": "var(--space-3)"
2965
+ },
2966
+ {
2967
+ "description": "Chart component tokens — the dependency-free `CompactBarTrend` primitive (`@godxjp/ui/charts`). Every piece of its geometry and every fill is a public knob so a service theme can match its own design grid WITHOUT page-local CSS or inline heights (cardinal rules #44/#45). The recharts-backed charts draw their own SVG from the foundation `--chart-1..6` palette, plus the cartesian series knobs at the bottom of this file.",
2968
+ "name": "--chart-heatmap-legend-gap",
2969
+ "tier": "component",
2970
+ "value": "var(--space-2)"
2971
+ },
2858
2972
  {
2859
2973
  "description": "Bubble fill. `initial` so the --muted default re-resolves at the CALL SITE under a scoped theme; a :root binding to a role var freezes at :root (docs/TOKENS.md — the :root freeze rule). Documented default = hsl(var(--muted)).",
2860
2974
  "name": "--chat-bubble-background",
@@ -3,7 +3,7 @@
3
3
  * alternative (WCAG 1.1.1). No JSX — filename pascal is intentionally NOT
4
4
  * exported, so this is not a catalog primitive.
5
5
  */
6
- import type { ChartDatum, ChartSeriesProp } from "../../props/components/charts.prop.js";
6
+ import type { ChartDatum, ChartSeriesProp, HeatmapCategory, HeatmapDatum } from "../../props/components/charts.prop.js";
7
7
  type Formatter = Intl.NumberFormat;
8
8
  export type ChartSummary = {
9
9
  rows: string[];
@@ -39,4 +39,35 @@ export declare function trendRatios(data: ChartDatum[], valueKey: string): numbe
39
39
  * datum).
40
40
  */
41
41
  export declare function resolveEmphasizedIndex(index: number | undefined, length: number): number;
42
+ /** Number of steps in the `--chart-heat-1..N` ramp. */
43
+ export declare const HEAT_STEPS = 5;
44
+ /** The lookup key of one heatmap cell. */
45
+ export declare function heatmapCellKey(row: string | number, column: string | number): string;
46
+ export type HeatmapModel = {
47
+ /** Finite values by cell key. A key that is absent is an EMPTY cell, never a zero. */
48
+ values: Map<string, number>;
49
+ /** Ramp step 1..HEAT_STEPS by cell key. */
50
+ steps: Map<string, number>;
51
+ /** 1-based rank of the top-N cells by cell key. */
52
+ ranks: Map<string, number>;
53
+ /** Scale domain: `min(0, smallest)` .. largest. */
54
+ min: number;
55
+ max: number;
56
+ /** Count of cells with a value inside the rows × columns matrix. */
57
+ present: number;
58
+ /** Count of matrix cells with no value. */
59
+ empty: number;
60
+ /** The highest cell, for the one-line summary. */
61
+ highest: {
62
+ row: HeatmapCategory;
63
+ column: HeatmapCategory;
64
+ value: number;
65
+ } | null;
66
+ };
67
+ /**
68
+ * Bin a heatmap's cells onto the sequential ramp. The domain starts at zero (or the smallest value,
69
+ * when that is negative) so the fill encodes MAGNITUDE — a heatmap of 900..1000 whose 900 painted
70
+ * as "empty-light" would read as a quiet hour. Cells outside `rows` × `columns` are ignored.
71
+ */
72
+ export declare function buildHeatmapModel(rows: HeatmapCategory[], columns: HeatmapCategory[], data: HeatmapDatum[], topN?: number): HeatmapModel;
42
73
  export {};
@@ -40,11 +40,60 @@ function resolveEmphasizedIndex(index, length) {
40
40
  const resolved = index < 0 ? length + index : index;
41
41
  return resolved >= 0 && resolved < length ? resolved : -1;
42
42
  }
43
+ const HEAT_STEPS = 5;
44
+ function heatmapCellKey(row, column) {
45
+ return `${String(row)}\0${String(column)}`;
46
+ }
47
+ function buildHeatmapModel(rows, columns, data, topN) {
48
+ const rowByKey = new Map(rows.map((row) => [String(row.key), row]));
49
+ const columnByKey = new Map(columns.map((column) => [String(column.key), column]));
50
+ const values = /* @__PURE__ */ new Map();
51
+ const order = [];
52
+ for (const datum of data) {
53
+ const row = rowByKey.get(String(datum.row));
54
+ const column = columnByKey.get(String(datum.column));
55
+ if (!row || !column) continue;
56
+ if (typeof datum.value !== "number" || !Number.isFinite(datum.value)) continue;
57
+ const key = heatmapCellKey(row.key, column.key);
58
+ if (values.has(key)) continue;
59
+ values.set(key, datum.value);
60
+ order.push({ key, value: datum.value, row, column });
61
+ }
62
+ let min = 0;
63
+ let max = 0;
64
+ for (const { value } of order) {
65
+ if (value < min) min = value;
66
+ if (value > max) max = value;
67
+ }
68
+ const steps = /* @__PURE__ */ new Map();
69
+ for (const { key, value } of order) {
70
+ const ratio = max > min ? (value - min) / (max - min) : max > 0 ? 1 : 0;
71
+ steps.set(key, Math.min(HEAT_STEPS, Math.floor(ratio * HEAT_STEPS) + 1));
72
+ }
73
+ const ranked = [...order].sort((a, b) => b.value - a.value);
74
+ const ranks = /* @__PURE__ */ new Map();
75
+ const n = topN !== void 0 && Number.isInteger(topN) && topN > 0 ? topN : 0;
76
+ ranked.slice(0, n).forEach((cell, index) => ranks.set(cell.key, index + 1));
77
+ const top = ranked[0];
78
+ return {
79
+ values,
80
+ steps,
81
+ ranks,
82
+ min,
83
+ max,
84
+ present: order.length,
85
+ empty: rows.length * columns.length - order.length,
86
+ highest: top ? { row: top.row, column: top.column, value: top.value } : null
87
+ };
88
+ }
43
89
  export {
90
+ HEAT_STEPS,
44
91
  buildCartesianSummary,
92
+ buildHeatmapModel,
45
93
  buildPieSummary,
46
94
  buildSeriesRows,
47
95
  buildTrendSummary,
96
+ heatmapCellKey,
48
97
  resolveEmphasizedIndex,
49
98
  trendRatios
50
99
  };
@@ -0,0 +1,19 @@
1
+ import * as React from "react";
2
+ import type { HeatmapChartProp } from "../../props/components/charts.prop.js";
3
+ export type { HeatmapChartProp, HeatmapChartProp as HeatmapChartProps, HeatmapCategory, HeatmapDatum, } from "../../props/components/charts.prop.js";
4
+ /**
5
+ * HeatmapChart — a **dependency-free** rows × columns matrix (weekday × hour revenue, order counts
6
+ * per slot). Cell fill steps through the sequential `--chart-heat-1..5` ramp.
7
+ *
8
+ * - **One structure for sight and for AT.** The matrix IS a native data table (`<th scope>` headers,
9
+ * one cell per slot) with `role="grid"`, so the screen-reader text alternative is the same element
10
+ * the eye reads — no second, drifting copy. Each cell carries its formatted value as text.
11
+ * - **Keyboard** — WAI-ARIA APG data grid: one tab stop (roving tabindex), arrows move between
12
+ * cells (mirrored under RTL), Home/End go to the row's ends, Ctrl+Home/End to the first/last
13
+ * cell. A focused or hovered cell shows ONE shared tooltip; Escape dismisses it.
14
+ * - **Never colour alone** — an empty cell is outlined (dashed) and reads "no data"; a zero is a
15
+ * filled step-1 cell that reads "0"; a top-N cell wears a ring and reads "top N".
16
+ * - **Narrow screens** — the matrix never shrinks a cell below its `size` tier; it scrolls inside
17
+ * its own box with the row header pinned, so the page never scrolls sideways.
18
+ */
19
+ export declare function HeatmapChart({ rows, columns, data, label, showCaption, description, size, numberFormat, topN, showLegend, emptyMessage, className, id, ref, }: HeatmapChartProp): React.JSX.Element;
@@ -0,0 +1,293 @@
1
+ "use client";
2
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
3
+ import * as React from "react";
4
+ import { cn } from "../../lib/utils.js";
5
+ import { EmptyState } from "../data-display/empty-state.js";
6
+ import { Tooltip, TooltipAnchorContext, TooltipContent } from "../feedback/tooltip.js";
7
+ import { useTranslation } from "../../i18n/use-translation.js";
8
+ import { useChartNumberFormat } from "./chart-frame.js";
9
+ import { HEAT_STEPS, buildHeatmapModel, heatmapCellKey } from "./chart-summary.js";
10
+ const OPEN_DELAY = 200;
11
+ const CLOSE_GRACE = 100;
12
+ const RAMP = Array.from({ length: HEAT_STEPS }, (_, index) => index + 1);
13
+ function HeatmapChart({
14
+ rows,
15
+ columns,
16
+ data,
17
+ label,
18
+ showCaption = true,
19
+ description,
20
+ size = "md",
21
+ numberFormat,
22
+ topN,
23
+ showLegend = true,
24
+ emptyMessage,
25
+ className,
26
+ id,
27
+ ref
28
+ }) {
29
+ const { t } = useTranslation();
30
+ const fmt = useChartNumberFormat(numberFormat);
31
+ const countFmt = useChartNumberFormat();
32
+ const model = React.useMemo(
33
+ () => buildHeatmapModel(rows, columns, data, topN),
34
+ [rows, columns, data, topN]
35
+ );
36
+ const hasData = rows.length > 0 && columns.length > 0 && model.present > 0;
37
+ const reactId = React.useId();
38
+ const baseId = id ?? reactId;
39
+ const titleId = `${baseId}-title`;
40
+ const descId = `${baseId}-desc`;
41
+ const [active, setActive] = React.useState([0, 0]);
42
+ const activeRow = Math.min(active[0], Math.max(0, rows.length - 1));
43
+ const activeColumn = Math.min(active[1], Math.max(0, columns.length - 1));
44
+ const scrollRef = React.useRef(null);
45
+ const cellRefs = React.useRef(/* @__PURE__ */ new Map());
46
+ const anchorRef = React.useRef(null);
47
+ const timer = React.useRef(void 0);
48
+ const [tip, setTip] = React.useState(null);
49
+ React.useEffect(() => () => clearTimeout(timer.current), []);
50
+ const showTip = (r, c, node, delay) => {
51
+ clearTimeout(timer.current);
52
+ timer.current = setTimeout(() => {
53
+ anchorRef.current = node;
54
+ setTip([r, c]);
55
+ }, delay);
56
+ };
57
+ const hideTip = () => {
58
+ clearTimeout(timer.current);
59
+ timer.current = setTimeout(() => setTip(null), CLOSE_GRACE);
60
+ };
61
+ React.useEffect(() => {
62
+ if (!tip) return void 0;
63
+ const onKeyDown = (event) => {
64
+ if (event.key === "Escape") {
65
+ clearTimeout(timer.current);
66
+ setTip(null);
67
+ }
68
+ };
69
+ document.addEventListener("keydown", onKeyDown);
70
+ return () => document.removeEventListener("keydown", onKeyDown);
71
+ }, [tip]);
72
+ const reveal = (cell) => {
73
+ const scroller = scrollRef.current;
74
+ const header = cell.parentElement?.querySelector("th");
75
+ if (!scroller || !header) return;
76
+ const cellBox = cell.getBoundingClientRect();
77
+ const headerBox = header.getBoundingClientRect();
78
+ if (getComputedStyle(scroller).direction === "rtl") {
79
+ if (cellBox.right > headerBox.left) scroller.scrollLeft += cellBox.right - headerBox.left;
80
+ } else if (cellBox.left < headerBox.right) {
81
+ scroller.scrollLeft -= headerBox.right - cellBox.left;
82
+ }
83
+ };
84
+ const moveTo = (r, c) => {
85
+ const row = Math.max(0, Math.min(rows.length - 1, r));
86
+ const column = Math.max(0, Math.min(columns.length - 1, c));
87
+ setActive([row, column]);
88
+ const cell = cellRefs.current.get(`${row}:${column}`);
89
+ if (cell) {
90
+ cell.focus();
91
+ reveal(cell);
92
+ }
93
+ };
94
+ const onGridKeyDown = (event) => {
95
+ const rtl = getComputedStyle(event.currentTarget).direction === "rtl";
96
+ const lastRow = rows.length - 1;
97
+ const lastColumn = columns.length - 1;
98
+ const ctrl = event.ctrlKey || event.metaKey;
99
+ switch (event.key) {
100
+ case "ArrowRight":
101
+ moveTo(activeRow, activeColumn + (rtl ? -1 : 1));
102
+ break;
103
+ case "ArrowLeft":
104
+ moveTo(activeRow, activeColumn + (rtl ? 1 : -1));
105
+ break;
106
+ case "ArrowDown":
107
+ moveTo(activeRow + 1, activeColumn);
108
+ break;
109
+ case "ArrowUp":
110
+ moveTo(activeRow - 1, activeColumn);
111
+ break;
112
+ case "Home":
113
+ moveTo(ctrl ? 0 : activeRow, 0);
114
+ break;
115
+ case "End":
116
+ moveTo(ctrl ? lastRow : activeRow, lastColumn);
117
+ break;
118
+ case "Escape":
119
+ clearTimeout(timer.current);
120
+ setTip(null);
121
+ return;
122
+ default:
123
+ return;
124
+ }
125
+ event.preventDefault();
126
+ };
127
+ const cellText = (r, c) => {
128
+ const key = heatmapCellKey(rows[r].key, columns[c].key);
129
+ const value = model.values.get(key);
130
+ const rank = model.ranks.get(key);
131
+ return {
132
+ value: value === void 0 ? t("chart.heatmapNoData") : fmt.format(value),
133
+ top: rank === void 0 ? void 0 : t("chart.heatmapTop", { rank: countFmt.format(rank) })
134
+ };
135
+ };
136
+ const summary = model.highest ? `${t("chart.heatmapSummary", {
137
+ rows: countFmt.format(rows.length),
138
+ columns: countFmt.format(columns.length)
139
+ })} ${t("chart.heatmapHighest", {
140
+ value: fmt.format(model.highest.value),
141
+ row: model.highest.row.label,
142
+ column: model.highest.column.label
143
+ })}` : "";
144
+ const tipText = tip && tip[0] < rows.length && tip[1] < columns.length ? cellText(tip[0], tip[1]) : null;
145
+ const span = model.max - model.min;
146
+ const bound = (step) => model.min + span * step / HEAT_STEPS;
147
+ return /* @__PURE__ */ jsxs(
148
+ "figure",
149
+ {
150
+ ref,
151
+ id,
152
+ "data-slot": "chart",
153
+ "data-size": size,
154
+ className: cn("ui-chart ui-chart-heatmap", className),
155
+ "aria-labelledby": titleId,
156
+ "aria-describedby": descId,
157
+ children: [
158
+ /* @__PURE__ */ jsx("figcaption", { id: titleId, className: showCaption ? "ui-chart-title" : "sr-only", children: label }),
159
+ hasData ? /* @__PURE__ */ jsxs(Fragment, { children: [
160
+ /* @__PURE__ */ jsx("div", { ref: scrollRef, className: "ui-chart-heatmap-scroll", children: /* @__PURE__ */ jsxs(
161
+ "table",
162
+ {
163
+ role: "grid",
164
+ "aria-labelledby": titleId,
165
+ className: "ui-chart-heatmap-grid",
166
+ onKeyDown: onGridKeyDown,
167
+ children: [
168
+ /* @__PURE__ */ jsx("thead", { children: /* @__PURE__ */ jsxs("tr", { children: [
169
+ /* @__PURE__ */ jsx("td", { className: "ui-chart-heatmap-corner" }),
170
+ columns.map((column) => /* @__PURE__ */ jsx(
171
+ "th",
172
+ {
173
+ scope: "col",
174
+ className: "ui-chart-heatmap-column-header",
175
+ children: column.label
176
+ },
177
+ String(column.key)
178
+ ))
179
+ ] }) }),
180
+ /* @__PURE__ */ jsx("tbody", { children: rows.map((row, r) => /* @__PURE__ */ jsxs("tr", { children: [
181
+ /* @__PURE__ */ jsx("th", { scope: "row", className: "ui-chart-heatmap-row-header", children: row.label }),
182
+ columns.map((column, c) => {
183
+ const key = heatmapCellKey(row.key, column.key);
184
+ const empty = !model.values.has(key);
185
+ const text = cellText(r, c);
186
+ return /* @__PURE__ */ jsx(
187
+ "td",
188
+ {
189
+ role: "gridcell",
190
+ ref: (node) => {
191
+ if (node) cellRefs.current.set(`${r}:${c}`, node);
192
+ else cellRefs.current.delete(`${r}:${c}`);
193
+ },
194
+ tabIndex: r === activeRow && c === activeColumn ? 0 : -1,
195
+ className: "ui-chart-heatmap-cell ui-focus-ring",
196
+ "data-step": empty ? void 0 : model.steps.get(key),
197
+ "data-empty": empty ? "" : void 0,
198
+ "data-emphasized": model.ranks.has(key) ? "" : void 0,
199
+ onFocus: (event) => {
200
+ setActive([r, c]);
201
+ showTip(r, c, event.currentTarget, 0);
202
+ },
203
+ onBlur: hideTip,
204
+ onPointerEnter: (event) => showTip(r, c, event.currentTarget, OPEN_DELAY),
205
+ onPointerLeave: hideTip,
206
+ children: /* @__PURE__ */ jsx("span", { className: "sr-only", children: text.top ? `${text.value}, ${text.top}` : text.value })
207
+ },
208
+ String(column.key)
209
+ );
210
+ })
211
+ ] }, String(row.key))) })
212
+ ]
213
+ }
214
+ ) }),
215
+ showLegend ? /* @__PURE__ */ jsxs("div", { className: "ui-chart-heatmap-legend", children: [
216
+ /* @__PURE__ */ jsxs("div", { className: "ui-chart-heatmap-legend-scale", children: [
217
+ /* @__PURE__ */ jsx("span", { className: "ui-chart-heatmap-legend-label", "aria-hidden": "true", children: fmt.format(model.min) }),
218
+ /* @__PURE__ */ jsx(
219
+ "ul",
220
+ {
221
+ role: "list",
222
+ className: "ui-chart-heatmap-legend-ramp",
223
+ "aria-label": t("chart.heatmapLegend"),
224
+ children: RAMP.map((step) => /* @__PURE__ */ jsx("li", { className: "ui-chart-heatmap-legend-swatch", "data-step": step, children: /* @__PURE__ */ jsx("span", { className: "sr-only", children: fmt.formatRange(bound(step - 1), bound(step)) }) }, step))
225
+ }
226
+ ),
227
+ /* @__PURE__ */ jsx("span", { className: "ui-chart-heatmap-legend-label", "aria-hidden": "true", children: fmt.format(model.max) })
228
+ ] }),
229
+ model.empty > 0 ? /* @__PURE__ */ jsxs("span", { className: "ui-chart-heatmap-legend-item", children: [
230
+ /* @__PURE__ */ jsx(
231
+ "span",
232
+ {
233
+ className: "ui-chart-heatmap-legend-swatch",
234
+ "data-empty": "",
235
+ "aria-hidden": "true"
236
+ }
237
+ ),
238
+ t("chart.heatmapNoData")
239
+ ] }) : null,
240
+ model.ranks.size > 0 ? /* @__PURE__ */ jsxs("span", { className: "ui-chart-heatmap-legend-item", children: [
241
+ /* @__PURE__ */ jsx(
242
+ "span",
243
+ {
244
+ className: "ui-chart-heatmap-legend-swatch",
245
+ "data-step": HEAT_STEPS,
246
+ "data-emphasized": "",
247
+ "aria-hidden": "true"
248
+ }
249
+ ),
250
+ t("chart.heatmapLegendTop", { count: countFmt.format(model.ranks.size) })
251
+ ] }) : null
252
+ ] }) : null
253
+ ] }) : /* @__PURE__ */ jsx(EmptyState, { title: emptyMessage ?? t("chart.empty") }),
254
+ /* @__PURE__ */ jsxs("p", { id: descId, className: "sr-only", children: [
255
+ description ? /* @__PURE__ */ jsxs(Fragment, { children: [
256
+ description,
257
+ " "
258
+ ] }) : null,
259
+ hasData ? summary : null
260
+ ] }),
261
+ /* @__PURE__ */ jsx(
262
+ Tooltip,
263
+ {
264
+ open: tip !== null,
265
+ onOpenChange: (next) => {
266
+ if (!next) setTip(null);
267
+ },
268
+ children: /* @__PURE__ */ jsx(TooltipAnchorContext.Provider, { value: anchorRef, children: tip && tipText ? /* @__PURE__ */ jsxs(
269
+ TooltipContent,
270
+ {
271
+ className: "ui-chart-heatmap-tooltip",
272
+ onPointerEnter: () => clearTimeout(timer.current),
273
+ onPointerLeave: hideTip,
274
+ children: [
275
+ /* @__PURE__ */ jsx("span", { className: "ui-chart-heatmap-tooltip-title", children: t("chart.heatmapCell", {
276
+ row: rows[tip[0]]?.label ?? "",
277
+ column: columns[tip[1]]?.label ?? "",
278
+ value: tipText.value
279
+ }) }),
280
+ tipText.top ? /* @__PURE__ */ jsx("span", { className: "ui-chart-heatmap-tooltip-note", children: tipText.top }) : null
281
+ ]
282
+ },
283
+ `${tip[0]}:${tip[1]}`
284
+ ) : null })
285
+ }
286
+ )
287
+ ]
288
+ }
289
+ );
290
+ }
291
+ export {
292
+ HeatmapChart
293
+ };
@@ -9,6 +9,9 @@ export type { BarChartProp, BarChartProps } from "./bar-chart.js";
9
9
  */
10
10
  export { CompactBarTrend } from "./compact-bar-trend.js";
11
11
  export type { CompactBarTrendProp, CompactBarTrendProps } from "./compact-bar-trend.js";
12
+ /** Dependency-free rows × columns heatmap — isolated entry `@godxjp/ui/charts/heatmap-chart`. */
13
+ export { HeatmapChart } from "./heatmap-chart.js";
14
+ export type { HeatmapChartProp, HeatmapChartProps, HeatmapCategory, HeatmapDatum, } from "./heatmap-chart.js";
12
15
  export { AreaChart } from "./area-chart.js";
13
16
  export type { AreaChartProp, AreaChartProps } from "./area-chart.js";
14
17
  export { PieChart } from "./pie-chart.js";
@@ -1,6 +1,7 @@
1
1
  import { LineChart } from "./line-chart.js";
2
2
  import { BarChart } from "./bar-chart.js";
3
3
  import { CompactBarTrend } from "./compact-bar-trend.js";
4
+ import { HeatmapChart } from "./heatmap-chart.js";
4
5
  import { AreaChart } from "./area-chart.js";
5
6
  import { PieChart } from "./pie-chart.js";
6
7
  import { CHART_COLORS } from "./chart-frame.js";
@@ -9,6 +10,7 @@ export {
9
10
  BarChart,
10
11
  CHART_COLORS,
11
12
  CompactBarTrend,
13
+ HeatmapChart,
12
14
  LineChart,
13
15
  PieChart
14
16
  };
@@ -1,6 +1,15 @@
1
1
  import * as React from "react";
2
2
  type Side = "top" | "right" | "bottom" | "left";
3
3
  type Align = "start" | "center" | "end";
4
+ /**
5
+ * Anchors `TooltipContent` to an element that is NOT a `TooltipTrigger` (gh#1045). `TooltipTrigger`
6
+ * wraps its child in RAC's `Focusable`, which stamps `tabIndex=0` on it — right for a button, wrong for a truncated `Text` inside a `treeitem`, where it
7
+ * would add a second tab stop. The ellipsis tooltip drives `open` itself and hands its own element
8
+ * here as the positioning target (RAC `Tooltip`'s `triggerRef`). HeatmapChart (gh#1330) does the
9
+ * same with ONE tooltip for its whole grid, anchored to the active cell. Module-internal: not in
10
+ * the feedback index.
11
+ */
12
+ export declare const TooltipAnchorContext: React.Context<React.RefObject<Element | null> | null>;
4
13
  interface TooltipProviderProps {
5
14
  /** Delay mở dùng chung cho mọi `Tooltip` bên trong, tính bằng ms. */
6
15
  delayDuration?: number;