@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
@@ -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 }
@@ -3,7 +3,9 @@
3
3
  "use client"
4
4
 
5
5
  /**
6
- * FilterBar — a compound filter row. `<FilterBar>` holds the (always
6
+ * FilterBarPreset — a whole filter row built from a `FilterConfig[]`, placed at the top of a page or section.
7
+ *
8
+ * `FilterBar` is the compound form: `<FilterBar>` holds the (always
7
9
  * controlled) filter values on context; the select, multi-select, active-chip
8
10
  * and clear parts read and update them. `FilterBarPreset` builds the whole row
9
11
  * from a `FilterConfig[]`.
@@ -15,12 +17,21 @@ import {
15
17
  type HTMLAttributes,
16
18
  type ReactNode,
17
19
  } from "react"
18
- import { ChevronDown, X } from "lucide-react"
20
+ import { ChevronDown, Plus, X } from "lucide-react"
19
21
 
20
22
  import { cn } from "@squadbase/vantage/ui"
21
23
  import { Badge } from "@squadbase/vantage/ui"
22
24
  import { Button } from "@squadbase/vantage/ui"
23
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"
24
35
  import { Popover, PopoverContent, PopoverTrigger } from "@squadbase/vantage/ui"
25
36
  import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@squadbase/vantage/ui"
26
37
  import type { FilterConfig, FilterOption, FilterValues } from "@squadbase/vantage/components"
@@ -175,6 +186,7 @@ const FilterBarContext = createContext<FilterBarContextValue | null>(null)
175
186
  const TEXT = {
176
187
  ja: {
177
188
  filters: "フィルター",
189
+ addFilter: "フィルターを追加",
178
190
  selected: (count: number) => ` (${count}件選択中)`,
179
191
  remove: (filter: string, option: string) => `${filter}: ${option} を削除`,
180
192
  clear: "クリア",
@@ -182,6 +194,7 @@ const TEXT = {
182
194
  },
183
195
  en: {
184
196
  filters: "Filters",
197
+ addFilter: "Add filter",
185
198
  selected: (count: number) => ` (${count} selected)`,
186
199
  remove: (filter: string, option: string) => `Remove ${filter}: ${option}`,
187
200
  clear: "Clear",
@@ -355,6 +368,68 @@ export function FilterBarMultiSelect({
355
368
  }
356
369
  FilterBarMultiSelect.displayName = "FilterBarMultiSelect"
357
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
+
358
433
  /* ---------------------------------------------------------- active chips */
359
434
 
360
435
  export interface FilterBarActiveChipsProps {