xertica-ui 2.1.2 → 2.1.4

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 (181) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/README.md +1 -1
  3. package/bin/cli.ts +1 -1
  4. package/bin/generate-tokens.ts +13 -7
  5. package/components/assistant/xertica-assistant/index.ts +2 -0
  6. package/components/assistant/xertica-assistant/parts/AssistantCollapsedView.tsx +97 -0
  7. package/components/assistant/xertica-assistant/parts/AssistantConversationList.tsx +104 -0
  8. package/components/assistant/xertica-assistant/parts/AssistantDocumentEditor.tsx +81 -0
  9. package/components/assistant/xertica-assistant/parts/AssistantFeedbackDialog.tsx +86 -0
  10. package/components/assistant/xertica-assistant/parts/AssistantHeader.tsx +77 -0
  11. package/components/assistant/xertica-assistant/parts/AssistantMessageBubble.tsx +573 -0
  12. package/components/assistant/xertica-assistant/parts/AssistantTabBar.tsx +65 -0
  13. package/components/assistant/xertica-assistant/parts/AssistantTypingIndicator.tsx +41 -0
  14. package/components/assistant/xertica-assistant/parts/AssistantWelcomeScreen.tsx +98 -0
  15. package/components/assistant/xertica-assistant/parts/index.ts +16 -0
  16. package/components/assistant/xertica-assistant/types.ts +139 -0
  17. package/components/assistant/xertica-assistant/use-assistant.ts +559 -0
  18. package/components/assistant/xertica-assistant/xertica-assistant.stories.tsx +200 -0
  19. package/components/assistant/xertica-assistant/xertica-assistant.tsx +198 -1460
  20. package/components/brand/theme-toggle/ThemeToggle.tsx +8 -27
  21. package/components/hooks/index.ts +3 -0
  22. package/components/hooks/use-layout-shortcuts.ts +46 -0
  23. package/components/layout/sidebar/index.ts +2 -0
  24. package/components/layout/sidebar/sidebar.stories.tsx +160 -8
  25. package/components/layout/sidebar/sidebar.tsx +606 -497
  26. package/components/layout/sidebar/use-sidebar.ts +104 -0
  27. package/components/media/audio-player/AudioPlayer.tsx +131 -206
  28. package/components/media/audio-player/use-audio-player.ts +298 -0
  29. package/components/pages/home-page/HomePage.tsx +1 -1
  30. package/components/pages/template-content/TemplateContent.tsx +5 -5
  31. package/components/pages/template-page/TemplatePage.tsx +5 -5
  32. package/components/shared/CustomTooltipContent.tsx +52 -0
  33. package/components/shared/layout-constants.ts +1 -1
  34. package/components/ui/chart/chart.stories.tsx +966 -7
  35. package/components/ui/chart/chart.tsx +918 -45
  36. package/components/ui/file-upload/file-upload.stories.tsx +100 -0
  37. package/components/ui/file-upload/file-upload.tsx +14 -74
  38. package/components/ui/file-upload/index.ts +1 -0
  39. package/components/ui/file-upload/use-file-upload.ts +181 -0
  40. package/components/ui/pagination/index.ts +2 -0
  41. package/components/ui/pagination/pagination.stories.tsx +94 -0
  42. package/components/ui/pagination/use-pagination.ts +194 -0
  43. package/components/ui/rich-text-editor/index.ts +2 -0
  44. package/components/ui/rich-text-editor/rich-text-editor.stories.tsx +129 -1
  45. package/components/ui/rich-text-editor/rich-text-editor.tsx +86 -305
  46. package/components/ui/rich-text-editor/use-rich-text-editor.ts +439 -0
  47. package/components/ui/stepper/index.ts +3 -1
  48. package/components/ui/stepper/stepper.stories.tsx +116 -0
  49. package/components/ui/stepper/stepper.tsx +4 -4
  50. package/components/ui/stepper/use-stepper.ts +137 -0
  51. package/components/ui/tree-view/index.ts +4 -1
  52. package/components/ui/tree-view/tree-view.stories.tsx +110 -4
  53. package/components/ui/tree-view/tree-view.tsx +17 -125
  54. package/components/ui/tree-view/use-tree-view.ts +229 -0
  55. package/contexts/AssistenteContext.tsx +17 -54
  56. package/contexts/BrandColorsContext.tsx +6 -17
  57. package/contexts/LayoutContext.tsx +5 -31
  58. package/dist/AssistantChart-BAudAfne.cjs +3591 -0
  59. package/dist/AssistantChart-BP8upjMk.js +3565 -0
  60. package/dist/AudioPlayer-1ypwE2Wh.cjs +936 -0
  61. package/dist/AudioPlayer-DuKXrCfy.js +937 -0
  62. package/dist/CustomTooltipContent-DHjkY0ww.js +40 -0
  63. package/dist/CustomTooltipContent-c_K-DWRr.cjs +56 -0
  64. package/dist/LanguageContext-BwhwC3G2.js +657 -0
  65. package/dist/LanguageContext-DvUt5jBg.cjs +656 -0
  66. package/dist/LayoutContext-BDmcZfMH.cjs +84 -0
  67. package/dist/LayoutContext-dbQvdC4O.js +85 -0
  68. package/dist/ThemeContext-RTy1m2Uq.js +82 -0
  69. package/dist/ThemeContext-bSzuOit2.cjs +81 -0
  70. package/dist/VerifyEmailPage-C_ihbcth.js +2828 -0
  71. package/dist/VerifyEmailPage-Dt7zgA4w.cjs +2827 -0
  72. package/dist/XerticaProvider-CW9hpCdF.cjs +39 -0
  73. package/dist/XerticaProvider-siSt9uG2.js +40 -0
  74. package/dist/XerticaXLogo-D8jf0SNv.cjs +214 -0
  75. package/dist/XerticaXLogo-fAJMy3H4.js +215 -0
  76. package/dist/assistant.cjs.js +2 -1
  77. package/dist/assistant.es.js +3 -2
  78. package/dist/brand.cjs.js +2 -2
  79. package/dist/brand.es.js +2 -2
  80. package/dist/cli.js +14 -8
  81. package/dist/components/assistant/xertica-assistant/index.d.ts +2 -0
  82. package/dist/components/assistant/xertica-assistant/parts/AssistantCollapsedView.d.ts +13 -0
  83. package/dist/components/assistant/xertica-assistant/parts/AssistantConversationList.d.ts +16 -0
  84. package/dist/components/assistant/xertica-assistant/parts/AssistantDocumentEditor.d.ts +17 -0
  85. package/dist/components/assistant/xertica-assistant/parts/AssistantFeedbackDialog.d.ts +19 -0
  86. package/dist/components/assistant/xertica-assistant/parts/AssistantHeader.d.ts +11 -0
  87. package/dist/components/assistant/xertica-assistant/parts/AssistantMessageBubble.d.ts +29 -0
  88. package/dist/components/assistant/xertica-assistant/parts/AssistantTabBar.d.ts +13 -0
  89. package/dist/components/assistant/xertica-assistant/parts/AssistantTypingIndicator.d.ts +4 -0
  90. package/dist/components/assistant/xertica-assistant/parts/AssistantWelcomeScreen.d.ts +17 -0
  91. package/dist/components/assistant/xertica-assistant/parts/index.d.ts +16 -0
  92. package/dist/components/assistant/xertica-assistant/types.d.ts +106 -0
  93. package/dist/components/assistant/xertica-assistant/use-assistant.d.ts +125 -0
  94. package/dist/components/assistant/xertica-assistant/xertica-assistant.d.ts +8 -97
  95. package/dist/components/hooks/index.d.ts +3 -0
  96. package/dist/components/hooks/use-layout-shortcuts.d.ts +22 -0
  97. package/dist/components/layout/sidebar/index.d.ts +2 -0
  98. package/dist/components/layout/sidebar/sidebar.d.ts +80 -0
  99. package/dist/components/layout/sidebar/use-sidebar.d.ts +22 -0
  100. package/dist/components/media/audio-player/AudioPlayer.d.ts +4 -1
  101. package/dist/components/media/audio-player/use-audio-player.d.ts +72 -0
  102. package/dist/components/shared/CustomTooltipContent.d.ts +20 -0
  103. package/dist/components/shared/layout-constants.d.ts +1 -1
  104. package/dist/components/ui/alert/alert.d.ts +1 -1
  105. package/dist/components/ui/badge/badge.d.ts +1 -1
  106. package/dist/components/ui/button/button.d.ts +2 -2
  107. package/dist/components/ui/chart/chart.d.ts +162 -5
  108. package/dist/components/ui/file-upload/file-upload.d.ts +2 -0
  109. package/dist/components/ui/file-upload/index.d.ts +1 -0
  110. package/dist/components/ui/file-upload/use-file-upload.d.ts +49 -0
  111. package/dist/components/ui/pagination/index.d.ts +2 -0
  112. package/dist/components/ui/pagination/use-pagination.d.ts +78 -0
  113. package/dist/components/ui/rich-text-editor/index.d.ts +2 -0
  114. package/dist/components/ui/rich-text-editor/use-rich-text-editor.d.ts +107 -0
  115. package/dist/components/ui/stepper/index.d.ts +3 -1
  116. package/dist/components/ui/stepper/stepper.d.ts +2 -2
  117. package/dist/components/ui/stepper/use-stepper.d.ts +60 -0
  118. package/dist/components/ui/tree-view/index.d.ts +4 -1
  119. package/dist/components/ui/tree-view/tree-view.d.ts +4 -6
  120. package/dist/components/ui/tree-view/use-tree-view.d.ts +60 -0
  121. package/dist/contexts/AssistenteContext.d.ts +10 -49
  122. package/dist/hooks.cjs.js +30 -10
  123. package/dist/hooks.es.js +25 -4
  124. package/dist/index.cjs.js +20 -9
  125. package/dist/index.es.js +38 -27
  126. package/dist/layout.cjs.js +82 -1
  127. package/dist/layout.es.js +83 -2
  128. package/dist/media.cjs.js +1 -1
  129. package/dist/media.es.js +1 -1
  130. package/dist/pages.cjs.js +1 -1
  131. package/dist/pages.es.js +1 -1
  132. package/dist/rich-text-editor-BmsjY03B.js +2949 -0
  133. package/dist/rich-text-editor-GS2kpTAK.cjs +2966 -0
  134. package/dist/sidebar-CVUGHOS_.cjs +756 -0
  135. package/dist/sidebar-CmvwjnVb.js +757 -0
  136. package/dist/ui.cjs.js +12 -2
  137. package/dist/ui.es.js +24 -14
  138. package/dist/use-audio-player-Bkh23vQ3.js +177 -0
  139. package/dist/use-audio-player-Dn1NR9xN.cjs +176 -0
  140. package/dist/utils/color-utils.d.ts +51 -0
  141. package/dist/xertica-assistant-BMqdyRVi.js +2082 -0
  142. package/dist/xertica-assistant-Bj3vBCq_.cjs +2081 -0
  143. package/dist/xertica-ui.css +1 -1
  144. package/docs/ai-usage.md +28 -10
  145. package/docs/architecture-improvements.md +463 -0
  146. package/docs/architecture.md +77 -1
  147. package/docs/components/assistant-chart.md +1 -1
  148. package/docs/components/assistant.md +159 -0
  149. package/docs/components/audio-player.md +46 -0
  150. package/docs/components/branding.md +251 -0
  151. package/docs/components/chart.md +354 -39
  152. package/docs/components/code-block.md +108 -0
  153. package/docs/components/file-upload.md +119 -2
  154. package/docs/components/formatted-document.md +113 -0
  155. package/docs/components/hooks.md +430 -0
  156. package/docs/components/image-with-fallback.md +106 -0
  157. package/docs/components/map-layers.md +140 -0
  158. package/docs/components/modern-chat-input.md +163 -0
  159. package/docs/components/pages.md +351 -0
  160. package/docs/components/pagination.md +187 -0
  161. package/docs/components/rich-text-editor.md +164 -0
  162. package/docs/components/sidebar.md +153 -4
  163. package/docs/components/stepper.md +157 -12
  164. package/docs/components/tree-view.md +164 -6
  165. package/docs/doc-audit.md +223 -0
  166. package/docs/getting-started.md +155 -1
  167. package/docs/guidelines.md +14 -8
  168. package/docs/layout.md +2 -2
  169. package/docs/llms.md +29 -9
  170. package/docs/patterns/detail-page.md +276 -0
  171. package/docs/patterns/settings.md +346 -0
  172. package/docs/patterns/wizard.md +217 -0
  173. package/guidelines/Guidelines.md +5 -3
  174. package/llms.txt +1 -1
  175. package/package.json +10 -10
  176. package/styles/xertica/tokens.css +41 -12
  177. package/templates/CLAUDE.md +16 -6
  178. package/templates/guidelines/Guidelines.md +16 -4
  179. package/templates/package.json +3 -3
  180. package/templates/src/styles/xertica/tokens.css +39 -10
  181. package/utils/color-utils.ts +72 -0
