diff --git a/.circleci/config.yml b/.circleci/config.yml index f726d76717..1f8e1c7dac 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -54,6 +54,9 @@ commands: enable_scoped_custom_element_registry: type: boolean default: false + disable_aria_reflection_polyfill: + type: boolean + default: false steps: - run: name: << parameters.command_name >> @@ -77,6 +80,7 @@ commands: <<# parameters.force_native_shadow_mode >> FORCE_NATIVE_SHADOW_MODE_FOR_TEST=1 <> \ <<# parameters.enable_native_custom_element_lifecycle >> ENABLE_NATIVE_CUSTOM_ELEMENT_LIFECYCLE=1 <> \ <<# parameters.enable_scoped_custom_element_registry >> ENABLE_SCOPED_CUSTOM_ELEMENT_REGISTRY=1 <> \ + <<# parameters.disable_aria_reflection_polyfill >> DISABLE_ARIA_REFLECTION_POLYFILL=1 <> \ <<# parameters.compat >> COMPAT=1 <> \ <<# parameters.coverage >> COVERAGE=1 <> \ retry << parameters.command >> @@ -167,6 +171,9 @@ commands: enable_scoped_custom_element_registry: type: boolean default: false + disable_aria_reflection_polyfill: + type: boolean + default: false compat: type: boolean default: false @@ -181,6 +188,7 @@ commands: force_native_shadow_mode: << parameters.force_native_shadow_mode >> enable_native_custom_element_lifecycle: << parameters.enable_native_custom_element_lifecycle >> enable_scoped_custom_element_registry: << parameters.enable_scoped_custom_element_registry >> + disable_aria_reflection_polyfill: << parameters.disable_aria_reflection_polyfill >> compat: << parameters.compat >> coverage: << parameters.coverage >> command: yarn sauce @@ -254,6 +262,9 @@ jobs: disable_synthetic: true enable_native_custom_element_lifecycle: true enable_scoped_custom_element_registry: true + - run_karma: + disable_synthetic: true + disable_aria_reflection_polyfill: true - retry_command: command_name: Run karma hydration tests command: yarn hydration:sauce diff --git a/packages/@lwc/aria-reflection-polyfill/src/index.ts b/packages/@lwc/aria-reflection-polyfill/src/index.ts deleted file mode 100644 index 1a1c246d8d..0000000000 --- a/packages/@lwc/aria-reflection-polyfill/src/index.ts +++ /dev/null @@ -1,18 +0,0 @@ -/* - * Copyright (c) 2018, salesforce.com, inc. - * All rights reserved. - * SPDX-License-Identifier: MIT - * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/MIT - */ -import { AriaPropNameToAttrNameMap, keys } from '@lwc/shared'; -import { detect } from './detect'; -import { patch } from './polyfill'; - -const ElementPrototypeAriaPropertyNames = keys(AriaPropNameToAttrNameMap); - -for (let i = 0, len = ElementPrototypeAriaPropertyNames.length; i < len; i += 1) { - const propName = ElementPrototypeAriaPropertyNames[i]; - if (detect(propName)) { - patch(propName); - } -} diff --git a/packages/@lwc/aria-reflection-polyfill/src/polyfill.ts b/packages/@lwc/aria-reflection-polyfill/src/polyfill.ts deleted file mode 100644 index 8931e3aedf..0000000000 --- a/packages/@lwc/aria-reflection-polyfill/src/polyfill.ts +++ /dev/null @@ -1,69 +0,0 @@ -/* - * Copyright (c) 2018, salesforce.com, inc. - * All rights reserved. - * SPDX-License-Identifier: MIT - * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/MIT - */ -import { hasOwnProperty, AriaPropNameToAttrNameMap } from '@lwc/shared'; - -type NormalizedAttributeValue = string | null; -type AriaPropMap = Record; - -const nodeToAriaPropertyValuesMap: WeakMap = new WeakMap(); - -function getAriaPropertyMap(elm: HTMLElement): AriaPropMap { - let map = nodeToAriaPropertyValuesMap.get(elm); - - if (map === undefined) { - map = {}; - nodeToAriaPropertyValuesMap.set(elm, map); - } - - return map; -} - -function getNormalizedAriaPropertyValue(value: any): NormalizedAttributeValue { - return value == null ? null : String(value); -} - -function createAriaPropertyPropertyDescriptor( - propName: string, - attrName: string -): PropertyDescriptor { - return { - get(this: HTMLElement): any { - const map = getAriaPropertyMap(this); - - if (hasOwnProperty.call(map, propName)) { - return map[propName]; - } - - // otherwise just reflect what's in the attribute - return this.hasAttribute(attrName) ? this.getAttribute(attrName) : null; - }, - set(this: HTMLElement, newValue: any) { - const normalizedValue = getNormalizedAriaPropertyValue(newValue); - - const map = getAriaPropertyMap(this); - map[propName] = normalizedValue; - - // reflect into the corresponding attribute - if (newValue === null) { - this.removeAttribute(attrName); - } else { - this.setAttribute(attrName, newValue); - } - }, - configurable: true, - enumerable: true, - }; -} - -export function patch(propName: string) { - // Typescript is inferring the wrong function type for this particular - // overloaded method: https://github.com/Microsoft/TypeScript/issues/27972 - // @ts-ignore type-mismatch - const attrName = AriaPropNameToAttrNameMap[propName]; - const descriptor = createAriaPropertyPropertyDescriptor(propName, attrName); - Object.defineProperty(Element.prototype, propName, descriptor); -} diff --git a/packages/@lwc/aria-reflection-polyfill/README.md b/packages/@lwc/aria-reflection/README.md similarity index 81% rename from packages/@lwc/aria-reflection-polyfill/README.md rename to packages/@lwc/aria-reflection/README.md index 466576468c..c6f9b1d798 100644 --- a/packages/@lwc/aria-reflection-polyfill/README.md +++ b/packages/@lwc/aria-reflection/README.md @@ -1,4 +1,4 @@ -# @lwc/aria-reflection-polyfill +# @lwc/aria-reflection Polyfill for [ARIA string reflection](https://wicg.github.io/aom/spec/aria-reflection.html) on Elements. This is part of the [Accessibility Object Model](https://wicg.github.io/aom/explainer.html) (AOM). @@ -17,14 +17,24 @@ Note that the attribute `aria-pressed` is reflected to the property `ariaPressed ## Usage ```shell -npm install @lwc/aria-reflection-polyfill +npm install @lwc/aria-reflection ``` ```js -import '@lwc/aria-reflection-polyfill'; +import { applyAriaReflection } from '@lwc/aria-reflection'; + +applyAriaReflection(); +``` + +The polyfill is applied as soon as the function is executed. + +Optionally, you can pass in a custom prototype: + +```js +applyAriaReflection(MyCustomElement.prototype); ``` -The polyfill is applied as soon as it's imported. +By default, the polyfill is applied to the global `Element.prototype`. ## Implementation diff --git a/packages/@lwc/aria-reflection-polyfill/package.json b/packages/@lwc/aria-reflection/package.json similarity index 91% rename from packages/@lwc/aria-reflection-polyfill/package.json rename to packages/@lwc/aria-reflection/package.json index baa11d8802..7ccedfb74a 100644 --- a/packages/@lwc/aria-reflection-polyfill/package.json +++ b/packages/@lwc/aria-reflection/package.json @@ -1,12 +1,12 @@ { - "name": "@lwc/aria-reflection-polyfill", + "name": "@lwc/aria-reflection", "version": "2.32.1", "description": "ARIA element reflection polyfill for strings", "homepage": "https://lwc.dev/", "repository": { "type": "git", "url": "https://github.com/salesforce/lwc.git", - "directory": "packages/@lwc/aria-reflection-polyfill" + "directory": "packages/@lwc/aria-reflection" }, "bugs": { "url": "https://github.com/salesforce/lwc/issues" diff --git a/packages/@lwc/aria-reflection-polyfill/scripts/rollup.config.js b/packages/@lwc/aria-reflection/scripts/rollup.config.js similarity index 100% rename from packages/@lwc/aria-reflection-polyfill/scripts/rollup.config.js rename to packages/@lwc/aria-reflection/scripts/rollup.config.js diff --git a/packages/@lwc/aria-reflection-polyfill/src/detect.ts b/packages/@lwc/aria-reflection/src/detect.ts similarity index 50% rename from packages/@lwc/aria-reflection-polyfill/src/detect.ts rename to packages/@lwc/aria-reflection/src/detect.ts index 16c7d8ca7a..0baad671c5 100644 --- a/packages/@lwc/aria-reflection-polyfill/src/detect.ts +++ b/packages/@lwc/aria-reflection/src/detect.ts @@ -4,8 +4,8 @@ * SPDX-License-Identifier: MIT * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/MIT */ -import { getOwnPropertyDescriptor } from '@lwc/shared'; +import { getOwnPropertyDescriptor, isUndefined } from '@lwc/shared'; -export function detect(propName: string): boolean { - return getOwnPropertyDescriptor(Element.prototype, propName) === undefined; +export function detect(propName: string, prototype: any): boolean { + return isUndefined(getOwnPropertyDescriptor(prototype, propName)); } diff --git a/packages/@lwc/aria-reflection/src/index.ts b/packages/@lwc/aria-reflection/src/index.ts new file mode 100644 index 0000000000..b58431d7d6 --- /dev/null +++ b/packages/@lwc/aria-reflection/src/index.ts @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2018, salesforce.com, inc. + * All rights reserved. + * SPDX-License-Identifier: MIT + * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/MIT + */ +import { AriaPropNameToAttrNameMap, keys } from '@lwc/shared'; +import { detect } from './detect'; +import { patch } from './polyfill'; + +export function applyAriaReflection(prototype: any = Element.prototype) { + const ElementPrototypeAriaPropertyNames = keys(AriaPropNameToAttrNameMap); + + for (let i = 0, len = ElementPrototypeAriaPropertyNames.length; i < len; i += 1) { + const propName = ElementPrototypeAriaPropertyNames[i]; + if (detect(propName, prototype)) { + patch(propName, prototype); + } + } +} diff --git a/packages/@lwc/aria-reflection/src/polyfill.ts b/packages/@lwc/aria-reflection/src/polyfill.ts new file mode 100644 index 0000000000..c69863ee34 --- /dev/null +++ b/packages/@lwc/aria-reflection/src/polyfill.ts @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2018, salesforce.com, inc. + * All rights reserved. + * SPDX-License-Identifier: MIT + * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/MIT + */ +import { AriaPropNameToAttrNameMap, isNull, defineProperty } from '@lwc/shared'; + +function createAriaPropertyPropertyDescriptor(attrName: string): PropertyDescriptor { + return { + get(this: HTMLElement): any { + // reflect what's in the attribute + return this.hasAttribute(attrName) ? this.getAttribute(attrName) : null; + }, + set(this: HTMLElement, newValue: any) { + // reflect into the corresponding attribute + if (isNull(newValue)) { + this.removeAttribute(attrName); + } else { + this.setAttribute(attrName, newValue); + } + }, + configurable: true, + enumerable: true, + }; +} + +export function patch(propName: string, prototype: any) { + // Typescript is inferring the wrong function type for this particular + // overloaded method: https://github.com/Microsoft/TypeScript/issues/27972 + // @ts-ignore type-mismatch + const attrName = AriaPropNameToAttrNameMap[propName]; + const descriptor = createAriaPropertyPropertyDescriptor(attrName); + defineProperty(prototype, propName, descriptor); +} diff --git a/packages/@lwc/aria-reflection-polyfill/tsconfig.json b/packages/@lwc/aria-reflection/tsconfig.json similarity index 100% rename from packages/@lwc/aria-reflection-polyfill/tsconfig.json rename to packages/@lwc/aria-reflection/tsconfig.json diff --git a/packages/@lwc/engine-core/package.json b/packages/@lwc/engine-core/package.json index d7e910acf3..2f3a743ace 100644 --- a/packages/@lwc/engine-core/package.json +++ b/packages/@lwc/engine-core/package.json @@ -24,6 +24,7 @@ "types/" ], "dependencies": { + "@lwc/aria-reflection": "2.32.1", "@lwc/features": "2.32.1", "@lwc/shared": "2.32.1" }, diff --git a/packages/@lwc/engine-core/src/framework/base-bridge-element.ts b/packages/@lwc/engine-core/src/framework/base-bridge-element.ts index f7054fe432..dcbdd2ae45 100644 --- a/packages/@lwc/engine-core/src/framework/base-bridge-element.ts +++ b/packages/@lwc/engine-core/src/framework/base-bridge-element.ts @@ -22,6 +22,8 @@ import { keys, htmlPropertyToAttribute, } from '@lwc/shared'; +import features from '@lwc/features'; +import { applyAriaReflection } from '@lwc/aria-reflection'; import { getAssociatedVM } from './vm'; import { getReadOnlyProxy } from './membrane'; @@ -177,6 +179,18 @@ export function HTMLBridgeElementFactory( configurable: true, }; } + if (process.env.IS_BROWSER) { + // This ARIA reflection only really makes sense in the browser. On the server, there is no `renderedCallback()`, + // so you cannot do e.g. `this.template.querySelector('x-child').ariaBusy = 'true'`. So we don't need to expose + // ARIA props outside the LightningElement + if (features.DISABLE_ARIA_REFLECTION_POLYFILL) { + // If ARIA reflection is not applied globally to Element.prototype, apply it to HTMLBridgeElement.prototype. + // This allows `elm.aria*` property accessors to work from outside a component, and to reflect `aria-*` attrs. + // This is especially important because the template compiler compiles aria-* attrs on components to aria* props + applyAriaReflection(HTMLBridgeElement.prototype); + } + } + // creating a new attributeChangedCallback per bridge because they are bound to the corresponding // map of attributes to props. We do this after all other props and methods to avoid the possibility // of getting overrule by a class declaration in user-land, and we make it non-writable, non-configurable diff --git a/packages/@lwc/engine-core/src/framework/base-lightning-element.ts b/packages/@lwc/engine-core/src/framework/base-lightning-element.ts index dce6c8d838..ad8783061d 100644 --- a/packages/@lwc/engine-core/src/framework/base-lightning-element.ts +++ b/packages/@lwc/engine-core/src/framework/base-lightning-element.ts @@ -27,6 +27,8 @@ import { keys, setPrototypeOf, } from '@lwc/shared'; +import features from '@lwc/features'; +import { applyAriaReflection } from '@lwc/aria-reflection'; import { logError } from '../shared/logger'; import { getComponentTag } from '../shared/format'; @@ -694,6 +696,21 @@ for (const propName in HTMLElementOriginalDescriptors) { defineProperties(LightningElement.prototype, lightningBasedDescriptors); +function applyAriaReflectionToLightningElement() { + // If ARIA reflection is not applied globally to Element.prototype, or if we are running server-side, + // apply it to LightningElement.prototype. + // This allows `this.aria*` property accessors to work from inside a component, and to reflect `aria-*` attrs. + applyAriaReflection(LightningElement.prototype); +} + +// The reason for this odd if/else branching is limitations in @lwc/features: +// https://github.com/salesforce/lwc/blob/master/packages/%40lwc/features/README.md#only-works-with-if-statements +if (features.DISABLE_ARIA_REFLECTION_POLYFILL) { + applyAriaReflectionToLightningElement(); +} else if (!process.env.IS_BROWSER) { + applyAriaReflectionToLightningElement(); +} + defineProperty(LightningElement, 'CustomElementConstructor', { get() { // If required, a runtime-specific implementation must be defined. diff --git a/packages/@lwc/engine-dom/package.json b/packages/@lwc/engine-dom/package.json index cc209c87a2..d845cf47ba 100644 --- a/packages/@lwc/engine-dom/package.json +++ b/packages/@lwc/engine-dom/package.json @@ -24,6 +24,7 @@ "types/" ], "devDependencies": { + "@lwc/aria-reflection": "2.32.1", "@lwc/engine-core": "2.32.1", "@lwc/shared": "2.32.1" }, diff --git a/packages/@lwc/engine-dom/src/aria-reflection-polyfill.ts b/packages/@lwc/engine-dom/src/aria-reflection-polyfill.ts new file mode 100644 index 0000000000..67dbaa0eeb --- /dev/null +++ b/packages/@lwc/engine-dom/src/aria-reflection-polyfill.ts @@ -0,0 +1,14 @@ +/* + * Copyright (c) 2018, salesforce.com, inc. + * All rights reserved. + * SPDX-License-Identifier: MIT + * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/MIT + */ +import features from '@lwc/features'; +import { applyAriaReflection } from '@lwc/aria-reflection'; + +if (!features.DISABLE_ARIA_REFLECTION_POLYFILL) { + // If DISABLE_ARIA_REFLECTION_POLYFILL is false, then we need to apply the ARIA reflection polyfill globally, + // i.e. to the global Element.prototype + applyAriaReflection(); +} diff --git a/packages/@lwc/engine-dom/src/index.ts b/packages/@lwc/engine-dom/src/index.ts index 247a50b2b2..6127edd439 100644 --- a/packages/@lwc/engine-dom/src/index.ts +++ b/packages/@lwc/engine-dom/src/index.ts @@ -6,7 +6,7 @@ */ // Polyfills --------------------------------------------------------------------------------------- -import '@lwc/aria-reflection-polyfill'; +import './aria-reflection-polyfill'; // Tests ------------------------------------------------------------------------------------------- import './testFeatureFlag.ts'; diff --git a/packages/@lwc/engine-server/src/__tests__/fixtures.spec.ts b/packages/@lwc/engine-server/src/__tests__/fixtures.spec.ts index 19d6e7e896..307ebb7158 100755 --- a/packages/@lwc/engine-server/src/__tests__/fixtures.spec.ts +++ b/packages/@lwc/engine-server/src/__tests__/fixtures.spec.ts @@ -14,6 +14,7 @@ import lwcRollupPlugin from '@lwc/rollup-plugin'; import { isVoidElement, HTML_NAMESPACE } from '@lwc/shared'; import { testFixtureDir } from '@lwc/jest-utils-lwc-internals'; import { setFeatureFlagForTest } from '../index'; +import type { FeatureFlagMap } from '@lwc/features'; import type * as lwc from '../index'; interface FixtureModule { @@ -183,15 +184,23 @@ describe('fixtures', () => { testFixtures(); }); - describe('native custom element lifecycle', () => { + function testWithFeatureFlagEnabled(flagName: keyof FeatureFlagMap) { beforeEach(() => { - setFeatureFlagForTest('ENABLE_NATIVE_CUSTOM_ELEMENT_LIFECYCLE', true); + setFeatureFlagForTest(flagName, true); }); afterEach(() => { - setFeatureFlagForTest('ENABLE_NATIVE_CUSTOM_ELEMENT_LIFECYCLE', false); + setFeatureFlagForTest(flagName, false); }); testFixtures(); + } + + describe('native custom element lifecycle', () => { + testWithFeatureFlagEnabled('ENABLE_NATIVE_CUSTOM_ELEMENT_LIFECYCLE'); + }); + + describe('disable aria reflection polyfill', () => { + testWithFeatureFlagEnabled('DISABLE_ARIA_REFLECTION_POLYFILL'); }); }); diff --git a/packages/@lwc/engine-server/src/__tests__/fixtures/attribute-aria-modify/expected.html b/packages/@lwc/engine-server/src/__tests__/fixtures/attribute-aria-modify/expected.html new file mode 100644 index 0000000000..2249685af7 --- /dev/null +++ b/packages/@lwc/engine-server/src/__tests__/fixtures/attribute-aria-modify/expected.html @@ -0,0 +1,4 @@ + + + \ No newline at end of file diff --git a/packages/@lwc/engine-server/src/__tests__/fixtures/attribute-aria-modify/index.js b/packages/@lwc/engine-server/src/__tests__/fixtures/attribute-aria-modify/index.js new file mode 100644 index 0000000000..76abf71393 --- /dev/null +++ b/packages/@lwc/engine-server/src/__tests__/fixtures/attribute-aria-modify/index.js @@ -0,0 +1,2 @@ +export const tagName = 'x-cmp'; +export { default } from 'x/cmp'; diff --git a/packages/@lwc/engine-server/src/__tests__/fixtures/attribute-aria-modify/modules/x/cmp/cmp.html b/packages/@lwc/engine-server/src/__tests__/fixtures/attribute-aria-modify/modules/x/cmp/cmp.html new file mode 100644 index 0000000000..6beff5199f --- /dev/null +++ b/packages/@lwc/engine-server/src/__tests__/fixtures/attribute-aria-modify/modules/x/cmp/cmp.html @@ -0,0 +1,2 @@ + diff --git a/packages/@lwc/engine-server/src/__tests__/fixtures/attribute-aria-modify/modules/x/cmp/cmp.js b/packages/@lwc/engine-server/src/__tests__/fixtures/attribute-aria-modify/modules/x/cmp/cmp.js new file mode 100644 index 0000000000..6a7622bf5d --- /dev/null +++ b/packages/@lwc/engine-server/src/__tests__/fixtures/attribute-aria-modify/modules/x/cmp/cmp.js @@ -0,0 +1,13 @@ +import { LightningElement } from 'lwc'; + +export default class extends LightningElement { + connectedCallback() { + // Modify this component's aria properties at runtime, which should be reflected to attributes + + // Standard ARIA property + this.ariaBusy = 'true' + + // Non-standard LWC-specific legacy ARIA property + this.ariaActiveDescendant = 'foo' + } +} diff --git a/packages/@lwc/engine-server/src/__tests__/fixtures/attribute-aria/expected.html b/packages/@lwc/engine-server/src/__tests__/fixtures/attribute-aria/expected.html new file mode 100644 index 0000000000..22451f0472 --- /dev/null +++ b/packages/@lwc/engine-server/src/__tests__/fixtures/attribute-aria/expected.html @@ -0,0 +1,10 @@ + + + \ No newline at end of file diff --git a/packages/@lwc/engine-server/src/__tests__/fixtures/attribute-aria/index.js b/packages/@lwc/engine-server/src/__tests__/fixtures/attribute-aria/index.js new file mode 100644 index 0000000000..51ac66cc15 --- /dev/null +++ b/packages/@lwc/engine-server/src/__tests__/fixtures/attribute-aria/index.js @@ -0,0 +1,2 @@ +export const tagName = 'x-parent'; +export { default } from 'x/parent'; diff --git a/packages/@lwc/engine-server/src/__tests__/fixtures/attribute-aria/modules/x/child/child.html b/packages/@lwc/engine-server/src/__tests__/fixtures/attribute-aria/modules/x/child/child.html new file mode 100644 index 0000000000..cc340bc4c9 --- /dev/null +++ b/packages/@lwc/engine-server/src/__tests__/fixtures/attribute-aria/modules/x/child/child.html @@ -0,0 +1 @@ + diff --git a/packages/@lwc/engine-server/src/__tests__/fixtures/attribute-aria/modules/x/child/child.js b/packages/@lwc/engine-server/src/__tests__/fixtures/attribute-aria/modules/x/child/child.js new file mode 100644 index 0000000000..ca8dce94e0 --- /dev/null +++ b/packages/@lwc/engine-server/src/__tests__/fixtures/attribute-aria/modules/x/child/child.js @@ -0,0 +1,3 @@ +import { LightningElement } from 'lwc'; + +export default class extends LightningElement {} diff --git a/packages/@lwc/engine-server/src/__tests__/fixtures/attribute-aria/modules/x/parent/parent.html b/packages/@lwc/engine-server/src/__tests__/fixtures/attribute-aria/modules/x/parent/parent.html new file mode 100644 index 0000000000..d612d20efc --- /dev/null +++ b/packages/@lwc/engine-server/src/__tests__/fixtures/attribute-aria/modules/x/parent/parent.html @@ -0,0 +1,102 @@ + diff --git a/packages/@lwc/engine-server/src/__tests__/fixtures/attribute-aria/modules/x/parent/parent.js b/packages/@lwc/engine-server/src/__tests__/fixtures/attribute-aria/modules/x/parent/parent.js new file mode 100644 index 0000000000..ca8dce94e0 --- /dev/null +++ b/packages/@lwc/engine-server/src/__tests__/fixtures/attribute-aria/modules/x/parent/parent.js @@ -0,0 +1,3 @@ +import { LightningElement } from 'lwc'; + +export default class extends LightningElement {} diff --git a/packages/@lwc/features/src/flags.ts b/packages/@lwc/features/src/flags.ts index d10297ebc5..cae63d00c2 100644 --- a/packages/@lwc/features/src/flags.ts +++ b/packages/@lwc/features/src/flags.ts @@ -17,6 +17,7 @@ const features: FeatureFlagMap = { DISABLE_LIGHT_DOM_UNSCOPED_CSS: null, ENABLE_SCOPED_CUSTOM_ELEMENT_REGISTRY: null, ENABLE_FROZEN_TEMPLATE: null, + DISABLE_ARIA_REFLECTION_POLYFILL: null, }; if (!globalThis.lwcRuntimeFlags) { @@ -80,3 +81,5 @@ export function setFeatureFlagForTest(name: FeatureFlagName, value: FeatureFlagV export const runtimeFlags = lwcRuntimeFlags; // backwards compatibility for before this was renamed export default features; + +export type { FeatureFlagMap }; diff --git a/packages/@lwc/features/src/types.ts b/packages/@lwc/features/src/types.ts index b5b1983cd4..06cff398c0 100644 --- a/packages/@lwc/features/src/types.ts +++ b/packages/@lwc/features/src/types.ts @@ -74,6 +74,13 @@ export interface FeatureFlagMap { * ``` */ ENABLE_FROZEN_TEMPLATE: FeatureFlagValue; + + /** + * Flag to remove the ARIA reflection polyfill. When set to true, this flag will avoid the global DOM patching + * to polyfill ARIA reflection. Instead, the necessary ARIA properties will only exist on the LightningElement + * and HTMLBridgeElement base classes, not on every Element. + */ + DISABLE_ARIA_REFLECTION_POLYFILL: FeatureFlagValue; } export type FeatureFlagName = keyof FeatureFlagMap; diff --git a/packages/@lwc/integration-karma/README.md b/packages/@lwc/integration-karma/README.md index f64930bc22..1cb1282793 100644 --- a/packages/@lwc/integration-karma/README.md +++ b/packages/@lwc/integration-karma/README.md @@ -34,6 +34,7 @@ This set of environment variables applies to the `start` and `test` commands: - **`DISABLE_SYNTHETIC=1`:** Run without any synthetic shadow polyfill patches. - **`FORCE_NATIVE_SHADOW_MODE_FOR_TEST=1`:** Force tests to run in native shadow mode with synthetic shadow polyfill patches. - **`ENABLE_NATIVE_CUSTOM_ELEMENT_LIFECYCLE=1`:** Use native custom element lifecycle callbacks. +- **`DISABLE_ARIA_REFLECTION_POLYFILL=1`:** Disable usage of `@lwc/aria-reflection` as a global polyfill. - **`COVERAGE=1`:** Gather engine code coverage, and store it in the `coverage` folder. - **`GREP="pattern"`:** Filter the spec to run based on the pattern. diff --git a/packages/@lwc/integration-karma/helpers/test-utils.js b/packages/@lwc/integration-karma/helpers/test-utils.js index 6da4f9ff2c..56d052dafb 100644 --- a/packages/@lwc/integration-karma/helpers/test-utils.js +++ b/packages/@lwc/integration-karma/helpers/test-utils.js @@ -442,6 +442,65 @@ window.TestUtils = (function (lwc, jasmine, beforeAll) { } } + // This mapping should be kept up-to-date with the mapping in @lwc/shared -> aria.ts + var ariaPropertiesMapping = { + ariaAutoComplete: 'aria-autocomplete', + ariaChecked: 'aria-checked', + ariaCurrent: 'aria-current', + ariaDisabled: 'aria-disabled', + ariaExpanded: 'aria-expanded', + ariaHasPopup: 'aria-haspopup', + ariaHidden: 'aria-hidden', + ariaInvalid: 'aria-invalid', + ariaLabel: 'aria-label', + ariaLevel: 'aria-level', + ariaMultiLine: 'aria-multiline', + ariaMultiSelectable: 'aria-multiselectable', + ariaOrientation: 'aria-orientation', + ariaPressed: 'aria-pressed', + ariaReadOnly: 'aria-readonly', + ariaRequired: 'aria-required', + ariaSelected: 'aria-selected', + ariaSort: 'aria-sort', + ariaValueMax: 'aria-valuemax', + ariaValueMin: 'aria-valuemin', + ariaValueNow: 'aria-valuenow', + ariaValueText: 'aria-valuetext', + ariaLive: 'aria-live', + ariaRelevant: 'aria-relevant', + ariaAtomic: 'aria-atomic', + ariaBusy: 'aria-busy', + ariaActiveDescendant: 'aria-activedescendant', + ariaControls: 'aria-controls', + ariaDescribedBy: 'aria-describedby', + ariaFlowTo: 'aria-flowto', + ariaLabelledBy: 'aria-labelledby', + ariaOwns: 'aria-owns', + ariaPosInSet: 'aria-posinset', + ariaSetSize: 'aria-setsize', + ariaColCount: 'aria-colcount', + ariaColSpan: 'aria-colspan', + ariaColIndex: 'aria-colindex', + ariaDetails: 'aria-details', + ariaErrorMessage: 'aria-errormessage', + ariaKeyShortcuts: 'aria-keyshortcuts', + ariaModal: 'aria-modal', + ariaPlaceholder: 'aria-placeholder', + ariaRoleDescription: 'aria-roledescription', + ariaRowCount: 'aria-rowcount', + ariaRowIndex: 'aria-rowindex', + ariaRowSpan: 'aria-rowspan', + role: 'role', + }; + + var ariaProperties = Object.keys(ariaPropertiesMapping); + + // Can't use Object.values because we need to support IE11 + var ariaAttributes = []; + for (let i = 0; i < ariaProperties.length; i++) { + ariaAttributes.push(ariaPropertiesMapping[ariaProperties[i]]); + } + return { clearRegister: clearRegister, extractDataIds: extractDataIds, @@ -455,5 +514,8 @@ window.TestUtils = (function (lwc, jasmine, beforeAll) { setHooks: setHooks, spyConsole: spyConsole, customElementConnectedErrorListener: customElementConnectedErrorListener, + ariaPropertiesMapping: ariaPropertiesMapping, + ariaProperties: ariaProperties, + ariaAttributes: ariaAttributes, }; })(LWC, jasmine, beforeAll); diff --git a/packages/@lwc/integration-karma/scripts/karma-plugins/env.js b/packages/@lwc/integration-karma/scripts/karma-plugins/env.js index 602d7bb97c..162fe77099 100644 --- a/packages/@lwc/integration-karma/scripts/karma-plugins/env.js +++ b/packages/@lwc/integration-karma/scripts/karma-plugins/env.js @@ -21,6 +21,7 @@ const { SYNTHETIC_SHADOW_ENABLED, ENABLE_NATIVE_CUSTOM_ELEMENT_LIFECYCLE, ENABLE_SCOPED_CUSTOM_ELEMENT_REGISTRY, + DISABLE_ARIA_REFLECTION_POLYFILL, } = require('../shared/options'); const DIST_DIR = path.resolve(__dirname, '../../dist'); @@ -37,7 +38,8 @@ function createEnvFile() { window.lwcRuntimeFlags = { ENABLE_FORCE_NATIVE_SHADOW_MODE_FOR_TEST: ${FORCE_NATIVE_SHADOW_MODE_FOR_TEST}, ENABLE_NATIVE_CUSTOM_ELEMENT_LIFECYCLE: ${ENABLE_NATIVE_CUSTOM_ELEMENT_LIFECYCLE}, - ENABLE_SCOPED_CUSTOM_ELEMENT_REGISTRY: ${ENABLE_SCOPED_CUSTOM_ELEMENT_REGISTRY} + ENABLE_SCOPED_CUSTOM_ELEMENT_REGISTRY: ${ENABLE_SCOPED_CUSTOM_ELEMENT_REGISTRY}, + DISABLE_ARIA_REFLECTION_POLYFILL: ${DISABLE_ARIA_REFLECTION_POLYFILL} }; window.process = { env: { diff --git a/packages/@lwc/integration-karma/scripts/shared/options.js b/packages/@lwc/integration-karma/scripts/shared/options.js index 8e722d460c..6c710fa8d0 100644 --- a/packages/@lwc/integration-karma/scripts/shared/options.js +++ b/packages/@lwc/integration-karma/scripts/shared/options.js @@ -21,12 +21,14 @@ const ENABLE_NATIVE_CUSTOM_ELEMENT_LIFECYCLE = Boolean( const ENABLE_SCOPED_CUSTOM_ELEMENT_REGISTRY = Boolean( process.env.ENABLE_SCOPED_CUSTOM_ELEMENT_REGISTRY ); +const DISABLE_ARIA_REFLECTION_POLYFILL = Boolean(process.env.DISABLE_ARIA_REFLECTION_POLYFILL); module.exports = { // Test configuration COMPAT, ENABLE_NATIVE_CUSTOM_ELEMENT_LIFECYCLE, ENABLE_SCOPED_CUSTOM_ELEMENT_REGISTRY, + DISABLE_ARIA_REFLECTION_POLYFILL, FORCE_NATIVE_SHADOW_MODE_FOR_TEST, SYNTHETIC_SHADOW_ENABLED: !DISABLE_SYNTHETIC, GREP: process.env.GREP, diff --git a/packages/@lwc/integration-karma/test/api/getComponentDef/index.spec.js b/packages/@lwc/integration-karma/test/api/getComponentDef/index.spec.js index 9e8af2e2d3..122aa817dc 100644 --- a/packages/@lwc/integration-karma/test/api/getComponentDef/index.spec.js +++ b/packages/@lwc/integration-karma/test/api/getComponentDef/index.spec.js @@ -1,5 +1,6 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ import { LightningElement, api, getComponentDef } from 'lwc'; +import { ariaProperties } from 'test-utils'; import PublicProperties from 'x/publicProperties'; import PublicAccessors from 'x/publicAccessors'; @@ -54,61 +55,17 @@ testInvalidComponentConstructor('Class not extending LightningElement', class Co const GLOBAL_HTML_ATTRIBUTES = [ 'accessKey', - 'ariaActiveDescendant', - 'ariaAtomic', - 'ariaAutoComplete', - 'ariaBusy', - 'ariaChecked', - 'ariaColCount', - 'ariaColIndex', - 'ariaColSpan', - 'ariaControls', - 'ariaCurrent', - 'ariaDescribedBy', - 'ariaDetails', - 'ariaDisabled', - 'ariaErrorMessage', - 'ariaExpanded', - 'ariaFlowTo', - 'ariaHasPopup', - 'ariaHidden', - 'ariaInvalid', - 'ariaKeyShortcuts', - 'ariaLabel', - 'ariaLabelledBy', - 'ariaLevel', - 'ariaLive', - 'ariaModal', - 'ariaMultiLine', - 'ariaMultiSelectable', - 'ariaOrientation', - 'ariaOwns', - 'ariaPlaceholder', - 'ariaPosInSet', - 'ariaPressed', - 'ariaReadOnly', - 'ariaRelevant', - 'ariaRequired', - 'ariaRoleDescription', - 'ariaRowCount', - 'ariaRowIndex', - 'ariaRowSpan', - 'ariaSelected', - 'ariaSetSize', - 'ariaSort', - 'ariaValueMax', - 'ariaValueMin', - 'ariaValueNow', - 'ariaValueText', 'dir', 'draggable', 'hidden', 'id', 'lang', - 'role', 'spellcheck', 'tabIndex', 'title', + // Copy over all aria props supported on Element.prototype. Note that this will vary from browser to browser. + // See: https://wicg.github.io/aom/spec/aria-reflection.html + ...Object.keys(Element.prototype).filter((prop) => ariaProperties.includes(prop)), ].sort(); it('it should return the global HTML attributes in props', () => { diff --git a/packages/@lwc/integration-karma/test/polyfills/aria-properties/index.spec.js b/packages/@lwc/integration-karma/test/polyfills/aria-properties/index.spec.js index b44db71c30..a0bc48599a 100644 --- a/packages/@lwc/integration-karma/test/polyfills/aria-properties/index.spec.js +++ b/packages/@lwc/integration-karma/test/polyfills/aria-properties/index.spec.js @@ -1,3 +1,5 @@ +import { ariaPropertiesMapping } from 'test-utils'; + function testAriaProperty(property, attribute) { describe(property, () => { it(`should assign property ${property} to Element prototype`, () => { @@ -37,56 +39,8 @@ function testAriaProperty(property, attribute) { }); } -const ariaPropertiesMapping = { - ariaAutoComplete: 'aria-autocomplete', - ariaChecked: 'aria-checked', - ariaCurrent: 'aria-current', - ariaDisabled: 'aria-disabled', - ariaExpanded: 'aria-expanded', - ariaHasPopup: 'aria-haspopup', - ariaHidden: 'aria-hidden', - ariaInvalid: 'aria-invalid', - ariaLabel: 'aria-label', - ariaLevel: 'aria-level', - ariaMultiLine: 'aria-multiline', - ariaMultiSelectable: 'aria-multiselectable', - ariaOrientation: 'aria-orientation', - ariaPressed: 'aria-pressed', - ariaReadOnly: 'aria-readonly', - ariaRequired: 'aria-required', - ariaSelected: 'aria-selected', - ariaSort: 'aria-sort', - ariaValueMax: 'aria-valuemax', - ariaValueMin: 'aria-valuemin', - ariaValueNow: 'aria-valuenow', - ariaValueText: 'aria-valuetext', - ariaLive: 'aria-live', - ariaRelevant: 'aria-relevant', - ariaAtomic: 'aria-atomic', - ariaBusy: 'aria-busy', - ariaActiveDescendant: 'aria-activedescendant', - ariaControls: 'aria-controls', - ariaDescribedBy: 'aria-describedby', - ariaFlowTo: 'aria-flowto', - ariaLabelledBy: 'aria-labelledby', - ariaOwns: 'aria-owns', - ariaPosInSet: 'aria-posinset', - ariaSetSize: 'aria-setsize', - ariaColCount: 'aria-colcount', - ariaColSpan: 'aria-colspan', - ariaColIndex: 'aria-colindex', - ariaDetails: 'aria-details', - ariaErrorMessage: 'aria-errormessage', - ariaKeyShortcuts: 'aria-keyshortcuts', - ariaModal: 'aria-modal', - ariaPlaceholder: 'aria-placeholder', - ariaRoleDescription: 'aria-roledescription', - ariaRowCount: 'aria-rowcount', - ariaRowIndex: 'aria-rowindex', - ariaRowSpan: 'aria-rowspan', - role: 'role', -}; - -for (const [ariaProperty, ariaAttribute] of Object.entries(ariaPropertiesMapping)) { - testAriaProperty(ariaProperty, ariaAttribute); +if (!window.lwcRuntimeFlags.DISABLE_ARIA_REFLECTION_POLYFILL) { + for (const [ariaProperty, ariaAttribute] of Object.entries(ariaPropertiesMapping)) { + testAriaProperty(ariaProperty, ariaAttribute); + } } diff --git a/packages/@lwc/integration-karma/test/synthetic-shadow/scoped-id/multiple-idrefs.spec.js b/packages/@lwc/integration-karma/test/synthetic-shadow/scoped-id/multiple-idrefs.spec.js index e1ca794989..ee3ee570b5 100644 --- a/packages/@lwc/integration-karma/test/synthetic-shadow/scoped-id/multiple-idrefs.spec.js +++ b/packages/@lwc/integration-karma/test/synthetic-shadow/scoped-id/multiple-idrefs.spec.js @@ -18,8 +18,8 @@ it('should handle multiple idrefs when set dynamically', () => { expect(hokkaido.id).toMatch(/^hokkaido-\w+/); } - expect(input.ariaLabelledBy).toContain(aomori.id); - expect(input.ariaLabelledBy).toContain(hokkaido.id); + expect(input.getAttribute('aria-labelledby')).toContain(aomori.id); + expect(input.getAttribute('aria-labelledby')).toContain(hokkaido.id); }); it('should handle multiple idrefs when set statically', () => { @@ -38,6 +38,6 @@ it('should handle multiple idrefs when set statically', () => { expect(iwate.id).toMatch(/^iwate-\w+/); } - expect(input.ariaLabelledBy).toContain(aomori.id); - expect(input.ariaLabelledBy).toContain(iwate.id); + expect(input.getAttribute('aria-labelledby')).toContain(aomori.id); + expect(input.getAttribute('aria-labelledby')).toContain(iwate.id); }); diff --git a/packages/@lwc/integration-karma/test/template/attribute-aria/index.spec.js b/packages/@lwc/integration-karma/test/template/attribute-aria/index.spec.js new file mode 100644 index 0000000000..99b501e4f2 --- /dev/null +++ b/packages/@lwc/integration-karma/test/template/attribute-aria/index.spec.js @@ -0,0 +1,85 @@ +import { createElement } from 'lwc'; +import { ariaAttributes, ariaProperties, ariaPropertiesMapping } from 'test-utils'; + +import Parent from 'x/parent'; + +describe('setting aria attributes', () => { + let childComponent; + let childDiv; + + beforeEach(() => { + const elm = createElement('x-parent', { is: Parent }); + document.body.appendChild(elm); + + childComponent = elm.shadowRoot.querySelector('x-child'); + childDiv = elm.shadowRoot.querySelector('div'); + }); + + describe('on a component', () => { + function testAriaPropertyEquals(prop, expectedValue) { + // four cases: + // 1. prop outside + // 2. prop inside + // 3. attribute outside + // 4. attribute inside + expect(childComponent[prop]).toEqual(expectedValue); + expect(childComponent.callPropertyGetter(prop)).toEqual(expectedValue); + expect(childComponent.getAttribute(ariaPropertiesMapping[prop])).toEqual(expectedValue); + expect(childComponent.callGetAttribute(ariaPropertiesMapping[prop])).toEqual( + expectedValue + ); + } + + it('attribute is set', () => { + for (const attrName of ariaAttributes) { + expect(childComponent.getAttribute(attrName)).toMatch(/^foo/); + } + }); + + it('component can use this.aria* property accessors', () => { + const privateAriaProperties = childComponent.getAllAriaProps(); + expect(Object.keys(privateAriaProperties)).toEqual(ariaProperties); + for (const prop of ariaProperties) { + expect(privateAriaProperties[prop]).toMatch(/^foo/); + } + }); + + it('can get aria prop from outside the component', () => { + for (const prop of ariaProperties) { + expect(childComponent[prop]).toMatch(/^foo/); + } + }); + + it('can mutate aria prop from outside the component', () => { + for (const prop of ariaProperties) { + childComponent[prop] = 'bar'; + testAriaPropertyEquals(prop, 'bar'); + } + }); + + it('can mutate aria prop from inside the component', () => { + childComponent.setAllAriaProps('bar'); + for (const prop of ariaProperties) { + testAriaPropertyEquals(prop, 'bar'); + } + }); + }); + + describe('on a div', () => { + it('attribute is set', () => { + for (const attrName of ariaAttributes) { + expect(childDiv.getAttribute(attrName)).toMatch(/^foo/); + } + }); + + // If the polyfill is not enabled, then we can't expect the div to have all the ARIA properties, + // because some are non-standard or not supported in all browsers + if (!window.lwcRuntimeFlags.DISABLE_ARIA_REFLECTION_POLYFILL) { + it('aria prop is set', () => { + for (const prop of ariaProperties) { + expect(childComponent[prop]).toMatch(/^foo/); + } + }); + } + }); +}); diff --git a/packages/@lwc/integration-karma/test/template/attribute-aria/x/child/child.html b/packages/@lwc/integration-karma/test/template/attribute-aria/x/child/child.html new file mode 100644 index 0000000000..cc340bc4c9 --- /dev/null +++ b/packages/@lwc/integration-karma/test/template/attribute-aria/x/child/child.html @@ -0,0 +1 @@ + diff --git a/packages/@lwc/integration-karma/test/template/attribute-aria/x/child/child.js b/packages/@lwc/integration-karma/test/template/attribute-aria/x/child/child.js new file mode 100644 index 0000000000..9184370c8e --- /dev/null +++ b/packages/@lwc/integration-karma/test/template/attribute-aria/x/child/child.js @@ -0,0 +1,30 @@ +import { LightningElement, api } from 'lwc'; +import { ariaProperties } from 'test-utils'; + +export default class extends LightningElement { + @api + getAllAriaProps() { + const result = {}; + for (const prop of ariaProperties) { + result[prop] = this[prop]; + } + return result; + } + + @api + setAllAriaProps(value) { + for (const prop of ariaProperties) { + this[prop] = value; + } + } + + @api + callGetAttribute(attrName) { + return this.getAttribute(attrName); + } + + @api + callPropertyGetter(propName) { + return this[propName]; + } +} diff --git a/packages/@lwc/integration-karma/test/template/attribute-aria/x/parent/parent.html b/packages/@lwc/integration-karma/test/template/attribute-aria/x/parent/parent.html new file mode 100644 index 0000000000..28cc6e55bb --- /dev/null +++ b/packages/@lwc/integration-karma/test/template/attribute-aria/x/parent/parent.html @@ -0,0 +1,102 @@ + diff --git a/packages/@lwc/integration-karma/test/template/attribute-aria/x/parent/parent.js b/packages/@lwc/integration-karma/test/template/attribute-aria/x/parent/parent.js new file mode 100644 index 0000000000..ca8dce94e0 --- /dev/null +++ b/packages/@lwc/integration-karma/test/template/attribute-aria/x/parent/parent.js @@ -0,0 +1,3 @@ +import { LightningElement } from 'lwc'; + +export default class extends LightningElement {} diff --git a/packages/@lwc/shared/src/aria.ts b/packages/@lwc/shared/src/aria.ts index 3550459ed1..e1350e175b 100644 --- a/packages/@lwc/shared/src/aria.ts +++ b/packages/@lwc/shared/src/aria.ts @@ -15,6 +15,9 @@ import { create, forEach, StringReplace, StringToLowerCase } from './language'; * The above list of 46 aria attributes is consistent with the following resources: * https://github.com/w3c/aria/pull/708/files#diff-eacf331f0ffc35d4b482f1d15a887d3bR11060 * https://wicg.github.io/aom/spec/aria-reflection.html + * + * NOTE: If you update this list, please update test files that implicitly reference this list! + * Searching the codebase for `aria-flowto` and `ariaFlowTo` should be good enough to find all usages. */ const AriaPropertyNames = [ 'ariaActiveDescendant',