@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
package/src/index.ts
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// Design System — public API
|
|
2
|
+
|
|
3
|
+
// Provider
|
|
4
|
+
export {
|
|
5
|
+
DesignSystemProvider,
|
|
6
|
+
useDsComponent,
|
|
7
|
+
useDsVariantOverrides,
|
|
8
|
+
} from "./ds/ds-provider";
|
|
9
|
+
|
|
10
|
+
// Display Mode
|
|
11
|
+
export { DisplayModeProvider, useDisplayMode } from "./ds/display-mode";
|
|
12
|
+
|
|
13
|
+
// Transitions
|
|
14
|
+
export { dsTransitions } from "./ds/transitions";
|
|
15
|
+
|
|
16
|
+
// Form
|
|
17
|
+
export {
|
|
18
|
+
Form,
|
|
19
|
+
FormContext,
|
|
20
|
+
FormField,
|
|
21
|
+
FormMessage,
|
|
22
|
+
FormPart,
|
|
23
|
+
useForm,
|
|
24
|
+
useFormField,
|
|
25
|
+
useFormPart,
|
|
26
|
+
TextField,
|
|
27
|
+
TextareaField,
|
|
28
|
+
TagField,
|
|
29
|
+
SelectField,
|
|
30
|
+
SuggestionListField,
|
|
31
|
+
SearchSelectField,
|
|
32
|
+
CheckboxSelectField,
|
|
33
|
+
} from "./ds/form";
|
|
34
|
+
export type {
|
|
35
|
+
FormProps,
|
|
36
|
+
FormRef,
|
|
37
|
+
FormFields,
|
|
38
|
+
FormFieldData,
|
|
39
|
+
TextFieldProps,
|
|
40
|
+
TextareaFieldProps,
|
|
41
|
+
TagFieldProps,
|
|
42
|
+
SelectFieldProps,
|
|
43
|
+
SuggestionListFieldProps,
|
|
44
|
+
SearchSelectFieldProps,
|
|
45
|
+
CheckboxSelectFieldProps,
|
|
46
|
+
CheckboxSelectOption,
|
|
47
|
+
} from "./ds/form";
|
|
48
|
+
|
|
49
|
+
// Components
|
|
50
|
+
export {
|
|
51
|
+
Avatar,
|
|
52
|
+
avatarFallbackVariants,
|
|
53
|
+
avatarVariants,
|
|
54
|
+
} from "./ds/avatar/avatar";
|
|
55
|
+
export { BentoCard } from "./ds/bento-card/bento-card";
|
|
56
|
+
export type { BentoCardProps } from "./ds/bento-card/bento-card";
|
|
57
|
+
export { BentoGrid, useBentoSpan } from "./ds/bento-grid/bento-grid";
|
|
58
|
+
export type { BentoGridProps } from "./ds/bento-grid/bento-grid";
|
|
59
|
+
export { CardModal, CardFormModal, ModalActions } from "./ds/card-modal/card-modal";
|
|
60
|
+
export type { CardModalProps, CardFormModalProps, ModalActionsProps } from "./ds/card-modal/card-modal";
|
|
61
|
+
export { TagList } from "./ds/tag-list/tag-list";
|
|
62
|
+
export type { TagListProps } from "./ds/tag-list/tag-list";
|
|
63
|
+
export { SuggestionList } from "./ds/suggestion-list/suggestion-list";
|
|
64
|
+
export type { SuggestionListProps, InitialCloudConfig } from "./ds/suggestion-list/suggestion-list";
|
|
65
|
+
export { SearchSelect } from "./ds/search-select/search-select";
|
|
66
|
+
export type { SearchSelectProps, SearchSelectOption } from "./ds/search-select/search-select";
|
|
67
|
+
export { Badge, badgeVariants } from "./ds/badge/badge";
|
|
68
|
+
export { Box, boxVariants } from "./ds/box/box";
|
|
69
|
+
export type { BoxProps } from "./ds/box/box";
|
|
70
|
+
export {
|
|
71
|
+
Button,
|
|
72
|
+
buttonIconVariants,
|
|
73
|
+
buttonLabelVariants,
|
|
74
|
+
buttonVariants,
|
|
75
|
+
} from "./ds/button/button";
|
|
76
|
+
export { Input, inputIconVariants, inputVariants } from "./ds/input/input";
|
|
77
|
+
export { Separator } from "./ds/separator/separator";
|
|
78
|
+
export { Tooltip } from "./ds/tooltip/tooltip";
|
|
79
|
+
export { TooltipProvider } from "./ui/tooltip";
|
|
80
|
+
|
|
81
|
+
// Utilities
|
|
82
|
+
export { mergeVariants } from "./ds/merge-variants";
|
|
83
|
+
export { cn } from "./lib/utils";
|
|
84
|
+
|
|
85
|
+
// Layout
|
|
86
|
+
export { Layout } from "./layout/layout";
|
|
87
|
+
export { DynamicSlotProvider, useSlotContent, useDynamicSlotContent } from "./layout/dynamic-slot";
|
|
88
|
+
export { OverlayProvider, useOverlay, Z } from "./layout/overlay-provider";
|
|
89
|
+
export { ScrollNav } from "./layout/scroll-nav";
|
|
90
|
+
export { SlotRenderProvider, useRenderSlot } from "./layout/slot-render-context";
|
|
91
|
+
export type { SlotRenderFn } from "./layout/slot-render-context";
|
|
92
|
+
export { SubNavShell } from "./layout/sub-nav-shell";
|
|
93
|
+
export type { SubNavShellProps, SubNavTab } from "./layout/sub-nav-shell";
|
|
94
|
+
export { ArcRouterProvider, useArcRouter, useArcNavigate, useArcRoute, useArcParams, matchRoutePath, hasRouteParams } from "./layout/router";
|
|
95
|
+
export { ToolbarExpandProvider, useToolbarExpand } from "./layout/toolbar-expand";
|
|
96
|
+
export { arcTransitions } from "./layout/transitions";
|
|
97
|
+
export { DragHandle } from "./layout/drag-handle";
|
|
98
|
+
export { ExpandablePanel } from "./layout/expandable-panel";
|
|
99
|
+
export { useExpandable } from "./layout/use-expandable";
|
|
100
|
+
export type { UseExpandableReturn } from "./layout/use-expandable";
|
|
101
|
+
|
|
102
|
+
// Types
|
|
103
|
+
export type {
|
|
104
|
+
AvatarProps,
|
|
105
|
+
BadgeProps,
|
|
106
|
+
ButtonProps,
|
|
107
|
+
CVAVariantOverride,
|
|
108
|
+
DSComponentMap,
|
|
109
|
+
DSComponentOverrides,
|
|
110
|
+
DSVariantOverrides,
|
|
111
|
+
DisplayMode,
|
|
112
|
+
InputProps,
|
|
113
|
+
SeparatorProps,
|
|
114
|
+
TooltipProps,
|
|
115
|
+
} from "./ds/types";
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef } from "react";
|
|
2
|
+
|
|
3
|
+
interface DragHandleProps {
|
|
4
|
+
contentHeight: number;
|
|
5
|
+
onHeightChange: (height: number) => void;
|
|
6
|
+
onRelease: (shouldClose: boolean) => void;
|
|
7
|
+
closeThreshold?: number;
|
|
8
|
+
closeDirection?: "up" | "down";
|
|
9
|
+
className?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function DragHandle({
|
|
13
|
+
contentHeight,
|
|
14
|
+
onHeightChange,
|
|
15
|
+
onRelease,
|
|
16
|
+
closeThreshold = 0.4,
|
|
17
|
+
closeDirection = "up",
|
|
18
|
+
className,
|
|
19
|
+
}: DragHandleProps) {
|
|
20
|
+
const elRef = useRef<HTMLButtonElement>(null);
|
|
21
|
+
const startYRef = useRef(0);
|
|
22
|
+
const startHeightRef = useRef(0);
|
|
23
|
+
const isDraggingRef = useRef(false);
|
|
24
|
+
|
|
25
|
+
const sign = closeDirection === "up" ? 1 : -1;
|
|
26
|
+
|
|
27
|
+
const handleStart = useCallback(
|
|
28
|
+
(clientY: number) => {
|
|
29
|
+
startYRef.current = clientY;
|
|
30
|
+
startHeightRef.current = contentHeight;
|
|
31
|
+
isDraggingRef.current = true;
|
|
32
|
+
},
|
|
33
|
+
[contentHeight],
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
const handleMove = useCallback(
|
|
37
|
+
(clientY: number) => {
|
|
38
|
+
if (!isDraggingRef.current) return;
|
|
39
|
+
const delta = clientY - startYRef.current;
|
|
40
|
+
const newHeight = Math.max(
|
|
41
|
+
0,
|
|
42
|
+
Math.min(startHeightRef.current, startHeightRef.current + delta * sign),
|
|
43
|
+
);
|
|
44
|
+
onHeightChange(newHeight);
|
|
45
|
+
},
|
|
46
|
+
[onHeightChange, sign],
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
const handleEnd = useCallback(
|
|
50
|
+
(clientY: number) => {
|
|
51
|
+
if (!isDraggingRef.current) return;
|
|
52
|
+
isDraggingRef.current = false;
|
|
53
|
+
const delta = clientY - startYRef.current;
|
|
54
|
+
const ratio = (-delta * sign) / startHeightRef.current;
|
|
55
|
+
onRelease(ratio > closeThreshold);
|
|
56
|
+
},
|
|
57
|
+
[onRelease, closeThreshold, sign],
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
useEffect(() => {
|
|
61
|
+
const el = elRef.current;
|
|
62
|
+
if (!el) return;
|
|
63
|
+
|
|
64
|
+
const onTouchStart = (e: TouchEvent) => handleStart(e.touches[0].clientY);
|
|
65
|
+
const onTouchMove = (e: TouchEvent) => {
|
|
66
|
+
e.preventDefault();
|
|
67
|
+
handleMove(e.touches[0].clientY);
|
|
68
|
+
};
|
|
69
|
+
const onTouchEnd = (e: TouchEvent) =>
|
|
70
|
+
handleEnd(e.changedTouches[0].clientY);
|
|
71
|
+
|
|
72
|
+
el.addEventListener("touchstart", onTouchStart, { passive: true });
|
|
73
|
+
el.addEventListener("touchmove", onTouchMove, { passive: false });
|
|
74
|
+
el.addEventListener("touchend", onTouchEnd, { passive: true });
|
|
75
|
+
|
|
76
|
+
return () => {
|
|
77
|
+
el.removeEventListener("touchstart", onTouchStart);
|
|
78
|
+
el.removeEventListener("touchmove", onTouchMove);
|
|
79
|
+
el.removeEventListener("touchend", onTouchEnd);
|
|
80
|
+
};
|
|
81
|
+
}, [handleStart, handleMove, handleEnd]);
|
|
82
|
+
|
|
83
|
+
const onMouseDown = useCallback(
|
|
84
|
+
(e: React.MouseEvent) => {
|
|
85
|
+
e.preventDefault();
|
|
86
|
+
handleStart(e.clientY);
|
|
87
|
+
|
|
88
|
+
const onMouseMove = (ev: MouseEvent) => handleMove(ev.clientY);
|
|
89
|
+
const onMouseUp = (ev: MouseEvent) => {
|
|
90
|
+
handleEnd(ev.clientY);
|
|
91
|
+
window.removeEventListener("mousemove", onMouseMove);
|
|
92
|
+
window.removeEventListener("mouseup", onMouseUp);
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
window.addEventListener("mousemove", onMouseMove);
|
|
96
|
+
window.addEventListener("mouseup", onMouseUp);
|
|
97
|
+
},
|
|
98
|
+
[handleStart, handleMove, handleEnd],
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
return (
|
|
102
|
+
<button
|
|
103
|
+
ref={elRef}
|
|
104
|
+
onMouseDown={onMouseDown}
|
|
105
|
+
onClick={(e) => {
|
|
106
|
+
if (!isDraggingRef.current) onRelease(true);
|
|
107
|
+
e.preventDefault();
|
|
108
|
+
}}
|
|
109
|
+
className={
|
|
110
|
+
className ?? "flex w-full items-center justify-center py-3 touch-none"
|
|
111
|
+
}
|
|
112
|
+
aria-label="Close"
|
|
113
|
+
>
|
|
114
|
+
<div className="h-1 w-8 rounded-full bg-muted-foreground/40 transition-colors hover:bg-muted-foreground/60" />
|
|
115
|
+
</button>
|
|
116
|
+
);
|
|
117
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
|
+
|
|
4
|
+
type DynamicSlotEntry = {
|
|
5
|
+
id: string;
|
|
6
|
+
content: ReactNode;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
type DynamicSlotContextValue = {
|
|
10
|
+
entries: Map<string, DynamicSlotEntry[]>;
|
|
11
|
+
register: (slotId: string, id: string, content: ReactNode) => void;
|
|
12
|
+
unregister: (slotId: string, id: string) => void;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const DynamicSlotContext = createContext<DynamicSlotContextValue | null>(null);
|
|
16
|
+
|
|
17
|
+
export function DynamicSlotProvider({ children }: { children: ReactNode }) {
|
|
18
|
+
const [entries, setEntries] = useState<Map<string, DynamicSlotEntry[]>>(
|
|
19
|
+
() => new Map(),
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
const register = useCallback(
|
|
23
|
+
(slotId: string, id: string, content: ReactNode) => {
|
|
24
|
+
setEntries((prev) => {
|
|
25
|
+
const next = new Map(prev);
|
|
26
|
+
const slot = next.get(slotId) ?? [];
|
|
27
|
+
const existing = slot.findIndex((e) => e.id === id);
|
|
28
|
+
if (existing >= 0) {
|
|
29
|
+
slot[existing] = { id, content };
|
|
30
|
+
} else {
|
|
31
|
+
slot.push({ id, content });
|
|
32
|
+
}
|
|
33
|
+
next.set(slotId, [...slot]);
|
|
34
|
+
return next;
|
|
35
|
+
});
|
|
36
|
+
},
|
|
37
|
+
[],
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
const unregister = useCallback((slotId: string, id: string) => {
|
|
41
|
+
setEntries((prev) => {
|
|
42
|
+
const next = new Map(prev);
|
|
43
|
+
const slot = next.get(slotId);
|
|
44
|
+
if (slot) {
|
|
45
|
+
const filtered = slot.filter((e) => e.id !== id);
|
|
46
|
+
if (filtered.length > 0) {
|
|
47
|
+
next.set(slotId, filtered);
|
|
48
|
+
} else {
|
|
49
|
+
next.delete(slotId);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return next;
|
|
53
|
+
});
|
|
54
|
+
}, []);
|
|
55
|
+
|
|
56
|
+
const value = useMemo(
|
|
57
|
+
() => ({ entries, register, unregister }),
|
|
58
|
+
[entries, register, unregister],
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
<DynamicSlotContext.Provider value={value}>
|
|
63
|
+
{children}
|
|
64
|
+
</DynamicSlotContext.Provider>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Register dynamic content into a named slot.
|
|
70
|
+
* Content appears while the component is mounted, removed on unmount.
|
|
71
|
+
*/
|
|
72
|
+
export function useSlotContent(slotId: string, id: string, content: ReactNode) {
|
|
73
|
+
const ctx = useContext(DynamicSlotContext);
|
|
74
|
+
if (!ctx) throw new Error("useSlotContent requires DynamicSlotProvider");
|
|
75
|
+
|
|
76
|
+
const registerRef = useRef(ctx.register);
|
|
77
|
+
const unregisterRef = useRef(ctx.unregister);
|
|
78
|
+
registerRef.current = ctx.register;
|
|
79
|
+
unregisterRef.current = ctx.unregister;
|
|
80
|
+
|
|
81
|
+
useEffect(() => {
|
|
82
|
+
registerRef.current(slotId, id, content);
|
|
83
|
+
return () => unregisterRef.current(slotId, id);
|
|
84
|
+
}, [slotId, id, content]);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Read dynamic slot content for a given slot ID.
|
|
89
|
+
*/
|
|
90
|
+
export function useDynamicSlotContent(slotId: string): ReactNode[] {
|
|
91
|
+
const ctx = useContext(DynamicSlotContext);
|
|
92
|
+
if (!ctx) return [];
|
|
93
|
+
const entries = ctx.entries.get(slotId);
|
|
94
|
+
return entries ? entries.map((e) => e.content) : [];
|
|
95
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { motion } from "framer-motion";
|
|
2
|
+
import { useLayoutEffect, useRef, useState, type ReactNode } from "react";
|
|
3
|
+
import { arcTransitions } from "./transitions";
|
|
4
|
+
|
|
5
|
+
interface ExpandablePanelProps {
|
|
6
|
+
open: boolean;
|
|
7
|
+
height?: number;
|
|
8
|
+
dragHeight?: number | null;
|
|
9
|
+
children: ReactNode;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function ExpandablePanel({
|
|
13
|
+
open,
|
|
14
|
+
height: externalHeight,
|
|
15
|
+
dragHeight = null,
|
|
16
|
+
children,
|
|
17
|
+
}: ExpandablePanelProps) {
|
|
18
|
+
const innerRef = useRef<HTMLDivElement>(null);
|
|
19
|
+
const [measuredHeight, setMeasuredHeight] = useState(0);
|
|
20
|
+
|
|
21
|
+
useLayoutEffect(() => {
|
|
22
|
+
const el = innerRef.current;
|
|
23
|
+
if (!el) return;
|
|
24
|
+
|
|
25
|
+
const measure = () => setMeasuredHeight(el.offsetHeight);
|
|
26
|
+
measure();
|
|
27
|
+
|
|
28
|
+
const ro = new ResizeObserver(measure);
|
|
29
|
+
ro.observe(el);
|
|
30
|
+
return () => ro.disconnect();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const targetHeight = externalHeight ?? measuredHeight;
|
|
34
|
+
const isDragging = dragHeight !== null;
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<motion.div
|
|
38
|
+
data-expanded={open || undefined}
|
|
39
|
+
animate={
|
|
40
|
+
isDragging
|
|
41
|
+
? undefined
|
|
42
|
+
: { height: open ? targetHeight : 0, opacity: open ? 1 : 0 }
|
|
43
|
+
}
|
|
44
|
+
style={
|
|
45
|
+
isDragging
|
|
46
|
+
? { height: dragHeight, opacity: 1, overflow: "hidden" }
|
|
47
|
+
: { overflow: "hidden" }
|
|
48
|
+
}
|
|
49
|
+
initial={false}
|
|
50
|
+
transition={arcTransitions.snappy}
|
|
51
|
+
>
|
|
52
|
+
<div ref={innerRef} className="flex flex-col">
|
|
53
|
+
{children}
|
|
54
|
+
</div>
|
|
55
|
+
</motion.div>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
import { createContext, useContext, useEffect, useRef, useState } from "react";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
|
+
import { Box } from "../ds/box/box";
|
|
4
|
+
import { Button } from "../ds/button/button";
|
|
5
|
+
import { DragHandle } from "./drag-handle";
|
|
6
|
+
import { useDynamicSlotContent } from "./dynamic-slot";
|
|
7
|
+
import { ExpandablePanel } from "./expandable-panel";
|
|
8
|
+
import { useOverlay } from "./overlay-provider";
|
|
9
|
+
import { useRenderSlot } from "./slot-render-context";
|
|
10
|
+
import { ToolbarExpandProvider, useToolbarExpand } from "./toolbar-expand";
|
|
11
|
+
import { useExpandable } from "./use-expandable";
|
|
12
|
+
import { ChevronDown } from "lucide-react";
|
|
13
|
+
|
|
14
|
+
function useMediaQuery(query: string): boolean {
|
|
15
|
+
const [matches, setMatches] = useState(
|
|
16
|
+
() => window.matchMedia(query).matches,
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
const mq = window.matchMedia(query);
|
|
21
|
+
const handler = (e: MediaQueryListEvent) => setMatches(e.matches);
|
|
22
|
+
mq.addEventListener("change", handler);
|
|
23
|
+
return () => mq.removeEventListener("change", handler);
|
|
24
|
+
}, [query]);
|
|
25
|
+
|
|
26
|
+
return matches;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function Layout({ children }: { children?: ReactNode }) {
|
|
30
|
+
const isDesktop = useMediaQuery("(min-width: 768px)");
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<ToolbarExpandProvider>
|
|
34
|
+
{isDesktop ? (
|
|
35
|
+
<DesktopLayout>{children}</DesktopLayout>
|
|
36
|
+
) : (
|
|
37
|
+
<MobileLayout>{children}</MobileLayout>
|
|
38
|
+
)}
|
|
39
|
+
</ToolbarExpandProvider>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
// Desktop
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
function SubNavSlot() {
|
|
48
|
+
const content = useDynamicSlotContent("sub-nav");
|
|
49
|
+
if (content.length === 0) return null;
|
|
50
|
+
return (
|
|
51
|
+
<div className="flex justify-center py-2">
|
|
52
|
+
<Box layoutId="sub-nav" className="flex items-center gap-1 px-3 py-2">
|
|
53
|
+
{content}
|
|
54
|
+
</Box>
|
|
55
|
+
</div>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function DesktopLayout({ children }: { children?: ReactNode }) {
|
|
60
|
+
const renderSlot = useRenderSlot();
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<>
|
|
64
|
+
<DesktopToolbar />
|
|
65
|
+
<div className="h-16" />
|
|
66
|
+
<SubNavSlot />
|
|
67
|
+
<div className="mx-auto flex w-full max-w-5xl flex-1 gap-4 p-4">
|
|
68
|
+
{renderSlot("sidebar-left", { className: "flex w-64 shrink-0 flex-col gap-4" })}
|
|
69
|
+
<div className="min-w-0 flex-1">
|
|
70
|
+
{children}
|
|
71
|
+
{renderSlot("main-content", { className: "flex flex-col gap-4" })}
|
|
72
|
+
</div>
|
|
73
|
+
{renderSlot("sidebar-right", { className: "flex w-64 shrink-0 flex-col gap-4" })}
|
|
74
|
+
</div>
|
|
75
|
+
</>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function DesktopToolbar() {
|
|
80
|
+
const renderSlot = useRenderSlot();
|
|
81
|
+
const { expandedId, collapse } = useToolbarExpand();
|
|
82
|
+
const { requestOverlay, releaseOverlay, zIndex } = useOverlay();
|
|
83
|
+
const prevIdRef = useRef<string | null>(null);
|
|
84
|
+
const collapseRef = useRef(collapse);
|
|
85
|
+
collapseRef.current = collapse;
|
|
86
|
+
|
|
87
|
+
useEffect(() => {
|
|
88
|
+
const prevId = prevIdRef.current;
|
|
89
|
+
prevIdRef.current = expandedId;
|
|
90
|
+
|
|
91
|
+
if (expandedId) {
|
|
92
|
+
requestOverlay(expandedId, () => collapseRef.current());
|
|
93
|
+
} else if (prevId) {
|
|
94
|
+
releaseOverlay(prevId);
|
|
95
|
+
}
|
|
96
|
+
}, [expandedId, requestOverlay, releaseOverlay]);
|
|
97
|
+
|
|
98
|
+
return (
|
|
99
|
+
<>
|
|
100
|
+
<div
|
|
101
|
+
className="fixed left-4 top-4"
|
|
102
|
+
style={{ zIndex: zIndex("workspace") }}
|
|
103
|
+
>
|
|
104
|
+
<Box layout={false} className="flex flex-col px-3 py-2">
|
|
105
|
+
{renderSlot("toolbar-left", { className: "flex items-center gap-2" })}
|
|
106
|
+
<ExpandedSection targetId="workspace" />
|
|
107
|
+
</Box>
|
|
108
|
+
</div>
|
|
109
|
+
|
|
110
|
+
<div
|
|
111
|
+
className="fixed left-1/2 top-4 max-w-[calc(100vw-26rem)] -translate-x-1/2"
|
|
112
|
+
style={{ zIndex: zIndex("center") }}
|
|
113
|
+
>
|
|
114
|
+
<Box layout={false} className="flex min-w-0 items-center px-3 py-2">
|
|
115
|
+
{renderSlot("toolbar-center", { className: "flex min-w-0 flex-1 items-center gap-1" })}
|
|
116
|
+
</Box>
|
|
117
|
+
</div>
|
|
118
|
+
|
|
119
|
+
<div
|
|
120
|
+
className="fixed right-4 top-4"
|
|
121
|
+
style={{ zIndex: zIndex("settings") }}
|
|
122
|
+
>
|
|
123
|
+
<Box layout={false} className="flex flex-col px-3 py-2">
|
|
124
|
+
{renderSlot("toolbar-right", { className: "flex items-center gap-2" })}
|
|
125
|
+
<ExpandedSection targetId="settings" />
|
|
126
|
+
</Box>
|
|
127
|
+
</div>
|
|
128
|
+
</>
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function ExpandedSection({ targetId }: { targetId: string }) {
|
|
133
|
+
const { expandedId, expandedContent } = useToolbarExpand();
|
|
134
|
+
const isActive = expandedId === targetId;
|
|
135
|
+
|
|
136
|
+
return (
|
|
137
|
+
<ExpandablePanel open={isActive}>
|
|
138
|
+
<div className="mt-2 border-t pt-2">{isActive && expandedContent}</div>
|
|
139
|
+
</ExpandablePanel>
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ---------------------------------------------------------------------------
|
|
144
|
+
// Mobile
|
|
145
|
+
// ---------------------------------------------------------------------------
|
|
146
|
+
|
|
147
|
+
function MobileSubNavSlot() {
|
|
148
|
+
const content = useDynamicSlotContent("sub-nav");
|
|
149
|
+
const { expand, expandedId, collapse } = useToolbarExpand();
|
|
150
|
+
const scrollRef = useRef<HTMLDivElement>(null);
|
|
151
|
+
const [isOverflowing, setIsOverflowing] = useState(false);
|
|
152
|
+
|
|
153
|
+
useEffect(() => {
|
|
154
|
+
const el = scrollRef.current;
|
|
155
|
+
if (!el) return;
|
|
156
|
+
const check = () => setIsOverflowing(el.scrollWidth > el.clientWidth);
|
|
157
|
+
check();
|
|
158
|
+
const ro = new ResizeObserver(check);
|
|
159
|
+
ro.observe(el);
|
|
160
|
+
return () => ro.disconnect();
|
|
161
|
+
}, [content]);
|
|
162
|
+
|
|
163
|
+
// Auto-scroll to active tab on content change (route change)
|
|
164
|
+
useEffect(() => {
|
|
165
|
+
const el = scrollRef.current;
|
|
166
|
+
if (!el) return;
|
|
167
|
+
const active = el.querySelector("[data-active='true']") as HTMLElement | null;
|
|
168
|
+
if (!active) return;
|
|
169
|
+
const containerRect = el.getBoundingClientRect();
|
|
170
|
+
const activeRect = active.getBoundingClientRect();
|
|
171
|
+
const offset = activeRect.left + activeRect.width / 2 - containerRect.left - containerRect.width / 2;
|
|
172
|
+
el.scrollBy({ left: offset, behavior: "smooth" });
|
|
173
|
+
}, [content]);
|
|
174
|
+
|
|
175
|
+
if (content.length === 0) return null;
|
|
176
|
+
if (expandedId) return null;
|
|
177
|
+
return (
|
|
178
|
+
<div className="mt-2 border-t pt-2">
|
|
179
|
+
<div className="flex items-center gap-1">
|
|
180
|
+
<div
|
|
181
|
+
ref={scrollRef}
|
|
182
|
+
className="flex flex-1 items-center gap-1 overflow-x-auto scrollbar-none"
|
|
183
|
+
>
|
|
184
|
+
{content}
|
|
185
|
+
</div>
|
|
186
|
+
{isOverflowing && (
|
|
187
|
+
<Button
|
|
188
|
+
icon={ChevronDown}
|
|
189
|
+
displayMode="minimal"
|
|
190
|
+
size="icon-xs"
|
|
191
|
+
onClick={() => expand("sub-nav",
|
|
192
|
+
<div className="flex flex-col gap-1" onClick={() => collapse()}>
|
|
193
|
+
{content}
|
|
194
|
+
</div>
|
|
195
|
+
)}
|
|
196
|
+
className="shrink-0"
|
|
197
|
+
/>
|
|
198
|
+
)}
|
|
199
|
+
</div>
|
|
200
|
+
</div>
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const MobileSpacingContext = createContext<{
|
|
205
|
+
setTopHeight: (h: number) => void;
|
|
206
|
+
setBottomHeight: (h: number) => void;
|
|
207
|
+
}>({ setTopHeight: () => {}, setBottomHeight: () => {} });
|
|
208
|
+
|
|
209
|
+
function MobileLayout({ children }: { children?: ReactNode }) {
|
|
210
|
+
const renderSlot = useRenderSlot();
|
|
211
|
+
const [topHeight, setTopHeight] = useState(64);
|
|
212
|
+
const [bottomHeight, setBottomHeight] = useState(64);
|
|
213
|
+
const gap = 8;
|
|
214
|
+
|
|
215
|
+
return (
|
|
216
|
+
<MobileSpacingContext.Provider value={{ setTopHeight, setBottomHeight }}>
|
|
217
|
+
<MobileToolbar />
|
|
218
|
+
<div style={{ height: topHeight + gap }} />
|
|
219
|
+
<div className="mx-auto w-full max-w-lg flex-1 p-2" style={{ paddingBottom: bottomHeight + gap }}>
|
|
220
|
+
{children}
|
|
221
|
+
{renderSlot("main-content", { className: "flex flex-col gap-2" })}
|
|
222
|
+
</div>
|
|
223
|
+
<MobileBottomBar />
|
|
224
|
+
</MobileSpacingContext.Provider>
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function MobileToolbar() {
|
|
229
|
+
const renderSlot = useRenderSlot();
|
|
230
|
+
const { expandedId, expandedContent, collapse } = useToolbarExpand();
|
|
231
|
+
const isExpanded = expandedId !== null;
|
|
232
|
+
const exp = useExpandable(isExpanded);
|
|
233
|
+
const { requestOverlay, releaseOverlay, zIndex } = useOverlay();
|
|
234
|
+
const { setTopHeight } = useContext(MobileSpacingContext);
|
|
235
|
+
const toolbarRef = useRef<HTMLDivElement>(null);
|
|
236
|
+
const collapseRef = useRef(collapse);
|
|
237
|
+
collapseRef.current = collapse;
|
|
238
|
+
|
|
239
|
+
useEffect(() => {
|
|
240
|
+
if (isExpanded) {
|
|
241
|
+
requestOverlay("mobile-toolbar", () => collapseRef.current());
|
|
242
|
+
} else {
|
|
243
|
+
releaseOverlay("mobile-toolbar");
|
|
244
|
+
}
|
|
245
|
+
}, [isExpanded, requestOverlay, releaseOverlay]);
|
|
246
|
+
|
|
247
|
+
useEffect(() => {
|
|
248
|
+
const el = toolbarRef.current;
|
|
249
|
+
if (!el) return;
|
|
250
|
+
const update = () => setTopHeight(el.offsetHeight);
|
|
251
|
+
update();
|
|
252
|
+
const ro = new ResizeObserver(update);
|
|
253
|
+
ro.observe(el);
|
|
254
|
+
return () => ro.disconnect();
|
|
255
|
+
}, [setTopHeight]);
|
|
256
|
+
|
|
257
|
+
return (
|
|
258
|
+
<div
|
|
259
|
+
ref={toolbarRef}
|
|
260
|
+
className="fixed inset-x-0 top-0 p-2"
|
|
261
|
+
style={{ zIndex: zIndex("mobile-toolbar") }}
|
|
262
|
+
>
|
|
263
|
+
<Box layoutId="mobile-toolbar" className="px-3 py-2">
|
|
264
|
+
<div className="flex items-center justify-between">
|
|
265
|
+
{renderSlot("toolbar-left", { displayMode: "minimal", className: "flex items-center gap-2" })}
|
|
266
|
+
{renderSlot("toolbar-right", { displayMode: "minimal", className: "flex items-center gap-1" })}
|
|
267
|
+
</div>
|
|
268
|
+
|
|
269
|
+
<MobileSubNavSlot />
|
|
270
|
+
|
|
271
|
+
<ExpandablePanel
|
|
272
|
+
open={isExpanded}
|
|
273
|
+
height={exp.expandedHeight}
|
|
274
|
+
dragHeight={exp.dragHeight}
|
|
275
|
+
>
|
|
276
|
+
<div ref={exp.expandedRef} className="flex flex-col">
|
|
277
|
+
<div className="mt-2 border-t pt-3">{expandedContent}</div>
|
|
278
|
+
<DragHandle
|
|
279
|
+
contentHeight={exp.expandedHeight}
|
|
280
|
+
onHeightChange={exp.onDragHeight}
|
|
281
|
+
onRelease={(shouldClose) => {
|
|
282
|
+
exp.onDragRelease(shouldClose);
|
|
283
|
+
if (shouldClose) collapse();
|
|
284
|
+
}}
|
|
285
|
+
/>
|
|
286
|
+
</div>
|
|
287
|
+
</ExpandablePanel>
|
|
288
|
+
</Box>
|
|
289
|
+
</div>
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function MobileBottomBar() {
|
|
294
|
+
const renderSlot = useRenderSlot();
|
|
295
|
+
const { zIndex } = useOverlay();
|
|
296
|
+
const { setBottomHeight } = useContext(MobileSpacingContext);
|
|
297
|
+
const barRef = useRef<HTMLDivElement>(null);
|
|
298
|
+
|
|
299
|
+
useEffect(() => {
|
|
300
|
+
const el = barRef.current;
|
|
301
|
+
if (!el) return;
|
|
302
|
+
const update = () => setBottomHeight(el.offsetHeight);
|
|
303
|
+
update();
|
|
304
|
+
const ro = new ResizeObserver(update);
|
|
305
|
+
ro.observe(el);
|
|
306
|
+
return () => ro.disconnect();
|
|
307
|
+
}, [setBottomHeight]);
|
|
308
|
+
|
|
309
|
+
return (
|
|
310
|
+
<div
|
|
311
|
+
ref={barRef}
|
|
312
|
+
className="fixed inset-x-0 bottom-0 p-2"
|
|
313
|
+
style={{ zIndex: zIndex("overflow-nav") }}
|
|
314
|
+
>
|
|
315
|
+
<Box
|
|
316
|
+
layoutId="mobile-bottom-bar"
|
|
317
|
+
className="border-t px-2 py-2 shadow-lg"
|
|
318
|
+
>
|
|
319
|
+
{renderSlot("toolbar-center", { displayMode: "compact", className: "flex items-center justify-around" })}
|
|
320
|
+
</Box>
|
|
321
|
+
</div>
|
|
322
|
+
);
|
|
323
|
+
}
|