tharaday 0.7.6 → 0.8.0

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 (59) hide show
  1. package/dist/ds.css +1 -1
  2. package/dist/ds.js +752 -702
  3. package/dist/ds.umd.cjs +1 -1
  4. package/dist/src/components/Button/Button.d.ts +1 -1
  5. package/dist/src/components/Button/Button.stories.d.ts +1 -1
  6. package/dist/src/components/ProgressBar/ProgressBar.types.d.ts +1 -1
  7. package/dist/src/components/Select/Select.types.d.ts +10 -4
  8. package/dist/src/components/Table/Table.d.ts +1 -1
  9. package/dist/src/components/Table/Table.stories.d.ts +1 -1
  10. package/dist/src/components/Table/Table.types.d.ts +1 -0
  11. package/dist/src/components/Text/Text.d.ts +1 -1
  12. package/dist/src/components/Text/Text.stories.d.ts +1 -1
  13. package/dist/src/components/Text/Text.types.d.ts +2 -2
  14. package/dist/src/hooks/useComponentId.d.ts +5 -0
  15. package/package.json +1 -1
  16. package/src/components/Accordion/Accordion.tsx +4 -3
  17. package/src/components/Avatar/Avatar.test.tsx +10 -0
  18. package/src/components/Avatar/Avatar.tsx +8 -1
  19. package/src/components/Box/Box.test.tsx +84 -0
  20. package/src/components/Breadcrumbs/Breadcrumbs.tsx +2 -2
  21. package/src/components/Button/Button.test.tsx +10 -0
  22. package/src/components/Button/Button.tsx +2 -1
  23. package/src/components/Card/Card.test.tsx +85 -0
  24. package/src/components/Checkbox/Checkbox.tsx +2 -3
  25. package/src/components/Divider/Divider.test.tsx +41 -0
  26. package/src/components/Dropdown/Dropdown.module.css +16 -1
  27. package/src/components/Dropdown/Dropdown.test.tsx +9 -0
  28. package/src/components/Dropdown/Dropdown.tsx +42 -10
  29. package/src/components/Header/Header.test.tsx +69 -0
  30. package/src/components/Input/Input.tsx +2 -3
  31. package/src/components/Loader/Loader.test.tsx +50 -0
  32. package/src/components/Modal/Modal.test.tsx +5 -0
  33. package/src/components/Modal/Modal.tsx +4 -3
  34. package/src/components/NavBar/NavBar.test.tsx +63 -0
  35. package/src/components/Pagination/Pagination.tsx +5 -4
  36. package/src/components/ProgressBar/ProgressBar.module.css +4 -0
  37. package/src/components/ProgressBar/ProgressBar.test.tsx +5 -0
  38. package/src/components/ProgressBar/ProgressBar.tsx +2 -3
  39. package/src/components/ProgressBar/ProgressBar.types.ts +1 -1
  40. package/src/components/RadioButton/RadioButton.tsx +2 -3
  41. package/src/components/Select/Select.tsx +2 -3
  42. package/src/components/Select/Select.types.ts +6 -4
  43. package/src/components/Skeleton/Skeleton.test.tsx +61 -0
  44. package/src/components/Slider/Slider.test.tsx +23 -0
  45. package/src/components/Slider/Slider.tsx +20 -7
  46. package/src/components/Stepper/Stepper.tsx +2 -3
  47. package/src/components/Switch/Switch.tsx +2 -3
  48. package/src/components/Table/Table.module.css +8 -0
  49. package/src/components/Table/Table.test.tsx +18 -0
  50. package/src/components/Table/Table.tsx +2 -0
  51. package/src/components/Table/Table.types.ts +1 -0
  52. package/src/components/Tabs/Tabs.tsx +4 -3
  53. package/src/components/Text/Text.test.tsx +60 -0
  54. package/src/components/Text/Text.tsx +42 -15
  55. package/src/components/Text/Text.types.ts +3 -2
  56. package/src/components/Textarea/Textarea.tsx +2 -3
  57. package/src/components/Tooltip/Tooltip.tsx +2 -3
  58. package/src/components/Tree/Tree.test.tsx +39 -0
  59. package/src/hooks/useComponentId.ts +10 -0
