Fix touch state not being activated in iOS Safari
This commit resolves the issue with the `:active` pseudo-class not
activating in mobile Safari on iOS devices. It introduces a workaround
specifically for mobile Safari on iOS/iPadOS to enable the `:active`
pseudo-class. This ensures a consistent and responsive user interface
in response to touch states on mobile Safari.
Other supporting changes:
- Introduce new test utility functions such as `createWindowEventSpies`
and `formatAssertionMessage` to improve code reusability and
maintainability.
- Improve browser detection:
- Add detection for iPadOS and Windows 10 Mobile.
- Add touch support detection to correctly determine iPadOS vs macOS.
- Fix misidentification of some Windows 10 Mobile platforms as Windows
Phone.
- Improve test coverage and refactor tests.
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
import { OperatingSystem } from '@/domain/OperatingSystem';
|
||||
|
||||
export enum TouchSupportExpectation {
|
||||
MustExist,
|
||||
MustNotExist,
|
||||
}
|
||||
|
||||
export interface BrowserCondition {
|
||||
readonly operatingSystem: OperatingSystem;
|
||||
|
||||
readonly existingPartsInSameUserAgent: readonly string[];
|
||||
|
||||
readonly notExistingPartsInUserAgent?: readonly string[];
|
||||
|
||||
readonly touchSupport?: TouchSupportExpectation;
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { OperatingSystem } from '@/domain/OperatingSystem';
|
||||
import { BrowserCondition, TouchSupportExpectation } from './BrowserCondition';
|
||||
|
||||
// They include "Android", "iPhone" in their user agents.
|
||||
const WindowsMobileIdentifiers: readonly string[] = [
|
||||
'Windows Phone',
|
||||
'Windows Mobile',
|
||||
] as const;
|
||||
|
||||
export const BrowserConditions: readonly BrowserCondition[] = [
|
||||
{
|
||||
operatingSystem: OperatingSystem.KaiOS,
|
||||
existingPartsInSameUserAgent: ['KAIOS'],
|
||||
},
|
||||
{
|
||||
operatingSystem: OperatingSystem.ChromeOS,
|
||||
existingPartsInSameUserAgent: ['CrOS'],
|
||||
},
|
||||
{
|
||||
operatingSystem: OperatingSystem.BlackBerryOS,
|
||||
existingPartsInSameUserAgent: ['BlackBerry'],
|
||||
},
|
||||
{
|
||||
operatingSystem: OperatingSystem.BlackBerryTabletOS,
|
||||
existingPartsInSameUserAgent: ['RIM Tablet OS'],
|
||||
},
|
||||
{
|
||||
operatingSystem: OperatingSystem.BlackBerry10,
|
||||
existingPartsInSameUserAgent: ['BB10'],
|
||||
},
|
||||
{
|
||||
operatingSystem: OperatingSystem.Android,
|
||||
existingPartsInSameUserAgent: ['Android'],
|
||||
notExistingPartsInUserAgent: [...WindowsMobileIdentifiers],
|
||||
},
|
||||
{
|
||||
operatingSystem: OperatingSystem.Android,
|
||||
existingPartsInSameUserAgent: ['Adr'],
|
||||
notExistingPartsInUserAgent: [...WindowsMobileIdentifiers],
|
||||
},
|
||||
{
|
||||
operatingSystem: OperatingSystem.iOS,
|
||||
existingPartsInSameUserAgent: ['iPhone'],
|
||||
notExistingPartsInUserAgent: [...WindowsMobileIdentifiers],
|
||||
},
|
||||
{
|
||||
operatingSystem: OperatingSystem.iOS,
|
||||
existingPartsInSameUserAgent: ['iPod'],
|
||||
},
|
||||
{
|
||||
operatingSystem: OperatingSystem.iPadOS,
|
||||
existingPartsInSameUserAgent: ['iPad'],
|
||||
// On Safari, only for older iPads running ≤ iOS 12 reports `iPad`
|
||||
// Other browsers report `iPad` both for older devices (≤ iOS 12) and newer (≥ iPadOS 13)
|
||||
// We detect all as `iPadOS` for simplicity.
|
||||
},
|
||||
{
|
||||
operatingSystem: OperatingSystem.iPadOS,
|
||||
existingPartsInSameUserAgent: ['Macintosh'], // Reported by Safari on iPads running ≥ iPadOS 13
|
||||
touchSupport: TouchSupportExpectation.MustExist, // Safari same user agent as desktop macOS
|
||||
},
|
||||
{
|
||||
operatingSystem: OperatingSystem.Linux,
|
||||
existingPartsInSameUserAgent: ['Linux'],
|
||||
notExistingPartsInUserAgent: ['Android', 'Adr'],
|
||||
},
|
||||
{
|
||||
operatingSystem: OperatingSystem.Windows,
|
||||
existingPartsInSameUserAgent: ['Windows'],
|
||||
notExistingPartsInUserAgent: [...WindowsMobileIdentifiers],
|
||||
},
|
||||
...['Windows Phone OS', 'Windows Phone 8'].map((userAgentPart) => ({
|
||||
operatingSystem: OperatingSystem.WindowsPhone,
|
||||
existingPartsInSameUserAgent: [userAgentPart],
|
||||
})),
|
||||
...['Windows Mobile', 'Windows Phone 10'].map((userAgentPart) => ({
|
||||
operatingSystem: OperatingSystem.Windows10Mobile,
|
||||
existingPartsInSameUserAgent: [userAgentPart],
|
||||
})),
|
||||
{
|
||||
operatingSystem: OperatingSystem.macOS,
|
||||
existingPartsInSameUserAgent: ['Macintosh'],
|
||||
notExistingPartsInUserAgent: ['like Mac OS X'], // Eliminate iOS and iPadOS for Safari
|
||||
touchSupport: TouchSupportExpectation.MustNotExist, // Distinguish from iPadOS for Safari
|
||||
},
|
||||
] as const;
|
||||
@@ -1,57 +1,10 @@
|
||||
import { OperatingSystem } from '@/domain/OperatingSystem';
|
||||
import { DetectorBuilder } from './DetectorBuilder';
|
||||
import { IBrowserOsDetector } from './IBrowserOsDetector';
|
||||
|
||||
export class BrowserOsDetector implements IBrowserOsDetector {
|
||||
private readonly detectors = BrowserDetectors;
|
||||
|
||||
public detect(userAgent: string): OperatingSystem | undefined {
|
||||
if (!userAgent) {
|
||||
return undefined;
|
||||
}
|
||||
for (const detector of this.detectors) {
|
||||
const os = detector.detect(userAgent);
|
||||
if (os !== undefined) {
|
||||
return os;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
export interface BrowserEnvironment {
|
||||
readonly isTouchSupported: boolean;
|
||||
readonly userAgent: string;
|
||||
}
|
||||
|
||||
// Reference: https://github.com/keithws/browser-report/blob/master/index.js#L304
|
||||
const BrowserDetectors = [
|
||||
define(OperatingSystem.KaiOS, (b) => b
|
||||
.mustInclude('KAIOS')),
|
||||
define(OperatingSystem.ChromeOS, (b) => b
|
||||
.mustInclude('CrOS')),
|
||||
define(OperatingSystem.BlackBerryOS, (b) => b
|
||||
.mustInclude('BlackBerry')),
|
||||
define(OperatingSystem.BlackBerryTabletOS, (b) => b
|
||||
.mustInclude('RIM Tablet OS')),
|
||||
define(OperatingSystem.BlackBerry, (b) => b
|
||||
.mustInclude('BB10')),
|
||||
define(OperatingSystem.Android, (b) => b
|
||||
.mustInclude('Android').mustNotInclude('Windows Phone')),
|
||||
define(OperatingSystem.Android, (b) => b
|
||||
.mustInclude('Adr').mustNotInclude('Windows Phone')),
|
||||
define(OperatingSystem.iOS, (b) => b
|
||||
.mustInclude('like Mac OS X')),
|
||||
define(OperatingSystem.Linux, (b) => b
|
||||
.mustInclude('Linux').mustNotInclude('Android').mustNotInclude('Adr')),
|
||||
define(OperatingSystem.Windows, (b) => b
|
||||
.mustInclude('Windows').mustNotInclude('Windows Phone')),
|
||||
define(OperatingSystem.WindowsPhone, (b) => b
|
||||
.mustInclude('Windows Phone')),
|
||||
define(OperatingSystem.macOS, (b) => b
|
||||
.mustInclude('OS X').mustNotInclude('Android').mustNotInclude('like Mac OS X')),
|
||||
];
|
||||
|
||||
function define(
|
||||
os: OperatingSystem,
|
||||
applyRules: (builder: DetectorBuilder) => DetectorBuilder,
|
||||
): IBrowserOsDetector {
|
||||
const builder = new DetectorBuilder(os);
|
||||
applyRules(builder);
|
||||
return builder.build();
|
||||
export interface BrowserOsDetector {
|
||||
detect(environment: BrowserEnvironment): OperatingSystem | undefined;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { OperatingSystem } from '@/domain/OperatingSystem';
|
||||
import { assertInRange } from '@/application/Common/Enum';
|
||||
import { BrowserEnvironment, BrowserOsDetector } from './BrowserOsDetector';
|
||||
import { BrowserCondition, TouchSupportExpectation } from './BrowserCondition';
|
||||
import { BrowserConditions } from './BrowserConditions';
|
||||
|
||||
export class ConditionBasedOsDetector implements BrowserOsDetector {
|
||||
constructor(private readonly conditions: readonly BrowserCondition[] = BrowserConditions) {
|
||||
validateConditions(conditions);
|
||||
}
|
||||
|
||||
public detect(environment: BrowserEnvironment): OperatingSystem | undefined {
|
||||
if (!environment.userAgent) {
|
||||
return undefined;
|
||||
}
|
||||
for (const condition of this.conditions) {
|
||||
if (satisfiesCondition(condition, environment)) {
|
||||
return condition.operatingSystem;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function satisfiesCondition(
|
||||
condition: BrowserCondition,
|
||||
browserEnvironment: BrowserEnvironment,
|
||||
): boolean {
|
||||
const { userAgent } = browserEnvironment;
|
||||
if (condition.touchSupport !== undefined) {
|
||||
if (!satisfiesTouchExpectation(condition.touchSupport, browserEnvironment)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (condition.existingPartsInSameUserAgent.some((part) => !userAgent.includes(part))) {
|
||||
return false;
|
||||
}
|
||||
if (condition.notExistingPartsInUserAgent?.some((part) => userAgent.includes(part))) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function satisfiesTouchExpectation(
|
||||
expectation: TouchSupportExpectation,
|
||||
browserEnvironment: BrowserEnvironment,
|
||||
): boolean {
|
||||
switch (expectation) {
|
||||
case TouchSupportExpectation.MustExist:
|
||||
if (!browserEnvironment.isTouchSupported) {
|
||||
return false;
|
||||
}
|
||||
break;
|
||||
case TouchSupportExpectation.MustNotExist:
|
||||
if (browserEnvironment.isTouchSupported) {
|
||||
return false;
|
||||
}
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unsupported touch support expectation: ${TouchSupportExpectation[expectation]}`);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function validateConditions(conditions: readonly BrowserCondition[]) {
|
||||
if (!conditions.length) {
|
||||
throw new Error('empty conditions');
|
||||
}
|
||||
for (const condition of conditions) {
|
||||
validateCondition(condition);
|
||||
}
|
||||
}
|
||||
|
||||
function validateCondition(condition: BrowserCondition) {
|
||||
if (!condition.existingPartsInSameUserAgent.length) {
|
||||
throw new Error('Each condition must include at least one identifiable part of the user agent string.');
|
||||
}
|
||||
const duplicates = getDuplicates([
|
||||
...condition.existingPartsInSameUserAgent,
|
||||
...(condition.notExistingPartsInUserAgent ?? []),
|
||||
]);
|
||||
if (duplicates.length > 0) {
|
||||
throw new Error(`Found duplicate entries in user agent parts: ${duplicates.join(', ')}. Each part should be unique.`);
|
||||
}
|
||||
if (condition.touchSupport !== undefined) {
|
||||
assertInRange(condition.touchSupport, TouchSupportExpectation);
|
||||
}
|
||||
}
|
||||
|
||||
function getDuplicates(texts: readonly string[]): string[] {
|
||||
return texts.filter((text, index) => texts.indexOf(text) !== index);
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
import { OperatingSystem } from '@/domain/OperatingSystem';
|
||||
import { IBrowserOsDetector } from './IBrowserOsDetector';
|
||||
|
||||
export class DetectorBuilder {
|
||||
private readonly existingPartsInUserAgent = new Array<string>();
|
||||
|
||||
private readonly notExistingPartsInUserAgent = new Array<string>();
|
||||
|
||||
constructor(private readonly os: OperatingSystem) { }
|
||||
|
||||
public mustInclude(str: string): DetectorBuilder {
|
||||
return this.add(str, this.existingPartsInUserAgent);
|
||||
}
|
||||
|
||||
public mustNotInclude(str: string): DetectorBuilder {
|
||||
return this.add(str, this.notExistingPartsInUserAgent);
|
||||
}
|
||||
|
||||
public build(): IBrowserOsDetector {
|
||||
if (!this.existingPartsInUserAgent.length) {
|
||||
throw new Error('Must include at least a part');
|
||||
}
|
||||
return {
|
||||
detect: (agent) => this.detect(agent),
|
||||
};
|
||||
}
|
||||
|
||||
private detect(userAgent: string): OperatingSystem | undefined {
|
||||
if (!userAgent) {
|
||||
return undefined;
|
||||
}
|
||||
if (this.existingPartsInUserAgent.some((part) => !userAgent.includes(part))) {
|
||||
return undefined;
|
||||
}
|
||||
if (this.notExistingPartsInUserAgent.some((part) => userAgent.includes(part))) {
|
||||
return undefined;
|
||||
}
|
||||
return this.os;
|
||||
}
|
||||
|
||||
private add(part: string, array: string[]): DetectorBuilder {
|
||||
if (!part) {
|
||||
throw new Error('part is empty or undefined');
|
||||
}
|
||||
if (this.existingPartsInUserAgent.includes(part)) {
|
||||
throw new Error(`part ${part} is already included as existing part`);
|
||||
}
|
||||
if (this.notExistingPartsInUserAgent.includes(part)) {
|
||||
throw new Error(`part ${part} is already included as not existing part`);
|
||||
}
|
||||
array.push(part);
|
||||
return this;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { OperatingSystem } from '@/domain/OperatingSystem';
|
||||
|
||||
export interface IBrowserOsDetector {
|
||||
detect(userAgent: string): OperatingSystem | undefined;
|
||||
}
|
||||
@@ -2,9 +2,10 @@ import { OperatingSystem } from '@/domain/OperatingSystem';
|
||||
import { WindowVariables } from '@/infrastructure/WindowVariables/WindowVariables';
|
||||
import { IEnvironmentVariables } from '@/infrastructure/EnvironmentVariables/IEnvironmentVariables';
|
||||
import { EnvironmentVariablesFactory } from '@/infrastructure/EnvironmentVariables/EnvironmentVariablesFactory';
|
||||
import { BrowserOsDetector } from './BrowserOs/BrowserOsDetector';
|
||||
import { IBrowserOsDetector } from './BrowserOs/IBrowserOsDetector';
|
||||
import { ConditionBasedOsDetector } from './BrowserOs/ConditionBasedOsDetector';
|
||||
import { BrowserEnvironment, BrowserOsDetector } from './BrowserOs/BrowserOsDetector';
|
||||
import { IRuntimeEnvironment } from './IRuntimeEnvironment';
|
||||
import { isTouchEnabledDevice } from './TouchSupportDetection';
|
||||
|
||||
export class RuntimeEnvironment implements IRuntimeEnvironment {
|
||||
public static readonly CurrentEnvironment: IRuntimeEnvironment = new RuntimeEnvironment(window);
|
||||
@@ -18,11 +19,10 @@ export class RuntimeEnvironment implements IRuntimeEnvironment {
|
||||
protected constructor(
|
||||
window: Partial<Window>,
|
||||
environmentVariables: IEnvironmentVariables = EnvironmentVariablesFactory.Current.instance,
|
||||
browserOsDetector: IBrowserOsDetector = new BrowserOsDetector(),
|
||||
browserOsDetector: BrowserOsDetector = new ConditionBasedOsDetector(),
|
||||
touchDetector = isTouchEnabledDevice,
|
||||
) {
|
||||
if (!window) {
|
||||
throw new Error('missing window');
|
||||
}
|
||||
if (!window) { throw new Error('missing window'); } // do not trust strictNullChecks for global objects
|
||||
this.isNonProduction = environmentVariables.isNonProduction;
|
||||
this.isDesktop = isDesktop(window);
|
||||
if (this.isDesktop) {
|
||||
@@ -31,7 +31,11 @@ export class RuntimeEnvironment implements IRuntimeEnvironment {
|
||||
this.os = undefined;
|
||||
const userAgent = getUserAgent(window);
|
||||
if (userAgent) {
|
||||
this.os = browserOsDetector.detect(userAgent);
|
||||
const browserEnvironment: BrowserEnvironment = {
|
||||
userAgent,
|
||||
isTouchSupported: touchDetector(),
|
||||
};
|
||||
this.os = browserOsDetector.detect(browserEnvironment);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
export function isTouchEnabledDevice(
|
||||
browserTouchAccessor: BrowserTouchSupportAccessor = GlobalTouchSupportAccessor,
|
||||
): boolean {
|
||||
return TouchSupportChecks.some(
|
||||
(check) => check(browserTouchAccessor),
|
||||
);
|
||||
}
|
||||
|
||||
export interface BrowserTouchSupportAccessor {
|
||||
navigatorMaxTouchPoints: () => number | undefined;
|
||||
windowMatchMediaMatches: (query: string) => boolean;
|
||||
documentOntouchend: () => undefined | unknown;
|
||||
windowTouchEvent: () => undefined | unknown;
|
||||
}
|
||||
|
||||
const TouchSupportChecks: ReadonlyArray<(accessor: BrowserTouchSupportAccessor) => boolean> = [
|
||||
/*
|
||||
✅ Mobile: Chrome, Safari, Firefox on iOS and Android
|
||||
❌ Touch-enabled Windows laptop: Chrome
|
||||
(Chromium has removed ontouch* events on desktop since Chrome 70+.)
|
||||
❌ Touch-enabled Windows laptop: Firefox
|
||||
*/
|
||||
(accessor) => accessor.documentOntouchend() !== undefined,
|
||||
/*
|
||||
✅ Mobile: Chrome, Safari, Firefox on iOS and Android
|
||||
✅ Touch-enabled Windows laptop: Chrome
|
||||
❌ Touch-enabled Windows laptop: Firefox
|
||||
*/
|
||||
(accessor) => {
|
||||
const maxTouchPoints = accessor.navigatorMaxTouchPoints();
|
||||
return maxTouchPoints !== undefined && maxTouchPoints > 0;
|
||||
},
|
||||
/*
|
||||
✅ Mobile: Chrome, Safari, Firefox on iOS and Android
|
||||
✅ Touch-enabled Windows laptop: Chrome
|
||||
❌ Touch-enabled Windows laptop: Firefox
|
||||
*/
|
||||
(accessor) => accessor.windowMatchMediaMatches('(any-pointer: coarse)'),
|
||||
/*
|
||||
✅ Mobile: Chrome, Safari, Firefox on iOS and Android
|
||||
✅ Touch-enabled Windows laptop: Chrome
|
||||
❌ Touch-enabled Windows laptop: Firefox
|
||||
*/
|
||||
(accessor) => accessor.windowTouchEvent() !== undefined,
|
||||
];
|
||||
|
||||
const GlobalTouchSupportAccessor: BrowserTouchSupportAccessor = {
|
||||
navigatorMaxTouchPoints: () => navigator.maxTouchPoints,
|
||||
windowMatchMediaMatches: (query: string) => window.matchMedia(query)?.matches,
|
||||
documentOntouchend: () => document.ontouchend,
|
||||
windowTouchEvent: () => window.TouchEvent,
|
||||
} as const;
|
||||
Reference in New Issue
Block a user