@squadbase/vantage 0.7.0 → 0.7.1-dev1.0d13f9a

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 (101) hide show
  1. package/README.md +1 -1
  2. package/dist/add-AU3EDPNW.js +6 -0
  3. package/dist/add-AU3EDPNW.js.map +1 -0
  4. package/dist/{chunk-AABMKHEU.js → chunk-35B3YIBF.js} +16 -7
  5. package/dist/chunk-35B3YIBF.js.map +1 -0
  6. package/dist/{chunk-QNOX7WLZ.js → chunk-5HBUBVQ3.js} +94 -8
  7. package/dist/chunk-5HBUBVQ3.js.map +1 -0
  8. package/dist/{chunk-EG4PX726.js → chunk-GWXKENNH.js} +122 -5
  9. package/dist/chunk-GWXKENNH.js.map +1 -0
  10. package/dist/{chunk-77CISBMG.js → chunk-I2GWIPTD.js} +3685 -350
  11. package/dist/chunk-I2GWIPTD.js.map +1 -0
  12. package/dist/{chunk-TRMHNMMZ.js → chunk-LYUGDHSF.js} +3 -3
  13. package/dist/{chunk-TRMHNMMZ.js.map → chunk-LYUGDHSF.js.map} +1 -1
  14. package/dist/{chunk-2IAPIOPS.js → chunk-U6MWBCM6.js} +28 -7
  15. package/dist/chunk-U6MWBCM6.js.map +1 -0
  16. package/dist/{chunk-3RJEOP3J.js → chunk-XRKSIC5Z.js} +5 -3
  17. package/dist/chunk-XRKSIC5Z.js.map +1 -0
  18. package/dist/cli.js +2 -2
  19. package/dist/components/index.d.ts +849 -12
  20. package/dist/components/index.js +5 -5
  21. package/dist/{delta-text-CbrOaW83.d.ts → delta-text-tav2Lh5d.d.ts} +1 -1
  22. package/dist/doc/index.d.ts +2 -2
  23. package/dist/doc/index.js +4 -4
  24. package/dist/{format-DihQkL1N.d.ts → format-D34piCei.d.ts} +2 -1
  25. package/dist/geo-japan-7BMSOJRX.js +8 -0
  26. package/dist/geo-japan-7BMSOJRX.js.map +1 -0
  27. package/dist/geo-us-GVOG7VXH.js +8 -0
  28. package/dist/geo-us-GVOG7VXH.js.map +1 -0
  29. package/dist/geo-world-35HL56UU.js +8 -0
  30. package/dist/geo-world-35HL56UU.js.map +1 -0
  31. package/dist/slide/index.d.ts +2 -2
  32. package/dist/slide/index.js +4 -4
  33. package/dist/table/index.js +5 -6
  34. package/dist/table/index.js.map +1 -1
  35. package/dist/ui/index.d.ts +35 -4
  36. package/dist/ui/index.js +174 -11
  37. package/dist/ui/index.js.map +1 -1
  38. package/dist/{upgrade-6DT7ADRP.js → upgrade-MCHMELWC.js} +3 -3
  39. package/dist/{upgrade-6DT7ADRP.js.map → upgrade-MCHMELWC.js.map} +1 -1
  40. package/docs/en/agent-skills.md +13 -14
  41. package/docs/en/changelog.md +8 -0
  42. package/docs/en/parts/draft-chart.md +42 -0
  43. package/docs/en/parts/page-shell.md +1 -1
  44. package/docs/index.json +70 -50
  45. package/docs/ja/agent-skills.md +14 -14
  46. package/docs/ja/changelog.md +8 -0
  47. package/docs/ja/parts/draft-chart.md +40 -0
  48. package/docs/ja/parts/page-shell.md +1 -1
  49. package/package.json +2 -2
  50. package/registry/parts.json +5319 -0
  51. package/registry/ui/area-chart.tsx +43 -8
  52. package/registry/ui/bar-chart.tsx +43 -8
  53. package/registry/ui/bar-list.tsx +43 -8
  54. package/registry/ui/box-plot-chart.tsx +42 -7
  55. package/registry/ui/category-bar.tsx +25 -5
  56. package/registry/ui/chart-frame.tsx +55 -14
  57. package/registry/ui/checkbox.tsx +4 -3
  58. package/registry/ui/code-block.tsx +44 -0
  59. package/registry/ui/combo-chart.tsx +1869 -0
  60. package/registry/ui/combobox.tsx +7 -0
  61. package/registry/ui/context-menu.tsx +71 -0
  62. package/registry/ui/dashboard-card.tsx +23 -10
  63. package/registry/ui/data-table.tsx +10 -2
  64. package/registry/ui/date-range-picker.tsx +51 -4
  65. package/registry/ui/delta.tsx +39 -12
  66. package/registry/ui/donut-chart.tsx +43 -8
  67. package/registry/ui/echart.tsx +167 -7
  68. package/registry/ui/file-dropzone.tsx +143 -0
  69. package/registry/ui/filter-bar.tsx +77 -2
  70. package/registry/ui/forecast-chart.tsx +4769 -0
  71. package/registry/ui/funnel-steps.tsx +23 -5
  72. package/registry/ui/heatmap.tsx +20 -3
  73. package/registry/ui/histogram-chart.tsx +42 -7
  74. package/registry/ui/kpi-card.tsx +15 -9
  75. package/registry/ui/legend.tsx +73 -15
  76. package/registry/ui/line-chart.tsx +43 -8
  77. package/registry/ui/metric-value.tsx +2 -2
  78. package/registry/ui/page-shell.tsx +27 -4
  79. package/registry/ui/paired-chart.tsx +44 -9
  80. package/registry/ui/pivot-table.tsx +1260 -0
  81. package/registry/ui/progress-circle.tsx +2 -2
  82. package/registry/ui/progress.tsx +1 -1
  83. package/registry/ui/scatter-chart.tsx +50 -12
  84. package/registry/ui/sparkline.tsx +44 -9
  85. package/registry/ui/states.tsx +34 -1
  86. package/registry/ui/status-badge.tsx +7 -7
  87. package/registry/ui/tabs.tsx +1 -0
  88. package/registry/ui/treemap.tsx +1934 -0
  89. package/registry/ui/waterfall-chart.tsx +64 -11
  90. package/templates/AGENTS.md +3 -3
  91. package/templates/agents/page.md +5 -4
  92. package/dist/add-4GHF2TX2.js +0 -6
  93. package/dist/add-4GHF2TX2.js.map +0 -1
  94. package/dist/chunk-2IAPIOPS.js.map +0 -1
  95. package/dist/chunk-3RJEOP3J.js.map +0 -1
  96. package/dist/chunk-77CISBMG.js.map +0 -1
  97. package/dist/chunk-AABMKHEU.js.map +0 -1
  98. package/dist/chunk-EG4PX726.js.map +0 -1
  99. package/dist/chunk-NZ5QIIG4.js +0 -74
  100. package/dist/chunk-NZ5QIIG4.js.map +0 -1
  101. package/dist/chunk-QNOX7WLZ.js.map +0 -1
