diff --git a/packages/core/accessibility/accessibility-common.ts b/packages/core/accessibility/accessibility-common.ts new file mode 100644 index 000000000..ac7280597 --- /dev/null +++ b/packages/core/accessibility/accessibility-common.ts @@ -0,0 +1,73 @@ +import type { View } from '../ui/core/view'; +import type { Page } from '../ui/page'; +import type { AccessibilityBlurEventData, AccessibilityFocusChangedEventData, AccessibilityFocusEventData } from './accessibility-types'; + +const lastFocusedViewOnPageKeyName = '__lastFocusedViewOnPage'; + +export const accessibilityBlurEvent = 'accessibilityBlur'; +export const accessibilityFocusEvent = 'accessibilityFocus'; +export const accessibilityFocusChangedEvent = 'accessibilityFocusChanged'; + +/** + * Send notification when accessibility focus state changes. + * If either receivedFocus or lostFocus is true, 'accessibilityFocusChanged' is send with value true if element received focus + * If receivedFocus, 'accessibilityFocus' is send + * if lostFocus, 'accessibilityBlur' is send + * + * @param {View} view + * @param {boolean} receivedFocus + * @param {boolean} lostFocus + */ +export function notifyAccessibilityFocusState(view: View, receivedFocus: boolean, lostFocus: boolean): void { + if (!receivedFocus && !lostFocus) { + return; + } + + view.notify({ + eventName: accessibilityFocusChangedEvent, + object: view, + value: !!receivedFocus, + } as AccessibilityFocusChangedEventData); + + if (receivedFocus) { + if (view.page) { + view.page[lastFocusedViewOnPageKeyName] = new WeakRef(view); + } + + view.notify({ + eventName: accessibilityFocusEvent, + object: view, + } as AccessibilityFocusEventData); + } else if (lostFocus) { + view.notify({ + eventName: accessibilityBlurEvent, + object: view, + } as AccessibilityBlurEventData); + } +} + +export function getLastFocusedViewOnPage(page: Page): View | null { + try { + const lastFocusedViewRef = page[lastFocusedViewOnPageKeyName] as WeakRef; + if (!lastFocusedViewRef) { + return null; + } + + const lastFocusedView = lastFocusedViewRef.get(); + if (!lastFocusedView) { + return null; + } + + if (!lastFocusedView.parent || lastFocusedView.page !== page) { + return null; + } + + return lastFocusedView; + } catch { + // ignore + } finally { + delete page[lastFocusedViewOnPageKeyName]; + } + + return null; +} diff --git a/packages/core/accessibility/accessibility-css-helper.ts b/packages/core/accessibility/accessibility-css-helper.ts new file mode 100644 index 000000000..2fe70f22c --- /dev/null +++ b/packages/core/accessibility/accessibility-css-helper.ts @@ -0,0 +1,126 @@ +import { applyCssClass, getRootView } from '../application'; +import * as Application from '../application'; +import type { View } from '../ui/core/view'; +import { AccessibilityServiceEnabledObservable } from './accessibility-service'; +import { FontScaleCategory, getCurrentFontScale, getFontScaleCategory, VALID_FONT_SCALES } from './font-scale'; + +// CSS-classes +const fontScaleExtraSmallCategoryClass = `a11y-fontscale-xs`; +const fontScaleMediumCategoryClass = `a11y-fontscale-m`; +const fontScaleExtraLargeCategoryClass = `a11y-fontscale-xl`; + +const fontScaleCategoryClasses = [fontScaleExtraSmallCategoryClass, fontScaleMediumCategoryClass, fontScaleExtraLargeCategoryClass]; + +const a11yServiceEnabledClass = `a11y-service-enabled`; +const a11yServiceDisabledClass = `a11y-service-disabled`; +const a11yServiceClasses = [a11yServiceEnabledClass, a11yServiceDisabledClass]; + +let accessibilityServiceObservable: AccessibilityServiceEnabledObservable; +let fontScaleCssClasses: Map; + +let currentFontScaleClass = ''; +let currentFontScaleCategory = ''; +let currentA11YServiceClass = ''; + +function ensureClasses() { + if (accessibilityServiceObservable) { + return; + } + + fontScaleCssClasses = new Map(VALID_FONT_SCALES.map((fs) => [fs, `a11y-fontscale-${Number(fs * 100).toFixed(0)}`])); + + accessibilityServiceObservable = new AccessibilityServiceEnabledObservable(); +} + +function applyRootCssClass(cssClasses: string[], newCssClass: string): void { + const rootView = getRootView(); + if (!rootView) { + return; + } + + applyCssClass(rootView, cssClasses, newCssClass); + + const rootModalViews = >rootView._getRootModalViews(); + rootModalViews.forEach((rootModalView) => applyCssClass(rootModalView, cssClasses, newCssClass)); +} + +function applyFontScaleToRootViews(): void { + const rootView = getRootView(); + if (!rootView) { + return; + } + + const fontScale = getCurrentFontScale(); + + rootView.style._fontScale = fontScale; + + const rootModalViews = >rootView._getRootModalViews(); + rootModalViews.forEach((rootModalView) => (rootModalView.style._fontScale = fontScale)); +} + +export function initAccessibilityCssHelper(): void { + ensureClasses(); + + Application.on(Application.fontScaleChangedEvent, () => { + updateCurrentHelperClasses(); + + applyFontScaleToRootViews(); + }); + + accessibilityServiceObservable.on(AccessibilityServiceEnabledObservable.propertyChangeEvent, updateCurrentHelperClasses); +} + +/** + * Update the helper CSS-classes. + * Return true is any changes. + */ +function updateCurrentHelperClasses(): void { + const fontScale = getCurrentFontScale(); + const fontScaleCategory = getFontScaleCategory(); + + const oldFontScaleClass = currentFontScaleClass; + if (fontScaleCssClasses.has(fontScale)) { + currentFontScaleClass = fontScaleCssClasses.get(fontScale); + } else { + currentFontScaleClass = fontScaleCssClasses.get(1); + } + + if (oldFontScaleClass !== currentFontScaleClass) { + applyRootCssClass([...fontScaleCssClasses.values()], currentFontScaleClass); + } + + const oldActiveFontScaleCategory = currentFontScaleCategory; + switch (fontScaleCategory) { + case FontScaleCategory.ExtraSmall: { + currentFontScaleCategory = fontScaleMediumCategoryClass; + break; + } + case FontScaleCategory.Medium: { + currentFontScaleCategory = fontScaleMediumCategoryClass; + break; + } + case FontScaleCategory.ExtraLarge: { + currentFontScaleCategory = fontScaleExtraLargeCategoryClass; + break; + } + default: { + currentFontScaleCategory = fontScaleMediumCategoryClass; + break; + } + } + + if (oldActiveFontScaleCategory !== currentFontScaleCategory) { + applyRootCssClass(fontScaleCategoryClasses, currentFontScaleCategory); + } + + const oldA11YStatusClass = currentA11YServiceClass; + if (accessibilityServiceObservable.accessibilityServiceEnabled) { + currentA11YServiceClass = a11yServiceEnabledClass; + } else { + currentA11YServiceClass = a11yServiceDisabledClass; + } + + if (oldA11YStatusClass !== currentA11YServiceClass) { + applyRootCssClass(a11yServiceClasses, currentA11YServiceClass); + } +} diff --git a/packages/core/accessibility/accessibility-properties.ts b/packages/core/accessibility/accessibility-properties.ts new file mode 100644 index 000000000..aba31c51a --- /dev/null +++ b/packages/core/accessibility/accessibility-properties.ts @@ -0,0 +1,121 @@ +import { CssProperty, InheritedCssProperty, Property } from '../ui/core/properties'; +import type { View } from '../ui/core/view'; +import { booleanConverter } from '../ui/core/view-base'; +import { Style } from '../ui/styling/style'; +import { AccessibilityLiveRegion, AccessibilityRole, AccessibilityState, AccessibilityTrait } from './accessibility-types'; + +function makePropertyEnumConverter(enumValues) { + return (value: string): T | null => { + if (!value || typeof value !== 'string') { + return null; + } + + for (const [enumKey, enumValue] of Object.entries(enumValues)) { + if (typeof enumKey !== 'string') { + continue; + } + + if (enumKey === value || `${enumValue}`.toLowerCase() === `${value}`.toLowerCase()) { + return enumValue; + } + } + + return null; + }; +} + +export const accessibilityEnabledProperty = new CssProperty({ + name: 'accessible', + cssName: 'a11y-enabled', + valueConverter: booleanConverter, +}); +accessibilityEnabledProperty.register(Style); + +const accessibilityHiddenPropertyName = 'accessibilityHidden'; +const accessibilityHiddenCssName = 'a11y-hidden'; + +export const accessibilityHiddenProperty = global.isIOS + ? new InheritedCssProperty({ + name: accessibilityHiddenPropertyName, + cssName: accessibilityHiddenCssName, + valueConverter: booleanConverter, + }) + : new CssProperty({ + name: accessibilityHiddenPropertyName, + cssName: accessibilityHiddenCssName, + valueConverter: booleanConverter, + }); +accessibilityHiddenProperty.register(Style); + +export const accessibilityIdentifierProperty = new Property({ + name: 'accessibilityIdentifier', +}); + +export const accessibilityRoleProperty = new CssProperty({ + name: 'accessibilityRole', + cssName: 'a11y-role', + valueConverter: makePropertyEnumConverter(AccessibilityRole), +}); +accessibilityRoleProperty.register(Style); + +export const accessibilityStateProperty = new CssProperty({ + name: 'accessibilityState', + cssName: 'a11y-state', + valueConverter: makePropertyEnumConverter(AccessibilityState), +}); +accessibilityStateProperty.register(Style); + +export const accessibilityLabelProperty = new Property({ + name: 'accessibilityLabel', +}); + +export const accessibilityValueProperty = new Property({ + name: 'accessibilityValue', +}); + +export const accessibilityHintProperty = new Property({ + name: 'accessibilityHint', +}); + +export const accessibilityLiveRegionProperty = new CssProperty({ + name: 'accessibilityLiveRegion', + cssName: 'a11y-live-region', + defaultValue: AccessibilityLiveRegion.None, + valueConverter: makePropertyEnumConverter(AccessibilityLiveRegion), +}); +accessibilityLiveRegionProperty.register(Style); + +export const accessibilityTraitsProperty = new Property({ + name: 'accessibilityTraits', +}); + +export const accessibilityLanguageProperty = new CssProperty({ + name: 'accessibilityLanguage', + cssName: 'a11y-lang', +}); +accessibilityLanguageProperty.register(Style); + +export const accessibilityMediaSessionProperty = new CssProperty({ + name: 'accessibilityMediaSession', + cssName: 'a11y-media-session', +}); +accessibilityMediaSessionProperty.register(Style); + +/** + * Represents the observable property backing the accessibilityStep property. + */ +export const accessibilityStepProperty = new CssProperty({ + name: 'accessibilityStep', + cssName: 'a11y-step', + defaultValue: 10, + valueConverter: (v): number => { + const step = parseFloat(v); + + if (isNaN(step) || step <= 0) { + return 10; + } + + return step; + }, +}); +accessibilityStepProperty.register(Style); diff --git a/packages/core/accessibility/accessibility-service-common.ts b/packages/core/accessibility/accessibility-service-common.ts new file mode 100644 index 000000000..8ea7503be --- /dev/null +++ b/packages/core/accessibility/accessibility-service-common.ts @@ -0,0 +1,35 @@ +import { Observable } from '../data/observable'; + +export class SharedA11YObservable extends Observable { + accessibilityServiceEnabled?: boolean; +} + +export const AccessibilityServiceEnabledPropName = 'accessibilityServiceEnabled'; + +export class CommonA11YServiceEnabledObservable extends SharedA11YObservable { + constructor(sharedA11YObservable: SharedA11YObservable) { + super(); + + const ref = new WeakRef(this); + let lastValue: boolean; + + function callback() { + const self = ref?.get(); + if (!self) { + sharedA11YObservable.off(Observable.propertyChangeEvent, callback); + + return; + } + + const newValue = !!sharedA11YObservable.accessibilityServiceEnabled; + if (newValue !== lastValue) { + self.set(AccessibilityServiceEnabledPropName, newValue); + lastValue = newValue; + } + } + + sharedA11YObservable.on(Observable.propertyChangeEvent, callback); + + this.set(AccessibilityServiceEnabledPropName, !!sharedA11YObservable.accessibilityServiceEnabled); + } +} diff --git a/packages/core/accessibility/accessibility-service.android.ts b/packages/core/accessibility/accessibility-service.android.ts new file mode 100644 index 000000000..90a8bd275 --- /dev/null +++ b/packages/core/accessibility/accessibility-service.android.ts @@ -0,0 +1,130 @@ +import * as Application from '../application'; +import { Observable } from '../data/observable'; +import { Trace } from '../trace'; +import * as Utils from '../utils'; +import { CommonA11YServiceEnabledObservable, SharedA11YObservable } from './accessibility-service-common'; + +export function getAndroidAccessibilityManager(): android.view.accessibility.AccessibilityManager | null { + const context = Utils.ad.getApplicationContext() as android.content.Context; + if (!context) { + return null; + } + + return context.getSystemService(android.content.Context.ACCESSIBILITY_SERVICE) as android.view.accessibility.AccessibilityManager; +} + +const accessibilityStateEnabledPropName = 'accessibilityStateEnabled'; +const touchExplorationStateEnabledPropName = 'touchExplorationStateEnabled'; + +class AndroidSharedA11YObservable extends SharedA11YObservable { + [accessibilityStateEnabledPropName]: boolean; + [touchExplorationStateEnabledPropName]: boolean; + + get accessibilityServiceEnabled(): boolean { + return !!this[accessibilityStateEnabledPropName] && !!this[touchExplorationStateEnabledPropName]; + } + + set accessibilityServiceEnabled(v) { + return; + } +} + +let accessibilityStateChangeListener: androidx.core.view.accessibility.AccessibilityManagerCompat.AccessibilityStateChangeListener; +let touchExplorationStateChangeListener: androidx.core.view.accessibility.AccessibilityManagerCompat.TouchExplorationStateChangeListener; +let sharedA11YObservable: AndroidSharedA11YObservable; + +function updateAccessibilityState(): void { + const accessibilityManager = getAndroidAccessibilityManager(); + if (!accessibilityManager) { + sharedA11YObservable.set(accessibilityStateEnabledPropName, false); + sharedA11YObservable.set(touchExplorationStateEnabledPropName, false); + + return; + } + + sharedA11YObservable.set(accessibilityStateEnabledPropName, !!accessibilityManager.isEnabled()); + sharedA11YObservable.set(touchExplorationStateEnabledPropName, !!accessibilityManager.isTouchExplorationEnabled()); +} + +function ensureStateListener(): SharedA11YObservable { + if (sharedA11YObservable) { + return sharedA11YObservable; + } + + const accessibilityManager = getAndroidAccessibilityManager(); + sharedA11YObservable = new AndroidSharedA11YObservable(); + + if (!accessibilityManager) { + sharedA11YObservable.set(accessibilityStateEnabledPropName, false); + sharedA11YObservable.set(touchExplorationStateEnabledPropName, false); + + return sharedA11YObservable; + } + + accessibilityStateChangeListener = new android.view.accessibility.AccessibilityManager.AccessibilityStateChangeListener({ + onAccessibilityStateChanged(enabled) { + updateAccessibilityState(); + + if (Trace.isEnabled()) { + Trace.write(`AccessibilityStateChangeListener state changed to: ${!!enabled}`, Trace.categories.Accessibility); + } + }, + }); + + touchExplorationStateChangeListener = new androidx.core.view.accessibility.AccessibilityManagerCompat.TouchExplorationStateChangeListener({ + onTouchExplorationStateChanged(enabled) { + updateAccessibilityState(); + + if (Trace.isEnabled()) { + Trace.write(`TouchExplorationStateChangeListener state changed to: ${!!enabled}`, Trace.categories.Accessibility); + } + }, + }); + + accessibilityManager.addAccessibilityStateChangeListener(accessibilityStateChangeListener); + androidx.core.view.accessibility.AccessibilityManagerCompat.addTouchExplorationStateChangeListener(accessibilityManager, touchExplorationStateChangeListener); + + updateAccessibilityState(); + + Application.on(Application.resumeEvent, updateAccessibilityState); + + return sharedA11YObservable; +} + +export function isAccessibilityServiceEnabled(): boolean { + return ensureStateListener().accessibilityServiceEnabled; +} + +Application.on(Application.exitEvent, (args: Application.ApplicationEventData) => { + const activity = args.android as android.app.Activity; + if (activity && !activity.isFinishing()) { + return; + } + + const accessibilityManager = getAndroidAccessibilityManager(); + if (accessibilityManager) { + if (accessibilityStateChangeListener) { + accessibilityManager.removeAccessibilityStateChangeListener(accessibilityStateChangeListener); + } + + if (touchExplorationStateChangeListener) { + androidx.core.view.accessibility.AccessibilityManagerCompat.removeTouchExplorationStateChangeListener(accessibilityManager, touchExplorationStateChangeListener); + } + } + + accessibilityStateChangeListener = null; + touchExplorationStateChangeListener = null; + + if (sharedA11YObservable) { + sharedA11YObservable.removeEventListener(Observable.propertyChangeEvent); + sharedA11YObservable = null; + } + + Application.off(Application.resumeEvent, updateAccessibilityState); +}); + +export class AccessibilityServiceEnabledObservable extends CommonA11YServiceEnabledObservable { + constructor() { + super(ensureStateListener()); + } +} diff --git a/packages/core/accessibility/accessibility-service.d.ts b/packages/core/accessibility/accessibility-service.d.ts new file mode 100644 index 000000000..4a7fbd2db --- /dev/null +++ b/packages/core/accessibility/accessibility-service.d.ts @@ -0,0 +1,10 @@ +import { Observable } from '../data/observable'; + +export class AccessibilityServiceEnabledObservable extends Observable { + public readonly accessibilityServiceEnabled?: boolean; +} + +/** + * Get the Android platform's AccessibilityManager. + */ +export function getAndroidAccessibilityManager(): /* android.view.accessibility.AccessibilityManager */ any | null; diff --git a/packages/core/accessibility/accessibility-service.ios.ts b/packages/core/accessibility/accessibility-service.ios.ts new file mode 100644 index 000000000..9bd936c60 --- /dev/null +++ b/packages/core/accessibility/accessibility-service.ios.ts @@ -0,0 +1,75 @@ +import * as Application from '../application'; +import { Observable } from '../data/observable'; +import { Trace } from '../trace'; +import { AccessibilityServiceEnabledPropName, CommonA11YServiceEnabledObservable, SharedA11YObservable } from './accessibility-service-common'; + +export function isAccessibilityServiceEnabled(): boolean { + return getSharedA11YObservable().accessibilityServiceEnabled; +} + +export function getAndroidAccessibilityManager(): null { + return null; +} + +let sharedA11YObservable: SharedA11YObservable; +let nativeObserver; + +function getSharedA11YObservable(): SharedA11YObservable { + if (sharedA11YObservable) { + return sharedA11YObservable; + } + + sharedA11YObservable = new SharedA11YObservable(); + + let isVoiceOverRunning: () => boolean; + if (typeof UIAccessibilityIsVoiceOverRunning === 'function') { + isVoiceOverRunning = UIAccessibilityIsVoiceOverRunning; + } else { + if (typeof UIAccessibilityIsVoiceOverRunning !== 'function') { + Trace.write(`UIAccessibilityIsVoiceOverRunning() - is not a function`, Trace.categories.Accessibility, Trace.messageType.error); + + isVoiceOverRunning = () => false; + } + } + + sharedA11YObservable.set(AccessibilityServiceEnabledPropName, isVoiceOverRunning()); + + let voiceOverStatusChangedNotificationName: string | null = null; + if (typeof UIAccessibilityVoiceOverStatusDidChangeNotification !== 'undefined') { + // iOS 11+ + voiceOverStatusChangedNotificationName = UIAccessibilityVoiceOverStatusDidChangeNotification; + } else if (typeof UIAccessibilityVoiceOverStatusChanged !== 'undefined') { + // iOS <11 + voiceOverStatusChangedNotificationName = UIAccessibilityVoiceOverStatusChanged; + } + + if (voiceOverStatusChangedNotificationName) { + nativeObserver = Application.ios.addNotificationObserver(voiceOverStatusChangedNotificationName, () => { + sharedA11YObservable?.set(AccessibilityServiceEnabledPropName, isVoiceOverRunning()); + }); + + Application.on(Application.exitEvent, () => { + if (nativeObserver) { + Application.ios.removeNotificationObserver(nativeObserver, voiceOverStatusChangedNotificationName); + } + + nativeObserver = null; + + if (sharedA11YObservable) { + sharedA11YObservable.removeEventListener(Observable.propertyChangeEvent); + + sharedA11YObservable = null; + } + }); + } + + Application.on(Application.resumeEvent, () => sharedA11YObservable.set(AccessibilityServiceEnabledPropName, isVoiceOverRunning())); + + return sharedA11YObservable; +} + +export class AccessibilityServiceEnabledObservable extends CommonA11YServiceEnabledObservable { + constructor() { + super(getSharedA11YObservable()); + } +} diff --git a/packages/core/accessibility/accessibility-types.ts b/packages/core/accessibility/accessibility-types.ts new file mode 100644 index 000000000..05431f101 --- /dev/null +++ b/packages/core/accessibility/accessibility-types.ts @@ -0,0 +1,326 @@ +import type { EventData } from '../data/observable'; +import type { View } from '../ui/core/view'; + +export enum AccessibilityTrait { + /** + * The element has no traits. + */ + None = 'none', + + /** + * The element should be treated as a button. + */ + Button = 'button', + + /** + * The element should be treated as a link. + */ + Link = 'link', + + /** + * The element should be treated as a search field. + */ + SearchField = 'search', + + /** + * The element should be treated as an image. + */ + Image = 'image', + + /** + * The element is currently selected. + */ + Selected = 'selected', + + /** + * The element plays its own sound when activated. + */ + PlaysSound = 'plays', + + /** + * The element behaves as a keyboard key. + */ + KeyboardKey = 'key', + + /** + * The element should be treated as static text that cannot change. + */ + StaticText = 'text', + + /** + * The element provides summary information when the application starts. + */ + SummaryElement = 'summary', + + /** + * The element is not enabled and does not respond to user interaction. + */ + NotEnabled = 'disabled', + + /** + * The element frequently updates its label or value. + */ + UpdatesFrequently = 'frequentUpdates', + + /** + * The element starts a media session when it is activated. + */ + StartsMediaSession = 'startsMedia', + + /** + * The element allows continuous adjustment through a range of values. + */ + Adjustable = 'adjustable', + + /** + * The element allows direct touch interaction for VoiceOver users. + */ + AllowsDirectInteraction = 'allowsDirectInteraction', + + /** + * The element should cause an automatic page turn when VoiceOver finishes reading the text within it. + * Note: Requires custom view with accessibilityScroll(...) + */ + CausesPageTurn = 'pageTurn', + + /** + * The element is a header that divides content into sections, such as the title of a navigation bar. + */ + Header = 'header', +} + +export enum AccessibilityRole { + /** + * The element has no traits. + */ + None = 'none', + + /** + * The element should be treated as a button. + */ + Button = 'button', + + /** + * The element should be treated as a link. + */ + Link = 'link', + + /** + * The element should be treated as a search field. + */ + Search = 'search', + + /** + * The element should be treated as an image. + */ + Image = 'image', + + /** + * The element should be treated as a image button. + */ + ImageButton = 'imageButton', + + /** + * The element behaves as a keyboard key. + */ + KeyboardKey = 'keyboardKey', + + /** + * The element should be treated as static text that cannot change. + */ + StaticText = 'textField', + + /** + * The element allows continuous adjustment through a range of values. + */ + Adjustable = 'adjustable', + + /** + * The element provides summary information when the application starts. + */ + Summary = 'summary', + + /** + * The element is a header that divides content into sections, such as the title of a navigation bar. + */ + Header = 'header', + + /** + * The element behaves like a Checkbox + */ + Checkbox = 'checkbox', + + /** + * The element behaves like a ProgressBar + */ + ProgressBar = 'progressBar', + + /** + * The element behaves like a RadioButton + */ + RadioButton = 'radioButton', + + /** + * The element behaves like a SpinButton + */ + SpinButton = 'spinButton', + + /** + * The element behaves like a switch + */ + Switch = 'switch', +} + +export enum AccessibilityState { + Selected = 'selected', + Checked = 'checked', + Unchecked = 'unchecked', + Disabled = 'disabled', +} + +export enum AccessibilityLiveRegion { + None = 'none', + Polite = 'polite', + Assertive = 'assertive', +} + +export interface AccessibilityFocusEventData extends EventData { + object: View; +} + +export type AccessibilityBlurEventData = AccessibilityFocusEventData; + +export interface AccessibilityFocusChangedEventData extends AccessibilityFocusEventData { + value: boolean; +} + +export enum IOSPostAccessibilityNotificationType { + Announcement = 'announcement', + Screen = 'screen', + Layout = 'layout', +} + +export enum AndroidAccessibilityEvent { + /** + * Invalid selection/focus position. + */ + INVALID_POSITION = 'invalid_position', + + /** + * Maximum length of the text fields. + */ + MAX_TEXT_LENGTH = 'max_text_length', + + /** + * Represents the event of clicking on a android.view.View like android.widget.Button, android.widget.CompoundButton, etc. + */ + VIEW_CLICKED = 'view_clicked', + + /** + * Represents the event of long clicking on a android.view.View like android.widget.Button, android.widget.CompoundButton, etc. + */ + VIEW_LONG_CLICKED = 'view_long_clicked', + + /** + * Represents the event of selecting an item usually in the context of an android.widget.AdapterView. + */ + VIEW_SELECTED = 'view_selected', + + /** + * Represents the event of setting input focus of a android.view.View. + */ + VIEW_FOCUSED = 'view_focused', + + /** + * Represents the event of changing the text of an android.widget.EditText. + */ + VIEW_TEXT_CHANGED = 'view_text_changed', + + /** + * Represents the event of opening a android.widget.PopupWindow, android.view.Menu, android.app.Dialog, etc. + */ + WINDOW_STATE_CHANGED = 'window_state_changed', + + /** + * Represents the event showing a android.app.Notification. + */ + NOTIFICATION_STATE_CHANGED = 'notification_state_changed', + + /** + * Represents the event of a hover enter over a android.view.View. + */ + VIEW_HOVER_ENTER = 'view_hover_enter', + + /** + * Represents the event of a hover exit over a android.view.View. + */ + VIEW_HOVER_EXIT = 'view_hover_exit', + /** + * Represents the event of starting a touch exploration gesture. + */ + TOUCH_EXPLORATION_GESTURE_START = 'touch_exploration_gesture_start', + /** + * Represents the event of ending a touch exploration gesture. + */ + TOUCH_EXPLORATION_GESTURE_END = 'touch_exploration_gesture_end', + + /** + * Represents the event of changing the content of a window and more specifically the sub-tree rooted at the event's source. + */ + WINDOW_CONTENT_CHANGED = 'window_content_changed', + + /** + * Represents the event of scrolling a view. + */ + VIEW_SCROLLED = 'view_scrolled', + + /** + * Represents the event of changing the selection in an android.widget.EditText. + */ + VIEW_TEXT_SELECTION_CHANGED = 'view_text_selection_changed', + + /** + * Represents the event of an application making an announcement. + */ + ANNOUNCEMENT = 'announcement', + + /** + * Represents the event of gaining accessibility focus. + */ + VIEW_ACCESSIBILITY_FOCUSED = 'view_accessibility_focused', + + /** + * Represents the event of clearing accessibility focus. + */ + VIEW_ACCESSIBILITY_FOCUS_CLEARED = 'view_accessibility_focus_cleared', + + /** + * Represents the event of traversing the text of a view at a given movement granularity. + */ + VIEW_TEXT_TRAVERSED_AT_MOVEMENT_GRANULARITY = 'view_text_traversed_at_movement_granularity', + + /** + * Represents the event of beginning gesture detection. + */ + GESTURE_DETECTION_START = 'gesture_detection_start', + + /** + * Represents the event of ending gesture detection. + */ + GESTURE_DETECTION_END = 'gesture_detection_end', + + /** + * Represents the event of the user starting to touch the screen. + */ + TOUCH_INTERACTION_START = 'touch_interaction_start', + + /** + * Represents the event of the user ending to touch the screen. + */ + TOUCH_INTERACTION_END = 'touch_interaction_end', + + /** + * Mask for AccessibilityEvent all types. + */ + ALL_MASK = 'all', +} diff --git a/packages/core/accessibility/font-scale-common.ts b/packages/core/accessibility/font-scale-common.ts new file mode 100644 index 000000000..04964431b --- /dev/null +++ b/packages/core/accessibility/font-scale-common.ts @@ -0,0 +1,15 @@ +export const VALID_FONT_SCALES = global.isIOS // iOS supports a wider number of font scales than Android does. + ? [0.5, 0.7, 0.85, 1, 1.15, 1.3, 1.5, 2, 2.5, 3, 3.5, 4] + : [0.85, 1, 1.15, 1.3]; + +export function getClosestValidFontScale(fontScale: number): number { + fontScale = Number(fontScale) || 1; + + return VALID_FONT_SCALES.sort((a, b) => Math.abs(fontScale - a) - Math.abs(fontScale - b)).shift(); +} + +export enum FontScaleCategory { + ExtraSmall = 'extra-small', + Medium = 'medium', + ExtraLarge = 'extra-large', +} diff --git a/packages/core/accessibility/font-scale.android.ts b/packages/core/accessibility/font-scale.android.ts new file mode 100644 index 000000000..0adec6c75 --- /dev/null +++ b/packages/core/accessibility/font-scale.android.ts @@ -0,0 +1,66 @@ +import * as Application from '../application'; +import { FontScaleCategory, getClosestValidFontScale } from './font-scale-common'; +export * from './font-scale-common'; + +let currentFontScale: number = null; +function fontScaleChanged(origFontScale: number) { + const oldValue = currentFontScale; + currentFontScale = getClosestValidFontScale(origFontScale); + + if (oldValue !== currentFontScale) { + Application.notify({ + eventName: Application.fontScaleChangedEvent, + object: Application, + }); + } +} + +export function getCurrentFontScale(): number { + setupConfigListener(); + + return currentFontScale; +} + +export function getFontScaleCategory(): FontScaleCategory { + return FontScaleCategory.Medium; +} + +function useAndroidFontScale() { + fontScaleChanged(Number(Application.android.context.getResources().getConfiguration().fontScale)); +} + +let configChangedCallback: android.content.ComponentCallbacks2; +function setupConfigListener() { + if (configChangedCallback) { + return; + } + + Application.off(Application.launchEvent, setupConfigListener); + const context = Application.android?.context as android.content.Context; + if (!context) { + Application.on(Application.launchEvent, setupConfigListener); + + return; + } + + useAndroidFontScale(); + + configChangedCallback = new android.content.ComponentCallbacks2({ + onLowMemory() { + // Dummy + }, + onTrimMemory() { + // Dummy + }, + onConfigurationChanged(newConfig: android.content.res.Configuration) { + fontScaleChanged(Number(newConfig.fontScale)); + }, + }); + + context.registerComponentCallbacks(configChangedCallback); + Application.on(Application.resumeEvent, useAndroidFontScale); +} + +export function initAccessibilityFontScale(): void { + setupConfigListener(); +} diff --git a/packages/core/accessibility/font-scale.d.ts b/packages/core/accessibility/font-scale.d.ts new file mode 100644 index 000000000..bfc8635f2 --- /dev/null +++ b/packages/core/accessibility/font-scale.d.ts @@ -0,0 +1,9 @@ +export const VALID_FONT_SCALES: number[]; +export function getCurrentFontScale(): number; +export enum FontScaleCategory { + ExtraSmall = 'extra-small', + Medium = 'medium', + ExtraLarge = 'extra-large', +} +export function getFontScaleCategory(): FontScaleCategory; +export function initAccessibilityFontScale(): void; diff --git a/packages/core/accessibility/font-scale.ios.ts b/packages/core/accessibility/font-scale.ios.ts new file mode 100644 index 000000000..2b9c6f56e --- /dev/null +++ b/packages/core/accessibility/font-scale.ios.ts @@ -0,0 +1,109 @@ +import * as Application from '../application'; +import { FontScaleCategory, getClosestValidFontScale } from './font-scale-common'; +export * from './font-scale-common'; + +let currentFontScale: number = null; +function fontScaleChanged(origFontScale: number) { + const oldValue = currentFontScale; + currentFontScale = getClosestValidFontScale(origFontScale); + + if (oldValue !== currentFontScale) { + Application.notify({ + eventName: Application.fontScaleChangedEvent, + object: Application, + }); + } +} + +export function getCurrentFontScale(): number { + setupConfigListener(); + + return currentFontScale; +} + +export function getFontScaleCategory(): FontScaleCategory { + if (currentFontScale < 0.85) { + return FontScaleCategory.ExtraSmall; + } + + if (currentFontScale > 1.5) { + return FontScaleCategory.ExtraLarge; + } + + return FontScaleCategory.Medium; +} + +const sizeMap = new Map([ + [UIContentSizeCategoryExtraSmall, 0.5], + [UIContentSizeCategorySmall, 0.7], + [UIContentSizeCategoryMedium, 0.85], + [UIContentSizeCategoryLarge, 1], + [UIContentSizeCategoryExtraLarge, 1.15], + [UIContentSizeCategoryExtraExtraLarge, 1.3], + [UIContentSizeCategoryExtraExtraExtraLarge, 1.5], + [UIContentSizeCategoryAccessibilityMedium, 2], + [UIContentSizeCategoryAccessibilityLarge, 2.5], + [UIContentSizeCategoryAccessibilityExtraLarge, 3], + [UIContentSizeCategoryAccessibilityExtraExtraLarge, 3.5], + [UIContentSizeCategoryAccessibilityExtraExtraExtraLarge, 4], +]); + +function contentSizeUpdated(fontSize: string) { + if (sizeMap.has(fontSize)) { + fontScaleChanged(sizeMap.get(fontSize)); + + return; + } + + fontScaleChanged(1); +} + +function useIOSFontScale() { + if (Application.ios.nativeApp) { + contentSizeUpdated(Application.ios.nativeApp.preferredContentSizeCategory); + } else { + fontScaleChanged(1); + } +} + +let fontSizeObserver; +function setupConfigListener(attempt = 0) { + if (fontSizeObserver) { + return; + } + + if (!Application.ios.nativeApp) { + if (attempt > 100) { + fontScaleChanged(1); + + return; + } + + // Couldn't get launchEvent to trigger. + setTimeout(() => setupConfigListener(attempt + 1), 1); + + return; + } + + fontSizeObserver = Application.ios.addNotificationObserver(UIContentSizeCategoryDidChangeNotification, (args) => { + const fontSize = args.userInfo.valueForKey(UIContentSizeCategoryNewValueKey); + contentSizeUpdated(fontSize); + }); + + Application.on(Application.exitEvent, () => { + if (fontSizeObserver) { + Application.ios.removeNotificationObserver(fontSizeObserver, UIContentSizeCategoryDidChangeNotification); + fontSizeObserver = null; + } + + Application.off(Application.resumeEvent, useIOSFontScale); + }); + + Application.on(Application.resumeEvent, useIOSFontScale); + + useIOSFontScale(); +} + +export function initAccessibilityFontScale(): void { + setupConfigListener(); +} diff --git a/packages/core/accessibility/index.android.ts b/packages/core/accessibility/index.android.ts new file mode 100644 index 000000000..eeb19d5e6 --- /dev/null +++ b/packages/core/accessibility/index.android.ts @@ -0,0 +1,667 @@ +import * as Application from '../application'; +import { Trace } from '../trace'; +import type { View } from '../ui/core/view'; +import { GestureTypes } from '../ui/gestures'; +import { notifyAccessibilityFocusState } from './accessibility-common'; +import { getAndroidAccessibilityManager } from './accessibility-service'; +import { AccessibilityRole, AccessibilityState, AndroidAccessibilityEvent } from './accessibility-types'; + +export * from './accessibility-common'; +export * from './accessibility-types'; +export * from './font-scale'; + +let clickableRolesMap = new Set(); + +let lastFocusedView: WeakRef; +function accessibilityEventHelper(view: View, eventType: number) { + const eventName = accessibilityEventTypeMap.get(eventType); + if (!isAccessibilityServiceEnabled()) { + if (Trace.isEnabled()) { + Trace.write(`accessibilityEventHelper: Service not active`, Trace.categories.Accessibility); + } + + return; + } + + if (!eventName) { + Trace.write(`accessibilityEventHelper: unknown eventType: ${eventType}`, Trace.categories.Accessibility, Trace.messageType.error); + + return; + } + + if (!view) { + if (Trace.isEnabled()) { + Trace.write(`accessibilityEventHelper: no owner: ${eventName}`, Trace.categories.Accessibility); + } + + return; + } + + const androidView = view.nativeViewProtected as android.view.View; + if (!androidView) { + if (Trace.isEnabled()) { + Trace.write(`accessibilityEventHelper: no nativeView`, Trace.categories.Accessibility); + } + + return; + } + + switch (eventType) { + case android.view.accessibility.AccessibilityEvent.TYPE_VIEW_CLICKED: { + /** + * Android API >= 26 handles accessibility tap-events by converting them to TYPE_VIEW_CLICKED + * These aren't triggered for custom tap events in NativeScript. + */ + if (android.os.Build.VERSION.SDK_INT >= 26) { + // Find all tap gestures and trigger them. + for (const tapGesture of view.getGestureObservers(GestureTypes.tap) ?? []) { + tapGesture.callback({ + android: view.android, + eventName: 'tap', + ios: null, + object: view, + type: GestureTypes.tap, + view: view, + }); + } + } + + return; + } + case android.view.accessibility.AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUSED: { + const lastView = lastFocusedView?.get(); + if (lastView && view !== lastView) { + const lastAndroidView = lastView.nativeViewProtected as android.view.View; + if (lastAndroidView) { + lastAndroidView.clearFocus(); + lastFocusedView = null; + + notifyAccessibilityFocusState(lastView, false, true); + } + } + + lastFocusedView = new WeakRef(view); + + notifyAccessibilityFocusState(view, true, false); + + return; + } + case android.view.accessibility.AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUS_CLEARED: { + const lastView = lastFocusedView?.get(); + if (lastView && view === lastView) { + lastFocusedView = null; + androidView.clearFocus(); + } + + notifyAccessibilityFocusState(view, false, true); + + return; + } + } +} + +let TNSAccessibilityDelegate: android.view.View.androidviewViewAccessibilityDelegate; + +const androidViewToTNSView = new WeakMap>(); + +let accessibilityEventMap: Map; +let accessibilityEventTypeMap: Map; + +function ensureNativeClasses() { + if (TNSAccessibilityDelegate) { + return; + } + + // WORKAROUND: Typing refers to android.view.View.androidviewViewAccessibilityDelegate but it is called android.view.View.AccessibilityDelegate at runtime + const AccessibilityDelegate: typeof android.view.View.androidviewViewAccessibilityDelegate = android.view.View['AccessibilityDelegate']; + + const RoleTypeMap = new Map([ + [AccessibilityRole.Button, android.widget.Button.class.getName()], + [AccessibilityRole.Search, android.widget.EditText.class.getName()], + [AccessibilityRole.Image, android.widget.ImageView.class.getName()], + [AccessibilityRole.ImageButton, android.widget.ImageButton.class.getName()], + [AccessibilityRole.KeyboardKey, android.inputmethodservice.Keyboard.Key.class.getName()], + [AccessibilityRole.StaticText, android.widget.TextView.class.getName()], + [AccessibilityRole.Adjustable, android.widget.SeekBar.class.getName()], + [AccessibilityRole.Checkbox, android.widget.CheckBox.class.getName()], + [AccessibilityRole.RadioButton, android.widget.RadioButton.class.getName()], + [AccessibilityRole.SpinButton, android.widget.Spinner.class.getName()], + [AccessibilityRole.Switch, android.widget.Switch.class.getName()], + [AccessibilityRole.ProgressBar, android.widget.ProgressBar.class.getName()], + ]); + + clickableRolesMap = new Set([AccessibilityRole.Button, AccessibilityRole.ImageButton]); + + const ignoreRoleTypesForTrace = new Set([AccessibilityRole.Header, AccessibilityRole.Link, AccessibilityRole.None, AccessibilityRole.Summary]); + + @NativeClass() + class TNSAccessibilityDelegateImpl extends AccessibilityDelegate { + constructor() { + super(); + + return global.__native(this); + } + + private getTnsView(androidView: android.view.View) { + const view = androidViewToTNSView.get(androidView)?.get(); + if (!view) { + androidViewToTNSView.delete(androidView); + + return null; + } + + return view; + } + + public onInitializeAccessibilityNodeInfo(host: android.view.View, info: android.view.accessibility.AccessibilityNodeInfo) { + super.onInitializeAccessibilityNodeInfo(host, info); + + const view = this.getTnsView(host); + if (!view) { + if (Trace.isEnabled()) { + Trace.write(`onInitializeAccessibilityNodeInfo ${host} ${info} no tns-view`, Trace.categories.Accessibility); + } + + return; + } + + const accessibilityRole = view.accessibilityRole; + if (accessibilityRole) { + const androidClassName = RoleTypeMap.get(accessibilityRole); + if (androidClassName) { + const oldClassName = info.getClassName() || (android.os.Build.VERSION.SDK_INT >= 28 && host.getAccessibilityClassName()) || null; + info.setClassName(androidClassName); + + if (Trace.isEnabled()) { + Trace.write(`${view}.accessibilityRole = "${accessibilityRole}" is mapped to "${androidClassName}" (was ${oldClassName}). ${info.getClassName()}`, Trace.categories.Accessibility); + } + } else if (!ignoreRoleTypesForTrace.has(accessibilityRole)) { + if (Trace.isEnabled()) { + Trace.write(`${view}.accessibilityRole = "${accessibilityRole}" is unknown`, Trace.categories.Accessibility); + } + } + + if (clickableRolesMap.has(accessibilityRole)) { + if (Trace.isEnabled()) { + Trace.write(`onInitializeAccessibilityNodeInfo ${view} - set clickable role=${accessibilityRole}`, Trace.categories.Accessibility); + } + + info.setClickable(true); + } + + if (android.os.Build.VERSION.SDK_INT >= 28) { + if (accessibilityRole === AccessibilityRole.Header) { + if (Trace.isEnabled()) { + Trace.write(`onInitializeAccessibilityNodeInfo ${view} - set heading role=${accessibilityRole}`, Trace.categories.Accessibility); + } + + info.setHeading(true); + } else if (host.isAccessibilityHeading()) { + if (Trace.isEnabled()) { + Trace.write(`onInitializeAccessibilityNodeInfo ${view} - set heading from host`, Trace.categories.Accessibility); + } + + info.setHeading(true); + } else { + if (Trace.isEnabled()) { + Trace.write(`onInitializeAccessibilityNodeInfo ${view} - set not heading`, Trace.categories.Accessibility); + } + + info.setHeading(false); + } + } + + switch (accessibilityRole) { + case AccessibilityRole.Switch: + case AccessibilityRole.RadioButton: + case AccessibilityRole.Checkbox: { + if (Trace.isEnabled()) { + Trace.write(`onInitializeAccessibilityNodeInfo ${view} - set checkable and check=${view.accessibilityState === AccessibilityState.Checked}`, Trace.categories.Accessibility); + } + + info.setCheckable(true); + info.setChecked(view.accessibilityState === AccessibilityState.Checked); + break; + } + default: { + if (Trace.isEnabled()) { + Trace.write(`onInitializeAccessibilityNodeInfo ${view} - set enabled=${view.accessibilityState !== AccessibilityState.Disabled} and selected=${view.accessibilityState === AccessibilityState.Selected}`, Trace.categories.Accessibility); + } + + info.setEnabled(view.accessibilityState !== AccessibilityState.Disabled); + info.setSelected(view.accessibilityState === AccessibilityState.Selected); + break; + } + } + } + + if (view.accessible === true) { + info.setFocusable(true); + } + } + + public sendAccessibilityEvent(host: android.view.ViewGroup, eventType: number) { + super.sendAccessibilityEvent(host, eventType); + const view = this.getTnsView(host); + if (!view) { + console.log(`skip - ${host} - ${accessibilityEventTypeMap.get(eventType)}`); + + return; + } + + try { + accessibilityEventHelper(view, eventType); + } catch (err) { + console.error(err); + } + } + } + + TNSAccessibilityDelegate = new TNSAccessibilityDelegateImpl(); + + accessibilityEventMap = new Map([ + /** + * Invalid selection/focus position. + */ + [AndroidAccessibilityEvent.INVALID_POSITION, android.view.accessibility.AccessibilityEvent.INVALID_POSITION], + /** + * Maximum length of the text fields. + */ + [AndroidAccessibilityEvent.MAX_TEXT_LENGTH, android.view.accessibility.AccessibilityEvent.MAX_TEXT_LENGTH], + /** + * Represents the event of clicking on a android.view.View like android.widget.Button, android.widget.CompoundButton, etc. + */ + [AndroidAccessibilityEvent.VIEW_CLICKED, android.view.accessibility.AccessibilityEvent.TYPE_VIEW_CLICKED], + /** + * Represents the event of long clicking on a android.view.View like android.widget.Button, android.widget.CompoundButton, etc. + */ + [AndroidAccessibilityEvent.VIEW_LONG_CLICKED, android.view.accessibility.AccessibilityEvent.TYPE_VIEW_LONG_CLICKED], + /** + * Represents the event of selecting an item usually in the context of an android.widget.AdapterView. + */ + [AndroidAccessibilityEvent.VIEW_SELECTED, android.view.accessibility.AccessibilityEvent.TYPE_VIEW_SELECTED], + /** + * Represents the event of setting input focus of a android.view.View. + */ + [AndroidAccessibilityEvent.VIEW_FOCUSED, android.view.accessibility.AccessibilityEvent.TYPE_VIEW_FOCUSED], + /** + * Represents the event of changing the text of an android.widget.EditText. + */ + [AndroidAccessibilityEvent.VIEW_TEXT_CHANGED, android.view.accessibility.AccessibilityEvent.TYPE_VIEW_TEXT_CHANGED], + /** + * Represents the event of opening a android.widget.PopupWindow, android.view.Menu, android.app.Dialog, etc. + */ + [AndroidAccessibilityEvent.WINDOW_STATE_CHANGED, android.view.accessibility.AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED], + /** + * Represents the event showing a android.app.Notification. + */ + [AndroidAccessibilityEvent.NOTIFICATION_STATE_CHANGED, android.view.accessibility.AccessibilityEvent.TYPE_NOTIFICATION_STATE_CHANGED], + /** + * Represents the event of a hover enter over a android.view.View. + */ + [AndroidAccessibilityEvent.VIEW_HOVER_ENTER, android.view.accessibility.AccessibilityEvent.TYPE_VIEW_HOVER_ENTER], + /** + * Represents the event of a hover exit over a android.view.View. + */ + [AndroidAccessibilityEvent.VIEW_HOVER_EXIT, android.view.accessibility.AccessibilityEvent.TYPE_VIEW_HOVER_EXIT], + /** + * Represents the event of starting a touch exploration gesture. + */ + [AndroidAccessibilityEvent.TOUCH_EXPLORATION_GESTURE_START, android.view.accessibility.AccessibilityEvent.TYPE_TOUCH_EXPLORATION_GESTURE_START], + /** + * Represents the event of ending a touch exploration gesture. + */ + [AndroidAccessibilityEvent.TOUCH_EXPLORATION_GESTURE_END, android.view.accessibility.AccessibilityEvent.TYPE_TOUCH_EXPLORATION_GESTURE_END], + /** + * Represents the event of changing the content of a window and more specifically the sub-tree rooted at the event's source. + */ + [AndroidAccessibilityEvent.WINDOW_CONTENT_CHANGED, android.view.accessibility.AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED], + /** + * Represents the event of scrolling a view. + */ + [AndroidAccessibilityEvent.VIEW_SCROLLED, android.view.accessibility.AccessibilityEvent.TYPE_VIEW_SCROLLED], + /** + * Represents the event of changing the selection in an android.widget.EditText. + */ + [AndroidAccessibilityEvent.VIEW_TEXT_SELECTION_CHANGED, android.view.accessibility.AccessibilityEvent.TYPE_VIEW_TEXT_SELECTION_CHANGED], + /** + * Represents the event of an application making an announcement. + */ + [AndroidAccessibilityEvent.ANNOUNCEMENT, android.view.accessibility.AccessibilityEvent.TYPE_ANNOUNCEMENT], + /** + * Represents the event of gaining accessibility focus. + */ + [AndroidAccessibilityEvent.VIEW_ACCESSIBILITY_FOCUSED, android.view.accessibility.AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUSED], + /** + * Represents the event of clearing accessibility focus. + */ + [AndroidAccessibilityEvent.VIEW_ACCESSIBILITY_FOCUS_CLEARED, android.view.accessibility.AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUS_CLEARED], + /** + * Represents the event of traversing the text of a view at a given movement granularity. + */ + [AndroidAccessibilityEvent.VIEW_TEXT_TRAVERSED_AT_MOVEMENT_GRANULARITY, android.view.accessibility.AccessibilityEvent.TYPE_VIEW_TEXT_TRAVERSED_AT_MOVEMENT_GRANULARITY], + /** + * Represents the event of beginning gesture detection. + */ + [AndroidAccessibilityEvent.GESTURE_DETECTION_START, android.view.accessibility.AccessibilityEvent.TYPE_GESTURE_DETECTION_START], + /** + * Represents the event of ending gesture detection. + */ + [AndroidAccessibilityEvent.GESTURE_DETECTION_END, android.view.accessibility.AccessibilityEvent.TYPE_GESTURE_DETECTION_END], + /** + * Represents the event of the user starting to touch the screen. + */ + [AndroidAccessibilityEvent.TOUCH_INTERACTION_START, android.view.accessibility.AccessibilityEvent.TYPE_TOUCH_INTERACTION_START], + /** + * Represents the event of the user ending to touch the screen. + */ + [AndroidAccessibilityEvent.TOUCH_INTERACTION_END, android.view.accessibility.AccessibilityEvent.TYPE_TOUCH_INTERACTION_END], + /** + * Mask for AccessibilityEvent all types. + */ + [AndroidAccessibilityEvent.ALL_MASK, android.view.accessibility.AccessibilityEvent.TYPES_ALL_MASK], + ]); + + accessibilityEventTypeMap = new Map([...accessibilityEventMap].map(([k, v]) => [v, k])); +} + +let accessibilityStateChangeListener: androidx.core.view.accessibility.AccessibilityManagerCompat.AccessibilityStateChangeListener; +let touchExplorationStateChangeListener: androidx.core.view.accessibility.AccessibilityManagerCompat.TouchExplorationStateChangeListener; + +function updateAccessibilityServiceState() { + const accessibilityManager = getAndroidAccessibilityManager(); + if (!accessibilityManager) { + return; + } + + accessibilityServiceEnabled = !!accessibilityManager.isEnabled() && !!accessibilityManager.isTouchExplorationEnabled(); +} + +let accessibilityServiceEnabled: boolean; +export function isAccessibilityServiceEnabled(): boolean { + if (typeof accessibilityServiceEnabled === 'boolean') { + return accessibilityServiceEnabled; + } + + const accessibilityManager = getAndroidAccessibilityManager(); + accessibilityStateChangeListener = new androidx.core.view.accessibility.AccessibilityManagerCompat.AccessibilityStateChangeListener({ + onAccessibilityStateChanged(enabled) { + updateAccessibilityServiceState(); + + if (Trace.isEnabled()) { + Trace.write(`AccessibilityStateChangeListener state changed to: ${!!enabled}`, Trace.categories.Accessibility); + } + }, + }); + + touchExplorationStateChangeListener = new androidx.core.view.accessibility.AccessibilityManagerCompat.TouchExplorationStateChangeListener({ + onTouchExplorationStateChanged(enabled) { + updateAccessibilityServiceState(); + + if (Trace.isEnabled()) { + Trace.write(`TouchExplorationStateChangeListener state changed to: ${!!enabled}`, Trace.categories.Accessibility); + } + }, + }); + + androidx.core.view.accessibility.AccessibilityManagerCompat.addAccessibilityStateChangeListener(accessibilityManager, accessibilityStateChangeListener); + androidx.core.view.accessibility.AccessibilityManagerCompat.addTouchExplorationStateChangeListener(accessibilityManager, touchExplorationStateChangeListener); + + updateAccessibilityServiceState(); + + Application.on(Application.exitEvent, (args: Application.ApplicationEventData) => { + const activity = args.android as android.app.Activity; + if (activity && !activity.isFinishing()) { + return; + } + + const accessibilityManager = getAndroidAccessibilityManager(); + if (accessibilityManager) { + if (accessibilityStateChangeListener) { + androidx.core.view.accessibility.AccessibilityManagerCompat.removeAccessibilityStateChangeListener(accessibilityManager, accessibilityStateChangeListener); + } + + if (touchExplorationStateChangeListener) { + androidx.core.view.accessibility.AccessibilityManagerCompat.removeTouchExplorationStateChangeListener(accessibilityManager, touchExplorationStateChangeListener); + } + } + + accessibilityStateChangeListener = null; + touchExplorationStateChangeListener = null; + + Application.off(Application.resumeEvent, updateAccessibilityServiceState); + }); + + Application.on(Application.resumeEvent, updateAccessibilityServiceState); + + return accessibilityServiceEnabled; +} + +export function setupAccessibleView(view: View): void { + updateAccessibilityProperties(view); +} + +export function updateAccessibilityProperties(view: View): void { + if (!view.nativeViewProtected) { + return; + } + + setAccessibilityDelegate(view); + applyContentDescription(view); +} + +export function sendAccessibilityEvent(view: View, eventType: AndroidAccessibilityEvent, text?: string): void { + if (!isAccessibilityServiceEnabled()) { + return; + } + + const cls = `sendAccessibilityEvent(${view}, ${eventType}, ${text})`; + + const androidView = view.nativeViewProtected as android.view.View; + if (!androidView) { + if (Trace.isEnabled()) { + Trace.write(`${cls}: no nativeView`, Trace.categories.Accessibility); + } + + return; + } + + if (!eventType) { + if (Trace.isEnabled()) { + Trace.write(`${cls}: no eventName provided`, Trace.categories.Accessibility); + } + + return; + } + + if (!isAccessibilityServiceEnabled()) { + if (Trace.isEnabled()) { + Trace.write(`${cls} - TalkBack not enabled`, Trace.categories.Accessibility); + } + + return; + } + + const accessibilityManager = getAndroidAccessibilityManager(); + if (!accessibilityManager?.isEnabled()) { + if (Trace.isEnabled()) { + Trace.write(`${cls} - accessibility service not enabled`, Trace.categories.Accessibility); + } + + return; + } + + if (!accessibilityEventMap.has(eventType)) { + if (Trace.isEnabled()) { + Trace.write(`${cls} - unknown event`, Trace.categories.Accessibility); + } + + return; + } + + const eventInt = accessibilityEventMap.get(eventType); + if (!text) { + return androidView.sendAccessibilityEvent(eventInt); + } + + const accessibilityEvent = android.view.accessibility.AccessibilityEvent.obtain(eventInt); + accessibilityEvent.setSource(androidView); + + accessibilityEvent.getText().clear(); + + if (!text) { + applyContentDescription(view); + + text = androidView.getContentDescription() || view['title']; + if (Trace.isEnabled()) { + Trace.write(`${cls} - text not provided use androidView.getContentDescription() - ${text}`, Trace.categories.Accessibility); + } + } + + if (Trace.isEnabled()) { + Trace.write(`${cls}: send event with text: '${JSON.stringify(text)}'`, Trace.categories.Accessibility); + } + + if (text) { + accessibilityEvent.getText().add(text); + } + + accessibilityManager.sendAccessibilityEvent(accessibilityEvent); +} + +export function updateContentDescription(view: View, forceUpdate?: boolean): string | null { + if (!view.nativeViewProtected) { + return; + } + + return applyContentDescription(view, forceUpdate); +} + +function setAccessibilityDelegate(view: View): void { + if (!view.nativeViewProtected) { + return; + } + + ensureNativeClasses(); + + const androidView = view.nativeViewProtected as android.view.View; + if (!androidView) { + return; + } + + androidViewToTNSView.set(androidView, new WeakRef(view)); + + const hasOldDelegate = androidView.getAccessibilityDelegate() === TNSAccessibilityDelegate; + + if (hasOldDelegate) { + return; + } + + androidView.setAccessibilityDelegate(TNSAccessibilityDelegate); +} + +function applyContentDescription(view: View, forceUpdate?: boolean) { + let androidView = view.nativeViewProtected as android.view.View; + if (!androidView) { + return; + } + + if (androidView instanceof androidx.appcompat.widget.Toolbar) { + const numChildren = androidView.getChildCount(); + + for (let i = 0; i < numChildren; i += 1) { + const childAndroidView = androidView.getChildAt(i); + if (childAndroidView instanceof androidx.appcompat.widget.AppCompatTextView) { + androidView = childAndroidView; + break; + } + } + } + + const cls = `applyContentDescription(${view})`; + if (!androidView) { + return null; + } + + const titleValue = view['title'] as string; + const textValue = view['text'] as string; + + if (!forceUpdate && view._androidContentDescriptionUpdated === false && textValue === view['_lastText'] && titleValue === view['_lastTitle']) { + // prevent updating this too much + return androidView.getContentDescription(); + } + + const contentDescriptionBuilder = new Array(); + + // Workaround: TalkBack won't read the checked state for fake Switch. + if (view.accessibilityRole === AccessibilityRole.Switch) { + const androidSwitch = new android.widget.Switch(Application.android.context); + if (view.accessibilityState === AccessibilityState.Checked) { + contentDescriptionBuilder.push(androidSwitch.getTextOn()); + } else { + contentDescriptionBuilder.push(androidSwitch.getTextOff()); + } + } + + if (view.accessibilityLabel) { + if (Trace.isEnabled()) { + Trace.write(`${cls} - have accessibilityLabel`, Trace.categories.Accessibility); + } + + contentDescriptionBuilder.push(`${view.accessibilityLabel}`); + } + + if (view.accessibilityValue) { + if (Trace.isEnabled()) { + Trace.write(`${cls} - have accessibilityValue`, Trace.categories.Accessibility); + } + + contentDescriptionBuilder.push(`${view.accessibilityValue}`); + } else if (textValue) { + if (textValue !== view.accessibilityLabel) { + if (Trace.isEnabled()) { + Trace.write(`${cls} - don't have accessibilityValue - use 'text' value`, Trace.categories.Accessibility); + } + + contentDescriptionBuilder.push(`${textValue}`); + } + } else if (titleValue) { + if (titleValue !== view.accessibilityLabel) { + if (Trace.isEnabled()) { + Trace.write(`${cls} - don't have accessibilityValue - use 'title' value`, Trace.categories.Accessibility); + } + + contentDescriptionBuilder.push(`${titleValue}`); + } + } + + if (view.accessibilityHint) { + if (Trace.isEnabled()) { + Trace.write(`${cls} - have accessibilityHint`, Trace.categories.Accessibility); + } + + contentDescriptionBuilder.push(`${view.accessibilityHint}`); + } + + const contentDescription = contentDescriptionBuilder.join('. ').trim().replace(/^\.$/, ''); + + if (contentDescription) { + if (Trace.isEnabled()) { + Trace.write(`${cls} - set to "${contentDescription}"`, Trace.categories.Accessibility); + } + + androidView.setContentDescription(contentDescription); + } else { + if (Trace.isEnabled()) { + Trace.write(`${cls} - remove value`, Trace.categories.Accessibility); + } + + androidView.setContentDescription(null); + } + + view['_lastTitle'] = titleValue; + view['_lastText'] = textValue; + view._androidContentDescriptionUpdated = false; + + return contentDescription; +} diff --git a/packages/core/accessibility/index.d.ts b/packages/core/accessibility/index.d.ts new file mode 100644 index 000000000..fc4e1ec68 --- /dev/null +++ b/packages/core/accessibility/index.d.ts @@ -0,0 +1,36 @@ +import { View } from '../ui/core/view'; +import { AndroidAccessibilityEvent } from './accessibility-types'; + +export * from './accessibility-common'; +export * from './accessibility-types'; +export * from './font-scale'; + +/** + * Initialize accessibility for View. This should be called on loaded-event. + */ +export function setupAccessibleView(view: View): void; + +/** + * Update accessibility properties on nativeView + */ +export function updateAccessibilityProperties(view: View): void; + +/** + * Android helper function for triggering accessibility events + */ +export function sendAccessibilityEvent(View: View, eventName: AndroidAccessibilityEvent, text?: string): void; + +/** + * Update the content description for android views + */ +export function updateContentDescription(View: View, forceUpdate?: boolean): string | null; + +/** + * Is Android TalkBack or iOS VoiceOver enabled? + */ +export function isAccessibilityServiceEnabled(): boolean; + +/** + * Find the last view focused on a page. + */ +export function getLastFocusedViewOnPage(page: Page): View | null; diff --git a/packages/core/accessibility/index.ios.ts b/packages/core/accessibility/index.ios.ts new file mode 100644 index 000000000..6c2d80397 --- /dev/null +++ b/packages/core/accessibility/index.ios.ts @@ -0,0 +1,264 @@ +import * as Application from '../application'; +import type { View } from '../ui/core/view'; +import { notifyAccessibilityFocusState } from './accessibility-common'; +import { AccessibilityLiveRegion, AccessibilityRole, AccessibilityState, AccessibilityTrait } from './accessibility-types'; + +export * from './accessibility-common'; +export * from './accessibility-types'; +export * from './font-scale'; + +function enforceArray(val: string | string[]): string[] { + if (Array.isArray(val)) { + return val; + } + + if (typeof val === 'string') { + return val.split(/[, ]/g).filter((v: string) => !!v); + } + + return []; +} + +/** + * Convert array of values into a bitmask. + * + * @param values string values + * @param map map lower-case name to integer value. + */ +function inputArrayToBitMask(values: string | string[], map: Map): number { + return ( + enforceArray(values) + .filter((value) => !!value) + .map((value) => `${value}`.toLocaleLowerCase()) + .filter((value) => map.has(value)) + .reduce((res, value) => res | map.get(value), 0) || 0 + ); +} + +let AccessibilityTraitsMap: Map; +let RoleTypeMap: Map; + +let nativeFocusedNotificationObserver; +let lastFocusedView: WeakRef; +function ensureNativeClasses() { + if (AccessibilityTraitsMap && nativeFocusedNotificationObserver) { + return; + } + + AccessibilityTraitsMap = new Map([ + [AccessibilityTrait.None, UIAccessibilityTraitNone], + [AccessibilityTrait.Button, UIAccessibilityTraitButton], + [AccessibilityTrait.Link, UIAccessibilityTraitLink], + [AccessibilityTrait.SearchField, UIAccessibilityTraitSearchField], + [AccessibilityTrait.Image, UIAccessibilityTraitImage], + [AccessibilityTrait.Selected, UIAccessibilityTraitSelected], + [AccessibilityTrait.PlaysSound, UIAccessibilityTraitPlaysSound], + [AccessibilityTrait.StaticText, UIAccessibilityTraitStaticText], + [AccessibilityTrait.SummaryElement, UIAccessibilityTraitSummaryElement], + [AccessibilityTrait.NotEnabled, UIAccessibilityTraitNotEnabled], + [AccessibilityTrait.UpdatesFrequently, UIAccessibilityTraitUpdatesFrequently], + [AccessibilityTrait.StartsMediaSession, UIAccessibilityTraitStartsMediaSession], + [AccessibilityTrait.Adjustable, UIAccessibilityTraitAdjustable], + [AccessibilityTrait.AllowsDirectInteraction, UIAccessibilityTraitAllowsDirectInteraction], + [AccessibilityTrait.CausesPageTurn, UIAccessibilityTraitCausesPageTurn], + [AccessibilityTrait.Header, UIAccessibilityTraitHeader], + ]); + + RoleTypeMap = new Map([ + [AccessibilityRole.Button, UIAccessibilityTraitButton], + [AccessibilityRole.Header, UIAccessibilityTraitHeader], + [AccessibilityRole.Link, UIAccessibilityTraitLink], + [AccessibilityRole.Search, UIAccessibilityTraitSearchField], + [AccessibilityRole.Image, UIAccessibilityTraitImage], + [AccessibilityRole.ImageButton, UIAccessibilityTraitImage | UIAccessibilityTraitButton], + [AccessibilityRole.KeyboardKey, UIAccessibilityTraitKeyboardKey], + [AccessibilityRole.StaticText, UIAccessibilityTraitStaticText], + [AccessibilityRole.Summary, UIAccessibilityTraitSummaryElement], + [AccessibilityRole.Adjustable, UIAccessibilityTraitAdjustable], + [AccessibilityRole.Checkbox, UIAccessibilityTraitButton], + [AccessibilityRole.Switch, UIAccessibilityTraitButton], + [AccessibilityRole.RadioButton, UIAccessibilityTraitButton], + ]); + + nativeFocusedNotificationObserver = Application.ios.addNotificationObserver(UIAccessibilityElementFocusedNotification, (args: NSNotification) => { + const uiView = args.userInfo.objectForKey(UIAccessibilityFocusedElementKey) as UIView; + if (!uiView.tag) { + return; + } + + const rootView = Application.getRootView(); + + // We use the UIView's tag to find the NativeScript View by its domId. + let view = rootView.getViewByDomId(uiView.tag); + if (!view) { + for (const modalView of >rootView._getRootModalViews()) { + view = modalView.getViewByDomId(uiView.tag); + if (view) { + break; + } + } + } + + if (!view) { + return; + } + + const lastView = lastFocusedView?.get(); + if (lastView && view !== lastView) { + const lastFocusedUIView = lastView.nativeViewProtected as UIView; + if (lastFocusedUIView) { + lastFocusedView = null; + + notifyAccessibilityFocusState(lastView, false, true); + } + } + + lastFocusedView = new WeakRef(view); + + notifyAccessibilityFocusState(view, true, false); + }); + + Application.on(Application.exitEvent, () => { + if (nativeFocusedNotificationObserver) { + Application.ios.removeNotificationObserver(nativeFocusedNotificationObserver, UIAccessibilityElementFocusedNotification); + } + + nativeFocusedNotificationObserver = null; + lastFocusedView = null; + }); +} + +export function setupAccessibleView(view: View): void { + const uiView = view.nativeViewProtected as UIView; + if (!uiView) { + return; + } + + /** + * We need to map back from the UIView to the NativeScript View. + * + * We do that by setting the uiView's tag to the View's domId. + * This way we can do reverse lookup. + */ + uiView.tag = view._domId; +} + +export function updateAccessibilityProperties(view: View): void { + const uiView = view.nativeViewProtected as UIView; + if (!uiView) { + return; + } + + ensureNativeClasses(); + + const accessibilityRole = view.accessibilityRole; + const accessibilityState = view.accessibilityState; + + if (!view.accessible || view.accessibilityHidden) { + uiView.accessibilityTraits = UIAccessibilityTraitNone; + + return; + } + + let a11yTraits = UIAccessibilityTraitNone; + if (RoleTypeMap.has(accessibilityRole)) { + a11yTraits |= RoleTypeMap.get(accessibilityRole); + } + + switch (accessibilityRole) { + case AccessibilityRole.Checkbox: + case AccessibilityRole.RadioButton: + case AccessibilityRole.Switch: { + if (accessibilityState === AccessibilityState.Checked) { + a11yTraits |= AccessibilityTraitsMap.get(AccessibilityTrait.Selected); + } + break; + } + default: { + if (accessibilityState === AccessibilityState.Selected) { + a11yTraits |= AccessibilityTraitsMap.get(AccessibilityTrait.Selected); + } + if (accessibilityState === AccessibilityState.Disabled) { + a11yTraits |= AccessibilityTraitsMap.get(AccessibilityTrait.NotEnabled); + } + break; + } + } + + const UpdatesFrequentlyTrait = AccessibilityTraitsMap.get(AccessibilityTrait.UpdatesFrequently); + + switch (view.accessibilityLiveRegion) { + case AccessibilityLiveRegion.Polite: + case AccessibilityLiveRegion.Assertive: { + a11yTraits |= UpdatesFrequentlyTrait; + break; + } + default: { + a11yTraits &= ~UpdatesFrequentlyTrait; + break; + } + } + + if (view.accessibilityMediaSession) { + a11yTraits |= AccessibilityTraitsMap.get(AccessibilityTrait.StartsMediaSession); + } + + if (view.accessibilityTraits) { + a11yTraits |= inputArrayToBitMask(view.accessibilityTraits, AccessibilityTraitsMap); + } + + uiView.accessibilityTraits = a11yTraits; +} + +// eslint-disable-next-line @typescript-eslint/no-empty-function +export const sendAccessibilityEvent = (): void => {}; +export const updateContentDescription = (): string | null => null; + +let accessibilityServiceEnabled: boolean; +let nativeObserver; +export function isAccessibilityServiceEnabled(): boolean { + if (typeof accessibilityServiceEnabled === 'boolean') { + return accessibilityServiceEnabled; + } + + let isVoiceOverRunning: () => boolean; + if (typeof UIAccessibilityIsVoiceOverRunning === 'function') { + isVoiceOverRunning = UIAccessibilityIsVoiceOverRunning; + } else { + // iOS is too old to tell us if voice over is enabled + if (typeof UIAccessibilityIsVoiceOverRunning !== 'function') { + accessibilityServiceEnabled = false; + return accessibilityServiceEnabled; + } + } + + accessibilityServiceEnabled = isVoiceOverRunning(); + + let voiceOverStatusChangedNotificationName: string | null = null; + if (typeof UIAccessibilityVoiceOverStatusDidChangeNotification !== 'undefined') { + voiceOverStatusChangedNotificationName = UIAccessibilityVoiceOverStatusDidChangeNotification; + } else if (typeof UIAccessibilityVoiceOverStatusChanged !== 'undefined') { + voiceOverStatusChangedNotificationName = UIAccessibilityVoiceOverStatusChanged; + } + + if (voiceOverStatusChangedNotificationName) { + nativeObserver = Application.ios.addNotificationObserver(voiceOverStatusChangedNotificationName, () => { + accessibilityServiceEnabled = isVoiceOverRunning(); + }); + + Application.on(Application.exitEvent, () => { + if (nativeObserver) { + Application.ios.removeNotificationObserver(nativeObserver, voiceOverStatusChangedNotificationName); + } + + accessibilityServiceEnabled = undefined; + nativeObserver = null; + }); + } + + Application.on(Application.resumeEvent, () => { + accessibilityServiceEnabled = isVoiceOverRunning(); + }); + + return accessibilityServiceEnabled; +} diff --git a/packages/core/application/application-common.ts b/packages/core/application/application-common.ts index 5cf6cabdb..93fabd2e2 100644 --- a/packages/core/application/application-common.ts +++ b/packages/core/application/application-common.ts @@ -28,6 +28,7 @@ export const uncaughtErrorEvent = 'uncaughtError'; export const discardedErrorEvent = 'discardedError'; export const orientationChangedEvent = 'orientationChanged'; export const systemAppearanceChangedEvent = 'systemAppearanceChanged'; +export const fontScaleChangedEvent = 'fontScaleChanged'; const ORIENTATION_CSS_CLASSES = [`${CSSUtils.CLASS_PREFIX}${Enums.DeviceOrientation.portrait}`, `${CSSUtils.CLASS_PREFIX}${Enums.DeviceOrientation.landscape}`, `${CSSUtils.CLASS_PREFIX}${Enums.DeviceOrientation.unknown}`]; @@ -122,7 +123,7 @@ function increaseStyleScopeApplicationCssSelectorVersion(rootView: View) { } } -function applyCssClass(rootView: View, cssClasses: string[], newCssClass: string) { +export function applyCssClass(rootView: View, cssClasses: string[], newCssClass: string): void { if (!rootView.cssClasses.has(newCssClass)) { cssClasses.forEach((cssClass) => removeCssClass(rootView, cssClass)); addCssClass(rootView, newCssClass); @@ -146,7 +147,7 @@ export function orientationChanged(rootView: View, newOrientation: 'portrait' | } export let autoSystemAppearanceChanged = true; -export function setAutoSystemAppearanceChanged(value: boolean) { +export function setAutoSystemAppearanceChanged(value: boolean): void { autoSystemAppearanceChanged = value; } diff --git a/packages/core/application/index.android.ts b/packages/core/application/index.android.ts index 50ce16597..bcd29d0a2 100644 --- a/packages/core/application/index.android.ts +++ b/packages/core/application/index.android.ts @@ -15,6 +15,8 @@ import { NavigationEntry, AndroidActivityCallbacks } from '../ui/frame/frame-int import { Observable } from '../data/observable'; import { profile } from '../profiling'; +import { initAccessibilityCssHelper } from '../accessibility/accessibility-css-helper'; +import { initAccessibilityFontScale } from '../accessibility/font-scale'; const ActivityCreated = 'activityCreated'; const ActivityDestroyed = 'activityDestroyed'; @@ -172,6 +174,9 @@ export function run(entry?: NavigationEntry | string) { const nativeApp = getNativeApplication(); androidApp.init(nativeApp); } + + initAccessibilityCssHelper(); + initAccessibilityFontScale(); } export function addCss(cssText: string, attributeScoped?: boolean): void { diff --git a/packages/core/application/index.d.ts b/packages/core/application/index.d.ts index 07416d5f8..3b3000dd4 100644 --- a/packages/core/application/index.d.ts +++ b/packages/core/application/index.d.ts @@ -54,6 +54,11 @@ export const orientationChangedEvent: string; */ export const systemAppearanceChangedEvent: string; +/** + * String value used when hooking to fontScaleChanged event. + */ +export const fontScaleChangedEvent: string; + /** * Boolean to enable/disable systemAppearanceChanged */ @@ -62,7 +67,7 @@ export let autoSystemAppearanceChanged: boolean; /** * enable/disable systemAppearanceChanged */ -export function setAutoSystemAppearanceChanged(value: boolean); +export function setAutoSystemAppearanceChanged(value: boolean): void; /** * Updates root view classes including those of modals @@ -184,6 +189,11 @@ export function setCssFileName(cssFile: string): void; */ export function getCssFileName(): string; +/** + * Ensure css-class is set on rootView + */ +export function applyCssClass(rootView: View, cssClasses: string[], newCssClass: string): void; + /** * Loads immediately the app.css. * By default the app.css file is loaded shortly after "loaded". diff --git a/packages/core/application/index.ios.ts b/packages/core/application/index.ios.ts index f36cafb70..d92c04d0d 100644 --- a/packages/core/application/index.ios.ts +++ b/packages/core/application/index.ios.ts @@ -18,6 +18,8 @@ import { IOSHelper } from '../ui/core/view/view-helper'; import { Device } from '../platform'; import { profile } from '../profiling'; import { iOSNativeHelper } from '../utils'; +import { initAccessibilityCssHelper } from '../accessibility/accessibility-css-helper'; +import { initAccessibilityFontScale } from '../accessibility/font-scale'; const IOS_PLATFORM = 'ios'; @@ -435,6 +437,9 @@ export function run(entry?: string | NavigationEntry) { } } } + + initAccessibilityCssHelper(); + initAccessibilityFontScale(); } export function addCss(cssText: string, attributeScoped?: boolean): void { diff --git a/packages/core/trace/index.ts b/packages/core/trace/index.ts index 2c3bfa7f7..9aee3459c 100644 --- a/packages/core/trace/index.ts +++ b/packages/core/trace/index.ts @@ -185,6 +185,7 @@ export namespace Trace { * all predefined categories. */ export namespace categories { + export const Accessibility = 'Accessibility'; export const VisualTreeEvents = 'VisualTreeEvents'; export const Layout = 'Layout'; export const Style = 'Style'; diff --git a/packages/core/ui/action-bar/index.android.ts b/packages/core/ui/action-bar/index.android.ts index 3fe45f003..137871df5 100644 --- a/packages/core/ui/action-bar/index.android.ts +++ b/packages/core/ui/action-bar/index.android.ts @@ -6,6 +6,7 @@ import { layout, RESOURCE_PREFIX, isFontIconURI } from '../../utils'; import { colorProperty } from '../styling/style-properties'; import { ImageSource } from '../../image-source'; import * as application from '../../application'; +import { isAccessibilityServiceEnabled, updateContentDescription } from '../../accessibility'; export * from './action-bar-common'; @@ -298,7 +299,7 @@ export class ActionBar extends ActionBarBase { } } - public _updateTitleAndTitleView() { + public _updateTitleAndTitleView(): void { if (!this.titleView) { // No title view - show the title const title = this.title; @@ -313,6 +314,9 @@ export class ActionBar extends ActionBarBase { } } } + + // Update content description for the screen reader. + updateContentDescription(this, true); } public _addActionItems() { @@ -447,6 +451,74 @@ export class ActionBar extends ActionBarBase { this.nativeViewProtected.setContentInsetsAbsolute(this.effectiveContentInsetLeft, this.effectiveContentInsetRight); } } + + public accessibilityScreenChanged(): void { + if (!isAccessibilityServiceEnabled()) { + return; + } + + const nativeView = this.nativeViewProtected; + if (!nativeView) { + return; + } + + const originalFocusableState = android.os.Build.VERSION.SDK_INT >= 26 && nativeView.getFocusable(); + const originalImportantForAccessibility = nativeView.getImportantForAccessibility(); + const originalIsAccessibilityHeading = android.os.Build.VERSION.SDK_INT >= 28 && nativeView.isAccessibilityHeading(); + + try { + nativeView.setFocusable(false); + nativeView.setImportantForAccessibility(android.view.View.IMPORTANT_FOR_ACCESSIBILITY_NO); + + let announceView: android.view.View | null = null; + + const numChildren = nativeView.getChildCount(); + for (let i = 0; i < numChildren; i += 1) { + const childView = nativeView.getChildAt(i); + if (!childView) { + continue; + } + + childView.setFocusable(true); + if (childView instanceof androidx.appcompat.widget.AppCompatTextView) { + announceView = childView; + if (android.os.Build.VERSION.SDK_INT >= 28) { + announceView.setAccessibilityHeading(true); + } + } + } + + if (!announceView) { + announceView = nativeView; + } + + announceView.setFocusable(true); + announceView.setImportantForAccessibility(android.view.View.IMPORTANT_FOR_ACCESSIBILITY_YES); + + announceView.sendAccessibilityEvent(android.view.accessibility.AccessibilityEvent.TYPE_VIEW_FOCUSED); + announceView.sendAccessibilityEvent(android.view.accessibility.AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUSED); + } catch { + // ignore + } finally { + setTimeout(() => { + // Reset status after the focus have been reset. + const localNativeView = this.nativeViewProtected; + if (!localNativeView) { + return; + } + + if (android.os.Build.VERSION.SDK_INT >= 28) { + nativeView.setAccessibilityHeading(originalIsAccessibilityHeading); + } + + if (android.os.Build.VERSION.SDK_INT >= 26) { + localNativeView.setFocusable(originalFocusableState); + } + + localNativeView.setImportantForAccessibility(originalImportantForAccessibility); + }); + } + } } function getAppCompatTextView(toolbar: androidx.appcompat.widget.Toolbar): typeof AppCompatTextView { diff --git a/packages/core/ui/action-bar/index.ios.ts b/packages/core/ui/action-bar/index.ios.ts index 831710e6e..1deafaffe 100644 --- a/packages/core/ui/action-bar/index.ios.ts +++ b/packages/core/ui/action-bar/index.ios.ts @@ -5,6 +5,7 @@ import { Color } from '../../color'; import { colorProperty, backgroundColorProperty, backgroundInternalProperty } from '../styling/style-properties'; import { ImageSource } from '../../image-source'; import { layout, iOSNativeHelper, isFontIconURI } from '../../utils'; +import { accessibilityHintProperty, accessibilityLabelProperty, accessibilityLanguageProperty, accessibilityValueProperty } from '../../accessibility/accessibility-properties'; export * from './action-bar-common'; @@ -99,6 +100,46 @@ export class ActionBar extends ActionBarBase { return null; } + [accessibilityValueProperty.setNative](value: string): void { + value = value == null ? null : `${value}`; + this.nativeViewProtected.accessibilityValue = value; + + const navigationItem = this._getNavigationItem(); + if (navigationItem) { + navigationItem.accessibilityValue = value; + } + } + + [accessibilityLabelProperty.setNative](value: string): void { + value = value == null ? null : `${value}`; + this.nativeViewProtected.accessibilityLabel = value; + + const navigationItem = this._getNavigationItem(); + if (navigationItem) { + navigationItem.accessibilityLabel = value; + } + } + + [accessibilityHintProperty.setNative](value: string): void { + value = value == null ? null : `${value}`; + this.nativeViewProtected.accessibilityHint = value; + + const navigationItem = this._getNavigationItem(); + if (navigationItem) { + navigationItem.accessibilityHint = value; + } + } + + [accessibilityLanguageProperty.setNative](value: string): void { + value = value == null ? null : `${value}`; + this.nativeViewProtected.accessibilityLanguage = value; + + const navigationItem = this._getNavigationItem(); + if (navigationItem) { + navigationItem.accessibilityLanguage = value; + } + } + public createNativeView(): UIView { return this.ios; } @@ -148,7 +189,18 @@ export class ActionBar extends ActionBarBase { } } - public update() { + private _getNavigationItem(): UINavigationItem | null { + const page = this.page; + // Page should be attached to frame to update the action bar. + if (!page || !page.frame) { + return null; + } + + const viewController = page.ios; + return viewController.navigationItem; + } + + public update(): void { const page = this.page; // Page should be attached to frame to update the action bar. if (!page || !page.frame) { @@ -228,6 +280,12 @@ export class ActionBar extends ActionBarBase { if (!this.isLayoutValid) { this.layoutInternal(); } + + // Make sure accessibility values are up-to-date on the navigationItem + navigationItem.accessibilityValue = this.accessibilityValue; + navigationItem.accessibilityLabel = this.accessibilityLabel; + navigationItem.accessibilityLanguage = this.accessibilityLanguage; + navigationItem.accessibilityHint = this.accessibilityHint; } private populateMenuItems(navigationItem: UINavigationItem) { diff --git a/packages/core/ui/button/button-common.ts b/packages/core/ui/button/button-common.ts index 7c59348ff..2169a5bcf 100644 --- a/packages/core/ui/button/button-common.ts +++ b/packages/core/ui/button/button-common.ts @@ -2,11 +2,15 @@ import { Button as ButtonDefinition } from '.'; import { TextBase } from '../text-base'; import { CSSType } from '../core/view'; import { booleanConverter } from '../core/view-base'; +import { AccessibilityRole } from '../../accessibility'; @CSSType('Button') export abstract class ButtonBase extends TextBase implements ButtonDefinition { public static tapEvent = 'tap'; + accessible = true; + accessibilityRole = AccessibilityRole.Button; + get textWrap(): boolean { return this.style.whiteSpace === 'normal'; } diff --git a/packages/core/ui/core/view-base/index.d.ts b/packages/core/ui/core/view-base/index.d.ts index a7eb05a2f..1c3f49232 100644 --- a/packages/core/ui/core/view-base/index.d.ts +++ b/packages/core/ui/core/view-base/index.d.ts @@ -34,6 +34,14 @@ export function isEventOrGesture(name: string, view: ViewBase): boolean; */ export function getViewById(view: ViewBase, id: string): ViewBase; +/** + * Gets a child view by domId. + * @param view - The parent (container) view of the view to look for. + * @param domId - The id of the view to look for. + * Returns an instance of a view (if found), otherwise undefined. + */ +export function getViewByDomId(view: ViewBase, domId: number): ViewBase; + export interface ShowModalOptions { /** * Any context you want to pass to the modally shown view. This same context will be available in the arguments of the shownModally event handler. @@ -289,6 +297,11 @@ export abstract class ViewBase extends Observable { */ public getViewById(id: string): T; + /** + * Returns the child view with the specified domId. + */ + public getViewByDomId(id: number): T; + /** * Load view. * @param view to load. diff --git a/packages/core/ui/core/view-base/index.ts b/packages/core/ui/core/view-base/index.ts index 1f9ad7ffa..bbfd57345 100644 --- a/packages/core/ui/core/view-base/index.ts +++ b/packages/core/ui/core/view-base/index.ts @@ -143,6 +143,32 @@ export function getViewById(view: ViewBaseDefinition, id: string): ViewBaseDefin return retVal; } +export function getViewByDomId(view: ViewBaseDefinition, domId: number): ViewBaseDefinition { + if (!view) { + return undefined; + } + + if (view._domId === domId) { + return view; + } + + let retVal: ViewBaseDefinition; + const descendantsCallback = function (child: ViewBaseDefinition): boolean { + if (view._domId === domId) { + retVal = child; + + // break the iteration by returning false + return false; + } + + return true; + }; + + eachDescendant(view, descendantsCallback); + + return retVal; +} + export function eachDescendant(view: ViewBaseDefinition, callback: (child: ViewBaseDefinition) => boolean) { if (!callback || !view) { return; @@ -373,6 +399,10 @@ export abstract class ViewBase extends Observable implements ViewBaseDefinition return getViewById(this, id); } + getViewByDomId(domId: number): T { + return getViewByDomId(this, domId); + } + get page(): Page { if (this.parent) { return this.parent.page; diff --git a/packages/core/ui/core/view/index.android.ts b/packages/core/ui/core/view/index.android.ts index fe7b0b7d4..5302732b1 100644 --- a/packages/core/ui/core/view/index.android.ts +++ b/packages/core/ui/core/view/index.android.ts @@ -1,8 +1,9 @@ // Definitions. -import { Point, CustomLayoutView as CustomLayoutViewDefinition, dip } from '.'; -import { GestureTypes, GestureEventData } from '../../gestures'; +import type { Point, CustomLayoutView as CustomLayoutViewDefinition, dip } from '.'; +import type { GestureTypes, GestureEventData } from '../../gestures'; + // Types. -import { ViewCommon, isEnabledProperty, originXProperty, originYProperty, automationTextProperty, isUserInteractionEnabledProperty } from './view-common'; +import { ViewCommon, isEnabledProperty, originXProperty, originYProperty, isUserInteractionEnabledProperty } from './view-common'; import { paddingLeftProperty, paddingTopProperty, paddingRightProperty, paddingBottomProperty } from '../../styling/style-properties'; import { layout } from '../../../utils'; import { Trace } from '../../../trace'; @@ -48,6 +49,9 @@ import { Screen } from '../../../platform'; import { AndroidActivityBackPressedEventData, android as androidApp } from '../../../application'; import { Device } from '../../../platform'; import lazy from '../../../utils/lazy'; +import { accessibilityEnabledProperty, accessibilityHiddenProperty, accessibilityHintProperty, accessibilityIdentifierProperty, accessibilityLabelProperty, accessibilityLanguageProperty, accessibilityLiveRegionProperty, accessibilityMediaSessionProperty, accessibilityRoleProperty, accessibilityStateProperty, accessibilityValueProperty } from '../../../accessibility/accessibility-properties'; +import { AccessibilityLiveRegion, AccessibilityRole, AndroidAccessibilityEvent, setupAccessibleView, isAccessibilityServiceEnabled, sendAccessibilityEvent, updateAccessibilityProperties, updateContentDescription } from '../../../accessibility'; +import * as Utils from '../../../utils'; export * from './view-common'; // helpers (these are okay re-exported here) @@ -323,6 +327,12 @@ export class View extends ViewCommon { nativeViewProtected: android.view.View; + constructor() { + super(); + + this.on(View.loadedEvent, () => setupAccessibleView(this)); + } + // TODO: Implement unobserve that detach the touchListener. _observe(type: GestureTypes, callback: (args: GestureEventData) => void, thisArg?: any): void { super._observe(type, callback, thisArg); @@ -744,13 +754,6 @@ export class View extends ViewCommon { org.nativescript.widgets.OriginPoint.setY(this.nativeViewProtected, value); } - [automationTextProperty.getDefault](): string { - return this.nativeViewProtected.getContentDescription(); - } - [automationTextProperty.setNative](value: string) { - this.nativeViewProtected.setContentDescription(value); - } - [isUserInteractionEnabledProperty.setNative](value: boolean) { this.nativeViewProtected.setClickable(value); this.nativeViewProtected.setFocusable(value); @@ -792,6 +795,77 @@ export class View extends ViewCommon { this.nativeViewProtected.setAlpha(float(value)); } + [accessibilityEnabledProperty.setNative](value: boolean): void { + this.nativeViewProtected.setFocusable(!!value); + + updateAccessibilityProperties(this); + } + + [accessibilityIdentifierProperty.setNative](value: string): void { + const id = Utils.ad.resources.getId(':id/nativescript_accessibility_id'); + + if (id) { + this.nativeViewProtected.setTag(id, value); + this.nativeViewProtected.setTag(value); + } + } + + [accessibilityRoleProperty.setNative](value: AccessibilityRole): void { + updateAccessibilityProperties(this); + + if (android.os.Build.VERSION.SDK_INT >= 28) { + this.nativeViewProtected?.setAccessibilityHeading(value === AccessibilityRole.Header); + } + } + + [accessibilityValueProperty.setNative](): void { + this._androidContentDescriptionUpdated = true; + updateContentDescription(this); + } + + [accessibilityLabelProperty.setNative](): void { + this._androidContentDescriptionUpdated = true; + updateContentDescription(this); + } + + [accessibilityHintProperty.setNative](): void { + this._androidContentDescriptionUpdated = true; + updateContentDescription(this); + } + + [accessibilityHiddenProperty.setNative](value: boolean): void { + if (value) { + this.nativeViewProtected.setImportantForAccessibility(android.view.View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS); + } else { + this.nativeViewProtected.setImportantForAccessibility(android.view.View.IMPORTANT_FOR_ACCESSIBILITY_YES); + } + } + + [accessibilityLiveRegionProperty.setNative](value: AccessibilityLiveRegion): void { + switch (value) { + case AccessibilityLiveRegion.Assertive: { + this.nativeViewProtected.setAccessibilityLiveRegion(android.view.View.ACCESSIBILITY_LIVE_REGION_ASSERTIVE); + break; + } + case AccessibilityLiveRegion.Polite: { + this.nativeViewProtected.setAccessibilityLiveRegion(android.view.View.ACCESSIBILITY_LIVE_REGION_POLITE); + break; + } + default: { + this.nativeViewProtected.setAccessibilityLiveRegion(android.view.View.ACCESSIBILITY_LIVE_REGION_NONE); + break; + } + } + } + + [accessibilityStateProperty.setNative](): void { + updateAccessibilityProperties(this); + } + + [accessibilityMediaSessionProperty.setNative](): void { + updateAccessibilityProperties(this); + } + [androidElevationProperty.getDefault](): number { return this.getDefaultElevation(); } @@ -1047,6 +1121,30 @@ export class View extends ViewCommon { (nativeView).background = undefined; } } + + public androidSendAccessibilityEvent(eventName: AndroidAccessibilityEvent, msg?: string): void { + if (!isAccessibilityServiceEnabled()) { + return; + } + + sendAccessibilityEvent(this, eventName, msg); + } + + public accessibilityAnnouncement(msg = this.accessibilityLabel): void { + if (!isAccessibilityServiceEnabled()) { + return; + } + + this.androidSendAccessibilityEvent(AndroidAccessibilityEvent.ANNOUNCEMENT, msg); + } + + public accessibilityScreenChanged(): void { + if (!isAccessibilityServiceEnabled()) { + return; + } + + this.androidSendAccessibilityEvent(AndroidAccessibilityEvent.WINDOW_STATE_CHANGED); + } } export class ContainerView extends View { diff --git a/packages/core/ui/core/view/index.d.ts b/packages/core/ui/core/view/index.d.ts index e546e6b88..0c224b606 100644 --- a/packages/core/ui/core/view/index.d.ts +++ b/packages/core/ui/core/view/index.d.ts @@ -4,8 +4,9 @@ import { EventData } from '../../../data/observable'; import { Color } from '../../../color'; import { Animation, AnimationDefinition, AnimationPromise } from '../../animation'; import { HorizontalAlignment, VerticalAlignment, Visibility, Length, PercentLength } from '../../styling/style-properties'; -import { GestureTypes, GestureEventData, GesturesObserver } from '../../gestures'; +import { GestureTypes, GesturesObserver } from '../../gestures'; import { LinearGradient } from '../../styling/gradient'; +import { AccessibilityLiveRegion, AccessibilityRole, AccessibilityState, AccessibilityTrait, AndroidAccessibilityEvent, IOSPostAccessibilityNotificationType } from '../../../accessibility/accessibility-types'; import { BoxShadow } from '../../styling/box-shadow'; // helpers (these are okay re-exported here) @@ -131,6 +132,21 @@ export abstract class View extends ViewBase { */ public static shownModallyEvent: string; + /** + * String value used when hooking to accessibilityBlur event. + */ + public static accessibilityBlurEvent: string; + + /** + * String value used when hooking to accessibilityFocus event. + */ + public static accessibilityFocusEvent: string; + + /** + * String value used when hooking to accessibilityFocusChanged event. + */ + public static accessibilityFocusChangedEvent: string; + /** * Gets the android-specific native instance that lies behind this proxy. Will be available if running on an Android platform. */ @@ -226,6 +242,68 @@ export abstract class View extends ViewBase { */ color: Color; + /** + * If `true` the element is an accessibility element and all the children will be treated as a single selectable component. + */ + accessible: boolean; + + /** + * Hide the view and its children from the a11y service + */ + accessibilityHidden: boolean; + + /** + * The view's unique accessibilityIdentifier. + * + * This is used for automated testing. + */ + accessibilityIdentifier: string; + + /** + * Which role should this view be treated by the a11y service? + */ + accessibilityRole: AccessibilityRole; + + /** + * Which state should this view be treated as by the a11y service? + */ + accessibilityState: AccessibilityState; + + /** + * Short description of the element, ideally one word. + */ + accessibilityLabel: string; + + /** + * Current value of the element in a localized string. + */ + accessibilityValue: string; + + /** + * A hint describes the elements behavior. Example: 'Tap change playback speed' + */ + accessibilityHint: string; + accessibilityTraits?: AccessibilityTrait[]; + accessibilityLiveRegion: AccessibilityLiveRegion; + + /** + * Sets the language in which to speak the element's label and value. + * Accepts language ID tags that follows the "BCP 47" specification. + */ + accessibilityLanguage: string; + + /** + * This view starts a media session. Equivalent to trait = startsMedia + */ + accessibilityMediaSession: boolean; + + /** + * Internal use only. This is used to limit the number of updates to android.view.View.setContentDescription() + */ + _androidContentDescriptionUpdated?: boolean; + + automationText: string; + /** * Gets or sets the elevation of the android view. */ @@ -364,11 +442,6 @@ export abstract class View extends ViewBase { //END Style property shortcuts - /** - * Gets or sets the automation text of the view. - */ - automationText: string; - /** * Gets or sets the X component of the origin point around which the view will be transformed. The default value is 0.5 representing the center of the view. */ @@ -673,6 +746,29 @@ export abstract class View extends ViewBase { */ public eachChildView(callback: (view: View) => boolean): void; + /** + * Android: Send accessibility event + */ + public androidSendAccessibilityEvent(eventName: AndroidAccessibilityEvent, msg?: string): void; + + /** + * iOS: post accessibility notification. + * type = 'announcement' will announce `args` via VoiceOver. If no args element will be announced instead. + * type = 'layout' used when the layout of a screen changes. + * type = 'screen' large change made to the screen. + */ + public iosPostAccessibilityNotification(notificationType: IOSPostAccessibilityNotificationType, msg?: string): void; + + /** + * Make an announcement to the screen reader. + */ + public accessibilityAnnouncement(msg?: string): void; + + /** + * Announce screen changed + */ + public accessibilityScreenChanged(): void; + //@private /** * @private @@ -879,7 +975,6 @@ export interface AddChildFromBuilder { _addChildFromBuilder(name: string, value: any): void; } -export const automationTextProperty: Property; export const originXProperty: Property; export const originYProperty: Property; export const isEnabledProperty: Property; diff --git a/packages/core/ui/core/view/index.ios.ts b/packages/core/ui/core/view/index.ios.ts index 546048f62..60165395f 100644 --- a/packages/core/ui/core/view/index.ios.ts +++ b/packages/core/ui/core/view/index.ios.ts @@ -2,7 +2,7 @@ import { Point, View as ViewDefinition, dip } from '.'; // Requires -import { ViewCommon, isEnabledProperty, originXProperty, originYProperty, automationTextProperty, isUserInteractionEnabledProperty } from './view-common'; +import { ViewCommon, isEnabledProperty, originXProperty, originYProperty, isUserInteractionEnabledProperty } from './view-common'; import { ShowModalOptions } from '../view-base'; import { Trace } from '../../../trace'; import { layout, iOSNativeHelper } from '../../../utils'; @@ -10,6 +10,8 @@ import { IOSHelper } from './view-helper'; import { ios as iosBackground, Background } from '../../styling/background'; import { perspectiveProperty, Visibility, visibilityProperty, opacityProperty, rotateProperty, rotateXProperty, rotateYProperty, scaleXProperty, scaleYProperty, translateXProperty, translateYProperty, zIndexProperty, backgroundInternalProperty, clipPathProperty } from '../../styling/style-properties'; import { profile } from '../../../profiling'; +import { accessibilityEnabledProperty, accessibilityHiddenProperty, accessibilityHintProperty, accessibilityIdentifierProperty, accessibilityLabelProperty, accessibilityLanguageProperty, accessibilityLiveRegionProperty, accessibilityMediaSessionProperty, accessibilityRoleProperty, accessibilityStateProperty, accessibilityTraitsProperty, accessibilityValueProperty } from '../../../accessibility/accessibility-properties'; +import { setupAccessibleView, IOSPostAccessibilityNotificationType, isAccessibilityServiceEnabled, updateAccessibilityProperties } from '../../../accessibility'; export * from './view-common'; // helpers (these are okay re-exported here) @@ -54,6 +56,12 @@ export class View extends ViewCommon implements ViewDefinition { return (this._privateFlags & PFLAG_FORCE_LAYOUT) === PFLAG_FORCE_LAYOUT; } + constructor() { + super(); + + this.once(View.loadedEvent, () => setupAccessibleView(this)); + } + public requestLayout(): void { super.requestLayout(); this._privateFlags |= PFLAG_FORCE_LAYOUT; @@ -553,14 +561,65 @@ export class View extends ViewCommon implements ViewDefinition { this.updateOriginPoint(this.originX, value); } - [automationTextProperty.getDefault](): string { + [accessibilityEnabledProperty.setNative](value: boolean): void { + this.nativeViewProtected.isAccessibilityElement = !!value; + + updateAccessibilityProperties(this); + } + + [accessibilityIdentifierProperty.getDefault](): string { return this.nativeViewProtected.accessibilityLabel; } - [automationTextProperty.setNative](value: string) { + [accessibilityIdentifierProperty.setNative](value: string): void { this.nativeViewProtected.accessibilityIdentifier = value; + } + + [accessibilityRoleProperty.setNative](): void { + updateAccessibilityProperties(this); + } + + [accessibilityTraitsProperty.setNative](): void { + updateAccessibilityProperties(this); + } + + [accessibilityValueProperty.setNative](value: string): void { + value = value == null ? null : `${value}`; + this.nativeViewProtected.accessibilityValue = value; + } + + [accessibilityLabelProperty.setNative](value: string): void { + value = value == null ? null : `${value}`; this.nativeViewProtected.accessibilityLabel = value; } + [accessibilityHintProperty.setNative](value: string): void { + value = value == null ? null : `${value}`; + this.nativeViewProtected.accessibilityHint = value; + } + + [accessibilityLanguageProperty.setNative](value: string): void { + value = value == null ? null : `${value}`; + this.nativeViewProtected.accessibilityLanguage = value; + } + + [accessibilityHiddenProperty.setNative](value: boolean): void { + this.nativeViewProtected.accessibilityElementsHidden = !!value; + + updateAccessibilityProperties(this); + } + + [accessibilityLiveRegionProperty.setNative](): void { + updateAccessibilityProperties(this); + } + + [accessibilityStateProperty.setNative](): void { + updateAccessibilityProperties(this); + } + + [accessibilityMediaSessionProperty.setNative](): void { + updateAccessibilityProperties(this); + } + [isUserInteractionEnabledProperty.getDefault](): boolean { return this.nativeViewProtected.userInteractionEnabled; } @@ -673,6 +732,54 @@ export class View extends ViewCommon implements ViewDefinition { } } + public iosPostAccessibilityNotification(notificationType: IOSPostAccessibilityNotificationType, msg?: string): void { + if (!notificationType) { + return; + } + + let notification: number; + let args: string | UIView | null = this.nativeViewProtected; + if (typeof msg === 'string' && msg) { + args = msg; + } + + switch (notificationType) { + case IOSPostAccessibilityNotificationType.Announcement: { + notification = UIAccessibilityAnnouncementNotification; + break; + } + case IOSPostAccessibilityNotificationType.Layout: { + notification = UIAccessibilityLayoutChangedNotification; + break; + } + case IOSPostAccessibilityNotificationType.Screen: { + notification = UIAccessibilityScreenChangedNotification; + break; + } + default: { + return; + } + } + + UIAccessibilityPostNotification(notification, args ?? null); + } + + public accessibilityAnnouncement(msg = this.accessibilityLabel): void { + if (!isAccessibilityServiceEnabled()) { + return; + } + + this.iosPostAccessibilityNotification(IOSPostAccessibilityNotificationType.Announcement, msg); + } + + public accessibilityScreenChanged(): void { + if (!isAccessibilityServiceEnabled()) { + return; + } + + this.iosPostAccessibilityNotification(IOSPostAccessibilityNotificationType.Screen); + } + _getCurrentLayoutBounds(): { left: number; top: number; diff --git a/packages/core/ui/core/view/view-common.ts b/packages/core/ui/core/view/view-common.ts index 511025e54..f65547374 100644 --- a/packages/core/ui/core/view/view-common.ts +++ b/packages/core/ui/core/view/view-common.ts @@ -22,6 +22,9 @@ import { LinearGradient } from '../../styling/linear-gradient'; import { TextTransform } from '../../text-base'; import * as am from '../../animation'; +import { AccessibilityLiveRegion, AccessibilityRole, AccessibilityState, AccessibilityTrait, AndroidAccessibilityEvent, IOSPostAccessibilityNotificationType } from '../../../accessibility/accessibility-types'; +import { accessibilityHintProperty, accessibilityIdentifierProperty, accessibilityLabelProperty, accessibilityTraitsProperty, accessibilityValueProperty } from '../../../accessibility/accessibility-properties'; +import { accessibilityBlurEvent, accessibilityFocusChangedEvent, accessibilityFocusEvent, getCurrentFontScale } from '../../../accessibility'; import { BoxShadow } from '../../styling/box-shadow'; // helpers (these are okay re-exported here) @@ -68,6 +71,9 @@ export abstract class ViewCommon extends ViewBase implements ViewDefinition { public static layoutChangedEvent = 'layoutChanged'; public static shownModallyEvent = 'shownModally'; public static showingModallyEvent = 'showingModally'; + public static accessibilityBlurEvent = accessibilityBlurEvent; + public static accessibilityFocusEvent = accessibilityFocusEvent; + public static accessibilityFocusChangedEvent = accessibilityFocusChangedEvent; protected _closeModalCallback: Function; public _manager: any; @@ -91,6 +97,8 @@ export abstract class ViewCommon extends ViewBase implements ViewDefinition { public _gestureObservers = {}; + _androidContentDescriptionUpdated?: boolean; + get css(): string { const scope = this._styleScope; @@ -360,6 +368,7 @@ export abstract class ViewCommon extends ViewBase implements ViewDefinition { modalRootViewCssClasses.forEach((c) => this.cssClasses.add(c)); parent._modal = this; + this.style._fontScale = getCurrentFontScale(); this._modalParent = parent; this._modalContext = options.context; const that = this; @@ -743,6 +752,71 @@ export abstract class ViewCommon extends ViewBase implements ViewDefinition { this.style.scaleY = value; } + get accessible(): boolean { + return this.style.accessible; + } + set accessible(value: boolean) { + this.style.accessible = value; + } + + get accessibilityHidden(): boolean { + return this.style.accessibilityHidden; + } + set accessibilityHidden(value: boolean) { + this.style.accessibilityHidden = value; + } + + public accessibilityIdentifier: string; + + get accessibilityRole(): AccessibilityRole { + return this.style.accessibilityRole; + } + set accessibilityRole(value: AccessibilityRole) { + this.style.accessibilityRole = value; + } + + get accessibilityState(): AccessibilityState { + return this.style.accessibilityState; + } + set accessibilityState(value: AccessibilityState) { + this.style.accessibilityState = value; + } + + public accessibilityLabel: string; + public accessibilityValue: string; + public accessibilityHint: string; + + get accessibilityLiveRegion(): AccessibilityLiveRegion { + return this.style.accessibilityLiveRegion; + } + set accessibilityLiveRegion(value: AccessibilityLiveRegion) { + this.style.accessibilityLiveRegion = value; + } + + get accessibilityLanguage(): string { + return this.style.accessibilityLanguage; + } + set accessibilityLanguage(value: string) { + this.style.accessibilityLanguage = value; + } + + get accessibilityMediaSession(): boolean { + return this.style.accessibilityMediaSession; + } + set accessibilityMediaSession(value: boolean) { + this.style.accessibilityMediaSession = value; + } + + public accessibilityTraits?: AccessibilityTrait[]; + + get automationText(): string { + return this.accessibilityIdentifier; + } + + set automationText(value: string) { + this.accessibilityIdentifier = value; + } + get androidElevation(): number { return this.style.androidElevation; } @@ -759,7 +833,6 @@ export abstract class ViewCommon extends ViewBase implements ViewDefinition { //END Style property shortcuts - public automationText: string; public originX: number; public originY: number; public isEnabled: boolean; @@ -1013,12 +1086,23 @@ export abstract class ViewCommon extends ViewBase implements ViewDefinition { return false; } -} -export const automationTextProperty = new Property({ - name: 'automationText', -}); -automationTextProperty.register(ViewCommon); + public androidSendAccessibilityEvent(eventName: AndroidAccessibilityEvent, msg?: string): void { + return; + } + + public iosPostAccessibilityNotification(notificationType: IOSPostAccessibilityNotificationType, msg?: string): void { + return; + } + + public accessibilityAnnouncement(msg?: string): void { + return; + } + + public accessibilityScreenChanged(): void { + return; + } +} export const originXProperty = new Property({ name: 'originX', @@ -1070,3 +1154,9 @@ export const iosIgnoreSafeAreaProperty = new InheritedProperty({ valueConverter: booleanConverter, }); iosIgnoreSafeAreaProperty.register(ViewCommon); + +accessibilityIdentifierProperty.register(ViewCommon); +accessibilityLabelProperty.register(ViewCommon); +accessibilityValueProperty.register(ViewCommon); +accessibilityHintProperty.register(ViewCommon); +accessibilityTraitsProperty.register(ViewCommon); diff --git a/packages/core/ui/page/index.android.ts b/packages/core/ui/page/index.android.ts index bac60a4a4..7f316923b 100644 --- a/packages/core/ui/page/index.android.ts +++ b/packages/core/ui/page/index.android.ts @@ -5,6 +5,7 @@ import { ActionBar } from '../action-bar'; import { GridLayout } from '../layouts/grid-layout'; import { Device } from '../../platform'; import { profile } from '../../profiling'; +import { AndroidAccessibilityEvent, getLastFocusedViewOnPage, isAccessibilityServiceEnabled } from '../../accessibility'; export * from './page-common'; @@ -122,4 +123,31 @@ export class Page extends PageBase { (window).setStatusBarColor(color); } } + + public accessibilityScreenChanged(refocus = false): void { + if (!isAccessibilityServiceEnabled()) { + return; + } + + if (refocus) { + const lastFocusedView = getLastFocusedViewOnPage(this); + if (lastFocusedView) { + const announceView = lastFocusedView.nativeViewProtected; + if (announceView) { + announceView.sendAccessibilityEvent(android.view.accessibility.AccessibilityEvent.TYPE_VIEW_FOCUSED); + announceView.sendAccessibilityEvent(android.view.accessibility.AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUSED); + + return; + } + } + } + + if (this.actionBarHidden || this.accessibilityLabel) { + this.androidSendAccessibilityEvent(AndroidAccessibilityEvent.WINDOW_STATE_CHANGED); + + return; + } + + this.actionBar.accessibilityScreenChanged(); + } } diff --git a/packages/core/ui/page/index.d.ts b/packages/core/ui/page/index.d.ts index 516f16c43..d7cd14e27 100644 --- a/packages/core/ui/page/index.d.ts +++ b/packages/core/ui/page/index.d.ts @@ -93,6 +93,11 @@ export declare class Page extends PageBase { */ public actionBar: ActionBar; + /** + * Should page changed be annnounced to the screen reader. + */ + public accessibilityAnnouncePageEnabled = true; + /** * A basic method signature to hook an event listener (shortcut alias to the addEventListener method). * @param eventNames - String corresponding to events (e.g. "propertyChange"). Optionally could be used more events separated by `,` (e.g. "propertyChange", "change"). @@ -161,6 +166,11 @@ export declare class Page extends PageBase { */ public onNavigatedFrom(isBackNavigation: boolean): void; //@endprivate + + /** + * Announce screen changed + */ + public accessibilityScreenChanged(refocus?: boolean): void; } /** diff --git a/packages/core/ui/page/index.ios.ts b/packages/core/ui/page/index.ios.ts index afd99aca9..075113925 100644 --- a/packages/core/ui/page/index.ios.ts +++ b/packages/core/ui/page/index.ios.ts @@ -7,6 +7,7 @@ import { PageBase, actionBarHiddenProperty, statusBarStyleProperty } from './pag import { profile } from '../../profiling'; import { iOSNativeHelper, layout } from '../../utils'; +import { getLastFocusedViewOnPage, isAccessibilityServiceEnabled } from '../../accessibility'; export * from './page-common'; @@ -522,6 +523,44 @@ export class Page extends PageBase { } } } + + public accessibilityScreenChanged(refocus = false): void { + if (!isAccessibilityServiceEnabled()) { + return; + } + + if (refocus) { + const lastFocusedView = getLastFocusedViewOnPage(this); + if (lastFocusedView) { + const uiView = lastFocusedView.nativeViewProtected; + if (uiView) { + UIAccessibilityPostNotification(UIAccessibilityScreenChangedNotification, uiView); + + return; + } + } + } + + if (this.actionBarHidden) { + UIAccessibilityPostNotification(UIAccessibilityScreenChangedNotification, this.nativeViewProtected); + + return; + } + + if (this.accessibilityLabel) { + UIAccessibilityPostNotification(UIAccessibilityScreenChangedNotification, this.nativeViewProtected); + + return; + } + + if (this.actionBar.accessibilityLabel || this.actionBar.title) { + UIAccessibilityPostNotification(UIAccessibilityScreenChangedNotification, this.actionBar.nativeView); + + return; + } + + UIAccessibilityPostNotification(UIAccessibilityScreenChangedNotification, this.nativeViewProtected); + } } function invalidateTopmostController(controller: UIViewController): void { diff --git a/packages/core/ui/page/page-common.ts b/packages/core/ui/page/page-common.ts index 9fd630d91..6bfb4b122 100644 --- a/packages/core/ui/page/page-common.ts +++ b/packages/core/ui/page/page-common.ts @@ -33,6 +33,7 @@ export class PageBase extends ContentView { public enableSwipeBackNavigation: boolean; public backgroundSpanUnderStatusBar: boolean; public hasActionBar: boolean; + public accessibilityAnnouncePageEnabled = true; get navigationContext(): any { return this._navigationContext; @@ -126,8 +127,12 @@ export class PageBase extends ContentView { } @profile - public onNavigatedTo(isBackNavigation: boolean) { + public onNavigatedTo(isBackNavigation: boolean): void { this.notify(this.createNavigatedData(PageBase.navigatedToEvent, isBackNavigation)); + + if (this.accessibilityAnnouncePageEnabled) { + this.accessibilityScreenChanged(!!isBackNavigation); + } } @profile @@ -152,6 +157,10 @@ export class PageBase extends ContentView { get _childrenCount(): number { return (this.content ? 1 : 0) + (this._actionBar ? 1 : 0); } + + public accessibilityScreenChanged(refocus?: boolean): void { + return; + } } PageBase.prototype.recycleNativeView = 'never'; diff --git a/packages/core/ui/slider/index.d.ts b/packages/core/ui/slider/index.d.ts index 52bd641f4..7d7a2aa70 100644 --- a/packages/core/ui/slider/index.d.ts +++ b/packages/core/ui/slider/index.d.ts @@ -1,10 +1,14 @@ import { View } from '../core/view'; import { Property, CoercibleProperty } from '../core/properties'; +import { EventData } from '../../data/observable'; /** * Represents a slider component. */ export class Slider extends View { + static readonly accessibilityDecrementEvent = 'accessibilityDecrement'; + static readonly accessibilityIncrementEvent = 'accessibilityIncrement'; + /** * Gets the native [android widget](http://developer.android.com/reference/android/widget/SeekBar.html) that represents the user interface for this component. Valid only when running on Android OS. */ @@ -29,6 +33,11 @@ export class Slider extends View { * Gets or sets a slider max value. The default value is 100. */ maxValue: number; + + /** + * Increase/Decrease step size for iOS Increment-/Decrement events + */ + accessibilityStep: number; } /** @@ -45,3 +54,18 @@ export const minValueProperty: Property; * Represents the observable property backing the maxValue property of each Slider instance. */ export const maxValueProperty: CoercibleProperty; + +/** + * Represents the observable property backing the accessibilityStep property of each Slider instance. + */ +export const accessibilityStepProperty: Property; + +interface AccessibilityIncrementEventData extends EventData { + object: Slider; + value?: number; +} + +interface AccessibilityDecrementEventData extends EventData { + object: Slider; + value?: number; +} diff --git a/packages/core/ui/slider/index.ios.ts b/packages/core/ui/slider/index.ios.ts index 8eb262a7f..d276ae813 100644 --- a/packages/core/ui/slider/index.ios.ts +++ b/packages/core/ui/slider/index.ios.ts @@ -3,9 +3,44 @@ import { Background } from '../styling/background'; import { SliderBase, valueProperty, minValueProperty, maxValueProperty } from './slider-common'; import { colorProperty, backgroundColorProperty, backgroundInternalProperty } from '../styling/style-properties'; import { Color } from '../../color'; +import { AccessibilityDecrementEventData, AccessibilityIncrementEventData } from '.'; export * from './slider-common'; +@NativeClass() +class TNSSlider extends UISlider { + public owner: WeakRef; + + public static initWithOwner(owner: WeakRef) { + const slider = TNSSlider.new() as TNSSlider; + slider.owner = owner; + + return slider; + } + + public accessibilityIncrement() { + const owner = this.owner.get(); + if (!owner) { + this.value += 10; + } else { + this.value = owner._handlerAccessibilityIncrementEvent(); + } + + this.sendActionsForControlEvents(UIControlEvents.ValueChanged); + } + + public accessibilityDecrement() { + const owner = this.owner.get(); + if (!owner) { + this.value += 10; + } else { + this.value = owner._handlerAccessibilityDecrementEvent(); + } + + this.sendActionsForControlEvents(UIControlEvents.ValueChanged); + } +} + @NativeClass class SliderChangeHandlerImpl extends NSObject { private _owner: WeakRef; @@ -30,11 +65,11 @@ class SliderChangeHandlerImpl extends NSObject { } export class Slider extends SliderBase { - nativeViewProtected: UISlider; + nativeViewProtected: TNSSlider; private _changeHandler: NSObject; - public createNativeView() { - return UISlider.new(); + public createNativeView(): TNSSlider { + return TNSSlider.initWithOwner(new WeakRef(this)); } public initNativeView(): void { @@ -47,7 +82,7 @@ export class Slider extends SliderBase { nativeView.addTargetActionForControlEvents(this._changeHandler, 'sliderValueChanged', UIControlEvents.ValueChanged); } - public disposeNativeView() { + public disposeNativeView(): void { this._changeHandler = null; super.disposeNativeView(); } @@ -98,4 +133,36 @@ export class Slider extends SliderBase { [backgroundInternalProperty.setNative](value: Background) { // } + + private getAccessibilityStep(): number { + if (!this.accessibilityStep || this.accessibilityStep <= 0) { + return 10; + } + + return this.accessibilityStep; + } + + public _handlerAccessibilityIncrementEvent(): number { + const args: AccessibilityIncrementEventData = { + object: this, + eventName: SliderBase.accessibilityIncrementEvent, + value: this.value + this.getAccessibilityStep(), + }; + + this.notify(args); + + return args.value; + } + + public _handlerAccessibilityDecrementEvent(): number { + const args: AccessibilityDecrementEventData = { + object: this, + eventName: SliderBase.accessibilityIncrementEvent, + value: this.value - this.getAccessibilityStep(), + }; + + this.notify(args); + + return args.value; + } } diff --git a/packages/core/ui/slider/slider-common.ts b/packages/core/ui/slider/slider-common.ts index fdd867c17..507ddcbce 100644 --- a/packages/core/ui/slider/slider-common.ts +++ b/packages/core/ui/slider/slider-common.ts @@ -1,13 +1,28 @@ import { Slider as SliderDefinition } from '.'; -import { View, CSSType } from '../core/view'; -import { Property, CoercibleProperty } from '../core/properties'; +import { AccessibilityRole } from '../../accessibility'; +import { CoercibleProperty, Property } from '../core/properties'; +import { CSSType, View } from '../core/view'; // TODO: Extract base Range class for slider and progress @CSSType('Slider') export class SliderBase extends View implements SliderDefinition { + static readonly accessibilityIncrementEvent = 'accessibilityIncrement'; + static readonly accessibilityDecrementEvent = 'accessibilityDecrement'; + public value: number; public minValue: number; public maxValue: number; + + get accessibilityStep(): number { + return this.style.accessibilityStep; + } + + set accessibilityStep(value: number) { + this.style.accessibilityStep = value; + } + + accessible = true; + accessibilityRole = AccessibilityRole.Adjustable; } SliderBase.prototype.recycleNativeView = 'auto'; diff --git a/packages/core/ui/styling/font-common.ts b/packages/core/ui/styling/font-common.ts index 19d6b0296..e635ccddc 100644 --- a/packages/core/ui/styling/font-common.ts +++ b/packages/core/ui/styling/font-common.ts @@ -15,7 +15,7 @@ export abstract class Font implements FontDefinition { return this.fontWeight === FontWeight.SEMI_BOLD || this.fontWeight === FontWeight.BOLD || this.fontWeight === '700' || this.fontWeight === FontWeight.EXTRA_BOLD || this.fontWeight === FontWeight.BLACK; } - protected constructor(public readonly fontFamily: string, public readonly fontSize: number, public readonly fontStyle: FontStyle, public readonly fontWeight: FontWeight) {} + protected constructor(public readonly fontFamily: string, public readonly fontSize: number, public readonly fontStyle: FontStyle, public readonly fontWeight: FontWeight, public readonly fontScale: number) {} public abstract getAndroidTypeface(): any /* android.graphics.Typeface */; public abstract getUIFont(defaultFont: any /* UIFont */): any /* UIFont */; @@ -23,6 +23,7 @@ export abstract class Font implements FontDefinition { public abstract withFontStyle(style: string): Font; public abstract withFontWeight(weight: string): Font; public abstract withFontSize(size: number): Font; + public abstract withFontScale(scale: number): Font; public static equals(value1: Font, value2: Font): boolean { // both values are falsy diff --git a/packages/core/ui/styling/font.android.ts b/packages/core/ui/styling/font.android.ts index 2f3128cc0..005cc1993 100644 --- a/packages/core/ui/styling/font.android.ts +++ b/packages/core/ui/styling/font.android.ts @@ -15,7 +15,7 @@ export class Font extends FontBase { private _typeface: android.graphics.Typeface; constructor(family: string, size: number, style: 'normal' | 'italic', weight: FontWeight) { - super(family, size, style, weight); + super(family, size, style, weight, 1); } public withFontFamily(family: string): Font { @@ -34,6 +34,10 @@ export class Font extends FontBase { return new Font(this.fontFamily, size, this.fontStyle, this.fontWeight); } + public withFontScale(scale: number): Font { + return new Font(this.fontFamily, this.fontSize, this.fontStyle, this.fontWeight); + } + public getAndroidTypeface(): android.graphics.Typeface { if (!this._typeface) { this._typeface = createTypeface(this); diff --git a/packages/core/ui/styling/font.d.ts b/packages/core/ui/styling/font.d.ts index 4e9103bde..5e1715685 100644 --- a/packages/core/ui/styling/font.d.ts +++ b/packages/core/ui/styling/font.d.ts @@ -5,6 +5,7 @@ public fontStyle: FontStyle; public fontWeight: FontWeight; public fontSize: number; + public fontScale: number; public isBold: boolean; public isItalic: boolean; @@ -18,6 +19,7 @@ public withFontStyle(style: string): Font; public withFontWeight(weight: string): Font; public withFontSize(size: number): Font; + public withFontScale(scale: number): Font; public static equals(value1: Font, value2: Font): boolean; } diff --git a/packages/core/ui/styling/font.ios.ts b/packages/core/ui/styling/font.ios.ts index fdd9caf80..246072752 100644 --- a/packages/core/ui/styling/font.ios.ts +++ b/packages/core/ui/styling/font.ios.ts @@ -11,28 +11,32 @@ const DEFAULT_MONOSPACE = 'Courier New'; const SUPPORT_FONT_WEIGHTS = parseFloat(Device.osVersion) >= 10.0; export class Font extends FontBase { - public static default = new Font(undefined, undefined, FontStyle.NORMAL, FontWeight.NORMAL); + public static default = new Font(undefined, undefined, FontStyle.NORMAL, FontWeight.NORMAL, 1); private _uiFont: UIFont; - constructor(family: string, size: number, style: FontStyle, weight: FontWeight) { - super(family, size, style, weight); + constructor(family: string, size: number, style: FontStyle, weight: FontWeight, scale: number) { + super(family, size, style, weight, scale); } public withFontFamily(family: string): Font { - return new Font(family, this.fontSize, this.fontStyle, this.fontWeight); + return new Font(family, this.fontSize, this.fontStyle, this.fontWeight, this.fontScale); } public withFontStyle(style: FontStyle): Font { - return new Font(this.fontFamily, this.fontSize, style, this.fontWeight); + return new Font(this.fontFamily, this.fontSize, style, this.fontWeight, this.fontScale); } public withFontWeight(weight: FontWeight): Font { - return new Font(this.fontFamily, this.fontSize, this.fontStyle, weight); + return new Font(this.fontFamily, this.fontSize, this.fontStyle, weight, this.fontScale); } public withFontSize(size: number): Font { - return new Font(this.fontFamily, size, this.fontStyle, this.fontWeight); + return new Font(this.fontFamily, size, this.fontStyle, this.fontWeight, this.fontScale); + } + + public withFontScale(scale: number): Font { + return new Font(this.fontFamily, this.fontSize, this.fontStyle, this.fontWeight, scale); } public getUIFont(defaultFont: UIFont): UIFont { diff --git a/packages/core/ui/styling/style-properties.ts b/packages/core/ui/styling/style-properties.ts index 517e70233..ee30e2382 100644 --- a/packages/core/ui/styling/style-properties.ts +++ b/packages/core/ui/styling/style-properties.ts @@ -1422,6 +1422,27 @@ export const fontFamilyProperty = new InheritedCssProperty({ }); fontFamilyProperty.register(Style); +export const fontScaleProperty = new InheritedCssProperty({ + name: '_fontScale', + cssName: '_fontScale', + affectsLayout: global.isIOS, + valueChanged: (target, oldValue, newValue) => { + if (global.isIOS) { + if (target.viewRef['handleFontSize'] === true) { + return; + } + + const currentFont = target.fontInternal || Font.default; + if (currentFont.fontScale !== newValue) { + const newFont = currentFont.withFontScale(newValue); + target.fontInternal = Font.equals(Font.default, newFont) ? unsetValue : newFont; + } + } + }, + valueConverter: (v) => parseFloat(v), +}); +fontScaleProperty.register(Style); + export const fontSizeProperty = new InheritedCssProperty({ name: 'fontSize', cssName: 'font-size', diff --git a/packages/core/ui/styling/style/index.ts b/packages/core/ui/styling/style/index.ts index b9292b435..edfc77e6c 100644 --- a/packages/core/ui/styling/style/index.ts +++ b/packages/core/ui/styling/style/index.ts @@ -11,6 +11,7 @@ import { Observable } from '../../../data/observable'; import { FlexDirection, FlexWrap, JustifyContent, AlignItems, AlignContent, Order, FlexGrow, FlexShrink, FlexWrapBefore, AlignSelf } from '../../layouts/flexbox-layout'; import { Trace } from '../../../trace'; import { TextAlignment, TextDecoration, TextTransform, WhiteSpace, TextShadow } from '../../text-base'; +import { AccessibilityLiveRegion, AccessibilityRole, AccessibilityState } from '../../../accessibility/accessibility-types'; import { BoxShadow } from '../box-shadow'; export interface CommonLayoutParams { @@ -98,6 +99,7 @@ export class Style extends Observable implements StyleDefinition { } public fontInternal: Font; + public _fontScale: number; public backgroundInternal: Background; public rotate: number; @@ -211,6 +213,16 @@ export class Style extends Observable implements StyleDefinition { public flexWrapBefore: FlexWrapBefore; public alignSelf: AlignSelf; + // Accessibility properties + public accessible: boolean; + public accessibilityHidden: boolean; + public accessibilityRole: AccessibilityRole; + public accessibilityState: AccessibilityState; + public accessibilityLiveRegion: AccessibilityLiveRegion; + public accessibilityLanguage: string; + public accessibilityMediaSession: boolean; + public accessibilityStep: number; + public PropertyBag: { new (): { [property: string]: string }; prototype: { [property: string]: string }; diff --git a/tools/assets/App_Resources/Android/src/main/res/values/ids.xml b/tools/assets/App_Resources/Android/src/main/res/values/ids.xml new file mode 100644 index 000000000..7a40388f1 --- /dev/null +++ b/tools/assets/App_Resources/Android/src/main/res/values/ids.xml @@ -0,0 +1,4 @@ + + + +