Skip to content

Commit

Permalink
feat(ui): #2044: SegmentedControl UI component (#2045)
Browse files Browse the repository at this point in the history
* feat(ui): #2044: `SegmentedControl` UI component

* chore: changeset

* docs(ui): #2044: add JSDoc to SegmentedControl

* fix: pnpm-lock
  • Loading branch information
VanishMax authored Feb 13, 2025
1 parent 03597b7 commit 28e2ccb
Show file tree
Hide file tree
Showing 6 changed files with 399 additions and 11 deletions.
5 changes: 5 additions & 0 deletions .changeset/pretty-hotels-shop.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@penumbra-zone/ui': minor
---

Create new `SegmentedControl` UI component
1 change: 1 addition & 0 deletions packages/ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
"@radix-ui/react-slider": "^1.1.2",
"@radix-ui/react-tabs": "^1.0.4",
"@radix-ui/react-toggle": "^1.0.3",
"@radix-ui/react-toggle-group": "^1.1.2",
"@radix-ui/react-tooltip": "^1.0.7",
"clsx": "^2.1.1",
"lucide-react": "^0.378.0",
Expand Down
74 changes: 74 additions & 0 deletions packages/ui/src/SegmentedControl/index.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import type { Meta, StoryObj } from '@storybook/react';

import { SegmentedControl } from '.';
import { ComponentType, useState } from 'react';

const meta: Meta<typeof SegmentedControl> = {
component: SegmentedControl,
tags: ['autodocs', '!dev', 'density'],
argTypes: {},
subcomponents: {
'SegmentedControl.Item': SegmentedControl.Item as ComponentType<unknown>,
},
};
export default meta;

type Story = StoryObj<typeof SegmentedControl>;

export const Basic: Story = {
args: {
value: 'one',
},

render: function Render(args) {
const [value, setValue] = useState(args.value);

return (
<SegmentedControl {...args} value={value} onChange={setValue}>
<SegmentedControl.Item value='one' />
<SegmentedControl.Item value='two' />
<SegmentedControl.Item value='three' />
</SegmentedControl>
);
},
};

export const Colorful: Story = {
args: {
value: 'three',
},

render: function Render(args) {
const [value, setValue] = useState(args.value);

return (
<SegmentedControl {...args} value={value} onChange={setValue}>
<SegmentedControl.Item value='one' style='unfilled' />
<SegmentedControl.Item value='two' style='filled' />
<SegmentedControl.Item value='three' style='red' />
<SegmentedControl.Item value='four' style='green' />
<SegmentedControl.Item value='five' style='unfilled' />
</SegmentedControl>
);
},
};

export const Disabled: Story = {
args: {
value: 'three',
},

render: function Render(args) {
const [value, setValue] = useState(args.value);

return (
<SegmentedControl {...args} value={value} onChange={setValue}>
<SegmentedControl.Item value='one' style='unfilled' disabled />
<SegmentedControl.Item value='two' style='filled' disabled />
<SegmentedControl.Item value='three' style='red' disabled />
<SegmentedControl.Item value='four' style='green' />
<SegmentedControl.Item value='five' style='unfilled' />
</SegmentedControl>
);
},
};
126 changes: 126 additions & 0 deletions packages/ui/src/SegmentedControl/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import cn from 'clsx';
import { ElementType, ReactNode } from 'react';
import * as ToggleGroup from '@radix-ui/react-toggle-group';
import { Density, useDensity } from '../utils/density';
import { buttonMedium, button } from '../utils/typography';

export interface SegmentedControlItemProps {
/** A string that is prompted into SegmentedControl's `onChange` when an Item is pressed */
value: string;
/** Visual identity, gives a color to a selected Item */
style?: 'red' | 'green' | 'filled' | 'unfilled';
as?: ElementType;
disabled?: boolean;
children?: ReactNode;
}

export interface SegmentedControlProps {
/** A value that selects one of the items within SegmentedControl */
value: string;
onChange: (value: string) => void;
children: ReactNode;
as?: ElementType;
}

