Repository: dayflow-js/calendar Branch: main Commit: a45483faeab9 Files: 615 Total size: 3.0 MB Directory structure: gitextract_0efm8vjk/ ├── .editorconfig ├── .github/ │ ├── FUNDING.yml │ ├── ISSUE_TEMPLATE/ │ │ ├── bug_report.md │ │ └── feature_request.md │ └── workflows/ │ └── deploy.yml ├── .gitignore ├── .nojekyll ├── .npmignore ├── .oxfmtrc.jsonc ├── .oxlintrc.json ├── .vscode/ │ ├── extensions.json │ └── settings.json ├── .zed/ │ └── settings.json ├── CHANGELOG.md ├── CNAME ├── CONTRIBUTING.md ├── LICENSE ├── README.ja.md ├── README.md ├── README.zh.md ├── examples/ │ ├── defaultCalendarExample/ │ │ └── defaultCalendarExample.tsx │ ├── main.tsx │ ├── styles/ │ │ └── tailwind.css │ └── utils/ │ ├── palette.ts │ └── sampleData.ts ├── index.html ├── lefthook.yml ├── package.json ├── packages/ │ ├── angular/ │ │ ├── LICENSE │ │ ├── README.md │ │ ├── ng-packagr.json │ │ ├── package.json │ │ ├── src/ │ │ │ ├── lib/ │ │ │ │ ├── day-flow-calendar.component.ts │ │ │ │ ├── day-flow-calendar.module.ts │ │ │ │ └── day-flow-portal.directive.ts │ │ │ └── public-api.ts │ │ └── tsconfig.json │ ├── core/ │ │ ├── LICENSE │ │ ├── README.md │ │ ├── bundle-analysis.html │ │ ├── jest.config.mjs │ │ ├── package.json │ │ ├── postcss.build.mjs │ │ ├── rollup.config.js │ │ ├── scripts/ │ │ │ ├── atomic-css-baseline.json │ │ │ ├── atomic-css-guard-utils.mjs │ │ │ ├── build-css.mjs │ │ │ ├── check-dist-styling.mjs │ │ │ └── check-semantic-css.mjs │ │ ├── src/ │ │ │ ├── components/ │ │ │ │ ├── calendarEvent/ │ │ │ │ │ ├── CalendarEvent.tsx │ │ │ │ │ ├── __tests__/ │ │ │ │ │ │ ├── CalendarEvent.contract.test.tsx │ │ │ │ │ │ └── CalendarEvent.timezone.test.tsx │ │ │ │ │ ├── components/ │ │ │ │ │ │ ├── AllDayContent.tsx │ │ │ │ │ │ ├── EventContent.tsx │ │ │ │ │ │ ├── EventDetailPanel.tsx │ │ │ │ │ │ ├── MonthAllDayContent.tsx │ │ │ │ │ │ ├── MonthRegularContent.tsx │ │ │ │ │ │ ├── RegularEventContent.tsx │ │ │ │ │ │ ├── YearEventContent.tsx │ │ │ │ │ │ └── __tests__/ │ │ │ │ │ │ ├── EventContent.test.tsx │ │ │ │ │ │ └── RegularEventContent.test.tsx │ │ │ │ │ ├── hooks/ │ │ │ │ │ │ ├── __tests__/ │ │ │ │ │ │ │ └── useEventActions.test.tsx │ │ │ │ │ │ ├── useClickOutside.ts │ │ │ │ │ │ ├── useDetailPanelPosition.ts │ │ │ │ │ │ ├── useEventActions.ts │ │ │ │ │ │ ├── useEventInteraction.ts │ │ │ │ │ │ ├── useEventStyles.ts │ │ │ │ │ │ └── useEventVisibility.ts │ │ │ │ │ ├── index.tsx │ │ │ │ │ ├── types.ts │ │ │ │ │ └── utils.ts │ │ │ │ ├── common/ │ │ │ │ │ ├── BlossomColorPicker.tsx │ │ │ │ │ ├── CalendarHeader.tsx │ │ │ │ │ ├── CalendarPicker.tsx │ │ │ │ │ ├── CreateCalendarDialog.tsx │ │ │ │ │ ├── DefaultColorPicker.tsx │ │ │ │ │ ├── DefaultEventDetailDialog.tsx │ │ │ │ │ ├── DefaultEventDetailPanel.tsx │ │ │ │ │ ├── EventDetailPanelWithContent.tsx │ │ │ │ │ ├── Icons.tsx │ │ │ │ │ ├── LoadingButton.tsx │ │ │ │ │ ├── MiniCalendar.tsx │ │ │ │ │ ├── QuickCreateEventPopup.tsx │ │ │ │ │ ├── TodayBox.tsx │ │ │ │ │ ├── ViewHeader.tsx │ │ │ │ │ ├── ViewSwitcher.tsx │ │ │ │ │ └── __tests__/ │ │ │ │ │ ├── MiniCalendar.test.tsx │ │ │ │ │ └── QuickCreateEventPopup.test.tsx │ │ │ │ ├── contextMenu/ │ │ │ │ │ ├── __tests__/ │ │ │ │ │ │ └── utils.test.ts │ │ │ │ │ ├── components/ │ │ │ │ │ │ ├── EventContextMenu.tsx │ │ │ │ │ │ ├── GridContextMenu.tsx │ │ │ │ │ │ └── __tests__/ │ │ │ │ │ │ └── readOnlyContextMenus.test.tsx │ │ │ │ │ ├── index.tsx │ │ │ │ │ └── utils.ts │ │ │ │ ├── dayView/ │ │ │ │ │ ├── DayContent.tsx │ │ │ │ │ ├── RightPanel.tsx │ │ │ │ │ ├── __tests__/ │ │ │ │ │ │ └── util.timezone.test.ts │ │ │ │ │ └── util.ts │ │ │ │ ├── eventLayout/ │ │ │ │ │ ├── calculate/ │ │ │ │ │ │ ├── grouping.ts │ │ │ │ │ │ ├── layout.ts │ │ │ │ │ │ ├── rebalance.ts │ │ │ │ │ │ └── structure.ts │ │ │ │ │ ├── constants.ts │ │ │ │ │ ├── index.tsx │ │ │ │ │ ├── types.ts │ │ │ │ │ └── utils.ts │ │ │ │ ├── mobileEventDrawer/ │ │ │ │ │ ├── DefaultMobileEventDrawer.tsx │ │ │ │ │ ├── __tests__/ │ │ │ │ │ │ └── DefaultMobileEventDrawer.test.tsx │ │ │ │ │ ├── components/ │ │ │ │ │ │ ├── Switch.tsx │ │ │ │ │ │ ├── TimePickerWheel.tsx │ │ │ │ │ │ └── __tests__/ │ │ │ │ │ │ └── Switch.test.tsx │ │ │ │ │ └── index.ts │ │ │ │ ├── monthView/ │ │ │ │ │ ├── MultiDayEvent.tsx │ │ │ │ │ ├── WeekComponent.tsx │ │ │ │ │ ├── WeekDayCell.tsx │ │ │ │ │ ├── __tests__/ │ │ │ │ │ │ └── WeekComponent.test.tsx │ │ │ │ │ └── util.tsx │ │ │ │ ├── search/ │ │ │ │ │ ├── MobileSearchDialog.tsx │ │ │ │ │ ├── SearchDrawer.tsx │ │ │ │ │ └── SearchResultsList.tsx │ │ │ │ ├── weekView/ │ │ │ │ │ ├── AllDayRow.tsx │ │ │ │ │ ├── CompactHeader.tsx │ │ │ │ │ ├── TimeGrid.tsx │ │ │ │ │ ├── __tests__/ │ │ │ │ │ │ └── util.timezone.test.ts │ │ │ │ │ └── util.ts │ │ │ │ └── yearView/ │ │ │ │ ├── DefaultYearView.tsx │ │ │ │ ├── FixedWeekMonthRow.tsx │ │ │ │ ├── FixedWeekYearView.tsx │ │ │ │ ├── GridDayPopup.tsx │ │ │ │ ├── GridYearView.tsx │ │ │ │ ├── YearDayCell.tsx │ │ │ │ ├── YearRowComponent.tsx │ │ │ │ ├── __tests__/ │ │ │ │ │ ├── DefaultYearView.test.tsx │ │ │ │ │ └── utils.test.ts │ │ │ │ └── utils.ts │ │ │ ├── contexts/ │ │ │ │ └── ThemeContext.tsx │ │ │ ├── core/ │ │ │ │ ├── CalendarApp.ts │ │ │ │ ├── CalendarStore.ts │ │ │ │ ├── __tests__/ │ │ │ │ │ ├── CalendarApp.test.ts │ │ │ │ │ ├── WeekViewConfig.test.ts │ │ │ │ │ └── calendarRegistry.test.ts │ │ │ │ ├── calendarRegistry.ts │ │ │ │ ├── config.ts │ │ │ │ ├── events/ │ │ │ │ │ └── EventManager.ts │ │ │ │ ├── index.ts │ │ │ │ ├── navigation/ │ │ │ │ │ └── NavigationController.ts │ │ │ │ ├── permissions/ │ │ │ │ │ └── CalendarPermissions.ts │ │ │ │ ├── plugins/ │ │ │ │ │ └── PluginManager.ts │ │ │ │ └── useCalendarApp.ts │ │ │ ├── factories/ │ │ │ │ ├── ViewAdapter.tsx │ │ │ │ ├── createAgendaView.ts │ │ │ │ ├── createDayView.ts │ │ │ │ ├── createMonthView.ts │ │ │ │ ├── createWeekView.ts │ │ │ │ ├── createYearView.ts │ │ │ │ └── index.ts │ │ │ ├── hooks/ │ │ │ │ ├── __tests__/ │ │ │ │ │ └── useCalendarDrop.test.tsx │ │ │ │ ├── useCalendarDrop.ts │ │ │ │ ├── useDebouncedValue.ts │ │ │ │ ├── useWeekViewSwipe.ts │ │ │ │ └── virtualScroll/ │ │ │ │ ├── index.ts │ │ │ │ ├── useVirtualMonthScroll.ts │ │ │ │ └── useVirtualScroll.ts │ │ │ ├── index.ts │ │ │ ├── locale/ │ │ │ │ ├── LocaleContext.tsx │ │ │ │ ├── LocaleProvider.tsx │ │ │ │ ├── index.ts │ │ │ │ ├── intl.ts │ │ │ │ ├── locales/ │ │ │ │ │ ├── en.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── translator.ts │ │ │ │ ├── types.ts │ │ │ │ ├── useLocale.ts │ │ │ │ └── utils.ts │ │ │ ├── plugins/ │ │ │ │ ├── dragBridge.ts │ │ │ │ ├── eventsPlugin.ts │ │ │ │ ├── index.ts │ │ │ │ └── sidebarBridge.ts │ │ │ ├── renderer/ │ │ │ │ ├── CalendarRenderer.tsx │ │ │ │ ├── CalendarRoot.tsx │ │ │ │ ├── ContentSlot.tsx │ │ │ │ ├── CustomRenderingContext.ts │ │ │ │ ├── CustomRenderingStore.ts │ │ │ │ └── hooks/ │ │ │ │ ├── __tests__/ │ │ │ │ │ └── useSearchController.test.ts │ │ │ │ ├── useAppSubscription.ts │ │ │ │ ├── useEventDialogController.ts │ │ │ │ ├── useQuickCreateController.ts │ │ │ │ ├── useResponsive.ts │ │ │ │ └── useSearchController.ts │ │ │ ├── setupTests.ts │ │ │ ├── styles/ │ │ │ │ ├── __tests__/ │ │ │ │ │ └── dist-css.test.ts │ │ │ │ ├── classNames.ts │ │ │ │ ├── core/ │ │ │ │ │ ├── common/ │ │ │ │ │ │ ├── forms-dialogs.css │ │ │ │ │ │ └── header-controls.css │ │ │ │ │ ├── events/ │ │ │ │ │ │ └── calendar-event.css │ │ │ │ │ ├── overlays/ │ │ │ │ │ │ ├── mobile-event-drawer.css │ │ │ │ │ │ └── quick-create.css │ │ │ │ │ ├── search/ │ │ │ │ │ │ └── search.css │ │ │ │ │ └── views/ │ │ │ │ │ └── layout.css │ │ │ │ ├── core-components.css │ │ │ │ ├── library-imports.css │ │ │ │ ├── shared-foundation.css │ │ │ │ ├── tailwind-components.css │ │ │ │ └── tailwind.css │ │ │ ├── types/ │ │ │ │ ├── calendar.ts │ │ │ │ ├── calendarTypes.ts │ │ │ │ ├── config.ts │ │ │ │ ├── core.ts │ │ │ │ ├── dragIndicator.ts │ │ │ │ ├── event.ts │ │ │ │ ├── eventDetail.ts │ │ │ │ ├── factory.ts │ │ │ │ ├── hook.ts │ │ │ │ ├── index.ts │ │ │ │ ├── layout.ts │ │ │ │ ├── mobileEvent.ts │ │ │ │ ├── monthView.ts │ │ │ │ ├── plugin.ts │ │ │ │ ├── search.ts │ │ │ │ └── timezone.ts │ │ │ ├── utils/ │ │ │ │ ├── __tests__/ │ │ │ │ │ ├── allDaySort.test.ts │ │ │ │ │ ├── crossRegionDrag.test.ts │ │ │ │ │ ├── eventHelpers.test.ts │ │ │ │ │ ├── helpers.test.ts │ │ │ │ │ ├── timeUtils.test.ts │ │ │ │ │ └── timeZoneUtils.test.ts │ │ │ │ ├── allDaySort.ts │ │ │ │ ├── calendarApp/ │ │ │ │ │ ├── configSync.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── normalizedConfig.ts │ │ │ │ │ └── viewConfigComparison.ts │ │ │ │ ├── calendarDataUtils.ts │ │ │ │ ├── clipboardStore.ts │ │ │ │ ├── colorUtils.ts │ │ │ │ ├── compareUtils.ts │ │ │ │ ├── crossRegionDrag.ts │ │ │ │ ├── dateConstants.ts │ │ │ │ ├── dateFormat.ts │ │ │ │ ├── dateRangeUtils.ts │ │ │ │ ├── dateTimeUtils.ts │ │ │ │ ├── eventHelpers.ts │ │ │ │ ├── eventUtils.ts │ │ │ │ ├── helpers.ts │ │ │ │ ├── ics/ │ │ │ │ │ ├── __tests__/ │ │ │ │ │ │ └── ics.test.ts │ │ │ │ │ ├── icsGenerator.ts │ │ │ │ │ ├── icsParser.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── types.ts │ │ │ │ │ └── utils.ts │ │ │ │ ├── index.ts │ │ │ │ ├── logger.ts │ │ │ │ ├── searchUtils.ts │ │ │ │ ├── styleUtils.ts │ │ │ │ ├── subscriptionUtils.ts │ │ │ │ ├── temporal.ts │ │ │ │ ├── temporalTypeGuards.ts │ │ │ │ ├── testDataUtils.ts │ │ │ │ ├── themeUtils.ts │ │ │ │ ├── throttle.ts │ │ │ │ ├── timeUtils.ts │ │ │ │ ├── timeZoneUtils.ts │ │ │ │ └── utilityFunctions.ts │ │ │ └── views/ │ │ │ ├── AgendaView.tsx │ │ │ ├── DayView.tsx │ │ │ ├── MonthView.tsx │ │ │ ├── WeekView.tsx │ │ │ ├── YearView.tsx │ │ │ └── utils/ │ │ │ ├── __tests__/ │ │ │ │ └── weekView.test.ts │ │ │ ├── dragCreate.ts │ │ │ └── weekView.ts │ │ ├── tsconfig.build.json │ │ ├── tsconfig.json │ │ └── vite.config.ts │ ├── create-dayflow/ │ │ ├── LICENSE │ │ ├── README.md │ │ ├── package.json │ │ ├── src/ │ │ │ └── index.ts │ │ ├── tsconfig.json │ │ └── tsup.config.ts │ ├── plugins/ │ │ ├── drag/ │ │ │ ├── LICENSE │ │ │ ├── README.md │ │ │ ├── jest.config.mjs │ │ │ ├── package.json │ │ │ ├── rollup.config.js │ │ │ ├── src/ │ │ │ │ ├── components/ │ │ │ │ │ ├── DefaultDragIndicator.tsx │ │ │ │ │ ├── DragIndicatorComponent.tsx │ │ │ │ │ ├── MonthDragIndicator.tsx │ │ │ │ │ └── __tests__/ │ │ │ │ │ └── MonthDragIndicator.test.tsx │ │ │ │ ├── hooks/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── useDrag.ts │ │ │ │ │ ├── useDragCommon.ts │ │ │ │ │ ├── useDragHandlers.ts │ │ │ │ │ ├── useDragManager.ts │ │ │ │ │ ├── useDragState.ts │ │ │ │ │ ├── useMonthDrag.ts │ │ │ │ │ ├── useWeekDayDrag.ts │ │ │ │ │ └── utils/ │ │ │ │ │ ├── __tests__/ │ │ │ │ │ │ ├── dateGridDrag.test.ts │ │ │ │ │ │ ├── eventEditing.test.ts │ │ │ │ │ │ ├── indicatorColor.test.ts │ │ │ │ │ │ └── resolveDragSourceElement.test.ts │ │ │ │ │ ├── dateGridDrag.ts │ │ │ │ │ ├── dragInteraction.ts │ │ │ │ │ ├── eventEditing.ts │ │ │ │ │ ├── indicatorColor.ts │ │ │ │ │ ├── resolveDragSourceElement.ts │ │ │ │ │ └── weekDay/ │ │ │ │ │ ├── __tests__/ │ │ │ │ │ │ ├── completion.test.ts │ │ │ │ │ │ ├── crossRegion.test.ts │ │ │ │ │ │ ├── drag.test.ts │ │ │ │ │ │ ├── layout.test.ts │ │ │ │ │ │ └── preview.test.ts │ │ │ │ │ ├── completion.ts │ │ │ │ │ ├── crossRegion.ts │ │ │ │ │ ├── drag.ts │ │ │ │ │ ├── layout.ts │ │ │ │ │ └── preview.ts │ │ │ │ ├── index.ts │ │ │ │ ├── plugin.ts │ │ │ │ ├── styles/ │ │ │ │ │ └── drag.css │ │ │ │ └── utils/ │ │ │ │ ├── defaultDragConfig.ts │ │ │ │ └── throttle.ts │ │ │ ├── tsconfig.build.json │ │ │ └── tsconfig.json │ │ ├── keyboard-shortcuts/ │ │ │ ├── LICENSE │ │ │ ├── README.md │ │ │ ├── package.json │ │ │ ├── rollup.config.js │ │ │ ├── src/ │ │ │ │ ├── index.ts │ │ │ │ └── plugin.ts │ │ │ ├── tsconfig.build.json │ │ │ └── tsconfig.json │ │ ├── localization/ │ │ │ ├── LICENSE │ │ │ ├── README.md │ │ │ ├── package.json │ │ │ ├── rollup.config.js │ │ │ ├── src/ │ │ │ │ ├── index.ts │ │ │ │ ├── locales/ │ │ │ │ │ ├── de.ts │ │ │ │ │ ├── es.ts │ │ │ │ │ ├── fr.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── ja.ts │ │ │ │ │ ├── ko.ts │ │ │ │ │ ├── types.ts │ │ │ │ │ └── zh.ts │ │ │ │ └── plugin.ts │ │ │ ├── tsconfig.build.json │ │ │ └── tsconfig.json │ │ └── sidebar/ │ │ ├── LICENSE │ │ ├── README.md │ │ ├── package.json │ │ ├── rollup.config.js │ │ ├── scripts/ │ │ │ └── build-css.mjs │ │ ├── src/ │ │ │ ├── DefaultCalendarSidebar.tsx │ │ │ ├── components/ │ │ │ │ ├── CalendarChip.tsx │ │ │ │ ├── CalendarList.tsx │ │ │ │ ├── DeleteCalendarDialog.tsx │ │ │ │ ├── ImportCalendarDialog.tsx │ │ │ │ ├── MergeCalendarDialog.tsx │ │ │ │ ├── MergeMenuItem.tsx │ │ │ │ ├── SidebarHeader.tsx │ │ │ │ └── SubscribeCalendarDialog.tsx │ │ │ ├── index.ts │ │ │ ├── plugin.ts │ │ │ └── styles/ │ │ │ ├── sidebar.css │ │ │ ├── tailwind-components.css │ │ │ └── tailwind.css │ │ ├── tsconfig.build.json │ │ └── tsconfig.json │ ├── react/ │ │ ├── LICENSE │ │ ├── README.md │ │ ├── package.json │ │ ├── rollup.config.js │ │ ├── src/ │ │ │ ├── DayFlowCalendar.tsx │ │ │ ├── hooks/ │ │ │ │ └── useCalendarApp.ts │ │ │ └── index.ts │ │ └── tsconfig.json │ ├── svelte/ │ │ ├── LICENSE │ │ ├── README.md │ │ ├── index.d.ts │ │ ├── package.json │ │ ├── rollup.config.js │ │ ├── src/ │ │ │ ├── DayFlowCalendar.svelte │ │ │ ├── index.ts │ │ │ ├── svelte-shims.d.ts │ │ │ └── useCalendarApp.ts │ │ ├── svelte.config.js │ │ └── tsconfig.json │ ├── ui/ │ │ ├── context-menu/ │ │ │ ├── LICENSE │ │ │ ├── README.md │ │ │ ├── package.json │ │ │ ├── rollup.config.js │ │ │ ├── scripts/ │ │ │ │ └── build-css.mjs │ │ │ ├── src/ │ │ │ │ ├── ContextMenu.tsx │ │ │ │ ├── index.ts │ │ │ │ └── styles/ │ │ │ │ ├── context-menu.css │ │ │ │ ├── tailwind-components.css │ │ │ │ └── tailwind.css │ │ │ ├── tsconfig.build.json │ │ │ └── tsconfig.json │ │ └── range-picker/ │ │ ├── LICENSE │ │ ├── README.md │ │ ├── package.json │ │ ├── rollup.config.js │ │ ├── scripts/ │ │ │ └── build-css.mjs │ │ ├── src/ │ │ │ ├── RangePicker.tsx │ │ │ ├── components/ │ │ │ │ ├── CalendarGrid.tsx │ │ │ │ ├── CalendarHeader.tsx │ │ │ │ ├── RangePickerPanel.tsx │ │ │ │ └── TimeSelector.tsx │ │ │ ├── constants.ts │ │ │ ├── icons.tsx │ │ │ ├── index.ts │ │ │ ├── styles/ │ │ │ │ ├── range-picker.css │ │ │ │ ├── tailwind-components.css │ │ │ │ └── tailwind.css │ │ │ ├── types.ts │ │ │ └── utils/ │ │ │ ├── locale.ts │ │ │ ├── rangePicker.ts │ │ │ └── temporal.ts │ │ ├── tsconfig.build.json │ │ └── tsconfig.json │ └── vue/ │ ├── LICENSE │ ├── README.md │ ├── package.json │ ├── rollup.config.js │ ├── src/ │ │ ├── DayFlowCalendar.ts │ │ ├── composables/ │ │ │ └── useCalendarApp.ts │ │ ├── index.ts │ │ └── vue-shims.d.ts │ └── tsconfig.json ├── pnpm-workspace.yaml ├── postcss.config.mjs ├── scripts/ │ ├── git-tag.sh │ ├── publish.sh │ ├── setup-website.sh │ └── update-versions.sh ├── tailwind.config.mjs ├── tsconfig.json ├── turbo.json └── website/ ├── .gitignore ├── .prettierrc ├── README.md ├── app/ │ ├── (home)/ │ │ ├── layout.tsx │ │ └── page.tsx │ ├── api/ │ │ └── search/ │ │ └── route.ts │ ├── blog/ │ │ ├── [...slug]/ │ │ │ └── page.tsx │ │ ├── layout.tsx │ │ └── page.tsx │ ├── docs/ │ │ ├── [[...slug]]/ │ │ │ └── page.tsx │ │ └── layout.tsx │ ├── docs-ja/ │ │ ├── [[...slug]]/ │ │ │ └── page.tsx │ │ └── layout.tsx │ ├── docs-zh/ │ │ ├── [[...slug]]/ │ │ │ └── page.tsx │ │ └── layout.tsx │ ├── global.css │ ├── layout.tsx │ ├── llms-full.txt/ │ │ └── route.ts │ ├── llms.txt/ │ │ └── route.ts │ ├── og/ │ │ └── docs/ │ │ └── [...slug]/ │ │ └── route.tsx │ ├── robots.ts │ ├── showcase/ │ │ └── mobile-event-detail/ │ │ └── page.tsx │ └── sitemap.ts ├── components/ │ ├── AppProvider.tsx │ ├── CliPreview.tsx │ ├── ColorPalette.tsx │ ├── DocsHeader.tsx │ ├── DocsSearchDialog.tsx │ ├── FrameworkInstall.tsx │ ├── FrameworkTabs.tsx │ ├── LanguageSwitcher.tsx │ ├── ai/ │ │ └── page-actions.tsx │ ├── showcase/ │ │ ├── ColorPickerShowcase.tsx │ │ ├── ContextMenuShowcase.tsx │ │ ├── CustomDetailDialogShowcase.tsx │ │ ├── CustomDetailPanelShowcase.tsx │ │ ├── EventContentShowcase.tsx │ │ ├── FeatureShowcase.tsx │ │ ├── InteractiveCalendar.tsx │ │ ├── LiveDemo.tsx │ │ ├── MobileEventDetailShowcase.tsx │ │ ├── MultiCalendarEventShowcase.tsx │ │ ├── SidebarShowcases.tsx │ │ ├── livedemo/ │ │ │ ├── CalendarViewer.tsx │ │ │ ├── ControlPanel.tsx │ │ │ ├── MiniDotsFeature.tsx │ │ │ ├── MultiCalFeature.tsx │ │ │ ├── ThemeColorColumn.tsx │ │ │ └── types.ts │ │ └── mobile-event-detail/ │ │ └── MobileEventDetailSimulator.tsx │ └── ui/ │ ├── alert.tsx │ ├── badge.tsx │ ├── button.tsx │ ├── card.tsx │ ├── checkbox.tsx │ ├── label.tsx │ ├── select.tsx │ ├── separator.tsx │ └── tooltip.tsx ├── content/ │ ├── blog/ │ │ ├── theme-customization.mdx │ │ ├── v1.4.mdx │ │ ├── v1.7.mdx │ │ ├── v1.8.mdx │ │ ├── v2.0.3.mdx │ │ └── v3.0.mdx │ ├── docs/ │ │ ├── features/ │ │ │ ├── calendar-header.mdx │ │ │ ├── content-slots.mdx │ │ │ ├── dark-mode.mdx │ │ │ ├── event-dialog.mdx │ │ │ ├── meta.json │ │ │ ├── multi-calendar-event.mdx │ │ │ ├── read-only.mdx │ │ │ └── switcher-mode.mdx │ │ ├── guides/ │ │ │ ├── global-css.mdx │ │ │ ├── meta.json │ │ │ ├── theme-customization.mdx │ │ │ └── timezones.mdx │ │ ├── introduction/ │ │ │ ├── dayflow-calendar.mdx │ │ │ ├── events.mdx │ │ │ ├── index.mdx │ │ │ ├── meta.json │ │ │ ├── pro-installation.mdx │ │ │ ├── resource-grid.mdx │ │ │ ├── resource-timeline.mdx │ │ │ ├── use-calendar-app.mdx │ │ │ └── views.mdx │ │ ├── meta.json │ │ ├── plugins/ │ │ │ ├── drag.mdx │ │ │ ├── events.mdx │ │ │ ├── keyboard-shortcuts.mdx │ │ │ ├── localization.mdx │ │ │ ├── meta.json │ │ │ ├── overview.mdx │ │ │ ├── print.mdx │ │ │ └── sidebar.mdx │ │ └── ui/ │ │ ├── context-menu.mdx │ │ ├── meta.json │ │ └── range-picker.mdx │ ├── docs-ja/ │ │ ├── features/ │ │ │ ├── calendar-header.mdx │ │ │ ├── content-slots.mdx │ │ │ ├── dark-mode.mdx │ │ │ ├── event-dialog.mdx │ │ │ ├── meta.json │ │ │ ├── multi-calendar-event.mdx │ │ │ ├── read-only.mdx │ │ │ └── switcher-mode.mdx │ │ ├── guides/ │ │ │ ├── global-css.mdx │ │ │ ├── meta.json │ │ │ ├── theme-customization.mdx │ │ │ └── timezones.mdx │ │ ├── introduction/ │ │ │ ├── dayflow-calendar.mdx │ │ │ ├── events.mdx │ │ │ ├── index.mdx │ │ │ ├── meta.json │ │ │ ├── resource-grid.mdx │ │ │ ├── resource-timeline.mdx │ │ │ ├── use-calendar-app.mdx │ │ │ └── views.mdx │ │ ├── meta.json │ │ ├── plugins/ │ │ │ ├── drag.mdx │ │ │ ├── events.mdx │ │ │ ├── keyboard-shortcuts.mdx │ │ │ ├── localization.mdx │ │ │ ├── meta.json │ │ │ ├── overview.mdx │ │ │ ├── print.mdx │ │ │ └── sidebar.mdx │ │ └── ui/ │ │ ├── context-menu.mdx │ │ ├── meta.json │ │ └── range-picker.mdx │ └── docs-zh/ │ ├── features/ │ │ ├── calendar-header.mdx │ │ ├── content-slots.mdx │ │ ├── dark-mode.mdx │ │ ├── event-dialog.mdx │ │ ├── meta.json │ │ ├── multi-calendar-event.mdx │ │ ├── read-only.mdx │ │ └── switcher-mode.mdx │ ├── guides/ │ │ ├── global-css.mdx │ │ ├── meta.json │ │ ├── theme-customization.mdx │ │ └── timezones.mdx │ ├── introduction/ │ │ ├── dayflow-calendar.mdx │ │ ├── events.mdx │ │ ├── index.mdx │ │ ├── meta.json │ │ ├── resource-grid.mdx │ │ ├── resource-timeline.mdx │ │ ├── use-calendar-app.mdx │ │ └── views.mdx │ ├── meta.json │ ├── plugins/ │ │ ├── drag.mdx │ │ ├── events.mdx │ │ ├── keyboard-shortcuts.mdx │ │ ├── localization.mdx │ │ ├── meta.json │ │ ├── overview.mdx │ │ ├── print.mdx │ │ └── sidebar.mdx │ └── ui/ │ ├── context-menu.mdx │ ├── meta.json │ └── range-picker.mdx ├── eslint.config.mjs ├── lib/ │ ├── cn.ts │ ├── i18n.ts │ ├── layout.shared.tsx │ ├── site.ts │ ├── source.tsx │ └── utils.ts ├── mdx-components.tsx ├── next.config.mjs ├── package.json ├── postcss.config.mjs ├── source.config.ts ├── tsconfig.json └── utils/ ├── palette.ts └── sampleData.ts ================================================ FILE CONTENTS ================================================ ================================================ FILE: .editorconfig ================================================ root = true [*] end_of_line = lf insert_final_newline = true indent_style = space indent_size = 2 charset = utf-8 trim_trailing_whitespace = true max_line_length = 80 ================================================ FILE: .github/FUNDING.yml ================================================ # These are supported funding model platforms github: [JayceV552] patreon: # Replace with a single Patreon username open_collective: # Replace with a single Open Collective username ko_fi: # Replace with a single Ko-fi username tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry liberapay: # Replace with a single Liberapay username issuehunt: # Replace with a single IssueHunt username lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry polar: # Replace with a single Polar username buy_me_a_coffee: # Replace with a single Buy Me a Coffee username thanks_dev: # Replace with a single thanks.dev username custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.md ================================================ --- name: Bug report about: Create a report to help us improve title: '' labels: '' assignees: '' --- **Describe the bug** A clear and concise description of what the bug is. **To Reproduce** Steps to reproduce the behavior: 1. Go to '...' 2. Click on '....' 3. Scroll down to '....' 4. See error **Expected behavior** A clear and concise description of what you expected to happen. **Screenshots** If applicable, add screenshots to help explain your problem. **Desktop (please complete the following information):** - OS: [e.g. iOS] - Browser [e.g. chrome, safari] - Version [e.g. 22] **Smartphone (please complete the following information):** - Device: [e.g. iPhone6] - OS: [e.g. iOS8.1] - Browser [e.g. stock browser, safari] - Version [e.g. 22] **Additional context** Add any other context about the problem here. ================================================ FILE: .github/ISSUE_TEMPLATE/feature_request.md ================================================ --- name: Feature request about: Suggest an idea for this project title: '' labels: '' assignees: '' --- **Is your feature request related to a problem? Please describe.** A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] **Describe the solution you'd like** A clear and concise description of what you want to happen. **Describe alternatives you've considered** A clear and concise description of any alternative solutions or features you've considered. **Additional context** Add any other context or screenshots about the feature request here. ================================================ FILE: .github/workflows/deploy.yml ================================================ name: Deploy to GitHub Pages on: push: branches: - main workflow_dispatch: permissions: contents: read pages: write id-token: write concurrency: group: 'pages' cancel-in-progress: false jobs: build: runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: '20' - name: Install deps run: | cd website npm install - name: Build website run: | cd website npm run build env: BASE_PATH: '' NEXT_PUBLIC_BASE_PATH: '' NEXT_PUBLIC_SITE_URL: https://calendar.dayflow.studio - name: Disable Jekyll and verify output run: | ls -la website/ if [ -d "website/out" ]; then touch website/out/.nojekyll echo "calendar.dayflow.studio" > website/out/CNAME echo "Created .nojekyll and CNAME in website/out" else echo "Error: website/out directory not found!" exit 1 fi - name: Upload artifact uses: actions/upload-pages-artifact@v3 with: path: website/out deploy: environment: name: github-pages url: ${{ steps.deployment.outputs.page_url }} runs-on: ubuntu-latest needs: build steps: - name: Deploy to GitHub Pages id: deployment uses: actions/deploy-pages@v4 ================================================ FILE: .gitignore ================================================ # Dependencies node_modules .pnp .pnp.js # Testing coverage # Next.js build dist website/.next website/out # Production *.log npm-debug.log* yarn-debug.log* yarn-error.log* # Misc .DS_Store *.pem # Debug .vscode/* !.vscode/settings.json !.vscode/extensions.json !.zed/settings.json .idea # Local env files .env*.local # Vercel .vercel # TypeScript *.tsbuildinfo next-env.d.ts # Turbo .turbo/ /bundle-analysis.html temp/ *.tgz ================================================ FILE: .nojekyll ================================================ ================================================ FILE: .npmignore ================================================ # Source files src/ website/ examples/ # Development files .git/ .gitignore .vscode/ .idea/ # Build artifacts .next/ node_modules/ # Logs *.log npm-debug.log* yarn-debug.log* yarn-error.log* # Configuration files (keep only essential ones) tsconfig.json tsconfig.build.json rollup.config.js # Documentation (except README) *.md !README.md # Test files tests/ __tests__/ *.test.* *.spec.* coverage/ # Environment files .env* # OS files .DS_Store Thumbs.db # Temporary files *.tmp *.temp *.tgz # Source maps (not needed in published package) *.map dist/**/*.map ================================================ FILE: .oxfmtrc.jsonc ================================================ // https://oxc.rs/docs/guide/usage/formatter/config-file-reference.html { "$schema": "./node_modules/oxfmt/configuration_schema.json", "printWidth": 80, "tabWidth": 2, "useTabs": false, "semi": true, "singleQuote": true, "quoteProps": "as-needed", "jsxSingleQuote": true, "trailingComma": "es5", "bracketSpacing": true, "bracketSameLine": false, "arrowParens": "avoid", "endOfLine": "lf", "sortPackageJson": { "sortScripts": true, }, "sortImports": { "ignoreCase": true, "newlinesBetween": true, "order": "asc", }, "sortTailwindcss": { "stylesheet": "./packages/core/src/styles/tailwind.css", "attributes": ["class", "className"], "functions": ["clsx", "cn", "cva", "tw"], }, "ignorePatterns": [ "pnpm-lock.yaml", "package-lock.json", "yarn.lock", "bun.lock", "pnpm-workspace.yaml", ".turbo", ".cache", ".output", "dist", ], } ================================================ FILE: .oxlintrc.json ================================================ { "$schema": "./node_modules/oxlint/configuration_schema.json", "ignorePatterns": ["**/*.mdx"], "plugins": [ "eslint", "typescript", "unicorn", "oxc", "import", "jsdoc", "node", "promise", "jest", "vitest", "react", "react-perf", "jsx-a11y", "nextjs", "vue" ], "env": { "browser": true }, "categories": { "correctness": "error", "perf": "error", "restriction": "error", "suspicious": "error", "pedantic": "error", "style": "off" }, "rules": { "complexity": "off", "no-await-in-loop": "off", "max-lines-per-function": "off", "no-inline-comments": "off", "no-implicit-coercion": "off", "no-magic-numbers": "off", "no-console": "off", "no-ternary": "off", "no-undefined": "off", "no-unused-vars": "warn", "max-lines": "off", "id-length": "off", "func-style": "off", "max-statements": "off", "no-plusplus": "off", "arrow-body-style": ["error", "as-needed"], "max-depth": "off", "max-params": "off", "capitalized-comments": "off", "new-cap": "off", "no-continue": "off", "no-barrel-file": "off", "init-declarations": "off", // Rely on oxfmt `sortImports` instead "sort-imports": "off", "sort-keys": "off", "no-duplicate-imports": ["error", { "allowSeparateTypeImports": true }], "import/no-default-export": "off", "import/exports-last": "off", "import/no-named-export": "off", "import/max-dependencies": "off", "import/no-unresolved": "off", "import/extensions": "off", "import/no-namespace": "off", "import/no-anonymous-default-export": "off", "import/prefer-default-export": "off", "import/group-exports": "off", "import/no-commonjs": "off", "import/no-duplicates": "error", "import/unambiguous": "off", "import/consistent-type-specifier-style": ["error", "prefer-top-level"], "import/no-dynamic-require": "off", "import/no-unassigned-import": "off", "import/no-relative-parent-imports": "error", "jsdoc/require-param": "off", "jsdoc/require-returns": "off", "jsdoc/require-param-type": "off", "jsdoc/require-returns-type": "off", "unicorn/explicit-length-check": "off", "unicorn/no-array-callback-reference": "off", "unicorn/no-process-exit": "off", "unicorn/prefer-global-this": "off", "unicorn/no-null": "off", "unicorn/prefer-top-level-await": "off", "unicorn/prefer-string-raw": "off", "unicorn/filename-case": "off", "unicorn/no-array-for-each": "off", "typescript/explicit-module-boundary-types": "off", "typescript/explicit-function-return-type": "off", "typescript/no-var-requires": "off", "typescript/no-unused-vars": "warn", "typescript/no-non-null-assertion": "off", "typescript/no-require-imports": "off", "typescript/require-await": "off", "node/no-process-env": "off", "oxc/no-map-spread": "off", "oxc/no-async-await": "off", "oxc/no-rest-spread-properties": "off", "oxc/no-optional-chaining": "off", "promise/catch-or-return": "off", "promise/always-return": "off", "vitest/prefer-called-times": "off", "react/jsx-max-depth": "off", "react/jsx-boolean-value": "off", "react/jsx-filename-extension": "off", "react/jsx-props-no-spreading": "off", "react/jsx-no-constructed-context-values": "warn", "react/no-array-index-key": "off", "react/no-multi-comp": "off", "react/no-unknown-property": "off", "react/react-in-jsx-scope": "off", "react/only-export-components": "off", "react_perf/jsx-no-jsx-as-prop": "off", "react_perf/jsx-no-new-object-as-prop": "off", "react_perf/jsx-no-new-array-as-prop": "off", "react_perf/jsx-no-new-function-as-prop": "off", "react-hooks/exhaustive-deps": "off", "jsx-a11y/alt-text": "warn", "jsx-a11y/click-events-have-key-events": "off", "jsx-a11y/heading-has-content": "warn", "jsx-a11y/no-autofocus": "off", "jsx-a11y/no-static-element-interactions": "off", "nextjs/no-img-element": "warn" }, "overrides": [ { "files": ["**/next-env.d.ts"], "rules": { "import/no-unassigned-import": "off" } } ] } ================================================ FILE: .vscode/extensions.json ================================================ { "recommendations": [ "oxc.oxc-vscode", "bradlc.vscode-tailwindcss", "fill-labs.dependi", "svelte.svelte-vscode", "antfu.pnpm-catalog-lens", "vue.volar" ] } ================================================ FILE: .vscode/settings.json ================================================ { "files.eol": "\n", // Suppress CSS language-server warnings for Tailwind-specific at-rules // (@apply, @variant, @theme, @source) that are valid PostCSS/Tailwind // directives but unknown to the standard VS Code CSS validator. "css.lint.unknownAtRules": "ignore", "prettier.enable": false, // "eslint.enable": false, "oxc.enable": true, "editor.tabSize": 2, "editor.formatOnSave": true, "editor.formatOnPaste": true, "editor.defaultFormatter": "oxc.oxc-vscode", "editor.codeActionsOnSave": { "source.fixAll.oxc": "explicit" }, "files.watcherExclude": { "**/node_modules/**": true, "**/dist/**": true, "**/build/**": true, "**/coverage/**": true }, "search.exclude": { "**/node_modules/**": true, "**/dist/**": true, "**/build/**": true, "**/coverage/**": true }, "[javascript]": { "editor.defaultFormatter": "oxc.oxc-vscode" }, "[javascriptreact]": { "editor.defaultFormatter": "oxc.oxc-vscode" }, "[typescript]": { "editor.defaultFormatter": "oxc.oxc-vscode" }, "[typescriptreact]": { "editor.defaultFormatter": "oxc.oxc-vscode" }, "[json]": { "editor.defaultFormatter": "oxc.oxc-vscode" }, "[jsonc]": { "editor.defaultFormatter": "oxc.oxc-vscode" }, "[css]": { "editor.defaultFormatter": "oxc.oxc-vscode" }, "[html]": { "editor.defaultFormatter": "oxc.oxc-vscode" }, "[vue]": { "editor.defaultFormatter": "oxc.oxc-vscode" }, "[vue-html]": { "editor.defaultFormatter": "oxc.oxc-vscode" }, "[markdown]": { "editor.defaultFormatter": "oxc.oxc-vscode" }, "[yaml]": { "editor.defaultFormatter": "oxc.oxc-vscode" }, "[tailwindcss]": { "editor.defaultFormatter": "oxc.oxc-vscode" } } ================================================ FILE: .zed/settings.json ================================================ { "formatter": "language_server", "format_on_save": "on", "languages": { "JavaScript": { "formatter": { "language_server": { "name": "oxfmt" } }, "code_actions_on_format": { "source.fixAll.oxc": true, "source.organizeImports.oxc": true } }, "TypeScript": { "formatter": { "language_server": { "name": "oxfmt" } }, "code_actions_on_format": { "source.fixAll.oxc": true, "source.organizeImports.oxc": true } }, "TSX": { "formatter": { "language_server": { "name": "oxfmt" } }, "code_actions_on_format": { "source.fixAll.oxc": true, "source.organizeImports.oxc": true } }, "JSON": { "formatter": { "language_server": { "name": "oxfmt" } }, "code_actions_on_format": { "source.fixAll.oxc": true, "source.organizeImports.oxc": true } }, "JSONC": { "formatter": { "language_server": { "name": "oxfmt" } }, "code_actions_on_format": { "source.fixAll.oxc": true, "source.organizeImports.oxc": true } }, "CSS": { "formatter": { "language_server": { "name": "oxfmt" } }, "code_actions_on_format": { "source.fixAll.oxc": true, "source.organizeImports.oxc": true } }, "HTML": { "formatter": { "language_server": { "name": "oxfmt" } }, "code_actions_on_format": { "source.fixAll.oxc": true, "source.organizeImports.oxc": true } }, "Markdown": { "formatter": { "language_server": { "name": "oxfmt" } }, "code_actions_on_format": { "source.fixAll.oxc": true, "source.organizeImports.oxc": true } }, "YAML": { "formatter": { "language_server": { "name": "oxfmt" } }, "code_actions_on_format": { "source.fixAll.oxc": true, "source.organizeImports.oxc": true } } }, "lsp": { "typescript-language-server": { "settings": { "typescript": { "preferences": { "includePackageJsonAutoImports": "on" } } } }, "oxlint": { "initialization_options": { "settings": { "disableNestedConfig": false, "fixKind": "safe_fix", "run": "onType", "typeAware": true, "unusedDisableDirectives": "deny" } } }, "oxfmt": { "initialization_options": { "settings": { "configPath": null, "flags": {}, "fmt.configPath": null, "fmt.experimental": true, "run": "onSave", "typeAware": false, "unusedDisableDirectives": false } } } } } ================================================ FILE: CHANGELOG.md ================================================ # Changelog All notable changes to this project will be documented in this file. ## [3.6.0] - 2026-05-03 ### New Features & Enhancements - **Agenda List View**: Added a new `createAgendaView` factory for a scrollable agenda-style list layout. Events are grouped by day with configurable options including `daysToShow`, `showEmptyDays`, and `timeFormat` (12h/24h). Supports both timed and all-day events, multi-day event continuation indicators, and full Content Slot integration. - **Grid Date Click Callbacks**: Added `gridDateClick` and `gridDateDoubleClick` callbacks to Month and Week views, enabling click and double-click handlers on empty date/time grid cells. - **Mobile Event Detail as Content Slot**: Migrated the internal `MobileEventDetailComponent` to the Content Slot system, making the mobile event detail panel fully customizable via framework render props. - **Keyboard Shortcuts Callbacks**: Added lifecycle callbacks to the keyboard-shortcuts plugin (`onKeyDown`, `onKeyUp`, and per-action hooks) for programmatic integration and custom shortcut handling. - **Sidebar `onReorder` Callback**: Added an `onReorder` callback to the sidebar plugin, fired whenever the user drags calendars to reorder them in the list. ### Fixed - **Range Picker `startOfWeek` Prop**: Added `startOfWeek` configuration to the range picker component so the calendar grid respects the locale or app-level week start setting. - **`useEventDetailPanel` API**: Moved `useEventDetailPanel` into `useCalendarApp`, making the event detail panel controls accessible from the same unified app hook across React, Vue, Angular, and Svelte. - **Framework Adapter Rendering**: Updated core rendering and framework-specific adapters (Vue, Angular, Svelte) to align with the Content Slot architecture introduced in v3.5.0. ### Performance - **View Rendering for Large Datasets**: Significantly optimized event layout calculations across Month, Week, Day, and Year views for calendars with large numbers of events. - **Plugin Bundle Size**: Reduced bundle size for the drag, keyboard-shortcuts, localization, sidebar, and UI packages by streamlining rollup configurations. - **Bundle Shrink & CSS Isolation**: Reduced the core bundle footprint and eliminated residual CSS conflicts by refining the build pipeline and library import boundaries. ## [3.5.0] - 2026-04-20 ### New Features & Enhancements - **Multi-calendar Events**: Added native `calendarIds` support on events. A single event can now belong to multiple calendars, stays visible as long as any linked calendar is visible, and renders with DayFlow's built-in multi-color striped styling. ### Fixed - **Style Self-Containment**: Completed the style refactor so DayFlow no longer depends on internal atomic CSS classes or host-side `@source` scanning. `styles.css` and `styles.components.css` are now self-contained distribution artifacts. - **Host Style Isolation**: Tightened `df-*` semantic styling contracts across core, UI packages, plugins, and dist CSS checks to reduce conflicts with UnoCSS, Tailwind, and other host styling systems. - **Year / Day / Week View Polish**: Refined several view-specific layout details, including year-view event rendering, day/week timezone labels, and overlay/token usage in complex UI surfaces. ### Documentation - Added multi-calendar event documentation and showcase examples. - Refreshed the website live demo controls and interactive examples. - Updated setup and theme customization guidance to match the self-contained styling model and remove outdated `@source`-based integration steps. ## [3.4.0] - 2026-04-06 ### New Features & Enhancements - **Global Calendar Time Zone**: Added `timeZone` to `useCalendarApp` / `CalendarAppConfig` as the primary display and editing timezone for all views. Day, Week, Month, and Year views now share the same calendar timezone semantics. - **Secondary Timeline Refinement**: Kept `secondaryTimeZone` for Day and Week views as a dedicated secondary timeline display setting, independent from the calendar's primary timezone. - **Mini Calendar Event Dots**: Added richer mini calendar event dot support, including calendar-color-aware dots with up to four unique indicators per day. - **Interactive Demo Controls**: Updated the website interactive calendar controls to expose both the global timezone and the Day/Week secondary timezone with clearer guidance. ### Fixed - **Timezone Consistency Across Views**: - Unified event projection in Day/Week/Month/Year so view filtering, layout, and visible dates stay aligned with the configured calendar timezone. - **Canonical Callback Semantics**: - Editing in a different calendar timezone now behaves: switching timezone only changes display, while `onEventUpdate`, `onEventDrop`, and `onEventResize` return canonical events after the edit is applied. - Aligned timed event creation flows such as quick create, context-menu paste, sidebar calendar drop, Month view create, and keyboard paste with `app.timeZone`. ### Documentation - Updated timezone documentation in English, Chinese, and Japanese to describe: - the new app-level `timeZone` - Day/Week-only `secondaryTimeZone` - canonical callback semantics after drag/resize/edit operations - Refreshed the website interactive calendar help text and tooltips to match the new timezone model. ## [3.3.0] - 2026-03-20 ### New Features & Enhancements - **Grid Year View**: Added a new `grid` mode for `createYearView`, providing a compact month-grid layout with heatmap intensity colors for event density visualization. - **`@create-dayflow` CLI**: Introduced the `npm create dayflow@latest` scaffolding tool. Interactively configures a new project with framework selection (React, Vue, Angular, Svelte), TypeScript support, and Tailwind CSS integration. - **`renderSidebarHeader`**: Added a `renderSidebarHeader` render prop to the sidebar plugin, allowing full customization of the sidebar header area (e.g. user avatar, collapse toggle). ### Fixed - **Style Isolation**: Fixed `tailwind-components.css` overriding host application styles. DayFlow's component CSS no longer emits Tailwind utility classes or leaks bare pseudo-class selectors (`:focus-visible`, `:checked`) to the host app. - **`bg-primary` Pollution**: Resolved context menu, sidebar merge menu, and calendar list items using the host application's `--color-primary` instead of DayFlow's own color variables. All interactive elements now use `var(--df-color-*)` directly. - **Month View Scroll**: Clicking on cross-month dates (previous month's trailing dates in the first row, next month's leading dates in the last row) no longer triggers unwanted month navigation. - **Portal Color Scope**: Added `df-portal` class to all `createPortal` root elements so portaled components (context menus, dialogs, drawers) correctly inherit DayFlow's color token scope. ### Style - Added `df-` prefix scoping to CSS class names in `tailwind-components.css` and `tailwind.css` to prevent conflicts with host application Tailwind instances. - Remapped `--color-primary` and related tokens within `.df-calendar-container` and `.df-portal` to always resolve to DayFlow's own `--df-color-*` variables, regardless of host app theme. ### Documentation - Migrated website from Nextra to Fumadocs. - Updated installation guides to feature `@create-dayflow` CLI as the primary setup method. - Updated theme customization guide. - Added `renderSidebarHeader` API documentation. ## [3.2.0] - 2026-02-28 ### New Features & Enhancements - **Drag & Drop Improvements**: - Added `onEventDrop` and `onEventResize` callbacks to the drag plugin for better event handling. - Updated Month and Year View all-day event drag indicators for better visual feedback. - **View Enhancements**: - Added `secondaryTimeZone` label support for Day and Week Views. - Added `timeFormat` configuration for Day and Week Views. - Updated configuration options for `monthView` and `yearView`. - **Developer Experience**: - Introduced `oxlint` for faster linting and improved code quality. - Added `pre-commit` hooks and `format:check` scripts to ensure code consistency. - Migrated to `pnpm workspace catalog` for better dependency management. - Added `.editorconfig` and improved VSCode settings/extensions recommendations. ### Performance - **Scrolling**: Optimized `MonthView` scrolling performance by memoizing scrollbar checks. ### Fixed - **Layout**: Resolved `eventLayout` stacking issues and improved mobile `WeekView` layout. - **Framework Support**: Corrected `ng-packagr` configuration schema path for Angular. - **Build & Packaging**: - Fixed CSS export errors and website build issues. - Removed duplicate `peerDependencies` in `package.json` files. - Fixed Tailwind CSS path configurations. - **UI/UX**: Fixed an issue where the "+ more" click in the website had no reaction. - **Documentation**: Corrected README image paths and updated view documentation. ### Style - Improved Day/Week View event resize pointer display. - Cleaned up Tailwind CSS class formatting. - Resolved various lint warnings reported by `oxlint`. ## [3.1.0] - 2026-02-20 ### Plugin Architecture & Decoupling This release introduces a new plugin-based architecture, further reducing the core bundle size and providing greater flexibility. Core features have been extracted into independent, optional packages. #### New Plugin Packages (v1.0.0) - **`@dayflow/plugin-drag`**: Handles all drag-and-drop interactions (move, resize, and create). - **`@dayflow/plugin-keyboard-shortcuts`**: Provides keyboard navigation and shortcuts support. - **`@dayflow/plugin-localization`**: Dedicated package for multi-language support and internationalization. - **`@dayflow/plugin-sidebar`**: Extracts the sidebar UI and logic into a standalone plugin. ### New Features & Enhancements - **Enhanced Visibility Control**: - Added `onVisibleRangeChange` callback with a `reason` parameter (scroll vs. navigation). - Marked `onVisibleMonthChange` as deprecated in favor of the more flexible range change callback. - **Improved API**: Simplified framework wrappers by removing the `sidebarConfig` attribute (now handled via the sidebar plugin). - **UI Refresh**: Updated the view switching button styles for a more modern look and feel. ### Fixed - **Accessibility**: Fixed an event scaling issue when using the keyboard `Tab` key for navigation. - **Search**: Improved search result location accuracy within the calendar views. - **Documentation**: Comprehensive updates to plugin documentation and multi-language guides. ### Breaking Changes - **Feature Extraction**: Drag-and-drop, keyboard shortcuts, and the sidebar are no longer included in `@dayflow/core` by default. You must install and register the corresponding plugins to retain these features. - **Sidebar Configuration**: The `sidebarConfig` prop has been removed from framework adapters. Configuration is now passed directly to the `@dayflow/plugin-sidebar` during initialization. ## [3.0.0] - 2026-02-15 ### Major Architectural Overhaul: Multi-Framework Support This version marks a complete rewrite of the DayFlow internal architecture, moving from a React-only library to a **framework-agnostic monorepo structure**. #### New Package Structure - **`@dayflow/core`**: The new heart of DayFlow. Powered by **Preact**, it handles all state management, layout algorithms, and the core rendering engine (~3KB gzipped). - **`@dayflow/react`**: High-performance React adapter. - **`@dayflow/vue`**: Brand new adapter for Vue 3. - **`@dayflow/svelte`**: Brand new adapter for Svelte 5 (with full SSR support). - **`@dayflow/angular`**: Brand new adapter for Angular (v14+). ### New Features - **Framework Agnostic**: Core logic and UI are now decoupled from specific frameworks. - **Improved Content Injection**: New **Content Slots** system allowing users to inject native framework components (React/Vue/Svelte/Angular) into the Preact-driven calendar. - **SSR Ready**: - **Svelte**: Provided dedicated SSR bundles (`dist/index.ssr.js`) to avoid DOM reference errors during server-side rendering. - **React/Vue**: Enhanced hydration safety. ### Fixed & Improved - Optimized mobile responsiveness for all framework adapters. - Improved build process using Rollup and Turborepo for faster and smaller bundles. ### Breaking Changes - **Package Names**: If you were using the old `dayflow` package, you should now migrate to framework-specific packages (e.g., `@dayflow/react`). - **Import Paths**: - Components and hooks are now exported from `@dayflow/[framework]`. - Core types and utilities are exported from `@dayflow/core`. - **External Dependencies**: To maintain framework-agnosticism, the built-in color picker (`react-color`) has been removed. Users should now provide their own color picker via Content Slots. --- ================================================ FILE: CNAME ================================================ calendar.dayflow.studio ================================================ FILE: CONTRIBUTING.md ================================================ # Contribution Guide Thank you for your interest in contributing to **DayFlow**! We welcome contributions from the community. Please follow this guide to set up the project and ensure your contributions align with our standards. ## 🚀 How to Start the Project If you have forked the repository and want to run the examples locally, follow these steps: 1. **Clone your fork:** ```bash git clone https://github.com/YOUR_USERNAME/DayFlow.git cd DayFlow ``` 2. **Install dependencies:** ```bash pnpm install ``` ### 3. Start the development server Navigate to the core package and start the dev server: ```bash cd packages/core pnpm run dev ``` The application will typically be available at: http://localhost:5529 --- ## Commit Message Convention Please follow these prefixes when writing commit messages: - `feat:` — new features - `fix:` — bug fixes - `docs:` — documentation updates - `style:` — formatting, missing semicolons, etc. (no logic changes) - `refactor:` — code changes that neither fix a bug nor add a feature - `test:` — adding or updating tests - `chore:` — maintenance tasks (build, dependencies, etc.) Example: ```bash feat: add drag-and-drop event support fix: resolve timezone offset issue in calendar view ``` --- ## Pull Request Guidelines To keep the project maintainable and easy to review: - Keep each pull request focused on **one feature, bug fix, or refactor** - Avoid mixing unrelated changes in a single PR - Provide a clear description of: - What changed - Why it was needed - Update documentation if necessary - Add or update tests when applicable - Ensure the project builds and runs correctly before submitting --- ## Development Tips - Keep changes minimal and focused - Follow existing code style and structure - When in doubt, open an issue first to discuss your idea --- ## License and Contribution Terms By submitting a contribution to this project, you represent that: - The contribution is your original work, or you have the legal right to submit it. - You grant DayFlow the right to use, modify, distribute, and relicense your contribution as part of both open source and commercial versions of the project. --- Thanks again for contributing to **DayFlow**! 🚀 ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2025 Jayce Li Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.ja.md ================================================ # DayFlow [English](README.md) | [中文](README.zh.md) | **日本語** | [はじめに & コントリビューション](CONTRIBUTING.md) ドラッグ&ドロップ、マルチビュー、プラグインアーキテクチャをサポートする、柔軟で機能豊富なReactカレンダーコンポーネントライブラリ。 [![npm](https://img.shields.io/npm/v/@dayflow/core?logo=npm&color=blue&label=version)](https://www.npmjs.com/package/@dayflow/core) [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen?logo=github)](https://github.com/dayflow-js/calendar/pulls) [![License](https://img.shields.io/github/license/dayflow-js/calendar)](https://github.com/dayflow-js/calendar/blob/main/LICENSE) [![Discord](https://img.shields.io/badge/Discord-Join%20Chat-5865F2?logo=discord&logoColor=white)](https://discord.gg/9vdFZKJqBb) ## 機能 ### 日次、週次、月次、年次、その他のビュータイプ #### 日次 ![日次](./assets/images/DayView.png) #### 週次 ![週次](./assets/images/WeekView.png) #### 月次 ![月次](./assets/images/MonthView.png) #### 年次 (固定週) ![年次](./assets/images/Year-Fixed-Week.png) #### 年次 (キャンバス) ![年次 (キャンバス)](./assets/images/Year-Canvas.png) ### モバイルビューのサポート #### モバイル日次 & 年次 ![モバイル日次と年次](./assets/images/Mobile-Day-Year.png) #### モバイル週次 & 月次 ![モバイル週次と月次](./assets/images/Mobile-Week-Month.png) ### デフォルトパネル(複数のイベント詳細パネルオプションが利用可能) #### 詳細ポップアップ ![詳細ポップアップ](./assets/images/popup.png) #### 詳細ダイアログ ![詳細ダイアログ](./assets/images/dialog.png) ### ダークモードのサポート ![ダークモード](./assets/images/DarkMode.png) ### ドラッグ&ドロップとリサイズも簡単 https://github.com/user-attachments/assets/726a5232-35a8-4fe3-8e7b-4de07c455353 https://github.com/user-attachments/assets/957317e5-02d8-4419-a74b-62b7d191e347 ## コントリビューション コントリビューションは大歓迎です!お気軽に Pull Request を送信してください。 ## バグ報告 バグを見つけた場合は、[GitHub Issues](https://github.com/dayflow-js/calendar/issues) で問題を報告してください。 ## サポート 質問やサポートについては、GitHub で Issue を開くか、Discord に参加してください。 --- ================================================ FILE: README.md ================================================ # DayFlow **English** | [中文](README.zh.md) | [日本語](README.ja.md) | [Getting Started & Contributing](CONTRIBUTING.md) A flexible and feature-rich calendar component library for **React, Vue, Angular, and Svelte** with drag-and-drop support, multiple views, and plugin architecture. [![npm](https://img.shields.io/npm/v/@dayflow/core?logo=npm&color=blue&label=version)](https://www.npmjs.com/package/@dayflow/core) [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen?logo=github)](https://github.com/dayflow-js/calendar/pulls) [![License](https://img.shields.io/github/license/dayflow-js/calendar)](https://github.com/dayflow-js/calendar/blob/main/LICENSE) [![Discord](https://img.shields.io/badge/Discord-Join%20Chat-5865F2?logo=discord&logoColor=white)](https://discord.gg/9vdFZKJqBb) ## Features ### Daily, Weekly, Monthly and Yearly View Types #### Day View ![Day View](./assets/images/DayView.png) #### Week View ![Week View](./assets/images/WeekView.png) #### Month View ![Month View](./assets/images/MonthView.png) #### Year View(Fixed-Week) ![Year View](./assets/images/Year-Fixed-Week.png) #### Year View(Year-Canvas) ![Year Canvas View](./assets/images/Year-Canvas.png) ### Mobile View Support #### Mobile Day & Year View ![Mobile Day and Year View](./assets/images/Mobile-Day-Year.png) #### Mobile Week & Month View ![Mobile Week and Month View](./assets/images/Mobile-Week-Month.png) ### Multiple Event Detail Panel options #### Detail Popup ![Popup](./assets/images/popup.png) #### Detail Dialog ![Dialog](./assets/images/dialog.png) ### Dark Mode Support ![Dark Mode](./assets/images/DarkMode.png) ### Easy to resize and drag https://github.com/user-attachments/assets/726a5232-35a8-4fe3-8e7b-4de07c455353 https://github.com/user-attachments/assets/957317e5-02d8-4419-a74b-62b7d191e347 ## Contributing Contributions are welcome! Please feel free to submit a Pull Request. ## Bug Reports If you find a bug, please file an issue on [GitHub Issues](https://github.com/dayflow-js/calendar/issues). ## Support For questions and support, please open an issue on GitHub or go to discord. --- ================================================ FILE: README.zh.md ================================================ # DayFlow [English](README.md) | **中文** | [日本語](README.ja.md) | [快速开始 & 贡献](CONTRIBUTING.md) 一个灵活且功能丰富的 React 日历组件库,支持拖拽、多视图和插件架构。 [![npm](https://img.shields.io/npm/v/@dayflow/core?logo=npm&color=blue&label=version)](https://www.npmjs.com/package/@dayflow/core) [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen?logo=github)](https://github.com/dayflow-js/calendar/pulls) [![License](https://img.shields.io/github/license/dayflow-js/calendar)](https://github.com/dayflow-js/calendar/blob/main/LICENSE) [![Discord](https://img.shields.io/badge/Discord-Join%20Chat-5865F2?logo=discord&logoColor=white)](https://discord.gg/9vdFZKJqBb) ## 功能特性 ### 日视图、周视图、月视图、年视图及多种视图类型 #### 日视图 ![日视图](./assets/images/DayView.png) #### 周视图 ![周视图](./assets/images/WeekView.png) #### 月视图 ![月视图](./assets/images/MonthView.png) #### 年视图 (固定周) ![年视图](./assets/images/Year-Fixed-Week.png) #### 年视图 (画布) ![年视图 (画布)](./assets/images/Year-Canvas.png) ### 移动端视图支持 #### 移动端日视图 & 年视图 ![移动端日视图与年视图](./assets/images/Mobile-Day-Year.png) #### 移动端周视图 & 月视图 ![移动端周视图与月视图](./assets/images/Mobile-Week-Month.png) ### 默认面板(提供多种事件详情面板选项) #### 详情弹窗 ![详情弹窗](./assets/images/popup.png) #### 详情对话框 ![详情对话框](./assets/images/dialog.png) ### 暗色模式支持 ![暗色模式](./assets/images/DarkMode.png) ### 轻松拖拽与缩放 https://github.com/user-attachments/assets/726a5232-35a8-4fe3-8e7b-4de07c455353 https://github.com/user-attachments/assets/957317e5-02d8-4419-a74b-62b7d191e347 ## 贡献 欢迎贡献!请随意提交 Pull Request。 ## Bug 反馈 如果您发现 Bug,请在 [GitHub Issues](https://github.com/dayflow-js/calendar/issues) 上提交 issue。 ## 支持 如有问题和支持需求,请在 GitHub 上打开 issue 或加入 discord。 --- ================================================ FILE: examples/defaultCalendarExample/defaultCalendarExample.tsx ================================================ import { Event, CalendarType, EventChange, ReadOnlyConfig, TimeZone, ViewType, } from '@dayflow/core'; import { createDragPlugin } from '@dayflow/plugin-drag'; import { createLocalizationPlugin, zh } from '@dayflow/plugin-localization'; import { useCalendarApp, DayFlowCalendar, createMonthView, createWeekView, createDayView, createYearView, createAgendaView, UseCalendarAppReturn, } from '@dayflow/react'; import { getWebsiteCalendars } from '@examples/utils/palette'; import { generateSampleEvents } from '@examples/utils/sampleData'; import { createKeyboardShortcutsPlugin } from '@keyboard-shortcuts/plugin'; import { createSidebarPlugin } from '@sidebar/plugin'; import { Sun, Moon, Globe, Clock } from 'lucide-react'; import React, { useState, useRef, useEffect, useMemo } from 'react'; const TZ_OPTIONS = Object.entries(TimeZone).map(([key, value]) => ({ label: `${key.replaceAll('_', ' ')} (${value})`, value, })); const hasRedo = (app: object): app is object & { redo: () => void } => 'redo' in app && typeof app.redo === 'function'; type ExampleThemeMode = 'light' | 'dark'; const getInitialThemeMode = (): ExampleThemeMode => { if (typeof window === 'undefined') { return 'light'; } if (document.documentElement.classList.contains('dark')) { return 'dark'; } const storedTheme = localStorage.getItem('theme'); if (storedTheme === 'dark' || storedTheme === 'light') { return storedTheme; } return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; }; const DefaultCalendarExample: React.FC<{ themeMode: ExampleThemeMode; }> = ({ themeMode }) => { const [events] = useState(generateSampleEvents()); const calendarRef = useRef(null); const [readOnly] = useState(false); // Global calendar timezone — affects all views' event bucketing and editing const [appTz, setAppTz] = useState(''); // Secondary timezone — only adds a second timeline label column in Day/Week const [secondaryTz, setSecondaryTz] = useState(''); const [isMobile, setIsMobile] = useState(false); useEffect(() => { const checkMobile = () => setIsMobile(window.innerWidth < 768); checkMobile(); window.addEventListener('resize', checkMobile); return () => window.removeEventListener('resize', checkMobile); }, []); const plugins = useMemo( () => [ createDragPlugin({ onEventDrop: (updatedEvent, _) => { console.log('onEventDrop:', updatedEvent); }, onEventResize: (updatedEvent, _) => { console.log('onEventResize:', updatedEvent); }, }), createSidebarPlugin({ createCalendarMode: 'modal', // colorPickerMode: 'default', showEventDots: true, }), createLocalizationPlugin({ locales: [zh], }), createKeyboardShortcutsPlugin({ callbacks: { redo: app => { console.log('Redo triggered via callback!', app); // You can add custom redo logic here if app.redo() is not enough if (hasRedo(app)) { app.redo(); } }, undo: app => { console.log('Undo triggered via callback!'); app.undo(); }, delete: (app, event) => { console.log('Delete triggered via callback!', event); if (!event) return; app.deleteEvent(event.id); app.selectEvent(null); console.log(`Deleted event without confirmation: ${event.title}`); }, }, }), ].filter(plugin => !(isMobile && plugin.name === 'sidebar')), [isMobile] ); const searchConfig = useMemo( () => ({ onResultClick: ({ event, defaultAction, }: { event: Event; defaultAction: () => void; }) => { console.log('Search result clicked:', event); defaultAction(); }, }), [] ); const views = useMemo( () => [ createDayView({ // timeFormat: '12h', secondaryTimeZone: secondaryTz || undefined, showEventDots: true, scrollToCurrentTime: true, }), createWeekView({ // timeFormat: '12h', secondaryTimeZone: secondaryTz || undefined, // startOfWeek: 2, // showAllDay: false, showEventDots: true, scrollToCurrentTime: true, }), createMonthView({ showWeekNumbers: true, // showMonthIndicator: false, showEventDots: true, }), createYearView({ mode: 'fixed-week', showTimedEventsInYearView: true, startOfWeek: 7, showEventDots: true, }), createAgendaView({}), ], [secondaryTz] ); const calendars = useMemo(() => getWebsiteCalendars(), []); const callbacks = useMemo( () => ({ onEventCreate: async (event: Event) => { await new Promise(resolve => { setTimeout(resolve, 500); }); console.log('create event:', event); }, onEventClick: (event: Event) => { console.log('click event:', event); }, onEventDoubleClick: (event: Event) => { console.log('double click event:', event); // You could use the event element as an anchor for a custom popover here return true; }, onEventUpdate: async (event: Event) => { await new Promise(resolve => { setTimeout(resolve, 1500); }); console.log('update event:', event); }, onEventDelete: async (eventId: string) => { await new Promise(resolve => { setTimeout(resolve, 1500); }); console.log('delete event:', eventId); }, onMoreEventsClick: (date: Date) => { console.log('more events click date:', date); calendarRef.current?.selectDate(date); calendarRef.current?.changeView(ViewType.DAY); }, onCalendarUpdate: async (cal: CalendarType) => { await new Promise(resolve => { setTimeout(resolve, 1500); }); console.log('update calendar:', cal); }, onCalendarDelete: async (calendarId: string) => { await new Promise(resolve => { setTimeout(resolve, 1500); }); console.log('delete calendar:', calendarId); }, onCalendarCreate: async (cal: CalendarType) => { await new Promise(resolve => { setTimeout(resolve, 1500); }); console.log('create calendar: w', cal); }, onCalendarMerge: async (sourceId: string, targetId: string) => { await new Promise(resolve => { setTimeout(resolve, 1500); }); console.log('merge calendar:', sourceId, targetId); }, onEventBatchChange: (event: EventChange[]) => { console.log('batch change events:', event); }, }), [] ); const calendar = useCalendarApp({ timeZone: appTz || undefined, views, theme: { mode: themeMode }, events: events, calendars, defaultCalendar: 'work', // useEventDetailDialog: true, // switcherMode: 'select', plugins, // locale: zh, defaultView: ViewType.MONTH, // useEventDetailDialog: true, // switcherMode: 'select' as const, // readOnly, callbacks, }); calendarRef.current = calendar; return (
{/* Global calendar timezone */}
Calendar Timezone
{/* Secondary timeline for Day/Week */}
Secondary Timeline (Day/Week)
( //
//
// 📅 // // {event.title} // //
//
// // 📍 {`${event.meta?.location || 'No location'}`} // // // {event.description} // //
//
// )} // eventContentWeek={({ event, isSelected }) => ( //
//
// // // {event.title} // //
//
// // 📍 {`${event.meta?.location || 'No location'}`} // // // {event.description} // //
//
// )} // eventContentMonth={({ event }) => ( //
// // {event.title} //
// )} // eventContentYear={({ event }) => ( //
// // {event.title} //
// )} // eventContentAllDayDay={({ event }) => ( //
// // {event.title} //
// )} // eventContentAllDayWeek={({ event }) => ( //
// // {event.title} //
// )} // eventContentAllDayMonth={({ event }) => ( //
// // {event.title} //
// )} // eventContentAllDayYear={({ event }) => ( //
// // {event.title} //
// )} />
); }; const ThemeToggle = ({ isDark, onToggle, }: { isDark: boolean; onToggle: () => void; }) => (
); export function CalendarTypesExample() { const [themeMode, setThemeMode] = useState(getInitialThemeMode); useEffect(() => { const root = document.documentElement; root.classList.toggle('dark', themeMode === 'dark'); root.classList.toggle('light', themeMode === 'light'); localStorage.theme = themeMode; }, [themeMode]); return (
{/* Header */}

Calendar Example

setThemeMode(current => (current === 'dark' ? 'light' : 'dark')) } />
{/* Calendar Instance */}
); } export default CalendarTypesExample; ================================================ FILE: examples/main.tsx ================================================ // import 'preact/debug'; import { createRoot } from 'react-dom/client'; // Local example shell utilities live in examples/styles/tailwind.css. // DayFlow component styles stay on the library side. import '@/styles/tailwind-components.css'; import './styles/tailwind.css'; import CalendarExample from './defaultCalendarExample/defaultCalendarExample'; const container = document.querySelector('#root'); if (container) { const root = createRoot(container); root.render(); } ================================================ FILE: examples/styles/tailwind.css ================================================ @import 'tailwindcss/preflight' layer(base); @import 'tailwindcss/theme' layer(theme); @import 'tailwindcss/utilities'; @source "../**/*.{ts,tsx}"; @variant dark (.dark &); ================================================ FILE: examples/utils/palette.ts ================================================ import { CalendarType, CalendarColors } from '@dayflow/core'; interface PaletteCalendar extends Pick< CalendarType, 'id' | 'name' | 'icon' | 'readOnly' > { color: string; colors: CalendarColors; darkColors: CalendarColors; source?: string; } export const CALENDAR_SIDE_PANEL: PaletteCalendar[] = [ { id: 'team', name: 'Product Team', source: 'Google', readOnly: true, color: '#2563eb', colors: { eventColor: 'rgba(37, 99, 235, 0.12)', eventSelectedColor: '#2563eb', lineColor: '#2563eb', textColor: '#1d4ed8', }, darkColors: { eventColor: 'rgba(59, 130, 246, 0.25)', eventSelectedColor: '#3b82f6', lineColor: '#60a5fa', textColor: '#dbeafe', }, }, { id: 'personal', name: 'Personal', source: 'iCloud', color: '#0ea5e9', colors: { eventColor: 'rgba(14, 165, 233, 0.12)', eventSelectedColor: '#0ea5e9', lineColor: '#0ea5e9', textColor: '#0369a1', }, darkColors: { eventColor: 'rgba(14, 165, 233, 0.24)', eventSelectedColor: '#38bdf8', lineColor: '#7dd3fc', textColor: '#e0f2fe', }, }, { id: 'learning', name: 'Learning', source: 'iCloud', color: '#8b5cf6', colors: { eventColor: 'rgba(139, 92, 246, 0.15)', eventSelectedColor: '#8b5cf6', lineColor: '#8b5cf6', textColor: '#5b21b6', }, darkColors: { eventColor: 'rgba(167, 139, 250, 0.28)', eventSelectedColor: '#a855f7', lineColor: '#c084fc', textColor: '#ede9fe', }, }, { id: 'travel', name: 'Travel', source: 'iCloud', color: '#f97316', colors: { eventColor: 'rgba(249, 115, 22, 0.15)', eventSelectedColor: '#f97316', lineColor: '#f97316', textColor: '#7c2d12', }, darkColors: { eventColor: 'rgba(251, 146, 60, 0.3)', eventSelectedColor: '#fb923c', lineColor: '#fdba74', textColor: '#ffedd5', }, }, { id: 'wellness', name: 'Wellness', source: 'Google', color: '#10b981', colors: { eventColor: 'rgba(16, 185, 129, 0.15)', eventSelectedColor: '#10b981', lineColor: '#10b981', textColor: '#065f46', }, darkColors: { eventColor: 'rgba(52, 211, 153, 0.25)', eventSelectedColor: '#34d399', lineColor: '#6ee7b7', textColor: '#ecfdf5', }, }, { id: 'marketing', name: 'Marketing', source: 'Google', color: '#ec4899', colors: { eventColor: 'rgba(236, 72, 153, 0.15)', eventSelectedColor: '#ec4899', lineColor: '#ec4899', textColor: '#831843', }, darkColors: { eventColor: 'rgba(244, 114, 182, 0.28)', eventSelectedColor: '#f472b6', lineColor: '#f9a8d4', textColor: '#fce7f3', }, }, { id: 'support', name: 'Support', source: 'Google', color: '#14b8a6', colors: { eventColor: 'rgba(20, 184, 166, 0.15)', eventSelectedColor: '#14b8a6', lineColor: '#14b8a6', textColor: '#115e59', }, darkColors: { eventColor: 'rgba(45, 212, 191, 0.25)', eventSelectedColor: '#5eead4', lineColor: '#99f6e4', textColor: '#ccfbf1', }, }, ]; export const getWebsiteCalendars = (): CalendarType[] => CALENDAR_SIDE_PANEL.map(item => ({ id: item.id, name: item.name, icon: item.icon, source: item.source, readOnly: item.readOnly, colors: { eventColor: `${item.color}30`, eventSelectedColor: `${item.color}`, lineColor: item.color, textColor: item.colors.textColor, }, isVisible: true, })); ================================================ FILE: examples/utils/sampleData.ts ================================================ import { Event } from '@dayflow/core'; import { Temporal } from 'temporal-polyfill'; const calendarIds = [ 'team', 'personal', 'learning', 'travel', 'wellness', 'marketing', 'support', ]; const titles = [ 'Product Sync', 'Design Review', 'Customer Call', 'Weekly Planning', 'Deep Work', 'Code Review', 'Brainstorm', 'Usability Test', 'Team Retro', 'Partner Demo', 'Lunch & Learn', 'Yoga Break', 'Travel Block', 'Hiring Interview', 'Content Planning', ]; const locations = [ 'Conference Room A', 'Meeting Room 302', 'Zoom Meeting', 'Main Office, 4th Floor', 'Starbucks Coffee', 'Community Center', 'Innovation Hub', 'Building 5, Lab 2', ]; const eventDetails: Record = { 'Product Sync': { description: 'Sync up on the latest product roadmap and milestones.', location: 'Room 101', }, 'Design Review': { description: 'Review the new UI/UX designs for the upcoming mobile app release.', location: 'Design Studio', }, 'Customer Call': { description: 'Discussion with key clients regarding feature requests and feedback.', location: 'Virtual', }, 'Weekly Planning': { description: 'Plan tasks and priorities for the upcoming week.', location: 'Main Hall', }, 'Deep Work': { description: 'Focus time for intense development and problem solving.', location: 'Quiet Zone', }, 'Code Review': { description: 'Review pull requests and ensure code quality standards.', location: 'Dev Corner', }, Brainstorm: { description: 'Ideation session for the next big feature.', location: 'Whiteboard Room', }, 'Usability Test': { description: 'Observe users interacting with the latest prototype.', location: 'User Lab', }, 'Team Retro': { description: 'Reflect on the past sprint and discuss improvements.', location: 'Common Area', }, 'Partner Demo': { description: 'Demonstrate our latest capabilities to potential partners.', location: 'Executive Suite', }, 'Lunch & Learn': { description: 'Educational session over lunch about new technologies.', location: 'Cafeteria', }, 'Yoga Break': { description: 'Stretch and relax with a quick yoga session.', location: 'Wellness Room', }, 'Travel Block': { description: 'Time allocated for travel and logistics.', location: 'Airport Terminal', }, 'Hiring Interview': { description: 'Interviewing candidates for the Senior Engineer position.', location: 'HR Office', }, 'Content Planning': { description: 'Plan the editorial calendar and upcoming blog posts.', location: 'Marketing Hub', }, }; // Simple deterministic random number generator const createRandom = (seed: number) => { let s = seed; return () => { const x = Math.sin(s++) * 10000; return x - Math.floor(x); }; }; const createRandomInt = (random: () => number) => (min: number, max: number) => Math.floor(random() * (max - min + 1)) + min; const DEFAULT_TIME_ZONE = Temporal.Now.timeZoneId(); const createTimedEvent = ( baseDate: Temporal.PlainDate, index: number, randomInt: (min: number, max: number) => number ): Event => { const title = titles[index % titles.length]; const details = eventDetails[title] || { description: 'General event details.', location: locations[index % locations.length], }; // Keep sample events concentrated in local working hours for easier testing. const startHour = randomInt(8, 15); const maxDuration = Math.max(1, 17 - startHour); const duration = Math.max(1, randomInt(1, Math.min(3, maxDuration))); const startPlain = baseDate.toPlainDateTime({ hour: startHour, minute: randomInt(0, 1) ? 30 : 0, }); const start = Temporal.ZonedDateTime.from({ timeZone: DEFAULT_TIME_ZONE, year: startPlain.year, month: startPlain.month, day: startPlain.day, hour: startPlain.hour, minute: startPlain.minute, }); const end = start.add({ hours: duration }); return { id: `event-${index}`, title: title, description: details.description, start, end, calendarId: calendarIds[index % calendarIds.length], meta: { location: details.location || locations[index % locations.length], }, }; }; const createAllDayEvent = ( start: Temporal.PlainDate, span: number, index: number, calendarId: string, title: string ): Event => { const details = eventDetails[title] || { description: 'All day event details.', location: 'Various', }; return { id: `all-day-${index}`, title, description: details.description, start, end: start.add({ days: span }), allDay: true, calendarId, icon: true, meta: { location: details.location || 'Multiple Locations', }, }; }; const baseAllDayDefinitions: Array<{ offset: number; span: number; calendarId: string; title: string; }> = [ { offset: -6, span: 2, calendarId: 'team', title: 'Sprint Offsite' }, { offset: -2, span: 0, calendarId: 'personal', title: 'Family Day' }, { offset: 3, span: 1, calendarId: 'travel', title: 'Client Visit' }, { offset: 7, span: 2, calendarId: 'marketing', title: 'Campaign Launch' }, { offset: 12, span: 0, calendarId: 'learning', title: 'Conference' }, { offset: 16, span: 3, calendarId: 'wellness', title: 'Wellness Retreat' }, { offset: 20, span: 1, calendarId: 'support', title: 'Support Rotation' }, ]; export interface Resource { id: string; title: string; avatar?: string; color?: string; meta?: Record; } export const generateSampleResources = (): Resource[] => [ { id: 'team', title: 'Team Alpha', avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=Alpha', color: '#3b82f6', }, { id: 'personal', title: 'John Doe', avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=John', color: '#ef4444', }, { id: 'learning', title: 'Training Room', avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=Room', color: '#10b981', }, { id: 'travel', title: 'Flight 101', avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=Flight', color: '#f59e0b', }, { id: 'wellness', title: 'Gym', avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=Gym', color: '#8b5cf6', }, { id: 'marketing', title: 'Marketing Suite', avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=Marketing', color: '#ec4899', }, { id: 'support', title: 'Support Desk', avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=Support', color: '#64748b', }, ]; /** Multi-calendar sample events that demonstrate calendarIds support. */ export const generateMultiCalendarEvents = (): Event[] => { const today = Temporal.Now.plainDateISO(); const tz = Temporal.Now.timeZoneId(); const makeZoned = ( date: Temporal.PlainDate, hour: number, minute = 0 ): Temporal.ZonedDateTime => Temporal.ZonedDateTime.from({ timeZone: tz, year: date.year, month: date.month, day: date.day, hour, minute, }); return [ // Two-calendar timed event — today { id: 'multi-cal-1', title: 'Family Dance Workshop', description: 'Shared across the whole family calendar.', start: makeZoned(today, 10), end: makeZoned(today, 11, 30), calendarIds: ['personal', 'wellness'], meta: { location: 'Community Center' }, }, // Three-calendar timed event — tomorrow { id: 'multi-cal-2', title: 'All-Hands Sprint Planning', description: 'Cross-team planning session.', start: makeZoned(today.add({ days: 1 }), 14), end: makeZoned(today.add({ days: 1 }), 16), calendarIds: ['team', 'marketing', 'support'], meta: { location: 'Main Hall' }, }, // Four-calendar all-day event — spans today + 2 days { id: 'multi-cal-3', title: 'Company Off-site', description: 'Belongs to every team calendar.', start: today.add({ days: 3 }), end: today.add({ days: 5 }), allDay: true, icon: true, calendarIds: ['team', 'personal', 'travel', 'learning'], }, // Two-calendar timed event — yesterday (tests backward visibility) { id: 'multi-cal-4', title: 'Retrospective + Wellness Check', description: 'Weekly retro combined with wellness review.', start: makeZoned(today.subtract({ days: 1 }), 15), end: makeZoned(today.subtract({ days: 1 }), 16), calendarIds: ['team', 'wellness'], meta: { location: 'Zoom' }, }, ]; }; export const generateSampleEvents = (): Event[] => { const today = Temporal.Now.plainDateISO(); const windowStart = today.subtract({ days: 24 }); const events: Event[] = []; // Initialize deterministic random generator const random = createRandom(12345); const randomInt = createRandomInt(random); for (let offset = 0; offset < 56; offset += 1) { const date = windowStart.add({ days: offset }); const dayEvents = randomInt(2, 4); for (let i = 0; i < dayEvents; i += 1) { events.push(createTimedEvent(date, offset * 10 + i, randomInt)); } } baseAllDayDefinitions.forEach((definition, index) => { const start = today.add({ days: definition.offset }); const span = Math.max(0, definition.span); events.push( createAllDayEvent( start, span, index, definition.calendarId, definition.title ) ); }); // Append multi-calendar demo events events.push(...generateMultiCalendarEvents()); // Annual events for Year View demonstration const currentYear = today.year; const annualEvents = [ // Jan: New Year & Kickoff { month: 1, day: 1, span: 3, calendarId: 'personal', title: 'New Year Holiday', }, { month: 1, day: 15, span: 5, calendarId: 'team', title: 'Annual Kickoff Week', }, { month: 1, day: 25, span: 3, calendarId: 'learning', title: 'Goal Setting Workshop', }, // Feb-Mar: Work Focus { month: 2, day: 5, span: 4, calendarId: 'team', title: 'Q1 Strategy Offsite', }, { month: 2, day: 14, span: 3, calendarId: 'personal', title: "Valentine's Trip", }, { month: 2, day: 26, span: 4, calendarId: 'learning', title: 'Tech Conference', }, { month: 3, day: 10, span: 4, calendarId: 'team', title: 'Design Sprint Week', }, { month: 3, day: 24, span: 4, calendarId: 'marketing', title: 'Product Launch Week', }, // Apr-May: Conferences & Holidays { month: 4, day: 12, span: 5, calendarId: 'travel', title: 'Spring Team Building', }, { month: 4, day: 25, span: 3, calendarId: 'personal', title: 'Anniversary Trip', }, { month: 5, day: 1, span: 3, calendarId: 'personal', title: 'Labour Day Holiday', }, { month: 5, day: 15, span: 4, calendarId: 'learning', title: 'Developer Summit', }, { month: 5, day: 28, span: 3, calendarId: 'marketing', title: 'Brand Workshop', }, // Jun-Jul: Travel & Vacation { month: 6, day: 10, span: 4, calendarId: 'support', title: 'Quarterly Review', }, { month: 6, day: 15, span: 14, calendarId: 'travel', title: 'Summer Vacation (Europe)', }, { month: 7, day: 8, span: 4, calendarId: 'team', title: 'Mid-Year Review Week', }, { month: 7, day: 20, span: 5, calendarId: 'wellness', title: 'Hiking Trip', }, // Aug-Sep: Projects & Learning { month: 8, day: 12, span: 6, calendarId: 'team', title: 'Hackathon Week' }, { month: 8, day: 25, span: 3, calendarId: 'wellness', title: 'Wellness Retreat', }, { month: 9, day: 5, span: 3, calendarId: 'learning', title: 'Leadership Training', }, { month: 9, day: 18, span: 4, calendarId: 'travel', title: 'Client Roadshow', }, // Oct-Nov: Q4 Push { month: 10, day: 10, span: 5, calendarId: 'team', title: 'Q4 Planning Week', }, { month: 10, day: 31, span: 3, calendarId: 'personal', title: 'Halloween Weekend', }, { month: 11, day: 15, span: 5, calendarId: 'marketing', title: 'Black Friday Prep', }, { month: 11, day: 24, span: 3, calendarId: 'personal', title: 'Thanksgiving Holiday', }, // Dec: Holidays { month: 12, day: 10, span: 3, calendarId: 'team', title: 'Year End Party Trip', }, { month: 12, day: 24, span: 5, calendarId: 'personal', title: 'Christmas Holiday', }, { month: 12, day: 29, span: 4, calendarId: 'travel', title: 'New Year Ski Trip', }, ]; annualEvents.forEach((def, index) => { try { const start = Temporal.PlainDate.from({ year: currentYear, month: def.month, day: def.day, }); events.push( createAllDayEvent( start, def.span, 2000 + index, // Use a high base index to avoid collisions def.calendarId, def.title ) ); } catch { // Ignore invalid dates (e.g. leap years edge cases in simple config) } }); return events; }; export const generateMinimalSampleEvents = (): Event[] => { const today = Temporal.Now.plainDateISO(); const windowStart = today.subtract({ days: 3 }); const events: Event[] = []; const random = createRandom(54321); const randomInt = createRandomInt(random); for (let offset = 0; offset < 7; offset += 1) { const date = windowStart.add({ days: offset }); const dayEvents = randomInt(1, 2); for (let i = 0; i < dayEvents; i += 1) { events.push(createTimedEvent(date, offset * 10 + i, randomInt)); } } // Add just a couple of all-day events events.push( createAllDayEvent( today.subtract({ days: 1 }), 2, 999, 'team', 'Minimal Team Event' ) ); return events; }; ================================================ FILE: index.html ================================================ Day Flow Example
================================================ FILE: lefthook.yml ================================================ pre-commit: jobs: - run: pnpm --filter @dayflow/core run check:css - run: pnpm format stage_fixed: true - run: pnpm lint:fix glob: - '*.js' - '*.jsx' - '*.ts' - '*.tsx' - '*.json' - '*.jsonc' - '*.css' - '*.md' stage_fixed: true - glob: 'website/**/*' run: pnpm --dir website run build ================================================ FILE: package.json ================================================ { "name": "dayflow-monorepo", "version": "3.1.2", "private": true, "description": "Multi-framework calendar component library", "scripts": { "build": "turbo run build", "clean": "turbo run clean && rimraf node_modules pnpm-lock.yaml", "dev": "turbo run dev", "format": "oxfmt .", "format:check": "oxfmt . --check", "lint": "oxlint .", "lint:fix": "oxlint . --fix", "prepare": "lefthook install", "publish:all": "./scripts/publish.sh all", "publish:cli": "./scripts/publish.sh cli", "publish:dry": "./scripts/publish.sh all --dry-run", "publish:main": "./scripts/publish.sh main", "tag": "./scripts/git-tag.sh", "test": "turbo run test", "typecheck": "turbo run typecheck", "version:update": "./scripts/update-versions.sh" }, "devDependencies": { "@dayflow/plugin-drag": "workspace:*", "@dayflow/plugin-keyboard-shortcuts": "workspace:*", "@dayflow/plugin-localization": "workspace:*", "@dayflow/plugin-sidebar": "workspace:*", "@dayflow/ui-range-picker": "workspace:*", "@preact/preset-vite": "catalog:", "@tailwindcss/postcss": "catalog:", "@types/react": "catalog:", "@types/react-dom": "catalog:", "autoprefixer": "catalog:", "lefthook": "^2.1.1", "lucide-react": "catalog:", "oxfmt": "^0.35.0", "oxlint": "^1.50.0", "postcss-import": "catalog:", "preact": "catalog:", "react": "catalog:", "react-dom": "catalog:", "rimraf": "catalog:", "tailwindcss": "catalog:", "temporal-polyfill": "catalog:", "tsc-alias": "^1.8.16", "turbo": "^2.8.10", "typescript": "catalog:" }, "packageManager": "pnpm@9.15.0" } ================================================ FILE: packages/angular/LICENSE ================================================ MIT License Copyright (c) 2025 Jayce Li Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: packages/angular/README.md ================================================ # DayFlow Angular A flexible and feature-rich Angular calendar component library with drag-and-drop support, multiple views, and plugin architecture. [![npm](https://img.shields.io/npm/v/@dayflow/angular?logo=npm&color=blue&label=version)](https://www.npmjs.com/package/@dayflow/angular) [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen?logo=github)](https://github.com/dayflow-js/dayflow/pulls) [![License](https://img.shields.io/github/license/dayflow-js/dayflow)](https://github.com/dayflow-js/dayflow/blob/main/LICENSE) [![Discord](https://img.shields.io/badge/Discord-Join%20Chat-5865F2?logo=discord&logoColor=white)](https://discord.gg/9vdFZKJqBb) ## Features ### Daily, Weekly, Monthly and Yearly View Types #### Day View ![Day View](https://raw.githubusercontent.com/dayflow-js/dayflow/main/assets/images/DayView.png) #### Week View ![Week View](https://raw.githubusercontent.com/dayflow-js/dayflow/main/assets/images/WeekView.png) #### Month View ![Month View](https://raw.githubusercontent.com/dayflow-js/dayflow/main/assets/images/MonthView.png) #### Year View(Fixed-Week) ![Year View](https://raw.githubusercontent.com/dayflow-js/dayflow/main/assets/images/Year-Fixed-Week.png) #### Year View(Year-Canvas) ![Year Canvas View](https://raw.githubusercontent.com/dayflow-js/dayflow/main/assets/images/Year-Canvas.png) ### Mobile View Support #### Mobile Day & Year View ![Mobile Day and Year View](https://raw.githubusercontent.com/dayflow-js/dayflow/main/assets/images/Mobile-Day-Year.png) #### Mobile Week & Month View ![Mobile Week and Month View](https://raw.githubusercontent.com/dayflow-js/dayflow/main/assets/images/Mobile-Week-Month.png) ### Multiple Event Detail Panel options #### Detail Popup ![Popup](https://raw.githubusercontent.com/dayflow-js/dayflow/main/assets/images/popup.png) #### Detail Dialog ![Dialog](https://raw.githubusercontent.com/dayflow-js/dayflow/main/assets/images/dialog.png) ### Dark Mode Support ![Dark Mode](https://raw.githubusercontent.com/dayflow-js/dayflow/main/assets/images/DarkMode.png) ### Easy to resize and drag https://github.com/user-attachments/assets/726a5232-35a8-4fe3-8e7b-4de07c455353 https://github.com/user-attachments/assets/957317e5-02d8-4419-a74b-62b7d191e347 ## Contributing Contributions are welcome! Please feel free to submit a Pull Request. ## Bug Reports If you find a bug, please file an issue on [GitHub Issues](https://github.com/dayflow-js/dayflow/issues). ## Support For questions and support, please open an issue on GitHub or go to discord. ================================================ FILE: packages/angular/ng-packagr.json ================================================ { "$schema": "./node_modules/ng-packagr/ng-package.schema.json", "lib": { "entryFile": "src/public-api.ts" }, "assets": ["README.md", "LICENSE"], "dest": "dist" } ================================================ FILE: packages/angular/package.json ================================================ { "name": "@dayflow/angular", "version": "3.6.2", "description": "Angular adapter for DayFlow calendar", "files": [ "**/*.mjs", "**/*.d.ts", "**/*.json", "README.md", "LICENSE" ], "scripts": { "build": "ng-packagr -p ng-packagr.json", "clean": "rimraf dist node_modules", "typecheck": "tsc --noEmit" }, "devDependencies": { "@angular/common": "^18.2.0", "@angular/compiler": "^18.2.0", "@angular/compiler-cli": "^18.2.0", "@angular/core": "^18.2.0", "@angular/platform-browser": "^18.2.0", "@angular/platform-browser-dynamic": "^18.2.0", "@dayflow/core": "workspace:*", "ng-packagr": "^18.2.1", "rimraf": "catalog:", "rxjs": "^7.8.1", "tslib": "catalog:", "typescript": "~5.5.0", "zone.js": "~0.14.10" }, "peerDependencies": { "@angular/common": ">=14.0.0", "@angular/core": ">=14.0.0", "@dayflow/core": "workspace:*" } } ================================================ FILE: packages/angular/src/lib/day-flow-calendar.component.ts ================================================ import { ElementRef, OnChanges, OnDestroy, AfterViewInit, SimpleChanges, TemplateRef, ChangeDetectorRef, Component, Input, ViewChild, ChangeDetectionStrategy, } from '@angular/core'; import type { ICalendarApp, CalendarAppConfig, CalendarAppConfigSyncSnapshot, UseCalendarAppReturn, CustomRendering, EventDetailContentProps, EventDetailDialogProps, CreateCalendarDialogProps, TitleBarSlotProps, EventContentSlotArgs, ColorPickerProps, CreateCalendarDialogColorPickerProps, CalendarHeaderProps, EventContextMenuSlotArgs, GridContextMenuSlotArgs, CalendarSearchProps, MobileEventProps, } from '@dayflow/core'; import { CalendarRenderer, CalendarApp, createConfigSyncSnapshot, createNormalizedCalendarAppConfigGetter, syncCalendarAppConfig, } from '@dayflow/core'; @Component({ selector: 'dayflow-calendar', template: `
`, changeDetection: ChangeDetectionStrategy.OnPush, }) export class DayFlowCalendarComponent implements AfterViewInit, OnChanges, OnDestroy { @Input() calendar!: ICalendarApp | UseCalendarAppReturn | CalendarAppConfig; // Templates for custom content injection @Input() eventContentDay?: TemplateRef; @Input() eventContentWeek?: TemplateRef; @Input() eventContentMonth?: TemplateRef; @Input() eventContentYear?: TemplateRef; @Input() eventContentAllDayDay?: TemplateRef; @Input() eventContentAllDayWeek?: TemplateRef; @Input() eventContentAllDayMonth?: TemplateRef; @Input() eventContentAllDayYear?: TemplateRef; @Input() eventDetailContent?: TemplateRef; @Input() eventDetailDialog?: TemplateRef; @Input() createCalendarDialog?: TemplateRef; @Input() titleBarSlot?: TemplateRef; @Input() colorPicker?: TemplateRef; @Input() createCalendarDialogColorPicker?: TemplateRef; @Input() calendarHeader?: TemplateRef; @Input() eventContextMenu?: TemplateRef; @Input() gridContextMenu?: TemplateRef; @Input() mobileEventDetail?: TemplateRef; @Input() collapsedSafeAreaLeft?: number; @Input() search?: CalendarSearchProps; @ViewChild('container') container!: ElementRef; customRenderings: CustomRendering[] = []; private renderer?: CalendarRenderer; private unsubscribe?: () => void; private internalApp?: CalendarApp; private getNormalizedInternalConfig?: () => CalendarAppConfig; private internalConfigSyncSnapshot?: CalendarAppConfigSyncSnapshot; constructor(private cdr: ChangeDetectorRef) {} private get app(): ICalendarApp { if (this.internalApp) { return this.internalApp; } if (this.calendar instanceof CalendarApp) { return this.calendar; } if ((this.calendar as { app?: ICalendarApp; views?: unknown[] }).app) { return (this.calendar as { app?: ICalendarApp; views?: unknown[] }).app!; } // If it's a config object, we create an internal instance if (DayFlowCalendarComponent.isCalendarConfig(this.calendar)) { return this.getOrCreateInternalApp(); } return this.calendar as ICalendarApp; } ngAfterViewInit() { this.initCalendar(); } ngOnChanges(changes: SimpleChanges) { if (changes['calendar'] && !changes['calendar'].firstChange) { if (this.canSyncInternalCalendarConfig(changes['calendar'])) { this.syncInternalCalendarConfig(); } else { this.resetInternalCalendarState(); this.destroyCalendar(); this.initCalendar(); } } else if (this.renderer) { if (changes['collapsedSafeAreaLeft'] || changes['search']) { this.renderer.setProps(this.getRendererProps()); } const slotKeys = [ 'eventContentDay', 'eventContentWeek', 'eventContentMonth', 'eventContentYear', 'eventContentAllDayDay', 'eventContentAllDayWeek', 'eventContentAllDayMonth', 'eventContentAllDayYear', 'eventDetailContent', 'eventDetailDialog', 'createCalendarDialog', 'titleBarSlot', 'colorPicker', 'createCalendarDialogColorPicker', 'calendarHeader', 'eventContextMenu', 'gridContextMenu', 'mobileEventDetail', ]; if (slotKeys.some(key => changes[key])) { const activeOverrides = this.getActiveOverrides(); this.renderer.getCustomRenderingStore().setOverrides(activeOverrides); this.app.setOverrides(activeOverrides); } } } ngOnDestroy() { this.destroyCalendar(); } private getRendererProps(): Record { return { collapsedSafeAreaLeft: this.collapsedSafeAreaLeft, search: this.search, }; } private initCalendar() { if (!this.container || !this.calendar) { return; } const activeOverrides = this.getActiveOverrides(); this.renderer = new CalendarRenderer(this.app, activeOverrides); this.renderer.setProps(this.getRendererProps()); this.renderer.mount(this.container.nativeElement); this.app.setOverrides(activeOverrides); this.unsubscribe = this.renderer .getCustomRenderingStore() .subscribe(renderings => { this.customRenderings = [...renderings.values()]; this.cdr.markForCheck(); }); } private getActiveOverrides(): string[] { const templateInputs: Record | undefined> = { eventContentDay: this.eventContentDay, eventContentWeek: this.eventContentWeek, eventContentMonth: this.eventContentMonth, eventContentYear: this.eventContentYear, eventContentAllDayDay: this.eventContentAllDayDay, eventContentAllDayWeek: this.eventContentAllDayWeek, eventContentAllDayMonth: this.eventContentAllDayMonth, eventContentAllDayYear: this.eventContentAllDayYear, eventDetailContent: this.eventDetailContent, eventDetailDialog: this.eventDetailDialog, createCalendarDialog: this.createCalendarDialog, titleBarSlot: this.titleBarSlot, colorPicker: this.colorPicker, createCalendarDialogColorPicker: this.createCalendarDialogColorPicker, calendarHeader: this.calendarHeader, eventContextMenu: this.eventContextMenu, gridContextMenu: this.gridContextMenu, mobileEventDetail: this.mobileEventDetail, }; return Object.keys(templateInputs).filter( key => templateInputs[key] !== null && templateInputs[key] !== undefined ); } private destroyCalendar() { if (this.unsubscribe) { this.unsubscribe(); } if (this.renderer) { this.renderer.unmount(); } this.unsubscribe = undefined; this.renderer = undefined; } private static isCalendarConfig(value: unknown): value is CalendarAppConfig { return ( !!value && typeof value === 'object' && 'views' in value && !('app' in value) ); } private getOrCreateInternalApp(): CalendarApp { if (!this.internalApp) { this.getNormalizedInternalConfig = createNormalizedCalendarAppConfigGetter( () => this.calendar as CalendarAppConfig ); const normalizedConfig = this.getNormalizedInternalConfig(); this.internalApp = new CalendarApp(normalizedConfig); this.internalConfigSyncSnapshot = createConfigSyncSnapshot(normalizedConfig); } return this.internalApp; } private canSyncInternalCalendarConfig( change: SimpleChanges['calendar'] ): boolean { return ( DayFlowCalendarComponent.isCalendarConfig(change.currentValue) && DayFlowCalendarComponent.isCalendarConfig(change.previousValue) && !!this.internalApp && !!this.getNormalizedInternalConfig && !!this.internalConfigSyncSnapshot ); } private syncInternalCalendarConfig() { if ( !this.internalApp || !this.getNormalizedInternalConfig || !this.internalConfigSyncSnapshot ) { return; } this.internalConfigSyncSnapshot = syncCalendarAppConfig( this.internalApp, this.internalConfigSyncSnapshot, this.getNormalizedInternalConfig() ); } private resetInternalCalendarState() { this.internalApp = undefined; this.getNormalizedInternalConfig = undefined; this.internalConfigSyncSnapshot = undefined; } getTemplate(name: string): TemplateRef | null { // Switch avoids allocating a new Record on every change-detection cycle. switch (name) { case 'eventContentDay': { return this.eventContentDay ?? null; } case 'eventContentWeek': { return this.eventContentWeek ?? null; } case 'eventContentMonth': { return this.eventContentMonth ?? null; } case 'eventContentYear': { return this.eventContentYear ?? null; } case 'eventContentAllDayDay': { return this.eventContentAllDayDay ?? null; } case 'eventContentAllDayWeek': { return this.eventContentAllDayWeek ?? null; } case 'eventContentAllDayMonth': { return this.eventContentAllDayMonth ?? null; } case 'eventContentAllDayYear': { return this.eventContentAllDayYear ?? null; } case 'eventDetailContent': { return this.eventDetailContent ?? null; } case 'eventDetailDialog': { return this.eventDetailDialog ?? null; } case 'createCalendarDialog': { return this.createCalendarDialog ?? null; } case 'titleBarSlot': { return this.titleBarSlot ?? null; } case 'colorPicker': { return this.colorPicker ?? null; } case 'createCalendarDialogColorPicker': { return this.createCalendarDialogColorPicker ?? null; } case 'calendarHeader': { return this.calendarHeader ?? null; } case 'eventContextMenu': { return this.eventContextMenu ?? null; } case 'gridContextMenu': { return this.gridContextMenu ?? null; } case 'mobileEventDetail': { return this.mobileEventDetail ?? null; } default: { return null; } } } // eslint-disable-next-line class-methods-use-this trackById(_index: number, item: CustomRendering) { return item.id; } } ================================================ FILE: packages/angular/src/lib/day-flow-calendar.module.ts ================================================ import { CommonModule } from '@angular/common'; import { NgModule } from '@angular/core'; import { DayFlowCalendarComponent } from './day-flow-calendar.component'; import { DayFlowPortalDirective } from './day-flow-portal.directive'; @NgModule({ declarations: [DayFlowCalendarComponent, DayFlowPortalDirective], imports: [CommonModule], exports: [DayFlowCalendarComponent, DayFlowPortalDirective], }) // eslint-disable-next-line @typescript-eslint/no-extraneous-class export class DayFlowCalendarModule {} ================================================ FILE: packages/angular/src/lib/day-flow-portal.directive.ts ================================================ import { ElementRef, OnChanges, SimpleChanges, OnDestroy, Directive, Input, } from '@angular/core'; @Directive({ selector: '[dayflowPortal]', }) export class DayFlowPortalDirective implements OnChanges, OnDestroy { @Input('dayflowPortal') targetEl!: HTMLElement; constructor(private el: ElementRef) {} ngOnChanges(changes: SimpleChanges) { if (changes['targetEl'] && this.targetEl) { this.targetEl.append(this.el.nativeElement); } } ngOnDestroy() { if (this.el.nativeElement.parentNode === this.targetEl) { this.el.nativeElement.remove(); } } } ================================================ FILE: packages/angular/src/public-api.ts ================================================ /* * Public API Surface of @dayflow/angular */ export * from './lib/day-flow-calendar.component'; export * from './lib/day-flow-calendar.module'; export * from './lib/day-flow-portal.directive'; export * from '@dayflow/core'; ================================================ FILE: packages/angular/tsconfig.json ================================================ { "extends": "../../tsconfig.json", "compilerOptions": { "outDir": "./dist/out-tsc", "types": [], "noUnusedLocals": false, "noUnusedParameters": false, "baseUrl": ".", "paths": { "@dayflow/core": ["../../packages/core/dist/index.d.ts"] } }, "include": ["src/**/*"], "exclude": ["node_modules", "dist"] } ================================================ FILE: packages/core/LICENSE ================================================ MIT License Copyright (c) 2025 Jayce Li Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: packages/core/README.md ================================================ # DayFlow Core The core engine of DayFlow, a flexible and feature-rich calendar library with drag-and-drop support, multiple views, and plugin architecture. [![npm](https://img.shields.io/npm/v/@dayflow/core?logo=npm&color=blue&label=version)](https://www.npmjs.com/package/@dayflow/core) [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen?logo=github)](https://github.com/dayflow-js/dayflow/pulls) [![License](https://img.shields.io/github/license/dayflow-js/dayflow)](https://github.com/dayflow-js/dayflow/blob/main/LICENSE) [![Discord](https://img.shields.io/badge/Discord-Join%20Chat-5865F2?logo=discord&logoColor=white)](https://discord.gg/9vdFZKJqBb) ## Features ### Daily, Weekly, Monthly and Yearly View Types #### Day View ![Day View](https://raw.githubusercontent.com/dayflow-js/dayflow/main/assets/images/DayView.png) #### Week View ![Week View](https://raw.githubusercontent.com/dayflow-js/dayflow/main/assets/images/WeekView.png) #### Month View ![Month View](https://raw.githubusercontent.com/dayflow-js/dayflow/main/assets/images/MonthView.png) #### Year View(Fixed-Week) ![Year View](https://raw.githubusercontent.com/dayflow-js/dayflow/main/assets/images/Year-Fixed-Week.png) #### Year View(Year-Canvas) ![Year Canvas View](https://raw.githubusercontent.com/dayflow-js/dayflow/main/assets/images/Year-Canvas.png) ### Mobile View Support #### Mobile Day & Year View ![Mobile Day and Year View](https://raw.githubusercontent.com/dayflow-js/dayflow/main/assets/images/Mobile-Day-Year.png) #### Mobile Week & Month View ![Mobile Week and Month View](https://raw.githubusercontent.com/dayflow-js/dayflow/main/assets/images/Mobile-Week-Month.png) ### Multiple Event Detail Panel options #### Detail Popup ![Popup](https://raw.githubusercontent.com/dayflow-js/dayflow/main/assets/images/popup.png) #### Detail Dialog ![Dialog](https://raw.githubusercontent.com/dayflow-js/dayflow/main/assets/images/dialog.png) ### Dark Mode Support ![Dark Mode](https://raw.githubusercontent.com/dayflow-js/dayflow/main/assets/images/DarkMode.png) ### Easy to resize and drag https://github.com/user-attachments/assets/726a5232-35a8-4fe3-8e7b-4de07c455353 https://github.com/user-attachments/assets/957317e5-02d8-4419-a74b-62b7d191e347 ## Contributing Contributions are welcome! Please feel free to submit a Pull Request. ## Bug Reports If you find a bug, please file an issue on [GitHub Issues](https://github.com/dayflow-js/dayflow/issues). ## Support For questions and support, please open an issue on GitHub or go to discord. ================================================ FILE: packages/core/bundle-analysis.html ================================================ Rollup Visualizer
================================================ FILE: packages/core/jest.config.mjs ================================================ export default { preset: 'ts-jest', testEnvironment: 'jsdom', roots: ['/src'], testMatch: ['**/__tests__/**/*.ts?(x)', '**/?(*.)+(spec|test).ts?(x)'], moduleNameMapper: { '^@/(.*)$': '/src/$1', '\\.(css|less|scss|sass)$': 'identity-obj-proxy', '^preact$': '/node_modules/preact/dist/preact.js', '^preact/hooks$': '/node_modules/preact/hooks/dist/hooks.js', '^preact/jsx-runtime$': '/node_modules/preact/jsx-runtime/dist/jsxRuntime.js', '^preact/compat$': '/node_modules/preact/compat/dist/compat.js', '^preact/test-utils$': '/node_modules/preact/test-utils/dist/testUtils.js', '^@testing-library/preact$': '/node_modules/@testing-library/preact/dist/cjs/index.js', '^@dayflow/ui-context-menu$': '/../ui/context-menu/src/index.ts', '^@dayflow/ui-range-picker$': '/../ui/range-picker/src/index.ts', '^@ui-range-picker/(.*)$': '/../ui/range-picker/src/$1', }, setupFilesAfterEnv: ['/src/setupTests.ts'], collectCoverageFrom: ['src/**/*.{ts,tsx}', '!src/**/*.d.ts', '!src/index.ts'], coverageThreshold: { global: { lines: 50, branches: 50, functions: 50, statements: 50, }, }, }; ================================================ FILE: packages/core/package.json ================================================ { "name": "@dayflow/core", "version": "3.6.2", "description": "A flexible and feature-rich calendar engine powered by Preact with drag-and-drop support, multiple views (Day, Week, Month, Year), and plugin architecture", "keywords": [ "calendar", "day-view", "drag-drop", "event-calendar", "events", "month-view", "plugin-architecture", "preact", "schedule", "typescript", "virtual-scroll", "week-view", "year-view" ], "homepage": "https://github.com/dayflow-js/dayflow#readme", "bugs": { "url": "https://github.com/dayflow-js/dayflow/issues" }, "license": "MIT", "author": "Jayce Li", "repository": { "type": "git", "url": "https://github.com/dayflow-js/dayflow.git" }, "files": [ "dist", "README.md", "LICENSE" ], "sideEffects": [ "*.css", "**/*.css" ], "main": "dist/index.esm.js", "module": "dist/index.esm.js", "types": "dist/index.d.ts", "exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.esm.js" }, "./dist/styles.css": "./dist/styles.css", "./dist/styles.components.css": "./dist/styles.components.css" }, "scripts": { "build": "tsc -p tsconfig.build.json && rollup -c --bundleConfigAsCjs && pnpm run build:css && pnpm run check:css", "build:css": "node ./scripts/build-css.mjs", "check:css": "pnpm run check:css:source && pnpm run check:css:dist", "check:css:dist": "node ./scripts/check-dist-styling.mjs --package-root .", "check:css:source": "node ./scripts/check-semantic-css.mjs", "clean": "rimraf dist node_modules", "dev": "vite --port 5529 --host", "postbuild": "rimraf dist/types", "prebuild": "rimraf dist", "prepublishOnly": "npm run typecheck && npm run check:css && npm run test && npm run build", "test": "jest", "test:coverage": "jest --coverage", "test:watch": "jest --watch", "typecheck": "tsc --noEmit" }, "dependencies": { "@dayflow/blossom-color-picker": "catalog:", "@dayflow/ui-context-menu": "workspace:*", "@dayflow/ui-range-picker": "workspace:*", "preact": "catalog:", "temporal-polyfill": "catalog:", "tslib": "catalog:" }, "devDependencies": { "@preact/preset-vite": "catalog:", "@rollup/plugin-commonjs": "catalog:", "@rollup/plugin-node-resolve": "catalog:", "@rollup/plugin-terser": "catalog:", "@rollup/plugin-typescript": "catalog:", "@tailwindcss/postcss": "catalog:", "@testing-library/jest-dom": "catalog:", "@testing-library/preact": "catalog:", "@testing-library/user-event": "catalog:", "@types/jest": "catalog:", "@types/lodash": "catalog:", "@types/node": "catalog:", "autoprefixer": "catalog:", "cssnano": "catalog:", "identity-obj-proxy": "^3.0.0", "jest": "catalog:", "jest-environment-jsdom": "catalog:", "postcss": "catalog:", "rimraf": "catalog:", "rollup": "catalog:", "rollup-plugin-dts": "catalog:", "rollup-plugin-peer-deps-external": "catalog:", "rollup-plugin-postcss": "catalog:", "rollup-plugin-visualizer": "catalog:", "tailwindcss": "catalog:", "ts-jest": "catalog:", "typescript": "catalog:", "vite": "catalog:" } } ================================================ FILE: packages/core/postcss.build.mjs ================================================ export default { plugins: { '@tailwindcss/postcss': {}, autoprefixer: {}, }, }; ================================================ FILE: packages/core/rollup.config.js ================================================ import path from 'node:path'; import resolve from '@rollup/plugin-node-resolve'; import terser from '@rollup/plugin-terser'; import typescript from '@rollup/plugin-typescript'; import { dts } from 'rollup-plugin-dts'; import peerDepsExternal from 'rollup-plugin-peer-deps-external'; import { visualizer } from 'rollup-plugin-visualizer'; export default [ { input: 'src/index.ts', output: [ { file: 'dist/index.esm.js', format: 'esm', sourcemap: false, exports: 'named', }, ], plugins: [ peerDepsExternal(), resolve({ browser: true, extensions: ['.js', '.jsx', '.ts', '.tsx'], alias: { '@': path.resolve('./src'), }, }), typescript({ tsconfig: './tsconfig.build.json', declaration: false, exclude: [ 'src/app/**', '**/*.test.ts', '**/*.test.tsx', '**/*.spec.ts', '**/*.spec.tsx', ], }), terser({ compress: { passes: 2, drop_console: true } }), visualizer({ filename: 'bundle-analysis.html', open: false, gzipSize: true, brotliSize: true, template: 'treemap', }), ], external: [ 'preact', 'preact/hooks', 'preact/compat', 'temporal-polyfill', 'tslib', '@dayflow/blossom-color-picker', ], }, { input: 'dist/types/index.d.ts', output: [{ file: 'dist/index.d.ts', format: 'es' }], plugins: [ dts({ compilerOptions: { baseUrl: '.', paths: { '@/*': ['./dist/types/*'], }, }, }), ], external: [/\.css$/], }, ]; ================================================ FILE: packages/core/scripts/atomic-css-baseline.json ================================================ { "source": {}, "distJs": {} } ================================================ FILE: packages/core/scripts/atomic-css-guard-utils.mjs ================================================ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); export const coreRoot = path.resolve(__dirname, '..'); export const workspaceRoot = path.resolve(coreRoot, '..', '..'); export const baselineFile = path.join(__dirname, 'atomic-css-baseline.json'); export const SOURCE_SCAN_ROOTS = [ { root: 'packages/core/src' }, { root: 'packages/plugins', requireSegment: `${path.sep}src${path.sep}` }, { root: 'packages/resource-grid/src' }, { root: 'packages/ui', requireSegment: `${path.sep}src${path.sep}` }, ]; export const FORBIDDEN_TOP_LEVEL_UTILITY_SELECTORS = [ String.raw`^\.flex-col\s*\{`, String.raw`^\.flex-row\s*\{`, String.raw`^\.grid\s*\{`, String.raw`^\.md\\:flex-row\s*\{`, String.raw`^\.bg-background\s*\{`, String.raw`^\.bg-primary\s*\{`, String.raw`^\.text-primary\s*\{`, String.raw`^\.bg-secondary\s*\{`, String.raw`^\.border-border\s*\{`, String.raw`^\.ring-primary\s*\{`, ]; const SOURCE_FILE_PATTERN = /\.(ts|tsx|js|jsx|mjs|cjs)$/; const DIST_JS_PATTERN = /\.(js|mjs|cjs)$/; const EXCLUDED_SOURCE_PATTERNS = [ /(?:^|\/)__tests__(?:\/|$)/, /\.test\.[jt]sx?$/, /\.spec\.[jt]sx?$/, /\.d\.ts$/, ]; const RESPONSIVE_PREFIXES = new Set(['sm', 'md', 'lg', 'xl', '2xl']); const STATE_PREFIXES = new Set([ 'dark', 'hover', 'focus', 'focus-visible', 'focus-within', 'active', 'disabled', 'group-hover', 'group-focus', 'motion-safe', 'motion-reduce', 'aria-selected', 'aria-expanded', 'data-[state=open]', ]); const EXACT_FORBIDDEN_TOKENS = new Set([ 'aspect-square', 'contents', 'cursor-pointer', 'cursor-default', 'overflow-auto', 'overflow-hidden', 'overflow-visible', 'select-none', 'shrink-0', 'snap-center', 'snap-mandatory', 'snap-y', 'uppercase', 'w-full', 'whitespace-nowrap', ]); const FORBIDDEN_PREFIXES = [ 'animate-', 'aspect-', 'backdrop-', 'bg-', 'border-', 'bottom-', 'break-', 'content-', 'cursor-', 'duration-', 'ease-', 'fill-', 'flex-', 'font-', 'gap-', 'grid-', 'h-', 'inset-', 'items-', 'justify-', 'leading-', 'left-', 'line-clamp-', 'm-', 'mb-', 'ml-', 'mr-', 'mt-', 'mx-', 'my-', 'max-h-', 'max-w-', 'min-h-', 'min-w-', 'object-', 'opacity-', 'origin-', 'outline-', 'overflow-', 'overscroll-', 'p-', 'pb-', 'pl-', 'pr-', 'pt-', 'px-', 'py-', 'right-', 'ring-', 'rotate-', 'rounded', 'scale-', 'shadow', 'shrink-', 'size-', 'snap-', 'space-x-', 'space-y-', 'stroke-', 'text-', 'top-', 'tracking-', 'transition', 'translate-', 'w-', 'will-change-', 'z-', ]; function collectFiles(dirPath, filterPattern) { if (!fs.existsSync(dirPath)) { return []; } return fs.readdirSync(dirPath, { withFileTypes: true }).flatMap(entry => { const entryPath = path.join(dirPath, entry.name); if (entry.isDirectory()) { return collectFiles(entryPath, filterPattern); } if (!filterPattern.test(entry.name)) { return []; } return [entryPath]; }); } export function isDayFlowSelector(selector) { return ( selector.startsWith('.df-') || selector.startsWith('.bcp-') || selector.startsWith('.dark ') ); } function stripComments(source) { return source .replaceAll(/\/\*[\s\S]*?\*\//g, ' ') .replaceAll(/(^|[^:])\/\/.*$/gm, '$1'); } function shouldSkipSourceFile(filePath) { return EXCLUDED_SOURCE_PATTERNS.some(pattern => pattern.test(filePath)); } function normalizeToken(token) { return token .trim() .replaceAll(/^[,;()[\]{}]+/g, '') .replaceAll(/[,;()[\]{}]+$/g, ''); } function isAllowedToken(token) { return token.startsWith('df-') || token.startsWith('bcp-'); } function hasForbiddenVariant(token) { if (!token.includes(':')) { return false; } const [prefix, suffix] = token.split(/:(.*)/s, 2); if (!suffix) { return false; } return RESPONSIVE_PREFIXES.has(prefix) || STATE_PREFIXES.has(prefix); } const ALLOWED_CSS_VALUES = new Set([ 'ease-in', 'ease-out', 'ease-in-out', 'select-all', 'pointer-events-auto', 'pointer-events-none', 'stroke-width', 'stroke-linecap', 'stroke-linejoin', ]); function hasForbiddenPrefix(token) { if (ALLOWED_CSS_VALUES.has(token)) { return false; } return FORBIDDEN_PREFIXES.some( prefix => token.startsWith(prefix) && token.length > prefix.length ); } function isArbitraryUtility(token) { // Only flag if it looks like a Tailwind arbitrary value/variant // e.g., bg-[#fff], w-[100px], [&_p]:mt-4 if (!/[[()].*[\])]/.test(token)) { return false; } // Must have a prefix followed by [ or ( // OR start with [& return ( /^[a-z0-9-]+[[()]/.test(token) || token.startsWith('[&') || token.startsWith('@') ); } function looksLikeForbiddenClassToken(token) { if (!token || isAllowedToken(token)) { return false; } if (hasForbiddenVariant(token)) { return true; } if (EXACT_FORBIDDEN_TOKENS.has(token)) { return true; } if (hasForbiddenPrefix(token)) { return true; } return isArbitraryUtility(token); } function extractTokensFromText(text) { return text .replaceAll(/\$\{[\s\S]*?\}/g, ' ') .split(/\s+/) .map(normalizeToken) .filter(Boolean) .filter(looksLikeForbiddenClassToken); } function lineForIndex(source, index) { return source.slice(0, index).split('\n').length; } function collectForbiddenTokens({ filePath, source, literalContent, snippet, index, }) { const tokens = extractTokensFromText(literalContent); if (tokens.length === 0) { return []; } const line = lineForIndex(source, index); return tokens.map(token => ({ file: path.relative(workspaceRoot, filePath), line, token, snippet: snippet.slice(0, 120), })); } function scanStringLiterals(filePath, content) { const stripped = stripComments(content); const matches = []; const stringLiteralPattern = /(["'`])((?:\\.|(?!\1)[\s\S])*?)\1/g; for (const match of stripped.matchAll(stringLiteralPattern)) { const [fullMatch, quote, inner] = match; if (!inner) continue; matches.push( ...collectForbiddenTokens({ filePath, source: stripped, literalContent: inner, snippet: fullMatch, index: match.index ?? 0, }) ); if (quote === '`') { const expressionPattern = /\$\{([\s\S]*?)\}/g; for (const expressionMatch of inner.matchAll(expressionPattern)) { const expression = expressionMatch[1]; if (!expression) continue; for (const nestedMatch of expression.matchAll(stringLiteralPattern)) { const [nestedFullMatch, , nestedInner] = nestedMatch; if (!nestedInner) continue; const nestedIndex = (match.index ?? 0) + (expressionMatch.index ?? 0) + 2 + (nestedMatch.index ?? 0); matches.push( ...collectForbiddenTokens({ filePath, source: stripped, literalContent: nestedInner, snippet: nestedFullMatch, index: nestedIndex, }) ); } } } } return matches; } export function scanSourceViolations() { const files = SOURCE_SCAN_ROOTS.flatMap(target => collectFiles( path.join(workspaceRoot, target.root), SOURCE_FILE_PATTERN ).filter(filePath => target.requireSegment ? filePath.includes(target.requireSegment) : true ) ).filter(filePath => !shouldSkipSourceFile(filePath)); return files.flatMap(filePath => scanStringLiterals(filePath, fs.readFileSync(filePath, 'utf8')) ); } export function scanDistJsViolations(packageRoot) { const root = path.resolve(packageRoot); const distRoot = path.join(root, 'dist'); const files = collectFiles(distRoot, DIST_JS_PATTERN).filter( filePath => !filePath.endsWith('.d.ts') && !filePath.includes(`${path.sep}dist${path.sep}build${path.sep}`) ); return files.flatMap(filePath => scanStringLiterals(filePath, fs.readFileSync(filePath, 'utf8')).map( violation => ({ ...violation, file: path.relative(workspaceRoot, filePath), }) ) ); } export function summarizeViolationsByFile(violations) { const summary = {}; for (const violation of violations) { summary[violation.file] = (summary[violation.file] ?? 0) + 1; } return summary; } export function loadBaseline() { if (!fs.existsSync(baselineFile)) { return { source: {}, distJs: {} }; } return JSON.parse(fs.readFileSync(baselineFile, 'utf8')); } export function writeBaseline(baseline) { fs.writeFileSync(baselineFile, `${JSON.stringify(baseline, null, 2)}\n`); } export function diffAgainstBaseline(current, baselineSection) { return Object.entries(current) .map(([file, count]) => ({ file, count, baseline: baselineSection[file] ?? 0, })) .filter(entry => entry.count > entry.baseline) .toSorted((left, right) => left.file.localeCompare(right.file)); } export function parseArgs(argv) { const args = new Map(); for (let i = 0; i < argv.length; i += 1) { const arg = argv[i]; if (!arg.startsWith('--')) continue; const [key, inlineValue] = arg.split('='); if (inlineValue !== undefined) { args.set(key, inlineValue); continue; } const next = argv[i + 1]; if (!next || next.startsWith('--')) { args.set(key, true); continue; } args.set(key, next); i += 1; } return args; } ================================================ FILE: packages/core/scripts/build-css.mjs ================================================ import fs from 'node:fs/promises'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import tailwindcss from '@tailwindcss/postcss'; import autoprefixer from 'autoprefixer'; import cssnano from 'cssnano'; import postcss, { parse } from 'postcss'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const root = path.resolve(__dirname, '..'); function stripCascadeLayers(css) { const rootNode = parse(css); rootNode.walkAtRules('layer', atRule => { if (!atRule.nodes?.length) { atRule.remove(); return; } atRule.replaceWith(...atRule.nodes); }); return rootNode.toString(); } async function buildCss(inputFile, outputFile) { const input = path.join(root, inputFile); const output = path.join(root, outputFile); const css = await fs.readFile(input, 'utf8'); const result = await postcss([ tailwindcss, autoprefixer, cssnano({ preset: ['default', { uniqueSelectors: false }] }), ]).process(css, { from: input, to: output, }); const builtCss = outputFile === 'dist/styles.css' ? stripCascadeLayers(result.css) : result.css; await fs.writeFile(output, builtCss); if (result.map) { await fs.writeFile(`${output}.map`, result.map.toString()); } console.log('CSS built successfully →', path.relative(root, output)); } await buildCss('src/styles/tailwind.css', 'dist/styles.css'); await buildCss( 'src/styles/tailwind-components.css', 'dist/styles.components.css' ); ================================================ FILE: packages/core/scripts/check-dist-styling.mjs ================================================ import fs from 'node:fs'; import path from 'node:path'; import { FORBIDDEN_TOP_LEVEL_UTILITY_SELECTORS, diffAgainstBaseline, isDayFlowSelector, loadBaseline, parseArgs, scanDistJsViolations, summarizeViolationsByFile, writeBaseline, workspaceRoot, } from './atomic-css-guard-utils.mjs'; const args = parseArgs(process.argv.slice(2)); const packageRoot = path.resolve( String(args.get('--package-root') ?? process.cwd()) ); const shouldWriteBaseline = Boolean(args.get('--write-baseline')); const packageName = path.relative(workspaceRoot, packageRoot) || '.'; const stylesComponentsPath = path.join( packageRoot, 'dist', 'styles.components.css' ); function readCss(filePath) { if (!fs.existsSync(filePath)) { throw new Error(`Dist CSS file not found: ${filePath}`); } return fs.readFileSync(filePath, 'utf8'); } function checkComponentsCss(css) { const failures = []; for (const pattern of FORBIDDEN_TOP_LEVEL_UTILITY_SELECTORS) { if (new RegExp(pattern, 'm').test(css)) { failures.push(`Top-level utility selector detected: ${pattern}`); } } const topLevelSelectors = css .split('\n') .filter(line => /^\.[a-z]/.test(line.trim())) .map(line => line.trim()); const violations = topLevelSelectors.filter( selector => !isDayFlowSelector(selector) ); if (violations.length > 0) { failures.push( `Found non-namespaced top-level selectors: ${violations.slice(0, 10).join(', ')}` ); } return failures; } const cssFailures = checkComponentsCss(readCss(stylesComponentsPath)); if (cssFailures.length > 0) { console.error( `[check-dist-styling] CSS contract violations detected for ${packageName}.` ); for (const failure of cssFailures) { console.error(` - ${failure}`); } process.exit(1); } const jsViolations = scanDistJsViolations(packageRoot); const jsSummary = summarizeViolationsByFile(jsViolations); const baseline = loadBaseline(); if (shouldWriteBaseline) { writeBaseline({ ...baseline, distJs: { ...baseline.distJs, ...jsSummary, }, }); console.log( `[check-dist-styling] Updated dist JS baseline for ${packageName} (${Object.keys(jsSummary).length} files).` ); process.exit(0); } const jsRegressions = diffAgainstBaseline(jsSummary, baseline.distJs ?? {}); if (jsRegressions.length > 0) { console.error( `[check-dist-styling] Dist JS atomic CSS regressions detected for ${packageName}.` ); console.error( 'The dist guard blocks new atomic utility debt in published JS while allowing the current migration baseline.' ); for (const regression of jsRegressions) { console.error( ` - ${regression.file}: ${regression.count} violations (baseline ${regression.baseline})` ); } process.exit(1); } console.log( `[check-dist-styling] Dist CSS/JS contract respected for ${packageName}.` ); ================================================ FILE: packages/core/scripts/check-semantic-css.mjs ================================================ import path from 'node:path'; import { diffAgainstBaseline, loadBaseline, parseArgs, scanSourceViolations, summarizeViolationsByFile, writeBaseline, } from './atomic-css-guard-utils.mjs'; const args = parseArgs(process.argv.slice(2)); const shouldWriteBaseline = Boolean(args.get('--write-baseline')); const violations = scanSourceViolations(); const summary = summarizeViolationsByFile(violations); const baseline = loadBaseline(); if (shouldWriteBaseline) { writeBaseline({ ...baseline, source: summary, }); console.log( `[check-semantic-css] Updated source baseline with ${Object.keys(summary).length} files.` ); process.exit(0); } const regressions = diffAgainstBaseline(summary, baseline.source ?? {}); if (regressions.length > 0) { console.error( '[check-semantic-css] Atomic CSS regressions detected in source files.' ); console.error( 'The guard allows existing baseline debt, but blocks any new internal atomic utility usage.' ); console.error( 'Allowed pass-through values such as `className={className}` are ignored; only literal internal class strings are counted.' ); for (const regression of regressions) { console.error( ` - ${regression.file}: ${regression.count} violations (baseline ${regression.baseline})` ); const details = violations .filter(violation => violation.file === regression.file) .slice(0, regression.count - regression.baseline + 3); for (const detail of details) { console.error( ` line ${detail.line}: ${detail.token} <- ${detail.snippet}` ); } } console.error(); console.error( `If the increase is intentional during migration, update ${path.relative(process.cwd(), new URL('./atomic-css-baseline.json', import.meta.url).pathname)} after review.` ); process.exit(1); } console.log( `[check-semantic-css] Source atomic CSS baseline respected (${violations.length} tracked matches across ${Object.keys(summary).length} files).` ); ================================================ FILE: packages/core/src/components/calendarEvent/CalendarEvent.tsx ================================================ import { memo } from 'preact/compat'; import { useRef, useState, useEffect, useCallback, useMemo, useContext, } from 'preact/hooks'; import { Temporal } from 'temporal-polyfill'; import { EventContextMenu } from '@/components/contextMenu'; import { ContentSlot } from '@/renderer/ContentSlot'; import { CustomRenderingContext } from '@/renderer/CustomRenderingContext'; import { Event, ViewType, ReadOnlyConfig, EventDetailContentProps, } from '@/types'; import { getSelectedBgColor, getEventBgColor, getEventTextColor, getPrimaryCalendarId, getCalendarEventBgColors, buildDiagonalPatternBackground, temporalToVisualTemporal, } from '@/utils'; import { EventContent } from './components/EventContent'; import { EventDetailPanel } from './components/EventDetailPanel'; import { useClickOutside } from './hooks/useClickOutside'; import { useDetailPanelPosition } from './hooks/useDetailPanelPosition'; import { useEventActions } from './hooks/useEventActions'; import { useEventInteraction } from './hooks/useEventInteraction'; import { useEventStyles } from './hooks/useEventStyles'; import { useEventVisibility } from './hooks/useEventVisibility'; import { CalendarEventProps } from './types'; // Import extracted utils and hooks import { getDayMetrics, getActiveDayIndex, getClickedDayIndex, getEventClasses, getEventSegmentShape, } from './utils'; const HIGHLIGHT_POP_DURATION_MS = 650; const CalendarEvent = ({ event, layout, isAllDay = false, allDayHeight = 28, calendarRef, isBeingDragged = false, isBeingResized = false, viewType, isMultiDay = false, segment, yearSegment, columnsPerRow, segmentIndex = 0, hourHeight, firstHour, selectedEventId, detailPanelEventId, onMoveStart, onResizeStart, onEventUpdate, onEventDelete, newlyCreatedEventId, onDetailPanelOpen, onEventSelect, onEventLongPress, onDetailPanelToggle, useEventDetailPanel, multiDaySegmentInfo, app, isMobile = false, isSlidingView = false, enableTouch, hideTime, timeFormat = '24h', styleOverride, className, disableDefaultStyle = false, renderVisualContent, resizeHandleOrientation, appTimeZone, monthEventHeight, }: CalendarEventProps) => { const customRenderingStore = useContext(CustomRenderingContext); const isTouchEnabled = enableTouch ?? isMobile; const isYearView = viewType === ViewType.YEAR; // Visual event for display (shifted walls) const visualEvent = useMemo(() => { if (!appTimeZone || event.allDay) return event; const start = temporalToVisualTemporal( event.start as Temporal.PlainDate, appTimeZone ); const end = event.end ? temporalToVisualTemporal(event.end as Temporal.PlainDate, appTimeZone) : undefined; return { ...event, start, end } as Event; }, [event, appTimeZone]); const [contextMenuPosition, setContextMenuPosition] = useState<{ x: number; y: number; } | null>(null); const [isPopping, setIsPopping] = useState(false); const eventRef = useRef(null); const detailPanelRef = useRef(null); const selectedEventElementRef = useRef(null); const selectedDayIndexRef = useRef(null); // Suppress click after a mouse-initiated resize (prevents detail drawer from // opening when mouseup triggers a click on the CalendarEvent element) const mouseResizeActiveRef = useRef(false); const mouseResizeClearTimerRef = useRef | null>( null ); const detailPanelKey = isMultiDay && segment ? `${event.id}::${segment.id}` : multiDaySegmentInfo?.dayIndex === undefined ? isYearView && yearSegment ? yearSegment.id : event.id : `${event.id}::day-${multiDaySegmentInfo.dayIndex}`; const showDetailPanel = detailPanelEventId === detailPanelKey; const panelEnabled = useEventDetailPanel !== false; const showDetailPanelForClickOutside = showDetailPanel && panelEnabled; const readOnlyConfig = app?.getReadOnlyConfig(event.id) as ReadOnlyConfig; const isEditable = app?.canMutateFromUI(event.id) ?? false; const canOpenDetail = readOnlyConfig?.viewable !== false; const isDraggable = readOnlyConfig?.draggable !== false; // Interaction Hook const { isSelected, setIsSelected, isPressed, setIsPressed, handleTouchStart, handleTouchMove, handleTouchEnd, handleTouchCancel, shouldSuppressClick, } = useEventInteraction({ event, isTouchEnabled, onMoveStart: isDraggable ? onMoveStart : undefined, onEventLongPress, onEventSelect, onDetailPanelToggle, canOpenDetail, useEventDetailPanel, app, multiDaySegmentInfo, isMultiDay, segment, detailPanelKey, }); const [eventVisibility, setEventVisibility] = useState< 'standard' | 'sticky-top' | 'sticky-bottom' | 'sticky-left' | 'sticky-right' >('standard'); // Suppress the click that fires after a mouse-based resize (mousedown → drag → mouseup) // so the detail drawer doesn't open when the user resizes via mouse on small-width desktops. const wrappedOnResizeStart = useCallback( (e: MouseEvent | TouchEvent, ev: Event, direction: string) => { if (!('touches' in e)) { // Mouse resize: the browser will fire a click after mouseup, suppress it. mouseResizeActiveRef.current = true; if (mouseResizeClearTimerRef.current) clearTimeout(mouseResizeClearTimerRef.current); // Clear AFTER the click event has fired (setTimeout 0 defers past click). document.addEventListener( 'mouseup', () => { mouseResizeClearTimerRef.current = setTimeout(() => { mouseResizeActiveRef.current = false; }, 0); }, { once: true } ); } onResizeStart?.(e, ev, direction); }, [onResizeStart] ); // Utility Wrappers const setActiveDayIndex = (dayIndex: number | null) => { selectedDayIndexRef.current = dayIndex; }; const getActiveDayIdx = useCallback( () => getActiveDayIndex( event, detailPanelEventId || undefined, detailPanelKey, selectedDayIndexRef.current, multiDaySegmentInfo, segment ), [event, detailPanelEventId, detailPanelKey, multiDaySegmentInfo, segment] ); const getClickedDayIdx = useCallback( (clientX: number) => getClickedDayIndex(clientX, calendarRef, viewType, isMobile), [calendarRef, viewType, isMobile] ); const getDayMetricsWrapper = useCallback( (dayIndex: number) => getDayMetrics(dayIndex, calendarRef, viewType, isMobile), [calendarRef, viewType, isMobile] ); // Positioning Hook const { detailPanelPosition, setDetailPanelPosition, updatePanelPosition } = useDetailPanelPosition({ event, viewType, isMultiDay, segment, yearSegment, multiDaySegmentInfo, calendarRef, eventRef, detailPanelRef, selectedEventElementRef, isMobile, eventVisibility, firstHour, hourHeight, columnsPerRow, showDetailPanel, detailPanelEventId, detailPanelKey, getActiveDayIdx, getDayMetricsWrapper, }); // Actions Hook const { handleClick, handleDoubleClick, handleContextMenu, hasPendingSelection, } = useEventActions({ event, timingEvent: visualEvent, viewType, isAllDay, isMultiDay, segment, multiDaySegmentInfo, calendarRef, firstHour, hourHeight, isMobile, canOpenDetail, useEventDetailPanel, detailPanelKey, app, onEventSelect, onDetailPanelToggle, setIsSelected, setDetailPanelPosition, setContextMenuPosition, setActiveDayIndex, getClickedDayIdx, updatePanelPosition, selectedEventElementRef, }); const isEventSelected = (selectedEventId === undefined ? isSelected : selectedEventId === event.id) || hasPendingSelection || (!isTouchEnabled && isPressed) || isBeingDragged; const hasTouchResizeHandles = isTouchEnabled && isEventSelected && isEditable; // Styles Hook const { calculateEventStyle } = useEventStyles({ event, timingEvent: visualEvent, layout, isBeingDragged, isAllDay, allDayHeight, viewType, isMultiDay, segment, yearSegment, columnsPerRow, segmentIndex, hourHeight, firstHour, isEventSelected, showDetailPanel, isPopping, isDraggable, canOpenDetail, eventVisibility, calendarRef, isMobile, eventRef, getActiveDayIdx, getDayMetricsWrapper, multiDaySegmentInfo, monthEventHeight, }); // Visibility Hook useEventVisibility({ event, timingEvent: visualEvent, isEventSelected, showDetailPanel, eventRef, calendarRef, isAllDay, viewType, isMobile, multiDaySegmentInfo, firstHour, hourHeight, updatePanelPosition, eventVisibility, setEventVisibility, }); // Click Outside Hook useClickOutside({ eventRef, detailPanelRef, eventId: event.id, isEventSelected: isEventSelected, showDetailPanel: showDetailPanelForClickOutside, onEventSelect, onDetailPanelToggle, setIsSelected, setActiveDayIndex, }); // Stable panel close handler const handlePanelClose = useCallback(() => { if (onEventSelect) onEventSelect(null); selectedDayIndexRef.current = null; setIsSelected(false); onDetailPanelToggle?.(null); }, [onEventSelect, onDetailPanelToggle, setIsSelected]); // Memoized args for the eventContent ContentSlot const eventContentSlotArgs = useMemo( () => ({ event, viewType, isAllDay, isMobile, isSelected: isEventSelected, isDragging: isBeingDragged, segment, layout, }), [ event, viewType, isAllDay, isMobile, isEventSelected, isBeingDragged, segment, layout, ] ); const contentSlotRenderer = useCallback( (contentProps: EventDetailContentProps) => ( ), [customRenderingStore] ); // Highlight effect useEffect(() => { if (app?.state.highlightedEventId === event.id) { setIsPopping(true); const timer = setTimeout(() => { setIsPopping(false); }, HIGHLIGHT_POP_DURATION_MS); return () => { clearTimeout(timer); setIsPopping(false); }; } }, [app?.state.highlightedEventId, event.id]); useEffect(() => { if (isEditable) return; setContextMenuPosition(null); }, [isEditable]); // Auto-open detail panel for newly created events useEffect(() => { const isFirst = (isMultiDay && segment?.isFirstSegment) || (isYearView && yearSegment?.isFirstSegment) || (!isMultiDay && !isYearView); if ( newlyCreatedEventId === event.id && !showDetailPanel && isFirst && useEventDetailPanel !== false ) { setTimeout(() => { onDetailPanelToggle?.(detailPanelKey); onDetailPanelOpen?.(); }, 50); } }, [ newlyCreatedEventId, event.id, showDetailPanel, isMultiDay, segment, isYearView, yearSegment, onDetailPanelToggle, onDetailPanelOpen, detailPanelKey, ]); // Final Render const calendarId = getPrimaryCalendarId(event); const calendarRegistry = app?.getCalendarRegistry(); const multiCalendarBgColors = event.calendarIds && event.calendarIds.length > 1 ? getCalendarEventBgColors(event, calendarRegistry) : null; const eventSegmentShape = getEventSegmentShape( viewType, isAllDay, segment, yearSegment ); return ( <>
1 ? { background: buildDiagonalPatternBackground( multiCalendarBgColors! ), color: getEventTextColor(calendarId, calendarRegistry), } : { backgroundColor: getEventBgColor( calendarId, calendarRegistry ), color: getEventTextColor(calendarId, calendarRegistry), }), ...styleOverride, // Prevent the browser from handling this touch as a scroll or zoom // gesture. CSS touch-action is evaluated before any JS runs, so it // is far more reliable than e.preventDefault() in a touchstart // handler for stopping the scroll container from claiming the touch. // Only applied when the event is draggable on a touch-enabled screen. ...(isTouchEnabled && isDraggable ? { touchAction: 'none' } : {}), }} onClick={e => { // Suppress click that fires after a mouse-based resize if (mouseResizeActiveRef.current) { mouseResizeActiveRef.current = false; e.preventDefault(); e.stopPropagation(); return; } if (isTouchEnabled && shouldSuppressClick()) { e.preventDefault(); e.stopPropagation(); return; } handleClick(e as MouseEvent); }} onContextMenu={handleContextMenu} onDblClick={handleDoubleClick} onMouseDown={e => { if (!isTouchEnabled) setIsPressed(true); if (onMoveStart && isDraggable) { const mouseEvent = e as MouseEvent; if (multiDaySegmentInfo) { onMoveStart(mouseEvent, { ...event, day: multiDaySegmentInfo.dayIndex ?? event.day, _segmentInfo: multiDaySegmentInfo, } as Event); } else if (isMultiDay && segment) { onMoveStart(mouseEvent, { ...event, day: segment.startDayIndex, _segmentInfo: { dayIndex: segment.startDayIndex, isFirst: segment.isFirstSegment, isLast: segment.isLastSegment, }, } as Event); } else { onMoveStart(mouseEvent, event); } } }} onMouseUp={() => !isTouchEnabled && setIsPressed(false)} onMouseLeave={() => !isTouchEnabled && setIsPressed(false)} onTouchStart={handleTouchStart} onTouchMove={handleTouchMove} onTouchEnd={handleTouchEnd} onTouchCancel={handleTouchCancel} >
{showDetailPanel && panelEnabled && (
)} {contextMenuPosition && app && isEditable && ( setContextMenuPosition(null)} app={app} onDetailPanelToggle={onDetailPanelToggle} detailPanelKey={detailPanelKey} /> )} ); }; export default memo(CalendarEvent); ================================================ FILE: packages/core/src/components/calendarEvent/__tests__/CalendarEvent.contract.test.tsx ================================================ import { render } from '@testing-library/preact'; import { Temporal } from 'temporal-polyfill'; import CalendarEvent from '@/components/calendarEvent/CalendarEvent'; import { ViewType, Event } from '@/types'; const baseEvent: Event = { id: 'event-1', title: 'Contract Event', calendarId: 'default', allDay: true, start: Temporal.ZonedDateTime.from('2026-04-09T00:00:00+00:00[UTC]'), end: Temporal.ZonedDateTime.from('2026-04-11T00:00:00+00:00[UTC]'), }; describe('CalendarEvent style contract', () => { it('exposes semantic data attributes for stateful all-day segments', () => { const calendarElement = document.createElement('div'); const calendarRef = { current: calendarElement }; const { container } = render( ); const eventElement = container.querySelector( '[data-event-id="event-1"]' ) as HTMLDivElement | null; expect(eventElement?.className).toContain('df-event'); expect(eventElement?.dataset.view).toBe(ViewType.MONTH); expect(eventElement?.dataset.allDay).toBe('true'); expect(eventElement?.dataset.multiDay).toBe('true'); expect(eventElement?.dataset.segmentShape).toBe('start'); expect(eventElement?.dataset.monthStack).toBe('false'); }); it('suppresses the synthetic click after a touch tap so mobile opens only one path', () => { const calendarElement = document.createElement('div'); const calendarRef = { current: calendarElement }; const onEventSelect = jest.fn(); const onDetailPanelToggle = jest.fn(); const timedEvent: Event = { ...baseEvent, allDay: false, start: Temporal.ZonedDateTime.from('2026-04-09T09:00:00+00:00[UTC]'), end: Temporal.ZonedDateTime.from('2026-04-09T10:00:00+00:00[UTC]'), }; const { container } = render( ); const eventElement = container.querySelector( '[data-event-id="event-1"]' ) as HTMLDivElement | null; eventElement?.dispatchEvent( new TouchEvent('touchstart', { bubbles: true, cancelable: true, touches: [ { identifier: 1, target: eventElement!, clientX: 24, clientY: 24, pageX: 24, pageY: 24, screenX: 24, screenY: 24, radiusX: 1, radiusY: 1, rotationAngle: 0, force: 1, } as unknown as Touch, ], }) ); eventElement?.dispatchEvent( new TouchEvent('touchend', { bubbles: true, cancelable: true, changedTouches: [ { identifier: 1, target: eventElement!, clientX: 24, clientY: 24, pageX: 24, pageY: 24, screenX: 24, screenY: 24, radiusX: 1, radiusY: 1, rotationAngle: 0, force: 1, } as unknown as Touch, ], }) ); eventElement?.click(); expect(onEventSelect).toHaveBeenCalledTimes(1); expect(onEventSelect).toHaveBeenCalledWith('event-1'); expect(onDetailPanelToggle).toHaveBeenCalledWith(null); expect(onDetailPanelToggle).not.toHaveBeenCalledWith('event-1'); }); it('keeps long-press drag active through small finger drift and blocks follow-up scrolling', () => { jest.useFakeTimers(); try { const calendarElement = document.createElement('div'); const calendarRef = { current: calendarElement }; const onMoveStart = jest.fn(); const timedEvent: Event = { ...baseEvent, allDay: false, start: Temporal.ZonedDateTime.from('2026-04-09T09:00:00+00:00[UTC]'), end: Temporal.ZonedDateTime.from('2026-04-09T10:00:00+00:00[UTC]'), }; const { container } = render( ); const eventElement = container.querySelector( '[data-event-id="event-1"]' ) as HTMLDivElement | null; const touchStartEvent = new TouchEvent('touchstart', { bubbles: true, cancelable: true, touches: [ { identifier: 1, target: eventElement!, clientX: 24, clientY: 24, pageX: 24, pageY: 24, screenX: 24, screenY: 24, radiusX: 1, radiusY: 1, rotationAngle: 0, force: 1, } as unknown as Touch, ], }); eventElement?.dispatchEvent(touchStartEvent); const driftMoveEvent = new TouchEvent('touchmove', { bubbles: true, cancelable: true, touches: [ { identifier: 1, target: eventElement!, clientX: 35, clientY: 35, pageX: 35, pageY: 35, screenX: 35, screenY: 35, radiusX: 1, radiusY: 1, rotationAngle: 0, force: 1, } as unknown as Touch, ], }); eventElement?.dispatchEvent(driftMoveEvent); jest.advanceTimersByTime(500); expect(onMoveStart).toHaveBeenCalledTimes(1); const dragMoveEvent = new TouchEvent('touchmove', { bubbles: true, cancelable: true, touches: [ { identifier: 1, target: eventElement!, clientX: 60, clientY: 60, pageX: 60, pageY: 60, screenX: 60, screenY: 60, radiusX: 1, radiusY: 1, rotationAngle: 0, force: 1, } as unknown as Touch, ], }); eventElement?.dispatchEvent(dragMoveEvent); expect(dragMoveEvent.defaultPrevented).toBe(true); } finally { jest.useRealTimers(); } }); }); ================================================ FILE: packages/core/src/components/calendarEvent/__tests__/CalendarEvent.timezone.test.tsx ================================================ import { render } from '@testing-library/preact'; import { Temporal } from 'temporal-polyfill'; import CalendarEvent from '@/components/calendarEvent/CalendarEvent'; import { Event, ViewType } from '@/types'; const baseEvent: Event = { id: 'event-1', title: 'Shifted Event', calendarId: 'default', allDay: false, start: Temporal.ZonedDateTime.from('2026-04-07T18:00:00+00:00[UTC]'), end: Temporal.ZonedDateTime.from('2026-04-07T19:30:00+00:00[UTC]'), }; describe('CalendarEvent timezone rendering', () => { it('recomputes timed event positioning when the secondary timezone changes', () => { const calendarElement = document.createElement('div'); const calendarRef = { current: calendarElement }; const { container, rerender } = render( ); const eventElement = container.querySelector( '[data-event-id="event-1"]' ) as HTMLDivElement | null; expect(eventElement?.style.top).toBe('83px'); expect(eventElement?.style.height).toBe('11px'); rerender( ); const updatedEventElement = container.querySelector( '[data-event-id="event-1"]' ) as HTMLDivElement | null; expect(updatedEventElement?.style.top).toBe('143px'); expect(updatedEventElement?.style.height).toBe('11px'); }); }); ================================================ FILE: packages/core/src/components/calendarEvent/components/AllDayContent.tsx ================================================ import { ComponentChildren } from 'preact'; import { CalendarDays } from '@/components/common/Icons'; import { MultiDayEventSegment } from '@/components/monthView/util'; import { eventIcon, eventTitleSmall, resizeHandleLeft, resizeHandleRight, } from '@/styles/classNames'; import { Event } from '@/types'; interface AllDayContentProps { event: Event; isEditable: boolean; onResizeStart?: (e: MouseEvent, event: Event, direction: string) => void; isMultiDay?: boolean; segment?: MultiDayEventSegment; isSlidingView?: boolean; /** Optional slot renderer — receives the default inner content and wraps it in a ContentSlot */ renderSlot?: (defaultContent: ComponentChildren) => ComponentChildren; } const AllDayContent = ({ event, isEditable, onResizeStart, isMultiDay, segment, isSlidingView, renderSlot, }: AllDayContentProps) => { const showIcon = event.icon !== false; const customIcon = typeof event.icon === 'boolean' ? null : event.icon; // Calculate title offset for mobile sliding mode const titleOffsetStyle = (() => { if (!isSlidingView || !isMultiDay || !segment) return {}; // The current visible window starts at index 2 of the 3-page range const visibleStartIndex = 2; // If the event starts before the visible window but ends within or after it if ( segment.startDayIndex < visibleStartIndex && segment.endDayIndex >= visibleStartIndex ) { const offsetDays = visibleStartIndex - segment.startDayIndex; const spanDays = segment.endDayIndex - segment.startDayIndex + 1; // Calculate offset as a percentage of the event bar's width const offsetPercent = (offsetDays / spanDays) * 100; return { paddingLeft: `calc(${offsetPercent}% + 0.75rem)`, // Ensure the transition matches the swipe transition for a smooth effect // transition: 'padding-left 0.3s ease-out', }; } return {}; })(); const innerContent = (
{showIcon && (customIcon ? (
{customIcon}
) : ( ))}
{event.title}
); return (
{renderSlot ? renderSlot(innerContent) : innerContent} {/* Left/Right resize handles — absolute positioned, always rendered outside the slot */} {onResizeStart && isEditable && ( <>
{ e.preventDefault(); e.stopPropagation(); onResizeStart(e, event, 'left'); }} onClick={e => { e.preventDefault(); e.stopPropagation(); }} />
{ e.preventDefault(); e.stopPropagation(); onResizeStart(e, event, 'right'); }} onClick={e => { e.preventDefault(); e.stopPropagation(); }} /> )}
); }; export default AllDayContent; ================================================ FILE: packages/core/src/components/calendarEvent/components/EventContent.tsx ================================================ import { ComponentChildren } from 'preact'; import MultiDayEvent from '@/components/monthView/MultiDayEvent'; import { MultiDayEventSegment } from '@/components/monthView/util'; import { YearMultiDaySegment } from '@/components/yearView/utils'; import { ContentSlot } from '@/renderer/ContentSlot'; import { CustomRenderingStore } from '@/renderer/CustomRenderingStore'; import { ViewType, Event, ICalendarApp, EventLayout } from '@/types'; import AllDayContent from './AllDayContent'; import MonthAllDayContent from './MonthAllDayContent'; import MonthRegularContent from './MonthRegularContent'; import RegularEventContent from './RegularEventContent'; import YearEventContent from './YearEventContent'; /** Resolve the most specific overridden generator name. Returns null if not overridden. */ function resolveGeneratorName( store: CustomRenderingStore | null, viewType: ViewType, isAllDay: boolean ): string | null { const viewKey = (viewType as string).charAt(0).toUpperCase() + (viewType as string).slice(1); const specificName = isAllDay ? `eventContentAllDay${viewKey}` // e.g. 'eventContentAllDayDay' : `eventContent${viewKey}`; // e.g. 'eventContentDay' if (store?.isOverridden(specificName)) return specificName; return null; } interface EventContentProps { event: Event; viewType: ViewType; isAllDay: boolean; isMultiDay: boolean; segment?: MultiDayEventSegment; yearSegment?: YearMultiDaySegment; segmentIndex: number; isBeingDragged: boolean; isBeingResized: boolean; isEventSelected: boolean; isPopping: boolean; isEditable: boolean; isDraggable: boolean; canOpenDetail: boolean; isTouchEnabled: boolean; hideTime?: boolean; isMobile: boolean; isSlidingView?: boolean; app?: ICalendarApp; onMoveStart?: (e: MouseEvent | TouchEvent, event: Event) => void; onResizeStart?: ( e: MouseEvent | TouchEvent, event: Event, direction: string ) => void; multiDaySegmentInfo?: { startHour: number; endHour: number; isFirst: boolean; isLast: boolean; dayIndex?: number; }; customRenderingStore: CustomRenderingStore | null; // oxlint-disable-next-line typescript/no-explicit-any eventContentSlotArgs: any; layout?: EventLayout; timeFormat?: '12h' | '24h'; appTimeZone?: string; renderVisualContent?: ( defaultContent: ComponentChildren ) => ComponentChildren; resizeHandleOrientation?: 'vertical' | 'horizontal'; monthEventHeight?: number; } export const EventContent = ({ event, viewType, isAllDay, isMultiDay, segment, yearSegment, segmentIndex, isBeingDragged, isBeingResized, isEventSelected, isPopping, isEditable, isDraggable, canOpenDetail, isTouchEnabled, hideTime, isMobile, isSlidingView, app, onMoveStart, onResizeStart, multiDaySegmentInfo, customRenderingStore, eventContentSlotArgs, timeFormat = '24h', appTimeZone, renderVisualContent, resizeHandleOrientation, monthEventHeight, }: EventContentProps) => { const isMonthView = viewType === ViewType.MONTH; const isYearView = viewType === ViewType.YEAR; const generatorName = resolveGeneratorName( customRenderingStore, viewType, isAllDay ); const applyVisualWrapper = (defaultContent: ComponentChildren) => renderVisualContent ? renderVisualContent(defaultContent) : defaultContent; // Month multi-day: MultiDayEvent owns absolute positioning and resize handles. // Year view: YearEventContent owns resize handles (CalendarEvent shell handles positioning). // In both cases the ContentSlot is injected via renderSlot so those are preserved // even when the user provides a custom event content renderer. if (isMonthView && isMultiDay && segment) { return ( ( )} /> ); } if (isYearView && yearSegment) { return ( ( )} /> ); } if (isMonthView) { const defaultContent = isAllDay ? ( ) : ( ); return ( ); } // Day/Week view: resize handles live inside AllDayContent/RegularEventContent. // Use renderSlot so resize handles are always rendered even when a custom slot overrides // the visual content. const slotRenderer = (defaultContent: ComponentChildren) => ( ); if (isAllDay) { return ( ); } return ( ); }; ================================================ FILE: packages/core/src/components/calendarEvent/components/EventDetailPanel.tsx ================================================ import { RefObject } from 'preact'; import DefaultEventDetailPanel from '@/components/common/DefaultEventDetailPanel'; import { EventDetailPanelWithContent } from '@/components/common/EventDetailPanelWithContent'; import { CustomRenderingStore } from '@/renderer/CustomRenderingStore'; import { Event, ICalendarApp, EventDetailPosition, EventDetailContentRenderer, } from '@/types'; interface EventDetailPanelProps { showDetailPanel: boolean; detailPanelPosition: EventDetailPosition | null; event: Event; detailPanelRef: RefObject; isAllDay: boolean; eventVisibility: | 'standard' | 'sticky-top' | 'sticky-bottom' | 'sticky-left' | 'sticky-right'; calendarRef: RefObject; selectedEventElementRef: RefObject; onEventUpdate: (event: Event) => void; onEventDelete: (id: string) => void; handlePanelClose: () => void; customRenderingStore: CustomRenderingStore | null; contentSlotRenderer: EventDetailContentRenderer; app?: ICalendarApp; } export const EventDetailPanel = ({ showDetailPanel, detailPanelPosition, event, detailPanelRef, isAllDay, eventVisibility, calendarRef, selectedEventElementRef, onEventUpdate, onEventDelete, handlePanelClose, customRenderingStore, contentSlotRenderer, app, }: EventDetailPanelProps) => { if (!showDetailPanel) return null; if (!detailPanelPosition) return null; const panelProps = { event, position: detailPanelPosition, panelRef: detailPanelRef, isAllDay, eventVisibility, calendarRef, selectedEventElementRef, onEventUpdate, onEventDelete, onClose: handlePanelClose, }; if (customRenderingStore?.isOverridden('eventDetailContent')) { return ( ); } return ; }; ================================================ FILE: packages/core/src/components/calendarEvent/components/MonthAllDayContent.tsx ================================================ import { CalendarDays } from '@/components/common/Icons'; import { monthAllDayContent, eventIcon } from '@/styles/classNames'; import { Event } from '@/types'; const mobileFadeStyle = { overflow: 'hidden', whiteSpace: 'nowrap', textOverflow: 'clip', WebkitMaskImage: 'linear-gradient(to right, black 70%, transparent 100%)', maskImage: 'linear-gradient(to right, black 70%, transparent 100%)', WebkitMaskRepeat: 'no-repeat', maskRepeat: 'no-repeat', } as const; interface MonthAllDayContentProps { event: Event; isEventSelected: boolean; isMobile: boolean; } const MonthAllDayContent = ({ event, isEventSelected: _isEventSelected, isMobile, }: MonthAllDayContentProps) => { const showIcon = event.icon !== false; const customIcon = typeof event.icon === 'boolean' ? null : event.icon; return (
{showIcon && (customIcon ? (
{customIcon}
) : event.title.toLowerCase().includes('easter') || event.title.toLowerCase().includes('holiday') ? ( ) : ( ))} {event.title}
); }; export default MonthAllDayContent; ================================================ FILE: packages/core/src/components/calendarEvent/components/MonthRegularContent.tsx ================================================ import { monthRegularContent, monthEventColorBar } from '@/styles/classNames'; import { Event, ICalendarApp } from '@/types'; import { getCalendarLineColors, buildColorBarGradient, extractHourFromDate, } from '@/utils'; const mobileFadeStyle = { overflow: 'hidden', whiteSpace: 'nowrap', textOverflow: 'clip', WebkitMaskImage: 'linear-gradient(to right, black 70%, transparent 100%)', maskImage: 'linear-gradient(to right, black 70%, transparent 100%)', WebkitMaskRepeat: 'no-repeat', maskRepeat: 'no-repeat', } as const; interface MonthRegularContentProps { event: Event; app?: ICalendarApp; isEventSelected: boolean; hideTime?: boolean; isMobile?: boolean; } const MonthRegularContent = ({ event, app, isEventSelected: _isEventSelected, hideTime, isMobile, }: MonthRegularContentProps) => { const startTime = `${Math.floor(extractHourFromDate(event.start)).toString().padStart(2, '0')}:${Math.round( (extractHourFromDate(event.start) % 1) * 60 ) .toString() .padStart(2, '0')}`; const lineColors = getCalendarLineColors(event, app?.getCalendarRegistry()); const colorBarValue = buildColorBarGradient(lineColors); const colorBarStyle = lineColors.length > 1 ? { background: colorBarValue } : { backgroundColor: colorBarValue }; const hideColorBar = _isEventSelected && lineColors.length > 1; return (
{!hideColorBar && (
)} {event.title}
{!hideTime && !isMobile && ( {startTime} )}
); }; export default MonthRegularContent; ================================================ FILE: packages/core/src/components/calendarEvent/components/RegularEventContent.tsx ================================================ import { ComponentChildren } from 'preact'; import { eventColorBar, eventTitleSmall, eventTime, resizeHandleLeft, resizeHandleTop, resizeHandleBottom, resizeHandleRight, } from '@/styles/classNames'; import { Event, ICalendarApp } from '@/types'; import { formatEventTimeRange, getLineColor, getCalendarLineColors, buildDiagonalColorBarGradient, getPrimaryCalendarId, extractHourFromDate, getEventEndHour, formatTime, } from '@/utils'; interface RegularEventContentProps { event: Event; app?: ICalendarApp; multiDaySegmentInfo?: { startHour: number; endHour: number; isFirst: boolean; isLast: boolean; dayIndex?: number; }; isEditable: boolean; isTouchEnabled: boolean; isEventSelected: boolean; isBeingDragged?: boolean; isBeingResized?: boolean; onResizeStart?: ( e: MouseEvent | TouchEvent, event: Event, direction: string ) => void; timeFormat?: '12h' | '24h'; resizeHandleOrientation?: 'vertical' | 'horizontal'; /** Optional slot renderer — receives the default visual content and wraps it in a ContentSlot */ renderSlot?: (defaultContent: ComponentChildren) => ComponentChildren; } const colorBarClipPath = 'inset(0.25rem calc(100% - 0.25rem - 3px) 0.25rem 0.25rem round 9999px)'; const RegularEventContent = ({ event, app, multiDaySegmentInfo, isEditable, isTouchEnabled, isEventSelected, isBeingDragged, isBeingResized, onResizeStart, timeFormat = '24h', resizeHandleOrientation = 'vertical', renderSlot, }: RegularEventContentProps) => { const startHour = multiDaySegmentInfo ? multiDaySegmentInfo.startHour : extractHourFromDate(event.start); const endHour = multiDaySegmentInfo ? multiDaySegmentInfo.endHour : getEventEndHour(event); const duration = endHour - startHour; const isFirstSegment = multiDaySegmentInfo ? multiDaySegmentInfo.isFirst : true; const isLastSegment = multiDaySegmentInfo ? multiDaySegmentInfo.isLast : true; const calendarId = getPrimaryCalendarId(event); const contentPaddingClass = !multiDaySegmentInfo && duration <= 0.25 ? 'compact' : 'default'; const lineColors = getCalendarLineColors(event, app?.getCalendarRegistry()); const colorBarValue = buildDiagonalColorBarGradient(lineColors); const hideColorBar = isEventSelected && lineColors.length > 1; const colorBarContent = hideColorBar ? null : lineColors.length > 1 ? (
) : (
); const visualContent = ( <> {colorBarContent}
{event.title}
{duration > 0.5 && (
{multiDaySegmentInfo ? `${formatTime(startHour, 0, timeFormat)} - ${formatTime(endHour, 0, timeFormat)}` : formatEventTimeRange(event, timeFormat)}
)}
); return ( <> {renderSlot ? renderSlot(visualContent) : visualContent} {onResizeStart && isEditable && resizeHandleOrientation === 'vertical' && ( <> {/* Only show top resize handle on the first segment */} {isFirstSegment && (
onResizeStart(e, event, 'top')} onClick={e => e.stopPropagation()} /> )} {/* Only show bottom resize handle on the last segment */} {isLastSegment && (
onResizeStart(e, event, 'bottom')} onClick={e => e.stopPropagation()} /> )} {/* Right resize handle for multi-day events (only on the last segment) */} {!isFirstSegment && isLastSegment && multiDaySegmentInfo && (
{ e.preventDefault(); e.stopPropagation(); onResizeStart(e, event, 'right'); }} onClick={e => { e.preventDefault(); e.stopPropagation(); }} /> )} )} {isTouchEnabled && isEventSelected && !isBeingDragged && !isBeingResized && onResizeStart && isEditable && resizeHandleOrientation === 'vertical' && ( <> {/* Top-Right Indicator (Start Time) */}
{ e.stopPropagation(); onResizeStart(e, event, 'top'); }} /> {/* Bottom-Left Indicator (End Time) */}
{ e.stopPropagation(); onResizeStart(e, event, 'bottom'); }} /> )} {onResizeStart && isEditable && resizeHandleOrientation === 'horizontal' && ( <>
{ e.preventDefault(); e.stopPropagation(); onResizeStart(e, event, 'left'); }} onClick={e => { e.preventDefault(); e.stopPropagation(); }} />
{ e.preventDefault(); e.stopPropagation(); onResizeStart(e, event, 'right'); }} onClick={e => { e.preventDefault(); e.stopPropagation(); }} /> )} ); }; export default RegularEventContent; ================================================ FILE: packages/core/src/components/calendarEvent/components/YearEventContent.tsx ================================================ import { ComponentChildren } from 'preact'; import { getEventIcon } from '@/components/monthView/util'; import { YearMultiDaySegment } from '@/components/yearView/utils'; import { resizeHandleLeft, resizeHandleRight } from '@/styles/classNames'; import { Event } from '@/types'; import { getLineColor, getPrimaryCalendarId, getCalendarLineColors, buildColorBarGradient, } from '@/utils'; interface YearEventContentProps { event: Event; segment: YearMultiDaySegment; isEditable: boolean; onMoveStart?: (e: MouseEvent | TouchEvent, event: Event) => void; onResizeStart?: ( e: MouseEvent | TouchEvent, event: Event, direction: string ) => void; /** Optional slot renderer — receives the default visual content and wraps it in a ContentSlot */ renderSlot?: (defaultContent: ComponentChildren) => ComponentChildren; } const YearEventContent = ({ event, segment, isEditable, onMoveStart, onResizeStart, renderSlot, }: YearEventContentProps) => { const isAllDay = !!event.allDay; const calendarId = getPrimaryCalendarId(event); const lineColors = getCalendarLineColors(event); const indicatorColorBarValue = buildColorBarGradient(lineColors); const indicatorColorBarStyle = lineColors.length > 1 ? { background: indicatorColorBarValue } : { backgroundColor: indicatorColorBarValue }; const { isFirstSegment, isLastSegment } = segment; const renderResizeHandle = (position: 'left' | 'right') => { const isLeft = position === 'left'; const shouldShow = isLeft ? isFirstSegment : isLastSegment; // Only allow resizing for all-day events in Year View if (!event.allDay || !shouldShow || !onResizeStart || !isEditable) return null; return (
{ e.preventDefault(); e.stopPropagation(); onResizeStart(e as MouseEvent, event, isLeft ? 'left' : 'right'); }} onTouchStart={e => { e.stopPropagation(); onResizeStart(e as TouchEvent, event, isLeft ? 'left' : 'right'); }} onClick={e => { e.preventDefault(); e.stopPropagation(); }} /> ); }; const renderContent = () => { if (isAllDay) { const getDisplayText = () => { if (segment.isFirstSegment) return event.title; return '···'; }; return (
{ if (onMoveStart) { e.stopPropagation(); onMoveStart(e as MouseEvent, event); } }} > {segment.isFirstSegment && getEventIcon(event) && (
{getEventIcon(event)}
)}
{getDisplayText()}
{/* Add small indicator for continuation if needed, similar to MultiDayEvent */} {segment.isLastSegment && !segment.isFirstSegment && (
)}
); } // For non-all-day events treated as bars in Year View const titleText = segment.isFirstSegment ? event.title : ''; return (
{ if (onMoveStart) { e.stopPropagation(); onMoveStart(e as MouseEvent, event); } }} > {!isAllDay && ( )} {titleText} {segment.isLastSegment && !segment.isFirstSegment && (
)}
); }; return ( <> {renderResizeHandle('left')} {renderSlot ? renderSlot(renderContent()) : renderContent()} {renderResizeHandle('right')} ); }; export default YearEventContent; ================================================ FILE: packages/core/src/components/calendarEvent/components/__tests__/EventContent.test.tsx ================================================ import { render, screen } from '@testing-library/preact'; import { Temporal } from 'temporal-polyfill'; import { EventContent } from '@/components/calendarEvent/components/EventContent'; import { ViewType, Event } from '@/types'; const baseEvent: Event = { id: 'event-1', title: 'All Day in Month', calendarId: 'default', allDay: false, start: Temporal.ZonedDateTime.from('2026-04-07T09:00:00+00:00[UTC]'), end: Temporal.ZonedDateTime.from('2026-04-07T10:00:00+00:00[UTC]'), }; describe('EventContent', () => { it('prefers the isAllDay prop for month view rendering', () => { render( ); const title = screen.getByText('All Day in Month'); expect(title.className).toContain('df-event-month-title'); }); it('uses mask fade for month all-day titles on mobile', () => { render( ); const title = screen.getByText('All Day in Month'); expect(title.className).toContain('df-mobile-mask-fade'); }); it('uses mask fade for multi-day timed event titles on desktop', () => { const multiDayTimedEvent: Event = { ...baseEvent, title: 'Multi-day Timed Event', allDay: false, }; const multiDaySegment = { id: 'segment-1', originalEventId: multiDayTimedEvent.id, event: multiDayTimedEvent, startDayIndex: 0, endDayIndex: 2, // Spans 3 days in this row isFirstSegment: true, isLastSegment: false, // Not the last segment of the entire event to ensure it's multi-day totalDays: 5, segmentType: 'start' as const, segmentIndex: 0, }; render( ); const title = screen.getByText('Multi-day Timed Event'); // In MultiDayEvent, the title wrapper is a div with df-event-month-main class const titleWrapper = title.closest('.df-event-month-main') as HTMLElement; // The mask is now on this wrapper const style = titleWrapper.style; const maskValue = `${style.maskImage}${style.webkitMaskImage}`; expect(maskValue).toContain('linear-gradient'); }); }); ================================================ FILE: packages/core/src/components/calendarEvent/components/__tests__/RegularEventContent.test.tsx ================================================ import { render } from '@testing-library/preact'; import { Temporal } from 'temporal-polyfill'; import RegularEventContent from '@/components/calendarEvent/components/RegularEventContent'; describe('RegularEventContent', () => { it('keeps default density for multi-day timed segments', () => { const event = { id: 'event-1', title: 'Cross-day Event', start: Temporal.ZonedDateTime.from( '2026-04-05T17:00:00+10:00[Australia/Sydney]' ), end: Temporal.ZonedDateTime.from( '2026-04-06T05:00:00+10:00[Australia/Sydney]' ), calendarId: 'blue', allDay: false, }; const { container } = render( ); const content = container.querySelector( '.df-event-timed-content' ) as HTMLElement | null; expect(content).not.toBeNull(); expect(content!.dataset.density).toBe('default'); }); }); ================================================ FILE: packages/core/src/components/calendarEvent/hooks/__tests__/useEventActions.test.tsx ================================================ import { render, fireEvent, act } from '@testing-library/preact'; import { useRef } from 'preact/hooks'; import { Temporal } from 'temporal-polyfill'; import { useEventActions } from '@/components/calendarEvent/hooks/useEventActions'; import { Event, ViewType } from '@/types'; const baseEvent: Event = { id: 'event-1', title: 'Year event', allDay: true, calendarId: 'default', start: Temporal.PlainDate.from('2026-03-24'), end: Temporal.PlainDate.from('2026-03-24'), }; interface HarnessProps { onEventSelect?: (eventId: string | null) => void; onDetailPanelToggle?: (key: string | null) => void; } const Harness = ({ onEventSelect, onDetailPanelToggle }: HarnessProps) => { const selectedEventElementRef = useRef(null); const handlers = useEventActions({ event: baseEvent, viewType: ViewType.YEAR, isAllDay: true, isMultiDay: false, calendarRef: { current: document.createElement('div') }, firstHour: 0, hourHeight: 56, isMobile: false, canOpenDetail: true, detailPanelKey: 'event-1::year-segment', onEventSelect, onDetailPanelToggle, setIsSelected: jest.fn(), setDetailPanelPosition: jest.fn(), setContextMenuPosition: jest.fn(), setActiveDayIndex: jest.fn(), getClickedDayIdx: jest.fn(), updatePanelPosition: jest.fn(), selectedEventElementRef, }); return ( ); }; describe('useEventActions', () => { beforeEach(() => { jest.useFakeTimers(); }); afterEach(() => { jest.runOnlyPendingTimers(); jest.useRealTimers(); }); it('delays year-view single click selection until the double-click window passes', () => { const onEventSelect = jest.fn(); const onDetailPanelToggle = jest.fn(); const { getByTestId } = render( ); fireEvent.click(getByTestId('event'), { clientX: 24 }); expect(onEventSelect).not.toHaveBeenCalled(); act(() => { jest.advanceTimersByTime(179); }); expect(onEventSelect).not.toHaveBeenCalled(); act(() => { jest.advanceTimersByTime(1); }); expect(onEventSelect).toHaveBeenCalledWith('event-1'); expect(onDetailPanelToggle).toHaveBeenCalledWith(null); }); it('cancels the pending year-view single click when the event is double-clicked', async () => { const onEventSelect = jest.fn(); const onDetailPanelToggle = jest.fn(); const { getByTestId } = render( ); const eventButton = getByTestId('event'); fireEvent.click(eventButton, { clientX: 24 }); await act(async () => { fireEvent.dblClick(eventButton, { clientX: 24 }); await Promise.resolve(); }); act(() => { jest.advanceTimersByTime(180); }); expect(onEventSelect).toHaveBeenCalledWith('event-1'); expect(onEventSelect).toHaveBeenCalledTimes(1); expect(onDetailPanelToggle).toHaveBeenCalledWith('event-1::year-segment'); expect(onDetailPanelToggle).not.toHaveBeenCalledWith(null); }); it('selects non-year events on double click before opening the detail panel', async () => { const onEventSelect = jest.fn(); const onDetailPanelToggle = jest.fn(); const selectedEventElementRef = { current: null as HTMLElement | null }; const setIsSelected = jest.fn(); const DayHarness = () => { const handlers = useEventActions({ event: baseEvent, viewType: ViewType.DAY, isAllDay: true, isMultiDay: false, calendarRef: { current: document.createElement('div') }, firstHour: 0, hourHeight: 56, isMobile: false, canOpenDetail: true, detailPanelKey: 'event-1', onEventSelect, onDetailPanelToggle, setIsSelected, setDetailPanelPosition: jest.fn(), setContextMenuPosition: jest.fn(), setActiveDayIndex: jest.fn(), getClickedDayIdx: jest.fn(), updatePanelPosition: jest.fn(), selectedEventElementRef, }); return ( ); }; const { getByTestId } = render(); await act(async () => { fireEvent.dblClick(getByTestId('day-event'), { clientX: 24 }); await Promise.resolve(); }); expect(onEventSelect).toHaveBeenCalledWith('event-1'); expect(onDetailPanelToggle).toHaveBeenCalledWith('event-1'); expect(setIsSelected).toHaveBeenCalledWith(true); }); it('defers non-year click callbacks until the double-click window passes', () => { const app = { onEventClick: jest.fn(), } as unknown as import('@/types').ICalendarApp; const onEventSelect = jest.fn(); const onDetailPanelToggle = jest.fn(); const selectedEventElementRef = { current: null as HTMLElement | null }; const setIsSelected = jest.fn(); const DayHarness = () => { const handlers = useEventActions({ event: baseEvent, viewType: ViewType.DAY, isAllDay: true, isMultiDay: false, app, calendarRef: { current: document.createElement('div') }, firstHour: 0, hourHeight: 56, isMobile: false, canOpenDetail: true, detailPanelKey: 'event-1', onEventSelect, onDetailPanelToggle, setIsSelected, setDetailPanelPosition: jest.fn(), setContextMenuPosition: jest.fn(), setActiveDayIndex: jest.fn(), getClickedDayIdx: jest.fn(), updatePanelPosition: jest.fn(), selectedEventElementRef, }); return ( ); }; const { getByTestId } = render(); fireEvent.click(getByTestId('day-click-event'), { clientX: 24 }); expect(onEventSelect).toHaveBeenCalledWith('event-1'); expect(onDetailPanelToggle).toHaveBeenCalledWith(null); expect(app.onEventClick).not.toHaveBeenCalled(); act(() => { jest.advanceTimersByTime(179); }); expect(app.onEventClick).not.toHaveBeenCalled(); act(() => { jest.advanceTimersByTime(1); }); expect(app.onEventClick).toHaveBeenCalledWith(baseEvent); }); it('suppresses click callbacks when a non-year event becomes a double click', async () => { const app = { onEventClick: jest.fn(), onEventDoubleClick: jest.fn(), } as unknown as import('@/types').ICalendarApp; const onEventSelect = jest.fn(); const onDetailPanelToggle = jest.fn(); const selectedEventElementRef = { current: null as HTMLElement | null }; const setIsSelected = jest.fn(); const DayHarness = () => { const handlers = useEventActions({ event: baseEvent, viewType: ViewType.DAY, isAllDay: true, isMultiDay: false, app, calendarRef: { current: document.createElement('div') }, firstHour: 0, hourHeight: 56, isMobile: false, canOpenDetail: true, detailPanelKey: 'event-1', onEventSelect, onDetailPanelToggle, setIsSelected, setDetailPanelPosition: jest.fn(), setContextMenuPosition: jest.fn(), setActiveDayIndex: jest.fn(), getClickedDayIdx: jest.fn(), updatePanelPosition: jest.fn(), selectedEventElementRef, }); return ( ); }; const { getByTestId } = render(); const eventButton = getByTestId('day-double-event'); fireEvent.click(eventButton, { clientX: 24 }); fireEvent.click(eventButton, { clientX: 24 }); await act(async () => { fireEvent.dblClick(eventButton, { clientX: 24 }); await Promise.resolve(); }); act(() => { jest.advanceTimersByTime(180); }); expect(app.onEventClick).not.toHaveBeenCalled(); expect(app.onEventDoubleClick).toHaveBeenCalledWith( baseEvent, expect.any(MouseEvent) ); expect(onEventSelect).toHaveBeenCalledWith('event-1'); expect(onDetailPanelToggle).toHaveBeenCalledWith('event-1'); }); it('allows onEventDoubleClick to suppress the default detail panel', async () => { const app = { onEventClick: jest.fn(), onEventDoubleClick: jest.fn(() => false), } as unknown as import('@/types').ICalendarApp; const onEventSelect = jest.fn(); const onDetailPanelToggle = jest.fn(); const selectedEventElementRef = { current: null as HTMLElement | null }; const setIsSelected = jest.fn(); const DayHarness = () => { const handlers = useEventActions({ event: baseEvent, viewType: ViewType.DAY, isAllDay: true, isMultiDay: false, app, calendarRef: { current: document.createElement('div') }, firstHour: 0, hourHeight: 56, isMobile: false, canOpenDetail: true, detailPanelKey: 'event-1', onEventSelect, onDetailPanelToggle, setIsSelected, setDetailPanelPosition: jest.fn(), setContextMenuPosition: jest.fn(), setActiveDayIndex: jest.fn(), getClickedDayIdx: jest.fn(), updatePanelPosition: jest.fn(), selectedEventElementRef, }); return ( ); }; const { getByTestId } = render(); await act(async () => { fireEvent.dblClick(getByTestId('day-event-suppress'), { clientX: 24 }); await Promise.resolve(); }); expect(app.onEventClick).not.toHaveBeenCalled(); expect(app.onEventDoubleClick).toHaveBeenCalledWith( baseEvent, expect.any(MouseEvent) ); expect(onEventSelect).toHaveBeenCalledWith('event-1'); expect(setIsSelected).toHaveBeenCalledWith(true); expect(onDetailPanelToggle).not.toHaveBeenCalled(); }); it('waits for resource-view scrolling to settle before opening the detail panel', async () => { const app = { onEventClick: jest.fn(), } as unknown as import('@/types').ICalendarApp; const onEventSelect = jest.fn(); const onDetailPanelToggle = jest.fn(); const setIsSelected = jest.fn(); const setDetailPanelPosition = jest.fn(); const selectedEventElementRef = { current: null as HTMLElement | null }; const calendarContent = document.createElement('div'); calendarContent.className = 'df-calendar-content'; calendarContent.scrollLeft = 0; calendarContent.scrollTop = 0; Object.defineProperty(calendarContent, 'clientWidth', { value: 320, configurable: true, }); Object.defineProperty(calendarContent, 'clientHeight', { value: 240, configurable: true, }); Object.defineProperty(calendarContent, 'scrollWidth', { value: 1200, configurable: true, }); Object.defineProperty(calendarContent, 'scrollHeight', { value: 1400, configurable: true, }); calendarContent.getBoundingClientRect = jest.fn(() => ({ left: 0, top: 0, right: 320, bottom: 240, width: 320, height: 240, x: 0, y: 0, toJSON: () => ({}), })) as unknown as typeof calendarContent.getBoundingClientRect; const scrollToMock = jest.fn((left: number, top: number) => { calendarContent.scrollLeft = left; calendarContent.scrollTop = top; }); calendarContent.scrollTo = scrollToMock as unknown as typeof calendarContent.scrollTo; const timedEvent: Event = { ...baseEvent, allDay: false, start: Temporal.ZonedDateTime.from( '2026-03-24T10:00:00+11:00[Australia/Sydney]' ), end: Temporal.ZonedDateTime.from( '2026-03-24T11:00:00+11:00[Australia/Sydney]' ), }; const ResourceHarness = () => { const handlers = useEventActions({ event: timedEvent, viewType: ViewType.RESOURCE, isAllDay: false, isMultiDay: false, app, calendarRef: { current: calendarContent }, firstHour: 0, hourHeight: 56, isMobile: false, canOpenDetail: true, detailPanelKey: 'event-1::resource', onEventSelect, onDetailPanelToggle, setIsSelected, setDetailPanelPosition, setContextMenuPosition: jest.fn(), setActiveDayIndex: jest.fn(), getClickedDayIdx: jest.fn(), updatePanelPosition: jest.fn(), selectedEventElementRef, }); return ( ); }; const { getByTestId } = render(); await act(async () => { fireEvent.dblClick(getByTestId('resource-event'), { clientX: 720 }); await Promise.resolve(); }); expect(scrollToMock).toHaveBeenCalled(); expect(app.onEventClick).not.toHaveBeenCalled(); expect(onEventSelect).toHaveBeenCalledWith('event-1'); expect(setIsSelected).toHaveBeenCalledWith(true); expect(onDetailPanelToggle).not.toHaveBeenCalled(); act(() => { jest.advanceTimersByTime(159); }); expect(onDetailPanelToggle).not.toHaveBeenCalled(); await act(async () => { jest.advanceTimersByTime(1); await Promise.resolve(); }); expect(onDetailPanelToggle).toHaveBeenCalledWith('event-1::resource'); expect(setDetailPanelPosition).toHaveBeenCalledWith( expect.objectContaining({ left: -9999, top: -9999 }) ); }); it('does not auto-scroll resource-view events that are already fully visible', async () => { const app = { onEventClick: jest.fn(), } as unknown as import('@/types').ICalendarApp; const onEventSelect = jest.fn(); const onDetailPanelToggle = jest.fn(); const setIsSelected = jest.fn(); const setDetailPanelPosition = jest.fn(); const selectedEventElementRef = { current: null as HTMLElement | null }; const calendarContent = document.createElement('div'); calendarContent.className = 'df-calendar-content'; Object.defineProperty(calendarContent, 'clientWidth', { value: 320, configurable: true, }); Object.defineProperty(calendarContent, 'clientHeight', { value: 240, configurable: true, }); calendarContent.getBoundingClientRect = jest.fn(() => ({ left: 0, top: 0, right: 320, bottom: 240, width: 320, height: 240, x: 0, y: 0, toJSON: () => ({}), })) as unknown as typeof calendarContent.getBoundingClientRect; const scrollToMock = jest.fn(); calendarContent.scrollTo = scrollToMock as unknown as typeof calendarContent.scrollTo; const timedEvent: Event = { ...baseEvent, allDay: false, start: Temporal.ZonedDateTime.from( '2026-03-24T10:00:00+11:00[Australia/Sydney]' ), end: Temporal.ZonedDateTime.from( '2026-03-24T11:00:00+11:00[Australia/Sydney]' ), }; const ResourceHarness = () => { const handlers = useEventActions({ event: timedEvent, viewType: ViewType.RESOURCE, isAllDay: false, isMultiDay: false, app, calendarRef: { current: calendarContent }, firstHour: 0, hourHeight: 56, isMobile: false, canOpenDetail: true, detailPanelKey: 'event-1::resource', onEventSelect, onDetailPanelToggle, setIsSelected, setDetailPanelPosition, setContextMenuPosition: jest.fn(), setActiveDayIndex: jest.fn(), getClickedDayIdx: jest.fn(), updatePanelPosition: jest.fn(), selectedEventElementRef, }); return ( ); }; const { getByTestId } = render(); await act(async () => { fireEvent.dblClick(getByTestId('resource-visible-event'), { clientX: 120, }); await Promise.resolve(); }); expect(scrollToMock).not.toHaveBeenCalled(); expect(app.onEventClick).not.toHaveBeenCalled(); expect(onEventSelect).toHaveBeenCalledWith('event-1'); expect(setIsSelected).toHaveBeenCalledWith(true); expect(onDetailPanelToggle).toHaveBeenCalledWith('event-1::resource'); }); }); ================================================ FILE: packages/core/src/components/calendarEvent/hooks/useClickOutside.ts ================================================ import { RefObject } from 'preact'; import { useEffect } from 'preact/hooks'; interface UseClickOutsideProps { eventRef: RefObject; detailPanelRef: RefObject; eventId: string; isEventSelected: boolean; showDetailPanel: boolean; onEventSelect?: (id: string | null) => void; onDetailPanelToggle?: (key: string | null) => void; setIsSelected: (selected: boolean) => void; setActiveDayIndex: (index: number | null) => void; } export const useClickOutside = ({ eventRef, detailPanelRef, eventId, isEventSelected, showDetailPanel, onEventSelect, onDetailPanelToggle, setIsSelected, setActiveDayIndex, }: UseClickOutsideProps) => { useEffect(() => { if (!isEventSelected && !showDetailPanel) return; const handleClickOutside = (e: MouseEvent) => { const target = e.target as HTMLElement; const clickedInsideEvent = eventRef.current?.contains(target); const clickedOnSameEvent = target.closest(`[data-event-id="${eventId}"]`) !== null; const clickedInsidePanel = detailPanelRef.current?.contains(target); const clickedInsideDetailDialog = target.closest( '[data-event-detail-dialog]' ); const clickedInsideRangePickerPopup = target.closest( '[data-range-picker-popup]' ); const clickedInsideCalendarPickerDropdown = target.closest( '[data-calendar-picker-dropdown]' ); if (showDetailPanel) { if ( !clickedInsideEvent && !clickedOnSameEvent && !clickedInsidePanel && !clickedInsideDetailDialog && !clickedInsideRangePickerPopup && !clickedInsideCalendarPickerDropdown ) { onEventSelect?.(null); setActiveDayIndex(null); setIsSelected(false); onDetailPanelToggle?.(null); } } else if ( isEventSelected && !clickedInsideEvent && !clickedOnSameEvent && !clickedInsideDetailDialog && !clickedInsideRangePickerPopup && !clickedInsideCalendarPickerDropdown ) { onEventSelect?.(null); setActiveDayIndex(null); setIsSelected(false); onDetailPanelToggle?.(null); } }; document.addEventListener('mousedown', handleClickOutside); return () => { document.removeEventListener('mousedown', handleClickOutside); }; }, [ isEventSelected, showDetailPanel, onEventSelect, onDetailPanelToggle, eventId, ]); }; ================================================ FILE: packages/core/src/components/calendarEvent/hooks/useDetailPanelPosition.ts ================================================ import { RefObject } from 'preact'; import { useState, useCallback, useEffect } from 'preact/hooks'; import { getTimeColumnWidth } from '@/components/calendarEvent/utils'; import { MultiDayEventSegment } from '@/components/monthView/util'; import { YearMultiDaySegment } from '@/components/yearView/utils'; import { Event, ViewType, EventDetailPosition } from '@/types'; interface UseDetailPanelPositionProps { event: Event; viewType: ViewType; isMultiDay: boolean; segment?: MultiDayEventSegment; yearSegment?: YearMultiDaySegment; multiDaySegmentInfo?: { startHour: number; endHour: number; isFirst: boolean; isLast: boolean; dayIndex?: number; }; calendarRef: RefObject; eventRef: RefObject; detailPanelRef: RefObject; selectedEventElementRef: RefObject; isMobile: boolean; eventVisibility: | 'standard' | 'sticky-top' | 'sticky-bottom' | 'sticky-left' | 'sticky-right'; firstHour: number; hourHeight: number; columnsPerRow?: number; showDetailPanel: boolean; detailPanelEventId?: string | null; detailPanelKey: string; getActiveDayIdx: () => number; getDayMetricsWrapper: ( dayIndex: number ) => { left: number; width: number } | null; } export const useDetailPanelPosition = ({ event, viewType, isMultiDay, segment, yearSegment, multiDaySegmentInfo, calendarRef, eventRef, detailPanelRef, selectedEventElementRef, isMobile, eventVisibility, firstHour, hourHeight, columnsPerRow, showDetailPanel, detailPanelEventId, detailPanelKey, getActiveDayIdx, getDayMetricsWrapper, }: UseDetailPanelPositionProps) => { const [detailPanelPosition, setDetailPanelPosition] = useState(null); const isDayView = viewType === ViewType.DAY; const isMonthView = viewType === ViewType.MONTH; const isYearView = viewType === ViewType.YEAR; const isResourceView = viewType === ViewType.RESOURCE; const updatePanelPosition = useCallback(() => { if ( (!selectedEventElementRef.current && !eventRef.current) || !calendarRef.current || !detailPanelRef.current ) return; const calendarRect = calendarRef.current.getBoundingClientRect(); const positionDayIndex = getActiveDayIdx(); const metricsForPosition = getDayMetricsWrapper(positionDayIndex); let dayStartX: number; let dayColumnWidth: number; if (metricsForPosition) { dayStartX = metricsForPosition.left; dayColumnWidth = metricsForPosition.width; } else if (isMonthView) { dayColumnWidth = calendarRect.width / 7; dayStartX = calendarRect.left + positionDayIndex * dayColumnWidth; } else { const timeColumnWidth = getTimeColumnWidth(calendarRef, isMobile); dayColumnWidth = (calendarRect.width - timeColumnWidth) / 7; dayStartX = calendarRect.left + timeColumnWidth + positionDayIndex * dayColumnWidth; } const boundaryWidth = Math.min(window.innerWidth, calendarRect.right); const boundaryHeight = Math.min(window.innerHeight, calendarRect.bottom); requestAnimationFrame(() => { if (!detailPanelRef.current) return; const eventElement = selectedEventElementRef.current || eventRef.current; if (!eventElement) return; const panelRect = detailPanelRef.current.getBoundingClientRect(); const panelWidth = panelRect.width; const panelHeight = panelRect.height; let left: number, top: number; let eventRect: DOMRect; if ( eventVisibility === 'sticky-top' || eventVisibility === 'sticky-bottom' || eventVisibility === 'sticky-left' || eventVisibility === 'sticky-right' ) { const actualEventRect = eventRef.current?.getBoundingClientRect(); if (!actualEventRect) return; eventRect = actualEventRect; } else { eventRect = eventElement.getBoundingClientRect(); } if (isMonthView && isMultiDay && segment) { const metrics = getDayMetricsWrapper(positionDayIndex); const currentDayColumnWidth = metrics?.width ?? calendarRect.width / 7; const selectedDayLeft = metrics?.left ?? calendarRect.left + positionDayIndex * currentDayColumnWidth; const selectedDayRight = selectedDayLeft + currentDayColumnWidth; eventRect = { top: eventRect.top, bottom: eventRect.bottom, left: selectedDayLeft, right: selectedDayRight, width: selectedDayRight - selectedDayLeft, height: eventRect.height, x: selectedDayLeft, y: eventRect.top, toJSON: () => ({}), } as DOMRect; } const spaceOnRight = boundaryWidth - eventRect.right; const spaceOnLeft = eventRect.left - calendarRect.left; if (spaceOnRight >= panelWidth + 20) { left = eventRect.right + 10; } else if (spaceOnLeft >= panelWidth + 20) { left = eventRect.left - panelWidth - 10; } else { left = spaceOnRight > spaceOnLeft ? Math.max(calendarRect.left + 10, boundaryWidth - panelWidth - 10) : calendarRect.left + 10; } const idealTop = eventRect.top - panelHeight / 2 + eventRect.height / 2; const topBoundary = Math.max(10, calendarRect.top + 10); const bottomBoundary = boundaryHeight - 10; top = idealTop < topBoundary ? topBoundary : idealTop + panelHeight > bottomBoundary ? bottomBoundary - panelHeight : idealTop; setDetailPanelPosition(prev => { if (!prev) return null; let isSunday = left < dayStartX; if (isYearView || isResourceView) { isSunday = left < eventRect.left; } return { ...prev, top, left, isSunday }; }); }); }, [ calendarRef, event.day, event.start, event.end, eventVisibility, isMonthView, firstHour, hourHeight, isMultiDay, segment, multiDaySegmentInfo, detailPanelEventId, detailPanelKey, isMobile, isDayView, isYearView, yearSegment, columnsPerRow, getActiveDayIdx, getDayMetricsWrapper, selectedEventElementRef, detailPanelRef, eventRef, ]); useEffect(() => { if (showDetailPanel && !detailPanelPosition && !isMobile) { setDetailPanelPosition({ top: -9999, left: -9999, eventHeight: 0, eventMiddleY: 0, isSunday: false, }); requestAnimationFrame(() => updatePanelPosition()); } }, [showDetailPanel, detailPanelPosition, updatePanelPosition, isMobile]); return { detailPanelPosition, setDetailPanelPosition, updatePanelPosition, }; }; ================================================ FILE: packages/core/src/components/calendarEvent/hooks/useEventActions.ts ================================================ import { RefObject } from 'preact'; import { useCallback, useEffect, useRef, useState } from 'preact/hooks'; import { getCalendarContentElement } from '@/components/calendarEvent/utils'; import { MultiDayEventSegment } from '@/components/monthView/util'; import { Event, ViewType, ICalendarApp, EventDetailPosition } from '@/types'; import { extractHourFromDate, getEventEndHour } from '@/utils'; import { logger } from '@/utils/logger'; const SINGLE_CLICK_DELAY_MS = 180; interface UseEventActionsProps { event: Event; timingEvent?: Event; viewType: ViewType; isAllDay: boolean; isMultiDay: boolean; segment?: MultiDayEventSegment; multiDaySegmentInfo?: { startHour: number; endHour: number; isFirst: boolean; isLast: boolean; dayIndex?: number; }; calendarRef: RefObject; firstHour: number; hourHeight: number; isMobile: boolean; canOpenDetail: boolean; useEventDetailPanel?: boolean; detailPanelKey: string; app?: ICalendarApp; onEventSelect?: (eventId: string | null) => void; onDetailPanelToggle?: (key: string | null) => void; setIsSelected: (selected: boolean) => void; setDetailPanelPosition: (pos: EventDetailPosition | null) => void; setContextMenuPosition: (pos: { x: number; y: number } | null) => void; setActiveDayIndex: (index: number | null) => void; getClickedDayIdx: (clientX: number) => number | null; updatePanelPosition: () => void; selectedEventElementRef: RefObject; } export const useEventActions = ({ event, timingEvent, viewType, isAllDay, isMultiDay, segment, multiDaySegmentInfo, calendarRef, firstHour, hourHeight, isMobile, canOpenDetail, useEventDetailPanel, detailPanelKey, app, onEventSelect, onDetailPanelToggle, setIsSelected, setDetailPanelPosition, setContextMenuPosition, setActiveDayIndex, getClickedDayIdx, updatePanelPosition, selectedEventElementRef, }: UseEventActionsProps) => { const isMonthView = viewType === ViewType.MONTH; const isYearView = viewType === ViewType.YEAR; const isResourceView = viewType === ViewType.RESOURCE; const eventForTiming = timingEvent ?? event; const clickTimeoutRef = useRef | null>(null); const [hasPendingSelection, setHasPendingSelection] = useState(false); const clearPendingClick = useCallback(() => { if (!clickTimeoutRef.current) return; clearTimeout(clickTimeoutRef.current); clickTimeoutRef.current = null; setHasPendingSelection(false); }, []); useEffect(() => () => clearPendingClick(), [clearPendingClick]); const waitForScrollSettled = useCallback( ( scrollContainer: HTMLElement, initialScrollLeft: number, initialScrollTop: number ): Promise => new Promise(resolve => { const sampleIntervalMs = 40; const quietWindowMs = 120; const timeoutMs = 600; let quietForMs = 0; let elapsedMs = 0; let lastScrollLeft = initialScrollLeft; let lastScrollTop = initialScrollTop; const checkScrollState = () => { const nextScrollLeft = scrollContainer.scrollLeft; const nextScrollTop = scrollContainer.scrollTop; const didMove = Math.abs(nextScrollLeft - lastScrollLeft) > 1 || Math.abs(nextScrollTop - lastScrollTop) > 1; quietForMs = didMove ? 0 : quietForMs + sampleIntervalMs; elapsedMs += sampleIntervalMs; lastScrollLeft = nextScrollLeft; lastScrollTop = nextScrollTop; if (quietForMs >= quietWindowMs || elapsedMs >= timeoutMs) { resolve(); return; } setTimeout(checkScrollState, sampleIntervalMs); }; setTimeout(checkScrollState, sampleIntervalMs); }), [] ); const scrollEventToCenter = useCallback( (): Promise => new Promise(resolve => { if ( !calendarRef.current || isAllDay || isMonthView || isYearView || hourHeight <= 0 ) { resolve(); return; } const calendarContent = getCalendarContentElement(calendarRef); if (!calendarContent) { resolve(); return; } if (isResourceView && selectedEventElementRef.current) { const eventRect = selectedEventElementRef.current.getBoundingClientRect(); const contentRect = calendarContent.getBoundingClientRect(); const isFullyVisibleInViewport = eventRect.left >= contentRect.left && eventRect.right <= contentRect.right && eventRect.top >= contentRect.top && eventRect.bottom <= contentRect.bottom; if (isFullyVisibleInViewport) { resolve(); return; } const initialScrollLeft = calendarContent.scrollLeft; const initialScrollTop = calendarContent.scrollTop; const targetScrollLeft = initialScrollLeft + (eventRect.left - contentRect.left) - (calendarContent.clientWidth - eventRect.width) / 2; const targetScrollTop = initialScrollTop + (eventRect.top - contentRect.top) - (calendarContent.clientHeight - eventRect.height) / 2; const maxScrollLeft = Math.max( 0, calendarContent.scrollWidth - calendarContent.clientWidth ); const maxScrollTop = Math.max( 0, calendarContent.scrollHeight - calendarContent.clientHeight ); const nextScrollLeft = Math.max( 0, Math.min(maxScrollLeft, targetScrollLeft) ); const nextScrollTop = Math.max( 0, Math.min(maxScrollTop, targetScrollTop) ); const needsScroll = Math.abs(nextScrollLeft - initialScrollLeft) > 1 || Math.abs(nextScrollTop - initialScrollTop) > 1; if (!needsScroll) { resolve(); return; } calendarContent.scrollTo({ left: nextScrollLeft, top: nextScrollTop, behavior: 'smooth', }); waitForScrollSettled( calendarContent, initialScrollLeft, initialScrollTop ).then(resolve); return; } const segmentStartHour = multiDaySegmentInfo ? multiDaySegmentInfo.startHour : extractHourFromDate(eventForTiming.start); const segmentEndHour = multiDaySegmentInfo ? multiDaySegmentInfo.endHour : getEventEndHour(eventForTiming); const eventTop = (segmentStartHour - firstHour) * hourHeight; const eventHeight = Math.max( (segmentEndHour - segmentStartHour) * hourHeight, hourHeight / 4 ); const eventBottom = eventTop + eventHeight; const scrollTop = calendarContent.scrollTop; const viewportHeight = calendarContent.clientHeight; const scrollBottom = scrollTop + viewportHeight; if (eventTop >= scrollTop && eventBottom <= scrollBottom) { resolve(); return; } const eventMiddleHour = (segmentStartHour + segmentEndHour) / 2; const targetScrollTop = (eventMiddleHour - firstHour) * hourHeight - viewportHeight / 2; const maxScrollTop = calendarContent.scrollHeight - viewportHeight; const nextScrollTop = Math.max( 0, Math.min(maxScrollTop, targetScrollTop) ); if (Math.abs(nextScrollTop - scrollTop) <= 1) { resolve(); return; } calendarContent.scrollTo({ top: nextScrollTop, behavior: 'smooth', }); waitForScrollSettled( calendarContent, calendarContent.scrollLeft, scrollTop ).then(resolve); }), [ calendarRef, isAllDay, isMonthView, isYearView, isResourceView, multiDaySegmentInfo, eventForTiming.start, eventForTiming.end, firstHour, hourHeight, selectedEventElementRef, waitForScrollSettled, ] ); const handleContextMenu = useCallback( (e: MouseEvent) => { clearPendingClick(); e.preventDefault(); e.stopPropagation(); if (app && !app.canMutateFromUI(event.id)) return; if (onEventSelect) onEventSelect(event.id); setContextMenuPosition({ x: e.clientX, y: e.clientY }); }, [app, clearPendingClick, event.id, onEventSelect, setContextMenuPosition] ); const applySingleClickSelection = useCallback( (clientX: number) => { if (isMultiDay) { const clickedDay = getClickedDayIdx(clientX); setActiveDayIndex( clickedDay === null ? (multiDaySegmentInfo?.dayIndex ?? event.day ?? null) : segment ? Math.min( Math.max(clickedDay, segment.startDayIndex), segment.endDayIndex ) : clickedDay ); } else { setActiveDayIndex(event.day ?? null); } if (onEventSelect) { onEventSelect(event.id); } else if (canOpenDetail) { setIsSelected(true); } if (useEventDetailPanel !== false) { onDetailPanelToggle?.(null); setDetailPanelPosition(null); } }, [ isMultiDay, getClickedDayIdx, setActiveDayIndex, multiDaySegmentInfo?.dayIndex, event, segment, onEventSelect, canOpenDetail, setIsSelected, onDetailPanelToggle, setDetailPanelPosition, ] ); const emitSingleClick = useCallback(() => { app?.onEventClick(event); }, [app, event]); const performSingleClick = useCallback( (clientX: number) => { applySingleClickSelection(clientX); emitSingleClick(); }, [applySingleClickSelection, emitSingleClick] ); const handleDoubleClick = useCallback( (e: MouseEvent) => { clearPendingClick(); e.preventDefault(); e.stopPropagation(); if (!canOpenDetail) return; let targetElement = e.currentTarget as HTMLDivElement; if (isMultiDay) { const multiDayElement = targetElement.querySelector( 'div' ) as HTMLDivElement; if (multiDayElement) targetElement = multiDayElement; } selectedEventElementRef.current = targetElement; if (isMultiDay) { const clickedDay = getClickedDayIdx(e.clientX); setActiveDayIndex( clickedDay === null ? (segment?.startDayIndex ?? event.day ?? 0) : Math.min( Math.max(clickedDay, segment?.startDayIndex ?? 0), segment?.endDayIndex ?? 6 ) ); } else { setActiveDayIndex(event.day ?? null); } setIsSelected(true); onEventSelect?.(event.id); const openDetailPanel = () => { scrollEventToCenter().then(() => { if (useEventDetailPanel === false) return; if (!isMobile) { onDetailPanelToggle?.(detailPanelKey); setDetailPanelPosition({ top: -9999, left: -9999, eventHeight: 0, eventMiddleY: 0, isSunday: false, }); requestAnimationFrame(() => updatePanelPosition()); } }); }; if (!app) { openDetailPanel(); return; } Promise.resolve(app.onEventDoubleClick?.(event, e)) .then(result => { if (result === false) return; openDetailPanel(); }) .catch(error => { logger.error('Failed to handle event double click callback', error); openDetailPanel(); }); }, [ clearPendingClick, canOpenDetail, isMultiDay, selectedEventElementRef, getClickedDayIdx, setActiveDayIndex, segment?.startDayIndex, segment?.endDayIndex, event.day, app, event, scrollEventToCenter, setIsSelected, isYearView, isMobile, onEventSelect, onDetailPanelToggle, detailPanelKey, setDetailPanelPosition, updatePanelPosition, ] ); const handleClick = useCallback( (e: MouseEvent) => { e.preventDefault(); e.stopPropagation(); const clientX = e.clientX; if (!isMobile && canOpenDetail) { clearPendingClick(); if (!isYearView && !isResourceView) { applySingleClickSelection(clientX); } setHasPendingSelection(true); clickTimeoutRef.current = setTimeout(() => { if (isYearView || isResourceView) { applySingleClickSelection(clientX); } emitSingleClick(); clickTimeoutRef.current = null; setHasPendingSelection(false); }, SINGLE_CLICK_DELAY_MS); return; } performSingleClick(clientX); }, [ clearPendingClick, canOpenDetail, applySingleClickSelection, emitSingleClick, isYearView, isResourceView, isMobile, performSingleClick, ] ); return { handleClick, handleDoubleClick, handleContextMenu, hasPendingSelection, scrollEventToCenter, }; }; ================================================ FILE: packages/core/src/components/calendarEvent/hooks/useEventInteraction.ts ================================================ import { useRef, useState } from 'preact/hooks'; import { MultiDayEventSegment } from '@/components/monthView/util'; import { Event, ICalendarApp } from '@/types'; interface UseEventInteractionProps { event: Event; isTouchEnabled: boolean; onMoveStart?: (e: MouseEvent | TouchEvent, event: Event) => void; onEventLongPress?: (eventId: string) => void; onEventSelect?: (eventId: string | null) => void; onDetailPanelToggle?: (key: string | null) => void; canOpenDetail: boolean; useEventDetailPanel?: boolean; app?: ICalendarApp; multiDaySegmentInfo?: { startHour?: number; endHour?: number; isFirst: boolean; isLast: boolean; dayIndex?: number; }; isMultiDay?: boolean; segment?: MultiDayEventSegment; detailPanelKey: string; } export const useEventInteraction = ({ event, isTouchEnabled, onMoveStart, onEventLongPress, onEventSelect, onDetailPanelToggle, canOpenDetail, useEventDetailPanel, app, multiDaySegmentInfo, isMultiDay, segment, }: UseEventInteractionProps) => { const [isSelected, setIsSelected] = useState(false); const [isPressed, setIsPressed] = useState(false); const longPressTimerRef = useRef | null>(null); const touchStartPosRef = useRef<{ x: number; y: number } | null>(null); const latestTouchPosRef = useRef<{ x: number; y: number } | null>(null); const longPressTriggeredRef = useRef(false); const suppressClickUntilRef = useRef(0); const LONG_PRESS_DELAY_MS = 500; const LONG_PRESS_MOVE_TOLERANCE_PX = 14; const handleTouchStart = (e: TouchEvent) => { if (!onMoveStart || !isTouchEnabled) return; e.stopPropagation(); e.preventDefault(); setIsPressed(true); const touch = e.touches[0]; const clientX = touch.clientX; const clientY = touch.clientY; const currentTarget = e.currentTarget as HTMLElement; touchStartPosRef.current = { x: clientX, y: clientY }; latestTouchPosRef.current = { x: clientX, y: clientY }; longPressTriggeredRef.current = false; longPressTimerRef.current = setTimeout(() => { const latestTouch = latestTouchPosRef.current ?? { x: clientX, y: clientY, }; if (onEventLongPress) { onEventLongPress(event.id); } else { setIsSelected(true); } const syntheticEvent = { preventDefault: () => { /* noop */ }, stopPropagation: () => { /* noop */ }, currentTarget: currentTarget, touches: [{ clientX: latestTouch.x, clientY: latestTouch.y }], cancelable: false, } as unknown as MouseEvent | TouchEvent; longPressTriggeredRef.current = true; if (multiDaySegmentInfo) { const adjustedEvent = { ...event, day: multiDaySegmentInfo.dayIndex ?? event.day, _segmentInfo: multiDaySegmentInfo, }; onMoveStart(syntheticEvent, adjustedEvent as Event); } else if (isMultiDay && segment) { const adjustedEvent = { ...event, day: segment.startDayIndex, _segmentInfo: { dayIndex: segment.startDayIndex, isFirst: segment.isFirstSegment, isLast: segment.isLastSegment, }, }; onMoveStart(syntheticEvent, adjustedEvent as Event); } else { onMoveStart(syntheticEvent, event); } longPressTimerRef.current = null; touchStartPosRef.current = null; if (navigator.vibrate) { navigator.vibrate(50); } suppressClickUntilRef.current = Date.now() + 400; }, LONG_PRESS_DELAY_MS); }; const handleTouchMove = (e: TouchEvent) => { const touch = e.touches[0]; if (touch) { latestTouchPosRef.current = { x: touch.clientX, y: touch.clientY, }; } if (longPressTriggeredRef.current) { if (e.cancelable) { e.preventDefault(); } e.stopPropagation(); return; } if (isTouchEnabled) { e.stopPropagation(); } if (longPressTimerRef.current && touchStartPosRef.current) { const dx = Math.abs((touch?.clientX ?? 0) - touchStartPosRef.current.x); const dy = Math.abs((touch?.clientY ?? 0) - touchStartPosRef.current.y); if ( dx > LONG_PRESS_MOVE_TOLERANCE_PX || dy > LONG_PRESS_MOVE_TOLERANCE_PX ) { clearTimeout(longPressTimerRef.current); longPressTimerRef.current = null; touchStartPosRef.current = null; latestTouchPosRef.current = null; setIsPressed(false); } } }; const handleTouchEnd = (e: TouchEvent) => { setIsPressed(false); if (longPressTimerRef.current) { clearTimeout(longPressTimerRef.current); longPressTimerRef.current = null; } if (longPressTriggeredRef.current) { longPressTriggeredRef.current = false; touchStartPosRef.current = null; latestTouchPosRef.current = null; return; } if (isTouchEnabled && touchStartPosRef.current) { e.preventDefault(); e.stopPropagation(); suppressClickUntilRef.current = Date.now() + 400; if (app) { app.onEventClick(event); } if (canOpenDetail) { if (onEventSelect) { onEventSelect(event.id); } else { setIsSelected(true); } if (useEventDetailPanel !== false) { onDetailPanelToggle?.(null); } } else { onEventSelect?.(null); if (useEventDetailPanel !== false) { onDetailPanelToggle?.(null); } } } touchStartPosRef.current = null; latestTouchPosRef.current = null; }; const handleTouchCancel = () => { setIsPressed(false); if (longPressTimerRef.current) { clearTimeout(longPressTimerRef.current); longPressTimerRef.current = null; } touchStartPosRef.current = null; latestTouchPosRef.current = null; longPressTriggeredRef.current = false; }; return { isSelected, setIsSelected, isPressed, setIsPressed, handleTouchStart, handleTouchMove, handleTouchEnd, handleTouchCancel, shouldSuppressClick: () => Date.now() < suppressClickUntilRef.current, }; }; ================================================ FILE: packages/core/src/components/calendarEvent/hooks/useEventStyles.ts ================================================ import { RefObject } from 'preact'; import type { EventVisibility } from '@/components/calendarEvent/hooks/useEventVisibility'; import { getCalendarContentElement, getTimeColumnWidth, } from '@/components/calendarEvent/utils'; import { MultiDayEventSegment } from '@/components/monthView/util'; import { YearMultiDaySegment } from '@/components/yearView/utils'; import { ViewType, Event, EventLayout } from '@/types'; import { extractHourFromDate, getEventEndHour } from '@/utils'; const POP_TRANSITION = 'transform 0.5s cubic-bezier(0.22, 1, 0.36, 1)'; interface UseEventStylesProps { event: Event; timingEvent?: Event; layout?: EventLayout; isBeingDragged: boolean; isAllDay: boolean; allDayHeight: number; viewType: ViewType; isMultiDay: boolean; segment?: MultiDayEventSegment; yearSegment?: YearMultiDaySegment; columnsPerRow?: number; segmentIndex: number; hourHeight: number; firstHour: number; isEventSelected: boolean; showDetailPanel: boolean; isPopping: boolean; isDraggable: boolean; canOpenDetail: boolean; eventVisibility: EventVisibility; calendarRef: RefObject; isMobile: boolean; eventRef: RefObject; getActiveDayIdx: () => number; getDayMetricsWrapper: ( dayIndex: number ) => { left: number; width: number } | null; multiDaySegmentInfo?: { startHour: number; endHour: number; isFirst: boolean; isLast: boolean; dayIndex?: number; }; monthEventHeight?: number; } export const useEventStyles = ({ event, timingEvent, layout, isBeingDragged, isAllDay, allDayHeight, viewType, isMultiDay, segment, yearSegment, columnsPerRow, segmentIndex, hourHeight, firstHour, isEventSelected, showDetailPanel, isPopping, isDraggable, canOpenDetail, eventVisibility, calendarRef, isMobile, eventRef, getActiveDayIdx, getDayMetricsWrapper, multiDaySegmentInfo, monthEventHeight, }: UseEventStylesProps) => { const isDayView = viewType === ViewType.DAY; const isMonthView = viewType === ViewType.MONTH; const isYearView = viewType === ViewType.YEAR; const eventForTiming = timingEvent ?? event; const calculateEventStyle = () => { if (isYearView && yearSegment && columnsPerRow) { const { startCellIndex, endCellIndex, visualRowIndex } = yearSegment; const startPercent = (startCellIndex / columnsPerRow) * 100; const widthPercent = ((endCellIndex - startCellIndex + 1) / columnsPerRow) * 100; const TOP_OFFSET = visualRowIndex * 18; // ROW_SPACING const HORIZONTAL_MARGIN = 2; const EVENT_HEIGHT = 16; return { position: 'absolute', left: `calc(${startPercent}% + ${HORIZONTAL_MARGIN}px)`, top: `${TOP_OFFSET}px`, width: `calc(${widthPercent}% - ${HORIZONTAL_MARGIN * 2}px)`, height: `${EVENT_HEIGHT}px`, opacity: 1, zIndex: isEventSelected || showDetailPanel ? 1000 : 1, transform: isPopping ? 'scale(1.05)' : 'scale(1)', transition: isBeingDragged ? [ 'left 90ms cubic-bezier(0.22, 1, 0.36, 1)', 'top 90ms cubic-bezier(0.22, 1, 0.36, 1)', 'width 90ms cubic-bezier(0.22, 1, 0.36, 1)', POP_TRANSITION, ].join(', ') : POP_TRANSITION, willChange: isBeingDragged ? ('left, top, width, transform' as const) : ('transform' as const), cursor: isDraggable ? 'pointer' : canOpenDetail ? 'pointer' : 'default', }; } if (isMonthView) { if (isMultiDay && segment) { return { opacity: 1, zIndex: isEventSelected || showDetailPanel ? 1000 : 1, cursor: isDraggable ? 'pointer' : canOpenDetail ? 'pointer' : 'default', }; } return { ...(monthEventHeight !== undefined && { height: `${monthEventHeight}px`, '--df-month-event-height': `${monthEventHeight}px`, }), opacity: 1, zIndex: isEventSelected || showDetailPanel ? 1000 : 1, transform: isPopping ? 'scale(1.05)' : 'scale(1)', transition: POP_TRANSITION, cursor: isDraggable ? 'pointer' : canOpenDetail ? 'pointer' : 'default', }; } if (isAllDay) { const styles: Record = { height: `${allDayHeight - 4}px`, opacity: 1, zIndex: isEventSelected || showDetailPanel ? 1000 : 1, transform: isPopping ? 'scale(1.05)' : 'scale(1)', transition: POP_TRANSITION, cursor: isDraggable ? 'pointer' : canOpenDetail ? 'pointer' : 'default', }; const topOffset = segmentIndex * allDayHeight; Object.assign(styles, { top: `${topOffset}px` }); if (isDayView) { Object.assign(styles, { width: '100%', left: '0px', right: '2px', position: 'absolute', }); } else if (isMultiDay && segment) { const cols = columnsPerRow || 7; const spanDays = segment.endDayIndex - segment.startDayIndex + 1; const widthPercent = (spanDays / cols) * 100; const leftPercent = (segment.startDayIndex / cols) * 100; const HORIZONTAL_MARGIN = 2; const marginLeft = segment.isFirstSegment ? HORIZONTAL_MARGIN : 0; const marginRight = segment.isLastSegment ? HORIZONTAL_MARGIN : 0; const totalMargin = marginLeft + marginRight; Object.assign(styles, { width: totalMargin > 0 ? `calc(${widthPercent}% - ${totalMargin}px)` : `${widthPercent}%`, left: marginLeft > 0 ? `calc(${leftPercent}% + ${marginLeft}px)` : `${leftPercent}%`, position: 'absolute', pointerEvents: 'auto', }); } else { Object.assign(styles, { width: '100%', left: '0px', position: 'relative', }); } return styles; } const startHour = multiDaySegmentInfo ? multiDaySegmentInfo.startHour : extractHourFromDate(eventForTiming.start); const endHour = multiDaySegmentInfo ? multiDaySegmentInfo.endHour : getEventEndHour(eventForTiming); const top = (startHour - firstHour) * hourHeight; const height = Math.max((endHour - startHour) * hourHeight, hourHeight / 4); const baseStyle = { top: `${top + 3}px`, height: `${height - 4}px`, position: 'absolute', opacity: 1, zIndex: isEventSelected || showDetailPanel ? 1000 : (layout?.zIndex ?? 1), transform: isPopping ? 'scale(1.05)' : 'scale(1)', transition: POP_TRANSITION, cursor: isDraggable ? 'pointer' : canOpenDetail ? 'pointer' : 'default', }; // Sticky-left/right: event's column has scrolled out of the visible horizontal area if ( isEventSelected && showDetailPanel && (eventVisibility === 'sticky-left' || eventVisibility === 'sticky-right') ) { const calendarContent = getCalendarContentElement(calendarRef); const contentRect = calendarContent?.getBoundingClientRect(); if (contentRect) { const timeColumnWidth = getTimeColumnWidth(calendarRef, isMobile); const gridLeft = contentRect.left + timeColumnWidth; const gridRight = contentRect.right; const parentRect = eventRef.current?.parentElement?.getBoundingClientRect(); // Compute event's natural vertical viewport position using the column container's top const gridRefTop = parentRect?.top ?? contentRect.top; const eventNaturalTop = gridRefTop + (startHour - firstHour) * hourHeight + 3; const clampedTop = Math.max(eventNaturalTop, contentRect.top); const clampedBottom = Math.min( eventNaturalTop + height - 4, contentRect.bottom ); if (clampedBottom <= clampedTop) { return { display: 'none' as const }; } const stickyStyle = { position: 'fixed', top: `${clampedTop}px`, height: `${clampedBottom - clampedTop}px`, width: '6px', zIndex: 1000, overflow: 'hidden', borderRadius: '0.25rem', }; if (eventVisibility === 'sticky-left') { return { ...stickyStyle, left: `${gridLeft}px` }; } return { ...stickyStyle, left: `${gridRight - 6}px` }; } } if ( isEventSelected && showDetailPanel && (eventVisibility === 'sticky-top' || eventVisibility === 'sticky-bottom') ) { const calendarRect = calendarRef.current?.getBoundingClientRect(); if (calendarRect) { const activeDayIndex = getActiveDayIdx(); const timeColumnWidth = getTimeColumnWidth(calendarRef, isMobile); const columnCount = isDayView ? 1 : columnsPerRow || 7; let dayColumnWidth = (calendarRect.width - timeColumnWidth) / columnCount; let dayStartX = calendarRect.left + timeColumnWidth + (isDayView ? 0 : activeDayIndex * dayColumnWidth); if (isMonthView) { dayColumnWidth = calendarRect.width / 7; dayStartX = calendarRect.left + activeDayIndex * dayColumnWidth; } const overrideMetrics = getDayMetricsWrapper(activeDayIndex); if (overrideMetrics) { dayStartX = overrideMetrics.left; dayColumnWidth = overrideMetrics.width; } let scrollContainer = getCalendarContentElement(calendarRef); if (!scrollContainer) { scrollContainer = (calendarRef.current?.querySelector( '.df-calendar-renderer' ) as HTMLElement | null) ?? null; } const contentRect = scrollContainer?.getBoundingClientRect(); const parentRect = eventRef.current?.parentElement?.getBoundingClientRect(); let stickyLeft: number; let stickyWidth: number; if (parentRect && parentRect.width > 0) { if (layout) { stickyLeft = parentRect.left + (layout.left / 100) * parentRect.width; stickyWidth = isDayView ? (layout.width / 100) * parentRect.width : ((layout.width - 1) / 100) * parentRect.width; } else { stickyLeft = parentRect.left; stickyWidth = isDayView ? parentRect.width : parentRect.width - 3; } } else { const metrics = getDayMetricsWrapper(activeDayIndex); const currentDayStartX = metrics?.left ?? dayStartX; const currentDayColumnWidth = metrics?.width ?? dayColumnWidth; stickyLeft = currentDayStartX; stickyWidth = currentDayColumnWidth - 3; if (layout) { stickyLeft = currentDayStartX + (layout.left / 100) * currentDayColumnWidth; stickyWidth = isDayView ? (layout.width / 100) * currentDayColumnWidth : ((layout.width - 1) / 100) * currentDayColumnWidth; } } // Clamp the sticky bar to stay within the grid content area (right of time column) const gridContentLeft = calendarRect.left + timeColumnWidth; if (stickyLeft < gridContentLeft) { stickyWidth = Math.max( 0, stickyWidth - (gridContentLeft - stickyLeft) ); stickyLeft = gridContentLeft; } // Also clamp right edge if (stickyLeft + stickyWidth > calendarRect.right) { stickyWidth = Math.max(0, calendarRect.right - stickyLeft); } if (eventVisibility === 'sticky-top') { let topPosition = contentRect ? contentRect.top : calendarRect.top; topPosition = Math.max(topPosition, 0); topPosition = Math.max(topPosition, calendarRect.top); topPosition = Math.min(topPosition, calendarRect.bottom - 6); topPosition = Math.min(topPosition, window.innerHeight - 6); return { position: 'fixed', top: `${topPosition}px`, left: `${stickyLeft}px`, width: `${stickyWidth}px`, height: '6px', zIndex: 1000, overflow: 'hidden', borderRadius: '0.25rem', }; } let bottomPosition = contentRect ? contentRect.bottom : calendarRect.bottom; bottomPosition = Math.min(bottomPosition, window.innerHeight); bottomPosition = Math.min(bottomPosition, calendarRect.bottom); bottomPosition = Math.max(bottomPosition, calendarRect.top + 6); bottomPosition = Math.max(bottomPosition, 6); return { position: 'fixed', top: `${bottomPosition - 6}px`, left: `${stickyLeft}px`, width: `${stickyWidth}px`, height: '6px', zIndex: 1000, overflow: 'hidden', borderRadius: '0.25rem', }; } } if (layout && !isAllDay) { const widthStyle = isDayView ? `${layout.width}%` : `${layout.width - 1}%`; return { ...baseStyle, left: `${layout.left}%`, width: widthStyle, right: 'auto', }; } return { ...baseStyle, left: '0px', right: isDayView ? '0px' : '3px', }; }; return { calculateEventStyle }; }; ================================================ FILE: packages/core/src/components/calendarEvent/hooks/useEventVisibility.ts ================================================ import { RefObject } from 'preact'; import { useEffect, useCallback } from 'preact/hooks'; import { getCalendarContentElement, getTimeColumnWidth, } from '@/components/calendarEvent/utils'; import { Event, ViewType } from '@/types'; import { extractHourFromDate, getEventEndHour } from '@/utils'; export type EventVisibility = | 'standard' | 'sticky-top' | 'sticky-bottom' | 'sticky-left' | 'sticky-right'; interface UseEventVisibilityProps { event: Event; timingEvent?: Event; isEventSelected: boolean; showDetailPanel: boolean; eventRef: RefObject; calendarRef: RefObject; isAllDay: boolean; viewType: ViewType; isMobile?: boolean; multiDaySegmentInfo?: { startHour: number; endHour: number; isFirst: boolean; isLast: boolean; dayIndex?: number; }; firstHour: number; hourHeight: number; updatePanelPosition: () => void; eventVisibility: EventVisibility; setEventVisibility: (visibility: EventVisibility) => void; } export const useEventVisibility = ({ event, timingEvent, isEventSelected, showDetailPanel, eventRef, calendarRef, isAllDay, viewType, isMobile = false, multiDaySegmentInfo, firstHour, hourHeight, updatePanelPosition, eventVisibility, setEventVisibility, }: UseEventVisibilityProps) => { const isMonthView = viewType === ViewType.MONTH; const isYearView = viewType === ViewType.YEAR; const isResourceView = viewType === ViewType.RESOURCE; const eventForTiming = timingEvent ?? event; const checkEventVisibility = useCallback(() => { if ( !isEventSelected || !showDetailPanel || !eventRef.current || !calendarRef.current || isAllDay || isMonthView || isYearView ) return; const calendarContent = getCalendarContentElement(calendarRef); if (!calendarContent) return; const segmentStartHour = multiDaySegmentInfo ? multiDaySegmentInfo.startHour : extractHourFromDate(eventForTiming.start); const segmentEndHour = multiDaySegmentInfo ? multiDaySegmentInfo.endHour : getEventEndHour(eventForTiming); const originalTop = (segmentStartHour - firstHour) * hourHeight; const originalHeight = Math.max( (segmentEndHour - segmentStartHour) * hourHeight, hourHeight / 4 ); const originalBottom = originalTop + originalHeight; const contentRect = calendarContent.getBoundingClientRect(); const scrollTop = calendarContent.scrollTop; const viewportHeight = contentRect.height; const scrollBottom = scrollTop + viewportHeight; const isContentAboveViewport = contentRect.bottom < 0; const isContentBelowViewport = contentRect.top > window.innerHeight; const STICKY_THRESHOLD = 20; let nextVisibility: EventVisibility = eventVisibility; if (isContentAboveViewport) { nextVisibility = 'sticky-top'; } else if (isContentBelowViewport) { nextVisibility = 'sticky-bottom'; } else { // Determine vertical state, treating horizontal sticky as 'standard' for transition purposes const isCurrentlyHorizontallySticky = eventVisibility === 'sticky-left' || eventVisibility === 'sticky-right'; const currentVerticalState: EventVisibility = isCurrentlyHorizontallySticky ? 'standard' : eventVisibility; let newVerticalState: EventVisibility; if (currentVerticalState === 'standard') { if (originalBottom < scrollTop) newVerticalState = 'sticky-top'; else if (originalTop > scrollBottom - STICKY_THRESHOLD) newVerticalState = 'sticky-bottom'; else newVerticalState = 'standard'; } else if (currentVerticalState === 'sticky-top') { newVerticalState = originalBottom >= scrollTop ? 'standard' : 'sticky-top'; } else { // sticky-bottom newVerticalState = originalTop <= scrollBottom - STICKY_THRESHOLD ? 'standard' : 'sticky-bottom'; } if (newVerticalState !== 'standard') { nextVisibility = newVerticalState; } else if (isResourceView) { // Vertically standard — check horizontal visibility const parentRect = eventRef.current?.parentElement?.getBoundingClientRect(); if (parentRect) { const timeColumnWidth = getTimeColumnWidth(calendarRef, isMobile); const gridLeft = contentRect.left + timeColumnWidth; const gridRight = contentRect.right; if (parentRect.right <= gridLeft) { nextVisibility = 'sticky-left'; } else if (parentRect.left >= gridRight) { nextVisibility = 'sticky-right'; } else { nextVisibility = 'standard'; } } else { nextVisibility = 'standard'; } } else { nextVisibility = 'standard'; } } if (nextVisibility === eventVisibility) { updatePanelPosition(); } else { setEventVisibility(nextVisibility); // Defer panel update until after the re-render commits the new sticky styles setTimeout(updatePanelPosition, 0); } }, [ isEventSelected, showDetailPanel, calendarRef, isAllDay, isMonthView, isResourceView, isMobile, eventForTiming.start, eventForTiming.end, firstHour, hourHeight, updatePanelPosition, multiDaySegmentInfo, eventVisibility, setEventVisibility, ]); useEffect(() => { if (!isEventSelected || !showDetailPanel || isAllDay) return; const calendarContent = getCalendarContentElement(calendarRef); if (!calendarContent) return; const handleScroll = () => checkEventVisibility(); const handleResize = () => { checkEventVisibility(); updatePanelPosition(); }; const scrollContainers: Element[] = [calendarContent]; let parent = calendarRef.current?.parentElement; while (parent) { const style = window.getComputedStyle(parent); if ( style.overflowY === 'auto' || style.overflowY === 'scroll' || style.overflowX === 'auto' || style.overflowX === 'scroll' ) { scrollContainers.push(parent); } parent = parent.parentElement; } scrollContainers.forEach(container => { container.addEventListener('scroll', handleScroll); }); window.addEventListener('scroll', handleScroll, true); window.addEventListener('resize', handleResize); let resizeObserver: ResizeObserver | null = null; if (calendarRef.current) { resizeObserver = new ResizeObserver(() => { handleResize(); }); resizeObserver.observe(calendarRef.current); } checkEventVisibility(); return () => { scrollContainers.forEach(container => { container.removeEventListener('scroll', handleScroll); }); window.removeEventListener('scroll', handleScroll, true); window.removeEventListener('resize', handleResize); if (resizeObserver) resizeObserver.disconnect(); }; }, [ isEventSelected, showDetailPanel, isAllDay, checkEventVisibility, updatePanelPosition, calendarRef, ]); }; ================================================ FILE: packages/core/src/components/calendarEvent/index.tsx ================================================ import CalendarEvent from './CalendarEvent'; export { CalendarEvent }; export default CalendarEvent; ================================================ FILE: packages/core/src/components/calendarEvent/types.ts ================================================ import { ComponentChildren, RefObject } from 'preact'; import { MultiDayEventSegment } from '@/components/monthView/util'; import { YearMultiDaySegment } from '@/components/yearView/utils'; import { Event, EventLayout, ICalendarApp, ViewType } from '@/types'; export interface CalendarEventProps { event: Event; layout?: EventLayout; isAllDay?: boolean; allDayHeight?: number; calendarRef: RefObject; isBeingDragged?: boolean; isBeingResized?: boolean; viewType: ViewType; isMultiDay?: boolean; segment?: MultiDayEventSegment; yearSegment?: YearMultiDaySegment; columnsPerRow?: number; segmentIndex?: number; hourHeight: number; firstHour: number; newlyCreatedEventId?: string | null; selectedEventId?: string | null; detailPanelEventId?: string | null; onMoveStart?: (e: MouseEvent | TouchEvent, event: Event) => void; onResizeStart?: ( e: MouseEvent | TouchEvent, event: Event, direction: string ) => void; onEventUpdate: (updatedEvent: Event) => void; onEventDelete: (eventId: string) => void; onDetailPanelOpen?: () => void; onEventSelect?: (eventId: string | null) => void; onEventLongPress?: (eventId: string) => void; onDetailPanelToggle?: (eventId: string | null) => void; /** When false, suppresses the floating event detail panel entirely */ useEventDetailPanel?: boolean; /** Multi-day regular event segment information */ multiDaySegmentInfo?: { startHour: number; endHour: number; isFirst: boolean; isLast: boolean; dayIndex?: number; }; app?: ICalendarApp; /** Whether the current view is in mobile mode */ isMobile?: boolean; /** Whether the current view is in mobile sliding mode */ isSlidingView?: boolean; /** Force enable touch interactions regardless of isMobile */ enableTouch?: boolean; /** Whether to hide the time in the event display (Month view regular events only) */ hideTime?: boolean; /** Time format for event display */ timeFormat?: '12h' | '24h'; /** Optional style override for custom view layouts */ styleOverride?: Record; /** Optional additional class names */ className?: string; /** Disable built-in layout calculation and rely on styleOverride */ disableDefaultStyle?: boolean; /** * Override the visual content rendered inside the event shell. * The function receives the built-in default content as its argument, * which can be wrapped, replaced, or ignored entirely. */ renderVisualContent?: ( defaultContent: ComponentChildren ) => ComponentChildren; /** Override resize handle orientation for custom views */ resizeHandleOrientation?: 'vertical' | 'horizontal'; /** App-level timezone used to project event times for display (Month/Year view). */ appTimeZone?: string; /** Height in pixels of each event row in the month grid (month multi-day events only). */ monthEventHeight?: number; } ================================================ FILE: packages/core/src/components/calendarEvent/utils.ts ================================================ import { RefObject } from 'preact'; import { Event, ViewType } from '@/types'; export type EventSegmentShape = 'full' | 'start' | 'end' | 'middle'; /** * Gets the actual width of the time column from the DOM */ export const getTimeColumnWidth = ( calendarRef: RefObject, isMobile: boolean ): number => { if (!calendarRef.current) return isMobile ? 48 : 80; const timeColumn = calendarRef.current.querySelector('.df-time-column'); return timeColumn ? timeColumn.getBoundingClientRect().width : isMobile ? 48 : 80; }; export const getCalendarContentElement = ( calendarRef: RefObject ): HTMLElement | null => { const element = calendarRef.current; if (!element) return null; const ownMatch = element.matches('.df-calendar-content') ? element : null; const descendantMatch = element.querySelector( '.df-calendar-content' ) as HTMLElement | null; const ancestorMatch = element.closest( '.df-calendar-content' ) as HTMLElement | null; return ownMatch ?? descendantMatch ?? ancestorMatch; }; /** * Calculates the horizontal metrics (left and width) for a day column */ export const getDayMetrics = ( dayIndex: number, calendarRef: RefObject, viewType: ViewType, isMobile: boolean ): { left: number; width: number } | null => { if (!calendarRef.current) return null; const calendarRect = calendarRef.current.getBoundingClientRect(); if (viewType === ViewType.MONTH) { const dayColumnWidth = calendarRect.width / 7; return { left: calendarRect.left + dayIndex * dayColumnWidth, width: dayColumnWidth, }; } const timeColumnWidth = getTimeColumnWidth(calendarRef, isMobile); if (viewType === ViewType.DAY) { const dayColumnWidth = calendarRect.width - timeColumnWidth; return { left: calendarRect.left + timeColumnWidth, width: dayColumnWidth, }; } const dayColumnWidth = (calendarRect.width - timeColumnWidth) / 7; return { left: calendarRect.left + timeColumnWidth + dayIndex * dayColumnWidth, width: dayColumnWidth, }; }; /** * Gets the active day index for multi-day events */ export const getActiveDayIndex = ( event: Event, detailPanelEventId: string | undefined, detailPanelKey: string, selectedDayIndex: number | null, multiDaySegmentInfo?: { dayIndex?: number }, segment?: { startDayIndex: number } ): number => { if (selectedDayIndex !== null) { return selectedDayIndex; } if (detailPanelEventId === detailPanelKey) { const keyParts = detailPanelKey.split('::'); const suffix = keyParts.at(-1); if (suffix && suffix.startsWith('day-')) { const parsed = Number(suffix.replace('day-', '')); if (!Number.isNaN(parsed)) { return parsed; } } } if (multiDaySegmentInfo?.dayIndex !== undefined) { return multiDaySegmentInfo.dayIndex; } if (segment) { return segment.startDayIndex; } return event.day ?? 0; }; /** * Gets the clicked day index based on mouse position */ export const getClickedDayIndex = ( clientX: number, calendarRef: RefObject, viewType: ViewType, isMobile: boolean ): number | null => { if (!calendarRef.current) return null; const calendarRect = calendarRef.current.getBoundingClientRect(); if (viewType === ViewType.MONTH) { const dayColumnWidth = calendarRect.width / 7; const relativeX = clientX - calendarRect.left; const index = Math.floor(relativeX / dayColumnWidth); return Number.isFinite(index) ? Math.max(0, Math.min(6, index)) : null; } const timeColumnWidth = getTimeColumnWidth(calendarRef, isMobile); const columnCount = viewType === ViewType.DAY ? 1 : 7; const dayColumnWidth = (calendarRect.width - timeColumnWidth) / columnCount; const relativeX = clientX - calendarRect.left - timeColumnWidth; const index = Math.floor(relativeX / dayColumnWidth); return Number.isFinite(index) ? Math.max(0, Math.min(columnCount - 1, index)) : null; }; /** * Gets the CSS classes for the event container */ export const getEventClasses = ( viewType: ViewType, isAllDay: boolean, isMultiDay: boolean ): string => { const classes = ['df-event']; if (viewType === ViewType.DAY) { classes.push('df-day-event'); } else if (viewType === ViewType.WEEK) { classes.push('df-week-event'); } else if (viewType === ViewType.MONTH) { classes.push('df-month-event'); if (!isMultiDay) { classes.push('df-month-event-stacked'); } } else if (viewType === ViewType.YEAR) { classes.push('df-year-event'); } classes.push(isAllDay ? 'df-event-all-day' : 'df-event-timed'); return classes.join(' '); }; export const getAllDaySegmentShape = (segment?: { segmentType: string; }): EventSegmentShape => { if (!segment) return 'full'; switch (segment.segmentType) { case 'start': case 'start-week-end': return 'start'; case 'end': case 'end-week-start': return 'end'; case 'middle': return 'middle'; default: return 'full'; } }; export const getYearSegmentShape = (yearSegment?: { isFirstSegment: boolean; isLastSegment: boolean; }): EventSegmentShape => { if (!yearSegment) return 'full'; if (yearSegment.isFirstSegment && yearSegment.isLastSegment) return 'full'; if (yearSegment.isFirstSegment) return 'start'; if (yearSegment.isLastSegment) return 'end'; return 'middle'; }; export const getEventSegmentShape = ( viewType: ViewType, isAllDay: boolean, segment?: { segmentType: string }, yearSegment?: { isFirstSegment: boolean; isLastSegment: boolean } ): EventSegmentShape => { if (viewType === ViewType.YEAR) { return getYearSegmentShape(yearSegment); } if (isAllDay) { return getAllDaySegmentShape(segment); } return 'full'; }; ================================================ FILE: packages/core/src/components/common/BlossomColorPicker.tsx ================================================ import { BlossomColorPicker as VanillaBlossomColorPicker, BlossomColorPickerOptions, } from '@dayflow/blossom-color-picker'; import { useEffect, useRef } from 'preact/hooks'; interface BlossomColorPickerProps extends Partial { className?: string; } export const BlossomColorPicker = (props: BlossomColorPickerProps) => { const containerRef = useRef(null); const pickerRef = useRef(null); useEffect(() => { if (containerRef.current) { const { ...options } = props; pickerRef.current = new VanillaBlossomColorPicker( containerRef.current, options ); if (options.initialExpanded) { pickerRef.current.expand(); } } return () => { if (pickerRef.current) { pickerRef.current.destroy(); pickerRef.current = null; } }; }, []); // Handle updates to options useEffect(() => { if (pickerRef.current) { const { ...options } = props; pickerRef.current.setOptions(options); } }, [props]); return
; }; ================================================ FILE: packages/core/src/components/common/CalendarHeader.tsx ================================================ import { JSX } from 'preact'; import { useCallback } from 'preact/hooks'; import { useResponsiveMonthConfig } from '@/hooks/virtualScroll'; import { useLocale } from '@/locale/useLocale'; import { iconButton } from '@/styles/classNames'; import { ViewType, CalendarHeaderProps } from '@/types'; import { Plus, Search } from './Icons'; import ViewSwitcher from './ViewSwitcher'; const CalendarHeader = ({ calendar, switcherMode = 'buttons', onAddCalendar, onSearchChange, onSearchClick, searchValue = '', isSearchOpen = false, isEditable = true, safeAreaLeft, }: CalendarHeaderProps) => { const isSwitcherCentered = switcherMode === 'buttons'; const isDayView = calendar.state.currentView === ViewType.DAY; const { screenSize } = useResponsiveMonthConfig(); const isMobile = screenSize === 'mobile'; const { t } = useLocale(); const handleSearchChange = useCallback( (e: JSX.TargetedEvent) => { const newValue = e.currentTarget.value; if (newValue !== searchValue) { onSearchChange?.(newValue); } }, [onSearchChange, searchValue] ); const handleClearSearch = () => { onSearchChange?.(''); }; const isBordered = isDayView || isSearchOpen; return (
e.preventDefault()} > {/* Left Section: Add Calendar Button Only */}
{onAddCalendar && isEditable && ( )}
{/* Middle Section: ViewSwitcher (if mode is buttons) */}
{isSwitcherCentered && ( )}
{/* Right Section: Search, ViewSwitcher (if select) */}
{!isSwitcherCentered && ( )} {isMobile ? ( /* Mobile: search icon only */ ) : ( /* Desktop: inline search bar */
{searchValue && ( )}
)}
); }; export default CalendarHeader; ================================================ FILE: packages/core/src/components/common/CalendarPicker.tsx ================================================ import { JSX } from 'preact'; import { createPortal } from 'preact/compat'; import { useState, useRef, useEffect } from 'preact/hooks'; import { getDefaultCalendarRegistry, CalendarRegistry, } from '@/core/calendarRegistry'; import { calendarPickerDropdown } from '@/styles/classNames'; import { ChevronsUpDown, Check } from './Icons'; export interface CalendarOption { label: string; value: string; // calendar ID } export interface CalendarPickerProps { options: CalendarOption[]; value: string; onChange: (value: string) => void; registry?: CalendarRegistry; variant?: 'desktop' | 'mobile'; disabled?: boolean; } /** * CalendarPicker Component * Used to select which calendar an event belongs to */ export const CalendarPicker = ({ options, value, onChange, registry, variant = 'desktop', disabled = false, }: CalendarPickerProps) => { const [isOpen, setIsOpen] = useState(false); const [dropdownStyle, setDropdownStyle] = useState({}); const pickerRef = useRef(null); const triggerRef = useRef(null); const updatePosition = () => { if (triggerRef.current) { const rect = triggerRef.current.getBoundingClientRect(); const isMobile = variant === 'mobile'; const style: JSX.CSSProperties = { position: 'fixed', zIndex: 10001, minWidth: isMobile ? '12rem' : `${rect.width}px`, top: `${rect.bottom + 4}px`, }; if (isMobile) { style.right = `${window.innerWidth - rect.right}px`; } else { style.left = `${rect.left}px`; } setDropdownStyle(style); } }; useEffect(() => { const handleClickOutside = (e: MouseEvent) => { if ( pickerRef.current && !pickerRef.current.contains(e.target as Node) && !(e.target as HTMLElement).closest('[data-calendar-picker-dropdown]') ) { setIsOpen(false); } }; if (isOpen) { updatePosition(); window.addEventListener('mousedown', handleClickOutside); window.addEventListener('scroll', updatePosition, true); window.addEventListener('resize', updatePosition); } return () => { window.removeEventListener('mousedown', handleClickOutside); window.removeEventListener('scroll', updatePosition, true); window.removeEventListener('resize', updatePosition); }; }, [isOpen]); const getColorForCalendarId = (calendarId: string): string => { const reg = registry || getDefaultCalendarRegistry(); const colors = reg.resolveColors(calendarId); return colors.lineColor; }; const handleSelect = ( e: JSX.TargetedMouseEvent, optionValue: string ) => { e.stopPropagation(); onChange(optionValue); setIsOpen(false); }; const currentOption = options.find(o => o.value === value); const renderDropdown = () => { if (!isOpen || typeof window === 'undefined') return null; if (variant === 'mobile') { return createPortal(
{options.map(opt => (
handleSelect(e, opt.value)} >
{opt.value === value && }
{opt.label}
))}
, document.body ); } return createPortal(
    {options.map(opt => (
  • handleSelect(e, opt.value)} >
    {opt.value === value && }
    {opt.label}
  • ))}
, document.body ); }; if (variant === 'mobile') { return (
{renderDropdown()}
); } return (
{renderDropdown()}
); }; export default CalendarPicker; ================================================ FILE: packages/core/src/components/common/CreateCalendarDialog.tsx ================================================ import { DEFAULT_COLORS, hslToHex, lightnessToSliderValue, } from '@dayflow/blossom-color-picker'; import { createPortal } from 'preact/compat'; import { useState, useMemo } from 'preact/hooks'; import { useTheme } from '@/contexts/ThemeContext'; import { getCalendarColorsForHex } from '@/core/calendarRegistry'; import { useLocale } from '@/locale'; import { ContentSlot } from '@/renderer/ContentSlot'; import { CalendarType, CreateCalendarDialogProps } from '@/types'; import { generateUniKey } from '@/utils/helpers'; import { BlossomColorPicker } from './BlossomColorPicker'; import { DefaultColorPicker } from './DefaultColorPicker'; import { LoadingButton } from './LoadingButton'; const PICKER_DEFAULT_COLORS = [ '#ea426b', '#f19a38', '#f7cf46', '#83d754', '#51aaf2', '#b672d0', '#957e5e', ]; export const CreateCalendarDialog = ({ onClose, onCreate, app, }: CreateCalendarDialogProps) => { const { t } = useLocale(); const { effectiveTheme } = useTheme(); const [name, setName] = useState(''); const [isLoading, setIsLoading] = useState(false); const hasCustomPicker = app.state.overrides.includes( 'createCalendarDialogColorPicker' ); const [customSelectedColor, setCustomSelectedColor] = useState( PICKER_DEFAULT_COLORS[ Math.floor(Math.random() * PICKER_DEFAULT_COLORS.length) ] ); const [showPicker, setShowPicker] = useState(false); const [previousColor, setPreviousColor] = useState(''); const initialColorData = useMemo(() => { const randomColor = DEFAULT_COLORS[Math.floor(Math.random() * DEFAULT_COLORS.length)]; const layer = (randomColor as { layer?: string }).layer || 'outer'; const sliderValue = lightnessToSliderValue(randomColor.l); return { hue: randomColor.h, saturation: sliderValue, lightness: randomColor.l, alpha: 100, layer: layer as 'inner' | 'outer', }; }, []); const [blossomSelectedColor, setBlossomSelectedColor] = useState<{ hex: string; hue: number; saturation: number; lightness?: number; alpha: number; layer: 'inner' | 'outer'; } | null>(null); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); if (!name.trim() || isLoading) return; setIsLoading(true); try { let hex: string; if (hasCustomPicker) { hex = customSelectedColor; } else { hex = blossomSelectedColor?.hex ?? hslToHex( initialColorData.hue, initialColorData.saturation, initialColorData.lightness ); } const { colors, darkColors } = getCalendarColorsForHex(hex); const newCalendar: CalendarType = { id: generateUniKey(), name: name.trim(), colors, darkColors, isVisible: true, isDefault: false, }; await onCreate(newCalendar); onClose(); } finally { setIsLoading(false); } }; const handleColorChange = (color: { hex: string }) => { setCustomSelectedColor(color.hex); }; const handleOpenPicker = () => { setPreviousColor(customSelectedColor); setShowPicker(true); }; const handleAccept = () => { setShowPicker(false); }; const handleCancel = () => { setCustomSelectedColor(previousColor); setShowPicker(false); }; const isDark = effectiveTheme === 'dark'; const pickerStyles = { default: { picker: { background: isDark ? '#1e293b' : '#ffffff', boxShadow: '0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)', borderRadius: '0.5rem', border: isDark ? '1px solid #4b5563' : '1px solid #e5e7eb', }, head: { background: isDark ? '#1e293b' : '#ffffff', borderBottom: isDark ? '1px solid #4b5563' : '1px solid #e5e7eb', boxShadow: 'none', }, body: { background: isDark ? '#1e293b' : '#ffffff' }, controls: { border: isDark ? '1px solid #4b5563' : '1px solid #e5e7eb', }, input: { background: isDark ? '#374151' : '#ffffff', color: isDark ? '#f3f4f6' : '#1f2937', border: isDark ? '1px solid #4b5563' : '1px solid #e5e7eb', boxShadow: 'none', }, previews: { border: isDark ? '1px solid #4b5563' : '1px solid #e5e7eb', }, actions: { borderTop: isDark ? '1px solid #4b5563' : '1px solid #e5e7eb', }, }, }; if (typeof window === 'undefined') return null; return createPortal(
e.stopPropagation()} >

