[T-4865: feat] Segmented controls component (#91)

* ️ feat: create segmented controls component

* 📝 docs: add segmented controls component to example page

* ♻️ refactor: put the icon size to icon theme

* 🐛 fix: remove `value` from `useCallback` dependency
This commit is contained in:
Wahyu Kurniawan 2024-02-23 10:20:08 +07:00 committed by GitHub
parent 3718e260f5
commit 9b6c777f5f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 316 additions and 0 deletions

View File

@ -0,0 +1,77 @@
import React, {
forwardRef,
type ComponentPropsWithoutRef,
type ReactNode,
} from 'react';
import {
segmentedControlsTheme,
type SegmentedControlsVariants,
} from './SegmentedControls.theme';
import { cloneIcon } from 'utils/cloneIcon';
/**
* Interface for the props of a segmented control item component.
*/
export interface SegmentedControlItemProps
extends Omit<ComponentPropsWithoutRef<'button'>, 'type' | 'children'>,
SegmentedControlsVariants {
/**
* The optional left icon element for a component.
*/
leftIcon?: ReactNode;
/**
* The optional right icon element to display.
*/
rightIcon?: ReactNode;
/**
* Indicates whether the item is active or not.
*/
active?: boolean;
/**
* Optional prop that represents the children of a React component.
*/
children?: ReactNode;
}
/**
* A functional component that represents an item in a segmented control.
* @returns The rendered segmented control item.
*/
const SegmentedControlItem = forwardRef<
HTMLButtonElement,
SegmentedControlItemProps
>(
(
{
className,
children,
size,
type,
leftIcon,
rightIcon,
active = false,
...props
},
ref,
) => {
const { item, icon } = segmentedControlsTheme({ size, type });
return (
<button
{...props}
ref={ref}
className={item({ className })}
data-active={active}
>
{leftIcon && cloneIcon(leftIcon, { className: icon({ size }) })}
{children}
{rightIcon && cloneIcon(rightIcon, { className: icon({ size }) })}
</button>
);
},
);
SegmentedControlItem.displayName = 'SegmentedControlItem';
export { SegmentedControlItem };

View File

@ -0,0 +1,76 @@
import { tv, type VariantProps } from 'tailwind-variants';
/**
* Defines the theme for a segmented controls.
*/
export const segmentedControlsTheme = tv({
slots: {
parent: [
'flex',
'items-center',
'bg-base-bg-emphasized',
'gap-0.5',
'rounded-lg',
],
item: [
'flex',
'items-center',
'justify-center',
'gap-2',
'text-elements-mid-em',
'bg-transparent',
'border',
'border-transparent',
'cursor-default',
'whitespace-nowrap',
'rounded-lg',
'focus-ring',
'hover:bg-controls-tertiary-hovered',
'focus-visible:z-20',
'focus-visible:bg-controls-tertiary-hovered',
'disabled:text-controls-disabled',
'disabled:bg-transparent',
'disabled:cursor-not-allowed',
'disabled:border-transparent',
'data-[active=true]:bg-controls-tertiary',
'data-[active=true]:text-elements-high-em',
'data-[active=true]:border-border-interactive/10',
'data-[active=true]:shadow-field',
'data-[active=true]:hover:bg-controls-tertiary-hovered',
],
icon: [],
},
variants: {
size: {
sm: {
item: ['px-3', 'py-2', 'text-xs'],
icon: ['h-4', 'w-4'],
},
md: {
item: ['px-4', 'py-3', 'text-sm', 'tracking-[-0.006em]'],
icon: ['h-5', 'w-5'],
},
},
type: {
'fixed-width': {
parent: ['w-fit'],
item: ['w-fit'],
},
'full-width': {
parent: ['w-full'],
item: ['w-full'],
},
},
},
defaultVariants: {
size: 'md',
type: 'fixed-width',
},
});
/**
* Defines the type for the variants of a segmented controls.
*/
export type SegmentedControlsVariants = VariantProps<
typeof segmentedControlsTheme
>;

View File

@ -0,0 +1,93 @@
import React, {
useCallback,
type ComponentPropsWithoutRef,
type ReactNode,
} from 'react';
import {
SegmentedControlItem,
type SegmentedControlItemProps,
} from './SegmentedControlItem';
import {
segmentedControlsTheme,
type SegmentedControlsVariants,
} from './SegmentedControls.theme';
/**
* Represents an option for a segmented control.
*/
export interface SegmentedControlsOption
extends Omit<SegmentedControlItemProps, 'children'> {
/**
* The label of the item.
*/
label: ReactNode;
/**
* The value of the item.
*
*/
value: string;
}
/**
* Represents the props for the SegmentedControls component.
*/
export interface SegmentedControlsProps<T extends string = string>
extends Omit<ComponentPropsWithoutRef<'div'>, 'onChange'>,
SegmentedControlsVariants {
/**
* An array of options for a segmented control component.
*/
options: SegmentedControlsOption[];
/**
* An optional string value.
*/
value?: T;
/**
* Optional callback function to handle changes in state.
*/
onChange?: (v: T) => void;
}
/**
* A component that renders segmented controls with customizable options.
*/
export function SegmentedControls<T extends string = string>({
className,
options,
value,
type,
size,
onChange,
...props
}: SegmentedControlsProps<T>) {
const { parent } = segmentedControlsTheme({ size, type });
/**
* Handles the change event for a given option.
*/
const handleChange = useCallback(
(option: T) => {
if (!option) return;
onChange?.(option);
},
[onChange],
);
return (
<div {...props} className={parent({ className })}>
{options.map((option, index) => (
<SegmentedControlItem
key={index}
active={value === option.value}
size={size}
type={type}
onClick={() => handleChange(option.value as T)}
{...option}
>
{option.label}
</SegmentedControlItem>
))}
</div>
);
}

View File

@ -0,0 +1,2 @@
export * from './SegmentedControlItem';
export * from './SegmentedControls';

View File

@ -18,6 +18,8 @@ import {
renderTabs, renderTabs,
renderVerticalTabs, renderVerticalTabs,
} from './renders/tabs'; } from './renders/tabs';
import { SegmentedControls } from 'components/shared/SegmentedControls';
import { SEGMENTED_CONTROLS_OPTIONS } from './renders/segmentedControls';
import { Switch } from 'components/shared/Switch'; import { Switch } from 'components/shared/Switch';
import { RADIO_OPTIONS } from './renders/radio'; import { RADIO_OPTIONS } from './renders/radio';
import { Radio } from 'components/shared/Radio'; import { Radio } from 'components/shared/Radio';
@ -32,6 +34,8 @@ import { renderTooltips } from './renders/tooltip';
const Page = () => { const Page = () => {
const [singleDate, setSingleDate] = useState<Value>(); const [singleDate, setSingleDate] = useState<Value>();
const [dateRange, setDateRange] = useState<Value>(); const [dateRange, setDateRange] = useState<Value>();
const [selectedSegmentedControl, setSelectedSegmentedControl] =
useState<string>('Test 1');
const [switchValue, setSwitchValue] = useState(false); const [switchValue, setSwitchValue] = useState(false);
const [selectedRadio, setSelectedRadio] = useState<string>(''); const [selectedRadio, setSelectedRadio] = useState<string>('');
@ -176,6 +180,26 @@ const Page = () => {
<div className="w-full h border border-gray-200 px-20 my-10" /> <div className="w-full h border border-gray-200 px-20 my-10" />
{/* Segmented Controls */}
<div className="flex flex-col gap-10 items-center justify-between">
<h1 className="text-2xl font-bold">Segmented Controls</h1>
<div className="flex flex-col gap-10">
<SegmentedControls
options={SEGMENTED_CONTROLS_OPTIONS}
value={selectedSegmentedControl}
onChange={setSelectedSegmentedControl}
/>
<SegmentedControls
size="sm"
options={SEGMENTED_CONTROLS_OPTIONS}
value={selectedSegmentedControl}
onChange={setSelectedSegmentedControl}
/>
</div>
</div>
<div className="w-full h border border-gray-200 px-20 my-10" />
{/* Switch */} {/* Switch */}
<div className="flex flex-col gap-10 items-center justify-between"> <div className="flex flex-col gap-10 items-center justify-between">
<h1 className="text-2xl font-bold">Switch</h1> <h1 className="text-2xl font-bold">Switch</h1>

View File

@ -0,0 +1,44 @@
import React from 'react';
import { Badge } from 'components/shared/Badge';
import { SegmentedControlsOption } from 'components/shared/SegmentedControls';
export const SEGMENTED_CONTROLS_OPTIONS: SegmentedControlsOption[] = [
{ label: 'Test 1', value: 'Test 1' },
{
label: 'Test 2',
value: 'Test 2',
leftIcon: (
<Badge size="xs" variant="tertiary">
1
</Badge>
),
},
{
label: 'Test 3',
value: 'Test 3',
rightIcon: (
<Badge size="xs" variant="tertiary">
1
</Badge>
),
},
{
label: 'Test 4',
value: 'Test 4',
leftIcon: (
<Badge size="xs" variant="tertiary">
1
</Badge>
),
rightIcon: (
<Badge size="xs" variant="tertiary">
1
</Badge>
),
},
{
label: 'Test 5',
value: 'Test 5',
disabled: true,
},
];