refactor(platform): type focusScope helper; browser updates
This commit is contained in:
@@ -21,49 +21,49 @@
|
||||
primitives that overlays, dialogs, and editors depend on — focus guards, tabbable-edge
|
||||
detection, sibling hiding for screen readers, and CSS animation settling — and ships them
|
||||
SSR-aware and dependency-free. It is the low-level layer that powers
|
||||
<NuxtLink to="/primitives">@robonen/primitives</NuxtLink> and the editor.
|
||||
<NuxtLink to="/primitives">@robonen/primitives</NuxtLink> and Writekit.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-5">
|
||||
<h3 class="text-sm font-semibold text-(--fg)">
|
||||
<div class="rounded-xl border border-border bg-bg-elevated p-5">
|
||||
<h3 class="text-sm font-semibold text-fg">
|
||||
Focus, done right
|
||||
</h3>
|
||||
<p class="mt-1.5 text-sm text-(--fg-muted)">
|
||||
<p class="mt-1.5 text-sm text-fg-muted">
|
||||
Shadow-DOM-aware active-element lookup, scroll-free focusing, and first/last tabbable-edge
|
||||
detection via a fast <code class="text-(--accent-text)">TreeWalker</code> — the bones of any focus trap.
|
||||
detection via a fast <code class="text-accent-text">TreeWalker</code> — the bones of any focus trap.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-5">
|
||||
<h3 class="text-sm font-semibold text-(--fg)">
|
||||
<div class="rounded-xl border border-border bg-bg-elevated p-5">
|
||||
<h3 class="text-sm font-semibold text-fg">
|
||||
Accessible isolation
|
||||
</h3>
|
||||
<p class="mt-1.5 text-sm text-(--fg-muted)">
|
||||
<code class="text-(--accent-text)">hideOthers</code> marks every sibling
|
||||
<code class="text-(--accent-text)">aria-hidden</code>, ref-counted across layers, preserving
|
||||
<code class="text-(--accent-text)">aria-live</code> regions. A dependency-free port of <code>aria-hidden</code>.
|
||||
<p class="mt-1.5 text-sm text-fg-muted">
|
||||
<code class="text-accent-text">hideOthers</code> marks every sibling
|
||||
<code class="text-accent-text">aria-hidden</code>, ref-counted across layers, preserving
|
||||
<code class="text-accent-text">aria-live</code> regions. A dependency-free port of <code>aria-hidden</code>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-5">
|
||||
<h3 class="text-sm font-semibold text-(--fg)">
|
||||
<div class="rounded-xl border border-border bg-bg-elevated p-5">
|
||||
<h3 class="text-sm font-semibold text-fg">
|
||||
Animation lifecycle
|
||||
</h3>
|
||||
<p class="mt-1.5 text-sm text-(--fg-muted)">
|
||||
<p class="mt-1.5 text-sm text-fg-muted">
|
||||
Detect running animations and transitions, then settle exit animations cleanly with
|
||||
fill-mode flash prevention — so unmounts wait for the CSS to finish.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-5">
|
||||
<h3 class="text-sm font-semibold text-(--fg)">
|
||||
<div class="rounded-xl border border-border bg-bg-elevated p-5">
|
||||
<h3 class="text-sm font-semibold text-fg">
|
||||
Multi-runtime safe
|
||||
</h3>
|
||||
<p class="mt-1.5 text-sm text-(--fg-muted)">
|
||||
A resolved <code class="text-(--accent-text)">_global</code> and an
|
||||
<code class="text-(--accent-text)">isClient</code> flag that work across Node, Bun, Deno, and the
|
||||
<p class="mt-1.5 text-sm text-fg-muted">
|
||||
A resolved <code class="text-accent-text">_global</code> and an
|
||||
<code class="text-accent-text">isClient</code> flag that work across Node, Bun, Deno, and the
|
||||
browser — guards baked in so SSR never throws.
|
||||
</p>
|
||||
</div>
|
||||
@@ -150,10 +150,10 @@ if (isClient) {
|
||||
_global.addEventListener('resize', onResize);
|
||||
}`" />
|
||||
|
||||
<div class="rounded-xl border border-(--border) bg-(--bg-subtle) p-4 text-sm text-(--fg-muted)">
|
||||
<strong class="text-(--fg)">SSR note:</strong> browser helpers touch the DOM, so call them
|
||||
<div class="rounded-xl border border-border bg-bg-subtle p-4 text-sm text-fg-muted">
|
||||
<strong class="text-fg">SSR note:</strong> browser helpers touch the DOM, so call them
|
||||
inside event handlers or after mount.
|
||||
<code class="text-(--accent-text)">hideOthers</code> already no-ops when <code>document</code> is
|
||||
<code class="text-accent-text">hideOthers</code> already no-ops when <code>document</code> is
|
||||
undefined, and <code>/multi</code> is import-safe everywhere.
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { base, compose, imports, stylistic, typescript } from '@robonen/eslint';
|
||||
import { base, compose, imports, stylistic, tests, typescript } from '@robonen/eslint';
|
||||
|
||||
export default compose(base, typescript, imports, stylistic, {
|
||||
name: 'platform/overrides',
|
||||
@@ -6,4 +6,4 @@ export default compose(base, typescript, imports, stylistic, {
|
||||
rules: {
|
||||
'unicorn/prefer-global-this': 'off',
|
||||
},
|
||||
});
|
||||
}, tests);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,5 +1,6 @@
|
||||
export * from './animationLifecycle';
|
||||
export * from './cookies';
|
||||
export * from './dom';
|
||||
export * from './domStyle';
|
||||
export * from './focusGuard';
|
||||
export * from './focusScope';
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user