@arcote.tech/arc-ds 0.4.1
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/package.json +42 -0
- package/src/ds/avatar/avatar.tsx +86 -0
- package/src/ds/badge/badge.tsx +61 -0
- package/src/ds/bento-card/bento-card.tsx +70 -0
- package/src/ds/bento-grid/bento-grid.tsx +52 -0
- package/src/ds/box/box.tsx +96 -0
- package/src/ds/button/button.tsx +191 -0
- package/src/ds/card-modal/card-modal.tsx +161 -0
- package/src/ds/display-mode.tsx +32 -0
- package/src/ds/ds-provider.tsx +85 -0
- package/src/ds/form/field.tsx +124 -0
- package/src/ds/form/fields/checkbox-select-field.tsx +326 -0
- package/src/ds/form/fields/index.ts +14 -0
- package/src/ds/form/fields/search-select-field.tsx +41 -0
- package/src/ds/form/fields/select-field.tsx +42 -0
- package/src/ds/form/fields/suggestion-list-field.tsx +43 -0
- package/src/ds/form/fields/tag-field.tsx +39 -0
- package/src/ds/form/fields/text-field.tsx +35 -0
- package/src/ds/form/fields/textarea-field.tsx +81 -0
- package/src/ds/form/form-part.tsx +79 -0
- package/src/ds/form/form.tsx +299 -0
- package/src/ds/form/index.tsx +5 -0
- package/src/ds/form/message.tsx +14 -0
- package/src/ds/input/input.tsx +115 -0
- package/src/ds/merge-variants.ts +26 -0
- package/src/ds/search-select/search-select.tsx +291 -0
- package/src/ds/separator/separator.tsx +26 -0
- package/src/ds/suggestion-list/suggestion-list.tsx +406 -0
- package/src/ds/tag-list/tag-list.tsx +87 -0
- package/src/ds/tooltip/tooltip.tsx +33 -0
- package/src/ds/transitions.ts +12 -0
- package/src/ds/types.ts +131 -0
- package/src/index.ts +115 -0
- package/src/layout/drag-handle.tsx +117 -0
- package/src/layout/dynamic-slot.tsx +95 -0
- package/src/layout/expandable-panel.tsx +57 -0
- package/src/layout/layout.tsx +323 -0
- package/src/layout/overlay-provider.tsx +103 -0
- package/src/layout/overlay.tsx +33 -0
- package/src/layout/router.tsx +101 -0
- package/src/layout/scroll-nav.tsx +121 -0
- package/src/layout/slot-render-context.tsx +14 -0
- package/src/layout/sub-nav-shell.tsx +41 -0
- package/src/layout/toolbar-expand.tsx +70 -0
- package/src/layout/transitions.ts +12 -0
- package/src/layout/use-expandable.ts +59 -0
- package/src/lib/utils.ts +6 -0
- package/src/ui/tooltip.tsx +59 -0
- package/tsconfig.json +13 -0
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createContext,
|
|
3
|
+
useCallback,
|
|
4
|
+
useContext,
|
|
5
|
+
useEffect,
|
|
6
|
+
useMemo,
|
|
7
|
+
useRef,
|
|
8
|
+
useState,
|
|
9
|
+
type ReactNode,
|
|
10
|
+
} from "react";
|
|
11
|
+
import { Overlay } from "./overlay";
|
|
12
|
+
import { useArcRoute } from "./router";
|
|
13
|
+
|
|
14
|
+
export const Z = {
|
|
15
|
+
BASE: 30,
|
|
16
|
+
OVERLAY: 40,
|
|
17
|
+
ELEVATED: 50,
|
|
18
|
+
} as const;
|
|
19
|
+
|
|
20
|
+
interface OverlayContextValue {
|
|
21
|
+
requestOverlay: (id: string, onClose: () => void) => void;
|
|
22
|
+
releaseOverlay: (id: string) => void;
|
|
23
|
+
isOverlayOwner: (id: string) => boolean;
|
|
24
|
+
isOverlayVisible: boolean;
|
|
25
|
+
zIndex: (id: string) => number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const OverlayContext = createContext<OverlayContextValue | null>(null);
|
|
29
|
+
|
|
30
|
+
export function OverlayProvider({ children }: { children: ReactNode }) {
|
|
31
|
+
const [activeId, setActiveId] = useState<string | null>(null);
|
|
32
|
+
const [exitingId, setExitingId] = useState<string | null>(null);
|
|
33
|
+
const onCloseRef = useRef<(() => void) | null>(null);
|
|
34
|
+
|
|
35
|
+
const requestOverlay = useCallback((id: string, onClose: () => void) => {
|
|
36
|
+
setActiveId((prev) => {
|
|
37
|
+
if (prev === id) return prev;
|
|
38
|
+
return id;
|
|
39
|
+
});
|
|
40
|
+
onCloseRef.current = onClose;
|
|
41
|
+
}, []);
|
|
42
|
+
|
|
43
|
+
const releaseOverlay = useCallback((id: string) => {
|
|
44
|
+
setActiveId((prev) => {
|
|
45
|
+
if (prev === id) {
|
|
46
|
+
setExitingId(id);
|
|
47
|
+
onCloseRef.current = null;
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
return prev;
|
|
51
|
+
});
|
|
52
|
+
}, []);
|
|
53
|
+
|
|
54
|
+
const handleOverlayClose = useCallback(() => {
|
|
55
|
+
const fn = onCloseRef.current;
|
|
56
|
+
setActiveId((prev) => {
|
|
57
|
+
if (prev) setExitingId(prev);
|
|
58
|
+
return null;
|
|
59
|
+
});
|
|
60
|
+
onCloseRef.current = null;
|
|
61
|
+
fn?.();
|
|
62
|
+
}, []);
|
|
63
|
+
|
|
64
|
+
const handleExitComplete = useCallback(() => {
|
|
65
|
+
setExitingId(null);
|
|
66
|
+
}, []);
|
|
67
|
+
|
|
68
|
+
// Close overlay on route change
|
|
69
|
+
const path = useArcRoute();
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
handleOverlayClose();
|
|
72
|
+
}, [path]);
|
|
73
|
+
|
|
74
|
+
const value = useMemo<OverlayContextValue>(() => {
|
|
75
|
+
const isOverlayOwner = (id: string) =>
|
|
76
|
+
activeId === id || (activeId === null && exitingId === id);
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
requestOverlay,
|
|
80
|
+
releaseOverlay,
|
|
81
|
+
isOverlayOwner,
|
|
82
|
+
isOverlayVisible: activeId !== null,
|
|
83
|
+
zIndex: (id: string) => (isOverlayOwner(id) ? Z.ELEVATED : Z.BASE),
|
|
84
|
+
};
|
|
85
|
+
}, [activeId, exitingId, requestOverlay, releaseOverlay]);
|
|
86
|
+
|
|
87
|
+
return (
|
|
88
|
+
<OverlayContext.Provider value={value}>
|
|
89
|
+
{children}
|
|
90
|
+
<Overlay
|
|
91
|
+
visible={activeId !== null}
|
|
92
|
+
onClose={handleOverlayClose}
|
|
93
|
+
onExitComplete={handleExitComplete}
|
|
94
|
+
/>
|
|
95
|
+
</OverlayContext.Provider>
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function useOverlay(): OverlayContextValue {
|
|
100
|
+
const ctx = useContext(OverlayContext);
|
|
101
|
+
if (!ctx) throw new Error("useOverlay must be used within OverlayProvider");
|
|
102
|
+
return ctx;
|
|
103
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { AnimatePresence, motion } from "framer-motion";
|
|
2
|
+
import { arcTransitions } from "./transitions";
|
|
3
|
+
|
|
4
|
+
interface OverlayProps {
|
|
5
|
+
visible: boolean;
|
|
6
|
+
onClose: () => void;
|
|
7
|
+
zIndex?: number;
|
|
8
|
+
onExitComplete?: () => void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function Overlay({
|
|
12
|
+
visible,
|
|
13
|
+
onClose,
|
|
14
|
+
zIndex = 40,
|
|
15
|
+
onExitComplete,
|
|
16
|
+
}: OverlayProps) {
|
|
17
|
+
return (
|
|
18
|
+
<AnimatePresence onExitComplete={onExitComplete}>
|
|
19
|
+
{visible && (
|
|
20
|
+
<motion.div
|
|
21
|
+
key="overlay"
|
|
22
|
+
initial={{ opacity: 0 }}
|
|
23
|
+
animate={{ opacity: 1 }}
|
|
24
|
+
exit={{ opacity: 0 }}
|
|
25
|
+
transition={arcTransitions.fade}
|
|
26
|
+
className="fixed inset-0 bg-black/20"
|
|
27
|
+
style={{ zIndex }}
|
|
28
|
+
onClick={onClose}
|
|
29
|
+
/>
|
|
30
|
+
)}
|
|
31
|
+
</AnimatePresence>
|
|
32
|
+
);
|
|
33
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createContext,
|
|
3
|
+
useCallback,
|
|
4
|
+
useContext,
|
|
5
|
+
useEffect,
|
|
6
|
+
useMemo,
|
|
7
|
+
useState,
|
|
8
|
+
type ReactNode,
|
|
9
|
+
} from "react";
|
|
10
|
+
|
|
11
|
+
interface ArcRouterState {
|
|
12
|
+
path: string;
|
|
13
|
+
params: Record<string, string>;
|
|
14
|
+
navigate: (path: string) => void;
|
|
15
|
+
back: () => void;
|
|
16
|
+
setParams: (params: Record<string, string>) => void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const ArcRouterContext = createContext<ArcRouterState | null>(null);
|
|
20
|
+
|
|
21
|
+
export function ArcRouterProvider({ children }: { children: ReactNode }) {
|
|
22
|
+
const [path, setPath] = useState(() => window.location.pathname);
|
|
23
|
+
const [params, setParams] = useState<Record<string, string>>({});
|
|
24
|
+
|
|
25
|
+
const navigate = useCallback((newPath: string) => {
|
|
26
|
+
window.history.pushState(null, "", newPath);
|
|
27
|
+
setPath(new URL(newPath, window.location.origin).pathname);
|
|
28
|
+
}, []);
|
|
29
|
+
|
|
30
|
+
const back = useCallback(() => {
|
|
31
|
+
window.history.back();
|
|
32
|
+
}, []);
|
|
33
|
+
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
const onPopState = () => setPath(window.location.pathname);
|
|
36
|
+
window.addEventListener("popstate", onPopState);
|
|
37
|
+
return () => window.removeEventListener("popstate", onPopState);
|
|
38
|
+
}, []);
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<ArcRouterContext.Provider value={{ path, params, navigate, back, setParams }}>
|
|
42
|
+
{children}
|
|
43
|
+
</ArcRouterContext.Provider>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function useArcRouter(): ArcRouterState {
|
|
48
|
+
const ctx = useContext(ArcRouterContext);
|
|
49
|
+
if (!ctx)
|
|
50
|
+
throw new Error("useArcRouter must be used within ArcRouterProvider");
|
|
51
|
+
return ctx;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function useArcNavigate(): (path: string) => void {
|
|
55
|
+
return useArcRouter().navigate;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function useArcRoute(): string {
|
|
59
|
+
return useArcRouter().path;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function useArcParams<T extends Record<string, string> = Record<string, string>>(): T {
|
|
63
|
+
return useArcRouter().params as T;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// --- Route pattern matching utilities ---
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Match a URL path against a route pattern with `:param` segments.
|
|
70
|
+
* Returns extracted params or null if no match.
|
|
71
|
+
*/
|
|
72
|
+
export function matchRoutePath(
|
|
73
|
+
pattern: string,
|
|
74
|
+
path: string,
|
|
75
|
+
): Record<string, string> | null {
|
|
76
|
+
const patternParts = pattern.split("/").filter(Boolean);
|
|
77
|
+
const pathParts = path.split("/").filter(Boolean);
|
|
78
|
+
|
|
79
|
+
// Pattern with params can match paths of same length
|
|
80
|
+
if (patternParts.length !== pathParts.length) return null;
|
|
81
|
+
|
|
82
|
+
const params: Record<string, string> = {};
|
|
83
|
+
|
|
84
|
+
for (let i = 0; i < patternParts.length; i++) {
|
|
85
|
+
const pp = patternParts[i];
|
|
86
|
+
if (pp.startsWith(":")) {
|
|
87
|
+
params[pp.slice(1)] = pathParts[i];
|
|
88
|
+
} else if (pp !== pathParts[i]) {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return params;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Check if a route pattern contains parameter segments (`:param`).
|
|
98
|
+
*/
|
|
99
|
+
export function hasRouteParams(pattern: string): boolean {
|
|
100
|
+
return pattern.includes("/:");
|
|
101
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { Button } from "../ds/button/button";
|
|
2
|
+
import { cn } from "../lib/utils";
|
|
3
|
+
import { motion } from "framer-motion";
|
|
4
|
+
import { ChevronDown } from "lucide-react";
|
|
5
|
+
import {
|
|
6
|
+
Children,
|
|
7
|
+
cloneElement,
|
|
8
|
+
isValidElement,
|
|
9
|
+
useCallback,
|
|
10
|
+
useEffect,
|
|
11
|
+
useRef,
|
|
12
|
+
useState,
|
|
13
|
+
type ReactElement,
|
|
14
|
+
type ReactNode,
|
|
15
|
+
} from "react";
|
|
16
|
+
import { DragHandle } from "./drag-handle";
|
|
17
|
+
import { ExpandablePanel } from "./expandable-panel";
|
|
18
|
+
import { arcTransitions } from "./transitions";
|
|
19
|
+
import { useExpandable } from "./use-expandable";
|
|
20
|
+
|
|
21
|
+
interface ScrollNavProps {
|
|
22
|
+
children: ReactNode;
|
|
23
|
+
className?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function ScrollNav({ children, className }: ScrollNavProps) {
|
|
27
|
+
const scrollRef = useRef<HTMLDivElement>(null);
|
|
28
|
+
const [isOverflowing, setIsOverflowing] = useState(false);
|
|
29
|
+
const exp = useExpandable();
|
|
30
|
+
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
const el = scrollRef.current;
|
|
33
|
+
if (!el) return;
|
|
34
|
+
|
|
35
|
+
const check = () => setIsOverflowing(el.scrollWidth > el.clientWidth);
|
|
36
|
+
check();
|
|
37
|
+
|
|
38
|
+
const ro = new ResizeObserver(check);
|
|
39
|
+
ro.observe(el);
|
|
40
|
+
return () => ro.disconnect();
|
|
41
|
+
}, [children]);
|
|
42
|
+
|
|
43
|
+
const scrollToCenter = useCallback((element: HTMLElement) => {
|
|
44
|
+
const container = scrollRef.current;
|
|
45
|
+
if (!container) return;
|
|
46
|
+
|
|
47
|
+
const containerRect = container.getBoundingClientRect();
|
|
48
|
+
const elementRect = element.getBoundingClientRect();
|
|
49
|
+
|
|
50
|
+
const elementCenter = elementRect.left + elementRect.width / 2;
|
|
51
|
+
const containerCenter = containerRect.left + containerRect.width / 2;
|
|
52
|
+
const offset = elementCenter - containerCenter;
|
|
53
|
+
|
|
54
|
+
container.scrollBy({ left: offset, behavior: "smooth" });
|
|
55
|
+
}, []);
|
|
56
|
+
|
|
57
|
+
const wrappedChildren = Children.map(children, (child) => {
|
|
58
|
+
if (!isValidElement(child)) return child;
|
|
59
|
+
|
|
60
|
+
return cloneElement(child as ReactElement<Record<string, unknown>>, {
|
|
61
|
+
onClick: (e: React.MouseEvent<HTMLElement>) => {
|
|
62
|
+
const originalOnClick = (child as ReactElement<Record<string, unknown>>)
|
|
63
|
+
.props.onClick as
|
|
64
|
+
| ((e: React.MouseEvent<HTMLElement>) => void)
|
|
65
|
+
| undefined;
|
|
66
|
+
originalOnClick?.(e);
|
|
67
|
+
|
|
68
|
+
const target = e.currentTarget;
|
|
69
|
+
requestAnimationFrame(() => scrollToCenter(target));
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
return (
|
|
75
|
+
<div className={cn("relative", className)}>
|
|
76
|
+
<motion.div
|
|
77
|
+
animate={
|
|
78
|
+
exp.isExpanded
|
|
79
|
+
? { height: 0, opacity: 0 }
|
|
80
|
+
: { height: "auto", opacity: 1 }
|
|
81
|
+
}
|
|
82
|
+
initial={false}
|
|
83
|
+
transition={arcTransitions.snappy}
|
|
84
|
+
style={{ overflow: "hidden" }}
|
|
85
|
+
>
|
|
86
|
+
<div className="flex items-center gap-1">
|
|
87
|
+
<div
|
|
88
|
+
ref={scrollRef}
|
|
89
|
+
className="flex flex-1 items-center gap-1 overflow-x-auto scrollbar-none"
|
|
90
|
+
>
|
|
91
|
+
{wrappedChildren}
|
|
92
|
+
</div>
|
|
93
|
+
{isOverflowing && (
|
|
94
|
+
<Button
|
|
95
|
+
icon={ChevronDown}
|
|
96
|
+
displayMode="minimal"
|
|
97
|
+
size="icon-xs"
|
|
98
|
+
onClick={exp.open}
|
|
99
|
+
className="shrink-0"
|
|
100
|
+
/>
|
|
101
|
+
)}
|
|
102
|
+
</div>
|
|
103
|
+
</motion.div>
|
|
104
|
+
|
|
105
|
+
<ExpandablePanel
|
|
106
|
+
open={exp.isExpanded}
|
|
107
|
+
height={exp.expandedHeight}
|
|
108
|
+
dragHeight={exp.dragHeight}
|
|
109
|
+
>
|
|
110
|
+
<div ref={exp.expandedRef} className="flex flex-col gap-1">
|
|
111
|
+
{children}
|
|
112
|
+
<DragHandle
|
|
113
|
+
contentHeight={exp.expandedHeight}
|
|
114
|
+
onHeightChange={exp.onDragHeight}
|
|
115
|
+
onRelease={exp.onDragRelease}
|
|
116
|
+
/>
|
|
117
|
+
</div>
|
|
118
|
+
</ExpandablePanel>
|
|
119
|
+
</div>
|
|
120
|
+
);
|
|
121
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { createContext, useContext } from "react";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
|
+
|
|
4
|
+
export type SlotRenderFn = (slotId: string, options?: { displayMode?: string; className?: string }) => ReactNode;
|
|
5
|
+
|
|
6
|
+
const SlotRenderContext = createContext<SlotRenderFn | null>(null);
|
|
7
|
+
|
|
8
|
+
export const SlotRenderProvider = SlotRenderContext.Provider;
|
|
9
|
+
|
|
10
|
+
export function useRenderSlot(): SlotRenderFn {
|
|
11
|
+
const render = useContext(SlotRenderContext);
|
|
12
|
+
if (!render) return () => null;
|
|
13
|
+
return render;
|
|
14
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { Button } from "../ds/button/button";
|
|
2
|
+
import { useMemo } from "react";
|
|
3
|
+
import type { ComponentType, ReactNode } from "react";
|
|
4
|
+
import { useSlotContent } from "./dynamic-slot";
|
|
5
|
+
|
|
6
|
+
export type SubNavTab = {
|
|
7
|
+
path: string;
|
|
8
|
+
icon?: ComponentType<{ className?: string }>;
|
|
9
|
+
label?: ReactNode;
|
|
10
|
+
tooltip?: ReactNode;
|
|
11
|
+
isActive?: boolean;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export type SubNavShellProps = {
|
|
15
|
+
children: ReactNode;
|
|
16
|
+
tabs: SubNavTab[];
|
|
17
|
+
onNavigate: (path: string) => void;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export function SubNavShell({ children, tabs, onNavigate }: SubNavShellProps) {
|
|
21
|
+
const tabButtons = useMemo(
|
|
22
|
+
() => (
|
|
23
|
+
<>
|
|
24
|
+
{tabs.map((tab) => (
|
|
25
|
+
<Button
|
|
26
|
+
key={tab.path}
|
|
27
|
+
icon={tab.icon}
|
|
28
|
+
label={tab.label}
|
|
29
|
+
isActive={tab.isActive}
|
|
30
|
+
onClick={() => onNavigate(tab.path)}
|
|
31
|
+
/>
|
|
32
|
+
))}
|
|
33
|
+
</>
|
|
34
|
+
),
|
|
35
|
+
[tabs, onNavigate],
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
useSlotContent("sub-nav", "sub-nav-tabs", tabButtons);
|
|
39
|
+
|
|
40
|
+
return <div className="px-2">{children}</div>;
|
|
41
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createContext,
|
|
3
|
+
useCallback,
|
|
4
|
+
useContext,
|
|
5
|
+
useEffect,
|
|
6
|
+
useState,
|
|
7
|
+
type ReactNode,
|
|
8
|
+
} from "react";
|
|
9
|
+
import { useArcRoute } from "./router";
|
|
10
|
+
|
|
11
|
+
interface ToolbarExpandState {
|
|
12
|
+
expandedId: string | null;
|
|
13
|
+
expandedContent: ReactNode | null;
|
|
14
|
+
expand: (id: string, content: ReactNode) => void;
|
|
15
|
+
collapse: () => void;
|
|
16
|
+
toggle: (id: string, content: ReactNode) => void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const ToolbarExpandContext = createContext<ToolbarExpandState | null>(null);
|
|
20
|
+
|
|
21
|
+
export function ToolbarExpandProvider({ children }: { children: ReactNode }) {
|
|
22
|
+
const [expandedId, setExpandedId] = useState<string | null>(null);
|
|
23
|
+
const [expandedContent, setExpandedContent] = useState<ReactNode | null>(
|
|
24
|
+
null,
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
const expand = useCallback((id: string, content: ReactNode) => {
|
|
28
|
+
setExpandedId(id);
|
|
29
|
+
setExpandedContent(content);
|
|
30
|
+
}, []);
|
|
31
|
+
|
|
32
|
+
const collapse = useCallback(() => {
|
|
33
|
+
setExpandedId(null);
|
|
34
|
+
setExpandedContent(null);
|
|
35
|
+
}, []);
|
|
36
|
+
|
|
37
|
+
const toggle = useCallback(
|
|
38
|
+
(id: string, content: ReactNode) => {
|
|
39
|
+
if (expandedId === id) {
|
|
40
|
+
collapse();
|
|
41
|
+
} else {
|
|
42
|
+
expand(id, content);
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
[expandedId, collapse, expand],
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
// Close expanded toolbar on route change
|
|
49
|
+
const path = useArcRoute();
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
collapse();
|
|
52
|
+
}, [path]);
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<ToolbarExpandContext.Provider
|
|
56
|
+
value={{ expandedId, expandedContent, expand, collapse, toggle }}
|
|
57
|
+
>
|
|
58
|
+
{children}
|
|
59
|
+
</ToolbarExpandContext.Provider>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function useToolbarExpand(): ToolbarExpandState {
|
|
64
|
+
const ctx = useContext(ToolbarExpandContext);
|
|
65
|
+
if (!ctx)
|
|
66
|
+
throw new Error(
|
|
67
|
+
"useToolbarExpand must be used within ToolbarExpandProvider",
|
|
68
|
+
);
|
|
69
|
+
return ctx;
|
|
70
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Arc layout transition presets.
|
|
3
|
+
* Jedno miejsce do tuningu animacji — wszystkie layout komponenty importują stąd.
|
|
4
|
+
*/
|
|
5
|
+
export const arcTransitions = {
|
|
6
|
+
/** Szybki spring — expand/collapse paneli, nav overflow. */
|
|
7
|
+
snappy: { type: "spring" as const, stiffness: 500, damping: 35 },
|
|
8
|
+
/** Łagodniejszy spring — layout morphing, ArcBox. */
|
|
9
|
+
smooth: { type: "spring" as const, stiffness: 400, damping: 30 },
|
|
10
|
+
/** Prosty fade — overlay. */
|
|
11
|
+
fade: { duration: 0.15 },
|
|
12
|
+
} as const;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { useCallback, useLayoutEffect, useRef, useState } from "react";
|
|
2
|
+
|
|
3
|
+
export interface UseExpandableReturn {
|
|
4
|
+
isExpanded: boolean;
|
|
5
|
+
open: () => void;
|
|
6
|
+
close: () => void;
|
|
7
|
+
toggle: () => void;
|
|
8
|
+
expandedRef: React.RefObject<HTMLDivElement | null>;
|
|
9
|
+
expandedHeight: number;
|
|
10
|
+
dragHeight: number | null;
|
|
11
|
+
onDragHeight: (height: number) => void;
|
|
12
|
+
onDragRelease: (shouldClose: boolean) => void;
|
|
13
|
+
isDragging: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function useExpandable(externalOpen?: boolean): UseExpandableReturn {
|
|
17
|
+
const [internalExpanded, setInternalExpanded] = useState(false);
|
|
18
|
+
const expandedRef = useRef<HTMLDivElement>(null);
|
|
19
|
+
const [expandedHeight, setExpandedHeight] = useState(0);
|
|
20
|
+
const [dragHeight, setDragHeight] = useState<number | null>(null);
|
|
21
|
+
|
|
22
|
+
const isExpanded = externalOpen ?? internalExpanded;
|
|
23
|
+
|
|
24
|
+
const open = useCallback(() => setInternalExpanded(true), []);
|
|
25
|
+
const close = useCallback(() => setInternalExpanded(false), []);
|
|
26
|
+
const toggle = useCallback(() => setInternalExpanded((v) => !v), []);
|
|
27
|
+
|
|
28
|
+
useLayoutEffect(() => {
|
|
29
|
+
const el = expandedRef.current;
|
|
30
|
+
if (!el) return;
|
|
31
|
+
|
|
32
|
+
const measure = () => setExpandedHeight(el.scrollHeight);
|
|
33
|
+
measure();
|
|
34
|
+
|
|
35
|
+
const ro = new ResizeObserver(measure);
|
|
36
|
+
ro.observe(el);
|
|
37
|
+
return () => ro.disconnect();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const onDragHeight = useCallback((h: number) => setDragHeight(h), []);
|
|
41
|
+
|
|
42
|
+
const onDragRelease = useCallback((shouldClose: boolean) => {
|
|
43
|
+
setDragHeight(null);
|
|
44
|
+
if (shouldClose) setInternalExpanded(false);
|
|
45
|
+
}, []);
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
isExpanded,
|
|
49
|
+
open,
|
|
50
|
+
close,
|
|
51
|
+
toggle,
|
|
52
|
+
expandedRef,
|
|
53
|
+
expandedHeight,
|
|
54
|
+
dragHeight,
|
|
55
|
+
onDragHeight,
|
|
56
|
+
onDragRelease,
|
|
57
|
+
isDragging: dragHeight !== null,
|
|
58
|
+
};
|
|
59
|
+
}
|
package/src/lib/utils.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { Tooltip as TooltipPrimitive } from "radix-ui";
|
|
2
|
+
import * as React from "react";
|
|
3
|
+
import { cn } from "../lib/utils";
|
|
4
|
+
|
|
5
|
+
function TooltipProvider({
|
|
6
|
+
delayDuration = 0,
|
|
7
|
+
...props
|
|
8
|
+
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
|
|
9
|
+
return (
|
|
10
|
+
<TooltipPrimitive.Provider
|
|
11
|
+
data-slot="tooltip-provider"
|
|
12
|
+
delayDuration={delayDuration}
|
|
13
|
+
{...props}
|
|
14
|
+
/>
|
|
15
|
+
);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function TooltipRoot({
|
|
19
|
+
...props
|
|
20
|
+
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
|
21
|
+
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function TooltipTrigger({
|
|
25
|
+
...props
|
|
26
|
+
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
|
27
|
+
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function TooltipContent({
|
|
31
|
+
className,
|
|
32
|
+
sideOffset = 0,
|
|
33
|
+
children,
|
|
34
|
+
...props
|
|
35
|
+
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
|
36
|
+
return (
|
|
37
|
+
<TooltipPrimitive.Portal>
|
|
38
|
+
<TooltipPrimitive.Content
|
|
39
|
+
data-slot="tooltip-content"
|
|
40
|
+
sideOffset={sideOffset}
|
|
41
|
+
className={cn(
|
|
42
|
+
"z-50 w-fit origin-(--radix-tooltip-content-transform-origin) animate-in rounded-md bg-foreground px-3 py-1.5 text-xs text-balance text-background fade-in-0 zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
|
|
43
|
+
className,
|
|
44
|
+
)}
|
|
45
|
+
{...props}
|
|
46
|
+
>
|
|
47
|
+
{children}
|
|
48
|
+
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground" />
|
|
49
|
+
</TooltipPrimitive.Content>
|
|
50
|
+
</TooltipPrimitive.Portal>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export {
|
|
55
|
+
TooltipRoot as Tooltip,
|
|
56
|
+
TooltipContent,
|
|
57
|
+
TooltipProvider,
|
|
58
|
+
TooltipTrigger,
|
|
59
|
+
};
|
package/tsconfig.json
ADDED