@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.
- package/.eslintignore +6 -0
- package/.eslintrc.js +4 -0
- package/.turbo/turbo-build.log +21 -0
- package/CHANGELOG.md +8 -0
- package/dist/esm/index.js +1357 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/index.d.mts +88 -0
- package/dist/index.d.ts +88 -0
- package/dist/index.js +1375 -0
- package/dist/index.js.map +1 -0
- package/jest.config.js +9 -0
- package/package.json +51 -0
- package/src/Card.feature +122 -0
- package/src/Card.stories.tsx +389 -0
- package/src/Card.tsx +73 -0
- package/src/CardTypes.ts +104 -0
- package/src/__tests__/Card.test.tsx +264 -0
- package/src/__tests__/CardTypes.typetest.tsx +76 -0
- package/src/index.ts +6 -0
- package/src/styles.tsx +171 -0
- package/src/subComponents.tsx +110 -0
- package/src/utils.ts +61 -0
- package/tsconfig.json +9 -0
- package/tsup.config.ts +12 -0
|
@@ -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
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
|
+
};
|