Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
4 changes: 2 additions & 2 deletions src/components/callouts/Callout.astro
Original file line number Diff line number Diff line change
Expand Up @@ -98,15 +98,15 @@ const iconClass = `mt-0.5 h-4 w-4 shrink-0 ${c.text}`;
class:list={[
"min-w-0",
c.text,
"[&_a]:border-current [&_a]:text-current [&_code]:text-current [&_p]:leading-relaxed",
"[&_a]:border-current [&_a]:text-current [&_code]:text-current [&_p]:leading-relaxed [&_p]:text-current [&_strong]:text-current [&_li]:text-current",
]}
>
{
title && (
<p class="mb-1 font-semibold leading-none tracking-tight">{title}</p>
)
}
<div class="[&_p]:m-0">
<div class="[&>*:first-child]:mt-0 [&>*:last-child]:mb-0">
<slot />
</div>
</div>
Expand Down
27 changes: 27 additions & 0 deletions src/components/tabs/Tab.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
---
import type { icons } from "lucide-react";
import Icon from "../Icon.astro";

interface Props {
title: string;
icon?: keyof typeof icons;
class?: string;
}

const { title, icon, class: className } = Astro.props;
---

<div
class:list={["bp-tab-panel overflow-x-auto", className]}
data-bp-tab-title={title}
role="tabpanel"
>
{
icon && (
<span class="bp-tab-icon-source" hidden aria-hidden="true">
<Icon icon={icon} class="size-4 shrink-0" />
</span>
)
}
<slot />
</div>
212 changes: 212 additions & 0 deletions src/components/tabs/Tabs.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
---
interface Props {
defaultIndex?: number;
class?: string;
}

const { defaultIndex = 0, class: className } = Astro.props;
---

<div
class:list={["bp-tabs tab-container", className]}
data-default-index={defaultIndex}
>
<ul
class="bp-tab-list not-prose mb-6 flex min-w-full flex-none gap-x-6 overflow-auto border-b border-stone-200 pb-px dark:border-stone-700"
role="tablist"
aria-label="Tabs"
>
</ul>
<div class="bp-tab-panels">
<slot />
</div>
</div>

<span
hidden
aria-hidden="true"
class="-mb-px max-w-max border-current border-transparent text-primary text-stone-900 hover:border-stone-300 dark:text-stone-200 dark:hover:border-stone-700"
></span>

<style is:global>
.bp-tabs .bp-tab-panel {
display: none;
}
.bp-tabs .bp-tab-panel:first-of-type {
display: block;
}
.bp-tabs .bp-tab-list:empty {
display: none;
}
.bp-tabs[data-ready="true"] .bp-tab-panel {
display: none;
}
.bp-tabs[data-ready="true"] .bp-tab-panel[data-active="true"] {
display: block;
}
</style>

<script>
const TAB_BUTTON_BASE =
"-mb-px flex max-w-max items-center gap-1.5 whitespace-nowrap border-b pt-3 pb-2.5 font-semibold text-sm leading-6";
const TAB_BUTTON_ACTIVE = "border-current text-primary";
const TAB_BUTTON_INACTIVE =
"border-transparent text-stone-900 hover:border-stone-300 dark:text-stone-200 dark:hover:border-stone-700";

const slugify = (input: string): string =>
input
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "") || "tab";

