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:
parent
a19514dae9
commit
9572035837
7 changed files with 158 additions and 38 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
export {
|
||||
Button,
|
||||
FormActions,
|
||||
FormError,
|
||||
FormField,
|
||||
|
|
@ -6,6 +7,7 @@ export {
|
|||
OptionButtonGroup,
|
||||
OptionCard,
|
||||
OptionCardGroup,
|
||||
Select,
|
||||
TextArea,
|
||||
TextInput,
|
||||
} from './form';
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue