sidebar12
A Sidebar block/component from shadcn/ui — clean-room reimplemented for Domphy (see methodology). Call sidebar12() with no arguments for a working demo, or edit the code below live.
Props
| Prop | Type | Description |
|---|---|---|
user | Sidebar12User | — |
selectedDate | Date | — |
groups | Sidebar12CalendarGroup[] | — |
onDateChange | (date: Date) => void | — |
onCalendarToggle | (groupLabel: string, entryId: string, checked: boolean) => void | — |
children | DomphyElement | DomphyElement[] | — |
Implementation notes
User header, a hand-built always-visible compact month-grid date picker (Sunday-first, prev/next month, adjacent-month days grayed + disabled, selected day as a filled accent circle) synced with the main header's period label/prev/next/today controls, three collapsible checkbox calendar groups (My Calendars/Favorites/Other) using inputCheckbox with a per-entry accent color, and a sticky sidebar with an independently scrolling main content area (position, not the height+overflow shell used by the rest of the family). Did not reuse @domphy/ui's datePicker() patch since that primitive is an input-triggered popover calendar, not an inline always-visible grid; built a minimal inline calendar instead. Per the spec's own research note, the main content event grid is an explicit open-ended placeholder (a plain day-number grid), not a real event/day view.
Status: ported · Reference: shadcn/ui original
// shadcn/ui "sidebar-12" — clean-room reimplementation from the public
// behavior description only (no upstream source viewed). A scheduling-app
// sidebar combining a compact month date-picker with grouped,
// checkbox-selectable calendar lists, sticky while the main content scrolls
// independently. See ./sidebar09-12-shared.ts and ./sidebar05-08-shared.ts.
import type { DomphyElement, Listener, State } from "@domphy/core";
import { rawHtml, toState } from "@domphy/core";
import {
type ThemeColor,
themeColor,
themeDensity,
themeSpacing,
} from "@domphy/theme";
import {
avatar,
icon,
inputCheckbox,
popover,
small,
strong,
} from "@domphy/ui";
import {
ICON_CHEVRON_RIGHT,
ICON_CHEVRONS_UPDOWN,
ICON_PANEL_TOGGLE,
ICON_PLUS,
ICON_SPARKLE,
interactiveRowStyle,
sidebarIcon,
verticalDivider,
} from "./sidebar09-12-shared.js";
type Sidebar12User = { name: string; email: string; avatarUrl?: string };
type Sidebar12CalendarEntry = { id: string; name: string; color: ThemeColor };
type Sidebar12CalendarGroup = {
label: string;
entries: Sidebar12CalendarEntry[];
};
type Sidebar12Props = {
user?: Sidebar12User;
selectedDate?: Date;
groups?: Sidebar12CalendarGroup[];
onDateChange?: (date: Date) => void;
onCalendarToggle?: (
groupLabel: string,
entryId: string,
checked: boolean,
) => void;
children?: DomphyElement | DomphyElement[];
};
const DEFAULT_USER: Sidebar12User = {
name: "Shad Cn",
email: "shadcn@example.com",
};
// Fixed reference date (not "today") so the zero-arg demo is deterministic —
// matches the spec's own example month/year.
const DEFAULT_DATE = new Date(2024, 9, 15);
const DEFAULT_GROUPS: Sidebar12CalendarGroup[] = [
{
label: "My Calendars",
entries: [
// Upstream checkboxes are the standard near-black primary, not per-calendar
// hues — keep every entry on the neutral accent.
{ id: "personal", name: "Personal", color: "neutral" },
{ id: "work", name: "Work", color: "neutral" },
{ id: "family", name: "Family", color: "neutral" },
],
},
{
label: "Favorites",
entries: [
{ id: "holidays", name: "Holidays", color: "neutral" },
{ id: "birthdays", name: "Birthdays", color: "neutral" },
],
},
{
label: "Other",
entries: [
{ id: "travel", name: "Travel", color: "neutral" },
{ id: "reminders", name: "Reminders", color: "neutral" },
{ id: "deadlines", name: "Deadlines", color: "neutral" },
],
},
];
const WEEKDAY_LABELS = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"];
// ---------------------------------------------------------------------------
// Account dropdown — upstream nav-user.tsx renders a user-info header plus five
// items in three separator-divided, icon-labeled groups. The shared
// renderUserFooter() only produces a plain 3-item Account/Billing/Log out menu,
// so this block builds its own footer + dropdown locally (see structured-output
// sharedFileSuspicion: the shared helper is the real root cause). Icons below
// are hand-authored generic line glyphs (24x24, stroke=currentColor), matching
// the clean-room icon style already used across this sidebar family — not
// sourced from any icon library.
// ---------------------------------------------------------------------------
const ICON_BADGE_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="M12 3l7 3v6c0 4-3 7-7 8-4-1-7-4-7-8V6z"/><path d="M9 12l2 2 4-4"/></svg>';
const ICON_CREDIT_CARD =
'<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"><rect x="2" y="5" width="20" height="14" rx="2"/><path d="M2 10h20"/></svg>';
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="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.5 21a1.5 1.5 0 0 1-3 0"/></svg>';
const ICON_LOG_OUT =
'<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 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><path d="M16 17l5-5-5-5"/><path d="M21 12H9"/></svg>';
function initialsOf(name: string): string {
const parts = name.trim().split(/\s+/).slice(0, 2);
return parts.map((part) => part[0]?.toUpperCase() ?? "").join("") || "?";
}
/** Avatar span: user image if present, otherwise two-letter initials fallback. */
function accountAvatar(user: Sidebar12User): DomphyElement<"span"> {
return user.avatarUrl
? ({
span: [
{
img: null,
src: user.avatarUrl,
alt: user.name,
} as unknown as DomphyElement,
],
$: [avatar({ color: "neutral" })],
} as unknown as DomphyElement<"span">)
: ({
span: initialsOf(user.name),
$: [avatar({ color: "neutral" })],
} as unknown as DomphyElement<"span">);
}
/** Two-line label (bold name + muted email) that clips instead of wrapping. */
function twoLineLabel(title: string, caption: string): DomphyElement<"div"> {
return {
div: [
{
strong: title,
// strong() paints an inherit-tone background; keep it transparent so
// no box appears over the host row's hover tint.
style: { backgroundColor: "transparent" },
$: [strong({ color: "neutral" })],
} as unknown as DomphyElement,
{
small: caption,
$: [small({ color: "neutral" })],
} as unknown as DomphyElement,
],
style: {
display: "flex",
flexDirection: "column",
gap: themeSpacing(0.5),
minWidth: "0",
overflow: "hidden",
whiteSpace: "nowrap",
},
} as unknown as DomphyElement<"div">;
}
type AccountMenuEntry = { icon: string; label: string };
/** Upstream nav-user.tsx dropdown groups (5 items across 3 separator-divided,
* icon-labeled groups): [Upgrade to Pro] / [Account, Billing, Notifications] /
* [Log out]. */
const ACCOUNT_MENU_GROUPS: AccountMenuEntry[][] = [
[{ 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_LOG_OUT, label: "Log out" }],
];
/** Account dropdown content: user-info header + separator, then the three
* icon-labeled item groups divided by separator hairlines — mirrors upstream
* nav-user.tsx exactly. */
function accountDropdownContent(user: Sidebar12User): DomphyElement<"div"> {
return {
div: [
// Header (DropdownMenuLabel): avatar + name/email, with a bottom hairline
// standing in for the separator between it and the first group.
{
div: [accountAvatar(user), twoLineLabel(user.name, user.email)],
style: {
display: "flex",
alignItems: "center",
gap: (l: Listener) => themeSpacing(themeDensity(l) * 2),
paddingBlock: (l: Listener) => themeSpacing(themeDensity(l) * 1.5),
paddingInline: (l: Listener) => themeSpacing(themeDensity(l) * 2),
borderBottom: (l: Listener) =>
`1px solid ${themeColor(l, "shift-3", "neutral")}`,
color: (l: Listener) => themeColor(l, "shift-9", "neutral"),
},
} as unknown as DomphyElement,
{
div: ACCOUNT_MENU_GROUPS.map((entries, groupIndex) => ({
div: entries.map((entry, itemIndex) => ({
button: [
sidebarIcon(entry.icon),
{
span: entry.label,
style: { flex: "1", textAlign: "left" },
} as unknown as DomphyElement,
],
type: "button",
role: "menuitem",
style: interactiveRowStyle(true),
_key: itemIndex,
})) as unknown as DomphyElement[],
role: "group",
style: {
display: "flex",
flexDirection: "column",
paddingBlock: (l: Listener) => themeSpacing(themeDensity(l) * 1),
paddingInline: (l: Listener) => themeSpacing(themeDensity(l) * 1),
// Separator hairline between groups (last group has none), matching
// upstream's DropdownMenuSeparator between every group.
borderBottom:
groupIndex < ACCOUNT_MENU_GROUPS.length - 1
? (l: Listener) =>
`1px solid ${themeColor(l, "shift-3", "neutral")}`
: undefined,
},
_key: groupIndex,
})) as unknown as DomphyElement[],
role: "menu",
style: { display: "flex", flexDirection: "column" },
} as unknown as DomphyElement,
],
dataTone: "shift-0",
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">;
}
/** Account footer trigger (avatar + name/email + chevrons) opening the
* upstream-faithful account dropdown. Local to this block instead of the shared
* renderUserFooter(), whose plain 3-item menu omits the header/groups/icons. */
function renderAccountFooter(user: Sidebar12User): DomphyElement<"div"> {
return {
div: [
{
button: [
accountAvatar(user),
twoLineLabel(user.name, user.email),
sidebarIcon(ICON_CHEVRONS_UPDOWN),
],
type: "button",
ariaLabel: "Account menu",
style: {
display: "flex",
alignItems: "center",
width: "100%",
gap: (l: Listener) => themeSpacing(themeDensity(l) * 2),
padding: (l: Listener) => themeSpacing(themeDensity(l) * 2),
borderRadius: (l: Listener) => themeSpacing(themeDensity(l) * 1),
border: "none",
cursor: "pointer",
overflow: "hidden",
color: (l: Listener) => themeColor(l, "shift-9", "neutral"),
backgroundColor: (l: Listener) => themeColor(l, "inherit", "neutral"),
"&:hover": {
backgroundColor: (l: Listener) =>
themeColor(l, "shift-2", "neutral"),
},
},
$: [
popover({
placement: "right-start",
content: accountDropdownContent(user),
}),
],
} as unknown as DomphyElement,
],
style: {
// Header sits at the top of the aside (like upstream SidebarHeader,
// border-b), so the hairline is a bottom border, not a top one. Upstream
// SidebarHeader here is `h-16 border-b` — a fixed 64px height that lines
// up with the content header, so center its trigger vertically.
display: "flex",
alignItems: "center",
height: themeSpacing(16),
boxSizing: "border-box",
padding: (l: Listener) => themeSpacing(themeDensity(l) * 2),
borderBottom: (l: Listener) =>
`1px solid ${themeColor(l, "shift-3", "neutral")}`,
color: (l: Listener) => themeColor(l, "shift-9", "neutral"),
},
} as unknown as DomphyElement<"div">;
}
function startOfMonth(date: Date): Date {
return new Date(date.getFullYear(), date.getMonth(), 1);
}
function addMonths(date: Date, count: number): Date {
return new Date(date.getFullYear(), date.getMonth() + count, 1);
}
function addDays(date: Date, count: number): Date {
return new Date(date.getFullYear(), date.getMonth(), date.getDate() + count);
}
function sameDay(a: Date, b: Date): boolean {
return (
a.getFullYear() === b.getFullYear() &&
a.getMonth() === b.getMonth() &&
a.getDate() === b.getDate()
);
}
function isoOf(date: Date): string {
return `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`;
}
/** Compact always-visible month-grid date picker (Sunday-first, no popover). */
function monthDatePicker(
viewMonth: State<Date>,
selectedDate: State<Date>,
onSelect: (date: Date) => void,
): DomphyElement<"div"> {
const monthFormatter = new Intl.DateTimeFormat("en-US", {
month: "long",
year: "numeric",
});
const fullDateFormatter = new Intl.DateTimeFormat("en-US", {
dateStyle: "full",
});
const header: DomphyElement<"div"> = {
div: [
{
button: "‹",
type: "button",
ariaLabel: "Previous month",
onClick: () => viewMonth.set(addMonths(viewMonth.get(), -1)),
style: navButtonStyle(),
} as unknown as DomphyElement,
{
div: (l: Listener) => monthFormatter.format(viewMonth.get(l)),
ariaLive: "polite",
style: {
flex: "1",
textAlign: "center",
color: (l: Listener) => themeColor(l, "shift-9", "neutral"),
},
} as unknown as DomphyElement,
{
button: "›",
type: "button",
ariaLabel: "Next month",
onClick: () => viewMonth.set(addMonths(viewMonth.get(), 1)),
style: navButtonStyle(),
} as unknown as DomphyElement,
],
style: {
display: "flex",
alignItems: "center",
gap: themeSpacing(1),
marginBottom: themeSpacing(2),
},
} as unknown as DomphyElement<"div">;
const weekdayHeader: DomphyElement<"div"> = {
// NOT `role="row"` — unlike the week rows below, this header is a
// sibling of `grid`, not nested inside its own `role="grid"` container,
// so `role="row"` here has no valid grid/table/rowgroup ancestor (and no
// cell-family children either) — it failed both `aria-required-parent`
// and `aria-required-children` once added.
div: WEEKDAY_LABELS.map((label, index) => ({
small: label,
_key: index,
style: { textAlign: "center" },
$: [small({ color: "neutral" })],
})) as unknown as DomphyElement[],
style: gridRowStyle(),
} as unknown as DomphyElement<"div">;
const grid: DomphyElement<"div"> = {
div: (listener: Listener) => {
const monthStart = startOfMonth(viewMonth.get(listener));
const month = monthStart.getMonth();
const gridStart = addDays(monthStart, -monthStart.getDay());
const selected = selectedDate.get(listener);
const weeks: DomphyElement[] = [];
for (let week = 0; week < 6; week++) {
const cells: DomphyElement[] = [];
for (let day = 0; day < 7; day++) {
const date = addDays(gridStart, week * 7 + day);
const outside = date.getMonth() !== month;
const isSelected = sameDay(date, selected);
cells.push({
button: String(date.getDate()),
type: "button",
role: "gridcell",
disabled: outside,
ariaSelected: isSelected,
ariaLabel: fullDateFormatter.format(date),
onClick: () => onSelect(date),
_key: isoOf(date),
// Selected day: solid near-black circle (upstream bg-primary) with
// light text — shift-9 resolves light on this dark edge surface.
...(isSelected ? { dataTone: "shift-17" as const } : {}),
style: {
appearance: "none",
border: "none",
cursor: outside ? "default" : "pointer",
aspectRatio: "1",
borderRadius: "50%",
opacity: outside ? 0.4 : 1,
color: (l: Listener) => themeColor(l, "shift-9", "neutral"),
backgroundColor: (l: Listener) =>
themeColor(l, "inherit", "neutral"),
"&:hover:not(:disabled)": {
backgroundColor: (l: Listener) =>
isSelected
? themeColor(l, "inherit", "neutral")
: themeColor(l, "shift-2", "neutral"),
},
},
} as unknown as DomphyElement);
}
weeks.push({
div: cells,
role: "row",
_key: isoOf(addDays(gridStart, week * 7)),
style: gridRowStyle(),
} as unknown as DomphyElement);
}
return weeks;
},
role: "grid",
ariaLabel: "Calendar",
style: { display: "flex", flexDirection: "column", gap: themeSpacing(0.5) },
} as unknown as DomphyElement<"div">;
return {
div: [header, weekdayHeader, grid],
style: {
padding: (l: Listener) => themeSpacing(themeDensity(l) * 3),
color: (l: Listener) => themeColor(l, "shift-9", "neutral"),
},
} as unknown as DomphyElement<"div">;
}
function navButtonStyle() {
return {
appearance: "none" as const,
border: "none",
background: "none",
cursor: "pointer",
width: themeSpacing(7),
height: themeSpacing(7),
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-3", "neutral"),
},
};
}
function gridRowStyle() {
return {
display: "grid",
gridTemplateColumns: "repeat(7, 1fr)",
gap: themeSpacing(0.5),
};
}
/** One calendar entry row: colored square checkbox + name. */
function calendarEntryRow(
groupLabel: string,
entry: Sidebar12CalendarEntry,
checked: State<boolean>,
onToggle?: (groupLabel: string, entryId: string, checked: boolean) => void,
): DomphyElement<"li"> {
const inputId = `sidebar12-calendar-${entry.id}`;
return {
li: [
{
label: [
{
input: null,
id: inputId,
type: "checkbox",
checked: (l: Listener) => checked.get(l),
onChange: (e: Event) => {
const next = (e.target as HTMLInputElement).checked;
checked.set(next);
onToggle?.(groupLabel, entry.id, next);
},
$: [inputCheckbox({ color: "neutral", accentColor: entry.color })],
} as unknown as DomphyElement,
{
span: entry.name,
style: { flex: "1", textAlign: "left" },
} as unknown as DomphyElement,
],
htmlFor: inputId,
style: {
display: "flex",
alignItems: "center",
width: "100%",
gap: (l: Listener) => themeSpacing(themeDensity(l) * 2),
paddingBlock: (l: Listener) => themeSpacing(themeDensity(l) * 1.5),
paddingInline: (l: Listener) => themeSpacing(themeDensity(l) * 3),
borderRadius: (l: Listener) => themeSpacing(themeDensity(l) * 1),
cursor: "pointer",
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,
],
_key: entry.id,
} as DomphyElement<"li">;
}
/** A collapsible calendar group: label + chevron trigger, list of checkbox rows.
* Matches the reference's defaults: only the first group starts expanded, and
* only each group's first two entries start checked. */
function calendarGroupSection(
group: Sidebar12CalendarGroup,
groupIndex: number,
onToggle?: (groupLabel: string, entryId: string, checked: boolean) => void,
): DomphyElement<"li"> {
const entryStates = group.entries.map((_entry, entryIndex) =>
toState(entryIndex < 2),
);
return {
li: [
{
details: [
{
summary: [
{
span: group.label,
style: { flex: "1", textAlign: "left" },
} as unknown as DomphyElement,
{
span: rawHtml(ICON_CHEVRON_RIGHT),
dataSlot: "chevron",
style: { transition: "transform 150ms ease" },
$: [icon({ color: "neutral" })],
} as unknown as DomphyElement,
],
style: {
listStyle: "none",
cursor: "pointer",
userSelect: "none",
display: "flex",
alignItems: "center",
width: "100%",
paddingBlock: (l: Listener) =>
themeSpacing(themeDensity(l) * 1.5),
paddingInline: (l: Listener) => themeSpacing(themeDensity(l) * 3),
color: (l: Listener) => themeColor(l, "shift-9", "neutral"),
backgroundColor: (l: Listener) =>
themeColor(l, "inherit", "neutral"),
"&::-webkit-details-marker": { display: "none" },
"&::marker": { content: `""` },
},
} as unknown as DomphyElement,
{
ul: group.entries.map((entry, index) =>
calendarEntryRow(
group.label,
entry,
entryStates[index]!,
onToggle,
),
),
style: {
listStyle: "none",
margin: "0",
padding: "0",
display: "flex",
flexDirection: "column",
gap: themeSpacing(0.5),
},
} as unknown as DomphyElement,
],
open: groupIndex === 0,
style: {
"&[open] summary [data-slot=chevron]": { transform: "rotate(90deg)" },
},
} as unknown as DomphyElement,
// Upstream calendars.tsx renders a <SidebarSeparator className="mx-0" />
// after every group (including the last) — a full-width divider line
// between/after each of the three calendar groups.
groupSeparator(),
],
_key: group.label,
} as DomphyElement<"li">;
}
/** Full-width horizontal divider rendered after each calendar group, matching
* upstream's per-group SidebarSeparator. Border (not backgroundColor) to stay
* theme-safe, like verticalDivider() in the shared module. */
function groupSeparator(): DomphyElement<"div"> {
return {
div: null,
ariaHidden: "true",
style: {
height: "0",
width: "100%",
marginBlockStart: themeSpacing(2),
borderBlockStart: (l: Listener) =>
`1px solid ${themeColor(l, "shift-3", "neutral")}`,
color: (l: Listener) => themeColor(l, "shift-3", "neutral"),
},
} as unknown as DomphyElement<"div">;
}
/** Tall placeholder day-grid demonstrating the sidebar's independent scroll. */
function eventGridPlaceholder(): DomphyElement<"div"> {
const cells = Array.from({ length: 35 }, (_unused, index) => ({
div: String((index % 31) + 1),
_key: index,
dataTone: "shift-1",
style: {
minHeight: themeSpacing(24),
borderRadius: (l: Listener) => themeSpacing(themeDensity(l) * 1),
padding: themeSpacing(1),
backgroundColor: (l: Listener) => themeColor(l, "inherit", "neutral"),
color: (l: Listener) => themeColor(l, "shift-9", "neutral"),
},
})) as unknown as DomphyElement[];
return {
div: cells,
style: {
display: "grid",
gridTemplateColumns: "repeat(7, minmax(0, 1fr))",
gap: (l: Listener) => themeSpacing(themeDensity(l) * 2),
padding: (l: Listener) => themeSpacing(themeDensity(l) * 4),
},
} as unknown as DomphyElement<"div">;
}
/**
* Scheduling-app sidebar: user header, compact month date-picker, grouped
* checkbox calendar lists — sticky while the main content scrolls
* independently. Call with no arguments for a fully working demo.
*/
function sidebar12(props: Sidebar12Props = {}): DomphyElement<"div"> {
const {
user = DEFAULT_USER,
groups = DEFAULT_GROUPS,
onDateChange,
onCalendarToggle,
children,
} = props;
const initialDate = props.selectedDate ?? DEFAULT_DATE;
const selectedDate = toState(initialDate);
const viewMonth = toState(startOfMonth(initialDate));
const collapsed = toState(false);
const mobileOpen = toState(false);
const selectDate = (date: Date) => {
selectedDate.set(date);
onDateChange?.(date);
};
const periodLabel = (listener: Listener) =>
new Intl.DateTimeFormat("en-US", { month: "long", year: "numeric" }).format(
viewMonth.get(listener),
);
const asideElement: DomphyElement<"aside"> = {
aside: [
renderAccountFooter(user),
monthDatePicker(viewMonth, selectedDate, selectDate),
// Upstream app-sidebar.tsx renders a single <SidebarSeparator className="mx-0" />
// between the DatePicker and the Calendars — a full-width HORIZONTAL rule.
// (verticalDivider() is a no-op sliver in this column stack, and the
// header/content divider is already the renderAccountFooter border-b.)
groupSeparator(),
{
ul: groups.map((group, groupIndex) =>
calendarGroupSection(group, groupIndex, onCalendarToggle),
),
style: {
listStyle: "none",
margin: "0",
padding: (l: Listener) => themeSpacing(themeDensity(l) * 3),
display: "flex",
flexDirection: "column",
gap: themeSpacing(2),
flex: "1",
},
} as unknown as DomphyElement,
{
div: [
{
button: [
sidebarIcon(ICON_PLUS),
{
span: "New Calendar",
style: { flex: "1", textAlign: "left" },
} as unknown as DomphyElement,
],
type: "button",
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),
border: "none",
cursor: "pointer",
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: {
borderTop: (l: Listener) =>
`1px solid ${themeColor(l, "shift-3", "neutral")}`,
padding: (l: Listener) => themeSpacing(themeDensity(l) * 1),
color: (l: Listener) => themeColor(l, "shift-9", "neutral"),
},
} as unknown as DomphyElement,
// Upstream app-sidebar.tsx renders <SidebarRail /> — a thin always-present
// edge strip that toggles the sidebar on click (same pattern as sidebar11).
{
div: null,
ariaHidden: "true",
onClick: () => {
collapsed.set(!collapsed.get());
mobileOpen.set(!mobileOpen.get());
},
style: {
position: "absolute",
insetBlock: "0",
insetInlineEnd: "0",
width: themeSpacing(1),
cursor: "col-resize",
},
} as unknown as DomphyElement,
],
style: {
display: "flex",
flexDirection: "column",
flexShrink: "0",
position: "sticky",
top: "0",
height: "100dvh",
overflowY: "auto",
width: (l: Listener) => (collapsed.get(l) ? "0" : themeSpacing(70)),
overflowX: "hidden",
transition: "width 180ms ease-out",
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)": {
position: "fixed",
insetBlock: "0",
insetInlineStart: "0",
zIndex: "15",
width: themeSpacing(70),
transform: (l: Listener) =>
mobileOpen.get(l) ? "translateX(0)" : "translateX(-100%)",
transition: "transform 180ms ease-out",
},
},
} as unknown as DomphyElement<"aside">;
const headerElement: DomphyElement<"header"> = {
header: [
{
button: [sidebarIcon(ICON_PANEL_TOGGLE)],
type: "button",
ariaLabel: "Toggle sidebar",
onClick: () => {
collapsed.set(!collapsed.get());
mobileOpen.set(!mobileOpen.get());
},
style: {
appearance: "none",
border: "none",
background: "none",
cursor: "pointer",
color: (l: Listener) => themeColor(l, "shift-8", "neutral"),
},
} as unknown as DomphyElement,
verticalDivider(),
{
strong: periodLabel,
$: [strong({ color: "neutral" })],
} as unknown as DomphyElement,
{
div: [
{
button: "Today",
type: "button",
onClick: () => {
viewMonth.set(startOfMonth(initialDate));
selectDate(initialDate);
},
style: todayButtonStyle(),
} as unknown as DomphyElement,
{
button: "‹",
type: "button",
ariaLabel: "Previous period",
onClick: () => viewMonth.set(addMonths(viewMonth.get(), -1)),
style: navButtonStyle(),
} as unknown as DomphyElement,
{
button: "›",
type: "button",
ariaLabel: "Next period",
onClick: () => viewMonth.set(addMonths(viewMonth.get(), 1)),
style: navButtonStyle(),
} as unknown as DomphyElement,
],
style: {
marginInlineStart: "auto",
display: "flex",
alignItems: "center",
gap: themeSpacing(1),
},
} as unknown as DomphyElement,
],
style: {
display: "flex",
alignItems: "center",
gap: (l: Listener) => themeSpacing(themeDensity(l) * 3),
height: themeSpacing(16),
flexShrink: "0",
paddingInline: (l: Listener) => themeSpacing(themeDensity(l) * 4),
borderBottom: (l: Listener) =>
`1px solid ${themeColor(l, "shift-3", "neutral")}`,
backgroundColor: (l: Listener) => themeColor(l, "inherit", "neutral"),
color: (l: Listener) => themeColor(l, "shift-9", "neutral"),
},
} as unknown as DomphyElement<"header">;
const mainContent: DomphyElement[] = children
? Array.isArray(children)
? children
: [children]
: [eventGridPlaceholder()];
const mainElement: DomphyElement<"main"> = {
main: [headerElement, ...mainContent],
style: {
display: "flex",
flexDirection: "column",
flex: "1",
minWidth: "0",
color: (l: Listener) => themeColor(l, "shift-9", "neutral"),
backgroundColor: (l: Listener) => themeColor(l, "inherit", "neutral"),
},
} as unknown as DomphyElement<"main">;
return {
div: [asideElement, mainElement],
dataTone: "shift-0",
style: {
display: "flex",
alignItems: "flex-start",
minHeight: "100dvh",
position: "relative",
backgroundColor: (l: Listener) => themeColor(l, "inherit", "neutral"),
color: (l: Listener) => themeColor(l, "shift-9", "neutral"),
},
} as unknown as DomphyElement<"div">;
}
function todayButtonStyle() {
return {
appearance: "none" as const,
border: "none",
cursor: "pointer",
paddingInline: (l: Listener) => themeSpacing(themeDensity(l) * 3),
paddingBlock: (l: Listener) => themeSpacing(themeDensity(l) * 1),
borderRadius: (l: Listener) => themeSpacing(themeDensity(l) * 1),
color: (l: Listener) => themeColor(l, "shift-9", "neutral"),
backgroundColor: (l: Listener) => themeColor(l, "inherit", "neutral"),
outline: (l: Listener) =>
`1px solid ${themeColor(l, "shift-4", "neutral")}`,
outlineOffset: "-1px",
"&:hover": {
backgroundColor: (l: Listener) => themeColor(l, "shift-2", "neutral"),
},
};
}
export { sidebar12 };
export type {
Sidebar12CalendarEntry,
Sidebar12CalendarGroup,
Sidebar12Props,
Sidebar12User,
};