@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.
Files changed (40) hide show
  1. package/.eslintrc.js +142 -0
  2. package/.github/workflows/ci.yml +48 -0
  3. package/.github/workflows/publish-pages.yml +50 -0
  4. package/.github/workflows/release.yml +34 -0
  5. package/.github/workflows/validate-pr.yml +49 -0
  6. package/.pre-commit-config.yaml +90 -0
  7. package/.prettierignore +1 -0
  8. package/.prettierrc.json +4 -0
  9. package/.releaserc.json +40 -0
  10. package/.secrets.baseline +113 -0
  11. package/.storybook/main.ts +46 -0
  12. package/.storybook/manager-head.html +1 -0
  13. package/.storybook/preview-head.html +5 -0
  14. package/.storybook/preview.tsx +15 -0
  15. package/.storybook/public/pexels-photo-1851188.webp +0 -0
  16. package/.yarn/releases/yarn-4.5.1.cjs +934 -0
  17. package/.yarnrc.yml +23 -0
  18. package/LICENSE +28 -0
  19. package/README.md +13 -0
  20. package/jest.config.ts +22 -0
  21. package/package.json +110 -0
  22. package/src/components/Button/ActionButton.stories.tsx +186 -0
  23. package/src/components/Button/Button.stories.tsx +275 -0
  24. package/src/components/Button/Button.test.tsx +56 -0
  25. package/src/components/Button/Button.tsx +418 -0
  26. package/src/components/LinkAdapter/LinkAdapter.tsx +38 -0
  27. package/src/components/ThemeProvider/ThemeProvider.stories.tsx +94 -0
  28. package/src/components/ThemeProvider/ThemeProvider.tsx +127 -0
  29. package/src/components/ThemeProvider/Typography.stories.tsx +74 -0
  30. package/src/components/ThemeProvider/breakpoints.ts +20 -0
  31. package/src/components/ThemeProvider/buttons.ts +22 -0
  32. package/src/components/ThemeProvider/chips.tsx +167 -0
  33. package/src/components/ThemeProvider/colors.ts +33 -0
  34. package/src/components/ThemeProvider/typography.ts +174 -0
  35. package/src/index.ts +24 -0
  36. package/src/jest-setup.ts +0 -0
  37. package/src/story-utils/index.ts +28 -0
  38. package/src/types/theme.d.ts +106 -0
  39. package/src/types/typography.d.ts +54 -0
  40. 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