Skip to content
Domphy

sidebar09

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

Props

PropTypeDescription
foldersSidebar09Folder[]
messagesSidebar09Message[]
activeFolderIdstring
activeMessageIdstring | null
userSidebar09User
breadcrumbItemsSidebarBreadcrumbItem[]
onFolderSelect(folderId: string) => void
onMessageSelect(messageId: string) => void
childrenDomphyElement | DomphyElement[]
Implementation notes

Nested icon-rail + message-list mail layout. Implemented: folder-icon rail with active highlight, instant folder-switch re-render, live search filter, 'Unreads' inputSwitch filter, 2-line clamped preview rows with unread dot + selected-row highlight, main header with SidebarTrigger/breadcrumb, icon-rail CSS width-transition collapse, and a mobile behavior where the message-list panel is hidden by default and revealed (as a fixed overlay with its own close button) when a folder is tapped. Gap: the main content stays the shared placeholder panel (no reading-pane wired to the selected message, consistent with every other sidebar in this family, which all use the same generic main-content slot). Direct-source-diff fix (2026-07-05): Message-list header was a static 'Acme Mail' string — upstream reactively shows the active folder's title. Fixed.

Status: ported · Reference: shadcn/ui original

// shadcn/ui "sidebar-09" — an email-client-style layout mirroring upstream
// sidebar-09's documented behavior: a narrow icon-only folder rail nested
// beside a wider message-list panel, both to the left of the main content
// inset. Upstream keeps ONE shared mail pool that every folder button reshuffles
// (random 5–10 slice) rather than per-folder mailboxes; its search box and
// "Unreads" switch are decorative. See ./sidebar09-12-shared.ts.

import type { DomphyElement, Listener, State } from "@domphy/core";
import { toState } from "@domphy/core";
import { themeColor, themeDensity, themeSpacing } from "@domphy/theme";
import {
  avatar,
  buttonGhost,
  inputSearch,
  inputSwitch,
  popover,
  small,
  strong,
} from "@domphy/ui";
import {
  ICON_DRAFTS,
  ICON_INBOX,
  ICON_JUNK,
  ICON_MARK,
  ICON_SEND,
  ICON_SPARKLE,
  ICON_TRASH,
  interactiveRowStyle,
  type SidebarBreadcrumbItem,
  sidebarIcon,
  sidebarMainContent,
  sidebarStickyHeader,
  srOnlyLabel,
} from "./sidebar09-12-shared.js";

type Sidebar09Folder = { id: string; label: string; icon: string };
type Sidebar09Message = {
  id: string;
  sender: string;
  timestamp: string;
  subject: string;
  preview: string;
};
type Sidebar09User = { name: string; email: string };

type Sidebar09Props = {
  folders?: Sidebar09Folder[];
  messages?: Sidebar09Message[];
  activeFolderId?: string;
  activeMessageId?: string | null;
  user?: Sidebar09User;
  breadcrumbItems?: SidebarBreadcrumbItem[];
  onFolderSelect?: (folderId: string) => void;
  onMessageSelect?: (messageId: string) => void;
  children?: DomphyElement | DomphyElement[];
};

// Account-menu icons the NavUser dropdown needs (lucide equivalents: Sparkles is
// shared as ICON_SPARKLE; the rest are hand-authored generic line glyphs).
const SVG_OPEN =
  '<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">';
const ICON_BADGE_CHECK = `${SVG_OPEN}<path d="M3.85 8.62a4 4 0 0 1 4.78-4.77 4 4 0 0 1 6.74 0 4 4 0 0 1 4.78 4.78 4 4 0 0 1 0 6.74 4 4 0 0 1-4.77 4.78 4 4 0 0 1-6.75 0 4 4 0 0 1-4.78-4.77 4 4 0 0 1 0-6.76Z"/><path d="m9 12 2 2 4-4"/></svg>`;
const ICON_CREDIT_CARD = `${SVG_OPEN}<rect width="20" height="14" x="2" y="5" rx="2"/><line x1="2" x2="22" y1="10" y2="10"/></svg>`;
const ICON_BELL = `${SVG_OPEN}<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/></svg>`;
const ICON_LOGOUT = `${SVG_OPEN}<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" x2="9" y1="12" y2="12"/></svg>`;

