Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
199 changes: 199 additions & 0 deletions app/shared/src/ui/Select.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
.container {
position: relative;
display: inline-flex;
flex-shrink: 0;
}

/* ── Trigger ──────────────────────────────── */
.trigger {
display: inline-flex;
align-items: center;
justify-content: space-between;
gap: 8px;
min-width: 158px;
width: 100%;
height: 34px;
padding: 0 10px 0 12px;
border: 1px solid var(--border);
border-radius: var(--radius-md, 6px);
background: var(--card, oklch(1 0 0 / 0.48));
color: var(--foreground);
font: inherit;
font-size: 13px;
font-weight: var(--font-weight-semibold, 600);
cursor: pointer;
white-space: nowrap;
transition: border-color 0.15s, background 0.15s, box-shadow 0.15s;
-webkit-font-smoothing: antialiased;
}

.trigger:hover {
background: var(--card-hover, oklch(1 0 0 / 0.62));
border-color: var(--border-hover, oklch(0 0 0 / 0.13));
}

.trigger:focus-visible {
outline: none;
border-color: var(--ring, oklch(0.35 0.02 260 / 0.5));
box-shadow: 0 0 0 3px var(--ring, oklch(0.35 0.02 260 / 0.15));
}

.trigger:disabled {
opacity: 0.4;
cursor: not-allowed;
}

.label {
overflow: hidden;
text-overflow: ellipsis;
}

.placeholder {
color: var(--muted-foreground);
font-weight: 450;
overflow: hidden;
text-overflow: ellipsis;
}

.chevron {
opacity: 0.4;
flex-shrink: 0;
transition: transform 0.2s ease;
}

.chevronOpen {
transform: rotate(180deg);
}

/* ── Dropdown panel ────────────────────────── */
.dropdown {
position: fixed;
z-index: var(--z-overlay, 30);
min-width: 158px;
max-height: 260px;
overflow-y: auto;
background: var(--popover, oklch(0.96 0.01 260 / 0.92));
backdrop-filter: blur(24px) saturate(1.08);
-webkit-backdrop-filter: blur(24px) saturate(1.08);
border: 1px solid var(--border);
border-radius: var(--radius-xl, 12px);
box-shadow: 0 18px 44px -12px oklch(0 0 0 / 0.2), 0 4px 14px -8px oklch(0 0 0 / 0.14);
padding: 6px;
animation: dropdownIn 0.12s ease-out;
font-family: var(--font-sans);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
scrollbar-width: thin;
scrollbar-color: transparent transparent;
}

.dropdown::-webkit-scrollbar {
width: 4px;
}

.dropdown::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 2px;
}

.dropdownUp {
animation: dropdownInUp 0.12s ease-out;
}

