Skip to content
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions astro.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,12 @@ export default defineConfig({
"../components/Page.astro": fileURLToPath(
new URL("./src/components/overrides/Page.astro", import.meta.url),
),
"./SidebarSublist.astro": fileURLToPath(
new URL(
"./src/components/overrides/SidebarSublist.astro",
import.meta.url,
),
),
},
},
},
Expand Down
167 changes: 77 additions & 90 deletions src/components/overrides/Sidebar.astro
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ import { lookupProductTitle } from "~/util/sidebar";
const [product, module] = Astro.url.pathname.split("/").filter(Boolean);
---

<div class="sidebar-header">
<div
class="sticky top-0 z-[1] flex flex-col gap-2 bg-[var(--sl-color-black)] py-3.5 min-[50rem]:bg-[var(--sl-color-bg-sidebar)]"
>
<div class="sidebar-product-title px-1">
<a href={"/" + product + "/"} class="flex items-center gap-2 no-underline">
<AstroIcon name={product} size="32px" class="text-cl1-brand-orange" />
Expand All @@ -22,10 +24,13 @@ const [product, module] = Astro.url.pathname.split("/").filter(Boolean);
<!-- Search Input -->
<div class="relative">
<input
type="text"
type="search"
id="sidebar-search"
placeholder="Search sidebar..."
class="w-full rounded-md border border-gray-300 bg-white px-3 py-2 pr-10 text-base sm:text-sm text-gray-900 placeholder-gray-500 transition-colors duration-200 focus:border-blue-500 focus:ring-2 focus:ring-blue-500 focus:outline-none dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 dark:placeholder-gray-400 dark:focus:border-orange-500 dark:focus:ring-orange-500"
aria-label="Search sidebar navigation"
autocomplete="off"
spellcheck="false"
class="box-border min-h-[2.125rem] w-full cursor-pointer rounded-lg border border-neutral-200 bg-transparent px-3 pr-10 text-sm text-neutral-950 placeholder-neutral-400 outline-2 outline-offset-2 outline-transparent transition-colors duration-150 hover:border-neutral-950 focus:cursor-text focus-visible:outline-blue-500 dark:border-neutral-700 dark:text-neutral-100 dark:placeholder-neutral-500 dark:hover:border-neutral-100 dark:focus-visible:outline-blue-400"
/>
<div class="sidebar-search-icon"></div>
</div>
Expand Down Expand Up @@ -69,6 +74,69 @@ const [product, module] = Astro.url.pathname.split("/").filter(Boolean);
)
return;

// ── Animated <details> toggle ──
const animating = new WeakSet<HTMLDetailsElement>();
const reducedMotion = matchMedia("(prefers-reduced-motion: reduce)");
const DURATION = 250;
const EASING = "cubic-bezier(0.87, 0, 0.13, 1)";

function animateAccordion(
details: HTMLDetailsElement,
from: string,
to: string,
onDone?: () => void,
) {
const el = details.querySelector<HTMLElement>(
":scope > .sidebar-accordion",
);
if (!el || reducedMotion.matches) {
onDone?.();
return;
}
if (animating.has(details)) el.getAnimations().forEach((a) => a.cancel());
animating.add(details);

const anim = el.animate(
[{ gridTemplateRows: from }, { gridTemplateRows: to }],
{
duration: DURATION,
easing: EASING,
fill: onDone ? "forwards" : "none",
},
);
const cleanup = () => animating.delete(details);
anim.oncancel = cleanup;
anim.onfinish = () => {
onDone?.();
anim.cancel();
cleanup();
};
}
sidebarContent.addEventListener("click", (e) => {
const summary = (e.target as HTMLElement).closest("summary");
if (!summary) return;
const details = summary.parentElement as HTMLDetailsElement | null;
if (!details || details.tagName !== "DETAILS") return;

e.preventDefault();

if (details.open) {
animateAccordion(details, "1fr", "0fr", () => {
details.open = false;
});
} else {
details.open = true;
animateAccordion(details, "0fr", "1fr");
}
});