@@ -0,0 +1,439 @@
1
+ import { useRef, useState, useEffect, useCallback } from 'react';
2
+
3
+ // ─────────────────────────────────────────────────────────────────────────────
4
+ // Types
5
+ // ─────────────────────────────────────────────────────────────────────────────
6
+
7
+ export interface ActiveFormats {
8
+ bold: boolean;
9
+ italic: boolean;
10
+ underline: boolean;
11
+ justifyLeft: boolean;
12
+ justifyCenter: boolean;
13
+ justifyRight: boolean;
14
+ insertUnorderedList: boolean;
15
+ insertOrderedList: boolean;
16
+ link: boolean;
17
+ h1: boolean;
18
+ h2: boolean;
19
+ h3: boolean;
20
+ p: boolean;
21
+ }
22
+
23
+ export interface UseRichTextEditorProps {
24
+ /** Current HTML value of the editor */
25
+ value: string;
26
+ /** Called whenever the editor content changes */
27
+ onChange?: (value: string) => void;
28
+ }
29
+
30
+ export interface UseRichTextEditorReturn {
31
+ // ── Refs ──────────────────────────────────────────────────────────────────
32
+ /** Attach to the `contenteditable` div */
33
+ editorRef: React.RefObject<HTMLDivElement>;
34
+ /** Attach to the search `<input>` */
35
+ searchInputRef: React.RefObject<HTMLInputElement>;
36
+ /** Attach to the link URL `<input>` */
37
+ linkInputRef: React.RefObject<HTMLInputElement>;
38
+
39
+ // ── State ─────────────────────────────────────────────────────────────────
40
+ /** Map of currently active formatting commands */
41
+ activeFormats: ActiveFormats;
42
+ /** Whether the inline search bar is open */
43
+ isSearchOpen: boolean;
44
+ setIsSearchOpen: (open: boolean) => void;
45
+ /** Current search query string */
46
+ searchQuery: string;
47
+ setSearchQuery: (query: string) => void;
48
+ /** Current link URL being edited */
49
+ linkUrl: string;
50
+ setLinkUrl: (url: string) => void;
51
+ /** Whether the link popover is open */
52
+ isLinkOpen: boolean;
53
+ /**
54
+ * True when the popover was opened with a non-collapsed text selection
55
+ * (or with the cursor inside an existing link). Use this to decide whether
56
+ * to show the link-insert form vs. the "select text first" hint.
57
+ */
58
+ hasSavedSelection: boolean;
59
+
60
+ // ── Computed ──────────────────────────────────────────────────────────────
61
+ /** Word count of the editor content */
62
+ wordCount: number;
63
+ /** Character count of the editor content */
64
+ characterCount: number;
65
+
66
+ // ── Handlers ──────────────────────────────────────────────────────────────
67
+ /** Re-read the current selection and update `activeFormats` */
68
+ updateActiveFormats: () => void;
69
+ /** Execute a `document.execCommand` and sync state */
70
+ execCommand: (command: string, value?: string) => void;
71
+ /** Called on every `input` event of the editor */
72
+ handleInput: () => void;
73
+ /** Search forward or backward for `searchQuery` */
74
+ performSearch: (text: string, backward?: boolean) => void;
75
+ /** Create or update a hyperlink at the current selection */
76
+ handleCreateLink: () => void;
77
+ /** Remove the hyperlink at the current cursor position */
78
+ handleUnlink: () => void;
79
+ /** Handle link popover open/close (saves selection, pre-fills URL) */
80
+ onLinkPopoverOpenChange: (open: boolean) => void;
81
+ }
82
+
83
+ // ─────────────────────────────────────────────────────────────────────────────
84
+ // Hook
85
+ // ─────────────────────────────────────────────────────────────────────────────
86
+
87
+ /**
88
+ * `useRichTextEditor` — Headless hook for the RichTextEditor component.
89
+ *
90
+ * Encapsulates all state, refs, and DOM-manipulation logic for a
91
+ * `contenteditable`-based rich text editor. Use this hook when you need to
92
+ * build a fully custom editor UI while reusing the same formatting logic as
93
+ * the default `RichTextEditor` component.
94
+ *
95
+ * @example
96
+ * ```tsx
97
+ * import { useRichTextEditor } from 'xertica-ui/ui';
98
+ *
99
+ * function MyEditor({ value, onChange }) {
100
+ * const {
101
+ * editorRef,
102
+ * activeFormats,
103
+ * execCommand,
104
+ * handleInput,
105
+ * wordCount,
106
+ * } = useRichTextEditor({ value, onChange });
107
+ *
108
+ * return (
109
+ * <div>
110
+ * <button
111
+ * onClick={() => execCommand('bold')}
112
+ * style={{ fontWeight: activeFormats.bold ? 'bold' : 'normal' }}
113
+ * >
114
+ * B
115
+ * </button>
116
+ * <div
117
+ * ref={editorRef}
118
+ * contentEditable
119
+ * onInput={handleInput}
120
+ * />
121
+ * <span>{wordCount} words</span>
122
+ * </div>
123
+ * );
124
+ * }
125
+ * ```
126
+ */
127
+ export function useRichTextEditor({
128
+ value,
129
+ onChange,
130
+ }: UseRichTextEditorProps): UseRichTextEditorReturn {
131
+
132
+ // ── Refs ────────────────────────────────────────────────────────────────────
133
+ const editorRef = useRef<HTMLDivElement>(null);
134
+ const searchInputRef = useRef<HTMLInputElement>(null);
135
+ const linkInputRef = useRef<HTMLInputElement>(null);
136
+ const savedSelection = useRef<Range | null>(null);
137
+
138
+ // ── State ───────────────────────────────────────────────────────────────────
139
+ const [activeFormats, setActiveFormats] = useState<ActiveFormats>({
140
+ bold: false,
141
+ italic: false,
142
+ underline: false,
143
+ justifyLeft: false,
144
+ justifyCenter: false,
145
+ justifyRight: false,
146
+ insertUnorderedList: false,
147
+ insertOrderedList: false,
148
+ link: false,
149
+ h1: false,
150
+ h2: false,
151
+ h3: false,
152
+ p: true,
153
+ });
154
+ const [isSearchOpen, setIsSearchOpen] = useState(false);
155
+ const [searchQuery, setSearchQuery] = useState('');
156
+ const [linkUrl, setLinkUrl] = useState('https://');
157
+ const [isLinkOpen, setIsLinkOpen] = useState(false);
158
+ const [hasSavedSelection, setHasSavedSelection] = useState(false);
159
+
160
+ // ── Helpers ─────────────────────────────────────────────────────────────────
161
+
162
+ const findParentTag = useCallback((node: Node | null, tagName: string): HTMLElement | null => {
163
+ let current = node;
164
+ while (current && current !== editorRef.current) {
165
+ if (current.nodeName === tagName) return current as HTMLElement;
166
+ current = current.parentNode;
167
+ }
168
+ return null;
169
+ }, []);
170
+
171
+ const updateActiveFormats = useCallback(() => {
172
+ let formatBlock = '';
173
+ try {
174
+ formatBlock = document.queryCommandValue('formatBlock');
175
+ } catch (_e) { /* ignore */ }
176
+
177
+ const selection = window.getSelection();
178
+ const anchorNode = selection?.anchorNode;
179
+ const focusNode = selection?.focusNode;
180
+
181
+ setActiveFormats({
182
+ bold: document.queryCommandState('bold'),
183
+ italic: document.queryCommandState('italic'),
184
+ underline: document.queryCommandState('underline'),
185
+ justifyLeft: document.queryCommandState('justifyLeft'),
186
+ justifyCenter: document.queryCommandState('justifyCenter'),
187
+ justifyRight: document.queryCommandState('justifyRight'),
188
+ insertUnorderedList: document.queryCommandState('insertUnorderedList'),
189
+ insertOrderedList: document.queryCommandState('insertOrderedList'),
190
+ link: !!(findParentTag(anchorNode || null, 'A') || findParentTag(focusNode || null, 'A')),
191
+ h1: formatBlock === 'h1' || formatBlock === 'H1',
192
+ h2: formatBlock === 'h2' || formatBlock === 'H2',
193
+ h3: formatBlock === 'h3' || formatBlock === 'H3',
194
+ p: formatBlock === 'p' || formatBlock === 'P' || formatBlock === 'div' || formatBlock === 'DIV' || formatBlock === '',
195
+ });
196
+ }, [findParentTag]);
197
+
198
+ // ── Effects ─────────────────────────────────────────────────────────────────
199
+
200
+ // Initialize editor content and listen for selection changes
201
+ useEffect(() => {
202
+ if (editorRef.current && editorRef.current.innerHTML !== value) {
203
+ editorRef.current.innerHTML = value;
204
+ }
205
+
206
+ const handleSelectionChange = () => {
207
+ if (document.activeElement === editorRef.current) {
208
+ updateActiveFormats();
209
+ }
210
+ };
211
+
212
+ document.addEventListener('selectionchange', handleSelectionChange);
213
+ return () => document.removeEventListener('selectionchange', handleSelectionChange);
214
+ // eslint-disable-next-line react-hooks/exhaustive-deps
215
+ }, []);
216
+
217
+ // ── Computed ────────────────────────────────────────────────────────────────
218
+
219
+ const wordCount = (() => {
220
+ const text = editorRef.current?.innerText || '';
221
+ return text.trim() ? text.trim().split(/\s+/).length : 0;
222
+ })();
223
+
224
+ const characterCount = (() => {
225
+ const text = editorRef.current?.innerText || '';
226
+ return text.trim() ? text.length : 0;
227
+ })();
228
+
229
+ // ── Handlers ────────────────────────────────────────────────────────────────
230
+
231
+ const handleInput = useCallback(() => {
232
+ if (editorRef.current) {
233
+ onChange?.(editorRef.current.innerHTML);
234
+ updateActiveFormats();
235
+ }
236
+ }, [onChange, updateActiveFormats]);
237
+
238
+ const execCommand = useCallback((command: string, val: string = '') => {
239
+ document.execCommand(command, false, val);
240
+ updateActiveFormats();
241
+ editorRef.current?.focus();
242
+ if (editorRef.current) {
243
+ onChange?.(editorRef.current.innerHTML);
244
+ }
245
+ }, [onChange, updateActiveFormats]);
246
+
247
+ const performSearch = useCallback((text: string, backward = false) => {
248
+ if (!text || !editorRef.current) return;
249
+
250
+ const editor = editorRef.current;
251
+ const selection = window.getSelection();
252
+ if (!selection) return;
253
+
254
+ const walker = document.createTreeWalker(editor, NodeFilter.SHOW_TEXT);
255
+ const segments: Array<{ node: Text; start: number }> = [];
256
+ let offset = 0;
257
+ let node: Node | null;
258
+ while ((node = walker.nextNode())) {
259
+ segments.push({ node: node as Text, start: offset });
260
+ offset += (node as Text).length;
261
+ }
262
+ const fullText = segments.reduce((acc, s) => acc + (s.node.textContent ?? ''), '');
263
+
264
+ let searchFrom = 0;
265
+ if (selection.rangeCount > 0) {
266
+ const range = selection.getRangeAt(0);
267
+ if (editor.contains(range.startContainer)) {
268
+ const seg = segments.find(s => s.node === range.startContainer);
269
+ if (seg) {
270
+ searchFrom = backward
271
+ ? seg.start + range.startOffset - 1
272
+ : seg.start + range.endOffset;
273
+ }
274
+ }
275
+ }
276
+
277
+ const lowerFull = fullText.toLowerCase();
278
+ const lowerQuery = text.toLowerCase();
279
+ let matchStart = -1;
280
+
281
+ if (backward) {
282
+ matchStart = lowerFull.lastIndexOf(lowerQuery, Math.max(0, searchFrom));
283
+ if (matchStart === -1) matchStart = lowerFull.lastIndexOf(lowerQuery);
284
+ } else {
285
+ matchStart = lowerFull.indexOf(lowerQuery, searchFrom);
286
+ if (matchStart === -1) matchStart = lowerFull.indexOf(lowerQuery);
287
+ }
288
+
289
+ if (matchStart === -1) return;
290
+
291
+ const matchEnd = matchStart + text.length;
292
+ const range = document.createRange();
293
+ let startSet = false;
294
+ let endSet = false;
295
+
296
+ for (let i = 0; i < segments.length && !endSet; i++) {
297
+ const seg = segments[i];
298
+ const segEnd = seg.start + seg.node.length;
299
+
300
+ if (!startSet && matchStart < segEnd && matchStart >= seg.start) {
301
+ range.setStart(seg.node, matchStart - seg.start);
302
+ startSet = true;
303
+ }
304
+ if (startSet && matchEnd <= segEnd) {
305
+ range.setEnd(seg.node, matchEnd - seg.start);
306
+ endSet = true;
307
+ }
308
+ }
309
+
310
+ if (startSet && endSet) {
311
+ selection.removeAllRanges();
312
+ selection.addRange(range);
313
+ (range.startContainer as Element).parentElement?.scrollIntoView?.({ block: 'nearest' });
314
+ }
315
+ }, []);
316
+
317
+ const handleCreateLink = useCallback(() => {
318
+ if (savedSelection.current) {
319
+ const selection = window.getSelection();
320
+ selection?.removeAllRanges();
321
+ selection?.addRange(savedSelection.current);
322
+ }
323
+
324
+ const selection = window.getSelection();
325
+ const anchorNode = selection?.anchorNode;
326
+ const existingLink = findParentTag(anchorNode || null, 'A');
327
+
328
+ if (existingLink) {
329
+ if (linkUrl) {
330
+ existingLink.setAttribute('href', linkUrl);
331
+ existingLink.setAttribute('target', '_blank');
332
+ existingLink.setAttribute('rel', 'noopener noreferrer');
333
+ existingLink.style.color = 'hsl(var(--primary))';
334
+ existingLink.style.textDecoration = 'underline';
335
+ existingLink.style.cursor = 'pointer';
336
+ }
337
+ handleInput();
338
+ setIsLinkOpen(false);
339
+ savedSelection.current = null;
340
+ return;
341
+ }
342
+
343
+ if (!selection || selection.rangeCount === 0 || selection.isCollapsed) {
344
+ return;
345
+ }
346
+
347
+ if (linkUrl) {
348
+ document.execCommand('createLink', false, linkUrl);
349
+ setTimeout(() => {
350
+ const anchor = findParentTag(window.getSelection()?.anchorNode || null, 'A');
351
+ if (anchor) {
352
+ anchor.setAttribute('target', '_blank');
353
+ anchor.setAttribute('rel', 'noopener noreferrer');
354
+ anchor.style.color = 'hsl(var(--primary))';
355
+ anchor.style.textDecoration = 'underline';
356
+ anchor.style.cursor = 'pointer';
357
+ }
358
+ handleInput();
359
+ }, 10);
360
+ setIsLinkOpen(false);
361
+ savedSelection.current = null;
362
+ }
363
+ }, [linkUrl, findParentTag, handleInput]);
364
+
365
+ const handleUnlink = useCallback(() => {
366
+ const selection = window.getSelection();
367
+ const anchorNode = selection?.anchorNode;
368
+ const existingLink = findParentTag(anchorNode || null, 'A');
369
+
370
+ if (existingLink) {
371
+ const parent = existingLink.parentNode;
372
+ while (existingLink.firstChild) {
373
+ parent?.insertBefore(existingLink.firstChild, existingLink);
374
+ }
375
+ parent?.removeChild(existingLink);
376
+ handleInput();
377
+ } else {
378
+ document.execCommand('unlink', false, '');
379
+ }
380
+ }, [findParentTag, handleInput]);
381
+
382
+ const onLinkPopoverOpenChange = useCallback((open: boolean) => {
383
+ if (open) {
384
+ const selection = window.getSelection();
385
+ const anchorNode = selection?.anchorNode;
386
+ const focusNode = selection?.focusNode;
387
+ const existingLink = findParentTag(anchorNode || null, 'A') || findParentTag(focusNode || null, 'A');
388
+
389
+ if (existingLink) {
390
+ setLinkUrl(existingLink.getAttribute('href') || 'https://');
391
+ } else {
392
+ setLinkUrl('https://');
393
+ }
394
+
395
+ // Save the selection whenever there is one (collapsed or not) so
396
+ // handleCreateLink can restore it after the popover steals focus.
397
+ // Also mark hasSavedSelection so the UI can show the insert form.
398
+ if (selection && selection.rangeCount > 0 && (!selection.isCollapsed || existingLink)) {
399
+ savedSelection.current = selection.getRangeAt(0).cloneRange();
400
+ setHasSavedSelection(true);
401
+ } else {
402
+ savedSelection.current = null;
403
+ setHasSavedSelection(false);
404
+ }
405
+ setTimeout(() => linkInputRef.current?.focus(), 100);
406
+ } else {
407
+ setHasSavedSelection(false);
408
+ }
409
+ setIsLinkOpen(open);
410
+ }, [findParentTag]);
411
+
412
+ return {
413
+ // Refs
414
+ editorRef,
415
+ searchInputRef,
416
+ linkInputRef,
417
+ // State
418
+ activeFormats,
419
+ isSearchOpen,
420
+ setIsSearchOpen,
421
+ searchQuery,
422
+ setSearchQuery,
423
+ linkUrl,
424
+ setLinkUrl,
425
+ isLinkOpen,
426
+ hasSavedSelection,
427
+ // Computed
428
+ wordCount,
429
+ characterCount,
430
+ // Handlers
431
+ updateActiveFormats,
432
+ execCommand,
433
+ handleInput,
434
+ performSearch,
435
+ handleCreateLink,
436
+ handleUnlink,
437
+ onLinkPopoverOpenChange,
438
+ };
439
+ }
@@ -1 +1,3 @@
1
- export * from './stepper';
1
+ export { Stepper, Step, useStepperContext } from './stepper';
2
+ export { useStepper } from './use-stepper';
3
+ export type { UseStepperProps, UseStepperReturn } from './use-stepper';
@@ -1,8 +1,11 @@
1
1
  import type { Meta, StoryObj } from '@storybook/react';
