snapshot of https://github.com/dream-num/univer.git (dev, 2026-09-16; blobs > 131072B omitted)
Some checks failed
📦 Build / build (push) Has been cancelled
🧪 CI Test / test shard (1/4) (push) Has been cancelled
🧪 CI Test / test shard (2/4) (push) Has been cancelled
🧪 CI Test / test shard (3/4) (push) Has been cancelled
🧪 CI Test / test shard (4/4) (push) Has been cancelled
🧪 CI Test / test (push) Has been cancelled
🧪 CI Test / eslint (push) Has been cancelled
🧪 CI Test / typecheck (push) Has been cancelled
🔫 Sync to Pro / dispatch-sync (push) Has been cancelled
🔮 Sync Mirror / sync (push) Has been cancelled

This commit is contained in:
upstream-mirror
2026-09-16 05:09:45 +05:00
commit bcf6ef63ad
7118 changed files with 1236854 additions and 0 deletions

View File

@@ -0,0 +1,76 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { Meta } from '@storybook/react';
import { Accordion } from './Accordion';
const meta: Meta<typeof Accordion> = {
title: 'Components / Accordion',
component: Accordion,
parameters: {
layout: 'centered',
},
tags: ['autodocs'],
};
export default meta;
export const AccordionBasic = {
render() {
const items = [{
label: (
<span>
Accordion A
</span>
),
children: (
<div>
Content A
</div>
),
}, {
label: (
<span>
Accordion B
</span>
),
children: (
<div>
Content B
</div>
),
}, {
label: (
<span>
Accordion C
</span>
),
children: (
<div>
Content C
</div>
),
}];
return (
<>
<div>
<Accordion items={items} />
</div>
</>
);
},
};

View File

