Layout Picker
Dashboard or navbar choice cards, plus a first-visit corner toast that saves the choice in a cookie.
import { type Layout, LayoutPicker, LayoutPickerToast,} from "@/components/layout-picker/layout-picker";import { Button } from "@/components/ui/button";import { useState } from "react";
export default function Demo() { const [layout, setLayout] = useState<Layout>("dashboard"); const [toast, setToast] = useState(false); return ( <div className="flex flex-col items-start gap-4"> <LayoutPicker value={layout} onValueChange={setLayout} /> <Button variant="outline" onClick={() => setToast(true)}> Show corner toast </Button> {toast ? ( <LayoutPickerToast defaultOpen defaultValue={layout} onValueChange={setLayout} onClose={() => setToast(false)} /> ) : null} </div> );}<script setup lang="ts">import { ref } from "vue";import { Button } from "@/components/ui/button";import { type Layout, LayoutPicker, LayoutPickerToast } from "@/components/ui/layout-picker";
const layout = ref<Layout>("dashboard");const toast = ref(false);</script>
<template> <div class="flex flex-col items-start gap-4"> <LayoutPicker v-model="layout" /> <Button variant="outline" @click="toast = true">Show corner toast</Button> <LayoutPickerToast v-if="toast" default-open v-model="layout" @close="toast = false" /> </div></template><script lang="ts"> import { Button } from "$lib/components/ui/button"; import { type Layout, LayoutPicker, LayoutPickerToast } from "$lib/components/ui/layout-picker";
let layout = $state<Layout>("dashboard"); let toast = $state(false);</script>
<div class="flex flex-col items-start gap-4"> <LayoutPicker bind:value={layout} /> <Button variant="outline" onclick={() => (toast = true)}>Show corner toast</Button> {#if toast} <LayoutPickerToast defaultOpen defaultValue={layout} onValueChange={(v) => (layout = v)} onClose={() => (toast = false)} /> {/if}</div>Installation
Section titled “Installation”npx shadcn@latest add @edmi-ui/layout-pickerpnpm dlx shadcn@latest add @edmi-ui/layout-pickeryarn dlx shadcn@latest add @edmi-ui/layout-pickerbunx shadcn@latest add @edmi-ui/layout-pickerPick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.
Add the Edmi components this one builds on:
button,elevation.Copy the code from Component source into your project and update the import paths to match your aliases.
npx shadcn-vue@latest add @edmi-ui/layout-pickerpnpm dlx shadcn-vue@latest add @edmi-ui/layout-pickeryarn dlx shadcn-vue@latest add @edmi-ui/layout-pickerbunx shadcn-vue@latest add @edmi-ui/layout-pickerPick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.
Add the Edmi components this one builds on:
button,elevation.Copy the code from Component source into your project and update the import paths to match your aliases.
npx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/layout-picker.jsonpnpm dlx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/layout-picker.jsonyarn dlx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/layout-picker.jsonbunx --bun shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/layout-picker.jsonPick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.
Add the Edmi components this one builds on:
button,elevation.Copy the code from Component source into your project and update the import paths to match your aliases.
import { LayoutPicker } from "@/components/layout-picker/layout-picker";import { Button } from "@/components/ui/button";
<div className="flex flex-col items-start gap-4"> <LayoutPicker /> <Button variant="outline"> Show corner toast </Button></div><script setup lang="ts">import { Button } from "@/components/ui/button";import { LayoutPicker, LayoutPickerToast } from "@/components/ui/layout-picker";</script>
<template> <div class="flex flex-col items-start gap-4"> <LayoutPicker /> <Button variant="outline">Show corner toast</Button> <LayoutPickerToast default-open /> </div></template><script lang="ts"> import { Button } from "$lib/components/ui/button"; import { LayoutPicker, LayoutPickerToast, } from "$lib/components/ui/layout-picker";</script>
<div class="flex flex-col items-start gap-4"> <LayoutPicker /> <Button variant="outline">Show corner toast</Button> {#if toast} <LayoutPickerToast defaultOpen /> {/if}</div>Component source
Section titled “Component source”"use client";
import { cn } from "cn";import * as React from "react";import { XIcon } from "@phosphor-icons/react";import { Button } from "@/registry/edmi/ui/button";import { controlLevel, type Elevation, surfaceElevation, useElevation,} from "@/registry/edmi/ui/elevation";
type Layout = "dashboard" | "navbar";
const LAYOUT_COOKIE = "edmi-layout";
/** Reads the saved layout from a cookie string (defaults to `document.cookie`); `undefined` when unset. */function getLayoutCookie(cookie?: string): Layout | undefined { const source = cookie ?? (typeof document === "undefined" ? "" : document.cookie); const m = source.match( new RegExp(`(?:^|;\\s*)${LAYOUT_COOKIE}=(dashboard|navbar)`), ); return m?.[1] as Layout | undefined;}
/** Saves the layout for a year (DESIGN §3/§6: a cookie so SSR renders the right shell with no flash). */function setLayoutCookie(layout: Layout) { document.cookie = `${LAYOUT_COOKIE}=${layout}; path=/; max-age=31536000; samesite=lax`;}
const bar = "block rounded-sm bg-[color-mix(in_srgb,var(--muted-foreground)_20%,var(--card))]";
function Wireframe({ layout }: { layout: Layout }) { return ( <div className="flex h-24 w-full overflow-hidden rounded-lg border border-sk-bd bg-sk-bg shadow-sunken"> {layout === "dashboard" ? ( <> <div className="flex w-[46px] flex-col gap-1.5 border-r border-border p-1.5"> {[0, 1, 2, 3].map((i) => ( <span key={i} className={cn(bar, "h-1.5")} /> ))} </div> <div className="flex flex-1 flex-col gap-1.5 p-2"> <span className={cn(bar, "h-2 w-3/5")} /> <span className={cn(bar, "h-10")} /> </div> </> ) : ( <div className="flex flex-1 flex-col"> <div className="flex h-4 items-center gap-3 border-b border-border px-2"> <span className={cn(bar, "h-1.5 w-[30px]")} /> <span className={cn(bar, "h-1.5 w-[60px]")} /> </div> <div className="flex flex-col gap-1.5 p-2"> <span className={cn(bar, "h-2 w-3/5")} /> <span className={cn(bar, "h-10")} /> </div> </div> )} </div> );}
const options: { value: Layout; label: string }[] = [ { value: "dashboard", label: "Dashboard" }, { value: "navbar", label: "Navbar" },];
// Two choice cards (native radios, so arrows/Space work). Controlled or uncontrolled.function LayoutPicker({ className, value, defaultValue = "dashboard", onValueChange, name, elevation, ...props}: Omit< React.ComponentProps<"div">, "children" | "defaultValue" | "onChange"> & { value?: Layout; defaultValue?: Layout; onValueChange?: (value: Layout) => void; name?: string; /** ✦ depth of the option cards: sunken -1, flat 0, raised +1, floating +2 (the checked card keeps its ring). */ elevation?: Elevation;}) { const level = useElevation(elevation, "control"); const groupName = React.useId(); const [inner, setInner] = React.useState<Layout>(defaultValue); const current = value ?? inner; return ( <div data-slot="layout-picker" role="radiogroup" className={cn("flex flex-wrap gap-3", className)} {...props} > {options.map((o) => ( <label key={o.value} className={cn( "group/lp flex w-[220px] cursor-pointer flex-col gap-2.5 rounded-xl border border-border bg-card p-3.5 has-[:checked]:border-ring has-[:checked]:bg-[color-mix(in_srgb,var(--brand)_5%,var(--card))] has-[:checked]:shadow-[0_0_0_1px_var(--ring)] has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-offset-2 has-[:focus-visible]:outline-ring", surfaceElevation[level], )} > <input type="radio" className="sr-only" name={name ?? groupName} value={o.value} checked={current === o.value} onChange={() => { setInner(o.value); onValueChange?.(o.value); }} /> <Wireframe layout={o.value} /> <span className="flex w-full items-center justify-between"> <span className="text-sm font-medium">{o.label}</span> <span className="flex size-[18px] items-center justify-center rounded-full border border-input bg-card group-has-[:checked]/lp:border-primary group-has-[:checked]/lp:after:size-[9px] group-has-[:checked]/lp:after:rounded-full group-has-[:checked]/lp:after:bg-primary group-has-[:checked]/lp:after:content-['']" /> </span> </label> ))} </div> );}
// First-visit corner toast. Renders nothing once a layout cookie exists. Choosing saves the cookie;// closing without choosing saves the default (`dashboard`) so it does not return.function LayoutPickerToast({ className, title = "Choose your layout", description = "You can switch any time.", defaultOpen, defaultValue = "dashboard", onValueChange, onClose, elevation, ...props}: Omit<React.ComponentProps<"div">, "title" | "children"> & { title?: React.ReactNode; description?: React.ReactNode; /** Skip the cookie check and show immediately (docs/previews). */ defaultOpen?: boolean; /** Initially selected layout. */ defaultValue?: Layout; onValueChange?: (value: Layout) => void; onClose?: () => void; /** ✦ depth of the toast (floating is its natural level); raised +1 / floating +2 also raise the option cards. */ elevation?: Elevation;}) { const level = useElevation(elevation, "overlay"); const [open, setOpen] = React.useState(false); const [value, setValue] = React.useState<Layout>(defaultValue); React.useEffect(() => { setOpen(defaultOpen ?? getLayoutCookie() === undefined); }, [defaultOpen]); if (!open) return null; return ( <div data-slot="layout-picker-toast" role="dialog" aria-label="Choose layout" className={cn( "fixed right-4 bottom-4 z-50 w-[min(92vw,500px)] rounded-xl border border-border bg-popover p-4 text-popover-foreground", surfaceElevation[level], className, )} {...props} > <div className="flex items-start justify-between gap-3"> <div> <div className="text-sm font-semibold">{title}</div> <div className="text-[13px] text-muted-foreground">{description}</div> </div> <Button variant="ghost" size="icon-xs" aria-label="Close" onClick={() => { if (getLayoutCookie() === undefined) setLayoutCookie(value); setOpen(false); onClose?.(); }} > <XIcon /> </Button> </div> <LayoutPicker className="mt-3 flex-nowrap" elevation={controlLevel(elevation)} value={value} onValueChange={(v) => { setValue(v); setLayoutCookie(v); onValueChange?.(v); }} /> </div> );}
export type { Layout };export { getLayoutCookie, LAYOUT_COOKIE, LayoutPicker, LayoutPickerToast, setLayoutCookie,};<script setup lang="ts">import type { HTMLAttributes } from 'vue'import type { Layout } from './layout'import { computed, ref, useId } from 'vue'import { cn } from '@/registry/edmi/lib/utils'import { type Elevation, surfaceElevation, useElevation } from '@/registry/edmi/ui/elevation'import LayoutPickerWireframe from './LayoutPickerWireframe.vue'
const props = withDefaults(defineProps<{ modelValue?: Layout defaultValue?: Layout name?: string /** ✦ depth of the option cards: sunken -1, flat 0, raised +1, floating +2 (the checked card keeps its ring) */ elevation?: Elevation class?: HTMLAttributes['class']}>(), { defaultValue: 'dashboard', elevation: undefined,})
const level = useElevation(() => props.elevation, 'control')
const emit = defineEmits<{ (e: 'update:modelValue', value: Layout): void}>()
const options: { value: Layout, label: string }[] = [ { value: 'dashboard', label: 'Dashboard' }, { value: 'navbar', label: 'Navbar' },]
const groupName = useId()const inner = ref<Layout>(props.defaultValue)const current = computed(() => props.modelValue ?? inner.value)
function choose(v: Layout) { inner.value = v emit('update:modelValue', v)}</script>
<template> <!-- Two choice cards (native radios, so arrows/Space work). Controlled (`v-model`) or uncontrolled. --> <div data-slot="layout-picker" role="radiogroup" :class="cn('flex flex-wrap gap-3', props.class)"> <label v-for="o in options" :key="o.value" :class="cn( 'group/lp flex w-[220px] cursor-pointer flex-col gap-2.5 rounded-xl border border-border bg-card p-3.5 has-[:checked]:border-ring has-[:checked]:bg-[color-mix(in_srgb,var(--brand)_5%,var(--card))] has-[:checked]:shadow-[0_0_0_1px_var(--ring)] has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-offset-2 has-[:focus-visible]:outline-ring', surfaceElevation[level], )" > <input type="radio" class="peer sr-only" :name="name ?? groupName" :value="o.value" :checked="current === o.value" @change="choose(o.value)" > <LayoutPickerWireframe :layout="o.value" /> <span class="flex w-full items-center justify-between"> <span class="text-sm font-medium">{{ o.label }}</span> <span class="flex size-[18px] items-center justify-center rounded-full border border-input bg-card group-has-[:checked]/lp:border-primary group-has-[:checked]/lp:after:size-[9px] group-has-[:checked]/lp:after:rounded-full group-has-[:checked]/lp:after:bg-primary group-has-[:checked]/lp:after:content-['']" /> </span> </label> </div></template><script setup lang="ts">import type { HTMLAttributes } from 'vue'import type { Layout } from './layout'import { X } from '@lucide/vue'import { computed, onMounted, ref } from 'vue'import { cn } from '@/registry/edmi/lib/utils'import { Button } from '@/registry/edmi/ui/button'import { controlLevel, type Elevation, surfaceElevation, useElevation } from '@/registry/edmi/ui/elevation'import { getLayoutCookie, setLayoutCookie } from './layout'import LayoutPicker from './LayoutPicker.vue'
const props = withDefaults(defineProps<{ title?: string description?: string /** Skip the cookie check and show immediately (docs/previews). */ defaultOpen?: boolean /** Controlled layout (`v-model`). */ modelValue?: Layout /** Initially selected layout. */ defaultValue?: Layout /** ✦ depth of the toast (floating is its natural level); raised +1 / floating +2 also raise the option cards */ elevation?: Elevation class?: HTMLAttributes['class']}>(), { title: 'Choose your layout', description: 'You can switch any time.', // Boolean props are cast to false when absent; keep undefined so the cookie check decides. defaultOpen: undefined, defaultValue: 'dashboard', elevation: undefined,})
const level = useElevation(() => props.elevation, 'overlay')const pickerElevation = computed(() => controlLevel(props.elevation))
const emit = defineEmits<{ (e: 'update:modelValue', value: Layout): void (e: 'close'): void}>()
// First-visit corner toast. Renders nothing once a layout cookie exists. Choosing saves the cookie;// closing without choosing saves the default (`dashboard`) so it does not return.const open = ref(false)const inner = ref<Layout>(props.defaultValue)const value = computed(() => props.modelValue ?? inner.value)
onMounted(() => { open.value = props.defaultOpen ?? getLayoutCookie() === undefined})
function onChange(v: Layout) { inner.value = v setLayoutCookie(v) emit('update:modelValue', v)}
function close() { if (getLayoutCookie() === undefined) setLayoutCookie(value.value) open.value = false emit('close')}</script>
<template> <div v-if="open" data-slot="layout-picker-toast" role="dialog" aria-label="Choose layout" :class="cn( 'fixed right-4 bottom-4 z-50 w-[min(92vw,500px)] rounded-xl border border-border bg-popover p-4 text-popover-foreground', surfaceElevation[level], props.class, )" > <div class="flex items-start justify-between gap-3"> <div> <div class="text-sm font-semibold"> {{ title }} </div> <div class="text-[13px] text-muted-foreground"> {{ description }} </div> </div> <Button variant="ghost" size="icon-xs" aria-label="Close" @click="close"> <X /> </Button> </div> <LayoutPicker class="mt-3 flex-nowrap" :elevation="pickerElevation" :model-value="value" @update:model-value="onChange" /> </div></template><script setup lang="ts">import type { Layout } from './layout'
defineProps<{ layout: Layout }>()
const bar = 'block rounded-sm bg-[color-mix(in_srgb,var(--muted-foreground)_20%,var(--card))]'</script>
<template> <div class="flex h-24 w-full overflow-hidden rounded-lg border border-sk-bd bg-sk-bg shadow-sunken"> <template v-if="layout === 'dashboard'"> <div class="flex w-[46px] flex-col gap-1.5 border-r border-border p-1.5"> <span v-for="i in 4" :key="i" :class="`${bar} h-1.5`" /> </div> <div class="flex flex-1 flex-col gap-1.5 p-2"> <span :class="`${bar} h-2 w-3/5`" /> <span :class="`${bar} h-10`" /> </div> </template> <div v-else class="flex flex-1 flex-col"> <div class="flex h-4 items-center gap-3 border-b border-border px-2"> <span :class="`${bar} h-1.5 w-[30px]`" /> <span :class="`${bar} h-1.5 w-[60px]`" /> </div> <div class="flex flex-col gap-1.5 p-2"> <span :class="`${bar} h-2 w-3/5`" /> <span :class="`${bar} h-10`" /> </div> </div> </div></template>export type Layout = "dashboard" | "navbar";
export const LAYOUT_COOKIE = "edmi-layout";
/** Reads the saved layout from a cookie string (defaults to `document.cookie`); `undefined` when unset. */export function getLayoutCookie(cookie?: string): Layout | undefined { const source = cookie ?? (typeof document === "undefined" ? "" : document.cookie); const m = source.match( new RegExp(`(?:^|;\\s*)${LAYOUT_COOKIE}=(dashboard|navbar)`), ); return m?.[1] as Layout | undefined;}
/** Saves the layout for a year (DESIGN §3/§6: a cookie so SSR renders the right shell with no flash). */export function setLayoutCookie(layout: Layout) { document.cookie = `${LAYOUT_COOKIE}=${layout}; path=/; max-age=31536000; samesite=lax`;}export { default as LayoutPicker } from "./LayoutPicker.vue";export { default as LayoutPickerToast } from "./LayoutPickerToast.vue";export type { Layout } from "./layout";export { getLayoutCookie, LAYOUT_COOKIE, setLayoutCookie } from "./layout";export { getLayoutCookie, LAYOUT_COOKIE, type Layout, setLayoutCookie,} from "./layout.js";export { default as LayoutPicker } from "./layout-picker.svelte";export { default as LayoutPickerToast } from "./layout-picker-toast.svelte";<script lang="ts"> import XIcon from "phosphor-svelte/lib/X"; import { Button } from "$lib/registry/ui/button/index.js"; import { cn, type WithElementRef } from "$lib/utils.js"; import type { HTMLAttributes } from "svelte/elements"; import { getLayoutCookie, setLayoutCookie, type Layout } from "./layout.js"; import LayoutPicker from "./layout-picker.svelte"; import { controlLevel, type Elevation, surfaceElevation, useElevation } from "$lib/registry/ui/elevation/index.js";
let { ref = $bindable(null), class: className, title = "Choose your layout", description = "You can switch any time.", defaultOpen, value = $bindable(), defaultValue = "dashboard", onValueChange, onClose, elevation = "auto", ...restProps }: WithElementRef<Omit<HTMLAttributes<HTMLDivElement>, "title" | "children">> & { title?: string; description?: string; /** Skip the cookie check and show immediately (docs/previews). */ defaultOpen?: boolean; /** Two-way bindable selected layout (`bind:value`). */ value?: Layout; /** Initially selected layout. */ defaultValue?: Layout; onValueChange?: (value: Layout) => void; onClose?: () => void; /** ✦ depth of the toast (floating is its natural level); raised +1 / floating +2 also raise the option cards. */ elevation?: Elevation; } = $props();
const level = useElevation(() => elevation, "overlay"); const pickerElevation = $derived<Elevation>(controlLevel(elevation) ?? "auto");
// First-visit corner toast. Renders nothing once a layout cookie exists. Choosing saves the cookie; // closing without choosing saves the default (`dashboard`) so it does not return. let open = $state(false); // svelte-ignore state_referenced_locally let inner = $state<Layout>(defaultValue); const current = $derived(value ?? inner);
$effect(() => { open = defaultOpen ?? getLayoutCookie() === undefined; });</script>
{#if open} <div bind:this={ref} data-slot="layout-picker-toast" role="dialog" aria-label="Choose layout" class={cn( "fixed right-4 bottom-4 z-50 w-[min(92vw,500px)] rounded-xl border border-border bg-popover p-4 text-popover-foreground", surfaceElevation[level.current], className )} {...restProps} > <div class="flex items-start justify-between gap-3"> <div> <div class="text-sm font-semibold">{title}</div> <div class="text-[13px] text-muted-foreground">{description}</div> </div> <Button variant="ghost" size="icon-xs" aria-label="Close" onclick={() => { if (getLayoutCookie() === undefined) setLayoutCookie(current); open = false; onClose?.(); }} > <XIcon /> </Button> </div> <LayoutPicker class="mt-3 flex-nowrap" elevation={pickerElevation} value={current} onValueChange={(v) => { inner = v; value = v; setLayoutCookie(v); onValueChange?.(v); }} /> </div>{/if}<script lang="ts"> import type { Layout } from "./layout.js";
let { layout }: { layout: Layout } = $props();
const bar = "block rounded-sm bg-[color-mix(in_srgb,var(--muted-foreground)_20%,var(--card))]";</script>
<div class="flex h-24 w-full overflow-hidden rounded-lg border border-sk-bd bg-sk-bg shadow-sunken"> {#if layout === "dashboard"} <div class="flex w-[46px] flex-col gap-1.5 border-r border-border p-1.5"> {#each [0, 1, 2, 3] as i (i)} <span class="{bar} h-1.5"></span> {/each} </div> <div class="flex flex-1 flex-col gap-1.5 p-2"> <span class="{bar} h-2 w-3/5"></span> <span class="{bar} h-10"></span> </div> {:else} <div class="flex flex-1 flex-col"> <div class="flex h-4 items-center gap-3 border-b border-border px-2"> <span class="{bar} h-1.5 w-[30px]"></span> <span class="{bar} h-1.5 w-[60px]"></span> </div> <div class="flex flex-col gap-1.5 p-2"> <span class="{bar} h-2 w-3/5"></span> <span class="{bar} h-10"></span> </div> </div> {/if}</div><script lang="ts"> import { cn, type WithElementRef } from "$lib/utils.js"; import type { HTMLAttributes } from "svelte/elements"; import type { Layout } from "./layout.js"; import { type Elevation, surfaceElevation, useElevation } from "$lib/registry/ui/elevation/index.js"; import LayoutPickerWireframe from "./layout-picker-wireframe.svelte";
let { ref = $bindable(null), class: className, value = $bindable("dashboard"), onValueChange, name, elevation = "auto", ...restProps }: WithElementRef<Omit<HTMLAttributes<HTMLDivElement>, "children" | "onchange">> & { /** Two-way bindable (`bind:value`); initial value doubles as the default. */ value?: Layout; onValueChange?: (value: Layout) => void; name?: string; /** ✦ depth of the option cards: sunken -1, flat 0, raised +1, floating +2 (the checked card keeps its ring). */ elevation?: Elevation; } = $props();
const level = useElevation(() => elevation, "control");
const options: { value: Layout; label: string }[] = [ { value: "dashboard", label: "Dashboard" }, { value: "navbar", label: "Navbar" }, ];
const groupName = $props.id();</script>
<!-- Two choice cards (native radios, so arrows/Space work). --><div bind:this={ref} data-slot="layout-picker" role="radiogroup" class={cn("flex flex-wrap gap-3", className)} {...restProps}> {#each options as o (o.value)} <label class={cn( "group/lp flex w-[220px] cursor-pointer flex-col gap-2.5 rounded-xl border border-border bg-card p-3.5 has-[:checked]:border-ring has-[:checked]:bg-[color-mix(in_srgb,var(--brand)_5%,var(--card))] has-[:checked]:shadow-[0_0_0_1px_var(--ring)] has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-offset-2 has-[:focus-visible]:outline-ring", surfaceElevation[level.current] )} > <input type="radio" class="sr-only" name={name ?? groupName} value={o.value} checked={value === o.value} onchange={() => { value = o.value; onValueChange?.(o.value); }} /> <LayoutPickerWireframe layout={o.value} /> <span class="flex w-full items-center justify-between"> <span class="text-sm font-medium">{o.label}</span> <span class="flex size-[18px] items-center justify-center rounded-full border border-input bg-card group-has-[:checked]/lp:border-primary group-has-[:checked]/lp:after:size-[9px] group-has-[:checked]/lp:after:rounded-full group-has-[:checked]/lp:after:bg-primary group-has-[:checked]/lp:after:content-['']" ></span> </span> </label> {/each}</div>export type Layout = "dashboard" | "navbar";
export const LAYOUT_COOKIE = "edmi-layout";
/** Reads the saved layout from a cookie string (defaults to `document.cookie`); `undefined` when unset. */export function getLayoutCookie(cookie?: string): Layout | undefined { const source = cookie ?? (typeof document === "undefined" ? "" : document.cookie); const m = source.match( new RegExp(`(?:^|;\\s*)${LAYOUT_COOKIE}=(dashboard|navbar)`), ); return m?.[1] as Layout | undefined;}
/** Saves the layout for a year (DESIGN §3/§6: a cookie so SSR renders the right shell with no flash). */export function setLayoutCookie(layout: Layout) { document.cookie = `${LAYOUT_COOKIE}=${layout}; path=/; max-age=31536000; samesite=lax`;}Elevation ✦
Section titled “Elevation ✦”See the Elevation guide for the levels, roles and layered mode.
Flat by default. elevation sets the depth of the option cards: sunken (-1) wells, raised (+1) the bevel, floating (+2) bevel plus drop; the checked card keeps its ring. LayoutPickerToast takes the same prop for the toast (floating is its natural level) and raises the cards when it is raised or floating.
Sunken (-1)
Flat (0)
Raised (+1)
Floating (+2)
Sunken (-1)
Flat (0)
Raised (+1)
Floating (+2)
Sunken (-1)
Flat (0)
Raised (+1)
Floating (+2)
import { type Layout, LayoutPicker, LayoutPickerToast,} from "@/components/layout-picker/layout-picker";import { Button } from "@/components/ui/button";import type { Elevation } from "@/components/ui/elevation";import { useState } from "react";
function Sample({ elevation }: { elevation: Elevation }) { const [layout, setLayout] = useState<Layout>("dashboard"); const [toast, setToast] = useState(false); return ( <div className="flex flex-col items-start gap-4"> <LayoutPicker elevation={elevation} value={layout} onValueChange={setLayout} /> <Button variant="outline" onClick={() => setToast(true)}> Show corner toast </Button> {toast ? ( <LayoutPickerToast elevation={elevation} defaultOpen defaultValue={layout} onValueChange={setLayout} onClose={() => setToast(false)} /> ) : null} </div> );}
const levels = [ { value: "sunken", label: "Sunken (-1)" }, { value: "flat", label: "Flat (0)" }, { value: "raised", label: "Raised (+1)" }, { value: "floating", label: "Floating (+2)" },] as const;
export default function Demo() { return ( <div className="flex flex-col gap-6"> {levels.map(({ value, label }) => ( <div key={value} className="flex flex-col gap-2"> <p className="text-xs font-medium text-muted-foreground">{label}</p> <Sample elevation={value} /> </div> ))} </div> );}<script setup lang="ts">import { ref } from "vue";import { Button } from "@/components/ui/button";import { type Layout, LayoutPicker, LayoutPickerToast } from "@/components/ui/layout-picker";
const layout = ref<Layout>("dashboard");const toast = ref(false);
const levels = [ { value: "sunken", label: "Sunken (-1)" } as const, { value: "flat", label: "Flat (0)" } as const, { value: "raised", label: "Raised (+1)" } as const, { value: "floating", label: "Floating (+2)" } as const,];</script>
<template> <div class="flex flex-col gap-6"> <div v-for="l in levels" :key="l.value" class="flex flex-col gap-2"> <p class="text-xs font-medium text-muted-foreground">{{ l.label }}</p> <div class="flex flex-col items-start gap-4"> <LayoutPicker v-model="layout" :elevation="l.value" /> <Button variant="outline" @click="toast = true">Show corner toast</Button> <LayoutPickerToast v-if="toast" :elevation="l.value" default-open v-model="layout" @close="toast = false" /> </div> </div> </div></template><script lang="ts"> import { Button } from "$lib/components/ui/button"; import { type Layout, LayoutPicker, LayoutPickerToast } from "$lib/components/ui/layout-picker";
let layout = $state<Layout>("dashboard"); let toast = $state(false);
const levels = [ { value: "sunken", label: "Sunken (-1)" } as const, { value: "flat", label: "Flat (0)" } as const, { value: "raised", label: "Raised (+1)" } as const, { value: "floating", label: "Floating (+2)" } as const, ];</script>
<div class="flex flex-col gap-6"> {#each levels as l (l.value)} <div class="flex flex-col gap-2"> <p class="text-xs font-medium text-muted-foreground">{l.label}</p><div class="flex flex-col items-start gap-4"> <LayoutPicker elevation={l.value} bind:value={layout} /> <Button variant="outline" onclick={() => (toast = true)}>Show corner toast</Button> {#if toast} <LayoutPickerToast elevation={l.value} defaultOpen defaultValue={layout} onValueChange={(v) => (layout = v)} onClose={() => (toast = false)} /> {/if}</div> </div> {/each}</div>| Part | Prop | Type | Default |
|---|---|---|---|
LayoutPicker |
value / defaultValue |
"dashboard" | "navbar" (Vue v-model, Svelte bind:value) |
"dashboard" |
onValueChange |
(layout) => void |
none | |
elevation |
"auto" | "sunken" | "flat" | "raised" | "floating" |
"auto" |
|
LayoutPickerToast |
defaultOpen |
boolean (skip the cookie check) |
cookie unset |
value / defaultValue |
selected layout (Vue v-model, Svelte bind:value) |
"dashboard" |
|
onValueChange / onClose |
callbacks (Vue emits update:modelValue / close) |
none | |
title / description |
ReactNode |
"Choose your layout" / "You can switch any time." |
|
elevation |
"auto" | "sunken" | "flat" | "raised" | "floating" |
"auto" |
|
| helpers | getLayoutCookie(cookie?) / setLayoutCookie(layout) |
cookie edmi-layout |
The toast renders nothing once the cookie exists. Picking a card saves it; closing without picking saves the default (dashboard) so the toast does not return. Read the cookie on the server (getLayoutCookie(request.headers.get("cookie") ?? "")) to render the right shell without a flash (DESIGN §3, §6).
✦ Edmi additions
Section titled “✦ Edmi additions”- Choice cards are native radios (arrow keys work).