@konce-pt/react 0.16.1 → 0.17.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)`).
@@ -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
  */
@@ -1,6 +1,8 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
- import { useRef, useState } from 'react';
3
+ import { useEffect, useLayoutEffect, useRef, useState } from 'react';
4
+ import { attachDragScroll, playGallerySlide, resolveEnterMotion, revealThumb, scrollStrip, stopGallerySlide, trackStripEdges, } from '@konce-pt/motion';
5
+ import { useKptMotion } from "../motion/motion-context.js";
4
6
  import { cn } from "../internal/cn.js";
5
7
  import { useControllableState } from "../internal/use-controllable-state.js";
6
8
  import { useKptMessages } from "../i18n/i18n-context.js";
@@ -15,6 +17,12 @@ const NAV_KEYS = new Set(['ArrowLeft', 'ArrowRight', 'Home', 'End']);
15
17
  * aktywną. Miniatura ma nazwę z `alt` obrazu i `aria-current` na aktywnej, a zmiana obrazu jest
16
18
  * ogłaszana uprzejmie ("Obraz 2 z 4: ...") - dopiero po działaniu użytkownika, nie przy starcie.
17
19
  *
20
+ * Ruch: nowe zdjęcie wjeżdża w kierunku zmiany na sprężynie, a poprzednie cofa się wolniej
21
+ * i przygasa (paralaksa); rodzaj z `enter` w `KptMotionProvider`, przy "ogranicz ruch" - przenikanie.
22
+ * Pasek miniatur nie ma paska przewijania: przewija się przeciąganiem (mysz, pióro, palec)
23
+ * albo strzałkami paska, krawędzie gasną tam, gdzie jest coś dalej, a aktywna miniatura
24
+ * zawsze wraca do widoku.
25
+ *
18
26
  * @example
19
27
  * <KptGalleria items={photos} activeIndex={index} onActiveIndexChange={setIndex} />
20
28
  */
@@ -31,8 +39,66 @@ export function KptGalleria({ items = [], activeIndex, defaultActiveIndex = 0, o
31
39
  const [userMoved, setUserMoved] = useState(false);
32
40
  const thumbsRef = useRef([]);
33
41
  const rootRef = useRef(null);
42
+ const stageRef = useRef(null);
43
+ const imageRef = useRef(null);
44
+ const stripRef = useRef(null);
45
+ const stripWrapRef = useRef(null);
46
+ const edgesRef = useRef(null);
47
+ /** Co było pokazane przy poprzednim renderze - z tego wynika kierunek i duch poprzedniego zdjęcia. */
48
+ const shownRef = useRef(null);
49
+ const motion = resolveEnterMotion(undefined, useKptMotion());
34
50
  const current = items[active] ?? null;
35
51
  const last = items.length - 1;
52
+ const currentSrc = current?.src ?? '';
53
+ // Zmiana zdjęcia: po wpisaniu nowego `src` do DOM, przed malowaniem. Pierwszy render i wymiana
54
+ // listy bez zmiany indeksu nie grają ruchu - przesuw ilustruje przejście, nie ładowanie.
55
+ useLayoutEffect(() => {
56
+ const prev = shownRef.current;
57
+ shownRef.current = { index: active, src: currentSrc };
58
+ const strip = stripRef.current;
59
+ const thumb = thumbsRef.current[active];
60
+ // Start na dalekim zdjęciu: pasek od razu pokazuje aktywną miniaturę, bez przewijania na oczach.
61
+ if (!prev) {
62
+ if (strip && thumb)
63
+ revealThumb(strip, thumb, false);
64
+ return;
65
+ }
66
+ if (prev.index === active)
67
+ return;
68
+ const stage = stageRef.current;
69
+ const image = imageRef.current;
70
+ if (stage && image && prev.src && prev.src !== currentSrc) {
71
+ playGallerySlide({ stage, image, previousSrc: prev.src, direction: active > prev.index ? 1 : -1, motion });
72
+ }
73
+ if (strip && thumb)
74
+ revealThumb(strip, thumb);
75
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- ruch czyta konfigurację z chwili zmiany; sama zmiana konfiguracji nie przesuwa zdjęcia
76
+ }, [active, currentSrc]);
77
+ // Pasek: krawędzie i przeciąganie - podpinane, gdy pasek istnieje, i odpinane razem z nim.
78
+ useEffect(() => {
79
+ const strip = stripRef.current;
80
+ const wrap = stripWrapRef.current;
81
+ if (!strip || !wrap)
82
+ return;
83
+ const edges = trackStripEdges(strip, wrap);
84
+ edgesRef.current = edges;
85
+ const detach = attachDragScroll(strip);
86
+ return () => {
87
+ edges.dispose();
88
+ detach();
89
+ edgesRef.current = null;
90
+ };
91
+ }, [showThumbnails]);
92
+ // Nowa lista miniatur zmienia szerokość paska bez zmiany jego rozmiaru - krawędzie liczymy na nowo.
93
+ useEffect(() => edgesRef.current?.update(), [items]);
94
+ useEffect(() => {
95
+ const stage = stageRef.current;
96
+ const image = imageRef;
97
+ return () => {
98
+ if (stage)
99
+ stopGallerySlide(stage, image.current);
100
+ };
101
+ }, []);
36
102
  const select = (index) => {
37
103
  if (last < 0)
38
104
  return active;
@@ -57,7 +123,7 @@ export function KptGalleria({ items = [], activeIndex, defaultActiveIndex = 0, o
57
123
  };
58
124
  const position = t('galleria.position', { index: active + 1, total: items.length });
59
125
  const announcement = userMoved ? (current?.alt ? `${position}: ${current.alt}` : position) : '';
60
- return (_jsx("div", { className: cn('kpt-galleria-host', className), ...rest, children: _jsxs("div", { ref: rootRef, className: "kpt-galleria", onKeyDown: onKeyDown, children: [_jsxs("div", { className: "kpt-galleria__stage", children: [_jsx("button", { type: "button", className: "kpt-galleria__nav kpt-galleria__nav--prev", "aria-disabled": active <= 0 || undefined, "aria-label": t('common.previous'), onClick: () => select(active - 1), children: _jsx(KptIcon, { name: "chevron-left" }) }), current ? _jsx("img", { className: "kpt-galleria__image", src: current.src, alt: current.alt || '' }) : null, _jsx("button", { type: "button", className: "kpt-galleria__nav kpt-galleria__nav--next", "aria-disabled": active >= last || undefined, "aria-label": t('common.next'), onClick: () => select(active + 1), children: _jsx(KptIcon, { name: "chevron-right" }) })] }), showThumbnails ? (_jsx("div", { className: "kpt-galleria__thumbs", children: items.map((item, index) => (_jsx("button", { ref: (el) => {
61
- thumbsRef.current[index] = el;
62
- }, type: "button", className: cn('kpt-galleria__thumb', index === active && 'is-active'), "aria-current": index === active ? 'true' : undefined, "aria-label": item.alt || t('galleria.image', { index: index + 1 }), onClick: () => select(index), children: _jsx("img", { src: item.thumbnail || item.src, alt: "" }) }, index))) })) : null, _jsx("span", { className: "kpt-galleria__live", "aria-live": "polite", children: announcement })] }) }));
126
+ return (_jsx("div", { className: cn('kpt-galleria-host', className), ...rest, children: _jsxs("div", { ref: rootRef, className: "kpt-galleria", onKeyDown: onKeyDown, children: [_jsxs("div", { ref: stageRef, className: "kpt-galleria__stage", children: [_jsx("button", { type: "button", className: "kpt-galleria__nav kpt-galleria__nav--prev", "aria-disabled": active <= 0 || undefined, "aria-label": t('common.previous'), onClick: () => select(active - 1), children: _jsx(KptIcon, { name: "chevron-left" }) }), current ? _jsx("img", { ref: imageRef, className: "kpt-galleria__image", src: current.src, alt: current.alt || '' }) : null, _jsx("button", { type: "button", className: "kpt-galleria__nav kpt-galleria__nav--next", "aria-disabled": active >= last || undefined, "aria-label": t('common.next'), onClick: () => select(active + 1), children: _jsx(KptIcon, { name: "chevron-right" }) })] }), showThumbnails ? (_jsxs("div", { ref: stripWrapRef, className: "kpt-galleria__strip", children: [_jsx("button", { type: "button", className: "kpt-galleria__scroll kpt-galleria__scroll--back", tabIndex: -1, "aria-label": t('galleria.scrollBack'), onClick: () => stripRef.current && scrollStrip(stripRef.current, -1), children: _jsx(KptIcon, { name: "chevron-left" }) }), _jsx("div", { ref: stripRef, className: "kpt-galleria__thumbs", children: items.map((item, index) => (_jsx("button", { ref: (el) => {
127
+ thumbsRef.current[index] = el;
128
+ }, type: "button", className: cn('kpt-galleria__thumb', index === active && 'is-active'), "aria-current": index === active ? 'true' : undefined, "aria-label": item.alt || t('galleria.image', { index: index + 1 }), onClick: () => select(index), children: _jsx("img", { src: item.thumbnail || item.src, alt: "", draggable: false }) }, index))) }), _jsx("button", { type: "button", className: "kpt-galleria__scroll kpt-galleria__scroll--forward", tabIndex: -1, "aria-label": t('galleria.scrollForward'), onClick: () => stripRef.current && scrollStrip(stripRef.current, 1), children: _jsx(KptIcon, { name: "chevron-right" }) })] })) : null, _jsx("span", { className: "kpt-galleria__live", "aria-live": "polite", children: announcement })] }) }));
63
129
  }
@@ -11,8 +11,12 @@ Import: `import { KptGalleria, type KptGalleriaItem } from '@konce-pt/react';`
11
11
  <img class="kpt-galleria__image" src="..." alt="Mountain lake">
12
12
  <button class="kpt-galleria__nav kpt-galleria__nav--next" aria-label="Next">...</button>
13
13
  </div>
14
- <div class="kpt-galleria__thumbs">
15
- <button class="kpt-galleria__thumb is-active" aria-current="true" aria-label="Mountain lake"><img src="..." alt=""></button>...
14
+ <div class="kpt-galleria__strip" data-scroll-end>
15
+ <button class="kpt-galleria__scroll kpt-galleria__scroll--back" tabindex="-1" aria-label="Scroll thumbnails back">...</button>
16
+ <div class="kpt-galleria__thumbs">
17
+ <button class="kpt-galleria__thumb is-active" aria-current="true" aria-label="Mountain lake"><img src="..." alt="" draggable="false"></button>...
18
+ </div>
19
+ <button class="kpt-galleria__scroll kpt-galleria__scroll--forward" tabindex="-1" aria-label="Scroll thumbnails forward">...</button>
16
20
  </div>
17
21
  <span class="kpt-galleria__live" aria-live="polite">Image 2 of 4: Pine forest</span>
18
22
  </div>
@@ -43,6 +47,29 @@ The arrows use `common.previous` / `common.next`, the thumbnails without `alt` `
43
47
  Anywhere inside the gallery, `ArrowLeft`/`ArrowRight` move to the neighbouring image (reversed in
44
48
  RTL) and `Home`/`End` to the first/last. When focus is on a thumbnail, it follows the active one.
