parent
f4a94397ab
commit
6faf00b375
@ -0,0 +1,195 @@
|
||||
import { expect, it, vi } from 'vitest';
|
||||
import { page } from 'vitest/browser';
|
||||
import { visit } from '../../../test/sticky/sticky-audit-view';
|
||||
|
||||
const near = (actual: number, expected: number) =>
|
||||
expect(Math.abs(actual - expected)).toBeLessThan(2);
|
||||
const waitStuck = (node: HTMLElement, stuck: boolean) =>
|
||||
vi.waitFor(() => expect(node.hasAttribute('data-stuck')).toBe(stuck), { timeout: 2000 });
|
||||
|
||||
it('native default Sticky has one box and an aria-hidden preceding sentinel', () =>
|
||||
visit(async (view) => {
|
||||
const root = view.node();
|
||||
const sentinel = view.sentinel();
|
||||
expect(root.tagName).toBe('DIV');
|
||||
expect(root.hasAttribute('data-sticky')).toBe(true);
|
||||
expect(root.getAttribute('data-edge')).toBe('top');
|
||||
expect(root.hasAttribute('data-stuck')).toBe(false);
|
||||
expect(sentinel.getAttribute('aria-hidden')).toBe('true');
|
||||
expect(sentinel.getAttribute('data-edge')).toBe('top');
|
||||
expect(sentinel.nextElementSibling).toBe(root);
|
||||
expect(getComputedStyle(root).position).toBe('sticky');
|
||||
await view.capture('default');
|
||||
}));
|
||||
|
||||
it('native sentinel keeps the top box in normal flow before pinning', () =>
|
||||
visit(async (view) => {
|
||||
near(view.node().getBoundingClientRect().top, view.section().getBoundingClientRect().top);
|
||||
near(view.sentinel().getBoundingClientRect().top, view.node().getBoundingClientRect().top);
|
||||
await view.capture('normal-flow');
|
||||
}));
|
||||
|
||||
it('native top edge pins at the offset and marks stuck after scroll', () =>
|
||||
visit(
|
||||
async (view) => {
|
||||
await view.scrollTo(100);
|
||||
await waitStuck(view.node(), true);
|
||||
near(
|
||||
view.node().getBoundingClientRect().top,
|
||||
view.host().getBoundingClientRect().top + 2 + 12
|
||||
);
|
||||
expect(getComputedStyle(view.node()).top).toBe('12px');
|
||||
await view.capture('top-stuck');
|
||||
},
|
||||
{ offset: 12 }
|
||||
));
|
||||
|
||||
it('native top edge clears the marker when scrolled back', () =>
|
||||
visit(
|
||||
async (view) => {
|
||||
await view.scrollTo(100);
|
||||
await waitStuck(view.node(), true);
|
||||
await view.scrollTo(0);
|
||||
await waitStuck(view.node(), false);
|
||||
near(view.node().getBoundingClientRect().top, view.section().getBoundingClientRect().top);
|
||||
await view.capture('top-return');
|
||||
},
|
||||
{ offset: 12 }
|
||||
));
|
||||
|
||||
it('native larger top offset changes both CSS inset and pin line', () =>
|
||||
visit(
|
||||
async (view) => {
|
||||
await view.scrollTo(100);
|
||||
await waitStuck(view.node(), true);
|
||||
near(
|
||||
view.node().getBoundingClientRect().top,
|
||||
view.host().getBoundingClientRect().top + 2 + 24
|
||||
);
|
||||
expect(view.node().style.getPropertyValue('--_sticky-offset')).toBe('24px');
|
||||
await view.capture('top-offset-24');
|
||||
},
|
||||
{ offset: 24 }
|
||||
));
|
||||
|
||||
it('native bottom edge puts the sentinel after the box', () =>
|
||||
visit(
|
||||
async (view) => {
|
||||
const root = view.node();
|
||||
const sentinel = view.sentinel();
|
||||
expect(root.getAttribute('data-edge')).toBe('bottom');
|
||||
expect(sentinel.getAttribute('data-edge')).toBe('bottom');
|
||||
expect(root.nextElementSibling).toBe(sentinel);
|
||||
expect(getComputedStyle(root).bottom).toBe('12px');
|
||||
await view.capture('bottom-structure');
|
||||
},
|
||||
{ edge: 'bottom', offset: 12, innerBeforeHeight: 500 }
|
||||
));
|
||||
|
||||
it('native bottom edge pins above the host bottom with its offset', () =>
|
||||
visit(
|
||||
async (view) => {
|
||||
const root = view.node();
|
||||
near(
|
||||
root.getBoundingClientRect().bottom,
|
||||
view.host().getBoundingClientRect().bottom - 2 - 12
|
||||
);
|
||||
await waitStuck(root, true);
|
||||
await view.capture('bottom-stuck');
|
||||
},
|
||||
{ edge: 'bottom', offset: 12, innerBeforeHeight: 500 }
|
||||
));
|
||||
|
||||
it('native disabled observation keeps the marker absent while CSS can pin', () =>
|
||||
visit(
|
||||
async (view) => {
|
||||
await view.scrollTo(100);
|
||||
near(
|
||||
view.node().getBoundingClientRect().top,
|
||||
view.host().getBoundingClientRect().top + 2 + 12
|
||||
);
|
||||
expect(view.node().hasAttribute('data-stuck')).toBe(false);
|
||||
await view.capture('disabled');
|
||||
},
|
||||
{ offset: 12, disabled: true }
|
||||
));
|
||||
|
||||
it('native reactive disabled clears an existing stuck marker', () =>
|
||||
visit(
|
||||
async (view) => {
|
||||
await view.scrollTo(100);
|
||||
await waitStuck(view.node(), true);
|
||||
view.controls().disabled(true);
|
||||
await view.ready();
|
||||
await waitStuck(view.node(), false);
|
||||
await view.capture('reactive-disabled');
|
||||
},
|
||||
{ offset: 12 }
|
||||
));
|
||||
|
||||
it('native reactive offset updates the same root and pin position', () =>
|
||||
visit(
|
||||
async (view) => {
|
||||
await view.scrollTo(100);
|
||||
const root = view.node();
|
||||
view.controls().offset(24);
|
||||
await view.ready();
|
||||
expect(view.node()).toBe(root);
|
||||
near(root.getBoundingClientRect().top, view.host().getBoundingClientRect().top + 2 + 24);
|
||||
expect(root.style.getPropertyValue('--_sticky-offset')).toBe('24px');
|
||||
await view.capture('reactive-offset');
|
||||
},
|
||||
{ offset: 12 }
|
||||
));
|
||||
|
||||
it('native reactive edge moves the sentinel after the same box', () =>
|
||||
visit(async (view) => {
|
||||
const root = view.node();
|
||||
view.controls().edge('bottom');
|
||||
await view.ready();
|
||||
expect(view.node()).toBe(root);
|
||||
expect(root.getAttribute('data-edge')).toBe('bottom');
|
||||
expect(root.nextElementSibling).toBe(view.sentinel());
|
||||
await view.capture('reactive-edge');
|
||||
}));
|
||||
|
||||
it('native consumer style and global attributes reach the box', () =>
|
||||
visit(
|
||||
async (view) => {
|
||||
const root = view.node();
|
||||
expect(root.classList.contains('audit-sticky')).toBe(true);
|
||||
expect(root.getAttribute('data-probe')).toBe('sticky');
|
||||
expect(root.getAttribute('title')).toBe('Audit Sticky');
|
||||
expect(getComputedStyle(root).color).toBe('rgb(20, 40, 60)');
|
||||
await view.capture('style');
|
||||
},
|
||||
{ style: 'color:rgb(20,40,60)' }
|
||||
));
|
||||
|
||||
it('native viewport root pins to the document viewport', () =>
|
||||
visit(
|
||||
async (view) => {
|
||||
window.scrollTo(0, 150);
|
||||
await view.ready();
|
||||
expect(window.scrollY).toBeGreaterThan(0);
|
||||
near(view.node().getBoundingClientRect().top, 12);
|
||||
await waitStuck(view.node(), true);
|
||||
await view.capture('viewport-stuck');
|
||||
},
|
||||
{ rootMode: 'viewport', offset: 12 }
|
||||
));
|
||||
|
||||
it('native 350px viewport fits the scroll host without horizontal overflow', () =>
|
||||
visit(
|
||||
async (view) => {
|
||||
await page.viewport(350, 900);
|
||||
await view.ready();
|
||||
const box = view.host().getBoundingClientRect();
|
||||
expect(box.left).toBeGreaterThanOrEqual(0);
|
||||
expect(box.right).toBeLessThanOrEqual(350);
|
||||
await view.scrollTo(100);
|
||||
await waitStuck(view.node(), true);
|
||||
await view.capture('350');
|
||||
},
|
||||
{ offset: 12 }
|
||||
));
|
||||
@ -0,0 +1,91 @@
|
||||
<script module lang="ts">
|
||||
import type { StickyProps } from '$uix/eidos/components/sticky';
|
||||
export type Config = {
|
||||
edge?: StickyProps['edge'];
|
||||
offset?: StickyProps['offset'];
|
||||
disabled?: StickyProps['disabled'];
|
||||
sectionHeight?: number;
|
||||
beforeHeight?: number;
|
||||
innerBeforeHeight?: number;
|
||||
rootMode?: 'container' | 'viewport';
|
||||
style?: string;
|
||||
};
|
||||
export type Controls = {
|
||||
edge: (next: StickyProps['edge']) => void;
|
||||
offset: (next: StickyProps['offset']) => void;
|
||||
disabled: (next: StickyProps['disabled']) => void;
|
||||
};
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { setActiveUix, type ActiveUix } from '$active-uix';
|
||||
import { Soma } from '$soma/core/soma.svelte';
|
||||
import Sticky from '$uix/eidos/components/sticky';
|
||||
let {
|
||||
uix,
|
||||
initial = {},
|
||||
onControls
|
||||
}: {
|
||||
uix: ActiveUix;
|
||||
initial?: Config;
|
||||
onControls: (controls: Controls) => void;
|
||||
} = $props();
|
||||
// svelte-ignore state_referenced_locally
|
||||
setActiveUix(uix);
|
||||
Soma.create();
|
||||
let host = $state<HTMLDivElement | null>(null);
|
||||
// svelte-ignore state_referenced_locally
|
||||
let edge = $state(initial.edge ?? 'top');
|
||||
// svelte-ignore state_referenced_locally
|
||||
let offset = $state(initial.offset ?? 0);
|
||||
// svelte-ignore state_referenced_locally
|
||||
let disabled = $state(initial.disabled ?? false);
|
||||
onMount(() =>
|
||||
onControls({
|
||||
edge: (next) => (edge = next ?? 'top'),
|
||||
offset: (next) => (offset = next ?? 0),
|
||||
disabled: (next) => (disabled = next ?? false)
|
||||
})
|
||||
);
|
||||
</script>
|
||||
|
||||
<div id="audit-frame" style="padding:24px;font:14px Arial,sans-serif">
|
||||
<div
|
||||
id="audit-host"
|
||||
bind:this={host}
|
||||
style={`height:200px;width:400px;max-width:100%;box-sizing:border-box;overflow:${initial.rootMode === 'viewport' ? 'visible' : 'auto'};position:relative;border:2px solid rgb(66,90,128);background:rgb(232,240,252)`}
|
||||
>
|
||||
<div id="audit-before" style={`height:${initial.beforeHeight ?? 60}px`}></div>
|
||||
<section
|
||||
id="audit-section"
|
||||
style={`height:${initial.sectionHeight ?? 600}px;background:rgb(213,227,244)`}
|
||||
>
|
||||
<div id="audit-inner-before" style={`height:${initial.innerBeforeHeight ?? 0}px`}></div>
|
||||
<Sticky
|
||||
id="audit-sticky"
|
||||
class="audit-sticky"
|
||||
data-probe="sticky"
|
||||
title="Audit Sticky"
|
||||
{edge}
|
||||
{offset}
|
||||
root={initial.rootMode === 'viewport' ? null : host}
|
||||
{disabled}
|
||||
style={initial.style}
|
||||
>
|
||||
Sticky header
|
||||
</Sticky>
|
||||
</section>
|
||||
<div id="audit-after" style="height:700px;background:rgb(244,248,253)">After section</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
:global(.audit-sticky) {
|
||||
height: 40px;
|
||||
background: rgb(107, 156, 206);
|
||||
color: white;
|
||||
padding: 8px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,65 @@
|
||||
import { mount, unmount, flushSync } from 'svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import { createActiveUix } from '$active-uix';
|
||||
import '$uix/eidos/index.css';
|
||||
import Harness, { type Config, type Controls } from './sticky-audit-harness.svelte';
|
||||
|
||||
export function renderSticky(initial: Config = {}) {
|
||||
const uix = createActiveUix({
|
||||
langs: { schema: {}, defaultLocale: 'en' },
|
||||
prefs: { storage: false },
|
||||
projectPrefs: false
|
||||
});
|
||||
const target = document.createElement('div');
|
||||
document.body.append(target);
|
||||
let controls!: Controls;
|
||||
const component = mount(Harness, {
|
||||
target,
|
||||
props: { uix, initial, onControls: (value) => (controls = value) }
|
||||
});
|
||||
flushSync();
|
||||
const node = () => target.querySelector<HTMLElement>('#audit-sticky')!;
|
||||
const sentinel = () => target.querySelector<HTMLElement>('[data-sticky-sentinel]')!;
|
||||
const host = () => target.querySelector<HTMLElement>('#audit-host')!;
|
||||
const section = () => target.querySelector<HTMLElement>('#audit-section')!;
|
||||
const ready = async () =>
|
||||
new Promise<void>((resolve) =>
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => resolve()))
|
||||
);
|
||||
return {
|
||||
target,
|
||||
node,
|
||||
sentinel,
|
||||
host,
|
||||
section,
|
||||
controls: () => controls,
|
||||
ready,
|
||||
async scrollTo(y: number) {
|
||||
host().scrollTop = y;
|
||||
await ready();
|
||||
},
|
||||
async capture(name: string) {
|
||||
if (import.meta.env.VITE_UIX_AUDIT_SCREENSHOTS === '1') {
|
||||
await ready();
|
||||
await page.screenshot({ path: 'tmp/sticky-' + name + '.png' });
|
||||
}
|
||||
},
|
||||
async dispose() {
|
||||
await unmount(component);
|
||||
uix.dispose();
|
||||
target.remove();
|
||||
window.scrollTo(0, 0);
|
||||
}
|
||||
};
|
||||
}
|
||||
export type View = ReturnType<typeof renderSticky>;
|
||||
export async function visit(fn: (view: View) => Promise<void>, initial: Config = {}) {
|
||||
await page.viewport(1100, 900);
|
||||
const view = renderSticky(initial);
|
||||
try {
|
||||
await view.ready();
|
||||
await fn(view);
|
||||
} finally {
|
||||
await view.dispose();
|
||||
}
|
||||
}
|
||||
Loading…
Reference in new issue