Skip to content

Commit

Permalink
Fix touch state not being activated in iOS Safari
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
undergroundwires committed Dec 11, 2023
1 parent 916c9d6 commit a985127
Show file tree
Hide file tree
Showing 43 changed files with 1,719 additions and 672 deletions.
30 changes: 27 additions & 3 deletions src/domain/OperatingSystem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
2 changes: 1 addition & 1 deletion src/infrastructure/Log/WindowInjectedLogger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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;
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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);
}
54 changes: 0 additions & 54 deletions src/infrastructure/RuntimeEnvironment/BrowserOs/DetectorBuilder.ts

This file was deleted.

This file was deleted.

0 comments on commit a985127

Please sign in to comment.