@konce-pt/react 0.9.0 → 0.10.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.
Files changed (196) hide show
  1. package/dist/accordion/accordion.js +7 -1
  2. package/dist/accordion/llms.txt +8 -4
  3. package/dist/alert/alert.d.ts +10 -4
  4. package/dist/alert/alert.js +13 -6
  5. package/dist/alert/llms.txt +8 -6
  6. package/dist/app-shell/app-shell.js +28 -2
  7. package/dist/app-shell/llms.txt +3 -0
  8. package/dist/autocomplete/autocomplete.d.ts +7 -2
  9. package/dist/autocomplete/autocomplete.js +46 -9
  10. package/dist/autocomplete/llms.txt +12 -0
  11. package/dist/avatar/avatar.d.ts +4 -2
  12. package/dist/avatar/avatar.js +8 -3
  13. package/dist/avatar/llms.txt +9 -5
  14. package/dist/avatar-group/avatar-group.d.ts +5 -2
  15. package/dist/avatar-group/avatar-group.js +10 -2
  16. package/dist/avatar-group/llms.txt +7 -4
  17. package/dist/badge/badge.d.ts +8 -1
  18. package/dist/badge/badge.js +3 -2
  19. package/dist/badge/llms.txt +11 -5
  20. package/dist/bottom-sheet/bottom-sheet.d.ts +1 -1
  21. package/dist/bottom-sheet/bottom-sheet.js +8 -2
  22. package/dist/bottom-sheet/llms.txt +8 -2
  23. package/dist/breadcrumb/breadcrumb.js +4 -4
  24. package/dist/breadcrumb/llms.txt +12 -5
  25. package/dist/card/card.d.ts +9 -1
  26. package/dist/card/card.js +2 -2
  27. package/dist/card/llms.txt +6 -2
  28. package/dist/carousel/carousel.d.ts +8 -1
  29. package/dist/carousel/carousel.js +9 -2
  30. package/dist/carousel/llms.txt +24 -10
  31. package/dist/checkbox/checkbox.d.ts +6 -1
  32. package/dist/checkbox/checkbox.js +11 -3
  33. package/dist/checkbox/llms.txt +11 -0
  34. package/dist/chip/chip.js +10 -1
  35. package/dist/chip/llms.txt +7 -4
  36. package/dist/chips-input/chips-input.d.ts +7 -1
  37. package/dist/chips-input/chips-input.js +21 -5
  38. package/dist/chips-input/llms.txt +12 -0
  39. package/dist/color-picker/color-picker.d.ts +18 -5
  40. package/dist/color-picker/color-picker.js +48 -14
  41. package/dist/color-picker/llms.txt +14 -0
  42. package/dist/confirm/confirm-container.d.ts +5 -1
  43. package/dist/confirm/confirm-container.js +15 -1
  44. package/dist/confirm/llms.txt +4 -2
  45. package/dist/context-menu/context-menu.d.ts +5 -0
  46. package/dist/context-menu/context-menu.js +48 -4
  47. package/dist/context-menu/llms.txt +11 -4
  48. package/dist/date-range/date-range.d.ts +14 -1
  49. package/dist/date-range/date-range.js +133 -17
  50. package/dist/date-range/llms.txt +26 -3
  51. package/dist/datepicker/datepicker.d.ts +10 -1
  52. package/dist/datepicker/datepicker.js +147 -14
  53. package/dist/datepicker/llms.txt +27 -3
  54. package/dist/dialog/dialog.d.ts +4 -0
  55. package/dist/dialog/dialog.js +7 -1
  56. package/dist/dialog/llms.txt +2 -0
  57. package/dist/drawer/drawer.js +7 -2
  58. package/dist/drawer/llms.txt +10 -5
  59. package/dist/empty/empty.d.ts +6 -1
  60. package/dist/empty/empty.js +2 -2
  61. package/dist/empty/llms.txt +4 -3
  62. package/dist/fab/llms.txt +2 -1
  63. package/dist/file-upload/file-upload.d.ts +5 -0
  64. package/dist/file-upload/file-upload.js +24 -3
  65. package/dist/file-upload/llms.txt +15 -4
  66. package/dist/form-field/form-field-context.d.ts +30 -1
  67. package/dist/form-field/form-field-context.js +27 -0
  68. package/dist/form-field/form-field.js +16 -2
  69. package/dist/form-field/llms.txt +10 -1
  70. package/dist/galleria/galleria.d.ts +6 -1
  71. package/dist/galleria/galleria.js +40 -1
  72. package/dist/galleria/llms.txt +17 -8
  73. package/dist/icon/icon.d.ts +7 -1
  74. package/dist/icon/icon.js +18 -4
  75. package/dist/icon/llms.txt +5 -2
  76. package/dist/image/image.d.ts +5 -0
  77. package/dist/image/image.js +38 -2
  78. package/dist/image/llms.txt +24 -17
  79. package/dist/input/input.d.ts +1 -1
  80. package/dist/input/input.js +4 -6
  81. package/dist/input/llms.txt +5 -0
  82. package/dist/input-mask/input-mask.d.ts +1 -1
  83. package/dist/input-mask/input-mask.js +8 -6
  84. package/dist/input-mask/llms.txt +11 -0
  85. package/dist/input-number/input-number.d.ts +13 -1
  86. package/dist/input-number/input-number.js +49 -9
  87. package/dist/input-number/llms.txt +21 -3
  88. package/dist/input-otp/input-otp.d.ts +7 -2
  89. package/dist/input-otp/input-otp.js +36 -7
  90. package/dist/input-otp/llms.txt +14 -0
  91. package/dist/internal/calendar-focus.d.ts +8 -0
  92. package/dist/internal/calendar-focus.js +38 -0
  93. package/dist/internal/option-nav.d.ts +24 -0
  94. package/dist/internal/option-nav.js +80 -0
  95. package/dist/internal/roving.d.ts +9 -0
  96. package/dist/internal/roving.js +43 -0
  97. package/dist/internal/use-modal-focus.d.ts +10 -0
  98. package/dist/internal/use-modal-focus.js +57 -0
  99. package/dist/knob/knob.d.ts +5 -1
  100. package/dist/knob/knob.js +4 -2
  101. package/dist/knob/llms.txt +8 -0
  102. package/dist/listbox/listbox.d.ts +13 -1
  103. package/dist/listbox/listbox.js +89 -6
  104. package/dist/listbox/llms.txt +21 -5
  105. package/dist/megamenu/llms.txt +28 -10
  106. package/dist/megamenu/megamenu.d.ts +10 -1
  107. package/dist/megamenu/megamenu.js +116 -5
  108. package/dist/menu/llms.txt +12 -1
  109. package/dist/menu/menu-keys.d.ts +15 -0
  110. package/dist/menu/menu-keys.js +53 -0
  111. package/dist/menu/menu-rows.js +1 -1
  112. package/dist/menu/menu.js +52 -4
  113. package/dist/menubar/llms.txt +24 -7
  114. package/dist/menubar/menubar.d.ts +8 -1
  115. package/dist/menubar/menubar.js +92 -6
  116. package/dist/meter-group/llms.txt +10 -3
  117. package/dist/meter-group/meter-group.d.ts +9 -2
  118. package/dist/meter-group/meter-group.js +8 -2
  119. package/dist/order-list/llms.txt +11 -3
  120. package/dist/order-list/order-list.d.ts +5 -1
  121. package/dist/order-list/order-list.js +46 -8
  122. package/dist/panel/llms.txt +9 -6
  123. package/dist/panel/panel.js +3 -1
  124. package/dist/password/llms.txt +8 -0
  125. package/dist/password/password.d.ts +6 -1
  126. package/dist/password/password.js +6 -5
  127. package/dist/pick-list/llms.txt +12 -3
  128. package/dist/pick-list/pick-list.d.ts +5 -1
  129. package/dist/pick-list/pick-list.js +52 -5
  130. package/dist/popover/llms.txt +9 -2
  131. package/dist/popover/popover.d.ts +9 -3
  132. package/dist/popover/popover.js +41 -6
  133. package/dist/public-api.d.ts +6 -6
  134. package/dist/public-api.js +1 -1
  135. package/dist/radio-group/llms.txt +11 -0
  136. package/dist/radio-group/radio-group.d.ts +9 -1
  137. package/dist/radio-group/radio-group.js +5 -2
  138. package/dist/rating/llms.txt +18 -5
  139. package/dist/rating/rating.d.ts +21 -3
  140. package/dist/rating/rating.js +87 -12
  141. package/dist/rich-text/llms.txt +20 -0
  142. package/dist/rich-text/rich-text.d.ts +7 -1
  143. package/dist/rich-text/rich-text.js +155 -28
  144. package/dist/scroll-top/llms.txt +5 -1
  145. package/dist/scroll-top/scroll-top.js +3 -1
  146. package/dist/select/llms.txt +21 -7
  147. package/dist/select/select.d.ts +12 -1
  148. package/dist/select/select.js +110 -38
  149. package/dist/sidenav/llms.txt +6 -0
  150. package/dist/skeleton/llms.txt +3 -2
  151. package/dist/skeleton/skeleton.d.ts +1 -1
  152. package/dist/skeleton/skeleton.js +2 -2
  153. package/dist/slider/llms.txt +14 -1
  154. package/dist/slider/slider.d.ts +11 -2
  155. package/dist/slider/slider.js +15 -2
  156. package/dist/speed-dial/llms.txt +6 -2
  157. package/dist/speed-dial/speed-dial.d.ts +1 -1
  158. package/dist/speed-dial/speed-dial.js +14 -3
  159. package/dist/spinner/llms.txt +12 -8
  160. package/dist/spinner/spinner.d.ts +5 -1
  161. package/dist/spinner/spinner.js +12 -2
  162. package/dist/split-button/llms.txt +11 -3
  163. package/dist/split-button/split-button.d.ts +3 -1
  164. package/dist/split-button/split-button.js +43 -3
  165. package/dist/splitter/llms.txt +14 -4
  166. package/dist/splitter/splitter.d.ts +9 -2
  167. package/dist/splitter/splitter.js +15 -2
  168. package/dist/stepper/llms.txt +8 -4
  169. package/dist/stepper/stepper.d.ts +3 -2
  170. package/dist/stepper/stepper.js +7 -2
  171. package/dist/switch/llms.txt +10 -0
  172. package/dist/switch/switch.d.ts +1 -1
  173. package/dist/switch/switch.js +4 -2
  174. package/dist/switch-group/llms.txt +11 -0
  175. package/dist/switch-group/switch-group.d.ts +5 -1
  176. package/dist/switch-group/switch-group.js +7 -2
  177. package/dist/tabs/llms.txt +10 -4
  178. package/dist/tabs/tabs.d.ts +4 -0
  179. package/dist/tabs/tabs.js +21 -2
  180. package/dist/textarea/llms.txt +10 -0
  181. package/dist/textarea/textarea.d.ts +3 -1
  182. package/dist/textarea/textarea.js +13 -8
  183. package/dist/toast/llms.txt +21 -7
  184. package/dist/toast/toast-container.d.ts +6 -1
  185. package/dist/toast/toast-container.js +52 -1
  186. package/dist/toast/toast-store.d.ts +12 -0
  187. package/dist/toast/toast-store.js +46 -7
  188. package/dist/tooltip/llms.txt +10 -3
  189. package/dist/tooltip/tooltip.d.ts +5 -0
  190. package/dist/tooltip/tooltip.js +80 -7
  191. package/dist/tree/llms.txt +20 -8
  192. package/dist/tree/tree-keys.d.ts +27 -0
  193. package/dist/tree/tree-keys.js +39 -0
  194. package/dist/tree/tree.d.ts +12 -5
  195. package/dist/tree/tree.js +75 -29
  196. package/package.json +12 -12
