@konce-pt/react 0.11.1 → 0.12.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 (203) hide show
  1. package/README.md +8 -2
  2. package/dist/accordion/accordion.js +11 -10
  3. package/dist/alert/alert.js +1 -0
  4. package/dist/alert/llms.txt +1 -0
  5. package/dist/app-shell/app-shell.js +12 -5
  6. package/dist/app-shell/llms.txt +5 -4
  7. package/dist/auth/auth.js +17 -8
  8. package/dist/auth/llms.txt +3 -2
  9. package/dist/autocomplete/autocomplete.d.ts +1 -1
  10. package/dist/autocomplete/autocomplete.js +12 -4
  11. package/dist/autocomplete/llms.txt +3 -0
  12. package/dist/avatar/avatar.js +1 -0
  13. package/dist/avatar-group/avatar-group.js +1 -0
  14. package/dist/backdrop/backdrop.d.ts +0 -19
  15. package/dist/backdrop/backdrop.js +18 -13
  16. package/dist/bottom-sheet/bottom-sheet.d.ts +6 -1
  17. package/dist/bottom-sheet/bottom-sheet.js +3 -2
  18. package/dist/bottom-sheet/llms.txt +3 -0
  19. package/dist/breadcrumb/breadcrumb.d.ts +7 -1
  20. package/dist/breadcrumb/breadcrumb.js +10 -2
  21. package/dist/breadcrumb/llms.txt +8 -0
  22. package/dist/button/button.js +2 -1
  23. package/dist/button-group/button-group.d.ts +15 -2
  24. package/dist/button-group/button-group.js +31 -4
  25. package/dist/button-group/llms.txt +9 -6
  26. package/dist/card/card.js +4 -3
  27. package/dist/carousel/carousel.js +4 -1
  28. package/dist/charts/chart-legend.js +1 -0
  29. package/dist/charts/chart.d.ts +3 -2
  30. package/dist/charts/chart.js +12 -4
  31. package/dist/charts/llms.txt +6 -3
  32. package/dist/chat/chat.js +39 -9
  33. package/dist/chat/llms.txt +9 -0
  34. package/dist/chat-composer/chat-composer.d.ts +5 -2
  35. package/dist/chat-composer/chat-composer.js +10 -1
  36. package/dist/chat-dock/chat-dock.js +1 -0
  37. package/dist/chat-message/chat-message.js +2 -1
  38. package/dist/chat-typing/chat-typing.js +1 -0
  39. package/dist/checkbox/checkbox.d.ts +1 -1
  40. package/dist/checkbox/checkbox.js +9 -2
  41. package/dist/checkbox/llms.txt +3 -0
  42. package/dist/chip/chip.js +1 -0
  43. package/dist/chips-input/chips-input.d.ts +3 -1
  44. package/dist/chips-input/chips-input.js +14 -5
  45. package/dist/chips-input/llms.txt +3 -0
  46. package/dist/clock/clock.d.ts +9 -4
  47. package/dist/clock/clock.js +35 -5
  48. package/dist/clock/llms.txt +6 -2
  49. package/dist/color-picker/color-picker.d.ts +5 -1
  50. package/dist/color-picker/color-picker.js +36 -17
  51. package/dist/color-picker/llms.txt +3 -0
  52. package/dist/confirm/confirm-container.js +1 -0
  53. package/dist/confirm/confirm-store.js +6 -0
  54. package/dist/confirm/llms.txt +2 -1
  55. package/dist/context-menu/context-menu.js +35 -12
  56. package/dist/context-menu/llms.txt +3 -0
  57. package/dist/data-table/data-table.d.ts +12 -1
  58. package/dist/data-table/data-table.js +113 -51
  59. package/dist/data-table/export-csv.d.ts +2 -2
  60. package/dist/data-table/export-csv.js +7 -9
  61. package/dist/data-table/llms.txt +19 -4
  62. package/dist/data-view/data-view.js +1 -0
  63. package/dist/date-range/date-range.d.ts +16 -4
  64. package/dist/date-range/date-range.js +69 -46
  65. package/dist/date-range/llms.txt +17 -3
  66. package/dist/datepicker/datepicker.d.ts +17 -3
  67. package/dist/datepicker/datepicker.js +86 -54
  68. package/dist/datepicker/llms.txt +17 -3
  69. package/dist/dialog/dialog.js +29 -8
  70. package/dist/dialog/llms.txt +3 -0
  71. package/dist/divider/divider.d.ts +7 -1
  72. package/dist/divider/divider.js +3 -2
  73. package/dist/divider/llms.txt +1 -0
  74. package/dist/drawer/drawer.d.ts +6 -1
  75. package/dist/drawer/drawer.js +3 -2
  76. package/dist/drawer/llms.txt +3 -0
  77. package/dist/fieldset/fieldset.js +4 -1
  78. package/dist/file-upload/file-upload.d.ts +7 -1
  79. package/dist/file-upload/file-upload.js +18 -7
  80. package/dist/file-upload/llms.txt +6 -3
  81. package/dist/form-field/form-field.js +1 -0
  82. package/dist/galleria/galleria.js +4 -1
  83. package/dist/i18n/i18n-context.d.ts +3 -3
  84. package/dist/i18n/i18n-context.js +25 -3
  85. package/dist/icon/icon.js +4 -1
  86. package/dist/image/image.js +4 -4
  87. package/dist/input/input.js +1 -0
  88. package/dist/input-mask/input-mask.d.ts +7 -2
  89. package/dist/input-mask/input-mask.js +15 -41
  90. package/dist/input-mask/llms.txt +3 -0
  91. package/dist/input-number/input-number.d.ts +3 -1
  92. package/dist/input-number/input-number.js +15 -11
  93. package/dist/input-number/llms.txt +5 -2
  94. package/dist/input-otp/input-otp.d.ts +16 -3
  95. package/dist/input-otp/input-otp.js +43 -19
  96. package/dist/input-otp/llms.txt +11 -0
  97. package/dist/internal/mask.d.ts +14 -0
  98. package/dist/internal/mask.js +62 -0
  99. package/dist/internal/number-group.d.ts +22 -0
  100. package/dist/internal/number-group.js +53 -0
  101. package/dist/internal/option-key.d.ts +6 -0
  102. package/dist/internal/option-key.js +8 -0
  103. package/dist/internal/overlay.js +37 -7
  104. package/dist/internal/password-strength.d.ts +6 -0
  105. package/dist/internal/password-strength.js +74 -0
  106. package/dist/internal/scroll-lock.d.ts +6 -0
  107. package/dist/internal/scroll-lock.js +35 -0
  108. package/dist/internal/slot-children.d.ts +13 -0
  109. package/dist/internal/slot-children.js +31 -0
  110. package/dist/internal/step.d.ts +12 -0
  111. package/dist/internal/step.js +31 -0
  112. package/dist/internal/tween.js +2 -3
  113. package/dist/internal/use-controllable-state.d.ts +5 -0
  114. package/dist/internal/use-controllable-state.js +25 -4
  115. package/dist/internal/use-modal-focus.d.ts +1 -0
  116. package/dist/internal/use-modal-focus.js +12 -1
  117. package/dist/knob/knob.d.ts +5 -2
  118. package/dist/knob/knob.js +15 -14
  119. package/dist/knob/llms.txt +4 -2
  120. package/dist/listbox/listbox.d.ts +3 -1
  121. package/dist/listbox/listbox.js +30 -10
  122. package/dist/listbox/llms.txt +3 -0
  123. package/dist/map/llms.txt +5 -1
  124. package/dist/map/map-create-dialog.js +30 -22
  125. package/dist/map/map-hours-field.js +1 -0
  126. package/dist/map/map-image-field.js +2 -1
  127. package/dist/map/map-legend.js +1 -0
  128. package/dist/map/map-poi-card.js +2 -1
  129. package/dist/map/map-search.js +54 -30
  130. package/dist/map/map.d.ts +6 -1
  131. package/dist/map/map.js +62 -34
  132. package/dist/megamenu/llms.txt +11 -0
  133. package/dist/megamenu/megamenu.d.ts +7 -1
  134. package/dist/megamenu/megamenu.js +15 -6
  135. package/dist/menu/menu-rows.js +1 -0
  136. package/dist/menu/menu.js +10 -3
  137. package/dist/menubar/llms.txt +11 -0
  138. package/dist/menubar/menubar.d.ts +7 -1
  139. package/dist/menubar/menubar.js +15 -3
  140. package/dist/meter-group/meter-group.js +1 -0
  141. package/dist/order-list/llms.txt +3 -0
  142. package/dist/order-list/order-list.js +21 -6
  143. package/dist/paginator/paginator.js +7 -4
  144. package/dist/panel/panel.js +5 -3
  145. package/dist/password/llms.txt +6 -2
  146. package/dist/password/password.js +4 -14
  147. package/dist/peek/llms.txt +1 -1
  148. package/dist/peek/peek.js +37 -15
  149. package/dist/pick-list/llms.txt +3 -0
  150. package/dist/pick-list/pick-list.js +19 -3
  151. package/dist/popover/llms.txt +3 -0
  152. package/dist/popover/popover.d.ts +3 -1
  153. package/dist/popover/popover.js +10 -3
  154. package/dist/progress/progress.js +1 -0
  155. package/dist/public-api.d.ts +1 -1
  156. package/dist/radio-group/llms.txt +3 -0
  157. package/dist/radio-group/radio-group.d.ts +3 -1
  158. package/dist/radio-group/radio-group.js +13 -2
  159. package/dist/rating/rating.js +1 -0
  160. package/dist/rich-text/llms.txt +14 -0
  161. package/dist/rich-text/rich-text.d.ts +3 -9
  162. package/dist/rich-text/rich-text.js +241 -96
  163. package/dist/rich-text/sanitize.d.ts +9 -0
  164. package/dist/rich-text/sanitize.js +77 -29
  165. package/dist/roadmap/llms.txt +9 -3
  166. package/dist/roadmap/roadmap.js +88 -11
  167. package/dist/scroll-top/scroll-top.js +1 -0
  168. package/dist/select/llms.txt +3 -0
  169. package/dist/select/select.d.ts +3 -1
  170. package/dist/select/select.js +46 -32
  171. package/dist/shared/types.d.ts +21 -0
  172. package/dist/shared/types.js +9 -1
  173. package/dist/sidenav/sidenav.d.ts +1 -2
  174. package/dist/sidenav/sidenav.js +4 -3
  175. package/dist/slider/llms.txt +3 -0
  176. package/dist/slider/slider.d.ts +3 -1
  177. package/dist/slider/slider.js +15 -4
  178. package/dist/speed-dial/speed-dial.js +2 -1
  179. package/dist/spinner/spinner.js +1 -0
  180. package/dist/split-button/split-button.js +10 -3
  181. package/dist/splitter/splitter.js +23 -8
  182. package/dist/stepper/llms.txt +3 -0
  183. package/dist/stepper/stepper.js +9 -5
  184. package/dist/switch/llms.txt +3 -0
  185. package/dist/switch/switch.d.ts +1 -1
  186. package/dist/switch/switch.js +9 -2
  187. package/dist/switch-group/llms.txt +3 -0
  188. package/dist/switch-group/switch-group.d.ts +3 -1
  189. package/dist/switch-group/switch-group.js +5 -3
  190. package/dist/tabs/llms.txt +3 -0
  191. package/dist/tabs/tabs.js +30 -14
  192. package/dist/textarea/textarea.js +1 -0
  193. package/dist/toast/llms.txt +3 -0
  194. package/dist/toast/toast-container.js +1 -0
  195. package/dist/toast/toast-store.d.ts +5 -1
  196. package/dist/toast/toast-store.js +1 -1
  197. package/dist/tooltip/llms.txt +5 -3
  198. package/dist/tooltip/tooltip.d.ts +3 -2
  199. package/dist/tooltip/tooltip.js +148 -55
  200. package/dist/tree/llms.txt +1 -0
  201. package/dist/tree/tree.d.ts +6 -1
  202. package/dist/tree/tree.js +14 -7
  203. package/package.json +15 -14
