feat(component): checkbox (#4665)

This commit is contained in:
Peng Xiao
2023-10-20 11:46:44 +08:00
committed by GitHub
parent 890905ed0e
commit 817463c40e
7 changed files with 254 additions and 5 deletions

View File

@@ -2,6 +2,7 @@ export * from './components/list-skeleton';
export * from './styles';
export * from './ui/breadcrumbs';
export * from './ui/button';
export * from './ui/checkbox';
export * from './ui/empty';
export * from './ui/input';
export * from './ui/layout';

View File

@@ -0,0 +1,100 @@
// components/checkbox.tsx
import clsx from 'clsx';
import { type HTMLAttributes, useCallback, useEffect, useRef } from 'react';
import * as icons from './icons';
import * as styles from './index.css';
type CheckboxProps = Omit<HTMLAttributes<HTMLInputElement>, 'onChange'> & {
checked: boolean;
onChange: (
event: React.ChangeEvent<HTMLInputElement>,
checked: boolean
) => void;
disabled?: boolean;
intermediate?: boolean;
};
export const Checkbox = ({
checked,
onChange,
intermediate,
disabled,
...otherProps
}: CheckboxProps) => {
const inputRef = useRef<HTMLInputElement>(null);
const handleChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
const newChecked = event.target.checked;
onChange(event, newChecked);
const inputElement = inputRef.current;
if (newChecked && inputElement) {
playCheckAnimation(inputElement.parentElement as Element).catch(
console.error
);
}
},
[onChange]
);
useEffect(() => {
if (inputRef.current) {
inputRef.current.indeterminate = !!intermediate;
}
}, [intermediate]);
const icon = intermediate
? icons.intermediate
: checked
? icons.checked
: icons.unchecked;
return (
<div
className={clsx(styles.root, disabled && styles.disabled)}
{...otherProps}
>
{icon}
<input
ref={inputRef}
data-testid="affine-checkbox"
className={clsx(styles.input)}
type="checkbox"
value={checked ? 'on' : 'off'}
checked={checked}
onChange={handleChange}
/>
</div>
);
};
export const playCheckAnimation = async (refElement: Element) => {
const sparkingEl = document.createElement('div');
sparkingEl.classList.add('affine-check-animation');
sparkingEl.style.cssText = `
position: absolute;
width: 1em;
height: 1em;
border-radius: 50%;
font-size: inherit;
`;
refElement.appendChild(sparkingEl);
await sparkingEl.animate(
[
{
offset: 0.5,
boxShadow:
'0 -18px 0 -8px #1e96eb, 16px -8px 0 -8px #1e96eb, 16px 8px 0 -8px #1e96eb, 0 18px 0 -8px #1e96eb, -16px 8px 0 -8px #1e96eb, -16px -8px 0 -8px #1e96eb',
},
{
offset: 1,
boxShadow:
'0 -32px 0 -10px transparent, 32px -16px 0 -10px transparent, 32px 16px 0 -10px transparent, 0 36px 0 -10px transparent, -32px 16px 0 -10px transparent, -32px -16px 0 -10px transparent',
},
],
{ duration: 500, easing: 'ease', fill: 'forwards' }
).finished;
sparkingEl.remove();
};

View File

