Skip to content

Commit

Permalink
Merge branch 'issue-0304' into add-sentry
Browse files Browse the repository at this point in the history
  • Loading branch information
Adammatthiesen authored Nov 17, 2024
2 parents 529cb42 + 79b0990 commit 47342df
Show file tree
Hide file tree
Showing 12 changed files with 362 additions and 11 deletions.
5 changes: 5 additions & 0 deletions .changeset/sharp-zoos-tickle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@studiocms/ui": patch
---

Added a theme helper and theme toggle component
1 change: 0 additions & 1 deletion packages/studiocms_core/src/schemas/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,5 +75,4 @@ export const StudioCMSOptionsSchema = z
.default({});

export type StudioCMSOptions = typeof StudioCMSOptionsSchema._input;

export type StudioCMSConfig = typeof StudioCMSOptionsSchema._output;
1 change: 1 addition & 0 deletions packages/studiocms_ui/src/components.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export { Select } from './components/index';
export { SearchSelect } from './components/index';
export { Dropdown, DropdownHelper } from './components/index';
export { User } from './components/index';
export { ThemeToggle } from './components/index';

export {
Sidebar,
Expand Down
40 changes: 40 additions & 0 deletions packages/studiocms_ui/src/components/ThemeToggle.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
---
import type { ComponentProps } from "astro/types";
import Button from "./Button.astro";
interface Props extends ComponentProps<typeof Button> {};
const props = Astro.props;
---

<Button id='sui__theme-toggle' {...props}>
<div id="dark-content">
<slot name="dark" />
</div>
<div id="light-content">
<slot name="light" />
</div>
</Button>

<script>
import { ThemeHelper } from '../utils/ThemeHelper';

const themeToggle = document.getElementById('sui__theme-toggle');
const themeHelper = new ThemeHelper();

themeHelper.registerToggle(themeToggle);
</script>

<style is:global>
#sui__theme-toggle #dark-content, #sui__theme-toggle #light-content {
display: none;
}

[data-theme="dark"] #sui__theme-toggle #dark-content {
display: block;
}

[data-theme="light"] #sui__theme-toggle #light-content {
display: block;
}
</style>
1 change: 1 addition & 0 deletions packages/studiocms_ui/src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export { default as Select } from "./Select.astro";
export { default as SearchSelect } from "./SearchSelect.astro";
export { default as Dropdown } from "./Dropdown/Dropdown.astro";
export { default as User } from "./User.astro";
export { default as ThemeToggle } from './ThemeToggle.astro';

export { default as Sidebar } from "./Sidebar/Single.astro";
export { default as DoubleSidebar } from "./Sidebar/Double.astro";
Expand Down
127 changes: 127 additions & 0 deletions packages/studiocms_ui/src/utils/ThemeHelper.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
type Theme = 'dark' | 'light' | 'system';
type ThemeChangeCallback = (newTheme: Theme, oldTheme: Theme) => void;

/**
* A helper to toggle, set and get the current StudioCMS UI theme.
*/
class ThemeHelper {
private themeManagerElement: HTMLElement;
private observer: MutationObserver | undefined;
private themeChangeCallbacks: ThemeChangeCallback[] = [];

/**
* A helper to toggle, set and get the current StudioCMS UI theme.
* @param themeProvider The element that should carry the data-theme attribute (replaces the document root)
*/
constructor(themeProvider?: HTMLElement) {
this.themeManagerElement = themeProvider || document.documentElement;
}

/**
* Get the current theme.
* @param {boolean} resolveSystemTheme Whether to resolve the `system` theme to the actual theme (`dark` or `light`)
* @returns {Theme} The current theme.
*/
public getTheme = <T extends boolean>(resolveSystemTheme?: T): T extends true ? 'dark' | 'light' : Theme => {
const theme = this.themeManagerElement.dataset.theme as Theme || 'system';

if (!resolveSystemTheme) {
// Side note: Don't ask me why this type wizardry is needed but it gives proper return types so I don't care
return theme as T extends true ? 'dark' | 'light' : Theme;
}

if (this.themeManagerElement.dataset.theme !== 'system') {
return this.themeManagerElement.dataset.theme as 'dark' | 'light';
}

if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
return 'dark';
}

if (window.matchMedia('(prefers-color-scheme: light)').matches) {
return 'light';
}

// This should (in theory) never happen since, at time of writing, window.matchMedia is supported
// by 96.83% of all browsers in use. (https://caniuse.com/mdn-api_window_matchmedia)
throw new Error('Unable to resolve theme. (Most likely cause: window.matchMedia is not supported by the browser)');
};

/**
* Sets the current theme.
* @param theme The new theme. One of `dark`, `light` or `system`.
*/
public setTheme = (theme: Theme): void => {
this.themeManagerElement.dataset.theme = theme;
};

/**
* Toggles the current theme.
*
* If the theme is set to `system` (or no theme is set via the root element),
* the theme is set depending on the user's color scheme preference (set in the browser).
*/
public toggleTheme = (): void => {
const theme = this.getTheme();

if (theme === 'dark') {
this.setTheme('light');
return;
}

if (theme === 'light') {
this.setTheme('dark');
return;
}

if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
this.setTheme('light');
return;
}

if (window.matchMedia('(prefers-color-scheme: light)').matches) {
this.setTheme('dark');
return;
}
};

/**
* Register an element to act as a toggle! When clicked, it will toggle the theme.
* @param toggle The HTML element that should act as the toggle
*/
public registerToggle = (toggle: HTMLElement | null): void => {
if (!toggle) {
console.error('Element passed to toggle registration does not exist.');
return;
};

toggle.addEventListener('click', this.toggleTheme);
};

