tekivex-ui 2.0.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 (173) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +201 -0
  3. package/dist/index.cjs +70 -0
  4. package/dist/index.d.ts +89 -0
  5. package/dist/index.d.ts.map +1 -0
  6. package/dist/index.js +11002 -0
  7. package/dist/src/a11y/index.d.ts +24 -0
  8. package/dist/src/a11y/index.d.ts.map +1 -0
  9. package/dist/src/components/TkxAccordion.d.ts +32 -0
  10. package/dist/src/components/TkxAccordion.d.ts.map +1 -0
  11. package/dist/src/components/TkxAlert.d.ts +14 -0
  12. package/dist/src/components/TkxAlert.d.ts.map +1 -0
  13. package/dist/src/components/TkxAvatar.d.ts +14 -0
  14. package/dist/src/components/TkxAvatar.d.ts.map +1 -0
  15. package/dist/src/components/TkxBadge.d.ts +12 -0
  16. package/dist/src/components/TkxBadge.d.ts.map +1 -0
  17. package/dist/src/components/TkxButton.d.ts +17 -0
  18. package/dist/src/components/TkxButton.d.ts.map +1 -0
  19. package/dist/src/components/TkxCard.d.ts +20 -0
  20. package/dist/src/components/TkxCard.d.ts.map +1 -0
  21. package/dist/src/components/TkxCarousel.d.ts +32 -0
  22. package/dist/src/components/TkxCarousel.d.ts.map +1 -0
  23. package/dist/src/components/TkxChat.d.ts +32 -0
  24. package/dist/src/components/TkxChat.d.ts.map +1 -0
  25. package/dist/src/components/TkxCheckbox.d.ts +13 -0
  26. package/dist/src/components/TkxCheckbox.d.ts.map +1 -0
  27. package/dist/src/components/TkxClock.d.ts +23 -0
  28. package/dist/src/components/TkxClock.d.ts.map +1 -0
  29. package/dist/src/components/TkxColorPicker.d.ts +18 -0
  30. package/dist/src/components/TkxColorPicker.d.ts.map +1 -0
  31. package/dist/src/components/TkxCommand.d.ts +34 -0
  32. package/dist/src/components/TkxCommand.d.ts.map +1 -0
  33. package/dist/src/components/TkxDatePicker.d.ts +47 -0
  34. package/dist/src/components/TkxDatePicker.d.ts.map +1 -0
  35. package/dist/src/components/TkxDivider.d.ts +8 -0
  36. package/dist/src/components/TkxDivider.d.ts.map +1 -0
  37. package/dist/src/components/TkxDrawer.d.ts +16 -0
  38. package/dist/src/components/TkxDrawer.d.ts.map +1 -0
  39. package/dist/src/components/TkxFileUpload.d.ts +16 -0
  40. package/dist/src/components/TkxFileUpload.d.ts.map +1 -0
  41. package/dist/src/components/TkxIcon.d.ts +16 -0
  42. package/dist/src/components/TkxIcon.d.ts.map +1 -0
  43. package/dist/src/components/TkxImage.d.ts +16 -0
  44. package/dist/src/components/TkxImage.d.ts.map +1 -0
  45. package/dist/src/components/TkxInput.d.ts +13 -0
  46. package/dist/src/components/TkxInput.d.ts.map +1 -0
  47. package/dist/src/components/TkxLogo.d.ts +21 -0
  48. package/dist/src/components/TkxLogo.d.ts.map +1 -0
  49. package/dist/src/components/TkxMenu.d.ts +64 -0
  50. package/dist/src/components/TkxMenu.d.ts.map +1 -0
  51. package/dist/src/components/TkxModal.d.ts +14 -0
  52. package/dist/src/components/TkxModal.d.ts.map +1 -0
  53. package/dist/src/components/TkxNumberInput.d.ts +32 -0
  54. package/dist/src/components/TkxNumberInput.d.ts.map +1 -0
  55. package/dist/src/components/TkxOTP.d.ts +24 -0
  56. package/dist/src/components/TkxOTP.d.ts.map +1 -0
  57. package/dist/src/components/TkxPagination.d.ts +19 -0
  58. package/dist/src/components/TkxPagination.d.ts.map +1 -0
  59. package/dist/src/components/TkxProgress.d.ts +13 -0
  60. package/dist/src/components/TkxProgress.d.ts.map +1 -0
  61. package/dist/src/components/TkxRadio.d.ts +22 -0
  62. package/dist/src/components/TkxRadio.d.ts.map +1 -0
  63. package/dist/src/components/TkxRating.d.ts +18 -0
  64. package/dist/src/components/TkxRating.d.ts.map +1 -0
  65. package/dist/src/components/TkxSelect.d.ts +37 -0
  66. package/dist/src/components/TkxSelect.d.ts.map +1 -0
  67. package/dist/src/components/TkxSkeleton.d.ts +12 -0
  68. package/dist/src/components/TkxSkeleton.d.ts.map +1 -0
  69. package/dist/src/components/TkxSlider.d.ts +33 -0
  70. package/dist/src/components/TkxSlider.d.ts.map +1 -0
  71. package/dist/src/components/TkxStepper.d.ts +31 -0
  72. package/dist/src/components/TkxStepper.d.ts.map +1 -0
  73. package/dist/src/components/TkxTable.d.ts +23 -0
  74. package/dist/src/components/TkxTable.d.ts.map +1 -0
  75. package/dist/src/components/TkxTabs.d.ts +32 -0
  76. package/dist/src/components/TkxTabs.d.ts.map +1 -0
  77. package/dist/src/components/TkxTag.d.ts +30 -0
  78. package/dist/src/components/TkxTag.d.ts.map +1 -0
  79. package/dist/src/components/TkxTimeline.d.ts +20 -0
  80. package/dist/src/components/TkxTimeline.d.ts.map +1 -0
  81. package/dist/src/components/TkxToast.d.ts +23 -0
  82. package/dist/src/components/TkxToast.d.ts.map +1 -0
  83. package/dist/src/components/TkxToggle.d.ts +11 -0
  84. package/dist/src/components/TkxToggle.d.ts.map +1 -0
  85. package/dist/src/components/TkxTooltip.d.ts +10 -0
  86. package/dist/src/components/TkxTooltip.d.ts.map +1 -0
  87. package/dist/src/components/TkxVideoPlayer.d.ts +43 -0
  88. package/dist/src/components/TkxVideoPlayer.d.ts.map +1 -0
  89. package/dist/src/components/index.d.ts +41 -0
  90. package/dist/src/components/index.d.ts.map +1 -0
  91. package/dist/src/engine/css.d.ts +24 -0
  92. package/dist/src/engine/css.d.ts.map +1 -0
  93. package/dist/src/engine/index.d.ts +10 -0
  94. package/dist/src/engine/index.d.ts.map +1 -0
  95. package/dist/src/engine/quantum.d.ts +47 -0
  96. package/dist/src/engine/quantum.d.ts.map +1 -0
  97. package/dist/src/engine/security.d.ts +54 -0
  98. package/dist/src/engine/security.d.ts.map +1 -0
  99. package/dist/src/engine/tkx.d.ts +29 -0
  100. package/dist/src/engine/tkx.d.ts.map +1 -0
  101. package/dist/src/engine/wcag.d.ts +34 -0
  102. package/dist/src/engine/wcag.d.ts.map +1 -0
  103. package/dist/src/hooks/index.d.ts +8 -0
  104. package/dist/src/hooks/index.d.ts.map +1 -0
  105. package/dist/src/themes/index.d.ts +26 -0
  106. package/dist/src/themes/index.d.ts.map +1 -0
  107. package/dist/style.css +1 -0
  108. package/dist/tests/TkxButton.test.d.ts +2 -0
  109. package/dist/tests/TkxButton.test.d.ts.map +1 -0
  110. package/dist/tests/a11y/components.test.d.ts +2 -0
  111. package/dist/tests/a11y/components.test.d.ts.map +1 -0
  112. package/dist/tests/quantum.test.d.ts +2 -0
  113. package/dist/tests/quantum.test.d.ts.map +1 -0
  114. package/dist/tests/security.test.d.ts +2 -0
  115. package/dist/tests/security.test.d.ts.map +1 -0
  116. package/dist/tests/setup.d.ts +2 -0
  117. package/dist/tests/setup.d.ts.map +1 -0
  118. package/dist/tests/tkx.test.d.ts +2 -0
  119. package/dist/tests/tkx.test.d.ts.map +1 -0
  120. package/dist/tests/wcag.test.d.ts +2 -0
  121. package/dist/tests/wcag.test.d.ts.map +1 -0
  122. package/package.json +78 -0
  123. package/src/a11y/index.tsx +87 -0
  124. package/src/components/TkxAccordion.tsx +588 -0
  125. package/src/components/TkxAlert.tsx +91 -0
  126. package/src/components/TkxAvatar.tsx +101 -0
  127. package/src/components/TkxBadge.tsx +73 -0
  128. package/src/components/TkxButton.tsx +153 -0
  129. package/src/components/TkxCard.tsx +92 -0
  130. package/src/components/TkxCarousel.tsx +552 -0
  131. package/src/components/TkxChat.tsx +354 -0
  132. package/src/components/TkxCheckbox.tsx +218 -0
  133. package/src/components/TkxClock.tsx +601 -0
  134. package/src/components/TkxColorPicker.tsx +633 -0
  135. package/src/components/TkxCommand.tsx +644 -0
  136. package/src/components/TkxDatePicker.tsx +1567 -0
  137. package/src/components/TkxDivider.tsx +49 -0
  138. package/src/components/TkxDrawer.tsx +234 -0
  139. package/src/components/TkxFileUpload.tsx +371 -0
  140. package/src/components/TkxIcon.tsx +683 -0
  141. package/src/components/TkxImage.tsx +277 -0
  142. package/src/components/TkxInput.tsx +101 -0
  143. package/src/components/TkxLogo.tsx +297 -0
  144. package/src/components/TkxMenu.tsx +1042 -0
  145. package/src/components/TkxModal.tsx +119 -0
  146. package/src/components/TkxNumberInput.tsx +408 -0
  147. package/src/components/TkxOTP.tsx +295 -0
  148. package/src/components/TkxPagination.tsx +388 -0
  149. package/src/components/TkxProgress.tsx +114 -0
  150. package/src/components/TkxRadio.tsx +284 -0
  151. package/src/components/TkxRating.tsx +286 -0
  152. package/src/components/TkxSelect.tsx +923 -0
  153. package/src/components/TkxSkeleton.tsx +105 -0
  154. package/src/components/TkxSlider.tsx +492 -0
  155. package/src/components/TkxStepper.tsx +485 -0
  156. package/src/components/TkxTable.tsx +148 -0
  157. package/src/components/TkxTabs.tsx +124 -0
  158. package/src/components/TkxTag.tsx +319 -0
  159. package/src/components/TkxTimeline.tsx +303 -0
  160. package/src/components/TkxToast.tsx +284 -0
  161. package/src/components/TkxToggle.tsx +93 -0
  162. package/src/components/TkxTooltip.tsx +69 -0
  163. package/src/components/TkxVideoPlayer.tsx +1013 -0
  164. package/src/components/index.ts +40 -0
  165. package/src/engine/css.ts +126 -0
  166. package/src/engine/index.ts +10 -0
  167. package/src/engine/quantum.ts +207 -0
  168. package/src/engine/security.ts +217 -0
  169. package/src/engine/tkx.ts +874 -0
  170. package/src/engine/wcag.ts +275 -0
  171. package/src/hooks/index.ts +120 -0
  172. package/src/styles/global.css +167 -0
  173. package/src/themes/index.ts +111 -0
