Add shared select and button primitives

- Keep Select thin and native, with options supplied as children
- Add a simple shared Button for form actions
- Use both primitives in the issues page and report modal
This commit is contained in:
Antti Kettunen 2026-04-26 20:25:45 +03:00
parent a19514dae9
commit 9572035837
No known key found for this signature in database
GPG key ID: C6B2A3D250359BD7
7 changed files with 158 additions and 38 deletions

View file

@ -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;

View file

@ -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 (
<form>
@ -71,11 +74,23 @@ function FormDemo() {
</OptionButtonGroup>
</FormField>
<FormField label="Status" helperText="Shared select primitive" htmlFor="status-select">
<Select
id="status-select"
value={status}
onChange={(event) => setStatus(event.target.value)}
>
<option value="open">Open</option>
<option value="in_progress">In Progress</option>
<option value="resolved">Resolved</option>
</Select>
</FormField>
<FormError message="Validation failed" />
<FormActions>
<button type="button">Cancel</button>
<button type="submit">Save</button>
<Button type="button">Cancel</Button>
<Button type="submit">Save</Button>
</FormActions>
</form>
);
@ -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();
});
});

View file

@ -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<HTMLTextAreaElement, TextAreaProps>(function
return <textarea ref={ref} className={mergeClassNames(styles.textArea, className)} {...props} />;
});
export type SelectProps = SelectHTMLAttributes<HTMLSelectElement>;
export const Select = forwardRef<HTMLSelectElement, SelectProps>(function Select(
{ className, ...props },
ref,
) {
return <select ref={ref} className={mergeClassNames(styles.select, className)} {...props} />;
});
export function OptionCardGroup({
className,
children,
@ -147,6 +157,15 @@ export const OptionButton = forwardRef<HTMLButtonElement, OptionButtonProps>(fun
);
});
export type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement>;
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
{ className, type = 'button', ...props },
ref,
) {
return <button ref={ref} className={mergeClassNames(styles.button, className)} type={type} {...props} />;
});
export function FormError({ className, message }: { className?: string; message?: ReactNode }) {
if (!message) return null;

View file

@ -1,4 +1,5 @@
export {
Button,
FormActions,
FormError,
FormField,
@ -6,6 +7,7 @@ export {
OptionButtonGroup,
OptionCard,
OptionCardGroup,
Select,
TextArea,
TextInput,
} from './form';

View file

@ -5,6 +5,7 @@ import { createPortal } from "react-dom";
import { getShellProfileContext } from "@/platform/shell/bridge";
import {
Button,
FormActions,
FormError,
FormField,
@ -325,13 +326,13 @@ function ReportIssueModal({
</div>
<FormActions className={styles.modalFooter}>
<button
<Button
className={`${styles.modalButton} ${styles.modalButtonSecondary}`}
type="button"
onClick={onClose}
>
Cancel
</button>
</Button>
<form.Subscribe
selector={(state) => ({
category: state.values.category,
@ -342,13 +343,13 @@ function ReportIssueModal({
{(state) => {
const isSubmitting = state.isSubmitting || createMutation.isPending;
return (
<button
<Button
className={`${styles.modalButton} ${styles.modalButtonPrimary}`}
type="submit"
disabled={!state.category || !state.title.trim() || isSubmitting}
>
{isSubmitting ? "Submitting..." : "Submit Issue"}
</button>
</Button>
);
}}
</form.Subscribe>

View file

@ -45,36 +45,9 @@
}
.issuesFilterSelect {
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.1);
color: #fff;
padding: 7px 12px;
border-radius: 8px;
font-size: 13px;
cursor: pointer;
outline: none;
transition: border-color 0.2s;
appearance: none;
-webkit-appearance: none;
color-scheme: dark;
background-image: 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;
background-position: right 10px center;
padding-right: 28px;
min-width: 130px;
}
.issuesFilterSelect:focus {
border-color: rgba(var(--accent-light-rgb), 0.5);
box-shadow: 0 0 0 3px rgba(var(--accent-light-rgb), 0.12);
}
.issuesFilterSelect option,
.issuesFilterSelect optgroup {
background: #1a1a2e;
color: #fff;
}
.issuesStats {
display: flex;
gap: 10px;

View file

@ -3,6 +3,7 @@ import { useNavigate } from '@tanstack/react-router';
import { useEffect, useState } from 'react';
import { getShellProfileContext } from '@/platform/shell/bridge';
import { Select } from '@/components/form';
import { useReactPageShell } from '@/platform/shell/route-controllers';
import type { IssueCounts, IssueRecord, IssueStatus } from '../-issues.types';
@ -158,7 +159,7 @@ function IssueBoard({
</div>
<div className={styles.issuesHeaderRight}>
<div className={styles.issuesFilters} id="issues-filters">
<select
<Select
id="issues-filter-status"
className={styles.issuesFilterSelect}
aria-label="Status"
@ -170,8 +171,8 @@ function IssueBoard({
<option value="in_progress">In Progress</option>
<option value="resolved">Resolved</option>
<option value="dismissed">Dismissed</option>
</select>
<select
</Select>
<Select
id="issues-filter-category"
className={styles.issuesFilterSelect}
aria-label="Category"
@ -195,7 +196,7 @@ function IssueBoard({
<option value="wrong_metadata">Wrong Metadata</option>
<option value="other">Other</option>
</optgroup>
</select>
</Select>
</div>
</div>
</div>