-
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
);
}