diff --git a/.changepacks/changepack_log_types_match_build.json b/.changepacks/changepack_log_types_match_build.json new file mode 100644 index 000000000..2865577c2 --- /dev/null +++ b/.changepacks/changepack_log_types_match_build.json @@ -0,0 +1,8 @@ +{ + "changes": { + "packages/react/package.json": "Patch", + "packages/plugin-utils/package.json": "Patch" + }, + "note": "Types now accept what the build compiles and nothing it rejects. css() takes classes, style objects, arrays and falsy parts, and the css/keyframes/globalCss tags take the static interpolations the build evaluates. styled gets .attrs(), .withConfig() and .withComponent(), and its result renders another tag. styleVars takes numbers; styleOrder is 1 to 254; ResponsiveValue is exported for hand-written custom shorthands. The compat declarations name what Emotion, styled-components and vanilla-extract give (SerializedStyles, Keyframes, RuleSet, an augmentable Theme and DefaultTheme, CSSObject and the other styled-components types) and follow the vanilla-extract APIs the evaluator registers (style arrays, the styleVariants mapper, createGlobalTheme, createGlobalThemeContract; globalKeyframes, composeStyles and generateIdentifier are gone). DevupTheme takes shadow as devup.json documents it. No new build errors.", + "date": "2026-10-01T00:00:00.000Z" +} \ No newline at end of file diff --git a/packages/plugin-utils/src/__tests__/theme-type.test.ts b/packages/plugin-utils/src/__tests__/theme-type.test.ts new file mode 100644 index 000000000..1c495e626 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/theme-type.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'bun:test' + +import type { DevupConfig, DevupTheme } from '../types' + +describe('DevupTheme', () => { + it('spells the shadow tokens as the docs and the engine do', () => { + const shadow = { default: { card: '0 1px 2px #0003' } } + const documented: DevupTheme = { shadow } + const alias: DevupTheme = { shadows: shadow } + const config: DevupConfig = { theme: { shadow } } + + expect(documented.shadow).toBe(shadow) + expect(alias.shadows).toBe(shadow) + expect(config.theme?.shadow).toBe(shadow) + }) +}) diff --git a/packages/plugin-utils/src/types.ts b/packages/plugin-utils/src/types.ts index 2a441a433..f0c251b4b 100644 --- a/packages/plugin-utils/src/types.ts +++ b/packages/plugin-utils/src/types.ts @@ -57,6 +57,9 @@ export interface DevupTheme { breakpoints?: number[] typography?: ThemeTypography length?: ThemeLength + /** Shadow tokens, as the docs and devup.json schema spell them */ + shadow?: ThemeShadows + /** The engine also reads this spelling; prefer shadow */ shadows?: ThemeShadows } diff --git a/packages/react/package.json b/packages/react/package.json index 9422f7176..b451f1692 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -21,7 +21,7 @@ "scripts": { "lint": "eslint && bun ../../node_modules/@typescript/native/bin/tsc --noEmit && bun run test:types", "build": "bun run test:types && bun ../../node_modules/@typescript/native/bin/tsc && vite build", - "test:types": "bun ../../node_modules/@typescript/native/bin/tsc --ignoreConfig --noEmit --strict --jsx react-jsx --moduleResolution bundler --module esnext --target esnext --types bun --skipLibCheck type-tests/custom-shorthand.ts type-tests/class-names.ts" + "test:types": "bun ../../node_modules/@typescript/native/bin/tsc -p type-tests/tsconfig.json" }, "publishConfig": { "access": "public" diff --git a/packages/react/src/compat/emotion.d.ts b/packages/react/src/compat/emotion.d.ts index 591f8f076..af350741b 100644 --- a/packages/react/src/compat/emotion.d.ts +++ b/packages/react/src/compat/emotion.d.ts @@ -13,17 +13,73 @@ declare module '@emotion/css' { } declare module '@emotion/react' { - export { css, keyframes } from '@devup-ui/react' - export type { StyledTheme as Theme } from '@devup-ui/react/compat' - export type { ClassNamesArg, ClassNamesContent } from '@devup-ui/react/compat' + import type { + CssPart, + KeyframesProps, + StyleTemplateValue, + } from '@devup-ui/react' + import type { + ClassNamesArg, + ClassNamesContent, + StyledTheme, + } from '@devup-ui/react/compat' + import type { + ComponentType, + ForwardRefExoticComponent, + PropsWithoutRef, + ReactNode, + RefAttributes, + } from 'react' + + const serializedStyles: unique symbol + const keyframesName: unique symbol + + /** + * What `css` gives: the build writes the class it makes of the styles in its + * place, so the value is that class, not an object with `name` and `styles`. + */ + export type SerializedStyles = string & { + readonly [serializedStyles]: true + } + + /** What `keyframes` gives: the name the build gives the animation. */ + export type Keyframes = string & { readonly [keyframesName]: true } + + /** The theme `ThemeProvider` publishes as CSS variables; augment it as in Emotion. */ + // eslint-disable-next-line @typescript-eslint/no-empty-object-type + export interface Theme extends StyledTheme {} + + export type { ClassNamesArg, ClassNamesContent } + + export function css( + strings: TemplateStringsArray, + ...values: StyleTemplateValue[] + ): SerializedStyles + export function css(...parts: CssPart[]): SerializedStyles + + export function keyframes(props: KeyframesProps): Keyframes + export function keyframes( + strings: TemplateStringsArray, + ...values: StyleTemplateValue[] + ): Keyframes + + export function useTheme(): Theme + export function withTheme

( + component: ComponentType

, + ): ForwardRefExoticComponent< + PropsWithoutRef & { theme?: Theme }> & + RefAttributes + > + export function ThemeProvider(props: { + theme?: Theme | ((outer: Theme | undefined) => Theme) + children?: ReactNode + }): ReactNode + export { CacheProvider, ClassNames, jsx as createElement, Global, jsx, - ThemeProvider, - useTheme, - withTheme, } from '@devup-ui/react/compat' } diff --git a/packages/react/src/compat/styled-components.d.ts b/packages/react/src/compat/styled-components.d.ts index abf6a3f06..e1c85a9d0 100644 --- a/packages/react/src/compat/styled-components.d.ts +++ b/packages/react/src/compat/styled-components.d.ts @@ -1,18 +1,84 @@ declare module 'styled-components' { - import { styled } from '@devup-ui/react' + import { + type CssPart, + type DevupProps, + type KeyframesProps, + styled, + type StyleTemplateValue, + } from '@devup-ui/react' + import type { StyledTheme } from '@devup-ui/react/compat' + import type { Properties } from 'csstype-extra' + import type { + ComponentType, + Context, + ForwardRefExoticComponent, + PropsWithoutRef, + ReactNode, + RefAttributes, + } from 'react' + + const ruleSet: unique symbol + const keyframesName: unique symbol + + /** + * What `css` gives: the build writes the class it makes of the rules in its + * place, so the value is that class and only the build-known parts compose it. + */ + export type RuleSet = string & { readonly [ruleSet]: true } + + /** What `keyframes` gives: the name the build gives the animation. */ + export type Keyframes = string & { readonly [keyframesName]: true } + + /** The theme `ThemeProvider` publishes as CSS variables; augment it as in styled-components. */ + // eslint-disable-next-line @typescript-eslint/no-empty-object-type + export interface DefaultTheme extends StyledTheme {} + + export type CSSProperties = Properties + export type CSSObject = DevupProps + export type CSSPseudos = Record<`:${string}`, CSSObject> + export type CSSKeyframes = KeyframesProps + export type CSSProp = CssPart + + export function css(props: DevupProps): RuleSet + export function css( + strings: TemplateStringsArray, + ...values: StyleTemplateValue[] + ): RuleSet + export function css(...parts: CssPart[]): RuleSet + + export function keyframes(props: KeyframesProps): Keyframes + export function keyframes( + strings: TemplateStringsArray, + ...values: StyleTemplateValue[] + ): Keyframes + + export function useTheme(): DefaultTheme + export function withTheme

( + component: ComponentType

, + ): ForwardRefExoticComponent< + PropsWithoutRef & { theme?: DefaultTheme }> & + RefAttributes + > + export function ThemeProvider(props: { + theme?: DefaultTheme | ((outer: DefaultTheme | undefined) => DefaultTheme) + children?: ReactNode + }): ReactNode + export function ThemeConsumer(props: { + children: (theme: DefaultTheme) => ReactNode + }): ReactNode + + /** + * Not compiled: the build keeps this import on `styled-components`, which + * must then be installed. + */ + export const ThemeContext: Context - export { css, keyframes } from '@devup-ui/react' - export type { StyledTheme as DefaultTheme } from '@devup-ui/react/compat' export { createGlobalStyle, createTheme, isStyledComponent, ServerStyleSheet, StyleSheetManager, - ThemeConsumer, - ThemeProvider, - useTheme, - withTheme, } from '@devup-ui/react/compat' export { styled } diff --git a/packages/react/src/compat/vanilla-extract.d.ts b/packages/react/src/compat/vanilla-extract.d.ts index 13ddd2357..802791f4f 100644 --- a/packages/react/src/compat/vanilla-extract.d.ts +++ b/packages/react/src/compat/vanilla-extract.d.ts @@ -3,6 +3,9 @@ declare module '@vanilla-extract/css' { type VanillaRule = Record + /** A rule, or rules composed in order: later declarations replace earlier ones */ + type VanillaStyle = VanillaRule | string | readonly VanillaStyle[] + /** * Keeps vanilla-extract's own two-argument shape. The extractor folds the * selector back into the object `globalCss` takes, so the call compiles even @@ -13,26 +16,44 @@ declare module '@vanilla-extract/css' { /** * Declared but not rewritten: these only run inside `.css.ts` / `.css.js` * stylesheets, where the extractor evaluates the module and replaces every - * call with its generated output. + * call with its generated output. Each is one of the APIs the evaluator + * registers. */ - export function styleVariants>( + export function styleVariants>( variants: T, ): Record + export function styleVariants< + T extends Record, + Key extends keyof T = keyof T, + >( + data: T, + mapData: (value: T[Key], key: Key) => VanillaStyle, + ): Record export function createVar(): string export function fallbackVar(...values: string[]): string export function fontFace(rule: VanillaRule): string export function globalFontFace(name: string, rule: VanillaRule): void - export function globalKeyframes(name: string, frames: VanillaRule): void export function createTheme(contract: T, values: unknown): string export function createTheme(values: T): [string, T] + export function createGlobalTheme( + selector: string, + values: T, + ): T + export function createGlobalTheme( + selector: string, + contract: T, + values: unknown, + ): void export function createThemeContract(shape: T): T + export function createGlobalThemeContract( + tokens: T, + mapFn?: (value: unknown, path: string[]) => string, + ): T export function assignVars( contract: unknown, values: unknown, ): Record - export function composeStyles(...classNames: string[]): string export function layer(options?: unknown): string export function globalLayer(options: unknown, name?: string): string export function createContainer(): string - export function generateIdentifier(debugId?: string): string } diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index f81ed198a..1de5d171a 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -10,16 +10,19 @@ export { ThemeScript } from './components/ThemeScript' export { VStack } from './components/VStack' export { useTheme } from './hooks/use-theme' export type { DevupProps } from './types/props' +export type { ResponsiveValue } from './types/responsive-value' export type { DevupCustomShorthands } from './types/shorthand' export type { DevupTheme } from './types/theme' export type { DevupThemeTypography, DevupThemeTypographyKeys, } from './types/typography' +export type { CssPart, StyleTemplateValue } from './utils/css' export { css } from './utils/css' export { getTheme } from './utils/get-theme' export { globalCss } from './utils/global-css' export { initTheme } from './utils/init-theme' +export type { KeyframesProps } from './utils/keyframes' export { keyframes } from './utils/keyframes' export { setTheme } from './utils/set-theme' export { styled } from './utils/styled' diff --git a/packages/react/src/types/props/index.ts b/packages/react/src/types/props/index.ts index 2972d17aa..c2646b9b5 100644 --- a/packages/react/src/types/props/index.ts +++ b/packages/react/src/types/props/index.ts @@ -44,7 +44,7 @@ export interface DevupComponentProps< T extends React.ElementType, > extends DevupPropsWithTheme { as?: T - styleVars?: Record + styleVars?: Record } export type DevupComponentMergedProps = Merge< diff --git a/packages/react/src/types/props/selector/index.ts b/packages/react/src/types/props/selector/index.ts index 81d19ad0c..f8ab9c576 100644 --- a/packages/react/src/types/props/selector/index.ts +++ b/packages/react/src/types/props/selector/index.ts @@ -4,6 +4,14 @@ import type { ResponsiveValue } from '../../responsive-value' import type { DevupTheme } from '../../theme' import type { DevupProps } from '../index' +type Range< + N extends number, + Acc extends number[] = [], +> = Acc['length'] extends N ? Acc[number] : Range + +/** The cascade layer orders the build places a style in: 1 to 254, written as a number or as its digits. Any other value is ignored by the build. */ +export type StyleOrder = Exclude, 0> + export type CamelCase = S extends Lowercase ? S extends `${infer F}-${infer RF}${infer R}` @@ -108,5 +116,5 @@ export interface DevupSelectorProps selectors?: Selectors - styleOrder?: number + styleOrder?: StyleOrder | `${StyleOrder}` } diff --git a/packages/react/src/utils/css.ts b/packages/react/src/utils/css.ts index 5ae6ffaea..5f5100daf 100644 --- a/packages/react/src/utils/css.ts +++ b/packages/react/src/utils/css.ts @@ -1,11 +1,19 @@ import type { DevupPropsWithTheme } from '../types/props' -export function css(props: DevupPropsWithTheme): string -export function css(strings: TemplateStringsArray): string -export function css(): string +/** What a tagged `css`, `keyframes` or `globalCss` template takes between its text: values the build knows, and what `css()` and `keyframes()` give */ +export type StyleTemplateValue = string | number | boolean | null | undefined + +/** A part `css()` composes: a style object, a class (another `css()` result or a plain class), a falsy part the build skips, or an array of parts */ +export type CssPart = + DevupPropsWithTheme | string | false | null | undefined | readonly CssPart[] +export function css(props: DevupPropsWithTheme): string export function css( - _strings?: TemplateStringsArray | DevupPropsWithTheme, -): string { + strings: TemplateStringsArray, + ...values: StyleTemplateValue[] +): string +export function css(...parts: CssPart[]): string + +export function css(..._parts: unknown[]): string { throw new Error('Cannot run on the runtime') } diff --git a/packages/react/src/utils/global-css.ts b/packages/react/src/utils/global-css.ts index dd86476c8..6a6a5757b 100644 --- a/packages/react/src/utils/global-css.ts +++ b/packages/react/src/utils/global-css.ts @@ -9,6 +9,7 @@ import type { MediaShorthand, SimpleSelector, } from '../types/props/selector' +import type { StyleTemplateValue } from './css' type GlobalCssKeys = | `*${T}` @@ -87,7 +88,10 @@ export function globalCss( >, ): void -export function globalCss(strings?: TemplateStringsArray): void +export function globalCss( + strings?: TemplateStringsArray, + ...values: StyleTemplateValue[] +): void export function globalCss(): void @@ -99,6 +103,7 @@ export function globalCss( string, DevupCommonProps & DevupSelectorProps & DevupThemeSelectorProps >, + ..._values: StyleTemplateValue[] ): void { throw new Error('Cannot run on the runtime') } diff --git a/packages/react/src/utils/keyframes.ts b/packages/react/src/utils/keyframes.ts index a2f365aa4..c15fc19b1 100644 --- a/packages/react/src/utils/keyframes.ts +++ b/packages/react/src/utils/keyframes.ts @@ -1,6 +1,7 @@ import type { DevupCommonProps } from '../types/props' +import type { StyleTemplateValue } from './css' -interface KeyframesProps { +export interface KeyframesProps { from?: DevupCommonProps | string to?: DevupCommonProps | string [key: `${number}%`]: DevupCommonProps | string @@ -8,11 +9,15 @@ interface KeyframesProps { export function keyframes(props: KeyframesProps): string export function keyframes(props: Record): string -export function keyframes(strings: TemplateStringsArray): string +export function keyframes( + strings: TemplateStringsArray, + ...values: StyleTemplateValue[] +): string export function keyframes(): string export function keyframes( _strings?: TemplateStringsArray | KeyframesProps, + ..._values: StyleTemplateValue[] ): string { throw new Error('Cannot run on the runtime') } diff --git a/packages/react/src/utils/styled.ts b/packages/react/src/utils/styled.ts index 66d22c84a..de00527b8 100644 --- a/packages/react/src/utils/styled.ts +++ b/packages/react/src/utils/styled.ts @@ -18,24 +18,54 @@ type Interpolation = | null | undefined +type StyledProps = P & + React.ComponentProps + +/** What `.withConfig()` takes: the props to forward, and names the build ignores */ +export interface StyledConfig { + shouldForwardProp?: ( + prop: string, + defaultValidatorFn: (prop: string) => boolean, + ) => boolean + displayName?: string + componentId?: string +} + +/** A component the build generates from `styled`, which can render another tag with the same styles */ +export interface StyledComponent

{ + (props: P): React.ReactElement + withComponent( + tag: U, + ): StyledComponent> +} + +/** What `styled.div`, `styled('div')` and what `.attrs()` and `.withConfig()` give: rules or CSS text come next */ +export interface StyledTemplate< + T extends React.ElementType | React.ComponentType, + B = unknown, +> { +

>( + strings: TemplateStringsArray | DevupPropsWithTheme, + ...values: Interpolation

[] + ): StyledComponent

> + attrs( + attrs: + | Partial> + | ((props: InterpolationProps) => Partial>), + ): StyledTemplate + withConfig(config: StyledConfig): StyledTemplate +} + interface StyledCreator { ( tag: T, styles: DevupPropsWithTheme, - ): (props: React.ComponentProps) => React.ReactElement - ( - tag: T, - ): ( - strings: TemplateStringsArray | DevupPropsWithTheme, - ...values: Interpolation[][] - ) => (props: React.ComponentProps) => React.ReactElement + ): StyledComponent> + (tag: T): StyledTemplate } type Styled = StyledCreator & { - [T in keyof React.JSX.IntrinsicElements]:

>( - strings: TemplateStringsArray | DevupPropsWithTheme, - ...values: Interpolation[] - ) => (props: P & React.ComponentProps) => React.ReactElement + [T in keyof React.JSX.IntrinsicElements]: StyledTemplate } export const styled: Styled = new Proxy(Function.prototype, { diff --git a/packages/react/type-tests/compat-theme.tsx b/packages/react/type-tests/compat-theme.tsx new file mode 100644 index 000000000..e47a8cba6 --- /dev/null +++ b/packages/react/type-tests/compat-theme.tsx @@ -0,0 +1,144 @@ +import { + type Theme, + ThemeProvider as EmotionThemeProvider, + withTheme as withEmotionTheme, +} from '@emotion/react' +import type { ComponentProps, ReactNode, Ref, RefAttributes } from 'react' +import { + type DefaultTheme, + ThemeConsumer, + ThemeProvider, + withTheme, +} from 'styled-components' + +type Accepts = [Input] extends [Contract] ? true : false +type Equal = + (() => T extends Left ? 1 : 2) extends () => T extends Right ? 1 : 2 + ? true + : false +type RefProp = 'ref' extends keyof Props ? Props['ref'] : never + +// Uses the module augmentations in compat.tsx, without adding new theme keys. +const styledTheme: DefaultTheme = { colors: { primary: 'red' } } +const emotionTheme: Theme = { spacing: { gap: 4 } } +type StyledProviderTheme = ComponentProps['theme'] +type EmotionProviderTheme = ComponentProps['theme'] + +export const styledObjectProvider = +export const emotionObjectProvider = ( + +) +export const styledFunctionProvider = ( + ({ + colors: { primary: outer?.colors.primary ?? 'blue' }, + })} + /> +) +export const emotionFunctionProvider = ( + ({ + spacing: { gap: outer?.spacing.gap ?? 8 }, + })} + /> +) + +export const styledRejectsNumber: Accepts = false +export const emotionRejectsNumber: Accepts = false +export const styledRejectsCallbackResult: Accepts< + (outer: DefaultTheme | undefined) => number, + StyledProviderTheme +> = false +export const emotionRejectsCallbackResult: Accepts< + (outer: Theme | undefined) => number, + EmotionProviderTheme +> = false +export const styledRejectsRequiredOuter: Accepts< + (outer: DefaultTheme) => DefaultTheme, + StyledProviderTheme +> = false +export const emotionRejectsRequiredOuter: Accepts< + (outer: Theme) => Theme, + EmotionProviderTheme +> = false + +export const styledConsumer = ( + {(theme) =>

{theme.colors.primary}
} +) +type ConsumerTheme = Parameters< + ComponentProps['children'] +>[0] +export const consumerKeepsAugmentation: Equal = + true +export const consumerRejectsUndefined: Accepts = false + +interface StyledOwnProps { + readonly label: string + readonly theme: DefaultTheme +} +interface EmotionOwnProps { + readonly label: string + readonly theme: Theme +} +function StyledView(props: StyledOwnProps): ReactNode { + return
{props.label + props.theme.colors.primary}
+} +function EmotionView(props: EmotionOwnProps): ReactNode { + return
{props.label + props.theme.spacing.gap}
+} +const StyledWithTheme = withTheme(StyledView) +const EmotionWithTheme = withEmotionTheme(EmotionView) +type StyledWrapperProps = ComponentProps +type EmotionWrapperProps = ComponentProps +declare const forwardedRef: Ref + +export const styledInjected = +export const emotionInjected = +export const styledOverride = ( + +) +export const emotionOverride = ( + +) +// The parent forwardRef returns RefAttributes, not an inferred DOM ref. +export const styledKeepsParentRef: Equal< + RefProp, + RefAttributes['ref'] +> = true +export const emotionKeepsParentRef: Equal< + RefProp, + RefAttributes['ref'] +> = true + +export const styledRequiresOwnProp: Accepts< + Record, + StyledWrapperProps +> = false +export const emotionRequiresOwnProp: Accepts< + Record, + EmotionWrapperProps +> = false +export const styledRejectsOwnPropType: Accepts< + { readonly label: number }, + StyledWrapperProps +> = false +export const emotionRejectsOwnPropType: Accepts< + { readonly label: number }, + EmotionWrapperProps +> = false +export const styledRejectsThemeOverride: Accepts< + { readonly label: string; readonly theme: number }, + StyledWrapperProps +> = false +export const emotionRejectsThemeOverride: Accepts< + { readonly label: string; readonly theme: number }, + EmotionWrapperProps +> = false +export const styledRejectsRefNumber: Accepts< + { readonly label: string; readonly ref: number }, + StyledWrapperProps +> = false +export const emotionRejectsRefNumber: Accepts< + { readonly label: string; readonly ref: number }, + EmotionWrapperProps +> = false diff --git a/packages/react/type-tests/compat.tsx b/packages/react/type-tests/compat.tsx new file mode 100644 index 000000000..fa15b975b --- /dev/null +++ b/packages/react/type-tests/compat.tsx @@ -0,0 +1,183 @@ +/* eslint-disable @eslint-react/rules-of-hooks, react-hooks/rules-of-hooks, no-constant-binary-expression */ +import { + css as emotionCss, + Global, + type Keyframes as EmotionKeyframes, + keyframes as emotionKeyframes, + type SerializedStyles, + type Theme, + ThemeProvider as EmotionThemeProvider, + useTheme as useEmotionTheme, + withTheme as withEmotionTheme, +} from '@emotion/react' +import emotionStyled from '@emotion/styled' +import { + assignVars, + createGlobalTheme, + createGlobalThemeContract, + createTheme, + createThemeContract, + createVar, + fontFace, + globalFontFace, + globalStyle, + keyframes as vanillaKeyframes, + style, + styleVariants, +} from '@vanilla-extract/css' +import styled, { + createGlobalStyle, + css as styledCss, + type CSSKeyframes, + type CSSObject, + type CSSProp, + type CSSProperties, + type CSSPseudos, + type DefaultTheme, + type Keyframes, + keyframes as styledKeyframes, + type RuleSet, + ServerStyleSheet, + StyleSheetManager, + ThemeConsumer, + ThemeContext, + ThemeProvider, + useTheme, + withTheme, +} from 'styled-components' + +declare module 'styled-components' { + interface DefaultTheme { + colors: { primary: string } + } +} +declare module '@emotion/react' { + interface Theme { + spacing: { gap: number } + } +} + +// SC-16: the theme is an interface, so it is augmented as in styled-components. +const styledTheme: DefaultTheme = useTheme() +export const themed = styledTheme.colors.primary +// @ts-expect-error a key the augmentation does not declare is still a theme key, so a number is not a theme value +export const badThemeKey: DefaultTheme['colors'] = 1 +export const emotionTheme: Theme = useEmotionTheme() +export const gap = emotionTheme.spacing.gap +export const object: CSSObject = { p: 1, _hover: { bg: 'red' } } +export const properties: CSSProperties = { color: 'red' } +export const pseudos: CSSPseudos = { ':hover': { p: 1 } } +export const frames: CSSKeyframes = { from: { opacity: 0 } } +export const prop: CSSProp = [{ p: 1 }, false, 'a'] +export const consumer = ThemeConsumer +export const context = ThemeContext + +// TOOL-13: each library names what its `css` and `keyframes` give. +const serialized: SerializedStyles = emotionCss({ color: 'red' }) +const emotionFrames: EmotionKeyframes = emotionKeyframes({ + from: { opacity: 0 }, +}) +const rules: RuleSet = styledCss`color: red;` +const frameName: Keyframes = styledKeyframes`from { opacity: 0; }` +export const animation = emotionCss({ animationName: emotionFrames }) +export const composed = emotionCss( + serialized, + 'extra', + [{ p: 1 }, { m: 1 }], + null, +) +export const singleElementEmotionArrayAccepted: [ + SerializedStyles, + 'extra', + [{ p: 1 }], + null, +] extends Parameters + ? true + : false = true +export const sc = styledCss(rules, { p: 1 }) +export const fromString: string = serialized +export const named = [frameName, emotionFrames, rules] +// @ts-expect-error a plain string is not what `css` gives +export const notSerialized: SerializedStyles = 'color: red;' +// @ts-expect-error a plain string is not what `keyframes` gives +export const notFrames: Keyframes = 'fade' +// @ts-expect-error an Emotion result is not a styled-components rule set +export const crossed: RuleSet = serialized + +// TOOL-14 / TOOL-15: the build compiles these, and rejects the rest. +export const Button = styled.button.attrs({ type: 'button' })<{ + active: boolean +}>` + color: ${(props) => (props.active ? 'red' : 'blue')}; +` +export const EmotionButton = emotionStyled.div({ p: 1 }) +export const GlobalStyle = createGlobalStyle`body { margin: ${0}px; }` +export const WithTheme = withTheme((props: { theme?: DefaultTheme }) => ( +
{props.theme?.colors.primary}
+)) +export const EmotionWithTheme = withEmotionTheme((props: { theme?: Theme }) => ( +
{props.theme?.spacing.gap}
+)) +export const provider = ( + +
+ +) +export const emotionProvider = ( + + + +) +export const sheet = new ServerStyleSheet().collectStyles(
) +export const managed = ( + +
+ +) +const outerTheme = (theme: DefaultTheme | undefined): DefaultTheme => + theme ?? { colors: { primary: 'red' } } +const emotionOuterTheme = (theme: Theme | undefined): Theme => + theme ?? { spacing: { gap: 4 } } +export const outer = +export const emotionOuter = +// @ts-expect-error a function giving the styles is read at runtime +export const FunctionStyles = emotionStyled.div(() => ({ color: 'red' })) + +// TOOL-16 / VE-06: what the evaluator registers. +export const composedStyle = style([ + { color: 'red' }, + { padding: 4 }, + 'external', +]) +export const nested = style([[{ color: 'red' }], false && { margin: 0 }]) +export const single = style({ color: 'red' }) +export const variants = styleVariants({ + red: { color: 'red' }, + both: [{ p: 1 }, 'a'], +}) +export const mapped = styleVariants({ a: 1, b: 2 }, (value, key) => ({ + order: value, + '--key': key, +})) +export const variable = createVar() +const contract = createThemeContract({ color: null }) +export const themeTuple = createTheme(contract, { color: 'red' }) +export const themeClass = createTheme({ color: 'red' }) +export const assigned = assignVars(contract, { color: 'red' }) +export const globalContract = createGlobalThemeContract( + { color: 'color' }, + (value) => `prefix-${String(value)}`, +) +export const globalTheme = createGlobalTheme(':root', { color: 'red' }) +createGlobalTheme(':root', contract, { color: 'red' }) +export const family = fontFace({ src: 'url(font.woff2)' }) +globalStyle('body', { margin: 0 }) +globalFontFace('Inter', { src: 'url(font.woff2)' }) +export const vanillaAnimation = vanillaKeyframes({ from: { opacity: 0 } }) + +// @ts-expect-error the evaluator registers no `globalKeyframes` +export { globalKeyframes } from '@vanilla-extract/css' +// @ts-expect-error the evaluator registers no `composeStyles` +export { composeStyles } from '@vanilla-extract/css' +// @ts-expect-error the evaluator registers no `generateIdentifier` +export { generateIdentifier } from '@vanilla-extract/css' diff --git a/packages/react/type-tests/css-forms.ts b/packages/react/type-tests/css-forms.ts new file mode 100644 index 000000000..ba3c8aeed --- /dev/null +++ b/packages/react/type-tests/css-forms.ts @@ -0,0 +1,49 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions */ +import { css, globalCss, keyframes } from '../src' + +declare const on: boolean +declare const name: string | undefined +const base = css({ p: 1 }) + +// FN-01: `css()` composes classes, style objects, arrays and falsy parts. +css('external-class', base, { p: 3 }) +css(base, on && { m: 1 }, on ? 'a' : null, undefined, false) +css(name, on ? { p: 1 } : 'b') +css([base, { p: 1 }], [[{ m: 1 }]]) +css(...[{ p: 1 }, 'a']) +css() + +// @ts-expect-error a number is neither a style object nor a class +css(1) +// @ts-expect-error a function is read at runtime +css(() => ({ p: 1 })) +// @ts-expect-error a single object with an unknown key +css({ notAProperty: 1 }) +// @ts-expect-error unknown keys stay rejected next to other parts +css(base, { notAProperty: 1 }) + +// FN-02: interpolations the build evaluates. +const unit = 4 +const text = 'top: 0;' +css` + padding: ${unit * 4}px; + color: ${'red'}; + margin: ${base}; + ${on && text} +` +keyframes`from { opacity: ${0}; } to { opacity: ${1}; }` +globalCss`body { margin: ${0}px; }` + +// The rejected forms go through aliases, which keep the formatter off their text. +const cssTag = css +const keyframesTag = keyframes +const globalCssTag = globalCss +const runtimeValue = () => 'red' +const rules = { a: 1 } + +// @ts-expect-error a function is evaluated at runtime +cssTag`color: ${runtimeValue};` +// @ts-expect-error an object is not CSS text +keyframesTag`from { opacity: ${rules}; }` +// @ts-expect-error a function is evaluated at runtime +globalCssTag`body { color: ${runtimeValue}; }` diff --git a/packages/react/type-tests/props.tsx b/packages/react/type-tests/props.tsx new file mode 100644 index 000000000..64ffb7ada --- /dev/null +++ b/packages/react/type-tests/props.tsx @@ -0,0 +1,46 @@ +import { Box, css } from '../src' + +declare module '../src' { + interface DevupTheme { + dark: null + } +} + +// TOOL-12: a custom property takes a number as well as a string. +export const vars = ( + +) + +// TOOL-09: the layers the build places a style in run from 1 to 254, and it ignores any other value. +export const order = ( + <> + + + + +) +css({ styleOrder: 20, p: 1 }) + +declare const dynamic: number +// @ts-expect-error 0 is below the first layer +export const zero = +// @ts-expect-error 255 is above the last layer +export const high = +// @ts-expect-error a value known only at runtime is ignored by the build +export const runtime = +// @ts-expect-error a string that is not a layer number +export const junk = +// @ts-expect-error a value known only at runtime is ignored by the build +css({ styleOrder: dynamic }) + +// custom shorthands written by hand take the responsive forms through `ResponsiveValue` +declare module '../src' { + interface DevupCustomShorthands { + insetY?: import('../src').ResponsiveValue + } +} +export const shorthand = ( + +) +// @ts-expect-error an object is not a value of the shorthand +export const badShorthand = diff --git a/packages/react/type-tests/styled.tsx b/packages/react/type-tests/styled.tsx new file mode 100644 index 000000000..3cd0ceef4 --- /dev/null +++ b/packages/react/type-tests/styled.tsx @@ -0,0 +1,47 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions */ +import { Box, css, styled } from '../src' +import { createGlobalStyle } from '../src/compat' + +declare const active: boolean + +// TOOL-14: the forms the build compiles on a styled component. +export const Attrs = styled.button.attrs({ type: 'button' })<{ + $active: boolean +}>` + color: ${(props) => (props.$active ? 'red' : 'blue')}; +` +export const FromProps = styled.div.attrs((props) => ({ + role: props.theme ? 'note' : undefined, +}))({ p: 1 }) +export const Configured = styled.span + .withConfig({ + shouldForwardProp: (prop) => prop !== '$active', + displayName: 'Configured', + }) + .attrs({ title: 'a' })`color: red;` +export const Rendered = styled('div')` + color: red; +`.withComponent('aside') +export const OfBox = styled(Box).attrs({ p: 1 })({ m: 2 }) +export const Plain = styled('div', { p: 1 }) +export const Curried = styled.div({ bg: 'red', _hover: { bg: 'blue' } }) +export const Global = createGlobalStyle`body { margin: ${0}px; }` +export const element = ( + <> + + + + +) +export const className = css({ p: 1 }) + +// @ts-expect-error the attrs of a tag are props of that tag +styled.div.attrs({ href: '/docs' })`` +// @ts-expect-error `withConfig` takes the config object +styled.div.withConfig('name')`` +// @ts-expect-error styles must be a rule object, CSS text or a condition between them +styled.div(() => ({ color: 'red' })) +// @ts-expect-error a component renders a tag or a component, not a number +styled(1) +// @ts-expect-error a global style takes no function interpolation: it is evaluated at build time +createGlobalStyle`body { color: ${(props: { foreground: string }) => props.foreground}; }` diff --git a/packages/react/type-tests/tsconfig.json b/packages/react/type-tests/tsconfig.json new file mode 100644 index 000000000..063831d01 --- /dev/null +++ b/packages/react/type-tests/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "strict": true, + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "Bundler", + "jsx": "react-jsx", + "noEmit": true, + "skipLibCheck": true, + "types": ["bun"], + "paths": { + "@devup-ui/react": ["../src/index.ts"], + "@devup-ui/react/*": ["../src/*"] + } + }, + "files": [ + "../src/compat/css-prop.d.ts", + "../src/compat/emotion.d.ts", + "../src/compat/styled-components.d.ts", + "../src/compat/vanilla-extract.d.ts", + "../src/compat/stylex.d.ts" + ], + "include": ["*.ts", "*.tsx"] +}