Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 47 additions & 1 deletion extensions/amp-story/1.0/amp-story-system-layer.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {toggleAttribute} from '#core/dom';
import {toggleAttribute, tryFocus} from '#core/dom';
import {escapeCssSelectorIdent} from '#core/dom/css-selectors';
import * as Preact from '#core/dom/jsx';
import {closest, matches, scopedQuerySelector} from '#core/dom/query';
Expand Down Expand Up @@ -660,7 +660,13 @@ export class SystemLayer {
* @param {boolean} captionsState
*/
onCaptionsStateUpdate_(captionsState) {
const from = captionsState ? NOCAPTIONS_CLASS : CAPTIONS_CLASS;
const to = captionsState ? CAPTIONS_CLASS : NOCAPTIONS_CLASS;
const restoreFocus = this.activeElementHasClass_(from);
toggleAttribute(this.systemLayerEl_, 'captions-on', captionsState);
if (restoreFocus) {
this.focusButton_(to);
}
}

/**
Expand All @@ -686,10 +692,16 @@ export class SystemLayer {
* @private
*/
onMutedStateUpdate_(isMuted) {
const from = isMuted ? MUTE_CLASS : UNMUTE_CLASS;
const to = isMuted ? UNMUTE_CLASS : MUTE_CLASS;
const restoreFocus = this.activeElementHasClass_(from);
this.vsync_.mutate(() => {
isMuted
? this.getShadowRoot().setAttribute(AUDIO_MUTED_ATTRIBUTE, '')
: this.getShadowRoot().removeAttribute(AUDIO_MUTED_ATTRIBUTE);
if (restoreFocus) {
this.focusButton_(to);
}
});
}

Expand All @@ -699,13 +711,47 @@ export class SystemLayer {
* @private
*/
onPausedStateUpdate_(isPaused) {
const from = isPaused ? PAUSE_CLASS : PLAY_CLASS;
const to = isPaused ? PLAY_CLASS : PAUSE_CLASS;
const restoreFocus = this.activeElementHasClass_(from);
this.vsync_.mutate(() => {
isPaused
? this.getShadowRoot().setAttribute(PAUSED_ATTRIBUTE, '')
: this.getShadowRoot().removeAttribute(PAUSED_ATTRIBUTE);
if (restoreFocus) {
this.focusButton_(to);
}
});
}

/**
* Whether the active element in the system layer's root has the given class.
* @param {string} className
* @return {boolean}
* @private
*/
activeElementHasClass_(className) {
const root = this.systemLayerEl_.getRootNode();
const active = root && root.activeElement;
return (
!!active && !!active.classList && active.classList.contains(className)
);
}

/**
* Focuses the first descendant button with the given class.
* @param {string} className
* @private
*/
focusButton_(className) {
const target = this.systemLayerEl_.querySelector(
`.${escapeCssSelectorIdent(className)}`
);
if (target) {
tryFocus(target);
}
}

/**
* Hides message after elapsed time.
* @param {string} message
Expand Down
190 changes: 190 additions & 0 deletions extensions/amp-story/1.0/test/test-amp-story-system-layer.js
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,196 @@ describes.fakeWin('amp-story system layer', {amp: true}, (env) => {
expect(systemLayer.getShadowRoot()).to.have.attribute('paused');
});

describe('paired button toggle focus restoration', () => {
/**
* Stubs the system layer's root so activeElement returns the given element.
* @param {?Element} activeElement
*/
function stubActiveElement(activeElement) {
env.sandbox
.stub(systemLayer.systemLayerEl_, 'getRootNode')
.returns({activeElement});
}

beforeEach(() => {
systemLayer.build();
});

it('should move focus to the nocaptions button when captions are turned off', () => {
storeService.dispatch(Action.TOGGLE_STORY_HAS_PLAYBACK_UI, true);
storeService.dispatch(Action.TOGGLE_PAGE_HAS_CAPTIONS, true);
storeService.dispatch(Action.TOGGLE_CAPTIONS, true);

const captionsButton = systemLayer
.getShadowRoot()
.querySelector('.i-amphtml-story-captions-control');
const nocaptionsButton = systemLayer
.getShadowRoot()
.querySelector('.i-amphtml-story-nocaptions-control');
const focusSpy = env.sandbox.spy(nocaptionsButton, 'focus');
stubActiveElement(captionsButton);

storeService.dispatch(Action.TOGGLE_CAPTIONS, false);

expect(focusSpy).to.have.been.calledOnce;
});

it('should move focus to the captions button when captions are turned on', () => {
storeService.dispatch(Action.TOGGLE_STORY_HAS_PLAYBACK_UI, true);
storeService.dispatch(Action.TOGGLE_PAGE_HAS_CAPTIONS, true);
storeService.dispatch(Action.TOGGLE_CAPTIONS, false);

const captionsButton = systemLayer
.getShadowRoot()
.querySelector('.i-amphtml-story-captions-control');
const nocaptionsButton = systemLayer
.getShadowRoot()
.querySelector('.i-amphtml-story-nocaptions-control');
const focusSpy = env.sandbox.spy(captionsButton, 'focus');
stubActiveElement(nocaptionsButton);

storeService.dispatch(Action.TOGGLE_CAPTIONS, true);

expect(focusSpy).to.have.been.calledOnce;
});

it('should not move focus on caption state change if no paired button is focused', () => {
storeService.dispatch(Action.TOGGLE_STORY_HAS_PLAYBACK_UI, true);
storeService.dispatch(Action.TOGGLE_PAGE_HAS_CAPTIONS, true);
storeService.dispatch(Action.TOGGLE_CAPTIONS, true);

const captionsButton = systemLayer
.getShadowRoot()
.querySelector('.i-amphtml-story-captions-control');
const nocaptionsButton = systemLayer
.getShadowRoot()
.querySelector('.i-amphtml-story-nocaptions-control');
const captionsFocusSpy = env.sandbox.spy(captionsButton, 'focus');
const nocaptionsFocusSpy = env.sandbox.spy(nocaptionsButton, 'focus');
stubActiveElement(win.document.body);

storeService.dispatch(Action.TOGGLE_CAPTIONS, false);

expect(captionsFocusSpy).to.not.have.been.called;
expect(nocaptionsFocusSpy).to.not.have.been.called;
});

it('should move focus to the unmute button when audio is muted', () => {
storeService.dispatch(Action.TOGGLE_PAGE_HAS_AUDIO, true);
storeService.dispatch(Action.TOGGLE_MUTED, false);

const muteButton = systemLayer
.getShadowRoot()
.querySelector('.i-amphtml-story-mute-audio-control');
const unmuteButton = systemLayer
.getShadowRoot()
.querySelector('.i-amphtml-story-unmute-audio-control');
const focusSpy = env.sandbox.spy(unmuteButton, 'focus');
stubActiveElement(muteButton);

storeService.dispatch(Action.TOGGLE_MUTED, true);

expect(focusSpy).to.have.been.calledOnce;
});

it('should move focus to the mute button when audio is unmuted', () => {
storeService.dispatch(Action.TOGGLE_PAGE_HAS_AUDIO, true);
storeService.dispatch(Action.TOGGLE_MUTED, true);

const muteButton = systemLayer
.getShadowRoot()
.querySelector('.i-amphtml-story-mute-audio-control');
const unmuteButton = systemLayer
.getShadowRoot()
.querySelector('.i-amphtml-story-unmute-audio-control');
const focusSpy = env.sandbox.spy(muteButton, 'focus');
stubActiveElement(unmuteButton);

storeService.dispatch(Action.TOGGLE_MUTED, false);

expect(focusSpy).to.have.been.calledOnce;
});

it('should not move focus on muted state change if no paired button is focused', () => {
storeService.dispatch(Action.TOGGLE_PAGE_HAS_AUDIO, true);
storeService.dispatch(Action.TOGGLE_MUTED, false);

const muteButton = systemLayer
.getShadowRoot()
.querySelector('.i-amphtml-story-mute-audio-control');
const unmuteButton = systemLayer
.getShadowRoot()
.querySelector('.i-amphtml-story-unmute-audio-control');
const muteFocusSpy = env.sandbox.spy(muteButton, 'focus');
const unmuteFocusSpy = env.sandbox.spy(unmuteButton, 'focus');
stubActiveElement(win.document.body);

storeService.dispatch(Action.TOGGLE_MUTED, true);

expect(muteFocusSpy).to.not.have.been.called;
expect(unmuteFocusSpy).to.not.have.been.called;
});

it('should move focus to the play button when the story is paused', () => {
storeService.dispatch(Action.TOGGLE_STORY_HAS_PLAYBACK_UI, true);
storeService.dispatch(Action.TOGGLE_PAGE_HAS_ELEMENT_WITH_PLAYBACK, true);
storeService.dispatch(Action.TOGGLE_PAUSED, false);

const pauseButton = systemLayer
.getShadowRoot()
.querySelector('.i-amphtml-story-pause-control');
const playButton = systemLayer
.getShadowRoot()
.querySelector('.i-amphtml-story-play-control');
const focusSpy = env.sandbox.spy(playButton, 'focus');
stubActiveElement(pauseButton);

storeService.dispatch(Action.TOGGLE_PAUSED, true);

expect(focusSpy).to.have.been.calledOnce;
});

it('should move focus to the pause button when the story is resumed', () => {
storeService.dispatch(Action.TOGGLE_STORY_HAS_PLAYBACK_UI, true);
storeService.dispatch(Action.TOGGLE_PAGE_HAS_ELEMENT_WITH_PLAYBACK, true);
storeService.dispatch(Action.TOGGLE_PAUSED, true);

const pauseButton = systemLayer
.getShadowRoot()
.querySelector('.i-amphtml-story-pause-control');
const playButton = systemLayer
.getShadowRoot()
.querySelector('.i-amphtml-story-play-control');
const focusSpy = env.sandbox.spy(pauseButton, 'focus');
stubActiveElement(playButton);

storeService.dispatch(Action.TOGGLE_PAUSED, false);

expect(focusSpy).to.have.been.calledOnce;
});

it('should not move focus on paused state change if no paired button is focused', () => {
storeService.dispatch(Action.TOGGLE_STORY_HAS_PLAYBACK_UI, true);
storeService.dispatch(Action.TOGGLE_PAGE_HAS_ELEMENT_WITH_PLAYBACK, true);
storeService.dispatch(Action.TOGGLE_PAUSED, false);

const pauseButton = systemLayer
.getShadowRoot()
.querySelector('.i-amphtml-story-pause-control');
const playButton = systemLayer
.getShadowRoot()
.querySelector('.i-amphtml-story-play-control');
const pauseFocusSpy = env.sandbox.spy(pauseButton, 'focus');
const playFocusSpy = env.sandbox.spy(playButton, 'focus');
stubActiveElement(win.document.body);

storeService.dispatch(Action.TOGGLE_PAUSED, true);

expect(pauseFocusSpy).to.not.have.been.called;
expect(playFocusSpy).to.not.have.been.called;
});
});

describe('localization', () => {
it('should load the localized aria-labels for buttons if strings are available', async () => {
getLocalizationService(win.document.body).registerLocalizedStringBundles({
Expand Down