@mitodl/smoot-design 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/.eslintrc.js +142 -0
- package/.github/workflows/ci.yml +48 -0
- package/.github/workflows/publish-pages.yml +50 -0
- package/.github/workflows/release.yml +34 -0
- package/.github/workflows/validate-pr.yml +49 -0
- package/.pre-commit-config.yaml +90 -0
- package/.prettierignore +1 -0
- package/.prettierrc.json +4 -0
- package/.releaserc.json +40 -0
- package/.secrets.baseline +113 -0
- package/.storybook/main.ts +46 -0
- package/.storybook/manager-head.html +1 -0
- package/.storybook/preview-head.html +5 -0
- package/.storybook/preview.tsx +15 -0
- package/.storybook/public/pexels-photo-1851188.webp +0 -0
- package/.yarn/releases/yarn-4.5.1.cjs +934 -0
- package/.yarnrc.yml +23 -0
- package/LICENSE +28 -0
- package/README.md +13 -0
- package/jest.config.ts +22 -0
- package/package.json +110 -0
- package/src/components/Button/ActionButton.stories.tsx +186 -0
- package/src/components/Button/Button.stories.tsx +275 -0
- package/src/components/Button/Button.test.tsx +56 -0
- package/src/components/Button/Button.tsx +418 -0
- package/src/components/LinkAdapter/LinkAdapter.tsx +38 -0
- package/src/components/ThemeProvider/ThemeProvider.stories.tsx +94 -0
- package/src/components/ThemeProvider/ThemeProvider.tsx +127 -0
- package/src/components/ThemeProvider/Typography.stories.tsx +74 -0
- package/src/components/ThemeProvider/breakpoints.ts +20 -0
- package/src/components/ThemeProvider/buttons.ts +22 -0
- package/src/components/ThemeProvider/chips.tsx +167 -0
- package/src/components/ThemeProvider/colors.ts +33 -0
- package/src/components/ThemeProvider/typography.ts +174 -0
- package/src/index.ts +24 -0
- package/src/jest-setup.ts +0 -0
- package/src/story-utils/index.ts +28 -0
- package/src/types/theme.d.ts +106 -0
- package/src/types/typography.d.ts +54 -0
- package/tsconfig.json +26 -0
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import * as React from "react"
|
|
2
|
+
import { render, screen } from "@testing-library/react"
|
|
3
|
+
import { ThemeProvider, createTheme } from "../ThemeProvider/ThemeProvider"
|
|
4
|
+
import { ButtonLink, ActionButtonLink } from "./Button"
|
|
5
|
+
|
|
6
|
+
const withLinkOverride = createTheme({
|
|
7
|
+
custom: {
|
|
8
|
+
LinkAdapter: React.forwardRef<HTMLAnchorElement, React.ComponentProps<"a">>(
|
|
9
|
+
(props, ref) => (
|
|
10
|
+
// eslint-disable-next-line jsx-a11y/anchor-has-content
|
|
11
|
+
<a ref={ref} data-custom="theme-default" {...props} />
|
|
12
|
+
),
|
|
13
|
+
),
|
|
14
|
+
},
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
describe.each([
|
|
18
|
+
//
|
|
19
|
+
{ ButtonComponent: ButtonLink },
|
|
20
|
+
{ ButtonComponent: ActionButtonLink },
|
|
21
|
+
])("$ButtonComponent.displayName overrides", ({ ButtonComponent }) => {
|
|
22
|
+
test("Uses anchor by default", () => {
|
|
23
|
+
render(<ButtonComponent href="/test">Link text here</ButtonComponent>, {
|
|
24
|
+
wrapper: ThemeProvider,
|
|
25
|
+
})
|
|
26
|
+
const link = screen.getByRole("link")
|
|
27
|
+
expect(link.dataset.custom).toBe(undefined)
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
test("Uses theme's override if supplied", () => {
|
|
31
|
+
render(<ButtonComponent href="/test">Link text here</ButtonComponent>, {
|
|
32
|
+
wrapper: (props) => <ThemeProvider theme={withLinkOverride} {...props} />,
|
|
33
|
+
})
|
|
34
|
+
const link = screen.getByRole("link")
|
|
35
|
+
expect(link.dataset.custom).toBe("theme-default")
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
test("Uses component's override if supplied", () => {
|
|
39
|
+
const LinkImplementation = (props: React.ComponentProps<"a">) => (
|
|
40
|
+
// eslint-disable-next-line jsx-a11y/anchor-has-content
|
|
41
|
+
<a data-custom="anchor-override" {...props} />
|
|
42
|
+
)
|
|
43
|
+
render(
|
|
44
|
+
<ButtonComponent Component={LinkImplementation} href="/test">
|
|
45
|
+
Link text here
|
|
46
|
+
</ButtonComponent>,
|
|
47
|
+
{
|
|
48
|
+
wrapper: (props) => (
|
|
49
|
+
<ThemeProvider theme={withLinkOverride} {...props} />
|
|
50
|
+
),
|
|
51
|
+
},
|
|
52
|
+
)
|
|
53
|
+
const link = screen.getByRole("link")
|
|
54
|
+
expect(link.dataset.custom).toBe("anchor-override")
|
|
55
|
+
})
|
|
56
|
+
})
|
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
import * as React from "react"
|
|
2
|
+
import styled from "@emotion/styled"
|
|
3
|
+
import { css } from "@emotion/react"
|
|
4
|
+
import { pxToRem } from "../ThemeProvider/typography"
|
|
5
|
+
import type { Theme, ThemeOptions } from "@mui/material/styles"
|
|
6
|
+
import {
|
|
7
|
+
LinkAdapter,
|
|
8
|
+
LinkAdapterPropsOverrides,
|
|
9
|
+
} from "../LinkAdapter/LinkAdapter"
|
|
10
|
+
|
|
11
|
+
type ButtonVariant =
|
|
12
|
+
| "primary"
|
|
13
|
+
| "secondary"
|
|
14
|
+
| "tertiary"
|
|
15
|
+
| "text"
|
|
16
|
+
| "unstable_noBorder"
|
|
17
|
+
| "unstable_inverted"
|
|
18
|
+
| "unstable_success"
|
|
19
|
+
type ButtonSize = "small" | "medium" | "large"
|
|
20
|
+
type ButtonEdge = "circular" | "rounded" | "none"
|
|
21
|
+
|
|
22
|
+
type ButtonStyleProps = {
|
|
23
|
+
variant?: ButtonVariant
|
|
24
|
+
size?: ButtonSize
|
|
25
|
+
edge?: ButtonEdge
|
|
26
|
+
/**
|
|
27
|
+
* Display an icon before the button text.
|
|
28
|
+
*/
|
|
29
|
+
startIcon?: React.ReactNode
|
|
30
|
+
/**
|
|
31
|
+
* Display an icon after the button text.
|
|
32
|
+
*/
|
|
33
|
+
endIcon?: React.ReactNode
|
|
34
|
+
/**
|
|
35
|
+
* If true (default: `false`), the button will become one size smaller at the
|
|
36
|
+
* `sm` breakpoint.
|
|
37
|
+
* - large -> medium
|
|
38
|
+
* - medium -> small
|
|
39
|
+
* - small -> small
|
|
40
|
+
*/
|
|
41
|
+
responsive?: boolean
|
|
42
|
+
color?: "secondary"
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const styleProps: Record<string, boolean> = {
|
|
46
|
+
variant: true,
|
|
47
|
+
size: true,
|
|
48
|
+
edge: true,
|
|
49
|
+
startIcon: true,
|
|
50
|
+
endIcon: true,
|
|
51
|
+
responsive: true,
|
|
52
|
+
color: true,
|
|
53
|
+
} satisfies Record<keyof ButtonStyleProps, boolean>
|
|
54
|
+
|
|
55
|
+
const shouldForwardProp = (prop: string) => !styleProps[prop]
|
|
56
|
+
|
|
57
|
+
const DEFAULT_PROPS: Required<
|
|
58
|
+
Omit<ButtonStyleProps, "startIcon" | "endIcon" | "color">
|
|
59
|
+
> = {
|
|
60
|
+
variant: "primary",
|
|
61
|
+
size: "medium",
|
|
62
|
+
edge: "rounded",
|
|
63
|
+
responsive: false,
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const BORDER_WIDTHS = {
|
|
67
|
+
small: 1,
|
|
68
|
+
medium: 1,
|
|
69
|
+
large: 2,
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const RESPONSIVE_SIZES: Record<ButtonSize, ButtonSize> = {
|
|
73
|
+
small: "small",
|
|
74
|
+
medium: "small",
|
|
75
|
+
large: "medium",
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const sizeStyles = (
|
|
79
|
+
size: ButtonSize,
|
|
80
|
+
hasBorder: boolean,
|
|
81
|
+
theme: Theme,
|
|
82
|
+
): Partial<ThemeOptions["typography"]>[] => {
|
|
83
|
+
const paddingAdjust = hasBorder ? BORDER_WIDTHS[size] : 0
|
|
84
|
+
return [
|
|
85
|
+
{
|
|
86
|
+
boxSizing: "border-box",
|
|
87
|
+
borderWidth: BORDER_WIDTHS[size],
|
|
88
|
+
},
|
|
89
|
+
size === "large" && {
|
|
90
|
+
padding: `${14 - paddingAdjust}px 24px`,
|
|
91
|
+
...theme.typography.buttonLarge,
|
|
92
|
+
},
|
|
93
|
+
size === "medium" && {
|
|
94
|
+
padding: `${11 - paddingAdjust}px 16px`,
|
|
95
|
+
...theme.typography.button,
|
|
96
|
+
},
|
|
97
|
+
size === "small" && {
|
|
98
|
+
padding: `${8 - paddingAdjust}px 12px`,
|
|
99
|
+
...theme.typography.buttonSmall,
|
|
100
|
+
},
|
|
101
|
+
]
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const buildStyles = (props: ButtonStyleProps & { theme: Theme }) => {
|
|
105
|
+
const { size, variant, edge, theme, color, responsive } = {
|
|
106
|
+
...DEFAULT_PROPS,
|
|
107
|
+
...props,
|
|
108
|
+
}
|
|
109
|
+
const { colors } = theme.custom
|
|
110
|
+
const hasBorder = variant === "secondary"
|
|
111
|
+
return css([
|
|
112
|
+
{
|
|
113
|
+
color: theme.palette.text.primary,
|
|
114
|
+
textAlign: "center",
|
|
115
|
+
// display
|
|
116
|
+
display: "inline-flex",
|
|
117
|
+
justifyContent: "center",
|
|
118
|
+
alignItems: "center",
|
|
119
|
+
// transitions
|
|
120
|
+
transition: `background ${theme.transitions.duration.short}ms`,
|
|
121
|
+
// cursor
|
|
122
|
+
cursor: "pointer",
|
|
123
|
+
":disabled": {
|
|
124
|
+
cursor: "default",
|
|
125
|
+
},
|
|
126
|
+
minWidth: "100px",
|
|
127
|
+
},
|
|
128
|
+
...sizeStyles(size, hasBorder, theme),
|
|
129
|
+
// responsive
|
|
130
|
+
responsive && {
|
|
131
|
+
[theme.breakpoints.down("sm")]: sizeStyles(
|
|
132
|
+
RESPONSIVE_SIZES[size],
|
|
133
|
+
hasBorder,
|
|
134
|
+
theme,
|
|
135
|
+
),
|
|
136
|
+
},
|
|
137
|
+
// variant
|
|
138
|
+
variant === "primary" && {
|
|
139
|
+
backgroundColor: colors.mitRed,
|
|
140
|
+
color: colors.white,
|
|
141
|
+
border: "none",
|
|
142
|
+
/* Shadow/04dp */
|
|
143
|
+
boxShadow:
|
|
144
|
+
"0px 2px 4px 0px rgba(37, 38, 43, 0.10), 0px 3px 8px 0px rgba(37, 38, 43, 0.12)",
|
|
145
|
+
":hover:not(:disabled)": {
|
|
146
|
+
backgroundColor: colors.red,
|
|
147
|
+
boxShadow: "none",
|
|
148
|
+
},
|
|
149
|
+
":disabled": {
|
|
150
|
+
backgroundColor: colors.silverGray,
|
|
151
|
+
boxShadow: "none",
|
|
152
|
+
},
|
|
153
|
+
},
|
|
154
|
+
hasBorder && {
|
|
155
|
+
backgroundColor: "transparent",
|
|
156
|
+
borderColor: "currentcolor",
|
|
157
|
+
borderStyle: "solid",
|
|
158
|
+
},
|
|
159
|
+
variant === "unstable_success" && {
|
|
160
|
+
backgroundColor: colors.darkGreen,
|
|
161
|
+
color: colors.white,
|
|
162
|
+
border: "none",
|
|
163
|
+
/* Shadow/04dp */
|
|
164
|
+
boxShadow:
|
|
165
|
+
"0px 2px 4px 0px rgba(37, 38, 43, 0.10), 0px 3px 8px 0px rgba(37, 38, 43, 0.12)",
|
|
166
|
+
":hover:not(:disabled)": {
|
|
167
|
+
backgroundColor: colors.darkGreen,
|
|
168
|
+
boxShadow: "none",
|
|
169
|
+
},
|
|
170
|
+
":disabled": {
|
|
171
|
+
backgroundColor: colors.silverGray,
|
|
172
|
+
boxShadow: "none",
|
|
173
|
+
},
|
|
174
|
+
},
|
|
175
|
+
hasBorder && {
|
|
176
|
+
backgroundColor: "transparent",
|
|
177
|
+
borderColor: "currentcolor",
|
|
178
|
+
borderStyle: "solid",
|
|
179
|
+
},
|
|
180
|
+
variant === "secondary" && {
|
|
181
|
+
color: colors.red,
|
|
182
|
+
":hover:not(:disabled)": {
|
|
183
|
+
// brightRed at 0.06 alpha
|
|
184
|
+
backgroundColor: "rgba(255, 20, 35, 0.06)",
|
|
185
|
+
},
|
|
186
|
+
":disabled": {
|
|
187
|
+
color: colors.silverGray,
|
|
188
|
+
},
|
|
189
|
+
},
|
|
190
|
+
variant === "text" && {
|
|
191
|
+
backgroundColor: "transparent",
|
|
192
|
+
borderStyle: "none",
|
|
193
|
+
color: colors.darkGray2,
|
|
194
|
+
":hover:not(:disabled)": {
|
|
195
|
+
// darkGray1 at 6% alpha
|
|
196
|
+
backgroundColor: "rgba(64, 70, 76, 0.06)",
|
|
197
|
+
},
|
|
198
|
+
":disabled": {
|
|
199
|
+
color: colors.silverGray,
|
|
200
|
+
},
|
|
201
|
+
},
|
|
202
|
+
variant === "unstable_noBorder" && {
|
|
203
|
+
backgroundColor: colors.white,
|
|
204
|
+
color: colors.darkGray2,
|
|
205
|
+
border: "none",
|
|
206
|
+
":hover:not(:disabled)": {
|
|
207
|
+
// darkGray1 at 6% alpha
|
|
208
|
+
backgroundColor: "rgba(64, 70, 76, 0.06)",
|
|
209
|
+
},
|
|
210
|
+
":disabled": {
|
|
211
|
+
color: colors.silverGray,
|
|
212
|
+
},
|
|
213
|
+
},
|
|
214
|
+
variant === "tertiary" && {
|
|
215
|
+
color: colors.darkGray2,
|
|
216
|
+
border: "none",
|
|
217
|
+
backgroundColor: colors.lightGray2,
|
|
218
|
+
":hover:not(:disabled)": {
|
|
219
|
+
backgroundColor: colors.white,
|
|
220
|
+
},
|
|
221
|
+
":disabled": {
|
|
222
|
+
backgroundColor: colors.lightGray2,
|
|
223
|
+
color: colors.silverGrayLight,
|
|
224
|
+
},
|
|
225
|
+
},
|
|
226
|
+
variant === "unstable_inverted" && {
|
|
227
|
+
backgroundColor: colors.white,
|
|
228
|
+
color: colors.mitRed,
|
|
229
|
+
borderColor: colors.mitRed,
|
|
230
|
+
borderStyle: "solid",
|
|
231
|
+
},
|
|
232
|
+
// edge
|
|
233
|
+
edge === "rounded" && {
|
|
234
|
+
borderRadius: "4px",
|
|
235
|
+
},
|
|
236
|
+
edge === "circular" && {
|
|
237
|
+
// Pill-shaped buttons... Overlapping border radius get clipped to pill.
|
|
238
|
+
borderRadius: "100vh",
|
|
239
|
+
},
|
|
240
|
+
// color
|
|
241
|
+
color === "secondary" && {
|
|
242
|
+
color: theme.custom.colors.silverGray,
|
|
243
|
+
borderColor: theme.custom.colors.silverGray,
|
|
244
|
+
":hover:not(:disabled)": {
|
|
245
|
+
backgroundColor: theme.custom.colors.lightGray1,
|
|
246
|
+
},
|
|
247
|
+
},
|
|
248
|
+
])
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const ButtonStyled = styled("button", { shouldForwardProp })<ButtonStyleProps>(
|
|
252
|
+
buildStyles,
|
|
253
|
+
)
|
|
254
|
+
const LinkStyled = styled(LinkAdapter, {
|
|
255
|
+
shouldForwardProp,
|
|
256
|
+
})<ButtonStyleProps>(buildStyles)
|
|
257
|
+
|
|
258
|
+
const IconContainer = styled.span<{ side: "start" | "end"; size: ButtonSize }>(
|
|
259
|
+
({ size, side }) => [
|
|
260
|
+
{
|
|
261
|
+
height: "1em",
|
|
262
|
+
display: "flex",
|
|
263
|
+
alignItems: "center",
|
|
264
|
+
},
|
|
265
|
+
side === "start" && {
|
|
266
|
+
/**
|
|
267
|
+
* The negative margin is to counteract the padding on the button itself.
|
|
268
|
+
* Without icons, the left space is 24/16/12 px.
|
|
269
|
+
* With icons, the left space is 20/12/8 px.
|
|
270
|
+
*/
|
|
271
|
+
marginLeft: "-4px",
|
|
272
|
+
marginRight: "8px",
|
|
273
|
+
},
|
|
274
|
+
side === "end" && {
|
|
275
|
+
marginLeft: "8px",
|
|
276
|
+
marginRight: "-4px",
|
|
277
|
+
},
|
|
278
|
+
{
|
|
279
|
+
"& svg, & .MuiSvgIcon-root": {
|
|
280
|
+
width: "1em",
|
|
281
|
+
height: "1em",
|
|
282
|
+
fontSize: pxToRem(
|
|
283
|
+
{
|
|
284
|
+
small: 16,
|
|
285
|
+
medium: 20,
|
|
286
|
+
large: 24,
|
|
287
|
+
}[size],
|
|
288
|
+
),
|
|
289
|
+
},
|
|
290
|
+
},
|
|
291
|
+
],
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
const ButtonInner: React.FC<
|
|
295
|
+
ButtonStyleProps & { children?: React.ReactNode }
|
|
296
|
+
> = (props) => {
|
|
297
|
+
const { children, size = DEFAULT_PROPS.size } = props
|
|
298
|
+
return (
|
|
299
|
+
<>
|
|
300
|
+
{props.startIcon ? (
|
|
301
|
+
<IconContainer size={size} side="start">
|
|
302
|
+
{props.startIcon}
|
|
303
|
+
</IconContainer>
|
|
304
|
+
) : null}
|
|
305
|
+
{children}
|
|
306
|
+
{props.endIcon ? (
|
|
307
|
+
<IconContainer size={size} side="end">
|
|
308
|
+
{props.endIcon}
|
|
309
|
+
</IconContainer>
|
|
310
|
+
) : null}
|
|
311
|
+
</>
|
|
312
|
+
)
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
type ButtonProps = ButtonStyleProps & React.ComponentProps<"button">
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Our standard button component.
|
|
319
|
+
*
|
|
320
|
+
* See also:
|
|
321
|
+
* - ButtonLink
|
|
322
|
+
* - ActionButton
|
|
323
|
+
*/
|
|
324
|
+
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
|
325
|
+
({ children, ...props }, ref) => {
|
|
326
|
+
return (
|
|
327
|
+
<ButtonStyled ref={ref} type="button" {...props}>
|
|
328
|
+
<ButtonInner {...props}>{children}</ButtonInner>
|
|
329
|
+
</ButtonStyled>
|
|
330
|
+
)
|
|
331
|
+
},
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
type ButtonLinkProps = ButtonStyleProps &
|
|
335
|
+
React.ComponentProps<"a"> & {
|
|
336
|
+
Component?: React.ElementType
|
|
337
|
+
} & LinkAdapterPropsOverrides
|
|
338
|
+
|
|
339
|
+
const ButtonLink = React.forwardRef<HTMLAnchorElement, ButtonLinkProps>(
|
|
340
|
+
({ children, Component, ...props }: ButtonLinkProps, ref) => {
|
|
341
|
+
return (
|
|
342
|
+
<LinkStyled Component={Component} ref={ref} {...props}>
|
|
343
|
+
<ButtonInner {...props}>{children}</ButtonInner>
|
|
344
|
+
</LinkStyled>
|
|
345
|
+
)
|
|
346
|
+
},
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
ButtonLink.displayName = "ButtonLink"
|
|
350
|
+
|
|
351
|
+
type ActionButtonStyleProps = Omit<ButtonStyleProps, "startIcon" | "endIcon">
|
|
352
|
+
type ActionButtonProps = ActionButtonStyleProps & React.ComponentProps<"button">
|
|
353
|
+
|
|
354
|
+
const actionStyles = (size: ButtonSize) => {
|
|
355
|
+
return {
|
|
356
|
+
minWidth: "auto",
|
|
357
|
+
padding: 0,
|
|
358
|
+
height: {
|
|
359
|
+
small: "32px",
|
|
360
|
+
medium: "40px",
|
|
361
|
+
large: "48px",
|
|
362
|
+
}[size],
|
|
363
|
+
width: {
|
|
364
|
+
small: "32px",
|
|
365
|
+
medium: "40px",
|
|
366
|
+
large: "48px",
|
|
367
|
+
}[size],
|
|
368
|
+
"& svg, & .MuiSvgIcon-root": {
|
|
369
|
+
width: "1em",
|
|
370
|
+
height: "1em",
|
|
371
|
+
fontSize: pxToRem(
|
|
372
|
+
{
|
|
373
|
+
small: 20,
|
|
374
|
+
medium: 24,
|
|
375
|
+
large: 32,
|
|
376
|
+
}[size],
|
|
377
|
+
),
|
|
378
|
+
},
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* A button that should contain a remixicon icon and nothing else.
|
|
384
|
+
* For a variant that functions as a link, see ActionButtonLink.
|
|
385
|
+
*/
|
|
386
|
+
const ActionButton = styled(
|
|
387
|
+
React.forwardRef<HTMLButtonElement, ActionButtonProps>((props, ref) => (
|
|
388
|
+
<ButtonStyled ref={ref} type="button" {...props} />
|
|
389
|
+
)),
|
|
390
|
+
)(({ size = DEFAULT_PROPS.size, responsive, theme }) => {
|
|
391
|
+
return [
|
|
392
|
+
actionStyles(size),
|
|
393
|
+
responsive && {
|
|
394
|
+
[theme.breakpoints.down("sm")]: actionStyles(RESPONSIVE_SIZES[size]),
|
|
395
|
+
},
|
|
396
|
+
]
|
|
397
|
+
})
|
|
398
|
+
|
|
399
|
+
type ActionButtonLinkProps = ActionButtonStyleProps &
|
|
400
|
+
React.ComponentProps<"a"> & {
|
|
401
|
+
Component?: React.ElementType
|
|
402
|
+
} & LinkAdapterPropsOverrides
|
|
403
|
+
|
|
404
|
+
const ActionButtonLink = ActionButton.withComponent(
|
|
405
|
+
({ Component, ...props }: ButtonLinkProps) => {
|
|
406
|
+
return <LinkStyled Component={Component} {...props} />
|
|
407
|
+
},
|
|
408
|
+
)
|
|
409
|
+
ActionButtonLink.displayName = "ActionButtonLink"
|
|
410
|
+
|
|
411
|
+
export { Button, ButtonLink, ActionButton, ActionButtonLink, DEFAULT_PROPS }
|
|
412
|
+
|
|
413
|
+
export type {
|
|
414
|
+
ButtonProps,
|
|
415
|
+
ButtonLinkProps,
|
|
416
|
+
ActionButtonProps,
|
|
417
|
+
ActionButtonLinkProps,
|
|
418
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import * as React from "react"
|
|
2
|
+
import { useTheme } from "@emotion/react"
|
|
3
|
+
import styled from "@emotion/styled"
|
|
4
|
+
|
|
5
|
+
const PlainLink = styled.a({
|
|
6
|
+
color: "inherit",
|
|
7
|
+
textDecoration: "none",
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* LinkAdapterPropsOverrides can be used with module augmentation to provide
|
|
12
|
+
* extra props to ButtonLink.
|
|
13
|
+
*
|
|
14
|
+
* For example, in a NextJS App, you might set `next/link` as your default
|
|
15
|
+
* Link implementation, and use LinkAdapterPropsOverrides to provide
|
|
16
|
+
* `next/link`-specific props.
|
|
17
|
+
*/
|
|
18
|
+
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
|
19
|
+
interface LinkAdapterPropsOverrides {}
|
|
20
|
+
type LinkAdapterProps = React.ComponentProps<"a"> & {
|
|
21
|
+
Component?: React.ElementType
|
|
22
|
+
} & LinkAdapterPropsOverrides
|
|
23
|
+
/**
|
|
24
|
+
* Overrideable link component.
|
|
25
|
+
* - If `Component` is provided, renders as `Component`
|
|
26
|
+
* - else, if `theme.custom.LinkAdapter` is provided, renders as `theme.custom.LinkAdapter`
|
|
27
|
+
* - else, renders as `a` tag
|
|
28
|
+
*/
|
|
29
|
+
const LinkAdapter = React.forwardRef<HTMLAnchorElement, LinkAdapterProps>(
|
|
30
|
+
({ Component, ...props }, ref) => {
|
|
31
|
+
const theme = useTheme()
|
|
32
|
+
const LinkComponent = Component ?? theme.custom.LinkAdapter
|
|
33
|
+
return <PlainLink as={LinkComponent} ref={ref} {...props} />
|
|
34
|
+
},
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
export { LinkAdapter }
|
|
38
|
+
export type { LinkAdapterPropsOverrides }
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import * as React from "react"
|
|
2
|
+
import type { Meta, StoryObj } from "@storybook/react"
|
|
3
|
+
import { ThemeProvider, createTheme } from "./ThemeProvider"
|
|
4
|
+
import { ButtonLink } from "../Button/Button"
|
|
5
|
+
|
|
6
|
+
const CustomLinkAdapater = React.forwardRef<
|
|
7
|
+
HTMLAnchorElement,
|
|
8
|
+
React.ComponentProps<"a">
|
|
9
|
+
>((props, ref) => (
|
|
10
|
+
// eslint-disable-next-line jsx-a11y/anchor-has-content, jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
|
|
11
|
+
<a
|
|
12
|
+
ref={ref}
|
|
13
|
+
onClick={(e) => {
|
|
14
|
+
e.preventDefault()
|
|
15
|
+
alert(`Custom link to: ${e.currentTarget.href}. (Preventing Navigation.)`)
|
|
16
|
+
}}
|
|
17
|
+
data-custom="theme-default"
|
|
18
|
+
{...props}
|
|
19
|
+
/>
|
|
20
|
+
))
|
|
21
|
+
const customTheme = createTheme({
|
|
22
|
+
custom: {
|
|
23
|
+
LinkAdapter: CustomLinkAdapater,
|
|
24
|
+
},
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
const meta: Meta<typeof ThemeProvider> = {
|
|
28
|
+
title: "smoot-design/ThemeProvider",
|
|
29
|
+
component: ThemeProvider,
|
|
30
|
+
argTypes: {
|
|
31
|
+
theme: {
|
|
32
|
+
options: ["Default", "Custom Link Adapter"],
|
|
33
|
+
mapping: {
|
|
34
|
+
Default: undefined,
|
|
35
|
+
"Custom Link Adapter": customTheme,
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
children: {
|
|
39
|
+
table: { disable: true },
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
tags: ["autodocs"],
|
|
43
|
+
id: "smoot-design/ThemeProvider",
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
type Story = StoryObj<typeof ThemeProvider>
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* `ThemeProvider` must wrap all components from `smoot`-design, and allows
|
|
50
|
+
* styling any component via [`styled`](https://emotion.sh/docs/styled).
|
|
51
|
+
*
|
|
52
|
+
* In general, most useful theme properties are exposed on `theme.custom`. (Root
|
|
53
|
+
* `theme` properties are used internally by MUI.) See typescript definitions
|
|
54
|
+
* for more information.
|
|
55
|
+
*
|
|
56
|
+
* ### Custom Link Adapter
|
|
57
|
+
* One particularly notable property is `theme.custom.LinkAdapter`. Some `smoot-design`
|
|
58
|
+
* components render links. These links are native anchor tags by default. In
|
|
59
|
+
* order to use these components with custom routing libraries (e.g. `react-router`
|
|
60
|
+
* or `next/link`), you can provide a custom link adapter. Link components wills
|
|
61
|
+
*
|
|
62
|
+
* As an example, `ButtonLink` will:
|
|
63
|
+
* - use `Component` on `ButtonLink` if specified (`<ButtonLink Component={Link} />`)
|
|
64
|
+
* - else, use `theme.custom.LinkAdapter` if specified,
|
|
65
|
+
* - else, use `a` tag.
|
|
66
|
+
*
|
|
67
|
+
* If you provide a custom `LinkAdapter` and need **aditional** props, you can
|
|
68
|
+
* use [module augmentation](https://www.typescriptlang.org/docs/handbook/declaration-merging.html#module-augmentation) to add the custom props to relevant
|
|
69
|
+
* components. For example, if using `next/link`
|
|
70
|
+
* ```
|
|
71
|
+
* // Add scroll prop to all components using LinkAdapter
|
|
72
|
+
* declare module "@mitodl/smoot-design" {
|
|
73
|
+
* interface LinkAdapterPropsOverrides {
|
|
74
|
+
* scroll?: boolean
|
|
75
|
+
* }
|
|
76
|
+
* }
|
|
77
|
+
* ```
|
|
78
|
+
*/
|
|
79
|
+
export const LinkAdapterOverride: Story = {
|
|
80
|
+
args: {
|
|
81
|
+
theme: customTheme,
|
|
82
|
+
},
|
|
83
|
+
render: (args) => {
|
|
84
|
+
return (
|
|
85
|
+
<ThemeProvider theme={args.theme}>
|
|
86
|
+
<ButtonLink href="https://mit.edu">
|
|
87
|
+
{args.theme ? "Custom theme in use" : "Default theme in use"}
|
|
88
|
+
</ButtonLink>
|
|
89
|
+
</ThemeProvider>
|
|
90
|
+
)
|
|
91
|
+
},
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export default meta
|