@gv-tech/design-system 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 (88) hide show
  1. package/.github/CODEOWNERS +2 -0
  2. package/.github/CONTRIBUTING.md +38 -0
  3. package/.github/FUNDING.yml +4 -0
  4. package/.github/PULL_REQUEST_TEMPLATE/build.md +5 -0
  5. package/.github/PULL_REQUEST_TEMPLATE/standard.md +3 -0
  6. package/.github/RELEASING.md +37 -0
  7. package/.github/copilot-instructions.md +93 -0
  8. package/.github/workflows/ci.yml +82 -0
  9. package/.github/workflows/codeql-analysis.yml +34 -0
  10. package/.github/workflows/release-please.yml +53 -0
  11. package/.husky/pre-commit +1 -0
  12. package/.nvmrc +1 -0
  13. package/.prettierignore +1 -0
  14. package/.storybook/.preview-head.html +1 -0
  15. package/.storybook/main.ts +38 -0
  16. package/.storybook/preview.tsx +30 -0
  17. package/.tool-versions +1 -0
  18. package/.vscode/launch.json +22 -0
  19. package/.vscode/settings.json +30 -0
  20. package/.yarn/releases/yarn-4.12.0.cjs +942 -0
  21. package/.yarnrc.yml +7 -0
  22. package/CHANGELOG.md +490 -0
  23. package/LICENSE +21 -0
  24. package/README.md +116 -0
  25. package/SECURITY.md +9 -0
  26. package/babel.config.js +3 -0
  27. package/dist/favicon.ico +0 -0
  28. package/dist/index.demo.html +40 -0
  29. package/dist/index.js +647 -0
  30. package/dist/index.js.map +1 -0
  31. package/dist/index.mjs +1053 -0
  32. package/dist/index.mjs.map +1 -0
  33. package/dist/logo192.png +0 -0
  34. package/dist/logo512.png +0 -0
  35. package/dist/manifest.json +25 -0
  36. package/dist/robots.txt +2 -0
  37. package/dist/vendor-DXgJBoQh.mjs +265 -0
  38. package/dist/vendor-DXgJBoQh.mjs.map +1 -0
  39. package/dist/vendor-nZSsnGb7.js +7 -0
  40. package/dist/vendor-nZSsnGb7.js.map +1 -0
  41. package/docs/MIGRATE_TO_GVTECH_SCOPE.md +74 -0
  42. package/eslint.config.mjs +95 -0
  43. package/netlify.toml +6 -0
  44. package/package.json +130 -0
  45. package/public/favicon.ico +0 -0
  46. package/public/index.demo.html +40 -0
  47. package/public/logo192.png +0 -0
  48. package/public/logo512.png +0 -0
  49. package/public/manifest.json +25 -0
  50. package/public/robots.txt +2 -0
  51. package/scripts/validate.js +56 -0
  52. package/serve.json +4 -0
  53. package/src/Avatar.stories.tsx +67 -0
  54. package/src/Avatar.tsx +174 -0
  55. package/src/Badge.stories.tsx +87 -0
  56. package/src/Badge.tsx +76 -0
  57. package/src/Button.stories.tsx +244 -0
  58. package/src/Button.tsx +384 -0
  59. package/src/Icon.stories.tsx +101 -0
  60. package/src/Icon.tsx +64 -0
  61. package/src/Intro.stories.tsx +20 -0
  62. package/src/Link.stories.tsx +69 -0
  63. package/src/Link.tsx +252 -0
  64. package/src/StoryLinkWrapper.d.ts +1 -0
  65. package/src/StoryLinkWrapper.tsx +33 -0
  66. package/src/__tests__/Avatar.test.tsx +28 -0
  67. package/src/__tests__/Badge.test.tsx +25 -0
  68. package/src/__tests__/Button.test.tsx +38 -0
  69. package/src/__tests__/Icon.test.tsx +26 -0
  70. package/src/__tests__/Link.test.tsx +31 -0
  71. package/src/index.ts +13 -0
  72. package/src/mdx.d.ts +5 -0
  73. package/src/setupTests.ts +1 -0
  74. package/src/shared/animation.d.ts +18 -0
  75. package/src/shared/animation.js +60 -0
  76. package/src/shared/global.d.ts +12 -0
  77. package/src/shared/global.js +120 -0
  78. package/src/shared/icons.d.ts +34 -0
  79. package/src/shared/icons.js +282 -0
  80. package/src/shared/styles.d.ts +86 -0
  81. package/src/shared/styles.js +98 -0
  82. package/src/test-utils/axe.ts +25 -0
  83. package/src/types.ts +316 -0
  84. package/tsconfig.build.json +12 -0
  85. package/tsconfig.json +20 -0
  86. package/tsconfig.node.json +10 -0
  87. package/vite.config.ts +35 -0
  88. package/vitest.config.ts +13 -0
