stylescape
Version:
Stylescape is a visual identity framework developed by Scape Agency.
1,590 lines (1,570 loc) • 201 kB
JavaScript
"use strict";
(() => {
// src/ts/state/StateManager.ts
var StateManager = class {
/**
* Toggles a specified class on an element.
* Logs a warning if the element is not found.
*
* @param element - The DOM element to toggle the class on
* @param className - The CSS class to toggle (default: "active")
*/
toggleClass(element, className = "active") {
if (!element) {
console.warn(`Element: '${element}' not found`);
return;
}
element.classList.toggle(className);
}
};
// src/ts/storage/LocalStorageManager.ts
var LocalStorageManager = class _LocalStorageManager {
/**
* Get the singleton instance of LocalStorageManager.
* Creates the instance if it doesn't exist.
*
* @returns The singleton LocalStorageManager instance
*/
static getInstance() {
if (!_LocalStorageManager.instance) {
_LocalStorageManager.instance = new _LocalStorageManager();
}
return _LocalStorageManager.instance;
}
/**
* Stores a value in localStorage or fallback storage.
* @param key The storage key.
* @param value The value to store.
*/
setValue(key, value) {
try {
if (localStorage) {
localStorage.setItem(key, String(value));
} else {
}
} catch (error) {
console.error("Error saving to localStorage:", error);
}
}
/**
* Retrieves a value from localStorage or fallback storage.
* @param key The storage key.
* @returns The retrieved value or null if not found.
*/
// getValue<T>(key: string): T | null {
getValue(key) {
try {
if (localStorage) {
return localStorage.getItem(key);
} else {
return null;
}
} catch (error) {
console.error("Error reading from localStorage:", error);
return null;
}
}
/**
* Removes a value from localStorage.
* @param key The storage key.
*/
removeValue(key) {
try {
if (localStorage) {
localStorage.removeItem(key);
} else {
}
} catch (error) {
console.error("Error removing from localStorage:", error);
}
}
/**
* Clears all values in localStorage.
*/
clearStorage() {
try {
if (localStorage) {
localStorage.clear();
} else {
}
} catch (error) {
console.error("Error clearing localStorage:", error);
}
}
};
// src/ts/elements/AsideHandler.ts
var AsideHandler = class _AsideHandler {
/**
* Creates a new AsideHandler instance.
*
* @param menuId - ID of the aside menu element
* @param switchId - ID of the toggle button element
*/
constructor(menuId, switchId) {
/** Reference to the aside menu element */
this.asideMenu = null;
/** Reference to the toggle switch element */
this.asideSwitch = null;
/** Current visibility state */
this.asideMenuActive = _AsideHandler.HIDDEN_STATE;
this.localStorageManager = LocalStorageManager.getInstance();
this.stateManager = new StateManager();
this.menuId = menuId;
this.switchId = switchId;
this.assertMenu();
this.setupToggleListener();
this.updateStateMenu();
}
static {
/** CSS class applied when menu is visible */
this.VISIBLE_CLASS = "active";
}
static {
/** LocalStorage key suffix for visibility state */
this.VISIBLE_SUFFIX = "_visibility";
}
static {
/** State value for visible menu */
this.VISIBLE_STATE = "show";
}
static {
/** State value for hidden menu */
this.HIDDEN_STATE = "hide";
}
/**
* Queries and caches DOM references for menu and switch elements.
*/
assertMenu() {
this.asideMenu = document.getElementById(this.menuId);
this.asideSwitch = document.getElementById(this.switchId);
}
/**
* Sets up click event listener on the toggle switch.
*/
setupToggleListener() {
if (this.asideSwitch) {
this.asideSwitch.addEventListener(
"click",
() => this.toggleMenu()
);
}
}
/**
* Toggles the menu between visible and hidden states.
*/
toggleMenu() {
this.assertMenu();
if (this.asideMenu?.classList.contains(_AsideHandler.VISIBLE_CLASS)) {
this.hideMenu();
} else {
this.showMenu();
}
}
/**
* Shows the menu and persists the state to localStorage.
*/
showMenu() {
this.assertMenu();
this.localStorageManager.setValue(
this.menuId + _AsideHandler.VISIBLE_SUFFIX,
_AsideHandler.VISIBLE_STATE
);
this.updateStateMenu();
}
/**
* Hides the menu and persists the state to localStorage.
*/
hideMenu() {
this.assertMenu();
this.localStorageManager.setValue(
this.menuId + _AsideHandler.VISIBLE_SUFFIX,
_AsideHandler.HIDDEN_STATE
);
this.updateStateMenu();
}
/**
* Updates the visual state of the menu based on stored preference.
* Applies or removes the visible class on both menu and switch.
*/
updateStateMenu() {
this.assertMenu();
if (!this.asideMenu) return;
this.asideMenuActive = this.localStorageManager.getValue(
this.menuId + _AsideHandler.VISIBLE_SUFFIX
) || this.asideMenuActive;
const isVisible = this.asideMenuActive === _AsideHandler.VISIBLE_STATE;
this.asideMenu.classList.toggle(_AsideHandler.VISIBLE_CLASS, isVisible);
this.asideSwitch?.classList.toggle(
_AsideHandler.VISIBLE_CLASS,
isVisible
);
}
};
// src/ts/elements/CollapsibleTableHandler.ts
var CollapsibleTableHandler = class {
constructor(containerSelector = ".collapsible_table") {
const tables = document.querySelectorAll(containerSelector);
tables.forEach((table) => {
const header = table.querySelector(
".collapsible_table--header"
);
const content = table.querySelector(
".collapsible_table--content"
);
const flipper = table.querySelector(
".flipper--down, .flipper--up"
);
if (!header || !content) return;
header.addEventListener("click", () => {
content.classList.toggle("expanded");
if (flipper) {
flipper.classList.toggle("flipper--up");
flipper.classList.toggle("flipper--down");
}
});
});
}
};
// src/ts/elements/DetailManager.ts
var DetailManager = class {
/**
* Initializes the DetailManager.
* Selects all <details> elements and attaches a single document-level listener.
* Excludes sidebar accordions from the exclusive behavior.
*
* @param selector - CSS selector for <details> elements (default: "details:not(.sidebar__accordion)").
*/
constructor(selector = "details:not(.sidebar__accordion)") {
this.details = document.querySelectorAll(selector);
this.boundHandler = this.handleClick.bind(this);
document.addEventListener("click", this.boundHandler);
}
/**
* Handles clicks:
* - If clicking a <summary>: closes all others, keeps only that one open.
* - If clicking outside any <details>: closes all.
*/
handleClick(event) {
const target = event.target;
const summary = target.closest("summary");
const parent = summary?.parentElement;
if (parent && parent.tagName === "DETAILS") {
this.details.forEach((detail) => {
if (detail !== parent) detail.removeAttribute("open");
});
} else {
this.details.forEach((detail) => detail.removeAttribute("open"));
}
}
/**
* Toggles a specific <details> element open or closed.
*/
toggle(detail, open) {
if (open) detail.setAttribute("open", "");
else detail.removeAttribute("open");
}
/**
* Cleans up the event listener (useful for SPA cleanup).
*/
destroy() {
document.removeEventListener("click", this.boundHandler);
}
};
// src/ts/elements/DropdownHandler.ts
var DropdownHandler = class {
constructor(containerSelector = ".select_dropdown") {
this.dropdowns = document.querySelectorAll(containerSelector);
if (this.dropdowns.length === 0) return;
this.initializeDropdowns();
this.setupGlobalClickListener();
}
initializeDropdowns() {
this.dropdowns.forEach((dropdown) => {
const header = dropdown.querySelector(
".select_dropdown--header"
);
const menu = dropdown.querySelector(
".select_dropdown--menu.dropdown--collapse"
);
const checkboxes = dropdown.querySelectorAll(
'input[type="checkbox"]'
);
if (!header) return;
header.addEventListener("click", () => {
if (menu) {
menu.classList.toggle("active");
}
const flipper = header.querySelector(
".flipper--down, .flipper--up"
);
if (flipper) {
flipper.classList.toggle("flipper--down");
flipper.classList.toggle("flipper--up");
}
});
checkboxes.forEach((checkbox) => {
checkbox.addEventListener("change", () => {
this.updateSelectionLabel(dropdown);
});
});
this.updateSelectionLabel(dropdown);
});
}
updateSelectionLabel(dropdown) {
const countSpan = dropdown.querySelector("#selected-count");
const checkboxes = dropdown.querySelectorAll(
'input[type="checkbox"]'
);
if (!countSpan || checkboxes.length === 0) return;
const selected = Array.from(checkboxes).filter(
(cb) => cb.checked
).length;
countSpan.textContent = `Selected: ${selected} option${selected !== 1 ? "s" : ""}`;
}
setupGlobalClickListener() {
document.addEventListener("click", (e) => {
this.dropdowns.forEach((dropdown) => {
const menu = dropdown.querySelector(
".dropdown--collapse"
);
if (menu && !dropdown.contains(e.target)) {
menu.classList.remove("active");
}
});
});
}
};
// src/ts/elements/ExclusiveDetails.ts
var ExclusiveDetails = class {
constructor(selector) {
this.detailsElements = document.querySelectorAll(selector);
this.bindEvents();
}
bindEvents() {
this.detailsElements.forEach((details) => {
details.addEventListener(
"click",
(e) => this.handleDetailsClick(e, details)
);
});
document.addEventListener("click", (e) => this.handleClickOutside(e));
}
handleDetailsClick(event, details) {
event.stopPropagation();
this.detailsElements.forEach((otherDetails) => {
if (otherDetails !== details) {
otherDetails.removeAttribute("open");
}
});
}
handleClickOutside(event) {
const target = event.target;
const isClickInside = Array.from(this.detailsElements).some(
(details) => details.contains(target)
);
if (!isClickInside) {
this.closeAll();
}
}
closeAll() {
this.detailsElements.forEach((details) => {
details.removeAttribute("open");
});
}
};
// src/ts/elements/PasswordToggleManager.ts
var PasswordToggleManager = class {
/**
* Creates a new PasswordToggleManager instance.
*
* @param selector - CSS selector for toggle buttons (default: "[data-password-toggle]")
*/
constructor(selector = "[data-password-toggle]") {
this.selector = selector;
this.init();
}
/**
* Initializes toggle functionality for all matching buttons.
*/
init() {
document.querySelectorAll(this.selector).forEach((button) => {
const inputId = button.dataset.passwordToggle;
if (!inputId) return;
const input = document.getElementById(
inputId
);
if (!input || input.type !== "password") return;
button.addEventListener(
"click",
() => this.togglePasswordVisibility(input, button)
);
});
}
/**
* Toggles password visibility for an input field.
*
* @param input - The password input element
* @param button - The toggle button element
*/
togglePasswordVisibility(input, button) {
const isText = input.type === "text";
input.type = isText ? "password" : "text";
button.classList.toggle("is-visible", !isText);
button.setAttribute("aria-pressed", String(!isText));
}
};
// src/ts/media/ImageCompareSlider.ts
var ImageCompareSlider = class _ImageCompareSlider {
/**
* Creates a new ImageCompareSlider instance.
*
* @param container - The container element holding both images and slider
*/
constructor(container) {
/** Whether the slider is currently being dragged */
this.isActive = false;
this.container = container;
this.slider = container.querySelector(
".image__compare--slider"
);
this.overlay = container.querySelector(
".image__compare--overlay"
);
this.baseImage = container.querySelector(
"img.image__compare--image:not(.image__compare--overlay)"
);
if (!this.container || !this.slider || !this.overlay || !this.baseImage) {
console.warn(
`ImageCompareSlider skipped: required elements not found in`,
container
);
return;
}
this.checkAndInject(this.baseImage);
this.checkAndInject(this.overlay);
this.initEvents();
this.slideMove(this.container.offsetWidth / 2);
}
/**
* Checks image brightness and injects dark mode indicators if needed.
*
* @param image - The image element to check
*/
checkAndInject(image) {
const side = image.dataset.darkSide;
if (!side) return;
const inject = () => {
this.isImageBright(image).then((isBright) => {
if (!isBright) return;
const el = document.createElement("div");
el.className = `dark--${side}`;
this.slider.appendChild(el);
const arrow = this.slider.querySelector(
`.arrow--${side}`
);
if (arrow) {
arrow.style.borderColor = "var(--color_text_primary)";
}
}).catch((err) => {
console.warn("Brightness check failed:", err);
});
};
if (image.complete && image.naturalWidth > 0) {
inject();
} else {
image.onload = () => {
if (image.naturalWidth > 0) inject();
};
}
}
/**
* Analyzes image brightness using canvas pixel sampling.
*
* @param image - The image element to analyze
* @returns Promise resolving to true if image is bright (avg > 160)
*/
isImageBright(image) {
return new Promise((resolve) => {
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
if (!ctx) return resolve(false);
canvas.width = image.naturalWidth;
canvas.height = image.naturalHeight;
ctx.drawImage(image, 0, 0);
const data = ctx.getImageData(
0,
0,
canvas.width,
canvas.height
).data;
let r = 0, g = 0, b = 0, count = 0;
const step = 4 * 20;
for (let i = 0; i < data.length; i += step) {
r += data[i];
g += data[i + 1];
b += data[i + 2];
count++;
}
const avg = (r + g + b) / (3 * count);
resolve(avg > 160);
});
}
/**
* Initializes mouse and touch event listeners for drag interaction.
*/
initEvents() {
this.slider.addEventListener(
"mousedown",
() => this.isActive = true
);
window.addEventListener("mouseup", () => this.isActive = false);
window.addEventListener("mousemove", (e) => {
if (this.isActive) this.slideMove(e.clientX);
});
this.slider.addEventListener(
"touchstart",
() => this.isActive = true
);
window.addEventListener("touchend", () => this.isActive = false);
window.addEventListener("touchmove", (e) => {
if (this.isActive) this.slideMove(e.touches[0].clientX);
});
}
/**
* Moves the slider and adjusts overlay width based on position.
*
* @param x - The x-coordinate (client position) to move to
*/
slideMove(x) {
const bounds = this.container.getBoundingClientRect();
let pos = x - bounds.left;
pos = Math.max(0, Math.min(pos, bounds.width));
this.overlay.style.width = `${pos}px`;
this.slider.style.left = `${pos}px`;
}
/**
* Static factory method to initialize all image compare sliders on the page.
*
* @param selector - CSS selector for container elements (default: ".image__compare")
*/
static initAll(selector = ".image__compare") {
const containers = document.querySelectorAll(selector);
containers.forEach((container) => {
new _ImageCompareSlider(container);
});
}
};
// src/ts/content/ActiveLinkHighlighter.ts
var ActiveLinkHighlighter = class {
/**
* Creates a new ActiveLinkHighlighter instance.
*
* @param activeClass - CSS class to apply to matching links (default: "active")
*/
constructor(activeClass = "active") {
this.activeClass = activeClass;
this.highlightAllLinks();
}
/**
* Normalizes a URL by extracting and cleaning the pathname and search.
*
* @param url - The URL to normalize
* @returns The normalized path including query parameters
*/
normalizeUrl(url) {
const a = document.createElement("a");
a.href = url;
const pathname = a.pathname.replace(/\/+$/, "");
return pathname + a.search;
}
/**
* Highlights all links on the page that match the current URL.
* Skips links inside ribbon titles and links without href attributes.
*/
highlightAllLinks() {
const currentPath = this.normalizeUrl(window.location.href);
const links = document.querySelectorAll("a");
links.forEach((link) => {
if (link.closest(".ribbon__title")) {
return;
}
if (!link.hasAttribute("href") || !link.getAttribute("href")) {
return;
}
const linkPath = this.normalizeUrl(link.href);
if (linkPath === currentPath) {
link.classList.add(this.activeClass);
}
});
}
};
// src/ts/scroll/ScrollSpyManager.ts
var ScrollSpyManager = class _ScrollSpyManager {
constructor(options = {}) {
this.sections = [];
this.navLinks = [];
this.ticking = false;
this.currentActiveId = null;
this.handleScroll = () => {
if (!this.ticking) {
window.requestAnimationFrame(() => {
this.updateActiveLink();
this.ticking = false;
});
this.ticking = true;
}
};
this.handleLinkClick = (event) => {
const link = event.currentTarget;
const href = link.getAttribute("href");
if (href?.startsWith("#")) {
event.preventDefault();
const sectionId = href.slice(1);
this.scrollTo(sectionId);
if (this.options.updateHistory) {
history.pushState(null, "", href);
}
}
};
this.options = {
navSelector: options.navSelector ?? "[data-ss-scrollspy-link], .scrollspy-link",
containerId: options.containerId ?? "",
threshold: options.threshold ?? 0.5,
offset: options.offset ?? 0,
activeClass: options.activeClass ?? "active",
activeParentClass: options.activeParentClass ?? "active",
smoothScroll: options.smoothScroll ?? true,
onChange: options.onChange ?? (() => {
}),
updateHistory: options.updateHistory ?? false
};
this.scrollContainer = this.options.containerId ? document.getElementById(this.options.containerId) ?? window : window;
this.init();
}
// For backwards compatibility with old constructor
static fromElements(sections, navLinksSelector, containerId, thresholdOffset = 0.5) {
const instance = new _ScrollSpyManager({
navSelector: navLinksSelector,
containerId,
threshold: thresholdOffset
});
instance.sections = sections;
instance.updateActiveLink();
return instance;
}
// ========================================================================
// Public Methods
// ========================================================================
/**
* Manually refresh sections and links
*/
refresh() {
this.findSections();
this.updateActiveLink();
}
/**
* Scroll to a specific section
*/
scrollTo(sectionId) {
const section = document.getElementById(sectionId);
if (!section) return;
const top = section.offsetTop - this.options.offset;
if (this.scrollContainer instanceof Window) {
window.scrollTo({
top,
behavior: this.options.smoothScroll ? "smooth" : "auto"
});
} else {
this.scrollContainer.scrollTo({
top,
behavior: this.options.smoothScroll ? "smooth" : "auto"
});
}
}
/**
* Get current active section ID
*/
getActive() {
return this.currentActiveId;
}
/**
* Destroy the scroll spy
*/
destroy() {
const container = this.scrollContainer instanceof Window ? window : this.scrollContainer;
container.removeEventListener("scroll", this.handleScroll);
this.navLinks.forEach((link) => {
link.removeEventListener("click", this.handleLinkClick);
});
this.sections = [];
this.navLinks = [];
}
// ========================================================================
// Static Factory
// ========================================================================
/**
* Initialize scroll spy from data-ss="scrollspy"
*/
static init() {
const managers = [];
document.querySelectorAll('[data-ss="scrollspy"]').forEach((el) => {
const navSelector = el.dataset.ssScrollspyNav || `#${el.id} a`;
const threshold = el.dataset.ssScrollspyThreshold;
const smooth = el.dataset.ssScrollspySmooth !== "false";
const offset = el.dataset.ssScrollspyOffset;
managers.push(
new _ScrollSpyManager({
navSelector,
threshold: threshold ? parseFloat(threshold) : void 0,
smoothScroll: smooth,
offset: offset ? parseInt(offset, 10) : void 0
})
);
});
return managers;
}
// ========================================================================
// Private Methods
// ========================================================================
init() {
this.findSections();
this.bindScrollListener();
this.bindLinkListeners();
this.updateActiveLink();
}
findSections() {
this.navLinks = Array.from(
document.querySelectorAll(this.options.navSelector)
);
this.sections = [];
this.navLinks.forEach((link) => {
const href = link.getAttribute("href");
if (href?.startsWith("#")) {
const sectionId = href.slice(1);
const section = document.getElementById(sectionId);
if (section && !this.sections.includes(section)) {
this.sections.push(section);
}
}
});
}
bindScrollListener() {
const container = this.scrollContainer instanceof Window ? window : this.scrollContainer;
container.addEventListener("scroll", this.handleScroll, {
passive: true
});
}
bindLinkListeners() {
this.navLinks.forEach((link) => {
link.addEventListener("click", this.handleLinkClick);
});
}
updateActiveLink() {
if (this.sections.length === 0 || this.navLinks.length === 0) return;
const scrollY = this.scrollContainer instanceof Window ? window.scrollY : this.scrollContainer.scrollTop;
let activeId = null;
for (const section of this.sections) {
const id = section.getAttribute("id");
if (!id) continue;
const top = section.offsetTop - this.options.offset;
const height = section.offsetHeight;
const threshold = top - height * this.options.threshold;
if (scrollY >= threshold) {
activeId = id;
}
}
if (activeId === this.currentActiveId) return;
this.currentActiveId = activeId;
let activeLink = null;
this.navLinks.forEach((link) => {
const targetId = link.getAttribute("href")?.replace("#", "");
const isActive = targetId === activeId;
link.classList.toggle(this.options.activeClass, isActive);
link.setAttribute("aria-current", isActive ? "true" : "false");
if (isActive) {
activeLink = link;
}
this.updateParentClasses(link, isActive);
});
this.options.onChange(activeId, activeLink);
}
updateParentClasses(link, isActive) {
let parent = link.parentElement;
while (parent && parent !== document.body) {
if (parent.tagName === "LI") {
parent.classList.toggle(
this.options.activeParentClass,
isActive
);
}
parent = parent.parentElement;
}
}
};
// src/ts/content/TableOfContentsBuilder.ts
var TableOfContentsBuilder = class {
/**
* Creates a new TableOfContentsBuilder instance.
*
* @param rootId - ID of the element containing content sections
* @param tocContainerId - ID of the element to append the TOC to
*/
constructor(rootId, tocContainerId) {
/** Set of generated IDs to ensure uniqueness */
this.idSet = /* @__PURE__ */ new Set();
/** Map linking TOC anchor elements to their target sections */
this.linkSectionMap = /* @__PURE__ */ new Map();
this.rootId = rootId;
this.tocContainerId = tocContainerId;
}
/**
* Generates a unique ID from a base string.
* Handles collisions by appending a numeric suffix.
*
* @param baseId - The base string to generate an ID from
* @returns A unique, URL-safe ID string
*/
generateUniqueId(baseId) {
let id = baseId.toLowerCase().replace(/\s+/g, "-").replace(/[^\w-]/g, "");
let count = 1;
while (this.idSet.has(id)) {
id = `${baseId}-${count++}`;
}
this.idSet.add(id);
return id;
}
/**
* Creates a TOC list item entry for a section element.
*
* @param element - The section element to create an entry for
* @returns An HTMLLIElement containing the anchor link
*/
createTOCEntry(element) {
const text = element.getAttribute("data-label") || "Untitled";
const id = this.generateUniqueId(text);
element.id = id;
const a = document.createElement("a");
a.href = `#${id}`;
a.textContent = text;
const li = document.createElement("li");
li.appendChild(a);
this.linkSectionMap.set(a, element);
return li;
}
/**
* Recursively builds the TOC tree structure from nested elements.
*
* @param element - The parent element to traverse
* @returns An HTMLUListElement containing the nested TOC structure
*/
buildTOCTree(element) {
const ul = document.createElement("ul");
Array.from(element.children).forEach((child) => {
if (!(child instanceof HTMLElement)) return;
if (child.hasAttribute("data-label")) {
const li = this.createTOCEntry(child);
const nestedUL = this.buildTOCTree(child);
if (nestedUL.children.length > 0) {
li.appendChild(nestedUL);
}
ul.appendChild(li);
} else {
const nested = this.buildTOCTree(child);
if (nested.children.length > 0) {
ul.append(...Array.from(nested.children));
}
}
});
return ul;
}
/**
* Builds the TOC tree and appends it to the container element.
* Also initializes ScrollSpyManager for active link tracking.
*/
buildAndAppendTOC() {
const root = document.getElementById(this.rootId);
const tocContainer = document.getElementById(this.tocContainerId);
if (!root || !tocContainer) return;
const tocTree = this.buildTOCTree(root);
tocContainer.innerHTML = "";
tocContainer.appendChild(tocTree);
this.scrollSpyManager = ScrollSpyManager.fromElements(
Array.from(this.linkSectionMap.values()),
`#${this.tocContainerId} a`,
this.rootId
);
}
/**
* Returns the map of TOC links to their corresponding content sections.
* Useful for custom scroll spy implementations or section tracking.
*
* @returns A Map with anchor elements as keys and section elements as values
*/
getLinkSectionMap() {
return this.linkSectionMap;
}
};
// src/ts/utilities/ClipboardHelper.ts
var ClipboardHelper = class _ClipboardHelper {
/**
* Copy code content from a sibling <code> element (original version).
*
* @param button - The button element that triggered the copy
*/
static copyCodeFromButton(button) {
const nextElement = button.nextElementSibling;
if (!(nextElement instanceof HTMLElement)) return;
const code = nextElement.innerText;
navigator.clipboard.writeText(code).then(() => {
button.textContent = "Copied!";
setTimeout(() => {
button.textContent = "Copy";
}, 1500);
});
}
/**
* Attach event listeners to all copy buttons matching the selector.
* Each button will copy content from its next sibling element.
*
* @param selector - CSS selector for copy buttons (default: '.copy-button')
*/
static attachToButtons(selector = ".copy-button") {
const buttons = document.querySelectorAll(selector);
buttons.forEach((button) => {
button.addEventListener(
"click",
() => _ClipboardHelper.copyCodeFromButton(button)
);
});
}
/**
* Copy content from an element by its ID and update the triggering button.
* Finds the associated button using onclick attribute matching.
*
* @param codeId - The ID of the element containing text to copy
*/
static copyById(codeId) {
const codeElement = document.getElementById(codeId);
if (!(codeElement instanceof HTMLElement)) {
console.warn(`Code element with ID "${codeId}" not found.`);
return;
}
const text = codeElement.innerText.trim();
navigator.clipboard.writeText(text).then(() => {
const button = document.querySelector(
`button[onclick*="${codeId}"]`
);
if (button) {
const original = button.textContent;
button.textContent = "Copied!";
setTimeout(() => {
button.textContent = original;
}, 1500);
}
}).catch((err) => {
console.error("Failed to copy text:", err);
});
}
};
// src/ts/utilities/GridManager.ts
var GridManager = class {
constructor() {
this.STORAGE_KEY = "unitgl:grid:visibility";
this.visibilityMap = {};
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", () => this.init());
} else {
this.init();
}
}
init() {
this.loadVisibility();
this.applyVisibilityState();
this.setupToggleButtons();
this.updateAllGridHeights();
window.addEventListener("resize", () => this.updateAllGridHeights());
window.addEventListener("scroll", () => this.updateAllGridHeights());
}
loadVisibility() {
try {
this.visibilityMap = JSON.parse(
localStorage.getItem(this.STORAGE_KEY) || "{}"
);
} catch {
this.visibilityMap = {};
}
}
saveVisibility() {
localStorage.setItem(
this.STORAGE_KEY,
JSON.stringify(this.visibilityMap)
);
}
updateAllGridHeights() {
const height = Math.max(
document.documentElement.scrollHeight,
document.body.scrollHeight,
document.documentElement.offsetHeight,
document.body.offsetHeight
);
document.querySelectorAll(".guide--layer").forEach((layer) => {
if (layer.offsetHeight !== height) {
layer.style.height = `${height}px`;
}
});
}
applyVisibilityState() {
document.querySelectorAll(".guide--layer").forEach((layer) => {
const id = layer.dataset.grid;
const isActive = !!this.visibilityMap[id];
layer.classList.toggle("active", isActive);
});
document.querySelectorAll("button[data-toggle]").forEach((button) => {
const id = button.dataset.toggle;
const isActive = !!this.visibilityMap[id];
button.classList.toggle("active", isActive);
});
}
setupToggleButtons() {
document.querySelectorAll("button[data-toggle]").forEach((button) => {
const id = button.dataset.toggle;
const layer = document.querySelector(
`[data-grid="${id}"]`
);
if (!layer) return;
button.addEventListener("click", () => {
const isNowActive = layer.classList.toggle("active");
button.classList.toggle("active", isNowActive);
this.visibilityMap[id] = isNowActive;
this.saveVisibility();
});
});
}
setupEventListeners() {
document.addEventListener("DOMContentLoaded", () => {
this.updateAllGridHeights();
this.setupToggleButtons();
window.addEventListener(
"resize",
() => this.updateAllGridHeights()
);
window.addEventListener(
"scroll",
() => this.updateAllGridHeights()
);
});
}
};
// src/ts/utilities/ThemeToggler.ts
var ThemeToggler = class _ThemeToggler {
static {
this.THEME_ATTRIBUTE = "theme";
}
static {
this.DARK_THEME = "dark";
}
static {
this.LIGHT_THEME = "light";
}
static {
this.STORAGE_KEY = "preferredTheme";
}
static {
this.htmlElement = document.documentElement;
}
constructor() {
}
/**
* Toggle between dark and light themes.
* Updates both the DOM attribute and localStorage.
*/
static toggle() {
const newTheme = _ThemeToggler.getCurrentTheme() === _ThemeToggler.DARK_THEME ? _ThemeToggler.LIGHT_THEME : _ThemeToggler.DARK_THEME;
_ThemeToggler.setTheme(newTheme);
}
/**
* Set theme explicitly to a specific value.
*
* @param theme - The theme to set ("dark" or "light")
*/
static setTheme(theme) {
_ThemeToggler.htmlElement.dataset[_ThemeToggler.THEME_ATTRIBUTE] = theme;
localStorage.setItem(_ThemeToggler.STORAGE_KEY, theme);
}
/**
* Get the currently active theme.
* Checks DOM attribute first, then localStorage, defaults to light.
*
* @returns The current theme ("dark" or "light")
*/
static getCurrentTheme() {
return _ThemeToggler.htmlElement.dataset[_ThemeToggler.THEME_ATTRIBUTE] || localStorage.getItem(_ThemeToggler.STORAGE_KEY) || _ThemeToggler.LIGHT_THEME;
}
/**
* Sync the toggle input checkbox state with the current theme.
*
* @param toggle - The checkbox input element to sync
*/
static syncToggleState(toggle) {
const currentTheme = _ThemeToggler.getCurrentTheme();
toggle.checked = currentTheme === _ThemeToggler.DARK_THEME;
}
/**
* Initialize a toggle switch (input[type=checkbox]) by ID or data attribute
* @param toggleId The ID of the toggle (default: 'themeToggle')
*/
static initializeToggleSwitch(toggleId = "themeToggle") {
let toggle = document.getElementById(
toggleId
);
if (!toggle) {
toggle = document.querySelector(
"[data-theme-toggle]"
);
}
if (!toggle) {
return;
}
_ThemeToggler.syncToggleState(toggle);
toggle.addEventListener("change", () => {
_ThemeToggler.toggle();
});
}
/**
* Register initialization to occur after full page load
* Recommended if HTML elements may load later
*/
static registerOnLoad(toggleId = "themeToggle") {
window.addEventListener("load", () => {
_ThemeToggler.initializeToggleSwitch(toggleId);
});
}
};
// src/ts/scroll/ScrollElementManager.ts
var ScrollElementManager = class {
constructor(contentSelector, storageKey = "scrollpos", debug = false) {
this.contentElement = document.querySelector(contentSelector);
this.storageKey = storageKey;
this.debug = debug;
if (!this.contentElement) {
if (this.debug)
console.warn(
"ScrollElementManager: Element not found:",
contentSelector
);
return;
}
this.initialize();
}
initialize() {
window.addEventListener("load", this.loadScrollPosition.bind(this));
this.contentElement?.addEventListener(
"scroll",
this.updateScrollPosition.bind(this)
);
if (this.debug) console.log("ScrollElementManager initialized.");
}
loadScrollPosition() {
try {
const scrollpos = sessionStorage.getItem(this.storageKey);
if (scrollpos !== null && this.contentElement) {
this.contentElement.scrollTop = parseInt(scrollpos, 10);
sessionStorage.removeItem(this.storageKey);
if (this.debug)
console.log("Scroll position restored:", scrollpos);
}
} catch (err) {
if (this.debug)
console.error(
"ScrollElementManager error loading scroll position:",
err
);
}
}
updateScrollPosition() {
try {
if (this.contentElement) {
sessionStorage.setItem(
this.storageKey,
this.contentElement.scrollTop.toString()
);
if (this.debug)
console.log(
"Scroll position saved:",
this.contentElement.scrollTop
);
}
} catch (err) {
if (this.debug)
console.error(
"ScrollElementManager error saving scroll position:",
err
);
}
}
};
// src/ts/scroll/ScrollPageManager.ts
var ScrollPageManager = class {
constructor() {
this.key = "scrollpos";
this.debounceTimeout = null;
this.initialize();
}
/**
* Sets up scroll position tracking and restoration.
*/
initialize() {
window.addEventListener("load", () => this.loadScrollPosition());
window.addEventListener("scroll", () => this.debounceSaveScroll());
window.addEventListener(
"beforeunload",
() => this.saveScrollPosition()
);
}
/**
* Restores scroll position from sessionStorage and clears it.
*/
loadScrollPosition() {
const scrollpos = sessionStorage.getItem(this.key);
if (scrollpos) {
window.scrollTo(0, parseInt(scrollpos, 10));
sessionStorage.removeItem(this.key);
}
}
/**
* Saves scroll position to sessionStorage.
*/
saveScrollPosition() {
sessionStorage.setItem(this.key, window.scrollY.toString());
}
/**
* Debounced scroll saving to reduce writes.
*/
debounceSaveScroll() {
if (this.debounceTimeout !== null) {
clearTimeout(this.debounceTimeout);
}
this.debounceTimeout = window.setTimeout(() => {
this.saveScrollPosition();
}, 200);
}
};
// src/ts/storage/AccordionState.ts
var AccordionState = class {
/**
* Creates an AccordionState manager
* @param selector - CSS selector for the accordion elements (must be <details>)
* @param storageKey - Key to use for localStorage (default: 'accordion-state')
*/
constructor(selector = "details.sidebar__accordion", storageKey = "accordion-state") {
this.selector = selector;
this.storageKey = storageKey;
this.accordions = document.querySelectorAll(
this.selector
);
this.init();
}
/**
* Initialize the accordion state manager
*/
init() {
this.restoreState();
this.attachListeners();
}
/**
* Generate a unique ID for an accordion based on its position and content
*/
getAccordionId(accordion, index) {
const summary = accordion.querySelector("summary");
const heading = summary?.querySelector("h2, h3");
if (accordion.id) {
return accordion.id;
}
if (heading?.textContent) {
return heading.textContent.toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
}
return `accordion-${index}`;
}
/**
* Get the current state of all accordions
*/
getState() {
const state = {};
this.accordions.forEach((accordion, index) => {
const id = this.getAccordionId(accordion, index);
state[id] = accordion.open;
});
return state;
}
/**
* Save the current state to localStorage
*/
saveState() {
try {
const state = this.getState();
localStorage.setItem(this.storageKey, JSON.stringify(state));
} catch (e) {
console.warn("AccordionState: Could not save state", e);
}
}
/**
* Restore the state from localStorage
*/
restoreState() {
try {
const savedState = localStorage.getItem(this.storageKey);
if (!savedState) {
return;
}
const state = JSON.parse(savedState);
this.accordions.forEach((accordion, index) => {
const id = this.getAccordionId(accordion, index);
if (id in state) {
accordion.open = state[id];
}
});
} catch (e) {
console.warn("AccordionState: Could not restore state", e);
}
}
/**
* Attach toggle listeners to all accordions
*/
attachListeners() {
this.accordions.forEach((accordion) => {
accordion.addEventListener("toggle", () => {
this.saveState();
});
});
}
/**
* Clear the saved state
*/
clearState() {
try {
localStorage.removeItem(this.storageKey);
} catch (e) {
console.warn("AccordionState: Could not clear state", e);
}
}
/**
* Expand all accordions
*/
expandAll() {
this.accordions.forEach((accordion) => {
accordion.open = true;
});
this.saveState();
}
/**
* Collapse all accordions
*/
collapseAll() {
this.accordions.forEach((accordion) => {
accordion.open = false;
});
this.saveState();
}
};
// src/ts/utilities/FontPreview.ts
var FontPreview = class {
constructor(inputSelector, previewSelector) {
const input = document.querySelector(inputSelector);
if (!input)
throw new Error(`Input element "${inputSelector}" not found`);
this.inputElement = input;
this.previewElements = Array.from(
document.querySelectorAll(previewSelector)
);
this.initialize();
}
initialize() {
this.inputElement.addEventListener(
"input",
() => this.updatePreviewText()
);
this.updatePreviewText();
}
updatePreviewText() {
const value = this.inputElement.value || "The quick brown fox jumps over the lazy dog.";
this.previewElements.forEach((el) => {
el.textContent = value;
});
}
};
// src/ts/animations/ContentRevealer.ts
var ContentRevealer = class {
constructor(selectorOrElements, options = {}) {
this.observer = null;
if (typeof selectorOrElements === "string") {
this.elements = Array.from(
document.querySelectorAll(selectorOrElements)
);
} else if (selectorOrElements instanceof HTMLElement) {
this.elements = [selectorOrElements];
} else {
this.elements = Array.from(selectorOrElements);
}
this.options = {
delay: options.delay ?? 0,
duration: options.duration ?? 500,
easing: options.easing ?? "ease",
initialOpacity: options.initialOpacity ?? 0,
onScroll: options.onScroll ?? false,
threshold: options.threshold ?? 0.1
};
this.init();
}
// ========================================================================
// Public Methods
// ========================================================================
/**
* Manually reveal all elements
*/
revealAll() {
this.elements.forEach((el) => this.reveal(el));
}
/**
* Reveal a specific element
*/
reveal(element) {
element.style.transition = `opacity ${this.options.duration}ms ${this.options.easing}`;
element.style.opacity = "1";
element.classList.add("reveal--visible");
element.setAttribute("data-ss-reveal-revealed", "true");
}
/**
* Reset element to hidden state
*/
hide(element) {
element.style.opacity = String(this.options.initialOpacity);
element.classList.remove("reveal--visible");
element.removeAttribute("data-ss-reveal-revealed");
}
/**
* Reset all elements to hidden state
*/
hideAll() {
this.elements.forEach((el) => this.hide(el));
}
/**
* Destroy the revealer instance
*/
destroy() {
if (this.observer) {
this.observer.disconnect();
this.observer = null;
}
}
// ========================================================================
// Private Methods
// ========================================================================
init() {
this.elements.forEach((el) => {
el.style.opacity = String(this.options.initialOpacity);
el.style.transition = `opacity ${this.options.duration}ms ${this.options.easing}`;
});
if (this.options.onScroll) {
this.initIntersectionObserver();
} else {
this.initLoadReveal();
}
}
initLoadReveal() {
const reveal = () => {
setTimeout(() => this.revealAll(), this.options.delay);
};
if (document.readyState === "complete") {
reveal();
} else {
window.addEventListener("load", reveal);
}
}
initIntersectionObserver() {
this.observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
const el = entry.target;
setTimeout(() => this.reveal(el), this.options.delay);
this.observer?.unobserve(el);
}
});
},
{ threshold: this.options.threshold }
);
this.elements.forEach((el) => this.observer?.observe(el));
}
};
// src/ts/animations/CountdownTimer.ts
var CountdownTimer = class {
constructor(selectorOrElement, options = {}) {
this.intervalId = null;
this.element = typeof selectorOrElement === "string" ? document.querySelector(selectorOrElement) : selectorOrElement;
this.options = {
endTime: options.endTime ?? new Date(Date.now() + 36e5),
// Default: 1 hour
format: options.format ?? "HH:MM:SS",
endText: options.endText ?? "Time's up!",
interval: options.interval ?? 1e3,
onComplete: options.onComplete ?? (() => {
}),
onTick: options.onTick ?? (() => {
}),
leadingZeros: options.leadingZeros !== false
};
if (typeof this.options.endTime === "string") {
this.endTime = new Date(this.options.endTime).getTime();
} else if (typeof this.options.endTime === "number") {
this.endTime = this.options.endTime;
} else {
this.endTime = this.options.endTime.getTime();
}
if (!this.element) {
console.warn("[Stylescape] CountdownTimer element not found");
return;
}
this.start();
}
// ========================================================================
// Public Methods
// ========================================================================
/**
* Start the countdown
*/
start() {
if (this.intervalId) return;
this.tick();
this.intervalId = window.setInterval(
() => this.tick(),
this.options.interval
);
}
/**
* Stop the countdown
*/
stop() {
if (this.intervalId) {
clearInterval(this.intervalId);
this.intervalId = null;
}
}
/**
* Reset with new end time
*/
reset(endTime) {
this.stop();
if (typeof endTime === "string") {
this.endTime = new Date(endTime).getTime();
} else if (typeof endTime === "number") {
this.endTime = endTime;
} else {
this.endTime = endTime.getTime();
}
this.start();
}
/**
* Get remaining time breakdown
*/
getRemaining() {
const total =