@konce-pt/react 0.16.1 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -77,14 +77,14 @@ import '@konce-pt/grid'; // optional - only if you use the layout c
77
77
  bundle, which is why both ports render identically. The Angular package injects those styles at
78
78
  runtime; React has no other source for them, so this import is not optional.
79
79
 
80
- Dark theme: set `<html data-theme="dark">` (or rely on `prefers-color-scheme`).
80
+ Dark theme: set `<html data-theme="dark">`. It is not switched automatically - toggle the attribute yourself, e.g. from `matchMedia('(prefers-color-scheme: dark)')`.
81
81
 
82
82
  **Next.js App Router / Server Components:** every interactive component module starts with
83
83
  `'use client'`, so you can import components directly into a server component - no wrapper file.
84
84
 
85
85
  > **No CDK here.** Components with a floating panel (`KptSelect`, `KptPopover`, `KptMenu`,
86
- > `KptAutocomplete`, `KptDatepicker`...) portal into `document.body` through the internal
87
- > `KptOverlay`, so `@angular/cdk/overlay-prebuilt.css` is **not** needed - positioning lives in the
86
+ > `KptAutocomplete`, `KptDatepicker`...) portal into `document.body` through `KptOverlay`
87
+ > (also exported for your own floating panels), so `@angular/cdk/overlay-prebuilt.css` is **not** needed - positioning lives in the
88
88
  > component, the look in `@konce-pt/styles/components`. Inside an open `KptDialog` those panels
89
89
  > portal into the `<dialog>` instead, because `showModal()` moves it to the browser top layer.
90
90
 