@@ -0,0 +1,101 @@
1
+ import { ComponentProps, Fragment } from 'react';
2
+ import styled, { css } from 'styled-components';
3
+
4
+ import { Icon } from './Icon';
5
+ import { icons } from './shared/icons';
6
+
7
+ const Meta = styled.div`
8
+ color: #666;
9
+ font-size: 12px;
10
+ `;
11
+
12
+ const Item = styled.li<{ minimal?: boolean }>`
13
+ display: inline-flex;
14
+ flex-direction: row;
15
+ align-items: center;
16
+ flex: 0 1 20%;
17
+ min-width: 120px;
18
+
19
+ padding: 0px 7.5px 20px;
20
+
21
+ svg {
22
+ margin-right: 10px;
23
+ width: 24px;
24
+ height: 24px;
25
+ }
26
+
27
+ ${(props) =>
28
+ props.minimal &&
29
+ css`
30
+ flex: none;
31
+ min-width: auto;
32
+ padding: 0;
33
+ background: #fff;
34
+ border: 1px solid #666;
35
+
36
+ svg {
37
+ display: block;
38
+ margin-right: 0;
39
+ width: 48px;
40
+ height: 48px;
41
+ }
42
+ `};
43
+ `;
44
+
45
+ const List = styled.ul`
46
+ display: flex;
47
+ flex-flow: row wrap;
48
+ list-style: none;
49
+ `;
50
+
51
+ export default {
52
+ title: 'Design System/Icon',
53
+ component: Icon,
54
+ };
55
+
56
+ export const Labels = (_args: Record<string, unknown>) => (
57
+ <Fragment>
58
+ There are {Object.keys(icons).length} icons
59
+ <List>
60
+ {Object.keys(icons).map((key) => (
61
+ <Item key={key}>
62
+ <Icon icon={key} aria-hidden />
63
+ <Meta>{key}</Meta>
64
+ </Item>
65
+ ))}
66
+ </List>
67
+ </Fragment>
68
+ );
69
+
70
+ export const NoLabels = (_args: Record<string, unknown>) => (
71
+ <List>
72
+ {Object.keys(icons).map((key) => (
73
+ <Item minimal key={key}>
74
+ <Icon icon={key} aria-label={key} />
75
+ </Item>
76
+ ))}
77
+ </List>
78
+ );
79
+
80
+ NoLabels.storyName = 'no labels';
81
+
82
+ export const Inline = (args: Partial<ComponentProps<typeof Icon>>) => (
83
+ <Fragment>
84
+ this is an inline <Icon {...(args as ComponentProps<typeof Icon>)} /> icon (default)
85
+ </Fragment>
86
+ );
87
+ Inline.args = {
88
+ icon: 'facehappy',
89
+ 'aria-label': 'Happy face',
90
+ };
91
+
92
+ export const Block = (args: Partial<ComponentProps<typeof Icon>>) => (
93
+ <Fragment>
94
+ this is a block <Icon {...(args as ComponentProps<typeof Icon>)} /> icon
95
+ </Fragment>
96
+ );
97
+ Block.args = {
98
+ icon: 'facehappy',
99
+ 'aria-label': 'Happy face',
100
+ block: true,
101
+ };
package/src/Icon.tsx ADDED
@@ -0,0 +1,64 @@
1
+ import styled from 'styled-components';
2
+ import { icons } from './shared/icons';
3
+ import { IconProps } from './types';
4
+
5
+ /**
6
+ * Props for styled Svg component
7
+ */
8
+ interface SvgProps {
9
+ block?: boolean;
10
+ }
11
+
12
+ /**
13
+ * Styled SVG wrapper
14
+ */
15
+ const Svg = styled.svg<SvgProps>`
16
+ display: ${(props) => (props.block ? 'block' : 'inline-block')};
17
+ vertical-align: middle;
18
+
19
+ shape-rendering: inherit;
20
+ transform: translate3d(0, 0, 0);
21
+ `;
22
+
23
+ /**
24
+ * Styled path element
25
+ */
26
+ const Path = styled.path`
27
+ fill: currentColor;
28
+ `;
29
+
30
+ /**
31
+ * An Icon is a piece of visual element, but we must ensure its accessibility while using it.
32
+ * It can have 2 purposes:
33
+ *
34
+ * - *decorative only*: for example, it illustrates a label next to it. We must ensure that it is ignored by screen readers, by setting `aria-hidden` attribute (ex: `<Icon icon="check" aria-hidden />`)
35
+ * - *non-decorative*: it means that it delivers information. For example, an icon as only child in a button. The meaning can be obvious visually, but it must have a proper text alternative via `aria-label` for screen readers. (ex: `<Icon icon="print" aria-label="Print this document" />`)
36
+ *
37
+ * @example
38
+ * ```tsx
39
+ * <Icon icon="check" />
40
+ * <Icon icon="user" block />
41
+ * <Icon icon="print" aria-label="Print document" />
42
+ * ```
43
+ */
44
+ export const Icon = ({ icon, block = false, ...props }: IconProps) => {
45
+ const path = icons[icon as keyof typeof icons];
46
+ if (!path) {
47
+ if (process.env.NODE_ENV !== 'production') {
48
+ console.warn(`Icon: icon key "${icon}" not found in icons map.`);
49
+ }
50
+
51
+ // Render an empty, aria-hidden SVG so consumers don't get a broken path element
52
+ return (
53
+ <Svg viewBox="0 0 1024 1024" width="20px" height="20px" block={block} aria-hidden {...props}>
54
+ <Path d="" />
55
+ </Svg>
56
+ );
57
+ }
58
+
59
+ return (
60
+ <Svg viewBox="0 0 1024 1024" width="20px" height="20px" block={block} {...props}>
61
+ <Path d={path} />
62
+ </Svg>
63
+ );
64
+ };
@@ -0,0 +1,20 @@
1
+ import { Meta } from '@storybook/react-vite';
2
+
3
+ const meta: Meta = {
4
+ title: 'Design System/Introduction',
5
+ parameters: {
6
+ docs: {
7
+ description: {
8
+ component: `
9
+ # Introduction to the Storybook design system tutorial
10
+
11
+ The Storybook design system tutorial is a subset of the full [Storybook design system](https://github.com/storybookjs/design-system/), created as a learning resource for those interested in learning how to write and publish a design system using best practice techniques.
12
+
13
+ Learn more in the [Storybook tutorials](https://storybook.js.org/tutorials/).
14
+ `,
15
+ },
16
+ },
17
+ },
18
+ };
19
+
20
+ export default meta;
@@ -0,0 +1,69 @@
1
+ import styled from 'styled-components';
2
+ import { action } from 'storybook/actions';
3
+
4
+ import { Icon } from './Icon';
5
+ import { Link } from './Link';
6
+ import { StoryLinkWrapper } from './StoryLinkWrapper';
7
+
8
+ const CustomLink = styled(Link)`
9
+ && {
10
+ color: red;
11
+ }
12
+ `;
13
+
14
+ const onLinkClick = action('onLinkClick');
15
+
16
+ export default {
17
+ title: 'Design System/Link',
18
+ component: Link,
19
+ };
20
+
21
+ export const All = () => (
22
+ <div>
23
+ <Link href="https://storybook.js.org/tutorials/">Default</Link>
24
+ <br />
25
+ <Link secondary href="https://storybook.js.org/tutorials/">
26
+ Secondary
27
+ </Link>
28
+ <br />
29
+ <Link tertiary href="https://storybook.js.org/tutorials/">
30
+ tertiary
31
+ </Link>
32
+ <br />
33
+ <Link nochrome href="https://storybook.js.org/tutorials/">
34
+ nochrome
35
+ </Link>
36
+ <br />
37
+ <Link href="https://storybook.js.org/tutorials/">
38
+ <Icon icon="discord" aria-hidden />
39
+ With icon in front
40
+ </Link>
41
+ <br />
42
+ <Link containsIcon href="https://storybook.js.org/tutorials/" aria-label="Toggle side bar">
43
+ <Icon icon="sidebar" aria-hidden />
44
+ </Link>
45
+ <br />
46
+ <Link containsIcon withArrow href="https://storybook.js.org/tutorials/">
47
+ With arrow behind
48
+ </Link>
49
+ <br />
50
+ <span style={{ background: '#333' }}>
51
+ <Link inverse href="https://storybook.js.org/tutorials/">
52
+ Inverted colors
53
+ </Link>
54
+ </span>
55
+ <br />
56
+ {/* gatsby and styled-components don't work nicely together */}
57
+ <Link isButton onClick={onLinkClick}>
58
+ is actually a button
59
+ </Link>
60
+ <br />
61
+ <Link tertiary LinkWrapper={StoryLinkWrapper} href="http://storybook.js.org">
62
+ has a LinkWrapper like GatsbyLink or NextLink
63
+ </Link>
64
+ <br />
65
+ <CustomLink tertiary LinkWrapper={StoryLinkWrapper} href="http://storybook.js.org">
66
+ has a LinkWrapper like GatsbyLink or NextLink with custom styling
67
+ </CustomLink>
68
+ </div>
69
+ );
package/src/Link.tsx ADDED
@@ -0,0 +1,252 @@
1
+ import type { ComponentType, ReactNode } from 'react';
2
+ import { Fragment } from 'react';
3
+ import styled, { css } from 'styled-components';
4
+ import { darken } from 'polished';
5
+
6
+ import { Icon } from './Icon';
7
+ import { color } from './shared/styles';
8
+ import { LinkProps } from './types';
9
+
10
+ /**
11
+ * Props for styled link components
12
+ */
13
+ interface StyledLinkProps {
14
+ secondary?: boolean;
15
+ tertiary?: boolean;
16
+ nochrome?: boolean;
17
+ inverse?: boolean;
18
+ isButton?: boolean;
19
+ containsIcon?: boolean;
20
+ withArrow?: boolean;
21
+ }
22
+
23
+ /**
24
+ * Props for LinkInner component
25
+ */
26
+ interface LinkInnerProps {
27
+ withArrow?: boolean;
28
+ }
29
+
30
+ const linkStyles = css<StyledLinkProps>`
31
+ display: inline-block;
32
+ transition:
33
+ transform 150ms ease-out,
34
+ color 150ms ease-out;
35
+ text-decoration: none;
36
+
37
+ color: ${color.secondary};
38
+
39
+ &:hover,
40
+ &:focus {
41
+ cursor: pointer;
42
+ transform: translateY(-1px);
43
+ color: ${darken(0.07, color.secondary)};
44
+ }
45
+ &:active {
46
+ transform: translateY(0);
47
+ color: ${darken(0.1, color.secondary)};
48
+ }
49
+
50
+ svg {
51
+ display: inline-block;
52
+ height: 1em;
53
+ width: 1em;
54
+ vertical-align: text-top;
55
+ position: relative;
56
+ bottom: -0.125em;
57
+ margin-right: 0.4em;
58
+ }
59
+
60
+ ${(props) =>
61
+ props.containsIcon &&
62
+ css`
63
+ svg {
64
+ height: 1em;
65
+ width: 1em;
66
+ vertical-align: middle;
67
+ position: relative;
68
+ bottom: 0;
69
+ margin-right: 0;
70
+ }
71
+ `};
72
+
73
+ ${(props) =>
74
+ props.secondary &&
75
+ css`
76
+ color: ${color.mediumdark};
77
+
78
+ &:hover {
79
+ color: ${color.dark};
80
+ }
81
+
82
+ &:active {
83
+ color: ${color.darker};
84
+ }
85
+ `};
86
+
87
+ ${(props) =>
88
+ props.tertiary &&
89
+ css`
90
+ color: ${color.dark};
91
+
92
+ &:hover {
93
+ color: ${color.darkest};
94
+ }
95
+
96
+ &:active {
97
+ color: ${color.mediumdark};
98
+ }
99
+ `};
100
+
101
+ ${(props) =>
102
+ props.nochrome &&
103
+ css`
104
+ color: inherit;
105
+
106
+ &:hover,
107
+ &:active {
108
+ color: inherit;
109
+ text-decoration: underline;
110
+ }
111
+ `};
112
+
113
+ ${(props) =>
114
+ props.inverse &&
115
+ css`
116
+ color: ${color.lightest};
117
+
118
+ &:hover {
119
+ color: ${color.lighter};
120
+ }
121
+
122
+ &:active {
123
+ color: ${color.light};
124
+ }
125
+ `};
126
+
127
+ ${(props) =>
128
+ props.isButton &&
129
+ css`
130
+ border: 0;
131
+ border-radius: 0;
132
+ background: none;
133
+ padding: 0;
134
+ font-size: inherit;
135
+ `};
136
+ `;
137
+
138
+ const LinkInner = styled.span<LinkInnerProps>`
139
+ ${(props) =>
140
+ props.withArrow &&
141
+ css`
142
+ > svg:last-of-type {
143
+ height: 0.7em;
144
+ width: 0.7em;
145
+ margin-right: 0;
146
+ margin-left: 0.25em;
147
+ bottom: auto;
148
+ vertical-align: inherit;
149
+ }
150
+ `};
151
+ `;
152
+
153
+ const LinkA = styled.a<StyledLinkProps>`
154
+ ${linkStyles};
155
+ `;
156
+
157
+ const LinkButton = styled.button<StyledLinkProps>`
158
+ /* reset button styles */
159
+ background: none;
160
+ color: inherit;
161
+ border: none;
162
+ padding: 0;
163
+ font: inherit;
164
+ cursor: pointer;
165
+ outline: inherit;
166
+
167
+ ${linkStyles};
168
+ `;
169
+
170
+ const applyStyle = (LinkWrapper?: LinkProps['LinkWrapper']) => {
171
+ return (
172
+ LinkWrapper &&
173
+ styled(
174
+ ({
175
+ containsIcon: _containsIcon,
176
+ inverse: _inverse,
177
+ nochrome: _nochrome,
178
+ secondary: _secondary,
179
+ tertiary: _tertiary,
180
+ children: _children,
181
+ ...linkWrapperRest
182
+ }: Record<string, unknown>) => (
183
+ <LinkWrapper {...(linkWrapperRest as Record<string, unknown>)}>{_children as ReactNode}</LinkWrapper>
184
+ ),
185
+ )`
186
+ ${linkStyles};
187
+ `
188
+ );
189
+ };
190
+
191
+ /**
192
+ * Links can contains text and/or icons. Be careful using only icons, you must provide a text alternative via aria-label for accessibility.
193
+ *
194
+ * @example
195
+ * ```tsx
196
+ * <Link href="/home">Home</Link>
197
+ * <Link secondary href="/about">About</Link>
198
+ * <Link withArrow>Learn more</Link>
199
+ * ```
200
+ */
201
+ export const Link = ({
202
+ isButton = false,
203
+ withArrow = false,
204
+ secondary = false,
205
+ tertiary = false,
206
+ nochrome = false,
207
+ inverse = false,
208
+ containsIcon = false,
209
+ LinkWrapper,
210
+ children,
211
+ ...props
212
+ }: LinkProps) => {
213
+ // Dev-time accessibility check for icon-only links
214
+ if (process.env.NODE_ENV !== 'production') {
215
+ const hasAriaLabel = Object.prototype.hasOwnProperty.call(props, 'aria-label');
216
+ const noVisibleChildren = !children || (typeof children === 'string' && children.trim() === '');
217
+ if (containsIcon && noVisibleChildren && !hasAriaLabel) {
218
+ console.warn('Link: icon-only links should include an `aria-label` or visible text for accessibility.');
219
+ }
220
+ }
221
+ const content = (
222
+ <Fragment>
223
+ <LinkInner withArrow={withArrow}>
224
+ {children}
225
+ {withArrow && <Icon icon="arrowright" />}
226
+ </LinkInner>
227
+ </Fragment>
228
+ );
229
+
230
+ const StyledLinkWrapper = applyStyle(LinkWrapper);
231
+
232
+ let SelectedLink: ComponentType<Record<string, unknown>> = LinkA;
233
+ if (LinkWrapper) {
234
+ SelectedLink = StyledLinkWrapper as ComponentType<Record<string, unknown>>;
235
+ } else if (isButton) {
236
+ SelectedLink = LinkButton as ComponentType<Record<string, unknown>>;
237
+ }
238
+
239
+ return (
240
+ <SelectedLink
241
+ secondary={secondary}
242
+ tertiary={tertiary}
243
+ nochrome={nochrome}
244
+ inverse={inverse}
245
+ isButton={isButton}
246
+ containsIcon={containsIcon}
247
+ {...props}
248
+ >
249
+ {content}
250
+ </SelectedLink>
251
+ );
252
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,33 @@
1
+ import React from 'react';
2
+ import { action } from 'storybook/actions';
3
+
4
+ const fireClickAction = action('onLinkClick');
5
+
6
+ interface Props {
7
+ children: React.ReactNode;
8
+ className?: string;
9
+ href?: string | null;
10
+ onClick?: () => void;
11
+ to?: string | null;
12
+ }
13
+
14
+ export const StoryLinkWrapper: React.FC<Props> = ({
15
+ children,
16
+ className = '',
17
+ href = null,
18
+ onClick = () => {},
19
+ to = null,
20
+ ...rest
21
+ }) => {
22
+ const modifiedOnClick = (event: React.MouseEvent) => {
23
+ event.preventDefault();
24
+ onClick?.();
25
+ fireClickAction(href || to);
26
+ };
27
+
28
+ return (
29
+ <a className={className} href={(href || to) ?? undefined} onClick={modifiedOnClick} {...rest}>
30
+ {children}
31
+ </a>
32
+ );
33
+ };
@@ -0,0 +1,28 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { render, screen } from '@testing-library/react';
3
+ import { Avatar } from '../Avatar';
4
+ import '@testing-library/jest-dom';
5
+
6
+ describe('Avatar', () => {
7
+ it('renders initials when no image is provided', () => {
8
+ render(<Avatar username="John Doe" />);
9
+ expect(screen.getByText('J')).toBeInTheDocument();
10
+ });
11
+
12
+ it('renders an image when src is provided', () => {
13
+ render(<Avatar src="https://example.com/avatar.jpg" username="Jane Doe" />);
14
+ const img = screen.getByAltText('Jane Doe');
15
+ expect(img).toBeInTheDocument();
16
+ expect(img).toHaveAttribute('src', 'https://example.com/avatar.jpg');
17
+ });
18
+
19
+ it('shows loading state accurately', () => {
20
+ render(<Avatar loading username="Loading..." />);
21
+ expect(screen.getByLabelText('Loading avatar ...')).toBeInTheDocument();
22
+ });
23
+
24
+ it('renders correct initial based on username', () => {
25
+ render(<Avatar username="Eric Garcia" />);
26
+ expect(screen.getByText('E')).toBeInTheDocument();
27
+ });
28
+ });
@@ -0,0 +1,25 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { render, screen } from '@testing-library/react';
3
+ import { Badge } from '../Badge';
4
+ import '@testing-library/jest-dom';
5
+
6
+ describe('Badge', () => {
7
+ it('renders with children text', () => {
8
+ render(<Badge>New</Badge>);
9
+ expect(screen.getByText('New')).toBeInTheDocument();
10
+ });
11
+
12
+ it('renders with different statuses', () => {
13
+ const { rerender } = render(<Badge status="positive">Positive</Badge>);
14
+ expect(screen.getByText('Positive')).toBeInTheDocument();
15
+
16
+ rerender(<Badge status="negative">Negative</Badge>);
17
+ expect(screen.getByText('Negative')).toBeInTheDocument();
18
+
19
+ rerender(<Badge status="warning">Warning</Badge>);
20
+ expect(screen.getByText('Warning')).toBeInTheDocument();
21
+
22
+ rerender(<Badge status="error">Error</Badge>);
23
+ expect(screen.getByText('Error')).toBeInTheDocument();
24
+ });
25
+ });
@@ -0,0 +1,38 @@
1
+ import { render, screen } from '@testing-library/react';
2
+ import { describe, it, expect } from 'vitest';
3
+
4
+ import { Button } from '../Button';
5
+ import assertNoA11yViolations from '../test-utils/axe';
6
+
7
+ describe('Button', () => {
8
+ it('renders children text', async () => {
9
+ const { container } = render(<Button>Click me</Button>);
10
+ expect(screen.getByText('Click me')).toBeTruthy();
11
+ await assertNoA11yViolations(container);
12
+ });
13
+
14
+ it('applies disabled when isDisabled prop is true', async () => {
15
+ const { container } = render(<Button isDisabled>Disabled</Button>);
16
+ const btn = screen.getByText('Disabled').closest('button');
17
+ expect(btn).toHaveAttribute('disabled');
18
+ await assertNoA11yViolations(container);
19
+ });
20
+
21
+ it('mounts icon-only button with accessible name', async () => {
22
+ // Render an icon-only button with aria-label to satisfy accessibility
23
+ const { container } = render(
24
+ <Button containsIcon aria-label="Icon action" isDisabled>
25
+ {/* icon-only scenario simulated with no children */}
26
+ </Button>,
27
+ );
28
+ const btn = screen.getByRole('button', { name: 'Icon action' });
29
+ expect(btn).toBeTruthy();
30
+ await assertNoA11yViolations(container);
31
+ });
32
+
33
+ it('fails axe for icon-only button without accessible name', async () => {
34
+ const { container } = render(<Button containsIcon isDisabled />);
35
+ // Expect the helper to throw with accessibility violations
36
+ await expect(assertNoA11yViolations(container)).rejects.toThrow(/Accessibility violations detected/);
37
+ });
38
+ });
@@ -0,0 +1,26 @@
1
+ import { render, screen } from '@testing-library/react';
2
+ import { describe, it, expect } from 'vitest';
3
+
4
+ import { Icon } from '../Icon';
5
+ import assertNoA11yViolations from '../test-utils/axe';
6
+
7
+ describe('Icon', () => {
8
+ it('renders an svg for a valid icon key', async () => {
9
+ const { container } = render(<Icon icon="check" data-testid="icon" />);
10
+ const el = screen.getByTestId('icon');
11
+ expect(el.tagName.toLowerCase()).toBe('svg');
12
+ // path should exist (may be empty if icon not present)
13
+ expect(el.querySelector('path')).not.toBeNull();
14
+ await assertNoA11yViolations(container);
15
+ });
16
+
17
+ it('renders aria-hidden fallback for unknown icon key', async () => {
18
+ const { container } = render(<Icon icon={'__does_not_exist__' as unknown as string} data-testid="missing" />);
19
+ const el = screen.getByTestId('missing');
20
+ expect(el.getAttribute('aria-hidden')).not.toBeNull();
21
+ const path = el.querySelector('path');
22
+ expect(path).not.toBeNull();
23
+ expect(path?.getAttribute('d')).toBe('');
24
+ await assertNoA11yViolations(container);
25
+ });
26
+ });
@@ -0,0 +1,31 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { render, screen } from '@testing-library/react';
3
+ import { Link } from '../Link';
4
+ import '@testing-library/jest-dom';
5
+
6
+ describe('Link', () => {
7
+ it('renders a link with correct text', () => {
8
+ render(<Link href="https://example.com">Example</Link>);
9
+ const linkElement = screen.getByRole('link', { name: /example/i });
10
+ expect(linkElement).toBeInTheDocument();
11
+ expect(linkElement).toHaveAttribute('href', 'https://example.com');
12
+ });
13
+
14
+ it('renders as a button when isButton is true', () => {
15
+ render(<Link isButton>Button Link</Link>);
16
+ const buttonElement = screen.getByRole('button', { name: /button link/i });
17
+ expect(buttonElement).toBeInTheDocument();
18
+ });
19
+
20
+ it('renders an arrow icon when withArrow is true', () => {
21
+ const { container } = render(<Link withArrow>Arrow Link</Link>);
22
+ const svg = container.querySelector('svg');
23
+ expect(svg).toBeInTheDocument();
24
+ });
25
+
26
+ it('renders with secondary styling', () => {
27
+ render(<Link secondary>Secondary Link</Link>);
28
+ const linkElement = screen.getByText('Secondary Link').closest('a');
29
+ expect(linkElement).toHaveStyle('color: rgb(153, 153, 153)'); // color.mediumdark
30
+ });
31
+ });