Domphy

sidebarInDialog

A Sidebar block/component from shadcn/ui — clean-room reimplemented for Domphy (see methodology). Call sidebarInDialog() with no arguments for a working demo, or edit the code below live.

Implementation notes

Settings-style modal (trigger button + <dialog>) with an embedded two-pane layout. Nav-column visibility uses a CSS @container query (containerType:'inline-size' on the dialog) rather than @media, so 'hidden on narrow dialog widths' tracks the dialog's own rendered width, not the viewport, matching the spec literally. Fade+scale entrance: the base dialog() patch only animates opacity (rAF-deferred so the browser paints the 0-state before transitioning), so a second _onMount hook was composed on the same element (Domphy hooks compose via addHook, don't override) driving transform: scale() on the same open state with the same rAF-deferral technique, keeping dialog()'s open/close/focus-trap/scroll-lock/Escape mechanics as the single source of truth. Category content swap is instant (style.display toggle keyed off active-category id) with a ~100ms background-color transition on the active nav row for polish, matching the 'no animation on selection' note. Default content renderer produces the same generic 10-skeleton-row body for every category (per researchNote: 'not a fixed requirement'); callers can override per-category via renderContent(categoryId).

Status: ported · Reference: shadcn/ui original

// shadcn/ui "sidebar-in-dialog" block — clean-room reimplementation from the
// public behavior/visual spec only (no upstream source viewed). A
// settings-style modal: a trigger opens a centered dialog that embeds its own
// compact two-pane layout — a narrow category list on the left (hidden on
// narrow dialog widths, revealed via a `@container` query so the breakpoint
// tracks the dialog's own rendered width, not the viewport) and a scrollable
// content pane on the right whose header/body swap instantly when a category
// is selected. This is a self-contained trigger+dialog pair, not a page shell
// like the other sidebar-0N blocks.

import type { DomphyElement, ElementNode, Listener, ValueOrState } from "@domphy/core";
import { toState } from "@domphy/core";
import { breadcrumb, button, dialog, icon, skeleton, small, strong } from "@domphy/ui";
import { themeColor, themeDensity, themeSpacing } from "@domphy/theme";

/** One row in the settings category list. */
interface SettingsCategory {
  id: string;
  label: string;
  /** Raw inline SVG markup (24x24, stroke=currentColor), same convention as the rest of the family. */
  icon: string;
}

type SidebarInDialogProps = {
  categories?: SettingsCategory[];
  /** Category selected when the dialog first opens. Defaults to the researched default ("messages"). */
  defaultCategoryId?: string;
  /** Per-category body renderer. Defaults to 10 stacked skeleton placeholder rows. */
  renderContent?: (categoryId: string) => DomphyElement | DomphyElement[];
  /** Dialog open state — pass a `State<boolean>` for controlled usage. Defaults to closed. */
  open?: ValueOrState<boolean>;
  /** Accessible dialog title (also used as the breadcrumb root segment). */
  title?: string;
  /** Accessible dialog description (visually hidden, read by screen readers only). */
  description?: string;
  /** Content of the button that opens the dialog. */
  triggerLabel?: string;
};

// ---------------------------------------------------------------------------
// Hand-authored generic line icons (24x24, stroke=currentColor) — simple
// geometric shapes, not sourced from any icon library.
// ---------------------------------------------------------------------------

const ICON_BELL =
  '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="1em" height="1em"><path d="M6 8a6 6 0 0 1 12 0c0 4 1.5 5.5 2 6H4c.5-.5 2-2 2-6z"/><path d="M9.5 18a2.5 2.5 0 0 0 5 0"/></svg>';

const ICON_COMPASS =
  '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="1em" height="1em"><circle cx="12" cy="12" r="9"/><path d="M15 9l-2 6-6 2 2-6z"/></svg>';

const ICON_HOME =
  '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="1em" height="1em"><path d="M4 11l8-7 8 7"/><path d="M6 10v9a1 1 0 0 0 1 1h4v-6h2v6h4a1 1 0 0 0 1-1v-9"/></svg>';

const ICON_APPEARANCE =
  '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="1em" height="1em"><circle cx="12" cy="12" r="9"/><path d="M9 9h.01M15 8h.01M16 13h.01M8.5 14h.01"/></svg>';

const ICON_MESSAGE =
  '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="1em" height="1em"><path d="M4 5h16v11H8l-4 4z"/></svg>';