@@ -120,6 +120,7 @@ export function Login() {
120
120
  onValueChange={setEmail}
121
121
  onTouch={() => setTouched((t) => ({ ...t, email: true }))}
122
122
  invalid={Boolean(emailError)}
123
+ touched={touched.email}
123
124
  />
124
125
  </KptFormField>
125
126
 
@@ -131,6 +132,7 @@ export function Login() {
131
132
  onValueChange={setPassword}
132
133
  onTouch={() => setTouched((t) => ({ ...t, password: true }))}
133
134
  invalid={Boolean(passwordError)}
135
+ touched={touched.password}
134
136
  />
135
137
  </KptFormField>
136
138
 
@@ -141,7 +143,8 @@ export function Login() {
141
143
  ```
142
144
 
143
145
  `KptFormField` generates the control id and publishes it through context, so `label[for]` and
144
- `input[id]` match with no configuration. Drop `value`/`onValueChange` and the field becomes
146
+ `input[id]` match with no configuration. `touched` gates the invalid state (`aria-invalid`, red border)
147
+ until the field is blurred; leave it out and `invalid` shows right away. Drop `value`/`onValueChange` and the field becomes
145
148
  uncontrolled - the value lives in the DOM, and a native `<form action={...}>` or React Hook Form's
146
149
  `register()` picks it up as-is.
147
150
 
@@ -166,13 +169,13 @@ Data table with sorting, filtering, pagination and a custom cell:
166
169
 
167
170
  | Category | Components |
168
171
  | --- | --- |
169
- | **Forms** | `KptInput`, `KptTextarea`, `KptSelect`, `KptAutocomplete`, `KptCheckbox`, `KptSwitch`, `KptSwitchGroup`, `KptRadioGroup`, `KptSlider`, `KptRating`, `KptDatepicker`, `KptDateRange`, `KptClock`, `KptColorPicker`, `KptFileUpload`, `KptInputNumber`, `KptPassword`, `KptInputOtp`, `KptChipsInput`, `KptInputMask`, `KptListbox`, `KptKnob`, `KptRichText`, `KptFormField` |
172
+ | **Forms** | `KptInput`, `KptTextarea`, `KptSelect`, `KptAutocomplete`, `KptCheckbox`, `KptSwitch`, `KptSwitchGroup`, `KptRadioGroup`, `KptSlider`, `KptRating`, `KptDatepicker`, `KptDateRange`, `KptClock`, `KptColorPicker`, `KptFileUpload`, `KptInputNumber`, `KptPassword`, `KptInputOtp`, `KptChipsInput`, `KptInputMask`, `KptListbox`, `KptKnob`, `KptRichText`, `KptFormField`, `KptSpeechButton` + `KptSpeechWave` (dictation) |
170
173
  | **Buttons & actions** | `KptButton`, `KptIconButton`, `KptButtonGroup`, `KptFab`, `KptSplitButton`, `KptSpeedDial` |
171
- | **Layout** | `KptAppShell`, `KptToolbar`, `KptSidenav`, `KptCard` (+ `Header`, `Footer`, `Media`, `Title`...), `KptDivider`, `KptPanel`, `KptFieldset`, `KptSplitter`, `KptScrollTop` |
174
+ | **Layout** | `KptAppShell`, `KptToolbar`, `KptSidenav`, `KptCard` (+ `Header`, `Footer`, `Media`, `Title`...), `KptDivider`, `KptPanel`, `KptFieldset`, `KptSplitter`, `KptScrollTop`, `KptBackdrop` |
172
175
  | **Navigation** | `KptTabs`, `KptAccordion`, `KptBreadcrumb`, `KptStepper`, `KptMenu`, `KptMenubar`, `KptMegamenu`, `KptContextMenu` |
173
176
  | **Data** | `KptDataTable`, `KptPaginator`, `KptTree`, `KptTimeline`, `KptCarousel`, `KptDataView`, `KptPickList`, `KptOrderList`, `KptGalleria`, `KptMeterGroup` |
174
- | **Feedback & overlay** | `KptAlert`, `KptDialog`, `kptToast` + `KptToastContainer`, `KptTooltip`, `KptPopover`, `KptDrawer`, `KptBottomSheet`, `kptConfirm`, `KptBadge`, `KptChip`, `KptAvatar`, `KptAvatarGroup`, `KptSpinner`, `KptProgress`, `KptSkeleton`, `KptEmpty`, `KptImage`, `KptAuth` |
175
- | **Subpaths** | `@konce-pt/react/charts` (`KptChart`), `/chat` (`KptChat`, `KptChatMessage`, `KptChatTyping`, `KptChatComposer`, `KptChatDock`), `/roadmap` (`KptRoadmap`), `/grid` (`KptGrid`, `KptCol`, `KptFlex`), `/map` (`KptMap` + legend, POI card, search), `/icons` (full Tabler set) |
177
+ | **Feedback & overlay** | `KptAlert`, `KptDialog`, `kptToast` + `KptToastContainer`, `KptTooltip`, `KptPopover`, `KptPeek`, `KptDrawer`, `KptBottomSheet`, `kptConfirm`, `KptBadge`, `KptChip`, `KptAvatar`, `KptAvatarGroup`, `KptIcon`, `KptSpinner`, `KptLoader`, `KptUpdate`, `KptProgress`, `KptSkeleton`, `KptEmpty`, `KptImage`, `KptAuth`, `KptMention`, `KptMentions` |
178
+ | **Subpaths** | `@konce-pt/react/charts` (`KptChart`), `/chat` (`KptChat`, `KptChatMessage`, `KptChatTyping`, `KptChatComposer`, `KptChatDock`), `/roadmap` (`KptRoadmap`), `/grid` (`KptGrid`, `KptCol`, `KptFlex`), `/map` (`KptMap` + `KptMapLegend`, `KptMapPoiCard`, `KptMapSearch`, `KptMapCreateDialog`, `KptMapHoursField`, `KptMapImageField`), `/icons` (full Tabler set) |
176
179
 
177
180
  The heavy pieces (charts, chat, roadmap, maps, the full icon set) live in subpaths on purpose, so the core
178
181
  entry point stays small. Every component ships an `llms.txt` API sheet next to its source.
@@ -199,7 +202,7 @@ classes - that is what the SCSS reads.
199
202
 
200
203
  ## Icons
201
204
 
202
- **84 icons are built in and need no configuration** - what the components themselves draw plus the
205
+ **98 icons are built in and need no configuration** - what the components themselves draw plus the
203
206
  staples of an application shell (`sun`, `moon`, `bell`, `settings`, `users`, `logout`,
204
207
  `layout-dashboard`, `home`, `activity`, `chart-bar`, `brand-github` ...). A typical admin panel needs
205
208
  nothing else.
@@ -286,12 +289,12 @@ export function App() {
286
289
  // <KptMotionProvider enabled={false}> - no motion at all (visual tests, kiosk)
287
290
  ```
288
291
 
289
- | Key | Values | Components (per-component input) |
292
+ | Key | Values | Components (per-component prop) |
290
293
  | --- | --- | --- |
291
294
  | `enabled` | `true` \| `false` | All. `false` also zeroes CSS durations (`data-kpt-motion="off"`, `--kpt-motion-scale: 0` on `<html>`); loading indicators slow down instead of stopping |
292
295
  | `layout` | `spring` \| `arc` \| `morph` \| `none` | DataView, PickList, OrderList (`layoutMotion`) |
293
296
  | `collapse` | `spring` \| `fade` \| `none` | Accordion, Fieldset, Panel, Tree (`collapseMotion`) |
294
- | `enter` | `spring` \| `fade` \| `none` | Chat, ChatMessage, ChatDock, FormField (`enterMotion`); Password meter, ChipsInput and Select chips |
297
+ | `enter` | `spring` \| `fade` \| `none` | Chat, ChatMessage, ChatDock, FormField (`enterMotion`); Password meter, ChipsInput and Select chips, Stepper panels, the Image preview, the Galleria slide |
295
298
  | `toggle` | `spring` \| `fade` \| `none` | Checkbox, RadioGroup, Switch (`toggleMotion`); Rating stars, ButtonGroup selection |
296
299
 
297
300
  Precedence: `enabled: false` always wins, then the component's own prop, then the provider value,
@@ -307,7 +310,7 @@ are available for your own views in [`@konce-pt/motion`](https://www.npmjs.com/p
307
310
 
308
311
  ## Documentation
309
312
 
310
- - **Playground** - live demos with copyable code: [ui.konce.pt/react](https://ui.konce.pt/react).
313
+ - **Playground** - live demos with copyable code: [ui.konce.pt/react](https://ui.konce.pt/react/).
311
314
  - **`llms.txt`** - LLM-friendly API sheets per component, shipped inside the package; the
312
315
  package-wide sheet lives at [ui.konce.pt/llms/react/llms.txt](https://ui.konce.pt/llms/react/llms.txt)
313
316
  (PL mirror: [llms-pl.txt](https://ui.konce.pt/llms/react/llms-pl.txt)).
@@ -371,20 +374,21 @@ import '@konce-pt/grid'; // opcjonalnie - klasy układu
371
374
  własnego bundla - stąd identyczny wygląd obu portów. Angular wstrzykuje te style w runtime, React
372
375
  nie ma dla nich innego źródła, więc ten import nie jest opcjonalny.
373
376
 
374
- Motyw ciemny: `<html data-theme="dark">` (albo `prefers-color-scheme`). Nadpisania `--kpt-color-*`
377
+ Motyw ciemny: `<html data-theme="dark">` - nie włącza się sam, atrybut przełączasz samodzielnie
378
+ (np. z `matchMedia('(prefers-color-scheme: dark)')`). Nadpisania `--kpt-color-*`
375
379
  w arkuszu importowanym **po** `@konce-pt/styles`.
376
380
 
377
381
  **Next.js App Router / Server Components:** każdy moduł interaktywnego komponentu zaczyna się od
378
382
  `'use client'`, więc komponenty importujesz wprost do komponentu serwerowego - bez pliku-opakowania.
379
383
 
380
384
  > **CDK nie jest tu potrzebny.** Komponenty z panelem (`KptSelect`, `KptPopover`, `KptMenu`,
381
- > `KptAutocomplete`, `KptDatepicker`...) renderują go przez wewnętrzny `KptOverlay` do portalu na
382
- > `document.body`, więc `@angular/cdk/overlay-prebuilt.css` nie jest wymagany. W otwartym
385
+ > `KptAutocomplete`, `KptDatepicker`...) renderują go przez `KptOverlay` (eksportowany także dla
386
+ > własnych paneli) do portalu na `document.body`, więc `@angular/cdk/overlay-prebuilt.css` nie jest wymagany. W otwartym
383
387
  > `KptDialog` panel trafia do `<dialog>`, bo `showModal()` przenosi go do górnej warstwy przeglądarki.
384
388
 
385
389
  ### Ikony
386
390
 
387
- **84 ikony są wbudowane i nie wymagają konfiguracji** - to, co rysują same komponenty, plus staple
391
+ **98 ikon jest wbudowanych i nie wymaga konfiguracji** - to, co rysują same komponenty, plus staple
388
392
  powłoki aplikacji (`sun`, `moon`, `bell`, `settings`, `users`, `logout`, `layout-dashboard`,
389
393
  `home`, `activity`, `chart-bar`, `brand-github`...). Typowy panel administracyjny nie potrzebuje nic
390
394
  więcej.
@@ -441,12 +445,12 @@ nakładki w portalach też go słuchają.
441
445
  <KptMotionProvider enabled={false}>...</KptMotionProvider> {/* bez ruchu (testy wizualne, kiosk) */}
442
446
  ```
443
447
 
444
- | Klucz | Wartości | Komponenty (input per komponent) |
448
+ | Klucz | Wartości | Komponenty (prop per komponent) |
445
449
  | --- | --- | --- |
446
450
  | `enabled` | `true` \| `false` | Wszystkie. `false` zeruje też czasy w CSS (`data-kpt-motion="off"`, `--kpt-motion-scale: 0` na `<html>`); wskaźniki ładowania zwalniają zamiast stanąć |
447
451
  | `layout` | `spring` \| `arc` \| `morph` \| `none` | DataView, PickList, OrderList (`layoutMotion`) |
448
452
  | `collapse` | `spring` \| `fade` \| `none` | Accordion, Fieldset, Panel, Tree (`collapseMotion`) |
449
- | `enter` | `spring` \| `fade` \| `none` | Chat, ChatMessage, ChatDock, FormField (`enterMotion`); miernik hasła, chipy ChipsInput i Select |
453
+ | `enter` | `spring` \| `fade` \| `none` | Chat, ChatMessage, ChatDock, FormField (`enterMotion`); miernik hasła, chipy ChipsInput i Select, panele Steppera, podgląd Image, slajd Galleria |
450
454
  | `toggle` | `spring` \| `fade` \| `none` | Checkbox, RadioGroup, Switch (`toggleMotion`); gwiazdki Rating, wybór ButtonGroup |
451
455
 
452
456
  Pierwszeństwo: `enabled: false` wygrywa zawsze, potem prop samego komponentu, potem wartość z providera,
@@ -463,7 +467,7 @@ Każdy komponent ma obok źródła plik `llms.txt` z opisem API.
463
467
 
464
468
  ### Dokumentacja
465
469
 
466
- Playground z demami: [ui.konce.pt/react](https://ui.konce.pt/react). Opisy API dla modeli:
470
+ Playground z demami: [ui.konce.pt/react](https://ui.konce.pt/react/). Opisy API dla modeli:
467
471
  [`llms.txt`](https://ui.konce.pt/llms/react/llms.txt) (EN) i
468
472
  [`llms-pl.txt`](https://ui.konce.pt/llms/react/llms-pl.txt) (PL). Repozytorium:
469
473
  [gitlab.com/konce-pt/koncept-ui](https://gitlab.com/konce-pt/koncept-ui).
@@ -9,6 +9,12 @@ export interface KptCarouselProps<T> extends Omit<ComponentPropsWithRef<'div'>,
9
9
  renderSlide: (item: T, index: number) => ReactNode;
10
10
  /** Nazwa regionu dla czytników ekranu, np. "Nowości"; domyślnie `carousel.label`. */
11
11
  label?: string;
12
+ /**
13
+ * Zawijanie strzałek: za ostatnim slajdem "dalej" wraca do pierwszego, przed pierwszym "wstecz" -
14
+ * do ostatniego. `false` - strzałki stają na końcach (np. kroki, które mają początek i koniec).
15
+ * Domyślnie `true`.
16
+ */
17
+ loop?: boolean;
12
18
  }
13
19
  /**
14
20
  * Karuzela slajdów. Dane przez `items`, wygląd slajdu przez `renderSlide`.
@@ -19,9 +25,11 @@ export interface KptCarouselProps<T> extends Omit<ComponentPropsWithRef<'div'>,
19
25
  * Semantyka według wzorca WAI-ARIA "carousel" (bez autoodtwarzania): region z
20
26
  * `aria-roledescription="karuzela"`, każdy slajd to grupa "Slajd 2 z 4", a slajdy poza widokiem są
21
27
  * `inert` - `Tab` i czytnik ich nie widzą. Zmiana slajdu jest ogłaszana uprzejmie (`aria-live`
22
- * na torze). Strzałki na końcach dostają `aria-disabled` zamiast `disabled`, żeby nie zgubić fokusu.
28
+ * na torze). Domyślnie strzałki zawijają: za ostatnim slajdem "dalej" wraca do pierwszego, przed
29
+ * pierwszym "wstecz" - do ostatniego. Z `loop={false}` strzałki stają na końcach i dostają
30
+ * `aria-disabled` zamiast `disabled`, żeby nie zgubić fokusu.
23
31
  *
24
32
  * @example
25
33
  * <KptCarousel items={photos} renderSlide={(photo) => <img src={photo} style={{ width: '100%' }} />} />
26
34
  */
27
- export declare function KptCarousel<T>({ items, activeIndex, defaultActiveIndex, onActiveIndexChange, renderSlide, label, locale, dictionary, className, ...rest }: Readonly<KptCarouselProps<T>>): import("react").JSX.Element;
35
+ export declare function KptCarousel<T>({ items, activeIndex, defaultActiveIndex, onActiveIndexChange, renderSlide, label, loop, locale, dictionary, className, ...rest }: Readonly<KptCarouselProps<T>>): import("react").JSX.Element;
@@ -13,12 +13,14 @@ import { KptIcon } from "../icon/icon.js";
13
13
  * Semantyka według wzorca WAI-ARIA "carousel" (bez autoodtwarzania): region z
14
14
  * `aria-roledescription="karuzela"`, każdy slajd to grupa "Slajd 2 z 4", a slajdy poza widokiem są
15
15
  * `inert` - `Tab` i czytnik ich nie widzą. Zmiana slajdu jest ogłaszana uprzejmie (`aria-live`
16
- * na torze). Strzałki na końcach dostają `aria-disabled` zamiast `disabled`, żeby nie zgubić fokusu.
16
+ * na torze). Domyślnie strzałki zawijają: za ostatnim slajdem "dalej" wraca do pierwszego, przed
17
+ * pierwszym "wstecz" - do ostatniego. Z `loop={false}` strzałki stają na końcach i dostają
18
+ * `aria-disabled` zamiast `disabled`, żeby nie zgubić fokusu.
17
19
  *
18
20
  * @example
19
21
  * <KptCarousel items={photos} renderSlide={(photo) => <img src={photo} style={{ width: '100%' }} />} />
20
22
  */
21
- export function KptCarousel({ items = [], activeIndex, defaultActiveIndex = 0, onActiveIndexChange, renderSlide, label, locale, dictionary, className, ...rest }) {
23
+ export function KptCarousel({ items = [], activeIndex, defaultActiveIndex = 0, onActiveIndexChange, renderSlide, label, loop = true, locale, dictionary, className, ...rest }) {
22
24
  const { t } = useKptMessages({ locale, dictionary });
23
25
  const [requested, setActive] = useControllableState({
24
26
  value: activeIndex,
@@ -28,7 +30,21 @@ export function KptCarousel({ items = [], activeIndex, defaultActiveIndex = 0, o
28
30
  // Indeks przycięty do listy: po skróceniu `items` (filtr) widok nie zostaje pusty.
29
31
  const active = Math.min(Math.max(requested, 0), Math.max(items.length - 1, 0));
30
32
  const goTo = (index) => setActive(Math.min(Math.max(index, 0), items.length - 1));
31
- const atStart = active <= 0;
32
- const atEnd = active >= items.length - 1;
33
- return (_jsx("div", { className: cn('kpt-carousel-host', className), ...rest, children: _jsxs("div", { className: "kpt-carousel", role: "region", "aria-roledescription": t('carousel.roleCarousel'), "aria-label": label || t('carousel.label'), children: [_jsx("div", { className: "kpt-carousel__viewport", children: _jsx("div", { className: "kpt-carousel__track", "aria-live": "polite", style: { transform: `translateX(calc(var(--_kpt-carousel-dir, 1) * -${active * 100}%))` }, children: items.map((item, index) => (_jsx("div", { className: "kpt-carousel__slide", role: "group", "aria-roledescription": t('carousel.roleSlide'), "aria-label": t('carousel.slideOf', { index: index + 1, total: items.length }), inert: index !== active, "aria-hidden": index === active ? undefined : true, children: renderSlide(item, index) }, index))) }) }), _jsx("button", { type: "button", className: "kpt-carousel__nav kpt-carousel__nav--prev", "aria-disabled": atStart || undefined, "aria-label": t('common.previous'), onClick: () => goTo(active - 1), children: _jsx(KptIcon, { name: "chevron-left" }) }), _jsx("button", { type: "button", className: "kpt-carousel__nav kpt-carousel__nav--next", "aria-disabled": atEnd || undefined, "aria-label": t('common.next'), onClick: () => goTo(active + 1), children: _jsx(KptIcon, { name: "chevron-right" }) }), _jsx("div", { className: "kpt-carousel__dots", children: items.map((_, index) => (_jsx("button", { type: "button", className: cn('kpt-carousel__dot', index === active && 'is-active'), "aria-current": index === active ? 'true' : undefined, "aria-label": t('carousel.slide', { index: index + 1 }), onClick: () => goTo(index) }, index))) })] }) }));
33
+ // Zawijanie ma sens od dwóch slajdów - przy jednym strzałki zostają wyłączone.
34
+ const wraps = loop && items.length > 1;
35
+ // Krok o jeden slajd; poza końcem - zawinięcie albo zatrzymanie, zależnie od `loop`.
36
+ const step = (delta) => {
37
+ const count = items.length;
38
+ if (count === 0)
39
+ return;
40
+ const target = active + delta;
41
+ if (target >= 0 && target < count)
42
+ setActive(target);
43
+ else if (wraps)
44
+ setActive((target + count) % count);
45
+ };
46
+ // Koniec, na którym strzałka staje - tylko bez zawijania.
47
+ const atStart = !wraps && active <= 0;
48
+ const atEnd = !wraps && active >= items.length - 1;
49
+ return (_jsx("div", { className: cn('kpt-carousel-host', className), ...rest, children: _jsxs("div", { className: "kpt-carousel", role: "region", "aria-roledescription": t('carousel.roleCarousel'), "aria-label": label || t('carousel.label'), children: [_jsx("div", { className: "kpt-carousel__viewport", children: _jsx("div", { className: "kpt-carousel__track", "aria-live": "polite", style: { transform: `translateX(calc(var(--_kpt-carousel-dir, 1) * -${active * 100}%))` }, children: items.map((item, index) => (_jsx("div", { className: "kpt-carousel__slide", role: "group", "aria-roledescription": t('carousel.roleSlide'), "aria-label": t('carousel.slideOf', { index: index + 1, total: items.length }), inert: index !== active, "aria-hidden": index === active ? undefined : true, children: renderSlide(item, index) }, index))) }) }), _jsx("button", { type: "button", className: "kpt-carousel__nav kpt-carousel__nav--prev", "aria-disabled": atStart || undefined, "aria-label": t('common.previous'), onClick: () => step(-1), children: _jsx(KptIcon, { name: "chevron-left" }) }), _jsx("button", { type: "button", className: "kpt-carousel__nav kpt-carousel__nav--next", "aria-disabled": atEnd || undefined, "aria-label": t('common.next'), onClick: () => step(1), children: _jsx(KptIcon, { name: "chevron-right" }) }), _jsx("div", { className: "kpt-carousel__dots", children: items.map((_, index) => (_jsx("button", { type: "button", className: cn('kpt-carousel__dot', index === active && 'is-active'), "aria-current": index === active ? 'true' : undefined, "aria-label": t('carousel.slide', { index: index + 1 }), onClick: () => goTo(index) }, index))) })] }) }));
34
50
  }
@@ -28,6 +28,7 @@ Import: `import { KptCarousel } from '@konce-pt/react';`
28
28
  - `activeIndex` / `defaultActiveIndex` / `onActiveIndexChange`
29
29
  - `renderSlide(item, index)` - required
30
30
  - `label`: string - the region name; empty = `carousel.label`
31
+ - `loop`: boolean (default true) - the arrows wrap from the last slide to the first and back
31
32
  - `className`, `ref` and every other `<div>` prop go to the host
32
33
 
33
34
  ## The render prop replaces the template
@@ -49,13 +50,18 @@ The arrows use `common.previous` / `common.next`, the dots `carousel.slide` (`{i
49
50
  ## Tokens
50
51
  The active dot `--kpt-color-primary`; the arrows `--kpt-color-surface` (80% via `color-mix`, solid on hover) with `--kpt-elevation-2`; the slide transition `--kpt-motion-duration-base`.
51
52
 
53
+ ## Looping
54
+ By default the arrows wrap: "next" on the last slide goes to the first one and "previous" on the first
55
+ goes to the last; the track rewinds across the slides in between. `loop={false}` stops the arrows at the
56
+ ends instead - for steps that have a beginning and an end (onboarding). There is no autoplay either way.
57
+
52
58
  ## Accessibility
53
59
  The WAI-ARIA carousel pattern without autoplay. The root is `role="region"` with
54
60
  `aria-roledescription` from `carousel.roleCarousel` ("carousel") and a name from `label` or
55
61
  `carousel.label`. Each slide is `role="group"` with `aria-roledescription` from `carousel.roleSlide`
56
62
  and a name from `carousel.slideOf` ("Slide 2 of 4"); slides outside the view are `inert` and
57
63
  `aria-hidden`, so Tab never lands on an invisible link. The track is `aria-live="polite"`, so the
58
- slide that comes in is announced. The arrows use `aria-disabled` at the ends instead of `disabled`,
64
+ slide that comes in is announced. With `loop` off the arrows use `aria-disabled` at the ends instead of `disabled`,
59
65
  so they keep the focus; the active dot carries `aria-current`, and every dot has a 24 px target
60
66
  (WCAG 2.5.8) with an 8 px visible dot. The transition is off under `prefers-reduced-motion`; in RTL
61
67
  the track and the chevrons follow the reading direction (`:dir(rtl)`).
@@ -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, 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, bandPath, bandScale, barLayout, chartScale, chartAccessibleName, chartDataTable, dataScale, deltaKind, domainScale, 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";
@@ -106,8 +106,11 @@ export function KptChart({ config, width = 600, height = 360, locale, dictionary
106
106
  const to = config.series.map((s) => [...s.data]);
107
107
  const withAxis = config.type === 'line' || config.type === 'bar';
108
108
  const visible = (rows) => rows.filter((_, i) => !hidden.has(`ser:${config.series[i]?.name ?? ''}`));
109
+ const axis = { valueAxis: config.valueAxis, stacked: config.stacked };
110
+ // Skala trzymana na czas ducha obejmuje oba stany - z wierszy już złożonych w stos.
111
+ const held = [...scaleRows(visible(from), config.stacked), ...scaleRows(visible(to), config.stacked)];
109
112
  const scales = withAxis
110
- ? { start: dataScale(visible(from)), hold: dataScale([holdDomain(visible(from), visible(to))]), end: dataScale(visible(to)) }
113
+ ? { start: dataScale(visible(from), axis), hold: chartScale(held, { valueAxis: config.valueAxis }), end: dataScale(visible(to), axis) }
111
114
  : null;
112
115
  motion.transition(from, to, { delta: withAxis && config.animation?.delta !== false, scales });
113
116
  }, [config, hidden, motion, motionConfig]);
@@ -204,16 +207,19 @@ export function KptChart({ config, width = 600, height = 360, locale, dictionary
204
207
  if (view.type !== 'line')
205
208
  return null;
206
209
  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]);
210
+ const rows = active.map(({ s }) => s.data);
209
211
  const scale = frame.domain
210
212
  ? domainScale(frame.domain.target, frame.domain.min, frame.domain.max)
211
- : niceScale(Math.min(0, mn), Math.max(0, mx), 5);
213
+ : chartScale(rows, { valueAxis: view.valueAxis, stacked: view.stacked });
214
+ // Stos: punkty serii na narastającej sumie, pasmo od linii serii pod nią.
215
+ const stack = view.stacked ? stackSeries(rows) : null;
212
216
  const plot = plotArea(width, height, VERTICAL_INSETS);
213
217
  const yScale = linearScale(scale.min, scale.max, plot.y + plot.height, plot.y);
214
218
  const count = view.labels.length;
215
219
  const xAt = (i) => count > 1 ? plot.x + (i / (count - 1)) * plot.width : plot.x + plot.width / 2;
216
- const baseline = yScale(0);
220
+ // Punkt nie wychodzi poza oś - przypięte `valueAxis.min`/`max` mogą uciąć wartość.
221
+ const yAt = (v) => yScale(Math.min(scale.max, Math.max(scale.min, v)));
222
+ const baseline = yScale(axisBase(scale));
217
223
  const interpolation = view.interpolation ?? 'linear';
218
224
  const showPoints = view.points ?? true;
219
225
  const grid = scale.ticks.map((t) => ({
@@ -234,13 +240,21 @@ export function KptChart({ config, width = 600, height = 360, locale, dictionary
234
240
  text: label,
235
241
  anchor: 'middle',
236
242
  }));
237
- const series = active.map(({ s, i }) => ({
243
+ const series = active.map(({ s, i }, k) => ({
238
244
  key: `ser:${s.name}`,
239
245
  name: s.name,
240
246
  color: seriesColor(s.color, i),
241
247
  i,
242
- points: s.data.map((v, idx) => ({ x: xAt(idx), y: yScale(v), value: v, label: view.labels[idx] ?? '' })),
248
+ points: s.data.map((v, idx) => ({ x: xAt(idx), y: yAt(stack?.top[k]?.[idx] ?? v), value: v, label: view.labels[idx] ?? '' })),
249
+ floor: stack ? s.data.map((_, idx) => ({ x: xAt(idx), y: yAt(stack.base[k]?.[idx] ?? 0) })) : null,
243
250
  }));
251
+ // Duch w stosie stoi na sumach poprzedniego stanu, tak jak bieżące punkty.
252
+ const ghostRows = ghostState
253
+ ? (() => {
254
+ const prev = active.map(({ i }) => ghostState.prev[i] ?? []);
255
+ return view.stacked ? stackSeries(prev).top : prev;
256
+ })()
257
+ : [];
244
258
  const crosshairCat = config.crosshair && activeIndex !== null ? catLabels[activeIndex] : undefined;
245
259
  // Prowadnica i pigułka jadą razem z dymkiem.
246
260
  const crossX = crosshairCat ? (frame.tip?.x ?? crosshairCat.x) : 0;
@@ -268,10 +282,10 @@ export function KptChart({ config, width = 600, height = 360, locale, dictionary
268
282
  ? series.map((s) => {
269
283
  const prev = ghostState.prev[s.i] ?? [];
270
284
  const next = ghostState.next[s.i] ?? [];
271
- const gpts = prev.map((v, idx) => ({ x: xAt(idx), y: yScale(v) }));
285
+ const gpts = (ghostRows[series.indexOf(s)] ?? prev).map((v, idx) => ({ x: xAt(idx), y: yAt(v) }));
272
286
  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
287
  })
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
288
+ : 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
289
  ? 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
290
  : null] }, s.key))), sheen
277
291
  ? 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 +315,7 @@ export function KptChart({ config, width = 600, height = 360, locale, dictionary
301
315
  const active = view.series.map((s, i) => ({ s, i })).filter(({ s }) => !hidden.has(`ser:${s.name}`));
302
316
  const plot = plotArea(width, height, vertical ? VERTICAL_INSETS : HORIZONTAL_INSETS);
303
317
  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);
318
+ 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
319
  const colors = active.map(({ s, i }) => seriesColor(s.color, i));
306
320
  const names = active.map(({ s }) => s.name);
307
321
  const cat = vertical
@@ -335,7 +349,7 @@ export function KptChart({ config, width = 600, height = 360, locale, dictionary
335
349
  ? (() => {
336
350
  const prevRows = active.map(({ i }) => ghostState.prev[i] ?? []);
337
351
  const nextRows = active.map(({ i }) => ghostState.next[i] ?? []);
338
- return barLayout(prevRows, view.labels.length, plot, orientation, radius, layout.scale).bars.map((g, k) => {
352
+ return barLayout(prevRows, view.labels.length, plot, orientation, radius, layout.scale, { stacked: view.stacked }).bars.map((g, k) => {
339
353
  const cur = layout.bars[k] ?? g;
340
354
  const a = barEnd(g, vertical);
341
355
  const z = barEnd(cur, vertical);
@@ -19,9 +19,19 @@ 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
+
25
35
  ## The geometry lives in a package
26
36
  Scales, paths, bar layout and arcs come from `@konce-pt/chart` - pure functions with `node --test`
27
37
  coverage, shared with the Angular port. The component only turns them into SVG elements; there is
@@ -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;
@@ -21,6 +21,12 @@ export interface KptGalleriaProps extends Omit<ComponentPropsWithRef<'div'>, 'on
21
21
  * aktywną. Miniatura ma nazwę z `alt` obrazu i `aria-current` na aktywnej, a zmiana obrazu jest
22
22
  * ogłaszana uprzejmie ("Obraz 2 z 4: ...") - dopiero po działaniu użytkownika, nie przy starcie.
23
23
  *
24
+ * Ruch: nowe zdjęcie wjeżdża w kierunku zmiany na sprężynie, a poprzednie cofa się wolniej
25
+ * i przygasa (paralaksa); rodzaj z `enter` w `KptMotionProvider`, przy "ogranicz ruch" - przenikanie.
26
+ * Pasek miniatur nie ma paska przewijania: przewija się przeciąganiem (mysz, pióro, palec)
27
+ * albo strzałkami paska, krawędzie gasną tam, gdzie jest coś dalej, a aktywna miniatura
28
+ * zawsze wraca do widoku.
29
+ *
24
30
  * @example
25
31
  * <KptGalleria items={photos} activeIndex={index} onActiveIndexChange={setIndex} />
26
32
  */