Date picker
Convierte un <input type="text"> en un campo de fecha con calendario, rango y hora.
En pantalla aparece el formato del idioma de la página; en el formulario va ISO, que es lo que Django
espera. La escritura con máscara sigue valiendo, para quien escribe más rápido de lo que hace clic.
Ejemplos
El mismo <input> con atributos distintos. Debajo de cada campo aparece el valor ISO,
que es lo que llega al servidor.
Fecha y hora
data-time="true" añade las columnas de hora y minuto, de 5 en 5.
Rango con hora
Con hora el panel no se cierra solo: el botón Aplicar confirma.
Minuto a minuto, con segundos
data-minute-step="1" lista de 00 a 59; data-seconds="true" añade la tercera columna.
Límites
data-min y data-max en aaaa-mm-dd. Fuera de ellos el día queda desactivado.
Solo días hábiles, a partir de hoy
disabledDates recibe una función y no existe como atributo — va en JavaScript.
Atajos de rango
data-presets="true" activa Hoy, Últimos 7 días, Este mes y los demás.
Atajos propios
Una lista de { label, value() }, en JavaScript.
Número de semana
data-week-numbers="true" muestra la semana ISO-8601 a la izquierda.
Otro idioma
data-locale="en-US": mes antes del día, semana que empieza el domingo, nombres en inglés.
Formato propio
data-format="dd.MM.yyyy". La máscara sigue el formato sola.
Con icono
El .tuc-input-group del formulario, con un SVG antes del campo.
Con error
aria-invalid="true" en el campo — Django 5 ya lo escribe.
La fecha tiene que ser posterior a hoy.
Cómo usar
Marca el <input> y se inicializa solo al cargar y en cada
htmx:afterSwap. El componente pone la clase .tuc-input en el campo, así que no hace falta escribirla.
<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">| Atributo | Por defecto | Para qué |
|---|---|---|
data-mode | single | range para rango |
data-time | false | Añade el selector de hora |
data-minute-step | 5 | Paso de la columna de minutos; 1 los lista todos |
data-seconds | false | Columna de segundos |
data-min data-max | — | Límites, en aaaa-mm-dd |
data-months | 1, o 2 en rango | Cuántos meses lado a lado |
data-presets | false | true activa los atajos — solo en modo rango |
data-week-numbers | false | Columna con la semana ISO |
data-locale | <html lang> | Nombres, orden de día y mes, inicio de la semana y reloj de 12 o 24 horas |
data-format | del idioma | Formato de visualización, como dd/MM/yyyy |
data-iso-name | el name | name del campo oculto con el ISO |
data-placement | bottom-center | Lado y alineación del panel |
data-native | false | true usa el selector del sistema |
En el formulario de Django
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"}),
}No uses type="date" en el widget: activaría el calendario del navegador encima del nuestro. El valor inicial puede venir
en ISO o en el formato del idioma — el componente lee los dos.
En JavaScript
Para lo que no cabe en un atributo — disabledDates, atajos propios, firstDayOfWeek,
autoApply, clearable, onChange — y para leer y escribir el valor.
const dp = new Tucano.DatePicker('#delivery', {
mode: 'range',
time: true,
minuteStep: 15,
min: '2026-01-01',
disabledDates: (d) => d.getDay() === 0, // cerrado los domingos
onChange: (value, { iso }) => console.log(value, iso),
});
dp.setValue({ start: '2026-03-01', end: '2026-03-15' });
dp.getValue(); // { start: Date, end: Date } — en el modo simple, un Date
dp.clear();
dp.open();
dp.destroy();// Atajos propios: value() devuelve { start, end } y se llama cada vez que se abre.
new Tucano.DatePicker('#period', {
mode: 'range',
presets: [
{ label: 'Próximos 7 días', value: () => {
const today = Tucano.dates.startOfDay(new Date());
return { start: today, end: Tucano.dates.addDays(today, 6) };
} },
],
});Valor y formulario
El campo visible muestra la fecha formateada, como 25/12/2026; un <input type="hidden"> creado al lado lleva el
name y el valor en ISO. El servidor recibe ISO y nunca el texto formateado, que cambiaría con el idioma de quien lo rellenó.
POST date = 2026-12-25
POST start = 2026-12-25T09:30 # DateTimeField lo lee directamente
POST exact = 2026-12-25T09:30:15 # con data-seconds
POST period = 2026-03-01,2026-03-15 # inicio y fin, separados por comaDateField y DateTimeField hacen el parse de esto sin configuración. El rango llega en un solo campo; sepáralo en el 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)Para editar, devuelve el valor en ISO en el value — el componente lee ISO y también el formato del idioma. Con
data-iso-name el campo visible se queda con su propio name y el oculto recibe otro, y los dos se envían.
Un campo sin name y sin data-iso-name no recibe ningún campo oculto.
Cada elección dispara tucano:change en el campo visible, y también el change nativo, así la validación y
hx-trigger="change" ven el valor.
document.querySelector('#due_date').addEventListener('tucano:change', (e) => {
e.detail.value; // Date, o { start, end } en el rango
e.detail.iso; // '2026-12-25' — el mismo texto del campo oculto
e.detail.instance; // el DatePicker
});Aquí e.target.name viene vacío
El evento sale del campo visible, y el name pasó al oculto. Usa e.detail.iso, o lee el oculto, que está justo después del campo.
Escritura
El campo tiene una máscara sacada del propio formato de visualización, así que sigue al idioma sin configuración. Se escriben solo los números; la barra, los dos puntos y la raya del rango se ponen solos.
| Modo | Máscara en pt-BR |
|---|---|
| Fecha | dd/mm/aaaa |
| Fecha y hora | dd/mm/aaaa hh:mm |
| Rango | dd/mm/aaaa — dd/mm/aaaa |
| Rango con hora | dd/mm/aaaa hh:mm — dd/mm/aaaa hh:mm |
Con la máscara completa el calendario ya salta a la fecha escrita, sin cerrarse y sin cambiar el valor. El valor se confirma al salir del campo
o con Enter mientras el panel está abierto, que también lo cierra; Esc descarta lo escrito. Un texto que no se convierte en fecha vuelve al valor anterior, y una fecha fuera de
min, max o disabledDates no se acepta — ni se lleva al límite. Un rango sin un fin válido se rechaza entero. Borrar sobre un separador elimina el dígito
vecino, en lugar de trabarse.
Un formato con nombre de mes (MMMM) o AM/PM no se puede enmascarar; ahí el campo queda libre y vale la
lectura tolerante, que acepta 25/12/26, 25-12-2026, 25122026, 2512 (año en curso) y
12/25/2026 2:05 pm. Escribir solo la fecha en un campo con hora mantiene la hora que ya estaba elegida.
Hacer clic en el mes de la cabecera cambia a la cuadrícula de meses, y en el año, a la de años — así una fecha de nacimiento no cuesta treinta clics en la flecha.
En el móvil
El panel es el mismo del escritorio, adaptado al tacto: texto de 16px, celdas más grandes, un mes debajo del otro en el rango y los atajos en una franja que se desplaza en horizontal.
Los 16px no son estética: Safari de iOS hace zoom en toda la página al enfocar un campo con fuente más pequeña. Por el mismo motivo, en pantalla estrecha táctil el campo no recibe foco ni máscara — tocar abre el panel, y el teclado del sistema no sube a tapar el calendario.
El selector del propio sistema es opcional:
native: false // por defecto: el panel en todas partes
native: 'auto' // el selector del sistema donde el puntero es táctil
native: true // siempre el selector del sistema — como atributo, data-native="true"El modo nativo no cambia el type de tu campo
Cambiar a type="date" hacía que todo CSS escrito como input[type=text] dejara de coincidir, y el campo se volvía un input en bruto del navegador. Ahora un input nativo transparente queda encima, del tamaño del campo, y el POST sigue siendo el del oculto. El rango nunca usa el nativo: no existe selector de intervalo en HTML.
Teclado y accesibilidad
El campo anuncia que abre un dialog, la cuadrícula es role="grid" y cada día tiene
aria-selected y el nombre completo — "viernes, 25 de diciembre de 2026". Llegar con Tab no abre el panel.
| Tecla | Dónde | Acción |
|---|---|---|
↓ | Campo | Abre el calendario |
Espacio | Campo vacío | Abre el calendario |
Enter | Campo, panel abierto | Confirma lo que se escribió y cierra |
← → | Cuadrícula | Día anterior y siguiente |
↑ ↓ | Cuadrícula | Mismo día en la semana anterior y en la siguiente |
PageUp PageDown | Cuadrícula | Mes anterior y siguiente; con Shift, año |
Home End | Cuadrícula | Inicio y fin de la semana |
Enter Espacio | Cuadrícula | Elige el día |
↑ ↓ | Columna de hora | Valor anterior y siguiente de la columna |
Home End | Columna de hora | Primer y último valor de la columna |
Enter Espacio | Columna de hora | Elige el valor |
Tab | Campo, panel abierto | Pasa al siguiente campo y cierra el panel |
Tab | Dentro del panel | Recorre los controles sin salir del panel; cada columna de hora es una sola parada |
Esc | Campo o panel | Cierra, descarta lo que no se confirmó y devuelve el foco al campo |
Por qué Tab no abre, y Enter tampoco
Quien tabula por un formulario hasta el botón de guardar no debería llevarse un calendario en la cara en cada campo, tapando el siguiente — era lo que apilaba paneles. Y este es un campo de texto dentro de un <form>: Enter ahí envía el formulario, que es lo que se espera después de escribir la fecha. Por eso quien abre es Espacio, y solo con el campo vacío, porque con hora se escribe 07/09/2026 14:30.
Un clic fuera cierra sin robar el foco de donde se hizo clic; un rango elegido a medias se descarta al cerrar, porque no
existe medio intervalo, y vuelve el rango que ya estaba elegido. Con el botón Aplicar — el predeterminado con hora, o
autoApply: false —, día, hora y atajo quedan pendientes: solo Aplicar confirma y dispara tucano:change,
una vez, y cerrar con Esc o un clic fuera descarta la elección. Sin él, cada elección vale en el acto. Con prefers-reduced-motion el panel solo se desvanece, sin deslizarse.
API
Generada a partir del código en cada build — si algo no está aquí, no existe.
[data-tuc-datepicker]new Tucano.DatePicker(alvo, opcoes)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-numbersgetValue setValue clear open close toggle destroytucano:changeOpciones
Las notas de esta tabla salen de los comentarios del código fuente, que están escritos en portugués.
| Opción | Por defecto | Para qué |
|---|---|---|
mode | 'single' | 'single' | 'range' |
time | false | true habilita seletor de hora |
seconds | false | |
minuteStep | 5 | |
locale | undefined | default: locale do documento/navegador |
format | undefined | default: padrao numerico do locale |
firstDayOfWeek | undefined | |
months | undefined | default: 2 em range, 1 em single |
min | null | |
max | null | |
disabledDates | null | (date) => boolean |
presets | false | atalhos de periodo (Hoje, Ultimos 7 dias...): opt-in |
autoApply | undefined | default: true sem hora, false com hora. Sem autoApply, a escolha so vale no Aplicar |
clearable | true | |
weekNumbers | false | |
placement | 'bottom-center' | centralizado no campo; as bordas da tela ainda mandam |
appendTo | undefined | |
isoName | undefined | name do input hidden com o valor ISO |
native | false | |
onChange | null | |
onOpen | null | |
onClose | null |