Skip to content

Commit 204e982

Browse files
feat(shared): 玻璃态 Select 组件——PR #222 修复版 (#223)
feat(shared): 玻璃态 Select 组件——PR #222 修复版
2 parents 68c5e4a + d0c67bb commit 204e982

4 files changed

Lines changed: 421 additions & 0 deletions

File tree

Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
.container {
2+
position: relative;
3+
display: inline-flex;
4+
flex-shrink: 0;
5+
}
6+
7+
/* ── Trigger ──────────────────────────────── */
8+
.trigger {
9+
display: inline-flex;
10+
align-items: center;
11+
justify-content: space-between;
12+
gap: 8px;
13+
min-width: 158px;
14+
width: 100%;
15+
height: 34px;
16+
padding: 0 10px 0 12px;
17+
border: 1px solid var(--border);
18+
border-radius: var(--radius-md, 6px);
19+
background: var(--card, oklch(1 0 0 / 0.48));
20+
color: var(--foreground);
21+
font: inherit;
22+
font-size: 13px;
23+
font-weight: var(--font-weight-semibold, 600);
24+
cursor: pointer;
25+
white-space: nowrap;
26+
transition: border-color 0.15s, background 0.15s, box-shadow 0.15s;
27+
-webkit-font-smoothing: antialiased;
28+
}
29+
30+
.trigger:hover {
31+
background: var(--card-hover, oklch(1 0 0 / 0.62));
32+
border-color: var(--border-hover, oklch(0 0 0 / 0.13));
33+
}
34+
35+
.trigger:focus-visible {
36+
outline: none;
37+
border-color: var(--ring, oklch(0.35 0.02 260 / 0.5));
38+
box-shadow: 0 0 0 3px var(--ring, oklch(0.35 0.02 260 / 0.15));
39+
}
40+
41+
.trigger:disabled {
42+
opacity: 0.4;
43+
cursor: not-allowed;
44+
}
45+
46+
.label {
47+
overflow: hidden;
48+
text-overflow: ellipsis;
49+
}
50+
51+
.placeholder {
52+
color: var(--muted-foreground);
53+
font-weight: 450;
54+
overflow: hidden;
55+
text-overflow: ellipsis;
56+
}
57+
58+
.chevron {
59+
opacity: 0.4;
60+
flex-shrink: 0;
61+
transition: transform 0.2s ease;
62+
}
63+
64+
.chevronOpen {
65+
transform: rotate(180deg);
66+
}
67+
68+
/* ── Dropdown panel ────────────────────────── */
69+
.dropdown {
70+
position: fixed;
71+
z-index: var(--z-overlay, 30);
72+
min-width: 158px;
73+
max-height: 260px;
74+
overflow-y: auto;
75+
background: var(--popover, oklch(0.96 0.01 260 / 0.92));
76+
backdrop-filter: blur(24px) saturate(1.08);
77+
-webkit-backdrop-filter: blur(24px) saturate(1.08);
78+
border: 1px solid var(--border);
79+
border-radius: var(--radius-xl, 12px);
80+
box-shadow: 0 18px 44px -12px oklch(0 0 0 / 0.2), 0 4px 14px -8px oklch(0 0 0 / 0.14);
81+
padding: 6px;
82+
animation: dropdownIn 0.12s ease-out;
83+
font-family: var(--font-sans);
84+
-webkit-font-smoothing: antialiased;
85+
-moz-osx-font-smoothing: grayscale;
86+
scrollbar-width: thin;
87+
scrollbar-color: transparent transparent;
88+
}
89+
90+
.dropdown::-webkit-scrollbar {
91+
width: 4px;
92+
}
93+
94+
.dropdown::-webkit-scrollbar-thumb {
95+
background: var(--border);
96+
border-radius: 2px;
97+
}
98+
99+
.dropdownUp {
100+
animation: dropdownInUp 0.12s ease-out;
101+
}
102+
103+
@keyframes dropdownIn {
104+
from {
105+
opacity: 0;
106+
transform: translateY(-4px) scale(0.98);
107+
}
108+
to {
109+
opacity: 1;
110+
transform: translateY(0) scale(1);
111+
}
112+
}
113+
114+
@keyframes dropdownInUp {
115+
from {
116+
opacity: 0;
117+
transform: translateY(4px) scale(0.98);
118+
}
119+
to {
120+
opacity: 1;
121+
transform: translateY(0) scale(1);
122+
}
123+
}
124+
125+
/* ── Options ───────────────────────────────── */
126+
.option {
127+
display: flex;
128+
align-items: center;
129+
gap: 8px;
130+
width: 100%;
131+
padding: 8px 12px;
132+
border: none;
133+
border-radius: var(--radius-md, 6px);
134+
background: transparent;
135+
cursor: pointer;
136+
text-align: left;
137+
font: inherit;
138+
font-size: 13px;
139+
font-weight: 500;
140+
color: var(--foreground);
141+
transition: background 0.12s ease;
142+
}
143+
144+
.option:hover,
145+
.optionFocused {
146+
background: var(--hover-overlay, oklch(0 0 0 / 0.04));
147+
}
148+
149+
.optionSelected {
150+
font-weight: var(--font-weight-semibold, 600);
151+
}
152+
153+
.optionLabel {
154+
flex: 1;
155+
overflow: hidden;
156+
text-overflow: ellipsis;
157+
white-space: nowrap;
158+
}
159+
160+
.check {
161+
flex-shrink: 0;
162+
color: var(--foreground);
163+
opacity: 0.6;
164+
}
165+
166+
/* ── Dark mode ─────────────────────────────── */
167+
[data-theme='dark'] .trigger {
168+
border-color: var(--border);
169+
background: var(--card, oklch(1 0 0 / 0.055));
170+
}
171+
172+
[data-theme='dark'] .trigger:hover {
173+
background: var(--card-hover, oklch(1 0 0 / 0.075));
174+
border-color: var(--border-hover, oklch(1 0 0 / 0.14));
175+
}
176+
177+
[data-theme='dark'] .trigger:focus-visible {
178+
border-color: var(--ring, oklch(1 0 0 / 0.16));
179+
box-shadow: 0 0 0 3px var(--ring, oklch(1 0 0 / 0.045));
180+
}
181+
182+
[data-theme='dark'] .dropdown {
183+
background: var(--popover, oklch(0.18 0.01 260 / 0.94));
184+
border-color: var(--border);
185+
box-shadow: 0 18px 48px -14px oklch(0 0 0 / 0.58), 0 4px 18px -10px oklch(0 0 0 / 0.42);
186+
}
187+
188+
[data-theme='dark'] .dropdown::-webkit-scrollbar-thumb {
189+
background: var(--border);
190+
}
191+
192+
[data-theme='dark'] .option:hover,
193+
[data-theme='dark'] .optionFocused {
194+
background: var(--hover-overlay, oklch(1 0 0 / 0.05));
195+
}
196+
197+
[data-theme='dark'] .chevron {
198+
opacity: 0.5;
199+
}