@@ -12,6 +12,12 @@ export interface KptInputNumberProps extends Omit<ComponentPropsWithRef<'div'>,
12
12
  suffix?: string;
13
13
  /** Przyciski krokowania. `false` daje samo pole — wariant do zakresów „od–do". */
14
14
  showButtons?: boolean;
15
+ /** Id elementu fokusowalnego (nie hosta); bez niego generowane, w `KptFormField` — z wrappera. */
16
+ id?: string;
17
+ /** Nazwa dostępna, gdy kontrolki nie opisuje etykieta (`<label for>` albo `KptFormField`). */
18
+ ariaLabel?: string | null;
19
+ /** Oznacza pole jako wymagane atrybutem `aria-required`. */
20
+ required?: boolean;
15
21
  disabled?: boolean;
16
22
  invalid?: boolean;
17
23
  touched?: boolean;
@@ -21,8 +27,14 @@ export interface KptInputNumberProps extends Omit<ComponentPropsWithRef<'div'>,
21
27
  * Pole liczbowe ze strzałkami krokowania. Wartość to `number | null` — puste pole ma znaczenie,
22
28
  * bo „brak wartości" to nie to samo co zero.
23
29
  *
30
+ * Pole to `role="spinbutton"` (z `aria-valuemin`/`max`/`now`, a przy `suffix` — `aria-valuetext`):
31
+ * `↑`/`↓` zmieniają wartość o `step`, `PageUp`/`PageDown` o dziesięć kroków. Wpisywana liczba nie
32
+ * jest przycinana w trakcie pisania (inaczej „150” przy `max` 99 stawało się „99”, zanim user skończył) —
33
+ * granice działają przy krokowaniu i na `blur`. Przyciski `−`/`+` stoją poza kolejnością Tab i nie
34
+ * zabierają fokusu polu.
35
+ *
24
36
  * @example
25
37
  * <KptInputNumber value={qty} onValueChange={setQty} min={0} max={99} suffix=" szt." />
26
38
  * <KptInputNumber value={from} onValueChange={setFrom} showButtons={false} suffix=" zł" />
