@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
@@ -9,6 +9,12 @@ export interface KptListboxProps<T> extends Omit<ComponentPropsWithRef<'div'>, '
9
9
  multiple?: boolean;
10
10
  /** Pole filtrowania nad listą. */
11
11
  filter?: boolean;
12
+ /** Id listy (nie hosta); bez niego generowane, w `KptFormField` — z wrappera. */
13
+ id?: string;
14
+ /** Nazwa dostępna listy, gdy nie opisuje jej etykieta (`KptFormField`). */
15
+ ariaLabel?: string | null;
16
+ /** Oznacza listę jako wymaganą atrybutem `aria-required`. */
17
+ required?: boolean;
12
18
  disabled?: boolean;
13
19
  invalid?: boolean;
14
20
  touched?: boolean;
@@ -21,8 +27,14 @@ export interface KptListboxProps<T> extends Omit<ComponentPropsWithRef<'div'>, '
21
27
  * W trybie pojedynczym ponowny klik w zaznaczoną opcję odznacza ją — lista bywa filtrem,
22
28
  * a filtr trzeba dać się wyczyścić bez sięgania po osobny przycisk.
23
29
  *
30
+ * Klawiatura według wzorca WAI-ARIA „listbox”: lista to jeden przystanek Tab, a aktywną opcję
31
+ * ogłasza `aria-activedescendant`. Strzałki, `Home`/`End`, `PageUp`/`PageDown` przesuwają aktywną
32
+ * opcję (wyłączone są pomijane), pisanie liter skacze do kolejnej pasującej (bez względu na
33
+ * diakrytyki), `Spacja`/`Enter` wybiera, a w trybie wielokrotnym `Ctrl`/`⌘`+`A` zaznacza wszystkie
34
+ * widoczne. Pole filtra stoi przed listą; `↓` przenosi z niego fokus do listy.
35
+ *
24
36
  * @example
25
37
  * <KptListbox options={roles} value={role} onValueChange={setRole} filter />
26
38
  * <KptListbox options={tags} value={tags} onValueChange={setTags} multiple />
27
39
  */
28
- export declare function KptListbox<T>({ options, value, defaultValue, onValueChange, multiple, filter, disabled, invalid, touched, onTouch, locale, dictionary, className, ...rest }: Readonly<KptListboxProps<T>>): import("react").JSX.Element;
40
+ export declare function KptListbox<T>({ options, value, defaultValue, onValueChange, multiple, filter, id, ariaLabel, required, disabled, invalid, touched, onTouch, locale, dictionary, className, ...rest }: Readonly<KptListboxProps<T>>): import("react").JSX.Element;
@@ -1,8 +1,11 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useMemo, useState } from 'react';
2
+ import { useEffect, useMemo, useRef, useState } from 'react';
3
+ import { foldMentionText } from '@konce-pt/mention';
3
4
  import { cn } from "../internal/cn.js";
4
5
  import { useControllableState } from "../internal/use-controllable-state.js";
6
+ import { KptTypeahead, OPTION_NAV_KEYS, isTypeaheadKey, nextOptionIndex, typeaheadIndex } from "../internal/option-nav.js";
5
7
  import { useKptMessages } from "../i18n/i18n-context.js";
8
+ import { useKptFieldA11y } from "../form-field/form-field-context.js";
6
9
  import { KptIcon } from "../icon/icon.js";
7
10
  /**
8
11
  * Lista wyboru widoczna w całości, bez rozwijania. Pojedynczy albo wielokrotny wybór,
@@ -11,23 +14,44 @@ import { KptIcon } from "../icon/icon.js";
11
14
  * W trybie pojedynczym ponowny klik w zaznaczoną opcję odznacza ją — lista bywa filtrem,
12
15
  * a filtr trzeba dać się wyczyścić bez sięgania po osobny przycisk.
13
16
  *
17
+ * Klawiatura według wzorca WAI-ARIA „listbox”: lista to jeden przystanek Tab, a aktywną opcję
18
+ * ogłasza `aria-activedescendant`. Strzałki, `Home`/`End`, `PageUp`/`PageDown` przesuwają aktywną
19
+ * opcję (wyłączone są pomijane), pisanie liter skacze do kolejnej pasującej (bez względu na
20
+ * diakrytyki), `Spacja`/`Enter` wybiera, a w trybie wielokrotnym `Ctrl`/`⌘`+`A` zaznacza wszystkie
21
+ * widoczne. Pole filtra stoi przed listą; `↓` przenosi z niego fokus do listy.
22
+ *
14
23
  * @example
15
24
  * <KptListbox options={roles} value={role} onValueChange={setRole} filter />
16
25
  * <KptListbox options={tags} value={tags} onValueChange={setTags} multiple />
17
26
  */
