@squadbase/vantage 0.7.1-dev1.9d223a2 → 0.7.1-dev1.bb6bd72

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 (67) hide show
  1. package/dist/{build-U7GO26HR.js → build-AON4MAQQ.js} +3 -3
  2. package/dist/{build-U7GO26HR.js.map → build-AON4MAQQ.js.map} +1 -1
  3. package/dist/{chunk-MQUIAP66.js → chunk-5HBUBVQ3.js} +28 -7
  4. package/dist/chunk-5HBUBVQ3.js.map +1 -0
  5. package/dist/{chunk-EG4PX726.js → chunk-GWXKENNH.js} +122 -5
  6. package/dist/chunk-GWXKENNH.js.map +1 -0
  7. package/dist/{chunk-YJQCB3UP.js → chunk-I2GWIPTD.js} +219 -67
  8. package/dist/chunk-I2GWIPTD.js.map +1 -0
  9. package/dist/{chunk-SDJTTMLK.js → chunk-SR43D6EF.js} +13 -8
  10. package/dist/chunk-SR43D6EF.js.map +1 -0
  11. package/dist/{chunk-P2KIHF65.js → chunk-U6MWBCM6.js} +28 -7
  12. package/dist/chunk-U6MWBCM6.js.map +1 -0
  13. package/dist/cli.js +2 -2
  14. package/dist/components/index.d.ts +223 -6
  15. package/dist/components/index.js +4 -4
  16. package/dist/{dev-O42JFQ64.js → dev-BZ7FXDB3.js} +3 -3
  17. package/dist/{dev-O42JFQ64.js.map → dev-BZ7FXDB3.js.map} +1 -1
  18. package/dist/doc/index.js +1 -1
  19. package/dist/slide/index.js +1 -1
  20. package/dist/table/index.js +4 -4
  21. package/dist/ui/index.d.ts +35 -4
  22. package/dist/ui/index.js +174 -10
  23. package/dist/ui/index.js.map +1 -1
  24. package/dist/vite/index.js +1 -1
  25. package/package.json +2 -2
  26. package/registry/parts.json +101 -3
  27. package/registry/ui/area-chart.tsx +43 -8
  28. package/registry/ui/bar-chart.tsx +43 -8
  29. package/registry/ui/bar-list.tsx +43 -8
  30. package/registry/ui/box-plot-chart.tsx +42 -7
  31. package/registry/ui/category-bar.tsx +25 -5
  32. package/registry/ui/chart-frame.tsx +47 -14
  33. package/registry/ui/checkbox.tsx +4 -3
  34. package/registry/ui/code-block.tsx +44 -0
  35. package/registry/ui/combo-chart.tsx +20 -3
  36. package/registry/ui/combobox.tsx +7 -0
  37. package/registry/ui/context-menu.tsx +71 -0
  38. package/registry/ui/dashboard-card.tsx +20 -9
  39. package/registry/ui/data-table.tsx +7 -1
  40. package/registry/ui/date-range-picker.tsx +51 -4
  41. package/registry/ui/delta.tsx +36 -11
  42. package/registry/ui/donut-chart.tsx +43 -8
  43. package/registry/ui/echart.tsx +167 -7
  44. package/registry/ui/file-dropzone.tsx +143 -0
  45. package/registry/ui/filter-bar.tsx +74 -1
  46. package/registry/ui/forecast-chart.tsx +42 -7
  47. package/registry/ui/funnel-steps.tsx +22 -4
  48. package/registry/ui/heatmap.tsx +20 -3
  49. package/registry/ui/histogram-chart.tsx +42 -7
  50. package/registry/ui/kpi-card.tsx +12 -8
  51. package/registry/ui/legend.tsx +73 -15
  52. package/registry/ui/line-chart.tsx +43 -8
  53. package/registry/ui/page-shell.tsx +2 -0
  54. package/registry/ui/paired-chart.tsx +43 -8
  55. package/registry/ui/progress.tsx +1 -1
  56. package/registry/ui/scatter-chart.tsx +50 -12
  57. package/registry/ui/sparkline.tsx +43 -8
  58. package/registry/ui/states.tsx +34 -1
  59. package/registry/ui/status-badge.tsx +7 -7
  60. package/registry/ui/tabs.tsx +1 -0
  61. package/registry/ui/treemap.tsx +20 -3
  62. package/registry/ui/waterfall-chart.tsx +64 -11
  63. package/dist/chunk-EG4PX726.js.map +0 -1
  64. package/dist/chunk-MQUIAP66.js.map +0 -1
  65. package/dist/chunk-P2KIHF65.js.map +0 -1
  66. package/dist/chunk-SDJTTMLK.js.map +0 -1
  67. package/dist/chunk-YJQCB3UP.js.map +0 -1
