@vectara/vectara-ui 18.4.0 → 19.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 (35) hide show
  1. package/lib/components/card/SimpleCard.d.ts +1 -1
  2. package/lib/components/card/_index.scss +5 -30
  3. package/lib/components/composer/Composer.d.ts +41 -0
  4. package/lib/components/composer/Composer.js +148 -0
  5. package/lib/components/composer/_index.scss +4 -0
  6. package/lib/components/composer/useComposerHistory.d.ts +34 -0
  7. package/lib/components/composer/useComposerHistory.js +127 -0
  8. package/lib/components/context/Theme.d.ts +24 -0
  9. package/lib/components/context/Theme.js +78 -2
  10. package/lib/components/fileDropTarget/FileDropTarget.d.ts +8 -0
  11. package/lib/components/fileDropTarget/FileDropTarget.js +69 -0
  12. package/lib/components/fileDropTarget/_index.scss +34 -0
  13. package/lib/components/index.d.ts +5 -2
  14. package/lib/components/index.js +4 -1
  15. package/lib/components/patch/VuiPatch.d.ts +10 -0
  16. package/lib/components/patch/VuiPatch.js +32 -0
  17. package/lib/components/patch/_index.scss +30 -0
  18. package/lib/styles/index.css +122 -28
  19. package/package.json +1 -1
  20. package/src/docs/pages/card/SimpleCard.tsx +72 -6
  21. package/src/docs/pages/colorPalette/CategoricalColors.tsx +89 -0
  22. package/src/docs/pages/colorPalette/NeutralColors.tsx +42 -0
  23. package/src/docs/pages/colorPalette/SemanticColors.tsx +64 -0
  24. package/src/docs/pages/colorPalette/Swatch.tsx +59 -0
  25. package/src/docs/pages/colorPalette/TextAndBorderColors.tsx +37 -0
  26. package/src/docs/pages/colorPalette/Usage.tsx +72 -0
  27. package/src/docs/pages/colorPalette/index.tsx +43 -0
  28. package/src/docs/pages/composer/Composer.tsx +130 -0
  29. package/src/docs/pages/composer/index.tsx +11 -0
  30. package/src/docs/pages/fileDropTarget/FileDropTarget.tsx +80 -0
  31. package/src/docs/pages/fileDropTarget/index.tsx +11 -0
  32. package/src/docs/pages/patch/Icons.tsx +16 -0
  33. package/src/docs/pages/patch/Sizes.tsx +20 -0
  34. package/src/docs/pages/patch/index.tsx +22 -0
  35. package/src/docs/pages.tsx +9 -3
@@ -4,7 +4,7 @@ type Props = {
4
4
  fullHeight?: boolean;
5
5
  href?: string;
6
6
  onClick?: (e: MouseEvent) => void;
7
- padding?: "xxs" | "xs" | "s" | "m" | "l";
7
+ padding?: "s" | "m" | "l";
8
8
  children?: React.ReactNode;
9
9
  error?: string | ReactNode;
10
10
  warning?: string | ReactNode;
@@ -179,28 +179,11 @@
179
179
  }
180
180
 
181
181
  .vuiSimpleCard--interactive {
182
- border: 1px solid var(--vui-color-primary-highlight-shade);
183
182
  transition: box-shadow $transitionSpeed, border-color $transitionSpeed;
184
183
 
185
184
  &:hover {
186
- border-color: var(--vui-color-primary-shade);
187
- box-shadow: $shadowLargeEnd;
188
- }
189
- }
190
-
191
- .vuiSimpleCard--danger {
192
- border-color: var(--vui-color-danger-shade);
193
-
194
- &.vuiSimpleCard--interactive:hover {
195
- border-color: var(--vui-color-danger-shade);
196
- }
197
- }
198
-
199
- .vuiSimpleCard--warning {
200
- border-color: var(--vui-color-warning-shade);
201
-
202
- &.vuiSimpleCard--interactive:hover {
203
- border-color: var(--vui-color-warning-shade);
185
+ border-color: var(--vui-color-primary-highlight-shade);
186
+ box-shadow: rgba(60, 64, 67, 0.3) 0px 1px 2px 0px, rgba(60, 64, 67, 0.15) 0px 2px 6px 2px;
204
187
  }
205
188
  }
206
189
 
@@ -208,22 +191,14 @@
208
191
  height: 100%;
209
192
  }
210
193
 
211
- .vuiSimpleCard--xxs {
212
- padding: $sizeXs $sizeS;
213
- }
214
-
215
- .vuiSimpleCard--xs {
216
- padding: $sizeS $sizeM;
217
- }
218
-
219
194
  .vuiSimpleCard--s {
220
- padding: $sizeM $sizeL;
195
+ padding: $sizeS;
221
196
  }
