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
@@ -1,6 +1,8 @@
1
1
  import type { Meta, StoryObj } from '@storybook/react';
2
2
  import { FileUpload } from './file-upload';
3
+ import { useFileUpload } from './use-file-upload';
3
4
  import React from 'react';
5
+ import { Upload, X, FileIcon, AlertCircle } from 'lucide-react';
4
6
 
5
7
  const meta: Meta<typeof FileUpload> = {
6
8
  title: 'UI/FileUpload',
@@ -51,3 +53,101 @@ export const ImageOnly: Story = {
51
53
  </div>
52
54
  ),
53
55
  };
56
+
57
+ /**
58
+ * ## useFileUpload — Headless Hook
59
+ *
60
+ * Use `useFileUpload` when you need the full drag-and-drop logic but want to
61
+ * render a completely custom drop-zone UI. The hook returns all state and
62
+ * handlers; you wire them to whatever markup you like.
63
+ *
64
+ * ```tsx
65
+ * import { useFileUpload } from '@xertica/ui/file-upload';
66
+ *
67
+ * const {
68
+ * files, dragActive, errorMessage, inputRef,
69
+ * handleDrag, handleDrop, handleChange, removeFile, openFileDialog,
70
+ * } = useFileUpload({ maxFiles: 3, maxSize: 10 * 1024 * 1024 });
71
+ * ```
72
+ */
73
+ export const HeadlessHook: Story = {
74
+ name: 'useFileUpload (headless)',
75
+ render: () => {
76
+ const {
77
+ files,
78
+ dragActive,
79
+ errorMessage,
80
+ inputRef,
81
+ handleDrag,
82
+ handleDrop,
83
+ handleChange,
84
+ removeFile,
85
+ openFileDialog,
86
+ } = useFileUpload({
87
+ maxFiles: 3,
88
+ maxSize: 2 * 1024 * 1024, // 2MB
89
+ onFilesChange: (f) => console.log('files changed', f),
90
+ });
91
+
92
+ return (
93
+ <div className="w-[500px] space-y-3">
94
+ {/* Custom drop-zone — completely different from the default FileUpload UI */}
95
+ <div
96
+ onDragEnter={handleDrag}
97
+ onDragLeave={handleDrag}
98
+ onDragOver={handleDrag}
99
+ onDrop={handleDrop}
100
+ onClick={openFileDialog}
101
+ className={[
102
+ 'flex cursor-pointer flex-col items-center gap-2 rounded-xl border-2 border-dashed p-10 text-center transition-all',
103
+ dragActive
104
+ ? 'border-blue-500 bg-blue-50 text-blue-700'
105
+ : 'border-gray-300 bg-gray-50 text-gray-500 hover:border-gray-400',
106
+ ].join(' ')}
107
+ >
108
+ <Upload className="h-8 w-8" />
109
+ <p className="text-sm font-medium">
110
+ {dragActive ? 'Drop files here…' : 'Drag files or click to browse'}
111
+ </p>
112
+ <p className="text-xs text-gray-400">Up to 3 files · Max 2 MB each</p>
113
+ <input
114
+ ref={inputRef}
115
+ type="file"
116
+ className="hidden"
117
+ multiple
118
+ onChange={handleChange}
119
+ />
120
+ </div>
121
+
122
+ {errorMessage && (
123
+ <div className="flex items-center gap-2 rounded-lg bg-red-50 px-3 py-2 text-sm text-red-600">
124
+ <AlertCircle className="h-4 w-4 shrink-0" />
125
+ {errorMessage}
126
+ </div>
127
+ )}
128
+
129
+ {files.length > 0 && (
130
+ <ul className="space-y-1">
131
+ {files.map((file, i) => (
132
+ <li
133
+ key={i}
134
+ className="flex items-center justify-between rounded-lg border bg-white px-3 py-2 text-sm"
135
+ >
136
+ <span className="flex items-center gap-2">
137
+ <FileIcon className="h-4 w-4 text-gray-400" />
138
+ {file.name}
139
+ </span>
140
+ <button
141
+ onClick={() => removeFile(i)}
142
+ className="text-gray-400 hover:text-red-500"
143
+ >
144
+ <X className="h-4 w-4" />
145
+ </button>
146
+ </li>
147
+ ))}
148
+ </ul>
149
+ )}
150
+ </div>
151
+ );
152
+ },
153
+ };
@@ -2,6 +2,7 @@ import * as React from "react";
2
2
  import { Upload, X, FileIcon, AlertCircle } from "lucide-react";
