@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
@@ -8,6 +8,10 @@ export interface KptSelectProps<T> extends Omit<ComponentPropsWithRef<'div'>, 'c
8
8
  multiple?: boolean;
9
9
  /** Pole wyszukiwania nad listą. */
10
10
  filter?: boolean;
11
+ /** Id wyzwalacza; bez niego generowane, w `KptFormField` — z wrappera. */
12
+ id?: string;
13
+ /** Oznacza pole jako wymagane atrybutem `aria-required`. */
14
+ required?: boolean;
11
15
  disabled?: boolean;
12
16
  invalid?: boolean;
13
17
  touched?: boolean;
@@ -26,6 +30,13 @@ export interface KptSelectProps<T> extends Omit<ComponentPropsWithRef<'div'>, 'c
26
30
  * Lista wyboru (select) na własnym panelu. Warianty: pojedynczy, z wyszukiwarką (`filter`)
27
31
  * i wielokrotny (`multiple`, chipy).
28
32
  *
33
+ * Klawiatura według wzorca WAI-ARIA „select-only combobox”: fokus zostaje na polu, a aktywna opcja
34
+ * idzie przez `aria-activedescendant`. `↓`/`↑`/`Enter`/`Spacja` otwierają listę na wybranej opcji,
35
+ * strzałki, `Home`/`End`, `PageUp`/`PageDown` chodzą po opcjach (z pominięciem wyłączonych),
36
+ * wpisywane litery skaczą do pasującej opcji (bez polskich znaków), `Enter` wybiera, `Escape`
37
+ * zamyka. Z `filter` fokus przechodzi do pola wyszukiwania, które obsługuje te same klawisze.
38
+ * W trybie `multiple` `Backspace` na zamkniętym polu usuwa ostatni wybór.
39
+ *
29
40
  * Wewnątrz `KptFormField` zgłasza wypełnienie hookiem `useKptFormFieldFilled`, więc etykieta
30
41
  * Float Label unosi się nad wybraną wartością — natywnego pola, z którego wrapper mógłby to
31
42
  * odczytać, tu nie ma.
@@ -34,4 +45,4 @@ export interface KptSelectProps<T> extends Omit<ComponentPropsWithRef<'div'>, 'c
34
45
  * <KptSelect options={roles} value={role} onValueChange={setRole} placeholder="Wybierz…" />
35
46
  * <KptSelect options={tags} multiple filter value={tags} onValueChange={setTags} />
36
47
  */