{t('createCalendar')}

{hasCustomPicker ? ( <>
setName((e.target as HTMLInputElement).value)} className='df-form-input' style={{ flex: 1 }} placeholder={t('calendarNamePlaceholder')} autoFocus />
{PICKER_DEFAULT_COLORS.map(color => (
{showPicker && (
e.stopPropagation()} >
} />
)}
) : (
setName((e.target as HTMLInputElement).value)} className='df-form-input' placeholder={t('calendarNamePlaceholder')} autoFocus />
setBlossomSelectedColor(color)} onCollapse={color => setBlossomSelectedColor(color)} className='df-create-calendar-dialog-blossom-picker' />
)}
{t('create')}
, document.body ); }; ================================================ FILE: packages/core/src/components/common/DefaultColorPicker.tsx ================================================ import { hexToHsl, lightnessToSliderValue, } from '@dayflow/blossom-color-picker'; import { useMemo } from 'preact/hooks'; import { BlossomColorPicker } from './BlossomColorPicker'; interface DefaultColorPickerProps { color: string; onChange: (color: { hex: string }, isPending?: boolean) => void; onClose?: () => void; [key: string]: unknown; } export const DefaultColorPicker = ({ color, onChange, onClose, }: DefaultColorPickerProps) => { const blossomValue = useMemo(() => { const { h, l } = hexToHsl(color); const sliderValue = lightnessToSliderValue(l); return { hue: h, saturation: sliderValue, lightness: l, alpha: 100, layer: 'outer' as const, }; }, [color]); return (
onChange({ hex: c.hex }, true)} onCollapse={onClose} />
); }; ================================================ FILE: packages/core/src/components/common/DefaultEventDetailDialog.tsx ================================================ import { RangePicker } from '@dayflow/ui-range-picker'; import { createPortal } from 'preact/compat'; import { useMemo, useState, useEffect, useRef } from 'preact/hooks'; import { Temporal } from 'temporal-polyfill'; import { getDefaultCalendarRegistry } from '@/core/calendarRegistry'; import { useLocale } from '@/locale'; import { dialogContainer } from '@/styles/classNames'; import { ICalendarApp } from '@/types'; import { EventDetailDialogProps } from '@/types/eventDetail'; import { isEventDeepEqual } from '@/utils/eventUtils'; import { isPlainDate } from '@/utils/temporal'; import { restoreVisualEventToCanonical } from '@/utils/timeUtils'; import { CalendarPicker, CalendarOption } from './CalendarPicker'; import { LoadingButton } from './LoadingButton'; interface DefaultEventDetailDialogProps extends EventDetailDialogProps { app?: ICalendarApp; } /** * Default event detail dialog component (Dialog mode) */ const DefaultEventDetailDialog = ({ event, isOpen, onEventUpdate, onEventDelete, onClose, app, }: DefaultEventDetailDialogProps) => { const [editedEvent, setEditedEvent] = useState(event); const [isSaving, setIsSaving] = useState(false); const [isDeleting, setIsDeleting] = useState(false); const previousEventIdRef = useRef(event.id); const { t } = useLocale(); useEffect(() => { setEditedEvent(event); if (previousEventIdRef.current !== event.id) { setIsSaving(false); setIsDeleting(false); previousEventIdRef.current = event.id; } }, [event]); const colorOptions: CalendarOption[] = useMemo(() => { const registry = app ? app.getCalendarRegistry() : getDefaultCalendarRegistry(); return registry.getVisible().map(cal => ({ label: cal.name, value: cal.id, })); }, [app, app?.getCalendars()]); const handleSave = async () => { if (isSaving || isDeleting) return; setIsSaving(true); try { await onEventUpdate( restoreVisualEventToCanonical(event, editedEvent, app?.timeZone) ); onClose(); } finally { setIsSaving(false); } }; const handleDelete = async () => { if (isSaving || isDeleting) return; setIsDeleting(true); try { await onEventDelete(event.id); onClose(); } finally { setIsDeleting(false); } }; const hasChanges = useMemo( () => !isEventDeepEqual(event, editedEvent), [event, editedEvent] ); const convertToAllDay = () => { const plainDate = isPlainDate(editedEvent.start) ? editedEvent.start : editedEvent.start.toPlainDate(); setEditedEvent({ ...editedEvent, allDay: true, start: plainDate, end: plainDate, }); }; const convertToRegular = () => { const plainDate = isPlainDate(editedEvent.start) ? editedEvent.start : editedEvent.start.toPlainDate(); const tz = app?.timeZone ?? Temporal.Now.timeZoneId(); const start = Temporal.ZonedDateTime.from({ year: plainDate.year, month: plainDate.month, day: plainDate.day, hour: 9, minute: 0, timeZone: tz, }); const end = Temporal.ZonedDateTime.from({ year: plainDate.year, month: plainDate.month, day: plainDate.day, hour: 10, minute: 0, timeZone: tz, }); setEditedEvent({ ...editedEvent, allDay: false, start, end }); }; const eventTimeZone = useMemo( () => app?.timeZone ?? Temporal.Now.timeZoneId(), [app] ); const startOfWeek = useMemo( () => (app?.getViewConfig('week')?.startOfWeek as number) ?? 1, [app] ); const handleAllDayRangeChange = ( nextRange: [Temporal.ZonedDateTime, Temporal.ZonedDateTime] ) => { const [start, end] = nextRange; setEditedEvent({ ...editedEvent, start: start.toPlainDate(), end: end.toPlainDate(), }); }; const isEditable = app?.canMutateFromUI(event.id) ?? false; const readOnlyConfig = app?.getReadOnlyConfig(event.id) as { draggable: boolean; viewable: boolean; }; const isViewable = readOnlyConfig?.viewable !== false; const isPending = isSaving || isDeleting; const isSubscribed = useMemo(() => { if (!event.calendarId) return false; const calendar = app?.getCalendarRegistry().get(event.calendarId); return !!calendar?.subscription; }, [app, event.calendarId]); const shouldShowNotes = !isSubscribed || (editedEvent.description || '').trim() !== ''; if (!isOpen || !isViewable) return null; if (typeof window === 'undefined' || typeof document === 'undefined') { return null; } const handleBackdropClick = (e: MouseEvent) => { const target = e.target as HTMLElement; if (target.closest('[data-range-picker-popup]')) return; if (target === e.currentTarget) onClose(); }; const dialogContent = (
{/* Backdrop */}
{/* Dialog */}
{t('eventTitle')}
) => { setEditedEvent({ ...editedEvent, title: (e.target as HTMLInputElement).value, }); }} className='df-form-input' />
{isEditable && ( setEditedEvent({ ...editedEvent, calendarId: value }) } registry={app?.getCalendarRegistry()} /> )}
{editedEvent.allDay ? (
{t('dateRange')}
) : (
{t('timeRange')}
{ const [start, end] = nextRange; setEditedEvent({ ...editedEvent, start, end }); }} onOk={( nextRange: [Temporal.ZonedDateTime, Temporal.ZonedDateTime] ) => { const [start, end] = nextRange; setEditedEvent({ ...editedEvent, start, end }); }} locale={app?.state.locale} />
)} {shouldShowNotes && (
{t('note')}