feat: replace checkboxes with Switch component for better UI consistency

- Updated DoctorDetailPage, MySecretariesPage, RecordNumberSettingsPage, RepresentationsPage, ResourcePoolsPage, ResourceTypesPage, SecretariesPage, SecretaryDetailPage, SettingsPage, SkillsPage, SmsWalletPage, and TagsSettingsPage to use the new Switch component instead of native checkboxes.
- Enhanced accessibility by ensuring the Switch component uses appropriate roles and labels.
- Added tests for the new Switch component to ensure functionality and accessibility compliance.
- Updated styles to accommodate the new Switch component design.
This commit is contained in:
hamed
2026-08-05 16:09:46 +03:30
parent f9bbdf1e7c
commit 0e7970d6e0
38 changed files with 634 additions and 350 deletions
@@ -4,6 +4,7 @@ import { toast } from 'sonner';
import { api } from '../../lib/api';
import type { ApiResponse } from '../../lib/api';
import Modal from './Modal';
import Switch from './Switch';
/** envelope کامل — همان چیزی که بک‌اند برمی‌گرداند، بدون flatten. */
export interface PermissionEnvelope {
@@ -146,15 +147,14 @@ export default function DoctorPermissionsModal({ clinicUuid, doctorUuid, doctorN
<p className="muted">در حال بارگذاری...</p>
) : (
<>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 14 }}>
<input
type="checkbox"
<div style={{ marginBottom: 14 }}>
<Switch
checked={active}
onChange={() => setActive(v => !v)}
style={{ width: 16, height: 16, accentColor: 'var(--primary)', cursor: 'pointer' }}
onChange={setActive}
label="دسترسی این پزشک به کلینیک فعال باشد"
hint="خاموش‌کردنش کل جدول زیر را بی‌اثر می‌کند."
/>
<span>دسترسی این پزشک به کلینیک فعال باشد</span>
</label>
</div>
<div style={{ overflowX: 'auto' }}>
<table className="t">
@@ -177,14 +177,17 @@ export default function DoctorPermissionsModal({ clinicUuid, doctorUuid, doctorN
return <td key={action} style={{ textAlign: 'center', color: 'var(--border)' }}></td>;
}
return (
<td key={action} style={{ textAlign: 'center' }}>
<input
type="checkbox"
disabled={!active}
checked={resources[resource]?.[action] ?? false}
onChange={() => toggle(resource, action)}
style={{ width: 16, height: 16, accentColor: 'var(--primary)', cursor: 'pointer' }}
/>
<td key={action}>
{/* سوییچ در سلول جدول: بدون برچسبِ دیداری، پس نامِ
دسترسی‌پذیر از ترکیب بخش و ستون ساخته می‌شود. */}
<div style={{ display: 'flex', justifyContent: 'center' }}>
<Switch
disabled={!active}
checked={resources[resource]?.[action] ?? false}
onChange={() => toggle(resource, action)}
ariaLabel={`${config.label}${ACTION_HEADERS[ACTION_COLUMNS.indexOf(action)]}`}
/>
</div>
</td>
);
})}
@@ -0,0 +1,63 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import Switch from './Switch';
/**
* سوییچ تنها شکل مجاز ورودی boolean در پنل است؛ چک‌باکس نیتیو جایی ندارد.
* این تست‌ها قرارداد آن را قفل می‌کنند: نقش `switch`، نامِ دسترسی‌پذیرِ کوتاه،
* و ظاهرِ آمده از کلاس‌های دیزاین‌سیستم.
*/
describe('Switch', () => {
it('نقشش switch است نه checkbox', () => {
render(<Switch checked={false} onChange={() => {}} label="فعال است" />);
expect(screen.getByRole('switch', { name: 'فعال است' })).toBeInTheDocument();
expect(screen.queryByRole('checkbox')).not.toBeInTheDocument();
});
it('ظاهرش از کلاس‌های دیزاین‌سیستم می‌آید', () => {
const { container } = render(<Switch checked onChange={() => {}} ariaLabel="وضعیت" />);
expect(container.querySelector('.switch')).not.toBeNull();
expect(container.querySelector('.switch-track .switch-thumb')).not.toBeNull();
});
it('کلیک مقدار تازه را می‌دهد، نه رویداد خام', () => {
const onChange = vi.fn();
render(<Switch checked={false} onChange={onChange} label="فعال است" />);
fireEvent.click(screen.getByRole('switch'));
expect(onChange).toHaveBeenCalledWith(true);
});
/** متنِ `<label>` شاملِ hint می‌شود؛ نامِ دسترسی‌پذیر باید همان برچسبِ کوتاه بماند. */
it('hint وارد نام دسترسی‌پذیر نمی‌شود', () => {
render(<Switch checked onChange={() => {}} label="فعال است" hint="توضیح بلند و اضافی" />);
expect(screen.getByRole('switch', { name: 'فعال است' })).toBeInTheDocument();
expect(screen.getByText('توضیح بلند و اضافی')).toBeInTheDocument();
});
it('کلیک روی برچسب هم سوییچ را می‌زند', () => {
const onChange = vi.fn();
render(<Switch checked={false} onChange={onChange} label="فعال است" />);
fireEvent.click(screen.getByText('فعال است'));
expect(onChange).toHaveBeenCalledWith(true);
});
/* `fireEvent.click` در jsdom قید `disabled` را دور می‌زند، پس خودِ صفت سنجیده
می‌شود — همان چیزی که مرورگر واقعی به آن تکیه می‌کند. */
it('در حالت غیرفعال، ورودی قفل است', () => {
render(<Switch checked={false} onChange={() => {}} label="فعال است" disabled />);
expect(screen.getByRole('switch')).toBeDisabled();
});
/** بدون برچسبِ دیداری — سلول جدول و ردیف فشرده — نام از `ariaLabel` می‌آید. */
it('بدون label، نام از ariaLabel می‌آید', () => {
render(<Switch checked onChange={() => {}} ariaLabel="دسترسی نوبت‌ها" />);
expect(screen.getByRole('switch', { name: 'دسترسی نوبت‌ها' })).toBeInTheDocument();
});
});
+87
View File
@@ -0,0 +1,87 @@
import React, { useId } from 'react';
interface Props {
checked: boolean;
onChange: (checked: boolean) => void;
/** متن کنار سوییچ. بدون آن، `ariaLabel` الزامی است. */
label?: React.ReactNode;
/** توضیح یک‌خطی زیر برچسب. */
hint?: React.ReactNode;
disabled?: boolean;
/**
* چیدمان فشرده: سوییچ و متن کنار هم، بدون کش‌آمدن تا عرض والد.
* برای ردیف فهرست و سلول جدول. پیش‌فرض (`false`) ردیف تنظیمات است:
* متن راست، سوییچ چپ.
*/
inline?: boolean;
ariaLabel?: string;
id?: string;
}
/**
* سوییچ فعال/غیرفعال — تنها شکل مجاز ورودی boolean در پنل.
*
* چک‌باکس نیتیو در این پنل استفاده نمی‌شود: ظاهرش را مرورگر تعیین می‌کند، با تم تیره
* و توکن‌های رنگی نمی‌خواند، و ارتفاعش زیر هدف لمسی می‌ماند. کلاس‌های `.switch` در
* `styles.css` همین حالا ظاهر درست را دارند؛ این کامپوننت فقط آن‌ها را یک‌جا و با
* برچسبِ متصل بسته‌بندی می‌کند.
*
* `input` نیتیو زیر لایهٔ ظاهری می‌ماند تا کیبورد و صفحه‌خوان و `:focus-visible`
* دست‌نخورده کار کنند.
*/
export default function Switch({
checked, onChange, label, hint, disabled, inline, ariaLabel, id,
}: Props) {
const autoId = useId();
const inputId = id ?? autoId;
const control = (
<span className="switch">
<input
id={inputId}
type="checkbox"
role="switch"
checked={checked}
disabled={disabled}
/* نامِ دسترسی‌پذیر صریح: متنِ `<label>` شاملِ `hint` هم می‌شود و نامی می‌سازد
که هیچ‌کس به آن فکر نکرده. با `aria-label`، نام همان برچسبِ کوتاه می‌ماند. */
aria-label={ariaLabel ?? (typeof label === 'string' ? label : undefined)}
onChange={(e) => onChange(e.target.checked)}
/>
<span className="switch-track"><span className="switch-thumb" /></span>
</span>
);
if (label === undefined) return control;
return (
<label
htmlFor={inputId}
style={{
display: 'flex',
alignItems: 'center',
gap: inline ? 8 : 12,
justifyContent: inline ? 'flex-start' : 'space-between',
width: inline ? undefined : '100%',
fontSize: 13.5,
cursor: disabled ? 'not-allowed' : 'pointer',
opacity: disabled ? 0.6 : undefined,
}}
>
{inline ? (
<>
{control}
<span style={{ color: 'var(--text)' }}>{label}</span>
</>
) : (
<>
<span>
<span style={{ display: 'block', color: 'var(--text)' }}>{label}</span>
{hint && <span className="field-hint" style={{ marginTop: 2 }}>{hint}</span>}
</span>
{control}
</>
)}
</label>
);
}