/*
* Copyright (c) 2010, 2025 BSI Business Systems Integration AG
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/
import {Device, ObjectIdProvider, objects, strings} from '../index';
/**
* Determines whether a labelledby id is inserted at the front or the back of current aria-labelledby value.
*/
export enum AriaLabelledByInsertPosition {
FRONT = 'front',
BACK = 'back'
}
/**
* List of all available ARIA roles.
*
* @see WAI-ARIA Roles
*/
export type AriaRole =
'alert'
| 'alertdialog'
| 'application'
| 'article'
| 'banner'
| 'button'
| 'cell'
| 'checkbox'
| 'columnheader'
| 'combobox'
| 'command'
| 'comment'
| 'complementary'
| 'contentinfo'
| 'definition'
| 'dialog'
| 'directory'
| 'document'
| 'feed'
| 'figure'
| 'form'
| 'grid'
| 'gridcell'
| 'group'
| 'heading'
| 'img'
| 'link'
| 'list'
| 'listbox'
| 'listitem'
| 'log'
| 'main'
| 'mark'
| 'marquee'
| 'math'
| 'menu'
| 'menubar'
| 'menuitem'
| 'menuitemcheckbox'
| 'menuitemradio'
| 'meter'
| 'navigation'
| 'none'
| 'note'
| 'option'
| 'presentation'
| 'progressbar'
| 'radio'
| 'radiogroup'
| 'region'
| 'row'
| 'rowgroup'
| 'rowheader'
| 'scrollbar'
| 'search'
| 'searchbox'
| 'separator'
| 'slider'
| 'spinbutton'
| 'status'
| 'suggestion'
| 'switch'
| 'tab'
| 'table'
| 'tablist'
| 'tabpanel'
| 'term'
| 'textbox'
| 'timer'
| 'toolbar'
| 'tooltip'
| 'tree'
| 'treegrid'
| 'treeitem';
export type AriaHasPopup = 'menu' | 'listbox' | 'tree' | 'grid' | 'dialog' | 'true' | 'false' | boolean;
export type AriaLive = 'assertive' | 'polite' | 'off';
export type AriaCurrent = 'page' | 'step' | 'location' | 'date' | 'time' | 'true' | 'false' | boolean;
export type AriaOrientation = 'horizontal' | 'vertical';
export const aria = {
/******************************************************************************************************************
* Roles
******************************************************************************************************************/
/**
* @see WAI-ARIA Roles
*
* @param $elem element to add/remove the attribute. If null, nothing is changed.
* @param value value of the attribute to set. If null, attribute is removed.
*/
role($elem: JQuery, role: AriaRole) {
if (!$elem) {
return;
}
$elem.attr('role', role);
// Alert should have aria-live set to assertive, except for iOS.
// In iOS this is not recommended because of double speaking issues in VoiceOver
// see https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/ARIA_Live_Regions#roles_with_implicit_live_region_attributes
if (role === 'alert' && !Device.get().isIos()) {
aria.live($elem, 'assertive');
}
// Log and status should have aria-live set to polite.
// see https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/ARIA_Live_Regions#roles_with_implicit_live_region_attributes
if (role === 'log' || role === 'status') {
aria.live($elem, 'polite');
}
},
/******************************************************************************************************************
* Attributes
******************************************************************************************************************/
/**
* Links the element to the given target element by giving an id to the target element (if needed) and prepending this id to the
* given aria attribute. If replace is set to true, completely replaces the attribute.
*
* For insert position see {@param position}, insert position has no effect if the labelledby property is replaced.
*/
_linkElementWithTargetElement($elem: JQuery, $targetElement: JQuery, ariaAttribute: string, position = AriaLabelledByInsertPosition.FRONT, replace = false) {
if (!$elem || !$targetElement || strings.empty(ariaAttribute)) {
return;
}
let targetId = aria.ensureId($targetElement);
if (!replace) {
let attributeValue = $elem.attr(ariaAttribute) || '';
if (attributeValue && !strings.contains(attributeValue, targetId)) {
// Add to the existing value if there is one
if (objects.isNullOrUndefined(position) || position === AriaLabelledByInsertPosition.FRONT) {
targetId += ' ' + attributeValue;
} else if (position === AriaLabelledByInsertPosition.BACK) {
targetId = attributeValue + ' ' + targetId;
} else {
// unknown position
return;
}
}
}
$elem.attr(ariaAttribute, targetId);
},
/**
* @returns the value of the id attribute. If the element doesn't have an id, a new one will be created and assigned.
*/
ensureId($element: JQuery): string {
let id = $element.attr('id');
if (!id) {
// Create an id if the element does not have one yet
id = ObjectIdProvider.get().createUiSeqId();
$element.attr('id', id);
}
return id;
},
/**
* Links the given element with the given label by setting aria-labelledby.
* This allows screen readers to build a catalog of the elements on the screen and their relationships, for example, to read the label when the input is focused.
*
* Per default linked labels are added to existing linked labels separated by space. If you want to completely replace the linked label, set replace to true.
*
* @see ARIA: aria-labelledby
*/
linkElementWithLabel($elem: JQuery, $label: JQuery, position = AriaLabelledByInsertPosition.FRONT, replace = false) {
aria._linkElementWithTargetElement($elem, $label, 'aria-labelledby', position, replace);
},
/**
* Links the given element with the given description by setting aria-describedBy.
*
* Per default linked descriptions are added to existing linked descriptions separated by space. If you want to completely replace the linked description, set replace to true.
*
* @see ARIA: aria-describedby
*/
linkElementWithDescription($elem: JQuery, $description: JQuery, position = AriaLabelledByInsertPosition.FRONT, replace = false) {
aria._linkElementWithTargetElement($elem, $description, 'aria-describedby', position, replace);
},
/**
* Links the given element with the given error message by setting aria-errormessage.
*
* @see ARIA: aria-errormessage
*/
linkElementWithErrorMessage($elem: JQuery, $errorMessage: JQuery) {
aria._linkElementWithTargetElement($elem, $errorMessage, 'aria-errormessage', AriaLabelledByInsertPosition.FRONT, true);
},
removeErrorMessage($elem: JQuery) {
if (!$elem) {
return;
}
$elem.removeAttr('aria-errormessage');
},
/**
* Adds aria heading semantics to {@param $header} and correctly assigns heading level information to the heading as well as the surrounding container {@param $elem}.
* Avoid using empty {@param $header} objects because a screen reader may ignore them in the heading structure leading to inconsistent heading levels.
* Default aria-level for headers is level 2.
*
* @see ARIA: heading role
*
* @see ARIA: aria-level
*/
linkElementWithHeader($elem: JQuery, $header: JQuery, defaultLevel = 2) {
if (!$elem || !$header) {
return;
}
let currentLevel = aria._computeHeaderLevel($elem);
if (currentLevel) {
currentLevel = currentLevel + 1;
} else if (defaultLevel) {
currentLevel = defaultLevel;
}
if (currentLevel) {
aria.role($header, 'heading');
aria.level($header, currentLevel);
aria._addHeaderLevelToElement($elem, currentLevel);
}
},
/**
* In most cases you should just use {@link linkElementWithHeader} which automatically creates an aria heading for you and assigns levels correctly.
*
* Use this to implicitly link your heading with its container by adding the header level to the container.
* This should ensure the heading structure is consistent. Normally your DOM structure looks something like this:
*
*
*
*
*
* After linking your headers to their containers it will look something like this:
*
*
*
* This allows us to go upwards in the DOM structure, find the last header level used, and pick a header level that fits the structure.
* Consequently, when adding a heading to your container and before calling this method, you should use {@link _computeHeaderLevel} on
* your container to find the last header level used and derive your header level accordingly.
* In most cases, this means adding 1 to the derived header level.
*/
_addHeaderLevelToElement($elem: JQuery, level: number) {
if (!$elem) {
return;
}
$elem.attr('data-aria-header-level', level);
},
/**
* In most cases you should just use {@link linkElementWithHeader} which automatically creates an aria heading for you and assigns levels correctly.
* Derives the current header level by going upwards in the DOM structure and finding the last header level used.
* If no parent with a heading is found, returns null.
*/
_computeHeaderLevel($elem: JQuery): number {
if (!$elem) {
return null;
}
let $parentHeader = $elem.parents('[data-aria-header-level]');
if ($parentHeader.length > 0) {
return parseInt($parentHeader.eq(0).attr('data-aria-header-level'));
}
return null;
},
/**
* Links the given element with the given controlled element by setting aria-controls.
*
* @see ARIA: aria-controls
*/
linkElementWithControls($elem: JQuery, $controls: JQuery, position = AriaLabelledByInsertPosition.FRONT, replace = false) {
aria._linkElementWithTargetElement($elem, $controls, 'aria-controls', position, replace);
},
removeControls($elem: JQuery) {
if (!$elem) {
return;
}
$elem.removeAttr('aria-controls');
},
/**
* Links the active descendant with the given element by setting aria-activedescendant.
*
* When an element does not receive focus when navigating, setting the active descendant property of the field that has focus to the element that has "implied" focus
* helps screen readers to announce elements as if they had focus. E.g. a selected sub menu item that is rendered selected, but focus remains on the main menu item.
* Setting the active descendant of the main menu item to the sub menu item will tell the screen reader to announce the currently selected sub item.
*
* @see ARIA: aria-activedescendant
*/
linkElementWithActiveDescendant($elem: JQuery, $activeDescendant: JQuery) {
aria._linkElementWithTargetElement($elem, $activeDescendant, 'aria-activedescendant', AriaLabelledByInsertPosition.FRONT, true);
},
removeActiveDescendant($elem: JQuery) {
if (!$elem) {
return;
}
$elem.removeAttr('aria-activedescendant');
},
/**
* Adds the screen reader only css class to the element, which hides it from seeing users, but is still visible to the screen reader. This can be useful to
* e.g. add hidden description elements and link them to field, or replacing visual content (like charts) with tables that make more sense to screen reader users.
*/
screenReaderOnly($elem: JQuery) {
if (!$elem) {
return;
}
$elem.addClass('sr-only');
},
/**
* @see ARIA: aria-required
*
* @param $elem element to add/remove the attribute. If null, nothing is changed.
* @param value value of the attribute to set. If null, attribute is removed.
*/
required($elem: JQuery, value: boolean) {
if (!$elem) {
return;
}
$elem.attr('aria-required', strings.asString(value));
},
/**
* @see ARIA: aria-invalid
*
* @param $elem element to add/remove the attribute. If null, nothing is changed.
* @param value value of the attribute to set. If null, attribute is removed.
*/
invalid($elem: JQuery, value: boolean) {
if (!$elem) {
return;
}
$elem.attr('aria-invalid', strings.asString(value));
},
/**
* @see ARIA: aria-label
*
* @param $elem element to add/remove the attribute. If null, nothing is changed.
* @param value value of the attribute to set. If null, attribute is removed (if not overridden by allowEmpty).
* @param allowEmpty if set to true, setting label to null/undefined will add an empty string.
*/
label($elem: JQuery, label: string, allowEmpty = false) {
if (!$elem) {
return;
}
if (strings.hasText(label)) {
$elem.attr('aria-label', label);
} else if (allowEmpty) {
$elem.attr('aria-label', '');
} else {
$elem.removeAttr('aria-label');
}
},
/**
* @see ARIA: aria-description
*
* @param $elem element to add/remove the attribute. If null, nothing is changed.
* @param value value of the attribute to set. If null, attribute is removed (if not overridden by allowEmpty).
* @param allowEmpty if set to true, setting label to null/undefined will add an empty string.
*/
description($elem: JQuery, description: string, allowEmpty = false) {
if (!$elem) {
return;
}
if (strings.hasText(description)) {
$elem.attr('aria-description', description);
} else if (allowEmpty) {
$elem.attr('aria-description', '');
} else {
$elem.removeAttr('aria-description');
}
},
/**
* @see ARIA: aria-checked
* @param $elem element to add/remove the attribute. If null, nothing is changed.
* @param value value of the attribute to set. If null, attribute is removed (if not overridden by triStateAllowed).
* @param triStateAllowed if set to true, null/undefined will set the checked property to 'mixed'.
*/
checked($elem: JQuery, value: boolean, triStateAllowed = false) {
if (!$elem) {
return;
}
if (triStateAllowed && value !== true && value !== false) {
$elem.attr('aria-checked', 'mixed');
} else {
$elem.attr('aria-checked', strings.asString(value));
}
},
/**
* @see ARIA: aria-haspopup
*
* @param $elem element to add/remove the attribute. If null, nothing is changed.
* @param type value of the attribute to set. If null, attribute is removed.
*/
hasPopup($elem: JQuery, type: AriaHasPopup) {
if (!$elem) {
return;
}
$elem.attr('aria-haspopup', strings.asString(type));
},
/**
* @see ARIA: aria-expanded
*
* @param $elem element to add/remove the attribute. If null, nothing is changed.
* @param value value of the attribute to set. If null, attribute is removed.
*/
expanded($elem: JQuery, value: boolean) {
if (!$elem) {
return;
}
$elem.attr('aria-expanded', strings.asString(value));
},
/**
* @see ARIA: aria-selected
*
* @param $elem element to add/remove the attribute. If null, nothing is changed.
* @param value value of the attribute to set. If null, attribute is removed.
*/
selected($elem: JQuery, value: boolean) {
if (!$elem) {
return;
}
$elem.attr('aria-selected', strings.asString(value));
},
/**
* @see ARIA: aria-pressed
*
* @param $elem element to add/remove the attribute. If null, nothing is changed.
* @param value value of the attribute to set. If null, attribute is removed.
*/
pressed($elem: JQuery, value: boolean) {
if (!$elem) {
return;
}
$elem.attr('aria-pressed', strings.asString(value));
},
/**
* @see ARIA: aria-live
*
* @param $elem element to add/remove the attribute. If null, nothing is changed.
* @param value value of the attribute to set. If null, attribute is removed.
*/
live($elem: JQuery, value: AriaLive) {
if (!$elem) {
return;
}
$elem.attr('aria-live', value);
},
/**
* @see ARIA: aria-level
*
* @param $elem element to add/remove the attribute. If null, nothing is changed.
* @param value value of the attribute to set. If null, attribute is removed.
*/
level($elem: JQuery, value: number) {
if (!$elem) {
return;
}
$elem.attr('aria-level', value);
},
/**
* @see ARIA: aria-hidden
*
* @param $elem element to add/remove the attribute. If null, nothing is changed.
* @param value value of the attribute to set. If null, attribute is removed.
*/
hidden($elem: JQuery, value: boolean) {
if (!$elem) {
return;
}
$elem.attr('aria-hidden', strings.asString(value));
},
/**
* @see ARIA: aria-multiselectable
*
* @param $elem element to add/remove the attribute. If null, nothing is changed.
* @param value value of the attribute to set. If null, attribute is removed.
*/
multiselectable($elem: JQuery, value: boolean) {
if (!$elem) {
return;
}
$elem.attr('aria-multiselectable', strings.asString(value));
},
/**
* @see ARIA: aria-posinset
*
* @param $elem element to add/remove the attribute. If null, nothing is changed.
* @param value value of the attribute to set. If null, attribute is removed.
*/
posinset($elem: JQuery, value: number) {
if (!$elem) {
return;
}
$elem.attr('aria-posinset', value);
},
/**
* @see ARIA: aria-setsize
*
* @param $elem element to add/remove the attribute. If null, nothing is changed.
* @param value value of the attribute to set. If null, attribute is removed.
*/
setsize($elem: JQuery, value: number) {
if (!$elem) {
return;
}
$elem.attr('aria-setsize', value);
},
/**
* @see ARIA: aria-disabled
*
* @param $elem element to add/remove the attribute. If null, nothing is changed.
* @param value value of the attribute to set. If null, attribute is removed.
*/
disabled($elem: JQuery, value: boolean) {
if (!$elem) {
return;
}
$elem.attr('aria-disabled', strings.asString(value));
},
/**
* @see ARIA: aria-modal
*
* @param $elem element to add/remove the attribute. If null, nothing is changed.
* @param value value of the attribute to set. If null, attribute is removed.
*/
modal($elem: JQuery, value: boolean) {
if (!$elem) {
return;
}
$elem.attr('aria-modal', strings.asString(value));
},
/**
* @see ARIA: aria-current
*
* @param $elem element to add/remove the attribute. If null, nothing is changed.
* @param value value of the attribute to set. If null, attribute is removed.
*/
current($elem: JQuery, value: AriaCurrent) {
if (!$elem) {
return;
}
$elem.attr('aria-current', strings.asString(value));
},
/**
* Sets the aria-orientation attribute.
*
* If `$elem` has a role and the given orientation is the default orientation for that role, the attribute aria-orientation won't be set / will be removed.
* Also, if the role does not support the aria-orientation attribute, it won't be set / will be removed.
*
* @see ARIA: aria-orientation
*
* @param $elem element to add/remove the attribute. If null, nothing is changed.
* @param value value of the attribute to set. If null, attribute is removed.
*/
orientation($elem: JQuery, orientation: AriaOrientation) {
if (!$elem) {
return;
}
let role = $elem.attr('role');
if (role) {
if (!Object.keys(aria.orientationDefault()).includes(role)) {
// Don't set orientation if the role that doesn't support it
orientation = null;
} else {
// Don't set orientation if it is the role's default
orientation = aria.orientationDefault()[role] === orientation ? null : orientation;
}
}
$elem.attr('aria-orientation', strings.asString(orientation));
},
/**
* @returns the orientation defaults per role according to https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-orientation and https://www.w3.org/TR/wai-aria/#aria-orientation
*/
orientationDefault(): Partial> {
return {
scrollbar: 'vertical',
tree: 'vertical',
treegrid: 'vertical',
listbox: 'vertical',
menu: 'vertical',
slider: 'horizontal',
separator: 'horizontal',
tablist: 'horizontal',
toolbar: 'horizontal',
menubar: 'horizontal',
radiogroup: undefined // inherits the orientation attribute from the abstract select role whose default is undefined
};
}
};