@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.
@@ -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,16 @@ 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;
45
+ /**
46
+ * Ikona z rejestru na początku pola (`search`, `mail`...). Dekoracyjna - nazwę pola daje etykieta
47
+ * albo `aria-label`, więc ikona nie trafia do drzewa dostępności.
48
+ */
49
+ icon?: string;
39
50
  }
40
51
  /**
41
52
  * Pole tekstowe Koncept UI. Renderuje natywny `<input>` w hoście `.kpt-input-host`.
@@ -51,4 +62,4 @@ export interface KptInputProps extends Omit<ComponentPropsWithRef<'input'>, 'typ
51
62
  * <KptInput name="email" type="email" defaultValue="" />
52
63
  * <KptInput value={email} onValueChange={setEmail} invalid={!ok} touched={blurred} />
53
64
  */
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;
65
+ export declare function KptInput({ type, required, invalid, touched, onValueChange, onTouch, hostProps, className, id, onChange, onBlur, ref, speech, speechLang, speechColor, speechMode, clearable, icon, onKeyDown, locale, dictionary, 'aria-invalid': ariaInvalid, 'aria-describedby': ariaDescribedBy, ...rest }: Readonly<KptInputProps>): import("react").JSX.Element;
@@ -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, icon, 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, "data-icon": icon ? '' : undefined, ...hostRest, children: [icon ? _jsx(KptIcon, { className: "kpt-input__icon", name: icon }) : null, _jsx("input", { className: cn('kpt-input__field', className), id: field.id, "aria-describedby": joinIds(ariaDescribedBy, field.describedBy), type: type, "aria-required": required || undefined, "aria-invalid": ariaInvalid ?? (showInvalid || undefined), onChange: handleChange, onBlur: handleBlur, onKeyDown: handleKeyDown, ref: composeRefs(innerRef, ref), ...rest }), showClear ? (_jsx("button", { type: "button", className: "kpt-input__clear", tabIndex: -1, "aria-label": t('input.clear'), onMouseDown: (event) => event.preventDefault(), onClick: clear, children: _jsx(KptIcon, { name: "x" }) })) : null, speech ? (_jsx(KptSpeechButton, { listeningColor: speechColor, speech: mic, size: "sm", unsupported: "hide", disabled: !!rest.disabled || !!rest.readOnly, hostProps: { className: 'kpt-input__speech' } })) : null] }));
50
96
  }
@@ -15,6 +15,10 @@ 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"
19
+ - `icon`: string - a registry icon at the start of the field (`search`, `mail`...). Decorative: the field's
20
+ name comes from the label or the aria label, so the icon stays out of the accessibility tree; a click on it
21
+ lands in the field
18
22
  - `hostProps` - props for the `.kpt-input-host` wrapper
19
23
  - `className`, `ref`, `name`, `placeholder`, `autoComplete` and every other `<input>` prop go to
20
24
  the inner `<input>`
@@ -51,6 +55,22 @@ configuration. An explicit `id` prop still wins.
51
55
  generated. Inside `KptFormField` the wrapper's `label[for]` points at it and the hint or error is
52
56
  added to `aria-describedby` (merged with your own). Without a wrapper, name it with `aria-label`.
53
57
 
58
+ ## Clear button
59
+ `type="search"` gets a clear button at the end of the field as soon as it has a value; `clearable`
60
+ turns it on for any other type, `clearable={false}` turns it off. The browser's own "x"
61
+ (`::-webkit-search-cancel-button`) is always hidden - Chromium and Safari draw it differently and
62
+ Firefox not at all. The button is hidden while the field is empty, disabled or read-only.
63
+
64
+ Clearing dispatches a real `input` event, so the value goes through the same path as typed text,
65
+ and the focus stays in the field. The button is `tabindex="-1"` (like the `KptInputNumber`
66
+ steppers); from the keyboard `Escape` clears a filled field and stops the event, so the first
67
+ `Escape` does not close the dialog the field sits in. Its name comes from `input.clear` in the
68
+ dictionary. The host gets `data-clear` while the button is visible.
69
+
70
+ <KptInput type="search" value={query} onValueChange={setQuery} />
71
+ <KptInput clearable value={coupon} onValueChange={setCoupon} />
72
+ <KptInput type="search" icon="search" value={query} onValueChange={setQuery} />
73
+
54
74
  ## Tokens
55
75
  `--kpt-form-field-{bg,text,placeholder,border,border-hover,border-focus,border-error,radius}`.
56
76
 
@@ -17,6 +17,9 @@ Import: `import { KptPopover } from '@konce-pt/react';`
17
17
  - `placement`: 'bottom' (default) | 'top' - the preferred side; it flips when there is no room
18
18
  - `maxWidth`: string - the panel's maximum width, any CSS length (`'24rem'`; default `20rem`). The panel is