const ICON_GLOBE =
  '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="1em" height="1em"><circle cx="12" cy="12" r="9"/><ellipse cx="12" cy="12" rx="4" ry="9"/><path d="M3 12h18"/></svg>';

const ICON_ACCESSIBILITY =
  '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="1em" height="1em"><circle cx="12" cy="12" r="9"/><circle cx="12" cy="8.5" r="1.4"/><path d="M8 11.5l4 1 4-1M12 12.5v3.5M9.7 19.5l2.3-3.5 2.3 3.5"/></svg>';

const ICON_CHECK =
  '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="1em" height="1em"><path d="M5 12l5 5 9-10"/></svg>';

const ICON_AV =
  '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="1em" height="1em"><path d="M4 14v-2a8 8 0 0 1 16 0v2"/><rect x="3" y="14" width="4" height="6" rx="1"/><rect x="17" y="14" width="4" height="6" rx="1"/></svg>';

const ICON_CONNECTED =
  '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="1em" height="1em"><path d="M9 15l6-6"/><path d="M8 12l-2 2a3 3 0 0 0 4 4l2-2"/><path d="M16 12l2-2a3 3 0 0 0-4-4l-2 2"/></svg>';

const ICON_EYE =
  '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="1em" height="1em"><path d="M2 12s4-7 10-7 10 7 10 7-4 7-10 7-10-7-10-7z"/><circle cx="12" cy="12" r="3"/></svg>';

const ICON_SLIDERS =
  '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="1em" height="1em"><line x1="5" y1="4" x2="5" y2="20"/><line x1="12" y1="4" x2="12" y2="20"/><line x1="19" y1="4" x2="19" y2="20"/><circle cx="5" cy="9" r="2"/><circle cx="12" cy="15" r="2"/><circle cx="19" cy="7" r="2"/></svg>';

const ICON_CLOSE =
  '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="1em" height="1em"><path d="M6 6l12 12M18 6L6 18"/></svg>';

const DEFAULT_CATEGORIES: SettingsCategory[] = [
  { id: "notifications", label: "Notifications", icon: ICON_BELL },
  { id: "navigation", label: "Navigation", icon: ICON_COMPASS },
  { id: "home", label: "Home", icon: ICON_HOME },
  { id: "appearance", label: "Appearance", icon: ICON_APPEARANCE },
  { id: "messages", label: "Messages & media", icon: ICON_MESSAGE },
  { id: "language", label: "Language & region", icon: ICON_GLOBE },
  { id: "accessibility", label: "Accessibility", icon: ICON_ACCESSIBILITY },
  { id: "mark-read", label: "Mark as read", icon: ICON_CHECK },
  { id: "audio-video", label: "Audio & video", icon: ICON_AV },
  { id: "connected", label: "Connected accounts", icon: ICON_CONNECTED },
  { id: "privacy", label: "Privacy & visibility", icon: ICON_EYE },
  { id: "advanced", label: "Advanced", icon: ICON_SLIDERS },
];

const SR_ONLY_STYLE = {
  position: "absolute",
  width: "1px",
  height: "1px",
  padding: "0",
  margin: "-1px",
  overflow: "hidden",
  clip: "rect(0, 0, 0, 0)",
  whiteSpace: "nowrap",
  border: "0",
} as const;

const DESCRIPTION_ID = "sidebar-in-dialog-description";

/** Ten stacked rounded placeholder blocks — stand-ins for real settings fields. */
function defaultCategoryContent(): DomphyElement<"div">[] {
  return Array.from({ length: 10 }, (_unused, index) => ({
    div: null,
    _key: `field-${index}`,
    $: [skeleton()],
    style: { height: themeSpacing(10), width: index % 3 === 2 ? "60%" : "100%" },
  })) as DomphyElement<"div">[];
}

