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
This commit is contained in:
Antti Kettunen 2026-04-26 19:39:48 +03:00
parent 48aec3f6f3
commit a19514dae9
No known key found for this signature in database
GPG key ID: C6B2A3D250359BD7
8 changed files with 628 additions and 184 deletions

View file

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

View file

@ -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 (
<form>
<FormField label="Title" helperText="Short summary" htmlFor="title-input">
<TextInput
id="title-input"
placeholder="Enter title"
value={title}
onChange={(event) => setTitle(event.target.value)}
/>
</FormField>
<FormField label="Details" helperText="Longer explanation" htmlFor="details-input">
<TextArea
id="details-input"
placeholder="Enter details"
rows={3}
value={details}
onChange={(event) => setDetails(event.target.value)}
/>
</FormField>
<FormField label="Category" helperText="Pick one">
<OptionCardGroup>
<OptionCard
description="Album art is wrong"
icon="🖼️"
onClick={() => setCategory('wrong_cover')}
selected={category === 'wrong_cover'}
title="Wrong Cover"
/>
<OptionCard
description="Metadata needs fixing"
icon="🏷️"
onClick={() => setCategory('wrong_metadata')}
selected={category === 'wrong_metadata'}
title="Wrong Metadata"
/>
</OptionCardGroup>
</FormField>
<FormField label="Priority" helperText="Set urgency">
<OptionButtonGroup>
{(['low', 'normal', 'high'] as const).map((value) => (
<OptionButton key={value} onClick={() => setPriority(value)} selected={priority === value}>
{value[0].toUpperCase()}
{value.slice(1)}
</OptionButton>
))}
</OptionButtonGroup>
</FormField>
<FormError message="Validation failed" />
<FormActions>
<button type="button">Cancel</button>
<button type="submit">Save</button>
</FormActions>
</form>
);
}
describe('form primitives', () => {
it('render accessible controls and support selection state', () => {
render(<FormDemo />);
expect(screen.getByLabelText('Title')).toHaveAttribute('placeholder', 'Enter title');
expect(screen.getByLabelText('Details')).toHaveAttribute('placeholder', 'Enter details');
expect(screen.getByText('Short summary')).toBeInTheDocument();
expect(screen.getByRole('alert')).toHaveTextContent('Validation failed');
const wrongCover = screen.getByRole('button', { name: /wrong cover/i });
const wrongMetadata = screen.getByRole('button', { name: /wrong metadata/i });
expect(wrongCover).toHaveAttribute('aria-pressed', 'true');
expect(wrongMetadata).toHaveAttribute('aria-pressed', 'false');
fireEvent.click(wrongMetadata);
expect(wrongCover).toHaveAttribute('aria-pressed', 'false');
expect(wrongMetadata).toHaveAttribute('aria-pressed', 'true');
const highPriority = screen.getByRole('button', { name: 'High' });
expect(highPriority).toHaveAttribute('aria-pressed', 'false');
fireEvent.click(highPriority);
expect(highPriority).toHaveAttribute('aria-pressed', 'true');
});
});

View file

@ -0,0 +1,168 @@
import {
forwardRef,
type ButtonHTMLAttributes,
type InputHTMLAttributes,
type ReactNode,
type TextareaHTMLAttributes,
} from 'react';
import styles from './form.module.css';
function mergeClassNames(...classNames: Array<string | false | null | undefined>) {
return classNames.filter(Boolean).join(' ');
}
export interface FormFieldProps {
children: ReactNode;
className?: string;
error?: ReactNode;
helperText?: ReactNode;
htmlFor?: string;
label: ReactNode;
}
export function FormField({
children,
className,
error,
helperText,
htmlFor,
label,
}: FormFieldProps) {
return (
<div className={mergeClassNames(styles.field, className)}>
<div className={styles.fieldHeader}>
{htmlFor ? (
<label className={styles.fieldLabel} htmlFor={htmlFor}>
{label}
</label>
) : (
<div className={styles.fieldLabel}>{label}</div>
)}
{helperText ? <div className={styles.fieldHelper}>{helperText}</div> : null}
</div>
<div className={styles.fieldControl}>{children}</div>
{error ? <FormError message={error} /> : null}
</div>
);
}
export type TextInputProps = InputHTMLAttributes<HTMLInputElement>;
export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(function TextInput(
{ className, ...props },
ref,
) {
return <input ref={ref} className={mergeClassNames(styles.textInput, className)} {...props} />;
});
export type TextAreaProps = TextareaHTMLAttributes<HTMLTextAreaElement>;
export const TextArea = forwardRef<HTMLTextAreaElement, TextAreaProps>(function TextArea(
{ className, ...props },
ref,
) {
return <textarea ref={ref} className={mergeClassNames(styles.textArea, className)} {...props} />;
});
export function OptionCardGroup({
className,
children,
}: {
children: ReactNode;
className?: string;
}) {
return <div className={mergeClassNames(styles.optionCardGroup, className)}>{children}</div>;
}
export interface OptionCardProps
extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'title'> {
description?: ReactNode;
icon?: ReactNode;
selected?: boolean;
title?: ReactNode;
}
export const OptionCard = forwardRef<HTMLButtonElement, OptionCardProps>(function OptionCard(
{ className, children, description, icon, selected = false, title, type = 'button', ...props },
ref,
) {
return (
<button
ref={ref}
aria-pressed={selected}
className={mergeClassNames(
styles.optionCard,
selected && styles.optionCardSelected,
className,
)}
type={type}
{...props}
>
{children ?? (
<>
{icon ? <div className={styles.optionCardIcon}>{icon}</div> : null}
<div className={styles.optionCardBody}>
{title ? <div className={styles.optionCardTitle}>{title}</div> : null}
{description ? <div className={styles.optionCardDescription}>{description}</div> : null}
</div>
</>
)}
</button>
);
});
export function OptionButtonGroup({
className,
children,
}: {
children: ReactNode;
className?: string;
}) {
return <div className={mergeClassNames(styles.optionButtonGroup, className)}>{children}</div>;
}
export interface OptionButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
selected?: boolean;
}
export const OptionButton = forwardRef<HTMLButtonElement, OptionButtonProps>(function OptionButton(
{ className, children, selected = false, type = 'button', ...props },
ref,
) {
return (
<button
ref={ref}
aria-pressed={selected}
className={mergeClassNames(
styles.optionButton,
selected && styles.optionButtonSelected,
className,
)}
type={type}
{...props}
>
{children}
</button>
);
});
export function FormError({ className, message }: { className?: string; message?: ReactNode }) {
if (!message) return null;
return (
<div className={mergeClassNames(styles.formError, className)} role="alert">
{message}
</div>
);
}
export function FormActions({
className,
children,
}: {
children: ReactNode;
className?: string;
}) {
return <div className={mergeClassNames(styles.formActions, className)}>{children}</div>;
}

View file

@ -0,0 +1,11 @@
export {
FormActions,
FormError,
FormField,
OptionButton,
OptionButtonGroup,
OptionCard,
OptionCardGroup,
TextArea,
TextInput,
} from './form';

View file