@@ -0,0 +1,96 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { ReactNode } from 'react';
import { DownIcon } from '@univerjs/icons';
import { useState } from 'react';
import { clsx } from '../../helper/clsx';
interface IAccordionItem {
id?: string;
label: ReactNode;
children: ReactNode;
}
export interface IAccordionProps {
className?: string;
items: IAccordionItem[];
defaultOpenIndex?: number | null;
openIndex?: number | null;
onOpenIndexChange?: (openIndex: number | null) => void;
}
export function Accordion(props: IAccordionProps) {
const { className, defaultOpenIndex = null, items, onOpenIndexChange } = props;
const [innerOpenIndex, setInnerOpenIndex] = useState<number | null>(defaultOpenIndex);
const openIndex = props.openIndex ?? innerOpenIndex;
const toggleItem = (index: number) => {
const nextOpenIndex = openIndex === index ? null : index;
if (props.openIndex === undefined) {
setInnerOpenIndex(nextOpenIndex);
}
onOpenIndexChange?.(nextOpenIndex);
};
return (
<div
data-u-comp="accordion"
className={clsx(`
univer-divide-x-0 univer-divide-y univer-divide-solid univer-divide-gray-200
dark:!univer-divide-gray-600
`, className)}
>
{items.map((item, index) => (
<div key={item.id ?? index}>
<button
className={`
univer-box-border univer-flex univer-w-full univer-cursor-pointer univer-items-center
univer-gap-1.5 univer-border-none univer-bg-transparent univer-p-4 univer-text-left
univer-text-gray-700
hover:univer-text-gray-900
focus:univer-outline-none
dark:!univer-text-gray-200
dark:hover:!univer-text-gray-0
`}
type="button"
onClick={() => toggleItem(index)}
>
<DownIcon
aria-hidden="true"
className={clsx('univer-size-2.5 univer-flex-shrink-0 univer-transition-transform', {
'-univer-rotate-90 rtl:univer-rotate-90': openIndex !== index,
'univer-rotate-0': openIndex === index,
})}
/>
<span className="univer-font-medium">{item.label}</span>
</button>
<div
className={clsx(`
univer-overflow-hidden univer-transition-[max-height,opacity] univer-duration-500
univer-ease-in-out
`, {
'univer-max-h-screen': openIndex === index,
'univer-max-h-0': openIndex !== index,
})}
>
<div className="univer-box-border univer-px-4 univer-py-1.5">{item.children}</div>
</div>
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,57 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { cleanup, fireEvent, render } from '@testing-library/react';
import { afterEach, describe, expect, it } from 'vitest';
import { Accordion } from '../Accordion';
afterEach(cleanup);
describe('Accordion', () => {
it('should render all items and only open one at a time', () => {
const items = [
{ label: 'Item 1', children: <div>Content 1</div> },
{ label: 'Item 2', children: <div>Content 2</div> },
{ label: 'Item 3', children: <div>Content 3</div> },
];
const { getByText, queryByText } = render(<Accordion items={items} />);
const firstTrigger = getByText('Item 1').closest('button');
const firstArrow = firstTrigger?.querySelector('[aria-hidden="true"]');
// Should be hidden by default
expect(firstArrow?.getAttribute('class')).toContain('rtl:univer-rotate-90');
expect(queryByText('Content 1')?.parentElement?.parentElement?.className).toContain('univer-max-h-0');
expect(queryByText('Content 2')?.parentElement?.parentElement?.className).toContain('univer-max-h-0');
expect(queryByText('Content 3')?.parentElement?.parentElement?.className).toContain('univer-max-h-0');
// Should expand the first item
fireEvent.click(getByText('Item 1'));
expect(firstArrow?.getAttribute('class')).not.toContain('rtl:univer-rotate-90');
expect(getByText('Content 1').parentElement?.parentElement?.className).toContain('univer-max-h-screen');
expect(queryByText('Content 2')?.parentElement?.parentElement?.className).toContain('univer-max-h-0');
expect(queryByText('Content 3')?.parentElement?.parentElement?.className).toContain('univer-max-h-0');
// Should expand the second item
fireEvent.click(getByText('Item 2'));
expect(getByText('Content 2').parentElement?.parentElement?.className).toContain('univer-max-h-screen');
expect(queryByText('Content 1')?.parentElement?.parentElement?.className).toContain('univer-max-h-0');
expect(queryByText('Content 3')?.parentElement?.parentElement?.className).toContain('univer-max-h-0');
// Should collapse the second item
fireEvent.click(getByText('Item 2'));
expect(queryByText('Content 2')?.parentElement?.parentElement?.className).toContain('univer-max-h-0');
});
});

View File

@@ -0,0 +1,23 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { HTMLAttributes } from 'react';
export type IActionRowProps = HTMLAttributes<HTMLDivElement>;
export function ActionRow({ className, ...props }: IActionRowProps) {
return <div className={className} {...props} />;
}

View File

@@ -0,0 +1,33 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { IActionRowProps } from './ActionRow';
import { clsx } from '../../helper/clsx';
export type IMobileActionRowGroupProps = IActionRowProps;
export function MobileActionRowGroup({ className, ...props }: IMobileActionRowGroupProps) {
return (
<div
className={clsx(className, `
univer-flex univer-w-full univer-justify-stretch univer-gap-3
[&>button]:!univer-m-0 [&>button]:!univer-h-12 [&>button]:!univer-min-w-0 [&>button]:!univer-flex-1
[&>button]:!univer-rounded-xl
`)}
{...props}
/>
);
}

View File

@@ -0,0 +1,63 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { Meta } from '@storybook/react';
import { Avatar } from './Avatar';
const meta: Meta<typeof Avatar> = {
title: 'Components / Avatar',
component: Avatar,
parameters: {
layout: 'centered',
},
tags: ['autodocs'],
};
export default meta;
export const AvatarShape = {
render() {
return (
<>
<Avatar>xxx</Avatar>
<Avatar shape="square" />
</>
);
},
};
export const AvatarSize = {
render() {
return (
<>
<div>
<Avatar size="small" />
<Avatar />
</div>
<div>
<Avatar shape="square" size="small" />
<Avatar shape="square" />
</div>
</>
);
},
};
export const AvatarSrc = {
render() {
return <Avatar src="https://avatars.githubusercontent.com/u/20630676?s=60&v=4" />;
},
};

View File

@@ -0,0 +1,143 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { VariantProps } from 'class-variance-authority';
import type { CSSProperties, ReactNode } from 'react';
import { cva } from 'class-variance-authority';
import { clsx } from '../../helper/clsx';
type ImageFit = 'fill' | 'contain' | 'cover' | 'none' | 'scale-down';
const avatarVariants = cva(
`
univer-relative univer-inline-block univer-overflow-hidden univer-whitespace-nowrap univer-bg-gray-200
univer-text-center univer-align-middle univer-text-gray-0
`,
{
variants: {
/**
* The shape of the avatar
* @default 'circle'
*/
shape: {
circle: 'univer-rounded-full',
square: 'univer-rounded',
},
size: {
middle: 'univer-size-9 univer-leading-9',
small: 'univer-size-7 univer-leading-7',
},
},
defaultVariants: {
shape: 'circle',
size: 'middle',
},
}
);
export interface IAvatarProps extends Omit<VariantProps<typeof avatarVariants>, 'size'> {
children?: ReactNode;
className?: string;
/** Semantic DOM style */
style?: CSSProperties;
/** The title of the image avatar */
title?: string;
/** Image description */
alt?: string;
/**
* The size of the avatar
* @default 'middle'
*/
size?: 'small' | 'middle' | number;
/** The address of the image for an image avatar or image element */
src?: string;
/**
* The fit of the image avatar
* @default fill
*/
fit?: ImageFit;
/** Handler when img load error */
onError?: () => void;
/** Handler when img load success */
onLoad?: () => void;
}
/**
* Avatar Component
*/
export function Avatar(props: IAvatarProps) {
const {
children,
className,
style,
title,
alt,
shape = 'circle',
size = 'middle',
src,
fit = 'fill',
onError,
onLoad,
} = props;
const sizeStyle =
typeof size === 'number'
? {
width: `${size}px`,
height: `${size}px`,
lineHeight: `${size}px`,
}
: {};
const fitStyle = { objectFit: fit };
return (
<span
className={clsx(avatarVariants({
shape,
size: typeof size === 'number' ? 'middle' : size,
}), {
'univer-bg-transparent': src,
}, className)}
style={{
...sizeStyle,
...style,
...(src && fitStyle),
}}
>
{src && (
<img
className="univer-block univer-size-full"
src={src}
title={title}
alt={alt}
onError={onError}
onLoad={onLoad}
/>
)}
{children}
</span>
);
}

View File

@@ -0,0 +1,49 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { cleanup, render } from '@testing-library/react';
import { afterEach, describe, expect, it } from 'vitest';
import { Avatar } from '../Avatar';
afterEach(cleanup);
describe('Avatar', () => {
it('renders the children', () => {
const { getByText } = render(<Avatar>Test</Avatar>);
const childrenElement = getByText('Test');
expect(childrenElement).not.toBeNull();
});
it('renders the image', () => {
const { container } = render(<Avatar src="test.png" />);
expect(container.querySelector('img')).not.toBeNull();
});
it('renders the image with fit', () => {
const { container } = render(<Avatar src="test.png" fit="contain" />);
expect(container.innerHTML).contains('object-fit: contain');
});
it('renders the size with number', () => {
const { container } = render(<Avatar size={100} />);
expect(container.innerHTML).contains('width: 100px');
expect(container.innerHTML).contains('height: 100px');
expect(container.innerHTML).contains('line-height: 100px');
});
});

View File

@@ -0,0 +1,42 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { Meta } from '@storybook/react';
import { Badge } from './Badge';
const meta: Meta<typeof Badge> = {
title: 'Components / Badge',
component: Badge,
parameters: {
layout: 'centered',
},
tags: ['autodocs'],
};
export default meta;
export const BadgeBasic = {
render() {
return (
<>
<div>
<Badge>Badge</Badge>
<Badge closable>Badge</Badge>
</div>
</>
);
},
};

View File

@@ -0,0 +1,67 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { ReactNode } from 'react';
import { CloseIcon } from '@univerjs/icons';
import { useContext } from 'react';
import { clsx } from '../../helper/clsx';
import { ConfigContext } from '../config-provider/ConfigProvider';
export interface IBadgeProps {
className?: string;
children: ReactNode;
closable?: boolean;
onClose?: () => void;
}
export function Badge(props: IBadgeProps) {
const { className, children, closable = false, onClose } = props;
const { locale } = useContext(ConfigContext);
return (
<span
data-u-comp="badge"
className={clsx(
`
univer-box-border univer-inline-flex univer-items-center univer-gap-1 univer-truncate
univer-rounded-md univer-border univer-border-solid univer-border-gray-100 univer-bg-gray-100
univer-px-2.5 univer-py-0.5 univer-text-xs univer-font-medium univer-text-gray-900
dark:!univer-border-gray-500 dark:!univer-bg-gray-700 dark:!univer-text-gray-300
`,
className
)}
>
<span className="univer-flex-1 univer-truncate">
{children}
</span>
{closable && (
<button
className="
univer-flex univer-cursor-pointer univer-items-center univer-justify-center univer-border-none
univer-p-0 univer-outline-none univer-transition-opacity
hover:univer-opacity-70
"
type="button"
aria-label={locale?.Accessibility.closeBadge}
onClick={onClose}
>
<CloseIcon className="univer-text-current" />
</button>
)}
</span>
);
}

View File

@@ -0,0 +1,44 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import enUS from '../../../locale/en-US';
import { ConfigProvider } from '../../config-provider/ConfigProvider';
import { Badge } from '../Badge';
import '@testing-library/jest-dom/vitest';
afterEach(cleanup);
describe('Badge', () => {
it('renders children', () => {
render(<Badge>Test Badge</Badge>);
expect(screen.getByText('Test Badge')).toBeInTheDocument();
});
it('renders closable badge', () => {
const onClose = vi.fn();
render(
<ConfigProvider locale={enUS.design} mountContainer={document.body}>
<Badge closable onClose={onClose}>Closable Badge</Badge>
</ConfigProvider>
);
const closeButton = screen.getByLabelText(enUS.design.Accessibility.closeBadge);
expect(closeButton).toBeInTheDocument();
fireEvent.click(closeButton);
expect(onClose).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,79 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { Meta } from '@storybook/react';
import { Button } from './Button';
const meta: Meta<typeof Button> = {
title: 'Components / Button',
component: Button,
parameters: {
layout: 'centered',
},
tags: ['autodocs'],
};
export default meta;
export const ButtonType = {
render() {
return (
<>
<div>
<Button>default button</Button>
<Button variant="primary">primary button</Button>
<Button variant="danger">danger button</Button>
<Button variant="text">text button</Button>
<Button variant="link">link button</Button>
</div>
</>
);
},
};
export const ButtonSize = {
render() {
return (
<>
<div>
<Button size="small">small button</Button>
<Button>medium button</Button>
<Button variant="primary" size="large">
large button
</Button>
</div>
</>
);
},
};
export const ButtonDisabled = {
render() {
return (
<>
<div>
<Button disabled>default button</Button>
<Button variant="text" disabled>
text button
</Button>
<Button variant="primary" disabled>
primary button
</Button>
</div>
</>
);
},
};

View File

@@ -0,0 +1,110 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { VariantProps } from 'class-variance-authority';
import type { ButtonHTMLAttributes } from 'react';
import { Slot } from '@radix-ui/react-slot';
import { cva } from 'class-variance-authority';
import { forwardRef } from 'react';
import { clsx } from '../../helper/clsx';
export const buttonVariants = cva(
`
univer-box-border univer-inline-flex univer-cursor-pointer univer-select-none univer-items-center
univer-justify-center univer-gap-2 univer-whitespace-nowrap univer-rounded-md univer-border univer-border-solid
univer-text-sm univer-font-medium univer-transition-colors
disabled:univer-pointer-events-none disabled:univer-cursor-not-allowed disabled:univer-opacity-50
[&_svg]:univer-pointer-events-none [&_svg]:univer-size-4 [&_svg]:univer-shrink-0
`,
{
variants: {
variant: {
default: `
univer-border-gray-200 univer-bg-gray-0 univer-text-gray-700
hover:univer-bg-gray-100
active:univer-bg-gray-200
dark:!univer-border-gray-600 dark:!univer-bg-gray-700 dark:!univer-text-gray-0
dark:hover:!univer-bg-gray-600
dark:active:!univer-bg-gray-700
`,
primary: `
univer-border-primary-600 univer-bg-primary-600 univer-text-gray-0
hover:univer-bg-primary-500
active:univer-bg-primary-700
`,
danger: `
univer-border-red-500 univer-bg-red-500 univer-text-gray-0
hover:univer-border-red-400 hover:univer-bg-red-400
active:univer-border-red-600 active:univer-bg-red-600
`,
text: `
univer-border-transparent univer-bg-transparent univer-text-gray-900
hover:univer-bg-gray-100
active:univer-bg-gray-200
dark:!univer-text-gray-0
dark:hover:!univer-bg-gray-700
dark:active:!univer-bg-gray-600
`,
link: `
univer-underline-current univer-border-transparent univer-bg-transparent univer-text-primary-600
univer-underline-offset-4
hover:univer-text-primary-500 hover:univer-underline
active:univer-text-primary-700
`,
ghost: `
univer-border-transparent univer-bg-transparent univer-text-gray-900
hover:univer-bg-gray-100
active:univer-bg-gray-200
dark:!univer-text-gray-0
dark:hover:!univer-bg-gray-700
dark:active:!univer-bg-gray-600
`,
},
size: {
icon: 'univer-size-8 !univer-p-0',
small: 'univer-h-6 univer-rounded-md univer-px-1.5 univer-text-xs',
middle: 'univer-h-8 univer-rounded-lg univer-px-2 univer-text-sm',
large: 'univer-h-10 univer-rounded-lg univer-px-3 univer-text-sm',
},
},
defaultVariants: {
variant: 'default',
size: 'middle',
},
}
);
export interface IButtonProps
extends ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
export const Button = forwardRef<HTMLButtonElement, IButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : 'button';
return (
<Comp
className={clsx(buttonVariants({ variant, size, className }))}
ref={ref}
data-u-comp="button"
{...props}
/>
);
}
);
Button.displayName = 'Button';

View File

@@ -0,0 +1,46 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { Meta } from '@storybook/react';
import { Button } from './Button';
import { ButtonGroup } from './ButtonGroup';
const meta: Meta<typeof ButtonGroup> = {
title: 'Components / ButtonGroup',
component: ButtonGroup,
parameters: {
layout: 'centered',
},
tags: ['autodocs'],
};
export default meta;
export const ButtonGroupBasic = {
render() {
return (
<>
<div>
<ButtonGroup>
<Button variant="primary">Button 1</Button>
<Button disabled>Button 2</Button>
<Button>Button 3</Button>
</ButtonGroup>
</div>
</>
);
},
};

View File

@@ -0,0 +1,73 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/* eslint-disable react/no-children-map */
/* eslint-disable react/no-children-count */
/* eslint-disable react/no-clone-element */
import type { ReactElement } from 'react';
import type { IButtonProps } from './Button';
import { Children, cloneElement, useContext } from 'react';
import { clsx } from '../../helper/clsx';
import { ConfigContext } from '../config-provider/ConfigProvider';
export interface IButtonGroupProps {
className?: string;
orientation?: 'horizontal' | 'vertical';
children: ReactElement<IButtonProps>[];
}
export const ButtonGroup = ({
className,
orientation = 'horizontal',
children,
}: IButtonGroupProps) => {
const { direction } = useContext(ConfigContext);
const totalButtons = Children.count(children);
const isHorizontal = orientation === 'horizontal';
const isVertical = orientation === 'vertical';
const isRtl = direction === 'rtl';
return (
<div
className={clsx('univer-grid', {
'univer-grid-flow-row': isVertical,
'univer-w-fit': isVertical,
}, className)}
>
{Children.map(children, (child, index) => {
const isFirst = index === 0;
const isLast = index === totalButtons - 1;
return cloneElement(child, {
className: clsx(
{
'!univer-rounded-l-none': isHorizontal && (isRtl ? !isLast : !isFirst),
'!univer-rounded-r-none': isHorizontal && (isRtl ? !isFirst : !isLast),
'!univer-border-l-0': isHorizontal && !isRtl && !isFirst,
'!univer-border-r-0': isHorizontal && isRtl && !isFirst,
'!univer-rounded-t-none': isVertical && !isFirst,
'!univer-rounded-b-none': isVertical && !isLast,
'!univer-border-t-0': isVertical && !isFirst,
},
child.props.className
),
});
})}
</div>
);
};

View File

@@ -0,0 +1,57 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { ComponentProps } from 'react';
import { clsx } from '../../helper/clsx';
import { Button } from './Button';
/** Active object states use the primary theme token in both light and dark themes. */
export function StateIconButton({ active, emphasizeActive = false, className, ...props }: ComponentProps<typeof Button> & { active: boolean; emphasizeActive?: boolean }) {
return (
<Button
{...props}
variant="ghost"
size="icon"
className={clsx(
`
univer-size-7 univer-shrink-0
focus-visible:univer-outline focus-visible:univer-outline-2 focus-visible:univer-outline-offset-1
focus-visible:univer-outline-primary-500
`,
active
? `
!univer-text-primary-600
dark:!univer-text-primary-400
`
: `
!univer-text-gray-500
hover:!univer-text-gray-900
focus-visible:!univer-text-gray-900
dark:!univer-text-gray-300
dark:hover:!univer-text-gray-100
dark:focus-visible:!univer-text-gray-100
`,
active && emphasizeActive && `
!univer-border-primary-300 !univer-bg-primary-50
hover:!univer-bg-primary-100
dark:!univer-border-primary-700 dark:!univer-bg-primary-900 dark:!univer-text-primary-100
dark:hover:!univer-bg-primary-800
`,
className
)}
/>
);
}

View File

@@ -0,0 +1,61 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { cleanup, fireEvent, render } from '@testing-library/react';
import { afterEach, describe, expect, it } from 'vitest';
import { Button } from '../Button';
import '@testing-library/jest-dom/vitest';
afterEach(cleanup);
describe('Button', () => {
it('click onClick function', () => {
let a = 1;
const { container } = render(
<Button
onClick={() => {
a++;
}}
>
btn2
</Button>
);
fireEvent.click(container.querySelector('button')!);
expect(a).toEqual(2);
});
it('should prevent event when button is disabled', () => {
let a = 1;
const { container } = render(
<Button
disabled
onClick={() => {
a++;
}}
>
btn3
</Button>
);
fireEvent.click(container.querySelector('button')!);
expect(a).toEqual(1);
});
});

View File

@@ -0,0 +1,48 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { Meta } from '@storybook/react';
import { useState } from 'react';
import { Calendar } from './Calendar';
const meta: Meta<typeof Calendar> = {
title: 'Components / Calendar',
component: Calendar,
parameters: {
layout: 'centered',
},
tags: ['autodocs'],
};
export default meta;
export const CalendarBasic = {
render() {
const [date, setDate] = useState<Date | undefined>(new Date('2025-08-04 12:11:43'));
return (
<>
<div>
<Calendar
value={date}
onValueChange={setDate}
showTime
/>
</div>
</>
);
},
};

View File

@@ -0,0 +1,340 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { ButtonHTMLAttributes } from 'react';
import { MoreRightIcon } from '@univerjs/icons';
import { useContext, useMemo, useState } from 'react';
import { clsx } from '../../helper/clsx';
import { Button } from '../button/Button';
import { ConfigContext } from '../config-provider/ConfigProvider';
import { DropdownMenu } from '../dropdown-menu/DropdownMenu';
import { Dropdown } from '../dropdown/Dropdown';
import { TimeInput } from '../time-input/TimeInput';
import { VirtualList } from '../virtual-list';
const DEFAULT_YEAR_RANGE = 100;
const YEAR_ITEM_HEIGHT = 32;
const YEAR_LIST_HEIGHT = 224;
function getDaysInMonth(year: number, month: number) {
return new Date(year, month + 1, 0).getDate();
}
function getFirstDayOfWeek(year: number, month: number) {
return new Date(year, month, 1).getDay();
}
function DayButton(props: ButtonHTMLAttributes<HTMLButtonElement>) {
const { className, ...restProps } = props;
return (
<button
className={clsx(`
univer-size-7 univer-rounded-md univer-border-none univer-bg-transparent univer-p-1 univer-transition-all
hover:univer-bg-gray-200
dark:hover:!univer-bg-gray-600
`, className)}
type="button"
{...restProps}
/>
);
}
interface ICalendarProps {
className?: string;
showTime?: boolean;
showSelection?: boolean;
max?: Date;
min?: Date;
value?: Date;
onValueChange?: (date: Date, changeType: 'date' | 'time') => void;
}
export function Calendar(props: ICalendarProps) {
const { className, max, min, showSelection = true, showTime = false, value, onValueChange } = props;
const { direction = 'ltr', locale } = useContext(ConfigContext);
const { ariaLabels, year, weekDays, months } = locale?.Calendar as {
ariaLabels: {
previousMonth: string;
nextMonth: string;
selectYear: string;
selectMonth: string;
};
year: string;
weekDays: string[];
months: string[];
};
const today = new Date();
const [currentYear, setCurrentYear] = useState((value ?? today).getFullYear());
const [currentMonth, setCurrentMonth] = useState((value ?? today).getMonth());
const [yearDropdownOpen, setYearDropdownOpen] = useState(false);
function prevMonth() {
setCurrentMonth((prev) => {
if (prev === 0) {
setCurrentYear((y) => y - 1);
return 11;
}
return prev - 1;
});
}
function nextMonth() {
setCurrentMonth((prev) => {
if (prev === 11) {
setCurrentYear((y) => y + 1);
return 0;
}
return prev + 1;
});
}
function handleChangeMonth(month: string) {
setCurrentMonth(Number(month));
}
function handleChangeYear(year: number) {
setCurrentYear(year);
setYearDropdownOpen(false);
}
const yearOptions = useMemo(() => {
const startYear = min?.getFullYear() ?? currentYear - DEFAULT_YEAR_RANGE;
const endYear = max?.getFullYear() ?? currentYear + DEFAULT_YEAR_RANGE;
return Array.from(
{ length: Math.max(0, endYear - startYear + 1) },
(_, index) => ({ year: startYear + index })
);
}, [currentYear, max, min]);
const currentYearIndex = Math.max(0, yearOptions.findIndex((item) => item.year === currentYear));
const monthMenuItems = useMemo(() => [{
type: 'radio' as const,
value: String(currentMonth),
hideIndicator: true,
options: months.map((month, index) => ({
label: month,
value: String(index),
})),
onSelect: handleChangeMonth,
}], [currentMonth, months]);
const daysInMonth = getDaysInMonth(currentYear, currentMonth);
const firstDay = getFirstDayOfWeek(currentYear, currentMonth);
const days = useMemo(() => {
const daysArray = [];
for (let i = 0; i < firstDay; i++) {
daysArray.push(null);
}
for (let d = 1; d <= daysInMonth; d++) {
daysArray.push(d);
}
return daysArray;
}, [daysInMonth, firstDay]);
function isSelected(day: number) {
return showSelection && day && currentYear === value?.getFullYear() && currentMonth === value?.getMonth() && day === value?.getDate();
}
function isToday(day: number) {
return day && currentYear === today.getFullYear() && currentMonth === today.getMonth() && day === today.getDate();
}
function isDisabled(day: number) {
if (!day) return false;
const hours = value?.getHours() ?? today.getHours();
const minutes = value?.getMinutes() ?? today.getMinutes();
const seconds = value?.getSeconds() ?? today.getSeconds();
const milliseconds = value?.getMilliseconds() ?? today.getMilliseconds();
const d = new Date(currentYear, currentMonth, day, hours, minutes, seconds, milliseconds);
if (min && d < min) return true;
if (max && d > max) return true;
return false;
}
function handleChangeDate(day: number) {
if (isDisabled(day)) return;
const hours = value?.getHours() ?? today.getHours();
const minutes = value?.getMinutes() ?? today.getMinutes();
const seconds = value?.getSeconds() ?? today.getSeconds();
const milliseconds = value?.getMilliseconds() ?? today.getMilliseconds();
const selectedDate = new Date(currentYear, currentMonth, day, hours, minutes, seconds, milliseconds);
onValueChange?.(selectedDate, 'date');
}
function handleChangeTime(time: Date) {
const hours = time.getHours();
const minutes = time.getMinutes();
const seconds = time.getSeconds();
const milliseconds = time.getMilliseconds();
const updatedDate = new Date(currentYear, currentMonth, value?.getDate() ?? today.getDate(), hours, minutes, seconds, milliseconds);
onValueChange?.(updatedDate, 'time');
}
return (
<div
data-u-comp="calendar"
dir={direction}
className={clsx(`
univer-mx-auto univer-max-w-xs univer-select-none univer-rounded univer-bg-gray-0 univer-text-gray-900
dark:!univer-bg-gray-900 dark:!univer-text-gray-0
`, className)}
>
<nav
className="univer-mb-4 univer-flex univer-items-center univer-justify-between"
>
<DayButton
className={`
univer-text-lg univer-text-gray-500
dark:!univer-text-gray-200
`}
aria-label={ariaLabels.previousMonth}
onClick={prevMonth}
>
<MoreRightIcon
className="
univer-rotate-180
rtl:!univer-rotate-0
"
/>
</DayButton>
<span className="univer-flex univer-items-center univer-gap-0.5 univer-text-sm univer-font-medium">
<Dropdown
align="center"
className="univer-w-24 univer-p-1.5"
open={yearDropdownOpen}
overlay={(
<VirtualList
className="univer-rounded"
data={yearOptions}
height={YEAR_LIST_HEIGHT}
initialScrollIndex={currentYearIndex}
itemHeight={YEAR_ITEM_HEIGHT}
itemKey="year"
>
{(item) => (
<button
className={clsx(`
univer-flex univer-h-8 univer-w-full univer-items-center univer-justify-center
univer-rounded univer-border-none univer-bg-transparent univer-px-2
univer-text-sm univer-text-gray-900 univer-transition-colors
hover:univer-bg-gray-100
dark:!univer-text-gray-0
dark:hover:!univer-bg-gray-600
`, {
'univer-bg-gray-200 dark:!univer-bg-gray-500': item.year === currentYear,
})}
type="button"
onClick={() => handleChangeYear(item.year)}
>
{item.year}
</button>
)}
</VirtualList>
)}
onOpenChange={setYearDropdownOpen}
>
<Button
variant="text"
type="button"
aria-label={ariaLabels.selectYear}
>
{currentYear}
{year}
</Button>
</Dropdown>
<DropdownMenu
align="center"
className="univer-max-h-80 univer-min-w-28 univer-overflow-auto"
items={monthMenuItems}
>
<Button
variant="text"
type="button"
aria-label={ariaLabels.selectMonth}
>
{months[currentMonth]}
</Button>
</DropdownMenu>
</span>
<DayButton
className={`
univer-text-lg univer-text-gray-500
dark:!univer-text-gray-200
`}
aria-label={ariaLabels.nextMonth}
onClick={nextMonth}
>
<MoreRightIcon className="rtl:univer-rotate-180" />
</DayButton>
</nav>
<div
className={`
univer-mb-1 univer-grid univer-grid-cols-7 univer-gap-1 univer-text-center univer-text-sm
univer-text-gray-500
dark:!univer-text-gray-200
`}
>
{weekDays.map((wd) => (
<div key={wd}>{wd}</div>
))}
</div>
<div className="univer-grid univer-grid-cols-7 univer-gap-1 univer-text-center">
{days.map((day, idx) =>
day
? (
<DayButton
key={idx}
className={clsx({
'!univer-bg-primary-600 univer-font-bold univer-text-gray-0': !isToday(day) && isSelected(day),
'dark:!univer-text-gray-0': !isToday(day) && !isSelected(day),
'!univer-bg-primary-600 univer-text-gray-0': isToday(day) && isSelected(day),
'univer-font-semibold univer-text-primary-600 dark:!univer-text-primary-500': isToday(day) && !isSelected(day),
'univer-cursor-not-allowed univer-opacity-40': isDisabled(day),
'univer-hover:bg-primary-100 univer-cursor-pointer univer-text-gray-800': !isSelected(day) && !isDisabled(day),
})}
onClick={() => handleChangeDate(day)}
disabled={isDisabled(day)}
>
{day}
</DayButton>
)
: <div key={idx} />
)}
</div>
{/* time input */}
{showTime && (
<TimeInput
value={value}
onValueChange={handleChangeTime}
/>
)}
</div>
);
};

View File

@@ -0,0 +1,157 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { cleanup, fireEvent, render } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import enUS from '../../../locale/en-US';
import { ConfigProvider } from '../../config-provider/ConfigProvider';
import { Calendar } from '../Calendar';
import '@testing-library/jest-dom/vitest';
afterEach(cleanup);
describe('Calendar', () => {
const onChange = vi.fn();
beforeEach(() => {
onChange.mockClear();
});
function renderCalendar(props = {}) {
const date = new Date(2023, 7, 15);
return render(
<ConfigProvider mountContainer={null} locale={{ Calendar: enUS.design.Calendar }}>
<Calendar value={date} onValueChange={onChange} {...props} />
</ConfigProvider>
);
}
it('should render calendar with correct year, month and day', () => {
const { getByText } = renderCalendar();
expect(getByText('2023')).toBeInTheDocument();
// August
expect(getByText(enUS.design.Calendar.months[7])).toBeInTheDocument();
// 15th
expect(getByText('15')).toBeInTheDocument();
});
it('should call onValueChange when a day is clicked', () => {
const { getByText } = renderCalendar();
getByText('20').click();
expect(onChange).toHaveBeenCalled();
const calledDate = onChange.mock.calls[0][0];
expect(calledDate.getFullYear()).toBe(2023);
expect(calledDate.getMonth()).toBe(7);
expect(calledDate.getDate()).toBe(20);
});
it('should preserve fractional seconds when only the calendar date changes', () => {
const date = new Date(2023, 7, 15, 1, 2, 3, 456);
const { getByText } = render(
<ConfigProvider mountContainer={null} locale={{ Calendar: enUS.design.Calendar }}>
<Calendar value={date} onValueChange={onChange} />
</ConfigProvider>
);
getByText('20').click();
expect((onChange.mock.calls[0][0] as Date).getMilliseconds()).toBe(456);
expect(onChange).toHaveBeenCalledWith(expect.any(Date), 'date');
});
it('should show TimeInput when showTime is true', () => {
const { container } = renderCalendar({ showTime: true });
expect(container.querySelector('[data-u-comp="time-input"]')).toBeInTheDocument();
});
it('should switch year when navigating across month boundaries', () => {
const date = new Date(2023, 0, 15);
const { getByLabelText, getByText } = render(
<ConfigProvider mountContainer={null} locale={{ Calendar: enUS.design.Calendar }}>
<Calendar value={date} onValueChange={onChange} />
</ConfigProvider>
);
fireEvent.click(getByLabelText(enUS.design.Calendar.ariaLabels.previousMonth));
expect(getByText('2022')).toBeInTheDocument();
fireEvent.click(getByLabelText(enUS.design.Calendar.ariaLabels.nextMonth));
expect(getByText('2023')).toBeInTheDocument();
});
it('should select a month from the month menu without changing value immediately', () => {
const { getByLabelText, getByRole, getByText } = renderCalendar();
fireEvent.pointerDown(getByLabelText(enUS.design.Calendar.ariaLabels.selectMonth));
fireEvent.click(getByRole('menuitemradio', { name: enUS.design.Calendar.months[10] }));
expect(getByText(enUS.design.Calendar.months[10])).toBeInTheDocument();
expect(onChange).not.toHaveBeenCalled();
fireEvent.click(getByText('20'));
expect(onChange).toHaveBeenCalledTimes(1);
const calledDate = onChange.mock.calls[0][0];
expect(calledDate.getFullYear()).toBe(2023);
expect(calledDate.getMonth()).toBe(10);
expect(calledDate.getDate()).toBe(20);
});
it('should select a year from the year menu without changing value immediately', () => {
const { getByLabelText, getByText } = renderCalendar();
fireEvent.click(getByLabelText(enUS.design.Calendar.ariaLabels.selectYear));
fireEvent.click(getByText('2030'));
expect(getByText('2030')).toBeInTheDocument();
expect(onChange).not.toHaveBeenCalled();
fireEvent.click(getByText('20'));
expect(onChange).toHaveBeenCalledTimes(1);
const calledDate = onChange.mock.calls[0][0];
expect(calledDate.getFullYear()).toBe(2030);
expect(calledDate.getMonth()).toBe(7);
expect(calledDate.getDate()).toBe(20);
});
it('should limit selectable years by min and max', () => {
const min = new Date(2022, 0, 1);
const max = new Date(2024, 11, 31);
const { getByLabelText, queryByText } = renderCalendar({ min, max });
fireEvent.click(getByLabelText(enUS.design.Calendar.ariaLabels.selectYear));
expect(queryByText('2021')).not.toBeInTheDocument();
expect(queryByText('2022')).toBeInTheDocument();
expect(queryByText('2024')).toBeInTheDocument();
expect(queryByText('2025')).not.toBeInTheDocument();
});
it('should disable out-of-range days and propagate time changes', () => {
const min = new Date(2023, 7, 10, 0, 0, 0);
const max = new Date(2023, 7, 20, 23, 59, 59);
const { getByText, container } = renderCalendar({ min, max, showTime: true });
const outOfRangeDay = getByText('9').closest('button') as HTMLButtonElement;
expect(outOfRangeDay).toBeDisabled();
fireEvent.click(outOfRangeDay);
const timeInput = container.querySelector('input[type="time"]') as HTMLInputElement;
fireEvent.change(timeInput, { target: { value: '11:22:33' } });
expect(onChange).toHaveBeenCalledWith(expect.any(Date), 'time');
});
});

View File

@@ -0,0 +1,188 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { Meta } from '@storybook/react';
import { useState } from 'react';
import { CascaderList } from './CascaderList';
const meta: Meta<typeof CascaderList> = {
title: 'Components / CascaderList',
component: CascaderList,
parameters: {
layout: 'centered',
},
tags: ['autodocs'],
};
export default meta;
export const Playground = {
render() {
const [value, setValue] = useState<string[]>([]);
const options = [
{
value: '常规',
label: '常规',
children: [
{
value: 'hangzhou',
label: 'Hangzhou',
},
],
},
{
value: '货币',
label: '货币',
children: [
{
value: 'nanjing',
label: 'Nanjing',
},
],
},
{
value: '自定义',
label: '自定义',
children: [
{
value: 'beijing',
label: 'beijing',
children: [
{
value: '-1,234.00',
label: '-1,234.00',
},
{
value: '(1,234.00)',
label: '(1,234.00)',
},
],
},
],
},
{
value: '日期',
label: '日期',
children: [],
},
{
value: '会计专用',
label: '会计专用',
},
{
value: '百分比',
label: '百分比',
children: [
{
value: '1.23%',
label: '1.23%',
},
{
value: '123.00%',
label: '123.00%',
},
{
value: '1.23%',
label: '1.23%',
},
{
value: '123.00%',
label: '123.00%',
},
{
value: '1.23%',
label: '1.23%',
},
{
value: '123.00%',
label: '123.00%',
},
{
value: '1.23%',
label: '1.23%',
},
{
value: '123.00%',
label: '123.00%',
},
{
value: '1.23%',
label: '1.23%',
},
{
value: '123.00%',
label: '123.00%',
},
{
value: '1.23%',
label: '1.23%',
},
{
value: '123.00%',
label: '123.00%',
},
{
value: '1.23%',
label: '1.23%',
},
{
value: '123.00%',
label: '123.00%',
},
{
value: '1.23%',
label: '1.23%',
},
{
value: '123.00%',
label: '123.00%',
},
{
value: '1.23%',
label: '1.23%',
},
{
value: '123.00%',
label: '123.00%',
},
{
value: '1.23%',
label: '1.23%',
},
{
value: '123.00%',
label: '123.00%',
},
{
value: '1.23%',
label: '1.23%',
},
{
value: '123.00%',
label: '123.00%',
},
],
},
];
function handleChange(value: string[]) {
setValue(value);
}
return <CascaderList wrapperClassName="univer-h-[150px]" value={value} options={options} onChange={handleChange} />;
},
};

View File

@@ -0,0 +1,171 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { CheckMarkIcon } from '@univerjs/icons';
import { useContext, useMemo } from 'react';
import { borderClassName, scrollbarClassName } from '../../helper/class-utilities';
import { clsx } from '../../helper/clsx';
import { ConfigContext } from '../config-provider/ConfigProvider';
export interface ICascaderOption {
label: string;
value: string;
color?: string;
children?: ICascaderOption[];
}
export interface ICascaderListProps {
/**
* The value of select
*/
value: string[];
/**
* The options of select
* @default []
*/
options?: ICascaderOption[];
/**
* The callback function that is triggered when the value is changed
*/
onChange: (value: string[]) => void;
/**
* The class name of the content
*/
contentClassName?: string;
/**
* The class name of the wrapper
*/
wrapperClassName?: string;
}
function Empty({ emptyText }: { emptyText?: string }) {
return (
<section
className={`
univer-h-8 univer-px-2 univer-pr-32 univer-text-sm/8 univer-text-gray-600
dark:!univer-text-gray-200
`}
>
{emptyText}
</section>
);
}
export function CascaderList(props: ICascaderListProps) {
const { value, options = [], onChange, contentClassName, wrapperClassName } = props;
const { locale } = useContext(ConfigContext);
const activeOptions = useMemo(() => {
const activeOptions = [options];
value.forEach((item, index) => {
const option = activeOptions[index].find((option) => option.value === item);
if (option?.children) {
activeOptions.push(option.children);
}
});
return activeOptions;
}, [value]);
function handleChange(index: number, v: string) {
if (v === value[index]) {
return;
}
if (value[index + 1]) {
const newValue = value.slice(0, index + 1);
newValue[index] = v;
onChange(newValue);
return;
}
const newValue = [...value];
newValue[index] = v;
onChange(newValue);
}
return (
<section
data-u-comp="cascader-list"
className={clsx(`
univer-overflow-auto-y univer-grid univer-h-full univer-max-h-80 univer-grid-flow-col univer-rounded
univer-py-2 univer-text-gray-900
dark:!univer-text-gray-0
[&>ul:not(:last-child)]:univer-border-0 [&>ul:not(:last-child)]:univer-border-r
[&>ul:not(:last-child)]:univer-border-solid [&>ul:not(:last-child)]:univer-border-r-gray-200
`, borderClassName, scrollbarClassName, wrapperClassName)}
>
{activeOptions.map((options, index) =>
options.length
? (
<ul
key={index}
className={clsx(`
univer-m-0 univer-h-full univer-max-h-full univer-list-none univer-overflow-auto
univer-px-2
`, scrollbarClassName, contentClassName)}
>
{options.map((option) => (
<li key={option.value}>
<a
className={clsx(`
univer-relative univer-block univer-h-8 univer-cursor-pointer univer-rounded
univer-text-sm/8
`, {
'univer-px-7': index > 0,
'univer-px-1.5': index === 0,
'univer-bg-gray-200 dark:!univer-bg-gray-600': option.value === value[index],
})}
onClick={() => handleChange(index, option.value)}
>
{index > 0 && (
<span
className={`
univer-absolute univer-left-2 univer-flex univer-h-full
univer-items-center
`}
>
{option.value === value[index] && (
<CheckMarkIcon
className="univer-text-primary-600"
/>
)}
</span>
)}
<span>{option.label}</span>
</a>
</li>
))}
</ul>
)
: (
<Empty key={index} emptyText={locale?.CascaderList.empty} />
)
)}
{value.length <= 0 && (
<Empty emptyText={locale?.CascaderList.empty} />
)}
</section>
);
}

View File

@@ -0,0 +1,102 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { cleanup, fireEvent, render } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { CascaderList } from '../CascaderList';
afterEach(cleanup);
describe('CascaderList', () => {
afterEach(cleanup);
// const options: ICascaderListProps['options'] = [
// {
// label: 'Option 1',
// value: 'option1',
// children: [
// { label: 'Option 1-1', value: 'option1-1' },
// { label: 'Option 1-2', value: 'option1-2' },
// ],
// },
// {
// label: 'Option 2',
// value: 'option2',
// children: [
// { label: 'Option 2-1', value: 'option2-1' },
// { label: 'Option 2-2', value: 'option2-2' },
// ],
// },
// ];
it('renders correctly with default props', () => {
const { container } = render(<CascaderList value={[]} onChange={() => {}} />);
expect(container).toBeTruthy();
});
it('should select parent and child options', () => {
const onChange = vi.fn();
const options = [
{
label: 'A',
value: 'a',
children: [
{ label: 'A-1', value: 'a-1' },
{ label: 'A-2', value: 'a-2' },
],
},
{
label: 'B',
value: 'b',
},
];
const { getByText, rerender } = render(<CascaderList value={[]} options={options} onChange={onChange} />);
fireEvent.click(getByText('A'));
expect(onChange).toHaveBeenCalledWith(['a']);
rerender(<CascaderList value={['a']} options={options} onChange={onChange} />);
fireEvent.click(getByText('A-2'));
expect(onChange).toHaveBeenCalledWith(['a', 'a-2']);
});
it('should ignore same value click and rewrite path when parent changes', () => {
const onChange = vi.fn();
const options = [
{
label: 'A',
value: 'a',
children: [{ label: 'A-1', value: 'a-1' }],
},
{
label: 'B',
value: 'b',
children: [{ label: 'B-1', value: 'b-1' }],
},
];
const { getByText } = render(
<CascaderList value={['a', 'a-1']} options={options} onChange={onChange} />
);
fireEvent.click(getByText('A'));
expect(onChange).not.toHaveBeenCalledWith(['a', 'a-1']);
fireEvent.click(getByText('B'));
expect(onChange).toHaveBeenCalledWith(['b']);
});
});

View File

@@ -0,0 +1,65 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { Meta } from '@storybook/react';
import { useState } from 'react';
import { Checkbox } from '../checkbox/Checkbox';
import { CheckboxGroup } from './CheckboxGroup';
const meta: Meta<typeof CheckboxGroup> = {
title: 'Components / CheckboxGroup',
component: CheckboxGroup,
parameters: {
layout: 'centered',
},
tags: ['autodocs'],
};
export default meta;
export const Playground = {
render() {
const [value, setValue] = useState<string[]>([]);
function handleChange(value: Array<string | number | boolean>) {
setValue(value as string[]);
}
return (
<CheckboxGroup value={value} onChange={handleChange}>
<Checkbox value="test">test</Checkbox>
<Checkbox value="test1">test1</Checkbox>
</CheckboxGroup>
);
},
};
export const CheckboxGroupVertical = {
render() {
const [value, setValue] = useState<string[]>([]);
function handleChange(value: Array<string | number | boolean>) {
setValue(value as string[]);
}
return (
<CheckboxGroup value={value} onChange={handleChange} direction="vertical">
<Checkbox value="test">test</Checkbox>
<Checkbox value="test1">test1</Checkbox>
</CheckboxGroup>
);
},
};

View File

@@ -0,0 +1,93 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { CSSProperties, ReactNode } from 'react';
import type { ICheckboxProps } from '../checkbox/Checkbox';
import { Children, cloneElement, isValidElement } from 'react';
import { clsx } from '../../helper/clsx';
export interface ICheckboxGroupProps {
children: ReactNode[];
/**
* The class name of the checkbox group
*/
className?: string;
/**
* The style of the checkbox group
*/
style?: CSSProperties;
/**
* Define which checkbox is selected
*/
value: Array<string | number | boolean>;
/**
* Whether the checkbox is disabled
* @default false
*/
disabled?: boolean;
/**
* Direction of the radio group
* @default 'horizontal'
*/
direction?: 'horizontal' | 'vertical';
/**
* The callback function triggered when switching options
*/
onChange: (value: Array<string | number | boolean>) => void;
}
/**
* CheckboxGroup Component
*/
export function CheckboxGroup(props: ICheckboxGroupProps) {
const { children, className, style, value, disabled, direction = 'horizontal', onChange } = props;
const handleChange = (item: string | number | boolean) => {
if (value.includes(item)) {
onChange(value.filter((i) => i !== item));
} else {
onChange([...value, item]);
}
};
return (
<div
className={clsx('univer-flex univer-gap-2', {
'univer-flex-col': direction === 'vertical',
}, className)}
style={style}
>
{Children.map(children, (child, index) => {
if (isValidElement<ICheckboxProps>(child)) {
return cloneElement(child, {
key: index,
children: child.props.children,
checked: child.props.value ? value.includes(child.props.value) : false,
disabled: disabled ?? child.props.disabled,
onChange: handleChange,
});
}
return child;
})}
</div>
);
}

View File

@@ -0,0 +1,68 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { cleanup, fireEvent, render } from '@testing-library/react';
import { useState } from 'react';
import { afterEach, describe, expect, it } from 'vitest';
import { Checkbox } from '../../checkbox/Checkbox';
import { CheckboxGroup } from '../CheckboxGroup';
afterEach(cleanup);
describe('CheckboxGroup', () => {
let active = ['0'];
const group = (
<CheckboxGroup
value={active}
onChange={(value) => {
active = value as string[];
}}
>
<Checkbox value="0">0</Checkbox>
<Checkbox value="1">1</Checkbox>
</CheckboxGroup>
);
it('click Checkbox', async () => {
render(group);
let result = ['0'];
function Demo() {
const [active, setActive] = useState(result);
return (
<CheckboxGroup
value={active}
onChange={(value) => {
setActive(value as string[]);
result = value as string[];
}}
>
<Checkbox value="0">x</Checkbox>
<Checkbox value="1">y</Checkbox>
</CheckboxGroup>
);
}
const { getByText } = render(<Demo />);
fireEvent.click(getByText('y'));
fireEvent.click(getByText('y'));
fireEvent.click(getByText('y'));
expect(result).toEqual(['0', '1']);
});
});

View File

@@ -0,0 +1,79 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { Meta } from '@storybook/react';
import { useState } from 'react';
import { Checkbox } from './Checkbox';
const meta: Meta<typeof Checkbox> = {
title: 'Components / Checkbox',
component: Checkbox,
parameters: {
layout: 'centered',
},
tags: ['autodocs'],
};
export default meta;
export const CheckboxBasic = {
render() {
const [checked, setChecked] = useState(false);
function handleChange(value: string | number | boolean) {
setChecked(value as boolean);
}
return (
<>
<Checkbox checked={checked} onChange={handleChange}>checkbox 1</Checkbox>
<Checkbox checked onChange={handleChange}>checkbox 1</Checkbox>
</>
);
},
};
export const CheckboxDisabled = {
render() {
return (
<>
<Checkbox disabled>
checkbox 1
</Checkbox>
<Checkbox disabled checked>
checkbox 2
</Checkbox>
</>
);
},
};
export const CheckboxIndeterminate = {
render() {
const [checked, setChecked] = useState(false);
function handleChange(value: string | number | boolean) {
setChecked(value as boolean);
}
return (
<>
<Checkbox checked={checked} onChange={handleChange} indeterminate>checkbox 1</Checkbox>
<Checkbox disabled indeterminate>
checkbox 2
</Checkbox>
</>
);
},
};

View File

@@ -0,0 +1,139 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { ChangeEvent, CSSProperties, ReactNode } from 'react';
import { CheckMarkIcon } from '@univerjs/icons';
import { useRef } from 'react';
import { clsx } from '../../helper/clsx';
export interface ICheckboxProps {
children?: ReactNode;
/**
* The class name of the checkbox group
*/
className?: string;
/**
* The style of the checkbox group
*/
style?: CSSProperties;
/**
* Used for setting the currently selected value
* @default false
*/
checked?: boolean;
/**
* Used for setting the checkbox to indeterminate
* @default false
*/
indeterminate?: boolean;
/**
* Used for setting the currently selected value
* Only used when the checkbox is in a group
*/
value?: string | number | boolean;
/**
* Specifies whether the checkbox is disabled
* @default false
*/
disabled?: boolean;
/**
* Set the handler to handle `click` event
*/
onChange?: (value: string | number | boolean) => void;
contentClassName?: string;
}
/**
* Checkbox Component
*/
export function Checkbox(props: ICheckboxProps) {
const { children, className, style, checked = false, indeterminate = false, value, disabled = false, onChange, contentClassName } = props;
const inputRef = useRef<HTMLInputElement>(null);
function handleChange(e: ChangeEvent<HTMLInputElement>) {
e.stopPropagation();
if (!onChange || disabled) return;
if (typeof value !== 'undefined') {
onChange?.(value);
} else {
const checked = inputRef?.current?.checked ?? false;
onChange?.(checked);
}
}
return (
<label
data-u-comp="checkbox"
className={clsx('univer-box-border univer-inline-flex univer-items-center univer-gap-2 univer-text-sm', {
'univer-cursor-pointer univer-text-gray-900 dark:!univer-text-gray-0': !disabled,
'univer-text-gray-400': disabled,
}, className)}
style={style}
>
<span className="univer-relative univer-block">
<input
ref={inputRef}
className="univer-absolute univer-size-0 univer-opacity-0"
type="checkbox"
checked={checked}
disabled={disabled}
onChange={handleChange}
/>
<span
className={clsx(`
univer-relative univer-box-border univer-flex univer-size-4 univer-items-center
univer-justify-center univer-overflow-hidden univer-rounded univer-border univer-border-solid
univer-transition-colors
`, {
'univer-opacity-50': disabled,
'univer-border-primary-600 univer-bg-primary-600': checked || indeterminate,
'univer-border-gray-300 univer-bg-gray-50 dark:!univer-border-gray-500 dark:!univer-bg-gray-600': !checked && !indeterminate,
})}
>
{checked && (
<CheckMarkIcon
className={`
univer-absolute univer-left-1/2 univer-top-1/2 univer-block univer-size-3
-univer-translate-x-1/2 -univer-translate-y-1/2 univer-text-gray-0
`}
/>
)}
{indeterminate && !checked && (
<span
className={`
univer-absolute univer-left-1/2 univer-top-1/2 univer-block univer-h-0.5 univer-w-2.5
-univer-translate-x-1/2 -univer-translate-y-1/2 univer-rounded univer-bg-gray-0
`}
/>
)}
</span>
</span>
<span className={clsx('univer-select-none', contentClassName)}>{children}</span>
</label>
);
}

View File

@@ -0,0 +1,46 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { cleanup, fireEvent, render } from '@testing-library/react';
import { useState } from 'react';
import { afterEach, describe, expect, it } from 'vitest';
import { Checkbox } from '../Checkbox';
afterEach(cleanup);
describe('Checkbox', () => {
it('click Checkbox', async () => {
function Component() {
const [checked, setChecked] = useState(false);
function handleChange(value: string | number | boolean) {
setChecked(value as boolean);
}
return <Checkbox checked={checked} onChange={handleChange}>text</Checkbox>;
}
const root = render(<Component />);
fireEvent.click(root.container.querySelector('input')!);
const $input = root.container.querySelector('input');
expect($input?.checked).toBe(true);
root.unmount();
});
});

View File

@@ -0,0 +1,133 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useCallback, useEffect, useRef, useState } from 'react';
import { hsvToRgb } from './color-conversion';
interface IAlphaSliderProps {
hsv: [number, number, number];
alpha: number; // 0-1
onChange: (alpha: number) => void;
onChanged?: (alpha: number) => void;
}
export function AlphaSlider({ hsv, alpha, onChange, onChanged }: IAlphaSliderProps) {
const [isDragging, setIsDragging] = useState(false);
const sliderRef = useRef<HTMLDivElement>(null);
const thumbRef = useRef<HTMLDivElement>(null);
const alphaRef = useRef(alpha);
const calculateAlpha = useCallback((clientX: number) => {
const slider = sliderRef.current;
if (!slider) return;
const thumbSize = thumbRef.current?.clientWidth ?? 0;
const rect = slider.getBoundingClientRect();
const maxX = rect.width - thumbSize;
if (maxX <= 0) {
onChange(0);
return;
}
const x = Math.max(0, Math.min(clientX - rect.left, maxX));
onChange(Math.round(x / maxX * 100) / 100);
}, [onChange]);
const handlePointerMove = useCallback((e: PointerEvent) => {
if (!isDragging) return;
calculateAlpha(e.clientX);
}, [isDragging, calculateAlpha]);
useEffect(() => {
alphaRef.current = alpha;
}, [alpha]);
const handlePointerUp = useCallback(() => {
setIsDragging(false);
onChanged?.(alphaRef.current);
}, [onChanged]);
useEffect(() => {
if (isDragging) {
window.addEventListener('pointermove', handlePointerMove);
window.addEventListener('pointerup', handlePointerUp);
window.addEventListener('mouseup', handlePointerUp);
}
return () => {
window.removeEventListener('pointermove', handlePointerMove);
window.removeEventListener('pointerup', handlePointerUp);
window.removeEventListener('mouseup', handlePointerUp);
};
}, [isDragging, handlePointerMove, handlePointerUp]);
const getThumbPosition = () => {
const safeAlpha = Math.min(Math.max(alpha * 100, 0), 100);
const thumbSize = thumbRef.current?.clientWidth ?? 0;
const sliderWidth = sliderRef.current?.clientWidth ?? 0;
const thumbOffsetPercent = sliderWidth > 0 ? (thumbSize / sliderWidth) * 100 : 0;
return `${(safeAlpha / 100) * (100 - thumbOffsetPercent)}%`;
};
const color = hsvToRgb(...hsv);
return (
<div data-u-comp="color-picker-alpha-slider" className="univer-relative univer-w-full univer-select-none">
{/* Chessboard background */}
<div
className="univer-absolute univer-inset-0 univer-rounded-full"
style={{
backgroundImage: 'linear-gradient(45deg, #E3E5EA 25%, transparent 25%), linear-gradient(-45deg, #E3E5EA 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #E3E5EA 75%), linear-gradient(-45deg, transparent 75%, #E3E5EA 75%)',
backgroundPosition: '0 0, 0 4px, 4px -4px, -4px 0',
backgroundSize: '8px 8px',
}}
/>
{/* Slider */}
<div
ref={sliderRef}
data-u-comp="color-picker-alpha-slider-track"
className={`
univer-relative univer-h-2 univer-w-full univer-cursor-pointer univer-rounded-full univer-shadow-inner
`}
style={{
background: `linear-gradient(to right, transparent, rgb(${color.join(',')}))`,
}}
onPointerDown={(e) => {
setIsDragging(true);
calculateAlpha(e.clientX);
}}
>
{/* Indicator */}
<div
ref={thumbRef}
data-u-comp="color-picker-alpha-slider-thumb"
className={`
univer-absolute univer-top-1/2 univer-box-border univer-size-2 univer-rounded-full
univer-bg-transparent univer-shadow-md univer-ring-2 univer-ring-gray-0
univer-transition-transform univer-duration-75 univer-will-change-transform
`}
style={{
left: getThumbPosition(),
transform: 'translateY(-50%)',
transition: isDragging ? 'none' : 'all 0.1s ease-out',
}}
/>
</div>
</div>
);
};

View File

@@ -0,0 +1,208 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { ChangeEvent } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { borderClassName } from '../../helper/class-utilities';
import { clsx } from '../../helper/clsx';
import { hexToHsv, hsvToHex, hsvToRgb, rgbToHsv } from './color-conversion';
interface IColorInputProps {
hsv: [number, number, number];
alpha: number;
format: 'hex' | 'rgba';
onChange: (h: number, s: number, v: number, a?: number) => void;
}
interface IInputProps {
hsv: [number, number, number];
alpha?: number;
format?: 'hex' | 'rgba';
onChange?: (h: number, s: number, v: number, a?: number) => void;
}
function HexInput({ hsv, onChange }: IInputProps) {
const [inputValue, setInputValue] = useState('');
const hexValue = useMemo(() => hsvToHex(hsv[0], hsv[1], hsv[2]), [hsv]);
useEffect(() => {
setInputValue(hexValue.replace(/^#/, ''));
}, [hexValue]);
const isValidHex = (hex: string) => {
return /^[0-9A-Fa-f]{6}$/.test(hex);
};
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value.trim();
if (newValue.length > 6) return;
if (newValue !== '' && !/^[0-9A-Fa-f]*$/.test(newValue)) return;
setInputValue(newValue);
if (isValidHex(newValue)) {
const hsvValue = hexToHsv(newValue);
if (hsvValue && onChange) {
onChange(...hsvValue);
}
}
};
const handleBlur = () => {
if (!isValidHex(inputValue)) {
setInputValue(hexValue.replace(/^#/, ''));
}
};
return (
<>
<input
className={clsx(`
univer-w-full univer-px-2 !univer-pl-4 univer-uppercase
focus:univer-border-primary-500 focus:univer-outline-none
rtl:!univer-pl-2 rtl:!univer-pr-4
dark:!univer-text-gray-0
`, borderClassName)}
value={inputValue}
onChange={handleChange}
onBlur={handleBlur}
maxLength={6}
spellCheck={false}
/>
<span
className={`
univer-absolute univer-left-1.5 univer-top-1/2 -univer-translate-y-1/2 univer-text-sm
univer-text-gray-400
rtl:univer-left-auto rtl:univer-right-1.5
`}
>
#
</span>
</>
);
}
function RgbInput({ hsv, alpha, format, onChange }: IInputProps) {
const [localValues, setLocalValues] = useState({ r: 0, g: 0, b: 0, a: 1 });
useEffect(() => {
const [r, g, b] = hsvToRgb(hsv[0], hsv[1], hsv[2]);
setLocalValues({
r: Math.round(r),
g: Math.round(g),
b: Math.round(b),
a: alpha ?? 1,
});
}, [hsv, alpha]);
const handleChange = (color: 'r' | 'g' | 'b' | 'a', value: string) => {
if (color === 'a') {
if (value !== '' && !/^\d*\.?\d*$/.test(value)) return;
const numValue = value === '' ? 0 : Number.parseFloat(value);
if (numValue > 1) return;
const newValues = { ...localValues, a: numValue };
setLocalValues(newValues);
if (onChange) {
onChange(hsv[0], hsv[1], hsv[2], numValue);
}
return;
}
if (value !== '' && !/^\d*$/.test(value)) return;
const numValue = value === '' ? 0 : Number.parseInt(value, 10);
if (numValue > 255) return;
const newValues = { ...localValues, [color]: numValue };
setLocalValues(newValues);
if (onChange) {
const hsv = rgbToHsv(newValues.r, newValues.g, newValues.b);
onChange(...hsv, localValues.a);
}
};
const handleBlur = () => {
const [r, g, b] = hsvToRgb(hsv[0], hsv[1], hsv[2]);
setLocalValues({
r: Math.round(r),
g: Math.round(g),
b: Math.round(b),
a: alpha ?? 1,
});
};
return (
<div
className={`
univer-flex univer-items-center univer-gap-2
[&>input]:univer-w-11 [&>input]:univer-border-gray-200
[&>input]:focus:univer-border-primary-500
dark:[&>input]:!univer-border-gray-600 dark:[&>input]:!univer-text-gray-0
`}
>
<input
value={localValues.r}
onChange={(e) => handleChange('r', e.target.value)}
onBlur={handleBlur}
maxLength={3}
/>
<input
value={localValues.g}
onChange={(e) => handleChange('g', e.target.value)}
onBlur={handleBlur}
maxLength={3}
/>
<input
value={localValues.b}
onChange={(e) => handleChange('b', e.target.value)}
onBlur={handleBlur}
maxLength={3}
/>
{format === 'rgba' && (
<input
value={localValues.a}
onChange={(e) => handleChange('a', e.target.value)}
onBlur={handleBlur}
maxLength={4}
/>
)}
</div>
);
}
export function ColorInput({ hsv, alpha, format, onChange }: IColorInputProps) {
return (
<div
className={`
dark:![&_input]:univer-border-gray-600 dark:![&_input]:univer-text-gray-0
univer-flex univer-gap-2
[&_input]:univer-box-border [&_input]:univer-flex [&_input]:univer-h-7 [&_input]:univer-items-center
[&_input]:univer-rounded [&_input]:univer-border [&_input]:univer-border-solid
[&_input]:univer-border-gray-200 [&_input]:univer-bg-transparent [&_input]:univer-px-1.5
[&_input]:univer-text-sm [&_input]:univer-text-gray-700 [&_input]:univer-outline-none
`}
>
<div className="univer-relative univer-flex univer-flex-1 univer-gap-2">
<HexInput hsv={hsv} onChange={onChange} />
<RgbInput hsv={hsv} alpha={alpha} format={format} onChange={onChange} />
</div>
</div>
);
}

View File

@@ -0,0 +1,67 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { Meta } from '@storybook/react';
import { useState } from 'react';
import { ColorPicker } from './ColorPicker';
const meta: Meta<typeof ColorPicker> = {
title: 'Components / ColorPicker',
component: ColorPicker,
parameters: {
layout: 'centered',
},
tags: ['autodocs'],
args: {},
};
export default meta;
export const Playground = {
render() {
const [value, setValue] = useState('#FF0000');
return (
<div className="univer-flex univer-flex-col univer-gap-4">
<div>
<div>
HEX Format:
{value}
</div>
<ColorPicker format="hex" value={value} onChange={setValue} />
</div>
</div>
);
},
};
export const RgbaFormat = {
render() {
const [value, setValue] = useState('rgba(255, 0, 0, 0.5)');
return (
<div className="univer-flex univer-flex-col univer-gap-4">
<div>
<div>
RGBA Format:
{value}
</div>
<ColorPicker format="rgba" value={value} onChange={setValue} />
</div>
</div>
);
},
};

View File

@@ -0,0 +1,196 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { memo, useCallback, useContext, useEffect, useState } from 'react';
import { isBrowser } from '../../helper/is-browser';
import { Button } from '../button/Button';
import { ConfigContext } from '../config-provider/ConfigProvider';
import { Dialog } from '../dialog/Dialog';
import { AlphaSlider } from './AlphaSlider';
import { hexToHsv, hsvToHex, hsvToRgb, hsvToRgba, parseRgba, rgbToHex, rgbToHsv } from './color-conversion';
import { ColorInput } from './ColorInput';
import { ColorPresets } from './ColorPresets';
import { ColorSpectrum } from './ColorSpectrum';
import { HueSlider } from './HueSlider';
const MemoizedColorSpectrum = memo(ColorSpectrum);
const MemoizedHueSlider = memo(HueSlider);
const MemoizedAlphaSlider = memo(AlphaSlider);
const MemoizedColorInput = memo(ColorInput);
const MemoizedColorPresets = memo(ColorPresets);
export interface IColorPickerProps {
format?: 'hex' | 'rgba';
value?: string;
onChange?: (value: string) => void;
}
export function ColorPicker({ format = 'hex', value, onChange }: IColorPickerProps) {
const { direction, locale } = useContext(ConfigContext);
const [hsv, setHsv] = useState<[number, number, number]>([0, 100, 100]);
const [alpha, setAlpha] = useState(1);
const [visible, setVisible] = useState(false);
const getRgb = useCallback((h: number, s: number, v: number) => {
return hsvToRgb(h, s, v);
}, []);
useEffect(() => {
try {
const actualValue = value || (format === 'hex' ? '#000000' : 'rgba(0, 0, 0, 1)');
if (format === 'hex') {
const [h, s, v] = hexToHsv(actualValue);
setHsv([h, s, v]);
setAlpha(1);
} else if (format === 'rgba') {
const [r, g, b, a] = parseRgba(actualValue);
const [h, s, v] = rgbToHsv(r, g, b);
setHsv([h, s, v]);
setAlpha(a);
}
} catch (error) {
console.error('Invalid value:', error);
}
}, [value, format]);
if (!isBrowser) {
return null;
}
function handleColorChange(h: number, s: number, v: number) {
setHsv([h, s, v]);
}
function handleAlphaChange(a: number) {
setAlpha(a);
}
function handleColorChanged(h: number, s: number, v: number, a: number = alpha) {
if (format === 'hex') {
const [r, g, b] = getRgb(h, s, v);
const hex = rgbToHex(r, g, b);
onChange?.(hex);
} else if (format === 'rgba') {
const [r, g, b] = getRgb(h, s, v);
onChange?.(`rgba(${r}, ${g}, ${b}, ${a})`);
}
}
function handleConfirmCustomColor() {
const [h, s, v] = hsv;
if (format === 'hex') {
const hex = hsvToHex(h, s, v);
onChange?.(hex);
} else if (format === 'rgba') {
const [r, g, b] = getRgb(h, s, v);
onChange?.(`rgba(${r}, ${g}, ${b}, ${alpha})`);
}
setVisible(false);
}
return (
<div
data-u-comp="color-picker"
dir={direction}
className="univer-cursor-default univer-space-y-2 univer-rounded-lg"
onClick={(e) => e.stopPropagation()}
>
<MemoizedColorPresets
hsv={hsv}
onChange={(h, s, v) => {
handleColorChange(h, s, v);
handleAlphaChange(1);
handleColorChanged(h, s, v, 1);
}}
/>
<div className="univer-flex univer-h-7 univer-items-center">
<button
type="button"
className="
univer-cursor-pointer univer-border-0 univer-bg-transparent univer-p-0 univer-text-sm
univer-text-gray-900 univer-transition-opacity
hover:univer-opacity-80
dark:!univer-text-gray-0
"
onClick={() => setVisible(true)}
>
{locale?.ColorPicker.more}
</button>
</div>
<Dialog
className="!univer-z-[1090] !univer-w-fit !univer-p-2.5"
overlayClassName="!univer-z-[1090]"
closable={false}
maskClosable={false}
open={visible}
onOpenChange={setVisible}
>
<div className="univer-grid univer-w-64 univer-gap-2">
<MemoizedColorSpectrum
hsv={hsv}
onChange={handleColorChange}
/>
<div className="univer-flex univer-items-center univer-gap-2">
<div
className="univer-size-6 univer-flex-shrink-0 univer-rounded-sm"
style={{
backgroundColor: format === 'hex' ? hsvToHex(...hsv) : hsvToRgba(...hsv, alpha),
}}
/>
<div className="univer-flex-1 univer-space-y-2">
<MemoizedHueSlider
hsv={hsv}
onChange={handleColorChange}
/>
{format === 'rgba' && (
<MemoizedAlphaSlider
hsv={hsv}
alpha={alpha}
onChange={handleAlphaChange}
/>
)}
</div>
</div>
<MemoizedColorInput
hsv={hsv}
alpha={alpha}
format={format}
onChange={(h, s, v, a) => {
handleColorChange(h, s, v);
if (a !== undefined) {
handleAlphaChange(a);
}
}}
/>
<footer className="univer-flex univer-items-center univer-justify-end univer-gap-2">
<Button onClick={() => setVisible(false)}>
{locale?.ColorPicker.cancel}
</Button>
<Button variant="primary" onClick={handleConfirmCustomColor}>
{locale?.ColorPicker.confirm}
</Button>
</footer>
</div>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,66 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useContext, useState } from 'react';
import { Button } from '../button/Button';
import { ConfigContext } from '../config-provider/ConfigProvider';
import { hexToHsv, hsvToHex } from './color-conversion';
import { ColorInput } from './ColorInput';
import { ColorSpectrum } from './ColorSpectrum';
import { HueSlider } from './HueSlider';
export interface IColorPickerPanelProps {
value?: string;
confirmText?: string;
onConfirm?: (value: string) => void;
}
export function ColorPickerPanel({ value = '#000000', confirmText, onConfirm }: IColorPickerPanelProps) {
const { locale } = useContext(ConfigContext);
const [hsv, setHsv] = useState<[number, number, number]>(() => hexToHsv(value));
const color = hsvToHex(...hsv);
return (
<div data-u-comp="color-picker-panel" className="univer-grid univer-gap-4">
<div className="univer-h-44 univer-overflow-hidden univer-rounded-lg">
<ColorSpectrum hsv={hsv} onChange={(h, s, v) => setHsv([h, s, v])} />
</div>
<div className="univer-flex univer-items-center univer-gap-3">
<span
className="
univer-size-10 univer-shrink-0 univer-rounded-lg univer-border univer-border-solid
univer-border-gray-200
dark:!univer-border-gray-600
"
style={{ backgroundColor: color }}
/>
<div className="univer-flex-1">
<HueSlider hsv={hsv} onChange={(h, s, v) => setHsv([h, s, v])} />
</div>
</div>
<ColorInput
hsv={hsv}
alpha={1}
format="hex"
onChange={(h, s, v) => setHsv([h, s, v])}
/>
<Button className="!univer-h-11 !univer-w-full" variant="primary" onClick={() => onConfirm?.(color)}>
{confirmText ?? locale?.ColorPicker.confirm}
</Button>
</div>
);
}

View File

@@ -0,0 +1,64 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useCallback } from 'react';
import { clsx } from '../../helper/clsx';
import { hexToHsv, hsvToHex } from './color-conversion';
import { colorPresets } from './presets';
interface IColorPresetsProps {
hsv: [number, number, number];
onChange: (h: number, s: number, v: number) => void;
}
export function ColorPresets({ hsv, onChange }: IColorPresetsProps) {
const handleSelectPreset = useCallback((color: string) => {
const [h, s, v] = hexToHsv(color);
onChange(h, s, v);
}, [onChange]);
const currentColor = hsvToHex(hsv[0], hsv[1], hsv[2]);
return (
<div
data-u-comp="color-picker-presets"
className="univer-grid univer-content-center univer-gap-2"
>
{colorPresets.map((row, i) => (
<div
key={i}
className="univer-grid univer-grid-flow-col univer-items-center univer-justify-between univer-gap-2"
>
{row.map((color, j) => (
<button
key={j}
type="button"
className={clsx(`
univer-box-border univer-size-5 univer-cursor-pointer univer-rounded-full univer-border
univer-border-solid univer-border-transparent univer-bg-gray-300 univer-transition-shadow
`, {
'univer-ring-2 univer-ring-offset-2 univer-ring-offset-gray-0 dark:!univer-ring-primary-600 dark:!univer-ring-offset-gray-600': color.toUpperCase() === currentColor.toUpperCase(),
'!univer-border-gray-200': i === 0 && j === 0,
})}
style={{ backgroundColor: color }}
onClick={() => handleSelectPreset(color)}
/>
))}
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,139 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { PointerEvent } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
interface IColorSpectrumProps {
hsv: [number, number, number];
onChange: (h: number, s: number, v: number) => void;
onChanged?: (h: number, s: number, v: number) => void;
}
export function ColorSpectrum({ hsv, onChange, onChanged }: IColorSpectrumProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const [isDragging, setIsDragging] = useState(false);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const gradientS = ctx.createLinearGradient(0, 0, canvas.width, 0);
gradientS.addColorStop(0, `hsl(${hsv[0]}, 0%, 50%)`);
gradientS.addColorStop(1, `hsl(${hsv[0]}, 100%, 50%)`);
ctx.fillStyle = gradientS;
ctx.fillRect(0, 0, canvas.width, canvas.height);
const gradientV = ctx.createLinearGradient(0, 0, 0, canvas.height);
gradientV.addColorStop(0, 'rgba(255, 255, 255, 0)');
gradientV.addColorStop(1, 'rgba(0, 0, 0, 1)');
ctx.fillStyle = gradientV;
ctx.fillRect(0, 0, canvas.width, canvas.height);
}, [hsv[0]]);
const handlePointerEvent = (e: PointerEvent) => {
e.stopPropagation();
const canvas = canvasRef.current;
if (!canvas) return;
const rect = canvas.getBoundingClientRect();
const x = Math.max(0, Math.min(e.clientX - rect.left, rect.width));
const y = Math.max(0, Math.min(e.clientY - rect.top, rect.height));
const s = (x / rect.width) * 100;
const v = 100 - (y / rect.height) * 100;
onChange(hsv[0], s, v);
};
const handlePointerUp = useCallback(() => {
setIsDragging(false);
}, []);
function handleChange() {
onChanged?.(hsv[0], hsv[1], hsv[2]);
}
useEffect(() => {
containerRef.current?.addEventListener('mouseup', handleChange);
window.addEventListener('pointerup', handlePointerUp);
window.addEventListener('mouseup', handlePointerUp);
return () => {
containerRef.current?.removeEventListener('mouseup', handleChange);
window.removeEventListener('pointerup', handlePointerUp);
window.removeEventListener('mouseup', handlePointerUp);
};
}, [hsv]);
// Calculate the position of the indicator
const getIndicatorStyles = () => {
return {
left: `${hsv[1]}%`,
top: `${100 - hsv[2]}%`,
transform: 'translate(-50%, -50%)',
transition: isDragging ? 'none' : 'left 0.1s ease-out, top 0.1s ease-out',
};
};
useEffect(() => {
const handlePointerUp = () => {
setIsDragging(false);
};
window.addEventListener('pointerup', handlePointerUp);
window.addEventListener('mouseup', handlePointerUp);
return () => {
window.removeEventListener('pointerup', handlePointerUp);
window.removeEventListener('mouseup', handlePointerUp);
};
}, []);
return (
<div
data-u-comp="color-picker-spectrum"
ref={containerRef}
className="univer-relative univer-size-full univer-overflow-hidden"
>
<canvas
ref={canvasRef}
data-u-comp="color-picker-spectrum-canvas"
className="univer-size-full univer-cursor-crosshair univer-rounded"
onPointerDown={(e) => {
setIsDragging(true);
handlePointerEvent(e);
}}
onPointerMove={(e) => isDragging && handlePointerEvent(e)}
/>
{/* Indicator */}
<div
className={`
univer-pointer-events-none univer-absolute univer-left-0 univer-top-0 univer-size-4
univer-rounded-full univer-border-2 univer-border-gray-0 univer-shadow-md univer-ring-2
univer-ring-gray-0 univer-will-change-transform
`}
style={getIndicatorStyles()}
/>
</div>
);
};

View File

@@ -0,0 +1,131 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useCallback, useEffect, useRef, useState } from 'react';
interface IHueSliderProps {
hsv: [number, number, number];
onChange: (h: number, s: number, v: number) => void;
onChanged?: (h: number, s: number, v: number) => void;
}
export function HueSlider({ hsv, onChange, onChanged }: IHueSliderProps) {
const [isDragging, setIsDragging] = useState(false);
const sliderRef = useRef<HTMLDivElement>(null);
const thumbRef = useRef<HTMLDivElement>(null);
const hsvRef = useRef(hsv);
const calculateHue = useCallback((clientX: number) => {
const slider = sliderRef.current;
if (!slider) return;
const thumbSize = thumbRef.current?.clientWidth ?? 0;
const rect = slider.getBoundingClientRect();
const maxX = rect.width - thumbSize;
if (maxX <= 0) {
onChange(0, hsv[1], hsv[2]);
return;
}
const x = Math.max(0, Math.min(clientX - rect.left, maxX));
const newHue = Math.round((x / maxX) * 360);
onChange(newHue, hsv[1], hsv[2]);
}, [hsv, onChange]);
const handlePointerMove = useCallback((e: PointerEvent) => {
e.stopPropagation();
if (!isDragging) return;
calculateHue(e.clientX);
}, [isDragging, calculateHue]);
useEffect(() => {
hsvRef.current = hsv;
}, [hsv]);
const handlePointerUp = useCallback(() => {
setIsDragging(false);
const [h, s, v] = hsvRef.current;
onChanged?.(h, s, v);
}, [onChanged]);
useEffect(() => {
if (isDragging) {
window.addEventListener('pointermove', handlePointerMove);
window.addEventListener('pointerup', handlePointerUp);
window.addEventListener('mouseup', handlePointerUp);
}
return () => {
window.removeEventListener('pointermove', handlePointerMove);
window.removeEventListener('pointerup', handlePointerUp);
window.removeEventListener('mouseup', handlePointerUp);
};
}, [isDragging, handlePointerMove, handlePointerUp]);
const getThumbPosition = () => {
const safeHue = Math.min(Math.max(hsv[0], 0), 360);
const thumbSize = thumbRef.current?.clientWidth ?? 0;
const sliderWidth = sliderRef.current?.clientWidth ?? 0;
const thumbOffsetPercent = sliderWidth > 0 ? (thumbSize / sliderWidth) * 100 : 0;
return `${(safeHue / 360) * (100 - thumbOffsetPercent)}%`;
};
return (
<div
data-u-comp="color-picker-hue-slider"
className="univer-relative univer-w-full univer-select-none"
>
<div
ref={sliderRef}
className={`
univer-relative univer-h-2 univer-w-full univer-cursor-pointer univer-rounded-full univer-shadow-inner
`}
style={{
background: `linear-gradient(to right,
hsl(0, 100%, 50%),
hsl(60, 100%, 50%),
hsl(120, 100%, 50%),
hsl(180, 100%, 50%),
hsl(240, 100%, 50%),
hsl(300, 100%, 50%),
hsl(360, 100%, 50%))`,
}}
onPointerDown={(e) => {
setIsDragging(true);
calculateHue(e.clientX);
}}
>
<div
ref={thumbRef}
data-u-comp="color-picker-hue-slider-thumb"
className={`
univer-absolute univer-top-1/2 univer-box-border univer-size-2 univer-rounded-full
univer-bg-transparent univer-shadow-md univer-ring-2 univer-ring-gray-0
univer-transition-transform univer-duration-75 univer-will-change-transform
`}
style={{
left: getThumbPosition(),
transform: 'translateY(-50%)',
transition: isDragging ? 'none' : 'all 0.1s ease-out',
}}
/>
</div>
</div>
);
};

View File

@@ -0,0 +1,168 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { IColorPickerProps } from './ColorPicker';
import { memo, useCallback, useContext, useEffect, useState } from 'react';
import { isBrowser } from '../../helper/is-browser';
import { MobileActionRowGroup } from '../action-row/MobileActionRowGroup';
import { Button } from '../button/Button';
import { ConfigContext } from '../config-provider/ConfigProvider';
import { MobileDialog } from '../dialog/MobileDialog';
import { MobileDropdownCloseContext } from '../dropdown/MobileDropdown';
import { AlphaSlider } from './AlphaSlider';
import { hexToHsv, hsvToHex, hsvToRgb, hsvToRgba, parseRgba, rgbToHex, rgbToHsv } from './color-conversion';
import { ColorInput } from './ColorInput';
import { ColorSpectrum } from './ColorSpectrum';
import { HueSlider } from './HueSlider';
import { MobileColorPresets } from './MobileColorPresets';
const MemoizedColorSpectrum = memo(ColorSpectrum);
const MemoizedHueSlider = memo(HueSlider);
const MemoizedAlphaSlider = memo(AlphaSlider);
const MemoizedColorInput = memo(ColorInput);
const MemoizedMobileColorPresets = memo(MobileColorPresets);
export type IMobileColorPickerProps = IColorPickerProps;
export function MobileColorPicker({ format = 'hex', value, onChange }: IMobileColorPickerProps) {
const { direction, locale } = useContext(ConfigContext);
const closeMobileDropdown = useContext(MobileDropdownCloseContext);
const [hsv, setHsv] = useState<[number, number, number]>([0, 100, 100]);
const [alpha, setAlpha] = useState(1);
const [visible, setVisible] = useState(false);
const getRgb = useCallback((h: number, s: number, v: number) => hsvToRgb(h, s, v), []);
useEffect(() => {
try {
const actualValue = value || (format === 'hex' ? '#000000' : 'rgba(0, 0, 0, 1)');
if (format === 'hex') {
setHsv(hexToHsv(actualValue));
setAlpha(1);
} else {
const [r, g, b, a] = parseRgba(actualValue);
setHsv(rgbToHsv(r, g, b));
setAlpha(a);
}
} catch (error) {
console.error('Invalid value:', error);
}
}, [format, value]);
if (!isBrowser) {
return null;
}
function handleColorChange(h: number, s: number, v: number) {
setHsv([h, s, v]);
}
function handleColorChanged(h: number, s: number, v: number, a: number = alpha) {
if (format === 'hex') {
onChange?.(rgbToHex(...getRgb(h, s, v)));
} else {
const [r, g, b] = getRgb(h, s, v);
onChange?.(`rgba(${r}, ${g}, ${b}, ${a})`);
}
closeMobileDropdown?.();
}
function handleConfirmCustomColor() {
const [h, s, v] = hsv;
setVisible(false);
handleColorChanged(h, s, v, alpha);
}
return (
<div
data-u-comp="color-picker"
data-presentation="mobile"
dir={direction}
className="univer-cursor-default univer-space-y-2 univer-rounded-lg"
onClick={(event) => event.stopPropagation()}
>
<MemoizedMobileColorPresets
value={hsvToHex(...hsv)}
onSelect={(color) => {
const [h, s, v] = hexToHsv(color);
handleColorChange(h, s, v);
setAlpha(1);
handleColorChanged(h, s, v, 1);
}}
/>
<div className="univer-flex univer-h-12 univer-items-center">
<button
type="button"
className="
univer-h-11 univer-w-full univer-cursor-pointer univer-rounded-xl univer-border-0
univer-bg-gray-100 univer-text-sm univer-font-medium univer-text-gray-900
active:univer-bg-gray-200
dark:!univer-bg-gray-800 dark:!univer-text-gray-0
dark:active:!univer-bg-gray-700
"
onClick={() => setVisible(true)}
>
{locale?.ColorPicker.more}
</button>
</div>
<MobileDialog
className="!univer-z-[1420] !univer-p-2.5"
overlayClassName="!univer-z-[1410]"
closable={false}
maskClosable={false}
open={visible}
onOpenChange={setVisible}
>
<div data-u-comp="mobile-color-picker-custom" className="univer-grid univer-w-full univer-gap-2">
<div className="univer-h-44 univer-w-full univer-overflow-hidden univer-rounded-lg">
<MemoizedColorSpectrum hsv={hsv} onChange={handleColorChange} />
</div>
<div className="univer-flex univer-items-center univer-gap-2">
<div
className="univer-size-6 univer-flex-shrink-0 univer-rounded-sm"
style={{ backgroundColor: format === 'hex' ? hsvToHex(...hsv) : hsvToRgba(...hsv, alpha) }}
/>
<div className="univer-flex-1 univer-space-y-2">
<MemoizedHueSlider hsv={hsv} onChange={handleColorChange} />
{format === 'rgba' && (
<MemoizedAlphaSlider hsv={hsv} alpha={alpha} onChange={setAlpha} />
)}
</div>
</div>
<MemoizedColorInput
hsv={hsv}
alpha={alpha}
format={format}
onChange={(h, s, v, a) => {
handleColorChange(h, s, v);
if (a !== undefined) {
setAlpha(a);
}
}}
/>
<MobileActionRowGroup>
<Button onClick={() => setVisible(false)}>{locale?.ColorPicker.cancel}</Button>
<Button variant="primary" onClick={handleConfirmCustomColor}>{locale?.ColorPicker.confirm}</Button>
</MobileActionRowGroup>
</div>
</MobileDialog>
</div>
);
}

View File

@@ -0,0 +1,61 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { clsx } from '../../helper/clsx';
import { colorPresets } from './presets';
export interface IMobileColorPresetsProps {
value?: string;
onSelect: (color: string) => void;
}
export function MobileColorPresets({ value = '', onSelect }: IMobileColorPresetsProps) {
return (
<div
className="univer-grid univer-gap-2"
style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(40px, 1fr))' }}
>
{colorPresets.flat().map((color) => (
<button
key={color}
type="button"
aria-label={color}
aria-pressed={color.toUpperCase() === value.toUpperCase()}
className={clsx(`
univer-box-border univer-flex univer-size-10 univer-cursor-pointer univer-items-center
univer-justify-center univer-justify-self-center univer-rounded-lg univer-border
univer-border-solid univer-border-transparent univer-bg-transparent univer-transition-colors
active:univer-bg-gray-100
dark:active:!univer-bg-gray-700
`, {
'univer-ring-2 univer-ring-primary-600 univer-ring-offset-2 univer-ring-offset-gray-0 dark:!univer-ring-primary-400 dark:!univer-ring-offset-gray-800': color.toUpperCase() === value.toUpperCase(),
})}
onClick={() => onSelect(color)}
>
<span
className={clsx(`
univer-aspect-square univer-w-8 univer-shrink-0 univer-rounded-md univer-border
univer-border-solid univer-border-transparent
`, {
'!univer-border-gray-200 dark:!univer-border-gray-600': color === '#FFFFFF',
})}
style={{ backgroundColor: color }}
/>
</button>
))}
</div>
);
}

View File

@@ -0,0 +1,209 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { fireEvent, render } from '@testing-library/react';
import { useState } from 'react';
import { describe, expect, it, vi } from 'vitest';
import { AlphaSlider } from '../AlphaSlider';
import { ColorInput } from '../ColorInput';
import '@testing-library/jest-dom/vitest';
describe('AlphaSlider', () => {
it('should update alpha during pointer interaction and emit onChanged', () => {
const onChange = vi.fn();
const onChanged = vi.fn();
const { container } = render(
<AlphaSlider hsv={[0, 100, 100]} alpha={0.3} onChange={onChange} onChanged={onChanged} />
);
const slider = container.querySelector('[data-u-comp="color-picker-alpha-slider-track"]') as HTMLDivElement;
const thumb = container.querySelector('[data-u-comp="color-picker-alpha-slider-thumb"]') as HTMLDivElement;
expect(slider).toBeInTheDocument();
expect(thumb).toBeInTheDocument();
Object.defineProperty(thumb, 'clientWidth', { value: 10, configurable: true });
vi.spyOn(slider, 'getBoundingClientRect').mockReturnValue({
x: 0,
y: 0,
top: 0,
left: 0,
width: 100,
height: 8,
right: 100,
bottom: 8,
toJSON: () => ({}),
} as DOMRect);
fireEvent.pointerDown(slider, { clientX: 50 });
fireEvent.pointerMove(window, { clientX: 80 });
fireEvent.pointerUp(window);
expect(onChange).toHaveBeenCalled();
expect(onChanged).toHaveBeenCalledWith(0.3);
});
it('should emit latest alpha on pointer up after prop update', () => {
const onChange = vi.fn();
const onChanged = vi.fn();
function Wrapper() {
const [alpha, setAlpha] = useState(0.3);
return (
<AlphaSlider
hsv={[0, 100, 100]}
alpha={alpha}
onChange={(a) => {
setAlpha(a);
onChange(a);
}}
onChanged={onChanged}
/>
);
}
const { container } = render(<Wrapper />);
const slider = container.querySelector('[data-u-comp="color-picker-alpha-slider-track"]') as HTMLDivElement;
const thumb = container.querySelector('[data-u-comp="color-picker-alpha-slider-thumb"]') as HTMLDivElement;
Object.defineProperty(thumb, 'clientWidth', { value: 10, configurable: true });
vi.spyOn(slider, 'getBoundingClientRect').mockReturnValue({
x: 0,
y: 0,
top: 0,
left: 0,
width: 100,
height: 8,
right: 100,
bottom: 8,
toJSON: () => ({}),
} as DOMRect);
fireEvent.pointerDown(slider, { clientX: 50 });
fireEvent.pointerMove(window, { clientX: 80 });
fireEvent.pointerUp(window);
expect(onChange).toHaveBeenCalled();
expect(onChanged).toHaveBeenCalled();
const lastCall = onChanged.mock.calls[onChanged.mock.calls.length - 1];
expect(lastCall[0]).toBeGreaterThan(0.3);
});
});
describe('ColorInput', () => {
it('should handle hex input validation and reset invalid value on blur', () => {
const onChange = vi.fn();
function Wrapper() {
const [hsv, setHsv] = useState<[number, number, number]>([0, 100, 100]);
return (
<ColorInput
hsv={hsv}
alpha={1}
format="hex"
onChange={(h, s, v) => {
setHsv([h, s, v]);
onChange(h, s, v);
}}
/>
);
}
const { container } = render(<Wrapper />);
const hexInput = container.querySelector('input[maxlength="6"]') as HTMLInputElement;
expect(hexInput).toBeInTheDocument();
fireEvent.change(hexInput, { target: { value: 'GGGGGG' } });
expect(onChange).not.toHaveBeenCalled();
fireEvent.change(hexInput, { target: { value: '00ff00' } });
expect(onChange).toHaveBeenCalled();
fireEvent.change(hexInput, { target: { value: '0' } });
fireEvent.blur(hexInput);
expect(hexInput.value.length).toBe(6);
});
it('should handle rgba channels and alpha bounds', () => {
const onChange = vi.fn();
function Wrapper() {
const [hsv, setHsv] = useState<[number, number, number]>([0, 100, 100]);
const [alpha, setAlpha] = useState(0.5);
return (
<ColorInput
hsv={hsv}
alpha={alpha}
format="rgba"
onChange={(h, s, v, a) => {
setHsv([h, s, v]);
setAlpha(a ?? alpha);
onChange(h, s, v, a);
}}
/>
);
}
const { container } = render(<Wrapper />);
const rgbInputs = Array.from(container.querySelectorAll('input[maxlength="3"]')) as HTMLInputElement[];
const alphaInput = container.querySelector('input[maxlength="4"]') as HTMLInputElement;
fireEvent.change(rgbInputs[0], { target: { value: '255' } });
fireEvent.change(rgbInputs[1], { target: { value: '255' } });
fireEvent.change(rgbInputs[2], { target: { value: '255' } });
expect(onChange).toHaveBeenCalled();
const callCount = onChange.mock.calls.length;
fireEvent.change(alphaInput, { target: { value: '1.5' } });
expect(onChange.mock.calls.length).toBe(callCount);
fireEvent.change(alphaInput, { target: { value: '0.75' } });
expect(onChange).toHaveBeenCalled();
});
it('should guard invalid length and character input branches', () => {
const onChange = vi.fn();
const { container } = render(
<ColorInput hsv={[0, 100, 100]} alpha={0.5} format="rgba" onChange={onChange} />
);
const hexInput = container.querySelector('input[maxlength="6"]') as HTMLInputElement;
const rgbInputs = Array.from(container.querySelectorAll('input[maxlength="3"]')) as HTMLInputElement[];
const alphaInput = container.querySelector('input[maxlength="4"]') as HTMLInputElement;
fireEvent.change(hexInput, { target: { value: '1234567' } });
expect(onChange).not.toHaveBeenCalled();
fireEvent.change(rgbInputs[0], { target: { value: '1a' } });
fireEvent.change(rgbInputs[1], { target: { value: '300' } });
fireEvent.change(alphaInput, { target: { value: 'abc' } });
expect(onChange).not.toHaveBeenCalled();
});
it('should reset rgb local values on blur to current hsv values', () => {
const onChange = vi.fn();
const { container } = render(
<ColorInput hsv={[0, 100, 100]} alpha={0.5} format="rgba" onChange={onChange} />
);
const rInput = container.querySelector('input[maxlength="3"]') as HTMLInputElement;
fireEvent.change(rInput, { target: { value: '1' } });
expect(onChange).toHaveBeenCalled();
fireEvent.blur(rInput);
expect(rInput.value).toBe('255');
});
});

View File

@@ -0,0 +1,62 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { describe, expect, it } from 'vitest';
import { hexToHsv, hsvToHex, hsvToRgb, hsvToRgba, parseRgba, rgbToHex, rgbToHsv } from '../color-conversion';
describe('color-conversion', () => {
it('should convert hsv to rgb across hue segments', () => {
expect(hsvToRgb(0, 100, 100)).toEqual([255, 0, 0]);
expect(hsvToRgb(60, 100, 100)).toEqual([255, 255, 0]);
expect(hsvToRgb(120, 100, 100)).toEqual([0, 255, 0]);
expect(hsvToRgb(180, 100, 100)).toEqual([0, 255, 255]);
expect(hsvToRgb(240, 100, 100)).toEqual([0, 0, 255]);
expect(hsvToRgb(300, 100, 100)).toEqual([255, 0, 255]);
});
it('should normalize hue values >= 360 to equivalent hue in [0, 360)', () => {
expect(hsvToRgb(360, 100, 100)).toEqual([255, 0, 0]);
expect(hsvToRgb(720, 100, 100)).toEqual([255, 0, 0]);
expect(hsvToRgb(420, 100, 100)).toEqual([255, 255, 0]);
});
it('should convert rgb and hsv values', () => {
expect(rgbToHex(255, 0, 16)).toBe('#ff0010');
expect(hsvToHex(0, 100, 100)).toBe('#ff0000');
expect(hsvToRgba(0, 100, 100, 0.5)).toBe('rgba(255, 0, 0, 0.5)');
const fromRed = rgbToHsv(255, 0, 0);
const fromGreen = rgbToHsv(0, 255, 0);
const fromBlue = rgbToHsv(0, 0, 255);
const fromGray = rgbToHsv(128, 128, 128);
expect(Math.round(fromRed[0])).toBe(0);
expect(Math.round(fromGreen[0])).toBe(120);
expect(Math.round(fromBlue[0])).toBe(240);
expect(fromGray[1]).toBe(0);
});
it('should parse hex and rgba strings', () => {
const shortHex = hexToHsv('#f00');
const longHex = hexToHsv('#00ff00');
expect(Math.round(shortHex[0])).toBe(0);
expect(Math.round(longHex[0])).toBe(120);
expect(parseRgba('rgba(255, 128, 0, 0.25)')).toEqual([255, 128, 0, 0.25]);
expect(parseRgba('rgb(10, 20, 30)')).toEqual([10, 20, 30, 1]);
expect(() => parseRgba('not-a-color')).toThrow('Invalid RGBA string');
});
});

View File

@@ -0,0 +1,105 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { cleanup, fireEvent, render } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { ColorPicker } from '../ColorPicker';
import '@testing-library/jest-dom/vitest';
afterEach(() => {
vi.restoreAllMocks();
cleanup();
});
function getMoreColorButton(container: HTMLElement): HTMLButtonElement {
const button = Array.from(
container.querySelectorAll<HTMLButtonElement>('[data-u-comp="color-picker"] button')
).at(-1);
if (!button) throw new Error('More color button was not rendered.');
return button;
}
describe('ColorPicker extra', () => {
it('should handle rgba mode and confirm custom color', () => {
const onChange = vi.fn();
const { container } = render(
<ColorPicker format="rgba" value="rgba(10, 20, 30, 0.5)" onChange={onChange} />
);
const moreButton = getMoreColorButton(container);
fireEvent.click(moreButton);
const buttons = Array.from(document.querySelectorAll('button'));
const confirmBtn = buttons[buttons.length - 1] as HTMLButtonElement;
expect(confirmBtn).toBeTruthy();
fireEvent.click(confirmBtn);
expect(onChange).toHaveBeenCalled();
expect(onChange.mock.calls.some(([value]) => String(value).startsWith('rgba('))).toBe(true);
});
it('should log error when invalid value is provided', () => {
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
render(<ColorPicker format="rgba" value="invalid-rgba" />);
expect(errorSpy).toHaveBeenCalled();
});
it('should stop click propagation on root container', () => {
const parentClick = vi.fn();
const { container } = render(
<div onClick={parentClick}>
<ColorPicker />
</div>
);
const root = container.querySelector('[data-u-comp="color-picker"]') as HTMLElement;
expect(root).toBeTruthy();
fireEvent.click(root);
expect(parentClick).not.toHaveBeenCalled();
});
it('should emit rgba from presets and confirm/cancel inside dialog', () => {
const onChange = vi.fn();
const { container } = render(<ColorPicker format="rgba" value="rgba(0, 0, 0, 1)" onChange={onChange} />);
const presetButton = container.querySelector('[data-u-comp="color-picker-presets"] button') as HTMLButtonElement;
fireEvent.click(presetButton);
expect(onChange.mock.calls.some(([value]) => String(value).startsWith('rgba('))).toBe(true);
const moreButton = getMoreColorButton(container);
fireEvent.click(moreButton);
const alphaInput = document.querySelector('input[maxlength="4"]') as HTMLInputElement;
fireEvent.change(alphaInput, { target: { value: '0.6' } });
const [cancelBtn, confirmBtn] = Array.from(document.querySelectorAll('footer button')) as HTMLButtonElement[];
fireEvent.click(cancelBtn);
fireEvent.click(moreButton);
fireEvent.click(confirmBtn);
expect(onChange).toHaveBeenCalled();
});
it('should confirm custom color in hex mode', () => {
const onChange = vi.fn();
const { container } = render(<ColorPicker format="hex" value="#00ff00" onChange={onChange} />);
const moreButton = getMoreColorButton(container);
fireEvent.click(moreButton);
const confirmBtn = Array.from(document.querySelectorAll('footer button')).at(-1) as HTMLButtonElement;
fireEvent.click(confirmBtn);
expect(onChange.mock.calls.some(([value]) => String(value).startsWith('#'))).toBe(true);
});
});

View File

@@ -0,0 +1,79 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { cleanup, fireEvent, render } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { ColorSpectrum } from '../ColorSpectrum';
import '@testing-library/jest-dom/vitest';
afterEach(() => {
vi.restoreAllMocks();
cleanup();
});
describe('ColorSpectrum extra', () => {
beforeEach(() => {
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockImplementation(() => {
const gradient = { addColorStop: vi.fn() };
return {
createLinearGradient: vi.fn(() => gradient),
fillRect: vi.fn(),
fillStyle: '',
} as unknown as CanvasRenderingContext2D;
});
});
it('should draw gradients and emit pointer-driven hsv changes', () => {
const onChange = vi.fn();
const onChanged = vi.fn();
const { container } = render(<ColorSpectrum hsv={[120, 20, 80]} onChange={onChange} onChanged={onChanged} />);
const canvas = container.querySelector('[data-u-comp="color-picker-spectrum-canvas"]') as HTMLCanvasElement;
const wrapper = container.querySelector('[data-u-comp="color-picker-spectrum"]') as HTMLDivElement;
Object.defineProperty(wrapper, 'clientWidth', { value: 100, configurable: true });
Object.defineProperty(wrapper, 'clientHeight', { value: 100, configurable: true });
canvas.getBoundingClientRect = vi.fn(() => ({
left: 0,
top: 0,
width: 100,
height: 100,
right: 100,
bottom: 100,
x: 0,
y: 0,
toJSON: () => {},
}));
fireEvent.pointerDown(canvas, { clientX: 50, clientY: 25 });
fireEvent.pointerMove(canvas, { clientX: 80, clientY: 40 });
fireEvent.mouseUp(wrapper);
fireEvent.pointerUp(window);
fireEvent.mouseUp(window);
expect(onChange).toHaveBeenCalled();
expect(onChanged).toHaveBeenCalledWith(120, 20, 80);
});
it('positions the initial indicator from hsv without waiting for container measurements', () => {
const { container } = render(<ColorSpectrum hsv={[120, 20, 80]} onChange={vi.fn()} />);
const indicator = container.querySelector('[data-u-comp="color-picker-spectrum"] > div') as HTMLDivElement;
expect(indicator.style.left).toBe('20%');
expect(indicator.style.top).toBe('20%');
expect(indicator.style.transform).toBe('translate(-50%, -50%)');
});
});

View File

@@ -0,0 +1,104 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { cleanup, fireEvent, render } from '@testing-library/react';
import { useState } from 'react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { HueSlider } from '../HueSlider';
import '@testing-library/jest-dom/vitest';
afterEach(() => {
vi.restoreAllMocks();
cleanup();
});
describe('HueSlider extra', () => {
it('should stop propagation, handle drag and emit onChanged', () => {
const onChange = vi.fn();
const onChanged = vi.fn();
const { container } = render(<HueSlider hsv={[120, 60, 70]} onChange={onChange} onChanged={onChanged} />);
const slider = container.querySelector('[data-u-comp="color-picker-hue-slider"] > div') as HTMLDivElement;
const thumb = container.querySelector('[data-u-comp="color-picker-hue-slider-thumb"]') as HTMLDivElement;
Object.defineProperty(thumb, 'clientWidth', { value: 10, configurable: true });
Object.defineProperty((slider.parentElement as HTMLDivElement), 'clientWidth', { value: 100, configurable: true });
slider.getBoundingClientRect = vi.fn(() => ({
left: 0,
top: 0,
width: 100,
height: 8,
right: 100,
bottom: 8,
x: 0,
y: 0,
toJSON: () => {},
}));
fireEvent.pointerMove(window, { clientX: 40 });
fireEvent.pointerDown(slider, { clientX: 40 });
fireEvent.pointerMove(window, { clientX: 70 });
fireEvent.pointerUp(window);
expect(onChange).toHaveBeenCalled();
expect(onChanged).toHaveBeenCalledWith(120, 60, 70);
});
it('should emit latest hsv on pointer up after prop update', () => {
const onChange = vi.fn();
const onChanged = vi.fn();
function Wrapper() {
const [hsv, setHsv] = useState<[number, number, number]>([120, 60, 70]);
return (
<HueSlider
hsv={hsv}
onChange={(h, s, v) => {
setHsv([h, s, v]);
onChange(h, s, v);
}}
onChanged={onChanged}
/>
);
}
const { container } = render(<Wrapper />);
const slider = container.querySelector('[data-u-comp="color-picker-hue-slider"] > div') as HTMLDivElement;
const thumb = container.querySelector('[data-u-comp="color-picker-hue-slider-thumb"]') as HTMLDivElement;
Object.defineProperty(thumb, 'clientWidth', { value: 10, configurable: true });
Object.defineProperty((slider.parentElement as HTMLDivElement), 'clientWidth', { value: 100, configurable: true });
slider.getBoundingClientRect = vi.fn(() => ({
left: 0,
top: 0,
width: 100,
height: 8,
right: 100,
bottom: 8,
x: 0,
y: 0,
toJSON: () => {},
}));
fireEvent.pointerDown(slider, { clientX: 40 });
fireEvent.pointerMove(window, { clientX: 70 });
fireEvent.pointerUp(window);
expect(onChange).toHaveBeenCalled();
expect(onChanged).toHaveBeenCalled();
const lastCall = onChanged.mock.calls[onChanged.mock.calls.length - 1];
expect(lastCall[0]).not.toBe(120);
});
});

View File

@@ -0,0 +1,351 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { ComponentProps } from 'react';
import { cleanup, fireEvent, render } from '@testing-library/react';
import { useState } from 'react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import enUS from '../../../locale/en-US';
import { ConfigProvider } from '../../config-provider/ConfigProvider';
import { MobileDropdown } from '../../dropdown/MobileDropdown';
import { ColorInput } from '../ColorInput';
import { ColorPicker } from '../ColorPicker';
import { ColorPickerPanel } from '../ColorPickerPanel';
import { ColorSpectrum } from '../ColorSpectrum';
import { HueSlider } from '../HueSlider';
import { MobileColorPicker } from '../MobileColorPicker';
import { MobileColorPresets } from '../MobileColorPresets';
import { colorPresets } from '../presets';
import '@testing-library/jest-dom/vitest';
afterEach(cleanup);
function getMoreColorButton(container: HTMLElement): HTMLButtonElement {
const button = Array.from(
container.querySelectorAll<HTMLButtonElement>('[data-u-comp="color-picker"] button')
).at(-1);
if (!button) throw new Error('More color button was not rendered.');
return button;
}
describe('ColorPicker', () => {
it('should not contain duplicate preset colors', () => {
const flattenedPresets = colorPresets.flat().map((color) => color.toUpperCase());
expect(new Set(flattenedPresets).size).toBe(flattenedPresets.length);
});
it('should render correctly', () => {
const { container } = render(<ColorPicker />);
expect(container.querySelector('[data-u-comp="color-picker"]')).toBeTruthy();
expect(container.querySelectorAll('[data-u-comp="color-picker-presets"] button').length).toBeGreaterThan(0);
});
it('should render with value', () => {
const { container } = render(<ColorPicker value="#FF0000" />);
expect(container.querySelector('[data-u-comp="color-picker"]')).toBeTruthy();
expect(container.querySelectorAll('[data-u-comp="color-picker-presets"] button').length).toBeGreaterThan(0);
});
it('should apply rtl direction to the picker content', () => {
const { container } = render(
<ConfigProvider mountContainer={document.body} direction="rtl">
<ColorPicker />
</ConfigProvider>
);
expect(container.querySelector('[data-u-comp="color-picker"]')?.getAttribute('dir')).toBe('rtl');
});
it('should call onChange when color changes', () => {
const handleChange = vi.fn();
const { container } = render(<ColorPicker onChange={handleChange} />);
const presetBtn = container.querySelector('[data-u-comp="color-picker-presets"] button');
if (presetBtn) {
presetBtn.dispatchEvent(new MouseEvent('click', { bubbles: true }));
expect(handleChange).toHaveBeenCalled();
}
});
it('should support format hex', () => {
const { container } = render(<ColorPicker format="hex" value="#00FF00" />);
expect(container.querySelector('[data-u-comp="color-picker"]')).toBeTruthy();
expect(container.querySelectorAll('[data-u-comp="color-picker-presets"] button').length).toBeGreaterThan(0);
});
it('should open dialog when more is clicked', () => {
const { container } = render(
<ConfigProvider locale={enUS.design} mountContainer={document.body}>
<ColorPicker />
</ConfigProvider>
);
fireEvent.click(getMoreColorButton(container));
expect(document.body.innerHTML).toContain('univer-grid univer-w-64 univer-gap-2');
});
it('should place custom color dialog above parent popovers', () => {
const { container } = render(
<ConfigProvider locale={enUS.design} mountContainer={document.body}>
<ColorPicker />
</ConfigProvider>
);
const moreButton = Array.from(container.querySelectorAll('[data-u-comp="color-picker"] button'))
.find((button) => button.textContent === enUS.design.ColorPicker.more);
if (!moreButton) throw new Error('Custom color button was not rendered.');
fireEvent.click(moreButton);
const dialog = document.querySelector('[role="dialog"]');
const overlay = document.querySelector('[data-state="open"].univer-fixed.univer-inset-0');
expect(dialog?.className).toContain('!univer-z-[1090]');
expect(overlay?.className).toContain('!univer-z-[1090]');
});
it('should call onChange when rgb input changes in dialog', () => {
const handleChange = vi.fn();
const { container } = render(<ColorPicker onChange={handleChange} />);
getMoreColorButton(container).dispatchEvent(new MouseEvent('click', { bubbles: true }));
const rgbInputs = Array.from(document.querySelectorAll('input')).filter((input) => input.maxLength === 3);
if (rgbInputs.length === 3) {
rgbInputs[0].value = '1';
rgbInputs[0].dispatchEvent(new Event('input', { bubbles: true }));
rgbInputs[1].value = '2';
rgbInputs[1].dispatchEvent(new Event('input', { bubbles: true }));
rgbInputs[2].value = '3';
rgbInputs[2].dispatchEvent(new Event('input', { bubbles: true }));
const confirmBtn = Array.from(document.querySelectorAll('button')).find((btn) => btn.textContent?.includes('确定') || btn.textContent?.toLowerCase().includes('confirm'));
if (confirmBtn) {
confirmBtn.dispatchEvent(new MouseEvent('click', { bubbles: true }));
expect(handleChange).toHaveBeenCalled();
}
}
});
});
describe('mobile color picker views', () => {
it('renders the touch-first picker explicitly', () => {
const { container } = render(
<ConfigProvider locale={enUS.design} mountContainer={document.body}>
<MobileColorPicker value="#FFFFFF" />
</ConfigProvider>
);
expect(container.querySelector('[data-u-comp="color-picker"][data-presentation="mobile"]')).toBeInTheDocument();
expect(getMoreColorButton(container)).toHaveClass('univer-h-11');
});
it('uses the mobile action layout for custom color actions', () => {
const { container } = render(
<ConfigProvider locale={enUS.design} mountContainer={document.body}>
<MobileColorPicker value="#FFFFFF" />
</ConfigProvider>
);
fireEvent.click(getMoreColorButton(container));
const confirmButton = Array.from(document.querySelectorAll('button'))
.find((button) => button.textContent === enUS.design.ColorPicker.confirm);
expect(confirmButton?.parentElement).toHaveClass('univer-w-full');
});
it('opens custom colors above the containing mobile dropdown', () => {
render(
<ConfigProvider locale={enUS.design} mountContainer={document.body}>
<MobileDropdown open overlay={<MobileColorPicker value="#FFFFFF" />}>
<button type="button">Color</button>
</MobileDropdown>
</ConfigProvider>
);
fireEvent.click(getMoreColorButton(document.body));
const dialogs = Array.from(document.querySelectorAll<HTMLElement>('[role="dialog"]'));
const customColorDialog = dialogs
.find((dialog) => dialog.className.includes('!univer-z-[1420]'));
const customColorOverlay = Array.from(document.querySelectorAll<HTMLElement>('[data-state="open"].univer-fixed.univer-inset-0'))
.find((overlay) => overlay.className.includes('!univer-z-[1410]'));
const customColorContent = customColorDialog?.querySelector<HTMLElement>('[data-u-comp="mobile-color-picker-custom"]');
const spectrum = customColorContent?.querySelector<HTMLElement>('[data-u-comp="color-picker-spectrum"]');
const spectrumContainer = spectrum?.parentElement;
expect(dialogs).toHaveLength(2);
expect(customColorDialog).toBeInTheDocument();
expect(customColorOverlay).toBeInTheDocument();
expect(customColorContent).toHaveClass('univer-w-full');
expect(spectrumContainer).toHaveClass('univer-h-44', 'univer-w-full');
expect(spectrum).toHaveClass('univer-size-full');
});
it('closes the containing mobile dropdown after selecting a preset color', () => {
render(
<ConfigProvider locale={enUS.design} mountContainer={document.body}>
<MobileDropdown overlay={<MobileColorPicker value="#FFFFFF" />}>
<button type="button">Color</button>
</MobileDropdown>
</ConfigProvider>
);
fireEvent.click(document.querySelector('button')!);
fireEvent.click(document.querySelectorAll('[data-u-comp="color-picker"] button')[1]);
expect(document.querySelector('[role="dialog"]')).not.toBeInTheDocument();
});
it('closes both color dialogs after confirming a custom color', () => {
render(
<ConfigProvider locale={enUS.design} mountContainer={document.body}>
<MobileDropdown overlay={<MobileColorPicker value="#FFFFFF" />}>
<button type="button">Color</button>
</MobileDropdown>
</ConfigProvider>
);
fireEvent.click(document.querySelector('button')!);
fireEvent.click(getMoreColorButton(document.body));
fireEvent.click(document.querySelector<HTMLButtonElement>('[data-u-comp="mobile-color-picker-custom"] button:last-child')!);
expect(document.querySelector('[role="dialog"]')).not.toBeInTheDocument();
});
it('keeps the containing mobile dropdown open after cancelling a custom color', () => {
render(
<ConfigProvider locale={enUS.design} mountContainer={document.body}>
<MobileDropdown overlay={<MobileColorPicker value="#FFFFFF" />}>
<button type="button">Color</button>
</MobileDropdown>
</ConfigProvider>
);
fireEvent.click(document.querySelector('button')!);
fireEvent.click(getMoreColorButton(document.body));
fireEvent.click(document.querySelector<HTMLButtonElement>('[data-u-comp="mobile-color-picker-custom"] button')!);
expect(document.querySelectorAll('[role="dialog"]')).toHaveLength(1);
expect(document.querySelector('[data-u-comp="color-picker"]')).toBeInTheDocument();
});
it('renders large preset targets and applies the selected color', () => {
const onSelect = vi.fn();
const { container } = render(<MobileColorPresets value="#FFFFFF" onSelect={onSelect} />);
const buttons = container.querySelectorAll('button');
expect(buttons).toHaveLength(colorPresets.flat().length);
expect(buttons[0]).toHaveAttribute('aria-pressed', 'true');
expect(buttons[0].querySelector('span')).toHaveClass('univer-aspect-square', 'univer-w-8');
fireEvent.click(buttons[1]);
expect(onSelect).toHaveBeenCalledWith(colorPresets.flat()[1]);
});
it('commits a custom color only when the apply button is clicked', () => {
const onConfirm = vi.fn();
const { getByRole } = render(
<ConfigProvider locale={enUS.design} mountContainer={document.body}>
<ColorPickerPanel value="#3F83F8" onConfirm={onConfirm} />
</ConfigProvider>
);
expect(onConfirm).not.toHaveBeenCalled();
fireEvent.click(getByRole('button', { name: enUS.design.ColorPicker.confirm }));
expect(onConfirm).toHaveBeenCalledWith('#3f83f8');
});
});
describe('HueSlider', () => {
const onChange = vi.fn();
function TestComponent(props: { onChange: typeof onChange } & Omit<ComponentProps<typeof HueSlider>, 'hsv' | 'onChange'>) {
const { onChange, ...restProps } = props;
const [hsv, setHsv] = useState<[number, number, number]>([0, 100, 100]);
function handleChange(h: number, s: number, v: number) {
setHsv([h, s, v]);
onChange([h, s, v]);
}
return <HueSlider hsv={hsv} onChange={handleChange} {...restProps} />;
}
it('should call onChange when hue changes by pointer', () => {
const { container } = render(<TestComponent onChange={onChange} />);
const slider = container.querySelector('[data-u-comp="color-picker-hue-slider"] > div');
if (slider) {
slider.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, clientX: 50 }));
expect(onChange).toHaveBeenCalled();
}
});
});
describe('ColorSpectrum', () => {
const onChange = vi.fn();
function TestComponent(props: { onChange: typeof onChange } & Omit<ComponentProps<typeof ColorSpectrum>, 'hsv' | 'onChange'>) {
const { onChange, ...restProps } = props;
const [hsv, setHsv] = useState<[number, number, number]>([0, 100, 100]);
function handleChange(h: number, s: number, v: number) {
setHsv([h, s, v]);
onChange([h, s, v]);
}
return <ColorSpectrum hsv={hsv} onChange={handleChange} {...restProps} />;
}
it('should call onChange when color changes by pointer', () => {
const { container } = render(<TestComponent onChange={onChange} />);
const spectrum = container.querySelector('[data-u-comp="color-picker-spectrum"] > [data-u-comp="color-picker-spectrum-canvas"]');
if (spectrum) {
spectrum.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, clientX: 50 }));
expect(onChange).toHaveBeenCalled();
}
});
});
describe('ColorInput', () => {
const onChange = vi.fn();
function TestComponent(props: { onChange: typeof onChange } & Omit<ComponentProps<typeof ColorInput>, 'hsv' | 'onChange'>) {
const { onChange, ...restProps } = props;
const [value, setValue] = useState<[number, number, number]>([0, 100, 100]);
function handleChange(h: number, s: number, v: number) {
setValue([h, s, v]);
onChange([h, s, v]);
}
return (
<ColorInput hsv={value} onChange={handleChange} {...restProps} />
);
}
it('should call onChange when color input changes', () => {
const { container } = render(<TestComponent onChange={onChange} alpha={1} format="hex" />);
const inputs = container.querySelectorAll('input[type="text"]') as unknown as HTMLInputElement[];
const [rInput, gInput, bInput] = inputs;
if (rInput && gInput && bInput) {
rInput.value = '#FF0000';
gInput.value = '#00FF00';
bInput.value = '#0000FF';
rInput.dispatchEvent(new Event('input', { bubbles: true }));
gInput.dispatchEvent(new Event('input', { bubbles: true }));
bInput.dispatchEvent(new Event('input', { bubbles: true }));
expect(onChange).toHaveBeenCalledWith([0, 100, 100]); // Assuming #FF0000 corresponds to HSV [0, 100, 100]
}
});
});

View File

@@ -0,0 +1,133 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export const hsvToRgb = (h: number, s: number, v: number): [number, number, number] => {
h = h % 360; // Normalize hue to [0, 360)
s = s / 100; // Normalize saturation to [0, 1]
v = v / 100; // Normalize value to [0, 1]
const c = v * s; // Chroma
const x = c * (1 - Math.abs((h / 60) % 2 - 1)); // Intermediate value
const m = v - c; // Match component to shift to [0, 1]
let r = 0;
let g = 0;
let b = 0;
if (h >= 0 && h < 60) {
r = c;
g = x;
b = 0;
} else if (h >= 60 && h < 120) {
r = x;
g = c;
b = 0;
} else if (h >= 120 && h < 180) {
r = 0;
g = c;
b = x;
} else if (h >= 180 && h < 240) {
r = 0;
g = x;
b = c;
} else if (h >= 240 && h < 300) {
r = x;
g = 0;
b = c;
} else if (h >= 300 && h < 360) {
r = c;
g = 0;
b = x;
}
// Convert to [0, 255] range
return [
Math.round((r + m) * 255),
Math.round((g + m) * 255),
Math.round((b + m) * 255),
];
};
export const rgbToHex = (r: number, g: number, b: number): string => {
const toHex = (n: number) => n.toString(16).padStart(2, '0');
return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
};
export const rgbToHsv = (r: number, g: number, b: number): [number, number, number] => {
// Normalize RGB values to [0, 1]
r /= 255;
g /= 255;
b /= 255;
const max = Math.max(r, g, b); // Maximum value among r, g, b
const min = Math.min(r, g, b); // Minimum value among r, g, b
const d = max - min; // Chroma (difference between max and min)
let h = 0; // Hue
let s = 0; // Saturation
const v = max; // Value is the maximum RGB component
// Calculate saturation (s)
if (max !== 0) {
s = d / max;
}
// Calculate hue (h)
if (d !== 0) {
if (max === r) {
h = ((g - b) / d + (g < b ? 6 : 0)); // Red is max
} else if (max === g) {
h = (b - r) / d + 2; // Green is max
} else if (max === b) {
h = (r - g) / d + 4; // Blue is max
}
h *= 60; // Convert to degrees
}
// Return HSV with hue in [0, 360], saturation and value in [0, 100]
return [
h,
s * 100,
v * 100,
];
};
export const hexToHsv = (hex: string): [number, number, number] => {
if (hex.length === 4) hex = `#${hex.slice(1).split('').map((i) => i + i).join('')}`;
const [r, g, b] = hex.match(/\w\w/g)!.map((x) => Number.parseInt(x, 16));
return rgbToHsv(r, g, b);
};
export const hsvToHex = (h: number, s: number, v: number): string => {
const [r, g, b] = hsvToRgb(h, s, v);
return rgbToHex(r, g, b);
};
export const hsvToRgba = (h: number, s: number, v: number, a: number): string => {
const [r, g, b] = hsvToRgb(h, s, v);
return `rgba(${r}, ${g}, ${b}, ${a})`;
};
export const parseRgba = (rgba: string): [number, number, number, number] => {
const match = rgba.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*(\d*\.?\d+))?\)/);
if (!match) throw new Error('Invalid RGBA string');
return [
Number.parseInt(match[1], 10),
Number.parseInt(match[2], 10),
Number.parseInt(match[3], 10),
match[4] ? Number.parseFloat(match[4]) : 1,
];
};

View File

@@ -0,0 +1,23 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export const colorPresets = [
['#FFFFFF', '#E1EFFE', '#FDE8E8', '#FEECDC', '#FFF4B9', '#DEF7EC', '#D5F5F6', '#EDEBFE', '#FCE8F3'],
['#CDD0D8', '#A4CAFE', '#F8B4B4', '#FDBA8C', '#FAC815', '#84E1BC', '#7EDCE2', '#CABFFD', '#F8B4D9'],
['#979DAC', '#3F83F8', '#F05252', '#FF5A1F', '#D49D0F', '#0DA471', '#0694A2', '#9061F9', '#E74694'],
['#414657', '#1A56DB', '#C81E1E', '#B43403', '#9A6D15', '#046C4E', '#036672', '#6C2BD9', '#BF125D'],
['#000000', '#233876', '#771D1D', '#8A2C0D', '#634312', '#014737', '#014451', '#4A1D96', '#751A3D'],
];

View File

@@ -0,0 +1,138 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { ComponentProps } from 'react';
import { SearchIcon } from '@univerjs/icons';
import { Command as CommandPrimitive } from 'cmdk';
import { clsx } from '../../helper/clsx';
export function Command({ className, ...props }: ComponentProps<typeof CommandPrimitive>) {
return (
<CommandPrimitive
data-slot="command"
className={clsx(`
univer-flex univer-h-full univer-w-full univer-flex-col univer-overflow-hidden univer-rounded-md
univer-bg-gray-0 univer-text-gray-900
dark:!univer-bg-gray-700 dark:!univer-text-gray-0
`, className)}
{...props}
/>
);
}
export function CommandInput({ className, ...props }: ComponentProps<typeof CommandPrimitive.Input>) {
return (
<div
data-slot="command-input-wrapper"
className={`
univer-flex univer-h-10 univer-items-center univer-gap-2 univer-border-0 univer-border-b
univer-border-solid univer-border-gray-200 univer-px-3
dark:!univer-border-gray-600
[&>svg]:univer-size-4 [&>svg]:univer-shrink-0 [&>svg]:univer-text-gray-400
`}
>
<SearchIcon />
<CommandPrimitive.Input
data-slot="command-input"
className={clsx(`
univer-h-10 univer-w-full univer-border-none univer-bg-transparent univer-text-sm univer-outline-none
placeholder:univer-text-gray-400
`, className)}
{...props}
/>
</div>
);
}
export function CommandList({ className, ...props }: ComponentProps<typeof CommandPrimitive.List>) {
return (
<CommandPrimitive.List
data-slot="command-list"
className={clsx(`
univer-max-h-[300px] univer-overflow-y-auto univer-overflow-x-hidden univer-p-1 [scroll-padding-block:4px]
`, className)}
{...props}
/>
);
}
export function CommandEmpty({ className, ...props }: ComponentProps<typeof CommandPrimitive.Empty>) {
return (
<CommandPrimitive.Empty
data-slot="command-empty"
className={clsx('univer-py-6 univer-text-center univer-text-sm univer-text-gray-500', className)}
{...props}
/>
);
}
export function CommandGroup({ className, ...props }: ComponentProps<typeof CommandPrimitive.Group>) {
return (
<CommandPrimitive.Group
data-slot="command-group"
className={clsx(`
univer-overflow-hidden univer-p-1
[&_[cmdk-group-heading]]:univer-px-2 [&_[cmdk-group-heading]]:univer-py-1.5
[&_[cmdk-group-heading]]:univer-text-xs [&_[cmdk-group-heading]]:univer-font-medium
[&_[cmdk-group-heading]]:univer-text-gray-500
`, className)}
{...props}
/>
);
}
export function CommandItem({ className, ...props }: ComponentProps<typeof CommandPrimitive.Item>) {
return (
<CommandPrimitive.Item
data-slot="command-item"
className={clsx(`
univer-relative univer-flex univer-cursor-default univer-select-none univer-items-center univer-gap-2
univer-rounded univer-px-2 univer-py-1.5 univer-text-sm univer-outline-none
data-[disabled=true]:univer-pointer-events-none
data-[selected=true]:univer-bg-gray-100
data-[disabled=true]:univer-opacity-50
dark:data-[selected=true]:!univer-bg-gray-600
`, className)}
{...props}
/>
);
}
export function CommandSeparator({ className, ...props }: ComponentProps<typeof CommandPrimitive.Separator>) {
return (
<CommandPrimitive.Separator
data-slot="command-separator"
className={clsx(`
univer-my-1 univer-h-px univer-bg-gray-200
dark:!univer-bg-gray-600
`, className)}
{...props}
/>
);
}
export function CommandShortcut({ className, ...props }: ComponentProps<'span'>) {
return (
<span
data-slot="command-shortcut"
className={clsx(`
univer-ml-auto univer-text-xs univer-tracking-widest univer-text-gray-400
rtl:univer-ml-0 rtl:univer-mr-auto
`, className)}
{...props}
/>
);
}

View File

@@ -0,0 +1,48 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '../Command';
afterEach(cleanup);
describe('Command', () => {
it('renders grouped items and selects an enabled result', () => {
const onSelect = vi.fn();
render(
<Command>
<CommandInput aria-label="Search" />
<CommandList>
<CommandEmpty>None</CommandEmpty>
<CommandGroup heading="Actions">
<CommandItem value="run" onSelect={onSelect}>Run</CommandItem>
</CommandGroup>
</CommandList>
</Command>
);
fireEvent.click(screen.getByText('Run'));
expect(onSelect).toHaveBeenCalledWith('run');
});
});

View File

@@ -0,0 +1,58 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { Meta } from '@storybook/react';
import { useState } from 'react';
import enUS from '../../locale/en-US';
import zhCN from '../../locale/zh-CN';
import { Button } from '../button/Button';
import { Confirm } from '../confirm/Confirm';
import { ConfigProvider } from './ConfigProvider';
const meta: Meta<typeof ConfigProvider> = {
title: 'Components / ConfigProvider',
component: ConfigProvider,
parameters: {
layout: 'centered',
},
tags: ['autodocs'],
};
export default meta;
export const Playground = {
render() {
const [visible, setVisible] = useState(false);
const [locale, setLocale] = useState(enUS);
function handleChangeLocale(locale: typeof enUS) {
setLocale(locale);
setVisible(true);
}
return (
<>
<Button onClick={() => handleChangeLocale(enUS)}>enUS</Button>
<Button onClick={() => handleChangeLocale(zhCN)}>zhCN</Button>
<ConfigProvider locale={locale?.design} mountContainer={document.body}>
<Confirm visible={visible} onClose={() => setVisible(false)}>
xx
</Confirm>
</ConfigProvider>
</>
);
},
};

View File

@@ -0,0 +1,51 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { ReactNode } from 'react';
import { DirectionProvider } from '@radix-ui/react-direction';
import { createContext, useMemo } from 'react';
import { isBrowser } from '../../helper/is-browser';
export interface IConfigProviderProps {
children: ReactNode;
locale?: any;
direction?: 'ltr' | 'rtl';
mountContainer: HTMLElement | null;
}
export const ConfigContext = createContext<Omit<IConfigProviderProps, 'children'>>({
mountContainer: isBrowser() ? document.body : null,
});
export function ConfigProvider(props: IConfigProviderProps) {
const { children, locale, mountContainer, direction } = props;
const value = useMemo(() => {
return {
locale,
direction,
mountContainer,
};
}, [locale, direction, mountContainer]);
return (
<ConfigContext.Provider value={value}>
<DirectionProvider dir={direction ?? 'ltr'}>
{children}
</DirectionProvider>
</ConfigContext.Provider>
);
}

View File

@@ -0,0 +1,76 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type enUS from '../../../locale/en-US';
import { cleanup, render } from '@testing-library/react';
import { useContext } from 'react';
import { afterEach, describe, expect, it } from 'vitest';
import zhCN from '../../../locale/zh-CN';
import { ConfigContext, ConfigProvider } from '../ConfigProvider';
afterEach(cleanup);
describe('ConfigProvider', () => {
it('should render correctly', () => {
let _mountContainer: HTMLElement | null = null;
let _locale: typeof enUS['design'] | undefined;
function Empty() {
const { locale, mountContainer } = useContext(ConfigContext);
_locale = locale;
_mountContainer = mountContainer;
return null;
}
const root = render(
<ConfigProvider locale={zhCN?.design} mountContainer={document.body}>
<Empty />
</ConfigProvider>
);
expect(_locale).equals(zhCN.design);
expect(_mountContainer).equals(document.body);
root.unmount();
});
it('should render correctly when mountContainer is not document.body', () => {
const mountContainer = document.createElement('div');
document.body.appendChild(mountContainer);
let _mountContainer: HTMLElement | null = null;
function Empty() {
const { mountContainer } = useContext(ConfigContext);
_mountContainer = mountContainer;
return null;
}
const root = render(
<ConfigProvider locale={zhCN?.design} mountContainer={mountContainer}>
<Empty />
</ConfigProvider>
);
expect(_mountContainer).equals(mountContainer);
root.unmount();
});
});

View File

@@ -0,0 +1,55 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { Meta } from '@storybook/react';
import { useState } from 'react';
import { Button } from '../button/Button';
import { Confirm } from './Confirm';
const meta: Meta<typeof Confirm> = {
title: 'Components / Confirm',
component: Confirm,
parameters: {
layout: 'centered',
},
tags: ['autodocs'],
};
export default meta;
export const ConfirmBasic = {
render() {
const [visible, setVisible] = useState(false);
function handleToggleVisible() {
setVisible(!visible);
}
return (
<>
<Button onClick={handleToggleVisible}>open confirm</Button>
<Confirm
visible={visible}
title="hello world"
onClose={handleToggleVisible}
onConfirm={handleToggleVisible}
>
xxxx
</Confirm>
</>
);
},
};

View File

@@ -0,0 +1,110 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { ReactNode } from 'react';
import type enUS from '../../locale/en-US';
import { useContext } from 'react';
import { Button } from '../button/Button';
import { ConfigContext } from '../config-provider/ConfigProvider';
import { Dialog } from '../dialog/Dialog';
export interface IConfirmProps {
children: ReactNode;
/**
* Whether the Confirm is visible.
* @default false
*/
visible?: boolean;
/**
* The title of the Confirm.
*/
title?: React.ReactNode;
/**
* The text of the Confirm's confirm button.
*/
cancelText?: string;
/**
* The text of the Confirm's cancel button.
*/
confirmText?: string;
/**
* Callback when the Confirm is closed.
*/
onClose?: () => void;
/**
* Callback when the Confirm is confirmed.
*/
onConfirm?: () => void;
/**
* The width of the Confirm.
*/
width?: number | string;
/**
* Whether to show the close button.
* @default true
*/
closable?: boolean;
}
function Footer(props: { locale: typeof enUS['design']; cancelText?: string; confirmText?: string; onClose: (() => void) | undefined; onConfirm: (() => void) | undefined; closable?: boolean }) {
const { locale, cancelText, confirmText, onClose, onConfirm, closable = true } = props;
return (
<footer className="univer-flex univer-items-center univer-justify-end univer-gap-2">
{closable && (<Button onClick={onClose}>{cancelText ?? locale?.Confirm.cancel}</Button>)}
<Button variant="primary" onClick={onConfirm}>
{confirmText ?? locale?.Confirm.confirm}
</Button>
</footer>
);
}
export function Confirm(props: IConfirmProps) {
const { children, visible = false, title, cancelText, confirmText, width, onClose, onConfirm, closable = true } = props;
const { locale } = useContext(ConfigContext);
return (
<Dialog
open={visible}
title={title}
maskClosable={false}
footer={(
<Footer
locale={locale!}
cancelText={cancelText}
confirmText={confirmText}
onClose={onClose}
onConfirm={onConfirm}
closable={closable}
/>
)}
onClose={onClose}
width={width}
closable={closable}
>
{children}
</Dialog>
);
}

View File

@@ -0,0 +1,58 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { IConfirmProps } from './Confirm';
import { useContext } from 'react';
import { MobileActionRowGroup } from '../action-row/MobileActionRowGroup';
import { Button } from '../button/Button';
import { ConfigContext } from '../config-provider/ConfigProvider';
import { MobileDialog } from '../dialog/MobileDialog';
export type IMobileConfirmProps = IConfirmProps;
export function MobileConfirm(props: IMobileConfirmProps) {
const {
children,
visible = false,
title,
cancelText,
confirmText,
onClose,
onConfirm,
closable = true,
} = props;
const { locale } = useContext(ConfigContext);
return (
<MobileDialog
open={visible}
title={title}
maskClosable={false}
footer={(
<MobileActionRowGroup>
{closable && <Button onClick={onClose}>{cancelText ?? locale?.Confirm.cancel}</Button>}
<Button variant="primary" onClick={onConfirm}>
{confirmText ?? locale?.Confirm.confirm}
</Button>
</MobileActionRowGroup>
)}
onClose={onClose}
closable={closable}
>
{children}
</MobileDialog>
);
}

View File

@@ -0,0 +1,49 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { cleanup, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it } from 'vitest';
import { Confirm } from '../Confirm';
import { MobileConfirm } from '../MobileConfirm';
import '@testing-library/jest-dom/vitest';
afterEach(cleanup);
describe('Confirm', () => {
it('should not render when visible is false', () => {
const { queryByText } = render(<Confirm visible={false} title="T">content</Confirm>);
expect(queryByText('T')).not.toBeInTheDocument();
});
it('should render with title and content', () => {
const { getByText } = render(<Confirm visible title="Test Title">Dialog Content</Confirm>);
expect(getByText('Test Title')).toBeInTheDocument();
expect(getByText('Dialog Content')).toBeInTheDocument();
});
it('should render custom button text', () => {
const { getByText } = render(<Confirm visible cancelText="No" confirmText="Yes">content</Confirm>);
expect(getByText('No')).toBeInTheDocument();
expect(getByText('Yes')).toBeInTheDocument();
});
it('should render the mobile confirmation as a bottom surface', () => {
render(<MobileConfirm visible title="Mobile confirm" confirmText="Confirm">content</MobileConfirm>);
expect(document.querySelector('[role="dialog"]')).toHaveClass('!univer-bottom-0');
expect(screen.getByRole('button', { name: 'Confirm' }).parentElement).toHaveClass('univer-w-full');
});
});

View File

@@ -0,0 +1,40 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { Meta } from '@storybook/react';
import { useState } from 'react';
import { DatePicker } from './DatePicker';
const meta: Meta<typeof DatePicker> = {
title: 'Components / DatePicker',
component: DatePicker,
parameters: {
layout: 'centered',
},
tags: ['autodocs'],
};
export default meta;
export const DatePickerBasic = {
render() {
const [value, setValue] = useState(new Date());
return (
<DatePicker value={value} onValueChange={setValue} />
);
},
};

View File

@@ -0,0 +1,83 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { CalendarIcon } from '@univerjs/icons';
import { useState } from 'react';
import { borderClassName } from '../../helper/class-utilities';
import { clsx } from '../../helper/clsx';
import { formatDateWithPattern } from '../../helper/date';
import { Calendar } from '../calendar/Calendar';
import { Dropdown } from '../dropdown/Dropdown';
interface IDatePickerProps {
/**
* Additional CSS classes for the date picker.
*/
className?: string;
/**
* The value of the date picker.
*/
value?: Date;
/**
* Callback when the value of the date picker changes.
*/
onValueChange?: (date: Date) => void;
}
export function DatePicker(props: IDatePickerProps) {
const { value, onValueChange, className } = props;
const [open, setOpen] = useState(false);
function handleValueChange(date: Date) {
onValueChange?.(date);
setOpen(false);
}
return (
<Dropdown
align="start"
overlay={(
<div className="univer-p-2">
<Calendar value={value} onValueChange={handleValueChange} />
</div>
)}
open={open}
onOpenChange={setOpen}
>
<button
className={clsx(`
univer-flex univer-h-8 univer-items-center univer-justify-between univer-gap-2 univer-rounded-md
univer-bg-transparent univer-px-2 univer-text-sm univer-text-gray-800 univer-transition-all
hover:univer-border-primary-600
dark:!univer-text-gray-0
`, borderClassName, className)}
type="button"
>
{formatDateWithPattern(value, 'YYYY-MM-DD')}
<CalendarIcon
className={`
univer-text-gray-600
dark:!univer-text-gray-400
`}
/>
</button>
</Dropdown>
);
}

View File

@@ -0,0 +1,88 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { DatePicker } from '../DatePicker';
import '@testing-library/jest-dom/vitest';
const { calendarPropsHistory } = vi.hoisted(() => ({
calendarPropsHistory: [] as any[],
}));
vi.mock('../../dropdown/Dropdown', () => ({
Dropdown: (props: any) => (
<div data-testid="mock-dropdown" data-open={String(props.open)}>
<button
type="button"
data-testid="mock-dropdown-toggle"
onClick={() => props.onOpenChange?.(!props.open)}
>
toggle
</button>
<div data-testid="mock-dropdown-overlay">{props.overlay}</div>
{props.children}
</div>
),
}));
vi.mock('../../calendar/Calendar', () => ({
Calendar: (props: any) => {
calendarPropsHistory.push(props);
return (
<button
type="button"
data-testid="mock-calendar-select"
onClick={() => props.onValueChange?.(new Date('2026-01-02T00:00:00.000Z'))}
>
select-date
</button>
);
},
}));
afterEach(() => {
cleanup();
calendarPropsHistory.length = 0;
});
describe('DatePicker', () => {
it('should render value, open dropdown and emit value change', () => {
const onValueChange = vi.fn();
const value = new Date('2026-01-01T00:00:00.000Z');
render(
<DatePicker
value={value}
className="custom-date-picker"
onValueChange={onValueChange}
/>
);
expect(screen.getByText('2026-01-01')).toBeInTheDocument();
expect(calendarPropsHistory[0].value).toEqual(value);
fireEvent.click(screen.getByTestId('mock-dropdown-toggle'));
expect(screen.getByTestId('mock-dropdown')).toHaveAttribute('data-open', 'true');
fireEvent.click(screen.getByTestId('mock-calendar-select'));
expect(onValueChange).toHaveBeenCalledWith(new Date('2026-01-02T00:00:00.000Z'));
expect(screen.getByTestId('mock-dropdown')).toHaveAttribute('data-open', 'false');
const dateButton = screen.getByText('2026-01-01').closest('button') as HTMLButtonElement;
expect(dateButton.className).toContain('custom-date-picker');
});
});

View File

@@ -0,0 +1,41 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { Meta } from '@storybook/react';
import { useState } from 'react';
import { DateRangePicker } from './DateRangePicker';
const meta: Meta<typeof DateRangePicker> = {
title: 'Components / DateRangePicker',
component: DateRangePicker,
parameters: {
layout: 'centered',
},
tags: ['autodocs'],
};
export default meta;
export const DateRangePickerBasic = {
render() {
const [value, setValue] = useState<[Date, Date]>([new Date(), new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)]);
return (
<DateRangePicker value={value} onValueChange={setValue} />
);
},
};