const DEFAULT_FOLDERS: Sidebar09Folder[] = [
  { id: "inbox", label: "Inbox", icon: ICON_INBOX },
  { id: "drafts", label: "Drafts", icon: ICON_DRAFTS },
  { id: "sent", label: "Sent", icon: ICON_SEND },
  { id: "junk", label: "Junk", icon: ICON_JUNK },
  { id: "trash", label: "Trash", icon: ICON_TRASH },
];

// Upstream keeps a single flat pool of conversational mails (no per-folder
// mailbox, no read/unread state); every folder button reshuffles this same pool.
const DEFAULT_MESSAGES: Sidebar09Message[] = [
  {
    id: "m1",
    sender: "William Smith",
    timestamp: "09:34 AM",
    subject: "Meeting Tomorrow",
    preview:
      "Hi team, just a reminder about our meeting tomorrow at 10 AM.\nPlease come prepared with your project updates.",
  },
  {
    id: "m2",
    sender: "Alice Smith",
    timestamp: "Yesterday",
    subject: "Re: Project Update",
    preview:
      "Thanks for the update. The progress looks great so far.\nLet's schedule a call to discuss the next steps.",
  },
  {
    id: "m3",
    sender: "Bob Johnson",
    timestamp: "2 days ago",
    subject: "Weekend Plans",
    preview:
      "Hey everyone! I'm thinking of organizing a team outing this weekend.\nWould you be interested in a hiking trip or a beach day?",
  },
  {
    id: "m4",
    sender: "Emily Davis",
    timestamp: "2 days ago",
    subject: "Re: Question about Budget",
    preview:
      "I've reviewed the budget numbers you sent over.\nCan we set up a quick call to discuss some potential adjustments?",
  },
  {
    id: "m5",
    sender: "Michael Wilson",
    timestamp: "1 week ago",
    subject: "Important Announcement",
    preview:
      "Please join us for an all-hands meeting this Friday at 3 PM.\nWe have some exciting news to share about the company's future.",
  },
  {
    id: "m6",
    sender: "Sarah Brown",
    timestamp: "1 week ago",
    subject: "Re: Feedback on Proposal",
    preview:
      "Thank you for sending over the proposal. I've reviewed it and have some thoughts.\nCould we schedule a meeting to discuss my feedback in detail?",
  },
  {
    id: "m7",
    sender: "David Lee",
    timestamp: "1 week ago",
    subject: "New Project Idea",
    preview:
      "I've been brainstorming and came up with an interesting project concept.\nDo you have time this week to discuss its potential impact and feasibility?",
  },
  {
    id: "m8",
    sender: "Olivia Wilson",
    timestamp: "1 week ago",
    subject: "Vacation Plans",
    preview:
      "Just a heads up that I'll be taking a two-week vacation next month.\nI'll make sure all my projects are up to date before I leave.",
  },
  {
    id: "m9",
    sender: "James Martin",
    timestamp: "1 week ago",
    subject: "Re: Conference Registration",
    preview:
      "I've completed the registration for the upcoming tech conference.\nLet me know if you need any additional information from my end.",
  },
  {
    id: "m10",
    sender: "Sophia White",
    timestamp: "1 week ago",
    subject: "Team Dinner",
    preview:
      "To celebrate our recent project success, I'd like to organize a team dinner.\nAre you available next Friday evening? Please let me know your preferences.",
  },
];

const DEFAULT_USER: Sidebar09User = {
  name: "Shad Cn",
  email: "shadcn@example.com",
};

/** Upstream's folder click: reshuffle the shared pool and take a random 5–10. */
function shuffledSlice(pool: Sidebar09Message[]): Sidebar09Message[] {
  const shuffled = [...pool].sort(() => Math.random() - 0.5);
  const count = Math.max(5, Math.floor(Math.random() * 10) + 1);
  return shuffled.slice(0, count);
}

