Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
54 commits
Select commit Hold shift + click to select a range
2014947
added `Hds::ThemeSwitcher` component
didoo Sep 30, 2025
347d7bf
added `Hds::Theming` service
didoo Sep 30, 2025
834ac51
added theming to the Showcase itself (and replaced hardcoded values w…
didoo Sep 30, 2025
be3f646
added `Shw::ThemeSwitcher` component for showcase
didoo Sep 30, 2025
ce2a6b6
updated `Mock::App` and added new yielded sub-components
didoo Sep 30, 2025
cb7d14f
added `Shw:: ThemeSwitcher` to the Showcase page header
didoo Sep 30, 2025
34775fa
added `foundations/theming` showcase page (and a frameless demo)
didoo Sep 30, 2025
189dc4d
refactored `hds-theming` service to align with the new themes/modes a…
didoo Oct 1, 2025
d9f7cb9
added `hdsTheming` initialization to main showcase app
didoo Oct 1, 2025
4f351ed
removed compilation of components Scss and replaced it with static in…
didoo Oct 3, 2025
7d0e323
added theming options via popover - part 1
didoo Oct 3, 2025
612d055
added theming options via popover - part 2
didoo Oct 3, 2025
4f7f2e4
added theming options via popover - part 3
didoo Oct 3, 2025
aa7cd45
added theming options via popover - part 4
didoo Oct 4, 2025
b5a1228
added theming options via popover - part 5
didoo Oct 6, 2025
ecee808
big code refactoring for the theme selector, to streamline user selec…
didoo Oct 6, 2025
efbe4d2
updated logic that sets the theming for the showcase itself (without …
didoo Oct 7, 2025
20e97f8
small fixes here and there for cleanup and linting
didoo Oct 10, 2025
1286d3a
fixed issue with `pnpm lint:format` (missing newline at the end of `p…
didoo Oct 10, 2025
d089466
fixed accessibility issue in `advanced-table` page, due to changes to…
didoo Oct 10, 2025
2ca0816
fixed typescript error due to new mock page being added
didoo Oct 10, 2025
505160d
added fix for tests failing
didoo Oct 13, 2025
bff955b
started large refactoring/rewrite of the theming switcher and page in…
didoo Oct 14, 2025
b09a8e3
updated logic by creating a `shwTheming` service that extends `hdsThe…
didoo Oct 15, 2025
7c4b794
moved theming logic from `ShwThemeSwitcher` component/subcomponents t…
didoo Oct 15, 2025
e1e593d
updated reference CSS files to follow new theming approach/logic
didoo Oct 16, 2025
cec3389
further refactoring/rewriting of theming logic
didoo Oct 16, 2025
068fff3
updated approach to `light/dark` styles in showcase by using the HDS …
didoo Oct 17, 2025
8a92d04
migrated back the `Contextual` demo content to the index page
didoo Oct 17, 2025
a6c8fe2
added a `DebuggingPanel` to the `ShwThemeSwitcher` controls
didoo Oct 17, 2025
a9e24d7
refactored/improved `DebuggingPanel` and added new preferences to adv…
didoo Oct 17, 2025
2c6358f
small cleanups and refactorings
didoo Oct 17, 2025
036be94
fixed small issue with `ShwThemeSwitcher` selector
didoo Oct 20, 2025
2b2d7df
removed some outdated comments
didoo Oct 20, 2025
07ab36f
small refactorings
didoo Oct 20, 2025
4c9f2e3
added local storage support for theming options
didoo Oct 20, 2025
ff0c674
big refactoring of the `hdsTheming` service to simplify logic and red…
didoo Oct 20, 2025
196c05d
cleanup of debugging comments and other stuff
didoo Oct 20, 2025
28c8bac
refactor and cleanup in preparation for PR review
didoo Oct 21, 2025
fb75198
refactored code to fix logic flow for theming initialization
didoo Oct 21, 2025
656eb31
fixed how the theming options were saved in local storage
didoo Oct 21, 2025
a3e75b4
Apply suggestions from Copilot's code review
didoo Oct 21, 2025
40574f4
fixed issue with `setTheme` not being passed `options` by the `ShwThe…
didoo Oct 22, 2025
98b147d
fix issue with the popover of the ShwThemeSwitcher component, where t…
didoo Oct 22, 2025
0095d99
Update showcase/app/services/shw-theming.ts
didoo Oct 23, 2025
1d7eb41
small tweak to the typing of `HdsModes` per code review suggestion
didoo Oct 23, 2025
a03ab42
updated how the CSS files (tokens and components, with/without themin…
didoo Oct 24, 2025
33d1b14
updated logic for styleshet switching using the `disabled` attribute
didoo Oct 24, 2025
888e15a
removed unused import
didoo Oct 24, 2025
b2c6616
removed `isInitialized` logic from the `hdeTheming` service to avoid bug
didoo Oct 24, 2025
e3bfc6c
TEMP - added temporary `hds-theme-light/dark` selectors
didoo Oct 24, 2025
d48811f
TEMP - Added back custom token values for testing purpose
didoo Oct 24, 2025
c7ca011
fixed small issue with the `HdsThemeSwitcher` components used in the …
didoo Oct 29, 2025
964ac32
added small comment
didoo Oct 29, 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 @@ -369,6 +369,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 @@ -397,6 +398,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';
225 changes: 225 additions & 0 deletions packages/components/src/services/hds-theming.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
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?

Copy link
Contributor

Choose a reason for hiding this comment

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

I guess you just meant the naming. This could be called "default", "legacy", "standard" or something else along those lines to make it clearer vs. calling it "hds".

}

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

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@KristinLBradley I just noticed that I didn't explain this in the sync. Essentially, since Carbon supports 4 themes/modes, we should do the same (in case some consumer, for reasons we didn't anticipate, may need to use a different combination that the standard g0/g100).

Copy link
Contributor

Choose a reason for hiding this comment

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

Ok, I understand we need to or should follow how it's done in Carbon, but I find it confusing.

}

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 _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() {
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,
});
}
}
}

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