Tucano v0.37.2

Date picker

Turns an <input type="text"> into a date field with a calendar, ranges and time. On screen it shows the format of the page's language; the form sends ISO, which is what Django expects. Masked typing still works, for people who type faster than they click.

Examples

The same <input> with different attributes. Below each field is the ISO value, which is what reaches the server.

Date and time

data-time="true" adds the hour and minute columns, in steps of 5.

Range with time

With time the panel doesn't close by itself: the Apply button confirms.

Every minute, with seconds

data-minute-step="1" lists 00 to 59; data-seconds="true" adds the third column.

Limits

data-min and data-max in yyyy-mm-dd. Outside them the day is disabled.

Weekdays only, from today

disabledDates takes a function and doesn't exist as an attribute — it goes in JavaScript.

Range presets

data-presets="true" turns on Today, Last 7 days, This month and the others.

Custom presets

A list of { label, value() }, in JavaScript.

Week number

data-week-numbers="true" shows the ISO-8601 week on the left.

A specific locale

data-locale="en-US": month before day, week starting on Sunday, names in English — whatever the page language.

Custom format

data-format="dd.MM.yyyy". The mask follows the format by itself.

With an icon

The form's .tuc-input-group, with an SVG before the field.

With an error

aria-invalid="true" on the field — Django 5 already writes it.

The date must be after today.

How to use

Mark the <input> and it initializes by itself on load and on every htmx:afterSwap. The component puts the .tuc-input class on the field, so you don't need to write it.

<input type="text" name="date" data-tuc-datepicker>
<input type="text" name="when" data-tuc-datepicker data-time="true">
<input type="text" name="period" data-tuc-datepicker data-mode="range">
<input type="text" name="window" data-tuc-datepicker data-mode="range" data-time="true">
AttributeDefaultWhat for
data-modesinglerange for a date range
data-timefalseAdds the time picker
data-minute-step5Step of the minutes column; 1 lists them all
data-secondsfalseSeconds column
data-min data-max—Limits, in yyyy-mm-dd
data-months1, or 2 for a rangeHow many months side by side
data-presetsfalsetrue turns on the presets — range mode only
data-week-numbersfalseColumn with the ISO week
data-locale<html lang>Names, day and month order, first day of the week and 12- or 24-hour clock
data-formatfrom the localeDisplay format, such as dd/MM/yyyy
data-iso-namethe namename of the hidden field with the ISO value
data-placementbottom-centerSide and alignment of the panel
data-nativefalsetrue uses the system picker

In the Django form

class EventForm(forms.ModelForm):
    class Meta:
        model = Event
        fields = ["date", "start", "period"]
        widgets = {
            "date": forms.TextInput(attrs={"data-tuc-datepicker": ""}),
            "start": forms.TextInput(attrs={"data-tuc-datepicker": "", "data-time": "true"}),
            "period": forms.TextInput(attrs={"data-tuc-datepicker": "", "data-mode": "range"}),
        }

Don't use type="date" on the widget: it would turn on the browser's calendar on top of ours. The initial value can come in ISO or in the locale's format — the component reads both.

In JavaScript

For what doesn't fit in an attribute — disabledDates, custom presets, firstDayOfWeek, autoApply, clearable, onChange — and to read and write the value.

const dp = new Tucano.DatePicker('#delivery', {
  mode: 'range',
  time: true,
  minuteStep: 15,
  min: '2026-01-01',
  disabledDates: (d) => d.getDay() === 0,   // closed on Sundays
  onChange: (value, { iso }) => console.log(value, iso),
});

dp.setValue({ start: '2026-03-01', end: '2026-03-15' });
dp.getValue();   // { start: Date, end: Date } — in single mode, a Date
dp.clear();
dp.open();
dp.destroy();
// Custom presets: value() returns { start, end } and is called every time the panel opens.
new Tucano.DatePicker('#period', {
  mode: 'range',
  presets: [
    { label: 'Next 7 days', value: () => {
      const today = Tucano.dates.startOfDay(new Date());
      return { start: today, end: Tucano.dates.addDays(today, 6) };
    } },
  ],
});

Value and form

The visible field shows the formatted date, such as 25/12/2026 in pt-BR; an <input type="hidden"> created next to it carries the name and the value in ISO. The server receives ISO and never the formatted text, which would change with the language of whoever filled it in.

POST  date    = 2026-12-25
POST  start   = 2026-12-25T09:30          # DateTimeField reads it directly
POST  exact   = 2026-12-25T09:30:15       # with data-seconds
POST  period  = 2026-03-01,2026-03-15     # start and end, separated by a comma

DateField and DateTimeField parse this with no configuration. The range arrives in a single field; split it in clean_:

class BookingForm(forms.Form):
    period = forms.CharField()

    def clean_period(self):
        start, _, end = self.cleaned_data["period"].partition(",")
        return date.fromisoformat(start), date.fromisoformat(end)

To edit, send the value back in ISO in value — the component reads ISO and also the locale's format. With data-iso-name the visible field keeps its own name and the hidden one gets another, and both are posted. A field with no name and no data-iso-name gets no hidden field at all.

Every choice fires tucano:change on the visible field, and also the native change, so validation and hx-trigger="change" see the value.

document.querySelector('#due_date').addEventListener('tucano:change', (e) => {
  e.detail.value;      // Date, or { start, end } for a range
  e.detail.iso;        // '2026-12-25' — the same text as the hidden field
  e.detail.instance;   // the DatePicker
});

