@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.
Files changed (51) hide show
  1. package/dist/client.cjs +185 -104
  2. package/dist/client.css +435 -411
  3. package/dist/client.d.cts +15 -13
  4. package/dist/client.d.ts +15 -13
  5. package/dist/client.js +189 -107
  6. package/dist/index.cjs +185 -104
  7. package/dist/index.css +435 -411
  8. package/dist/index.d.cts +15 -13
  9. package/dist/index.d.ts +15 -13
  10. package/dist/index.js +189 -107
  11. package/package.json +1 -1
  12. package/src/components/interaction/Button/Button.tsx +35 -8
  13. package/src/components/interaction/Dialog/Dialog.spec.tsx +10 -10
  14. package/src/components/interaction/Dialog/Dialog.tsx +21 -7
  15. package/src/components/interaction/DropdownMenu/DropdownMenu.tsx +1 -1
  16. package/src/components/interaction/DropdownMenu/index.ts +1 -0
  17. package/src/components/interaction/Toast/Toast.module.css +28 -0
  18. package/src/components/interaction/Toast/Toast.tsx +10 -1
  19. package/src/components/interaction/form/Checkbox/Checkbox.module.css +1 -1
  20. package/src/components/interaction/form/Checkbox/Checkbox.tsx +2 -1
  21. package/src/components/interaction/form/Combobox/Combobox.tsx +11 -3
  22. package/src/components/interaction/form/Combobox/index.ts +1 -0
  23. package/src/components/interaction/form/FileInput/FileInput.tsx +15 -2
  24. package/src/components/interaction/form/FormInput/FormInput.tsx +4 -3
  25. package/src/components/interaction/form/NumberInput/NumberInput.tsx +21 -17
  26. package/src/components/interaction/form/Radio/Radio.module.css +1 -1
  27. package/src/components/interaction/form/Radio/Radio.tsx +2 -1
  28. package/src/components/interaction/form/Select/Select.spec.tsx +2 -1
  29. package/src/components/interaction/form/Select/Select.tsx +3 -3
  30. package/src/components/interaction/form/Select/index.ts +1 -0
  31. package/src/components/interaction/form/Slider/Slider.tsx +5 -4
  32. package/src/components/interaction/form/Switch/Switch.tsx +1 -1
  33. package/src/components/interaction/form/Textarea/Textarea.tsx +1 -1
  34. package/src/components/interaction/form/atoms/Message.tsx +2 -2
  35. package/src/components/layout/ButtonGroup/ButtonGroup.tsx +1 -1
  36. package/src/components/layout/Card/Card.tsx +3 -0
  37. package/src/components/layout/SectionHeader/SectionHeader.tsx +2 -3
  38. package/src/components/ui/Accordion/Accordion.tsx +1 -1
  39. package/src/components/ui/Accordion/index.ts +1 -0
  40. package/src/components/ui/Breadcrumb/Breadcrumb.tsx +2 -2
  41. package/src/components/ui/Breadcrumb/index.ts +1 -0
  42. package/src/components/ui/Calendar/Calendar.tsx +8 -3
  43. package/src/components/ui/DescriptionList/DescriptionList.tsx +1 -1
  44. package/src/components/ui/DescriptionList/index.ts +1 -0
  45. package/src/components/ui/Link/Link.tsx +3 -1
  46. package/src/components/ui/Separator/Separator.module.css +1 -1
  47. package/src/components/ui/Table/Table.tsx +1 -1
  48. package/src/components/ui/Table/index.ts +1 -0
  49. package/src/components/ui/Tabs/Tabs.tsx +1 -1
  50. package/src/components/ui/Tabs/index.ts +1 -0
  51. package/src/index.ts +8 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@boostdev/design-system-components",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "description": "BoostDev React component library: accessible, token-driven components built on @boostdev/design-system-foundation",