19
19
  never wider than the window minus the 8 px overlay margin
20
+ - `ariaLabel`: string - the accessible name of the panel (`role="dialog"`); give one whenever the panel has
21
+ no visible heading. It names the panel, not the host - `aria-label` in the rest props would land on the
22
+ host `<span>`
20
23
  - `className`, `ref` and every other `<span>` prop go to the host
21
24
 
22
25
  ## The panel is a portal
@@ -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';
@@ -74,7 +74,7 @@ export { KptDataTable } from './data-table/data-table.tsx';
74
74
  export type { KptDataTableProps } from './data-table/data-table.tsx';
75
75
  export { exportTableCsv } from './data-table/export-csv.ts';
76
76
  export type { KptCellEditContext, KptCellEditEvent, KptCellEditRenderers, KptCellRenderers, KptColumn, KptColumnEdit, KptColumnEditType, KptSortDir, KptTableLazyState, } from './data-table/data-table-types.ts';
77
- export type { KptColumnFilter, KptColumnFilterType, KptColumnSummary, KptSummaryFn } from '@konce-pt/table';
77
+ export type { KptColumnFilter, KptColumnFilterType, KptColumnGroup, KptColumnSummary, KptSummaryFn } from '@konce-pt/table';
78
78
  export { KptDataView } from './data-view/data-view.tsx';
79
79
  export type { KptDataViewLayout, KptDataViewProps } from './data-view/data-view.tsx';
80
80
  export { KptTree } from './tree/tree.tsx';
@@ -123,7 +123,7 @@ export type { KptOverlayProps } from './internal/overlay.tsx';
123
123
  export { KptButtonGroup } from './button-group/button-group.tsx';
124
124
  export type { KptButtonGroupProps } from './button-group/button-group.tsx';
125
125
  export { KptSelect } from './select/select.tsx';
126
- export type { KptSelectProps } from './select/select.tsx';
126
+ export type { KptSelectMultipleProps, KptSelectProps, KptSelectSingleProps } from './select/select.tsx';
127
127
  export type { KptAlertVariant, KptNavigateEvent, KptOption } from './shared/types.ts';
128
128
  export { KptFormField } from './form-field/form-field.tsx';
129
129
  export type { KptFormFieldLabelType, KptFormFieldProps } from './form-field/form-field.tsx';
@@ -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";
@@ -15,7 +15,11 @@ A select on its own panel. Variants: single, with a search box (`filter`) and mu
15
15
 
16
16
  ## Props
17
17
  - `options`: `KptOption<T>[]` - `{ label, value, disabled? }`
18
- - `value` / `defaultValue` / `onValueChange` - a single `T`, or `T[]` in `multiple` mode
18
+ - `value` / `defaultValue` / `onValueChange` - a single `T`, or `T[]` in `multiple` mode. The handler type
19
+ follows `multiple` (overloads): without it `(value: T) => void` - a single select cannot clear its
20
+ choice, so it never reports an array or `null`; with `multiple` `(value: T[]) => void`. A flag computed at
21
+ run time (`multiple={flag}`) keeps the wide `T | T[] | null`. Types: `KptSelectSingleProps`,
22
+ `KptSelectMultipleProps`, `KptSelectProps` (the wide one)
19
23
  - `open` / `defaultOpen` / `onOpenChange` - the panel state, so the list can be opened from outside
20
24
  - `placeholder`, `multiple`, `filter`, `disabled`, `invalid`, `touched`
21
25
  - `onTouch()` - fires when the panel closes or focus leaves the closed trigger
@@ -1,4 +1,4 @@
1
- import { type ComponentPropsWithRef } from 'react';
1
+ import { type ComponentPropsWithRef, type JSX } from 'react';
2
2
  import { type KptI18nOverrides } from '../i18n/i18n-context.tsx';
3
3
  import type { KptOption } from '../shared/types.ts';
4
4
  export interface KptSelectProps<T> extends Omit<ComponentPropsWithRef<'div'>, 'children' | 'onChange' | 'defaultValue'>, KptI18nOverrides {
@@ -28,6 +28,23 @@ export interface KptSelectProps<T> extends Omit<ComponentPropsWithRef<'div'>, 'c
28
28
  /** Wywoływane, gdy panel się zamyka albo trigger traci fokus. */
29
29
  onTouch?: () => void;
30
30
  }
