@konce-pt/react 0.10.0 → 0.11.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.
@@ -5,8 +5,13 @@ export interface KptOverlayProps {
5
5
  anchor: HTMLElement | null;
6
6
  /** Kliknięcie w backdrop albo Escape. */
7
7
  onClose: () => void;
8
- /** Panel dostaje szerokość wyzwalacza (tak działa lista selecta). */
8
+ /** Panel dostaje szerokość wyzwalacza — dokładnie tę (podpowiedzi autocomplete pod polem). */
9
9
  matchAnchorWidth?: boolean;
10
+ /**
11
+ * Panel jest co najmniej tak szeroki jak wyzwalacz, ale rośnie do treści (lista selecta:
12
+ * w wąskim polu opcja dłuższa od niego wchodziłaby pod znacznik zaznaczenia).
13
+ */
14
+ minAnchorWidth?: boolean;
10
15
  /** Odstęp od wyzwalacza w pikselach. */
11
16
  offset?: number;
12
17
  /**
@@ -28,4 +33,4 @@ export interface KptOverlayProps {
28
33
  preferAbove?: boolean;
29
34
  children: ReactNode;
30
35
  }
31
- export declare function KptOverlay({ open, anchor, onClose, matchAnchorWidth, offset, align, withBackdrop, preferAbove, children, }: Readonly<KptOverlayProps>): import("react").ReactPortal | null;
36
+ export declare function KptOverlay({ open, anchor, onClose, matchAnchorWidth, minAnchorWidth, offset, align, withBackdrop, preferAbove, children, }: Readonly<KptOverlayProps>): import("react").ReactPortal | null;
@@ -4,7 +4,7 @@ import { createPortal } from 'react-dom';
4
4
  import { useKptOverlayContainer } from "./overlay-container.js";
5
5
  /** Ta sama warstwa co `--kpt-z-overlay` w tokenach — panel ma być nad treścią aplikacji. */
6
6
  const Z_INDEX = 'var(--kpt-z-overlay, 1100)';
7
- export function KptOverlay({ open, anchor, onClose, matchAnchorWidth = false, offset = 4, align = 'start', withBackdrop = true, preferAbove = false, children, }) {
7
+ export function KptOverlay({ open, anchor, onClose, matchAnchorWidth = false, minAnchorWidth = false, offset = 4, align = 'start', withBackdrop = true, preferAbove = false, children, }) {
8
8
  const container = useKptOverlayContainer();
9
9
  const panelRef = useRef(null);
10
10
  const [position, setPosition] = useState(null);
@@ -24,8 +24,9 @@ export function KptOverlay({ open, anchor, onClose, matchAnchorWidth = false, of
24
24
  top: flip ? rect.top - panelHeight - offset : rect.bottom + offset,
25
25
  left: align === 'end' ? rect.right - panelWidth : rect.left,
26
26
  ...(matchAnchorWidth ? { width: rect.width } : {}),
27
+ ...(minAnchorWidth ? { minWidth: rect.width } : {}),
27
28
  });
28
- }, [anchor, matchAnchorWidth, offset, align, preferAbove]);
29
+ }, [anchor, matchAnchorWidth, minAnchorWidth, offset, align, preferAbove]);
29
30
  useLayoutEffect(() => {
30
31
  if (!open) {
31
32
  setPosition(null);
@@ -58,6 +59,7 @@ export function KptOverlay({ open, anchor, onClose, matchAnchorWidth = false, of
58
59
  top: position?.top ?? 0,
59
60
  left: position?.left ?? 0,
60
61
  width: position?.width,
62
+ minWidth: position?.minWidth,
61
63
  zIndex: Z_INDEX,
62
64
  // Panel bez pozycji jeszcze nie został zmierzony — chowamy go na pierwszej klatce,
63
65
  // żeby nie mrugnął w lewym górnym rogu.
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Płynne dojście liczby do celu — dla kontrolek, które pokazują wartość rysunkiem (łuk pokrętła)
3
+ * i licznikiem. Pure TS bez frameworka; bliźniak portu Angulara: `shared/tween.ts`.
4
+ *
5
+ * Bez animacji, gdy użytkownik prosi o ograniczenie ruchu (`prefers-reduced-motion`) albo gdy nie
6
+ * ma `requestAnimationFrame` (render po stronie serwera) — wtedy cel ustawia się od razu.
7
+ */
8
+ /** Czy animować — reguła wspólna dla wszystkich kontrolek, żeby „mniej ruchu" działało wszędzie. */
9
+ export declare function kptMotionAllowed(): boolean;
10
+ /**
11
+ * Animuje od `from` do `to` przez `duration` ms, wołając `onFrame` z wartością pośrednią.
12
+ * Zwraca funkcję przerwania — kolejny skok przerywa poprzedni i rusza z miejsca, w którym ten był,
13
+ * więc szybkie stuknięcia strzałką nie cofają łuku do startu.
14
+ */
15
+ export declare function kptTween(from: number, to: number, duration: number, onFrame: (value: number) => void): () => void;
16
+ /** Liczba miejsc po przecinku kroku (`0.25` → 2) — licznik w trakcie animacji nie pokazuje szumu. */
17
+ export declare function kptStepDecimals(step: number): number;
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Płynne dojście liczby do celu — dla kontrolek, które pokazują wartość rysunkiem (łuk pokrętła)
3
+ * i licznikiem. Pure TS bez frameworka; bliźniak portu Angulara: `shared/tween.ts`.
4
+ *
5
+ * Bez animacji, gdy użytkownik prosi o ograniczenie ruchu (`prefers-reduced-motion`) albo gdy nie
6
+ * ma `requestAnimationFrame` (render po stronie serwera) — wtedy cel ustawia się od razu.
7
+ */
8
+ /** Zwolnienie pod koniec (ease-out cubic): ruch rusza od razu i dojeżdża miękko. */
9
+ const easeOut = (t) => 1 - (1 - t) ** 3;
10
+ /** Czy animować — reguła wspólna dla wszystkich kontrolek, żeby „mniej ruchu" działało wszędzie. */
11
+ export function kptMotionAllowed() {
12
+ if (typeof requestAnimationFrame === 'undefined')
13
+ return false;
14
+ return !(typeof matchMedia !== 'undefined' && matchMedia('(prefers-reduced-motion: reduce)').matches);
15
+ }
16
+ /**
17
+ * Animuje od `from` do `to` przez `duration` ms, wołając `onFrame` z wartością pośrednią.
18
+ * Zwraca funkcję przerwania — kolejny skok przerywa poprzedni i rusza z miejsca, w którym ten był,
19
+ * więc szybkie stuknięcia strzałką nie cofają łuku do startu.
20
+ */
21
+ export function kptTween(from, to, duration, onFrame) {
22
+ if (from === to || duration <= 0 || !kptMotionAllowed()) {
23
+ onFrame(to);
24
+ return () => { };
25
+ }
26
+ const start = performance.now();
27
+ let frame = requestAnimationFrame(function tick(now) {
28
+ const t = Math.min(1, (now - start) / duration);
29
+ onFrame(from + (to - from) * easeOut(t));
30
+ if (t < 1)
31
+ frame = requestAnimationFrame(tick);
32
+ });
33
+ return () => cancelAnimationFrame(frame);
34
+ }
35
+ /** Liczba miejsc po przecinku kroku (`0.25` → 2) — licznik w trakcie animacji nie pokazuje szumu. */
36
+ export function kptStepDecimals(step) {
37
+ const text = String(step);
38
+ const dot = text.indexOf('.');
39
+ return dot === -1 ? 0 : text.length - dot - 1;
40
+ }
@@ -25,6 +25,10 @@ export interface KptKnobProps extends Omit<ComponentPropsWithRef<'div'>, 'onChan
25
25
  /**
26
26
  * Pokrętło — wybór wartości po łuku, przeciąganiem albo strzałkami.
27
27
  *
28
+ * Skok wartości (strzałka, `Home`/`End`, klik w łuk, zmiana z zewnątrz) łuk i licznik pokonują
29
+ * płynnie; przy przeciąganiu idą za kursorem bez opóźnienia. `prefers-reduced-motion` wyłącza
30
+ * animację, a `aria-valuenow` zawsze niesie wartość docelową, nie pośrednią.
31
+ *
28
32
  * @example
29
33
  * <KptKnob value={level} onValueChange={setLevel} min={0} max={100} step={5} suffix="%" />
30
34
  */
package/dist/knob/knob.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useRef } from 'react';
2
+ import { useEffect, useRef, useState } from 'react';
3
3
  import { cn } from "../internal/cn.js";
4
+ import { kptStepDecimals, kptTween } from "../internal/tween.js";
4
5
  import { useControllableState } from "../internal/use-controllable-state.js";
5
6
  import { useKptFieldA11y } from "../form-field/form-field-context.js";
6
7
  const TAU = Math.PI * 2;
@@ -8,9 +9,15 @@ const TAU = Math.PI * 2;
8
9
  const GAP = Math.PI / 4;
9
10
  const START_ANGLE = Math.PI / 2 + GAP;
10
11
  const SWEEP = TAU - GAP * 2;
12
+ /** Czas dojścia łuku i licznika po skoku wartości (klawisz, klik, zmiana z zewnątrz). */
13
+ const TWEEN_MS = 220;
11
14
  /**
12
15
  * Pokrętło — wybór wartości po łuku, przeciąganiem albo strzałkami.
13
16
  *
17
+ * Skok wartości (strzałka, `Home`/`End`, klik w łuk, zmiana z zewnątrz) łuk i licznik pokonują
18
+ * płynnie; przy przeciąganiu idą za kursorem bez opóźnienia. `prefers-reduced-motion` wyłącza
19
+ * animację, a `aria-valuenow` zawsze niesie wartość docelową, nie pośrednią.
20
+ *
14
21
  * @example
15
22
  * <KptKnob value={level} onValueChange={setLevel} min={0} max={100} step={5} suffix="%" />
16
23
  */
@@ -20,7 +27,23 @@ export function KptKnob({ value, defaultValue = 0, onValueChange, min = 0, max =
20
27
  const [current, setCurrent] = useControllableState({ value, defaultValue, onChange: onValueChange });
21
28
  const showInvalid = invalid && (touched ?? true);
22
29
  const range = max - min;
23
- const ratio = range <= 0 ? 0 : (current - min) / range;
30
+ /** Wartość na rysunku — w trakcie animacji pośrednia, poza nią równa `current`. */
31
+ const [shown, setShown] = useState(current);
32
+ const shownRef = useRef(current);
33
+ /** Przeciąganie trwa — wtedy rysunek idzie za kursorem bez animacji. */
34
+ const dragging = useRef(false);
35
+ useEffect(() => {
36
+ const paint = (v) => {
37
+ shownRef.current = v;
38
+ setShown(v);
39
+ };
40
+ if (dragging.current) {
41
+ paint(current);
42
+ return;
43
+ }
44
+ return kptTween(shownRef.current, current, TWEEN_MS, paint);
45
+ }, [current]);
46
+ const ratio = range <= 0 ? 0 : Math.min(1, Math.max(0, (shown - min) / range));
24
47
  /** Wycinek łuku jako ścieżka SVG; `to <= from` daje samo `M`, czyli nic widocznego. */
25
48
  const arc = (from, to) => {
26
49
  const r = (size - stroke) / 2;
@@ -54,17 +77,28 @@ export function KptKnob({ value, defaultValue = 0, onValueChange, min = 0, max =
54
77
  const onPointerDown = (event) => {
55
78
  if (disabled)
56
79
  return;
80
+ // Bez tego przeciąganie zaznacza tekst: licznik w środku i — przez SVG — całe koło, jak po
81
+ // `Ctrl+A`. Zablokowanie domyślnej akcji zabiera też fokus, więc oddajemy go ręcznie.
82
+ event.preventDefault();
83
+ event.currentTarget.focus();
84
+ // Klik w łuk to skok — animowany. Dopiero ruch po nim jest przeciąganiem.
57
85
  setFromPoint(event.clientX, event.clientY);
58
86
  // Nasłuch wisi na oknie, a nie na pokrętle: przeciąganie ma nie gubić wartości,
59
87
  // gdy kursor wyjedzie poza koło — a wyjeżdża praktycznie zawsze.
60
- const move = (e) => setFromPoint(e.clientX, e.clientY);
88
+ const move = (e) => {
89
+ dragging.current = true;
90
+ setFromPoint(e.clientX, e.clientY);
91
+ };
61
92
  const up = () => {
62
93
  window.removeEventListener('pointermove', move);
63
94
  window.removeEventListener('pointerup', up);
95
+ window.removeEventListener('pointercancel', up);
96
+ dragging.current = false;
64
97
  onTouch?.();
65
98
  };
66
99
  window.addEventListener('pointermove', move);
67
100
  window.addEventListener('pointerup', up);
101
+ window.addEventListener('pointercancel', up);
68
102
  };
69
103
  const onKeyDown = (event) => {
70
104
  if (disabled)
@@ -84,5 +118,5 @@ export function KptKnob({ value, defaultValue = 0, onValueChange, min = 0, max =
84
118
  setCurrent(Math.min(max, Math.max(min, next)));
85
119
  onTouch?.();
86
120
  };
87
- return (_jsx("div", { className: cn('kpt-knob-host', className), ...rest, children: _jsx("div", { className: cn('kpt-knob', showInvalid && 'kpt-knob--invalid'), role: "slider", id: field.id, tabIndex: disabled ? -1 : 0, "aria-valuemin": min, "aria-valuemax": max, "aria-valuenow": current, "aria-valuetext": suffix ? `${current}${suffix}` : undefined, "aria-disabled": disabled || undefined, "aria-invalid": showInvalid || undefined, "aria-label": ariaLabel ?? undefined, "aria-labelledby": ariaLabel ? undefined : field.labelledBy, "aria-describedby": field.describedBy, onPointerDown: onPointerDown, onKeyDown: onKeyDown, children: _jsxs("svg", { ref: svgRef, viewBox: `0 0 ${size} ${size}`, style: { width: size, height: size }, children: [_jsx("path", { className: "kpt-knob__range", d: arc(0, 1), fill: "none", strokeWidth: stroke, strokeLinecap: "round" }), _jsx("path", { className: "kpt-knob__value", d: arc(0, ratio), fill: "none", strokeWidth: stroke, strokeLinecap: "round" }), showValue ? (_jsxs("text", { className: "kpt-knob__text", x: size / 2, y: size / 2, textAnchor: "middle", dominantBaseline: "central", children: [current, suffix] })) : null] }) }) }));
121
+ return (_jsx("div", { className: cn('kpt-knob-host', className), ...rest, children: _jsx("div", { className: cn('kpt-knob', showInvalid && 'kpt-knob--invalid'), role: "slider", id: field.id, tabIndex: disabled ? -1 : 0, "aria-valuemin": min, "aria-valuemax": max, "aria-valuenow": current, "aria-valuetext": suffix ? `${current}${suffix}` : undefined, "aria-disabled": disabled || undefined, "aria-invalid": showInvalid || undefined, "aria-label": ariaLabel ?? undefined, "aria-labelledby": ariaLabel ? undefined : field.labelledBy, "aria-describedby": field.describedBy, onPointerDown: onPointerDown, onKeyDown: onKeyDown, children: _jsxs("svg", { ref: svgRef, viewBox: `0 0 ${size} ${size}`, style: { width: size, height: size }, children: [_jsx("path", { className: "kpt-knob__range", d: arc(0, 1), fill: "none", strokeWidth: stroke, strokeLinecap: "round" }), _jsx("path", { className: "kpt-knob__value", d: arc(0, ratio), fill: "none", strokeWidth: stroke, strokeLinecap: "round" }), showValue ? (_jsxs("text", { className: "kpt-knob__text", x: size / 2, y: size / 2, textAnchor: "middle", dominantBaseline: "central", children: [shown.toFixed(kptStepDecimals(step)), suffix] })) : null] }) }) }));
88
122
  }
@@ -48,6 +48,13 @@ lands on the wrapper.
48
48
  The background arc `--kpt-color-border`, the value `--kpt-color-primary`, the text
49
49
  `--kpt-color-on-surface`.
50
50
 
51
+ ## Motion
52
+ A jump in value — an arrow key, Home/End, a click on the arc, a change from outside — moves the arc
53
+ and counts the number up or down over ~220 ms (ease-out). Dragging follows the pointer with no
54
+ delay, so it never feels laggy. `prefers-reduced-motion: reduce` turns the animation off.
55
+ `aria-valuenow` always carries the target value, never an in-between frame. Dragging does not
56
+ select the number or the arc.
57
+
51
58
  ## Accessibility
52
59
  `role="slider"` with `aria-valuemin` / `aria-valuemax` / `aria-valuenow`; arrows step by `step`,
53
60
  Home and End jump to the bounds. `disabled` sets `aria-disabled` and drops the knob out of the
@@ -180,7 +180,7 @@ export function KptSelect({ options = [], placeholder = '', multiple = false, fi
180
180
  }, ...rest, children: [_jsx("div", { className: "kpt-select__value", children: multiple && selectedOptions.length ? (selectedOptions.map((option) => (_jsxs("span", { className: "kpt-select__chip", children: [option.label, _jsx("button", { type: "button", className: "kpt-select__chip-remove", tabIndex: -1, "aria-label": `${t('select.removeChip')}: ${option.label}`, onClick: (event) => {
181
181
  event.stopPropagation();
182
182
  setCurrent(selectedValues.filter((v) => v !== option.value));
183
- }, children: _jsx(KptIcon, { name: "x" }) })] }, String(option.value))))) : !multiple && filled ? (_jsx("span", { className: "kpt-select__single", children: selectedOptions[0]?.label })) : (_jsx("span", { className: "kpt-select__placeholder", children: placeholder })) }), _jsx(KptIcon, { className: "kpt-select__chevron", name: "chevron-down" })] }), _jsx(KptOverlay, { open: isOpen, anchor: triggerRef.current, onClose: close, matchAnchorWidth: true, children: _jsxs("div", { className: "kpt-select__panel", children: [filter ? (_jsxs("div", { className: "kpt-select__search", children: [_jsx(KptIcon, { name: "search" }), _jsx("input", { ref: searchRef, type: "text", className: "kpt-select__search-input", role: "combobox", "aria-autocomplete": "list", "aria-expanded": true, "aria-controls": listId, "aria-activedescendant": activeIndex >= 0 ? optionId(activeIndex) : undefined, "aria-label": t('select.search'), value: filterText, placeholder: t('select.search'), onKeyDown: (event) => onKeyDown(event, 'search'), onChange: (event) => {
183
+ }, children: _jsx(KptIcon, { name: "x" }) })] }, String(option.value))))) : !multiple && filled ? (_jsx("span", { className: "kpt-select__single", children: selectedOptions[0]?.label })) : (_jsx("span", { className: "kpt-select__placeholder", children: placeholder })) }), _jsx(KptIcon, { className: "kpt-select__chevron", name: "chevron-down" })] }), _jsx(KptOverlay, { open: isOpen, anchor: triggerRef.current, onClose: close, minAnchorWidth: true, children: _jsxs("div", { className: "kpt-select__panel", children: [filter ? (_jsxs("div", { className: "kpt-select__search", children: [_jsx(KptIcon, { name: "search" }), _jsx("input", { ref: searchRef, type: "text", className: "kpt-select__search-input", role: "combobox", "aria-autocomplete": "list", "aria-expanded": true, "aria-controls": listId, "aria-activedescendant": activeIndex >= 0 ? optionId(activeIndex) : undefined, "aria-label": t('select.search'), value: filterText, placeholder: t('select.search'), onKeyDown: (event) => onKeyDown(event, 'search'), onChange: (event) => {
184
184
  setFilterText(event.target.value);
185
185
  // Po zawężeniu aktywna jest pierwsza pasująca opcja — `Enter` wybiera ją od razu.
186
186
  const narrowed = filterOptions(event.target.value).map((o) => !!o.disabled);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@konce-pt/react",
3
- "version": "0.10.0",
3
+ "version": "0.11.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,17 +65,17 @@
65
65
  "leaflet": "^1.9.0"
66
66
  },
67
67
  "dependencies": {
68
- "@konce-pt/chat": "0.10.0",
69
- "@konce-pt/chart": "0.10.0",
70
- "@konce-pt/backdrop": "0.10.0",
71
- "@konce-pt/i18n": "0.10.0",
72
- "@konce-pt/datetime": "0.10.0",
73
- "@konce-pt/mention": "0.10.0",
74
- "@konce-pt/tokens": "0.10.0",
75
- "@konce-pt/table": "0.10.0",
76
- "@konce-pt/roadmap": "0.10.0",
77
- "@konce-pt/map": "0.10.0",
78
- "@konce-pt/icons": "0.10.0"
68
+ "@konce-pt/chart": "0.11.0",
69
+ "@konce-pt/icons": "0.11.0",
70
+ "@konce-pt/chat": "0.11.0",
71
+ "@konce-pt/backdrop": "0.11.0",
72
+ "@konce-pt/table": "0.11.0",
73
+ "@konce-pt/datetime": "0.11.0",
74
+ "@konce-pt/roadmap": "0.11.0",
75
+ "@konce-pt/tokens": "0.11.0",
76
+ "@konce-pt/i18n": "0.11.0",
77
+ "@konce-pt/mention": "0.11.0",
78
+ "@konce-pt/map": "0.11.0"
79
79
  },
80
80
  "devDependencies": {
81
81
  "@types/react": "^19.2.0",