5
5
  "keywords": [
6
6
  "React",
@@ -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 allChildren = (
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
- {!!iconStart && <span className={css.prefix}>{iconStart}</span>}
61
+ {Boolean(iconStart) && <span className={css.prefix}>{iconStart}</span>}
50
62
  {children}
51
- {!!iconEnd && <span className={css.suffix}>{iconEnd}</span>}
63
+ {Boolean(iconEnd) && <span className={css.suffix}>{iconEnd}</span>}
52
64
  </>
53
65
  );
54
66
 
55
67
  if (href) {
56
68
  return (
57
- <a className={classNames} href={href} target={target} rel={rel} onClick={onClick as MouseEventHandler<HTMLAnchorElement>} {...rest}>
58
- {allChildren}
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 type={type} className={classNames} disabled={disabled} onClick={onClick as MouseEventHandler<HTMLButtonElement>} {...rest}>
65
- {allChildren}
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 isVisible><p>Dialog content</p></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 isVisible becomes true', () => {
22
- render(<Dialog isVisible><span>Content</span></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 isVisible><span>Content</span></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 handleClose when close button is clicked', async () => {
31
+ it('calls onClose when close button is clicked', async () => {
32
32
  const user = userEvent.setup();
33
- const handleClose = vi.fn();
34
- render(<Dialog isVisible handleClose={handleClose}><span>Content</span></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(handleClose).toHaveBeenCalled();
36
+ expect(onClose).toHaveBeenCalled();
37
37
  });
38
38
 
39
- it('does not call showModal when isVisible is false', () => {
40
- render(<Dialog isVisible={false}><span>Content</span></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
- isVisible?: boolean;
9
- handleClose?: () => void;
8
+ isOpen?: boolean;
9
+ onClose?: () => void;
10
10
  }
11
11
 
12
- export function Dialog({ children, isVisible = false, className, handleClose }: DialogProps) {
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 (isVisible) {
18
+ if (isOpen) {
19
19
  dialog.showModal();
20
20
  } else if (dialog.open) {
21
21
  dialog.close();
22
22
  }
23
- }, [isVisible]);
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 ref={dialogRef} className={cn(className, css.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={handleClose}
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">
@@ -11,7 +11,7 @@ import {
11
11
  import css from './DropdownMenu.module.css';
12
12
  import { cn } from '@boostdev/design-system-foundation';
13
13
 
14
- interface DropdownMenuItem {
14
+ export interface DropdownMenuItem {
15
15
  id: string;
16
16
  label: string;
17
17
  onClick?: () => void;
@@ -1 +1,2 @@
1
1
  export { DropdownMenu } from './DropdownMenu';
2
+ export type { DropdownMenuItem } from './DropdownMenu';
@@ -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 <div className={cn(css.toast, css[`--variant_${toast.variant}`])}>{toast.message}</div>;
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(--color_border);
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 = !!error ? errorId : hintId;
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 ? errorId : hintId;
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}
@@ -1 +1,2 @@
1
1
  export { Combobox } from './Combobox';
2
+ export type { ComboboxOption } from './Combobox';
@@ -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 ? errorId : hintId;
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
- handleFiles(e.dataTransfer.files);
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 = !!error ? errorId : hintId;
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 { useId, useRef } from 'react';
1
+ import {useId, useRef, useState} from 'react';
2
2
  import css from './NumberInput.module.css';
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';
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 ? errorId : hintId;
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
- const withMax = max !== undefined ? Math.min(max, withMin) : withMin;
46
- return withMax;
49
+ return max !== undefined ? Math.min(max, withMin) : withMin;
47
50
  };
48
51
 
49
52
  const adjust = (delta: number) => {
50
- if (!inputRef.current) return;
51
- const current = parseFloat(inputRef.current.value) || 0;
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 && (value ?? defaultValue ?? 0) <= min)}
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
- defaultValue={defaultValue}
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 => onChange?.(parseFloat(e.target.value))}
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 && (value ?? defaultValue ?? 0) >= max)}
97
+ disabled={disabled || (max !== undefined && currentValue >= max)}
94
98
  onClick={() => adjust(step)}
95
99
  tabIndex={-1}
96
100
  >
@@ -18,7 +18,7 @@
18
18
  margin-block-start: 0.25em;
19
19
  width: var(--inputSize);
20
20
  height: var(--inputSize);
21
- border: 1px solid var(--color_border);
21
+ border: 1px solid var(--color_on-bg);
22
22
  border-radius: 50%;
23
23
  appearance: none;
24
24
  background-color: var(--color_bg);
@@ -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 = !!error ? errorId : hintId;
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
- expect(screen.getByRole('option', { name: 'Pick a country' })).toBeInTheDocument();
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 ? errorId : hintId;
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
  )}
@@ -1 +1,2 @@
1
1
  export { Select } from './Select';
2
+ export type { SelectOption } from './Select';
@@ -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 ? errorId : hintId;
34
+ const describedBy = [error && errorId, hint && hintId].filter(Boolean).join(' ') || undefined;
35
35
 
36
- const initialValue = Number(props.value ?? props.defaultValue ?? min);
37
- const [currentValue, setCurrentValue] = useState(initialValue);
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
- setCurrentValue(Number(e.target.value));
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 ? errorId : hintId;
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 ? errorId : hintId;
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
- <label id={inputId + type} htmlFor={inputId} className={css[type]}>
13
+ <p id={inputId + type} className={css[type]}>
14
14
  {message}
15
- </label>
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[`--variant__${variant}`])}>
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;