diff --git a/packages/ui/src/Colors.mdx b/packages/ui/src/Colors.mdx new file mode 100644 index 0000000000..316f688d59 --- /dev/null +++ b/packages/ui/src/Colors.mdx @@ -0,0 +1,10 @@ +import { Meta, Canvas } from '@storybook/blocks'; +import * as ColorsStories from './Colors.stories'; + + + +# Colors + +Below are the colors we use at Penumbra. We deliberately do not include RGB/hex values for our colors, as they should be used via tokens — e.g., `text.primary`, `neutral.light`, `destructive.main`, etc. — to ensure consistency. + + diff --git a/packages/ui/src/Colors.stories.tsx b/packages/ui/src/Colors.stories.tsx new file mode 100644 index 0000000000..6c196f59ec --- /dev/null +++ b/packages/ui/src/Colors.stories.tsx @@ -0,0 +1,108 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { Grid } from './Grid'; +import { Technical } from './Typography'; +import styled from 'styled-components'; +import { + type ColorVariant, + type Color as TColor, + type TextColorVariant, +} from './ThemeProvider/theme'; +import { Fragment } from 'react'; +import { media } from './utils/media'; + +const meta: Meta = {}; +export default meta; + +const Label = styled.div` + display: flex; + height: 100%; + + ${media.tablet` + align-items: center; + `} +`; + +const Variants = styled.div` + display: grid; + gap: ${props => props.theme.spacing(4)}; + grid-template-columns: 1fr; + + ${media.tablet` + grid-template-columns: repeat(4, 1fr); + `} +`; + +type VariantProps = + | { + $color: 'text'; + $colorVariant: TextColorVariant; + } + | { + $color: Exclude; + $colorVariant: ColorVariant; + }; + +const Variant = styled.div` + background-color: ${props => + props.$color === 'text' ? 'transparent' : props.theme.color[props.$color][props.$colorVariant]}; + border-radius: ${props => props.theme.borderRadius.xl}; + color: ${props => + props.$color === 'text' + ? props.theme.color.text[props.$colorVariant] + : props.$colorVariant === 'contrast' || props.$colorVariant === 'light' + ? props.theme.color[props.$color].dark + : props.theme.color.text.primary}; + padding: ${props => props.theme.spacing(2)}; +`; + +const BASE_COLORS: Exclude[] = [ + 'neutral', + 'primary', + 'secondary', + 'unshield', + 'destructive', + 'caution', + 'success', +]; + +const Color = >({ color }: { color: T }) => ( + + + + + + + {color === 'text' + ? (['primary', 'secondary', 'disabled', 'special'] as const).map(variant => ( + + {variant} + + )) + : (['main', 'light', 'dark', 'contrast'] as const).map(variant => ( + + {variant} + + ))} + + + +); + +export const ColorGrid: StoryObj = { + tags: ['!dev'], + render: function Render() { + return ( + <> + + + + {BASE_COLORS.map(color => ( + + ))} + + + ); + }, +}; diff --git a/packages/ui/src/Grid/index.stories.tsx b/packages/ui/src/Grid/index.stories.tsx new file mode 100644 index 0000000000..14f95a435b --- /dev/null +++ b/packages/ui/src/Grid/index.stories.tsx @@ -0,0 +1,74 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { Grid } from '.'; +import styled from 'styled-components'; +import { Text } from '../Text'; + +const meta: Meta = { + component: Grid, + title: 'Grid', + tags: ['autodocs', '!dev'], + argTypes: { + container: { control: false }, + mobile: { control: false }, + tablet: { control: false }, + desktop: { control: false }, + lg: { control: false }, + xl: { control: false }, + as: { control: false }, + }, +}; +export default meta; + +type Story = StoryObj; + +const Item = styled.div` + background-color: ${props => props.theme.color.neutral.main}; + display: flex; + align-items: center; + justify-content: center; + padding: ${props => props.theme.spacing(2)}; +`; + +export const Demo: Story = { + render: function Render() { + return ( + + + + mobile=12 + + + + {Array(2) + .fill(null) + .map((_, index) => ( + + + mobile=12 tablet=6 + + + ))} + + {Array(4) + .fill(null) + .map((_, index) => ( + + + mobile=6 tablet=6 desktop=3 + + + ))} + + {Array(48) + .fill(null) + .map((_, index) => ( + + + lg=1 + + + ))} + + ); + }, +}; diff --git a/packages/ui/src/Grid/index.tsx b/packages/ui/src/Grid/index.tsx new file mode 100644 index 0000000000..4d8c882456 --- /dev/null +++ b/packages/ui/src/Grid/index.tsx @@ -0,0 +1,135 @@ +import { PropsWithChildren } from 'react'; +import styled from 'styled-components'; +import { AsTransientProps, asTransientProps } from '../utils/asTransientProps'; +import { media } from '../utils/media'; + +type GridElement = 'div' | 'main' | 'section'; + +interface BaseGridProps extends Record { + /** Which element to use. Defaults to `'div'`. */ + as?: GridElement; +} + +interface GridContainerProps extends BaseGridProps { + /** Whether this is a grid container, vs. an item. */ + container: true; + + // For some reason, Storybook needs these properties to be defined on the + // container props interface in order to show their typings properly. + mobile?: undefined; + tablet?: undefined; + desktop?: undefined; + lg?: undefined; + xl?: undefined; +} + +interface GridItemProps extends BaseGridProps { + /** Whether this is a grid container, vs. an item. */ + container?: false; + /** + * The number of columns this grid item should span on mobile. + * + * The mobile grid layout can only be split in half, so you can only set a + * grid item to 6 or 12 columns on mobile. + */ + mobile?: 6 | 12; + /** + * The number of columns this grid item should span on tablet. + * + * The tablet grid layout can only be split into six columns. + */ + tablet?: 2 | 4 | 6 | 8 | 10 | 12; + /** The number of columns this grid item should span on desktop. */ + desktop?: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12; + /** The number of columns this grid item should span on large screens. */ + lg?: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12; + /** The number of columns this grid item should span on XL screens. */ + xl?: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12; +} + +export type GridProps = PropsWithChildren; + +const Container = styled.div` + display: grid; + grid-template-columns: repeat(12, 1fr); + gap: ${props => props.theme.spacing(4)}; +`; + +const Item = styled.div>>` + ${props => media.mobile` + grid-column: span ${props.$mobile ?? 12}; + `} + + ${props => + props.$tablet && + media.tablet` + grid-column: span ${props.$tablet}; + `} + + ${props => + props.$desktop && + media.desktop` + grid-column: span ${props.$desktop}; + `} + + ${props => + props.$lg && + media.lg` + grid-column: span ${props.$lg}; + `} + + ${props => + props.$xl && + media.xl` + grid-column: span ${props.$xl}; + `} +`; + +/** + * A responsive grid component that makes 12-column layouts super easy to build. + * + * Pass the `container` prop to the root `` component; then, any nested + * children ``s will be treated as grid items. You can customize which + * HTML element to use for each grid container or item by passing the element's + * name via the optional `as` prop. + * + * Use the `` component — rather than styling your own HTML elements + * with `display: grid` — to ensure consistent behavior (such as grid gutters) + * throughout your app. + * + * ```tsx + * + * This will span the full width on all screen sizes. + * + * So will this. + * + * + * These will span the full width on mobile... + * + * + * + * ...but half the width on desktop. + * + * + * + * These will... + * + * + * + * ...take up... + * + * + * + * ...one third each. + * + * + * ``` + */ +export const Grid = ({ container, children, as = 'div', ...props }: GridProps) => + container ? ( + {children} + ) : ( + + {children} + + ); diff --git a/packages/ui/src/Icon/index.stories.ts b/packages/ui/src/Icon/index.stories.ts index f8f114b316..4e15331937 100644 --- a/packages/ui/src/Icon/index.stories.ts +++ b/packages/ui/src/Icon/index.stories.ts @@ -5,7 +5,7 @@ import { Icon } from '.'; const meta: Meta = { component: Icon, - tags: ['autodocs'], + tags: ['autodocs', '!dev'], argTypes: { IconComponent: { options: ['ArrowRightLeft', 'Send', 'Wallet'], diff --git a/packages/ui/src/Icon/index.tsx b/packages/ui/src/Icon/index.tsx index eebab87627..154f2d9969 100644 --- a/packages/ui/src/Icon/index.tsx +++ b/packages/ui/src/Icon/index.tsx @@ -46,6 +46,10 @@ const PROPS_BY_SIZE: Record> = { * component rather than rendering Lucide icon components directly, since this * component standardizes the stroke width and sizes throughout the Penumbra * ecosystem. + * + * ```tsx + * + * ``` */ export const Icon = ({ IconComponent, size = 'sm', color }: IconProps) => ( diff --git a/packages/ui/src/Text/index.stories.tsx b/packages/ui/src/Text/index.stories.tsx new file mode 100644 index 0000000000..2fccaf195f --- /dev/null +++ b/packages/ui/src/Text/index.stories.tsx @@ -0,0 +1,155 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import { Text } from '.'; +import styled from 'styled-components'; +import { useArgs } from '@storybook/preview-api'; + +const meta: Meta = { + component: Text, + tags: ['autodocs', '!dev'], + argTypes: { + h1: { control: false }, + h2: { control: false }, + h3: { control: false }, + h4: { control: false }, + large: { control: false }, + body: { control: false }, + p: { control: false }, + strong: { control: false }, + detail: { control: false }, + small: { control: false }, + technical: { control: false }, + + as: { + options: ['span', 'div', 'h1', 'h2', 'h3', 'h4', 'p', 'main', 'section'], + }, + }, +}; +export default meta; + +const Wrapper = styled.div<{ $dir: 'column' | 'row' }>` + display: flex; + flex-direction: ${props => props.$dir}; + ${props => (props.$dir === 'row' ? `align-items: center;` : '')} + gap: ${props => props.theme.spacing(2)}; +`; + +const OPTIONS = [ + 'h1', + 'h2', + 'h3', + 'h4', + 'large', + 'body', + 'p', + 'strong', + 'detail', + 'small', + 'technical', +] as const; + +const Option = ({ + value, + checked, + onSelect, +}: { + value: (typeof OPTIONS)[number]; + checked: boolean; + onSelect: (value: (typeof OPTIONS)[number]) => void; +}) => ( + +); +export const KitchenSink: StoryObj = { + args: { + children: 'The quick brown fox jumps over the lazy dog.', + h1: true, + as: 'span', + }, + + render: function Render(props) { + const [, updateArgs] = useArgs(); + + const onSelect = (option: (typeof OPTIONS)[number]) => + updateArgs( + OPTIONS.reduce( + (prev, curr) => ({ + ...prev, + [curr]: curr === option ? true : undefined, + }), + {}, + ), + ); + + return ( + + + Text style: + {OPTIONS.map(option => ( + + + + + ); + }, +}; + +export const UsageExample: StoryObj = { + render: function Render() { + return ( + <> + h1. Typography + h2. This is a section + + Here is some filler text: Giggster kickstarter painting with light + academy award charlie kaufman shotdeck breakdown services indie white balance. Student + emmys sound design ots character arc low angle coming-of-age composition. Storyboard beat + sheet greenlight cowboy shot margarita shot blocking foley stage seed&spark. + + + + Shot list low angle mit out sound telephoto rec.709 high angle eyeline assembly cut 8 1/2 + dga. Post-viz circle of confusion location scout unpaid internship reality of doing genre + film. Jean-luc godard ilm symbolism alexa mini white balance margarita shot. Jordan peele + log line ryan coogler actors access. + + + h2. Section two + + Silent film conflict sound design blocking script treatment. Teal and orange composition + fotokem third act blackmagic ingmar bergman jordan peele rembrandt lighting critical + darling silent film. Wes anderson arthouse diegetic sound after effects. + + + This is some large text. + + + White balance crafty debut film pan up 180-degree rule academy award exposure triangle + director's vision. Lavs led wall the actor prepares wrylies character arc stinger + sanford meisner. Given circumstances under-exposed jordan peele color grade nomadland team + deakins crafty dogme 95. French new wave pan up save the cat contrast ratio blue filter + cinema studies super 16 jump cut cannes unreal engine. + + + + Establishing shot stella adler ludwig göransson first-time director shotdeck fotokem + over-exposed flashback reality of doing color grade. Fetch coffee student emmys indie key + light rembrandt lighting. Undercranking beat beat scriptnotes podcast. Sound design + academy award day-for-night christopher nolan undercranking. Unreal engine visionary match + cut grain vs. noise 35mm anti-hero production design. + + + ); + }, +}; diff --git a/packages/ui/src/Text/index.tsx b/packages/ui/src/Text/index.tsx new file mode 100644 index 0000000000..3b3ca6d3cc --- /dev/null +++ b/packages/ui/src/Text/index.tsx @@ -0,0 +1,242 @@ +import styled, { WebTarget } from 'styled-components'; +import { body, detail, h1, h2, h3, h4, large, small, strong, technical } from '../utils/typography'; +import { ReactNode } from 'react'; + +const H1 = styled.h1` + ${h1} +`; + +const H2 = styled.h2` + ${h2} +`; + +const H3 = styled.h3` + ${h3} +`; + +const H4 = styled.h4` + ${h4} +`; + +const Large = styled.span` + ${large} +`; + +const Body = styled.span` + ${body} +`; + +const Strong = styled.span` + ${strong} +`; + +const Detail = styled.span` + ${detail} +`; + +const Small = styled.span` + ${small} +`; + +const Technical = styled.span` + ${technical} +`; + +const P = styled.p` + ${body} + + margin-bottom: ${props => props.theme.lineHeight.textBase}; + + &:last-child { + margin-bottom: 0; + } +`; + +/** + * Utility interface to be used below to ensure that only one text type is used + * at a time. + */ +interface NeverTextTypes { + h1?: never; + h2?: never; + h3?: never; + h4?: never; + large?: never; + p?: never; + strong?: never; + detail?: never; + small?: never; + technical?: never; + body?: never; +} + +type TextType = + | (Omit & { + /** + * Renders a styled `