18
- export function KptListbox({ options = [], value, defaultValue = null, onValueChange, multiple = false, filter = false, disabled = false, invalid = false, touched, onTouch, locale, dictionary, className, ...rest }) {
27
+ export function KptListbox({ options = [], value, defaultValue = null, onValueChange, multiple = false, filter = false, id, ariaLabel = null, required = false, disabled = false, invalid = false, touched, onTouch, locale, dictionary, className, ...rest }) {
19
28
  const { t } = useKptMessages({ locale, dictionary });
29
+ /** `div` z rolą nie dostaje nazwy z `label[for]` — stąd `aria-labelledby` na etykietę wrappera. */
30
+ const field = useKptFieldA11y(id, 'kpt-listbox');
20
31
  const [current, setCurrent] = useControllableState({
21
32
  value,
22
33
  defaultValue,
23
34
  onChange: onValueChange,
24
35
  });
25
36
  const [query, setQuery] = useState('');
37
+ /** Aktywna opcja (indeks w `visible`); `-1` = żadna. */
38
+ const [activeIndex, setActiveIndex] = useState(-1);
39
+ const listRef = useRef(null);
40
+ const typeahead = useRef(new KptTypeahead()).current;
26
41
  const showInvalid = invalid && (touched ?? true);
42
+ /** Filtr bez względu na wielkość liter i diakrytyki („lodz” znajduje „Łódź”). */
27
43
  const visible = useMemo(() => {
28
- const q = query.trim().toLowerCase();
29
- return q ? options.filter((option) => option.label.toLowerCase().includes(q)) : options;
44
+ const q = foldMentionText(query.trim());
45
+ return q ? options.filter((option) => foldMentionText(option.label).includes(q)) : options;
30
46
  }, [options, query]);
47
+ const disabledMap = visible.map((option) => disabled || !!option.disabled);
48
+ const optionId = (index) => `${field.id}-opt-${index}`;
49
+ const activeOptionId = activeIndex >= 0 && activeIndex < visible.length ? optionId(activeIndex) : undefined;
50
+ // Aktywna opcja zawsze w polu widzenia listy (przewijana `max-height`).
51
+ useEffect(() => {
52
+ if (activeOptionId)
53
+ document.getElementById(activeOptionId)?.scrollIntoView({ block: 'nearest' });
54
+ }, [activeOptionId]);
31
55
  const isSelected = (candidate) => multiple ? Array.isArray(current) && current.includes(candidate) : current === candidate;
32
56
  const toggle = (candidate) => {
33
57
  if (multiple) {
@@ -37,7 +61,66 @@ export function KptListbox({ options = [], value, defaultValue = null, onValueCh
37
61
  else {
38
62
  setCurrent(current === candidate ? null : candidate);
39
63
  }
40
- onTouch?.();
41
64
  };
42
- return (_jsx("div", { className: cn('kpt-listbox-host', className), ...rest, children: _jsxs("div", { className: cn('kpt-listbox', showInvalid && 'kpt-listbox--invalid'), role: "listbox", "aria-multiselectable": multiple, children: [filter ? (_jsxs("div", { className: "kpt-listbox__filter", children: [_jsx(KptIcon, { name: "search" }), _jsx("input", { type: "text", placeholder: t('listbox.search'), value: query, onChange: (event) => setQuery(event.target.value) })] })) : null, _jsx("div", { className: "kpt-listbox__items", children: visible.length ? (visible.map((option) => (_jsxs("button", { type: "button", role: "option", className: cn('kpt-listbox__item', isSelected(option.value) && 'is-selected'), "aria-selected": isSelected(option.value), disabled: disabled || option.disabled, onClick: () => toggle(option.value), children: [_jsx("span", { children: option.label }), isSelected(option.value) ? _jsx(KptIcon, { name: "check" }) : null] }, String(option.value))))) : (_jsx("div", { className: "kpt-listbox__empty", children: t('listbox.empty') })) })] }) }));
65
+ const pick = (index) => {
66
+ const option = visible[index];
67
+ if (!option || disabledMap[index])
68
+ return;
69
+ setActiveIndex(index);
70
+ toggle(option.value);
71
+ };
72
+ /** Wejście fokusem: aktywna zostaje wybrana opcja, a bez niej pierwsza dozwolona. */
73
+ const onFocus = () => {
74
+ if (activeIndex >= 0 && activeIndex < visible.length)
75
+ return;
76
+ const selected = visible.findIndex((option, index) => isSelected(option.value) && !disabledMap[index]);
77
+ setActiveIndex(selected >= 0 ? selected : (nextOptionIndex(disabledMap, -1, 'Home') ?? -1));
78
+ };
79
+ const onQuery = (text) => {
80
+ setQuery(text);
81
+ const q = foldMentionText(text.trim());
82
+ const next = q ? options.filter((option) => foldMentionText(option.label).includes(q)) : options;
83
+ setActiveIndex(nextOptionIndex(next.map((option) => disabled || !!option.disabled), -1, 'Home') ?? -1);
84
+ };
85
+ const onKeyDown = (event) => {
86
+ if (disabled)
87
+ return;
88
+ if (OPTION_NAV_KEYS.has(event.key)) {
89
+ event.preventDefault();
90
+ const next = nextOptionIndex(disabledMap, activeIndex, event.key);
91
+ if (next !== null)
92
+ setActiveIndex(next);
93
+ return;
94
+ }
95
+ if (event.key === ' ' || event.key === 'Enter') {
96
+ if (activeIndex < 0)
97
+ return;
98
+ event.preventDefault();
99
+ pick(activeIndex);
100
+ return;
101
+ }
102
+ if (multiple && (event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'a') {
103
+ event.preventDefault();
104
+ const all = visible.filter((_, index) => !disabledMap[index]).map((option) => option.value);
105
+ const list = Array.isArray(current) ? current : [];
106
+ // Drugi raz `Ctrl+A` przy wszystkich zaznaczonych — odznacza widoczne.
107
+ const allOn = all.every((item) => list.includes(item));
108
+ setCurrent(allOn ? list.filter((item) => !all.includes(item)) : [...list, ...all.filter((item) => !list.includes(item))]);
109
+ return;
110
+ }
111
+ if (isTypeaheadKey(event.nativeEvent)) {
112
+ const hit = typeaheadIndex(visible.map((option) => option.label), disabledMap, activeIndex, typeahead.push(event.key));
113
+ if (hit !== null)
114
+ setActiveIndex(hit);
115
+ }
116
+ };
117
+ return (_jsx("div", { className: cn('kpt-listbox-host', className), ...rest, children: _jsxs("div", { className: cn('kpt-listbox', showInvalid && 'kpt-listbox--invalid', disabled && 'kpt-listbox--disabled'), children: [filter ? (_jsxs("div", { className: "kpt-listbox__filter", children: [_jsx(KptIcon, { name: "search" }), _jsx("input", { type: "text", placeholder: t('listbox.search'), "aria-label": t('listbox.search'), "aria-controls": field.id, disabled: disabled, value: query, onChange: (event) => onQuery(event.target.value), onKeyDown: (event) => {
118
+ if (event.key !== 'ArrowDown')
119
+ return;
120
+ event.preventDefault();
121
+ listRef.current?.focus();
122
+ } })] })) : null, _jsx("div", { ref: listRef, className: "kpt-listbox__items", role: "listbox", id: field.id, tabIndex: disabled ? -1 : 0, "aria-multiselectable": multiple || undefined, "aria-activedescendant": activeOptionId, "aria-disabled": disabled || undefined, "aria-invalid": showInvalid || undefined, "aria-required": required || undefined, "aria-label": ariaLabel ?? undefined, "aria-labelledby": ariaLabel ? undefined : field.labelledBy, "aria-describedby": field.describedBy, onKeyDown: onKeyDown, onFocus: onFocus, onBlur: () => onTouch?.(), children: visible.length ? (visible.map((option, index) => (_jsxs("div", { role: "option", id: optionId(index), className: cn('kpt-listbox__item', isSelected(option.value) && 'is-selected', index === activeIndex && 'is-active'), "aria-selected": isSelected(option.value), "aria-disabled": disabledMap[index] || undefined, onMouseDown: (event) => {
123
+ event.preventDefault();
124
+ listRef.current?.focus();
125
+ }, onClick: () => pick(index), children: [_jsx("span", { children: option.label }), isSelected(option.value) ? _jsx(KptIcon, { name: "check" }) : null] }, String(option.value))))) : (_jsx("div", { className: "kpt-listbox__empty", role: "status", children: t('listbox.empty') })) })] }) }));
43
126
  }
@@ -5,10 +5,10 @@ Import: `import { KptListbox } from '@konce-pt/react';`
5
5
 
6
6
  ## DOM
7
7
  <div class="kpt-listbox-host">
8
- <div class="kpt-listbox" role="listbox" aria-multiselectable="false">
8
+ <div class="kpt-listbox">
9
9
  <div class="kpt-listbox__filter">…icon + input…</div>
10
- <div class="kpt-listbox__items">
11
- <button class="kpt-listbox__item is-selected" role="option" aria-selected="true">Admin</button>
10
+ <div class="kpt-listbox__items" role="listbox" tabindex="0" aria-activedescendant="…-opt-0">
11
+ <div class="kpt-listbox__item is-selected is-active" role="option" id="…-opt-0" aria-selected="true">Admin</div>
12
12
  </div>
13
13
  </div>
14
14
  </div>
@@ -17,6 +17,7 @@ Import: `import { KptListbox } from '@konce-pt/react';`
17
17
  - `options`: `KptOption<T>[]`
18
18
  - `value` / `defaultValue` / `onValueChange` — `T` (single) or `T[]` (multiple)
19
19
  - `multiple`, `filter`, `disabled`, `invalid`, `touched`, `onTouch()`
20
+ - `id`, `ariaLabel`, `required`
20
21
  - `locale`, `dictionary` — i18n overrides for this component only
21
22
 
22
23
  ## Clicking the selection clears it
@@ -30,10 +31,25 @@ filter has to be clearable without a separate button.
30
31
  <KptListbox options={roles} value={role} onValueChange={setRole} filter />
31
32
  <KptListbox options={tags} value={tags} onValueChange={setTags} multiple />
32
33
 
34
+ ## Keyboard
35
+ The WAI-ARIA listbox pattern: the list is one Tab stop and the active option is announced through
36
+ `aria-activedescendant`. `↑`/`↓`, `Home`/`End` and `PageUp`/`PageDown` move the active option,
37
+ skipping disabled ones (no wrapping); typing letters jumps to the next match, diacritics ignored;
38
+ `Space`/`Enter` picks; in `multiple` mode `Ctrl`/`⌘`+`A` selects every visible option (again —
39
+ clears them). The filter field sits before the list (outside the `listbox`) and `↓` moves focus
40
+ from it into the list; the filter itself ignores diacritics ("lodz" finds "Łódź").
41
+
42
+ ## Label and description
43
+ `id` goes to the list (`role="listbox"`), never the host. A `div` with a role is not named by
44
+ `label[for]`, so inside `KptFormField` the list gets `aria-labelledby` pointing at the wrapper's
45
+ label (unless `ariaLabel` is set) and the hint or error in `aria-describedby`. New: `id`,
46
+ `ariaLabel`, `required` (→ `aria-required`); `aria-invalid` after touch; `touch` fires on blur of the list.
47
+
33
48
  ## Tokens
34
49
  `--kpt-color-surface`, `--kpt-color-border`, `--kpt-color-primary-subtle` (the selected row),
35
50
  `--kpt-form-field-border-error`.
36
51
 
37
52
  ## Accessibility
38
- `role="listbox"` with `aria-multiselectable`; every option is a native button with
39
- `role="option"` and `aria-selected`, so the keyboard walks the list without extra handling.
53
+ The list is `role="listbox"` with `aria-multiselectable`, `aria-activedescendant` and a name from
54
+ `ariaLabel` or `KptFormField`; options are `role="option"` with `aria-selected`/`aria-disabled`.
55
+ The keyboard is described above.
@@ -5,21 +5,23 @@ Import: `import { KptMegamenu } from '@konce-pt/react';`
5
5
 
6
6
  ## DOM
7
7
  <div class="kpt-megamenu-host">
8
- <nav class="kpt-megamenu" role="menubar">
9
- <button class="kpt-megamenu__top is-open" role="menuitem">Products …caret…</button>
10
- </nav>
8
+ <div class="kpt-megamenu" role="menubar" aria-orientation="horizontal" aria-label="Main menu">
9
+ <button class="kpt-megamenu__top is-open" role="menuitem" tabindex="0"
10
+ aria-haspopup="menu" aria-expanded="true" aria-controls="kpt-megamenu-r1">Products …caret…</button>
11
+ </div>
11
12
  </div>
12
13
  <!-- while open, in a portal: -->
13
- <div class="kpt-megamenu__panel" role="menu">
14
- <div class="kpt-megamenu__column">
15
- <div class="kpt-megamenu__col-title">Components</div>
16
- <button class="kpt-megamenu__link">Forms</button>
14
+ <div class="kpt-megamenu__panel" role="menu" id="kpt-megamenu-r1" aria-label="Products">
15
+ <div class="kpt-megamenu__column" role="group" aria-labelledby="kpt-megamenu-r1-col-0">
16
+ <div class="kpt-megamenu__col-title" role="presentation" id="kpt-megamenu-r1-col-0">Components</div>
17
+ <button class="kpt-megamenu__link" role="menuitem" tabindex="-1">Forms</button>
17
18
  </div>
18
19
  </div>
19
20
 
20
21
  ## Props
21
22
  - `items`: `KptMenuItem<T>[]` — three levels: top item → `items` (columns) → column `items` (links)
22
- - `onSelect(link)` — a picked link from a column
23
+ - `onSelect(link)` — a picked link from a column, or a top item without columns
24
+ - `ariaLabel`: string — the bar's name for screen readers, e.g. "Main menu"
23
25
  - `className`, `ref` and every other `<div>` prop go to the host
24
26
 
25
27
  ## The panel is anchored to the whole bar
@@ -39,10 +41,26 @@ without closing the panel, so browsing across sections costs one click each.
39
41
  ],
40
42
  }];
41
43
 
44
+ ## Links (`url`)
45
+ An entry with `url` and no children (and not disabled) renders as a real `<a href>` with the same
46
+ look, so the browser handles navigation — opening in a new tab, the status-bar preview. `onSelect`
47
+ still fires before the browser follows the link.
48
+
42
49
  ## Tokens
43
50
  `--kpt-color-surface-raised`, `--kpt-color-border`, `--kpt-elevation-3`,
44
51
  `--kpt-color-on-surface-muted` (the column titles).
45
52
 
46
53
  ## Accessibility
47
- The bar is `role="menubar"` with `role="menuitem"` buttons; the panel is `role="menu"`. Column
48
- titles are plain text, not headings, so they do not disturb the page outline.
54
+ The bar is `role="menubar"` (`aria-orientation="horizontal"`, named by `ariaLabel`) with
55
+ `role="menuitem"` buttons; those with columns carry `aria-haspopup="menu"`, `aria-expanded` and
56
+ `aria-controls`. The panel is `role="menu"` named after the open item, each column is
57
+ `role="group"` named by its title, and links are `role="menuitem"`. Column titles are not headings,
58
+ so they do not disturb the page outline. A top item without columns fires `onSelect` instead of
59
+ opening an empty panel.
60
+
61
+ ## Keyboard
62
+ The same as `KptMenubar`: one `Tab` stop for the bar, `ArrowLeft`/`ArrowRight` and `Home`/`End`
63
+ along it, `ArrowDown` (or `Enter`/`Space`) opens the panel on the first link, `ArrowUp` on the last.
64
+ In the panel `ArrowDown`/`ArrowUp`/`Home`/`End` walk through all links column by column,
65
+ `ArrowLeft`/`ArrowRight` show the neighbouring item's panel, `Escape` or `Tab` close it and return
66
+ focus to the top item.
@@ -3,15 +3,24 @@ import type { KptMenuItem } from '../menu/menu-item.ts';
3
3
  export interface KptMegamenuProps<T> extends Omit<ComponentPropsWithRef<'div'>, 'onSelect'> {
4
4
  items?: readonly KptMenuItem<T>[];
5
5
  onSelect?: (item: KptMenuItem<T>) => void;
6
+ /** Nazwa belki dla czytników ekranu, np. „Menu główne”. */
7
+ ariaLabel?: string;
6
8
  }
7
9
  /**
8
10
  * Mega-menu — pozioma belka z szerokim, wielokolumnowym panelem.
9
11
  * Struktura: pozycja górna → `items` (kolumny) → `items` kolumny (odnośniki).
12
+ * Pozycja górna bez kolumn działa jak akcja — wywołuje `onSelect` zamiast otwierać pusty panel.
10
13
  *
11
14
  * Panel kotwiczy się do CAŁEJ belki, nie do pojedynczego przycisku: jest szeroki, więc ma
12
15
  * zaczynać się przy krawędzi menu, a nie przy przypadkowej pozycji, w którą kliknięto.
13
16
  *
17
+ * Klawiatura jak w `KptMenubar`: belka to jeden przystanek Tab, strzałki w lewo/prawo
18
+ * i `Home`/`End` chodzą po pozycjach górnych, strzałka w dół (albo `Enter`/spacja) otwiera panel
19
+ * z fokusem na pierwszym odnośniku. W panelu strzałki w górę/dół przechodzą po wszystkich
20
+ * odnośnikach kolumna po kolumnie, w lewo/prawo — do panelu sąsiedniej pozycji, a `Escape`
21
+ * zamyka panel i oddaje fokus belce.
22
+ *
14
23
  * @example
15
24
  * <KptMegamenu items={items} onSelect={onSelect} />
16
25
  */
17
- export declare function KptMegamenu<T>({ items, onSelect, className, ...rest }: Readonly<KptMegamenuProps<T>>): import("react").JSX.Element;
26
+ export declare function KptMegamenu<T>({ items, onSelect, ariaLabel, className, ...rest }: Readonly<KptMegamenuProps<T>>): import("react").JSX.Element;
@@ -1,28 +1,139 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useRef, useState } from 'react';
2
+ import { useEffect, useRef, useState } from 'react';
3
3
  import { cn } from "../internal/cn.js";
4
4
  import { KptOverlay } from "../internal/overlay.js";
5
+ import { nextRovingIndex, rovingStop } from "../internal/roving.js";
6
+ import { useKptId } from "../internal/use-id.js";
5
7
  import { KptIcon } from "../icon/icon.js";
8
+ import { focusMenuEdge, handleMenuKeydown } from "../menu/menu-keys.js";
6
9
  /**
7
10
  * Mega-menu — pozioma belka z szerokim, wielokolumnowym panelem.
8
11
  * Struktura: pozycja górna → `items` (kolumny) → `items` kolumny (odnośniki).
12
+ * Pozycja górna bez kolumn działa jak akcja — wywołuje `onSelect` zamiast otwierać pusty panel.
9
13
  *
10
14
  * Panel kotwiczy się do CAŁEJ belki, nie do pojedynczego przycisku: jest szeroki, więc ma
11
15
  * zaczynać się przy krawędzi menu, a nie przy przypadkowej pozycji, w którą kliknięto.
12
16
  *
17
+ * Klawiatura jak w `KptMenubar`: belka to jeden przystanek Tab, strzałki w lewo/prawo
18
+ * i `Home`/`End` chodzą po pozycjach górnych, strzałka w dół (albo `Enter`/spacja) otwiera panel
19
+ * z fokusem na pierwszym odnośniku. W panelu strzałki w górę/dół przechodzą po wszystkich
20
+ * odnośnikach kolumna po kolumnie, w lewo/prawo — do panelu sąsiedniej pozycji, a `Escape`
21
+ * zamyka panel i oddaje fokus belce.
22
+ *
13
23
  * @example
14
24
  * <KptMegamenu items={items} onSelect={onSelect} />
15
25
  */
16
- export function KptMegamenu({ items = [], onSelect, className, ...rest }) {
17
- const navRef = useRef(null);
26
+ export function KptMegamenu({ items = [], onSelect, ariaLabel, className, ...rest }) {
27
+ const barRef = useRef(null);
28
+ const panelRef = useRef(null);
29
+ const buttonsRef = useRef([]);
18
30
  const [openIndex, setOpenIndex] = useState(null);
19
- const closeAll = () => setOpenIndex(null);
31
+ // Ostatnio skupiona pozycja górna — ona jest przystankiem Tab belki.
32
+ const [focusIndex, setFocusIndex] = useState(0);
33
+ // Który odnośnik skupić po otwarciu — strzałka w górę otwiera „od końca”.
34
+ const focusEdge = useRef('first');
35
+ const panelId = useKptId(undefined, 'kpt-megamenu');
36
+ const disabled = items.map((item) => !!item.disabled);
37
+ // Pozycja z `url` (bez kolumn, niewyłączona) to `<a href>` — nawigację robi przeglądarka,
38
+ // więc działa nowa karta i podgląd adresu. `onSelect` i tak jest wołane.
39
+ const isLink = (item) => !!item.url && !item.items?.length && !item.disabled;
40
+ const tabStop = rovingStop(disabled, focusIndex);
20
41
  const columns = openIndex === null ? [] : (items[openIndex]?.items ?? []);
42
+ // Po otwarciu i po każdej zmianie pozycji fokus na skrajny odnośnik. Klatkę później:
43
+ // `KptOverlay` trzyma panel w `visibility: hidden`, dopóki go nie zmierzy.
44
+ useEffect(() => {
45
+ if (openIndex === null)
46
+ return;
47
+ const frame = requestAnimationFrame(() => {
48
+ if (panelRef.current)
49
+ focusMenuEdge(panelRef.current, focusEdge.current);
50
+ });
51
+ return () => cancelAnimationFrame(frame);
52
+ }, [openIndex]);
53
+ const closeAll = () => setOpenIndex(null);
54
+ const focusTop = (index) => {
55
+ setFocusIndex(index);
56
+ buttonsRef.current[index]?.focus();
57
+ };
58
+ /** Skupia pozycję górną i pokazuje jej panel, jeśli ma kolumny (inaczej zamyka otwarty). */
59
+ const openAt = (index, edge) => {
60
+ focusEdge.current = edge;
61
+ const hasMenu = !!items[index]?.items?.length;
62
+ if (hasMenu && openIndex === index && panelRef.current) {
63
+ focusMenuEdge(panelRef.current, edge);
64
+ return;
65
+ }
66
+ setOpenIndex(hasMenu ? index : null);
67
+ focusTop(index);
68
+ };
69
+ const toggle = (index, item) => {
70
+ if (item.disabled)
71
+ return;
72
+ if (item.items?.length) {
73
+ focusEdge.current = 'first';
74
+ setOpenIndex((current) => (current === index ? null : index));
75
+ }
76
+ else {
77
+ onSelect?.(item);
78
+ closeAll();
79
+ }
80
+ };
21
81
  const pick = (link) => {
22
82
  if (link.disabled)
23
83
  return;
84
+ const top = openIndex;
24
85
  onSelect?.(link);
25
86
  closeAll();
87
+ if (top !== null)
88
+ focusTop(top);
89
+ };
90
+ const onTopKeyDown = (event, index) => {
91
+ if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
92
+ if (!items[index]?.items?.length)
93
+ return;
94
+ event.preventDefault();
95
+ openAt(index, event.key === 'ArrowUp' ? 'last' : 'first');
96
+ return;
97
+ }
98
+ const next = nextRovingIndex(disabled, index, event.key);
99
+ if (next === null)
100
+ return;
101
+ event.preventDefault();
102
+ // Przy otwartym panelu przejście w bok pokazuje panel sąsiada — jak w menu aplikacji.
103
+ if (openIndex === null)
104
+ focusTop(next);
105
+ else
106
+ openAt(next, 'first');
107
+ };
108
+ const onPanelKeyDown = (event) => {
109
+ if (openIndex === null)
110
+ return;
111
+ if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') {
112
+ const next = nextRovingIndex(disabled, openIndex, event.key);
113
+ if (next === null)
114
+ return;
115
+ event.preventDefault();
116
+ openAt(next, 'first');
117
+ return;
118
+ }
119
+ if (handleMenuKeydown(event.currentTarget, event.nativeEvent) === 'close') {
120
+ const top = openIndex;
121
+ closeAll();
122
+ focusTop(top);
123
+ }
26
124
  };
27
- return (_jsxs("div", { className: cn('kpt-megamenu-host', className), ...rest, children: [_jsx("nav", { className: "kpt-megamenu", role: "menubar", ref: navRef, children: items.map((item, index) => (_jsxs("button", { type: "button", className: cn('kpt-megamenu__top', openIndex === index && 'is-open'), role: "menuitem", onClick: () => setOpenIndex((current) => (current === index ? null : index)), children: [item.icon ? _jsx(KptIcon, { name: item.icon }) : null, _jsx("span", { children: item.label }), _jsx(KptIcon, { name: "chevron-down", className: "kpt-megamenu__caret" })] }, index))) }), _jsx(KptOverlay, { open: openIndex !== null, anchor: navRef.current, onClose: closeAll, children: _jsx("div", { className: "kpt-megamenu__panel", role: "menu", children: columns.map((column, columnIndex) => (_jsxs("div", { className: "kpt-megamenu__column", children: [column.label ? _jsx("div", { className: "kpt-megamenu__col-title", children: column.label }) : null, (column.items ?? []).map((link, linkIndex) => (_jsxs("button", { type: "button", className: "kpt-megamenu__link", disabled: link.disabled, onClick: () => pick(link), children: [link.icon ? _jsx(KptIcon, { name: link.icon }) : null, _jsx("span", { children: link.label })] }, linkIndex)))] }, columnIndex))) }) })] }));
125
+ return (_jsxs("div", { className: cn('kpt-megamenu-host', className), ...rest, children: [_jsx("div", { className: "kpt-megamenu", role: "menubar", "aria-orientation": "horizontal", "aria-label": ariaLabel || undefined, ref: barRef, children: items.map((item, index) => {
126
+ const hasMenu = !!item.items?.length;
127
+ if (isLink(item)) {
128
+ return (_jsxs("a", { ref: (el) => {
129
+ buttonsRef.current[index] = el;
130
+ }, className: "kpt-megamenu__top", role: "menuitem", href: item.url, tabIndex: index === tabStop ? 0 : -1, onClick: () => toggle(index, item), onFocus: () => setFocusIndex(index), onKeyDown: (event) => onTopKeyDown(event, index), children: [item.icon ? _jsx(KptIcon, { name: item.icon }) : null, _jsx("span", { children: item.label })] }, index));
131
+ }
132
+ return (_jsxs("button", { ref: (el) => {
133
+ buttonsRef.current[index] = el;
134
+ }, type: "button", className: cn('kpt-megamenu__top', openIndex === index && 'is-open'), role: "menuitem", disabled: item.disabled, tabIndex: index === tabStop ? 0 : -1, "aria-haspopup": hasMenu ? 'menu' : undefined, "aria-expanded": hasMenu ? openIndex === index : undefined, "aria-controls": openIndex === index ? panelId : undefined, onClick: () => toggle(index, item), onFocus: () => setFocusIndex(index), onKeyDown: (event) => onTopKeyDown(event, index), children: [item.icon ? _jsx(KptIcon, { name: item.icon }) : null, _jsx("span", { children: item.label }), hasMenu ? _jsx(KptIcon, { name: "chevron-down", className: "kpt-megamenu__caret" }) : null] }, index));
135
+ }) }), _jsx(KptOverlay, { open: openIndex !== null, anchor: barRef.current, onClose: closeAll, children: _jsx("div", { ref: panelRef, className: "kpt-megamenu__panel", role: "menu", id: panelId, "aria-label": openIndex === null ? undefined : items[openIndex]?.label, onKeyDown: onPanelKeyDown, children: columns.map((column, columnIndex) => {
136
+ const titleId = `${panelId}-col-${columnIndex}`;
137
+ return (_jsxs("div", { className: "kpt-megamenu__column", role: "group", "aria-labelledby": column.label ? titleId : undefined, children: [column.label ? (_jsx("div", { className: "kpt-megamenu__col-title", role: "presentation", id: titleId, children: column.label })) : null, (column.items ?? []).map((link, linkIndex) => isLink(link) ? (_jsxs("a", { className: "kpt-megamenu__link", role: "menuitem", tabIndex: -1, href: link.url, onClick: () => pick(link), children: [link.icon ? _jsx(KptIcon, { name: link.icon }) : null, _jsx("span", { children: link.label })] }, linkIndex)) : (_jsxs("button", { type: "button", className: "kpt-megamenu__link", role: "menuitem", tabIndex: -1, disabled: link.disabled, onClick: () => pick(link), children: [link.icon ? _jsx(KptIcon, { name: link.icon }) : null, _jsx("span", { children: link.label })] }, linkIndex)))] }, columnIndex));
138
+ }) }) })] }));
28
139
  }
