Navigation·06.8
Dynamic Toolbar
A toolbar that becomes the field it opened.
Field notes
12 notes, 2 pinned
Install
One dependency. The component is copied into your project, so the file is yours after that.
bun add motionOr let the shadcn CLI do the copying: same file, landing in components/yugo.
bunx shadcn@latest add https://yugo.click/r/dynamic-toolbar.jsonUsage
"use client";
import { useState } from "react";
import { DynamicToolbar, ToolbarAction } from "@/components/yugo/dynamic-toolbar";
export function NotesBar() {
const [mode, setMode] = useState("actions");
const [query, setQuery] = useState("");
return (
<DynamicToolbar
label="Notes toolbar"
mode={mode}
onModeChange={setMode}
modes={[
{
id: "actions",
content: (
<ToolbarAction label="Search notes" onClick={() => setMode("search")}>
<SearchIcon />
</ToolbarAction>
),
},
{
id: "search",
content: (
<input
value={query}
onChange={(event) => setQuery(event.currentTarget.value)}
aria-label="Search notes"
className="h-10 w-[212px] rounded-[10px]"
/>
),
},
]}
/>
);
}Source
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
motion,
useIsomorphicLayoutEffect,
useReducedMotion,
} from "motion/react";
const SHELL = {
type: "spring",
stiffness: 380,
damping: 38,
mass: 0.7,
} as const;
const CROSSFADE = {
type: "spring",
stiffness: 260,
damping: 34,
mass: 0.8,
} as const;
const INSTANT = { duration: 0 } as const;
const FOCUSABLE =
'a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])';
type Box = { width: number; height: number };
export type UseDynamicToolbarOptions = {
modes: readonly string[];
mode?: string;
defaultMode?: string;
onModeChange?: (mode: string) => void;
};
export type UseDynamicToolbarResult = {
rootRef: React.RefObject<HTMLDivElement | null>;
mode: string;
/** the active mode's own measured box, or null before the first read */
box: Box | null;
ready: boolean;
setMode: (mode: string) => void;
dismiss: () => void;
measure: (id: string) => (node: HTMLDivElement | null) => void;
};
/**
* The shell travels to a measured number, never to a magic one. Each mode is
* laid out at its natural width and watched by one ResizeObserver, so adding
* a button or lengthening a placeholder changes the shell without anybody
* editing a pixel value.
*/
export function useDynamicToolbar({
modes,
mode: controlled,
defaultMode,
onModeChange,
}: UseDynamicToolbarOptions): UseDynamicToolbarResult {
const home = defaultMode ?? modes[0] ?? "";
const rootRef = useRef<HTMLDivElement>(null);
const [uncontrolled, setUncontrolled] = useState(home);
const [boxes, setBoxes] = useState<Record<string, Box>>({});
const [turn, setTurn] = useState(0);
const mode = controlled ?? uncontrolled;
const panels = useRef(new Map<string, HTMLDivElement>());
const binders = useRef(
new Map<string, (node: HTMLDivElement | null) => void>(),
);
const observer = useRef<ResizeObserver | null>(null);
const emit = useRef(onModeChange);
emit.current = onModeChange;
const live = useRef({ mode, home });
live.current = { mode, home };
const read = useCallback((id: string, node: HTMLElement) => {
const rect = node.getBoundingClientRect();
setBoxes((prev) => {
const at = prev[id];
// An epsilon, so a fractional width cannot feed its own observer. §21
if (
at &&
Math.abs(at.width - rect.width) < 0.5 &&
Math.abs(at.height - rect.height) < 0.5
) {
return prev;
}
return { ...prev, [id]: { width: rect.width, height: rect.height } };
});
}, []);
const measure = useCallback(
(id: string) => {
const cached = binders.current.get(id);
if (cached) return cached;
const bind = (node: HTMLDivElement | null) => {
const previous = panels.current.get(id);
if (previous && previous !== node) observer.current?.unobserve(previous);
if (!node) {
panels.current.delete(id);
return;
}
panels.current.set(id, node);
observer.current?.observe(node);
read(id, node);
};
binders.current.set(id, bind);
return bind;
},
[read],
);
useIsomorphicLayoutEffect(() => {
const watcher = new ResizeObserver((entries) => {
for (const entry of entries) {
for (const [id, node] of panels.current) {
if (node === entry.target) read(id, node);
}
}
});
observer.current = watcher;
for (const [id, node] of panels.current) {
watcher.observe(node);
read(id, node);
}
return () => {
watcher.disconnect();
observer.current = null;
};
}, [read]);
const setMode = useCallback(
(next: string) => {
if (live.current.mode === next) return;
if (controlled === undefined) setUncontrolled(next);
emit.current?.(next);
setTurn((count) => count + 1);
},
[controlled],
);
const dismiss = useCallback(() => setMode(live.current.home), [setMode]);
// A toolbar that becomes a field has to put the caret in the field. The
// count, not the mode, is the dependency: mounting is not a mode change,
// so nothing is stolen on the first paint.
useEffect(() => {
if (turn === 0) return;
const node = panels.current.get(live.current.mode);
node?.querySelector<HTMLElement>(FOCUSABLE)?.focus({ preventScroll: true });
}, [turn]);
useEffect(() => {
if (mode === home) return;
const onPointerDown = (event: PointerEvent) => {
const target = event.target as Node | null;
if (!target || rootRef.current?.contains(target)) return;
dismiss();
};
const onKeyDown = (event: KeyboardEvent) => {
if (event.key !== "Escape") return;
if (!rootRef.current?.contains(document.activeElement)) return;
event.stopPropagation();
dismiss();
};
document.addEventListener("pointerdown", onPointerDown, true);
document.addEventListener("keydown", onKeyDown, true);
return () => {
document.removeEventListener("pointerdown", onPointerDown, true);
document.removeEventListener("keydown", onKeyDown, true);
};
}, [mode, home, dismiss]);
const box = boxes[mode] ?? null;
return { rootRef, mode, box, ready: box !== null, setMode, dismiss, measure };
}
export type ToolbarMode = {
id: string;
content: React.ReactNode;
};
export type DynamicToolbarProps = Omit<UseDynamicToolbarOptions, "modes"> & {
modes: readonly ToolbarMode[];
label: string;
className?: string;
};
export function DynamicToolbar({
modes,
label,
className = "",
...options
}: DynamicToolbarProps) {
const reduced = useReducedMotion();
const ids = useMemo(() => modes.map((entry) => entry.id), [modes]);
const { rootRef, mode, box, measure } = useDynamicToolbar({
...options,
modes: ids,
});
return (
<div ref={rootRef} className={`inline-block ${className}`}>
<motion.div
role="group"
aria-label={label}
initial={false}
animate={box ? { width: box.width, height: box.height } : {}}
transition={reduced ? INSTANT : SHELL}
style={{
overflow: "hidden",
width: box ? undefined : "max-content",
height: box ? undefined : "auto",
}}
className="relative rounded-[14px] border border-stone-200 bg-white shadow-[0_1px_2px_rgba(28,25,23,0.06),0_4px_10px_-8px_rgba(28,25,23,0.45)] dark:border-white/[0.16] dark:bg-[#1D1D1A] dark:shadow-[0_1px_6px_rgba(0,0,0,0.45)]"
>
{modes.map((entry) => {
const active = entry.id === mode;
return (
<motion.div
key={entry.id}
ref={measure(entry.id)}
inert={!active}
aria-hidden={!active}
initial={false}
animate={{ opacity: active ? 1 : 0 }}
transition={
reduced
? INSTANT
: { ...CROSSFADE, delay: active ? 0.05 : 0 }
}
// The inactive modes stay mounted at their natural width so
// they can be measured; only the active one is in flow, so the
// shell has a real size before anything has been observed.
className={`w-max p-1 ${
active ? "relative" : "pointer-events-none absolute left-0 top-0"
}`}
>
{entry.content}
</motion.div>
);
})}
</motion.div>
</div>
);
}
export type ToolbarActionProps = {
children: React.ReactNode;
label: string;
onClick?: () => void;
disabled?: boolean;
};
/** The key inside the shell: 14 outer − 4 padding = 10, which is also the
* height every field in the set is drawn at, so a mode that becomes an
* input sits on the same line as the keys it replaced. §4 */
export function ToolbarAction({
children,
label,
onClick,
disabled = false,
}: ToolbarActionProps) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
aria-label={label}
className="grid size-10 shrink-0 place-items-center rounded-[10px] text-stone-500 outline-none transition-colors duration-150 hover:bg-stone-100 hover:text-stone-800 focus-visible:bg-[#4568FF]/[0.06] focus-visible:shadow-[inset_0_0_0_1px_#4568FF] disabled:pointer-events-none disabled:opacity-50 dark:text-stone-400 dark:hover:bg-white/10 dark:hover:text-stone-100 dark:focus-visible:bg-[#93B0FF]/[0.1] dark:focus-visible:shadow-[inset_0_0_0_1px_#93B0FF]"
>
{children}
</button>
);
}Props
modesreadonly ToolbarMode[]The states the shell can be in. Each is { id, content }; the first is home, which is where Escape and an outside press send it back to.
labelstringThe accessible name of the group.
modestringControlled mode. Supplying it makes the parent the source of truth.
defaultModestringUncontrolled starting mode, and the mode dismissal returns to. Defaults to the first entry.
onModeChange(mode: string) => voidFires with the next mode on every change, controlled or not.
className""stringAppended last to the wrapper around the shell.