Search by

sugarcraft / sugar-datepicker

detain

Interactive date picker component for terminal UIs. Supports month/year navigation with min/max selection constraints, single and range selection via a TEA-ready model wrapper, keyboard navigation with a visible cursor, i18n month/day names, and customizable styling. Design antecedent: EthanEFung/bu

dev-master 2026-11-03 22:43 UTC

This package is auto-updated.

Last update: 2026-11-05 01:01:53 UTC


README

sugar-datepicker

CI codecov Packagist Version License PHP

SugarDatepicker

Formerly published as sugarcraft/sugar-calendar (renamed 2026-10). Namespace changed: SugarCraft\Calendar\ → SugarCraft\DatePicker\.

sugar-datepicker — an interactive date picker component for terminal UIs, for PHP 8.3+.

Features

  • Month/year navigation — programmatic GoTo* steps, and PageUp/PageDown month steps driven through the Model; the view window is clamped to the calendar floor (January 0001) and to optional min/max bounds
  • Keyboard handling — handleKey() for arrow keys, Home/End, Enter to select, Esc to clear (works in both single and range modes)
  • Date selection — select/clear a single date, or a start/end range
  • Min/max date constraints — navigation and selection refuse to leave the allowed window
  • TEA integration — a Model wrapper implementing the sugar-core Model contract (init()/update(Msg)/view()), recording picker events into an optional EventStore
  • Today highlight — show the reference "today" distinctly; withoutToday() drops the pinned reference again
  • Selected date and range styling — seven SGR style setters, validated
  • Pure renderer — outputs ANSI strings, no external TUI framework needed

Install

composer require sugarcraft/sugar-datepicker

Quick Start

use SugarCraft\DatePicker\DatePicker;

$picker = DatePicker::new(new \DateTimeImmutable('2026-05-01'));
$picker = $picker->handleKey(DatePicker::KEY_ENTER);  // select the cursor date

echo $picker->View();  // render the month grid
$selected = $picker->SelectedDate();  // 2026-05-01

Or drive it as a TEA model:

use SugarCraft\Core\Msg\KeyMsg;
use SugarCraft\Core\KeyType;
use SugarCraft\DatePicker\Model;

$model = Model::new(new \DateTimeImmutable('2026-05-15'));
[$model, $cmd] = $model->update(new KeyMsg(KeyType::Right));
[$model, $cmd] = $model->update(new KeyMsg(KeyType::Enter));  // selects the date
echo $model->view();

Navigation

// Month navigation
$picker = $picker->GoToPreviousMonth();
$picker = $picker->GoToNextMonth();
$picker = $picker->GoToPreviousYear();
$picker = $picker->GoToNextYear();
$picker = $picker->GoToToday();

// Set arbitrary month
$picker = $picker->SetTime(new \DateTimeImmutable('2025-12-01'));

Steps never throw and never wrap: the view position is clamped at the calendar floor (January 0001 — earlier years cannot be represented as Y-m-d grid dates) and, when set, at the min/max bound months. Stepping at a clamp boundary is a no-op. The cursor index is re-clamped into the new month on every navigation step, so navigating to a shorter month lands on its last day. The mirror edge exists at the top: an unbounded picker stepping past year 9999 enters year 10000, which cannot be written as %04d, so the grid date is unresolvable — View() still renders (reporting year 10000), but dateAtCursor() is null and SelectDate() refuses. This tolerated upper edge never crashes.

Selection

Single mode (default) — Enter (via handleKey or the Model) selects the date under the cursor; Esc clears the selection:

$picker = $picker->handleKey(DatePicker::KEY_ENTER);  // SelectDate() under the hood
$picker = $picker->handleKey(DatePicker::KEY_ESCAPE); // ClearDate() under the hood
$picker = $picker->ClearDate();                       // programmatic equivalent
$picker = $picker->ToggleSelection();                 // select-or-clear in one call

If the cursor rests on an empty lead-in/trailing grid cell, Enter falls back to the first of the viewed month (the cursor only moves within the current grid; arrow keys do not wrap into neighbouring months).

Range mode — the first Enter sets the start, the second the end (normalized so start ≤ end), a third restarts:

$picker = $picker->withRangeMode(true);
$picker = $picker->handleKey(DatePicker::KEY_ENTER); // rangeStart
// ... move cursor ...
$picker = $picker->handleKey(DatePicker::KEY_ENTER); // rangeEnd (complete)
$picker = $picker->rangeStart();
$picker = $picker->rangeEnd();

Min/max bounds

$picker = $picker
    ->withMinDate(new \DateTimeImmutable('2026-03-10'))
    ->withMaxDate(new \DateTimeImmutable('2026-05-20'));