View File

@@ -0,0 +1,107 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { CalendarIcon } from '@univerjs/icons';
import { useState } from 'react';
import { borderClassName } from '../../helper/class-utilities';
import { clsx } from '../../helper/clsx';
import { formatDateWithPattern } from '../../helper/date';
import { Calendar } from '../calendar/Calendar';
import { Dropdown } from '../dropdown/Dropdown';
interface IDateRangePickerProps {
/**
* Additional CSS classes for the date picker.
*/
className?: string;
/**
* The value of the date picker.
*/
value?: [Date, Date];
/**
* Callback when the value of the date picker changes.
*/
onValueChange?: (date: [Date, Date]) => void;
}
export function DateRangePicker(props: IDateRangePickerProps) {
const { value = [new Date(), new Date()], onValueChange, className } = props;
const [open, setOpen] = useState(false);
function handleValueChange(date: [Date, Date]) {
let [start, end] = date;
// Ensure start date is before end date
if (start > end) {
// Swap the dates if they are in the wrong order
[start, end] = [end, start];
}
onValueChange?.([start, end]);
setOpen(false);
}
return (
<Dropdown
align="start"
overlay={(
<div className="univer-grid univer-grid-cols-2 univer-gap-2 univer-p-2">
<Calendar
value={value[0]}
max={value[1]}
onValueChange={(date) => {
handleValueChange([date, value[1]]);
}}
/>
<Calendar
value={value[1]}
min={value[0]}
onValueChange={(date) => {
handleValueChange([value[0], date]);
}}
/>
</div>
)}
open={open}
onOpenChange={setOpen}
>
<button
className={clsx(`
univer-flex univer-h-8 univer-items-center univer-justify-between univer-gap-2 univer-rounded-md
univer-bg-transparent univer-px-2 univer-text-sm univer-text-gray-800 univer-transition-all
hover:univer-border-primary-600
dark:!univer-text-gray-0
`, borderClassName, className)}
type="button"
>
<span className="univer-flex univer-gap-1">
<span>{formatDateWithPattern(value[0], 'YYYY-MM-DD')}</span>
<span>-</span>
<span>{formatDateWithPattern(value[1], 'YYYY-MM-DD')}</span>
</span>
<CalendarIcon
className={`
univer-text-gray-600
dark:!univer-text-gray-400
`}
/>
</button>
</Dropdown>
);
}

