@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.
Files changed (77) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +167 -0
  3. package/dist/Button.style-CCplEN2o.js +474 -0
  4. package/dist/Button.style-CCplEN2o.js.map +1 -0
  5. package/dist/Button.style-CI6Xbod7.cjs +10 -0
  6. package/dist/Button.style-CI6Xbod7.cjs.map +1 -0
  7. package/dist/_internal/Svg.d.ts +15 -0
  8. package/dist/_internal/colors.d.ts +17 -0
  9. package/dist/_internal/gradients.d.ts +11 -0
  10. package/dist/_internal/hexAddAlpha.d.ts +3 -0
  11. package/dist/_internal/palette.d.ts +30 -0
  12. package/dist/_internal/sizing.d.ts +14 -0
  13. package/dist/_internal/unstyledButton.d.ts +8 -0
  14. package/dist/_internal/utils.d.ts +7 -0
  15. package/dist/components/Alert/Alert.d.ts +17 -0
  16. package/dist/components/Alert/index.d.ts +1 -0
  17. package/dist/components/Button/Button.d.ts +32 -0
  18. package/dist/components/Button/Button.style.d.ts +48 -0
  19. package/dist/components/Button/index.d.ts +1 -0
  20. package/dist/components/CloseButton/CloseButton.d.ts +14 -0
  21. package/dist/components/CloseButton/index.d.ts +1 -0
  22. package/dist/components/Layout/FlexLayout.d.ts +83 -0
  23. package/dist/components/Layout/Spacer.d.ts +80 -0
  24. package/dist/components/Layout/index.d.ts +2 -0
  25. package/dist/components/MonacoEditor/EditorSettingsContext.d.ts +15 -0
  26. package/dist/components/MonacoEditor/MonacoEditorWithSettings.d.ts +20 -0
  27. package/dist/components/MonacoEditor/index.d.ts +2 -0
  28. package/dist/components/Text/Text.d.ts +71 -0
  29. package/dist/components/Text/index.d.ts +1 -0
  30. package/dist/index.d.ts +7 -0
  31. package/dist/providers/SoloContextProvider.d.ts +62 -0
  32. package/dist/providers/SoloModeContext.d.ts +5 -0
  33. package/dist/providers/index.d.ts +2 -0
  34. package/dist/solo-components.cjs +56 -0
  35. package/dist/solo-components.cjs.map +1 -0
  36. package/dist/solo-components.js +664 -0
  37. package/dist/solo-components.js.map +1 -0
  38. package/dist/styles.cjs +2 -0
  39. package/dist/styles.cjs.map +1 -0
  40. package/dist/styles.d.ts +1 -0
  41. package/dist/styles.js +5 -0
  42. package/dist/styles.js.map +1 -0
  43. package/package.json +109 -0
  44. package/src/_internal/Svg.tsx +58 -0
  45. package/src/_internal/colors.ts +26 -0
  46. package/src/_internal/gradients.ts +36 -0
  47. package/src/_internal/hexAddAlpha.ts +9 -0
  48. package/src/_internal/palette.ts +46 -0
  49. package/src/_internal/sizing.ts +15 -0
  50. package/src/_internal/unstyledButton.ts +17 -0
  51. package/src/_internal/utils.ts +11 -0
  52. package/src/components/Alert/Alert.stories.tsx +44 -0
  53. package/src/components/Alert/Alert.tsx +168 -0
  54. package/src/components/Alert/index.ts +1 -0
  55. package/src/components/Button/Button.stories.tsx +62 -0
  56. package/src/components/Button/Button.style.ts +158 -0
  57. package/src/components/Button/Button.test.tsx +47 -0
  58. package/src/components/Button/Button.tsx +84 -0
  59. package/src/components/Button/index.ts +11 -0
  60. package/src/components/CloseButton/CloseButton.tsx +76 -0
  61. package/src/components/CloseButton/index.ts +1 -0
  62. package/src/components/Layout/FlexLayout.tsx +101 -0
  63. package/src/components/Layout/Spacer.tsx +131 -0
  64. package/src/components/Layout/index.ts +2 -0
  65. package/src/components/MonacoEditor/EditorSettingsContext.test.tsx +55 -0
  66. package/src/components/MonacoEditor/EditorSettingsContext.tsx +63 -0
  67. package/src/components/MonacoEditor/MonacoEditor.stories.tsx +197 -0
  68. package/src/components/MonacoEditor/MonacoEditorWithSettings.tsx +376 -0
  69. package/src/components/MonacoEditor/index.ts +9 -0
  70. package/src/components/Text/Text.stories.tsx +63 -0
  71. package/src/components/Text/Text.tsx +65 -0
  72. package/src/components/Text/index.ts +1 -0
  73. package/src/index.ts +7 -0
  74. package/src/providers/SoloContextProvider.tsx +213 -0
  75. package/src/providers/SoloModeContext.tsx +13 -0
  76. package/src/providers/index.ts +10 -0
  77. 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';