37
- export declare function KptSelect<T>({ options, placeholder, multiple, filter, disabled, invalid, touched, value, defaultValue, onValueChange, open, defaultOpen, onOpenChange, onTouch, locale, dictionary, className, onBlur, ...rest }: Readonly<KptSelectProps<T>>): import("react").JSX.Element;
48
+ export declare function KptSelect<T>({ options, placeholder, multiple, filter, disabled, 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;
@@ -3,13 +3,22 @@ import { useCallback, useEffect, useMemo, useRef, useState, } from 'react';
3
3
  import { cn } from "../internal/cn.js";
4
4
  import { KptOverlay } from "../internal/overlay.js";
5
5
  import { useControllableState } from "../internal/use-controllable-state.js";
6
+ import { useKptId } from "../internal/use-id.js";
7
+ import { KptTypeahead, OPTION_NAV_KEYS, isTypeaheadKey, nextOptionIndex, typeaheadIndex } from "../internal/option-nav.js";
6
8
  import { useKptMessages } from "../i18n/i18n-context.js";
7
- import { useKptFormFieldFilled } from "../form-field/form-field-context.js";
9
+ import { joinIds, useKptFieldA11y, useKptFormFieldFilled } from "../form-field/form-field-context.js";
8
10
  import { KptIcon } from "../icon/icon.js";
9
11
  /**
10
12
  * Lista wyboru (select) na własnym panelu. Warianty: pojedynczy, z wyszukiwarką (`filter`)
11
13
  * i wielokrotny (`multiple`, chipy).
12
14
  *
15
+ * Klawiatura według wzorca WAI-ARIA „select-only combobox”: fokus zostaje na polu, a aktywna opcja
16
+ * idzie przez `aria-activedescendant`. `↓`/`↑`/`Enter`/`Spacja` otwierają listę na wybranej opcji,
17
+ * strzałki, `Home`/`End`, `PageUp`/`PageDown` chodzą po opcjach (z pominięciem wyłączonych),
18
+ * wpisywane litery skaczą do pasującej opcji (bez polskich znaków), `Enter` wybiera, `Escape`
19
+ * zamyka. Z `filter` fokus przechodzi do pola wyszukiwania, które obsługuje te same klawisze.
20
+ * W trybie `multiple` `Backspace` na zamkniętym polu usuwa ostatni wybór.
21
+ *
13
22
  * Wewnątrz `KptFormField` zgłasza wypełnienie hookiem `useKptFormFieldFilled`, więc etykieta
14
23
  * Float Label unosi się nad wybraną wartością — natywnego pola, z którego wrapper mógłby to
15
24
  * odczytać, tu nie ma.
@@ -18,9 +27,16 @@ import { KptIcon } from "../icon/icon.js";
18
27
  * <KptSelect options={roles} value={role} onValueChange={setRole} placeholder="Wybierz…" />
19
28
  * <KptSelect options={tags} multiple filter value={tags} onValueChange={setTags} />
20
29
  */
21
- export function KptSelect({ options = [], placeholder = '', multiple = false, filter = false, disabled = false, invalid = false, touched, value, defaultValue = null, onValueChange, open, defaultOpen = false, onOpenChange, onTouch, locale, dictionary, className, onBlur, ...rest }) {
30
+ export function KptSelect({ options = [], placeholder = '', multiple = false, filter = false, disabled = 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 }) {
31
+ /** `div` z rolą nie dostaje nazwy z `label[for]` — stąd `aria-labelledby` na etykietę wrappera. */
32
+ const field = useKptFieldA11y(id, 'kpt-select-trigger');
22
33
  const { t } = useKptMessages({ locale, dictionary });
23
34
  const triggerRef = useRef(null);
35
+ const searchRef = useRef(null);
36
+ const typeahead = useRef(new KptTypeahead());
37
+ const uid = useKptId(undefined, 'kpt-select');
38
+ const listId = `${uid}-list`;
39
+ const optionId = (index) => `${uid}-opt-${index}`;
24
40
  const [current, setCurrent] = useControllableState({
25
41
  value,
26
42
  defaultValue,
@@ -33,14 +49,6 @@ export function KptSelect({ options = [], placeholder = '', multiple = false, fi
33
49
  });
34
50
  const [filterText, setFilterText] = useState('');
35
51
  const [activeIndex, setActiveIndex] = useState(-1);
36
- // Panel może zostać otwarty także z zewnątrz (`open`), nie tylko klikiem w trigger —
37
- // filtr i aktywną pozycję resetujemy więc przy każdym otwarciu, nie przy przełączaniu.
38
- useEffect(() => {
39
- if (!isOpen)
40
- return;
41
- setFilterText('');
42
- setActiveIndex(-1);
43
- }, [isOpen]);
44
52
  const showInvalid = invalid && (touched ?? true);
45
53
  const selectedValues = useMemo(() => {
46
54
  if (multiple)
@@ -48,19 +56,47 @@ export function KptSelect({ options = [], placeholder = '', multiple = false, fi
48
56
  return current == null ? [] : [current];
49
57
  }, [current, multiple]);
50
58
  const selectedOptions = useMemo(() => options.filter((option) => selectedValues.includes(option.value)), [options, selectedValues]);
51
- const filteredOptions = useMemo(() => {
52
- const q = filterText.trim().toLowerCase();
59
+ const filterOptions = (text) => {
60
+ const q = text.trim().toLowerCase();
53
61
  return q ? options.filter((option) => option.label.toLowerCase().includes(q)) : options;
54
- }, [options, filterText]);
62
+ };
63
+ const filteredOptions = useMemo(() => filterOptions(filterText), [options, filterText]); // eslint-disable-line react-hooks/exhaustive-deps
64
+ const disabledMap = filteredOptions.map((option) => !!option.disabled);
55
65
  const filled = selectedOptions.length > 0;
56
66
  useKptFormFieldFilled(filled);
57
67
  const isSelected = (option) => selectedValues.includes(option.value);
68
+ // Panel może zostać otwarty także z zewnątrz (`open`), nie tylko z pola — filtr i aktywną
69
+ // pozycję ustawiamy więc przy każdym otwarciu. Lista otwiera się na wybranej opcji; z `filter`
70
+ // fokus idzie do wyszukiwarki (klatkę później: `KptOverlay` mierzy panel w `visibility: hidden`).
71
+ // Tylko na otwarcie — zmiana wyboru w trybie `multiple` nie może przesuwać aktywnej opcji.
72
+ useEffect(() => {
73
+ if (!isOpen)
74
+ return;
75
+ setFilterText('');
76
+ const selected = options.findIndex((option) => selectedValues.includes(option.value));
77
+ const firstEnabled = nextOptionIndex(options.map((o) => !!o.disabled), -1, 'ArrowDown') ?? -1;
78
+ setActiveIndex(selected >= 0 ? selected : firstEnabled);
79
+ if (!filter)
80
+ return;
81
+ const frame = requestAnimationFrame(() => searchRef.current?.focus());
82
+ return () => cancelAnimationFrame(frame);
83
+ }, [isOpen]); // eslint-disable-line react-hooks/exhaustive-deps
84
+ // Aktywna opcja zawsze w polu widzenia listy.
85
+ useEffect(() => {
86
+ if (isOpen && activeIndex >= 0)
87
+ document.getElementById(`${uid}-opt-${activeIndex}`)?.scrollIntoView({ block: 'nearest' });
88
+ }, [isOpen, activeIndex, uid]);
58
89
  const close = useCallback(() => {
59
90
  if (!isOpen)
60
91
  return;
61
92
  setOpen(false);
62
93
  onTouch?.();
63
94
  }, [isOpen, setOpen, onTouch]);
95
+ /** Zamknięcie z powrotem fokusu na pole — po wyborze, `Escape`, `Tab` w wyszukiwarce. */
96
+ const closeAndFocus = () => {
97
+ close();
98
+ triggerRef.current?.focus();
99
+ };
64
100
  const pick = (option) => {
65
101
  if (option.disabled)
66
102
  return;
@@ -69,49 +105,85 @@ export function KptSelect({ options = [], placeholder = '', multiple = false, fi
69
105
  }
70
106
  else {
71
107
  setCurrent(option.value);
72
- close();
108
+ closeAndFocus();
73
109
  }
74
110
  };
75
- const onTriggerKeyDown = (event) => {
111
+ const jumpTo = (char) => {
112
+ const labels = filteredOptions.map((option) => option.label);
113
+ const found = typeaheadIndex(labels, disabledMap, activeIndex, typeahead.current.push(char));
114
+ if (found !== null)
115
+ setActiveIndex(found);
116
+ };
117
+ /** Jeden obsługiwacz dla pola i dla wyszukiwarki w panelu — różnią się tylko `Spacją` i `Tab`. */
118
+ const onKeyDown = (event, source) => {
76
119
  if (disabled)
77
120
  return;
78
- if (event.key === 'ArrowDown' || event.key === 'Enter' || event.key === ' ') {
79
- event.preventDefault();
80
- if (!isOpen)
121
+ const key = event.key;
122
+ if (!isOpen) {
123
+ if (['ArrowDown', 'ArrowUp', 'Enter', ' ', 'Home', 'End'].includes(key)) {
124
+ event.preventDefault();
81
125
  setOpen(true);
126
+ if (key === 'ArrowUp' || key === 'End' || key === 'Home') {
127
+ // Efekt otwarcia ustawia wybraną opcję; skrajne klawisze nadpisują to klatkę później.
128
+ const target = nextOptionIndex(disabledMap, -1, key === 'Home' ? 'Home' : 'End');
129
+ requestAnimationFrame(() => {
130
+ if (target !== null)
131
+ setActiveIndex(target);
132
+ });
133
+ }
134
+ }
135
+ else if ((key === 'Backspace' || key === 'Delete') && multiple && selectedValues.length) {
136
+ event.preventDefault();
137
+ setCurrent(selectedValues.slice(0, -1));
138
+ }
139
+ else if (isTypeaheadKey(event.nativeEvent) && !filter) {
140
+ setOpen(true);
141
+ jumpTo(key);
142
+ }
143
+ return;
82
144
  }
83
- else if (event.key === 'Escape') {
84
- close();
85
- }
86
- };
87
- const onPanelKeyDown = (event) => {
88
- if (event.key === 'Escape') {
145
+ if (key === 'Escape') {
89
146
  event.preventDefault();
90
- close();
147
+ closeAndFocus();
91
148
  }
92
- else if (event.key === 'ArrowDown') {
93
- event.preventDefault();
94
- setActiveIndex((index) => Math.min(index + 1, filteredOptions.length - 1));
149
+ else if (key === 'Tab') {
150
+ // Z wyszukiwarki (w portalu na końcu strony) Tab uciekłby w próżnię — wracamy na pole.
151
+ if (source === 'search') {
152
+ event.preventDefault();
153
+ closeAndFocus();
154
+ }
155
+ else {
156
+ close();
157
+ }
95
158
  }
96
- else if (event.key === 'ArrowUp') {
159
+ else if (OPTION_NAV_KEYS.has(key)) {
97
160
  event.preventDefault();
98
- setActiveIndex((index) => Math.max(index - 1, 0));
161
+ const next = nextOptionIndex(disabledMap, activeIndex, key);
162
+ if (next !== null)
163
+ setActiveIndex(next);
99
164
  }
100
- else if (event.key === 'Enter') {
165
+ else if (key === 'Enter' || (key === ' ' && source === 'trigger')) {
101
166
  event.preventDefault();
102
167
  const option = filteredOptions[activeIndex];
103
- if (option)
168
+ if (option && !option.disabled)
104
169
  pick(option);
105
170
  }
171
+ else if (source === 'trigger' && isTypeaheadKey(event.nativeEvent)) {
172
+ jumpTo(key);
173
+ }
106
174
  };
107
- return (_jsxs("span", { className: "kpt-select-host", children: [_jsxs("div", { ref: triggerRef, className: cn('kpt-select', isOpen && 'kpt-select--open', disabled && 'kpt-select--disabled', showInvalid && 'kpt-select--invalid', className), role: "combobox", tabIndex: 0, "aria-expanded": isOpen, "aria-disabled": disabled || undefined, onClick: () => !disabled && setOpen(!isOpen), onKeyDown: onTriggerKeyDown, onBlur: (event) => {
175
+ return (_jsxs("span", { className: "kpt-select-host", children: [_jsxs("div", { ref: triggerRef, className: cn('kpt-select', isOpen && 'kpt-select--open', disabled && 'kpt-select--disabled', showInvalid && 'kpt-select--invalid', className), role: "combobox", id: field.id, tabIndex: 0, "aria-haspopup": "listbox", "aria-label": ariaLabel, "aria-labelledby": ariaLabel ? undefined : field.labelledBy, "aria-describedby": joinIds(ariaDescribedBy, field.describedBy), "aria-required": required || undefined, "aria-expanded": isOpen, "aria-controls": isOpen ? listId : undefined, "aria-activedescendant": isOpen && !filter && activeIndex >= 0 ? optionId(activeIndex) : undefined, "aria-disabled": disabled || undefined, "aria-invalid": showInvalid || undefined, onClick: () => !disabled && setOpen(!isOpen), onKeyDown: (event) => onKeyDown(event, 'trigger'), onBlur: (event) => {
108
176
  onBlur?.(event);
109
- onTouch?.();
110
- }, ...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", "aria-label": t('select.removeChip'), onClick: (event) => {
177
+ // Przy otwartym panelu fokus idzie do pola szukania — „dotknięcie” wyśle dopiero zamknięcie.
178
+ if (!isOpen)
179
+ onTouch?.();
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) => {
111
181
  event.stopPropagation();
112
182
  setCurrent(selectedValues.filter((v) => v !== option.value));
113
- }, 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", onKeyDown: onPanelKeyDown, children: [filter ? (_jsxs("div", { className: "kpt-select__search", children: [_jsx(KptIcon, { name: "search" }), _jsx("input", { type: "text", className: "kpt-select__search-input", value: filterText, placeholder: t('select.search'), autoFocus: true, 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, 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) => {
114
184
  setFilterText(event.target.value);
115
- setActiveIndex(-1);
116
- } })] })) : null, _jsx("ul", { className: "kpt-select__list", role: "listbox", "aria-multiselectable": multiple, children: filteredOptions.length ? (filteredOptions.map((option, index) => (_jsxs("li", { className: cn('kpt-select__option', isSelected(option) && 'kpt-select__option--selected', index === activeIndex && 'kpt-select__option--active', option.disabled && 'kpt-select__option--disabled'), role: "option", "aria-selected": isSelected(option), onClick: () => pick(option), onMouseEnter: () => setActiveIndex(index), children: [multiple ? (_jsx("span", { className: cn('kpt-select__check', isSelected(option) && 'is-on'), children: _jsx(KptIcon, { name: "check" }) })) : null, _jsx("span", { className: "kpt-select__option-label", children: option.label }), !multiple && isSelected(option) ? (_jsx(KptIcon, { className: "kpt-select__option-tick", name: "check" })) : null] }, String(option.value))))) : (_jsx("li", { className: "kpt-select__empty", children: t('select.noResults') })) })] }) })] }));
185
+ // Po zawężeniu aktywna jest pierwsza pasująca opcja — `Enter` wybiera ją od razu.
186
+ const narrowed = filterOptions(event.target.value).map((o) => !!o.disabled);
187
+ setActiveIndex(nextOptionIndex(narrowed, -1, 'ArrowDown') ?? -1);
188
+ } })] })) : null, _jsx("ul", { className: "kpt-select__list", role: "listbox", id: listId, "aria-multiselectable": multiple, "aria-label": ariaLabel, "aria-labelledby": ariaLabel ? undefined : field.labelledBy, children: filteredOptions.length ? (filteredOptions.map((option, index) => (_jsxs("li", { id: optionId(index), className: cn('kpt-select__option', isSelected(option) && 'kpt-select__option--selected', index === activeIndex && 'kpt-select__option--active', option.disabled && 'kpt-select__option--disabled'), role: "option", "aria-selected": isSelected(option), "aria-disabled": option.disabled || undefined, onMouseDown: (event) => event.preventDefault(), onClick: () => pick(option), onMouseEnter: () => setActiveIndex(index), children: [multiple ? (_jsx("span", { className: cn('kpt-select__check', isSelected(option) && 'is-on'), children: _jsx(KptIcon, { name: "check" }) })) : null, _jsx("span", { className: "kpt-select__option-label", children: option.label }), !multiple && isSelected(option) ? (_jsx(KptIcon, { className: "kpt-select__option-tick", name: "check" })) : null] }, String(option.value))))) : (_jsx("li", { className: "kpt-select__empty", role: "status", children: t('select.noResults') })) })] }) })] }));
117
189
  }