@@ -0,0 +1,644 @@
1
+ import {
2
+ useState,
3
+ useEffect,
4
+ useRef,
5
+ useCallback,
6
+ useId,
7
+ type CSSProperties,
8
+ type ReactNode,
9
+ type KeyboardEvent,
10
+ } from 'react';
11
+ import { createPortal } from 'react-dom';
12
+ import { useTheme } from '../themes';
13
+ import { tkx, cx } from '../engine/tkx';
14
+ import { sanitizeString } from '../engine/security';
15
+
16
+ // ── Inline hooks ──────────────────────────────────────────────────────────────
17
+
18
+ function useEscapeKey(onEscape: () => void, enabled: boolean) {
19
+ useEffect(() => {
20
+ if (!enabled) return;
21
+ const handler = (e: globalThis.KeyboardEvent) => {
22
+ if (e.key === 'Escape') onEscape();
23
+ };
24
+ document.addEventListener('keydown', handler);
25
+ return () => document.removeEventListener('keydown', handler);
26
+ }, [onEscape, enabled]);
27
+ }
28
+
29
+ function useFocusTrap(enabled: boolean) {
30
+ const ref = useRef<HTMLDivElement>(null);
31
+ useEffect(() => {
32
+ if (!enabled || !ref.current) return;
33
+ const el = ref.current;
34
+ const focusable = el.querySelectorAll<HTMLElement>(
35
+ 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
36
+ );
37
+ const first = focusable[0];
38
+ const last = focusable[focusable.length - 1];
39
+ const trap = (e: globalThis.KeyboardEvent) => {
40
+ if (e.key !== 'Tab') return;
41
+ if (e.shiftKey) {
42
+ if (document.activeElement === first) { e.preventDefault(); last?.focus(); }
43
+ } else {
44
+ if (document.activeElement === last) { e.preventDefault(); first?.focus(); }
45
+ }
46
+ };
47
+ el.addEventListener('keydown', trap);
48
+ return () => el.removeEventListener('keydown', trap);
49
+ }, [enabled]);
50
+ return ref;
51
+ }
52
+
53
+ // ── Types ─────────────────────────────────────────────────────────────────────
54
+
55
+ export interface CommandItem {
56
+ id: string;
57
+ label: string;
58
+ description?: string;
59
+ icon?: ReactNode;
60
+ shortcut?: string;
61
+ group?: string;
62
+ keywords?: string[];
63
+ disabled?: boolean;
64
+ onSelect?: () => void;
65
+ }
66
+
67
+ export interface TkxCommandProps {
68
+ items: CommandItem[];
69
+ isOpen?: boolean;
70
+ onClose?: () => void;
71
+ placeholder?: string;
72
+ emptyMessage?: string;
73
+ maxItems?: number;
74
+ onItemSelect?: (item: CommandItem) => void;
75
+ className?: string;
76
+ style?: CSSProperties;
77
+ }
78
+
79
+ // ── Hook ──────────────────────────────────────────────────────────────────────
80
+
81
+ export function useTkxCommand(): { isOpen: boolean; open: () => void; close: () => void; toggle: () => void } {
82
+ const [isOpen, setIsOpen] = useState(false);
83
+ const open = useCallback(() => setIsOpen(true), []);
84
+ const close = useCallback(() => setIsOpen(false), []);
85
+ const toggle = useCallback(() => setIsOpen((v) => !v), []);
86
+ return { isOpen, open, close, toggle };
87
+ }
88
+
89
+ // ── Fuzzy search ──────────────────────────────────────────────────────────────
90
+
91
+ interface MatchResult {
92
+ item: CommandItem;
93
+ score: number;
94
+ labelRanges: [number, number][];
95
+ }
96
+
97
+ function fuzzyMatch(query: string, text: string): { matched: boolean; ranges: [number, number][]; score: number } {
98
+ if (!query) return { matched: true, ranges: [], score: 0 };
99
+ const q = query.toLowerCase();
100
+ const t = text.toLowerCase();
101
+
102
+ // Try substring match first (highest score)
103
+ const subIdx = t.indexOf(q);
104
+ if (subIdx !== -1) {
105
+ return {
106
+ matched: true,
107
+ ranges: [[subIdx, subIdx + q.length - 1]],
108
+ score: subIdx === 0 ? 100 : 80,
109
+ };
110
+ }
111
+
112
+ // Fuzzy character-by-character match
113
+ const ranges: [number, number][] = [];
114
+ let qi = 0;
115
+ let start = -1;
116
+ let prevIdx = -1;
117
+
118
+ for (let i = 0; i < t.length && qi < q.length; i++) {
119
+ if (t[i] === q[qi]) {
120
+ if (prevIdx === -1 || i !== prevIdx + 1) {
121
+ if (start !== -1) ranges.push([start, prevIdx]);
122
+ start = i;
123
+ }
124
+ prevIdx = i;
125
+ qi++;
126
+ }
127
+ }
128
+ if (start !== -1 && prevIdx !== -1) ranges.push([start, prevIdx]);
129
+
130
+ if (qi < q.length) return { matched: false, ranges: [], score: 0 };
131
+ return { matched: true, ranges, score: 40 };
132
+ }
133
+
134
+ function searchItems(query: string, items: CommandItem[]): MatchResult[] {
135
+ if (!query.trim()) {
136
+ return items
137
+ .filter((item) => !item.disabled)
138
+ .map((item) => ({ item, score: 0, labelRanges: [] }));
139
+ }
140
+
141
+ const results: MatchResult[] = [];
142
+
143
+ for (const item of items) {
144
+ if (item.disabled) continue;
145
+
146
+ const labelMatch = fuzzyMatch(query, item.label);
147
+ const descMatch = item.description ? fuzzyMatch(query, item.description) : { matched: false, score: 0 };
148
+ const kwMatch = (item.keywords ?? []).some((kw) => fuzzyMatch(query, kw).matched);
149
+
150
+ if (labelMatch.matched) {
151
+ results.push({ item, score: labelMatch.score + 20, labelRanges: labelMatch.ranges });
152
+ } else if (descMatch.matched) {
153
+ results.push({ item, score: descMatch.score, labelRanges: [] });
154
+ } else if (kwMatch) {
155
+ results.push({ item, score: 20, labelRanges: [] });
156
+ }
157
+ }
158
+
159
+ return results.sort((a, b) => b.score - a.score);
160
+ }
161
+
162
+ // ── Highlighted label ─────────────────────────────────────────────────────────
163
+
164
+ function HighlightedText({ text, ranges, color }: { text: string; ranges: [number, number][]; color: string }) {
165
+ if (!ranges.length) return <>{text}</>;
166
+
167
+ const parts: ReactNode[] = [];
168
+ let cursor = 0;
169
+
170
+ for (const [start, end] of ranges) {
171
+ if (cursor < start) parts.push(<span key={`t-${cursor}`}>{text.slice(cursor, start)}</span>);
172
+ parts.push(
173
+ <span key={`h-${start}`} style={{ color, fontWeight: 700 }}>
174
+ {text.slice(start, end + 1)}
175
+ </span>,
176
+ );
177
+ cursor = end + 1;
178
+ }
179
+ if (cursor < text.length) parts.push(<span key={`t-end`}>{text.slice(cursor)}</span>);
180
+
181
+ return <>{parts}</>;
182
+ }
183
+
184
+ // ── Skeleton shimmer items ────────────────────────────────────────────────────
185
+
186
+ function SkeletonItem({ surface, border }: { surface: string; border: string }) {
187
+ return (
188
+ <div
189
+ style={{
190
+ display: 'flex',
191
+ alignItems: 'center',
192
+ gap: 10,
193
+ padding: '10px 12px',
194
+ borderRadius: 8,
195
+ overflow: 'hidden',
196
+ }}
197
+ >
198
+ <div
199
+ style={{
200
+ width: 24,
201
+ height: 24,
202
+ borderRadius: 6,
203
+ background: `linear-gradient(90deg, ${border} 25%, ${surface} 50%, ${border} 75%)`,
204
+ backgroundSize: '200% 100%',
205
+ animation: 'tkx-shimmer 1.4s ease-in-out infinite',
206
+ flexShrink: 0,
207
+ }}
208
+ />
209
+ <div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 5 }}>
210
+ <div
211
+ style={{
212
+ height: 13,
213
+ borderRadius: 4,
214
+ width: '60%',
215
+ background: `linear-gradient(90deg, ${border} 25%, ${surface} 50%, ${border} 75%)`,
216
+ backgroundSize: '200% 100%',
217
+ animation: 'tkx-shimmer 1.4s ease-in-out infinite',
218
+ }}
219
+ />
220
+ <div
221
+ style={{
222
+ height: 10,
223
+ borderRadius: 4,
224
+ width: '40%',
225
+ background: `linear-gradient(90deg, ${border} 25%, ${surface} 50%, ${border} 75%)`,
226
+ backgroundSize: '200% 100%',
227
+ animation: 'tkx-shimmer 1.4s ease-in-out 0.2s infinite',
228
+ }}
229
+ />
230
+ </div>
231
+ </div>
232
+ );
233
+ }
234
+
235
+ // ── Main component ────────────────────────────────────────────────────────────
236
+
237
+ export function TkxCommand({
238
+ items,
239
+ isOpen = false,
240
+ onClose,
241
+ placeholder = 'Type a command or search…',
242
+ emptyMessage,
243
+ maxItems = 8,
244
+ onItemSelect,
245
+ className,
246
+ style,
247
+ }: TkxCommandProps) {
248
+ const theme = useTheme();
249
+ const [query, setQuery] = useState('');
250
+ const [activeIdx, setActiveIdx] = useState(0);
251
+ const [loading, setLoading] = useState(false);
252
+ const inputRef = useRef<HTMLInputElement>(null);
253
+ const listRef = useRef<HTMLDivElement>(null);
254
+ const panelId = useId();
255
+ const trapRef = useFocusTrap(isOpen);
256
+
257
+ const results = searchItems(query, items).slice(0, maxItems);
258
+
259
+ // Group results
260
+ const grouped: { group: string | undefined; items: MatchResult[] }[] = [];
261
+ for (const r of results) {
262
+ const group = r.item.group;
263
+ const existing = grouped.find((g) => g.group === group);
264
+ if (existing) existing.items.push(r);
265
+ else grouped.push({ group, items: [r] });
266
+ }
267
+
268
+ const flatResults = grouped.flatMap((g) => g.items);
269
+
270
+ useEscapeKey(() => { onClose?.(); }, isOpen);
271
+
272
+ // Reset state when opened
273
+ useEffect(() => {
274
+ if (isOpen) {
275
+ setQuery('');
276
+ setActiveIdx(0);
277
+ setTimeout(() => inputRef.current?.focus(), 30);
278
+ }
279
+ }, [isOpen]);
280
+
281
+ // Clamp activeIdx when results change
282
+ useEffect(() => {
283
+ if (activeIdx >= flatResults.length) setActiveIdx(Math.max(0, flatResults.length - 1));
284
+ }, [flatResults.length, activeIdx]);
285
+
286
+ const scrollActiveIntoView = useCallback((idx: number) => {
287
+ const list = listRef.current;
288
+ if (!list) return;
289
+ const active = list.querySelectorAll<HTMLElement>('[data-command-item]')[idx];
290
+ active?.scrollIntoView({ block: 'nearest' });
291
+ }, []);
292
+
293
+ const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
294
+ if (e.key === 'ArrowDown') {
295
+ e.preventDefault();
296
+ const next = Math.min(flatResults.length - 1, activeIdx + 1);
297
+ setActiveIdx(next);
298
+ scrollActiveIntoView(next);
299
+ } else if (e.key === 'ArrowUp') {
300
+ e.preventDefault();
301
+ const next = Math.max(0, activeIdx - 1);
302
+ setActiveIdx(next);
303
+ scrollActiveIntoView(next);
304
+ } else if (e.key === 'Enter') {
305
+ e.preventDefault();
306
+ const selected = flatResults[activeIdx];
307
+ if (selected) selectItem(selected.item);
308
+ }
309
+ };
310
+
311
+ const selectItem = (item: CommandItem) => {
312
+ if (item.disabled) return;
313
+ item.onSelect?.();
314
+ onItemSelect?.(item);
315
+ onClose?.();
316
+ };
317
+
318
+ if (!isOpen || typeof document === 'undefined') return null;
319
+
320
+ const safePlaceholder = sanitizeString(placeholder);
321
+ const safeEmptyMsg = emptyMessage ? sanitizeString(emptyMessage) : `No results for "${sanitizeString(query)}"`;
322
+
323
+ // Shimmer CSS injection (idempotent)
324
+ if (typeof document !== 'undefined' && !document.getElementById('tkx-shimmer-style')) {
325
+ const s = document.createElement('style');
326
+ s.id = 'tkx-shimmer-style';
327
+ s.textContent = `@keyframes tkx-shimmer { 0%{background-position:200% 0} 100%{background-position:-200% 0} }`;
328
+ document.head.appendChild(s);
329
+ }
330
+
331
+ return createPortal(
332
+ <div
333
+ role="presentation"
334
+ style={{
335
+ position: 'fixed',
336
+ inset: 0,
337
+ zIndex: 10000,
338
+ display: 'flex',
339
+ alignItems: 'flex-start',
340
+ justifyContent: 'center',
341
+ paddingTop: '15vh',
342
+ paddingLeft: 16,
343
+ paddingRight: 16,
344
+ }}
345
+ >
346
+ {/* Backdrop */}
347
+ <div
348
+ aria-hidden="true"
349
+ onClick={onClose}
350
+ style={{
351
+ position: 'absolute',
352
+ inset: 0,
353
+ backgroundColor: 'rgba(0,0,0,0.55)',
354
+ backdropFilter: 'blur(4px)',
355
+ animation: 'tkx-fade-in 120ms ease',
356
+ }}
357
+ />
358
+
359
+ {/* Panel */}
360
+ <div
361
+ ref={trapRef as React.RefObject<HTMLDivElement>}
362
+ role="dialog"
363
+ aria-modal="true"
364
+ aria-label="Command palette"
365
+ id={panelId}
366
+ className={cx(className)}
367
+ style={{
368
+ position: 'relative',
369
+ zIndex: 1,
370
+ width: '100%',
371
+ maxWidth: 560,
372
+ backgroundColor: theme.surface,
373
+ border: `1px solid ${theme.border}`,
374
+ borderRadius: 16,
375
+ boxShadow: `0 24px 64px rgba(0,0,0,0.5), 0 0 0 1px ${theme.border}`,
376
+ overflow: 'hidden',
377
+ display: 'flex',
378
+ flexDirection: 'column',
379
+ maxHeight: '70vh',
380
+ ...style,
381
+ }}
382
+ >
383
+ {/* Search bar */}
384
+ <div
385
+ style={{
386
+ display: 'flex',
387
+ alignItems: 'center',
388
+ gap: 10,
389
+ padding: '14px 16px',
390
+ borderBottom: `1px solid ${theme.border}`,
391
+ }}
392
+ >
393
+ <svg
394
+ width="18"
395
+ height="18"
396
+ viewBox="0 0 24 24"
397
+ fill="none"
398
+ stroke={theme.textMuted}
399
+ strokeWidth={2}
400
+ aria-hidden="true"
401
+ style={{ flexShrink: 0 }}
402
+ >
403
+ <circle cx="11" cy="11" r="8" />
404
+ <line x1="21" y1="21" x2="16.65" y2="16.65" />
405
+ </svg>
406
+ <input
407
+ ref={inputRef}
408
+ type="text"
409
+ role="combobox"
410
+ aria-expanded={true}
411
+ aria-autocomplete="list"
412
+ aria-controls={`${panelId}-list`}
413
+ aria-activedescendant={flatResults[activeIdx] ? `${panelId}-item-${flatResults[activeIdx].item.id}` : undefined}
414
+ value={query}
415
+ onChange={(e) => { setQuery(e.target.value); setActiveIdx(0); }}
416
+ onKeyDown={handleKeyDown}
417
+ placeholder={safePlaceholder}
418
+ style={{
419
+ flex: 1,
420
+ border: 'none',
421
+ background: 'transparent',
422
+ outline: 'none',
423
+ fontSize: 16,
424
+ fontFamily: 'inherit',
425
+ color: theme.text,
426
+ }}
427
+ />
428
+ {query && (
429
+ <button
430
+ type="button"
431
+ aria-label="Clear search"
432
+ onClick={() => { setQuery(''); setActiveIdx(0); inputRef.current?.focus(); }}
433
+ style={{
434
+ border: 'none',
435
+ background: 'transparent',
436
+ cursor: 'pointer',
437
+ color: theme.textMuted,
438
+ display: 'flex',
439
+ alignItems: 'center',
440
+ padding: 0,
441
+ }}
442
+ >
443
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} aria-hidden="true">
444
+ <line x1="18" y1="6" x2="6" y2="18" />
445
+ <line x1="6" y1="6" x2="18" y2="18" />
446
+ </svg>
447
+ </button>
448
+ )}
449
+ </div>
450
+
451
+ {/* Results */}
452
+ <div
453
+ id={`${panelId}-list`}
454
+ ref={listRef}
455
+ role="listbox"
456
+ aria-label="Command results"
457
+ style={{ overflowY: 'auto', flex: 1, padding: '6px 8px' }}
458
+ >
459
+ {loading ? (
460
+ Array.from({ length: 4 }, (_, i) => (
461
+ <SkeletonItem key={i} surface={theme.surface} border={theme.border} />
462
+ ))
463
+ ) : flatResults.length === 0 ? (
464
+ /* Empty state */
465
+ <div
466
+ style={{
467
+ display: 'flex',
468
+ flexDirection: 'column',
469
+ alignItems: 'center',
470
+ justifyContent: 'center',
471
+ gap: 8,
472
+ padding: '32px 16px',
473
+ color: theme.textMuted,
474
+ }}
475
+ >
476
+ <svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.5} aria-hidden="true">
477
+ <circle cx="11" cy="11" r="8" />
478
+ <line x1="21" y1="21" x2="16.65" y2="16.65" />
479
+ <line x1="8" y1="11" x2="14" y2="11" />
480
+ </svg>
481
+ <span style={{ fontSize: '0.875rem' }}>{safeEmptyMsg}</span>
482
+ </div>
483
+ ) : (
484
+ grouped.map(({ group, items: groupItems }, gi) => {
485
+ // Build flat index offset for this group
486
+ const offset = grouped.slice(0, gi).reduce((acc, g) => acc + g.items.length, 0);
487
+
488
+ return (
489
+ <div key={group ?? '__default__'}>
490
+ {group && (
491
+ <div
492
+ role="presentation"
493
+ style={{
494
+ fontSize: '0.6875rem',
495
+ fontWeight: 600,
496
+ textTransform: 'uppercase',
497
+ letterSpacing: '0.08em',
498
+ color: theme.textMuted,
499
+ padding: '10px 12px 4px',
500
+ }}
501
+ >
502
+ {sanitizeString(group)}
503
+ </div>
504
+ )}
505
+ {groupItems.map((r, localIdx) => {
506
+ const flatIdx = offset + localIdx;
507
+ const isActive = flatIdx === activeIdx;
508
+ const item = r.item;
509
+
510
+ return (
511
+ <div
512
+ key={item.id}
513
+ id={`${panelId}-item-${item.id}`}
514
+ role="option"
515
+ aria-selected={isActive}
516
+ aria-disabled={item.disabled}
517
+ data-command-item
518
+ onClick={() => selectItem(item)}
519
+ onMouseEnter={() => setActiveIdx(flatIdx)}
520
+ style={{
521
+ display: 'flex',
522
+ alignItems: 'center',
523
+ gap: 10,
524
+ padding: '9px 12px',
525
+ borderRadius: 8,
526
+ cursor: item.disabled ? 'not-allowed' : 'pointer',
527
+ opacity: item.disabled ? 0.45 : 1,
528
+ backgroundColor: isActive ? `${theme.primary}18` : 'transparent',
529
+ borderLeft: isActive ? `2px solid ${theme.primary}` : '2px solid transparent',
530
+ transition: 'background 80ms ease',
531
+ userSelect: 'none',
532
+ }}
533
+ >
534
+ {/* Icon */}
535
+ {item.icon && (
536
+ <span
537
+ style={{
538
+ width: 20,
539
+ height: 20,
540
+ flexShrink: 0,
541
+ display: 'flex',
542
+ alignItems: 'center',
543
+ justifyContent: 'center',
544
+ color: isActive ? theme.primary : theme.textMuted,
545
+ }}
546
+ >
547
+ {item.icon}
548
+ </span>
549
+ )}
550
+
551
+ {/* Label + description */}
552
+ <div style={{ flex: 1, minWidth: 0 }}>
553
+ <div
554
+ style={{
555
+ fontSize: '0.875rem',
556
+ fontWeight: 500,
557
+ color: isActive ? theme.text : theme.text,
558
+ whiteSpace: 'nowrap',
559
+ overflow: 'hidden',
560
+ textOverflow: 'ellipsis',
561
+ }}
562
+ >
563
+ <HighlightedText
564
+ text={item.label}
565
+ ranges={r.labelRanges}
566
+ color={theme.primary}
567
+ />
568
+ </div>
569
+ {item.description && (
570
+ <div
571
+ style={{
572
+ fontSize: '0.75rem',
573
+ color: theme.textMuted,
574
+ marginTop: 1,
575
+ whiteSpace: 'nowrap',
576
+ overflow: 'hidden',
577
+ textOverflow: 'ellipsis',
578
+ }}
579
+ >
580
+ {sanitizeString(item.description)}
581
+ </div>
582
+ )}
583
+ </div>
584
+
585
+ {/* Shortcut badge */}
586
+ {item.shortcut && (
587
+ <span
588
+ style={{
589
+ flexShrink: 0,
590
+ fontSize: '0.6875rem',
591
+ fontWeight: 500,
592
+ color: theme.textMuted,
593
+ backgroundColor: theme.surfaceAlt,
594
+ border: `1px solid ${theme.border}`,
595
+ borderRadius: 6,
596
+ padding: '2px 6px',
597
+ fontFamily: 'monospace',
598
+ letterSpacing: '0.04em',
599
+ }}
600
+ >
601
+ {sanitizeString(item.shortcut)}
602
+ </span>
603
+ )}
604
+ </div>
605
+ );
606
+ })}
607
+ </div>
608
+ );
609
+ })
610
+ )}
611
+ </div>
612
+
613
+ {/* Footer hint */}
614
+ {flatResults.length > 0 && (
615
+ <div
616
+ aria-hidden="true"
617
+ style={{
618
+ display: 'flex',
619
+ alignItems: 'center',
620
+ gap: 12,
621
+ padding: '8px 16px',
622
+ borderTop: `1px solid ${theme.border}`,
623
+ fontSize: '0.6875rem',
624
+ color: theme.textMuted,
625
+ }}
626
+ >
627
+ <span>
628
+ <kbd style={{ fontFamily: 'monospace' }}>↑↓</kbd> navigate
629
+ </span>
630
+ <span>
631
+ <kbd style={{ fontFamily: 'monospace' }}>↵</kbd> select
632
+ </span>
633
+ <span>
634
+ <kbd style={{ fontFamily: 'monospace' }}>Esc</kbd> close
635
+ </span>
636
+ </div>
637
+ )}
638
+ </div>
639
+ </div>,
640
+ document.body,
641
+ );
642
+ }
643
+
644
+ TkxCommand.displayName = 'TkxCommand';