From 0d7fb91d98c951f7eb293b311794dd88413c8165 Mon Sep 17 00:00:00 2001 From: Antti Kettunen Date: Sat, 16 May 2026 19:58:36 +0300 Subject: [PATCH] refactor(webui): share import form primitives - add shared Base UI-backed checkbox and slider primitives under the form component layer - move the singles import checkbox and auto-import confidence slider to the shared controls - keep the import route tests aligned with the new accessible component roles --- webui/src/components/form/form.module.css | 114 ++++++++++++++++++ webui/src/components/form/form.test.tsx | 28 +++++ webui/src/components/form/form.tsx | 81 +++++++++++++ webui/src/routes/import/-route.test.tsx | 8 +- .../routes/import/-ui/album-import-tab.tsx | 28 +++-- .../src/routes/import/-ui/auto-import-tab.tsx | 63 +++++----- .../routes/import/-ui/import-page.module.css | 73 ++--------- webui/src/routes/import/-ui/import-page.tsx | 9 +- .../routes/import/-ui/singles-import-tab.tsx | 25 ++-- 9 files changed, 298 insertions(+), 131 deletions(-) diff --git a/webui/src/components/form/form.module.css b/webui/src/components/form/form.module.css index bf673cac..54638847 100644 --- a/webui/src/components/form/form.module.css +++ b/webui/src/components/form/form.module.css @@ -129,6 +129,120 @@ color: #fff; } +.checkbox { + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + flex-shrink: 0; + box-sizing: border-box; + margin: 0; + border: 2px solid rgba(255, 255, 255, 0.2); + border-radius: 4px; + background: transparent; + color: #000; + cursor: pointer; + transition: + border-color 0.18s ease, + background 0.18s ease, + box-shadow 0.18s ease, + transform 0.18s ease; + color-scheme: dark; +} + +.checkbox[data-checked] { + border-color: rgb(var(--accent-light-rgb)); + background: rgb(var(--accent-light-rgb)); +} + +.checkbox[data-focused] { + outline: none; + box-shadow: 0 0 0 3px rgba(var(--accent-light-rgb), 0.14); +} + +.checkboxIndicator { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + opacity: 0; + transition: opacity 0.18s ease; +} + +.checkbox[data-checked] .checkboxIndicator { + opacity: 1; +} + +.checkboxIcon { + color: #000; + font-size: 12px; + font-weight: 700; + line-height: 1; + transform: translateY(-0.5px) scaleX(1.18); +} + +.rangeRoot { + display: inline-flex; + flex: 0 0 160px; + width: 160px; + min-width: 160px; + color-scheme: dark; +} + +.rangeControl { + display: flex; + align-items: center; + width: 100%; + min-height: 28px; + cursor: pointer; +} + +.rangeTrack { + position: relative; + width: 100%; + height: 8px; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 999px; + background: rgba(255, 255, 255, 0.08); + box-shadow: inset 0 1px 1px rgba(255, 255, 255, 0.04); +} + +.rangeIndicator { + height: 100%; + border-radius: inherit; + background: linear-gradient( + 90deg, + rgba(var(--accent-rgb), 0.95), + rgba(var(--accent-light-rgb), 0.95) + ); +} + +.rangeThumb { + width: 16px; + height: 16px; + border: 1px solid rgba(255, 255, 255, 0.24); + border-radius: 50%; + background: + radial-gradient(circle at 30% 28%, rgba(255, 255, 255, 0.3), transparent 46%), + rgb(var(--accent-light-rgb)); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.28); + transition: + transform 0.18s ease, + box-shadow 0.18s ease; +} + +.rangeThumb:hover { + transform: scale(1.04); +} + +.rangeThumb:focus-visible { + box-shadow: + 0 0 0 4px rgba(var(--accent-light-rgb), 0.14), + 0 2px 8px rgba(0, 0, 0, 0.28); +} + .optionCardGroup { display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); diff --git a/webui/src/components/form/form.test.tsx b/webui/src/components/form/form.test.tsx index 3488a2f6..2feb7a2e 100644 --- a/webui/src/components/form/form.test.tsx +++ b/webui/src/components/form/form.test.tsx @@ -4,6 +4,7 @@ import { describe, expect, it } from 'vitest'; import { Button, + Checkbox, FormActions, FormError, FormField, @@ -11,6 +12,7 @@ import { OptionButtonGroup, OptionCard, OptionCardGroup, + RangeInput, Select, TextArea, TextInput, @@ -22,6 +24,8 @@ function FormDemo() { const [category, setCategory] = useState<'wrong_cover' | 'wrong_metadata'>('wrong_cover'); const [priority, setPriority] = useState<'low' | 'normal' | 'high'>('normal'); const [status, setStatus] = useState('open'); + const [archive, setArchive] = useState(false); + const [confidence, setConfidence] = useState(90); return (
@@ -90,6 +94,20 @@ function FormDemo() { + + + + + + + + @@ -112,6 +130,16 @@ describe('form primitives', () => { fireEvent.change(screen.getByLabelText('Status'), { target: { value: 'resolved' } }); expect(screen.getByLabelText('Status')).toHaveValue('resolved'); + const archiveCheckbox = screen.getByRole('checkbox', { name: 'Archive' }); + expect(archiveCheckbox).not.toBeChecked(); + fireEvent.click(archiveCheckbox); + expect(archiveCheckbox).toBeChecked(); + + const confidenceSlider = screen.getByLabelText('Confidence', { selector: 'input' }); + expect(confidenceSlider).toHaveValue('90'); + fireEvent.change(confidenceSlider, { target: { value: '75' } }); + expect(confidenceSlider).toHaveValue('75'); + const wrongCover = screen.getByRole('button', { name: /wrong cover/i }); const wrongMetadata = screen.getByRole('button', { name: /wrong metadata/i }); expect(wrongCover).toHaveAttribute('aria-pressed', 'true'); diff --git a/webui/src/components/form/form.tsx b/webui/src/components/form/form.tsx index 15938435..1a26870e 100644 --- a/webui/src/components/form/form.tsx +++ b/webui/src/components/form/form.tsx @@ -1,10 +1,13 @@ import { Button as BaseButton } from '@base-ui/react/button'; +import { Checkbox as BaseCheckbox } from '@base-ui/react/checkbox'; import { Field } from '@base-ui/react/field'; import { Input as BaseInput } from '@base-ui/react/input'; +import { Slider } from '@base-ui/react/slider'; import { Toggle as BaseToggle } from '@base-ui/react/toggle'; import clsx from 'clsx'; import { forwardRef, + type CSSProperties, type ComponentPropsWithoutRef, type ButtonHTMLAttributes, type SelectHTMLAttributes, @@ -82,6 +85,84 @@ export const Select = forwardRef(function Select return - - +
@@ -431,20 +433,20 @@ function AlbumMatchPanel({ viewModel }: { viewModel: AlbumImportViewModel }) {

Track Matching

- - +
@@ -504,7 +506,7 @@ function AlbumMatchPanel({ viewModel }: { viewModel: AlbumImportViewModel }) { {file ? ( - + ) : null}
@@ -564,7 +566,7 @@ function AlbumMatchPanel({ viewModel }: { viewModel: AlbumImportViewModel }) {
{matchedCount} of {albumMatch.matches?.length ?? 0} tracks matched
- + ) : ( diff --git a/webui/src/routes/import/-ui/auto-import-tab.tsx b/webui/src/routes/import/-ui/auto-import-tab.tsx index 62b94c84..471c10f3 100644 --- a/webui/src/routes/import/-ui/auto-import-tab.tsx +++ b/webui/src/routes/import/-ui/auto-import-tab.tsx @@ -1,6 +1,8 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useEffect, useState } from 'react'; +import { Button, RangeInput, Select } from '@/components/form/form'; + import type { ImportAutoFilter, ImportAutoImportResult, @@ -171,7 +173,7 @@ export function AutoImportPanel({ {getAutoImportStatusText(statusQuery.data)} {statusQuery.data?.running ? ( - + ) : null} {statusQuery.data?.running ? (
- - -
+ + ) : null} {activeLines.length > 0 ? ( @@ -264,7 +267,7 @@ export function AutoImportPanel({
{(['all', 'pending', 'imported', 'failed'] as const).map((filter) => ( - + ))}
{counts.review > 0 ? ( - + ) : null} {counts.imported + counts.failed > 0 ? ( - + ) : null}
@@ -445,7 +448,7 @@ function AutoImportResultCard({
{confidencePercent}% confidence
{result.status === 'pending_review' ? (
- - +
) : null}
diff --git a/webui/src/routes/import/-ui/import-page.module.css b/webui/src/routes/import/-ui/import-page.module.css index 1ac434ef..cf99b973 100644 --- a/webui/src/routes/import/-ui/import-page.module.css +++ b/webui/src/routes/import/-ui/import-page.module.css @@ -637,53 +637,6 @@ margin-top: 2px; } -.importPageSingleCheckboxInput { - position: absolute; - inset: 0; - opacity: 0; - margin: 0; -} - -.importPageSingleCheckbox { - width: 18px; - height: 18px; - box-sizing: border-box; - border: 2px solid rgba(255, 255, 255, 0.2); - background: transparent; - padding: 0; - border-radius: 4px; - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - transition: all 0.2s; - flex-shrink: 0; -} - -.importPageSingleCheckbox::after { - content: '✓'; - color: #000; - font-size: 12px; - font-weight: 700; - line-height: 1; - opacity: 0; - transform: translateY(-0.5px) scaleX(1.18); -} - -.importPageSingleCheckboxInput:focus-visible + .importPageSingleCheckbox { - outline: none; - box-shadow: 0 0 0 3px rgba(var(--accent-light-rgb), 0.14); -} - -.importPageSingleCheckboxInput:checked + .importPageSingleCheckbox { - background: rgb(var(--accent-light-rgb)); - border-color: rgb(var(--accent-light-rgb)); -} - -.importPageSingleCheckboxInput:checked + .importPageSingleCheckbox::after { - opacity: 1; -} - .importPageSingleInfo { grid-column: 2; min-width: 0; @@ -1199,23 +1152,19 @@ color: rgba(255, 255, 255, 0.5); } -.autoImportSettingsRow label { +.autoImportSetting { display: flex; align-items: center; gap: 6px; } -.autoImportSettingsRow input[type='range'] { - width: 100px; -} -.autoImportSettingsRow select { - background: rgba(255, 255, 255, 0.05); - border: 1px solid rgba(255, 255, 255, 0.1); - color: #fff; - border-radius: 6px; - padding: 3px 8px; - font-size: 12px; -} +.autoImportConfidenceValue { + display: inline-block; + flex: 0 0 5ch; + width: 5ch; + text-align: right; + font-variant-numeric: tabular-nums; +} .autoImportEmpty { text-align: center; padding: 40px 20px; @@ -1468,12 +1417,6 @@ display: inline-flex; align-items: center; gap: 7px; - padding: 9px 18px; - border-radius: 10px; - border: 1px solid transparent; - cursor: pointer; - font-size: 13px; - font-weight: 500; transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1); white-space: nowrap; letter-spacing: 0.1px; diff --git a/webui/src/routes/import/-ui/import-page.tsx b/webui/src/routes/import/-ui/import-page.tsx index 05a22599..932d4922 100644 --- a/webui/src/routes/import/-ui/import-page.tsx +++ b/webui/src/routes/import/-ui/import-page.tsx @@ -1,6 +1,7 @@ import { Link, Outlet } from '@tanstack/react-router'; import { Show } from '@/components/primitives'; +import { Button } from '@/components/form/form'; import { useReactPageShell } from '@/platform/shell/route-controllers'; import type { ImportQueueEntry } from '../-import.types'; @@ -76,7 +77,7 @@ function ImportHeader({ Import Music - +
@@ -118,7 +119,7 @@ function ImportProcessingQueue() { >
Processing - +
{queue.map((entry) => ( diff --git a/webui/src/routes/import/-ui/singles-import-tab.tsx b/webui/src/routes/import/-ui/singles-import-tab.tsx index 63885b36..a9c05a8b 100644 --- a/webui/src/routes/import/-ui/singles-import-tab.tsx +++ b/webui/src/routes/import/-ui/singles-import-tab.tsx @@ -1,5 +1,7 @@ import { useEffect } from 'react'; +import { Button, Checkbox, TextInput } from '@/components/form/form'; + import type { SingleSearchState } from '../-import.store'; import type { ImportTrackResult } from '../-import.types'; import type { ImportStagingFile } from '../-import.types'; @@ -172,12 +174,12 @@ export function SinglesImportPanel({ <>
- - +
@@ -206,14 +208,11 @@ export function SinglesImportPanel({ data-single-key={fileKey} >
{file.filename}
@@ -280,7 +279,7 @@ function SingleSearchPanel({ return (
- - +
{searchState?.loading ? (