@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
@@ -193,7 +193,7 @@ const deltaVariants = cva(
193
193
  {
194
194
  variants: {
195
195
  variant: {
196
- 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",
197
197
  inline: "text-sm [&>svg]:size-3.5",
198
198
  },
199
199
  sentiment: {
@@ -203,9 +203,9 @@ const deltaVariants = cva(
203
203
  },
204
204
  },
205
205
  compoundVariants: [
206
- { variant: "badge", sentiment: "positive", className: "border-positive/20 bg-positive-subtle dark:border-positive/25" },
207
- { variant: "badge", sentiment: "negative", className: "border-negative/20 bg-negative-subtle dark:border-negative/25" },
208
- { 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" },
209
209
  ],
210
210
  defaultVariants: {
211
211
  variant: "badge",
@@ -287,6 +287,8 @@ export function deltaAriaLabel(
287
287
  return t.change(label, change === "points" ? amount.replace(/pt$/, t.points) : amount, direction === "up")
288
288
  }
289
289
 
290
+ const SENTIMENT_TEXT = { positive: "text-positive", negative: "text-negative", neutral: "text-neutral" } as const
291
+
290
292
  export const Delta = forwardRef<HTMLSpanElement, DeltaProps>(
291
293
  (
292
294
  {
@@ -330,18 +332,41 @@ export const Delta = forwardRef<HTMLSpanElement, DeltaProps>(
330
332
  data-sentiment={result.sentiment}
331
333
  aria-label={deltaAriaLabel(result, locale, display, format, changeDigits)}
332
334
  className={cn(
333
- 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],
334
340
  !comparable && "text-muted-foreground",
335
341
  className,
336
342
  )}
337
343
  {...props}
338
344
  >
339
- {comparable && <Icon aria-hidden="true" />}
340
- <span aria-hidden="true">{text ?? DELTA_NOT_COMPARABLE}</span>
341
- {showLabel && (
342
- <span aria-hidden="true" className="font-normal text-muted-foreground">
343
- {result.label}
344
- </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
+ </>
345
370
  )}
346
371
  </span>
347
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",
@@ -40,6 +40,7 @@
40
40
  * import { ScatterChart } from "echarts/charts"
41
41
  * use([ScatterChart])
42
42
  */
43
+ import type { FocusEvent, KeyboardEvent } from "react"
43
44
  import { forwardRef, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"
44
45
  import { init, use } from "echarts/core"
45
46
  import { BarChart, CustomChart, LineChart, PieChart } from "echarts/charts"
@@ -193,6 +194,80 @@ function useLocale(locale?: string | null): string {
193
194
  return locale || page
194
195
  }
195
196
 
197
+ /* ---------- inlined from @squadbase/squadkit registry/lib/chart-keyboard.ts (internal to @squadbase/vantage) ---------- */
198
+
199
+ interface ChartPoint {
200
+ /** Index in the option's `series` (what `dispatchAction` takes as `seriesIndex`). */
201
+ series: number
202
+ index: number
203
+ }
204
+
205
+ interface ChartKeyboardSeries {
206
+ /** Index in the option's `series`. */
207
+ series: number
208
+ count: number
209
+ }
210
+
211
+ type Loose = Record<string, unknown>
212
+
213
+ const asList = (v: unknown): Loose[] => (Array.isArray(v) ? (v as Loose[]) : v && typeof v === "object" ? [v as Loose] : [])
214
+
215
+ /** The line / bar / pie series that have data, in option order. */
216
+ function chartKeyboardSeries(option: EChartsOption): ChartKeyboardSeries[] {
217
+ const out: ChartKeyboardSeries[] = []
218
+ asList(option.series).forEach((s, series) => {
219
+ if ((s.type === "line" || s.type === "bar" || s.type === "pie") && Array.isArray(s.data) && s.data.length > 0) {
220
+ out.push({ series, count: s.data.length })
221
+ }
222
+ })
223
+ return out
224
+ }
225
+
226
+ /**
227
+ * Where `key` takes the cursor from `from` (`null` = nothing selected yet → the first point).
228
+ * Left / Right walk the data index (no wrap), Up / Down switch series (only with more than one),
229
+ * Home / End jump to the first / last index. `null` when the key does nothing.
230
+ */
231
+ function moveChartPoint(from: ChartPoint | null, key: string, series: ChartKeyboardSeries[]): ChartPoint | null {
232
+ if (series.length === 0) return null
233
+ const pos = (n: number, index: number): ChartPoint => ({ series: series[n]!.series, index: Math.min(index, series[n]!.count - 1) })
234
+ if (!from) return pos(0, 0)
235
+ const n = Math.max(0, series.findIndex((s) => s.series === from.series))
236
+ const last = series[n]!.count - 1
237
+ switch (key) {
238
+ case "ArrowRight": return from.index >= last ? null : pos(n, from.index + 1)
239
+ case "ArrowLeft": return from.index <= 0 ? null : pos(n, from.index - 1)
240
+ case "Home": return pos(n, 0)
241
+ case "End": return pos(n, last)
242
+ case "ArrowDown": return n + 1 < series.length ? pos(n + 1, from.index) : null
243
+ case "ArrowUp": return n > 0 ? pos(n - 1, from.index) : null
244
+ default: return null
245
+ }
246
+ }
247
+
248
+ /** "series, category, value" for a point, the value written the way the tooltip writes it (`tooltip.valueFormatter`). */
249
+ function describeChartPoint(option: EChartsOption, point: ChartPoint): string {
250
+ const s = asList(option.series)[point.series]
251
+ if (!s) return ""
252
+ const item = (s.data as unknown[])[point.index]
253
+ const obj = item && typeof item === "object" && !Array.isArray(item) ? (item as Loose) : null
254
+ const raw = obj ? obj.value : item
255
+ const value = Array.isArray(raw) ? raw[raw.length - 1] : raw
256
+ const axisName = (axis: unknown): string | undefined => {
257
+ const a = asList(axis)[0]
258
+ if (!a || a.type !== "category" || !Array.isArray(a.data)) return undefined
259
+ const c = a.data[point.index]
260
+ return String(c && typeof c === "object" ? ((c as Loose).value ?? "") : c)
261
+ }
262
+ const category =
263
+ s.type === "pie"
264
+ ? (obj?.name as string | undefined)
265
+ : (axisName(option.xAxis) ?? axisName(option.yAxis) ?? (Array.isArray(raw) ? String(raw[0]) : obj?.name as string | undefined))
266
+ const fmt = asList(option.tooltip)[0]?.valueFormatter
267
+ const shown = value == null ? "" : typeof fmt === "function" ? String((fmt as (v: unknown) => unknown)(value)) : String(value)
268
+ return [s.name, category, shown].filter((p) => p != null && p !== "").join(", ")
269
+ }
270
+
196
271
  /* ---------- inlined from @squadbase/squadkit registry/lib/chart-theme.ts (internal to @squadbase/vantage) ---------- */
197
272
 
198
273
  /**
@@ -206,10 +281,25 @@ function useLocale(locale?: string | null): string {
206
281
  const CHART_OTHER_COLOR = "color-mix(in oklab, var(--neutral) 50%, var(--background))"
207
282
 
208
283
  /**
209
- * True inside `ChartFrame`. The frame's plot area carries the chart's single
210
- * `role="img"` and label, so `EChart` drops its own when it sees this.
284
+ * What a `ChartFrame` tells the plot inside it. A frame whose plot is an image
285
+ * (`image`) puts `role="img"` on its plot area, unless an `EChart` inside it
286
+ * `claim`s the image: then the canvas alone carries `role="img"` with this name
287
+ * and description (`claimed`), so the action buttons beside the canvas are not
288
+ * inside the image (an interactive control inside `role="img"` is a
289
+ * nested-interactive violation and is not exposed). A `group` frame never
290
+ * hands the role to the canvas. `false` outside a frame.
211
291
  */
212
- const ChartFrameContext = createContext(false)
292
+ interface ChartFramePlot {
293
+ image: boolean
294
+ /** An `EChart` inside has taken over the `role="img"`. */
295
+ claimed: boolean
296
+ /** Take over the image role; returns the release. */
297
+ claim: () => () => void
298
+ ariaLabel?: string
299
+ ariaLabelledBy?: string
300
+ ariaDescribedBy?: string
301
+ }
302
+ const ChartFrameContext = createContext<ChartFramePlot | false>(false)
213
303
 
214
304
  /** Series palette, in order. Missing tokens are skipped, not defaulted. */
215
305
  const SERIES_TOKENS = ["--chart-1", "--chart-2", "--chart-3", "--chart-4", "--chart-5"] as const
@@ -433,6 +523,8 @@ function buildChartTheme(tokens: ChartTokens): Record<string, unknown> {
433
523
  tooltip: {
434
524
  // ECharts' default 0.4s transform lags the tooltip ~300ms behind the cursor.
435
525
  transitionDuration: 0,
526
+ // Stay inside the chart: cards are often narrow and overflow-hidden.
527
+ confine: true,
436
528
  backgroundColor: tokens.popover ?? undefined,
437
529
  borderColor: line,
438
530
  borderWidth: 1,
@@ -1127,6 +1219,63 @@ export const EChart = forwardRef<HTMLDivElement, EChartProps>(function EChart(
1127
1219
  // 高さの解決: height prop を渡した場合はインライン style が勝つ。未指定なら
1128
1220
  // h-[400px] をデフォルトで当て、className の高さクラスに後勝ちさせる。
1129
1221
  const inFrame = useContext(ChartFrameContext)
1222
+ const claimImage = inFrame && inFrame.image ? inFrame.claim : undefined
1223
+ // In a ChartFrame the canvas takes over the frame's role="img" so the action buttons stay outside it.
1224
+ useEffect(() => claimImage?.(), [claimImage])
1225
+ // Keyboard reading: the canvas takes focus, arrows walk the points, the tooltip is the mouse one.
1226
+ const keyboardSeries = useMemo(() => chartKeyboardSeries(option), [option])
1227
+ const keyboard = keyboardSeries.length > 0 && !loading
1228
+ const cursorRef = useRef<ChartPoint | null>(null)
1229
+ const [announcement, setAnnouncement] = useState("")
1230
+ const hideCursor = useCallback(() => {
1231
+ const at = cursorRef.current
1232
+ if (!at || !instance || instance.isDisposed()) return
1233
+ instance.dispatchAction({ type: "downplay", seriesIndex: at.series, dataIndex: at.index })
1234
+ instance.dispatchAction({ type: "hideTip" })
1235
+ }, [instance])
1236
+ const showCursor = useCallback(
1237
+ (to: ChartPoint) => {
1238
+ if (!instance || instance.isDisposed()) return
1239
+ hideCursor()
1240
+ cursorRef.current = to
1241
+ instance.dispatchAction({ type: "highlight", seriesIndex: to.series, dataIndex: to.index })
1242
+ instance.dispatchAction({ type: "showTip", seriesIndex: to.series, dataIndex: to.index })
1243
+ setAnnouncement(describeChartPoint(option, to))
1244
+ },
1245
+ [instance, option, hideCursor],
1246
+ )
1247
+ const handleFocus = useCallback(
1248
+ (e: FocusEvent<HTMLDivElement>) => {
1249
+ // A mouse click focuses too, but only keyboard focus starts the reading (the mouse has its own tooltip).
1250
+ let visible = true
1251
+ try { visible = e.currentTarget.matches(":focus-visible") } catch { /* no :focus-visible support */ }
1252
+ if (visible) showCursor(cursorRef.current ?? moveChartPoint(null, "", keyboardSeries)!)
1253
+ },
1254
+ [showCursor, keyboardSeries],
1255
+ )
1256
+ const handleBlur = useCallback(() => {
1257
+ hideCursor()
1258
+ setAnnouncement("")
1259
+ }, [hideCursor])
1260
+ const handleKeyDown = useCallback(
1261
+ (e: KeyboardEvent<HTMLDivElement>) => {
1262
+ if (e.altKey || e.ctrlKey || e.metaKey) return
1263
+ if (e.key === "Escape") {
1264
+ hideCursor()
1265
+ return
1266
+ }
1267
+ if (!["ArrowRight", "ArrowLeft", "ArrowUp", "ArrowDown", "Home", "End"].includes(e.key)) return
1268
+ const to = moveChartPoint(cursorRef.current, e.key, keyboardSeries)
1269
+ // Up / Down with one series do nothing, so the page keeps scrolling.
1270
+ if (e.key === "ArrowUp" || e.key === "ArrowDown") {
1271
+ if (!to) return
1272
+ }
1273
+ e.preventDefault()
1274
+ if (to) showCursor(to)
1275
+ },
1276
+ [hideCursor, showCursor, keyboardSeries],
1277
+ )
1278
+
1130
1279
  const styleHeight =
1131
1280
  height == null ? undefined : typeof height === "number" ? `${height}px` : height
1132
1281
 
@@ -1137,14 +1286,25 @@ export const EChart = forwardRef<HTMLDivElement, EChartProps>(function EChart(
1137
1286
  className={cn("group relative w-full", height == null && "h-[400px]", className)}
1138
1287
  style={styleHeight != null ? { height: styleHeight } : undefined}
1139
1288
  >
1140
- {/* `role="img"` wraps the canvas only, so the action buttons stay reachable. Inside a ChartFrame the plot area is the one image. */}
1289
+ {/* `role="img"` wraps the canvas only, so the action buttons stay reachable. Inside a ChartFrame the canvas takes the frame's name and description (no role in a group frame). */}
1141
1290
  <div
1142
1291
  ref={containerRef}
1143
1292
  data-slot="echart-canvas"
1144
- className="h-full w-full"
1145
- role={inFrame ? undefined : "img"}
1146
- aria-label={inFrame ? undefined : (ariaLabel ?? t.chart)}
1293
+ className={cn("h-full w-full", keyboard && "rounded-lg outline-none focus-visible:ring-3 focus-visible:ring-ring/50")}
1294
+ tabIndex={keyboard ? 0 : undefined}
1295
+ onFocus={keyboard ? handleFocus : undefined}
1296
+ onBlur={keyboard ? handleBlur : undefined}
1297
+ onKeyDown={keyboard ? handleKeyDown : undefined}
1298
+ role={inFrame && !inFrame.claimed ? undefined : "img"}
1299
+ aria-label={inFrame ? (inFrame.claimed ? inFrame.ariaLabel : undefined) : (ariaLabel ?? t.chart)}
1300
+ aria-labelledby={inFrame && inFrame.claimed ? inFrame.ariaLabelledBy : undefined}
1301
+ aria-describedby={inFrame && inFrame.claimed ? inFrame.ariaDescribedBy : undefined}
1147
1302
  />
1303
+ {keyboard && (
1304
+ <div data-slot="echart-live" className="sr-only" aria-live="polite" aria-atomic="true">
1305
+ {announcement}
1306
+ </div>
1307
+ )}
1148
1308
  {actions && (
1149
1309
  <div
1150
1310
  data-slot="echart-actions"
@@ -0,0 +1,143 @@
1
+ // Copied by `vantage add ui file-dropzone`. This file is yours to edit.
2
+ // Generated from @squadbase/squadkit registry/ui/file-dropzone.tsx by `pnpm run registry` (in Vantage, edit packages/squadkit, not registry/).
3
+ "use client"
4
+
5
+ import * as React from "react"
6
+ import { UploadIcon, XIcon } from "lucide-react"
7
+
8
+ import { cn } from "@squadbase/vantage/ui"
9
+ import { Button } from "@squadbase/vantage/ui"
10
+
11
+ type FileDropzoneProps = Omit<React.ComponentProps<"div">, "onChange"> & {
12
+ /** Comma-separated types, same syntax as `<input accept>` (e.g. ".csv,.xlsx" or "text/*"). */
13
+ accept?: string
14
+ /** Largest allowed file size in bytes. */
15
+ maxSize?: number
16
+ multiple?: boolean
17
+ disabled?: boolean
18
+ onFilesChange?: (files: File[]) => void
19
+ }
20
+
21
+ function matchesAccept(file: File, accept?: string) {
22
+ if (!accept) return true
23
+ const name = file.name.toLowerCase()
24
+ return accept.split(",").some((raw) => {
25
+ const t = raw.trim().toLowerCase()
26
+ if (!t) return false
27
+ if (t.startsWith(".")) return name.endsWith(t)
28
+ if (t.endsWith("/*")) return file.type.toLowerCase().startsWith(t.slice(0, -1))
29
+ return file.type.toLowerCase() === t
30
+ })
31
+ }
32
+
33
+ function formatSize(bytes: number) {
34
+ if (bytes < 1024) return `${bytes} B`
35
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
36
+ return `${(bytes / 1024 / 1024).toFixed(1)} MB`
37
+ }
38
+
39
+ /** Drag & drop or click to pick files. Wrong type / too large files are rejected with a message and never reach `onFilesChange`. */
40
+ function FileDropzone({
41
+ accept,
42
+ maxSize,
43
+ multiple = false,
44
+ disabled = false,
45
+ onFilesChange,
46
+ className,
47
+ ...props
48
+ }: FileDropzoneProps) {
49
+ const inputRef = React.useRef<HTMLInputElement>(null)
50
+ const [files, setFiles] = React.useState<File[]>([])
51
+ const [error, setError] = React.useState<string | null>(null)
52
+ const [dragOver, setDragOver] = React.useState(false)
53
+
54
+ const commit = (next: File[]) => {
55
+ setFiles(next)
56
+ onFilesChange?.(next)
57
+ }
58
+
59
+ const accepting = (incoming: File[]) => {
60
+ const list = multiple ? incoming : incoming.slice(0, 1)
61
+ const badType = list.find((f) => !matchesAccept(f, accept))
62
+ if (badType) return setError(`${badType.name}: this file type isn't accepted${accept ? ` (${accept})` : ""}.`)
63
+ const tooBig = maxSize === undefined ? undefined : list.find((f) => f.size > maxSize)
64
+ if (tooBig) return setError(`${tooBig.name}: larger than ${formatSize(maxSize!)}.`)
65
+ setError(null)
66
+ commit(multiple ? [...files, ...list] : list)
67
+ }
68
+
69
+ return (
70
+ <div data-slot="file-dropzone" className={cn("flex flex-col gap-2", className)} {...props}>
71
+ <button
72
+ type="button"
73
+ disabled={disabled}
74
+ data-state={error ? "error" : dragOver ? "drag-over" : "idle"}
75
+ onClick={() => inputRef.current?.click()}
76
+ onDragOver={(e) => {
77
+ e.preventDefault()
78
+ if (!disabled) setDragOver(true)
79
+ }}
80
+ onDragLeave={() => setDragOver(false)}
81
+ onDrop={(e) => {
82
+ e.preventDefault()
83
+ setDragOver(false)
84
+ if (!disabled) accepting(Array.from(e.dataTransfer.files))
85
+ }}
86
+ className={cn(
87
+ "flex flex-col items-center gap-1 rounded-lg border border-dashed border-border bg-card px-6 py-8 text-sm text-muted-foreground outline-none transition-colors hover:bg-muted focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50",
88
+ "data-[state=drag-over]:border-primary data-[state=drag-over]:bg-muted data-[state=error]:border-destructive"
89
+ )}
90
+ >
91
+ <UploadIcon className="size-5" aria-hidden />
92
+ <span className="font-medium text-foreground">Drop {multiple ? "files" : "a file"} here, or click to browse</span>
93
+ {(accept || maxSize !== undefined) && (
94
+ <span className="text-xs">
95
+ {[accept, maxSize !== undefined && `up to ${formatSize(maxSize)}`].filter(Boolean).join(" · ")}
96
+ </span>
97
+ )}
98
+ </button>
99
+ <input
100
+ ref={inputRef}
101
+ type="file"
102
+ hidden
103
+ accept={accept}
104
+ multiple={multiple}
105
+ disabled={disabled}
106
+ data-slot="file-dropzone-input"
107
+ onChange={(e) => {
108
+ accepting(Array.from(e.target.files ?? []))
109
+ e.target.value = ""
110
+ }}
111
+ />
112
+ {error && (
113
+ <p role="alert" className="text-sm text-destructive">
114
+ {error}
115
+ </p>
116
+ )}
117
+ {files.length > 0 && (
118
+ <ul className="flex flex-col gap-1 text-sm">
119
+ {files.map((f, i) => (
120
+ <li key={`${f.name}-${i}`} className="flex items-center justify-between gap-2 rounded-md bg-muted px-2 py-1">
121
+ <span className="truncate">{f.name}</span>
122
+ <span className="flex items-center gap-1 text-xs text-muted-foreground tabular-nums">
123
+ {formatSize(f.size)}
124
+ <Button
125
+ type="button"
126
+ variant="ghost"
127
+ size="icon"
128
+ className="size-6"
129
+ aria-label={`Remove ${f.name}`}
130
+ onClick={() => commit(files.filter((_, j) => j !== i))}
131
+ >
132
+ <XIcon />
133
+ </Button>
134
+ </span>
135
+ </li>
136
+ ))}
137
+ </ul>
138
+ )}
139
+ </div>
140
+ )
141
+ }
142
+
143
+ export { FileDropzone, type FileDropzoneProps }
@@ -17,12 +17,21 @@ import {
17
17
  type HTMLAttributes,
18
18
  type ReactNode,
19
19
  } from "react"
20
- import { ChevronDown, X } from "lucide-react"
20
+ import { ChevronDown, Plus, X } from "lucide-react"
21
21
 
22
22
  import { cn } from "@squadbase/vantage/ui"
23
23
  import { Badge } from "@squadbase/vantage/ui"
24
24
  import { Button } from "@squadbase/vantage/ui"
25
25
  import { Checkbox } from "@squadbase/vantage/ui"
26
+ import {
27
+ DropdownMenu,
28
+ DropdownMenuCheckboxItem,
29
+ DropdownMenuContent,
30
+ DropdownMenuSub,
31
+ DropdownMenuSubContent,
32
+ DropdownMenuSubTrigger,
33
+ DropdownMenuTrigger,
34
+ } from "@squadbase/vantage/ui"
26
35
  import { Popover, PopoverContent, PopoverTrigger } from "@squadbase/vantage/ui"
27
36
  import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@squadbase/vantage/ui"
28
37
  import type { FilterConfig, FilterOption, FilterValues } from "@squadbase/vantage/components"
@@ -177,6 +186,7 @@ const FilterBarContext = createContext<FilterBarContextValue | null>(null)
177
186
  const TEXT = {
178
187
  ja: {
179
188
  filters: "フィルター",
189
+ addFilter: "フィルターを追加",
180
190
  selected: (count: number) => ` (${count}件選択中)`,
181
191
  remove: (filter: string, option: string) => `${filter}: ${option} を削除`,
182
192
  clear: "クリア",
@@ -184,6 +194,7 @@ const TEXT = {
184
194
  },
185
195
  en: {
186
196
  filters: "Filters",
197
+ addFilter: "Add filter",
187
198
  selected: (count: number) => ` (${count} selected)`,
188
199
  remove: (filter: string, option: string) => `Remove ${filter}: ${option}`,
189
200
  clear: "Clear",
@@ -357,6 +368,68 @@ export function FilterBarMultiSelect({
357
368
  }
358
369
  FilterBarMultiSelect.displayName = "FilterBarMultiSelect"
359
370
 
371
+ /* ------------------------------------------------------------ add filter */
372
+
373
+ export interface FilterBarAddFilterProps {
374
+ /** Conditions offered in the menu. Only `select` / `multi-select` entries with options are listed. */
375
+ filters: FilterConfig[]
376
+ className?: string
377
+ }
378
+
379
+ /** A menu that lists the conditions; each opens a submenu of values to check. Checked values show as chips. */
380
+ export function FilterBarAddFilter({ filters, className }: FilterBarAddFilterProps) {
381
+ const { value, onChange } = useFilterBar()
382
+ const t = useText()
383
+
384
+ const toggle = (filter: FilterConfig, option: string) => {
385
+ if (filter.type === "multi-select") {
386
+ const current = (value[filter.key] as string[] | undefined) ?? []
387
+ const next = current.includes(option) ? current.filter((v) => v !== option) : [...current, option]
388
+ onChange({ ...value, [filter.key]: next.length > 0 ? next : undefined })
389
+ } else {
390
+ onChange({ ...value, [filter.key]: value[filter.key] === option ? undefined : option })
391
+ }
392
+ }
393
+ const isChecked = (filter: FilterConfig, option: string) => {
394
+ const v = value[filter.key]
395
+ return Array.isArray(v) ? v.includes(option) : v === option
396
+ }
397
+
398
+ return (
399
+ <DropdownMenu>
400
+ <DropdownMenuTrigger
401
+ render={<Button variant="outline" size="sm" className={cn("h-8 gap-1", className)} />}
402
+ data-slot="filter-bar-add-filter"
403
+ >
404
+ <Plus className="size-3.5" aria-hidden="true" />
405
+ {t.addFilter}
406
+ </DropdownMenuTrigger>
407
+ <DropdownMenuContent align="start" className="w-48">
408
+ {filters
409
+ .filter((f) => (f.type === "select" || f.type === "multi-select") && f.options?.length)
410
+ .map((filter) => (
411
+ <DropdownMenuSub key={filter.key}>
412
+ <DropdownMenuSubTrigger>{filter.label}</DropdownMenuSubTrigger>
413
+ <DropdownMenuSubContent>
414
+ {filter.options?.map((option) => (
415
+ <DropdownMenuCheckboxItem
416
+ key={option.value}
417
+ checked={isChecked(filter, option.value)}
418
+ disabled={option.disabled}
419
+ onCheckedChange={() => toggle(filter, option.value)}
420
+ >
421
+ {option.label}
422
+ </DropdownMenuCheckboxItem>
423
+ ))}
424
+ </DropdownMenuSubContent>
425
+ </DropdownMenuSub>
426
+ ))}
427
+ </DropdownMenuContent>
428
+ </DropdownMenu>
429
+ )
430
+ }
431
+ FilterBarAddFilter.displayName = "FilterBarAddFilter"
432
+
360
433
  /* ---------------------------------------------------------- active chips */
361
434
 
362
435
  export interface FilterBarActiveChipsProps {