From a19514dae9b36577af1a78fc4819fb182cfd48a8 Mon Sep 17 00:00:00 2001 From: Antti Kettunen Date: Sun, 26 Apr 2026 19:39:48 +0300 Subject: [PATCH] Add shared form primitives for issues - Introduce reusable input, textarea, card, button, and action components - Use them in the issue report composer as the reference implementation - Keep the TanStack form logic at the usage site and add focused regression coverage --- webui/src/components/form/form.module.css | 191 +++++++++++++++++ webui/src/components/form/form.test.tsx | 106 ++++++++++ webui/src/components/form/form.tsx | 168 +++++++++++++++ webui/src/components/form/index.ts | 11 + webui/src/routes/issues/-route.test.tsx | 18 +- .../issues/-ui/issue-detail-modal.module.css | 117 +++-------- .../routes/issues/-ui/issue-domain-host.tsx | 193 +++++++++--------- .../routes/issues/-ui/issues-page.module.css | 8 + 8 files changed, 628 insertions(+), 184 deletions(-) create mode 100644 webui/src/components/form/form.module.css create mode 100644 webui/src/components/form/form.test.tsx create mode 100644 webui/src/components/form/form.tsx create mode 100644 webui/src/components/form/index.ts diff --git a/webui/src/components/form/form.module.css b/webui/src/components/form/form.module.css new file mode 100644 index 00000000..8f7a4e50 --- /dev/null +++ b/webui/src/components/form/form.module.css @@ -0,0 +1,191 @@ +.field { + display: flex; + flex-direction: column; + gap: 10px; +} + +.fieldHeader { + display: flex; + flex-direction: column; + gap: 4px; +} + +.fieldLabel { + color: #fff; + font-size: 13px; + font-weight: 600; + letter-spacing: 0.01em; +} + +.fieldHelper { + color: rgba(255, 255, 255, 0.45); + font-size: 12px; + line-height: 1.45; +} + +.fieldControl { + display: flex; + flex-direction: column; +} + +.textInput, +.textArea { + width: 100%; + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 12px; + background: rgba(255, 255, 255, 0.05); + color: #fff; + font: inherit; + font-size: 14px; + line-height: 1.5; + padding: 12px 14px; + transition: + border-color 0.18s ease, + background 0.18s ease, + box-shadow 0.18s ease, + transform 0.18s ease; +} + +.textInput::placeholder, +.textArea::placeholder { + color: rgba(255, 255, 255, 0.38); +} + +.textInput:hover, +.textArea:hover { + border-color: rgba(255, 255, 255, 0.16); + background: rgba(255, 255, 255, 0.06); +} + +.textInput:focus, +.textArea:focus { + outline: none; + border-color: rgba(var(--accent-light-rgb), 0.55); + background: rgba(255, 255, 255, 0.07); + box-shadow: 0 0 0 3px rgba(var(--accent-light-rgb), 0.12); +} + +.textArea { + min-height: 128px; + resize: vertical; +} + +.optionCardGroup { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); + gap: 12px; +} + +.optionCard { + display: flex; + align-items: flex-start; + gap: 12px; + width: 100%; + padding: 14px 16px; + text-align: left; + border-radius: 14px; + border: 1px solid rgba(255, 255, 255, 0.08); + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.05), rgba(255, 255, 255, 0.03)), + rgba(255, 255, 255, 0.03); + color: #fff; + cursor: pointer; + transition: + transform 0.18s ease, + border-color 0.18s ease, + box-shadow 0.18s ease, + background 0.18s ease; +} + +.optionCard:hover { + transform: translateY(-1px); + border-color: rgba(255, 255, 255, 0.14); + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.075), rgba(255, 255, 255, 0.04)), + rgba(255, 255, 255, 0.04); + box-shadow: 0 12px 28px rgba(0, 0, 0, 0.22); +} + +.optionCardSelected { + border-color: rgba(var(--accent-light-rgb), 0.45); + 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); +} + +.optionCardIcon { + flex-shrink: 0; + font-size: 18px; + line-height: 1; + margin-top: 1px; +} + +.optionCardBody { + display: flex; + min-width: 0; + flex: 1; + flex-direction: column; + gap: 5px; +} + +.optionCardTitle { + font-size: 14px; + font-weight: 600; + line-height: 1.3; +} + +.optionCardDescription { + color: rgba(255, 255, 255, 0.48); + font-size: 12px; + line-height: 1.45; +} + +.optionButtonGroup { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.optionButton { + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 999px; + background: rgba(255, 255, 255, 0.05); + color: #fff; + cursor: pointer; + font: inherit; + font-size: 13px; + font-weight: 600; + min-width: 80px; + padding: 10px 14px; + transition: + transform 0.18s ease, + border-color 0.18s ease, + box-shadow 0.18s ease, + background 0.18s ease; +} + +.optionButton:hover { + transform: translateY(-1px); + border-color: rgba(255, 255, 255, 0.18); + background: rgba(255, 255, 255, 0.08); +} + +.optionButtonSelected { + border-color: rgba(var(--accent-light-rgb), 0.5); + background: rgba(var(--accent-rgb), 0.18); + box-shadow: 0 0 0 1px rgba(var(--accent-light-rgb), 0.08); +} + +.formError { + color: #ff8d8d; + font-size: 12px; + line-height: 1.45; +} + +.formActions { + display: flex; + justify-content: flex-end; + gap: 12px; + flex-wrap: wrap; +} diff --git a/webui/src/components/form/form.test.tsx b/webui/src/components/form/form.test.tsx new file mode 100644 index 00000000..70336ad2 --- /dev/null +++ b/webui/src/components/form/form.test.tsx @@ -0,0 +1,106 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { useState } from 'react'; +import { describe, expect, it } from 'vitest'; + +import { + FormActions, + FormError, + FormField, + OptionButton, + OptionButtonGroup, + OptionCard, + OptionCardGroup, + TextArea, + TextInput, +} from './form'; + +function FormDemo() { + const [title, setTitle] = useState(''); + const [details, setDetails] = useState(''); + const [category, setCategory] = useState<'wrong_cover' | 'wrong_metadata'>('wrong_cover'); + const [priority, setPriority] = useState<'low' | 'normal' | 'high'>('normal'); + + return ( +
+ + setTitle(event.target.value)} + /> + + + +