@@ -8,13 +8,15 @@ export interface SelectOption {
8
8
  disabled?: boolean;
9
9
  }
10
10
 
11
- export interface SelectProps extends Omit<SelectHTMLAttributes<HTMLSelectElement>, 'size'> {
11
+ type SelectBaseProps = Omit<SelectHTMLAttributes<HTMLSelectElement>, 'size'> & {
12
12
  className?: string;
13
13
  size?: SelectSize;
14
14
  error?: boolean;
15
15
  label?: string;
16
16
  helperText?: string;
17
- options?: SelectOption[];
18
- children?: ReactNode;
19
17
  fullWidth?: boolean;
20
- }
18
+ };
19
+
20
+ export type SelectProps =
21
+ | (SelectBaseProps & { options: SelectOption[]; children?: never })
22
+ | (SelectBaseProps & { options?: never; children?: ReactNode });
@@ -0,0 +1,61 @@
1
+ import { render } from '@testing-library/react';
2
+
3
+ import { Skeleton } from './Skeleton.tsx';
4
+
5
+ describe('Skeleton', () => {
6
+ it('renders a div', () => {
7
+ const { container } = render(<Skeleton />);
8
+ expect(container.firstChild).toBeInTheDocument();
9
+ });
10
+
11
+ it('is aria-hidden by default', () => {
12
+ const { container } = render(<Skeleton />);
13
+ expect(container.firstChild).toHaveAttribute('aria-hidden', 'true');
14
+ });
15
+
16
+ it('allows overriding aria-hidden', () => {
17
+ const { container } = render(<Skeleton aria-hidden={false} />);
18
+ expect(container.firstChild).toHaveAttribute('aria-hidden', 'false');
19
+ });
20
+
21
+ it('applies rectangular variant class by default', () => {
22
+ const { container } = render(<Skeleton />);
23
+ expect(container.firstChild).toHaveClass('rectangular');
24
+ });
25
+
26
+ it('applies circular variant class', () => {
27
+ const { container } = render(<Skeleton variant="circular" />);
28
+ expect(container.firstChild).toHaveClass('circular');
29
+ });
30
+
31
+ it('applies text variant class', () => {
32
+ const { container } = render(<Skeleton variant="text" />);
33
+ expect(container.firstChild).toHaveClass('text');
34
+ });
35
+
36
+ it('applies pulse animation class by default', () => {
37
+ const { container } = render(<Skeleton />);
38
+ expect(container.firstChild).toHaveClass('pulse');
39
+ });
40
+
41
+ it('applies wave animation class', () => {
42
+ const { container } = render(<Skeleton animation="wave" />);
43
+ expect(container.firstChild).toHaveClass('wave');
44
+ });
45
+
46
+ it('applies no animation class when animation="none"', () => {
47
+ const { container } = render(<Skeleton animation="none" />);
48
+ expect(container.firstChild).not.toHaveClass('pulse');
49
+ expect(container.firstChild).not.toHaveClass('wave');
50
+ });
51
+
52
+ it('applies custom width as inline style', () => {
53
+ const { container } = render(<Skeleton width="200px" />);
54
+ expect((container.firstChild as HTMLElement).style.width).toBe('200px');
55
+ });
56
+
57
+ it('applies custom height as inline style', () => {
58
+ const { container } = render(<Skeleton height="40px" />);
59
+ expect((container.firstChild as HTMLElement).style.height).toBe('40px');
60
+ });
61
+ });
@@ -46,4 +46,27 @@ describe('Slider', () => {
46
46
  render(<Slider defaultValue={[20, 80]} />);
47
47
  expect(screen.getAllByRole('slider')).toHaveLength(2);
48
48
  });
