@matthieumordrel/chart-studio-ui 0.5.2
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 +35 -0
- package/dist/index.d.mts +19 -0
- package/dist/index.mjs +18 -0
- package/dist/theme.css +67 -0
- package/dist/ui/chart-axis-ticks.mjs +65 -0
- package/dist/ui/chart-canvas.d.mts +40 -0
- package/dist/ui/chart-canvas.mjs +872 -0
- package/dist/ui/chart-context.d.mts +101 -0
- package/dist/ui/chart-context.mjs +117 -0
- package/dist/ui/chart-date-range-badge.d.mts +20 -0
- package/dist/ui/chart-date-range-badge.mjs +49 -0
- package/dist/ui/chart-date-range-panel.d.mts +18 -0
- package/dist/ui/chart-date-range-panel.mjs +126 -0
- package/dist/ui/chart-date-range.d.mts +20 -0
- package/dist/ui/chart-date-range.mjs +67 -0
- package/dist/ui/chart-debug.d.mts +21 -0
- package/dist/ui/chart-debug.mjs +172 -0
- package/dist/ui/chart-dropdown.mjs +92 -0
- package/dist/ui/chart-filters-panel.d.mts +26 -0
- package/dist/ui/chart-filters-panel.mjs +258 -0
- package/dist/ui/chart-filters.d.mts +18 -0
- package/dist/ui/chart-filters.mjs +48 -0
- package/dist/ui/chart-group-by-selector.d.mts +16 -0
- package/dist/ui/chart-group-by-selector.mjs +32 -0
- package/dist/ui/chart-metric-panel.d.mts +25 -0
- package/dist/ui/chart-metric-panel.mjs +172 -0
- package/dist/ui/chart-metric-selector.d.mts +16 -0
- package/dist/ui/chart-metric-selector.mjs +50 -0
- package/dist/ui/chart-select.mjs +61 -0
- package/dist/ui/chart-source-switcher.d.mts +24 -0
- package/dist/ui/chart-source-switcher.mjs +56 -0
- package/dist/ui/chart-time-bucket-selector.d.mts +17 -0
- package/dist/ui/chart-time-bucket-selector.mjs +37 -0
- package/dist/ui/chart-toolbar-overflow.d.mts +28 -0
- package/dist/ui/chart-toolbar-overflow.mjs +223 -0
- package/dist/ui/chart-toolbar.d.mts +33 -0
- package/dist/ui/chart-toolbar.mjs +60 -0
- package/dist/ui/chart-type-selector.d.mts +19 -0
- package/dist/ui/chart-type-selector.mjs +173 -0
- package/dist/ui/chart-x-axis-selector.d.mts +16 -0
- package/dist/ui/chart-x-axis-selector.mjs +28 -0
- package/dist/ui/index.d.mts +18 -0
- package/dist/ui/percent-stacked.mjs +36 -0
- package/dist/ui/toolbar-types.d.mts +7 -0
- package/dist/ui/toolbar-types.mjs +83 -0
- package/package.json +55 -0
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { useChartContext } from "./chart-context.mjs";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
4
|
+
import { applyFilters, computeDateRange, filterByDateRange } from "@matthieumordrel/chart-studio/_internal";
|
|
5
|
+
//#region src/ui/chart-debug.tsx
|
|
6
|
+
/**
|
|
7
|
+
* Debug panel — shows raw data, transformed data, series, and current state.
|
|
8
|
+
* Drop `<ChartDebug />` inside a `<Chart>` to inspect what's happening.
|
|
9
|
+
*/
|
|
10
|
+
const TABS = [
|
|
11
|
+
{
|
|
12
|
+
id: "raw",
|
|
13
|
+
label: "Raw"
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
id: "transformed",
|
|
17
|
+
label: "Transformed"
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
id: "series",
|
|
21
|
+
label: "Series"
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
id: "state",
|
|
25
|
+
label: "State"
|
|
26
|
+
}
|
|
27
|
+
];
|
|
28
|
+
/**
|
|
29
|
+
* Format a date as a full calendar label to avoid ambiguous numeric dates.
|
|
30
|
+
*/
|
|
31
|
+
function formatFullDay(date) {
|
|
32
|
+
return new Intl.DateTimeFormat("en-US", {
|
|
33
|
+
month: "long",
|
|
34
|
+
day: "numeric",
|
|
35
|
+
year: "numeric",
|
|
36
|
+
timeZone: "UTC"
|
|
37
|
+
}).format(date);
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Get the exact earliest and latest visible dates for the active date X-axis.
|
|
41
|
+
*/
|
|
42
|
+
function getVisibleDateRange(chart) {
|
|
43
|
+
if (!chart.isTimeSeries || !chart.xAxisId) return null;
|
|
44
|
+
const xAxisColumn = chart.columns.find((column) => column.id === chart.xAxisId);
|
|
45
|
+
if (!xAxisColumn || xAxisColumn.type !== "date") return null;
|
|
46
|
+
let visibleData = chart.rawData;
|
|
47
|
+
if (chart.dateRangeFilter && chart.referenceDateId) {
|
|
48
|
+
const referenceDateColumn = chart.columns.find((column) => column.id === chart.referenceDateId);
|
|
49
|
+
if (referenceDateColumn?.type === "date") visibleData = filterByDateRange(visibleData, referenceDateColumn, chart.dateRangeFilter);
|
|
50
|
+
}
|
|
51
|
+
visibleData = applyFilters(visibleData, chart.columns, chart.filters);
|
|
52
|
+
const { min, max } = computeDateRange(visibleData, xAxisColumn);
|
|
53
|
+
if (!min || !max) return null;
|
|
54
|
+
return {
|
|
55
|
+
columnId: xAxisColumn.id,
|
|
56
|
+
columnLabel: xAxisColumn.label,
|
|
57
|
+
earliest: { label: formatFullDay(min) },
|
|
58
|
+
latest: { label: formatFullDay(max) },
|
|
59
|
+
rule: "Uses exact dates from visible rows on the active X-axis date column."
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Build a compact UI label for the visible time range.
|
|
64
|
+
*/
|
|
65
|
+
function getVisibleDateRangeLabel(range) {
|
|
66
|
+
if (!range) return null;
|
|
67
|
+
const columnLabel = `${range.columnLabel} (${range.columnId})`;
|
|
68
|
+
if (range.earliest.label === range.latest.label) return `Visible exact date: ${range.earliest.label} via ${columnLabel}`;
|
|
69
|
+
return `Visible exact date range: ${range.earliest.label} -> ${range.latest.label} via ${columnLabel}`;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Resolve the currently selected debug payload.
|
|
73
|
+
*/
|
|
74
|
+
function getDebugContent(chart, activeTab) {
|
|
75
|
+
switch (activeTab) {
|
|
76
|
+
case "raw": return chart.rawData;
|
|
77
|
+
case "transformed": return chart.transformedData;
|
|
78
|
+
case "series": return chart.series;
|
|
79
|
+
case "state": return {
|
|
80
|
+
activeSourceId: chart.activeSourceId,
|
|
81
|
+
chartType: chart.chartType,
|
|
82
|
+
xAxisId: chart.xAxisId,
|
|
83
|
+
groupById: chart.groupById,
|
|
84
|
+
metric: chart.metric,
|
|
85
|
+
timeBucket: chart.timeBucket,
|
|
86
|
+
availableTimeBuckets: chart.availableTimeBuckets,
|
|
87
|
+
isTimeSeries: chart.isTimeSeries,
|
|
88
|
+
filters: Object.fromEntries([...chart.filters.entries()].map(([k, v]) => [k, [...v]])),
|
|
89
|
+
sorting: chart.sorting,
|
|
90
|
+
availableChartTypes: chart.availableChartTypes,
|
|
91
|
+
availableGroupBys: chart.availableGroupBys,
|
|
92
|
+
availableMetrics: chart.availableMetrics,
|
|
93
|
+
columns: chart.columns,
|
|
94
|
+
availableFilters: chart.availableFilters.map((filter) => ({
|
|
95
|
+
...filter,
|
|
96
|
+
options: filter.options.length > 5 ? [...filter.options.slice(0, 5), {
|
|
97
|
+
value: "...",
|
|
98
|
+
label: `+${filter.options.length - 5} more`,
|
|
99
|
+
count: 0
|
|
100
|
+
}] : filter.options
|
|
101
|
+
})),
|
|
102
|
+
visibleDateRange: getVisibleDateRange(chart)
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
/** Tab bar button. */
|
|
107
|
+
function TabButton({ tab, isActive, onClick }) {
|
|
108
|
+
return /* @__PURE__ */ jsx("button", {
|
|
109
|
+
onClick,
|
|
110
|
+
className: `border-b-2 px-3 py-1.5 text-[11px] font-mono transition-colors ${isActive ? "border-primary bg-primary/10 text-primary" : "border-transparent text-muted-foreground hover:text-foreground"}`,
|
|
111
|
+
children: tab.label
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
/** Debug panel that renders chart internals as formatted JSON.
|
|
115
|
+
*
|
|
116
|
+
* @param className - Additional CSS classes for the debug panel.
|
|
117
|
+
* @param defaultOpen - Whether the debug panel should be open by default. (default: false)
|
|
118
|
+
*/
|
|
119
|
+
function ChartDebug({ className, defaultOpen = false }) {
|
|
120
|
+
const chart = useChartContext();
|
|
121
|
+
const [isOpen, setIsOpen] = useState(defaultOpen);
|
|
122
|
+
const [activeTab, setActiveTab] = useState("raw");
|
|
123
|
+
const content = getDebugContent(chart, activeTab);
|
|
124
|
+
const visibleDateRangeLabel = getVisibleDateRangeLabel(getVisibleDateRange(chart));
|
|
125
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
126
|
+
className: `overflow-hidden rounded-lg border border-dashed border-border bg-background ${className ?? ""}`,
|
|
127
|
+
children: [/* @__PURE__ */ jsx("button", {
|
|
128
|
+
onClick: () => setIsOpen(!isOpen),
|
|
129
|
+
className: "flex w-full items-center gap-2 px-3 py-2 text-left text-xs font-mono text-foreground transition-colors hover:bg-muted/50",
|
|
130
|
+
children: /* @__PURE__ */ jsxs("span", {
|
|
131
|
+
className: "flex items-center gap-2",
|
|
132
|
+
children: [
|
|
133
|
+
/* @__PURE__ */ jsx("span", { children: isOpen ? "▼" : "▶" }),
|
|
134
|
+
/* @__PURE__ */ jsx("span", { children: "chart-studio debug" }),
|
|
135
|
+
/* @__PURE__ */ jsxs("span", {
|
|
136
|
+
className: "text-muted-foreground",
|
|
137
|
+
children: [
|
|
138
|
+
"(",
|
|
139
|
+
chart.rawData.length,
|
|
140
|
+
" raw, ",
|
|
141
|
+
chart.transformedData.length,
|
|
142
|
+
" points, ",
|
|
143
|
+
chart.series.length,
|
|
144
|
+
" ",
|
|
145
|
+
"series)"
|
|
146
|
+
]
|
|
147
|
+
}),
|
|
148
|
+
visibleDateRangeLabel ? /* @__PURE__ */ jsxs("span", {
|
|
149
|
+
className: "text-[11px] text-muted-foreground",
|
|
150
|
+
children: ["• ", visibleDateRangeLabel]
|
|
151
|
+
}) : null
|
|
152
|
+
]
|
|
153
|
+
})
|
|
154
|
+
}), isOpen && /* @__PURE__ */ jsxs("div", {
|
|
155
|
+
className: "border-t border-dashed border-border",
|
|
156
|
+
children: [/* @__PURE__ */ jsx("div", {
|
|
157
|
+
className: "flex gap-0 border-b border-border bg-muted/20",
|
|
158
|
+
children: TABS.map((tab) => /* @__PURE__ */ jsx(TabButton, {
|
|
159
|
+
tab,
|
|
160
|
+
isActive: activeTab === tab.id,
|
|
161
|
+
onClick: () => setActiveTab(tab.id)
|
|
162
|
+
}, tab.id))
|
|
163
|
+
}), /* @__PURE__ */ jsx("pre", {
|
|
164
|
+
className: "max-h-64 overflow-auto bg-muted/20 p-3 font-mono text-[11px] leading-relaxed text-foreground",
|
|
165
|
+
onWheel: (e) => e.stopPropagation(),
|
|
166
|
+
children: JSON.stringify(content, null, 2)
|
|
167
|
+
})]
|
|
168
|
+
})]
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
//#endregion
|
|
172
|
+
export { ChartDebug };
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { useLayoutEffect, useRef, useState } from "react";
|
|
2
|
+
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { createPortal } from "react-dom";
|
|
4
|
+
//#region src/ui/chart-dropdown.tsx
|
|
5
|
+
/**
|
|
6
|
+
* Shared dropdown panel primitive for chart-studio popover controls.
|
|
7
|
+
* Provides a positioned floating panel with backdrop, premium layered
|
|
8
|
+
* shadows, and close-on-click-outside.
|
|
9
|
+
*/
|
|
10
|
+
/**
|
|
11
|
+
* Positioned dropdown panel with premium styling.
|
|
12
|
+
* Renders a transparent backdrop overlay and a fixed-position content panel
|
|
13
|
+
* anchored to a trigger element.
|
|
14
|
+
*
|
|
15
|
+
* @property isOpen - Whether the panel is visible
|
|
16
|
+
* @property onClose - Callback to close the panel
|
|
17
|
+
* @property triggerRef - Ref to the button or element that anchors the panel
|
|
18
|
+
* @property align - Horizontal alignment relative to trigger ('left' | 'right')
|
|
19
|
+
* @property width - Fixed panel width in pixels
|
|
20
|
+
* @property minWidth - Minimum panel width in pixels or equal to trigger width
|
|
21
|
+
* @property offset - Gap between trigger and panel
|
|
22
|
+
* @property repositionKey - Value that forces re-measurement when panel content changes
|
|
23
|
+
* @property className - Additional CSS classes for the content area
|
|
24
|
+
* @property children - Panel content
|
|
25
|
+
*/
|
|
26
|
+
function ChartDropdownPanel({ isOpen, onClose, triggerRef, align = "left", width, minWidth, offset = 6, repositionKey, className, children }) {
|
|
27
|
+
const panelRef = useRef(null);
|
|
28
|
+
const [position, setPosition] = useState(null);
|
|
29
|
+
useLayoutEffect(() => {
|
|
30
|
+
if (!isOpen) {
|
|
31
|
+
setPosition(null);
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Measure the trigger and panel, then place the panel in the best
|
|
36
|
+
* available viewport position.
|
|
37
|
+
*/
|
|
38
|
+
function updatePosition() {
|
|
39
|
+
const trigger = triggerRef.current;
|
|
40
|
+
const panel = panelRef.current;
|
|
41
|
+
if (!trigger || !panel) return;
|
|
42
|
+
const triggerRect = trigger.getBoundingClientRect();
|
|
43
|
+
const measuredPanelWidth = width ?? panel.offsetWidth;
|
|
44
|
+
const resolvedMinWidth = minWidth === "trigger" ? triggerRect.width : minWidth;
|
|
45
|
+
const panelWidth = Math.max(measuredPanelWidth, resolvedMinWidth ?? 0);
|
|
46
|
+
const panelHeight = panel.offsetHeight;
|
|
47
|
+
let left = align === "right" ? triggerRect.right - panelWidth : triggerRect.left;
|
|
48
|
+
left = Math.min(Math.max(left, 8), window.innerWidth - panelWidth - 8);
|
|
49
|
+
let top = window.innerHeight - triggerRect.bottom < panelHeight + offset && triggerRect.top >= panelHeight + offset ? triggerRect.top - panelHeight - offset : triggerRect.bottom + offset;
|
|
50
|
+
top = Math.max(8, Math.min(top, window.innerHeight - panelHeight - 8));
|
|
51
|
+
setPosition({
|
|
52
|
+
top,
|
|
53
|
+
left
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
updatePosition();
|
|
57
|
+
window.addEventListener("resize", updatePosition);
|
|
58
|
+
window.addEventListener("scroll", updatePosition, true);
|
|
59
|
+
return () => {
|
|
60
|
+
window.removeEventListener("resize", updatePosition);
|
|
61
|
+
window.removeEventListener("scroll", updatePosition, true);
|
|
62
|
+
};
|
|
63
|
+
}, [
|
|
64
|
+
align,
|
|
65
|
+
isOpen,
|
|
66
|
+
minWidth,
|
|
67
|
+
offset,
|
|
68
|
+
repositionKey,
|
|
69
|
+
triggerRef,
|
|
70
|
+
width
|
|
71
|
+
]);
|
|
72
|
+
if (!isOpen) return null;
|
|
73
|
+
if (typeof document === "undefined") return null;
|
|
74
|
+
return createPortal(/* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("div", {
|
|
75
|
+
className: "fixed inset-0 z-40",
|
|
76
|
+
onClick: onClose
|
|
77
|
+
}), /* @__PURE__ */ jsx("div", {
|
|
78
|
+
ref: panelRef,
|
|
79
|
+
className: `fixed z-50 overflow-hidden rounded-xl border border-border/50 bg-popover text-popover-foreground shadow-[0_8px_30px_-6px_rgba(0,0,0,0.12),0_2px_8px_-2px_rgba(0,0,0,0.05)] ${className ?? ""}`,
|
|
80
|
+
style: {
|
|
81
|
+
top: position?.top ?? 0,
|
|
82
|
+
left: position?.left ?? 0,
|
|
83
|
+
width,
|
|
84
|
+
minWidth: minWidth === "trigger" ? triggerRef.current?.getBoundingClientRect().width : minWidth,
|
|
85
|
+
visibility: position ? "visible" : "hidden"
|
|
86
|
+
},
|
|
87
|
+
onWheel: (e) => e.stopPropagation(),
|
|
88
|
+
children
|
|
89
|
+
})] }), document.body);
|
|
90
|
+
}
|
|
91
|
+
//#endregion
|
|
92
|
+
export { ChartDropdownPanel };
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import * as react_jsx_runtime0 from "react/jsx-runtime";
|
|
2
|
+
|
|
3
|
+
//#region src/ui/chart-filters-panel.d.ts
|
|
4
|
+
/**
|
|
5
|
+
* Filters panel content — reusable by both ChartFilters (inside a popover)
|
|
6
|
+
* and ChartToolbarOverflow (rendered inline).
|
|
7
|
+
*
|
|
8
|
+
* Shows filterable columns as collapsible sections with checkbox options.
|
|
9
|
+
* Each column section can be expanded/collapsed independently.
|
|
10
|
+
*/
|
|
11
|
+
/**
|
|
12
|
+
* Filters panel content (no popover wrapper).
|
|
13
|
+
*
|
|
14
|
+
* @property className - Additional CSS classes
|
|
15
|
+
* @property showHeader - When true (default), shows "Filters" title + clear button.
|
|
16
|
+
* Set false when the header is rendered by the parent (e.g. overflow DetailPage).
|
|
17
|
+
*/
|
|
18
|
+
declare function ChartFiltersPanel({
|
|
19
|
+
className,
|
|
20
|
+
showHeader
|
|
21
|
+
}: {
|
|
22
|
+
className?: string;
|
|
23
|
+
showHeader?: boolean;
|
|
24
|
+
}): react_jsx_runtime0.JSX.Element;
|
|
25
|
+
//#endregion
|
|
26
|
+
export { ChartFiltersPanel };
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import { useChartContext } from "./chart-context.mjs";
|
|
2
|
+
import { useLayoutEffect, useRef, useState } from "react";
|
|
3
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
4
|
+
import { ChevronDown, Eraser, Search, X } from "lucide-react";
|
|
5
|
+
//#region src/ui/chart-filters-panel.tsx
|
|
6
|
+
/**
|
|
7
|
+
* Filters panel content — reusable by both ChartFilters (inside a popover)
|
|
8
|
+
* and ChartToolbarOverflow (rendered inline).
|
|
9
|
+
*
|
|
10
|
+
* Shows filterable columns as collapsible sections with checkbox options.
|
|
11
|
+
* Each column section can be expanded/collapsed independently.
|
|
12
|
+
*/
|
|
13
|
+
/** Maximum number of options to show per column before collapsing. */
|
|
14
|
+
const MAX_VISIBLE_OPTIONS = 6;
|
|
15
|
+
/**
|
|
16
|
+
* Filters panel content (no popover wrapper).
|
|
17
|
+
*
|
|
18
|
+
* @property className - Additional CSS classes
|
|
19
|
+
* @property showHeader - When true (default), shows "Filters" title + clear button.
|
|
20
|
+
* Set false when the header is rendered by the parent (e.g. overflow DetailPage).
|
|
21
|
+
*/
|
|
22
|
+
function ChartFiltersPanel({ className, showHeader = true }) {
|
|
23
|
+
const { availableFilters, filters, toggleFilter, clearAllFilters } = useChartContext();
|
|
24
|
+
const [searchQuery, setSearchQuery] = useState("");
|
|
25
|
+
const searchInputRef = useRef(null);
|
|
26
|
+
const activeCount = [...filters.values()].reduce((sum, set) => sum + set.size, 0);
|
|
27
|
+
const activeBadges = availableFilters.flatMap((af) => {
|
|
28
|
+
const active = filters.get(af.columnId);
|
|
29
|
+
if (!active?.size) return [];
|
|
30
|
+
return [...active].map((value) => {
|
|
31
|
+
const option = af.options.find((o) => o.value === value);
|
|
32
|
+
return {
|
|
33
|
+
columnId: af.columnId,
|
|
34
|
+
value,
|
|
35
|
+
label: `${af.label}: ${option?.label ?? value}`
|
|
36
|
+
};
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
const normalizedQuery = searchQuery.trim().toLowerCase();
|
|
40
|
+
const isSearching = normalizedQuery.length > 0;
|
|
41
|
+
const filteredSections = availableFilters.map((filter) => {
|
|
42
|
+
if (!isSearching) return {
|
|
43
|
+
filter,
|
|
44
|
+
matchedOptions: filter.options
|
|
45
|
+
};
|
|
46
|
+
return {
|
|
47
|
+
filter,
|
|
48
|
+
matchedOptions: filter.options.filter((option) => option.label.toLowerCase().includes(normalizedQuery))
|
|
49
|
+
};
|
|
50
|
+
});
|
|
51
|
+
const visibleSections = isSearching ? filteredSections.filter((s) => s.matchedOptions.length > 0) : filteredSections;
|
|
52
|
+
if (availableFilters.length === 0) return /* @__PURE__ */ jsx("div", {
|
|
53
|
+
className,
|
|
54
|
+
children: /* @__PURE__ */ jsx("div", {
|
|
55
|
+
className: "py-6 text-center text-xs text-muted-foreground",
|
|
56
|
+
children: "No filterable columns"
|
|
57
|
+
})
|
|
58
|
+
});
|
|
59
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
60
|
+
className,
|
|
61
|
+
children: [
|
|
62
|
+
showHeader && /* @__PURE__ */ jsxs("div", {
|
|
63
|
+
className: "mb-3 flex items-center justify-between gap-2",
|
|
64
|
+
children: [/* @__PURE__ */ jsx("div", {
|
|
65
|
+
className: "truncate text-xs font-semibold text-foreground",
|
|
66
|
+
children: "Filters"
|
|
67
|
+
}), /* @__PURE__ */ jsx("button", {
|
|
68
|
+
onClick: () => clearAllFilters(),
|
|
69
|
+
disabled: activeCount === 0,
|
|
70
|
+
className: "shrink-0 rounded-md p-1.5 text-muted-foreground transition-colors enabled:hover:bg-muted enabled:hover:text-foreground disabled:opacity-0",
|
|
71
|
+
"aria-label": "Clear all filters",
|
|
72
|
+
children: /* @__PURE__ */ jsx(Eraser, { className: "h-3.5 w-3.5" })
|
|
73
|
+
})]
|
|
74
|
+
}),
|
|
75
|
+
/* @__PURE__ */ jsxs("div", {
|
|
76
|
+
className: "relative mb-2",
|
|
77
|
+
children: [
|
|
78
|
+
/* @__PURE__ */ jsx(Search, { className: "pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground/60" }),
|
|
79
|
+
/* @__PURE__ */ jsx("input", {
|
|
80
|
+
ref: searchInputRef,
|
|
81
|
+
type: "text",
|
|
82
|
+
value: searchQuery,
|
|
83
|
+
onChange: (e) => setSearchQuery(e.target.value),
|
|
84
|
+
placeholder: "Search filters…",
|
|
85
|
+
className: "h-8 w-full rounded-lg border border-border/50 bg-muted/30 pl-8 pr-8 text-xs text-foreground placeholder:text-muted-foreground/50 focus:border-primary/30 focus:bg-background focus:outline-none focus:ring-2 focus:ring-ring/20",
|
|
86
|
+
"aria-label": "Search filters"
|
|
87
|
+
}),
|
|
88
|
+
isSearching && /* @__PURE__ */ jsx("button", {
|
|
89
|
+
onClick: () => {
|
|
90
|
+
setSearchQuery("");
|
|
91
|
+
searchInputRef.current?.focus();
|
|
92
|
+
},
|
|
93
|
+
className: "absolute right-1.5 top-1/2 -translate-y-1/2 rounded-md p-1 text-muted-foreground/60 transition-colors hover:text-foreground",
|
|
94
|
+
"aria-label": "Clear search",
|
|
95
|
+
children: /* @__PURE__ */ jsx(X, { className: "h-3.5 w-3.5" })
|
|
96
|
+
})
|
|
97
|
+
]
|
|
98
|
+
}),
|
|
99
|
+
isSearching && visibleSections.length === 0 && /* @__PURE__ */ jsxs("div", {
|
|
100
|
+
className: "py-4 text-center text-xs text-muted-foreground",
|
|
101
|
+
children: [
|
|
102
|
+
"No filters matching “",
|
|
103
|
+
searchQuery.trim(),
|
|
104
|
+
"”"
|
|
105
|
+
]
|
|
106
|
+
}),
|
|
107
|
+
/* @__PURE__ */ jsx("div", {
|
|
108
|
+
className: "space-y-1",
|
|
109
|
+
children: visibleSections.map(({ filter, matchedOptions }) => /* @__PURE__ */ jsx(FilterSection, {
|
|
110
|
+
filter,
|
|
111
|
+
matchedOptions,
|
|
112
|
+
isSearching,
|
|
113
|
+
activeValues: filters.get(filter.columnId),
|
|
114
|
+
onToggle: (value) => toggleFilter(filter.columnId, value)
|
|
115
|
+
}, filter.columnId))
|
|
116
|
+
}),
|
|
117
|
+
activeCount > 0 && /* @__PURE__ */ jsx("div", {
|
|
118
|
+
className: "mt-2 border-t border-border/40 pt-2",
|
|
119
|
+
children: /* @__PURE__ */ jsx(BadgeRow, {
|
|
120
|
+
badges: activeBadges,
|
|
121
|
+
onRemove: toggleFilter
|
|
122
|
+
})
|
|
123
|
+
})
|
|
124
|
+
]
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Renders filter badges that fit within 2 rows, with a "+N" overflow pill
|
|
129
|
+
* for badges that don't fit. Measures after layout to determine the cutoff.
|
|
130
|
+
*/
|
|
131
|
+
function BadgeRow({ badges, onRemove }) {
|
|
132
|
+
const containerRef = useRef(null);
|
|
133
|
+
const [visibleCount, setVisibleCount] = useState(null);
|
|
134
|
+
useLayoutEffect(() => {
|
|
135
|
+
setVisibleCount(null);
|
|
136
|
+
}, [badges.length]);
|
|
137
|
+
useLayoutEffect(() => {
|
|
138
|
+
if (visibleCount !== null) return;
|
|
139
|
+
const container = containerRef.current;
|
|
140
|
+
if (!container) return;
|
|
141
|
+
const children = Array.from(container.children);
|
|
142
|
+
const first = children[0];
|
|
143
|
+
if (!first) return;
|
|
144
|
+
const gap = 4;
|
|
145
|
+
const maxBottom = first.offsetTop + first.offsetHeight * 2 + gap;
|
|
146
|
+
const badgeCount = badges.length;
|
|
147
|
+
let fits = badgeCount;
|
|
148
|
+
for (let i = 0; i < badgeCount; i++) {
|
|
149
|
+
const el = children[i];
|
|
150
|
+
if (el.offsetTop + el.offsetHeight > maxBottom) {
|
|
151
|
+
fits = i;
|
|
152
|
+
break;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
if (fits >= badgeCount) {
|
|
156
|
+
setVisibleCount(badgeCount);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
const pillSpace = 34 + gap;
|
|
160
|
+
let adjusted = fits;
|
|
161
|
+
for (let i = fits - 1; i >= 0; i--) {
|
|
162
|
+
const el = children[i];
|
|
163
|
+
if (el.offsetLeft + el.offsetWidth + pillSpace <= container.clientWidth) {
|
|
164
|
+
adjusted = i + 1;
|
|
165
|
+
break;
|
|
166
|
+
}
|
|
167
|
+
adjusted = i;
|
|
168
|
+
}
|
|
169
|
+
setVisibleCount(Math.max(1, adjusted));
|
|
170
|
+
});
|
|
171
|
+
const resolved = visibleCount ?? badges.length;
|
|
172
|
+
const overflowCount = badges.length - resolved;
|
|
173
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
174
|
+
ref: containerRef,
|
|
175
|
+
className: "flex min-w-0 flex-1 flex-wrap content-start items-start gap-1",
|
|
176
|
+
children: [badges.slice(0, resolved).map(({ columnId, value, label }) => /* @__PURE__ */ jsxs("button", {
|
|
177
|
+
onClick: () => onRemove(columnId, value),
|
|
178
|
+
className: "inline-flex items-center gap-1 rounded-md border border-primary/20 bg-primary/5 px-1.5 py-0.5 text-[10px] font-medium text-primary transition-colors hover:bg-primary/10",
|
|
179
|
+
children: [/* @__PURE__ */ jsx("span", {
|
|
180
|
+
className: "max-w-[10rem] truncate",
|
|
181
|
+
children: label
|
|
182
|
+
}), /* @__PURE__ */ jsx(X, { className: "h-2.5 w-2.5 shrink-0 opacity-60" })]
|
|
183
|
+
}, `${columnId}-${value}`)), overflowCount > 0 && /* @__PURE__ */ jsxs("span", {
|
|
184
|
+
className: "rounded-md bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground",
|
|
185
|
+
children: ["+", overflowCount]
|
|
186
|
+
})]
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
/** A collapsible filter column section with checkable options. */
|
|
190
|
+
function FilterSection({ filter, matchedOptions, isSearching, activeValues, onToggle }) {
|
|
191
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
192
|
+
const [expanded, setExpanded] = useState(false);
|
|
193
|
+
const visibleOptions = isSearching || expanded ? matchedOptions : matchedOptions.slice(0, MAX_VISIBLE_OPTIONS);
|
|
194
|
+
const hasMore = !isSearching && matchedOptions.length > MAX_VISIBLE_OPTIONS;
|
|
195
|
+
const activeCount = activeValues?.size ?? 0;
|
|
196
|
+
const effectiveIsOpen = isSearching || isOpen;
|
|
197
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
198
|
+
className: "rounded-lg",
|
|
199
|
+
children: [/* @__PURE__ */ jsxs("button", {
|
|
200
|
+
onClick: () => setIsOpen(!isOpen),
|
|
201
|
+
className: "flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left transition-colors hover:bg-muted/50",
|
|
202
|
+
children: [
|
|
203
|
+
/* @__PURE__ */ jsx(ChevronDown, { className: `h-3 w-3 shrink-0 text-muted-foreground transition-transform ${effectiveIsOpen ? "" : "-rotate-90"}` }),
|
|
204
|
+
/* @__PURE__ */ jsx("span", {
|
|
205
|
+
className: "text-[11px] font-semibold text-foreground",
|
|
206
|
+
children: filter.label
|
|
207
|
+
}),
|
|
208
|
+
activeCount > 0 && /* @__PURE__ */ jsx("span", {
|
|
209
|
+
className: "ml-auto flex h-4 min-w-4 items-center justify-center rounded-full bg-primary/10 px-1.5 text-[9px] font-semibold text-primary",
|
|
210
|
+
children: activeCount
|
|
211
|
+
})
|
|
212
|
+
]
|
|
213
|
+
}), effectiveIsOpen && /* @__PURE__ */ jsxs("div", {
|
|
214
|
+
className: "pb-1 pl-2 pr-1 pt-0.5",
|
|
215
|
+
children: [/* @__PURE__ */ jsx("div", {
|
|
216
|
+
className: "space-y-px",
|
|
217
|
+
children: visibleOptions.map((option) => {
|
|
218
|
+
const isActive = activeValues?.has(option.value) ?? false;
|
|
219
|
+
return /* @__PURE__ */ jsxs("button", {
|
|
220
|
+
onClick: () => onToggle(option.value),
|
|
221
|
+
className: `flex w-full items-center gap-2.5 rounded-md px-2 py-1.5 text-left text-xs transition-colors ${isActive ? "bg-primary/8 text-primary" : "text-foreground hover:bg-muted/60"}`,
|
|
222
|
+
children: [
|
|
223
|
+
/* @__PURE__ */ jsx("div", {
|
|
224
|
+
className: `flex h-4 w-4 shrink-0 items-center justify-center rounded border transition-colors ${isActive ? "border-primary bg-primary" : "border-muted-foreground/30 bg-background"}`,
|
|
225
|
+
children: isActive && /* @__PURE__ */ jsx("svg", {
|
|
226
|
+
xmlns: "http://www.w3.org/2000/svg",
|
|
227
|
+
width: "10",
|
|
228
|
+
height: "10",
|
|
229
|
+
viewBox: "0 0 24 24",
|
|
230
|
+
fill: "none",
|
|
231
|
+
stroke: "white",
|
|
232
|
+
strokeWidth: "3",
|
|
233
|
+
strokeLinecap: "round",
|
|
234
|
+
strokeLinejoin: "round",
|
|
235
|
+
children: /* @__PURE__ */ jsx("polyline", { points: "20 6 9 17 4 12" })
|
|
236
|
+
})
|
|
237
|
+
}),
|
|
238
|
+
/* @__PURE__ */ jsx("span", {
|
|
239
|
+
className: "flex-1 truncate",
|
|
240
|
+
children: option.label
|
|
241
|
+
}),
|
|
242
|
+
/* @__PURE__ */ jsx("span", {
|
|
243
|
+
className: "shrink-0 tabular-nums text-[10px] text-muted-foreground/70",
|
|
244
|
+
children: option.count
|
|
245
|
+
})
|
|
246
|
+
]
|
|
247
|
+
}, option.value);
|
|
248
|
+
})
|
|
249
|
+
}), hasMore && /* @__PURE__ */ jsx("button", {
|
|
250
|
+
onClick: () => setExpanded(!expanded),
|
|
251
|
+
className: "mt-1 w-full rounded-md px-2 py-1 text-left text-[10px] font-medium text-muted-foreground transition-colors hover:bg-muted/60 hover:text-foreground",
|
|
252
|
+
children: expanded ? "Show less" : `Show ${matchedOptions.length - MAX_VISIBLE_OPTIONS} more…`
|
|
253
|
+
})]
|
|
254
|
+
})]
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
//#endregion
|
|
258
|
+
export { ChartFiltersPanel };
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import * as react_jsx_runtime0 from "react/jsx-runtime";
|
|
2
|
+
|
|
3
|
+
//#region src/ui/chart-filters.d.ts
|
|
4
|
+
/**
|
|
5
|
+
* Chart filters — compact button that shows active filter count.
|
|
6
|
+
* Clicking reveals a popover wrapping ChartFiltersPanel.
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* Compact filter button + dropdown for all filterable columns.
|
|
10
|
+
* Shows a count badge when filters are active.
|
|
11
|
+
*/
|
|
12
|
+
declare function ChartFilters({
|
|
13
|
+
className
|
|
14
|
+
}: {
|
|
15
|
+
className?: string;
|
|
16
|
+
}): react_jsx_runtime0.JSX.Element | null;
|
|
17
|
+
//#endregion
|
|
18
|
+
export { ChartFilters };
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { useChartContext } from "./chart-context.mjs";
|
|
2
|
+
import { ChartDropdownPanel } from "./chart-dropdown.mjs";
|
|
3
|
+
import { ChartFiltersPanel } from "./chart-filters-panel.mjs";
|
|
4
|
+
import { useRef, useState } from "react";
|
|
5
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
6
|
+
import { Filter } from "lucide-react";
|
|
7
|
+
//#region src/ui/chart-filters.tsx
|
|
8
|
+
/**
|
|
9
|
+
* Chart filters — compact button that shows active filter count.
|
|
10
|
+
* Clicking reveals a popover wrapping ChartFiltersPanel.
|
|
11
|
+
*/
|
|
12
|
+
/**
|
|
13
|
+
* Compact filter button + dropdown for all filterable columns.
|
|
14
|
+
* Shows a count badge when filters are active.
|
|
15
|
+
*/
|
|
16
|
+
function ChartFilters({ className }) {
|
|
17
|
+
const { availableFilters, filters } = useChartContext();
|
|
18
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
19
|
+
const triggerRef = useRef(null);
|
|
20
|
+
if (availableFilters.length === 0) return null;
|
|
21
|
+
const activeCount = [...filters.values()].reduce((sum, set) => sum + set.size, 0);
|
|
22
|
+
const isActive = activeCount > 0;
|
|
23
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
24
|
+
className,
|
|
25
|
+
children: [/* @__PURE__ */ jsxs("button", {
|
|
26
|
+
ref: triggerRef,
|
|
27
|
+
onClick: () => setIsOpen(!isOpen),
|
|
28
|
+
className: `inline-flex h-7 items-center gap-1.5 rounded-lg border px-2.5 text-xs font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/20 ${isActive ? "border-primary/30 bg-primary/5 text-primary shadow-sm shadow-primary/5 hover:bg-primary/8" : "border-border/50 bg-background text-muted-foreground shadow-sm hover:border-border hover:bg-muted/30 hover:shadow hover:text-foreground"}`,
|
|
29
|
+
children: [
|
|
30
|
+
/* @__PURE__ */ jsx(Filter, { className: "h-3 w-3" }),
|
|
31
|
+
"Filters",
|
|
32
|
+
isActive && /* @__PURE__ */ jsx("span", {
|
|
33
|
+
className: "flex h-4 min-w-4 items-center justify-center rounded-full bg-primary px-1 text-[10px] font-semibold text-primary-foreground",
|
|
34
|
+
children: activeCount
|
|
35
|
+
})
|
|
36
|
+
]
|
|
37
|
+
}), /* @__PURE__ */ jsx(ChartDropdownPanel, {
|
|
38
|
+
isOpen,
|
|
39
|
+
onClose: () => setIsOpen(false),
|
|
40
|
+
triggerRef,
|
|
41
|
+
width: 288,
|
|
42
|
+
className: "max-h-[420px] overflow-y-auto overscroll-contain p-3",
|
|
43
|
+
children: /* @__PURE__ */ jsx(ChartFiltersPanel, {})
|
|
44
|
+
})]
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
//#endregion
|
|
48
|
+
export { ChartFilters };
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import * as react_jsx_runtime0 from "react/jsx-runtime";
|
|
2
|
+
|
|
3
|
+
//#region src/ui/chart-group-by-selector.d.ts
|
|
4
|
+
/**
|
|
5
|
+
* GroupBy selector — premium custom dropdown replacing native <select>.
|
|
6
|
+
*/
|
|
7
|
+
/** Custom dropdown to select the groupBy column. */
|
|
8
|
+
declare function ChartGroupBySelector({
|
|
9
|
+
className,
|
|
10
|
+
hideIcon
|
|
11
|
+
}: {
|
|
12
|
+
className?: string;
|
|
13
|
+
hideIcon?: boolean;
|
|
14
|
+
}): react_jsx_runtime0.JSX.Element | null;
|
|
15
|
+
//#endregion
|
|
16
|
+
export { ChartGroupBySelector };
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { useChartContext } from "./chart-context.mjs";
|
|
2
|
+
import { ChartSelect } from "./chart-select.mjs";
|
|
3
|
+
import { jsx } from "react/jsx-runtime";
|
|
4
|
+
import { CHART_TYPE_CONFIG } from "@matthieumordrel/chart-studio/_internal";
|
|
5
|
+
import { Layers } from "lucide-react";
|
|
6
|
+
//#region src/ui/chart-group-by-selector.tsx
|
|
7
|
+
/**
|
|
8
|
+
* GroupBy selector — premium custom dropdown replacing native <select>.
|
|
9
|
+
*/
|
|
10
|
+
/** Custom dropdown to select the groupBy column. */
|
|
11
|
+
function ChartGroupBySelector({ className, hideIcon }) {
|
|
12
|
+
const { chartType, groupById, setGroupBy, availableGroupBys, isGroupByOptional } = useChartContext();
|
|
13
|
+
const options = [...isGroupByOptional ? [{
|
|
14
|
+
value: "",
|
|
15
|
+
label: "No grouping"
|
|
16
|
+
}] : [], ...availableGroupBys.map((col) => ({
|
|
17
|
+
value: col.id,
|
|
18
|
+
label: col.label
|
|
19
|
+
}))];
|
|
20
|
+
if (!CHART_TYPE_CONFIG[chartType].supportsGrouping || options.length <= 1) return null;
|
|
21
|
+
return /* @__PURE__ */ jsx(ChartSelect, {
|
|
22
|
+
value: groupById ?? "",
|
|
23
|
+
options,
|
|
24
|
+
onChange: (v) => setGroupBy(v || null),
|
|
25
|
+
ariaLabel: "Group by",
|
|
26
|
+
icon: Layers,
|
|
27
|
+
hideIcon,
|
|
28
|
+
className
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
//#endregion
|
|
32
|
+
export { ChartGroupBySelector };
|