31
+ /**
32
+ * Pojedynczy wybór: `onValueChange` dostaje zawsze wybraną opcję - nigdy tablicę ani `null`, bo
33
+ * lista bez `multiple` nie ma jak wyczyścić wyboru. Rodzic trzyma więc zwykłe `T`.
34
+ */
35
+ export interface KptSelectSingleProps<T> extends Omit<KptSelectProps<T>, 'multiple' | 'value' | 'defaultValue' | 'onValueChange'> {
36
+ multiple?: false;
37
+ value?: T | null;
38
+ defaultValue?: T | null;
39
+ onValueChange?: (value: T) => void;
40
+ }
41
+ /** Wybór wielokrotny: wartością jest zawsze tablica (pusta, gdy nic nie wybrano). */
42
+ export interface KptSelectMultipleProps<T> extends Omit<KptSelectProps<T>, 'multiple' | 'value' | 'defaultValue' | 'onValueChange'> {
43
+ multiple: true;
44
+ value?: T[] | null;
45
+ defaultValue?: T[] | null;
46
+ onValueChange?: (value: T[]) => void;
47
+ }
31
48
  /**
32
49
  * Lista wyboru (select) na własnym panelu. Warianty: pojedynczy, z wyszukiwarką (`filter`)
33
50
  * i wielokrotny (`multiple`, chipy).
@@ -46,5 +63,10 @@ export interface KptSelectProps<T> extends Omit<ComponentPropsWithRef<'div'>, 'c
46
63
  * @example
47
64
  * <KptSelect options={roles} value={role} onValueChange={setRole} placeholder="Wybierz..." />
48
65
  * <KptSelect options={tags} multiple filter value={tags} onValueChange={setTags} />
66
+ *
67
+ * Typ `onValueChange` idzie za `multiple`: bez niego `(value: T)`, z `multiple` - `(value: T[])`.
68
+ * `multiple` liczone w locie (`multiple={flag}`) zostawia szeroki typ `T | T[] | null`.
49
69
  */
50
- export declare function KptSelect<T>({ options, placeholder, multiple, filter, disabled, readOnly, invalid, touched, value, defaultValue, onValueChange, open, defaultOpen, onOpenChange, onTouch, locale, dictionary, className, onBlur, id, required, 'aria-label': ariaLabel, 'aria-describedby': ariaDescribedBy, ...rest }: Readonly<KptSelectProps<T>>): import("react").JSX.Element;
70
+ export declare function KptSelect<T>(props: Readonly<KptSelectSingleProps<T>>): JSX.Element;
71
+ export declare function KptSelect<T>(props: Readonly<KptSelectMultipleProps<T>>): JSX.Element;
72
+ export declare function KptSelect<T>(props: Readonly<KptSelectProps<T>>): JSX.Element;
@@ -18,25 +18,6 @@ function filterSelectOptions(options, text) {
18
18
  const q = foldMentionText(text.trim());
19
19
  return q ? options.filter((option) => foldMentionText(option.label).includes(q)) : options;
20
20
  }
21
- /**
22
- * Lista wyboru (select) na własnym panelu. Warianty: pojedynczy, z wyszukiwarką (`filter`)
23
- * i wielokrotny (`multiple`, chipy).
24
- *
25
- * Klawiatura według wzorca WAI-ARIA "select-only combobox": fokus zostaje na polu, a aktywna opcja
26
- * idzie przez `aria-activedescendant`. `ArrowDown`/`ArrowUp`/`Enter`/`Spacja` otwierają listę na wybranej opcji,
27
- * strzałki, `Home`/`End`, `PageUp`/`PageDown` chodzą po opcjach (z pominięciem wyłączonych),
28
- * wpisywane litery skaczą do pasującej opcji (bez polskich znaków), `Enter` wybiera, `Escape`
29
- * zamyka. Z `filter` fokus przechodzi do pola wyszukiwania, które obsługuje te same klawisze.
30
- * W trybie `multiple` `Backspace` na zamkniętym polu usuwa ostatni wybór.
31
- *
32
- * Wewnątrz `KptFormField` zgłasza wypełnienie hookiem `useKptFormFieldFilled`, więc etykieta
33
- * Float Label unosi się nad wybraną wartością - natywnego pola, z którego wrapper mógłby to
34
- * odczytać, tu nie ma.
35
- *
36
- * @example
37
- * <KptSelect options={roles} value={role} onValueChange={setRole} placeholder="Wybierz..." />
38
- * <KptSelect options={tags} multiple filter value={tags} onValueChange={setTags} />
39
- */
40
21
  export function KptSelect({ options = [], placeholder = '', multiple = false, filter = false, disabled = false, readOnly = false, invalid = false, touched, value, defaultValue = null, onValueChange, open, defaultOpen = false, onOpenChange, onTouch, locale, dictionary, className, onBlur, id, required = false, 'aria-label': ariaLabel, 'aria-describedby': ariaDescribedBy, ...rest }) {
41
22
  /** `div` z rolą nie dostaje nazwy z `label[for]` - stąd `aria-labelledby` na etykietę wrappera. */
42
23
  const field = useKptFieldA11y(id, 'kpt-select-trigger');