View File

@@ -0,0 +1,96 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { DateRangePicker } from '../DateRangePicker';
import '@testing-library/jest-dom/vitest';
const { calendarPropsHistory } = vi.hoisted(() => ({
calendarPropsHistory: [] as any[],
}));
vi.mock('../../dropdown/Dropdown', () => ({
Dropdown: (props: any) => (
<div data-testid="mock-dropdown" data-open={String(props.open)}>
<button
type="button"
data-testid="mock-dropdown-toggle"
onClick={() => props.onOpenChange?.(!props.open)}
>
toggle
</button>
<div data-testid="mock-dropdown-overlay">{props.overlay}</div>
{props.children}
</div>
),
}));
vi.mock('../../calendar/Calendar', () => ({
Calendar: (props: any) => {
calendarPropsHistory.push(props);
const testId = props.max ? 'mock-calendar-start' : 'mock-calendar-end';
const nextDate = props.max
? new Date('2026-02-15T00:00:00.000Z')
: new Date('2026-02-03T00:00:00.000Z');
return (
<button
type="button"
data-testid={testId}
onClick={() => props.onValueChange?.(nextDate)}
>
{testId}
</button>
);
},
}));
afterEach(() => {
cleanup();
calendarPropsHistory.length = 0;
});
describe('DateRangePicker', () => {
it('should render values, pass min/max and normalize start/end order', () => {
const onValueChange = vi.fn();
const value = [
new Date('2026-02-10T00:00:00.000Z'),
new Date('2026-02-01T00:00:00.000Z'),
] as [Date, Date];
render(<DateRangePicker value={value} onValueChange={onValueChange} />);
expect(screen.getByText('2026-02-10')).toBeInTheDocument();
expect(screen.getByText('2026-02-01')).toBeInTheDocument();
expect(calendarPropsHistory[0].value).toEqual(value[0]);
expect(calendarPropsHistory[0].max).toEqual(value[1]);
expect(calendarPropsHistory[1].value).toEqual(value[1]);
expect(calendarPropsHistory[1].min).toEqual(value[0]);
fireEvent.click(screen.getByTestId('mock-dropdown-toggle'));
expect(screen.getByTestId('mock-dropdown')).toHaveAttribute('data-open', 'true');
fireEvent.click(screen.getByTestId('mock-calendar-start'));
expect(onValueChange).toHaveBeenCalledWith([
new Date('2026-02-01T00:00:00.000Z'),
new Date('2026-02-15T00:00:00.000Z'),
]);
expect(screen.getByTestId('mock-dropdown')).toHaveAttribute('data-open', 'false');
});
});