@ -180,10 +180,26 @@ describe('issues route', () => {
fireEvent.click(await screen.findByRole('button', { name: /wrong cover art/i })); fireEvent.click(await screen.findByRole('button', { name: /wrong cover art/i }));
const titleInput = screen.getByLabelText(/title/i); const titleInput = screen.getByLabelText(/title/i);
const descriptionInput = screen.getByLabelText(/details/i);
const submitButton = screen.getByRole('button', { name: /submit issue/i });
const form = submitButton.closest('form');
expect(titleInput).toHaveValue('Wrong Cover Art: Album Name'); expect(titleInput).toHaveValue('Wrong Cover Art: Album Name');
fireEvent.change(titleInput, { target: { value: '' } });
expect(submitButton).toBeDisabled();
fireEvent.submit(form!);
await waitFor(() => {
expect(screen.getByRole('alert')).toHaveTextContent('Please provide a title for the issue');
});
fireEvent.change(titleInput, { target: { value: 'Custom report title' } }); fireEvent.change(titleInput, { target: { value: 'Custom report title' } });
fireEvent.click(screen.getByRole('button', { name: /missing tracks/i })); fireEvent.blur(titleInput);
fireEvent.change(descriptionInput, { target: { value: 'Detailed reproduction notes' } });
fireEvent.click(screen.getByRole('button', { name: /high/i }));
fireEvent.click(screen.getByRole('button', { name: /wrong metadata/i }));
expect(titleInput).toHaveValue('Custom report title'); expect(titleInput).toHaveValue('Custom report title');
expect(descriptionInput).toHaveValue('Detailed reproduction notes');
expect(screen.getByRole('button', { name: /high/i })).toHaveAttribute('aria-pressed', 'true');
fireEvent.click(screen.getByRole('button', { name: /submit issue/i })); fireEvent.click(screen.getByRole('button', { name: /submit issue/i }));
await waitFor(() => { await waitFor(() => {

View file

@ -828,92 +828,6 @@
font-size: 13px; font-size: 13px;
} }
.reportIssueCategoryGrid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 10px;
}
.reportIssueCategoryCard {
display: grid;
gap: 5px;
min-height: 122px;
text-align: left;
padding: 12px;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.04);
color: #fff;
cursor: pointer;
font-family: inherit;
}
.reportIssueCategoryCard:hover,
.reportIssueCategoryCardSelected {
border-color: rgba(var(--accent-light-rgb), 0.45);
background: rgba(var(--accent-light-rgb), 0.1);
}
.reportIssueCategoryIcon {
font-size: 18px;
}
.reportIssueCategoryLabel {
font-weight: 700;
font-size: 13px;
}
.reportIssueCategoryDesc {
color: rgba(255, 255, 255, 0.48);
font-size: 12px;
line-height: 1.4;
}
.reportIssueInput {
width: 100%;
box-sizing: border-box;
margin-bottom: 12px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
color: #fff;
padding: 10px 12px;
border-radius: 8px;
font-size: 13px;
outline: none;
font-family: inherit;
}
.reportIssuePriorityRow {
display: flex;
gap: 8px;
margin-top: 12px;
}
.reportIssuePriorityButton {
padding: 8px 12px;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.05);
color: rgba(255, 255, 255, 0.72);
cursor: pointer;
font-family: inherit;
}
.reportIssuePriorityButtonSelected {
border-color: rgba(var(--accent-light-rgb), 0.45);
background: rgba(var(--accent-light-rgb), 0.14);
color: #fff;
}
.reportIssueError {
color: #ffd0d0;
border: 1px solid rgba(239, 68, 68, 0.25);
background: rgba(239, 68, 68, 0.1);
border-radius: 10px;
padding: 10px 12px;
font-size: 13px;
}
.modalFooter { .modalFooter {
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
@ -1065,6 +979,11 @@
justify-content: space-between; justify-content: space-between;
} }
.issueDetailInfoLeft,
.issueDetailInfoRight {
width: 100%;
}
.modal { .modal {
max-height: calc(100vh - 24px); max-height: calc(100vh - 24px);
border-radius: 22px; border-radius: 22px;
@ -1075,18 +994,40 @@
} }
.modalFooter { .modalFooter {
flex-direction: column;
align-items: stretch;
padding: 0 18px 18px; padding: 0 18px 18px;
} }
.issueDetailMetaGrid { .issueDetailMetaGrid {
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(1, minmax(0, 1fr));
} }
.issueMetaValue { .issueMetaValue {
max-width: 80px; max-width: none;
} }
.issueActionButtons { .issueActionButtons {
flex-direction: column;
align-items: stretch;
justify-content: center;
}
.issueActionButton,
.modalButton {
width: 100%;
}
.issueActionButton {
justify-content: center;
}
.issueExternalLinks {
justify-content: center;
}
.issueExternalLink {
width: 100%;
justify-content: center; justify-content: center;
} }
} }