/** Compact icon-badge logo header at the top of the icon rail. Upstream keeps
 * the "Acme Inc"/"Enterprise" text next to the badge; at icon-rail width it's
 * clipped (overflow hidden) exactly as upstream truncates it. */
function railLogo(): DomphyElement<"div"> {
  return {
    div: [
      {
        div: [
          sidebarIcon(ICON_MARK),
          {
            div: [
              {
                strong: "Acme Inc",
                style: {
                  overflow: "hidden",
                  textOverflow: "ellipsis",
                  whiteSpace: "nowrap",
                },
                $: [strong({ color: "neutral" })],
              } as unknown as DomphyElement,
              {
                small: "Enterprise",
                style: {
                  overflow: "hidden",
                  textOverflow: "ellipsis",
                  whiteSpace: "nowrap",
                },
                $: [small({ color: "neutral" })],
              } as unknown as DomphyElement,
            ],
            style: {
              display: "flex",
              flexDirection: "column",
              minWidth: "0",
              overflow: "hidden",
              textAlign: "left",
            },
          } as unknown as DomphyElement,
        ],
        style: {
          display: "flex",
          alignItems: "center",
          justifyContent: "center",
          gap: themeSpacing(2),
          width: "100%",
          overflow: "hidden",
        },
      } as unknown as DomphyElement,
    ],
    style: {
      display: "flex",
      alignItems: "center",
      justifyContent: "center",
      height: themeSpacing(14),
      flexShrink: "0",
      borderBottom: (l: Listener) =>
        `1px solid ${themeColor(l, "shift-3", "neutral")}`,
      color: (l: Listener) => themeColor(l, "shift-9", "neutral"),
    },
  } as unknown as DomphyElement<"div">;
}

function folderRailButton(
  folder: Sidebar09Folder,
  activeFolderId: State<string>,
  onSelect: (id: string) => void,
): DomphyElement<"li"> {
  return {
    li: [
      {
        button: [sidebarIcon(folder.icon)],
        type: "button",
        ariaLabel: folder.label,
        ariaCurrent: (l: Listener) =>
          activeFolderId.get(l) === folder.id ? "true" : undefined,
        onClick: () => onSelect(folder.id),
        style: {
          display: "flex",
          alignItems: "center",
          justifyContent: "center",
          width: themeSpacing(10),
          height: themeSpacing(10),
          border: "none",
          cursor: "pointer",
          borderRadius: (l: Listener) => themeSpacing(themeDensity(l) * 2),
          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-2", "neutral"),
            color: (l: Listener) => themeColor(l, "shift-12", "neutral"),
          },
        },
      } as unknown as DomphyElement,
    ],
    _key: folder.id,
  } as DomphyElement<"li">;
}

/** One icon + label row inside the account dropdown (upstream DropdownMenuItem). */
function accountMenuItem(icon: string, label: string): DomphyElement {
  return {
    button: [
      sidebarIcon(icon),
      {
        span: label,
        style: { flex: "1", textAlign: "left" },
      } as unknown as DomphyElement,
    ],
    type: "button",
    role: "menuitem",
    style: interactiveRowStyle(true),
  } as unknown as DomphyElement;
}

/**
 * The NavUser account dropdown, matching upstream nav-user.tsx: a header label
 * (avatar + name + email), then separator-delimited groups — "Upgrade to Pro",
 * then Account/Billing/Notifications, then "Log out" — each row with its icon.
 */
