[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:
parent
3718e260f5
commit
9b6c777f5f
@ -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 };
|
@ -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
|
||||||
|
>;
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
@ -0,0 +1,2 @@
|
|||||||
|
export * from './SegmentedControlItem';
|
||||||
|
export * from './SegmentedControls';
|
@ -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>
|
||||||
|
@ -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,
|
||||||
|
},
|
||||||
|
];
|
Loading…
Reference in New Issue
Block a user