@@ -29,6 +29,7 @@ function ComboboxTrigger({
29
29
  return (
30
30
  <ComboboxPrimitive.Trigger
31
31
  data-slot="combobox-trigger"
32
+ aria-label="Toggle options"
32
33
  className={cn("[&_svg:not([class*='size-'])]:size-4", className)}
33
34
  {...props}
34
35
  >
@@ -44,6 +45,7 @@ function ComboboxClear({ className, ...props }: ComboboxPrimitive.Clear.Props) {
44
45
  return (
45
46
  <ComboboxPrimitive.Clear
46
47
  data-slot="combobox-clear"
48
+ aria-label="Clear"
47
49
  render={<InputGroupButton variant="ghost" size="icon-xs" />}
48
50
  className={cn(className)}
49
51
  {...props}
@@ -263,6 +265,11 @@ function ComboboxChip({
263
265
  render={<Button variant="ghost" size="icon-xs" />}
264
266
  className="-ml-1 opacity-50 hover:opacity-100"
265
267
  data-slot="combobox-chip-remove"
268
+ aria-label={
269
+ typeof children === "string" || typeof children === "number"
270
+ ? `Remove ${children}`
271
+ : "Remove"
272
+ }
266
273
  >
267
274
  <XIcon
268
275
  className="pointer-events-none"
@@ -0,0 +1,71 @@
1
+ // Copied by `vantage add ui context-menu`. This file is yours to edit.
2
+ // Generated from @squadbase/squadkit registry/ui/context-menu.tsx by `pnpm run registry` (in Vantage, edit packages/squadkit, not registry/).
3
+ "use client"
4
+
5
+ import * as React from "react"
6
+ import { ContextMenu as ContextMenuPrimitive } from "@base-ui/react/context-menu"
7
+
8
+ import { cn } from "@squadbase/vantage/ui"
9
+ import {
10
+ DropdownMenuCheckboxItem,
11
+ DropdownMenuContent,
12
+ DropdownMenuGroup,
13
+ DropdownMenuItem,
14
+ DropdownMenuLabel,
15
+ DropdownMenuRadioGroup,
16
+ DropdownMenuRadioItem,
17
+ DropdownMenuSeparator,
18
+ DropdownMenuShortcut,
19
+ DropdownMenuSub,
20
+ DropdownMenuSubContent,
21
+ DropdownMenuSubTrigger,
22
+ } from "@squadbase/vantage/ui"
23
+
24
+ function ContextMenu({ ...props }: ContextMenuPrimitive.Root.Props) {
25
+ return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />
26
+ }
27
+
28
+ /** The area that opens the menu on right-click (long-press on touch). */
29
+ function ContextMenuTrigger({ className, ...props }: ContextMenuPrimitive.Trigger.Props) {
30
+ return (
31
+ <ContextMenuPrimitive.Trigger
32
+ data-slot="context-menu-trigger"
33
+ className={cn("select-none", className)}
34
+ {...props}
35
+ />
36
+ )
37
+ }
38
+
39
+ // The popup is the same as DropdownMenu's (same look); only the anchor differs: the pointer, not a button.
40
+ function ContextMenuContent({
41
+ className,
42
+ ...props
43
+ }: React.ComponentProps<typeof DropdownMenuContent>) {
44
+ return (
45
+ <DropdownMenuContent
46
+ data-slot="context-menu-content"
47
+ align="start"
48
+ side="right"
49
+ sideOffset={0}
50
+ className={cn("w-auto", className)}
51
+ {...props}
52
+ />
53
+ )
54
+ }
55
+
56
+ export {
57
+ ContextMenu,
58
+ ContextMenuTrigger,
59
+ ContextMenuContent,
60
+ DropdownMenuGroup as ContextMenuGroup,
61
+ DropdownMenuLabel as ContextMenuLabel,
62
+ DropdownMenuItem as ContextMenuItem,
63
+ DropdownMenuCheckboxItem as ContextMenuCheckboxItem,
64
+ DropdownMenuRadioGroup as ContextMenuRadioGroup,
65
+ DropdownMenuRadioItem as ContextMenuRadioItem,
66
+ DropdownMenuSeparator as ContextMenuSeparator,
67
+ DropdownMenuShortcut as ContextMenuShortcut,
68
+ DropdownMenuSub as ContextMenuSub,
69
+ DropdownMenuSubTrigger as ContextMenuSubTrigger,
70
+ DropdownMenuSubContent as ContextMenuSubContent,
71
+ }
@@ -3,7 +3,9 @@
3
3
  "use client"
4
4
 
5
5
  /**
6
- * DashboardCard — the tighter card used for dashboard tiles: smaller padding
6
+ * DashboardCardPreset — a card (title, description, action, content) for one tile that holds a chart, ranking or table; not for a single KPI, since `KpiCard` is itself a card.
7
+ *
8
+ * `DashboardCard` is the tighter card used for dashboard tiles: smaller padding
7
9
  * than the plain `Card`, a title/description/action header, and a matching
8
10
  * skeleton for the loading state. `DashboardCardPreset` wires the common
9
11
  * arrangement in one prop-driven component.
@@ -127,7 +129,7 @@ export const DashboardCardFooter = forwardRef<HTMLDivElement, DashboardCardFoote
127
129
  <div
128
130
  ref={ref}
129
131
  data-slot="dashboard-card-footer"
130
- className={cn("flex items-center px-3 py-1.5 group-data-[size=sm]:px-2", className)}
132
+ className={cn("-mx-1 -mb-1 flex min-w-0 items-center justify-between gap-3 border-t px-4 py-2.5 text-xs text-muted-foreground tabular-nums group-data-[size=sm]:px-3", className)}
131
133
  {...props}
132
134
  />
133
135
  ),
@@ -159,7 +161,10 @@ export interface DashboardCardPresetProps
159
161
  Pick<DashboardCardProps, "subgrid" | "size"> {
160
162
  title?: ReactNode
161
163
  description?: ReactNode
164
+ /** One headline number or status at the right end of the header ("212 tasks", a `StatusBadge`). Put the number in `<span className="font-medium text-foreground">`. */
165
+ headline?: ReactNode
162
166
  actions?: ReactNode
167
+ /** One line at the bottom of the card: a summary fact on the left, an action or hint on the right. */
163
168
  footer?: ReactNode
164
169
  headerClassName?: string
165
170
  contentClassName?: string
@@ -169,6 +174,7 @@ export interface DashboardCardPresetProps
169
174
  export function DashboardCardPreset({
170
175
  title,
171
176
  description,
177
+ headline,
172
178
  actions,
173
179
  footer,
174
180
  children,
@@ -181,21 +187,28 @@ export function DashboardCardPreset({
181
187
  }: DashboardCardPresetProps) {
182
188
  // Inside SampleData the card shows the mark in its header, and the parts inside it do not repeat it.
183
189
  const sample = useSampleMark()
184
- const hasHeader = title || description || actions || sample
190
+ const hasTitle = Boolean(title || description)
191
+ // A card with no title of its own (the chart inside has it) floats the mark in the corner instead of adding a row of its own.
192
+ const floatingMark = sample && !hasTitle && !headline && !actions
193
+ const hasHeader = hasTitle || headline || actions || (sample && !floatingMark)
185
194
  return (
186
- <DashboardCard className={className} subgrid={subgrid} {...props}>
195
+ <DashboardCard className={cn(floatingMark && "relative", className)} subgrid={subgrid} {...props}>
187
196
  <SampleClaim>
197
+ {floatingMark && <SampleBadge label={sample.label} className="absolute top-2 right-2 z-10" />}
188
198
  {hasHeader && (
189
199
  <DashboardCardHeader className={cn(subgrid && "row-start-1", headerClassName)}>
190
- {(title || description) && (
191
- <div>
192
- {title && <DashboardCardTitle>{title}</DashboardCardTitle>}
200
+ {(hasTitle || sample) && (
201
+ <div className="min-w-0">
202
+ <div className="flex flex-wrap items-center gap-x-2 gap-y-1">
203
+ {title && <DashboardCardTitle>{title}</DashboardCardTitle>}
204
+ {sample && <SampleBadge label={sample.label} />}
205
+ </div>
193
206
  {description && <DashboardCardDescription>{description}</DashboardCardDescription>}
194
207
  </div>
195
208
  )}
196
- {(actions || sample) && (
197
- <DashboardCardAction className={cn(sample && actions && "flex items-center gap-2")}>
198
- {sample && <SampleBadge label={sample.label} />}
209
+ {(headline || actions) && (
210
+ <DashboardCardAction className="flex items-center gap-2">
211
+ {headline && <div data-slot="dashboard-card-headline" className="text-sm tabular-nums text-muted-foreground">{headline}</div>}
199
212
  {actions}
200
213
  </DashboardCardAction>
201
214
  )}
@@ -3,7 +3,9 @@
3
3
  "use client"
4
4
 
5
5
  /**
6
- * DataTable — a compound table over TanStack Table. `<DataTable>` owns the
6
+ * DataTablePreset — a table (toolbar, body, pagination) over TanStack Table.
7
+ *
8
+ * `DataTable` is the compound form: `<DataTable>` owns the
7
9
  * table instance and publishes it on context; the toolbar, body, pagination and
8
10
  * column-visibility parts read from that context, so a page composes only the
9
11
  * pieces it wants. `DataTablePreset` assembles the usual arrangement.
@@ -309,6 +311,8 @@ const TEXT = {
309
311
  trend: (points: number, first: string, last: string, min: string, max: string) =>
310
312
  `${points}点の推移、${first}から${last}、最小${min}、最大${max}`,
311
313
  noData: "データがありません",
314
+ search: "検索",
315
+ pageSizeLabel: "1ページの表示件数",
312
316
  pageSize: (size: number) => `${size}件`,
313
317
  range: (total: number, start: number, end: number) => `${total}件中 ${start} - ${end} を表示`,
314
318
  first: "最初",
@@ -326,6 +330,8 @@ const TEXT = {
326
330
  trend: (points: number, first: string, last: string, min: string, max: string) =>
327
331
  `Trend over ${points} points: ${first} to ${last}, min ${min}, max ${max}`,
328
332
  noData: "No results.",
333
+ search: "Search",
334
+ pageSizeLabel: "Rows per page",
329
335
  pageSize: (size: number) => `${size} / page`,
330
336
  range: (total: number, start: number, end: number) => `Showing ${start}–${end} of ${total}`,
331
337
  first: "First",
@@ -918,6 +924,7 @@ export const DataTableToolbar = forwardRef<HTMLDivElement, DataTableToolbarProps
918
924
  ({ showSearch, toolbar, bulkActions, className, ...props }, ref) => {
919
925
  const { globalFilter, setGlobalFilter, hasSelection, selectedRows, enableFiltering } =
920
926
  useDataTable()
927
+ const t = useText()
921
928
 
922
929
  const showSearchEffective = showSearch ?? enableFiltering
923
930
 
@@ -936,6 +943,7 @@ export const DataTableToolbar = forwardRef<HTMLDivElement, DataTableToolbarProps
936
943
  <div className="relative">
937
944
  <Search className="absolute top-2.5 left-2.5 size-4 text-muted-foreground" />
938
945
  <Input
946
+ aria-label={t.search}
939
947
  value={globalFilter}
940
948
  onChange={(e) => setGlobalFilter(e.target.value)}
941
949
  className="max-w-sm pl-8"
@@ -1205,7 +1213,7 @@ export const DataTablePagination = forwardRef<HTMLDivElement, DataTablePaginatio
1205
1213
  value={String(currentPageSize)}
1206
1214
  onValueChange={(value) => table.setPageSize(Number(value))}
1207
1215
  >
1208
- <SelectTrigger size="sm" className="min-w-[80px]">
1216
+ <SelectTrigger size="sm" className="min-w-[80px]" aria-label={t.pageSizeLabel}>
1209
1217
  <SelectValue />
1210
1218
  </SelectTrigger>
1211
1219
  <SelectContent>
@@ -12,6 +12,7 @@ import { forwardRef, useCallback, useMemo, useState } from "react"
12
12
  import { CalendarIcon } from "lucide-react"
13
13
  import {
14
14
  addMonths,
15
+ differenceInCalendarDays,
15
16
  endOfDay,
16
17
  endOfMonth,
17
18
  endOfWeek,
@@ -20,6 +21,7 @@ import {
20
21
  startOfMonth,
21
22
  startOfWeek,
22
23
  subDays,
24
+ subYears,
23
25
  subMonths,
24
26
  subWeeks,
25
27
  } from "date-fns"
@@ -28,7 +30,7 @@ import type { DateRange as RdpDateRange } from "react-day-picker"
28
30
 
29
31
  import { cn } from "@squadbase/vantage/ui"
30
32
  import { useIsMobile } from "@squadbase/vantage/ui"
31
- import { formatDate } from "@squadbase/vantage/components"
33
+ import { formatDate, formatDateRange as formatRange } from "@squadbase/vantage/components"
32
34
  import { Button } from "@squadbase/vantage/ui"
33
35
  import { Calendar } from "@squadbase/vantage/ui"
34
36
  import { Popover, PopoverContent, PopoverTrigger } from "@squadbase/vantage/ui"
@@ -213,6 +215,23 @@ export function fiscalPeriodRange(
213
215
  return { from: periodStart, to: endOfMonth(addMonths(periodStart, length - 1)) }
214
216
  }
215
217
 
218
+ /**
219
+ * 選択範囲の比較期間を計算する。`previous-period` は同じ日数だけ直前にずらした範囲、
220
+ * `previous-year` は両端を 1 年前にした範囲(うるう日は 2/28 に丸める)。
221
+ *
222
+ * @example
223
+ * comparisonRange({ from: new Date(2026, 2, 1), to: new Date(2026, 2, 31) }, "previous-period")
224
+ * // 2026-01-29〜2026-02-28
225
+ */
226
+ export function comparisonRange(
227
+ { from, to }: { from: Date; to: Date },
228
+ kind: "previous-period" | "previous-year",
229
+ ): { from: Date; to: Date } {
230
+ if (kind === "previous-year") return { from: subYears(from, 1), to: subYears(to, 1) }
231
+ const days = differenceInCalendarDays(to, from) + 1
232
+ return { from: subDays(from, days), to: subDays(from, 1) }
233
+ }
234
+
216
235
  const PRESET_LABELS = {
217
236
  ja: {
218
237
  today: "今日",
@@ -251,8 +270,14 @@ const PRESET_LABELS = {
251
270
  } as const
252
271
 
253
272
  const TEXT = {
254
- ja: { placeholder: "期間を選択", rangeSeparator: " - " },
255
- en: { placeholder: "Pick a date range", rangeSeparator: " – " },
273
+ ja: {
274
+ placeholder: "期間を選択",
275
+ comparison: { "previous-period": "前期間", "previous-year": "前年同期" },
276
+ },
277
+ en: {
278
+ placeholder: "Pick a date range",
279
+ comparison: { "previous-period": "vs. previous period", "previous-year": "vs. same period last year" },
280
+ },
256
281
  }
257
282
 
258
283
  /** カレンダーに渡す date-fns の locale。`ja*` なら日本語、それ以外は react-day-picker の既定(英語)。 */
@@ -398,6 +423,14 @@ export interface DateRangePickerProps {
398
423
  * <DateRangePicker fiscalYearStartMonth={1} />
399
424
  */
400
425
  fiscalYearStartMonth?: number
426
+ /**
427
+ * 比較期間。渡すと選択範囲から比較する範囲を計算し、トリガーに添える。
428
+ * `previous-period` は直前の同じ日数、`previous-year` は 1 年前の同じ日付。比較の日付は渡さない。
429
+ *
430
+ * @example
431
+ * <DateRangePicker comparison="previous-year" />
432
+ */
433
+ comparison?: "previous-period" | "previous-year"
401
434
  maxDate?: Date
402
435
  minDate?: Date
403
436
  placeholder?: string
@@ -415,6 +448,7 @@ export const DateRangePicker = forwardRef<HTMLDivElement, DateRangePickerProps>(
415
448
  showPresets = true,
416
449
  locale: localeProp,
417
450
  fiscalYearStartMonth,
451
+ comparison,
418
452
  maxDate,
419
453
  minDate,
420
454
  placeholder,
@@ -451,11 +485,19 @@ export const DateRangePicker = forwardRef<HTMLDivElement, DateRangePickerProps>(
451
485
  const formatDateRange = () => {
452
486
  if (!value?.from) return placeholder ?? t.placeholder
453
487
  if (!value.to) return formatDate(value.from, "date", locale)
454
- return `${formatDate(value.from, "date", locale)}${t.rangeSeparator}${formatDate(value.to, "date", locale)}`
488
+ // The same writer as the page header's period chip, so one range reads the same in both places.
489
+ return formatRange(value.from, value.to, { locale })
455
490
  }
456
491
  // 選んだ期間がプリセットの結果と一致するときは、日付の前にそのプリセット名を出す。
457
492
  const selectedPreset = findMatchingPreset(presets, value)
458
493
  const presetLabel = selectedPreset?.label
494
+ const comparisonText =
495
+ comparison && value?.from && value.to
496
+ ? (() => {
497
+ const c = comparisonRange({ from: value.from, to: value.to }, comparison)
498
+ return `${t.comparison[comparison]} ${formatRange(c.from, c.to, { locale })}`
499
+ })()
500
+ : undefined
459
501
 
460
502
  // 開いたとき、右の月が終了月・左がその前月になるよう、表示月(左の月)を決める。1 か月表示(モバイル)は終了月。
461
503
  const endMonth = value?.to ?? value?.from
@@ -492,6 +534,11 @@ export const DateRangePicker = forwardRef<HTMLDivElement, DateRangePickerProps>(
492
534
  ) : (
493
535
  <span>{formatDateRange()}</span>
494
536
  )}
537
+ {comparisonText && (
538
+ <span data-slot="date-range-picker-comparison" className="text-muted-foreground">
539
+ {comparisonText}
540
+ </span>
541
+ )}
495
542
  </PopoverTrigger>
496
543
  <PopoverContent
497
544
  align={align}
@@ -3,7 +3,9 @@
3
3
  "use client"
4
4
 
5
5
  /**
6
- * Delta — the change of a value against a comparison, rendered as arrow + signed
6
+ * Delta — the change of a value against a comparison, as arrow + signed change + comparison label on one line; it is not a card, so place it beside a value.
7
+ *
8
+ * It renders as arrow + signed
7
9
  * change + comparison label ("↑ +12.4% vs. prior month"; ja: "↑ +12.4% 前月比") on one line, in the page's
8
10
  * language (`<html lang>`) unless `locale` is given.
9
11
  *
@@ -191,7 +193,7 @@ const deltaVariants = cva(
191
193
  {
192
194
  variants: {
193
195
  variant: {
194
- badge: "h-5 rounded-md border px-2 py-0.5 text-xs font-medium [&>svg]:size-3",
196
+ badge: "h-5 rounded-md px-2 py-0.5 text-xs font-medium [&>svg]:size-3",
195
197
  inline: "text-sm [&>svg]:size-3.5",
196
198
  },
197
199
  sentiment: {
@@ -201,9 +203,9 @@ const deltaVariants = cva(
201
203
  },
202
204
  },
203
205
  compoundVariants: [
204
- { variant: "badge", sentiment: "positive", className: "border-positive/20 bg-positive-subtle dark:border-positive/25" },
205
- { variant: "badge", sentiment: "negative", className: "border-negative/20 bg-negative-subtle dark:border-negative/25" },
206
- { variant: "badge", sentiment: "neutral", className: "border-neutral/20 bg-neutral-subtle dark:border-neutral/25" },
206
+ { variant: "badge", sentiment: "positive", className: "bg-positive-subtle" },
207
+ { variant: "badge", sentiment: "negative", className: "bg-negative-subtle" },
208
+ { variant: "badge", sentiment: "neutral", className: "bg-neutral-subtle" },
207
209
  ],
208
210
  defaultVariants: {
209
211
  variant: "badge",
@@ -285,6 +287,8 @@ export function deltaAriaLabel(
285
287
  return t.change(label, change === "points" ? amount.replace(/pt$/, t.points) : amount, direction === "up")
286
288
  }
287
289
 
290
+ const SENTIMENT_TEXT = { positive: "text-positive", negative: "text-negative", neutral: "text-neutral" } as const
291
+
288
292
  export const Delta = forwardRef<HTMLSpanElement, DeltaProps>(
289
293
  (
290
294
  {
@@ -328,18 +332,41 @@ export const Delta = forwardRef<HTMLSpanElement, DeltaProps>(
328
332
  data-sentiment={result.sentiment}
329
333
  aria-label={deltaAriaLabel(result, locale, display, format, changeDigits)}
330
334
  className={cn(
331
- deltaVariants({ variant, sentiment: result.sentiment }),
335
+ variant === "badge"
336
+ ? "inline-flex w-fit shrink-0 items-center gap-1.5 font-medium whitespace-nowrap tabular-nums"
337
+ : deltaVariants({ variant, sentiment: result.sentiment }),
338
+ // The chip carries the colour of the change; the root repeats it for the "—" case and for `className` consumers.
339
+ variant === "badge" && SENTIMENT_TEXT[result.sentiment],
332
340
  !comparable && "text-muted-foreground",
333
341
  className,
334
342
  )}
335
343
  {...props}
336
344
  >
337
- {comparable && <Icon aria-hidden="true" />}
338
- <span aria-hidden="true">{text ?? DELTA_NOT_COMPARABLE}</span>
339
- {showLabel && (
340
- <span aria-hidden="true" className="font-normal text-muted-foreground">
341
- {result.label}
342
- </span>
345
+ {variant === "badge" ? (
346
+ <>
347
+ <span
348
+ data-slot="delta-chip"
349
+ className={cn(deltaVariants({ variant, sentiment: result.sentiment }), !comparable && "text-muted-foreground")}
350
+ >
351
+ {comparable && <Icon aria-hidden="true" />}
352
+ <span aria-hidden="true">{text ?? DELTA_NOT_COMPARABLE}</span>
353
+ </span>
354
+ {showLabel && (
355
+ <span aria-hidden="true" className="text-xs font-normal text-muted-foreground">
356
+ {result.label}
357
+ </span>
358
+ )}
359
+ </>
360
+ ) : (
361
+ <>
362
+ {comparable && <Icon aria-hidden="true" />}
363
+ <span aria-hidden="true">{text ?? DELTA_NOT_COMPARABLE}</span>
364
+ {showLabel && (
365
+ <span aria-hidden="true" className="font-normal text-muted-foreground">
366
+ {result.label}
367
+ </span>
368
+ )}
369
+ </>
343
370
  )}
344
371
  </span>
345
372
  )
@@ -176,10 +176,25 @@ function useLocale(locale?: string | null): string {
176
176
  const CHART_OTHER_COLOR = "color-mix(in oklab, var(--neutral) 50%, var(--background))"
177
177
 
178
178
  /**
179
- * True inside `ChartFrame`. The frame's plot area carries the chart's single
180
- * `role="img"` and label, so `EChart` drops its own when it sees this.
181
- */
182
- const ChartFrameContext = createContext(false)
179
+ * What a `ChartFrame` tells the plot inside it. A frame whose plot is an image
180
+ * (`image`) puts `role="img"` on its plot area, unless an `EChart` inside it
181
+ * `claim`s the image: then the canvas alone carries `role="img"` with this name
182
+ * and description (`claimed`), so the action buttons beside the canvas are not
183
+ * inside the image (an interactive control inside `role="img"` is a
184
+ * nested-interactive violation and is not exposed). A `group` frame never
185
+ * hands the role to the canvas. `false` outside a frame.
186
+ */
187
+ interface ChartFramePlot {
188
+ image: boolean
189
+ /** An `EChart` inside has taken over the `role="img"`. */
190
+ claimed: boolean
191
+ /** Take over the image role; returns the release. */
192
+ claim: () => () => void
193
+ ariaLabel?: string
194
+ ariaLabelledBy?: string
195
+ ariaDescribedBy?: string
196
+ }
197
+ const ChartFrameContext = createContext<ChartFramePlot | false>(false)
183
198
 
184
199
  /** Series palette, in order. Missing tokens are skipped, not defaulted. */
185
200
  const SERIES_TOKENS = ["--chart-1", "--chart-2", "--chart-3", "--chart-4", "--chart-5"] as const
@@ -403,6 +418,8 @@ function buildChartTheme(tokens: ChartTokens): Record<string, unknown> {
403
418
  tooltip: {
404
419
  // ECharts' default 0.4s transform lags the tooltip ~300ms behind the cursor.
405
420
  transitionDuration: 0,
421
+ // Stay inside the chart: cards are often narrow and overflow-hidden.
422
+ confine: true,
406
423
  backgroundColor: tokens.popover ?? undefined,
407
424
  borderColor: line,
408
425
  borderWidth: 1,
@@ -2278,6 +2295,10 @@ function legendLines(items: readonly string[], plotWidth?: number, reserve = 0):
2278
2295
 
2279
2296
  /** Width (px) kept free at the legend's right for the PNG action buttons (two 32px buttons + gaps). */
2280
2297
  const ACTIONS_RESERVE = 80
2298
+ /** Bars stay slim on wide cards (px); stacked segments round all corners and are parted by a hairline of the surface colour (px). */
2299
+ const BAR_MAX_WIDTH = 40
2300
+ const BAR_RADIUS = 3
2301
+ const STACK_GAP = 1.5
2281
2302
 
2282
2303
  /** Room (px) one time label (`2026年1月`) needs, gap included. */
2283
2304
  const TIME_LABEL_WIDTH = 80
@@ -2491,6 +2512,8 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
2491
2512
  const compared = cartesianSeriesValues(data, [categories[compIdx]!], "default")[0]!
2492
2513
  return categories.map((_, i) => (i === compIdx ? compared : main[mainIdx.indexOf(i)]!))
2493
2514
  })()
2515
+ // Any negative bar: the part lays out the diverging view itself (a solid zero line, category labels kept at the axis end).
2516
+ const diverging = kind === "bar" && values.some((s) => s.some((v) => typeof v === "number" && v < 0))
2494
2517
  const comparisonColor = guideColor ? withAlpha(guideColor, COMPARISON_OPACITY) : undefined
2495
2518
 
2496
2519
  /* -- x labels: the actual rows, then the forecast rows that extend them */
@@ -2700,15 +2723,17 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
2700
2723
  const seriesData = pad(values[c]!)
2701
2724
  if (kind === "bar") {
2702
2725
  const rounded = horizontalBars ? [0, 4, 4, 0] : [4, 4, 0, 0]
2703
- // Stacked: only the topmost segment is rounded.
2704
- const radius = stacked && c !== categories.length - 1 ? 0 : rounded
2726
+ // Stacked: every segment is rounded, with a hairline of the surface colour between segments (the gap).
2727
+ const radius = stacked ? BAR_RADIUS : rounded
2728
+ const gapStyle = stacked && labelBackground ? { borderColor: labelBackground, borderWidth: STACK_GAP } : {}
2705
2729
  return {
2706
2730
  id: SERIES_ID_PREFIX + category,
2707
2731
  type: "bar" as const,
2708
2732
  name: category,
2709
2733
  stack: stacked ? "total" : undefined,
2710
2734
  barCategoryGap,
2711
- itemStyle: { color, borderRadius: radius },
2735
+ barMaxWidth: BAR_MAX_WIDTH,
2736
+ itemStyle: { color, borderRadius: radius, ...gapStyle },
2712
2737
  markPoint: markPointFor(c, solid),
2713
2738
  markLine: undefined,
2714
2739
  data: seriesData.map((value, i) => {
@@ -3004,6 +3029,14 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
3004
3029
  },
3005
3030
  })
3006
3031
  }
3032
+ if (diverging) {
3033
+ markLines.push({
3034
+ name: "zero",
3035
+ [horizontalBars ? "xAxis" : "yAxis"]: 0,
3036
+ lineStyle: { type: "solid", width: 1, color: guideColor },
3037
+ label: { show: false },
3038
+ })
3039
+ }
3007
3040
  if (forecast) {
3008
3041
  const forecastSpan =
3009
3042
  plotWidth && labels.length > 1
@@ -3149,6 +3182,8 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
3149
3182
  show: horizontalBars ? showYAxis : showXAxis,
3150
3183
  boundaryGap: kind === "bar",
3151
3184
  inverse: horizontalBars,
3185
+ // Diverging: the axis stays at the plot's edge (not at 0, where labels would hit the negative bars); the zero line is a mark line.
3186
+ axisLine: diverging ? { show: false, onZero: false } : undefined,
3152
3187
  axisTick: { show: false },
3153
3188
  axisLabel: {
3154
3189
  hideOverlap: true,
@@ -3251,7 +3286,7 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
3251
3286
  // Narrow: left-aligned and wrapping above the plot, so nothing is cut off at the right edge.
3252
3287
  ...(plotWidth !== undefined && plotWidth < NARROW_PLOT_WIDTH
3253
3288
  ? { left: 0, right: 0 }
3254
- : { right: reserveActions ? ACTIONS_RESERVE : 0 }),
3289
+ : { left: 0, right: reserveActions ? ACTIONS_RESERVE : 0 }),
3255
3290
  // Plain wraps onto a second line at narrow widths instead of hiding items behind arrows.
3256
3291
  type: "plain",
3257
3292
  icon: "circle",