diff --git a/core/platform/docs/intro.vue b/core/platform/docs/intro.vue index 88464c7..f3a5fbd 100644 --- a/core/platform/docs/intro.vue +++ b/core/platform/docs/intro.vue @@ -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 - @robonen/primitives and the editor. + @robonen/primitives and Writekit.

-
-

+
+

Focus, done right

-

+

Shadow-DOM-aware active-element lookup, scroll-free focusing, and first/last tabbable-edge - detection via a fast TreeWalker — the bones of any focus trap. + detection via a fast TreeWalker — the bones of any focus trap.

-
-

+
+

Accessible isolation

-

- hideOthers marks every sibling - aria-hidden, ref-counted across layers, preserving - aria-live regions. A dependency-free port of aria-hidden. +

+ hideOthers marks every sibling + aria-hidden, ref-counted across layers, preserving + aria-live regions. A dependency-free port of aria-hidden.

-
-

+
+

Animation lifecycle

-

+

Detect running animations and transitions, then settle exit animations cleanly with fill-mode flash prevention — so unmounts wait for the CSS to finish.

-
-

+
+

Multi-runtime safe

-

- A resolved _global and an - isClient flag that work across Node, Bun, Deno, and the +

+ A resolved _global and an + isClient flag that work across Node, Bun, Deno, and the browser — guards baked in so SSR never throws.

@@ -150,10 +150,10 @@ if (isClient) { _global.addEventListener('resize', onResize); }`" /> -
- SSR note: browser helpers touch the DOM, so call them +
+ SSR note: browser helpers touch the DOM, so call them inside event handlers or after mount. - hideOthers already no-ops when document is + hideOthers already no-ops when document is undefined, and /multi is import-safe everywhere.
diff --git a/core/platform/eslint.config.ts b/core/platform/eslint.config.ts index f458d0d..2e66109 100644 --- a/core/platform/eslint.config.ts +++ b/core/platform/eslint.config.ts @@ -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); diff --git a/core/platform/src/browsers/cookies/index.ts b/core/platform/src/browsers/cookies/index.ts index 6b06fad..52a6e2e 100644 --- a/core/platform/src/browsers/cookies/index.ts +++ b/core/platform/src/browsers/cookies/index.ts @@ -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'); } /** diff --git a/core/platform/src/browsers/dom/index.test.ts b/core/platform/src/browsers/dom/index.test.ts new file mode 100644 index 0000000..36c8356 --- /dev/null +++ b/core/platform/src/browsers/dom/index.test.ts @@ -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); + }); +}); diff --git a/core/platform/src/browsers/dom/index.ts b/core/platform/src/browsers/dom/index.ts new file mode 100644 index 0000000..c0d80da --- /dev/null +++ b/core/platform/src/browsers/dom/index.ts @@ -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; +} diff --git a/core/platform/src/browsers/domStyle/index.test.ts b/core/platform/src/browsers/domStyle/index.test.ts index c50075b..f958624 100644 --- a/core/platform/src/browsers/domStyle/index.test.ts +++ b/core/platform/src/browsers/domStyle/index.test.ts @@ -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(); + }); +}); diff --git a/core/platform/src/browsers/domStyle/index.ts b/core/platform/src/browsers/domStyle/index.ts index b9adba5..f119f9e 100644 --- a/core/platform/src/browsers/domStyle/index.ts +++ b/core/platform/src/browsers/domStyle/index.ts @@ -10,6 +10,12 @@ export type StylePatch = Record; */ 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; +} diff --git a/core/platform/src/browsers/focusGuard/index.ts b/core/platform/src/browsers/focusGuard/index.ts index 675075d..720d6c6 100644 --- a/core/platform/src/browsers/focusGuard/index.ts +++ b/core/platform/src/browsers/focusGuard/index.ts @@ -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 { diff --git a/core/platform/src/browsers/focusScope/index.ts b/core/platform/src/browsers/focusScope/index.ts index 4662ee4..71e6afd 100644 --- a/core/platform/src/browsers/focusScope/index.ts +++ b/core/platform/src/browsers/focusScope/index.ts @@ -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; diff --git a/core/platform/src/browsers/index.ts b/core/platform/src/browsers/index.ts index bee1ccd..a375c40 100644 --- a/core/platform/src/browsers/index.ts +++ b/core/platform/src/browsers/index.ts @@ -1,5 +1,6 @@ export * from './animationLifecycle'; export * from './cookies'; +export * from './dom'; export * from './domStyle'; export * from './focusGuard'; export * from './focusScope'; diff --git a/core/platform/src/browsers/userAgent/index.ts b/core/platform/src/browsers/userAgent/index.ts index 650f234..c601818 100644 --- a/core/platform/src/browsers/userAgent/index.ts +++ b/core/platform/src/browsers/userAgent/index.ts @@ -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 ); }