function accountDropdown(user: Sidebar09User): DomphyElement<"div"> {
  const groups: { icon: string; label: string }[][] = [
    [{ icon: ICON_SPARKLE, label: "Upgrade to Pro" }],
    [
      { icon: ICON_BADGE_CHECK, label: "Account" },
      { icon: ICON_CREDIT_CARD, label: "Billing" },
      { icon: ICON_BELL, label: "Notifications" },
    ],
    [{ icon: ICON_LOGOUT, label: "Log out" }],
  ];

  const header: DomphyElement = {
    div: [
      {
        span: user.name.slice(0, 1).toUpperCase(),
        $: [avatar({ color: "neutral" })],
      } as unknown as DomphyElement,
      {
        div: [
          {
            strong: user.name,
            style: {
              overflow: "hidden",
              textOverflow: "ellipsis",
              whiteSpace: "nowrap",
            },
            $: [strong({ color: "neutral" })],
          } as unknown as DomphyElement,
          {
            small: user.email,
            style: {
              overflow: "hidden",
              textOverflow: "ellipsis",
              whiteSpace: "nowrap",
            },
            $: [small({ color: "neutral" })],
          } as unknown as DomphyElement,
        ],
        style: {
          display: "flex",
          flexDirection: "column",
          minWidth: "0",
          overflow: "hidden",
          textAlign: "left",
        },
      } as unknown as DomphyElement,
    ],
    style: {
      display: "flex",
      alignItems: "center",
      gap: themeSpacing(2),
      paddingInline: (l: Listener) => themeSpacing(themeDensity(l) * 2),
      paddingBlock: (l: Listener) => themeSpacing(themeDensity(l) * 1.5),
      borderBottom: (l: Listener) =>
        `1px solid ${themeColor(l, "shift-3", "neutral")}`,
    },
  } as unknown as DomphyElement;

  const sections = groups.map((items, groupIndex) => ({
    div: items.map((item) => accountMenuItem(item.icon, item.label)),
    // `role="menu"` (below) requires menu-item-family children or a "group"
    // wrapper; this section div carries "group" so each `menuitem` button one
    // level down has a valid parent.
    role: "group",
    style: {
      display: "flex",
      flexDirection: "column",
      paddingBlock: (l: Listener) => themeSpacing(themeDensity(l) * 1),
      paddingInline: (l: Listener) => themeSpacing(themeDensity(l) * 1),
      borderBottom:
        groupIndex < groups.length - 1
          ? (l: Listener) => `1px solid ${themeColor(l, "shift-3", "neutral")}`
          : undefined,
    },
    _key: groupIndex,
  })) as unknown as DomphyElement[];

  return {
    div: [header, ...sections],
    dataTone: "shift-0",
    role: "menu",
    style: {
      display: "flex",
      flexDirection: "column",
      minWidth: themeSpacing(56),
      borderRadius: (l: Listener) => themeSpacing(themeDensity(l) * 2),
      border: (l: Listener) =>
        `1px solid ${themeColor(l, "shift-3", "neutral")}`,
      boxShadow: (l: Listener) =>
        `0 ${themeSpacing(2)} ${themeSpacing(8)} ${themeColor(l, "shift-4", "neutral")}`,
      overflow: "hidden",
      backgroundColor: (l: Listener) => themeColor(l, "inherit", "neutral"),
      color: (l: Listener) => themeColor(l, "shift-9", "neutral"),
    },
  } as unknown as DomphyElement<"div">;
}

/** Round account-avatar footer button at the bottom of the icon rail. */
function railFooter(user: Sidebar09User): DomphyElement<"div"> {
  return {
    div: [
      {
        button: [
          {
            span: user.name.slice(0, 1).toUpperCase(),
            $: [avatar({ color: "neutral" })],
          } as unknown as DomphyElement,
        ],
        type: "button",
        ariaLabel: "Account menu",
        style: {
          display: "flex",
          border: "none",
          background: "none",
          cursor: "pointer",
          padding: "0",
        },
        $: [
          popover({ placement: "right-end", content: accountDropdown(user) }),
        ],
      } as unknown as DomphyElement,
    ],
    style: {
      display: "flex",
      alignItems: "center",
      justifyContent: "center",
      paddingBlock: (l: Listener) => themeSpacing(themeDensity(l) * 3),
      flexShrink: "0",
      borderTop: (l: Listener) =>
        `1px solid ${themeColor(l, "shift-3", "neutral")}`,
      color: (l: Listener) => themeColor(l, "shift-9", "neutral"),
    },
  } as unknown as DomphyElement<"div">;
}

