@solo-io-public/ui-components 0.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/LICENSE +201 -0
- package/README.md +167 -0
- package/dist/Button.style-CCplEN2o.js +474 -0
- package/dist/Button.style-CCplEN2o.js.map +1 -0
- package/dist/Button.style-CI6Xbod7.cjs +10 -0
- package/dist/Button.style-CI6Xbod7.cjs.map +1 -0
- package/dist/_internal/Svg.d.ts +15 -0
- package/dist/_internal/colors.d.ts +17 -0
- package/dist/_internal/gradients.d.ts +11 -0
- package/dist/_internal/hexAddAlpha.d.ts +3 -0
- package/dist/_internal/palette.d.ts +30 -0
- package/dist/_internal/sizing.d.ts +14 -0
- package/dist/_internal/unstyledButton.d.ts +8 -0
- package/dist/_internal/utils.d.ts +7 -0
- package/dist/components/Alert/Alert.d.ts +17 -0
- package/dist/components/Alert/index.d.ts +1 -0
- package/dist/components/Button/Button.d.ts +32 -0
- package/dist/components/Button/Button.style.d.ts +48 -0
- package/dist/components/Button/index.d.ts +1 -0
- package/dist/components/CloseButton/CloseButton.d.ts +14 -0
- package/dist/components/CloseButton/index.d.ts +1 -0
- package/dist/components/Layout/FlexLayout.d.ts +83 -0
- package/dist/components/Layout/Spacer.d.ts +80 -0
- package/dist/components/Layout/index.d.ts +2 -0
- package/dist/components/MonacoEditor/EditorSettingsContext.d.ts +15 -0
- package/dist/components/MonacoEditor/MonacoEditorWithSettings.d.ts +20 -0
- package/dist/components/MonacoEditor/index.d.ts +2 -0
- package/dist/components/Text/Text.d.ts +71 -0
- package/dist/components/Text/index.d.ts +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/providers/SoloContextProvider.d.ts +62 -0
- package/dist/providers/SoloModeContext.d.ts +5 -0
- package/dist/providers/index.d.ts +2 -0
- package/dist/solo-components.cjs +56 -0
- package/dist/solo-components.cjs.map +1 -0
- package/dist/solo-components.js +664 -0
- package/dist/solo-components.js.map +1 -0
- package/dist/styles.cjs +2 -0
- package/dist/styles.cjs.map +1 -0
- package/dist/styles.d.ts +1 -0
- package/dist/styles.js +5 -0
- package/dist/styles.js.map +1 -0
- package/package.json +109 -0
- package/src/_internal/Svg.tsx +58 -0
- package/src/_internal/colors.ts +26 -0
- package/src/_internal/gradients.ts +36 -0
- package/src/_internal/hexAddAlpha.ts +9 -0
- package/src/_internal/palette.ts +46 -0
- package/src/_internal/sizing.ts +15 -0
- package/src/_internal/unstyledButton.ts +17 -0
- package/src/_internal/utils.ts +11 -0
- package/src/components/Alert/Alert.stories.tsx +44 -0
- package/src/components/Alert/Alert.tsx +168 -0
- package/src/components/Alert/index.ts +1 -0
- package/src/components/Button/Button.stories.tsx +62 -0
- package/src/components/Button/Button.style.ts +158 -0
- package/src/components/Button/Button.test.tsx +47 -0
- package/src/components/Button/Button.tsx +84 -0
- package/src/components/Button/index.ts +11 -0
- package/src/components/CloseButton/CloseButton.tsx +76 -0
- package/src/components/CloseButton/index.ts +1 -0
- package/src/components/Layout/FlexLayout.tsx +101 -0
- package/src/components/Layout/Spacer.tsx +131 -0
- package/src/components/Layout/index.ts +2 -0
- package/src/components/MonacoEditor/EditorSettingsContext.test.tsx +55 -0
- package/src/components/MonacoEditor/EditorSettingsContext.tsx +63 -0
- package/src/components/MonacoEditor/MonacoEditor.stories.tsx +197 -0
- package/src/components/MonacoEditor/MonacoEditorWithSettings.tsx +376 -0
- package/src/components/MonacoEditor/index.ts +9 -0
- package/src/components/Text/Text.stories.tsx +63 -0
- package/src/components/Text/Text.tsx +65 -0
- package/src/components/Text/index.ts +1 -0
- package/src/index.ts +7 -0
- package/src/providers/SoloContextProvider.tsx +213 -0
- package/src/providers/SoloModeContext.tsx +13 -0
- package/src/providers/index.ts +10 -0
- package/src/styles.ts +5 -0
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import styled from '@emotion/styled';
|
|
2
|
+
import { CircleCheck, Info, OctagonAlert, TriangleAlert, type LucideIcon } from 'lucide-react';
|
|
3
|
+
import { useState, type ReactNode } from 'react';
|
|
4
|
+
import { soloColorTokens, type SoloColorName } from '../../_internal/palette';
|
|
5
|
+
import { dontForwardProps } from '../../_internal/utils';
|
|
6
|
+
import { useSoloMode } from '../../providers/SoloModeContext';
|
|
7
|
+
import { CloseButton } from '../CloseButton';
|
|
8
|
+
import { Text } from '../Text';
|
|
9
|
+
|
|
10
|
+
// region Styles
|
|
11
|
+
|
|
12
|
+
const Container = styled('div', dontForwardProps('background', 'frame', 'shadow'))<{
|
|
13
|
+
background: string;
|
|
14
|
+
frame: string;
|
|
15
|
+
shadow: string;
|
|
16
|
+
}>`
|
|
17
|
+
display: flex;
|
|
18
|
+
align-items: flex-start;
|
|
19
|
+
gap: 12px;
|
|
20
|
+
padding: 14px 16px;
|
|
21
|
+
border-radius: 6px;
|
|
22
|
+
border: 1px solid ${props => props.frame};
|
|
23
|
+
background: ${props => props.background};
|
|
24
|
+
box-shadow: ${props => props.shadow};
|
|
25
|
+
`;
|
|
26
|
+
|
|
27
|
+
const IconSlot = styled('div', dontForwardProps('accent'))<{ accent: string }>`
|
|
28
|
+
flex-shrink: 0;
|
|
29
|
+
display: flex;
|
|
30
|
+
margin-top: 1px;
|
|
31
|
+
color: ${props => props.accent};
|
|
32
|
+
|
|
33
|
+
svg {
|
|
34
|
+
width: 18px;
|
|
35
|
+
height: 18px;
|
|
36
|
+
}
|
|
37
|
+
`;
|
|
38
|
+
|
|
39
|
+
const Body = styled.div`
|
|
40
|
+
display: flex;
|
|
41
|
+
flex: 1;
|
|
42
|
+
flex-direction: column;
|
|
43
|
+
gap: 2px;
|
|
44
|
+
min-width: 0;
|
|
45
|
+
`;
|
|
46
|
+
|
|
47
|
+
// region Component
|
|
48
|
+
|
|
49
|
+
export type AlertType = 'info' | 'success' | 'warning' | 'danger';
|
|
50
|
+
|
|
51
|
+
// `darkAccent` is a neon-bright icon/outline for dark mode. `lightAccent` lets a
|
|
52
|
+
// type swap its muted palette solid for a punchier hue in light mode (e.g. a
|
|
53
|
+
// truer red / yellow); it falls back to the solid when omitted.
|
|
54
|
+
const typeConfig: Record<AlertType, { color: SoloColorName; Icon: LucideIcon; darkAccent: string; lightAccent?: string }> = {
|
|
55
|
+
// Brand purple, matching the Button's `dark-purple`.
|
|
56
|
+
info: { color: 'dark-purple', Icon: Info, darkAccent: '#b57bff', lightAccent: '#8134e2' },
|
|
57
|
+
success: { color: 'success', Icon: CircleCheck, darkAccent: '#2fe07f', lightAccent: '#16a34a' },
|
|
58
|
+
warning: { color: 'warning', Icon: TriangleAlert, darkAccent: '#ffb238', lightAccent: '#f7c52e' },
|
|
59
|
+
danger: { color: 'red', Icon: OctagonAlert, darkAccent: '#ff6a6a', lightAccent: '#ef3030' }
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export interface AlertProps {
|
|
63
|
+
type?: AlertType;
|
|
64
|
+
title?: ReactNode;
|
|
65
|
+
children?: ReactNode;
|
|
66
|
+
/** Shows an X button in the upper-right that hides the alert. */
|
|
67
|
+
isDismissable?: boolean;
|
|
68
|
+
/** Called when the alert is dismissed (after it hides itself). */
|
|
69
|
+
onDismiss?: () => void;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Inline alert with a Lucide icon. Dark uses a neon accent — a bright outline +
|
|
74
|
+
* icon with a glow — over a translucent fill; light uses a soft accent wash on
|
|
75
|
+
* near-white. Pass `isDismissable` for an X button that closes it.
|
|
76
|
+
*/
|
|
77
|
+
export function Alert({ type = 'info', title, children, isDismissable = false, onDismiss }: AlertProps) {
|
|
78
|
+
const { color, Icon, darkAccent, lightAccent } = typeConfig[type];
|
|
79
|
+
const tokens = soloColorTokens[color];
|
|
80
|
+
const mode = useSoloMode();
|
|
81
|
+
const [dismissed, setDismissed] = useState(false);
|
|
82
|
+
|
|
83
|
+
// Light mode can swap the muted solid for a punchier hue; dark keeps its neon.
|
|
84
|
+
const lightBase = lightAccent ?? tokens.bg;
|
|
85
|
+
const accent = mode === 'dark' ? darkAccent : lightBase;
|
|
86
|
+
// Base color the fill + text tints are composed from.
|
|
87
|
+
const tintBase = mode === 'dark' ? tokens.bg : lightBase;
|
|
88
|
+
// Colored icon in light (keeps the hue, grounded for contrast); dark keeps the
|
|
89
|
+
// neon accent. Warning's bright yellow is deepened more so it stays legible.
|
|
90
|
+
const iconColor =
|
|
91
|
+
mode === 'dark'
|
|
92
|
+
? accent
|
|
93
|
+
: type === 'warning'
|
|
94
|
+
? `color-mix(in srgb, ${lightBase} 42%, #15151a)`
|
|
95
|
+
: `color-mix(in srgb, ${lightBase} 60%, #1c1c22)`;
|
|
96
|
+
// Light uses an accent-on-white border; dark keeps the neon edge. Warning's
|
|
97
|
+
// pale-yellow border needs full strength to stay visible.
|
|
98
|
+
const borderColor =
|
|
99
|
+
mode === 'dark'
|
|
100
|
+
? accent
|
|
101
|
+
: type === 'warning'
|
|
102
|
+
? lightBase
|
|
103
|
+
: `color-mix(in srgb, ${lightBase} 60%, #ffffff)`;
|
|
104
|
+
|
|
105
|
+
// Dark: a translucent accent-tinted fill so the page shows through. Light: a
|
|
106
|
+
// medium accent tint on near-white.
|
|
107
|
+
const opacity = mode === 'dark' ? 72 : 100;
|
|
108
|
+
const fillPct = mode === 'dark' ? 28 : 20;
|
|
109
|
+
const background = `color-mix(in srgb, color-mix(in srgb, ${tintBase} ${fillPct}%, var(--color-bg-elevated, #1e1e22)) ${opacity}%, transparent)`;
|
|
110
|
+
|
|
111
|
+
// Dark: a neon glow from the accent plus a small drop shadow. Light: a soft,
|
|
112
|
+
// slightly accent-tinted card shadow so the alert reads as an elevated surface
|
|
113
|
+
// instead of a flat tinted rectangle.
|
|
114
|
+
const shadow =
|
|
115
|
+
mode === 'dark'
|
|
116
|
+
? `0 0 16px color-mix(in srgb, ${accent} 34%, transparent), 0 2px 4px rgba(0, 0, 0, 0.32)`
|
|
117
|
+
: `0 1px 2px rgba(16, 24, 40, 0.05), 0 2px 5px rgba(16, 24, 40, 0.04)`;
|
|
118
|
+
|
|
119
|
+
// Text is tinted with the accent — light on dark, and a soft accent-tinted dark
|
|
120
|
+
// on light (tinted rather than near-black, to suit the airy wash).
|
|
121
|
+
const titleColor =
|
|
122
|
+
mode === 'dark'
|
|
123
|
+
? `color-mix(in srgb, ${tintBase} 22%, #ffffff)`
|
|
124
|
+
: `color-mix(in srgb, ${tintBase} 30%, #17171b)`;
|
|
125
|
+
const messageColor =
|
|
126
|
+
mode === 'dark'
|
|
127
|
+
? `color-mix(in srgb, ${tintBase} 20%, #c8ccd4)`
|
|
128
|
+
: `color-mix(in srgb, ${tintBase} 22%, #3c3c44)`;
|
|
129
|
+
|
|
130
|
+
if (dismissed) return null;
|
|
131
|
+
|
|
132
|
+
const handleDismiss = () => {
|
|
133
|
+
setDismissed(true);
|
|
134
|
+
onDismiss?.();
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
// info/success are polite (status); warning/danger interrupt (alert).
|
|
138
|
+
const role = type === 'warning' || type === 'danger' ? 'alert' : 'status';
|
|
139
|
+
|
|
140
|
+
return (
|
|
141
|
+
<Container role={role} background={background} frame={borderColor} shadow={shadow}>
|
|
142
|
+
{/* Icon is decorative — the title/message already carry the meaning. */}
|
|
143
|
+
<IconSlot accent={iconColor} aria-hidden='true'>
|
|
144
|
+
<Icon />
|
|
145
|
+
</IconSlot>
|
|
146
|
+
<Body>
|
|
147
|
+
{title ? (
|
|
148
|
+
<Text size='14px' weight={600} color={titleColor}>
|
|
149
|
+
{title}
|
|
150
|
+
</Text>
|
|
151
|
+
) : null}
|
|
152
|
+
{children ? (
|
|
153
|
+
<Text size='14px' color={messageColor}>
|
|
154
|
+
{children}
|
|
155
|
+
</Text>
|
|
156
|
+
) : null}
|
|
157
|
+
</Body>
|
|
158
|
+
{isDismissable ? (
|
|
159
|
+
<CloseButton
|
|
160
|
+
aria-label='Dismiss'
|
|
161
|
+
size={16}
|
|
162
|
+
onClick={handleDismiss}
|
|
163
|
+
style={{ color: messageColor, flexShrink: 0, marginTop: 1 }}
|
|
164
|
+
/>
|
|
165
|
+
) : null}
|
|
166
|
+
</Container>
|
|
167
|
+
);
|
|
168
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { Alert, type AlertProps, type AlertType } from './Alert';
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react';
|
|
2
|
+
import { Button, buttonVariants } from './Button';
|
|
3
|
+
import { Text } from '../Text';
|
|
4
|
+
|
|
5
|
+
const meta: Meta<typeof Button> = {
|
|
6
|
+
title: 'Common / Button',
|
|
7
|
+
component: Button,
|
|
8
|
+
args: {
|
|
9
|
+
children: 'Button',
|
|
10
|
+
color: 'dark-purple',
|
|
11
|
+
variant: 'solid',
|
|
12
|
+
size: 'md'
|
|
13
|
+
},
|
|
14
|
+
argTypes: {
|
|
15
|
+
color: { control: 'select', options: buttonVariants.colors },
|
|
16
|
+
variant: { control: 'select', options: buttonVariants.variants },
|
|
17
|
+
size: { control: 'select', options: buttonVariants.sizes },
|
|
18
|
+
disabled: { control: 'boolean' }
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
export default meta;
|
|
22
|
+
|
|
23
|
+
type Story = StoryObj<typeof Button>;
|
|
24
|
+
|
|
25
|
+
const Row = ({ children, label }: { children: React.ReactNode; label: string }) => (
|
|
26
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
|
27
|
+
<Text size='12px' color='var(--color-text-secondary, #a1a1aa)'>
|
|
28
|
+
{label}
|
|
29
|
+
</Text>
|
|
30
|
+
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 10 }}>{children}</div>
|
|
31
|
+
</div>
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
// `blue` is de-emphasized — rarely used, so keep it out of the showcase.
|
|
35
|
+
const showcaseColors = buttonVariants.colors.filter(c => c !== 'blue');
|
|
36
|
+
const showcaseSizes = ['md', 'sm'] as const;
|
|
37
|
+
|
|
38
|
+
export const AllColors: Story = {
|
|
39
|
+
render: () => (
|
|
40
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
|
41
|
+
{buttonVariants.variants.flatMap(variant =>
|
|
42
|
+
showcaseSizes.map(size => (
|
|
43
|
+
<Row key={`${variant}-${size}`} label={`${variant} variant (${size})`}>
|
|
44
|
+
{showcaseColors.map(c => (
|
|
45
|
+
<Button key={c} color={c} variant={variant} size={size}>
|
|
46
|
+
{variant} {c} {size}
|
|
47
|
+
</Button>
|
|
48
|
+
))}
|
|
49
|
+
</Row>
|
|
50
|
+
))
|
|
51
|
+
)}
|
|
52
|
+
<Row label='Disabled'>
|
|
53
|
+
<Button disabled color='dark-purple'>
|
|
54
|
+
Next
|
|
55
|
+
</Button>
|
|
56
|
+
<Button disabled color='gray'>
|
|
57
|
+
Back
|
|
58
|
+
</Button>
|
|
59
|
+
</Row>
|
|
60
|
+
</div>
|
|
61
|
+
)
|
|
62
|
+
};
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { css } from "@emotion/react";
|
|
2
|
+
import styled from "@emotion/styled";
|
|
3
|
+
import { resolveBareFg, soloColorTokens } from "../../_internal/palette";
|
|
4
|
+
import { spacingPx } from "../../_internal/sizing";
|
|
5
|
+
import { UnstyledButton } from "../../_internal/unstyledButton";
|
|
6
|
+
import { dontForwardProps, type ElementOf } from "../../_internal/utils";
|
|
7
|
+
import type { SoloMode } from "../../providers/SoloModeContext";
|
|
8
|
+
import { buttonVariants, type ButtonProps } from "./Button";
|
|
9
|
+
|
|
10
|
+
type SizeType = ElementOf<(typeof buttonVariants)["sizes"]>;
|
|
11
|
+
|
|
12
|
+
const sizeMap: Record<
|
|
13
|
+
SizeType,
|
|
14
|
+
{ py: string; px: string; gap: string; height: string; fontSize: string }
|
|
15
|
+
> = {
|
|
16
|
+
sm: {
|
|
17
|
+
py: "5px",
|
|
18
|
+
px: "10px",
|
|
19
|
+
gap: spacingPx.xs,
|
|
20
|
+
height: "30px",
|
|
21
|
+
fontSize: "13px",
|
|
22
|
+
},
|
|
23
|
+
md: {
|
|
24
|
+
py: "8px",
|
|
25
|
+
px: "14px",
|
|
26
|
+
gap: spacingPx.sm,
|
|
27
|
+
height: "36px",
|
|
28
|
+
fontSize: "14px",
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export const StyledButton = styled(
|
|
33
|
+
UnstyledButton,
|
|
34
|
+
dontForwardProps(
|
|
35
|
+
"uiTestId",
|
|
36
|
+
"styleOverrides",
|
|
37
|
+
"isSquareIconButton",
|
|
38
|
+
"isCircleIconButton",
|
|
39
|
+
"leftIcon",
|
|
40
|
+
"rightIcon",
|
|
41
|
+
"minWidth",
|
|
42
|
+
),
|
|
43
|
+
)<ButtonProps>((props) => {
|
|
44
|
+
const {
|
|
45
|
+
variant = "solid",
|
|
46
|
+
size,
|
|
47
|
+
color = "dark-purple",
|
|
48
|
+
isSquareIconButton,
|
|
49
|
+
isCircleIconButton,
|
|
50
|
+
} = props;
|
|
51
|
+
const sizeData = sizeMap[size ?? "md"];
|
|
52
|
+
const c = soloColorTokens[color];
|
|
53
|
+
// The active light/dark mode is published on the emotion theme by SoloContextProvider.
|
|
54
|
+
const mode = (props.theme as { mode?: SoloMode }).mode ?? "dark";
|
|
55
|
+
const bareFg = resolveBareFg(c.bareFg, mode);
|
|
56
|
+
const isIcon = isSquareIconButton || isCircleIconButton;
|
|
57
|
+
|
|
58
|
+
return css`
|
|
59
|
+
box-sizing: border-box;
|
|
60
|
+
display: inline-flex;
|
|
61
|
+
align-items: center;
|
|
62
|
+
justify-content: center;
|
|
63
|
+
gap: ${sizeData.gap};
|
|
64
|
+
height: ${sizeData.height};
|
|
65
|
+
border: 1px solid transparent;
|
|
66
|
+
border-radius: ${isCircleIconButton ? "999px" : "4px"};
|
|
67
|
+
font: inherit;
|
|
68
|
+
font-weight: 400;
|
|
69
|
+
font-size: ${sizeData.fontSize};
|
|
70
|
+
line-height: 1;
|
|
71
|
+
white-space: nowrap;
|
|
72
|
+
cursor: pointer;
|
|
73
|
+
user-select: none;
|
|
74
|
+
transition:
|
|
75
|
+
background-color 0.08s ease,
|
|
76
|
+
border-color 0.08s ease,
|
|
77
|
+
transform 0.08s ease;
|
|
78
|
+
|
|
79
|
+
${isIcon
|
|
80
|
+
? css`
|
|
81
|
+
width: ${sizeData.height};
|
|
82
|
+
padding: 0;
|
|
83
|
+
`
|
|
84
|
+
: css`
|
|
85
|
+
min-width: ${props.minWidth ?? "96px"};
|
|
86
|
+
padding: ${sizeData.py} ${sizeData.px};
|
|
87
|
+
`}
|
|
88
|
+
|
|
89
|
+
${variant === "bare"
|
|
90
|
+
? css`
|
|
91
|
+
/* Outlined "secondary" button: dim fill with the accent as both outline
|
|
92
|
+
and text, so the color/variant reads clearly (legible in both modes). */
|
|
93
|
+
background-color: var(--color-bg-elevated, #1b1624);
|
|
94
|
+
color: ${bareFg};
|
|
95
|
+
border-color: ${bareFg};
|
|
96
|
+
/* Hover/active tint the fill toward the button's own accent — active is a
|
|
97
|
+
clearly deeper step than hover, plus a press shift. */
|
|
98
|
+
&:not([aria-disabled="true"]):hover {
|
|
99
|
+
background-color: color-mix(
|
|
100
|
+
in srgb,
|
|
101
|
+
${c.bg} ${mode === "dark" ? "12" : "6"}%,
|
|
102
|
+
var(--color-bg-elevated, #1b1624)
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
&:not([aria-disabled="true"]):active {
|
|
106
|
+
background-color: color-mix(
|
|
107
|
+
in srgb,
|
|
108
|
+
${c.bg} ${mode === "dark" ? "24" : "15"}%,
|
|
109
|
+
var(--color-bg-elevated, #1b1624)
|
|
110
|
+
);
|
|
111
|
+
transform: translateY(1px);
|
|
112
|
+
}
|
|
113
|
+
`
|
|
114
|
+
: css`
|
|
115
|
+
background-color: ${c.bg};
|
|
116
|
+
color: ${c.fg};
|
|
117
|
+
border-color: ${c.border ?? "transparent"};
|
|
118
|
+
/* Hover/active darken the fill (active darker than hover) in both modes. */
|
|
119
|
+
&:not([aria-disabled="true"]):hover {
|
|
120
|
+
background-color: color-mix(in srgb, ${c.bg} 88%, #000);
|
|
121
|
+
}
|
|
122
|
+
&:not([aria-disabled="true"]):active {
|
|
123
|
+
background-color: color-mix(in srgb, ${c.bg} 76%, #000);
|
|
124
|
+
transform: translateY(1px);
|
|
125
|
+
}
|
|
126
|
+
`}
|
|
127
|
+
|
|
128
|
+
&:focus-visible {
|
|
129
|
+
outline: 2px solid ${c.border ?? c.bg};
|
|
130
|
+
outline-offset: 2px;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
&[aria-disabled="true"] {
|
|
134
|
+
opacity: 0.5;
|
|
135
|
+
cursor: not-allowed;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/* The only motion is the 1px press shift; color feedback stays. */
|
|
139
|
+
@media (prefers-reduced-motion: reduce) {
|
|
140
|
+
transition: none;
|
|
141
|
+
&:active {
|
|
142
|
+
transform: none;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
${props.styleOverrides ? props.styleOverrides : ""}
|
|
147
|
+
`;
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
// Alias so the namespace below can reference the original without shadowing.
|
|
151
|
+
const _StyledButton = StyledButton;
|
|
152
|
+
|
|
153
|
+
// Namespace form for consumers that prefer the grouped `SoloButtonStyles.X` API
|
|
154
|
+
// (e.g. when targeting these styled components as CSS selectors).
|
|
155
|
+
// eslint-disable-next-line @typescript-eslint/no-namespace
|
|
156
|
+
export namespace SoloButtonStyles {
|
|
157
|
+
export const StyledButton = _StyledButton;
|
|
158
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { render, screen } from '@testing-library/react';
|
|
3
|
+
import userEvent from '@testing-library/user-event';
|
|
4
|
+
import { Button } from './Button';
|
|
5
|
+
|
|
6
|
+
describe('Button', () => {
|
|
7
|
+
it('renders its children', () => {
|
|
8
|
+
render(<Button>Click me</Button>);
|
|
9
|
+
expect(screen.getByRole('button', { name: 'Click me' })).toBeInTheDocument();
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it('fires onClick when clicked', async () => {
|
|
13
|
+
const user = userEvent.setup();
|
|
14
|
+
const onClick = vi.fn();
|
|
15
|
+
render(<Button onClick={onClick}>Go</Button>);
|
|
16
|
+
await user.click(screen.getByRole('button', { name: 'Go' }));
|
|
17
|
+
expect(onClick).toHaveBeenCalledTimes(1);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('does not fire onClick when disabled', async () => {
|
|
21
|
+
const user = userEvent.setup();
|
|
22
|
+
const onClick = vi.fn();
|
|
23
|
+
render(
|
|
24
|
+
<Button disabled onClick={onClick}>
|
|
25
|
+
Nope
|
|
26
|
+
</Button>
|
|
27
|
+
);
|
|
28
|
+
// The styled button uses pointer-events: none when disabled; userEvent honors it.
|
|
29
|
+
await user.click(screen.getByRole('button', { name: 'Nope' })).catch(() => {});
|
|
30
|
+
expect(onClick).not.toHaveBeenCalled();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('renders left and right icons', () => {
|
|
34
|
+
render(
|
|
35
|
+
<Button leftIcon={<span data-testid='left' />} rightIcon={<span data-testid='right' />}>
|
|
36
|
+
Label
|
|
37
|
+
</Button>
|
|
38
|
+
);
|
|
39
|
+
expect(screen.getByTestId('left')).toBeInTheDocument();
|
|
40
|
+
expect(screen.getByTestId('right')).toBeInTheDocument();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('exposes uiTestId as data-testid', () => {
|
|
44
|
+
render(<Button uiTestId='my-button'>Tagged</Button>);
|
|
45
|
+
expect(screen.getByTestId('my-button')).toBeInTheDocument();
|
|
46
|
+
});
|
|
47
|
+
});
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { css, type SerializedStyles } from '@emotion/react';
|
|
2
|
+
import type { CSSProperties, PropsWithChildren, ReactNode, ButtonHTMLAttributes } from 'react';
|
|
3
|
+
import { Svg, type SvgAsset } from '../../_internal/Svg';
|
|
4
|
+
import type { ElementOf } from '../../_internal/utils';
|
|
5
|
+
import { StyledButton } from './Button.style';
|
|
6
|
+
|
|
7
|
+
/** Helper to render an SvgAsset at the standard 16px size with `currentColor`. */
|
|
8
|
+
export const buttonSvg = (asset: SvgAsset) => <Svg asset={asset} color='currentColor' size={16} />;
|
|
9
|
+
|
|
10
|
+
export const buttonVariants = {
|
|
11
|
+
/** `variant='bare'` is an outlined secondary button (dim fill + accent outline & text). */
|
|
12
|
+
variants: ['solid', 'bare'],
|
|
13
|
+
colors: ['dark-purple', 'red', 'warning', 'success', 'gray', 'black', 'blue'],
|
|
14
|
+
sizes: ['sm', 'md']
|
|
15
|
+
} as const;
|
|
16
|
+
|
|
17
|
+
export type ButtonPropsBase = {
|
|
18
|
+
variant?: ElementOf<(typeof buttonVariants)['variants']>;
|
|
19
|
+
color?: ElementOf<(typeof buttonVariants)['colors']>;
|
|
20
|
+
size?: ElementOf<(typeof buttonVariants)['sizes']>;
|
|
21
|
+
|
|
22
|
+
disabled?: boolean;
|
|
23
|
+
isSquareIconButton?: boolean;
|
|
24
|
+
isCircleIconButton?: boolean;
|
|
25
|
+
minWidth?: CSSProperties['minWidth'];
|
|
26
|
+
|
|
27
|
+
leftIcon?: ReactNode;
|
|
28
|
+
rightIcon?: ReactNode;
|
|
29
|
+
uiTestId?: string;
|
|
30
|
+
styleOverrides?: SerializedStyles;
|
|
31
|
+
} & PropsWithChildren<unknown>;
|
|
32
|
+
|
|
33
|
+
export type ButtonProps = ButtonPropsBase & ButtonHTMLAttributes<HTMLButtonElement>;
|
|
34
|
+
|
|
35
|
+
export const Button = ({ children, leftIcon, rightIcon, uiTestId, disabled, ...props }: ButtonProps) => {
|
|
36
|
+
return (
|
|
37
|
+
<StyledButton
|
|
38
|
+
type='button'
|
|
39
|
+
// `aria-disabled` (not the native attribute) keeps the button focusable and
|
|
40
|
+
// lets `cursor: not-allowed` show — browsers suppress custom cursors on a
|
|
41
|
+
// natively-disabled control. Activation is guarded in the onClick below.
|
|
42
|
+
aria-disabled={disabled || undefined}
|
|
43
|
+
{...props}
|
|
44
|
+
{...(uiTestId ? { 'data-testid': uiTestId } : {})}
|
|
45
|
+
onClick={
|
|
46
|
+
props.onClick && !disabled
|
|
47
|
+
? e => {
|
|
48
|
+
// Stops propagation so the button is safe inside clickable rows/cards.
|
|
49
|
+
e.stopPropagation();
|
|
50
|
+
e.preventDefault();
|
|
51
|
+
props.onClick?.(e);
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
: undefined
|
|
55
|
+
}>
|
|
56
|
+
{leftIcon}
|
|
57
|
+
{children}
|
|
58
|
+
{rightIcon}
|
|
59
|
+
</StyledButton>
|
|
60
|
+
);
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export type IconButtonProps = Omit<ButtonProps, 'children' | 'leftIcon' | 'rightIcon' | 'size'> & {
|
|
64
|
+
icon: SvgAsset;
|
|
65
|
+
size?: number | ButtonPropsBase['size'];
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
export const IconButton = ({ icon, size: sizeIn = 'md', ...props }: IconButtonProps) => {
|
|
69
|
+
const size = typeof sizeIn === 'number' ? sizeIn : sizeIn === 'sm' ? 32 : 36;
|
|
70
|
+
return (
|
|
71
|
+
<Button
|
|
72
|
+
{...props}
|
|
73
|
+
size='sm'
|
|
74
|
+
minWidth='auto'
|
|
75
|
+
styleOverrides={css`
|
|
76
|
+
padding: ${size / 4}px;
|
|
77
|
+
width: ${size + 1}px;
|
|
78
|
+
height: ${size + 1}px;
|
|
79
|
+
${props.styleOverrides}
|
|
80
|
+
`}>
|
|
81
|
+
<Svg asset={icon} size={size / 2} color='currentColor' />
|
|
82
|
+
</Button>
|
|
83
|
+
);
|
|
84
|
+
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export {
|
|
2
|
+
Button,
|
|
3
|
+
IconButton,
|
|
4
|
+
buttonSvg,
|
|
5
|
+
buttonVariants,
|
|
6
|
+
type ButtonProps,
|
|
7
|
+
type ButtonPropsBase,
|
|
8
|
+
type IconButtonProps
|
|
9
|
+
} from './Button';
|
|
10
|
+
// Note: `SoloButtonStyles` (and the underlying `StyledButton`) are exposed via
|
|
11
|
+
// the `@solo-io-public/ui-components/styles` subpath, not the main entry.
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { SerializedStyles } from '@emotion/react';
|
|
2
|
+
import styled from '@emotion/styled';
|
|
3
|
+
import { X } from 'lucide-react';
|
|
4
|
+
import type { ButtonHTMLAttributes } from 'react';
|
|
5
|
+
import { dontForwardProps } from '../../_internal/utils';
|
|
6
|
+
|
|
7
|
+
// region Styles
|
|
8
|
+
|
|
9
|
+
const StyledCloseButton = styled('button', dontForwardProps('styleOverrides'))<{ styleOverrides?: SerializedStyles }>`
|
|
10
|
+
display: inline-flex;
|
|
11
|
+
align-items: center;
|
|
12
|
+
justify-content: center;
|
|
13
|
+
padding: 6px;
|
|
14
|
+
background: transparent;
|
|
15
|
+
border: none;
|
|
16
|
+
border-radius: 4px;
|
|
17
|
+
cursor: pointer;
|
|
18
|
+
color: inherit;
|
|
19
|
+
opacity: 0.8;
|
|
20
|
+
transition:
|
|
21
|
+
background-color 0.08s ease,
|
|
22
|
+
opacity 0.08s ease;
|
|
23
|
+
|
|
24
|
+
&:hover {
|
|
25
|
+
opacity: 1;
|
|
26
|
+
/* Subtle wash derived from the button's own color, so it works on any background. */
|
|
27
|
+
background-color: color-mix(in srgb, currentColor 14%, transparent);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
&:active {
|
|
31
|
+
background-color: color-mix(in srgb, currentColor 24%, transparent);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
&:focus-visible {
|
|
35
|
+
outline: 2px solid var(--color-primary, #6844ff);
|
|
36
|
+
outline-offset: 2px;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
@media (prefers-reduced-motion: reduce) {
|
|
40
|
+
transition: none;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
svg {
|
|
44
|
+
display: block;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
${props => props.styleOverrides}
|
|
48
|
+
`;
|
|
49
|
+
|
|
50
|
+
// region Component
|
|
51
|
+
|
|
52
|
+
export interface CloseButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
|
53
|
+
/** X icon size in px. */
|
|
54
|
+
size?: number;
|
|
55
|
+
/** Emotion styles appended to the button. */
|
|
56
|
+
styleOverrides?: SerializedStyles;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* A minimal "×" icon button (Lucide `X`). Inherits its color from context
|
|
61
|
+
* (override via `style`/`styleOverrides`); hover/active washes derive from that
|
|
62
|
+
* color, so it sits cleanly on any surface — alerts, drawers, modals, etc.
|
|
63
|
+
*/
|
|
64
|
+
export function CloseButton({
|
|
65
|
+
size = 18,
|
|
66
|
+
styleOverrides,
|
|
67
|
+
type = 'button',
|
|
68
|
+
'aria-label': ariaLabel = 'Close',
|
|
69
|
+
...props
|
|
70
|
+
}: CloseButtonProps) {
|
|
71
|
+
return (
|
|
72
|
+
<StyledCloseButton type={type} aria-label={ariaLabel} styleOverrides={styleOverrides} {...props}>
|
|
73
|
+
<X size={size} />
|
|
74
|
+
</StyledCloseButton>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { CloseButton, type CloseButtonProps } from './CloseButton';
|