diff --git a/packages/components/package.json b/packages/components/package.json index 26d9acdaa2c..fe47a4a3a34 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -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", @@ -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" } }, diff --git a/packages/components/src/components.ts b/packages/components/src/components.ts index 400dc061324..adb7400d800 100644 --- a/packages/components/src/components.ts +++ b/packages/components/src/components.ts @@ -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'; diff --git a/packages/components/src/components/hds/theme-switcher/index.hbs b/packages/components/src/components/hds/theme-switcher/index.hbs new file mode 100644 index 00000000000..b273f5936dc --- /dev/null +++ b/packages/components/src/components/hds/theme-switcher/index.hbs @@ -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 + ------------------------------------------------------------------------------------------ +}} + + + + {{#each-in this._options as |key data|}} + {{data.label}} + {{/each-in}} + \ No newline at end of file diff --git a/packages/components/src/components/hds/theme-switcher/index.ts b/packages/components/src/components/hds/theme-switcher/index.ts new file mode 100644 index 00000000000..d5da386baf6 --- /dev/null +++ b/packages/components/src/components/hds/theme-switcher/index.ts @@ -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 = { + 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 { + @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 = { ...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 }); + } +} diff --git a/packages/components/src/services.ts b/packages/components/src/services.ts index 2650e722d57..9f61ec10978 100644 --- a/packages/components/src/services.ts +++ b/packages/components/src/services.ts @@ -4,3 +4,5 @@ */ // This file is used to expose public services + +export * from './services/hds-theming.ts'; diff --git a/packages/components/src/services/hds-theming.ts b/packages/components/src/services/hds-theming.ts new file mode 100644 index 00000000000..eedc9c60f05 --- /dev/null +++ b/packages/components/src/services/hds-theming.ts @@ -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` +} + +enum HdsModesLightValues { + CdsG0 = 'cds-g0', + CdsG10 = 'cds-g10', +} + +enum HdsModesDarkValues { + CdsG90 = 'cds-g90', + CdsG100 = 'cds-g100', +} + +export enum HdsCssSelectorsValues { + Data = 'data', + Class = 'class', +} + +export type HdsThemes = `${HdsThemeValues}`; +export type HdsModes = + | `${HdsModesBaseValues}` + | `${HdsModesLightValues}` + | `${HdsModesDarkValues}` + | undefined; +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; + @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 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 ( + options !== undefined && + 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( + 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 { + 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; + } +} diff --git a/packages/components/src/template-registry.ts b/packages/components/src/template-registry.ts index 66183aa31c6..25f4575d028 100644 --- a/packages/components/src/template-registry.ts +++ b/packages/components/src/template-registry.ts @@ -232,6 +232,7 @@ import type HdsTagComponent from './components/hds/tag'; import type HdsTooltipButtonComponent from './components/hds/tooltip-button'; import type HdsToastComponent from './components/hds/toast'; import type HdsTextCodeComponent from './components/hds/text/code'; +import type HdsThemeSwitcherComponent from './components/hds/theme-switcher'; import type HdsTimeComponent from './components/hds/time'; import type HdsTimeSingleComponent from './components/hds/time/single'; import type HdsTimeRangeComponent from './components/hds/time/range'; @@ -1021,6 +1022,10 @@ export default interface HdsComponentsRegistry { 'Hds::Toast': typeof HdsToastComponent; 'hds/toast': typeof HdsToastComponent; + // ThemeSwitcher + 'Hds::ThemeSwitcher': typeof HdsThemeSwitcherComponent; + 'hds/theme-switcher': typeof HdsThemeSwitcherComponent; + // Time 'Hds::Time': typeof HdsTimeComponent; 'hds/time': typeof HdsTimeComponent; diff --git a/showcase/.prettierignore b/showcase/.prettierignore index 6b95cb7bf32..63e7e2dc3d7 100644 --- a/showcase/.prettierignore +++ b/showcase/.prettierignore @@ -12,3 +12,6 @@ ember-cli-update.json *.html *.scss + +# temporary CSS files for theming +/public/assets/styles/@hashicorp/ diff --git a/showcase/.stylelintignore b/showcase/.stylelintignore index fc178a0b910..6c722d9246d 100644 --- a/showcase/.stylelintignore +++ b/showcase/.stylelintignore @@ -3,3 +3,6 @@ # compiled output /dist/ + +# temporary CSS files for theming +/public/assets/styles/@hashicorp/ diff --git a/showcase/app/components/mock/app/index.gts b/showcase/app/components/mock/app/index.gts index e8ffe853e0b..5e8f983f4d5 100644 --- a/showcase/app/components/mock/app/index.gts +++ b/showcase/app/components/mock/app/index.gts @@ -11,10 +11,15 @@ import MockAppSidebarOldSideNav from './sidebar/side-nav'; import MockAppMainPageHeader from './main/page-header'; import MockAppMainGenericTextContent from './main/generic-text-content'; import MockAppMainGenericAdvancedTable from './main/generic-advanced-table'; +import MockAppMainFormComplex from './main/form-complex'; +import MockAppMainTableComplex from './main/table-complex'; import MockAppFooterAppFooter from './footer/app-footer'; // HDS components -import { HdsAppFrame } from '@hashicorp/design-system-components/components'; +import { + HdsAlert, + HdsAppFrame, +} from '@hashicorp/design-system-components/components'; // types import type { ComponentLike } from '@glint/template'; @@ -25,10 +30,14 @@ import type { MockAppSidebarOldSideNavSignature } from './sidebar/side-nav'; import type { MockAppMainPageHeaderSignature } from './main/page-header'; import type { MockAppMainGenericTextContentSignature } from './main/generic-text-content'; import type { MockAppMainGenericAdvancedTableSignature } from './main/generic-advanced-table'; +import type { MockAppMainFormComplexSignature } from './main/form-complex'; +import type { MockAppMainTableComplexSignature } from './main/table-complex'; +import type { MockAppMainPaginationSignature } from './main/pagination'; import type { MockAppFooterAppFooterSignature } from './footer/app-footer'; export interface MockAppSignature { Args: { + hasPageAlert?: boolean; hasHeader?: HdsAppFrameSignature['Args']['hasHeader']; hasSidebar?: HdsAppFrameSignature['Args']['hasSidebar']; hasOldSidebar?: boolean; @@ -42,9 +51,8 @@ export interface MockAppSignature { ]; sidebar?: [ { - SideNav?: - | ComponentLike - | ComponentLike; + AppSideNav?: ComponentLike; + SideNav?: ComponentLike; }, ]; main?: [ @@ -52,6 +60,9 @@ export interface MockAppSignature { PageHeader?: ComponentLike; GenericTextContent?: ComponentLike; GenericAdvancedTable?: ComponentLike; + FormComplex?: ComponentLike; + TableComplex?: ComponentLike; + Pagination?: ComponentLike; }, ]; footer?: [ @@ -82,7 +93,13 @@ export default class MockApp extends Component { {{#if (has-block "sidebar")}} - {{yield (hash SideNav=MockAppSidebarAppSideNav) to="sidebar"}} + {{yield + (hash + AppSideNav=MockAppSidebarAppSideNav + SideNav=MockAppSidebarOldSideNav + ) + to="sidebar" + }} {{else}} {{#if @hasOldSidebar}} @@ -92,12 +109,20 @@ export default class MockApp extends Component { {{/if}} + {{#if @hasPageAlert}} + + Lorem ipsum + Lorem ipsum dolor sit amet. + + {{/if}}
{{yield (hash PageHeader=MockAppMainPageHeader GenericTextContent=MockAppMainGenericTextContent GenericAdvancedTable=MockAppMainGenericAdvancedTable + FormComplex=MockAppMainFormComplex + TableComplex=MockAppMainTableComplex ) to="main" }} diff --git a/showcase/app/components/mock/app/main/form-complex.gts b/showcase/app/components/mock/app/main/form-complex.gts new file mode 100644 index 00000000000..4d966dc64c3 --- /dev/null +++ b/showcase/app/components/mock/app/main/form-complex.gts @@ -0,0 +1,354 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import Component from '@glimmer/component'; +import style from 'ember-style-modifier/modifiers/style'; + +// HDS components +import { + HdsButton, + HdsButtonSet, + HdsFormCheckboxGroup, + HdsFormFileInputField, + HdsFormMaskedInputField, + HdsFormRadioGroup, + HdsFormRadioCardGroup, + HdsFormSelectField, + HdsFormSuperSelectSingleField, + HdsFormSuperSelectMultipleField, + HdsFormTextInputField, + HdsFormTextareaField, + HdsFormToggleField, + HdsLinkInline, + HdsSeparator, + HdsTextBody, + HdsTextDisplay, +} from '@hashicorp/design-system-components/components'; + +const RADIOCARDS = [ + { + value: '1', + label: 'Radio card label 1', + badge: 'Badge', + checked: true, + description: 'Radio card description 1', + generic: 'Radio card custom content 1', + }, + { + value: '2', + label: 'Radio card label 2', + badge: 'Badge', + description: 'Radio card description 2', + generic: 'Radio card custom content 2', + }, + { + value: '3', + label: 'Radio card label 3', + badge: 'Badge', + description: 'Radio card description 3', + generic: 'Radio card custom content 3', + }, +]; + +const SUPERSELECT1_OPTIONS = [ + { + size: 'Extra Small', + description: '2 vCPU | 1 GiB RAM', + price: '$0.02', + }, + { + size: 'Small', + description: '2 vCPU | 2 GiB RAM', + price: '$0.04', + disabled: true, + }, + { + size: 'Medium', + description: '4 vCPU | 4 GiB RAM', + price: '$0.08', + disabled: true, + }, + { size: 'Large', description: '8 vCPU | 8 GiB RAM', price: '$0.16' }, + { + size: 'Extra Large', + description: '16 vCPU | 16 GiB RAM', + price: '$0.32', + }, +]; +const SELECTED_SUPERSELECT1_OPTION = SUPERSELECT1_OPTIONS[1]; + +const SUPERSELECT2_OPTIONS = ['Option 1', 'Option 2', 'Option 3']; +const SELECTED_SUPERSELECT2_OPTIONS = [ + SUPERSELECT2_OPTIONS[0], + SUPERSELECT2_OPTIONS[1], +]; + +const noop = () => {}; + +export interface MockAppMainFormComplexSignature { + Args: { + showAll?: boolean; + showErrors?: boolean; + showIntro?: boolean; + showCheckbox?: boolean; + showFileInput?: boolean; + showMaskedInput?: boolean; + showRadio?: boolean; + showRadioCard?: boolean; + showSelect?: boolean; + showSuperSelect?: boolean; + showTextarea?: boolean; + showTextInput?: boolean; + showToggle?: boolean; + showButtons?: boolean; + }; + Element: HTMLDivElement; +} + +export default class MockAppMainFormComplex extends Component { + _showIntro; + _showCheckbox; + _showFileInput; + _showMaskedInput; + _showRadio; + _showRadioCard; + _showSelect; + _showSuperSelect; + _showTextarea; + _showTextInput; + _showToggle; + _showButtons; + _showErrors; + + constructor(owner: unknown, args: MockAppMainFormComplexSignature['Args']) { + super(owner, args); + this._showIntro = this.args.showIntro ?? this.args.showAll ?? false; + this._showCheckbox = this.args.showCheckbox ?? this.args.showAll ?? false; + this._showFileInput = this.args.showFileInput ?? this.args.showAll ?? false; + this._showMaskedInput = + this.args.showMaskedInput ?? this.args.showAll ?? false; + this._showRadio = this.args.showRadio ?? this.args.showAll ?? false; + this._showRadioCard = this.args.showRadioCard ?? this.args.showAll ?? false; + this._showSelect = this.args.showSelect ?? this.args.showAll ?? false; + this._showSuperSelect = + this.args.showSuperSelect ?? this.args.showAll ?? false; + this._showTextarea = this.args.showTextarea ?? this.args.showAll ?? false; + this._showToggle = this.args.showToggle ?? this.args.showAll ?? false; + this._showErrors = this.args.showErrors ?? this.args.showAll ?? false; + // we want at least something to be visible by default + this._showTextInput = this.args.showTextInput ?? this.args.showAll ?? true; + this._showButtons = this.args.showButtons ?? this.args.showAll ?? true; + } + + +} diff --git a/showcase/app/components/mock/app/main/generic-text-content.gts b/showcase/app/components/mock/app/main/generic-text-content.gts index 94c54345959..0fd674d4f35 100644 --- a/showcase/app/components/mock/app/main/generic-text-content.gts +++ b/showcase/app/components/mock/app/main/generic-text-content.gts @@ -8,16 +8,23 @@ import type { TemplateOnlyComponent } from '@ember/component/template-only'; // HDS components import { HdsLinkInline, + HdsTextDisplay, HdsTextBody, } from '@hashicorp/design-system-components/components'; export interface MockAppMainGenericTextContentSignature { + Args: { + showHeadings?: boolean; + }; Element: HTMLDivElement; } const MockAppMainGenericTextContent: TemplateOnlyComponent = diff --git a/showcase/app/components/mock/app/sidebar/side-nav.gts b/showcase/app/components/mock/app/sidebar/side-nav.gts index c5f61f41237..c2329a4d04e 100644 --- a/showcase/app/components/mock/app/sidebar/side-nav.gts +++ b/showcase/app/components/mock/app/sidebar/side-nav.gts @@ -27,6 +27,10 @@ export interface MockAppSidebarOldSideNavSignature { showHeader?: boolean; showFooter?: boolean; }; + Blocks: { + extraBodyAfter: []; + extraFooterBefore: []; + }; Element: HdsSideNavSignature['Element']; } @@ -153,9 +157,11 @@ export default class MockAppSidebarOldSideNav extends Component + {{yield to="extraBodyAfter"}} <:footer> {{#if this.showFooter}} + {{yield to="extraFooterBefore"}} + {{pageTitle "Theming"}} + + Theming + +
+ + + + + +
+ + + + {{! For some reason, Ember tests don't play well with iframes (URL not found) so we don't snapshots them in Percy }} +
+ +
+; + +export default ThemingIndex; diff --git a/showcase/app/components/page-foundations/theming/sub-sections/components.gts b/showcase/app/components/page-foundations/theming/sub-sections/components.gts new file mode 100644 index 00000000000..0d8cf4e23f7 --- /dev/null +++ b/showcase/app/components/page-foundations/theming/sub-sections/components.gts @@ -0,0 +1,107 @@ +import type { TemplateOnlyComponent } from '@ember/component/template-only'; +import { on } from '@ember/modifier'; + +import ShwDivider from 'showcase/components/shw/divider'; +import ShwFlex from 'showcase/components/shw/flex'; +import ShwTextH2 from 'showcase/components/shw/text/h2'; +import ShwTextH4 from 'showcase/components/shw/text/h4'; + +import { + HdsAppFooter, + HdsDropdown, + HdsCodeBlock, +} from '@hashicorp/design-system-components/components'; + +const SubSectionComponents: TemplateOnlyComponent = ; + +export default SubSectionComponents; diff --git a/showcase/app/components/page-foundations/theming/sub-sections/contexts.gts b/showcase/app/components/page-foundations/theming/sub-sections/contexts.gts new file mode 100644 index 00000000000..e1fd3671a7d --- /dev/null +++ b/showcase/app/components/page-foundations/theming/sub-sections/contexts.gts @@ -0,0 +1,213 @@ +import Component from '@glimmer/component'; +import type { TemplateOnlyComponent } from '@ember/component/template-only'; +import { service } from '@ember/service'; +import style from 'ember-style-modifier'; + +import ShwTextH2 from 'showcase/components/shw/text/h2'; +import ShwTextH3 from 'showcase/components/shw/text/h3'; +import ShwTextH4 from 'showcase/components/shw/text/h4'; +import ShwTextBody from 'showcase/components/shw/text/body'; +import ShwDivider from 'showcase/components/shw/divider'; +import ShwFlex from 'showcase/components/shw/flex'; +import ShwGrid from 'showcase/components/shw/grid'; + +import ShwThemingService from 'showcase/services/shw-theming'; + +interface ThemingBasicContainerSignature { + Args: { + text?: string; + }; + Blocks: { + default: []; + }; + Element: HTMLDivElement; +} + +const ThemingBasicContainer: TemplateOnlyComponent = + ; + +export default class SubSectionContexts extends Component { + @service declare readonly shwTheming: ShwThemingService; + + get showContextualExamples() { + return ( + this.shwTheming.currentStylesheet === 'css-selectors' || + this.shwTheming.currentStylesheet === 'combined-strategies' + ); + } + + +} diff --git a/showcase/app/components/page-foundations/theming/sub-sections/demo.gts b/showcase/app/components/page-foundations/theming/sub-sections/demo.gts new file mode 100644 index 00000000000..b96f0c27545 --- /dev/null +++ b/showcase/app/components/page-foundations/theming/sub-sections/demo.gts @@ -0,0 +1,17 @@ +import type { TemplateOnlyComponent } from '@ember/component/template-only'; + +import ShwTextH2 from 'showcase/components/shw/text/h2'; +import ShwFrame from 'showcase/components/shw/frame'; + +const SubSectionDemo: TemplateOnlyComponent = ; + +export default SubSectionDemo; diff --git a/showcase/app/components/page-foundations/theming/sub-sections/theme-switcher.gts b/showcase/app/components/page-foundations/theming/sub-sections/theme-switcher.gts new file mode 100644 index 00000000000..ac0cf959ba8 --- /dev/null +++ b/showcase/app/components/page-foundations/theming/sub-sections/theme-switcher.gts @@ -0,0 +1,47 @@ +import type { TemplateOnlyComponent } from '@ember/component/template-only'; +import style from 'ember-style-modifier'; + +import ShwFlex from 'showcase/components/shw/flex'; +import ShwTextH2 from 'showcase/components/shw/text/h2'; +import ShwTextH4 from 'showcase/components/shw/text/h4'; +import ShwDivider from 'showcase/components/shw/divider'; + +import { HdsThemeSwitcher } from '@hashicorp/design-system-components/components'; + +const SubSectionThemeSwitcher: TemplateOnlyComponent = ; + +export default SubSectionThemeSwitcher; diff --git a/showcase/app/components/shw/theme-switcher/control/select.gts b/showcase/app/components/shw/theme-switcher/control/select.gts new file mode 100644 index 00000000000..f1e8fa56d0e --- /dev/null +++ b/showcase/app/components/shw/theme-switcher/control/select.gts @@ -0,0 +1,67 @@ +import Component from '@glimmer/component'; +import { guidFor } from '@ember/object/internals'; +import { on } from '@ember/modifier'; +import { eq } from 'ember-truth-helpers'; + +interface ShwThemeSwitcherControlSelectSignature { + Args: { + label: string; + values?: string[] | Record; + selectedValue?: string; + onChange?: (event: Event) => void; + }; + Blocks: { + default: []; + }; +} + +export default class ShwThemeSwitcherControlSelect extends Component { + selectId = `shw-theme-switcher-select-${guidFor(this)}`; + + get options() { + if (Array.isArray(this.args.values)) { + // Convert array to an object where keys and values are the same + return this.args.values.reduce( + (acc, value) => { + acc[value] = value; + return acc; + }, + {} as Record, + ); + } else { + // If values is already an object, return it directly + return this.args.values; + } + } + + onChange = (event: Event) => { + if (this.args.onChange) { + this.args.onChange(event); + } + }; + + +} diff --git a/showcase/app/components/shw/theme-switcher/control/toggle.gts b/showcase/app/components/shw/theme-switcher/control/toggle.gts new file mode 100644 index 00000000000..51ae24fd1a6 --- /dev/null +++ b/showcase/app/components/shw/theme-switcher/control/toggle.gts @@ -0,0 +1,37 @@ +import Component from '@glimmer/component'; +import { guidFor } from '@ember/object/internals'; +import { on } from '@ember/modifier'; + +interface ShwThemeSwitcherControlToggleSignature { + Args: { + label: string; + checked?: boolean; + onToggle?: (event: Event) => void; + }; +} + +export default class ShwThemeSwitcherControlToggle extends Component { + inputId = `shw-theme-switcher-input-${guidFor(this)}`; + + onToggle = (event: Event) => { + if (this.args.onToggle) { + this.args.onToggle(event); + } + }; + + +} diff --git a/showcase/app/components/shw/theme-switcher/debugging-panel.gts b/showcase/app/components/shw/theme-switcher/debugging-panel.gts new file mode 100644 index 00000000000..81988606bac --- /dev/null +++ b/showcase/app/components/shw/theme-switcher/debugging-panel.gts @@ -0,0 +1,52 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import Component from '@glimmer/component'; +import { service } from '@ember/service'; + +import ShwLabel from '../../shw/label'; + +import ShwThemingService from 'showcase/services/shw-theming'; +import HdsThemingService from '@hashicorp/design-system-components/services/hds-theming'; + +export default class ShwThemeSwitcherDebuggingPanel extends Component { + @service declare readonly hdsTheming: HdsThemingService; + @service declare readonly shwTheming: ShwThemingService; + + +} diff --git a/showcase/app/components/shw/theme-switcher/index.gts b/showcase/app/components/shw/theme-switcher/index.gts new file mode 100644 index 00000000000..371cf43ea38 --- /dev/null +++ b/showcase/app/components/shw/theme-switcher/index.gts @@ -0,0 +1,91 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import Component from '@glimmer/component'; +import { service } from '@ember/service'; +import { tracked } from '@glimmer/tracking'; +import { guidFor } from '@ember/object/internals'; +import type Owner from '@ember/owner'; + +import ShwThemeSwitcherPopover from './popover'; +import ShwThemeSwitcherSelector from './selector'; +import ShwThemeSwitcherDebuggingPanel from './debugging-panel'; + +import HdsThemingService from '@hashicorp/design-system-components/services/hds-theming'; + +import { HdsIcon } from '@hashicorp/design-system-components/components'; + +export type ControlsPreferences = { + hasFixedControls: boolean; + hasDebuggingPanel: boolean; +}; + +export type OnApply = (options: ControlsPreferences) => void; + +const LOCALSTORAGE_FIXED_CONTROLS = 'shw-theming-has-fixed-controls'; +const LOCALSTORAGE_DEBUGGING_PANEL = 'shw-theming-has-debugging-panel'; + +export default class ShwThemeSwitcher extends Component { + @service declare readonly hdsTheming: HdsThemingService; + + @tracked hasFixedControls: boolean; + @tracked hasDebuggingPanel: boolean; + + popoverId = `shw-theming-options-popover-${guidFor(this)}`; + + // eslint-disable-next-line @typescript-eslint/no-empty-object-type + constructor(owner: Owner, args: {}) { + super(owner, args); + + const storedHasFixedControls = localStorage.getItem( + LOCALSTORAGE_FIXED_CONTROLS, + ); + this.hasFixedControls = storedHasFixedControls === 'true'; + + const storedHasDebuggingPanel = localStorage.getItem( + LOCALSTORAGE_DEBUGGING_PANEL, + ); + this.hasDebuggingPanel = storedHasDebuggingPanel === 'true'; + } + + onApply = ({ hasFixedControls, hasDebuggingPanel }: ControlsPreferences) => { + this.hasFixedControls = hasFixedControls; + this.hasDebuggingPanel = hasDebuggingPanel; + + localStorage.setItem( + LOCALSTORAGE_FIXED_CONTROLS, + String(this.hasFixedControls), + ); + localStorage.setItem( + LOCALSTORAGE_DEBUGGING_PANEL, + String(this.hasDebuggingPanel), + ); + }; + + +} diff --git a/showcase/app/components/shw/theme-switcher/popover.gts b/showcase/app/components/shw/theme-switcher/popover.gts new file mode 100644 index 00000000000..bdf93a2884d --- /dev/null +++ b/showcase/app/components/shw/theme-switcher/popover.gts @@ -0,0 +1,182 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { on } from '@ember/modifier'; +import { hash, fn } from '@ember/helper'; +import { service } from '@ember/service'; +import type Owner from '@ember/owner'; + +import ShwThemeSwitcherControlSelect from './control/select'; +import ShwThemeSwitcherControlToggle from './control/toggle'; + +import HdsThemingService from '@hashicorp/design-system-components/services/hds-theming'; +import { + MODES_LIGHT, + MODES_DARK, +} from '@hashicorp/design-system-components/services/hds-theming'; +import type { + HdsModesLight, + HdsModesDark, + HdsCssSelectors, +} from '@hashicorp/design-system-components/services/hds-theming'; + +import type { OnApply } from './index'; + +interface ShwThemeSwitcherPopoverSignature { + Args: { + popoverId: string; + hasFixedControls: boolean; + hasDebuggingPanel: boolean; + onApply: OnApply; + }; + Element: HTMLDivElement; +} + +export default class ShwThemeSwitcherPopover extends Component { + @service declare readonly hdsTheming: HdsThemingService; + + @tracked _selectedLightTheme; + @tracked _selectedDarkTheme; + @tracked _selectedCssSelector; + @tracked _hasFixedControls: boolean; + @tracked _hasDebuggingPanel: boolean; + + constructor(owner: Owner, args: ShwThemeSwitcherPopoverSignature['Args']) { + super(owner, args); + this._selectedLightTheme = this.hdsTheming.currentLightTheme; + this._selectedDarkTheme = this.hdsTheming.currentDarkTheme; + this._selectedCssSelector = this.hdsTheming.currentCssSelector; + this._hasFixedControls = this.args.hasFixedControls; + this._hasDebuggingPanel = this.args.hasDebuggingPanel; + } + + onChangeAdvancedOption = (optionName: string, event: Event) => { + const select = event.target as HTMLSelectElement; + switch (optionName) { + case 'light-theme': + this._selectedLightTheme = select.value as HdsModesLight; + break; + case 'dark-theme': + this._selectedDarkTheme = select.value as HdsModesDark; + break; + case 'css-selector': + this._selectedCssSelector = select.value as HdsCssSelectors; + break; + } + }; + + onTogglePreference = (preferenceName: string, event: Event) => { + const input = event.target as HTMLInputElement; + switch (preferenceName) { + case 'fixed-controls': + this._hasFixedControls = input.checked; + break; + case 'debugging-panel': + this._hasDebuggingPanel = input.checked; + break; + } + }; + + onApplyThemingPreferences = () => { + this.hdsTheming.setTheme({ + // we reuse the current theme (we're not changing it here) + theme: this.hdsTheming.currentTheme, + // we update the options + options: { + lightTheme: this._selectedLightTheme, + darkTheme: this._selectedDarkTheme, + cssSelector: this._selectedCssSelector, + }, + }); + + if (typeof this.args.onApply === 'function') { + this.args.onApply({ + hasFixedControls: this._hasFixedControls, + hasDebuggingPanel: this._hasDebuggingPanel, + }); + } + + // programmatically close the popover + const popoverElement = document.getElementById(this.args.popoverId); + if (popoverElement && 'hidePopover' in popoverElement) { + popoverElement.hidePopover(); + } + }; + + +} diff --git a/showcase/app/components/shw/theme-switcher/selector.gts b/showcase/app/components/shw/theme-switcher/selector.gts new file mode 100644 index 00000000000..28d4134ebf4 --- /dev/null +++ b/showcase/app/components/shw/theme-switcher/selector.gts @@ -0,0 +1,98 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import Component from '@glimmer/component'; +import { eq } from 'ember-truth-helpers'; +import { service } from '@ember/service'; + +import ShwThemeSwitcherControlSelect from './control/select'; +import ShwThemingService from 'showcase/services/shw-theming'; +import type { ShwStylesheets } from 'showcase/services/shw-theming'; + +import type { HdsThemes } from '@hashicorp/design-system-components/services/hds-theming'; + +import HdsThemingService from '@hashicorp/design-system-components/services/hds-theming'; + +export default class ShwThemeSwitcherSelector extends Component { + @service declare readonly hdsTheming: HdsThemingService; + @service declare readonly shwTheming: ShwThemingService; + + get gLight() { + return this.hdsTheming.currentLightTheme.replace('cds-', ''); + } + + get gDark() { + return this.hdsTheming.currentDarkTheme.replace('cds-', ''); + } + + get selectedOption() { + return `${this.shwTheming.currentStylesheet ?? 'no-theming'}|${this.hdsTheming.currentTheme ?? ''}`; + } + + get themingOptions(): Record> { + return { + 'No theming': { + 'no-theming|': 'HDS / Standard', + }, + 'Theming via prefers-color-scheme': { + 'prefers-color-scheme|system': 'Carbon / System', + }, + 'Theming via CSS selectors': { + 'css-selectors|': 'HDS / Default', + 'css-selectors|light': `Carbon / Light (${this.gLight})`, + 'css-selectors|dark': `Carbon / Dark (${this.gDark})`, + }, + 'Theming via combined strategies': { + 'combined-strategies|': 'HDS / Default', + 'combined-strategies|system': 'Carbon / System', + 'combined-strategies|light': `Carbon / Light (${this.gLight})`, + 'combined-strategies|dark': `Carbon / Dark (${this.gDark})`, + }, + }; + } + + onSelectPageTheme = (event: Event) => { + const select = event.target as HTMLSelectElement; + const selectValue = select.value; + + const [selectedStylesheet, selectedTheme] = selectValue.split('|') as [ + ShwStylesheets, + HdsThemes | '', + ]; + + // we set the `currentStylesheet` in the `shwTheming` service + this.shwTheming.setStylesheet(selectedStylesheet); + // we set the `currentTheme` in the `hdsTheming` service + this.hdsTheming.setTheme({ + theme: selectedTheme === '' ? undefined : selectedTheme, + // example of how a consumer could use the `onSetTheme` callback by passing it to the `setTheme` function as extra option + // onSetTheme: ({ currentTheme, currentMode }) => { + // console.log( + // '➡️ LOCAL INVOCATION via setShwHdsThemes callback', + // currentTheme, + // currentMode, + // ); + // }, + }); + }; + + +} diff --git a/showcase/app/controllers/application.ts b/showcase/app/controllers/application.ts index 79d6ff8ac42..b1dbad2a4b7 100644 --- a/showcase/app/controllers/application.ts +++ b/showcase/app/controllers/application.ts @@ -10,14 +10,18 @@ import { tracked } from '@glimmer/tracking'; import type RouterService from '@ember/routing/router-service'; import type Owner from '@ember/owner'; +import HdsThemingService from '@hashicorp/design-system-components/services/hds-theming'; + export default class ApplicationController extends Controller { @service declare readonly router: RouterService; + @service declare readonly hdsTheming: HdsThemingService; @tracked isFrameless = false; constructor(owner: Owner) { super(owner); this.router.on('routeDidChange', this.routeDidChange.bind(this)); + this.hdsTheming.initializeTheme(); } routeDidChange() { diff --git a/showcase/app/index.html b/showcase/app/index.html index 116b91e14f9..e6cba2057a5 100644 --- a/showcase/app/index.html +++ b/showcase/app/index.html @@ -14,6 +14,14 @@ {{content-for "head"}} + + + + + + @@ -26,7 +34,7 @@ - + {{content-for "body-footer"}} diff --git a/showcase/app/router.ts b/showcase/app/router.ts index cbc23642057..9fd96b42b8d 100644 --- a/showcase/app/router.ts +++ b/showcase/app/router.ts @@ -23,6 +23,11 @@ Router.map(function () { this.route('demo-viewport-breakpoints-page-padding'); }); }); + this.route('theming', function () { + this.route('frameless', function () { + this.route('demo-application-with-theme-switcher'); + }); + }); }); this.route('page-components', { path: 'components' }, function () { this.route('accordion'); diff --git a/showcase/app/services/shw-theming.ts b/showcase/app/services/shw-theming.ts new file mode 100644 index 00000000000..1fc39cc68aa --- /dev/null +++ b/showcase/app/services/shw-theming.ts @@ -0,0 +1,102 @@ +import type Owner from '@ember/owner'; +import { tracked } from '@glimmer/tracking'; +import { inject as service } from '@ember/service'; + +import HdsThemingService from '@hashicorp/design-system-components/services/hds-theming'; + +// import type { +// OnSetThemeCallback, +// OnSetThemeCallbackArgs, +// } from '@hashicorp/design-system-components/services/hds-theming'; + +import config from 'showcase/config/environment'; + +export type ShwStylesheets = + | 'standard' + | 'prefers-color-scheme' + | 'css-selectors' + | 'combined-strategies'; + +const updatePageStylesheet = (currentStylesheet: string) => { + let newStylesheet; + switch (currentStylesheet) { + case 'prefers-color-scheme': + // themed CSS where theming is applied via `@media(prefers-color-scheme)` + newStylesheet = + 'assets/styles/@hashicorp/design-system-components-theming-with-prefers-color-scheme.css'; + break; + case 'css-selectors': + // themed CSS where theming is applied via CSS selectors + newStylesheet = + 'assets/styles/@hashicorp/design-system-components-theming-with-css-selectors.css'; + break; + case 'combined-strategies': + // this is used for local testing purposes + newStylesheet = + 'assets/styles/@hashicorp/design-system-components-theming-with-combined-strategies.css'; + break; + default: + // this is the standard CSS for HDS components, without any theming + newStylesheet = 'assets/styles/@hashicorp/design-system-components.css'; + break; + } + + // re-assign the stylesheet `href` attribute + const hdsComponentsStylesheet = document.getElementById( + 'hds-components-stylesheet', + ); + if (hdsComponentsStylesheet) { + hdsComponentsStylesheet.setAttribute( + 'href', + `${config.rootURL}${newStylesheet}`, + ); + } +}; + +const LOCALSTORAGE_CURRENT_STYLESHEET = 'shw-theming-current-stylesheet'; +export default class ShwThemingService extends HdsThemingService { + @service declare readonly hdsTheming: HdsThemingService; + + @tracked _currentStylesheet: ShwStylesheets = 'standard'; + + constructor(owner: Owner) { + super(owner); + + const storedStylesheet = localStorage.getItem( + LOCALSTORAGE_CURRENT_STYLESHEET, + ) as ShwStylesheets; + if (storedStylesheet) { + this.setStylesheet(storedStylesheet); + } + } + + // example of how a consumer could use the `globalOnSetTheme` callback by extending the `hdsTheming` service + // + // globalOnSetTheme: OnSetThemeCallback = ({ + // currentTheme, + // currentMode, + // }: OnSetThemeCallbackArgs) => { + // console.log( + // 'ShwTheming Service -- globalOnSetTheme invoked', + // currentTheme, + // currentMode, + // ); + // }; + + setStylesheet(stylesheet: ShwStylesheets) { + if (stylesheet !== this._currentStylesheet) { + this._currentStylesheet = stylesheet; + updatePageStylesheet(this._currentStylesheet); + } + + // store the current stylesheet in local storage + localStorage.setItem( + LOCALSTORAGE_CURRENT_STYLESHEET, + this._currentStylesheet, + ); + } + + get currentStylesheet(): ShwStylesheets { + return this._currentStylesheet; + } +} diff --git a/showcase/app/styles/_globals.scss b/showcase/app/styles/_globals.scss index 1447a120e7e..76695a082fc 100644 --- a/showcase/app/styles/_globals.scss +++ b/showcase/app/styles/_globals.scss @@ -19,6 +19,7 @@ body { min-height: 100vh; margin: 0; padding: 0; + color: var(--shw-color-black); background: var(--shw-color-white); } @@ -32,7 +33,7 @@ body { height: 68px; padding: 0 24px; color: var(--shw-color-black); - border-bottom: 1px solid #eaeaea; + border-bottom: 1px solid var(--shw-color-gray-500); } .shw-page-header__logo { @@ -68,6 +69,10 @@ body { line-height: 1; } +.shw-page-header__theme-toggle { + margin-left: auto; +} + .shw-page-aside { padding: 1rem; diff --git a/showcase/app/styles/_tokens.scss b/showcase/app/styles/_tokens.scss deleted file mode 100644 index fa11ede62f2..00000000000 --- a/showcase/app/styles/_tokens.scss +++ /dev/null @@ -1,41 +0,0 @@ -/** - * Copyright (c) HashiCorp, Inc. - * SPDX-License-Identifier: MPL-2.0 - */ - -// TOKENS (CSS PROPS) - -:root { - // COLORS - --shw-color-white: #fff; - --shw-color-gray-600: #f2f2f3; - --shw-color-gray-500: #dbdbdc; - --shw-color-gray-400: #bfbfc0; - --shw-color-gray-300: #727374; - --shw-color-gray-200: #343536; - --shw-color-gray-100: #1d1e1f; - --shw-color-black: #000; - --shw-color-link-on-black: #4294ff; - --shw-color-link-on-white: #2264d6; - --shw-color-feedback-information-100: #0d44cc; - --shw-color-feedback-information-200: #1563ff; - --shw-color-feedback-information-300: #d0e0ff; - --shw-color-feedback-information-400: #eff5ff; - --shw-color-feedback-success-100: #007854; - --shw-color-feedback-success-200: #00bc7f; - --shw-color-feedback-success-300: #c1f1e0; - --shw-color-feedback-success-400: #ebfdf7; - --shw-color-feedback-warning-100: #975b06; - --shw-color-feedback-warning-200: #eaaa32; - --shw-color-feedback-warning-300: #f9eacd; - --shw-color-feedback-warning-400: #fcf6ea; - --shw-color-feedback-critical-100: #ba2226; - --shw-color-feedback-critical-200: #f25054; - --shw-color-feedback-critical-300: #ffd4d6; - --shw-color-feedback-critical-400: #fcf0f2; - --shw-color-action-active-foreground: #00f; // HTML "blue" - --shw-color-action-active-border: #00f; // HTML "blue" - --shw-color-action-active-background: #f0f8ff; // HTML "aliceblue" - // "FLEX/GRID" COMPONENTS - --shw-layout-gap-base: 1rem; -} diff --git a/showcase/app/styles/_typography.scss b/showcase/app/styles/_typography.scss index d94b4c80230..ab1d9d2d4f8 100644 --- a/showcase/app/styles/_typography.scss +++ b/showcase/app/styles/_typography.scss @@ -86,6 +86,7 @@ $show-font-family-mono: ui-monospace, menlo, consolas, monospace; @mixin shw-font-style-h1() { @include shw-font-family("gilmer"); + color: var(--shw-color-black); font-weight: 700; font-size: 3rem; line-height: 1.3; @@ -95,6 +96,7 @@ $show-font-family-mono: ui-monospace, menlo, consolas, monospace; @mixin shw-font-style-h2() { @include shw-font-family("gilmer"); + color: var(--shw-color-black); font-weight: 400; font-size: 1.8rem; line-height: 1.3; @@ -104,6 +106,7 @@ $show-font-family-mono: ui-monospace, menlo, consolas, monospace; @mixin shw-font-style-h3() { @include shw-font-family("gilmer"); + color: var(--shw-color-black); font-weight: 400; font-size: 1.4rem; line-height: 1.3; @@ -113,7 +116,7 @@ $show-font-family-mono: ui-monospace, menlo, consolas, monospace; @mixin shw-font-style-h4() { @include shw-font-family("gilmer"); - color: #666; // equivalent to `opacity: 0.5` + color: var(--shw-color-gray-300); font-weight: 500; font-size: 1.2rem; line-height: 1.3; @@ -123,6 +126,7 @@ $show-font-family-mono: ui-monospace, menlo, consolas, monospace; @mixin shw-font-style-body { @include shw-font-family("gilmer"); + color: var(--shw-color-black); font-size: 1rem; line-height: 1.4; } @@ -170,6 +174,14 @@ $show-font-family-mono: ui-monospace, menlo, consolas, monospace; } } +// we need to increase the contrast for accessibility, for the `h4` style (lighter than the others) +.shw-text-h4 { + code { + color: var(--shw-color-gray-200); + } +} + + .shw-text-body code { font-size: 0.85em; } // more visually balanced // ALIGNMENT diff --git a/showcase/app/styles/app.scss b/showcase/app/styles/app.scss index 4df2b448959..43c3171db12 100644 --- a/showcase/app/styles/app.scss +++ b/showcase/app/styles/app.scss @@ -3,12 +3,12 @@ * SPDX-License-Identifier: MPL-2.0 */ -@use "@hashicorp/design-system-components"; -@use "@hashicorp/design-system-power-select-overrides"; +// @use "@hashicorp/design-system-components"; +// @use "@hashicorp/design-system-power-select-overrides"; // global declarations -@use "./tokens"; +@use "./showcase-theming" as theming; @use "./layout"; @use "./typography"; @use "./globals"; @@ -23,6 +23,7 @@ @use "./showcase-components/label"; @use "./showcase-components/outliner"; @use "./showcase-components/placeholder"; +@use "./showcase-components/theme-switcher"; @use "./mock-components/app"; @use "./mock-components/demo/breakpoints"; @@ -81,6 +82,7 @@ @use "./showcase-pages/tabs" as showcase-tabs; @use "./showcase-pages/tag" as showcase-tag; @use "./showcase-pages/text" as showcase-text; +@use "./showcase-pages/theming" as showcase-theming; @use "./showcase-pages/tooltip" as showcase-tooltip; @use "./showcase-pages/typography" as showcase-typography; // END COMPONENT PAGES IMPORTS diff --git a/showcase/app/styles/showcase-components/divider.scss b/showcase/app/styles/showcase-components/divider.scss index 51f63676a67..19718e76ab3 100644 --- a/showcase/app/styles/showcase-components/divider.scss +++ b/showcase/app/styles/showcase-components/divider.scss @@ -6,9 +6,9 @@ .shw-divider { margin: 3rem 0; border: none; - border-top: 2px solid #ccc; + border-top: 2px solid var(--shw-color-gray-500); } .shw-divider--level-2 { - border-top: 2px dotted #ddd; + border-top-style: dotted; } diff --git a/showcase/app/styles/showcase-components/flex.scss b/showcase/app/styles/showcase-components/flex.scss index d5779511a0a..32808edb42f 100644 --- a/showcase/app/styles/showcase-components/flex.scss +++ b/showcase/app/styles/showcase-components/flex.scss @@ -8,13 +8,13 @@ .shw-flex { & + &, & + .shw-grid { - margin-top: var(--shw-layout-gap-base); + margin-top: 1rem; } } .shw-flex__items { display: flex; - gap: var(--shw-layout-gap-base); + gap: 1rem; .shw-flex--direction-row > & { flex-direction: row; diff --git a/showcase/app/styles/showcase-components/frame.scss b/showcase/app/styles/showcase-components/frame.scss index cf57890321f..c99a40ee31a 100644 --- a/showcase/app/styles/showcase-components/frame.scss +++ b/showcase/app/styles/showcase-components/frame.scss @@ -15,7 +15,7 @@ $shw-frame-navigation-bar-height: 48px; max-width: 100%; height: calc(var(--iframe-height) + #{$shw-frame-navigation-bar-height}); max-height: 100%; - outline: 1px solid #e4e4d4; + outline: 1px solid var(--shw-color-gray-500); } .shw-frame__browser-navigation { @@ -25,12 +25,12 @@ $shw-frame-navigation-bar-height: 48px; height: $shw-frame-navigation-bar-height; // safe area for the dots padding: 8px 24px 8px 120px; - background-color: #fafafa; + background-color: var(--shw-frame-browser-navigation-background); background-image: url('data:image/svg+xml,'); background-repeat: no-repeat; background-position: 24px 50%; background-size: 56px 14px; - border-bottom: 1px solid #e4e4d4; + border-bottom: 1px solid var(--shw-color-gray-500); } .shw-frame__open-link { diff --git a/showcase/app/styles/showcase-components/grid.scss b/showcase/app/styles/showcase-components/grid.scss index 313db6e024b..9d2b9d92d23 100644 --- a/showcase/app/styles/showcase-components/grid.scss +++ b/showcase/app/styles/showcase-components/grid.scss @@ -8,7 +8,7 @@ .shw-grid { & + &, & + .shw-flex { - margin-top: var(--shw-layout-gap-base); + margin-top: 1rem; } } @@ -17,7 +17,7 @@ flex-wrap: wrap; // this will be set via JS grid-template-columns: repeat(var(--shw-grid-columns), 1fr); - gap: var(--shw-layout-gap-base); + gap: 1rem; } .shw-grid__item--grow { diff --git a/showcase/app/styles/showcase-components/label.scss b/showcase/app/styles/showcase-components/label.scss index 2340e936091..bdee3f9b876 100644 --- a/showcase/app/styles/showcase-components/label.scss +++ b/showcase/app/styles/showcase-components/label.scss @@ -10,7 +10,7 @@ .shw-label { @include shw-font-family("rubik"); margin: 0 0 10px 0; - color: #545454; + color: var(--shw-label-text-color); font-size: 0.8rem; line-height: 1.2; } diff --git a/showcase/app/styles/showcase-components/placeholder.scss b/showcase/app/styles/showcase-components/placeholder.scss index df37357fc9b..664ff2e37d4 100644 --- a/showcase/app/styles/showcase-components/placeholder.scss +++ b/showcase/app/styles/showcase-components/placeholder.scss @@ -11,18 +11,18 @@ display: flex; align-items: center; justify-content: center; - color: #6b6b6b; // if background is #EEE then this has the appropriate color contrast (4.59:1) + color: var(--shw-placeholder-text-color); font-weight: bold; font-size: 10px; font-family: monaco, Consolas, "Lucida Console", monospace; line-height: 1.2; text-align: center; - text-shadow: 0 0 5px #fff; - background-color: #eee; + text-shadow: 0 0 5px var(--shw-color-white); + background-color: var(--shw-placeholder-background-color); a, a > & { - color: #333; + color: var(--shw-placeholder-link-color); text-decoration: underline; } } diff --git a/showcase/app/styles/showcase-components/theme-switcher.scss b/showcase/app/styles/showcase-components/theme-switcher.scss new file mode 100644 index 00000000000..cbdaa8e308e --- /dev/null +++ b/showcase/app/styles/showcase-components/theme-switcher.scss @@ -0,0 +1,224 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +@use "../typography" as *; + +.shw-theme-switcher { + position: relative; + right: -12px; + z-index: 999; + display: flex; + gap: 8px; + align-items: center; + padding: 12px; + color: var(--shw-color-black); + isolation: isolate; + + &::after { + position: absolute; + z-index: -1; + inset: 0; + background: var(--shw-color-white); + opacity: 0.6; + content: ""; + pointer-events: none; + } + + &.shw-theme-switcher--is-fixed, + &:has(.shw-theme-switcher-popover:popover-open) { + position: fixed; + top: 9px; + right: 12px; + } +} + + +// POPOVER + +.shw-theme-switcher-popover { + top: 50px; + right: 24px; + left: auto; + width: 350px; + max-width: calc(100vw - 48px); + margin: initial; + margin-left: 24px; + padding: 16px; + background-color: var(--shw-color-white); + border: 1px solid var(--shw-color-gray-400); + border-radius: 4px; + box-shadow: 2px 4px 8px rgba(0, 0, 0, 15%); + + &::backdrop { + background-color: var(--shw-color-white); + opacity: 0.8; + } +} + +.shw-theme-switcher-popover__title { + @include shw-font-family("gilmer"); + + margin: 0 0 8px; + color: var(--shw-color-gray-300); + font-weight: 500; + font-size: 1.2rem; + line-height: 1.3; +} + +.shw-theme-switcher-popover__description { + @include shw-font-family("gilmer"); + + margin: 4px 0 12px; + color: var(--shw-color-black); + font-weight: 400; + font-size: 0.9rem; + line-height: 1.3; +} + +.shw-theme-switcher-popover__separator { + margin: 16px 0; + border: none; + border-top: 1px solid var(--shw-color-gray-500); +} + +.shw-theme-switcher-popover__options-list { + display: flex; + flex-direction: column; + gap: 12px; + margin-top: 16px; +} + +.shw-theme-switcher-popover__actions { + display: flex; + flex-direction: row; + gap: 12px; +} + +.shw-theme-switcher-popover__button { + @include shw-font-family('gilmer'); + + min-width: 72px; + height: 32px; + padding: 2px 12px 2px 12px; + font-size: 0.9rem; + border-style: solid; + border-width: 1px; + border-radius: 3px; + cursor: pointer; + appearance: none; +} + +.shw-theme-switcher-popover__button--primary { + color: var(--shw-color-action-active-foreground); + background-color: var(--shw-color-action-active-background); + border-color: var(--shw-color-action-active-border); +} + +.shw-theme-switcher-popover__button--secondary { + color: var(--shw-color-gray-100); + background-color: transparent; + border-color: var(--shw-color-gray-300); +} + + +// CONTROLS + +.shw-theme-switcher-popover__control-wrapper { + display: flex; + flex-direction: row; + gap: 8px; + align-items: center; +} + +.shw-theme-switcher__control-label { + @include shw-font-family('gilmer'); + + margin: 0; + color: var(--shw-color-black); + font-size: 0.9rem; + line-height: 20px; +} + +.shw-theme-switcher__control-select { + @include shw-font-family('gilmer'); + + height: 24px; + padding: 2px 24px 2px 8px; + color: var(--shw-color-gray-100); + font-size: 0.8rem; + background-color: var(--shw-color-gray-600); + background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M3.34572 7H20.6543C21.8517 7 22.4504 8.4463 21.6028 9.29391L12.9519 17.9515C12.4272 18.4763 11.5728 18.4763 11.0481 17.9515L2.39722 9.29391C1.54961 8.4463 2.14832 7 3.34572 7Z' fill='%23808080'/%3E%3C/svg%3E"); // notice: the 'caret' color is hardcoded here! + background-repeat: no-repeat; + background-position: right 6px top 4px; + background-size: 12px 12px; + border: 1px solid var(--shw-color-gray-400); + border-radius: 3px; + appearance: none; + + &:hover { + background-color: var(--shw-color-gray-500); + } + + &:where(:focus-visible) { + outline: 2px dashed var(--shw-color-action-active-foreground); + } +} + + +// OPTIONS BUTTON + +.shw-theme-switcher__options-button { + height: 24px; + padding: 2px 8px; + color: var(--shw-color-gray-100); + background-color: var(--shw-color-gray-600); + border: 1px solid var(--shw-color-gray-400); + border-radius: 3px; + cursor: pointer; + appearance: none; + + &:hover { + background-color: var(--shw-color-gray-500); + } + + &:where(:focus-visible) { + outline: 2px dashed var(--shw-color-action-active-foreground); + } +} + +// DEBUGGING PANEL + +.shw-theme-switcher__debugging-panel { + position: fixed; + right: 12px; + bottom: 12px; + display: grid; + grid-template-columns: auto auto; + gap: 6px 4px; + width: max-content; + padding: 9px 12px; + color: var(--shw-color-gray-100); + background-color: var(--shw-color-white); + border: 1px solid var(--shw-color-gray-400); + border-radius: 3px; + opacity: 0.5; + + &:hover { + opacity: 1; + } + + .shw-theme-switcher__debugging-panel-key { + margin: 0; + color: var(--shw-color-gray-200); + font-style: italic; + text-align: right; + } + + .shw-theme-switcher__debugging-panel-value { + margin: 0; + color: var(--shw-color-gray-100); + font-weight: 500; + } +} diff --git a/showcase/app/styles/showcase-pages/theming.scss b/showcase/app/styles/showcase-pages/theming.scss new file mode 100644 index 00000000000..03b2b933efd --- /dev/null +++ b/showcase/app/styles/showcase-pages/theming.scss @@ -0,0 +1,43 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +// THEMING + +body.page-foundations-theming { + .shw-foundation-theming-light-background { + padding: 8px; + background: var(--shw-color-white); + } + + .shw-foundation-theming-dark-background { + padding: 8px; + background: var(--shw-color-black); + } + + .shw-foundation-theming-basic-container { + display: flex; + align-items: center; + justify-content: center; + width: fit-content; + min-width: 120px; + min-height: 120px; + padding: 50px; + color: var(--token-color-foreground-strong); + font-weight: bold; + font-size: 10px; + font-family: monaco, Consolas, "Lucida Console", monospace; + line-height: 1.2; + background-color: var(--token-color-surface-strong); + } + + .shw-page-foundations-theming-banner-incorrect-stylesheet { + padding: 0 24px; + color: var(--shw-color-gray-100); + background-color: var(--shw-color-gray-600); + border: 1px solid var(--shw-color-gray-400); + border-left-width: 3px; + } + +} diff --git a/showcase/app/styles/showcase-pages/typography.scss b/showcase/app/styles/showcase-pages/typography.scss index aa44b080a01..6ec00b2e738 100644 --- a/showcase/app/styles/showcase-pages/typography.scss +++ b/showcase/app/styles/showcase-pages/typography.scss @@ -7,11 +7,12 @@ body.page-foundations-typography { .shw-label { - color: #999; + color: var(--shw-color-gray-300); } p[class^="hds-"] { margin: 0; padding: 0; + color: var(--shw-color-black); } } diff --git a/showcase/app/styles/showcase-theming/dark-color-variables.scss b/showcase/app/styles/showcase-theming/dark-color-variables.scss new file mode 100644 index 00000000000..9361c0ff922 --- /dev/null +++ b/showcase/app/styles/showcase-theming/dark-color-variables.scss @@ -0,0 +1,22 @@ +// SHOWCASE COLORS > DARK THEME + +@mixin shw-theme-color-variables-dark() { + // SEMANTIC PALETTE + --shw-color-white: #1a1a1a; + --shw-color-gray-600: #222225; + --shw-color-gray-500: #353537; + --shw-color-gray-400: #4c4c4d; + --shw-color-gray-300: #89898a; + --shw-color-gray-200: #babbbc; + --shw-color-gray-100: #cccdcf; + --shw-color-black: #e5e5e5; + --shw-color-action-active-foreground: #3b81d2; + --shw-color-action-active-border: #3b81d2; + --shw-color-action-active-background: #062139; + // COMPONENTS + --shw-frame-browser-navigation-background: #050505; + --shw-label-text-color: #c4c4c4; + --shw-placeholder-text-color: #949494; + --shw-placeholder-background-color: #121212; + --shw-placeholder-link-color: #ccc; +} diff --git a/showcase/app/styles/showcase-theming/index.scss b/showcase/app/styles/showcase-theming/index.scss new file mode 100644 index 00000000000..460b20968ce --- /dev/null +++ b/showcase/app/styles/showcase-theming/index.scss @@ -0,0 +1,49 @@ +// SHOWCASE COLORS > LIGHT/DARK THEMES + +@use "./light-color-variables" as *; +@use "./dark-color-variables" as *; + +// DEFAULT + +:root { + @include shw-theme-color-variables-light(); +} + +// PREFERS COLOR SCHEME + +// this works because the specificity of `:root` is 010, the same as a class-based and an attribute-based selector; when specificity is equal, the later rules override earlier ones due to cascade order + +@media (prefers-color-scheme: light) { + :root { + @include shw-theme-color-variables-light(); + } +} + +@media (prefers-color-scheme: dark) { + :root { + @include shw-theme-color-variables-dark(); + } +} + +// CSS SELECTORS + +// after testing different options, we have decided to rely on `hds-` selectors instead of custom `shw-` selectors +// to avoid extra un-necessary complexity (see different attempts in https://github.com/hashicorp/design-system/pull/3240) + +.hds-theme-light, +.hds-theme-cds-g0, +.hds-theme-cds-g10, +[data-hds-theme='light'], +[data-hds-theme='cds-g0'], +[data-hds-theme='cds-g10'] { + @include shw-theme-color-variables-light(); +} + +.hds-theme-dark, +.hds-theme-cds-g90, +.hds-theme-cds-g100, +[data-hds-theme='dark'], +[data-hds-theme='cds-g90'], +[data-hds-theme='cds-g100'] { + @include shw-theme-color-variables-dark(); +} diff --git a/showcase/app/styles/showcase-theming/light-color-variables.scss b/showcase/app/styles/showcase-theming/light-color-variables.scss new file mode 100644 index 00000000000..3e791d94e84 --- /dev/null +++ b/showcase/app/styles/showcase-theming/light-color-variables.scss @@ -0,0 +1,22 @@ +// SHOWCASE COLORS > LIGHT THEME + +@mixin shw-theme-color-variables-light() { + // SEMANTIC PALETTE + --shw-color-white: #fff; + --shw-color-gray-600: #f2f2f3; + --shw-color-gray-500: #dbdbdc; + --shw-color-gray-400: #bfbfc0; + --shw-color-gray-300: #727374; + --shw-color-gray-200: #343536; + --shw-color-gray-100: #1d1e1f; + --shw-color-black: #000; + --shw-color-action-active-foreground: #00f; // HTML "blue" + --shw-color-action-active-border: #00f; // HTML "blue" + --shw-color-action-active-background: #f0f8ff; // HTML "aliceblue" + // COMPONENTS + --shw-frame-browser-navigation-background: #fafafa; + --shw-label-text-color: #545454; + --shw-placeholder-text-color: #6b6b6b; // if background is #EEE then this has the appropriate color contrast (4.59:1) + --shw-placeholder-background-color: #eee; + --shw-placeholder-link-color: #333; +} diff --git a/showcase/app/templates/application.hbs b/showcase/app/templates/application.hbs index ff9e1cbdd64..f0bd45abb47 100644 --- a/showcase/app/templates/application.hbs +++ b/showcase/app/templates/application.hbs @@ -13,6 +13,9 @@
Components showcase
+
+ +