fix(02): remove custom ViewToolbar; use Schedule-X built-in header
- Remove <ViewToolbar> render and its import from CalendarShell - Remove createCalendarControlsPlugin import, useState instance, and plugin array entry (calendar-controls only served the custom toolbar) - Delete ViewToolbar.tsx (no longer referenced anywhere) - Remove calendar-controls mock from CalendarShell.test.tsx - CSS audit confirmed no rules hide Schedule-X weekday-name row; no CSS changes needed - All four views (day/week/month-grid/month-agenda) remain; Schedule-X's native header exposes them in its own view switcher
This commit is contained in:
@@ -56,41 +56,6 @@ vi.mock('@schedule-x/event-modal', () => ({
|
|||||||
})),
|
})),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// Mock @schedule-x/calendar-controls so CalendarShell can create the plugin
|
|
||||||
vi.mock('@schedule-x/calendar-controls', () => ({
|
|
||||||
createCalendarControlsPlugin: vi.fn(() => ({
|
|
||||||
name: 'calendarControls',
|
|
||||||
beforeRender: vi.fn(),
|
|
||||||
onRender: vi.fn(),
|
|
||||||
setDate: vi.fn(),
|
|
||||||
setView: vi.fn(),
|
|
||||||
getDate: vi.fn(() => Temporal.Now.plainDateISO()),
|
|
||||||
getView: vi.fn(() => 'month-grid'),
|
|
||||||
setFirstDayOfWeek: vi.fn(),
|
|
||||||
setLocale: vi.fn(),
|
|
||||||
setViews: vi.fn(),
|
|
||||||
setDayBoundaries: vi.fn(),
|
|
||||||
setWeekOptions: vi.fn(),
|
|
||||||
setCalendars: vi.fn(),
|
|
||||||
setMinDate: vi.fn(),
|
|
||||||
setMaxDate: vi.fn(),
|
|
||||||
setMonthGridOptions: vi.fn(),
|
|
||||||
setTimezone: vi.fn(),
|
|
||||||
setResources: vi.fn(),
|
|
||||||
getFirstDayOfWeek: vi.fn(),
|
|
||||||
getLocale: vi.fn(),
|
|
||||||
getViews: vi.fn(() => []),
|
|
||||||
getDayBoundaries: vi.fn(),
|
|
||||||
getWeekOptions: vi.fn(),
|
|
||||||
getCalendars: vi.fn(() => ({})),
|
|
||||||
getMinDate: vi.fn(),
|
|
||||||
getMaxDate: vi.fn(),
|
|
||||||
getMonthGridOptions: vi.fn(),
|
|
||||||
getResources: vi.fn(() => []),
|
|
||||||
getRange: vi.fn(() => null),
|
|
||||||
})),
|
|
||||||
}))
|
|
||||||
|
|
||||||
// Mock the API client
|
// Mock the API client
|
||||||
vi.mock('../api/client.js', () => ({
|
vi.mock('../api/client.js', () => ({
|
||||||
fetchMe: vi.fn(),
|
fetchMe: vi.fn(),
|
||||||
|
|||||||
@@ -14,10 +14,12 @@
|
|||||||
* - Event popover: driven exclusively by Zustand openEventId via onEventClick → standalone
|
* - Event popover: driven exclusively by Zustand openEventId via onEventClick → standalone
|
||||||
* EventDetailPopover; createEventModalPlugin and customComponents.eventModal are NOT used
|
* EventDetailPopover; createEventModalPlugin and customComponents.eventModal are NOT used
|
||||||
* - Threat T-02d-01: all event fields are plain-text JSX children — no raw HTML injection
|
* - Threat T-02d-01: all event fields are plain-text JSX children — no raw HTML injection
|
||||||
|
* - Navigation: Schedule-X's built-in header is the sole navigation bar (Today/‹›/view switcher
|
||||||
|
* + weekday-name row). The custom ViewToolbar and calendar-controls plugin have been removed.
|
||||||
*
|
*
|
||||||
* Layout:
|
* Layout:
|
||||||
* - Phone (≤767px): AppNav top bar → ViewToolbar → calendar grid (primary focal point)
|
* - Phone (≤767px): AppNav top bar → Schedule-X (header + grid)
|
||||||
* - Tablet/Desktop (≥768px): AppNav left sidebar (240px) + main area (ViewToolbar + grid)
|
* - Tablet/Desktop (≥768px): AppNav left sidebar (240px) + main area (Schedule-X header + grid)
|
||||||
*
|
*
|
||||||
* State branches:
|
* State branches:
|
||||||
* isLoading (initial) → SkeletonCalendar
|
* isLoading (initial) → SkeletonCalendar
|
||||||
@@ -37,7 +39,6 @@ import {
|
|||||||
type CalendarType,
|
type CalendarType,
|
||||||
} from '@schedule-x/calendar'
|
} from '@schedule-x/calendar'
|
||||||
import { createEventsServicePlugin } from '@schedule-x/events-service'
|
import { createEventsServicePlugin } from '@schedule-x/events-service'
|
||||||
import { createCalendarControlsPlugin } from '@schedule-x/calendar-controls'
|
|
||||||
|
|
||||||
import { fetchMe, fetchEvents } from '../api/client.js'
|
import { fetchMe, fetchEvents } from '../api/client.js'
|
||||||
import { hydrateEvents } from '../lib/hydrateEvents.js'
|
import { hydrateEvents } from '../lib/hydrateEvents.js'
|
||||||
@@ -45,7 +46,6 @@ import { buildCalendarConfig, SX_FIRST_DAY_OF_WEEK } from '../lib/calendarConfig
|
|||||||
import { useCalendarStore } from '../store/calendarStore.js'
|
import { useCalendarStore } from '../store/calendarStore.js'
|
||||||
import { EventDetailPopover } from './EventDetailPopover.js'
|
import { EventDetailPopover } from './EventDetailPopover.js'
|
||||||
import { AppNav } from './AppNav.js'
|
import { AppNav } from './AppNav.js'
|
||||||
import { ViewToolbar } from './ViewToolbar.js'
|
|
||||||
import { ColorLegend } from './ColorLegend.js'
|
import { ColorLegend } from './ColorLegend.js'
|
||||||
import { SkeletonCalendar } from './SkeletonCalendar.js'
|
import { SkeletonCalendar } from './SkeletonCalendar.js'
|
||||||
import { EmptyState } from './EmptyState.js'
|
import { EmptyState } from './EmptyState.js'
|
||||||
@@ -96,7 +96,6 @@ export function CalendarShell() {
|
|||||||
|
|
||||||
// Create plugins once (stable across renders)
|
// Create plugins once (stable across renders)
|
||||||
const eventsService = useState(() => createEventsServicePlugin())[0]
|
const eventsService = useState(() => createEventsServicePlugin())[0]
|
||||||
const calendarControls = useState(() => createCalendarControlsPlugin())[0]
|
|
||||||
|
|
||||||
// Build members list from /api/me for AppNav + ColorLegend
|
// Build members list from /api/me for AppNav + ColorLegend
|
||||||
const members = useMemo(() => {
|
const members = useMemo(() => {
|
||||||
@@ -148,7 +147,7 @@ export function CalendarShell() {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
[eventsService, calendarControls],
|
[eventsService],
|
||||||
)
|
)
|
||||||
|
|
||||||
// Sync TanStack Query result into Schedule-X eventsService (Pitfall 4 guard)
|
// Sync TanStack Query result into Schedule-X eventsService (Pitfall 4 guard)
|
||||||
@@ -205,9 +204,6 @@ export function CalendarShell() {
|
|||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* ViewToolbar */}
|
|
||||||
<ViewToolbar controls={calendarControls} />
|
|
||||||
|
|
||||||
{/* Main calendar area */}
|
{/* Main calendar area */}
|
||||||
<div style={{ flex: 1, minHeight: 0, position: 'relative', overflow: 'hidden' }}>
|
<div style={{ flex: 1, minHeight: 0, position: 'relative', overflow: 'hidden' }}>
|
||||||
{isInitialLoading ? (
|
{isInitialLoading ? (
|
||||||
|
|||||||
@@ -1,192 +0,0 @@
|
|||||||
/**
|
|
||||||
* ViewToolbar — calendar navigation and view switcher.
|
|
||||||
*
|
|
||||||
* UI-SPEC §ViewToolbar:
|
|
||||||
* - Buttons: Today | < | > | [Day] [Week] [Month] [Agenda]
|
|
||||||
* - Font: 13px label weight
|
|
||||||
* - Active view: subtle surface tint (NOT accent) — --color-member-0 at 12% opacity
|
|
||||||
* - Touch targets: 44px minimum height
|
|
||||||
* - role="button", keyboard-activatable with Enter/Space
|
|
||||||
*
|
|
||||||
* Reviewer note: ViewToolbar is secondary chrome — accent colors NOT on chrome.
|
|
||||||
* Active state uses a subtle surface tint, not the accent color directly.
|
|
||||||
*
|
|
||||||
* Schedule-X integration: navigation and view-switching via the official
|
|
||||||
* @schedule-x/calendar-controls plugin (createCalendarControlsPlugin).
|
|
||||||
* The plugin is created once in CalendarShell and passed down as `controls`.
|
|
||||||
*
|
|
||||||
* Zustand subscription: uses per-field selectors so ViewToolbar only re-renders
|
|
||||||
* when selectedView or setSelectedView change — NOT when openEventId changes.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useCalendarStore } from '../store/calendarStore.js'
|
|
||||||
import type { createCalendarControlsPlugin } from '@schedule-x/calendar-controls'
|
|
||||||
|
|
||||||
type ViewId = 'day' | 'week' | 'month-grid' | 'month-agenda'
|
|
||||||
|
|
||||||
interface ViewConfig {
|
|
||||||
id: ViewId
|
|
||||||
label: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const VIEWS: ViewConfig[] = [
|
|
||||||
{ id: 'day', label: 'Day' },
|
|
||||||
{ id: 'week', label: 'Week' },
|
|
||||||
{ id: 'month-grid', label: 'Month' },
|
|
||||||
{ id: 'month-agenda', label: 'Agenda' },
|
|
||||||
]
|
|
||||||
|
|
||||||
type CalendarControlsPlugin = ReturnType<typeof createCalendarControlsPlugin>
|
|
||||||
|
|
||||||
interface ViewToolbarProps {
|
|
||||||
controls: CalendarControlsPlugin | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ViewToolbar({ controls }: ViewToolbarProps) {
|
|
||||||
// Per-field selectors: do NOT subscribe to openEventId.
|
|
||||||
// If the whole store is destructured unselected, every popover open/close
|
|
||||||
// causes a re-render here, which flashes the toolbar (Bug B).
|
|
||||||
const selectedView = useCalendarStore((s) => s.selectedView)
|
|
||||||
const setSelectedView = useCalendarStore((s) => s.setSelectedView)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Navigate using the calendar-controls plugin API.
|
|
||||||
* - today: setDate(Temporal.Now.plainDateISO())
|
|
||||||
* - prev/next: read current date, step by the active view's unit, call setDate()
|
|
||||||
*
|
|
||||||
* Step mapping (matches Schedule-X built-in backward/forward behaviour):
|
|
||||||
* day → ±1 day
|
|
||||||
* week → ±1 week
|
|
||||||
* month-grid → ±1 month
|
|
||||||
* month-agenda → ±1 month
|
|
||||||
*/
|
|
||||||
const navigate = (direction: 'prev' | 'next' | 'today') => {
|
|
||||||
if (!controls) return
|
|
||||||
|
|
||||||
if (direction === 'today') {
|
|
||||||
controls.setDate(Temporal.Now.plainDateISO())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the current date from the controls plugin (returns Temporal.PlainDate)
|
|
||||||
const current = controls.getDate()
|
|
||||||
|
|
||||||
let step: Temporal.DurationLike
|
|
||||||
if (selectedView === 'day') {
|
|
||||||
step = { days: 1 }
|
|
||||||
} else if (selectedView === 'week') {
|
|
||||||
step = { weeks: 1 }
|
|
||||||
} else {
|
|
||||||
// month-grid or month-agenda
|
|
||||||
step = { months: 1 }
|
|
||||||
}
|
|
||||||
|
|
||||||
const newDate = direction === 'prev' ? current.subtract(step) : current.add(step)
|
|
||||||
controls.setDate(newDate)
|
|
||||||
}
|
|
||||||
|
|
||||||
const switchView = (viewId: ViewId) => {
|
|
||||||
if (!controls) return
|
|
||||||
controls.setView(viewId)
|
|
||||||
setSelectedView(viewId)
|
|
||||||
}
|
|
||||||
|
|
||||||
const buttonBase: React.CSSProperties = {
|
|
||||||
background: 'none',
|
|
||||||
border: '1px solid var(--color-border)',
|
|
||||||
cursor: 'pointer',
|
|
||||||
minHeight: '44px',
|
|
||||||
padding: '0 var(--space-3)',
|
|
||||||
fontSize: 'var(--text-label-size)',
|
|
||||||
fontWeight: 'var(--text-label-weight)',
|
|
||||||
lineHeight: 'var(--text-label-line-height)',
|
|
||||||
color: 'var(--color-text-primary)',
|
|
||||||
borderRadius: 'var(--space-1)',
|
|
||||||
fontFamily: 'var(--font-family-base)',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
transition: 'background 0.1s',
|
|
||||||
}
|
|
||||||
|
|
||||||
const activeButtonStyle: React.CSSProperties = {
|
|
||||||
...buttonBase,
|
|
||||||
// Subtle surface tint — NOT accent color (UI-SPEC 60/30/10 rule)
|
|
||||||
// Active state: --color-member-0 at 12% opacity over white
|
|
||||||
background: 'rgba(74, 144, 217, 0.12)',
|
|
||||||
fontWeight: 600,
|
|
||||||
border: '1px solid rgba(74, 144, 217, 0.3)',
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 'var(--space-2)',
|
|
||||||
padding: 'var(--space-2) var(--space-4)',
|
|
||||||
borderBottom: '1px solid var(--color-border)',
|
|
||||||
background: 'var(--color-surface)',
|
|
||||||
flexWrap: 'wrap',
|
|
||||||
fontFamily: 'var(--font-family-base)',
|
|
||||||
minHeight: '48px',
|
|
||||||
}}
|
|
||||||
role="toolbar"
|
|
||||||
aria-label="Calendar navigation"
|
|
||||||
>
|
|
||||||
{/* Today button */}
|
|
||||||
<button
|
|
||||||
role="button"
|
|
||||||
onClick={() => navigate('today')}
|
|
||||||
style={buttonBase}
|
|
||||||
aria-label="Go to today"
|
|
||||||
>
|
|
||||||
Today
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Prev / Next */}
|
|
||||||
<button
|
|
||||||
role="button"
|
|
||||||
onClick={() => navigate('prev')}
|
|
||||||
style={{ ...buttonBase, minWidth: '44px' }}
|
|
||||||
aria-label="Previous period"
|
|
||||||
>
|
|
||||||
‹
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
role="button"
|
|
||||||
onClick={() => navigate('next')}
|
|
||||||
style={{ ...buttonBase, minWidth: '44px' }}
|
|
||||||
aria-label="Next period"
|
|
||||||
>
|
|
||||||
›
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Spacer */}
|
|
||||||
<div style={{ flex: 1 }} />
|
|
||||||
|
|
||||||
{/* View switcher */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
gap: 'var(--space-1)',
|
|
||||||
}}
|
|
||||||
role="group"
|
|
||||||
aria-label="View selector"
|
|
||||||
>
|
|
||||||
{VIEWS.map((view) => (
|
|
||||||
<button
|
|
||||||
key={view.id}
|
|
||||||
role="button"
|
|
||||||
onClick={() => switchView(view.id)}
|
|
||||||
style={selectedView === view.id ? activeButtonStyle : buttonBase}
|
|
||||||
aria-pressed={selectedView === view.id}
|
|
||||||
aria-label={`Switch to ${view.label} view`}
|
|
||||||
>
|
|
||||||
{view.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user