@konce-pt/react 0.17.0 → 0.18.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.
@@ -1,12 +1,35 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
3
  import { useEffect, useId, useRef, useState } from 'react';
4
- import { KptChartMotion, areaPath, bandScale, barLayout, chartAccessibleName, chartDataTable, dataScale, deltaKind, domainScale, extent, formatNumber, holdDomain, linePath, linearScale, niceScale, pieSlices, plotArea, polarSlices, polarToCartesian, polygonPath, radarGrid, radarPoints, radialArea, sameChartShape, seriesColor, significantDelta, } from '@konce-pt/chart';
4
+ import { KptChartMotion, areaPath, axisBase, axisLabelIndices, bandPath, bandScale, barLayout, chartScale, chartAccessibleName, chartDataTable, clampLabel, dataScale, deltaKind, domainScale, estimateLabelWidth, formatNumber, linePath, linearScale, pieSlices, plotArea, polarSlices, polarToCartesian, polygonPath, radarGrid, radarPoints, radialArea, sameChartShape, scaleRows, seriesColor, significantDelta, stackSeries, } from '@konce-pt/chart';
5
5
  import { cn } from "../internal/cn.js";
6
6
  import { useKptMotion } from "../motion/motion-context.js";
7
7
  import { useKptMessages } from "../i18n/i18n-context.js";
8
8
  import { KptChartLegend } from "./chart-legend.js";
9
9
  import { KptChartTooltip } from "./chart-tooltip.js";
10
+ /** Wysokość linii etykiety kategorii na osi pionowej (słupki poziome) - jednostki viewBoxa. */
11
+ const CATEGORY_LINE = 12;
12
+ /**
13
+ * Etykiety osi poziomej: wszystkie, jeśli się mieszczą, inaczej równy podział 9/5/3/2
14
+ * (`axisLabelIndices`). Pełna nazwa każdej kategorii zostaje w pigułce prowadnicy i w dymku.
15
+ */
16
+ function bottomLabels(labels, centers, y, width) {
17
+ const sizes = labels.map((l) => estimateLabelWidth(l));
18
+ const shown = new Set(axisLabelIndices(centers, sizes));
19
+ return labels.map((text, i) => ({
20
+ at: centers[i] ?? 0,
21
+ x: clampLabel(centers[i] ?? 0, sizes[i] ?? 0, 0, width),
22
+ y,
23
+ text,
24
+ anchor: 'middle',
25
+ visible: shown.has(i),
26
+ }));
27
+ }
28
+ /** Etykiety osi pionowej (słupki poziome): ten sam podział, rozmiarem jest wysokość linii. */
29
+ function sideLabels(labels, centers, x) {
30
+ const shown = new Set(axisLabelIndices(centers, centers.map(() => CATEGORY_LINE), 2));
31
+ return labels.map((text, i) => ({ at: centers[i] ?? 0, x, y: (centers[i] ?? 0) + 3, text, anchor: 'end', visible: shown.has(i) }));
32
+ }
10
33
  const VERTICAL_INSETS = { top: 12, right: 16, bottom: 28, left: 46 };
11
34
  const HORIZONTAL_INSETS = { top: 12, right: 16, bottom: 28, left: 76 };
12
35
  /** Szerokość pasma smugi światła (jednostki viewBoxa) - połowa po każdej stronie czoła fali. */
@@ -106,8 +129,11 @@ export function KptChart({ config, width = 600, height = 360, locale, dictionary
106
129
  const to = config.series.map((s) => [...s.data]);
107
130
  const withAxis = config.type === 'line' || config.type === 'bar';
108
131
  const visible = (rows) => rows.filter((_, i) => !hidden.has(`ser:${config.series[i]?.name ?? ''}`));
132
+ const axis = { valueAxis: config.valueAxis, stacked: config.stacked };
133
+ // Skala trzymana na czas ducha obejmuje oba stany - z wierszy już złożonych w stos.
134
+ const held = [...scaleRows(visible(from), config.stacked), ...scaleRows(visible(to), config.stacked)];
109
135
  const scales = withAxis
110
- ? { start: dataScale(visible(from)), hold: dataScale([holdDomain(visible(from), visible(to))]), end: dataScale(visible(to)) }
136
+ ? { start: dataScale(visible(from), axis), hold: chartScale(held, { valueAxis: config.valueAxis }), end: dataScale(visible(to), axis) }
111
137
  : null;
112
138
  motion.transition(from, to, { delta: withAxis && config.animation?.delta !== false, scales });
113
139
  }, [config, hidden, motion, motionConfig]);
