@arbor-education/design-system.components 0.13.1 → 0.15.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.
- package/CHANGELOG.md +18 -0
- package/dist/components/articleCard/ArticleCard.d.ts +30 -0
- package/dist/components/articleCard/ArticleCard.d.ts.map +1 -0
- package/dist/components/articleCard/ArticleCard.js +24 -0
- package/dist/components/articleCard/ArticleCard.js.map +1 -0
- package/dist/components/articleCard/ArticleCard.stories.d.ts +18 -0
- package/dist/components/articleCard/ArticleCard.stories.d.ts.map +1 -0
- package/dist/components/articleCard/ArticleCard.stories.js +112 -0
- package/dist/components/articleCard/ArticleCard.stories.js.map +1 -0
- package/dist/components/articleCard/ArticleCard.test.d.ts +2 -0
- package/dist/components/articleCard/ArticleCard.test.d.ts.map +1 -0
- package/dist/components/articleCard/ArticleCard.test.js +49 -0
- package/dist/components/articleCard/ArticleCard.test.js.map +1 -0
- package/dist/components/card/Card.d.ts +41 -12
- package/dist/components/card/Card.d.ts.map +1 -1
- package/dist/components/card/Card.js +46 -17
- package/dist/components/card/Card.js.map +1 -1
- package/dist/components/card/Card.stories.d.ts +9 -84
- package/dist/components/card/Card.stories.d.ts.map +1 -1
- package/dist/components/card/Card.stories.js +15 -73
- package/dist/components/card/Card.stories.js.map +1 -1
- package/dist/components/card/Card.test.js +50 -152
- package/dist/components/card/Card.test.js.map +1 -1
- package/dist/components/formField/inputs/number/NumberInput.d.ts.map +1 -1
- package/dist/components/formField/inputs/number/NumberInput.js +14 -2
- package/dist/components/formField/inputs/number/NumberInput.js.map +1 -1
- package/dist/components/formField/inputs/number/NumberInput.test.js +21 -0
- package/dist/components/formField/inputs/number/NumberInput.test.js.map +1 -1
- package/dist/components/formField/inputs/time/TimeInput.d.ts +1 -1
- package/dist/components/formField/inputs/time/TimeInput.stories.d.ts +1 -1
- package/dist/components/icoText/IcoText.d.ts +37 -0
- package/dist/components/icoText/IcoText.d.ts.map +1 -0
- package/dist/components/icoText/IcoText.js +29 -0
- package/dist/components/icoText/IcoText.js.map +1 -0
- package/dist/components/icoText/IcoText.stories.d.ts +34 -0
- package/dist/components/icoText/IcoText.stories.d.ts.map +1 -0
- package/dist/components/icoText/IcoText.stories.js +24 -0
- package/dist/components/icoText/IcoText.stories.js.map +1 -0
- package/dist/components/icoText/IcoText.test.d.ts +2 -0
- package/dist/components/icoText/IcoText.test.d.ts.map +1 -0
- package/dist/components/icoText/IcoText.test.js +27 -0
- package/dist/components/icoText/IcoText.test.js.map +1 -0
- package/dist/components/kpiCard/KPICard.d.ts +13 -0
- package/dist/components/kpiCard/KPICard.d.ts.map +1 -0
- package/dist/components/kpiCard/KPICard.js +8 -0
- package/dist/components/kpiCard/KPICard.js.map +1 -0
- package/dist/components/kpiCard/KPICard.stories.d.ts +9 -0
- package/dist/components/kpiCard/KPICard.stories.d.ts.map +1 -0
- package/dist/components/kpiCard/KPICard.stories.js +18 -0
- package/dist/components/kpiCard/KPICard.stories.js.map +1 -0
- package/dist/components/kpiCard/KPICard.test.d.ts +2 -0
- package/dist/components/kpiCard/KPICard.test.d.ts.map +1 -0
- package/dist/components/kpiCard/KPICard.test.js +37 -0
- package/dist/components/kpiCard/KPICard.test.js.map +1 -0
- package/dist/components/kvpList/KVPList.d.ts +34 -0
- package/dist/components/kvpList/KVPList.d.ts.map +1 -0
- package/dist/components/kvpList/KVPList.js +20 -0
- package/dist/components/kvpList/KVPList.js.map +1 -0
- package/dist/components/kvpList/KVPList.stories.d.ts +27 -0
- package/dist/components/kvpList/KVPList.stories.d.ts.map +1 -0
- package/dist/components/kvpList/KVPList.stories.js +18 -0
- package/dist/components/kvpList/KVPList.stories.js.map +1 -0
- package/dist/components/kvpList/KVPList.test.d.ts +2 -0
- package/dist/components/kvpList/KVPList.test.d.ts.map +1 -0
- package/dist/components/kvpList/KVPList.test.js +29 -0
- package/dist/components/kvpList/KVPList.test.js.map +1 -0
- package/dist/components/singleUser/SingleUser.d.ts +1 -1
- package/dist/components/table/Table.d.ts +1 -0
- package/dist/components/table/Table.d.ts.map +1 -1
- package/dist/components/table/Table.js +4 -2
- package/dist/components/table/Table.js.map +1 -1
- package/dist/components/table/Table.stories.d.ts +1 -0
- package/dist/components/table/Table.stories.d.ts.map +1 -1
- package/dist/components/table/Table.stories.js +88 -0
- package/dist/components/table/Table.stories.js.map +1 -1
- package/dist/components/table/Table.test.js +184 -0
- package/dist/components/table/Table.test.js.map +1 -1
- package/dist/components/table/cellEditors/NumberCellEditor.d.ts +13 -0
- package/dist/components/table/cellEditors/NumberCellEditor.d.ts.map +1 -0
- package/dist/components/table/cellEditors/NumberCellEditor.js +35 -0
- package/dist/components/table/cellEditors/NumberCellEditor.js.map +1 -0
- package/dist/components/tabs/TabsItem.stories.d.ts +2 -2
- package/dist/index.css +205 -22
- package/dist/index.css.map +1 -1
- package/dist/index.d.ts +14 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +13 -3
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/components/articleCard/ArticleCard.stories.tsx +132 -0
- package/src/components/articleCard/ArticleCard.test.tsx +121 -0
- package/src/components/articleCard/ArticleCard.tsx +100 -0
- package/src/components/articleCard/articleCard.scss +39 -0
- package/src/components/card/Card.stories.tsx +35 -79
- package/src/components/card/Card.test.tsx +72 -190
- package/src/components/card/Card.tsx +117 -58
- package/src/components/card/card.scss +18 -31
- package/src/components/formField/inputs/number/NumberInput.test.tsx +28 -0
- package/src/components/formField/inputs/number/NumberInput.tsx +15 -0
- package/src/components/icoText/IcoText.stories.tsx +47 -0
- package/src/components/icoText/IcoText.test.tsx +41 -0
- package/src/components/icoText/IcoText.tsx +93 -0
- package/src/components/icoText/icoText.scss +34 -0
- package/src/components/kpiCard/KPICard.stories.tsx +47 -0
- package/src/components/kpiCard/KPICard.test.tsx +60 -0
- package/src/components/kpiCard/KPICard.tsx +45 -0
- package/src/components/kpiCard/kpiCard.scss +35 -0
- package/src/components/kvpList/KVPList.stories.tsx +51 -0
- package/src/components/kvpList/KVPList.test.tsx +66 -0
- package/src/components/kvpList/KVPList.tsx +109 -0
- package/src/components/kvpList/kvpList.scss +64 -0
- package/src/components/table/Table.stories.tsx +93 -0
- package/src/components/table/Table.test.tsx +255 -0
- package/src/components/table/Table.tsx +6 -0
- package/src/components/table/cellEditors/NumberCellEditor.tsx +83 -0
- package/src/components/table/cellEditors/numberCellEditor.scss +11 -0
- package/src/components/table/table.scss +11 -0
- package/src/index.scss +5 -0
- package/src/index.ts +14 -4
- package/src/tokens.scss +6 -0
- package/tokens/json/Arbor.json +30 -0
|
@@ -1,6 +1,15 @@
|
|
|
1
|
+
.ds-card__icon-click {
|
|
2
|
+
flex-shrink: 0;
|
|
3
|
+
|
|
4
|
+
&.ds-icon-arrow-right {
|
|
5
|
+
display: none;
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
|
|
1
9
|
.ds-card__container {
|
|
2
10
|
display: flex;
|
|
3
11
|
width: 100%;
|
|
12
|
+
margin: 0;
|
|
4
13
|
padding: var(--card-spacing-vertical) var(--card-spacing-horizontal);
|
|
5
14
|
flex-direction: column;
|
|
6
15
|
align-items: flex-start;
|
|
@@ -12,18 +21,10 @@
|
|
|
12
21
|
line-height: var(--line-height-default);
|
|
13
22
|
box-sizing: border-box;
|
|
14
23
|
|
|
15
|
-
.ds-card__icon-click {
|
|
16
|
-
flex-shrink: 0;
|
|
17
|
-
|
|
18
|
-
&.ds-icon-arrow-right {
|
|
19
|
-
display: none;
|
|
20
|
-
}
|
|
21
|
-
}
|
|
22
|
-
|
|
23
24
|
&:hover:not(.ds-card__container--disabled) {
|
|
24
25
|
border: var(--border-weight) solid var(--card-hover-color-border);
|
|
25
26
|
background: var(--card-hover-color-background);
|
|
26
|
-
box-shadow:
|
|
27
|
+
box-shadow: var(--shadow-small);
|
|
27
28
|
|
|
28
29
|
.ds-card__icon-click {
|
|
29
30
|
&.ds-icon-chevron-right {
|
|
@@ -45,6 +46,10 @@
|
|
|
45
46
|
&--clickable {
|
|
46
47
|
cursor: pointer;
|
|
47
48
|
}
|
|
49
|
+
|
|
50
|
+
&--dense {
|
|
51
|
+
padding: var(--spacing-small);
|
|
52
|
+
}
|
|
48
53
|
}
|
|
49
54
|
|
|
50
55
|
.ds-card__content {
|
|
@@ -53,27 +58,9 @@
|
|
|
53
58
|
align-items: flex-start;
|
|
54
59
|
gap: var(--card-spacing-gap-horizontal);
|
|
55
60
|
width: 100%;
|
|
61
|
+
}
|
|
56
62
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
.ds-card__text {
|
|
62
|
-
display: flex;
|
|
63
|
-
flex-direction: column;
|
|
64
|
-
justify-content: space-between;
|
|
65
|
-
gap: var(--card-spacing-gap-vertical);
|
|
66
|
-
flex-grow: 1;
|
|
67
|
-
|
|
68
|
-
.ds-card__title {
|
|
69
|
-
margin: 0;
|
|
70
|
-
font-family: var(--type-headings-h4-family);
|
|
71
|
-
font-size: var(--type-headings-h4-size);
|
|
72
|
-
font-weight: var(--type-headings-h4-weight);
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
.ds-card__paragraph {
|
|
76
|
-
margin: 0;
|
|
77
|
-
}
|
|
78
|
-
}
|
|
63
|
+
.ds-card__body {
|
|
64
|
+
flex: 1 1 auto;
|
|
65
|
+
min-width: 0;
|
|
79
66
|
}
|
|
@@ -31,6 +31,34 @@ describe('NumberInput component', () => {
|
|
|
31
31
|
expect(inputElement.value).toBe('12');
|
|
32
32
|
});
|
|
33
33
|
|
|
34
|
+
describe('Keyboard controls', () => {
|
|
35
|
+
test('pressing Enter validates and clamps value to max', () => {
|
|
36
|
+
render(<NumberInput placeholder="Enter a number" defaultValue="5" min={0} max={10} />);
|
|
37
|
+
const inputElement = screen.getByPlaceholderText('Enter a number') as HTMLInputElement;
|
|
38
|
+
|
|
39
|
+
fireEvent.change(inputElement, { target: { value: '12' } });
|
|
40
|
+
fireEvent.keyDown(inputElement, { key: 'Enter' });
|
|
41
|
+
|
|
42
|
+
expect(inputElement.value).toBe('10');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('ArrowUp increments by step', () => {
|
|
46
|
+
render(<NumberInput placeholder="Enter a number" defaultValue="10" step={2} />);
|
|
47
|
+
const inputElement = screen.getByPlaceholderText('Enter a number') as HTMLInputElement;
|
|
48
|
+
|
|
49
|
+
fireEvent.keyDown(inputElement, { key: 'ArrowUp' });
|
|
50
|
+
expect(inputElement.value).toBe('12');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('ArrowDown decrements by step', () => {
|
|
54
|
+
render(<NumberInput placeholder="Enter a number" defaultValue="10" step={2} />);
|
|
55
|
+
const inputElement = screen.getByPlaceholderText('Enter a number') as HTMLInputElement;
|
|
56
|
+
|
|
57
|
+
fireEvent.keyDown(inputElement, { key: 'ArrowDown' });
|
|
58
|
+
expect(inputElement.value).toBe('8');
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
34
62
|
describe('Decimal value handling', () => {
|
|
35
63
|
test('handles decimal step values correctly', () => {
|
|
36
64
|
render(<NumberInput placeholder="Enter a number" defaultValue="0.5" step={0.1} />);
|
|
@@ -22,6 +22,7 @@ export const NumberInput = (props: NumberInputProps) => {
|
|
|
22
22
|
disableSpinners,
|
|
23
23
|
containerClassName,
|
|
24
24
|
value: passedValue = '',
|
|
25
|
+
onKeyDown,
|
|
25
26
|
...rest
|
|
26
27
|
} = props;
|
|
27
28
|
|
|
@@ -78,6 +79,19 @@ export const NumberInput = (props: NumberInputProps) => {
|
|
|
78
79
|
checkOverOrUnderMaxOrMin(value);
|
|
79
80
|
};
|
|
80
81
|
|
|
82
|
+
const handleOnKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
83
|
+
if (e.key === 'Enter') {
|
|
84
|
+
handleOnBlur();
|
|
85
|
+
}
|
|
86
|
+
if (e.key === 'ArrowUp') {
|
|
87
|
+
handleSpinner(step);
|
|
88
|
+
}
|
|
89
|
+
if (e.key === 'ArrowDown') {
|
|
90
|
+
handleSpinner(-step);
|
|
91
|
+
}
|
|
92
|
+
onKeyDown?.(e);
|
|
93
|
+
};
|
|
94
|
+
|
|
81
95
|
return (
|
|
82
96
|
<div className={classNames('ds-number-input__container', {
|
|
83
97
|
'ds-number-input__container--error': hasError,
|
|
@@ -109,6 +123,7 @@ export const NumberInput = (props: NumberInputProps) => {
|
|
|
109
123
|
defaultValue={defaultValue}
|
|
110
124
|
onChange={(e: ChangeEvent<HTMLInputElement>) => handleOnChange(e.currentTarget.value)}
|
|
111
125
|
onBlur={handleOnBlur}
|
|
126
|
+
onKeyDown={handleOnKeyDown}
|
|
112
127
|
step={step}
|
|
113
128
|
min={min}
|
|
114
129
|
max={max}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite';
|
|
2
|
+
import { IcoText } from './IcoText';
|
|
3
|
+
|
|
4
|
+
const meta = {
|
|
5
|
+
title: 'Components/IcoText',
|
|
6
|
+
component: IcoText,
|
|
7
|
+
parameters: {
|
|
8
|
+
docs: {
|
|
9
|
+
description: {
|
|
10
|
+
component:
|
|
11
|
+
'Use `IcoText.Icon` as a direct child of `IcoText` so it is hoisted into the leading icon rail.',
|
|
12
|
+
},
|
|
13
|
+
},
|
|
14
|
+
},
|
|
15
|
+
} satisfies Meta<typeof IcoText>;
|
|
16
|
+
|
|
17
|
+
type Story = StoryObj<typeof meta>;
|
|
18
|
+
|
|
19
|
+
export const WithIconAndParagraph: Story = {
|
|
20
|
+
render: args => (
|
|
21
|
+
<IcoText {...args}>
|
|
22
|
+
<IcoText.Icon name="eye" />
|
|
23
|
+
<IcoText.Heading>Article heading</IcoText.Heading>
|
|
24
|
+
<IcoText.Paragraph>Supporting text for an article card layout.</IcoText.Paragraph>
|
|
25
|
+
</IcoText>
|
|
26
|
+
),
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export const TextOnly: Story = {
|
|
30
|
+
render: args => (
|
|
31
|
+
<IcoText {...args}>
|
|
32
|
+
<IcoText.Heading>Article heading</IcoText.Heading>
|
|
33
|
+
<IcoText.Paragraph>Supporting text with no leading icon.</IcoText.Paragraph>
|
|
34
|
+
</IcoText>
|
|
35
|
+
),
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export const HeadingWithIcon: Story = {
|
|
39
|
+
render: args => (
|
|
40
|
+
<IcoText {...args}>
|
|
41
|
+
<IcoText.Icon name="eye" />
|
|
42
|
+
<IcoText.Heading>Heading only</IcoText.Heading>
|
|
43
|
+
</IcoText>
|
|
44
|
+
),
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export default meta;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { render, screen, within } from '@testing-library/react';
|
|
2
|
+
import '@testing-library/jest-dom/vitest';
|
|
3
|
+
import { describe, expect, test } from 'vitest';
|
|
4
|
+
import { IcoText } from './IcoText';
|
|
5
|
+
|
|
6
|
+
describe('IcoText', () => {
|
|
7
|
+
test('renders icon children before content children and keeps content in the content wrapper', () => {
|
|
8
|
+
const { container } = render(
|
|
9
|
+
<IcoText>
|
|
10
|
+
<IcoText.Heading>Article title</IcoText.Heading>
|
|
11
|
+
<IcoText.Icon name="eye" screenReaderText="Views" />
|
|
12
|
+
<IcoText.Paragraph>Helpful supporting copy</IcoText.Paragraph>
|
|
13
|
+
<span>Metadata</span>
|
|
14
|
+
<IcoText.Icon name="eye" screenReaderText="More views" />
|
|
15
|
+
</IcoText>,
|
|
16
|
+
);
|
|
17
|
+
|
|
18
|
+
const root = container.querySelector<HTMLDivElement>('.ds-ico-text');
|
|
19
|
+
const content = container.querySelector<HTMLDivElement>('.ds-ico-text__content');
|
|
20
|
+
|
|
21
|
+
if (!content) {
|
|
22
|
+
throw new Error('Expected IcoText content wrapper to exist');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const iconElements = Array.from(root?.querySelectorAll(':scope > .ds-ico-text__icon') ?? []);
|
|
26
|
+
|
|
27
|
+
expect(iconElements).toHaveLength(2);
|
|
28
|
+
expect(root?.lastElementChild).toBe(content);
|
|
29
|
+
expect(content.querySelectorAll('.ds-ico-text__icon')).toHaveLength(0);
|
|
30
|
+
iconElements.forEach((iconElement) => {
|
|
31
|
+
expect(
|
|
32
|
+
iconElement.compareDocumentPosition(content) & Node.DOCUMENT_POSITION_FOLLOWING,
|
|
33
|
+
).not.toBe(0);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
expect(within(content).getByRole('heading', { level: 4 })).toHaveTextContent('Article title');
|
|
37
|
+
expect(within(content).getByText('Helpful supporting copy')).toBeInTheDocument();
|
|
38
|
+
expect(within(content).getByText('Metadata')).toBeInTheDocument();
|
|
39
|
+
expect(screen.getAllByRole('img', { hidden: true })).toHaveLength(2);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import classNames from 'classnames';
|
|
2
|
+
import type { IconName } from 'Components/icon/allowedIcons';
|
|
3
|
+
import { Icon } from 'Components/icon/Icon';
|
|
4
|
+
import { Children, isValidElement } from 'react';
|
|
5
|
+
|
|
6
|
+
export type IcoTextProps = {
|
|
7
|
+
children?: React.ReactNode;
|
|
8
|
+
className?: string;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export type IcoTextHeadingProps = React.HTMLAttributes<HTMLHeadingElement> & {
|
|
12
|
+
children: React.ReactNode;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export type IcoTextParagraphProps = React.HTMLAttributes<HTMLParagraphElement> & {
|
|
16
|
+
children: React.ReactNode;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export type IcoTextIconProps = {
|
|
20
|
+
className?: string;
|
|
21
|
+
color?: Icon.Props['color'];
|
|
22
|
+
name: IconName;
|
|
23
|
+
screenReaderText?: string;
|
|
24
|
+
size?: 12 | 16 | 24;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const IcoTextHeading = ({
|
|
28
|
+
children,
|
|
29
|
+
className,
|
|
30
|
+
...rest
|
|
31
|
+
}: IcoTextHeadingProps): React.JSX.Element => (
|
|
32
|
+
<h4 className={classNames('ds-ico-text__heading', className)} {...rest}>
|
|
33
|
+
{children}
|
|
34
|
+
</h4>
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
const IcoTextParagraph = ({
|
|
38
|
+
children,
|
|
39
|
+
className,
|
|
40
|
+
...rest
|
|
41
|
+
}: IcoTextParagraphProps): React.JSX.Element => (
|
|
42
|
+
<p className={classNames('ds-ico-text__paragraph', className)} {...rest}>
|
|
43
|
+
{children}
|
|
44
|
+
</p>
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
const IcoTextIcon = ({
|
|
48
|
+
className,
|
|
49
|
+
color,
|
|
50
|
+
name,
|
|
51
|
+
screenReaderText,
|
|
52
|
+
size = 24,
|
|
53
|
+
}: IcoTextIconProps): React.JSX.Element => (
|
|
54
|
+
<Icon
|
|
55
|
+
name={name}
|
|
56
|
+
className={classNames('ds-ico-text__icon', className)}
|
|
57
|
+
color={color}
|
|
58
|
+
screenReaderText={screenReaderText}
|
|
59
|
+
size={size}
|
|
60
|
+
/>
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
const IcoTextRoot = ({ children, className }: IcoTextProps): React.JSX.Element => {
|
|
64
|
+
const iconChildren: React.ReactNode[] = [];
|
|
65
|
+
const contentChildren: React.ReactNode[] = [];
|
|
66
|
+
|
|
67
|
+
Children.forEach(children, (child) => {
|
|
68
|
+
if (isValidElement(child) && child.type === IcoTextIcon) {
|
|
69
|
+
iconChildren.push(child);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
contentChildren.push(child);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
return (
|
|
77
|
+
<div className={classNames('ds-ico-text', className)}>
|
|
78
|
+
{iconChildren}
|
|
79
|
+
<div className="ds-ico-text__content">{contentChildren}</div>
|
|
80
|
+
</div>
|
|
81
|
+
);
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
IcoTextHeading.displayName = 'IcoText.Heading';
|
|
85
|
+
IcoTextParagraph.displayName = 'IcoText.Paragraph';
|
|
86
|
+
IcoTextIcon.displayName = 'IcoText.Icon';
|
|
87
|
+
IcoTextRoot.displayName = 'IcoText';
|
|
88
|
+
|
|
89
|
+
export const IcoText = Object.assign(IcoTextRoot, {
|
|
90
|
+
Heading: IcoTextHeading,
|
|
91
|
+
Paragraph: IcoTextParagraph,
|
|
92
|
+
Icon: IcoTextIcon,
|
|
93
|
+
});
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
.ds-ico-text {
|
|
2
|
+
display: flex;
|
|
3
|
+
align-items: flex-start;
|
|
4
|
+
gap: var(--ico-text-spacing-gap-horizontal);
|
|
5
|
+
width: 100%;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
.ds-ico-text__icon {
|
|
9
|
+
flex-shrink: 0;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
.ds-ico-text__content {
|
|
13
|
+
display: flex;
|
|
14
|
+
flex: 1 1 auto;
|
|
15
|
+
min-width: 0;
|
|
16
|
+
flex-direction: column;
|
|
17
|
+
gap: var(--ico-text-spacing-gap-vertical);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
.ds-ico-text__heading {
|
|
21
|
+
margin: 0;
|
|
22
|
+
font-family: var(--type-headings-h4-family);
|
|
23
|
+
font-size: var(--type-headings-h4-size);
|
|
24
|
+
font-weight: var(--type-headings-h4-weight);
|
|
25
|
+
line-height: var(--type-headings-h4-line-height);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
.ds-ico-text__paragraph {
|
|
29
|
+
margin: 0;
|
|
30
|
+
font-family: var(--type-body-p-family);
|
|
31
|
+
font-size: var(--type-body-p-size);
|
|
32
|
+
font-weight: var(--type-body-p-weight);
|
|
33
|
+
line-height: var(--type-body-line-height);
|
|
34
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite';
|
|
2
|
+
import { KVPList } from 'Components/kvpList/KVPList';
|
|
3
|
+
import { Progress } from 'Components/progress/Progress';
|
|
4
|
+
import { KPICard } from './KPICard';
|
|
5
|
+
|
|
6
|
+
const meta = {
|
|
7
|
+
title: 'Components/Card/KPICard',
|
|
8
|
+
component: KPICard,
|
|
9
|
+
} satisfies Meta<typeof KPICard>;
|
|
10
|
+
|
|
11
|
+
type Story = StoryObj<typeof meta>;
|
|
12
|
+
|
|
13
|
+
export const Default: Story = {
|
|
14
|
+
args: {
|
|
15
|
+
label: 'KPI label text',
|
|
16
|
+
value: 'XX.X',
|
|
17
|
+
isPercentage: true,
|
|
18
|
+
},
|
|
19
|
+
render: args => (
|
|
20
|
+
<KPICard {...args}>
|
|
21
|
+
<KVPList aria-label="Summary values">
|
|
22
|
+
<KVPList.Row orientation="horizontal">
|
|
23
|
+
<KVPList.Term>Bar label text</KVPList.Term>
|
|
24
|
+
<KVPList.Definition prominence="strong">XX.X</KVPList.Definition>
|
|
25
|
+
</KVPList.Row>
|
|
26
|
+
</KVPList>
|
|
27
|
+
<KVPList aria-label="Progress values">
|
|
28
|
+
<KVPList.Row orientation="horizontal">
|
|
29
|
+
<KVPList.Term>Bar label text</KVPList.Term>
|
|
30
|
+
<KVPList.Definition prominence="neutral">X</KVPList.Definition>
|
|
31
|
+
<KVPList.Definition aria-hidden="true" isRow>
|
|
32
|
+
<Progress aria-label="Progress bar first" max={100} value={95} />
|
|
33
|
+
</KVPList.Definition>
|
|
34
|
+
</KVPList.Row>
|
|
35
|
+
<KVPList.Row orientation="horizontal">
|
|
36
|
+
<KVPList.Term>Bar label text</KVPList.Term>
|
|
37
|
+
<KVPList.Definition prominence="neutral">X</KVPList.Definition>
|
|
38
|
+
<KVPList.Definition aria-hidden="true" isRow>
|
|
39
|
+
<Progress aria-label="Progress bar second" max={100} value={95} />
|
|
40
|
+
</KVPList.Definition>
|
|
41
|
+
</KVPList.Row>
|
|
42
|
+
</KVPList>
|
|
43
|
+
</KPICard>
|
|
44
|
+
),
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export default meta;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { fireEvent, render, screen } from '@testing-library/react';
|
|
2
|
+
import '@testing-library/jest-dom/vitest';
|
|
3
|
+
import { describe, expect, test, vi } from 'vitest';
|
|
4
|
+
import { KPICard } from './KPICard';
|
|
5
|
+
import { KVPList } from 'Components/kvpList/KVPList';
|
|
6
|
+
|
|
7
|
+
describe('KPICard', () => {
|
|
8
|
+
test('renders header content and kvp rows', () => {
|
|
9
|
+
render(
|
|
10
|
+
<KPICard isPercentage label="Attendance" value="95.4">
|
|
11
|
+
<KVPList aria-label="Breakdown">
|
|
12
|
+
<KVPList.Row orientation="horizontal">
|
|
13
|
+
<KVPList.Term>Summary</KVPList.Term>
|
|
14
|
+
<KVPList.Definition prominence="strong">95.4</KVPList.Definition>
|
|
15
|
+
</KVPList.Row>
|
|
16
|
+
</KVPList>
|
|
17
|
+
</KPICard>,
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
expect(screen.getByText('Attendance')).toBeInTheDocument();
|
|
21
|
+
expect(screen.getAllByText('95.4')).toHaveLength(2);
|
|
22
|
+
expect(screen.getByText('%')).toBeInTheDocument();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test('does not render a percentage suffix when isPercentage is false', () => {
|
|
26
|
+
render(<KPICard label="Attendance" value="95.4" />);
|
|
27
|
+
|
|
28
|
+
expect(screen.queryByText('%')).not.toBeInTheDocument();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('uses the shared card shell for click interaction', () => {
|
|
32
|
+
const handleClick = vi.fn();
|
|
33
|
+
|
|
34
|
+
render(<KPICard aria-label="Attendance KPI card" label="Attendance" onClick={handleClick} value="95.4" />);
|
|
35
|
+
|
|
36
|
+
fireEvent.click(screen.getByRole('button', { name: 'Attendance KPI card' }));
|
|
37
|
+
|
|
38
|
+
expect(handleClick).toHaveBeenCalledTimes(1);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('supports keyboard activation through the shared card shell', () => {
|
|
42
|
+
const handleClick = vi.fn();
|
|
43
|
+
|
|
44
|
+
render(<KPICard aria-label="Attendance KPI card" label="Attendance" onClick={handleClick} value="95.4" />);
|
|
45
|
+
|
|
46
|
+
fireEvent.keyDown(screen.getByRole('button', { name: 'Attendance KPI card' }), { key: 'Enter' });
|
|
47
|
+
|
|
48
|
+
expect(handleClick).toHaveBeenCalledTimes(1);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('does not invoke click handlers when disabled', () => {
|
|
52
|
+
const handleClick = vi.fn();
|
|
53
|
+
|
|
54
|
+
render(<KPICard aria-label="Attendance KPI card" disabled label="Attendance" onClick={handleClick} value="95.4" />);
|
|
55
|
+
|
|
56
|
+
fireEvent.click(screen.getByRole('button', { name: 'Attendance KPI card' }));
|
|
57
|
+
|
|
58
|
+
expect(handleClick).not.toHaveBeenCalled();
|
|
59
|
+
});
|
|
60
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { Card, getCardInteractionProps } from 'Components/card/Card';
|
|
2
|
+
|
|
3
|
+
type KPICardBaseProps = {
|
|
4
|
+
children?: React.ReactNode;
|
|
5
|
+
className?: string;
|
|
6
|
+
label: React.ReactNode;
|
|
7
|
+
value: React.ReactNode;
|
|
8
|
+
isPercentage?: boolean;
|
|
9
|
+
disabled?: boolean;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export type KPICardProps = KPICardBaseProps & Card.InteractionProps;
|
|
13
|
+
|
|
14
|
+
export const KPICard = (props: KPICardProps): React.JSX.Element => {
|
|
15
|
+
const {
|
|
16
|
+
children,
|
|
17
|
+
className,
|
|
18
|
+
label,
|
|
19
|
+
value,
|
|
20
|
+
isPercentage = false,
|
|
21
|
+
disabled = false,
|
|
22
|
+
} = props;
|
|
23
|
+
|
|
24
|
+
const content = (
|
|
25
|
+
<div className="ds-kpi-card">
|
|
26
|
+
<p className="ds-kpi-card__label">{label}</p>
|
|
27
|
+
<p className="ds-kpi-card__value">
|
|
28
|
+
{value}
|
|
29
|
+
{isPercentage && <span className="ds-kpi-card__suffix">%</span>}
|
|
30
|
+
</p>
|
|
31
|
+
{children}
|
|
32
|
+
</div>
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<Card
|
|
37
|
+
{...getCardInteractionProps(props)}
|
|
38
|
+
className={className}
|
|
39
|
+
disabled={disabled}
|
|
40
|
+
spacing="dense"
|
|
41
|
+
>
|
|
42
|
+
{content}
|
|
43
|
+
</Card>
|
|
44
|
+
);
|
|
45
|
+
};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
.ds-kpi-card {
|
|
2
|
+
display: grid;
|
|
3
|
+
width: 100%;
|
|
4
|
+
grid-template-columns: minmax(0, 1fr) auto;
|
|
5
|
+
gap: var(--spacing-medium) var(--spacing-small);
|
|
6
|
+
|
|
7
|
+
> :not(.ds-kpi-card__label, .ds-kpi-card__value) {
|
|
8
|
+
grid-column: 1 / -1;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
.ds-kpi-card__label {
|
|
13
|
+
align-self: end;
|
|
14
|
+
margin: 0;
|
|
15
|
+
color: var(--kpi-card-color-text-label);
|
|
16
|
+
font-family: var(--type-body-p-family);
|
|
17
|
+
font-size: var(--type-body-p-size);
|
|
18
|
+
font-weight: var(--type-body-p-weight);
|
|
19
|
+
line-height: var(--type-body-line-height);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
.ds-kpi-card__value {
|
|
23
|
+
justify-self: end;
|
|
24
|
+
margin: 0;
|
|
25
|
+
color: var(--kpi-card-color-text-value);
|
|
26
|
+
font-family: var(--type-headings-h2-family);
|
|
27
|
+
font-size: var(--type-headings-h2-size);
|
|
28
|
+
font-weight: var(--type-headings-h2-weight);
|
|
29
|
+
line-height: var(--type-headings-h2-line-height);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
.ds-kpi-card__suffix {
|
|
33
|
+
margin-left: var(--spacing-xsmall);
|
|
34
|
+
font: inherit;
|
|
35
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite';
|
|
2
|
+
import { Progress } from 'Components/progress/Progress';
|
|
3
|
+
import { KVPList } from './KVPList';
|
|
4
|
+
|
|
5
|
+
const meta = {
|
|
6
|
+
title: 'Components/KVPList',
|
|
7
|
+
component: KVPList,
|
|
8
|
+
} satisfies Meta<typeof KVPList>;
|
|
9
|
+
|
|
10
|
+
type Story = StoryObj<typeof meta>;
|
|
11
|
+
|
|
12
|
+
export const Summary: Story = {
|
|
13
|
+
render: args => (
|
|
14
|
+
<KVPList {...args} aria-label="Attendance metrics">
|
|
15
|
+
<KVPList.Row orientation="horizontal">
|
|
16
|
+
<KVPList.Term>Attendance</KVPList.Term>
|
|
17
|
+
<KVPList.Definition isPercentage prominence="strong">95</KVPList.Definition>
|
|
18
|
+
</KVPList.Row>
|
|
19
|
+
</KVPList>
|
|
20
|
+
),
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export const WithProgressAndPercentage: Story = {
|
|
24
|
+
render: args => (
|
|
25
|
+
<KVPList {...args} aria-label="Attendance breakdown">
|
|
26
|
+
<KVPList.Row orientation="horizontal">
|
|
27
|
+
<KVPList.Term>Bar label text</KVPList.Term>
|
|
28
|
+
<KVPList.Definition isPercentage prominence="neutral">95</KVPList.Definition>
|
|
29
|
+
<KVPList.Definition aria-hidden="true" isRow>
|
|
30
|
+
<Progress aria-label="Attendance progress" max={100} value={95} />
|
|
31
|
+
</KVPList.Definition>
|
|
32
|
+
</KVPList.Row>
|
|
33
|
+
</KVPList>
|
|
34
|
+
),
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export const WithProgressNoPercentage: Story = {
|
|
38
|
+
render: args => (
|
|
39
|
+
<KVPList {...args} aria-label="Attendance breakdown without percentage">
|
|
40
|
+
<KVPList.Row orientation="horizontal">
|
|
41
|
+
<KVPList.Term>Bar label text</KVPList.Term>
|
|
42
|
+
<KVPList.Definition prominence="neutral">X</KVPList.Definition>
|
|
43
|
+
<KVPList.Definition aria-hidden="true" isRow>
|
|
44
|
+
<Progress aria-label="Attendance progress second" max={100} value={95} />
|
|
45
|
+
</KVPList.Definition>
|
|
46
|
+
</KVPList.Row>
|
|
47
|
+
</KVPList>
|
|
48
|
+
),
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export default meta;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import '@testing-library/jest-dom/vitest';
|
|
2
|
+
import { render, screen } from '@testing-library/react';
|
|
3
|
+
import { describe, expect, test } from 'vitest';
|
|
4
|
+
import { KVPList } from './KVPList';
|
|
5
|
+
|
|
6
|
+
describe('KVPList', () => {
|
|
7
|
+
test('renders term and definition semantics', () => {
|
|
8
|
+
render(
|
|
9
|
+
<KVPList aria-label="Attendance metrics">
|
|
10
|
+
<KVPList.Row orientation="horizontal">
|
|
11
|
+
<KVPList.Term>Attendance</KVPList.Term>
|
|
12
|
+
<KVPList.Definition prominence="strong">95</KVPList.Definition>
|
|
13
|
+
</KVPList.Row>
|
|
14
|
+
</KVPList>,
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
expect(screen.getByText('Attendance').tagName).toBe('DT');
|
|
18
|
+
expect(screen.getByText('95').tagName).toBe('DD');
|
|
19
|
+
expect(screen.getByRole('group', { name: 'Attendance metrics' })).toBeInTheDocument();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test('renders a typed percentage suffix', () => {
|
|
23
|
+
render(
|
|
24
|
+
<KVPList>
|
|
25
|
+
<KVPList.Row orientation="horizontal">
|
|
26
|
+
<KVPList.Term>Attendance</KVPList.Term>
|
|
27
|
+
<KVPList.Definition isPercentage prominence="neutral">
|
|
28
|
+
95
|
|
29
|
+
</KVPList.Definition>
|
|
30
|
+
</KVPList.Row>
|
|
31
|
+
</KVPList>,
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
const percentageDefinition = screen.getByText('95').closest('dd');
|
|
35
|
+
|
|
36
|
+
expect(screen.getByText('%')).toBeInTheDocument();
|
|
37
|
+
expect(percentageDefinition).toHaveTextContent('95%');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('supports horizontal rows and row-spanning definitions', () => {
|
|
41
|
+
const { container } = render(
|
|
42
|
+
<KVPList>
|
|
43
|
+
<KVPList.Row orientation="horizontal">
|
|
44
|
+
<KVPList.Term>Progress</KVPList.Term>
|
|
45
|
+
<KVPList.Definition isRow>Full width content</KVPList.Definition>
|
|
46
|
+
</KVPList.Row>
|
|
47
|
+
</KVPList>,
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
expect(container.querySelector('.ds-kvp-list__row--horizontal')).toBeInTheDocument();
|
|
51
|
+
expect(container.querySelector('.ds-kvp-list__description--row')).toBeInTheDocument();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('does not add group semantics when no accessible label is provided', () => {
|
|
55
|
+
render(
|
|
56
|
+
<KVPList>
|
|
57
|
+
<KVPList.Row orientation="horizontal">
|
|
58
|
+
<KVPList.Term>Attendance</KVPList.Term>
|
|
59
|
+
<KVPList.Definition prominence="strong">95</KVPList.Definition>
|
|
60
|
+
</KVPList.Row>
|
|
61
|
+
</KVPList>,
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
expect(screen.queryByRole('group')).not.toBeInTheDocument();
|
|
65
|
+
});
|
|
66
|
+
});
|