@sproutsocial/seeds-react-card 1.0.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.
@@ -0,0 +1,264 @@
1
+ import React from "react";
2
+ import {
3
+ render,
4
+ screen,
5
+ waitFor,
6
+ } from "@sproutsocial/seeds-react-testing-library";
7
+ import { PointerEventsCheckLevel } from "@testing-library/user-event";
8
+ import Card from "../";
9
+ import Badge from "@sproutsocial/seeds-react-badge";
10
+ import {
11
+ CardContent,
12
+ CardFooter,
13
+ CardHeader,
14
+ CardLink,
15
+ } from "../subComponents";
16
+ import { theme } from "@sproutsocial/seeds-react-theme";
17
+
18
+ jest.mock("../utils");
19
+ const mockCardClick = jest.fn();
20
+
21
+ describe("A card is interactive", () => {
22
+ it("should be clickable", async () => {
23
+ const { user } = render(
24
+ <Card role="button" onClick={mockCardClick}>
25
+ Test
26
+ </Card>
27
+ );
28
+
29
+ const card = screen.getByRole("button");
30
+
31
+ await user.click(card);
32
+ expect(mockCardClick).toBeCalledTimes(1);
33
+ });
34
+
35
+ it("should function as a link", async () => {
36
+ const { user } = render(
37
+ <Card role="link" href="https://sproutsocial.com/">
38
+ Hello
39
+ <CardLink>Test</CardLink>
40
+ </Card>
41
+ );
42
+
43
+ const card = screen.getByText("Hello");
44
+ const link = screen.getByText("Test");
45
+
46
+ // listen to the child link to make sure that clicking the parent Card clicks the link programmatically
47
+ link.addEventListener("click", mockCardClick);
48
+
49
+ expect(card).toContainElement(link);
50
+ expect(link).toHaveAttribute("href", "https://sproutsocial.com/");
51
+
52
+ await user.click(card);
53
+ expect(mockCardClick).toBeCalled();
54
+ });
55
+
56
+ it("should function as a button", async () => {
57
+ const { user } = render(
58
+ <Card role="button" onClick={mockCardClick}>
59
+ Test
60
+ </Card>
61
+ );
62
+
63
+ const card = screen.getByRole("button");
64
+ expect(card).toHaveAttribute("role", "button");
65
+
66
+ await user.click(card);
67
+ expect(mockCardClick).toBeCalledTimes(1);
68
+ });
69
+
70
+ it("can be purely presentational", () => {
71
+ render(<Card role="presentation">Test</Card>);
72
+
73
+ const card = screen.getByRole("presentation");
74
+ expect(card).toHaveAttribute("role", "presentation");
75
+ });
76
+
77
+ it("can be disabled", async () => {
78
+ const { user } = render(
79
+ <Card role="button" onClick={mockCardClick} disabled={true}>
80
+ Test
81
+ </Card>
82
+ );
83
+
84
+ const card = screen.getByRole("button");
85
+
86
+ await user
87
+ .setup({
88
+ pointerEventsCheck: PointerEventsCheckLevel.Never,
89
+ })
90
+ .click(card);
91
+ expect(card).toHaveAttribute("aria-disabled", "true");
92
+ });
93
+
94
+ it("should have an adjustable hover state style", async () => {
95
+ const { user } = render(
96
+ <Card role="presentation" elevation="high">
97
+ Test
98
+ </Card>
99
+ );
100
+
101
+ const card = screen.getByRole("presentation");
102
+
103
+ // apparently, jest-dom can't do this with toHaveStyle
104
+ // https://github.com/testing-library/jest-dom/issues/59
105
+ //
106
+ // have to use toHaveStyleRule from jest-styled-comps
107
+ // https://github.com/styled-components/jest-styled-components#tohavestylerule
108
+ await user.hover(card);
109
+ expect(card).toHaveStyleRule("box-shadow", theme.shadows.high, {
110
+ modifier: ":hover",
111
+ });
112
+ });
113
+
114
+ it("can be focused", async () => {
115
+ const { user } = render(
116
+ <Card role="presentation">
117
+ Test
118
+ <button>child click test</button>
119
+ </Card>
120
+ );
121
+
122
+ const card = screen.getByRole("presentation");
123
+ const button = screen.getByRole("button");
124
+
125
+ expect(card).toHaveAttribute("tabindex", "0");
126
+
127
+ await user.tab();
128
+ expect(card).toHaveFocus();
129
+
130
+ await user.tab();
131
+ expect(button).toHaveFocus();
132
+
133
+ await user.tab({ shift: true });
134
+ expect(card).toHaveFocus();
135
+ });
136
+
137
+ it('handles onKeyDown "enter"', async () => {
138
+ const { rerender, debug, user } = render(
139
+ <Card role="button" onClick={mockCardClick}>
140
+ Test
141
+ </Card>
142
+ );
143
+
144
+ debug;
145
+
146
+ const cardAsButton = screen.getByRole("button");
147
+
148
+ await user.tab();
149
+ expect(cardAsButton).toHaveFocus();
150
+
151
+ await user.type(cardAsButton, "{enter}");
152
+ expect(mockCardClick).toBeCalledTimes(1);
153
+
154
+ rerender(
155
+ <Card role="link" href="https://sproutsocial.com/">
156
+ Hello
157
+ <CardLink>Test</CardLink>
158
+ </Card>
159
+ );
160
+
161
+ const cardAsLink = screen.getByText("Test");
162
+
163
+ await user.tab();
164
+ expect(cardAsLink).toHaveFocus();
165
+ expect(cardAsLink).toHaveAttribute("href", "https://sproutsocial.com/");
166
+ });
167
+
168
+ it("is selectable", async () => {
169
+ const TestCheckboxCard = () => {
170
+ const [selected, setSelected] = React.useState<boolean>(false);
171
+ return (
172
+ <Card
173
+ role="checkbox"
174
+ onClick={() => setSelected(!selected)}
175
+ selected={selected}
176
+ >
177
+ Test
178
+ </Card>
179
+ );
180
+ };
181
+
182
+ const { user } = render(<TestCheckboxCard />);
183
+
184
+ const card = screen.getByRole("checkbox");
185
+ await user.click(card);
186
+
187
+ await waitFor(() => expect(screen.getByRole("checkbox")).toBeChecked());
188
+ expect(card).toHaveStyle(
189
+ `border: ${theme.borderWidths[500]} solid ${theme.colors.container.border.selected}`
190
+ );
191
+ });
192
+
193
+ it("should support interactive children", async () => {
194
+ const mockChildClick = jest.fn((e) => e.stopPropagation());
195
+
196
+ const { user } = render(
197
+ <Card role="presentation" onClick={mockCardClick}>
198
+ Test
199
+ <button onClick={mockChildClick}>child click test</button>
200
+ </Card>
201
+ );
202
+
203
+ const card = screen.getByRole("presentation");
204
+ const cardChild = screen.getByText("child click test");
205
+
206
+ // Expect clicking the card to trigger the card's onClick but not the child's
207
+ await user.click(card);
208
+ expect(mockCardClick).toBeCalledTimes(1);
209
+ expect(mockChildClick).toBeCalledTimes(0);
210
+
211
+ // Expect clicking the interactive child NOT to trigger the card's onClick.
212
+ await user.click(cardChild);
213
+ expect(mockCardClick).toBeCalledTimes(1);
214
+ expect(mockChildClick).toBeCalledTimes(1);
215
+ });
216
+ });
217
+
218
+ describe("A Card supports composable layouts", () => {
219
+ it("should support children", () => {
220
+ render(
221
+ <Card role="presentation">
222
+ Hello, world!
223
+ <Badge>Cool Badge</Badge>
224
+ </Card>
225
+ );
226
+
227
+ const parent = screen.getByRole("presentation");
228
+ const child = screen.getByText("Cool Badge");
229
+
230
+ expect(parent).toContainElement(child);
231
+ });
232
+
233
+ it("should accept system props", () => {
234
+ render(
235
+ <Card role="presentation" p={300}>
236
+ Hello, world!
237
+ </Card>
238
+ );
239
+
240
+ const card = screen.getByRole("presentation");
241
+ expect(card).toHaveStyle(`padding: ${theme.space[300]}`);
242
+ });
243
+
244
+ it("adjusts it's styles dynamically when subcomponents are present", () => {
245
+ render(
246
+ <Card role="presentation">
247
+ <CardHeader>Card Header</CardHeader>
248
+ <CardContent>CardContent</CardContent>
249
+ <CardFooter>CardFooter</CardFooter>
250
+ </Card>
251
+ );
252
+
253
+ const card = screen.getByRole("presentation");
254
+ const cardSubcomponent = screen.getByText("CardFooter");
255
+
256
+ expect(cardSubcomponent).toBeInTheDocument();
257
+ // We have to wait for the new classes to be set once the state changes. Not sure how to do this better but a function seems to work.
258
+ () => expect(card).toHaveStyle("padding: 0px");
259
+ });
260
+ });
261
+
262
+ afterEach(() => {
263
+ jest.clearAllMocks();
264
+ });
@@ -0,0 +1,76 @@
1
+ import * as React from "react";
2
+ import Card from "..";
3
+
4
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
5
+ function CardTypes() {
6
+ return (
7
+ <>
8
+ <Card role="link" href="https://sproutsocial.com/">
9
+ This is a card.
10
+ </Card>
11
+ {/* @ts-expect-error - test that href is required with role=link */}
12
+ <Card role="link">This is a card.</Card>
13
+ {/* @ts-expect-error - test that onClick is never allowed with role=link */}
14
+ <Card
15
+ role="link"
16
+ onClick={() => {
17
+ return;
18
+ }}
19
+ >
20
+ This is a card.
21
+ </Card>
22
+ <Card
23
+ role="button"
24
+ onClick={() => {
25
+ return;
26
+ }}
27
+ >
28
+ This is a card.
29
+ </Card>
30
+ {/* @ts-expect-error - test that onClick is required with role=button */}
31
+ <Card role="button">This is a card.</Card>
32
+ <Card
33
+ role="checkbox"
34
+ selected={true}
35
+ onClick={() => {
36
+ return;
37
+ }}
38
+ >
39
+ This is a card.
40
+ </Card>
41
+ {/* @ts-expect-error - test that select is required with role=checkbox */}
42
+ <Card
43
+ role="checkbox"
44
+ onClick={() => {
45
+ return;
46
+ }}
47
+ >
48
+ This is a card.
49
+ </Card>
50
+ {/* @ts-expect-error - test that onClick is required with role=checkbox */}
51
+ <Card role="checkbox" selected={true}>
52
+ This is a card.
53
+ </Card>
54
+ <Card role="presentation">This is a card.</Card>
55
+ <Card
56
+ role="presentation"
57
+ onClick={() => {
58
+ return;
59
+ }}
60
+ >
61
+ This is a card.
62
+ </Card>
63
+ {/* @ts-expect-error - test that href is never allowed with role=presentation */}
64
+ <Card role="presentation" href="">
65
+ This is a card.
66
+ </Card>
67
+ <Card role="presentation" disabled={true} selected={true}>
68
+ This is a card.
69
+ </Card>
70
+ {/* @ts-expect-error - test that consumer level transient props fail */}
71
+ <Card role="presentation" $disabled={true} $selected={true}>
72
+ This is a card.
73
+ </Card>
74
+ </>
75
+ );
76
+ }
package/src/index.ts ADDED
@@ -0,0 +1,6 @@
1
+ import Card from "./Card";
2
+
3
+ export default Card;
4
+ export { Card };
5
+ export { CardHeader, CardContent, CardFooter, CardLink } from "./subComponents";
6
+ export * from "./CardTypes";
package/src/styles.tsx ADDED
@@ -0,0 +1,171 @@
1
+ import styled from "styled-components";
2
+ import {
3
+ border,
4
+ color,
5
+ flexbox,
6
+ grid,
7
+ layout,
8
+ position,
9
+ space,
10
+ typography,
11
+ } from "styled-system";
12
+ import { focusRing, disabled } from "@sproutsocial/seeds-react-mixins";
13
+ import type {
14
+ TypeStyledCard,
15
+ TypeCardArea,
16
+ TypeStyledSelectedIcon,
17
+ TypeCardLink,
18
+ } from "./CardTypes";
19
+ import Icon from "@sproutsocial/seeds-react-icon";
20
+
21
+ // TODO: Would be really cool to cherry pick specific props from style functions. For example,
22
+ // removing the css prop 'color' from the color function or importing just the specific
23
+ // props the component needs. It appears to be possible with some and not others.
24
+ // https://github.com/styled-system/styled-system/issues/1569
25
+
26
+ export const StyledCardContent = styled.div<TypeCardArea>`
27
+ display: flex;
28
+ flex-direction: column;
29
+ padding: ${({ theme }) => theme.space[400]};
30
+ box-sizing: border-box;
31
+
32
+ ${border}
33
+ ${color}
34
+ ${flexbox}
35
+ ${grid}
36
+ ${layout}
37
+ ${space}
38
+ `;
39
+
40
+ export const StyledCardHeader = styled(StyledCardContent)`
41
+ flex-direction: row;
42
+ border-bottom: ${({ theme }) => `${theme.borderWidths[500]} solid
43
+ ${theme.colors.container.border.base}`};
44
+ border-top-left-radius: ${({ theme }) => theme.radii.inner};
45
+ border-top-right-radius: ${({ theme }) => theme.radii.inner};
46
+
47
+ ${border}
48
+ ${color}
49
+ ${flexbox}
50
+ ${grid}
51
+ ${layout}
52
+ ${space}
53
+ `;
54
+
55
+ export const StyledCardFooter = styled(StyledCardContent)`
56
+ flex-direction: row;
57
+ border-top: ${({ theme }) => `${theme.borderWidths[500]} solid
58
+ ${theme.colors.container.border.base}`};
59
+ border-bottom-left-radius: ${({ theme }) => theme.radii.inner};
60
+ border-bottom-right-radius: ${({ theme }) => theme.radii.inner};
61
+
62
+ ${border}
63
+ ${color}
64
+ ${flexbox}
65
+ ${grid}
66
+ ${layout}
67
+ ${space}
68
+ `;
69
+
70
+ export const SelectedIconWrapper = styled.div`
71
+ display: flex;
72
+ align-items: center;
73
+ justify-content: center;
74
+ position: absolute;
75
+ top: -8px;
76
+ right: -8px;
77
+ `;
78
+
79
+ export const StyledSelectedIcon = styled(Icon)<TypeStyledSelectedIcon>`
80
+ border-radius: 50%;
81
+ background: ${({ theme }) => theme.colors.container.background.base};
82
+ opacity: 0;
83
+ transition: opacity ${({ theme }) => theme.duration.medium};
84
+
85
+ ${({ $selected }) =>
86
+ $selected &&
87
+ `
88
+ opacity: 1;
89
+ `}
90
+ `;
91
+
92
+ export const StyledCardLink = styled.a<TypeCardLink>`
93
+ font-family: ${(p) => p.theme.fontFamily};
94
+ font-weight: ${(p) => p.theme.fontWeights.bold};
95
+ color: ${(p) => p.theme.colors.text.headline};
96
+ ${(p) => p.theme.typography[400]};
97
+
98
+ ${color}
99
+ ${typography}
100
+ `;
101
+
102
+ export const StyledCard = styled.div<TypeStyledCard>`
103
+ position: relative;
104
+ display: flex;
105
+ flex-direction: column;
106
+ box-sizing: border-box;
107
+ margin: 0;
108
+ background: ${({ theme }) => theme.colors.container.background.base};
109
+ border: ${({ theme }) => theme.borderWidths[500]} solid
110
+ ${({ theme }) => theme.colors.container.border.base};
111
+ padding: ${({ theme, $compositionalComponents }) =>
112
+ $compositionalComponents ? 0 : theme.space[400]};
113
+ border-radius: ${({ theme }) => theme.radii.outer};
114
+ transition: box-shadow ${({ theme }) => theme.duration.medium},
115
+ border ${({ theme }) => theme.duration.medium};
116
+
117
+ &[role="button"],
118
+ &[role="checkbox"] {
119
+ cursor: pointer;
120
+ }
121
+
122
+ ${({ $isRoleLink }) =>
123
+ $isRoleLink &&
124
+ `
125
+ cursor: pointer;
126
+ `}
127
+
128
+ &:hover {
129
+ box-shadow: ${({ theme, $elevation = "low" }) => theme.shadows[$elevation]};
130
+ }
131
+
132
+ &:focus-within {
133
+ ${({ $isRoleLink }) => ($isRoleLink ? focusRing : null)}
134
+ ${StyledCardLink}:focus {
135
+ border: none;
136
+ box-shadow: none;
137
+ outline: none;
138
+ }
139
+ }
140
+
141
+ &:focus {
142
+ ${focusRing}
143
+ }
144
+
145
+ ${({ $disabled }) =>
146
+ $disabled &&
147
+ `
148
+ ${disabled}
149
+ `}
150
+
151
+ ${({ $selected, theme }) =>
152
+ $selected &&
153
+ `
154
+ border: ${theme.borderWidths[500]} solid ${theme.colors.container.border.selected};
155
+ `}
156
+
157
+ ${border}
158
+ ${color}
159
+ ${flexbox}
160
+ ${grid}
161
+ ${layout}
162
+ ${position}
163
+ ${space}
164
+ `;
165
+
166
+ export const StyledCardAffordance = styled(Icon)`
167
+ ${StyledCard}:hover & {
168
+ transform: translateX(${(p) => p.theme.space[200]});
169
+ }
170
+ transition: ${(p) => p.theme.duration.medium};
171
+ `;
@@ -0,0 +1,110 @@
1
+ import React, { useContext } from "react";
2
+ import { useChildContext, SubComponentContext } from "./utils";
3
+ import type {
4
+ TypeCardLink,
5
+ TypeSharedCardSystemProps,
6
+ TypeStyledSelectedIcon,
7
+ } from "./CardTypes";
8
+ import {
9
+ StyledCardContent,
10
+ StyledCardHeader,
11
+ StyledCardFooter,
12
+ StyledSelectedIcon,
13
+ SelectedIconWrapper,
14
+ StyledCardAffordance,
15
+ StyledCardLink,
16
+ } from "./styles";
17
+
18
+ interface TypeSharedSubComponentProps extends TypeSharedCardSystemProps {
19
+ children?: React.ReactNode;
20
+ }
21
+
22
+ export const CardContent = ({
23
+ children,
24
+ ...rest
25
+ }: TypeSharedSubComponentProps) => {
26
+ // TODO: It could be cool to possibly adjust the context to include an array of names of child components.
27
+ // Then, if CardHeader or CardFooter aren't used with CardContent throw an error.
28
+ useChildContext();
29
+ return <StyledCardContent {...rest}>{children}</StyledCardContent>;
30
+ };
31
+
32
+ export const CardHeader = ({
33
+ children,
34
+ ...rest
35
+ }: TypeSharedSubComponentProps) => {
36
+ useChildContext();
37
+ return <StyledCardHeader {...rest}>{children}</StyledCardHeader>;
38
+ };
39
+
40
+ export const CardFooter = ({
41
+ children,
42
+ ...rest
43
+ }: TypeSharedSubComponentProps) => {
44
+ useChildContext();
45
+ return <StyledCardFooter {...rest}>{children}</StyledCardFooter>;
46
+ };
47
+
48
+ interface TypeSelectedIconProps {
49
+ $selected?: TypeStyledSelectedIcon["$selected"];
50
+ }
51
+
52
+ export const SelectedIcon = ({ $selected }: TypeSelectedIconProps) => {
53
+ return (
54
+ <SelectedIconWrapper>
55
+ <StyledSelectedIcon
56
+ aria-hidden
57
+ color="icon.base"
58
+ name="circle-check-solid"
59
+ $selected={$selected}
60
+ />
61
+ </SelectedIconWrapper>
62
+ );
63
+ };
64
+
65
+ export const CardAffordance = ({ ...rest }) => {
66
+ return (
67
+ <StyledCardAffordance
68
+ {...rest}
69
+ size="mini"
70
+ name="arrow-right-solid"
71
+ // TODO: probably need to make this available to the top level for external links https://sprout.atlassian.net/browse/DS-2223
72
+ aria-hidden
73
+ />
74
+ );
75
+ };
76
+
77
+ export const CardLink = ({
78
+ affordance,
79
+ children,
80
+ external = false,
81
+ color,
82
+ ...rest
83
+ }: React.PropsWithChildren<TypeCardLink>) => {
84
+ const { href, linkRef } = useContext(SubComponentContext);
85
+
86
+ // Because we are hijacking Card click event to directly click this link, we need to stop propagation to avoid a double click event.
87
+ const handleClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
88
+ e.stopPropagation();
89
+ };
90
+
91
+ return (
92
+ <StyledCardLink
93
+ {...rest}
94
+ target={external ? "_blank" : undefined}
95
+ rel={external ? "noreferrer" : undefined}
96
+ href={href}
97
+ onClick={handleClick}
98
+ ref={linkRef}
99
+ // TODO: fix this type since `color` should be valid here. TS can't resolve the correct type.
100
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
101
+ // @ts-ignore
102
+ color={color}
103
+ >
104
+ <>
105
+ {children}
106
+ {affordance ? <CardAffordance ml={300} /> : null}
107
+ </>
108
+ </StyledCardLink>
109
+ );
110
+ };
package/src/utils.ts ADDED
@@ -0,0 +1,61 @@
1
+ import { createContext, useContext, useEffect } from "react";
2
+ import { assertIsElement } from "@sproutsocial/seeds-react-utilities";
3
+ import type { TypeCardProps, TypeCardContext } from "./CardTypes";
4
+
5
+ export const SubComponentContext = createContext<TypeCardContext>({
6
+ // eslint-disable-next-line @typescript-eslint/no-empty-function
7
+ setHasSubComponent: () => {},
8
+ href: "",
9
+ linkRef: null,
10
+ });
11
+
12
+ export function useChildContext() {
13
+ const { setHasSubComponent } = useContext(SubComponentContext);
14
+ useEffect(() => {
15
+ setHasSubComponent && setHasSubComponent(true);
16
+ }, [setHasSubComponent]);
17
+ }
18
+
19
+ interface navigateToParams extends Pick<TypeCardProps, "href"> {
20
+ e: React.MouseEvent | React.KeyboardEvent;
21
+ ref: React.RefObject<HTMLDivElement>;
22
+ }
23
+
24
+ export const navigateTo = ({ e, href, ref }: navigateToParams) => {
25
+ const { target } = e;
26
+
27
+ // asserts that target is an element so `contains` accepts it
28
+ assertIsElement(target);
29
+
30
+ if (ref.current?.contains(target)) {
31
+ if (
32
+ target.getAttribute("onclick") !== null ||
33
+ target.getAttribute("href") !== null
34
+ ) {
35
+ e.stopPropagation();
36
+ return;
37
+ }
38
+ }
39
+
40
+ window.open(href, "_blank")?.focus();
41
+ };
42
+
43
+ interface onKeyDownParams
44
+ extends Pick<TypeCardProps, "href" | "onClick" | "role"> {
45
+ e: React.KeyboardEvent;
46
+ ref: React.RefObject<HTMLDivElement>;
47
+ }
48
+
49
+ export const onKeyDown = ({ e, href, onClick, ref, role }: onKeyDownParams) => {
50
+ if (e?.key === "Enter") {
51
+ if (role === "link") {
52
+ return navigateTo({ e, href, ref });
53
+ }
54
+
55
+ if (role === "presentation") {
56
+ return;
57
+ }
58
+
59
+ return onClick?.(e);
60
+ }
61
+ };
package/tsconfig.json ADDED
@@ -0,0 +1,9 @@
1
+ {
2
+ "extends": "@sproutsocial/seeds-tsconfig/bundler/dom/library-monorepo",
3
+ "compilerOptions": {
4
+ "jsx": "react-jsx",
5
+ "module": "esnext"
6
+ },
7
+ "include": ["src/**/*"],
8
+ "exclude": ["node_modules", "dist", "coverage"]
9
+ }