const initRoot = (root: HTMLElement): void => {
if (root.dataset.ready === "true") {
return;
}

const list = root.querySelector<HTMLUListElement>(":scope > .bp-tab-list");
const panelsContainer = root.querySelector<HTMLElement>(
":scope > .bp-tab-panels",
);
if (!list || !panelsContainer) {
return;
}

const panels = Array.from(
panelsContainer.querySelectorAll<HTMLElement>(":scope > .bp-tab-panel"),
);
if (panels.length === 0) {
return;
}

const uid = (
typeof crypto !== "undefined" && "randomUUID" in crypto
? crypto.randomUUID()
: Math.random().toString(36).slice(2)
).replace(/-/g, "");

const tabIds = panels.map((panel, i) => {
const title = panel.dataset.bpTabTitle ?? "tab";
return `bp-${uid}-${slugify(title)}-${i}`;
});

const tabs: HTMLLIElement[] = [];

panels.forEach((panel, i) => {
const title = panel.dataset.bpTabTitle ?? "Tab";
const iconSource = panel.querySelector<HTMLElement>(
":scope > .bp-tab-icon-source",
);

const li = document.createElement("li");
li.id = tabIds[i];
li.setAttribute("role", "tab");
li.setAttribute("aria-controls", `panel-${tabIds[i]}`);
li.className = "cursor-pointer";

const button = document.createElement("div");
button.dataset.bpTabButton = "";

if (iconSource) {
const clone = iconSource.cloneNode(true) as HTMLElement;
clone.hidden = false;
clone.removeAttribute("aria-hidden");
clone.classList.remove("bp-tab-icon-source");
button.appendChild(clone);
}

const titleSpan = document.createElement("span");
titleSpan.textContent = title;
button.appendChild(titleSpan);

li.appendChild(button);

panel.id = `panel-${tabIds[i]}`;
panel.setAttribute("aria-labelledby", tabIds[i]);

list.appendChild(li);
tabs.push(li);
});

const parsedDefault = Number.parseInt(root.dataset.defaultIndex ?? "0", 10);
const defaultIndex = Number.isFinite(parsedDefault) ? parsedDefault : 0;
let activeIndex = Math.max(0, Math.min(defaultIndex, panels.length - 1));

const render = (): void => {
tabs.forEach((tab, i) => {
const isActive = i === activeIndex;
const button = tab.firstElementChild as HTMLElement;
tab.setAttribute("aria-selected", String(isActive));
tab.setAttribute("tabindex", isActive ? "0" : "-1");
tab.dataset.active = String(isActive);
button.className = `${TAB_BUTTON_BASE} ${
isActive ? TAB_BUTTON_ACTIVE : TAB_BUTTON_INACTIVE
}`;
button.dataset.active = String(isActive);
});

panels.forEach((panel, i) => {
const isActive = i === activeIndex;
panel.dataset.active = String(isActive);
panel.setAttribute("aria-hidden", String(!isActive));
panel.setAttribute("tabindex", isActive ? "0" : "-1");
});
};

const setActive = (newIndex: number): void => {
if (newIndex === activeIndex) {
return;
}
activeIndex = newIndex;
render();
};

render();

tabs.forEach((tab, i) => {
tab.addEventListener("click", (e) => {
e.stopPropagation();
setActive(i);
});

tab.addEventListener("keydown", (e) => {
const count = tabs.length;
let newIndex = activeIndex;
switch (e.key) {
case "ArrowLeft":
newIndex = (activeIndex - 1 + count) % count;
break;
case "ArrowRight":
newIndex = (activeIndex + 1) % count;
break;
case "Home":
newIndex = 0;
break;
case "End":
newIndex = count - 1;
break;
case "Enter":
case " ":
e.preventDefault();
return;
default:
return;
}
e.preventDefault();
setActive(newIndex);
window.setTimeout(() => tabs[newIndex]?.focus(), 0);
});
});

root.dataset.ready = "true";
};

const initAll = (): void => {
document
.querySelectorAll<HTMLElement>(".bp-tabs")
.forEach((root) => initRoot(root));
};

initAll();
document.addEventListener("astro:page-load", initAll);
</script>
2 changes: 2 additions & 0 deletions src/components/tabs/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { default as Tabs } from './Tabs.astro'
export { default as Tab } from './Tab.astro'
70 changes: 57 additions & 13 deletions src/lib/cleanup-headings.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,70 @@
import type { MarkdownHeading } from 'astro'

function extractRawHeadingTexts(body: string): string[] {
const headingRegex = /^(#{2,3})\s+(.+)$/gm
const texts: string[] = []
let match
while ((match = headingRegex.exec(body)) !== null) {
const rawText = match[2].trim()
// strip jsx expressions from raw text
const text = rawText.replace(/\{[^}]*\}/g, '').trim()
texts.push(text)
interface ParsedHeading {
text: string
insideTabs: boolean
}

function stripFences(body: string): string {
const lines = body.split('\n')
const out: string[] = []
let opener: string | null = null
for (const line of lines) {
const m = line.match(/^[ \t]{0,3}(`{3,}|~{3,})/)
if (opener === null) {
if (m) {
opener = m[1]
out.push('')
continue
}
out.push(line)
} else {
if (m && m[1][0] === opener[0] && m[1].length >= opener.length) {
opener = null
}
out.push('')
}
}
return texts
return out.join('\n')
}

const TOKEN_RE = /<Tabs\b[\s\S]*?(?<!\/)>|<\/Tabs>|^[ \t]*#{2,3}[ \t]+.+$/gm

function parseHeadings(body: string): ParsedHeading[] {
const cleaned = stripFences(body)
const out: ParsedHeading[] = []
let tabsDepth = 0
for (const match of cleaned.matchAll(TOKEN_RE)) {
const tok = match[0]
if (tok.startsWith('</Tabs')) {
tabsDepth = Math.max(0, tabsDepth - 1)
} else if (tok.startsWith('<Tabs')) {
if (!/\/[ \t\r\n]*>$/.test(tok)) tabsDepth++
} else {
const hm = tok.match(/^[ \t]*(#{2,3})[ \t]+(.+)$/)
if (hm) {
const rawText = hm[2]
.trim()
.replace(/\{[^}]*\}/g, '')
.trim()
out.push({ text: rawText, insideTabs: tabsDepth > 0 })
}
}
}
return out
}

export const cleanupHeadings = (body: string, headings: MarkdownHeading[]): MarkdownHeading[] => {
const rawTexts = extractRawHeadingTexts(body ?? '')
const parsed = parseHeadings(body ?? '')

return headings
.filter((h) => h.depth >= 2 && h.depth <= 3)
.map((h, index) => ({
depth: h.depth,
slug: h.slug,
// use cleaned text from raw body if available, fallback to processed text
text: rawTexts[index] ?? h.text,
text: parsed[index]?.text ?? h.text,
insideTabs: parsed[index]?.insideTabs ?? false,
}))
.filter((h) => !h.insideTabs)
Comment thread
adkah marked this conversation as resolved.
.map(({ depth, slug, text }) => ({ depth, slug, text }))
}
Loading
Loading