calendar function

BloomNode calendar({
  1. required DateTime month,
  2. DateTime? selected,
  3. required void onSelect(
    1. DateTime date
    ),
  4. required void onMonthChange(
    1. DateTime date
    ),
  5. String extraClassName = '',
})

Controlled month-grid date picker component.

Caller owns both the displayed month and the selected date.

Implementation

BloomNode calendar({
  required DateTime month,
  DateTime? selected,
  required void Function(DateTime date) onSelect,
  required void Function(DateTime month) onMonthChange,
  String extraClassName = '',
}) {
  final currentYear = month.year;
  final currentMonth = month.month;
  final monthTitle = '${_monthNames[currentMonth - 1]} $currentYear';

  final firstDay = DateTime(currentYear, currentMonth, 1);
  final daysInMonth = DateTime(currentYear, currentMonth + 1, 0).day;
  final startWeekday = firstDay.weekday % 7; // 0=Sun, 1=Mon, ..., 6=Sat

  final prevMonthDays = DateTime(currentYear, currentMonth, 0).day;
  final totalCells = ((startWeekday + daysInMonth + 6) ~/ 7) * 7;

  final now = DateTime.now();

  return Div(
    attrs: const {'data-slot': 'calendar'},
    className: cn([
      'p-3 w-fit bg-[var(--card)] border border-[var(--border)] rounded-[var(--radius-lg)] shadow-[var(--shadow-card)] flex flex-col gap-3 select-none',
      extraClassName,
    ]),
    children: [
      // Navigation Header
      Div(
        className: 'flex items-center justify-between px-1',
        children: [
          El(
            'button',
            attrs: const {
              'type': 'button',
              'aria-label': 'Previous month',
            },
            className:
                'h-7 w-7 flex items-center justify-center rounded-[var(--radius-sm)] '
                'text-[var(--text-muted)] hover:text-[var(--text)] hover:bg-[var(--bg-muted)] '
                'transition-colors cursor-pointer',
            onClick: (_) => onMonthChange(DateTime(currentYear, currentMonth - 1, 1)),
            children: [uiIcon('chevron-left', className: 'w-4 h-4')],
          ),
          Span(
            className: 'text-xs sm:text-sm font-semibold text-[var(--text)]',
            text: monthTitle,
          ),
          El(
            'button',
            attrs: const {
              'type': 'button',
              'aria-label': 'Next month',
            },
            className:
                'h-7 w-7 flex items-center justify-center rounded-[var(--radius-sm)] '
                'text-[var(--text-muted)] hover:text-[var(--text)] hover:bg-[var(--bg-muted)] '
                'transition-colors cursor-pointer',
            onClick: (_) => onMonthChange(DateTime(currentYear, currentMonth + 1, 1)),
            children: [uiIcon('chevron-right', className: 'w-4 h-4')],
          ),
        ],
      ),
      // Weekday headers
      Div(
        className: 'grid grid-cols-7 gap-1 text-center',
        children: _weekdays
            .map((wd) => Div(
                  className:
                      'h-7 flex items-center justify-center text-[10px] sm:text-xs font-medium text-[var(--text-muted)]',
                  text: wd,
                ))
            .toList(),
      ),
      // Day cells
      Div(
        className: 'grid grid-cols-7 gap-1',
        children: List.generate(totalCells, (index) {
          late final DateTime cellDate;
          late final bool isOutside;

          if (index < startWeekday) {
            // Previous month day
            final day = prevMonthDays - startWeekday + index + 1;
            cellDate = DateTime(currentYear, currentMonth - 1, day);
            isOutside = true;
          } else if (index >= startWeekday + daysInMonth) {
            // Next month day
            final day = index - (startWeekday + daysInMonth) + 1;
            cellDate = DateTime(currentYear, currentMonth + 1, day);
            isOutside = true;
          } else {
            // Current month day
            final day = index - startWeekday + 1;
            cellDate = DateTime(currentYear, currentMonth, day);
            isOutside = false;
          }

          final isSelected = selected != null &&
              selected.year == cellDate.year &&
              selected.month == cellDate.month &&
              selected.day == cellDate.day;

          final isToday = now.year == cellDate.year &&
              now.month == cellDate.month &&
              now.day == cellDate.day;

          return El(
            'button',
            attrs: {
              'type': 'button',
              'data-slot': 'calendar-day',
              'data-selected': isSelected ? 'true' : 'false',
              'data-outside': isOutside ? 'true' : 'false',
              'aria-label': '${cellDate.year}-${cellDate.month}-${cellDate.day}',
            },
            className: cn([
              'h-7 w-7 sm:h-8 sm:w-8 flex items-center justify-center rounded-[var(--radius-sm)] text-xs font-normal transition-colors cursor-pointer',
              isSelected
                  ? 'bg-[var(--primary)] text-[var(--primary-foreground)] font-medium hover:bg-[var(--primary-hover)] shadow-sm'
                  : isOutside
                      ? 'text-[var(--text-faint)] opacity-40 hover:bg-[var(--bg-muted)] hover:text-[var(--text-muted)]'
                      : isToday
                          ? 'bg-[var(--bg-muted)] text-[var(--text)] font-semibold border border-[var(--border)]'
                          : 'text-[var(--text)] hover:bg-[var(--bg-muted)]',
            ]),
            onClick: (_) => onSelect(cellDate),
            text: '${cellDate.day}',
          );
        }),
      ),
    ],
  );
}