@boostdev/design-system-components 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +72 -0
- package/README.md +396 -0
- package/dist/index.cjs +2273 -0
- package/dist/index.css +2543 -0
- package/dist/index.d.cts +453 -0
- package/dist/index.d.ts +453 -0
- package/dist/index.js +2221 -0
- package/package.json +143 -0
- package/src/components/interaction/Button/Button.module.css +136 -0
- package/src/components/interaction/Button/Button.spec.tsx +50 -0
- package/src/components/interaction/Button/Button.stories.tsx +43 -0
- package/src/components/interaction/Button/Button.tsx +68 -0
- package/src/components/interaction/Button/index.ts +1 -0
- package/src/components/interaction/Command/Command.module.css +128 -0
- package/src/components/interaction/Command/Command.spec.tsx +60 -0
- package/src/components/interaction/Command/Command.stories.tsx +35 -0
- package/src/components/interaction/Command/Command.tsx +161 -0
- package/src/components/interaction/Command/index.ts +2 -0
- package/src/components/interaction/Dialog/Dialog.module.css +39 -0
- package/src/components/interaction/Dialog/Dialog.spec.tsx +43 -0
- package/src/components/interaction/Dialog/Dialog.stories.tsx +36 -0
- package/src/components/interaction/Dialog/Dialog.tsx +42 -0
- package/src/components/interaction/Dialog/index.ts +1 -0
- package/src/components/interaction/Drawer/Drawer.module.css +98 -0
- package/src/components/interaction/Drawer/Drawer.spec.tsx +43 -0
- package/src/components/interaction/Drawer/Drawer.stories.tsx +46 -0
- package/src/components/interaction/Drawer/Drawer.tsx +71 -0
- package/src/components/interaction/Drawer/index.ts +1 -0
- package/src/components/interaction/DropdownMenu/DropdownMenu.module.css +68 -0
- package/src/components/interaction/DropdownMenu/DropdownMenu.spec.tsx +74 -0
- package/src/components/interaction/DropdownMenu/DropdownMenu.stories.tsx +68 -0
- package/src/components/interaction/DropdownMenu/DropdownMenu.tsx +137 -0
- package/src/components/interaction/DropdownMenu/index.ts +1 -0
- package/src/components/interaction/Popover/Popover.module.css +39 -0
- package/src/components/interaction/Popover/Popover.spec.tsx +72 -0
- package/src/components/interaction/Popover/Popover.stories.tsx +47 -0
- package/src/components/interaction/Popover/Popover.tsx +78 -0
- package/src/components/interaction/Popover/index.ts +1 -0
- package/src/components/interaction/Rating/Rating.module.css +16 -0
- package/src/components/interaction/Rating/Rating.spec.tsx +30 -0
- package/src/components/interaction/Rating/Rating.stories.tsx +29 -0
- package/src/components/interaction/Rating/Rating.tsx +30 -0
- package/src/components/interaction/Rating/index.ts +1 -0
- package/src/components/interaction/Toast/Toast.module.css +48 -0
- package/src/components/interaction/Toast/Toast.spec.tsx +41 -0
- package/src/components/interaction/Toast/Toast.stories.tsx +57 -0
- package/src/components/interaction/Toast/Toast.tsx +64 -0
- package/src/components/interaction/Toast/index.ts +1 -0
- package/src/components/interaction/form/Checkbox/Checkbox.module.css +61 -0
- package/src/components/interaction/form/Checkbox/Checkbox.spec.tsx +39 -0
- package/src/components/interaction/form/Checkbox/Checkbox.stories.tsx +17 -0
- package/src/components/interaction/form/Checkbox/Checkbox.tsx +39 -0
- package/src/components/interaction/form/Checkbox/index.ts +1 -0
- package/src/components/interaction/form/Combobox/Combobox.module.css +104 -0
- package/src/components/interaction/form/Combobox/Combobox.spec.tsx +81 -0
- package/src/components/interaction/form/Combobox/Combobox.stories.tsx +25 -0
- package/src/components/interaction/form/Combobox/Combobox.tsx +182 -0
- package/src/components/interaction/form/Combobox/index.ts +1 -0
- package/src/components/interaction/form/FileInput/FileInput.module.css +79 -0
- package/src/components/interaction/form/FileInput/FileInput.spec.tsx +53 -0
- package/src/components/interaction/form/FileInput/FileInput.stories.tsx +17 -0
- package/src/components/interaction/form/FileInput/FileInput.tsx +99 -0
- package/src/components/interaction/form/FileInput/index.ts +1 -0
- package/src/components/interaction/form/FormInput/FormInput.module.css +37 -0
- package/src/components/interaction/form/FormInput/FormInput.spec.tsx +43 -0
- package/src/components/interaction/form/FormInput/FormInput.stories.tsx +17 -0
- package/src/components/interaction/form/FormInput/FormInput.tsx +47 -0
- package/src/components/interaction/form/FormInput/index.ts +1 -0
- package/src/components/interaction/form/NumberInput/NumberInput.module.css +78 -0
- package/src/components/interaction/form/NumberInput/NumberInput.spec.tsx +49 -0
- package/src/components/interaction/form/NumberInput/NumberInput.stories.tsx +17 -0
- package/src/components/interaction/form/NumberInput/NumberInput.tsx +106 -0
- package/src/components/interaction/form/NumberInput/index.ts +1 -0
- package/src/components/interaction/form/Radio/Radio.module.css +62 -0
- package/src/components/interaction/form/Radio/Radio.spec.tsx +38 -0
- package/src/components/interaction/form/Radio/Radio.stories.tsx +26 -0
- package/src/components/interaction/form/Radio/Radio.tsx +39 -0
- package/src/components/interaction/form/Radio/index.ts +1 -0
- package/src/components/interaction/form/Select/Select.module.css +64 -0
- package/src/components/interaction/form/Select/Select.spec.tsx +61 -0
- package/src/components/interaction/form/Select/Select.stories.tsx +24 -0
- package/src/components/interaction/form/Select/Select.tsx +72 -0
- package/src/components/interaction/form/Select/index.ts +1 -0
- package/src/components/interaction/form/Slider/Slider.module.css +99 -0
- package/src/components/interaction/form/Slider/Slider.spec.tsx +53 -0
- package/src/components/interaction/form/Slider/Slider.stories.tsx +18 -0
- package/src/components/interaction/form/Slider/Slider.tsx +71 -0
- package/src/components/interaction/form/Slider/index.ts +1 -0
- package/src/components/interaction/form/Switch/Switch.module.css +114 -0
- package/src/components/interaction/form/Switch/Switch.spec.tsx +48 -0
- package/src/components/interaction/form/Switch/Switch.stories.tsx +31 -0
- package/src/components/interaction/form/Switch/Switch.tsx +54 -0
- package/src/components/interaction/form/Switch/index.ts +1 -0
- package/src/components/interaction/form/Textarea/Textarea.module.css +44 -0
- package/src/components/interaction/form/Textarea/Textarea.spec.tsx +53 -0
- package/src/components/interaction/form/Textarea/Textarea.stories.tsx +18 -0
- package/src/components/interaction/form/Textarea/Textarea.tsx +44 -0
- package/src/components/interaction/form/Textarea/index.ts +1 -0
- package/src/components/interaction/form/atoms/InputContainer.module.css +9 -0
- package/src/components/interaction/form/atoms/InputContainer.tsx +9 -0
- package/src/components/interaction/form/atoms/Label.module.css +10 -0
- package/src/components/interaction/form/atoms/Label.tsx +15 -0
- package/src/components/interaction/form/atoms/Message.module.css +11 -0
- package/src/components/interaction/form/atoms/Message.tsx +17 -0
- package/src/components/layout/ButtonGroup/ButtonGroup.module.css +59 -0
- package/src/components/layout/ButtonGroup/ButtonGroup.spec.tsx +20 -0
- package/src/components/layout/ButtonGroup/ButtonGroup.stories.tsx +28 -0
- package/src/components/layout/ButtonGroup/ButtonGroup.tsx +17 -0
- package/src/components/layout/ButtonGroup/index.ts +1 -0
- package/src/components/layout/Card/Card.module.css +72 -0
- package/src/components/layout/Card/Card.spec.tsx +33 -0
- package/src/components/layout/Card/Card.stories.tsx +32 -0
- package/src/components/layout/Card/Card.tsx +45 -0
- package/src/components/layout/Card/index.ts +1 -0
- package/src/components/layout/IconWrapper/IconWrapper.module.css +24 -0
- package/src/components/layout/IconWrapper/IconWrapper.spec.tsx +19 -0
- package/src/components/layout/IconWrapper/IconWrapper.stories.tsx +22 -0
- package/src/components/layout/IconWrapper/IconWrapper.tsx +14 -0
- package/src/components/layout/IconWrapper/index.ts +1 -0
- package/src/components/layout/SectionHeader/SectionHeader.module.css +75 -0
- package/src/components/layout/SectionHeader/SectionHeader.spec.tsx +31 -0
- package/src/components/layout/SectionHeader/SectionHeader.stories.tsx +21 -0
- package/src/components/layout/SectionHeader/SectionHeader.tsx +32 -0
- package/src/components/layout/SectionHeader/index.ts +1 -0
- package/src/components/ui/Accordion/Accordion.module.css +87 -0
- package/src/components/ui/Accordion/Accordion.spec.tsx +78 -0
- package/src/components/ui/Accordion/Accordion.stories.tsx +34 -0
- package/src/components/ui/Accordion/Accordion.tsx +82 -0
- package/src/components/ui/Accordion/index.ts +1 -0
- package/src/components/ui/Alert/Alert.module.css +91 -0
- package/src/components/ui/Alert/Alert.spec.tsx +63 -0
- package/src/components/ui/Alert/Alert.stories.tsx +53 -0
- package/src/components/ui/Alert/Alert.tsx +54 -0
- package/src/components/ui/Alert/index.ts +1 -0
- package/src/components/ui/Avatar/Avatar.module.css +42 -0
- package/src/components/ui/Avatar/Avatar.spec.tsx +49 -0
- package/src/components/ui/Avatar/Avatar.stories.tsx +44 -0
- package/src/components/ui/Avatar/Avatar.tsx +45 -0
- package/src/components/ui/Avatar/index.ts +1 -0
- package/src/components/ui/Badge/Badge.module.css +46 -0
- package/src/components/ui/Badge/Badge.spec.tsx +19 -0
- package/src/components/ui/Badge/Badge.stories.tsx +29 -0
- package/src/components/ui/Badge/Badge.tsx +13 -0
- package/src/components/ui/Badge/index.ts +1 -0
- package/src/components/ui/Breadcrumb/Breadcrumb.module.css +50 -0
- package/src/components/ui/Breadcrumb/Breadcrumb.spec.tsx +44 -0
- package/src/components/ui/Breadcrumb/Breadcrumb.stories.tsx +48 -0
- package/src/components/ui/Breadcrumb/Breadcrumb.tsx +41 -0
- package/src/components/ui/Breadcrumb/index.ts +1 -0
- package/src/components/ui/Calendar/Calendar.module.css +120 -0
- package/src/components/ui/Calendar/Calendar.spec.tsx +64 -0
- package/src/components/ui/Calendar/Calendar.stories.tsx +59 -0
- package/src/components/ui/Calendar/Calendar.tsx +184 -0
- package/src/components/ui/Calendar/index.ts +1 -0
- package/src/components/ui/Carousel/Carousel.module.css +66 -0
- package/src/components/ui/Carousel/Carousel.spec.tsx +29 -0
- package/src/components/ui/Carousel/Carousel.stories.tsx +30 -0
- package/src/components/ui/Carousel/Carousel.tsx +64 -0
- package/src/components/ui/Carousel/index.ts +1 -0
- package/src/components/ui/DescriptionList/DescriptionList.module.css +43 -0
- package/src/components/ui/DescriptionList/DescriptionList.spec.tsx +31 -0
- package/src/components/ui/DescriptionList/DescriptionList.stories.tsx +21 -0
- package/src/components/ui/DescriptionList/DescriptionList.tsx +30 -0
- package/src/components/ui/DescriptionList/index.ts +1 -0
- package/src/components/ui/Link/Link.module.css +64 -0
- package/src/components/ui/Link/Link.spec.tsx +43 -0
- package/src/components/ui/Link/Link.stories.tsx +55 -0
- package/src/components/ui/Link/Link.tsx +42 -0
- package/src/components/ui/Link/index.ts +1 -0
- package/src/components/ui/Loading/Loading.module.css +33 -0
- package/src/components/ui/Loading/Loading.spec.tsx +19 -0
- package/src/components/ui/Loading/Loading.stories.tsx +27 -0
- package/src/components/ui/Loading/Loading.tsx +15 -0
- package/src/components/ui/Loading/index.ts +1 -0
- package/src/components/ui/NotificationBanner/NotificationBanner.module.css +79 -0
- package/src/components/ui/NotificationBanner/NotificationBanner.spec.tsx +42 -0
- package/src/components/ui/NotificationBanner/NotificationBanner.stories.tsx +30 -0
- package/src/components/ui/NotificationBanner/NotificationBanner.tsx +45 -0
- package/src/components/ui/NotificationBanner/index.ts +1 -0
- package/src/components/ui/Pagination/Pagination.module.css +78 -0
- package/src/components/ui/Pagination/Pagination.spec.tsx +67 -0
- package/src/components/ui/Pagination/Pagination.stories.tsx +40 -0
- package/src/components/ui/Pagination/Pagination.tsx +87 -0
- package/src/components/ui/Pagination/index.ts +1 -0
- package/src/components/ui/Progress/Progress.module.css +51 -0
- package/src/components/ui/Progress/Progress.spec.tsx +55 -0
- package/src/components/ui/Progress/Progress.stories.tsx +30 -0
- package/src/components/ui/Progress/Progress.tsx +43 -0
- package/src/components/ui/Progress/index.ts +1 -0
- package/src/components/ui/ProgressCircle/ProgressCircle.module.css +40 -0
- package/src/components/ui/ProgressCircle/ProgressCircle.spec.tsx +34 -0
- package/src/components/ui/ProgressCircle/ProgressCircle.stories.tsx +18 -0
- package/src/components/ui/ProgressCircle/ProgressCircle.tsx +75 -0
- package/src/components/ui/ProgressCircle/index.ts +1 -0
- package/src/components/ui/Separator/Separator.module.css +23 -0
- package/src/components/ui/Separator/Separator.spec.tsx +30 -0
- package/src/components/ui/Separator/Separator.stories.tsx +40 -0
- package/src/components/ui/Separator/Separator.tsx +21 -0
- package/src/components/ui/Separator/index.ts +1 -0
- package/src/components/ui/Skeleton/Skeleton.module.css +24 -0
- package/src/components/ui/Skeleton/Skeleton.spec.tsx +19 -0
- package/src/components/ui/Skeleton/Skeleton.stories.tsx +25 -0
- package/src/components/ui/Skeleton/Skeleton.tsx +12 -0
- package/src/components/ui/Skeleton/index.ts +1 -0
- package/src/components/ui/SkipLink/SkipLink.module.css +30 -0
- package/src/components/ui/SkipLink/SkipLink.spec.tsx +24 -0
- package/src/components/ui/SkipLink/SkipLink.stories.tsx +24 -0
- package/src/components/ui/SkipLink/SkipLink.tsx +14 -0
- package/src/components/ui/SkipLink/index.ts +1 -0
- package/src/components/ui/Table/Table.module.css +111 -0
- package/src/components/ui/Table/Table.spec.tsx +69 -0
- package/src/components/ui/Table/Table.stories.tsx +53 -0
- package/src/components/ui/Table/Table.tsx +98 -0
- package/src/components/ui/Table/index.ts +1 -0
- package/src/components/ui/Tabs/Tabs.module.css +61 -0
- package/src/components/ui/Tabs/Tabs.spec.tsx +91 -0
- package/src/components/ui/Tabs/Tabs.stories.tsx +59 -0
- package/src/components/ui/Tabs/Tabs.tsx +100 -0
- package/src/components/ui/Tabs/index.ts +1 -0
- package/src/components/ui/Tooltip/Tooltip.module.css +69 -0
- package/src/components/ui/Tooltip/Tooltip.spec.tsx +46 -0
- package/src/components/ui/Tooltip/Tooltip.stories.tsx +69 -0
- package/src/components/ui/Tooltip/Tooltip.tsx +38 -0
- package/src/components/ui/Tooltip/index.ts +1 -0
- package/src/components/ui/Typography/Typography.module.css +41 -0
- package/src/components/ui/Typography/Typography.spec.tsx +39 -0
- package/src/components/ui/Typography/Typography.stories.tsx +31 -0
- package/src/components/ui/Typography/Typography.tsx +28 -0
- package/src/components/ui/Typography/index.ts +1 -0
- package/src/css/index.css +55 -0
- package/src/index.ts +54 -0
- package/src/test/setup.ts +1 -0
- package/src/typings.d.ts +4 -0
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { render, screen } from '@testing-library/react';
|
|
2
|
+
import { Slider } from './Slider';
|
|
3
|
+
|
|
4
|
+
describe('Slider', () => {
|
|
5
|
+
it('renders with a label', () => {
|
|
6
|
+
render(<Slider label="Volume" name="volume" />);
|
|
7
|
+
expect(screen.getByLabelText('Volume')).toBeInTheDocument();
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it('renders a range input', () => {
|
|
11
|
+
render(<Slider label="Volume" name="volume" />);
|
|
12
|
+
expect(screen.getByRole('slider')).toBeInTheDocument();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('sets default min and max', () => {
|
|
16
|
+
render(<Slider label="Volume" name="volume" />);
|
|
17
|
+
const slider = screen.getByRole('slider');
|
|
18
|
+
expect(slider).toHaveAttribute('min', '0');
|
|
19
|
+
expect(slider).toHaveAttribute('max', '100');
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('forwards custom min and max', () => {
|
|
23
|
+
render(<Slider label="Rating" name="rating" min={1} max={10} />);
|
|
24
|
+
const slider = screen.getByRole('slider');
|
|
25
|
+
expect(slider).toHaveAttribute('min', '1');
|
|
26
|
+
expect(slider).toHaveAttribute('max', '10');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('shows the current value when showValue is true', () => {
|
|
30
|
+
render(<Slider label="Volume" name="volume" defaultValue={42} showValue />);
|
|
31
|
+
expect(screen.getByText('42')).toBeInTheDocument();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('does not show value text when showValue is false', () => {
|
|
35
|
+
render(<Slider label="Volume" name="volume" defaultValue={42} />);
|
|
36
|
+
expect(screen.queryByText('42')).not.toBeInTheDocument();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('shows an error message', () => {
|
|
40
|
+
render(<Slider label="Volume" name="volume" error="Required" />);
|
|
41
|
+
expect(screen.getByText('Required')).toBeInTheDocument();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('shows a hint message', () => {
|
|
45
|
+
render(<Slider label="Volume" name="volume" hint="Drag to adjust" />);
|
|
46
|
+
expect(screen.getByText('Drag to adjust')).toBeInTheDocument();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('is disabled when disabled prop is set', () => {
|
|
50
|
+
render(<Slider label="Volume" name="volume" disabled />);
|
|
51
|
+
expect(screen.getByRole('slider')).toBeDisabled();
|
|
52
|
+
});
|
|
53
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react';
|
|
2
|
+
import { Slider } from './Slider';
|
|
3
|
+
|
|
4
|
+
const meta = {
|
|
5
|
+
title: 'Form/Slider',
|
|
6
|
+
component: Slider,
|
|
7
|
+
tags: ['autodocs'],
|
|
8
|
+
} satisfies Meta<typeof Slider>;
|
|
9
|
+
|
|
10
|
+
export default meta;
|
|
11
|
+
type Story = StoryObj<typeof meta>;
|
|
12
|
+
|
|
13
|
+
export const Default: Story = { args: { label: 'Volume', name: 'volume', defaultValue: 50 } };
|
|
14
|
+
export const WithValue: Story = { args: { label: 'Brightness', name: 'brightness', defaultValue: 70, showValue: true } };
|
|
15
|
+
export const CustomRange: Story = { args: { label: 'Price', name: 'price', min: 0, max: 500, defaultValue: 150, showValue: true } };
|
|
16
|
+
export const WithHint: Story = { args: { label: 'Volume', name: 'volume', defaultValue: 50, hint: 'Adjust the playback volume' } };
|
|
17
|
+
export const WithError: Story = { args: { label: 'Rating', name: 'rating', min: 1, max: 10, defaultValue: 1, error: 'Rating must be at least 5' } };
|
|
18
|
+
export const Disabled: Story = { args: { label: 'Locked', name: 'locked', defaultValue: 60, disabled: true, showValue: true } };
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { CSSProperties, ChangeEvent, InputHTMLAttributes, useId, useState } from 'react';
|
|
2
|
+
import css from './Slider.module.css';
|
|
3
|
+
import { cn } from '@boostdev/design-system-foundation';
|
|
4
|
+
import { InputContainer } from '../atoms/InputContainer';
|
|
5
|
+
import { Label } from '../atoms/Label';
|
|
6
|
+
import { Message } from '../atoms/Message';
|
|
7
|
+
|
|
8
|
+
interface SliderProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'type'> {
|
|
9
|
+
label: string;
|
|
10
|
+
name: string;
|
|
11
|
+
min?: number;
|
|
12
|
+
max?: number;
|
|
13
|
+
showValue?: boolean;
|
|
14
|
+
error?: string;
|
|
15
|
+
hint?: string;
|
|
16
|
+
className?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function Slider({
|
|
20
|
+
label,
|
|
21
|
+
name,
|
|
22
|
+
min = 0,
|
|
23
|
+
max = 100,
|
|
24
|
+
showValue = false,
|
|
25
|
+
error,
|
|
26
|
+
hint,
|
|
27
|
+
className,
|
|
28
|
+
onChange,
|
|
29
|
+
...props
|
|
30
|
+
}: Readonly<SliderProps>) {
|
|
31
|
+
const id = name + useId();
|
|
32
|
+
const hintId = id + 'hint';
|
|
33
|
+
const errorId = id + 'error';
|
|
34
|
+
const describedBy = error ? errorId : hintId;
|
|
35
|
+
|
|
36
|
+
const initialValue = Number(props.value ?? props.defaultValue ?? min);
|
|
37
|
+
const [currentValue, setCurrentValue] = useState(initialValue);
|
|
38
|
+
|
|
39
|
+
const fillPct = ((currentValue - min) / (max - min)) * 100;
|
|
40
|
+
|
|
41
|
+
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
|
|
42
|
+
setCurrentValue(Number(e.target.value));
|
|
43
|
+
onChange?.(e);
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
return (
|
|
47
|
+
<InputContainer className={cn(css.formGroup, className)}>
|
|
48
|
+
<div className={css.labelRow}>
|
|
49
|
+
<Label id={id} label={label} />
|
|
50
|
+
{showValue && <span className={css.value}>{currentValue}</span>}
|
|
51
|
+
</div>
|
|
52
|
+
<input
|
|
53
|
+
type="range"
|
|
54
|
+
id={id}
|
|
55
|
+
name={name}
|
|
56
|
+
min={min}
|
|
57
|
+
max={max}
|
|
58
|
+
aria-describedby={describedBy}
|
|
59
|
+
aria-valuemin={min}
|
|
60
|
+
aria-valuemax={max}
|
|
61
|
+
aria-valuenow={currentValue}
|
|
62
|
+
className={cn(css.slider, error ? css.sliderError : undefined)}
|
|
63
|
+
style={{ '--slider_fill': `${fillPct}%` } as CSSProperties}
|
|
64
|
+
onChange={handleChange}
|
|
65
|
+
{...props}
|
|
66
|
+
/>
|
|
67
|
+
<Message inputId={id} type="error" message={error} />
|
|
68
|
+
<Message inputId={id} type="hint" message={hint} />
|
|
69
|
+
</InputContainer>
|
|
70
|
+
);
|
|
71
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { Slider } from './Slider';
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
@layer component {
|
|
2
|
+
.switchGroup {
|
|
3
|
+
--switch_thumb-size: 1.25em;
|
|
4
|
+
--switch_track-pad: var(--space_xxxs);
|
|
5
|
+
--switch_track-height: calc(var(--switch_thumb-size) + var(--switch_track-pad) * 2);
|
|
6
|
+
--switch_track-width: calc(var(--switch_thumb-size) * 2 + var(--switch_track-pad) * 2);
|
|
7
|
+
--track_bg: var(--color_grey--subtle);
|
|
8
|
+
--track_active: var(--color_green--subtle);
|
|
9
|
+
--thumb_bg: var(--color_grey);
|
|
10
|
+
--thumb_bg--active: var(--color_green--strong);
|
|
11
|
+
|
|
12
|
+
display: flex;
|
|
13
|
+
flex-direction: column;
|
|
14
|
+
margin-block-end: var(--space_m);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
.switchGroup.--size_small { --switch_thumb-size: 1em; }
|
|
18
|
+
.switchGroup.--size_medium { --switch_thumb-size: 1.25em; }
|
|
19
|
+
.switchGroup.--size_large { --switch_thumb-size: 1.5em; }
|
|
20
|
+
|
|
21
|
+
.inputWrapper {
|
|
22
|
+
display: flex;
|
|
23
|
+
align-items: center;
|
|
24
|
+
gap: var(--space_xs);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
.trackWrapper {
|
|
28
|
+
position: relative;
|
|
29
|
+
display: inline-flex;
|
|
30
|
+
align-items: center;
|
|
31
|
+
flex-shrink: 0;
|
|
32
|
+
width: var(--switch_track-width);
|
|
33
|
+
height: var(--switch_track-height);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/* Real input — invisible overlay to capture pointer/keyboard events */
|
|
37
|
+
.switch {
|
|
38
|
+
position: absolute;
|
|
39
|
+
inset: 0;
|
|
40
|
+
appearance: none;
|
|
41
|
+
opacity: 0;
|
|
42
|
+
width: 100%;
|
|
43
|
+
height: 100%;
|
|
44
|
+
margin: 0;
|
|
45
|
+
cursor: pointer;
|
|
46
|
+
z-index: 1;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
.switch:disabled {
|
|
50
|
+
cursor: not-allowed;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/* Visual track */
|
|
54
|
+
.track {
|
|
55
|
+
display: inline-flex;
|
|
56
|
+
align-items: center;
|
|
57
|
+
width: var(--switch_track-width);
|
|
58
|
+
height: var(--switch_track-height);
|
|
59
|
+
border-radius: 999px;
|
|
60
|
+
background-color: var(--track_bg);
|
|
61
|
+
padding-inline: var(--switch_track-pad);
|
|
62
|
+
pointer-events: none;
|
|
63
|
+
transition: var(--animation_transition);
|
|
64
|
+
outline: 1px solid var(--thumb_bg);
|
|
65
|
+
outline-offset: var(--outline_offset);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/* Thumb */
|
|
69
|
+
.thumb {
|
|
70
|
+
display: block;
|
|
71
|
+
width: var(--switch_thumb-size);
|
|
72
|
+
height: var(--switch_thumb-size);
|
|
73
|
+
border-radius: 50%;
|
|
74
|
+
background-color: var(--thumb_bg);
|
|
75
|
+
box-shadow: var(--shadow_s);
|
|
76
|
+
transition: var(--animation_transition);
|
|
77
|
+
transform: translateX(0);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/* Error state — declared before checked/focus so higher-specificity states can override */
|
|
81
|
+
.switch.switchError + .track {
|
|
82
|
+
outline: 1px solid var(--color_error);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/* Checked state via CSS sibling combinator */
|
|
86
|
+
.switch:checked + .track {
|
|
87
|
+
background-color: var(--track_active);
|
|
88
|
+
outline-color: var(--thumb_bg--active);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
.switch:checked + .track .thumb {
|
|
92
|
+
background-color: var(--thumb_bg--active);
|
|
93
|
+
transform: translateX(var(--switch_thumb-size));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/* Focus visible on the visual track */
|
|
97
|
+
.switch:focus-visible + .track {
|
|
98
|
+
outline: var(--outline_default);
|
|
99
|
+
outline-offset: var(--outline_offset);
|
|
100
|
+
border-radius: 999px;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/* Disabled state */
|
|
104
|
+
.switch:disabled + .track {
|
|
105
|
+
opacity: 0.4;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
@media (prefers-reduced-motion: reduce) {
|
|
109
|
+
.track,
|
|
110
|
+
.thumb {
|
|
111
|
+
transition: none;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { render, screen } from '@testing-library/react';
|
|
2
|
+
import userEvent from '@testing-library/user-event';
|
|
3
|
+
import { Switch } from './Switch';
|
|
4
|
+
|
|
5
|
+
describe('Switch', () => {
|
|
6
|
+
it('renders with a label', () => {
|
|
7
|
+
render(<Switch label="Enable notifications" name="notifications" />);
|
|
8
|
+
expect(screen.getByLabelText('Enable notifications')).toBeInTheDocument();
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it('renders with role="switch"', () => {
|
|
12
|
+
render(<Switch label="Dark mode" name="dark-mode" />);
|
|
13
|
+
expect(screen.getByRole('switch')).toBeInTheDocument();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('is unchecked by default', () => {
|
|
17
|
+
render(<Switch label="Toggle" name="toggle" />);
|
|
18
|
+
expect(screen.getByRole('switch')).not.toBeChecked();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('can be toggled on', async () => {
|
|
22
|
+
const user = userEvent.setup();
|
|
23
|
+
render(<Switch label="Toggle" name="toggle" />);
|
|
24
|
+
const toggle = screen.getByRole('switch');
|
|
25
|
+
await user.click(toggle);
|
|
26
|
+
expect(toggle).toBeChecked();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('renders as checked when defaultChecked is set', () => {
|
|
30
|
+
render(<Switch label="Toggle" name="toggle" defaultChecked />);
|
|
31
|
+
expect(screen.getByRole('switch')).toBeChecked();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('shows an error message', () => {
|
|
35
|
+
render(<Switch label="Accept" name="accept" error="This field is required" />);
|
|
36
|
+
expect(screen.getByText('This field is required')).toBeInTheDocument();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('shows a hint message', () => {
|
|
40
|
+
render(<Switch label="Subscribe" name="subscribe" hint="You can unsubscribe at any time" />);
|
|
41
|
+
expect(screen.getByText('You can unsubscribe at any time')).toBeInTheDocument();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('is disabled when the disabled prop is set', () => {
|
|
45
|
+
render(<Switch label="Disabled" name="disabled" disabled />);
|
|
46
|
+
expect(screen.getByRole('switch')).toBeDisabled();
|
|
47
|
+
});
|
|
48
|
+
});
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react';
|
|
2
|
+
import { Switch } from './Switch';
|
|
3
|
+
|
|
4
|
+
const meta = {
|
|
5
|
+
title: 'Form/Switch',
|
|
6
|
+
component: Switch,
|
|
7
|
+
tags: ['autodocs'],
|
|
8
|
+
argTypes: {
|
|
9
|
+
size: { control: 'radio', options: ['small', 'medium', 'large'] },
|
|
10
|
+
},
|
|
11
|
+
} satisfies Meta<typeof Switch>;
|
|
12
|
+
|
|
13
|
+
export default meta;
|
|
14
|
+
type Story = StoryObj<typeof meta>;
|
|
15
|
+
|
|
16
|
+
export const Default: Story = { args: { label: 'Enable notifications', name: 'notifications' } };
|
|
17
|
+
export const Checked: Story = { args: { label: 'Dark mode', name: 'dark-mode', defaultChecked: true } };
|
|
18
|
+
export const WithHint: Story = { args: { label: 'Marketing emails', name: 'marketing', hint: 'Receive occasional product updates' } };
|
|
19
|
+
export const WithError: Story = { args: { label: 'Required toggle', name: 'required', error: 'You must enable this to continue' } };
|
|
20
|
+
export const Disabled: Story = { args: { label: 'Unavailable', name: 'unavailable', disabled: true } };
|
|
21
|
+
export const DisabledChecked: Story = { args: { label: 'Always on', name: 'always-on', defaultChecked: true, disabled: true } };
|
|
22
|
+
|
|
23
|
+
export const AllSizes: Story = {
|
|
24
|
+
render: () => (
|
|
25
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
|
26
|
+
<Switch label="Small" name="small" size="small" />
|
|
27
|
+
<Switch label="Medium" name="medium" size="medium" />
|
|
28
|
+
<Switch label="Large" name="large" size="large" />
|
|
29
|
+
</div>
|
|
30
|
+
),
|
|
31
|
+
};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { InputHTMLAttributes, useId } from 'react';
|
|
2
|
+
import css from './Switch.module.css';
|
|
3
|
+
import { cn } from '@boostdev/design-system-foundation';
|
|
4
|
+
import { InputContainer } from '../atoms/InputContainer';
|
|
5
|
+
import { Label } from '../atoms/Label';
|
|
6
|
+
import { Message } from '../atoms/Message';
|
|
7
|
+
|
|
8
|
+
interface SwitchProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'type' | 'size'> {
|
|
9
|
+
label: string;
|
|
10
|
+
name: string;
|
|
11
|
+
size?: 'small' | 'medium' | 'large';
|
|
12
|
+
error?: string;
|
|
13
|
+
hint?: string;
|
|
14
|
+
className?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function Switch({
|
|
18
|
+
label,
|
|
19
|
+
name,
|
|
20
|
+
size = 'medium',
|
|
21
|
+
error,
|
|
22
|
+
hint,
|
|
23
|
+
className,
|
|
24
|
+
...props
|
|
25
|
+
}: Readonly<SwitchProps>) {
|
|
26
|
+
const id = name + useId();
|
|
27
|
+
const hintId = id + 'hint';
|
|
28
|
+
const errorId = id + 'error';
|
|
29
|
+
const describedBy = error ? errorId : hintId;
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<InputContainer className={cn(css.switchGroup, css[`--size_${size}`], className)}>
|
|
33
|
+
<div className={css.inputWrapper}>
|
|
34
|
+
<div className={css.trackWrapper}>
|
|
35
|
+
<input
|
|
36
|
+
type="checkbox"
|
|
37
|
+
role="switch"
|
|
38
|
+
id={id}
|
|
39
|
+
name={name}
|
|
40
|
+
aria-describedby={describedBy}
|
|
41
|
+
className={cn(css.switch, error ? css.switchError : undefined)}
|
|
42
|
+
{...props}
|
|
43
|
+
/>
|
|
44
|
+
<span className={css.track} aria-hidden="true">
|
|
45
|
+
<span className={css.thumb} />
|
|
46
|
+
</span>
|
|
47
|
+
</div>
|
|
48
|
+
<Label id={id} label={label} />
|
|
49
|
+
</div>
|
|
50
|
+
<Message inputId={id} type="error" message={error} />
|
|
51
|
+
<Message inputId={id} type="hint" message={hint} />
|
|
52
|
+
</InputContainer>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { Switch } from './Switch';
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
@layer component {
|
|
2
|
+
.formGroup {
|
|
3
|
+
display: flex;
|
|
4
|
+
flex-direction: column;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
.textarea {
|
|
8
|
+
--textarea_border-color: var(--color_on-bg);
|
|
9
|
+
--textarea_focus-ring: 0 0 0 2px rgb(from var(--color_interactive) r g b / 20%);
|
|
10
|
+
|
|
11
|
+
font-family: var(--font_family--body);
|
|
12
|
+
font-size: var(--font_size--body);
|
|
13
|
+
line-height: var(--font_line-height--body);
|
|
14
|
+
padding: var(--space_s);
|
|
15
|
+
border: 1px solid var(--textarea_border-color);
|
|
16
|
+
border-radius: var(--border_radius--xs);
|
|
17
|
+
background-color: var(--color_bg);
|
|
18
|
+
color: var(--color_on-bg);
|
|
19
|
+
resize: vertical;
|
|
20
|
+
min-height: calc(var(--space_m) * 5);
|
|
21
|
+
transition: var(--animation_transition);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
.textarea:focus {
|
|
25
|
+
--textarea_border-color: var(--color_interactive);
|
|
26
|
+
|
|
27
|
+
outline: none;
|
|
28
|
+
box-shadow: var(--textarea_focus-ring);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
.textarea:disabled {
|
|
32
|
+
opacity: 0.5;
|
|
33
|
+
cursor: not-allowed;
|
|
34
|
+
resize: none;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
.textareaError {
|
|
38
|
+
--textarea_border-color: var(--color_error);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
.textareaError:focus {
|
|
42
|
+
--textarea_focus-ring: 0 0 0 2px rgb(from var(--color_error) r g b / 20%);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { render, screen } from '@testing-library/react';
|
|
2
|
+
import userEvent from '@testing-library/user-event';
|
|
3
|
+
import { Textarea } from './Textarea';
|
|
4
|
+
|
|
5
|
+
describe('Textarea', () => {
|
|
6
|
+
it('renders with a label', () => {
|
|
7
|
+
render(<Textarea label="Message" name="message" />);
|
|
8
|
+
expect(screen.getByLabelText('Message')).toBeInTheDocument();
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it('renders a textarea element', () => {
|
|
12
|
+
render(<Textarea label="Message" name="message" />);
|
|
13
|
+
expect(screen.getByRole('textbox')).toBeInTheDocument();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('accepts typed input', async () => {
|
|
17
|
+
const user = userEvent.setup();
|
|
18
|
+
render(<Textarea label="Message" name="message" />);
|
|
19
|
+
const textarea = screen.getByRole('textbox');
|
|
20
|
+
await user.type(textarea, 'Hello world');
|
|
21
|
+
expect(textarea).toHaveValue('Hello world');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('renders with a default value', () => {
|
|
25
|
+
render(<Textarea label="Message" name="message" defaultValue="Pre-filled text" />);
|
|
26
|
+
expect(screen.getByRole('textbox')).toHaveValue('Pre-filled text');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('shows an error message', () => {
|
|
30
|
+
render(<Textarea label="Message" name="message" error="Required" />);
|
|
31
|
+
expect(screen.getByText('Required')).toBeInTheDocument();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('sets aria-invalid when there is an error', () => {
|
|
35
|
+
render(<Textarea label="Message" name="message" error="Required" />);
|
|
36
|
+
expect(screen.getByRole('textbox')).toHaveAttribute('aria-invalid', 'true');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('shows a hint message', () => {
|
|
40
|
+
render(<Textarea label="Message" name="message" hint="Max 500 characters" />);
|
|
41
|
+
expect(screen.getByText('Max 500 characters')).toBeInTheDocument();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('is disabled when the disabled prop is set', () => {
|
|
45
|
+
render(<Textarea label="Message" name="message" disabled />);
|
|
46
|
+
expect(screen.getByRole('textbox')).toBeDisabled();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('forwards rows prop', () => {
|
|
50
|
+
render(<Textarea label="Message" name="message" rows={6} />);
|
|
51
|
+
expect(screen.getByRole('textbox')).toHaveAttribute('rows', '6');
|
|
52
|
+
});
|
|
53
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react';
|
|
2
|
+
import { Textarea } from './Textarea';
|
|
3
|
+
|
|
4
|
+
const meta = {
|
|
5
|
+
title: 'Form/Textarea',
|
|
6
|
+
component: Textarea,
|
|
7
|
+
tags: ['autodocs'],
|
|
8
|
+
} satisfies Meta<typeof Textarea>;
|
|
9
|
+
|
|
10
|
+
export default meta;
|
|
11
|
+
type Story = StoryObj<typeof meta>;
|
|
12
|
+
|
|
13
|
+
export const Default: Story = { args: { label: 'Message', name: 'message', placeholder: 'Type your message…' } };
|
|
14
|
+
export const WithValue: Story = { args: { label: 'Message', name: 'message', defaultValue: 'Pre-filled content here.' } };
|
|
15
|
+
export const WithError: Story = { args: { label: 'Message', name: 'message', error: 'Message is required' } };
|
|
16
|
+
export const WithHint: Story = { args: { label: 'Message', name: 'message', hint: 'Maximum 500 characters' } };
|
|
17
|
+
export const Disabled: Story = { args: { label: 'Message', name: 'message', disabled: true, defaultValue: 'Cannot edit this.' } };
|
|
18
|
+
export const Tall: Story = { args: { label: 'Notes', name: 'notes', rows: 8, placeholder: 'Add detailed notes…' } };
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { TextareaHTMLAttributes, useId, ReactNode } from 'react';
|
|
2
|
+
import css from './Textarea.module.css';
|
|
3
|
+
import { cn } from '@boostdev/design-system-foundation';
|
|
4
|
+
import { InputContainer } from '../atoms/InputContainer';
|
|
5
|
+
import { Label } from '../atoms/Label';
|
|
6
|
+
import { Message } from '../atoms/Message';
|
|
7
|
+
|
|
8
|
+
interface TextareaProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {
|
|
9
|
+
label: ReactNode;
|
|
10
|
+
name: string;
|
|
11
|
+
error?: string;
|
|
12
|
+
hint?: string;
|
|
13
|
+
className?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function Textarea({
|
|
17
|
+
label,
|
|
18
|
+
name,
|
|
19
|
+
error,
|
|
20
|
+
hint,
|
|
21
|
+
className,
|
|
22
|
+
...props
|
|
23
|
+
}: Readonly<TextareaProps>) {
|
|
24
|
+
const id = name + useId();
|
|
25
|
+
const hintId = id + 'hint';
|
|
26
|
+
const errorId = id + 'error';
|
|
27
|
+
const describedBy = error ? errorId : hintId;
|
|
28
|
+
|
|
29
|
+
return (
|
|
30
|
+
<InputContainer className={cn(css.formGroup, className)}>
|
|
31
|
+
<Label id={id} label={label} />
|
|
32
|
+
<textarea
|
|
33
|
+
id={id}
|
|
34
|
+
name={name}
|
|
35
|
+
aria-invalid={!!error}
|
|
36
|
+
aria-describedby={describedBy}
|
|
37
|
+
className={cn(css.textarea, error ? css.textareaError : undefined)}
|
|
38
|
+
{...props}
|
|
39
|
+
/>
|
|
40
|
+
<Message inputId={id} type="error" message={error} />
|
|
41
|
+
<Message inputId={id} type="hint" message={hint} />
|
|
42
|
+
</InputContainer>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { Textarea } from './Textarea';
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { HTMLAttributes } from 'react';
|
|
2
|
+
import css from './InputContainer.module.css';
|
|
3
|
+
import { cn } from '@boostdev/design-system-foundation';
|
|
4
|
+
|
|
5
|
+
type ContainerProps = HTMLAttributes<HTMLDivElement>;
|
|
6
|
+
|
|
7
|
+
export const InputContainer = ({ children, className }: ContainerProps) => {
|
|
8
|
+
return <div className={cn(css.container, className)}>{children}</div>;
|
|
9
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { ReactNode } from 'react';
|
|
2
|
+
import css from './Label.module.css';
|
|
3
|
+
|
|
4
|
+
interface LabelProps {
|
|
5
|
+
id: string;
|
|
6
|
+
label: ReactNode;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export const Label = ({ label, id }: LabelProps) => {
|
|
10
|
+
return (
|
|
11
|
+
<label htmlFor={id} className={css.label}>
|
|
12
|
+
{label}
|
|
13
|
+
</label>
|
|
14
|
+
);
|
|
15
|
+
};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import css from './Message.module.css';
|
|
2
|
+
|
|
3
|
+
interface MessageProps {
|
|
4
|
+
inputId: string;
|
|
5
|
+
type: 'hint' | 'error';
|
|
6
|
+
message: string | undefined;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export const Message = ({ message, type, inputId }: MessageProps) => {
|
|
10
|
+
if (!message) return null;
|
|
11
|
+
|
|
12
|
+
return (
|
|
13
|
+
<label id={inputId + type} htmlFor={inputId} className={css[type]}>
|
|
14
|
+
{message}
|
|
15
|
+
</label>
|
|
16
|
+
);
|
|
17
|
+
};
|