fix(vue): eslint/tsconfig migration + resolve type errors

@robonen/vue (toolkit): migrate to eslint flat config + composite tsconfig;
fix composable + test type errors (writable computed returns, null guards,
overload-compatible signatures, typed test helpers) — all type-level.
This commit is contained in:
2026-06-07 16:29:39 +07:00
parent e6919de29e
commit c7644ade69
203 changed files with 23016 additions and 141 deletions
@@ -0,0 +1,311 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { effectScope, nextTick, shallowRef } from 'vue';
import { useFullscreen } from '.';
type Listener = (ev: Event) => void;
interface FakeEl {
requestFullscreen: ReturnType<typeof vi.fn>;
addEventListener: ReturnType<typeof vi.fn>;
removeEventListener: ReturnType<typeof vi.fn>;
}
interface FakeDoc {
documentElement: FakeEl;
exitFullscreen: ReturnType<typeof vi.fn>;
fullscreenElement: Element | null;
fullScreen: boolean;
addEventListener: (event: string, cb: Listener) => void;
removeEventListener: (event: string, cb: Listener) => void;
dispatch: (event: string) => void;
}
function createFakeElement(): FakeEl {
return {
requestFullscreen: vi.fn(async () => {}),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
};
}
function createFakeDocument(el?: FakeEl): FakeDoc {
const listeners = new Map<string, Set<Listener>>();
const documentElement = el ?? createFakeElement();
const doc: FakeDoc = {
documentElement,
exitFullscreen: vi.fn(async () => {}),
fullscreenElement: null,
fullScreen: false,
addEventListener(event, cb) {
if (!listeners.has(event))
listeners.set(event, new Set());
listeners.get(event)!.add(cb);
},
removeEventListener(event, cb) {
listeners.get(event)?.delete(cb);
},
dispatch(event) {
listeners.get(event)?.forEach(cb => cb(new Event(event)));
},
};
return doc;
}
afterEach(() => {
vi.unstubAllGlobals();
});
describe(useFullscreen, () => {
it('reports support when request/exit/flag methods exist', () => {
const document = createFakeDocument();
const scope = effectScope();
let fs: ReturnType<typeof useFullscreen>;
scope.run(() => {
fs = useFullscreen(undefined, { document: document as unknown as Document });
});
expect(fs!.isSupported.value).toBeTruthy();
scope.stop();
});
it('is not supported when the Fullscreen API is absent (SSR/unsupported)', () => {
const document = {
documentElement: { addEventListener: vi.fn(), removeEventListener: vi.fn() },
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
} as unknown as Document;
const scope = effectScope();
let fs: ReturnType<typeof useFullscreen>;
scope.run(() => {
fs = useFullscreen(undefined, { document });
});
expect(fs!.isSupported.value).toBeFalsy();
scope.stop();
});
it('is not supported when no document is available (SSR)', () => {
const scope = effectScope();
let fs: ReturnType<typeof useFullscreen>;
// No document and no defaultDocument in jsdom-less branch — pass an explicit undefined.
scope.run(() => {
fs = useFullscreen(undefined, { document: undefined });
});
expect(fs!.isSupported.value).toBeFalsy();
scope.stop();
});
it('starts not fullscreen', () => {
const document = createFakeDocument();
const scope = effectScope();
let fs: ReturnType<typeof useFullscreen>;
scope.run(() => {
fs = useFullscreen(undefined, { document: document as unknown as Document });
});
expect(fs!.isFullscreen.value).toBeFalsy();
scope.stop();
});
it('enter() requests fullscreen on the target element and sets the flag', async () => {
const el = createFakeElement();
const document = createFakeDocument(el);
const scope = effectScope();
let fs: ReturnType<typeof useFullscreen>;
scope.run(() => {
fs = useFullscreen(undefined, { document: document as unknown as Document });
});
await fs!.enter();
expect(el.requestFullscreen).toHaveBeenCalledTimes(1);
expect(fs!.isFullscreen.value).toBeTruthy();
scope.stop();
});
it('enter() requests fullscreen on a provided target element', async () => {
const target = createFakeElement();
const document = createFakeDocument();
const scope = effectScope();
let fs: ReturnType<typeof useFullscreen>;
scope.run(() => {
fs = useFullscreen(target as unknown as HTMLElement, { document: document as unknown as Document });
});
await fs!.enter();
expect(target.requestFullscreen).toHaveBeenCalledTimes(1);
expect(document.documentElement.requestFullscreen).not.toHaveBeenCalled();
expect(fs!.isFullscreen.value).toBeTruthy();
scope.stop();
});
it('enter() is a no-op when already fullscreen', async () => {
const el = createFakeElement();
const document = createFakeDocument(el);
const scope = effectScope();
let fs: ReturnType<typeof useFullscreen>;
scope.run(() => {
fs = useFullscreen(undefined, { document: document as unknown as Document });
});
await fs!.enter();
el.requestFullscreen.mockClear();
await fs!.enter();
expect(el.requestFullscreen).not.toHaveBeenCalled();
scope.stop();
});
it('exit() calls exitFullscreen and clears the flag', async () => {
const el = createFakeElement();
const document = createFakeDocument(el);
const scope = effectScope();
let fs: ReturnType<typeof useFullscreen>;
scope.run(() => {
fs = useFullscreen(undefined, { document: document as unknown as Document });
});
await fs!.enter();
await fs!.exit();
expect(document.exitFullscreen).toHaveBeenCalledTimes(1);
expect(fs!.isFullscreen.value).toBeFalsy();
scope.stop();
});
it('exit() is a no-op when not fullscreen', async () => {
const document = createFakeDocument();
const scope = effectScope();
let fs: ReturnType<typeof useFullscreen>;
scope.run(() => {
fs = useFullscreen(undefined, { document: document as unknown as Document });
});
await fs!.exit();
expect(document.exitFullscreen).not.toHaveBeenCalled();
scope.stop();
});
it('toggle() flips between enter and exit', async () => {
const el = createFakeElement();
const document = createFakeDocument(el);
const scope = effectScope();
let fs: ReturnType<typeof useFullscreen>;
scope.run(() => {
fs = useFullscreen(undefined, { document: document as unknown as Document });
});
await fs!.toggle();
expect(fs!.isFullscreen.value).toBeTruthy();
expect(el.requestFullscreen).toHaveBeenCalledTimes(1);
await fs!.toggle();
expect(fs!.isFullscreen.value).toBeFalsy();
expect(document.exitFullscreen).toHaveBeenCalledTimes(1);
scope.stop();
});
it('does nothing when unsupported', async () => {
const document = {
documentElement: { addEventListener: vi.fn(), removeEventListener: vi.fn() },
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
} as unknown as Document;
const scope = effectScope();
let fs: ReturnType<typeof useFullscreen>;
scope.run(() => {
fs = useFullscreen(undefined, { document });
});
await fs!.enter();
expect(fs!.isFullscreen.value).toBeFalsy();
scope.stop();
});
it('syncs isFullscreen to true on fullscreenchange when our element is the fullscreen element', async () => {
const document = createFakeDocument();
const scope = effectScope();
let fs: ReturnType<typeof useFullscreen>;
scope.run(() => {
fs = useFullscreen(undefined, { document: document as unknown as Document });
});
// Simulate the browser entering fullscreen for the document element.
document.fullScreen = true;
document.fullscreenElement = document.documentElement as unknown as Element;
document.dispatch('fullscreenchange');
await nextTick();
expect(fs!.isFullscreen.value).toBeTruthy();
scope.stop();
});
it('syncs isFullscreen to false on fullscreenchange when fullscreen ends', async () => {
const el = createFakeElement();
const document = createFakeDocument(el);
const scope = effectScope();
let fs: ReturnType<typeof useFullscreen>;
scope.run(() => {
fs = useFullscreen(undefined, { document: document as unknown as Document });
});
await fs!.enter();
expect(fs!.isFullscreen.value).toBeTruthy();
// Browser exits fullscreen (e.g. user pressed Escape).
document.fullScreen = false;
document.fullscreenElement = null;
document.dispatch('fullscreenchange');
await nextTick();
expect(fs!.isFullscreen.value).toBeFalsy();
scope.stop();
});
it('resolves the target from a getter ref', async () => {
const target = createFakeElement();
const elRef = shallowRef<FakeEl | null>(null);
const document = createFakeDocument();
const scope = effectScope();
let fs: ReturnType<typeof useFullscreen>;
scope.run(() => {
fs = useFullscreen(() => elRef.value as unknown as HTMLElement, { document: document as unknown as Document });
});
elRef.value = target;
await nextTick();
await fs!.enter();
expect(target.requestFullscreen).toHaveBeenCalledTimes(1);
scope.stop();
});
it('autoExit exits fullscreen when the scope is disposed', async () => {
const el = createFakeElement();
const document = createFakeDocument(el);
const scope = effectScope();
let fs: ReturnType<typeof useFullscreen>;
scope.run(() => {
fs = useFullscreen(undefined, { document: document as unknown as Document, autoExit: true });
});
await fs!.enter();
expect(fs!.isFullscreen.value).toBeTruthy();
scope.stop();
// onScopeDispose triggers exit() (fire-and-forget); allow the microtask to flush.
await Promise.resolve();
expect(document.exitFullscreen).toHaveBeenCalledTimes(1);
});
it('does not autoExit by default', async () => {
const el = createFakeElement();
const document = createFakeDocument(el);
const scope = effectScope();
let fs: ReturnType<typeof useFullscreen>;
scope.run(() => {
fs = useFullscreen(undefined, { document: document as unknown as Document });
});
await fs!.enter();
scope.stop();
await Promise.resolve();
expect(document.exitFullscreen).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,231 @@
import { computed, shallowRef } from 'vue';
import type { ComputedRef, ShallowRef } from 'vue';
import { isFunction } from '@robonen/stdlib';
import type { ConfigurableDocument } from '@/types';
import { defaultDocument } from '@/types';
import type { MaybeComputedElementRef } from '@/composables/component/unrefElement';
import { unrefElement } from '@/composables/component/unrefElement';
import { useEventListener } from '@/composables/browser/useEventListener';
import { useSupported } from '@/composables/browser/useSupported';
import { tryOnMounted } from '@/composables/lifecycle/tryOnMounted';
import { tryOnScopeDispose } from '@/composables/lifecycle/tryOnScopeDispose';
export interface UseFullscreenOptions extends ConfigurableDocument {
/**
* Automatically exit fullscreen when the component is unmounted
*
* @default false
*/
autoExit?: boolean;
}
export interface UseFullscreenReturn {
/**
* Whether the Fullscreen API is supported for the target element
*/
isSupported: ComputedRef<boolean>;
/**
* Whether the target element is currently in fullscreen mode
*/
isFullscreen: ShallowRef<boolean>;
/**
* Request fullscreen mode for the target element
*/
enter: () => Promise<void>;
/**
* Exit fullscreen mode
*/
exit: () => Promise<void>;
/**
* Toggle fullscreen mode for the target element
*/
toggle: () => Promise<void>;
}
// Vendor-prefixed `fullscreenchange` event names across engines.
const eventHandlers = [
'fullscreenchange',
'webkitfullscreenchange',
'webkitendfullscreen',
'mozfullscreenchange',
'MSFullscreenChange',
] as unknown as Array<'fullscreenchange'>;
const requestMethods = [
'requestFullscreen',
'webkitRequestFullscreen',
'webkitEnterFullscreen',
'webkitEnterFullScreen',
'webkitRequestFullScreen',
'mozRequestFullScreen',
'msRequestFullscreen',
] as const;
const exitMethods = [
'exitFullscreen',
'webkitExitFullscreen',
'webkitExitFullScreen',
'webkitCancelFullScreen',
'mozCancelFullScreen',
'msExitFullscreen',
] as const;
const fullscreenFlags = [
'fullScreen',
'webkitIsFullScreen',
'webkitDisplayingFullscreen',
'mozFullScreen',
'msFullscreenElement',
] as const;
const fullscreenElements = [
'fullscreenElement',
'webkitFullscreenElement',
'mozFullScreenElement',
'msFullscreenElement',
] as const;
const listenerOptions = { capture: false, passive: true } as const;
/**
* @name useFullscreen
* @category Browser
* @description Reactive Fullscreen API for an element (or the document element).
* Handles vendor-prefixed fallbacks for request/exit/state detection and syncs
* `isFullscreen` from `fullscreenchange` events. SSR-safe.
*
* @param {MaybeComputedElementRef} [target] Element to display fullscreen (ref, getter, or component instance). Defaults to `document.documentElement`
* @param {UseFullscreenOptions} [options={}] Options (`document`, `autoExit`)
* @returns {UseFullscreenReturn} `{ isSupported, isFullscreen, enter, exit, toggle }`
*
* @example
* const el = useTemplateRef('el');
* const { isFullscreen, enter, exit, toggle } = useFullscreen(el);
*
* @example
* // Fullscreen the whole page
* const { toggle } = useFullscreen();
*
* @since 0.0.15
*/
export function useFullscreen(
target?: MaybeComputedElementRef,
options: UseFullscreenOptions = {},
): UseFullscreenReturn {
const {
document = defaultDocument,
autoExit = false,
} = options;
const targetRef = computed(() => unrefElement(target) ?? document?.documentElement);
const isFullscreen = shallowRef(false);
const has = (method: string): boolean =>
Boolean((document && method in document) || (targetRef.value && method in targetRef.value));
const requestMethod = computed<typeof requestMethods[number] | undefined>(
() => requestMethods.find(has),
);
const exitMethod = computed<typeof exitMethods[number] | undefined>(
() => exitMethods.find(has),
);
const fullscreenFlag = computed<typeof fullscreenFlags[number] | undefined>(
() => fullscreenFlags.find(has),
);
const fullscreenElementMethod = fullscreenElements.find(m => document && m in document);
const isSupported = useSupported(() =>
targetRef.value
&& document
&& requestMethod.value !== undefined
&& exitMethod.value !== undefined
&& fullscreenFlag.value !== undefined);
const isCurrentElementFullScreen = (): boolean => {
if (fullscreenElementMethod)
return (document as any)?.[fullscreenElementMethod] === targetRef.value;
return false;
};
const isElementFullScreen = (): boolean => {
const flag = fullscreenFlag.value;
if (!flag)
return false;
const docFlag = document && (document as any)[flag];
if (docFlag !== null && docFlag !== undefined)
return Boolean(docFlag);
// Fallback for WebKit / iOS Safari, where the flag lives on the element itself.
const elFlag = (targetRef.value as any)?.[flag];
if (elFlag !== null && elFlag !== undefined)
return Boolean(elFlag);
return false;
};
async function exit(): Promise<void> {
if (!isSupported.value || !isFullscreen.value)
return;
const method = exitMethod.value;
if (method) {
if (typeof (document as any)?.[method] === 'function')
await (document as any)[method]();
else {
// Fallback for Safari iOS, where exit lives on the element.
const el = targetRef.value as any;
if (isFunction(el?.[method]))
await el[method]();
}
}
isFullscreen.value = false;
}
async function enter(): Promise<void> {
if (!isSupported.value || isFullscreen.value)
return;
if (isElementFullScreen())
await exit();
const el = targetRef.value as any;
const method = requestMethod.value;
if (method && isFunction(el?.[method])) {
await el[method]();
isFullscreen.value = true;
}
}
async function toggle(): Promise<void> {
await (isFullscreen.value ? exit() : enter());
}
const handlerCallback = (): void => {
const elementFullScreen = isElementFullScreen();
// Only sync to `false`, or to `true` when *our* element is the fullscreen one,
// so multiple instances on the page don't clobber each other.
if (!elementFullScreen || (elementFullScreen && isCurrentElementFullScreen()))
isFullscreen.value = elementFullScreen;
};
useEventListener(document, eventHandlers, handlerCallback, listenerOptions);
useEventListener(() => targetRef.value, eventHandlers, handlerCallback, listenerOptions);
tryOnMounted(handlerCallback, { sync: false });
if (autoExit)
tryOnScopeDispose(exit);
return {
isSupported,
isFullscreen,
enter,
exit,
toggle,
};
}