@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.
Files changed (49) hide show
  1. package/package.json +42 -0
  2. package/src/ds/avatar/avatar.tsx +86 -0
  3. package/src/ds/badge/badge.tsx +61 -0
  4. package/src/ds/bento-card/bento-card.tsx +70 -0
  5. package/src/ds/bento-grid/bento-grid.tsx +52 -0
  6. package/src/ds/box/box.tsx +96 -0
  7. package/src/ds/button/button.tsx +191 -0
  8. package/src/ds/card-modal/card-modal.tsx +161 -0
  9. package/src/ds/display-mode.tsx +32 -0
  10. package/src/ds/ds-provider.tsx +85 -0
  11. package/src/ds/form/field.tsx +124 -0
  12. package/src/ds/form/fields/checkbox-select-field.tsx +326 -0
  13. package/src/ds/form/fields/index.ts +14 -0
  14. package/src/ds/form/fields/search-select-field.tsx +41 -0
  15. package/src/ds/form/fields/select-field.tsx +42 -0
  16. package/src/ds/form/fields/suggestion-list-field.tsx +43 -0
  17. package/src/ds/form/fields/tag-field.tsx +39 -0
  18. package/src/ds/form/fields/text-field.tsx +35 -0
  19. package/src/ds/form/fields/textarea-field.tsx +81 -0
  20. package/src/ds/form/form-part.tsx +79 -0
  21. package/src/ds/form/form.tsx +299 -0
  22. package/src/ds/form/index.tsx +5 -0
  23. package/src/ds/form/message.tsx +14 -0
  24. package/src/ds/input/input.tsx +115 -0
  25. package/src/ds/merge-variants.ts +26 -0
  26. package/src/ds/search-select/search-select.tsx +291 -0
  27. package/src/ds/separator/separator.tsx +26 -0
  28. package/src/ds/suggestion-list/suggestion-list.tsx +406 -0
  29. package/src/ds/tag-list/tag-list.tsx +87 -0
  30. package/src/ds/tooltip/tooltip.tsx +33 -0
  31. package/src/ds/transitions.ts +12 -0
  32. package/src/ds/types.ts +131 -0
  33. package/src/index.ts +115 -0
  34. package/src/layout/drag-handle.tsx +117 -0
  35. package/src/layout/dynamic-slot.tsx +95 -0
  36. package/src/layout/expandable-panel.tsx +57 -0
  37. package/src/layout/layout.tsx +323 -0
  38. package/src/layout/overlay-provider.tsx +103 -0
  39. package/src/layout/overlay.tsx +33 -0
  40. package/src/layout/router.tsx +101 -0
  41. package/src/layout/scroll-nav.tsx +121 -0
  42. package/src/layout/slot-render-context.tsx +14 -0
  43. package/src/layout/sub-nav-shell.tsx +41 -0
  44. package/src/layout/toolbar-expand.tsx +70 -0
  45. package/src/layout/transitions.ts +12 -0
  46. package/src/layout/use-expandable.ts +59 -0
  47. package/src/lib/utils.ts +6 -0
  48. package/src/ui/tooltip.tsx +59 -0
  49. 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
+ }