/** A single category row: icon + label, soft-highlighted background while active. */
function categoryRow(
  category: SettingsCategory,
  activeCategoryId: ReturnType<typeof toState<string>>,
  onSelect: (id: string) => void,
): DomphyElement<"li"> {
  return {
    li: [
      {
        button: [
          { span: category.icon, $: [icon({ color: "neutral" })] } as unknown as DomphyElement,
          { span: category.label, style: { flex: "1", textAlign: "left" } } as unknown as DomphyElement,
        ],
        type: "button",
        ariaCurrent: (l: Listener) => (activeCategoryId.get(l) === category.id ? "true" : undefined),
        onClick: () => onSelect(category.id),
        style: {
          display: "flex",
          alignItems: "center",
          width: "100%",
          gap: (l: Listener) => themeSpacing(themeDensity(l) * 2),
          paddingBlock: (l: Listener) => themeSpacing(themeDensity(l) * 2),
          paddingInline: (l: Listener) => themeSpacing(themeDensity(l) * 3),
          borderRadius: (l: Listener) => themeSpacing(themeDensity(l) * 1),
          border: "none",
          cursor: "pointer",
          textAlign: "left",
          transition: "background-color 100ms ease",
          color: (l: Listener) => themeColor(l, "shift-9", "neutral"),
          backgroundColor: (l: Listener) => themeColor(l, "inherit", "neutral"),
          "&:hover": { backgroundColor: (l: Listener) => themeColor(l, "shift-2", "neutral") },
          "&[aria-current=true]": {
            backgroundColor: (l: Listener) => themeColor(l, "shift-3", "neutral"),
            color: (l: Listener) => themeColor(l, "shift-11", "neutral"),
          },
        },
      } as unknown as DomphyElement,
    ],
    _key: category.id,
  } as DomphyElement<"li">;
}

/**
 * shadcn/ui "sidebar-in-dialog" — a settings dialog with its own embedded
 * two-pane layout: a compact category list on the left, matching scrollable
 * content on the right. Category selection is a pure local state change (no
 * navigation, no content-swap animation). Call with no arguments for a fully
 * working demo (trigger button + dialog, 12 default categories).
 */