const getItemClassesByDensity = (density: Density): string => {
if (density === 'sparse') {
return cn('h-8 px-4 py-1', button);
}
return cn('h-6 px-2', buttonMedium);
};

const getItemClassesByStyle = (style: SegmentedControlItemProps['style']): string => {
if (style === 'red') {
return cn(
'aria-checked:bg-destructive-main aria-checked:border-transparent aria-checked:focus:outline-action-destructiveFocusOutline',
);
}
if (style === 'green') {
return cn(
'aria-checked:bg-success-main aria-checked:border-transparent aria-checked:focus:outline-action-successFocusOutline',
);
}
if (style === 'filled') {
return cn('aria-checked:bg-neutral-main aria-checked:border-transparent');
}
return cn('aria-checked:border-neutral-light');
};

export const SegmentedControlItem = ({
as: Container = 'button',
value,
style,
disabled,
children,
}: SegmentedControlItemProps) => {
const density = useDensity();

return (
<ToggleGroup.Item
value={value}
asChild
disabled={disabled}
className={cn(
'flex items-center justify-center overflow-hidden relative',
'text-text-secondary aria-checked:text-text-primary',
'bg-transparent border border-other-tonalStroke',
'transition-colors',
getItemClassesByDensity(density),
getItemClassesByStyle(style),
// Hover style
'[&:not(:disabled)]:hover:border-neutral-light after:content-[""] after:absolute after:inset-0 after:bg-transparent after:pointer-events-none [&:not(:disabled)]:hover:after:bg-action-hoverOverlay after:transition-colors',
// Focus style
'outline outline-2 outline-transparent focus:outline-action-neutralFocusOutline',
// Disabled style
'disabled:cursor-not-allowed before:content-[""] before:absolute before:inset-0 before:bg-transparent before:pointer-events-none disabled:before:bg-action-disabledOverlay before:transition-colors',
// Correctly round and hide borders based on the item position
'only:rounded-full first:rounded-l-full last:rounded-r-full',
'[&:not(:last-child)]:border-r-0 [&[aria-checked="true"]:not(:last-child)]:border-r-[1px] [&[aria-checked="true"]:not(:last-child)+*]:border-l-0',
'[&:hover:not(:last-child)]:border-r-[1px] [&:hover:not(:last-child)+*]:border-l-0',
)}
>
<Container>{children ?? value}</Container>
</ToggleGroup.Item>
);
};

/**
* SegmentedControl is a single-choice selector, consisting of multiple button-looking checkboxes.
* Use it to fit a list of options in a small space.
*
* Example:
*
* ```tsx
* const Component = () => {
* const [value, setValue] = useState('one');
*
* return (
* <SegmentedControl value={value} onChange={setValue}>
* <SegmentedControl.Item value='one' style='unfilled' disabled />
* <SegmentedControl.Item value='two' style='filled' />
* <SegmentedControl.Item value='three' style='red' />
* <SegmentedControl.Item value='four' style='green' />
* </SegmentedControl>
* );
* },
* ```
*/
export const SegmentedControl = ({
children,
as: Container = 'div',
value,
onChange,
}: SegmentedControlProps) => {
return (
<ToggleGroup.Root
type='single'
asChild
value={value}
onValueChange={onChange}
className={cn('flex items-center')}
>
<Container>{children}</Container>
</ToggleGroup.Root>
);
};
SegmentedControl.Item = SegmentedControlItem;
4 changes: 2 additions & 2 deletions packages/ui/src/utils/typography.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,6 @@ export const p = cn('font-default text-textBase font-normal leading-textBase mb-

export const button = cn('font-default text-textBase font-medium leading-textBase');

export const buttonMedium = cn('font-default text-textBase font-medium leading-textBase');
export const buttonMedium = cn('font-default text-textSm font-medium leading-textBase');

export const buttonSmall = cn('font-default text-textSm font-medium leading-textBase');
export const buttonSmall = cn('font-default text-textXs font-medium leading-textBase');
Loading

0 comments on commit 28e2ccb

Please sign in to comment.