222
197
 
223
198
  .vuiSimpleCard--m {
224
- padding: $sizeL $sizeXl;
199
+ padding: $sizeM;
225
200
  }
226
201
 
227
202
  .vuiSimpleCard--l {
228
- padding: $sizeXl $sizeXxl;
203
+ padding: $sizeL;
229
204
  }
@@ -0,0 +1,41 @@
1
+ export type ComposerSubmission = {
2
+ text: string;
3
+ files: File[];
4
+ };
5
+ export type ComposerFileError = {
6
+ file: File;
7
+ message: string;
8
+ };
9
+ export type ComposerShortcutApi = {
10
+ value: string;
11
+ setValue: (value: string) => void;
12
+ clear: () => void;
13
+ };
14
+ export type ComposerShortcutHandler = (event: React.KeyboardEvent<HTMLTextAreaElement>, composer: ComposerShortcutApi) => void;
15
+ type Props = {
16
+ onSubmit: (submission: ComposerSubmission) => void;
17
+ isRunning?: boolean;
18
+ onCancel?: () => void;
19
+ isSendDisabled?: boolean;
20
+ sendLabel?: string;
21
+ cancelLabel?: string;
22
+ placeholder?: string;
23
+ autoFocus?: boolean;
24
+ onChange?: (value: string) => void;
25
+ value?: string;
26
+ enableHistory?: boolean;
27
+ historyKey?: string;
28
+ canUploadFiles?: boolean;
29
+ accept?: string;
30
+ validateFile?: (file: File) => string | null;
31
+ onFilesRejected?: (errors: ComposerFileError[]) => void;
32
+ fileDropScope?: "document" | "composer";
33
+ fileDropMessage?: React.ReactNode;
34
+ onShortcutKeys?: Record<string, ComposerShortcutHandler>;
35
+ leadingActions?: React.ReactNode;
36
+ footer?: React.ReactNode;
37
+ className?: string;
38
+ "data-testid"?: string;
39
+ };
40
+ export declare const VuiComposer: ({ onSubmit, isRunning, onCancel, isSendDisabled, sendLabel, cancelLabel, placeholder, autoFocus, onChange, value, enableHistory, historyKey, canUploadFiles, accept, validateFile, onFilesRejected, fileDropScope, fileDropMessage, onShortcutKeys, leadingActions, footer, className, "data-testid": dataTestId }: Props) => import("react/jsx-runtime").JSX.Element;
41
+ export {};
@@ -0,0 +1,148 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { useRef, useState } from "react";
3
+ import { BiPaperclip } from "react-icons/bi";
4
+ import classNames from "classnames";
5
+ import { VuiFlexContainer } from "../flex/FlexContainer";
6
+ import { VuiFlexItem } from "../flex/FlexItem";
7
+ import { VuiIcon } from "../icon/Icon";
8
+ import { VuiIconButton } from "../button/IconButton";
9
+ import { VuiButtonPrimary } from "../button/ButtonPrimary";
10
+ import { VuiBadge } from "../badge/Badge";
11
+ import { VuiSpacer } from "../spacer/Spacer";
12
+ import { VuiTextArea } from "../form";
13
+ import { VuiFileDropTarget } from "../fileDropTarget/FileDropTarget";
14
+ import { useComposerHistory } from "./useComposerHistory";
15
+ const MOD_ORDER = ["mod", "alt", "shift"];
16
+ // Single-character keys are matched case-insensitively; named keys keep their case.
17
+ const normalizeKeyName = (key) => (key.length === 1 ? key.toLowerCase() : key);
18
+ const canonical = (mods, key) => [...MOD_ORDER.filter((mod) => mods.has(mod)), normalizeKeyName(key)].join("+");
19
+ const eventSignature = (e) => {
20
+ const mods = new Set();
21
+ // Treat Cmd and Ctrl interchangeably so "Mod+k" works across platforms.
22
+ if (e.metaKey || e.ctrlKey)
23
+ mods.add("mod");
24
+ if (e.altKey)
25
+ mods.add("alt");
26
+ if (e.shiftKey)
27
+ mods.add("shift");
28
+ return canonical(mods, e.key);
29
+ };
30
+ const comboSignature = (combo) => {
31
+ var _a;
32
+ const segments = combo.split("+").map((segment) => segment.trim());
33
+ const key = (_a = segments.pop()) !== null && _a !== void 0 ? _a : "";
34
+ const mods = new Set();
35
+ for (const segment of segments) {
36
+ const mod = segment.toLowerCase();
37
+ mods.add(mod === "ctrl" || mod === "meta" ? "mod" : mod);
38
+ }
39
+ return canonical(mods, key);
40
+ };
41
+ export const VuiComposer = ({ onSubmit, isRunning, onCancel, isSendDisabled, sendLabel = "Send", cancelLabel = "Cancel", placeholder, autoFocus, onChange, value, enableHistory, historyKey, canUploadFiles, accept, validateFile, onFilesRejected, fileDropScope = "document", fileDropMessage, onShortcutKeys, leadingActions, footer, className, "data-testid": dataTestId }) => {
42
+ const isControlled = value !== undefined;
43
+ const [internalValue, setInternalValue] = useState("");
44
+ const [files, setFiles] = useState([]);
45
+ const fileInputRef = useRef(null);
46
+ const composerRef = useRef(null);
47
+ const currentValue = isControlled ? value : internalValue;
48
+ const setValue = (next) => {
49
+ if (!isControlled)
50
+ setInternalValue(next);
51
+ onChange === null || onChange === void 0 ? void 0 : onChange(next);
52
+ };
53
+ const clear = () => setValue("");
54
+ const history = useComposerHistory({ storageKey: historyKey, value: currentValue, setValue });
55
+ const isEmptyMessage = currentValue.trim() === "" && files.length === 0;
56
+ const addFiles = (incoming) => {
57
+ const existingNames = new Set(files.map((file) => file.name));
58
+ const deduped = incoming.filter((file) => !existingNames.has(file.name));
59
+ if (!validateFile) {
60
+ if (deduped.length > 0)
61
+ setFiles((prev) => [...prev, ...deduped]);
62
+ return;
63
+ }
64
+ const valid = [];
65
+ const rejected = [];
66
+ for (const file of deduped) {
67
+ const message = validateFile(file);
68
+ if (message)
69
+ rejected.push({ file, message });
70
+ else
71
+ valid.push(file);
72
+ }
73
+ if (valid.length > 0)
74
+ setFiles((prev) => [...prev, ...valid]);
75
+ if (rejected.length > 0)
76
+ onFilesRejected === null || onFilesRejected === void 0 ? void 0 : onFilesRejected(rejected);
77
+ };
78
+ const removeFile = (index) => {
79
+ setFiles((prev) => prev.filter((_, i) => i !== index));
80
+ };
81
+ const submit = () => {
82
+ if (isEmptyMessage || isSendDisabled)
83
+ return;
84
+ onSubmit({ text: currentValue, files });
85
+ if (enableHistory)
86
+ history.record(currentValue);
87
+ setValue("");
88
+ setFiles([]);
89
+ };
90
+ const handleChange = (e) => {
91
+ setValue(e.target.value);
92
+ // Any edit cancels in-progress history navigation.
93
+ if (enableHistory)
94
+ history.reset();
95
+ };
96
+ const handleKeyDown = (e) => {
97
+ var _a;
98
+ // Consumer shortcuts run first; preventDefault suppresses built-in handling.
99
+ if (onShortcutKeys) {
100
+ const signature = eventSignature(e);
101
+ for (const combo in onShortcutKeys) {
102
+ if (comboSignature(combo) === signature) {
103
+ onShortcutKeys[combo](e, { value: currentValue, setValue, clear });
104
+ break;
105
+ }
106
+ }
107
+ if (e.defaultPrevented)
108
+ return;
109
+ }
110
+ // Enter submits; Shift+Enter falls through so the textarea inserts a newline.
111
+ if (!e.shiftKey && e.code === "Enter") {
112
+ e.preventDefault();
113
+ submit();
114
+ return;
115
+ }
116
+ // Only let history cycling kick in when the caret is at the textarea's
117
+ // vertical boundary in the direction of the arrow key — otherwise the native
118
+ // textarea must move the caret between internal lines. Without this gate, the
119
+ // hook's primed→cycling state machine (designed for a single-line input)
120
+ // would replace the textarea contents regardless of caret position.
121
+ if (enableHistory && (e.key === "ArrowUp" || e.key === "ArrowDown")) {
122
+ const el = e.currentTarget;
123
+ const pos = (_a = el.selectionStart) !== null && _a !== void 0 ? _a : 0;
124
+ // A caret at position 0 is always on the first line. We special-case it
125
+ // because lastIndexOf("\n", -1) clamps its negative fromIndex to 0 and then
126
+ // inspects index 0 — so a value that begins with a newline would be wrongly
127
+ // reported as "not first line", trapping history navigation. Otherwise,
128
+ // lastIndexOf("\n", pos - 1) scans backward from the character just before
129
+ // the caret; -1 means no newline precedes it, so it is on the first line.
130
+ const cursorOnFirstLine = pos === 0 || el.value.lastIndexOf("\n", pos - 1) === -1;
131
+ // indexOf("\n", pos) scans forward from the caret; -1 means no newline at or
132
+ // after it, so it is on the last line.
133
+ const cursorOnLastLine = el.value.indexOf("\n", pos) === -1;
134
+ if (e.key === "ArrowUp" && !cursorOnFirstLine)
135
+ return;
136
+ if (e.key === "ArrowDown" && !cursorOnLastLine)
137
+ return;
138
+ history.handleKeyDown(e);
139
+ }
140
+ };
141
+ const handleFileInputChange = (e) => {
142
+ if (e.target.files)
143
+ addFiles(Array.from(e.target.files));
144
+ // Reset so selecting the same file again still fires a change event.
145
+ e.target.value = "";
146
+ };
147
+ return (_jsxs("div", Object.assign({ className: classNames("vuiComposer", className), "data-testid": dataTestId, ref: composerRef }, { children: [_jsxs(VuiFlexContainer, Object.assign({ alignItems: "end", fullWidth: true, spacing: "xs" }, { children: [canUploadFiles && (_jsx(VuiFlexItem, { children: _jsx(VuiIconButton, { color: "neutral", "aria-label": "Attach files", icon: _jsx(VuiIcon, { children: _jsx(BiPaperclip, {}) }), onClick: () => { var _a; return (_a = fileInputRef.current) === null || _a === void 0 ? void 0 : _a.click(); } }) })), leadingActions, _jsx(VuiFlexItem, Object.assign({ grow: 1 }, { children: _jsx(VuiTextArea, { autoFocus: autoFocus, autoGrow: true, fullWidth: true, rows: 1, placeholder: placeholder, value: currentValue, onChange: handleChange, onKeyDown: handleKeyDown }) })), _jsx(VuiFlexItem, { children: isRunning ? (_jsx(VuiButtonPrimary, Object.assign({ color: "subdued", size: "l", onClick: onCancel }, { children: cancelLabel }))) : (_jsx(VuiButtonPrimary, Object.assign({ color: "primary", size: "l", onClick: submit, isDisabled: isEmptyMessage || isSendDisabled }, { children: sendLabel }))) })] })), canUploadFiles && (_jsx("input", { ref: fileInputRef, type: "file", multiple: true, accept: accept, style: { display: "none" }, onChange: handleFileInputChange })), files.length > 0 && (_jsxs(_Fragment, { children: [_jsx(VuiSpacer, { size: "s" }), _jsx(VuiFlexContainer, Object.assign({ fullWidth: true, wrap: true, spacing: "xxs" }, { children: files.map((file, index) => (_jsx(VuiBadge, Object.assign({ color: "primary", onClose: () => removeFile(index) }, { children: file.name }), file.name))) }))] })), footer, canUploadFiles && (_jsx(VuiFileDropTarget, { onFilesDropped: addFiles, message: fileDropMessage, scopeRef: fileDropScope === "composer" ? composerRef : undefined }))] })));
148
+ };
@@ -0,0 +1,4 @@
1
+ .vuiComposer {
2
+ position: relative;
3
+ width: 100%;
4
+ }
@@ -0,0 +1,34 @@
1
+ import { KeyboardEvent } from "react";
2
+ type Args = {
3
+ storageKey?: string;
4
+ value: string;
5
+ setValue: (value: string) => void;
6
+ };
7
+ /**
8
+ * Cycles through previous submissions with UP/DOWN arrow keys.
9
+ *
10
+ * State machine for navigation (3 phases):
11
+ *
12
+ * idle — no navigation active.
13
+ * primed — first key press (UP/DOWN) consumed by native cursor movement.
14
+ * cycling — UP/DOWN keys walk through the history stack.
15
+ *
16
+ * Transitions on arrow key press:
17
+ *
18
+ * idle + empty input + UP → cycling (skip primed, start navigating immediately)
19
+ * idle + empty input + DOWN → idle (nothing to cycle to)
20
+ * idle + has text + UP/DN → primed (let native cursor move first)
21
+ * primed + same direction → cycling (second press enters history)
22
+ * primed + diff direction → primed (re-prime for the new direction)
23
+ * cycling + same direction → cycling (keep walking history)
24
+ * cycling + diff direction → primed (let native cursor adjust, re-prime)
25
+ *
26
+ * The composer calls reset() on any user edit and record() on submit, both of
27
+ * which return the machine to idle.
28
+ */
29
+ export declare function useComposerHistory({ storageKey, value, setValue }: Args): {
30
+ handleKeyDown: (e: KeyboardEvent) => void;
31
+ record: (input: string) => void;
32
+ reset: () => void;
33
+ };
34
+ export {};
@@ -0,0 +1,127 @@
1
+ import { useRef, useState } from "react";
2
+ function loadHistory(storageKey) {
3
+ try {
4
+ const stored = sessionStorage.getItem(storageKey);
5
+ return stored ? JSON.parse(stored) : [];
6
+ }
7
+ catch (_a) {
8
+ return [];
9
+ }
10
+ }
11
+ function saveHistory(storageKey, history) {
12
+ try {
13
+ sessionStorage.setItem(storageKey, JSON.stringify(history));
14
+ }
15
+ catch (_a) {
16
+ // Quota exceeded or unavailable — silently drop.
17
+ }
18
+ }
19
+ /**
20
+ * Cycles through previous submissions with UP/DOWN arrow keys.
21
+ *
22
+ * State machine for navigation (3 phases):
23
+ *
24
+ * idle — no navigation active.
25
+ * primed — first key press (UP/DOWN) consumed by native cursor movement.
26
+ * cycling — UP/DOWN keys walk through the history stack.
27
+ *
28
+ * Transitions on arrow key press:
29
+ *
30
+ * idle + empty input + UP → cycling (skip primed, start navigating immediately)
31
+ * idle + empty input + DOWN → idle (nothing to cycle to)
32
+ * idle + has text + UP/DN → primed (let native cursor move first)
33
+ * primed + same direction → cycling (second press enters history)
34
+ * primed + diff direction → primed (re-prime for the new direction)
35
+ * cycling + same direction → cycling (keep walking history)
36
+ * cycling + diff direction → primed (let native cursor adjust, re-prime)
37
+ *
38
+ * The composer calls reset() on any user edit and record() on submit, both of
39
+ * which return the machine to idle.
40
+ */
41
+ export function useComposerHistory({ storageKey, value, setValue }) {
42
+ const historyRef = useRef(storageKey ? loadHistory(storageKey) : []);
43
+ const [historyIndex, setHistoryIndex] = useState(null);
44
+ const draftRef = useRef("");
45
+ const phaseRef = useRef("idle");
46
+ const directionRef = useRef(null);
47
+ const reset = () => {
48
+ setHistoryIndex(null);
49
+ phaseRef.current = "idle";
50
+ directionRef.current = null;
51
+ };
52
+ const record = (input) => {
53
+ if (input.trim()) {
54
+ historyRef.current = [...historyRef.current, input];
55
+ if (storageKey)
56
+ saveHistory(storageKey, historyRef.current);
57
+ }
58
+ reset();
59
+ };
60
+ const handleKeyDown = (e) => {
61
+ if (e.key !== "ArrowUp" && e.key !== "ArrowDown")
62
+ return;
63
+ const history = historyRef.current;
64
+ if (history.length === 0)
65
+ return;
66
+ const direction = e.key === "ArrowUp" ? "up" : "down";
67
+ const phase = phaseRef.current;
68
+ const lastDirection = directionRef.current;
69
+ // Idle with text in input — let native cursor handle the first press.
70
+ if (phase === "idle" && value.length > 0) {
71
+ phaseRef.current = "primed";
72
+ directionRef.current = direction;
73
+ return;
74
+ }
75
+ // Idle with empty input — skip primed and start cycling immediately on UP; ignore DOWN.
76
+ if (phase === "idle") {
77
+ if (direction === "down")
78
+ return;
79
+ e.preventDefault();
80
+ draftRef.current = value;
81
+ const newIndex = history.length - 1;
82
+ setHistoryIndex(newIndex);
83
+ setValue(history[newIndex]);
84
+ phaseRef.current = "cycling";
85
+ directionRef.current = direction;
86
+ return;
87
+ }
88
+ // Direction changed — let native cursor handle it, re-prime.
89
+ if (direction !== lastDirection) {
90
+ phaseRef.current = "primed";
91
+ directionRef.current = direction;
92
+ return;
93
+ }
94
+ // Same direction: primed → cycling, or cycling → cycling.
95
+ // Primed DOWN with no active cycling — nothing to cycle to.
96
+ if (direction === "down" && historyIndex === null)
97
+ return;
98
+ e.preventDefault();
99
+ if (direction === "up") {
100
+ if (historyIndex === null) {
101
+ draftRef.current = value;
102
+ const newIndex = history.length - 1;
103
+ setHistoryIndex(newIndex);
104
+ setValue(history[newIndex]);
105
+ }
106
+ else if (historyIndex > 0) {
107
+ const newIndex = historyIndex - 1;
108
+ setHistoryIndex(newIndex);
109
+ setValue(history[newIndex]);
110
+ }
111
+ }
112
+ else {
113
+ if (historyIndex !== null && historyIndex < history.length - 1) {
114
+ const newIndex = historyIndex + 1;
115
+ setHistoryIndex(newIndex);
116
+ setValue(history[newIndex]);
117
+ }
118
+ else if (historyIndex !== null) {
119
+ setHistoryIndex(null);
120
+ setValue(draftRef.current);
121
+ }
122
+ }
123
+ phaseRef.current = "cycling";
124
+ directionRef.current = direction;
125
+ };
126
+ return { handleKeyDown, record, reset };
127
+ }
@@ -55,6 +55,30 @@ export type Theme = {
55
55
  colorBorderMediumRgb?: string;
56
56
  colorBorderLight?: string;
57
57
  colorBorderLightRgb?: string;
58
+ colorIndigoBackground?: string;
59
+ colorIndigoText?: string;
60
+ colorEmeraldBackground?: string;
61
+ colorEmeraldText?: string;
62
+ colorAmberBackground?: string;
63
+ colorAmberText?: string;
64
+ colorPinkBackground?: string;
65
+ colorPinkText?: string;
66
+ colorSkyBackground?: string;
67
+ colorSkyText?: string;
68
+ colorOrangeBackground?: string;
69
+ colorOrangeText?: string;
70
+ colorSlateBackground?: string;
71
+ colorSlateText?: string;
72
+ colorTealBackground?: string;
73
+ colorTealText?: string;
74
+ colorLimeBackground?: string;
75
+ colorLimeText?: string;
76
+ colorPurpleBackground?: string;
77
+ colorPurpleText?: string;
78
+ colorFuchsiaBackground?: string;
79
+ colorFuchsiaText?: string;
80
+ colorRedBackground?: string;
81
+ colorRedText?: string;
58
82
  };
