diff --git a/.changeset/sharp-zoos-tickle.md b/.changeset/sharp-zoos-tickle.md new file mode 100644 index 0000000000..7f18113741 --- /dev/null +++ b/.changeset/sharp-zoos-tickle.md @@ -0,0 +1,5 @@ +--- +"@studiocms/ui": patch +--- + +Added a theme helper and theme toggle component diff --git a/packages/studiocms_core/src/schemas/config/index.ts b/packages/studiocms_core/src/schemas/config/index.ts index fc7933e7a5..d689ec5e5e 100644 --- a/packages/studiocms_core/src/schemas/config/index.ts +++ b/packages/studiocms_core/src/schemas/config/index.ts @@ -75,5 +75,4 @@ export const StudioCMSOptionsSchema = z .default({}); export type StudioCMSOptions = typeof StudioCMSOptionsSchema._input; - export type StudioCMSConfig = typeof StudioCMSOptionsSchema._output; diff --git a/packages/studiocms_ui/src/components.ts b/packages/studiocms_ui/src/components.ts index e4ae92774a..09998d4fc9 100644 --- a/packages/studiocms_ui/src/components.ts +++ b/packages/studiocms_ui/src/components.ts @@ -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, diff --git a/packages/studiocms_ui/src/components/ThemeToggle.astro b/packages/studiocms_ui/src/components/ThemeToggle.astro new file mode 100644 index 0000000000..bc6472b5c0 --- /dev/null +++ b/packages/studiocms_ui/src/components/ThemeToggle.astro @@ -0,0 +1,40 @@ +--- +import type { ComponentProps } from "astro/types"; +import Button from "./Button.astro"; + +interface Props extends ComponentProps {}; + +const props = Astro.props; +--- + + + + + + diff --git a/packages/studiocms_ui/src/components/index.ts b/packages/studiocms_ui/src/components/index.ts index 072e5be655..46bf1c7e1a 100644 --- a/packages/studiocms_ui/src/components/index.ts +++ b/packages/studiocms_ui/src/components/index.ts @@ -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"; diff --git a/packages/studiocms_ui/src/utils/ThemeHelper.ts b/packages/studiocms_ui/src/utils/ThemeHelper.ts new file mode 100644 index 0000000000..ffffb794fc --- /dev/null +++ b/packages/studiocms_ui/src/utils/ThemeHelper.ts @@ -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 = (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 }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 799a1d4aac..c5429a20f5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -13262,4 +13262,4 @@ snapshots: zod@3.23.8: {} - zwitch@2.0.4: {} + zwitch@2.0.4: {} \ No newline at end of file diff --git a/www/docs/src/components/ThemeHelperScript.astro b/www/docs/src/components/ThemeHelperScript.astro new file mode 100644 index 0000000000..dde1575bce --- /dev/null +++ b/www/docs/src/components/ThemeHelperScript.astro @@ -0,0 +1,11 @@ + diff --git a/www/docs/src/components/Youtube.astro b/www/docs/src/components/Youtube.astro index 6e16aaca6d..ea35f96148 100644 --- a/www/docs/src/components/Youtube.astro +++ b/www/docs/src/components/Youtube.astro @@ -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; --- diff --git a/www/docs/src/content/docs/customizing/studiocms-ui/components/theme-helper.mdx b/www/docs/src/content/docs/customizing/studiocms-ui/components/theme-helper.mdx new file mode 100644 index 0000000000..c310ef5536 --- /dev/null +++ b/www/docs/src/content/docs/customizing/studiocms-ui/components/theme-helper.mdx @@ -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 `