@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.
- package/.github/CODEOWNERS +2 -0
- package/.github/CONTRIBUTING.md +38 -0
- package/.github/FUNDING.yml +4 -0
- package/.github/PULL_REQUEST_TEMPLATE/build.md +5 -0
- package/.github/PULL_REQUEST_TEMPLATE/standard.md +3 -0
- package/.github/RELEASING.md +37 -0
- package/.github/copilot-instructions.md +93 -0
- package/.github/workflows/ci.yml +82 -0
- package/.github/workflows/codeql-analysis.yml +34 -0
- package/.github/workflows/release-please.yml +53 -0
- package/.husky/pre-commit +1 -0
- package/.nvmrc +1 -0
- package/.prettierignore +1 -0
- package/.storybook/.preview-head.html +1 -0
- package/.storybook/main.ts +38 -0
- package/.storybook/preview.tsx +30 -0
- package/.tool-versions +1 -0
- package/.vscode/launch.json +22 -0
- package/.vscode/settings.json +30 -0
- package/.yarn/releases/yarn-4.12.0.cjs +942 -0
- package/.yarnrc.yml +7 -0
- package/CHANGELOG.md +490 -0
- package/LICENSE +21 -0
- package/README.md +116 -0
- package/SECURITY.md +9 -0
- package/babel.config.js +3 -0
- package/dist/favicon.ico +0 -0
- package/dist/index.demo.html +40 -0
- package/dist/index.js +647 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +1053 -0
- package/dist/index.mjs.map +1 -0
- package/dist/logo192.png +0 -0
- package/dist/logo512.png +0 -0
- package/dist/manifest.json +25 -0
- package/dist/robots.txt +2 -0
- package/dist/vendor-DXgJBoQh.mjs +265 -0
- package/dist/vendor-DXgJBoQh.mjs.map +1 -0
- package/dist/vendor-nZSsnGb7.js +7 -0
- package/dist/vendor-nZSsnGb7.js.map +1 -0
- package/docs/MIGRATE_TO_GVTECH_SCOPE.md +74 -0
- package/eslint.config.mjs +95 -0
- package/netlify.toml +6 -0
- package/package.json +130 -0
- package/public/favicon.ico +0 -0
- package/public/index.demo.html +40 -0
- package/public/logo192.png +0 -0
- package/public/logo512.png +0 -0
- package/public/manifest.json +25 -0
- package/public/robots.txt +2 -0
- package/scripts/validate.js +56 -0
- package/serve.json +4 -0
- package/src/Avatar.stories.tsx +67 -0
- package/src/Avatar.tsx +174 -0
- package/src/Badge.stories.tsx +87 -0
- package/src/Badge.tsx +76 -0
- package/src/Button.stories.tsx +244 -0
- package/src/Button.tsx +384 -0
- package/src/Icon.stories.tsx +101 -0
- package/src/Icon.tsx +64 -0
- package/src/Intro.stories.tsx +20 -0
- package/src/Link.stories.tsx +69 -0
- package/src/Link.tsx +252 -0
- package/src/StoryLinkWrapper.d.ts +1 -0
- package/src/StoryLinkWrapper.tsx +33 -0
- package/src/__tests__/Avatar.test.tsx +28 -0
- package/src/__tests__/Badge.test.tsx +25 -0
- package/src/__tests__/Button.test.tsx +38 -0
- package/src/__tests__/Icon.test.tsx +26 -0
- package/src/__tests__/Link.test.tsx +31 -0
- package/src/index.ts +13 -0
- package/src/mdx.d.ts +5 -0
- package/src/setupTests.ts +1 -0
- package/src/shared/animation.d.ts +18 -0
- package/src/shared/animation.js +60 -0
- package/src/shared/global.d.ts +12 -0
- package/src/shared/global.js +120 -0
- package/src/shared/icons.d.ts +34 -0
- package/src/shared/icons.js +282 -0
- package/src/shared/styles.d.ts +86 -0
- package/src/shared/styles.js +98 -0
- package/src/test-utils/axe.ts +25 -0
- package/src/types.ts +316 -0
- package/tsconfig.build.json +12 -0
- package/tsconfig.json +20 -0
- package/tsconfig.node.json +10 -0
- package/vite.config.ts +35 -0
- 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
|
+
});
|