UNPKG

@furystack/shades-common-components

Version:

Common UI components for FuryStack Shades

260 lines (248 loc) 10.6 kB
import type { DeepPartial } from '@furystack/utils' import type { Theme } from './theme-provider-service.js' export const cssVariableTheme = { name: 'css-variable-theme', text: { primary: 'var(--shades-theme-text-primary)', secondary: 'var(--shades-theme-text-secondary)', disabled: 'var(--shades-theme-text-disabled)', }, button: { active: 'var(--shades-theme-button-active)', hover: 'var(--shades-theme-button-hover)', selected: 'var(--shades-theme-button-selected)', disabled: 'var(--shades-theme-button-disabled)', disabledBackground: 'var(--shades-theme-button-disabled-background)', }, background: { default: 'var(--shades-theme-background-default)', paper: 'var(--shades-theme-background-paper)', paperImage: 'var(--shades-theme-background-paper-image)', }, palette: { primary: { light: 'var(--shades-theme-palette-primary-light)', lightContrast: 'var(--shades-theme-palette-primary-light-contrast)', main: 'var(--shades-theme-palette-primary-main)', mainContrast: 'var(--shades-theme-palette-primary-main-contrast)', dark: 'var(--shades-theme-palette-primary-dark)', darkContrast: 'var(--shades-theme-palette-primary-dark-contrast)', }, secondary: { light: 'var(--shades-theme-palette-secondary-light)', lightContrast: 'var(--shades-theme-palette-secondary-light-contrast)', main: 'var(--shades-theme-palette-secondary-main)', mainContrast: 'var(--shades-theme-palette-secondary-main-contrast)', dark: 'var(--shades-theme-palette-secondary-dark)', darkContrast: 'var(--shades-theme-palette-secondary-dark-contrast)', }, error: { light: 'var(--shades-theme-palette-error-light)', lightContrast: 'var(--shades-theme-palette-error-light-contrast)', main: 'var(--shades-theme-palette-error-main)', mainContrast: 'var(--shades-theme-palette-error-main-contrast)', dark: 'var(--shades-theme-palette-error-dark)', darkContrast: 'var(--shades-theme-palette-error-dark-contrast)', }, warning: { light: 'var(--shades-theme-palette-warning-light)', lightContrast: 'var(--shades-theme-palette-warning-light-contrast)', main: 'var(--shades-theme-palette-warning-main)', mainContrast: 'var(--shades-theme-palette-warning-main-contrast)', dark: 'var(--shades-theme-palette-warning-dark)', darkContrast: 'var(--shades-theme-palette-warning-dark-contrast)', }, info: { light: 'var(--shades-theme-palette-info-light)', lightContrast: 'var(--shades-theme-palette-info-light-contrast)', main: 'var(--shades-theme-palette-info-main)', mainContrast: 'var(--shades-theme-palette-info-main-contrast)', dark: 'var(--shades-theme-palette-info-dark)', darkContrast: 'var(--shades-theme-palette-info-dark-contrast)', }, success: { light: 'var(--shades-theme-palette-success-light)', lightContrast: 'var(--shades-theme-palette-success-light-contrast)', main: 'var(--shades-theme-palette-success-main)', mainContrast: 'var(--shades-theme-palette-success-main-contrast)', dark: 'var(--shades-theme-palette-success-dark)', darkContrast: 'var(--shades-theme-palette-success-dark-contrast)', }, }, divider: 'var(--shades-theme-divider)', action: { hoverBackground: 'var(--shades-theme-action-hover-background)', selectedBackground: 'var(--shades-theme-action-selected-background)', activeBackground: 'var(--shades-theme-action-active-background)', focusRing: 'var(--shades-theme-action-focus-ring)', focusOutline: 'var(--shades-theme-action-focus-outline)', disabledOpacity: 'var(--shades-theme-action-disabled-opacity)', backdrop: 'var(--shades-theme-action-backdrop)', subtleBorder: 'var(--shades-theme-action-subtle-border)', }, shape: { borderRadius: { xs: 'var(--shades-theme-shape-border-radius-xs)', sm: 'var(--shades-theme-shape-border-radius-sm)', md: 'var(--shades-theme-shape-border-radius-md)', lg: 'var(--shades-theme-shape-border-radius-lg)', full: 'var(--shades-theme-shape-border-radius-full)', }, borderWidth: 'var(--shades-theme-shape-border-width)', }, shadows: { none: 'var(--shades-theme-shadows-none)', sm: 'var(--shades-theme-shadows-sm)', md: 'var(--shades-theme-shadows-md)', lg: 'var(--shades-theme-shadows-lg)', xl: 'var(--shades-theme-shadows-xl)', }, typography: { fontFamily: 'var(--shades-theme-typography-font-family)', fontSize: { xs: 'var(--shades-theme-typography-font-size-xs)', sm: 'var(--shades-theme-typography-font-size-sm)', md: 'var(--shades-theme-typography-font-size-md)', lg: 'var(--shades-theme-typography-font-size-lg)', xl: 'var(--shades-theme-typography-font-size-xl)', xxl: 'var(--shades-theme-typography-font-size-xxl)', xxxl: 'var(--shades-theme-typography-font-size-xxxl)', xxxxl: 'var(--shades-theme-typography-font-size-xxxxl)', }, fontWeight: { normal: 'var(--shades-theme-typography-font-weight-normal)', medium: 'var(--shades-theme-typography-font-weight-medium)', semibold: 'var(--shades-theme-typography-font-weight-semibold)', bold: 'var(--shades-theme-typography-font-weight-bold)', }, lineHeight: { tight: 'var(--shades-theme-typography-line-height-tight)', normal: 'var(--shades-theme-typography-line-height-normal)', relaxed: 'var(--shades-theme-typography-line-height-relaxed)', }, letterSpacing: { tight: 'var(--shades-theme-typography-letter-spacing-tight)', dense: 'var(--shades-theme-typography-letter-spacing-dense)', normal: 'var(--shades-theme-typography-letter-spacing-normal)', wide: 'var(--shades-theme-typography-letter-spacing-wide)', wider: 'var(--shades-theme-typography-letter-spacing-wider)', widest: 'var(--shades-theme-typography-letter-spacing-widest)', }, textShadow: 'var(--shades-theme-typography-text-shadow)', }, transitions: { duration: { fast: 'var(--shades-theme-transitions-duration-fast)', normal: 'var(--shades-theme-transitions-duration-normal)', slow: 'var(--shades-theme-transitions-duration-slow)', }, easing: { default: 'var(--shades-theme-transitions-easing-default)', easeOut: 'var(--shades-theme-transitions-easing-ease-out)', easeInOut: 'var(--shades-theme-transitions-easing-ease-in-out)', }, }, spacing: { xs: 'var(--shades-theme-spacing-xs)', sm: 'var(--shades-theme-spacing-sm)', md: 'var(--shades-theme-spacing-md)', lg: 'var(--shades-theme-spacing-lg)', xl: 'var(--shades-theme-spacing-xl)', }, zIndex: { drawer: 'var(--shades-theme-z-index-drawer)', appBar: 'var(--shades-theme-z-index-app-bar)', modal: 'var(--shades-theme-z-index-modal)', tooltip: 'var(--shades-theme-z-index-tooltip)', dropdown: 'var(--shades-theme-z-index-dropdown)', }, effects: { blurSm: 'var(--shades-theme-effects-blur-sm)', blurMd: 'var(--shades-theme-effects-blur-md)', blurLg: 'var(--shades-theme-effects-blur-lg)', blurXl: 'var(--shades-theme-effects-blur-xl)', }, } satisfies Theme /** * Builds a CSS transition string from property-duration-easing triplets. * @param specs - Array of [property, duration, easing] tuples * @returns A CSS transition string * @example * buildTransition( * ['background', cssVariableTheme.transitions.duration.normal, cssVariableTheme.transitions.easing.default], * ['opacity', cssVariableTheme.transitions.duration.fast, 'ease-out'], * ) */ export const buildTransition = (...specs: Array<[property: string, duration: string, easing: string]>): string => specs.map(([prop, dur, ease]) => `${prop} ${dur} ${ease}`).join(', ') const FOCUS_STYLES_ID = 'shades-focus-visible-styles' /** * Injects global `:focus-visible` styles using the theme's `focusOutline` CSS variable. * Ensures keyboard/spatial navigation focus is visible while mouse clicks produce no outline. * Safe to call multiple times — the style element is only created once. */ export const injectFocusVisibleStyles = (): void => { if (document.getElementById(FOCUS_STYLES_ID)) return const style = document.createElement('style') style.id = FOCUS_STYLES_ID style.textContent = ` :focus-visible { outline: ${cssVariableTheme.action.focusOutline}; outline-offset: 2px; } :focus:not(:focus-visible) { outline: none; } ` document.head.appendChild(style) } const extractVarName = (key: string): string => key.replace(/^var\(/, '').replace(/[,)].*/, '') export const setCssVariable = (key: string, value: string, root: HTMLElement) => { root.style.setProperty(extractVarName(key), value) } export const removeCssVariable = (key: string, root: HTMLElement) => { root.style.removeProperty(extractVarName(key)) } export const getCssVariable = (key: string, root: HTMLElement = document.querySelector(':root') as HTMLElement) => { return getComputedStyle(root).getPropertyValue(extractVarName(key)) } const removeValue = <T extends object>(target: T, root: HTMLElement) => { const keys = Object.keys(target) as Array<keyof T> keys.forEach((key) => { if (typeof target[key] === 'object') { removeValue(target[key] as object, root) } else { removeCssVariable(target[key] as string, root) } }) } const assignValue = <T extends object>( target: T, source: DeepPartial<T>, root: HTMLElement, assignFn = setCssVariable, ) => { const keys = Object.keys(target) as Array<keyof T> keys.forEach((key) => { if (typeof target[key] === 'object') { if (source[key] === undefined) { removeValue(target[key] as object, root) } else { assignValue(target[key] as object, source[key], root, assignFn) } } else if (source[key] === undefined) { removeCssVariable(target[key] as string, root) } else { assignFn(target[key] as string, source[key] as string, root) } }) } export const useThemeCssVariables = (theme: DeepPartial<Theme>, root?: HTMLElement) => { root ??= document.querySelector(':root') as HTMLElement assignValue(cssVariableTheme, theme, root) if (window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches) { setCssVariable(cssVariableTheme.transitions.duration.fast, '0s', root) setCssVariable(cssVariableTheme.transitions.duration.normal, '0s', root) setCssVariable(cssVariableTheme.transitions.duration.slow, '0s', root) } }