diff --git a/package.json b/package.json index 2322952..356b5f5 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,7 @@ "@nativescript/plugin-tools": "5.5.3", "@nativescript/tailwind": "^2.1.0", "@nativescript/types": "~9.0.0", - "@nativescript/webpack": "5.0.31", + "@nativescript/webpack": "5.0.33", "@nativescript-community/ui-material-bottomsheet": "7.2.67", "@ngtools/webpack": "^19.0.0", "husky": "~9.0.0", diff --git a/packages/nativescript-calendar/CHANGELOG.md b/packages/nativescript-calendar/CHANGELOG.md index 103aeb8..aacd82f 100644 --- a/packages/nativescript-calendar/CHANGELOG.md +++ b/packages/nativescript-calendar/CHANGELOG.md @@ -1,3 +1,40 @@ +## 1.0.5 (2026-04-03) + +### 🩹 Fixes + +- **calendar:** emit dayRender with the actual reusable cell view ([befd9c3](https://github.com/nstudio/nativescript-ui-kit/commit/befd9c3)) + +### ❤️ Thank You + +- Nathan Walker + +## 1.0.4 (2026-04-03) + +### 🩹 Fixes + +- build and packaging with NativeClass + +## 1.0.3 (2026-04-02) + +### 🚀 Features + +- **calendar:** disabledDates and disabledWeekdays ([1f04c04](https://github.com/nstudio/nativescript-ui-kit/commit/1f04c04)) + +### ❤️ Thank You + +- Nathan Walker + +## 1.0.2 (2026-04-02) + +### 🩹 Fixes + +- more robust selection and programmatic date handling ([0acad8c](https://github.com/nstudio/nativescript-ui-kit/commit/0acad8c)) +- **calendar:** header sizing and selecteddate bindings ([fe4aa74](https://github.com/nstudio/nativescript-ui-kit/commit/fe4aa74)) + +### ❤️ Thank You + +- Nathan Walker + # 1.0.0 (2026-03-01) ### 🚀 Features diff --git a/packages/nativescript-calendar/common.ts b/packages/nativescript-calendar/common.ts index a95c65a..17950c6 100644 --- a/packages/nativescript-calendar/common.ts +++ b/packages/nativescript-calendar/common.ts @@ -165,6 +165,8 @@ export abstract class NCalendarCommon extends View { selectedDayBackgroundColor: Color; selectedRangeColor: Color; weekendTextColor: Color; + disabledDates: Date[]; + disabledWeekdays: number[]; disabledDayTextColor: Color; outDateTextColor: Color; monthHeaderTextColor: Color; @@ -204,6 +206,15 @@ export abstract class NCalendarCommon extends View { const t = normalizeDate(date).getTime(); if (this.minDate && t < normalizeDate(this.minDate).getTime()) return true; if (this.maxDate && t > normalizeDate(this.maxDate).getTime()) return true; + if (this.disabledWeekdays && this.disabledWeekdays.length) { + if (this.disabledWeekdays.indexOf(date.getDay()) !== -1) return true; + } + if (this.disabledDates && this.disabledDates.length) { + const key = dateToKey(date); + for (const d of this.disabledDates) { + if (dateToKey(d) === key) return true; + } + } return false; } @@ -361,8 +372,17 @@ export abstract class NCalendarCommon extends View { } selectDateRange(start: Date, end: Date): void { - this._rangeStart = normalizeDate(start); - this._rangeEnd = normalizeDate(end); + const normalizedStart = normalizeDate(start); + const normalizedEnd = normalizeDate(end); + + if (normalizedStart.getTime() <= normalizedEnd.getTime()) { + this._rangeStart = normalizedStart; + this._rangeEnd = normalizedEnd; + } else { + this._rangeStart = normalizedEnd; + this._rangeEnd = normalizedStart; + } + this._selectedKeys.clear(); const cursor = new Date(this._rangeStart.getTime()); while (cursor.getTime() <= this._rangeEnd.getTime()) { @@ -371,6 +391,9 @@ export abstract class NCalendarCommon extends View { } this._syncSelectedDateRange(); this._refreshAfterSelectionChange(); + + // Keep programmatic range selection behavior intuitive by navigating to the range start. + this.scrollToDate(this._rangeStart, false); } clearSelection(): void { @@ -484,12 +507,39 @@ firstDayOfWeekProperty.register(NCalendarCommon); export const selectedDatesProperty = new Property({ name: 'selectedDates', defaultValue: [], + valueChanged: (target, _oldValue, newValue) => { + if (target._internalSelectionChange) return; + target._selectedKeys.clear(); + if (newValue && newValue.length) { + for (const d of newValue) { + target._selectedKeys.add(target._toDateKey(d)); + } + } + }, }); selectedDatesProperty.register(NCalendarCommon); export const selectedDateRangeProperty = new Property({ name: 'selectedDateRange', defaultValue: undefined, + valueChanged: (target, _oldValue, newValue) => { + if (target._internalSelectionChange) return; + if (newValue && newValue.start && newValue.end) { + target._rangeStart = newValue.start; + target._rangeEnd = newValue.end; + target._selectedKeys.clear(); + const cursor = new Date(newValue.start.getTime()); + const endTime = newValue.end.getTime(); + while (cursor.getTime() <= endTime) { + target._selectedKeys.add(target._toDateKey(cursor)); + cursor.setDate(cursor.getDate() + 1); + } + } else { + target._rangeStart = null; + target._rangeEnd = null; + target._selectedKeys.clear(); + } + }, }); selectedDateRangeProperty.register(NCalendarCommon); @@ -499,6 +549,18 @@ export const eventsProperty = new Property({ }); eventsProperty.register(NCalendarCommon); +export const disabledDatesProperty = new Property({ + name: 'disabledDates', + defaultValue: [], +}); +disabledDatesProperty.register(NCalendarCommon); + +export const disabledWeekdaysProperty = new Property({ + name: 'disabledWeekdays', + defaultValue: [], +}); +disabledWeekdaysProperty.register(NCalendarCommon); + export const interMonthSpacingProperty = new Property({ name: 'interMonthSpacing', defaultValue: 0, diff --git a/packages/nativescript-calendar/index.android.ts b/packages/nativescript-calendar/index.android.ts index f2c9b4b..61b64aa 100644 --- a/packages/nativescript-calendar/index.android.ts +++ b/packages/nativescript-calendar/index.android.ts @@ -19,6 +19,8 @@ import { selectedDatesProperty, selectedDateRangeProperty, eventsProperty, + disabledDatesProperty, + disabledWeekdaysProperty, interMonthSpacingProperty, outDateStyleProperty, monthColumnsProperty, @@ -786,7 +788,14 @@ export class NCalendar extends NCalendarCommon { const localDate = jsDateToLocalDate(date); if (this.displayMode === DisplayMode.Month) { - if (animated) { + if (this.orientation === Orientation.Horizontal && this.scrollPaged) { + const ym = java.time.YearMonth.of(date.getFullYear(), date.getMonth() + 1); + if (animated) { + this._calendarView.smoothScrollToMonth(ym); + } else { + this._calendarView.scrollToMonth(ym); + } + } else if (animated) { this._calendarView.smoothScrollToDate(localDate); } else { this._calendarView.scrollToDate(localDate); @@ -870,19 +879,54 @@ export class NCalendar extends NCalendarCommon { [selectedDatesProperty.setNative](value: Date[]) { if (this._internalSelectionChange) return; this._selectedKeys.clear(); + const normalizedDates: Date[] = []; if (value && value.length) { for (const d of value) { - this._selectedKeys.add(this._toDateKey(d)); + const normalized = new Date(d.getFullYear(), d.getMonth(), d.getDate()); + normalizedDates.push(normalized); + this._selectedKeys.add(this._toDateKey(normalized)); + } + } + + if (this.selectionMode === SelectionMode.Range) { + normalizedDates.sort((a, b) => a.getTime() - b.getTime()); + if (normalizedDates.length) { + this._rangeStart = normalizedDates[0]; + this._rangeEnd = normalizedDates[normalizedDates.length - 1]; + this._selectedKeys.clear(); + const cursor = new Date(this._rangeStart.getTime()); + while (cursor.getTime() <= this._rangeEnd.getTime()) { + this._selectedKeys.add(this._toDateKey(cursor)); + cursor.setDate(cursor.getDate() + 1); + } + } else { + this._rangeStart = null; + this._rangeEnd = null; } + this._internalSelectionChange = true; + this._syncSelectedDateRange(); + this._internalSelectionChange = false; } + this._refreshCalendar(); + + if (normalizedDates.length) { + this.scrollToDate(normalizedDates[0], false); + } } [selectedDateRangeProperty.setNative](value: any) { if (this._internalSelectionChange) return; if (value && value.start && value.end) { - this._rangeStart = value.start; - this._rangeEnd = value.end; + const start = new Date(value.start.getFullYear(), value.start.getMonth(), value.start.getDate()); + const end = new Date(value.end.getFullYear(), value.end.getMonth(), value.end.getDate()); + if (start.getTime() <= end.getTime()) { + this._rangeStart = start; + this._rangeEnd = end; + } else { + this._rangeStart = end; + this._rangeEnd = start; + } this._selectedKeys.clear(); const cursor = new Date(this._rangeStart.getTime()); while (cursor.getTime() <= this._rangeEnd.getTime()) { @@ -895,11 +939,21 @@ export class NCalendar extends NCalendarCommon { this._selectedKeys.clear(); } this._refreshCalendar(); + + if (this._rangeStart) { + this.scrollToDate(this._rangeStart, false); + } } [eventsProperty.setNative](_value: any) { this._refreshCalendar(); } + [disabledDatesProperty.setNative](_value: any) { + this._refreshCalendar(); + } + [disabledWeekdaysProperty.setNative](_value: any) { + this._refreshCalendar(); + } [interMonthSpacingProperty.setNative](_value: number) { this._swapCalendarView(); } diff --git a/packages/nativescript-calendar/index.d.ts b/packages/nativescript-calendar/index.d.ts index ca1bdfa..5a8a67d 100644 --- a/packages/nativescript-calendar/index.d.ts +++ b/packages/nativescript-calendar/index.d.ts @@ -137,6 +137,10 @@ export class NCalendar extends View { outDateStyle: OutDateStyle; monthColumns: number; + // Disabled dates + disabledDates: Date[]; + disabledWeekdays: number[]; + // Style dayTextColor: Color; dayFontSize: number; diff --git a/packages/nativescript-calendar/index.ios.ts b/packages/nativescript-calendar/index.ios.ts index f1ca622..ffcbe4d 100644 --- a/packages/nativescript-calendar/index.ios.ts +++ b/packages/nativescript-calendar/index.ios.ts @@ -8,6 +8,7 @@ import { ScrollPosition, CalendarMonth, CalendarDayEventData, + CalendarDayRenderEventData, displayModeProperty, selectionModeProperty, orientationProperty, @@ -18,6 +19,8 @@ import { selectedDatesProperty, selectedDateRangeProperty, eventsProperty, + disabledDatesProperty, + disabledWeekdaysProperty, interMonthSpacingProperty, verticalDayMarginProperty, horizontalDayMarginProperty, @@ -113,6 +116,25 @@ export class NCalendar extends NCalendarCommon { this._calView.onMonthChanged = (year: number, month: number) => { this._notifyMonthChanged({ month, year }); }; + + this._calView.onDayRender = (year: number, month: number, day: number, view: any, isSelected: boolean, isInRange: boolean, isDisabled: boolean) => { + if (this.hasListeners(NCalendarCommon.dayRenderEvent)) { + const date = new Date(year, month - 1, day); + const calDay = this._buildCalendarDay(date, DayPosition.MonthDate); + this.notify({ + eventName: NCalendarCommon.dayRenderEvent, + object: this, + data: { + day: calDay, + view, + isSelected, + isInRange, + isDisabled, + events: this._getEventsForDate(date), + }, + } as CalendarDayRenderEventData); + } + }; } // Selection Sync @@ -153,6 +175,10 @@ export class NCalendar extends NCalendarCommon { // Selection this._calView.selectionModeStr = this.selectionMode; + // Disabled dates + this._syncDisabledDates(); + this._syncDisabledWeekdays(); + // Style this._applyStyleProperties(); @@ -160,6 +186,25 @@ export class NCalendar extends NCalendarCommon { this._syncSelectionToBridge(); } + private _syncDisabledDates() { + if (!this._calView) return; + if (this.disabledDates && this.disabledDates.length) { + const keys = this.disabledDates.map((d) => this._toDateKey(d)); + this._calView.setDisabledDayKeys(keys); + } else { + this._calView.setDisabledDayKeys([]); + } + } + + private _syncDisabledWeekdays() { + if (!this._calView) return; + if (this.disabledWeekdays && this.disabledWeekdays.length) { + this._calView.setDisabledWeekdays(this.disabledWeekdays); + } else { + this._calView.setDisabledWeekdays([]); + } + } + private _applyStyleProperties() { if (!this._calView) return; @@ -248,25 +293,64 @@ export class NCalendar extends NCalendarCommon { [firstDayOfWeekProperty.setNative](value: number) { if (this._calView) { this._calView.firstDayOfWeekJS = value; + // After recreating the CalendarView, restore scroll position + if (this._currentMonth) { + this.scrollToMonth(this._currentMonth.year, this._currentMonth.month, false); + } } } [selectedDatesProperty.setNative](value: Date[]) { if (this._internalSelectionChange) return; this._selectedKeys.clear(); + const normalizedDates: Date[] = []; if (value && value.length) { for (const d of value) { - this._selectedKeys.add(this._toDateKey(d)); + const normalized = new Date(d.getFullYear(), d.getMonth(), d.getDate()); + normalizedDates.push(normalized); + this._selectedKeys.add(this._toDateKey(normalized)); + } + } + + if (this.selectionMode === SelectionMode.Range) { + normalizedDates.sort((a, b) => a.getTime() - b.getTime()); + if (normalizedDates.length) { + this._rangeStart = normalizedDates[0]; + this._rangeEnd = normalizedDates[normalizedDates.length - 1]; + this._selectedKeys.clear(); + const cursor = new Date(this._rangeStart.getTime()); + while (cursor.getTime() <= this._rangeEnd.getTime()) { + this._selectedKeys.add(this._toDateKey(cursor)); + cursor.setDate(cursor.getDate() + 1); + } + } else { + this._rangeStart = null; + this._rangeEnd = null; } + this._internalSelectionChange = true; + this._syncSelectedDateRange(); + this._internalSelectionChange = false; } + this._syncSelectionToBridge(); + + if (normalizedDates.length) { + this.scrollToDate(normalizedDates[0], false); + } } [selectedDateRangeProperty.setNative](value: any) { if (this._internalSelectionChange) return; if (value && value.start && value.end) { - this._rangeStart = value.start; - this._rangeEnd = value.end; + const start = new Date(value.start.getFullYear(), value.start.getMonth(), value.start.getDate()); + const end = new Date(value.end.getFullYear(), value.end.getMonth(), value.end.getDate()); + if (start.getTime() <= end.getTime()) { + this._rangeStart = start; + this._rangeEnd = end; + } else { + this._rangeStart = end; + this._rangeEnd = start; + } this._selectedKeys.clear(); const cursor = new Date(this._rangeStart.getTime()); while (cursor.getTime() <= this._rangeEnd.getTime()) { @@ -279,6 +363,10 @@ export class NCalendar extends NCalendarCommon { this._selectedKeys.clear(); } this._syncSelectionToBridge(); + + if (this._rangeStart) { + this.scrollToDate(this._rangeStart, false); + } } [eventsProperty.setNative](value: any) { @@ -293,6 +381,14 @@ export class NCalendar extends NCalendarCommon { } } + [disabledDatesProperty.setNative](_value: Date[]) { + this._syncDisabledDates(); + } + + [disabledWeekdaysProperty.setNative](_value: any) { + this._syncDisabledWeekdays(); + } + [interMonthSpacingProperty.setNative](value: number) { if (this._calView) this._calView.interMonthSpacingPt = value; } diff --git a/packages/nativescript-calendar/package.json b/packages/nativescript-calendar/package.json index d999e77..26c2744 100644 --- a/packages/nativescript-calendar/package.json +++ b/packages/nativescript-calendar/package.json @@ -1,6 +1,6 @@ { "name": "@nstudio/nativescript-calendar", - "version": "1.0.0", + "version": "1.0.5", "description": "A full-featured calendar view with month, week, and year views. Uses Airbnb HorizonCalendar (iOS) and kizitonwose Calendar (Android).", "main": "index", "types": "index.d.ts", diff --git a/packages/nativescript-calendar/platforms/ios/src/NCalendarView.swift b/packages/nativescript-calendar/platforms/ios/src/NCalendarView.swift index 1286d10..ec055fa 100644 --- a/packages/nativescript-calendar/platforms/ios/src/NCalendarView.swift +++ b/packages/nativescript-calendar/platforms/ios/src/NCalendarView.swift @@ -68,9 +68,14 @@ public class NCalendarView: UIView { horizontalMonthLabel?.frame = CGRect(x: 0, y: 0, width: bounds.width, height: labelHeight) let dayWidth = (bounds.width - 6 * horizontalDayMarginPt) / 7 - let dowRowHeight: CGFloat = ceil(dayOfWeekFontSizePt * 1.5) + 8 - let maxMonthHeight = dowRowHeight + 6 * dayWidth + 5 * verticalDayMarginPt + let dowRowHeight: CGFloat = ceil(dayOfWeekFontSizePt * 1.8) + 10 + let maxMonthHeight = dowRowHeight + 6 * dayWidth + 6 * verticalDayMarginPt calendarView.frame = CGRect(x: 0, y: labelHeight, width: bounds.width, height: maxMonthHeight) + + // Scroll callbacks are not guaranteed on first render, so seed the title now. + if !didInitializeHorizontalMonthLabel { + syncHorizontalMonthLabelToInitialVisibleMonth(notify: true) + } } else { horizontalMonthLabel?.isHidden = true calendarView.frame = bounds @@ -84,6 +89,8 @@ public class NCalendarView: UIView { private var rangeStartKey: String? private var rangeEndKey: String? private var eventsByKey = [String: [[String: Any]]]() + private var disabledDayKeys = Set() + private var disabledWeekdaySet = Set() private var _calendar = Calendar.current private lazy var _dateFormatter: DateFormatter = { let f = DateFormatter() @@ -101,13 +108,17 @@ public class NCalendarView: UIView { // Horizontal mode month label private var horizontalMonthLabel: UILabel? + private var didInitializeHorizontalMonthLabel = false // MARK: - Configuration Properties @objc public var isHorizontal: Bool = false { didSet { guard oldValue != isHorizontal else { return } - if isHorizontal { ensureHorizontalMonthLabel() } + if isHorizontal { + ensureHorizontalMonthLabel() + didInitializeHorizontalMonthLabel = false + } recreateCalendarView() setNeedsLayout() } @@ -124,7 +135,18 @@ public class NCalendarView: UIView { @objc public var minDateMs: Double = 0 { didSet { rebuildContent() } } @objc public var maxDateMs: Double = 0 { didSet { rebuildContent() } } - @objc public var firstDayOfWeekJS: Int = 0 { didSet { updateCalendar(); rebuildContent() } } + @objc public var firstDayOfWeekJS: Int = 0 { + didSet { + guard oldValue != firstDayOfWeekJS else { return } + updateCalendar() + if displayModeStr == "week" { + rebuildContent() + } else { + recreateCalendarView() + setNeedsLayout() + } + } + } @objc public var interMonthSpacingPt: CGFloat = 0 { didSet { rebuildContent() } } @objc public var verticalDayMarginPt: CGFloat = 0 { didSet { rebuildContent() } } @@ -209,20 +231,43 @@ public class NCalendarView: UIView { guard let self = self else { return } let startDay = visibleDayRange.lowerBound let endDay = visibleDayRange.upperBound - self.updateHorizontalMonthLabel(year: startDay.month.year, month: startDay.month.month) - self.onScroll?(startDay.month.year, startDay.month.month, endDay.month.year, endDay.month.month, isDragging) + let visibleMonth = self.resolvePrimaryVisibleMonth(startDay: startDay, endDay: endDay) + self.updateHorizontalMonthLabel(year: visibleMonth.year, month: visibleMonth.month) + self.onScroll?(visibleMonth.year, visibleMonth.month, endDay.month.year, endDay.month.month, isDragging) } calendarView.didEndDecelerating = { [weak self] visibleDayRange in guard let self = self else { return } let startDay = visibleDayRange.lowerBound let endDay = visibleDayRange.upperBound - self.updateHorizontalMonthLabel(year: startDay.month.year, month: startDay.month.month) - self.onScrollEnd?(startDay.month.year, startDay.month.month, endDay.month.year, endDay.month.month) - self.onMonthChanged?(startDay.month.year, startDay.month.month) + let visibleMonth = self.resolvePrimaryVisibleMonth(startDay: startDay, endDay: endDay) + self.updateHorizontalMonthLabel(year: visibleMonth.year, month: visibleMonth.month) + self.onScrollEnd?(visibleMonth.year, visibleMonth.month, endDay.month.year, endDay.month.month) + self.onMonthChanged?(visibleMonth.year, visibleMonth.month) } } + private func resolvePrimaryVisibleMonth(startDay: DayComponents, endDay: DayComponents) -> (year: Int, month: Int) { + let fallback = (year: startDay.month.year, month: startDay.month.month) + + guard + let startDate = _calendar.date(from: DateComponents(year: startDay.month.year, month: startDay.month.month, day: startDay.day)), + let endDate = _calendar.date(from: DateComponents(year: endDay.month.year, month: endDay.month.month, day: endDay.day)) + else { + return fallback + } + + let lower = min(startDate.timeIntervalSinceReferenceDate, endDate.timeIntervalSinceReferenceDate) + let upper = max(startDate.timeIntervalSinceReferenceDate, endDate.timeIntervalSinceReferenceDate) + let midpoint = Date(timeIntervalSinceReferenceDate: lower + ((upper - lower) / 2.0)) + let comps = _calendar.dateComponents([.year, .month], from: midpoint) + + guard let year = comps.year, let month = comps.month else { + return fallback + } + return (year, month) + } + private func updateCalendar() { _calendar = Calendar.current _calendar.firstWeekday = firstDayOfWeekJS + 1 @@ -408,51 +453,32 @@ public class NCalendarView: UIView { let dayText = "\(dayComponents.day)" let accLabel = _dateFormatter.string(from: date) - // Use DayWithEventsView when this day has events, otherwise standard DayView - if !eventColorHexes.isEmpty { - let properties = DayWithEventsView.InvariantViewProperties( - textColorHex: textColorHex, - fontSize: dayFontSizePt, - bgFillColorHex: bgFillHex, - bgBorderColorHex: bgBorderHex, - bgBorderWidth: bgBorderWidth, - isCircle: isCircle - ) - - return DayWithEventsView.calendarItemModel( - invariantViewProperties: properties, - content: .init(dayText: dayText, accessibilityLabel: accLabel, eventColorHexes: eventColorHexes) - ) - } else { - // Standard DayView — no events - let textColor: UIColor = colorFromHex(textColorHex) ?? .label - - let bgDrawingConfig: DrawingConfig - if (isRangeStart || isRangeEnd) && !isSingleDayRange { - bgDrawingConfig = DrawingConfig(fillColor: colorFromHex(bgFillHex) ?? .systemBlue, borderColor: .clear) - } else if isMiddleOfRange && !isSingleDayRange { - bgDrawingConfig = .transparent - } else if isSelected { - bgDrawingConfig = DrawingConfig(fillColor: colorFromHex(bgFillHex) ?? .systemBlue, borderColor: .clear) - } else if isToday && !todayBgColorHex.isEmpty { - bgDrawingConfig = DrawingConfig(fillColor: colorFromHex(todayBgColorHex) ?? .clear, borderColor: .clear) - } else if isToday { - bgDrawingConfig = DrawingConfig(fillColor: .clear, borderColor: colorFromHex(bgBorderHex) ?? .systemBlue, borderWidth: 1) - } else { - bgDrawingConfig = .transparent - } - - var properties = DayView.InvariantViewProperties.baseInteractive - properties.textColor = textColor - properties.font = .systemFont(ofSize: dayFontSizePt) - properties.backgroundShapeDrawingConfig = bgDrawingConfig - properties.shape = isCircle ? .circle : .rectangle(cornerRadius: 0) + let properties = DayWithEventsView.InvariantViewProperties( + textColorHex: textColorHex, + fontSize: dayFontSizePt, + bgFillColorHex: bgFillHex, + bgBorderColorHex: bgBorderHex, + bgBorderWidth: bgBorderWidth, + isCircle: isCircle + ) - return DayView.calendarItemModel( - invariantViewProperties: properties, - content: .init(dayText: dayText, accessibilityLabel: accLabel, accessibilityHint: nil) + // Use the custom day view for every month/year cell so dayRender receives the actual cell view. + return DayWithEventsView.calendarItemModel( + invariantViewProperties: properties, + content: .init( + dayText: dayText, + accessibilityLabel: accLabel, + eventColorHexes: eventColorHexes, + renderContext: .init( + year: dayComponents.month.year, + month: dayComponents.month.month, + day: dayComponents.day, + isSelected: isSelected, + isInRange: isInRange, + isDisabled: isDisabled + ) ) - } + ) } // MARK: - Month Header Item @@ -539,6 +565,16 @@ public class NCalendarView: UIView { rebuildContent() } + @objc public func setDisabledDayKeys(_ keys: [String]) { + disabledDayKeys = Set(keys) + rebuildContent() + } + + @objc public func setDisabledWeekdays(_ weekdays: [Int]) { + disabledWeekdaySet = Set(weekdays) + rebuildContent() + } + // MARK: - Programmatic Scrolling @objc public func scrollToMonthContaining(year: Int, month: Int, day: Int, animated: Bool) { @@ -548,6 +584,10 @@ public class NCalendarView: UIView { showWeekContaining(date) } else { calendarView.scroll(toMonthContaining: date, scrollPosition: .firstFullyVisiblePosition, animated: animated) + if isHorizontal { + updateHorizontalMonthLabel(year: year, month: month) + onMonthChanged?(year, month) + } } } @@ -556,8 +596,18 @@ public class NCalendarView: UIView { guard let date = _calendar.date(from: components) else { return } if displayModeStr == "week" { showWeekContaining(date) + } else if isHorizontal && isPaginated { + // In horizontal paged mode, paging is month-based. Scrolling to day can be a no-op + // when the target day's month is not promoted to the leading page. + calendarView.scroll(toMonthContaining: date, scrollPosition: .firstFullyVisiblePosition, animated: animated) + updateHorizontalMonthLabel(year: year, month: month) + onMonthChanged?(year, month) } else { calendarView.scroll(toDayContaining: date, scrollPosition: .centered, animated: animated) + if isHorizontal { + updateHorizontalMonthLabel(year: year, month: month) + onMonthChanged?(year, month) + } } } @@ -606,6 +656,36 @@ public class NCalendarView: UIView { label.text = formatter.string(from: date) label.font = .boldSystemFont(ofSize: monthHeaderFontSizePt) label.textColor = colorFromHex(monthHeaderTextColorHex) ?? .label + didInitializeHorizontalMonthLabel = true + } + + private func syncHorizontalMonthLabelToInitialVisibleMonth(notify: Bool) { + guard isHorizontal else { return } + + let now = Date() + var anchorDate = now + + if minDateMs > 0 { + let minDate = Date(timeIntervalSince1970: minDateMs / 1000) + if anchorDate < minDate { + anchorDate = minDate + } + } + + if maxDateMs > 0 { + let maxDate = Date(timeIntervalSince1970: maxDateMs / 1000) + if anchorDate > maxDate { + anchorDate = maxDate + } + } + + let comps = _calendar.dateComponents([.year, .month], from: anchorDate) + guard let year = comps.year, let month = comps.month else { return } + + updateHorizontalMonthLabel(year: year, month: month) + if notify { + onMonthChanged?(year, month) + } } private func isDateDisabled(_ date: Date) -> Bool { @@ -618,6 +698,15 @@ public class NCalendarView: UIView { let dayAfterMax = _calendar.date(byAdding: .day, value: 1, to: _calendar.startOfDay(for: maxDate))! if date >= dayAfterMax { return true } } + if !disabledWeekdaySet.isEmpty { + let weekday = _calendar.component(.weekday, from: date) + // Convert Calendar weekday (1=Sun..7=Sat) to JS weekday (0=Sun..6=Sat) + if disabledWeekdaySet.contains(weekday - 1) { return true } + } + if !disabledDayKeys.isEmpty { + let key = dateKey(date) + if disabledDayKeys.contains(key) { return true } + } return false } @@ -736,6 +825,10 @@ public class NCalendarView: UIView { let isDisabled = isDateDisabled(date) let isWeekend = _calendar.isDateInWeekend(date) + // Fire dayRender callback + let comps = _calendar.dateComponents([.year, .month, .day], from: date) + onDayRender?(comps.year!, comps.month!, comps.day!, cell, isSelected, isInRange, isDisabled) + // Range position let isRangeStart = key == rangeStartKey && rangeEndKey != nil let isRangeEnd = key == rangeEndKey && rangeStartKey != nil @@ -931,6 +1024,15 @@ public final class DayRangeHighlightView: UIView, CalendarItemViewRepresentable /// Used in place of DayView when a day has associated events. public final class DayWithEventsView: UIView, CalendarItemViewRepresentable { + public struct DayRenderContext: Equatable { + let year: Int + let month: Int + let day: Int + let isSelected: Bool + let isInRange: Bool + let isDisabled: Bool + } + public struct InvariantViewProperties: Hashable { var textColorHex: String var fontSize: CGFloat @@ -944,12 +1046,15 @@ public final class DayWithEventsView: UIView, CalendarItemViewRepresentable { let dayText: String let accessibilityLabel: String? let eventColorHexes: [String] + let renderContext: DayRenderContext } private let dayLabel = UILabel() private let bgShapeLayer = CAShapeLayer() private var dotLayers: [CAShapeLayer] = [] private var isCircle: Bool = true + private var renderContext: DayRenderContext? + private var hasEmittedRenderCallback = false fileprivate init(invariantViewProperties props: InvariantViewProperties) { super.init(frame: .zero) @@ -972,6 +1077,16 @@ public final class DayWithEventsView: UIView, CalendarItemViewRepresentable { required init?(coder: NSCoder) { fatalError() } + public override func didMoveToSuperview() { + super.didMoveToSuperview() + emitDayRenderIfNeeded() + } + + public override func didMoveToWindow() { + super.didMoveToWindow() + emitDayRenderIfNeeded() + } + public override func layoutSubviews() { super.layoutSubviews() @@ -993,6 +1108,7 @@ public final class DayWithEventsView: UIView, CalendarItemViewRepresentable { dayLabel.frame = CGRect(x: 0, y: labelOffset, width: w, height: h) layoutDots() + emitDayRenderIfNeeded() } private func layoutDots() { @@ -1013,8 +1129,12 @@ public final class DayWithEventsView: UIView, CalendarItemViewRepresentable { } fileprivate func setContent(_ content: Content) { + backgroundColor = .clear + alpha = 1.0 dayLabel.text = content.dayText accessibilityLabel = content.accessibilityLabel + renderContext = content.renderContext + hasEmittedRenderCallback = false // Remove old dots for dot in dotLayers { dot.removeFromSuperlayer() } @@ -1029,6 +1149,34 @@ public final class DayWithEventsView: UIView, CalendarItemViewRepresentable { } setNeedsLayout() + emitDayRenderIfNeeded() + } + + private func emitDayRenderIfNeeded() { + guard !hasEmittedRenderCallback, + let renderContext, + let calendarView = enclosingCalendarView() else { return } + hasEmittedRenderCallback = true + calendarView.onDayRender?( + renderContext.year, + renderContext.month, + renderContext.day, + self, + renderContext.isSelected, + renderContext.isInRange, + renderContext.isDisabled + ) + } + + private func enclosingCalendarView() -> NCalendarView? { + var ancestor = superview + while let current = ancestor { + if let calendarView = current as? NCalendarView { + return calendarView + } + ancestor = current.superview + } + return nil } public static func makeView( diff --git a/packages/nativescript-calendar/typings/swift!NCalendarView.d.ts b/packages/nativescript-calendar/typings/swift!NCalendarView.d.ts index a2f76ee..5a509ad 100644 --- a/packages/nativescript-calendar/typings/swift!NCalendarView.d.ts +++ b/packages/nativescript-calendar/typings/swift!NCalendarView.d.ts @@ -164,6 +164,10 @@ declare class NCalendarView extends UIView { rebuildContent(): void; + setDisabledDayKeys(keys: NSArray | string[]): void; + + setDisabledWeekdays(weekdays: NSArray | number[]): void; + scrollToDayContainingWithYearMonthDayAnimated(year: number, month: number, day: number, animated: boolean): void; scrollToMonthContainingWithYearMonthDayAnimated(year: number, month: number, day: number, animated: boolean): void; diff --git a/tools/demo/nativescript-calendar/index.ts b/tools/demo/nativescript-calendar/index.ts index d269ab8..e88a429 100644 --- a/tools/demo/nativescript-calendar/index.ts +++ b/tools/demo/nativescript-calendar/index.ts @@ -2,7 +2,7 @@ import { ShowModalOptions } from '@nativescript/core'; import { DemoSharedBase } from '../utils'; import { NCalendar, DisplayMode, SelectionMode, Orientation, CalendarDayEventData, CalendarMonthEventData, CalendarEvent } from '@nstudio/nativescript-calendar'; -const SCENARIOS = ['Single', 'Range', 'Multiple', 'Horizontal', 'Week', 'Events', 'Styled']; +const SCENARIOS = ['Single', 'Range', 'Multiple', 'Horizontal', 'Week', 'Events', 'Disabled', 'Styled']; function pad(n: number): string { return n < 10 ? '0' + n : '' + n; @@ -84,6 +84,8 @@ export class DemoSharedNativescriptCalendar extends DemoSharedBase { this.calendar.dayOfWeekTextColor = null; this.calendar.outDateTextColor = null; this.calendar.disabledDayTextColor = null; + this.calendar.disabledDates = []; + this.calendar.disabledWeekdays = []; switch (name) { case 'Single': @@ -104,6 +106,9 @@ export class DemoSharedNativescriptCalendar extends DemoSharedBase { case 'Events': this._setupEvents(); break; + case 'Disabled': + this._setupDisabled(); + break; case 'Styled': this._setupStyled(); break; @@ -182,6 +187,23 @@ export class DemoSharedNativescriptCalendar extends DemoSharedBase { this.set('statusText', 'Tap a day with events to see details'); } + private _setupDisabled() { + this.calendar.selectionMode = SelectionMode.Single; + const today = new Date(); + const y = today.getFullYear(); + const m = today.getMonth(); + + // Disable specific dates (e.g. holidays) + this.calendar.disabledDates = [new Date(y, m, 5), new Date(y, m, 10), new Date(y, m, 15), new Date(y, m, 25)]; + + // Disable weekends (0 = Sunday, 6 = Saturday) + this.calendar.disabledWeekdays = [0, 6]; + + this.set('scenarioLabel', 'Disabled Dates'); + this.set('scenarioDescription', 'Weekends + specific dates disabled'); + this.set('statusText', 'Weekends and select dates are disabled'); + } + private _setupStyled() { this.calendar.selectionMode = SelectionMode.Single; // Dark elegant theme