@workday/canvas-kit-react
Version:
The parent module that contains all Workday Canvas Kit React components
567 lines (514 loc) • 20 kB
text/typescript
import * as React from 'react';
import {colorSpace, maybeWrapCSSVariables} from '@workday/canvas-kit-styling';
import {brand, system} from '@workday/canvas-tokens-web';
import {defaultCanvasTheme} from './theme';
import {
CanvasActionBrandRamp,
CanvasBrandRamp,
CanvasNeutralBrandRamp,
CanvasNumericalBrandTheme,
CanvasProviderTheme,
CanvasTheme,
PartialEmotionCanvasTheme,
isNumericalTheme,
} from './types';
/** True when a semantic theme intentionally passes a non-empty `canvas.palette`. */
export function hasExplicitSemanticPalette(
theme: CanvasProviderTheme
): theme is PartialEmotionCanvasTheme {
return (
!isNumericalTheme(theme) &&
!!theme.canvas?.palette &&
Object.keys(theme.canvas.palette).length > 0
);
}
/** Tokens that change when a consumer sets only their primary brand color. */
export const BRAND_SCOPE_PRIMARY_BUNDLE = {
action: ['base', 'dark', 'darkest', 'accent'] as const,
accent: ['primary', 'action'] as const,
selected: {
fg: system.color.brand.fg.selected,
surface: system.color.brand.surface.selected,
},
} as const;
/**
* Independently brandable tokens — writable when explicitly provided,
* but NEVER derived from primary.main / brand.primary.600.
*/
export const BRAND_SCOPE_INDEPENDENT = {
focus: {
primary: system.color.brand.focus.primary,
brandToken: brand.primary500,
semanticKey: 'focusOutline' as const,
},
border: {
primary: system.color.brand.border.primary,
brandToken: brand.primary500,
semanticKey: 'focusOutline' as const,
},
} as const;
const commonTokenMapping = {
focusOutline: brand.common.focus,
alertInner: brand.common.caution.inner,
alertOuter: brand.common.caution.outer,
errorInner: brand.common.critical,
} as const;
type BrandColor = 'primary' | 'critical' | 'caution' | 'positive' | 'neutral';
const brandColorMapping: Record<string, BrandColor> = {
primary: 'primary',
error: 'critical',
success: 'positive',
alert: 'caution',
neutral: 'neutral',
};
/** Brand ramp keys defined in Sana CSS but not yet exported from canvas-tokens-web. */
const EXTENDED_BRAND_TOKEN_MAP: Record<string, string> = {
neutral150: '--cnvs-brand-neutral-150',
neutral850: '--cnvs-brand-neutral-850',
neutralA150: '--cnvs-brand-neutral-a150',
};
const setStyleVar = (style: React.CSSProperties, token: string, value: string) => {
// @ts-ignore - CSS custom property key
style[token] = maybeWrapCSSVariables(value);
};
/** Maps a numerical brand ramp onto `brand.<color><rampKey>` CSS variables. */
export function writeNumericalBrandRamp(
color: BrandColor | 'action',
ramp: CanvasBrandRamp | CanvasNeutralBrandRamp | CanvasActionBrandRamp | undefined,
style: React.CSSProperties,
options?: {skipKeys?: Set<string>}
) {
if (!ramp) {
return;
}
(
Object.keys(ramp) as Array<
keyof (CanvasBrandRamp | CanvasNeutralBrandRamp | CanvasActionBrandRamp)
>
).forEach(rampKey => {
if (options?.skipKeys?.has(String(rampKey))) {
return;
}
const value = ramp[rampKey as keyof typeof ramp];
if (value == null) {
return;
}
const token =
color === 'action'
? brand.action[rampKey as keyof typeof brand.action]
: // @ts-ignore - dynamic token lookup
(brand[`${color}${rampKey}`] ?? EXTENDED_BRAND_TOKEN_MAP[`${color}${rampKey}`]);
if (token) {
setStyleVar(style, token, value);
}
});
}
/** Called when consumer sets only `primary.main` or `brand.primary['600']`. */
export function applyPrimaryBrandBundle(primaryColor: string, style: React.CSSProperties) {
const value = maybeWrapCSSVariables(primaryColor);
setStyleVar(style, brand.primary.base, value);
setStyleVar(style, brand.primary600, value);
setStyleVar(style, brand.action.base, value);
if (system.color.brand.accent.primary) {
setStyleVar(style, system.color.brand.accent.primary, value);
}
if (system.color.brand.accent.action) {
setStyleVar(style, system.color.brand.accent.action, value);
}
const selectedFg = colorSpace.pressed({
color: `var(${brand.primary600})`,
fallback: value,
colorType: 'accent',
});
const selectedSurface = colorSpace.darken({
color: system.color.bg.default,
fallback: 'white',
mixinColor: `var(${brand.primary600})`,
mixinValue: '0.11',
});
setStyleVar(style, brand.primary700, selectedFg);
setStyleVar(style, brand.primary.dark, selectedFg);
setStyleVar(style, brand.primaryA50, selectedSurface);
if (BRAND_SCOPE_PRIMARY_BUNDLE.selected.fg) {
setStyleVar(style, BRAND_SCOPE_PRIMARY_BUNDLE.selected.fg, selectedFg);
}
if (BRAND_SCOPE_PRIMARY_BUNDLE.selected.surface) {
setStyleVar(style, BRAND_SCOPE_PRIMARY_BUNDLE.selected.surface, selectedSurface);
}
const hoverColor = colorSpace.hover({
color: `var(${brand.action.base})`,
fallback: value,
colorType: 'accent',
});
const pressedColor = colorSpace.pressed({
color: `var(${brand.action.base})`,
fallback: value,
colorType: 'accent',
});
setStyleVar(style, brand.action.dark, hoverColor);
setStyleVar(style, brand.action.darkest, pressedColor);
}
/** Called when consumer sets only `error.main` or `brand.critical['600']`. */
export function applyCriticalBrandBundle(criticalColor: string, style: React.CSSProperties) {
const value = maybeWrapCSSVariables(criticalColor);
setStyleVar(style, brand.error.base, value);
setStyleVar(style, brand.critical600, value);
setStyleVar(style, brand.critical500, value);
if (system.color.brand.accent.critical) {
setStyleVar(style, system.color.brand.accent.critical, value);
}
if (system.color.brand.fg.critical?.default) {
setStyleVar(style, system.color.brand.fg.critical.default, value);
}
if (system.color.brand.border.critical) {
setStyleVar(style, system.color.brand.border.critical, value);
}
if (system.color.brand.focus.critical) {
setStyleVar(style, system.color.brand.focus.critical, value);
}
}
/** Called when consumer sets only `alert.main` or `brand.caution['400']`. */
export function applyCautionBrandBundle(cautionColor: string, style: React.CSSProperties) {
const value = maybeWrapCSSVariables(cautionColor);
setStyleVar(style, brand.alert.base, value);
setStyleVar(style, brand.caution400, value);
setStyleVar(style, brand.caution500, value);
if (system.color.brand.accent.caution) {
setStyleVar(style, system.color.brand.accent.caution, value);
}
if (system.color.brand.fg.caution?.default) {
setStyleVar(style, system.color.brand.fg.caution.default, value);
}
if (system.color.brand.border.caution) {
setStyleVar(style, system.color.brand.border.caution, value);
}
if (system.color.brand.focus.caution?.inner) {
setStyleVar(style, system.color.brand.focus.caution.inner, value);
}
if (system.color.brand.focus.caution?.outer) {
setStyleVar(style, system.color.brand.focus.caution.outer, value);
}
}
/** Called when consumer sets only `success.main` or `brand.positive['600']`. */
export function applyPositiveBrandBundle(positiveColor: string, style: React.CSSProperties) {
const value = maybeWrapCSSVariables(positiveColor);
setStyleVar(style, brand.success.base, value);
setStyleVar(style, brand.positive600, value);
if (system.color.brand.accent.positive) {
setStyleVar(style, system.color.brand.accent.positive, value);
}
if (system.color.brand.fg.positive?.default) {
setStyleVar(style, system.color.brand.fg.positive.default, value);
}
}
/** Called when consumer sets only `neutral.main` or `brand.neutral['600']`. */
export function applyNeutralBrandBundle(neutralColor: string, style: React.CSSProperties) {
const value = maybeWrapCSSVariables(neutralColor);
setStyleVar(style, brand.neutral.base, value);
setStyleVar(style, brand.neutral600, value);
}
/** Writes first-class selected shortcuts from numerical theme input. */
export function writeSelectedShortcuts(
selected: CanvasNumericalBrandTheme['selected'] | undefined,
style: React.CSSProperties
) {
if (!selected) {
return;
}
if (selected.fg && BRAND_SCOPE_PRIMARY_BUNDLE.selected.fg) {
setStyleVar(style, BRAND_SCOPE_PRIMARY_BUNDLE.selected.fg, selected.fg);
}
if (selected.surface && BRAND_SCOPE_PRIMARY_BUNDLE.selected.surface) {
setStyleVar(style, BRAND_SCOPE_PRIMARY_BUNDLE.selected.surface, selected.surface);
}
}
function writeFocusBorderBundle(value: string, style: React.CSSProperties) {
setStyleVar(style, brand.common.focusOutline, value);
setStyleVar(style, brand.primary500, value);
if (BRAND_SCOPE_INDEPENDENT.focus.primary) {
setStyleVar(style, BRAND_SCOPE_INDEPENDENT.focus.primary, value);
}
if (BRAND_SCOPE_INDEPENDENT.border.primary) {
setStyleVar(style, BRAND_SCOPE_INDEPENDENT.border.primary, value);
}
}
/** Writes focus/border from explicit common or brand.primary.500 input — never from primary.main. */
export function writeIndependentBrandTokens(
theme: CanvasProviderTheme | undefined,
style: React.CSSProperties
) {
if (!theme) {
return;
}
if (isNumericalTheme(theme)) {
const focusValue = theme.brand?.primary?.['500'];
if (focusValue) {
writeFocusBorderBundle(focusValue, style);
}
if (theme.system) {
writeSystemBrandOverrides(theme.system, style);
}
return;
}
const common = theme.canvas?.palette?.common;
if (!common) {
return;
}
(['focusOutline', 'alertInner', 'alertOuter', 'errorInner'] as const).forEach(key => {
const rawValue = common[key];
if (rawValue == null) {
return;
}
if (rawValue === defaultCanvasTheme.palette.common[key]) {
return;
}
const value = maybeWrapCSSVariables(rawValue);
// @ts-ignore
setStyleVar(style, brand.common[key], value);
// @ts-ignore
setStyleVar(style, commonTokenMapping[key], value);
if (key === 'focusOutline') {
writeFocusBorderBundle(value, style);
}
if (key === 'alertInner' && system.color.brand.focus.caution?.inner) {
setStyleVar(style, system.color.brand.focus.caution.inner, value);
}
if (key === 'alertOuter' && system.color.brand.border.caution) {
setStyleVar(style, system.color.brand.border.caution, value);
}
if (key === 'errorInner') {
if (system.color.brand.focus.critical) {
setStyleVar(style, system.color.brand.focus.critical, value);
}
if (system.color.brand.border.critical) {
setStyleVar(style, system.color.brand.border.critical, value);
}
}
});
}
/** Walks nested system override object and sets matching system CSS vars. */
export function writeSystemBrandOverrides(
systemOverrides: {color?: {brand?: Record<string, unknown>}} | undefined,
style: React.CSSProperties
) {
const brandOverrides = systemOverrides?.color?.brand;
if (!brandOverrides) {
return;
}
const walk = (node: Record<string, unknown>, path: string[]) => {
Object.entries(node).forEach(([key, val]) => {
if (val && typeof val === 'object') {
walk(val as Record<string, unknown>, [...path, key]);
} else if (typeof val === 'string') {
let tokenNode: unknown = system.color.brand;
for (const seg of [...path, key]) {
// @ts-ignore
tokenNode = tokenNode?.[seg];
}
if (typeof tokenNode === 'string') {
const cssVarName = tokenNode.match(/--[\w-]+/)?.[0] ?? tokenNode;
setStyleVar(style, cssVarName, val);
}
}
});
};
walk(brandOverrides, []);
}
const mappedKeys = {
lightest: 'lightest',
lighter: 'lighter',
light: 'light',
main: 'base',
dark: 'dark',
darkest: 'darkest',
contrast: 'accent',
} as const;
const numericalTokenMapping = {
lightest: '25',
lighter: '50',
light: '200',
main: '600',
dark: '700',
darkest: '800',
} as const;
/** Full-scope semantic writer — filled palette compared against defaults unless `writeAll`. */
export function writeSemanticTheme(
palette: CanvasTheme['palette'],
style: React.CSSProperties,
options?: {writeAll?: boolean}
) {
const writeAll = options?.writeAll ?? false;
(['common', 'primary', 'error', 'alert', 'success', 'neutral'] as const).forEach(color => {
if (color === 'common') {
(['focusOutline', 'alertInner', 'alertOuter', 'errorInner'] as const).forEach(key => {
if (writeAll || palette.common[key] !== defaultCanvasTheme.palette.common[key]) {
const value = maybeWrapCSSVariables(palette.common[key]);
// @ts-ignore
setStyleVar(style, brand.common[key], value);
// @ts-ignore
setStyleVar(style, commonTokenMapping[key], value);
if (key === 'focusOutline') {
writeFocusBorderBundle(value, style);
}
if (key === 'alertInner' && system.color.brand.focus.caution?.inner) {
setStyleVar(style, system.color.brand.focus.caution.inner, value);
}
if (key === 'alertOuter' && system.color.brand.border.caution) {
setStyleVar(style, system.color.brand.border.caution, value);
}
if (key === 'errorInner') {
if (system.color.brand.focus.critical) {
setStyleVar(style, system.color.brand.focus.critical, value);
}
if (system.color.brand.border.critical) {
setStyleVar(style, system.color.brand.border.critical, value);
}
}
}
});
} else {
(['lightest', 'lighter', 'light', 'main', 'dark', 'darkest', 'contrast'] as const).forEach(
key => {
// @ts-ignore
if (writeAll || palette[color][key] !== defaultCanvasTheme.palette[color][key]) {
// @ts-ignore
const value = maybeWrapCSSVariables(palette[color][key]);
// @ts-ignore
setStyleVar(style, brand[color][mappedKeys[key]], value);
if (key !== 'contrast' && key in numericalTokenMapping) {
const newBrandColor = brandColorMapping[color];
const numericalSuffix =
numericalTokenMapping[key as keyof typeof numericalTokenMapping];
// @ts-ignore
const numericalToken = brand[newBrandColor + numericalSuffix];
if (numericalToken) {
setStyleVar(style, numericalToken, value);
}
if (key === 'main') {
// @ts-ignore
const systemAccentToken = system.color.brand.accent[newBrandColor];
if (systemAccentToken) {
setStyleVar(style, systemAccentToken, value);
}
// @ts-ignore
const systemFgToken = system.color.brand.fg[newBrandColor]?.default;
if (systemFgToken) {
setStyleVar(style, systemFgToken, value);
}
} else if (key === 'dark') {
// @ts-ignore
const systemFgStrongToken = system.color.brand.fg[newBrandColor]?.strong;
if (systemFgStrongToken) {
setStyleVar(style, systemFgStrongToken, value);
}
if (newBrandColor === 'primary' && system.color.brand.fg.selected) {
setStyleVar(style, system.color.brand.fg.selected, value);
}
if (newBrandColor === 'critical') {
if (system.color.brand.focus.critical) {
setStyleVar(style, system.color.brand.focus.critical, value);
}
if (system.color.brand.border.critical) {
setStyleVar(style, system.color.brand.border.critical, value);
}
}
if (newBrandColor === 'caution') {
if (system.color.brand.focus.caution?.outer) {
setStyleVar(style, system.color.brand.focus.caution.outer, value);
}
if (system.color.brand.border.caution) {
setStyleVar(style, system.color.brand.border.caution, value);
}
}
} else if (key === 'lighter') {
// @ts-ignore
const surfaceStrongToken = system.color.brand.surface[newBrandColor]?.strong;
if (surfaceStrongToken) {
setStyleVar(style, surfaceStrongToken, value);
}
if (newBrandColor === 'primary' && system.color.brand.surface.selected) {
setStyleVar(style, system.color.brand.surface.selected, value);
}
} else if (key === 'lightest') {
// @ts-ignore
const surfaceDefaultToken = system.color.brand.surface[newBrandColor]?.default;
if (surfaceDefaultToken) {
setStyleVar(style, surfaceDefaultToken, value);
}
}
}
}
}
);
}
});
}
/** Writes numerical theme — brand scope applies per-family shortcuts; remaining keys write 1:1. */
export function writeNumericalTheme(
theme: CanvasNumericalBrandTheme,
style: React.CSSProperties,
scope: 'brand' | 'full'
) {
const brandPalette = theme.brand;
const skipRamp: Partial<Record<BrandColor | 'action', Set<string>>> = {};
if (scope === 'brand' && brandPalette) {
if (brandPalette.primary?.['600'] && Object.keys(brandPalette.primary).length === 1) {
applyPrimaryBrandBundle(brandPalette.primary['600'], style);
skipRamp.primary = new Set(['600']);
// Do not skip action keys — explicit `brand.action.*` must win over derived shades
// written by applyPrimaryBrandBundle (writeNumericalBrandRamp runs after).
}
// Only the main ramp key triggers the family shortcut. Lone `'500'` (focus/border) must
// write 1:1 and must not overwrite critical600 / caution400 via the bundle.
const critical = brandPalette.critical;
if (critical?.['600'] && Object.keys(critical).length === 1) {
applyCriticalBrandBundle(critical['600'], style);
skipRamp.critical = new Set(['600']);
}
const caution = brandPalette.caution;
if (caution?.['400'] && Object.keys(caution).length === 1) {
applyCautionBrandBundle(caution['400'], style);
skipRamp.caution = new Set(['400']);
}
if (brandPalette.positive?.['600'] && Object.keys(brandPalette.positive).length === 1) {
applyPositiveBrandBundle(brandPalette.positive['600'], style);
skipRamp.positive = new Set(['600']);
}
if (brandPalette.neutral?.['600'] && Object.keys(brandPalette.neutral).length === 1) {
applyNeutralBrandBundle(brandPalette.neutral['600'], style);
skipRamp.neutral = new Set(['600']);
}
}
if (brandPalette) {
(['primary', 'critical', 'caution', 'positive', 'neutral', 'action'] as const).forEach(
color => {
writeNumericalBrandRamp(color, brandPalette[color], style, {skipKeys: skipRamp[color]});
}
);
}
writeSelectedShortcuts(theme.selected, style);
writeIndependentBrandTokens(theme, style);
}
/** Brand-scope semantic path — reads raw input only, not useTheme-filled palette. */
export function writeBrandScopeSemantic(
theme: PartialEmotionCanvasTheme,
style: React.CSSProperties
) {
const palette = theme.canvas?.palette;
if (palette?.primary?.main) {
applyPrimaryBrandBundle(palette.primary.main, style);
}
if (palette?.error?.main) {
applyCriticalBrandBundle(palette.error.main, style);
}
if (palette?.alert?.main) {
applyCautionBrandBundle(palette.alert.main, style);
}
if (palette?.success?.main) {
applyPositiveBrandBundle(palette.success.main, style);
}
if (palette?.neutral?.main) {
applyNeutralBrandBundle(palette.neutral.main, style);
}
writeIndependentBrandTokens(theme, style);
}