45
49
 
50
+ ## Motion and the thumbnail strip
51
+ Changing the image slides the new one in from the side of travel on a soft spring, while the
52
+ previous one - a temporary `<img class="kpt-galleria__ghost">` under it - drifts back slower and
53
+ dims (parallax). The kind comes from `enter` in `KptMotionProvider`: `spring` (default), `fade` (a short
54
+ cross-fade) or `none`; with the system "reduce motion" setting it only cross-fades. The first
55
+ render and a new `items` list at the same index do not animate.
56
+
57
+ The strip has no scrollbar. It scrolls by dragging (mouse and pen; a touch screen scrolls natively),
58
+ with the wheel or with two small strip arrows that appear only on the side there is more to see -
59
+ the strip fades out on that edge too (`data-scroll-start` / `data-scroll-end` on
60
+ `.kpt-galleria__strip`). A move under 5 px is still a click on a thumbnail; after a real drag the
61
+ click is swallowed and the strip glides on with inertia. The active thumbnail always returns into
62
+ view, and only as far as needed. The strip arrows are `tabindex="-1"`: from the keyboard the gallery
63
+ arrows drive the strip. All of it lives in `@konce-pt/motion` (`playGallerySlide`, `attachDragScroll`,
64
+ `revealThumb`, `trackStripEdges`), shared by both ports.
65
+
66
+ ## Size
67
+ The stage has a fixed aspect ratio and the image fills it, so the gallery takes the full width of
68
+ its container and does not jump in height between images. An image with different proportions is
69
+ cropped (`object-fit: cover`). Two custom properties change it, set on the gallery or any ancestor:
70
+ - `--kpt-galleria-aspect-ratio` (default `16 / 9`), e.g. `4 / 3` or `1`
71
+ - `--kpt-galleria-fit` (default `cover`); `contain` shows the whole image with the stage colour around it
72
+
46
73
  ## Tokens
47
74
  The stage `--kpt-color-muted`; the active thumbnail `--kpt-color-primary`; the arrows `--kpt-color-surface` (85% via `color-mix`, solid on hover) / `--kpt-color-on-surface` with `--kpt-elevation-2`.
48
75
 
@@ -16,6 +16,10 @@ export interface KptImageProps extends Omit<ComponentPropsWithRef<'div'>, 'onCli
16
16
  * stronę i górną warstwę. Na czas podglądu blokujemy przewijanie dokumentu, a po zamknięciu
17
17
  * fokus wraca na miniaturę.
18
18
  *
19
+ * Ruch: zdjęcie wylatuje z miniatury i ląduje na środku na sprężynie, a przy zamknięciu (`x`,
20
+ * `Escape`, tło) wraca do niej, zanim okno się zamknie. Rodzaj ruchu bierze z `enter`
21
+ * w `KptMotionProvider` (`spring` / `fade` / `none`); przy "ogranicz ruch" podgląd tylko się przenika.
22
+ *
19
23
  * @example
