@galaxy-io/dls 2.0.0 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/dist/charts/BarChart.js +29 -13
  2. package/dist/charts/ChartCategoryTargets.d.ts +2 -2
  3. package/dist/charts/ChartCategoryTargets.js +2 -1
  4. package/dist/charts/ChartFrame.d.ts +7 -5
  5. package/dist/charts/ChartFrame.js +151 -76
  6. package/dist/charts/ChartLegend.d.ts +14 -4
  7. package/dist/charts/ChartLegend.js +90 -53
  8. package/dist/charts/ChartMarkTargets.d.ts +2 -2
  9. package/dist/charts/ChartMarkTargets.js +2 -1
  10. package/dist/charts/Heatmap.js +19 -7
  11. package/dist/charts/LineChart.js +19 -8
  12. package/dist/charts/PieChart.js +16 -7
  13. package/dist/charts/chartSelection.d.ts +15 -0
  14. package/dist/charts/chartSelection.js +22 -0
  15. package/dist/charts/constants.d.ts +4 -0
  16. package/dist/charts/constants.js +6 -2
  17. package/dist/charts/types.d.ts +10 -1
  18. package/dist/charts/useChartInteraction.d.ts +25 -3
  19. package/dist/charts/useChartInteraction.js +56 -10
  20. package/dist/drawer/Drawer.d.ts +3 -1
  21. package/dist/drawer/Drawer.js +43 -14
  22. package/dist/feedback/Alert.d.ts +1 -1
  23. package/dist/inputs/CheckboxGroup.js +1 -1
  24. package/dist/inputs/CheckboxInput.js +1 -1
  25. package/dist/inputs/ColorInput.js +1 -1
  26. package/dist/inputs/CronInput.js +1 -1
  27. package/dist/inputs/DateInput.js +1 -1
  28. package/dist/inputs/KeyValueInput.js +1 -1
  29. package/dist/inputs/SelectInput.js +1 -1
  30. package/dist/inputs/SwitchInput.js +1 -1
  31. package/dist/inputs/TextAreaInput.js +6 -0
  32. package/dist/inputs/internal/ownField.js +1 -1
  33. package/dist/styles.css +1 -1
  34. package/dist/table/TableCore.js +6 -1
  35. package/dist/timeline/Timeline.d.ts +22 -1
  36. package/dist/timeline/Timeline.js +75 -43
  37. package/package.json +5 -1
@@ -5,13 +5,15 @@ import Flex, { AlignItems } from "../layout/Flex.js";
5
5
  import Tooltip from "../tooltip/Tooltip.js";
6
6
  import { ChartLegendPlacement, ChartMarkState, ChartPalette } from "./types.js";
7
7
  import { CHART_MARK_STATE_TO_CSS_MAP } from "./ChartPrimitives.js";
8
+ import "./constants.js";
9
+ import { isAdditiveEvent } from "./chartSelection.js";
8
10
  import Circle, { CircleSize, CircleVariant } from "../shapes/Circle.js";
9
11
  import Square, { SquareSize, SquareVariant } from "../shapes/Square.js";
10
12
  import { CHART_PALETTE_TO_COLOR_MAP } from "./chartPalette.js";
11
13
  /* empty css */
12
14
  import { styled } from "@linaria/react";
13
15
  import { useCallback, useRef, useState } from "react";
14
- import { jsx, jsxs } from "react/jsx-runtime";
16
+ import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
15
17
  import { cx } from "@linaria/core";
16
18
  //#region src/charts/ChartLegend.tsx
17
19
  /**
@@ -20,8 +22,13 @@ import { cx } from "@linaria/core";
20
22
  * them. The fit is measured, not guessed: a hidden copy of every entry is laid out unconstrained
21
23
  * and the visible count is however many real sizes fit beside the trigger.
22
24
  *
23
- * Entries are list items with no action (no button semantics); hovering one isolates its series,
24
- * a pointer enhancement (keyboard users hear every series in the announcement). Spacing is
25
+ * Entries are list items. Hovering one previews its series (a pointer enhancement; keyboard users
26
+ * hear every series in the announcement). On a filterable chart each entry is also a toggle button
27
+ * (`aria-pressed`): a click pins its series, ⌘ / Ctrl / Shift-click adds or removes it. Entries
28
+ * collapsed behind "+N more" stay hover-only (a tooltip holds no controls). A filterable chart's
29
+ * legend ends in a slot for its filter chip, right after the last entry (under the column when
30
+ * RIGHT): the slot always keeps its height, and a bottom legend always keeps room for the chip's
31
+ * width, so pinning never moves an entry. Spacing is
25
32
  * padding on each entry (trailing in a row, split above and below in a column so the column
26
33
  * centers), not a flex gap, so sweeping across entries never drops the isolation. Isolation changes opacity only; it never rescales the axis.
27
34
  */
@@ -75,14 +82,27 @@ var MEASURE_PLACEMENT_TO_CSS_MAP = {
75
82
  [ChartLegendPlacement.BOTTOM]: "gx-ChartLegend-ChartLegendPlacement_BOTTOM-cixvbm6",
76
83
  [ChartLegendPlacement.RIGHT]: "gx-ChartLegend-ChartLegendPlacement_RIGHT-c1ehakcs"
77
84
  };
85
+ /** The filter chips' slot: one or two chips, truncating at its cap, never taller than the row it sets. */
86
+ var FilterSlot = /*#__PURE__*/ styled("li")({
87
+ name: "FilterSlot",
88
+ class: "gx-ChartLegend-FilterSlot-fsomlsk",
89
+ propsAsIs: false
90
+ });
91
+ /** BOTTOM: a 20px row the chip fits; RIGHT: the chip under the column, 8px below the last entry. */
92
+ var FILTER_SLOT_PLACEMENT_TO_CSS_MAP = {
93
+ [ChartLegendPlacement.BOTTOM]: "gx-ChartLegend-ChartLegendPlacement_BOTTOM-cdc52xq",
94
+ [ChartLegendPlacement.RIGHT]: "gx-ChartLegend-ChartLegendPlacement_RIGHT-cme95dp"
95
+ };
78
96
  /** An entry never shrinks below its content; the fit decides what shows. */
