diff --git a/.changeset/migrate-mosaic-input-to-stylex.md b/.changeset/migrate-mosaic-input-to-stylex.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/migrate-mosaic-input-to-stylex.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/swingset/src/stories/input.stories.tsx b/packages/swingset/src/stories/input.stories.tsx index 4220c7e6e10..be98937592e 100644 --- a/packages/swingset/src/stories/input.stories.tsx +++ b/packages/swingset/src/stories/input.stories.tsx @@ -1,6 +1,5 @@ -/** @jsxImportSource @emotion/react */ import type { InputProps } from '@clerk/ui/mosaic/components/input'; -import { Input, inputRecipe } from '@clerk/ui/mosaic/components/input'; +import { Input } from '@clerk/ui/mosaic/components/input'; import type { StoryMeta } from '@/lib/types'; @@ -11,8 +10,16 @@ export { default as __source } from './input.stories?raw'; export const meta: StoryMeta = { group: 'Components', title: 'Input', - source: 'packages/ui/src/mosaic/components/input.tsx', - styles: inputRecipe, + source: 'packages/ui/src/mosaic/components/input/input.tsx', + styleEngine: 'stylex', + styles: { + _variants: { + size: { sm: {}, md: {}, lg: {} }, + }, + _defaultVariants: { + size: 'md', + }, + }, }; function knobsAsProps(props: Record) { @@ -41,6 +48,11 @@ export function Sizes(props: Record) { size='md' placeholder='Medium' /> + ); } diff --git a/packages/ui/src/mosaic/block/destructive.tsx b/packages/ui/src/mosaic/block/destructive.tsx index cac00ae1fb5..066d430041a 100644 --- a/packages/ui/src/mosaic/block/destructive.tsx +++ b/packages/ui/src/mosaic/block/destructive.tsx @@ -90,9 +90,7 @@ export function Destructive({ value={confirmValue} onChange={e => setConfirmValue(e.target.value)} disabled={isDeleting} - sx={t => ({ - marginBlockStart: t.spacing(1), - })} + style={{ marginBlockStart: 'var(--cl-spacing)' }} /> { size?: TypographySize; @@ -33,7 +33,7 @@ export const Heading = React.forwardRef(functi const props = { ...mergeStyleProps( themeProps('heading', { size, color }), - stylex.props(styles.base, sizes[size], colors[color]), + stylex.props(typographyStyles.base, headingStyles.base, sizes[size], colors[color]), className, style, ), diff --git a/packages/ui/src/mosaic/components/input.tsx b/packages/ui/src/mosaic/components/input.tsx deleted file mode 100644 index 146deb77ad9..00000000000 --- a/packages/ui/src/mosaic/components/input.tsx +++ /dev/null @@ -1,76 +0,0 @@ -import React from 'react'; - -import type { RecipeVariantProps } from '../slot-recipe'; -import { defineSlotRecipe, useRecipe } from '../slot-recipe'; - -export const inputRecipe = defineSlotRecipe(theme => ({ - slot: 'input', - base: { - display: 'block', - height: theme.spacing(8), - width: '100%', - minWidth: 0, - paddingInline: theme.spacing(2.5), - borderRadius: theme.rounded.lg, - border: `1px solid ${theme.alpha('primary', 30)}`, - backgroundColor: 'transparent', - color: 'inherit', - fontFamily: 'inherit', - ...theme.text('base'), - outline: 'none', - transition: 'color 0.15s, background-color 0.15s, border-color 0.15s', - '@media (min-width: 768px)': { ...theme.text('sm') }, - '&::placeholder': { color: theme.alpha('primary', 40) }, - '&::file-selector-button': { - display: 'inline-flex', - height: theme.spacing(6), - border: 0, - backgroundColor: 'transparent', - ...theme.text('sm'), - fontWeight: 500, - color: 'inherit', - }, - _focusVisible: { - borderColor: theme.color.primary, - boxShadow: `0 0 0 3px ${theme.alpha('primary', 20)}`, - }, - _invalid: { - borderColor: theme.color.primary, - boxShadow: `0 0 0 3px ${theme.alpha('primary', 15)}`, - }, - _disabled: { - pointerEvents: 'none', - cursor: 'not-allowed', - backgroundColor: theme.alpha('primary', 5), - opacity: 0.5, - }, - }, - variants: { - size: { - sm: { height: theme.spacing(7), paddingInline: theme.spacing(2), ...theme.text('xs') }, - md: { height: theme.spacing(8), paddingInline: theme.spacing(2.5) }, - }, - }, - defaultVariants: { size: 'md' }, -})); - -declare module '../registry' { - interface MosaicSlotRegistry { - input: true; - } -} - -export type InputProps = Omit, 'size'> & RecipeVariantProps; - -export const Input = React.forwardRef(function MosaicInput(props, ref) { - const { size, disabled, sx, ...rest } = props; - const { root } = useRecipe(inputRecipe, { variants: { size }, state: { disabled: !!disabled }, sx }); - return ( - - ); -}); diff --git a/packages/ui/src/mosaic/components/input/index.ts b/packages/ui/src/mosaic/components/input/index.ts new file mode 100644 index 00000000000..188cbaa0d71 --- /dev/null +++ b/packages/ui/src/mosaic/components/input/index.ts @@ -0,0 +1,2 @@ +export { Input } from './input'; +export type { InputProps } from './input'; diff --git a/packages/ui/src/mosaic/components/input/input.styles.ts b/packages/ui/src/mosaic/components/input/input.styles.ts new file mode 100644 index 00000000000..3c9c42b8281 --- /dev/null +++ b/packages/ui/src/mosaic/components/input/input.styles.ts @@ -0,0 +1,101 @@ +import * as stylex from '@stylexjs/stylex'; + +import { + colorVars, + durationVars, + fontFamilyVars, + fontWeightVars, + radiusVars, + space, + typeScaleVars, +} from '../../tokens.stylex'; + +const disabledBackgroundColor = `color-mix(in oklab, ${colorVars['--cl-color-primary']} 5%, transparent)`; +const interactionBorderColor = `color-mix(in oklab, ${colorVars['--cl-color-neutral']} 20%, transparent)`; + +export const styles = stylex.create({ + base: { + borderColor: { + default: colorVars['--cl-color-border'], + ':focus-visible': interactionBorderColor, + ':focus-visible:where([aria-invalid="true"])': colorVars['--cl-color-negative'], + ':where([aria-invalid="true"])': colorVars['--cl-color-negative'], + '@media (hover: hover)': { + ':hover:not([aria-invalid="true"])': interactionBorderColor, + }, + }, + borderStyle: 'solid', + borderWidth: { + default: '1px', + ':where([aria-invalid="true"])': '2px', + }, + outline: { + default: 'none', + ':focus-visible': `2px solid ${colorVars['--cl-color-primary']}`, + ':focus-visible:where([aria-invalid="true"])': 'none', + }, + backgroundColor: colorVars['--cl-color-input'], + boxSizing: 'border-box', + color: 'inherit', + display: 'block', + fontFamily: fontFamilyVars['--cl-font-family-sans'], + outlineOffset: '2px', + transitionDuration: durationVars['--cl-duration-base'], + transitionProperty: 'color, background-color, border-color', + minWidth: 0, + width: '100%', + '::file-selector-button': { + borderStyle: 'none', + borderWidth: 0, + backgroundColor: 'transparent', + color: 'inherit', + display: 'inline-flex', + fontSize: typeScaleVars['--cl-text-sm-size'], + fontWeight: fontWeightVars['--cl-font-medium'], + lineHeight: typeScaleVars['--cl-text-sm-leading'], + height: space['6'], + }, + '::placeholder': { + color: colorVars['--cl-color-input-placeholder'], + }, + }, + disabled: { + backgroundColor: disabledBackgroundColor, + cursor: 'not-allowed', + opacity: 0.5, + pointerEvents: 'none', + }, +}); + +export const sizes = stylex.create({ + sm: { + borderRadius: radiusVars['--cl-radius-control'], + paddingInline: space['3'], + fontSize: { + default: typeScaleVars['--cl-text-xs-size'], + '@media (pointer: coarse)': `max(1rem, ${typeScaleVars['--cl-text-xs-size']})`, + }, + lineHeight: typeScaleVars['--cl-text-xs-leading'], + height: space['7'], + }, + md: { + borderRadius: radiusVars['--cl-radius-control'], + paddingInline: space['3'], + fontSize: { + default: typeScaleVars['--cl-text-sm-size'], + '@media (pointer: coarse)': `max(1rem, ${typeScaleVars['--cl-text-sm-size']})`, + }, + lineHeight: typeScaleVars['--cl-text-sm-leading'], + height: space['8'], + }, + lg: { + borderRadius: radiusVars['--cl-radius-element'], + paddingInline: space['3'], + fontSize: { + default: typeScaleVars['--cl-text-base-size'], + '@media (pointer: coarse)': `max(1rem, ${typeScaleVars['--cl-text-base-size']})`, + }, + lineHeight: 1.375, + height: space['9'], + }, +}); diff --git a/packages/ui/src/mosaic/components/input/input.test.tsx b/packages/ui/src/mosaic/components/input/input.test.tsx new file mode 100644 index 00000000000..103361daac4 --- /dev/null +++ b/packages/ui/src/mosaic/components/input/input.test.tsx @@ -0,0 +1,106 @@ +import { render, screen } from '@testing-library/react'; +import React from 'react'; +import { describe, expect, it } from 'vitest'; + +import { Input } from './input'; + +describe('Mosaic Input', () => { + it('applies the default size', () => { + render(); + const input = screen.getByRole('textbox', { name: 'Name' }); + expect(input).toHaveClass('cl-input'); + expect(input).toHaveAttribute('data-size', 'md'); + expect(input).not.toHaveAttribute('data-disabled'); + expect(input).toBeEnabled(); + }); + + it.each(['sm', 'md', 'lg'] as const)('reflects the %s size', size => { + render( + , + ); + expect(screen.getByRole('textbox', { name: 'Name' })).toHaveAttribute('data-size', size); + }); + + it('reflects and forwards the disabled state', () => { + render( + , + ); + const input = screen.getByRole('textbox', { name: 'Name' }); + expect(input).toBeDisabled(); + expect(input).toHaveAttribute('data-disabled', ''); + }); + + it('forwards aria-invalid without reflecting general browser invalidity', () => { + render( + <> + + + , + ); + expect(screen.getByRole('textbox', { name: 'ARIA invalid' })).toHaveAttribute('aria-invalid', 'true'); + expect(screen.getByRole('textbox', { name: 'Browser invalid' })).not.toHaveAttribute('aria-invalid'); + }); + + it('merges consumer className and inline styles', () => { + render( + , + ); + const input = screen.getByRole('textbox', { name: 'Name' }); + expect(input).toHaveClass('cl-input', 'my-input'); + expect(input).toHaveStyle({ marginTop: '8px' }); + }); + + it('forwards native props and the ref', () => { + const ref = React.createRef(); + render( + , + ); + const input = screen.getByPlaceholderText('Name'); + expect(ref.current).toBe(input); + expect(input).toHaveAttribute('name', 'displayName'); + }); + + it('renders a custom element via render while keeping the styling contract', () => { + render( + } + size='sm' + />, + ); + const input = screen.getByRole('textbox', { name: 'Biography' }); + expect(input.tagName).toBe('TEXTAREA'); + expect(input).toHaveClass('cl-input'); + expect(input).toHaveAttribute('data-size', 'sm'); + }); + + it('lets consumer data attributes override generated ones', () => { + render( + , + ); + expect(screen.getByRole('textbox', { name: 'Name' })).toHaveAttribute('data-size', 'consumer'); + }); +}); diff --git a/packages/ui/src/mosaic/components/input/input.tsx b/packages/ui/src/mosaic/components/input/input.tsx new file mode 100644 index 00000000000..96b8abdc98d --- /dev/null +++ b/packages/ui/src/mosaic/components/input/input.tsx @@ -0,0 +1,32 @@ +import { useRender } from '@clerk/headless/utils'; +import * as stylex from '@stylexjs/stylex'; +import React from 'react'; + +import type { MosaicComponentProps } from '../../props'; +import { mergeStyleProps, themeProps } from '../../props'; +import { sizes, styles } from './input.styles'; + +export interface InputProps extends Omit, 'size'> { + size?: 'sm' | 'md' | 'lg'; +} + +export const Input = React.forwardRef(function MosaicInput( + { size = 'md', disabled = false, render, className, style, ...rest }, + ref, +) { + return useRender({ + defaultTagName: 'input', + render, + ref, + props: { + disabled, + ...mergeStyleProps( + themeProps('input', { size, disabled }), + stylex.props(styles.base, sizes[size], disabled && styles.disabled), + className, + style, + ), + ...rest, + }, + }); +}); diff --git a/packages/ui/src/mosaic/components/item/item.styles.ts b/packages/ui/src/mosaic/components/item/item.styles.ts index bb787fc74a3..200099b0dea 100644 --- a/packages/ui/src/mosaic/components/item/item.styles.ts +++ b/packages/ui/src/mosaic/components/item/item.styles.ts @@ -1,6 +1,6 @@ import * as stylex from '@stylexjs/stylex'; -import { colorVars, fontWeightVars, radiusVars, space, typeScaleVars } from '../../tokens.stylex'; +import { colorVars, fontFamilyVars, fontWeightVars, radiusVars, space, typeScaleVars } from '../../tokens.stylex'; import { itemScope } from './item.markers.stylex'; export const item = stylex.create({ @@ -17,7 +17,7 @@ export const item = stylex.create({ boxSizing: 'border-box', color: colorVars['--cl-color-card-foreground'], display: 'flex', - fontFamily: 'inherit', + fontFamily: fontFamilyVars['--cl-font-family-sans'], fontSize: typeScaleVars['--cl-text-sm-size'], lineHeight: typeScaleVars['--cl-text-sm-leading'], outlineOffset: '2px', diff --git a/packages/ui/src/mosaic/components/menu/menu.styles.ts b/packages/ui/src/mosaic/components/menu/menu.styles.ts index 82c60c8dfa0..46a9764e32e 100644 --- a/packages/ui/src/mosaic/components/menu/menu.styles.ts +++ b/packages/ui/src/mosaic/components/menu/menu.styles.ts @@ -1,6 +1,6 @@ import * as stylex from '@stylexjs/stylex'; -import { colorVars, fontWeightVars, radiusVars, space, typeScaleVars } from '../../tokens.stylex'; +import { colorVars, fontFamilyVars, fontWeightVars, radiusVars, space, typeScaleVars } from '../../tokens.stylex'; export const styles = stylex.create({ // Positioning is applied inline by the headless positioner; this only clears the @@ -67,7 +67,7 @@ export const styles = stylex.create({ color: 'inherit', cursor: { default: 'pointer', ':is([data-disabled])': 'not-allowed' }, display: 'flex', - fontFamily: 'inherit', + fontFamily: fontFamilyVars['--cl-font-family-sans'], fontSize: typeScaleVars['--cl-text-sm-size'], fontWeight: fontWeightVars['--cl-font-medium'], lineHeight: typeScaleVars['--cl-text-sm-leading'], diff --git a/packages/ui/src/mosaic/components/text/text.tsx b/packages/ui/src/mosaic/components/text/text.tsx index e43bb7204ec..1492eaaa245 100644 --- a/packages/ui/src/mosaic/components/text/text.tsx +++ b/packages/ui/src/mosaic/components/text/text.tsx @@ -6,7 +6,7 @@ import type { MosaicComponentProps } from '../../props'; import { mergeStyleProps, themeProps } from '../../props'; import { useContextProps } from '../../utils/context'; import type { TypographyColor, TypographySize } from '../typography.styles'; -import { colors, sizes } from '../typography.styles'; +import { colors, sizes, styles } from '../typography.styles'; export interface TextProps extends MosaicComponentProps<'p'> { size?: TypographySize; @@ -23,7 +23,12 @@ export const Text = React.forwardRef(function M const { size = 'sm', color = 'primary', render, className, style, ...rest } = useContextProps(rawProps, TextContext); const props = { - ...mergeStyleProps(themeProps('text', { size, color }), stylex.props(sizes[size], colors[color]), className, style), + ...mergeStyleProps( + themeProps('text', { size, color }), + stylex.props(styles.base, sizes[size], colors[color]), + className, + style, + ), ...rest, }; diff --git a/packages/ui/src/mosaic/components/typography.styles.ts b/packages/ui/src/mosaic/components/typography.styles.ts index a267425e655..463c4deb187 100644 --- a/packages/ui/src/mosaic/components/typography.styles.ts +++ b/packages/ui/src/mosaic/components/typography.styles.ts @@ -1,11 +1,17 @@ import * as stylex from '@stylexjs/stylex'; -import { colorVars, typeScaleVars } from '../tokens.stylex'; +import { colorVars, fontFamilyVars, typeScaleVars } from '../tokens.stylex'; export type TypographySize = 'xs' | 'sm' | 'base' | 'lg' | 'xl' | '2xl'; export type TypographyColor = 'primary' | 'neutral' | 'warning' | 'negative' | 'positive'; +export const styles = stylex.create({ + base: { + fontFamily: fontFamilyVars['--cl-font-family-sans'], + }, +}); + export const sizes = stylex.create({ xs: { fontSize: typeScaleVars['--cl-text-xs-size'], diff --git a/packages/ui/src/mosaic/organization/organization-profile-domains-section-add-verify.view.tsx b/packages/ui/src/mosaic/organization/organization-profile-domains-section-add-verify.view.tsx index 7fc7e67eb6b..e043f91d91f 100644 --- a/packages/ui/src/mosaic/organization/organization-profile-domains-section-add-verify.view.tsx +++ b/packages/ui/src/mosaic/organization/organization-profile-domains-section-add-verify.view.tsx @@ -88,7 +88,7 @@ export function OrganizationProfileDomainsSectionAddVerifyView({ disabled={isBusy} placeholder='example.com' onChange={e => send({ type: 'TYPE_NAME', value: e.target.value })} - sx={t => ({ marginBlockStart: t.spacing(1) })} + style={{ marginBlockStart: 'var(--cl-spacing)' }} /> send({ type: 'TYPE_CODE', value: e.target.value })} - sx={t => ({ marginBlockStart: t.spacing(1) })} + style={{ marginBlockStart: 'var(--cl-spacing)' }} /> ({ marginBlockStart: t.spacing(2), display: 'flex', columnGap: t.spacing(2) })}> diff --git a/packages/ui/src/mosaic/organization/organization-profile-profile-section.view.tsx b/packages/ui/src/mosaic/organization/organization-profile-profile-section.view.tsx index d77683b2fde..e63d442cb02 100644 --- a/packages/ui/src/mosaic/organization/organization-profile-profile-section.view.tsx +++ b/packages/ui/src/mosaic/organization/organization-profile-profile-section.view.tsx @@ -120,7 +120,7 @@ export function OrganizationProfileProfileSectionView({ value={nameValue} onChange={e => send({ type: 'TYPE_NAME', value: e.target.value })} disabled={isSaving} - sx={t => ({ marginBlockStart: t.spacing(1) })} + style={{ marginBlockStart: 'var(--cl-spacing)' }} /> {slugEnabled && ( @@ -138,7 +138,7 @@ export function OrganizationProfileProfileSectionView({ value={slugValue} onChange={e => send({ type: 'TYPE_SLUG', value: e.target.value })} disabled={isSaving} - sx={t => ({ marginBlockStart: t.spacing(1) })} + style={{ marginBlockStart: 'var(--cl-spacing)' }} /> )} diff --git a/packages/ui/src/mosaic/styles/index.ts b/packages/ui/src/mosaic/styles/index.ts index 826944639b8..fd5ffb1aec6 100644 --- a/packages/ui/src/mosaic/styles/index.ts +++ b/packages/ui/src/mosaic/styles/index.ts @@ -18,6 +18,8 @@ export { Heading, HeadingContext } from '../components/heading'; export type { HeadingProps } from '../components/heading'; export { Icon } from '../components/icon'; export type { IconProps } from '../components/icon'; +export { Input } from '../components/input'; +export type { InputProps } from '../components/input'; export { Item } from '../components/item'; export type { ItemProps } from '../components/item'; export { Menu } from '../components/menu'; @@ -48,6 +50,7 @@ import { colorVars, durationVars, easingVars, + fontFamilyVars, fontWeightVars, radiusVars, scrollbarVars, @@ -62,6 +65,7 @@ export { colorVars, durationVars, easingVars, + fontFamilyVars, fontWeightVars, radiusVars, scrollbarVars, @@ -78,6 +82,7 @@ export { export type ColorVarName = keyof typeof colorVars; export type DurationVarName = keyof typeof durationVars; export type EasingVarName = keyof typeof easingVars; +export type FontFamilyVarName = keyof typeof fontFamilyVars; export type FontWeightVarName = keyof typeof fontWeightVars; export type RadiusVarName = keyof typeof radiusVars; export type ScrollbarVarName = keyof typeof scrollbarVars; diff --git a/packages/ui/src/mosaic/tokens.stylex.ts b/packages/ui/src/mosaic/tokens.stylex.ts index 09371e11f8b..f0d498d9e94 100644 --- a/packages/ui/src/mosaic/tokens.stylex.ts +++ b/packages/ui/src/mosaic/tokens.stylex.ts @@ -47,7 +47,10 @@ const colorDefaults = { '--cl-color-card': 'light-dark(oklch(1 0 0), oklch(0.205 0 0))', '--cl-color-card-foreground': 'light-dark(oklch(0.145 0 0), oklch(0.985 0 0))', - '--cl-color-border': 'light-dark(oklch(0.9475 0.0067 286.27), oklch(0.3321 0.014 285.61))', + '--cl-color-input': 'light-dark(oklch(1 0 0), oklch(0.2046 0 0))', + '--cl-color-input-placeholder': '#a3a3a3', + + '--cl-color-border': 'light-dark(oklch(0.9219 0 0), oklch(0.3715 0 0))', '--cl-color-border-faded': 'light-dark(oklch(0.9587 0.0027 286.35), oklch(0.296 0.0126 285.61))', } as const; @@ -235,6 +238,16 @@ const typeScaleDefaults = { export const typeScaleVars = stylex.defineVars(typeScaleDefaults); +// ============================================================================= +// Typography Tokens — font family +// ============================================================================= + +const fontFamilyDefaults = { + '--cl-font-family-sans': 'inherit', +} as const; + +export const fontFamilyVars = stylex.defineVars(fontFamilyDefaults); + // ============================================================================= // Typography Tokens — font weight // ============================================================================= diff --git a/references/mosaic-architecture.md b/references/mosaic-architecture.md index 37133a94468..cefc13e554a 100644 --- a/references/mosaic-architecture.md +++ b/references/mosaic-architecture.md @@ -117,7 +117,7 @@ Two ways to style a part — both hit the same class + attributes: /> ``` -Tokens are a third, independent lever: every `--cl-*` custom property (`--cl-color-*`, `--cl-radius-*`, `--cl-spacing`) can be overridden in plain CSS at `:root` or any scope to re-theme without touching a component. +Tokens are a third, independent lever: every `--cl-*` custom property (`--cl-color-*`, `--cl-radius-*`, `--cl-font-family-sans`, `--cl-spacing`) can be overridden in plain CSS at `:root` or any scope to re-theme without touching a component. State styling uses real class + attribute-selector specificity — no `&&` boost, no data-attr-vs-class ambiguity.