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:
undergroundwires
2023-12-11 05:24:27 +01:00
parent 916c9d62d9
commit a9851272ae
43 changed files with 1719 additions and 672 deletions

View File

@@ -4,10 +4,34 @@ export enum OperatingSystem {
Linux,
KaiOS,
ChromeOS,
BlackBerryOS,
BlackBerry,
BlackBerryTabletOS,
Android,
iOS,
iPadOS,
/**
* Legacy: Released in 1999, discontinued in 2013, succeeded by BlackBerry10.
*/
BlackBerryOS,
/**
* Legacy: Released in 2013, discontinued in 2015, succeeded by {@link OperatingSystem.Android}.
*/
BlackBerry10,
/**
* Legacy: Released in 2010, discontinued in 2017,
* succeeded by {@link OperatingSystem.Windows10Mobile}.
*/
WindowsPhone,
/**
* Legacy: Released in 2015, discontinued in 2017, succeeded by {@link OperatingSystem.Android}.
*/
Windows10Mobile,
/**
* Also known as "BlackBerry PlayBook OS"
* Legacy: Released in 2011, discontinued in 2014, succeeded by {@link OperatingSystem.Android}.
*/
BlackBerryTabletOS,
}

View File

@@ -5,7 +5,7 @@ export class WindowInjectedLogger implements Logger {
private readonly logger: Logger;
constructor(windowVariables: WindowVariables | undefined | null = window) {
if (!windowVariables) { // do not trust strict null checks for global objects
if (!windowVariables) { // do not trust strictNullChecks for global objects
throw new Error('missing window');
}
if (!windowVariables.log) {

View File

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

View File

@@ -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;

View File

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

View File

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

View File

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

View File

@@ -1,5 +0,0 @@
import { OperatingSystem } from '@/domain/OperatingSystem';
export interface IBrowserOsDetector {
detect(userAgent: string): OperatingSystem | undefined;
}

View File

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

View File

@@ -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;

View File

@@ -2,6 +2,7 @@ import { Bootstrapper } from './Bootstrapper';
import { RuntimeSanityValidator } from './Modules/RuntimeSanityValidator';
import { AppInitializationLogger } from './Modules/AppInitializationLogger';
import { DependencyBootstrapper } from './Modules/DependencyBootstrapper';
import { MobileSafariActivePseudoClassEnabler } from './Modules/MobileSafariActivePseudoClassEnabler';
import type { App } from 'vue';
export class ApplicationBootstrapper implements Bootstrapper {
@@ -19,6 +20,7 @@ export class ApplicationBootstrapper implements Bootstrapper {
new RuntimeSanityValidator(),
new DependencyBootstrapper(),
new AppInitializationLogger(),
new MobileSafariActivePseudoClassEnabler(),
];
}
}

View File

@@ -0,0 +1,104 @@
import { IRuntimeEnvironment } from '@/infrastructure/RuntimeEnvironment/IRuntimeEnvironment';
import { RuntimeEnvironment } from '@/infrastructure/RuntimeEnvironment/RuntimeEnvironment';
import { OperatingSystem } from '@/domain/OperatingSystem';
import { Bootstrapper } from '../Bootstrapper';
export class MobileSafariActivePseudoClassEnabler implements Bootstrapper {
constructor(
private readonly currentEnvironment = RuntimeEnvironment.CurrentEnvironment,
private readonly browser: BrowserAccessor = GlobalBrowserAccessor,
) {
}
public async bootstrap(): Promise<void> {
if (!isMobileSafari(this.currentEnvironment, this.browser.getNavigatorUserAgent())) {
return;
}
/*
Workaround to fix issue with `:active` pseudo-class not working on mobile Safari.
This is required so `hover-or-touch` mixin works properly.
Last tested: iPhone with iOS 17.1.1
See:
- Source: https://stackoverflow.com/a/33681490
- Snapshot 1: https://web.archive.org/web/20231112151701/https://stackoverflow.com/questions/3885018/active-pseudo-class-doesnt-work-in-mobile-safari/33681490#33681490
- Snapshot 2: tps://archive.ph/r1zpJ
*/
this.browser.addWindowEventListener('touchstart', () => {}, {
/*
- Setting to `true` removes the need for scrolling to block on touch and wheel
event listeners.
- If set to `true`, it indicates that the function specified by listener will
never call `preventDefault`.
- Defaults to `true` on Safari for `touchstart`.
*/
passive: true,
});
}
}
export interface BrowserAccessor {
getNavigatorUserAgent(): string;
addWindowEventListener(...args: Parameters<typeof window.addEventListener>): void;
}
function isMobileSafari(environment: IRuntimeEnvironment, userAgent: string): boolean {
if (!isMobileAppleOperatingSystem(environment)) {
return false;
}
return isSafari(userAgent);
}
function isMobileAppleOperatingSystem(environment: IRuntimeEnvironment): boolean {
if (environment.os === undefined) {
return false;
}
if (![OperatingSystem.iOS, OperatingSystem.iPadOS].includes(environment.os)) {
return false;
}
return true;
}
function isSafari(userAgent: string): boolean {
if (!userAgent) {
return false;
}
return SafariUserAgentIdentifiers.every((i) => userAgent.includes(i))
&& NonSafariBrowserIdentifiers.every((i) => !userAgent.includes(i));
}
const GlobalBrowserAccessor: BrowserAccessor = {
getNavigatorUserAgent: () => navigator.userAgent,
addWindowEventListener: (...args) => window.addEventListener(...args),
} as const;
const SafariUserAgentIdentifiers = [
'Safari',
] as const;
const NonSafariBrowserIdentifiers = [
// Chrome:
'Chrome',
'CriOS',
// Firefox:
'FxiOS',
// Opera:
'OPiOS',
'OPR', // Opera Desktop and Android
'Opera', // Opera Mini
'OPT',
// Edge:
'EdgiOS', // Edge on iOS/iPadOS
'Edg', // Edge on macOS
'EdgA', // Edge on Android
'Edge', // Microsoft Edge Legacy
// UC Browser:
'UCBrowser',
// Baidu:
'BaiduHD',
'BaiduBrowser',
'baiduboxapp',
'baidubrowser',
// QQ Browser:
'MQQBrowser',
] as const;