function messageListHeader(
  title: (listener: Listener) => string,
  onCloseMobile: () => void,
): DomphyElement<"div"> {
  return {
    div: [
      {
        div: [
          // Upstream's second-sidebar header shows the active folder's title
          // (it re-renders on folder switch), not a static workspace name.
          {
            strong: (l: Listener) => title(l),
            $: [strong({ color: "neutral" })],
          } as unknown as DomphyElement,
          {
            div: [
              {
                label: [
                  {
                    small: "Unreads",
                    $: [small({ color: "neutral" })],
                  } as unknown as DomphyElement,
                  // Decorative switch (upstream <Switch className="shadow-none" />
                  // has no handler/state and does not filter the list).
                  {
                    input: null,
                    id: "sidebar09-unreads-toggle",
                    type: "checkbox",
                    $: [inputSwitch({ accentColor: "neutral" })],
                  } as unknown as DomphyElement,
                ],
                htmlFor: "sidebar09-unreads-toggle",
                style: {
                  display: "flex",
                  alignItems: "center",
                  gap: themeSpacing(2),
                },
              } as unknown as DomphyElement,
              {
                button: "×",
                type: "button",
                ariaLabel: "Close message list",
                onClick: onCloseMobile,
                style: {
                  display: "none",
                  "@media (max-width: 768px)": { display: "inline-flex" },
                },
                $: [buttonGhost({ color: "neutral" })],
              } as unknown as DomphyElement,
            ],
            style: {
              display: "flex",
              alignItems: "center",
              gap: themeSpacing(2),
              flexShrink: "0",
            },
          } as unknown as DomphyElement,
        ],
        style: {
          display: "flex",
          alignItems: "center",
          justifyContent: "space-between",
          paddingInline: (l: Listener) => themeSpacing(themeDensity(l) * 4),
          paddingBlock: (l: Listener) => themeSpacing(themeDensity(l) * 2),
        },
      } as unknown as DomphyElement,
      {
        div: [
          // Decorative search box (upstream <SidebarInput placeholder="Type to
          // search..." /> is uncontrolled and does not filter the list).
          srOnlyLabel("Search", "sidebar09-message-search"),
          {
            input: null,
            id: "sidebar09-message-search",
            type: "search",
            placeholder: "Type to search...",
            style: { width: "100%" },
            $: [inputSearch({ color: "neutral", accentColor: "neutral" })],
          } as unknown as DomphyElement,
        ],
        style: {
          paddingInline: (l: Listener) => themeSpacing(themeDensity(l) * 4),
          paddingBlock: (l: Listener) => themeSpacing(themeDensity(l) * 3),
        },
      } as unknown as DomphyElement,
    ],
    style: {
      display: "flex",
      flexDirection: "column",
      flexShrink: "0",
      borderBottom: (l: Listener) =>
        `1px solid ${themeColor(l, "shift-3", "neutral")}`,
      color: (l: Listener) => themeColor(l, "shift-9", "neutral"),
    },
  } as unknown as DomphyElement<"div">;
}