View File

@@ -0,0 +1,17 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { DateRangePicker } from './DateRangePicker';

View File

@@ -0,0 +1,74 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { Meta } from '@storybook/react';
import { useState } from 'react';
import { Button } from '../button/Button';
import { Dialog } from './Dialog';
const meta: Meta<typeof Dialog> = {
title: 'Components / Dialog',
component: Dialog,
parameters: {
layout: 'centered',
},
tags: ['autodocs'],
};
export default meta;
export const DialogBasic = {
render() {
const [visible, setVisible] = useState(false);
return (
<>
<Button onClick={() => setVisible(true)}>open dialog</Button>
<Dialog
open={visible}
title="hello world"
showOk
showCancel
onOpenChange={setVisible}
>
xxxx
</Dialog>
</>
);
},
};
export const DialogDraggable = {
render() {
const [visible, setVisible] = useState(false);
return (
<>
<Button onClick={() => setVisible(true)}>open dialog</Button>
<Dialog
open={visible}
title="hello world"
draggable
preservePositionOnDestroy
defaultPosition={{ x: 100, y: 100 }}
onClose={() => setVisible(false)}
>
xxxx
</Dialog>
</>
);
},
};

View File

@@ -0,0 +1,404 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { CSSProperties, MouseEvent, ReactNode } from 'react';
import { useCallback, useContext, useEffect, useRef, useState } from 'react';
import { clsx } from '../../helper/clsx';
import { Button } from '../button/Button';
import { ConfigContext } from '../config-provider/ConfigProvider';
import {
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
Dialog as DialogProvider,
DialogTitle,
} from './DialogPrimitive';
export interface IDialogProps {
children: ReactNode;
/**
* The style of the dialog.
*/
style?: CSSProperties;
/**
* Whether the dialog is visible.
* @default false
*/
open?: boolean;
/**
* The width of the dialog.
*/
width?: number | string;
/**
* The title of the dialog.
*/
title?: ReactNode;
/**
* Whether the dialog can be dragged. If a dialog is draggable, the backdrop would be hidden and
* the wrapper container would not response to user's mouse events.
*
* @default false
*/
draggable?: boolean;
/**
* The default position of the dialog.
*/
defaultPosition?: { x: number; y: number };
/**
* Whether the dialog should preserve its position on destroy.
* @default false
*/
preservePositionOnDestroy?: boolean;
/**
* The footer of the dialog.
*/
footer?: ReactNode;
/**
* Whether the dialog should show a mask.
*/
mask?: boolean;
/**
* additional className for dialog
*/
className?: string;
/**
* Additional className for the dialog overlay.
*/
overlayClassName?: string;
/**
* whether show close button
*/
closable?: boolean;
/**
* whether click mask to close, default is true
*/
maskClosable?: boolean;
/**
* whether support press esc to close
* @default true
*/
keyboard?: boolean;
/**
* The callback function when the open state changes.
*/
onOpenChange?: (open: boolean) => void;
/**
* The callback function when the dialog is closed.
*/
onClose?: () => void;
showOk?: boolean;
showCancel?: boolean;
onOk?: () => void;
onCancel?: () => void;
}
function useDraggable(
options: {
defaultPosition?: { x: number; y: number };
enabled?: boolean;
} = {}
) {
const getCenteredPosition = useCallback(() => {
const { innerWidth, innerHeight } = window;
const defaultWidth = 0;
const defaultHeight = 0;
return {
x: Math.max(0, (innerWidth - defaultWidth) / 2),
y: Math.max(0, (innerHeight - defaultHeight) / 2),
};
}, []);
const { defaultPosition = getCenteredPosition(), enabled = false } = options;
const [position, setPosition] = useState(defaultPosition);
const [isDragging, setIsDragging] = useState(false);
const elementRef = useRef<HTMLElement | null>(null);
const startPosRef = useRef({ x: 0, y: 0 });
const startClientRef = useRef({ x: 0, y: 0 });
const initializedRef = useRef(false);
useEffect(() => {
if (!elementRef.current || initializedRef.current || options.defaultPosition) {
return;
}
const { width, height } = elementRef.current.getBoundingClientRect();
const { innerWidth, innerHeight } = window;
const centeredX = Math.max(0, (innerWidth - width) / 2);
const centeredY = Math.max(0, (innerHeight - height) / 2);
setPosition({ x: centeredX, y: centeredY });
startPosRef.current = { x: centeredX, y: centeredY };
initializedRef.current = true;
}, [options.defaultPosition]);
const calculateBounds = useCallback((clientX: number, clientY: number) => {
if (!elementRef.current) {
return { x: clientX, y: clientY };
}
const rect = elementRef.current.getBoundingClientRect();
const { clientWidth, clientHeight } = document.documentElement;
let newX = startPosRef.current.x + (clientX - startClientRef.current.x);
let newY = startPosRef.current.y + (clientY - startClientRef.current.y);
if (newX < 0) {
newX = 0;
}
if (newY < 0) {
newY = 0;
}
if (newX + rect.width > clientWidth) {
newX = clientWidth - rect.width;
}
if (newY + rect.height > clientHeight) {
newY = clientHeight - rect.height;
}
return { x: newX, y: newY };
}, []);
const startDrag = useCallback((e: MouseEvent<HTMLElement> | MouseEvent) => {
if (!enabled) {
return;
}
e.preventDefault();
e.stopPropagation();
startPosRef.current = { ...position };
startClientRef.current = { x: e.clientX, y: e.clientY };
setIsDragging(true);
document.body.style.userSelect = 'none';
}, [enabled, position]);
const onDrag = useCallback((e: globalThis.MouseEvent) => {
if (!isDragging) {
return;
}
e.preventDefault();
e.stopPropagation();
const newPosition = calculateBounds(e.clientX, e.clientY);
setPosition(newPosition);
}, [isDragging, calculateBounds]);
const endDrag = useCallback(() => {
setIsDragging(false);
document.body.style.userSelect = '';
}, []);
useEffect(() => {
if (enabled) {
document.addEventListener('mousemove', onDrag);
document.addEventListener('mouseup', endDrag);
return () => {
document.removeEventListener('mousemove', onDrag);
document.removeEventListener('mouseup', endDrag);
};
}
}, [enabled, onDrag, endDrag]);
return {
position,
isDragging,
elementRef,
setElementRef: (el: HTMLElement | null) => {
elementRef.current = el;
if (el && !initializedRef.current && !options.defaultPosition) {
const { width, height } = el.getBoundingClientRect();
const { innerWidth, innerHeight } = window;
const centeredX = Math.max(0, (innerWidth - width) / 2);
const centeredY = Math.max(0, (innerHeight - height) / 2);
setPosition({ x: centeredX, y: centeredY });
startPosRef.current = { x: centeredX, y: centeredY };
initializedRef.current = true;
}
},
handleMouseDown: startDrag,
};
}
export function Dialog(props: IDialogProps) {
const {
className,
overlayClassName,
children,
style,
open = false,
title,
width,
draggable = false,
defaultPosition,
footer: propFooter,
mask = true,
keyboard = true,
closable = true,
maskClosable = true,
showOk,
showCancel,
onOpenChange,
onClose,
onOk,
onCancel,
} = props;
const { locale, mountContainer, direction } = useContext(ConfigContext);
const { position, isDragging, setElementRef, handleMouseDown } = useDraggable({ defaultPosition, enabled: draggable });
const footer = propFooter ?? (showOk || showCancel
? (
<div className="univer-flex univer-justify-end univer-gap-2">
{showCancel && (
<Button onClick={onCancel}>
{locale?.Confirm.cancel}
</Button>
)}
{showOk && (
<Button variant="primary" onClick={onOk}>
{locale?.Confirm.confirm}
</Button>
)}
</div>
)
: null);
const handleContentRef = useCallback((node: HTMLDivElement | null) => {
if (node && draggable) {
setElementRef(node);
}
}, [draggable, setElementRef]);
const handleOpenChange = useCallback((isOpen: boolean) => {
if (!mask && !isOpen) {
return;
}
onOpenChange?.(isOpen);
if (!isOpen) {
onClose?.();
}
}, [onClose, onOpenChange]);
function handleClickClose() {
onOpenChange?.(false);
onClose?.();
}
return (
<DialogProvider
open={open}
onOpenChange={handleOpenChange}
modal={mask !== false}
>
<DialogContent
ref={handleContentRef}
className={clsx(className, {
'!univer-animate-none': draggable,
})}
style={{
...style,
width: width ? (typeof width === 'number' ? `${width}px` : width) : undefined,
maxWidth: width ? 'initial' : undefined,
...(draggable
? {
position: 'absolute',
margin: 0,
left: 0,
top: 0,
transform: `translate(${position.x}px, ${position.y}px)`,
transition: isDragging ? 'none' : undefined,
cursor: isDragging ? 'grabbing' : undefined,
}
: {}),
}}
closable={closable}
mountContainer={mountContainer}
overlayClassName={overlayClassName}
dir={direction}
onClickClose={handleClickClose}
onEscapeKeyDown={(e) => {
if (keyboard) {
handleClickClose();
}
e.preventDefault();
}}
onPointerDownOutside={(e) => {
if (maskClosable) {
handleClickClose();
}
e.preventDefault();
}}
>
<DialogHeader
className={clsx({
'!univer-hidden': !title,
})}
data-drag-handle={draggable ? 'true' : undefined}
style={{
cursor: draggable ? 'grab' : undefined,
userSelect: draggable ? 'none' : undefined,
touchAction: draggable ? 'none' : undefined,
}}
onMouseDown={draggable ? handleMouseDown : undefined}
>
<DialogTitle>{title}</DialogTitle>
<DialogDescription className="univer-hidden" />
</DialogHeader>
{children}
{footer && (
<DialogFooter>
{footer}
</DialogFooter>
)}
</DialogContent>
</DialogProvider>
);
}

View File

@@ -0,0 +1,185 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { ComponentPropsWithoutRef, ElementRef, HTMLAttributes } from 'react';
import { Close, Content, Description, Overlay, Portal, Root, Title, Trigger } from '@radix-ui/react-dialog';
import { CloseIcon } from '@univerjs/icons';
import { forwardRef } from 'react';
import { borderClassName } from '../../helper/class-utilities';
import { clsx } from '../../helper/clsx';
const Dialog = Root;
const DialogTrigger = Trigger;
const DialogPortal = Portal;
const DialogClose = Close;
const DialogOverlay = forwardRef<
ElementRef<typeof Overlay>,
ComponentPropsWithoutRef<typeof Overlay>
>(({ className, ...props }, ref) => (
<Overlay
ref={ref}
className={clsx(
`
data-[state=open]:univer-animate-in data-[state=open]:univer-fade-in-0
data-[state=closed]:univer-animate-out data-[state=closed]:univer-fade-out-0
univer-fixed univer-inset-0 univer-z-[1080] univer-bg-[rgba(0,0,0,0.8)]
`,
className
)}
{...props}
/>
));
DialogOverlay.displayName = Overlay.displayName;
export interface IDialogContentProps {
closable?: boolean;
onClickClose?: () => void;
mountContainer?: HTMLElement | null;
overlayClassName?: string;
}
const DialogContent = forwardRef<
ElementRef<typeof Content>,
ComponentPropsWithoutRef<typeof Content> & IDialogContentProps
>(({ className, children, closable = true, onClickClose, mountContainer, overlayClassName, ...props }, ref) => (
<DialogPortal container={mountContainer ?? undefined}>
<DialogOverlay className={overlayClassName} />
<Content
ref={ref}
className={clsx(
`
data-[state=open]:univer-animate-in data-[state=open]:univer-fade-in-0
data-[state=open]:univer-zoom-in-95 data-[state=open]:univer-slide-in-from-left-1/2
data-[state=open]:univer-slide-in-from-top-[48%]
data-[state=closed]:univer-animate-out data-[state=closed]:univer-fade-out-0
data-[state=closed]:univer-zoom-out-95 data-[state=closed]:univer-slide-out-to-left-1/2
data-[state=closed]:univer-slide-out-to-top-[48%]
univer-fixed univer-left-1/2 univer-top-1/2 univer-z-[1080] univer-box-border univer-grid
univer-w-full univer-max-w-lg -univer-translate-x-1/2 -univer-translate-y-1/2 univer-gap-4
univer-bg-gray-0 univer-p-4 univer-text-gray-500 univer-shadow-md univer-duration-200
sm:!univer-rounded-lg
dark:!univer-bg-gray-700 dark:!univer-text-gray-400
`,
borderClassName,
className
)}
{...props}
>
{children}
{closable && (
<Close
data-slot="close"
className={`
univer-absolute univer-right-4 univer-top-4 univer-size-6 univer-cursor-pointer univer-rounded-sm
univer-border-none univer-bg-transparent univer-p-0 univer-transition-opacity
hover:univer-opacity-100
disabled:univer-pointer-events-none
rtl:univer-left-4 rtl:univer-right-auto
`}
onClick={onClickClose}
>
<CloseIcon className="univer-size-4 univer-text-gray-400" />
<span className="univer-sr-only">Close</span>
</Close>
)}
</Content>
</DialogPortal>
));
DialogContent.displayName = Content.displayName;
const DialogHeader = ({
className,
...props
}: HTMLAttributes<HTMLDivElement>) => (
<div
data-slot="dialog-header"
className={clsx(
`
univer-flex univer-flex-col univer-space-y-1.5 univer-text-center
sm:!univer-text-left
sm:rtl:!univer-text-right
`,
className
)}
{...props}
/>
);
DialogHeader.displayName = 'DialogHeader';
const DialogFooter = ({
className,
...props
}: HTMLAttributes<HTMLDivElement>) => (
<div
data-slot="dialog-footer"
className={clsx(
`
univer-flex univer-flex-col-reverse
sm:!univer-flex-row sm:!univer-justify-end sm:!univer-space-x-2
`,
className
)}
{...props}
/>
);
DialogFooter.displayName = 'DialogFooter';
const DialogTitle = forwardRef<
ElementRef<typeof Title>,
ComponentPropsWithoutRef<typeof Title>
>(({ className, ...props }, ref) => (
<Title
ref={ref}
className={clsx(
`
univer-my-0 univer-text-lg univer-font-semibold univer-leading-none univer-tracking-tight
univer-text-gray-900
dark:!univer-text-gray-0
`,
className
)}
{...props}
/>
));
DialogTitle.displayName = Title.displayName;
const DialogDescription = forwardRef<
ElementRef<typeof Description>,
ComponentPropsWithoutRef<typeof Description>
>(({ className, ...props }, ref) => (
<Description
ref={ref}
className={clsx('univer-text-sm univer-text-gray-500', className)}
{...props}
/>
));
DialogDescription.displayName = Description.displayName;
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
};

View File

@@ -0,0 +1,141 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { IDialogProps } from './Dialog';
import { useContext, useRef } from 'react';
import { clsx } from '../../helper/clsx';
import { Button } from '../button/Button';
import { ConfigContext } from '../config-provider/ConfigProvider';
import {
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
Dialog as DialogProvider,
DialogTitle,
} from './DialogPrimitive';
export type IMobileDialogProps = IDialogProps;
export function MobileDialog(props: IMobileDialogProps) {
const {
className,
overlayClassName,
children,
style,
open = false,
title,
footer: propFooter,
mask = true,
keyboard = true,
closable = true,
maskClosable = true,
showOk,
showCancel,
onOpenChange,
onClose,
onOk,
onCancel,
} = props;
const contentRef = useRef<HTMLDivElement>(null);
const { locale, mountContainer, direction } = useContext(ConfigContext);
const maxHeight = globalThis.CSS?.supports('height', '1dvh') ? '80dvh' : '80vh';
const footer = propFooter ?? (showOk || showCancel
? (
<div className="univer-flex univer-justify-end univer-gap-2">
{showCancel && <Button onClick={onCancel}>{locale?.Confirm.cancel}</Button>}
{showOk && <Button variant="primary" onClick={onOk}>{locale?.Confirm.confirm}</Button>}
</div>
)
: null);
function close() {
onOpenChange?.(false);
onClose?.();
}
function handleOpenChange(isOpen: boolean) {
if (!mask && !isOpen) {
return;
}
onOpenChange?.(isOpen);
if (!isOpen) {
onClose?.();
}
}
return (
<DialogProvider open={open} onOpenChange={handleOpenChange} modal={mask !== false}>
<DialogContent
className={clsx(`
!univer-bottom-0 !univer-left-0 !univer-right-0 !univer-top-auto !univer-flex !univer-max-w-none
!univer-translate-x-0 !univer-translate-y-0 !univer-flex-col !univer-gap-4 !univer-overflow-hidden
!univer-rounded-t-2xl !univer-p-4
[&_[data-slot='dialog-footer']]:!univer-flex-row [&_[data-slot='dialog-footer']]:!univer-gap-3
[&_[data-slot='dialog-footer']_button]:!univer-h-12
[&_[data-slot='dialog-footer']_button]:!univer-flex-1
[&_button[data-slot='close']]:!univer-right-3 [&_button[data-slot='close']]:!univer-top-3
[&_button[data-slot='close']]:!univer-size-10
`, className)}
ref={contentRef}
onOpenAutoFocus={(event) => {
event.preventDefault();
contentRef.current?.focus({ preventScroll: true });
}}
style={{
...style,
position: 'fixed',
insetInline: 0,
top: 'auto',
bottom: 0,
width: '100%',
maxHeight,
maxWidth: 'none',
margin: 0,
transform: 'none',
}}
closable={closable}
mountContainer={mountContainer}
overlayClassName={overlayClassName}
dir={direction}
onEscapeKeyDown={(event) => {
if (keyboard) {
close();
}
event.preventDefault();
}}
onPointerDownOutside={(event) => {
if (maskClosable) {
close();
}
event.preventDefault();
}}
>
<DialogHeader className={title ? 'univer-shrink-0 univer-px-10' : '!univer-hidden'}>
<DialogTitle>{title}</DialogTitle>
<DialogDescription className="univer-hidden" />
</DialogHeader>
<div className="univer-min-h-0 univer-min-w-0 univer-overflow-y-auto">
{children}
</div>
{footer && <DialogFooter className="univer-shrink-0">{footer}</DialogFooter>}
</DialogContent>
</DialogProvider>
);
}

View File

