@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.
- package/README.md +1 -1
- package/dist/add-AU3EDPNW.js +6 -0
- package/dist/add-AU3EDPNW.js.map +1 -0
- package/dist/{chunk-AABMKHEU.js → chunk-35B3YIBF.js} +16 -7
- package/dist/chunk-35B3YIBF.js.map +1 -0
- package/dist/{chunk-QNOX7WLZ.js → chunk-5HBUBVQ3.js} +94 -8
- package/dist/chunk-5HBUBVQ3.js.map +1 -0
- package/dist/{chunk-EG4PX726.js → chunk-GWXKENNH.js} +122 -5
- package/dist/chunk-GWXKENNH.js.map +1 -0
- package/dist/{chunk-77CISBMG.js → chunk-I2GWIPTD.js} +3685 -350
- package/dist/chunk-I2GWIPTD.js.map +1 -0
- package/dist/{chunk-TRMHNMMZ.js → chunk-LYUGDHSF.js} +3 -3
- package/dist/{chunk-TRMHNMMZ.js.map → chunk-LYUGDHSF.js.map} +1 -1
- package/dist/{chunk-2IAPIOPS.js → chunk-U6MWBCM6.js} +28 -7
- package/dist/chunk-U6MWBCM6.js.map +1 -0
- package/dist/{chunk-3RJEOP3J.js → chunk-XRKSIC5Z.js} +5 -3
- package/dist/chunk-XRKSIC5Z.js.map +1 -0
- package/dist/cli.js +2 -2
- package/dist/components/index.d.ts +849 -12
- package/dist/components/index.js +5 -5
- package/dist/{delta-text-CbrOaW83.d.ts → delta-text-tav2Lh5d.d.ts} +1 -1
- package/dist/doc/index.d.ts +2 -2
- package/dist/doc/index.js +4 -4
- package/dist/{format-DihQkL1N.d.ts → format-D34piCei.d.ts} +2 -1
- package/dist/geo-japan-7BMSOJRX.js +8 -0
- package/dist/geo-japan-7BMSOJRX.js.map +1 -0
- package/dist/geo-us-GVOG7VXH.js +8 -0
- package/dist/geo-us-GVOG7VXH.js.map +1 -0
- package/dist/geo-world-35HL56UU.js +8 -0
- package/dist/geo-world-35HL56UU.js.map +1 -0
- package/dist/slide/index.d.ts +2 -2
- package/dist/slide/index.js +4 -4
- package/dist/table/index.js +5 -6
- package/dist/table/index.js.map +1 -1
- package/dist/ui/index.d.ts +35 -4
- package/dist/ui/index.js +174 -11
- package/dist/ui/index.js.map +1 -1
- package/dist/{upgrade-6DT7ADRP.js → upgrade-MCHMELWC.js} +3 -3
- package/dist/{upgrade-6DT7ADRP.js.map → upgrade-MCHMELWC.js.map} +1 -1
- package/docs/en/agent-skills.md +13 -14
- package/docs/en/changelog.md +8 -0
- package/docs/en/parts/draft-chart.md +42 -0
- package/docs/en/parts/page-shell.md +1 -1
- package/docs/index.json +70 -50
- package/docs/ja/agent-skills.md +14 -14
- package/docs/ja/changelog.md +8 -0
- package/docs/ja/parts/draft-chart.md +40 -0
- package/docs/ja/parts/page-shell.md +1 -1
- package/package.json +2 -2
- package/registry/parts.json +5319 -0
- package/registry/ui/area-chart.tsx +43 -8
- package/registry/ui/bar-chart.tsx +43 -8
- package/registry/ui/bar-list.tsx +43 -8
- package/registry/ui/box-plot-chart.tsx +42 -7
- package/registry/ui/category-bar.tsx +25 -5
- package/registry/ui/chart-frame.tsx +55 -14
- package/registry/ui/checkbox.tsx +4 -3
- package/registry/ui/code-block.tsx +44 -0
- package/registry/ui/combo-chart.tsx +1869 -0
- package/registry/ui/combobox.tsx +7 -0
- package/registry/ui/context-menu.tsx +71 -0
- package/registry/ui/dashboard-card.tsx +23 -10
- package/registry/ui/data-table.tsx +10 -2
- package/registry/ui/date-range-picker.tsx +51 -4
- package/registry/ui/delta.tsx +39 -12
- package/registry/ui/donut-chart.tsx +43 -8
- package/registry/ui/echart.tsx +167 -7
- package/registry/ui/file-dropzone.tsx +143 -0
- package/registry/ui/filter-bar.tsx +77 -2
- package/registry/ui/forecast-chart.tsx +4769 -0
- package/registry/ui/funnel-steps.tsx +23 -5
- package/registry/ui/heatmap.tsx +20 -3
- package/registry/ui/histogram-chart.tsx +42 -7
- package/registry/ui/kpi-card.tsx +15 -9
- package/registry/ui/legend.tsx +73 -15
- package/registry/ui/line-chart.tsx +43 -8
- package/registry/ui/metric-value.tsx +2 -2
- package/registry/ui/page-shell.tsx +27 -4
- package/registry/ui/paired-chart.tsx +44 -9
- package/registry/ui/pivot-table.tsx +1260 -0
- package/registry/ui/progress-circle.tsx +2 -2
- package/registry/ui/progress.tsx +1 -1
- package/registry/ui/scatter-chart.tsx +50 -12
- package/registry/ui/sparkline.tsx +44 -9
- package/registry/ui/states.tsx +34 -1
- package/registry/ui/status-badge.tsx +7 -7
- package/registry/ui/tabs.tsx +1 -0
- package/registry/ui/treemap.tsx +1934 -0
- package/registry/ui/waterfall-chart.tsx +64 -11
- package/templates/AGENTS.md +3 -3
- package/templates/agents/page.md +5 -4
- package/dist/add-4GHF2TX2.js +0 -6
- package/dist/add-4GHF2TX2.js.map +0 -1
- package/dist/chunk-2IAPIOPS.js.map +0 -1
- package/dist/chunk-3RJEOP3J.js.map +0 -1
- package/dist/chunk-77CISBMG.js.map +0 -1
- package/dist/chunk-AABMKHEU.js.map +0 -1
- package/dist/chunk-EG4PX726.js.map +0 -1
- package/dist/chunk-NZ5QIIG4.js +0 -74
- package/dist/chunk-NZ5QIIG4.js.map +0 -1
- package/dist/chunk-QNOX7WLZ.js.map +0 -1
package/registry/ui/echart.tsx
CHANGED
|
@@ -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
|
-
*
|
|
210
|
-
* `role="img"`
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1146
|
-
|
|
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
|
-
*
|
|
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 {
|