diff --git a/README.md b/README.md
index 9ca8636..333bcf7 100644
--- a/README.md
+++ b/README.md
@@ -29,7 +29,7 @@ It powers the [Vortex](https://www.vortextemplate.com) project installer, but kn
## Features
- ๐งญ [**Panel TUI**](#panels-and-navigation) - a full-screen, scrollable, keyboard-driven form: panels hold fields, sub-panels drill in to any depth
-- ๐งฉ [**Widgets**](#widgets) - `text`, `number`, `textarea`, `password`, `select`, `multiselect`, `suggest`, `search`, `multisearch`, `filepicker`, `multifilepicker`, `confirm`, `toggle`, `pause`
+- ๐งฉ [**Widgets**](#widgets) - `text`, `number`, `date`, `textarea`, `password`, `select`, `multiselect`, `suggest`, `search`, `multisearch`, `filepicker`, `multifilepicker`, `confirm`, `toggle`, `pause`
- ๐๏ธ [**Builder-driven**](#configuration) - panels and fields are declared in PHP with a fluent builder; the common cases need no code
- ๐ค [**Interactive or headless**](#headless-collection) - drive the panel TUI by keyboard, or collect answers non-interactively from a JSON payload and environment variables (and emit a JSON schema for agents and forms)
- ๐ [**Derived values**](#derived-values) - compute one field from others with [str2name](https://github.com/AlexSkrypnyk/str2name) transforms; chains settle to a fixpoint
@@ -73,7 +73,7 @@ It also exposes `schema()`, `agentHelp()` and `validate()`, and - when you want
## Widgets
-Fourteen widget types cover text entry, choices, filesystem browsing and gates. Every field of the form opens its widget in an editor; the same widgets also run standalone (see [`playground/3-widgets/`](playground/3-widgets)). Widgets pull their glyphs and colours from the theme, so each one below is shown in all four display modes.
+Fifteen widget types cover text entry, choices, filesystem browsing and gates. Every field of the form opens its widget in an editor; the same widgets also run standalone (see [`playground/3-widgets/`](playground/3-widgets)). Widgets pull their glyphs and colours from the theme, so each one below is shown in all four display modes.
@@ -137,6 +137,40 @@ Declare optional `min`, `max` and `step` to bound the value and enable keyboard
$p->number('port', 'HTTP port')->min(1)->max(65535)->step(1)->default(8080);
```
+### Date
+
+A month calendar that returns a normalized ISO `YYYY-MM-DD` date. Left/Right move by day and Up/Down by week (the vim preset binds `h`/`l` and `k`/`j` to the same moves), PageUp/PageDown change month, Home/End jump to the first/last day of the month, and Enter accepts. Pass `default` (a `YYYY-MM-DD` string) to open on a specific date; with none the calendar opens on today. The move keys are configurable through the key map, like every other widget.
+
+```php
+$p->date('release', 'Release date')->default('2026-07-15');
+```
+
+Declare optional `minDate`, `maxDate` and `weekStart` to bound the range and set the first column of the week. Navigation is clamped to the range - the cursor never leaves it - and days outside it render dimmed. The bounds are enforced headlessly too - a `--prompts` or environment value outside the range is rejected - and are reflected in the JSON schema as `min_date`, `max_date` and `week_start` on the prompt.
+
+```php
+use DrevOps\Tui\Config\Weekday;
+
+$p->date('release', 'Release date')->minDate('2026-01-01')->maxDate('2026-12-31')->weekStart(Weekday::Sunday);
+```
+
+
+
+
+
ANSI
+
No ANSI
+
+
+
Unicode
+
+
+
+
+
ASCII
+
+
+
+
+
### Textarea
Multi-line text input: Enter inserts a newline, Up/Down move between lines keeping the column, Tab accepts.
diff --git a/docs/assets/widget-date-ascii-no-ansi.svg b/docs/assets/widget-date-ascii-no-ansi.svg
new file mode 100644
index 0000000..1b40b31
--- /dev/null
+++ b/docs/assets/widget-date-ascii-no-ansi.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/docs/assets/widget-date-ascii.svg b/docs/assets/widget-date-ascii.svg
new file mode 100644
index 0000000..06af3b6
--- /dev/null
+++ b/docs/assets/widget-date-ascii.svg
@@ -0,0 +1 @@
+Datewidget-----------July2026MoTuWeThFrSaSu1234567891011121314[15]16171819202122232425262728293031</>day*^/vweek*PgUp/PgDnmonth*<accept*esccancel
\ No newline at end of file
diff --git a/docs/assets/widget-date-no-ansi.svg b/docs/assets/widget-date-no-ansi.svg
new file mode 100644
index 0000000..15fa27a
--- /dev/null
+++ b/docs/assets/widget-date-no-ansi.svg
@@ -0,0 +1 @@
+DatewidgetโโโโโโโโโโโJuly2026MoTuWeThFrSaSu1234567891011121314[15]16171819202122232425262728293031โ/โdayยทโ/โweekยทPgUp/PgDnmonthยทโตacceptยทesccancel
\ No newline at end of file
diff --git a/docs/assets/widget-date.svg b/docs/assets/widget-date.svg
new file mode 100644
index 0000000..f43b90c
--- /dev/null
+++ b/docs/assets/widget-date.svg
@@ -0,0 +1 @@
+DatewidgetโโโโโโโโโโโJuly2026MoTuWeThFrSaSu1234567891011121314[15]16171819202122232425262728293031โ/โdayยทโ/โweekยทPgUp/PgDnmonthยทโตacceptยทesccancel
\ No newline at end of file
diff --git a/docs/assets/widgets-ascii-no-ansi.svg b/docs/assets/widgets-ascii-no-ansi.svg
index 7b03758..a4007d6 100644
--- a/docs/assets/widgets-ascii-no-ansi.svg
+++ b/docs/assets/widgets-ascii-no-ansi.svg
@@ -1 +1 @@
-Textwidget-----------edit*Enteraccept*EsccancelText:"AcmeCorp"Numberwidget-------------|Number:3000Textareawidget---------------edit*Tabaccept*EsccancelRedisforcacheenternewline*tabacceptSolrforsearchTextarea:"Redisforcache\nSolrforsearch\nClamAVformail"PasswordwidgetPassword:"hunter2s3cret"Selectwidget()StandardSelect:"demo_umami"MultiSelectwidget------------------>[x]Redis[]Solr[]ClamAVspaceselect*^/vmove*</>none/all*<accept*esccancel[x]RedisMultiSelect:["redis","solr"]Suggestwidget--------------Europe/LondonEurope/ParisAustralia/SydneyEur|Suggest:"Europe\/London"Searchwidget(*)Europe/London()Europe/Paris(*)Europe/ParisSearch:"paris"MultiSearchwidget[]Memcached>[]ClamAVcl|MultiSearch:["redis","clamav"]ConfirmwidgetConfirm:falseTogglewidgetToggle:"disabled"Pause:trueAcmeSite|AcmeSit|AcmeSi|AcmeS|Acme|AcmeC|AcmeCo|AcmeCor|AcmeCorp|8080|808|80|8|3|30|300|3000|Solrforsearch|C|Cl|Cla|Clam|ClamA|ClamAV|ClamAV|ClamAVf|ClamAVfo|ClamAVfor|ClamAVfor|ClamAVform|ClamAVforma|ClamAVformai|ClamAVformail|*******|********|*********|**********|***********|************|*************|(*)Minimal()DemoUmami()Minimal(*)DemoUmami>[]Solr>[x]SolrUTCE|Eu|>Europe/London()UTC()Australia/Sydneyp|pa|par|c|>[x]ClamAV(*)Yes()No()Yes(*)No(*)Enabled()Disabled()Enabled(*)DisabledPausewidget------------Entercontinue*Esccancel<PressEntertocontinue
\ No newline at end of file
+Textwidget-----------edit*Enteraccept*EsccancelText:"AcmeCorp"Numberwidget-------------|Number:3000DatewidgetJuly2026MoTuWeThFrSaSu1234567891011122728293031</>day*^/vweek*PgUp/PgDnmonth*<accept*esccancelDate:"2026-07-22"Textareawidget---------------Redisforcacheenternewline*tabacceptSolrforsearchTextarea:"Redisforcache\nSolrforsearch\nClamAVformail"PasswordwidgetPassword:"hunter2s3cret"Selectwidget()StandardSelect:"demo_umami"MultiSelectwidget------------------>[x]Redis[]Solr[]ClamAVspaceselect*^/vmove*</>none/all*<accept*esccancel[x]RedisMultiSelect:["redis","solr"]Suggestwidget--------------Europe/LondonEurope/ParisAustralia/SydneyEur|Suggest:"Europe\/London"Searchwidget(*)Europe/London()Europe/Paris(*)Europe/ParisSearch:"paris"MultiSearchwidget[]Memcached>[]ClamAVcl|MultiSearch:["redis","clamav"]ConfirmwidgetConfirm:falseTogglewidgetToggle:"disabled"Pause:trueAcmeSite|AcmeSit|AcmeSi|AcmeS|Acme|AcmeC|AcmeCo|AcmeCor|AcmeCorp|8080|808|80|8|3|30|300|3000|1314[15]1617181920212223242526131415161718192021[22]23242526Solrforsearch|C|Cl|Cla|Clam|ClamA|ClamAV|ClamAV|ClamAVf|ClamAVfo|ClamAVfor|ClamAVfor|ClamAVform|ClamAVforma|ClamAVformai|ClamAVformail|*******|********|*********|**********|***********|************|*************|(*)Minimal()DemoUmami()Minimal(*)DemoUmami>[]Solr>[x]SolrUTCE|Eu|>Europe/London()UTC()Australia/Sydneyp|pa|par|c|>[x]ClamAV(*)Yes()No()Yes(*)No(*)Enabled()Disabled()Enabled(*)DisabledPausewidget------------Entercontinue*Esccancel<PressEntertocontinue
\ No newline at end of file
diff --git a/docs/assets/widgets-ascii.svg b/docs/assets/widgets-ascii.svg
index e34d534..eddbfff 100644
--- a/docs/assets/widgets-ascii.svg
+++ b/docs/assets/widgets-ascii.svg
@@ -1 +1 @@
-Textwidget-----------edit*Enteraccept*EsccancelText:"AcmeCorp"Numberwidget-------------|Number:3000Textareawidget---------------edit*Tabaccept*EsccancelRedisforcacheenternewline*tabacceptSolrforsearchTextarea:"Redisforcache\nSolrforsearch\nClamAVformail"PasswordwidgetPassword:"hunter2s3cret"Selectwidget()StandardSelect:"demo_umami"MultiSelectwidget------------------>[x]Redis[]Solr[]ClamAVspaceselect*^/vmove*</>none/all*<accept*esccancel[x]RedisMultiSelect:["redis","solr"]Suggestwidget--------------Europe/LondonEurope/ParisAustralia/SydneyEur|Suggest:"Europe\/London"Searchwidget(*)Europe/London()Europe/Paris(*)Europe/ParisSearch:"paris"MultiSearchwidget[]Memcached>[]ClamAVcl|MultiSearch:["redis","clamav"]ConfirmwidgetConfirm:falseTogglewidgetToggle:"disabled"Pause:trueAcmeSite|AcmeSit|AcmeSi|AcmeS|Acme|AcmeC|AcmeCo|AcmeCor|AcmeCorp|8080|808|80|8|3|30|300|3000|Solrforsearch|C|Cl|Cla|Clam|ClamA|ClamAV|ClamAV|ClamAVf|ClamAVfo|ClamAVfor|ClamAVfor|ClamAVform|ClamAVforma|ClamAVformai|ClamAVformail|*******|********|*********|**********|***********|************|*************|(*)Minimal()DemoUmami()Minimal(*)DemoUmami>[]Solr>[x]SolrUTCE|Eu|>Europe/London()UTC()Australia/Sydneyp|pa|par|c|>[x]ClamAV(*)Yes()No()Yes(*)No(*)Enabled()Disabled()Enabled(*)DisabledPausewidget------------Entercontinue*Esccancel<PressEntertocontinue
\ No newline at end of file
+Textwidget-----------edit*Enteraccept*EsccancelText:"AcmeCorp"Numberwidget-------------|Number:3000DatewidgetJuly2026MoTuWeThFrSaSu1234567891011122728293031</>day*^/vweek*PgUp/PgDnmonth*<accept*esccancelDate:"2026-07-22"Textareawidget---------------Redisforcacheenternewline*tabacceptSolrforsearchTextarea:"Redisforcache\nSolrforsearch\nClamAVformail"PasswordwidgetPassword:"hunter2s3cret"Selectwidget()StandardSelect:"demo_umami"MultiSelectwidget------------------>[x]Redis[]Solr[]ClamAVspaceselect*^/vmove*</>none/all*<accept*esccancel[x]RedisMultiSelect:["redis","solr"]Suggestwidget--------------Europe/LondonEurope/ParisAustralia/SydneyEur|Suggest:"Europe\/London"Searchwidget(*)Europe/London()Europe/Paris(*)Europe/ParisSearch:"paris"MultiSearchwidget[]Memcached>[]ClamAVcl|MultiSearch:["redis","clamav"]ConfirmwidgetConfirm:falseTogglewidgetToggle:"disabled"Pause:trueAcmeSite|AcmeSit|AcmeSi|AcmeS|Acme|AcmeC|AcmeCo|AcmeCor|AcmeCorp|8080|808|80|8|3|30|300|3000|1314[15]1617181920212223242526131415161718192021[22]23242526Solrforsearch|C|Cl|Cla|Clam|ClamA|ClamAV|ClamAV|ClamAVf|ClamAVfo|ClamAVfor|ClamAVfor|ClamAVform|ClamAVforma|ClamAVformai|ClamAVformail|*******|********|*********|**********|***********|************|*************|(*)Minimal()DemoUmami()Minimal(*)DemoUmami>[]Solr>[x]SolrUTCE|Eu|>Europe/London()UTC()Australia/Sydneyp|pa|par|c|>[x]ClamAV(*)Yes()No()Yes(*)No(*)Enabled()Disabled()Enabled(*)DisabledPausewidget------------Entercontinue*Esccancel<PressEntertocontinue
\ No newline at end of file
diff --git a/docs/assets/widgets-no-ansi.svg b/docs/assets/widgets-no-ansi.svg
index 7130a73..214c116 100644
--- a/docs/assets/widgets-no-ansi.svg
+++ b/docs/assets/widgets-no-ansi.svg
@@ -1 +1 @@
-TextwidgetโโโโโโโโโโโeditยทEnteracceptยทEsccancelText:"AcmeCorp"NumberwidgetโโโโโโโโโโโโโโNumber:3000TextareawidgetโโโโโโโโโโโโโโโeditยทTabacceptยทEsccancelRedisforcacheenternewlineยทtabacceptSolrforsearchTextarea:"Redisforcache\nSolrforsearch\nClamAVformail"PasswordwidgetPassword:"hunter2s3cret"SelectwidgetโStandardSelect:"demo_umami"MultiSelectwidgetโโโโโโโโโโโโโโโโโโโฏโผRedisโปSolrโปClamAVspaceselectยทโ/โmoveยทโ/โnone/allยทโตacceptยทesccancelโผRedisMultiSelect:["redis","solr"]SuggestwidgetโโโโโโโโโโโโโโEurope/LondonEurope/ParisAustralia/SydneyEurโSuggest:"Europe\/London"SearchwidgetโEurope/LondonโEurope/ParisโEurope/ParisSearch:"paris"MultiSearchwidgetโปMemcachedโฏโปClamAVclโMultiSearch:["redis","clamav"]ConfirmwidgetConfirm:falseTogglewidgetToggle:"disabled"Pause:trueAcmeSiteโAcmeSitโAcmeSiโAcmeSโAcmeโAcmeCโAcmeCoโAcmeCorโAcmeCorpโ8080โ808โ80โ8โ3โ30โ300โ3000โSolrforsearchโCโClโClaโClamโClamAโClamAVโClamAVโClamAVfโClamAVfoโClamAVforโClamAVforโClamAVformโClamAVformaโClamAVformaiโClamAVformailโโขโขโขโขโขโขโขโโขโขโขโขโขโขโขโขโโขโขโขโขโขโขโขโขโขโโขโขโขโขโขโขโขโขโขโขโโขโขโขโขโขโขโขโขโขโขโขโโขโขโขโขโขโขโขโขโขโขโขโขโโขโขโขโขโขโขโขโขโขโขโขโขโขโโMinimalโDemoUmamiโMinimalโDemoUmamiโฏโปSolrโฏโผSolrUTCEโEuโโฏEurope/LondonโUTCโAustralia/SydneypโpaโparโcโโฏโผClamAVโYesโNoโYesโNoโEnabledโDisabledโEnabledโDisabledPausewidgetโโโโโโโโโโโโEntercontinueยทEsccancelโตPressEntertocontinue
\ No newline at end of file
+TextwidgetโโโโโโโโโโโeditยทEnteracceptยทEsccancelText:"AcmeCorp"NumberwidgetโโโโโโโโโโโโโโNumber:3000DatewidgetJuly2026MoTuWeThFrSaSu1234567891011122728293031โ/โdayยทโ/โweekยทPgUp/PgDnmonthยทโตacceptยทesccancelDate:"2026-07-22"TextareawidgetโโโโโโโโโโโโโโโRedisforcacheenternewlineยทtabacceptSolrforsearchTextarea:"Redisforcache\nSolrforsearch\nClamAVformail"PasswordwidgetPassword:"hunter2s3cret"SelectwidgetโStandardSelect:"demo_umami"MultiSelectwidgetโโโโโโโโโโโโโโโโโโโฏโผRedisโปSolrโปClamAVspaceselectยทโ/โmoveยทโ/โnone/allยทโตacceptยทesccancelโผRedisMultiSelect:["redis","solr"]SuggestwidgetโโโโโโโโโโโโโโEurope/LondonEurope/ParisAustralia/SydneyEurโSuggest:"Europe\/London"SearchwidgetโEurope/LondonโEurope/ParisโEurope/ParisSearch:"paris"MultiSearchwidgetโปMemcachedโฏโปClamAVclโMultiSearch:["redis","clamav"]ConfirmwidgetConfirm:falseTogglewidgetToggle:"disabled"Pause:trueAcmeSiteโAcmeSitโAcmeSiโAcmeSโAcmeโAcmeCโAcmeCoโAcmeCorโAcmeCorpโ8080โ808โ80โ8โ3โ30โ300โ3000โ1314[15]1617181920212223242526131415161718192021[22]23242526SolrforsearchโCโClโClaโClamโClamAโClamAVโClamAVโClamAVfโClamAVfoโClamAVforโClamAVforโClamAVformโClamAVformaโClamAVformaiโClamAVformailโโขโขโขโขโขโขโขโโขโขโขโขโขโขโขโขโโขโขโขโขโขโขโขโขโขโโขโขโขโขโขโขโขโขโขโขโโขโขโขโขโขโขโขโขโขโขโขโโขโขโขโขโขโขโขโขโขโขโขโขโโขโขโขโขโขโขโขโขโขโขโขโขโขโโMinimalโDemoUmamiโMinimalโDemoUmamiโฏโปSolrโฏโผSolrUTCEโEuโโฏEurope/LondonโUTCโAustralia/SydneypโpaโparโcโโฏโผClamAVโYesโNoโYesโNoโEnabledโDisabledโEnabledโDisabledPausewidgetโโโโโโโโโโโโEntercontinueยทEsccancelโตPressEntertocontinue
\ No newline at end of file
diff --git a/docs/assets/widgets.svg b/docs/assets/widgets.svg
index a5b85ae..cad7649 100644
--- a/docs/assets/widgets.svg
+++ b/docs/assets/widgets.svg
@@ -1 +1 @@
-TextwidgetโโโโโโโโโโโeditยทEnteracceptยทEsccancelText:"AcmeCorp"NumberwidgetโโโโโโโโโโโโโโNumber:3000TextareawidgetโโโโโโโโโโโโโโโeditยทTabacceptยทEsccancelRedisforcacheenternewlineยทtabacceptSolrforsearchTextarea:"Redisforcache\nSolrforsearch\nClamAVformail"PasswordwidgetPassword:"hunter2s3cret"SelectwidgetโStandardSelect:"demo_umami"MultiSelectwidgetโโโโโโโโโโโโโโโโโโโฏโผRedisโปSolrโปClamAVspaceselectยทโ/โmoveยทโ/โnone/allยทโตacceptยทesccancelโผRedisMultiSelect:["redis","solr"]SuggestwidgetโโโโโโโโโโโโโโEurope/LondonEurope/ParisAustralia/SydneyEurโSuggest:"Europe\/London"SearchwidgetโEurope/LondonโEurope/ParisโEurope/ParisSearch:"paris"MultiSearchwidgetโปMemcachedโฏโปClamAVclโMultiSearch:["redis","clamav"]ConfirmwidgetConfirm:falseTogglewidgetToggle:"disabled"Pause:trueAcmeSiteโAcmeSitโAcmeSiโAcmeSโAcmeโAcmeCโAcmeCoโAcmeCorโAcmeCorpโ8080โ808โ80โ8โ3โ30โ300โ3000โSolrforsearchโCโClโClaโClamโClamAโClamAVโClamAVโClamAVfโClamAVfoโClamAVforโClamAVforโClamAVformโClamAVformaโClamAVformaiโClamAVformailโโขโขโขโขโขโขโขโโขโขโขโขโขโขโขโขโโขโขโขโขโขโขโขโขโขโโขโขโขโขโขโขโขโขโขโขโโขโขโขโขโขโขโขโขโขโขโขโโขโขโขโขโขโขโขโขโขโขโขโขโโขโขโขโขโขโขโขโขโขโขโขโขโขโโMinimalโDemoUmamiโMinimalโDemoUmamiโฏโปSolrโฏโผSolrUTCEโEuโโฏEurope/LondonโUTCโAustralia/SydneypโpaโparโcโโฏโผClamAVโYesโNoโYesโNoโEnabledโDisabledโEnabledโDisabledPausewidgetโโโโโโโโโโโโEntercontinueยทEsccancelโตPressEntertocontinue
\ No newline at end of file
+TextwidgetโโโโโโโโโโโeditยทEnteracceptยทEsccancelText:"AcmeCorp"NumberwidgetโโโโโโโโโโโโโโNumber:3000DatewidgetJuly2026MoTuWeThFrSaSu1234567891011122728293031โ/โdayยทโ/โweekยทPgUp/PgDnmonthยทโตacceptยทesccancelDate:"2026-07-22"TextareawidgetโโโโโโโโโโโโโโโRedisforcacheenternewlineยทtabacceptSolrforsearchTextarea:"Redisforcache\nSolrforsearch\nClamAVformail"PasswordwidgetPassword:"hunter2s3cret"SelectwidgetโStandardSelect:"demo_umami"MultiSelectwidgetโโโโโโโโโโโโโโโโโโโฏโผRedisโปSolrโปClamAVspaceselectยทโ/โmoveยทโ/โnone/allยทโตacceptยทesccancelโผRedisMultiSelect:["redis","solr"]SuggestwidgetโโโโโโโโโโโโโโEurope/LondonEurope/ParisAustralia/SydneyEurโSuggest:"Europe\/London"SearchwidgetโEurope/LondonโEurope/ParisโEurope/ParisSearch:"paris"MultiSearchwidgetโปMemcachedโฏโปClamAVclโMultiSearch:["redis","clamav"]ConfirmwidgetConfirm:falseTogglewidgetToggle:"disabled"Pause:trueAcmeSiteโAcmeSitโAcmeSiโAcmeSโAcmeโAcmeCโAcmeCoโAcmeCorโAcmeCorpโ8080โ808โ80โ8โ3โ30โ300โ3000โ1314[15]1617181920212223242526131415161718192021[22]23242526SolrforsearchโCโClโClaโClamโClamAโClamAVโClamAVโClamAVfโClamAVfoโClamAVforโClamAVforโClamAVformโClamAVformaโClamAVformaiโClamAVformailโโขโขโขโขโขโขโขโโขโขโขโขโขโขโขโขโโขโขโขโขโขโขโขโขโขโโขโขโขโขโขโขโขโขโขโขโโขโขโขโขโขโขโขโขโขโขโขโโขโขโขโขโขโขโขโขโขโขโขโขโโขโขโขโขโขโขโขโขโขโขโขโขโขโโMinimalโDemoUmamiโMinimalโDemoUmamiโฏโปSolrโฏโผSolrUTCEโEuโโฏEurope/LondonโUTCโAustralia/SydneypโpaโparโcโโฏโผClamAVโYesโNoโYesโNoโEnabledโDisabledโEnabledโDisabledPausewidgetโโโโโโโโโโโโEntercontinueยทEsccancelโตPressEntertocontinue
\ No newline at end of file
diff --git a/docs/util/update-assets.php b/docs/util/update-assets.php
index b14de77..f5a1695 100644
--- a/docs/util/update-assets.php
+++ b/docs/util/update-assets.php
@@ -79,6 +79,14 @@ function widgetInteractions(): array {
type_text "3000"
wait_and_enter
}
+EXPECT,
+ 'date' => <<<'EXPECT'
+# Date: move down a week, accept.
+expect "Date widget" {
+ pause 1000
+ arrow_down
+ wait_and_enter
+}
EXPECT,
'textarea' => <<<'EXPECT'
# Textarea: Enter adds a line, type, Tab accepts.
@@ -489,6 +497,7 @@ function getJobs(string $project_dir): array {
$widget_rows = [
'text' => 6,
'number' => 6,
+ 'date' => 14,
'textarea' => 8,
'password' => 6,
'select' => 8,
diff --git a/playground/3-widgets/show.php b/playground/3-widgets/show.php
index 43894c4..a073504 100644
--- a/playground/3-widgets/show.php
+++ b/playground/3-widgets/show.php
@@ -18,6 +18,7 @@
use DrevOps\Tui\Theme\DefaultTheme;
use DrevOps\Tui\Widget\ConfirmWidget;
+use DrevOps\Tui\Widget\DateWidget;
use DrevOps\Tui\Widget\MultiSearchWidget;
use DrevOps\Tui\Widget\MultiSelectWidget;
use DrevOps\Tui\Widget\NumberWidget;
@@ -45,6 +46,7 @@
$widgets = [
'Text' => static fn(): WidgetInterface => new TextWidget('Acme Site'),
'Number' => static fn(): WidgetInterface => new NumberWidget('8080'),
+ 'Date' => static fn(): WidgetInterface => new DateWidget('2026-07-15'),
'Textarea' => static fn(): WidgetInterface => new TextareaWidget("Redis for cache\nSolr for search"),
'Password' => static fn(): WidgetInterface => new PasswordWidget('hunter2'),
'Select' => static fn(): WidgetInterface => new SelectWidget([
diff --git a/playground/3-widgets/widget-date.php b/playground/3-widgets/widget-date.php
new file mode 100644
index 0000000..69ecbb4
--- /dev/null
+++ b/playground/3-widgets/widget-date.php
@@ -0,0 +1,50 @@
+ !isset($opts['no-ansi']), 'unicode' => !isset($opts['no-unicode'])]);
+
+$widget = new DateWidget('2026-07-15');
+
+$terminal = new Terminal();
+$parser = new KeyParser();
+$terminal->setup();
+
+try {
+ while (!$widget->isComplete() && !$widget->isCancelled()) {
+ $terminal->render(implode("\n", [
+ $theme->renderEditorHeader('Date widget'),
+ '',
+ $widget->view($theme),
+ ]));
+
+ foreach ($parser->parse($terminal->read()) as $key) {
+ $widget->handle($key);
+ }
+ }
+}
+finally {
+ $terminal->restore();
+}
+
+echo 'Date: ' . ($widget->isCancelled() ? '(cancelled)' : (string) json_encode($widget->value())) . PHP_EOL;
diff --git a/playground/3-widgets/widgets.php b/playground/3-widgets/widgets.php
index 2f37b37..858feb1 100644
--- a/playground/3-widgets/widgets.php
+++ b/playground/3-widgets/widgets.php
@@ -16,6 +16,7 @@
use DrevOps\Tui\Render\Terminal;
use DrevOps\Tui\Theme\DefaultTheme;
use DrevOps\Tui\Widget\ConfirmWidget;
+use DrevOps\Tui\Widget\DateWidget;
use DrevOps\Tui\Widget\MultiSearchWidget;
use DrevOps\Tui\Widget\MultiSelectWidget;
use DrevOps\Tui\Widget\NumberWidget;
@@ -46,12 +47,17 @@
try {
while (!$widget->isComplete() && !$widget->isCancelled()) {
- $terminal->render(implode("\n", [
- $theme->renderEditorHeader($label . ' widget'),
- $theme->renderHintLine(...$hints),
- '',
- $widget->view($theme),
- ]));
+ $lines = [$theme->renderEditorHeader($label . ' widget')];
+
+ // A widget that draws its own key hints (like the date calendar) gets no
+ // generic hint line, so the two cannot contradict.
+ if (!$widget->rendersHint()) {
+ $lines[] = $theme->renderHintLine(...$hints);
+ }
+
+ $lines[] = '';
+ $lines[] = $widget->view($theme);
+ $terminal->render(implode("\n", $lines));
foreach ($parser->parse($terminal->read()) as $key) {
$widget->handle($key);
@@ -67,6 +73,7 @@
$interact(new TextWidget('Acme Site'), 'Text');
$interact(new NumberWidget('8080'), 'Number');
+$interact(new DateWidget('2026-07-15'), 'Date');
$interact(new TextareaWidget("Redis for cache\nSolr for search"), 'Textarea', ['edit', 'Tab accept', 'Esc cancel']);
$interact(new PasswordWidget('hunter2'), 'Password');
$interact(new SelectWidget(['standard' => 'Standard', 'minimal' => 'Minimal', 'demo_umami' => 'Demo Umami'], 'minimal'), 'Select');
diff --git a/src/Builder/FieldBuilder.php b/src/Builder/FieldBuilder.php
index 1b381e3..a1d39c2 100644
--- a/src/Builder/FieldBuilder.php
+++ b/src/Builder/FieldBuilder.php
@@ -6,12 +6,14 @@
use DrevOps\Tui\Condition\ConditionInterface;
use DrevOps\Tui\Config\ConfigException;
+use DrevOps\Tui\Config\DateBounds;
use DrevOps\Tui\Config\Field;
use DrevOps\Tui\Config\FieldType;
use DrevOps\Tui\Config\FilePickerMode;
use DrevOps\Tui\Config\NumberBounds;
use DrevOps\Tui\Config\Option;
use DrevOps\Tui\Config\OptionKind;
+use DrevOps\Tui\Config\Weekday;
use DrevOps\Tui\Derive\Derive;
use DrevOps\Tui\Discovery\DiscoverInterface;
@@ -131,6 +133,21 @@ final class FieldBuilder {
*/
protected bool $pickerShowHidden = FALSE;
+ /**
+ * The date field's inclusive earliest date (ISO `Y-m-d`), when declared.
+ */
+ protected ?string $minDate = NULL;
+
+ /**
+ * The date field's inclusive latest date (ISO `Y-m-d`), when declared.
+ */
+ protected ?string $maxDate = NULL;
+
+ /**
+ * The date field's week-start day, when declared.
+ */
+ protected ?Weekday $weekStart = NULL;
+
/**
* Construct a field builder.
*
@@ -375,6 +392,51 @@ public function showHidden(bool $show = TRUE): self {
return $this;
}
+ /**
+ * Date only: set the inclusive earliest selectable date.
+ *
+ * @param string $date
+ * The earliest date, as an ISO `Y-m-d` string.
+ *
+ * @return $this
+ * The builder.
+ */
+ public function minDate(string $date): self {
+ $this->minDate = $date;
+
+ return $this;
+ }
+
+ /**
+ * Date only: set the inclusive latest selectable date.
+ *
+ * @param string $date
+ * The latest date, as an ISO `Y-m-d` string.
+ *
+ * @return $this
+ * The builder.
+ */
+ public function maxDate(string $date): self {
+ $this->maxDate = $date;
+
+ return $this;
+ }
+
+ /**
+ * Date only: set the day the calendar week begins on.
+ *
+ * @param \DrevOps\Tui\Config\Weekday $weekday
+ * The week-start day.
+ *
+ * @return $this
+ * The builder.
+ */
+ public function weekStart(Weekday $weekday): self {
+ $this->weekStart = $weekday;
+
+ return $this;
+ }
+
/**
* Set the conditional-visibility rule.
*
@@ -562,6 +624,7 @@ public function build(): Field {
$this->pickerStart,
$this->pickerExtensions,
$this->pickerShowHidden,
+ $this->buildDateBounds(),
);
}
@@ -612,6 +675,55 @@ protected function buildBounds(): ?NumberBounds {
return new NumberBounds($this->min, $this->max, $this->step);
}
+ /**
+ * Assemble the date bounds for a date field from the declared min/max/start.
+ *
+ * @return \DrevOps\Tui\Config\DateBounds|null
+ * The bounds for a date field, or NULL for any other field type.
+ *
+ * @throws \DrevOps\Tui\Config\ConfigException
+ * When a declared date is not a valid `Y-m-d` date, or min is after max.
+ */
+ protected function buildDateBounds(): ?DateBounds {
+ if ($this->fieldType !== FieldType::Date) {
+ return NULL;
+ }
+
+ $min = $this->parseBoundDate($this->minDate);
+ $max = $this->parseBoundDate($this->maxDate);
+
+ if ($min instanceof \DateTimeImmutable && $max instanceof \DateTimeImmutable && $min > $max) {
+ throw new ConfigException(sprintf('Field "%s" declares min date %s after max date %s.', $this->id, $min->format('Y-m-d'), $max->format('Y-m-d')));
+ }
+
+ return new DateBounds($min, $max, $this->weekStart ?? Weekday::Monday);
+ }
+
+ /**
+ * Strictly parse a declared bound date, failing loudly on a bad value.
+ *
+ * @param string|null $value
+ * The declared date string, or NULL when the bound is open.
+ *
+ * @return \DateTimeImmutable|null
+ * The parsed date, or NULL when none was declared.
+ *
+ * @throws \DrevOps\Tui\Config\ConfigException
+ * When the value is not a valid `Y-m-d` date.
+ */
+ protected function parseBoundDate(?string $value): ?\DateTimeImmutable {
+ if ($value === NULL) {
+ return NULL;
+ }
+
+ $date = DateBounds::parse($value);
+ if (!$date instanceof \DateTimeImmutable) {
+ throw new ConfigException(sprintf('Field "%s" declares an invalid date "%s".', $this->id, $value));
+ }
+
+ return $date;
+ }
+
/**
* The engine default for a field type when none is declared.
*
diff --git a/src/Builder/PanelBuilder.php b/src/Builder/PanelBuilder.php
index 87ea5d0..d7aa898 100644
--- a/src/Builder/PanelBuilder.php
+++ b/src/Builder/PanelBuilder.php
@@ -165,6 +165,21 @@ public function number(string $id, string $label = ''): FieldBuilder {
return $this->field($id, $label, FieldType::Number);
}
+ /**
+ * Add a date field (a navigable month calendar).
+ *
+ * @param string $id
+ * The field id.
+ * @param string $label
+ * The label (defaults to the id).
+ *
+ * @return \DrevOps\Tui\Builder\FieldBuilder
+ * The field builder.
+ */
+ public function date(string $id, string $label = ''): FieldBuilder {
+ return $this->field($id, $label, FieldType::Date);
+ }
+
/**
* Add a textarea field.
*
diff --git a/src/Config/DateBounds.php b/src/Config/DateBounds.php
new file mode 100644
index 0000000..ed36949
--- /dev/null
+++ b/src/Config/DateBounds.php
@@ -0,0 +1,149 @@
+min instanceof \DateTimeImmutable && $this->max instanceof \DateTimeImmutable && $this->min > $this->max) {
+ throw new ConfigException(sprintf('Date bounds declare a minimum of %s after the maximum of %s.', $this->min->format('Y-m-d'), $this->max->format('Y-m-d')));
+ }
+ }
+
+ /**
+ * Strictly parse an ISO `Y-m-d` date, or NULL when it is not a valid one.
+ *
+ * The parse is round-tripped through the same format, so an unpadded,
+ * rolled-over or otherwise malformed value (e.g. "2026-2-3" or "2026-02-30")
+ * is rejected rather than silently normalized.
+ *
+ * @param string $value
+ * The candidate date string.
+ *
+ * @return \DateTimeImmutable|null
+ * The parsed date at midnight, or NULL when the value is not a strict
+ * `Y-m-d` calendar date.
+ */
+ public static function parse(string $value): ?\DateTimeImmutable {
+ $date = \DateTimeImmutable::createFromFormat('!Y-m-d', $value);
+
+ return $date instanceof \DateTimeImmutable && $date->format('Y-m-d') === $value ? $date : NULL;
+ }
+
+ /**
+ * Whether a date is within the bounds.
+ *
+ * @param \DateTimeInterface $value
+ * The date.
+ *
+ * @return bool
+ * TRUE when the date is within both declared bounds.
+ */
+ public function contains(\DateTimeInterface $value): bool {
+ if ($this->min instanceof \DateTimeImmutable && $value < $this->min) {
+ return FALSE;
+ }
+
+ return !$this->max instanceof \DateTimeImmutable || $value <= $this->max;
+ }
+
+ /**
+ * The range phrase for a date that violates the bounds, else NULL.
+ *
+ * Mirrors {@see NumberBounds::violation()}: a value that is not a strict date
+ * string is not this object's concern and passes (the type check lives in the
+ * schema validator), and an in-range date passes too.
+ *
+ * @param mixed $value
+ * The value to test.
+ *
+ * @return string|null
+ * The range phrase when out of range, else NULL.
+ */
+ public function violation(mixed $value): ?string {
+ if (!is_string($value)) {
+ return NULL;
+ }
+
+ $date = self::parse($value);
+ if (!$date instanceof \DateTimeImmutable) {
+ return NULL;
+ }
+
+ return $this->contains($date) ? NULL : $this->describe();
+ }
+
+ /**
+ * The human range phrase, e.g. "between 2026-01-01 and 2026-12-31".
+ *
+ * @return string
+ * The phrase, or an empty string when neither bound is declared.
+ */
+ public function describe(): string {
+ if ($this->min instanceof \DateTimeImmutable && $this->max instanceof \DateTimeImmutable) {
+ return sprintf('between %s and %s', $this->min->format('Y-m-d'), $this->max->format('Y-m-d'));
+ }
+
+ if ($this->min instanceof \DateTimeImmutable) {
+ return sprintf('on or after %s', $this->min->format('Y-m-d'));
+ }
+
+ if ($this->max instanceof \DateTimeImmutable) {
+ return sprintf('on or before %s', $this->max->format('Y-m-d'));
+ }
+
+ return '';
+ }
+
+ /**
+ * Clamp a date onto the nearest bound it exceeds.
+ *
+ * @param \DateTimeImmutable $value
+ * The date.
+ *
+ * @return \DateTimeImmutable
+ * The date, moved onto the nearest bound it exceeds.
+ */
+ public function clamp(\DateTimeImmutable $value): \DateTimeImmutable {
+ if ($this->min instanceof \DateTimeImmutable && $value < $this->min) {
+ return $this->min;
+ }
+
+ if ($this->max instanceof \DateTimeImmutable && $value > $this->max) {
+ return $this->max;
+ }
+
+ return $value;
+ }
+
+}
diff --git a/src/Config/Field.php b/src/Config/Field.php
index 5fec8b8..e87239b 100644
--- a/src/Config/Field.php
+++ b/src/Config/Field.php
@@ -81,6 +81,9 @@
* (dot-less, case-insensitive); empty allows every extension.
* @param bool $pickerShowHidden
* File picker only: whether dot-entries are shown when the browser opens.
+ * @param \DrevOps\Tui\Config\DateBounds|null $dateBounds
+ * Date only: the min/max range and week-start day; NULL for non-date
+ * fields.
*/
public function __construct(
public string $id,
@@ -104,6 +107,7 @@ public function __construct(
public string $pickerStart = '',
public array $pickerExtensions = [],
public bool $pickerShowHidden = FALSE,
+ public ?DateBounds $dateBounds = NULL,
) {
$this->options = Option::list($options);
}
diff --git a/src/Config/FieldType.php b/src/Config/FieldType.php
index 309494e..a3a6689 100644
--- a/src/Config/FieldType.php
+++ b/src/Config/FieldType.php
@@ -18,6 +18,7 @@ enum FieldType: string {
case Toggle = 'toggle';
case Suggest = 'suggest';
case Number = 'number';
+ case Date = 'date';
case Textarea = 'textarea';
case Password = 'password';
case Search = 'search';
diff --git a/src/Config/Weekday.php b/src/Config/Weekday.php
new file mode 100644
index 0000000..4629889
--- /dev/null
+++ b/src/Config/Weekday.php
@@ -0,0 +1,85 @@
+ 'Mo',
+ self::Tuesday => 'Tu',
+ self::Wednesday => 'We',
+ self::Thursday => 'Th',
+ self::Friday => 'Fr',
+ self::Saturday => 'Sa',
+ self::Sunday => 'Su',
+ };
+ }
+
+ /**
+ * The weekday a date falls on.
+ *
+ * @param \DateTimeInterface $date
+ * The date.
+ *
+ * @return self
+ * The weekday.
+ */
+ public static function fromDate(\DateTimeInterface $date): self {
+ return self::from((int) $date->format('N'));
+ }
+
+ /**
+ * The seven weekdays in display order, starting from this one.
+ *
+ * @return list
+ * The rotated sequence of all seven weekdays.
+ */
+ public function sequence(): array {
+ $out = [];
+
+ for ($offset = 0; $offset < 7; $offset++) {
+ $out[] = self::from(($this->value - 1 + $offset) % 7 + 1);
+ }
+
+ return $out;
+ }
+
+ /**
+ * The zero-based column a weekday occupies in a week starting on this day.
+ *
+ * @param self $weekday
+ * The weekday to place.
+ *
+ * @return int
+ * The column index, 0 (the week-start day) to 6.
+ */
+ public function columnOf(self $weekday): int {
+ return ($weekday->value - $this->value + 7) % 7;
+ }
+
+}
diff --git a/src/Engine/Engine.php b/src/Engine/Engine.php
index 82f3a14..756c082 100644
--- a/src/Engine/Engine.php
+++ b/src/Engine/Engine.php
@@ -193,7 +193,7 @@ protected function resolveInitial(Field $field, array $inputs, Context $context)
* An error message, or NULL when the value is valid.
*/
protected function validateValue(Field $field, mixed $value): ?string {
- $violation = $field->bounds?->violation($value);
+ $violation = $field->bounds?->violation($value) ?? $field->dateBounds?->violation($value);
if ($violation !== NULL) {
return sprintf('must be %s.', $violation);
}
diff --git a/src/Input/VimKeyMap.php b/src/Input/VimKeyMap.php
index 34afc65..bcad105 100644
--- a/src/Input/VimKeyMap.php
+++ b/src/Input/VimKeyMap.php
@@ -10,7 +10,8 @@
* A vim-style preset: h/j/k/l navigate alongside the arrow keys.
*
* The letter keys are added only where they cannot be swallowed as typed input:
- * panel navigation and the single-choice list. Text fields and the filtering
+ * panel navigation, the single-choice list and the date calendar. Text fields
+ * and the filtering
* lists (search, suggest, checkbox) keep the arrow keys, because there a letter
* is a character the user is typing - binding it to movement would make it
* un-typeable, which the key map rejects outright. Everything else is inherited
@@ -33,6 +34,11 @@ public function bindings(): array {
new Binding(Scope::field(FieldType::Select), Action::MoveUp, 'k', KeyName::Up),
new Binding(Scope::field(FieldType::Select), Action::MoveDown, 'j', KeyName::Down),
+
+ new Binding(Scope::field(FieldType::Date), Action::MoveUp, 'k', KeyName::Up),
+ new Binding(Scope::field(FieldType::Date), Action::MoveDown, 'j', KeyName::Down),
+ new Binding(Scope::field(FieldType::Date), Action::MoveLeft, 'h', KeyName::Left),
+ new Binding(Scope::field(FieldType::Date), Action::MoveRight, 'l', KeyName::Right),
]);
}
diff --git a/src/Schema/AgentHelp.php b/src/Schema/AgentHelp.php
index 6294fcc..e702440 100644
--- a/src/Schema/AgentHelp.php
+++ b/src/Schema/AgentHelp.php
@@ -5,6 +5,7 @@
namespace DrevOps\Tui\Schema;
use DrevOps\Tui\Config\Config;
+use DrevOps\Tui\Config\DateBounds;
use DrevOps\Tui\Config\Field;
use DrevOps\Tui\Config\NumberBounds;
@@ -50,7 +51,7 @@ public function generate(): string {
foreach ($this->config->fields() as $field) {
$required = $field->required ? ' (required)' : '';
- $lines[] = sprintf(' %s [%s]%s - %s%s', $field->id, $field->type->value, $required, $field->label, $this->rangeNote($field));
+ $lines[] = sprintf(' %s [%s]%s - %s%s%s', $field->id, $field->type->value, $required, $field->label, $this->rangeNote($field), $this->dateNote($field));
}
return implode("\n", $lines);
@@ -84,4 +85,24 @@ protected function rangeNote(Field $field): string {
return sprintf(' (%s)', implode(', ', $parts));
}
+ /**
+ * A format-and-range annotation for a date field.
+ *
+ * @param \DrevOps\Tui\Config\Field $field
+ * The field.
+ *
+ * @return string
+ * The annotation (e.g. " (between 2026-01-01 and 2026-12-31)"), or an empty
+ * string when the field is not a date.
+ */
+ protected function dateNote(Field $field): string {
+ if (!$field->dateBounds instanceof DateBounds) {
+ return '';
+ }
+
+ $described = $field->dateBounds->describe();
+
+ return sprintf(' (%s)', $described === '' ? 'YYYY-MM-DD' : $described);
+ }
+
}
diff --git a/src/Schema/SchemaGenerator.php b/src/Schema/SchemaGenerator.php
index f502820..3e56046 100644
--- a/src/Schema/SchemaGenerator.php
+++ b/src/Schema/SchemaGenerator.php
@@ -50,6 +50,9 @@ public function generate(): array {
'min' => $field->bounds?->min,
'max' => $field->bounds?->max,
'step' => $field->bounds?->step,
+ 'min_date' => $field->dateBounds?->min?->format('Y-m-d'),
+ 'max_date' => $field->dateBounds?->max?->format('Y-m-d'),
+ 'week_start' => $field->dateBounds?->weekStart->value,
'when' => $field->when?->toArray(),
'derive' => $field->derive?->toArray(),
'discover' => $field->discover instanceof DiscoverInterface ? $field->discover->toArray() : NULL,
diff --git a/src/Schema/SchemaValidator.php b/src/Schema/SchemaValidator.php
index 93aff82..3340400 100644
--- a/src/Schema/SchemaValidator.php
+++ b/src/Schema/SchemaValidator.php
@@ -5,6 +5,7 @@
namespace DrevOps\Tui\Schema;
use DrevOps\Tui\Config\Config;
+use DrevOps\Tui\Config\DateBounds;
use DrevOps\Tui\Config\Field;
use DrevOps\Tui\Config\FieldType;
@@ -93,6 +94,11 @@ protected function validateValue(Field $field, mixed $value): ?string {
return $bounds_error;
}
+ $date_error = $this->checkDateBounds($field, $value);
+ if ($date_error !== NULL) {
+ return $date_error;
+ }
+
return $this->checkOptions($field, $value);
}
@@ -113,6 +119,23 @@ protected function checkBounds(Field $field, mixed $value): ?string {
return $violation === NULL ? NULL : sprintf('Question "%s" must be %s.', $field->id, $violation);
}
+ /**
+ * Check a date value against its declared range.
+ *
+ * @param \DrevOps\Tui\Config\Field $field
+ * The field.
+ * @param mixed $value
+ * The value.
+ *
+ * @return string|null
+ * An error, or NULL when in range (or when the field declares no range).
+ */
+ protected function checkDateBounds(Field $field, mixed $value): ?string {
+ $violation = $field->dateBounds?->violation($value);
+
+ return $violation === NULL ? NULL : sprintf('Question "%s" must be %s.', $field->id, $violation);
+ }
+
/**
* Whether the value matches the field type.
*
@@ -129,6 +152,9 @@ protected function isType(FieldType $type, mixed $value): bool {
FieldType::Confirm, FieldType::Pause => is_bool($value),
FieldType::MultiSelect, FieldType::MultiSearch, FieldType::MultiFilePicker => is_array($value),
FieldType::Number => is_int($value) || is_float($value),
+ // An empty string is an unset date, left to the required check; any other
+ // value must be a strict `Y-m-d` calendar date.
+ FieldType::Date => is_string($value) && ($value === '' || DateBounds::parse($value) instanceof \DateTimeImmutable),
default => is_string($value),
};
}
@@ -147,6 +173,7 @@ protected function typeName(FieldType $type): string {
FieldType::Confirm, FieldType::Pause => 'a boolean',
FieldType::MultiSelect, FieldType::MultiSearch, FieldType::MultiFilePicker => 'a list',
FieldType::Number => 'a number',
+ FieldType::Date => 'a date (YYYY-MM-DD)',
default => 'a string',
};
}
diff --git a/src/Widget/DateWidget.php b/src/Widget/DateWidget.php
new file mode 100644
index 0000000..aedabcb
--- /dev/null
+++ b/src/Widget/DateWidget.php
@@ -0,0 +1,283 @@
+bounds = $bounds ?? new DateBounds();
+ $seed = DateBounds::parse($value) ?? new \DateTimeImmutable('today');
+ $this->cursor = $this->bounds->clamp($seed);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ #[\Override]
+ protected function keyScope(): Scope {
+ return Scope::field(FieldType::Date);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function handle(Key $key): void {
+ $keys = $this->keys();
+
+ if ($this->handleCancel($key)) {
+ return;
+ }
+
+ if ($keys->matches($key, Action::Accept)) {
+ $this->accept($this->liveValue());
+
+ return;
+ }
+
+ $moved = $this->move($key, $keys);
+ if ($moved instanceof \DateTimeImmutable) {
+ $this->cursor = $this->bounds->clamp($moved);
+ }
+ }
+
+ /**
+ * The date a navigation key moves to before clamping, or NULL for no move.
+ *
+ * Day and week movement resolve through the injected key bindings, so the
+ * arrow keys, the vim preset and any consumer remap all reach them; the month
+ * and month-edge jumps have no action of their own and stay on their keys.
+ *
+ * @param \DrevOps\Tui\Input\Key $key
+ * The key to interpret.
+ * @param \DrevOps\Tui\Input\ScopedKeyMap $keys
+ * The resolved bindings for this widget's scope.
+ *
+ * @return \DateTimeImmutable|null
+ * The unclamped target date, or NULL when the key does not navigate.
+ */
+ protected function move(Key $key, ScopedKeyMap $keys): ?\DateTimeImmutable {
+ return match (TRUE) {
+ $keys->matches($key, Action::MoveLeft) => $this->cursor->modify('-1 day'),
+ $keys->matches($key, Action::MoveRight) => $this->cursor->modify('+1 day'),
+ $keys->matches($key, Action::MoveUp) => $this->cursor->modify('-7 days'),
+ $keys->matches($key, Action::MoveDown) => $this->cursor->modify('+7 days'),
+ $key->is(KeyName::PageUp) => $this->shiftMonths(-1),
+ $key->is(KeyName::PageDown) => $this->shiftMonths(1),
+ $key->is(KeyName::Home) => $this->cursor->modify('first day of this month'),
+ $key->is(KeyName::End) => $this->cursor->modify('last day of this month'),
+ default => NULL,
+ };
+ }
+
+ /**
+ * The cursor moved by whole months, kept on a valid day-of-month.
+ *
+ * Anchoring on the first of the month before shifting avoids the day-of-month
+ * overflow that a naive "+1 month" produces (e.g. Jan 31 becoming Mar 3); the
+ * day is then re-applied, capped to the shorter month's length.
+ *
+ * @param int $months
+ * The signed number of months to move.
+ *
+ * @return \DateTimeImmutable
+ * The shifted date.
+ */
+ protected function shiftMonths(int $months): \DateTimeImmutable {
+ $day = (int) $this->cursor->format('j');
+ $first = $this->cursor->modify('first day of this month')->modify(sprintf('%+d months', $months));
+
+ return $first->setDate((int) $first->format('Y'), (int) $first->format('n'), min($day, (int) $first->format('t')));
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function liveValue(): mixed {
+ return $this->cursor->format('Y-m-d');
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function view(ThemeInterface $theme): string {
+ $rows = array_merge([$this->heading($theme), $this->weekdayRow($theme)], $this->weekRows($theme), [$this->hint($theme)]);
+
+ if ($this->error !== NULL) {
+ $rows[] = $theme->error($this->error);
+ }
+
+ return implode("\n", $rows);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ #[\Override]
+ public function rendersHint(): bool {
+ return TRUE;
+ }
+
+ /**
+ * The centered "Month YYYY" heading over the calendar grid.
+ *
+ * @param \DrevOps\Tui\Theme\ThemeInterface $theme
+ * The theme.
+ *
+ * @return string
+ * The themed, centered heading.
+ */
+ protected function heading(ThemeInterface $theme): string {
+ $title = $this->cursor->format('F Y');
+ $left = max(0, intdiv(self::GRID_WIDTH - mb_strlen($title), 2));
+
+ return str_repeat(' ', $left) . $theme->title($title);
+ }
+
+ /**
+ * The weekday heading row, ordered from the configured week-start day.
+ *
+ * @param \DrevOps\Tui\Theme\ThemeInterface $theme
+ * The theme.
+ *
+ * @return string
+ * The themed weekday row.
+ */
+ protected function weekdayRow(ThemeInterface $theme): string {
+ $cells = array_map(static fn(Weekday $day): string => sprintf(' %2s ', $day->abbreviation()), $this->bounds->weekStart->sequence());
+
+ return $theme->footer(implode('', $cells));
+ }
+
+ /**
+ * The calendar grid rows for the visible month.
+ *
+ * @param \DrevOps\Tui\Theme\ThemeInterface $theme
+ * The theme.
+ *
+ * @return list
+ * One string per week row.
+ */
+ protected function weekRows(ThemeInterface $theme): array {
+ $first = $this->cursor->modify('first day of this month');
+ $days = (int) $this->cursor->format('t');
+ $lead = $this->bounds->weekStart->columnOf(Weekday::fromDate($first));
+
+ $cells = array_fill(0, $lead, self::BLANK_CELL);
+ for ($day = 1; $day <= $days; $day++) {
+ $cells[] = $this->dayCell($theme, $first->setDate((int) $first->format('Y'), (int) $first->format('n'), $day), $day);
+ }
+
+ $rows = [];
+ foreach (array_chunk($cells, 7) as $week) {
+ $rows[] = implode('', array_pad($week, 7, self::BLANK_CELL));
+ }
+
+ return $rows;
+ }
+
+ /**
+ * Render one day cell: bracketed at the cursor, dimmed when out of range.
+ *
+ * The cursor cell carries literal brackets so it stays distinguishable even
+ * with colour off, mirroring how the radio glyph marks a selection in ASCII.
+ *
+ * @param \DrevOps\Tui\Theme\ThemeInterface $theme
+ * The theme.
+ * @param \DateTimeImmutable $date
+ * The cell's date.
+ * @param int $day
+ * The day-of-month number.
+ *
+ * @return string
+ * The four-column themed cell.
+ */
+ protected function dayCell(ThemeInterface $theme, \DateTimeImmutable $date, int $day): string {
+ if ($date->format('Y-m-d') === $this->cursor->format('Y-m-d')) {
+ return $theme->highlight(sprintf('[%2d]', $day));
+ }
+
+ $cell = sprintf(' %2d ', $day);
+
+ return $this->bounds->contains($date) ? $cell : $theme->description($cell);
+ }
+
+ /**
+ * Build the key-hint line shown beneath the calendar.
+ *
+ * @param \DrevOps\Tui\Theme\ThemeInterface $theme
+ * The theme.
+ *
+ * @return string
+ * The themed, dot-joined hint line.
+ */
+ protected function hint(ThemeInterface $theme): string {
+ $fragments = [
+ $theme->arrowLeft() . '/' . $theme->arrowRight() . ' day',
+ $theme->arrowUp() . '/' . $theme->arrowDown() . ' week',
+ 'PgUp/PgDn month',
+ $theme->enter() . ' accept',
+ 'esc cancel',
+ ];
+
+ return $theme->footer(implode(' ' . $theme->dot() . ' ', $fragments));
+ }
+
+}
diff --git a/src/Widget/WidgetFactory.php b/src/Widget/WidgetFactory.php
index fb5035c..b9969a9 100644
--- a/src/Widget/WidgetFactory.php
+++ b/src/Widget/WidgetFactory.php
@@ -57,6 +57,7 @@ public function create(Field $field, mixed $current): WidgetInterface {
FieldType::FilePicker => new FilePickerWidget($field->pickerStart, is_string($current) ? $current : '', $field->pickerMode, $field->pickerExtensions, $field->pickerShowHidden),
FieldType::MultiFilePicker => new FilePickerWidget($field->pickerStart, $this->toList($current), $field->pickerMode, $field->pickerExtensions, $field->pickerShowHidden, multiple: TRUE),
FieldType::Number => new NumberWidget(is_int($current) || is_float($current) ? (string) (int) $current : '', bounds: $field->bounds),
+ FieldType::Date => new DateWidget(is_string($current) ? $current : '', bounds: $field->dateBounds),
FieldType::Textarea => new TextareaWidget(is_string($current) ? $current : '', externalEdit: $field->externalEditor && $this->externalEditorAvailable),
FieldType::Password => new PasswordWidget(is_string($current) ? $current : '', revealable: $field->revealable, confirm: $field->confirm),
FieldType::Pause => new PauseWidget(),
diff --git a/tests/phpunit/Unit/Builder/FormTest.php b/tests/phpunit/Unit/Builder/FormTest.php
index 388802b..4af9db0 100644
--- a/tests/phpunit/Unit/Builder/FormTest.php
+++ b/tests/phpunit/Unit/Builder/FormTest.php
@@ -9,12 +9,14 @@
use DrevOps\Tui\Builder\PanelBuilder;
use DrevOps\Tui\Condition\Condition;
use DrevOps\Tui\Config\ConfigException;
+use DrevOps\Tui\Config\DateBounds;
use DrevOps\Tui\Config\Field;
use DrevOps\Tui\Config\FieldType;
use DrevOps\Tui\Config\FilePickerMode;
use DrevOps\Tui\Config\Fixup;
use DrevOps\Tui\Config\NumberBounds;
use DrevOps\Tui\Config\OptionKind;
+use DrevOps\Tui\Config\Weekday;
use DrevOps\Tui\Derive\Derive;
use DrevOps\Tui\Discovery\Dotenv;
use DrevOps\Tui\Input\Action;
@@ -142,6 +144,7 @@ public function testDefaultsAndFallbacks(): void {
$panel->confirm('c');
$panel->suggest('g');
$panel->number('n');
+ $panel->date('dt');
$panel->textarea('ta');
$panel->password('pw');
$panel->search('se')->option('a');
@@ -160,6 +163,8 @@ public function testDefaultsAndFallbacks(): void {
$this->assertFalse($config->field('c')?->default);
$this->assertSame('', $config->field('g')?->default);
$this->assertSame(0, $config->field('n')?->default);
+ // A date with no explicit default is empty; the widget opens on today.
+ $this->assertSame('', $config->field('dt')?->default);
$this->assertSame('', $config->field('ta')?->default);
$this->assertSame('', $config->field('pw')?->default);
// The password options are opt-in, so they default off.
@@ -241,6 +246,57 @@ public function testNumberBoundsAssembled(): void {
$this->assertNotInstanceOf(NumberBounds::class, $config->field('plain')?->bounds);
}
+ public function testDateBoundsAssembled(): void {
+ $config = Form::create('T')
+ ->panel('p', 'P', function (PanelBuilder $panel): void {
+ $panel->date('birthday', 'Birthday')->minDate('2000-01-01')->maxDate('2030-12-31')->weekStart(Weekday::Sunday);
+ $panel->date('plain', 'Plain');
+ })
+ ->build();
+
+ $birthday = $config->field('birthday');
+ $this->assertInstanceOf(Field::class, $birthday);
+ $this->assertInstanceOf(DateBounds::class, $birthday->dateBounds);
+ $this->assertSame('2000-01-01', $birthday->dateBounds->min?->format('Y-m-d'));
+ $this->assertSame('2030-12-31', $birthday->dateBounds->max?->format('Y-m-d'));
+ $this->assertSame(Weekday::Sunday, $birthday->dateBounds->weekStart);
+
+ // A date with nothing declared still carries bounds, defaulting to a
+ // Monday-first, open range.
+ $plain = $config->field('plain');
+ $this->assertInstanceOf(DateBounds::class, $plain?->dateBounds);
+ $this->assertNotInstanceOf(\DateTimeImmutable::class, $plain->dateBounds->min);
+ $this->assertNotInstanceOf(\DateTimeImmutable::class, $plain->dateBounds->max);
+ $this->assertSame(Weekday::Monday, $plain->dateBounds->weekStart);
+ }
+
+ public function testDateInvalidBoundThrows(): void {
+ $this->expectException(ConfigException::class);
+ $this->expectExceptionMessage('Field "d" declares an invalid date "2026-13-01".');
+
+ Form::create('T')
+ ->panel('p', 'P', fn(PanelBuilder $p): FieldBuilder => $p->date('d')->minDate('2026-13-01'))
+ ->build();
+ }
+
+ public function testDateMinAfterMaxThrows(): void {
+ $this->expectException(ConfigException::class);
+ $this->expectExceptionMessage('Field "d" declares min date 2026-12-31 after max date 2026-01-01.');
+
+ Form::create('T')
+ ->panel('p', 'P', fn(PanelBuilder $p): FieldBuilder => $p->date('d')->minDate('2026-12-31')->maxDate('2026-01-01'))
+ ->build();
+ }
+
+ public function testDateBoundsIgnoredOnNonDateField(): void {
+ $config = Form::create('T')
+ ->panel('p', 'P', fn(PanelBuilder $p): FieldBuilder => $p->text('t')->minDate('2020-01-01')->weekStart(Weekday::Sunday))
+ ->build();
+
+ // The date setters are inert on a non-date field: no bounds are attached.
+ $this->assertNotInstanceOf(DateBounds::class, $config->field('t')?->dateBounds);
+ }
+
public function testNumberMinGreaterThanMaxThrows(): void {
$this->expectException(ConfigException::class);
$this->expectExceptionMessage('Field "n" declares min 10 greater than max 1.');
diff --git a/tests/phpunit/Unit/Config/DateBoundsTest.php b/tests/phpunit/Unit/Config/DateBoundsTest.php
new file mode 100644
index 0000000..10c19f8
--- /dev/null
+++ b/tests/phpunit/Unit/Config/DateBoundsTest.php
@@ -0,0 +1,125 @@
+assertSame($expected, DateBounds::parse($value)?->format('Y-m-d'));
+ }
+
+ public static function dataProviderParse(): \Iterator {
+ yield 'valid' => ['2026-07-15', '2026-07-15'];
+ yield 'leap day' => ['2024-02-29', '2024-02-29'];
+ yield 'unpadded rejected' => ['2026-7-5', NULL];
+ yield 'rolled-over rejected' => ['2026-02-30', NULL];
+ yield 'non-leap feb 29 rejected' => ['2026-02-29', NULL];
+ yield 'invalid month rejected' => ['2026-13-01', NULL];
+ yield 'garbage rejected' => ['not-a-date', NULL];
+ yield 'empty rejected' => ['', NULL];
+ yield 'trailing time rejected' => ['2026-07-15 10:00', NULL];
+ }
+
+ #[DataProvider('dataProviderContains')]
+ public function testContains(?string $min, ?string $max, string $value, bool $expected): void {
+ $this->assertSame($expected, $this->bounds($min, $max)->contains(new \DateTimeImmutable($value)));
+ }
+
+ public static function dataProviderContains(): \Iterator {
+ yield 'within both' => ['2026-01-01', '2026-12-31', '2026-07-15', TRUE];
+ yield 'on lower bound' => ['2026-01-01', '2026-12-31', '2026-01-01', TRUE];
+ yield 'on upper bound' => ['2026-01-01', '2026-12-31', '2026-12-31', TRUE];
+ yield 'before lower' => ['2026-01-01', '2026-12-31', '2025-12-31', FALSE];
+ yield 'after upper' => ['2026-01-01', '2026-12-31', '2027-01-01', FALSE];
+ yield 'before open max' => ['2026-01-01', NULL, '2025-12-31', FALSE];
+ yield 'after open max' => ['2026-01-01', NULL, '2099-01-01', TRUE];
+ yield 'after open min' => [NULL, '2026-12-31', '2027-01-01', FALSE];
+ yield 'before open min' => [NULL, '2026-12-31', '1999-01-01', TRUE];
+ yield 'unbounded' => [NULL, NULL, '2099-01-01', TRUE];
+ }
+
+ #[DataProvider('dataProviderViolation')]
+ public function testViolation(?string $min, ?string $max, mixed $value, ?string $expected): void {
+ $this->assertSame($expected, $this->bounds($min, $max)->violation($value));
+ }
+
+ public static function dataProviderViolation(): \Iterator {
+ yield 'in range' => ['2026-01-01', '2026-12-31', '2026-07-15', NULL];
+ yield 'out of range' => ['2026-01-01', '2026-12-31', '2027-01-01', 'between 2026-01-01 and 2026-12-31'];
+ yield 'before min only' => ['2026-01-01', NULL, '2025-01-01', 'on or after 2026-01-01'];
+ yield 'after max only' => [NULL, '2026-12-31', '2027-01-01', 'on or before 2026-12-31'];
+ yield 'malformed ignored' => ['2026-01-01', '2026-12-31', 'not-a-date', NULL];
+ yield 'empty ignored' => ['2026-01-01', '2026-12-31', '', NULL];
+ yield 'non-string ignored' => ['2026-01-01', '2026-12-31', 42, NULL];
+ }
+
+ #[DataProvider('dataProviderDescribe')]
+ public function testDescribe(?string $min, ?string $max, string $expected): void {
+ $this->assertSame($expected, $this->bounds($min, $max)->describe());
+ }
+
+ public static function dataProviderDescribe(): \Iterator {
+ yield 'both' => ['2026-01-01', '2026-12-31', 'between 2026-01-01 and 2026-12-31'];
+ yield 'min only' => ['2026-01-01', NULL, 'on or after 2026-01-01'];
+ yield 'max only' => [NULL, '2026-12-31', 'on or before 2026-12-31'];
+ yield 'neither' => [NULL, NULL, ''];
+ }
+
+ #[DataProvider('dataProviderClamp')]
+ public function testClamp(?string $min, ?string $max, string $value, string $expected): void {
+ $this->assertSame($expected, $this->bounds($min, $max)->clamp(new \DateTimeImmutable($value))->format('Y-m-d'));
+ }
+
+ public static function dataProviderClamp(): \Iterator {
+ yield 'within' => ['2026-01-01', '2026-12-31', '2026-07-15', '2026-07-15'];
+ yield 'before min' => ['2026-01-01', '2026-12-31', '2025-01-01', '2026-01-01'];
+ yield 'after max' => ['2026-01-01', '2026-12-31', '2027-01-01', '2026-12-31'];
+ yield 'open min below max' => [NULL, '2026-12-31', '2027-01-01', '2026-12-31'];
+ yield 'open max above min' => ['2026-01-01', NULL, '2025-01-01', '2026-01-01'];
+ yield 'unbounded' => [NULL, NULL, '2099-01-01', '2099-01-01'];
+ }
+
+ public function testWeekStartDefaultsToMonday(): void {
+ $this->assertSame(Weekday::Monday, (new DateBounds())->weekStart);
+ $this->assertSame(Weekday::Sunday, (new DateBounds(weekStart: Weekday::Sunday))->weekStart);
+ }
+
+ public function testConstructorRejectsReversedBounds(): void {
+ $this->expectException(ConfigException::class);
+ $this->expectExceptionMessage('Date bounds declare a minimum of 2026-12-31 after the maximum of 2026-01-01.');
+
+ new DateBounds(new \DateTimeImmutable('2026-12-31'), new \DateTimeImmutable('2026-01-01'));
+ }
+
+ /**
+ * Build date bounds from optional ISO date strings.
+ *
+ * @param string|null $min
+ * The minimum date, or NULL for an open lower bound.
+ * @param string|null $max
+ * The maximum date, or NULL for an open upper bound.
+ */
+ protected function bounds(?string $min, ?string $max): DateBounds {
+ return new DateBounds(
+ $min === NULL ? NULL : new \DateTimeImmutable($min),
+ $max === NULL ? NULL : new \DateTimeImmutable($max),
+ );
+ }
+
+}
diff --git a/tests/phpunit/Unit/Config/WeekdayTest.php b/tests/phpunit/Unit/Config/WeekdayTest.php
new file mode 100644
index 0000000..fa05814
--- /dev/null
+++ b/tests/phpunit/Unit/Config/WeekdayTest.php
@@ -0,0 +1,72 @@
+assertSame($expected, $weekday->abbreviation());
+ }
+
+ public static function dataProviderAbbreviation(): \Iterator {
+ yield 'monday' => [Weekday::Monday, 'Mo'];
+ yield 'tuesday' => [Weekday::Tuesday, 'Tu'];
+ yield 'wednesday' => [Weekday::Wednesday, 'We'];
+ yield 'thursday' => [Weekday::Thursday, 'Th'];
+ yield 'friday' => [Weekday::Friday, 'Fr'];
+ yield 'saturday' => [Weekday::Saturday, 'Sa'];
+ yield 'sunday' => [Weekday::Sunday, 'Su'];
+ }
+
+ #[DataProvider('dataProviderFromDate')]
+ public function testFromDate(string $date, Weekday $expected): void {
+ $this->assertSame($expected, Weekday::fromDate(new \DateTimeImmutable($date)));
+ }
+
+ public static function dataProviderFromDate(): \Iterator {
+ // 2026-07-13 is a Monday; the week runs to Sunday 2026-07-19.
+ yield 'monday' => ['2026-07-13', Weekday::Monday];
+ yield 'wednesday' => ['2026-07-15', Weekday::Wednesday];
+ yield 'sunday' => ['2026-07-19', Weekday::Sunday];
+ }
+
+ #[DataProvider('dataProviderSequence')]
+ public function testSequence(Weekday $start, array $expected): void {
+ $this->assertSame($expected, array_map(static fn(Weekday $day): string => $day->abbreviation(), $start->sequence()));
+ }
+
+ public static function dataProviderSequence(): \Iterator {
+ yield 'from monday' => [Weekday::Monday, ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su']];
+ yield 'from sunday' => [Weekday::Sunday, ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa']];
+ yield 'from saturday' => [Weekday::Saturday, ['Sa', 'Su', 'Mo', 'Tu', 'We', 'Th', 'Fr']];
+ }
+
+ #[DataProvider('dataProviderColumnOf')]
+ public function testColumnOf(Weekday $start, Weekday $weekday, int $expected): void {
+ $this->assertSame($expected, $start->columnOf($weekday));
+ }
+
+ public static function dataProviderColumnOf(): \Iterator {
+ yield 'monday start, monday' => [Weekday::Monday, Weekday::Monday, 0];
+ yield 'monday start, wednesday' => [Weekday::Monday, Weekday::Wednesday, 2];
+ yield 'monday start, sunday' => [Weekday::Monday, Weekday::Sunday, 6];
+ yield 'sunday start, sunday' => [Weekday::Sunday, Weekday::Sunday, 0];
+ yield 'sunday start, monday' => [Weekday::Sunday, Weekday::Monday, 1];
+ yield 'sunday start, wednesday' => [Weekday::Sunday, Weekday::Wednesday, 3];
+ }
+
+}
diff --git a/tests/phpunit/Unit/Engine/EngineDeclaredBehaviourTest.php b/tests/phpunit/Unit/Engine/EngineDeclaredBehaviourTest.php
index e1e988b..7086b3d 100644
--- a/tests/phpunit/Unit/Engine/EngineDeclaredBehaviourTest.php
+++ b/tests/phpunit/Unit/Engine/EngineDeclaredBehaviourTest.php
@@ -82,6 +82,18 @@ public function testNumberBoundsRejectOutOfRangeFloat(): void {
$engine->collect(['port' => 50.5], new Context());
}
+ public function testDateBoundsRejectOutOfRange(): void {
+ $engine = $this->engine(function (PanelBuilder $p): void {
+ $p->date('due')->minDate('2026-01-01')->maxDate('2026-12-31');
+ });
+
+ $this->assertSame(['due' => '2026-06-15'], $engine->collect(['due' => '2026-06-15'], new Context()));
+
+ $this->expectException(EngineException::class);
+ $this->expectExceptionMessage('Invalid value for field "due": must be between 2026-01-01 and 2026-12-31.');
+ $engine->collect(['due' => '2027-01-01'], new Context());
+ }
+
public function testDeclaredTransformApplies(): void {
$engine = $this->engine(function (PanelBuilder $p): void {
$p->text('name')->transform(fn (mixed $v): mixed => is_string($v) ? trim($v) : $v);
diff --git a/tests/phpunit/Unit/Resolver/InputResolverTest.php b/tests/phpunit/Unit/Resolver/InputResolverTest.php
index 1137559..ce39dab 100644
--- a/tests/phpunit/Unit/Resolver/InputResolverTest.php
+++ b/tests/phpunit/Unit/Resolver/InputResolverTest.php
@@ -77,6 +77,12 @@ public function testPromptsFromFile(): void {
$this->assertSame('FromFile', $inputs['name']);
}
+ public function testDateCoercionPassesThroughString(): void {
+ $inputs = (new InputResolver('VORTEX_'))->resolve($this->fields(), '', ['VORTEX_DUE' => '2026-07-15']);
+
+ $this->assertSame('2026-07-15', $inputs['due']);
+ }
+
public function testEnvName(): void {
$this->assertSame('VORTEX_MACHINE_NAME', (new InputResolver('VORTEX_'))->envName('machine_name'));
}
@@ -121,6 +127,7 @@ protected function fields(): array {
new Field('vis', 'Visibility', '', FieldType::Toggle, 'public'),
new Field('paths', 'Paths', '', FieldType::MultiFilePicker, []),
new Field('cfg', 'Config', '', FieldType::FilePicker, ''),
+ new Field('due', 'Due', '', FieldType::Date, ''),
];
}
diff --git a/tests/phpunit/Unit/Schema/AgentHelpTest.php b/tests/phpunit/Unit/Schema/AgentHelpTest.php
index f43cfdc..8aba94c 100644
--- a/tests/phpunit/Unit/Schema/AgentHelpTest.php
+++ b/tests/phpunit/Unit/Schema/AgentHelpTest.php
@@ -54,6 +54,21 @@ public function testNumberRangeAnnotation(): void {
$this->assertStringContainsString('plain [number] - Plain', $help);
}
+ public function testDateRangeAnnotation(): void {
+ $config = Form::create('T')
+ ->panel('p', 'p', function (PanelBuilder $p): void {
+ $p->date('due', 'Due date')->minDate('2026-01-01')->maxDate('2026-12-31');
+ $p->date('any', 'Any date');
+ })
+ ->build();
+
+ $help = (new AgentHelp($config))->generate();
+
+ $this->assertStringContainsString('due [date] - Due date (between 2026-01-01 and 2026-12-31)', $help);
+ // With no range declared, the format itself is the annotation.
+ $this->assertStringContainsString('any [date] - Any date (YYYY-MM-DD)', $help);
+ }
+
public function testNoEnvPrefixOmitsEnvLine(): void {
$config = Form::create('T')
->panel('p', 'p', function (PanelBuilder $p): void {
diff --git a/tests/phpunit/Unit/Schema/SchemaGeneratorTest.php b/tests/phpunit/Unit/Schema/SchemaGeneratorTest.php
index 1f11d57..a5f4df5 100644
--- a/tests/phpunit/Unit/Schema/SchemaGeneratorTest.php
+++ b/tests/phpunit/Unit/Schema/SchemaGeneratorTest.php
@@ -7,6 +7,7 @@
use DrevOps\Tui\Builder\Form;
use DrevOps\Tui\Builder\PanelBuilder;
use DrevOps\Tui\Condition\Condition;
+use DrevOps\Tui\Config\Weekday;
use DrevOps\Tui\Derive\Derive;
use DrevOps\Tui\Schema\SchemaGenerator;
use PHPUnit\Framework\Attributes\CoversClass;
@@ -27,6 +28,7 @@ public function testGenerate(): void {
$profile->option('standard', 'Standard', 'Std')->option('minimal', 'Minimal');
$p->text('theme')->derive(new Derive('{{profile}}'))->when(new Condition('profile', eq: 'standard'));
$p->number('port', 'Port')->min(1)->max(65535)->step(5);
+ $p->date('release', 'Release date')->minDate('2000-01-01')->maxDate('2030-12-31')->weekStart(Weekday::Sunday);
})
->build();
@@ -46,6 +48,9 @@ public function testGenerate(): void {
'min' => NULL,
'max' => NULL,
'step' => NULL,
+ 'min_date' => NULL,
+ 'max_date' => NULL,
+ 'week_start' => NULL,
'when' => NULL,
'derive' => NULL,
'discover' => NULL,
@@ -62,6 +67,9 @@ public function testGenerate(): void {
'min' => NULL,
'max' => NULL,
'step' => NULL,
+ 'min_date' => NULL,
+ 'max_date' => NULL,
+ 'week_start' => NULL,
'when' => ['field' => 'profile', 'eq' => 'standard'],
'derive' => ['template' => '{{profile}}'],
'discover' => NULL,
@@ -78,6 +86,28 @@ public function testGenerate(): void {
'min' => 1,
'max' => 65535,
'step' => 5,
+ 'min_date' => NULL,
+ 'max_date' => NULL,
+ 'week_start' => NULL,
+ 'when' => NULL,
+ 'derive' => NULL,
+ 'discover' => NULL,
+ 'depends_on' => [],
+ ],
+ [
+ 'id' => 'release',
+ 'type' => 'date',
+ 'label' => 'Release date',
+ 'description' => '',
+ 'options' => [],
+ 'default' => '',
+ 'required' => FALSE,
+ 'min' => NULL,
+ 'max' => NULL,
+ 'step' => NULL,
+ 'min_date' => '2000-01-01',
+ 'max_date' => '2030-12-31',
+ 'week_start' => Weekday::Sunday->value,
'when' => NULL,
'derive' => NULL,
'discover' => NULL,
@@ -115,6 +145,9 @@ public function testExcludesNonSelectableOptions(): void {
'min' => NULL,
'max' => NULL,
'step' => NULL,
+ 'min_date' => NULL,
+ 'max_date' => NULL,
+ 'week_start' => NULL,
'when' => NULL,
'derive' => NULL,
'discover' => NULL,
diff --git a/tests/phpunit/Unit/Schema/SchemaValidatorTest.php b/tests/phpunit/Unit/Schema/SchemaValidatorTest.php
index 7c4f168..dbc7d55 100644
--- a/tests/phpunit/Unit/Schema/SchemaValidatorTest.php
+++ b/tests/phpunit/Unit/Schema/SchemaValidatorTest.php
@@ -107,6 +107,27 @@ public function testNumberBoundsRejectOutOfRange(): void {
$this->assertContains('Question "port" must be between 1 and 65535.', $validator->validate(['name' => 'Acme', 'port' => 99999]));
}
+ public function testDateAcceptsValidRejectsMalformed(): void {
+ $validator = new SchemaValidator($this->config());
+ $error = 'Question "due" must be a date (YYYY-MM-DD).';
+
+ $this->assertSame([], $validator->validate(['name' => 'Acme', 'due' => '2026-07-15']));
+ // A wrongly-padded value and an impossible calendar date are both rejected.
+ $this->assertContains($error, $validator->validate(['name' => 'Acme', 'due' => '2026-7-5']));
+ $this->assertContains($error, $validator->validate(['name' => 'Acme', 'due' => '2026-02-30']));
+ }
+
+ public function testDateBoundsRejectOutOfRange(): void {
+ $validator = new SchemaValidator($this->config());
+ $error = 'Question "due" must be between 2026-01-01 and 2026-12-31.';
+
+ // The inclusive endpoints are accepted; a date on either side is rejected.
+ $this->assertSame([], $validator->validate(['name' => 'Acme', 'due' => '2026-01-01']));
+ $this->assertSame([], $validator->validate(['name' => 'Acme', 'due' => '2026-12-31']));
+ $this->assertContains($error, $validator->validate(['name' => 'Acme', 'due' => '2025-12-31']));
+ $this->assertContains($error, $validator->validate(['name' => 'Acme', 'due' => '2027-01-01']));
+ }
+
public function testPauseAcceptsBoolRejectsString(): void {
$validator = new SchemaValidator($this->config());
@@ -172,6 +193,7 @@ protected function config(): Config {
$p->multiselect('mods')->option('a')->option('b')->option('c', 'C', disabled: TRUE);
$p->text('custom')->required()->when(new Condition('profile', eq: 'custom'));
$p->number('port')->min(1)->max(65535);
+ $p->date('due')->minDate('2026-01-01')->maxDate('2026-12-31');
$p->pause('ack');
$p->search('engine')->option('solr')->option('none');
$p->multisearch('tags')->option('a')->option('b');
diff --git a/tests/phpunit/Unit/Widget/DateWidgetTest.php b/tests/phpunit/Unit/Widget/DateWidgetTest.php
new file mode 100644
index 0000000..7bba16d
--- /dev/null
+++ b/tests/phpunit/Unit/Widget/DateWidgetTest.php
@@ -0,0 +1,241 @@
+assertSame('2026-07-15', $widget->value());
+ }
+
+ public function testOpensOnTodayWhenEmpty(): void {
+ $widget = new DateWidget();
+
+ $this->assertSame((new \DateTimeImmutable('today'))->format('Y-m-d'), $widget->value());
+ }
+
+ public function testInvalidSeedFallsBackToToday(): void {
+ $widget = new DateWidget('not-a-date');
+
+ $this->assertSame((new \DateTimeImmutable('today'))->format('Y-m-d'), $widget->value());
+ }
+
+ #[DataProvider('dataProviderNavigation')]
+ public function testNavigation(Key $key, string $expected): void {
+ $widget = new DateWidget('2026-07-15');
+
+ $widget->handle($key);
+
+ $this->assertSame($expected, $widget->value());
+ }
+
+ public static function dataProviderNavigation(): \Iterator {
+ yield 'left is previous day' => [Key::named(KeyName::Left), '2026-07-14'];
+ yield 'right is next day' => [Key::named(KeyName::Right), '2026-07-16'];
+ yield 'up is previous week' => [Key::named(KeyName::Up), '2026-07-08'];
+ yield 'down is next week' => [Key::named(KeyName::Down), '2026-07-22'];
+ yield 'page up is previous month' => [Key::named(KeyName::PageUp), '2026-06-15'];
+ yield 'page down is next month' => [Key::named(KeyName::PageDown), '2026-08-15'];
+ yield 'home is first of month' => [Key::named(KeyName::Home), '2026-07-01'];
+ yield 'end is last of month' => [Key::named(KeyName::End), '2026-07-31'];
+ }
+
+ #[DataProvider('dataProviderVimNavigation')]
+ public function testVimNavigation(Key $key, string $expected): void {
+ // Injecting the vim scope map proves day and week movement resolve through
+ // the key bindings: the vim preset reaches the same moves via h/j/k/l.
+ $widget = (new DateWidget('2026-07-15'))->setKeys(KeyMapManager::create('vim')->forField(FieldType::Date));
+
+ $widget->handle($key);
+
+ $this->assertSame($expected, $widget->value());
+ }
+
+ public static function dataProviderVimNavigation(): \Iterator {
+ yield 'h is previous day' => [Key::char('h'), '2026-07-14'];
+ yield 'l is next day' => [Key::char('l'), '2026-07-16'];
+ yield 'k is previous week' => [Key::char('k'), '2026-07-08'];
+ yield 'j is next week' => [Key::char('j'), '2026-07-22'];
+ }
+
+ #[DataProvider('dataProviderPageMonthClampsToShortMonth')]
+ public function testPageMonthClampsToShortMonth(string $seed, Key $key, string $expected): void {
+ $widget = new DateWidget($seed);
+
+ $widget->handle($key);
+
+ $this->assertSame($expected, $widget->value());
+ }
+
+ public static function dataProviderPageMonthClampsToShortMonth(): \Iterator {
+ // Jan 31 has no counterpart in the shorter month, so the day caps to
+ // that month's end.
+ yield 'jan 31 to non-leap feb' => ['2026-01-31', Key::named(KeyName::PageDown), '2026-02-28'];
+ yield 'jan 31 to leap feb' => ['2024-01-31', Key::named(KeyName::PageDown), '2024-02-29'];
+ yield 'mar 31 back to feb' => ['2026-03-31', Key::named(KeyName::PageUp), '2026-02-28'];
+ yield 'oct 31 to nov' => ['2026-10-31', Key::named(KeyName::PageDown), '2026-11-30'];
+ }
+
+ public function testUnhandledKeysAreNoOps(): void {
+ $widget = new DateWidget('2026-07-15');
+
+ // An unmapped character and an unmapped named key both leave the cursor
+ // in place.
+ $widget->handle(Key::char('z'));
+ $widget->handle(Key::named(KeyName::Tab));
+
+ $this->assertSame('2026-07-15', $widget->value());
+ }
+
+ public function testNavigationClampsWithinBounds(): void {
+ $bounds = new DateBounds(new \DateTimeImmutable('2026-07-10'), new \DateTimeImmutable('2026-07-20'));
+ $widget = new DateWidget('2026-07-11', bounds: $bounds);
+
+ // A week back would land before the minimum, so it clamps to the minimum.
+ $widget->handle(Key::named(KeyName::Up));
+ $this->assertSame('2026-07-10', $widget->value());
+
+ // Already on the minimum, a further step left stays on it.
+ $widget->handle(Key::named(KeyName::Left));
+ $this->assertSame('2026-07-10', $widget->value());
+
+ // The end of the month is past the maximum, so it clamps to the maximum.
+ $widget->handle(Key::named(KeyName::End));
+ $this->assertSame('2026-07-20', $widget->value());
+ }
+
+ public function testConstructionClampsSeedIntoBounds(): void {
+ $bounds = new DateBounds(new \DateTimeImmutable('2026-07-10'), new \DateTimeImmutable('2026-07-20'));
+
+ $this->assertSame('2026-07-10', (new DateWidget('2026-07-01', bounds: $bounds))->value());
+ $this->assertSame('2026-07-20', (new DateWidget('2026-07-31', bounds: $bounds))->value());
+ }
+
+ public function testAcceptReturnsIsoDate(): void {
+ $widget = new DateWidget('2026-07-15');
+
+ $value = WidgetRunner::run($widget, ArrayKeyStream::of(Key::named(KeyName::Right), Key::named(KeyName::Enter)));
+
+ $this->assertSame('2026-07-16', $value);
+ $this->assertTrue($widget->isComplete());
+ }
+
+ public function testCancel(): void {
+ $widget = new DateWidget('2026-07-15');
+
+ $widget->handle(Key::named(KeyName::Escape));
+
+ $this->assertTrue($widget->isCancelled());
+ }
+
+ public function testValidatorErrorIsShown(): void {
+ $widget = new DateWidget('2026-07-15', validate: static fn(mixed $value): string => 'No dates allowed.');
+
+ $widget->handle(Key::named(KeyName::Enter));
+
+ $this->assertFalse($widget->isComplete());
+ $this->assertStringContainsString('No dates allowed.', Ansi::strip($widget->view(new DefaultTheme())));
+ }
+
+ public function testRendersCalendar(): void {
+ $widget = new DateWidget('2026-07-15');
+
+ $view = Ansi::strip($widget->view(new DefaultTheme()));
+
+ $this->assertStringContainsString('July 2026', $view);
+ // The cursor day is bracketed.
+ $this->assertStringContainsString('[15]', $view);
+ // The weekday header defaults to a Monday-first week.
+ $this->assertMatchesRegularExpression('/Mo\s+Tu\s+We\s+Th\s+Fr\s+Sa\s+Su/', $view);
+ }
+
+ public function testRendersOwnHint(): void {
+ $widget = new DateWidget('2026-07-15');
+
+ $this->assertTrue($widget->rendersHint());
+
+ $view = Ansi::strip($widget->view(new DefaultTheme()));
+ $this->assertStringContainsString('day', $view);
+ $this->assertStringContainsString('week', $view);
+ $this->assertStringContainsString('month', $view);
+ $this->assertStringContainsString('accept', $view);
+ $this->assertStringContainsString('cancel', $view);
+ }
+
+ public function testWeekStartRotatesHeaderAndLayout(): void {
+ $sunday = Ansi::strip((new DateWidget('2026-07-15', bounds: new DateBounds(weekStart: Weekday::Sunday)))->view(new DefaultTheme()));
+
+ // A Sunday-first week reorders the weekday header.
+ $this->assertMatchesRegularExpression('/Su\s+Mo\s+Tu\s+We\s+Th\s+Fr\s+Sa/', $sunday);
+
+ // July 1, 2026 is a Wednesday. Starting the week on Sunday shifts the
+ // month one column right, so the first row holds only days 1-4 (through
+ // Saturday) and day 5 (Sunday) starts the next row. The default
+ // Monday-first week fits days 1-5 in the first row. The first grid row is
+ // the third rendered line.
+ $monday = Ansi::strip((new DateWidget('2026-07-15'))->view(new DefaultTheme()));
+ $this->assertStringContainsString('5', explode("\n", $monday)[2]);
+ $this->assertStringNotContainsString('5', explode("\n", $sunday)[2]);
+ }
+
+ public function testAsciiRendering(): void {
+ $widget = new DateWidget('2026-07-15');
+ $theme = new DefaultTheme(76, ['unicode' => FALSE, 'color' => FALSE]);
+
+ $view = $widget->view($theme);
+
+ $this->assertStringContainsString('July 2026', $view);
+ // The bracket keeps the cursor day distinguishable without colour.
+ $this->assertStringContainsString('[15]', $view);
+ }
+
+ public function testDimsOutOfRangeDays(): void {
+ $bounds = new DateBounds(new \DateTimeImmutable('2026-07-10'));
+ $widget = new DateWidget('2026-07-15', bounds: $bounds);
+ $theme = new DefaultTheme();
+
+ $view = $widget->view($theme);
+
+ // A day before the minimum is rendered dimmed, not plain.
+ $this->assertStringContainsString($theme->description(sprintf(' %2d ', 5)), $view);
+ // The cursor day stays bracketed and highlighted.
+ $this->assertStringContainsString($theme->highlight('[15]'), $view);
+ }
+
+ public function testDimsDaysPastMaximum(): void {
+ $bounds = new DateBounds(max: new \DateTimeImmutable('2026-07-20'));
+ $widget = new DateWidget('2026-07-15', bounds: $bounds);
+ $theme = new DefaultTheme();
+
+ $view = $widget->view($theme);
+
+ // A day after the maximum is dimmed too, guarding the upper bound.
+ $this->assertStringContainsString($theme->description(sprintf(' %2d ', 25)), $view);
+ }
+
+}
diff --git a/tests/phpunit/Unit/Widget/WidgetFactoryTest.php b/tests/phpunit/Unit/Widget/WidgetFactoryTest.php
index 5a992a0..5a8ad13 100644
--- a/tests/phpunit/Unit/Widget/WidgetFactoryTest.php
+++ b/tests/phpunit/Unit/Widget/WidgetFactoryTest.php
@@ -4,6 +4,7 @@
namespace DrevOps\Tui\Tests\Unit\Widget;
+use DrevOps\Tui\Config\DateBounds;
use DrevOps\Tui\Config\Field;
use DrevOps\Tui\Config\FieldType;
use DrevOps\Tui\Config\NumberBounds;
@@ -14,6 +15,7 @@
use DrevOps\Tui\Input\KeyName;
use DrevOps\Tui\Theme\DefaultTheme;
use DrevOps\Tui\Widget\ConfirmWidget;
+use DrevOps\Tui\Widget\DateWidget;
use DrevOps\Tui\Widget\FilePickerWidget;
use DrevOps\Tui\Widget\MultiSearchWidget;
use DrevOps\Tui\Widget\MultiSelectWidget;
@@ -48,6 +50,7 @@ public function testCreatesByType(): void {
$this->assertInstanceOf(MultiSelectWidget::class, $factory->create($this->fieldWithOptions(FieldType::MultiSelect), ['a']));
$this->assertInstanceOf(SuggestWidget::class, $factory->create($this->fieldWithOptions(FieldType::Suggest), 'a'));
$this->assertInstanceOf(NumberWidget::class, $factory->create($this->field(FieldType::Number), 42));
+ $this->assertInstanceOf(DateWidget::class, $factory->create($this->field(FieldType::Date), '2026-07-15'));
$this->assertInstanceOf(TextareaWidget::class, $factory->create($this->field(FieldType::Textarea), 'x'));
$this->assertInstanceOf(PasswordWidget::class, $factory->create($this->field(FieldType::Password), 'x'));
$this->assertInstanceOf(SearchWidget::class, $factory->create($this->fieldWithOptions(FieldType::Search), 'a'));
@@ -106,6 +109,21 @@ public function testNumberBoundsPassedThrough(): void {
$this->assertSame(6, $widget->value());
}
+ public function testDateBoundsPassedThrough(): void {
+ $field = new Field('f', 'F', '', FieldType::Date, '', dateBounds: new DateBounds(new \DateTimeImmutable('2026-07-10'), new \DateTimeImmutable('2026-07-20')));
+
+ $widget = (new WidgetFactory())->create($field, '2026-07-01');
+
+ // The seed is clamped into the field's declared range.
+ $this->assertSame('2026-07-10', $widget->value());
+ }
+
+ public function testDateWithNonStringCurrentOpensOnToday(): void {
+ $widget = (new WidgetFactory())->create($this->field(FieldType::Date), 42);
+
+ $this->assertSame((new \DateTimeImmutable('today'))->format('Y-m-d'), $widget->value());
+ }
+
public function testSeedsCurrentValue(): void {
$widget = (new WidgetFactory())->create($this->field(FieldType::Text), 'Acme');