mirror of
https://github.com/imfing/hextra.git
synced 2025-09-15 12:01:59 -04:00
Compare commits
11 Commits
v0.11.1
...
image-zoom
Author | SHA1 | Date | |
---|---|---|---|
![]() |
6fc6391d06 | ||
![]() |
be49fe6f57 | ||
![]() |
9e50415b94 | ||
![]() |
6e33f17cba | ||
![]() |
ba0934b2e1 | ||
![]() |
a528d9adc0 | ||
![]() |
c2c4cafa13 | ||
![]() |
09728a4aa9 | ||
![]() |
ccb63d60f1 | ||
![]() |
3bc454bbf6 | ||
![]() |
1b536e27a5 |
84
assets/css/components/image-zoom.css
Normal file
84
assets/css/components/image-zoom.css
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
.hextra-zoom-image-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: var(--hextra-image-zoom-backdrop, rgba(0, 0, 0, 0.9));
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 9999;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 260ms cubic-bezier(0.2, 0, 0, 1);
|
||||||
|
cursor: zoom-out;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
touch-action: none;
|
||||||
|
backdrop-filter: blur(var(--hextra-image-zoom-blur, 4px));
|
||||||
|
-webkit-backdrop-filter: blur(var(--hextra-image-zoom-blur, 4px));
|
||||||
|
/* Prevent iOS bounce */
|
||||||
|
position: fixed;
|
||||||
|
overflow: hidden;
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hextra-zoom-image-overlay.show {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hextra-zoom-image-overlay.closing {
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 360ms cubic-bezier(0.2, 0, 0, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hextra-zoom-image {
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 18px 80px rgba(0, 0, 0, 0.5);
|
||||||
|
image-rendering: -webkit-optimize-contrast;
|
||||||
|
image-rendering: high-quality;
|
||||||
|
will-change: transform;
|
||||||
|
/* Prevent image selection on mobile */
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
-webkit-touch-callout: none;
|
||||||
|
/* Hardware acceleration */
|
||||||
|
transform: translateZ(0);
|
||||||
|
-webkit-transform: translateZ(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hextra-zoom-image-overlay.show .hextra-zoom-image {
|
||||||
|
transition:
|
||||||
|
transform 320ms cubic-bezier(0.2, 0.8, 0.2, 1),
|
||||||
|
left 320ms cubic-bezier(0.2, 0.8, 0.2, 1),
|
||||||
|
top 320ms cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hextra-zoom-image-overlay.closing .hextra-zoom-image {
|
||||||
|
transition:
|
||||||
|
transform 340ms cubic-bezier(0.3, 0, 0.2, 1),
|
||||||
|
left 340ms cubic-bezier(0.3, 0, 0.2, 1),
|
||||||
|
top 340ms cubic-bezier(0.3, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Disable transitions during interaction */
|
||||||
|
.hextra-zoom-image-overlay.interacting .hextra-zoom-image {
|
||||||
|
transition: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.hextra-zoom-image-overlay,
|
||||||
|
.hextra-zoom-image {
|
||||||
|
transition: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.content img:not([data-no-zoom]):not(.not-prose img) {
|
||||||
|
cursor: zoom-in;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading indicator */
|
||||||
|
.hextra-zoom-image.loading {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hextra-zoom-image.loaded {
|
||||||
|
opacity: 1;
|
||||||
|
transition: opacity 200ms ease-in-out;
|
||||||
|
}
|
408
assets/js/image-zoom.js
Normal file
408
assets/js/image-zoom.js
Normal file
@@ -0,0 +1,408 @@
|
|||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
function createOverlayFromTarget(targetImg) {
|
||||||
|
const src = targetImg.currentSrc || targetImg.src;
|
||||||
|
const alt = targetImg.alt || "";
|
||||||
|
if (!src) return;
|
||||||
|
|
||||||
|
const rect = targetImg.getBoundingClientRect();
|
||||||
|
|
||||||
|
const overlay = document.createElement("div");
|
||||||
|
overlay.className = "hextra-zoom-image-overlay";
|
||||||
|
overlay.setAttribute("role", "dialog");
|
||||||
|
overlay.setAttribute("aria-modal", "true");
|
||||||
|
overlay.setAttribute("aria-label", alt || "Zoomed image");
|
||||||
|
|
||||||
|
const img = document.createElement("img");
|
||||||
|
img.className = "hextra-zoom-image loading";
|
||||||
|
img.src = src;
|
||||||
|
if (alt) img.alt = alt;
|
||||||
|
|
||||||
|
// Center-origin positioning for cleaner transforms
|
||||||
|
const startCX = rect.left + rect.width / 2;
|
||||||
|
const startCY = rect.top + rect.height / 2;
|
||||||
|
img.style.position = "fixed";
|
||||||
|
img.style.left = startCX + "px";
|
||||||
|
img.style.top = startCY + "px";
|
||||||
|
img.style.width = rect.width + "px";
|
||||||
|
img.style.height = rect.height + "px";
|
||||||
|
img.style.transformOrigin = "center center";
|
||||||
|
img.style.transform = "translate3d(-50%, -50%, 0) scale(1)";
|
||||||
|
|
||||||
|
// Ensure overlay scales from center when zooming the whole overlay
|
||||||
|
overlay.style.transformOrigin = "center center";
|
||||||
|
overlay.appendChild(img);
|
||||||
|
|
||||||
|
// Image loaded handler
|
||||||
|
img.addEventListener('load', function () {
|
||||||
|
img.classList.remove('loading');
|
||||||
|
img.classList.add('loaded');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Gesture state management
|
||||||
|
let isPinching = false;
|
||||||
|
let isDragging = false;
|
||||||
|
let isInteracting = false;
|
||||||
|
let pinchEndTimer = null;
|
||||||
|
|
||||||
|
const pointers = new Map(); // pointerId -> {x, y, startX, startY}
|
||||||
|
let gestureState = {
|
||||||
|
scale: 1,
|
||||||
|
panX: 0,
|
||||||
|
panY: 0,
|
||||||
|
lastScale: 1,
|
||||||
|
lastPanX: 0,
|
||||||
|
lastPanY: 0,
|
||||||
|
initialDistance: 0,
|
||||||
|
midpointX: 0,
|
||||||
|
midpointY: 0
|
||||||
|
};
|
||||||
|
|
||||||
|
// Utility functions
|
||||||
|
function getDistance(p1, p2) {
|
||||||
|
const dx = p2.x - p1.x;
|
||||||
|
const dy = p2.y - p1.y;
|
||||||
|
return Math.sqrt(dx * dx + dy * dy);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMidpoint(p1, p2) {
|
||||||
|
return {
|
||||||
|
x: (p1.x + p2.x) / 2,
|
||||||
|
y: (p1.y + p2.y) / 2
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function setInteracting(value) {
|
||||||
|
isInteracting = value;
|
||||||
|
if (value) {
|
||||||
|
overlay.classList.add('interacting');
|
||||||
|
} else {
|
||||||
|
overlay.classList.remove('interacting');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pointer event handlers
|
||||||
|
let tapCandidate = false;
|
||||||
|
let tapStartX = 0;
|
||||||
|
let tapStartY = 0;
|
||||||
|
let tapStartTime = 0;
|
||||||
|
|
||||||
|
function onPointerDown(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
pointers.set(e.pointerId, {
|
||||||
|
x: e.clientX,
|
||||||
|
y: e.clientY,
|
||||||
|
startX: e.clientX,
|
||||||
|
startY: e.clientY
|
||||||
|
});
|
||||||
|
|
||||||
|
if (pointers.size === 1) {
|
||||||
|
isDragging = false;
|
||||||
|
setInteracting(true);
|
||||||
|
gestureState.lastPanX = gestureState.panX;
|
||||||
|
gestureState.lastPanY = gestureState.panY;
|
||||||
|
|
||||||
|
// Tap detection setup
|
||||||
|
tapCandidate = true;
|
||||||
|
tapStartX = e.clientX;
|
||||||
|
tapStartY = e.clientY;
|
||||||
|
tapStartTime = (typeof performance !== 'undefined' ? performance.now() : Date.now());
|
||||||
|
} else if (pointers.size === 2) {
|
||||||
|
// Two touches - start pinch
|
||||||
|
isDragging = false;
|
||||||
|
isPinching = true;
|
||||||
|
setInteracting(true);
|
||||||
|
|
||||||
|
const pts = Array.from(pointers.values());
|
||||||
|
gestureState.initialDistance = getDistance(pts[0], pts[1]);
|
||||||
|
gestureState.lastScale = gestureState.scale;
|
||||||
|
|
||||||
|
const midpoint = getMidpoint(pts[0], pts[1]);
|
||||||
|
gestureState.midpointX = midpoint.x;
|
||||||
|
gestureState.midpointY = midpoint.y;
|
||||||
|
|
||||||
|
if (pinchEndTimer) {
|
||||||
|
clearTimeout(pinchEndTimer);
|
||||||
|
pinchEndTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerMove(e) {
|
||||||
|
if (!pointers.has(e.pointerId)) return;
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const pointer = pointers.get(e.pointerId);
|
||||||
|
pointer.x = e.clientX;
|
||||||
|
pointer.y = e.clientY;
|
||||||
|
|
||||||
|
if (isPinching && pointers.size === 2) {
|
||||||
|
// Handle pinch zoom
|
||||||
|
const pts = Array.from(pointers.values());
|
||||||
|
const currentDistance = getDistance(pts[0], pts[1]);
|
||||||
|
const scaleDelta = currentDistance / gestureState.initialDistance;
|
||||||
|
|
||||||
|
// Calculate new scale with limits - minimum is 1 (original zoom level)
|
||||||
|
const newScale = Math.max(1, Math.min(5, gestureState.lastScale * scaleDelta));
|
||||||
|
|
||||||
|
// Only update pan if scale is actually changing
|
||||||
|
// This prevents drift when pinching at minimum scale
|
||||||
|
if (Math.abs(newScale - gestureState.scale) > 0.001) {
|
||||||
|
gestureState.scale = newScale;
|
||||||
|
|
||||||
|
// Calculate pan based on pinch center movement
|
||||||
|
const currentMidpoint = getMidpoint(pts[0], pts[1]);
|
||||||
|
const panDeltaX = currentMidpoint.x - gestureState.midpointX;
|
||||||
|
const panDeltaY = currentMidpoint.y - gestureState.midpointY;
|
||||||
|
|
||||||
|
gestureState.panX = gestureState.lastPanX + panDeltaX;
|
||||||
|
gestureState.panY = gestureState.lastPanY + panDeltaY;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Any multi-touch movement cancels tap
|
||||||
|
tapCandidate = false;
|
||||||
|
applyTransform();
|
||||||
|
} else if (pointers.size === 1) {
|
||||||
|
// Single pointer: no drag; only cancel tap if large move
|
||||||
|
const moveThreshold = 10;
|
||||||
|
if (Math.abs(pointer.x - tapStartX) > moveThreshold || Math.abs(pointer.y - tapStartY) > moveThreshold) {
|
||||||
|
tapCandidate = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerUp(e) {
|
||||||
|
pointers.delete(e.pointerId);
|
||||||
|
|
||||||
|
if (pointers.size === 0) {
|
||||||
|
// All pointers released
|
||||||
|
isDragging = false;
|
||||||
|
setInteracting(false);
|
||||||
|
// Tap-to-close when there was minimal movement and short duration
|
||||||
|
const now = (typeof performance !== 'undefined' ? performance.now() : Date.now());
|
||||||
|
const duration = now - tapStartTime;
|
||||||
|
if (tapCandidate && !isPinching && duration < 300) {
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
tapCandidate = false;
|
||||||
|
if (isPinching) {
|
||||||
|
pinchEndTimer = setTimeout(() => {
|
||||||
|
isPinching = false;
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
} else if (pointers.size === 1) {
|
||||||
|
// Going from pinch to single touch — keep dragging disabled
|
||||||
|
isPinching = false;
|
||||||
|
isDragging = false;
|
||||||
|
const remaining = Array.from(pointers.values())[0];
|
||||||
|
gestureState.lastPanX = gestureState.panX;
|
||||||
|
gestureState.lastPanY = gestureState.panY;
|
||||||
|
remaining.startX = remaining.x;
|
||||||
|
remaining.startY = remaining.y;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerCancel(e) {
|
||||||
|
onPointerUp(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mouse wheel zoom and scroll handling
|
||||||
|
function onWheel(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
// If it's a regular scroll (not pinch), dismiss the overlay
|
||||||
|
if (!e.ctrlKey && !e.metaKey) {
|
||||||
|
// Regular scroll - dismiss overlay gracefully
|
||||||
|
close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle trackpad pinch (ctrl+wheel or cmd+wheel on Mac)
|
||||||
|
const delta = e.deltaY;
|
||||||
|
const scaleFactor = 0.01;
|
||||||
|
const zoomSpeed = Math.exp(-delta * scaleFactor);
|
||||||
|
|
||||||
|
const prevScale = gestureState.scale;
|
||||||
|
const unclamped = prevScale * zoomSpeed;
|
||||||
|
const nextScale = Math.max(1, Math.min(5, unclamped));
|
||||||
|
|
||||||
|
// Effective scale ratio (applied), use it to anchor zoom around cursor
|
||||||
|
const f = prevScale === 0 ? 1 : (nextScale / prevScale);
|
||||||
|
if (f !== 1) {
|
||||||
|
// Zoom towards mouse position
|
||||||
|
const rect = img.getBoundingClientRect();
|
||||||
|
const centerX = rect.left + rect.width / 2;
|
||||||
|
const centerY = rect.top + rect.height / 2;
|
||||||
|
const offsetX = e.clientX - centerX;
|
||||||
|
const offsetY = e.clientY - centerY;
|
||||||
|
|
||||||
|
// Adjust pan to keep cursor's point stable
|
||||||
|
gestureState.panX -= offsetX * (f - 1);
|
||||||
|
gestureState.panY -= offsetY * (f - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
gestureState.scale = nextScale;
|
||||||
|
|
||||||
|
setInteracting(true);
|
||||||
|
applyTransform();
|
||||||
|
setTimeout(() => setInteracting(false), 150);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keyboard navigation
|
||||||
|
function onKeyDown(e) {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
close();
|
||||||
|
} else if (e.key === "+" || e.key === "=") {
|
||||||
|
gestureState.scale = Math.min(5, gestureState.scale * 1.2);
|
||||||
|
applyTransform();
|
||||||
|
} else if (e.key === "-") {
|
||||||
|
gestureState.scale = Math.max(1, gestureState.scale / 1.2);
|
||||||
|
applyTransform();
|
||||||
|
} else if (e.key === "0") {
|
||||||
|
gestureState.scale = 1;
|
||||||
|
gestureState.panX = 0;
|
||||||
|
gestureState.panY = 0;
|
||||||
|
applyTransform();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply transforms
|
||||||
|
let final = computeFinal();
|
||||||
|
let baseScale = final.scale;
|
||||||
|
|
||||||
|
function computeFinal() {
|
||||||
|
const vv = window.visualViewport;
|
||||||
|
const vw = vv ? vv.width : window.innerWidth;
|
||||||
|
const vh = vv ? vv.height : window.innerHeight;
|
||||||
|
const vx = vv ? vv.offsetLeft : 0;
|
||||||
|
const vy = vv ? vv.offsetTop : 0;
|
||||||
|
|
||||||
|
const margin = 20;
|
||||||
|
const maxW = Math.min(vw - margin * 2, 1200);
|
||||||
|
const maxH = vh - margin * 2;
|
||||||
|
|
||||||
|
const scale = Math.min(maxW / rect.width, maxH / rect.height, 2);
|
||||||
|
const centerX = vx + vw / 2;
|
||||||
|
const centerY = vy + vh / 2;
|
||||||
|
|
||||||
|
return { cx: centerX, cy: centerY, scale };
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyTransform() {
|
||||||
|
img.style.left = final.cx + "px";
|
||||||
|
img.style.top = final.cy + "px";
|
||||||
|
|
||||||
|
const overlayScale = Math.max(1, gestureState.scale);
|
||||||
|
overlay.style.transform = overlayScale > 1 ? `scale(${overlayScale})` : "none";
|
||||||
|
|
||||||
|
const totalScale = baseScale;
|
||||||
|
const transform = `translate3d(-50%, -50%, 0) translate3d(${gestureState.panX}px, ${gestureState.panY}px, 0) scale(${totalScale})`;
|
||||||
|
img.style.transform = transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close function
|
||||||
|
function close(immediate = false) {
|
||||||
|
overlay.classList.add("closing");
|
||||||
|
|
||||||
|
// Animate back to original position
|
||||||
|
img.style.left = startCX + "px";
|
||||||
|
img.style.top = startCY + "px";
|
||||||
|
img.style.transform = `translate3d(-50%, -50%, 0) scale(1)`;
|
||||||
|
|
||||||
|
// Cleanup event listeners
|
||||||
|
cleanup();
|
||||||
|
|
||||||
|
if (immediate) {
|
||||||
|
overlay.remove();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove after animation
|
||||||
|
const done = () => overlay.remove();
|
||||||
|
overlay.addEventListener("transitionend", done, { once: true });
|
||||||
|
img.addEventListener("transitionend", done, { once: true });
|
||||||
|
|
||||||
|
// Fallback removal
|
||||||
|
setTimeout(() => {
|
||||||
|
if (overlay.parentNode) {
|
||||||
|
overlay.remove();
|
||||||
|
}
|
||||||
|
}, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanup() {
|
||||||
|
window.removeEventListener("keydown", onKeyDown, true);
|
||||||
|
overlay.removeEventListener("wheel", onWheel);
|
||||||
|
overlay.removeEventListener("pointermove", onPointerMove);
|
||||||
|
overlay.removeEventListener("pointerdown", onPointerDown);
|
||||||
|
overlay.removeEventListener("pointerup", onPointerUp);
|
||||||
|
overlay.removeEventListener("pointercancel", onPointerCancel);
|
||||||
|
window.removeEventListener("resize", onResize);
|
||||||
|
if (window.visualViewport) {
|
||||||
|
window.visualViewport.removeEventListener("resize", onResize);
|
||||||
|
window.visualViewport.removeEventListener("scroll", onResize);
|
||||||
|
}
|
||||||
|
if (pinchEndTimer) {
|
||||||
|
clearTimeout(pinchEndTimer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle viewport changes
|
||||||
|
function onResize() {
|
||||||
|
final = computeFinal();
|
||||||
|
baseScale = final.scale;
|
||||||
|
applyTransform();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup event listeners
|
||||||
|
overlay.addEventListener("wheel", onWheel, { passive: false });
|
||||||
|
overlay.addEventListener("pointermove", onPointerMove);
|
||||||
|
overlay.addEventListener("pointerdown", onPointerDown);
|
||||||
|
overlay.addEventListener("pointerup", onPointerUp);
|
||||||
|
overlay.addEventListener("pointercancel", onPointerCancel);
|
||||||
|
window.addEventListener("keydown", onKeyDown, true);
|
||||||
|
window.addEventListener("resize", onResize, { passive: true });
|
||||||
|
|
||||||
|
if (window.visualViewport) {
|
||||||
|
window.visualViewport.addEventListener("resize", onResize, { passive: true });
|
||||||
|
window.visualViewport.addEventListener("scroll", onResize, { passive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add to DOM
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
|
||||||
|
// Trigger opening animation
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
overlay.classList.add("show");
|
||||||
|
applyTransform();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize after DOM ready
|
||||||
|
function init() {
|
||||||
|
const container = document.querySelector(".content");
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
container.addEventListener("click", function (e) {
|
||||||
|
const target = e.target;
|
||||||
|
if (!(target instanceof HTMLImageElement)) return;
|
||||||
|
// Only allow images inside `.content` that are NOT within a `.not-prose` block
|
||||||
|
if (target.closest('.not-prose')) return;
|
||||||
|
if (target.dataset.noZoom === "" || target.dataset.noZoom === "true") return;
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
createOverlayFromTarget(target);
|
||||||
|
}, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', init, { once: true });
|
||||||
|
} else {
|
||||||
|
init();
|
||||||
|
}
|
||||||
|
})();
|
2
build.sh
2
build.sh
@@ -9,7 +9,7 @@ echo "Using base URL: $BASE_URL"
|
|||||||
# Version configuration - modify these arrays to specify versions to build
|
# Version configuration - modify these arrays to specify versions to build
|
||||||
# MAIN_VERSION format: "ref:display_name:source_dir"
|
# MAIN_VERSION format: "ref:display_name:source_dir"
|
||||||
# VERSIONS format: "ref:display_name:source_dir" where source_dir is either "docs" or "exampleSite"
|
# VERSIONS format: "ref:display_name:source_dir" where source_dir is either "docs" or "exampleSite"
|
||||||
MAIN_VERSION="v0.11.0:latest:exampleSite"
|
MAIN_VERSION="v0.11.1:latest:docs"
|
||||||
VERSIONS=(
|
VERSIONS=(
|
||||||
"main:latest:docs" # latest version always builds from main
|
"main:latest:docs" # latest version always builds from main
|
||||||
"v0.10.2:v0.10:exampleSite"
|
"v0.10.2:v0.10:exampleSite"
|
||||||
|
@@ -164,6 +164,21 @@ menu:
|
|||||||
weight: 3
|
weight: 3
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Hiding
|
||||||
|
|
||||||
|
Hiding the sidebar can be done using front matter:
|
||||||
|
|
||||||
|
```yaml {filename="content/docs/guide/configuration.md"}
|
||||||
|
---
|
||||||
|
title: Configuration
|
||||||
|
sidebar:
|
||||||
|
hide: true
|
||||||
|
---
|
||||||
|
```
|
||||||
|
|
||||||
|
This will hide the main sidebar from the page, freeing up space for the main content of the page.
|
||||||
|
|
||||||
|
|
||||||
## Right Sidebar
|
## Right Sidebar
|
||||||
|
|
||||||
### Table of Contents
|
### Table of Contents
|
||||||
|
@@ -5,12 +5,10 @@ next: /docs/guide/deploy-site
|
|||||||
|
|
||||||
## Example
|
## Example
|
||||||
|
|
||||||
{{< tabs items="macOS,Linux,Windows" >}}
|
{{< tabs >}}
|
||||||
|
{{< tab name="JSON" >}}**JSON**: JavaScript Object Notation (JSON) is a standard text-based format for representing structured data based on JavaScript object syntax.{{< /tab >}}
|
||||||
{{< tab >}}**macOS**: A desktop operating system by Apple.{{< /tab >}}
|
{{< tab name="YAML" >}}**YAML**: YAML is a human-readable data serialization language.{{< /tab >}}
|
||||||
{{< tab >}}**Linux**: An open-source operating system.{{< /tab >}}
|
{{< tab name="TOML" >}}**TOML**: TOML aims to be a minimal configuration file format that's easy to read due to obvious semantics.{{< /tab >}}
|
||||||
{{< tab >}}**Windows**: A desktop operating system by Microsoft.{{< /tab >}}
|
|
||||||
|
|
||||||
{{< /tabs >}}
|
{{< /tabs >}}
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
@@ -18,37 +16,35 @@ next: /docs/guide/deploy-site
|
|||||||
### Default
|
### Default
|
||||||
|
|
||||||
```
|
```
|
||||||
{{</* tabs items="JSON,YAML,TOML" */>}}
|
{{</* tabs */>}}
|
||||||
|
|
||||||
{{</* tab */>}}**JSON**: JavaScript Object Notation (JSON) is a standard text-based format for representing structured data based on JavaScript object syntax.{{</* /tab */>}}
|
{{</* tab name="JSON" */>}}**JSON**: JavaScript Object Notation (JSON) is a standard text-based format for representing structured data based on JavaScript object syntax.{{</* /tab */>}}
|
||||||
{{</* tab */>}}**YAML**: YAML is a human-readable data serialization language.{{</* /tab */>}}
|
{{</* tab name="YAML" */>}}**YAML**: YAML is a human-readable data serialization language.{{</* /tab */>}}
|
||||||
{{</* tab */>}}**TOML**: TOML aims to be a minimal configuration file format that's easy to read due to obvious semantics.{{</* /tab */>}}
|
{{</* tab name="TOML" */>}}**TOML**: TOML aims to be a minimal configuration file format that's easy to read due to obvious semantics.{{</* /tab */>}}
|
||||||
|
|
||||||
{{</* /tabs */>}}
|
{{</* /tabs */>}}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Specify Selected Index
|
### Specify Selected Tab
|
||||||
|
|
||||||
Use `defaultIndex` property to specify the selected tab. The index starts from 0.
|
Use `selected` property to specify the selected tab.
|
||||||
|
|
||||||
```
|
```
|
||||||
{{</* tabs items="JSON,YAML,TOML" defaultIndex="1" */>}}
|
{{</* tabs */>}}
|
||||||
|
|
||||||
{{</* tab */>}}**JSON**: JavaScript Object Notation (JSON) is a standard text-based format for representing structured data based on JavaScript object syntax.{{</* /tab */>}}
|
{{</* tab name="JSON" */>}}**JSON**: JavaScript Object Notation (JSON) is a standard text-based format for representing structured data based on JavaScript object syntax.{{</* /tab */>}}
|
||||||
{{</* tab */>}}**YAML**: YAML is a human-readable data serialization language.{{</* /tab */>}}
|
{{</* tab name="YAML" selected=true */>}}**YAML**: YAML is a human-readable data serialization language.{{</* /tab */>}}
|
||||||
{{</* tab */>}}**TOML**: TOML aims to be a minimal configuration file format that's easy to read due to obvious semantics.{{</* /tab */>}}
|
{{</* tab name="TOML" */>}}**TOML**: TOML aims to be a minimal configuration file format that's easy to read due to obvious semantics.{{</* /tab */>}}
|
||||||
|
|
||||||
{{</* /tabs */>}}
|
{{</* /tabs */>}}
|
||||||
```
|
```
|
||||||
|
|
||||||
The `YAML` tab will be selected by default.
|
The `YAML` tab will be selected by default.
|
||||||
|
|
||||||
{{< tabs items="JSON,YAML,TOML" defaultIndex="1" >}}
|
{{< tabs >}}
|
||||||
|
{{< tab name="JSON" >}}**JSON**: JavaScript Object Notation (JSON) is a standard text-based format for representing structured data based on JavaScript object syntax.{{< /tab >}}
|
||||||
{{< tab >}}**JSON**: JavaScript Object Notation (JSON) is a standard text-based format for representing structured data based on JavaScript object syntax.{{< /tab >}}
|
{{< tab name="YAML" selected=true >}}**YAML**: YAML is a human-readable data serialization language.{{< /tab >}}
|
||||||
{{< tab >}}**YAML**: YAML is a human-readable data serialization language.{{< /tab >}}
|
{{< tab name="TOML" >}}**TOML**: TOML aims to be a minimal configuration file format that's easy to read due to obvious semantics.{{< /tab >}}
|
||||||
{{< tab >}}**TOML**: TOML aims to be a minimal configuration file format that's easy to read due to obvious semantics.{{< /tab >}}
|
|
||||||
|
|
||||||
{{< /tabs >}}
|
{{< /tabs >}}
|
||||||
|
|
||||||
|
|
||||||
@@ -57,9 +53,9 @@ The `YAML` tab will be selected by default.
|
|||||||
Markdown syntax including code block is also supported:
|
Markdown syntax including code block is also supported:
|
||||||
|
|
||||||
````
|
````
|
||||||
{{</* tabs items="JSON,YAML,TOML" */>}}
|
{{</* tabs */>}}
|
||||||
|
|
||||||
{{</* tab */>}}
|
{{</* tab name="JSON" */>}}
|
||||||
```json
|
```json
|
||||||
{ "hello": "world" }
|
{ "hello": "world" }
|
||||||
```
|
```
|
||||||
@@ -70,21 +66,21 @@ Markdown syntax including code block is also supported:
|
|||||||
{{</* /tabs */>}}
|
{{</* /tabs */>}}
|
||||||
````
|
````
|
||||||
|
|
||||||
{{< tabs items="JSON,YAML,TOML" >}}
|
{{< tabs >}}
|
||||||
|
|
||||||
{{< tab >}}
|
{{< tab name="JSON" >}}
|
||||||
```json
|
```json
|
||||||
{ "hello": "world" }
|
{ "hello": "world" }
|
||||||
```
|
```
|
||||||
{{< /tab >}}
|
{{< /tab >}}
|
||||||
|
|
||||||
{{< tab >}}
|
{{< tab name="YAML" >}}
|
||||||
```yaml
|
```yaml
|
||||||
hello: world
|
hello: world
|
||||||
```
|
```
|
||||||
{{< /tab >}}
|
{{< /tab >}}
|
||||||
|
|
||||||
{{< tab >}}
|
{{< tab name="TOML" >}}
|
||||||
```toml
|
```toml
|
||||||
hello = "world"
|
hello = "world"
|
||||||
```
|
```
|
||||||
@@ -97,7 +93,7 @@ Markdown syntax including code block is also supported:
|
|||||||
|
|
||||||
Tabs with the same list of `items` can be synchronized. When enabled, selecting a tab updates all other tabs with the same `items` and remembers the selection across pages.
|
Tabs with the same list of `items` can be synchronized. When enabled, selecting a tab updates all other tabs with the same `items` and remembers the selection across pages.
|
||||||
|
|
||||||
Enable globally in your `hugo.yaml` under the `page` section:
|
Enable/disable globally in your `hugo.yaml` under the `page` section:
|
||||||
|
|
||||||
```yaml {filename="hugo.yaml"}
|
```yaml {filename="hugo.yaml"}
|
||||||
params:
|
params:
|
||||||
@@ -106,20 +102,33 @@ params:
|
|||||||
sync: true
|
sync: true
|
||||||
```
|
```
|
||||||
|
|
||||||
With this enabled the following two tab blocks will always display the same selected item:
|
Enable/disable per page inside the front matter:
|
||||||
|
|
||||||
|
```yaml {filename="my_page.md"}
|
||||||
|
---
|
||||||
|
title: My page
|
||||||
|
params:
|
||||||
|
tabs:
|
||||||
|
sync: true
|
||||||
|
---
|
||||||
|
|
||||||
|
Example content.
|
||||||
|
```
|
||||||
|
|
||||||
|
With this enabled, the following two tab blocks will always display the same selected item:
|
||||||
|
|
||||||
```markdown
|
```markdown
|
||||||
{{</* tabs items="A,B" */>}}
|
{{</* tabs */>}}
|
||||||
|
|
||||||
{{</* tab */>}}A content{{</* /tab */>}}
|
{{</* tab name="A" */>}}A content{{</* /tab */>}}
|
||||||
{{</* tab */>}}B content{{</* /tab */>}}
|
{{</* tab name="B" */>}}B content{{</* /tab */>}}
|
||||||
|
|
||||||
{{</* /tabs */>}}
|
{{</* /tabs */>}}
|
||||||
|
|
||||||
{{</* tabs items="A,B" */>}}
|
{{</* tabs */>}}
|
||||||
|
|
||||||
{{</* tab */>}}Second A content{{</* /tab */>}}
|
{{</* tab name="A" */>}}Second A content{{</* /tab */>}}
|
||||||
{{</* tab */>}}Second B content{{</* /tab */>}}
|
{{</* tab name="B" */>}}Second B content{{</* /tab */>}}
|
||||||
|
|
||||||
{{</* /tabs */>}}
|
{{</* /tabs */>}}
|
||||||
```
|
```
|
||||||
|
@@ -238,3 +238,6 @@ params:
|
|||||||
# inputPosition: top
|
# inputPosition: top
|
||||||
# lang: en
|
# lang: en
|
||||||
# theme: noborder_dark
|
# theme: noborder_dark
|
||||||
|
|
||||||
|
imageZoom:
|
||||||
|
enable: true
|
||||||
|
@@ -13,3 +13,10 @@
|
|||||||
{{- if (.Store.Get "hasAsciinema") -}}
|
{{- if (.Store.Get "hasAsciinema") -}}
|
||||||
{{- partial "scripts/asciinema.html" . -}}
|
{{- partial "scripts/asciinema.html" . -}}
|
||||||
{{- end -}}
|
{{- end -}}
|
||||||
|
|
||||||
|
{{/* Image zoom */}}
|
||||||
|
{{- with site.Params.imageZoom }}
|
||||||
|
{{- if .enable }}
|
||||||
|
{{- partial "scripts/image-zoom.html" $ -}}
|
||||||
|
{{- end -}}
|
||||||
|
{{- end -}}
|
||||||
|
13
layouts/_partials/scripts/image-zoom.html
Normal file
13
layouts/_partials/scripts/image-zoom.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{{/* Optional minimal image zoom assets */}}
|
||||||
|
{{- $js := resources.Get "js/image-zoom.js" -}}
|
||||||
|
{{- $css := resources.Get "css/components/image-zoom.css" -}}
|
||||||
|
|
||||||
|
{{- if hugo.IsProduction -}}
|
||||||
|
{{- $js = $js | minify | fingerprint -}}
|
||||||
|
{{- $css = $css | minify | fingerprint -}}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
|
||||||
|
<link rel="preload" href="{{ $css.RelPermalink }}" as="style" integrity="{{ $css.Data.Integrity }}" />
|
||||||
|
<link href="{{ $css.RelPermalink }}" rel="stylesheet" integrity="{{ $css.Data.Integrity }}" />
|
||||||
|
<script defer src="{{ $js.RelPermalink }}" integrity="{{ $js.Data.Integrity }}"></script>
|
69
layouts/_partials/shortcodes/tabs.html
Normal file
69
layouts/_partials/shortcodes/tabs.html
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
{{- $tabsID := .id }}
|
||||||
|
|
||||||
|
{{- /*
|
||||||
|
The `tabs` parameter is a list of dict with the following keys:
|
||||||
|
- `id`: (int) the ID of the tab (the Ordinal of the tab shortcode).
|
||||||
|
- `name`: (string) the name of the tab (the title).
|
||||||
|
- `content`: (string) the content of the tab.
|
||||||
|
- `selected`: (bool) whether the tab is selected.
|
||||||
|
*/ -}}
|
||||||
|
{{- $tabs := .tabs }}
|
||||||
|
|
||||||
|
{{- if eq (len $tabs) 0 -}}
|
||||||
|
{{ errorf "tabs must have at least one tab" }}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{- $enableSync := .enableSync }}
|
||||||
|
|
||||||
|
{{- /* Create group data for syncing and select the first tab if none is selected. */ -}}
|
||||||
|
{{- $selectedIndex := 0 -}}
|
||||||
|
{{ $dataTabGroup := slice -}}
|
||||||
|
|
||||||
|
{{- range $i, $item := $tabs -}}
|
||||||
|
{{- $dataTabGroup = $dataTabGroup | append ($item.name) -}}
|
||||||
|
|
||||||
|
{{- if $item.selected -}}
|
||||||
|
{{- $selectedIndex = $i -}}
|
||||||
|
{{- end -}}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{- /* Generate a unique ID for each tab group. */ -}}
|
||||||
|
{{- $globalID := printf "tabs-%02v" $tabsID -}}
|
||||||
|
|
||||||
|
<div class="hextra-scrollbar hx:overflow-x-auto hx:overflow-y-hidden hx:overscroll-x-contain">
|
||||||
|
<div
|
||||||
|
class="hx:mt-4 hx:flex hx:w-max hx:min-w-full hx:border-b hx:border-gray-200 hx:pb-px hx:dark:border-neutral-800"
|
||||||
|
{{ if $enableSync }} data-tab-group="{{ delimit $dataTabGroup `,` }}"{{ end }}
|
||||||
|
>
|
||||||
|
{{- range $i, $item := $tabs -}}
|
||||||
|
<button
|
||||||
|
class="hextra-tabs-toggle hx:cursor-pointer hx:data-[state=selected]:border-primary-500 hx:data-[state=selected]:text-primary-600 hx:data-[state=selected]:dark:border-primary-500 hx:data-[state=selected]:dark:text-primary-600 hx:mr-2 hx:rounded-t hx:p-2 hx:font-medium hx:leading-5 hx:transition-colors hx:-mb-0.5 hx:select-none hx:border-b-2 hx:border-transparent hx:text-gray-600 hx:hover:border-gray-200 hx:hover:text-black hx:dark:text-gray-200 hx:dark:hover:border-neutral-800 hx:dark:hover:text-white"
|
||||||
|
role="tab"
|
||||||
|
type="button"
|
||||||
|
aria-controls="tabs-panel-{{ $globalID }}-{{ $item.id }}"
|
||||||
|
{{- if eq $i $selectedIndex -}}
|
||||||
|
aria-selected="true"
|
||||||
|
tabindex="0"
|
||||||
|
data-state="selected"
|
||||||
|
{{- end }}
|
||||||
|
>
|
||||||
|
{{- $item.name -}}
|
||||||
|
</button>
|
||||||
|
{{- end -}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{{- range $i, $item := $tabs -}}
|
||||||
|
<div
|
||||||
|
class="hextra-tabs-panel hx:rounded-sm hx:pt-6 hx:hidden hx:data-[state=selected]:block"
|
||||||
|
id="tabs-panel-{{ $globalID }}-{{ $item.id }}"
|
||||||
|
role="tabpanel"
|
||||||
|
{{- if eq $i $selectedIndex -}}
|
||||||
|
tabindex="0"
|
||||||
|
data-state="selected"
|
||||||
|
{{ end -}}
|
||||||
|
>
|
||||||
|
{{- $item.content | markdownify -}}
|
||||||
|
</div>
|
||||||
|
{{- end -}}
|
||||||
|
</div>
|
@@ -3,17 +3,22 @@
|
|||||||
{{- $disableSidebar := .disableSidebar | default false -}}
|
{{- $disableSidebar := .disableSidebar | default false -}}
|
||||||
{{- $displayPlaceholder := .displayPlaceholder | default false -}}
|
{{- $displayPlaceholder := .displayPlaceholder | default false -}}
|
||||||
|
|
||||||
{{- $sidebarClass := cond $disableSidebar (cond $displayPlaceholder "hx:md:hidden hx:xl:block" "hx:md:hidden") "hx:md:sticky" -}}
|
|
||||||
|
|
||||||
{{- $navRoot := cond (eq site.Home.Type "docs") site.Home $context.FirstSection -}}
|
{{- $navRoot := cond (eq site.Home.Type "docs") site.Home $context.FirstSection -}}
|
||||||
{{- $pageURL := $context.RelPermalink -}}
|
{{- $pageURL := $context.RelPermalink -}}
|
||||||
|
|
||||||
{{/* EXPERIMENTAL */}}
|
|
||||||
{{- if .context.Params.sidebar.hide -}}
|
{{- if .context.Params.sidebar.hide -}}
|
||||||
{{- $disableSidebar = true -}}
|
{{- $disableSidebar = true -}}
|
||||||
{{- $displayPlaceholder = true -}}
|
{{- $displayPlaceholder = false -}}
|
||||||
{{- end -}}
|
{{- end -}}
|
||||||
|
|
||||||
|
{{- $sidebarClass := "hx:md:sticky" -}}
|
||||||
|
{{- if $disableSidebar -}}
|
||||||
|
{{- if $displayPlaceholder -}}
|
||||||
|
{{- $sidebarClass = "hx:md:hidden hx:xl:block" -}}
|
||||||
|
{{- else -}}
|
||||||
|
{{- $sidebarClass = "hx:md:hidden" -}}
|
||||||
|
{{- end -}}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
<aside class="hextra-sidebar-container hx:flex hx:flex-col hx:print:hidden hx:md:top-16 hx:md:shrink-0 hx:md:w-64 hx:md:self-start hx:max-md:[transform:translate3d(0,-100%,0)] {{ $sidebarClass }}">
|
<aside class="hextra-sidebar-container hx:flex hx:flex-col hx:print:hidden hx:md:top-16 hx:md:shrink-0 hx:md:w-64 hx:md:self-start hx:max-md:[transform:translate3d(0,-100%,0)] {{ $sidebarClass }}">
|
||||||
<!-- Search bar on small screen -->
|
<!-- Search bar on small screen -->
|
||||||
|
@@ -1,18 +1,24 @@
|
|||||||
{{- /*
|
{{- /*
|
||||||
Create a tab.
|
Create a tab.
|
||||||
|
|
||||||
@example {{< tab >}}content{{< /tab >}}
|
@param {string} name The name of the tab.
|
||||||
|
@param {string} selected Whether the tab is selected.
|
||||||
|
|
||||||
|
@example {{< tab name="Foo" selected=true >}}content{{< /tab >}}
|
||||||
*/ -}}
|
*/ -}}
|
||||||
|
|
||||||
{{- $defaultIndex := int ((.Parent.Get "defaultIndex") | default "0") -}}
|
{{- $name := .Get "name" | default (printf "Tab %d" .Ordinal) -}}
|
||||||
|
|
||||||
<div
|
{{- $selected := .Get "selected" -}}
|
||||||
class="hextra-tabs-panel hx:rounded-sm hx:pt-6 hx:hidden hx:data-[state=selected]:block"
|
{{- if .Parent.Get "defaultIndex" -}}
|
||||||
id="tabs-panel-{{ .Ordinal }}"
|
{{- $selected = eq .Ordinal (int (.Parent.Get "defaultIndex")) -}}
|
||||||
role="tabpanel"
|
{{- end -}}
|
||||||
{{- if eq .Ordinal $defaultIndex }} tabindex="0" {{ end -}}
|
|
||||||
{{- if eq .Ordinal $defaultIndex }} data-state="selected" {{ end -}}
|
{{- $tabs := .Parent.Store.Get "tabs" | default slice -}}
|
||||||
>
|
{{ .Parent.Store.Set "tabs" ($tabs | append (dict
|
||||||
{{- .InnerDeindent | markdownify -}}
|
"id" .Ordinal
|
||||||
</div>
|
"name" $name
|
||||||
{{- /* Drop trailing newlines */ -}}
|
"content" .InnerDeindent
|
||||||
|
"selected" $selected
|
||||||
|
))
|
||||||
|
-}}
|
||||||
|
@@ -1,49 +1,39 @@
|
|||||||
{{- /*
|
{{- /*
|
||||||
Create a tabbed interface with the given items.
|
Create a tabbed interface with the given items.
|
||||||
|
|
||||||
@param {string} items The items to display in the tabs.
|
@example {{< tabs >}}...{{< /tabs >}}
|
||||||
@param {string} defaultIndex The index of the default tab.
|
|
||||||
|
|
||||||
@example {{< tabs items="JSON,YAML,TOML" >}}{{< /tabs >}}
|
|
||||||
*/ -}}
|
*/ -}}
|
||||||
|
|
||||||
{{- $items := split (.Get "items") "," -}}
|
{{- /* Unused, but required for the shortcode to work. */ -}}
|
||||||
{{- $defaultIndex := int ((.Get "defaultIndex") | default "0") -}}
|
{{- .Inner -}}
|
||||||
|
|
||||||
{{- $enableSync := site.Params.page.tabs.sync | default false -}}
|
{{- /* Enable syncing of tabs across the page. */ -}}
|
||||||
|
{{- $enableSync := false -}}
|
||||||
{{- if not (.Get "items") -}}
|
{{- if or (eq .Page.Params.tabs.sync false) (eq .Page.Params.tabs.sync true) -}}
|
||||||
{{ errorf "tabs shortcode: 'items' parameter is required" }}
|
{{- $enableSync = .Page.Params.tabs.sync -}}
|
||||||
|
{{- else -}}
|
||||||
|
{{- $enableSync = site.Params.page.tabs.sync | default false -}}
|
||||||
{{- end -}}
|
{{- end -}}
|
||||||
|
|
||||||
{{- if not $items -}}
|
{{- $tabs := ($.Store.Get "tabs") | default slice -}}
|
||||||
{{ errorf "tabs shortcode: 'items' parameter cannot be empty" }}
|
|
||||||
|
{{- /* Compatibility with previous parameter "items". */ -}}
|
||||||
|
{{- if .Get "defaultIndex" -}}
|
||||||
|
{{- warnf "The 'defaultIndex' parameter of the 'tabs' shortcode is deprecated. Please use 'selected' on 'tab' instead." -}}
|
||||||
{{- end -}}
|
{{- end -}}
|
||||||
|
|
||||||
{{- range $items -}}
|
{{- if .Get "items" -}}
|
||||||
{{- if eq (trim . " ") "" -}}
|
{{- warnf "The 'items' parameter of the 'tabs' shortcode is deprecated. Please use 'name' on 'tab' instead." -}}
|
||||||
{{ errorf "tabs shortcode: empty item found in 'items' parameter" }}
|
|
||||||
|
{{- $items := split (.Get "items") "," -}}
|
||||||
|
|
||||||
|
{{- $temp := slice -}}
|
||||||
|
{{- range $i, $item := $items -}}
|
||||||
|
{{- $tab := index $tabs $i -}}
|
||||||
|
{{- $temp = $temp | append (merge $tab (dict "name" $item)) -}}
|
||||||
{{- end -}}
|
{{- end -}}
|
||||||
|
|
||||||
|
{{- $tabs = $temp -}}
|
||||||
{{- end -}}
|
{{- end -}}
|
||||||
|
|
||||||
<div class="hextra-scrollbar hx:overflow-x-auto hx:overflow-y-hidden hx:overscroll-x-contain">
|
{{- partial "shortcodes/tabs" (dict "tabs" $tabs "enableSync" $enableSync "id" .Ordinal) -}}
|
||||||
<div class="hx:mt-4 hx:flex hx:w-max hx:min-w-full hx:border-b hx:border-gray-200 hx:pb-px hx:dark:border-neutral-800"{{ if $enableSync }} data-tab-group="{{ delimit $items `,` }}"{{ end }}>
|
|
||||||
{{- range $i, $item := $items -}}
|
|
||||||
<button
|
|
||||||
class="hextra-tabs-toggle hx:cursor-pointer hx:data-[state=selected]:border-primary-500 hx:data-[state=selected]:text-primary-600 hx:data-[state=selected]:dark:border-primary-500 hx:data-[state=selected]:dark:text-primary-600 hx:mr-2 hx:rounded-t hx:p-2 hx:font-medium hx:leading-5 hx:transition-colors hx:-mb-0.5 hx:select-none hx:border-b-2 hx:border-transparent hx:text-gray-600 hx:hover:border-gray-200 hx:hover:text-black hx:dark:text-gray-200 hx:dark:hover:border-neutral-800 hx:dark:hover:text-white"
|
|
||||||
role="tab"
|
|
||||||
type="button"
|
|
||||||
aria-controls="tabs-panel-{{ $i }}"
|
|
||||||
{{- if eq $i $defaultIndex }} aria-selected="true" {{ end -}}
|
|
||||||
{{- if eq $i $defaultIndex }} tabindex="0" {{ end -}}
|
|
||||||
{{- if eq $i $defaultIndex }} data-state="selected"{{ end -}}
|
|
||||||
>
|
|
||||||
{{- $item -}}
|
|
||||||
</button>
|
|
||||||
{{- end -}}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
{{- .Inner -}}
|
|
||||||
</div>
|
|
||||||
{{- /* Drop trailing newlines */ -}}
|
|
||||||
|
Reference in New Issue
Block a user