Here e.target.name is empty

The event comes from the visible field, and the name moved to the hidden one. Use e.detail.iso, or read the hidden field, which sits right after the visible one.

Typing

The field has a mask taken from the display format itself, so it follows the locale with no configuration. You type only the digits; slash, colon and the range dash go in by themselves.

ModeMask in pt-BR
Datedd/mm/aaaa
Date and timedd/mm/aaaa hh:mm
Rangedd/mm/aaaa — dd/mm/aaaa
Range with timedd/mm/aaaa hh:mm — dd/mm/aaaa hh:mm

Once the mask is complete the calendar jumps to the typed date, without closing and without changing the value. The value is confirmed when leaving the field or with Enter while the panel is open, which also closes it; Esc discards what was typed. Text that doesn't become a date reverts to the previous value, and a date outside min, max or disabledDates is not accepted — nor pulled to the limit. A range without a valid end is refused whole. Deleting over a separator removes the neighboring digit, instead of getting stuck.

A format with a month name (MMMM) or AM/PM can't be masked; there the field stays free and the tolerant parser applies, accepting 25/12/26, 25-12-2026, 25122026, 2512 (current year) and 12/25/2026 2:05 pm. Typing only the date in a field with time keeps the time that was already chosen.

Clicking the month in the header switches to the month grid, and clicking the year, to the year grid — so a date of birth doesn't cost thirty clicks on the arrow.

On mobile

The panel is the same as on desktop, adapted to touch: 16px text, larger cells, one month below the other for ranges and the presets in a strip that scrolls horizontally.

The 16px aren't about looks: iOS Safari zooms the whole page when focusing a field with a smaller font. For the same reason, on narrow touch screens the field gets neither focus nor mask — tapping opens the panel, and the system keyboard doesn't rise to cover the calendar.

The system's own picker is optional:

native: false     // default: the panel everywhere
native: 'auto'    // the system picker where the pointer is touch
native: true      // always the system picker — as an attribute, data-native="true"

Native mode doesn't change your field's type

Switching to type="date" made every CSS rule written as input[type=text] stop matching, and the field turned into a raw browser input. Now a transparent native input sits on top, the size of the field, and the POST is still the hidden field's. Ranges never use native mode: there is no range picker in HTML.

Keyboard and accessibility

The field announces that it opens a dialog, the grid is role="grid" and each day has aria-selected and its full name — "Friday, December 25, 2026". Arriving with Tab doesn't open the panel.

KeyWhereAction
↓FieldOpens the calendar
SpaceEmpty fieldOpens the calendar
EnterField, panel openConfirms what was typed and closes
← →GridPrevious and next day
↑ ↓GridSame day in the previous and next week
PageUp PageDownGridPrevious and next month; with Shift, year
Home EndGridStart and end of the week
Enter SpaceGridChooses the day
↑ ↓Time columnPrevious and next value in the column
Home EndTime columnFirst and last value in the column
Enter SpaceTime columnChooses the value
TabField, panel openMoves on to the next field and closes the panel
TabInside the panelMoves between controls without leaving the panel; each time column is a single stop
EscField or panelCloses, discards what wasn't confirmed and returns focus to the field

Why Tab doesn't open it, and neither does Enter

Someone tabbing through a form to the save button shouldn't get a calendar in their face at every field, covering the next one — that's what stacked panels. And this is a text field inside a <form>: Enter there submits the form, which is what you expect after typing the date. That's why Space is what opens it, and only with the field empty, because with time you type 07/09/2026 14:30.

Clicking outside closes it without stealing focus from where you clicked; a half-chosen range is discarded on close, because there is no such thing as half an interval, and the range that was already chosen comes back. With the Apply button — the default with time, or autoApply: false —, day, time and preset stay pending: only Apply confirms and fires tucano:change, once, and closing with Esc or a click outside discards the choice. Without it, each choice takes effect right away. With prefers-reduced-motion the panel only fades, without sliding.

API

Generated from the code on every build — if something is not here, it does not exist.

Markup
[data-tuc-datepicker]
In JS
new Tucano.DatePicker(alvo, opcoes)
Attributes
data-format data-iso-name data-locale data-max data-min data-minute-step data-mode data-months data-native data-placement data-presets data-seconds data-time data-week-numbers
Methods
getValue setValue clear open close toggle destroy
Events
tucano:change

Options

The notes in this table come from comments in the source code, which are written in Portuguese.

OptionDefaultWhat for
mode'single''single' | 'range'
timefalsetrue habilita seletor de hora
secondsfalse
minuteStep5
localeundefineddefault: locale do documento/navegador
formatundefineddefault: padrao numerico do locale
firstDayOfWeekundefined
monthsundefineddefault: 2 em range, 1 em single
minnull
maxnull
disabledDatesnull(date) => boolean
presetsfalseatalhos de periodo (Hoje, Ultimos 7 dias...): opt-in
autoApplyundefineddefault: true sem hora, false com hora. Sem autoApply, a escolha so vale no Aplicar
clearabletrue
weekNumbersfalse
placement'bottom-center'centralizado no campo; as bordas da tela ainda mandam
appendToundefined
isoNameundefinedname do input hidden com o valor ISO
nativefalse
onChangenull
onOpennull
onClosenull