feat: add slider preview thumbnails (#935)

This commit is contained in:
Sam Potts
2026-03-14 08:30:51 +11:00
committed by GitHub
parent dc12cc2162
commit e3f438e9f4
51 changed files with 551 additions and 58 deletions
+5 -1
View File
@@ -155,7 +155,11 @@ export function createSlider(options: SliderOptions): SliderApi {
options.onValueChange?.(percent);
// Focus the thumb for keyboard follow-up and screen reader tracking.
options.getThumbElement?.()?.focus();
options.getThumbElement?.()?.focus({
preventScroll: true,
// @ts-expect-error -- focusVisible is not yet in TypeScript's lib.dom typings.
focusVisible: false,
});
},
onPointerMove(event) {
@@ -308,6 +308,104 @@ describe('createThumbnail', () => {
handle.destroy();
});
it('React lifecycle: updateSrc before img available, then connect after mount', () => {
let img: HTMLImageElement | null = null;
const onStateChange = vi.fn();
const handle = createThumbnail(
createOptions({
getImg: () => img,
onStateChange,
})
);
// 1. Render phase: updateSrc called but img ref is null (not mounted yet).
handle.updateSrc('sprite.jpg');
expect(handle.loading).toBe(true);
// 2. Commit phase: img becomes available (React sets ref) but image is still loading.
img = document.createElement('img');
// Simulate a loading image: in a real browser, an img with src set is !complete
// while the network request is in flight.
Object.defineProperty(img, 'complete', { value: false, configurable: true });
// 3. useEffect: connect() binds events and checks img.complete.
handle.connect();
// Image is not complete, so loading should remain true.
expect(handle.loading).toBe(true);
// 4. Image loads — event listener should catch it.
Object.defineProperty(img, 'naturalWidth', { value: 2560, configurable: true });
Object.defineProperty(img, 'naturalHeight', { value: 1600, configurable: true });
img.dispatchEvent(new Event('load'));
expect(handle.loading).toBe(false);
expect(handle.naturalWidth).toBe(2560);
expect(onStateChange).toHaveBeenCalled();
handle.destroy();
});
it('React lifecycle: handles already-loaded img when ref was null during updateSrc', () => {
let img: HTMLImageElement | null = null;
const onStateChange = vi.fn();
const handle = createThumbnail(
createOptions({
getImg: () => img,
onStateChange,
})
);
// 1. Render phase: updateSrc called but img ref is null.
handle.updateSrc('sprite.jpg');
expect(handle.loading).toBe(true);
// 2. Commit phase: img becomes available and is already cached/complete.
img = createMockImg();
Object.defineProperty(img, 'complete', { value: true, configurable: true });
// 3. useEffect: connect() should detect the already-loaded image.
handle.connect();
expect(handle.loading).toBe(false);
expect(handle.naturalWidth).toBe(2560);
expect(onStateChange).toHaveBeenCalled();
handle.destroy();
});
it('React lifecycle: handles errored img when ref was null during updateSrc', () => {
let img: HTMLImageElement | null = null;
const onStateChange = vi.fn();
const handle = createThumbnail(
createOptions({
getImg: () => img,
onStateChange,
})
);
// 1. Render phase: updateSrc called but img ref is null.
handle.updateSrc('bad.jpg');
expect(handle.loading).toBe(true);
// 2. Commit phase: img becomes available but image errored (complete but no dimensions).
img = document.createElement('img');
Object.defineProperty(img, 'complete', { value: true, configurable: true });
// naturalWidth defaults to 0 in jsdom — simulates an errored image.
// 3. useEffect: connect() should detect the errored image.
handle.connect();
expect(handle.loading).toBe(false);
expect(handle.error).toBe(true);
expect(onStateChange).toHaveBeenCalled();
handle.destroy();
});
});
describe('destroy', () => {
+13 -7
View File
@@ -108,15 +108,21 @@ export function createThumbnail(options: CreateThumbnailOptions): ThumbnailApi {
function connect(): void {
ensureBindings();
// Handle the case where the img already loaded before listeners were bound
// (e.g., cached image in React where mount happens before useEffect).
// Handle the case where the img already loaded or errored before listeners
// were bound (e.g., cached image in React where mount happens before useEffect).
const img = getImg();
if (img?.complete && img.naturalWidth > 0 && lastSrc) {
naturalWidth = img.naturalWidth;
naturalHeight = img.naturalHeight;
loading = false;
error = false;
if (img?.complete && lastSrc) {
if (img.naturalWidth > 0) {
naturalWidth = img.naturalWidth;
naturalHeight = img.naturalHeight;
loading = false;
error = false;
} else {
loading = false;
error = true;
}
onStateChange();
}
}