@neasg/design-system 0.3.0 → 0.4.2
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/README.md +37 -1
- package/dist/alert.d.ts +15 -0
- package/dist/alert.js +24 -0
- package/dist/avatar.d.ts +5 -2
- package/dist/avatar.js +8 -4
- package/dist/back-button.d.ts +6 -0
- package/dist/back-button.js +8 -0
- package/dist/badge.d.ts +1 -1
- package/dist/breadcrumb.js +2 -2
- package/dist/button.d.ts +2 -2
- package/dist/button.js +3 -4
- package/dist/card.d.ts +3 -0
- package/dist/card.js +9 -2
- package/dist/checkbox.d.ts +13 -0
- package/dist/checkbox.js +29 -0
- package/dist/collapsible.d.ts +19 -0
- package/dist/collapsible.js +20 -0
- package/dist/command-search.js +2 -2
- package/dist/copy-button.d.ts +19 -0
- package/dist/copy-button.js +31 -0
- package/dist/date-input.js +2 -2
- package/dist/dialog-primitive.js +1 -1
- package/dist/draggable-tabs.js +1 -1
- package/dist/drawer.js +1 -1
- package/dist/editable-table.js +2 -2
- package/dist/empty-state.js +1 -1
- package/dist/field.d.ts +4 -1
- package/dist/field.js +3 -3
- package/dist/file-upload.d.ts +12 -0
- package/dist/file-upload.js +106 -0
- package/dist/index.d.ts +47 -6
- package/dist/index.js +25 -4
- package/dist/input-control.d.ts +1 -1
- package/dist/input-control.js +1 -1
- package/dist/input.js +2 -2
- package/dist/label-value-grid.d.ts +3 -1
- package/dist/label-value-grid.js +15 -2
- package/dist/label-value.d.ts +4 -1
- package/dist/label-value.js +4 -3
- package/dist/layout.d.ts +7 -1
- package/dist/layout.js +3 -3
- package/dist/lib/date-utils.d.ts +9 -0
- package/dist/lib/date-utils.js +34 -0
- package/dist/link.d.ts +9 -0
- package/dist/link.js +19 -0
- package/dist/multi-select.d.ts +3 -1
- package/dist/multi-select.js +12 -11
- package/dist/page-section.d.ts +14 -0
- package/dist/page-section.js +13 -0
- package/dist/pagination.js +1 -1
- package/dist/popover-menu.d.ts +35 -0
- package/dist/popover-menu.js +36 -0
- package/dist/popover-primitive.d.ts +7 -0
- package/dist/popover-primitive.js +11 -0
- package/dist/popover.d.ts +10 -5
- package/dist/popover.js +6 -9
- package/dist/progress.d.ts +10 -0
- package/dist/progress.js +17 -0
- package/dist/search-input.js +1 -1
- package/dist/section-nav.d.ts +22 -0
- package/dist/section-nav.js +25 -0
- package/dist/select-primitive.js +2 -2
- package/dist/select.js +2 -2
- package/dist/separator.d.ts +6 -0
- package/dist/separator.js +6 -0
- package/dist/skeleton.d.ts +4 -0
- package/dist/skeleton.js +6 -0
- package/dist/spinner.d.ts +22 -0
- package/dist/spinner.js +24 -0
- package/dist/styles.css +42 -0
- package/dist/switch.d.ts +12 -0
- package/dist/switch.js +16 -0
- package/dist/table.d.ts +7 -1
- package/dist/table.js +26 -12
- package/dist/tabs.d.ts +24 -1
- package/dist/tabs.js +61 -3
- package/dist/tailwind-preset.js +9 -0
- package/dist/textarea.js +1 -1
- package/dist/toaster.js +19 -10
- package/dist/use-error-shake.d.ts +7 -0
- package/dist/use-error-shake.js +36 -0
- package/dist/use-file-upload.d.ts +34 -0
- package/dist/use-file-upload.js +133 -0
- package/dist/use-sticky-sentinel.d.ts +16 -0
- package/dist/use-sticky-sentinel.js +62 -0
- package/dist/use-table-sort.d.ts +28 -0
- package/dist/use-table-sort.js +53 -0
- package/dist/use-viewport-threshold.d.ts +12 -0
- package/dist/use-viewport-threshold.js +43 -0
- package/package.json +32 -6
- package/dist/tab-with-dropdown.d.ts +0 -18
- package/dist/tab-with-dropdown.js +0 -70
package/dist/tabs.d.ts
CHANGED
|
@@ -20,12 +20,35 @@ interface TabsContentProps extends React.ComponentPropsWithoutRef<typeof TabsPri
|
|
|
20
20
|
value: string;
|
|
21
21
|
}
|
|
22
22
|
declare const TabsContent: React.ForwardRefExoticComponent<TabsContentProps & React.RefAttributes<HTMLDivElement>>;
|
|
23
|
+
export interface TabDropdownSection {
|
|
24
|
+
id: string;
|
|
25
|
+
label: React.ReactNode;
|
|
26
|
+
}
|
|
23
27
|
export interface TabsItem {
|
|
24
28
|
value: string;
|
|
25
29
|
label: React.ReactNode;
|
|
26
30
|
content: React.ReactNode;
|
|
27
31
|
disabled?: boolean;
|
|
32
|
+
/** When provided, the tab shows a chevron and opens a dropdown menu of in-page sections. */
|
|
33
|
+
sections?: TabDropdownSection[];
|
|
34
|
+
/** Scroll offset in pixels when scrolling to a section (default 200). */
|
|
35
|
+
scrollOffset?: number;
|
|
36
|
+
/** Override the default scroll-to-id behavior when a section is picked. */
|
|
37
|
+
onSectionSelect?: (sectionId: string) => void;
|
|
38
|
+
/** Currently-active section id. When set, the parent tab renders as active and the item is highlighted in the dropdown. */
|
|
39
|
+
activeSection?: string;
|
|
40
|
+
}
|
|
41
|
+
export interface TabWithDropdownProps {
|
|
42
|
+
value: string;
|
|
43
|
+
label: React.ReactNode;
|
|
44
|
+
sections?: TabDropdownSection[];
|
|
45
|
+
disabled?: boolean;
|
|
46
|
+
scrollOffset?: number;
|
|
47
|
+
onSectionSelect?: (sectionId: string) => void;
|
|
48
|
+
activeSection?: string;
|
|
49
|
+
className?: string;
|
|
28
50
|
}
|
|
51
|
+
declare function TabWithDropdown({ value, label, sections, disabled, scrollOffset, onSectionSelect, activeSection, className, }: TabWithDropdownProps): import("react/jsx-runtime").JSX.Element;
|
|
29
52
|
export interface TabsProps extends Omit<React.ComponentPropsWithoutRef<typeof TabsPrimitive.Root>, "children" | "value" | "defaultValue" | "onValueChange"> {
|
|
30
53
|
items: TabsItem[];
|
|
31
54
|
defaultValue?: string;
|
|
@@ -36,4 +59,4 @@ export interface TabsProps extends Omit<React.ComponentPropsWithoutRef<typeof Ta
|
|
|
36
59
|
panelClassName?: string;
|
|
37
60
|
}
|
|
38
61
|
declare function Tabs({ items, defaultValue, value, onValueChange, className, listClassName, triggerClassName, panelClassName, ...props }: TabsProps): import("react/jsx-runtime").JSX.Element | null;
|
|
39
|
-
export { Tabs, TabsRoot, TabsList, TabsTrigger, TabsContent };
|
|
62
|
+
export { Tabs, TabsRoot, TabsList, TabsTrigger, TabsContent, TabWithDropdown };
|
package/dist/tabs.js
CHANGED
|
@@ -2,7 +2,10 @@
|
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
3
|
import * as React from "react";
|
|
4
4
|
import * as TabsPrimitive from "@radix-ui/react-tabs";
|
|
5
|
+
import { ChevronDownIcon } from "./animated-icons/chevron-down";
|
|
5
6
|
import { cn } from "./lib/utils";
|
|
7
|
+
import { Popover } from "./popover";
|
|
8
|
+
import { PopoverMenuItem, PopoverMenuSection } from "./popover-menu";
|
|
6
9
|
const TabsContext = React.createContext(null);
|
|
7
10
|
export function useTabsContext() {
|
|
8
11
|
return React.useContext(TabsContext);
|
|
@@ -23,16 +26,71 @@ const TabsRoot = React.forwardRef(({ defaultValue = "", value: valueProp, onValu
|
|
|
23
26
|
TabsRoot.displayName = TabsPrimitive.Root.displayName;
|
|
24
27
|
const TabsList = React.forwardRef(({ className, ...props }, ref) => (_jsx(TabsPrimitive.List, { ref: ref, className: cn("inline-flex h-fit items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground", className), ...props })));
|
|
25
28
|
TabsList.displayName = TabsPrimitive.List.displayName;
|
|
26
|
-
const TabsTrigger = React.forwardRef(({ className, value, ...props }, ref) => (_jsx(TabsPrimitive.Trigger, { ref: ref, value: value, className: cn("inline-flex cursor-pointer items-center justify-center whitespace-nowrap rounded-md px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm", className), ...props })));
|
|
29
|
+
const TabsTrigger = React.forwardRef(({ className, value, ...props }, ref) => (_jsx(TabsPrimitive.Trigger, { ref: ref, value: value, className: cn("inline-flex cursor-pointer items-center justify-center whitespace-nowrap rounded-md px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=inactive]:hover:bg-background/50 data-[state=inactive]:hover:text-foreground data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm", className), ...props })));
|
|
27
30
|
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
|
|
28
31
|
const TabsContent = React.forwardRef(({ className, value, ...props }, ref) => (_jsx(TabsPrimitive.Content, { ref: ref, value: value, className: cn("focus-visible:outline-none", className), ...props })));
|
|
29
32
|
TabsContent.displayName = TabsPrimitive.Content.displayName;
|
|
33
|
+
function TabTriggerWithDropdown({ item, triggerClassName, }) {
|
|
34
|
+
var _a, _b, _c, _d, _e;
|
|
35
|
+
const [open, setOpen] = React.useState(false);
|
|
36
|
+
const sections = (_a = item.sections) !== null && _a !== void 0 ? _a : [];
|
|
37
|
+
const [internalActiveSection, setInternalActiveSection] = React.useState((_c = (_b = sections[0]) === null || _b === void 0 ? void 0 : _b.id) !== null && _c !== void 0 ? _c : null);
|
|
38
|
+
const tabsContext = useTabsContext();
|
|
39
|
+
const scrollOffset = (_d = item.scrollOffset) !== null && _d !== void 0 ? _d : 200;
|
|
40
|
+
const activeSection = (_e = item.activeSection) !== null && _e !== void 0 ? _e : internalActiveSection;
|
|
41
|
+
const handleSectionClick = (sectionId) => {
|
|
42
|
+
setOpen(false);
|
|
43
|
+
setInternalActiveSection(sectionId);
|
|
44
|
+
if (item.onSectionSelect) {
|
|
45
|
+
item.onSectionSelect(sectionId);
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
const doScroll = () => {
|
|
49
|
+
const el = document.getElementById(sectionId);
|
|
50
|
+
if (!el)
|
|
51
|
+
return;
|
|
52
|
+
const top = el.getBoundingClientRect().top + window.scrollY - scrollOffset;
|
|
53
|
+
window.scrollTo({ top, behavior: "smooth" });
|
|
54
|
+
};
|
|
55
|
+
if (tabsContext && tabsContext.value !== item.value) {
|
|
56
|
+
tabsContext.setValue(item.value);
|
|
57
|
+
setTimeout(doScroll, 50);
|
|
58
|
+
}
|
|
59
|
+
else {
|
|
60
|
+
doScroll();
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
// Clear the internal "active section" when the user switches away from
|
|
64
|
+
// this tab, so we don't leave a stale highlight in the dropdown menu.
|
|
65
|
+
const isTabSelected = tabsContext
|
|
66
|
+
? tabsContext.value === item.value
|
|
67
|
+
: false;
|
|
68
|
+
React.useEffect(() => {
|
|
69
|
+
var _a, _b;
|
|
70
|
+
if (!isTabSelected && item.activeSection === undefined) {
|
|
71
|
+
setInternalActiveSection((_b = (_a = sections[0]) === null || _a === void 0 ? void 0 : _a.id) !== null && _b !== void 0 ? _b : null);
|
|
72
|
+
}
|
|
73
|
+
}, [isTabSelected, item.activeSection, sections]);
|
|
74
|
+
return (_jsx(Popover, { open: open, onOpenChange: setOpen, surface: "menu", align: "start", contentClassName: "min-w-[12rem]", trigger: _jsxs(TabsTrigger, { value: item.value, disabled: item.disabled, "data-state": isTabSelected ? "active" : "inactive", className: cn("flex items-center gap-1", triggerClassName), children: [item.label, _jsx(ChevronDownIcon, { size: 12, className: cn("transition-transform duration-200", open && "rotate-180") })] }), children: _jsx(PopoverMenuSection, { children: sections.map((section) => (_jsx(PopoverMenuItem, { active: isTabSelected && activeSection === section.id, className: "py-2 pl-2 pr-8", onClick: () => handleSectionClick(section.id), children: section.label }, section.id))) }) }));
|
|
75
|
+
}
|
|
76
|
+
function TabWithDropdown({ value, label, sections, disabled, scrollOffset, onSectionSelect, activeSection, className, }) {
|
|
77
|
+
return (_jsx(TabTriggerWithDropdown, { item: {
|
|
78
|
+
value,
|
|
79
|
+
label,
|
|
80
|
+
content: null,
|
|
81
|
+
sections,
|
|
82
|
+
disabled,
|
|
83
|
+
scrollOffset,
|
|
84
|
+
onSectionSelect,
|
|
85
|
+
activeSection,
|
|
86
|
+
}, triggerClassName: className }));
|
|
87
|
+
}
|
|
30
88
|
function Tabs({ items, defaultValue, value, onValueChange, className, listClassName, triggerClassName, panelClassName, ...props }) {
|
|
31
89
|
var _a, _b;
|
|
32
90
|
const fallbackValue = (_b = defaultValue !== null && defaultValue !== void 0 ? defaultValue : (_a = items[0]) === null || _a === void 0 ? void 0 : _a.value) !== null && _b !== void 0 ? _b : "";
|
|
33
91
|
if (!items.length) {
|
|
34
92
|
return null;
|
|
35
93
|
}
|
|
36
|
-
return (_jsxs(TabsRoot, { defaultValue: fallbackValue, value: value, onValueChange: onValueChange, className: cn("space-y-4", className), ...props, children: [_jsx(TabsList, { className: listClassName, children: items.map((item) => (_jsx(TabsTrigger, { value: item.value, disabled: item.disabled, className: triggerClassName, children: item.label }, item.value))) }), items.map((item) => (_jsx(TabsContent, { value: item.value, className: cn("
|
|
94
|
+
return (_jsxs(TabsRoot, { defaultValue: fallbackValue, value: value, onValueChange: onValueChange, className: cn("space-y-4", className), ...props, children: [_jsx(TabsList, { className: listClassName, children: items.map((item) => item.sections && item.sections.length > 0 ? (_jsx(TabTriggerWithDropdown, { item: item, triggerClassName: triggerClassName }, item.value)) : (_jsx(TabsTrigger, { value: item.value, disabled: item.disabled, className: triggerClassName, children: item.label }, item.value))) }), items.map((item) => (_jsx(TabsContent, { value: item.value, className: cn("pt-4", panelClassName), children: item.content }, item.value)))] }));
|
|
37
95
|
}
|
|
38
|
-
export { Tabs, TabsRoot, TabsList, TabsTrigger, TabsContent };
|
|
96
|
+
export { Tabs, TabsRoot, TabsList, TabsTrigger, TabsContent, TabWithDropdown };
|
package/dist/tailwind-preset.js
CHANGED
|
@@ -64,6 +64,15 @@ export const neaTailwindPreset = {
|
|
|
64
64
|
semibold: "var(--font-weight-semibold)",
|
|
65
65
|
bold: "var(--font-weight-bold)",
|
|
66
66
|
},
|
|
67
|
+
height: {
|
|
68
|
+
control: "var(--size-control)",
|
|
69
|
+
},
|
|
70
|
+
width: {
|
|
71
|
+
control: "var(--size-control)",
|
|
72
|
+
},
|
|
73
|
+
minHeight: {
|
|
74
|
+
control: "var(--size-control)",
|
|
75
|
+
},
|
|
67
76
|
spacing: {
|
|
68
77
|
0: "var(--space-0)",
|
|
69
78
|
1: "var(--space-1)",
|
package/dist/textarea.js
CHANGED
|
@@ -2,7 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
2
2
|
import * as React from "react";
|
|
3
3
|
import { Field, FieldDescription, FieldError, FieldLabel } from "./field";
|
|
4
4
|
import { cn } from "./lib/utils";
|
|
5
|
-
const TextareaControl = React.forwardRef(({ className, ...props }, ref) => (_jsx("textarea", { ref: ref, "data-slot": "textarea", className: cn("flex min-h-20 w-full rounded-md border border-input bg-transparent px-3 py-2 text-
|
|
5
|
+
const TextareaControl = React.forwardRef(({ className, ...props }, ref) => (_jsx("textarea", { ref: ref, "data-slot": "textarea", className: cn("flex min-h-20 w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm transition-[color,box-shadow] outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-[invalid=true]:border-destructive aria-[invalid=true]:ring-destructive/20", className), ...props })));
|
|
6
6
|
TextareaControl.displayName = "TextareaControl";
|
|
7
7
|
const Textarea = React.forwardRef(({ label, description, error, invalid = false, hideLabel = false, className, textareaClassName, labelClassName, descriptionClassName, errorClassName, id, disabled, "aria-invalid": ariaInvalid, "aria-describedby": ariaDescribedBy, ...props }, ref) => {
|
|
8
8
|
const generatedId = React.useId();
|
package/dist/toaster.js
CHANGED
|
@@ -1,17 +1,26 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
3
|
import { Toaster as SonnerToaster, toast } from "sonner";
|
|
4
|
-
|
|
5
|
-
|
|
4
|
+
import { buttonVariants } from "./button";
|
|
5
|
+
import { XIcon } from "./animated-icons/x";
|
|
6
|
+
import { cn } from "./lib/utils";
|
|
7
|
+
function Toaster({ position = "top-right", visibleToasts = 3, closeButton = true, duration = 4000, }) {
|
|
8
|
+
return (_jsx(SonnerToaster, { position: position, visibleToasts: visibleToasts, closeButton: closeButton, duration: duration, icons: {
|
|
9
|
+
close: _jsx(XIcon, { size: 14 }),
|
|
10
|
+
}, toastOptions: {
|
|
6
11
|
classNames: {
|
|
7
|
-
toast: "group border border-border bg-background text-foreground shadow-md rounded-lg p-4 flex items-start gap-3",
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
12
|
+
toast: "group relative border border-border bg-background text-foreground shadow-md rounded-lg p-4 pr-10 flex items-start gap-3",
|
|
13
|
+
content: "min-w-0 flex-1 gap-1",
|
|
14
|
+
title: "text-sm font-medium leading-5",
|
|
15
|
+
description: "text-sm text-muted-foreground leading-5",
|
|
16
|
+
icon: "mt-0.5 text-muted-foreground",
|
|
17
|
+
actionButton: cn(buttonVariants({ variant: "link" }), "!h-auto !min-h-0 !self-start !p-0 !text-sm !font-medium !text-primary !no-underline hover:!text-primary/80 hover:!no-underline"),
|
|
18
|
+
cancelButton: cn(buttonVariants({ variant: "link" }), "!h-auto !min-h-0 !self-start !bg-transparent !p-0 !text-sm !font-medium !text-muted-foreground !no-underline hover:!bg-transparent hover:!text-foreground hover:!no-underline"),
|
|
19
|
+
closeButton: "!absolute !left-auto !right-2 !top-2 !h-6 !w-6 !translate-x-0 !translate-y-0 !rounded-md !border-0 !bg-transparent !text-muted-foreground hover:!bg-accent hover:!text-foreground",
|
|
20
|
+
success: "border-[hsl(var(--status-success-foreground))]/20 [&_[data-icon]]:text-[hsl(var(--status-success-foreground))]",
|
|
21
|
+
error: "border-destructive/20 [&_[data-icon]]:text-[hsl(var(--status-destructive-foreground))]",
|
|
22
|
+
warning: "border-[hsl(var(--status-warning-foreground))]/20 [&_[data-icon]]:text-[hsl(var(--status-warning-foreground))]",
|
|
23
|
+
info: "border-[hsl(var(--status-info-foreground))]/20 [&_[data-icon]]:text-[hsl(var(--status-info-foreground))]",
|
|
15
24
|
},
|
|
16
25
|
} }));
|
|
17
26
|
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
3
|
+
const DEFAULT_SHAKE_DURATION_MS = 500;
|
|
4
|
+
export function useErrorShake(options = {}) {
|
|
5
|
+
const { durationMs = DEFAULT_SHAKE_DURATION_MS } = options;
|
|
6
|
+
const [shouldShake, setShouldShake] = useState(false);
|
|
7
|
+
const timeoutRef = useRef(null);
|
|
8
|
+
const frameRef = useRef(null);
|
|
9
|
+
const clearPendingAnimation = useCallback(() => {
|
|
10
|
+
if (timeoutRef.current !== null) {
|
|
11
|
+
window.clearTimeout(timeoutRef.current);
|
|
12
|
+
timeoutRef.current = null;
|
|
13
|
+
}
|
|
14
|
+
if (frameRef.current !== null) {
|
|
15
|
+
window.cancelAnimationFrame(frameRef.current);
|
|
16
|
+
frameRef.current = null;
|
|
17
|
+
}
|
|
18
|
+
}, []);
|
|
19
|
+
const triggerShake = useCallback(() => {
|
|
20
|
+
clearPendingAnimation();
|
|
21
|
+
setShouldShake(false);
|
|
22
|
+
frameRef.current = window.requestAnimationFrame(() => {
|
|
23
|
+
frameRef.current = null;
|
|
24
|
+
setShouldShake(true);
|
|
25
|
+
timeoutRef.current = window.setTimeout(() => {
|
|
26
|
+
setShouldShake(false);
|
|
27
|
+
timeoutRef.current = null;
|
|
28
|
+
}, durationMs);
|
|
29
|
+
});
|
|
30
|
+
}, [clearPendingAnimation, durationMs]);
|
|
31
|
+
useEffect(() => clearPendingAnimation, [clearPendingAnimation]);
|
|
32
|
+
return {
|
|
33
|
+
shouldShake,
|
|
34
|
+
triggerShake,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export type FileUploadStatus = "pending" | "uploading" | "complete" | "error";
|
|
2
|
+
export interface FileUploadResult {
|
|
3
|
+
name: string;
|
|
4
|
+
size: string;
|
|
5
|
+
sizeBytes: number;
|
|
6
|
+
type: string;
|
|
7
|
+
key: string;
|
|
8
|
+
url: string;
|
|
9
|
+
}
|
|
10
|
+
export interface FileUploadProgress {
|
|
11
|
+
name: string;
|
|
12
|
+
progress: number;
|
|
13
|
+
status: FileUploadStatus;
|
|
14
|
+
error?: string;
|
|
15
|
+
result?: FileUploadResult;
|
|
16
|
+
}
|
|
17
|
+
export interface UseFileUploadOptions {
|
|
18
|
+
/** Upload endpoint URL. Defaults to `/api/upload`. */
|
|
19
|
+
endpoint?: string;
|
|
20
|
+
/** Folder path sent alongside each file as form field `folder`. */
|
|
21
|
+
folder?: string;
|
|
22
|
+
/** Additional form fields appended to every request. */
|
|
23
|
+
extraFields?: Record<string, string>;
|
|
24
|
+
onComplete?: (results: FileUploadProgress[]) => void;
|
|
25
|
+
onError?: (error: Error) => void;
|
|
26
|
+
}
|
|
27
|
+
export interface UseFileUploadReturn {
|
|
28
|
+
uploadProgress: Map<string, FileUploadProgress>;
|
|
29
|
+
isUploading: boolean;
|
|
30
|
+
uploadFiles: (files: File[], folder?: string) => Promise<FileUploadProgress[]>;
|
|
31
|
+
reset: () => void;
|
|
32
|
+
cancel: () => void;
|
|
33
|
+
}
|
|
34
|
+
export declare function useFileUpload(options?: UseFileUploadOptions): UseFileUploadReturn;
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useCallback, useRef, useState } from "react";
|
|
3
|
+
export function useFileUpload(options = {}) {
|
|
4
|
+
const [uploadProgress, setUploadProgress] = useState(new Map());
|
|
5
|
+
const [isUploading, setIsUploading] = useState(false);
|
|
6
|
+
const xhrRefs = useRef([]);
|
|
7
|
+
const updateProgress = useCallback((fileName, updates) => {
|
|
8
|
+
setUploadProgress((prev) => {
|
|
9
|
+
var _a;
|
|
10
|
+
const newMap = new Map(prev);
|
|
11
|
+
const current = (_a = newMap.get(fileName)) !== null && _a !== void 0 ? _a : {
|
|
12
|
+
name: fileName,
|
|
13
|
+
progress: 0,
|
|
14
|
+
status: "pending",
|
|
15
|
+
};
|
|
16
|
+
newMap.set(fileName, { ...current, ...updates });
|
|
17
|
+
return newMap;
|
|
18
|
+
});
|
|
19
|
+
}, []);
|
|
20
|
+
const uploadSingleFile = useCallback((file, folder) => {
|
|
21
|
+
var _a;
|
|
22
|
+
const endpoint = (_a = options.endpoint) !== null && _a !== void 0 ? _a : "/api/upload";
|
|
23
|
+
const extraFields = options.extraFields;
|
|
24
|
+
return new Promise((resolve) => {
|
|
25
|
+
const xhr = new XMLHttpRequest();
|
|
26
|
+
xhrRefs.current.push(xhr);
|
|
27
|
+
xhr.upload.onprogress = (event) => {
|
|
28
|
+
if (event.lengthComputable) {
|
|
29
|
+
const progress = Math.round((event.loaded / event.total) * 100);
|
|
30
|
+
updateProgress(file.name, { progress, status: "uploading" });
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
xhr.onload = () => {
|
|
34
|
+
var _a;
|
|
35
|
+
if (xhr.status >= 200 && xhr.status < 300) {
|
|
36
|
+
try {
|
|
37
|
+
const response = JSON.parse(xhr.responseText);
|
|
38
|
+
const uploadedFile = (_a = response.files) === null || _a === void 0 ? void 0 : _a[0];
|
|
39
|
+
const result = {
|
|
40
|
+
name: file.name,
|
|
41
|
+
progress: 100,
|
|
42
|
+
status: "complete",
|
|
43
|
+
result: uploadedFile,
|
|
44
|
+
};
|
|
45
|
+
updateProgress(file.name, result);
|
|
46
|
+
resolve(result);
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
const result = {
|
|
50
|
+
name: file.name,
|
|
51
|
+
progress: 0,
|
|
52
|
+
status: "error",
|
|
53
|
+
error: "Failed to parse response",
|
|
54
|
+
};
|
|
55
|
+
updateProgress(file.name, result);
|
|
56
|
+
resolve(result);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
else {
|
|
60
|
+
const result = {
|
|
61
|
+
name: file.name,
|
|
62
|
+
progress: 0,
|
|
63
|
+
status: "error",
|
|
64
|
+
error: `Upload failed: ${xhr.statusText}`,
|
|
65
|
+
};
|
|
66
|
+
updateProgress(file.name, result);
|
|
67
|
+
resolve(result);
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
xhr.onerror = () => {
|
|
71
|
+
const result = {
|
|
72
|
+
name: file.name,
|
|
73
|
+
progress: 0,
|
|
74
|
+
status: "error",
|
|
75
|
+
error: "Network error",
|
|
76
|
+
};
|
|
77
|
+
updateProgress(file.name, result);
|
|
78
|
+
resolve(result);
|
|
79
|
+
};
|
|
80
|
+
xhr.onabort = () => {
|
|
81
|
+
const result = {
|
|
82
|
+
name: file.name,
|
|
83
|
+
progress: 0,
|
|
84
|
+
status: "error",
|
|
85
|
+
error: "Upload cancelled",
|
|
86
|
+
};
|
|
87
|
+
updateProgress(file.name, result);
|
|
88
|
+
resolve(result);
|
|
89
|
+
};
|
|
90
|
+
const formData = new FormData();
|
|
91
|
+
formData.append("files", file);
|
|
92
|
+
formData.append("folder", folder);
|
|
93
|
+
if (extraFields) {
|
|
94
|
+
for (const [key, value] of Object.entries(extraFields)) {
|
|
95
|
+
formData.append(key, value);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
updateProgress(file.name, { status: "pending", progress: 0 });
|
|
99
|
+
xhr.open("POST", endpoint);
|
|
100
|
+
xhr.send(formData);
|
|
101
|
+
});
|
|
102
|
+
}, [options.endpoint, options.extraFields, updateProgress]);
|
|
103
|
+
const uploadFiles = useCallback(async (files, folder) => {
|
|
104
|
+
var _a, _b, _c;
|
|
105
|
+
const uploadFolder = (_a = folder !== null && folder !== void 0 ? folder : options.folder) !== null && _a !== void 0 ? _a : "attachments";
|
|
106
|
+
setIsUploading(true);
|
|
107
|
+
xhrRefs.current = [];
|
|
108
|
+
const results = await Promise.all(files.map((file) => uploadSingleFile(file, uploadFolder)));
|
|
109
|
+
setIsUploading(false);
|
|
110
|
+
(_b = options.onComplete) === null || _b === void 0 ? void 0 : _b.call(options, results);
|
|
111
|
+
const errors = results.filter((r) => r.status === "error");
|
|
112
|
+
if (errors.length > 0) {
|
|
113
|
+
(_c = options.onError) === null || _c === void 0 ? void 0 : _c.call(options, new Error(`${errors.length} file(s) failed to upload`));
|
|
114
|
+
}
|
|
115
|
+
return results;
|
|
116
|
+
}, [options, uploadSingleFile]);
|
|
117
|
+
const reset = useCallback(() => {
|
|
118
|
+
setUploadProgress(new Map());
|
|
119
|
+
setIsUploading(false);
|
|
120
|
+
}, []);
|
|
121
|
+
const cancel = useCallback(() => {
|
|
122
|
+
xhrRefs.current.forEach((xhr) => xhr.abort());
|
|
123
|
+
xhrRefs.current = [];
|
|
124
|
+
setIsUploading(false);
|
|
125
|
+
}, []);
|
|
126
|
+
return {
|
|
127
|
+
uploadProgress,
|
|
128
|
+
isUploading,
|
|
129
|
+
uploadFiles,
|
|
130
|
+
reset,
|
|
131
|
+
cancel,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { type RefObject } from "react";
|
|
2
|
+
export interface UseStickySentinelOptions {
|
|
3
|
+
root?: Element | Document | null;
|
|
4
|
+
rootMargin?: string;
|
|
5
|
+
threshold?: number;
|
|
6
|
+
debounceMs?: number;
|
|
7
|
+
initialSticky?: boolean;
|
|
8
|
+
disabled?: boolean;
|
|
9
|
+
resetOnUnmount?: boolean;
|
|
10
|
+
onStickyChange?: (isSticky: boolean) => void;
|
|
11
|
+
}
|
|
12
|
+
export interface UseStickySentinelReturn<TElement extends Element> {
|
|
13
|
+
isSticky: boolean;
|
|
14
|
+
sentinelRef: RefObject<TElement>;
|
|
15
|
+
}
|
|
16
|
+
export declare function useStickySentinel<TElement extends Element = HTMLDivElement>({ root, rootMargin, threshold, debounceMs, initialSticky, disabled, resetOnUnmount, onStickyChange, }?: UseStickySentinelOptions): UseStickySentinelReturn<TElement>;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useEffect, useRef, useState } from "react";
|
|
3
|
+
export function useStickySentinel({ root = null, rootMargin = "0px", threshold = 0, debounceMs = 0, initialSticky = false, disabled = false, resetOnUnmount = false, onStickyChange, } = {}) {
|
|
4
|
+
const [isSticky, setIsSticky] = useState(initialSticky);
|
|
5
|
+
const sentinelRef = useRef(null);
|
|
6
|
+
const timeoutRef = useRef(null);
|
|
7
|
+
const lastValueRef = useRef(null);
|
|
8
|
+
const onStickyChangeRef = useRef(onStickyChange);
|
|
9
|
+
useEffect(() => {
|
|
10
|
+
onStickyChangeRef.current = onStickyChange;
|
|
11
|
+
}, [onStickyChange]);
|
|
12
|
+
useEffect(() => {
|
|
13
|
+
if (disabled || typeof IntersectionObserver === "undefined")
|
|
14
|
+
return;
|
|
15
|
+
const sentinel = sentinelRef.current;
|
|
16
|
+
if (!sentinel)
|
|
17
|
+
return;
|
|
18
|
+
const clearPendingUpdate = () => {
|
|
19
|
+
if (timeoutRef.current !== null) {
|
|
20
|
+
window.clearTimeout(timeoutRef.current);
|
|
21
|
+
timeoutRef.current = null;
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
const commitStickyState = (nextSticky) => {
|
|
25
|
+
var _a;
|
|
26
|
+
lastValueRef.current = nextSticky;
|
|
27
|
+
setIsSticky(nextSticky);
|
|
28
|
+
(_a = onStickyChangeRef.current) === null || _a === void 0 ? void 0 : _a.call(onStickyChangeRef, nextSticky);
|
|
29
|
+
};
|
|
30
|
+
const scheduleStickyState = (nextSticky) => {
|
|
31
|
+
if (nextSticky === lastValueRef.current)
|
|
32
|
+
return;
|
|
33
|
+
clearPendingUpdate();
|
|
34
|
+
if (debounceMs > 0) {
|
|
35
|
+
timeoutRef.current = window.setTimeout(() => {
|
|
36
|
+
timeoutRef.current = null;
|
|
37
|
+
commitStickyState(nextSticky);
|
|
38
|
+
}, debounceMs);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
commitStickyState(nextSticky);
|
|
42
|
+
};
|
|
43
|
+
const observer = new IntersectionObserver(([entry]) => scheduleStickyState(!entry.isIntersecting), {
|
|
44
|
+
root,
|
|
45
|
+
rootMargin,
|
|
46
|
+
threshold,
|
|
47
|
+
});
|
|
48
|
+
observer.observe(sentinel);
|
|
49
|
+
return () => {
|
|
50
|
+
var _a;
|
|
51
|
+
clearPendingUpdate();
|
|
52
|
+
observer.disconnect();
|
|
53
|
+
if (resetOnUnmount) {
|
|
54
|
+
(_a = onStickyChangeRef.current) === null || _a === void 0 ? void 0 : _a.call(onStickyChangeRef, false);
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
}, [debounceMs, disabled, resetOnUnmount, root, rootMargin, threshold]);
|
|
58
|
+
return {
|
|
59
|
+
isSticky,
|
|
60
|
+
sentinelRef,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { TableSortDirection, TableSortingProps } from "./table";
|
|
2
|
+
export interface UseTableSortOptions<Field extends string = string> {
|
|
3
|
+
/** Initial sort field. */
|
|
4
|
+
defaultField?: Field | null;
|
|
5
|
+
/** Initial sort direction. */
|
|
6
|
+
defaultDirection?: TableSortDirection;
|
|
7
|
+
/**
|
|
8
|
+
* Toggle cycle when clicking the same column again.
|
|
9
|
+
* - "asc-desc" (default): asc → desc → asc
|
|
10
|
+
* - "asc-desc-none": asc → desc → unsorted → asc
|
|
11
|
+
*/
|
|
12
|
+
cycle?: "asc-desc" | "asc-desc-none";
|
|
13
|
+
/** Called whenever sort changes — use for server-side query params. */
|
|
14
|
+
onChange?: (field: Field | null, direction: TableSortDirection) => void;
|
|
15
|
+
}
|
|
16
|
+
export interface UseTableSortReturn<Field extends string = string> {
|
|
17
|
+
field: Field | null;
|
|
18
|
+
direction: TableSortDirection;
|
|
19
|
+
/** Pass directly to `<Table sorting={...} />`. */
|
|
20
|
+
sorting: TableSortingProps;
|
|
21
|
+
/** Imperatively set sort (e.g. restore from URL). */
|
|
22
|
+
setSort: (field: Field | null, direction?: TableSortDirection) => void;
|
|
23
|
+
/** Reset to the default field/direction. */
|
|
24
|
+
reset: () => void;
|
|
25
|
+
/** Client-side helper: returns a sorted copy of rows using the given accessor. */
|
|
26
|
+
sortRows: <Row>(rows: Row[], accessor: (row: Row, field: Field) => unknown) => Row[];
|
|
27
|
+
}
|
|
28
|
+
export declare function useTableSort<Field extends string = string>({ defaultField, defaultDirection, cycle, onChange, }?: UseTableSortOptions<Field>): UseTableSortReturn<Field>;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
export function useTableSort({ defaultField = null, defaultDirection = "asc", cycle = "asc-desc", onChange, } = {}) {
|
|
3
|
+
const [field, setField] = React.useState(defaultField);
|
|
4
|
+
const [direction, setDirection] = React.useState(defaultDirection);
|
|
5
|
+
const emit = React.useCallback((nextField, nextDirection) => {
|
|
6
|
+
setField(nextField);
|
|
7
|
+
setDirection(nextDirection);
|
|
8
|
+
onChange === null || onChange === void 0 ? void 0 : onChange(nextField, nextDirection);
|
|
9
|
+
}, [onChange]);
|
|
10
|
+
const onSort = React.useCallback((nextField) => {
|
|
11
|
+
const typed = nextField;
|
|
12
|
+
if (field !== typed) {
|
|
13
|
+
emit(typed, "asc");
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
if (direction === "asc") {
|
|
17
|
+
emit(typed, "desc");
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
if (cycle === "asc-desc-none") {
|
|
21
|
+
emit(null, "asc");
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
emit(typed, "asc");
|
|
25
|
+
}, [field, direction, cycle, emit]);
|
|
26
|
+
const setSort = React.useCallback((nextField, nextDirection = "asc") => {
|
|
27
|
+
emit(nextField, nextDirection);
|
|
28
|
+
}, [emit]);
|
|
29
|
+
const reset = React.useCallback(() => {
|
|
30
|
+
emit(defaultField, defaultDirection);
|
|
31
|
+
}, [emit, defaultField, defaultDirection]);
|
|
32
|
+
const sortRows = React.useCallback((rows, accessor) => {
|
|
33
|
+
if (!field)
|
|
34
|
+
return rows;
|
|
35
|
+
const multiplier = direction === "asc" ? 1 : -1;
|
|
36
|
+
return [...rows].sort((a, b) => {
|
|
37
|
+
const av = accessor(a, field);
|
|
38
|
+
const bv = accessor(b, field);
|
|
39
|
+
if (av == null && bv == null)
|
|
40
|
+
return 0;
|
|
41
|
+
if (av == null)
|
|
42
|
+
return 1;
|
|
43
|
+
if (bv == null)
|
|
44
|
+
return -1;
|
|
45
|
+
if (typeof av === "number" && typeof bv === "number") {
|
|
46
|
+
return (av - bv) * multiplier;
|
|
47
|
+
}
|
|
48
|
+
return String(av).localeCompare(String(bv)) * multiplier;
|
|
49
|
+
});
|
|
50
|
+
}, [field, direction]);
|
|
51
|
+
const sorting = React.useMemo(() => ({ field, direction, onSort }), [field, direction, onSort]);
|
|
52
|
+
return { field, direction, sorting, setSort, reset, sortRows };
|
|
53
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { type RefObject } from "react";
|
|
2
|
+
export interface UseViewportThresholdOptions {
|
|
3
|
+
collapseAt: number;
|
|
4
|
+
expandAt?: number;
|
|
5
|
+
initialPastThreshold?: boolean;
|
|
6
|
+
disabled?: boolean;
|
|
7
|
+
}
|
|
8
|
+
export interface UseViewportThresholdReturn<TElement extends Element> {
|
|
9
|
+
isPastThreshold: boolean;
|
|
10
|
+
sentinelRef: RefObject<TElement>;
|
|
11
|
+
}
|
|
12
|
+
export declare function useViewportThreshold<TElement extends Element = HTMLDivElement>({ collapseAt, expandAt, initialPastThreshold, disabled, }: UseViewportThresholdOptions): UseViewportThresholdReturn<TElement>;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useEffect, useRef, useState } from "react";
|
|
3
|
+
export function useViewportThreshold({ collapseAt, expandAt = collapseAt, initialPastThreshold = false, disabled = false, }) {
|
|
4
|
+
const [isPastThreshold, setIsPastThreshold] = useState(initialPastThreshold);
|
|
5
|
+
const sentinelRef = useRef(null);
|
|
6
|
+
useEffect(() => {
|
|
7
|
+
if (disabled)
|
|
8
|
+
return;
|
|
9
|
+
const sentinel = sentinelRef.current;
|
|
10
|
+
if (!sentinel)
|
|
11
|
+
return;
|
|
12
|
+
let frameId = null;
|
|
13
|
+
const updateThresholdState = () => {
|
|
14
|
+
frameId = null;
|
|
15
|
+
const sentinelTop = sentinel.getBoundingClientRect().top;
|
|
16
|
+
setIsPastThreshold((prev) => {
|
|
17
|
+
if (prev) {
|
|
18
|
+
return sentinelTop <= expandAt;
|
|
19
|
+
}
|
|
20
|
+
return sentinelTop <= collapseAt;
|
|
21
|
+
});
|
|
22
|
+
};
|
|
23
|
+
const requestUpdate = () => {
|
|
24
|
+
if (frameId !== null)
|
|
25
|
+
return;
|
|
26
|
+
frameId = window.requestAnimationFrame(updateThresholdState);
|
|
27
|
+
};
|
|
28
|
+
requestUpdate();
|
|
29
|
+
window.addEventListener("scroll", requestUpdate, { passive: true });
|
|
30
|
+
window.addEventListener("resize", requestUpdate);
|
|
31
|
+
return () => {
|
|
32
|
+
if (frameId !== null) {
|
|
33
|
+
window.cancelAnimationFrame(frameId);
|
|
34
|
+
}
|
|
35
|
+
window.removeEventListener("scroll", requestUpdate);
|
|
36
|
+
window.removeEventListener("resize", requestUpdate);
|
|
37
|
+
};
|
|
38
|
+
}, [collapseAt, disabled, expandAt]);
|
|
39
|
+
return {
|
|
40
|
+
isPastThreshold,
|
|
41
|
+
sentinelRef,
|
|
42
|
+
};
|
|
43
|
+
}
|