@duro-app/ui 0.12.2 → 0.14.1

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 (135) hide show
  1. package/dist/components/Fieldset/Fieldset.d.ts.map +1 -1
  2. package/dist/components/PageShell/PageShell.d.ts +15 -0
  3. package/dist/components/PageShell/PageShell.d.ts.map +1 -0
  4. package/dist/components/PageShell/index.d.ts +3 -0
  5. package/dist/components/PageShell/index.d.ts.map +1 -0
  6. package/dist/components/PageShell/styles.css.d.ts +41 -0
  7. package/dist/components/PageShell/styles.css.d.ts.map +1 -0
  8. package/dist/components/ThemeProvider/ThemeProvider.d.ts.map +1 -1
  9. package/dist/index.css +1 -1
  10. package/dist/index.d.ts +1 -2
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +3203 -3355
  13. package/dist/index.js.map +1 -1
  14. package/package.json +4 -4
  15. package/src/components/Alert/Alert.stories.tsx +76 -0
  16. package/src/components/Alert/Alert.tsx +45 -0
  17. package/src/components/Alert/styles.css.ts +50 -0
  18. package/src/components/Badge/Badge.stories.tsx +94 -0
  19. package/src/components/Badge/Badge.tsx +21 -0
  20. package/src/components/Badge/styles.css.ts +51 -0
  21. package/src/components/Button/Button.stories.tsx +130 -0
  22. package/src/components/Button/Button.tsx +48 -0
  23. package/src/components/Button/styles.css.ts +107 -0
  24. package/src/components/Callout/Callout.stories.tsx +97 -0
  25. package/src/components/Callout/Callout.tsx +39 -0
  26. package/src/components/Callout/index.ts +1 -0
  27. package/src/components/Callout/styles.css.ts +45 -0
  28. package/src/components/Card/Card.stories.tsx +119 -0
  29. package/src/components/Card/Card.tsx +35 -0
  30. package/src/components/Card/styles.css.ts +67 -0
  31. package/src/components/Checkbox/Checkbox.stories.tsx +88 -0
  32. package/src/components/Checkbox/Checkbox.tsx +73 -0
  33. package/src/components/Checkbox/styles.css.ts +57 -0
  34. package/src/components/Cluster/Cluster.stories.tsx +92 -0
  35. package/src/components/Cluster/Cluster.tsx +43 -0
  36. package/src/components/Cluster/styles.css.ts +25 -0
  37. package/src/components/EmptyState/EmptyState.stories.tsx +54 -0
  38. package/src/components/EmptyState/EmptyState.tsx +19 -0
  39. package/src/components/EmptyState/styles.css.ts +25 -0
  40. package/src/components/Field/Field.stories.tsx +92 -0
  41. package/src/components/Field/Field.tsx +80 -0
  42. package/src/components/Field/FieldContext.ts +14 -0
  43. package/src/components/Field/styles.css.ts +25 -0
  44. package/src/components/Fieldset/Fieldset.stories.tsx +85 -0
  45. package/src/components/Fieldset/Fieldset.tsx +49 -0
  46. package/src/components/Fieldset/index.ts +1 -0
  47. package/src/components/Fieldset/styles.css.ts +33 -0
  48. package/src/components/Grid/Grid.stories.tsx +107 -0
  49. package/src/components/Grid/Grid.tsx +41 -0
  50. package/src/components/Grid/styles.css.ts +25 -0
  51. package/src/components/Heading/Heading.tsx +48 -0
  52. package/src/components/Heading/styles.css.ts +26 -0
  53. package/src/components/Icon/Icon.tsx +168 -0
  54. package/src/components/Icon/index.ts +2 -0
  55. package/src/components/Inline/Inline.stories.tsx +88 -0
  56. package/src/components/Inline/Inline.tsx +45 -0
  57. package/src/components/Inline/styles.css.ts +27 -0
  58. package/src/components/Input/Input.stories.tsx +89 -0
  59. package/src/components/Input/Input.tsx +77 -0
  60. package/src/components/Input/styles.css.ts +60 -0
  61. package/src/components/InputGroup/InputGroup.stories.tsx +119 -0
  62. package/src/components/InputGroup/InputGroup.tsx +60 -0
  63. package/src/components/InputGroup/InputGroupContext.ts +11 -0
  64. package/src/components/InputGroup/styles.css.ts +61 -0
  65. package/src/components/LinkButton/LinkButton.stories.tsx +91 -0
  66. package/src/components/LinkButton/LinkButton.tsx +42 -0
  67. package/src/components/LinkButton/styles.css.ts +56 -0
  68. package/src/components/Menu/Menu.stories.tsx +146 -0
  69. package/src/components/Menu/Menu.tsx +151 -0
  70. package/src/components/Menu/MenuContext.ts +20 -0
  71. package/src/components/Menu/styles.css.ts +89 -0
  72. package/src/components/Menu/useMenuRoot.ts +136 -0
  73. package/src/components/PageShell/PageShell.tsx +45 -0
  74. package/src/components/PageShell/index.ts +2 -0
  75. package/src/components/PageShell/styles.css.ts +26 -0
  76. package/src/components/ScrollArea/ScrollArea.stories.tsx +82 -0
  77. package/src/components/ScrollArea/ScrollArea.tsx +170 -0
  78. package/src/components/ScrollArea/ScrollAreaContext.ts +21 -0
  79. package/src/components/ScrollArea/styles.css.ts +81 -0
  80. package/src/components/ScrollArea/useScrollAreaRoot.ts +72 -0
  81. package/src/components/Select/Select.stories.tsx +144 -0
  82. package/src/components/Select/Select.tsx +183 -0
  83. package/src/components/Select/SelectContext.ts +24 -0
  84. package/src/components/Select/styles.css.ts +97 -0
  85. package/src/components/Select/useSelectRoot.ts +178 -0
  86. package/src/components/SideNav/SideNav.stories.tsx +77 -0
  87. package/src/components/SideNav/SideNav.tsx +172 -0
  88. package/src/components/SideNav/SideNavContext.ts +18 -0
  89. package/src/components/SideNav/styles.css.ts +95 -0
  90. package/src/components/Spinner/Spinner.stories.tsx +59 -0
  91. package/src/components/Spinner/Spinner.tsx +24 -0
  92. package/src/components/Spinner/styles.css.ts +47 -0
  93. package/src/components/Stack/Stack.stories.tsx +103 -0
  94. package/src/components/Stack/Stack.tsx +33 -0
  95. package/src/components/Stack/styles.css.ts +21 -0
  96. package/src/components/StatusIcon/StatusIcon.stories.tsx +81 -0
  97. package/src/components/StatusIcon/StatusIcon.tsx +24 -0
  98. package/src/components/StatusIcon/styles.css.ts +27 -0
  99. package/src/components/Switch/Switch.stories.tsx +88 -0
  100. package/src/components/Switch/Switch.tsx +78 -0
  101. package/src/components/Switch/styles.css.ts +71 -0
  102. package/src/components/Table/Table.stories.tsx +308 -0
  103. package/src/components/Table/Table.tsx +179 -0
  104. package/src/components/Table/styles.css.ts +97 -0
  105. package/src/components/Tabs/Tabs.stories.tsx +142 -0
  106. package/src/components/Tabs/Tabs.tsx +210 -0
  107. package/src/components/Tabs/TabsContext.ts +20 -0
  108. package/src/components/Tabs/styles.css.ts +98 -0
  109. package/src/components/Tabs/useTabsRoot.ts +42 -0
  110. package/src/components/Text/Text.tsx +52 -0
  111. package/src/components/Text/styles.css.ts +57 -0
  112. package/src/components/Textarea/Textarea.stories.tsx +80 -0
  113. package/src/components/Textarea/Textarea.tsx +50 -0
  114. package/src/components/Textarea/styles.css.ts +56 -0
  115. package/src/components/ThemeProvider/ThemeProvider.stories.tsx +163 -0
  116. package/src/components/ThemeProvider/ThemeProvider.tsx +33 -0
  117. package/src/components/Toggle/Toggle.stories.tsx +84 -0
  118. package/src/components/Toggle/Toggle.tsx +85 -0
  119. package/src/components/Toggle/styles.css.ts +66 -0
  120. package/src/components/ToggleGroup/ToggleGroup.stories.tsx +159 -0
  121. package/src/components/ToggleGroup/ToggleGroup.tsx +63 -0
  122. package/src/components/ToggleGroup/ToggleGroupContext.ts +18 -0
  123. package/src/components/ToggleGroup/styles.css.ts +17 -0
  124. package/src/components/Tooltip/Tooltip.stories.tsx +127 -0
  125. package/src/components/Tooltip/Tooltip.tsx +97 -0
  126. package/src/components/Tooltip/styles.css.ts +56 -0
  127. package/src/docs/Spacing.mdx +80 -0
  128. package/src/docs/Spacing.stories.tsx +202 -0
  129. package/src/docs/Typography.mdx +93 -0
  130. package/src/docs/Typography.stories.tsx +211 -0
  131. package/src/docs/helpers.tsx +135 -0
  132. package/src/hooks/useContainerQuery.ts +54 -0
  133. package/src/hooks/useControllableValue.ts +18 -0
  134. package/src/index.ts +56 -0
  135. package/src/stubs/assets-registry.ts +3 -0