59
83
  export declare const toRgba: (hex: string, alpha: number) => string;
60
84
  export declare const toRgb: (hex: string) => string;
@@ -42,6 +42,32 @@ const colorDarkerShade = "#1c1d22";
42
42
  const colorFullShade = "#0b0c0e";
43
43
  // Border colors
44
44
  const colorBorderLightShade = "#e3e4f3";
45
+ // Categorical colors
46
+ // Each hue pairs a tinted background with a saturated foreground for text and icons.
47
+ const colorIndigoBackground = "#eef2ff";
48
+ const colorIndigoText = "#4f46e5";
49
+ const colorEmeraldBackground = "#ecfdf5";
50
+ const colorEmeraldText = "#059669";
51
+ const colorAmberBackground = "#fef3c7";
52
+ const colorAmberText = "#b45309";
53
+ const colorPinkBackground = "#fce7f3";
54
+ const colorPinkText = "#be185d";
55
+ const colorSkyBackground = "#e0f2fe";
56
+ const colorSkyText = "#0369a1";
57
+ const colorOrangeBackground = "#ffedd5";
58
+ const colorOrangeText = "#c2410c";
59
+ const colorSlateBackground = "#f1f5f9";
60
+ const colorSlateText = "#475569";
61
+ const colorTealBackground = "#ccfbf1";
62
+ const colorTealText = "#0f766e";
63
+ const colorLimeBackground = "#ecfccb";
64
+ const colorLimeText = "#4d7c0f";
65
+ const colorPurpleBackground = "#f3e8ff";
66
+ const colorPurpleText = "#7e22ce";
67
+ const colorFuchsiaBackground = "#fae8ff";
68
+ const colorFuchsiaText = "#a21caf";
69
+ const colorRedBackground = "#fee2e2";
70
+ const colorRedText = "#b91c1c";
45
71
  export const LIGHT_THEME = {
46
72
  // Font
47
73
  fontFamily,
@@ -104,7 +130,32 @@ export const LIGHT_THEME = {
104
130
  colorBorderMedium: colorMediumShade,
105
131
  colorBorderMediumRgb: toRgb(colorMediumShade),
106
132
  colorBorderLight: colorBorderLightShade,
107
- colorBorderLightRgb: toRgb(colorBorderLightShade)
133
+ colorBorderLightRgb: toRgb(colorBorderLightShade),
134
+ // Categorical colors
135
+ colorIndigoBackground,
136
+ colorIndigoText,
137
+ colorEmeraldBackground,
138
+ colorEmeraldText,
139
+ colorAmberBackground,
140
+ colorAmberText,
141
+ colorPinkBackground,
142
+ colorPinkText,
143
+ colorSkyBackground,
144
+ colorSkyText,
145
+ colorOrangeBackground,
146
+ colorOrangeText,
147
+ colorSlateBackground,
148
+ colorSlateText,
149
+ colorTealBackground,
150
+ colorTealText,
151
+ colorLimeBackground,
152
+ colorLimeText,
153
+ colorPurpleBackground,
154
+ colorPurpleText,
155
+ colorFuchsiaBackground,
156
+ colorFuchsiaText,
157
+ colorRedBackground,
158
+ colorRedText
108
159
  };
109
160
  export const DARK_THEME = {
110
161
  // Special colors
@@ -194,7 +245,32 @@ export const toStyle = (theme) => {
194
245
  "--vui-color-border-medium": theme.colorBorderMedium,
195
246
  "--vui-color-border-medium-rgb": theme.colorBorderMediumRgb,
196
247
  "--vui-color-border-light": theme.colorBorderLight,
197
- "--vui-color-border-light-rgb": theme.colorBorderLightRgb
248
+ "--vui-color-border-light-rgb": theme.colorBorderLightRgb,
249
+ // Categorical colors
250
+ "--vui-color-indigo-background": theme.colorIndigoBackground,
251
+ "--vui-color-indigo-text": theme.colorIndigoText,
252
+ "--vui-color-emerald-background": theme.colorEmeraldBackground,
253
+ "--vui-color-emerald-text": theme.colorEmeraldText,
254
+ "--vui-color-amber-background": theme.colorAmberBackground,
255
+ "--vui-color-amber-text": theme.colorAmberText,
256
+ "--vui-color-pink-background": theme.colorPinkBackground,
257
+ "--vui-color-pink-text": theme.colorPinkText,
258
+ "--vui-color-sky-background": theme.colorSkyBackground,
259
+ "--vui-color-sky-text": theme.colorSkyText,
260
+ "--vui-color-orange-background": theme.colorOrangeBackground,
261
+ "--vui-color-orange-text": theme.colorOrangeText,
262
+ "--vui-color-slate-background": theme.colorSlateBackground,
263
+ "--vui-color-slate-text": theme.colorSlateText,
264
+ "--vui-color-teal-background": theme.colorTealBackground,
265
+ "--vui-color-teal-text": theme.colorTealText,
266
+ "--vui-color-lime-background": theme.colorLimeBackground,
267
+ "--vui-color-lime-text": theme.colorLimeText,
268
+ "--vui-color-purple-background": theme.colorPurpleBackground,
269
+ "--vui-color-purple-text": theme.colorPurpleText,
270
+ "--vui-color-fuchsia-background": theme.colorFuchsiaBackground,
271
+ "--vui-color-fuchsia-text": theme.colorFuchsiaText,
272
+ "--vui-color-red-background": theme.colorRedBackground,
273
+ "--vui-color-red-text": theme.colorRedText
198
274
  };
199
275
  // Remove undefined values.
200
276
  return Object.fromEntries(Object.entries(vars).filter(([_, v]) => v !== undefined));
@@ -0,0 +1,8 @@
1
+ import { ReactNode, RefObject } from "react";
2
+ type Props = {
3
+ onFilesDropped: (files: File[]) => void;
4
+ scopeRef?: RefObject<HTMLElement>;
5
+ message?: ReactNode;
6
+ };
7
+ export declare const VuiFileDropTarget: ({ onFilesDropped, scopeRef, message }: Props) => import("react/jsx-runtime").JSX.Element | null;
8
+ export {};
@@ -0,0 +1,69 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useRef, useState } from "react";
3
+ import { createPortal } from "react-dom";
4
+ import { BiUpload } from "react-icons/bi";
5
+ import { VuiScreenBlock } from "../screenBlock/ScreenBlock";
6
+ import { VuiFlexContainer } from "../flex/FlexContainer";
7
+ import { VuiFlexItem } from "../flex/FlexItem";
8
+ import { VuiIcon } from "../icon/Icon";
9
+ import { VuiText } from "../typography/Text";
10
+ const defaultMessage = (_jsxs(VuiFlexContainer, Object.assign({ direction: "column", alignItems: "center", justifyContent: "center" }, { children: [_jsx(VuiFlexItem, { children: _jsx(VuiIcon, Object.assign({ size: "xxxl", color: "empty" }, { children: _jsx(BiUpload, {}) })) }), _jsx(VuiFlexItem, { children: _jsx(VuiText, Object.assign({ align: "center", size: "l" }, { children: _jsx("p", { children: "Drop files to add to upload" }) })) })] })));
11
+ export const VuiFileDropTarget = ({ onFilesDropped, scopeRef, message = defaultMessage }) => {
12
+ const [isDragging, setIsDragging] = useState(false);
13
+ const dragCounterRef = useRef(0);
14
+ useEffect(() => {
15
+ var _a;
16
+ const target = (_a = scopeRef === null || scopeRef === void 0 ? void 0 : scopeRef.current) !== null && _a !== void 0 ? _a : document;
17
+ // Reset whenever the target changes (e.g. when scopeRef attaches or the parent
18
+ // swaps between scoped and global modes) so a stale counter doesn't suppress events.
19
+ dragCounterRef.current = 0;
20
+ setIsDragging(false);
21
+ const handleDragEnter = (e) => {
22
+ var _a;
23
+ e.preventDefault();
24
+ dragCounterRef.current += 1;
25
+ // Only show the overlay for drags that carry files, not e.g. selected text.
26
+ if (dragCounterRef.current === 1 && ((_a = e.dataTransfer) === null || _a === void 0 ? void 0 : _a.items) && e.dataTransfer.items.length > 0) {
27
+ setIsDragging(true);
28
+ }
29
+ };
30
+ const handleDragLeave = (e) => {
31
+ e.preventDefault();
32
+ dragCounterRef.current -= 1;
33
+ if (dragCounterRef.current === 0) {
34
+ setIsDragging(false);
35
+ }
36
+ };
37
+ const handleDragOver = (e) => {
38
+ e.preventDefault();
39
+ };
40
+ const handleDrop = (e) => {
41
+ var _a;
42
+ e.preventDefault();
43
+ setIsDragging(false);
44
+ dragCounterRef.current = 0;
45
+ const files = (_a = e.dataTransfer) === null || _a === void 0 ? void 0 : _a.files;
46
+ if (files && files.length > 0) {
47
+ onFilesDropped(Array.from(files));
48
+ }
49
+ };
50
+ target.addEventListener("dragenter", handleDragEnter);
51
+ target.addEventListener("dragleave", handleDragLeave);
52
+ target.addEventListener("dragover", handleDragOver);
53
+ target.addEventListener("drop", handleDrop);
54
+ return () => {
55
+ target.removeEventListener("dragenter", handleDragEnter);
56
+ target.removeEventListener("dragleave", handleDragLeave);
57
+ target.removeEventListener("dragover", handleDragOver);
58
+ target.removeEventListener("drop", handleDrop);
59
+ };
60
+ }, [scopeRef, onFilesDropped]);
61
+ if (!isDragging)
62
+ return null;
63
+ // Scoped mode: overlay covers only the parent element.
64
+ if (scopeRef === null || scopeRef === void 0 ? void 0 : scopeRef.current) {
65
+ return createPortal(_jsx("div", Object.assign({ className: "vuiFileDropTarget__scopedOverlay" }, { children: _jsx("div", Object.assign({ className: "vuiFileDropTarget__message" }, { children: message })) })), scopeRef.current);
66
+ }
67
+ // Global mode: full-screen overlay.
68
+ return (_jsx(VuiScreenBlock, Object.assign({ color: "primary" }, { children: _jsx("div", Object.assign({ className: "vuiFileDropTarget__messageContainer" }, { children: _jsx("div", Object.assign({ className: "vuiFileDropTarget__message" }, { children: message })) })) })));
69
+ };
@@ -0,0 +1,34 @@
1
+ .vuiFileDropTarget__messageContainer {
2
+ position: fixed;
3
+ left: 0;
4
+ top: 0;
5
+ width: 100%;
6
+ height: 100%;
7
+ display: flex;
8
+ justify-content: center;
9
+ align-items: center;
10
+ }
11
+
12
+ .vuiFileDropTarget__message {
13
+ padding: $sizeL;
14
+ max-width: 400px;
15
+ width: 100%;
16
+ pointer-events: none;
17
+
18
+ p,
19
+ span {
20
+ color: var(--vui-color-empty-shade) !important;
21
+ }
22
+ }
23
+
24
+ // Scoped mode: portal-mounted overlay covering only the parent element (which
25
+ // must be non-statically positioned).
26
+ .vuiFileDropTarget__scopedOverlay {
27
+ position: absolute;
28
+ inset: 0;
29
+ display: flex;
30
+ align-items: center;
31
+ justify-content: center;
32
+ background-color: rgba(var(--vui-color-primary-shade-rgb), 0.6);
33
+ z-index: 10;
34
+ }