@react-spectrum/s2
Version:
Spectrum 2 UI components in React
1,011 lines (929 loc) • 45.1 kB
text/typescript
/*
* Copyright 2024 Adobe. All rights reserved.
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. You may obtain a copy
* of the License at http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
* OF ANY KIND, either express or implied. See the License for the specific language
* governing permissions and limitations under the License.
*/
import {ArbitraryProperty, Color, createTheme, ExpandedProperty, MappedProperty, parseArbitraryValue, PercentageProperty, SizingProperty} from './style-macro';
import {ArbitraryValue, CSSProperties, CSSValue, PropertyValueDefinition, PropertyValueMap, Value} from './types';
import {autoStaticColor, ColorRef, colorScale, ColorToken, colorToken, fontSizeToken, generateOverlayColorScale, getToken, rawColorToken, simpleColorScale, weirdColorToken} from './tokens' with {type: 'macro'};
import type * as CSS from 'csstype';
interface MacroContext {
addAsset(asset: {type: string, content: string}): void
}
function pxToRem(px: string | number) {
if (typeof px === 'string') {
px = parseFloat(px);
}
return px / 16 + 'rem';
}
const baseColors = {
transparent: 'transparent',
black: 'black',
white: 'white',
...colorScale('gray'),
...colorScale('blue'),
...colorScale('red'),
...colorScale('orange'),
...colorScale('yellow'),
...colorScale('chartreuse'),
...colorScale('celery'),
...colorScale('green'),
...colorScale('seafoam'),
...colorScale('cyan'),
...colorScale('indigo'),
...colorScale('purple'),
...colorScale('fuchsia'),
...colorScale('magenta'),
...colorScale('pink'),
...colorScale('turquoise'),
...colorScale('brown'),
...colorScale('silver'),
...colorScale('cinnamon'),
...colorScale('accent-color'),
...colorScale('informative-color'),
...colorScale('negative-color'),
...colorScale('notice-color'),
...colorScale('positive-color'),
...simpleColorScale('transparent-white'),
...simpleColorScale('transparent-black'),
...generateOverlayColorScale(),
// High contrast mode.
Background: 'Background',
ButtonBorder: 'ButtonBorder',
ButtonFace: 'ButtonFace',
ButtonText: 'ButtonText',
Field: 'Field',
Highlight: 'Highlight',
HighlightText: 'HighlightText',
GrayText: 'GrayText',
Mark: 'Mark',
LinkText: 'LinkText'
};
// Resolves a color to its most basic form, following all aliases.
function resolveColorToken(token: string | ColorToken | ColorRef): ColorToken {
if (typeof token === 'string') {
return {
type: 'color',
light: token,
dark: token
};
}
if (token.type === 'color') {
return token;
}
let lightToken = baseColors[token.light];
if (!lightToken) {
throw new Error(`${token.light} is not a valid color reference`);
}
let darkToken = baseColors[token.dark];
if (!darkToken) {
throw new Error(`${token.dark} is not a valid color reference`);
}
let light = resolveColorToken(lightToken);
let dark = resolveColorToken(darkToken);
return {
type: 'color',
light: light.light,
dark: dark.dark
};
}
function colorTokenToString(token: ColorToken, opacity?: string | number) {
let result = token.light === token.dark ? token.light : `light-dark(${token.light}, ${token.dark})`;
if (opacity) {
result = `rgb(from ${result} r g b / ${opacity}%)`;
}
return result;
}
// Bumps up a color token by one stop, e.g. for hover/press states.
let colorList = Object.keys(baseColors);
function nextColorStop(name: string, token: string | ColorToken | ColorRef): ColorToken {
if (typeof token === 'object' && token.type === 'ref') {
let light = nextColorStop(token.light, baseColors[token.light]);
let dark = nextColorStop(token.dark, baseColors[token.dark]);
return {
type: 'color',
light: light.light,
dark: dark.dark,
forcedColors: token.forcedColors
};
}
let index = colorList.indexOf(name);
if (index === -1) {
throw new Error(`${name} does not support states`);
}
let key = colorList[index + 1];
if (key.split('-')[0] !== name.split('-')[0]) {
throw new Error(`${name} does not support states`);
}
return resolveColorToken(baseColors[key]);
}
class SpectrumColorProperty<C extends string> extends ArbitraryProperty<C> {
mapping: {[name in C]: string | ColorToken | ColorRef};
constructor(property: string, mapping: {[name in C]: string | ColorToken | ColorRef}) {
super(property);
this.mapping = mapping;
}
toCSSValue(value: Color<C>): PropertyValueDefinition<Value> {
let [colorWithOpacity, state] = value.split(':');
let [color, opacity] = colorWithOpacity.split('/');
let token: string | ColorToken | ColorRef = this.mapping[color];
if (!token) {
throw new Error('Invalid color ' + value);
}
if (state === 'hovered' || state === 'pressed' || state === 'focused') {
token = nextColorStop(color, token);
} else {
token = resolveColorToken(token);
}
let result = colorTokenToString(token, opacity);
if (token.forcedColors) {
return {
default: result,
forcedColors: token.forcedColors
};
}
return result;
}
}
type BaseColor = keyof typeof baseColors;
export function baseColor<C extends string = BaseColor>(base: BaseColor | C): {default: C, isHovered: C, isFocusVisible: C, isPressed: C} {
return {
default: base as C,
isHovered: `${base}:hovered` as C,
isFocusVisible: `${base}:focused` as C,
isPressed: `${base}:pressed` as C
};
}
type SpectrumColor = Color<BaseColor> | ArbitraryValue;
export function color(value: SpectrumColor): string {
let arbitrary = parseArbitraryValue(value);
if (arbitrary) {
return arbitrary;
}
let [colorValue, opacity] = value.split('/');
return colorTokenToString(resolveColorToken(baseColors[colorValue]), opacity);
}
export function lightDark(light: SpectrumColor, dark: SpectrumColor): `[${string}]` {
return `[light-dark(${color(light)}, ${color(dark)})]`;
}
export function colorMix(a: SpectrumColor, b: SpectrumColor, percent: number): `[${string}]` {
return `[color-mix(in srgb, ${color(a)}, ${color(b)} ${percent}%)]`;
}
interface LinearGradient {
type: 'linear-gradient',
angle: string,
stops: [SpectrumColor, number][]
}
export function linearGradient(this: MacroContext | void, angle: string, ...tokens: [SpectrumColor, number][]): [LinearGradient] {
// Generate @property rules for each gradient stop color. This allows the gradient to be animated.
let propertyDefinitions: string[] = [];
for (let i = 0; i < tokens.length; i++) {
propertyDefinitions.push(`@property --g${i} {
syntax: '<color>';
initial-value: #0000;
inherits: false;
}`);
}
if (this && typeof this.addAsset === 'function') {
this.addAsset({
type: 'css',
content: propertyDefinitions.join('\n\n')
});
}
return [{
type: 'linear-gradient',
angle,
stops: tokens
}];
}
// Spacing uses rems, padding does not.
function generateSpacing<K extends number[]>(px: K): {spacing: {[P in K[number]]: string}, padding: {[P in K[number]]: string}} {
let spacing: any = {};
let padding: any = {};
for (let p of px) {
spacing[p] = pxToRem(p);
padding[p] = p + 'px';
}
return {spacing, padding};
}
const {spacing: baseSpacing, padding: basePadding} = generateSpacing([
0,
2, // spacing-50
4, // spacing-75
8, // spacing-100
12, // spacing-200
16, // spacing-300
20,
24, // spacing-400
28,
32, // spacing-500
36,
40, // spacing-600
44,
48, // spacing-700
56,
// From here onward the values are mostly spaced by 1rem (16px)
64, // spacing-800
80, // spacing-900
96 // spacing-1000
] as const);
// This should match the above, but negative. There's no way to negate a number
// type in typescript so this has to be done manually for now.
const {spacing: negativeSpacing, padding: negativePadding} = generateSpacing([
-2, // spacing-50
-4, // spacing-75
-8, // spacing-100
-12, // spacing-200
-16, // spacing-300
-20,
-24, // spacing-400
-28,
-32, // spacing-500
-36,
-40, // spacing-600
-44,
-48, // spacing-700
-56,
// From here onward the values are mostly spaced by 1rem (16px)
-64, // spacing-800
-80, // spacing-900
-96 // spacing-1000
] as const);
export type PositiveSpacing = keyof typeof baseSpacing;
export type NegativeSpacing = keyof typeof negativeSpacing;
export type Spacing = PositiveSpacing | NegativeSpacing;
export function fontRelative(this: MacroContext | void, base: number, baseFontSize = 14): string {
return (base / baseFontSize) + 'em';
}
export function edgeToText(this: MacroContext | void, height: keyof typeof baseSpacing): string {
return `calc(${baseSpacing[height]} * 3 / 8)`;
}
export function space(this: MacroContext | void, px: number): string {
return pxToRem(px);
}
const relativeSpacing = {
// font-size relative values
'text-to-control': fontRelative(10),
'text-to-visual': {
default: fontRelative(6), // -> 5px, 5px, 6px, 7px, 8px
touch: fontRelative(8, 17) // -> 6px, 7px, 8px, 9px, 10px, should be 7px, 7px, 8px, 9px, 11px
},
// height relative values
'edge-to-text': 'calc(self(height, self(minHeight)) * 3 / 8)',
'pill': 'calc(self(height, self(minHeight)) / 2)'
} as const;
const spacing = {
...baseSpacing,
...relativeSpacing
};
const padding = {
...basePadding,
...relativeSpacing
};
export function size(this: MacroContext | void, px: number): string {
return `calc(${pxToRem(px)} * var(--s2-scale))`;
}
const sizing = {
auto: 'auto',
full: '100%',
min: 'min-content',
max: 'max-content',
fit: 'fit-content'
};
const height = {
...sizing,
screen: '100vh'
};
const width = {
...sizing,
screen: '100vw'
};
function createSpectrumSizingProperty<T extends CSSValue>(property: string, values: PropertyValueMap<T>) {
return new SizingProperty(property, values, px => `calc(${pxToRem(px)} * var(--s2-scale))`);
}
const margin = {
...spacing,
...negativeSpacing,
auto: 'auto'
};
const inset = {
...basePadding,
...negativePadding,
auto: 'auto',
full: '100%'
};
export type Inset = keyof typeof inset;
const translate = {
...basePadding,
...negativePadding,
full: '100%'
} as const;
const borderWidth = {
0: '0px',
1: getToken('border-width-100'),
2: getToken('border-width-200'),
4: getToken('border-width-400')
};
const radius = {
none: getToken('corner-radius-none'), // 0px
sm: pxToRem(getToken('corner-radius-small-default')), // 4px
default: pxToRem(getToken('corner-radius-medium-default')), // 8px
lg: pxToRem(getToken('corner-radius-large-default')), // 10px
xl: pxToRem(getToken('corner-radius-extra-large-default')), // 16px
full: '9999px',
pill: 'calc(self(height, self(minHeight, 9999px)) / 2)'
};
type GridTrack = 'none' | 'subgrid' | (string & {}) | readonly GridTrackSize[];
type GridTrackSize = 'auto' | 'min-content' | 'max-content' | `${number}fr` | `minmax(${string}, ${string})` | keyof typeof baseSpacing | (string & {});
let gridTrack = (value: GridTrack) => {
if (typeof value === 'string') {
return value;
}
return value.map(v => gridTrackSize(v)).join(' ');
};
let gridTrackSize = (value: GridTrackSize) => {
return value in baseSpacing ? baseSpacing[value] : value;
};
const transitionProperty = {
// var(--gp) is generated by the backgroundImage property when setting a gradient.
// It includes a list of all of the custom properties used for each color stop.
default: 'color, background-color, var(--gp, color), border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, translate, scale, rotate, filter, backdrop-filter',
colors: 'color, background-color, var(--gp, color), border-color, text-decoration-color, fill, stroke',
opacity: 'opacity',
shadow: 'box-shadow',
transform: 'transform, translate, scale, rotate',
all: 'all',
none: 'none'
};
// TODO
const timingFunction = {
default: 'cubic-bezier(0.45, 0, 0.4, 1)',
linear: 'linear',
in: 'cubic-bezier(0.5, 0, 1, 1)',
out: 'cubic-bezier(0, 0, 0.40, 1)',
'in-out': 'cubic-bezier(0.45, 0, 0.4, 1)'
};
let durationValue = (value: number | string) => typeof value === 'number' ? value + 'ms' : value;
const fontWeightBase = {
light: '300',
normal: '400',
medium: '500',
bold: '700',
'extra-bold': '800',
black: '900'
} as const;
const fontWeight = {
...fontWeightBase,
heading: {
default: fontWeightBase[getToken('heading-sans-serif-font-weight') as keyof typeof fontWeightBase],
':lang(ja, ko, zh, zh-Hant, zh-Hans)': fontWeightBase[getToken('heading-cjk-font-weight') as keyof typeof fontWeightBase]
},
title: {
default: fontWeightBase[getToken('title-sans-serif-font-weight') as keyof typeof fontWeightBase],
':lang(ja, ko, zh, zh-Hant, zh-Hans)': fontWeightBase[getToken('title-cjk-font-weight') as keyof typeof fontWeightBase]
},
detail: {
default: fontWeightBase[getToken('detail-sans-serif-font-weight') as keyof typeof fontWeightBase],
':lang(ja, ko, zh, zh-Hant, zh-Hans)': fontWeightBase[getToken('detail-cjk-font-weight') as keyof typeof fontWeightBase]
}
} as const;
const i18nFonts = {
':lang(ar)': 'myriad-arabic, ui-sans-serif, system-ui, sans-serif',
':lang(he)': 'myriad-hebrew, ui-sans-serif, system-ui, sans-serif',
':lang(ja)': "adobe-clean-han-japanese, 'Hiragino Kaku Gothic ProN', 'ヒラギノ角ゴ ProN W3', Osaka, YuGothic, 'Yu Gothic', 'メイリオ', Meiryo, 'MS Pゴシック', 'MS PGothic', sans-serif",
':lang(ko)': "adobe-clean-han-korean, source-han-korean, 'Malgun Gothic', 'Apple Gothic', sans-serif",
':lang(zh)': "adobe-clean-han-traditional, source-han-traditional, 'MingLiu', 'Heiti TC Light', sans-serif",
// TODO: are these fallbacks supposed to be different than above?
':lang(zh-hant)': "adobe-clean-han-traditional, source-han-traditional, 'MingLiu', 'Microsoft JhengHei UI', 'Microsoft JhengHei', 'Heiti TC Light', sans-serif",
':lang(zh-Hans, zh-CN, zh-SG)': "adobe-clean-han-simplified-c, source-han-simplified-c, 'SimSun', 'Heiti SC Light', sans-serif"
} as const;
const fontSize = {
// The default font size scale is for use within UI components.
'ui-xs': fontSizeToken('font-size-50'),
'ui-sm': fontSizeToken('font-size-75'),
ui: fontSizeToken('font-size-100'),
'ui-lg': fontSizeToken('font-size-200'),
'ui-xl': fontSizeToken('font-size-300'),
'ui-2xl': fontSizeToken('font-size-400'),
'ui-3xl': fontSizeToken('font-size-500'),
'heading-2xs': fontSizeToken('heading-size-xxs'),
'heading-xs': fontSizeToken('heading-size-xs'),
'heading-sm': fontSizeToken('heading-size-s'),
heading: fontSizeToken('heading-size-m'),
'heading-lg': fontSizeToken('heading-size-l'),
'heading-xl': fontSizeToken('heading-size-xl'),
'heading-2xl': fontSizeToken('heading-size-xxl'),
'heading-3xl': fontSizeToken('heading-size-xxxl'),
'title-xs': fontSizeToken('title-size-xs'),
'title-sm': fontSizeToken('title-size-s'),
title: fontSizeToken('title-size-m'),
'title-lg': fontSizeToken('title-size-l'),
'title-xl': fontSizeToken('title-size-xl'),
'title-2xl': fontSizeToken('title-size-xxl'),
'title-3xl': fontSizeToken('title-size-xxxl'),
// Body is for large blocks of text, e.g. paragraphs, not in UI components.
'body-2xs': fontSizeToken('font-size-50'), // TODO: seems like there is no token for this
'body-xs': fontSizeToken('body-size-xs'),
'body-sm': fontSizeToken('body-size-s'),
body: fontSizeToken('body-size-m'),
'body-lg': fontSizeToken('body-size-l'),
'body-xl': fontSizeToken('body-size-xl'),
'body-2xl': fontSizeToken('body-size-xxl'),
'body-3xl': fontSizeToken('body-size-xxxl'),
'detail-sm': fontSizeToken('detail-size-s'),
detail: fontSizeToken('detail-size-m'),
'detail-lg': fontSizeToken('detail-size-l'),
'detail-xl': fontSizeToken('detail-size-xl'),
'code-xs': fontSizeToken('code-size-xs'),
'code-sm': fontSizeToken('code-size-s'),
code: fontSizeToken('code-size-m'),
'code-lg': fontSizeToken('code-size-l'),
'code-xl': fontSizeToken('code-size-xl')
} as const;
export const style = createTheme({
properties: {
// colors
color: new SpectrumColorProperty('color', {
...baseColors,
accent: colorToken('accent-content-color-default'),
neutral: colorToken('neutral-content-color-default'),
'neutral-subdued': colorToken('neutral-subdued-content-color-default'),
negative: colorToken('negative-content-color-default'),
disabled: colorToken('disabled-content-color'),
heading: colorToken('heading-color'),
title: colorToken('title-color'),
body: colorToken('body-color'),
detail: colorToken('detail-color'),
code: colorToken('code-color'),
auto: autoStaticColor('self(backgroundColor, var(--s2-container-bg))')
}),
backgroundColor: new SpectrumColorProperty('backgroundColor', {
...baseColors,
accent: weirdColorToken('accent-background-color-default'),
'accent-subtle': weirdColorToken('accent-subtle-background-color-default'),
neutral: colorToken('neutral-background-color-default'),
'neutral-subdued': weirdColorToken('neutral-subdued-background-color-default'),
'neutral-subtle': weirdColorToken('neutral-subtle-background-color-default'),
negative: weirdColorToken('negative-background-color-default'),
'negative-subtle': weirdColorToken('negative-subtle-background-color-default'),
informative: weirdColorToken('informative-background-color-default'),
'informative-subtle': weirdColorToken('informative-subtle-background-color-default'),
positive: weirdColorToken('positive-background-color-default'),
'positive-subtle': weirdColorToken('positive-subtle-background-color-default'),
notice: weirdColorToken('notice-background-color-default'),
'notice-subtle': weirdColorToken('notice-subtle-background-color-default'),
gray: weirdColorToken('gray-background-color-default'),
'gray-subtle': weirdColorToken('gray-subtle-background-color-default'),
red: weirdColorToken('red-background-color-default'),
'red-subtle': weirdColorToken('red-subtle-background-color-default'),
orange: weirdColorToken('orange-background-color-default'),
'orange-subtle': weirdColorToken('orange-subtle-background-color-default'),
yellow: weirdColorToken('yellow-background-color-default'),
'yellow-subtle': weirdColorToken('yellow-subtle-background-color-default'),
chartreuse: weirdColorToken('chartreuse-background-color-default'),
'chartreuse-subtle': weirdColorToken('chartreuse-subtle-background-color-default'),
celery: weirdColorToken('celery-background-color-default'),
'celery-subtle': weirdColorToken('celery-subtle-background-color-default'),
green: weirdColorToken('green-background-color-default'),
'green-subtle': weirdColorToken('green-subtle-background-color-default'),
seafoam: weirdColorToken('seafoam-background-color-default'),
'seafoam-subtle': weirdColorToken('seafoam-subtle-background-color-default'),
cyan: weirdColorToken('cyan-background-color-default'),
'cyan-subtle': weirdColorToken('cyan-subtle-background-color-default'),
blue: weirdColorToken('blue-background-color-default'),
'blue-subtle': weirdColorToken('blue-subtle-background-color-default'),
indigo: weirdColorToken('indigo-background-color-default'),
'indigo-subtle': weirdColorToken('indigo-subtle-background-color-default'),
purple: weirdColorToken('purple-background-color-default'),
'purple-subtle': weirdColorToken('purple-subtle-background-color-default'),
fuchsia: weirdColorToken('fuchsia-background-color-default'),
'fuchsia-subtle': weirdColorToken('fuchsia-subtle-background-color-default'),
magenta: weirdColorToken('magenta-background-color-default'),
'magenta-subtle': weirdColorToken('magenta-subtle-background-color-default'),
pink: weirdColorToken('pink-background-color-default'),
'pink-subtle': weirdColorToken('pink-subtle-background-color-default'),
turquoise: weirdColorToken('turquoise-background-color-default'),
'turquoise-subtle': weirdColorToken('turquoise-subtle-background-color-default'),
cinnamon: weirdColorToken('cinnamon-background-color-default'),
'cinnamon-subtle': weirdColorToken('cinnamon-subtle-background-color-default'),
brown: weirdColorToken('brown-background-color-default'),
'brown-subtle': weirdColorToken('brown-subtle-background-color-default'),
silver: weirdColorToken('silver-background-color-default'),
'silver-subtle': weirdColorToken('silver-subtle-background-color-default'),
disabled: colorToken('disabled-background-color'),
base: colorToken('background-base-color'),
'layer-1': colorToken('background-layer-1-color'),
'layer-2': weirdColorToken('background-layer-2-color'),
pasteboard: weirdColorToken('background-pasteboard-color'),
elevated: weirdColorToken('background-elevated-color')
}),
borderColor: new SpectrumColorProperty('borderColor', {
...baseColors,
negative: colorToken('negative-border-color-default'),
disabled: colorToken('disabled-border-color')
}),
outlineColor: new SpectrumColorProperty('outlineColor', {
...baseColors,
'focus-ring': {
...colorToken('focus-indicator-color'),
forcedColors: 'Highlight'
}
}),
fill: new SpectrumColorProperty('fill', {
none: 'none',
currentColor: 'currentColor',
accent: weirdColorToken('accent-visual-color'),
neutral: weirdColorToken('neutral-visual-color'),
negative: weirdColorToken('negative-visual-color'),
informative: weirdColorToken('informative-visual-color'),
positive: weirdColorToken('positive-visual-color'),
notice: weirdColorToken('notice-visual-color'),
gray: weirdColorToken('gray-visual-color'),
red: weirdColorToken('red-visual-color'),
orange: weirdColorToken('orange-visual-color'),
yellow: weirdColorToken('yellow-visual-color'),
chartreuse: weirdColorToken('chartreuse-visual-color'),
celery: weirdColorToken('celery-visual-color'),
green: weirdColorToken('green-visual-color'),
seafoam: weirdColorToken('seafoam-visual-color'),
cyan: weirdColorToken('cyan-visual-color'),
blue: weirdColorToken('blue-visual-color'),
indigo: weirdColorToken('indigo-visual-color'),
purple: weirdColorToken('purple-visual-color'),
fuchsia: weirdColorToken('fuchsia-visual-color'),
magenta: weirdColorToken('magenta-visual-color'),
pink: weirdColorToken('pink-visual-color'),
turquoise: weirdColorToken('turquoise-visual-color'),
cinnamon: weirdColorToken('cinnamon-visual-color'),
brown: weirdColorToken('brown-visual-color'),
silver: weirdColorToken('silver-visual-color'),
...baseColors
}),
stroke: new SpectrumColorProperty('stroke', {
none: 'none',
currentColor: 'currentColor',
...baseColors
}),
// dimensions
borderSpacing: baseSpacing, // TODO: separate x and y
flexBasis: createSpectrumSizingProperty('flexBasis', {
auto: 'auto',
full: '100%'
}),
rowGap: spacing,
columnGap: spacing,
height: createSpectrumSizingProperty('height', height),
width: createSpectrumSizingProperty('width', width),
containIntrinsicWidth: createSpectrumSizingProperty('containIntrinsicWidth', width),
containIntrinsicHeight: createSpectrumSizingProperty('containIntrinsicHeight', height),
minHeight: createSpectrumSizingProperty('minHeight', height),
maxHeight: createSpectrumSizingProperty('maxHeight', {
...height,
none: 'none'
}),
minWidth: createSpectrumSizingProperty('minWidth', width),
maxWidth: createSpectrumSizingProperty('maxWidth', {
...width,
none: 'none'
}),
borderStartWidth: new MappedProperty('borderInlineStartWidth', borderWidth),
borderEndWidth: new MappedProperty('borderInlineEndWidth', borderWidth),
borderTopWidth: borderWidth,
borderBottomWidth: borderWidth,
borderStyle: ['solid', 'dashed', 'dotted', 'double', 'hidden', 'none'] as const,
strokeWidth: {
0: '0',
1: '1',
2: '2'
},
marginStart: new PercentageProperty('marginInlineStart', margin),
marginEnd: new PercentageProperty('marginInlineEnd', margin),
marginTop: new PercentageProperty('marginTop', margin),
marginBottom: new PercentageProperty('marginBottom', margin),
paddingStart: new PercentageProperty('paddingInlineStart', padding),
paddingEnd: new PercentageProperty('paddingInlineEnd', padding),
paddingTop: new PercentageProperty('paddingTop', padding),
paddingBottom: new PercentageProperty('paddingBottom', padding),
scrollMarginStart: new MappedProperty('scrollMarginInlineStart', baseSpacing),
scrollMarginEnd: new MappedProperty('scrollMarginInlineEnd', baseSpacing),
scrollMarginTop: baseSpacing,
scrollMarginBottom: baseSpacing,
// Using rems instead of px here (unlike regular padding) because this often needs to match the height of something.
scrollPaddingStart: new MappedProperty('scrollPaddingInlineStart', baseSpacing),
scrollPaddingEnd: new MappedProperty('scrollPaddingInlineEnd', baseSpacing),
scrollPaddingTop: baseSpacing,
scrollPaddingBottom: baseSpacing,
textIndent: new PercentageProperty('textIndent', baseSpacing),
translateX: new ExpandedProperty(['--translateX', 'translate'], value => ({
'--translateX': String(value),
translate: 'var(--translateX, 0) var(--translateY, 0)'
}), new PercentageProperty('--translateX', translate)),
translateY: new ExpandedProperty(['--translateY', 'translate'], value => ({
'--translateY': String(value),
translate: 'var(--translateX, 0) var(--translateY, 0)'
}), new PercentageProperty('--translateY', translate)),
rotate: new ArbitraryProperty('rotate', (value: number | `${number}deg` | `${number}rad` | `${number}grad` | `${number}turn`) => typeof value === 'number' ? `${value}deg` : value),
scaleX: new ExpandedProperty<number | `${number}%`>(['--scaleX', 'scale'], value => ({
'--scaleX': String(value),
scale: 'var(--scaleX, 1) var(--scaleY, 1)'
})),
scaleY: new ExpandedProperty<number | `${number}%`>(['--scaleY', 'scale'], value => ({
'--scaleY': String(value),
scale: 'var(--scaleX, 1) var(--scaleY, 1)'
})),
transform: new ArbitraryProperty<string>('transform'),
position: ['absolute', 'fixed', 'relative', 'sticky', 'static'] as const,
insetStart: new PercentageProperty('insetInlineStart', inset),
insetEnd: new PercentageProperty('insetInlineEnd', inset),
top: new PercentageProperty('top', inset),
left: new PercentageProperty('left', inset),
bottom: new PercentageProperty('bottom', inset),
right: new PercentageProperty('right', inset),
aspectRatio: new ArbitraryProperty<'auto' | 'square' | 'video' | `${number}/${number}`>('aspectRatio', value => {
if (value === 'square') {
return '1/1';
}
if (value === 'video') {
return '16/9';
}
return value;
}),
// text
fontFamily: {
sans: {
default: 'adobe-clean-variable, adobe-clean, ui-sans-serif, system-ui, sans-serif',
...i18nFonts
},
serif: {
default: 'adobe-clean-serif, "Source Serif", Georgia, serif',
...i18nFonts
},
code: 'source-code-pro, "Source Code Pro", Monaco, monospace'
},
fontSize,
fontWeight: new ExpandedProperty<keyof typeof fontWeight>(['fontWeight', 'fontVariationSettings', 'fontSynthesisWeight'], (value) => {
return {
// Set font-variation-settings in addition to font-weight to work around typekit issue.
fontVariationSettings: value === 'inherit' ? 'inherit' : `"wght" ${value}`,
fontWeight: value as any,
fontSynthesisWeight: 'none'
};
}, fontWeight),
lineHeight: {
// See https://spectrum.corp.adobe.com/page/typography/#Line-height
ui: {
default: getToken('line-height-100'),
':lang(ja, ko, zh, zh-Hant, zh-Hans)': getToken('line-height-200')
},
heading: {
default: getToken('heading-line-height'),
':lang(ja, ko, zh, zh-Hant, zh-Hans)': getToken('heading-cjk-line-height')
},
title: {
default: getToken('title-line-height'),
':lang(ja, ko, zh, zh-Hant, zh-Hans)': getToken('title-cjk-line-height')
},
body: {
default: getToken('body-line-height'),
':lang(ja, ko, zh, zh-Hant, zh-Hans)': getToken('body-cjk-line-height')
},
detail: {
default: getToken('detail-line-height'),
':lang(ja, ko, zh, zh-Hant, zh-Hans)': getToken('detail-cjk-line-height')
},
code: {
default: getToken('code-line-height'),
':lang(ja, ko, zh, zh-Hant, zh-Hans)': getToken('code-cjk-line-height')
}
},
listStyleType: ['none', 'disc', 'decimal'] as const,
listStylePosition: ['inside', 'outside'] as const,
textTransform: ['uppercase', 'lowercase', 'capitalize', 'none'] as const,
textAlign: ['start', 'center', 'end', 'justify'] as const,
verticalAlign: ['baseline', 'top', 'middle', 'bottom', 'text-top', 'text-bottom', 'sub', 'super'] as const,
textDecoration: new ExpandedProperty<'underline' | 'overline' | 'line-through' | 'none'>(['textDecoration', 'textUnderlineOffset'], (value) => ({
textDecoration: value === 'none' ? 'none' : `${value} ${getToken('text-underline-thickness')}`,
textUnderlineOffset: value === 'underline' ? getToken('text-underline-gap') : undefined
})),
textOverflow: ['ellipsis', 'clip'] as const,
lineClamp: new ExpandedProperty<number>(['overflow', 'display', '-webkit-box-orient', '-webkit-line-clamp'], (value) => ({
overflow: 'hidden',
display: '-webkit-box',
'-webkit-box-orient': 'vertical',
'-webkit-line-clamp': String(value)
})),
hyphens: ['none', 'manual', 'auto'] as const,
whiteSpace: ['normal', 'nowrap', 'pre', 'pre-line', 'pre-wrap', 'break-spaces'] as const,
textWrap: ['wrap', 'nowrap', 'balance', 'pretty'] as const,
wordBreak: ['normal', 'break-all', 'keep-all'] as const, // also overflowWrap??
boxDecorationBreak: ['slice', 'clone'] as const,
// effects
boxShadow: {
emphasized: `${getToken('drop-shadow-emphasized-default-x')} ${getToken('drop-shadow-emphasized-default-y')} ${getToken('drop-shadow-emphasized-default-blur')} ${rawColorToken('drop-shadow-emphasized-default-color')}`,
elevated: `${getToken('drop-shadow-elevated-x')} ${getToken('drop-shadow-elevated-y')} ${getToken('drop-shadow-elevated-blur')} ${rawColorToken('drop-shadow-elevated-color')}`,
dragged: `${getToken('drop-shadow-dragged-x')} ${getToken('drop-shadow-dragged-y')} ${getToken('drop-shadow-dragged-blur')} ${rawColorToken('drop-shadow-dragged-color')}`,
none: 'none'
},
filter: {
emphasized: `drop-shadow(${getToken('drop-shadow-emphasized-default-x')} ${getToken('drop-shadow-emphasized-default-y')} ${getToken('drop-shadow-emphasized-default-blur')} ${rawColorToken('drop-shadow-emphasized-default-color')})`,
elevated: `drop-shadow(${getToken('drop-shadow-elevated-x')} ${getToken('drop-shadow-elevated-y')} ${getToken('drop-shadow-elevated-blur')} ${rawColorToken('drop-shadow-elevated-color')})`,
dragged: `drop-shadow${getToken('drop-shadow-dragged-x')} ${getToken('drop-shadow-dragged-y')} ${getToken('drop-shadow-dragged-blur')} ${rawColorToken('drop-shadow-dragged-color')}`,
none: 'none'
},
borderTopStartRadius: new MappedProperty('borderStartStartRadius', radius),
borderTopEndRadius: new MappedProperty('borderStartEndRadius', radius),
borderBottomStartRadius: new MappedProperty('borderEndStartRadius', radius),
borderBottomEndRadius: new MappedProperty('borderEndEndRadius', radius),
forcedColorAdjust: ['auto', 'none'] as const,
colorScheme: ['light', 'dark', 'light dark'] as const,
backgroundImage: new ExpandedProperty<string | [LinearGradient]>(['backgroundImage', '--g0', '--g1', '--g2', '--gp'], (value) => {
if (typeof value === 'string') {
return {backgroundImage: value};
} else if (Array.isArray(value) && value[0]?.type === 'linear-gradient') {
let values: CSSProperties = {
backgroundImage: `linear-gradient(${value[0].angle}, ${value[0].stops.map(([, stop], i) => `var(--g${i}) ${stop}%`)})`
};
// Create a CSS var for each color stop so the gradient can be transitioned.
// These are registered via @property in the `linearGradient` macro.
let properties: string[] = [];
value[0].stops.forEach(([colorValue], i) => {
properties.push(`--g${i}`);
values[`--g${i}`] = color(colorValue);
});
// This is used by transition-property so we automatically transition all of the color stops.
values['--gp'] = properties.join(', ');
return values;
} else {
throw new Error('Unexpected backgroundImage value: ' + JSON.stringify(value));
}
}),
// TODO: do we need separate x and y properties?
backgroundPosition: ['bottom', 'center', 'left', 'left bottom', 'left top', 'right', 'right bottom', 'right top', 'top'] as const,
backgroundSize: ['auto', 'cover', 'contain'] as const,
backgroundAttachment: ['fixed', 'local', 'scroll'] as const,
backgroundClip: ['border-box', 'padding-box', 'content-box', 'text'] as const,
backgroundRepeat: ['repeat', 'no-repeat', 'repeat-x', 'repeat-y', 'round', 'space'] as const,
backgroundOrigin: ['border-box', 'padding-box', 'content-box'] as const,
backgroundBlendMode: ['normal', 'multiply', 'screen', 'overlay', 'darken', 'lighten', 'color-dodge', 'color-burn', 'hard-light', 'soft-light', 'difference', 'exclusion', 'hue', 'saturation', 'color', 'luminosity'] as const,
mixBlendMode: ['normal', 'multiply', 'screen', 'overlay', 'darken', 'lighten', 'color-dodge', 'color-burn', 'hard-light', 'soft-light', 'difference', 'exclusion', 'hue', 'saturation', 'color', 'luminosity', 'plus-darker', 'plus-lighter'] as const,
opacity: new ArbitraryProperty<number>('opacity'),
outlineStyle: ['none', 'solid', 'dashed', 'dotted', 'double', 'inset'] as const,
outlineOffset: new ArbitraryProperty<number>('outlineOffset', v => `${v}px`),
outlineWidth: borderWidth,
transition: new MappedProperty('transitionProperty', transitionProperty),
transitionDelay: new ArbitraryProperty('transitionDelay', durationValue),
transitionDuration: new ArbitraryProperty('transitionDuration', durationValue),
transitionTimingFunction: timingFunction,
animation: new ArbitraryProperty<string>('animationName'),
animationDuration: new ArbitraryProperty('animationDuration', durationValue),
animationDelay: new ArbitraryProperty('animationDelay', durationValue),
animationDirection: ['normal', 'reverse', 'alternate', 'alternate-reverse'] as const,
animationFillMode: ['none', 'forwards', 'backwards', 'both'] as const,
animationIterationCount: new ArbitraryProperty<number | string>('animationIterationCount'),
animationTimingFunction: timingFunction,
// layout
display: ['block', 'inline-block', 'inline', 'flex', 'inline-flex', 'grid', 'inline-grid', 'contents', 'list-item', 'none'] as const, // tables?
alignContent: ['normal', 'center', 'start', 'end', 'space-between', 'space-around', 'space-evenly', 'baseline', 'stretch'] as const,
alignItems: ['start', 'end', 'center', 'baseline', 'stretch'] as const,
justifyContent: ['normal', 'start', 'end', 'center', 'space-between', 'space-around', 'space-evenly', 'stretch'] as const,
justifyItems: ['start', 'end', 'center', 'stretch'] as const,
alignSelf: ['auto', 'start', 'end', 'center', 'stretch', 'baseline'] as const,
justifySelf: ['auto', 'start', 'end', 'center', 'stretch'] as const,
flexDirection: ['row', 'column', 'row-reverse', 'column-reverse'] as const,
flexWrap: ['wrap', 'wrap-reverse', 'nowrap'] as const,
flexShrink: new ArbitraryProperty<CSS.Property.FlexShrink>('flexShrink'),
flexGrow: new ArbitraryProperty<CSS.Property.FlexGrow>('flexGrow'),
gridColumnStart: new ArbitraryProperty<CSS.Property.GridColumnStart>('gridColumnStart'),
gridColumnEnd: new ArbitraryProperty<CSS.Property.GridColumnEnd>('gridColumnEnd'),
gridRowStart: new ArbitraryProperty<CSS.Property.GridRowStart>('gridRowStart'),
gridRowEnd: new ArbitraryProperty<CSS.Property.GridRowEnd>('gridRowEnd'),
gridAutoFlow: ['row', 'column', 'dense', 'row dense', 'column dense'] as const,
gridAutoRows: new ArbitraryProperty('gridAutoRows', gridTrackSize),
gridAutoColumns: new ArbitraryProperty('gridAutoColumns', gridTrackSize),
gridTemplateColumns: new ArbitraryProperty('gridTemplateColumns', gridTrack),
gridTemplateRows: new ArbitraryProperty('gridTemplateRows', gridTrack),
gridTemplateAreas: new ArbitraryProperty('gridTemplateAreas', (value: readonly string[]) => value.map(v => `"${v}"`).join('')),
float: ['inline-start', 'inline-end', 'right', 'left', 'none'] as const,
clear: ['inline-start', 'inline-end', 'left', 'right', 'both', 'none'] as const,
contain: ['none', 'strict', 'content', 'size', 'inline-size', 'layout', 'style', 'paint'] as const,
boxSizing: ['border-box', 'content-box'] as const,
tableLayout: ['auto', 'fixed'] as const,
captionSide: ['top', 'bottom'] as const,
borderCollapse: ['collapse', 'separate'] as const,
breakBefore: ['auto', 'avoid', 'all', 'avoid-page', 'page', 'left', 'right', 'column'] as const,
breakInside: ['auto', 'avoid', 'avoid-page', 'avoid-column'] as const,
breakAfter: ['auto', 'avoid', 'all', 'avoid-page', 'page', 'left', 'right', 'column'] as const,
overflowX: ['auto', 'hidden', 'clip', 'visible', 'scroll'] as const,
overflowY: ['auto', 'hidden', 'clip', 'visible', 'scroll'] as const,
overscrollBehaviorX: ['auto', 'contain', 'none'] as const,
overscrollBehaviorY: ['auto', 'contain', 'none'] as const,
scrollBehavior: ['auto', 'smooth'] as const,
order: new ArbitraryProperty<number>('order'),
pointerEvents: ['none', 'auto'] as const,
touchAction: ['auto', 'none', 'pan-x', 'pan-y', 'manipulation', 'pinch-zoom'] as const,
userSelect: ['none', 'text', 'all', 'auto'] as const,
visibility: ['visible', 'hidden', 'collapse'] as const,
isolation: ['isolate', 'auto'] as const,
transformOrigin: ['center', 'top', 'top right', 'right', 'bottom right', 'bottom', 'bottom left', 'left', 'top right'] as const,
cursor: ['auto', 'default', 'pointer', 'wait', 'text', 'move', 'help', 'not-allowed', 'none', 'context-menu', 'progress', 'cell', 'crosshair', 'vertical-text', 'alias', 'copy', 'no-drop', 'grab', 'grabbing', 'all-scroll', 'col-resize', 'row-resize', 'n-resize', 'e-resize', 's-resize', 'w-resize', 'ne-resize', 'nw-resize', 'se-resize', 'ew-resize', 'ns-resize', 'nesw-resize', 'nwse-resize', 'zoom-in', 'zoom-out'] as const,
resize: ['none', 'vertical', 'horizontal', 'both'] as const,
scrollSnapType: ['x', 'y', 'both', 'x mandatory', 'y mandatory', 'both mandatory'] as const,
scrollSnapAlign: ['start', 'end', 'center', 'none'] as const,
scrollSnapStop: ['normal', 'always'] as const,
appearance: ['none', 'auto'] as const,
objectFit: ['contain', 'cover', 'fill', 'none', 'scale-down'] as const,
objectPosition: ['bottom', 'center', 'left', 'left bottom', 'left top', 'right', 'right bottom', 'right top', 'top'] as const,
willChange: ['auto', 'scroll-position', 'contents', 'transform'] as const,
zIndex: new ArbitraryProperty<number>('zIndex'),
// eslint-disable-next-line @typescript-eslint/no-unused-vars
disableTapHighlight: new ArbitraryProperty('-webkit-tap-highlight-color', (_value: true) => 'rgba(0,0,0,0)'),
unicodeBidi: ['normal', 'embed', 'bidi-override', 'isolate', 'isolate-override', 'plaintext'] as const
},
shorthands: {
padding: ['paddingTop', 'paddingBottom', 'paddingStart', 'paddingEnd'] as const,
paddingX: ['paddingStart', 'paddingEnd'] as const,
paddingY: ['paddingTop', 'paddingBottom'] as const,
margin: ['marginTop', 'marginBottom', 'marginStart', 'marginEnd'] as const,
marginX: ['marginStart', 'marginEnd'] as const,
marginY: ['marginTop', 'marginBottom'] as const,
scrollPadding: ['scrollPaddingTop', 'scrollPaddingBottom', 'scrollPaddingStart', 'scrollPaddingEnd'] as const,
scrollPaddingX: ['scrollPaddingStart', 'scrollPaddingEnd'] as const,
scrollPaddingY: ['scrollPaddingTop', 'scrollPaddingBottom'] as const,
scrollMargin: ['scrollMarginTop', 'scrollMarginBottom', 'scrollMarginStart', 'scrollMarginEnd'] as const,
scrollMarginX: ['scrollMarginStart', 'scrollMarginEnd'] as const,
scrollMarginY: ['scrollMarginTop', 'scrollMarginBottom'] as const,
borderWidth: ['borderTopWidth', 'borderBottomWidth', 'borderStartWidth', 'borderEndWidth'] as const,
borderXWidth: ['borderStartWidth', 'borderEndWidth'] as const,
borderYWidth: ['borderTopWidth', 'borderBottomWidth'] as const,
borderRadius: ['borderTopStartRadius', 'borderTopEndRadius', 'borderBottomStartRadius', 'borderBottomEndRadius'] as const,
borderTopRadius: ['borderTopStartRadius', 'borderTopEndRadius'] as const,
borderBottomRadius: ['borderBottomStartRadius', 'borderBottomEndRadius'] as const,
borderStartRadius: ['borderTopStartRadius', 'borderBottomStartRadius'] as const,
borderEndRadius: ['borderTopEndRadius', 'borderBottomEndRadius'] as const,
translate: ['translateX', 'translateY'] as const,
scale: ['scaleX', 'scaleY'] as const,
inset: ['top', 'bottom', 'insetStart', 'insetEnd'] as const,
insetX: ['insetStart', 'insetEnd'] as const,
insetY: ['top', 'bottom'] as const,
placeItems: ['alignItems', 'justifyItems'] as const,
placeContent: ['alignContent', 'justifyContent'] as const,
placeSelf: ['alignSelf', 'justifySelf'] as const,
gap: ['rowGap', 'columnGap'] as const,
size: ['width', 'height'] as const,
minSize: ['minWidth', 'minHeight'] as const,
maxSize: ['maxWidth', 'maxHeight'] as const,
overflow: ['overflowX', 'overflowY'] as const,
overscrollBehavior: ['overscrollBehaviorX', 'overscrollBehaviorY'] as const,
gridArea: ['gridColumnStart', 'gridColumnEnd', 'gridRowStart', 'gridRowEnd'] as const,
transition: (value: keyof typeof transitionProperty) => ({
transition: value,
transitionDuration: 150,
transitionTimingFunction: 'default'
}),
animation: (value: string) => ({
animation: value,
animationDuration: 150,
animationTimingFunction: 'default'
}),
// eslint-disable-next-line @typescript-eslint/no-unused-vars
truncate: (_value: true) => ({
overflowX: 'hidden',
overflowY: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
}),
font: (value: keyof typeof fontSize) => {
let type = value.split('-')[0];
return {
fontFamily: type === 'code' ? 'code' : 'sans',
fontSize: value,
fontWeight: type === 'heading' || type === 'title' || type === 'detail' ? type : 'normal',
lineHeight: type,
color: type === 'ui' ? 'body' : type
};
}
},
conditions: {
forcedColors: '@media (forced-colors: active)',
// This detects touch primary devices as best as we can.
// Ideally we'd use (pointer: course) but browser/device support is inconsistent.
// Samsung Android devices claim to be mice at the hardware/OS level: (any-pointer: fine), (any-hover: hover), (hover: hover), and nothing for pointer.
// More details: https://www.ctrl.blog/entry/css-media-hover-samsung.html
// iPhone matches (any-hover: none), (hover: none), and nothing for any-pointer or pointer.
// If a trackpad or Apple Pencil is connected to iPad, it matches (any-pointer: fine), (any-hover: hover), (hover: none).
// Windows tablet matches the same as iPhone. No difference when a mouse is connected.
// Windows touch laptop matches same as macOS: (any-pointer: fine), (pointer: fine), (any-hover: hover), (hover: hover).
touch: '@media not ((hover: hover) and (pointer: fine))',
sm: `@media (min-width: ${pxToRem(640)})`,
md: `@media (min-width: ${pxToRem(768)})`,
lg: `@media (min-width: ${pxToRem(1024)})`,
xl: `@media (min-width: ${pxToRem(1280)})`,
'2xl': `@media (min-width: ${pxToRem(1536)})`
}
});