20
24
  * <KptImage src="/foto.jpg" alt="Zdjęcie" preview width="240px" />
21
25
  */
@@ -1,6 +1,8 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { useEffect, useRef, useState } from 'react';
4
+ import { playZoomClose, playZoomOpen, resolveEnterMotion, stopZoomMotion } from '@konce-pt/motion';
5
+ import { useKptMotion } from "../motion/motion-context.js";
4
6
  import { cn } from "../internal/cn.js";
5
7
  import { kptLockScroll } from "../internal/scroll-lock.js";
6
8
  import { useKptMessages } from "../i18n/i18n-context.js";
@@ -13,6 +15,10 @@ import { KptIcon } from "../icon/icon.js";
13
15
  * stronę i górną warstwę. Na czas podglądu blokujemy przewijanie dokumentu, a po zamknięciu
14
16
  * fokus wraca na miniaturę.
15
17
  *
18
+ * Ruch: zdjęcie wylatuje z miniatury i ląduje na środku na sprężynie, a przy zamknięciu (`x`,
19
+ * `Escape`, tło) wraca do niej, zanim okno się zamknie. Rodzaj ruchu bierze z `enter`
20
+ * w `KptMotionProvider` (`spring` / `fade` / `none`); przy "ogranicz ruch" podgląd tylko się przenika.
21
+ *
16
22
  * @example
17
23
  * <KptImage src="/foto.jpg" alt="Zdjęcie" preview width="240px" />
18
24
  */