@@ -45,12 +45,23 @@ Angular port, which stands on CDK. Position, backdrop and Escape behave the same
45
45
  `--kpt-color-surface-raised`, `--kpt-color-border`, `--kpt-elevation-3`, `--kpt-color-danger`
46
46
  (the `danger` item), `--kpt-z-overlay`.
47
47
 
48
+ ## Keyboard
49
+ - `↓` / `↑` on the trigger open the menu with focus on the first / last item; a click, `Enter`
50
+ or `Space` opens it on the first.
51
+ - Inside: `↓` / `↑` move between items and wrap around, `Home` / `End` jump to the ends;
52
+ disabled items, separators and headers are skipped.
53
+ - `Escape` and `Tab` close the menu, and so does picking an item — focus returns to the trigger.
54
+ - Items carry `tabindex="-1"` (roving focus), so an open menu is one Tab stop, not one per item.
55
+
56
+ The logic lives in `menu-keys.ts`, shared with `KptSplitButton`; the other port has a twin file.
57
+
48
58
  ## Accessibility
49
59
  The panel is `role="menu"`, items are native buttons with `role="menuitem"`, separators carry
50
60
  `role="separator"` and header rows `role="presentation"`, so a screen reader skips them instead of
51
61
  announcing them as actions.
52
62
 