`. Pass the `as` prop to use a different HTML + * element with the same styling. + */ + h1: true; + }) + | (Omit & { + /** + * Renders a styled `

`. Pass the `as` prop to use a different HTML + * element with the same styling. + */ + h2: true; + }) + | (Omit & { + /** + * Renders a styled `

`. Pass the `as` prop to use a different HTML + * element with the same styling. + */ + h3: true; + }) + | (Omit & { + /** + * Renders a styled `

`. Pass the `as` prop to use a different HTML + * element with the same styling. + */ + h4: true; + }) + | (Omit & { + /** + * Renders bigger text used for section titles. Renders a `` by + * default; pass the `as` prop to use a different HTML element with the + * same styling. + */ + large: true; + }) + | (Omit & { + /** + * Renders a styled `

` tag with a bottom-margin (unless it's the last + * child). Aside from the margin, `

` is identical to ``. + * + * Note that this is the only component in the entire Penumbra UI library + * that renders an external margin. It's a convenience for developers who + * don't want to wrap each `` in a `

` with the + * appropriate margin, or a flex columnn with a gap. + */ + p: true; + }) + | (Omit & { + /** + * Emphasized body text. + * + * Renders a `` by default; pass the `as` prop to use a different + * HTML element with the same styling. + */ + strong: true; + }) + | (Omit & { + /** + * Detail text used for small bits of tertiary information. + * + * Renders a `` by default; pass the `as` prop to use a different + * HTML element with the same styling. + */ + detail: true; + }) + | (Omit & { + /** + * Small text used for secondary information. + * + * Renders a `` by default; pass the `as` prop to use a different + * HTML element with the same styling. + */ + small: true; + }) + | (Omit & { + /** + * Monospaced text used for code, values, and other technical information. + * + * Renders a `` by default; pass the `as` prop to use a different + * HTML element with the same styling. + */ + technical: true; + }) + | (Omit & { + /** + * Body text used throughout most of our UIs. + * + * Renders a `` by default; pass the `as` prop to use a different + * HTML element with the same styling. + */ + body?: true; + }); + +export type TextProps = TextType & { + children?: ReactNode; + /** + * Which component or HTML element to render this text as. + * + * @example + * ```tsx + * This is a span with H1 styling + * ``` + */ + as?: WebTarget; +}; + +/** + * All-purpose text wrapper for quickly styling text per the Penumbra UI + * guidelines. + * + * Use with a _single_ text style name: + * + * ```tsx + * This will be rendered with the `h1` style. + * This will be rendered with the `body` style. + * + * INCORRECT: This will result in a TypeScript error. Only use one text style + * at a time. + * + * ``` + * + * When no text style is passed, it will render using the `body` style. + * + * The heading text styles are rendered as their corresponding heading tags + * (`