79
- var ENTRY_CSS = "gx-ChartLegend-ENTRY_CSS-esomlsk";
97
+ var ENTRY_CSS = "gx-ChartLegend-ENTRY_CSS-e1lt2566";
98
+ /** A filterable entry's toggle: the swatch and label as one button, ring on keyboard focus. */
99
+ var ENTRY_BUTTON_CSS = "gx-ChartLegend-ENTRY_BUTTON_CSS-e1btzd6s";
80
100
  /** The "+N more" trigger: a real button, Tab-reachable. */
81
- var TRIGGER_CSS = "gx-ChartLegend-TRIGGER_CSS-tdc52xq";
101
+ var TRIGGER_CSS = "gx-ChartLegend-TRIGGER_CSS-t1hkz6fn";
82
102
  /** The hidden entries, listed in the trigger's tooltip. */
83
103
  var OverflowList = /*#__PURE__*/ styled("ul")({
84
104
  name: "OverflowList",
85
- class: "gx-ChartLegend-OverflowList-ome95dp",
105
+ class: "gx-ChartLegend-OverflowList-o1wv3pdp",
86
106
  propsAsIs: false
87
107
  });
88
108
  function Swatch({ item, swatch }) {
@@ -97,8 +117,17 @@ function Swatch({ item, swatch }) {
97
117
  })
98
118
  });
99
119
  }
100
- function Entry({ item, swatch, isVertical, state, as = "li", onEnter, onLeave }) {
101
- return /* @__PURE__ */ jsxs(Flex, {
120
+ function Entry({ item, swatch, isVertical, state, as = "li", isPinned = false, onEnter, onLeave, onClick }) {
121
+ const content = /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx(Swatch, {
122
+ item,
123
+ swatch
124
+ }), /* @__PURE__ */ jsx(Text, {
125
+ size: TextSize.BODY_SM,
126
+ variant: isPinned ? TextVariant.PRIMARY : TextVariant.SECONDARY,
127
+ lineClamp: 1,
128
+ children: item.label
129
+ })] });
130
+ return /* @__PURE__ */ jsx(Flex, {
102
131
  as,
103
132
  alignItems: AlignItems.CENTER,
104
133
  gap: 8,
@@ -111,19 +140,17 @@ function Entry({ item, swatch, isVertical, state, as = "li", onEnter, onLeave })
111
140
  className: cx(ENTRY_CSS, CHART_MARK_STATE_TO_CSS_MAP[state]),
112
141
  ...onEnter && { onMouseEnter: onEnter },
113
142
  ...onLeave && { onMouseLeave: onLeave },
114
- children: [/* @__PURE__ */ jsx(Swatch, {
115
- item,
116
- swatch
117
- }), /* @__PURE__ */ jsx(Text, {
118
- size: TextSize.BODY_SM,
119
- variant: TextVariant.SECONDARY,
120
- lineClamp: 1,
121
- children: item.label
122
- })]
143
+ children: onClick ? /* @__PURE__ */ jsx("button", {
144
+ type: "button",
145
+ "aria-pressed": isPinned,
146
+ className: ENTRY_BUTTON_CSS,
147
+ onClick: (event) => onClick(isAdditiveEvent(event)),
148
+ children: content
149
+ }) : content
123
150
  });
124
151
  }
125
152
  /** @internal The chart legend. */