@@ -44,6 +44,12 @@ attribute names in CSS selectors are case-insensitive in HTML, so one rule serve
44
44
  the components rather than writing the attribute by hand — JSX rejects an unknown camelCase
45
45
  attribute, and a hand-written one is easy to misspell.
46
46
 
47
+ ## Links
48
+ Links in the scroll area are `display: flex` with a gap, so an icon and a label never glue together
49
+ even when the template compiler drops the whitespace between them. A link with
50
+ `aria-current="page"` gets the muted background and a medium weight — mark the current page
51
+ yourself, the component does not know which one it is.
52
+
47
53
  ## Tokens
48
54
  `--kpt-sidenav-bg`, `--kpt-sidenav-fg`, `--kpt-sidenav-border`, plus `--kpt-sidenav-width` and
49
55
  `--kpt-sidenav-rail-width`, which are read by `KptAppShell`.
@@ -17,7 +17,8 @@ Import: `import { KptSkeleton } from '@konce-pt/react';`
17
17
 
18
18
  ## The last line is shorter
19
19
  In the `text` variant the last line is 60% wide. A stack of full-width rectangles does not read as
20
- a paragraph, and a paragraph is exactly what it stands in for.
20
+ a paragraph, and a paragraph is exactly what it stands in for. A single line (`lines={1}`) keeps
21
+ the full `width` — there is no paragraph to imitate.
21
22
 