49
+
50
+ it('renders a number input when showInputs is true', () => {
51
+ render(<Slider showInputs />);
52
+ expect(screen.getByRole('spinbutton')).toBeInTheDocument();
53
+ });
54
+
55
+ it('renders two number inputs for dual range with showInputs', () => {
56
+ render(<Slider defaultValue={[20, 80]} showInputs />);
57
+ expect(screen.getAllByRole('spinbutton')).toHaveLength(2);
58
+ });
59
+
60
+ it('labels the dual range inputs correctly', () => {
61
+ render(<Slider defaultValue={[20, 80]} showInputs label="Price" />);
62
+ expect(screen.getByLabelText('Price minimum input')).toBeInTheDocument();
63
+ expect(screen.getByLabelText('Price maximum input')).toBeInTheDocument();
64
+ });
65
+
66
+ it('shows aria-valuetext on both range inputs when showValue is true', () => {
67
+ render(<Slider defaultValue={[10, 90]} showValue />);
68
+ const [start, end] = screen.getAllByRole('slider');
69
+ expect(start).toHaveAttribute('aria-valuetext', '10 - 90');
70
+ expect(end).toHaveAttribute('aria-valuetext', '10 - 90');
71
+ });
49
72
  });
@@ -1,5 +1,7 @@
1
1
  import classnames from 'classnames';
2
- import { useId, useMemo, useState } from 'react';
2
+ import { useEffect, useMemo, useState } from 'react';
3
+
4
+ import { useComponentId } from '../../hooks/useComponentId.ts';
3
5
 
4
6
  import { Input } from '../Input/Input.tsx';
5
7
  import styles from './Slider.module.css';
