diff --git a/apps/tester/src/settings.tsx b/apps/tester/src/settings.tsx index 3576403..60ab84b 100644 --- a/apps/tester/src/settings.tsx +++ b/apps/tester/src/settings.tsx @@ -25,6 +25,7 @@ export type Fixture = { alterVariants?: UIVariant[]; alterColors?: UIColor[]; alterStates?: UIState[]; + waitTime?: number; renderJoyElement: ElementRenderer; renderTjElement: ElementRenderer; }; @@ -63,6 +64,7 @@ export function testEach( alterVariants, alterColors, alterStates, + waitTime, renderJoyElement, renderTjElement, } of fixtures) { @@ -112,6 +114,9 @@ export function testEach( , ); + if (waitTime) { + await sleep(waitTime); + } if (state === 'hover') { await page.getByTestId(elementTestId).hover(); } else if (state === 'focus-visible') { @@ -153,6 +158,9 @@ export function testEach( , ); + if (waitTime) { + await sleep(waitTime); + } if (state === 'hover') { await page.getByTestId(elementTestId).hover(); } else if (state === 'focus-visible') { diff --git a/apps/tester/tests/06-aspect-ratio.spec.tsx b/apps/tester/tests/06-aspect-ratio.spec.tsx new file mode 100644 index 0000000..c9097da --- /dev/null +++ b/apps/tester/tests/06-aspect-ratio.spec.tsx @@ -0,0 +1,88 @@ +import { AspectRatio as JoyAspectRatio } from '@mui/joy'; +import { AspectRatio as TJAspectRatio } from 'tailwind-joy/components'; + +import type { Fixture } from '@/settings'; +import { testEach } from '@/settings'; + +const containerClassName = + 'flex h-[200px] w-[400px] items-center justify-center p-2'; + +const fixtures: Fixture[] = [ + { + title: 'basics', + alterSizes: ['md'], + alterColors: ['primary'], + renderJoyElement({ testId, size, variant, color }) { + return ( + +
Lorem ipsum
+
+ ); + }, + renderTjElement({ testId, size, variant, color }) { + return ( + +
Lorem ipsum
+
+ ); + }, + }, + { + title: 'ratio', + alterSizes: ['md'], + alterColors: ['primary'], + renderJoyElement({ testId, size, variant, color }) { + return ( + +
Lorem ipsum
+
+ ); + }, + renderTjElement({ testId, size, variant, color }) { + return ( + +
Lorem ipsum
+
+ ); + }, + }, + { + title: 'objectFit', + alterSizes: ['md'], + alterColors: ['primary'], + waitTime: 3000, + renderJoyElement({ testId, size, variant, color }) { + return ( + + A beautiful landscape. + + ); + }, + renderTjElement({ testId, size, variant, color }) { + return ( + + A beautiful landscape. + + ); + }, + }, +]; + +testEach(fixtures, { + containerClassName, + viewport: { width: 500, height: 500 }, +}); diff --git a/apps/website/docs/apis/06-aspect-ratio.md b/apps/website/docs/apis/06-aspect-ratio.md new file mode 100644 index 0000000..7dc99a5 --- /dev/null +++ b/apps/website/docs/apis/06-aspect-ratio.md @@ -0,0 +1,110 @@ +--- +sidebar_position: 6 +title: +--- + +# AspectRatio API + + + +API reference docs for the React AspectRatio component. +Learn about the props of this exported module. + +## Demos + +:::tip + +For examples and details on the usage of this React component, visit the component demo pages: + +- [AspectRatio](../components/aspect-ratio) + +::: + +## Import + +```tsx +import { AspectRatio } from 'tailwind-joy/components'; +``` + +## Props + +:::info + +The `ref` is forwarded to the root element. + +::: + +### `className` + +Class name applied to the root element. + +- Type: `string` + +### `color` + +The color of the component. + +- Type: `'primary' | 'neutral' | 'danger' | 'success' | 'warning'` +- Default: `'neutral'` + +### `component` + +The component used for the root node. + +- Type: `keyof JSX.IntrinsicElements` +- Default: `'div'` + +### `flex` + +By default, the AspectRatio will maintain the aspect ratio of its content. +Set this prop to `true` when the container is a flex row and you want the AspectRatio to fill the height of its container. + +- Type: `boolean` +- Default: `false` + +### `maxHeight` + +The maximum calculated height of the element (not the CSS height). + +- Type: `number | string` + +### `minHeight` + +The minimum calculated height of the element (not the CSS height). + +- Type: `number | string` + +### `objectFit` + +The CSS object-fit value of the first-child. + +- Type: `'-moz-initial' | 'contain' | 'cover' | 'fill' | 'inherit' | 'initial' | 'none' | 'revert-layer' | 'revert' | 'scale-down' | 'unset'` +- Default: `'cover'` + +### `ratio` + +The aspect-ratio of the element. +The current implementation uses padding instead of the CSS aspect-ratio. + +- Type: `number | string` +- Default: `'16/9'` + +### `slotProps` + +The props used for each slot inside. + +- Type: + ```tsx + { + root?: ComponentProps<'div'>; + content?: ComponentProps<'div'>; + } + ``` +- Default: `{}` + +### `variant` + +The variant of the component. + +- Type: `'solid' | 'soft' | 'outlined' | 'plain'` +- Default: `'soft'` diff --git a/apps/website/docs/components/02-data-display/01-aspect-ratio.mdx b/apps/website/docs/components/02-data-display/01-aspect-ratio.mdx new file mode 100644 index 0000000..e641a29 --- /dev/null +++ b/apps/website/docs/components/02-data-display/01-aspect-ratio.mdx @@ -0,0 +1,226 @@ +--- +sidebar_position: 1 +slug: /components/aspect-ratio +--- + +import { + AspectRatioBasics, + AspectRatioVariants, + AspectRatioRatio, + AspectRatioObjectFit, + AspectRatioMediaPlaceholder, + AspectRatioMinimumAndMaximumHeight, +} from '@site/src/demos/aspect-ratio'; + +# AspectRatio + + + +The Aspect Ratio component resizes its contents to match the desired ratio. + +## Basics + +```tsx +import { AspectRatio } from 'tailwind-joy/components'; +``` + +The Aspect Ratio component wraps around the content that it resizes. +The element to be resized must be the first direct child. +The default ratio is `16/9`. + + + +```tsx +import { AspectRatio, Typography } from 'tailwind-joy/components'; + +export function AspectRatioBasics() { + return ( + + + 16/9 + + + ); +} +``` + +## Customization + +### Variants + +The Aspect Ratio component supports four variants: `solid`, `soft` (default), `outlined`, and `plain`. + + + +```tsx +import { AspectRatio, Box, Typography } from 'tailwind-joy/components'; + +export function AspectRatioVariants() { + return ( + + + + + Solid + + + + + + + Soft + + + + + + + Outlined + + + + + + + Plain + + + + + ); +} +``` + +### Ratio + +Use the `ratio` prop to change the aspect ratio, following the pattern `width/height`. +For example, the demo below uses a ratio of `4/3`, which is a common alternative to the default `16/9`: + + + +```tsx +import { AspectRatio, Typography } from 'tailwind-joy/components'; + +export function AspectRatioRatio() { + return ( + + + 4/3 + + + ); +} +``` + +### Object fit + +When the content inside the Aspect Ratio component is an image or a video, you can use the `objectFit` prop to control how it's resized. + +This prop gives you access to all of the values associated with the CSS `object-fit` property: `cover` (default), `contain`, `fill`, `scaleDown`, `initial`, `inherit`, and `none`. + + + +```tsx +import { AspectRatio, Box } from 'tailwind-joy/components'; + +export function AspectRatioObjectFit() { + return ( + + + A beautiful landscape. + + + ); +} +``` + +### Media placeholder + +Use a `
`, or a [Box](./box) component paired with an icon, as a fallback when there is no media content provided: + + + +```tsx +import { MdImage } from 'react-icons/md'; +import { AspectRatio, Sheet, Typography } from 'tailwind-joy/components'; +import { iconClass } from 'tailwind-joy/utils'; +import { twMerge } from 'tailwind-merge'; + +export function AspectRatioMediaPlaceholder() { + return ( + + +
+ +
+
+
+ Title + Description of the card. +
+
+ ); +} +``` + +### Minimum and maximum height + +Use the `minHeight` and `maxHeight` props to set the lower and upper bound for the height of the content. +This is useful when the Aspect Ratio component wraps dynamic-width content, as shown in the demo below: + + + +```tsx +import { AspectRatio, Box } from 'tailwind-joy/components'; + +export function AspectRatioMinimumAndMaximumHeight() { + return ( + + + + + + ); +} +``` + +## Anatomy + +The Aspect Ratio component is composed of a root `
` with a content `
` nested inside; the child component is given a `data-first-child` attribute for styling purposes: + +```html +
+
+ + + +
+
+``` + +## API + +- [``](../apis/aspect-ratio) +- [``](../apis/box) +- [``](../apis/sheet) +- [``](../apis/typography) diff --git a/apps/website/src/demos/aspect-ratio/Basics.tsx b/apps/website/src/demos/aspect-ratio/Basics.tsx new file mode 100644 index 0000000..cd9d45c --- /dev/null +++ b/apps/website/src/demos/aspect-ratio/Basics.tsx @@ -0,0 +1,14 @@ +import { AspectRatio, Typography } from 'tailwind-joy/components'; +import { DisplayStand } from '@site/src/components/docs/DisplayStand'; + +export function AspectRatioBasics() { + return ( + + + + 16/9 + + + + ); +} diff --git a/apps/website/src/demos/aspect-ratio/MediaPlaceholder.tsx b/apps/website/src/demos/aspect-ratio/MediaPlaceholder.tsx new file mode 100644 index 0000000..e0ccdf1 --- /dev/null +++ b/apps/website/src/demos/aspect-ratio/MediaPlaceholder.tsx @@ -0,0 +1,32 @@ +import { MdImage } from 'react-icons/md'; +import { AspectRatio, Sheet, Typography } from 'tailwind-joy/components'; +import { iconClass } from 'tailwind-joy/utils'; +import { twMerge } from 'tailwind-merge'; +import { DisplayStand } from '@site/src/components/docs/DisplayStand'; + +export function AspectRatioMediaPlaceholder() { + return ( + + {/* TODO: Replace Sheet with Card. */} + + +
+ +
+
+
+ Title + Description of the card. +
+
+
+ ); +} diff --git a/apps/website/src/demos/aspect-ratio/MinimumAndMaximumHeight.tsx b/apps/website/src/demos/aspect-ratio/MinimumAndMaximumHeight.tsx new file mode 100644 index 0000000..229eea1 --- /dev/null +++ b/apps/website/src/demos/aspect-ratio/MinimumAndMaximumHeight.tsx @@ -0,0 +1,18 @@ +import { AspectRatio, Box } from 'tailwind-joy/components'; +import { DisplayStand } from '@site/src/components/docs/DisplayStand'; + +export function AspectRatioMinimumAndMaximumHeight() { + return ( + + + + + + + + ); +} diff --git a/apps/website/src/demos/aspect-ratio/ObjectFit.tsx b/apps/website/src/demos/aspect-ratio/ObjectFit.tsx new file mode 100644 index 0000000..96ffd5f --- /dev/null +++ b/apps/website/src/demos/aspect-ratio/ObjectFit.tsx @@ -0,0 +1,18 @@ +import { AspectRatio, Box } from 'tailwind-joy/components'; +import { DisplayStand } from '@site/src/components/docs/DisplayStand'; + +export function AspectRatioObjectFit() { + return ( + + + + A beautiful landscape. + + + + ); +} diff --git a/apps/website/src/demos/aspect-ratio/Ratio.tsx b/apps/website/src/demos/aspect-ratio/Ratio.tsx new file mode 100644 index 0000000..d5445e8 --- /dev/null +++ b/apps/website/src/demos/aspect-ratio/Ratio.tsx @@ -0,0 +1,18 @@ +import { AspectRatio, Typography } from 'tailwind-joy/components'; +import { DisplayStand } from '@site/src/components/docs/DisplayStand'; + +export function AspectRatioRatio() { + return ( + + + + 4/3 + + + + ); +} diff --git a/apps/website/src/demos/aspect-ratio/Variants.tsx b/apps/website/src/demos/aspect-ratio/Variants.tsx new file mode 100644 index 0000000..401e973 --- /dev/null +++ b/apps/website/src/demos/aspect-ratio/Variants.tsx @@ -0,0 +1,44 @@ +import { AspectRatio, Box, Typography } from 'tailwind-joy/components'; +import { DisplayStand } from '@site/src/components/docs/DisplayStand'; + +export function AspectRatioVariants() { + return ( + + {/* TODO: Replace Box with Grid. */} + + {/* TODO: Replace Box with Grid. */} + + + + Solid + + + + {/* TODO: Replace Box with Grid. */} + + + + Soft + + + + {/* TODO: Replace Box with Grid. */} + + + + Outlined + + + + {/* TODO: Replace Box with Grid. */} + + + + Plain + + + + + + ); +} diff --git a/apps/website/src/demos/aspect-ratio/index.ts b/apps/website/src/demos/aspect-ratio/index.ts new file mode 100644 index 0000000..6154dc2 --- /dev/null +++ b/apps/website/src/demos/aspect-ratio/index.ts @@ -0,0 +1,6 @@ +export { AspectRatioBasics } from './Basics'; +export { AspectRatioVariants } from './Variants'; +export { AspectRatioRatio } from './Ratio'; +export { AspectRatioObjectFit } from './ObjectFit'; +export { AspectRatioMediaPlaceholder } from './MediaPlaceholder'; +export { AspectRatioMinimumAndMaximumHeight } from './MinimumAndMaximumHeight'; diff --git a/packages/tailwind-joy/README.md b/packages/tailwind-joy/README.md index b05709c..32f7eaf 100644 --- a/packages/tailwind-joy/README.md +++ b/packages/tailwind-joy/README.md @@ -21,6 +21,7 @@ https://tailwind-joy.vercel.app ### Data display +- Aspect Ratio - Divider - Typography diff --git a/packages/tailwind-joy/src/components.ts b/packages/tailwind-joy/src/components.ts index 2417b6b..9d182ed 100644 --- a/packages/tailwind-joy/src/components.ts +++ b/packages/tailwind-joy/src/components.ts @@ -1,3 +1,4 @@ +export { AspectRatio } from './components/AspectRatio'; export { Box } from './components/Box'; export { Button } from './components/Button'; export { ButtonGroup } from './components/ButtonGroup'; diff --git a/packages/tailwind-joy/src/components/AspectRatio.tsx b/packages/tailwind-joy/src/components/AspectRatio.tsx new file mode 100644 index 0000000..158c55e --- /dev/null +++ b/packages/tailwind-joy/src/components/AspectRatio.tsx @@ -0,0 +1,240 @@ +import { clsx } from 'clsx'; +import type { ComponentProps, ForwardedRef } from 'react'; +import { + forwardRef, + createElement, + cloneElement, + isValidElement, + Children, + useMemo, +} from 'react'; +import type { + BaseVariants, + GeneratorInput, + GenericComponentPropsWithVariants, +} from '@/base/types'; +import { twMerge } from '../base/alias'; +import { addPrefix, toVariableClass } from '../base/modifier'; +import { theme } from '../base/theme'; +import { baseTokens } from '../base/tokens'; +import { excludeClassName } from '../base/utils'; + +type AspectRatioObjectFit = + | '-moz-initial' + | 'contain' + | 'cover' + | 'fill' + | 'inherit' + | 'initial' + | 'none' + | 'revert-layer' + | 'revert' + | 'scale-down' + | 'unset'; + +function aspectRatioRootVariants( + props?: Pick & { + flex?: boolean; + }, +) { + const { color = 'neutral', variant = 'soft', flex = false } = props ?? {}; + + return twMerge( + clsx([ + 'tj-aspect-ratio-root group/tj-aspect-ratio', + '[--AspectRatio-paddingBottom:clamp(var(--AspectRatio-minHeight),calc(100%/(var(--tj-AspectRatio-ratio))),var(--AspectRatio-maxHeight))]', + '[--AspectRatio-maxHeight:var(--tj-AspectRatio-maxHeight)]', + '[--AspectRatio-minHeight:var(--tj-AspectRatio-minHeight)]', + color !== 'neutral' || variant === 'solid' + ? '[--Icon-color:currentColor] dark:[--Icon-color:currentColor]' + : toVariableClass(baseTokens.text.icon, 'Icon-color'), + 'rounded-[var(--AspectRatio-radius)]', + flex ? 'flex' : 'block', + flex ? 'flex-1' : 'flex-[initial]', + 'flex-col', + 'm-[var(--AspectRatio-margin)]', + ]), + ); +} + +function aspectRatioContentVariants( + props?: Pick, +) { + const { color = 'neutral', variant = 'soft' } = props ?? {}; + + return twMerge( + clsx([ + 'tj-aspect-ratio-content', + 'flex-1', + 'relative', + 'rounded-[inherit]', + 'h-0', + 'pb-[calc(var(--AspectRatio-paddingBottom)-2*var(--variant-borderWidth,0px))]', + 'overflow-hidden', + '[transition:inherit]', + addPrefix( + clsx([ + 'flex', + 'justify-center', + 'items-center', + 'box-border', + 'absolute', + 'w-full', + 'h-full', + '[object-fit:var(--tj-AspectRatio-objectFit)]', + 'm-0', + 'p-0', + addPrefix( + clsx([ + 'w-full', + 'h-full', + '[object-fit:var(--tj-AspectRatio-objectFit)]', + ]), + '[&>img]:', + ), + ]), + '[&_[data-first-child]]:', + ), + theme.typography['body-md'].className, + theme.variants[variant][color].className, + ]), + ); +} + +type AspectRatioRootVariants = Pick & { + flex?: boolean; + maxHeight?: number | string; + minHeight?: number | string; + objectFit?: AspectRatioObjectFit; + ratio?: number | string; +} & { + slotProps?: { + root?: ComponentProps<'div'>; + content?: ComponentProps<'div'>; + }; +}; + +type AspectRatioRootProps = GenericComponentPropsWithVariants< + 'div', + AspectRatioRootVariants, + T +>; + +function AspectRatioRoot< + T extends keyof JSX.IntrinsicElements | undefined = undefined, +>( + { + // ---- non-passing props ---- + // base variants + color = 'neutral', + variant = 'soft', + + // non-base variants + className, + flex = false, + maxHeight, + minHeight, + objectFit = 'cover', + ratio = '16/9', + style, + + // slot props + slotProps = {}, + + // others + component = 'div', + children, + ...otherProps + // --------------------------- + }: AspectRatioRootProps, + ref: ForwardedRef, +) { + const slotPropsWithoutClassName = useMemo( + () => excludeClassName(slotProps), + [slotProps], + ); + + return createElement( + component, + { + ref, + className: twMerge( + aspectRatioRootVariants({ + color, + variant, + flex, + }), + className, + slotProps.root?.className ?? '', + ), + style: { + ...style, + ...(maxHeight === undefined + ? { + '--tj-AspectRatio-maxHeight': '9999px', + } + : { + '--tj-AspectRatio-maxHeight': + typeof maxHeight === 'number' ? `${maxHeight}px` : maxHeight, + }), + ...(minHeight === undefined + ? { + '--tj-AspectRatio-minHeight': '0px', + } + : { + '--tj-AspectRatio-minHeight': + typeof minHeight === 'number' ? `${minHeight}px` : minHeight, + }), + '--tj-AspectRatio-objectFit': objectFit, + '--tj-AspectRatio-ratio': ratio, + }, + ...otherProps, + ...(slotPropsWithoutClassName.root ?? {}), + }, +
+ {Children.map(children, (child, index) => { + if (!isValidElement(child)) { + return child; + } + + return cloneElement(child, { + // @ts-expect-error + 'data-first-child': index === 0 ? '' : undefined, + }); + })} +
, + ); +} + +export const AspectRatio = forwardRef(AspectRatioRoot) as < + T extends keyof JSX.IntrinsicElements | undefined = undefined, +>( + props: AspectRatioRootProps & { ref?: ForwardedRef }, +) => JSX.Element; + +export const generatorInputs: GeneratorInput[] = [ + { + generatorFn: aspectRatioRootVariants, + variants: { + color: ['primary', 'neutral', 'danger', 'success', 'warning'], + variant: ['solid', 'soft', 'outlined', 'plain'], + flex: [false, true], + }, + }, + { + generatorFn: aspectRatioContentVariants, + variants: { + color: ['primary', 'neutral', 'danger', 'success', 'warning'], + variant: ['solid', 'soft', 'outlined', 'plain'], + }, + }, +]; diff --git a/packages/tailwind-joy/src/plugins/safelist-generator.ts b/packages/tailwind-joy/src/plugins/safelist-generator.ts index 73159a1..74b9a05 100644 --- a/packages/tailwind-joy/src/plugins/safelist-generator.ts +++ b/packages/tailwind-joy/src/plugins/safelist-generator.ts @@ -1,4 +1,5 @@ import type { GeneratorInput } from '../base/types'; +import { generatorInputs as aspectRatioClassNameGeneratorInputs } from '../components/AspectRatio'; import { generatorInputs as boxClassNameGeneratorInputs } from '../components/Box'; import { generatorInputs as buttonClassNameGeneratorInputs } from '../components/Button'; import { generatorInputs as buttonGroupClassNameGeneratorInputs } from '../components/ButtonGroup'; @@ -20,6 +21,7 @@ import { generatorInputs as adaptedIconClassNameGeneratorInputs } from '../compo const SPACE = ' '; const inputs: GeneratorInput[] = [ + ...aspectRatioClassNameGeneratorInputs, ...boxClassNameGeneratorInputs, ...buttonClassNameGeneratorInputs, ...buttonGroupClassNameGeneratorInputs,