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:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user