function messageRow(
  message: Sidebar09Message,
  selected: boolean,
  onSelect: (id: string) => void,
): DomphyElement<"li"> {
  return {
    li: [
      {
        button: [
          {
            div: [
              {
                strong: message.sender,
                $: [strong({ color: "neutral" })],
              } as unknown as DomphyElement,
              {
                small: message.timestamp,
                style: { marginInlineStart: "auto", flexShrink: "0" },
                $: [small({ color: "neutral" })],
              } as unknown as DomphyElement,
            ],
            style: {
              display: "flex",
              alignItems: "center",
              gap: themeSpacing(2),
              width: "100%",
            },
          } as unknown as DomphyElement,
          {
            strong: message.subject,
            style: {
              display: "block",
              width: "100%",
              overflow: "hidden",
              textOverflow: "ellipsis",
              whiteSpace: "nowrap",
            },
            $: [strong({ color: "neutral" })],
          } as unknown as DomphyElement,
          {
            small: message.preview,
            style: {
              display: "-webkit-box",
              WebkitLineClamp: "2",
              WebkitBoxOrient: "vertical",
              overflow: "hidden",
              whiteSpace: "break-spaces",
            },
            $: [small({ color: "neutral" })],
          } as unknown as DomphyElement,
        ],
        type: "button",
        role: "option",
        ariaSelected: selected,
        onClick: () => onSelect(message.id),
        style: {
          display: "flex",
          flexDirection: "column",
          gap: themeSpacing(1),
          width: "100%",
          textAlign: "left",
          border: "none",
          cursor: "pointer",
          paddingBlock: (l: Listener) => themeSpacing(themeDensity(l) * 3),
          paddingInline: (l: Listener) => themeSpacing(themeDensity(l) * 4),
          borderBottom: (l: Listener) =>
            `1px solid ${themeColor(l, "shift-2", "neutral")}`,
          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-selected=true]": {
            backgroundColor: (l: Listener) =>
              themeColor(l, "shift-2", "neutral"),
          },
        },
      } as unknown as DomphyElement,
    ],
    // The parent `<ul>` carries `role="listbox"` (see `buildMessageList`) —
    // `role="option"` (on the button above) requires its DIRECT parent to be
    // a listbox/group, but this `<li>` wrapper sits in between with its own
    // native "listitem" role, failing both `aria-required-parent` (option)
    // and `listitem` (the ul no longer reads as a plain list once it's a
    // listbox). `role="presentation"` strips the `<li>`'s own semantics so
    // ARIA parent/child computation skips over it to the `<ul>` beneath.
    role: "presentation",
    style: { "&:last-child > button": { borderBottom: "none" } },
    _key: message.id,
  } as DomphyElement<"li">;
}

function buildMessageList(
  displayedMessages: State<Sidebar09Message[]>,
  activeMessageId: State<string | null>,
  onSelect: (id: string) => void,
): DomphyElement<"ul"> {
  return {
    ul: (listener: Listener) => {
      const list = displayedMessages.get(listener);
      const selected = activeMessageId.get(listener);

      if (list.length === 0) {
        return [
          {
            li: [
              {
                small: "No messages",
                $: [small({ color: "neutral" })],
              } as unknown as DomphyElement,
            ],
            // Same reasoning as `messageRow`'s `<li>` — the parent `<ul>`
            // carries `role="listbox"`, so a native listitem role here would
            // fail axe-core's `listitem` check the same way.
            role: "presentation",
            _key: "empty",
            style: {
              padding: (l: Listener) => themeSpacing(themeDensity(l) * 4),
              listStyle: "none",
            },
          } as unknown as DomphyElement,
        ];
      }
      return list.map((message) =>
        messageRow(message, message.id === selected, onSelect),
      );
    },
    role: "listbox",
    ariaLabel: "Messages",
    style: {
      listStyle: "none",
      margin: "0",
      padding: "0",
      flex: "1",
      minHeight: "0",
      overflowY: "auto",
    },
  } as unknown as DomphyElement<"ul">;
}

/**
 * Nested-sidebar mail layout: a narrow icon-only folder rail beside a wider
 * message-list panel, both left of the main content inset. Call with no
 * arguments for a fully working demo.
 */