3
3
  import { cn } from "../../shared/utils";
4
4
  import { Button } from "../button";
5
+ import { useFileUpload } from "./use-file-upload";
5
6
 
6
7
  interface FileUploadProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'onChange' | 'onError'> {
7
8
  onFilesChange?: (files: File[]) => void;
@@ -20,6 +21,8 @@ interface FileUploadProps extends Omit<React.InputHTMLAttributes<HTMLInputElemen
20
21
  * for file uploads. Supports click-to-browse and drag-and-drop. Shows a preview
21
22
  * list of selected files with per-file remove buttons.
22
23
  *
24
+ * Headless logic is available via `useFileUpload` for custom drop-zone UIs.
25
+ *
23
26
  * @ai-rules
24
27
  * 1. Use `onFilesChange` to capture the selected files array in your form state.
25
28
  * 2. `maxFiles` defaults to 1 (single file). Set to a higher number for multi-upload.
@@ -39,80 +42,17 @@ const FileUpload = React.forwardRef<HTMLDivElement, FileUploadProps>(
39
42
  disabled,
40
43
  ...props
41
44
  }, ref) => {
42
- const [files, setFiles] = React.useState<File[]>([]);
43
- const [dragActive, setDragActive] = React.useState(false);
44
- const [errorMessage, setErrorMessage] = React.useState<string | null>(null);
45
- const inputRef = React.useRef<HTMLInputElement>(null);
46
-
47
- const handleFiles = (newFiles: FileList | null) => {
48
- if (!newFiles) return;
49
- setErrorMessage(null);
50
-
51
- const filesArray = Array.from(newFiles);
52
-
53
- const oversized = filesArray.filter(f => f.size > maxSize);
54
- const validFiles = filesArray.filter(f => f.size <= maxSize);
55
-
56
- if (oversized.length > 0) {
57
- const limitMB = (maxSize / 1024 / 1024).toFixed(0);
58
- setErrorMessage(`${oversized.length} file(s) exceed the ${limitMB}MB limit and were not added.`);
59
- onError?.(oversized, 'size');
60
- }
61
-
62
- const merged = maxFiles === 1
63
- ? validFiles.slice(0, 1)
64
- : [...files, ...validFiles].slice(0, maxFiles);
65
-
66
- const countRejected = maxFiles === 1
67
- ? validFiles.slice(1)
68
- : [...files, ...validFiles].slice(maxFiles);
69
-
70
- if (countRejected.length > 0) {
71
- setErrorMessage(`Only ${maxFiles} file(s) allowed. ${countRejected.length} file(s) were not added.`);
72
- onError?.(countRejected, 'count');
73
- }
74
-
75
- setFiles(merged);
76
- onFilesChange?.(merged);
77
- };
78
-
79
- const handleDrag = (e: React.DragEvent) => {
80
- e.preventDefault();
81
- e.stopPropagation();
82
- if (e.type === "dragenter" || e.type === "dragover") {
83
- setDragActive(true);
84
- } else if (e.type === "dragleave") {
85
- setDragActive(false);
86
- }
87
- };
88
-
89
- const handleDrop = (e: React.DragEvent) => {
90
- e.preventDefault();
91
- e.stopPropagation();
92
- setDragActive(false);
93
- if (disabled) return;
94
- handleFiles(e.dataTransfer.files);
95
- };
96
-
97
- const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
98
- e.preventDefault();
99
- if (disabled) return;
100
- handleFiles(e.target.files);
101
- };
102
-
103
- const removeFile = (index: number) => {
104
- const updatedFiles = files.filter((_, i) => i !== index);
105
- setFiles(updatedFiles);
106
- onFilesChange?.(updatedFiles);
107
- if (updatedFiles.length === 0) setErrorMessage(null);
108
- };
109
-
110
- const openFileDialog = () => {
111
- if (!disabled) {
112
- setErrorMessage(null);
113
- inputRef.current?.click();
114
- }
115
- };
45
+ const {
46
+ files,
47
+ dragActive,
48
+ errorMessage,
49
+ inputRef,
50
+ handleDrag,
51
+ handleDrop,
52
+ handleChange,
53
+ removeFile,
54
+ openFileDialog,
55
+ } = useFileUpload({ maxFiles, maxSize, onFilesChange, onError, disabled });
116
56
 