@@ -163,10 +163,25 @@ function useLocale(locale?: string | null): string {
163
163
  const CHART_OTHER_COLOR = "color-mix(in oklab, var(--neutral) 50%, var(--background))"
164
164
 
165
165
  /**
166
- * True inside `ChartFrame`. The frame's plot area carries the chart's single
167
- * `role="img"` and label, so `EChart` drops its own when it sees this.
166
+ * What a `ChartFrame` tells the plot inside it. A frame whose plot is an image
167
+ * (`image`) puts `role="img"` on its plot area, unless an `EChart` inside it
168
+ * `claim`s the image: then the canvas alone carries `role="img"` with this name
169
+ * and description (`claimed`), so the action buttons beside the canvas are not
170
+ * inside the image (an interactive control inside `role="img"` is a
171
+ * nested-interactive violation and is not exposed). A `group` frame never
172
+ * hands the role to the canvas. `false` outside a frame.
168
173
  */
169
- const ChartFrameContext = createContext(false)
174
+ interface ChartFramePlot {
175
+ image: boolean
176
+ /** An `EChart` inside has taken over the `role="img"`. */
177
+ claimed: boolean
178
+ /** Take over the image role; returns the release. */
179
+ claim: () => () => void
180
+ ariaLabel?: string
181
+ ariaLabelledBy?: string
182
+ ariaDescribedBy?: string
183
+ }
184
+ const ChartFrameContext = createContext<ChartFramePlot | false>(false)
170
185
 
171
186
  /** Series palette, in order. Missing tokens are skipped, not defaulted. */
172
187
  const SERIES_TOKENS = ["--chart-1", "--chart-2", "--chart-3", "--chart-4", "--chart-5"] as const
@@ -390,6 +405,8 @@ function buildChartTheme(tokens: ChartTokens): Record<string, unknown> {
390
405
  tooltip: {
391
406
  // ECharts' default 0.4s transform lags the tooltip ~300ms behind the cursor.
392
407
  transitionDuration: 0,
408
+ // Stay inside the chart: cards are often narrow and overflow-hidden.
409
+ confine: true,
393
410
  backgroundColor: tokens.popover ?? undefined,
394
411
  borderColor: line,
395
412
  borderWidth: 1,
@@ -1439,8 +1456,11 @@ export const CategoryBar = forwardRef<HTMLDivElement, CategoryBarProps>(function
1439
1456
  />
1440
1457
  )
1441
1458
 
1459
+ // aria-label is prohibited on a role-less div: a labelled bar is a group (the label names it, the marker stays reachable).
1460
+ const labelled = props["aria-label"] != null || props["aria-labelledby"] != null
1461
+
1442
1462
  return (
1443
- <div ref={ref} data-slot="category-bar" className={cn("w-full", className)} {...props}>
1463
+ <div ref={ref} data-slot="category-bar" role={labelled ? "group" : undefined} className={cn("w-full", className)} {...props}>
1444
1464
  {showLabels && labels.length > 0 && (
1445
1465
  <div data-slot="category-bar-labels" className="relative mb-2 h-4 text-xs text-muted-foreground tabular-nums">
1446
1466
  {labels.map((label, i) => (
@@ -1481,7 +1501,7 @@ export const CategoryBar = forwardRef<HTMLDivElement, CategoryBarProps>(function
1481
1501
  >
1482
1502
  {marker.tooltip ? (
1483
1503
  <Tooltip>
1484
- <TooltipTrigger render={<span aria-label={marker.tooltip} className="block py-1" />}>
1504
+ <TooltipTrigger render={<button type="button" aria-label={marker.tooltip} className="block cursor-default py-1" />}>
1485
1505
  {markerDot}
1486
1506
  </TooltipTrigger>
1487
1507
  <TooltipContent>{marker.tooltip}</TooltipContent>
@@ -8,20 +8,23 @@
8
8
  * by the title and described by the summary, so a screen reader hears what the
9
9
  * chart says, not just that there is one.
10
10
  *
11
- * The plot area is the frame's one `role="img"` (`EChart` inside it drops its
12
- * own); `dataTable` adds the numbers as a visually-hidden table beside it, so a
13
- * screen reader can read the values the canvas only draws.
11
+ * The plot is the frame's one `role="img"`. When it is an `EChart`, that
12
+ * canvas takes the role (and the frame's name and description) and the plot
13
+ * area drops it, so the copy / download buttons are not inside the image;
14
+ * any other plot keeps the role on the plot area. `dataTable` adds the numbers
15
+ * as a visually-hidden table beside it, so a screen reader can read the values
16
+ * the canvas only draws.
14
17
  *
15
18
  * The plot area keeps its height in every state: while loading it shows a
16
19
  * skeleton shaped like the final chart, and with no data it says so (and offers
17
20
  * one action) instead of drawing empty axes.
18
21
  */
19
- import { forwardRef, useId, type HTMLAttributes, type ReactNode } from "react"
22
+ import { forwardRef, useCallback, useId, useMemo, useState, type HTMLAttributes, type ReactNode } from "react"
20
23
 
21
24
  import { cn } from "@squadbase/vantage/ui"
22
25
  import type { ChartEmptyState } from "@squadbase/vantage/components"
23
26
  import { useSyncExternalStore } from "react"
24
- import { createContext, useCallback, useEffect, useRef, useState, type ForwardedRef, type RefObject } from "react"
27
+ import { createContext, useEffect, useRef, type ForwardedRef, type RefObject } from "react"
25
28
  import type { ChartTone } from "@squadbase/vantage/components"
26
29
  import { EMPTY_VALUE, formatValue, toValueFormatter, type ValueFormat } from "@squadbase/vantage/components"
27
30
  import type { ChartColor, ChartColors, ChartSeriesColor, ValueFormatter } from "@squadbase/vantage/components"
@@ -170,10 +173,25 @@ function useLocale(locale?: string | null): string {
170
173
  const CHART_OTHER_COLOR = "color-mix(in oklab, var(--neutral) 50%, var(--background))"
171
174
 
172
175
  /**
173
- * True inside `ChartFrame`. The frame's plot area carries the chart's single
174
- * `role="img"` and label, so `EChart` drops its own when it sees this.
176
+ * What a `ChartFrame` tells the plot inside it. A frame whose plot is an image
177
+ * (`image`) puts `role="img"` on its plot area, unless an `EChart` inside it
178
+ * `claim`s the image: then the canvas alone carries `role="img"` with this name
179
+ * and description (`claimed`), so the action buttons beside the canvas are not
180
+ * inside the image (an interactive control inside `role="img"` is a
181
+ * nested-interactive violation and is not exposed). A `group` frame never
182
+ * hands the role to the canvas. `false` outside a frame.
175
183
  */
176
- const ChartFrameContext = createContext(false)
184
+ interface ChartFramePlot {
185
+ image: boolean
186
+ /** An `EChart` inside has taken over the `role="img"`. */
187
+ claimed: boolean
188
+ /** Take over the image role; returns the release. */
189
+ claim: () => () => void
190
+ ariaLabel?: string
191
+ ariaLabelledBy?: string
192
+ ariaDescribedBy?: string
193
+ }
194
+ const ChartFrameContext = createContext<ChartFramePlot | false>(false)
177
195
 
178
196
  /** Series palette, in order. Missing tokens are skipped, not defaulted. */
179
197
  const SERIES_TOKENS = ["--chart-1", "--chart-2", "--chart-3", "--chart-4", "--chart-5"] as const
@@ -397,6 +415,8 @@ function buildChartTheme(tokens: ChartTokens): Record<string, unknown> {
397
415
  tooltip: {
398
416
  // ECharts' default 0.4s transform lags the tooltip ~300ms behind the cursor.
399
417
  transitionDuration: 0,
418
+ // Stay inside the chart: cards are often narrow and overflow-hidden.
419
+ confine: true,
400
420
  backgroundColor: tokens.popover ?? undefined,
401
421
  borderColor: line,
402
422
  borderWidth: 1,
@@ -1422,7 +1442,8 @@ export interface ChartFrameProps extends Omit<HTMLAttributes<HTMLElement>, "titl
1422
1442
  /** 見出しが無いときのプロット領域のラベル。 */
1423
1443
  ariaLabel?: string
1424
1444
  /**
1425
- * プロット領域の役割。既定 `"img"`(canvas のグラフ)。行がボタンやリンクになる
1445
+ * プロットの役割。既定 `"img"`(canvas のグラフ。`role="img"` と名前・要約は
1446
+ * `EChart` が canvas にだけ付け、操作ボタンは画像の外に置く)。行がボタンやリンクになる
1426
1447
  * HTML のグラフ(BarList・FunnelSteps)は `"group"` にして、中身を読ませる。
1427
1448
  */
1428
1449
  plotRole?: "img" | "group"
@@ -1462,6 +1483,18 @@ export const ChartFrame = forwardRef<HTMLDivElement, ChartFrameProps>(function C
1462
1483
  const titleId = title ? `${id}-title` : undefined
1463
1484
  const summaryId = summary ? `${id}-summary` : undefined
1464
1485
  const state = isLoading ? "loading" : isEmpty ? "empty" : "ready"
1486
+ // An EChart inside takes the image role for its canvas (see ChartFramePlot).
1487
+ const [claims, setClaims] = useState(0)
1488
+ const claim = useCallback(() => {
1489
+ setClaims((n) => n + 1)
1490
+ return () => setClaims((n) => n - 1)
1491
+ }, [])
1492
+ const claimed = claims > 0 && plotRole === "img"
1493
+ const plotOwnsImage = state === "ready" && claimed
1494
+ const plot = useMemo(
1495
+ () => ({ image: plotRole === "img", claimed, claim, ariaLabel: titleId ? undefined : ariaLabel, ariaLabelledBy: titleId, ariaDescribedBy: summaryId }),
1496
+ [plotRole, claimed, claim, titleId, ariaLabel, summaryId],
1497
+ )
1465
1498
 
1466
1499
  return (
1467
1500
  <figure data-slot="chart-frame" className={cn("m-0 flex w-full min-w-0 flex-col gap-3", className)} {...props}>
@@ -1483,10 +1516,10 @@ export const ChartFrame = forwardRef<HTMLDivElement, ChartFrameProps>(function C
1483
1516
  ref={ref}
1484
1517
  data-slot="chart-frame-body"
1485
1518
  data-state={state}
1486
- role={state === "ready" ? plotRole : "group"}
1487
- aria-label={titleId ? undefined : ariaLabel}
1488
- aria-labelledby={titleId}
1489
- aria-describedby={summaryId}
1519
+ role={plotOwnsImage ? undefined : state === "ready" ? plotRole : "group"}
1520
+ aria-label={plotOwnsImage || titleId ? undefined : ariaLabel}
1521
+ aria-labelledby={plotOwnsImage ? undefined : titleId}
1522
+ aria-describedby={plotOwnsImage ? undefined : summaryId}
1490
1523
  aria-busy={isLoading || undefined}
1491
1524
  className="relative w-full min-w-0"
1492
1525
  style={{ height: typeof height === "number" ? `${height}px` : height }}
@@ -1496,7 +1529,7 @@ export const ChartFrame = forwardRef<HTMLDivElement, ChartFrameProps>(function C
1496
1529
  ) : state === "empty" ? (
1497
1530
  <ChartEmpty {...emptyState} defaultTitle={t.emptyTitle} />
1498
1531
  ) : (
1499
- <ChartFrameContext.Provider value>{children}</ChartFrameContext.Provider>
1532
+ <ChartFrameContext.Provider value={plot}>{children}</ChartFrameContext.Provider>
1500
1533
  )}
1501
1534
  </div>
1502
1535
  {footnote && (
@@ -5,14 +5,14 @@
5
5
  import { Checkbox as CheckboxPrimitive } from "@base-ui/react/checkbox"
6
6
 
7
7
  import { cn } from "@squadbase/vantage/ui"
8
- import { CheckIcon } from "lucide-react"
8
+ import { CheckIcon, MinusIcon } from "lucide-react"
9
9
 
10
10
  function Checkbox({ className, ...props }: CheckboxPrimitive.Root.Props) {
11
11
  return (
12
12
  <CheckboxPrimitive.Root
13
13
  data-slot="checkbox"
14
14
  className={cn(
15
- "peer relative flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-input transition-colors outline-none group-has-disabled/field:opacity-50 after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary",
15
+ "group/checkbox peer relative flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-input transition-colors outline-none group-has-disabled/field:opacity-50 after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary data-indeterminate:border-primary data-indeterminate:bg-primary data-indeterminate:text-primary-foreground dark:data-indeterminate:bg-primary",
16
16
  className
17
17
  )}
18
18
  {...props}
@@ -21,7 +21,8 @@ function Checkbox({ className, ...props }: CheckboxPrimitive.Root.Props) {
21
21
  data-slot="checkbox-indicator"
22
22
  className="grid place-content-center text-current transition-none [&>svg]:size-3.5"
23
23
  >
24
- <CheckIcon />
24
+ <CheckIcon className="group-data-indeterminate/checkbox:hidden" />
25
+ <MinusIcon className="hidden group-data-indeterminate/checkbox:block" />
25
26
  </CheckboxPrimitive.Indicator>
26
27
  </CheckboxPrimitive.Root>
27
28
  )
@@ -0,0 +1,44 @@
1
+ // Copied by `vantage add ui code-block`. This file is yours to edit.
2
+ // Generated from @squadbase/squadkit registry/ui/code-block.tsx by `pnpm run registry` (in Vantage, edit packages/squadkit, not registry/).
3
+ "use client"
4
+
5
+ import * as React from "react"
6
+ import { CheckIcon, CopyIcon } from "lucide-react"
7
+
8
+ import { cn } from "@squadbase/vantage/ui"
9
+ import { Button } from "@squadbase/vantage/ui"
10
+
11
+ type CodeBlockProps = Omit<React.ComponentProps<"div">, "children"> & {
12
+ /** The text shown and copied. No syntax highlighting. */
13
+ code: string
14
+ /** Plain-text label above the code (a file name or language). */
15
+ title?: string
16
+ }
17
+
18
+ /** Monospace text with a copy button. */
19
+ function CodeBlock({ code, title, className, ...props }: CodeBlockProps) {
20
+ const [copied, setCopied] = React.useState(false)
21
+
22
+ const copy = async () => {
23
+ await navigator.clipboard.writeText(code)
24
+ setCopied(true)
25
+ setTimeout(() => setCopied(false), 1500)
26
+ }
27
+
28
+ return (
29
+ <div data-slot="code-block" className={cn("rounded-lg bg-muted", className)} {...props}>
30
+ <div className="flex items-center justify-between gap-2 border-b border-border px-3 py-1.5">
31
+ <span className="truncate text-xs text-muted-foreground">{title}</span>
32
+ <Button type="button" variant="ghost" size="xs" onClick={copy} aria-label="Copy code">
33
+ {copied ? <CheckIcon /> : <CopyIcon />}
34
+ {copied ? "Copied" : "Copy"}
35
+ </Button>
36
+ </div>
37
+ <pre className="overflow-x-auto p-3 font-mono text-sm text-foreground">
38
+ <code>{code}</code>
39
+ </pre>
40
+ </div>
41
+ )
42
+ }
43
+
44
+ export { CodeBlock, type CodeBlockProps }
@@ -171,10 +171,25 @@ function useLocale(locale?: string | null): string {
171
171
  const CHART_OTHER_COLOR = "color-mix(in oklab, var(--neutral) 50%, var(--background))"
172
172
 
173
173
  /**
174
- * True inside `ChartFrame`. The frame's plot area carries the chart's single
175
- * `role="img"` and label, so `EChart` drops its own when it sees this.
174
+ * What a `ChartFrame` tells the plot inside it. A frame whose plot is an image
175
+ * (`image`) puts `role="img"` on its plot area, unless an `EChart` inside it
176
+ * `claim`s the image: then the canvas alone carries `role="img"` with this name
177
+ * and description (`claimed`), so the action buttons beside the canvas are not
178
+ * inside the image (an interactive control inside `role="img"` is a
179
+ * nested-interactive violation and is not exposed). A `group` frame never
180
+ * hands the role to the canvas. `false` outside a frame.
176
181
  */
177
- const ChartFrameContext = createContext(false)
182
+ interface ChartFramePlot {
183
+ image: boolean
184
+ /** An `EChart` inside has taken over the `role="img"`. */
185
+ claimed: boolean
186
+ /** Take over the image role; returns the release. */
187
+ claim: () => () => void
188
+ ariaLabel?: string
189
+ ariaLabelledBy?: string
190
+ ariaDescribedBy?: string
191
+ }
192
+ const ChartFrameContext = createContext<ChartFramePlot | false>(false)
178
193
 
179
194
  /** Series palette, in order. Missing tokens are skipped, not defaulted. */
180
195
  const SERIES_TOKENS = ["--chart-1", "--chart-2", "--chart-3", "--chart-4", "--chart-5"] as const
@@ -398,6 +413,8 @@ function buildChartTheme(tokens: ChartTokens): Record<string, unknown> {
398
413
  tooltip: {
399
414
  // ECharts' default 0.4s transform lags the tooltip ~300ms behind the cursor.
400
415
  transitionDuration: 0,
416
+ // Stay inside the chart: cards are often narrow and overflow-hidden.
417
+ confine: true,
401
418
  backgroundColor: tokens.popover ?? undefined,
402
419
  borderColor: line,
403
420
  borderWidth: 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
+ }
@@ -129,7 +129,7 @@ export const DashboardCardFooter = forwardRef<HTMLDivElement, DashboardCardFoote
129
129
  <div
130
130
  ref={ref}
131
131
  data-slot="dashboard-card-footer"
132
- 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)}
133
133
  {...props}
134
134
  />
135
135
  ),
@@ -161,7 +161,10 @@ export interface DashboardCardPresetProps
161
161
  Pick<DashboardCardProps, "subgrid" | "size"> {
162
162
  title?: ReactNode
163
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
164
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. */
165
168
  footer?: ReactNode
166
169
  headerClassName?: string
167
170
  contentClassName?: string
@@ -171,6 +174,7 @@ export interface DashboardCardPresetProps
171
174
  export function DashboardCardPreset({
172
175
  title,
173
176
  description,
177
+ headline,
174
178
  actions,
175
179
  footer,
176
180
  children,
@@ -183,21 +187,28 @@ export function DashboardCardPreset({
183
187
  }: DashboardCardPresetProps) {
184
188
  // Inside SampleData the card shows the mark in its header, and the parts inside it do not repeat it.
185
189
  const sample = useSampleMark()
186
- 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)
187
194
  return (
188
- <DashboardCard className={className} subgrid={subgrid} {...props}>
195
+ <DashboardCard className={cn(floatingMark && "relative", className)} subgrid={subgrid} {...props}>
189
196
  <SampleClaim>
197
+ {floatingMark && <SampleBadge label={sample.label} className="absolute top-2 right-2 z-10" />}
190
198
  {hasHeader && (
191
199
  <DashboardCardHeader className={cn(subgrid && "row-start-1", headerClassName)}>
192
- {(title || description) && (
193
- <div>
194
- {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>
195
206
  {description && <DashboardCardDescription>{description}</DashboardCardDescription>}
196
207
  </div>
197
208
  )}
198
- {(actions || sample) && (
199
- <DashboardCardAction className={cn(sample && actions && "flex items-center gap-2")}>
200
- {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>}
201
212
  {actions}
202
213
  </DashboardCardAction>
203
214
  )}
@@ -311,6 +311,8 @@ const TEXT = {
311
311
  trend: (points: number, first: string, last: string, min: string, max: string) =>
312
312
  `${points}点の推移、${first}から${last}、最小${min}、最大${max}`,
313
313
  noData: "データがありません",
314
+ search: "検索",
315
+ pageSizeLabel: "1ページの表示件数",
314
316
  pageSize: (size: number) => `${size}件`,
315
317
  range: (total: number, start: number, end: number) => `${total}件中 ${start} - ${end} を表示`,
316
318
  first: "最初",
@@ -328,6 +330,8 @@ const TEXT = {
328
330
  trend: (points: number, first: string, last: string, min: string, max: string) =>
329
331
  `Trend over ${points} points: ${first} to ${last}, min ${min}, max ${max}`,
330
332
  noData: "No results.",
333
+ search: "Search",
334
+ pageSizeLabel: "Rows per page",
331
335
  pageSize: (size: number) => `${size} / page`,
332
336
  range: (total: number, start: number, end: number) => `Showing ${start}–${end} of ${total}`,
333
337
  first: "First",
@@ -920,6 +924,7 @@ export const DataTableToolbar = forwardRef<HTMLDivElement, DataTableToolbarProps
920
924
  ({ showSearch, toolbar, bulkActions, className, ...props }, ref) => {
921
925
  const { globalFilter, setGlobalFilter, hasSelection, selectedRows, enableFiltering } =
922
926
  useDataTable()
927
+ const t = useText()
923
928
 
924
929
  const showSearchEffective = showSearch ?? enableFiltering
925
930
 
@@ -938,6 +943,7 @@ export const DataTableToolbar = forwardRef<HTMLDivElement, DataTableToolbarProps
938
943
  <div className="relative">
939
944
  <Search className="absolute top-2.5 left-2.5 size-4 text-muted-foreground" />
940
945
  <Input
946
+ aria-label={t.search}
941
947
  value={globalFilter}
942
948
  onChange={(e) => setGlobalFilter(e.target.value)}
943
949
  className="max-w-sm pl-8"
@@ -1207,7 +1213,7 @@ export const DataTablePagination = forwardRef<HTMLDivElement, DataTablePaginatio
1207
1213
  value={String(currentPageSize)}
1208
1214
  onValueChange={(value) => table.setPageSize(Number(value))}
1209
1215
  >
1210
- <SelectTrigger size="sm" className="min-w-[80px]">
1216
+ <SelectTrigger size="sm" className="min-w-[80px]" aria-label={t.pageSizeLabel}>
1211
1217
  <SelectValue />
1212
1218
  </SelectTrigger>
1213
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}