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.
- package/CHANGELOG.md +46 -0
- package/README.md +1 -1
- package/bin/cli.ts +1 -1
- package/bin/generate-tokens.ts +13 -7
- package/components/assistant/xertica-assistant/index.ts +2 -0
- package/components/assistant/xertica-assistant/parts/AssistantCollapsedView.tsx +97 -0
- package/components/assistant/xertica-assistant/parts/AssistantConversationList.tsx +104 -0
- package/components/assistant/xertica-assistant/parts/AssistantDocumentEditor.tsx +81 -0
- package/components/assistant/xertica-assistant/parts/AssistantFeedbackDialog.tsx +86 -0
- package/components/assistant/xertica-assistant/parts/AssistantHeader.tsx +77 -0
- package/components/assistant/xertica-assistant/parts/AssistantMessageBubble.tsx +573 -0
- package/components/assistant/xertica-assistant/parts/AssistantTabBar.tsx +65 -0
- package/components/assistant/xertica-assistant/parts/AssistantTypingIndicator.tsx +41 -0
- package/components/assistant/xertica-assistant/parts/AssistantWelcomeScreen.tsx +98 -0
- package/components/assistant/xertica-assistant/parts/index.ts +16 -0
- package/components/assistant/xertica-assistant/types.ts +139 -0
- package/components/assistant/xertica-assistant/use-assistant.ts +559 -0
- package/components/assistant/xertica-assistant/xertica-assistant.stories.tsx +200 -0
- package/components/assistant/xertica-assistant/xertica-assistant.tsx +198 -1460
- package/components/brand/theme-toggle/ThemeToggle.tsx +8 -27
- package/components/hooks/index.ts +3 -0
- package/components/hooks/use-layout-shortcuts.ts +46 -0
- package/components/layout/sidebar/index.ts +2 -0
- package/components/layout/sidebar/sidebar.stories.tsx +160 -8
- package/components/layout/sidebar/sidebar.tsx +606 -497
- package/components/layout/sidebar/use-sidebar.ts +104 -0
- package/components/media/audio-player/AudioPlayer.tsx +131 -206
- package/components/media/audio-player/use-audio-player.ts +298 -0
- package/components/pages/home-page/HomePage.tsx +1 -1
- package/components/pages/template-content/TemplateContent.tsx +5 -5
- package/components/pages/template-page/TemplatePage.tsx +5 -5
- package/components/shared/CustomTooltipContent.tsx +52 -0
- package/components/shared/layout-constants.ts +1 -1
- package/components/ui/chart/chart.stories.tsx +966 -7
- package/components/ui/chart/chart.tsx +918 -45
- package/components/ui/file-upload/file-upload.stories.tsx +100 -0
- package/components/ui/file-upload/file-upload.tsx +14 -74
- package/components/ui/file-upload/index.ts +1 -0
- package/components/ui/file-upload/use-file-upload.ts +181 -0
- package/components/ui/pagination/index.ts +2 -0
- package/components/ui/pagination/pagination.stories.tsx +94 -0
- package/components/ui/pagination/use-pagination.ts +194 -0
- package/components/ui/rich-text-editor/index.ts +2 -0
- package/components/ui/rich-text-editor/rich-text-editor.stories.tsx +129 -1
- package/components/ui/rich-text-editor/rich-text-editor.tsx +86 -305
- package/components/ui/rich-text-editor/use-rich-text-editor.ts +439 -0
- package/components/ui/stepper/index.ts +3 -1
- package/components/ui/stepper/stepper.stories.tsx +116 -0
- package/components/ui/stepper/stepper.tsx +4 -4
- package/components/ui/stepper/use-stepper.ts +137 -0
- package/components/ui/tree-view/index.ts +4 -1
- package/components/ui/tree-view/tree-view.stories.tsx +110 -4
- package/components/ui/tree-view/tree-view.tsx +17 -125
- package/components/ui/tree-view/use-tree-view.ts +229 -0
- package/contexts/AssistenteContext.tsx +17 -54
- package/contexts/BrandColorsContext.tsx +6 -17
- package/contexts/LayoutContext.tsx +5 -31
- package/dist/AssistantChart-BAudAfne.cjs +3591 -0
- package/dist/AssistantChart-BP8upjMk.js +3565 -0
- package/dist/AudioPlayer-1ypwE2Wh.cjs +936 -0
- package/dist/AudioPlayer-DuKXrCfy.js +937 -0
- package/dist/CustomTooltipContent-DHjkY0ww.js +40 -0
- package/dist/CustomTooltipContent-c_K-DWRr.cjs +56 -0
- package/dist/LanguageContext-BwhwC3G2.js +657 -0
- package/dist/LanguageContext-DvUt5jBg.cjs +656 -0
- package/dist/LayoutContext-BDmcZfMH.cjs +84 -0
- package/dist/LayoutContext-dbQvdC4O.js +85 -0
- package/dist/ThemeContext-RTy1m2Uq.js +82 -0
- package/dist/ThemeContext-bSzuOit2.cjs +81 -0
- package/dist/VerifyEmailPage-C_ihbcth.js +2828 -0
- package/dist/VerifyEmailPage-Dt7zgA4w.cjs +2827 -0
- package/dist/XerticaProvider-CW9hpCdF.cjs +39 -0
- package/dist/XerticaProvider-siSt9uG2.js +40 -0
- package/dist/XerticaXLogo-D8jf0SNv.cjs +214 -0
- package/dist/XerticaXLogo-fAJMy3H4.js +215 -0
- package/dist/assistant.cjs.js +2 -1
- package/dist/assistant.es.js +3 -2
- package/dist/brand.cjs.js +2 -2
- package/dist/brand.es.js +2 -2
- package/dist/cli.js +14 -8
- package/dist/components/assistant/xertica-assistant/index.d.ts +2 -0
- package/dist/components/assistant/xertica-assistant/parts/AssistantCollapsedView.d.ts +13 -0
- package/dist/components/assistant/xertica-assistant/parts/AssistantConversationList.d.ts +16 -0
- package/dist/components/assistant/xertica-assistant/parts/AssistantDocumentEditor.d.ts +17 -0
- package/dist/components/assistant/xertica-assistant/parts/AssistantFeedbackDialog.d.ts +19 -0
- package/dist/components/assistant/xertica-assistant/parts/AssistantHeader.d.ts +11 -0
- package/dist/components/assistant/xertica-assistant/parts/AssistantMessageBubble.d.ts +29 -0
- package/dist/components/assistant/xertica-assistant/parts/AssistantTabBar.d.ts +13 -0
- package/dist/components/assistant/xertica-assistant/parts/AssistantTypingIndicator.d.ts +4 -0
- package/dist/components/assistant/xertica-assistant/parts/AssistantWelcomeScreen.d.ts +17 -0
- package/dist/components/assistant/xertica-assistant/parts/index.d.ts +16 -0
- package/dist/components/assistant/xertica-assistant/types.d.ts +106 -0
- package/dist/components/assistant/xertica-assistant/use-assistant.d.ts +125 -0
- package/dist/components/assistant/xertica-assistant/xertica-assistant.d.ts +8 -97
- package/dist/components/hooks/index.d.ts +3 -0
- package/dist/components/hooks/use-layout-shortcuts.d.ts +22 -0
- package/dist/components/layout/sidebar/index.d.ts +2 -0
- package/dist/components/layout/sidebar/sidebar.d.ts +80 -0
- package/dist/components/layout/sidebar/use-sidebar.d.ts +22 -0
- package/dist/components/media/audio-player/AudioPlayer.d.ts +4 -1
- package/dist/components/media/audio-player/use-audio-player.d.ts +72 -0
- package/dist/components/shared/CustomTooltipContent.d.ts +20 -0
- package/dist/components/shared/layout-constants.d.ts +1 -1
- package/dist/components/ui/alert/alert.d.ts +1 -1
- package/dist/components/ui/badge/badge.d.ts +1 -1
- package/dist/components/ui/button/button.d.ts +2 -2
- package/dist/components/ui/chart/chart.d.ts +162 -5
- package/dist/components/ui/file-upload/file-upload.d.ts +2 -0
- package/dist/components/ui/file-upload/index.d.ts +1 -0
- package/dist/components/ui/file-upload/use-file-upload.d.ts +49 -0
- package/dist/components/ui/pagination/index.d.ts +2 -0
- package/dist/components/ui/pagination/use-pagination.d.ts +78 -0
- package/dist/components/ui/rich-text-editor/index.d.ts +2 -0
- package/dist/components/ui/rich-text-editor/use-rich-text-editor.d.ts +107 -0
- package/dist/components/ui/stepper/index.d.ts +3 -1
- package/dist/components/ui/stepper/stepper.d.ts +2 -2
- package/dist/components/ui/stepper/use-stepper.d.ts +60 -0
- package/dist/components/ui/tree-view/index.d.ts +4 -1
- package/dist/components/ui/tree-view/tree-view.d.ts +4 -6
- package/dist/components/ui/tree-view/use-tree-view.d.ts +60 -0
- package/dist/contexts/AssistenteContext.d.ts +10 -49
- package/dist/hooks.cjs.js +30 -10
- package/dist/hooks.es.js +25 -4
- package/dist/index.cjs.js +20 -9
- package/dist/index.es.js +38 -27
- package/dist/layout.cjs.js +82 -1
- package/dist/layout.es.js +83 -2
- package/dist/media.cjs.js +1 -1
- package/dist/media.es.js +1 -1
- package/dist/pages.cjs.js +1 -1
- package/dist/pages.es.js +1 -1
- package/dist/rich-text-editor-BmsjY03B.js +2949 -0
- package/dist/rich-text-editor-GS2kpTAK.cjs +2966 -0
- package/dist/sidebar-CVUGHOS_.cjs +756 -0
- package/dist/sidebar-CmvwjnVb.js +757 -0
- package/dist/ui.cjs.js +12 -2
- package/dist/ui.es.js +24 -14
- package/dist/use-audio-player-Bkh23vQ3.js +177 -0
- package/dist/use-audio-player-Dn1NR9xN.cjs +176 -0
- package/dist/utils/color-utils.d.ts +51 -0
- package/dist/xertica-assistant-BMqdyRVi.js +2082 -0
- package/dist/xertica-assistant-Bj3vBCq_.cjs +2081 -0
- package/dist/xertica-ui.css +1 -1
- package/docs/ai-usage.md +28 -10
- package/docs/architecture-improvements.md +463 -0
- package/docs/architecture.md +77 -1
- package/docs/components/assistant-chart.md +1 -1
- package/docs/components/assistant.md +159 -0
- package/docs/components/audio-player.md +46 -0
- package/docs/components/branding.md +251 -0
- package/docs/components/chart.md +354 -39
- package/docs/components/code-block.md +108 -0
- package/docs/components/file-upload.md +119 -2
- package/docs/components/formatted-document.md +113 -0
- package/docs/components/hooks.md +430 -0
- package/docs/components/image-with-fallback.md +106 -0
- package/docs/components/map-layers.md +140 -0
- package/docs/components/modern-chat-input.md +163 -0
- package/docs/components/pages.md +351 -0
- package/docs/components/pagination.md +187 -0
- package/docs/components/rich-text-editor.md +164 -0
- package/docs/components/sidebar.md +153 -4
- package/docs/components/stepper.md +157 -12
- package/docs/components/tree-view.md +164 -6
- package/docs/doc-audit.md +223 -0
- package/docs/getting-started.md +155 -1
- package/docs/guidelines.md +14 -8
- package/docs/layout.md +2 -2
- package/docs/llms.md +29 -9
- package/docs/patterns/detail-page.md +276 -0
- package/docs/patterns/settings.md +346 -0
- package/docs/patterns/wizard.md +217 -0
- package/guidelines/Guidelines.md +5 -3
- package/llms.txt +1 -1
- package/package.json +10 -10
- package/styles/xertica/tokens.css +41 -12
- package/templates/CLAUDE.md +16 -6
- package/templates/guidelines/Guidelines.md +16 -4
- package/templates/package.json +3 -3
- package/templates/src/styles/xertica/tokens.css +39 -10
- 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
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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)}>
|
|
@@ -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
|
+
}
|
|
@@ -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
|
+
};
|