mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
Fix editorial deck navigation (#2173)
This commit is contained in:
parent
716f06cb73
commit
d1eb8b7bef
4 changed files with 305 additions and 2 deletions
74
apps/web/tests/runtime/design-template-deck-nav.test.ts
Normal file
74
apps/web/tests/runtime/design-template-deck-nav.test.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { JSDOM, VirtualConsole } from 'jsdom';
|
||||
import type { DOMWindow } from 'jsdom';
|
||||
|
||||
const tasteEditorialExamplePath = fileURLToPath(
|
||||
new URL('../../../../design-templates/html-ppt-taste-editorial/example.html', import.meta.url),
|
||||
);
|
||||
|
||||
function setupTasteEditorialDeck() {
|
||||
const html = readFileSync(tasteEditorialExamplePath, 'utf8');
|
||||
const dom = new JSDOM(html, {
|
||||
pretendToBeVisual: true,
|
||||
runScripts: 'dangerously',
|
||||
url: 'https://example.test/taste-editorial.html',
|
||||
virtualConsole: new VirtualConsole(),
|
||||
});
|
||||
return dom;
|
||||
}
|
||||
|
||||
function activeSlideIndex(win: DOMWindow) {
|
||||
const slides = Array.from(win.document.querySelectorAll<HTMLElement>('.deck > .slide'));
|
||||
return slides.findIndex((slide) => slide.classList.contains('active'));
|
||||
}
|
||||
|
||||
function fireTouch(win: DOMWindow, startX: number, endX: number) {
|
||||
const start = new win.Event('touchstart', { bubbles: true, cancelable: true });
|
||||
Object.defineProperty(start, 'touches', {
|
||||
value: [{ clientX: startX, clientY: 120 }],
|
||||
});
|
||||
win.dispatchEvent(start);
|
||||
|
||||
const end = new win.Event('touchend', { bubbles: true, cancelable: true });
|
||||
Object.defineProperty(end, 'changedTouches', {
|
||||
value: [{ clientX: endX, clientY: 124 }],
|
||||
});
|
||||
win.dispatchEvent(end);
|
||||
}
|
||||
|
||||
describe('design template deck navigation', () => {
|
||||
it('wires the taste editorial example to the deck input contract', () => {
|
||||
const dom = setupTasteEditorialDeck();
|
||||
const { window: win } = dom;
|
||||
const dots = Array.from(win.document.querySelectorAll<HTMLButtonElement>('#deck-nav .dot'));
|
||||
|
||||
expect(win.document.querySelectorAll('.deck > .slide')).toHaveLength(10);
|
||||
expect(activeSlideIndex(win)).toBe(0);
|
||||
expect(dots).toHaveLength(10);
|
||||
expect(dots[0]?.classList.contains('active')).toBe(true);
|
||||
|
||||
win.dispatchEvent(new win.KeyboardEvent('keydown', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
key: 'ArrowRight',
|
||||
}));
|
||||
expect(activeSlideIndex(win)).toBe(1);
|
||||
|
||||
win.dispatchEvent(new win.WheelEvent('wheel', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
deltaY: 90,
|
||||
}));
|
||||
expect(activeSlideIndex(win)).toBe(2);
|
||||
|
||||
fireTouch(win, 500, 360);
|
||||
expect(activeSlideIndex(win)).toBe(3);
|
||||
|
||||
dots[6]?.dispatchEvent(new win.MouseEvent('click', { bubbles: true, cancelable: true }));
|
||||
expect(activeSlideIndex(win)).toBe(6);
|
||||
expect(dots[6]?.classList.contains('active')).toBe(true);
|
||||
expect(dots[6]?.getAttribute('aria-current')).toBe('true');
|
||||
});
|
||||
});
|
||||
|
|
@ -32,3 +32,31 @@ full split.
|
|||
gallery has something to preview.
|
||||
3. Optionally drop additional baked samples under `examples/<key>.html`
|
||||
to surface them as derived `<parent>:<key>` cards.
|
||||
|
||||
## Deck preview navigation contract
|
||||
|
||||
Any template with `od.mode: deck` must make its baked `example.html`
|
||||
usable inside the gallery iframe without relying on the host app to add
|
||||
navigation. Use a shared deck runtime where one is available; otherwise
|
||||
ship a tiny local runtime with the same minimum behavior.
|
||||
|
||||
- **Keyboard:** `ArrowRight` / `ArrowDown` / `PageDown` / `Space` move to
|
||||
the next slide; `ArrowLeft` / `ArrowUp` / `PageUp` move to the previous
|
||||
slide; `Home` and `End` jump to the first and last slide. Ignore events
|
||||
from inputs, selects, textareas, and editable regions.
|
||||
- **Wheel / trackpad:** accumulated `deltaX + deltaY` past a small threshold
|
||||
moves exactly one slide, then resets quickly so a single gesture does not
|
||||
overshoot.
|
||||
- **Touch:** a horizontal swipe of roughly 50px or more, greater than the
|
||||
vertical movement, moves previous / next.
|
||||
- **Dots:** render one clickable button per slide, update the active dot on
|
||||
every navigation path, and mark it with `aria-current="true"`.
|
||||
- **Active slide state:** keep the visible slide marked with
|
||||
`.slide.active`; adding `.is-active` as a compatibility alias is fine.
|
||||
Open Design's preview bridge reads this state for the host slide counter,
|
||||
so it must stay in sync with keyboard, wheel, touch, and dot navigation.
|
||||
- **Iframe safety:** focus the deck on load / pointer interaction so keyboard
|
||||
navigation works after the gallery preview appears. Avoid
|
||||
`scrollIntoView()` because it can move the parent page instead of the deck.
|
||||
- **Fallbacks:** no-script and print output should still expose every slide.
|
||||
Hide non-active slides only after the runtime has booted.
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ A 16:9 deck for the briefs that hate neon: investor updates, design reviews, int
|
|||
|
||||
## Source
|
||||
|
||||
Distilled from [Leonxlnx/taste-skill](https://github.com/Leonxlnx/taste-skill) — `skills/minimalist-skill/SKILL.md`. The deck system follows the existing project convention from `skills/html-ppt-pitch-deck/example.html` (each `.slide` is a `100vw × 100vh` section; opened directly, slides stack vertically). See `example.html` in this directory.
|
||||
Distilled from [Leonxlnx/taste-skill](https://github.com/Leonxlnx/taste-skill) — `skills/minimalist-skill/SKILL.md`. The deck system follows the project deck convention: each `.slide` is a `100vw × 100vh` section, the active slide carries `.active` / `.is-active`, and the baked example owns keyboard, wheel, touch, and dot navigation inside the gallery iframe. No-script and print fallbacks keep every slide visible. See `example.html` in this directory.
|
||||
|
||||
## Hard rules
|
||||
|
||||
|
|
@ -47,7 +47,8 @@ Distilled from [Leonxlnx/taste-skill](https://github.com/Leonxlnx/taste-skill)
|
|||
|
||||
## Motion
|
||||
|
||||
- Static-preview fallback: keep every slide visible (already wired by the deck base). When run as a real deck, fade-in at `400ms cubic-bezier(0.16, 1, 0.3, 1)` is plenty.
|
||||
- Runtime navigation: keyboard, wheel / trackpad, touch swipe, and dot buttons must all update the same active slide state. Keep the fade at roughly `400ms cubic-bezier(0.16, 1, 0.3, 1)`.
|
||||
- Static / print fallback: keep every slide visible when the runtime has not booted or the document is printed.
|
||||
- No translate, no blur, no auto-advance.
|
||||
|
||||
## Pre-flight
|
||||
|
|
@ -58,3 +59,4 @@ Distilled from [Leonxlnx/taste-skill](https://github.com/Leonxlnx/taste-skill)
|
|||
- [ ] Every slide has eyebrow + section number + page number
|
||||
- [ ] At least one hairline-grid table or comparison module
|
||||
- [ ] No drop shadows, no gradients, no emojis, no banned fonts
|
||||
- [ ] Keyboard, wheel / trackpad, touch swipe, and dot navigation all move one slide and keep the active dot / active slide in sync
|
||||
|
|
|
|||
|
|
@ -47,6 +47,75 @@
|
|||
page-break-after: always;
|
||||
}
|
||||
.slide + .slide { border-top: 1px solid var(--hairline); }
|
||||
body.deck-nav-ready { overflow: hidden; }
|
||||
body.deck-nav-ready .deck {
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
body.deck-nav-ready .slide {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 420ms var(--ease);
|
||||
}
|
||||
body.deck-nav-ready .slide.active {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
body.deck-nav-ready .slide + .slide { border-top: 0; }
|
||||
|
||||
.deck-progress {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
z-index: 40;
|
||||
background: var(--hairline-soft);
|
||||
}
|
||||
.deck-progress span {
|
||||
display: block;
|
||||
width: 0;
|
||||
height: 100%;
|
||||
background: var(--accent);
|
||||
transition: width 220ms var(--ease);
|
||||
}
|
||||
#deck-nav {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
bottom: 24px;
|
||||
transform: translateX(-50%);
|
||||
z-index: 40;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 11px;
|
||||
border: 1px solid rgba(26, 26, 25, 0.12);
|
||||
border-radius: 999px;
|
||||
background: rgba(251, 251, 250, 0.72);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
}
|
||||
#deck-nav .dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
padding: 0;
|
||||
background: rgba(26, 26, 25, 0.24);
|
||||
cursor: pointer;
|
||||
transition: width 220ms var(--ease), background 220ms var(--ease), transform 220ms var(--ease);
|
||||
}
|
||||
#deck-nav .dot:hover { transform: scale(1.2); background: rgba(26, 26, 25, 0.42); }
|
||||
#deck-nav .dot.active {
|
||||
width: 22px;
|
||||
background: var(--accent);
|
||||
}
|
||||
#deck-nav .dot:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
/* Slide chrome */
|
||||
.meta-row {
|
||||
|
|
@ -295,6 +364,16 @@
|
|||
/* Print / preview */
|
||||
@media print {
|
||||
.slide { height: auto; min-height: 100vh; page-break-after: always; }
|
||||
body.deck-nav-ready { overflow: visible; }
|
||||
body.deck-nav-ready .deck { height: auto; overflow: visible; }
|
||||
body.deck-nav-ready .slide {
|
||||
position: relative;
|
||||
inset: auto;
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.deck-progress,
|
||||
#deck-nav { display: none; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
|
@ -486,5 +565,125 @@
|
|||
</section>
|
||||
|
||||
</div>
|
||||
<div class="deck-progress" id="deck-progress" aria-hidden="true"><span></span></div>
|
||||
<nav id="deck-nav" aria-label="Slide navigation"></nav>
|
||||
<script>
|
||||
(function () {
|
||||
var deck = document.querySelector('.deck');
|
||||
if (!deck) return;
|
||||
|
||||
var slides = Array.prototype.filter.call(deck.children, function (el) {
|
||||
return el.classList && el.classList.contains('slide');
|
||||
});
|
||||
var nav = document.getElementById('deck-nav');
|
||||
var progress = document.querySelector('#deck-progress span');
|
||||
if (!slides.length || !nav || !progress) return;
|
||||
|
||||
var idx = 0;
|
||||
var wheelAcc = 0;
|
||||
var wheelTimer = null;
|
||||
var touchX = 0;
|
||||
var touchY = 0;
|
||||
|
||||
document.body.classList.add('deck-nav-ready');
|
||||
document.body.setAttribute('tabindex', '-1');
|
||||
document.body.style.outline = 'none';
|
||||
|
||||
slides.forEach(function (_, i) {
|
||||
var dot = document.createElement('button');
|
||||
dot.className = 'dot';
|
||||
dot.type = 'button';
|
||||
dot.setAttribute('aria-label', 'Go to slide ' + (i + 1));
|
||||
dot.addEventListener('click', function () { go(i); });
|
||||
nav.appendChild(dot);
|
||||
});
|
||||
|
||||
function clamp(n) {
|
||||
return Math.max(0, Math.min(slides.length - 1, n));
|
||||
}
|
||||
|
||||
function go(n) {
|
||||
idx = clamp(n);
|
||||
slides.forEach(function (slide, i) {
|
||||
var active = i === idx;
|
||||
slide.classList.toggle('active', active);
|
||||
slide.classList.toggle('is-active', active);
|
||||
slide.setAttribute('aria-hidden', active ? 'false' : 'true');
|
||||
});
|
||||
Array.prototype.forEach.call(nav.querySelectorAll('.dot'), function (dot, i) {
|
||||
var active = i === idx;
|
||||
dot.classList.toggle('active', active);
|
||||
if (active) dot.setAttribute('aria-current', 'true');
|
||||
else dot.removeAttribute('aria-current');
|
||||
});
|
||||
progress.style.width = (((idx + 1) / slides.length) * 100) + '%';
|
||||
}
|
||||
|
||||
function shouldIgnoreKeyTarget(target) {
|
||||
if (!target || !target.tagName) return false;
|
||||
var tag = target.tagName.toLowerCase();
|
||||
return tag === 'input' || tag === 'textarea' || tag === 'select' || target.isContentEditable;
|
||||
}
|
||||
|
||||
function onKey(e) {
|
||||
if (e.__tasteDeckHandled || shouldIgnoreKeyTarget(e.target)) return;
|
||||
var next = null;
|
||||
if (e.key === 'ArrowRight' || e.key === 'ArrowDown' || e.key === 'PageDown' || e.key === ' ') next = idx + 1;
|
||||
else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp' || e.key === 'PageUp') next = idx - 1;
|
||||
else if (e.key === 'Home') next = 0;
|
||||
else if (e.key === 'End') next = slides.length - 1;
|
||||
if (next === null) return;
|
||||
e.__tasteDeckHandled = true;
|
||||
e.preventDefault();
|
||||
go(next);
|
||||
}
|
||||
|
||||
function onWheel(e) {
|
||||
var delta = e.deltaY + e.deltaX;
|
||||
if (Math.abs(delta) < 1) return;
|
||||
e.preventDefault();
|
||||
wheelAcc += delta;
|
||||
if (Math.abs(wheelAcc) > 60) {
|
||||
go(idx + (wheelAcc > 0 ? 1 : -1));
|
||||
wheelAcc = 0;
|
||||
}
|
||||
clearTimeout(wheelTimer);
|
||||
wheelTimer = setTimeout(function () { wheelAcc = 0; }, 150);
|
||||
}
|
||||
|
||||
function onTouchStart(e) {
|
||||
if (!e.touches || !e.touches.length) return;
|
||||
touchX = e.touches[0].clientX;
|
||||
touchY = e.touches[0].clientY;
|
||||
}
|
||||
|
||||
function onTouchEnd(e) {
|
||||
if (!e.changedTouches || !e.changedTouches.length) return;
|
||||
var dx = e.changedTouches[0].clientX - touchX;
|
||||
var dy = e.changedTouches[0].clientY - touchY;
|
||||
if (Math.abs(dx) > 50 && Math.abs(dx) > Math.abs(dy)) {
|
||||
go(idx + (dx < 0 ? 1 : -1));
|
||||
}
|
||||
}
|
||||
|
||||
function focusDeck() {
|
||||
try {
|
||||
window.focus();
|
||||
document.body.focus({ preventScroll: true });
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', onKey, true);
|
||||
document.addEventListener('keydown', onKey, true);
|
||||
window.addEventListener('wheel', onWheel, { passive: false });
|
||||
window.addEventListener('touchstart', onTouchStart, { passive: true });
|
||||
window.addEventListener('touchend', onTouchEnd, { passive: true });
|
||||
document.addEventListener('mousedown', focusDeck);
|
||||
window.addEventListener('load', focusDeck);
|
||||
|
||||
go(0);
|
||||
focusDeck();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
Loading…
Reference in a new issue