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
+7 -18
View File
@@ -30,6 +30,7 @@ import SearchableSelect from "./ui/SearchableSelect";
import ServiceSlotPicker from "./appointments/ServiceSlotPicker";
import type { PickedService, ServicePick } from "./appointments/ServiceSlotPicker";
import { useDoctorBookingServices } from "../hooks/useDoctorBookingServices";
import Switch from './ui/Switch';
/** Row actions for the appointments table (Figma عملیات menu). */
type ModalKind = null | "info" | "move" | "transfer" | "replace";
@@ -1009,24 +1010,12 @@ export function ReplaceAppointmentModal({
marginBottom: 12,
}}
>
<label
style={{
display: "inline-flex",
alignItems: "center",
gap: 8,
fontSize: 13,
cursor: "pointer",
}}
>
<input
type="checkbox"
checked={depositRequired}
onChange={(e) =>
setDepositRequired(e.target.checked)
}
/>
بیعانه مورد نیاز است.
</label>
<Switch
inline
checked={depositRequired}
onChange={setDepositRequired}
label="بیعانه مورد نیاز است."
/>
{depositRequired && (
<WalletChargeLink mobile={effectiveMobile} />
)}
@@ -7,6 +7,7 @@ import type { Appointment } from '../types';
import Modal from './ui/Modal';
import SearchableSelect from './ui/SearchableSelect';
import { digitsOnly } from '../lib/utils';
import Switch from './ui/Switch';
interface Option { uuid: string; name?: string }
@@ -120,9 +121,13 @@ export default function AppointmentFiltersModal({ value, onApply, onClose }: {
<div style={{ fontSize: 13.5, fontWeight: 700, marginBottom: 8 }}>وضعیت نوبت</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 14 }}>
{STATUS_OPTIONS.map(([v, l]) => (
<label key={v} style={{ display: 'inline-flex', alignItems: 'center', gap: 8, fontSize: 13, cursor: 'pointer' }}>
<input type="checkbox" checked={f.statuses.includes(v)} onChange={() => toggleStatus(v)} /> {l}
</label>
<Switch
key={v}
inline
checked={f.statuses.includes(v)}
onChange={() => toggleStatus(v)}
label={l}
/>
))}
</div>
+3 -6
View File
@@ -13,6 +13,7 @@ import PriceInput from './ui/PriceInput';
import PersianDateInput from './ui/PersianDateInput';
import { digitsOnly } from '../lib/utils';
import { usePermissions } from '../hooks/usePermissions';
import Switch from './ui/Switch';
const TYPE_LABELS: Record<DiscountRuleType, string> = {
patient_tag: 'تگ بیمار',
@@ -333,12 +334,8 @@ function RuleModal({ initial, onClose, onSaved }: { initial: DiscountRule | null
</div>
<div style={{ display: 'flex', gap: 20, marginTop: 4 }}>
<label style={{ display: 'inline-flex', alignItems: 'center', gap: 8, fontSize: 13, cursor: 'pointer' }}>
<input type="checkbox" checked={f.combinable} onChange={(e) => set('combinable', e.target.checked)} /> قابل ترکیب با سایر تخفیفها
</label>
<label style={{ display: 'inline-flex', alignItems: 'center', gap: 8, fontSize: 13, cursor: 'pointer' }}>
<input type="checkbox" checked={f.active} onChange={(e) => set('active', e.target.checked)} /> فعال
</label>
<Switch inline checked={f.combinable} onChange={(v) => set('combinable', v)} label="قابل ترکیب با سایر تخفیف‌ها" />
<Switch inline checked={f.active} onChange={(v) => set('active', v)} label="فعال" />
</div>
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', marginTop: 8 }}>
+9 -17
View File
@@ -4,6 +4,7 @@ import { toast } from 'sonner';
import { api } from '../lib/api';
import { formatRial, rialToToman, tomanToRial } from '../lib/utils';
import { digitsOnly } from '../lib/utils';
import Switch from './ui/Switch';
interface Pricing { free_visit_price_rials: number; require_visit_price: boolean }
@@ -76,23 +77,14 @@ export default function FreeVisitPrice({ doctorUuid, readOnly = false }: { docto
<p style={{ fontSize: 12, color: 'var(--danger)', margin: '6px 0 0' }}>{error}</p>
)}
<label style={{ display: 'inline-flex', alignItems: 'center', gap: 10, fontSize: 13, cursor: 'pointer', marginTop: 16 }}>
<span style={{
position: 'relative', width: 42, height: 22, borderRadius: 999, flexShrink: 0,
background: required ? 'var(--primary)' : 'var(--border-2)', transition: 'background .2s',
}}>
<input
type="checkbox" checked={required} role="switch" aria-label="الزامی کردن هزینه ویزیت"
onChange={(e) => { setRequired(e.target.checked); setError(''); }}
style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', margin: 0, opacity: 0, cursor: 'pointer' }}
/>
<span style={{
position: 'absolute', top: 2, insetInlineStart: required ? 22 : 2, width: 18, height: 18,
borderRadius: 999, background: 'var(--surface)', transition: 'inset-inline-start .2s', boxShadow: '0 1px 2px rgba(0,0,0,.2)',
}} />
</span>
الزامی کردن هزینه ویزیت
</label>
<div style={{ marginTop: 16 }}>
<Switch
inline
checked={required}
onChange={(v) => { setRequired(v); setError(''); }}
label="الزامی کردن هزینه ویزیت"
/>
</div>
<p style={{ fontSize: 12, color: 'var(--text-3)', margin: '6px 0 0', lineHeight: 1.7 }}>
با فعال شدن این گزینه، وارد کردن هزینه ویزیت در تنظیمات، ثبت مراجعه (سرویس)، فاکتور سرویس و ثبت نوبت الزامی میشود و بدون آن امکان ذخیره وجود ندارد.
</p>
@@ -4,6 +4,7 @@ import { toast } from 'sonner';
import { api } from '../lib/api';
import type { ApiResponse } from '../lib/api';
import { usePermissions } from '../hooks/usePermissions';
import Switch from './ui/Switch';
interface ServiceCategoryRow {
key: string;
@@ -70,22 +71,14 @@ export default function InsuranceServiceCategoriesCard() {
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 18 }}>
{rows.map((row) => (
<label
<Switch
key={row.key}
style={{ display: 'inline-flex', alignItems: 'center', gap: 10, cursor: canUpdate ? 'pointer' : 'default' }}
>
<span className="switch">
<input
type="checkbox"
aria-label={row.label}
checked={row.enabled}
disabled={!canUpdate || save.isPending}
onChange={() => toggle(row.key)}
/>
<span className="switch-track"><span className="switch-thumb" /></span>
</span>
<span style={{ fontSize: 13 }}>{row.label}</span>
</label>
inline
checked={row.enabled}
disabled={!canUpdate || save.isPending}
onChange={() => toggle(row.key)}
label={row.label}
/>
))}
</div>
</div>
@@ -11,6 +11,7 @@ import PriceInput from './ui/PriceInput';
import SearchableSelect from './ui/SearchableSelect';
import { WalletChargeLink } from './AppointmentActions';
import { tehranWallClockToUnix, tomanToRial, rialToToman, digitsOnly, sanitizeMobileInput } from '../lib/utils';
import Switch from './ui/Switch';
interface Option { uuid: string; name?: string; full_name?: string }
interface PatientRow { uuid: string; user_name?: string; user_mobile?: string; user_national_code?: string }
@@ -342,10 +343,12 @@ export default function NewAppointmentDrawer({ doctorUuid, defaultDate, queryKey
<>
<div style={{ fontSize: 13.5, fontWeight: 700, margin: '6px 0 10px' }}>بیعانه:</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 10 }}>
<label style={{ display: 'inline-flex', alignItems: 'center', gap: 8, fontSize: 13, cursor: 'pointer' }}>
<input type="checkbox" checked={depositRequired} onChange={e => setDepositRequired(e.target.checked)} />
بیعانه مورد نیاز است.
</label>
<Switch
inline
checked={depositRequired}
onChange={setDepositRequired}
label="بیعانه مورد نیاز است."
/>
</div>
{depositRequired && (
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 10, marginBottom: 12 }}>
@@ -5,6 +5,7 @@ import type { ApiResponse } from '../lib/api';
import Modal from './ui/Modal';
import PersianDateInput from './ui/PersianDateInput';
import SearchableSelect from './ui/SearchableSelect';
import Switch from './ui/Switch';
export interface PatientFilters {
gender?: string; // male | female
@@ -111,11 +112,12 @@ export default function PatientsFilterModal({ open, onClose, value, onApply }: {
<div>
<label style={label}>وضعیت پرونده</label>
<label className="switch" title="فقط پرونده‌های دارای بدهی" style={{ display: 'inline-flex', alignItems: 'center', gap: 10 }}>
<input type="checkbox" checked={!!f.has_debt} onChange={(e) => set('has_debt', e.target.checked)} aria-label="فقط پرونده‌های دارای بدهی" />
<span className="switch-track"><span className="switch-thumb" /></span>
<span style={{ fontSize: 13, color: 'var(--text-2)' }}>فقط پروندههای دارای بدهی</span>
</label>
<Switch
inline
checked={!!f.has_debt}
onChange={(v) => set('has_debt', v)}
label="فقط پرونده‌های دارای بدهی"
/>
</div>
<div>
@@ -7,6 +7,7 @@ import { digitsOnly, parseUserNumberClamped } from '../lib/utils';
import Modal from './ui/Modal';
import PriceInput from './ui/PriceInput';
import type { ServiceItem } from '../types';
import Switch from './ui/Switch';
interface TenantInsurance {
uuid: string;
@@ -111,15 +112,12 @@ function ContractCard({ contract, item }: { contract: TenantInsurance; item: Ser
پوشش پیشفرض قرارداد: {contract.coverage_percent}٪
</div>
</div>
<label className="switch">
<input
type="checkbox"
checked={draft.covered}
disabled={isLoading}
onChange={(e) => setDraft((d) => ({ ...d, covered: e.target.checked }))}
/>
<span className="switch-track"><span className="switch-thumb" /></span>
</label>
<Switch
checked={draft.covered}
disabled={isLoading}
onChange={(v) => setDraft((d) => ({ ...d, covered: v }))}
ariaLabel="پوشش بیمه برای این سرویس"
/>
</div>
{/* بدنه — فقط وقتی پوشش فعال است */}
@@ -15,6 +15,7 @@ import { useServiceCategories } from '../hooks/useServiceCategories';
import Modal from './ui/Modal';
import PriceInput from './ui/PriceInput';
import SearchableSelect from './ui/SearchableSelect';
import Switch from './ui/Switch';
const itemSchema = z.object({
name: z.string().min(1, 'نام سرویس الزامی است'),
@@ -238,17 +239,14 @@ export default function ServiceItemFormModal({ item, sectionUuid, onClose, onMan
<input {...numericField(form.register('duration_minutes'))} placeholder="مثلاً: 50" />
</div>
</div>
<label style={{ display: 'flex', alignItems: 'center', gap: 10, cursor: 'pointer', padding: '9px 0' }}>
<span className="switch">
<input
type="checkbox"
checked={form.watch('bookable') ?? false}
onChange={(e) => form.setValue('bookable', e.target.checked)}
/>
<span className="switch-track"><span className="switch-thumb" /></span>
</span>
<span style={{ fontSize: 13 }}>نمایش در نوبتدهی</span>
</label>
<div style={{ padding: '9px 0' }}>
<Switch
inline
checked={form.watch('bookable') ?? false}
onChange={(v) => form.setValue('bookable', v, { shouldDirty: true })}
label="نمایش در نوبت‌دهی"
/>
</div>
</div>
<div>
@@ -1,4 +1,5 @@
import React from 'react';
import Switch from '../ui/Switch';
/**
* سوییچ وضعیت فعال/غیرفعال — معادل MUI Switch مبدأ با دیزاین‌سیستم مقصد.
@@ -16,16 +17,9 @@ export default function StatusToggle({
const label = active ? 'فعال' : 'غیرفعال';
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<label className="switch" title={label}>
<input
type="checkbox"
checked={active}
onChange={onToggle}
disabled={disabled}
aria-label={label}
/>
<span className="switch-track"><span className="switch-thumb" /></span>
</label>
<span title={label}>
<Switch checked={active} onChange={onToggle} disabled={disabled} ariaLabel={label} />
</span>
<span style={{ fontSize: 13, color: 'var(--text-2)' }}>{label}</span>
</div>
);
@@ -1,7 +1,11 @@
import React, { useEffect, useState } from 'react';
import { XMarkIcon } from '@heroicons/react/24/outline';
import Modal from '../ui/Modal';
import { useQuery } from '@tanstack/react-query';
import SearchableSelect from '../ui/SearchableSelect';
import Field from '../ui/Field';
import Input from '../ui/Input';
import Switch from '../ui/Switch';
import { api, type ApiResponse } from '../../lib/api';
import type { ClinicResource, ResourcePayload, ResourceType } from '../../types';
import { useResourceDetail } from '../../hooks/useResources';
@@ -98,8 +102,14 @@ export default function ResourceFormModal({
return (
<Modal open={open} onClose={onClose} title={isEdit ? 'ویرایش منبع' : 'افزودن منبع'} size="lg">
<div style={{ display: 'grid', gap: 14 }}>
<Field label="نام منبع">
<input className="field" value={name} onChange={(e) => setName(e.target.value)} placeholder="لیزر آلکساندرایت ۱" />
<Field label="نام منبع" htmlFor="resource-name">
<Input
id="resource-name"
value={name}
autoFocus
onChange={(e) => setName(e.target.value)}
placeholder="لیزر آلکساندرایت ۱"
/>
</Field>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: 12 }}>
@@ -134,14 +144,17 @@ export default function ResourceFormModal({
)}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(160px, 1fr))', gap: 12 }}>
<Field label="ظرفیت هم‌زمان">
<input className="field" type="number" min={1} value={capacity} onChange={(e) => setCapacity(e.target.value)} />
{/* `numeric` به‌جای `type="number"`: ورودیِ عددیِ فارسی در `type="number"`
نامعتبر است و مرورگر رشتهٔ خالی می‌دهد — یعنی «۳» تایپ‌شده به ۰ می‌رسید.
`Input numeric` ارقام را زنده به لاتین برمی‌گرداند. */}
<Field label="ظرفیت هم‌زمان" htmlFor="resource-capacity">
<Input id="resource-capacity" numeric value={capacity} onChange={(e) => setCapacity(e.target.value)} />
</Field>
<Field label="آماده‌سازی (دقیقه)">
<input className="field" type="number" min={0} value={setupMinutes} onChange={(e) => setSetupMinutes(e.target.value)} />
<Field label="آماده‌سازی (دقیقه)" htmlFor="resource-setup">
<Input id="resource-setup" numeric value={setupMinutes} onChange={(e) => setSetupMinutes(e.target.value)} />
</Field>
<Field label="تمیزکاری (دقیقه)">
<input className="field" type="number" min={0} value={cleanupMinutes} onChange={(e) => setCleanupMinutes(e.target.value)} />
<Field label="تمیزکاری (دقیقه)" htmlFor="resource-cleanup">
<Input id="resource-cleanup" numeric value={cleanupMinutes} onChange={(e) => setCleanupMinutes(e.target.value)} />
</Field>
</div>
@@ -152,7 +165,7 @@ export default function ResourceFormModal({
<div style={{ display: 'grid', gap: 8 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<label style={{ fontSize: 12, color: 'var(--text-2)' }}>ویژگیها (اختیاری)</label>
<span className="cp-label">ویژگیها (اختیاری)</span>
<button
type="button"
className="btn secondary sm"
@@ -164,32 +177,33 @@ export default function ResourceFormModal({
{attributes.map((row, index) => (
<div key={index} style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<input
className="field"
<Input
list="resource-attribute-keys"
value={row.key}
placeholder="gender"
aria-label={`کلید ویژگی ${formatNumber(index + 1)}`}
onChange={(e) =>
setAttributes((a) => a.map((r, i) => (i === index ? { ...r, key: e.target.value } : r)))
}
style={{ flex: 1 }}
/>
<input
className="field"
<Input
value={row.value}
placeholder="female"
aria-label={`مقدار ویژگی ${formatNumber(index + 1)}`}
onChange={(e) =>
setAttributes((a) => a.map((r, i) => (i === index ? { ...r, value: e.target.value } : r)))
}
style={{ flex: 1 }}
/>
{/* دکمهٔ فقط-آیکون → `mini-btn`، نه `btn secondary sm` با یک ✕ متنی */}
<button
type="button"
className="btn secondary sm"
className="mini-btn danger"
onClick={() => setAttributes((a) => a.filter((_, i) => i !== index))}
aria-label="حذف ویژگی"
aria-label={`حذف ویژگی ${formatNumber(index + 1)}`}
>
<XMarkIcon style={{ width: 16 }} />
</button>
</div>
))}
@@ -199,10 +213,13 @@ export default function ResourceFormModal({
</datalist>
</div>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13 }}>
<input type="checkbox" checked={active} onChange={(e) => setActive(e.target.checked)} />
منبع فعال است
</label>
<Switch
id="resource-active"
checked={active}
onChange={setActive}
label="منبع فعال است"
hint="منبع غیرفعال در جستجوی وقت و رزرو نوبت نمی‌آید."
/>
{/* غیرفعال‌کردن نوبت‌های ثبت‌شده را لغو نمی‌کند؛ فقط از جستجوی وقتِ بعدی حذف
می‌شود. پس این هشدار است نه مانع — ولی اپراتور باید بداند چند بیمار روی
@@ -233,12 +250,3 @@ export default function ResourceFormModal({
</Modal>
);
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div style={{ display: 'grid', gap: 6 }}>
<label style={{ fontSize: 12, color: 'var(--text-2)' }}>{label}</label>
{children}
</div>
);
}
@@ -1,5 +1,6 @@
import React, { useEffect, useState } from 'react';
import SearchableSelect from '../ui/SearchableSelect';
import Switch from '../ui/Switch';
import { formatRial } from '../../lib/utils';
import type { ClinicResource, ResourceServiceOffering, ServiceItemOption } from '../../types';
@@ -106,14 +107,13 @@ export default function ResourceServicesPanel({
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<span style={{ flex: 1, fontSize: 13, fontWeight: 600 }}>{line.service_name}</span>
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 12, color: 'var(--text-2)' }}>
<input
type="checkbox"
checked={line.active}
onChange={(e) => patch(index, { active: e.target.checked })}
/>
فعال
</label>
<Switch
inline
checked={line.active}
onChange={(v) => patch(index, { active: v })}
label="فعال"
ariaLabel={`فعال بودن سرویس ${line.service_name}`}
/>
<button
type="button"
@@ -1,6 +1,7 @@
import React, { useEffect, useMemo, useState } from 'react';
import { ExclamationTriangleIcon, PlusIcon, TrashIcon } from '@heroicons/react/24/outline';
import { useResourceCalendar } from '../../hooks/useResourceCalendar';
import Switch from '../ui/Switch';
import { formatNumber } from '../../lib/utils';
/** ۰ = شنبه — همان قرارداد بک‌اند برای روزهای هفته. */
@@ -197,15 +198,16 @@ export default function ResourceWorkingHoursPanel({ resourceUuid, canUpdate }: {
)}
</label>
<label className="wh-eod">
<input
type="checkbox"
<span className="wh-eod">
<Switch
inline
checked={row.endOfDay}
disabled={!canUpdate}
onChange={(e) => editRange(day, index, { endOfDay: e.target.checked })}
onChange={(v) => editRange(day, index, { endOfDay: v })}
label="تا پایان روز"
ariaLabel={`تا پایان روز برای شیفت ${formatNumber(index + 1)} روز ${label}`}
/>
تا پایان روز
</label>
</span>
{canUpdate && (
<button
@@ -15,6 +15,7 @@ import Modal from '../ui/Modal';
import ConfirmDialog from '../ui/ConfirmDialog';
import GlobalSearchableSelect from '../ui/SearchableSelect';
import NationalHolidaysCard from '../holidays/NationalHolidaysCard';
import Switch from '../ui/Switch';
/**
* ساختار واحد تنظیمات نوبت‌دهی یک پزشک — برنامه هفتگی، استثناهای تاریخ و تعطیلات.
@@ -789,22 +790,17 @@ export function WeeklyScheduleTab({ doctorUuid, clinicUuid, addresses, readOnly
{/* ─ نوبت‌دهی آنلاین */}
<div className="mb-3 rounded-xl border border-[var(--border)] overflow-hidden">
{/* header + toggle */}
<label className="flex items-center justify-between gap-3 px-4 py-3 cursor-pointer bg-[var(--surface-2)]">
<div className="flex items-center justify-between gap-3 px-4 py-3 bg-[var(--surface-2)]">
<div className="flex items-center gap-2">
<GlobeAltIcon className="w-4 h-4 text-[var(--text-2)] shrink-0" />
<span className="text-sm font-medium text-[var(--text)]">نوبتدهی آنلاین</span>
</div>
<span className="relative inline-flex shrink-0">
<input
type="checkbox"
className="peer sr-only"
checked={meta.online_booking_enabled}
onChange={(e) => setMeta(m => ({ ...m, online_booking_enabled: e.target.checked }))}
/>
<span className="w-10 h-6 rounded-full bg-[var(--surface-3)] transition-colors peer-checked:bg-[var(--success)]" />
<span className="absolute top-0.5 right-0.5 w-5 h-5 rounded-full bg-[var(--surface)] shadow transition-transform peer-checked:-translate-x-4" />
</span>
</label>
<Switch
checked={meta.online_booking_enabled}
onChange={(v) => setMeta(m => ({ ...m, online_booking_enabled: v }))}
ariaLabel="نوبت‌دهی آنلاین"
/>
</div>
{/* booking window control */}
<div className={`px-4 py-3 transition-opacity ${meta.online_booking_enabled ? '' : 'opacity-50 pointer-events-none'}`}>
@@ -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>
);
}