@keyframes dropdownIn {
from {
opacity: 0;
transform: translateY(-4px) scale(0.98);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}

@keyframes dropdownInUp {
from {
opacity: 0;
transform: translateY(4px) scale(0.98);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}

/* ── Options ───────────────────────────────── */
.option {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 8px 12px;
border: none;
border-radius: var(--radius-md, 6px);
background: transparent;
cursor: pointer;
text-align: left;
font: inherit;
font-size: 13px;
font-weight: 500;
color: var(--foreground);
transition: background 0.12s ease;
}

.option:hover,
.optionFocused {
background: var(--hover-overlay, oklch(0 0 0 / 0.04));
}

.optionSelected {
font-weight: var(--font-weight-semibold, 600);
}

.optionLabel {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

.check {
flex-shrink: 0;
color: var(--foreground);
opacity: 0.6;
}

/* ── Dark mode ─────────────────────────────── */
[data-theme='dark'] .trigger {
border-color: var(--border);
background: var(--card, oklch(1 0 0 / 0.055));
}

[data-theme='dark'] .trigger:hover {
background: var(--card-hover, oklch(1 0 0 / 0.075));
border-color: var(--border-hover, oklch(1 0 0 / 0.14));
}

[data-theme='dark'] .trigger:focus-visible {
border-color: var(--ring, oklch(1 0 0 / 0.16));
box-shadow: 0 0 0 3px var(--ring, oklch(1 0 0 / 0.045));
}

[data-theme='dark'] .dropdown {
background: var(--popover, oklch(0.18 0.01 260 / 0.94));
border-color: var(--border);
box-shadow: 0 18px 48px -14px oklch(0 0 0 / 0.58), 0 4px 18px -10px oklch(0 0 0 / 0.42);
}

[data-theme='dark'] .dropdown::-webkit-scrollbar-thumb {
background: var(--border);
}

[data-theme='dark'] .option:hover,
[data-theme='dark'] .optionFocused {
background: var(--hover-overlay, oklch(1 0 0 / 0.05));
}

[data-theme='dark'] .chevron {
opacity: 0.5;
}
53 changes: 53 additions & 0 deletions app/shared/src/ui/Select.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import '@testing-library/jest-dom/vitest';
import { Select } from './Select';

const options: Array<[string, string]> = [
['a', 'Option A'],
['b', 'Option B'],
['c', 'Option C'],
];

describe('Select', () => {
it('renders placeholder when no value', () => {
render(<Select options={options} placeholder="Pick one" />);

Check failure on line 14 in app/shared/src/ui/Select.test.tsx

View workflow job for this annotation

GitHub Actions / frontend-desktop

../shared/src/ui/Select.test.tsx > Select > renders placeholder when no value

ReferenceError: React is not defined ❯ ../shared/src/ui/Select.test.tsx:14:12
expect(screen.getByText('Pick one')).toBeInTheDocument();
});

it('renders selected label when value is set', () => {
render(<Select options={options} value="b" />);

Check failure on line 19 in app/shared/src/ui/Select.test.tsx

View workflow job for this annotation

GitHub Actions / frontend-desktop

../shared/src/ui/Select.test.tsx > Select > renders selected label when value is set

ReferenceError: React is not defined ❯ ../shared/src/ui/Select.test.tsx:19:12
expect(screen.getByText('Option B')).toBeInTheDocument();
});

it('opens dropdown on trigger click', () => {
render(<Select options={options} placeholder="Select" />);

Check failure on line 24 in app/shared/src/ui/Select.test.tsx

View workflow job for this annotation

GitHub Actions / frontend-desktop

../shared/src/ui/Select.test.tsx > Select > opens dropdown on trigger click

ReferenceError: React is not defined ❯ ../shared/src/ui/Select.test.tsx:24:12
fireEvent.click(screen.getByRole('button'));
expect(screen.getByRole('listbox')).toBeInTheDocument();
});

it('calls onChange when option clicked', () => {
const onChange = vi.fn();
render(<Select options={options} value="a" onChange={onChange} />);

Check failure on line 31 in app/shared/src/ui/Select.test.tsx

View workflow job for this annotation

GitHub Actions / frontend-desktop

../shared/src/ui/Select.test.tsx > Select > calls onChange when option clicked

ReferenceError: React is not defined ❯ ../shared/src/ui/Select.test.tsx:31:12
fireEvent.click(screen.getByRole('button'));
fireEvent.click(screen.getByText('Option B'));
expect(onChange).toHaveBeenCalledWith('b');
});

it('closes on Escape', () => {
render(<Select options={options} placeholder="Select" />);

Check failure on line 38 in app/shared/src/ui/Select.test.tsx

View workflow job for this annotation

GitHub Actions / frontend-desktop

../shared/src/ui/Select.test.tsx > Select > closes on Escape

ReferenceError: React is not defined ❯ ../shared/src/ui/Select.test.tsx:38:12
fireEvent.click(screen.getByRole('button'));
expect(screen.getByRole('listbox')).toBeInTheDocument();
fireEvent.keyDown(screen.getByRole('listbox'), { key: 'Escape' });
expect(screen.queryByRole('listbox')).toBeNull();
});

it('navigates with ArrowDown and selects with Enter', () => {
const onChange = vi.fn();
render(<Select options={[['x', 'X'], ['y', 'Y']]} value="x" onChange={onChange} />);

Check failure on line 47 in app/shared/src/ui/Select.test.tsx

View workflow job for this annotation

GitHub Actions / frontend-desktop

../shared/src/ui/Select.test.tsx > Select > navigates with ArrowDown and selects with Enter

ReferenceError: React is not defined ❯ ../shared/src/ui/Select.test.tsx:47:12
fireEvent.click(screen.getByRole('button'));
fireEvent.keyDown(screen.getByRole('listbox'), { key: 'ArrowDown' });
fireEvent.keyDown(screen.getByRole('listbox'), { key: 'Enter' });
expect(onChange).toHaveBeenCalledWith('y');
});
});
Loading
Loading