$picker->inBounds(new \DateTimeImmutable('2026-04-01')); // true
$picker = $picker->withoutMinDate();  // lift one bound again
$picker = $picker->withoutMaxDate();

Bounds are compared at midnight (time-of-day is ignored). Setting a bound pulls the currently viewed month into the window and re-clamps the cursor. Selecting a date outside the window is refused (the receiver is returned unchanged); an existing selection is never retroactively cleared. Setting minDate after maxDate (or maxDate before minDate) throws an InvalidArgumentException.

Today anchor

$picker = $picker->withToday(new \DateTimeImmutable('2026-05-15')); // pin "today"
$picker = $picker->withoutToday();  // drop the pin (falls back to the real clock)

withoutToday() removes only the pinned reference — the picker then treats the real wall-clock as "today", so the highlight keeps rendering (it is not a way to hide the marker; set WithTodayStyle('') to suppress it). GoToToday() always navigates to the pinned date, or to the real current day when nothing is pinned.

Rendering

echo $picker->View();                 // default 21 columns, no week numbers
echo $picker->View(30, true);         // 34 columns: 30-wide grid + 4-cell week column

View() takes a column width and a showWeekNumbers flag. The TOTAL width — the width-wide calendar area plus the optional 4-cell ISO week column — is clamped into [natural grid: 21 without a week column / 25 with one, 63] and never rejected. The floor guarantees all seven day columns stay visible (a narrower request would amputate the Friday/Saturday columns while leaving their days selectable), so the rendered width is never below the natural grid.

Styling

Seven style setters accept bare SGR digit strings (e.g. '7', '1;31', '4;35'); the extended-colour introducers 38/48, escape sequences and dangling delimiters are rejected with an InvalidArgumentException:

$picker = $picker
    ->WithHeaderStyle('1;36')
    ->WithTodayStyle('1;33')
    ->WithSelectedStyle('1;32')
    ->WithCursorStyle('7')
    ->WithRangeStyle('4;35');

Keyboard handling

handleKey() maps the eight KEY_* constants — left, right, up, down, home, end, enter, esc — onto cursor movement, selection and clearing. Unrecognized keys are ignored (the receiver is returned unchanged). The Model wrapper translates sugar-core KeyMsg messages into these strings, so a TEA application never calls handleKey() directly.

Honest limitations: there is no month-wrap on arrow keys (they clamp at the grid edge and can rest on empty lead-in/trailing cells, where Enter falls back to the first of the month), and no typeahead year entry. PageUp/PageDown step one month through the Model; larger jumps between months and years go through the GoTo* methods.

API surface

Beyond DatePicker itself, the shipped public classes are:

  • DateRange — an immutable start/end value object: contains($date) (compares calendar days, timezone-safe), durationInDays(?$now = null) (an optional clock seam; a null end measures against $now), and isComplete().
  • EventStore / EventStoreInterface — the host-facing transition channel: record($type, $payload), release() (drain-and-clear), hasEvents(), count(). It keeps a bounded ring of MAX_EVENTS (1000) and evicts the oldest records beyond that, so a host that never drains cannot grow memory without limit.
  • Navigation::gridIndexToDate($index, $month, $year) — maps a raw grid index to a date; it throws when month is outside 1–12 or year is below 1, which the picker's view floor prevents from ever reaching it in normal use.
  • Model — the TEA wrapper (init()/update($msg)/view()/ subscriptions()). Its update() command is always null — a host learns the outcome by polling picker()->SelectedDate()/rangeStart()/rangeEnd() (or the EventStore) after each update, never from a returned command. Because the shared Core\Model::view() contract is argless, a framework-driven host renders the default 21×9 grid; call picker()->View($width, $showWeekNumbers) directly for a sized render.
  • DatePicker accessors — minDate(), maxDate(), isRangeMode(), CursorIndex(), ViewMonth(), ViewYear(), dateAtCursor(), and MoveToDate() (aim the cursor at an existing value).

Internationalization

Day abbreviations and month names resolve through the shared sugar-core i18n lookup (Lang::t): keys day.0–day.6 (Sunday-first) and month.1–month.12 live in lang/en.php, which is the source of truth. Only English ships today; additional locales are added as lang/<code>.php files carrying the same key set.

Shared foundations

  • sugar-cellbuffer — month grid rendered as a 7-column × N-row Buffer; the day cells are laid out by DatePicker, and Buffer::toAnsi() serialises them to canonical ANSI SGR output.
  • sugar-core — Util\Width is the canonical display-width oracle (the picker delegates its cell placement widths to it); I18n\Lang powers the translations above.
  • sugar-testing — golden-file snapshot tests via assertGoldenAnsi() pin canonical month layouts.

License

MIT

Credits & inspiration

Design antecedent: EthanEFung/bubble-datepicker; SugarCraft is developed as a native PHP project.