@furystack/shades-common-components
Version:
Common UI components for FuryStack Shades
260 lines (248 loc) • 10.6 kB
text/typescript
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)
}
}