@boostdev/design-system-components 0.1.4 → 0.1.5
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/dist/client.cjs +185 -104
- package/dist/client.css +435 -411
- package/dist/client.d.cts +15 -13
- package/dist/client.d.ts +15 -13
- package/dist/client.js +189 -107
- package/dist/index.cjs +185 -104
- package/dist/index.css +435 -411
- package/dist/index.d.cts +15 -13
- package/dist/index.d.ts +15 -13
- package/dist/index.js +189 -107
- package/package.json +1 -1
- package/src/components/interaction/Button/Button.tsx +35 -8
- package/src/components/interaction/Dialog/Dialog.spec.tsx +10 -10
- package/src/components/interaction/Dialog/Dialog.tsx +21 -7
- package/src/components/interaction/DropdownMenu/DropdownMenu.tsx +1 -1
- package/src/components/interaction/DropdownMenu/index.ts +1 -0
- package/src/components/interaction/Toast/Toast.module.css +28 -0
- package/src/components/interaction/Toast/Toast.tsx +10 -1
- package/src/components/interaction/form/Checkbox/Checkbox.module.css +1 -1
- package/src/components/interaction/form/Checkbox/Checkbox.tsx +2 -1
- package/src/components/interaction/form/Combobox/Combobox.tsx +11 -3
- package/src/components/interaction/form/Combobox/index.ts +1 -0
- package/src/components/interaction/form/FileInput/FileInput.tsx +15 -2
- package/src/components/interaction/form/FormInput/FormInput.tsx +4 -3
- package/src/components/interaction/form/NumberInput/NumberInput.tsx +21 -17
- package/src/components/interaction/form/Radio/Radio.module.css +1 -1
- package/src/components/interaction/form/Radio/Radio.tsx +2 -1
- package/src/components/interaction/form/Select/Select.spec.tsx +2 -1
- package/src/components/interaction/form/Select/Select.tsx +3 -3
- package/src/components/interaction/form/Select/index.ts +1 -0
- package/src/components/interaction/form/Slider/Slider.tsx +5 -4
- package/src/components/interaction/form/Switch/Switch.tsx +1 -1
- package/src/components/interaction/form/Textarea/Textarea.tsx +1 -1
- package/src/components/interaction/form/atoms/Message.tsx +2 -2
- package/src/components/layout/ButtonGroup/ButtonGroup.tsx +1 -1
- package/src/components/layout/Card/Card.tsx +3 -0
- package/src/components/layout/SectionHeader/SectionHeader.tsx +2 -3
- package/src/components/ui/Accordion/Accordion.tsx +1 -1
- package/src/components/ui/Accordion/index.ts +1 -0
- package/src/components/ui/Breadcrumb/Breadcrumb.tsx +2 -2
- package/src/components/ui/Breadcrumb/index.ts +1 -0
- package/src/components/ui/Calendar/Calendar.tsx +8 -3
- package/src/components/ui/DescriptionList/DescriptionList.tsx +1 -1
- package/src/components/ui/DescriptionList/index.ts +1 -0
- package/src/components/ui/Link/Link.tsx +3 -1
- package/src/components/ui/Separator/Separator.module.css +1 -1
- package/src/components/ui/Table/Table.tsx +1 -1
- package/src/components/ui/Table/index.ts +1 -0
- package/src/components/ui/Tabs/Tabs.tsx +1 -1
- package/src/components/ui/Tabs/index.ts +1 -0
- package/src/index.ts +8 -0
package/package.json
CHANGED
|
@@ -22,7 +22,7 @@ interface ButtonProps {
|
|
|
22
22
|
|
|
23
23
|
export function Button({
|
|
24
24
|
children,
|
|
25
|
-
className
|
|
25
|
+
className,
|
|
26
26
|
variant = 'primary',
|
|
27
27
|
type = 'button',
|
|
28
28
|
iconStart,
|
|
@@ -44,25 +44,52 @@ export function Button({
|
|
|
44
44
|
className,
|
|
45
45
|
);
|
|
46
46
|
|
|
47
|
-
const
|
|
47
|
+
const handleAnchorClick: MouseEventHandler<HTMLAnchorElement> = (e) => {
|
|
48
|
+
if (disabled) {
|
|
49
|
+
e.preventDefault();
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
onClick?.(e);
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const handleButtonClick: MouseEventHandler<HTMLButtonElement> = (e) => {
|
|
56
|
+
onClick?.(e);
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const content = (
|
|
48
60
|
<>
|
|
49
|
-
{
|
|
61
|
+
{Boolean(iconStart) && <span className={css.prefix}>{iconStart}</span>}
|
|
50
62
|
{children}
|
|
51
|
-
{
|
|
63
|
+
{Boolean(iconEnd) && <span className={css.suffix}>{iconEnd}</span>}
|
|
52
64
|
</>
|
|
53
65
|
);
|
|
54
66
|
|
|
55
67
|
if (href) {
|
|
56
68
|
return (
|
|
57
|
-
<a
|
|
58
|
-
{
|
|
69
|
+
<a
|
|
70
|
+
className={classNames}
|
|
71
|
+
href={disabled ? undefined : href}
|
|
72
|
+
target={target}
|
|
73
|
+
rel={rel}
|
|
74
|
+
aria-disabled={disabled}
|
|
75
|
+
tabIndex={disabled ? -1 : undefined}
|
|
76
|
+
onClick={handleAnchorClick}
|
|
77
|
+
{...rest}
|
|
78
|
+
>
|
|
79
|
+
{content}
|
|
59
80
|
</a>
|
|
60
81
|
);
|
|
61
82
|
}
|
|
62
83
|
|
|
63
84
|
return (
|
|
64
|
-
<button
|
|
65
|
-
{
|
|
85
|
+
<button
|
|
86
|
+
type={type}
|
|
87
|
+
className={classNames}
|
|
88
|
+
disabled={disabled}
|
|
89
|
+
onClick={handleButtonClick}
|
|
90
|
+
{...rest}
|
|
91
|
+
>
|
|
92
|
+
{content}
|
|
66
93
|
</button>
|
|
67
94
|
);
|
|
68
95
|
}
|
|
@@ -14,30 +14,30 @@ beforeEach(() => {
|
|
|
14
14
|
|
|
15
15
|
describe('Dialog', () => {
|
|
16
16
|
it('renders children', () => {
|
|
17
|
-
render(<Dialog
|
|
17
|
+
render(<Dialog isOpen><p>Dialog content</p></Dialog>);
|
|
18
18
|
expect(screen.getByText('Dialog content')).toBeInTheDocument();
|
|
19
19
|
});
|
|
20
20
|
|
|
21
|
-
it('calls showModal when
|
|
22
|
-
render(<Dialog
|
|
21
|
+
it('calls showModal when isOpen becomes true', () => {
|
|
22
|
+
render(<Dialog isOpen><span>Content</span></Dialog>);
|
|
23
23
|
expect(HTMLDialogElement.prototype.showModal).toHaveBeenCalled();
|
|
24
24
|
});
|
|
25
25
|
|
|
26
26
|
it('renders a close button with aria-label', () => {
|
|
27
|
-
render(<Dialog
|
|
27
|
+
render(<Dialog isOpen><span>Content</span></Dialog>);
|
|
28
28
|
expect(screen.getByRole('button', { name: /close dialog/i })).toBeInTheDocument();
|
|
29
29
|
});
|
|
30
30
|
|
|
31
|
-
it('calls
|
|
31
|
+
it('calls onClose when close button is clicked', async () => {
|
|
32
32
|
const user = userEvent.setup();
|
|
33
|
-
const
|
|
34
|
-
render(<Dialog
|
|
33
|
+
const onClose = vi.fn();
|
|
34
|
+
render(<Dialog isOpen onClose={onClose}><span>Content</span></Dialog>);
|
|
35
35
|
await user.click(screen.getByRole('button', { name: /close dialog/i }));
|
|
36
|
-
expect(
|
|
36
|
+
expect(onClose).toHaveBeenCalled();
|
|
37
37
|
});
|
|
38
38
|
|
|
39
|
-
it('does not call showModal when
|
|
40
|
-
render(<Dialog
|
|
39
|
+
it('does not call showModal when isOpen is false', () => {
|
|
40
|
+
render(<Dialog isOpen={false}><span>Content</span></Dialog>);
|
|
41
41
|
expect(HTMLDialogElement.prototype.showModal).not.toHaveBeenCalled();
|
|
42
42
|
});
|
|
43
43
|
});
|
|
@@ -5,30 +5,44 @@ import { cn } from '@boostdev/design-system-foundation';
|
|
|
5
5
|
interface DialogProps {
|
|
6
6
|
children: ReactNode;
|
|
7
7
|
className?: string;
|
|
8
|
-
|
|
9
|
-
|
|
8
|
+
isOpen?: boolean;
|
|
9
|
+
onClose?: () => void;
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
-
export function Dialog({ children,
|
|
12
|
+
export function Dialog({ children, isOpen = false, className, onClose }: DialogProps) {
|
|
13
13
|
const dialogRef = useRef<HTMLDialogElement | null>(null);
|
|
14
14
|
|
|
15
15
|
useEffect(() => {
|
|
16
16
|
const dialog = dialogRef.current;
|
|
17
17
|
if (!dialog) return;
|
|
18
|
-
if (
|
|
18
|
+
if (isOpen) {
|
|
19
19
|
dialog.showModal();
|
|
20
20
|
} else if (dialog.open) {
|
|
21
21
|
dialog.close();
|
|
22
22
|
}
|
|
23
|
-
}, [
|
|
23
|
+
}, [isOpen]);
|
|
24
|
+
|
|
25
|
+
const handleBackdropClick = (e: React.MouseEvent<HTMLDialogElement>) => {
|
|
26
|
+
if (e.target === dialogRef.current) onClose?.();
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const handleCancel = (e: React.SyntheticEvent) => {
|
|
30
|
+
e.preventDefault();
|
|
31
|
+
onClose?.();
|
|
32
|
+
};
|
|
24
33
|
|
|
25
34
|
return (
|
|
26
|
-
<dialog
|
|
35
|
+
<dialog
|
|
36
|
+
ref={dialogRef}
|
|
37
|
+
className={cn(className, css.dialog)}
|
|
38
|
+
onClick={handleBackdropClick}
|
|
39
|
+
onCancel={handleCancel}
|
|
40
|
+
>
|
|
27
41
|
<form method="dialog" className={css.closeForm}>
|
|
28
42
|
<button
|
|
29
43
|
type="submit"
|
|
30
44
|
className={css.closeButton}
|
|
31
|
-
onClick={
|
|
45
|
+
onClick={onClose}
|
|
32
46
|
aria-label="Close dialog"
|
|
33
47
|
>
|
|
34
48
|
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
@@ -33,6 +33,34 @@
|
|
|
33
33
|
.--variant_info {
|
|
34
34
|
border-left: 4px solid var(--color_interactive);
|
|
35
35
|
}
|
|
36
|
+
|
|
37
|
+
.message {
|
|
38
|
+
flex: 1;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
.closeButton {
|
|
42
|
+
display: flex;
|
|
43
|
+
align-items: center;
|
|
44
|
+
justify-content: center;
|
|
45
|
+
flex-shrink: 0;
|
|
46
|
+
width: 1.25rem;
|
|
47
|
+
height: 1.25rem;
|
|
48
|
+
padding: 0;
|
|
49
|
+
background: none;
|
|
50
|
+
border: none;
|
|
51
|
+
cursor: pointer;
|
|
52
|
+
color: inherit;
|
|
53
|
+
opacity: 0.6;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
.closeButton:hover {
|
|
57
|
+
opacity: 1;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
.closeButton svg {
|
|
61
|
+
width: 1rem;
|
|
62
|
+
height: 1rem;
|
|
63
|
+
}
|
|
36
64
|
}
|
|
37
65
|
|
|
38
66
|
@keyframes slideIn {
|
|
@@ -52,7 +52,16 @@ function ToastItem({ toast, onRemove }: { toast: Toast; onRemove: () => void })
|
|
|
52
52
|
return () => clearTimeout(timer);
|
|
53
53
|
}, [onRemove]);
|
|
54
54
|
|
|
55
|
-
return
|
|
55
|
+
return (
|
|
56
|
+
<div className={cn(css.toast, css[`--variant_${toast.variant}`])} role="status">
|
|
57
|
+
<span className={css.message}>{toast.message}</span>
|
|
58
|
+
<button type="button" className={css.closeButton} onClick={onRemove} aria-label="Dismiss">
|
|
59
|
+
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
60
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M18 6L6 18M6 6l12 12" />
|
|
61
|
+
</svg>
|
|
62
|
+
</button>
|
|
63
|
+
</div>
|
|
64
|
+
);
|
|
56
65
|
}
|
|
57
66
|
|
|
58
67
|
export function useToast() {
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
margin-block-start: 0.25em;
|
|
18
18
|
width: var(--inputSize);
|
|
19
19
|
height: var(--inputSize);
|
|
20
|
-
border: 1px solid var(--
|
|
20
|
+
border: 1px solid var(--color_on-bg);
|
|
21
21
|
border-radius: var(--border_radius--xs);
|
|
22
22
|
appearance: none;
|
|
23
23
|
background-color: var(--color_bg);
|
|
@@ -17,13 +17,14 @@ export function Checkbox({ label, name, error, hint, className, ...props }: Chec
|
|
|
17
17
|
const id = name + useId();
|
|
18
18
|
const hintId = id + 'hint';
|
|
19
19
|
const errorId = id + 'error';
|
|
20
|
-
const describedBy =
|
|
20
|
+
const describedBy = [error && errorId, hint && hintId].filter(Boolean).join(' ') || undefined;
|
|
21
21
|
|
|
22
22
|
return (
|
|
23
23
|
<InputContainer className={cn(css.checkboxGroup, className)}>
|
|
24
24
|
<div className={css.inputWrapper}>
|
|
25
25
|
<input
|
|
26
26
|
aria-describedby={describedBy}
|
|
27
|
+
aria-invalid={!!error}
|
|
27
28
|
type="checkbox"
|
|
28
29
|
id={id}
|
|
29
30
|
name={name}
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
useId,
|
|
7
7
|
useRef,
|
|
8
8
|
useState,
|
|
9
|
+
useMemo,
|
|
9
10
|
} from 'react';
|
|
10
11
|
import css from './Combobox.module.css';
|
|
11
12
|
import { cn } from '@boostdev/design-system-foundation';
|
|
@@ -13,7 +14,7 @@ import { InputContainer } from '../atoms/InputContainer';
|
|
|
13
14
|
import { Label } from '../atoms/Label';
|
|
14
15
|
import { Message } from '../atoms/Message';
|
|
15
16
|
|
|
16
|
-
interface ComboboxOption {
|
|
17
|
+
export interface ComboboxOption {
|
|
17
18
|
value: string;
|
|
18
19
|
label: string;
|
|
19
20
|
disabled?: boolean;
|
|
@@ -26,6 +27,7 @@ interface ComboboxProps {
|
|
|
26
27
|
placeholder?: string;
|
|
27
28
|
value?: string;
|
|
28
29
|
onChange?: (value: string) => void;
|
|
30
|
+
disabled?: boolean;
|
|
29
31
|
error?: string;
|
|
30
32
|
hint?: string;
|
|
31
33
|
className?: string;
|
|
@@ -38,6 +40,7 @@ export function Combobox({
|
|
|
38
40
|
placeholder,
|
|
39
41
|
value,
|
|
40
42
|
onChange,
|
|
43
|
+
disabled = false,
|
|
41
44
|
error,
|
|
42
45
|
hint,
|
|
43
46
|
className,
|
|
@@ -46,10 +49,14 @@ export function Combobox({
|
|
|
46
49
|
const listboxId = id + 'listbox';
|
|
47
50
|
const hintId = id + 'hint';
|
|
48
51
|
const errorId = id + 'error';
|
|
49
|
-
const describedBy = error
|
|
52
|
+
const describedBy = [error && errorId, hint && hintId].filter(Boolean).join(' ') || undefined;
|
|
50
53
|
|
|
51
|
-
const selectedOption = options.find(o => o.value === value);
|
|
54
|
+
const selectedOption = useMemo(() => options.find(o => o.value === value), [options, value]);
|
|
52
55
|
const [inputValue, setInputValue] = useState(selectedOption?.label ?? '');
|
|
56
|
+
|
|
57
|
+
useEffect(() => {
|
|
58
|
+
setInputValue(selectedOption?.label ?? '');
|
|
59
|
+
}, [selectedOption]);
|
|
53
60
|
const [isOpen, setIsOpen] = useState(false);
|
|
54
61
|
const [highlightedIndex, setHighlightedIndex] = useState(-1);
|
|
55
62
|
|
|
@@ -134,6 +141,7 @@ export function Combobox({
|
|
|
134
141
|
autoComplete="off"
|
|
135
142
|
placeholder={placeholder}
|
|
136
143
|
value={inputValue}
|
|
144
|
+
disabled={disabled}
|
|
137
145
|
className={cn(css.input, error ? css.inputError : undefined)}
|
|
138
146
|
onChange={handleInputChange}
|
|
139
147
|
onKeyDown={handleKeyDown}
|
|
@@ -31,11 +31,21 @@ export function FileInput({
|
|
|
31
31
|
const uid = name + useId();
|
|
32
32
|
const hintId = uid + 'hint';
|
|
33
33
|
const errorId = uid + 'error';
|
|
34
|
-
const describedBy = error
|
|
34
|
+
const describedBy = [error && errorId, hint && hintId].filter(Boolean).join(' ') || undefined;
|
|
35
35
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
36
36
|
const [isDragging, setIsDragging] = useState(false);
|
|
37
37
|
const [fileNames, setFileNames] = useState<string[]>([]);
|
|
38
38
|
|
|
39
|
+
const isFileAccepted = (file: File): boolean => {
|
|
40
|
+
if (!accept) return true;
|
|
41
|
+
return accept.split(',').some(token => {
|
|
42
|
+
const t = token.trim();
|
|
43
|
+
if (t.startsWith('.')) return file.name.toLowerCase().endsWith(t.toLowerCase());
|
|
44
|
+
if (t.endsWith('/*')) return file.type.startsWith(t.slice(0, -1));
|
|
45
|
+
return file.type === t;
|
|
46
|
+
});
|
|
47
|
+
};
|
|
48
|
+
|
|
39
49
|
const handleFiles = (files: FileList | null) => {
|
|
40
50
|
if (!files) return;
|
|
41
51
|
setFileNames(Array.from(files).map(f => f.name));
|
|
@@ -48,7 +58,10 @@ export function FileInput({
|
|
|
48
58
|
e.preventDefault();
|
|
49
59
|
setIsDragging(false);
|
|
50
60
|
if (disabled) return;
|
|
51
|
-
|
|
61
|
+
const dt = new DataTransfer();
|
|
62
|
+
Array.from(e.dataTransfer.files).filter(isFileAccepted).forEach(f => dt.items.add(f));
|
|
63
|
+
if (inputRef.current) inputRef.current.files = dt.files;
|
|
64
|
+
handleFiles(dt.files.length > 0 ? dt.files : null);
|
|
52
65
|
};
|
|
53
66
|
|
|
54
67
|
const handleDragOver = (e: DragEvent<HTMLLabelElement>) => {
|
|
@@ -1,13 +1,14 @@
|
|
|
1
|
-
import { InputHTMLAttributes, useId, ReactNode } from 'react';
|
|
1
|
+
import { InputHTMLAttributes, useId, ReactNode, HTMLInputTypeAttribute } from 'react';
|
|
2
2
|
import css from './FormInput.module.css';
|
|
3
3
|
import { Label } from '../atoms/Label';
|
|
4
4
|
import { Message } from '../atoms/Message';
|
|
5
5
|
import { cn } from '@boostdev/design-system-foundation';
|
|
6
6
|
import { InputContainer } from '../atoms/InputContainer';
|
|
7
7
|
|
|
8
|
-
interface FormInputProps extends InputHTMLAttributes<HTMLInputElement> {
|
|
8
|
+
interface FormInputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'type'> {
|
|
9
9
|
label: ReactNode;
|
|
10
10
|
name: string;
|
|
11
|
+
type?: Exclude<HTMLInputTypeAttribute, 'checkbox' | 'radio' | 'file'>;
|
|
11
12
|
ariaLabel?: string;
|
|
12
13
|
error?: string;
|
|
13
14
|
hint?: string;
|
|
@@ -26,7 +27,7 @@ export function FormInput({
|
|
|
26
27
|
const id = name + useId();
|
|
27
28
|
const hintId = id + 'hint';
|
|
28
29
|
const errorId = id + 'error';
|
|
29
|
-
const describedBy =
|
|
30
|
+
const describedBy = [error && errorId, hint && hintId].filter(Boolean).join(' ') || undefined;
|
|
30
31
|
|
|
31
32
|
return (
|
|
32
33
|
<InputContainer className={cn(css.formGroup, className)}>
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {useId, useRef, useState} from 'react';
|
|
2
2
|
import css from './NumberInput.module.css';
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
3
|
+
import {Label} from '../atoms/Label';
|
|
4
|
+
import {Message} from '../atoms/Message';
|
|
5
|
+
import {InputContainer} from '../atoms/InputContainer';
|
|
6
|
+
import {cn} from '@boostdev/design-system-foundation';
|
|
7
7
|
|
|
8
8
|
interface NumberInputProps {
|
|
9
9
|
label: string;
|
|
@@ -37,20 +37,21 @@ export function NumberInput({
|
|
|
37
37
|
const uid = name + useId();
|
|
38
38
|
const hintId = uid + 'hint';
|
|
39
39
|
const errorId = uid + 'error';
|
|
40
|
-
const describedBy = error
|
|
40
|
+
const describedBy = [error && errorId, hint && hintId].filter(Boolean).join(' ') || undefined;
|
|
41
41
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
42
42
|
|
|
43
|
+
const isControlled = value !== undefined;
|
|
44
|
+
const [internalValue, setInternalValue] = useState(defaultValue ?? 0);
|
|
45
|
+
const currentValue = isControlled ? value! : internalValue;
|
|
46
|
+
|
|
43
47
|
const clamp = (v: number): number => {
|
|
44
48
|
const withMin = min !== undefined ? Math.max(min, v) : v;
|
|
45
|
-
|
|
46
|
-
return withMax;
|
|
49
|
+
return max !== undefined ? Math.min(max, withMin) : withMin;
|
|
47
50
|
};
|
|
48
51
|
|
|
49
52
|
const adjust = (delta: number) => {
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
const next = clamp(current + delta);
|
|
53
|
-
inputRef.current.value = String(next);
|
|
53
|
+
const next = clamp(currentValue + delta);
|
|
54
|
+
if (!isControlled) setInternalValue(next);
|
|
54
55
|
onChange?.(next);
|
|
55
56
|
};
|
|
56
57
|
|
|
@@ -62,7 +63,7 @@ export function NumberInput({
|
|
|
62
63
|
type="button"
|
|
63
64
|
className={css.stepper}
|
|
64
65
|
aria-label="Decrease"
|
|
65
|
-
disabled={disabled || (min !== undefined &&
|
|
66
|
+
disabled={disabled || (min !== undefined && currentValue <= min)}
|
|
66
67
|
onClick={() => adjust(-step)}
|
|
67
68
|
tabIndex={-1}
|
|
68
69
|
>
|
|
@@ -75,8 +76,7 @@ export function NumberInput({
|
|
|
75
76
|
id={uid}
|
|
76
77
|
type="number"
|
|
77
78
|
name={name}
|
|
78
|
-
|
|
79
|
-
value={value}
|
|
79
|
+
{...(isControlled ? { value } : { defaultValue })}
|
|
80
80
|
min={min}
|
|
81
81
|
max={max}
|
|
82
82
|
step={step}
|
|
@@ -84,13 +84,17 @@ export function NumberInput({
|
|
|
84
84
|
aria-invalid={!!error}
|
|
85
85
|
aria-describedby={describedBy}
|
|
86
86
|
className={cn(css.input, error ? css.inputError : undefined)}
|
|
87
|
-
onChange={e =>
|
|
87
|
+
onChange={e => {
|
|
88
|
+
const v = parseFloat(e.target.value);
|
|
89
|
+
if (!isControlled) setInternalValue(isNaN(v) ? 0 : v);
|
|
90
|
+
onChange?.(v);
|
|
91
|
+
}}
|
|
88
92
|
/>
|
|
89
93
|
<button
|
|
90
94
|
type="button"
|
|
91
95
|
className={css.stepper}
|
|
92
96
|
aria-label="Increase"
|
|
93
|
-
disabled={disabled || (max !== undefined &&
|
|
97
|
+
disabled={disabled || (max !== undefined && currentValue >= max)}
|
|
94
98
|
onClick={() => adjust(step)}
|
|
95
99
|
tabIndex={-1}
|
|
96
100
|
>
|
|
@@ -17,13 +17,14 @@ export function Radio({ label, name, error, hint, className, ...props }: RadioPr
|
|
|
17
17
|
const id = name + useId();
|
|
18
18
|
const hintId = id + 'hint';
|
|
19
19
|
const errorId = id + 'error';
|
|
20
|
-
const describedBy =
|
|
20
|
+
const describedBy = [error && errorId, hint && hintId].filter(Boolean).join(' ') || undefined;
|
|
21
21
|
|
|
22
22
|
return (
|
|
23
23
|
<InputContainer className={cn(css.radioGroup, className)}>
|
|
24
24
|
<div className={css.inputWrapper}>
|
|
25
25
|
<input
|
|
26
26
|
aria-describedby={describedBy}
|
|
27
|
+
aria-invalid={!!error}
|
|
27
28
|
type="radio"
|
|
28
29
|
id={id}
|
|
29
30
|
name={name}
|
|
@@ -28,7 +28,8 @@ describe('Select', () => {
|
|
|
28
28
|
|
|
29
29
|
it('renders a placeholder option', () => {
|
|
30
30
|
render(<Select label="Country" name="country" options={options} placeholder="Pick a country" />);
|
|
31
|
-
|
|
31
|
+
// placeholder is hidden from the a11y tree (hidden attribute) but present in the DOM
|
|
32
|
+
expect(screen.getByText('Pick a country')).toBeInTheDocument();
|
|
32
33
|
});
|
|
33
34
|
|
|
34
35
|
it('marks a disabled option as disabled', () => {
|
|
@@ -5,7 +5,7 @@ import { InputContainer } from '../atoms/InputContainer';
|
|
|
5
5
|
import { Label } from '../atoms/Label';
|
|
6
6
|
import { Message } from '../atoms/Message';
|
|
7
7
|
|
|
8
|
-
interface SelectOption {
|
|
8
|
+
export interface SelectOption {
|
|
9
9
|
value: string;
|
|
10
10
|
label: string;
|
|
11
11
|
disabled?: boolean;
|
|
@@ -34,7 +34,7 @@ export function Select({
|
|
|
34
34
|
const id = name + useId();
|
|
35
35
|
const hintId = id + 'hint';
|
|
36
36
|
const errorId = id + 'error';
|
|
37
|
-
const describedBy = error
|
|
37
|
+
const describedBy = [error && errorId, hint && hintId].filter(Boolean).join(' ') || undefined;
|
|
38
38
|
|
|
39
39
|
return (
|
|
40
40
|
<InputContainer className={cn(css.formGroup, className)}>
|
|
@@ -49,7 +49,7 @@ export function Select({
|
|
|
49
49
|
{...props}
|
|
50
50
|
>
|
|
51
51
|
{placeholder && (
|
|
52
|
-
<option value="" disabled>
|
|
52
|
+
<option value="" disabled hidden>
|
|
53
53
|
{placeholder}
|
|
54
54
|
</option>
|
|
55
55
|
)}
|
|
@@ -31,15 +31,16 @@ export function Slider({
|
|
|
31
31
|
const id = name + useId();
|
|
32
32
|
const hintId = id + 'hint';
|
|
33
33
|
const errorId = id + 'error';
|
|
34
|
-
const describedBy = error
|
|
34
|
+
const describedBy = [error && errorId, hint && hintId].filter(Boolean).join(' ') || undefined;
|
|
35
35
|
|
|
36
|
-
const
|
|
37
|
-
const [
|
|
36
|
+
const isControlled = props.value !== undefined;
|
|
37
|
+
const [internalValue, setInternalValue] = useState(Number(props.defaultValue ?? min));
|
|
38
|
+
const currentValue = isControlled ? Number(props.value) : internalValue;
|
|
38
39
|
|
|
39
40
|
const fillPct = ((currentValue - min) / (max - min)) * 100;
|
|
40
41
|
|
|
41
42
|
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
|
|
42
|
-
|
|
43
|
+
if (!isControlled) setInternalValue(Number(e.target.value));
|
|
43
44
|
onChange?.(e);
|
|
44
45
|
};
|
|
45
46
|
|
|
@@ -26,7 +26,7 @@ export function Switch({
|
|
|
26
26
|
const id = name + useId();
|
|
27
27
|
const hintId = id + 'hint';
|
|
28
28
|
const errorId = id + 'error';
|
|
29
|
-
const describedBy = error
|
|
29
|
+
const describedBy = [error && errorId, hint && hintId].filter(Boolean).join(' ') || undefined;
|
|
30
30
|
|
|
31
31
|
return (
|
|
32
32
|
<InputContainer className={cn(css.switchGroup, css[`--size_${size}`], className)}>
|
|
@@ -24,7 +24,7 @@ export function Textarea({
|
|
|
24
24
|
const id = name + useId();
|
|
25
25
|
const hintId = id + 'hint';
|
|
26
26
|
const errorId = id + 'error';
|
|
27
|
-
const describedBy = error
|
|
27
|
+
const describedBy = [error && errorId, hint && hintId].filter(Boolean).join(' ') || undefined;
|
|
28
28
|
|
|
29
29
|
return (
|
|
30
30
|
<InputContainer className={cn(css.formGroup, className)}>
|
|
@@ -10,8 +10,8 @@ export const Message = ({ message, type, inputId }: MessageProps) => {
|
|
|
10
10
|
if (!message) return null;
|
|
11
11
|
|
|
12
12
|
return (
|
|
13
|
-
<
|
|
13
|
+
<p id={inputId + type} className={css[type]}>
|
|
14
14
|
{message}
|
|
15
|
-
</
|
|
15
|
+
</p>
|
|
16
16
|
);
|
|
17
17
|
};
|
|
@@ -10,7 +10,7 @@ interface ButtonGroupProps {
|
|
|
10
10
|
|
|
11
11
|
export function ButtonGroup({ children, className, variant }: ButtonGroupProps) {
|
|
12
12
|
return (
|
|
13
|
-
<div className={cn(css.buttonGroup, className, variant && css[`--
|
|
13
|
+
<div className={cn(css.buttonGroup, className, variant && css[`--variant_${variant}`])}>
|
|
14
14
|
<div className={css.container}>{children}</div>
|
|
15
15
|
</div>
|
|
16
16
|
);
|
|
@@ -10,6 +10,7 @@ interface CardProps {
|
|
|
10
10
|
textAlign?: 'start' | 'center' | 'end';
|
|
11
11
|
style?: CSSProperties;
|
|
12
12
|
onClick?: () => void;
|
|
13
|
+
'aria-label'?: string;
|
|
13
14
|
}
|
|
14
15
|
|
|
15
16
|
export function Card({
|
|
@@ -20,6 +21,7 @@ export function Card({
|
|
|
20
21
|
textAlign = 'start',
|
|
21
22
|
style,
|
|
22
23
|
onClick,
|
|
24
|
+
'aria-label': ariaLabel,
|
|
23
25
|
}: CardProps) {
|
|
24
26
|
const classNames = cn(
|
|
25
27
|
css.card,
|
|
@@ -37,6 +39,7 @@ export function Card({
|
|
|
37
39
|
className={classNames}
|
|
38
40
|
onClick={onClick}
|
|
39
41
|
style={style}
|
|
42
|
+
aria-label={ariaLabel}
|
|
40
43
|
{...(onClick && { type: 'button' as const })}
|
|
41
44
|
>
|
|
42
45
|
{children}
|
|
@@ -1,9 +1,8 @@
|
|
|
1
|
+
import type { JSX } from 'react';
|
|
1
2
|
import css from './SectionHeader.module.css';
|
|
2
3
|
import { cn } from '@boostdev/design-system-foundation';
|
|
3
|
-
import {JSX} from "react/jsx-runtime";
|
|
4
|
-
type IntrinsicElements = JSX.IntrinsicElements;
|
|
5
4
|
|
|
6
|
-
type IntrinsicElement = keyof IntrinsicElements
|
|
5
|
+
type IntrinsicElement = keyof JSX.IntrinsicElements;
|
|
7
6
|
|
|
8
7
|
type SectionHeaderProps = {
|
|
9
8
|
title: string;
|
|
@@ -2,7 +2,7 @@ import { ReactNode, useId, useState } from 'react';
|
|
|
2
2
|
import css from './Accordion.module.css';
|
|
3
3
|
import { cn } from '@boostdev/design-system-foundation';
|
|
4
4
|
|
|
5
|
-
interface AccordionItem {
|
|
5
|
+
export interface AccordionItem {
|
|
6
6
|
id: string;
|
|
7
7
|
title: ReactNode;
|
|
8
8
|
content: ReactNode;
|