117
57
  return (
118
58
  <div ref={ref} className={cn("w-full", className)}>
@@ -1 +1,2 @@
1
1
  export * from './file-upload';
2
+ export * from './use-file-upload';
@@ -0,0 +1,181 @@
1
+ import { useState, useRef, useCallback } from 'react';
2
+
3
+ // ─────────────────────────────────────────────────────────────────────────────
4
+ // Types
5
+ // ─────────────────────────────────────────────────────────────────────────────
6
+
7
+ export interface UseFileUploadProps {
8
+ /** Maximum number of files allowed. @default 1 */
9
+ maxFiles?: number;
10
+ /** Maximum file size in bytes. @default 5MB */
11
+ maxSize?: number;
12
+ /** Called with the current accepted file list whenever it changes. */
13
+ onFilesChange?: (files: File[]) => void;
14
+ /** Called when files are rejected due to size or count limits. */
15
+ onError?: (rejectedFiles: File[], reason: 'size' | 'count') => void;
16
+ /** Whether the upload area is disabled. */
17
+ disabled?: boolean;
18
+ }
19
+
20
+ export interface UseFileUploadReturn {
21
+ // ── State ─────────────────────────────────────────────────────────────────
22
+ /** Currently accepted files. */
23
+ files: File[];
24
+ /** Whether a drag operation is currently active over the drop zone. */
25
+ dragActive: boolean;
26
+ /** Inline error message, or `null` when there is no error. */
27
+ errorMessage: string | null;
28
+
29
+ // ── Refs ──────────────────────────────────────────────────────────────────
30
+ /** Attach to the hidden `<input type="file">` element. */
31
+ inputRef: React.RefObject<HTMLInputElement>;
32
+
33
+ // ── Handlers ──────────────────────────────────────────────────────────────
34
+ /** Process a `FileList` from any source (input change, drop, paste, etc.). */
35
+ handleFiles: (newFiles: FileList | null) => void;
36
+ /** Drag enter / over / leave handler — attach to all three events. */
37
+ handleDrag: (e: React.DragEvent) => void;
38
+ /** Drop handler — attach to the drop zone element. */
39
+ handleDrop: (e: React.DragEvent) => void;
40
+ /** Input `onChange` handler — attach to the hidden `<input>`. */
41
+ handleChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
42
+ /** Remove a file by its index in the `files` array. */
43
+ removeFile: (index: number) => void;
44
+ /** Programmatically open the native file picker dialog. */
45
+ openFileDialog: () => void;
46
+ }
47
+
48
+ // ─────────────────────────────────────────────────────────────────────────────
49
+ // Hook
50
+ // ─────────────────────────────────────────────────────────────────────────────
51
+
52
+ /**
53
+ * Headless hook for drag-and-drop file upload logic.
54
+ *
55
+ * @description
56
+ * Encapsulates all stateful behaviour for a file upload area: file validation
57
+ * (size and count limits), drag-active tracking, error messaging, and the
58
+ * hidden-input ref. Pair with any custom drop-zone UI.
59
+ *
60
+ * @example
61
+ * ```tsx
62
+ * const { files, dragActive, errorMessage, inputRef, handleDrag, handleDrop, handleChange, removeFile, openFileDialog } =
63
+ * useFileUpload({ maxFiles: 3, maxSize: 10 * 1024 * 1024 });
64
+ * ```
65
+ */
66
+ export function useFileUpload({
67
+ maxFiles = 1,
68
+ maxSize = 5 * 1024 * 1024,
69
+ onFilesChange,
70
+ onError,
71
+ disabled = false,
72
+ }: UseFileUploadProps = {}): UseFileUploadReturn {
73
+ const [files, setFiles] = useState<File[]>([]);
74
+ const [dragActive, setDragActive] = useState(false);
75
+ const [errorMessage, setErrorMessage] = useState<string | null>(null);
76
+ const inputRef = useRef<HTMLInputElement>(null);
77
+
78
+ const handleFiles = useCallback(
79
+ (newFiles: FileList | null) => {
80
+ if (!newFiles) return;
81
+ setErrorMessage(null);
82
+
83
+ const filesArray = Array.from(newFiles);
84
+
85
+ const oversized = filesArray.filter(f => f.size > maxSize);
86
+ const validFiles = filesArray.filter(f => f.size <= maxSize);
87
+
88
+ if (oversized.length > 0) {
89
+ const limitMB = (maxSize / 1024 / 1024).toFixed(0);
90
+ setErrorMessage(
91
+ `${oversized.length} file(s) exceed the ${limitMB}MB limit and were not added.`
92
+ );
93
+ onError?.(oversized, 'size');
94
+ }
95
+
96
+ const merged =
97
+ maxFiles === 1
98
+ ? validFiles.slice(0, 1)
99
+ : [...files, ...validFiles].slice(0, maxFiles);
100
+
101
+ const countRejected =
102
+ maxFiles === 1
103
+ ? validFiles.slice(1)
104
+ : [...files, ...validFiles].slice(maxFiles);
105
+
106
+ if (countRejected.length > 0) {
107
+ setErrorMessage(
108
+ `Only ${maxFiles} file(s) allowed. ${countRejected.length} file(s) were not added.`
109
+ );
110
+ onError?.(countRejected, 'count');
111
+ }
112
+
113
+ setFiles(merged);
114
+ onFilesChange?.(merged);
115
+ },
116
+ // eslint-disable-next-line react-hooks/exhaustive-deps
117
+ [files, maxFiles, maxSize, onError, onFilesChange]
118
+ );
119
+
120
+ const handleDrag = useCallback((e: React.DragEvent) => {
121
+ e.preventDefault();
122
+ e.stopPropagation();
123
+ if (e.type === 'dragenter' || e.type === 'dragover') {
124
+ setDragActive(true);
125
+ } else if (e.type === 'dragleave') {
126
+ setDragActive(false);
127
+ }
128
+ }, []);
129
+
130
+ const handleDrop = useCallback(
131
+ (e: React.DragEvent) => {
132
+ e.preventDefault();
133
+ e.stopPropagation();
134
+ setDragActive(false);
135
+ if (disabled) return;
136
+ handleFiles(e.dataTransfer.files);
137
+ },
138
+ [disabled, handleFiles]
139
+ );
140
+
141
+ const handleChange = useCallback(
142
+ (e: React.ChangeEvent<HTMLInputElement>) => {
143
+ e.preventDefault();
144
+ if (disabled) return;
145
+ handleFiles(e.target.files);
146
+ },
147
+ [disabled, handleFiles]
148
+ );
149
+
150
+ const removeFile = useCallback(
151
+ (index: number) => {
152
+ setFiles(prev => {
153
+ const updated = prev.filter((_, i) => i !== index);
154
+ onFilesChange?.(updated);
155
+ if (updated.length === 0) setErrorMessage(null);
156
+ return updated;
157
+ });
158
+ },
159
+ [onFilesChange]
160
+ );
161
+
162
+ const openFileDialog = useCallback(() => {
163
+ if (!disabled) {
164
+ setErrorMessage(null);
165
+ inputRef.current?.click();
166
+ }
167
+ }, [disabled]);
168
+
169
+ return {
170
+ files,
171
+ dragActive,
172
+ errorMessage,
173
+ inputRef,
174
+ handleFiles,
175
+ handleDrag,
176
+ handleDrop,
177
+ handleChange,
178
+ removeFile,
179
+ openFileDialog,
180
+ };
181
+ }
@@ -1 +1,3 @@
1
1
  export * from './pagination';
2
+ export { usePagination } from './use-pagination';
3
+ export type { PaginationPageItem, UsePaginationProps, UsePaginationReturn } from './use-pagination';
@@ -8,7 +8,9 @@ import {
8
8
  PaginationNext,
9
9
  PaginationPrevious,
10
10
  } from './pagination';
11
+ import { usePagination } from './use-pagination';
11
12
  import React, { useState } from 'react';
13
+ import { ChevronLeft, ChevronRight } from 'lucide-react';
12
14
 
13
15
  type PaginationStoryProps = React.ComponentProps<typeof Pagination> & {
14
16
  activePage?: number;
@@ -104,3 +106,95 @@ export const Controlled: Story = {
104
106
  );
105
107
  },
106
108
  };
