admin/components/ui/SegmentedControl.tsx
alisaza e1eaf5eff5 feat: initial ghabilee-admin backoffice app
Extract admin dashboard from ghabilee-frontend2 into a dedicated Next.js
app for backoffice.ghabilee.ir (no SEO indexing / Clarity).
2026-09-05 13:12:59 +03:30

125 lines
3.8 KiB
TypeScript

'use client'
/**
* SegmentedControl — equal-width options in a pill track with a sliding indicator.
*
* Purpose
* - Shared single-select chrome for short fixed option sets (gender, ticket type,
* my-events status filters, and similar).
* - Soft: white sliding pill on a muted track.
* - Solid: dark sliding pill (e.g. free / paid).
*
* When NOT to use
* - Wrapping chip rows of unequal width → keep dedicated chip groups.
* - Scrollable many-item rows → use `AppTabs` with a scrollable tab list.
*/
import type { ReactNode } from 'react'
import { motion, useReducedMotion } from 'framer-motion'
import Button from '@/components/formElements/Button'
import { cn } from '@/lib/cn'
export type SegmentedControlVariant = 'soft' | 'solid'
export interface SegmentedControlOption<T extends string = string> {
value: T
label: ReactNode
icon?: ReactNode
/** Always-on classes (e.g. per-option color). Active state still toggles weight. */
className?: string
}
interface SegmentedControlProps<T extends string = string> {
options: readonly SegmentedControlOption<T>[]
/** Active value. When it matches no option, the sliding pill is hidden. */
value: T | null | undefined
onChange: (value: T) => void
ariaLabel: string
/** Unique framer-motion layout id so multiple controls on one page do not clash. */
layoutId: string
variant?: SegmentedControlVariant
size?: 'sm' | 'md' | 'lg'
className?: string
}
const SIZE_CLASSES = {
sm: 'min-h-9 text-[13px]',
md: 'min-h-11 text-sm',
lg: 'min-h-12 text-base',
} as const
const SegmentedControl = <T extends string>({
options,
value,
onChange,
ariaLabel,
layoutId,
variant = 'soft',
size = 'md',
className,
}: SegmentedControlProps<T>) => {
const reduceMotion = useReducedMotion()
const isSoft = variant === 'soft'
if (!options.length) return null
return (
<div
aria-label={ariaLabel}
className={cn('grid p-1', isSoft ? 'rounded-full bg-secondary-50' : 'rounded-consumer-control bg-[#E2E2E280]', className)}
role="group"
style={{ gridTemplateColumns: `repeat(${options.length}, minmax(0, 1fr))` }}
>
{options.map((option) => {
const isActive = option.value === value
return (
<div
key={option.value}
className="relative min-w-0"
>
{isActive ? (
<motion.span
aria-hidden
className={cn('absolute inset-0 shadow-sm', isSoft ? 'rounded-full bg-white' : 'rounded-xl bg-tertiary-900')}
layoutId={layoutId}
transition={reduceMotion ? { duration: 0 } : { type: 'spring', stiffness: 420, damping: 34, mass: 0.8 }}
/>
) : null}
<Button
fullWidth
aria-pressed={isActive}
className={cn(
'consumer-focus-ring relative z-10 h-full w-full min-w-0 gap-1 bg-transparent px-1 font-medium hover:bg-transparent hover:opacity-80',
isSoft ? 'rounded-full' : 'rounded-xl',
SIZE_CLASSES[size],
isSoft
? isActive
? 'font-bold text-tertiary-900'
: 'text-default-600'
: isActive
? 'font-semibold text-white'
: 'text-consumer-text',
option.className
)}
color="default"
iconStart={option.icon}
radius={isSoft ? 'full' : 'lg'}
variant="light"
onClick={() => {
onChange(option.value)
}}
>
<span className="truncate">{option.label}</span>
</Button>
</div>
)
})}
</div>
)
}
export default SegmentedControl