Skip to main content
Glama
index.tsx6.88 kB
'use client'; import { cva, type VariantProps } from 'class-variance-authority'; import { type HTMLAttributes, type ReactNode, useEffect, useRef, useState, } from 'react'; import { useItemSelector } from '../../hooks'; import { cn } from '../../utils/cn'; export type SwitchSelectorChoice<T = boolean> = { content: ReactNode; value: T; } & HTMLAttributes<HTMLButtonElement>; export type SwitchSelectorChoices<T> = SwitchSelectorChoice<T>[]; const defaultChoices: SwitchSelectorChoices<boolean> = [ { content: 'Off', value: false }, { content: 'On', value: true }, ]; export type SwitchSelectorProps<T = boolean> = { choices?: SwitchSelectorChoices<T>; value?: T; defaultValue?: T; onChange?: (choice: T) => void; className?: string; hoverable?: boolean; disabled?: boolean; } & VariantProps<typeof switchSelectorVariant> & VariantProps<typeof choiceVariant>; export enum SwitchSelectorColor { PRIMARY = 'primary', SECONDARY = 'secondary', DESTRUCTIVE = 'destructive', NEUTRAL = 'neutral', LIGHT = 'light', DARK = 'dark', TEXT = 'text', } const switchSelectorVariant = cva( 'flex w-fit cursor-pointer flex-row gap-2 rounded-full border-[1.3px] p-[1.5px]', { variants: { color: { [`${SwitchSelectorColor.PRIMARY}`]: 'border-primary text-primary', [`${SwitchSelectorColor.SECONDARY}`]: 'border-secondary text-secondary', [`${SwitchSelectorColor.DESTRUCTIVE}`]: 'border-destructive bg-destructive text-destructive', [`${SwitchSelectorColor.NEUTRAL}`]: 'border-neutral text-neutral', [`${SwitchSelectorColor.LIGHT}`]: 'border-white text-white', [`${SwitchSelectorColor.DARK}`]: 'border-neutral-800 text-neutral-800', [`${SwitchSelectorColor.TEXT}`]: 'border-text text-text', }, disabled: { true: 'cursor-not-allowed opacity-50', false: '', }, }, defaultVariants: { color: `${SwitchSelectorColor.PRIMARY}`, disabled: false, }, } ); export enum SwitchSelectorSize { SM = 'sm', MD = 'md', LG = 'lg', } const choiceVariant = cva( 'z-1 w-full flex-1 cursor-pointer font-medium text-sm transition-all duration-300 ease-in-out aria-selected:cursor-default data-[indicator=true]:text-text-opposite motion-reduce:transition-none', { variants: { size: { [`${SwitchSelectorSize.SM}`]: 'px-2 py-1 text-xs', [`${SwitchSelectorSize.MD}`]: 'p-2 text-sm', [`${SwitchSelectorSize.LG}`]: 'p-4 text-base', }, }, defaultVariants: { size: `${SwitchSelectorSize.MD}`, }, } ); const indicatorVariant = cva( 'absolute top-0 z-0 h-full w-auto rounded-full transition-[left,width] duration-300 ease-in-out motion-reduce:transition-none', { variants: { color: { [`${SwitchSelectorColor.PRIMARY}`]: 'bg-primary data-[indicator=true]:text-text', [`${SwitchSelectorColor.SECONDARY}`]: 'bg-secondary data-[indicator=true]:text-text', [`${SwitchSelectorColor.DESTRUCTIVE}`]: 'bg-destructive data-[indicator=true]:text-text', [`${SwitchSelectorColor.NEUTRAL}`]: 'bg-neutral data-[indicator=true]:text-white', [`${SwitchSelectorColor.LIGHT}`]: 'bg-white data-[indicator=true]:text-black', [`${SwitchSelectorColor.DARK}`]: 'bg-neutral-800 data-[indicator=true]:text-white', [`${SwitchSelectorColor.TEXT}`]: 'bg-text data-[indicator=true]:text-text-opposite', }, }, } ); /** * * Component that allows the user to select one of the provided choices. * * Example: * ```jsx * <SwitchSelector * choices={[ * { content: 'Option 1', value: 'option1' }, * { content: 'Option 2', value: 'option2' }, * { content: 'Option 3', value: 'option3' }, * ]} * value="option1" * onChange={(choice) => console.log(choice)} * /> * ``` */ export const SwitchSelector = <T,>({ choices = defaultChoices as SwitchSelectorChoices<T>, value, defaultValue, onChange, color = SwitchSelectorColor.PRIMARY, size = SwitchSelectorSize.MD, className, hoverable = true, disabled = false, }: SwitchSelectorProps<T>) => { const [valueState, setValue] = useState<T>( value ?? defaultValue ?? choices[0].value ); const [hoveredIndex, setHoveredIndex] = useState<number | null>(null); const optionsRefs = useRef<HTMLButtonElement[]>([]); const indicatorRef = useRef<HTMLDivElement | null>(null); const { choiceIndicatorPosition } = useItemSelector(optionsRefs, { isHoverable: hoverable, }); const selectedIndex = choices.findIndex( (choice) => choice.value === valueState ); // The indicator follows hover if hoverable, otherwise the selected option const indicatorIndex = hoverable && hoveredIndex !== null ? hoveredIndex : selectedIndex; const handleChange = (newValue: T) => { setValue(newValue); onChange?.(newValue); }; useEffect(() => { if (value === undefined) return; setValue(value); }, [value]); return ( <div className={switchSelectorVariant({ color, disabled, className, })} role="tablist" aria-disabled={disabled ? 'true' : undefined} > <div className="relative flex size-full flex-row items-center justify-center"> {choices.map((choice, index) => { const { content, value, ...buttonProps } = choice; const isKeyOfKey = typeof value === 'string' || typeof value === 'number'; const isSelected = index === selectedIndex; const isIndicatorOwner = index === indicatorIndex; return ( <button {...buttonProps} className={cn( choiceVariant({ size, }), disabled && 'cursor-not-allowed' )} key={isKeyOfKey ? value : index} role="tab" onClick={() => handleChange(value)} aria-selected={isSelected ? 'true' : undefined} data-indicator={isIndicatorOwner ? 'true' : undefined} disabled={disabled || isSelected} tabIndex={isSelected ? 0 : -1} ref={(el) => { optionsRefs.current[index] = el!; }} onMouseEnter={() => !disabled && setHoveredIndex(index)} onMouseLeave={() => !disabled && setHoveredIndex(null)} > {content} </button> ); })} {choiceIndicatorPosition && ( <div className={cn( indicatorVariant({ color, }) )} style={choiceIndicatorPosition} ref={indicatorRef} /> )} </div> </div> ); };

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/aymericzip/intlayer'

If you have feedback or need assistance with the MCP directory API, please join our Discord server