refactor(platform): type focusScope helper; browser updates

This commit is contained in:
2026-06-15 16:54:51 +07:00
parent 425a7bc6e7
commit 44848bc9e6
11 changed files with 145 additions and 39 deletions
+7 -2
View File
@@ -61,6 +61,11 @@ const ALLOWED_VALUE_ESCAPES = /%(?:2[346BF]|3[AC-F]|40|5[BDE]|60|7[BCD])/g;
// encodeURIComponent escapes: %23 # | %24 $ | %26 & | %2B + | %5E ^ | %60 ` | %7C |
const ALLOWED_NAME_ESCAPES = /%(?:2[346B]|5E|60|7C)/g;
// Runs of percent-escapes to decode in a cookie value, and the `()` pair a
// cookie name must escape. Global, but `replaceAll` resets lastIndex per call.
const PERCENT_ESCAPE_RE = /(?:%[\dA-F]{2})+/gi;
const PAREN_RE = /[()]/g;
/**
* @name encodeCookieValue
* @category Browsers
@@ -104,7 +109,7 @@ export function decodeCookieValue(value: string): string {
value = value.slice(1, -1);
try {
return value.replaceAll(/(?:%[\dA-F]{2})+/gi, decodeURIComponent);
return value.replaceAll(PERCENT_ESCAPE_RE, decodeURIComponent);
}
catch {
return value;
@@ -129,7 +134,7 @@ export function decodeCookieValue(value: string): string {
export function encodeCookieName(name: string): string {
return encodeURIComponent(name)
.replaceAll(ALLOWED_NAME_ESCAPES, decodeURIComponent)
.replaceAll(/[()]/g, c => c === '(' ? '%28' : '%29');
.replaceAll(PAREN_RE, c => c === '(' ? '%28' : '%29');
}
/**
@@ -0,0 +1,19 @@
import { describe, expect, it } from 'vitest';
import { isEventTarget } from './index';
describe('isEventTarget', () => {
it('is true for objects exposing addEventListener', () => {
expect(isEventTarget(globalThis)).toBe(true);
expect(isEventTarget(document)).toBe(true);
expect(isEventTarget(document.createElement('div'))).toBe(true);
});
it('is false for non-targets', () => {
expect(isEventTarget(null)).toBe(false);
expect(isEventTarget(undefined)).toBe(false);
expect(isEventTarget({})).toBe(false);
expect(isEventTarget('window')).toBe(false);
expect(isEventTarget(42)).toBe(false);
expect(isEventTarget(() => {})).toBe(false);
});
});
+19
View File
@@ -0,0 +1,19 @@
/**
* @name isEventTarget
* @category Browsers
* @description Type guard for a value that is itself an {@link EventTarget}
* (e.g. `window`, `document`, or an element) — i.e. it can be attached to
* directly rather than unwrapped from a ref/getter first.
*
* @param {unknown} value The value to test
* @returns {boolean} `true` when `value` is a non-null object exposing `addEventListener`
*
* @example
* if (isEventTarget(target))
* target.addEventListener('click', onClick);
*
* @since 0.0.5
*/
export function isEventTarget(value: unknown): value is EventTarget {
return typeof value === 'object' && value !== null && 'addEventListener' in value;
}
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { assignStyle, getTranslate, isInView, resetStyle, setStyle } from './index';
import { assignStyle, getTranslate, isInView, pxValue, resetStyle, setStyle } from './index';
function makeEl(): HTMLElement {
const el = document.createElement('div');
@@ -103,3 +103,19 @@ describe('isInView', () => {
Object.defineProperty(globalThis, 'visualViewport', { value: original, configurable: true });
});
});
describe('pxValue', () => {
it('parses raw px / unitless numbers', () => {
expect(pxValue('1024px')).toBe(1024);
expect(pxValue('768')).toBe(768);
});
it('treats em/rem as 16px', () => {
expect(pxValue('30rem')).toBe(480);
expect(pxValue('1.5em')).toBe(24);
});
it('returns NaN for non-numeric input', () => {
expect(pxValue('auto')).toBeNaN();
});
});
+36 -2
View File
@@ -10,6 +10,12 @@ export type StylePatch = Record<string, string>;
*/
export type TranslateAxis = 'x' | 'y';
// Parsing patterns hoisted to module scope (compiled once, reused). All are
// stateless (no `g`/`y` flag), so sharing across calls is behavior-safe.
const MATRIX3D_RE = /^matrix3d\((.+)\)$/;
const MATRIX_RE = /^matrix\((.+)\)$/;
const EM_REM_RE = /(?:em|rem)\s*$/i;
// Remembers the styles that {@link setStyle} overwrote, keyed by element, so
// {@link resetStyle} can put them back. A WeakMap lets the entry be collected
// once the element is gone.
@@ -114,7 +120,7 @@ export function getTranslate(element: HTMLElement, axis: TranslateAxis): number
// @ts-expect-error — vendor-prefixed transforms only exist in some browsers
= style.transform || style.webkitTransform || style.mozTransform;
let match = transform.match(/^matrix3d\((.+)\)$/);
let match = transform.match(MATRIX3D_RE);
if (match) {
// matrix3d: the translate components live at indices 12 (x) and 13 (y).
// https://developer.mozilla.org/en-US/docs/Web/CSS/transform-function/matrix3d
@@ -123,7 +129,7 @@ export function getTranslate(element: HTMLElement, axis: TranslateAxis): number
// matrix: the translate components live at indices 4 (x) and 5 (y).
// https://developer.mozilla.org/en-US/docs/Web/CSS/transform-function/matrix
match = transform.match(/^matrix\((.+)\)$/);
match = transform.match(MATRIX_RE);
return match ? Number.parseFloat(match[1].split(', ')[axis === 'y' ? 5 : 4]) : null;
}
@@ -188,3 +194,31 @@ export function isInView(element: HTMLElement): boolean {
&& rect.right <= window.visualViewport.width
);
}
/**
* @name pxValue
* @category Browsers
* @description Parse a CSS length token (`"1024px"`, `"48em"`, `"30rem"`,
* `"50%"`) into a pixel number. `em`/`rem` use the conventional 16px root size.
* Returns `NaN` for non-numeric input.
*
* @param {string} value The CSS length token to parse
* @returns {number} The value in pixels, or `NaN` when not parseable
*
* @example
* pxValue('30rem'); // 480
* pxValue('1024px'); // 1024
*
* @since 0.0.5
*/
export function pxValue(value: string): number {
const number = Number.parseFloat(value);
if (Number.isNaN(number))
return Number.NaN;
if (EM_REM_RE.test(value))
return number * 16;
return number;
}
@@ -20,16 +20,18 @@
*/
export function focusGuard(namespace = 'focus-guard') {
const guardAttr = `data-${namespace}`;
// Build the attribute selector once per guard, not on every create/remove call.
const guardSelector = `[${guardAttr}]`;
const createGuard = () => {
const edges = document.querySelectorAll(`[${guardAttr}]`);
const edges = document.querySelectorAll(guardSelector);
document.body.insertAdjacentElement('afterbegin', edges[0] ?? createGuardAttrs(guardAttr));
document.body.insertAdjacentElement('beforeend', edges[1] ?? createGuardAttrs(guardAttr));
};
const removeGuard = () => {
document.querySelectorAll(`[${guardAttr}]`).forEach(element => element.remove());
document.querySelectorAll(guardSelector).forEach(element => element.remove());
};
return {
@@ -85,7 +85,7 @@ export function getTabbableCandidates(container: HTMLElement): HTMLElement[] {
acceptNode: (node: HTMLElement) => {
const isHiddenInput = node.tagName === 'INPUT' && (node as HTMLInputElement).type === 'hidden';
if ((node as any).disabled || node.hidden || isHiddenInput)
if ((node as HTMLElement & { disabled?: boolean }).disabled || node.hidden || isHiddenInput)
return NodeFilter.FILTER_SKIP;
return node.tabIndex >= 0 ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP;
+1
View File
@@ -1,5 +1,6 @@
export * from './animationLifecycle';
export * from './cookies';
export * from './dom';
export * from './domStyle';
export * from './focusGuard';
export * from './focusScope';
+17 -7
View File
@@ -19,6 +19,17 @@ export function testUserAgentPlatform(re: RegExp): boolean | undefined {
: undefined;
}
// Detection patterns hoisted to module scope so they're compiled once, not on
// every call. All are stateless (no `g`/`y` flag), so reuse is behavior-safe.
const MAC_RE = /^Mac/;
const IPHONE_RE = /^iPhone/;
const IPAD_RE = /^iPad/;
// eslint-disable-next-line regexp/no-unused-capturing-group
const SAFARI_RE = /^((?!chrome|android).)*safari/i;
const FIREFOX_RE = /Firefox/;
const MOBILE_RE = /Mobile/;
const FXIOS_RE = /FxiOS/;
/**
* @name isMac
* @category Browsers
@@ -30,7 +41,7 @@ export function testUserAgentPlatform(re: RegExp): boolean | undefined {
* @since 0.0.5
*/
export function isMac(): boolean | undefined {
return testUserAgentPlatform(/^Mac/);
return testUserAgentPlatform(MAC_RE);
}
/**
@@ -43,7 +54,7 @@ export function isMac(): boolean | undefined {
* @since 0.0.5
*/
export function isIPhone(): boolean | undefined {
return testUserAgentPlatform(/^iPhone/);
return testUserAgentPlatform(IPHONE_RE);
}
/**
@@ -58,7 +69,7 @@ export function isIPhone(): boolean | undefined {
*/
export function isIPad(): boolean | undefined {
return (
testUserAgentPlatform(/^iPad/)
testUserAgentPlatform(IPAD_RE)
// iPadOS 13+ lies and reports as a Mac; touch support gives it away.
|| (isMac() && navigator.maxTouchPoints > 1)
);
@@ -91,8 +102,7 @@ export function isSafari(): boolean {
if (typeof navigator === 'undefined')
return false;
// eslint-disable-next-line regexp/no-unused-capturing-group
return /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
return SAFARI_RE.test(navigator.userAgent);
}
/**
@@ -111,7 +121,7 @@ export function isMobileFirefox(): boolean {
const userAgent = navigator.userAgent;
return (
(/Firefox/.test(userAgent) && /Mobile/.test(userAgent)) // Android Firefox
|| /FxiOS/.test(userAgent) // iOS Firefox
(FIREFOX_RE.test(userAgent) && MOBILE_RE.test(userAgent)) // Android Firefox
|| FXIOS_RE.test(userAgent) // iOS Firefox
);
}