diff --git a/packages/ui/package.json b/packages/ui/package.json index 4ce1ae4236..b241b6cde1 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -37,6 +37,7 @@ "@radix-ui/react-navigation-menu": "^1.1.4", "@radix-ui/react-popover": "^1.0.7", "@radix-ui/react-progress": "^1.0.3", + "@radix-ui/react-radio-group": "^1.2.0", "@radix-ui/react-select": "^2.0.0", "@radix-ui/react-slider": "^1.1.2", "@radix-ui/react-slot": "^1.0.2", @@ -84,6 +85,7 @@ "@storybook/react": "^8.1.1", "@storybook/react-vite": "8.1.1", "@storybook/theming": "^8.1.11", + "@testing-library/user-event": "^14.5.2", "@types/humanize-duration": "^3.27.4", "@types/murmurhash3js": "^3.0.7", "@types/react": "^18.3.2", diff --git a/packages/ui/src/AddressViewComponent/AddressIcon.tsx b/packages/ui/src/AddressViewComponent/AddressIcon.tsx new file mode 100644 index 0000000000..05310d2da8 --- /dev/null +++ b/packages/ui/src/AddressViewComponent/AddressIcon.tsx @@ -0,0 +1,15 @@ +import { Address } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/keys/v1/keys_pb.js'; +import { bech32mAddress } from '@penumbra-zone/bech32m/penumbra'; +import { Identicon } from '../Identicon'; + +export interface AddressIconProps { + address: Address; + size: number; +} + +/** + * A simple component to display a consistently styled icon for a given address. + */ +export const AddressIcon = ({ address, size }: AddressIconProps) => ( + +); diff --git a/packages/ui/src/AddressViewComponent/index.stories.tsx b/packages/ui/src/AddressViewComponent/index.stories.tsx new file mode 100644 index 0000000000..80df42e238 --- /dev/null +++ b/packages/ui/src/AddressViewComponent/index.stories.tsx @@ -0,0 +1,67 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import { AddressViewComponent } from '.'; +import { AddressView } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/keys/v1/keys_pb.js'; +import { addressFromBech32m } from '@penumbra-zone/bech32m/penumbra'; +import styled from 'styled-components'; + +const EXAMPLE_VIEW_DECODED = new AddressView({ + addressView: { + case: 'decoded', + + value: { + address: { inner: new Uint8Array(80) }, + index: { + account: 0, + randomizer: new Uint8Array([0, 0, 0]), + }, + }, + }, +}); + +const EXAMPLE_VIEW_OPAQUE = new AddressView({ + addressView: { + case: 'opaque', + value: { + address: addressFromBech32m( + 'penumbra1e8k5cyds484dxvapeamwveh5khqv4jsvyvaf5wwxaaccgfghm229qw03pcar3ryy8smptevstycch0qk3uu0rgkvtjpxy3cu3rjd0agawqtlz6erev28a6sg69u7cxy0t02nd4', + ), + }, + }, +}); + +const MaxWidthWrapper = styled.div` + width: 100%; + overflow: hidden; +`; + +const meta: Meta = { + component: AddressViewComponent, + tags: ['autodocs', '!dev'], + argTypes: { + addressView: { + options: ['Sample decoded address view', 'Sample opaque address view'], + mapping: { + 'Sample decoded address view': EXAMPLE_VIEW_DECODED, + 'Sample opaque address view': EXAMPLE_VIEW_OPAQUE, + }, + }, + }, + decorators: [ + Story => ( + + + + ), + ], +}; +export default meta; + +type Story = StoryObj; + +export const Basic: Story = { + args: { + addressView: EXAMPLE_VIEW_DECODED, + copyable: true, + }, +}; diff --git a/packages/ui/src/AddressViewComponent/index.test.tsx b/packages/ui/src/AddressViewComponent/index.test.tsx new file mode 100644 index 0000000000..0c2fe536da --- /dev/null +++ b/packages/ui/src/AddressViewComponent/index.test.tsx @@ -0,0 +1,82 @@ +import { + Address, + AddressIndex, + AddressView, + AddressView_Decoded, +} from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/keys/v1/keys_pb.js'; +import { AddressViewComponent } from '.'; +import { describe, expect, it } from 'vitest'; +import { render } from '@testing-library/react'; +import { PenumbraUIProvider } from '../PenumbraUIProvider'; + +const addressViewWithOneTimeAddress = new AddressView({ + addressView: { + case: 'decoded', + + value: new AddressView_Decoded({ + address: new Address({ inner: new Uint8Array(80) }), + index: new AddressIndex({ + account: 0, + // A one-time address is defined by a randomizer with at least one + // non-zero byte. + randomizer: new Uint8Array([1, 2, 3]), + }), + }), + }, +}); + +const addressViewWithNormalAddress = new AddressView({ + addressView: { + case: 'decoded', + + value: new AddressView_Decoded({ + address: new Address({ inner: new Uint8Array(80) }), + index: new AddressIndex({ + account: 0, + randomizer: new Uint8Array([0, 0, 0]), + }), + }), + }, +}); + +describe('', () => { + describe('when `copyable` is `true`', () => { + it('does not show the copy icon when the address is a one-time address', () => { + const { queryByLabelText } = render( + , + { wrapper: PenumbraUIProvider }, + ); + + expect(queryByLabelText('Copy')).toBeNull(); + }); + + it('shows the copy icon when the address is not a one-time address', () => { + const { queryByLabelText } = render( + , + { wrapper: PenumbraUIProvider }, + ); + + expect(queryByLabelText('Copy')).not.toBeNull(); + }); + }); + + describe('when `copyable` is `false`', () => { + it('does not show the copy icon when the address is a one-time address', () => { + const { queryByLabelText } = render( + , + { wrapper: PenumbraUIProvider }, + ); + + expect(queryByLabelText('Copy')).toBeNull(); + }); + + it('does not show the copy icon when the address is not a one-time address', () => { + const { queryByLabelText } = render( + , + { wrapper: PenumbraUIProvider }, + ); + + expect(queryByLabelText('Copy')).toBeNull(); + }); + }); +}); diff --git a/packages/ui/src/AddressViewComponent/index.tsx b/packages/ui/src/AddressViewComponent/index.tsx new file mode 100644 index 0000000000..76318d176d --- /dev/null +++ b/packages/ui/src/AddressViewComponent/index.tsx @@ -0,0 +1,66 @@ +import { AddressIcon } from './AddressIcon'; +import { AddressView } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/keys/v1/keys_pb.js'; +import { bech32mAddress } from '@penumbra-zone/bech32m/penumbra'; +import styled from 'styled-components'; +import { Text } from '../Text'; +import { CopyToClipboardButton } from '../CopyToClipboardButton'; +import { Shrink0 } from '../utils/Shrink0'; + +const Root = styled.div` + display: flex; + gap: ${props => props.theme.spacing(2)}; + align-items: center; +`; + +export interface AddressViewProps { + addressView: AddressView | undefined; + copyable?: boolean; +} + +// Renders an address or an address view. +// If the view is given and is "visible", the account information will be displayed instead. +export const AddressViewComponent = ({ addressView, copyable = true }: AddressViewProps) => { + if (!addressView?.addressView.value?.address) { + return null; + } + + const accountIndex = + addressView.addressView.case === 'decoded' + ? addressView.addressView.value.index?.account + : undefined; + const isOneTimeAddress = + addressView.addressView.case === 'decoded' + ? !addressView.addressView.value.index?.randomizer.every(v => v === 0) // Randomized (and thus, a one-time address) if the randomizer is not all zeros. + : undefined; + + const addressIndexLabel = isOneTimeAddress ? 'IBC Deposit Address for Account #' : 'Account #'; + + copyable = isOneTimeAddress ? false : copyable; + + const encodedAddress = bech32mAddress(addressView.addressView.value.address); + + return ( + + + + + + {accountIndex === undefined ? ( + + {encodedAddress} + + ) : ( + + {addressIndexLabel} + {accountIndex} + + )} + + {copyable && ( + + + + )} + + ); +}; diff --git a/packages/ui/src/Button/helpers.test.ts b/packages/ui/src/Button/helpers.test.ts index d8aab34473..9edd51f9c7 100644 --- a/packages/ui/src/Button/helpers.test.ts +++ b/packages/ui/src/Button/helpers.test.ts @@ -19,6 +19,12 @@ describe('getBackgroundColor()', () => { it('returns the corresponding color for other action types', () => { expect(getBackgroundColor('destructive', 'primary', theme)).toBe('#f00'); }); + + describe('when `iconOnly` is `true`', () => { + it('returns the primary color for the `accent` action type', () => { + expect(getBackgroundColor('accent', 'primary', theme, true)).toBe('#aaa'); + }); + }); }); describe('when `priority` is `secondary`', () => { @@ -26,4 +32,10 @@ describe('getBackgroundColor()', () => { expect(getBackgroundColor('accent', 'secondary', theme)).toBe('transparent'); }); }); + + describe('when `iconOnly` is `adornment`', () => { + it('returns `transparent`', () => { + expect(getBackgroundColor('accent', 'primary', theme, 'adornment')).toBe('transparent'); + }); + }); }); diff --git a/packages/ui/src/Button/helpers.ts b/packages/ui/src/Button/helpers.ts index 9a8129aa25..ad583ee5e4 100644 --- a/packages/ui/src/Button/helpers.ts +++ b/packages/ui/src/Button/helpers.ts @@ -5,8 +5,9 @@ export const getBackgroundColor = ( actionType: ActionType, priority: Priority, theme: DefaultTheme, + iconOnly?: boolean | 'adornment', ): string => { - if (priority === 'secondary') { + if (priority === 'secondary' || iconOnly === 'adornment') { return 'transparent'; } diff --git a/packages/ui/src/Button/index.stories.tsx b/packages/ui/src/Button/index.stories.tsx index 2ffadfa5be..6516a59cd9 100644 --- a/packages/ui/src/Button/index.stories.tsx +++ b/packages/ui/src/Button/index.stories.tsx @@ -12,6 +12,10 @@ const meta: Meta = { options: ['None', 'Check', 'ArrowLeftRight'], mapping: { None: undefined, Check, ArrowLeftRight }, }, + iconOnly: { + options: ['true', 'false', 'adornment'], + mapping: { true: true, false: false, adornment: 'adornment' }, + }, onClick: { control: false }, }, }; @@ -26,5 +30,6 @@ export const Basic: Story = { disabled: false, icon: Check, iconOnly: false, + type: 'button', }, }; diff --git a/packages/ui/src/Button/index.tsx b/packages/ui/src/Button/index.tsx index 9dbde31726..543c08bb03 100644 --- a/packages/ui/src/Button/index.tsx +++ b/packages/ui/src/Button/index.tsx @@ -1,7 +1,7 @@ import { MouseEventHandler, useContext } from 'react'; import styled, { css, DefaultTheme } from 'styled-components'; import { asTransientProps } from '../utils/asTransientProps'; -import { Priority, ActionType, focusOutline, overlays } from '../utils/button'; +import { Priority, ActionType, focusOutline, overlays, buttonBase } from '../utils/button'; import { getBackgroundColor } from './helpers'; import { button } from '../utils/typography'; import { LucideIcon } from 'lucide-react'; @@ -9,6 +9,11 @@ import { ButtonPriorityContext } from '../utils/ButtonPriorityContext'; import { Density } from '../types/Density'; import { useDensity } from '../hooks/useDensity'; +const iconOnlyAdornment = css` + border-radius: ${props => props.theme.borderRadius.full}; + padding: ${props => props.theme.spacing(1)}; +`; + const sparse = css` border-radius: ${props => props.theme.borderRadius.sm}; padding-left: ${props => props.theme.spacing(4)}; @@ -43,7 +48,7 @@ const borderColorByActionType: Record< }; interface StyledButtonProps { - $iconOnly?: boolean; + $iconOnly?: boolean | 'adornment'; $actionType: ActionType; $priority: Priority; $density: Density; @@ -52,10 +57,11 @@ interface StyledButtonProps { } const StyledButton = styled.button` + ${buttonBase} ${button} - background-color: ${props => getBackgroundColor(props.$actionType, props.$priority, props.theme)}; - border: none; + background-color: ${props => + getBackgroundColor(props.$actionType, props.$priority, props.theme, props.$iconOnly)}; outline: ${props => props.$priority === 'secondary' ? `1px solid ${props.theme.color[borderColorByActionType[props.$actionType]].main}` @@ -66,11 +72,15 @@ const StyledButton = styled.button` align-items: center; justify-content: center; color: ${props => props.theme.color.neutral.contrast}; - cursor: pointer; overflow: hidden; position: relative; - ${props => (props.$density === 'sparse' ? sparse : compact)} + ${props => + props.$iconOnly === 'adornment' + ? iconOnlyAdornment + : props.$density === 'sparse' + ? sparse + : compact} ${focusOutline} ${overlays} @@ -83,8 +93,8 @@ const StyledButton = styled.button` interface BaseButtonProps { type?: HTMLButtonElement['type']; /** - * The button label. If `iconOnly` is `true`, this will be used as the - * `aria-label` attribute. + * The button label. If `iconOnly` is `true` or `adornment`, this will be used + * as the `aria-label` attribute. */ children: string; /** @@ -102,10 +112,20 @@ interface BaseButtonProps { interface IconOnlyProps { /** - * When `true`, will render just an icon button. The label text passed via - * `children` will be used as the `aria-label`. + * When set to `true`, will render just an icon button. When set to + * `adornment`, will render an icon button without the fill or outline of a + * normal button. This latter case is useful when the button is an adornment + * to another component (e.g., when it's a copy icon attached to an + * `AddressViewComponent`). + * + * In both of these cases, the label text passed via `children` will be used + * as the `aria-label`. + * + * Note that, when `iconOnly` is `adornment`, density has no impact on the + * button: it will render at the same size in either a `compact` or `sparse` + * context. */ - iconOnly: true; + iconOnly: true | 'adornment'; /** * The icon import from `lucide-react` to render. If `iconOnly` is `true`, no * label will be rendered -- just the icon. Otherwise, the icon will be @@ -121,10 +141,6 @@ interface IconOnlyProps { } interface RegularProps { - /** - * When `true`, will render just an icon button. The label text passed via - * `children` will be used as the `aria-label`. - */ iconOnly?: false; /** * The icon import from `lucide-react` to render. If `iconOnly` is `true`, no @@ -165,10 +181,14 @@ export const Button = ({ title={iconOnly ? children : undefined} $getFocusOutlineColor={theme => theme.color.action[outlineColorByActionType[actionType]]} $getBorderRadius={theme => - density === 'sparse' ? theme.borderRadius.sm : theme.borderRadius.full + density === 'sparse' && iconOnly !== 'adornment' + ? theme.borderRadius.sm + : theme.borderRadius.full } > - {IconComponent && } + {IconComponent && ( + + )} {!iconOnly && children} diff --git a/packages/ui/src/CopyToClipboardButton/index.stories.tsx b/packages/ui/src/CopyToClipboardButton/index.stories.tsx new file mode 100644 index 0000000000..023b3e78ed --- /dev/null +++ b/packages/ui/src/CopyToClipboardButton/index.stories.tsx @@ -0,0 +1,17 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import { CopyToClipboardButton } from '.'; + +const meta: Meta = { + component: CopyToClipboardButton, + tags: ['autodocs', '!dev'], +}; +export default meta; + +type Story = StoryObj; + +export const Basic: Story = { + args: { + text: 'This is sample text copied by the PenumbraUI component.', + }, +}; diff --git a/packages/ui/src/CopyToClipboardButton/index.test.tsx b/packages/ui/src/CopyToClipboardButton/index.test.tsx new file mode 100644 index 0000000000..9f123cdebf --- /dev/null +++ b/packages/ui/src/CopyToClipboardButton/index.test.tsx @@ -0,0 +1,44 @@ +import { beforeAll, describe, expect, it } from 'vitest'; +import userEvent from '@testing-library/user-event'; +import { CopyToClipboardButton } from '.'; +import { fireEvent, render, waitFor } from '@testing-library/react'; +import { PenumbraUIProvider } from '../PenumbraUIProvider'; + +describe('', () => { + beforeAll(() => { + userEvent.setup(); + }); + + it('copies the value of the `text` prop to the clipboard', async () => { + const { getByLabelText } = render(, { + wrapper: PenumbraUIProvider, + }); + + fireEvent.click(getByLabelText('Copy')); + + const clipboardText = await navigator.clipboard.readText(); + + expect(clipboardText).toBe('copy that'); + }); + + it('has an initial label of "Copy"', () => { + const { queryByLabelText } = render(, { + wrapper: PenumbraUIProvider, + }); + + expect(queryByLabelText('Copy')).toBeTruthy(); + }); + + it('changes the label to "Copied" after the user clicks it', async () => { + const { getByLabelText, queryByLabelText } = render( + , + { + wrapper: PenumbraUIProvider, + }, + ); + + fireEvent.click(getByLabelText('Copy')); + + await waitFor(() => expect(queryByLabelText('Copied')).toBeTruthy()); + }); +}); diff --git a/packages/ui/src/CopyToClipboardButton/index.tsx b/packages/ui/src/CopyToClipboardButton/index.tsx new file mode 100644 index 0000000000..8517f684c0 --- /dev/null +++ b/packages/ui/src/CopyToClipboardButton/index.tsx @@ -0,0 +1,42 @@ +import { ButtonHTMLAttributes, useState } from 'react'; +import { Copy, Check, LucideIcon } from 'lucide-react'; +import { Button } from '../Button'; + +const useClipboardButton = (text: string) => { + const [icon, setIcon] = useState(Copy); + const [label, setLabel] = useState('Copy'); + + const onClick = () => { + setIcon(Check); + setLabel('Copied'); + setTimeout(() => { + setIcon(Copy); + setLabel('Copy'); + }, 2000); + void navigator.clipboard.writeText(text); + }; + + return { onClick, icon, label }; +}; + +export interface CopyToClipboardButtonProps extends ButtonHTMLAttributes { + /** + * The text that should be copied to the clipboard when the user presses this + * button. + */ + text: string; +} + +/** + * A simple icon button for copying some text to the clipboard. Use it alongside + * text that the user may want to copy. + */ +export const CopyToClipboardButton = ({ text }: CopyToClipboardButtonProps) => { + const { onClick, icon, label } = useClipboardButton(text); + + return ( + + ); +}; diff --git a/packages/ui/src/ValueViewComponent/AssetIcon/Identicon/generate.ts b/packages/ui/src/Identicon/generate.ts similarity index 100% rename from packages/ui/src/ValueViewComponent/AssetIcon/Identicon/generate.ts rename to packages/ui/src/Identicon/generate.ts diff --git a/packages/ui/src/Identicon/index.stories.tsx b/packages/ui/src/Identicon/index.stories.tsx new file mode 100644 index 0000000000..48bb6ccb25 --- /dev/null +++ b/packages/ui/src/Identicon/index.stories.tsx @@ -0,0 +1,19 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import { Identicon } from '.'; + +const meta: Meta = { + component: Identicon, + tags: ['autodocs', '!dev'], +}; +export default meta; + +type Story = StoryObj; + +export const Basic: Story = { + args: { + uniqueIdentifier: 'abc123', + type: 'solid', + size: 24, + }, +}; diff --git a/packages/ui/src/ValueViewComponent/AssetIcon/Identicon/index.tsx b/packages/ui/src/Identicon/index.tsx similarity index 50% rename from packages/ui/src/ValueViewComponent/AssetIcon/Identicon/index.tsx rename to packages/ui/src/Identicon/index.tsx index 7b30f26d11..3959a760fd 100644 --- a/packages/ui/src/ValueViewComponent/AssetIcon/Identicon/index.tsx +++ b/packages/ui/src/Identicon/index.tsx @@ -2,10 +2,19 @@ import { useMemo } from 'react'; import { generateGradient, generateSolidColor } from './generate'; import styled from 'styled-components'; +/** + * The view box size is separate from the passed-in `size` prop. + * + * The view box controls how the elements inside the SVG are sized in relation + * to the SVG as a whole. The passed-in `size` prop controls how big the SVG as + * a whole is. + */ +const VIEW_BOX_SIZE = 24; + const Svg = styled.svg.attrs<{ $size: number }>(props => ({ width: props.$size, height: props.$size, - viewBox: `0 0 ${props.$size} ${props.$size}`, + viewBox: `0 0 ${VIEW_BOX_SIZE} ${VIEW_BOX_SIZE}`, version: '1.1', xmlns: 'http://www.w3.org/2000/svg', }))` @@ -13,13 +22,35 @@ const Svg = styled.svg.attrs<{ $size: number }>(props => ({ border-radius: ${props => props.theme.borderRadius.full}; `; +const SvgText = styled.text` + text-transform: uppercase; + font-family: ${props => props.theme.font.default}; +`; + export interface IdenticonProps { + /** + * The ID or other string representation of the object you want an identicon + * for. `` will deterministically generate a solid color or + * gradient (depending on the value of `type`) based on the value of + * `uniqueIdentifier`. + */ uniqueIdentifier: string; + /** The identicon size, in pixels. */ size?: number; - className?: string; + /** + * When `solid`, will render a solid color along with the (upper-cased) first + * character of `uniqueIdentifier`. When `gradient`, will render just a + * gradient. + */ type: 'gradient' | 'solid'; } +/** + * Renders an SVG icon whose color or gradient is deterministically generated + * based on the value of the `uniqueIdentifier` prop. + * + * Use this for assets, addresses, etc. that don't otherwise have an icon. + */ export const Identicon = (props: IdenticonProps) => { if (props.type === 'gradient') { return ; @@ -40,7 +71,13 @@ const IdenticonGradient = ({ uniqueIdentifier, size = 120 }: IdenticonProps) => - + ); @@ -51,18 +88,10 @@ const IdenticonSolid = ({ uniqueIdentifier, size = 120 }: IdenticonProps) => { return ( - - + + {uniqueIdentifier[0]} - + ); }; diff --git a/packages/ui/src/PenumbraUIProvider/theme.ts b/packages/ui/src/PenumbraUIProvider/theme.ts index 6fc39adce0..d133e46340 100644 --- a/packages/ui/src/PenumbraUIProvider/theme.ts +++ b/packages/ui/src/PenumbraUIProvider/theme.ts @@ -181,7 +181,7 @@ export const theme = { special: PALETTE.orange['400'], }, action: { - hoverOverlay: PALETTE.neutral['50'] + FIFTEEN_PERCENT_OPACITY_IN_HEX, + hoverOverlay: PALETTE.teal['400'] + FIFTEEN_PERCENT_OPACITY_IN_HEX, activeOverlay: PALETTE.neutral['950'] + FIFTEEN_PERCENT_OPACITY_IN_HEX, disabledOverlay: PALETTE.neutral['950'] + EIGHTY_PERCENT_OPACITY_IN_HEX, primaryFocusOutline: PALETTE.orange['400'], diff --git a/packages/ui/src/SegmentedControl/index.stories.tsx b/packages/ui/src/SegmentedControl/index.stories.tsx new file mode 100644 index 0000000000..829d402741 --- /dev/null +++ b/packages/ui/src/SegmentedControl/index.stories.tsx @@ -0,0 +1,41 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { useArgs } from '@storybook/preview-api'; + +import { SegmentedControl } from '.'; + +const OPTIONS = [ + { label: 'One', value: 'one' }, + { label: 'Two', value: 'two' }, + { label: 'Three', value: 'three' }, + { label: 'Four (disabled)', value: 'four', disabled: true }, +]; + +const meta: Meta = { + component: SegmentedControl, + tags: ['autodocs', '!dev', 'density'], + argTypes: { + value: { + control: 'select', + options: OPTIONS.filter(({ disabled }) => !disabled).map(({ value }) => value), + }, + options: { control: false }, + }, +}; +export default meta; + +type Story = StoryObj; + +export const Basic: Story = { + args: { + options: OPTIONS, + value: 'one', + }, + + render: function Render({ value, options }) { + const [, updateArgs] = useArgs(); + + const onChange = (value: { toString: () => string }) => updateArgs({ value }); + + return ; + }, +}; diff --git a/packages/ui/src/SegmentedControl/index.test.tsx b/packages/ui/src/SegmentedControl/index.test.tsx new file mode 100644 index 0000000000..78c00b91b8 --- /dev/null +++ b/packages/ui/src/SegmentedControl/index.test.tsx @@ -0,0 +1,38 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { fireEvent, render } from '@testing-library/react'; +import { SegmentedControl } from '.'; +import { PenumbraUIProvider } from '../PenumbraUIProvider'; + +describe('', () => { + const onChange = vi.fn(); + const options = [ + { value: 'one', label: 'One' }, + { value: 'two', label: 'Two' }, + { value: 'three', label: 'Three' }, + ]; + + beforeEach(() => { + onChange.mockReset(); + }); + + it('renders all passed-in options', () => { + const { container } = render( + , + { wrapper: PenumbraUIProvider }, + ); + + expect(container).toHaveTextContent('One'); + expect(container).toHaveTextContent('Two'); + expect(container).toHaveTextContent('Three'); + }); + + it('calls the `onClick` handler with the value of the clicked option', () => { + const { getByText } = render( + , + { wrapper: PenumbraUIProvider }, + ); + fireEvent.click(getByText('Two', { selector: ':not([aria-hidden])' })); + + expect(onChange).toHaveBeenCalledWith('two'); + }); +}); diff --git a/packages/ui/src/SegmentedControl/index.tsx b/packages/ui/src/SegmentedControl/index.tsx new file mode 100644 index 0000000000..7c86997567 --- /dev/null +++ b/packages/ui/src/SegmentedControl/index.tsx @@ -0,0 +1,92 @@ +import styled, { DefaultTheme } from 'styled-components'; +import { button } from '../utils/typography'; +import { focusOutline, overlays, buttonBase } from '../utils/button'; +import { Density } from '../types/Density'; +import { useDensity } from '../hooks/useDensity'; +import * as RadixRadioGroup from '@radix-ui/react-radio-group'; + +const Root = styled.div` + display: flex; + gap: ${props => props.theme.spacing(2)}; +`; + +const Segment = styled.button<{ + $getFocusOutlineColor: (theme: DefaultTheme) => string; + $getBorderRadius: (theme: DefaultTheme) => string; + $selected: boolean; + $density: Density; +}>` + ${buttonBase} + ${button} + ${overlays} + ${focusOutline} + + color:${props => props.theme.color.base.white}; + border: 1px solid + ${props => + props.$selected ? props.theme.color.neutral.light : props.theme.color.other.tonalStroke}; + border-radius: ${props => props.theme.borderRadius.full}; + + padding-top: ${props => props.theme.spacing(props.$density === 'sparse' ? 2 : 1)}; + padding-bottom: ${props => props.theme.spacing(props.$density === 'sparse' ? 2 : 1)}; + padding-left: ${props => props.theme.spacing(props.$density === 'sparse' ? 4 : 2)}; + padding-right: ${props => props.theme.spacing(props.$density === 'sparse' ? 4 : 2)}; +`; + +export interface Option { + value: string; + label: string; + disabled?: boolean; +} + +export interface SegmentedControlProps { + value: string; + onChange: (value: string) => void; + options: Option[]; +} + +/** + * Renders a segmented control where only one option can be selected at a time. + * Functionally equivalent to a `