@@ -0,0 +1,77 @@
1
+ import {html} from 'react-strict-dom'
2
+ import {useFieldContext} from '../Field/FieldContext'
3
+ import {useInputGroupContext} from '../InputGroup/InputGroupContext'
4
+ import {styles} from './styles.css'
5
+
6
+ type StrictInputProps = React.ComponentProps<typeof html.input>
7
+ export type InputType = NonNullable<StrictInputProps['type']>
8
+
9
+ export type InputVariant = 'default' | 'error'
10
+
11
+ interface InputProps {
12
+ variant?: InputVariant
13
+ type?: InputType
14
+ name?: string
15
+ placeholder?: string
16
+ required?: boolean
17
+ minLength?: number
18
+ pattern?: string
19
+ autoComplete?:
20
+ | 'on'
21
+ | 'off'
22
+ | 'email'
23
+ | 'username'
24
+ | 'current-password'
25
+ | 'new-password'
26
+ | 'name'
27
+ | 'tel'
28
+ | 'url'
29
+ value?: string
30
+ defaultValue?: string
31
+ disabled?: boolean
32
+ onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void
33
+ }
34
+
35
+ export function Input({
36
+ variant = 'default',
37
+ type = 'text',
38
+ name,
39
+ placeholder,
40
+ required,
41
+ minLength,
42
+ pattern,
43
+ autoComplete,
44
+ value,
45
+ defaultValue,
46
+ disabled,
47
+ onChange,
48
+ }: InputProps) {
49
+ const ctx = useFieldContext()
50
+ const groupCtx = useInputGroupContext()
51
+
52
+ // react-strict-dom omits web-only `pattern` from its types, but the
53
+ // underlying DOM element supports it. Type-assert to pass it through.
54
+ const extraProps = pattern !== undefined ? {pattern} : undefined
55
+
56
+ return (
57
+ <html.input
58
+ id={ctx?.controlId}
59
+ type={type}
60
+ name={name}
61
+ placeholder={placeholder}
62
+ required={required}
63
+ minLength={minLength}
64
+ autoComplete={autoComplete}
65
+ value={value}
66
+ defaultValue={defaultValue}
67
+ disabled={disabled}
68
+ aria-describedby={
69
+ ctx ? `${ctx.descriptionId} ${ctx.invalid ? ctx.errorId : ''}`.trim() : undefined
70
+ }
71
+ aria-invalid={ctx?.invalid || variant === 'error' || undefined}
72
+ onChange={onChange}
73
+ style={[styles.base, styles[variant], groupCtx?.inGroup && styles.inGroup]}
74
+ {...(extraProps as Record<string, unknown>)}
75
+ />
76
+ )
77
+ }
@@ -0,0 +1,60 @@
1
+ import {css} from 'react-strict-dom'
2
+ import {colors} from '@duro-app/tokens/tokens/colors.css'
3
+ import {spacing, radii} from '@duro-app/tokens/tokens/spacing.css'
4
+ import {typography} from '@duro-app/tokens/tokens/typography.css'
5
+
6
+ export const styles = css.create({
7
+ base: {
8
+ boxSizing: 'border-box',
9
+ width: '100%',
10
+ paddingTop: spacing.sm,
11
+ paddingBottom: spacing.sm,
12
+ paddingLeft: spacing.md,
13
+ paddingRight: spacing.md,
14
+ fontFamily: typography.fontFamily,
15
+ fontSize: typography.fontSizeSm,
16
+ lineHeight: typography.lineHeight,
17
+ color: colors.text,
18
+ backgroundColor: colors.bg,
19
+ borderWidth: 1,
20
+ borderStyle: 'solid',
21
+ borderRadius: radii.sm,
22
+ transitionProperty: 'border-color',
23
+ transitionDuration: '150ms',
24
+ transitionTimingFunction: 'ease',
25
+ outlineWidth: {
26
+ default: 0,
27
+ ':focus-visible': 2,
28
+ },
29
+ outlineStyle: {
30
+ default: 'none',
31
+ ':focus-visible': 'solid',
32
+ },
33
+ outlineColor: {
34
+ default: 'transparent',
35
+ ':focus-visible': colors.accent,
36
+ },
37
+ outlineOffset: {
38
+ default: 0,
39
+ ':focus-visible': 1,
40
+ },
41
+ },
42
+ default: {
43
+ borderColor: {
44
+ default: colors.border,
45
+ ':hover': colors.textMuted,
46
+ ':focus': colors.accent,
47
+ },
48
+ },
49
+ error: {
50
+ borderColor: {
51
+ default: colors.error,
52
+ ':focus': colors.error,
53
+ },
54
+ },
55
+ inGroup: {
56
+ borderWidth: 0,
57
+ borderRadius: 0,
58
+ outlineWidth: 0,
59
+ },
60
+ })
@@ -0,0 +1,119 @@
1
+ import type {Meta, StoryObj} from '@storybook/react'
2
+ import {expect, fn} from 'storybook/test'
3
+ import {css, html} from 'react-strict-dom'
4
+ import {InputGroup} from './InputGroup'
5
+ import {Input} from '../Input/Input'
6
+
7
+ const meta: Meta = {
8
+ title: 'Components/InputGroup',
9
+ }
10
+
11
+ export default meta
12
+ type Story = StoryObj
13
+
14
+ const onCopy = fn()
15
+
16
+ export const EndAddon: Story = {
17
+ render: () => (
18
+ <html.div style={layoutStyles.container}>
19
+ <InputGroup.Root>
20
+ <Input placeholder="Certificate password" />
21
+ <InputGroup.Addon onClick={onCopy}>Copy</InputGroup.Addon>
22
+ </InputGroup.Root>
23
+ </html.div>
24
+ ),
25
+ play: async ({canvas}) => {
26
+ const input = canvas.getByPlaceholderText('Certificate password')
27
+ await expect(input).toBeInTheDocument()
28
+
29
+ const button = canvas.getByRole('button', {name: 'Copy'})
30
+ await expect(button).toBeInTheDocument()
31
+ },
32
+ }
33
+
34
+ export const StartAddon: Story = {
35
+ render: () => (
36
+ <html.div style={layoutStyles.container}>
37
+ <InputGroup.Root>
38
+ <InputGroup.Addon position="start">https://</InputGroup.Addon>
39
+ <Input placeholder="example.com" />
40
+ </InputGroup.Root>
41
+ </html.div>
42
+ ),
43
+ play: async ({canvas}) => {
44
+ await expect(canvas.getByText('https://')).toBeInTheDocument()
45
+ await expect(canvas.getByPlaceholderText('example.com')).toBeInTheDocument()
46
+ },
47
+ }
48
+
49
+ export const BothSides: Story = {
50
+ render: () => (
51
+ <html.div style={layoutStyles.container}>
52
+ <InputGroup.Root>
53
+ <InputGroup.Addon position="start">https://</InputGroup.Addon>
54
+ <Input placeholder="example.com" />
55
+ <InputGroup.Addon onClick={() => {}}>Go</InputGroup.Addon>
56
+ </InputGroup.Root>
57
+ </html.div>
58
+ ),
59
+ play: async ({canvas}) => {
60
+ await expect(canvas.getByText('https://')).toBeInTheDocument()
61
+ await expect(canvas.getByPlaceholderText('example.com')).toBeInTheDocument()
62
+ await expect(canvas.getByRole('button', {name: 'Go'})).toBeInTheDocument()
63
+ },
64
+ }
65
+
66
+ export const ClickableAddon: Story = {
67
+ render: () => (
68
+ <html.div style={layoutStyles.container}>
69
+ <InputGroup.Root>
70
+ <Input placeholder="Click the addon" />
71
+ <InputGroup.Addon onClick={onCopy}>Copy</InputGroup.Addon>
72
+ </InputGroup.Root>
73
+ </html.div>
74
+ ),
75
+ play: async ({canvas, userEvent}) => {
76
+ const button = canvas.getByRole('button', {name: 'Copy'})
77
+ await userEvent.click(button)
78
+ await expect(onCopy).toHaveBeenCalled()
79
+ },
80
+ }
81
+
82
+ export const StandaloneInput: Story = {
83
+ name: 'Standalone Input (unchanged)',
84
+ render: () => (
85
+ <html.div style={layoutStyles.container}>
86
+ <Input placeholder="Regular input, no group" />
87
+ </html.div>
88
+ ),
89
+ play: async ({canvas}) => {
90
+ const input = canvas.getByPlaceholderText('Regular input, no group')
91
+ await expect(input).toBeInTheDocument()
92
+ },
93
+ }
94
+
95
+ const layoutStyles = css.create({
96
+ container: {maxWidth: 400},
97
+ stack: {display: 'flex', flexDirection: 'column', gap: 16, maxWidth: 400},
98
+ })
99
+
100
+ export const AllVariants: Story = {
101
+ render: () => (
102
+ <html.div style={layoutStyles.stack}>
103
+ <InputGroup.Root>
104
+ <Input placeholder="End addon" />
105
+ <InputGroup.Addon onClick={() => {}}>Copy</InputGroup.Addon>
106
+ </InputGroup.Root>
107
+ <InputGroup.Root>
108
+ <InputGroup.Addon position="start">@</InputGroup.Addon>
109
+ <Input placeholder="Start addon" />
110
+ </InputGroup.Root>
111
+ <InputGroup.Root>
112
+ <InputGroup.Addon position="start">https://</InputGroup.Addon>
113
+ <Input placeholder="Both sides" />
114
+ <InputGroup.Addon onClick={() => {}}>Go</InputGroup.Addon>
115
+ </InputGroup.Root>
116
+ <Input placeholder="Standalone (no group)" />
117
+ </html.div>
118
+ ),
119
+ }
@@ -0,0 +1,60 @@
1
+ import type {ReactNode} from 'react'
2
+ import {useMemo} from 'react'
3
+ import {css, html} from 'react-strict-dom'
4
+ import {InputGroupContext} from './InputGroupContext'
5
+ import {styles} from './styles.css'
6
+
7
+ // --- Root ---
8
+ interface RootProps {
9
+ children: ReactNode
10
+ }
11
+
12
+ function Root({children}: RootProps) {
13
+ const ctx = useMemo(() => ({inGroup: true}), [])
14
+
15
+ return (
16
+ <InputGroupContext.Provider value={ctx}>
17
+ <html.div style={styles.wrapper}>{children}</html.div>
18
+ </InputGroupContext.Provider>
19
+ )
20
+ }
21
+
22
+ // --- Addon ---
23
+ const dynamicStyles = css.create({
24
+ minWidth: (value: number | string) => ({minWidth: value}),
25
+ })
26
+
27
+ interface AddonProps {
28
+ position?: 'start' | 'end'
29
+ onClick?: () => void
30
+ disabled?: boolean
31
+ /** Optional minimum width to prevent layout shift (e.g. Copy → Copied!) */
32
+ minWidth?: number | string
33
+ children: ReactNode
34
+ }
35
+
36
+ function Addon({position = 'end', onClick, disabled, minWidth, children}: AddonProps) {
37
+ const positionStyle = position === 'start' ? styles.addonStart : styles.addonEnd
38
+ const style = [
39
+ styles.addon,
40
+ positionStyle,
41
+ onClick && !disabled && styles.addonClickable,
42
+ disabled && styles.addonDisabled,
43
+ minWidth != null && dynamicStyles.minWidth(minWidth),
44
+ ]
45
+
46
+ if (onClick) {
47
+ return (
48
+ <html.button type="button" onClick={onClick} disabled={disabled} style={style}>
49
+ {children}
50
+ </html.button>
51
+ )
52
+ }
53
+
54
+ return <html.span style={style}>{children}</html.span>
55
+ }
56
+
57
+ export const InputGroup = {
58
+ Root,
59
+ Addon,
60
+ }
@@ -0,0 +1,11 @@
1
+ import {createContext, useContext} from 'react'
2
+
3
+ interface InputGroupContextValue {
4
+ inGroup: boolean
5
+ }
6
+
7
+ export const InputGroupContext = createContext<InputGroupContextValue | null>(null)
8
+
9
+ export function useInputGroupContext() {
10
+ return useContext(InputGroupContext)
11
+ }
@@ -0,0 +1,61 @@
1
+ import {css} from 'react-strict-dom'
2
+ import {colors} from '@duro-app/tokens/tokens/colors.css'
3
+ import {spacing, radii} from '@duro-app/tokens/tokens/spacing.css'
4
+ import {typography} from '@duro-app/tokens/tokens/typography.css'
5
+
6
+ export const styles = css.create({
7
+ wrapper: {
8
+ display: 'flex',
9
+ alignItems: 'stretch',
10
+ borderWidth: 1,
11
+ borderStyle: 'solid',
12
+ borderColor: {
13
+ default: colors.border,
14
+ ':focus-within': colors.accent,
15
+ },
16
+ borderRadius: radii.sm,
17
+ overflow: 'hidden',
18
+ transitionProperty: 'border-color',
19
+ transitionDuration: '150ms',
20
+ transitionTimingFunction: 'ease',
21
+ },
22
+ addon: {
23
+ display: 'inline-flex',
24
+ alignItems: 'center',
25
+ justifyContent: 'center',
26
+ flexShrink: 0,
27
+ paddingLeft: spacing.md,
28
+ paddingRight: spacing.md,
29
+ backgroundColor: colors.bgCardHover,
30
+ fontFamily: typography.fontFamily,
31
+ fontSize: typography.fontSizeSm,
32
+ color: colors.textMuted,
33
+ userSelect: 'none',
34
+ borderWidth: 0,
35
+ },
36
+ addonStart: {
37
+ borderRightWidth: 1,
38
+ borderRightStyle: 'solid',
39
+ borderRightColor: colors.border,
40
+ },
41
+ addonEnd: {
42
+ borderLeftWidth: 1,
43
+ borderLeftStyle: 'solid',
44
+ borderLeftColor: colors.border,
45
+ },
46
+ addonClickable: {
47
+ cursor: 'pointer',
48
+ backgroundColor: {
49
+ default: colors.bgCardHover,
50
+ ':hover': colors.bgCard,
51
+ ':active': colors.bg,
52
+ },
53
+ transitionProperty: 'background-color',
54
+ transitionDuration: '150ms',
55
+ transitionTimingFunction: 'ease',
56
+ },
57
+ addonDisabled: {
58
+ opacity: 0.4,
59
+ cursor: 'not-allowed',
60
+ },
61
+ })
@@ -0,0 +1,91 @@
1
+ import type {Meta, StoryObj} from '@storybook/react'
2
+ import {expect} from 'storybook/test'
3
+ import {css, html} from 'react-strict-dom'
4
+ import {LinkButton} from './LinkButton'
5
+
6
+ const meta: Meta<typeof LinkButton> = {
7
+ title: 'Components/LinkButton',
8
+ component: LinkButton,
9
+ argTypes: {
10
+ variant: {
11
+ control: 'select',
12
+ options: ['primary', 'secondary'],
13
+ },
14
+ size: {
15
+ control: 'select',
16
+ options: ['default', 'small'],
17
+ },
18
+ fullWidth: {control: 'boolean'},
19
+ },
20
+ }
21
+
22
+ export default meta
23
+ type Story = StoryObj<typeof LinkButton>
24
+
25
+ export const Primary: Story = {
26
+ args: {href: '#', variant: 'primary', children: 'Get started'},
27
+ play: async ({canvas}) => {
28
+ const link = canvas.getByRole('link', {name: 'Get started'})
29
+ await expect(link).toBeInTheDocument()
30
+ await expect(link).toHaveAttribute('href', '#')
31
+ },
32
+ }
33
+
34
+ export const Secondary: Story = {
35
+ args: {href: '#', variant: 'secondary', children: 'Learn more'},
36
+ play: async ({canvas}) => {
37
+ await expect(canvas.getByRole('link', {name: 'Learn more'})).toBeInTheDocument()
38
+ },
39
+ }
40
+
41
+ export const Small: Story = {
42
+ args: {href: '#', size: 'small', children: 'Small link'},
43
+ }
44
+
45
+ export const FullWidth: Story = {
46
+ args: {href: '#', fullWidth: true, children: 'Full width link'},
47
+ }
48
+
49
+ export const ExternalLink: Story = {
50
+ args: {
51
+ href: 'https://example.com',
52
+ target: '_blank',
53
+ rel: 'noopener noreferrer',
54
+ children: 'External',
55
+ },
56
+ play: async ({canvas}) => {
57
+ const link = canvas.getByRole('link', {name: 'External'})
58
+ await expect(link).toHaveAttribute('target', '_blank')
59
+ await expect(link).toHaveAttribute('rel', 'noopener noreferrer')
60
+ },
61
+ }
62
+
63
+ const rowStyles = css.create({
64
+ row: {display: 'flex', gap: 12, alignItems: 'center', flexWrap: 'wrap'},
65
+ stack: {display: 'flex', flexDirection: 'column', gap: 16},
66
+ })
67
+
68
+ export const AllVariants: Story = {
69
+ render: () => (
70
+ <html.div style={rowStyles.stack}>
71
+ <html.div style={rowStyles.row}>
72
+ <LinkButton href="#">Primary</LinkButton>
73
+ <LinkButton href="#" variant="secondary">
74
+ Secondary
75
+ </LinkButton>
76
+ </html.div>
77
+ <html.div style={rowStyles.row}>
78
+ <LinkButton href="#" size="small">
79
+ Small Primary
80
+ </LinkButton>
81
+ <LinkButton href="#" variant="secondary" size="small">
82
+ Small Secondary
83
+ </LinkButton>
84
+ </html.div>
85
+ </html.div>
86
+ ),
87
+ play: async ({canvas}) => {
88
+ const links = canvas.getAllByRole('link')
89
+ await expect(links.length).toBe(4)
90
+ },
91
+ }
@@ -0,0 +1,42 @@
1
+ import type {ReactNode} from 'react'
2
+ import {html} from 'react-strict-dom'
3
+ import {styles} from './styles.css'
4
+
5
+ export type LinkButtonVariant = 'primary' | 'secondary'
6
+ export type LinkButtonSize = 'default' | 'small'
7
+
8
+ interface LinkButtonProps {
9
+ href: string
10
+ variant?: LinkButtonVariant
11
+ size?: LinkButtonSize
12
+ fullWidth?: boolean
13
+ target?: '_blank' | '_self'
14
+ rel?: string
15
+ children: ReactNode
16
+ }
17
+
18
+ const sizeMap = {
19
+ default: styles.sizeDefault,
20
+ small: styles.sizeSmall,
21
+ } as const
22
+
23
+ export function LinkButton({
24
+ href,
25
+ variant = 'primary',
26
+ size = 'default',
27
+ fullWidth = false,
28
+ target,
29
+ rel,
30
+ children,
31
+ }: LinkButtonProps) {
32
+ return (
33
+ <html.a
34
+ href={href}
35
+ target={target}
36
+ rel={rel}
37
+ style={[styles.base, sizeMap[size], styles[variant], fullWidth && styles.fullWidth]}
38
+ >
39
+ {children}
40
+ </html.a>
41
+ )
42
+ }
@@ -0,0 +1,56 @@
1
+ import {css} from 'react-strict-dom'
2
+ import {colors} from '@duro-app/tokens/tokens/colors.css'
3
+ import {spacing, radii} from '@duro-app/tokens/tokens/spacing.css'
4
+ import {typography} from '@duro-app/tokens/tokens/typography.css'
5
+
6
+ export const styles = css.create({
7
+ base: {
8
+ display: 'inline-flex',
9
+ alignItems: 'center',
10
+ justifyContent: 'center',
11
+ gap: spacing.sm,
12
+ fontFamily: typography.fontFamily,
13
+ fontSize: typography.fontSizeSm,
14
+ fontWeight: typography.fontWeightMedium,
15
+ lineHeight: typography.lineHeight,
16
+ borderRadius: radii.sm,
17
+ cursor: 'pointer',
18
+ textDecoration: 'none',
19
+ transitionProperty: 'background-color, border-color, color',
20
+ transitionDuration: '150ms',
21
+ transitionTimingFunction: 'ease',
22
+ },
23
+ sizeDefault: {
24
+ paddingTop: spacing.sm,
25
+ paddingBottom: spacing.sm,
26
+ paddingLeft: spacing.md,
27
+ paddingRight: spacing.md,
28
+ },
29
+ sizeSmall: {
30
+ paddingTop: spacing.xs,
31
+ paddingBottom: spacing.xs,
32
+ paddingLeft: spacing.sm,
33
+ paddingRight: spacing.sm,
34
+ fontSize: typography.fontSizeXs,
35
+ },
36
+ primary: {
37
+ backgroundColor: {
38
+ default: colors.accent,
39
+ ':hover': colors.accentHover,
40
+ },
41
+ color: colors.accentContrast,
42
+ },
43
+ secondary: {
44
+ backgroundColor: {
45
+ default: 'transparent',
46
+ ':hover': colors.bgCardHover,
47
+ },
48
+ borderWidth: 1,
49
+ borderStyle: 'solid',
50
+ borderColor: colors.border,
51
+ color: colors.textMuted,
52
+ },
53
+ fullWidth: {
54
+ width: '100%',
55
+ },
56
+ })