22
23
  ## Examples
23
24
  <KptSkeleton variant="text" lines={3} />
@@ -25,7 +26,7 @@ a paragraph, and a paragraph is exactly what it stands in for.
25
26
  <KptSkeleton variant="rect" width="100%" height="8rem" />
26
27
 
27
28
  ## Tokens
28
- `--kpt-color-muted` and `--kpt-color-surface` for the shimmer gradient, `--kpt-radius-md`.
29
+ The base `--kpt-color-muted`; the shimmer is a lighter band mixed with `--kpt-color-surface`; corners `--kpt-radius-md` (blocks) / `--kpt-radius-sm` (text lines).
29
30
 
30
31
  ## Accessibility
31
32
  The host is `aria-hidden="true"` — a placeholder has nothing to announce, and reading out empty
@@ -11,7 +11,7 @@ export interface KptSkeletonProps extends Omit<ComponentPropsWithRef<'span'>, 'c
11
11
  * Placeholder ładowania z animacją shimmer.
12
12
  *
13
13
  * Wariant `text` skraca ostatnią linię do 60% — pełny prostokąt nie wygląda jak akapit, a to
14
- * właśnie akapit ma udawać.
14
+ * właśnie akapit ma udawać. Przy jednej linii nie ma czego udawać, więc ta ma pełną szerokość.
15
15
  *
16
16
  * @example
17
17
  * <KptSkeleton variant="text" lines={3} />
@@ -4,12 +4,12 @@ import { cn } from "../internal/cn.js";
4
4
  * Placeholder ładowania z animacją shimmer.
5
5
  *
6
6
  * Wariant `text` skraca ostatnią linię do 60% — pełny prostokąt nie wygląda jak akapit, a to
7
- * właśnie akapit ma udawać.
7
+ * właśnie akapit ma udawać. Przy jednej linii nie ma czego udawać, więc ta ma pełną szerokość.
8
8
  *
9
9
  * @example
10
10
  * <KptSkeleton variant="text" lines={3} />
11
11
  * <KptSkeleton variant="circle" width="3rem" height="3rem" />
12
12
  */
13
13
  export function KptSkeleton({ variant = 'rect', width = '100%', height = '1rem', lines = 3, className, ...rest }) {
14
- return (_jsx("span", { className: cn('kpt-skeleton-host', className), "aria-hidden": "true", ...rest, children: variant === 'text' ? (Array.from({ length: lines }, (_, index) => (_jsx("span", { className: "kpt-skeleton kpt-skeleton--text", style: { width: index === lines - 1 ? '60%' : width } }, index)))) : (_jsx("span", { className: "kpt-skeleton", "data-variant": variant, style: { width, height } })) }));
14
+ return (_jsx("span", { className: cn('kpt-skeleton-host', className), "aria-hidden": "true", ...rest, children: variant === 'text' ? (Array.from({ length: lines }, (_, index) => (_jsx("span", { className: "kpt-skeleton kpt-skeleton--text", style: { width: lines > 1 && index === lines - 1 ? '60%' : width } }, index)))) : (_jsx("span", { className: "kpt-skeleton", "data-variant": variant, style: { width, height } })) }));
15
15
  }
@@ -16,7 +16,7 @@ Import: `import { KptSlider } from '@konce-pt/react';`
16
16
  <div class="kpt-slider__rail"></div>
17
17
  <div class="kpt-slider__fill" style="left: 20%; right: 20%"></div>
18
18
  <input class="kpt-slider__thumb kpt-slider__thumb--low" type="range" aria-label="Minimum">
19
- <input class="kpt-slider__thumb kpt-slider__thumb--high" type="range" aria-label="Maksimum">
19
+ <input class="kpt-slider__thumb kpt-slider__thumb--high" type="range" aria-label="Maximum">
20
20
  </div>
