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;
|
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 {
|
.optionCardGroup {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
|
||||||
|
|
@ -111,7 +170,9 @@
|
||||||
background:
|
background:
|
||||||
linear-gradient(180deg, rgba(var(--accent-rgb), 0.18), rgba(255, 255, 255, 0.04)),
|
linear-gradient(180deg, rgba(var(--accent-rgb), 0.18), rgba(255, 255, 255, 0.04)),
|
||||||
rgba(255, 255, 255, 0.05);
|
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 {
|
.optionCardIcon {
|
||||||
|
|
@ -177,6 +238,48 @@
|
||||||
box-shadow: 0 0 0 1px rgba(var(--accent-light-rgb), 0.08);
|
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 {
|
.formError {
|
||||||
color: #ff8d8d;
|
color: #ff8d8d;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { useState } from 'react';
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
Button,
|
||||||
FormActions,
|
FormActions,
|
||||||
FormError,
|
FormError,
|
||||||
FormField,
|
FormField,
|
||||||
|
|
@ -10,6 +11,7 @@ import {
|
||||||
OptionButtonGroup,
|
OptionButtonGroup,
|
||||||
OptionCard,
|
OptionCard,
|
||||||
OptionCardGroup,
|
OptionCardGroup,
|
||||||
|
Select,
|
||||||
TextArea,
|
TextArea,
|
||||||
TextInput,
|
TextInput,
|
||||||
} from './form';
|
} from './form';
|
||||||
|
|
@ -19,6 +21,7 @@ function FormDemo() {
|
||||||
const [details, setDetails] = useState('');
|
const [details, setDetails] = useState('');
|
||||||
const [category, setCategory] = useState<'wrong_cover' | 'wrong_metadata'>('wrong_cover');
|
const [category, setCategory] = useState<'wrong_cover' | 'wrong_metadata'>('wrong_cover');
|
||||||
const [priority, setPriority] = useState<'low' | 'normal' | 'high'>('normal');
|
const [priority, setPriority] = useState<'low' | 'normal' | 'high'>('normal');
|
||||||
|
const [status, setStatus] = useState('open');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form>
|
<form>
|
||||||
|
|
@ -71,11 +74,23 @@ function FormDemo() {
|
||||||
</OptionButtonGroup>
|
</OptionButtonGroup>
|
||||||
</FormField>
|
</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" />
|
<FormError message="Validation failed" />
|
||||||
|
|
||||||
<FormActions>
|
<FormActions>
|
||||||
<button type="button">Cancel</button>
|
<Button type="button">Cancel</Button>
|
||||||
<button type="submit">Save</button>
|
<Button type="submit">Save</Button>
|
||||||
</FormActions>
|
</FormActions>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
|
|
@ -89,6 +104,9 @@ describe('form primitives', () => {
|
||||||
expect(screen.getByLabelText('Details')).toHaveAttribute('placeholder', 'Enter details');
|
expect(screen.getByLabelText('Details')).toHaveAttribute('placeholder', 'Enter details');
|
||||||
expect(screen.getByText('Short summary')).toBeInTheDocument();
|
expect(screen.getByText('Short summary')).toBeInTheDocument();
|
||||||
expect(screen.getByRole('alert')).toHaveTextContent('Validation failed');
|
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 wrongCover = screen.getByRole('button', { name: /wrong cover/i });
|
||||||
const wrongMetadata = screen.getByRole('button', { name: /wrong metadata/i });
|
const wrongMetadata = screen.getByRole('button', { name: /wrong metadata/i });
|
||||||
|
|
@ -102,5 +120,8 @@ describe('form primitives', () => {
|
||||||
expect(highPriority).toHaveAttribute('aria-pressed', 'false');
|
expect(highPriority).toHaveAttribute('aria-pressed', 'false');
|
||||||
fireEvent.click(highPriority);
|
fireEvent.click(highPriority);
|
||||||
expect(highPriority).toHaveAttribute('aria-pressed', 'true');
|
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,
|
forwardRef,
|
||||||
type ButtonHTMLAttributes,
|
type ButtonHTMLAttributes,
|
||||||
type InputHTMLAttributes,
|
type InputHTMLAttributes,
|
||||||
|
type SelectHTMLAttributes,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
type TextareaHTMLAttributes,
|
type TextareaHTMLAttributes,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
|
|
@ -65,6 +66,15 @@ export const TextArea = forwardRef<HTMLTextAreaElement, TextAreaProps>(function
|
||||||
return <textarea ref={ref} className={mergeClassNames(styles.textArea, className)} {...props} />;
|
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({
|
export function OptionCardGroup({
|
||||||
className,
|
className,
|
||||||
children,
|
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 }) {
|
export function FormError({ className, message }: { className?: string; message?: ReactNode }) {
|
||||||
if (!message) return null;
|
if (!message) return null;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
export {
|
export {
|
||||||
|
Button,
|
||||||
FormActions,
|
FormActions,
|
||||||
FormError,
|
FormError,
|
||||||
FormField,
|
FormField,
|
||||||
|
|
@ -6,6 +7,7 @@ export {
|
||||||
OptionButtonGroup,
|
OptionButtonGroup,
|
||||||
OptionCard,
|
OptionCard,
|
||||||
OptionCardGroup,
|
OptionCardGroup,
|
||||||
|
Select,
|
||||||
TextArea,
|
TextArea,
|
||||||
TextInput,
|
TextInput,
|
||||||
} from './form';
|
} from './form';
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { createPortal } from "react-dom";
|
||||||
|
|
||||||
import { getShellProfileContext } from "@/platform/shell/bridge";
|
import { getShellProfileContext } from "@/platform/shell/bridge";
|
||||||
import {
|
import {
|
||||||
|
Button,
|
||||||
FormActions,
|
FormActions,
|
||||||
FormError,
|
FormError,
|
||||||
FormField,
|
FormField,
|
||||||
|
|
@ -325,13 +326,13 @@ function ReportIssueModal({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<FormActions className={styles.modalFooter}>
|
<FormActions className={styles.modalFooter}>
|
||||||
<button
|
<Button
|
||||||
className={`${styles.modalButton} ${styles.modalButtonSecondary}`}
|
className={`${styles.modalButton} ${styles.modalButtonSecondary}`}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</Button>
|
||||||
<form.Subscribe
|
<form.Subscribe
|
||||||
selector={(state) => ({
|
selector={(state) => ({
|
||||||
category: state.values.category,
|
category: state.values.category,
|
||||||
|
|
@ -342,13 +343,13 @@ function ReportIssueModal({
|
||||||
{(state) => {
|
{(state) => {
|
||||||
const isSubmitting = state.isSubmitting || createMutation.isPending;
|
const isSubmitting = state.isSubmitting || createMutation.isPending;
|
||||||
return (
|
return (
|
||||||
<button
|
<Button
|
||||||
className={`${styles.modalButton} ${styles.modalButtonPrimary}`}
|
className={`${styles.modalButton} ${styles.modalButtonPrimary}`}
|
||||||
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>
|
||||||
|
|
|
||||||
|
|
@ -45,36 +45,9 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.issuesFilterSelect {
|
.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;
|
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 {
|
.issuesStats {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { useNavigate } from '@tanstack/react-router';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
import { getShellProfileContext } from '@/platform/shell/bridge';
|
import { getShellProfileContext } from '@/platform/shell/bridge';
|
||||||
|
import { Select } from '@/components/form';
|
||||||
import { useReactPageShell } from '@/platform/shell/route-controllers';
|
import { useReactPageShell } from '@/platform/shell/route-controllers';
|
||||||
|
|
||||||
import type { IssueCounts, IssueRecord, IssueStatus } from '../-issues.types';
|
import type { IssueCounts, IssueRecord, IssueStatus } from '../-issues.types';
|
||||||
|
|
@ -158,7 +159,7 @@ function IssueBoard({
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.issuesHeaderRight}>
|
<div className={styles.issuesHeaderRight}>
|
||||||
<div className={styles.issuesFilters} id="issues-filters">
|
<div className={styles.issuesFilters} id="issues-filters">
|
||||||
<select
|
<Select
|
||||||
id="issues-filter-status"
|
id="issues-filter-status"
|
||||||
className={styles.issuesFilterSelect}
|
className={styles.issuesFilterSelect}
|
||||||
aria-label="Status"
|
aria-label="Status"
|
||||||
|
|
@ -170,8 +171,8 @@ function IssueBoard({
|
||||||
<option value="in_progress">In Progress</option>
|
<option value="in_progress">In Progress</option>
|
||||||
<option value="resolved">Resolved</option>
|
<option value="resolved">Resolved</option>
|
||||||
<option value="dismissed">Dismissed</option>
|
<option value="dismissed">Dismissed</option>
|
||||||
</select>
|
</Select>
|
||||||
<select
|
<Select
|
||||||
id="issues-filter-category"
|
id="issues-filter-category"
|
||||||
className={styles.issuesFilterSelect}
|
className={styles.issuesFilterSelect}
|
||||||
aria-label="Category"
|
aria-label="Category"
|
||||||
|
|
@ -195,7 +196,7 @@ function IssueBoard({
|
||||||
<option value="wrong_metadata">Wrong Metadata</option>
|
<option value="wrong_metadata">Wrong Metadata</option>
|
||||||
<option value="other">Other</option>
|
<option value="other">Other</option>
|
||||||
</optgroup>
|
</optgroup>
|
||||||
</select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue