From b6124cb823ef29a9b586527a771ccf30923bc2f9 Mon Sep 17 00:00:00 2001 From: Deepak Lalwani Date: Fri, 10 Dec 2021 00:47:49 +0530 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Register=20toggleTheme=20API=20acti?= =?UTF-8?q?on=20for=20dark=20mode=20support=20(#36958)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Register toggleTheme API action for dark mode support * Add toggleTheme action details to the amp-actions readme * Add amp boilerplate style * Fix handleToggleTheme default mode conditional * Wrap localStorage in try catch and refactor duplicate code * Refactor duplicate and remove unnecessary code * Use matchMedia only when localstorage not available * Add support for prefers-dark-mode-class attribute for dark mode class * Rename prefers-dark-mode-class to data-prefers-dark-mode-class * Add unit test cases for toggleTheme action * Fix localStorage eslint errors * Fix failing unit tests * Fix unit tests --- build-system/test-configs/forbidden-terms.js | 1 + docs/spec/amp-actions-and-events.md | 4 + examples/amp-toggle-theme.html | 76 ++++++++++++++ src/service/standard-actions-impl.js | 65 ++++++++++++ test/unit/test-standard-actions.js | 102 +++++++++++++++++++ 5 files changed, 248 insertions(+) create mode 100644 examples/amp-toggle-theme.html diff --git a/build-system/test-configs/forbidden-terms.js b/build-system/test-configs/forbidden-terms.js index 384575fc09a3..0d066740a523 100644 --- a/build-system/test-configs/forbidden-terms.js +++ b/build-system/test-configs/forbidden-terms.js @@ -462,6 +462,7 @@ const forbiddenTermsGlobal = { 'extensions/amp-web-push/0.1/amp-web-push-permission-dialog.js', 'src/experiments/index.js', 'src/service/cid-impl.js', + 'src/service/standard-actions-impl.js', 'src/service/storage-impl.js', 'testing/init-tests.js', 'testing/fake-dom.js', diff --git a/docs/spec/amp-actions-and-events.md b/docs/spec/amp-actions-and-events.md index 21bc521023f6..a59ac4764433 100644 --- a/docs/spec/amp-actions-and-events.md +++ b/docs/spec/amp-actions-and-events.md @@ -741,6 +741,10 @@ actions that apply to the whole document.

Requires amp-bind.

Merges an object literal into the bindable state and pushes a new entry onto browser history stack. Popping the entry will restore the previous values of variables (in this example, foo). + + toggleTheme() + Toggles the amp-dark-mode class on the body element when called and sets users preference to the localStorage. The amp-dark-mode class is added by default to body based on the prefers-color-scheme value. Use data-prefers-dark-mode-class attribute on body tag to override the class to be used for dark mode. + 1When used with multiple actions, subsequent actions will wait for setState() or pushState() to complete before invocation. Only a single setState() or pushState() is allowed per event. diff --git a/examples/amp-toggle-theme.html b/examples/amp-toggle-theme.html new file mode 100644 index 000000000000..dc845b87e39f --- /dev/null +++ b/examples/amp-toggle-theme.html @@ -0,0 +1,76 @@ + + + + + + + + + + + + + + +

+ +

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod + tempor incididunt ut labore et dolore magna aliqua. Eget arcu dictum + varius duis at. Volutpat consequat mauris nunc congue. Ultrices sagittis + orci a scelerisque purus. Feugiat pretium nibh ipsum consequat nisl vel + pretium lectus quam. Id aliquet lectus proin nibh nisl condimentum id. + Facilisis sed odio morbi quis commodo odio aenean sed adipiscing. Non + curabitur gravida arcu ac. In egestas erat imperdiet sed euismod. Eu + lobortis elementum nibh tellus. In nisl nisi scelerisque eu ultrices. + Leo in vitae turpis massa sed elementum tempus egestas sed. Id neque + aliquam vestibulum morbi blandit cursus risus at. Dui accumsan sit amet + nulla facilisi morbi tempus. Integer eget aliquet nibh praesent + tristique magna sit amet. Nibh ipsum consequat nisl vel pretium lectus + quam id. Vel turpis nunc eget lorem dolor. Dignissim sodales ut eu sem + integer vitae justo. Amet est placerat in egestas erat imperdiet sed. + Vulputate dignissim suspendisse in est ante. Eleifend donec pretium + vulputate sapien. Magna sit amet purus gravida quis blandit turpis. + Morbi tristique senectus et netus et malesuada fames. Nec feugiat nisl + pretium fusce. In fermentum et sollicitudin ac orci phasellus egestas + tellus. Eget nunc lobortis mattis aliquam faucibus. Arcu cursus vitae + congue mauris rhoncus aenean vel elit. Turpis egestas sed tempus urna + et. Nulla facilisi morbi tempus iaculis urna id volutpat lacus laoreet. + Ullamcorper eget nulla facilisi etiam. Cras sed felis eget velit aliquet + sagittis id consectetur purus. Diam volutpat commodo sed egestas egestas + fringilla phasellus faucibus scelerisque. Odio facilisis mauris sit amet + massa vitae tortor. Interdum consectetur libero id faucibus nisl. Ipsum + nunc aliquet bibendum enim facilisis gravida neque convallis a. In + fermentum et sollicitudin ac orci phasellus egestas. Eu mi bibendum + neque egestas congue quisque. Et netus et malesuada fames ac. Nam libero + justo laoreet sit amet cursus sit amet. Magna fringilla urna porttitor + rhoncus dolor purus. Cursus turpis massa tincidunt dui ut ornare. Mauris + sit amet massa vitae tortor. Nisi est sit amet facilisis. Lobortis + feugiat vivamus at augue eget. Sit amet consectetur adipiscing elit + pellentesque. Dignissim sodales ut eu sem integer vitae. Libero justo + laoreet sit amet cursus sit amet dictum sit. Nulla pellentesque + dignissim enim sit amet. Tincidunt dui ut ornare lectus sit. Volutpat ac + tincidunt vitae semper quis lectus nulla at. Diam volutpat commodo sed + egestas egestas fringilla phasellus faucibus. Duis at tellus at urna + condimentum mattis pellentesque. Fringilla est ullamcorper eget nulla + facilisi etiam dignissim diam. Suspendisse potenti nullam ac tortor + vitae purus faucibus ornare. Eget magna fermentum iaculis eu non. Enim + nec dui nunc mattis enim ut tellus. Quam viverra orci sagittis eu + volutpat odio facilisis mauris sit. Orci a scelerisque purus semper eget + duis. Sit amet nisl suscipit adipiscing bibendum est ultricies. In + iaculis nunc sed augue. Amet nulla facilisi morbi tempus iaculis urna id + volutpat lacus. Eros in cursus turpis massa tincidunt dui ut ornare. + Pharetra et ultrices neque ornare aenean. Enim sed faucibus turpis in eu + mi bibendum neque. Sit amet commodo nulla facilisi nullam vehicula ipsum + a arcu. Eget felis eget nunc lobortis mattis aliquam faucibus purus in. + Massa massa ultricies mi quis. Aenean vel elit scelerisque mauris. + Tristique risus nec feugiat in fermentum posuere. +

+
+ + diff --git a/src/service/standard-actions-impl.js b/src/service/standard-actions-impl.js index b0ddae095930..4e06c4995cc7 100644 --- a/src/service/standard-actions-impl.js +++ b/src/service/standard-actions-impl.js @@ -64,6 +64,8 @@ export class StandardActions { // Explicitly not setting `Action` as a member to scope installation to one // method and for bundle size savings. 💰 this.installActions_(Services.actionServiceForDoc(context)); + + this.initThemeMode_(); } /** @@ -103,6 +105,42 @@ export class StandardActions { ); } + /** + * Handles initiliazing the theme mode. + * + * This methode needs to be called on page load to set the `amp-dark-mode` + * class on the body if the user prefers the dark mode. + */ + initThemeMode_() { + if (this.prefersDarkMode_()) { + this.ampdoc.waitForBodyOpen().then((body) => { + const darkModeClass = + body.getAttribute('data-prefers-dark-mode-class') || 'amp-dark-mode'; + + body.classList.add(darkModeClass); + }); + } + } + + /** + * Checks whether the user prefers dark mode based on local storage and + * user's operating systen settings. + * + * @return {boolean} + */ + prefersDarkMode_() { + try { + const themeMode = this.ampdoc.win.localStorage.getItem('amp-dark-mode'); + + if (themeMode) { + return 'yes' === themeMode; + } + } catch (e) {} + + // LocalStorage may not be accessible + return this.ampdoc.win.matchMedia?.('(prefers-color-scheme: dark)').matches; + } + /** * Handles global `AMP` actions. * See `amp-actions-and-events.md` for details. @@ -160,6 +198,9 @@ export class StandardActions { .catch((reason) => { dev().error(TAG, 'Failed to opt out of CID', reason); }); + case 'toggleTheme': + this.handleToggleTheme_(); + return null; } throw user().createError('Unknown AMP action ', method); } @@ -198,6 +239,30 @@ export class StandardActions { ); } + /** + * Handles the `toggleTheme` action. + * + * This action sets the `amp-dark-mode` class on the body element and stores the the preference for dark mode in localstorage. + */ + handleToggleTheme_() { + this.ampdoc.waitForBodyOpen().then((body) => { + try { + const darkModeClass = + body.getAttribute('data-prefers-dark-mode-class') || 'amp-dark-mode'; + + if (this.prefersDarkMode_()) { + body.classList.remove(darkModeClass); + this.ampdoc.win.localStorage.setItem('amp-dark-mode', 'no'); + } else { + body.classList.add(darkModeClass); + this.ampdoc.win.localStorage.setItem('amp-dark-mode', 'yes'); + } + } catch (e) { + // LocalStorage may not be accessible. + } + }); + } + /** * Handles the `handleCloseOrNavigateTo_` action. * This action tries to close the requesting window if allowed, otherwise diff --git a/test/unit/test-standard-actions.js b/test/unit/test-standard-actions.js index 62b33ef832e7..d6b630a6e1d1 100644 --- a/test/unit/test-standard-actions.js +++ b/test/unit/test-standard-actions.js @@ -951,3 +951,105 @@ describes.sandboxed('StandardActions', {}, (env) => { }); }); }); + +describes.realWin('toggleTheme action', {amp: true}, (env) => { + let invocation, win, body, standardActions; + let matchMediaStub, getItemStub, setItemStub; + + beforeEach(() => { + win = env.win; + body = win.document.body; + standardActions = new StandardActions(env.ampdoc); + + getItemStub = env.sandbox.stub(win.localStorage, 'getItem'); + setItemStub = env.sandbox.stub(win.localStorage, 'setItem'); + + matchMediaStub = env.sandbox.stub(win, 'matchMedia'); + + invocation = { + node: { + ownerDocument: { + defaultView: env.win, + }, + }, + satisfiesTrust: () => true, + }; + + invocation.method = 'toggleTheme'; + }); + + it('should set amp-dark-mode property in localStorage with yes', async () => { + getItemStub.withArgs('amp-dark-mode').returns('no'); + + await standardActions.handleAmpTarget_(invocation); + + expect(getItemStub) + .to.be.calledOnce.and.calledWith('amp-dark-mode') + .and.returned('no'); + + expect(body).to.have.class('amp-dark-mode'); + + expect(setItemStub).to.be.calledOnce.and.calledWith('amp-dark-mode', 'yes'); + }); + + it('should set amp-dark-mode property in localStorage with no', async () => { + getItemStub.withArgs('amp-dark-mode').returns('yes'); + + await standardActions.handleAmpTarget_(invocation); + + expect(getItemStub) + .to.be.calledOnce.and.calledWith('amp-dark-mode') + .and.returned('yes'); + + expect(body).to.not.have.class('amp-dark-mode'); + + expect(setItemStub).to.be.calledOnce.and.calledWith('amp-dark-mode', 'no'); + }); + + it('should set amp-dark-mode property in localStorage with yes if it is null and user prefers light mode', async () => { + getItemStub.withArgs('amp-dark-mode').returns(null); + + matchMediaStub + .withArgs('(prefers-color-scheme: dark)') + .returns({matches: false}); + + await standardActions.handleAmpTarget_(invocation); + + expect(getItemStub) + .to.be.calledOnce.and.calledWith('amp-dark-mode') + .and.returned(null); + + expect(body).to.have.class('amp-dark-mode'); + + expect(setItemStub).to.be.calledOnce.and.calledWith('amp-dark-mode', 'yes'); + }); + + it('should set amp-dark-mode property in localStorage with no if it is null and user prefers dark mode', async () => { + getItemStub.withArgs('amp-dark-mode').returns(null); + + matchMediaStub + .withArgs('(prefers-color-scheme: dark)') + .returns({matches: true}); + + await standardActions.handleAmpTarget_(invocation); + + expect(getItemStub) + .to.be.calledOnce.and.calledWith('amp-dark-mode') + .and.returned(null); + + expect(body).to.not.have.class('amp-dark-mode'); + + expect(setItemStub).to.be.calledOnce.and.calledWith('amp-dark-mode', 'no'); + }); + + it('should add custom dark mode class to the body', async () => { + body.setAttribute('data-prefers-dark-mode-class', 'is-dark-mode'); + getItemStub.withArgs('amp-dark-mode').returns('no'); + + await standardActions.handleAmpTarget_(invocation); + + expect(body).to.have.class('is-dark-mode'); + + expect(setItemStub).to.be.calledOnce.and.calledWith('amp-dark-mode', 'yes'); + }); +});