@@ -21,32 +27,77 @@ export function KptImage({ src, alt = '', width = null, preview = false, locale,
21
27
  const [open, setOpen] = useState(false);
22
28
  const dialogRef = useRef(null);
23
29
  const triggerRef = useRef(null);
30
+ const fullRef = useRef(null);
31
+ const closeRef = useRef(null);
32
+ const closeTimer = useRef(null);
33
+ const motion = resolveEnterMotion(undefined, useKptMotion());
34
+ const zoomElements = () => {
35
+ const thumb = triggerRef.current, image = fullRef.current, dialog = dialogRef.current;
36
+ return thumb && image && dialog ? { thumb, image, dialog, close: closeRef.current } : null;
37
+ };
24
38
  useEffect(() => {
25
39
  const el = dialogRef.current;
26
40
  if (!el || !open)
27
41
  return;
28
- if (!el.open)
42
+ if (!el.open) {
29
43
  el.showModal();
44
+ // Tuż po showModal(), przed malowaniem - pierwsza klatka stoi już na miniaturze.
45
+ const els = zoomElements();
46
+ if (els)
47
+ playZoomOpen(els, motion);
48
+ }
30
49
  const releaseScroll = kptLockScroll();
31
50
  return () => {
32
51
  releaseScroll();
33
52
  if (el.open)
34
53
  el.close();
35
54
  };
55
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- ruch czyta stan z chwili otwarcia; zmiana konfiguracji nie otwiera okna ponownie
36
56
  }, [open]);
57
+ // Zegar zamknięcia nie może przeżyć komponentu.
58
+ useEffect(() => () => {
59
+ if (closeTimer.current)
60
+ clearTimeout(closeTimer.current);
61
+ }, []);
37
62
  const style = width ? { width } : undefined;
38
63
  if (!preview) {
39
64
  return (_jsx("div", { className: cn('kpt-image-host', className), ...rest, children: _jsx("div", { className: "kpt-image", style: style, children: _jsx("img", { className: "kpt-image__img", src: src, alt: alt }) }) }));
40
65
  }
41
- // `close` przychodzi z każdej drogi: `x`, `Escape`, tło.
66
+ // Zamknięcie z `x`, `Escape` i tła: najpierw powrót zdjęcia do miniatury, potem okno.
67
+ const requestClose = () => {
68
+ if (closeTimer.current)
69
+ return;
70
+ const els = zoomElements();
71
+ const ms = els ? playZoomClose(els, motion) : 0;
72
+ if (ms === 0) {
73
+ dialogRef.current?.close();
74
+ return;
75
+ }
76
+ closeTimer.current = setTimeout(() => {
77
+ closeTimer.current = null;
78
+ dialogRef.current?.close();
79
+ }, ms);
80
+ };
81
+ // `Escape` zamknąłby okno od razu - przejmujemy je, żeby zdjęcie zdążyło wrócić.
82
+ const onCancel = (event) => {
83
+ event.preventDefault();
84
+ requestClose();
85
+ };
86
+ // `close` przychodzi z każdej drogi (także wymuszonej przez przeglądarkę) - tu sprzątamy.
42
87
  const onClosed = () => {
88
+ if (closeTimer.current)
89
+ clearTimeout(closeTimer.current);
90
+ closeTimer.current = null;
91
+ const els = zoomElements();
92
+ if (els)
93
+ stopZoomMotion(els);
43
94
  setOpen(false);
44
95
  triggerRef.current?.focus();
45
96
  };
46
97
  // Okno wypełnia cały ekran, więc kliknięcie w nie samo (a nie w zdjęcie) to kliknięcie w tło.
47
98
  const onDialogClick = (event) => {
48
99
  if (event.target === event.currentTarget)
49
- event.currentTarget.close();
100
+ requestClose();
50
101
  };
51
- return (_jsxs("div", { className: cn('kpt-image-host', className), ...rest, children: [_jsxs("button", { ref: triggerRef, type: "button", className: "kpt-image kpt-image--preview", "aria-haspopup": "dialog", "aria-label": alt ? t('image.previewOf', { alt }) : t('image.preview'), style: style, onClick: () => setOpen(true), children: [_jsx("img", { className: "kpt-image__img", src: src, alt: "" }), _jsx("span", { className: "kpt-image__mask", "aria-hidden": "true", children: _jsx(KptIcon, { name: "zoom-in" }) })] }), _jsxs("dialog", { ref: dialogRef, className: "kpt-image__overlay", "aria-label": alt || t('image.preview'), onClose: onClosed, onClick: onDialogClick, children: [_jsx("button", { type: "button", className: "kpt-image__close", onClick: () => dialogRef.current?.close(), "aria-label": t('common.close'), children: _jsx(KptIcon, { name: "x" }) }), _jsx("img", { className: "kpt-image__full", src: src, alt: alt, loading: "lazy" })] })] }));
102
+ return (_jsxs("div", { className: cn('kpt-image-host', className), ...rest, children: [_jsxs("button", { ref: triggerRef, type: "button", className: "kpt-image kpt-image--preview", "aria-haspopup": "dialog", "aria-label": alt ? t('image.previewOf', { alt }) : t('image.preview'), style: style, onClick: () => setOpen(true), children: [_jsx("img", { className: "kpt-image__img", src: src, alt: "" }), _jsx("span", { className: "kpt-image__mask", "aria-hidden": "true", children: _jsx(KptIcon, { name: "zoom-in" }) })] }), _jsxs("dialog", { ref: dialogRef, className: "kpt-image__overlay", "aria-label": alt || t('image.preview'), onClose: onClosed, onCancel: onCancel, onClick: onDialogClick, children: [_jsx("button", { ref: closeRef, type: "button", className: "kpt-image__close", onClick: requestClose, "aria-label": t('common.close'), children: _jsx(KptIcon, { name: "x" }) }), _jsx("img", { ref: fullRef, className: "kpt-image__full", src: src, alt: alt })] })] }));
52
103
  }
@@ -40,6 +40,21 @@ only add noise for a screen reader.
40
40
  ## i18n
41
41
  The thumbnail is named from `image.previewOf` (`{alt}`) or `image.preview`; the close button from `common.close`.
42
42
 
43
+ ## Motion
44
+ The preview flies out of the thumbnail and lands in the middle on a soft spring; the corners keep the
45
+ thumbnail radius in flight and the thumbnail is empty while the photo is out. Closing - the x button,
46
+ `Escape` or a click on the backdrop - sends it back into the thumbnail before the dialog closes
47
+ (`Escape` is taken over through the dialog's `cancel` event). The kind comes from `enter` in the
48
+ motion config: `spring` (default), `fade` (cross-fade) or `none`. With the system "reduce motion"
49
+ setting the preview only cross-fades. The flight lives in `@konce-pt/motion` (`playZoomOpen`,
50
+ `playZoomClose`), shared by both ports.
51
+
52
+ ## Preview size
53
+ The full-size photo keeps its proportions and is limited to 80% of the window height and 90% of its
54
+ width, so a portrait photo fits whole with a margin above and below, and the x button does not cover
55
+ it. Change the limits with `--kpt-image-preview-max-height` (default `80vh`) and
56
+ `--kpt-image-preview-max-width` (default `90vw`).
57
+
43
58
  ## Tokens
44
59
  The radius `--kpt-radius-md`; the backdrop and the hover mask `--kpt-color-overlay-scrim`; the x button and the magnifier disc `--kpt-color-surface` / `--kpt-color-on-surface` with `--kpt-elevation-2`.
45
60
 
@@ -1,9 +1,10 @@
1
1
  import { type ComponentPropsWithRef } from 'react';
2
2
  import type { KptSpeechMode } from '@konce-pt/speech';
3
+ import { type KptI18nOverrides } from '../i18n/i18n-context.tsx';
3
4
  import { type KptSpeechColor } from '../speech/speech-button.tsx';
4
5
  import type { KptHostProps } from '../internal/types.ts';
5
6
  export type KptInputType = 'text' | 'email' | 'password' | 'number' | 'search' | 'tel' | 'url' | 'time';
6
- export interface KptInputProps extends Omit<ComponentPropsWithRef<'input'>, 'type' | 'required'> {
7
+ export interface KptInputProps extends Omit<ComponentPropsWithRef<'input'>, 'type' | 'required'>, KptI18nOverrides {
7
8
  /** Typ pola. */
8
9
  type?: KptInputType;
9
10
  /**
@@ -36,6 +37,11 @@ export interface KptInputProps extends Omit<ComponentPropsWithRef<'input'>, 'typ
36
37
  * mikrofonu, a dyktowanie kończy się po jednej wypowiedzi (wzorzec wyszukiwarki).
37
38
  */
38
39
  speechMode?: KptSpeechMode;
40
+ /**
41
+ * Przycisk czyszczenia na końcu pola. Bez ustawienia - włączony tylko dla `type="search"`,
42
+ * gdzie zastępuje systemowe "x" (ukryte zawsze, także przy `clearable={false}`).
43
+ */
44
+ clearable?: boolean;
39
45
  }
40
46
  /**
41
47
  * Pole tekstowe Koncept UI. Renderuje natywny `<input>` w hoście `.kpt-input-host`.
@@ -51,4 +57,4 @@ export interface KptInputProps extends Omit<ComponentPropsWithRef<'input'>, 'typ
51
57
  * <KptInput name="email" type="email" defaultValue="" />
52
58
  * <KptInput value={email} onValueChange={setEmail} invalid={!ok} touched={blurred} />
53
59
  */
54
- export declare function KptInput({ type, required, invalid, touched, onValueChange, onTouch, hostProps, className, id, onChange, onBlur, ref, speech, speechLang, speechColor, speechMode, 'aria-invalid': ariaInvalid, 'aria-describedby': ariaDescribedBy, ...rest }: Readonly<KptInputProps>): import("react").JSX.Element;
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;
@@ -1,11 +1,21 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
- import { useCallback, useRef } from 'react';
3
+ import { useCallback, useEffect, useRef, useState, } from 'react';
4
+ import { useKptMessages } from "../i18n/i18n-context.js";
5
+ import { KptIcon } from "../icon/icon.js";
4
6
  import { cn } from "../internal/cn.js";
5
7
  import { composeRefs } from "../internal/compose-refs.js";
6
8
  import { KptSpeechButton } from "../speech/speech-button.js";
7
9
  import { useKptSpeechRecognition } from "../speech/use-speech-recognition.js";
8
10
  import { joinIds, useKptFieldA11y } from "../form-field/form-field-context.js";
11
+ /**
12
+ * Wpisuje wartość setterem z prototypu i wysyła `input`. Samo `el.value = ''` React by połknął:
13
+ * jego tracker wartości uznałby zdarzenie za echo i nie wywołałby `onChange`.
14
+ */
15
+ function setFieldValue(field, value) {
16
+ Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set?.call(field, value);
17
+ field.dispatchEvent(new Event('input', { bubbles: true }));
18
+ }
9
19
  /**
10
20
  * Pole tekstowe Koncept UI. Renderuje natywny `<input>` w hoście `.kpt-input-host`.
11
21
  *
@@ -20,7 +30,7 @@ import { joinIds, useKptFieldA11y } from "../form-field/form-field-context.js";
20
30
  * <KptInput name="email" type="email" defaultValue="" />
21
31
  * <KptInput value={email} onValueChange={setEmail} invalid={!ok} touched={blurred} />
22
32
  */
23
- export function KptInput({ type = 'text', required = false, invalid = false, touched, onValueChange, onTouch, hostProps, className, id, onChange, onBlur, ref, speech = false, speechLang, speechColor, speechMode = 'insert', '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, onKeyDown, locale, dictionary, 'aria-invalid': ariaInvalid, 'aria-describedby': ariaDescribedBy, ...rest }) {
24
34
  const { className: hostClassName, ...hostRest } = hostProps ?? {};
25
35
  const field = useKptFieldA11y(id, 'kpt-input');
26
36
  const innerRef = useRef(null);
@@ -38,13 +48,49 @@ export function KptInput({ type = 'text', required = false, invalid = false, tou
38
48
  // Stan nieprawidłowy pokazujemy dopiero po dotknięciu pola - jak natywne `:user-invalid`.
39
49
  // Bez `touched` nie ma czego bramkować, więc decyduje samo `invalid`.
40
50
  const showInvalid = invalid && (touched ?? true);
51
+ const { t } = useKptMessages({ locale, dictionary });
52
+ // W trybie niekontrolowanym wartość należy do DOM i React jej nie widzi - to, czy jest co
53
+ // czyścić, trzymamy osobno. W kontrolowanym decyduje `value`.
54
+ const [filledUncontrolled, setFilledUncontrolled] = useState(() => String(rest.defaultValue ?? '') !== '');
55
+ const filled = rest.value === undefined ? filledUncontrolled : String(rest.value) !== '';
56
+ const showClear = (clearable ?? type === 'search') && filled && !rest.disabled && !rest.readOnly;
57
+ // Reset formularza (`form.reset()`, a w React 19 także po `<form action>`) przywraca wartość bez
58
+ // zdarzenia `input` - stan "jest co czyścić" odczytujemy wtedy z pola, gdy reset już się wykona.
59
+ useEffect(() => {
60
+ const form = innerRef.current?.form;
61
+ if (!form)
62
+ return;
63
+ const onReset = () => {
64
+ queueMicrotask(() => setFilledUncontrolled((innerRef.current?.value ?? '') !== ''));
65
+ };
66
+ form.addEventListener('reset', onReset);
67
+ return () => form.removeEventListener('reset', onReset);
68
+ }, []);
41
69
  const handleChange = useCallback((event) => {
70
+ setFilledUncontrolled(event.target.value !== '');
42
71
  onChange?.(event);
43
72
  onValueChange?.(event.target.value);
44
73
  }, [onChange, onValueChange]);
74
+ /** Czyści zdarzeniem `input` - przechodzi przez `onChange` jak wpisane, w obu trybach. */
75
+ const clear = () => {
76
+ const field = innerRef.current;
77
+ if (!field)
78
+ return;
79
+ setFieldValue(field, '');
80
+ field.focus();
81
+ };
82
+ /** Escape czyści pełne pole i nie idzie dalej - pierwszy nie zamyka okna, w którym pole stoi. */
83
+ const handleKeyDown = (event) => {
84
+ onKeyDown?.(event);
85
+ if (event.defaultPrevented || event.key !== 'Escape' || !showClear)
86
+ return;
87
+ event.preventDefault();
88
+ event.stopPropagation();
89
+ clear();
90
+ };
45
91
  const handleBlur = useCallback((event) => {
46
92
  onBlur?.(event);
47
93
  onTouch?.();
48
94
  }, [onBlur, onTouch]);
49
- return (_jsxs("span", { className: cn('kpt-input-host', hostClassName), "data-speech": speechState, ...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, ref: composeRefs(innerRef, ref), ...rest }), 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, ...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] }));
50
96
  }
@@ -15,6 +15,7 @@ Import: `import { KptInput } from '@konce-pt/react';`
15
15
  - `onTouch()` - fires on blur, the moment the field becomes touched
16
16
  - `required`: boolean - see the note below
17
17
  - `invalid`: boolean, `touched`: boolean - the error state
18
+ - `clearable`: boolean (default: `true` only for `type="search"`) - a clear button, see "Clear button"
18
19
  - `hostProps` - props for the `.kpt-input-host` wrapper
19
20
  - `className`, `ref`, `name`, `placeholder`, `autoComplete` and every other `<input>` prop go to
20
21
  the inner `<input>`
@@ -51,6 +52,21 @@ configuration. An explicit `id` prop still wins.
51
52
  generated. Inside `KptFormField` the wrapper's `label[for]` points at it and the hint or error is
52
53
  added to `aria-describedby` (merged with your own). Without a wrapper, name it with `aria-label`.
53
54
 
55
+ ## Clear button
56
+ `type="search"` gets a clear button at the end of the field as soon as it has a value; `clearable`
57
+ turns it on for any other type, `clearable={false}` turns it off. The browser's own "x"
58
+ (`::-webkit-search-cancel-button`) is always hidden - Chromium and Safari draw it differently and
59
+ Firefox not at all. The button is hidden while the field is empty, disabled or read-only.
60
+
61
+ Clearing dispatches a real `input` event, so the value goes through the same path as typed text,
62
+ and the focus stays in the field. The button is `tabindex="-1"` (like the `KptInputNumber`
63
+ steppers); from the keyboard `Escape` clears a filled field and stops the event, so the first
64
+ `Escape` does not close the dialog the field sits in. Its name comes from `input.clear` in the
65
+ dictionary. The host gets `data-clear` while the button is visible.
66
+
67
+ <KptInput type="search" value={query} onValueChange={setQuery} />
68
+ <KptInput clearable value={coupon} onValueChange={setCoupon} />
69
+
54
70
  ## Tokens
55
71
  `--kpt-form-field-{bg,text,placeholder,border,border-hover,border-focus,border-error,radius}`.
56
72
 
@@ -5,7 +5,7 @@ export type { KptToolbarProps } from './toolbar/toolbar.tsx';
5
5
  export { KptSidenav, KptSidenavFooter } from './sidenav/sidenav.tsx';
6
6
  export type { KptSidenavProps } from './sidenav/sidenav.tsx';
7
7
  export { KptTabs, KptTab } from './tabs/tabs.tsx';
8
- export type { KptTabProps, KptTabsProps } from './tabs/tabs.tsx';
8
+ export type { KptTabIconColor, KptTabProps, KptTabsProps } from './tabs/tabs.tsx';
9
9
  export { KptAccordion, KptAccordionPanel } from './accordion/accordion.tsx';
10
10
  export type { KptAccordionPanelProps, KptAccordionProps } from './accordion/accordion.tsx';
11
11
  export { KptPanel, KptPanelActions } from './panel/panel.tsx';
@@ -194,6 +194,8 @@ export type { KptCardHeadingLevel, KptCardProps, KptCardVariant } from './card/c
194
194
  export { KptI18nProvider, useKptI18n, useKptMessages } from './i18n/i18n-context.tsx';
195
195
  export type { KptI18nContextValue, KptI18nOverrides, KptI18nProviderProps } from './i18n/i18n-context.tsx';
196
196
  export { KPT_MESSAGES_EN, KPT_MESSAGES_PL } from '@konce-pt/i18n';
197
+ export { KptMotionProvider, useKptMotion } from './motion/motion-context.tsx';
198
+ export type { KptMotionProviderProps, KptMotionConfig, KptLayoutMotion } from './motion/motion-context.tsx';
197
199
  export type { DeepPartial, KptLocale, KptMessages } from '@konce-pt/i18n';
198
200
  export { cn } from './internal/cn.ts';
199
201
  export type { ClassValue } from './internal/cn.ts';
@@ -109,6 +109,7 @@ export { KptCard, KptCardActions, KptCardFooter, KptCardHeader, KptCardLead, Kpt
109
109
  // i18n
110
110
  export { KptI18nProvider, useKptI18n, useKptMessages } from "./i18n/i18n-context.js";
111
111
  export { KPT_MESSAGES_EN, KPT_MESSAGES_PL } from '@konce-pt/i18n';
112
+ export { KptMotionProvider, useKptMotion } from "./motion/motion-context.js";
112
113
  // Prymitywy pomocnicze
113
114
  export { cn } from "./internal/cn.js";
114
115
  export { composeRefs } from "./internal/compose-refs.js";
@@ -5,12 +5,15 @@ Import: `import { KptTabs } from '@konce-pt/react';`
5
5
 
6
6
  ## DOM
7
7
  <div class="kpt-tabs-host" style="--kpt-anim-duration: 500ms; --kpt-anim-dir: 1">
8
- <div class="kpt-tabs__header" role="tablist">
9
- <button class="kpt-tabs__tab is-active" role="tab" id="kpt-tabs-r1-tab-0"
10
- aria-selected="true" aria-controls="kpt-tabs-r1-panel" tabindex="0">General</button>
11
- <button class="kpt-tabs__tab" role="tab" id="kpt-tabs-r1-tab-1"
12
- aria-selected="false" tabindex="-1">Advanced</button>
13
- <span class="kpt-tabs__ink" style="width: 95px; transform: translateX(85px)"></span>
8
+ <div class="kpt-tabs__header">
9
+ <div class="kpt-tabs__list" role="tablist">
10
+ <button class="kpt-tabs__tab is-active" role="tab" id="kpt-tabs-r1-tab-0"
11
+ aria-selected="true" aria-controls="kpt-tabs-r1-panel" tabindex="0">General</button>
12
+ <button class="kpt-tabs__tab" role="tab" id="kpt-tabs-r1-tab-1"
13
+ aria-selected="false" tabindex="-1">Advanced</button>
14
+ <span class="kpt-tabs__ink" style="width: 95px; transform: translateX(85px)"></span>
15
+ </div>
16
+ <div class="kpt-tabs__links"><a class="kpt-tabs__tab kpt-tabs__link" href="...">Docs</a></div>
14
17
  </div>
15
18
  <div class="kpt-tabs__body" role="tabpanel" tabindex="0" id="kpt-tabs-r1-panel"
16
19
  aria-labelledby="kpt-tabs-r1-tab-0">
@@ -21,13 +24,42 @@ Import: `import { KptTabs } from '@konce-pt/react';`
21
24
  ## Props
22
25
  - `activeIndex` / `defaultActiveIndex` / `onActiveIndexChange` - controlled or uncontrolled
23
26
  - `animated`: boolean (default true), `animationDuration`: number (ms, default 500)
27
+ - `onNavigate`: (event: KptNavigateEvent<KptTabProps>) => void - a plain click on a link tab
24
28
  - `children` - `<KptTabs.Tab>` elements
25
29
 
26
30
  ## KptTabs.Tab
27
- `label` (required), `disabled`, `children`. The tab component never renders itself - `KptTabs`
31
+ `label` (required), `disabled`, `icon`, `iconEnd`, `iconColor`, `iconEndColor`, `href`, `target`, `rel`, `children`. The tab component never renders itself - `KptTabs`
28
32
  reads its props and decides what to show, exactly as the Angular version reads the `kpt-tab`
29
33
  template. Only the active tab's content is in the DOM.
30
34
 
35
+ ## Icons
36
+ `icon` goes before the label, `iconEnd` after it - either, both or none. `iconColor` /
37
+ `iconEndColor` take a `KptTabIconColor` (`'primary' | 'success' | 'warning' | 'danger' | 'info'`),
38
+ set as `data-color` and mapped to `--kpt-color-<name>`, or any CSS color (`var(--brand-accent)`),
39
+ set inline as `color`. Without a color the icon inherits the tab's text color. Same markup as
40
+ Angular: `<span class="kpt-icon-host kpt-tabs__icon" data-color="success">` inside the button.
41
+
42
+ <KptTabs.Tab label="Checks" icon="circle-check" iconColor="success">...</KptTabs.Tab>
43
+ <KptTabs.Tab label="Issues" icon="bell" iconEnd="alert-triangle" iconEndColor="warning">...</KptTabs.Tab>
44
+
45
+ ## Link tabs
46
+ A tab with `href` is a link to another page or an external service, not a tab: it has no panel
47
+ (its content is ignored), it does not count towards `activeIndex`, and it renders **after** the
48
+ tabs, outside `role="tablist"` - a tablist may only contain tabs, and a link announced as a tab would
49
+ promise a panel that does not exist. In the keyboard order the links come after the tab row and
50
+ before the panel. `target="_blank"` gets `rel="noopener noreferrer"` unless you pass `rel`.
51
+ A disabled link renders as `<span aria-disabled="true">` without `href`.
52
+
53
+ A plain click (left button, no modifier keys, no `target`) also emits `onNavigate` with
54
+ `{ item, url, event }`; an app with a router calls `event.preventDefault()` and navigates itself,
55
+ without a page reload.
56
+
57
+ <KptTabs onNavigate={({ event, url }) => { event.preventDefault(); navigate(url); }}>
58
+ <KptTabs.Tab label="Overview">...</KptTabs.Tab>
59
+ <KptTabs.Tab label="Settings" href="/settings" />
60
+ <KptTabs.Tab label="npm" href="https://www.npmjs.com/package/@konce-pt/react" target="_blank" iconEnd="external-link" />
61
+ </KptTabs>
62
+
31
63
  ## The underline is measured, not computed
32
64
  `__ink` takes its width and offset from the active button in the DOM, not from the index: tabs have
33
65
  different widths and the labels change with the language. The measurement runs after every render,
@@ -47,7 +79,7 @@ schedule another render and the component would loop.
47
79
  `--kpt-color-border`, `--kpt-motion-easing-standard`.
48
80
 
49
81
  ## Accessibility
50
- The header is `role="tablist"` with `role="tab"` buttons carrying `aria-selected`; the body is
82
+ The tab list (`__list`) is `role="tablist"` with `role="tab"` buttons carrying `aria-selected`; the body is
51
83
  `role="tabpanel"`, named by the active tab (`aria-labelledby`), which points back at it with
52
84
  `aria-controls`. The tab row is a single `Tab` stop (roving `tabindex`): `ArrowLeft`/`ArrowRight`
53
85
  move between tabs, wrapping at the ends, `Home`/`End` jump to the first/last one, and disabled tabs
@@ -1,7 +1,27 @@
1
1
  import { type ComponentPropsWithRef, type ReactNode } from 'react';
2
+ import { type KptNavigateEvent } from '../shared/types.ts';
3
+ /** Barwa semantyczna ikony zakładki - nazwa tokenu `--kpt-color-*`. */
4
+ export type KptTabIconColor = 'primary' | 'success' | 'warning' | 'danger' | 'info';
2
5
  export interface KptTabProps {
3
6
  label: string;
4
7
  disabled?: boolean;
8
+ /** Ikona przed etykietą - nazwa z rejestru ikon. */
9
+ icon?: string;
10
+ /** Ikona za etykietą - nazwa z rejestru ikon. */
11
+ iconEnd?: string;
12
+ /** Kolor ikony przed etykietą: nazwa semantyczna albo dowolna wartość CSS (`var(--brand)`). */
13
+ iconColor?: KptTabIconColor | (string & {});
14
+ /** Kolor ikony za etykietą: nazwa semantyczna albo dowolna wartość CSS (`var(--brand)`). */
15
+ iconEndColor?: KptTabIconColor | (string & {});
16
+ /**
17
+ * Adres - zakładka staje się odnośnikiem do strony albo serwisu zewnętrznego. Nie ma panelu
18
+ * (`children` są pomijane), nie liczy się do `activeIndex` i stoi za zakładkami, poza `tablist`.
19
+ */
20
+ href?: string;
21
+ /** `target` odnośnika, np. `_blank`. */
22
+ target?: string;
23
+ /** `rel` odnośnika; przy `target="_blank"` domyślnie `noopener noreferrer`. */
24
+ rel?: string;
5
25
  children?: ReactNode;
6
26
  }
7
27
  /**
@@ -13,11 +33,17 @@ export declare namespace KptTab {
13
33
  var kptTabsSlot: "tab";
14
34
  }
15
35
  export interface KptTabsProps extends Omit<ComponentPropsWithRef<'div'>, 'onChange'> {
36
+ /** Indeks wśród zakładek z panelem - odnośniki (`href`) się nie liczą. */
16
37
  activeIndex?: number;
17
38
  defaultActiveIndex?: number;
18
39
  onActiveIndexChange?: (index: number) => void;
19
40
  animated?: boolean;
20
41
  animationDuration?: number;
42
+ /**
43
+ * Zwykły klik w zakładkę-odnośnik bez `target`. Aplikacja z routerem woła
44
+ * `event.preventDefault()` i nawiguje sama, bez przeładowania strony.
45
+ */
46
+ onNavigate?: (event: KptNavigateEvent<KptTabProps>) => void;
21
47
  children?: ReactNode;
22
48
  }
23
49
  /**
@@ -25,7 +51,10 @@ export interface KptTabsProps extends Omit<ComponentPropsWithRef<'div'>, 'onChan
25
51
  *
26
52
  * Klawiatura według wzorca WAI-ARIA "tabs": rząd zakładek to jeden przystanek Tab, strzałki
27
53
  * w lewo/prawo oraz `Home`/`End` przechodzą po zakładkach (z pominięciem wyłączonych) i od razu je
28
- * aktywują, a kolejny `Tab` prowadzi do panelu.
54
+ * aktywują, a kolejny `Tab` prowadzi do odnośników (jeśli są) i dalej do panelu.
55
+ *
56
+ * Zakładka z `href` jest odnośnikiem: stoi za zakładkami, poza `tablist` (ta może zawierać wyłącznie
57
+ * zakładki), nie ma panelu i nie liczy się do `activeIndex`.
29
58
  *
30
59
  * Podkreślenie aktywnej zakładki (`__ink`) jest mierzone z DOM przycisku, nie liczone z indeksu:
31
60
  * zakładki mają różne szerokości, a etykiety zmieniają się z językiem.
@@ -33,10 +62,10 @@ export interface KptTabsProps extends Omit<ComponentPropsWithRef<'div'>, 'onChan
33
62
  * @example
34
63
  * <KptTabs>
35
64
  * <KptTabs.Tab label="Ogólne">...</KptTabs.Tab>
36
- * <KptTabs.Tab label="Zaawansowane">...</KptTabs.Tab>
65
+ * <KptTabs.Tab label="Zaawansowane" iconEnd="settings">...</KptTabs.Tab>
37
66
  * </KptTabs>
38
67
  */
39
- export declare function KptTabs({ activeIndex, defaultActiveIndex, onActiveIndexChange, animated, animationDuration, className, style, children, ...rest }: Readonly<KptTabsProps>): import("react").JSX.Element;
68
+ export declare function KptTabs({ activeIndex, defaultActiveIndex, onActiveIndexChange, animated, animationDuration, onNavigate, className, style, children, ...rest }: Readonly<KptTabsProps>): import("react").JSX.Element;
40
69
  export declare namespace KptTabs {
41
70
  var Tab: typeof KptTab;
42
71
  }
package/dist/tabs/tabs.js CHANGED
@@ -1,11 +1,27 @@
1
1
  'use client';
2
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { isValidElement, useCallback, useLayoutEffect, useRef, useState, } from 'react';
4
+ import { KptIcon } from "../icon/icon.js";
4
5
  import { cn } from "../internal/cn.js";
5
6
  import { slotChildren, warnUnslottedChild } from "../internal/slot-children.js";
6
7
  import { nextRovingIndex, rovingStop } from "../internal/roving.js";
7
8
  import { useControllableState } from "../internal/use-controllable-state.js";
8
9
  import { useKptId } from "../internal/use-id.js";
10
+ import { kptIsPlainClick } from "../shared/types.js";
11
+ const ICON_TONES = new Set(['primary', 'success', 'warning', 'danger', 'info']);
12
+ /** Etykieta z ikonami - wspólna dla zakładki i odnośnika. */
13
+ function TabInner({ tab }) {
14
+ return (_jsxs(_Fragment, { children: [tab.icon && _jsx(TabIcon, { name: tab.icon, color: tab.iconColor }), tab.label, tab.iconEnd && _jsx(TabIcon, { name: tab.iconEnd, color: tab.iconEndColor })] }));
15
+ }
16
+ /**
17
+ * Ikona zakładki. Nazwa semantyczna idzie w `data-color` (barwa z tokenu, zmienia się z motywem),
18
+ * a każdy inny napis - np. `var(--brand-accent)` - trafia wprost do `color`. Bez koloru ikona
19
+ * dziedziczy barwę tekstu zakładki.
20
+ */
21
+ function TabIcon({ name, color }) {
22
+ const tone = color && ICON_TONES.has(color) ? color : undefined;
23
+ return (_jsx(KptIcon, { className: "kpt-tabs__icon", name: name, "data-color": tone, style: color && !tone ? { color } : undefined }));
24
+ }
9
25
  /**
10
26
  * Pojedyncza zakładka. Umieszczaj w `<KptTabs>`. Treść renderuje się leniwie - dopiero gdy
11
27
  * zakładka jest aktywna, tak samo jak szablon `kpt-tab` w porcie Angulara.
@@ -21,7 +37,10 @@ KptTab.kptTabsSlot = 'tab';
21
37
  *
22
38
  * Klawiatura według wzorca WAI-ARIA "tabs": rząd zakładek to jeden przystanek Tab, strzałki
23
39
  * w lewo/prawo oraz `Home`/`End` przechodzą po zakładkach (z pominięciem wyłączonych) i od razu je
24
- * aktywują, a kolejny `Tab` prowadzi do panelu.
40
+ * aktywują, a kolejny `Tab` prowadzi do odnośników (jeśli są) i dalej do panelu.
41
+ *
42
+ * Zakładka z `href` jest odnośnikiem: stoi za zakładkami, poza `tablist` (ta może zawierać wyłącznie
43
+ * zakładki), nie ma panelu i nie liczy się do `activeIndex`.
25
44
  *
26
45
  * Podkreślenie aktywnej zakładki (`__ink`) jest mierzone z DOM przycisku, nie liczone z indeksu:
27
46
  * zakładki mają różne szerokości, a etykiety zmieniają się z językiem.
@@ -29,10 +48,10 @@ KptTab.kptTabsSlot = 'tab';
29
48
  * @example
30
49
  * <KptTabs>
31
50
  * <KptTabs.Tab label="Ogólne">...</KptTabs.Tab>
32
- * <KptTabs.Tab label="Zaawansowane">...</KptTabs.Tab>
51
+ * <KptTabs.Tab label="Zaawansowane" iconEnd="settings">...</KptTabs.Tab>
33
52
  * </KptTabs>
34
53
  */
35
- export function KptTabs({ activeIndex, defaultActiveIndex = 0, onActiveIndexChange, animated = true, animationDuration = 500, className, style, children, ...rest }) {
54
+ export function KptTabs({ activeIndex, defaultActiveIndex = 0, onActiveIndexChange, animated = true, animationDuration = 500, onNavigate, className, style, children, ...rest }) {
36
55
  const [active, setActive] = useControllableState({
37
56
  value: activeIndex,
38
57
  defaultValue: defaultActiveIndex,
@@ -43,6 +62,7 @@ export function KptTabs({ activeIndex, defaultActiveIndex = 0, onActiveIndexChan
43
62
  const buttonsRef = useRef([]);
44
63
  const baseId = useKptId(undefined, 'kpt-tabs');
45
64
  const tabs = [];
65
+ const links = [];
46
66
  const contents = [];
47
67
  for (const child of slotChildren(children)) {
48
68
  if (!isValidElement(child) || child.type?.kptTabsSlot !== 'tab') {
@@ -50,9 +70,18 @@ export function KptTabs({ activeIndex, defaultActiveIndex = 0, onActiveIndexChan
50
70
  continue;
51
71
  }
52
72
  const props = child.props;
73
+ if (props.href) {
74
+ links.push(props);
75
+ continue;
76
+ }
53
77
  tabs.push(props);
54
78
  contents.push(props.children);
55
79
  }
80
+ const onLinkClick = (event, link) => {
81
+ if (link.href && onNavigate && kptIsPlainClick(event, event.currentTarget.target)) {
82
+ onNavigate({ item: link, url: link.href, event: event.nativeEvent });
83
+ }
84
+ };
56
85
  const disabled = tabs.map((tab) => !!tab.disabled);
57
86
  /**
58
87
  * Zakładka pokazywana i jedyna w kolejności Tab: aktywna, a gdy ta jest wyłączona - pierwsza
@@ -102,8 +131,11 @@ export function KptTabs({ activeIndex, defaultActiveIndex = 0, onActiveIndexChan
102
131
  ['--kpt-anim-duration']: `${animated ? animationDuration : 0}ms`,
103
132
  ['--kpt-anim-dir']: direction,
104
133
  ...style,
105
- }, ...rest, children: [_jsxs("div", { ref: headerRef, className: "kpt-tabs__header", role: "tablist", onKeyDown: onKeyDown, children: [tabs.map((tab, index) => (_jsx("button", { ref: (el) => {
106
- buttonsRef.current[index] = el;
107
- }, type: "button", className: cn('kpt-tabs__tab', index === shown && 'is-active'), role: "tab", id: `${baseId}-tab-${index}`, disabled: tab.disabled, "aria-selected": index === shown, "aria-controls": index === shown ? `${baseId}-panel` : undefined, tabIndex: index === shown ? 0 : -1, onClick: () => select(index), children: tab.label }, index))), _jsx("span", { className: "kpt-tabs__ink", style: { width: ink.w, transform: `translateX(${ink.x}px)` } })] }), _jsx("div", { className: "kpt-tabs__body", role: "tabpanel", tabIndex: 0, id: `${baseId}-panel`, "aria-labelledby": `${baseId}-tab-${shown}`, children: contents[shown] !== undefined ? (_jsx("div", { className: "kpt-tabs__panel", children: contents[shown] }, shown)) : null })] }));
134
+ }, ...rest, children: [_jsxs("div", { ref: headerRef, className: "kpt-tabs__header", children: [_jsxs("div", { className: "kpt-tabs__list", role: "tablist", onKeyDown: onKeyDown, children: [tabs.map((tab, index) => (_jsx("button", { ref: (el) => {
135
+ buttonsRef.current[index] = el;
136
+ }, type: "button", className: cn('kpt-tabs__tab', index === shown && 'is-active'), role: "tab", id: `${baseId}-tab-${index}`, disabled: tab.disabled, "aria-selected": index === shown, "aria-controls": index === shown ? `${baseId}-panel` : undefined, tabIndex: index === shown ? 0 : -1, onClick: () => select(index), children: _jsx(TabInner, { tab: tab }) }, index))), _jsx("span", { className: "kpt-tabs__ink", style: { width: ink.w, transform: `translateX(${ink.x}px)` } })] }), links.length > 0 && (_jsx("div", { className: "kpt-tabs__links", children: links.map((link, index) =>
137
+ // Wyłączony odnośnik nie jest `<a>` - z `href` dałoby się go otworzyć klawiaturą albo
138
+ // środkowym przyciskiem, a `<a>` bez `href` i tak nie jest już odnośnikiem.
139
+ link.disabled ? (_jsx("span", { className: "kpt-tabs__tab kpt-tabs__link", role: "link", "aria-disabled": "true", children: _jsx(TabInner, { tab: link }) }, index)) : (_jsx("a", { className: "kpt-tabs__tab kpt-tabs__link", href: link.href, target: link.target, rel: link.rel ?? (link.target === '_blank' ? 'noopener noreferrer' : undefined), onClick: (event) => onLinkClick(event, link), children: _jsx(TabInner, { tab: link }) }, index))) }))] }), _jsx("div", { className: "kpt-tabs__body", role: "tabpanel", tabIndex: 0, id: `${baseId}-panel`, "aria-labelledby": `${baseId}-tab-${shown}`, children: contents[shown] !== undefined ? (_jsx("div", { className: "kpt-tabs__panel", children: contents[shown] }, shown)) : null })] }));
108
140
  }
109
141
  KptTabs.Tab = KptTab;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@konce-pt/react",
3
- "version": "0.16.1",
3
+ "version": "0.17.0",
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/backdrop": "0.16.1",
69
- "@konce-pt/datetime": "0.16.1",
70
- "@konce-pt/chart": "0.16.1",
71
- "@konce-pt/mention": "0.16.1",
72
- "@konce-pt/icons": "0.16.1",
73
- "@konce-pt/roadmap": "0.16.1",
74
- "@konce-pt/chat": "0.16.1",
75
- "@konce-pt/tokens": "0.16.1",
76
- "@konce-pt/i18n": "0.16.1",
77
- "@konce-pt/speech": "0.16.1",
78
- "@konce-pt/motion": "0.16.1",
79
- "@konce-pt/table": "0.16.1",
80
- "@konce-pt/map": "0.16.1"
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"
81
81
  },
82
82
  "devDependencies": {
83
83
  "@types/react": "^19.2.0",