diff --git a/js/accessibility/pdom/FocusableHeadingNode.ts b/js/accessibility/pdom/FocusableHeadingNode.ts new file mode 100644 index 000000000..10695ef54 --- /dev/null +++ b/js/accessibility/pdom/FocusableHeadingNode.ts @@ -0,0 +1,79 @@ +// Copyright 2022, University of Colorado Boulder + +/** + * A Node represented by a heading in the parallel dom that can receive focus. Typically + * headings are not focusable and not interactive. But it may be desirable to put focus + * on a heading to orient the user or control where the traversal order starts without + * focusing an interactive component. + * + * When a screen reader is focused on a heading it will read the name of the heading and + * possibly the content below it. + * + * @author Jesse Greenberg (PhET Interactive Simulations) + */ + +import optionize from '../../../../phet-core/js/optionize.js'; +import StrictOmit from '../../../../phet-core/js/types/StrictOmit.js'; +import { Node, NodeOptions, scenery } from '../../imports.js'; + +// Available heading levels, according to DOM spec. +type HeadingLevelNumber = 1 | 2 | 3 | 4 | 5 | 6; + +type SelfOptions = { + + // The heading level for this focusable heading in the PDOM, 1-6 according to DOM spec. + headingLevel?: HeadingLevelNumber; +}; +type ParentOptions = StrictOmit; +export type FocusableHeadingNodeOptions = SelfOptions & ParentOptions; + +class FocusableHeadingNode extends Node { + + // Removes listeners and makes eligible for garbage collection. + private readonly disposeFocusableHeadingNode: () => void; + + constructor( providedOptions?: FocusableHeadingNodeOptions ) { + const options = optionize()( { + headingLevel: 1 + }, providedOptions ); + + super( options ); + + this.tagName = `h${options.headingLevel}`; + + // This Node is focusable but there is no interactive component to surround with a highlight. + this.focusHighlight = 'invisible'; + + // After losing focus, this element is removed from the traversal order. It can only receive + // focus again after calling focus() directly. + const blurListener = { + blur: () => { this.focusable = false; } + }; + this.addInputListener( blurListener ); + + this.disposeFocusableHeadingNode = () => { + this.removeInputListener( blurListener ); + }; + } + + /** + * Focus this heading in the Parallel DOM. The screen reader will read its name and possibly + * content below it. Traversal with alternative input will continue from wherever this element + * is located in the PDOM order. + * + * Once the heading loses focus, it is removed from the traversal order until this is called + * explicitly again. + */ + public override focus(): void { + this.focusable = true; + super.focus(); + } + + public override dispose(): void { + this.disposeFocusableHeadingNode(); + super.dispose(); + } +} + +scenery.register( 'FocusableHeadingNode', FocusableHeadingNode ); +export default FocusableHeadingNode; diff --git a/js/imports.ts b/js/imports.ts index db2a97e9c..cb93633cd 100644 --- a/js/imports.ts +++ b/js/imports.ts @@ -153,6 +153,8 @@ export type { TransformTrackerOptions } from './util/TransformTracker.js'; export { default as TrailVisibilityTracker } from './util/TrailVisibilityTracker.js'; export { default as AriaHasPopUpMutator } from './accessibility/pdom/AriaHasPopUpMutator.js'; +export { default as FocusableHeadingNode } from './accessibility/pdom/FocusableHeadingNode.js'; +export type { FocusableHeadingNodeOptions } from './accessibility/pdom/FocusableHeadingNode.js'; export { default as Cursor } from './accessibility/reader/Cursor.js'; export { default as Reader } from './accessibility/reader/Reader.js'; export { default as KeyStateTracker } from './accessibility/KeyStateTracker.js';