mirror of https://github.com/buster-so/buster.git
added segmented
This commit is contained in:
parent
ce300ad08e
commit
1997f3d3a8
|
@ -1,107 +0,0 @@
|
||||||
'use client';
|
|
||||||
|
|
||||||
import * as React from 'react';
|
|
||||||
import * as Tabs from '@radix-ui/react-tabs';
|
|
||||||
import { motion } from 'framer-motion';
|
|
||||||
import { cn } from '@/lib/classMerge';
|
|
||||||
import { useEffect, useState } from 'react';
|
|
||||||
|
|
||||||
export interface SegmentedItem {
|
|
||||||
value: string;
|
|
||||||
label: React.ReactNode;
|
|
||||||
icon?: React.ReactNode;
|
|
||||||
disabled?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RadixSegmentedProps {
|
|
||||||
items: SegmentedItem[];
|
|
||||||
value?: string;
|
|
||||||
onChange?: (value: string) => void;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const RadixSegmented = React.forwardRef<HTMLDivElement, RadixSegmentedProps>(
|
|
||||||
({ items, value, onChange, className }, ref) => {
|
|
||||||
const tabRefs = React.useRef<Map<string, HTMLButtonElement>>(new Map());
|
|
||||||
const [selectedValue, setSelectedValue] = useState(value || items[0]?.value);
|
|
||||||
const [hoveredValue, setHoveredValue] = useState<string | null>(null);
|
|
||||||
const [gliderStyle, setGliderStyle] = useState({
|
|
||||||
width: 0,
|
|
||||||
transform: 'translateX(0)'
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (value !== undefined && value !== selectedValue) {
|
|
||||||
setSelectedValue(value);
|
|
||||||
}
|
|
||||||
}, [value]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const selectedTab = tabRefs.current.get(selectedValue);
|
|
||||||
if (selectedTab) {
|
|
||||||
const { offsetWidth, offsetLeft } = selectedTab;
|
|
||||||
setGliderStyle({
|
|
||||||
width: offsetWidth,
|
|
||||||
transform: `translateX(${offsetLeft}px)`
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [selectedValue]);
|
|
||||||
|
|
||||||
const handleValueChange = (newValue: string) => {
|
|
||||||
setSelectedValue(newValue);
|
|
||||||
onChange?.(newValue);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Tabs.Root
|
|
||||||
ref={ref}
|
|
||||||
value={selectedValue}
|
|
||||||
onValueChange={handleValueChange}
|
|
||||||
className={cn(
|
|
||||||
'relative inline-flex h-10 items-center rounded-lg bg-gray-100 p-1',
|
|
||||||
className
|
|
||||||
)}>
|
|
||||||
<motion.div
|
|
||||||
className="absolute h-8 rounded-md bg-white shadow-sm"
|
|
||||||
initial={false}
|
|
||||||
animate={{
|
|
||||||
width: gliderStyle.width,
|
|
||||||
x: parseInt(gliderStyle.transform.replace('translateX(', '').replace('px)', ''))
|
|
||||||
}}
|
|
||||||
transition={{
|
|
||||||
type: 'spring',
|
|
||||||
stiffness: 400,
|
|
||||||
damping: 35
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Tabs.List className="relative z-10 flex items-center gap-1" aria-label="Segmented Control">
|
|
||||||
{items.map((item) => (
|
|
||||||
<Tabs.Trigger
|
|
||||||
key={item.value}
|
|
||||||
value={item.value}
|
|
||||||
disabled={item.disabled}
|
|
||||||
ref={(el) => {
|
|
||||||
if (el) tabRefs.current.set(item.value, el);
|
|
||||||
}}
|
|
||||||
onMouseEnter={() => !item.disabled && setHoveredValue(item.value)}
|
|
||||||
onMouseLeave={() => setHoveredValue(null)}
|
|
||||||
className={cn(
|
|
||||||
'relative z-10 flex h-8 items-center justify-center gap-2 rounded-md px-3 text-sm font-medium transition-colors',
|
|
||||||
'focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2',
|
|
||||||
selectedValue === item.value
|
|
||||||
? 'text-gray-900'
|
|
||||||
: 'text-gray-500 hover:text-gray-900',
|
|
||||||
hoveredValue === item.value && selectedValue !== item.value && 'bg-gray-50/50',
|
|
||||||
item.disabled && 'cursor-not-allowed opacity-50 hover:text-gray-500'
|
|
||||||
)}>
|
|
||||||
{item.icon && <span className="flex items-center">{item.icon}</span>}
|
|
||||||
{item.label}
|
|
||||||
</Tabs.Trigger>
|
|
||||||
))}
|
|
||||||
</Tabs.List>
|
|
||||||
</Tabs.Root>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
RadixSegmented.displayName = 'RadixSegmented';
|
|
|
@ -1,9 +1,9 @@
|
||||||
import type { Meta, StoryObj } from '@storybook/react';
|
import type { Meta, StoryObj } from '@storybook/react';
|
||||||
import { RadixSegmented } from './RadixSegmented';
|
import { Segmented } from './Segmented';
|
||||||
|
|
||||||
const meta: Meta<typeof RadixSegmented> = {
|
const meta: Meta<typeof Segmented> = {
|
||||||
title: 'UI/RadixSegmented',
|
title: 'Base/Segmented',
|
||||||
component: RadixSegmented,
|
component: Segmented,
|
||||||
parameters: {
|
parameters: {
|
||||||
layout: 'centered'
|
layout: 'centered'
|
||||||
},
|
},
|
||||||
|
@ -11,11 +11,11 @@ const meta: Meta<typeof RadixSegmented> = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export default meta;
|
export default meta;
|
||||||
type Story = StoryObj<typeof RadixSegmented>;
|
type Story = StoryObj<typeof Segmented>;
|
||||||
|
|
||||||
const defaultItems = [
|
const defaultItems = [
|
||||||
{ value: 'tab1', label: 'Tab 1' },
|
{ value: 'tab1', label: 'Tab 1' },
|
||||||
{ value: 'tab2', label: 'Tab 2' },
|
{ value: 'tab2', label: 'Tab 2', disabled: true },
|
||||||
{ value: 'tab3', label: 'Tab 3' }
|
{ value: 'tab3', label: 'Tab 3' }
|
||||||
];
|
];
|
||||||
|
|
|
@ -0,0 +1,117 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import * as Tabs from '@radix-ui/react-tabs';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { cn } from '@/lib/classMerge';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
export interface SegmentedItem {
|
||||||
|
value: string;
|
||||||
|
label: React.ReactNode;
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SegmentedProps {
|
||||||
|
items: SegmentedItem[];
|
||||||
|
value?: string;
|
||||||
|
onChange?: (value: string) => void;
|
||||||
|
className?: string;
|
||||||
|
size?: 'default' | 'large';
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Segmented = React.forwardRef<HTMLDivElement, SegmentedProps>(
|
||||||
|
({ items, value, onChange, className, size = 'default' }, ref) => {
|
||||||
|
const tabRefs = React.useRef<Map<string, HTMLButtonElement>>(new Map());
|
||||||
|
const [selectedValue, setSelectedValue] = useState(value || items[0]?.value);
|
||||||
|
const [hoveredValue, setHoveredValue] = useState<string | null>(null);
|
||||||
|
const [gliderStyle, setGliderStyle] = useState({
|
||||||
|
width: 0,
|
||||||
|
transform: 'translateX(0)'
|
||||||
|
});
|
||||||
|
|
||||||
|
const height = size === 'default' ? 'h-[28px]' : 'h-[50px]';
|
||||||
|
const innerHeight = size === 'default' ? 'h-[28px]' : 'h-[50px]';
|
||||||
|
const padding = size === 'default' ? 'p-[0px]' : 'p-[0px]';
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (value !== undefined && value !== selectedValue) {
|
||||||
|
setSelectedValue(value);
|
||||||
|
}
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const selectedTab = tabRefs.current.get(selectedValue);
|
||||||
|
if (selectedTab) {
|
||||||
|
const { offsetWidth, offsetLeft } = selectedTab;
|
||||||
|
setGliderStyle({
|
||||||
|
width: offsetWidth,
|
||||||
|
transform: `translateX(${offsetLeft}px)`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [selectedValue]);
|
||||||
|
|
||||||
|
const handleValueChange = (newValue: string) => {
|
||||||
|
setSelectedValue(newValue);
|
||||||
|
onChange?.(newValue);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tabs.Root
|
||||||
|
ref={ref}
|
||||||
|
value={selectedValue}
|
||||||
|
onValueChange={handleValueChange}
|
||||||
|
className={cn(
|
||||||
|
'bg-item-select relative inline-flex items-center rounded-lg',
|
||||||
|
height,
|
||||||
|
padding,
|
||||||
|
className
|
||||||
|
)}>
|
||||||
|
<motion.div
|
||||||
|
className={cn('absolute rounded-md border border-gray-200 bg-white', innerHeight)}
|
||||||
|
initial={false}
|
||||||
|
animate={{
|
||||||
|
width: gliderStyle.width,
|
||||||
|
x: parseInt(gliderStyle.transform.replace('translateX(', '').replace('px)', ''))
|
||||||
|
}}
|
||||||
|
transition={{
|
||||||
|
type: 'spring',
|
||||||
|
stiffness: 400,
|
||||||
|
damping: 35
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tabs.List className="relative z-10 flex items-center gap-1" aria-label="Segmented Control">
|
||||||
|
{items.map((item) => (
|
||||||
|
<Tabs.Trigger
|
||||||
|
key={item.value}
|
||||||
|
value={item.value}
|
||||||
|
disabled={item.disabled}
|
||||||
|
ref={(el) => {
|
||||||
|
if (el) tabRefs.current.set(item.value, el);
|
||||||
|
}}
|
||||||
|
onMouseEnter={() => !item.disabled && setHoveredValue(item.value)}
|
||||||
|
onMouseLeave={() => setHoveredValue(null)}
|
||||||
|
className={cn(
|
||||||
|
'relative z-10 flex items-center justify-center gap-2 rounded-md px-2.5 text-sm transition-colors',
|
||||||
|
innerHeight,
|
||||||
|
'focus-visible:ring-primary focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2',
|
||||||
|
selectedValue === item.value
|
||||||
|
? 'text-foreground'
|
||||||
|
: 'text-gray-dark hover:text-foreground',
|
||||||
|
hoveredValue === item.value && selectedValue !== item.value && 'bg-gray-50/50',
|
||||||
|
item.disabled
|
||||||
|
? 'text-foreground/30 hover:text-foreground/30 cursor-not-allowed'
|
||||||
|
: 'cursor-pointer'
|
||||||
|
)}>
|
||||||
|
{item.icon && <span className="flex items-center">{item.icon}</span>}
|
||||||
|
{item.label}
|
||||||
|
</Tabs.Trigger>
|
||||||
|
))}
|
||||||
|
</Tabs.List>
|
||||||
|
</Tabs.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
Segmented.displayName = 'Segmented';
|
Loading…
Reference in New Issue