View file

@ -1,12 +1,23 @@
import { useForm } from '@tanstack/react-form'; import { useForm } from "@tanstack/react-form";
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from "react";
import { createPortal } from 'react-dom'; import { createPortal } from "react-dom";
import { getShellProfileContext } from '@/platform/shell/bridge'; import { getShellProfileContext } from "@/platform/shell/bridge";
import { useShellBridge } from '@/platform/shell/route-controllers'; import {
FormActions,
FormError,
FormField,
OptionButton,
OptionButtonGroup,
OptionCard,
OptionCardGroup,
TextArea,
TextInput,
} from "@/components/form";
import { useShellBridge } from "@/platform/shell/route-controllers";
import type { IssuePriority, IssueReportPayload } from '../-issues.types'; import type { IssuePriority, IssueReportPayload } from "../-issues.types";
import { import {
REFRESH_EVENT, REFRESH_EVENT,
@ -14,10 +25,10 @@ import {
createIssue, createIssue,
getIssueCategoriesForEntity, getIssueCategoriesForEntity,
issueCountsQueryOptions, issueCountsQueryOptions,
} from '../-issues.helpers'; } from "../-issues.helpers";
import styles from './issue-detail-modal.module.css'; import styles from "./issue-detail-modal.module.css";
const ISSUE_DOMAIN_QUERY_KEY = ['issues'] as const; const ISSUE_DOMAIN_QUERY_KEY = ["issues"] as const;
interface ReportIssueFormValues { interface ReportIssueFormValues {
category: string; category: string;
@ -27,10 +38,10 @@ interface ReportIssueFormValues {
} }
const DEFAULT_REPORT_ISSUE_VALUES: ReportIssueFormValues = { const DEFAULT_REPORT_ISSUE_VALUES: ReportIssueFormValues = {
category: '', category: "",
description: '', description: "",
priority: 'normal', priority: "normal",
title: '', title: "",
}; };
export function IssueDomainHost() { export function IssueDomainHost() {
@ -115,7 +126,7 @@ function ReportIssueModal({
[payload.entityType], [payload.entityType],
); );
const entityLabel = const entityLabel =
payload.entityType === 'track' ? 'Track' : payload.entityType === 'album' ? 'Album' : 'Artist'; payload.entityType === "track" ? "Track" : payload.entityType === "album" ? "Album" : "Artist";
const createMutation = useMutation({ const createMutation = useMutation({
mutationFn: async (values: ReportIssueFormValues) => { mutationFn: async (values: ReportIssueFormValues) => {
@ -129,7 +140,7 @@ function ReportIssueModal({
}); });
}, },
onSuccess: () => { onSuccess: () => {
notify('Issue reported successfully', 'success'); notify("Issue reported successfully", "success");
onSubmitted(); onSubmitted();
}, },
}); });
@ -149,9 +160,9 @@ function ReportIssueModal({
await createMutation.mutateAsync(normalizedValues); await createMutation.mutateAsync(normalizedValues);
} catch (mutationError) { } catch (mutationError) {
const message = const message =
mutationError instanceof Error ? mutationError.message : 'Failed to submit issue'; mutationError instanceof Error ? mutationError.message : "Failed to submit issue";
formApi.setErrorMap({ onSubmit: message }); formApi.setErrorMap({ onSubmit: message });
notify(message, 'error'); notify(message, "error");
throw mutationError; throw mutationError;
} }
}, },
@ -194,63 +205,56 @@ function ReportIssueModal({
{payload.artistName ? ( {payload.artistName ? (
<div className={styles.reportIssueEntityArtist}> <div className={styles.reportIssueEntityArtist}>
{payload.artistName} {payload.artistName}
{payload.albumTitle ? ` - ${payload.albumTitle}` : ''} {payload.albumTitle ? ` - ${payload.albumTitle}` : ""}
</div> </div>
) : null} ) : null}
</div> </div>
<div className={styles.issueDetailSection}> <FormField
<label className={styles.issueDetailSectionTitle}>What's the problem?</label> label="What's the problem?"
<div className={styles.reportIssueCategoryGrid}> helperText="Pick the closest match for the report."
<form.Field name="category"> >
{(field) => <form.Field name="category">
categories.map(([category, meta]) => ( {(field) => (
<button <OptionCardGroup>
{categories.map(([category, meta]) => (
<OptionCard
key={category} key={category}
className={`${styles.reportIssueCategoryCard} ${ description={meta.description}
field.state.value === category icon={meta.icon}
? styles.reportIssueCategoryCardSelected
: ''
}`}
type="button"
onClick={() => { onClick={() => {
field.handleChange(category); field.handleChange(category);
createMutation.reset(); createMutation.reset();
form.setErrorMap({ onSubmit: undefined }); form.setErrorMap({ onSubmit: undefined });
if (!form.getFieldMeta('title')?.isTouched) { if (!form.getFieldMeta("title")?.isTouched) {
form.setFieldValue( form.setFieldValue(
'title', "title",
createDefaultIssueTitle(category, payload.entityName), createDefaultIssueTitle(category, payload.entityName),
{ dontUpdateMeta: true }, { dontUpdateMeta: true },
); );
} }
}} }}
> selected={field.state.value === category}
<div className={styles.reportIssueCategoryIcon}>{meta.icon}</div> title={meta.label}
<div className={styles.reportIssueCategoryLabel}>{meta.label}</div> />
<div className={styles.reportIssueCategoryDesc}>{meta.description}</div> ))}
</button> </OptionCardGroup>
)) )}
} </form.Field>
</form.Field> </FormField>
</div>
</div>
<form.Subscribe selector={(state) => state.values.category}> <form.Subscribe selector={(state) => state.values.category}>
{(selectedCategory) => {(selectedCategory) =>
selectedCategory ? ( selectedCategory ? (
<div className={styles.issueDetailSection}> <>
<form.Field name="title"> <form.Field name="title">
{(field) => ( {(field) => (
<> <FormField
<label helperText="A short summary that makes the problem obvious."
className={styles.issueDetailSectionTitle} htmlFor="report-issue-title-input"
htmlFor="report-issue-title-input" label="Title"
> >
Title <TextInput
</label>
<input
className={styles.reportIssueInput}
id="report-issue-title-input" id="report-issue-title-input"
maxLength={200} maxLength={200}
onBlur={field.handleBlur} onBlur={field.handleBlur}
@ -262,20 +266,18 @@ function ReportIssueModal({
placeholder="Brief summary of the issue..." placeholder="Brief summary of the issue..."
value={field.state.value} value={field.state.value}
/> />
</> </FormField>
)} )}
</form.Field> </form.Field>
<form.Field name="description"> <form.Field name="description">
{(field) => ( {(field) => (
<> <FormField
<label helperText="Include any details that will help triage the issue."
className={styles.issueDetailSectionTitle} htmlFor="report-issue-desc-input"
htmlFor="report-issue-desc-input" label="Details"
> >
Details <TextArea
</label>
<textarea
className={styles.issueDetailResponseTextarea}
id="report-issue-desc-input" id="report-issue-desc-input"
maxLength={2000} maxLength={2000}
onBlur={field.handleBlur} onBlur={field.handleBlur}
@ -284,31 +286,32 @@ function ReportIssueModal({
rows={4} rows={4}
value={field.state.value} value={field.state.value}
/> />
</> </FormField>
)} )}
</form.Field> </form.Field>
<form.Field name="priority"> <form.Field name="priority">
{(field) => ( {(field) => (
<div className={styles.reportIssuePriorityRow} aria-label="Priority"> <FormField
{(['low', 'normal', 'high'] as const).map((priority) => ( helperText="Set the urgency if this needs faster attention."
<button label="Priority"
key={priority} >
className={`${styles.reportIssuePriorityButton} ${ <OptionButtonGroup>
field.state.value === priority {(["low", "normal", "high"] as const).map((priority) => (
? styles.reportIssuePriorityButtonSelected <OptionButton
: '' key={priority}
}`} onClick={() => field.handleChange(priority)}
type="button" selected={field.state.value === priority}
onClick={() => field.handleChange(priority)} >
> {priority[0].toUpperCase()}
{priority[0].toUpperCase()} {priority.slice(1)}
{priority.slice(1)} </OptionButton>
</button> ))}
))} </OptionButtonGroup>
</div> </FormField>
)} )}
</form.Field> </form.Field>
</div> </>
) : null ) : null
} }
</form.Subscribe> </form.Subscribe>
@ -316,12 +319,12 @@ function ReportIssueModal({
<form.Subscribe selector={(state) => state.errors}> <form.Subscribe selector={(state) => state.errors}>
{(errors) => { {(errors) => {
const error = getReportIssueFormError(errors); const error = getReportIssueFormError(errors);
return error ? <div className={styles.reportIssueError}>{error}</div> : null; return <FormError message={error} />;
}} }}
</form.Subscribe> </form.Subscribe>
</div> </div>
<div className={styles.modalFooter}> <FormActions className={styles.modalFooter}>
<button <button
className={`${styles.modalButton} ${styles.modalButtonSecondary}`} className={`${styles.modalButton} ${styles.modalButtonSecondary}`}
type="button" type="button"
@ -344,12 +347,12 @@ function ReportIssueModal({
type="submit" type="submit"
disabled={!state.category || !state.title.trim() || isSubmitting} disabled={!state.category || !state.title.trim() || isSubmitting}
> >
{isSubmitting ? 'Submitting...' : 'Submit Issue'} {isSubmitting ? "Submitting..." : "Submit Issue"}
</button> </button>
); );
}} }}
</form.Subscribe> </form.Subscribe>
</div> </FormActions>
</form> </form>
</div> </div>
); );
@ -369,27 +372,27 @@ function validateReportIssueForm(
values: ReportIssueFormValues, values: ReportIssueFormValues,
): string | undefined { ): string | undefined {
const normalizedValues = normalizeReportIssueFormValues(values); const normalizedValues = normalizeReportIssueFormValues(values);
if (!profileId) return 'Profile is still loading'; if (!profileId) return "Profile is still loading";
if (!normalizedValues.category) return 'Please select an issue category'; if (!normalizedValues.category) return "Please select an issue category";
if (!normalizedValues.title) return 'Please provide a title for the issue'; if (!normalizedValues.title) return "Please provide a title for the issue";
return undefined; return undefined;
} }
function getReportIssueFormError(errors: Array<unknown>): string { function getReportIssueFormError(errors: Array<unknown>): string {
const error = errors.find(Boolean); const error = errors.find(Boolean);
if (!error) return ''; if (!error) return "";
if (typeof error === 'string') return error; if (typeof error === "string") return error;
if (error instanceof Error) return error.message; if (error instanceof Error) return error.message;
return String(error); return String(error);
} }
function notify(message: string, type: 'success' | 'error' | 'warning' | 'info' = 'info') { function notify(message: string, type: "success" | "error" | "warning" | "info" = "info") {
window.showToast?.(message, type); window.showToast?.(message, type);
} }
function updateBadge(openCount: number) { function updateBadge(openCount: number) {
const badge = document.getElementById('issues-nav-badge'); const badge = document.getElementById("issues-nav-badge");
if (!badge) return; if (!badge) return;
badge.textContent = String(openCount || 0); badge.textContent = String(openCount || 0);
badge.classList.toggle('hidden', !openCount); badge.classList.toggle("hidden", !openCount);
} }

View file

@ -828,6 +828,14 @@
padding: 18px; padding: 18px;
} }
.issuesHeaderRight {
width: 100%;
}
.issuesFilters {
width: 100%;
}
.issuesFilterSelect { .issuesFilterSelect {
min-width: 100%; min-width: 100%;
width: 100%; width: 100%;