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
|
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
|
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
|
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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-5">
|
<div class="rounded-xl border border-border bg-bg-elevated p-5">
|
||||||
<h3 class="text-sm font-semibold text-(--fg)">
|
<h3 class="text-sm font-semibold text-fg">
|
||||||
Focus, done right
|
Focus, done right
|
||||||
</h3>
|
</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
|
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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-5">
|
<div class="rounded-xl border border-border bg-bg-elevated p-5">
|
||||||
<h3 class="text-sm font-semibold text-(--fg)">
|
<h3 class="text-sm font-semibold text-fg">
|
||||||
Accessible isolation
|
Accessible isolation
|
||||||
</h3>
|
</h3>
|
||||||
<p class="mt-1.5 text-sm text-(--fg-muted)">
|
<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">hideOthers</code> marks every sibling
|
||||||
<code class="text-(--accent-text)">aria-hidden</code>, ref-counted across layers, preserving
|
<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>.
|
<code class="text-accent-text">aria-live</code> regions. A dependency-free port of <code>aria-hidden</code>.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-5">
|
<div class="rounded-xl border border-border bg-bg-elevated p-5">
|
||||||
<h3 class="text-sm font-semibold text-(--fg)">
|
<h3 class="text-sm font-semibold text-fg">
|
||||||
Animation lifecycle
|
Animation lifecycle
|
||||||
</h3>
|
</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
|
Detect running animations and transitions, then settle exit animations cleanly with
|
||||||
fill-mode flash prevention — so unmounts wait for the CSS to finish.
|
fill-mode flash prevention — so unmounts wait for the CSS to finish.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-5">
|
<div class="rounded-xl border border-border bg-bg-elevated p-5">
|
||||||
<h3 class="text-sm font-semibold text-(--fg)">
|
<h3 class="text-sm font-semibold text-fg">
|
||||||
Multi-runtime safe
|
Multi-runtime safe
|
||||||
</h3>
|
</h3>
|
||||||
<p class="mt-1.5 text-sm text-(--fg-muted)">
|
<p class="mt-1.5 text-sm text-fg-muted">
|
||||||
A resolved <code class="text-(--accent-text)">_global</code> and an
|
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
|
<code class="text-accent-text">isClient</code> flag that work across Node, Bun, Deno, and the
|
||||||
browser — guards baked in so SSR never throws.
|
browser — guards baked in so SSR never throws.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -150,10 +150,10 @@ if (isClient) {
|
|||||||
_global.addEventListener('resize', onResize);
|
_global.addEventListener('resize', onResize);
|
||||||
}`" />
|
}`" />
|
||||||
|
|
||||||
<div class="rounded-xl border border-(--border) bg-(--bg-subtle) p-4 text-sm text-(--fg-muted)">
|
<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
|
<strong class="text-fg">SSR note:</strong> browser helpers touch the DOM, so call them
|
||||||
inside event handlers or after mount.
|
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.
|
undefined, and <code>/multi</code> is import-safe everywhere.
|
||||||
</div>
|
</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, {
|
export default compose(base, typescript, imports, stylistic, {
|
||||||
name: 'platform/overrides',
|
name: 'platform/overrides',
|
||||||
@@ -6,4 +6,4 @@ export default compose(base, typescript, imports, stylistic, {
|
|||||||
rules: {
|
rules: {
|
||||||
'unicorn/prefer-global-this': 'off',
|
'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 |
|
// encodeURIComponent escapes: %23 # | %24 $ | %26 & | %2B + | %5E ^ | %60 ` | %7C |
|
||||||
const ALLOWED_NAME_ESCAPES = /%(?:2[346B]|5E|60|7C)/g;
|
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
|
* @name encodeCookieValue
|
||||||
* @category Browsers
|
* @category Browsers
|
||||||
@@ -104,7 +109,7 @@ export function decodeCookieValue(value: string): string {
|
|||||||
value = value.slice(1, -1);
|
value = value.slice(1, -1);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return value.replaceAll(/(?:%[\dA-F]{2})+/gi, decodeURIComponent);
|
return value.replaceAll(PERCENT_ESCAPE_RE, decodeURIComponent);
|
||||||
}
|
}
|
||||||
catch {
|
catch {
|
||||||
return value;
|
return value;
|
||||||
@@ -129,7 +134,7 @@ export function decodeCookieValue(value: string): string {
|
|||||||
export function encodeCookieName(name: string): string {
|
export function encodeCookieName(name: string): string {
|
||||||
return encodeURIComponent(name)
|
return encodeURIComponent(name)
|
||||||
.replaceAll(ALLOWED_NAME_ESCAPES, decodeURIComponent)
|
.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 { 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 {
|
function makeEl(): HTMLElement {
|
||||||
const el = document.createElement('div');
|
const el = document.createElement('div');
|
||||||
@@ -103,3 +103,19 @@ describe('isInView', () => {
|
|||||||
Object.defineProperty(globalThis, 'visualViewport', { value: original, configurable: true });
|
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';
|
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
|
// Remembers the styles that {@link setStyle} overwrote, keyed by element, so
|
||||||
// {@link resetStyle} can put them back. A WeakMap lets the entry be collected
|
// {@link resetStyle} can put them back. A WeakMap lets the entry be collected
|
||||||
// once the element is gone.
|
// 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
|
// @ts-expect-error — vendor-prefixed transforms only exist in some browsers
|
||||||
= style.transform || style.webkitTransform || style.mozTransform;
|
= style.transform || style.webkitTransform || style.mozTransform;
|
||||||
|
|
||||||
let match = transform.match(/^matrix3d\((.+)\)$/);
|
let match = transform.match(MATRIX3D_RE);
|
||||||
if (match) {
|
if (match) {
|
||||||
// matrix3d: the translate components live at indices 12 (x) and 13 (y).
|
// matrix3d: the translate components live at indices 12 (x) and 13 (y).
|
||||||
// https://developer.mozilla.org/en-US/docs/Web/CSS/transform-function/matrix3d
|
// 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).
|
// matrix: the translate components live at indices 4 (x) and 5 (y).
|
||||||
// https://developer.mozilla.org/en-US/docs/Web/CSS/transform-function/matrix
|
// 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;
|
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
|
&& 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') {
|
export function focusGuard(namespace = 'focus-guard') {
|
||||||
const guardAttr = `data-${namespace}`;
|
const guardAttr = `data-${namespace}`;
|
||||||
|
// Build the attribute selector once per guard, not on every create/remove call.
|
||||||
|
const guardSelector = `[${guardAttr}]`;
|
||||||
|
|
||||||
const createGuard = () => {
|
const createGuard = () => {
|
||||||
const edges = document.querySelectorAll(`[${guardAttr}]`);
|
const edges = document.querySelectorAll(guardSelector);
|
||||||
|
|
||||||
document.body.insertAdjacentElement('afterbegin', edges[0] ?? createGuardAttrs(guardAttr));
|
document.body.insertAdjacentElement('afterbegin', edges[0] ?? createGuardAttrs(guardAttr));
|
||||||
document.body.insertAdjacentElement('beforeend', edges[1] ?? createGuardAttrs(guardAttr));
|
document.body.insertAdjacentElement('beforeend', edges[1] ?? createGuardAttrs(guardAttr));
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeGuard = () => {
|
const removeGuard = () => {
|
||||||
document.querySelectorAll(`[${guardAttr}]`).forEach(element => element.remove());
|
document.querySelectorAll(guardSelector).forEach(element => element.remove());
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ export function getTabbableCandidates(container: HTMLElement): HTMLElement[] {
|
|||||||
acceptNode: (node: HTMLElement) => {
|
acceptNode: (node: HTMLElement) => {
|
||||||
const isHiddenInput = node.tagName === 'INPUT' && (node as HTMLInputElement).type === 'hidden';
|
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 NodeFilter.FILTER_SKIP;
|
||||||
|
|
||||||
return node.tabIndex >= 0 ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP;
|
return node.tabIndex >= 0 ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
export * from './animationLifecycle';
|
export * from './animationLifecycle';
|
||||||
export * from './cookies';
|
export * from './cookies';
|
||||||
|
export * from './dom';
|
||||||
export * from './domStyle';
|
export * from './domStyle';
|
||||||
export * from './focusGuard';
|
export * from './focusGuard';
|
||||||
export * from './focusScope';
|
export * from './focusScope';
|
||||||
|
|||||||
@@ -19,6 +19,17 @@ export function testUserAgentPlatform(re: RegExp): boolean | undefined {
|
|||||||
: 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
|
* @name isMac
|
||||||
* @category Browsers
|
* @category Browsers
|
||||||
@@ -30,7 +41,7 @@ export function testUserAgentPlatform(re: RegExp): boolean | undefined {
|
|||||||
* @since 0.0.5
|
* @since 0.0.5
|
||||||
*/
|
*/
|
||||||
export function isMac(): boolean | undefined {
|
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
|
* @since 0.0.5
|
||||||
*/
|
*/
|
||||||
export function isIPhone(): boolean | undefined {
|
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 {
|
export function isIPad(): boolean | undefined {
|
||||||
return (
|
return (
|
||||||
testUserAgentPlatform(/^iPad/)
|
testUserAgentPlatform(IPAD_RE)
|
||||||
// iPadOS 13+ lies and reports as a Mac; touch support gives it away.
|
// iPadOS 13+ lies and reports as a Mac; touch support gives it away.
|
||||||
|| (isMac() && navigator.maxTouchPoints > 1)
|
|| (isMac() && navigator.maxTouchPoints > 1)
|
||||||
);
|
);
|
||||||
@@ -91,8 +102,7 @@ export function isSafari(): boolean {
|
|||||||
if (typeof navigator === 'undefined')
|
if (typeof navigator === 'undefined')
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
// eslint-disable-next-line regexp/no-unused-capturing-group
|
return SAFARI_RE.test(navigator.userAgent);
|
||||||
return /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -111,7 +121,7 @@ export function isMobileFirefox(): boolean {
|
|||||||
|
|
||||||
const userAgent = navigator.userAgent;
|
const userAgent = navigator.userAgent;
|
||||||
return (
|
return (
|
||||||
(/Firefox/.test(userAgent) && /Mobile/.test(userAgent)) // Android Firefox
|
(FIREFOX_RE.test(userAgent) && MOBILE_RE.test(userAgent)) // Android Firefox
|
||||||
|| /FxiOS/.test(userAgent) // iOS Firefox
|
|| FXIOS_RE.test(userAgent) // iOS Firefox
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user