@squadbase/vantage 0.7.1-dev1.e38d696 → 0.7.1-dev1.fcc890c
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/dist/{build-U7GO26HR.js → build-YQGJBR3G.js} +3 -3
- package/dist/{build-U7GO26HR.js.map → build-YQGJBR3G.js.map} +1 -1
- package/dist/{chunk-EG4PX726.js → chunk-GWXKENNH.js} +122 -5
- package/dist/chunk-GWXKENNH.js.map +1 -0
- package/dist/{chunk-SC7K2RD6.js → chunk-I2GWIPTD.js} +101 -27
- package/dist/chunk-I2GWIPTD.js.map +1 -0
- package/dist/{chunk-SDJTTMLK.js → chunk-K2ZA547X.js} +3 -3
- package/dist/chunk-K2ZA547X.js.map +1 -0
- package/dist/{chunk-P2KIHF65.js → chunk-U6MWBCM6.js} +28 -7
- package/dist/chunk-U6MWBCM6.js.map +1 -0
- package/dist/cli.js +2 -2
- package/dist/components/index.d.ts +206 -4
- package/dist/components/index.js +3 -3
- package/dist/{dev-O42JFQ64.js → dev-5PHW3KNJ.js} +3 -3
- package/dist/{dev-O42JFQ64.js.map → dev-5PHW3KNJ.js.map} +1 -1
- package/dist/doc/index.js +1 -1
- package/dist/slide/index.js +1 -1
- package/dist/table/index.js +3 -3
- package/dist/ui/index.d.ts +28 -1
- package/dist/ui/index.js +172 -9
- package/dist/ui/index.js.map +1 -1
- package/dist/vite/index.js +1 -1
- package/package.json +2 -2
- package/registry/parts.json +81 -2
- 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 +47 -14
- package/registry/ui/checkbox.tsx +4 -3
- package/registry/ui/code-block.tsx +44 -0
- package/registry/ui/combo-chart.tsx +20 -3
- package/registry/ui/combobox.tsx +7 -0
- package/registry/ui/context-menu.tsx +71 -0
- package/registry/ui/dashboard-card.tsx +20 -9
- package/registry/ui/data-table.tsx +7 -1
- package/registry/ui/date-range-picker.tsx +51 -4
- package/registry/ui/delta.tsx +36 -11
- 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 +74 -1
- package/registry/ui/forecast-chart.tsx +42 -7
- package/registry/ui/funnel-steps.tsx +22 -4
- package/registry/ui/heatmap.tsx +20 -3
- package/registry/ui/histogram-chart.tsx +42 -7
- package/registry/ui/kpi-card.tsx +12 -8
- package/registry/ui/legend.tsx +73 -15
- package/registry/ui/line-chart.tsx +43 -8
- package/registry/ui/page-shell.tsx +2 -0
- package/registry/ui/paired-chart.tsx +43 -8
- package/registry/ui/progress.tsx +1 -1
- package/registry/ui/scatter-chart.tsx +50 -12
- package/registry/ui/sparkline.tsx +43 -8
- 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 +20 -3
- package/registry/ui/waterfall-chart.tsx +64 -11
- package/dist/chunk-EG4PX726.js.map +0 -1
- package/dist/chunk-P2KIHF65.js.map +0 -1
- package/dist/chunk-SC7K2RD6.js.map +0 -1
- package/dist/chunk-SDJTTMLK.js.map +0 -1
|
@@ -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
|
-
*
|
|
180
|
-
* `role="img"`
|
|
181
|
-
|
|
182
|
-
|
|
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:
|
|
2704
|
-
const radius = stacked
|
|
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
|
-
|
|
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",
|
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 }
|
|
@@ -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 {
|