Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
30c7358
added `Hds::ThemeSwitcher` component
didoo Sep 30, 2025
b010313
added `Hds::Theming` service
didoo Sep 30, 2025
6e7c212
added theming to the Showcase itself (and replaced hardcoded values w…
didoo Sep 30, 2025
e34eec2
added `Shw::ThemeSwitcher` component for showcase
didoo Sep 30, 2025
5334488
updated `Mock::App` and added new yielded sub-components
didoo Sep 30, 2025
886991c
added `Shw:: ThemeSwitcher` to the Showcase page header
didoo Sep 30, 2025
fe43287
added `foundations/theming` showcase page (and a frameless demo)
didoo Sep 30, 2025
ea7d04f
refactored `hds-theming` service to align with the new themes/modes a…
didoo Oct 1, 2025
405e985
added `hdsTheming` initialization to main showcase app
didoo Oct 1, 2025
cc8f42b
removed compilation of components Scss and replaced it with static in…
didoo Oct 3, 2025
047d063
added theming options via popover - part 1
didoo Oct 3, 2025
88fa64d
added theming options via popover - part 2
didoo Oct 3, 2025
ef9c402
added theming options via popover - part 3
didoo Oct 3, 2025
8b74fa2
added theming options via popover - part 4
didoo Oct 4, 2025
6c54ffa
added theming options via popover - part 5
didoo Oct 6, 2025
e203f2a
big code refactoring for the theme selector, to streamline user selec…
didoo Oct 6, 2025
b779edf
updated logic that sets the theming for the showcase itself (without …
didoo Oct 7, 2025
d353214
small fixes here and there for cleanup and linting
didoo Oct 10, 2025
28bd8d3
fixed issue with `pnpm lint:format` (missing newline at the end of `p…
didoo Oct 10, 2025
5c09ae0
fixed accessibility issue in `advanced-table` page, due to changes to…
didoo Oct 10, 2025
d0d9929
fixed typescript error due to new mock page being added
didoo Oct 10, 2025
fae141d
added fix for tests failing
didoo Oct 13, 2025
ba04696
started large refactoring/rewrite of the theming switcher and page in…
didoo Oct 14, 2025
f227d85
updated logic by creating a `shwTheming` service that extends `hdsThe…
didoo Oct 15, 2025
b39fc16
moved theming logic from `ShwThemeSwitcher` component/subcomponents t…
didoo Oct 15, 2025
7c1b1d9
updated reference CSS files to follow new theming approach/logic
didoo Oct 16, 2025
d60ad02
further refactoring/rewriting of theming logic
didoo Oct 16, 2025
2ca46d3
updated approach to `light/dark` styles in showcase by using the HDS …
didoo Oct 17, 2025
c0b0a55
migrated back the `Contextual` demo content to the index page
didoo Oct 17, 2025
d916092
added a `DebuggingPanel` to the `ShwThemeSwitcher` controls
didoo Oct 17, 2025
e68a6d8
refactored/improved `DebuggingPanel` and added new preferences to adv…
didoo Oct 17, 2025
b07fd14
small cleanups and refactorings
didoo Oct 17, 2025
96fdc4a
fixed small issue with `ShwThemeSwitcher` selector
didoo Oct 20, 2025
a291556
removed some outdated comments
didoo Oct 20, 2025
590d0fc
small refactorings
didoo Oct 20, 2025
326f0e0
added local storage support for theming options
didoo Oct 20, 2025
3132fe2
big refactoring of the `hdsTheming` service to simplify logic and red…
didoo Oct 20, 2025
be76fe3
cleanup of debugging comments and other stuff
didoo Oct 20, 2025
b6ad465
refactor and cleanup in preparation for PR review
didoo Oct 21, 2025
8c6da52
refactored code to fix logic flow for theming initialization
didoo Oct 21, 2025
25c5e29
fixed how the theming options were saved in local storage
didoo Oct 21, 2025
4d45460
Apply suggestions from Copilot's code review
didoo Oct 21, 2025
c3a9268
fixed issue with `setTheme` not being passed `options` by the `ShwThe…
didoo Oct 22, 2025
7800278
fix issue with the popover of the ShwThemeSwitcher component, where t…
didoo Oct 22, 2025
85beb6f
TEMP - silence deprecations
didoo Oct 22, 2025
502312e
Update showcase/app/services/shw-theming.ts
didoo Oct 23, 2025
fd95fc1
small tweak to the typing of `HdsModes` per code review suggestion
didoo Oct 23, 2025
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
2 changes: 2 additions & 0 deletions packages/components/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -366,6 +366,7 @@
"./components/hds/text/code.js": "./dist/_app_/components/hds/text/code.js",
"./components/hds/text/display.js": "./dist/_app_/components/hds/text/display.js",
"./components/hds/text.js": "./dist/_app_/components/hds/text.js",
"./components/hds/theme-switcher.js": "./dist/_app_/components/hds/theme-switcher.js",
"./components/hds/time.js": "./dist/_app_/components/hds/time.js",
"./components/hds/time/range.js": "./dist/_app_/components/hds/time/range.js",
"./components/hds/time/single.js": "./dist/_app_/components/hds/time/single.js",
Expand Down Expand Up @@ -394,6 +395,7 @@
"./modifiers/hds-register-event.js": "./dist/_app_/modifiers/hds-register-event.js",
"./modifiers/hds-tooltip.js": "./dist/_app_/modifiers/hds-tooltip.js",
"./services/hds-intl.js": "./dist/_app_/services/hds-intl.js",
"./services/hds-theming.js": "./dist/_app_/services/hds-theming.js",
"./services/hds-time.js": "./dist/_app_/services/hds-time.js"
}
},
Expand Down
3 changes: 3 additions & 0 deletions packages/components/src/components.ts
Original file line number Diff line number Diff line change
Expand Up @@ -327,6 +327,9 @@ export { default as HdsTextCode } from './components/hds/text/code.ts';
export { default as HdsTextDisplay } from './components/hds/text/display.ts';
export * from './components/hds/text/types.ts';

// Theme Switcher
export { default as HdsThemeSwitcher } from './components/hds/theme-switcher/index.ts';

// Time
export { default as HdsTime } from './components/hds/time/index.ts';
export { default as HdsTimeSingle } from './components/hds/time/single.ts';
Expand Down
29 changes: 29 additions & 0 deletions packages/components/src/components/hds/theme-switcher/index.hbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: MPL-2.0
}}

{{!
------------------------------------------------------------------------------------------
IMPORTANT: this is a temporary implementation, while we wait for the design specifications
------------------------------------------------------------------------------------------
}}

<Hds::Dropdown
@enableCollisionDetection={{true}}
@matchToggleWidth={{@toggleIsFullWidth}}
class="hds-theme-switcher-control"
...attributes
as |D|
>
<D.ToggleButton
@color="secondary"
@size={{this.toggleSize}}
@isFullWidth={{this.toggleIsFullWidth}}
@text={{this.toggleContent.label}}
@icon={{this.toggleContent.icon}}
/>
{{#each-in this._options as |key data|}}
<D.Interactive @icon={{data.icon}} {{on "click" (fn this.onSelectTheme data.theme)}}>{{data.label}}</D.Interactive>
{{/each-in}}
</Hds::Dropdown>
95 changes: 95 additions & 0 deletions packages/components/src/components/hds/theme-switcher/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: MPL-2.0
*/

// ------------------------------------------------------------------------------------------
// IMPORTANT: this is a temporary implementation, while we wait for the design specifications
// ------------------------------------------------------------------------------------------

import Component from '@glimmer/component';
import { inject as service } from '@ember/service';
import { action } from '@ember/object';

import type { HdsDropdownSignature } from '../dropdown/index.ts';
import type { HdsDropdownToggleButtonSignature } from '../dropdown/toggle/button.ts';
import type { HdsIconSignature } from '../icon/index.ts';
import type HdsThemingService from '../../../services/hds-theming.ts';
import type {
HdsThemes,
OnSetThemeCallback,
} from '../../../services/hds-theming.ts';

interface ThemeOption {
theme: HdsThemes | undefined;
icon: HdsIconSignature['Args']['name'];
label: string;
}

const OPTIONS: Record<HdsThemes, ThemeOption> = {
system: { theme: 'system', icon: 'monitor', label: 'System' },
light: { theme: 'light', icon: 'sun', label: 'Light' },
dark: { theme: 'dark', icon: 'moon', label: 'Dark' },
};

interface HdsThemeSwitcherSignature {
Args: {
toggleSize?: HdsDropdownToggleButtonSignature['Args']['size'];
toggleIsFullWidth?: HdsDropdownToggleButtonSignature['Args']['isFullWidth'];
hasSystemOption?: boolean;
onSetTheme?: OnSetThemeCallback;
};
Element: HdsDropdownSignature['Element'];
}

export default class HdsThemeSwitcher extends Component<HdsThemeSwitcherSignature> {
@service declare readonly hdsTheming: HdsThemingService;

get toggleSize() {
return this.args.toggleSize ?? 'small';
}

get toggleIsFullWidth() {
return this.args.toggleIsFullWidth ?? false;
}

get toggleContent() {
if (
(this.currentTheme === 'system' && this.hasSystemOption) ||
this.currentTheme === 'light' ||
this.currentTheme === 'dark'
) {
return {
label: OPTIONS[this.currentTheme].label,
icon: OPTIONS[this.currentTheme].icon,
};
} else {
return { label: 'Theme', icon: undefined };
}
}

get hasSystemOption() {
return this.args.hasSystemOption ?? true;
}

get _options() {
const options: Partial<typeof OPTIONS> = { ...OPTIONS };

if (!this.hasSystemOption) {
delete options.system;
}

return options;
}

get currentTheme() {
// we get the theme from the global service
return this.hdsTheming.currentTheme;
}

@action
onSelectTheme(theme: HdsThemes | undefined): void {
// we set the theme in the global service (and provide an optional user-defined callback)
this.hdsTheming.setTheme({ theme, onSetTheme: this.args.onSetTheme });
}
}
2 changes: 2 additions & 0 deletions packages/components/src/services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,5 @@
*/

// This file is used to expose public services

export * from './services/hds-theming.ts';
232 changes: 232 additions & 0 deletions packages/components/src/services/hds-theming.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,232 @@
import Service from '@ember/service';
import { tracked } from '@glimmer/tracking';

export enum HdsThemeValues {
// system settings (prefers-color-scheme)
System = 'system',
// user settings for dark/light
Light = 'light',
Dark = 'dark',
}

enum HdsModesBaseValues {
Hds = 'hds', // TODO understand if it should be `default`
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If consumers don't do anything, the "Hds" theme is the one they should get so that would be a "default". Is that what you mean?

}

enum HdsModesLightValues {
CdsG0 = 'cds-g0',
CdsG10 = 'cds-g10',
}

enum HdsModesDarkValues {
CdsG90 = 'cds-g90',
CdsG100 = 'cds-g100',
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I still don't really understand the purpose of having multiple Light & Dark value options.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll explain in today's sync

}

export enum HdsCssSelectorsValues {
Data = 'data',
Class = 'class',
}

export type HdsThemes = `${HdsThemeValues}`;
export type HdsModes =
| `${HdsModesBaseValues}`
| `${HdsModesLightValues}`
| `${HdsModesDarkValues}`;
export type HdsModesLight = `${HdsModesLightValues}`;
export type HdsModesDark = `${HdsModesDarkValues}`;
export type HdsCssSelectors = `${HdsCssSelectorsValues}`;

type HdsThemingOptions = {
lightTheme: HdsModesLight;
darkTheme: HdsModesDark;
cssSelector: HdsCssSelectors;
};

type SetThemeArgs = {
theme: HdsThemes | undefined;
options?: HdsThemingOptions;
onSetTheme?: OnSetThemeCallback;
};

export type OnSetThemeCallbackArgs = {
currentTheme: HdsThemes | undefined;
currentMode: HdsModes | undefined;
};

export type OnSetThemeCallback = (args: OnSetThemeCallbackArgs) => void;

export const THEMES: HdsThemes[] = Object.values(HdsThemeValues);
export const MODES_LIGHT: HdsModesLight[] = Object.values(HdsModesLightValues);
export const MODES_DARK: HdsModesDark[] = Object.values(HdsModesDarkValues);
export const MODES: HdsModes[] = [
...Object.values(HdsModesBaseValues),
...MODES_LIGHT,
...MODES_DARK,
];

export const HDS_THEMING_DATA_SELECTOR = 'data-hds-theme';
export const HDS_THEMING_CLASS_SELECTOR_PREFIX = 'hds-theme';
export const HDS_THEMING_CLASS_SELECTORS_LIST = [
...MODES_LIGHT,
...MODES_DARK,
].map((mode) => `${HDS_THEMING_CLASS_SELECTOR_PREFIX}-${mode}`);

export const HDS_THEMING_LOCALSTORAGE_DATA = 'hds-theming-data';

export const DEFAULT_THEMING_OPTION_LIGHT_THEME = HdsModesLightValues.CdsG0;
export const DEFAULT_THEMING_OPTION_DARK_THEME = HdsModesDarkValues.CdsG100;
export const DEFAULT_THEMING_OPTION_CSS_SELECTOR = 'data';

export default class HdsThemingService extends Service {
@tracked _isInitialized: boolean = false;
@tracked _currentTheme: HdsThemes | undefined = undefined;
@tracked _currentMode: HdsModes | undefined = undefined;
@tracked _currentLightTheme: HdsModesLight =
DEFAULT_THEMING_OPTION_LIGHT_THEME;
@tracked _currentDarkTheme: HdsModesDark = DEFAULT_THEMING_OPTION_DARK_THEME;
@tracked _currentCssSelector: HdsCssSelectors =
DEFAULT_THEMING_OPTION_CSS_SELECTOR;
@tracked globalOnSetTheme: OnSetThemeCallback | undefined;

initializeTheme() {
if (this._isInitialized) {
return;
}

const rawStoredThemingData = localStorage.getItem(
HDS_THEMING_LOCALSTORAGE_DATA
);
if (rawStoredThemingData !== null) {
const storedThemingData: unknown = JSON.parse(rawStoredThemingData);
if (storedThemingData) {
const { theme, options } = storedThemingData as {
theme: HdsThemes | undefined;
options: HdsThemingOptions;
};
this.setTheme({
theme,
options,
});
}
}

this._isInitialized = true;
}

setTheme({ theme, options, onSetTheme }: SetThemeArgs) {
if (options !== undefined) {
// if we have new options, we override the current ones (`lightTheme` / `darkTheme` / `cssSelector`)
// these options can be used by consumers that want to customize how they apply theming
// (and used by the showcase for the custom theming / theme switching logic)
if (
Object.hasOwn(options, 'lightTheme') &&
Object.hasOwn(options, 'darkTheme') &&
Object.hasOwn(options, 'cssSelector')
) {
const { lightTheme, darkTheme, cssSelector } = options;

this._currentLightTheme = lightTheme;
this._currentDarkTheme = darkTheme;
this._currentCssSelector = cssSelector;
} else {
// fallback if something goes wrong
this._currentLightTheme = DEFAULT_THEMING_OPTION_LIGHT_THEME;
this._currentDarkTheme = DEFAULT_THEMING_OPTION_DARK_THEME;
this._currentCssSelector = DEFAULT_THEMING_OPTION_CSS_SELECTOR;
}
}

// set the current theme/mode (`currentTheme` / `currentMode`)
if (
theme === undefined || // standard (no theming)
!THEMES.includes(theme) // handle possible errors
) {
this._currentTheme = undefined;
this._currentMode = undefined;
} else if (
theme === HdsThemeValues.System // system (prefers-color-scheme)
) {
this._currentTheme = HdsThemeValues.System;
this._currentMode = undefined;
} else {
this._currentTheme = theme;
if (this._currentTheme === HdsThemeValues.Light) {
this._currentMode = this._currentLightTheme;
}
if (this._currentTheme === HdsThemeValues.Dark) {
this._currentMode = this._currentDarkTheme;
}
}

// IMPORTANT: for this to work, it needs to be the HTML tag (it's the `:root` in CSS)
const rootElement = document.querySelector('html');

if (!rootElement) {
return;
}
// remove or update the CSS selectors applied to the root element (depending on the `theme` argument)
rootElement.removeAttribute(HDS_THEMING_DATA_SELECTOR);
rootElement.classList.remove(...HDS_THEMING_CLASS_SELECTORS_LIST);
if (this._currentMode !== undefined) {
if (this._currentCssSelector === 'data') {
rootElement.setAttribute(HDS_THEMING_DATA_SELECTOR, this._currentMode);
} else if (this._currentCssSelector === 'class') {
rootElement.classList.add(
`${HDS_THEMING_CLASS_SELECTOR_PREFIX}-${this._currentMode}`
);
}
}

// store the current theme and theming options in local storage (unless undefined)
localStorage.setItem(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] There could be use cases where a consumer wants to handle on their own how they keep track of a user's theme. They could want to handle it through their own cookies or storage or some other means. It would be useful to have an argument like hasLocalStorage, where if false then nothing would get set from our service in local storage.

A consumer could then use the globalOnSetTheme to listen for theme updates and handle them in their own way.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we want to keep the globalOnSetTheme callback? which use cases do we foresee?

Because of my point above I think it would be useful to keep the callback.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With the current implementation, what they could do would be to use the setTheme() call in the application controller, instead of the initializeTheme() (see here) and set their own theme and options; this would write on localstorage, but they could simply ignore our values and use theirs way of storing this information (as you suggested, cookie for example).

The setTheme() method has a callback, so in theory they may use that, instead of the global one, so we still have to find a good reason to keep it alive :)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would using globalOnSetTheme be easier or more straightforward for them to use vs. setTheme() in any way?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no, globalOnSetTheme is an (optional) callback function that gets called (if provided) when setTheme() is executed:

setTheme(
    ↳ onSetTheme()
    ↳ globalOnSetTheme()
)

Copy link
Contributor

@dchyun dchyun Oct 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The setTheme() method has a callback, so in theory they may use that, instead of the global one, so we still have to find a good reason to keep it alive

This was the main use case I was thinking to keep it for. It's good to have some way for consumers to listen for theme changes and onSetTheme covers that. So maybe it's not needed.

HDS_THEMING_LOCALSTORAGE_DATA,
JSON.stringify({
theme: this._currentTheme,
options: {
lightTheme: this._currentLightTheme,
darkTheme: this._currentDarkTheme,
cssSelector: this._currentCssSelector,
},
})
);

// this is a general callback that can be defined globally (by extending the service)
if (this.globalOnSetTheme) {
this.globalOnSetTheme({
currentTheme: this._currentTheme,
currentMode: this._currentMode,
});
}

// this is a "local" callback that can be defined "locally" (eg. in a theme switcher)
if (onSetTheme) {
onSetTheme({
currentTheme: this._currentTheme,
currentMode: this._currentMode,
});
}
}

// getters used for reactivity in the components/services using this service

get currentTheme(): HdsThemes | undefined {
return this._currentTheme;
}

get currentMode(): HdsModes | undefined {
return this._currentMode;
}

get currentLightTheme(): HdsModesLight {
return this._currentLightTheme ?? DEFAULT_THEMING_OPTION_LIGHT_THEME;
}

get currentDarkTheme(): HdsModesDark {
return this._currentDarkTheme ?? DEFAULT_THEMING_OPTION_DARK_THEME;
}

get currentCssSelector(): HdsCssSelectors {
return this._currentCssSelector ?? DEFAULT_THEMING_OPTION_CSS_SELECTOR;
}
}
Loading
Loading