sugarcraft / sugar-datepicker
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
Requires
- php: ^8.3
- ext-mbstring: *
- sugarcraft/sugar-cellbuffer: dev-master
- sugarcraft/sugar-core: dev-master
Requires (Dev)
- phpunit/phpunit: ^10.5
- sugarcraft/sugar-testing: dev-master
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-11-05 01:01:53 UTC
README
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, andPageUp/PageDownmonth steps driven through theModel; the view window is clamped to the calendar floor (January 0001) and to optionalmin/maxbounds - 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
Modelwrapper implementing the sugar-coreModelcontract (init()/update(Msg)/view()), recording picker events into an optionalEventStore - 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), andisComplete().EventStore/EventStoreInterface— the host-facing transition channel:record($type, $payload),release()(drain-and-clear),hasEvents(),count(). It keeps a bounded ring ofMAX_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 whenmonthis outside 1–12 oryearis 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()). Itsupdate()command is always null — a host learns the outcome by pollingpicker()->SelectedDate()/rangeStart()/rangeEnd()(or theEventStore) after each update, never from a returned command. Because the sharedCore\Model::view()contract is argless, a framework-driven host renders the default 21×9 grid; callpicker()->View($width, $showWeekNumbers)directly for a sized render.DatePickeraccessors —minDate(),maxDate(),isRangeMode(),CursorIndex(),ViewMonth(),ViewYear(),dateAtCursor(), andMoveToDate()(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 byDatePicker, andBuffer::toAnsi()serialises them to canonical ANSI SGR output. - sugar-core —
Util\Widthis the canonical display-width oracle (the picker delegates its cell placement widths to it);I18n\Langpowers 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.