Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import {busk} from '@logfox/busker';
import '@logfox/busker/busker.css';

busk(document.querySelector('.app'), {
scene: 'home',
initialScene: 'home',
steps: [
{click: '[data-nav-item="alerts"]', wait: 900},
{click: '[data-row="p0"]', wait: 1500},
Expand Down
39 changes: 35 additions & 4 deletions busker.css
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
--busker-cursor-size: 0.95rem;
--busker-cursor-fill: rgb(0 0 0 / 0.42);
--busker-cursor-edge: #fff;
--busker-cursor-shadow: rgb(0 0 0 / 0.3);
--busker-scene-ms: 0.4s;

/* The cursor is positioned against the root. */
position: relative;
Expand All @@ -27,7 +29,7 @@
background: var(--busker-cursor-fill);
border: 2px solid var(--busker-cursor-edge);
border-radius: 999px;
box-shadow: 0 1px 6px rgb(0 0 0 / 0.3);
box-shadow: 0 1px 6px var(--busker-cursor-shadow);
display: block;
height: var(--busker-cursor-size);
opacity: 0;
Expand Down Expand Up @@ -103,13 +105,37 @@
display: none;
}

/* Scenes: one at a time. */
/* Scenes are a stack, so the one going out can fade under the one coming in
rather than popping. They are out of flow, so whatever element holds them
needs a height of its own — from a parent, a grid track, or its own rule. */
.busker :has(> [data-scene]) {
position: relative;
}

.busker [data-scene] {
display: none;
inset: 0;
opacity: 0;
pointer-events: none;
position: absolute;
translate: 0 4px;
visibility: hidden;

/* Visibility waits out the fade on the way out and switches at once on the
way in, so a scene nobody can see is not read out or tabbed into. */
transition:
opacity var(--busker-scene-ms) ease,
translate var(--busker-scene-ms) ease,
visibility 0s linear var(--busker-scene-ms);
}

.busker [data-scene].is-active {
display: block;
opacity: 1;
pointer-events: auto;
translate: none;
visibility: visible;
transition:
opacity var(--busker-scene-ms) ease,
translate var(--busker-scene-ms) ease;
}

@media (prefers-reduced-motion: reduce) {
Expand All @@ -120,4 +146,9 @@
.busker .is-hint {
animation: none;
}

.busker [data-scene],
.busker [data-scene].is-active {
transition: none;
}
}
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
"publishConfig": {
"access": "public"
},
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
Expand Down Expand Up @@ -52,7 +53,7 @@
"build": "tsc",
"lint": "eslint . --fix",
"lint:check": "eslint .",
"test": "c8 kizu -f 'src/**/*.spec.ts' && c8 report -r text -r html",
"test": "NODE_OPTIONS='--import tsx' c8 kizu -f 'src/**/*.spec.ts' && c8 report -r text -r html",
"deadcode:check": "knip",
"sync:fonts": "node scripts/sync-fonts.mjs",
"astro:sync": "astro sync",
Expand All @@ -71,6 +72,7 @@
"happy-dom": "^20.13.2",
"kizu": "^5.1.1",
"knip": "^6.34.0",
"tsx": "^4.23.13",
"typescript": "^6.0.3",
"typescript-eslint": "^8.69.0"
}
Expand Down
56 changes: 50 additions & 6 deletions src/busk.spec.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import {test} from 'kizu';
import {GlobalRegistrator} from '@happy-dom/global-registrator';
import {busk} from './busk';
import type {Busker, Routine} from './types';
import {busk} from './busk.ts';
import type {Busker, Routine} from './types.ts';

GlobalRegistrator.register({url: 'https://busker.test', width: 1024, height: 768});

Expand Down Expand Up @@ -34,7 +34,10 @@ class FakeObserver implements IntersectionObserver {
readonly rootMargin = '';
readonly thresholds: readonly number[] = [];

constructor(private readonly callback: IntersectionObserverCallback) {
private readonly callback: IntersectionObserverCallback;

constructor(callback: IntersectionObserverCallback) {
this.callback = callback;
observers.push(this);
}

Expand All @@ -53,8 +56,8 @@ class FakeObserver implements IntersectionObserver {

/**
* happy-dom does no layout, so every rect is zero and the show would think it
* is off screen. Give the root a size; nothing here depends on where the
* cursor lands (that is covered in timeline.spec.ts).
* is off screen. Give the root a size, and give everything inside it a box
* that goes away when its scene is hidden, the way a real one does.
*/
function stage(routine: Routine): Stage {
document.body.innerHTML = `<div id="root">${MOCK}</div>`;
Expand All @@ -64,6 +67,15 @@ function stage(routine: Routine): Stage {
if (!root) throw new Error('no root');

root.getBoundingClientRect = (): DOMRect => new DOMRect(0, 0, 800, 600);
root.querySelectorAll('*').forEach((el) => {
el.getBoundingClientRect = (): DOMRect => {
const scene = el.closest('[data-scene]');

return scene && !scene.classList.contains('is-active')
? new DOMRect(0, 0, 0, 0)
: new DOMRect(100, 50, 80, 20);
};
});

observers.length = 0;

Expand Down Expand Up @@ -94,7 +106,7 @@ function stage(routine: Routine): Stage {
}

const routine: Routine = {
scene: 'home',
initialScene: 'home',
steps: [
{click: '[data-nav-item="alerts"]', moveFor: 100, dwell: 0},
{click: '[data-row="p0"]', wait: 100, moveFor: 100, dwell: 0},
Expand Down Expand Up @@ -149,6 +161,38 @@ test('a routine that ends on a click still lands it', (assert) => {

});

test('the cursor holds its place when its own click takes the target away', (assert) => {

const {root, startShow, tick} = stage({
initialScene: 'list',
steps: [{click: '[data-row="p0"]', moveFor: 100, dwell: 0}],
routes: [{click: '[data-row="p0"]', scene: 'home'}],
});

const cursor = root.querySelector<HTMLElement>('[data-cursor]');

// Arrived on the row and pressing it.
tick(0);
startShow();
tick(100);

const onTheRow = cursor?.style.left;

assert.equal(cursor?.classList.contains('is-pressing'), true);

// The click has landed and taken the row out of layout with it. The ring
// outlives the press on purpose, so on every frame that is left it has to
// keep running where the row was — not wherever an unresolvable target
// works out to.
tick(210);
tick(16);

assert.equal(root.querySelector('[data-scene="home"]')?.classList.contains('is-active'), true);
assert.equal(cursor?.classList.contains('is-ringing'), true);
assert.equal(cursor?.style.left, onTheRow);

});

test('the show does not mistake its own click for a visitor taking over', (assert) => {

const {root, startShow, tick} = stage(routine);
Expand Down
30 changes: 19 additions & 11 deletions src/busk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ import {
moveIndexAt,
positionAt,
typedText,
} from './timeline';
import type {Busker, Move, Point, Routine} from './types';
} from './timeline.ts';
import type {Busker, Move, Point, Routine} from './types.ts';

const DEFAULT_START: Point = [0.5, 0.5];
/** IntersectionObserver ratios are floating point; 1 is rarely exactly 1. */
Expand Down Expand Up @@ -48,10 +48,10 @@ export function busk(root: HTMLElement, routine: Routine): Busker {
navItems.set(el.dataset.navItem as string, el);
});

// Script mode presses for real; timeline mode only animates the press.
// A script presses for real; a hand-timed routine only animates the press.
const script = routine.steps ? compile(routine.steps) : null;
const moves = script?.moves ?? routine.moves ?? [];
const duration = routine.duration ?? script?.duration ?? 0;
const duration = script?.duration ?? routine.duration ?? 0;
const start = routine.start ?? DEFAULT_START;
const routes = routine.routes ?? [];
const visibility = routine.visibility ?? 1;
Expand All @@ -68,21 +68,29 @@ export function busk(root: HTMLElement, routine: Routine): Busker {
const typings = found(routine.typing);
const countdowns = found(routine.countdowns);

/** Where each selector was last seen, in case it stops being anywhere. */
const lastSeen = new Map<string, Point>();

/** Where a move target sits, in px relative to the root's top-left. */
function resolve(target: string | Point): Point | null {
if (Array.isArray(target)) return [target[0] * root.clientWidth, target[1] * root.clientHeight];

const el = root.querySelector(target);
const rect = root.querySelector(target)?.getBoundingClientRect();

if (!el) return null;
// A press takes its own target away whenever the click changes the
// scene, and the ring outlives the press. With no box left to aim at,
// stay where the target was rather than sliding off to the corner.
if (!rect || (rect.width === 0 && rect.height === 0)) return lastSeen.get(target) ?? null;

const rootRect = root.getBoundingClientRect();
const rect = el.getBoundingClientRect();

return [
const at: Point = [
rect.left - rootRect.left + rect.width / 2,
rect.top - rootRect.top + rect.height / 2,
];

lastSeen.set(target, at);

return at;
}

let elapsed = 0;
Expand Down Expand Up @@ -213,7 +221,7 @@ export function busk(root: HTMLElement, routine: Routine): Busker {
// drop the start of the routine or fire a burst of catch-up clicks.
if (next >= duration) {
pressed.clear();
activate(routine.scene ?? null);
activate(routine.initialScene ?? null);
elapsed = 0;
} else {
elapsed = next;
Expand Down Expand Up @@ -307,7 +315,7 @@ export function busk(root: HTMLElement, routine: Routine): Busker {
root.addEventListener('click', onClick);
}

activate(routine.scene ?? null);
activate(routine.initialScene ?? null);

const observer = reducedMotion
? undefined
Expand Down
2 changes: 1 addition & 1 deletion src/components/codeBlockTitles.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
isFilePathTitle,
kindFromTitle,
tryCopyText,
} from './codeBlockTitles';
} from './codeBlockTitles.ts';

test('copyButtonContent idle and copied labels', (assert) => {
assert.equal(copyButtonContent(false), {label: 'Copy', state: 'idle'});
Expand Down
2 changes: 1 addition & 1 deletion src/components/docsSearchQuery.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import {test} from 'kizu';
import {DOCS_SEARCH_MIN_QUERY_LENGTH, DOCS_SEARCH_PAGE_SIZE, isDocsSearchQueryReady} from './docsSearchQuery.js';
import {DOCS_SEARCH_MIN_QUERY_LENGTH, DOCS_SEARCH_PAGE_SIZE, isDocsSearchQueryReady} from './docsSearchQuery.ts';

test('isDocsSearchQueryReady requires at least DOCS_SEARCH_MIN_QUERY_LENGTH characters', (assert) => {

Expand Down
2 changes: 1 addition & 1 deletion src/components/isUsefulPagefindMatch.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import {test} from 'kizu';
import {isUsefulMark, isUsefulPagefindMatch} from './isUsefulPagefindMatch.js';
import {isUsefulMark, isUsefulPagefindMatch} from './isUsefulPagefindMatch.ts';

test('rejects inverted short-prefix garbage (adsf → a, asdfasdf → as)', (assert) => {
assert.equal(isUsefulMark('adsf', 'a'), false);
Expand Down
2 changes: 1 addition & 1 deletion src/components/pagefindMount.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import {test} from 'kizu';
import {bindPagefindMount, type PagefindMountEl} from './pagefindMount.js';
import {bindPagefindMount, type PagefindMountEl} from './pagefindMount.ts';

function hostWith(mount: PagefindMountEl | null): {querySelector(selector: string): PagefindMountEl | null} {
return {
Expand Down
2 changes: 1 addition & 1 deletion src/components/searchClearRefocus.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import {test} from 'kizu';
import {bindSearchClearRefocus} from './searchClearRefocus.js';
import {bindSearchClearRefocus} from './searchClearRefocus.ts';

function stubRoot(input: StubInput, clear: StubClear): Element {
return {
Expand Down
2 changes: 1 addition & 1 deletion src/components/searchDialog.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {
shouldCloseOnOutsideClick,
shouldCloseSearchOnEscape,
shouldHandleGlobalSlash,
} from './searchDialog.js';
} from './searchDialog.ts';

test('shouldHandleGlobalSlash opens only when idle and not typing in a field', (assert) => {
const base = {
Expand Down
2 changes: 1 addition & 1 deletion src/components/searchIdleState.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import {test} from 'kizu';
import {isSearchIdle} from './searchIdleState.js';
import {isSearchIdle} from './searchIdleState.ts';

test('idle when search is ready and query is empty or shorter than 3 characters', (assert) => {
assert.equal(isSearchIdle(true, true, ''), true);
Expand Down
2 changes: 1 addition & 1 deletion src/components/searchIdleState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
* skeleton before Pagefind mounts. Queries shorter than 3 characters stay
* idle — Pagefind does not search until then.
*/
import {DOCS_SEARCH_MIN_QUERY_LENGTH} from './docsSearchQuery.js';
import {DOCS_SEARCH_MIN_QUERY_LENGTH} from './docsSearchQuery.ts';

export function isSearchIdle(
searchReady: boolean,
Expand Down
2 changes: 1 addition & 1 deletion src/components/searchKeyboardNav.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import {
scrollSearchSelectionIntoView,
shouldHandleSearchListKeyboard,
type KeyboardNavState,
} from './searchKeyboardNav.js';
} from './searchKeyboardNav.ts';

test('resetSearchNavSession clears highlight and scroll for a new dialog open', (assert) => {
const selected = stubEl();
Expand Down
2 changes: 1 addition & 1 deletion src/components/searchPendingDelay.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import {
INITIAL_SEARCH_PENDING_DELAY_STATE,
resolveSearchPendingDelayState,
shouldShowSearchPending,
} from './searchPendingDelay.js';
} from './searchPendingDelay.ts';

test('shouldShowSearchPending only while searching with no settled results', (assert) => {
assert.equal(shouldShowSearchPending('searching', false, false), true);
Expand Down
2 changes: 1 addition & 1 deletion src/components/searchStaleResults.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {
resolveStaleResultsHold,
shouldDiscardStaleResults,
shouldShowStaleResults,
} from './searchStaleResults.js';
} from './searchStaleResults.ts';

test('shouldShowStaleResults only while holding a snapshot and live results are gone', (assert) => {
assert.equal(shouldShowStaleResults(true, false, true, true, false, false), true);
Expand Down
2 changes: 1 addition & 1 deletion src/components/wrapPagefindSearch.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import {test} from 'kizu';
import {wrapPagefindSearch} from './wrapPagefindSearch.js';
import {wrapPagefindSearch} from './wrapPagefindSearch.ts';

test('wrapPagefindSearch drops inverted short-prefix hits', async (assert) => {
const rawSearch = async (_term: string): Promise<{
Expand Down
4 changes: 2 additions & 2 deletions src/components/wrapPagefindSearch.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import {isUsefulPagefindMatch, type PagefindMatchData} from './isUsefulPagefindMatch.js';
import {isDocsSearchQueryReady} from './docsSearchQuery.js';
import {isUsefulPagefindMatch, type PagefindMatchData} from './isUsefulPagefindMatch.ts';
import {isDocsSearchQueryReady} from './docsSearchQuery.ts';

type PagefindSearchResult = {
data: () => Promise<PagefindMatchData>;
Expand Down
Loading
Loading