forked from cerc-io/snowballtools-base
[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,
|
||||
renderVerticalTabs,
|
||||
} from './renders/tabs';
|
||||
import { SegmentedControls } from 'components/shared/SegmentedControls';
|
||||
import { SEGMENTED_CONTROLS_OPTIONS } from './renders/segmentedControls';
|
||||
import { Switch } from 'components/shared/Switch';
|
||||
import { RADIO_OPTIONS } from './renders/radio';
|
||||
import { Radio } from 'components/shared/Radio';
|
||||
@ -32,6 +34,8 @@ import { renderTooltips } from './renders/tooltip';
|
||||
const Page = () => {
|
||||
const [singleDate, setSingleDate] = useState<Value>();
|
||||
const [dateRange, setDateRange] = useState<Value>();
|
||||
const [selectedSegmentedControl, setSelectedSegmentedControl] =
|
||||
useState<string>('Test 1');
|
||||
const [switchValue, setSwitchValue] = useState(false);
|
||||
const [selectedRadio, setSelectedRadio] = useState<string>('');
|
||||
|
||||
@ -176,6 +180,26 @@ const Page = () => {
|
||||
|
||||
<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 */}
|
||||
<div className="flex flex-col gap-10 items-center justify-between">
|
||||
<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