27
39
  */
28
- export declare function KptInputNumber({ value, defaultValue, onValueChange, min, max, step, placeholder, suffix, showButtons, disabled, invalid, touched, onTouch, locale, dictionary, className, ...rest }: Readonly<KptInputNumberProps>): import("react").JSX.Element;
40
+ export declare function KptInputNumber({ value, defaultValue, onValueChange, min, max, step, placeholder, suffix, showButtons, id, ariaLabel, required, disabled, invalid, touched, onTouch, locale, dictionary, className, ...rest }: Readonly<KptInputNumberProps>): import("react").JSX.Element;
@@ -1,25 +1,35 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useCallback } from 'react';
2
+ import { useCallback, useState } from 'react';
3
3
  import { cn } from "../internal/cn.js";
4
4
  import { useControllableState } from "../internal/use-controllable-state.js";
5
5
  import { useKptMessages } from "../i18n/i18n-context.js";
6
6
  import { KptIcon } from "../icon/icon.js";
7
+ import { useKptFieldA11y } from "../form-field/form-field-context.js";
7
8
  /**
8
9
  * Pole liczbowe ze strzałkami krokowania. Wartość to `number | null` — puste pole ma znaczenie,
9
10
  * bo „brak wartości" to nie to samo co zero.
10
11
  *
12
+ * Pole to `role="spinbutton"` (z `aria-valuemin`/`max`/`now`, a przy `suffix` — `aria-valuetext`):
13
+ * `↑`/`↓` zmieniają wartość o `step`, `PageUp`/`PageDown` o dziesięć kroków. Wpisywana liczba nie
14
+ * jest przycinana w trakcie pisania (inaczej „150” przy `max` 99 stawało się „99”, zanim user skończył) —
15
+ * granice działają przy krokowaniu i na `blur`. Przyciski `−`/`+` stoją poza kolejnością Tab i nie
16
+ * zabierają fokusu polu.
17
+ *
11
18
  * @example
12
19
  * <KptInputNumber value={qty} onValueChange={setQty} min={0} max={99} suffix=" szt." />
13
20
  * <KptInputNumber value={from} onValueChange={setFrom} showButtons={false} suffix=" zł" />
14
21
  */
