232 lines
7.2 KiB
TypeScript
232 lines
7.2 KiB
TypeScript
/**
|
|
* CalendarTree - left sidebar calendar selector for the Calendar plugin.
|
|
*
|
|
* Groups calendars by type (personal, team, project, company) with color dots,
|
|
* visibility checkboxes, and a "New Calendar" button at the bottom.
|
|
* Pattern follows DMS SourceTree.
|
|
*/
|
|
|
|
import React, { useState } from 'react';
|
|
import clsx from 'clsx';
|
|
import { useTranslation } from 'react-i18next';
|
|
import type { Calendar, CalendarType } from '@/api/calendar';
|
|
|
|
const TYPE_ORDER: CalendarType[] = ['personal', 'team', 'project', 'company'];
|
|
|
|
interface CalendarRowProps {
|
|
calendar: Calendar;
|
|
selected: boolean;
|
|
visible: boolean;
|
|
onSelect: () => void;
|
|
onToggleVisibility: () => void;
|
|
}
|
|
|
|
function CalendarRow({ calendar, selected, visible, onSelect, onToggleVisibility }: CalendarRowProps) {
|
|
return (
|
|
<div
|
|
className={clsx(
|
|
'flex items-center gap-2 px-2 py-1.5 rounded-md text-sm cursor-pointer',
|
|
'hover:bg-secondary-100 motion-safe:transition-colors',
|
|
'focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500',
|
|
selected && 'bg-primary-50 text-primary-700 font-medium',
|
|
)}
|
|
style={{ paddingLeft: '20px' }}
|
|
onClick={onSelect}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter' || e.key === ' ') {
|
|
e.preventDefault();
|
|
onSelect();
|
|
}
|
|
}}
|
|
tabIndex={0}
|
|
role="button"
|
|
aria-label={calendar.name}
|
|
data-testid={`calendar-tree-row-${calendar.id}`}
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
checked={visible}
|
|
onChange={(e) => {
|
|
e.stopPropagation();
|
|
onToggleVisibility();
|
|
}}
|
|
onClick={(e) => e.stopPropagation()}
|
|
className="flex-shrink-0 h-3.5 w-3.5 rounded border-secondary-300 text-primary-600 focus:ring-primary-500"
|
|
aria-label={`toggle visibility ${calendar.name}`}
|
|
data-testid={`calendar-tree-visibility-${calendar.id}`}
|
|
/>
|
|
<span
|
|
className="flex-shrink-0 w-3 h-3 rounded-full"
|
|
style={{ backgroundColor: calendar.color || '#3b82f6' }}
|
|
aria-hidden="true"
|
|
/>
|
|
<span className="truncate flex-1">{calendar.name}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
interface TypeSectionProps {
|
|
type: CalendarType;
|
|
calendars: Calendar[];
|
|
activeCalendarId: string | null;
|
|
visibleCalendarIds: Set<string>;
|
|
onSelect: (id: string) => void;
|
|
onToggleVisibility: (id: string) => void;
|
|
}
|
|
|
|
function TypeSection({
|
|
type,
|
|
calendars,
|
|
activeCalendarId,
|
|
visibleCalendarIds,
|
|
onSelect,
|
|
onToggleVisibility,
|
|
}: TypeSectionProps) {
|
|
const { t } = useTranslation();
|
|
const [expanded, setExpanded] = useState(true);
|
|
|
|
if (calendars.length === 0) return null;
|
|
|
|
return (
|
|
<div className="mb-1">
|
|
<div
|
|
className={clsx(
|
|
'flex items-center gap-1 px-2 py-1.5 rounded-md text-sm font-semibold cursor-pointer',
|
|
'hover:bg-secondary-100 motion-safe:transition-colors',
|
|
)}
|
|
onClick={() => setExpanded((p) => !p)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter' || e.key === ' ') {
|
|
e.preventDefault();
|
|
setExpanded((p) => !p);
|
|
}
|
|
}}
|
|
tabIndex={0}
|
|
role="button"
|
|
aria-expanded={expanded}
|
|
aria-label={t(`calendar.type.${type}`)}
|
|
>
|
|
<button
|
|
type="button"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
setExpanded((p) => !p);
|
|
}}
|
|
className="flex-shrink-0 w-4 h-4 flex items-center justify-center text-secondary-400 hover:text-secondary-600"
|
|
aria-label={expanded ? 'Collapse' : 'Expand'}
|
|
>
|
|
<svg className="w-3 h-3 transition-transform" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={expanded ? 'M19 9l-7 7-7-7' : 'M9 5l7 7-7 7'} />
|
|
</svg>
|
|
</button>
|
|
<span className="truncate text-secondary-700">{t(`calendar.type.${type}`)}</span>
|
|
<span className="ml-auto text-xs text-secondary-400">{calendars.length}</span>
|
|
</div>
|
|
{expanded && (
|
|
<ul role="group" className="space-y-0 mt-0.5">
|
|
{calendars.map((cal) => (
|
|
<li key={cal.id}>
|
|
<CalendarRow
|
|
calendar={cal}
|
|
selected={activeCalendarId === cal.id}
|
|
visible={
|
|
visibleCalendarIds.size === 0 || visibleCalendarIds.has(cal.id)
|
|
}
|
|
onSelect={() => onSelect(cal.id)}
|
|
onToggleVisibility={() => onToggleVisibility(cal.id)}
|
|
/>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export interface CalendarTreeProps {
|
|
calendars: Calendar[];
|
|
activeCalendarId: string | null;
|
|
visibleCalendarIds: Set<string>;
|
|
onSelect: (id: string) => void;
|
|
onToggleVisibility: (id: string) => void;
|
|
onCreate: () => void;
|
|
loading: boolean;
|
|
}
|
|
|
|
export function CalendarTree({
|
|
calendars,
|
|
activeCalendarId,
|
|
visibleCalendarIds,
|
|
onSelect,
|
|
onToggleVisibility,
|
|
onCreate,
|
|
loading,
|
|
}: CalendarTreeProps) {
|
|
const { t } = useTranslation();
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="space-y-2 p-2" data-testid="calendar-tree-loading">
|
|
{[1, 2, 3].map((i) => (
|
|
<div key={i} className="h-8 bg-secondary-100 rounded animate-pulse" />
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const grouped = new Map<CalendarType, Calendar[]>();
|
|
for (const type of TYPE_ORDER) {
|
|
grouped.set(type, []);
|
|
}
|
|
for (const cal of calendars) {
|
|
const list = grouped.get(cal.type) ?? grouped.get('personal')!;
|
|
list.push(cal);
|
|
}
|
|
|
|
return (
|
|
<nav aria-label={t('calendar.tree.title')} data-testid="calendar-tree" className="flex flex-col h-full">
|
|
<div className="px-3 py-2 border-b border-secondary-200">
|
|
<h2 className="text-sm font-semibold text-secondary-900">{t('calendar.tree.title')}</h2>
|
|
</div>
|
|
|
|
<div className="flex-1 overflow-y-auto p-2"> {calendars.length === 0 ? (
|
|
<p className="text-xs text-secondary-400 italic px-2 py-4 text-center">
|
|
{t('calendar.noCalendars')}
|
|
</p>
|
|
) : (
|
|
TYPE_ORDER.map((type) => (
|
|
<TypeSection
|
|
key={type}
|
|
type={type}
|
|
calendars={grouped.get(type) ?? []}
|
|
activeCalendarId={activeCalendarId}
|
|
visibleCalendarIds={visibleCalendarIds}
|
|
onSelect={onSelect}
|
|
onToggleVisibility={onToggleVisibility}
|
|
/>
|
|
))
|
|
)}
|
|
</div>
|
|
|
|
<div className="p-2 border-t border-secondary-200">
|
|
<button
|
|
type="button"
|
|
onClick={onCreate}
|
|
className={clsx(
|
|
'w-full inline-flex items-center justify-center gap-1.5 px-3 py-2 rounded-md text-sm font-medium',
|
|
'bg-primary-50 text-primary-700 hover:bg-primary-100 min-h-touch',
|
|
)}
|
|
data-testid="calendar-tree-new"
|
|
>
|
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
|
</svg>
|
|
{t('calendar.tree.newCalendar')}
|
|
</button>
|
|
</div>
|
|
</nav>
|
|
);
|
|
}
|
|
|
|
export default CalendarTree;
|