2
2
  import { Stepper, Step } from './stepper';
3
+ import { useStepper } from './use-stepper';
3
4
  import { Button } from '../button';
4
5
  import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '../card';
5
6
  import React, { useState } from 'react';
7
+ import { Check } from 'lucide-react';
8
+ import { cn } from '../../shared/utils';
6
9
 
7
10
  const meta: Meta<typeof Stepper> = {
8
11
  title: 'UI/Stepper',
@@ -56,3 +59,116 @@ export const Default: Story = {
56
59
  );
57
60
  },
58
61
  };
62
+
63
+ /**
64
+ * ## useStepper — Headless Hook
65
+ *
66
+ * Use `useStepper` when you need step navigation logic (next/prev/goTo,
67
+ * async validation guard, first/last flags) but want to render a completely
68
+ * custom step indicator UI — or when the step controls live far away from
69
+ * the `<Stepper>` visual component.
70
+ *
71
+ * The `onBeforeNext` guard lets you run async form validation before the
72
+ * step advances. Return `false` to block the transition.
73
+ *
74
+ * ```tsx
75
+ * import { useStepper } from '@xertica/ui/stepper';
76
+ *
77
+ * const { currentStep, totalSteps, next, prev, isFirstStep, isLastStep } =
78
+ * useStepper({ totalSteps: 3, onBeforeNext: () => form.trigger() });
79
+ * ```
80
+ */
81
+ export const HeadlessHook: Story = {
82
+ name: 'useStepper (headless)',
83
+ render: () => {
84
+ const steps = [
85
+ { id: 1, label: 'Personal Info', content: 'Fill in your name and email.' },
86
+ { id: 2, label: 'Address', content: 'Enter your shipping address.' },
87
+ { id: 3, label: 'Review', content: 'Confirm your details before submitting.' },
88
+ ];
89
+
90
+ const {
91
+ currentStep,
92
+ totalSteps,
93
+ next,
94
+ prev,
95
+ goTo,
96
+ isFirstStep,
97
+ isLastStep,
98
+ canGoPrev,
99
+ } = useStepper({
100
+ totalSteps: steps.length,
101
+ // Simulate async validation — always passes in this demo
102
+ onBeforeNext: async (step) => {
103
+ console.log(`Validating step ${step}…`);
104
+ await new Promise((r) => setTimeout(r, 300));
105
+ return true;
106
+ },
107
+ });
108
+
109
+ return (
110
+ <div className="w-[520px] space-y-6">
111
+ {/* Custom step indicator — pill style */}
112
+ <nav aria-label="Progress" className="flex items-center gap-2">
113
+ {steps.map((step, idx) => {
114
+ const isCompleted = step.id < currentStep;
115
+ const isActive = step.id === currentStep;
116
+ return (
117
+ <React.Fragment key={step.id}>
118
+ <button
119
+ onClick={() => goTo(step.id)}
120
+ className={cn(
121
+ 'flex items-center gap-2 rounded-full px-3 py-1.5 text-sm font-medium transition-colors',
122
+ isActive && 'bg-primary text-primary-foreground',
123
+ isCompleted && 'bg-primary/20 text-primary hover:bg-primary/30',
124
+ !isActive && !isCompleted && 'bg-muted text-muted-foreground hover:bg-muted/80'
125
+ )}
126
+ aria-current={isActive ? 'step' : undefined}
127
+ >
128
+ {isCompleted ? (
129
+ <Check className="h-3.5 w-3.5" />
130
+ ) : (
131
+ <span className="flex h-4 w-4 items-center justify-center rounded-full border border-current text-xs">
132
+ {step.id}
133
+ </span>
134
+ )}
135
+ {step.label}
136
+ </button>
137
+ {idx < steps.length - 1 && (
138
+ <div className={cn('h-px flex-1', step.id < currentStep ? 'bg-primary' : 'bg-border')} />
139
+ )}
140
+ </React.Fragment>
141
+ );
142
+ })}
143
+ </nav>
144
+
145
+ {/* Step content */}
146
+ <div className="rounded-xl border bg-card p-6 shadow-sm">
147
+ <h3 className="mb-1 font-semibold">{steps[currentStep - 1].label}</h3>
148
+ <p className="text-sm text-muted-foreground">{steps[currentStep - 1].content}</p>
149
+
150
+ <div className="mt-6 flex h-20 items-center justify-center rounded-lg border border-dashed text-sm text-muted-foreground">
151
+ Step {currentStep} of {totalSteps} — form fields go here
152
+ </div>
153
+ </div>
154
+
155
+ {/* Navigation controls */}
156
+ <div className="flex justify-between">
157
+ <Button variant="outline" onClick={prev} disabled={!canGoPrev}>
158
+ Back
159
+ </Button>
160
+ <Button onClick={() => next()}>
161
+ {isLastStep ? 'Submit' : 'Continue'}
162
+ </Button>
163
+ </div>
164
+
165
+ {isFirstStep && (
166
+ <p className="text-center text-xs text-muted-foreground">
167
+ Step navigation is driven entirely by <code>useStepper</code> — no{' '}
168
+ <code>&lt;Stepper&gt;</code> component used.
169
+ </p>
170
+ )}
171
+ </div>
172
+ );
173
+ },
174
+ };
@@ -10,9 +10,9 @@ interface StepperContextValue {
10
10
 
11
11
  const StepperContext = React.createContext<StepperContextValue | undefined>(undefined);
12
12
 
13
- const useStepper = () => {
13
+ const useStepperContext = () => {
14
14
  const context = React.useContext(StepperContext);
15
- if (!context) throw new Error("useStepper must be used within a Stepper");
15
+ if (!context) throw new Error("useStepperContext must be used within a Stepper");
16
16
  return context;
17
17
  };
18
18
 
@@ -69,7 +69,7 @@ interface StepProps extends React.HTMLAttributes<HTMLDivElement> {
69
69
 
70
70
  const Step = React.forwardRef<HTMLDivElement, StepProps>(
71
71
  ({ step, label, description, error = false, className, ...props }, ref) => {
72
- const { currentStep, totalSteps, orientation } = useStepper();
72
+ const { currentStep, totalSteps, orientation } = useStepperContext();
73
73
  const isActive = step === currentStep;
74
74
  const isCompleted = step < currentStep && !error;
75
75
  const isFirst = step === 1;
@@ -146,4 +146,4 @@ const Step = React.forwardRef<HTMLDivElement, StepProps>(
146
146
  );
147
147
  Step.displayName = "Step";
148
148
 
149
- export { Stepper, Step, useStepper };
149
+ export { Stepper, Step, useStepperContext };