Skip to content

Commit

Permalink
feat(ui): Descriptor Variance Authority (#4343)
Browse files Browse the repository at this point in the history
Co-authored-by: Alex Carpenter <[email protected]>
  • Loading branch information
dstaley and alexcarpenter authored Oct 17, 2024
1 parent 3b50b67 commit 5c6391b
Show file tree
Hide file tree
Showing 9 changed files with 413 additions and 77 deletions.
2 changes: 2 additions & 0 deletions .changeset/rich-pens-build.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
2 changes: 1 addition & 1 deletion packages/tailwindcss-transformer/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ function visitNode(node: recast.types.ASTNode, ctx: { styleCache: StyleCache },
if (path.parentPath.node.type === 'ObjectProperty' && path.parentPath.node.key === path.node) {
return false;
}
if (path.node.value === '') {
if (path.node.value === '' || path.node.value === ' ') {
return false;
}
const cn = generateHashedClassName(path.node.value);
Expand Down
1 change: 1 addition & 0 deletions packages/ui/src/common/connections.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ export function Connections(
asChild
>
<Button
// descriptor={`buttonConnection__${c.provider}`}
intent='connection'
busy={isConnectionLoading}
disabled={props?.disabled || isConnectionLoading}
Expand Down
24 changes: 23 additions & 1 deletion packages/ui/src/contexts/AppearanceContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,35 @@ import React from 'react';
import { fullTheme } from '~/themes';

type AlertDescriptorIdentifier = 'alert' | 'alert__error' | 'alert__warning' | 'alertIcon';
type ButtonDescriptorIdentifier =
| 'button'
| 'buttonPrimary'
| 'buttonSecondary'
| 'buttonConnection'
| 'buttonPrimaryDefault'
| 'buttonSecondaryDefault'
| 'buttonConnectionDefault'
| 'buttonDisabled'
| 'buttonBusy'
| 'buttonText'
| 'buttonTextVisuallyHidden'
| 'buttonIcon'
| 'buttonIconStart'
| 'buttonIconEnd'
| 'buttonSpinner';
type SeparatorDescriptorIdentifier = 'separator';
type SpinnerDescriptorIdentifier = 'spinner';
type CardDescriptorIdentifier = 'logoBox' | 'logoLink' | 'logoImage';

/**
* Union of all valid descriptors used throughout the components.
*/
export type DescriptorIdentifier = AlertDescriptorIdentifier | SeparatorDescriptorIdentifier | CardDescriptorIdentifier;
export type DescriptorIdentifier =
| AlertDescriptorIdentifier
| ButtonDescriptorIdentifier
| SeparatorDescriptorIdentifier
| SpinnerDescriptorIdentifier
| CardDescriptorIdentifier;

/**
* The final resulting descriptor that gets passed to mergeDescriptors and spread on the element.
Expand Down
186 changes: 118 additions & 68 deletions packages/ui/src/primitives/button.tsx
Original file line number Diff line number Diff line change
@@ -1,52 +1,114 @@
import type { VariantProps } from 'cva';
import { cva, cx } from 'cva';
import * as React from 'react';

import type { ParsedElementsFragment } from '~/contexts/AppearanceContext';
import { mergeDescriptors, useAppearance } from '~/contexts/AppearanceContext';
import { applyDescriptors, dva, type VariantProps } from '~/utils/dva';

import { Spinner } from './spinner';

// Note:
// - To create the overlapping border/shadow effect"
// - `ring` – "focus ring"
// - `ring-offset` - border
const button = cva({
base: [
'[--button-icon-size:calc(var(--cl-font-size)*1.24)]', // 16px
'appearance-none relative isolate select-none',
'text-base font-medium',
'px-3 py-1.5',
'min-h-[1.875rem]',
'inline-flex w-full items-center justify-center gap-3',
'ring ring-offset-1 rounded-md',
'[&:not(:focus-visible)]:ring-transparent',
'outline-none',
'*:min-w-0',
],
export const layoutStyle = {
button: {
className: [
'[--button-icon-size:calc(var(--cl-font-size)*1.24)]', // 16px
'appearance-none relative isolate select-none',
'text-base font-medium',
'px-3 py-1.5',
'min-h-[1.875rem]',
'inline-flex w-full items-center justify-center gap-3',
'ring ring-offset-1 rounded-md',
'[&:not(:focus-visible)]:ring-transparent',
'outline-none',
'*:min-w-0',
].join(' '),
},
buttonPrimary: {},
buttonSecondary: {},
buttonConnection: {},
buttonPrimaryDefault: {},
buttonSecondaryDefault: {},
buttonConnectionDefault: {},
buttonDisabled: {},
buttonBusy: {},
buttonConnection__google: {},
buttonText: {},
buttonTextVisuallyHidden: {},
buttonIcon: {
className: 'shrink-0',
},
buttonIconStart: {},
buttonIconEnd: {},
buttonSpinner: {
className: 'shrink-0',
},
} satisfies ParsedElementsFragment;

export const visualStyle = {
button: {},
buttonPrimary: {
className: [
'[--button-icon-color:currentColor]',
'[--button-icon-opacity:0.6]',
'text-accent-contrast bg-accent-9 ring-offset-accent-9',
'shadow-[0px_1px_1px_0px_theme(colors.white/.07)_inset,0px_2px_3px_0px_theme(colors.gray.a7),0px_1px_1px_0px_theme(colors.gray.a9)]',
'before:absolute before:inset-0 before:rounded-[inherit] before:shadow-[0_1px_1px_0_theme(colors.white/.07)_inset]',
'after:pointer-events-none after:absolute after:inset-0 after:-z-10 after:rounded-[inherit] after:bg-gradient-to-b after:from-white/10 after:to-transparent',
].join(' '),
},
buttonSecondary: {
className: [
'[--button-icon-color:theme(colors.gray.12)]',
'[--button-icon-opacity:1]',
'text-gray-12 bg-gray-surface ring-light ring-offset-gray-a4',
'shadow-[0px_1px_0px_0px_theme(colors.gray.a2),0px_2px_3px_-1px_theme(colors.gray.a3)]',
].join(' '),
},
buttonConnection: {
className: [
'[--button-icon-color:theme(colors.gray.12)]',
'[--button-icon-opacity:1]',
'text-gray-12 bg-gray-surface ring-light ring-offset-gray-a4',
'shadow-[0px_1px_0px_0px_theme(colors.gray.a2),0px_2px_3px_-1px_theme(colors.gray.a3)]',
].join(' '),
},
buttonPrimaryDefault: {
className: 'hover:bg-accent-10 hover:after:opacity-0',
},
buttonSecondaryDefault: {
className: 'hover:bg-gray-2',
},
buttonConnectionDefault: {
className: 'hover:bg-gray-2',
},
buttonDisabled: {
className: 'disabled:cursor-not-allowed disabled:opacity-50',
},
buttonBusy: {
className: 'cursor-wait',
},
buttonConnection__google: {},
buttonText: {
className: 'truncate leading-4',
},
buttonTextVisuallyHidden: {
className: 'sr-only',
},
buttonIcon: {
className: 'text-[length:--button-icon-size] text-[--button-icon-color] opacity-[--button-icon-opacity]',
},
buttonIconStart: {},
buttonIconEnd: {},
buttonSpinner: {
className: 'text-[length:--button-icon-size] text-[--button-icon-color]',
},
} satisfies ParsedElementsFragment;

const button = dva({
base: 'button',
variants: {
intent: {
primary: [
'[--button-icon-color:currentColor]',
'[--button-icon-opacity:0.6]',
'text-accent-contrast bg-accent-9 ring-offset-accent-9',
'shadow-[0px_1px_1px_0px_theme(colors.white/.07)_inset,0px_2px_3px_0px_theme(colors.gray.a7),0px_1px_1px_0px_theme(colors.gray.a9)]',
'before:absolute before:inset-0 before:rounded-[inherit] before:shadow-[0_1px_1px_0_theme(colors.white/.07)_inset]',
'after:pointer-events-none after:absolute after:inset-0 after:-z-10 after:rounded-[inherit] after:bg-gradient-to-b after:from-white/10 after:to-transparent',
],
secondary: [
'[--button-icon-color:theme(colors.gray.12)]',
'[--button-icon-opacity:1]',
'text-gray-12 bg-gray-surface ring-light ring-offset-gray-a4',
'shadow-[0px_1px_0px_0px_theme(colors.gray.a2),0px_2px_3px_-1px_theme(colors.gray.a3)]',
],
// Note:
// This currently looks the same as `secondary`, but we've intentfully
// kept this as a separate variant for now, due to its nuances in `busy`
// behavior
connection: [
'[--button-icon-color:theme(colors.gray.12)]',
'[--button-icon-opacity:1]',
'text-gray-12 bg-gray-surface ring-light ring-offset-gray-a4',
'shadow-[0px_1px_0px_0px_theme(colors.gray.a2),0px_2px_3px_-1px_theme(colors.gray.a3)]',
],
primary: 'buttonPrimary',
secondary: 'buttonSecondary',
connection: 'buttonConnection',
},
busy: {
false: null,
Expand All @@ -58,19 +120,19 @@ const button = cva({
},
},
compoundVariants: [
{ busy: false, disabled: false, intent: 'primary', className: 'hover:bg-accent-10 hover:after:opacity-0' },
{ busy: false, disabled: false, intent: 'secondary', className: 'hover:bg-gray-2' },
{ busy: false, disabled: false, intent: 'connection', className: 'hover:bg-gray-2' },
{ busy: false, disabled: true, className: 'disabled:cursor-not-allowed disabled:opacity-50' },
{ busy: true, disabled: false, className: 'cursor-wait' },
{ busy: false, disabled: false, intent: 'primary', descriptor: 'buttonPrimaryDefault' },
{ busy: false, disabled: false, intent: 'secondary', descriptor: 'buttonSecondaryDefault' },
{ busy: false, disabled: false, intent: 'connection', descriptor: 'buttonConnectionDefault' },
{ busy: false, disabled: true, descriptor: 'buttonDisabled' },
{ busy: true, disabled: false, descriptor: 'buttonBusy' },
],
});

export const Button = React.forwardRef(function Button(
{
busy = false,
children,
className,
descriptor,
disabled = false,
iconStart,
iconEnd,
Expand All @@ -87,15 +149,13 @@ export const Button = React.forwardRef(function Button(
},
forwardedRef: React.ForwardedRef<HTMLButtonElement>,
) {
const spinner = (
<Spinner className='shrink-0 text-[length:--button-icon-size] text-[--button-icon-color]'>Loading…</Spinner>
);
const { elements } = useAppearance().parsedAppearance;
const spinner = <Spinner descriptor='buttonSpinner'>Loading…</Spinner>;

return (
<button
data-button=''
ref={forwardedRef}
className={button({ busy, disabled, intent, className })}
{...applyDescriptors(elements, button({ busy, disabled, intent, descriptor }))}
disabled={busy || disabled}
// eslint-disable-next-line react/button-has-type
type={type}
Expand All @@ -109,25 +169,15 @@ export const Button = React.forwardRef(function Button(
busy && intent === 'connection' ? (
spinner
) : (
<span
data-button-icon=''
className='shrink-0 text-[length:--button-icon-size] text-[--button-icon-color] opacity-[--button-icon-opacity]'
>
{iconStart}
</span>
<span {...applyDescriptors(elements, 'buttonIcon buttonIconStart')}>{iconStart}</span>
)
) : null}
{children ? (
<span className={cx('truncate leading-4', textVisuallyHidden && 'sr-only')}>{children}</span>
) : null}
{iconEnd ? (
<span
data-button-icon=''
className='shrink-0 text-[length:--button-icon-size] text-[--button-icon-color] opacity-[--button-icon-opacity]'
>
{iconEnd}
<span {...mergeDescriptors(elements.buttonText, !!textVisuallyHidden && elements.buttonTextVisuallyHidden)}>
{children}
</span>
) : null}
{iconEnd ? <span {...applyDescriptors(elements, 'buttonIcon buttonIconEnd')}>{iconEnd}</span> : null}
</>
)}
</button>
Expand Down
32 changes: 27 additions & 5 deletions packages/ui/src/primitives/spinner.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,30 @@
import type { VariantProps } from 'cva';
import { cx } from 'cva';
import { forwardRef, type HTMLAttributes } from 'react';

import { type ParsedElementsFragment, useAppearance } from '~/contexts/AppearanceContext';
import { applyDescriptors, dva } from '~/utils/dva';

const SIZE = 16;

type SpinnerRef = HTMLDivElement;

export const layoutStyle = {
spinner: {
className: 'relative isolate block',
},
} satisfies ParsedElementsFragment;

export const visualStyle = {
spinner: {
className: 'size-[1em] text-current',
},
} satisfies ParsedElementsFragment;

const spinner = dva({
base: 'spinner',
});

/**
* # Spinner
*
Expand All @@ -27,16 +47,18 @@ type SpinnerRef = HTMLDivElement;
export const Spinner = forwardRef(function Spinner(
{
children,
className,
}: Pick<HTMLAttributes<SpinnerRef>, 'className'> & {
children: string;
},
descriptor,
}: HTMLAttributes<SpinnerRef> &
VariantProps<typeof spinner> & {
children: string;
},
ref: React.ForwardedRef<SpinnerRef>,
) {
const { elements } = useAppearance().parsedAppearance;
return (
<span
ref={ref}
className={cx('relative isolate block size-[1em] text-current', className)}
{...applyDescriptors(elements, spinner({ descriptor }))}
>
<span className='sr-only'>{children}</span>

Expand Down
10 changes: 9 additions & 1 deletion packages/ui/src/themes/full.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,17 @@
import { visualStyle as alertVisualStyle } from '~/primitives/alert';
import { visualStyle as buttonVisualStyle } from '~/primitives/button';
import { visualStyle as cardVisualStyle } from '~/primitives/card';
import { visualStyle as separatorVisualStyle } from '~/primitives/separator';
import { visualStyle as spinnerVisualStyle } from '~/primitives/spinner';

import { buildTheme, mergeTheme } from './buildTheme';
import { layoutTheme } from './layout';

const visualTheme = buildTheme({ ...alertVisualStyle, ...cardVisualStyle, ...separatorVisualStyle });
const visualTheme = buildTheme({
...alertVisualStyle,
...buttonVisualStyle,
...cardVisualStyle,
...separatorVisualStyle,
...spinnerVisualStyle,
});
export const fullTheme = mergeTheme(layoutTheme, visualTheme);
10 changes: 9 additions & 1 deletion packages/ui/src/themes/layout.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,15 @@
import { layoutStyle as alertLayoutStyle } from '~/primitives/alert';
import { layoutStyle as buttonStyle } from '~/primitives/button';
import { layoutStyle as cardLayoutStyle } from '~/primitives/card';
import { layoutStyle as separatorStyle } from '~/primitives/separator';
import { layoutStyle as layoutSpinnerStyle } from '~/primitives/spinner';

import { buildTheme } from './buildTheme';

export const layoutTheme = buildTheme({ ...alertLayoutStyle, ...cardLayoutStyle, ...separatorStyle });
export const layoutTheme = buildTheme({
...alertLayoutStyle,
...buttonStyle,
...cardLayoutStyle,
...separatorStyle,
...layoutSpinnerStyle,
});
Loading

0 comments on commit 5c6391b

Please sign in to comment.