feat(core): first class a11y support (#8909)

This commit is contained in:
Morten Sjøgren
2021-01-29 20:51:51 +01:00
committed by Nathan Walker
parent ef9c3b1f5f
commit c46da3fad9
43 changed files with 2938 additions and 47 deletions

View File

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

View File

@ -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 = <UIViewController>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) {

View File

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

View File

@ -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<T extends ViewBase>(id: string): T;
/**
* Returns the child view with the specified domId.
*/
public getViewByDomId<T extends ViewBase>(id: number): T;
/**
* Load view.
* @param view to load.

View File

@ -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 <T>getViewById(this, id);
}
getViewByDomId<T extends ViewBaseDefinition>(domId: number): T {
return <T>getViewByDomId(this, domId);
}
get page(): Page {
if (this.parent) {
return this.parent.page;

View File

@ -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 {
(<any>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 {

View File

@ -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<View, string>;
export const originXProperty: Property<View, number>;
export const originYProperty: Property<View, number>;
export const isEnabledProperty: Property<View, boolean>;

View File

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

View File

@ -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<ViewCommon, string>({
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<ViewCommon, number>({
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);

View File

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

View File

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

View File

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

View File

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

View File

@ -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<Slider, number>;
* Represents the observable property backing the maxValue property of each Slider instance.
*/
export const maxValueProperty: CoercibleProperty<Slider, number>;
/**
* Represents the observable property backing the accessibilityStep property of each Slider instance.
*/
export const accessibilityStepProperty: Property<SliderBase, number>;
interface AccessibilityIncrementEventData extends EventData {
object: Slider;
value?: number;
}
interface AccessibilityDecrementEventData extends EventData {
object: Slider;
value?: number;
}

View File

@ -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<Slider>;
public static initWithOwner(owner: WeakRef<Slider>) {
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<Slider>;
@ -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;
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1422,6 +1422,27 @@ export const fontFamilyProperty = new InheritedCssProperty<Style, string>({
});
fontFamilyProperty.register(Style);
export const fontScaleProperty = new InheritedCssProperty<Style, number>({
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<Style, number>({
name: 'fontSize',
cssName: 'font-size',

View File

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