// Cancel in-flight animations before Astro page transitions.
document.addEventListener("astro:before-swap", () => {
sidebarContent
?.querySelectorAll<HTMLElement>(".sidebar-accordion")
.forEach((el) => el.getAnimations().forEach((a) => a.cancel()));
});

const originalState: Map<Element, boolean> = new Map();

// Store original state of details elements
Expand All @@ -90,7 +158,9 @@ const [product, module] = Astro.url.pathname.split("/").filter(Boolean);
// Show only direct children of a folder (not recursive)
function showDirectChildren(details: HTMLDetailsElement) {
details.open = true;
const directList = details.querySelector(":scope > ul");
const directList = details.querySelector(
":scope > .sidebar-accordion > .sidebar-accordion-inner > ul",
);
if (directList) {
const directChildren = directList.querySelectorAll(":scope > li");
directChildren.forEach((child) => {
Expand Down Expand Up @@ -326,91 +396,8 @@ const [product, module] = Astro.url.pathname.split("/").filter(Boolean);
background-color: currentColor;
pointer-events: none;
}

:root {
/* Sticky header: position: sticky works because .sidebar-pane (overflow-y: auto)
is the scroll container, and we remove min-height: max-content from
.sidebar-content so the containing block height is bounded by the viewport. */
.sidebar-header {
position: sticky;
top: 0;
z-index: 1;
display: flex;
flex-direction: column;
gap: 0.5rem;
background-color: var(--sl-color-black);
padding-top: 1rem;
padding-bottom: 1rem;

@media (min-width: 50rem) {
background-color: var(--sl-color-bg-sidebar);
}
}

/* Override Starlight's height: 100% / min-height: max-content.
Let .sidebar-content size to its content so sticky has the full
scroll range to work within, not just one viewport height. */
.sidebar-content {
height: auto !important;
min-height: 0 !important;

--sl-color-hairline-light: #cacaca !important;

& > * {
a {
padding: 0.2375em var(--sl-sidebar-item-padding-inline) !important;

&[aria-current="page"] {
background-color: unset !important;
border: unset !important;
border-color: unset !important;
color: var(--sl-color-accent) !important;
font-weight: 600 !important;
}
}

/* Style parent navigation items that contain the current page */
details:has(a[aria-current="page"]) > summary,
details:has(a[aria-current="page"]) > summary * {
color: var(--sl-color-accent-high) !important;
font-weight: 600 !important;
}

/* Override to keep caret default color */
details:has(a[aria-current="page"]) > summary svg,
details:has(a[aria-current="page"]) > summary .caret,
details:has(a[aria-current="page"]) > summary [class*="caret"] {
fill: var(--sl-color-gray-3) !important;
}

summary {
padding: 0.1375em var(--sl-sidebar-item-padding-inline) !important;
}

.large {
color: var(--sl-color-gray-2) !important;
font-weight: unset !important;
font-size: unset !important;

@media (min-width: 50rem) {
font-size: var(--sl-text-sm) !important;
}
}

.caret {
font-size: 1rem !important;
}
}
}
}

:root[data-theme="dark"] {
.sidebar-content {
--sl-color-hairline-light: #444444 !important;

& > * a[aria-current="page"] {
color: var(--sl-color-accent-high) !important;
}
}
.sidebar-content {
height: auto !important;
min-height: 0 !important;
}
</style>
134 changes: 134 additions & 0 deletions src/components/overrides/SidebarSublist.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
---
import { Icon, Badge } from "@astrojs/starlight/components";
import type { SidebarEntry } from "~/util/sidebar";

interface Props {
sublist: SidebarEntry[];
nested?: boolean;
}

const { sublist, nested } = Astro.props;

/** Flatten a sidebar tree into a flat list of link entries. */
function flattenSidebar(entries: SidebarEntry[]): SidebarEntry[] {
return entries.flatMap((entry: SidebarEntry) =>
entry.type === "group" ? flattenSidebar(entry.entries) : entry,
);
}

/**
* SidebarRestorePoint counter — inlined from Starlight's internal component.
* Each <details> needs a unique, incrementing index for the sidebar persister.
*/
const currentGroupIndexSymbol = Symbol.for("starlight-sidebar-group-index");
const locals = Astro.locals as typeof Astro.locals & {
[currentGroupIndexSymbol]: number;
};

function nextRestoreIndex(): number {
const index = locals[currentGroupIndexSymbol] || 0;
locals[currentGroupIndexSymbol] = index + 1;
return index;
}

// Pre-compute restore indices for each group entry in this sublist
const restoreIndices = sublist.map((entry) =>
entry.type === "group" ? nextRestoreIndex() : -1,
);
---

<ul
class:list={[
!nested
? "top-level list-none p-0"
: "mt-0.5 ml-3 list-none border-l border-[var(--sidebar-border)] p-0 pl-2",
]}
>
{
sublist.map((entry, idx) => (
<li class="break-words">
{entry.type === "link" ? (
<a
href={entry.href}
aria-current={entry.isCurrent ? "page" : undefined}
class:list={[
"flex min-h-[2.125rem] items-start rounded-lg px-3 py-1.5 text-sm font-medium no-underline",
"outline-2 outline-offset-[-2px] outline-transparent focus-visible:outline-blue-500 dark:focus-visible:outline-blue-400",
entry.isCurrent
? "!bg-[var(--sidebar-active-bg)] !font-semibold !text-[var(--sidebar-active-text)]"
: "transition-colors duration-150 text-[var(--sidebar-text)] hover:bg-[var(--sidebar-hover-bg)] hover:text-[var(--sidebar-hover-text)]",
!nested && "large",
entry.attrs.class,
]}
{...entry.attrs}
>
<span>{entry.label}</span>
{entry.badge && (
<Badge
variant={entry.badge.variant}
class={`ml-[0.5em] ${entry.badge.class ?? ""}`}
text={entry.badge.text}
/>
)}
</a>
) : (
<details
class="group/accordion"
open={
flattenSidebar(entry.entries).some(
(i) => i.type === "link" && i.isCurrent,
) || !entry.collapsed
}
>
<summary class:list={[
"group/expander flex min-h-[2.125rem] cursor-pointer items-center justify-between rounded-lg px-3 py-1.5 text-sm no-underline outline-2 outline-offset-[-2px] outline-transparent transition-colors duration-150 select-none focus-visible:outline-blue-500 dark:focus-visible:outline-blue-400 [&::-webkit-details-marker]:hidden [&::marker]:hidden",
"hover:bg-[var(--sidebar-hover-bg)] hover:text-[var(--sidebar-hover-text)]",
entry.hasActivePage
? "font-semibold text-[var(--sidebar-text-strong)]"
: "font-medium text-[var(--sidebar-text)]",
]}>
<span class="group-label">
<span class:list={[!nested && "large"]}>{entry.label}</span>
{entry.badge && (
<Badge
variant={entry.badge.variant}
class={`ml-[0.5em] ${entry.badge.class ?? ""}`}
text={entry.badge.text}
/>
)}
</span>
<Icon
name="right-caret"
class="caret shrink-0 ml-auto -mr-px text-base text-[var(--sidebar-text)] opacity-50 group-hover/expander:text-[var(--sidebar-hover-text)] transition-transform duration-250"
size="1.25rem"
/>
</summary>
<sl-sidebar-restore data-index={restoreIndices[idx]} />
<div class="sidebar-accordion grid grid-rows-[0fr] group-open/accordion:grid-rows-[1fr]">
<div class="sidebar-accordion-inner overflow-hidden min-h-0">
<Astro.self sublist={entry.entries} nested />
</div>
</div>
</details>
)}
</li>
))
}
</ul>

<style is:global>
/* Caret rotation — must be global because .caret is on an SVG from a child component */
.sidebar-content [open] > summary .caret {
transform: rotate(90deg);
}
.sidebar-content [dir="rtl"] .caret {
transform: rotateZ(180deg);
}
</style>

<style>
/* Spacing between sibling items — top-level and nested */
ul > li + li {
margin-top: 1px;
}
</style>
Loading
Loading