`, `

`, etc.), and the `p` style is rendered as a `

` tag. + * All other styles are rendered as ``s. To customize which tag is + * rendered without affecting its appearance, use the `as` prop: + * + * ```tsx + * + * This will render with the h1 style, but inside an inline span tag. + * + * ``` + */ +export const Text = (props: TextProps) => { + if (props.h1) { + return

; + } + if (props.h2) { + return

; + } + if (props.h3) { + return

; + } + if (props.h4) { + return

; + } + if (props.large) { + return ; + } + if (props.strong) { + return ; + } + if (props.detail) { + return ; + } + if (props.small) { + return ; + } + if (props.technical) { + return ; + } + if (props.p) { + return

; + } + + return ; +}; diff --git a/packages/ui/src/utils/asTransientProps.test.ts b/packages/ui/src/utils/asTransientProps.test.ts new file mode 100644 index 0000000000..1245812c13 --- /dev/null +++ b/packages/ui/src/utils/asTransientProps.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest'; +import { asTransientProps } from './asTransientProps'; + +describe('asTransientProps()', () => { + it('converts all properties to have a `$` prefix', () => { + const props = { + size: 'lg', + color: 'red', + }; + + const expected = { + $size: 'lg', + $color: 'red', + }; + + expect(asTransientProps(props)).toEqual(expected); + }); +}); diff --git a/packages/ui/src/utils/asTransientProps.ts b/packages/ui/src/utils/asTransientProps.ts new file mode 100644 index 0000000000..12b7e14e03 --- /dev/null +++ b/packages/ui/src/utils/asTransientProps.ts @@ -0,0 +1,25 @@ +// Thanks to https://stackoverflow.com/a/65278278/974981 for the prefixer +// utility types +type Prefix = `${K}${T}`; + +type Prefixer = { + [P in keyof K as Prefix]: K[P]; +}; + +export type AsTransientProps> = Prefixer; + +/** + * Converts a props object to an object of "transient props" -- i.e., where each + * key is prefixed with a `$` to indicate to styled-components that it should + * not be passed down to the DOM element. + * + * @see https://styled-components.com/docs/api#transient-props + * @internal + */ +export const asTransientProps = >( + props: T, +): AsTransientProps => + Object.entries(props).reduce>>((prev, curr) => { + const [key, value] = curr; + return { ...prev, [`$${key}`]: value }; + }, {}) as unknown as AsTransientProps; diff --git a/packages/ui/src/utils/typography.ts b/packages/ui/src/utils/typography.ts new file mode 100644 index 0000000000..c39433f618 --- /dev/null +++ b/packages/ui/src/utils/typography.ts @@ -0,0 +1,126 @@ +import { css } from 'styled-components'; + +/** + * This file contains styles that are used throughout the Penumbra UI library. + * Many of them correlate 1-to-1 to specific components (such as `h1`, `large`, + * etc.), while others are base styles shared by a number of components. + */ + +const base = ` + margin: 0; +`; + +export const h1 = css` + ${base} + + font-family: ${props => props.theme.font.heading}; + font-size: ${props => props.theme.fontSize.text6xl}; + font-weight: 500; + line-height: ${props => props.theme.lineHeight.text6xl}; +`; + +export const h2 = css` + ${base} + + font-family: ${props => props.theme.font.heading}; + font-size: ${props => props.theme.fontSize.text5xl}; + font-weight: 500; + line-height: ${props => props.theme.lineHeight.text5xl}; +`; + +export const h3 = css` + ${base} + + font-family: ${props => props.theme.font.heading}; + font-size: ${props => props.theme.fontSize.text4xl}; + font-weight: 500; + line-height: ${props => props.theme.lineHeight.text4xl}; +`; + +export const h4 = css` + ${base} + + font-family: ${props => props.theme.font.heading}; + font-size: ${props => props.theme.fontSize.text3xl}; + font-weight: 500; + line-height: ${props => props.theme.lineHeight.text3xl}; +`; + +export const large = css` + ${base} + + font-family: ${props => props.theme.font.default}; + font-size: ${props => props.theme.fontSize.textLg}; + font-weight: 500; + line-height: ${props => props.theme.lineHeight.textLg}; +`; + +export const body = css` + ${base} + + font-family: ${props => props.theme.font.default}; + font-size: ${props => props.theme.fontSize.textBase}; + font-weight: 400; + line-height: ${props => props.theme.lineHeight.textBase}; +`; + +export const strong = css` + ${base} + + font-family: ${props => props.theme.font.default}; + font-size: ${props => props.theme.fontSize.textBase}; + font-weight: 500; + line-height: ${props => props.theme.lineHeight.textBase}; +`; + +export const detail = css` + ${base} + + font-family: ${props => props.theme.font.default}; + font-size: ${props => props.theme.fontSize.textXs}; + font-weight: 500; + line-height: ${props => props.theme.lineHeight.textXs}; +`; + +export const small = css` + ${base} + + font-family: ${props => props.theme.font.default}; + font-size: ${props => props.theme.fontSize.textSm}; + font-weight: 400; + line-height: ${props => props.theme.lineHeight.textSm}; +`; + +export const tableItem = css` + ${base} + + font-family: ${props => props.theme.font.default}; + font-size: ${props => props.theme.fontSize.textBase}; + font-weight: 400; + line-height: ${props => props.theme.lineHeight.textBase}; +`; + +export const tableHeading = css` + ${base} + + font-family: ${props => props.theme.font.default}; + font-size: ${props => props.theme.fontSize.textBase}; + font-weight: 500; + line-height: ${props => props.theme.lineHeight.textBase}; +`; + +export const technical = css` + ${base} + + font-family: ${props => props.theme.font.mono}; + font-size: ${props => props.theme.fontSize.textSm}; + font-weight: 500; + line-height: ${props => props.theme.lineHeight.textSm}; +`; + +export const button = css` + font-family: ${props => props.theme.font.default}; + font-size: ${props => props.theme.fontSize.textBase}; + font-weight: 500; + line-height: ${props => props.theme.lineHeight.textBase}; +`;