109
+
110
+ /**
111
+ * ## usePagination — Headless Hook
112
+ *
113
+ * Use `usePagination` when you need page state, ellipsis logic, and slice
114
+ * indices but want to render a completely custom pagination UI — or when you
115
+ * need to drive a data-fetch without any visual pagination bar at all.
116
+ *
117
+ * The hook computes `items` (an ordered list of page numbers + ellipsis
118
+ * markers) so you never have to calculate which pages to show manually.
119
+ *
120
+ * ```tsx
121
+ * import { usePagination } from '@xertica/ui/pagination';
122
+ *
123
+ * const { currentPage, totalPages, items, next, prev, goTo, canGoPrev, canGoNext } =
124
+ * usePagination({ totalItems: 200, pageSize: 20 });
125
+ * ```
126
+ */
127
+ export const HeadlessHook: Story = {
128
+ name: 'usePagination (headless)',
129
+ render: () => {
130
+ const {
131
+ currentPage,
132
+ totalPages,
133
+ items,
134
+ next,
135
+ prev,
136
+ goTo,
137
+ canGoPrev,
138
+ canGoNext,
139
+ startIndex,
140
+ endIndex,
141
+ } = usePagination({ totalItems: 237, pageSize: 20, siblingCount: 1 });
142
+
143
+ return (
144
+ <div className="space-y-4">
145
+ {/* Data slice info */}
146
+ <p className="text-sm text-muted-foreground">
147
+ Showing items <span className="font-medium text-foreground">{startIndex + 1}–{endIndex}</span> of{' '}
148
+ <span className="font-medium text-foreground">237</span>
149
+ </p>
150
+
151
+ {/* Fully custom pagination bar */}
152
+ <div className="flex items-center gap-1">
153
+ <button
154
+ onClick={prev}
155
+ disabled={!canGoPrev}
156
+ className="flex h-8 w-8 items-center justify-center rounded-md border text-sm disabled:opacity-40 hover:bg-muted transition-colors"
157
+ aria-label="Previous page"
158
+ >
159
+ <ChevronLeft className="h-4 w-4" />
160
+ </button>
161
+
162
+ {items.map((item) =>
163
+ item.type === 'ellipsis' ? (
164
+ <span key={item.key} className="flex h-8 w-8 items-center justify-center text-muted-foreground text-sm">
165
+
166
+ </span>
167
+ ) : (
168
+ <button
169
+ key={item.page}
170
+ onClick={() => goTo(item.page)}
171
+ className={[
172
+ 'flex h-8 w-8 items-center justify-center rounded-md border text-sm font-medium transition-colors',
173
+ item.page === currentPage
174
+ ? 'bg-primary text-primary-foreground border-primary'
175
+ : 'hover:bg-muted',
176
+ ].join(' ')}
177
+ aria-current={item.page === currentPage ? 'page' : undefined}
178
+ >
179
+ {item.page}
180
+ </button>
181
+ )
182
+ )}
183
+
184
+ <button
185
+ onClick={next}
186
+ disabled={!canGoNext}
187
+ className="flex h-8 w-8 items-center justify-center rounded-md border text-sm disabled:opacity-40 hover:bg-muted transition-colors"
188
+ aria-label="Next page"
189
+ >
190
+ <ChevronRight className="h-4 w-4" />
191
+ </button>
192
+ </div>
193
+
194
+ <p className="text-xs text-muted-foreground">
195
+ Page {currentPage} of {totalPages}
196
+ </p>
197
+ </div>
198
+ );
199
+ },
200
+ };