53
- The trigger itself gets `aria-haspopup="menu"` and a live `aria-expanded`. They are placed on the
63
+ The trigger itself gets `aria-haspopup="menu"`, a live `aria-expanded` and, while open,
64
+ `aria-controls` pointing at the panel. They are placed on the
54
65
  interactive control INSIDE the slot (the projected `<button>`/`<a>`), never on the
55
66
  `.kpt-menu__trigger` wrapper — giving that wrapper a role would create a nested control and a second
56
67
  tab stop, and state attributes on a roleless element violate `aria-allowed-attr`.
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Indeks pozycji, na którą ma przejść fokus. `count` to liczba **aktywnych** pozycji (wyłączone,
3
+ * separatory i nagłówki są już odfiltrowane), `current` to indeks obecnie skupionej albo `-1`.
4
+ * Strzałki zawijają się na końcach. Zwraca `null` dla klawisza, który nie nawiguje.
5
+ */
6
+ export declare function nextMenuIndex(count: number, current: number, key: string): number | null;
7
+ /** Aktywne pozycje panelu w kolejności DOM. */
8
+ export declare function menuItems(panel: HTMLElement): HTMLElement[];
9
+ /** Skupia pierwszą albo ostatnią aktywną pozycję — po otwarciu menu z klawiatury lub myszy. */
10
+ export declare function focusMenuEdge(panel: HTMLElement, edge: 'first' | 'last'): void;
11
+ /**
12
+ * Obsługa klawisza w otwartym panelu. Zwraca `'close'`, gdy menu ma się zamknąć z powrotem
13
+ * fokusu na przycisk (Escape, Tab), `true`, gdy klawisz przesunął fokus, `false` — inny klawisz.
14
+ */
15
+ export declare function handleMenuKeydown(panel: HTMLElement, event: KeyboardEvent): 'close' | boolean;
@@ -0,0 +1,53 @@
1
+ /*
2
+ * Klawiatura panelu menu (wzorzec WAI-ARIA „menu button”), wspólna dla `kpt-menu`
3
+ * i `kpt-split-button` — oba mają ten sam panel z pozycjami `role="menuitem"`.
4
+ * Port Angulara ma bliźniaczy plik `lib/menu/menu-keys.ts` — zmiana zachowania w obu.
5
+ */
6
+ /** Klawisze przesuwające fokus po pozycjach. */
7
+ const NAV_KEYS = new Set(['ArrowDown', 'ArrowUp', 'Home', 'End']);
8
+ /**
9
+ * Indeks pozycji, na którą ma przejść fokus. `count` to liczba **aktywnych** pozycji (wyłączone,
10
+ * separatory i nagłówki są już odfiltrowane), `current` to indeks obecnie skupionej albo `-1`.
11
+ * Strzałki zawijają się na końcach. Zwraca `null` dla klawisza, który nie nawiguje.
12
+ */
13
+ export function nextMenuIndex(count, current, key) {
14
+ if (!NAV_KEYS.has(key) || count === 0)
15
+ return null;
16
+ switch (key) {
17
+ case 'Home':
18
+ return 0;
19
+ case 'End':
20
+ return count - 1;
21
+ case 'ArrowDown':
22
+ return current < 0 ? 0 : (current + 1) % count;
23
+ default:
24
+ return current < 0 ? count - 1 : (current - 1 + count) % count;
25
+ }
26
+ }
27
+ /** Aktywne pozycje panelu w kolejności DOM. */
28
+ export function menuItems(panel) {
29
+ return [...panel.querySelectorAll('[role="menuitem"]:not(:disabled)')];
30
+ }
31
+ /** Skupia pierwszą albo ostatnią aktywną pozycję — po otwarciu menu z klawiatury lub myszy. */
32
+ export function focusMenuEdge(panel, edge) {
33
+ const items = menuItems(panel);
34
+ (edge === 'first' ? items[0] : items[items.length - 1])?.focus();
35
+ }
36
+ /**
37
+ * Obsługa klawisza w otwartym panelu. Zwraca `'close'`, gdy menu ma się zamknąć z powrotem
38
+ * fokusu na przycisk (Escape, Tab), `true`, gdy klawisz przesunął fokus, `false` — inny klawisz.
39
+ */
40
+ export function handleMenuKeydown(panel, event) {
41
+ if (event.key === 'Escape' || event.key === 'Tab') {
42
+ event.preventDefault();
43
+ return 'close';
44
+ }
45
+ const items = menuItems(panel);
46
+ const current = items.indexOf(document.activeElement);
47
+ const next = nextMenuIndex(items.length, current, event.key);
48
+ if (next === null)
49
+ return false;
50
+ event.preventDefault();
51
+ items[next].focus();
52
+ return true;
53
+ }
@@ -10,6 +10,6 @@ export function MenuRows({ items, block, onPick }) {
10
10
  if (item.header) {
11
11
  return (_jsxs("div", { className: `${block}__header`, role: "presentation", children: [item.avatar ? (_jsx(KptAvatar, { src: item.avatar.src ?? null, name: item.avatar.name ?? '', icon: item.avatar.icon ?? null, size: "sm" })) : item.icon ? (_jsx(KptIcon, { name: item.icon })) : null, _jsxs("span", { className: `${block}__text`, children: [item.label ? _jsx("span", { className: `${block}__label`, children: item.label }) : null, item.description ? _jsx("span", { className: `${block}__description`, children: item.description }) : null] })] }, index));
12
12
  }
13
- return (_jsxs("button", { type: "button", className: `${block}__item${item.danger ? ` ${block}__item--danger` : ''}`, role: "menuitem", disabled: item.disabled, onClick: () => onPick(item), children: [item.icon ? _jsx(KptIcon, { name: item.icon }) : null, _jsxs("span", { className: `${block}__text`, children: [_jsx("span", { className: `${block}__label`, children: item.label }), item.description ? _jsx("span", { className: `${block}__description`, children: item.description }) : null] }), item.badge !== undefined && item.badge !== null ? (_jsx(KptBadge, { className: `${block}__badge`, value: item.badge, variant: item.badgeVariant ?? 'primary' })) : null] }, index));
13
+ return (_jsxs("button", { type: "button", className: `${block}__item${item.danger ? ` ${block}__item--danger` : ''}`, role: "menuitem", tabIndex: -1, disabled: item.disabled, onClick: () => onPick(item), children: [item.icon ? _jsx(KptIcon, { name: item.icon }) : null, _jsxs("span", { className: `${block}__text`, children: [_jsx("span", { className: `${block}__label`, children: item.label }), item.description ? _jsx("span", { className: `${block}__description`, children: item.description }) : null] }), item.badge !== undefined && item.badge !== null ? (_jsx(KptBadge, { className: `${block}__badge`, value: item.badge, variant: item.badgeVariant ?? 'primary' })) : null] }, index));
14
14
  });
15
15
  }