Add remaining samples, tooling, and local project assets
This commit is contained in:
95
samples/src/components/CollapsibleTOC.astro
Normal file
95
samples/src/components/CollapsibleTOC.astro
Normal file
@@ -0,0 +1,95 @@
|
||||
---
|
||||
import DefaultTableOfContents from '@astrojs/starlight/components/TableOfContents.astro';
|
||||
|
||||
const props = Astro.props;
|
||||
---
|
||||
|
||||
<div class="toc-wrapper">
|
||||
<DefaultTableOfContents {...props} />
|
||||
</div>
|
||||
|
||||
<button id="sidebar-toggle" aria-label="Toggle Sidebar">
|
||||
<span class="icon">👉</span>
|
||||
</button>
|
||||
|
||||
<script>
|
||||
const toggleBtn = document.getElementById('sidebar-toggle');
|
||||
|
||||
if (toggleBtn) {
|
||||
// [핵심 해결책]
|
||||
// 버튼을 사이드바 감옥에서 탈출시켜 'body' 태그 직속으로 옮깁니다.
|
||||
// 그래야 사이드바가 사라져도 버튼은 살아남습니다.
|
||||
document.body.appendChild(toggleBtn);
|
||||
|
||||
toggleBtn.addEventListener('click', () => {
|
||||
// 1. body에 클래스 토글
|
||||
document.body.classList.toggle('hide-right-sidebar');
|
||||
|
||||
// 2. 아이콘 방향 변경
|
||||
const icon = toggleBtn.querySelector('.icon');
|
||||
if (icon) {
|
||||
if (document.body.classList.contains('hide-right-sidebar')) {
|
||||
icon.textContent = '👈'; // 돌아오기
|
||||
} else {
|
||||
icon.textContent = '👉'; // 넓게 보기
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<style is:global>
|
||||
/* [버튼 스타일] */
|
||||
#sidebar-toggle {
|
||||
position: fixed; /* 화면 기준 고정 */
|
||||
top: 5rem;
|
||||
right: 1.5rem; /* 스크롤바 고려 위치 */
|
||||
z-index: 9999; /* 어떤 요소보다 위에 있게 아주 높게 설정 */
|
||||
background-color: var(--sl-color-bg-nav);
|
||||
border: 1px solid var(--sl-color-gray-5);
|
||||
border-radius: 50%;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
cursor: pointer;
|
||||
font-size: 1.2rem;
|
||||
box-shadow: 0 4px 6px rgba(0,0,0,0.2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: transform 0.2s, background-color 0.2s;
|
||||
}
|
||||
|
||||
#sidebar-toggle:hover {
|
||||
transform: scale(1.1);
|
||||
background-color: var(--sl-color-gray-6);
|
||||
}
|
||||
|
||||
/* =========================================
|
||||
[레이아웃 강제 조정]
|
||||
========================================= */
|
||||
|
||||
/* 1. 우측 사이드바 영역 숨기기 */
|
||||
body.hide-right-sidebar .right-sidebar,
|
||||
body.hide-right-sidebar aside,
|
||||
body.hide-right-sidebar .right-sidebar-panel {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* 2. 화면 분할(Grid) 비율 재설정 (오른쪽 공간 삭제) */
|
||||
@media (min-width: 72rem) {
|
||||
body.hide-right-sidebar .sl-container {
|
||||
grid-template-columns: var(--sl-sidebar-width) 1fr !important;
|
||||
}
|
||||
body.hide-right-sidebar .page {
|
||||
grid-template-columns: var(--sl-sidebar-width) 1fr !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* 3. 본문 영역(main) 너비 100% 강제 */
|
||||
body.hide-right-sidebar main,
|
||||
body.hide-right-sidebar .main-pane {
|
||||
margin-right: 0 !important;
|
||||
width: 100% !important;
|
||||
max-width: 100% !important;
|
||||
}
|
||||
</style>
|
||||
157
samples/src/components/ContextualSidebar.astro
Normal file
157
samples/src/components/ContextualSidebar.astro
Normal file
@@ -0,0 +1,157 @@
|
||||
---
|
||||
import SidebarPersister from "@astrojs/starlight/components/SidebarPersister.astro";
|
||||
import SidebarSublist from "@astrojs/starlight/components/SidebarSublist.astro";
|
||||
import ThemeSelect from "virtual:starlight/components/ThemeSelect";
|
||||
import SidebarToggle from "./SidebarToggle.astro";
|
||||
|
||||
const { sidebar = [] } = Astro.locals.starlightRoute ?? {};
|
||||
const currentPath = decodeURIComponent(Astro.url.pathname);
|
||||
|
||||
const getTopSegment = (value) => {
|
||||
const normalized = decodeURIComponent(value || "")
|
||||
.replace(/^\/+|\/+$/g, "");
|
||||
if (!normalized) return "";
|
||||
return normalized.split("/")[0];
|
||||
};
|
||||
|
||||
const currentTopSegment = getTopSegment(currentPath);
|
||||
|
||||
const entryTopSegment = (entry) => {
|
||||
if (entry.type === "link" && entry.href) {
|
||||
try {
|
||||
const pathname = new URL(entry.href, Astro.url.origin).pathname;
|
||||
return getTopSegment(pathname);
|
||||
} catch {
|
||||
return getTopSegment(entry.href);
|
||||
}
|
||||
}
|
||||
|
||||
if (entry.type === "group" && Array.isArray(entry.entries)) {
|
||||
for (const child of entry.entries) {
|
||||
const segment = entryTopSegment(child);
|
||||
if (segment) return segment;
|
||||
}
|
||||
}
|
||||
|
||||
return "";
|
||||
};
|
||||
|
||||
const entryContainsPath = (entry) => {
|
||||
if (entry.type === "link" && entry.href) {
|
||||
const linkHref = decodeURIComponent(entry.href);
|
||||
return currentPath.startsWith(linkHref);
|
||||
}
|
||||
|
||||
if (entry.type === "group" && Array.isArray(entry.entries)) {
|
||||
return entry.entries.some(entryContainsPath);
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const activeTopLevelGroup = sidebar.find(
|
||||
(entry) =>
|
||||
entry.type === "group" &&
|
||||
(entryContainsPath(entry) || (currentTopSegment && entryTopSegment(entry) === currentTopSegment)),
|
||||
);
|
||||
|
||||
const scopedSidebar =
|
||||
activeTopLevelGroup && Array.isArray(activeTopLevelGroup.entries)
|
||||
? activeTopLevelGroup.entries
|
||||
: sidebar;
|
||||
|
||||
---
|
||||
|
||||
<div class="mobile-theme-bar md:sl-hidden" data-testid="mobile-theme-toggle">
|
||||
<ThemeSelect />
|
||||
<span class="theme-label">Light / Dark</span>
|
||||
</div>
|
||||
<SidebarPersister>
|
||||
<SidebarSublist sublist={scopedSidebar} />
|
||||
</SidebarPersister>
|
||||
<SidebarToggle />
|
||||
|
||||
<style>
|
||||
:global(#starlight__sidebar a[data-cel-hidden="true"]) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
:global(html.show-hidden-sidebar-items #starlight__sidebar a[data-cel-hidden="true"]) {
|
||||
display: revert;
|
||||
}
|
||||
|
||||
.mobile-theme-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.5rem 0 0.75rem;
|
||||
border-bottom: 1px solid var(--sl-color-hairline);
|
||||
}
|
||||
|
||||
.mobile-theme-bar .nova-theme-select {
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
}
|
||||
|
||||
.mobile-theme-bar .theme-label {
|
||||
font-size: 0.9rem;
|
||||
color: var(--sl-color-gray-3);
|
||||
}
|
||||
|
||||
@media (min-width: 50rem) {
|
||||
.mobile-theme-bar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script is:inline>
|
||||
(() => {
|
||||
const SHOW_CLASS = "show-hidden-sidebar-items";
|
||||
const truthy = new Set(["true", "1", "yes", "y", "on"]);
|
||||
|
||||
const shouldShowHidden = () => {
|
||||
const value = new URLSearchParams(window.location.search).get("showHiddens");
|
||||
if (!value) return false;
|
||||
return truthy.has(value.trim().toLowerCase());
|
||||
};
|
||||
|
||||
const applySidebarHiddenToggle = () => {
|
||||
const showHidden = shouldShowHidden();
|
||||
document.documentElement.classList.toggle(SHOW_CLASS, showHidden);
|
||||
|
||||
const sidebar = document.getElementById("starlight__sidebar");
|
||||
if (!sidebar) return;
|
||||
|
||||
const hiddenLinks = sidebar.querySelectorAll("a[data-cel-hidden='true']");
|
||||
for (const link of hiddenLinks) {
|
||||
link.toggleAttribute("hidden", !showHidden);
|
||||
}
|
||||
|
||||
const listItems = sidebar.querySelectorAll("li");
|
||||
for (const item of listItems) {
|
||||
const directLink = item.querySelector(":scope > a[href]");
|
||||
if (directLink) {
|
||||
item.toggleAttribute("hidden", !showHidden && directLink.hasAttribute("hidden"));
|
||||
continue;
|
||||
}
|
||||
|
||||
const descendantLinks = item.querySelectorAll("a[href]");
|
||||
if (descendantLinks.length === 0) continue;
|
||||
|
||||
const hasVisibleLink = Array.from(descendantLinks).some(
|
||||
(link) => !link.hasAttribute("hidden"),
|
||||
);
|
||||
item.toggleAttribute("hidden", !showHidden && !hasVisibleLink);
|
||||
}
|
||||
};
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", applySidebarHiddenToggle, { once: true });
|
||||
} else {
|
||||
applySidebarHiddenToggle();
|
||||
}
|
||||
|
||||
document.addEventListener("astro:page-load", applySidebarHiddenToggle);
|
||||
})();
|
||||
</script>
|
||||
204
samples/src/components/CustomMobileTableOfContents.astro
Normal file
204
samples/src/components/CustomMobileTableOfContents.astro
Normal file
@@ -0,0 +1,204 @@
|
||||
---
|
||||
import TableOfContentsList from "./TableOfContentsList.astro";
|
||||
|
||||
const parseFlag = (value) => {
|
||||
if (typeof value === "boolean") return value;
|
||||
if (typeof value === "string") {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (["true", "1", "yes", "y", "on"].includes(normalized)) return true;
|
||||
if (["false", "0", "no", "n", "off"].includes(normalized)) return false;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const route = Astro.locals.starlightRoute ?? {};
|
||||
const { toc } = route;
|
||||
const entryData = route.entry?.data ?? {};
|
||||
const frontmatter = entryData.frontmatter ?? entryData;
|
||||
const enableNumbering = parseFlag(frontmatter.numberedHeadings);
|
||||
const filteredItems = (toc?.items ?? []).filter((item) => item.slug !== "_top");
|
||||
const title = Astro.locals.t("tableOfContents.onThisPage");
|
||||
---
|
||||
|
||||
{
|
||||
filteredItems.length > 0 && (
|
||||
<mobile-starlight-toc data-min-h={toc?.minHeadingLevel} data-max-h={toc?.maxHeadingLevel}>
|
||||
<nav aria-labelledby="starlight__on-this-page--mobile">
|
||||
<details id="starlight__mobile-toc">
|
||||
<summary id="starlight__on-this-page--mobile" class="sl-flex">
|
||||
<div class="toggle sl-flex">
|
||||
<a href="#_top">{title}</a>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class="caret"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
style="--sl-icon-size: 1rem;"
|
||||
>
|
||||
<path d="m14.83 11.29-4.24-4.24a1 1 0 1 0-1.42 1.41L12.71 12l-3.54 3.54a1 1 0 0 0 0 1.41 1 1 0 0 0 .71.29 1 1 0 0 0 .71-.29l4.24-4.24a1.002 1.002 0 0 0 0-1.42Z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span class="display-current" />
|
||||
</summary>
|
||||
<div class="dropdown">
|
||||
<TableOfContentsList toc={filteredItems} isMobile enableNumbering={enableNumbering} />
|
||||
</div>
|
||||
</details>
|
||||
</nav>
|
||||
</mobile-starlight-toc>
|
||||
)
|
||||
}
|
||||
|
||||
<style>
|
||||
@layer starlight.core {
|
||||
:global(mobile-starlight-toc) {
|
||||
display: block !important;
|
||||
visibility: visible !important;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
nav {
|
||||
position: fixed;
|
||||
z-index: var(--sl-z-index-toc);
|
||||
top: calc(var(--sl-nav-height) - 1px);
|
||||
inset-inline: 0;
|
||||
border-top: 1px solid var(--sl-color-gray-5);
|
||||
background-color: var(--sl-color-bg-nav);
|
||||
display: block !important;
|
||||
}
|
||||
@media (min-width: 50rem) {
|
||||
nav {
|
||||
inset-inline-start: var(--sl-content-inline-start, 0);
|
||||
}
|
||||
}
|
||||
:global(.sidebar-collapsed) nav {
|
||||
inset-inline-start: 11px !important;
|
||||
inset-inline-end: 2px !important;
|
||||
}
|
||||
@media (min-width: 72rem) {
|
||||
nav {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
@media (max-width: 87.499rem) {
|
||||
:global([data-has-toc] mobile-starlight-toc nav) {
|
||||
display: block !important;
|
||||
visibility: visible !important;
|
||||
opacity: 1 !important;
|
||||
}
|
||||
}
|
||||
|
||||
summary {
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
height: var(--sl-mobile-toc-height);
|
||||
border-bottom: 1px solid var(--sl-color-hairline-shade);
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: var(--sl-text-xs);
|
||||
outline-offset: var(--sl-outline-offset-inside);
|
||||
}
|
||||
summary::marker,
|
||||
summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.toggle {
|
||||
flex-shrink: 0;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border: 1px solid var(--sl-color-gray-5);
|
||||
border-radius: 0.5rem;
|
||||
padding-block: 0.5rem;
|
||||
padding-inline-start: 0.75rem;
|
||||
padding-inline-end: 0.5rem;
|
||||
line-height: 1;
|
||||
background-color: var(--sl-color-black);
|
||||
user-select: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.toggle a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
.toggle a:hover,
|
||||
.toggle a:focus-visible {
|
||||
text-decoration: underline;
|
||||
}
|
||||
details[open] .toggle {
|
||||
color: var(--sl-color-white);
|
||||
border-color: var(--sl-color-accent);
|
||||
}
|
||||
details .toggle:hover {
|
||||
color: var(--sl-color-white);
|
||||
border-color: var(--sl-color-gray-2);
|
||||
}
|
||||
|
||||
:global([dir="rtl"]) .caret {
|
||||
transform: rotateZ(180deg);
|
||||
}
|
||||
details[open] .caret {
|
||||
transform: rotateZ(90deg);
|
||||
}
|
||||
|
||||
.display-current {
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
color: var(--sl-color-white);
|
||||
}
|
||||
|
||||
.dropdown {
|
||||
--border-top: 1px;
|
||||
margin-top: calc(-1 * var(--border-top));
|
||||
border: var(--border-top) solid var(--sl-color-gray-6);
|
||||
border-top-color: var(--sl-color-hairline-shade);
|
||||
max-height: calc(85vh - var(--sl-nav-height) - var(--sl-mobile-toc-height));
|
||||
overflow-y: auto;
|
||||
background-color: var(--sl-color-black);
|
||||
box-shadow: var(--sl-shadow-md);
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
import { StarlightTOC } from "./starlight-toc";
|
||||
|
||||
class MobileStarlightTOC extends StarlightTOC {
|
||||
override set current(link) {
|
||||
super.current = link;
|
||||
const display = this.querySelector(".display-current");
|
||||
if (display) display.textContent = link.textContent;
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
const details = this.querySelector("details");
|
||||
if (!details) return;
|
||||
const closeToC = () => {
|
||||
details.open = false;
|
||||
};
|
||||
details.querySelectorAll("a").forEach((a) => {
|
||||
a.addEventListener("click", closeToC);
|
||||
});
|
||||
window.addEventListener("click", (e) => {
|
||||
if (!details.contains(e.target)) closeToC();
|
||||
});
|
||||
window.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape" && details.open) {
|
||||
const hasFocus = details.contains(document.activeElement);
|
||||
closeToC();
|
||||
if (hasFocus) {
|
||||
const summary = details.querySelector("summary");
|
||||
if (summary) summary.focus();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("mobile-starlight-toc", MobileStarlightTOC);
|
||||
</script>
|
||||
254
samples/src/components/CustomTableOfContents.astro
Normal file
254
samples/src/components/CustomTableOfContents.astro
Normal file
@@ -0,0 +1,254 @@
|
||||
---
|
||||
import TableOfContentsList from "./TableOfContentsList.astro";
|
||||
|
||||
const parseFlag = (value) => {
|
||||
if (typeof value === "boolean") return value;
|
||||
if (typeof value === "string") {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (["true", "1", "yes", "y", "on"].includes(normalized)) return true;
|
||||
if (["false", "0", "no", "n", "off"].includes(normalized)) return false;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const route = Astro.locals.starlightRoute ?? {};
|
||||
const { toc } = route;
|
||||
const entryData = route.entry?.data ?? {};
|
||||
const frontmatter = entryData.frontmatter ?? entryData;
|
||||
const enableNumbering = parseFlag(frontmatter.numberedHeadings);
|
||||
const filteredItems = (toc?.items ?? []).filter((item) => item.slug !== "_top");
|
||||
const title = Astro.locals.t("tableOfContents.onThisPage");
|
||||
---
|
||||
|
||||
{
|
||||
filteredItems.length > 0 && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
class="toc-toggle"
|
||||
data-toc-toggle
|
||||
data-label-open="목차 숨기기"
|
||||
data-label-closed="목차 펼치기"
|
||||
aria-pressed="false"
|
||||
>
|
||||
<span class="toggle-icon" aria-hidden="true">⟫</span>
|
||||
</button>
|
||||
<starlight-toc data-min-h={toc?.minHeadingLevel} data-max-h={toc?.maxHeadingLevel}>
|
||||
<nav aria-labelledby="starlight__on-this-page">
|
||||
<h1 id="starlight__on-this-page">
|
||||
<a href="#_top">{title}</a>
|
||||
</h1>
|
||||
<TableOfContentsList toc={filteredItems} enableNumbering={enableNumbering} />
|
||||
</nav>
|
||||
</starlight-toc>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
<script src="./starlight-toc.ts"></script>
|
||||
<script>
|
||||
const root = document.documentElement;
|
||||
const toggleBtn = document.querySelector("[data-toc-toggle]");
|
||||
const collapsedClass = "toc-collapsed";
|
||||
const storageKey = "cel:toc-collapsed";
|
||||
|
||||
const isDesktopToc = () => window.innerWidth >= 1400;
|
||||
|
||||
let savedState = null;
|
||||
let lastSidebarRect = null;
|
||||
|
||||
const applyState = (isCollapsed, { persist = true } = {}) => {
|
||||
root.classList.toggle(collapsedClass, isCollapsed);
|
||||
|
||||
if (!toggleBtn) return;
|
||||
|
||||
const openLabel = toggleBtn.dataset.labelOpen ?? "목차 숨기기";
|
||||
const closedLabel = toggleBtn.dataset.labelClosed ?? "목차 펼치기";
|
||||
const label = isCollapsed ? closedLabel : openLabel;
|
||||
const icon = toggleBtn.querySelector(".toggle-icon");
|
||||
toggleBtn.setAttribute("aria-pressed", isCollapsed ? "true" : "false");
|
||||
toggleBtn.setAttribute("aria-label", label);
|
||||
if (icon) icon.textContent = isCollapsed ? "⟪" : "⟫";
|
||||
|
||||
if (persist) {
|
||||
savedState = isCollapsed ? "1" : "0";
|
||||
try {
|
||||
window.sessionStorage.setItem(storageKey, isCollapsed ? "1" : "0");
|
||||
} catch (error) {
|
||||
console.warn("Failed to persist TOC toggle state", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const setTogglePosition = () => {
|
||||
if (!toggleBtn) return;
|
||||
const isCollapsed = root.classList.contains(collapsedClass);
|
||||
const sidebar = document.querySelector(".right-sidebar");
|
||||
|
||||
if (sidebar) {
|
||||
const rectCandidate = sidebar.getBoundingClientRect();
|
||||
if (rectCandidate.width > 0 && rectCandidate.height > 0) {
|
||||
lastSidebarRect = rectCandidate;
|
||||
}
|
||||
}
|
||||
|
||||
const rect = lastSidebarRect;
|
||||
const fallbackLeft = window.innerWidth - 12;
|
||||
const anchorLeft = isCollapsed
|
||||
? fallbackLeft
|
||||
: rect
|
||||
? rect.left
|
||||
: fallbackLeft;
|
||||
const topCenter =
|
||||
rect && rect.height > 0 ? rect.top + rect.height / 2 : window.innerHeight / 2;
|
||||
|
||||
toggleBtn.style.left = `${anchorLeft}px`;
|
||||
toggleBtn.style.top = `${topCenter}px`;
|
||||
};
|
||||
|
||||
if (toggleBtn) {
|
||||
document.body.appendChild(toggleBtn);
|
||||
try {
|
||||
savedState = window.sessionStorage.getItem(storageKey);
|
||||
} catch (error) {
|
||||
console.warn("Failed to read TOC toggle state", error);
|
||||
}
|
||||
const resolveState = () => {
|
||||
if (!isDesktopToc()) return true;
|
||||
if (savedState !== null) return savedState === "1";
|
||||
return false;
|
||||
};
|
||||
|
||||
const syncState = () => {
|
||||
const desktop = isDesktopToc();
|
||||
const nextState = resolveState();
|
||||
const shouldPersist = desktop && savedState !== null;
|
||||
applyState(nextState, { persist: shouldPersist });
|
||||
setTogglePosition();
|
||||
};
|
||||
|
||||
syncState();
|
||||
|
||||
toggleBtn.addEventListener("click", () => {
|
||||
const nextState = !root.classList.contains(collapsedClass);
|
||||
applyState(nextState);
|
||||
setTogglePosition();
|
||||
});
|
||||
|
||||
window.addEventListener("resize", () => {
|
||||
window.requestAnimationFrame(() => syncState());
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
@layer starlight.core {
|
||||
.toc-toggle {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 12px;
|
||||
transform: translate(-100%, -50%);
|
||||
z-index: calc(var(--sl-z-index-navbar) + 10);
|
||||
display: none;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0;
|
||||
background: var(--sl-color-bg-sidebar);
|
||||
border: 1px solid var(--sl-color-hairline);
|
||||
border-right: 0;
|
||||
border-radius: 0;
|
||||
color: color-mix(in srgb, var(--sl-color-hairline-shade) 85%, var(--sl-color-text) 15%);
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: transform 0.15s ease, background-color 0.2s ease, border-color 0.2s ease;
|
||||
justify-content: center;
|
||||
position: fixed;
|
||||
}
|
||||
|
||||
.toc-toggle:hover {
|
||||
transform: translate(-100%, calc(-50% - 1px));
|
||||
background: var(--sl-color-bg);
|
||||
border-color: var(--sl-color-hairline-shade);
|
||||
}
|
||||
|
||||
.toc-toggle::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 1px;
|
||||
height: 100%;
|
||||
background: var(--sl-color-hairline);
|
||||
}
|
||||
|
||||
.toc-toggle:hover::after {
|
||||
background: var(--sl-color-hairline-shade);
|
||||
}
|
||||
|
||||
@media (min-width: 72rem) {
|
||||
.toc-toggle {
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
width: 44px;
|
||||
height: 64px;
|
||||
}
|
||||
}
|
||||
|
||||
h1#starlight__on-this-page {
|
||||
margin: 0 0 0.75rem;
|
||||
font-size: var(--sl-text-h1, var(--sl-text-3xl, 2.25rem));
|
||||
line-height: 1.25;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
h1#starlight__on-this-page a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
h1#starlight__on-this-page a:hover,
|
||||
h1#starlight__on-this-page a:focus-visible {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
@media (max-width: 71.999rem) {
|
||||
:global(.sidebar-collapsed .main-frame) {
|
||||
padding-inline-start: 0 !important;
|
||||
}
|
||||
|
||||
:global(.sidebar-collapsed .main-pane) {
|
||||
margin-inline: auto !important;
|
||||
width: min(100%, var(--sl-content-width)) !important;
|
||||
max-width: var(--sl-content-width) !important;
|
||||
padding-inline: clamp(1rem, 4vw, 2rem) !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 72rem) and (max-width: 87.499rem) {
|
||||
:global(.toc-collapsed .right-sidebar-container),
|
||||
:global(.toc-collapsed .right-sidebar-panel) {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
:global(.toc-collapsed [data-has-sidebar][data-has-toc] .main-pane) {
|
||||
--sl-content-margin-inline: auto;
|
||||
width: calc(100% - var(--sl-sidebar-width));
|
||||
max-width: calc(100% - var(--sl-sidebar-width));
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 87.5rem) {
|
||||
:global(.toc-collapsed .right-sidebar-container),
|
||||
:global(.toc-collapsed .right-sidebar-panel) {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
:global(.toc-collapsed [data-has-sidebar][data-has-toc] .main-pane) {
|
||||
--sl-content-margin-inline: auto;
|
||||
width: calc(100% - var(--sl-sidebar-width));
|
||||
max-width: calc(100% - var(--sl-sidebar-width));
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
22
samples/src/components/GridGallery.astro
Normal file
22
samples/src/components/GridGallery.astro
Normal file
@@ -0,0 +1,22 @@
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
<div class="grid-gallery">
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.grid-gallery {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1.5rem;
|
||||
margin: 2rem 0;
|
||||
}
|
||||
|
||||
@media (min-width: 40rem) {
|
||||
.grid-gallery {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
38
samples/src/components/GridItem.astro
Normal file
38
samples/src/components/GridItem.astro
Normal file
@@ -0,0 +1,38 @@
|
||||
---
|
||||
const { title } = Astro.props;
|
||||
---
|
||||
|
||||
<div class="grid-item">
|
||||
<div class="media">
|
||||
<slot name="media" />
|
||||
</div>
|
||||
<div class="content">
|
||||
{title && <h3>{title}</h3>}
|
||||
<slot name="content" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.grid-item {
|
||||
border: 1px solid var(--sl-color-gray-5);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: var(--sl-color-bg-nav);
|
||||
}
|
||||
|
||||
.media img {
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.content h3 {
|
||||
margin-top: 0;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
</style>
|
||||
61
samples/src/components/LoginCarousel.astro
Normal file
61
samples/src/components/LoginCarousel.astro
Normal file
@@ -0,0 +1,61 @@
|
||||
---
|
||||
interface Slide {
|
||||
image: string;
|
||||
title: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
const { slides }: { slides: Slide[] } = Astro.props;
|
||||
---
|
||||
|
||||
<div class="login-carousel" data-carousel>
|
||||
<button class="nav prev" type="button" aria-label="이전 슬라이드">‹</button>
|
||||
|
||||
<div class="track" data-track>
|
||||
{slides.map((slide, idx) => (
|
||||
<article class={`slide ${idx === 0 ? "is-active" : ""}`} data-index={idx}>
|
||||
<img src={slide.image} alt={slide.title} loading="lazy" />
|
||||
<div class="caption">
|
||||
<h4>{slide.title}</h4>
|
||||
<p>{slide.body}</p>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button class="nav next" type="button" aria-label="다음 슬라이드">›</button>
|
||||
|
||||
<div class="dots" aria-hidden="true">
|
||||
{slides.map((_, idx) => (
|
||||
<button
|
||||
class={`dot ${idx === 0 ? "is-active" : ""}`}
|
||||
type="button"
|
||||
data-dot={idx}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
if (typeof window !== "undefined") {
|
||||
const carousels = document.querySelectorAll("[data-carousel]");
|
||||
|
||||
carousels.forEach((root) => {
|
||||
const slides = [...root.querySelectorAll(".slide")];
|
||||
const dots = [...root.querySelectorAll(".dot")];
|
||||
const prev = root.querySelector(".prev");
|
||||
const next = root.querySelector(".next");
|
||||
let current = 0;
|
||||
|
||||
const activate = (idx) => {
|
||||
current = (idx + slides.length) % slides.length;
|
||||
slides.forEach((el, i) => el.classList.toggle("is-active", i === current));
|
||||
dots.forEach((el, i) => el.classList.toggle("is-active", i === current));
|
||||
};
|
||||
|
||||
prev?.addEventListener("click", () => activate(current - 1));
|
||||
next?.addEventListener("click", () => activate(current + 1));
|
||||
dots.forEach((dot, i) => dot.addEventListener("click", () => activate(i)));
|
||||
});
|
||||
}
|
||||
</script>
|
||||
444
samples/src/components/SearchBar.astro
Normal file
444
samples/src/components/SearchBar.astro
Normal file
@@ -0,0 +1,444 @@
|
||||
---
|
||||
import Fuse from "fuse.js";
|
||||
import { generateSearchData } from '../lib/searchIndex.js';
|
||||
const searchData = await generateSearchData(); // 서버에서 생성
|
||||
---
|
||||
|
||||
<div class="searchbar-wrapper">
|
||||
<!-- 검색 입력 영역 -->
|
||||
<div class="search-container">
|
||||
<div class="search-input-wrapper">
|
||||
<input id="search-input" type="text" placeholder="검색" autocomplete="off" />
|
||||
<button id="search-clear-btn" class="search-clear-btn" title="검색어 지우기">✕</button>
|
||||
<span class="search-icon" title="검색">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" >
|
||||
<path d="M11.1681 6.18235C11.1681 3.42879 8.93592 1.19658 6.18235 1.19658C3.42879 1.19658 1.19658 3.42879 1.19658 6.18235C1.19658 8.93592 3.42879 11.1681 6.18235 11.1681C8.93592 11.1681 11.1681 8.93592 11.1681 6.18235ZM12.3647 6.18235C12.3647 7.67411 11.836 9.04217 10.9562 10.1102L15.3803 14.5343C15.614 14.7679 15.614 15.1467 15.3803 15.3803C15.1467 15.614 14.7679 15.614 14.5343 15.3803L10.1102 10.9562C9.04217 11.836 7.67411 12.3647 6.18235 12.3647C2.76793 12.3647 0 9.59677 0 6.18235C0 2.76793 2.76793 0 6.18235 0C9.59677 0 12.3647 2.76793 12.3647 6.18235Z" fill="#000"/>
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 최근 검색어 드롭다운 -->
|
||||
<div id="search-history-dropdown" class="search-history-dropdown" style="display:none;">
|
||||
<div class="search-history-inner">
|
||||
<ul id="history-list" class="history-list"></ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 검색 결과 영역 -->
|
||||
<div id="search-results-container" class="search-results-container" style="display: none;">
|
||||
<div class="search-results-header">
|
||||
<p class="results-count">
|
||||
<strong id="current-search-query"></strong><!-- 에 대한 결과 -->
|
||||
<span id="results-count">0</span> <!--건-->
|
||||
</p>
|
||||
</div>
|
||||
<div class="search-list-wrap">
|
||||
<div id="search-results-list" class="search-results-list">
|
||||
<!-- 검색 결과가 여기에 동적으로 표시됩니다 -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script define:vars={{ searchData }}>
|
||||
document.addEventListener('astro:page-load', () => new SearchManager(searchData));
|
||||
|
||||
class SearchManager {
|
||||
constructor(searchData) {
|
||||
this.data = searchData;
|
||||
this.fuse = new Fuse(searchData, {
|
||||
includeScore: true,
|
||||
threshold: 0.6,
|
||||
ignoreLocation: true,
|
||||
minMatchCharLength: 1,
|
||||
distance: 200,
|
||||
keys: [
|
||||
{ name: "title", weight: 0.65 },
|
||||
{ name: "fullContent", weight: 0.35 },
|
||||
],
|
||||
});
|
||||
this.input = document.getElementById('search-input');
|
||||
this.clearBtn = document.getElementById('search-clear-btn');
|
||||
this.dropdown = document.getElementById('search-history-dropdown');
|
||||
this.historyList = document.getElementById('history-list');
|
||||
this.resultsContainer = document.getElementById('search-results-container');
|
||||
this.resultsList = document.getElementById('search-results-list');
|
||||
this.resultsCount = document.getElementById('results-count');
|
||||
this.currentQuery = document.getElementById('current-search-query');
|
||||
|
||||
this.history = JSON.parse(localStorage.getItem('searchHistory') || '[]');
|
||||
this.historyFocusIndex = -1;
|
||||
this.resultsFocusIndex = -1;
|
||||
this.currentResultItems = [];
|
||||
this.debounceTimer = null;
|
||||
this.debug = this.isDebugEnabled();
|
||||
|
||||
if (!this.input || !this.clearBtn || !this.dropdown || !this.historyList) return;
|
||||
|
||||
this.bindEvents();
|
||||
this.updateClearButton();
|
||||
this.initURLQuery();
|
||||
}
|
||||
|
||||
bindEvents() {
|
||||
this.input.addEventListener('focus', () => this.onFocus());
|
||||
this.input.addEventListener('input', () => this.onInput());
|
||||
this.input.addEventListener('keydown', e => this.onKeydown(e));
|
||||
this.clearBtn.addEventListener('click', e => this.onClear(e));
|
||||
document.addEventListener('mousedown', e => this.onOutsideClick(e), true);
|
||||
}
|
||||
|
||||
normalize(str) {
|
||||
return str.replace(/ |[\s]+/g, ' ').toLowerCase();
|
||||
}
|
||||
|
||||
normalizeLoose(str) {
|
||||
return this.normalize(str).replace(/\s+/g, '');
|
||||
}
|
||||
|
||||
onFocus() {
|
||||
const value = this.input.value.trim();
|
||||
if (value.length >= 2) this.performSearch(value);
|
||||
else this.updateHistoryDisplay();
|
||||
}
|
||||
|
||||
onInput() {
|
||||
this.updateClearButton();
|
||||
clearTimeout(this.debounceTimer);
|
||||
const value = this.input.value.trim();
|
||||
if (value.length >= 2) {
|
||||
this.debounceTimer = setTimeout(() => this.performSearch(value), 250);
|
||||
} else {
|
||||
this.clearResults();
|
||||
this.updateHistoryDisplay();
|
||||
}
|
||||
}
|
||||
|
||||
onClear(e) {
|
||||
e.preventDefault();
|
||||
this.input.value = '';
|
||||
this.updateClearButton();
|
||||
this.clearResults();
|
||||
this.updateHistoryDisplay();
|
||||
this.input.focus();
|
||||
}
|
||||
|
||||
onOutsideClick(e) {
|
||||
if (!e.target.closest('.searchbar-wrapper')) {
|
||||
this.hideDropdown();
|
||||
this.hideResults();
|
||||
if (this.input !== document.activeElement) this.clearResults();
|
||||
}
|
||||
}
|
||||
|
||||
performSearch(queryRaw) {
|
||||
const query = queryRaw.trim();
|
||||
if (query.length < 2) return this.clearResults();
|
||||
// 🔹 검색 히스토리에 추가
|
||||
this.saveToHistory(query);
|
||||
|
||||
const queryLoose = this.normalizeLoose(query);
|
||||
const fuseResults = this.fuse.search(query);
|
||||
const fuzzyMatched = fuseResults.map(r => ({ ...r.item, score: r.score ?? 0 }));
|
||||
|
||||
// 단순 포함(공백 제거) fallback
|
||||
const simpleMatched = this.data
|
||||
.filter(d => {
|
||||
const titleLoose = this.normalizeLoose(d.title);
|
||||
const contentLoose = this.normalizeLoose(d.fullContent || '');
|
||||
return titleLoose.includes(queryLoose) || contentLoose.includes(queryLoose);
|
||||
})
|
||||
.map(d => ({ ...d, score: 0.8 })); // fuzz보다 낮은 우선순위지만 결과 보강
|
||||
|
||||
// 병합 & dedup by url
|
||||
const merged = [...fuzzyMatched, ...simpleMatched];
|
||||
const seen = new Set();
|
||||
const matched = merged.filter(item => {
|
||||
if (seen.has(item.url)) return false;
|
||||
seen.add(item.url);
|
||||
return true;
|
||||
});
|
||||
|
||||
// type별 그룹화
|
||||
const grouped = matched.reduce((acc, item) => {
|
||||
(acc[item.type] = acc[item.type] || []).push(item);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
// 각 그룹 최대 50개 제한 + 점수로 정렬
|
||||
Object.keys(grouped).forEach(k => {
|
||||
grouped[k] = grouped[k]
|
||||
.sort((a, b) => a.score - b.score)
|
||||
.slice(0, 50);
|
||||
});
|
||||
|
||||
// flatten
|
||||
const finalResults = Object.values(grouped).flat();
|
||||
this.renderResults(finalResults, query);
|
||||
this.hideDropdown();
|
||||
this.logDebugResults(finalResults, query);
|
||||
}
|
||||
|
||||
executeSearch(queryRaw) {
|
||||
if (queryRaw.trim().length < 2) return;
|
||||
this.saveToHistory(queryRaw);
|
||||
this.performSearch(queryRaw);
|
||||
}
|
||||
|
||||
renderResults(results, query) {
|
||||
this.currentQuery.textContent = query;
|
||||
this.resultsFocusIndex = -1;
|
||||
|
||||
if (!results.length) {
|
||||
this.resultsList.innerHTML = `
|
||||
<div class="no-results">
|
||||
<div class="no-results-text">
|
||||
<strong>"${query}"</strong>에 대한 검색 결과가 없습니다.
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
this.resultsCount.textContent = '0';
|
||||
this.showResults();
|
||||
this.currentResultItems = [];
|
||||
return;
|
||||
}
|
||||
|
||||
// 그룹화
|
||||
const groups = {
|
||||
command: results.filter(r => r.type === 'mdx').slice(0, 50),
|
||||
page: results.filter(r => r.type !== 'mdx').slice(0, 50),
|
||||
};
|
||||
|
||||
// 🔹 현재 활성 탭 확인
|
||||
const activeTab = document.querySelector('[data-tab].active');
|
||||
const activeType = activeTab?.dataset.tab;
|
||||
const order = activeType === 'commands' ? ['command', 'page'] : ['page', 'command'];
|
||||
|
||||
// 🔹 그룹별 카운트
|
||||
const countCommand = groups.command.length;
|
||||
const countPage = groups.page.length;
|
||||
const totalCount = countCommand + countPage;
|
||||
|
||||
// 🔹 결과 수 표시 수정
|
||||
/*
|
||||
this.resultsCount.innerHTML = `
|
||||
${totalCount}건
|
||||
<span class="sub-count">(가이드 ${countPage}건 / 명령어 ${countCommand}건)</span>
|
||||
`;
|
||||
*/
|
||||
|
||||
const debugBadge = this.debug
|
||||
? '<span class="result-badge badge-score">DEBUG: score 표시</span>'
|
||||
: '';
|
||||
|
||||
this.resultsCount.innerHTML = `
|
||||
가이드 <strong>${countPage} 건</strong> / 명령어 <strong>${countCommand} 건</strong>
|
||||
${debugBadge}
|
||||
`;
|
||||
|
||||
// 🔹 HTML 조립
|
||||
const htmlSections = [];
|
||||
for (const type of order) {
|
||||
const group = groups[type];
|
||||
if (!group.length) continue;
|
||||
|
||||
const title = type === 'command' ? '명령어' : '가이드';
|
||||
htmlSections.push(`
|
||||
<div class="search-group" data-tab="${type}">
|
||||
<div class="group-title">${title}</div>
|
||||
${group.map(item => this.renderResultItem(item, query)).join('')}
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
|
||||
this.resultsList.innerHTML = htmlSections.join('');
|
||||
this.showResults();
|
||||
|
||||
this.currentResultItems = Array.from(this.resultsList.querySelectorAll('.search-result-item'));
|
||||
this.bindResultClicks();
|
||||
this.resultsContainer.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
|
||||
|
||||
renderResultItem(item, query) {
|
||||
const titleHtml = item.title.replace(
|
||||
new RegExp(`(${query.replace(/[.*+?^${}()|[\]\\]/g,'\\$&')})`, 'gi'),
|
||||
'<mark>$1</mark>'
|
||||
);
|
||||
const typeBadge = item.type === 'mdx'
|
||||
? '<span class="result-badge badge-command">명령어</span>'
|
||||
: '<span class="result-badge badge-page">가이드</span>';
|
||||
const urlWithQuery = `${item.url}?search=${encodeURIComponent(query)}`;
|
||||
const scoreBadge = this.debug && typeof item.score === 'number'
|
||||
? `<span class="result-badge badge-score">score: ${item.score.toFixed(3)}</span>`
|
||||
: '';
|
||||
|
||||
return `
|
||||
<div class="search-result-item" tabindex="-1" data-type="${item.type}">
|
||||
<a href="${urlWithQuery}">
|
||||
<div class="result-title">
|
||||
<span class="title-text">${titleHtml}</span>
|
||||
${typeBadge}
|
||||
${scoreBadge}
|
||||
</div>
|
||||
<div class="result-preview">${this.createPreview(item.fullContent, query)}</div>
|
||||
</a>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
|
||||
createPreview(content, query) {
|
||||
const normalizedContent = content.replace(/ |[\s]+/g, ' ');
|
||||
const pos = normalizedContent.toLowerCase().indexOf(query.toLowerCase());
|
||||
const snippet = pos === -1
|
||||
? normalizedContent.slice(0, 300)
|
||||
: normalizedContent.slice(Math.max(0, pos - 100), Math.min(normalizedContent.length, pos + query.length + 200));
|
||||
const segment = snippet.split('\n').slice(0, 2).join(' ').trim();
|
||||
return segment.replace(new RegExp(`(${query.replace(/[.*+?^${}()|[\]\\]/g,'\\$&')})`, 'gi'), '<mark>$1</mark>') + '...';
|
||||
}
|
||||
|
||||
bindResultClicks() {
|
||||
this.currentResultItems.forEach(item => {
|
||||
const link = item.querySelector('a');
|
||||
if (!link) return;
|
||||
link.addEventListener('click', e => {
|
||||
const targetPath = new URL(link.href, location.origin).pathname;
|
||||
const sidebar = document.querySelector('.lnb-container');
|
||||
if (sidebar) {
|
||||
sidebar.querySelectorAll('.depth01 > li').forEach(li => li.classList.remove('active'));
|
||||
sidebar.querySelectorAll('.depth02:not(.single)').forEach(depth => depth.classList.add('d-none'));
|
||||
}
|
||||
|
||||
document.addEventListener('astro:page-load', () => {
|
||||
const sidebar = document.querySelector('.lnb-container');
|
||||
if (!sidebar) return;
|
||||
const targetAnchor = [...sidebar.querySelectorAll('a[data-page]')].find(a => new URL(a.href, location.origin).pathname === targetPath);
|
||||
if (targetAnchor) {
|
||||
const li = targetAnchor.closest('li');
|
||||
const parentLi = targetAnchor.closest('.depth01 > li');
|
||||
li?.classList.add('active');
|
||||
parentLi?.classList.add('active');
|
||||
parentLi?.querySelector('.depth02')?.classList.remove('d-none');
|
||||
}
|
||||
}, { once: true });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
clearResults() {
|
||||
this.hideResults();
|
||||
this.resultsList.innerHTML = '';
|
||||
this.currentResultItems = [];
|
||||
this.resultsFocusIndex = -1;
|
||||
}
|
||||
|
||||
saveToHistory(query) {
|
||||
const q = query.trim();
|
||||
this.history = [q, ...this.history.filter(h => h.toLowerCase() !== q.toLowerCase())].slice(0, 10);
|
||||
localStorage.setItem('searchHistory', JSON.stringify(this.history));
|
||||
}
|
||||
|
||||
renderHistory() {
|
||||
if (!this.history.length) { this.dropdown.classList.remove("on"); this.dropdown.style.display='none'; return; }
|
||||
this.historyList.innerHTML = this.history.map(q => `
|
||||
<li class="history-item" tabindex="-1">
|
||||
<span class="text" data-query="${q}">${q}</span>
|
||||
<button class="delete-btn" title="삭제">✕</button>
|
||||
</li>
|
||||
`).join('');
|
||||
this.historyList.querySelectorAll('.history-item').forEach(item => {
|
||||
const text = item.querySelector('.text');
|
||||
const del = item.querySelector('.delete-btn');
|
||||
text.addEventListener('click', () => this.selectHistoryItem(text.dataset.query));
|
||||
del.addEventListener('click', e => this.deleteHistoryItem(e, text.dataset.query));
|
||||
});
|
||||
}
|
||||
|
||||
updateHistoryDisplay() {
|
||||
if (!this.input.value.trim() && this.history.length > 0) this.renderHistory(), this.showDropdown();
|
||||
else this.hideDropdown();
|
||||
this.historyFocusIndex = -1;
|
||||
}
|
||||
|
||||
deleteHistoryItem(e, query) {
|
||||
e.stopPropagation();
|
||||
this.history = this.history.filter(h => h !== query);
|
||||
localStorage.setItem('searchHistory', JSON.stringify(this.history));
|
||||
this.updateHistoryDisplay();
|
||||
}
|
||||
|
||||
selectHistoryItem(query) {
|
||||
this.input.value = query;
|
||||
this.updateClearButton();
|
||||
this.executeSearch(query);
|
||||
this.input.focus();
|
||||
}
|
||||
|
||||
updateClearButton() {
|
||||
this.clearBtn.style.display = this.input.value.trim() ? 'flex' : 'none';
|
||||
}
|
||||
|
||||
showDropdown() { this.dropdown.classList.add('on'); this.dropdown.style.display='block'; }
|
||||
hideDropdown() { this.dropdown.classList.remove('on'); this.dropdown.style.display='none'; this.historyFocusIndex=-1; }
|
||||
showResults() { this.resultsContainer.classList.add('on'); this.resultsContainer.style.display='block'; }
|
||||
hideResults() { this.resultsContainer.classList.remove('on'); this.resultsContainer.style.display='none'; }
|
||||
|
||||
initURLQuery() {
|
||||
const urlQuery = new URLSearchParams(window.location.search).get('search');
|
||||
if (urlQuery) { this.input.value = urlQuery; this.updateClearButton(); this.executeSearch(urlQuery); }
|
||||
}
|
||||
|
||||
isDebugEnabled() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
if (params.get('search_debug') === '1') {
|
||||
localStorage.setItem('searchDebug', '1');
|
||||
return true;
|
||||
}
|
||||
const stored = localStorage.getItem('searchDebug');
|
||||
return stored === '1';
|
||||
}
|
||||
|
||||
logDebugResults(_results, _query) {
|
||||
// 콘솔 디버그 제거
|
||||
}
|
||||
|
||||
onKeydown(e) {
|
||||
const value = this.input.value.trim();
|
||||
if (e.key==='Escape'){this.hideDropdown(); this.hideResults(); this.historyFocusIndex=-1; this.resultsFocusIndex=-1; return;}
|
||||
|
||||
if (this.dropdown.classList.contains('on')){
|
||||
const items = Array.from(this.historyList.querySelectorAll('.history-item'));
|
||||
if (!items.length) return;
|
||||
if (e.key==='ArrowDown'){ this.historyFocusIndex = (this.historyFocusIndex+1) % items.length; items.forEach(i=>i.classList.remove('focused')); items[this.historyFocusIndex].classList.add('focused'); e.preventDefault(); }
|
||||
if (e.key==='ArrowUp'){ this.historyFocusIndex = (this.historyFocusIndex-1+items.length) % items.length; items.forEach(i=>i.classList.remove('focused')); items[this.historyFocusIndex].classList.add('focused'); e.preventDefault(); }
|
||||
if (e.key==='Enter'){ if(this.historyFocusIndex>-1){ const q=items[this.historyFocusIndex].querySelector('.text').dataset.query; this.selectHistoryItem(q); e.preventDefault(); } }
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.resultsContainer.classList.contains('on')){
|
||||
if (!this.currentResultItems.length) return;
|
||||
if (e.key==='ArrowDown'){ this.resultsFocusIndex = (this.resultsFocusIndex+1) % this.currentResultItems.length; this.focusResult(this.resultsFocusIndex); e.preventDefault(); }
|
||||
if (e.key==='ArrowUp'){ this.resultsFocusIndex = (this.resultsFocusIndex-1+this.currentResultItems.length+1) % this.currentResultItems.length; this.focusResult(this.resultsFocusIndex); e.preventDefault(); }
|
||||
if (e.key==='Enter'){ if(this.resultsFocusIndex>-1){ this.currentResultItems[this.resultsFocusIndex].querySelector('a').click(); e.preventDefault(); } }
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
focusResult(index) {
|
||||
this.currentResultItems.forEach(i=>i.classList.remove('focused'));
|
||||
if (index>-1) this.currentResultItems[index].classList.add('focused'), this.currentResultItems[index].scrollIntoView({block:'nearest'});
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
if (params.get('search_debug') === '1' || localStorage.getItem('searchDebug') === '1') {
|
||||
document.body.insertAdjacentHTML(
|
||||
'afterbegin',
|
||||
'<div style="position:fixed;top:0;right:0;padding:6px 10px;background:#111;color:#fff;font-size:12px;z-index:9999;">SEARCH DEBUG ON</div>'
|
||||
);
|
||||
}
|
||||
}
|
||||
190
samples/src/components/SidebarToggle.astro
Normal file
190
samples/src/components/SidebarToggle.astro
Normal file
@@ -0,0 +1,190 @@
|
||||
---
|
||||
const labelOpen = "사이드바 접기";
|
||||
const labelClosed = "사이드바 펼치기";
|
||||
---
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="sidebar-toggle"
|
||||
data-sidebar-toggle
|
||||
data-label-open={labelOpen}
|
||||
data-label-closed={labelClosed}
|
||||
aria-pressed="false"
|
||||
>
|
||||
<span class="toggle-icon" aria-hidden="true">⟫</span>
|
||||
</button>
|
||||
|
||||
<script>
|
||||
const root = document.documentElement;
|
||||
const toggleBtn = document.querySelector("[data-sidebar-toggle]");
|
||||
const collapsedClass = "sidebar-collapsed";
|
||||
const storageKey = "cel:sidebar-collapsed";
|
||||
|
||||
const getDefaultCollapsed = () => {
|
||||
const width = window.innerWidth;
|
||||
if (width < 800) return true;
|
||||
if (width < 1000) return true;
|
||||
return false;
|
||||
};
|
||||
|
||||
let savedState = null;
|
||||
|
||||
const applyState = (isCollapsed, { persist = true } = {}) => {
|
||||
root.classList.toggle(collapsedClass, isCollapsed);
|
||||
|
||||
if (!toggleBtn) return;
|
||||
const label = isCollapsed ? toggleBtn.dataset.labelClosed ?? "사이드바 펼치기" : toggleBtn.dataset.labelOpen ?? "사이드바 접기";
|
||||
const icon = toggleBtn.querySelector(".toggle-icon");
|
||||
|
||||
toggleBtn.setAttribute("aria-pressed", isCollapsed ? "true" : "false");
|
||||
toggleBtn.setAttribute("aria-label", label);
|
||||
if (icon) icon.textContent = isCollapsed ? "⟫" : "⟪";
|
||||
if (persist) {
|
||||
savedState = isCollapsed ? "1" : "0";
|
||||
try {
|
||||
window.sessionStorage.setItem(storageKey, isCollapsed ? "1" : "0");
|
||||
} catch (error) {
|
||||
console.warn("Failed to persist sidebar toggle state", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const getToggleTop = () => {
|
||||
const header = document.querySelector("header.header") ?? document.querySelector("header");
|
||||
const headerRect = header?.getBoundingClientRect();
|
||||
if (headerRect) return headerRect.bottom + 14;
|
||||
return 22;
|
||||
};
|
||||
|
||||
const setTogglePosition = () => {
|
||||
if (!toggleBtn) return;
|
||||
const isCollapsed = root.classList.contains(collapsedClass);
|
||||
const sidebarPane = document.getElementById("starlight__sidebar");
|
||||
const rect = sidebarPane?.getBoundingClientRect();
|
||||
const sidebarWidth = parseFloat(getComputedStyle(root).getPropertyValue("--sl-sidebar-width") || "0") || 0;
|
||||
|
||||
const fallbackLeft = sidebarWidth > 0 ? sidebarWidth : 12;
|
||||
const anchorLeft = rect && rect.width > 0 ? rect.right : fallbackLeft;
|
||||
const top = Math.max(12, getToggleTop());
|
||||
|
||||
toggleBtn.style.left = `${anchorLeft}px`;
|
||||
toggleBtn.style.top = `${top}px`;
|
||||
toggleBtn.style.transform = "translate(-90%, 0)";
|
||||
};
|
||||
|
||||
if (toggleBtn) {
|
||||
document.body.appendChild(toggleBtn);
|
||||
|
||||
try {
|
||||
savedState = window.sessionStorage.getItem(storageKey);
|
||||
} catch (error) {
|
||||
console.warn("Failed to read sidebar toggle state", error);
|
||||
}
|
||||
|
||||
const resolveState = () => {
|
||||
const width = window.innerWidth;
|
||||
if (width < 800) return true;
|
||||
if (savedState !== null) return savedState === "1";
|
||||
return getDefaultCollapsed();
|
||||
};
|
||||
|
||||
const initialCollapsed = resolveState();
|
||||
|
||||
applyState(initialCollapsed, { persist: savedState !== null });
|
||||
setTogglePosition();
|
||||
|
||||
toggleBtn.addEventListener("click", () => {
|
||||
const nextState = !root.classList.contains(collapsedClass);
|
||||
applyState(nextState, { persist: true });
|
||||
setTogglePosition();
|
||||
});
|
||||
|
||||
window.addEventListener("resize", () => {
|
||||
window.requestAnimationFrame(() => {
|
||||
const nextState = resolveState();
|
||||
const shouldPersist = savedState !== null && window.innerWidth >= 800;
|
||||
applyState(nextState, { persist: shouldPersist });
|
||||
setTogglePosition();
|
||||
});
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
@layer starlight.core {
|
||||
.sidebar-toggle {
|
||||
position: fixed;
|
||||
top: auto;
|
||||
left: 12px;
|
||||
transform: translate(-90%);
|
||||
z-index: calc(var(--sl-z-index-navbar) + 8);
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 30px;
|
||||
height: 40px;
|
||||
padding: 0;
|
||||
background: var(--sl-color-bg-sidebar);
|
||||
border: 1px solid var(--sl-color-hairline);
|
||||
border-right: 0;
|
||||
border-radius: 0;
|
||||
color: color-mix(in srgb, var(--sl-color-hairline-shade) 85%, var(--sl-color-text) 15%);
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: transform 0.15s ease, background-color 0.2s ease, border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.sidebar-toggle:hover {
|
||||
transform: translate(-90%, -1px);
|
||||
background: var(--sl-color-bg);
|
||||
border-color: var(--sl-color-hairline-shade);
|
||||
}
|
||||
|
||||
.sidebar-toggle::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: auto;
|
||||
right: 0;
|
||||
width: 1px;
|
||||
height: 100%;
|
||||
background: var(--sl-color-hairline);
|
||||
}
|
||||
|
||||
.sidebar-toggle:hover::before {
|
||||
background: var(--sl-color-hairline-shade);
|
||||
}
|
||||
|
||||
@media (min-width: 50rem) {
|
||||
.sidebar-toggle {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
:global(.sidebar-collapsed [data-has-sidebar]) {
|
||||
--sl-content-inline-start: 0 !important;
|
||||
}
|
||||
|
||||
:global(.sidebar-collapsed nav.sidebar) {
|
||||
transform: translateX(-100%);
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
width: 0;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
:global(.sidebar-collapsed .sidebar-pane) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
:global(.sidebar-collapsed .main-frame) {
|
||||
padding-inline-start: 0 !important;
|
||||
}
|
||||
|
||||
:global(.sidebar-collapsed .main-pane) {
|
||||
width: 100% !important;
|
||||
max-width: 100% !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
154
samples/src/components/SiteTitleWithSelect.astro
Normal file
154
samples/src/components/SiteTitleWithSelect.astro
Normal file
@@ -0,0 +1,154 @@
|
||||
---
|
||||
import { Logos } from "starlight-theme-nova/components/Logos";
|
||||
import type { Props } from "@astrojs/starlight/props";
|
||||
|
||||
const { siteTitle, locale } = Astro.props; // Starlight props
|
||||
|
||||
const sections = [
|
||||
{ label: "Civil DX", value: "/civil-dx/" },
|
||||
{ label: "기반기술", value: "/기반기술/" },
|
||||
{ label: "설계", value: "/설계/" },
|
||||
{ label: "시공", value: "/시공/" },
|
||||
];
|
||||
|
||||
const currentPath = decodeURIComponent(Astro.url.pathname);
|
||||
let currentSection = "";
|
||||
|
||||
// Determine current section
|
||||
for (const section of sections) {
|
||||
if (currentPath.includes(section.value)) {
|
||||
currentSection = section.value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
---
|
||||
|
||||
<div class="site-title-wrapper">
|
||||
<!-- Logo/Title -->
|
||||
<a
|
||||
href="/"
|
||||
class="site-title"
|
||||
aria-label={siteTitle ? siteTitle.textContent : "CivilEngineeringLab"}
|
||||
>
|
||||
<span class="logo-mark" data-testid="cel-mobile-mark">CEL</span>
|
||||
{
|
||||
siteTitle &&
|
||||
siteTitle.logoVisibleOnMobile !== false &&
|
||||
siteTitle.logo && (
|
||||
<img
|
||||
class="logo"
|
||||
src={siteTitle.logo.src}
|
||||
alt={siteTitle.logo.alt || "Logo"}
|
||||
width="32"
|
||||
height="32"
|
||||
/>
|
||||
)
|
||||
}
|
||||
<span class="title-text" data-testid="cel-full-title"
|
||||
>{siteTitle ? siteTitle.textContent : "CivilEngineeringLab"}</span
|
||||
>
|
||||
</a>
|
||||
|
||||
<!-- Divider -->
|
||||
<span class="divider">|</span>
|
||||
|
||||
<!-- Select Box -->
|
||||
<select class="section-select" onchange="window.location.href=this.value">
|
||||
<option value="" disabled selected={!currentSection}>섹션 선택</option>
|
||||
{
|
||||
sections.map((section) => (
|
||||
<option
|
||||
value={section.value}
|
||||
selected={section.value === currentSection}
|
||||
>
|
||||
{section.label}
|
||||
</option>
|
||||
))
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.site-title-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
.title-text {
|
||||
font-weight: bold;
|
||||
font-size: 1.2rem;
|
||||
color: var(--sl-color-text-accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
.site-title {
|
||||
text-decoration: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
min-height: 2.25rem;
|
||||
}
|
||||
|
||||
.logo-mark {
|
||||
display: none;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.12em;
|
||||
color: var(--sl-color-text);
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.divider {
|
||||
color: var(--sl-color-gray-4);
|
||||
}
|
||||
|
||||
.section-select {
|
||||
background-color: var(--sl-color-bg-nav);
|
||||
color: var(--sl-color-white);
|
||||
border: 1px solid var(--sl-color-gray-5);
|
||||
border-radius: 0.25rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.section-select:hover {
|
||||
border-color: var(--sl-color-gray-3);
|
||||
}
|
||||
|
||||
/* Mobile handling */
|
||||
@media (max-width: 64rem) {
|
||||
.site-title-wrapper {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.logo {
|
||||
display: none;
|
||||
}
|
||||
.logo-mark {
|
||||
display: inline-flex;
|
||||
}
|
||||
.title-text {
|
||||
font-size: 1rem;
|
||||
display: none !important;
|
||||
}
|
||||
.section-select {
|
||||
max-width: 110px;
|
||||
padding: 0.25rem 0.4rem;
|
||||
}
|
||||
.divider {
|
||||
display: none;
|
||||
}
|
||||
:global(.nova-header-actions-lg .nova-theme-select) {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 64rem) {
|
||||
.logo-mark {
|
||||
display: none;
|
||||
}
|
||||
.title-text {
|
||||
display: inline;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script type="module" src="/scripts/fuzzy-search-client.js"></script>
|
||||
48
samples/src/components/SplitCard.astro
Normal file
48
samples/src/components/SplitCard.astro
Normal file
@@ -0,0 +1,48 @@
|
||||
---
|
||||
interface Props {
|
||||
reverse?: boolean;
|
||||
}
|
||||
|
||||
const { reverse = false } = Astro.props;
|
||||
---
|
||||
|
||||
<div class={`split-card ${reverse ? "reverse" : ""}`}>
|
||||
<div class="content">
|
||||
<slot name="content" />
|
||||
</div>
|
||||
<div class="media">
|
||||
<slot name="media" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.split-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2rem;
|
||||
margin: 2rem 0;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
@media (min-width: 50rem) {
|
||||
.split-card {
|
||||
flex-direction: row;
|
||||
}
|
||||
.split-card.reverse {
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
}
|
||||
|
||||
.content,
|
||||
.media {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.media img {
|
||||
border-radius: 8px;
|
||||
box-shadow: var(--sl-shadow-md);
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
</style>
|
||||
118
samples/src/components/TableOfContentsList.astro
Normal file
118
samples/src/components/TableOfContentsList.astro
Normal file
@@ -0,0 +1,118 @@
|
||||
---
|
||||
import type { TocItem } from "@astrojs/starlight/utils/generateToC";
|
||||
|
||||
interface Props {
|
||||
toc: TocItem[];
|
||||
depth?: number;
|
||||
isMobile?: boolean;
|
||||
enableNumbering?: boolean;
|
||||
prefix?: number[];
|
||||
}
|
||||
|
||||
const {
|
||||
toc,
|
||||
isMobile = false,
|
||||
depth = 0,
|
||||
enableNumbering = false,
|
||||
prefix = [],
|
||||
} = Astro.props;
|
||||
---
|
||||
|
||||
<ul class:list={{ isMobile }}>
|
||||
{
|
||||
toc.map((heading, index) => {
|
||||
const currentPrefix = enableNumbering ? [...prefix, index + 1] : [];
|
||||
const currentNumber = currentPrefix.length > 0 ? `${currentPrefix.join(".")}.` : null;
|
||||
return (
|
||||
<li>
|
||||
<a href={"#" + heading.slug}>
|
||||
<span class="toc-label">
|
||||
{currentNumber && <span class="toc-number">{currentNumber}</span>}
|
||||
<span>{heading.text}</span>
|
||||
</span>
|
||||
</a>
|
||||
{heading.children.length > 0 && (
|
||||
<Astro.self
|
||||
toc={heading.children}
|
||||
depth={depth + 1}
|
||||
isMobile={isMobile}
|
||||
enableNumbering={enableNumbering}
|
||||
prefix={currentPrefix}
|
||||
/>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})
|
||||
}
|
||||
</ul>
|
||||
|
||||
<style define:vars={{ depth }}>
|
||||
@layer starlight.core {
|
||||
ul {
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
a {
|
||||
--pad-inline: 0.5rem;
|
||||
display: block;
|
||||
border-radius: 0.25rem;
|
||||
padding-block: 0.25rem;
|
||||
padding-inline: calc(1rem * var(--depth) + var(--pad-inline)) var(--pad-inline);
|
||||
line-height: 1.25;
|
||||
}
|
||||
a[aria-current="true"] {
|
||||
color: var(--sl-color-text-accent);
|
||||
}
|
||||
.isMobile a {
|
||||
--pad-inline: 1rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--pad-inline);
|
||||
border-top: 1px solid var(--sl-color-gray-6);
|
||||
border-radius: 0;
|
||||
padding-block: 0.5rem;
|
||||
color: var(--sl-color-text);
|
||||
font-size: var(--sl-text-sm);
|
||||
text-decoration: none;
|
||||
outline-offset: var(--sl-outline-offset-inside);
|
||||
}
|
||||
.isMobile:first-child > li:first-child > a {
|
||||
border-top: 0;
|
||||
}
|
||||
.isMobile a[aria-current="true"],
|
||||
.isMobile a[aria-current="true"]:hover,
|
||||
.isMobile a[aria-current="true"]:focus {
|
||||
color: var(--sl-color-white);
|
||||
background-color: unset;
|
||||
}
|
||||
.isMobile a[aria-current="true"]::after {
|
||||
content: "";
|
||||
width: 1rem;
|
||||
background-color: var(--sl-color-text-accent);
|
||||
/* Check mark SVG icon */
|
||||
-webkit-mask-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHZpZXdCb3g9JzAgMCAxNCAxNCc+PHBhdGggZD0nTTEwLjkxNCA0LjIwNmEuNTgzLjU4MyAwIDAgMC0uODI4IDBMNS43NCA4LjU1NyAzLjkxNCA2LjcyNmEuNTk2LjU5NiAwIDAgMC0uODI4Ljg1N2wyLjI0IDIuMjRhLjU4My41ODMgMCAwIDAgLjgyOCAwbDQuNzYtNC43NmEuNTgzLjU4MyAwIDAgMCAwLS44NTdaJy8+PC9zdmc+Cg==");
|
||||
mask-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHZpZXdCb3g9JzAgMCAxNCAxNCc+PHBhdGggZD0nTTEwLjkxNCA0LjIwNmEuNTgzLjU4MyAwIDAgMC0uODI4IDBMNS43NCA4LjU1NyAzLjkxNCA2LjcyNmEuNTk2LjU5NiAwIDAgMC0uODI4Ljg1N2wyLjI0IDIuMjRhLjU4My41ODMgMCAwIDAgLjgyOCAwbDQuNzYtNC43NmEuNTgzLjU4MyAwIDAgMCAwLS44NTdaJy8+PC9zdmc+Cg==");
|
||||
-webkit-mask-repeat: no-repeat;
|
||||
mask-repeat: no-repeat;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.toc-label {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.toc-number {
|
||||
min-width: 2.5ch;
|
||||
color: var(--sl-color-text-accent);
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.isMobile .toc-number {
|
||||
color: var(--sl-color-text);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
132
samples/src/components/UniversalMenuToggle.astro
Normal file
132
samples/src/components/UniversalMenuToggle.astro
Normal file
@@ -0,0 +1,132 @@
|
||||
---
|
||||
import Icon from "../user-components/Icon.astro";
|
||||
---
|
||||
|
||||
<starlight-menu-button class="print:hidden">
|
||||
<button
|
||||
aria-expanded="false"
|
||||
aria-label={Astro.locals.t("menuButton.accessibleLabel")}
|
||||
aria-controls="starlight__sidebar"
|
||||
class="sl-flex"
|
||||
>
|
||||
<Icon name="bars" class="open-menu" />
|
||||
<Icon name="close" class="close-menu" />
|
||||
</button>
|
||||
</starlight-menu-button>
|
||||
|
||||
<script>
|
||||
class StarlightMenuButton extends HTMLElement {
|
||||
btn = this.querySelector("button");
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
if (!this.btn) return;
|
||||
|
||||
this.btn.addEventListener("click", () => this.toggleExpanded());
|
||||
|
||||
const parentNav = this.closest("nav");
|
||||
if (parentNav) {
|
||||
parentNav.addEventListener("keyup", (e) => this.closeOnEscape(e));
|
||||
}
|
||||
}
|
||||
|
||||
setExpanded(expanded) {
|
||||
this.setAttribute("aria-expanded", String(expanded));
|
||||
document.body.toggleAttribute("data-mobile-menu-expanded", expanded);
|
||||
}
|
||||
|
||||
toggleExpanded() {
|
||||
this.setExpanded(this.getAttribute("aria-expanded") !== "true");
|
||||
}
|
||||
|
||||
closeOnEscape(e) {
|
||||
if (e.code === "Escape") {
|
||||
this.setExpanded(false);
|
||||
this.btn?.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("starlight-menu-button", StarlightMenuButton);
|
||||
</script>
|
||||
|
||||
<style>
|
||||
@layer starlight.core {
|
||||
starlight-menu-button {
|
||||
display: block;
|
||||
}
|
||||
|
||||
:global(header starlight-menu-button) {
|
||||
display: block !important;
|
||||
}
|
||||
|
||||
button {
|
||||
position: fixed;
|
||||
z-index: calc(var(--sl-z-index-navbar) + 5);
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
width: var(--sl-menu-button-size);
|
||||
height: var(--sl-menu-button-size);
|
||||
padding: 0.5rem;
|
||||
background-color: var(--sl-color-white);
|
||||
color: var(--sl-color-black);
|
||||
box-shadow: var(--sl-shadow-md);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
[aria-expanded="true"] button {
|
||||
background-color: var(--sl-color-gray-2);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
[aria-expanded="true"] button .open-menu {
|
||||
display: none;
|
||||
}
|
||||
|
||||
:not([aria-expanded="true"]) button .close-menu {
|
||||
display: none;
|
||||
}
|
||||
|
||||
:global([data-theme="light"]) button {
|
||||
background-color: var(--sl-color-black);
|
||||
color: var(--sl-color-white);
|
||||
}
|
||||
|
||||
:global([data-theme="light"]) [aria-expanded="true"] button {
|
||||
background-color: var(--sl-color-gray-5);
|
||||
}
|
||||
|
||||
/* Mobile default: header right */
|
||||
@media (max-width: 49.999rem) {
|
||||
button {
|
||||
top: calc((var(--sl-nav-height) - var(--sl-menu-button-size)) / 2);
|
||||
inset-inline-end: var(--sl-nav-pad-x);
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Tablet/desktop: float at sidebar upper-right */
|
||||
@media (min-width: 50rem) {
|
||||
button {
|
||||
top: calc(var(--sl-nav-height) + 0.75rem);
|
||||
left: var(--sl-sidebar-width);
|
||||
transform: translate(-50%, 0);
|
||||
inset-inline-end: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style is:global>
|
||||
@layer starlight.core {
|
||||
[data-mobile-menu-expanded] {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@media (min-width: 70rem) {
|
||||
[data-mobile-menu-expanded] {
|
||||
overflow: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
21
samples/src/components/VideoModal.astro
Normal file
21
samples/src/components/VideoModal.astro
Normal file
@@ -0,0 +1,21 @@
|
||||
---
|
||||
interface Props {
|
||||
id: string;
|
||||
videoSrc: string;
|
||||
}
|
||||
|
||||
const { id, videoSrc } = Astro.props;
|
||||
---
|
||||
|
||||
<div class="modal" id={id}>
|
||||
<div class="modal-content">
|
||||
<span class="close">×</span>
|
||||
<video
|
||||
src={videoSrc}
|
||||
controls
|
||||
muted="muted"
|
||||
controlsList="nodownload"
|
||||
></video>
|
||||
<p>▲ 영상 위에 마우스 커서를 올리면 재생 컨트롤바가 보입니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
132
samples/src/components/starlight-toc.ts
Normal file
132
samples/src/components/starlight-toc.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
const PAGE_TITLE_ID = "_top";
|
||||
|
||||
export class StarlightTOC extends HTMLElement {
|
||||
private _current = this.querySelector<HTMLAnchorElement>('a[aria-current="true"]');
|
||||
private minH = parseInt(this.dataset.minH || "2", 10);
|
||||
private maxH = parseInt(this.dataset.maxH || "3", 10);
|
||||
|
||||
protected set current(link: HTMLAnchorElement) {
|
||||
if (link === this._current) return;
|
||||
if (this._current) this._current.removeAttribute("aria-current");
|
||||
link.setAttribute("aria-current", "true");
|
||||
this._current = link;
|
||||
}
|
||||
|
||||
private onIdle = (cb: IdleRequestCallback) =>
|
||||
(window.requestIdleCallback || ((cb) => setTimeout(cb, 1)))(cb);
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.onIdle(() => this.init());
|
||||
}
|
||||
|
||||
private init = (): void => {
|
||||
const links = [...this.querySelectorAll("a")];
|
||||
this.syncNumbers(links);
|
||||
|
||||
const isHeading = (el: Element): el is HTMLHeadingElement => {
|
||||
if (el instanceof HTMLHeadingElement) {
|
||||
if (el.id === PAGE_TITLE_ID) return true;
|
||||
const level = el.tagName[1];
|
||||
if (level) {
|
||||
const int = parseInt(level, 10);
|
||||
if (int >= this.minH && int <= this.maxH) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const getElementHeading = (el: Element | null): HTMLHeadingElement | null => {
|
||||
if (!el) return null;
|
||||
const origin = el;
|
||||
while (el) {
|
||||
if (isHeading(el)) return el;
|
||||
el = el.previousElementSibling;
|
||||
while (el?.lastElementChild) {
|
||||
el = el.lastElementChild;
|
||||
}
|
||||
const h = getElementHeading(el);
|
||||
if (h) return h;
|
||||
}
|
||||
return getElementHeading(origin.parentElement);
|
||||
};
|
||||
|
||||
const setCurrent: IntersectionObserverCallback = (entries) => {
|
||||
for (const { isIntersecting, target } of entries) {
|
||||
if (!isIntersecting) continue;
|
||||
const heading = getElementHeading(target);
|
||||
if (!heading) continue;
|
||||
const link = links.find((link) => link.hash === "#" + encodeURIComponent(heading.id));
|
||||
if (link) {
|
||||
this.current = link;
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const toObserve = document.querySelectorAll("main [id], main [id] ~ *, main .content > *");
|
||||
|
||||
let observer: IntersectionObserver | undefined;
|
||||
const observe = () => {
|
||||
if (observer) return;
|
||||
observer = new IntersectionObserver(setCurrent, { rootMargin: this.getRootMargin() });
|
||||
toObserve.forEach((h) => observer!.observe(h));
|
||||
};
|
||||
observe();
|
||||
|
||||
let timeout: ReturnType<typeof setTimeout>;
|
||||
window.addEventListener("resize", () => {
|
||||
if (observer) {
|
||||
observer.disconnect();
|
||||
observer = undefined;
|
||||
}
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(() => this.onIdle(observe), 200);
|
||||
});
|
||||
};
|
||||
|
||||
private syncNumbers = (links: HTMLAnchorElement[]) => {
|
||||
const toId = (hash: string) => {
|
||||
if (!hash) return "";
|
||||
const raw = hash.startsWith("#") ? hash.slice(1) : hash;
|
||||
try {
|
||||
return decodeURIComponent(raw);
|
||||
} catch {
|
||||
return raw;
|
||||
}
|
||||
};
|
||||
|
||||
let didApply = false;
|
||||
for (const link of links) {
|
||||
const targetId = toId(link.hash);
|
||||
if (!targetId) continue;
|
||||
const heading = document.getElementById(targetId);
|
||||
const number = heading?.dataset.headingNumber;
|
||||
if (!number) continue;
|
||||
|
||||
const label = link.querySelector(".toc-label") ?? link;
|
||||
const existing = label.querySelector(".toc-number") ?? document.createElement("span");
|
||||
existing.classList.add("toc-number");
|
||||
existing.textContent = number;
|
||||
if (!existing.parentElement) {
|
||||
label.insertBefore(existing, label.firstChild);
|
||||
}
|
||||
didApply = true;
|
||||
}
|
||||
|
||||
if (didApply) {
|
||||
this.dataset.numbering = "true";
|
||||
}
|
||||
};
|
||||
|
||||
private getRootMargin(): `-${number}px 0% ${number}px` {
|
||||
const navBarHeight = document.querySelector("header")?.getBoundingClientRect().height || 0;
|
||||
const mobileTocHeight = this.querySelector("summary")?.getBoundingClientRect().height || 0;
|
||||
const top = navBarHeight + mobileTocHeight + 32;
|
||||
const bottom = top + 53;
|
||||
const height = document.documentElement.clientHeight;
|
||||
return `-${top}px 0% ${bottom - height}px`;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("starlight-toc", StarlightTOC);
|
||||
Reference in New Issue
Block a user