@@ -59,8 +61,7 @@ export const Slider = ({
59
61
  onValueChange,
60
62
  ...props
61
63
  }: SliderProps) => {
62
- const baseId = useId();
63
- const componentId = id ?? `ds-slider-${baseId}`;
64
+ const componentId = useComponentId('slider', id);
64
65
  const helperId = helperText ? `${componentId}-help` : undefined;
65
66
  const min = toNumber(props.min as number | string | undefined, 0);
66
67
  const max = toNumber(props.max as number | string | undefined, 100);
@@ -75,6 +76,17 @@ export const Slider = ({
75
76
  const [internalPair, setInternalPair] = useState<[number, number]>(initialPair);
76
77
  const resolvedPair = value != null ? normalizeToPair(value, min, max) : internalPair;
77
78
  const [startValue, endValue] = resolvedPair;
79
+
80
+ const [startInputValue, setStartInputValue] = useState(String(startValue));
81
+ const [endInputValue, setEndInputValue] = useState(String(endValue));
82
+
83
+ useEffect(() => {
84
+ setStartInputValue(String(startValue));
85
+ }, [startValue]);
86
+
87
+ useEffect(() => {
88
+ setEndInputValue(String(endValue));
89
+ }, [endValue]);
78
90
  const showValueText = isDual ? `${startValue} - ${endValue}` : String(startValue);
79
91
  const rangeSpan = Math.max(max - min, 1);
80
92
  const leftPercent = ((startValue - min) / rangeSpan) * 100;
@@ -164,6 +176,7 @@ export const Slider = ({
164
176
  className={classnames(styles.inputRoot, styles.inputEnd)}
165
177
  aria-describedby={helperId}
166
178
  aria-label={label ? `${label} maximum` : 'Slider maximum'}
179
+ aria-valuetext={showValue ? showValueText : undefined}
167
180
  {...props}
168
181
  min={min}
169
182
  max={max}
@@ -179,11 +192,11 @@ export const Slider = ({
179
192
  {showInputs && (
180
193
  <div className={classnames(styles.inputsRow, !isDual && styles.singleInputRow)}>
181
194
  <Input
182
- key={`slider-start-${startValue}-${endValue}`}
183
195
  type="number"
184
196
  inputMode="decimal"
185
197
  size={size}
186
- defaultValue={startValue}
198
+ value={startInputValue}
199
+ onChange={(event) => setStartInputValue(event.target.value)}
187
200
  min={min}
188
201
  max={isDual ? endValue : max}
189
202
  step={step}
@@ -203,11 +216,11 @@ export const Slider = ({
203
216
  {isDual && <span className={styles.separator}>-</span>}
204
217
  {isDual && (
205
218
  <Input
206
- key={`slider-end-${startValue}-${endValue}`}
207
219
  type="number"
208
220
  inputMode="decimal"
209
221
  size={size}
210
- defaultValue={endValue}
222
+ value={endInputValue}
223
+ onChange={(event) => setEndInputValue(event.target.value)}
211
224
  min={startValue}
212
225
  max={max}
213
226
  step={step}
@@ -1,5 +1,5 @@
1
1
  import classnames from 'classnames';
2
- import { useId } from 'react';
2
+ import { useComponentId } from '../../hooks/useComponentId.ts';
3
3
 
4
4
  import { Step } from './Step.tsx';
5
5
  import styles from './Stepper.module.css';
@@ -17,8 +17,7 @@ export const Stepper = ({
17
17
  id,
18
18
  ...props
19
19
  }: StepperProps) => {
20
- const baseId = useId();
21
- const componentId = id ?? `ds-stepper-${baseId}`;
20
+ const componentId = useComponentId('stepper', id);
22
21
  const currentIndex = resolveCurrentIndex(currentStep, steps);
23
22
  const listLabel = ariaLabel ?? 'Progress';
24
23
 
@@ -1,12 +1,11 @@
1
1
  import classnames from 'classnames';
2
- import { useId } from 'react';
2
+ import { useComponentId } from '../../hooks/useComponentId.ts';
3
3
 
4
4
  import styles from './Switch.module.css';
5
5
  import type { SwitchProps } from './Switch.types.ts';
6
6
 
7
7
  export const Switch = ({ label, helperText, className, disabled, id, ...props }: SwitchProps) => {
8
- const baseId = useId();
9
- const componentId = id ?? `ds-switch-${baseId}`;
8
+ const componentId = useComponentId('switch', id);
10
9
  const helperId = helperText ? `${componentId}-help` : undefined;
11
10
 
12
11
  return (
@@ -72,6 +72,14 @@
72
72
  text-align: justify;
73
73
  }
74
74
 
75
+ .caption {
76
+ caption-side: top;
77
+ padding: var(--ds-space-3) var(--ds-space-4);
78
+ font-size: var(--ds-font-size-sm);
79
+ color: var(--ds-text-2);
80
+ text-align: left;
81
+ }
82
+
75
83
  .loading {
76
84
  opacity: 0.7;
77
85
  pointer-events: none;
@@ -63,6 +63,24 @@ describe('Table', () => {
63
63
  expect(screen.getByRole('table')).not.toHaveAttribute('aria-busy');
64
64
  });
65
65
 
66
+ it('renders a caption when provided', () => {
67
+ render(
68
+ <Table caption="User list">
69
+ <TableBody>
70
+ <TableRow>
71
+ <TableCell>Alice</TableCell>
72
+ </TableRow>
73
+ </TableBody>
74
+ </Table>
75
+ );
76
+ expect(screen.getByText('User list')).toBeInTheDocument();
77
+ });
78
+
79
+ it('does not render a caption element when caption is omitted', () => {
80
+ render(<BasicTable />);
81
+ expect(document.querySelector('caption')).not.toBeInTheDocument();
82
+ });
83
+
66
84
  it('renders a footer', () => {
67
85
  render(
68
86
  <Table>
@@ -18,6 +18,7 @@ export const Table = ({
18
18
  hoverable,
19
19
  dense,
20
20
  isLoading,
21
+ caption,
21
22
  ...props
22
23
  }: TableProps) => {
23
24
  return (
@@ -32,6 +33,7 @@ export const Table = ({
32
33
  aria-busy={isLoading || undefined}
33
34
  {...props}
34
35
  >
36
+ {caption && <caption className={styles.caption}>{caption}</caption>}
35
37
  {children}
36
38
  </table>
37
39
  </div>
@@ -11,6 +11,7 @@ export interface TableProps extends TableHTMLAttributes<HTMLTableElement> {
11
11
  hoverable?: boolean;
12
12
  dense?: boolean;
13
13
  isLoading?: boolean;
14
+ caption?: string;
14
15
  }
15
16
 
16
17
  export type TableHeaderProps = HTMLAttributes<HTMLTableSectionElement>;
@@ -1,5 +1,7 @@
1
1
  import classnames from 'classnames';
2
- import { useId, useState, useRef, type KeyboardEvent } from 'react';
2
+ import { useState, useRef, type KeyboardEvent } from 'react';
3
+
4
+ import { useComponentId } from '../../hooks/useComponentId.ts';
3
5
 
4
6
  import styles from './Tabs.module.css';
5
7
  import type { TabsProps } from './Tabs.types.ts';
@@ -13,8 +15,7 @@ export const Tabs = ({
13
15
  variant = 'line',
14
16
  id,
15
17
  }: TabsProps) => {
16
- const baseId = useId();
17
- const componentId = id ?? `ds-tabs-${baseId}`;
18
+ const componentId = useComponentId('tabs', id);
18
19
  const [uncontrolledActiveId, setUncontrolledActiveId] = useState(
19
20
  defaultActiveId || (items.length > 0 ? items[0].id : '')
20
21
  );
@@ -0,0 +1,60 @@
1
+ import { render, screen } from '@testing-library/react';
2
+
3
+ import { Text } from './Text.tsx';
4
+
5
+ describe('Text', () => {
6
+ it('renders children', () => {
7
+ render(<Text>Hello</Text>);
8
+ expect(screen.getByText('Hello')).toBeInTheDocument();
9
+ });
10
+
11
+ it('renders as a <p> by default (body-md variant)', () => {
12
+ const { container } = render(<Text>Content</Text>);
13
+ expect(container.querySelector('p')).toBeInTheDocument();
14
+ });
15
+
16
+ it('renders as h1 for variant="h1"', () => {
17
+ render(<Text variant="h1">Title</Text>);
18
+ expect(screen.getByRole('heading', { level: 1, name: 'Title' })).toBeInTheDocument();
19
+ });
20
+
21
+ it('renders as h2 for variant="h2"', () => {
22
+ render(<Text variant="h2">Subtitle</Text>);
23
+ expect(screen.getByRole('heading', { level: 2, name: 'Subtitle' })).toBeInTheDocument();
24
+ });
25
+
26
+ it('renders as code element for variant="code"', () => {
27
+ const { container } = render(<Text variant="code">const x = 1</Text>);
28
+ expect(container.querySelector('code')).toBeInTheDocument();
29
+ });
30
+
31
+ it('renders as a custom element via as prop', () => {
32
+ const { container } = render(<Text as="span">Span text</Text>);
33
+ expect(container.querySelector('span')).toBeInTheDocument();
34
+ });
35
+
36
+ it('applies numeric padding as a CSS class', () => {
37
+ const { container } = render(<Text padding={4}>Text</Text>);
38
+ expect(container.firstChild).toHaveClass('p-4');
39
+ });
40
+
41
+ it('applies string padding as inline style', () => {
42
+ const { container } = render(<Text padding="1.5rem">Text</Text>);
43
+ expect((container.firstChild as HTMLElement).style.padding).toBe('1.5rem');
44
+ });
45
+
46
+ it('applies numeric margin as a CSS class', () => {
47
+ const { container } = render(<Text margin={2}>Text</Text>);
48
+ expect(container.firstChild).toHaveClass('margin-2');
49
+ });
50
+
51
+ it('applies noWrap class when noWrap is true', () => {
52
+ const { container } = render(<Text noWrap>Text</Text>);
53
+ expect(container.firstChild).toHaveClass('noWrap');
54
+ });
55
+
56
+ it('forwards className', () => {
57
+ const { container } = render(<Text className="custom">Text</Text>);
58
+ expect(container.firstChild).toHaveClass('custom');
59
+ });
60
+ });
@@ -1,6 +1,7 @@
1
1
  import classnames from 'classnames';
2
- import type { ElementType } from 'react';
2
+ import type { CSSProperties, ElementType } from 'react';
3
3
 
4
+ import { getSpacingStyles } from '../Box/helpers/getSpacingStyles.ts';
4
5
  import styles from './Text.module.css';
5
6
  import type { TextProps, TextVariant } from './Text.types.ts';
6
7
 
@@ -27,6 +28,7 @@ export const Text = ({
27
28
  color,
28
29
  noWrap,
29
30
  className,
31
+ style,
30
32
  padding,
31
33
  paddingX,
32
34
  paddingY,
@@ -45,6 +47,30 @@ export const Text = ({
45
47
  }: TextProps) => {
46
48
  const Component = as || variantElementMap[variant] || 'span';
47
49
 
50
+ const spacingStyles: CSSProperties = {
51
+ ...style,
52
+ ...getSpacingStyles(
53
+ 'padding',
54
+ padding,
55
+ paddingX,
56
+ paddingY,
57
+ paddingTop,
58
+ paddingBottom,
59
+ paddingLeft,
60
+ paddingRight
61
+ ),
62
+ ...getSpacingStyles(
63
+ 'margin',
64
+ margin,
65
+ marginX,
66
+ marginY,
67
+ marginTop,
68
+ marginBottom,
69
+ marginLeft,
70
+ marginRight
71
+ ),
72
+ };
73
+
48
74
  return (
49
75
  <Component
50
76
  className={classnames(
@@ -54,22 +80,23 @@ export const Text = ({
54
80
  weight && styles[weight],
55
81
  color && styles[`color-${color}`],
56
82
  noWrap && styles.noWrap,
57
- padding !== undefined && styles[`p-${padding}`],
58
- paddingX !== undefined && styles[`px-${paddingX}`],
59
- paddingY !== undefined && styles[`py-${paddingY}`],
60
- paddingTop !== undefined && styles[`pt-${paddingTop}`],
61
- paddingBottom !== undefined && styles[`pb-${paddingBottom}`],
62
- paddingLeft !== undefined && styles[`pl-${paddingLeft}`],
63
- paddingRight !== undefined && styles[`pr-${paddingRight}`],
64
- margin !== undefined && styles[`margin-${margin}`],
65
- marginX !== undefined && styles[`marginX-${marginX}`],
66
- marginY !== undefined && styles[`marginY-${marginY}`],
67
- marginTop !== undefined && styles[`marginTop-${marginTop}`],
68
- marginBottom !== undefined && styles[`marginBottom-${marginBottom}`],
69
- marginLeft !== undefined && styles[`marginLeft-${marginLeft}`],
70
- marginRight !== undefined && styles[`marginRight-${marginRight}`],
83
+ typeof padding === 'number' && styles[`p-${padding}`],
84
+ typeof paddingX === 'number' && styles[`px-${paddingX}`],
85
+ typeof paddingY === 'number' && styles[`py-${paddingY}`],
86
+ typeof paddingTop === 'number' && styles[`pt-${paddingTop}`],
87
+ typeof paddingBottom === 'number' && styles[`pb-${paddingBottom}`],
88
+ typeof paddingLeft === 'number' && styles[`pl-${paddingLeft}`],
89
+ typeof paddingRight === 'number' && styles[`pr-${paddingRight}`],
90
+ typeof margin === 'number' && styles[`margin-${margin}`],
91
+ typeof marginX === 'number' && styles[`marginX-${marginX}`],
92
+ typeof marginY === 'number' && styles[`marginY-${marginY}`],
93
+ typeof marginTop === 'number' && styles[`marginTop-${marginTop}`],
94
+ typeof marginBottom === 'number' && styles[`marginBottom-${marginBottom}`],
95
+ typeof marginLeft === 'number' && styles[`marginLeft-${marginLeft}`],
96
+ typeof marginRight === 'number' && styles[`marginRight-${marginRight}`],
71
97
  className
72
98
  )}
99
+ style={spacingStyles}
73
100
  {...props}
74
101
  >
75
102
  {children}
@@ -1,7 +1,8 @@
1
1
  import type { ReactNode, ElementType, HTMLAttributes } from 'react';
2
2
 
3
- export type BoxPadding = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 8 | 10 | 12 | 14;
4
- export type BoxMargin = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 8 | 10 | 12 | 14;
3
+ import type { BoxPadding, BoxMargin } from '../Box/Box.types.ts';
4
+
5
+ export type { BoxPadding, BoxMargin };
5
6
 
6
7
  export type TextVariant =
7
8
  | 'h1'
@@ -1,5 +1,5 @@
1
1
  import classnames from 'classnames';
2
- import { useId } from 'react';
2
+ import { useComponentId } from '../../hooks/useComponentId.ts';
3
3
 
4
4
  import styles from './Textarea.module.css';
5
5
  import type { TextareaProps } from './Textarea.types.ts';
@@ -15,8 +15,7 @@ export const Textarea = ({
15
15
  rows = 4,
16
16
  ...props
17
17
  }: TextareaProps) => {
18
- const baseId = useId();
19
- const componentId = id ?? `ds-textarea-${baseId}`;
18
+ const componentId = useComponentId('textarea', id);
20
19
  const helperId = helperText ? `${componentId}-help` : undefined;
21
20
 
22
21
  return (
@@ -2,13 +2,13 @@ import classnames from 'classnames';
2
2
  import {
3
3
  useState,
4
4
  useRef,
5
- useId,
6
5
  isValidElement,
7
6
  cloneElement,
8
7
  type KeyboardEvent,
9
8
  type ReactElement,
10
9
  } from 'react';
11
10
 
11
+ import { useComponentId } from '../../hooks/useComponentId.ts';
12
12
  import styles from './Tooltip.module.css';
13
13
  import type { TooltipProps } from './Tooltip.types.ts';
14
14
 
@@ -23,8 +23,7 @@ export const Tooltip = ({
23
23
  }: TooltipProps) => {
24
24
  const [isVisible, setIsVisible] = useState(false);
25
25
  const timeoutRef = useRef<number | null>(null);
26
- const baseId = useId();
27
- const componentId = id ?? `ds-tooltip-${baseId}`;
26
+ const componentId = useComponentId('tooltip', id);
28
27
  const tooltipId = `${componentId}-content`;
29
28
 
30
29
  const showTooltip = () => {
@@ -113,4 +113,43 @@ describe('Tree', () => {
113
113
  render(<Tree data={{ val: null }} />);
114
114
  expect(screen.getByText('null')).toBeInTheDocument();
115
115
  });
116
+
117
+ it('moves focus to first item with Home key', async () => {
118
+ render(<Tree data={objectData} />);
119
+ const items = screen.getAllByRole('treeitem');
120
+ items[2].tabIndex = 0;
121
+ items[2].focus();
122
+ await userEvent.keyboard('{Home}');
123
+ expect(document.activeElement).toBe(items[0]);
124
+ });
125
+
126
+ it('moves focus to last item with End key', async () => {
127
+ render(<Tree data={objectData} />);
128
+ const items = screen.getAllByRole('treeitem');
129
+ items[0].focus();
130
+ await userEvent.keyboard('{End}');
131
+ expect(document.activeElement).toBe(items[items.length - 1]);
132
+ });
133
+
134
+ it('sets aria-setsize and aria-posinset on top-level items', () => {
135
+ render(<Tree data={objectData} />);
136
+ const items = screen.getAllByRole('treeitem');
137
+ const topLevel = items.filter((el) => el.getAttribute('aria-level') === '1');
138
+ topLevel.forEach((item, i) => {
139
+ expect(item).toHaveAttribute('aria-setsize', String(topLevel.length));
140
+ expect(item).toHaveAttribute('aria-posinset', String(i + 1));
141
+ });
142
+ });
143
+
144
+ it('sets aria-level="2" on nested items when expanded', async () => {
145
+ render(<Tree data={objectData} />);
146
+ const addressItem = screen
147
+ .getAllByRole('treeitem')
148
+ .find((el) => el.textContent?.includes('address'))!;
149
+ await userEvent.click(addressItem);
150
+ const nested = screen
151
+ .getAllByRole('treeitem')
152
+ .filter((el) => el.getAttribute('aria-level') === '2');
153
+ expect(nested.length).toBeGreaterThan(0);
154
+ });
116
155
  });
@@ -0,0 +1,10 @@
1
+ import { useId } from 'react';
2
+
3
+ /**
4
+ * Generates a stable, unique component ID.
5
+ * Returns the provided id if given, otherwise creates one prefixed with `ds-{prefix}-`.
6
+ */
7
+ export const useComponentId = (prefix: string, id?: string): string => {
8
+ const baseId = useId();
9
+ return id ?? `ds-${prefix}-${baseId}`;
10
+ };