@@ -1,7 +1,6 @@
1
1
  /*
2
- * Sanityzacja treści edytora. Kod nie zna Reacta — operuje wyłącznie na DOM, więc gdyby port
3
- * Angulara kiedyś sięgnął po wspólny rdzeń, wystarczy przenieść ten plik do osobnej paczki
4
- * (jak `@konce-pt/datetime`).
2
+ * Sanityzacja treści edytora. Kod nie zna frameworka — operuje wyłącznie na DOM. Bliźniak
3
+ * w porcie Angulara: `rich-text/sanitize.ts` — zmiana w obu (docelowo wspólna paczka pure-TS).
5
4
  */
6
5
  /** Znaczniki dopuszczone w treści; pozostałe rozwijamy do dzieci, nie usuwając tekstu. */
7
6
  const ALLOWED_TAGS = new Set([
@@ -17,15 +16,29 @@ const ALLOWED_STYLES = new Set([
17
16
  * `data-mention` i `contenteditable` są tu dla wzmianek: identyfikator musi przeżyć zapis treści,
18
17
  * a `contenteditable="false"` trzyma wzmiankę jako jeden kawałek, w którego środku nie da się
19
18
  * pisać. Oba atrybuty są bierne — nie wykonują niczego i nie wskazują zasobów, więc wpuszczenie
20
- * ich nie otwiera nowej furtki.
19
+ * ich nie otwiera nowej furtki. `class` przechodzi osobno, tylko z klasami samego edytora.
21
20
  */
22
21
  const ALLOWED_ATTRS = new Set([
23
- 'colspan', 'rowspan', 'span', 'width', 'class', 'target', 'rel', 'title',
22
+ 'colspan', 'rowspan', 'span', 'width', 'target', 'rel', 'title', 'color',
24
23
  'data-mention', 'contenteditable',
25
24
  ]);
26
- // Schematy dopuszczone w `href` narzędzia „link"; blocklista byłaby obchodzalna.
25
+ /**
26
+ * Klasy, które zostawia sam edytor (`kpt-rt-table`, `kpt-mention`). Obce klasy z wklejki — aplikacji,
27
+ * biblioteki (`kpt-dialog`, `kpt-visually-hidden`) czy Tailwinda — przejęłyby ich wygląd tam,
28
+ * gdzie aplikacja wyświetla treść.
29
+ */
30
+ const EDITOR_CLASS = /^kpt-(rt-[a-z0-9-]+|mention)$/;
31
+ // Schematy dopuszczone w `href` — ta sama allowlista dla narzędzia „link” i dla wklejonego HTML.
27
32
  const SAFE_URL_SCHEME = /^(https?:|mailto:|tel:)/i;
28
33
  const HAS_URL_SCHEME = /^[a-z][a-z0-9+.-]*:/i;
34
+ /** Znaki sterujące, które przeglądarka pomija przy parsowaniu URL-a (`jav\tascript:`). */
35
+ // eslint-disable-next-line no-control-regex -- celowo: właśnie te znaki trzeba wyciąć przed testem schematu
36
+ const URL_CONTROL = /[\x00-\x1f]/g;
37
+ /** Czy adres z treści może zostać: względny (bez schematu) albo ze schematu z allowlisty. */
38
+ function isSafeHref(value) {
39
+ const compact = value.replace(URL_CONTROL, '').trim();
40
+ return !HAS_URL_SCHEME.test(compact) || SAFE_URL_SCHEME.test(compact);
41
+ }
29
42
  /** Deklaracje `style` z allowlisty; `null`, gdy nie zostaje nic. */
30
43
  function sanitizeStyleAttr(value) {
31
44
  const kept = value
@@ -34,56 +47,91 @@ function sanitizeStyleAttr(value) {
34
47
  .filter((decl) => ALLOWED_STYLES.has(decl.split(':')[0]?.trim().toLowerCase() ?? ''));
35
48
  return kept.length ? kept.join('; ') : null;
36
49
  }
37
- function sanitizeAttribute(el, attr) {
50
+ function sanitizeAttribute(el, attr, report) {
38
51
  const name = attr.name.toLowerCase();
39
- if (name.startsWith('on')) {
52
+ const remove = () => {
40
53
  el.removeAttribute(attr.name);
41
- }
42
- else if (name === 'href' || name === 'src') {
43
- // Znaki kontrolne usuwamy przed testem schematu — przeglądarka i tak je pomija przy
44
- // parsowaniu URL-a, więc `jav\tascript:` obszedłby samo porównanie napisów.
45
- // eslint-disable-next-line no-control-regex
46
- if (/^\s*javascript:/i.test(attr.value.replace(/[\x00-\x1f]/g, '')))
47
- el.removeAttribute(attr.name);
54
+ report.changed = true;
55
+ };
56
+ if (name === 'href') {
57
+ if (!isSafeHref(attr.value))
58
+ remove();
48
59
  }
49
60
  else if (name === 'style') {
50
61
  const kept = sanitizeStyleAttr(attr.value);
51
- if (kept)
62
+ const declared = attr.value.split(';').filter((decl) => decl.trim()).length;
63
+ if (kept === null)
64
+ remove();
65
+ else if (kept.split('; ').length !== declared) {
66
+ // Zmiana tylko wtedy, gdy wypadła deklaracja — samo przeformatowanie zapisu się nie liczy.
52
67
  el.setAttribute('style', kept);
53
- else
54
- el.removeAttribute('style');
68
+ report.changed = true;
69
+ }
55
70
  }
56
- else if (!ALLOWED_ATTRS.has(name) && name !== 'color') {
57
- el.removeAttribute(attr.name);
71
+ else if (name === 'class') {
72
+ const tokens = attr.value.split(/\s+/).filter(Boolean);
73
+ const kept = tokens.filter((token) => EDITOR_CLASS.test(token));
74
+ if (kept.length === 0)
75
+ remove();
76
+ else if (kept.length !== tokens.length) {
77
+ el.setAttribute('class', kept.join(' '));
78
+ report.changed = true;
79
+ }
58
80
  }
81
+ else if (!ALLOWED_ATTRS.has(name)) {
82
+ // Także `on*` i `src` — żaden dopuszczony znacznik nie ładuje zasobów.
83
+ remove();
84
+ }
85
+ }
86
+ /** Link otwierany w nowej karcie dostaje `noopener noreferrer` — bez tego karta sięga do `opener`. */
87
+ function secureTarget(el, report) {
88
+ if (el.tagName !== 'A' || !el.hasAttribute('target'))
89
+ return;
90
+ const rel = new Set((el.getAttribute('rel') ?? '').split(/\s+/).filter(Boolean));
91
+ if (rel.has('noopener') && rel.has('noreferrer'))
92
+ return;
93
+ rel.add('noopener');
94
+ rel.add('noreferrer');
95
+ el.setAttribute('rel', [...rel].join(' '));
96
+ report.changed = true;
59
97
  }
60
98
  /** Tag spoza allowlisty rozwijamy do dzieci; atrybuty czyścimy po kolei. */
61
- function sanitizeElement(el) {
99
+ function sanitizeElement(el, report) {
62
100
  if (!ALLOWED_TAGS.has(el.tagName)) {
63
101
  el.replaceWith(...Array.from(el.childNodes));
102
+ report.changed = true;
64
103
  return;
65
104
  }
66
105
  for (const attr of Array.from(el.attributes))
67
- sanitizeAttribute(el, attr);
106
+ sanitizeAttribute(el, attr, report);
107
+ secureTarget(el, report);
68
108
  }
69
109
  /**
70
- * Czyści HTML wchodzący do edytora. Dotyczy **każdego** zapisu do DOM edytora, nie tylko
71
- * wklejania: `value` z aplikacji też bywa niezaufane (baza, CMS, inny użytkownik).
110
+ * Czyści HTML i mówi, czy coś usunął. `changed: false` znaczy, że treść była czysta — wtedy
111
+ * wołający nie musi nadpisywać wartości modelu (serializacja DOM potrafi zmienić zapis, np.
112
+ * cudzysłowy, bez zmiany treści, a taka różnica nie może brudzić formularza).
72
113
  */
73
- export function sanitizeHtml(html) {
114
+ export function sanitizeHtmlReport(html) {
74
115
  const template = document.createElement('template');
75
116
  template.innerHTML = html;
117
+ const report = { changed: false };
76
118
  for (const el of Array.from(template.content.querySelectorAll('*')))
77
- sanitizeElement(el);
78
- return template.innerHTML;
119
+ sanitizeElement(el, report);
120
+ return { html: template.innerHTML, changed: report.changed };
121
+ }
122
+ /**
123
+ * Czyści HTML wchodzący do edytora. Dotyczy **każdego** zapisu do DOM edytora, nie tylko
124
+ * wklejania: `value` z aplikacji też bywa niezaufane (baza, CMS, inny użytkownik).
125
+ */
126
+ export function sanitizeHtml(html) {
127
+ return sanitizeHtmlReport(html).html;
79
128
  }
80
129
  /**
81
130
  * Waliduje URL z narzędzia „link". Brak schematu → dopisujemy `https://`; `#` i `/` przechodzą
82
131
  * bez zmian; schemat spoza allowlisty → `null`, czyli link nie zostaje wstawiony.
83
132
  */
84
133
  export function sanitizeUrl(url) {
85
- // eslint-disable-next-line no-control-regex
86
- const trimmed = url.trim().replace(/[\x00-\x1f]/g, '');
134
+ const trimmed = url.trim().replace(URL_CONTROL, '');
87
135
  if (!trimmed)
88
136
  return null;
89
137
  if (trimmed.startsWith('#') || trimmed.startsWith('/'))
@@ -69,7 +69,7 @@ frame without arrows beats arrows in the wrong place.
69
69
  - Entries with an invalid date or a duplicate `id` are skipped, with a warning in a development build.
70
70
 
71
71
  ## i18n
72
- The `roadmap.*` keys: `empty`, `week`, `today`, `milestone`, `expandGroup`, `collapseGroup`.
72
+ The `roadmap.*` keys: `empty`, `week`, `today`, `milestone`, `expandGroup`, `collapseGroup`, `progress` (`{percent}`).
73
73
  Month and weekday names come from `Intl` per `config.locale` or the locale from `KptI18nProvider`.
74
74
 
75
75
  ## Tokens
@@ -78,5 +78,11 @@ bars per variant `--kpt-color-{primary,success,danger,warning,info,muted}`.
78
78
  Sizes: `--kpt-roadmap-label-w`, `--kpt-roadmap-cell-w` (both can be overridden from outside).
79
79
 
80
80
  ## Accessibility
81
- Group toggles are real buttons with `aria-expanded`; bars and milestones are focusable and answer
82
- Enter and Space like a click. The background grid and the dependency layer are `aria-hidden`.
81
+ Bars and milestones are `role="button"` with an accessible name built from the label, sublabel, date range
82
+ and progress (`Design, 3–14 Mar 2026, 40% complete`) — the end is exclusive, so the last occupied day is
83
+ announced. The whole bar grid is one Tab stop (roving tabindex): Left/Right move within a row, Up/Down go to
84
+ the bar with the nearest start in the neighbouring row, Home/End jump to the row's edge, Ctrl+Home/End to the
85
+ first/last bar; Enter and Space act like a click. With `virtual` on, moving to a row outside the window
86
+ scrolls it in first. Disabled items get `aria-disabled`. A group toggle is named by the group label and
87
+ carries `aria-expanded`; "Expand/Collapse group" is only its tooltip. The background grid and the dependency
88
+ layer are `aria-hidden`.
@@ -1,6 +1,7 @@
1
+ 'use client';
1
2
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
3
  import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, } from 'react';
3
- import { buildRows, buildTimeline, linkGeometry, resolveLinks, resolveRange, sliceRows, } from '@konce-pt/roadmap';
4
+ import { buildRows, buildTimeline, linkGeometry, resolveLinks, resolveRange, roadmapItemDates, roadmapNavTarget, sliceRows, } from '@konce-pt/roadmap';
4
5
  import { cn } from "../internal/cn.js";
5
6
  import { isDevBuild } from "../internal/dev.js";
6
7
  import { useControllableState } from "../internal/use-controllable-state.js";
@@ -44,9 +45,9 @@ export function KptRoadmap({ config, scrollHeight = null, labelWidth = '14rem',
44
45
  * efekt zrobiłby to samo o jeden render za późno, czyli po mignięciu rozwiniętej grupy.
45
46
  */
46
47
  const [fromConfig, setFromConfig] = useState(() => declaredCollapsed(config.groups));
47
- const groupsRef = useRef(config.groups);
48
- if (groupsRef.current !== config.groups) {
49
- groupsRef.current = config.groups;
48
+ const [prevGroups, setPrevGroups] = useState(config.groups);
49
+ if (prevGroups !== config.groups) {
50
+ setPrevGroups(config.groups);
50
51
  setFromConfig(declaredCollapsed(config.groups));
51
52
  }
52
53
  /** Suma obu źródeł — stan faktycznie użyty do budowy wierszy. */
@@ -68,6 +69,28 @@ export function KptRoadmap({ config, scrollHeight = null, labelWidth = '14rem',
68
69
  weekPrefix: config.timeline?.weekPrefix ?? t('roadmap.week'),
69
70
  }), [range, zoom, config, locale, t]);
70
71
  const layout = useMemo(() => buildRows(config.items, config.groups, collapsedIds, config.rows), [config.items, config.groups, config.rows, collapsedIds]);
72
+ const itemLocale = config.locale ?? config.timeline?.locale ?? locale;
73
+ /**
74
+ * Paski widoczne na osi, wiersz po wierszu — mapa dla nawigacji strzałkami. Liczona ze wszystkich
75
+ * wierszy, nie z okna wirtualizacji: strzałka w dół z ostatniego wyrenderowanego wiersza ma dokąd iść.
76
+ */
77
+ const navGrid = useMemo(() => layout.rows.map((row) => row.kind === 'group'
78
+ ? []
79
+ : row.items
80
+ .filter((item) => timeline.span(item.start, item.end).visible)
81
+ .map((item) => ({ id: item.id, start: item.start }))
82
+ .sort((a, b) => a.start - b.start)), [layout, timeline]);
83
+ /** Pasek, na którym ostatnio był fokus. */
84
+ const [activeId, setActiveId] = useState(null);
85
+ /**
86
+ * Jedyny przystanek Tab w siatce pasków (roving tabindex) — ostatnio aktywny pasek, a gdy zniknął
87
+ * (zwinięta grupa, inne dane), pierwszy. Bez tego roadmapa ze stu zadaniami to sto przystanków Tab.
88
+ */
89
+ const tabStopId = useMemo(() => {
90
+ if (activeId && navGrid.some((row) => row.some((item) => item.id === activeId)))
91
+ return activeId;
92
+ return navGrid.find((row) => row.length > 0)?.[0].id ?? null;
93
+ }, [navGrid, activeId]);
71
94
  /** Wiersze faktycznie renderowane — całość albo okno wirtualizacji. */
72
95
  const visibleRows = useMemo(() => {
73
96
  if (!virtual || viewportHeight <= 0)
@@ -110,6 +133,21 @@ export function KptRoadmap({ config, scrollHeight = null, labelWidth = '14rem',
110
133
  const total = row.laneCount * barHeight + (row.laneCount - 1) * LANE_GAP;
111
134
  return Math.max(0, (row.height - total) / 2) + lane * laneStep;
112
135
  }, [barHeight, laneStep]);
136
+ /** Nazwa dostępna paska: „Etykieta, 3–14 mar 2026, ukończono 40%". */
137
+ const itemAriaLabel = useCallback((item) => {
138
+ const source = item.source;
139
+ const dates = roadmapItemDates(item.start, item.end, itemLocale, {
140
+ isMilestone: item.isMilestone,
141
+ withTime: zoom === 'hour',
142
+ });
143
+ const parts = [item.isMilestone ? `${t('roadmap.milestone')}: ${source.label}` : source.label, dates];
144
+ if (source.sublabel)
145
+ parts.splice(1, 0, source.sublabel);
146
+ if (source.progress != null && !item.isMilestone) {
147
+ parts.push(t('roadmap.progress', { percent: Math.round(source.progress * 100) }));
148
+ }
149
+ return parts.join(', ');
150
+ }, [itemLocale, zoom, t]);
113
151
  /** Paski danego wiersza gotowe do renderu. */
114
152
  const bars = useCallback((row) => {
115
153
  const result = [];
@@ -132,10 +170,11 @@ export function KptRoadmap({ config, scrollHeight = null, labelWidth = '14rem',
132
170
  isMilestone: item.isMilestone,
133
171
  clippedStart: geom.clippedStart,
134
172
  clippedEnd: geom.clippedEnd,
173
+ ariaLabel: itemAriaLabel(item),
135
174
  });
136
175
  }
137
176
  return result;
138
- }, [timeline, barTop, barHeight]);
177
+ }, [timeline, barTop, barHeight, itemAriaLabel]);
139
178
  /** Pasek zbiorczy wiersza grupy. */
140
179
  const summary = useCallback((row) => {
141
180
  const span = row.groupState?.span;
@@ -208,11 +247,49 @@ export function KptRoadmap({ config, scrollHeight = null, labelWidth = '14rem',
208
247
  return;
209
248
  onItemClick?.(item.source);
210
249
  };
250
+ /** Przy wirtualizacji przewija tak, żeby wiersz z paskiem wszedł do okna i się wyrenderował. */
251
+ const revealRow = (scroll, id) => {
252
+ if (!virtual)
253
+ return;
254
+ const row = layout.rows.find((candidate) => candidate.items.some((item) => item.id === id));
255
+ if (!row)
256
+ return;
257
+ const header = scroll.querySelector('.kpt-roadmap__head')?.offsetHeight ?? 0;
258
+ const viewport = scroll.clientHeight - header;
259
+ let top = scroll.scrollTop;
260
+ if (row.top < top)
261
+ top = row.top;
262
+ else if (row.top + row.height > top + viewport)
263
+ top = row.top + row.height - viewport;
264
+ if (top === scroll.scrollTop)
265
+ return;
266
+ scroll.scrollTop = top;
267
+ setScrollTop(top);
268
+ };
269
+ /**
270
+ * Klawiatura na pasku: Enter/Spacja działają jak kliknięcie, strzałki przenoszą fokus po siatce
271
+ * (w poziomie po wierszu, w pionie między wierszami), Home/End na skraj wiersza, z Ctrl — roadmapy.
272
+ */
211
273
  const handleItemKeydown = (event, item) => {
212
- if (event.key !== 'Enter' && event.key !== ' ')
274
+ if (event.key === 'Enter' || event.key === ' ') {
275
+ event.preventDefault();
276
+ handleItemClick(item);
277
+ return;
278
+ }
279
+ const target = roadmapNavTarget(navGrid, item.id, event.key, event.ctrlKey || event.metaKey);
280
+ if (!target)
213
281
  return;
214
282
  event.preventDefault();
215
- handleItemClick(item);
283
+ setActiveId(target);
284
+ const scroll = event.currentTarget.closest('.kpt-roadmap__scroll');
285
+ if (!scroll)
286
+ return;
287
+ revealRow(scroll, target);
288
+ // Pasek z innego wiersza może jeszcze nie istnieć w DOM (wirtualizacja). Aktualizacja ze zdarzenia
289
+ // klawiatury jest zatwierdzana synchronicznie, więc w następnej klatce pasek już jest.
290
+ requestAnimationFrame(() => {
291
+ scroll.querySelector(`[data-kpt-item="${CSS.escape(target)}"]`)?.focus();
292
+ });
216
293
  };
217
294
  /**
218
295
  * Treść dymka dla kolumny etykiet. Pusta tam, gdzie dymek nie miałby czego
@@ -242,25 +319,25 @@ export function KptRoadmap({ config, scrollHeight = null, labelWidth = '14rem',
242
319
  const group = row.group;
243
320
  const first = row.items[0];
244
321
  const sum = row.kind === 'group' ? summary(row) : null;
245
- return (_jsxs("div", { className: cn('kpt-roadmap__row', row.kind === 'group' && 'kpt-roadmap__row--group'), style: { insetBlockStart: `${row.top}px`, blockSize: `${row.height}px` }, children: [_jsx(KptTooltip, { text: labelTooltip(row), onlyWhenTruncated: true, children: _jsx("div", { className: cn('kpt-roadmap__label', row.kind === 'group' && 'kpt-roadmap__label--group'), style: { paddingInlineStart: `${0.75 + row.depth * 1}rem` }, children: group ? (_jsxs(_Fragment, { children: [_jsx("button", { type: "button", className: "kpt-roadmap__toggle", "aria-expanded": !isCollapsed(group), "aria-label": isCollapsed(group) ? t('roadmap.expandGroup') : t('roadmap.collapseGroup'), onClick: () => toggleGroup(group), children: _jsx("span", { className: cn('kpt-roadmap__toggle-icon', !isCollapsed(group) && 'kpt-roadmap__toggle-icon--open') }) }), _jsx(KptTooltip, { text: group.sublabel ? `${group.label} — ${group.sublabel}` : group.label, onlyWhenTruncated: true, children: _jsxs("span", { className: "kpt-roadmap__label-stack", children: [_jsx("span", { className: "kpt-roadmap__label-text kpt-roadmap__label-text--group", children: group.label }), group.sublabel && _jsx("span", { className: "kpt-roadmap__label-sub", children: group.sublabel })] }) }), row.groupState?.itemCount ? (_jsx("span", { className: "kpt-roadmap__label-count", children: row.groupState.itemCount })) : null] })) : (first && (_jsxs(_Fragment, { children: [_jsx("span", { className: "kpt-roadmap__label-text", children: first.source.label }), row.items.length === 1 && first.source.sublabel && (_jsx("span", { className: "kpt-roadmap__label-sub", children: first.source.sublabel }))] }))) }) }), _jsx("div", { className: "kpt-roadmap__lane", children: row.kind === 'group'
322
+ return (_jsxs("div", { className: cn('kpt-roadmap__row', row.kind === 'group' && 'kpt-roadmap__row--group'), style: { insetBlockStart: `${row.top}px`, blockSize: `${row.height}px` }, children: [_jsx(KptTooltip, { text: labelTooltip(row), onlyWhenTruncated: true, children: _jsx("div", { className: cn('kpt-roadmap__label', row.kind === 'group' && 'kpt-roadmap__label--group'), style: { paddingInlineStart: `${0.75 + row.depth * 1}rem` }, children: group ? (_jsxs(_Fragment, { children: [_jsx(KptTooltip, { text: isCollapsed(group) ? t('roadmap.expandGroup') : t('roadmap.collapseGroup'), children: _jsx("button", { type: "button", className: "kpt-roadmap__toggle", "aria-expanded": !isCollapsed(group), "aria-label": group.label, onClick: () => toggleGroup(group), children: _jsx("span", { className: cn('kpt-roadmap__toggle-icon', !isCollapsed(group) && 'kpt-roadmap__toggle-icon--open') }) }) }), _jsx(KptTooltip, { text: group.sublabel ? `${group.label} — ${group.sublabel}` : group.label, onlyWhenTruncated: true, children: _jsxs("span", { className: "kpt-roadmap__label-stack", children: [_jsx("span", { className: "kpt-roadmap__label-text kpt-roadmap__label-text--group", children: group.label }), group.sublabel && _jsx("span", { className: "kpt-roadmap__label-sub", children: group.sublabel })] }) }), row.groupState?.itemCount ? (_jsx("span", { className: "kpt-roadmap__label-count", children: row.groupState.itemCount })) : null] })) : (first && (_jsxs(_Fragment, { children: [_jsx("span", { className: "kpt-roadmap__label-text", children: first.source.label }), row.items.length === 1 && first.source.sublabel && (_jsx("span", { className: "kpt-roadmap__label-sub", children: first.source.sublabel }))] }))) }) }), _jsx("div", { className: "kpt-roadmap__lane", children: row.kind === 'group'
246
323
  ? sum && (_jsx("div", { className: "kpt-roadmap__summary", "data-variant": sum.variant, style: {
247
324
  insetInlineStart: `${sum.left}%`,
248
325
  inlineSize: `${sum.width}%`,
249
326
  backgroundColor: sum.color,
250
327
  } }))
251
- : bars(row).map((bar) => bar.isMilestone ? (_jsx(KptTooltip, { text: bar.title, children: _jsx("div", { className: "kpt-roadmap__milestone", "data-variant": bar.variant, "aria-label": `${t('roadmap.milestone')}: ${bar.label}`, style: {
328
+ : bars(row).map((bar) => bar.isMilestone ? (_jsx(KptTooltip, { text: bar.title, children: _jsx("div", { className: "kpt-roadmap__milestone", "data-variant": bar.variant, style: {
252
329
  insetInlineStart: `${bar.left}%`,
253
330
  insetBlockStart: `${bar.top}px`,
254
331
  inlineSize: `${bar.height}px`,
255
332
  blockSize: `${bar.height}px`,
256
333
  backgroundColor: bar.color,
257
- }, tabIndex: 0, onClick: () => handleItemClick(bar.item), onKeyDown: (event) => handleItemKeydown(event, bar.item) }) }, bar.item.id)) : (_jsx(KptTooltip, { text: bar.title, onlyWhenTruncated: true, children: _jsxs("div", { className: cn('kpt-roadmap__bar', bar.clippedStart && 'kpt-roadmap__bar--clipped-start', bar.clippedEnd && 'kpt-roadmap__bar--clipped-end', bar.item.source.disabled && 'kpt-roadmap__bar--disabled'), "data-variant": bar.variant, style: {
334
+ }, role: "button", "aria-label": bar.ariaLabel, "aria-disabled": bar.item.source.disabled || undefined, "data-kpt-item": bar.item.id, tabIndex: bar.item.id === tabStopId ? 0 : -1, onClick: () => handleItemClick(bar.item), onFocus: () => setActiveId(bar.item.id), onKeyDown: (event) => handleItemKeydown(event, bar.item) }) }, bar.item.id)) : (_jsx(KptTooltip, { text: bar.title, onlyWhenTruncated: true, children: _jsxs("div", { className: cn('kpt-roadmap__bar', bar.clippedStart && 'kpt-roadmap__bar--clipped-start', bar.clippedEnd && 'kpt-roadmap__bar--clipped-end', bar.item.source.disabled && 'kpt-roadmap__bar--disabled'), "data-variant": bar.variant, style: {
258
335
  insetInlineStart: `${bar.left}%`,
259
336
  inlineSize: `${bar.width}%`,
260
337
  insetBlockStart: `${bar.top}px`,
261
338
  blockSize: `${bar.height}px`,
262
339
  backgroundColor: bar.color,
263
- }, tabIndex: 0, onClick: () => handleItemClick(bar.item), onKeyDown: (event) => handleItemKeydown(event, bar.item), children: [bar.progress !== null && (_jsx("span", { className: "kpt-roadmap__bar-progress", style: { inlineSize: `${bar.progress * 100}%` } })), _jsx("span", { className: "kpt-roadmap__bar-label", children: bar.label })] }) }, bar.item.id))) })] }, row.key));
340
+ }, role: "button", "aria-label": bar.ariaLabel, "aria-disabled": bar.item.source.disabled || undefined, "data-kpt-item": bar.item.id, tabIndex: bar.item.id === tabStopId ? 0 : -1, onClick: () => handleItemClick(bar.item), onFocus: () => setActiveId(bar.item.id), onKeyDown: (event) => handleItemKeydown(event, bar.item), children: [bar.progress !== null && (_jsx("span", { className: "kpt-roadmap__bar-progress", style: { inlineSize: `${bar.progress * 100}%` } })), _jsx("span", { className: "kpt-roadmap__bar-label", children: bar.label })] }) }, bar.item.id))) })] }, row.key));
264
341
  })] }))] }) }));
265
342
  }
266
343
  /** Identyfikatory grup zwiniętych deklaratywnie w danych. */
@@ -1,3 +1,4 @@
1
+ 'use client';
1
2
  import { jsx as _jsx } from "react/jsx-runtime";
2
3
  import { useEffect, useState } from 'react';
3
4
  import { cn } from "../internal/cn.js";
@@ -68,3 +68,6 @@ The trigger is `role="combobox"` with `aria-haspopup="listbox"`, `aria-expanded`
68
68
  and `aria-activedescendant`; the list is `role="listbox"` with `aria-multiselectable`, and each
69
69
  option is `role="option"` with `aria-selected` and `aria-disabled`. Chip remove buttons sit outside
70
70
  the Tab order and are named with the option label.
71
+
72
+ ## Read-only and required
73
+ `readOnly` shows the choice, but the panel does not open and the chips have no remove button (`aria-readonly`). The filter ignores case and diacritics ("lodz" finds "Łódź"), as in `listbox`. "No results" sits next to the list, not inside it (a listbox may contain only options).
@@ -13,6 +13,8 @@ export interface KptSelectProps<T> extends Omit<ComponentPropsWithRef<'div'>, 'c
13
13
  /** Oznacza pole jako wymagane atrybutem `aria-required`. */
14
14
  required?: boolean;
15
15
  disabled?: boolean;
16
+ /** Tylko do odczytu — wybór widać, panel się nie otwiera (`aria-readonly`). */
17
+ readOnly?: boolean;
16
18
  invalid?: boolean;
17
19
  touched?: boolean;
18
20
  /** Wartość: pojedyncza (T) albo tablica (T[]) w trybie `multiple`. */
@@ -45,4 +47,4 @@ export interface KptSelectProps<T> extends Omit<ComponentPropsWithRef<'div'>, 'c
45
47
  * <KptSelect options={roles} value={role} onValueChange={setRole} placeholder="Wybierz…" />
46
48
  * <KptSelect options={tags} multiple filter value={tags} onValueChange={setTags} />
47
49
  */
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;
50
+ export declare function KptSelect<T>({ options, placeholder, multiple, filter, disabled, readOnly, invalid, touched, value, defaultValue, onValueChange, open, defaultOpen, onOpenChange, onTouch, locale, dictionary, className, onBlur, id, required, 'aria-label': ariaLabel, 'aria-describedby': ariaDescribedBy, ...rest }: Readonly<KptSelectProps<T>>): import("react").JSX.Element;
@@ -1,6 +1,9 @@
1
+ 'use client';
1
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
3
  import { useCallback, useEffect, useMemo, useRef, useState, } from 'react';
4
+ import { foldMentionText } from '@konce-pt/mention';
3
5
  import { cn } from "../internal/cn.js";
6
+ import { optionKey } from "../internal/option-key.js";
4
7
  import { KptOverlay } from "../internal/overlay.js";
5
8
  import { useControllableState } from "../internal/use-controllable-state.js";
6
9
  import { useKptId } from "../internal/use-id.js";
@@ -8,6 +11,11 @@ import { KptTypeahead, OPTION_NAV_KEYS, isTypeaheadKey, nextOptionIndex, typeahe
8
11
  import { useKptMessages } from "../i18n/i18n-context.js";
9
12
  import { joinIds, useKptFieldA11y, useKptFormFieldFilled } from "../form-field/form-field-context.js";
10
13
  import { KptIcon } from "../icon/icon.js";
14
+ /** Filtr opcji bez wielkości liter i diakrytyków, jak w `KptListbox`: „lodz” znajduje „Łódź”. */
15
+ function filterSelectOptions(options, text) {
16
+ const q = foldMentionText(text.trim());
17
+ return q ? options.filter((option) => foldMentionText(option.label).includes(q)) : options;
18
+ }
11
19
  /**
12
20
  * Lista wyboru (select) na własnym panelu. Warianty: pojedynczy, z wyszukiwarką (`filter`)
13
21
  * i wielokrotny (`multiple`, chipy).
@@ -27,11 +35,13 @@ import { KptIcon } from "../icon/icon.js";
27
35
  * <KptSelect options={roles} value={role} onValueChange={setRole} placeholder="Wybierz…" />
28
36
  * <KptSelect options={tags} multiple filter value={tags} onValueChange={setTags} />
29
37
  */
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 }) {
38
+ export function KptSelect({ options = [], placeholder = '', multiple = false, filter = false, disabled = false, readOnly = false, invalid = false, touched, value, defaultValue = null, onValueChange, open, defaultOpen = false, onOpenChange, onTouch, locale, dictionary, className, onBlur, id, required = false, 'aria-label': ariaLabel, 'aria-describedby': ariaDescribedBy, ...rest }) {
31
39
  /** `div` z rolą nie dostaje nazwy z `label[for]` — stąd `aria-labelledby` na etykietę wrappera. */
32
40
  const field = useKptFieldA11y(id, 'kpt-select-trigger');
33
41
  const { t } = useKptMessages({ locale, dictionary });
34
42
  const triggerRef = useRef(null);
43
+ /** Kotwica nakładki w stanie (callback ref), nie `triggerRef.current` czytane w renderze. */
44
+ const [anchor, setAnchor] = useState(null);
35
45
  const searchRef = useRef(null);
36
46
  const typeahead = useRef(new KptTypeahead());
37
47
  const uid = useKptId(undefined, 'kpt-select');
@@ -52,35 +62,41 @@ export function KptSelect({ options = [], placeholder = '', multiple = false, fi
52
62
  const showInvalid = invalid && (touched ?? true);
53
63
  const selectedValues = useMemo(() => {
54
64
  if (multiple)
55
- return Array.isArray(current) ? current : [];
65
+ return Array.isArray(current) ? (current) : [];
56
66
  return current == null ? [] : [current];
57
67
  }, [current, multiple]);
58
68
  const selectedOptions = useMemo(() => options.filter((option) => selectedValues.includes(option.value)), [options, selectedValues]);
59
- const filterOptions = (text) => {
60
- const q = text.trim().toLowerCase();
61
- return q ? options.filter((option) => option.label.toLowerCase().includes(q)) : options;
62
- };
63
- const filteredOptions = useMemo(() => filterOptions(filterText), [options, filterText]); // eslint-disable-line react-hooks/exhaustive-deps
69
+ const filteredOptions = useMemo(() => filterSelectOptions(options, filterText), [options, filterText]);
64
70
  const disabledMap = filteredOptions.map((option) => !!option.disabled);
65
71
  const filled = selectedOptions.length > 0;
66
72
  useKptFormFieldFilled(filled);
67
73
  const isSelected = (option) => selectedValues.includes(option.value);
74
+ /** Skrajna opcja, na której ma się otworzyć lista (`Home`/`End`/`↑` na zamkniętym polu). */
75
+ const [openEdge, setOpenEdge] = useState(null);
68
76
  // 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.
77
+ // pozycję ustawiamy więc przy każdym przejściu w stan otwarty, wzorcem „poprzednia wartość
78
+ // w stanie” (w tym samym renderze, bez efektu). Lista otwiera się na wybranej opcji; zmiana
79
+ // wyboru w trybie `multiple` przy otwartym panelu nie przesuwa aktywnej opcji.
80
+ const [wasOpen, setWasOpen] = useState(isOpen);
81
+ if (wasOpen !== isOpen) {
82
+ setWasOpen(isOpen);
83
+ if (isOpen) {
84
+ setFilterText('');
85
+ const disabledAll = options.map((o) => !!o.disabled);
86
+ const selected = options.findIndex((option) => selectedValues.includes(option.value));
87
+ const firstEnabled = nextOptionIndex(disabledAll, -1, 'ArrowDown') ?? -1;
88
+ setActiveIndex(openEdge ? (nextOptionIndex(disabledAll, -1, openEdge) ?? -1) : selected >= 0 ? selected : firstEnabled);
89
+ setOpenEdge(null);
90
+ }
91
+ }
92
+ // Z `filter` fokus idzie do wyszukiwarki — klatkę później, bo `KptOverlay` mierzy panel
93
+ // w `visibility: hidden`.
72
94
  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)
95
+ if (!isOpen || !filter)
80
96
  return;
81
97
  const frame = requestAnimationFrame(() => searchRef.current?.focus());
82
98
  return () => cancelAnimationFrame(frame);
83
- }, [isOpen]); // eslint-disable-line react-hooks/exhaustive-deps
99
+ }, [isOpen, filter]);
84
100
  // Aktywna opcja zawsze w polu widzenia listy.
85
101
  useEffect(() => {
86
102
  if (isOpen && activeIndex >= 0)
@@ -116,21 +132,16 @@ export function KptSelect({ options = [], placeholder = '', multiple = false, fi
116
132
  };
117
133
  /** Jeden obsługiwacz dla pola i dla wyszukiwarki w panelu — różnią się tylko `Spacją` i `Tab`. */
118
134
  const onKeyDown = (event, source) => {
119
- if (disabled)
135
+ if (disabled || readOnly)
120
136
  return;
121
137
  const key = event.key;
122
138
  if (!isOpen) {
123
139
  if (['ArrowDown', 'ArrowUp', 'Enter', ' ', 'Home', 'End'].includes(key)) {
124
140
  event.preventDefault();
141
+ // Skrajne klawisze otwierają listę na pierwszej albo ostatniej opcji zamiast na wybranej.
142
+ if (key === 'ArrowUp' || key === 'End' || key === 'Home')
143
+ setOpenEdge(key === 'Home' ? 'Home' : 'End');
125
144
  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
145
  }
135
146
  else if ((key === 'Backspace' || key === 'Delete') && multiple && selectedValues.length) {
136
147
  event.preventDefault();
@@ -172,18 +183,21 @@ export function KptSelect({ options = [], placeholder = '', multiple = false, fi
172
183
  jumpTo(key);
173
184
  }
174
185
  };
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) => {
186
+ return (_jsxs("span", { className: "kpt-select-host", children: [_jsxs("div", { ref: (el) => {
187
+ triggerRef.current = el;
188
+ setAnchor(el);
189
+ }, className: cn('kpt-select', isOpen && 'kpt-select--open', disabled && 'kpt-select--disabled', readOnly && 'kpt-select--readonly', 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-readonly": readOnly || 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 && !readOnly && setOpen(!isOpen), onKeyDown: (event) => onKeyDown(event, 'trigger'), onBlur: (event) => {
176
190
  onBlur?.(event);
177
191
  // Przy otwartym panelu fokus idzie do pola szukania — „dotknięcie” wyśle dopiero zamknięcie.
178
192
  if (!isOpen)
179
193
  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) => {
194
+ }, ...rest, children: [_jsx("div", { className: "kpt-select__value", children: multiple && selectedOptions.length ? (selectedOptions.map((option, index) => (_jsxs("span", { className: "kpt-select__chip", children: [option.label, readOnly ? null : (_jsx("button", { type: "button", className: "kpt-select__chip-remove", tabIndex: -1, "aria-label": `${t('select.removeChip')}: ${option.label}`, onClick: (event) => {
181
195
  event.stopPropagation();
182
196
  setCurrent(selectedValues.filter((v) => v !== option.value));
183
- }, children: _jsx(KptIcon, { name: "x" }) })] }, String(option.value))))) : !multiple && filled ? (_jsx("span", { className: "kpt-select__single", children: selectedOptions[0]?.label })) : (_jsx("span", { className: "kpt-select__placeholder", children: placeholder })) }), _jsx(KptIcon, { className: "kpt-select__chevron", name: "chevron-down" })] }), _jsx(KptOverlay, { open: isOpen, anchor: triggerRef.current, onClose: close, minAnchorWidth: true, children: _jsxs("div", { className: "kpt-select__panel", children: [filter ? (_jsxs("div", { className: "kpt-select__search", children: [_jsx(KptIcon, { name: "search" }), _jsx("input", { ref: searchRef, type: "text", className: "kpt-select__search-input", role: "combobox", "aria-autocomplete": "list", "aria-expanded": true, "aria-controls": listId, "aria-activedescendant": activeIndex >= 0 ? optionId(activeIndex) : undefined, "aria-label": t('select.search'), value: filterText, placeholder: t('select.search'), onKeyDown: (event) => onKeyDown(event, 'search'), onChange: (event) => {
197
+ }, children: _jsx(KptIcon, { name: "x" }) }))] }, optionKey(option.value, index))))) : !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: anchor, onClose: close, minAnchorWidth: true, children: _jsxs("div", { className: "kpt-select__panel", children: [filter ? (_jsxs("div", { className: "kpt-select__search", children: [_jsx(KptIcon, { name: "search" }), _jsx("input", { ref: searchRef, type: "text", className: "kpt-select__search-input", role: "combobox", "aria-autocomplete": "list", "aria-expanded": true, "aria-controls": listId, "aria-activedescendant": activeIndex >= 0 ? optionId(activeIndex) : undefined, "aria-label": t('select.search'), value: filterText, placeholder: t('select.search'), onKeyDown: (event) => onKeyDown(event, 'search'), onChange: (event) => {
184
198
  setFilterText(event.target.value);
185
199
  // 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);
200
+ const narrowed = filterSelectOptions(options, event.target.value).map((o) => !!o.disabled);
187
201
  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') })) })] }) })] }));
202
+ } })] })) : 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.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] }, optionKey(option.value, index)))) }), filteredOptions.length === 0 ? (_jsx("div", { className: "kpt-select__empty", role: "status", children: t('select.noResults') })) : null] }) })] }));
189
203
  }
@@ -8,3 +8,24 @@ export interface KptOption<T = unknown> {
8
8
  }
9
9
  /** Wariant komunikatu — wspólny dla alertu i toastów. */
10
10
  export type KptAlertVariant = 'info' | 'success' | 'warning' | 'danger';
11
+ /**
12
+ * Klik w odnośnik komponentu nawigacji (`kpt-breadcrumb`, `kpt-menubar`, `kpt-megamenu`). Linki
13
+ * są zwykłym `<a href>`, więc bez routera klik przeładowuje stronę. Aplikacja z routerem łapie to
14
+ * zdarzenie, woła `event.preventDefault()` i nawiguje sama — biblioteka nie zależy od żadnego routera.
15
+ * Zdarzenie leci tylko dla zwykłego kliku: Ctrl/⌘/Shift/środkowy przycisk i `target="_blank"`
16
+ * zostają natywne (nowa karta, nowe okno).
17
+ */
18
+ export interface KptNavigateEvent<T = unknown> {
19
+ /** Pozycja, w którą kliknięto. */
20
+ item: T;
21
+ /** Adres z `href`. */
22
+ url: string;
23
+ /** Oryginalne zdarzenie — `preventDefault()` zatrzymuje przeładowanie strony. */
24
+ event: MouseEvent;
25
+ }
26
+ /**
27
+ * Czy klik to zwykła nawigacja w tej samej karcie: lewy przycisk, bez modyfikatorów, a odnośnik
28
+ * nie ma `target` innego niż `_self`. `target` podaje wołający — w React `currentTarget`
29
+ * zdarzenia natywnego to korzeń aplikacji, nie kliknięty odnośnik.
30
+ */
31
+ export declare function kptIsPlainClick(event: Pick<MouseEvent, 'button' | 'ctrlKey' | 'metaKey' | 'shiftKey' | 'altKey'>, target?: string): boolean;
@@ -1 +1,9 @@
1
- export {};
1
+ /**
2
+ * Czy klik to zwykła nawigacja w tej samej karcie: lewy przycisk, bez modyfikatorów, a odnośnik
3
+ * nie ma `target` innego niż `_self`. `target` podaje wołający — w React `currentTarget`
4
+ * zdarzenia natywnego to korzeń aplikacji, nie kliknięty odnośnik.
5
+ */
6
+ export function kptIsPlainClick(event, target = '') {
7
+ const modified = event.ctrlKey || event.metaKey || event.shiftKey || event.altKey;
8
+ return event.button === 0 && !modified && (target === '' || target === '_self');
9
+ }
@@ -1,6 +1,5 @@
1
1
  import { type ComponentPropsWithRef } from 'react';
2
- export interface KptSidenavProps extends ComponentPropsWithRef<'div'> {
3
- }
2
+ export type KptSidenavProps = ComponentPropsWithRef<'div'>;
4
3
  /**
5
4
  * Panel boczny (sidenav) — stylowany kontener nawigacji, zwykle podawany do slotu
6
5
  * `sidenav` komponentu `<KptAppShell>`.
@@ -1,6 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { Children, isValidElement } from 'react';
2
+ import { isValidElement } from 'react';
3
3
  import { cn } from "../internal/cn.js";
4
+ import { slotChildren } from "../internal/slot-children.js";
4
5
  /*
5
6
  * Panel odtwarza projekcję z Angulara: treść domyślna ląduje w obszarze przewijanym,
6
7
  * a `<KptSidenav.Footer>` jest wynoszona POZA ten obszar. Dzięki temu stopka zostaje
@@ -20,14 +21,14 @@ import { cn } from "../internal/cn.js";
20
21
  export function KptSidenav({ className, children, ...rest }) {
21
22
  const scroll = [];
22
23
  const footers = [];
23
- Children.forEach(children, (child) => {
24
+ for (const child of slotChildren(children)) {
24
25
  if (isValidElement(child) && child.type?.kptSidenavSlot === 'footer') {
25
26
  footers.push(child);
26
27
  }
27
28
  else {
28
29
  scroll.push(child);
29
30
  }
30
- });
31
+ }
31
32
  return (_jsxs("div", { className: cn('kpt-sidenav', className), ...rest, children: [_jsx("div", { className: "kpt-sidenav__scroll", children: scroll }), footers] }));
32
33
  }
33
34
  /**