app/shared/src/ui/Select.test.tsx

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { describe, it, expect, vi } from 'vitest';
2+
import { render, screen, fireEvent } from '@testing-library/react';
3+
import '@testing-library/jest-dom/vitest';
4+
import { Select } from './Select';
5+
6+
const options: Array<[string, string]> = [
7+
['a', 'Option A'],
8+
['b', 'Option B'],
9+
['c', 'Option C'],
10+
];
11+
12+
describe('Select', () => {
13+
it('renders placeholder when no value', () => {
14+
render(<Select options={options} placeholder="Pick one" />);
15+
expect(screen.getByText('Pick one')).toBeInTheDocument();
16+
});
17+
18+
it('renders selected label when value is set', () => {
19+
render(<Select options={options} value="b" />);
20+
expect(screen.getByText('Option B')).toBeInTheDocument();
21+
});
22+
23+
it('opens dropdown on trigger click', () => {
24+
render(<Select options={options} placeholder="Select" />);
25+
fireEvent.click(screen.getByRole('button'));
26+
expect(screen.getByRole('listbox')).toBeInTheDocument();
27+
});
28+
29+
it('calls onChange when option clicked', () => {
30+
const onChange = vi.fn();
31+
render(<Select options={options} value="a" onChange={onChange} />);
32+
fireEvent.click(screen.getByRole('button'));
33+
fireEvent.click(screen.getByText('Option B'));
34+
expect(onChange).toHaveBeenCalledWith('b');
35+
});
36+
37+
it('closes on Escape', () => {
38+
render(<Select options={options} placeholder="Select" />);
39+
fireEvent.click(screen.getByRole('button'));
40+
expect(screen.getByRole('listbox')).toBeInTheDocument();
41+
fireEvent.keyDown(screen.getByRole('listbox'), { key: 'Escape' });
42+
expect(screen.queryByRole('listbox')).toBeNull();
43+
});
44+
45+
it('navigates with ArrowDown and selects with Enter', () => {
46+
const onChange = vi.fn();
47+
render(<Select options={[['x', 'X'], ['y', 'Y']]} value="x" onChange={onChange} />);
48+
fireEvent.click(screen.getByRole('button'));
49+
fireEvent.keyDown(screen.getByRole('listbox'), { key: 'ArrowDown' });
50+
fireEvent.keyDown(screen.getByRole('listbox'), { key: 'Enter' });
51+
expect(onChange).toHaveBeenCalledWith('y');
52+
});
53+
});

0 commit comments

Comments
 (0)