@@ -204,16 +230,19 @@ export function KptChart({ config, width = 600, height = 360, locale, dictionary
204
230
  if (view.type !== 'line')
205
231
  return null;
206
232
  const active = view.series.map((s, i) => ({ s, i })).filter(({ s }) => !hidden.has(`ser:${s.name}`));
207
- const values = active.flatMap(({ s }) => s.data);
208
- const [mn, mx] = extent(values.length ? values : [0]);
233
+ const rows = active.map(({ s }) => s.data);
209
234
  const scale = frame.domain
210
235
  ? domainScale(frame.domain.target, frame.domain.min, frame.domain.max)
211
- : niceScale(Math.min(0, mn), Math.max(0, mx), 5);
236
+ : chartScale(rows, { valueAxis: view.valueAxis, stacked: view.stacked });
237
+ // Stos: punkty serii na narastającej sumie, pasmo od linii serii pod nią.
238
+ const stack = view.stacked ? stackSeries(rows) : null;
212
239
  const plot = plotArea(width, height, VERTICAL_INSETS);
213
240
  const yScale = linearScale(scale.min, scale.max, plot.y + plot.height, plot.y);
214
241
  const count = view.labels.length;
215
242
  const xAt = (i) => count > 1 ? plot.x + (i / (count - 1)) * plot.width : plot.x + plot.width / 2;
216
- const baseline = yScale(0);
243
+ // Punkt nie wychodzi poza oś - przypięte `valueAxis.min`/`max` mogą uciąć wartość.
244
+ const yAt = (v) => yScale(Math.min(scale.max, Math.max(scale.min, v)));
245
+ const baseline = yScale(axisBase(scale));
217
246
  const interpolation = view.interpolation ?? 'linear';
218
247
  const showPoints = view.points ?? true;
219
248
  const grid = scale.ticks.map((t) => ({
@@ -228,29 +257,35 @@ export function KptChart({ config, width = 600, height = 360, locale, dictionary
228
257
  text: formatNumber(t),
229
258
  anchor: 'end',
230
259
  }));
231
- const catLabels = view.labels.map((label, i) => ({
232
- x: xAt(i),
233
- y: plot.y + plot.height + 18,
234
- text: label,
235
- anchor: 'middle',
236
- }));
237
- const series = active.map(({ s, i }) => ({
260
+ const catLabels = bottomLabels(view.labels, view.labels.map((_, i) => xAt(i)), plot.y + plot.height + 18, width);
261
+ const catTicks = view.labels.map((_, i) => ({ x: xAt(i), y: plot.y + plot.height }));
262
+ const series = active.map(({ s, i }, k) => ({
238
263
  key: `ser:${s.name}`,
239
264
  name: s.name,
240
265
  color: seriesColor(s.color, i),
241
266
  i,
242
- points: s.data.map((v, idx) => ({ x: xAt(idx), y: yScale(v), value: v, label: view.labels[idx] ?? '' })),
267
+ points: s.data.map((v, idx) => ({ x: xAt(idx), y: yAt(stack?.top[k]?.[idx] ?? v), value: v, label: view.labels[idx] ?? '' })),
268
+ floor: stack ? s.data.map((_, idx) => ({ x: xAt(idx), y: yAt(stack.base[k]?.[idx] ?? 0) })) : null,
243
269
  }));
270
+ // Duch w stosie stoi na sumach poprzedniego stanu, tak jak bieżące punkty.
271
+ const ghostRows = ghostState
272
+ ? (() => {
273
+ const prev = active.map(({ i }) => ghostState.prev[i] ?? []);
274
+ return view.stacked ? stackSeries(prev).top : prev;
275
+ })()
276
+ : [];
244
277
  const crosshairCat = config.crosshair && activeIndex !== null ? catLabels[activeIndex] : undefined;
245
278
  // Prowadnica i pigułka jadą razem z dymkiem.
246
- const crossX = crosshairCat ? (frame.tip?.x ?? crosshairCat.x) : 0;
279
+ const crossX = crosshairCat ? (frame.tip?.x ?? crosshairCat.at) : 0;
247
280
  const pillWidth = crosshairCat ? Math.max(28, crosshairCat.text.length * 7 + 16) : 0;
281
+ // Pigułka przy skrajnej kategorii przesuwa się do środka zamiast wyjść poza viewBox.
282
+ const pillX = crosshairCat ? clampLabel(crossX, pillWidth, 0, width) : 0;
248
283
  /** Najbliższa kategoria w całym obszarze wykresu: wiersze wszystkich serii, dymek nad najwyższym punktem. */
249
284
  const onMove = (event) => {
250
285
  const p = svgPoint(event);
251
286
  if (!p || !series.length)
252
287
  return;
253
- const idx = nearestIndex(catLabels.map((l) => l.x), p.x);
288
+ const idx = nearestIndex(catLabels.map((l) => l.at), p.x);
254
289
  if (keepTip(idx))
255
290
  return;
256
291
  const hits = series.map((s) => ({ s, pt: s.points[idx] })).filter((e) => !!e.pt);
@@ -264,14 +299,14 @@ export function KptChart({ config, width = 600, height = 360, locale, dictionary
264
299
  if (moved && tipMotion())
265
300
  setRipple({ id: ++rippleId.current, x: top.pt.x, y: top.pt.y, color: top.s.color });
266
301
  };
267
- return (_jsxs(_Fragment, { children: [_jsx("g", { className: "kpt-chart__grid", children: grid.map((g, i) => (_jsx("line", { className: "kpt-chart__gridline", x1: g.x1, y1: g.y1, x2: g.x2, y2: g.y2 }, i))) }), crosshairCat ? (_jsx("line", { className: "kpt-chart__crosshair", x1: crossX, y1: plot.y, x2: crossX, y2: plot.y + plot.height })) : null, _jsx("g", { className: "kpt-chart__axis", children: [...valueLabels, ...catLabels].map((l, i) => (_jsx("text", { x: l.x, y: l.y, textAnchor: l.anchor, children: l.text }, i))) }), crosshairCat ? (_jsxs("g", { className: "kpt-chart__cat-active", children: [_jsx("rect", { x: crossX - pillWidth / 2, y: crosshairCat.y - 13, width: pillWidth, height: 18, rx: 9 }), _jsx("text", { x: crossX, y: crosshairCat.y, textAnchor: "middle", children: crosshairCat.text })] })) : null, ghostState
302
+ return (_jsxs(_Fragment, { children: [_jsx("g", { className: "kpt-chart__grid", children: grid.map((g, i) => (_jsx("line", { className: "kpt-chart__gridline", x1: g.x1, y1: g.y1, x2: g.x2, y2: g.y2 }, i))) }), crosshairCat ? (_jsx("line", { className: "kpt-chart__crosshair", x1: crossX, y1: plot.y, x2: crossX, y2: plot.y + plot.height })) : null, _jsx("g", { className: "kpt-chart__axis", children: [...valueLabels, ...catLabels.filter((l) => l.visible)].map((l, i) => (_jsx("text", { x: l.x, y: l.y, textAnchor: l.anchor, children: l.text }, i))) }), _jsx("g", { className: "kpt-chart__cat-ticks", "aria-hidden": "true", children: catTicks.map((tk, i) => (_jsx("circle", { className: cn('kpt-chart__cat-tick', activeIndex === i && 'kpt-chart__cat-tick--active'), r: 2, cx: tk.x, cy: tk.y }, i))) }), crosshairCat ? (_jsxs("g", { className: "kpt-chart__cat-active", children: [_jsx("rect", { x: pillX - pillWidth / 2, y: crosshairCat.y - 13, width: pillWidth, height: 18, rx: 9 }), _jsx("text", { x: pillX, y: crosshairCat.y, textAnchor: "middle", children: crosshairCat.text })] })) : null, ghostState
268
303
  ? series.map((s) => {
269
304
  const prev = ghostState.prev[s.i] ?? [];
270
305
  const next = ghostState.next[s.i] ?? [];
271
- const gpts = prev.map((v, idx) => ({ x: xAt(idx), y: yScale(v) }));
306
+ const gpts = (ghostRows[series.indexOf(s)] ?? prev).map((v, idx) => ({ x: xAt(idx), y: yAt(v) }));
272
307
  return (_jsxs("g", { className: "kpt-chart__ghost", "aria-hidden": "true", children: [_jsx("path", { className: "kpt-chart__ghost-line", d: linePath(gpts, interpolation) }), gpts.map((p, idx) => (_jsx("line", { className: "kpt-chart__delta", "data-kind": deltaKind(prev[idx] ?? 0, next[idx] ?? 0), x1: p.x, x2: p.x, y1: p.y, y2: s.points[idx]?.y ?? p.y }, idx)))] }, `${ghostState.id}:${s.key}`));
273
308
  })
274
- : null, series.map((s) => (_jsxs("g", { className: cn('kpt-chart__series', dim(s.key) && 'kpt-chart__series--dim'), children: [view.area ? (_jsx("path", { className: "kpt-chart__area", d: areaPath(s.points, baseline, interpolation), style: { fill: s.color } })) : null, _jsx("path", { className: "kpt-chart__line", d: linePath(s.points, interpolation), style: { stroke: s.color }, pathLength: 1 }), showPoints
309
+ : null, series.map((s) => (_jsxs("g", { className: cn('kpt-chart__series', dim(s.key) && 'kpt-chart__series--dim'), children: [view.area ? (_jsx("path", { className: "kpt-chart__area", d: s.floor ? bandPath(s.points, s.floor, interpolation) : areaPath(s.points, baseline, interpolation), style: { fill: s.color } })) : null, _jsx("path", { className: "kpt-chart__line", d: linePath(s.points, interpolation), style: { stroke: s.color }, pathLength: 1 }), showPoints
275
310
  ? s.points.map((p, idx) => (_jsx("circle", { className: cn('kpt-chart__point', activeIndex === idx && 'kpt-chart__point--active'), cx: p.x, cy: p.y, r: 4, style: { fill: s.color, ['--kpt-chart-i']: idx } }, idx)))
276
311
  : null] }, s.key))), sheen
277
312
  ? series.map((s) => (_jsx("path", { className: "kpt-chart__sheen-line", d: linePath(s.points, interpolation), stroke: `url(#${sheenId})`, style: { opacity: sheen.opacity }, "aria-hidden": "true" }, s.key)))
@@ -301,7 +336,7 @@ export function KptChart({ config, width = 600, height = 360, locale, dictionary
301
336
  const active = view.series.map((s, i) => ({ s, i })).filter(({ s }) => !hidden.has(`ser:${s.name}`));
302
337
  const plot = plotArea(width, height, vertical ? VERTICAL_INSETS : HORIZONTAL_INSETS);
303
338
  const radius = view.borderRadius ?? 4;
304
- const layout = barLayout(active.map(({ s }) => s.data), view.labels.length, plot, orientation, radius, frame.domain ? domainScale(frame.domain.target, frame.domain.min, frame.domain.max) : undefined);
339
+ const layout = barLayout(active.map(({ s }) => s.data), view.labels.length, plot, orientation, radius, frame.domain ? domainScale(frame.domain.target, frame.domain.min, frame.domain.max) : undefined, { stacked: view.stacked, valueAxis: view.valueAxis });
305
340
  const colors = active.map(({ s, i }) => seriesColor(s.color, i));
306
341
  const names = active.map(({ s }) => s.name);
307
342
  const cat = vertical
@@ -318,9 +353,10 @@ export function KptChart({ config, width = 600, height = 360, locale, dictionary
318
353
  text: formatNumber(t.value),
319
354
  anchor: 'middle',
320
355
  }));
356
+ const centers = view.labels.map((_, i) => cat.center(i));
321
357
  const catLabels = vertical
322
- ? view.labels.map((l, i) => ({ x: cat.center(i), y: plot.y + plot.height + 18, text: l, anchor: 'middle' }))
323
- : view.labels.map((l, i) => ({ x: plot.x - 8, y: cat.center(i) + 3, text: l, anchor: 'end' }));
358
+ ? bottomLabels(view.labels, centers, plot.y + plot.height + 18, width)
359
+ : sideLabels(view.labels, centers, plot.x - 8);
324
360
  const bars = layout.bars.map((b, order) => ({
325
361
  b,
326
362
  order,
@@ -335,7 +371,7 @@ export function KptChart({ config, width = 600, height = 360, locale, dictionary
335
371
  ? (() => {
336
372
  const prevRows = active.map(({ i }) => ghostState.prev[i] ?? []);
337
373
  const nextRows = active.map(({ i }) => ghostState.next[i] ?? []);
338
- return barLayout(prevRows, view.labels.length, plot, orientation, radius, layout.scale).bars.map((g, k) => {
374
+ return barLayout(prevRows, view.labels.length, plot, orientation, radius, layout.scale, { stacked: view.stacked }).bars.map((g, k) => {
339
375
  const cur = layout.bars[k] ?? g;
340
376
  const a = barEnd(g, vertical);
341
377
  const z = barEnd(cur, vertical);
@@ -357,7 +393,7 @@ export function KptChart({ config, width = 600, height = 360, locale, dictionary
357
393
  const p = svgPoint(event);
358
394
  if (!p)
359
395
  return;
360
- const idx = nearestIndex(catLabels.map((l) => (vertical ? l.x : l.y - 3)), vertical ? p.x : p.y);
396
+ const idx = nearestIndex(catLabels.map((l) => l.at), vertical ? p.x : p.y);
361
397
  if (keepTip(idx))
362
398
  return;
363
399
  const group = bars.filter((e) => e.b.categoryIndex === idx);
@@ -365,11 +401,11 @@ export function KptChart({ config, width = 600, height = 360, locale, dictionary
365
401
  if (!group.length || !center)
366
402
  return;
367
403
  const label = config.labels[idx] ?? '';
368
- const x = vertical ? center.x : Math.max(...group.map((e) => e.tipX));
369
- const y = vertical ? Math.min(...group.map((e) => e.tipY)) : center.y - 3;
404
+ const x = vertical ? center.at : Math.max(...group.map((e) => e.tipX));
405
+ const y = vertical ? Math.min(...group.map((e) => e.tipY)) : center.at;
370
406
  openTip(x, y, label, group.map((e) => row(e.name, label, e.b.value, e.color)), idx);
371
407
  };
372
- return (_jsxs(_Fragment, { children: [_jsx("g", { className: "kpt-chart__grid", children: grid.map((g, i) => (_jsx("line", { className: "kpt-chart__gridline", x1: g.x1, y1: g.y1, x2: g.x2, y2: g.y2 }, i))) }), _jsx("g", { className: "kpt-chart__axis", children: [...valueLabels, ...catLabels].map((l, i) => (_jsx("text", { x: l.x, y: l.y, textAnchor: l.anchor, children: l.text }, i))) }), ghostBars.length ? (_jsx("g", { className: "kpt-chart__ghost", "aria-hidden": "true", children: ghostBars.map((g) => (_jsx("path", { className: "kpt-chart__ghost-bar", d: g.path }, g.key))) })) : null, _jsx("g", { className: "kpt-chart__bars", "data-orientation": orientation, children: bars.map((e) => (_jsx("path", { className: cn('kpt-chart__bar', dim(e.key) && 'kpt-chart__series--dim'), d: e.b.path, style: { fill: e.color, ['--kpt-chart-i']: e.order } }, `${e.key}#${e.order}`))) }), ghostBars.length ? (_jsx("g", { className: "kpt-chart__ghost", "aria-hidden": "true", children: ghostBars.map((g) => (_jsx("rect", { className: "kpt-chart__delta-bar", "data-kind": g.kind, rx: 2, ...g.delta }, g.key))) })) : null, sheen
408
+ return (_jsxs(_Fragment, { children: [_jsx("g", { className: "kpt-chart__grid", children: grid.map((g, i) => (_jsx("line", { className: "kpt-chart__gridline", x1: g.x1, y1: g.y1, x2: g.x2, y2: g.y2 }, i))) }), _jsx("g", { className: "kpt-chart__axis", children: [...valueLabels, ...catLabels.filter((l) => l.visible)].map((l, i) => (_jsx("text", { x: l.x, y: l.y, textAnchor: l.anchor, children: l.text }, i))) }), ghostBars.length ? (_jsx("g", { className: "kpt-chart__ghost", "aria-hidden": "true", children: ghostBars.map((g) => (_jsx("path", { className: "kpt-chart__ghost-bar", d: g.path }, g.key))) })) : null, _jsx("g", { className: "kpt-chart__bars", "data-orientation": orientation, children: bars.map((e) => (_jsx("path", { className: cn('kpt-chart__bar', dim(e.key) && 'kpt-chart__series--dim'), d: e.b.path, style: { fill: e.color, ['--kpt-chart-i']: e.order } }, `${e.key}#${e.order}`))) }), ghostBars.length ? (_jsx("g", { className: "kpt-chart__ghost", "aria-hidden": "true", children: ghostBars.map((g) => (_jsx("rect", { className: "kpt-chart__delta-bar", "data-kind": g.kind, rx: 2, ...g.delta }, g.key))) })) : null, sheen
373
409
  ? bars.map((e) => (_jsx("path", { className: "kpt-chart__sheen-bar", d: e.b.path, fill: `url(#${sheenId})`, style: { opacity: sheen.opacity * 0.7 }, "aria-hidden": "true" }, `${e.key}#${e.order}`)))
374
410
  : null, _jsx("rect", { className: "kpt-chart__hover", x: plot.x, y: plot.y, width: plot.width, height: plot.height, onPointerMove: onMove, onPointerLeave: hideTip })] }));
375
411
  })();
@@ -19,9 +19,29 @@ proportionally (`width={300} height={180}`) instead of letting a 600-unit chart
19
19
 
20
20
  ## The config is the API
21
21
  `{ type, labels, series, points?, area?, interpolation?, orientation?, borderRadius?, innerRadius?,
22
- crosshair?, tooltip?, legend?, title?, subtitle?, animation?, ariaLabel?, description? }` - the same shape the Angular port
22
+ valueAxis?, stacked?, crosshair?, tooltip?, legend?, title?, subtitle?, animation?, ariaLabel?, description? }` - the same shape the Angular port
23
23
  takes, because both read it from `@konce-pt/chart`.
24
24
 
25
+ ## Value axis and stacking (line, bar)
26
+ - `valueAxis?: { zero?, min?, max? }` - the value axis starts at zero by default, so a bar and the area under a line
27
+ show the size directly. `zero: false` fits the axis to the data (a weight of 79-90 kg is no longer a flat line under
28
+ the ceiling); `min` / `max` pin an end of the axis exactly and win over `zero` - a value beyond a pinned end is
29
+ cut at the edge. Bars and areas grow from zero, or from the end of the axis nearer to it
30
+ - `stacked?: boolean` - stacked series: bars on top of each other in one band per category (positive values up,
31
+ negative down; only the outer segment is rounded), lines as running totals with a band under each one (with
32
+ `area`). The axis covers the totals; the tooltip shows the values of the series, not the totals
33
+ - The rules live in `@konce-pt/chart`: `axisScale`, `chartScale`, `stackSeries`, `bandPath` (tested)
34
+
35
+ ## Category axis (line, bar)
36
+ - When the category labels do not fit, the axis shows an even subset instead of overlapping text: all of
37
+ them if they fit, otherwise the densest split that fits - 9 labels (eighths of the axis), 5 (first, 1/4,
38
+ middle, 3/4, last), 3, and finally the two ends only. Horizontal bars apply the same rule to the rows
39
+ - The choice is made in viewBox units, so it follows `width`, not the on-screen size; the outer labels and the
40
+ crosshair pill are shifted inward so they never leave the chart
41
+ - A line chart marks every category with a small dot on the axis; the active one grows on hover. The full
42
+ name of any category stays in the crosshair pill and the tooltip
43
+ - The rules live in `@konce-pt/chart`: `axisLabelIndices`, `estimateLabelWidth`, `clampLabel` (tested)
44
+
25
45
  ## The geometry lives in a package
26
46
  Scales, paths, bar layout and arcs come from `@konce-pt/chart` - pure functions with `node --test`
27
47
  coverage, shared with the Angular port. The component only turns them into SVG elements; there is
@@ -1,4 +1,4 @@
1
1
  export { KptChart } from '../charts/chart.tsx';
2
2
  export type { KptChartProps } from '../charts/chart.tsx';
3
- export type { KptChartConfig, KptChartSeries, KptChartType, KptLineInterpolation, KptBarOrientation, KptLegendOptions, KptLegendPosition, KptLegendTitle, KptAlign, KptPointStyle, KptTitleOptions, KptTooltipOptions, KptTooltipContext, KptAnimationOptions, KptFont, KptPadding, } from '@konce-pt/chart';
3
+ export type { KptChartConfig, KptChartSeries, KptChartType, KptLineInterpolation, KptBarOrientation, KptLegendOptions, KptLegendPosition, KptLegendTitle, KptAlign, KptPointStyle, KptTitleOptions, KptTooltipOptions, KptTooltipContext, KptAnimationOptions, KptValueAxisOptions, KptFont, KptPadding, } from '@konce-pt/chart';
4
4
  export { KPT_CHART_PALETTE, seriesColor, linePath, areaPath, pieSlices, polarSlices, barLayout, radarGrid, radarPoints, niceScale, } from '@konce-pt/chart';
@@ -1,4 +1,5 @@
1
1
  import { type ComponentPropsWithRef, type ReactNode } from 'react';
2
+ import { type KptColumnGroup } from '@konce-pt/table';
2
3
  import { type KptI18nOverrides } from '../i18n/i18n-context.tsx';
3
4
  import { type KptSpeechColor } from '../speech/speech-button.tsx';
4
5
  import type { KptCellEditEvent, KptCellEditRenderers, KptCellRenderers, KptColumn, KptTableLazyState } from './data-table-types.ts';
@@ -45,6 +46,12 @@ export interface KptDataTableProps<T> extends Omit<ComponentPropsWithRef<'div'>,
45
46
  /** Szerokość kolumny bez liczalnego `width` w trybie `virtualScroll` (`px`/`rem`/`em`). */
46
47
  columnWidth?: string;
47
48
  rowHeight?: number;
49
+ /**
50
+ * Nagłówki grupowane: wiersz nad etykietami, w którym sąsiednie kolumny grupy dzielą komórkę
51
+ * (`[{ header: 'Makroskładniki', fields: ['carbs', 'protein', 'fat'] }]`). Liczony z kolumn
52
+ * widocznych, w bieżącej kolejności - ukryta albo przestawiona kolumna wypada z grupy sama.
53
+ */
54
+ columnGroups?: readonly KptColumnGroup[];
48
55
  reorderable?: boolean;
49
56
  /**
50
57
  * Przykleja kolumny systemowe (rozwijanie, zaznaczanie) do lewej krawędzi także bez kolumn `frozen`.
@@ -83,4 +90,4 @@ export interface KptDataTableProps<T> extends Omit<ComponentPropsWithRef<'div'>,
83
90
  /** Treść po lewej stronie paginatora (odpowiednik slotu `[kptPaginatorActions]`). */
84
91
  paginatorActions?: ReactNode;
85
92
  }
86
- export declare function KptDataTable<T = Record<string, unknown>>({ columns, data, filterable, filterSpeech, filterSpeechLang, filterSpeechColor, filterRowPosition, pageSize, pageSizeOptions, selectable, loading, ariaLabel, caption, clickableRows, emptyMessage, rowKey, scrollHeight, virtualScroll, columnWidth, rowHeight, reorderable, frozenSystemColumns, exportable, exportFilename, lazy, totalRecords, expandable, multiExpand, summaryLabel, summaryValues, selection, defaultSelection, onSelectionChange, expandedRows, defaultExpandedRows, onExpandedRowsChange, hiddenFields, defaultHiddenFields, onHiddenFieldsChange, onRowClick, onLazyLoad, onCellEdit, cells, cellEditors, renderRowDetail, paginatorActions, locale, dictionary, className, style, ...rest }: Readonly<KptDataTableProps<T>>): import("react").JSX.Element;
93
+ export declare function KptDataTable<T = Record<string, unknown>>({ columns, data, filterable, filterSpeech, filterSpeechLang, filterSpeechColor, filterRowPosition, pageSize, pageSizeOptions, selectable, loading, ariaLabel, caption, clickableRows, emptyMessage, rowKey, scrollHeight, virtualScroll, columnWidth, rowHeight, reorderable, columnGroups, frozenSystemColumns, exportable, exportFilename, lazy, totalRecords, expandable, multiExpand, summaryLabel, summaryValues, selection, defaultSelection, onSelectionChange, expandedRows, defaultExpandedRows, onExpandedRowsChange, hiddenFields, defaultHiddenFields, onHiddenFieldsChange, onRowClick, onLazyLoad, onCellEdit, cells, cellEditors, renderRowDetail, paginatorActions, locale, dictionary, className, style, ...rest }: Readonly<KptDataTableProps<T>>): import("react").JSX.Element;
@@ -1,7 +1,7 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
3
  import { useEffect, useEffectEvent, useId, useLayoutEffect, useMemo, useRef, useState, } from 'react';
4
- import { aggregate, columnOffsets, createGlobalFilter, cssLengthToPx, isEmptyFilterValue, matchesColumnFilter, sortRows, toText, virtualColumnRange, virtualRange, } from '@konce-pt/table';
4
+ import { aggregate, columnOffsets, createGlobalFilter, cssLengthToPx, groupHeaderCells, isEmptyFilterValue, matchesColumnFilter, sortRows, toText, virtualColumnRange, virtualRange, } from '@konce-pt/table';
5
5
  import { cn } from "../internal/cn.js";
6
6
  import { useControllableState } from "../internal/use-controllable-state.js";
7
7
  import { useKptMessages } from "../i18n/i18n-context.js";
@@ -82,7 +82,7 @@ const EMPTY_FIELDS = [];
82
82
  function getValue(row, field) {
83
83
  return row[field];
84
84
  }
85
- export function KptDataTable({ columns, data = EMPTY_ROWS, filterable = false, filterSpeech = false, filterSpeechLang, filterSpeechColor, filterRowPosition = 'bottom', pageSize = 0, pageSizeOptions = [5, 10, 20, 50], selectable = 'none', loading = false, ariaLabel = null, caption = null, clickableRows, emptyMessage, rowKey = null, scrollHeight = null, virtualScroll = false, columnWidth = '10rem', rowHeight = 41, reorderable = false, frozenSystemColumns = false, exportable = false, exportFilename = 'export.csv', lazy = false, totalRecords = null, expandable = false, multiExpand = true, summaryLabel, summaryValues = null, selection, defaultSelection = EMPTY_ROWS, onSelectionChange, expandedRows, defaultExpandedRows = EMPTY_ROWS, onExpandedRowsChange, hiddenFields, defaultHiddenFields = EMPTY_FIELDS, onHiddenFieldsChange, onRowClick, onLazyLoad, onCellEdit, cells, cellEditors, renderRowDetail, paginatorActions, locale, dictionary, className, style, ...rest }) {
85
+ export function KptDataTable({ columns, data = EMPTY_ROWS, filterable = false, filterSpeech = false, filterSpeechLang, filterSpeechColor, filterRowPosition = 'bottom', pageSize = 0, pageSizeOptions = [5, 10, 20, 50], selectable = 'none', loading = false, ariaLabel = null, caption = null, clickableRows, emptyMessage, rowKey = null, scrollHeight = null, virtualScroll = false, columnWidth = '10rem', rowHeight = 41, reorderable = false, columnGroups, frozenSystemColumns = false, exportable = false, exportFilename = 'export.csv', lazy = false, totalRecords = null, expandable = false, multiExpand = true, summaryLabel, summaryValues = null, selection, defaultSelection = EMPTY_ROWS, onSelectionChange, expandedRows, defaultExpandedRows = EMPTY_ROWS, onExpandedRowsChange, hiddenFields, defaultHiddenFields = EMPTY_FIELDS, onHiddenFieldsChange, onRowClick, onLazyLoad, onCellEdit, cells, cellEditors, renderRowDetail, paginatorActions, locale, dictionary, className, style, ...rest }) {
86
86
  const { t, locale: activeLocale } = useKptMessages({ locale, dictionary });
87
87
  const uid = useId();
88
88
  const rowsClickable = clickableRows ?? onRowClick !== undefined;
@@ -149,6 +149,8 @@ export function KptDataTable({ columns, data = EMPTY_ROWS, filterable = false, f
149
149
  const [viewport, setViewport] = useState({ height: 400, width: 1200 });
150
150
  const scrollFrame = useRef(0);
151
151
  const [theadOffset, setTheadOffset] = useState(0);
152
+ /** Offset sticky trzeciego wiersza nagłówka (grupy + etykiety + filtry). */
153
+ const [theadOffset2, setTheadOffset2] = useState(0);
152
154
  const scrollRef = useRef(null);
153
155
  const theadRef = useRef(null);
154
156
  const editorHostRef = useRef(null);
@@ -234,14 +236,17 @@ export function KptDataTable({ columns, data = EMPTY_ROWS, filterable = false, f
234
236
  return;
235
237
  requestLazyLoad({ pageIndex, pageSize: pageSizeState, sortField, sortDir, filter: filterText, columnFilters });
236
238
  }, [lazy, pageIndex, pageSizeState, sortField, sortDir, filterText, columnFilters]);
237
- // Offset sticky drugiego wiersza nagłówka = wysokość pierwszego.
239
+ // Offsety sticky drugiego i trzeciego wiersza nagłówka = wysokość jednego i dwóch pierwszych.
238
240
  useLayoutEffect(() => {
239
- const first = theadRef.current?.rows?.[0];
241
+ const rows = theadRef.current?.rows;
240
242
  // Dokładna wysokość, nie `offsetHeight`: ten zaokrągla (46,5 -> 47) i przy skali ekranu innej
241
243
  // niż 100% między wierszami nagłówka zostawała szpara, przez którą prześwitywały wiersze.
242
- if (first)
243
- setTheadOffset(first.getBoundingClientRect().height);
244
- }, [filterRowPosition, hasColumnFilters, visibleColumns]);
244
+ const h1 = rows?.[0]?.getBoundingClientRect().height ?? 0;
245
+ const h2 = rows?.[1]?.getBoundingClientRect().height ?? 0;
246
+ if (rows?.[0])
247
+ setTheadOffset(h1);
248
+ setTheadOffset2(h1 + h2);
249
+ }, [filterRowPosition, hasColumnFilters, visibleColumns, columnGroups]);
245
250
  // Rozmiar viewportu dla wirtualizacji - także po zmianie rozmiaru kontenera bez przewinięcia.
246
251
  useLayoutEffect(() => {
247
252
  const el = scrollRef.current;
@@ -400,8 +405,22 @@ export function KptDataTable({ columns, data = EMPTY_ROWS, filterable = false, f
400
405
  const tableWidth = virtualScroll
401
406
  ? scrollable.pinnedWidth + scrollable.offsets[scrollable.offsets.length - 1]
402
407
  : undefined;
403
- /** Liczba wierszy nagłówka (etykiety + ewentualnie filtry) - przesunięcie `aria-rowindex`. */
404
- const headerRowCount = hasColumnFilters ? 2 : 1;
408
+ /**
409
+ * Wiersz grup nad etykietami: komórki nad pozycjami `slots`. Komórka przykleja się do lewej, gdy
410
+ * wszystkie jej kolumny są zamrożone - inaczej przewija się z nimi. Pusta lista = bez wiersza.
411
+ */
412
+ const groupCells = columnGroups?.length
413
+ ? groupHeaderCells(slots.map((s) => s.meta?.col.field ?? null), columnGroups).map((cell) => {
414
+ const metas = slots.slice(cell.start, cell.start + cell.span).map((s) => s.meta);
415
+ const spacer = metas.some((m) => m === null);
416
+ const frozen = !spacer && metas.every((m) => m?.frozen);
417
+ return { ...cell, key: slots[cell.start]?.key ?? String(cell.start), spacer, frozen, left: frozen ? (metas[0]?.left ?? undefined) : undefined };
418
+ })
419
+ : [];
420
+ /** Wiersz grup jest pierwszym wierszem nagłówka - przesuwa `aria-rowindex` pozostałych. */
421
+ const groupRows = groupCells.length ? 1 : 0;
422
+ /** Liczba wierszy nagłówka (grupy, etykiety, ewentualnie filtry) - przesunięcie `aria-rowindex`. */
423
+ const headerRowCount = groupRows + (hasColumnFilters ? 2 : 1);
405
424
  /** `aria-colindex` komórki kolumny (od 1, z kolumnami systemowymi) - tylko w trybie wirtualnym. */
406
425
  const colIndex = (meta) => virtualScroll ? leadingCells + meta.index + 1 : undefined;
407
426
  // -- Przestawianie kolumn --
@@ -671,7 +690,7 @@ export function KptDataTable({ columns, data = EMPTY_ROWS, filterable = false, f
671
690
  return null;
672
691
  }
673
692
  };
674
- const filterRowIndex = filterRowPosition === 'top' ? 1 : 2;
693
+ const filterRowIndex = groupRows + (filterRowPosition === 'top' ? 1 : 2);
675
694
  const filterRow = (_jsxs("tr", { className: "kpt-data-table__filter-row", "aria-rowindex": virtualScroll ? filterRowIndex : undefined, children: [hasRowDetail ? (_jsx("th", { className: cn('kpt-data-table__expander', hasFrozen && 'kpt-data-table__cell--frozen'), style: { left: expanderStickyLeft } })) : null, selectable !== 'none' ? (_jsx("th", { className: cn('kpt-data-table__checkbox', hasFrozen && 'kpt-data-table__cell--frozen'), style: { left: checkboxStickyLeft } })) : null, slots.map((slot) => slot.meta ? (_jsx("th", { style: { width: virtualScroll ? undefined : slot.meta.col.width, left: slot.meta.left }, "data-align": slot.meta.align, "aria-colindex": colIndex(slot.meta), className: cn(slot.meta.frozen && 'kpt-data-table__cell--frozen'), children: filterCell(slot.meta.col) }, slot.key)) : (hspacer(slot)))] }));
676
695
  const editor = (row, col) => {
677
696
  const custom = cellEditors?.[col.field];
@@ -743,7 +762,7 @@ export function KptDataTable({ columns, data = EMPTY_ROWS, filterable = false, f
743
762
  startEdit(row, col, event);
744
763
  }, children: editing ? (_jsx("div", { ref: editorHostRef, className: "kpt-data-table__editor", onClick: (event) => event.stopPropagation(), onBlur: onEditorFocusOut, children: editor(row, col) })) : cells?.[col.field] ? (cells[col.field](row, getValue(row, col.field))) : getValue(row, col.field) }, slot.key));
745
764
  })] }), hasRowDetail && isExpanded(row) && !virtualScroll ? (_jsx("tr", { className: "kpt-data-table__detail-row", children: _jsx("td", { className: "kpt-data-table__detail", colSpan: colspan, children: renderRowDetail?.(row) }) })) : null] }, rowKey ? String(getValue(row, rowKey)) : index));
746
- return (_jsxs("div", { className: cn('kpt-data-table', className), style: { ['--kpt-thead-offset']: `${theadOffset}px`, ...style }, ...rest, children: [filterable || exportable ? (_jsxs("div", { className: "kpt-data-table__toolbar", children: [filterable ? (_jsxs("div", { className: "kpt-data-table__search", "data-speech": filterSpeechState, children: [_jsx("input", { ref: filterRef, className: "kpt-data-table__filter", type: "search", placeholder: t('dataTable.search'), "aria-label": t('dataTable.search'), value: filterInput, onChange: (event) => onFilterInput(event.target.value) }), filterSpeech ? (_jsx(KptSpeechButton, { listeningColor: filterSpeechColor, speech: filterMic, size: "sm", unsupported: "hide", hostProps: { className: 'kpt-data-table__speech' } })) : null] })) : null, exportable ? (_jsx(KptButton, { variant: "outline", size: "sm", onClick: () => exportTableCsv(visibleColumns, (lazy ? data : sorted), exportFilename), children: t('dataTable.exportCsv') })) : null] })) : null, _jsx("div", { ref: scrollRef, className: "kpt-data-table__scroll", style: { maxHeight: scrollHeight ?? undefined }, onScroll: onScroll, children: _jsxs("table", { className: cn('kpt-data-table__table', hasFrozen && 'kpt-data-table__table--frozen', virtualScroll && 'kpt-data-table__table--virtual'), style: { width: tableWidth }, "aria-label": ariaLabel ?? undefined, "aria-busy": loading || undefined, "aria-rowcount": virtualScroll ? headerRowCount + sorted.length + (hasSummary ? 1 : 0) : undefined, "aria-colcount": virtualScroll ? leadingCells + visibleColumns.length : undefined, children: [caption ? _jsx("caption", { className: "kpt-data-table__caption", children: _jsx("span", { className: "kpt-data-table__caption-text", children: caption }) }) : null, virtualScroll ? (_jsxs("colgroup", { children: [hasRowDetail ? _jsx("col", { className: "kpt-data-table__col-expander" }) : null, selectable !== 'none' ? _jsx("col", { className: "kpt-data-table__col-checkbox" }) : null, slots.map((slot) => (_jsx("col", { style: { width: slot.width } }, slot.key)))] })) : null, _jsxs("thead", { ref: theadRef, children: [hasColumnFilters && filterRowPosition === 'top' ? filterRow : null, _jsxs("tr", { "aria-rowindex": virtualScroll ? (hasColumnFilters && filterRowPosition === 'top' ? 2 : 1) : undefined, children: [hasRowDetail ? (_jsx("th", { className: cn('kpt-data-table__expander', hasFrozen && 'kpt-data-table__cell--frozen'), style: { left: expanderStickyLeft } })) : null, selectable === 'multiple' ? (_jsx("th", { className: cn('kpt-data-table__checkbox', hasFrozen && 'kpt-data-table__cell--frozen'), style: { left: checkboxStickyLeft }, children: _jsx("input", { type: "checkbox", checked: pageAllSelected, ref: (el) => {
765
+ return (_jsxs("div", { className: cn('kpt-data-table', className), style: { ['--kpt-thead-offset']: `${theadOffset}px`, ['--kpt-thead-offset-2']: `${theadOffset2}px`, ...style }, ...rest, children: [filterable || exportable ? (_jsxs("div", { className: "kpt-data-table__toolbar", children: [filterable ? (_jsxs("div", { className: "kpt-data-table__search", "data-speech": filterSpeechState, children: [_jsx("input", { ref: filterRef, className: "kpt-data-table__filter", type: "search", placeholder: t('dataTable.search'), "aria-label": t('dataTable.search'), value: filterInput, onChange: (event) => onFilterInput(event.target.value) }), filterSpeech ? (_jsx(KptSpeechButton, { listeningColor: filterSpeechColor, speech: filterMic, size: "sm", unsupported: "hide", hostProps: { className: 'kpt-data-table__speech' } })) : null] })) : null, exportable ? (_jsx(KptButton, { variant: "outline", size: "sm", onClick: () => exportTableCsv(visibleColumns, (lazy ? data : sorted), exportFilename), children: t('dataTable.exportCsv') })) : null] })) : null, _jsx("div", { ref: scrollRef, className: "kpt-data-table__scroll", style: { maxHeight: scrollHeight ?? undefined }, onScroll: onScroll, children: _jsxs("table", { className: cn('kpt-data-table__table', hasFrozen && 'kpt-data-table__table--frozen', virtualScroll && 'kpt-data-table__table--virtual'), style: { width: tableWidth }, "aria-label": ariaLabel ?? undefined, "aria-busy": loading || undefined, "aria-rowcount": virtualScroll ? headerRowCount + sorted.length + (hasSummary ? 1 : 0) : undefined, "aria-colcount": virtualScroll ? leadingCells + visibleColumns.length : undefined, children: [caption ? _jsx("caption", { className: "kpt-data-table__caption", children: _jsx("span", { className: "kpt-data-table__caption-text", children: caption }) }) : null, virtualScroll ? (_jsxs("colgroup", { children: [hasRowDetail ? _jsx("col", { className: "kpt-data-table__col-expander" }) : null, selectable !== 'none' ? _jsx("col", { className: "kpt-data-table__col-checkbox" }) : null, slots.map((slot) => (_jsx("col", { style: { width: slot.width } }, slot.key)))] })) : null, _jsxs("thead", { ref: theadRef, children: [groupCells.length ? (_jsxs("tr", { className: "kpt-data-table__group-row", "aria-rowindex": virtualScroll ? 1 : undefined, children: [hasRowDetail ? (_jsx("th", { className: cn('kpt-data-table__expander', hasFrozen && 'kpt-data-table__cell--frozen'), style: { left: expanderStickyLeft } })) : null, selectable !== 'none' ? (_jsx("th", { className: cn('kpt-data-table__checkbox', hasFrozen && 'kpt-data-table__cell--frozen'), style: { left: checkboxStickyLeft } })) : null, groupCells.map((cell) => cell.spacer ? (_jsx("td", { className: "kpt-data-table__hspacer", colSpan: cell.span, "aria-hidden": "true" }, cell.key)) : (_jsx("th", { className: cn('kpt-data-table__group', !cell.header && 'kpt-data-table__group--empty', cell.frozen && 'kpt-data-table__cell--frozen'), style: { left: cell.left }, colSpan: cell.span > 1 ? cell.span : undefined, scope: cell.header ? 'colgroup' : undefined, children: cell.header }, cell.key)))] })) : null, hasColumnFilters && filterRowPosition === 'top' ? filterRow : null, _jsxs("tr", { "aria-rowindex": virtualScroll ? groupRows + (hasColumnFilters && filterRowPosition === 'top' ? 2 : 1) : undefined, children: [hasRowDetail ? (_jsx("th", { className: cn('kpt-data-table__expander', hasFrozen && 'kpt-data-table__cell--frozen'), style: { left: expanderStickyLeft } })) : null, selectable === 'multiple' ? (_jsx("th", { className: cn('kpt-data-table__checkbox', hasFrozen && 'kpt-data-table__cell--frozen'), style: { left: checkboxStickyLeft }, children: _jsx("input", { type: "checkbox", checked: pageAllSelected, ref: (el) => {
747
766
  if (el)
748
767
  el.indeterminate = pageSomeSelected;
749
768
  }, "aria-label": t('dataTable.selectAll'), onChange: toggleAllOnPage }) })) : selectable === 'single' ? (_jsx("th", { className: cn('kpt-data-table__checkbox', hasFrozen && 'kpt-data-table__cell--frozen'), style: { left: checkboxStickyLeft } })) : null, slots.map((slot) => {
@@ -18,6 +18,11 @@ rendered header.
18
18
 
19
19
  ## Props
20
20
  - `columns` (required), `data`
21
+ - `columnGroups`: KptColumnGroup[] - grouped headers: a row above the column labels where adjacent columns of a
22
+ group share one cell (`[{ header: 'Macros', fields: ['carbs', 'protein', 'fat'] }]`, `scope="colgroup"`). It is
23
+ computed from the visible columns in their current order: a hidden column drops out, a group split by reordering
24
+ gets two cells with the same text, a column without a group gets an empty cell. A cell sticks to the left only
25
+ when all its columns are `frozen`. With column filters the header has three rows and all of them stay sticky
21
26
  - `filterable`, `filterRowPosition` (`'bottom'` default), `pageSize` (0 = no pagination),
22
27
  `pageSizeOptions`, `selectable` (`'none' | 'single' | 'multiple'`), `loading`, `emptyMessage`,
23
28
  `rowKey`, `scrollHeight`, `virtualScroll` (rows **and** columns, see below), `columnWidth`
@@ -113,7 +118,7 @@ The toolbar, the states and the filter labels come from the `dataTable` namespac
113
118
  the expanded row `--kpt-table-detail-bg`, the summary row `--kpt-table-summary-bg`.
114
119
 
115
120
  ## Not ported yet
116
- Grouped columns (absent in the Angular port too). A ready column-toggle UI is not part of either
121
+ A ready column-toggle UI is not part of either
117
122
  port - `hiddenFields` is the hook to build one on.
118
123
 
119
124
  ## Filter by voice
@@ -50,9 +50,16 @@ export function KptDialog({ open, onOpenChange, header = '', size = 'md', dismis
50
50
  * `close` przychodzi z każdej drogi. Przy `dismissible=false` zamknąć wolno tylko z kodu:
51
51
  * Chromium z CloseWatcherem pozwala anulować `cancel` jedynie po aktywacji użytkownika, więc drugi
52
52
  * Escape z rzędu zamyka okno mimo `preventDefault` - wtedy otwieramy je z powrotem.
53
+ *
54
+ * Zdarzenie `close` jest asynchroniczne, więc może dojść, gdy okno jest już znowu otwarte: pod
55
+ * `StrictMode` efekt montuje się dwa razy (`showModal` -> sprzątanie `close` -> `showModal`)
56
+ * i dialog zamontowany od razu z `open` dostawał `onOpenChange(false)` od własnego sprzątania.
57
+ * Otwarty element znaczy, że to zamknięcie jest nieaktualne.
53
58
  */
54
59
  const onClose = () => {
55
60
  const el = dialogRef.current;
61
+ if (el?.open)
62
+ return;
56
63
  if (!dismissible && open && el && !el.open) {
57
64
  el.showModal();
58
65
  return;
@@ -42,6 +42,11 @@ export interface KptInputProps extends Omit<ComponentPropsWithRef<'input'>, 'typ
42
42
  * gdzie zastępuje systemowe "x" (ukryte zawsze, także przy `clearable={false}`).
43
43
  */
44
44
  clearable?: boolean;
45
+ /**
46
+ * Ikona z rejestru na początku pola (`search`, `mail`...). Dekoracyjna - nazwę pola daje etykieta
47
+ * albo `aria-label`, więc ikona nie trafia do drzewa dostępności.
48
+ */
49
+ icon?: string;
45
50
  }
46
51
  /**
47
52
  * Pole tekstowe Koncept UI. Renderuje natywny `<input>` w hoście `.kpt-input-host`.
@@ -57,4 +62,4 @@ export interface KptInputProps extends Omit<ComponentPropsWithRef<'input'>, 'typ
57
62
  * <KptInput name="email" type="email" defaultValue="" />
58
63
  * <KptInput value={email} onValueChange={setEmail} invalid={!ok} touched={blurred} />
59
64
  */
60
- export declare function KptInput({ type, required, invalid, touched, onValueChange, onTouch, hostProps, className, id, onChange, onBlur, ref, speech, speechLang, speechColor, speechMode, clearable, onKeyDown, locale, dictionary, 'aria-invalid': ariaInvalid, 'aria-describedby': ariaDescribedBy, ...rest }: Readonly<KptInputProps>): import("react").JSX.Element;
65
+ export declare function KptInput({ type, required, invalid, touched, onValueChange, onTouch, hostProps, className, id, onChange, onBlur, ref, speech, speechLang, speechColor, speechMode, clearable, icon, onKeyDown, locale, dictionary, 'aria-invalid': ariaInvalid, 'aria-describedby': ariaDescribedBy, ...rest }: Readonly<KptInputProps>): import("react").JSX.Element;
@@ -30,7 +30,7 @@ function setFieldValue(field, value) {
30
30
  * <KptInput name="email" type="email" defaultValue="" />
31
31
  * <KptInput value={email} onValueChange={setEmail} invalid={!ok} touched={blurred} />
32
32
  */
33
- export function KptInput({ type = 'text', required = false, invalid = false, touched, onValueChange, onTouch, hostProps, className, id, onChange, onBlur, ref, speech = false, speechLang, speechColor, speechMode = 'insert', clearable, onKeyDown, locale, dictionary, 'aria-invalid': ariaInvalid, 'aria-describedby': ariaDescribedBy, ...rest }) {
33
+ export function KptInput({ type = 'text', required = false, invalid = false, touched, onValueChange, onTouch, hostProps, className, id, onChange, onBlur, ref, speech = false, speechLang, speechColor, speechMode = 'insert', clearable, icon, onKeyDown, locale, dictionary, 'aria-invalid': ariaInvalid, 'aria-describedby': ariaDescribedBy, ...rest }) {
34
34
  const { className: hostClassName, ...hostRest } = hostProps ?? {};
35
35
  const field = useKptFieldA11y(id, 'kpt-input');
36
36
  const innerRef = useRef(null);
@@ -92,5 +92,5 @@ export function KptInput({ type = 'text', required = false, invalid = false, tou
92
92
  onBlur?.(event);
93
93
  onTouch?.();
94
94
  }, [onBlur, onTouch]);
95
- return (_jsxs("span", { className: cn('kpt-input-host', hostClassName), "data-speech": speechState, "data-clear": showClear ? '' : undefined, ...hostRest, children: [_jsx("input", { className: cn('kpt-input__field', className), id: field.id, "aria-describedby": joinIds(ariaDescribedBy, field.describedBy), type: type, "aria-required": required || undefined, "aria-invalid": ariaInvalid ?? (showInvalid || undefined), onChange: handleChange, onBlur: handleBlur, onKeyDown: handleKeyDown, ref: composeRefs(innerRef, ref), ...rest }), showClear ? (_jsx("button", { type: "button", className: "kpt-input__clear", tabIndex: -1, "aria-label": t('input.clear'), onMouseDown: (event) => event.preventDefault(), onClick: clear, children: _jsx(KptIcon, { name: "x" }) })) : null, speech ? (_jsx(KptSpeechButton, { listeningColor: speechColor, speech: mic, size: "sm", unsupported: "hide", disabled: !!rest.disabled || !!rest.readOnly, hostProps: { className: 'kpt-input__speech' } })) : null] }));
95
+ return (_jsxs("span", { className: cn('kpt-input-host', hostClassName), "data-speech": speechState, "data-clear": showClear ? '' : undefined, "data-icon": icon ? '' : undefined, ...hostRest, children: [icon ? _jsx(KptIcon, { className: "kpt-input__icon", name: icon }) : null, _jsx("input", { className: cn('kpt-input__field', className), id: field.id, "aria-describedby": joinIds(ariaDescribedBy, field.describedBy), type: type, "aria-required": required || undefined, "aria-invalid": ariaInvalid ?? (showInvalid || undefined), onChange: handleChange, onBlur: handleBlur, onKeyDown: handleKeyDown, ref: composeRefs(innerRef, ref), ...rest }), showClear ? (_jsx("button", { type: "button", className: "kpt-input__clear", tabIndex: -1, "aria-label": t('input.clear'), onMouseDown: (event) => event.preventDefault(), onClick: clear, children: _jsx(KptIcon, { name: "x" }) })) : null, speech ? (_jsx(KptSpeechButton, { listeningColor: speechColor, speech: mic, size: "sm", unsupported: "hide", disabled: !!rest.disabled || !!rest.readOnly, hostProps: { className: 'kpt-input__speech' } })) : null] }));
96
96
  }
@@ -16,6 +16,9 @@ Import: `import { KptInput } from '@konce-pt/react';`
16
16
  - `required`: boolean - see the note below
17
17
  - `invalid`: boolean, `touched`: boolean - the error state
18
18
  - `clearable`: boolean (default: `true` only for `type="search"`) - a clear button, see "Clear button"
19
+ - `icon`: string - a registry icon at the start of the field (`search`, `mail`...). Decorative: the field's
20
+ name comes from the label or the aria label, so the icon stays out of the accessibility tree; a click on it
21
+ lands in the field
19
22
  - `hostProps` - props for the `.kpt-input-host` wrapper
20
23
  - `className`, `ref`, `name`, `placeholder`, `autoComplete` and every other `<input>` prop go to
21
24
  the inner `<input>`
@@ -66,6 +69,7 @@ dictionary. The host gets `data-clear` while the button is visible.
66
69
 
67
70
  <KptInput type="search" value={query} onValueChange={setQuery} />
68
71
  <KptInput clearable value={coupon} onValueChange={setCoupon} />
72
+ <KptInput type="search" icon="search" value={query} onValueChange={setQuery} />
69
73
 
70
74
  ## Tokens
71
75
  `--kpt-form-field-{bg,text,placeholder,border,border-hover,border-focus,border-error,radius}`.
@@ -17,6 +17,9 @@ Import: `import { KptPopover } from '@konce-pt/react';`
17
17
  - `placement`: 'bottom' (default) | 'top' - the preferred side; it flips when there is no room
18
18
  - `maxWidth`: string - the panel's maximum width, any CSS length (`'24rem'`; default `20rem`). The panel is
19
19
  never wider than the window minus the 8 px overlay margin
20
+ - `ariaLabel`: string - the accessible name of the panel (`role="dialog"`); give one whenever the panel has
21
+ no visible heading. It names the panel, not the host - `aria-label` in the rest props would land on the
22
+ host `<span>`
20
23
  - `className`, `ref` and every other `<span>` prop go to the host
21
24
 
22
25
  ## The panel is a portal
@@ -74,7 +74,7 @@ export { KptDataTable } from './data-table/data-table.tsx';
74
74
  export type { KptDataTableProps } from './data-table/data-table.tsx';
75
75
  export { exportTableCsv } from './data-table/export-csv.ts';
76
76
  export type { KptCellEditContext, KptCellEditEvent, KptCellEditRenderers, KptCellRenderers, KptColumn, KptColumnEdit, KptColumnEditType, KptSortDir, KptTableLazyState, } from './data-table/data-table-types.ts';
77
- export type { KptColumnFilter, KptColumnFilterType, KptColumnSummary, KptSummaryFn } from '@konce-pt/table';
77
+ export type { KptColumnFilter, KptColumnFilterType, KptColumnGroup, KptColumnSummary, KptSummaryFn } from '@konce-pt/table';
78
78
  export { KptDataView } from './data-view/data-view.tsx';
79
79
  export type { KptDataViewLayout, KptDataViewProps } from './data-view/data-view.tsx';
80
80
  export { KptTree } from './tree/tree.tsx';
@@ -123,7 +123,7 @@ export type { KptOverlayProps } from './internal/overlay.tsx';
123
123
  export { KptButtonGroup } from './button-group/button-group.tsx';
124
124
  export type { KptButtonGroupProps } from './button-group/button-group.tsx';
125
125
  export { KptSelect } from './select/select.tsx';
126
- export type { KptSelectProps } from './select/select.tsx';
126
+ export type { KptSelectMultipleProps, KptSelectProps, KptSelectSingleProps } from './select/select.tsx';
127
127
  export type { KptAlertVariant, KptNavigateEvent, KptOption } from './shared/types.ts';
128
128
  export { KptFormField } from './form-field/form-field.tsx';
129
129
  export type { KptFormFieldLabelType, KptFormFieldProps } from './form-field/form-field.tsx';
@@ -15,7 +15,11 @@ A select on its own panel. Variants: single, with a search box (`filter`) and mu
15
15
 
16
16
  ## Props
17
17
  - `options`: `KptOption<T>[]` - `{ label, value, disabled? }`
18
- - `value` / `defaultValue` / `onValueChange` - a single `T`, or `T[]` in `multiple` mode
18
+ - `value` / `defaultValue` / `onValueChange` - a single `T`, or `T[]` in `multiple` mode. The handler type
19
+ follows `multiple` (overloads): without it `(value: T) => void` - a single select cannot clear its
20
+ choice, so it never reports an array or `null`; with `multiple` `(value: T[]) => void`. A flag computed at
21
+ run time (`multiple={flag}`) keeps the wide `T | T[] | null`. Types: `KptSelectSingleProps`,
22
+ `KptSelectMultipleProps`, `KptSelectProps` (the wide one)
19
23
  - `open` / `defaultOpen` / `onOpenChange` - the panel state, so the list can be opened from outside
20
24
  - `placeholder`, `multiple`, `filter`, `disabled`, `invalid`, `touched`
21
25
  - `onTouch()` - fires when the panel closes or focus leaves the closed trigger
@@ -1,4 +1,4 @@
1
- import { type ComponentPropsWithRef } from 'react';
1
+ import { type ComponentPropsWithRef, type JSX } from 'react';
2
2
  import { type KptI18nOverrides } from '../i18n/i18n-context.tsx';
3
3
  import type { KptOption } from '../shared/types.ts';
4
4
  export interface KptSelectProps<T> extends Omit<ComponentPropsWithRef<'div'>, 'children' | 'onChange' | 'defaultValue'>, KptI18nOverrides {
@@ -28,6 +28,23 @@ export interface KptSelectProps<T> extends Omit<ComponentPropsWithRef<'div'>, 'c
28
28
  /** Wywoływane, gdy panel się zamyka albo trigger traci fokus. */
29
29
  onTouch?: () => void;
30
30
  }
31
+ /**
32
+ * Pojedynczy wybór: `onValueChange` dostaje zawsze wybraną opcję - nigdy tablicę ani `null`, bo
33
+ * lista bez `multiple` nie ma jak wyczyścić wyboru. Rodzic trzyma więc zwykłe `T`.
34
+ */
35
+ export interface KptSelectSingleProps<T> extends Omit<KptSelectProps<T>, 'multiple' | 'value' | 'defaultValue' | 'onValueChange'> {
36
+ multiple?: false;
37
+ value?: T | null;
38
+ defaultValue?: T | null;
39
+ onValueChange?: (value: T) => void;
40
+ }
41
+ /** Wybór wielokrotny: wartością jest zawsze tablica (pusta, gdy nic nie wybrano). */
42
+ export interface KptSelectMultipleProps<T> extends Omit<KptSelectProps<T>, 'multiple' | 'value' | 'defaultValue' | 'onValueChange'> {
43
+ multiple: true;
44
+ value?: T[] | null;
45
+ defaultValue?: T[] | null;
46
+ onValueChange?: (value: T[]) => void;
47
+ }
31
48
  /**
32
49
  * Lista wyboru (select) na własnym panelu. Warianty: pojedynczy, z wyszukiwarką (`filter`)
33
50
  * i wielokrotny (`multiple`, chipy).
@@ -46,5 +63,10 @@ export interface KptSelectProps<T> extends Omit<ComponentPropsWithRef<'div'>, 'c
46
63
  * @example
47
64
  * <KptSelect options={roles} value={role} onValueChange={setRole} placeholder="Wybierz..." />
48
65
  * <KptSelect options={tags} multiple filter value={tags} onValueChange={setTags} />
66
+ *
67
+ * Typ `onValueChange` idzie za `multiple`: bez niego `(value: T)`, z `multiple` - `(value: T[])`.
68
+ * `multiple` liczone w locie (`multiple={flag}`) zostawia szeroki typ `T | T[] | null`.
49
69
  */
50
- export declare function KptSelect<T>({ options, placeholder, multiple, filter, disabled, readOnly, invalid, touched, value, defaultValue, onValueChange, open, defaultOpen, onOpenChange, onTouch, locale, dictionary, className, onBlur, id, required, 'aria-label': ariaLabel, 'aria-describedby': ariaDescribedBy, ...rest }: Readonly<KptSelectProps<T>>): import("react").JSX.Element;
70
+ export declare function KptSelect<T>(props: Readonly<KptSelectSingleProps<T>>): JSX.Element;
71
+ export declare function KptSelect<T>(props: Readonly<KptSelectMultipleProps<T>>): JSX.Element;
72
+ export declare function KptSelect<T>(props: Readonly<KptSelectProps<T>>): JSX.Element;
@@ -18,25 +18,6 @@ function filterSelectOptions(options, text) {
18
18
  const q = foldMentionText(text.trim());
19
19
  return q ? options.filter((option) => foldMentionText(option.label).includes(q)) : options;
20
20
  }
21
- /**
22
- * Lista wyboru (select) na własnym panelu. Warianty: pojedynczy, z wyszukiwarką (`filter`)
23
- * i wielokrotny (`multiple`, chipy).
24
- *
25
- * Klawiatura według wzorca WAI-ARIA "select-only combobox": fokus zostaje na polu, a aktywna opcja
26
- * idzie przez `aria-activedescendant`. `ArrowDown`/`ArrowUp`/`Enter`/`Spacja` otwierają listę na wybranej opcji,
27
- * strzałki, `Home`/`End`, `PageUp`/`PageDown` chodzą po opcjach (z pominięciem wyłączonych),
28
- * wpisywane litery skaczą do pasującej opcji (bez polskich znaków), `Enter` wybiera, `Escape`
29
- * zamyka. Z `filter` fokus przechodzi do pola wyszukiwania, które obsługuje te same klawisze.
30
- * W trybie `multiple` `Backspace` na zamkniętym polu usuwa ostatni wybór.
31
- *
32
- * Wewnątrz `KptFormField` zgłasza wypełnienie hookiem `useKptFormFieldFilled`, więc etykieta
33
- * Float Label unosi się nad wybraną wartością - natywnego pola, z którego wrapper mógłby to
34
- * odczytać, tu nie ma.
35
- *
36
- * @example
37
- * <KptSelect options={roles} value={role} onValueChange={setRole} placeholder="Wybierz..." />
38
- * <KptSelect options={tags} multiple filter value={tags} onValueChange={setTags} />
39
- */
40
21
  export function KptSelect({ options = [], placeholder = '', multiple = false, filter = false, disabled = false, readOnly = false, invalid = false, touched, value, defaultValue = null, onValueChange, open, defaultOpen = false, onOpenChange, onTouch, locale, dictionary, className, onBlur, id, required = false, 'aria-label': ariaLabel, 'aria-describedby': ariaDescribedBy, ...rest }) {
41
22
  /** `div` z rolą nie dostaje nazwy z `label[for]` - stąd `aria-labelledby` na etykietę wrappera. */
42
23
  const field = useKptFieldA11y(id, 'kpt-select-trigger');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@konce-pt/react",
3
- "version": "0.17.0",
3
+ "version": "0.18.1",
4
4
  "description": "Open-source React 19 component library on the Koncept UI design system - the same kpt-* CSS layer and --kpt-* tokens as the Angular port. Zero runtime dependencies.",
5
5
  "license": "MIT",
6
6
  "author": "konce.pt",
@@ -65,19 +65,19 @@
65
65
  "leaflet": "^1.9.0"
66
66
  },
67
67
  "dependencies": {
68
- "@konce-pt/chart": "0.17.0",
69
- "@konce-pt/chat": "0.17.0",
70
- "@konce-pt/backdrop": "0.17.0",
71
- "@konce-pt/mention": "0.17.0",
72
- "@konce-pt/motion": "0.17.0",
73
- "@konce-pt/roadmap": "0.17.0",
74
- "@konce-pt/icons": "0.17.0",
75
- "@konce-pt/datetime": "0.17.0",
76
- "@konce-pt/table": "0.17.0",
77
- "@konce-pt/speech": "0.17.0",
78
- "@konce-pt/i18n": "0.17.0",
79
- "@konce-pt/tokens": "0.17.0",
80
- "@konce-pt/map": "0.17.0"
68
+ "@konce-pt/backdrop": "0.18.1",
69
+ "@konce-pt/icons": "0.18.1",
70
+ "@konce-pt/chart": "0.18.1",
71
+ "@konce-pt/roadmap": "0.18.1",
72
+ "@konce-pt/mention": "0.18.1",
73
+ "@konce-pt/speech": "0.18.1",
74
+ "@konce-pt/motion": "0.18.1",
75
+ "@konce-pt/i18n": "0.18.1",
76
+ "@konce-pt/map": "0.18.1",
77
+ "@konce-pt/chat": "0.18.1",
78
+ "@konce-pt/tokens": "0.18.1",
79
+ "@konce-pt/datetime": "0.18.1",
80
+ "@konce-pt/table": "0.18.1"
81
81
  },
82
82
  "devDependencies": {
83
83
  "@types/react": "^19.2.0",