@@ -0,0 +1,39 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { MobileDialog } from '../MobileDialog';
import '@testing-library/jest-dom/vitest';
afterEach(cleanup);
describe('MobileDialog', () => {
it('keeps keyboard focus out of input fields until the user edits', () => {
render(<MobileDialog open title="Print"><input aria-label="Page range" /></MobileDialog>);
expect(screen.getByRole('dialog')).toHaveFocus();
expect(screen.getByRole('textbox')).not.toHaveFocus();
screen.getByRole('textbox').focus();
expect(screen.getByRole('textbox')).toHaveFocus();
});
it('closes once when the close control is pressed', () => {
const onClose = vi.fn();
render(<MobileDialog open title="Print" onClose={onClose}>Preview</MobileDialog>);
fireEvent.click(screen.getByRole('button', { name: 'Close' }));
expect(onClose).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,162 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { cleanup, fireEvent, render } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import enUS from '../../../locale/en-US';
import { ConfigProvider } from '../../config-provider/ConfigProvider';
import { Dialog } from '../Dialog';
import { MobileDialog } from '../MobileDialog';
import '@testing-library/jest-dom/vitest';
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
describe('Dialog', () => {
it('should not render when open is false', () => {
const { queryByText } = render(<Dialog open={false} title="Title">content</Dialog>);
expect(queryByText('Title')).not.toBeInTheDocument();
});
it('should render with title and content', () => {
const { getByText } = render(<Dialog open title="Test Title">Dialog Content</Dialog>);
expect(getByText('Test Title')).toBeInTheDocument();
expect(getByText('Dialog Content')).toBeInTheDocument();
});
it('should render custom footer', () => {
const { getByText } = render(<Dialog open footer={<div>Custom Footer</div>}>content</Dialog>);
expect(getByText('Custom Footer')).toBeInTheDocument();
});
it('should call onOpenChange and onClose when close button clicked', () => {
const onOpenChange = vi.fn();
const onClose = vi.fn();
const { container } = render(<Dialog open title="T" onOpenChange={onOpenChange} onClose={onClose}>content</Dialog>);
const closeBtn = container.querySelector('[data-slot="close"]') as HTMLElement;
if (closeBtn) {
closeBtn.click();
expect(onOpenChange).toHaveBeenCalledWith(false);
expect(onClose).toHaveBeenCalled();
}
});
it('should call onOk and onCancel', () => {
const onOk = vi.fn();
const onCancel = vi.fn();
const { getByText } = render(
<ConfigProvider locale={enUS.design} mountContainer={document.body}>
<Dialog open showOk showCancel onOk={onOk} onCancel={onCancel}>content</Dialog>
</ConfigProvider>
);
getByText(enUS.design.Confirm.confirm).click();
getByText(enUS.design.Confirm.cancel).click();
expect(onOk).toHaveBeenCalled();
expect(onCancel).toHaveBeenCalled();
});
it('should not close when maskClosable is false', () => {
const onOpenChange = vi.fn();
const { container } = render(<Dialog open maskClosable={false} onOpenChange={onOpenChange}>content</Dialog>);
// mock the mask click event
const mask = container.querySelector('.univer-fixed');
if (mask) {
mask.dispatchEvent(new MouseEvent('click', { bubbles: true }));
expect(onOpenChange).not.toHaveBeenCalledWith(false);
}
});
it('should apply width and draggable styles', () => {
const { rerender } = render(
<Dialog open title="drag" width={320} draggable>
content
</Dialog>
);
const content = document.querySelector('[role="dialog"]') as HTMLElement;
expect(content.style.width).toBe('320px');
expect(content.style.transform).toContain('translate(');
const header = document.querySelector('[data-drag-handle="true"]') as HTMLElement;
expect(header).toBeInTheDocument();
fireEvent.mouseDown(header, { clientX: 20, clientY: 20 });
fireEvent.mouseMove(document, { clientX: 40, clientY: 50 });
fireEvent.mouseUp(document);
rerender(
<Dialog open title="drag" width="40rem" draggable>
content
</Dialog>
);
expect((document.querySelector('[role="dialog"]') as HTMLElement).style.width).toBe('40rem');
});
it('should honor keyboard=false and trigger open change from close button', () => {
const onOpenChange = vi.fn();
const onClose = vi.fn();
render(
<Dialog open keyboard={false} onOpenChange={onOpenChange} onClose={onClose}>
content
</Dialog>
);
fireEvent.keyDown(document, { key: 'Escape' });
const closeBtn = (document.querySelector('.univer-sr-only') as HTMLElement | null)?.parentElement as HTMLElement | null;
expect(closeBtn).toBeInTheDocument();
if (!closeBtn) {
throw new Error('Close button should exist');
}
closeBtn.click();
expect(onOpenChange).toHaveBeenCalledWith(false);
expect(onClose).toHaveBeenCalled();
});
it('should render inside the configured mount container with rtl direction', () => {
const mountContainer = document.createElement('div');
mountContainer.dir = 'rtl';
document.body.appendChild(mountContainer);
render(
<ConfigProvider mountContainer={mountContainer} direction="rtl">
<Dialog open title="RTL Title">content</Dialog>
</ConfigProvider>
);
const dialog = mountContainer.querySelector('[role="dialog"]') as HTMLElement;
expect(dialog).toBeInTheDocument();
expect(dialog.dir).toBe('rtl');
mountContainer.remove();
});
it('should render a touch-first bottom surface from MobileDialog', () => {
vi.stubGlobal('CSS', { supports: () => false });
render(
<ConfigProvider locale={enUS.design} mountContainer={document.body}>
<MobileDialog open title="Mobile title" showOk>content</MobileDialog>
</ConfigProvider>
);
const dialog = document.querySelector('[role="dialog"]') as HTMLElement;
expect(dialog).toHaveClass('!univer-bottom-0');
expect(dialog).toHaveStyle({ bottom: '0px', position: 'fixed', width: '100%' });
expect(dialog.style.maxHeight).toBe('80vh');
expect(dialog.querySelector('[data-slot="dialog-footer"]')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,159 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { Dialog } from '../Dialog';
import '@testing-library/jest-dom/vitest';
vi.mock('../DialogPrimitive', async () => {
const React = await import('react');
const Dialog = ({ children, open, onOpenChange, modal }: any) => (
<div data-testid="dialog-provider" data-open={String(open)} data-modal={String(modal)}>
<button type="button" data-testid="provider-close" onClick={() => onOpenChange?.(false)}>
provider-close
</button>
{children}
</div>
);
const DialogContent = React.forwardRef<HTMLDivElement, any>((props, ref) => {
const { children, onEscapeKeyDown, onPointerDownOutside, onClickClose } = props;
return (
<div
ref={ref}
role="dialog"
tabIndex={0}
onKeyDown={(e) => onEscapeKeyDown?.(e)}
onPointerDown={(e) => onPointerDownOutside?.(e)}
>
<button type="button" data-slot="close" onClick={onClickClose}>
close
</button>
{children}
</div>
);
});
const DialogHeader = ({ children, ...props }: any) => <div {...props}>{children}</div>;
const DialogFooter = ({ children }: any) => <div>{children}</div>;
const DialogTitle = ({ children }: any) => <div>{children}</div>;
const DialogDescription = ({ children }: any) => <div>{children}</div>;
return {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
};
});
afterEach(() => {
vi.restoreAllMocks();
cleanup();
});
describe('Dialog logic branches', () => {
it('should ignore provider close when mask is false', () => {
const onOpenChange = vi.fn();
const onClose = vi.fn();
render(
<Dialog open mask={false} onOpenChange={onOpenChange} onClose={onClose}>
content
</Dialog>
);
fireEvent.click(screen.getByTestId('provider-close'));
expect(onOpenChange).not.toHaveBeenCalled();
expect(onClose).not.toHaveBeenCalled();
});
it('should close on escape and pointer outside when enabled', () => {
const onOpenChange = vi.fn();
const onClose = vi.fn();
render(
<Dialog open keyboard maskClosable onOpenChange={onOpenChange} onClose={onClose}>
content
</Dialog>
);
const dialog = screen.getByRole('dialog');
fireEvent.keyDown(dialog, { key: 'Escape' });
expect(onOpenChange).toHaveBeenCalledWith(false);
expect(onClose).toHaveBeenCalled();
onOpenChange.mockClear();
onClose.mockClear();
fireEvent.pointerDown(dialog);
expect(onOpenChange).toHaveBeenCalledWith(false);
expect(onClose).toHaveBeenCalled();
});
it('should not close from pointer outside when maskClosable is false', () => {
const onOpenChange = vi.fn();
const onClose = vi.fn();
render(
<Dialog open maskClosable={false} onOpenChange={onOpenChange} onClose={onClose}>
content
</Dialog>
);
fireEvent.pointerDown(screen.getByRole('dialog'));
expect(onOpenChange).not.toHaveBeenCalledWith(false);
expect(onClose).not.toHaveBeenCalled();
});
it('should run draggable bounds and drag lifecycle', () => {
Object.defineProperty(document.documentElement, 'clientWidth', { value: 300, configurable: true });
Object.defineProperty(document.documentElement, 'clientHeight', { value: 200, configurable: true });
render(
<Dialog open draggable title="drag-title">
content
</Dialog>
);
const dialog = screen.getByRole('dialog') as HTMLDivElement;
dialog.getBoundingClientRect = vi.fn(() => ({
width: 200,
height: 100,
top: 0,
left: 0,
right: 200,
bottom: 100,
x: 0,
y: 0,
toJSON: () => {},
}));
fireEvent.mouseMove(document, { clientX: 1, clientY: 1 });
const header = screen.getByText('drag-title').parentElement as HTMLElement;
fireEvent.mouseDown(header, { clientX: 10, clientY: 10 });
expect(document.body.style.userSelect).toBe('none');
fireEvent.mouseMove(document, { clientX: -100, clientY: -100 });
fireEvent.mouseMove(document, { clientX: 500, clientY: 500 });
fireEvent.mouseUp(document);
expect(document.body.style.userSelect).toBe('');
});
});

View File

@@ -0,0 +1,53 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { Meta } from '@storybook/react';
import { useState } from 'react';
import { DraggableList } from './DraggableList';
const meta: Meta<typeof DraggableList> = {
title: 'Components / DraggableList',
component: DraggableList,
parameters: {
layout: 'centered',
},
tags: ['autodocs'],
};
export default meta;
export const DraggableListDemo = {
render() {
const [list, setList] = useState([{ title: '1', key: '1' }, { title: '11', key: '2' }]);
return (
<DraggableList
list={list}
onListChange={setList}
idKey="key"
itemRender={(item) => (
<div
style={{ width: 120, border: '1px solid #ccc', borderRadius: 4 }}
>
{item.title}
</div>
)}
rowHeight={32}
margin={[0, 12]}
/>
);
},
};

View File

@@ -0,0 +1,298 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { CSSProperties, HTMLAttributes, PointerEvent, ReactNode } from 'react';
import { useContext, useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { clsx } from '../../helper/clsx';
import { ConfigContext } from '../config-provider/ConfigProvider';
interface IDragPosition {
y: number;
}
interface IDraggableListDragCallbacks {
onDragStart?: (layout: unknown, from: IDragPosition) => void;
onDragStop?: (layout: unknown, from: IDragPosition, to: IDragPosition) => void;
}
export interface IDraggableListProps<T> extends Omit<HTMLAttributes<HTMLDivElement>, 'onDragStart'>, IDraggableListDragCallbacks {
list: T[];
onListChange: (list: T[]) => void;
idKey: keyof T;
itemRender: (item: T, index: number) => ReactNode;
draggableHandle?: string;
rowHeight?: number;
margin?: [number, number];
}
function moveItemByIndex<T>(list: T[], fromIndex: number, toIndex: number) {
if (fromIndex === toIndex || fromIndex < 0 || toIndex < 0 || fromIndex >= list.length || toIndex >= list.length) {
return list;
}
const next = [...list];
const [item] = next.splice(fromIndex, 1);
next.splice(toIndex, 0, item);
return next;
}
export function DraggableList<T = any>(props: IDraggableListProps<T>) {
const {
list,
onListChange,
idKey,
itemRender,
className,
style,
draggableHandle,
rowHeight,
margin = [0, 0],
onDragStart,
onDragStop,
...restProps
} = props;
const [displayList, setDisplayList] = useState(list);
const displayListRef = useRef(list);
const [draggingId, setDraggingId] = useState<string | null>(null);
const [dragOverId, setDragOverId] = useState<string | null>(null);
const [ghostElement, setGhostElement] = useState<HTMLElement | null>(null);
const [ghostPosition, setGhostPosition] = useState<{ x: number; y: number } | null>(null);
const [canUsePortal, setCanUsePortal] = useState(false);
const pointerIdRef = useRef<number | null>(null);
const dragPointerOffsetRef = useRef({ x: 0, y: 0 });
const dragItemSizeRef = useRef({ width: 0, height: 0 });
const ghostContainerRef = useRef<HTMLDivElement | null>(null);
const pressedHandleIdRef = useRef<string | null>(null);
const dragSourceIdRef = useRef<string | null>(null);
const dragStartIndexRef = useRef(-1);
const { direction } = useContext(ConfigContext);
useEffect(() => {
if (!draggingId) {
setDisplayList(list);
displayListRef.current = list;
}
}, [draggingId, list]);
useEffect(() => {
setCanUsePortal(typeof document !== 'undefined');
}, []);
useEffect(() => {
const container = ghostContainerRef.current;
if (!container) {
return;
}
container.replaceChildren();
if (ghostElement) {
container.appendChild(ghostElement);
}
}, [ghostElement]);
useEffect(() => {
if (!draggingId) {
return;
}
const handlePointerMove = (evt: PointerEvent | globalThis.PointerEvent) => {
const sourceId = dragSourceIdRef.current;
if (!sourceId || pointerIdRef.current !== evt.pointerId) {
return;
}
setGhostPosition({
x: evt.clientX - dragPointerOffsetRef.current.x,
y: evt.clientY - dragPointerOffsetRef.current.y,
});
const element = document.elementFromPoint(evt.clientX, evt.clientY);
const itemElement = element?.closest('[data-draggable-list-item-id]') as HTMLElement | null;
const targetId = itemElement?.dataset.draggableListItemId;
if (!targetId || targetId === sourceId) {
return;
}
setDragOverId(targetId);
setDisplayList((prev) => {
const fromIndex = prev.findIndex((it) => getItemId(it) === sourceId);
const toIndex = prev.findIndex((it) => getItemId(it) === targetId);
if (fromIndex < 0 || toIndex < 0 || fromIndex === toIndex) {
return prev;
}
const next = moveItemByIndex(prev, fromIndex, toIndex);
displayListRef.current = next;
return next;
});
};
const handlePointerUp = (evt: globalThis.PointerEvent) => {
if (pointerIdRef.current !== evt.pointerId) {
return;
}
const sourceId = dragSourceIdRef.current;
const startIndex = dragStartIndexRef.current;
const currentList = displayListRef.current;
if (sourceId) {
const finalIndex = currentList.findIndex((it) => getItemId(it) === sourceId);
onDragStop?.(undefined, { y: startIndex }, { y: finalIndex });
if (startIndex >= 0) {
onListChange([...currentList]);
}
}
pointerIdRef.current = null;
pressedHandleIdRef.current = null;
dragSourceIdRef.current = null;
dragStartIndexRef.current = -1;
setDragOverId(null);
setGhostElement(null);
setGhostPosition(null);
setDraggingId(null);
};
window.addEventListener('pointermove', handlePointerMove as any);
window.addEventListener('pointerup', handlePointerUp);
window.addEventListener('pointercancel', handlePointerUp);
return () => {
window.removeEventListener('pointermove', handlePointerMove as any);
window.removeEventListener('pointerup', handlePointerUp);
window.removeEventListener('pointercancel', handlePointerUp);
};
}, [draggingId, onDragStop, onListChange]);
const gapStyle: CSSProperties = useMemo(() => {
const [horizontal, vertical] = margin;
return {
rowGap: `${vertical}px`,
paddingLeft: horizontal ? `${horizontal}px` : undefined,
paddingRight: horizontal ? `${horizontal}px` : undefined,
...style,
};
}, [margin, style]);
const getItemId = (item: T) => String(item[idKey]);
const draggingItem = draggingId ? displayList.find((item) => getItemId(item) === draggingId) : null;
return (
<>
<div
{...restProps}
className={clsx('univer-flex univer-flex-col', draggingId && 'univer-cursor-grabbing univer-select-none', className)}
style={gapStyle}
>
{displayList.map((item, index) => {
const itemId = getItemId(item);
const isDraggingItem = draggingId === itemId;
const isDragOverItem = dragOverId === itemId && !isDraggingItem;
return (
<div
key={itemId}
data-draggable-list-item-id={itemId}
className={clsx(
'univer-relative univer-transition-all univer-duration-150',
isDraggingItem && 'univer-opacity-0',
isDragOverItem && `
univer-bg-primary-50/60
dark:!univer-bg-primary-900/20
univer-rounded univer-border univer-border-primary-200
dark:!univer-border-primary-700
`
)}
onPointerDownCapture={(e: PointerEvent<HTMLDivElement>) => {
if (pointerIdRef.current !== null) {
return;
}
if (!draggableHandle) {
pressedHandleIdRef.current = itemId;
} else {
const target = e.target as HTMLElement;
const isMatched = !!target.closest(draggableHandle);
pressedHandleIdRef.current = isMatched ? itemId : null;
}
if (pressedHandleIdRef.current !== itemId) {
return;
}
const rect = (e.currentTarget as HTMLDivElement).getBoundingClientRect();
dragPointerOffsetRef.current = {
x: e.clientX - rect.left,
y: e.clientY - rect.top,
};
dragItemSizeRef.current = {
width: rect.width,
height: rect.height,
};
setGhostPosition({ x: rect.left, y: rect.top });
setGhostElement((e.currentTarget as HTMLDivElement).cloneNode(true) as HTMLElement);
pointerIdRef.current = e.pointerId;
dragSourceIdRef.current = itemId;
dragStartIndexRef.current = index;
setDraggingId(itemId);
displayListRef.current = displayList;
onDragStart?.(undefined, { y: index });
e.preventDefault();
(e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId);
}}
style={{
...(rowHeight ? { minHeight: `${rowHeight}px` } : undefined),
cursor: draggingId ? 'grabbing' : undefined,
}}
>
{isDraggingItem && (
<div
className="
univer-bg-primary-50/50 univer-absolute univer-inset-0 univer-rounded
univer-border univer-border-dashed univer-border-primary-300
"
/>
)}
{itemRender(item, index)}
</div>
);
})}
</div>
{canUsePortal && draggingItem && ghostPosition && createPortal((
<div
dir={direction}
className={clsx(`
univer-pointer-events-none univer-fixed univer-rounded-md univer-border univer-border-gray-200
univer-bg-gray-0 univer-shadow-lg
dark:!univer-border-gray-700 dark:!univer-bg-gray-800
`)}
style={{
zIndex: 2147483647,
left: `${ghostPosition.x}px`,
top: `${ghostPosition.y}px`,
width: `${dragItemSizeRef.current.width}px`,
height: `${dragItemSizeRef.current.height}px`,
opacity: 0.95,
}}
>
{ghostElement
? <div ref={ghostContainerRef} />
: itemRender(draggingItem, displayList.findIndex((item) => getItemId(item) === getItemId(draggingItem)))}
</div>
), document.body
)}
</>
);
}

View File

@@ -0,0 +1,303 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { DraggableList } from '../DraggableList';
import '@testing-library/jest-dom/vitest';
vi.mock('react-dom', async () => {
const actual = await vi.importActual<typeof import('react-dom')>('react-dom');
return {
...actual,
createPortal: (node: any) => node,
};
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
interface IItem {
id: string;
label: string;
}
const BASE_LIST: IItem[] = [
{ id: 'a', label: 'A' },
{ id: 'b', label: 'B' },
{ id: 'c', label: 'C' },
];
function mockRect(element: HTMLElement) {
vi.spyOn(element, 'getBoundingClientRect').mockReturnValue({
x: 10,
y: 20,
left: 10,
top: 20,
right: 110,
bottom: 56,
width: 100,
height: 36,
toJSON: () => ({}),
} as DOMRect);
}
describe('DraggableList', () => {
it('should reorder list and emit drag callbacks', async () => {
const onListChange = vi.fn();
const onDragStart = vi.fn();
const onDragStop = vi.fn();
const { container } = render(
<DraggableList<IItem>
list={BASE_LIST}
idKey="id"
rowHeight={36}
margin={[4, 6]}
onListChange={onListChange}
onDragStart={onDragStart}
onDragStop={onDragStop}
itemRender={(item) => <div>{item.label}</div>}
/>
);
const listRoot = container.querySelector('.univer-flex-col') as HTMLElement;
expect(listRoot.style.rowGap).toBe('6px');
expect(listRoot.style.paddingLeft).toBe('4px');
expect(listRoot.style.paddingRight).toBe('4px');
const itemA = container.querySelector('[data-draggable-list-item-id="a"]') as HTMLDivElement;
const itemB = container.querySelector('[data-draggable-list-item-id="b"]') as HTMLDivElement;
expect(itemA.style.minHeight).toBe('36px');
mockRect(itemA);
Object.defineProperty(itemA, 'setPointerCapture', {
configurable: true,
value: vi.fn(),
});
const elementFromPointSpy = vi.spyOn(document, 'elementFromPoint').mockReturnValue(itemB);
fireEvent.pointerDown(itemA, {
pointerId: 1,
clientX: 20,
clientY: 24,
});
await waitFor(() => {
expect(onDragStart).toHaveBeenCalledWith(undefined, { y: 0 });
});
expect(container.querySelector('.univer-pointer-events-none')).toBeInTheDocument();
fireEvent.pointerMove(window, {
pointerId: 1,
clientX: 48,
clientY: 50,
});
fireEvent.pointerUp(window, {
pointerId: 1,
clientX: 48,
clientY: 50,
});
await waitFor(() => {
expect(onDragStop).toHaveBeenCalledWith(undefined, { y: 0 }, { y: 1 });
expect(onListChange).toHaveBeenCalledTimes(1);
});
const updatedList = onListChange.mock.calls[0][0] as IItem[];
expect(updatedList.map((item) => item.id)).toEqual(['b', 'a', 'c']);
elementFromPointSpy.mockRestore();
});
it('should only start drag when pointer down on draggable handle', async () => {
const onListChange = vi.fn();
const onDragStart = vi.fn();
const { container } = render(
<DraggableList<IItem>
list={BASE_LIST.slice(0, 2)}
idKey="id"
draggableHandle=".drag-handle"
onListChange={onListChange}
onDragStart={onDragStart}
itemRender={(item) => (
<div>
<span>{item.label}</span>
<button type="button" className="drag-handle">drag</button>
</div>
)}
/>
);
const itemA = container.querySelector('[data-draggable-list-item-id="a"]') as HTMLDivElement;
mockRect(itemA);
Object.defineProperty(itemA, 'setPointerCapture', {
configurable: true,
value: vi.fn(),
});
fireEvent.pointerDown(screen.getByText('A'), {
pointerId: 2,
clientX: 20,
clientY: 24,
});
fireEvent.pointerMove(window, {
pointerId: 2,
clientX: 26,
clientY: 30,
});
fireEvent.pointerUp(window, {
pointerId: 2,
clientX: 26,
clientY: 30,
});
expect(onDragStart).not.toHaveBeenCalled();
expect(onListChange).not.toHaveBeenCalled();
const handleButton = itemA.querySelector('.drag-handle') as HTMLButtonElement;
fireEvent.pointerDown(handleButton, {
pointerId: 3,
clientX: 20,
clientY: 24,
});
await waitFor(() => {
expect(onDragStart).toHaveBeenCalledTimes(1);
});
});
it('should ignore unrelated pointer events while dragging', async () => {
const onListChange = vi.fn();
const { container } = render(
<DraggableList<IItem>
list={BASE_LIST.slice(0, 2)}
idKey="id"
onListChange={onListChange}
itemRender={(item) => <div>{item.label}</div>}
/>
);
const itemA = container.querySelector('[data-draggable-list-item-id="a"]') as HTMLDivElement;
const itemB = container.querySelector('[data-draggable-list-item-id="b"]') as HTMLDivElement;
mockRect(itemA);
Object.defineProperty(itemA, 'setPointerCapture', {
configurable: true,
value: vi.fn(),
});
fireEvent.pointerDown(itemA, {
pointerId: 10,
clientX: 20,
clientY: 24,
});
// pointerId mismatch should be ignored
fireEvent.pointerMove(window, {
pointerId: 999,
clientX: 30,
clientY: 34,
});
// target equals source should be ignored
vi.spyOn(document, 'elementFromPoint').mockReturnValue(itemA);
fireEvent.pointerMove(window, {
pointerId: 10,
clientX: 32,
clientY: 36,
});
// target not in current list should keep previous list
const invalidTarget = document.createElement('div');
invalidTarget.setAttribute('data-draggable-list-item-id', 'missing');
vi.spyOn(document, 'elementFromPoint').mockReturnValue(invalidTarget);
fireEvent.pointerMove(window, {
pointerId: 10,
clientX: 34,
clientY: 38,
});
// A second pointer down during active drag should be ignored.
fireEvent.pointerDown(itemB, {
pointerId: 11,
clientX: 20,
clientY: 24,
});
// pointerup mismatch should not end current dragging
fireEvent.pointerUp(window, {
pointerId: 999,
clientX: 34,
clientY: 38,
});
expect(onListChange).not.toHaveBeenCalled();
fireEvent.pointerUp(window, {
pointerId: 10,
clientX: 34,
clientY: 38,
});
expect(onListChange).toHaveBeenCalledTimes(1);
});
it('should render ghost from cloned item dom', async () => {
const onListChange = vi.fn();
const { container } = render(
<DraggableList<IItem>
list={BASE_LIST.slice(0, 1)}
idKey="id"
onListChange={onListChange}
itemRender={(item) => <div className="drag-content">{item.label}</div>}
/>
);
const itemA = container.querySelector('[data-draggable-list-item-id="a"]') as HTMLDivElement;
mockRect(itemA);
const cloneNodeSpy = vi.spyOn(itemA, 'cloneNode');
Object.defineProperty(itemA, 'setPointerCapture', {
configurable: true,
value: vi.fn(),
});
fireEvent.pointerDown(itemA, {
pointerId: 20,
clientX: 20,
clientY: 24,
});
await waitFor(() => {
expect(cloneNodeSpy).toHaveBeenCalledWith(true);
expect(container.querySelectorAll('.drag-content')).toHaveLength(2);
});
fireEvent.pointerUp(window, {
pointerId: 20,
clientX: 20,
clientY: 24,
});
expect(onListChange).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,18 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { DraggableList } from './DraggableList';
export type { IDraggableListProps } from './DraggableList';

View File

@@ -0,0 +1,227 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { ComponentProps, ReactNode } from 'react';
import { useState } from 'react';
import {
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuPortal,
DropdownMenuPrimitive,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from './DropdownMenuPrimitive';
interface IDropdownMenuNormalItem {
type: 'item';
className?: string;
children: ReactNode;
disabled?: boolean;
variant?: 'default' | 'destructive';
onSelect?: (item: DropdownMenuType) => void;
}
interface IDropdownMenuNormalSubItem {
type: 'subItem';
className?: string;
children: ReactNode;
options?: DropdownMenuType[];
disabled?: boolean;
onSelect?: (item: DropdownMenuType) => void;
}
interface IDropdownMenuSeparatorItem {
type: 'separator';
className?: string;
}
interface IDropdownMenuOption {
label?: ReactNode;
value?: string;
disabled?: boolean;
}
interface IDropdownMenuRadioItem {
type: 'radio';
className?: string;
value: string;
hideIndicator?: boolean;
options: (IDropdownMenuOption | IDropdownMenuSeparatorItem)[];
onSelect?: (item: string) => void;
}
interface IDropdownMenuCheckItem {
type: 'checkbox';
className?: string;
label?: ReactNode;
value: string;
disabled?: boolean;
checked?: boolean;
onSelect?: (item: string) => void;
}
interface IDropdownMenuCustomItem {
type: 'custom';
className?: string;
children: ReactNode;
}
export type DropdownMenuType = IDropdownMenuNormalItem | IDropdownMenuNormalSubItem | IDropdownMenuSeparatorItem | IDropdownMenuRadioItem | IDropdownMenuCheckItem | IDropdownMenuCustomItem;
export interface IDropdownMenuProps extends ComponentProps<typeof DropdownMenuContent> {
children: ReactNode;
items: DropdownMenuType[];
disabled?: boolean;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}
export function DropdownMenu(props: IDropdownMenuProps) {
const {
children,
items,
disabled,
open: controlledOpen,
onOpenChange: controlledOnOpenChange,
...restProps
} = props;
const [uncontrolledOpen, setUncontrolledOpen] = useState(false);
const isControlled = controlledOpen !== undefined;
const open = isControlled ? controlledOpen : uncontrolledOpen;
function handleChangeOpen(newOpen: boolean) {
if (disabled) {
return;
}
if (!isControlled) {
setUncontrolledOpen(newOpen);
}
controlledOnOpenChange?.(newOpen);
}
function renderMenuItem(item: DropdownMenuType, index: number) {
const { className, type } = item;
if (type === 'separator') {
return <DropdownMenuSeparator key={index} className={className} />;
} else if (type === 'custom') {
return (
<div key={index} className={className}>
{item.children}
</div>
);
} else if (type === 'radio') {
return (
<DropdownMenuRadioGroup
key={index}
className={className}
value={item.value}
onValueChange={item.onSelect}
>
{item.options.map((option, index) => {
if ('type' in option) {
if (option.type === 'separator') {
return <DropdownMenuSeparator key={index} className={option.className} />;
}
} else {
if (option.value === undefined) {
throw new Error('[DropdownMenu]: `value` is required');
}
return (
<DropdownMenuRadioItem
key={option.value}
value={option.value}
disabled={option.disabled}
hideIndicator={item.hideIndicator}
>
{option.label}
</DropdownMenuRadioItem>
);
}
return null;
})}
</DropdownMenuRadioGroup>
);
} else if (type === 'checkbox') {
return (
<DropdownMenuCheckboxItem
key={index}
className={className}
disabled={item.disabled}
checked={item.checked}
onSelect={() => {
item.onSelect?.(item.value);
}}
>
{item.label}
</DropdownMenuCheckboxItem>
);
} else if (type === 'item') {
return (
<DropdownMenuItem
key={index}
className={className}
disabled={item.disabled}
variant={item.variant}
onSelect={() => {
item.onSelect?.(item);
}}
>
{item.children}
</DropdownMenuItem>
);
} else if (type === 'subItem') {
return (
<DropdownMenuSub key={index}>
<DropdownMenuSubTrigger>{item.children}</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent sideOffset={12}>
{item.options?.map((subItem, subIndex) => (
renderMenuItem(subItem, subIndex)
))}
</DropdownMenuSubContent>
</DropdownMenuPortal>
</DropdownMenuSub>
);
}
}
return (
<DropdownMenuPrimitive modal={false} open={open} onOpenChange={handleChangeOpen}>
<DropdownMenuTrigger asChild>
{children}
</DropdownMenuTrigger>
<DropdownMenuContent
className="univer-text-sm"
collisionPadding={{ top: 12, bottom: 12 }}
onWheel={(e) => e.stopPropagation()}
{...restProps}
>
{items.map((item, index) => renderMenuItem(item, index))}
</DropdownMenuContent>
</DropdownMenuPrimitive>
);
}

View File

@@ -0,0 +1,375 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { ComponentProps } from 'react';
import {
CheckboxItem,
Content,
Group,
Item,
ItemIndicator,
Label,
Portal,
RadioGroup,
RadioItem,
Root,
Separator,
Sub,
SubContent,
SubTrigger,
Trigger,
} from '@radix-ui/react-dropdown-menu';
import { CheckMarkIcon, MoreRightIcon } from '@univerjs/icons';
import { borderClassName, scrollbarClassName } from '../../helper/class-utilities';
import { clsx } from '../../helper/clsx';
function DropdownMenuPrimitive({
...props
}: ComponentProps<typeof Root>) {
return <Root data-slot="dropdown-menu" {...props} />;
}
function DropdownMenuPortal({
...props
}: ComponentProps<typeof Portal>) {
return (
<Portal data-slot="dropdown-menu-portal" {...props} />
);
}
function DropdownMenuTrigger({
...props
}: ComponentProps<typeof Trigger>) {
return (
<Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
);
}
function DropdownMenuGroup({
...props
}: ComponentProps<typeof Group>) {
return (
<Group data-slot="dropdown-menu-group" {...props} />
);
}
function DropdownMenuSub({
...props
}: ComponentProps<typeof Sub>) {
return <Sub data-slot="dropdown-menu-sub" {...props} />;
}
function DropdownMenuRadioGroup({
...props
}: ComponentProps<typeof RadioGroup>) {
return (
<RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
);
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: ComponentProps<typeof SubTrigger> & {
inset?: boolean;
}) {
return (
<SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={clsx(
`
univer-flex univer-cursor-default univer-select-none univer-items-center univer-justify-between
univer-gap-2 univer-rounded univer-px-2 univer-py-1.5 univer-text-sm univer-outline-none
data-[state=open]:univer-bg-gray-100
focus:univer-bg-gray-100
dark:data-[state=open]:!univer-bg-gray-600
dark:focus:!univer-bg-gray-600
[&_svg]:univer-pointer-events-none [&_svg]:univer-size-4 [&_svg]:univer-shrink-0
`,
className
)}
{...props}
>
{children}
<MoreRightIcon className="ml-auto" />
</SubTrigger>
);
}
function DropdownMenuSubContent({
className,
...props
}: ComponentProps<typeof SubContent>) {
return (
<SubContent
data-slot="dropdown-menu-sub-content"
className={clsx(
`
data-[state=open]:univer-animate-in data-[state=open]:univer-fade-in-0
data-[state=open]:univer-zoom-in-95
data-[state=closed]:univer-animate-out data-[state=closed]:univer-fade-out-0
data-[state=closed]:univer-zoom-out-95
data-[side=bottom]:univer-slide-in-from-top-2
data-[side=left]:univer-slide-in-from-right-2
data-[side=right]:univer-slide-in-from-left-2
data-[side=top]:univer-slide-in-from-bottom-2
univer-z-[1080] univer-box-border univer-max-h-[--radix-popper-available-height]
univer-overflow-y-auto univer-rounded-md univer-bg-gray-0 univer-p-1.5 univer-text-gray-900
univer-shadow-md
dark:!univer-bg-gray-700 dark:!univer-text-gray-0
`,
borderClassName,
scrollbarClassName,
className
)}
{...props}
/>
);
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: ComponentProps<typeof Content>) {
return (
<Portal>
<Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={clsx(
`
data-[state=open]:univer-animate-in data-[state=open]:univer-fade-in-0
data-[state=open]:univer-zoom-in-95
data-[state=closed]:univer-animate-out data-[state=closed]:univer-fade-out-0
data-[state=closed]:univer-zoom-out-95
data-[side=bottom]:univer-slide-in-from-top-2
data-[side=left]:univer-slide-in-from-right-2
data-[side=right]:univer-slide-in-from-left-2
data-[side=top]:univer-slide-in-from-bottom-2
univer-z-[1080] univer-box-border univer-max-h-[--radix-popper-available-height]
univer-overflow-y-auto univer-rounded-md univer-bg-gray-0 univer-p-1.5 univer-text-gray-900
univer-shadow-md
dark:!univer-bg-gray-700 dark:!univer-text-gray-0
`,
borderClassName,
scrollbarClassName,
className
)}
{...props}
/>
</Portal>
);
}
function DropdownMenuItem({
className,
inset,
variant = 'default',
...props
}: ComponentProps<typeof Item> & {
inset?: boolean;
variant?: 'default' | 'destructive';
}) {
return (
<Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={clsx(
`
univer-relative univer-flex univer-cursor-default univer-select-none univer-items-center univer-gap-2
univer-rounded univer-px-2 univer-py-1.5 univer-text-sm univer-outline-none univer-transition-colors
data-[disabled]:univer-pointer-events-none
data-[variant=destructive]:univer-text-red-600
data-[disabled]:univer-opacity-50
focus:univer-bg-gray-100
data-[variant=destructive]:focus:univer-bg-red-50
dark:data-[variant=destructive]:!univer-text-red-400
dark:focus:!univer-bg-gray-600
dark:data-[variant=destructive]:focus:!univer-bg-red-900
[&>svg]:univer-size-4 [&>svg]:univer-shrink-0
`,
className
)}
{...props}
/>
);
}
function DropdownMenuCheckboxItem({
className,
children,
hideIndicator,
checked,
...props
}: ComponentProps<typeof CheckboxItem> & { hideIndicator?: boolean }) {
return (
<CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={clsx(
`
univer-relative univer-flex univer-cursor-default univer-select-none univer-items-center
univer-rounded univer-py-1.5 univer-pr-2 univer-text-sm univer-outline-none univer-transition-colors
data-[disabled]:univer-pointer-events-none data-[disabled]:univer-opacity-50
focus:univer-bg-gray-100
dark:focus:!univer-bg-gray-600
`,
{
'univer-pl-8': !hideIndicator,
'univer-pl-2': hideIndicator,
},
className
)}
checked={checked}
{...props}
>
{!hideIndicator && (
<span
className={`
univer-absolute univer-left-2 univer-flex univer-size-3.5 univer-items-center
univer-justify-center
`}
>
<ItemIndicator>
<CheckMarkIcon
className="univer-block univer-size-4 univer-fill-current univer-text-primary-600"
/>
</ItemIndicator>
</span>
)}
{children}
</CheckboxItem>
);
}
function DropdownMenuRadioItem({
className,
children,
hideIndicator,
...props
}: ComponentProps<typeof RadioItem> & { hideIndicator?: boolean }) {
return (
<RadioItem
data-slot="dropdown-menu-radio-item"
className={clsx(
`
univer-relative univer-flex univer-cursor-default univer-select-none univer-items-center
univer-rounded univer-py-1.5 univer-pr-2 univer-text-sm univer-outline-none univer-transition-colors
data-[disabled]:univer-pointer-events-none data-[disabled]:univer-opacity-50
focus:univer-bg-gray-100
dark:focus:!univer-bg-gray-600
`,
{
'univer-pl-8': !hideIndicator,
'univer-pl-2': hideIndicator,
},
className
)}
{...props}
>
{!hideIndicator && (
<span
className={`
univer-absolute univer-left-2 univer-flex univer-size-3.5 univer-items-center
univer-justify-center
`}
>
<ItemIndicator>
<CheckMarkIcon
className="univer-block univer-size-4 univer-fill-current univer-text-primary-600"
/>
</ItemIndicator>
</span>
)}
{children}
</RadioItem>
);
}
function DropdownMenuLabel({
className,
inset,
...props
}: ComponentProps<typeof Label> & {
inset?: boolean;
}) {
return (
<Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={clsx(
'univer-px-2 univer-py-1.5 univer-text-sm univer-font-semibold',
className
)}
{...props}
/>
);
}
function DropdownMenuSeparator({
className,
...props
}: ComponentProps<typeof Separator>) {
return (
<Separator
className={clsx(`
-univer-mx-1 univer-my-1 univer-h-px univer-bg-gray-200
dark:!univer-bg-gray-600
`, className)}
{...props}
/>
);
}
function DropdownMenuShortcut({
className,
...props
}: ComponentProps<'span'>) {
return (
<span
className={clsx('univer-ml-auto univer-text-sm univer-tracking-widest univer-opacity-60', className)}
{...props}
/>
);
};
export {
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuPortal,
DropdownMenuPrimitive,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
};

View File

@@ -0,0 +1,158 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { ReactNode } from 'react';
import type { DropdownMenuType, IDropdownMenuProps } from './DropdownMenu';
import { useState } from 'react';
import { clsx } from '../../helper/clsx';
import { MobileDropdownSurface } from '../dropdown/MobileDropdownSurface';
export type IMobileDropdownMenuProps = IDropdownMenuProps;
export function MobileDropdownMenu(props: IMobileDropdownMenuProps) {
const { children, items, disabled, open: controlledOpen, onOpenChange: controlledOnOpenChange } = props;
const [uncontrolledOpen, setUncontrolledOpen] = useState(false);
const isControlled = controlledOpen !== undefined;
const open = isControlled ? controlledOpen : uncontrolledOpen;
function handleChangeOpen(newOpen: boolean) {
if (disabled) {
return;
}
if (!isControlled) {
setUncontrolledOpen(newOpen);
}
controlledOnOpenChange?.(newOpen);
}
function renderMenuItem(item: DropdownMenuType, index: number): ReactNode {
if (item.type === 'separator') {
return (
<div
key={index}
className="
univer-my-1 univer-h-px univer-bg-gray-200
dark:!univer-bg-gray-700
"
/>
);
}
if (item.type === 'custom') {
return <div key={index} className={item.className}>{item.children}</div>;
}
if (item.type === 'radio') {
return item.options.map((option, optionIndex) => {
if ('type' in option) {
return renderMenuItem(option, optionIndex);
}
const { value } = option;
if (value === undefined) {
throw new Error('[DropdownMenu]: `value` is required');
}
return (
<button
key={value}
type="button"
disabled={option.disabled}
className={mobileRowClassName(value === item.value)}
onClick={() => {
item.onSelect?.(value);
handleChangeOpen(false);
}}
>
{option.label}
</button>
);
});
}
if (item.type === 'checkbox') {
return (
<button
key={index}
type="button"
disabled={item.disabled}
className={clsx(mobileRowClassName(Boolean(item.checked)), item.className)}
onClick={() => {
item.onSelect?.(item.value);
handleChangeOpen(false);
}}
>
{item.label}
</button>
);
}
if (item.type === 'item') {
return (
<button
key={index}
type="button"
disabled={item.disabled}
className={clsx(mobileRowClassName(false), item.className, {
'!univer-text-red-600 dark:!univer-text-red-400': item.variant === 'destructive',
})}
onClick={() => {
item.onSelect?.(item);
handleChangeOpen(false);
}}
>
{item.children}
</button>
);
}
return (
<div key={index} className="univer-flex univer-flex-col univer-gap-2">
<div className="univer-px-4 univer-py-2 univer-text-sm univer-font-medium univer-text-gray-500">
{item.children}
</div>
{item.options?.map(renderMenuItem)}
</div>
);
}
return (
<MobileDropdownSurface
open={open}
disabled={disabled}
onOpenChange={handleChangeOpen}
content={<div className="univer-flex univer-flex-col univer-gap-2">{items.map(renderMenuItem)}</div>}
>
{children}
</MobileDropdownSurface>
);
}
function mobileRowClassName(active: boolean): string {
return clsx(`
univer-flex univer-h-12 univer-w-full univer-items-center univer-rounded-xl univer-border-0 univer-px-4
univer-text-left univer-text-base univer-text-gray-900 univer-outline-none
active:univer-bg-gray-200
disabled:univer-opacity-40
dark:!univer-text-gray-0
dark:active:!univer-bg-gray-700
`, active
? `
univer-bg-primary-50
dark:!univer-bg-gray-700
`
: `
univer-bg-gray-0
dark:!univer-bg-gray-800
`);
}

View File

@@ -0,0 +1,211 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { cleanup, fireEvent, render } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { DropdownMenu } from '../DropdownMenu';
import { MobileDropdownMenu } from '../MobileDropdownMenu';
import '@testing-library/jest-dom/vitest';
afterEach(cleanup);
describe('DropdownMenu', () => {
it('should render with normal items', () => {
const items = [
{ type: 'item' as const, children: 'Item 1' },
{ type: 'item' as const, children: 'Item 2', disabled: true, variant: 'destructive' as const },
];
const { container } = render(
<DropdownMenu open items={items}>
<button type="button">Trigger</button>
</DropdownMenu>
);
const trigger = container.querySelector('button');
expect(trigger).toBeTruthy();
expect(trigger).toHaveAttribute('aria-haspopup', 'menu');
expect(trigger).toHaveAttribute('type', 'button');
expect(document.querySelector('[data-variant="destructive"]')).toHaveTextContent('Item 2');
});
it('should render with separator', () => {
const items = [
{ type: 'item' as const, children: 'Item 1' },
{ type: 'separator' as const },
{ type: 'item' as const, children: 'Item 2' },
];
const { container } = render(
<DropdownMenu items={items}>
<button type="button">Trigger</button>
</DropdownMenu>
);
const trigger = container.querySelector('button');
expect(trigger).toBeTruthy();
expect(trigger).toHaveAttribute('aria-haspopup', 'menu');
});
it('should render with subItem', () => {
const items = [
{
type: 'subItem' as const,
children: 'More',
options: [
{ type: 'item' as const, children: 'Sub 1' },
{ type: 'item' as const, children: 'Sub 2' },
],
},
];
const { container } = render(
<DropdownMenu items={items}>
<button type="button">Trigger</button>
</DropdownMenu>
);
const trigger = container.querySelector('button');
expect(trigger).toBeTruthy();
expect(trigger).toHaveAttribute('aria-haspopup', 'menu');
});
it('should render with radio group', () => {
const items = [
{
type: 'radio' as const,
value: 'a',
options: [
{ label: 'A', value: 'a' },
{ label: 'B', value: 'b' },
],
},
];
const { container } = render(
<DropdownMenu items={items}>
<button type="button">Trigger</button>
</DropdownMenu>
);
const trigger = container.querySelector('button');
expect(trigger).toBeTruthy();
expect(trigger).toHaveAttribute('aria-haspopup', 'menu');
});
it('should render with checkbox', () => {
const items = [
{
type: 'checkbox' as const,
value: 'c1',
label: 'Check 1',
checked: true,
},
{
type: 'checkbox' as const,
value: 'c2',
label: 'Check 2',
checked: false,
},
];
const { container } = render(
<DropdownMenu items={items}>
<button type="button">Trigger</button>
</DropdownMenu>
);
const trigger = container.querySelector('button');
expect(trigger).toBeTruthy();
expect(trigger).toHaveAttribute('aria-haspopup', 'menu');
});
it('should render custom content without wrapping it as a menu item', () => {
const items = [
{
type: 'custom' as const,
children: <input aria-label="Insert count" defaultValue="1" />,
},
];
const { getByLabelText } = render(
<DropdownMenu open items={items}>
<button type="button">Trigger</button>
</DropdownMenu>
);
const input = getByLabelText('Insert count');
expect(input).toBeInTheDocument();
expect(input.closest('[role="menuitem"]')).toBeNull();
});
it('should invoke onSelect callbacks for item/checkbox/radio', () => {
const onItemSelect = vi.fn();
const onCheckboxSelect = vi.fn();
const onRadioSelect = vi.fn();
const items = [
{ type: 'item' as const, children: 'Run', onSelect: onItemSelect },
{ type: 'checkbox' as const, value: 'c1', label: 'Check', checked: false, onSelect: onCheckboxSelect },
{
type: 'radio' as const,
value: 'a',
onSelect: onRadioSelect,
options: [{ label: 'A', value: 'a' }, { label: 'B', value: 'b' }],
},
];
const { getByText } = render(
<DropdownMenu open items={items}>
<button type="button">Trigger</button>
</DropdownMenu>
);
fireEvent.click(getByText('Run'));
fireEvent.click(getByText('Check'));
fireEvent.click(getByText('B'));
expect(onItemSelect).toHaveBeenCalled();
expect(onCheckboxSelect).toHaveBeenCalledWith('c1');
expect(onRadioSelect).toHaveBeenCalledWith('b');
});
it('should throw when radio option misses value', () => {
const badItems = [
{
type: 'radio' as const,
value: 'a',
options: [{ label: 'Missing Value' }],
},
];
expect(() =>
render(
<DropdownMenu open items={badItems}>
<button type="button">Trigger</button>
</DropdownMenu>
)
).toThrow('[DropdownMenu]: `value` is required');
});
it('should render full-width actionable rows from MobileDropdownMenu', () => {
const onSelect = vi.fn();
const { getByText } = render(
<MobileDropdownMenu
open
items={[{ type: 'item', children: 'Mobile item', onSelect }]}
onOpenChange={() => {}}
>
<button type="button">Trigger</button>
</MobileDropdownMenu>
);
const item = getByText('Mobile item');
expect(item).toHaveClass('univer-w-full');
fireEvent.click(item);
expect(onSelect).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,69 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { render } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import {
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuPrimitive,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from '../DropdownMenuPrimitive';
import '@testing-library/jest-dom/vitest';
describe('DropdownMenuPrimitive', () => {
it('should render primitive wrappers and variants', () => {
const { container } = render(
<DropdownMenuPrimitive open>
<DropdownMenuTrigger>Trigger</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuGroup>
<DropdownMenuLabel inset>Label</DropdownMenuLabel>
<DropdownMenuItem inset variant="destructive">Item</DropdownMenuItem>
<DropdownMenuCheckboxItem checked hideIndicator>
Check
</DropdownMenuCheckboxItem>
<DropdownMenuRadioGroup value="a">
<DropdownMenuRadioItem value="a" hideIndicator>
Radio A
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
<DropdownMenuSeparator />
<DropdownMenuShortcut>Cmd+K</DropdownMenuShortcut>
<DropdownMenuSub>
<DropdownMenuSubTrigger inset>More</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem>Sub Item</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenuPrimitive>
);
expect(container).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,70 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { Meta } from '@storybook/react';
import { borderClassName } from '../../helper/class-utilities';
import { clsx } from '../../helper/clsx';
import { Dropdown } from './Dropdown';
const meta: Meta<typeof Dropdown> = {
title: 'Components / Dropdown',
component: Dropdown,
parameters: {
layout: 'centered',
},
tags: ['autodocs'],
};
export default meta;
export const Playground = {
render() {
return (
<div className="univer-relative">
<Dropdown
overlay={(
<div className="univer-text-primary-500">
Hello Univer
<Dropdown
overlay={(
<div className="univer-text-cyan-700">
<div>Nested Content</div>
<div>Nested Content</div>
<div>Nested Content</div>
<div>Nested Content</div>
</div>
)}
>
<a>Nested Popover</a>
</Dropdown>
</div>
)}
>
<a
className={clsx(`
univer-cursor-pointer univer-rounded-lg univer-px-4 univer-py-2 univer-transition-all
hover:univer-bg-gray-100
dark:hover:!univer-bg-gray-700
`, borderClassName)}
>
Click me
</a>
</Dropdown>
</div>
);
},
};

View File

@@ -0,0 +1,69 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { ComponentProps, ReactNode } from 'react';
import { useState } from 'react';
import { PopoverContent, PopoverPrimitive, PopoverTrigger } from './PopoverPrimitive';
export interface IDropdownProps extends ComponentProps<typeof PopoverContent> {
children: ReactNode;
overlay: ReactNode;
disabled?: boolean;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}
export function Dropdown(props: IDropdownProps) {
const {
children,
overlay,
disabled,
open: controlledOpen,
onOpenChange: controlledOnOpenChange,
forceMount,
...restProps
} = props;
const [uncontrolledOpen, setUncontrolledOpen] = useState(false);
const isControlled = controlledOpen !== undefined;
const open = isControlled ? controlledOpen : uncontrolledOpen;
function handleChangeOpen(newOpen: boolean) {
if (disabled) {
return;
}
if (!isControlled) {
setUncontrolledOpen(newOpen);
}
controlledOnOpenChange?.(newOpen);
}
return (
<PopoverPrimitive open={open} onOpenChange={handleChangeOpen}>
<PopoverTrigger asChild>
{children}
</PopoverTrigger>
{(open || forceMount) && (
<PopoverContent forceMount={forceMount} {...restProps}>
{overlay}
</PopoverContent>
)}
</PopoverPrimitive>
);
}

View File

@@ -0,0 +1,62 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { IDropdownProps } from './Dropdown';
import { createContext, useCallback, useState } from 'react';
import { MobileDropdownSurface } from './MobileDropdownSurface';
export type IMobileDropdownProps = IDropdownProps;
export const MobileDropdownCloseContext = createContext<(() => void) | undefined>(undefined);
export function MobileDropdown(props: IMobileDropdownProps) {
const {
children,
overlay,
disabled,
open: controlledOpen,
onOpenChange: controlledOnOpenChange,
} = props;
const [uncontrolledOpen, setUncontrolledOpen] = useState(false);
const isControlled = controlledOpen !== undefined;
const open = isControlled ? controlledOpen : uncontrolledOpen;
const handleChangeOpen = useCallback((newOpen: boolean) => {
if (disabled) {
return;
}
if (!isControlled) {
setUncontrolledOpen(newOpen);
}
controlledOnOpenChange?.(newOpen);
}, [controlledOnOpenChange, disabled, isControlled]);
const handleClose = useCallback(() => handleChangeOpen(false), [handleChangeOpen]);
return (
<MobileDropdownCloseContext.Provider value={handleClose}>
<MobileDropdownSurface
open={open}
disabled={disabled}
onOpenChange={handleChangeOpen}
content={overlay}
>
{children}
</MobileDropdownSurface>
</MobileDropdownCloseContext.Provider>
);
}

View File

@@ -0,0 +1,94 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { ReactNode } from 'react';
import { useContext, useLayoutEffect, useState } from 'react';
import { ConfigContext } from '../config-provider/ConfigProvider';
import {
Dialog,
DialogContent,
DialogDescription,
DialogTitle,
DialogTrigger,
} from '../dialog/DialogPrimitive';
import { MobileOverlayContext } from './mobile-overlay-context';
export function MobileDropdownSurface(props: {
children: ReactNode;
content: ReactNode;
open: boolean;
disabled?: boolean;
onOpenChange: (open: boolean) => void;
}) {
const { children, content, open, disabled, onOpenChange } = props;
const { locale, mountContainer } = useContext(ConfigContext);
const mobileOverlay = useContext(MobileOverlayContext);
const [surface, setSurface] = useState<HTMLDivElement | null>(null);
const modal = mobileOverlay?.modal ?? true;
useLayoutEffect(() => {
if (open && surface) {
return mobileOverlay?.onMount(surface);
}
}, [open, surface, mobileOverlay]);
const viewportHeightUnit = globalThis.CSS?.supports('height', '1dvh') ? 'dvh' : 'vh';
return (
<Dialog open={open} onOpenChange={onOpenChange} modal={modal}>
<DialogTrigger asChild disabled={disabled}>
{children}
</DialogTrigger>
<DialogContent
ref={setSurface}
mountContainer={mountContainer}
overlayClassName="!univer-z-[1390]"
className="
!univer-left-0 !univer-right-0 !univer-top-auto !univer-z-[1400] !univer-block !univer-w-full
!univer-max-w-none !univer-translate-x-0 !univer-translate-y-0 !univer-overflow-y-auto
!univer-rounded-t-2xl !univer-border-0 !univer-bg-gray-50 !univer-p-4 !univer-pt-14
dark:!univer-bg-gray-900
[&_button[data-slot='close']]:!univer-right-3 [&_button[data-slot='close']]:!univer-top-3
[&_button[data-slot='close']]:!univer-size-10
[&_button]:!univer-min-h-11
"
style={{
position: 'fixed',
insetInline: 0,
top: 'auto',
bottom: 0,
maxHeight: `80${viewportHeightUnit}`,
width: '100%',
maxWidth: 'none',
transform: 'none',
...mobileOverlay?.contentProps.style,
}}
onOpenAutoFocus={mobileOverlay?.contentProps.onOpenAutoFocus}
onCloseAutoFocus={mobileOverlay?.contentProps.onCloseAutoFocus}
>
<div
aria-hidden="true"
className="
univer-absolute univer-left-1/2 univer-top-3 univer-h-1 univer-w-10 -univer-translate-x-1/2
univer-rounded-full univer-bg-gray-300
dark:!univer-bg-gray-600
"
/>
<DialogTitle className="univer-sr-only">{locale?.Accessibility.menu}</DialogTitle>
<DialogDescription className="univer-hidden" />
{content}
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,79 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { ComponentProps } from 'react';
import { Anchor, Content, Portal, Root, Trigger } from '@radix-ui/react-popover';
import { borderClassName, scrollbarClassName } from '../../helper/class-utilities';
import { clsx } from '../../helper/clsx';
function PopoverPrimitive({
...props
}: ComponentProps<typeof Root>) {
return <Root data-slot="popover" {...props} />;
}
function PopoverTrigger({
...props
}: ComponentProps<typeof Trigger>) {
return <Trigger data-slot="popover-trigger" {...props} />;
}
function PopoverContent({
className,
align = 'center',
sideOffset = 4,
...props
}: ComponentProps<typeof Content>) {
return (
<Portal>
<Content
data-slot="popover-content"
align={align}
sideOffset={sideOffset}
className={clsx(
`
univer-outline-hidden
data-[state=open]:univer-animate-in data-[state=open]:univer-fade-in-0
data-[state=open]:univer-zoom-in-95
data-[state=closed]:univer-animate-out data-[state=closed]:univer-fade-out-0
data-[state=closed]:univer-zoom-out-95
data-[side=bottom]:univer-slide-in-from-top-2
data-[side=left]:univer-slide-in-from-right-2
data-[side=right]:univer-slide-in-from-left-2
data-[side=top]:univer-slide-in-from-bottom-2
univer-z-[1080] univer-max-h-[--radix-popper-available-height] univer-overflow-y-auto
univer-rounded-md univer-bg-gray-0 univer-text-gray-900 univer-shadow-md
data-[state=closed]:univer-pointer-events-none
dark:!univer-bg-gray-900 dark:!univer-text-gray-0
dark:focus-visible:!univer-border-gray-600
`,
borderClassName,
scrollbarClassName,
className
)}
{...props}
/>
</Portal>
);
}
function PopoverAnchor({
...props
}: ComponentProps<typeof Anchor>) {
return <Anchor data-slot="popover-anchor" {...props} />;
}
export { PopoverAnchor, PopoverContent, PopoverPrimitive, PopoverTrigger };

View File

@@ -0,0 +1,153 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* @vitest-environment jsdom
*/
import { cleanup, render, screen, waitFor } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import enUS from '../../../locale/en-US';
import { ConfigProvider } from '../../config-provider/ConfigProvider';
import { Dropdown } from '../Dropdown';
import { MobileOverlayContext } from '../mobile-overlay-context';
import { MobileDropdown } from '../MobileDropdown';
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
describe('Dropdown', () => {
it('should render trigger and not show overlay by default', () => {
const { getByText, queryByText } = render(
<Dropdown overlay={<div>Overlay Content</div>}>
<button type="button">Trigger</button>
</Dropdown>
);
expect(getByText('Trigger')).toBeTruthy();
expect(queryByText('Overlay Content')).toBeNull();
});
it('should show overlay when open is true', () => {
const { getByText } = render(
<Dropdown overlay={<div>Overlay Content</div>} open>
<button type="button">Trigger</button>
</Dropdown>
);
expect(getByText('Overlay Content')).toBeTruthy();
});
it('should stop intercepting pointer input as soon as the overlay closes', () => {
const { rerender } = render(
<Dropdown overlay={<div>Overlay Content</div>} open>
<button type="button">Trigger</button>
</Dropdown>
);
expect(screen.getByText('Overlay Content').closest('[data-slot="popover-content"]')?.classList.contains('data-[state=closed]:univer-pointer-events-none')).toBe(true);
rerender(
<Dropdown overlay={<div>Overlay Content</div>} open={false}>
<button type="button">Trigger</button>
</Dropdown>
);
expect(screen.queryByText('Overlay Content')).toBeNull();
});
it('should call onOpenChange when trigger is clicked', () => {
const handleOpenChange = vi.fn();
const { getByText } = render(
<Dropdown overlay={<div>Overlay Content</div>} onOpenChange={handleOpenChange}>
<button type="button">Trigger</button>
</Dropdown>
);
getByText('Trigger').click();
expect(handleOpenChange).toHaveBeenCalled();
});
it('should render a touch-first dialog surface from MobileDropdown', () => {
vi.stubGlobal('CSS', { supports: () => false });
render(
<ConfigProvider locale={enUS.design} mountContainer={document.body}>
<MobileDropdown overlay={<div>Overlay Content</div>} open>
<button type="button">Trigger</button>
</MobileDropdown>
</ConfigProvider>
);
const dialog = screen.getByRole('dialog');
expect(dialog.textContent).toContain('Overlay Content');
expect(dialog.style.bottom).toBe('0px');
expect(dialog.style.maxHeight).toBe('80vh');
expect(screen.getByText(enUS.design.Accessibility.menu)).toBeTruthy();
});
it('registers a non-modal object surface and releases it when closed', () => {
const release = vi.fn();
const onMount = vi.fn((_element: HTMLElement) => release);
const overlay = { modal: false, contentProps: {}, onMount };
const app = (open: boolean) => (
<ConfigProvider locale={enUS.design} mountContainer={document.body}>
<MobileOverlayContext.Provider value={overlay}>
<button type="button">Canvas action</button>
<MobileDropdown overlay={<div>Object options</div>} open={open}>
<button type="button">Trigger</button>
</MobileDropdown>
</MobileOverlayContext.Provider>
</ConfigProvider>
);
const view = render(app(true));
const dialog = screen.getByRole('dialog');
expect(onMount).toHaveBeenCalledWith(dialog);
expect(dialog.getAttribute('aria-modal')).not.toBe('true');
expect(screen.getByRole('button', { name: 'Canvas action' })).toBeTruthy();
expect(dialog.style.height).toBe('');
view.rerender(app(false));
expect(release).toHaveBeenCalledTimes(1);
});
it('applies host sizing and focus behavior independently of modality', async () => {
const onOpenAutoFocus = vi.fn((event: Event) => event.preventDefault());
const onCloseAutoFocus = vi.fn((event: Event) => event.preventDefault());
const overlay = {
modal: false,
contentProps: { style: { height: 240, maxHeight: 320 }, onOpenAutoFocus, onCloseAutoFocus },
onMount: vi.fn(),
};
const app = (open: boolean) => (
<ConfigProvider locale={enUS.design} mountContainer={document.body}>
<MobileOverlayContext.Provider value={overlay}>
<input aria-label="Editor" />
<MobileDropdown overlay={<input aria-label="Options" />} open={open}>
<button type="button">Trigger</button>
</MobileDropdown>
</MobileOverlayContext.Provider>
</ConfigProvider>
);
const view = render(app(false));
const editor = screen.getByRole('textbox', { name: 'Editor' });
editor.focus();
view.rerender(app(true));
expect(screen.getByRole('dialog').style.height).toBe('240px');
expect(screen.getByRole('dialog').style.maxHeight).toBe('320px');
expect(document.activeElement).toBe(editor);
expect(onOpenAutoFocus).toHaveBeenCalledOnce();
view.rerender(app(false));
await waitFor(() => expect(onCloseAutoFocus).toHaveBeenCalledOnce());
expect(document.activeElement).toBe(editor);
});
});

View File

@@ -0,0 +1,26 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { ComponentPropsWithoutRef } from 'react';
import type { DialogContent } from '../dialog/DialogPrimitive';
import { createContext } from 'react';
/** The host supplies layout and focus behavior for nested mobile surfaces. */
export const MobileOverlayContext = createContext<{
modal: boolean;
contentProps: Pick<ComponentPropsWithoutRef<typeof DialogContent>, 'style' | 'onOpenAutoFocus' | 'onCloseAutoFocus'>;
onMount: (element: HTMLElement) => void | (() => void);
} | null>(null);

View File

@@ -0,0 +1,55 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { Meta } from '@storybook/react';
import { Input } from '../input/Input';
import { Select } from '../select/Select';
import { FormLayout } from './FormLayout';
const meta: Meta = {
title: 'Components / FormLayout',
parameters: {
layout: 'centered',
},
tags: ['autodocs'],
args: {},
};
export default meta;
function PagerDemo() {
return (
<>
<FormLayout label="行标题与列标题" desc="转到“查看”>“冻结”可选择要在所有页面重复的行/列">
<Input placeholder="请输入" />
</FormLayout>
<FormLayout label="行标题与列标题" desc="转到“查看”>“冻结”可选择要在所有页面重复的行/列" error="123">
<Select value="" onChange={() => { /* empty */ }} />
</FormLayout>
</>
);
}
export const Playground = {
render: () => {
return (
<>
<PagerDemo />
</>
);
},
};

View File

@@ -0,0 +1,128 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { CSSProperties, PropsWithChildren, ReactNode } from 'react';
import { MoreUpIcon } from '@univerjs/icons';
import { createContext, useState } from 'react';
import { clsx } from '../../helper/clsx';
export interface IFormLayoutProps {
label?: ReactNode;
desc?: ReactNode;
children?: ReactNode;
style?: CSSProperties;
className?: string;
contentStyle?: CSSProperties;
error?: string;
collapsable?: boolean;
defaultCollapsed?: boolean;
}
const FormLayoutContext = createContext(false);
export const FormLayout = (props: IFormLayoutProps) => {
const { label, desc, children, style, className, error, contentStyle, collapsable = false, defaultCollapsed = false } = props;
const [collapsed, setCollapsed] = useState(defaultCollapsed);
return (
<FormLayoutContext.Provider value>
<div data-u-comp="form-layout" className={clsx('univer-mb-3 univer-flex univer-flex-col', className)} style={style}>
{label && (
<div
className={clsx(`
univer-mb-2 univer-flex univer-min-h-3.5 univer-items-center univer-text-sm
univer-text-gray-900
dark:!univer-text-gray-0
`, {
'univer-cursor-pointer': collapsable,
})}
onClick={() => setCollapsed(!collapsed)}
>
{label}
{collapsable && (
<MoreUpIcon
className={clsx('univer-ml-1 univer-transition-transform', {
'univer-rotate-180': collapsed,
})}
/>
)}
</div>
)}
{collapsed && collapsable
? null
: (
<>
{desc && (
<div
className={`
univer-mt-1 univer-text-sm univer-text-gray-600
dark:!univer-text-gray-200
`}
>
{desc}
</div>
)}
{children
? (
<div
className={clsx(`
last:univer-mb-0
[&_[data-u-comp=input]]:univer-w-full
[&_[data-u-comp=select]]:univer-w-full
`, {
'[&_[data-u-comp=input]]:univer-border-red-500': error,
'[&_[data-u-comp=select]]:univer-border-red-500': error,
})}
style={contentStyle}
>
{children}
{error
? (
<div className="univer-mt-1 univer-text-xs univer-text-red-500">
{error}
</div>
)
: null}
</div>
)
: null}
</>
)}
</div>
</FormLayoutContext.Provider>
);
};
export type IFormDualColumnLayoutProps = PropsWithChildren;
/**
* A dual columns layout component for the form.
* @param props props of the component
*/
export const FormDualColumnLayout = (props: IFormDualColumnLayoutProps) => {
return (
<div
className={`
univer-flex univer-justify-between
[&_[data-u-comp=form-layout]]:univer-max-w-[calc(50%-8px)] [&_[data-u-comp=form-layout]]:univer-shrink
[&_[data-u-comp=form-layout]]:univer-grow
`}
>
{props.children}
</div>
);
};

View File

@@ -0,0 +1,60 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it } from 'vitest';
import { FormLayout } from '../FormLayout';
import '@testing-library/jest-dom/vitest';
afterEach(cleanup);
describe('FormLayout', () => {
it('should toggle content when collapsable', () => {
const { container } = render(
<FormLayout
label="Section"
desc="Description"
error="Invalid value"
collapsable
defaultCollapsed
>
<input data-u-comp="input" />
</FormLayout>
);
expect(screen.queryByText('Description')).not.toBeInTheDocument();
expect(screen.queryByText('Invalid value')).not.toBeInTheDocument();
fireEvent.click(screen.getByText('Section'));
expect(screen.getByText('Description')).toBeInTheDocument();
expect(screen.getByText('Invalid value')).toBeInTheDocument();
expect(container.querySelector('[data-u-comp="input"]')).toBeInTheDocument();
expect(container.innerHTML).toContain('univer-border-red-500');
});
it('should keep content visible when not collapsable', () => {
render(
<FormLayout label="Always Visible" defaultCollapsed>
<div>Child Content</div>
</FormLayout>
);
fireEvent.click(screen.getByText('Always Visible'));
expect(screen.getByText('Child Content')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,18 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export type { IFormDualColumnLayoutProps, IFormLayoutProps } from './FormLayout';
export { FormDualColumnLayout, FormLayout } from './FormLayout';

View File

@@ -0,0 +1,48 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { Meta } from '@storybook/react';
import { useState } from 'react';
import { Button } from '../button/Button';
import { Gallery } from './Gallery';
const meta: Meta<typeof Gallery> = {
title: 'Components / Gallery',
component: Gallery,
parameters: {
layout: 'centered',
},
tags: ['autodocs'],
};
export default meta;
export const GalleryBasic = {
render() {
const [open, setOpen] = useState(false);
const images = ['https://github.com/dream-num.png', 'https://github.com/awesome-univer.png', 'https://github.com/dream-num.png'];
return (
<>
<div>
<Button onClick={() => setOpen(true)}>Open Gallery</Button>
<Gallery open={open} onOpenChange={setOpen} images={images} />
</div>
</>
);
},
};

View File

@@ -0,0 +1,295 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { CloseIcon, OneToOneIcon, ZoomInIcon, ZoomOutIcon } from '@univerjs/icons';
import { useContext, useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { clsx } from '../../helper/clsx';
import { Button } from '../button/Button';
import { ConfigContext } from '../config-provider/ConfigProvider';
import { Pager } from '../pager/Pager';
export interface IGalleryProps {
className?: string;
images: string[];
open?: boolean;
onOpenChange?: (open: boolean) => void;
}
const toolbarButtonClassName = `
!univer-border-transparent !univer-bg-transparent !univer-text-gray-300
hover:!univer-bg-gray-600 hover:!univer-text-gray-0
focus-visible:!univer-outline-none focus-visible:!univer-ring-2 focus-visible:!univer-ring-gray-0
`;
const focusableElementSelector = `
button:not([disabled]), [href], input:not([disabled]), select:not([disabled]),
textarea:not([disabled]), [tabindex]:not([tabindex="-1"])
`;
export function Gallery(props: IGalleryProps) {
const { className, images, open, onOpenChange } = props;
const [isVisible, setIsVisible] = useState(Boolean(open));
const [activeImageIndex, setActiveImageIndex] = useState(0);
const [zoomLevel, setZoomLevel] = useState(1);
const { direction, locale } = useContext(ConfigContext);
const dialogRef = useRef<HTMLDivElement>(null);
const closeButtonRef = useRef<HTMLButtonElement>(null);
const previouslyFocusedElementRef = useRef<HTMLElement | null>(null);
const activeImage = images[activeImageIndex];
const hasPagination = images.length > 1;
const imageLabel = locale?.Accessibility.image
?.replace('{0}', String(activeImageIndex + 1))
.replace('{1}', String(images.length)) ?? `Image ${activeImageIndex + 1} of ${images.length}`;
useEffect(() => {
const timer = setTimeout(() => {
setIsVisible(Boolean(open));
if (!open && previouslyFocusedElementRef.current?.isConnected) {
previouslyFocusedElementRef.current.focus();
}
if (!open) {
previouslyFocusedElementRef.current = null;
}
}, open ? 0 : 150);
return () => clearTimeout(timer);
}, [open]);
useEffect(() => {
if (!open) return;
previouslyFocusedElementRef.current = document.activeElement instanceof HTMLElement
? document.activeElement
: null;
closeButtonRef.current?.focus();
}, [open]);
useEffect(() => {
if (!open && !isVisible) return;
const handler = (event: KeyboardEvent) => {
if (event.key === 'Escape' && open) {
event.preventDefault();
event.stopPropagation();
onOpenChange?.(false);
return;
}
if (event.key !== 'Tab' || !dialogRef.current) {
return;
}
const focusableElements = Array.from(
dialogRef.current.querySelectorAll<HTMLElement>(focusableElementSelector)
);
if (focusableElements.length === 0) {
event.preventDefault();
dialogRef.current.focus();
return;
}
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
const activeElement = document.activeElement;
if (event.shiftKey && (activeElement === firstElement || !dialogRef.current.contains(activeElement))) {
event.preventDefault();
lastElement.focus();
} else if (!event.shiftKey && (activeElement === lastElement || !dialogRef.current.contains(activeElement))) {
event.preventDefault();
firstElement.focus();
}
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [isVisible, onOpenChange, open]);
useEffect(() => {
return () => {
if (previouslyFocusedElementRef.current?.isConnected) {
previouslyFocusedElementRef.current.focus();
}
};
}, []);
// wheel
useEffect(() => {
if (!open) return;
const handleWheel = (e: WheelEvent) => {
e.preventDefault();
const step = -e.deltaY * 0.001;
setZoomLevel((prev) => Math.min(Math.max(0.5, prev + step), 2));
};
window.addEventListener('wheel', handleWheel, { passive: false });
return () => {
window.removeEventListener('wheel', handleWheel);
};
}, [open]);
if (!open && !isVisible) return null;
function handleToggleZoom(ratio: number | 'reset') {
if (ratio === 'reset') {
setZoomLevel(1);
return;
}
setZoomLevel((previousZoomLevel) => Math.min(Math.max(0.5, previousZoomLevel + ratio), 2));
}
function handleClose() {
onOpenChange?.(false);
}
return createPortal(
<div
data-u-comp="gallery"
dir={direction}
role="dialog"
aria-modal="true"
aria-label={locale?.Accessibility.imageGallery ?? 'Image gallery'}
tabIndex={-1}
ref={dialogRef}
className={clsx(
`
univer-fixed univer-inset-0 univer-z-[1080] univer-flex univer-h-screen univer-w-screen
univer-select-none univer-items-center univer-justify-center
`,
{
'univer-animate-in univer-fade-in': open,
'univer-animate-out univer-fade-out': !open,
},
className
)}
>
<div
className="univer-absolute univer-inset-0 univer-size-full univer-bg-gray-900 univer-opacity-80"
aria-hidden="true"
onClick={handleClose}
/>
<Button
ref={closeButtonRef}
data-u-comp="gallery-close"
type="button"
variant="ghost"
size="icon"
aria-label={locale?.Accessibility.close ?? 'Close'}
className={`
univer-absolute univer-right-4 univer-top-4 univer-z-10 univer-size-10 univer-rounded-full
!univer-border-gray-500 !univer-bg-gray-800 !univer-text-gray-0
hover:!univer-bg-gray-700
focus-visible:!univer-outline-none focus-visible:!univer-ring-2 focus-visible:!univer-ring-gray-0
rtl:univer-left-4 rtl:univer-right-auto
`}
onClick={handleClose}
>
<CloseIcon aria-hidden="true" />
</Button>
{/* Content */}
<div
className="univer-relative univer-flex univer-w-fit univer-items-center univer-justify-center"
>
{activeImage && (
<img
className={`
univer-h-full univer-w-full univer-object-contain univer-transition-transform
univer-duration-300 univer-ease-out
`}
style={{
transform: `scale(${zoomLevel})`,
}}
src={activeImage}
alt={imageLabel}
draggable={false}
/>
)}
</div>
<span className="univer-sr-only" aria-live="polite" aria-atomic="true">
{imageLabel}
</span>
{/* Toolbar */}
<footer
className={`
univer-absolute univer-bottom-6 univer-left-1/2 univer-flex -univer-translate-x-1/2
univer-items-center univer-gap-3 univer-rounded-full univer-bg-gray-800 univer-px-6 univer-py-3
univer-text-gray-400
rtl:univer-flex-row-reverse
`}
>
{hasPagination && (
<Pager
className={`
!univer-text-gray-400
[&_[data-u-comp=pager-left-arrow]:hover]:!univer-bg-gray-600
[&_[data-u-comp=pager-right-arrow]:hover]:!univer-bg-gray-600
`}
value={activeImageIndex + 1}
total={images.length}
previousButtonAriaLabel={locale?.Accessibility.previous ?? 'Previous'}
nextButtonAriaLabel={locale?.Accessibility.next ?? 'Next'}
onChange={(value) => setActiveImageIndex(value - 1)}
/>
)}
<Button
type="button"
variant="ghost"
size="icon"
aria-label={locale?.Accessibility.zoomIn ?? 'Zoom in'}
className={toolbarButtonClassName}
disabled={zoomLevel >= 2}
onClick={() => handleToggleZoom(0.25)}
>
<ZoomInIcon aria-hidden="true" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
aria-label={locale?.Accessibility.zoomOut ?? 'Zoom out'}
className={toolbarButtonClassName}
disabled={zoomLevel <= 0.5}
onClick={() => handleToggleZoom(-0.25)}
>
<ZoomOutIcon aria-hidden="true" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
aria-label={locale?.Accessibility.resetZoom ?? 'Reset zoom'}
className={toolbarButtonClassName}
disabled={zoomLevel === 1}
onClick={() => handleToggleZoom('reset')}
>
<OneToOneIcon aria-hidden="true" />
</Button>
</footer>
</div>,
document.body
);
}

View File

@@ -0,0 +1,216 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { ComponentProps, PropsWithChildren } from 'react';
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import enUS from '../../../locale/en-US';
import { ConfigProvider } from '../../config-provider/ConfigProvider';
import { Gallery } from '../Gallery';
import '@testing-library/jest-dom/vitest';
const images = [
'https://example.com/1.jpg',
'https://example.com/2.jpg',
'https://example.com/3.jpg',
];
function LocaleProvider({ children }: PropsWithChildren) {
return <ConfigProvider locale={enUS.design} mountContainer={document.body}>{children}</ConfigProvider>;
}
function renderGallery(props: ComponentProps<typeof Gallery>) {
return render(<Gallery {...props} />, { wrapper: LocaleProvider });
}
function FocusHarness({ open }: { open: boolean }) {
return (
<>
<button type="button">Open gallery trigger</button>
<Gallery images={images} open={open} />
</>
);
}
afterEach(() => {
cleanup();
vi.useRealTimers();
});
describe('Gallery', () => {
beforeEach(() => {
vi.useRealTimers();
});
it('does not render when open is false', () => {
renderGallery({ images, open: false });
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
it('renders and displays the first image when open', () => {
renderGallery({ images, open: true });
const dialog = screen.getByRole('dialog');
expect(dialog).toBeInTheDocument();
const img = screen.getByRole('img');
expect(img).toHaveAttribute('src', images[0]);
expect(img).toHaveAttribute('alt', 'Image 1 of 3');
});
it('calls onOpenChange(false) when clicking the overlay', () => {
const onOpenChange = vi.fn();
renderGallery({ images, open: true, onOpenChange });
// The overlay is the first child of the dialog
const dialog = screen.getByRole('dialog');
const overlay = dialog.querySelector('div');
if (overlay) {
fireEvent.click(overlay);
expect(onOpenChange).toHaveBeenCalledWith(false);
}
});
it('calls onOpenChange(false) when pressing ESC', () => {
const onOpenChange = vi.fn();
renderGallery({ images, open: true, onOpenChange });
fireEvent.keyDown(window, { key: 'Escape', code: 'Escape' });
expect(onOpenChange).toHaveBeenCalledWith(false);
});
it('provides named close, pagination, and zoom controls', () => {
renderGallery({ images, open: true });
expect(screen.getByRole('button', { name: 'Close' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Previous' })).toBeDisabled();
expect(screen.getByRole('button', { name: 'Next' })).toBeEnabled();
expect(screen.getByRole('button', { name: /zoom in/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /zoom out/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /reset zoom/i })).toBeInTheDocument();
});
it('calls onOpenChange(false) when clicking the close button', () => {
const onOpenChange = vi.fn();
renderGallery({ images, open: true, onOpenChange });
fireEvent.click(screen.getByRole('button', { name: 'Close' }));
expect(onOpenChange).toHaveBeenCalledWith(false);
});
it('contains focus while open and restores it after closing', () => {
vi.useFakeTimers();
const { rerender } = render(<FocusHarness open={false} />, { wrapper: LocaleProvider });
const trigger = screen.getByRole('button', { name: 'Open gallery trigger' });
trigger.focus();
rerender(<FocusHarness open />);
const closeButton = screen.getByRole('button', { name: 'Close' });
const zoomOutButton = screen.getByRole('button', { name: /zoom out/i });
expect(closeButton).toHaveFocus();
fireEvent.keyDown(window, { key: 'Tab', shiftKey: true });
expect(zoomOutButton).toHaveFocus();
fireEvent.keyDown(window, { key: 'Tab' });
expect(closeButton).toHaveFocus();
rerender(<FocusHarness open={false} />);
act(() => vi.advanceTimersByTime(150));
expect(trigger).toHaveFocus();
});
it('keeps the toolbar controls in logical order in RTL', () => {
render(
<ConfigProvider locale={enUS.design} direction="rtl" mountContainer={document.body}>
<Gallery images={images} open />
</ConfigProvider>
);
expect(screen.getByRole('dialog').querySelector('footer')).toHaveClass('rtl:univer-flex-row-reverse');
});
it('zoom in/out/reset buttons adjust the image scale', () => {
renderGallery({ images, open: true });
const img = screen.getByRole('img');
const zoomInBtn = screen.getByRole('button', { name: /zoom in/i });
const zoomOutBtn = screen.getByRole('button', { name: /zoom out/i });
const resetBtn = screen.getByRole('button', { name: /reset zoom/i });
expect(img).toHaveStyle('transform: scale(1)');
fireEvent.click(zoomInBtn);
expect(img).toHaveStyle('transform: scale(1.25)');
fireEvent.click(zoomOutBtn);
expect(img).toHaveStyle('transform: scale(1)');
fireEvent.click(resetBtn);
expect(img).toHaveStyle('transform: scale(1)');
});
it('can switch images using the pager', () => {
renderGallery({ images, open: true });
const nextButton = document.querySelector('[data-u-comp="pager-right-arrow"]') as HTMLButtonElement;
fireEvent.click(nextButton);
const img = screen.getByRole('img');
expect(img).toHaveAttribute('src', images[1]);
expect(img).toHaveAttribute('alt', 'Image 2 of 3');
});
it('does not render pagination for a single image', () => {
renderGallery({ images: [images[0]], open: true });
expect(document.querySelector('[data-u-comp="pager"]')).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: /zoom in/i })).toBeInTheDocument();
});
it('should zoom with wheel event and keep value in range', () => {
renderGallery({ images, open: true });
const img = screen.getByRole('img');
const getScale = () => Number.parseFloat((img.style.transform.match(/scale\(([^)]+)\)/)?.[1] ?? '1'));
fireEvent.wheel(window, { deltaY: -300 });
expect(img.style.transform).toContain('scale(');
const zoomInBtn = screen.getByRole('button', { name: /zoom in/i });
const zoomOutBtn = screen.getByRole('button', { name: /zoom out/i });
for (let i = 0; i < 8; i++) {
fireEvent.click(zoomInBtn);
}
const atUpperBound = getScale();
fireEvent.click(zoomInBtn);
expect(getScale()).toBe(atUpperBound);
expect(getScale()).toBeLessThanOrEqual(2);
for (let i = 0; i < 12; i++) {
fireEvent.click(zoomOutBtn);
}
const atLowerBound = getScale();
fireEvent.click(zoomOutBtn);
expect(getScale()).toBe(atLowerBound);
expect(getScale()).toBeGreaterThanOrEqual(0.5);
});
it('should schedule close animation timer when closing', () => {
const timeoutSpy = vi.spyOn(globalThis, 'setTimeout');
const { rerender } = renderGallery({ images, open: true });
expect(screen.getByRole('dialog')).toBeInTheDocument();
rerender(<Gallery images={images} open={false} />);
expect(screen.getByRole('dialog')).toBeInTheDocument();
expect(timeoutSpy).toHaveBeenCalled();
expect(timeoutSpy.mock.calls.some(([, delay]) => delay === 150)).toBe(true);
});
});

View File

@@ -0,0 +1,53 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { Meta } from '@storybook/react';
import type { IGradientValue } from './GradientColorPicker';
import { useState } from 'react';
import { GradientColorPicker } from './GradientColorPicker';
const meta: Meta<typeof GradientColorPicker> = {
title: 'Components / GradientColorPicker',
component: GradientColorPicker,
parameters: {
layout: 'centered',
},
tags: ['autodocs'],
};
export default meta;
export const Playground = {
render() {
const [value, setValue] = useState<IGradientValue>({
type: 'linear',
stops: [
{ color: '#409eff', offset: 0 },
{ color: '#67c23a', offset: 100 },
],
angle: 0,
});
return (
<div className="univer-flex univer-flex-col univer-gap-4">
<div className="univer-font-mono univer-text-sm">
{JSON.stringify(value, null, 2)}
</div>
<GradientColorPicker className="univer-w-80" value={value} onChange={setValue} />
</div>
);
},
};

View File

@@ -0,0 +1,467 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { DeleteIcon } from '@univerjs/icons';
import { useContext, useEffect, useMemo, useRef, useState } from 'react';
import { clsx } from '../../helper/clsx';
import { Button } from '../button/Button';
import { ColorPicker } from '../color-picker/ColorPicker';
import { ConfigContext } from '../config-provider/ConfigProvider';
import { InputNumber } from '../input-number/InputNumber';
import { Segmented } from '../segmented/Segmented';
import { Tooltip } from '../tooltip/Tooltip';
export type GradientType = 'linear' | 'radial' | 'angular' | 'diamond';
export interface IGradientStop {
color: string;
offset: number;
opacity?: number;
}
export interface IGradientValue {
type: GradientType;
stops: IGradientStop[];
angle?: number;
}
export interface IGradientColorPickerProps {
className?: string;
compact?: boolean;
value?: IGradientValue;
onChange?: (value: IGradientValue) => void;
types?: readonly GradientType[];
}
const DEFAULT_VALUE: IGradientValue = {
type: 'linear',
stops: [
{ color: '#ffffff', offset: 0 },
{ color: '#000000', offset: 100 },
],
angle: 0,
};
function clamp(value: number, min: number, max: number): number {
return Math.max(min, Math.min(max, value));
}
function getStopOpacity(stop: IGradientStop | undefined): number {
return clamp(stop?.opacity ?? 1, 0, 1);
}
function getColorWithOpacity(color: string, opacity: number): string {
if (opacity >= 1) {
return color;
}
const value = color.trim();
if (/^#[0-9a-f]{3}$/i.test(value)) {
const [, r, g, b] = value;
return `rgba(${Number.parseInt(r + r, 16)}, ${Number.parseInt(g + g, 16)}, ${Number.parseInt(b + b, 16)}, ${opacity})`;
}
if (/^#[0-9a-f]{6}$/i.test(value)) {
return `rgba(${Number.parseInt(value.slice(1, 3), 16)}, ${Number.parseInt(value.slice(3, 5), 16)}, ${Number.parseInt(value.slice(5, 7), 16)}, ${opacity})`;
}
const rgba = /^rgba?\(([^)]+)\)$/i.exec(value);
if (rgba) {
const parts = rgba[1].split(',').map((part) => part.trim());
const baseAlpha = parts[3] === undefined ? 1 : Number.parseFloat(parts[3]);
const alpha = Number.isFinite(baseAlpha) ? clamp(baseAlpha * opacity, 0, 1) : opacity;
return `rgba(${parts[0]}, ${parts[1]}, ${parts[2]}, ${alpha})`;
}
return color;
}
function getStopColor(stop: IGradientStop): string {
return getColorWithOpacity(stop.color, getStopOpacity(stop));
}
function getGradientAngle(value: IGradientValue): number {
return value.angle ?? DEFAULT_VALUE.angle ?? 0;
}
function getCssLinearGradientAngle(value: IGradientValue): number {
return (getGradientAngle(value) + 90) % 360;
}
export function GradientColorPicker(props: IGradientColorPickerProps) {
const { className, compact = false, value = DEFAULT_VALUE, onChange } = props;
const { locale } = useContext(ConfigContext);
const [draftValue, setDraftValue] = useState<IGradientValue>(value);
const [selectedIndex, setSelectedIndex] = useState<number>(0);
const barRef = useRef<HTMLDivElement>(null);
const valueRef = useRef<IGradientValue>(value);
const cleanupRef = useRef<(() => void) | null>(null);
useEffect(() => {
valueRef.current = draftValue;
}, [draftValue]);
useEffect(() => {
setDraftValue(value);
}, [value]);
const emitChange = (nextValue: IGradientValue) => {
valueRef.current = nextValue;
setDraftValue(nextValue);
onChange?.(nextValue);
};
useEffect(() => {
setSelectedIndex((prev) => {
if (prev >= draftValue.stops.length) {
return Math.max(0, draftValue.stops.length - 1);
}
return prev;
});
}, [draftValue.stops.length]);
useEffect(() => {
return () => {
cleanupRef.current?.();
};
}, []);
const stops = useMemo(() => {
return [...draftValue.stops].sort((a, b) => a.offset - b.offset);
}, [draftValue.stops]);
const handleTypeChange = (type: GradientType) => {
emitChange({ ...valueRef.current, type });
};
const handleAngleChange = (angle: number | null) => {
emitChange({ ...valueRef.current, angle: angle ?? 0 });
};
const handleStopColorChange = (color: string) => {
const newStops = [...valueRef.current.stops];
newStops[selectedIndex] = { ...newStops[selectedIndex], color };
emitChange({ ...valueRef.current, stops: newStops });
};
const handleStopOffsetChange = (offset: number | null) => {
if (offset === null) return;
const newStops = [...valueRef.current.stops];
newStops[selectedIndex] = { ...newStops[selectedIndex], offset };
emitChange({ ...valueRef.current, stops: newStops });
};
const handleStopTransparencyChange = (transparency: number | null) => {
const nextTransparency = clamp(transparency ?? 0, 0, 100);
const newStops = [...valueRef.current.stops];
newStops[selectedIndex] = {
...newStops[selectedIndex],
opacity: (100 - nextTransparency) / 100,
};
emitChange({ ...valueRef.current, stops: newStops });
};
// const handleFlip = () => {
// const newStops = value.stops.map((stop) => ({
// ...stop,
// offset: 100 - stop.offset,
// }));
// onChange?.({ ...value, stops: newStops });
// };
const handleAddStop = (e: React.MouseEvent<HTMLDivElement>) => {
if (!barRef.current) return;
const rect = barRef.current.getBoundingClientRect();
const offset = Math.round(((e.clientX - rect.left) / rect.width) * 100);
// Find color at this offset (simple linear interpolation)
const leftStop = [...stops].reverse().find((s) => s.offset <= offset) || stops[0];
// const rightStop = stops.find((s) => s.offset >= offset) || stops[stops.length - 1];
const newStop = { color: leftStop.color, offset, opacity: getStopOpacity(leftStop) };
const newStops = [...valueRef.current.stops, newStop];
emitChange({ ...valueRef.current, stops: newStops });
setSelectedIndex(newStops.length - 1);
};
const handleRemoveStop = () => {
if (draftValue.stops.length <= 2) return;
const newStops = valueRef.current.stops.filter((_, i) => i !== selectedIndex);
emitChange({ ...valueRef.current, stops: newStops });
setSelectedIndex(0);
};
const gradientPreview = useMemo(() => {
const stopsStr = stops.map((s) => `${getStopColor(s)} ${s.offset}%`).join(', ');
return `linear-gradient(to right, ${stopsStr})`;
}, [stops]);
const mainPreview = useMemo(() => {
const stopsStr = stops.map((s) => `${getStopColor(s)} ${s.offset}%`).join(', ');
switch (draftValue.type) {
case 'linear':
return `linear-gradient(${getCssLinearGradientAngle(draftValue)}deg, ${stopsStr})`;
case 'radial':
return `radial-gradient(circle, ${stopsStr})`;
case 'angular':
return `conic-gradient(from ${getGradientAngle(draftValue)}deg, ${stopsStr})`;
case 'diamond':
// Diamond is tricky in CSS, using a placeholder or approximation
return `radial-gradient(circle, ${stopsStr})`;
default:
return `linear-gradient(${getCssLinearGradientAngle(draftValue)}deg, ${stopsStr})`;
}
}, [draftValue, stops]);
const showAngleEditor = draftValue.type === 'linear' || draftValue.type === 'angular';
return (
<div
className={clsx(
compact
? `
univer-flex univer-w-full univer-min-w-0 univer-flex-col univer-gap-3 univer-rounded-md
univer-bg-transparent univer-p-0
dark:!univer-bg-transparent
`
: `
univer-flex univer-w-full univer-min-w-0 univer-max-w-80 univer-flex-col univer-gap-4
univer-rounded-lg univer-bg-gray-0 univer-p-4 univer-shadow-lg
dark:!univer-bg-gray-800
`,
className
)}
onClick={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
>
{compact
? (
<div
className="
univer-flex univer-min-w-0 univer-items-center univer-gap-4 univer-border-b
univer-border-gray-100
dark:!univer-border-gray-700
"
>
{[
{ label: locale?.GradientColorPicker.linear, value: 'linear' },
{ label: locale?.GradientColorPicker.radial, value: 'radial' },
{ label: locale?.GradientColorPicker.angular, value: 'angular' },
{ label: locale?.GradientColorPicker.diamond, value: 'diamond' },
].filter((item) => !props.types || props.types.some((type) => type === item.value)).map((item) => {
const selected = draftValue.type === item.value;
return (
<button
key={item.value}
type="button"
className={clsx(
`
univer-relative univer-min-w-0 univer-flex-1 univer-cursor-pointer
univer-truncate univer-border-none univer-bg-transparent univer-px-0
univer-pb-2 univer-pt-0 univer-text-xs univer-transition-colors
`,
selected
? `
univer-font-medium univer-text-primary-600
dark:!univer-text-primary-300
`
: `
univer-text-gray-500
hover:univer-text-gray-900
dark:hover:!univer-text-gray-0
`
)}
onClick={() => handleTypeChange(item.value as GradientType)}
>
{item.label}
{selected && (
<span
className="
univer-absolute univer-inset-x-2 -univer-bottom-px univer-h-0.5
univer-rounded-full univer-bg-primary-600
dark:!univer-bg-primary-300
"
/>
)}
</button>
);
})}
</div>
)
: (
<Segmented
className="univer-w-full univer-min-w-0 univer-gap-1 univer-overflow-hidden"
items={[
{ label: locale?.GradientColorPicker.linear, value: 'linear' },
{ label: locale?.GradientColorPicker.radial, value: 'radial' },
{ label: locale?.GradientColorPicker.angular, value: 'angular' },
{ label: locale?.GradientColorPicker.diamond, value: 'diamond' },
].filter((item) => !props.types || props.types.some((type) => type === item.value))}
value={draftValue.type}
onChange={(v) => handleTypeChange(v as GradientType)}
/>
)}
{showAngleEditor && (
<div
data-u-comp="gradient-color-picker-angle"
className="univer-flex univer-items-center univer-justify-between univer-gap-3"
>
<span className="univer-text-xs univer-text-gray-500">{locale?.GradientColorPicker.angle}</span>
<InputNumber
className="univer-w-24"
value={getGradientAngle(draftValue)}
min={0}
max={360}
onChange={handleAngleChange}
/>
</div>
)}
<div
data-u-comp="gradient-color-picker-preview"
className={clsx(
`
univer-w-full univer-rounded-md univer-border univer-border-gray-200
dark:!univer-border-gray-600
`,
compact ? 'univer-h-20' : 'univer-h-32'
)}
style={{ background: mainPreview }}
/>
<div className={clsx('univer-relative univer-h-6', compact ? 'univer-mt-1' : 'univer-mt-4')}>
<div
ref={barRef}
data-u-comp="gradient-color-picker-bar"
className={`
univer-absolute univer-inset-x-0 univer-top-1/2 univer-h-2 -univer-translate-y-1/2
univer-cursor-crosshair univer-rounded-full
`}
style={{ background: gradientPreview }}
onClick={handleAddStop}
/>
{draftValue.stops.map((stop, index) => (
<div
key={index}
data-u-comp="gradient-color-picker-stop"
data-selected={selectedIndex === index}
className={clsx(
`
univer-absolute univer-top-1/2 univer-size-4 -univer-translate-x-1/2
-univer-translate-y-1/2 univer-cursor-pointer univer-rounded-full univer-border-2
univer-border-gray-0 univer-shadow-md
`,
selectedIndex === index ? 'univer-z-10 univer-ring-2 univer-ring-primary-500' : 'univer-z-0'
)}
style={{
left: `${stop.offset}%`,
backgroundColor: getStopColor(stop),
}}
onClick={(e) => {
e.stopPropagation();
setSelectedIndex(index);
}}
onPointerDown={(e) => {
e.preventDefault();
e.stopPropagation();
const startX = e.clientX;
const startOffset = stop.offset;
setSelectedIndex(index);
const handlePointerMove = (moveEvent: PointerEvent) => {
if (!barRef.current) return;
moveEvent.preventDefault();
const rect = barRef.current.getBoundingClientRect();
const deltaX = moveEvent.clientX - startX;
const deltaOffset = (deltaX / rect.width) * 100;
const newOffset = Math.max(0, Math.min(100, Math.round(startOffset + deltaOffset)));
const newStops = [...valueRef.current.stops];
newStops[index] = { ...newStops[index], offset: newOffset };
emitChange({ ...valueRef.current, stops: newStops });
};
const handlePointerUp = () => {
window.removeEventListener('pointermove', handlePointerMove);
window.removeEventListener('pointerup', handlePointerUp);
cleanupRef.current = null;
};
cleanupRef.current = handlePointerUp;
window.addEventListener('pointermove', handlePointerMove);
window.addEventListener('pointerup', handlePointerUp);
}}
/>
))}
</div>
<div className="univer-flex univer-items-end univer-gap-2">
<div className="univer-flex-1">
<div className="univer-mb-0.5 univer-text-xs univer-text-gray-500">{locale?.GradientColorPicker.offset}</div>
<InputNumber
value={draftValue.stops[selectedIndex]?.offset}
min={0}
max={100}
onChange={handleStopOffsetChange}
/>
</div>
<div className="univer-flex-1">
<div className="univer-mb-0.5 univer-text-xs univer-text-gray-500">
{locale?.GradientColorPicker.transparency}
</div>
<InputNumber
value={Math.round((1 - getStopOpacity(draftValue.stops[selectedIndex])) * 100)}
min={0}
max={100}
step={10}
formatter={(v) => `${v}%`}
parser={(v) => v?.replace(/%/g, '') || ''}
onChange={handleStopTransparencyChange}
/>
</div>
<div className="univer-flex univer-gap-1">
<Tooltip title={locale?.GradientColorPicker.delete}>
<Button
data-u-comp="gradient-color-picker-delete"
variant="danger"
onClick={handleRemoveStop}
disabled={draftValue.stops.length <= 2}
>
<DeleteIcon />
</Button>
</Tooltip>
</div>
</div>
<div
className={clsx(
`
univer-border-t univer-border-gray-100
dark:!univer-border-gray-700
`,
compact ? 'univer-pt-3' : 'univer-pt-4'
)}
>
<ColorPicker
value={draftValue.stops[selectedIndex]?.color}
onChange={handleStopColorChange}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,118 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { GradientType, IGradientColorPickerProps, IGradientStop } from './GradientColorPicker';
import { DeleteIcon, IncreaseIcon } from '@univerjs/icons';
import { useContext, useState } from 'react';
import { clsx } from '../../helper/clsx';
import { Button } from '../button/Button';
import { MobileColorPicker } from '../color-picker/MobileColorPicker';
import { ConfigContext } from '../config-provider/ConfigProvider';
import { InputNumber } from '../input-number/InputNumber';
import { MobileActionRow } from '../mobile-action-row/MobileActionRow';
const GRADIENT_TYPES: readonly GradientType[] = ['linear', 'radial', 'angular', 'diamond'];
const NUMBER_CLASS = 'univer-w-full [&_input]:!univer-h-12 [&_input]:!univer-text-base';
export function MobileGradientColorPicker({ value = { type: 'linear', stops: [{ color: '#ffffff', offset: 0 }, { color: '#000000', offset: 100 }], angle: 0 }, onChange, types = GRADIENT_TYPES, className }: IGradientColorPickerProps) {
const { locale } = useContext(ConfigContext);
const [selectedIndex, setSelectedIndex] = useState(0);
const index = Math.min(selectedIndex, Math.max(0, value.stops.length - 1));
const selectedStop = value.stops[index];
function updateStop(patch: Partial<IGradientStop>) {
onChange?.({ ...value, stops: value.stops.map((stop, stopIndex) => stopIndex === index ? { ...stop, ...patch } : stop) });
}
function addStop() {
const stop = selectedStop ?? { color: '#ffffff', offset: 0 };
const nextOffset = value.stops.filter((item) => item.offset > stop.offset).reduce((offset, item) => Math.min(offset, item.offset), 100);
onChange?.({ ...value, stops: [...value.stops, { ...stop, offset: Math.round((stop.offset + nextOffset) / 2) }] });
setSelectedIndex(value.stops.length);
}
return (
<div className={clsx('univer-grid univer-w-full univer-min-w-0 univer-gap-4', className)}>
{types.length > 1 && (
<div className="univer-grid univer-grid-cols-2 univer-gap-2">
{types.map((type) => (
<MobileActionRow
key={type}
title={locale?.GradientColorPicker[type]}
aria-pressed={value.type === type}
className={value.type === type ? '!univer-bg-primary-50 !univer-text-primary-600' : undefined}
onClick={() => onChange?.({ ...value, type })}
/>
))}
</div>
)}
{(value.type === 'linear' || value.type === 'angular') && (
<label className="univer-grid univer-gap-2 univer-text-base">
{locale?.GradientColorPicker.angle}
<InputNumber className={NUMBER_CLASS} controls={false} value={value.angle ?? 0} min={0} max={360} onChange={(angle) => onChange?.({ ...value, angle: angle ?? 0 })} />
</label>
)}
<div className="univer-flex univer-flex-wrap univer-gap-2">
{value.stops.map((stop, stopIndex) => (
<button
key={stopIndex}
type="button"
aria-label={`${locale?.GradientColorPicker.offset} ${stop.offset}%`}
aria-pressed={index === stopIndex}
className={clsx(`
univer-flex univer-h-12 univer-min-w-12 univer-appearance-none univer-items-center
univer-justify-center univer-gap-2 univer-rounded-lg univer-border univer-border-solid
univer-bg-transparent univer-px-2 univer-text-sm
`, index === stopIndex
? 'univer-border-primary-600 univer-text-primary-600'
: 'univer-border-gray-200')}
onClick={() => setSelectedIndex(stopIndex)}
>
<span className="univer-size-6 univer-shrink-0 univer-rounded" style={{ backgroundColor: stop.color, opacity: stop.opacity ?? 1 }} />
{`${stop.offset}%`}
</button>
))}
<Button className="univer-size-12" aria-label={locale?.Accessibility.increment} onClick={addStop}><IncreaseIcon /></Button>
<Button
className="univer-size-12"
aria-label={locale?.GradientColorPicker.delete}
disabled={value.stops.length <= 2}
onClick={() => {
onChange?.({ ...value, stops: value.stops.filter((_, stopIndex) => stopIndex !== index) });
setSelectedIndex(0);
}}
>
<DeleteIcon />
</Button>
</div>
{selectedStop && (
<>
<div className="univer-grid univer-grid-cols-2 univer-gap-3">
<label className="univer-grid univer-gap-2 univer-text-base">
{locale?.GradientColorPicker.offset}
<InputNumber className={NUMBER_CLASS} controls={false} value={selectedStop.offset} min={0} max={100} onChange={(offset) => updateStop({ offset: offset ?? 0 })} />
</label>
<label className="univer-grid univer-gap-2 univer-text-base">
{locale?.GradientColorPicker.transparency}
<InputNumber className={NUMBER_CLASS} controls={false} value={Math.round((1 - (selectedStop.opacity ?? 1)) * 100)} min={0} max={100} onChange={(transparency) => updateStop({ opacity: 1 - (transparency ?? 0) / 100 })} />
</label>
</div>
<MobileColorPicker value={selectedStop.color} onChange={(color) => updateStop({ color })} />
</>
)}
</div>
);
}

View File

@@ -0,0 +1,329 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* @vitest-environment jsdom
*/
import type { IGradientValue } from '../GradientColorPicker';
import { cleanup, fireEvent, render, waitFor } from '@testing-library/react';
import { useState } from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import enUS from '../../../locale/en-US';
import { ConfigProvider } from '../../config-provider/ConfigProvider';
import { GradientColorPicker } from '../GradientColorPicker';
import { MobileGradientColorPicker } from '../MobileGradientColorPicker';
import '@testing-library/jest-dom/vitest';
beforeEach(() => vi.stubGlobal('CSS', { ...globalThis.CSS, supports: () => false }));
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
const defaultValue: IGradientValue = {
type: 'linear',
stops: [
{ color: '#ffffff', offset: 0 },
{ color: '#000000', offset: 100 },
],
angle: 0,
};
describe('mobile GradientColorPicker', () => {
it('updates the selected stop, adds and removes stops without desktop hover controls', () => {
const onChange = vi.fn();
function Editor() {
const [value, setValue] = useState(defaultValue);
return (
<ConfigProvider mountContainer={document.body} locale={enUS.design}>
<MobileGradientColorPicker
value={value}
types={['linear']}
onChange={(next) => {
setValue(next);
onChange(next);
}}
/>
</ConfigProvider>
);
}
const view = render(<Editor />);
expect(view.queryByText(enUS.design.GradientColorPicker.radial)).toBeNull();
fireEvent.click(view.getByRole('button', { name: enUS.design.Accessibility.increment }));
expect(onChange).toHaveBeenLastCalledWith(expect.objectContaining({ stops: [defaultValue.stops[0], defaultValue.stops[1], { ...defaultValue.stops[0], offset: 50 }] }));
expect(view.getByRole('button', { name: `${enUS.design.GradientColorPicker.offset} 50%` })).toHaveAttribute('aria-pressed', 'true');
fireEvent.change(view.getByLabelText(enUS.design.GradientColorPicker.angle), { target: { value: '180' } });
fireEvent.change(view.getByLabelText(enUS.design.GradientColorPicker.offset), { target: { value: '60' } });
fireEvent.change(view.getByLabelText(enUS.design.GradientColorPicker.transparency), { target: { value: '40' } });
expect(onChange).toHaveBeenLastCalledWith(expect.objectContaining({ angle: 180, stops: expect.arrayContaining([{ color: '#ffffff', offset: 60, opacity: 0.6 }]) }));
fireEvent.click(view.getByRole('button', { name: enUS.design.GradientColorPicker.delete }));
expect(onChange).toHaveBeenLastCalledWith({ ...defaultValue, angle: 180 });
expect(view.getByRole('button', { name: enUS.design.GradientColorPicker.delete })).toBeDisabled();
});
it('supports every registered gradient type and controlled value updates', () => {
const onChange = vi.fn();
const view = render(<ConfigProvider mountContainer={document.body} locale={enUS.design}><MobileGradientColorPicker value={defaultValue} onChange={onChange} /></ConfigProvider>);
for (const type of ['linear', 'radial', 'angular', 'diamond'] as const) {
fireEvent.click(view.getByRole('button', { name: enUS.design.GradientColorPicker[type] }));
expect(onChange).toHaveBeenLastCalledWith({ ...defaultValue, type });
}
view.rerender(<ConfigProvider mountContainer={document.body} locale={enUS.design}><MobileGradientColorPicker value={{ ...defaultValue, type: 'radial' }} onChange={onChange} /></ConfigProvider>);
expect(view.getByRole('button', { name: enUS.design.GradientColorPicker.radial })).toHaveAttribute('aria-pressed', 'true');
expect(view.queryByLabelText(enUS.design.GradientColorPicker.angle)).toBeNull();
});
});
describe('GradientColorPicker', () => {
it('renders correctly', () => {
const { container } = render(<GradientColorPicker value={defaultValue} />);
expect(container).toBeDefined();
// Check if segmented items are rendered - they are buttons inside the segmented container
const segmented = container.querySelector('[data-u-comp="segmented"]');
expect(segmented?.querySelectorAll('button').length).toBe(4);
});
it('should call onChange when type changes', () => {
const onChange = vi.fn();
const { container } = render(<GradientColorPicker value={defaultValue} onChange={onChange} />);
const segmented = container.querySelector('[data-u-comp="segmented"]');
const items = segmented?.querySelectorAll('button');
// Click 'radial' (index 1)
if (items && items[1]) {
fireEvent.click(items[1]);
}
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({
type: 'radial',
}));
});
it('should call onChange when offset changes', () => {
const onChange = vi.fn();
const { container } = render(<GradientColorPicker value={defaultValue} onChange={onChange} />);
const inputs = container.querySelectorAll('input');
const offsetInput = inputs[1]; // Second input is offset
fireEvent.change(offsetInput, { target: { value: '50' } });
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({
stops: expect.arrayContaining([
expect.objectContaining({ offset: 50 }),
]),
}));
});
it('should call onChange when angle changes', () => {
const onChange = vi.fn();
const { container } = render(<GradientColorPicker value={defaultValue} onChange={onChange} />);
const inputs = container.querySelectorAll('input');
const angleInput = inputs[0]; // First input is angle for linear
fireEvent.change(angleInput, { target: { value: '180' } });
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({
angle: 180,
}));
});
it('should call onChange when stop transparency changes', () => {
const onChange = vi.fn();
const { container } = render(<GradientColorPicker value={defaultValue} onChange={onChange} />);
const inputs = container.querySelectorAll('input');
const transparencyInput = inputs[2]; // Third input is stop transparency for linear
fireEvent.change(transparencyInput, { target: { value: '40' } });
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({
stops: expect.arrayContaining([
expect.objectContaining({ opacity: 0.6 }),
]),
}));
});
it('should select stop when clicked', () => {
const { container } = render(<GradientColorPicker value={defaultValue} />);
const stops = container.querySelectorAll('[data-u-comp="gradient-color-picker-stop"]');
// Click the second stop
fireEvent.click(stops[1]);
// The second stop should be selected
expect(stops[1]).toHaveAttribute('data-selected', 'true');
});
it('should call onChange when a stop is removed', () => {
const onChange = vi.fn();
const valueWithThreeStops: IGradientValue = {
...defaultValue,
stops: [
{ color: '#ffffff', offset: 0 },
{ color: '#ff0000', offset: 50 },
{ color: '#000000', offset: 100 },
],
};
const { container } = render(<GradientColorPicker value={valueWithThreeStops} onChange={onChange} />);
const deleteButton = container.querySelector('[data-u-comp="gradient-color-picker-delete"]');
if (deleteButton) {
fireEvent.click(deleteButton);
}
expect(onChange).toHaveBeenCalled();
const calledValue = onChange.mock.calls[0][0] as IGradientValue;
expect(calledValue.stops.length).toBe(2);
});
it('should disable delete button when only 2 stops remain', () => {
const { container } = render(<GradientColorPicker value={defaultValue} />);
const deleteButton = container.querySelector('[data-u-comp="gradient-color-picker-delete"]');
expect(deleteButton).toBeDisabled();
});
it('should call onChange when clicking the bar to add a stop', () => {
const onChange = vi.fn();
const { container } = render(<GradientColorPicker value={defaultValue} onChange={onChange} />);
const bar = container.querySelector('[data-u-comp="gradient-color-picker-bar"]');
if (bar) {
// Mock getBoundingClientRect for the bar
bar.getBoundingClientRect = vi.fn(() => ({
left: 0,
width: 100,
top: 0,
height: 10,
bottom: 10,
right: 100,
x: 0,
y: 0,
toJSON: () => {},
}));
fireEvent.click(bar, { clientX: 50 });
}
expect(onChange).toHaveBeenCalled();
const calledValue = onChange.mock.calls[0][0] as IGradientValue;
expect(calledValue.stops.length).toBe(3);
expect(calledValue.stops.some((s) => s.offset === 50)).toBe(true);
});
it('should ignore null offset change from offset input', () => {
const onChange = vi.fn();
const { container } = render(<GradientColorPicker value={defaultValue} onChange={onChange} />);
const inputs = container.querySelectorAll('input');
const offsetInput = inputs[1] as HTMLInputElement;
fireEvent.change(offsetInput, { target: { value: '' } });
expect(onChange).not.toHaveBeenCalled();
});
it('should update stop color via nested color picker presets', () => {
const onChange = vi.fn();
const { container } = render(<GradientColorPicker value={defaultValue} onChange={onChange} />);
const presetButton = container.querySelector('[data-u-comp="color-picker-presets"] button') as HTMLButtonElement;
expect(presetButton).toBeTruthy();
fireEvent.click(presetButton);
expect(onChange).toHaveBeenCalled();
const next = onChange.mock.calls[0][0] as IGradientValue;
expect(next.stops[0].color).toMatch(/^#/);
});
it('should support radial/angular/diamond and fallback preview background', () => {
const { container, rerender } = render(<GradientColorPicker value={{ ...defaultValue, type: 'radial' }} />);
const preview = container.querySelector('[data-u-comp="gradient-color-picker-preview"]') as HTMLDivElement;
expect(preview.style.background).toContain('radial-gradient');
rerender(<GradientColorPicker value={{ ...defaultValue, type: 'angular' }} />);
expect((container.querySelector('[data-u-comp="gradient-color-picker-preview"]') as HTMLDivElement).style.background).toContain('conic-gradient');
rerender(<GradientColorPicker value={{ ...defaultValue, type: 'diamond' }} />);
expect((container.querySelector('[data-u-comp="gradient-color-picker-preview"]') as HTMLDivElement).style.background).toContain('radial-gradient');
rerender(<GradientColorPicker value={{ ...defaultValue, type: 'unexpected' as unknown as IGradientValue['type'] }} />);
expect((container.querySelector('[data-u-comp="gradient-color-picker-preview"]') as HTMLDivElement).style.background).toContain('linear-gradient');
});
it('should drag a stop and emit new offset', () => {
const onChange = vi.fn();
const { container } = render(<GradientColorPicker value={defaultValue} onChange={onChange} />);
const bar = container.querySelector('[data-u-comp="gradient-color-picker-bar"]') as HTMLDivElement;
const stop = container.querySelector('[data-u-comp="gradient-color-picker-stop"]') as HTMLDivElement;
bar.getBoundingClientRect = vi.fn(() => ({
left: 0,
width: 100,
top: 0,
height: 10,
bottom: 10,
right: 100,
x: 0,
y: 0,
toJSON: () => {},
}));
fireEvent.pointerDown(stop, { clientX: 0 });
fireEvent.pointerMove(window, { clientX: 60 });
fireEvent.pointerUp(window);
expect(onChange).toHaveBeenCalled();
const values = onChange.mock.calls.map((call) => call[0] as IGradientValue);
expect(values.some((v) => v.stops[0].offset === 60)).toBe(true);
});
it('should not remove stop when there are only two stops', () => {
const onChange = vi.fn();
const { container } = render(<GradientColorPicker value={defaultValue} onChange={onChange} />);
const deleteButton = container.querySelector('[data-u-comp="gradient-color-picker-delete"]') as HTMLButtonElement;
expect(deleteButton).toBeDisabled();
fireEvent.click(deleteButton);
expect(onChange).not.toHaveBeenCalled();
});
it('should adjust selectedIndex when stops are reduced externally', async () => {
const valueWithThreeStops: IGradientValue = {
...defaultValue,
stops: [
{ color: '#ffffff', offset: 0 },
{ color: '#ff0000', offset: 50 },
{ color: '#000000', offset: 100 },
],
};
const { container, rerender } = render(<GradientColorPicker value={valueWithThreeStops} />);
// Select the third stop (index 2)
const stops = container.querySelectorAll('[data-u-comp="gradient-color-picker-stop"]');
fireEvent.click(stops[2]);
expect(stops[2]).toHaveAttribute('data-selected', 'true');
// Externally reduce stops to 2
rerender(<GradientColorPicker value={defaultValue} />);
// selectedIndex should be clamped to the last valid index (1)
await waitFor(() => {
const updatedStops = container.querySelectorAll('[data-u-comp="gradient-color-picker-stop"]');
expect(updatedStops[1]).toHaveAttribute('data-selected', 'true');
expect(updatedStops[0]).toHaveAttribute('data-selected', 'false');
});
});
});

View File

@@ -0,0 +1,70 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { Meta } from '@storybook/react';
import { borderClassName } from '../../helper/class-utilities';
import { clsx } from '../../helper/clsx';
import { HoverCard } from './HoverCard';
const meta: Meta<typeof HoverCard> = {
title: 'Components / HoverCard',
component: HoverCard,
parameters: {
layout: 'centered',
},
tags: ['autodocs'],
};
export default meta;
export const Playground = {
render() {
return (
<div className="univer-relative">
<HoverCard
overlay={(
<div className="univer-text-primary-500">
Hello Univer
<HoverCard
openDelay={0}
overlay={(
<div className="univer-text-cyan-700">
<div>Nested Content</div>
<div>Nested Content</div>
<div>Nested Content</div>
<div>Nested Content</div>
</div>
)}
>
<a>Nested Hover Card</a>
</HoverCard>
</div>
)}
>
<a
className={clsx(`
univer-cursor-pointer univer-rounded-lg univer-px-4 univer-py-2 univer-transition-all
hover:univer-bg-gray-100
dark:hover:!univer-bg-gray-700
`, borderClassName)}
>
Click me
</a>
</HoverCard>
</div>
);
},
};

View File

@@ -0,0 +1,70 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { ComponentProps, ReactNode } from 'react';
import { useState } from 'react';
import { HoverCardContent, HoverCardPortal, HoverCardPrimitive, HoverCardTrigger } from './HoverCardPrimitive';
export interface IHoverCardProps extends ComponentProps<typeof HoverCardContent> {
children: ReactNode;
overlay: ReactNode;
disabled?: boolean;
/**
* Delay in milliseconds before the dropdown opens.
* @default 200
*/
openDelay?: number;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}
export function HoverCard(props: IHoverCardProps) {
const {
children,
overlay,
disabled,
openDelay = 200,
open: controlledOpen,
onOpenChange: controlledOnOpenChange,
...restProps
} = props;
const [uncontrolledOpen, setUncontrolledOpen] = useState(false);
const isControlled = controlledOpen !== undefined;
const open = isControlled ? controlledOpen : uncontrolledOpen;
function handleChangeOpen(newOpen: boolean) {
if (disabled) return;
if (!isControlled) {
setUncontrolledOpen(newOpen);
}
controlledOnOpenChange?.(newOpen);
}
return (
<HoverCardPrimitive open={open} onOpenChange={handleChangeOpen} openDelay={openDelay}>
<HoverCardTrigger asChild>{children}</HoverCardTrigger>
<HoverCardPortal>
<HoverCardContent {...restProps}>
{overlay}
</HoverCardContent>
</HoverCardPortal>
</HoverCardPrimitive>
);
}

View File

@@ -0,0 +1,59 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { ComponentPropsWithoutRef, ElementRef } from 'react';
import { Content, Portal, Root, Trigger } from '@radix-ui/react-hover-card';
import { forwardRef } from 'react';
import { borderClassName } from '../../helper/class-utilities';
import { clsx } from '../../helper/clsx';
const HoverCardPrimitive = Root;
const HoverCardPortal = Portal;
const HoverCardTrigger = Trigger;
const HoverCardContent = forwardRef<
ElementRef<typeof Content>,
ComponentPropsWithoutRef<typeof Content>
>(({ className, align = 'center', sideOffset = 4, ...props }, ref) => (
<Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={clsx(
`
data-[state=open]:univer-animate-in data-[state=open]:univer-fade-in-0 data-[state=open]:univer-zoom-in-95
data-[state=closed]:univer-animate-out data-[state=closed]:univer-fade-out-0
data-[state=closed]:univer-zoom-out-95
data-[side=bottom]:univer-slide-in-from-top-2
data-[side=left]:univer-slide-in-from-right-2
data-[side=right]:univer-slide-in-from-left-2
data-[side=top]:univer-slide-in-from-bottom-2
univer-z-[1080] univer-w-64 univer-origin-[--radix-hover-card-content-transform-origin] univer-rounded-md
univer-bg-gray-0 univer-text-gray-900 univer-shadow-md univer-outline-none
dark:!univer-bg-gray-900 dark:!univer-text-gray-0
`,
borderClassName,
className
)}
{...props}
/>
));
HoverCardContent.displayName = Content.displayName;
export { HoverCardContent, HoverCardPortal, HoverCardPrimitive, HoverCardTrigger };

Some files were not shown because too many files have changed in this diff Show More