Skip to content

Commit 9961926

Browse files
authored
feat: add theme management tools (#2037)
* feat: theme toggle control adds a new component to allow switching in the ui between light and dark modes adds a theme toggle to all stories by default updates developer test page to include toggle adds helper functions to assist with theme changing using fluentui web components theming tools
1 parent 11e6807 commit 9961926

File tree

17 files changed

+1350
-797
lines changed

17 files changed

+1350
-797
lines changed

.storybook/addons/codeEditorAddon/codeAddon.js

Lines changed: 43 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,8 @@ export const withCodeEditor = makeDecorator({
6565
name: `withCodeEditor`,
6666
parameterName: 'myParameter',
6767
skipIfNoParametersOrOptions: false,
68-
wrapper: (getStory, context, { parameters }) => {
68+
wrapper: (getStory, context, { options }) => {
69+
const disableThemeToggle = options ? options.disableThemeToggle : false;
6970
let story = getStory(context);
7071

7172
let storyHtml;
@@ -153,7 +154,29 @@ export const withCodeEditor = makeDecorator({
153154
}
154155
}
155156
}
156-
157+
const themeToggleCss = disableThemeToggle
158+
? ''
159+
: `
160+
body {
161+
background-color: var(--neutral-fill-rest);
162+
color: var(--neutral-foreground-rest);
163+
font-family: var(--body-font);
164+
padding: 0 12px;
165+
}
166+
header {
167+
display: flex;
168+
flex-direction: row;
169+
justify-content: flex-end;
170+
padding: 0 0 12px 0;
171+
}
172+
`;
173+
const themeToggle = disableThemeToggle
174+
? ''
175+
: `
176+
<header>
177+
<mgt-theme-toggle mode="light"></mgt-theme-toggle>
178+
</header>
179+
`;
157180
const loadEditorContent = () => {
158181
let providerInitCode = `
159182
import {Providers, MockProvider} from "${mgtScriptName}";
@@ -162,16 +185,18 @@ export const withCodeEditor = makeDecorator({
162185

163186
const storyElement = document.createElement('iframe');
164187

165-
storyElement.addEventListener('load', () => {
166-
let doc = storyElement.contentDocument;
188+
storyElement.addEventListener(
189+
'load',
190+
() => {
191+
let doc = storyElement.contentDocument;
167192

168-
let { html, css, js } = editor.files;
169-
js = js.replace(
170-
/import \{([^\}]+)\}\s+from\s+['"]@microsoft\/mgt['"];/gm,
171-
`import {$1} from '${mgtScriptName}';`
172-
);
193+
let { html, css, js } = editor.files;
194+
js = js.replace(
195+
/import \{([^\}]+)\}\s+from\s+['"]@microsoft\/mgt['"];/gm,
196+
`import {$1} from '${mgtScriptName}';`
197+
);
173198

174-
const docContent = `
199+
const docContent = `
175200
<html>
176201
<head>
177202
<script type="module" src="${mgtScriptName}"></script>
@@ -183,10 +208,12 @@ export const withCodeEditor = makeDecorator({
183208
html, body {
184209
height: 100%;
185210
}
211+
${themeToggleCss}
186212
${css}
187213
</style>
188214
</head>
189215
<body>
216+
${themeToggle}
190217
${html}
191218
<script type="module">
192219
${js}
@@ -195,10 +222,12 @@ export const withCodeEditor = makeDecorator({
195222
</html>
196223
`;
197224

198-
doc.open();
199-
doc.write(docContent);
200-
doc.close();
201-
}, {once:true});
225+
doc.open();
226+
doc.write(docContent);
227+
doc.close();
228+
},
229+
{ once: true }
230+
);
202231

203232
storyElement.className = 'story-mgt-preview';
204233
storyElement.setAttribute('title', 'preview');

index.html

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,19 @@
1414
<!-- <script src="./packages/mgt/dist/bundle/mgt-loader.js"></script> -->
1515

1616
<script type="module" src="./packages/mgt/dist/es6/index.js"></script>
17+
<style>
18+
header {
19+
display: flex;
20+
flex-direction: row;
21+
justify-content: flex-end;
22+
}
23+
body {
24+
background-color: var(--neutral-fill-rest);
25+
color: var(--neutral-foreground-rest);
26+
font-family: var(--body-font);
27+
padding: 24px 12px;
28+
}
29+
</style>
1730
</head>
1831

1932
<body>
@@ -51,6 +64,9 @@
5164
></mgt-msal2-provider> -->
5265

5366
<mgt-mock-provider></mgt-mock-provider>
67+
<header>
68+
<mgt-theme-toggle></mgt-theme-toggle>
69+
</header>
5470

5571
<h1>Developer test page</h1>
5672
<main>
@@ -77,4 +93,4 @@ <h2>mgt-picker</h2>
7793
<mgt-picker resource="me/todo/lists" scopes="tasks.read, tasks.readwrite"></mgt-picker>
7894
</main>
7995
</body>
80-
</html>
96+
</html>

jest.config.js

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,16 @@
1-
// These files are know to be ESM and should be transformed by ts-jest
1+
// These packages are know to be ESM and should be transformed by ts-jest
22
const esModules = [
33
'msal',
44
'@open-wc',
55
'@lit',
66
'lit',
7+
'@fluentui/web-components',
78
'testing-library__dom',
9+
'@microsoft/fast-color',
10+
'@microsoft/fast-element',
11+
'@microsoft/fast-foundation',
12+
'@microsoft/fast-web-utilities',
13+
'exenv-es6',
814
'@microsoft/microsoft-graph-client',
915
'@microsoft/mgt-element',
1016
'@microsoft/mgt-components',

packages/mgt-components/src/components/components.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import './mgt-person/mgt-person-types';
1919
import './mgt-tasks/mgt-tasks';
2020
import './mgt-teams-channel-picker/mgt-teams-channel-picker';
2121
import './mgt-todo/mgt-todo';
22+
import './mgt-theme-toggle/mgt-theme-toggle';
2223

2324
export * from './mgt-agenda/mgt-agenda';
2425
export * from './mgt-file/mgt-file';
@@ -34,3 +35,4 @@ export * from './mgt-person/mgt-person-types';
3435
export * from './mgt-tasks/mgt-tasks';
3536
export * from './mgt-teams-channel-picker/mgt-teams-channel-picker';
3637
export * from './mgt-todo/mgt-todo';
38+
export * from './mgt-theme-toggle/mgt-theme-toggle';

packages/mgt-components/src/components/mgt-person/mgt-person-types.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,10 @@
1+
/**
2+
* -------------------------------------------------------------------------------------------
3+
* Copyright (c) Microsoft Corporation. All Rights Reserved. Licensed under the MIT License.
4+
* See License in the project root for license information.
5+
* -------------------------------------------------------------------------------------------
6+
*/
7+
18
/**
29
* Enumeration to define what parts of the person component render
310
*
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
/**
2+
* -------------------------------------------------------------------------------------------
3+
* Copyright (c) Microsoft Corporation. All Rights Reserved. Licensed under the MIT License.
4+
* See License in the project root for license information.
5+
* -------------------------------------------------------------------------------------------
6+
*/
7+
// import the mock for media match first to ensure it's hoisted and available for our dependencies
8+
import './mock-media-match';
9+
import { screen } from 'testing-library__dom';
10+
import { fixture } from '@open-wc/testing-helpers';
11+
import './mgt-theme-toggle';
12+
13+
describe('mgt-theme-toggle - tests', () => {
14+
it('should render', async () => {
15+
await fixture('<mgt-theme-toggle></mgt-theme-toggle>');
16+
const toggle = await screen.findByRole('switch');
17+
expect(toggle).not.toBeNull();
18+
});
19+
it("should emit darkmodechanged with the current 'checked' state on click", async () => {
20+
let darkModeState = false;
21+
const element = await fixture('<mgt-theme-toggle></mgt-theme-toggle>');
22+
const toggle: HTMLInputElement = await screen.findByRole('switch');
23+
expect(toggle).not.toBeNull();
24+
element.addEventListener('darkmodechanged', (e: CustomEvent<boolean>) => {
25+
darkModeState = e.detail;
26+
});
27+
expect(darkModeState).toBe(false);
28+
expect(toggle.checked).toBe(false);
29+
toggle.click();
30+
expect(darkModeState).toBe(true);
31+
expect(toggle.checked).toBe(true);
32+
toggle.click();
33+
expect(darkModeState).toBe(false);
34+
expect(toggle.checked).toBe(false);
35+
});
36+
37+
it('should have a checked switch if mode is dark', async () => {
38+
await fixture('<mgt-theme-toggle mode="dark"></mgt-theme-toggle>');
39+
const toggle = await screen.findByRole('switch');
40+
expect(toggle).not.toBeNull();
41+
expect(toggle.getAttribute('aria-checked')).toBe('true');
42+
expect(toggle.getAttribute('checked')).toBe('true');
43+
});
44+
45+
it('should not have a checked switch if mode is light', async () => {
46+
await fixture('<mgt-theme-toggle></mgt-theme-toggle>');
47+
const toggle = await screen.findByRole('switch');
48+
expect(toggle).not.toBeNull();
49+
expect(toggle.getAttribute('aria-checked')).toBe('false');
50+
expect(toggle.getAttribute('checked')).toBe('false');
51+
});
52+
53+
it('should have a checked switch if user prefers dark mode and no mode is set', async () => {
54+
// redefine matchMedia to return true
55+
Object.defineProperty(window, 'matchMedia', {
56+
writable: true,
57+
value: jest.fn().mockImplementation(query => ({
58+
matches: true,
59+
media: query,
60+
onchange: null,
61+
addListener: jest.fn(), // deprecated
62+
removeListener: jest.fn(), // deprecated
63+
addEventListener: jest.fn(),
64+
removeEventListener: jest.fn(),
65+
dispatchEvent: jest.fn()
66+
}))
67+
});
68+
await fixture('<mgt-theme-toggle></mgt-theme-toggle>');
69+
const toggle = await screen.findByRole('switch');
70+
expect(toggle).not.toBeNull();
71+
expect(toggle.getAttribute('aria-checked')).toBe('true');
72+
expect(toggle.getAttribute('checked')).toBe('true');
73+
});
74+
});
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
/**
2+
* -------------------------------------------------------------------------------------------
3+
* Copyright (c) Microsoft Corporation. All Rights Reserved. Licensed under the MIT License.
4+
* See License in the project root for license information.
5+
* -------------------------------------------------------------------------------------------
6+
*/
7+
8+
import { html, TemplateResult } from 'lit';
9+
import { property } from 'lit/decorators.js';
10+
import { customElement, MgtBaseComponent } from '@microsoft/mgt-element';
11+
import { fluentSwitch } from '@fluentui/web-components/dist/esm/switch';
12+
import { registerFluentComponents } from '../../utils/FluentComponents';
13+
import { applyTheme } from '../../styles/theme-manager';
14+
import { strings } from './strings';
15+
16+
registerFluentComponents(fluentSwitch);
17+
18+
/**
19+
* Toggle to switch between light and dark mode
20+
* Will detect browser preference and set accordingly or dark mode can be forced
21+
*
22+
* @fires {CustomEvent<boolean>} darkmodechanged - Fired when dark mode is toggled by a user action
23+
*
24+
* @class MgtDarkToggle
25+
* @extends {MgtBaseComponent}
26+
*/
27+
@customElement('theme-toggle')
28+
class MgtThemeToggle extends MgtBaseComponent {
29+
constructor() {
30+
super();
31+
const prefersDarkMode = window.matchMedia('(prefers-color-scheme:dark)').matches;
32+
this.darkModeActive = prefersDarkMode;
33+
this.applyTheme(this.darkModeActive);
34+
}
35+
/**
36+
* Provides strings for localization
37+
*
38+
* @readonly
39+
* @protected
40+
* @memberof MgtDarkToggle
41+
*/
42+
protected get strings() {
43+
return strings;
44+
}
45+
46+
/**
47+
* Controls whether dark mode is active
48+
*
49+
* @type {boolean}
50+
* @memberof MgtDarkToggle
51+
*/
52+
@property({
53+
attribute: 'mode',
54+
reflect: true,
55+
type: String,
56+
converter: {
57+
fromAttribute(value: string) {
58+
return value === 'dark';
59+
},
60+
toAttribute(value: boolean) {
61+
return value ? 'dark' : 'light';
62+
}
63+
}
64+
})
65+
public darkModeActive: boolean;
66+
67+
/**
68+
* Fires after a component is updated.
69+
* Allows a component to trigger side effects after updating.
70+
*
71+
* @param {Map<string, any>} changedProperties
72+
* @memberof MgtDarkToggle
73+
*/
74+
updated(changedProperties: Map<string, any>): void {
75+
if (changedProperties.has('darkModeActive')) {
76+
this.applyTheme(this.darkModeActive);
77+
}
78+
}
79+
80+
/**
81+
* renders the component
82+
*
83+
* @return {TemplateResult}
84+
* @memberof MgtDarkToggle
85+
*/
86+
render(): TemplateResult {
87+
return html`
88+
<fluent-switch checked=${this.darkModeActive} @change=${this.onSwitchChanged}>
89+
<span slot="checked-message">${strings.on}</span>
90+
<span slot="unchecked-message">${strings.off}</span>
91+
<label for="direction-switch">${strings.label}</label>
92+
</fluent-switch>
93+
`;
94+
}
95+
96+
private onSwitchChanged(e: Event) {
97+
this.darkModeActive = (e.target as HTMLInputElement).checked;
98+
this.fireCustomEvent('darkmodechanged', this.darkModeActive);
99+
}
100+
101+
private applyTheme(active: boolean) {
102+
const targetTheme = active ? 'dark' : 'light';
103+
applyTheme(targetTheme);
104+
105+
document.body.classList.remove('mgt-dark-mode', 'mgt-light-mode');
106+
document.body.classList.add(`mgt-${targetTheme}-mode`);
107+
}
108+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
/**
2+
* -------------------------------------------------------------------------------------------
3+
* Copyright (c) Microsoft Corporation. All Rights Reserved. Licensed under the MIT License.
4+
* See License in the project root for license information.
5+
* -------------------------------------------------------------------------------------------
6+
*/
7+
8+
Object.defineProperty(window, 'matchMedia', {
9+
writable: true,
10+
value: jest.fn().mockImplementation(query => ({
11+
matches: false,
12+
media: query,
13+
onchange: null,
14+
addListener: jest.fn(), // deprecated
15+
removeListener: jest.fn(), // deprecated
16+
addEventListener: jest.fn(),
17+
removeEventListener: jest.fn(),
18+
dispatchEvent: jest.fn()
19+
}))
20+
});
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
/**
2+
* -------------------------------------------------------------------------------------------
3+
* Copyright (c) Microsoft Corporation. All Rights Reserved. Licensed under the MIT License.
4+
* See License in the project root for license information.
5+
* -------------------------------------------------------------------------------------------
6+
*/
7+
8+
export const strings = {
9+
label: 'Color mode:',
10+
on: 'Dark',
11+
off: 'Light'
12+
};

0 commit comments

Comments
 (0)