diff --git a/packages/frontend/src/components/shared/SegmentedControls/SegmentedControlItem.tsx b/packages/frontend/src/components/shared/SegmentedControls/SegmentedControlItem.tsx new file mode 100644 index 00000000..aee2c883 --- /dev/null +++ b/packages/frontend/src/components/shared/SegmentedControls/SegmentedControlItem.tsx @@ -0,0 +1,79 @@ +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, '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 iconSize = size === 'sm' ? 16 : 20; + const { item } = segmentedControlsTheme({ size, type }); + + return ( + + ); + }, +); + +SegmentedControlItem.displayName = 'SegmentedControlItem'; + +export { SegmentedControlItem }; diff --git a/packages/frontend/src/components/shared/SegmentedControls/SegmentedControls.theme.ts b/packages/frontend/src/components/shared/SegmentedControls/SegmentedControls.theme.ts new file mode 100644 index 00000000..521cab9b --- /dev/null +++ b/packages/frontend/src/components/shared/SegmentedControls/SegmentedControls.theme.ts @@ -0,0 +1,73 @@ +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', + ], + }, + variants: { + size: { + sm: { + item: ['px-3', 'py-2', 'text-xs'], + }, + md: { + item: ['px-4', 'py-3', 'text-sm', 'tracking-[-0.006em]'], + }, + }, + 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 +>; diff --git a/packages/frontend/src/components/shared/SegmentedControls/SegmentedControls.tsx b/packages/frontend/src/components/shared/SegmentedControls/SegmentedControls.tsx new file mode 100644 index 00000000..c1111723 --- /dev/null +++ b/packages/frontend/src/components/shared/SegmentedControls/SegmentedControls.tsx @@ -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 { + /** + * The label of the item. + */ + label: ReactNode; + /** + * The value of the item. + * + */ + value: string; +} + +/** + * Represents the props for the SegmentedControls component. + */ +export interface SegmentedControlsProps + extends Omit, '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({ + className, + options, + value, + type, + size, + onChange, + ...props +}: SegmentedControlsProps) { + const { parent } = segmentedControlsTheme({ size, type }); + + /** + * Handles the change event for a given option. + */ + const handleChange = useCallback( + (option: T) => { + if (!option) return; + onChange?.(option); + }, + [value, onChange], + ); + + return ( +
+ {options.map((option, index) => ( + handleChange(option.value as T)} + {...option} + > + {option.label} + + ))} +
+ ); +} diff --git a/packages/frontend/src/components/shared/SegmentedControls/index.ts b/packages/frontend/src/components/shared/SegmentedControls/index.ts new file mode 100644 index 00000000..5d569509 --- /dev/null +++ b/packages/frontend/src/components/shared/SegmentedControls/index.ts @@ -0,0 +1,2 @@ +export * from './SegmentedControlItem'; +export * from './SegmentedControls';