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
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:
@@ -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>
|
||||
</>
|
||||
);
|
||||
},
|
||||
};
|
||||
96
packages/design/src/components/accordion/Accordion.tsx
Normal file
96
packages/design/src/components/accordion/Accordion.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
23
packages/design/src/components/action-row/ActionRow.tsx
Normal file
23
packages/design/src/components/action-row/ActionRow.tsx
Normal 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} />;
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
63
packages/design/src/components/avatar/Avatar.stories.tsx
Normal file
63
packages/design/src/components/avatar/Avatar.stories.tsx
Normal 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" />;
|
||||
},
|
||||
};
|
||||
143
packages/design/src/components/avatar/Avatar.tsx
Normal file
143
packages/design/src/components/avatar/Avatar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
42
packages/design/src/components/badge/Badge.stories.tsx
Normal file
42
packages/design/src/components/badge/Badge.stories.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
},
|
||||
};
|
||||
67
packages/design/src/components/badge/Badge.tsx
Normal file
67
packages/design/src/components/badge/Badge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
79
packages/design/src/components/button/Button.stories.tsx
Normal file
79
packages/design/src/components/button/Button.stories.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
},
|
||||
};
|
||||
110
packages/design/src/components/button/Button.tsx
Normal file
110
packages/design/src/components/button/Button.tsx
Normal 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';
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
},
|
||||
};
|
||||
73
packages/design/src/components/button/ButtonGroup.tsx
Normal file
73
packages/design/src/components/button/ButtonGroup.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
57
packages/design/src/components/button/StateIconButton.tsx
Normal file
57
packages/design/src/components/button/StateIconButton.tsx
Normal 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
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
48
packages/design/src/components/calendar/Calendar.stories.tsx
Normal file
48
packages/design/src/components/calendar/Calendar.stories.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
},
|
||||
};
|
||||
340
packages/design/src/components/calendar/Calendar.tsx
Normal file
340
packages/design/src/components/calendar/Calendar.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
157
packages/design/src/components/calendar/__tests__/index.spec.tsx
Normal file
157
packages/design/src/components/calendar/__tests__/index.spec.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -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} />;
|
||||
},
|
||||
};
|
||||
171
packages/design/src/components/cascader-list/CascaderList.tsx
Normal file
171
packages/design/src/components/cascader-list/CascaderList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
79
packages/design/src/components/checkbox/Checkbox.stories.tsx
Normal file
79
packages/design/src/components/checkbox/Checkbox.stories.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
},
|
||||
};
|
||||
139
packages/design/src/components/checkbox/Checkbox.tsx
Normal file
139
packages/design/src/components/checkbox/Checkbox.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
133
packages/design/src/components/color-picker/AlphaSlider.tsx
Normal file
133
packages/design/src/components/color-picker/AlphaSlider.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
208
packages/design/src/components/color-picker/ColorInput.tsx
Normal file
208
packages/design/src/components/color-picker/ColorInput.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
},
|
||||
};
|
||||
196
packages/design/src/components/color-picker/ColorPicker.tsx
Normal file
196
packages/design/src/components/color-picker/ColorPicker.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
64
packages/design/src/components/color-picker/ColorPresets.tsx
Normal file
64
packages/design/src/components/color-picker/ColorPresets.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
139
packages/design/src/components/color-picker/ColorSpectrum.tsx
Normal file
139
packages/design/src/components/color-picker/ColorSpectrum.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
131
packages/design/src/components/color-picker/HueSlider.tsx
Normal file
131
packages/design/src/components/color-picker/HueSlider.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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%)');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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]
|
||||
}
|
||||
});
|
||||
});
|
||||
133
packages/design/src/components/color-picker/color-conversion.ts
Normal file
133
packages/design/src/components/color-picker/color-conversion.ts
Normal 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,
|
||||
];
|
||||
};
|
||||
23
packages/design/src/components/color-picker/presets.ts
Normal file
23
packages/design/src/components/color-picker/presets.ts
Normal 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'],
|
||||
];
|
||||
138
packages/design/src/components/command/Command.tsx
Normal file
138
packages/design/src/components/command/Command.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
55
packages/design/src/components/confirm/Confirm.stories.tsx
Normal file
55
packages/design/src/components/confirm/Confirm.stories.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
},
|
||||
};
|
||||
110
packages/design/src/components/confirm/Confirm.tsx
Normal file
110
packages/design/src/components/confirm/Confirm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
58
packages/design/src/components/confirm/MobileConfirm.tsx
Normal file
58
packages/design/src/components/confirm/MobileConfirm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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} />
|
||||
);
|
||||
},
|
||||
};
|
||||
83
packages/design/src/components/date-picker/DatePicker.tsx
Normal file
83
packages/design/src/components/date-picker/DatePicker.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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} />
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
17
packages/design/src/components/date-range-picker/index.ts
Normal file
17
packages/design/src/components/date-range-picker/index.ts
Normal 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';
|
||||
74
packages/design/src/components/dialog/Dialog.stories.tsx
Normal file
74
packages/design/src/components/dialog/Dialog.stories.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
},
|
||||
};
|
||||
404
packages/design/src/components/dialog/Dialog.tsx
Normal file
404
packages/design/src/components/dialog/Dialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
185
packages/design/src/components/dialog/DialogPrimitive.tsx
Normal file
185
packages/design/src/components/dialog/DialogPrimitive.tsx
Normal 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,
|
||||
};
|
||||
141
packages/design/src/components/dialog/MobileDialog.tsx
Normal file
141
packages/design/src/components/dialog/MobileDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
162
packages/design/src/components/dialog/__tests__/index.spec.tsx
Normal file
162
packages/design/src/components/dialog/__tests__/index.spec.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
159
packages/design/src/components/dialog/__tests__/logic.spec.tsx
Normal file
159
packages/design/src/components/dialog/__tests__/logic.spec.tsx
Normal 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('');
|
||||
});
|
||||
});
|
||||
@@ -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]}
|
||||
/>
|
||||
);
|
||||
},
|
||||
};
|
||||
298
packages/design/src/components/draggable-list/DraggableList.tsx
Normal file
298
packages/design/src/components/draggable-list/DraggableList.tsx
Normal 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
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
18
packages/design/src/components/draggable-list/index.ts
Normal file
18
packages/design/src/components/draggable-list/index.ts
Normal 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';
|
||||
227
packages/design/src/components/dropdown-menu/DropdownMenu.tsx
Normal file
227
packages/design/src/components/dropdown-menu/DropdownMenu.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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
|
||||
`);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
70
packages/design/src/components/dropdown/Dropdown.stories.tsx
Normal file
70
packages/design/src/components/dropdown/Dropdown.stories.tsx
Normal 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>
|
||||
);
|
||||
},
|
||||
};
|
||||
69
packages/design/src/components/dropdown/Dropdown.tsx
Normal file
69
packages/design/src/components/dropdown/Dropdown.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
62
packages/design/src/components/dropdown/MobileDropdown.tsx
Normal file
62
packages/design/src/components/dropdown/MobileDropdown.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
79
packages/design/src/components/dropdown/PopoverPrimitive.tsx
Normal file
79
packages/design/src/components/dropdown/PopoverPrimitive.tsx
Normal 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 };
|
||||
153
packages/design/src/components/dropdown/__tests__/index.spec.tsx
Normal file
153
packages/design/src/components/dropdown/__tests__/index.spec.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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 />
|
||||
</>
|
||||
);
|
||||
},
|
||||
};
|
||||
128
packages/design/src/components/form-layout/FormLayout.tsx
Normal file
128
packages/design/src/components/form-layout/FormLayout.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
18
packages/design/src/components/form-layout/index.ts
Normal file
18
packages/design/src/components/form-layout/index.ts
Normal 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';
|
||||
48
packages/design/src/components/gallery/Gallery.stories.tsx
Normal file
48
packages/design/src/components/gallery/Gallery.stories.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
},
|
||||
};
|
||||
295
packages/design/src/components/gallery/Gallery.tsx
Normal file
295
packages/design/src/components/gallery/Gallery.tsx
Normal 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
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
},
|
||||
};
|
||||
70
packages/design/src/components/hover-card/HoverCard.tsx
Normal file
70
packages/design/src/components/hover-card/HoverCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user