function sidebarInDialog(props: SidebarInDialogProps = {}): DomphyElement<"div"> {
  const {
    categories = DEFAULT_CATEGORIES,
    title = "Settings",
    description = "Manage your account settings and preferences.",
    triggerLabel = "Open settings",
  } = props;
  const defaultCategoryId =
    props.defaultCategoryId ?? categories.find((category) => category.id === "messages")?.id ?? categories[0]?.id ?? "";
  const renderContent = props.renderContent ?? (() => defaultCategoryContent());

  const open = toState(props.open ?? false);
  const activeCategoryId = toState(defaultCategoryId);

  const currentLabel = (l: Listener): string =>
    categories.find((category) => category.id === activeCategoryId.get(l))?.label ?? "";

  const navColumn: DomphyElement<"nav"> = {
    nav: [
      {
        ul: categories.map((category) => categoryRow(category, activeCategoryId, (id) => activeCategoryId.set(id))),
        style: {
          listStyle: "none",
          margin: "0",
          padding: "0",
          display: "flex",
          flexDirection: "column",
          gap: themeSpacing(0.5),
        },
      } as unknown as DomphyElement,
    ],
    ariaLabel: "Settings categories",
    style: {
      display: "none",
      flexShrink: "0",
      width: themeSpacing(56),
      overflowY: "auto",
      padding: (l: Listener) => themeSpacing(themeDensity(l) * 3),
      borderInlineEnd: (l: Listener) => `1px solid ${themeColor(l, "shift-3", "neutral")}`,
      color: (l: Listener) => themeColor(l, "shift-9", "neutral"),
      backgroundColor: (l: Listener) => themeColor(l, "inherit", "neutral"),
      "@container (min-width: 30em)": { display: "flex", flexDirection: "column" },
    },
  } as unknown as DomphyElement<"nav">;

  const headerBar: DomphyElement<"div"> = {
    div: [
      {
        nav: [
          { small: title, $: [small({ color: "neutral" })] } as unknown as DomphyElement,
          {
            strong: (l: Listener) => currentLabel(l),
            ariaCurrent: "page",
            $: [strong({ color: "neutral" })],
          } as unknown as DomphyElement,
        ],
        $: [breadcrumb({ color: "neutral" })],
      } as unknown as DomphyElement,
      {
        button: { span: ICON_CLOSE, $: [icon({ color: "neutral" })] } as unknown as DomphyElement,
        type: "button",
        ariaLabel: "Close settings",
        onClick: () => open.set(false),
        style: {
          display: "flex",
          alignItems: "center",
          justifyContent: "center",
          flexShrink: "0",
          border: "none",
          cursor: "pointer",
          width: themeSpacing(8),
          height: themeSpacing(8),
          borderRadius: (l: Listener) => themeSpacing(themeDensity(l) * 1),
          color: (l: Listener) => themeColor(l, "shift-9", "neutral"),
          backgroundColor: (l: Listener) => themeColor(l, "inherit", "neutral"),
          "&:hover": { backgroundColor: (l: Listener) => themeColor(l, "shift-2", "neutral") },
        },
      } as unknown as DomphyElement,
    ],
    style: {
      display: "flex",
      alignItems: "center",
      justifyContent: "space-between",
      flexShrink: "0",
      gap: (l: Listener) => themeSpacing(themeDensity(l) * 3),
      height: themeSpacing(12),
      paddingInline: (l: Listener) => themeSpacing(themeDensity(l) * 4),
      borderBottom: (l: Listener) => `1px solid ${themeColor(l, "shift-3", "neutral")}`,
      color: (l: Listener) => themeColor(l, "shift-9", "neutral"),
      backgroundColor: (l: Listener) => themeColor(l, "inherit", "neutral"),
    },
  } as unknown as DomphyElement<"div">;

  const bodyScroll: DomphyElement<"div"> = {
    div: categories.map((category) => ({
      div: renderContent(category.id),
      _key: category.id,
      style: {
        display: (l: Listener) => (activeCategoryId.get(l) === category.id ? "flex" : "none"),
        flexDirection: "column",
        gap: (l: Listener) => themeSpacing(themeDensity(l) * 3),
      },
    })) as unknown as DomphyElement[],
    style: {
      flex: "1",
      minHeight: "0",
      overflowY: "auto",
      padding: (l: Listener) => themeSpacing(themeDensity(l) * 4),
      color: (l: Listener) => themeColor(l, "shift-9", "neutral"),
      backgroundColor: (l: Listener) => themeColor(l, "inherit", "neutral"),
    },
  } as unknown as DomphyElement<"div">;

  const contentColumn: DomphyElement<"div"> = {
    div: [headerBar, bodyScroll],
    style: { display: "flex", flexDirection: "column", flex: "1", minWidth: "0", overflow: "hidden" },
  } as unknown as DomphyElement<"div">;

  const paneRow: DomphyElement<"div"> = {
    div: [navColumn, contentColumn],
    style: { display: "flex", flexDirection: "row", flex: "1", minHeight: "0", overflow: "hidden" },
  } as unknown as DomphyElement<"div">;

  const dialogElement: DomphyElement<"dialog"> = {
    dialog: [
      { p: description, id: DESCRIPTION_ID, style: SR_ONLY_STYLE } as unknown as DomphyElement,
      paneRow,
    ],
    ariaLabel: title,
    ariaDescribedby: DESCRIPTION_ID,
    $: [dialog({ open, color: "neutral" })],
    // Scale entrance/exit layered on top of the base patch's opacity fade —
    // driven by the same `open` state, mirroring the patch's own rAF-deferred
    // opacity toggle so both properties animate together. This adds a purely
    // decorative style effect; the dialog patch itself owns all open/close
    // mechanics (single source of truth for focus trap/scroll lock/escape).
    _onMount: (node: ElementNode) => {
      const element = node.domElement as HTMLDialogElement;
      const update = (isOpen: boolean) => {
        if (isOpen) {
          requestAnimationFrame(() => {
            element.style.transform = "scale(1)";
          });
        } else {
          element.style.transform = "scale(0.95)";
        }
      };
      update(open.get());
      const release = open.addListener(update);
      node.addHook("Remove", () => release());
    },
    style: {
      display: "flex",
      flexDirection: "column",
      padding: "0",
      width: "92vw",
      maxWidth: themeSpacing(190),
      height: themeSpacing(125),
      maxHeight: "88vh",
      overflow: "hidden",
      containerType: "inline-size",
      borderRadius: (l: Listener) => themeSpacing(themeDensity(l) * 3),
      transform: "scale(0.95)",
      transition: "opacity 180ms ease-out, transform 180ms ease-out",
    },
  } as unknown as DomphyElement<"dialog">;

  const triggerElement: DomphyElement<"button"> = {
    button: triggerLabel,
    type: "button",
    onClick: () => open.set(true),
    $: [button({ color: "primary" })],
  } as unknown as DomphyElement<"button">;

  return {
    div: [triggerElement, dialogElement],
  } as DomphyElement<"div">;
}

export { sidebarInDialog };
export type { SidebarInDialogProps, SettingsCategory };

← Back to shadcn/ui catalog