refactor(webui): theme shared import controls
- add a shared switch primitive for theme-aware toggle styling\n- keep import-page buttons leaning on shared variants instead of local color rules\n- simplify the singles and auto-import controls around the shared form layer
This commit is contained in:
parent
af3d51c2ed
commit
ae0711f464
6 changed files with 96 additions and 46 deletions
|
|
@ -183,6 +183,62 @@
|
||||||
transform: translateY(-0.5px) scaleX(1.18);
|
transform: translateY(-0.5px) scaleX(1.18);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.switch {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
width: 44px;
|
||||||
|
height: 24px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 3px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.16);
|
||||||
|
border-radius: 999px;
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(255, 255, 255, 0.08), rgba(255, 255, 255, 0.04)),
|
||||||
|
rgba(255, 255, 255, 0.06);
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
border-color 0.18s ease,
|
||||||
|
background 0.18s ease,
|
||||||
|
box-shadow 0.18s ease,
|
||||||
|
transform 0.18s ease;
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch[data-checked] {
|
||||||
|
border-color: rgba(var(--accent-light-rgb), 0.55);
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(var(--accent-light-rgb), 0.55), rgba(var(--accent-rgb), 0.8)),
|
||||||
|
rgba(var(--accent-rgb), 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch[data-focused] {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: 0 0 0 3px rgba(var(--accent-light-rgb), 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch[data-disabled] {
|
||||||
|
opacity: 0.55;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switchThumb {
|
||||||
|
display: block;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.25);
|
||||||
|
transform: translateX(0);
|
||||||
|
transition: transform 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch[data-checked] .switchThumb {
|
||||||
|
transform: translateX(20px);
|
||||||
|
}
|
||||||
|
|
||||||
.rangeRoot {
|
.rangeRoot {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
flex: 0 0 160px;
|
flex: 0 0 160px;
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import {
|
||||||
OptionCardGroup,
|
OptionCardGroup,
|
||||||
RangeInput,
|
RangeInput,
|
||||||
Select,
|
Select,
|
||||||
|
Switch,
|
||||||
TextArea,
|
TextArea,
|
||||||
TextInput,
|
TextInput,
|
||||||
} from './form';
|
} from './form';
|
||||||
|
|
@ -25,6 +26,7 @@ function FormDemo() {
|
||||||
const [priority, setPriority] = useState<'low' | 'normal' | 'high'>('normal');
|
const [priority, setPriority] = useState<'low' | 'normal' | 'high'>('normal');
|
||||||
const [status, setStatus] = useState('open');
|
const [status, setStatus] = useState('open');
|
||||||
const [archive, setArchive] = useState(false);
|
const [archive, setArchive] = useState(false);
|
||||||
|
const [enabled, setEnabled] = useState(true);
|
||||||
const [confidence, setConfidence] = useState(90);
|
const [confidence, setConfidence] = useState(90);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -98,6 +100,10 @@ function FormDemo() {
|
||||||
<Checkbox checked={archive} onCheckedChange={setArchive} />
|
<Checkbox checked={archive} onCheckedChange={setArchive} />
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
|
<FormField label="Enabled" helperText="Shared switch primitive">
|
||||||
|
<Switch checked={enabled} onCheckedChange={setEnabled} />
|
||||||
|
</FormField>
|
||||||
|
|
||||||
<FormField label="Confidence" helperText="Shared range primitive">
|
<FormField label="Confidence" helperText="Shared range primitive">
|
||||||
<RangeInput
|
<RangeInput
|
||||||
label="Confidence"
|
label="Confidence"
|
||||||
|
|
@ -137,6 +143,11 @@ describe('form primitives', () => {
|
||||||
fireEvent.click(archiveCheckbox);
|
fireEvent.click(archiveCheckbox);
|
||||||
expect(archiveCheckbox).toBeChecked();
|
expect(archiveCheckbox).toBeChecked();
|
||||||
|
|
||||||
|
const enabledSwitch = screen.getByRole('switch', { name: 'Enabled' });
|
||||||
|
expect(enabledSwitch).toBeChecked();
|
||||||
|
fireEvent.click(enabledSwitch);
|
||||||
|
expect(enabledSwitch).not.toBeChecked();
|
||||||
|
|
||||||
const confidenceSlider = screen.getByLabelText('Confidence', { selector: 'input' });
|
const confidenceSlider = screen.getByLabelText('Confidence', { selector: 'input' });
|
||||||
expect(confidenceSlider).toHaveValue('90');
|
expect(confidenceSlider).toHaveValue('90');
|
||||||
fireEvent.change(confidenceSlider, { target: { value: '75' } });
|
fireEvent.change(confidenceSlider, { target: { value: '75' } });
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { Checkbox as BaseCheckbox } from '@base-ui/react/checkbox';
|
||||||
import { Field } from '@base-ui/react/field';
|
import { Field } from '@base-ui/react/field';
|
||||||
import { Input as BaseInput } from '@base-ui/react/input';
|
import { Input as BaseInput } from '@base-ui/react/input';
|
||||||
import { Slider } from '@base-ui/react/slider';
|
import { Slider } from '@base-ui/react/slider';
|
||||||
|
import { Switch as BaseSwitch } from '@base-ui/react/switch';
|
||||||
import { Toggle as BaseToggle } from '@base-ui/react/toggle';
|
import { Toggle as BaseToggle } from '@base-ui/react/toggle';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import {
|
import {
|
||||||
|
|
@ -106,6 +107,23 @@ export const Checkbox = forwardRef<HTMLElement, CheckboxProps>(function Checkbox
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
type BaseSwitchProps = ComponentPropsWithoutRef<typeof BaseSwitch.Root>;
|
||||||
|
|
||||||
|
export type SwitchProps = Omit<BaseSwitchProps, 'className' | 'children'> & {
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Switch = forwardRef<HTMLElement, SwitchProps>(function Switch(
|
||||||
|
{ className, ...props },
|
||||||
|
ref,
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<BaseSwitch.Root ref={ref} className={clsx(styles.switch, className)} {...props}>
|
||||||
|
<BaseSwitch.Thumb className={styles.switchThumb} />
|
||||||
|
</BaseSwitch.Root>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
export interface RangeInputProps {
|
export interface RangeInputProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
import { Button, RangeInput, Select } from '@/components/form/form';
|
import { Button, RangeInput, Select, Switch } from '@/components/form/form';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
ImportAutoFilter,
|
ImportAutoFilter,
|
||||||
|
|
@ -155,17 +155,16 @@ export function AutoImportPanel({
|
||||||
<>
|
<>
|
||||||
<div className={styles.autoImportControls}>
|
<div className={styles.autoImportControls}>
|
||||||
<div className={styles.autoImportToggleRow}>
|
<div className={styles.autoImportToggleRow}>
|
||||||
<label className={styles.autoImportToggleLabel}>
|
<div className={styles.autoImportToggleLabel}>
|
||||||
<input
|
<Switch
|
||||||
type="checkbox"
|
|
||||||
id="auto-import-enabled"
|
|
||||||
checked={Boolean(statusQuery.data?.running)}
|
checked={Boolean(statusQuery.data?.running)}
|
||||||
disabled={toggleMutation.isPending}
|
disabled={toggleMutation.isPending}
|
||||||
onChange={(event) => toggleMutation.mutate(event.target.checked)}
|
aria-labelledby="auto-import-toggle-label"
|
||||||
|
id="auto-import-enabled"
|
||||||
|
onCheckedChange={(checked) => toggleMutation.mutate(checked)}
|
||||||
/>
|
/>
|
||||||
<span className={styles.autoImportToggleSlider} />
|
<span id="auto-import-toggle-label">Auto-Import</span>
|
||||||
<span>Auto-Import</span>
|
</div>
|
||||||
</label>
|
|
||||||
<span
|
<span
|
||||||
className={`${styles.autoImportStatus} ${styles[statusClassName]}`}
|
className={`${styles.autoImportStatus} ${styles[statusClassName]}`}
|
||||||
id="auto-import-status-text"
|
id="auto-import-status-text"
|
||||||
|
|
|
||||||
|
|
@ -1024,46 +1024,11 @@
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
cursor: pointer;
|
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: rgba(255, 255, 255, 0.7);
|
color: rgba(255, 255, 255, 0.7);
|
||||||
}
|
}
|
||||||
|
|
||||||
.autoImportToggleLabel input {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.autoImportToggleSlider {
|
|
||||||
position: relative;
|
|
||||||
width: 44px;
|
|
||||||
height: 24px;
|
|
||||||
background: rgba(255, 255, 255, 0.12);
|
|
||||||
border-radius: 12px;
|
|
||||||
transition: background 0.2s ease;
|
|
||||||
display: inline-block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.autoImportToggleSlider::after {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
top: 3px;
|
|
||||||
left: 3px;
|
|
||||||
width: 18px;
|
|
||||||
height: 18px;
|
|
||||||
background: #fff;
|
|
||||||
border-radius: 50%;
|
|
||||||
transition: transform 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.autoImportToggleLabel input:checked + .autoImportToggleSlider {
|
|
||||||
background: var(--accent-color, #6366f1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.autoImportToggleLabel input:checked + .autoImportToggleSlider::after {
|
|
||||||
transform: translateX(20px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.autoImportStatus {
|
.autoImportStatus {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
|
@ -1102,7 +1067,7 @@
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
flex: 0 0 5ch;
|
flex: 0 0 5ch;
|
||||||
width: 5ch;
|
width: 5ch;
|
||||||
text-align: right;
|
text-align: center;
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
.autoImportEmpty {
|
.autoImportEmpty {
|
||||||
|
|
|
||||||
|
|
@ -169,6 +169,7 @@ export function SinglesImportPanel({
|
||||||
}) {
|
}) {
|
||||||
const selectedCount = files.filter((file) => selected.has(getStagingFileKey(file))).length;
|
const selectedCount = files.filter((file) => selected.has(getStagingFileKey(file))).length;
|
||||||
const allSelected = files.length > 0 && selectedCount === files.length;
|
const allSelected = files.length > 0 && selectedCount === files.length;
|
||||||
|
const processVariant = selectedCount > 0 ? 'primary' : 'default';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
@ -181,7 +182,7 @@ export function SinglesImportPanel({
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="primary"
|
variant={processVariant}
|
||||||
id="import-page-singles-process-btn"
|
id="import-page-singles-process-btn"
|
||||||
disabled={selectedCount === 0}
|
disabled={selectedCount === 0}
|
||||||
onClick={onProcessSingles}
|
onClick={onProcessSingles}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue