Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add ThemeUtil class with tests #539

Closed
wants to merge 3 commits into from
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
5 changes: 5 additions & 0 deletions .changeset/theme-util-class.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@ldn-viz/ui': minor
---

ADDED: added `ThemeUtil` static class with useful theme lookup functions.
1 change: 1 addition & 0 deletions packages/ui/src/lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ export { default as PlaceholderImage } from './placeholderImage/PlaceholderImage
export { default as Theme } from './themeSwitcher/Theme.svelte';
export * from './themeSwitcher/themeStore';
export { default as themeSwitcher } from './themeSwitcher/ThemeSwitcher.svelte';
export { default as ThemeUtil } from './themeSwitcher/ThemeUtil';

export * from './uniformInput/types';
export { default as UniformInput } from './uniformInput/UniformInput.svelte';
Expand Down
121 changes: 121 additions & 0 deletions packages/ui/src/lib/themeSwitcher/ThemeUtil.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { describe, test, expect } from 'vitest';
import ThemeUtil from './ThemeUtil';

import tokens from '@ldn-viz/themes/styles/js/theme-tokens';
import { userThemeSelectionStore } from './themeStore';

// currentThemeMode is not writable so userThemeSelectionStore is used to
// update currentThemeMode.

describe('ThemeUtil.lookup', () => {
test('accepts array token parts', () => {
userThemeSelectionStore.set('light');

const act = ThemeUtil.lookup('color', 'data', 'primary');
expect(act).toEqual(tokens.theme.light.color.data.primary);
});

test('accepts dot separated token parts', () => {
userThemeSelectionStore.set('light');

const act = ThemeUtil.lookup('color.data.primary');
expect(act).toEqual(tokens.theme.light.color.data.primary);
});

test('accepts mixture of dot separated and array token parts', () => {
userThemeSelectionStore.set('light');

const act = ThemeUtil.lookup('color', 'data.primary');
expect(act).toEqual(tokens.theme.light.color.data.primary);
});

test('returns expected value when in light mode', () => {
userThemeSelectionStore.set('light');

const act = ThemeUtil.lookup('color.data.primary');
expect(act).toEqual(tokens.theme.light.color.data.primary);
});

test('returns expected value when in dark mode', () => {
userThemeSelectionStore.set('dark');

const act = ThemeUtil.lookup('color.data.primary');
expect(act).toEqual(tokens.theme.dark.color.data.primary);
});
});

describe('ThemeUtil.createCategoricalDataColorNameGenerator', () => {
test('generates expected sequence of light tokens', () => {
userThemeSelectionStore.set('light');
const generator = ThemeUtil.createCategoricalDataColorNameGenerator();
const lightTokens = Object.keys(tokens.theme.light.color.data.categorical);

let i = 0;
for (const colorName of generator) {
expect(colorName).toEqual(lightTokens[i]);
i++;
}
});

test('generates expected sequence of dark tokens', () => {
userThemeSelectionStore.set('dark');
const generator = ThemeUtil.createCategoricalDataColorNameGenerator();
const darkTokens = Object.keys(tokens.theme.dark.color.data.categorical);

let i = 0;
for (const colorName of generator) {
expect(colorName).toEqual(darkTokens[i]);
i++;
}
});

test('returns null at end of sequence', () => {
userThemeSelectionStore.set('light');
const generator = ThemeUtil.createCategoricalDataColorNameGenerator();

generator.return();

expect(generator.next()).toEqual({
done: true,
value: undefined
});
});
});

describe('ThemeUtil.createCategoricalDataColorGenerator', () => {
test('generates expected sequence of light tokens', () => {
userThemeSelectionStore.set('light');
const generator = ThemeUtil.createCategoricalDataColorGenerator();
const lightTokens = Object.values(tokens.theme.light.color.data.categorical);

let i = 0;
for (const color of generator) {
expect(color).toEqual(lightTokens[i]);
i++;
}
});

test('generates expected sequence of dark tokens', () => {
userThemeSelectionStore.set('dark');
const generator = ThemeUtil.createCategoricalDataColorGenerator();
const darkTokens = Object.values(tokens.theme.dark.color.data.categorical);

let i = 0;
for (const color of generator) {
expect(color).toEqual(darkTokens[i]);
i++;
}
});

test('returns null at end of sequence', () => {
userThemeSelectionStore.set('light');
const generator = ThemeUtil.createCategoricalDataColorGenerator();

generator.return();

expect(generator.next()).toEqual({
done: true,
value: undefined
});
});
});
140 changes: 140 additions & 0 deletions packages/ui/src/lib/themeSwitcher/ThemeUtil.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import { get } from 'svelte/store';
import { currentThemeMode, type ThemeMode } from './themeStore';
import tokens from '@ldn-viz/themes/styles/js/theme-tokens';

type MaybeThemeMode = null | ThemeMode;

/**
* Returns a nested value within an object given an ordered array of member
* names, e.g:
*
* object: {
* one: {
* two: {
* three: "value"
* }
* }
* }
*
* objectLookup(object, ['one', 'two', 'three'])
*/
const objectLookup = (object: object, path: string[]) => {
let result: any = object;

for (let i = 0; i < path.length; i++) {
if (typeof result !== 'object' || Array.isArray(result)) {
throw new Error(`Invalid path to nested value within object: '${path}'`);
}

result = result[path[i]];
}

return result;
};

/**
* ThemeUtil is a static class to ease theme and token lookup in the
* current theme mode.
*
* The class subscribes to the currentThemeMode so lookup is always based
* on the current theme.
*/
export default class ThemeUtil {
static _mode = get(currentThemeMode);

/**
* Returns current theme mode.
*/
static getMode() {
return ThemeUtil._mode;
}

/**
* Returns true if in the specified mode.
*/
static isMode(mode: ThemeMode) {
return ThemeUtil._mode === mode;
}

/**
* Returns the theme object for the specified mode. If no mode is provided
* then the current theme is returned.
*/
static getTheme(mode: MaybeThemeMode = null) {
mode = mode ? mode : ThemeUtil._mode;
return tokens.theme[mode];
}

/**
* Convenience for lookupTheme where the current theme mode should be used.
*/
static lookup(...tokenSegments: string[]) {
return ThemeUtil.lookupTheme(ThemeUtil._mode, ...tokenSegments);
}

/**
* Looks up a token value given a mode and then a sequence of token path
* segments.
*
* The segments may be provided as separate arguments, as a dot separated
* string, or a mixture of both.
*
* E.g. all these are valid and return the same token value:
* - lookupTheme('color', 'data', 'primary')
* - lookupTheme('color.data.primary')
* - lookupTheme('color', 'data.primary')
*/
static lookupTheme(mode: MaybeThemeMode, ...tokenSegments: string[]) {
const splitSegments = (tk: string) => tk.split('.');
const path = tokenSegments.map(splitSegments).flat();
const theme = ThemeUtil.getTheme(mode);
return objectLookup(theme, path);
}

/**
* Creates a generator function that returns categorical data tokens.
*
* The generator initialises with the current theme mode unless a specific
* mode is passed. If the mode needs to change a new generator must be
* created, including if the current theme mode store updates.
*/
static createCategoricalDataColorNameGenerator = (mode: MaybeThemeMode = null) => {
const theme = ThemeUtil.getTheme(mode);
const tokens = Object.keys(theme.color.data.categorical);

function* generateColorName() {
for (const name of tokens) {
yield name;
}
}

return generateColorName();
};

/**
* Creates a generator function that returns categorical data colors.
*
* The generator initialises with the current theme mode unless a specific
* mode is passed. If the mode needs to change a new generator must be
* created, including if the current theme mode store updates.
*/
static createCategoricalDataColorGenerator = (mode: MaybeThemeMode = null) => {
const nameGenerator = ThemeUtil.createCategoricalDataColorNameGenerator(mode);

function* generateColor() {
for (const name of nameGenerator) {
yield ThemeUtil.lookupTheme(mode, 'color.data.categorical', name);
}
}

return generateColor();
};

constructor() {
throw new Error('ThemeUtil is a static class, use its static functions instead');
}
}

currentThemeMode.subscribe((mode) => {
ThemeUtil._mode = mode;
});
9 changes: 6 additions & 3 deletions packages/ui/src/lib/themeSwitcher/themeStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,19 @@ import { browser } from '$app/environment';
import { prefersDarkMode } from '../userPreference/mediaQueryStore';
import { derived, writable, type Readable } from 'svelte/store';

export type UserThemeSelection = 'light' | 'dark' | 'system';
export type ThemeMode = 'light' | 'dark';

const getLocalStorage = () => {
if (browser) {
return globalThis.localStorage?.getItem('theme') || 'light';
return (globalThis.localStorage?.getItem('theme') as UserThemeSelection) || 'light';
}
return 'light';
};

export const userThemeSelectionStore = writable(getLocalStorage());
export const userThemeSelectionStore = writable<UserThemeSelection>(getLocalStorage());

export const currentThemeMode: Readable<'light' | 'dark'> = derived(
export const currentThemeMode: Readable<ThemeMode> = derived(
[userThemeSelectionStore, prefersDarkMode],
([$userThemeSelectionStore, $prefersDarkMode]) => {
if ($userThemeSelectionStore === 'system') {
Expand Down