15
- export function KptInputNumber({ value, defaultValue = null, onValueChange, min, max, step = 1, placeholder = '', suffix = '', showButtons = true, disabled = false, invalid = false, touched, onTouch, locale, dictionary, className, ...rest }) {
16
- const { t } = useKptMessages({ locale, dictionary });
22
+ export function KptInputNumber({ value, defaultValue = null, onValueChange, min, max, step = 1, placeholder = '', suffix = '', showButtons = true, id, ariaLabel = null, required = false, disabled = false, invalid = false, touched, onTouch, locale, dictionary, className, ...rest }) {
23
+ const { t, locale: activeLocale } = useKptMessages({ locale, dictionary });
24
+ const field = useKptFieldA11y(id, 'kpt-input-number');
17
25
  const [current, setCurrent] = useControllableState({
18
26
  value,
19
27
  defaultValue,
20
28
  onChange: onValueChange,
21
29
  });
22
30
  const showInvalid = invalid && (touched ?? true);
31
+ /** Tekst w trakcie pisania — dopóki jest, pole pokazuje go zamiast sformatowanej wartości. */
32
+ const [draft, setDraft] = useState(null);
23
33
  const clamp = useCallback((n) => {
24
34
  if (min !== undefined && n < min)
25
35
  return min;
@@ -27,26 +37,56 @@ export function KptInputNumber({ value, defaultValue = null, onValueChange, min,
27
37
  return max;
28
38
  return n;
29
39
  }, [min, max]);
30
- const display = current === null || current === undefined || Number.isNaN(current) ? '' : `${current}${suffix}`;
40
+ // Zapis liczby wg aktywnego języka (PL: `12,5`), bez separatorów tysięcy — pole przyjmuje i kropkę,
41
+ // i przecinek, a grupowanie cyfr rozjechałoby się z tym, co user wpisuje.
42
+ const format = new Intl.NumberFormat(activeLocale, { useGrouping: false, maximumFractionDigits: 20 });
43
+ const display = current === null || current === undefined || Number.isNaN(current) ? '' : `${format.format(current)}${suffix}`;
31
44
  const canIncrement = max === undefined || (current ?? -Infinity) < max;
32
45
  const canDecrement = min === undefined || (current ?? Infinity) > min;
33
46
  const onInput = (event) => {
34
47
  // Przecinek jako separator dziesiętny i dopisek trafiają do pola razem z liczbą —
35
48
  // czyścimy je przed parsowaniem, żeby wpisywanie „12,5 zł" działało naturalnie.
49
+ setDraft(event.target.value);
36
50
  const raw = event.target.value.replace(suffix, '').replace(',', '.').trim();
37
51
  if (raw === '') {
38
52
  setCurrent(null);
39
53
  return;
40
54
  }
55
+ // Bez przycinania: wartość spoza granic zostaje, a walidacja formularza ją zgłosi.
41
56
  const parsed = Number(raw);
42
57
  if (!Number.isNaN(parsed))
43
- setCurrent(clamp(parsed));
58
+ setCurrent(parsed);
44
59
  };
45
- const stepBy = (direction) => {
60
+ const stepBy = (count) => {
46
61
  const base = current ?? min ?? 0;
47
- setCurrent(clamp(base + direction * step));
62
+ // Zaokrąglenie do precyzji kroku — 0.1 + 0.2 nie może dać 0.30000000000000004.
63
+ const decimals = (String(step).split('.')[1] ?? '').length;
64
+ setDraft(null);
65
+ setCurrent(clamp(Number((base + count * step).toFixed(decimals))));
66
+ };
67
+ const onKeyDown = (event) => {
68
+ const steps = { ArrowUp: 1, ArrowDown: -1, PageUp: 10, PageDown: -10 };
69
+ const count = steps[event.key];
70
+ if (count === undefined || disabled)
71
+ return;
72
+ event.preventDefault();
73
+ stepBy(count);
74
+ };
75
+ /** Wyjście z pola: przycięcie do granic i sformatowany zapis. */
76
+ const onBlur = () => {
77
+ if (current !== null && current !== undefined && !Number.isNaN(current)) {
78
+ const clamped = clamp(current);
79
+ if (clamped !== current)
80
+ setCurrent(clamped);
81
+ }
82
+ setDraft(null);
48
83
  onTouch?.();
49
84
  };
50
- const onBlur = (_event) => onTouch?.();
51
- return (_jsx("div", { className: cn('kpt-input-number-host', className), ...rest, children: _jsxs("div", { className: cn('kpt-input-number', disabled && 'kpt-input-number--disabled', showInvalid && 'kpt-input-number--invalid', !showButtons && 'kpt-input-number--plain'), children: [showButtons ? (_jsx("button", { type: "button", className: "kpt-input-number__btn", disabled: disabled || !canDecrement, onClick: () => stepBy(-1), "aria-label": t('inputNumber.decrement'), children: _jsx(KptIcon, { name: "minus" }) })) : null, _jsx("input", { className: "kpt-input-number__input", type: "text", inputMode: "decimal", value: display, disabled: disabled, placeholder: placeholder, onChange: onInput, onBlur: onBlur }), showButtons ? (_jsx("button", { type: "button", className: "kpt-input-number__btn", disabled: disabled || !canIncrement, onClick: () => stepBy(1), "aria-label": t('inputNumber.increment'), children: _jsx(KptIcon, { name: "plus" }) })) : null] }) }));
85
+ return (_jsx("div", { className: cn('kpt-input-number-host', className), ...rest, children: _jsxs("div", { className: cn('kpt-input-number', disabled && 'kpt-input-number--disabled', showInvalid && 'kpt-input-number--invalid', !showButtons && 'kpt-input-number--plain'), children: [showButtons ? (_jsx("button", { type: "button", className: "kpt-input-number__btn", tabIndex: -1, disabled: disabled || !canDecrement, onMouseDown: (event) => event.preventDefault(), onClick: () => {
86
+ stepBy(-1);
87
+ onTouch?.();
88
+ }, "aria-label": t('inputNumber.decrement'), children: _jsx(KptIcon, { name: "minus" }) })) : null, _jsx("input", { className: "kpt-input-number__input", id: field.id, "aria-label": ariaLabel ?? undefined, "aria-required": required || undefined, "aria-invalid": showInvalid || undefined, "aria-describedby": field.describedBy, type: "text", inputMode: "decimal", role: "spinbutton", "aria-valuemin": min, "aria-valuemax": max, "aria-valuenow": current ?? undefined, "aria-valuetext": suffix && current !== null ? display : undefined, value: draft ?? display, disabled: disabled, placeholder: placeholder, onChange: onInput, onKeyDown: onKeyDown, onBlur: onBlur }), showButtons ? (_jsx("button", { type: "button", className: "kpt-input-number__btn", tabIndex: -1, disabled: disabled || !canIncrement, onMouseDown: (event) => event.preventDefault(), onClick: () => {
89
+ stepBy(1);
90
+ onTouch?.();
91
+ }, "aria-label": t('inputNumber.increment'), children: _jsx(KptIcon, { name: "plus" }) })) : null] }) }));
52
92
  }
@@ -27,7 +27,7 @@ nothing to step from.
27
27
  ## A text input, not a number input
28
28
  The field is `type="text"` with `inputmode="decimal"`, so the suffix can live inside it and a comma
29
29
  works as the decimal separator — typing "12,5 zł" parses correctly. A native `type="number"` would
30
- reject both. Values out of range are clamped on input, not rejected.
30
+ reject both. Values out of range are clamped on blur and when stepping — not while typing.
31
31
 
32
32
  ## i18n
33
33
  The stepper labels come from `inputNumber.decrement` and `inputNumber.increment`.
@@ -36,9 +36,27 @@ The stepper labels come from `inputNumber.decrement` and `inputNumber.increment`
36
36
  <KptInputNumber value={qty} onValueChange={setQty} min={0} max={99} />
37
37
  <KptInputNumber value={price} onValueChange={setPrice} showButtons={false} suffix=" zł" />
38
38
 
39
+ ## Keyboard
40
+ The field is `role="spinbutton"` with `aria-valuemin`/`aria-valuemax`/`aria-valuenow` (and
41
+ `aria-valuetext` with the `suffix`, "5 pcs"). `↑`/`↓` change the value by `step`,
42
+ `PageUp`/`PageDown` by ten steps; stepping is rounded to the precision of `step`. A typed value is
43
+ **not** clamped while typing ("150" with `max` 99 stays "150", so the form's `max()` can report it) —
44
+ it is clamped on blur. The `−`/`+` buttons sit outside the Tab order and do not take focus from the
45
+ field, so focus never drops to `<body>` when a button goes disabled at a limit.
46
+
47
+ ## Screen reader and keyboard
48
+ The value is shown in the active locale's notation (PL `12,5`), without thousands separators — the
49
+ field accepts both a dot and a comma, and digit grouping would clash with what the user types.
50
+
51
+ ## Label and description
52
+ `id` goes to the native field, not the host; without one it comes from `KptFormField` or is
53
+ generated. Inside `KptFormField` the wrapper's `label[for]` points at it and the hint or error is
54
+ added to `aria-describedby` (merged with your own). Without a wrapper, name it with `ariaLabel`.
55
+ New: `id`, `ariaLabel`, `required` and `aria-invalid` on the field.
56
+
39
57
  ## Tokens
40
58
  `--kpt-form-field-{bg,text,border,border-focus,border-error,radius}`.
41
59
 
42
60
  ## Accessibility
43
- The stepper buttons carry labels from the dictionary and go `disabled` at the limits, so the
44
- keyboard never lands on a control that cannot do anything.
61
+ The field is a `spinbutton` (see Keyboard). The stepper buttons carry labels from the dictionary,
62
+ sit outside the Tab order and go `disabled` at the limits without stealing focus from the field.
@@ -1,11 +1,16 @@
1
1
  import type { ComponentPropsWithRef } from 'react';
2
- export interface KptInputOtpProps extends Omit<ComponentPropsWithRef<'div'>, 'onChange' | 'defaultValue'> {
2
+ import { type KptI18nOverrides } from '../i18n/i18n-context.tsx';
3
+ export interface KptInputOtpProps extends Omit<ComponentPropsWithRef<'div'>, 'onChange' | 'defaultValue'>, KptI18nOverrides {
3
4
  value?: string;
4
5
  defaultValue?: string;
5
6
  onValueChange?: (value: string) => void;
6
7
  length?: number;
7
8
  /** Dopuszcza wyłącznie cyfry i przełącza klawiaturę mobilną na numeryczną. */
8
9
  integerOnly?: boolean;
10
+ /** Id pierwszej komórki (nie hosta) — klik w etykietę zaczyna wpisywanie od początku. */
11
+ id?: string;
12
+ /** Nazwa grupy komórek, gdy nie opisuje jej etykieta (`KptFormField`). */
13
+ ariaLabel?: string | null;
9
14
  disabled?: boolean;
10
15
  invalid?: boolean;
11
16
  touched?: boolean;
@@ -18,4 +23,4 @@ export interface KptInputOtpProps extends Omit<ComponentPropsWithRef<'div'>, 'on
18
23
  * @example
19
24
  * <KptInputOtp value={code} onValueChange={setCode} length={6} integerOnly />
20
25
  */
21
- export declare function KptInputOtp({ value, defaultValue, onValueChange, length, integerOnly, disabled, invalid, touched, onTouch, className, ...rest }: Readonly<KptInputOtpProps>): import("react").JSX.Element;
26
+ export declare function KptInputOtp({ value, defaultValue, onValueChange, length, integerOnly, id, ariaLabel, disabled, invalid, touched, onTouch, locale, dictionary, className, ...rest }: Readonly<KptInputOtpProps>): import("react").JSX.Element;
@@ -1,6 +1,8 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { cn } from "../internal/cn.js";
3
3
  import { useControllableState } from "../internal/use-controllable-state.js";
4
+ import { useKptFieldA11y } from "../form-field/form-field-context.js";
5
+ import { useKptMessages } from "../i18n/i18n-context.js";
4
6
  /**
5
7
  * Pole kodu jednorazowego (OTP/PIN) — N osobnych pól, automatyczne przejście do następnego
6
8
  * i wklejanie całego kodu naraz. Wartością jest sklejony string.
@@ -8,7 +10,9 @@ import { useControllableState } from "../internal/use-controllable-state.js";
8
10
  * @example
9
11
  * <KptInputOtp value={code} onValueChange={setCode} length={6} integerOnly />
10
12
  */
11
- export function KptInputOtp({ value, defaultValue = '', onValueChange, length = 6, integerOnly = false, disabled = false, invalid = false, touched, onTouch, className, ...rest }) {
13
+ export function KptInputOtp({ value, defaultValue = '', onValueChange, length = 6, integerOnly = false, id, ariaLabel = null, disabled = false, invalid = false, touched, onTouch, locale, dictionary, className, ...rest }) {
14
+ const { t } = useKptMessages({ locale, dictionary });
15
+ const field = useKptFieldA11y(id, 'kpt-input-otp');
12
16
  const [current, setCurrent] = useControllableState({ value, defaultValue, onChange: onValueChange });
13
17
  const showInvalid = invalid && (touched ?? true);
14
18
  const charAt = (index) => current[index] ?? '';
@@ -26,8 +30,31 @@ export function KptInputOtp({ value, defaultValue = '', onValueChange, length =
26
30
  const target = direction === 1 ? el.nextElementSibling : el.previousElementSibling;
27
31
  target?.focus();
28
32
  };
33
+ /** Wpisuje `text` od komórki `start` (bez spacji i — przy `integerOnly` — bez nie-cyfr). */
34
+ const fillFrom = (start, text, el) => {
35
+ let chars = text.replace(/\s/g, '');
36
+ if (integerOnly)
37
+ chars = chars.replace(/\D/g, '');
38
+ const arr = current.padEnd(length, ' ').split('');
39
+ for (let k = 0; k < chars.length && start + k < length; k += 1)
40
+ arr[start + k] = chars[k];
41
+ setCurrent(arr.join('').replace(/ +$/, ''));
42
+ // Fokus na pierwszą pustą komórkę, a przy komplecie — na ostatnią.
43
+ const cells = [...(el.closest('.kpt-input-otp')?.querySelectorAll('input') ?? [])];
44
+ const empty = arr.findIndex((char) => char === ' ');
45
+ cells[empty >= 0 ? empty : cells.length - 1]?.focus();
46
+ };
29
47
  const onInput = (index, event) => {
30
48
  const el = event.target;
49
+ // Kilka znaków naraz to autouzupełnienie kodu z SMS (`one-time-code`) albo wklejenie przez
50
+ // klawiaturę ekranową — rozkładamy je po komórkach od bieżącej. Dwa znaki w zajętej komórce
51
+ // to zwykłe nadpisanie: stary znak + nowy.
52
+ if (el.value.length > 1 && !(el.value.length === 2 && charAt(index))) {
53
+ const text = el.value;
54
+ el.value = charAt(index);
55
+ fillFrom(index, text, el);
56
+ return;
57
+ }
31
58
  let char = el.value.slice(-1);
32
59
  if (integerOnly && !/\d/.test(char))
33
60
  char = '';
@@ -49,11 +76,13 @@ export function KptInputOtp({ value, defaultValue = '', onValueChange, length =
49
76
  };
50
77
  const onPaste = (event) => {
51
78
  event.preventDefault();
52
- let text = event.clipboardData.getData('text');
53
- if (integerOnly)
54
- text = text.replace(/\D/g, '');
55
- setCurrent(text.slice(0, length));
56
- onTouch?.();
79
+ fillFrom(0, event.clipboardData.getData('text'), event.currentTarget);
80
+ };
81
+ /** „Dotknięcie” dopiero przy wyjściu fokusu z całej grupy, nie przy przejściu między komórkami. */
82
+ const onGroupBlur = (event) => {
83
+ const next = event.relatedTarget;
84
+ if (!next || !event.currentTarget.contains(next))
85
+ onTouch?.();
57
86
  };
58
- return (_jsx("div", { className: cn('kpt-input-otp-host', className), ...rest, children: _jsx("div", { className: cn('kpt-input-otp', showInvalid && 'kpt-input-otp--invalid'), children: Array.from({ length }, (_, index) => (_jsx("input", { className: "kpt-input-otp__cell", type: "text", inputMode: integerOnly ? 'numeric' : 'text', maxLength: 1, value: charAt(index), disabled: disabled, onChange: (event) => onInput(index, event), onKeyDown: (event) => onKeyDown(index, event), onPaste: onPaste, onFocus: (event) => event.target.select() }, index))) }) }));
87
+ return (_jsx("div", { className: cn('kpt-input-otp-host', className), ...rest, children: _jsx("div", { className: cn('kpt-input-otp', showInvalid && 'kpt-input-otp--invalid'), role: "group", "aria-label": ariaLabel ?? undefined, "aria-labelledby": ariaLabel ? undefined : field.labelledBy, "aria-describedby": field.describedBy, onBlur: onGroupBlur, children: Array.from({ length }, (_, index) => (_jsx("input", { className: "kpt-input-otp__cell", id: index === 0 ? field.id : undefined, "aria-label": t('inputOtp.cell', { index: index + 1, count: length }), "aria-invalid": showInvalid || undefined, type: "text", inputMode: integerOnly ? 'numeric' : 'text', autoComplete: index === 0 ? 'one-time-code' : 'off', value: charAt(index), disabled: disabled, onChange: (event) => onInput(index, event), onKeyDown: (event) => onKeyDown(index, event), onPaste: onPaste, onFocus: (event) => event.target.select() }, index))) }) }));
59
88
  }
@@ -35,6 +35,20 @@ code at once — with `integerOnly` everything non-numeric is stripped first, so
35
35
  <KptInputOtp value={code} onValueChange={setCode} length={6} integerOnly />
36
36
  <KptInputOtp value={code} onValueChange={setCode} length={4} />
37
37
 
38
+ ## Screen reader and keyboard
39
+ The first cell has `autocomplete="one-time-code"`, so phones offer the code from an SMS; a code that
40
+ arrives in one cell at once (autofill, an on-screen keyboard paste) is spread over the cells like a
41
+ paste. `touch` fires when focus leaves the whole group — not after a paste, and not when moving
42
+ between cells.
43
+
44
+ ## Label and description
45
+ `id` goes to the focusable element, not the host; without one it comes from `KptFormField` or is
46
+ generated. A `div` with a role is not named by `label[for]`, so inside `KptFormField` it gets
47
+ `aria-labelledby` pointing at the wrapper's label (unless `ariaLabel` is set), and the hint or error
48
+ is added to `aria-describedby`. The cells form a named `role="group"`; `id` goes to the first cell
49
+ (clicking the label starts at the beginning), and each cell is named "Character 2 of 6"
50
+ (`inputOtp.cell`).
51
+
38
52
  ## Tokens
39
53
  `--kpt-form-field-{bg,text,border,border-focus,border-error,radius}`.
40
54
 
@@ -0,0 +1,8 @@
1
+ /** Zawija `Tab` / `Shift+Tab` w obrębie `panel`. */
2
+ export declare function trapTab(panel: HTMLElement, event: KeyboardEvent): void;
3
+ /**
4
+ * Fokus na „aktywną” komórkę panelu: dzień `iso`, przystanek siatki komórek, a bez nich — pierwszy
5
+ * element panelu (zegar). Panel w nakładce jest niewidoczny do pierwszego pomiaru, więc wołający
6
+ * odkłada to na `requestAnimationFrame`.
7
+ */
8
+ export declare function focusCalendarActive(panel: HTMLElement | null, iso: string): void;
@@ -0,0 +1,38 @@
1
+ /*
2
+ * Fokus w panelu kalendarza — wspólny dla `KptDatepicker` i `KptDateRange`. Panel stoi
3
+ * w nakładce na końcu strony, więc `Tab` z ostatniego elementu uciekłby poza niego; zawijamy go
4
+ * w obrębie panelu, jak w oknie dialogowym.
5
+ *
6
+ * Bliźniak w porcie Angulara: `shared/calendar-focus.ts` — zmiana zachowania w obu.
7
+ */
8
+ const FOCUSABLE = 'button:not(:disabled):not([tabindex="-1"]), input:not(:disabled), [tabindex="0"]';
9
+ /** Zawija `Tab` / `Shift+Tab` w obrębie `panel`. */
10
+ export function trapTab(panel, event) {
11
+ const items = [...panel.querySelectorAll(FOCUSABLE)].filter((el) => el.offsetParent !== null);
12
+ if (!items.length)
13
+ return;
14
+ const first = items[0];
15
+ const last = items[items.length - 1];
16
+ const active = document.activeElement;
17
+ if (event.shiftKey && (active === first || !panel.contains(active))) {
18
+ event.preventDefault();
19
+ last.focus();
20
+ }
21
+ else if (!event.shiftKey && (active === last || !panel.contains(active))) {
22
+ event.preventDefault();
23
+ first.focus();
24
+ }
25
+ }
26
+ /**
27
+ * Fokus na „aktywną” komórkę panelu: dzień `iso`, przystanek siatki komórek, a bez nich — pierwszy
28
+ * element panelu (zegar). Panel w nakładce jest niewidoczny do pierwszego pomiaru, więc wołający
29
+ * odkłada to na `requestAnimationFrame`.
30
+ */
31
+ export function focusCalendarActive(panel, iso) {
32
+ if (!panel)
33
+ return;
34
+ const target = panel.querySelector(`.kpt-calendar__day[data-iso="${iso}"]`) ??
35
+ panel.querySelector('.kpt-calendar__cell[tabindex="0"]') ??
36
+ panel.querySelector('button:not(:disabled), [tabindex="0"]');
37
+ target?.focus();
38
+ }
@@ -0,0 +1,24 @@
1
+ /** Klawisze, które przesuwają aktywną opcję. */
2
+ export declare const OPTION_NAV_KEYS: Set<string>;
3
+ /**
4
+ * Indeks opcji po klawiszu `key`, z pominięciem wyłączonych (`disabled[i]`). Bez zawijania — na
5
+ * końcu listy strzałka zostaje na miejscu, jak w natywnym `<select>`. `current` = -1 znaczy „nic
6
+ * aktywnego”: `↓` idzie wtedy na pierwszą, `↑` na ostatnią. `null` dla innych klawiszy albo listy
7
+ * bez aktywnych opcji.
8
+ */
9
+ export declare function nextOptionIndex(disabled: readonly boolean[], current: number, key: string): number | null;
10
+ /**
11
+ * Wyszukiwanie po pierwszych literach (type-ahead): pierwsza aktywna opcja zaczynająca się od
12
+ * `query`, licząc od pozycji za `current` i zawijając. Porównanie bez wielkości liter i polskich
13
+ * znaków — „lo” znajduje „Łódź”.
14
+ */
15
+ export declare function typeaheadIndex(labels: readonly string[], disabled: readonly boolean[], current: number, query: string): number | null;
16
+ /** Bufor wpisywanych liter dla type-ahead: zeruje się po chwili bez pisania. */
17
+ export declare class KptTypeahead {
18
+ private buffer;
19
+ private timer;
20
+ /** Dopisuje znak i zwraca całe zapytanie. */
21
+ push(char: string): string;
22
+ }
23
+ /** Czy klawisz to pojedynczy znak do wyszukiwania (bez modyfikatorów skrótów). */
24
+ export declare function isTypeaheadKey(event: KeyboardEvent): boolean;
@@ -0,0 +1,80 @@
1
+ /*
2
+ * Nawigacja klawiaturą po liście opcji z aktywną pozycją (`aria-activedescendant`) — wspólna dla
3
+ * `KptSelect`, `KptListbox` i `KptAutocomplete`. Port Angulara ma bliźniaczy plik
4
+ * `shared/option-nav.ts`. Fokus zostaje na kontrolce; zmienia się tylko indeks aktywnej opcji.
5
+ */
6
+ import { foldMentionText } from '@konce-pt/mention';
7
+ /** Ile pozycji przeskakuje `PageUp` / `PageDown`. */
8
+ const PAGE = 10;
9
+ /** Klawisze, które przesuwają aktywną opcję. */
10
+ export const OPTION_NAV_KEYS = new Set(['ArrowDown', 'ArrowUp', 'Home', 'End', 'PageDown', 'PageUp']);
11
+ /**
12
+ * Indeks opcji po klawiszu `key`, z pominięciem wyłączonych (`disabled[i]`). Bez zawijania — na
13
+ * końcu listy strzałka zostaje na miejscu, jak w natywnym `<select>`. `current` = -1 znaczy „nic
14
+ * aktywnego”: `↓` idzie wtedy na pierwszą, `↑` na ostatnią. `null` dla innych klawiszy albo listy
15
+ * bez aktywnych opcji.
16
+ */
17
+ export function nextOptionIndex(disabled, current, key) {
18
+ const count = disabled.length;
19
+ const enabled = (i) => i >= 0 && i < count && !disabled[i];
20
+ const first = disabled.findIndex((d) => !d);
21
+ if (first < 0 || !OPTION_NAV_KEYS.has(key))
22
+ return null;
23
+ let last = count - 1;
24
+ while (last >= 0 && disabled[last])
25
+ last--;
26
+ const scan = (from, step, fallback) => {
27
+ for (let i = from; i >= 0 && i < count; i += step)
28
+ if (enabled(i))
29
+ return i;
30
+ return fallback;
31
+ };
32
+ switch (key) {
33
+ case 'Home':
34
+ return first;
35
+ case 'End':
36
+ return last;
37
+ case 'ArrowDown':
38
+ return current < 0 ? first : scan(current + 1, 1, current);
39
+ case 'ArrowUp':
40
+ return current < 0 ? last : scan(current - 1, -1, current);
41
+ case 'PageDown':
42
+ return scan(Math.min(count - 1, Math.max(current, 0) + PAGE), -1, last);
43
+ default:
44
+ return scan(Math.max(0, current - PAGE), 1, first);
45
+ }
46
+ }
47
+ /**
48
+ * Wyszukiwanie po pierwszych literach (type-ahead): pierwsza aktywna opcja zaczynająca się od
49
+ * `query`, licząc od pozycji za `current` i zawijając. Porównanie bez wielkości liter i polskich
50
+ * znaków — „lo” znajduje „Łódź”.
51
+ */
52
+ export function typeaheadIndex(labels, disabled, current, query) {
53
+ const q = foldMentionText(query);
54
+ if (!q)
55
+ return null;
56
+ const count = labels.length;
57
+ for (let n = 1; n <= count; n++) {
58
+ const i = (Math.max(current, -1) + n + count) % count;
59
+ if (!disabled[i] && foldMentionText(labels[i]).startsWith(q))
60
+ return i;
61
+ }
62
+ return null;
63
+ }
64
+ /** Bufor wpisywanych liter dla type-ahead: zeruje się po chwili bez pisania. */
65
+ export class KptTypeahead {
66
+ buffer = '';
67
+ timer = null;
68
+ /** Dopisuje znak i zwraca całe zapytanie. */
69
+ push(char) {
70
+ if (this.timer)
71
+ clearTimeout(this.timer);
72
+ this.buffer += char;
73
+ this.timer = setTimeout(() => (this.buffer = ''), 500);
74
+ return this.buffer;
75
+ }
76
+ }
77
+ /** Czy klawisz to pojedynczy znak do wyszukiwania (bez modyfikatorów skrótów). */
78
+ export function isTypeaheadKey(event) {
79
+ return event.key.length === 1 && event.key !== ' ' && !event.ctrlKey && !event.metaKey && !event.altKey;
80
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Indeks elementu, na który ma przejść fokus po klawiszu `key`. `disabled[i]` mówi, czy element
3
+ * `i` jest wyłączony — takie są pomijane. Strzałki zawijają się na końcach, `Home`/`End` idą do
4
+ * pierwszego/ostatniego aktywnego. Zwraca `null` dla klawisza, który nie nawiguje, albo gdy nie ma
5
+ * dokąd przejść.
6
+ */
7
+ export declare function nextRovingIndex(disabled: readonly boolean[], current: number, key: string): number | null;
8
+ /** Pierwszy aktywny indeks od `preferred` — przystanek Tab, gdy wskazany element jest wyłączony. */
9
+ export declare function rovingStop(disabled: readonly boolean[], preferred: number): number;
@@ -0,0 +1,43 @@
1
+ /*
2
+ * Nawigacja strzałkami po poziomym rzędzie z jednym przystankiem Tab („roving tabindex”) —
3
+ * wspólna dla `KptTabs`, `KptMenubar` i `KptMegamenu`. Port Angulara ma bliźniaczy plik
4
+ * `shared/roving.ts`.
5
+ */
6
+ /**
7
+ * Indeks elementu, na który ma przejść fokus po klawiszu `key`. `disabled[i]` mówi, czy element
8
+ * `i` jest wyłączony — takie są pomijane. Strzałki zawijają się na końcach, `Home`/`End` idą do
9
+ * pierwszego/ostatniego aktywnego. Zwraca `null` dla klawisza, który nie nawiguje, albo gdy nie ma
10
+ * dokąd przejść.
11
+ */
12
+ export function nextRovingIndex(disabled, current, key) {
13
+ const count = disabled.length;
14
+ const enabled = (i) => !disabled[i];
15
+ const scan = (from, step) => {
16
+ for (let n = 0, i = from; n < count; n += 1, i = (i + step + count) % count) {
17
+ if (enabled(i))
18
+ return i;
19
+ }
20
+ return null;
21
+ };
22
+ if (count === 0)
23
+ return null;
24
+ switch (key) {
25
+ case 'Home':
26
+ return scan(0, 1);
27
+ case 'End':
28
+ return scan(count - 1, -1);
29
+ case 'ArrowRight':
30
+ return scan((current + 1 + count) % count, 1);
31
+ case 'ArrowLeft':
32
+ return scan((current - 1 + count) % count, -1);
33
+ default:
34
+ return null;
35
+ }
36
+ }
37
+ /** Pierwszy aktywny indeks od `preferred` — przystanek Tab, gdy wskazany element jest wyłączony. */
38
+ export function rovingStop(disabled, preferred) {
39
+ if (preferred >= 0 && preferred < disabled.length && !disabled[preferred])
40
+ return preferred;
41
+ const first = disabled.findIndex((d) => !d);
42
+ return first < 0 ? 0 : first;
43
+ }
@@ -0,0 +1,10 @@
1
+ import { type RefObject } from 'react';
2
+ /**
3
+ * Fokus panelu modalnego (`KptDrawer`, `KptBottomSheet`): przy otwarciu zapamiętuje element
4
+ * z fokusem, przenosi fokus do panelu (pierwszy element z kolejki Tab albo sam panel) i zawija
5
+ * `Tab` / `Shift+Tab` w jego obrębie; przy zamknięciu oddaje fokus zapamiętanemu elementowi.
6
+ * Bez tego `aria-modal="true"` obiecywał czytnikowi okno, z którego Tab po cichu wychodził.
7
+ *
8
+ * Port Angulara robi to samo na pułapce CDK (`shared/modal-focus.ts`) — zmiana zachowania w obu.
9
+ */
10
+ export declare function useModalFocus(open: boolean, panelRef: RefObject<HTMLElement | null>): void;
@@ -0,0 +1,57 @@
1
+ import { useEffect } from 'react';
2
+ /** Elementy z kolejki Tab — na nich zawija się pułapka. */
3
+ const TABBABLE = 'a[href], button:not(:disabled), input:not(:disabled), select:not(:disabled), textarea:not(:disabled), [tabindex]:not([tabindex="-1"])';
4
+ function tabbables(panel) {
5
+ return [...panel.querySelectorAll(TABBABLE)].filter((el) => !el.closest('[hidden], [inert]'));
6
+ }
7
+ /**
8
+ * Fokus panelu modalnego (`KptDrawer`, `KptBottomSheet`): przy otwarciu zapamiętuje element
9
+ * z fokusem, przenosi fokus do panelu (pierwszy element z kolejki Tab albo sam panel) i zawija
10
+ * `Tab` / `Shift+Tab` w jego obrębie; przy zamknięciu oddaje fokus zapamiętanemu elementowi.
11
+ * Bez tego `aria-modal="true"` obiecywał czytnikowi okno, z którego Tab po cichu wychodził.
12
+ *
13
+ * Port Angulara robi to samo na pułapce CDK (`shared/modal-focus.ts`) — zmiana zachowania w obu.
14
+ */
15
+ export function useModalFocus(open, panelRef) {
16
+ useEffect(() => {
17
+ const panel = panelRef.current;
18
+ if (!open || !panel)
19
+ return;
20
+ const active = document.activeElement;
21
+ const returnTo = active instanceof HTMLElement && !panel.contains(active) ? active : null;
22
+ // Klatkę później: panel dostaje `is-open` (i `visibility: visible`) w tym samym commicie,
23
+ // a przeglądarka nie skupi elementu, który w chwili wywołania jest jeszcze niewidoczny.
24
+ const frame = requestAnimationFrame(() => {
25
+ const first = tabbables(panel)[0];
26
+ (first ?? panel).focus();
27
+ });
28
+ const onKeyDown = (event) => {
29
+ if (event.key !== 'Tab')
30
+ return;
31
+ const items = tabbables(panel);
32
+ if (!items.length) {
33
+ event.preventDefault();
34
+ panel.focus();
35
+ return;
36
+ }
37
+ const first = items[0];
38
+ const last = items[items.length - 1];
39
+ const current = document.activeElement;
40
+ if (event.shiftKey && (current === first || !panel.contains(current))) {
41
+ event.preventDefault();
42
+ last.focus();
43
+ }
44
+ else if (!event.shiftKey && (current === last || !panel.contains(current))) {
45
+ event.preventDefault();
46
+ first.focus();
47
+ }
48
+ };
49
+ document.addEventListener('keydown', onKeyDown);
50
+ return () => {
51
+ cancelAnimationFrame(frame);
52
+ document.removeEventListener('keydown', onKeyDown);
53
+ if (returnTo?.isConnected)
54
+ returnTo.focus();
55
+ };
56
+ }, [open, panelRef]);
57
+ }
@@ -13,6 +13,10 @@ export interface KptKnobProps extends Omit<ComponentPropsWithRef<'div'>, 'onChan
13
13
  /** Dopisek za wartością w środku (np. '%'). */
14
14
  suffix?: string;
15
15
  showValue?: boolean;
16
+ /** Id elementu fokusowalnego (nie hosta); bez niego generowane, w `KptFormField` — z wrappera. */
17
+ id?: string;
18
+ /** Nazwa dostępna, gdy kontrolki nie opisuje etykieta (`<label for>` albo `KptFormField`). */
19
+ ariaLabel?: string | null;
16
20
  disabled?: boolean;
17
21
  invalid?: boolean;
18
22
  touched?: boolean;
@@ -24,4 +28,4 @@ export interface KptKnobProps extends Omit<ComponentPropsWithRef<'div'>, 'onChan
24
28
  * @example
25
29
  * <KptKnob value={level} onValueChange={setLevel} min={0} max={100} step={5} suffix="%" />
26
30
  */
27
- export declare function KptKnob({ value, defaultValue, onValueChange, min, max, step, size, stroke, suffix, showValue, disabled, invalid, touched, onTouch, className, ...rest }: Readonly<KptKnobProps>): import("react").JSX.Element;
31
+ export declare function KptKnob({ value, defaultValue, onValueChange, min, max, step, size, stroke, suffix, showValue, id, ariaLabel, disabled, invalid, touched, onTouch, className, ...rest }: Readonly<KptKnobProps>): import("react").JSX.Element;
package/dist/knob/knob.js CHANGED
@@ -2,6 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useRef } from 'react';
3
3
  import { cn } from "../internal/cn.js";
4
4
  import { useControllableState } from "../internal/use-controllable-state.js";
5
+ import { useKptFieldA11y } from "../form-field/form-field-context.js";
5
6
  const TAU = Math.PI * 2;
6
7
  /** Przerwa u dołu — kąt „otwarcia" łuku, żeby początek i koniec były rozróżnialne. */
7
8
  const GAP = Math.PI / 4;
@@ -13,7 +14,8 @@ const SWEEP = TAU - GAP * 2;
13
14
  * @example
14
15
  * <KptKnob value={level} onValueChange={setLevel} min={0} max={100} step={5} suffix="%" />
15
16
  */
16
- export function KptKnob({ value, defaultValue = 0, onValueChange, min = 0, max = 100, step = 1, size = 96, stroke = 10, suffix = '', showValue = true, disabled = false, invalid = false, touched, onTouch, className, ...rest }) {
17
+ export function KptKnob({ value, defaultValue = 0, onValueChange, min = 0, max = 100, step = 1, size = 96, stroke = 10, suffix = '', showValue = true, id, ariaLabel = null, disabled = false, invalid = false, touched, onTouch, className, ...rest }) {
18
+ const field = useKptFieldA11y(id, 'kpt-knob');
17
19
  const svgRef = useRef(null);
18
20
  const [current, setCurrent] = useControllableState({ value, defaultValue, onChange: onValueChange });
19
21
  const showInvalid = invalid && (touched ?? true);
@@ -82,5 +84,5 @@ export function KptKnob({ value, defaultValue = 0, onValueChange, min = 0, max =
82
84
  setCurrent(Math.min(max, Math.max(min, next)));
83
85
  onTouch?.();
84
86
  };
85
- return (_jsx("div", { className: cn('kpt-knob-host', className), ...rest, children: _jsx("div", { className: cn('kpt-knob', showInvalid && 'kpt-knob--invalid'), role: "slider", tabIndex: disabled ? -1 : 0, "aria-valuemin": min, "aria-valuemax": max, "aria-valuenow": current, "aria-disabled": disabled || undefined, 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] }) }) }));
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] }) }) }));
86
88
  }
@@ -36,6 +36,14 @@ on `pointerup`, which is also where `onTouch` fires — one touch per drag, not
36
36
  <KptKnob value={level} onValueChange={setLevel} min={0} max={100} step={5} suffix="%" />
37
37
  <KptKnob value={level} onValueChange={setLevel} size={64} stroke={6} showValue={false} />
38
38
 
39
+ ## Label and description
40
+ `id` goes to the focusable element, not the host; without one it comes from `KptFormField` or is
41
+ generated. A `div` with a role is not named by `label[for]`, so inside `KptFormField` it gets
42
+ `aria-labelledby` pointing at the wrapper's label (unless `ariaLabel` is set), and the hint or error
43
+ is added to `aria-describedby`. New: `id`, `ariaLabel`; `aria-valuetext` includes `suffix` ("40%"),
44
+ and a disabled knob leaves the Tab order. Use `ariaLabel`, not `aria-label` on the host — that one
45
+ lands on the wrapper.
46
+
39
47
  ## Tokens
40
48
  The background arc `--kpt-color-border`, the value `--kpt-color-primary`, the text
41
49
  `--kpt-color-on-surface`.