diff --git a/webui/src/components/form/form.module.css b/webui/src/components/form/form.module.css index 8f7a4e50..c2db90ef 100644 --- a/webui/src/components/form/form.module.css +++ b/webui/src/components/form/form.module.css @@ -70,6 +70,65 @@ resize: vertical; } +.select { + width: auto; + min-width: 130px; + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 12px; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.055), rgba(255, 255, 255, 0.035)), + rgba(255, 255, 255, 0.05); + background-image: + linear-gradient(180deg, rgba(255, 255, 255, 0.055), rgba(255, 255, 255, 0.035)), + rgba(255, 255, 255, 0.05), + url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' fill='%23888'%3E%3Cpath d='M0 0l5 6 5-6z'/%3E%3C/svg%3E"); + background-repeat: no-repeat, no-repeat, no-repeat; + background-position: + 0 0, + 0 0, + right 12px center; + background-size: + auto, + auto, + 10px 6px; + color: #fff; + font: inherit; + font-size: 14px; + line-height: 1.5; + padding: 12px 34px 12px 14px; + transition: + border-color 0.18s ease, + background 0.18s ease, + box-shadow 0.18s ease, + transform 0.18s ease; + appearance: none; + -webkit-appearance: none; + color-scheme: dark; + cursor: pointer; +} + +.select:hover { + border-color: rgba(255, 255, 255, 0.16); + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.075), rgba(255, 255, 255, 0.045)), + rgba(255, 255, 255, 0.06); +} + +.select:focus { + outline: none; + border-color: rgba(var(--accent-light-rgb), 0.55); + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.08), rgba(255, 255, 255, 0.05)), + rgba(255, 255, 255, 0.07); + box-shadow: 0 0 0 3px rgba(var(--accent-light-rgb), 0.12); +} + +.select option, +.select optgroup { + background: #1a1a2e; + color: #fff; +} + .optionCardGroup { display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); @@ -111,7 +170,9 @@ background: linear-gradient(180deg, rgba(var(--accent-rgb), 0.18), rgba(255, 255, 255, 0.04)), rgba(255, 255, 255, 0.05); - box-shadow: 0 0 0 1px rgba(var(--accent-light-rgb), 0.1), 0 14px 32px rgba(0, 0, 0, 0.26); + box-shadow: + 0 0 0 1px rgba(var(--accent-light-rgb), 0.1), + 0 14px 32px rgba(0, 0, 0, 0.26); } .optionCardIcon { @@ -177,6 +238,48 @@ box-shadow: 0 0 0 1px rgba(var(--accent-light-rgb), 0.08); } +.button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 12px; + background: rgba(255, 255, 255, 0.05); + color: #fff; + cursor: pointer; + font: inherit; + font-size: 14px; + font-weight: 600; + line-height: 1.2; + min-height: 42px; + padding: 10px 16px; + transition: + transform 0.18s ease, + border-color 0.18s ease, + box-shadow 0.18s ease, + background 0.18s ease, + color 0.18s ease; +} + +.button:hover:not(:disabled) { + transform: translateY(-1px); + border-color: rgba(255, 255, 255, 0.18); + background: rgba(255, 255, 255, 0.08); +} + +.button:focus-visible { + outline: none; + border-color: rgba(var(--accent-light-rgb), 0.55); + box-shadow: 0 0 0 3px rgba(var(--accent-light-rgb), 0.12); +} + +.button:disabled { + opacity: 0.55; + cursor: not-allowed; + transform: none; +} + .formError { color: #ff8d8d; font-size: 12px; diff --git a/webui/src/components/form/form.test.tsx b/webui/src/components/form/form.test.tsx index 70336ad2..423c0eac 100644 --- a/webui/src/components/form/form.test.tsx +++ b/webui/src/components/form/form.test.tsx @@ -3,6 +3,7 @@ import { useState } from 'react'; import { describe, expect, it } from 'vitest'; import { + Button, FormActions, FormError, FormField, @@ -10,6 +11,7 @@ import { OptionButtonGroup, OptionCard, OptionCardGroup, + Select, TextArea, TextInput, } from './form'; @@ -19,6 +21,7 @@ function FormDemo() { const [details, setDetails] = useState(''); const [category, setCategory] = useState<'wrong_cover' | 'wrong_metadata'>('wrong_cover'); const [priority, setPriority] = useState<'low' | 'normal' | 'high'>('normal'); + const [status, setStatus] = useState('open'); return (
@@ -71,11 +74,23 @@ function FormDemo() { + + + + - - + + ); @@ -89,6 +104,9 @@ describe('form primitives', () => { expect(screen.getByLabelText('Details')).toHaveAttribute('placeholder', 'Enter details'); expect(screen.getByText('Short summary')).toBeInTheDocument(); expect(screen.getByRole('alert')).toHaveTextContent('Validation failed'); + expect(screen.getByLabelText('Status')).toHaveValue('open'); + fireEvent.change(screen.getByLabelText('Status'), { target: { value: 'resolved' } }); + expect(screen.getByLabelText('Status')).toHaveValue('resolved'); const wrongCover = screen.getByRole('button', { name: /wrong cover/i }); const wrongMetadata = screen.getByRole('button', { name: /wrong metadata/i }); @@ -102,5 +120,8 @@ describe('form primitives', () => { expect(highPriority).toHaveAttribute('aria-pressed', 'false'); fireEvent.click(highPriority); expect(highPriority).toHaveAttribute('aria-pressed', 'true'); + + expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Save' })).toBeInTheDocument(); }); }); diff --git a/webui/src/components/form/form.tsx b/webui/src/components/form/form.tsx index 71cef56c..db06faa7 100644 --- a/webui/src/components/form/form.tsx +++ b/webui/src/components/form/form.tsx @@ -2,6 +2,7 @@ import { forwardRef, type ButtonHTMLAttributes, type InputHTMLAttributes, + type SelectHTMLAttributes, type ReactNode, type TextareaHTMLAttributes, } from 'react'; @@ -65,6 +66,15 @@ export const TextArea = forwardRef(function return