Skip to content

Commit 9a2cedc

Browse files
author
root
committed
feat: add web button primitive
1 parent 2609805 commit 9a2cedc

7 files changed

Lines changed: 406 additions & 0 deletions

File tree

packages/web/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
"@xterm/addon-fit": "^0.11.0",
1717
"@xterm/addon-webgl": "^0.19.0",
1818
"@xterm/xterm": "^6.0.0",
19+
"clsx": "^2.1.1",
1920
"jotai": "^2.19.1",
2021
"jotai-family": "^1.0.1",
2122
"lucide-react": "^1.14.0",
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# Button
2+
3+
## 使用
4+
`src/components/ui/index.ts` 的 public barrel 导入后使用:
5+
6+
```tsx
7+
<Button variant="primary" size="md">保存</Button>
8+
```
9+
10+
## Props
11+
| Prop | Type | Default | 说明 |
12+
|---|---|---|---|
13+
| `variant` | `"primary" \| "secondary" \| "ghost" \| "danger"` | `"secondary"` | 视觉变体 |
14+
| `size` | `"sm" \| "md" \| "lg"` | `"md"` | 尺寸 |
15+
| `loading` | `boolean` | `false` | 显示 spinner,并禁用 button 点击 |
16+
| `leadingIcon` | `ReactNode` | `undefined` | 文本前图标 |
17+
| `trailingIcon` | `ReactNode` | `undefined` | 文本后图标 |
18+
| `as` | `"button" \| "a"` | `"button"` | 渲染元素 |
19+
20+
## 注意
21+
- `danger` 只用于破坏性操作。
22+
- `loading` 只会对原生 `<button>` 强制 `disabled`;对于 `<a>`,只会加 `aria-busy`
23+
- 新代码不要再写 `btn btn-*`
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
.btn,
2+
:global(.btn) {
3+
display: inline-flex;
4+
align-items: center;
5+
justify-content: center;
6+
gap: var(--sp-2);
7+
height: var(--btn-height-md);
8+
min-height: var(--touch-target-min);
9+
padding: 0 var(--sp-4);
10+
border: 1px solid transparent;
11+
border-radius: var(--radius-md);
12+
font-family: var(--font-sans);
13+
font-size: var(--text-base);
14+
font-weight: var(--font-medium);
15+
line-height: 1;
16+
white-space: nowrap;
17+
text-decoration: none;
18+
transition:
19+
background var(--duration-normal) var(--ease-out),
20+
border-color var(--duration-normal) var(--ease-out),
21+
color var(--duration-normal) var(--ease-out),
22+
box-shadow var(--duration-normal) var(--ease-out),
23+
transform var(--duration-fast) var(--ease-out);
24+
cursor: pointer;
25+
}
26+
27+
.btn:focus-visible,
28+
:global(.btn):focus-visible {
29+
box-shadow:
30+
0 0 0 calc(var(--sp-1) / 2) var(--bg-page),
31+
0 0 0 var(--sp-1) var(--border-focus);
32+
}
33+
34+
.btn:hover:not(:disabled):not([aria-disabled="true"]),
35+
:global(.btn):hover:not(:disabled):not([aria-disabled="true"]) {
36+
transform: translateY(calc(var(--sp-1) / -4));
37+
}
38+
39+
.btn:disabled,
40+
.btn[aria-disabled="true"],
41+
:global(.btn):disabled,
42+
:global(.btn[aria-disabled="true"]) {
43+
opacity: 0.5;
44+
cursor: not-allowed;
45+
transform: none;
46+
}
47+
48+
.primary,
49+
:global(.btn-primary) {
50+
background: var(--accent-blue);
51+
color: var(--text-inverse);
52+
}
53+
54+
.primary:hover:not(:disabled):not([aria-disabled="true"]),
55+
:global(.btn-primary):hover:not(:disabled):not([aria-disabled="true"]) {
56+
background: color-mix(in srgb, var(--accent-blue) 84%, var(--bg-surface) 16%);
57+
}
58+
59+
.secondary,
60+
:global(.btn-default),
61+
:global(.btn-secondary) {
62+
background: color-mix(in srgb, var(--bg-surface) 84%, var(--accent-blue) 16%);
63+
border-color: color-mix(in srgb, var(--border) 70%, var(--accent-blue) 30%);
64+
color: var(--text-primary);
65+
}
66+
67+
.secondary:hover:not(:disabled):not([aria-disabled="true"]),
68+
:global(.btn-default):hover:not(:disabled):not([aria-disabled="true"]),
69+
:global(.btn-secondary):hover:not(:disabled):not([aria-disabled="true"]) {
70+
background: color-mix(in srgb, var(--bg-hover) 72%, var(--accent-blue) 28%);
71+
border-color: color-mix(in srgb, var(--border-light) 70%, var(--accent-blue) 30%);
72+
}
73+
74+
.ghost,
75+
:global(.btn-ghost) {
76+
background: transparent;
77+
border-color: transparent;
78+
color: var(--text-secondary);
79+
}
80+
81+
.ghost:hover:not(:disabled):not([aria-disabled="true"]),
82+
:global(.btn-ghost):hover:not(:disabled):not([aria-disabled="true"]) {
83+
background: var(--bg-hover);
84+
color: var(--text-primary);
85+
}
86+
87+
.danger,
88+
:global(.btn-danger) {
89+
background: var(--accent-pink);
90+
color: var(--text-inverse);
91+
}
92+
93+
.sm,
94+
:global(.btn-sm) {
95+
height: var(--btn-height-sm);
96+
min-height: var(--touch-target-min);
97+
padding: 0 var(--sp-2);
98+
border-radius: var(--radius-sm);
99+
font-size: var(--text-xs);
100+
}
101+
102+
.lg,
103+
:global(.btn-lg) {
104+
height: var(--btn-height-lg);
105+
min-height: var(--touch-target-min);
106+
padding: 0 var(--sp-6);
107+
border-radius: var(--radius-lg);
108+
font-size: var(--text-lg);
109+
}
110+
111+
.label {
112+
display: inline-flex;
113+
align-items: center;
114+
}
115+
116+
.icon {
117+
display: inline-flex;
118+
align-items: center;
119+
}
120+
121+
.loading {
122+
pointer-events: none;
123+
}
124+
125+
.spinner {
126+
width: var(--sp-3);
127+
height: var(--sp-3);
128+
border: calc(var(--sp-1) / 2) solid color-mix(in srgb, currentColor 30%, var(--bg-page) 70%);
129+
border-top-color: currentColor;
130+
border-radius: var(--radius-full);
131+
}
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import { render, screen } from "@testing-library/react";
2+
import userEvent from "@testing-library/user-event";
3+
import { describe, expect, it, vi } from "vitest";
4+
import { Button } from ".";
5+
6+
describe("Button", () => {
7+
it("renders a secondary button by default", () => {
8+
render(<Button>Save</Button>);
9+
10+
const button = screen.getByRole("button", { name: "Save" });
11+
expect(button).toHaveAttribute("type", "button");
12+
expect(button).toBeEnabled();
13+
});
14+
15+
it("supports the four visual variants", () => {
16+
render(
17+
<>
18+
<Button variant="primary">Primary</Button>
19+
<Button variant="secondary">Secondary</Button>
20+
<Button variant="ghost">Ghost</Button>
21+
<Button variant="danger">Danger</Button>
22+
</>
23+
);
24+
25+
expect(screen.getByRole("button", { name: "Primary" })).toBeInTheDocument();
26+
expect(screen.getByRole("button", { name: "Secondary" })).toBeInTheDocument();
27+
expect(screen.getByRole("button", { name: "Ghost" })).toBeInTheDocument();
28+
expect(screen.getByRole("button", { name: "Danger" })).toBeInTheDocument();
29+
});
30+
31+
it("supports the three size options", () => {
32+
render(
33+
<>
34+
<Button size="sm">Small</Button>
35+
<Button size="md">Medium</Button>
36+
<Button size="lg">Large</Button>
37+
</>
38+
);
39+
40+
expect(screen.getByRole("button", { name: "Small" })).toBeInTheDocument();
41+
expect(screen.getByRole("button", { name: "Medium" })).toBeInTheDocument();
42+
expect(screen.getByRole("button", { name: "Large" })).toBeInTheDocument();
43+
});
44+
45+
it("disables click handlers while loading", async () => {
46+
const user = userEvent.setup();
47+
const onClick = vi.fn();
48+
49+
render(
50+
<Button loading onClick={onClick}>
51+
Create
52+
</Button>
53+
);
54+
55+
const button = screen.getByRole("button", { name: "Create" });
56+
expect(button).toBeDisabled();
57+
expect(button).toHaveAttribute("aria-busy", "true");
58+
59+
await user.click(button);
60+
expect(onClick).not.toHaveBeenCalled();
61+
});
62+
63+
it("calls onClick when enabled", async () => {
64+
const user = userEvent.setup();
65+
const onClick = vi.fn();
66+
67+
render(<Button onClick={onClick}>Run</Button>);
68+
69+
await user.click(screen.getByRole("button", { name: "Run" }));
70+
expect(onClick).toHaveBeenCalledTimes(1);
71+
});
72+
73+
it("renders leading and trailing icons", () => {
74+
render(
75+
<Button
76+
leadingIcon={<span data-testid="leading-icon">L</span>}
77+
trailingIcon={<span data-testid="trailing-icon">R</span>}
78+
>
79+
Open
80+
</Button>
81+
);
82+
83+
expect(screen.getByTestId("leading-icon")).toBeInTheDocument();
84+
expect(screen.getByTestId("trailing-icon")).toBeInTheDocument();
85+
expect(screen.getByRole("button", { name: "Open" })).toBeInTheDocument();
86+
});
87+
88+
it("renders as an anchor when as='a'", () => {
89+
render(
90+
<Button as="a" href="/settings">
91+
Settings
92+
</Button>
93+
);
94+
95+
expect(screen.getByRole("link", { name: "Settings" })).toHaveAttribute("href", "/settings");
96+
});
97+
});

0 commit comments

Comments
 (0)