/**
* Allows for adding a callback that gets called whenever the theme changes,
* @param callback The callback to be executed
*/
public onThemeChange = (callback: ThemeChangeCallback): void => {
if (!this.observer) {
this.observer = new MutationObserver(this.themeManagerMutationHandler);
this.observer.observe(this.themeManagerElement, { attributes: true, attributeOldValue: true, attributeFilter: ['data-theme'] });
}

this.themeChangeCallbacks.push(callback);
};

/**
* Simply gets the first mutation and calls all registered callbacks.
* @param mutations The mutations array from the observer. Due to the specified options, this will always be a 1-length array,
*/
private themeManagerMutationHandler = (mutations: MutationRecord[]): void => {
if (!mutations[0]) return;

for (const callback of this.themeChangeCallbacks) {
callback(this.getTheme(), mutations[0].oldValue as Theme || 'system');
}
};
}

export { ThemeHelper, type Theme };
2 changes: 1 addition & 1 deletion pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 11 additions & 0 deletions www/docs/src/components/ThemeHelperScript.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<script>
import { ThemeHelper } from '@studiocms/ui/utils/ThemeHelper.ts';

const themeHelper = new ThemeHelper();

const outputSpan = document.querySelector<HTMLSpanElement>('#theme-listener-output')!;

themeHelper.onThemeChange((newTheme, oldTheme) => {
outputSpan.textContent = `Theme is now: ${newTheme}! (Before: ${oldTheme})`;
});
</script>
16 changes: 8 additions & 8 deletions www/docs/src/components/Youtube.astro
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
---
import { YouTube } from 'astro-embed';
interface Props {
id: string;
title?: string;
}
const { id, title } = Astro.props;
import { YouTube } from 'astro-embed';
interface Props {
id: string;
title?: string;
}
const { id, title } = Astro.props;
---
<YouTube id={id} title={title} />

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
---
title: Theme Helper
---
import { Tabs, TabItem } from '@astrojs/starlight/components';
import PreviewCard from '~/components/PreviewCard.astro';
import ThemeHelperScript from '~/components/ThemeHelperScript.astro';

To make managing the active theme easier, we provide a helper class to get, set and toggle the theme. Additionally,
you can provide callbacks for when the theme gets changed!

## Usage

:::caution
The `ThemeHelper` can only be used client-side (in `<script>` tags).
:::
```ts title="script.ts" twoslash
import { ThemeHelper } from '@studiocms/ui/utils/ThemeHelper.ts';

// Instanciate a new helper
const themeHelper = new ThemeHelper();

// Get the current theme. (One of `dark`, `light` or `system`)
const theme = themeHelper.getTheme();

// Get the current theme but resolve the actual theme if `system` is selected
// @noErrors
const resolvedTheme = themeHelper.getTheme(true);

// Set the theme to light
themeHelper.setTheme('light');

// Toggle the theme
themeHelper.toggleTheme();

// Register an element that should act as a toggle
const toggleButton = document.querySelector<HTMLButtonElement>('#toggle-button');
themeHelper.registerToggle(toggleButton);
```

### Listening for theme changes

Using the `ThemeHelper` class, you can listen to theme changes! This is useful when you have logic that needs to run
whenever the color scheme changes, for example in a `three.js` canvas where you need to change an image
(*\*cough cough\* our login page \*cough\**).

```ts title="script.ts" twoslash
import { ThemeHelper } from '@studiocms/ui/utils/ThemeHelper.ts';

// Instanciate a new helper
const themeHelper = new ThemeHelper();

// Add a callback that gets called when the theme changes
themeHelper.onThemeChange((newTheme) => {
// Your logic here!
});
```

Here's a live example: Change the theme via the option in the top-right corner of your screen. If you're on mobile,
open the navbar and scroll all the way down. After you've changed the theme, check the text below:

<Tabs>
<TabItem label="Preview">
<PreviewCard>
<span id="theme-listener-output">
Theme hasn't changed yet.
</span>
</PreviewCard>
</TabItem>
<TabItem label="Code">
```html
<span id="theme-listener-output">
Theme hasn't changed yet.
</span>
```
```ts twoslash
import { ThemeHelper, type Theme } from '@studiocms/ui/utils/ThemeHelper.ts';

// Instanciate a new helper
const themeHelper = new ThemeHelper();

const outputSpan = document.querySelector<HTMLSpanElement>('#theme-listener-output')!;

// Add a callback that gets called when the theme changes
// @noErrors
themeHelper.onThemeChange((newTheme: Theme, oldTheme: Theme) => {
// Your logic here!
outputSpan.textContent = `Theme is now: ${newTheme}! (Before: ${oldTheme})`;
});
```
</TabItem>
</Tabs>

Since `@studiocms/ui` is compatible with Starlight's theme system, this even picks up on those changes.

### Remembering a users theme selection

One of the few things the `ThemeHelper` does not do is saving the users theme preference. This is by design, since we don't want to force websites operating in the EU (and other GDPR-enforcing countries) to have to add a cookie notice just for a UI library. Instead, implementation of this functionality is up to the developers themselves.

As a starting point, here's a barebones example of how to implement this:

```ts twoslash
import { ThemeHelper, type Theme } from '@studiocms/ui/utils/ThemeHelper.ts';

const themeHelper = new ThemeHelper();

themeHelper.onThemeChange((newTheme: Theme) => {
localStorage.setItem('theme-selection', newTheme);
})
```

If you want to go even further, you can store this information in a cookie to retrieve it server-side.

<ThemeHelperScript />
Loading

0 comments on commit 47342df

Please sign in to comment.