Skip to content

Commit

Permalink
✨ Register toggleTheme API action for dark mode support (#36958)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
deepaklalwani97 committed Dec 9, 2021
1 parent 58a1c29 commit b6124cb
Show file tree
Hide file tree
Showing 5 changed files with 248 additions and 0 deletions.
1 change: 1 addition & 0 deletions build-system/test-configs/forbidden-terms.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
4 changes: 4 additions & 0 deletions docs/spec/amp-actions-and-events.md
Original file line number Diff line number Diff line change
Expand Up @@ -741,6 +741,10 @@ actions that apply to the whole document.
<p>Requires <a href="https://amp.dev/documentation/components/amp-bind.html#modifying-history-with-amppushstate">amp-bind</a>.</p>
<p>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, <code>foo</code>). </td>
</tr>
<tr>
<td><code>toggleTheme()</code></td>
<td>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 <code>prefers-color-scheme</code> value. Use <code>data-prefers-dark-mode-class</code> attribute on body tag to override the class to be used for dark mode.</td>
</tr>
</table>

<sup>1</sup>When used with <a href="#multiple-actions-for-one-event">multiple actions</a>, subsequent actions will wait for <code>setState()</code> or <code>pushState()</code> to complete before invocation. Only a single <code>setState()</code> or <code>pushState()</code> is allowed per event.
Expand Down
76 changes: 76 additions & 0 deletions examples/amp-toggle-theme.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<script async src="https://cdn.ampproject.org/v0.js"></script>
<style amp-boilerplate>body{-webkit-animation:-amp-start 8s steps(1,end) 0s 1 normal both;-moz-animation:-amp-start 8s steps(1,end) 0s 1 normal both;-ms-animation:-amp-start 8s steps(1,end) 0s 1 normal both;animation:-amp-start 8s steps(1,end) 0s 1 normal both}@-webkit-keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}@-moz-keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}@-ms-keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}@-o-keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}@keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}</style><noscript><style amp-boilerplate>body{-webkit-animation:none;-moz-animation:none;-ms-animation:none;animation:none}</style></noscript>
<!-- ## Setup -->
<link rel="canonical" href="https://amp.dev/documentation/examples/components/amp-toggle-theme/">
<meta name="viewport" content="width=device-width,minimum-scale=1,initial-scale=1">
</head>

<style amp-custom>
.is-dark-mode {
background-color: #20202A;
color: #FFF;
}
</style>

<body data-prefers-dark-mode-class="is-dark-mode">
<main>
<button on="tap:AMP.toggleTheme()">Toggle Mode</button>
<p>
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.
</p>
</main>
</body>
</html>
65 changes: 65 additions & 0 deletions src/service/standard-actions-impl.js
Original file line number Diff line number Diff line change
Expand Up @@ -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_();
}

/**
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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
Expand Down
102 changes: 102 additions & 0 deletions test/unit/test-standard-actions.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});

0 comments on commit b6124cb

Please sign in to comment.