21
21
  <span class="kpt-slider__value">20–80</span>
22
22
  </div>
@@ -42,6 +42,19 @@ pair therefore always stays ordered, so the fill never inverts.
42
42
  <KptSlider value={volume} onValueChange={(v) => setVolume(v as number)} showValue />
43
43
  <KptSlider range value={span} onValueChange={(v) => setSpan(v as [number, number])} showValue />
44
44
 
45
+ ## Screen reader and keyboard
46
+ `suffix`: string (" zł", "%", " km") — shown after the value and put into `aria-valuetext` ("40
47
+ zł"), so the unit is read too. `aria-invalid` after touch.
48
+
49
+ ## Label and description
50
+ `id` goes to the native field, not the host; without one it comes from `KptFormField` or is
51
+ generated. Inside `KptFormField` the wrapper's `label[for]` points at it and the hint or error is
52
+ added to `aria-describedby` (merged with your own). Without a wrapper, name it with `ariaLabel`.
53
+ With `range` the thumbs are named "Minimum"/"Maximum" from i18n (`slider.min`/`slider.max`,
54
+ previously hard-coded and mixed-language); inside `kpt-form-field` each thumb reads "<label>
55
+ Minimum" through `aria-labelledby`, without one `ariaLabel` gives "<ariaLabel>, Minimum". `id` goes
56
+ to the single input or the lower thumb.
57
+
45
58
  ## Tokens
46
59
  `--kpt-color-primary` (the fill and the handles), `--kpt-color-muted` (the rail).
47
60
 
@@ -1,7 +1,8 @@
1
1
  import type { ComponentPropsWithRef } from 'react';
2
+ import { type KptI18nOverrides } from '../i18n/i18n-context.tsx';
2
3
  /** Wartość suwaka: pojedyncza liczba albo para `[min, max]` w trybie `range`. */
3
4
  export type KptSliderValue = number | [number, number];
4
- export interface KptSliderProps extends Omit<ComponentPropsWithRef<'div'>, 'onChange' | 'defaultValue'> {
5
+ export interface KptSliderProps extends Omit<ComponentPropsWithRef<'div'>, 'onChange' | 'defaultValue'>, KptI18nOverrides {
5
6
  value?: KptSliderValue;
6
7
  defaultValue?: KptSliderValue;
7
8
  onValueChange?: (value: KptSliderValue) => void;
@@ -12,6 +13,14 @@ export interface KptSliderProps extends Omit<ComponentPropsWithRef<'div'>, 'onCh
12
13
  step?: number;
13
14
  /** Pokazuje bieżącą wartość obok suwaka. */
14
15
  showValue?: boolean;
16
+ /** Jednostka za liczbą („ zł”, „%”, „ km”) — w pokazanej wartości i w `aria-valuetext`. */
17
+ suffix?: string;
18
+ invalid?: boolean;
19
+ touched?: boolean;
20
+ /** Id elementu fokusowalnego (nie hosta); bez niego generowane, w `KptFormField` — z wrappera. */
21
+ id?: string;
22
+ /** Nazwa dostępna, gdy kontrolki nie opisuje etykieta (`<label for>` albo `KptFormField`). */
23
+ ariaLabel?: string | null;
15
24
  disabled?: boolean;
16
25
  onTouch?: () => void;
17
26
  }
@@ -25,4 +34,4 @@ export interface KptSliderProps extends Omit<ComponentPropsWithRef<'div'>, 'onCh
25
34
  * <KptSlider value={volume} onValueChange={setVolume} min={0} max={100} step={5} showValue />
26
35
  * <KptSlider range value={range} onValueChange={setRange} showValue />
27
36
  */
28
- export declare function KptSlider({ value, defaultValue, onValueChange, range, min, max, step, showValue, disabled, onTouch, className, ...rest }: Readonly<KptSliderProps>): import("react").JSX.Element;
37
+ export declare function KptSlider({ value, defaultValue, onValueChange, range, min, max, step, showValue, suffix, invalid, touched, id, ariaLabel, disabled, onTouch, locale, dictionary, className, ...rest }: Readonly<KptSliderProps>): import("react").JSX.Element;
@@ -1,6 +1,8 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { cn } from "../internal/cn.js";
3
3
  import { useControllableState } from "../internal/use-controllable-state.js";
4
+ import { joinIds, useKptFieldA11y } from "../form-field/form-field-context.js";
5
+ import { useKptMessages } from "../i18n/i18n-context.js";
4
6
  /**
5
7
  * Suwak wartości. W trybie `range` udostępnia dwa uchwyty, a wartością jest para `[min, max]`.
6
8
  *
@@ -11,7 +13,18 @@ import { useControllableState } from "../internal/use-controllable-state.js";
11
13
  * <KptSlider value={volume} onValueChange={setVolume} min={0} max={100} step={5} showValue />
12
14
  * <KptSlider range value={range} onValueChange={setRange} showValue />
13
15
  */
14
- export function KptSlider({ value, defaultValue, onValueChange, range = false, min = 0, max = 100, step = 1, showValue = false, disabled = false, onTouch, className, ...rest }) {
16
+ export function KptSlider({ value, defaultValue, onValueChange, range = false, min = 0, max = 100, step = 1, showValue = false, suffix = '', invalid = false, touched, id, ariaLabel = null, disabled = false, onTouch, locale, dictionary, className, ...rest }) {
17
+ const { t } = useKptMessages({ locale, dictionary });
18
+ const field = useKptFieldA11y(id, 'kpt-slider');
19
+ /** Uchwyt zakresu bez etykiety pola: „Cena, Minimum” z `ariaLabel` albo samo „Minimum”. */
20
+ const thumbLabel = (end) => {
21
+ if (field.labelledBy)
22
+ return undefined;
23
+ const name = t(end === 'min' ? 'slider.min' : 'slider.max');
24
+ return ariaLabel ? `${ariaLabel}, ${name}` : name;
25
+ };
26
+ /** Uchwyt zakresu w `KptFormField`: etykieta pola + nazwa uchwytu. */
27
+ const thumbLabelledBy = (end) => field.labelledBy ? joinIds(field.labelledBy, `${field.id}-${end}-name`) : undefined;
15
28
  const [current, setCurrent] = useControllableState({
16
29
  value,
17
30
  defaultValue: defaultValue ?? (range ? [min, max] : 0),
@@ -26,5 +39,5 @@ export function KptSlider({ value, defaultValue, onValueChange, range = false, m
26
39
  // Uchwyty nie mogą się wyminąć — dolny nie przekracza górnego i odwrotnie.
27
40
  const setLow = (next) => setCurrent([Math.min(next, high), high]);
28
41
  const setHigh = (next) => setCurrent([low, Math.max(next, low)]);
29
- return (_jsx("div", { className: cn('kpt-slider-host', className), ...rest, children: range ? (_jsxs(_Fragment, { children: [_jsxs("div", { className: "kpt-slider__range", children: [_jsx("div", { className: "kpt-slider__rail" }), _jsx("div", { className: "kpt-slider__fill", style: { left: `${pct(low)}%`, right: `${100 - pct(high)}%` } }), _jsx("input", { className: "kpt-slider__thumb kpt-slider__thumb--low", type: "range", min: min, max: max, step: step, value: low, disabled: disabled, "aria-label": "Minimum", onChange: (event) => setLow(Number(event.target.value)), onBlur: () => onTouch?.() }), _jsx("input", { className: "kpt-slider__thumb kpt-slider__thumb--high", type: "range", min: min, max: max, step: step, value: high, disabled: disabled, "aria-label": "Maksimum", onChange: (event) => setHigh(Number(event.target.value)), onBlur: () => onTouch?.() })] }), showValue ? (_jsxs("span", { className: "kpt-slider__value", children: [low, "\u2013", high] })) : null] })) : (_jsxs(_Fragment, { children: [_jsx("input", { className: "kpt-slider__input", type: "range", min: min, max: max, step: step, value: single, disabled: disabled, style: { ['--_pct']: `${pct(single)}%` }, onChange: (event) => setCurrent(Number(event.target.value)), onBlur: () => onTouch?.() }), showValue ? _jsx("span", { className: "kpt-slider__value", children: single }) : null] })) }));
42
+ return (_jsx("div", { className: cn('kpt-slider-host', className), ...rest, children: range ? (_jsxs(_Fragment, { children: [_jsxs("div", { className: "kpt-slider__range", children: [_jsx("span", { className: "kpt-slider__sr", id: `${field.id}-min-name`, children: t('slider.min') }), _jsx("span", { className: "kpt-slider__sr", id: `${field.id}-max-name`, children: t('slider.max') }), _jsx("div", { className: "kpt-slider__rail" }), _jsx("div", { className: "kpt-slider__fill", style: { left: `${pct(low)}%`, right: `${100 - pct(high)}%` } }), _jsx("input", { className: "kpt-slider__thumb kpt-slider__thumb--low", type: "range", min: min, max: max, step: step, value: low, "aria-valuetext": suffix ? `${low}${suffix}` : undefined, "aria-invalid": invalid && (touched ?? true) ? true : undefined, id: field.id, disabled: disabled, "aria-label": thumbLabel('min'), "aria-labelledby": thumbLabelledBy('min'), "aria-describedby": field.describedBy, onChange: (event) => setLow(Number(event.target.value)), onBlur: () => onTouch?.() }), _jsx("input", { className: "kpt-slider__thumb kpt-slider__thumb--high", type: "range", min: min, max: max, step: step, value: high, "aria-valuetext": suffix ? `${high}${suffix}` : undefined, "aria-invalid": invalid && (touched ?? true) ? true : undefined, disabled: disabled, "aria-label": thumbLabel('max'), "aria-labelledby": thumbLabelledBy('max'), "aria-describedby": field.describedBy, onChange: (event) => setHigh(Number(event.target.value)), onBlur: () => onTouch?.() })] }), showValue ? (_jsxs("span", { className: "kpt-slider__value", children: [low, suffix, "\u2013", high, suffix] })) : null] })) : (_jsxs(_Fragment, { children: [_jsx("input", { className: "kpt-slider__input", type: "range", min: min, max: max, step: step, value: single, "aria-valuetext": suffix ? `${single}${suffix}` : undefined, "aria-invalid": invalid && (touched ?? true) ? true : undefined, id: field.id, disabled: disabled, "aria-label": ariaLabel ?? undefined, "aria-describedby": field.describedBy, style: { ['--_pct']: `${pct(single)}%` }, onChange: (event) => setCurrent(Number(event.target.value)), onBlur: () => onTouch?.() }), showValue ? (_jsxs("span", { className: "kpt-slider__value", children: [single, suffix] })) : null] })) }));
30
43
  }
@@ -42,5 +42,9 @@ The main button comes from `KptFab`; the actions use `--kpt-color-surface-raised
42
42
  `--kpt-elevation-2`.
43
43
 
44
44
  ## Accessibility
45
- Every action is a `<button>` labelled with its own `label`, so the fan works from the keyboard even
46
- when the actions show icons only.
45
+ - The main button carries `aria-expanded` and `aria-controls` pointing at the actions container.
46
+ - Closed actions are `visibility: hidden` (switched after the fade-out), so they are out of the Tab
47
+ order and the accessibility tree — not just transparent.
48
+ - `Escape` anywhere in the dial closes it and returns focus to the main button.
49
+ - Every action is a `<button>` labelled with its own `label`, so the fan works from the keyboard even
50
+ when the actions show icons only.
@@ -17,4 +17,4 @@ export interface KptSpeedDialProps<T> extends Omit<ComponentPropsWithRef<'div'>,
17
17
  * @example
18
18
  * <KptSpeedDial items={actions} direction="up" onSelect={onAction} />
19
19
  */
20
- export declare function KptSpeedDial<T>({ items, direction, icon, ariaLabel, onSelect, locale, dictionary, className, ...rest }: Readonly<KptSpeedDialProps<T>>): import("react").JSX.Element;
20
+ export declare function KptSpeedDial<T>({ items, direction, icon, ariaLabel, onSelect, locale, dictionary, className, onKeyDown: onKeyDownProp, ...rest }: Readonly<KptSpeedDialProps<T>>): import("react").JSX.Element;
@@ -1,6 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useState } from 'react';
2
+ import { useRef, useState } from 'react';
3
3
  import { cn } from "../internal/cn.js";
4
+ import { useKptId } from "../internal/use-id.js";
4
5
  import { useKptMessages } from "../i18n/i18n-context.js";
5
6
  import { KptFab } from "../fab/fab.js";
6
7
  import { KptIcon } from "../icon/icon.js";
@@ -10,14 +11,24 @@ import { KptIcon } from "../icon/icon.js";
10
11
  * @example
11
12
  * <KptSpeedDial items={actions} direction="up" onSelect={onAction} />
12
13
  */
13
- export function KptSpeedDial({ items = [], direction = 'up', icon = 'plus', ariaLabel, onSelect, locale, dictionary, className, ...rest }) {
14
+ export function KptSpeedDial({ items = [], direction = 'up', icon = 'plus', ariaLabel, onSelect, locale, dictionary, className, onKeyDown: onKeyDownProp, ...rest }) {
14
15
  const { t } = useKptMessages({ locale, dictionary });
15
16
  const [open, setOpen] = useState(false);
17
+ const actionsId = useKptId(undefined, 'kpt-speed-dial-actions');
18
+ const hostRef = useRef(null);
19
+ /** Escape z dowolnego miejsca w wachlarzu: zamyka i zwraca fokus na główny przycisk. */
20
+ const onKeyDown = (event) => {
21
+ onKeyDownProp?.(event);
22
+ if (event.key !== 'Escape' || !open)
23
+ return;
24
+ setOpen(false);
25
+ hostRef.current?.querySelector('.kpt-fab')?.focus();
26
+ };
16
27
  const pick = (item) => {
17
28
  if (item.disabled)
18
29
  return;
19
30
  onSelect?.(item);
20
31
  setOpen(false);
21
32
  };
22
- return (_jsxs("div", { className: cn('kpt-speed-dial-host', className), ...rest, children: [_jsxs("div", { className: cn('kpt-speed-dial', open && 'is-open'), "data-direction": direction, children: [_jsx("div", { className: "kpt-speed-dial__actions", children: items.map((item, index) => (_jsxs("button", { type: "button", className: "kpt-speed-dial__action", disabled: item.disabled, "aria-label": item.label, onClick: () => pick(item), children: [item.icon ? _jsx(KptIcon, { name: item.icon }) : null, item.label ? _jsx("span", { className: "kpt-speed-dial__label", children: item.label }) : null] }, index))) }), _jsx(KptFab, { "aria-label": open ? t('common.close') : (ariaLabel ?? t('speedDial.actions')), onClick: () => setOpen(!open), children: _jsx("span", { className: "kpt-speed-dial__trigger-icon", children: _jsx(KptIcon, { name: icon }) }) })] }), open ? _jsx("div", { className: "kpt-speed-dial__backdrop", onClick: () => setOpen(false) }) : null] }));
33
+ return (_jsxs("div", { ref: hostRef, className: cn('kpt-speed-dial-host', className), onKeyDown: onKeyDown, ...rest, children: [_jsxs("div", { className: cn('kpt-speed-dial', open && 'is-open'), "data-direction": direction, children: [_jsx("div", { className: "kpt-speed-dial__actions", id: actionsId, children: items.map((item, index) => (_jsxs("button", { type: "button", className: "kpt-speed-dial__action", disabled: item.disabled, "aria-label": item.label, onClick: () => pick(item), children: [item.icon ? _jsx(KptIcon, { name: item.icon }) : null, item.label ? _jsx("span", { className: "kpt-speed-dial__label", children: item.label }) : null] }, index))) }), _jsx(KptFab, { "aria-label": open ? t('common.close') : (ariaLabel ?? t('speedDial.actions')), "aria-expanded": open, "aria-controls": actionsId, onClick: () => setOpen(!open), children: _jsx("span", { className: "kpt-speed-dial__trigger-icon", children: _jsx(KptIcon, { name: icon }) }) })] }), open ? _jsx("div", { className: "kpt-speed-dial__backdrop", onClick: () => setOpen(false) }) : null] }));
23
34
  }
@@ -4,7 +4,8 @@ A loading indicator. Import: `import { KptSpinner } from '@konce-pt/react';`
4
4
 
5
5
  ## DOM
6
6
  <span class="kpt-spinner-host">
7
- <span class="kpt-spinner" role="status" aria-label="Loading" style="width: 1.75rem; height: 1.75rem"></span>
7
+ <span class="kpt-spinner" aria-hidden="true" style="width: 1.75rem; height: 1.75rem"></span>
8
+ <span class="kpt-spinner__label" role="status">Loading</span>
8
9
  </span>
9
10
 
10
11
  ## Props
@@ -14,9 +15,10 @@ A loading indicator. Import: `import { KptSpinner } from '@konce-pt/react';`
14
15
  - `className`, `ref` and every other `<span>` prop go to the host
15
16
 
16
17
  ## The colour is inherited
17
- The spinner draws itself in `currentColor`, so it takes the colour of the text around it. Put it
18
- inside a button and it matches the button; put it on a card and it matches the card. There is no
19
- colour prop, on purpose.
18
+ The host sets `color: var(--kpt-color-primary)`, and the ring is drawn in `currentColor` — so the
19
+ spinner is primary by default, and anything that sets `color` on the host changes it. Pass
20
+ `style={{ color: 'inherit' }}` (or a class) to take the colour of the text around it, e.g. inside a
21
+ filled chip. There is no colour prop, on purpose.
20
22
 
21
23
  ## Examples
22
24
  <KptSpinner />
@@ -24,10 +26,12 @@ colour prop, on purpose.
24
26
  <KptButton disabled><KptSpinner size="1em" /> Saving…</KptButton>
25
27
 
26
28
  ## Tokens
27
- None of its own — the size comes from the prop, the colour from `currentColor`, the speed from
29
+ None of its own — the colour is `--kpt-color-primary` on the host (drawn in `currentColor`), the
30
+ size comes from the prop, the ring width is 8% of the size (at least 2px), the speed from
28
31
  `--kpt-motion-duration-*`.
29
32
 
30
33
  ## Accessibility
31
- `role="status"` with a label, so a screen reader announces the wait politely. For a longer
32
- operation prefer `KptProgress` — a spinner says "something is happening", a progress bar says how
33
- much is left.
34
+ The ring is `aria-hidden`; next to it sits a visually hidden `.kpt-spinner__label` with
35
+ `role="status"`. Its text — `label`, or `spinner.label` — is written a frame after the spinner
36
+ appears, because a status region that is born already filled in is skipped by most screen readers.
37
+ Under `prefers-reduced-motion` the rotation slows down instead of stopping.
@@ -1,4 +1,4 @@
1
- import type { ComponentPropsWithRef } from 'react';
1
+ import { type ComponentPropsWithRef } from 'react';
2
2
  import { type KptI18nOverrides } from '../i18n/i18n-context.tsx';
3
3
  export interface KptSpinnerProps extends Omit<ComponentPropsWithRef<'span'>, 'children'>, KptI18nOverrides {
4
4
  size?: string;
@@ -8,6 +8,10 @@ export interface KptSpinnerProps extends Omit<ComponentPropsWithRef<'span'>, 'ch
8
8
  /**
9
9
  * Wskaźnik ładowania. Kolor bierze z `currentColor`, więc dopasowuje się do treści wokół.
10
10
  *
11
+ * Pierścień jest dekoracją (`aria-hidden`); dla czytnika jest osobny, wizualnie ukryty region
12
+ * `role="status"` z etykietą. Tekst trafia do regionu klatkę po pojawieniu się spinnera: region,
13
+ * który powstaje od razu z treścią, większość czytników przemilcza — ogłaszają dopiero zmianę.
14
+ *
11
15
  * @example
12
16
  * <KptSpinner />
13
17
  * <KptSpinner size="2rem" label="Wczytywanie danych" />
@@ -1,14 +1,24 @@
1
- import { jsx as _jsx } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useState } from 'react';
2
3
  import { cn } from "../internal/cn.js";
3
4
  import { useKptMessages } from "../i18n/i18n-context.js";
4
5
  /**
5
6
  * Wskaźnik ładowania. Kolor bierze z `currentColor`, więc dopasowuje się do treści wokół.
6
7
  *
8
+ * Pierścień jest dekoracją (`aria-hidden`); dla czytnika jest osobny, wizualnie ukryty region
9
+ * `role="status"` z etykietą. Tekst trafia do regionu klatkę po pojawieniu się spinnera: region,
10
+ * który powstaje od razu z treścią, większość czytników przemilcza — ogłaszają dopiero zmianę.
11
+ *
7
12
  * @example
8
13
  * <KptSpinner />
9
14
  * <KptSpinner size="2rem" label="Wczytywanie danych" />
10
15
  */
11
16
  export function KptSpinner({ size = '1.5rem', label, locale, dictionary, className, ...rest }) {
12
17
  const { t } = useKptMessages({ locale, dictionary });
13
- return (_jsx("span", { className: cn('kpt-spinner-host', className), ...rest, children: _jsx("span", { className: "kpt-spinner", role: "status", "aria-label": label ?? t('spinner.label'), style: { width: size, height: size } }) }));
18
+ const [ready, setReady] = useState(false);
19
+ useEffect(() => {
20
+ const frame = requestAnimationFrame(() => setReady(true));
21
+ return () => cancelAnimationFrame(frame);
22
+ }, []);
23
+ return (_jsxs("span", { className: cn('kpt-spinner-host', className), ...rest, children: [_jsx("span", { className: "kpt-spinner", "aria-hidden": "true", style: { width: size, height: size, borderWidth: `max(2px, calc(${size} * 0.08))` } }), _jsx("span", { className: "kpt-spinner__label", role: "status", children: ready ? (label ?? t('spinner.label')) : '' })] }));
14
24
  }
@@ -14,7 +14,9 @@ Import: `import { KptSplitButton } from '@konce-pt/react';`
14
14
  <div class="kpt-split-button__panel" role="menu">…rows…</div>
15
15
 
16
16
  ## Props
17
- - `variant`: the same union as `KptButton` (default 'filled')
17
+ - `variant`: `KptSplitButtonVariant` = 'filled' | 'tonal' | 'outline' | 'text' (default 'filled') —
18
+ the subset of `KptButtonVariant` that has a style; `outline` and `text` use the `--kpt-button-outline-*`
19
+ and `--kpt-button-text-*` tokens, so they match `kpt-button` in the same row
18
20
  - `items`: `KptMenuItem<T>[]` — the same shape as `KptMenu`
19
21
  - `disabled`: boolean — disables both buttons
20
22
  - `onPrimaryClick()` — the main action
@@ -35,6 +37,12 @@ the `danger` modifier all behave identically, only the BEM block differs.
35
37
  ## i18n
36
38
  The toggle's label comes from `splitButton.more` in the dictionary.
37
39
 
40
+ ## Keyboard
41
+ The toggle is a menu button: `aria-haspopup="menu"`, a live `aria-expanded`, `aria-controls` while
42
+ open. `↓` / `↑` on it open the menu on the first / last item; inside, `↓` / `↑` wrap, `Home` / `End`
43
+ jump, `Escape` / `Tab` close, and closing or picking returns focus to the toggle. Same rules as the
44
+ menu — see the `KptMenu` docs.
45
+
38
46
  ## Examples
39
47
  <KptSplitButton items={items} onPrimaryClick={save} onSelect={onItem}>Save</KptSplitButton>
40
48
 
@@ -50,5 +58,5 @@ The `--kpt-button-*` tokens of the chosen variant, plus `--kpt-color-surface-rai
50
58
  `--kpt-elevation-3` for the panel.
51
59
 
52
60
  ## Accessibility
53
- Two native buttons; the toggle carries `aria-label`, the panel is `role="menu"` with
54
- `role="menuitem"` rows.
61
+ Two native buttons; the toggle carries `aria-label` plus the menu-button attributes above, the
62
+ panel is `role="menu"` with `role="menuitem"` rows.