@@ -0,0 +1,52 @@
const unchecked = (
<svg
xmlns="http://www.w3.org/2000/svg"
width="1em"
height="1em"
viewBox="0 0 24 24"
fill="none"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M6 3.25C4.48122 3.25 3.25 4.48122 3.25 6V18C3.25 19.5188 4.48122 20.75 6 20.75H18C19.5188 20.75 20.75 19.5188 20.75 18V6C20.75 4.48122 19.5188 3.25 18 3.25H6ZM4.75 6C4.75 5.30964 5.30964 4.75 6 4.75H18C18.6904 4.75 19.25 5.30964 19.25 6V18C19.25 18.6904 18.6904 19.25 18 19.25H6C5.30964 19.25 4.75 18.6904 4.75 18V6Z"
fill="var(--affine-icon-color)"
/>
</svg>
);
const checked = (
<svg
xmlns="http://www.w3.org/2000/svg"
width="1em"
height="1em"
viewBox="0 0 24 24"
fill="none"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M3.25 6C3.25 4.48122 4.48122 3.25 6 3.25H18C19.5188 3.25 20.75 4.48122 20.75 6V18C20.75 19.5188 19.5188 20.75 18 20.75H6C4.48122 20.75 3.25 19.5188 3.25 18V6ZM16.5303 9.53033C16.8232 9.23744 16.8232 8.76256 16.5303 8.46967C16.2374 8.17678 15.7626 8.17678 15.4697 8.46967L10.5 13.4393L9.03033 11.9697C8.73744 11.6768 8.26256 11.6768 7.96967 11.9697C7.67678 12.2626 7.67678 12.7374 7.96967 13.0303L9.96967 15.0303C10.2626 15.3232 10.7374 15.3232 11.0303 15.0303L16.5303 9.53033Z"
fill="var(--affine-blue-600)"
/>
</svg>
);
const intermediate = (
<svg
xmlns="http://www.w3.org/2000/svg"
width="1em"
height="1em"
viewBox="0 0 24 24"
fill="none"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M6 3.25C4.48122 3.25 3.25 4.48122 3.25 6V18C3.25 19.5188 4.48122 20.75 6 20.75H18C19.5188 20.75 20.75 19.5188 20.75 18V6C20.75 4.48122 19.5188 3.25 18 3.25H6ZM8.54 11.25C8.12579 11.25 7.79 11.5858 7.79 12C7.79 12.4142 8.12579 12.75 8.54 12.75H15.54C15.9542 12.75 16.29 12.4142 16.29 12C16.29 11.5858 15.9542 11.25 15.54 11.25H8.54Z"
fill="var(--affine-icon-color)"
/>
</svg>
);
export { checked, intermediate, unchecked };

View File

@@ -0,0 +1,28 @@
import { style } from '@vanilla-extract/css';
export const root = style({
display: 'inline-flex',
alignItems: 'center',
position: 'relative',
':hover': {
opacity: 0.8,
},
':active': {
opacity: 0.9,
},
});
export const disabled = style({
opacity: 0.5,
pointerEvents: 'none',
});
export const input = style({
opacity: 0,
position: 'absolute',
width: '1em',
height: '1em',
inset: 0,
cursor: 'pointer',
fontSize: 'inherit',
});

View File

@@ -0,0 +1 @@
export * from './checkbox';

View File

@@ -1,39 +0,0 @@
import { expect } from '@storybook/jest';
import type { Meta, StoryFn } from '@storybook/react';
import { userEvent, within } from '@storybook/testing-library';
import { Input } from '.';
export default {
title: 'AFFiNE/Input',
component: Input,
} satisfies Meta<typeof Input>;
export const Basic: StoryFn<typeof Input> = () => {
return <Input data-testid="test-input" defaultValue="test" />;
};
Basic.play = async ({ canvasElement }) => {
const element = within(canvasElement);
const item = element.getByTestId('test-input') as HTMLInputElement;
expect(item).toBeTruthy();
expect(item.value).toBe('test');
userEvent.clear(item);
userEvent.type(item, 'test 2');
expect(item.value).toBe('test 2');
};
export const DynamicHeight: StoryFn<typeof Input> = () => {
return <Input width={200} data-testid="test-input" />;
};
DynamicHeight.play = async ({ canvasElement }) => {
const element = within(canvasElement);
const item = element.getByTestId('test-input') as HTMLInputElement;
expect(item).toBeTruthy();
expect(item.getBoundingClientRect().width).toBe(200);
};
export const NoBorder: StoryFn<typeof Input> = () => {
return <Input noBorder={true} data-testid="test-input" />;
};