function sidebar09(props: Sidebar09Props = {}): DomphyElement<"div"> {
  const {
    folders = DEFAULT_FOLDERS,
    messages = DEFAULT_MESSAGES,
    user = DEFAULT_USER,
    breadcrumbItems = [{ label: "All Inboxes" }, { label: "Inbox" }],
    onFolderSelect,
    onMessageSelect,
    children,
  } = props;

  const railCollapsed = toState(false);
  const activeFolderId = toState(
    props.activeFolderId ?? folders[0]?.id ?? "inbox",
  );
  const activeMessageId = toState<string | null>(props.activeMessageId ?? null);
  // Upstream seeds the list with the full pool, then reshuffles it on each
  // folder click — folders are triggers over one shared mailbox, not scopes.
  const displayedMessages = toState<Sidebar09Message[]>(messages);
  const mobileListOpen = toState(false);

  const selectFolder = (id: string) => {
    activeFolderId.set(id);
    displayedMessages.set(shuffledSlice(messages));
    mobileListOpen.set(true);
    onFolderSelect?.(id);
  };
  const selectMessage = (id: string) => {
    activeMessageId.set(id);
    onMessageSelect?.(id);
  };

  const iconRail: DomphyElement<"aside"> = {
    aside: [
      railLogo(),
      {
        ul: folders.map((folder) =>
          folderRailButton(folder, activeFolderId, selectFolder),
        ),
        style: {
          listStyle: "none",
          margin: "0",
          padding: (l: Listener) => themeSpacing(themeDensity(l) * 2),
          display: "flex",
          flexDirection: "column",
          alignItems: "center",
          gap: themeSpacing(1),
          flex: "1",
          minHeight: "0",
          overflowY: "auto",
        },
      } as unknown as DomphyElement,
      railFooter(user),
    ],
    // `listPanel` below is also a plain `<aside>` with no name — without a
    // distinguishing label the two collide as duplicate "complementary"
    // landmarks (axe-core `landmark-unique`).
    ariaLabel: "Folder rail",
    style: {
      display: "flex",
      flexDirection: "column",
      flexShrink: "0",
      width: (l: Listener) => (railCollapsed.get(l) ? "0" : themeSpacing(14)),
      overflow: "hidden",
      transition: "width 180ms ease-out",
      borderInlineEnd: (l: Listener) =>
        `1px solid ${themeColor(l, "shift-3", "neutral")}`,
      color: (l: Listener) => themeColor(l, "shift-9", "neutral"),
    },
  } as unknown as DomphyElement<"aside">;

  const listPanel: DomphyElement<"aside"> = {
    aside: [
      messageListHeader(
        (l: Listener) =>
          folders.find((folder) => folder.id === activeFolderId.get(l))
            ?.label ?? "",
        () => mobileListOpen.set(false),
      ),
      buildMessageList(displayedMessages, activeMessageId, selectMessage),
    ],
    ariaLabel: "Message list",
    style: {
      display: "flex",
      flexDirection: "column",
      flexShrink: "0",
      width: themeSpacing(84),
      minHeight: "0",
      overflow: "hidden",
      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"),
      "@media (max-width: 768px)": {
        display: (l: Listener) => (mobileListOpen.get(l) ? "flex" : "none"),
        position: "fixed",
        insetBlock: "0",
        insetInlineStart: (l: Listener) =>
          railCollapsed.get(l) ? "0" : themeSpacing(14),
        zIndex: "16",
        width: themeSpacing(84),
        boxShadow: (l: Listener) =>
          `0 0 ${themeSpacing(6)} ${themeColor(l, "shift-4", "neutral")}`,
      },
    },
  } as unknown as DomphyElement<"aside">;

  const mainElement: DomphyElement<"main"> = {
    main: [
      sidebarStickyHeader({
        onToggle: () => railCollapsed.set(!railCollapsed.get()),
        breadcrumbItems,
      }),
      sidebarMainContent(children),
    ],
    style: {
      display: "flex",
      flexDirection: "column",
      flex: "1",
      minWidth: "0",
      minHeight: "0",
      overflow: "hidden",
      color: (l: Listener) => themeColor(l, "shift-9", "neutral"),
      backgroundColor: (l: Listener) => themeColor(l, "inherit", "neutral"),
    },
  } as unknown as DomphyElement<"main">;

  return {
    div: [iconRail, listPanel, mainElement],
    dataTone: "shift-0",
    style: {
      display: "flex",
      height: "100dvh",
      overflow: "hidden",
      position: "relative",
      backgroundColor: (l: Listener) => themeColor(l, "inherit", "neutral"),
      color: (l: Listener) => themeColor(l, "shift-9", "neutral"),
    },
  } as unknown as DomphyElement<"div">;
}

export { sidebar09 };
export type {
  Sidebar09Folder,
  Sidebar09Message,
  Sidebar09Props,
  Sidebar09User,
};

← Back to shadcn/ui catalog