mirror of https://github.com/buster-so/buster.git
update segmented components
This commit is contained in:
parent
e053f68391
commit
d53e003ce2
|
@ -3,4 +3,7 @@ description: Rules for new stories
|
|||
globs: src/components/**/*.stories.tsx
|
||||
alwaysApply: false
|
||||
---
|
||||
All new stories title with "UI/directory/{componentName}" unless specified otherwise.
|
||||
- All new stories title with "UI/directory/{componentName}" unless specified otherwise.
|
||||
- Instead of console log for click events, you should do native storybook actions
|
||||
- If a new story is made in the controller directory, I would like it title "Controllers/{controller name or parent controller name}/{componentName}"
|
||||
- If a new story is found in the features directory, I would like it titled "Features/{featureName or parent feature name}/{componentName}"
|
|
@ -1,12 +1,11 @@
|
|||
'use client';
|
||||
|
||||
import { AppSegmented } from '@/components/ui';
|
||||
import { AppSegmented, type SegmentedItem } from '@/components/ui/segmented';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import React, { useMemo } from 'react';
|
||||
import { DatasetApps, DataSetAppText } from '../config';
|
||||
import { createBusterRoute, BusterRoutes } from '@/routes';
|
||||
import { useMemoizedFn } from 'ahooks';
|
||||
import { SegmentedValue } from 'antd/es/segmented';
|
||||
|
||||
export const DatasetsHeaderOptions: React.FC<{
|
||||
selectedApp: DatasetApps;
|
||||
|
@ -22,7 +21,7 @@ export const DatasetsHeaderOptions: React.FC<{
|
|||
[isAdmin]
|
||||
);
|
||||
|
||||
const options = useMemo(
|
||||
const options: SegmentedItem<DatasetApps>[] = useMemo(
|
||||
() =>
|
||||
optionsItems.map((key) => ({
|
||||
label: DataSetAppText[key as DatasetApps],
|
||||
|
@ -32,8 +31,8 @@ export const DatasetsHeaderOptions: React.FC<{
|
|||
[datasetId, optionsItems]
|
||||
);
|
||||
|
||||
const onChangeSegment = useMemoizedFn((value: SegmentedValue) => {
|
||||
if (datasetId) push(keyToRoute(datasetId, value as DatasetApps));
|
||||
const onChangeSegment = useMemoizedFn((value: SegmentedItem<DatasetApps>) => {
|
||||
if (datasetId) push(keyToRoute(datasetId, value.value));
|
||||
});
|
||||
|
||||
return <AppSegmented options={options} value={selectedApp} onChange={onChangeSegment} />;
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { AppSegmented } from '@/components/ui';
|
||||
import { useMemoizedFn } from 'ahooks';
|
||||
import { createStyles } from 'antd-style';
|
||||
import { SegmentedValue } from 'antd/es/segmented';
|
||||
import { type SegmentedItem } from '@/components/ui/segmented';
|
||||
import React from 'react';
|
||||
|
||||
export enum EditorApps {
|
||||
|
@ -20,8 +20,8 @@ export const EditorContainerSubHeader: React.FC<{
|
|||
}> = React.memo(({ selectedApp, setSelectedApp }) => {
|
||||
const { styles, cx } = useStyles();
|
||||
|
||||
const onSegmentedChange = useMemoizedFn((value: SegmentedValue) => {
|
||||
setSelectedApp(value as EditorApps);
|
||||
const onSegmentedChange = useMemoizedFn((value: SegmentedItem<EditorApps>) => {
|
||||
setSelectedApp(value.value);
|
||||
});
|
||||
|
||||
return (
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import React, { useMemo } from 'react';
|
||||
import { AppSegmented } from '@/components/ui/segmented';
|
||||
import { useMemoizedFn } from 'ahooks';
|
||||
import { SegmentedValue } from 'antd/es/segmented';
|
||||
import { type SegmentedItem } from '@/components/ui/segmented';
|
||||
import { Divider } from 'antd';
|
||||
import { createBusterRoute, BusterRoutes } from '@/routes';
|
||||
|
||||
|
@ -30,7 +30,7 @@ export const UserSegments: React.FC<{
|
|||
onSelectApp: (app: UserSegmentsApps) => void;
|
||||
userId: string;
|
||||
}> = React.memo(({ isAdmin, selectedApp, onSelectApp, userId }) => {
|
||||
const onChange = useMemoizedFn((value: SegmentedValue) => {
|
||||
const onChange = useMemoizedFn((value: SegmentedItem) => {
|
||||
onSelectApp(value as UserSegmentsApps);
|
||||
});
|
||||
const options = useMemo(
|
||||
|
|
|
@ -4,7 +4,7 @@ import { CopyLinkButton } from './CopyLinkButton';
|
|||
import { ShareAssetType } from '@/api/asset_interfaces';
|
||||
import { ShareRole } from '@/api/asset_interfaces';
|
||||
import { useMemoizedFn } from 'ahooks';
|
||||
import { SegmentedValue } from 'antd/es/segmented';
|
||||
import { type SegmentedItem } from '@/components/ui/segmented';
|
||||
|
||||
export enum ShareMenuTopBarOptions {
|
||||
Share = 'Share',
|
||||
|
@ -45,7 +45,7 @@ export const ShareMenuTopBar: React.FC<{
|
|||
.map((o) => ({ ...o, show: undefined }));
|
||||
}, [assetType, isOwner]);
|
||||
|
||||
const onChange = useMemoizedFn((v: SegmentedValue) => {
|
||||
const onChange = useMemoizedFn((v: SegmentedItem) => {
|
||||
onChangeSelectedOption(v as ShareMenuTopBarOptions);
|
||||
});
|
||||
|
||||
|
|
|
@ -15,7 +15,7 @@ import { useBusterSearchContextSelector } from '@/context/Search';
|
|||
import isEmpty from 'lodash/isEmpty';
|
||||
import { useBusterDashboardContextSelector } from '@/context/Dashboards';
|
||||
import { useBusterCollectionIndividualContextSelector } from '@/context/Collections';
|
||||
import { SegmentedValue } from 'antd/es/segmented';
|
||||
import { type SegmentedItem } from '@/components/ui/segmented';
|
||||
import { BusterSearchRequest } from '@/api/buster_socket/search';
|
||||
import { busterAppStyleConfig } from '@/styles/busterAntDStyleConfig';
|
||||
|
||||
|
@ -350,7 +350,7 @@ const ModalContent: React.FC<{
|
|||
onSelectChange,
|
||||
onClose
|
||||
}) => {
|
||||
const onSetSelectedFiltersPreflight = useMemoizedFn((value: SegmentedValue) => {
|
||||
const onSetSelectedFiltersPreflight = useMemoizedFn((value: SegmentedItem) => {
|
||||
onSetSelectedFilter(value as string);
|
||||
});
|
||||
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
import { Segmented } from './Segmented';
|
||||
import { AppSegmented } from './AppSegmented';
|
||||
import { BottleChampagne, Grid, HouseModern, PaintRoller } from '../icons';
|
||||
|
||||
const meta: Meta<typeof Segmented> = {
|
||||
title: 'UI/Segmented',
|
||||
component: Segmented,
|
||||
const meta: Meta<typeof AppSegmented> = {
|
||||
title: 'UI/Segmented/AppSegmented',
|
||||
component: AppSegmented,
|
||||
parameters: {
|
||||
layout: 'centered'
|
||||
},
|
||||
|
@ -28,14 +28,14 @@ const meta: Meta<typeof Segmented> = {
|
|||
render: (args) => {
|
||||
return (
|
||||
<div className="flex w-full min-w-[500px] flex-col items-center justify-center gap-4">
|
||||
<Segmented {...args} />
|
||||
<AppSegmented {...args} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof Segmented>;
|
||||
type Story = StoryObj<typeof AppSegmented>;
|
||||
|
||||
const defaultItems = [
|
||||
{ value: 'tab1', label: 'Tab 1', icon: <HouseModern /> },
|
||||
|
@ -45,20 +45,20 @@ const defaultItems = [
|
|||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
items: defaultItems
|
||||
options: defaultItems
|
||||
}
|
||||
};
|
||||
|
||||
export const Large: Story = {
|
||||
args: {
|
||||
items: defaultItems,
|
||||
options: defaultItems,
|
||||
size: 'large'
|
||||
}
|
||||
};
|
||||
|
||||
export const Block: Story = {
|
||||
args: {
|
||||
items: defaultItems,
|
||||
options: defaultItems,
|
||||
block: true
|
||||
},
|
||||
parameters: {
|
||||
|
@ -68,7 +68,7 @@ export const Block: Story = {
|
|||
|
||||
export const LargeBlock: Story = {
|
||||
args: {
|
||||
items: defaultItems,
|
||||
options: defaultItems,
|
||||
size: 'large',
|
||||
block: true
|
||||
},
|
||||
|
@ -79,7 +79,7 @@ export const LargeBlock: Story = {
|
|||
|
||||
export const WithIcons: Story = {
|
||||
args: {
|
||||
items: [
|
||||
options: [
|
||||
{ value: 'list', label: 'List', icon: <PaintRoller /> },
|
||||
{ value: 'grid', label: 'Grid', icon: <Grid /> },
|
||||
{ value: 'gallery', label: 'Gallery', icon: <BottleChampagne /> }
|
||||
|
@ -89,14 +89,14 @@ export const WithIcons: Story = {
|
|||
|
||||
export const Controlled: Story = {
|
||||
args: {
|
||||
items: defaultItems,
|
||||
options: defaultItems,
|
||||
value: 'tab2'
|
||||
}
|
||||
};
|
||||
|
||||
export const WithDisabledItems: Story = {
|
||||
args: {
|
||||
items: [
|
||||
options: [
|
||||
{ value: 'tab1', label: 'Enabled' },
|
||||
{ value: 'tab2', label: 'Disabled', disabled: true },
|
||||
{ value: 'tab3', label: 'Enabled' }
|
||||
|
@ -106,7 +106,7 @@ export const WithDisabledItems: Story = {
|
|||
|
||||
export const CustomStyling: Story = {
|
||||
args: {
|
||||
items: defaultItems,
|
||||
options: defaultItems,
|
||||
className: 'bg-blue-100 [&_[data-state=active]]:text-blue-700'
|
||||
}
|
||||
};
|
|
@ -1,108 +1,211 @@
|
|||
'use client';
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import { ConfigProvider, Segmented, SegmentedProps, ThemeConfig } from 'antd';
|
||||
import { createStyles } from 'antd-style';
|
||||
import { busterAppStyleConfig } from '@/styles/busterAntDStyleConfig';
|
||||
import Link from 'next/link';
|
||||
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';
|
||||
import { cva } from 'class-variance-authority';
|
||||
import { useMemoizedFn } from 'ahooks';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { AppTooltip } from '@/components/ui/tooltip';
|
||||
const token = busterAppStyleConfig.token!;
|
||||
|
||||
type SegmentedOption = {
|
||||
value: string;
|
||||
label?: string;
|
||||
link?: string;
|
||||
onHover?: () => void;
|
||||
export interface SegmentedItem<T extends string = string> {
|
||||
value: T;
|
||||
label: React.ReactNode;
|
||||
icon?: React.ReactNode;
|
||||
tooltip?: string;
|
||||
};
|
||||
export interface AppSegmentedProps extends Omit<SegmentedProps, 'options'> {
|
||||
bordered?: boolean;
|
||||
options: SegmentedOption[];
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const useStyles = createStyles(({ css, token }) => {
|
||||
return {
|
||||
segmented: css``
|
||||
};
|
||||
});
|
||||
interface AppSegmentedProps<T extends string = string> {
|
||||
options: SegmentedItem<T>[];
|
||||
value?: T;
|
||||
onChange?: (value: SegmentedItem<T>) => void;
|
||||
className?: string;
|
||||
size?: 'default' | 'large';
|
||||
block?: boolean;
|
||||
type?: 'button' | 'track';
|
||||
}
|
||||
|
||||
const THEME_CONFIG: ThemeConfig = {
|
||||
components: {
|
||||
Segmented: {
|
||||
itemColor: token.colorTextDescription,
|
||||
trackBg: 'transparent',
|
||||
itemSelectedColor: token.colorTextBase,
|
||||
itemSelectedBg: token.controlItemBgActive,
|
||||
colorBorder: token.colorBorder,
|
||||
boxShadowTertiary: 'none'
|
||||
const segmentedVariants = cva('relative inline-flex items-center rounded-md', {
|
||||
variants: {
|
||||
block: {
|
||||
true: 'w-full',
|
||||
false: ''
|
||||
},
|
||||
size: {
|
||||
default: '',
|
||||
large: ''
|
||||
},
|
||||
type: {
|
||||
button: 'bg-transparent',
|
||||
track: 'bg-item-select'
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const triggerVariants = cva(
|
||||
'relative z-10 flex items-center justify-center gap-x-1.5 gap-y-1 rounded-md transition-colors',
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
default: 'px-2.5 flex-row',
|
||||
large: 'px-3 flex-col'
|
||||
},
|
||||
block: {
|
||||
true: 'flex-1',
|
||||
false: ''
|
||||
},
|
||||
disabled: {
|
||||
true: '!text-foreground/30 !hover:text-foreground/30 cursor-not-allowed',
|
||||
false: 'cursor-pointer'
|
||||
},
|
||||
selected: {
|
||||
true: 'text-foreground',
|
||||
false: 'text-gray-dark hover:text-foreground'
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const gliderVariants = cva('absolute border-border rounded-md border', {
|
||||
variants: {
|
||||
type: {
|
||||
button: 'bg-item-select',
|
||||
track: 'bg-background'
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Create a type for the forwardRef component that includes displayName
|
||||
type AppSegmentedComponent = (<T extends string = string>(
|
||||
props: AppSegmentedProps<T> & { ref?: React.ForwardedRef<HTMLDivElement> }
|
||||
) => React.ReactElement) & {
|
||||
displayName?: string;
|
||||
};
|
||||
|
||||
export const AppSegmented = React.memo<AppSegmentedProps>(
|
||||
({ size = 'small', bordered = true, onChange, options: optionsProps, ...props }) => {
|
||||
const { cx, styles } = useStyles();
|
||||
const router = useRouter();
|
||||
// Update the component definition to properly handle generics
|
||||
export const AppSegmented: AppSegmentedComponent = React.forwardRef(
|
||||
<T extends string = string>(
|
||||
{
|
||||
options,
|
||||
type = 'track',
|
||||
value,
|
||||
onChange,
|
||||
className,
|
||||
size = 'default',
|
||||
block = false
|
||||
}: AppSegmentedProps<T>,
|
||||
ref: React.ForwardedRef<HTMLDivElement>
|
||||
) => {
|
||||
const tabRefs = React.useRef<Map<string, HTMLButtonElement>>(new Map());
|
||||
const [selectedValue, setSelectedValue] = useState(value || options[0]?.value);
|
||||
const [gliderStyle, setGliderStyle] = useState({
|
||||
width: 0,
|
||||
transform: 'translateX(0)'
|
||||
});
|
||||
|
||||
const options = useMemo(() => {
|
||||
return optionsProps.map((option) => ({
|
||||
value: option.value,
|
||||
label: <SegmentedItem option={option} />
|
||||
}));
|
||||
}, [optionsProps]);
|
||||
const height = size === 'default' ? 'h-[28px]' : 'h-[50px]';
|
||||
|
||||
const onChangePreflight = useMemoizedFn((value: string | number) => {
|
||||
const link = optionsProps.find((option) => option.value === value)?.link;
|
||||
if (link) {
|
||||
router.push(link);
|
||||
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 handleTabClick = useMemoizedFn((value: string) => {
|
||||
const item = options.find((item) => item.value === value);
|
||||
if (item && !item.disabled) {
|
||||
setSelectedValue(item.value);
|
||||
onChange?.(item);
|
||||
}
|
||||
onChange?.(value);
|
||||
});
|
||||
|
||||
return (
|
||||
<ConfigProvider theme={THEME_CONFIG}>
|
||||
<Segmented
|
||||
{...props}
|
||||
onChange={onChangePreflight}
|
||||
options={options}
|
||||
size={size}
|
||||
className={cx(
|
||||
styles.segmented,
|
||||
props.className,
|
||||
'shadow-none!',
|
||||
!bordered && 'no-border'
|
||||
)}
|
||||
<Tabs.Root
|
||||
ref={ref}
|
||||
value={selectedValue}
|
||||
onValueChange={handleTabClick}
|
||||
className={cn(segmentedVariants({ block, type }), height, className)}>
|
||||
<motion.div
|
||||
className={cn(gliderVariants({ type }), height)}
|
||||
initial={false}
|
||||
animate={{
|
||||
width: gliderStyle.width,
|
||||
x: parseInt(gliderStyle.transform.replace('translateX(', '').replace('px)', ''))
|
||||
}}
|
||||
transition={{
|
||||
type: 'spring',
|
||||
stiffness: 400,
|
||||
damping: 35
|
||||
}}
|
||||
/>
|
||||
</ConfigProvider>
|
||||
<Tabs.List
|
||||
className="relative z-10 flex w-full items-center gap-1"
|
||||
aria-label="Segmented Control">
|
||||
{options.map((item) => (
|
||||
<SegmentedTrigger
|
||||
key={item.value}
|
||||
item={item}
|
||||
selectedValue={selectedValue}
|
||||
size={size}
|
||||
block={block}
|
||||
tabRefs={tabRefs}
|
||||
/>
|
||||
))}
|
||||
</Tabs.List>
|
||||
</Tabs.Root>
|
||||
);
|
||||
}
|
||||
);
|
||||
) as AppSegmentedComponent;
|
||||
|
||||
AppSegmented.displayName = 'AppSegmented';
|
||||
|
||||
const SegmentedItem: React.FC<{ option: SegmentedOption }> = ({ option }) => {
|
||||
return (
|
||||
<AppTooltip mouseEnterDelay={0.75} title={option.tooltip}>
|
||||
<SegmentedItemLink href={option.link}>
|
||||
<div className="flex items-center gap-0.5" onClick={option.onHover}>
|
||||
{option.icon}
|
||||
{option.label}
|
||||
</div>
|
||||
</SegmentedItemLink>
|
||||
</AppTooltip>
|
||||
);
|
||||
};
|
||||
interface SegmentedTriggerProps<T extends string = string> {
|
||||
item: SegmentedItem<T>;
|
||||
selectedValue: string;
|
||||
size: AppSegmentedProps<T>['size'];
|
||||
block: AppSegmentedProps<T>['block'];
|
||||
tabRefs: React.MutableRefObject<Map<string, HTMLButtonElement>>;
|
||||
}
|
||||
|
||||
const SegmentedItemLink: React.FC<{ href?: string; children: React.ReactNode }> = ({
|
||||
href,
|
||||
children
|
||||
}) => {
|
||||
if (!href) return children;
|
||||
function SegmentedTriggerComponent<T extends string = string>(
|
||||
props: SegmentedTriggerProps<T>
|
||||
): JSX.Element {
|
||||
const { item, selectedValue, size, block, tabRefs } = props;
|
||||
return (
|
||||
<Link prefetch={true} href={href}>
|
||||
{children}
|
||||
</Link>
|
||||
<Tabs.Trigger
|
||||
key={item.value}
|
||||
value={item.value}
|
||||
disabled={item.disabled}
|
||||
ref={(el) => {
|
||||
if (el) tabRefs.current.set(item.value, el);
|
||||
}}
|
||||
className={cn(
|
||||
'focus-visible:ring-primary focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2',
|
||||
triggerVariants({
|
||||
size,
|
||||
block,
|
||||
disabled: item.disabled,
|
||||
selected: selectedValue === item.value
|
||||
})
|
||||
)}>
|
||||
{item.icon && <span className={cn('flex items-center text-sm')}>{item.icon}</span>}
|
||||
<span className={cn('text-sm')}>{item.label}</span>
|
||||
</Tabs.Trigger>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
SegmentedTriggerComponent.displayName = 'SegmentedTrigger';
|
||||
|
||||
const SegmentedTrigger = React.memo(SegmentedTriggerComponent) as typeof SegmentedTriggerComponent;
|
||||
SegmentedTrigger.displayName = 'SegmentedTrigger';
|
||||
|
|
|
@ -1,183 +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';
|
||||
import { cva } from 'class-variance-authority';
|
||||
import { useMemoizedFn } from 'ahooks';
|
||||
|
||||
export interface SegmentedItem {
|
||||
value: string;
|
||||
label: React.ReactNode;
|
||||
icon?: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface SegmentedProps {
|
||||
items: SegmentedItem[];
|
||||
value?: string;
|
||||
onChange?: (value: SegmentedItem) => void;
|
||||
className?: string;
|
||||
size?: 'default' | 'large';
|
||||
block?: boolean;
|
||||
type?: 'button' | 'track';
|
||||
}
|
||||
|
||||
const segmentedVariants = cva('relative inline-flex items-center rounded-md', {
|
||||
variants: {
|
||||
block: {
|
||||
true: 'w-full',
|
||||
false: ''
|
||||
},
|
||||
size: {
|
||||
default: '',
|
||||
large: ''
|
||||
},
|
||||
type: {
|
||||
button: 'bg-transparent',
|
||||
track: 'bg-item-select'
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const triggerVariants = cva(
|
||||
'relative z-10 flex items-center justify-center gap-x-1.5 gap-y-1 rounded-md transition-colors',
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
default: 'px-2.5 flex-row',
|
||||
large: 'px-3 flex-col'
|
||||
},
|
||||
block: {
|
||||
true: 'flex-1',
|
||||
false: ''
|
||||
},
|
||||
disabled: {
|
||||
true: '!text-foreground/30 !hover:text-foreground/30 cursor-not-allowed',
|
||||
false: 'cursor-pointer'
|
||||
},
|
||||
selected: {
|
||||
true: 'text-foreground',
|
||||
false: 'text-gray-dark hover:text-foreground'
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const gliderVariants = cva('absolute border-border rounded-md border', {
|
||||
variants: {
|
||||
type: {
|
||||
button: 'bg-item-select',
|
||||
track: 'bg-background'
|
||||
}
|
||||
}
|
||||
});
|
||||
export const Segmented = React.forwardRef<HTMLDivElement, SegmentedProps>(
|
||||
({ items, type = 'track', value, onChange, className, size = 'default', block = false }, ref) => {
|
||||
const tabRefs = React.useRef<Map<string, HTMLButtonElement>>(new Map());
|
||||
const [selectedValue, setSelectedValue] = useState(value || items[0]?.value);
|
||||
const [gliderStyle, setGliderStyle] = useState({
|
||||
width: 0,
|
||||
transform: 'translateX(0)'
|
||||
});
|
||||
|
||||
const height = size === 'default' ? 'h-[28px]' : 'h-[50px]';
|
||||
|
||||
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 handleTabClick = useMemoizedFn((value: string) => {
|
||||
const item = items.find((item) => item.value === value);
|
||||
if (item && !item.disabled) {
|
||||
setSelectedValue(item.value);
|
||||
onChange?.(item);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<Tabs.Root
|
||||
ref={ref}
|
||||
value={selectedValue}
|
||||
onValueChange={handleTabClick}
|
||||
className={cn(segmentedVariants({ block, type }), height, className)}>
|
||||
<motion.div
|
||||
className={cn(gliderVariants({ type }), height)}
|
||||
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 w-full items-center gap-1"
|
||||
aria-label="Segmented Control">
|
||||
{items.map((item) => (
|
||||
<SegmentedTrigger
|
||||
key={item.value}
|
||||
item={item}
|
||||
selectedValue={selectedValue}
|
||||
size={size}
|
||||
block={block}
|
||||
tabRefs={tabRefs}
|
||||
/>
|
||||
))}
|
||||
</Tabs.List>
|
||||
</Tabs.Root>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Segmented.displayName = 'Segmented';
|
||||
|
||||
const SegmentedTrigger = React.memo<{
|
||||
item: SegmentedItem;
|
||||
selectedValue: string;
|
||||
size: SegmentedProps['size'];
|
||||
block: SegmentedProps['block'];
|
||||
tabRefs: React.MutableRefObject<Map<string, HTMLButtonElement>>;
|
||||
}>(({ item, selectedValue, size, block, tabRefs }) => {
|
||||
return (
|
||||
<Tabs.Trigger
|
||||
key={item.value}
|
||||
value={item.value}
|
||||
disabled={item.disabled}
|
||||
ref={(el) => {
|
||||
if (el) tabRefs.current.set(item.value, el);
|
||||
}}
|
||||
className={cn(
|
||||
'focus-visible:ring-primary focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2',
|
||||
triggerVariants({
|
||||
size,
|
||||
block,
|
||||
disabled: item.disabled,
|
||||
selected: selectedValue === item.value
|
||||
})
|
||||
)}>
|
||||
{item.icon && <span className={cn('flex items-center text-sm')}>{item.icon}</span>}
|
||||
<span className={cn('text-sm')}>{item.label}</span>
|
||||
</Tabs.Trigger>
|
||||
);
|
||||
});
|
||||
|
||||
SegmentedTrigger.displayName = 'SegmentedTrigger';
|
|
@ -15,7 +15,7 @@ import { CollectionsListEmit } from '@/api/buster_socket/collections';
|
|||
import isEmpty from 'lodash/isEmpty';
|
||||
import omit from 'lodash/omit';
|
||||
import { useMemoizedFn } from 'ahooks';
|
||||
import { SegmentedValue } from 'antd/es/segmented';
|
||||
import { type SegmentedItem } from '@/components/ui/segmented';
|
||||
|
||||
export const CollectionListHeader: React.FC<{
|
||||
collectionId?: string;
|
||||
|
@ -122,7 +122,7 @@ const CollectionFilters: React.FC<{
|
|||
return filters.find((f) => f.value === activeFiltersValue)?.value || filters[0].value;
|
||||
}, [filters, collectionListFilters]);
|
||||
|
||||
const onChangeFilter = useMemoizedFn((v: SegmentedValue) => {
|
||||
const onChangeFilter = useMemoizedFn((v: SegmentedItem) => {
|
||||
let parsedValue;
|
||||
try {
|
||||
parsedValue = JSON.parse(v as string);
|
||||
|
|
|
@ -0,0 +1,89 @@
|
|||
import React from 'react';
|
||||
import { Meta, StoryObj } from '@storybook/react';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import { MetricStylingAppSegment } from './MetricStylingAppSegment';
|
||||
import { MetricStylingAppSegments } from './config';
|
||||
import { ChartType } from '@/components/ui/charts/interfaces/enum';
|
||||
|
||||
const meta: Meta<typeof MetricStylingAppSegment> = {
|
||||
title: 'Controllers/EditMetricController/MetricStylingAppSegment',
|
||||
component: MetricStylingAppSegment,
|
||||
parameters: {
|
||||
layout: 'centered'
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
segment: {
|
||||
control: 'select',
|
||||
options: Object.values(MetricStylingAppSegments),
|
||||
description: 'The currently selected segment'
|
||||
},
|
||||
setSegment: {
|
||||
action: 'setSegment',
|
||||
description: 'Function called when segment changes'
|
||||
},
|
||||
selectedChartType: {
|
||||
control: 'select',
|
||||
options: Object.values(ChartType),
|
||||
description: 'The type of chart currently selected'
|
||||
},
|
||||
className: {
|
||||
control: 'text',
|
||||
description: 'Additional CSS classes to apply'
|
||||
}
|
||||
},
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<div className="w-full min-w-[330px]">
|
||||
<Story />
|
||||
</div>
|
||||
)
|
||||
]
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof MetricStylingAppSegment>;
|
||||
|
||||
// Create a reusable action handler
|
||||
const handleSetSegment = action('setSegment');
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
segment: MetricStylingAppSegments.VISUALIZE,
|
||||
setSegment: handleSetSegment,
|
||||
selectedChartType: ChartType.Line,
|
||||
className: ''
|
||||
}
|
||||
};
|
||||
|
||||
export const WithTableChart: Story = {
|
||||
args: {
|
||||
segment: MetricStylingAppSegments.VISUALIZE,
|
||||
setSegment: handleSetSegment,
|
||||
selectedChartType: ChartType.Table,
|
||||
className: ''
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'When table chart type is selected, Styling and Colors segments are disabled'
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const WithMetricChart: Story = {
|
||||
args: {
|
||||
segment: MetricStylingAppSegments.VISUALIZE,
|
||||
setSegment: handleSetSegment,
|
||||
selectedChartType: ChartType.Metric,
|
||||
className: ''
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'When metric chart type is selected, Styling and Colors segments are disabled'
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
|
@ -1,16 +1,9 @@
|
|||
import React, { useMemo } from 'react';
|
||||
import { MetricStylingAppSegments } from './config';
|
||||
import { SegmentedOptions, SegmentedValue } from 'antd/es/segmented';
|
||||
import { useMemoizedFn } from 'ahooks';
|
||||
import { Segmented } from 'antd';
|
||||
import { createStyles } from 'antd-style';
|
||||
import { IBusterMetricChartConfig } from '@/api/asset_interfaces';
|
||||
|
||||
const useStyles = createStyles(({ css, token }) => ({
|
||||
container: css`
|
||||
border-bottom: 0.5px solid ${token.colorBorder};
|
||||
`
|
||||
}));
|
||||
import { AppSegmented, type SegmentedItem } from '@/components/ui/segmented';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export const MetricStylingAppSegment: React.FC<{
|
||||
segment: MetricStylingAppSegments;
|
||||
|
@ -18,13 +11,12 @@ export const MetricStylingAppSegment: React.FC<{
|
|||
selectedChartType: IBusterMetricChartConfig['selectedChartType'];
|
||||
className?: string;
|
||||
}> = React.memo(({ segment, setSegment, selectedChartType, className = '' }) => {
|
||||
const { cx, styles } = useStyles();
|
||||
const isTable = selectedChartType === 'table';
|
||||
const isMetric = selectedChartType === 'metric';
|
||||
const disableColors = isTable || isMetric;
|
||||
const disableStyling = isTable || isMetric;
|
||||
|
||||
const options: SegmentedOptions = useMemo(
|
||||
const options: SegmentedItem<MetricStylingAppSegments>[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
label: MetricStylingAppSegments.VISUALIZE,
|
||||
|
@ -44,14 +36,21 @@ export const MetricStylingAppSegment: React.FC<{
|
|||
[disableColors, disableStyling]
|
||||
);
|
||||
|
||||
const onChangeSegment = useMemoizedFn((value: SegmentedValue) => {
|
||||
setSegment(value as MetricStylingAppSegments);
|
||||
const onChangeSegment = useMemoizedFn((value: SegmentedItem<MetricStylingAppSegments>) => {
|
||||
setSegment(value.value);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={cx(styles.container)}>
|
||||
<div className={cx('pb-3', className)}>
|
||||
<Segmented block options={options} value={segment} onChange={onChangeSegment} />
|
||||
<div className={cn('border-b')}>
|
||||
<div className={cn('pb-3', className)}>
|
||||
<AppSegmented
|
||||
type="track"
|
||||
size="default"
|
||||
block
|
||||
options={options}
|
||||
value={segment}
|
||||
onChange={onChangeSegment}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -5,7 +5,7 @@ import { AppMaterialIcons, AppSegmented, AppTooltip } from '@/components/ui';
|
|||
import { useEditAppSegmented } from './useEditAppSegmented';
|
||||
import { ENABLED_DOTS_ON_LINE_SIZE } from '@/api/asset_interfaces';
|
||||
import { useMemoizedFn } from 'ahooks';
|
||||
import { SegmentedValue } from 'antd/es/segmented';
|
||||
import { type SegmentedItem } from '@/components/ui/segmented';
|
||||
|
||||
const options: { icon: React.ReactNode; value: LineValue }[] = [
|
||||
{
|
||||
|
@ -106,7 +106,7 @@ export const EditLineStyle: React.FC<{
|
|||
methodRecord[lineValue]();
|
||||
});
|
||||
|
||||
const onChangeValue = useMemoizedFn((value: SegmentedValue) => {
|
||||
const onChangeValue = useMemoizedFn((value: SegmentedItem) => {
|
||||
if (value) onClickValue(value as string);
|
||||
});
|
||||
|
||||
|
|
|
@ -6,7 +6,7 @@ import { AppSegmented } from '@/components/ui';
|
|||
import { VerificationStatus } from '@/api/asset_interfaces';
|
||||
import { Text } from '@/components/ui';
|
||||
import { useMemoizedFn } from 'ahooks';
|
||||
import { SegmentedValue } from 'antd/lib/segmented';
|
||||
import { type SegmentedItem } from '@/components/ui/segmented';
|
||||
|
||||
export const MetricListHeader: React.FC<{
|
||||
type: 'logs' | 'metrics';
|
||||
|
@ -30,7 +30,7 @@ export const MetricListHeader: React.FC<{
|
|||
);
|
||||
};
|
||||
|
||||
const options = [
|
||||
const options: SegmentedItem<VerificationStatus | 'all'>[] = [
|
||||
{
|
||||
label: 'All',
|
||||
value: 'all'
|
||||
|
@ -50,7 +50,7 @@ const MetricsFilters: React.FC<{
|
|||
filters: VerificationStatus[];
|
||||
onSetFilters: (filters: VerificationStatus[]) => void;
|
||||
}> = React.memo(({ type, filters, onSetFilters }) => {
|
||||
const selectedOption = useMemo(() => {
|
||||
const selectedOption: SegmentedItem<VerificationStatus | 'all'> | undefined = useMemo(() => {
|
||||
return (
|
||||
options.find((option) => {
|
||||
return filters.includes(option.value as VerificationStatus);
|
||||
|
@ -58,11 +58,11 @@ const MetricsFilters: React.FC<{
|
|||
);
|
||||
}, [filters]);
|
||||
|
||||
const onChange = useMemoizedFn((v: SegmentedValue) => {
|
||||
if (v === 'all') {
|
||||
const onChange = useMemoizedFn((v: SegmentedItem<VerificationStatus | 'all'>) => {
|
||||
if (v.value === 'all') {
|
||||
onSetFilters([]);
|
||||
} else {
|
||||
onSetFilters([v as VerificationStatus]);
|
||||
onSetFilters([v.value as VerificationStatus]);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
@ -4,7 +4,7 @@ import type { DashboardFileView, FileView } from '../../ChatLayoutContext/useCha
|
|||
import { AppSegmented } from '@/components/ui/segmented';
|
||||
import { useChatLayoutContextSelector } from '../../ChatLayoutContext';
|
||||
import { useMemoizedFn } from 'ahooks';
|
||||
import { SegmentedValue } from 'antd/es/segmented';
|
||||
import { type SegmentedItem } from '@/components/ui/segmented';
|
||||
|
||||
const segmentOptions: { label: string; value: DashboardFileView }[] = [
|
||||
{ label: 'Dashboard', value: 'dashboard' },
|
||||
|
@ -15,7 +15,7 @@ export const DashboardContainerHeaderSegment: React.FC<FileContainerSegmentProps
|
|||
({ selectedFileView }) => {
|
||||
const onSetFileView = useChatLayoutContextSelector((x) => x.onSetFileView);
|
||||
|
||||
const onChange = useMemoizedFn((fileView: SegmentedValue) => {
|
||||
const onChange = useMemoizedFn((fileView: SegmentedItem) => {
|
||||
onSetFileView({ fileView: fileView as FileView });
|
||||
});
|
||||
|
||||
|
|
|
@ -3,7 +3,7 @@ import { FileContainerSegmentProps } from './interfaces';
|
|||
import { AppSegmented } from '@/components/ui/segmented';
|
||||
import { useChatLayoutContextSelector } from '../../ChatLayoutContext';
|
||||
import type { FileView, MetricFileView } from '../../ChatLayoutContext/useChatFileLayout';
|
||||
import { SegmentedValue } from 'antd/es/segmented';
|
||||
import { type SegmentedItem } from '@/components/ui/segmented';
|
||||
import { useMemoizedFn } from 'ahooks';
|
||||
|
||||
const segmentOptions: { label: string; value: MetricFileView }[] = [
|
||||
|
@ -16,7 +16,7 @@ export const MetricContainerHeaderSegment: React.FC<FileContainerSegmentProps> =
|
|||
({ selectedFileView }) => {
|
||||
const onSetFileView = useChatLayoutContextSelector((x) => x.onSetFileView);
|
||||
|
||||
const onChange = useMemoizedFn((fileView: SegmentedValue) => {
|
||||
const onChange = useMemoizedFn((fileView: SegmentedItem) => {
|
||||
onSetFileView({ fileView: fileView as FileView });
|
||||
});
|
||||
|
||||
|
|
|
@ -3,7 +3,7 @@ import { FileContainerSegmentProps } from './interfaces';
|
|||
import { AppSegmented } from '@/components/ui/segmented';
|
||||
import { useChatLayoutContextSelector } from '../../ChatLayoutContext';
|
||||
import type { FileView, ReasoningFileView } from '../../ChatLayoutContext/useChatFileLayout';
|
||||
import { SegmentedValue } from 'antd/es/segmented';
|
||||
import { type SegmentedItem } from '@/components/ui/segmented';
|
||||
import { useMemoizedFn } from 'ahooks';
|
||||
|
||||
const segmentOptions: { label: string; value: ReasoningFileView }[] = [
|
||||
|
@ -14,7 +14,7 @@ export const ReasoningContainerHeaderSegment: React.FC<FileContainerSegmentProps
|
|||
({ selectedFileView }) => {
|
||||
const onSetFileView = useChatLayoutContextSelector((x) => x.onSetFileView);
|
||||
|
||||
const onChange = useMemoizedFn((fileView: SegmentedValue) => {
|
||||
const onChange = useMemoizedFn((fileView: SegmentedItem) => {
|
||||
onSetFileView({ fileView: fileView as FileView });
|
||||
});
|
||||
|
||||
|
|
Loading…
Reference in New Issue