126
- function ChartLegend({ items, placement, swatch, inset, extent = 0, hoveredKey, onItemEnter, onItemLeave }) {
153
+ function ChartLegend({ items, placement, swatch, inset, extent = 0, isFixedExtent = true, hasFilterSlot = false, filter = null, getEntryState, isEntryPinned, onItemClick, onItemEnter, onItemLeave }) {
127
154
  const listRef = useRef(null);
128
155
  const measureRef = useRef(null);
129
156
  const [fitCount, setFitCount] = useState(null);
@@ -135,7 +162,7 @@ function ChartLegend({ items, placement, swatch, inset, extent = 0, hoveredKey,
135
162
  const rendered = Array.from(measure.children);
136
163
  const trigger = rendered.pop();
137
164
  if (!trigger) return;
138
- const available = isVertical ? extent || Number.POSITIVE_INFINITY : list.clientWidth - inset;
165
+ const available = isVertical ? extent || Number.POSITIVE_INFINITY : list.clientWidth - inset - (hasFilterSlot ? 240 : 0);
139
166
  const sizeOf = (element) => {
140
167
  const rect = element.getBoundingClientRect();
141
168
  return Math.ceil(isVertical ? rect.height : rect.width);
@@ -157,7 +184,8 @@ function ChartLegend({ items, placement, swatch, inset, extent = 0, hoveredKey,
157
184
  }, [
158
185
  isVertical,
159
186
  extent,
160
- inset
187
+ inset,
188
+ hasFilterSlot
161
189
  ]);
162
190
  useIsomorphicLayoutEffect(() => {
163
191
  recompute();
@@ -170,14 +198,12 @@ function ChartLegend({ items, placement, swatch, inset, extent = 0, hoveredKey,
170
198
  return () => observer.disconnect();
171
199
  }, [recompute]);
172
200
  if (items.length === 0) return null;
173
- const stateOf = (key) => hoveredKey === null || key === hoveredKey ? ChartMarkState.ACTIVE : ChartMarkState.MUTED;
174
201
  const inline = items.slice(0, fitCount ?? items.length);
175
202
  const overflow = items.slice(inline.length);
176
- const isOverflowEngaged = hoveredKey !== null && overflow.some((item) => item.key === hoveredKey);
177
- const triggerState = hoveredKey === null || isOverflowEngaged ? ChartMarkState.ACTIVE : ChartMarkState.MUTED;
203
+ const triggerState = overflow.some((item) => getEntryState(item.key) === ChartMarkState.ACTIVE) ? ChartMarkState.ACTIVE : ChartMarkState.MUTED;
178
204
  const variables = {
179
205
  "--gx-chart-legend-inset": `${inset}px`,
180
- ...isVertical && extent > 0 && { "--gx-chart-legend-extent": `${extent}px` }
206
+ ...isVertical && extent > 0 && isFixedExtent && { "--gx-chart-legend-extent": `${extent}px` }
181
207
  };
182
208
  return /* @__PURE__ */ jsxs(Root, { children: [/* @__PURE__ */ jsx(MeasureClip, {
183
209
  "aria-hidden": "true",
@@ -209,38 +235,49 @@ function ChartLegend({ items, placement, swatch, inset, extent = 0, hoveredKey,
209
235
  ref: listRef,
210
236
  className: CHART_LEGEND_PLACEMENT_TO_LIST_CSS_MAP[placement],
211
237
  style: variables,
212
- children: [inline.map((item) => /* @__PURE__ */ jsx(Entry, {
213
- item,
214
- swatch,
215
- isVertical,
216
- state: stateOf(item.key),
217
- onEnter: () => onItemEnter(item.key),
218
- onLeave: onItemLeave
219
- }, item.key)), overflow.length > 0 && /* @__PURE__ */ jsx(Flex, {
220
- as: "li",
221
- alignItems: AlignItems.CENTER,
222
- className: cx(ENTRY_CSS, CHART_MARK_STATE_TO_CSS_MAP[triggerState]),
223
- children: /* @__PURE__ */ jsx(Tooltip, {
224
- placement: isVertical ? Placement.RIGHT : Placement.TOP,
225
- body: /* @__PURE__ */ jsx(OverflowList, { children: overflow.map((item) => /* @__PURE__ */ jsx(Entry, {
226
- item,
227
- swatch,
228
- isVertical: true,
229
- state: stateOf(item.key),
230
- onEnter: () => onItemEnter(item.key),
231
- onLeave: onItemLeave
232
- }, item.key)) }),
233
- children: /* @__PURE__ */ jsx("button", {
234
- type: "button",
235
- className: TRIGGER_CSS,
236
- children: /* @__PURE__ */ jsx(Text, {
237
- size: TextSize.BODY_SM,
238
- variant: TextVariant.SECONDARY,
239
- children: `+${overflow.length} more`
238
+ children: [
239
+ inline.map((item) => /* @__PURE__ */ jsx(Entry, {
240
+ item,
241
+ swatch,
242
+ isVertical,
243
+ state: getEntryState(item.key),
244
+ isPinned: isEntryPinned?.(item.key) ?? false,
245
+ onEnter: () => onItemEnter(item.key),
246
+ onLeave: onItemLeave,
247
+ onClick: onItemClick && ((isAdditive) => onItemClick(item.key, isAdditive))
248
+ }, item.key)),
249
+ overflow.length > 0 && /* @__PURE__ */ jsx(Flex, {
250
+ as: "li",
251
+ alignItems: AlignItems.CENTER,
252
+ className: cx(ENTRY_CSS, CHART_MARK_STATE_TO_CSS_MAP[triggerState]),
253
+ children: /* @__PURE__ */ jsx(Tooltip, {
254
+ placement: isVertical ? Placement.RIGHT : Placement.TOP,
255
+ body: /* @__PURE__ */ jsx(OverflowList, { children: overflow.map((item) => /* @__PURE__ */ jsx(Entry, {
256
+ item,
257
+ swatch,
258
+ isVertical: true,
259
+ state: getEntryState(item.key),
260
+ isPinned: isEntryPinned?.(item.key) ?? false,
261
+ onEnter: () => onItemEnter(item.key),
262
+ onLeave: onItemLeave
263
+ }, item.key)) }),
264
+ children: /* @__PURE__ */ jsx("button", {
265
+ type: "button",
266
+ className: TRIGGER_CSS,
267
+ children: /* @__PURE__ */ jsx(Text, {
268
+ size: TextSize.BODY_SM,
269
+ variant: TextVariant.SECONDARY,
270
+ children: `+${overflow.length} more`
271
+ })
240
272
  })
241
273
  })
274
+ }),
275
+ hasFilterSlot && /* @__PURE__ */ jsx(FilterSlot, {
276
+ className: FILTER_SLOT_PLACEMENT_TO_CSS_MAP[placement],
277
+ "aria-hidden": filter === null || void 0,
278
+ children: filter
242
279
  })
243
- })]
280
+ ]
244
281
  })] });
245
282
  }
246
283
  //#endregion
@@ -7,8 +7,8 @@ export interface ChartMarkTargetsProps {
7
7
  onEnter: (categoryIndex: number) => void;
8
8
  /** Pointer left the target layers (moving between targets is not leaving). */
9
9
  onLeave: () => void;
10
- /** Click on a mark, by its index in `targets`. */
11
- onSelect: (markIndex: number) => void;
10
+ /** Click on a mark, by its index in `targets` (additive with ⌘ / Ctrl / Shift). */
11
+ onSelect: (markIndex: number, isAdditive: boolean) => void;
12
12
  }
13
13
  /** @internal The mark hit layer. */
14
14
  export declare function ChartMarkTargets({ targets, onEnter, onLeave, onSelect }: ChartMarkTargetsProps): import("react").JSX.Element;
@@ -1,5 +1,6 @@
1
1
  import { ChartTargetShape } from "./types.js";
2
2
  import { CHART_HIT_TARGET_CLICKABLE_CSS, CHART_HIT_TARGET_CSS, leaveUnlessTarget } from "./ChartPrimitives.js";
3
+ import { isAdditiveEvent } from "./chartSelection.js";
3
4
  import { Fragment, jsx } from "react/jsx-runtime";
4
5
  import { cx } from "@linaria/core";
5
6
  //#region src/charts/ChartMarkTargets.tsx
@@ -17,7 +18,7 @@ function ChartMarkTargets({ targets, onEnter, onLeave, onSelect }) {
17
18
  const handlers = {
18
19
  onMouseEnter: () => onEnter(target.categoryIndex),
19
20
  onMouseLeave: (event) => leaveUnlessTarget(event, onLeave),
20
- onClick: () => onSelect(index)
21
+ onClick: (event) => onSelect(index, isAdditiveEvent(event))
21
22
  };
22
23
  return target.shape === ChartTargetShape.RECT ? /* @__PURE__ */ jsx("rect", {
23
24
  x: target.x,
@@ -99,7 +99,7 @@ function HeatmapLegend({ ramp, min, max, hasEmpty, inset }) {
99
99
  }
100
100
  /** A grid of cells colored by value along one hue's token ramp. */
101
101
  var Heatmap = forwardRef(function Heatmap(props, ref) {
102
- const { cells, columns: explicitColumns, rows: explicitRows, color = ChartPalette.PURPLE, labelFormatter, valueDomain, valueFormatter, hasLegend = true, hasTooltip = true, renderTooltip, isLoading = false, placeholder = DEFAULT_PLACEHOLDER, onSelect, selection, ariaLabel, id, className, style, ...rest } = props;
102
+ const { cells, columns: explicitColumns, rows: explicitRows, color = ChartPalette.PURPLE, labelFormatter, valueDomain, valueFormatter, hasLegend = true, hasTooltip = true, renderTooltip, isLoading = false, placeholder = DEFAULT_PLACEHOLDER, onSelect, selection, isFilterable = false, defaultSelection, onSelectionChange, ariaLabel, id, className, style, ...rest } = props;
103
103
  const plotRef = useRef(null);
104
104
  const { formatValue, formatLabel } = useChartFormatters(valueFormatter, labelFormatter);
105
105
  const { columns, rows } = useMemo(() => getHeatmapLabels(cells, explicitColumns, explicitRows), [
@@ -151,8 +151,11 @@ var Heatmap = forwardRef(function Heatmap(props, ref) {
151
151
  categoryKeys: cellColumnKeys,
152
152
  enableGroupSync: false,
153
153
  selection,
154
+ isFilterable,
155
+ defaultSelection,
156
+ onSelectionChange,
154
157
  categoryMuteState: ChartMarkState.ACTIVE,
155
- isDisabled: !(hasTooltip || onSelect) || isLoading || isEmpty,
158
+ isDisabled: !(hasTooltip || onSelect || isFilterable) || isLoading || isEmpty,
156
159
  isLoading,
157
160
  grid
158
161
  });
@@ -206,16 +209,25 @@ var Heatmap = forwardRef(function Heatmap(props, ref) {
206
209
  plotWidth,
207
210
  plotHeight
208
211
  ]);
209
- const handleSelect = useCallback((index) => {
212
+ const { toggleSelection } = interaction;
213
+ const handleSelect = useCallback((index, isAdditive = false) => {
210
214
  const cell = geometry.cells[index];
211
- if (!cell || !onSelect) return;
212
- onSelect({
215
+ if (!cell) return;
216
+ onSelect?.({
213
217
  categoryIndex: cell.columnIndex,
214
218
  categoryKey: cell.column,
215
219
  seriesKey: cell.row,
216
220
  ...cell.value !== null && { value: cell.value }
217
221
  });
218
- }, [geometry.cells, onSelect]);
222
+ toggleSelection({
223
+ categoryKey: cell.column,
224
+ seriesKey: cell.row
225
+ }, isAdditive);
226
+ }, [
227
+ geometry.cells,
228
+ onSelect,
229
+ toggleSelection
230
+ ]);
219
231
  const passedAriaLabel = typeof rest["aria-label"] === "string" ? rest["aria-label"] : void 0;
220
232
  const resolvedAriaLabel = ariaLabel ?? passedAriaLabel ?? `Heatmap, ${columns.length} columns by ${rows.length} rows`;
221
233
  const aspectRatio = clamp(columns.length / Math.max(1, rows.length), 1, 4);
@@ -256,7 +268,7 @@ var Heatmap = forwardRef(function Heatmap(props, ref) {
256
268
  buildCategoryTooltip,
257
269
  renderTooltip,
258
270
  categoryTargets,
259
- onCategorySelect: onSelect && handleSelect,
271
+ onCategorySelect: onSelect || isFilterable ? handleSelect : void 0,
260
272
  anchorHalfWidth: (geometry.cells[0]?.width ?? 0) / 2,
261
273
  isEmpty,
262
274
  isLoading,
@@ -20,7 +20,8 @@ import { cx } from "@linaria/core";
20
20
  * from `ChartPalette`; a `null` reading breaks the line instead of dropping to zero. Hover or
21
21
  * keyboard focus works per category column: a cursor rule marks the column, every line shows its
22
22
  * dot there, and the tooltip lists every line's value. Clicks and Enter report the category
23
- * through `onSelect`. It joins `ChartGroupProvider` sync.
23
+ * through `onSelect`; with `isFilterable` they also pin it, and legend clicks pin a line. It joins
24
+ * `ChartGroupProvider` sync.
24
25
  *
25
26
  * Filled series are `AreaChart`; inline trends without axes are `Sparkline`. The chart fills its
26
27
  * parent (2:1 when the parent has no definite height): size it with a `Box`, `Grid` track or
@@ -40,7 +41,7 @@ var DEFAULT_PLACEHOLDER = "No data available";
40
41
  * components, no public mode prop (the `MenuRoot` precedent).
41
42
  */
42
43
  function LineChartRootInner(props, ref) {
43
- const { series, lines, categories, curve = ChartCurve.SMOOTH, fill, isStacked, kind, valueFormatter, labelFormatter, valueDomain, axisLabels, hasLegend = true, hasTooltip = true, renderTooltip, isLoading = false, placeholder = DEFAULT_PLACEHOLDER, onSelect, selection, ariaLabel, id, className, style, ...rest } = props;
44
+ const { series, lines, categories, curve = ChartCurve.SMOOTH, fill, isStacked, kind, valueFormatter, labelFormatter, valueDomain, axisLabels, hasLegend = true, hasTooltip = true, renderTooltip, isLoading = false, placeholder = DEFAULT_PLACEHOLDER, onSelect, selection, isFilterable = false, defaultSelection, onSelectionChange, ariaLabel, id, className, style, ...rest } = props;
44
45
  const plotRef = useRef(null);
45
46
  const colors = useSeriesColorResolver(series);
46
47
  const { dimensions, domain, valueTicks, formatValue, formatLabel } = useCartesianChartLayout({
@@ -93,7 +94,10 @@ function LineChartRootInner(props, ref) {
93
94
  seriesKeys,
94
95
  categoryKeys: geometry.categories,
95
96
  selection,
96
- isDisabled: !(hasTooltip || onSelect) || isLoading || isEmpty,
97
+ isFilterable,
98
+ defaultSelection,
99
+ onSelectionChange,
100
+ isDisabled: !(hasTooltip || onSelect || isFilterable) || isLoading || isEmpty,
97
101
  isLoading
98
102
  });
99
103
  const activeColumn = interaction.activeIndex === null ? void 0 : geometry.columns[interaction.activeIndex];
@@ -148,14 +152,20 @@ function LineChartRootInner(props, ref) {
148
152
  width: column.bandWidth,
149
153
  height: plotHeight
150
154
  })), [geometry.columns, plotHeight]);
151
- const handleCategorySelect = useCallback((index) => {
155
+ const { toggleSelection } = interaction;
156
+ const handleCategorySelect = useCallback((index, isAdditive = false) => {
152
157
  const categoryKey = geometry.categories[index];
153
- if (categoryKey === void 0 || !onSelect) return;
154
- onSelect({
158
+ if (categoryKey === void 0) return;
159
+ onSelect?.({
155
160
  categoryIndex: index,
156
161
  categoryKey
157
162
  });
158
- }, [geometry.categories, onSelect]);
163
+ toggleSelection({ categoryKey }, isAdditive);
164
+ }, [
165
+ geometry.categories,
166
+ onSelect,
167
+ toggleSelection
168
+ ]);
159
169
  const selectedColumns = geometry.columns.filter((column) => interaction.selectedCategoryIndices.has(column.categoryIndex));
160
170
  const point = (item, keyPrefix = "") => /* @__PURE__ */ jsx("circle", {
161
171
  cx: item.x,
@@ -200,12 +210,13 @@ function LineChartRootInner(props, ref) {
200
210
  buildCategoryTooltip,
201
211
  renderTooltip,
202
212
  categoryTargets,
203
- onCategorySelect: onSelect && handleCategorySelect,
213
+ onCategorySelect: onSelect || isFilterable ? handleCategorySelect : void 0,
204
214
  anchorHalfWidth: geometry.step / 2,
205
215
  isEmpty,
206
216
  isLoading,
207
217
  placeholder,
208
218
  hasLegend,
219
+ formatCategoryKey: formatLabel,
209
220
  legendItems,
210
221
  legendPlacement: ChartLegendPlacement.BOTTOM,
211
222
  legendSwatch: kind === ChartKind.AREA ? ChartLegendSwatch.SQUARE : ChartLegendSwatch.CIRCLE,
@@ -47,7 +47,7 @@ var CenterBox = /*#__PURE__*/ styled("div")({
47
47
  propsAsIs: false
48
48
  });
49
49
  function PieChartInner(props, ref) {
50
- const { series, slices, isSolid = false, minSliceShare = PIE_MIN_SLICE_SHARE, aggregatedLabel = DEFAULT_AGGREGATED_LABEL, children, valueFormatter, hasLegend = true, hasTooltip = true, renderTooltip, isLoading = false, placeholder = DEFAULT_PLACEHOLDER, onSelect, selection, ariaLabel, id, className, style, ...rest } = props;
50
+ const { series, slices, isSolid = false, minSliceShare = PIE_MIN_SLICE_SHARE, aggregatedLabel = DEFAULT_AGGREGATED_LABEL, children, valueFormatter, hasLegend = true, hasTooltip = true, renderTooltip, isLoading = false, placeholder = DEFAULT_PLACEHOLDER, onSelect, selection, isFilterable = false, defaultSelection, onSelectionChange, ariaLabel, id, className, style, ...rest } = props;
51
51
  const plotRef = useRef(null);
52
52
  const colors = useSeriesColorResolver(series);
53
53
  const dimensions = useChartDimensions(plotRef, PLOT_INSETS);
@@ -93,7 +93,10 @@ function PieChartInner(props, ref) {
93
93
  categoryKeys: arcKeys,
94
94
  enableGroupSync: false,
95
95
  selection,
96
- isDisabled: !(hasTooltip || onSelect) || isLoading || isEmpty,
96
+ isFilterable,
97
+ defaultSelection,
98
+ onSelectionChange,
99
+ isDisabled: !(hasTooltip || onSelect || isFilterable) || isLoading || isEmpty,
97
100
  isLoading
98
101
  });
99
102
  const buildCategoryTooltip = useCallback((index) => {
@@ -123,16 +126,22 @@ function PieChartInner(props, ref) {
123
126
  shape: ChartTargetShape.PATH,
124
127
  d: item.path
125
128
  })), [geometry.arcs]);
126
- const handleSelect = useCallback((index) => {
129
+ const { toggleSelection } = interaction;
130
+ const handleSelect = useCallback((index, isAdditive = false) => {
127
131
  const item = resolvedSlices[index];
128
- if (!item || !onSelect) return;
129
- onSelect({
132
+ if (!item) return;
133
+ onSelect?.({
130
134
  categoryIndex: index,
131
135
  categoryKey: item.id,
132
136
  seriesKey: item.id,
133
137
  value: item.value
134
138
  });
135
- }, [resolvedSlices, onSelect]);
139
+ toggleSelection({ seriesKey: item.id }, isAdditive);
140
+ }, [
141
+ resolvedSlices,
142
+ onSelect,
143
+ toggleSelection
144
+ ]);
136
145
  const passedAriaLabel = typeof rest["aria-label"] === "string" ? rest["aria-label"] : void 0;
137
146
  const resolvedAriaLabel = ariaLabel ?? passedAriaLabel ?? `${isSolid ? "Pie" : "Donut"} chart, ${resolvedSlices.length} slices`;
138
147
  const hasCenter = !isSolid && children !== void 0 && children !== null && !isEmpty;
@@ -172,7 +181,7 @@ function PieChartInner(props, ref) {
172
181
  renderTooltip,
173
182
  categoryTargets,
174
183
  targetsTransform: centerTransform,
175
- onCategorySelect: onSelect && handleSelect,
184
+ onCategorySelect: onSelect || isFilterable ? handleSelect : void 0,
176
185
  placeAwayFromX: geometry.centerX,
177
186
  verticalAlign: ChartTooltipVerticalAlign.CENTERED,
178
187
  isEmpty,
@@ -0,0 +1,15 @@
1
+ /**
2
+ * @internal The filter model behind `isFilterable`: a selection is a list of descriptors
3
+ * (`ChartSelection`), and a click toggles one. A plain click pins the descriptor alone, or unpins it
4
+ * when it is already the whole filter; an additive click (⌘ / Ctrl / Shift) adds or removes it.
5
+ */
6
+ import type { KeyboardEvent as ReactKeyboardEvent, MouseEvent as ReactMouseEvent } from "react";
7
+ import type { ChartSelection, ChartSelectionInput } from "./types.js";
8
+ /** @internal The selection as a list; `null` and `undefined` are empty. */
9
+ export declare function toSelectionList<TKey extends string>(input: ChartSelectionInput<TKey> | undefined): ChartSelection<TKey>[];
10
+ /** @internal Two descriptors name the same marks: the same fields, the same values. */
11
+ export declare function isSameSelection(a: ChartSelection, b: ChartSelection): boolean;
12
+ /** @internal The filter after one click on `descriptor`. */
13
+ export declare function toggleSelection<TKey extends string>(current: ChartSelection<TKey>[], descriptor: ChartSelection<TKey>, isAdditive: boolean): ChartSelection<TKey>[];
14
+ /** @internal ⌘, Ctrl or Shift held: the click adds to the filter instead of replacing it. */
15
+ export declare function isAdditiveEvent(event: Pick<ReactMouseEvent | ReactKeyboardEvent, "metaKey" | "ctrlKey" | "shiftKey">): boolean;
@@ -0,0 +1,22 @@
1
+ //#region src/charts/chartSelection.ts
2
+ /** @internal The selection as a list; `null` and `undefined` are empty. */
3
+ function toSelectionList(input) {
4
+ if (input === void 0 || input === null) return [];
5
+ return Array.isArray(input) ? input : [input];
6
+ }
7
+ /** @internal Two descriptors name the same marks: the same fields, the same values. */
8
+ function isSameSelection(a, b) {
9
+ return a.categoryKey === b.categoryKey && a.seriesKey === b.seriesKey;
10
+ }
11
+ /** @internal The filter after one click on `descriptor`. */
12
+ function toggleSelection(current, descriptor, isAdditive) {
13
+ const isPinned = current.some((item) => isSameSelection(item, descriptor));
14
+ if (isAdditive) return isPinned ? current.filter((item) => !isSameSelection(item, descriptor)) : [...current, descriptor];
15
+ return isPinned && current.length === 1 ? [] : [descriptor];
16
+ }
17
+ /** @internal ⌘, Ctrl or Shift held: the click adds to the filter instead of replacing it. */
18
+ function isAdditiveEvent(event) {
19
+ return event.metaKey || event.ctrlKey || event.shiftKey;
20
+ }
21
+ //#endregion
22
+ export { isAdditiveEvent, isSameSelection, toSelectionList, toggleSelection };
@@ -97,6 +97,10 @@ export declare const CHART_TOOLTIP_OFFSET: number;
97
97
  export declare const CHART_TOOLTIP_MAX_ROWS = 8;
98
98
  /** @internal Gap between the plot box and a side-placed legend. */
99
99
  export declare const CHART_LEGEND_SIDE_GAP: number;
100
+ /** @internal Height a side legend gives up to its filter chips: the 8px top padding and a 20px `Chip SMALL`. */
101
+ export declare const CHART_LEGEND_FILTER_EXTENT: number;
102
+ /** @internal Widest the filter chips grow (one or two) before their labels truncate; a bottom legend always keeps this much room for them. */
103
+ export declare const CHART_LEGEND_FILTER_MAX_WIDTH = 240;
100
104
  /** @internal Height cap of the "+N more" legend list (about eight entries) before it scrolls. */
101
105
  export declare const CHART_LEGEND_OVERFLOW_MAX_HEIGHT = 208;
102
106
  /** @internal 2:1, the aspect-ratio fallback of cartesian charts and PieChart. */
@@ -1,4 +1,4 @@
1
- import { RADIUS, SPACE } from "../theme/tokens/scales.js";
1
+ import { CONTROL_SIZE, RADIUS, SPACE } from "../theme/tokens/scales.js";
2
2
  //#region src/charts/constants.ts
3
3
  /**
4
4
  * @internal Geometry and interaction constants for the chart family.
@@ -107,9 +107,13 @@ var CHART_TOOLTIP_OFFSET = px(SPACE[8]);
107
107
  var CHART_TOOLTIP_MAX_ROWS = 8;
108
108
  /** @internal Gap between the plot box and a side-placed legend. */
109
109
  var CHART_LEGEND_SIDE_GAP = px(SPACE[16]);
110
+ /** @internal Height a side legend gives up to its filter chips: the 8px top padding and a 20px `Chip SMALL`. */
111
+ var CHART_LEGEND_FILTER_EXTENT = px(SPACE[8]) + px(CONTROL_SIZE.xSmall);
112
+ /** @internal Widest the filter chips grow (one or two) before their labels truncate; a bottom legend always keeps this much room for them. */
113
+ var CHART_LEGEND_FILTER_MAX_WIDTH = 240;
110
114
  /** @internal Height cap of the "+N more" legend list (about eight entries) before it scrolls. */
111
115
  var CHART_LEGEND_OVERFLOW_MAX_HEIGHT = 208;
112
116
  /** @internal 2:1, the aspect-ratio fallback of cartesian charts and PieChart. */
113
117
  var CHART_DEFAULT_ASPECT_RATIO = 2;
114
118
  //#endregion
115
- export { AREA_FILL_OPACITY, AREA_STACKED_FILL_OPACITY, AUTO_INSET_LEFT_STEP, AXIS_LABEL_GAP, AXIS_TITLE_BAND, AXIS_TITLE_GAP, BAR_GAP_RATIO, BAR_RADIUS, BAR_RADIUS_NARROW, CAPTION_CHAR_WIDTH_PX, CAPTION_LINE, CATEGORY_AXIS_LABEL_GAP, CATEGORY_AXIS_LABEL_HEIGHT, CATEGORY_GAP_RATIO, CHART_DEFAULT_ASPECT_RATIO, CHART_LEGEND_OVERFLOW_MAX_HEIGHT, CHART_LEGEND_SIDE_GAP, CHART_MARK_OPACITY_ACTIVE, CHART_MARK_OPACITY_DIMMED, CHART_MARK_OPACITY_MUTED, CHART_TOOLTIP_MAX_ROWS, CHART_TOOLTIP_OFFSET, DEFAULT_VALUE_TICK_COUNT, GRID_LINE_BLEED, HEATMAP_CELL_GAP_RATIO, LINE_DASH, LINE_POINT_RADIUS, LINE_STROKE_WIDTH, MAX_AUTO_INSET_LEFT, MAX_BAR_GAP, MAX_CATEGORY_GAP, MAX_HEATMAP_CELL_GAP, MIN_AUTO_INSET_LEFT, MIN_BAR_GAP, MIN_BAR_LENGTH, MIN_CATEGORY_GAP, MIN_CATEGORY_LABEL_GAP, MIN_HEATMAP_CELL_GAP, MIN_VALUE_TICK_SPACING, NARROW_BAR_WIDTH, PIE_SLICE_GAP, PLOT_INSET_BOTTOM, PLOT_INSET_RIGHT, PLOT_INSET_TOP, RADIAL_PLOT_INSET, STACK_SEGMENT_GAP, VALUE_AXIS_LABEL_HEIGHT, VALUE_AXIS_TICK_GAP };
119
+ export { AREA_FILL_OPACITY, AREA_STACKED_FILL_OPACITY, AUTO_INSET_LEFT_STEP, AXIS_LABEL_GAP, AXIS_TITLE_BAND, AXIS_TITLE_GAP, BAR_GAP_RATIO, BAR_RADIUS, BAR_RADIUS_NARROW, CAPTION_CHAR_WIDTH_PX, CAPTION_LINE, CATEGORY_AXIS_LABEL_GAP, CATEGORY_AXIS_LABEL_HEIGHT, CATEGORY_GAP_RATIO, CHART_DEFAULT_ASPECT_RATIO, CHART_LEGEND_FILTER_EXTENT, CHART_LEGEND_FILTER_MAX_WIDTH, CHART_LEGEND_OVERFLOW_MAX_HEIGHT, CHART_LEGEND_SIDE_GAP, CHART_MARK_OPACITY_ACTIVE, CHART_MARK_OPACITY_DIMMED, CHART_MARK_OPACITY_MUTED, CHART_TOOLTIP_MAX_ROWS, CHART_TOOLTIP_OFFSET, DEFAULT_VALUE_TICK_COUNT, GRID_LINE_BLEED, HEATMAP_CELL_GAP_RATIO, LINE_DASH, LINE_POINT_RADIUS, LINE_STROKE_WIDTH, MAX_AUTO_INSET_LEFT, MAX_BAR_GAP, MAX_CATEGORY_GAP, MAX_HEATMAP_CELL_GAP, MIN_AUTO_INSET_LEFT, MIN_BAR_GAP, MIN_BAR_LENGTH, MIN_CATEGORY_GAP, MIN_CATEGORY_LABEL_GAP, MIN_HEATMAP_CELL_GAP, MIN_VALUE_TICK_SPACING, NARROW_BAR_WIDTH, PIE_SLICE_GAP, PLOT_INSET_BOTTOM, PLOT_INSET_RIGHT, PLOT_INSET_TOP, RADIAL_PLOT_INSET, STACK_SEGMENT_GAP, VALUE_AXIS_LABEL_HEIGHT, VALUE_AXIS_TICK_GAP };
@@ -281,8 +281,17 @@ export interface BaseChartProps<TKey extends string> extends AriaAttributes {
281
281
  placeholder?: string;
282
282
  /** Called when a mark or category is clicked, or Enter / Space is pressed on the active category. Supplying it makes the plot selectable. */
283
283
  onSelect?: (event: ChartSelectionEvent<TKey>) => void;
284
- /** Which marks read as selected: matching marks hold full strength, the rest recede. Presentational; the chart never writes it. */
284
+ /**
285
+ * Click to filter. Clicking a legend entry, a mark or a category pins it (matching marks hold full strength, the rest recede); clicking it again unpins it, clicking another switches to it, and ⌘ / Ctrl / Shift-click adds or removes. While something is pinned, filter chips after the legend name it, one per part; a chip's × drops its part, and Escape on the plot clears the filter.
286
+ * @default false
287
+ */
288
+ isFilterable?: boolean;
289
+ /** Which marks read as selected: matching marks hold full strength, the rest recede. With `isFilterable`, the controlled filter (pass it with `onSelectionChange`); without, presentational and never written by the chart. */
285
290
  selection?: ChartSelectionInput<TKey>;
291
+ /** Initial filter while uncontrolled, with `isFilterable`. */
292
+ defaultSelection?: ChartSelectionInput<TKey>;
293
+ /** With `isFilterable`: called with the new filter when a click, Enter, Escape or the chip's × changes it. An empty array means cleared. */
294
+ onSelectionChange?: (selection: ChartSelection<TKey>[]) => void;
286
295
  /** Accessible name of the chart. @default derived, e.g. Line chart, 12 categories, 3 series */
287
296
  ariaLabel?: string;
288
297
  /** DOM id on the root `<div>`. */
@@ -1,4 +1,4 @@
1
- import { ChartMarkState, type ChartSelectionInput } from "./types.js";
1
+ import { ChartMarkState, type ChartSelection, type ChartSelectionInput } from "./types.js";
2
2
  /** @internal Inputs of `useChartInteraction`. */
3
3
  export interface UseChartInteractionOptions {
4
4
  /** Number of categories (slices, cells). A shrinking count clears stale indices. */
@@ -9,8 +9,14 @@ export interface UseChartInteractionOptions {
9
9
  categoryKeys?: string[];
10
10
  /** Follow and publish to a `ChartGroupProvider`. PieChart and Heatmap pass `false`. @default true */
11
11
  enableGroupSync?: boolean;
12
- /** The caller's selection. */
12
+ /** The caller's selection: presentational, or the controlled filter with `isFilterable`. */
13
13
  selection?: ChartSelectionInput | undefined;
14
+ /** Clicks toggle the filter; the filter chips' × and Escape clear it. @default false */
15
+ isFilterable?: boolean;
16
+ /** Initial filter while uncontrolled, with `isFilterable`. */
17
+ defaultSelection?: ChartSelectionInput | undefined;
18
+ /** Called with the new filter, with `isFilterable`. */
19
+ onSelectionChange?: ((selection: ChartSelection[]) => void) | undefined;
14
20
  /** How off-category marks recede. @default ChartMarkState.MUTED */
15
21
  categoryMuteState?: ChartMarkState;
16
22
  /** No category interaction (static plot, loading, empty). The legend stays live. */
@@ -39,6 +45,22 @@ export interface ChartInteraction {
39
45
  hoveredSeriesKey: string | null;
40
46
  /** Categories the selection names outright (persistent cursors). */
41
47
  selectedCategoryIndices: ReadonlySet<number>;
48
+ /** Clicks filter (`isFilterable`). */
49
+ isFilterable: boolean;
50
+ /** Something is pinned (or the caller's selection is not empty). */
51
+ hasSelection: boolean;
52
+ /** The selection as a list (the pinned filter with `isFilterable`). */
53
+ selection: readonly ChartSelection[];
54
+ /** One click on a descriptor: pin, unpin, switch, or (additive) add / remove. No-op unless filterable. */
55
+ toggleSelection: (descriptor: ChartSelection, isAdditive: boolean) => void;
56
+ /** Replace the filter (a chip's × dropping one part of a pin). No-op unless filterable. */
57
+ setSelection: (selection: ChartSelection[]) => void;
58
+ /** Clear the filter (the chip's ×, Escape). No-op unless filterable. */
59
+ clearSelection: () => void;
60
+ /** A legend entry's emphasis: the hovered entry, else whether the selection covers its series. */
61
+ getSeriesState: (seriesKey: string) => ChartMarkState;
62
+ /** The series is pinned on its own (`{ seriesKey }` is in the filter): its legend entry reads pressed. */
63
+ isSeriesPinned: (seriesKey: string) => boolean;
42
64
  /** Emphasis of one mark, composed from selection, series and category. */
43
65
  getMarkState: (args: ChartMarkStateArgs) => ChartMarkState;
44
66
  /** Pointer entered a category's target. */
@@ -63,4 +85,4 @@ export declare function getNextCategoryIndex(current: number, key: string, count
63
85
  columns: number;
64
86
  }): number | null;
65
87
  /** @internal Shared interaction model; see the module comment. */
66
- export declare function useChartInteraction({ categoryCount, seriesKeys, categoryKeys, enableGroupSync, selection, categoryMuteState, isDisabled, isLoading, grid, }: UseChartInteractionOptions): ChartInteraction;
88
+ export declare function useChartInteraction({ categoryCount, seriesKeys, categoryKeys, enableGroupSync, selection, isFilterable, defaultSelection, onSelectionChange, categoryMuteState, isDisabled, isLoading, grid, }: UseChartInteractionOptions): ChartInteraction;