date-range-picker: expand morfo + README → PASS audit

Closes incidencia 2026-05-20 #7 plus the remaining audit gaps:

- Morfo now declares the full event surface for the picker layer:
  `open` (emerge, with intent fromProp), `close-range-commit` (commit
  fulfill), `close-cancel` / `close-dismiss` / `close-dismiss-outside`
  (emerge), and `commit-clear` (commit reset). Adds `data-last-action`
  on Calendar for causal exit tinting, plus `aria-label`, archetype
  classification, keyboard bindings (Enter/Space on Trigger; Escape +
  Tab focus on Calendar), and `apg` URL pointing at the W3C dialog-modal
  pattern.
- `texts.label` added with full catalog entry under
  `src/uix/langs/components/date-range-picker.ts` so A-1.3 passes.
- README rewritten with the required structure: Baseline (air / terra /
  soma actual), Comparativa table (React Aria / Bits UI / Ark UI),
  Decisiones, and Gaps with explicit disposition markers
  (implementar/diferir/descartar). The pending modal mode + clear
  button + demo control prune go in Gaps as `implementar`/`diferir`.
- Fix a pre-existing audit-script regex bug: section-extraction
  regexes used `m` flag with `$` lookahead, which matched end-of-line
  and truncated section bodies to their headers. Switched to
  `\n## ` as the explicit section boundary.

Audit: date-range-picker BROKEN → PASS. Project-wide BROKEN count
0 (was 2 before, now tooltip remains as NEEDS-WORK).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
active-uix
dev 5 months ago
parent 9728e76975
commit c61ba78d35

@ -618,8 +618,11 @@ function checkReadme(kebab: string, info: ComponentReport): CheckResult[] {
if (/##\s*Baseline\b/m.test(src)) out.push(pass('F-1.1', 'error'));
else out.push(fail('F-1.1', 'error', 'Missing ## Baseline section'));
// F-1.2 Comparativa (3+ external references)
const compMatch = src.match(/##\s*Comparativa\b[\s\S]*?(?=##\s|$)/m);
// F-1.2 Comparativa (3+ external references).
// Use `\n## ` as the section terminator. The previous regex used `$` with
// the `m` flag, which matched end-of-line and truncated the section body
// to just the header.
const compMatch = src.match(/##\s*Comparativa\b[\s\S]*?(?=\n##\s|$)/);
if (compMatch) {
// Count rows in the comparison table
const rows = compMatch[0].match(/^\|/gm) || [];
@ -634,8 +637,8 @@ function checkReadme(kebab: string, info: ComponentReport): CheckResult[] {
if (/##\s*Decisiones\b/m.test(src)) out.push(pass('F-1.3', 'warn'));
else out.push(fail('F-1.3', 'warn', 'Missing ## Decisiones section'));
// F-1.4 Gaps with disposition
const gapsMatch = src.match(/##\s*Gaps\b[\s\S]*?(?=##\s|$)/m);
// F-1.4 Gaps with disposition. Same regex fix as Comparativa above.
const gapsMatch = src.match(/##\s*Gaps\b[\s\S]*?(?=\n##\s|$)/);
if (gapsMatch) {
const dispositions = (gapsMatch[0].match(/\b(implementar|diferir|descartar)\b/gi) || []).length;
if (dispositions >= 1) out.push(pass('F-1.4', 'error', `${dispositions} disposition markers`));

@ -1,56 +1,84 @@
# Eidos DateRangePicker
## Status
`DateRangePicker` is the Eidos visual wrapper over the existing Soma
composition. It does not own range state or validation logic; those remain in
Soma through `DateRangePicker.Provider`, `DateRangeField`, `RangeCalendar` and
`Popover`.
## Incidencia abierta 2026-05-20
No cerrar este componente. El usuario prescindio de Codex durante esta tanda
porque el DateRangePicker seguia fallando visual y funcionalmente:
- Al seleccionar la fecha final en el segundo calendario, el popup podia
desplazarse automaticamente al mes siguiente. Ese re-anclaje no debe ocurrir
al cerrar el rango.
- Al deseleccionar una fecha final se limpiaban ambos endpoints; debe limpiarse
solo el endpoint seleccionado y conservar el otro.
- La seleccion con limites (`minValue`, `maxValue`, `minDays`, `maxDays`) se
comportaba de forma erratica y no comunicaba bien que fechas estaban
habilitadas ni por que.
- La fecha inicial y final deben tener tratamientos visuales distintos; la
seleccion inicial no puede quedar indistinguible o transparente.
- Faltan modos modal/no-modal, accion explicita de seleccionar/cerrar en modal
y boton de borrar.
- `start segments`, `end segments` y `paged nav` aparecieron como controles de
demo aunque no se visualizaban de forma util en el preview.
- El Morfo de DateRangePicker/RangeCalendar no cubre toda la superficie
necesaria de `data-*`, estados, eventos y partes. El demo no debe inventar
atributos para tapar esa carencia.
## Baselines
- `air`: no DateRangePicker component in `morfo-runtime`; only calendar visuals
are usable as visual precedent.
- `terra` in `morfo-runtime`: had a composite date-range-picker over
date-range-field, range-calendar and popover.
- Current `soma`: adds split `startReadonlySegments` and
`endReadonlySegments`, `allowSingleDay`, `isDateHoliday`, week numbers and
navigation announcements.
- Reference frameworks: React Aria `DateRangePicker`, Ark UI `Date Picker`
range mode and Bits UI `Date Range Picker`.
## Decisions
- Eidos exports one root namespace: `DateRangePicker`.
- Public `Provider` is not exposed in Eidos; the root component wraps Soma's
provider and stamps visual `data-size`, `data-variant` and `data-color`.
- Calendar sub-parts wrap Soma `date-range-picker` exports directly, not
Eidos `Calendar`, because the active context is `RangeCalendar`.
- The visual recipe targets `data-range-calendar-*` attributes emitted by Soma
and mirrors the base calendar treatment while adding range-track states.
- Morfo/Sema are not closed. The wrapper composes DateRangeField,
RangeCalendar and Popover; the public docs and demo must show the composed
event surface and every Morfo-backed attr used by the recipe.
> Composite component: segmented `DateRangeField` + `Popover` + `RangeCalendar`.
> Eidos wraps Soma's composition without owning range state or validation —
> those remain in Soma through `DateRangePicker.Provider` + child providers.
## Estado
Incidencia 2026-05-20 (parcialmente atendida):
- ✓ Auto-paging del popup en selección eliminado (incidencia #1).
- ✓ Click sobre endpoint de rango completo limpia solo ese endpoint
(incidencia #2).
- ✓ Tratamientos visuales `start`/`end` diferenciados con stripe + swap a
`secondary` cuando el `data-color` colisiona con `affirm/fulfill`
(incidencia #4).
- ✓ Raw hex purgados; tokens de eidos regenerados.
- ✓ Morfo declara eventos (`open`, `close-range-commit`, `close-cancel`,
`close-dismiss`, `close-dismiss-outside`, `commit-clear`) y `data-last-action`.
- ⚠️ Modo modal/no-modal con acciones explícitas (incidencia #5) — todavía
no implementado.
- ⚠️ Botón `clear` explícito en la UI — todavía no implementado.
- ⚠️ Controles de demo `start segments` / `end segments` / `paged nav` —
pendiente decidir si exponer en demo o sólo en API doc.
## Baseline
- **air**: no había componente `DateRangePicker` en `morfo-runtime`; sólo
visuales de calendario sirven como precedente.
- **terra** (`morfo-runtime`): componente compuesto sobre `date-range-field`,
`range-calendar` y `popover`. Es la referencia directa de este wrapper.
- **soma actual**: añade `startReadonlySegments`/`endReadonlySegments`,
`allowSingleDay`, `isDateHoliday`, números de semana y anuncios de
navegación accesibles.
## Comparativa
| Aspecto | UIX DateRangePicker | React Aria | Bits UI | Ark UI |
| --- | --- | --- | --- | --- |
| Composición | Field + Popover + RangeCalendar | Field + Popover + RangeCalendar | Field + Popover + RangeCalendar | Field + Popover + RangeCalendar |
| `minDays` / `maxDays` | ✓ | ✗ | ✗ | ✗ |
| `allowSingleDay` | ✓ | ✗ | ✗ | ✗ |
| `isDateHoliday` | ✓ | ✗ | ✗ | ✗ |
| Segmentos `readonly` por endpoint | ✓ | ✗ | parcial | ✗ |
| Eventos sema declarativos | ✓ (5 events + intent) | n/a | n/a | n/a |
| `data-last-action` para tinte de salida | ✓ | ✗ | ✗ | ✗ |
| Modos modal/no-modal | ✗ (pendiente) | ✓ | ✗ | ✗ |
| Auto-page al seleccionar en mes final | ✗ (deliberado) | ✗ | ✓ | ✓ |
| Limpieza por endpoint individual | ✓ | parcial | ✗ | ✗ |
| Distinción visual start/end | ✓ (stripe + swap) | ✓ | parcial | parcial |
## Decisiones
- **Eidos no expone `Provider`** — el root wrapper monta `DateRangePicker.Provider`
internamente y estampa `data-size` / `data-variant` / `data-color`. Los
consumidores manipulan estado por props (`bind:value`, `bind:placeholder`,
`bind:open`), no accediendo al provider.
- **El calendario es un `range-calendar`, no un `calendar`**. Sub-partes
envuelven los exports de `soma/date-range-picker` directamente, no los de
Eidos `Calendar`, porque el contexto activo es `RangeCalendarProvider`.
- **Selección nunca re-ancla el placeholder**. La navegación entre meses es
siempre explícita por parte del usuario (prev/next/month-select). Click en
cualquier fecha — incluso en el último mes visible — mantiene el rango
visible donde estaba (incidencia #1).
- **Limpieza es por endpoint, no global**. Click en un endpoint de un rango
completo elimina sólo ese endpoint y re-ancla en el otro (incidencia #2).
Para limpiar todo el rango se usa el botón `clear` (futuro) o
`value = { start: undefined, end: undefined }` desde el caller.
- **Start usa `affirm` por defecto, end usa el `data-color` accent**. Cuando
el accent colisiona (`data-color="affirm"`/`"fulfill"`), el start cambia a
`secondary` para mantener distinción perceptiva (incidencia #4).
- **El morfo declara eventos del picker** (`open`, `close-*`, `commit-clear`).
Eventos de celda (`commit-start`, `commit-range`, `shift-navigate`) los
emite `range-calendar`; el picker no los duplica.
## Gaps
| ID | Disposición | Detalle |
| --- | --- | --- |
| Modal mode con `Save`/`Cancel` (#5) | **implementar** | Prop `mode: 'modal' \| 'inline'`. Modal añade Footer + SaveButton + CancelButton; click en celdas no auto-cierra. Requiere extensión del morfo (Footer/Save/Cancel parts) y nuevo evento `close-save`. |
| Botón `Clear` explícito | **implementar** | Limpia ambos endpoints. Emite `commit-clear` (ya declarado). Va en el header del calendario o en el field input. |
| Demo: `start segments` / `end segments` / `paged nav` | **diferir** | Mover a sección API-only del README; quitar de los controles interactivos del demo si no mejoran el preview. |
| `data-disabled-reason` en celdas para min/maxDays | **diferir** | El cell ya emite `data-disabled`; el "porqué" (out-of-bounds vs range-length) puede añadirse vía `data-disabled-reason` para que el screen reader explique. No bloqueante. |
| Tests browser-level del flujo completo | **implementar** | Falta cobertura visual (Playwright) del flujo abrir → seleccionar start → seleccionar end → commit/dismiss. |

@ -8,12 +8,16 @@ import type { LangNode } from '$libs/langs';
* `'#?components.date-range-picker.{key}|fallback'`.
*/
export const dateRangePickerLangs = {
label: {
es: 'Selector de rango de fechas',
en: 'Date range picker'
},
trigger: {
es: "Abrir calendario de rango",
en: "Open range calendar"
es: 'Abrir calendario de rango',
en: 'Open range calendar'
},
calendar: {
es: "Calendario de rango de fechas",
en: "Date range calendar"
es: 'Calendario de rango de fechas',
en: 'Date range calendar'
}
} satisfies LangNode;

@ -1,15 +1,138 @@
import type { Morfo } from '../types';
import { v } from '../types';
/**
* DateRangePicker — composite popover containing a DateRangeField (segmented
* inputs) + RangeCalendar. The morfo declares the picker's own surface
* (trigger + calendar shell + dismissal events); cell-level events and
* range tracking are owned by the `range-calendar` morfo.
*/
export const dateRangePickerMorfo = {
name: 'DateRangePicker',
kebab: 'date-range-picker',
scope: ['soma'],
scope: ['soma', 'sema'],
apg: 'https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/',
texts: {
label: '#?components.date-range-picker.label|Date range picker',
trigger: '#?components.date-range-picker.trigger|Open range calendar',
calendar: '#?components.date-range-picker.calendar|Date range calendar'
},
events: [
{
name: 'open',
semantic: {
family: 'emerge',
verb: 'open',
target: v.partRef('calendar'),
sequence: 'pre',
intent: {
fromProp: 'intent',
default: 'neutral',
supported: ['neutral', 'affirm', 'risk']
}
},
commits: {
part: v.partRef('calendar'),
attr: 'data-state',
value: 'open'
}
},
{
// The user committed a complete range and the popover closed.
// `fulfill` because the picker's job — pick a complete range — is done.
name: 'close-range-commit',
semantic: {
family: 'commit',
verb: 'save',
target: v.partRef('calendar'),
sequence: 'post',
intent: 'fulfill'
},
regime: 'lock',
prewrite: [
{ part: v.partRef('calendar'), attr: 'data-last-action', value: 'range-committed' }
],
commits: {
part: v.partRef('calendar'),
attr: 'data-state',
value: 'closed'
}
},
{
// Explicit cancel from a Save/Cancel button (modal mode).
name: 'close-cancel',
semantic: {
family: 'emerge',
verb: 'close',
target: v.partRef('calendar'),
sequence: 'pre'
},
regime: 'lock',
prewrite: [
{ part: v.partRef('calendar'), attr: 'data-last-action', value: 'cancelled' }
],
commits: {
part: v.partRef('calendar'),
attr: 'data-state',
value: 'closed'
}
},
{
// Escape key.
name: 'close-dismiss',
semantic: {
family: 'emerge',
verb: 'dismiss',
target: v.partRef('calendar'),
sequence: 'pre'
},
regime: 'lock',
prewrite: [
{ part: v.partRef('calendar'), attr: 'data-last-action', value: 'dismissed' }
],
commits: {
part: v.partRef('calendar'),
attr: 'data-state',
value: 'closed'
}
},
{
// Click outside the popover.
name: 'close-dismiss-outside',
semantic: {
family: 'emerge',
verb: 'dismiss',
target: v.partRef('calendar'),
sequence: 'pre'
},
regime: 'lock',
prewrite: [
{
part: v.partRef('calendar'),
attr: 'data-last-action',
value: 'dismissed-outside'
}
],
commits: {
part: v.partRef('calendar'),
attr: 'data-state',
value: 'closed'
}
},
{
// User cleared the range (explicit clear button or backspace).
name: 'commit-clear',
semantic: {
family: 'commit',
verb: 'reset',
target: v.partRef('calendar'),
sequence: 'post',
intent: 'neutral'
}
}
],
focus: {
initial: 'first-focusable',
trap: true,
@ -27,11 +150,11 @@ export const dateRangePickerMorfo = {
optional: false,
states: ['open', 'closed'],
data: [
{ attr: 'data-state', values: ['open', 'closed'] },
{ attr: 'data-disabled', severity: 'optional' },
{ attr: 'data-readonly', severity: 'optional' },
{ attr: 'data-required', severity: 'optional' },
{ attr: 'data-invalid', severity: 'optional' }
{ attr: 'data-state', values: ['open', 'closed'], value: v.stateRef('open') },
{ attr: 'data-disabled', value: v.propRef('disabled'), severity: 'optional' },
{ attr: 'data-readonly', value: v.propRef('readonly'), severity: 'optional' },
{ attr: 'data-required', value: v.propRef('required'), severity: 'optional' },
{ attr: 'data-invalid', value: v.propRef('invalid'), severity: 'optional' }
],
aria: [
{ attr: 'aria-disabled', value: v.propRef('disabled'), severity: 'optional' },
@ -48,7 +171,7 @@ export const dateRangePickerMorfo = {
role: 'button',
optional: false,
states: ['open', 'closed'],
data: [{ attr: 'data-state', values: ['open', 'closed'] }],
data: [{ attr: 'data-state', values: ['open', 'closed'], value: v.stateRef('open') }],
aria: [
{ attr: 'type', value: v.literal('button') },
{ attr: 'aria-haspopup', value: v.literal('dialog') },
@ -64,17 +187,56 @@ export const dateRangePickerMorfo = {
value: v.translationRef('#?components.date-range-picker.trigger|Open range calendar'),
severity: 'recommended'
}
],
keyboard: [
{ key: 'Enter', action: 'open' },
{ key: ' ', action: 'open' }
]
},
{
name: 'Calendar',
kebab: 'calendar',
archetype: 'content',
kind: 'public',
defaultElement: 'div',
role: 'dialog',
optional: false,
data: [],
aria: []
states: ['open', 'closed'],
data: [
{ attr: 'data-state', values: ['open', 'closed'], value: v.stateRef('open') },
{
/**
* Causal exit reason — mirror of dialog's `data-last-action`.
* Updated by `prewrite` BEFORE `data-state` flips to `closed`,
* so Sema can tint the exit animation per-action.
*/
attr: 'data-last-action',
values: ['range-committed', 'cancelled', 'dismissed', 'dismissed-outside'],
severity: 'optional'
},
{
attr: 'data-starting-style',
severity: 'optional',
condition: { when: 'state-equals', state: 'open', value: 'starting' }
},
{
attr: 'data-ending-style',
severity: 'optional',
condition: { when: 'state-equals', state: 'closed', value: 'ending' }
}
],
aria: [
{
attr: 'aria-label',
value: v.translationRef('#?components.date-range-picker.calendar|Date range calendar'),
severity: 'recommended'
}
],
keyboard: [
{ key: 'Escape', action: 'close' },
{ key: 'Tab', action: 'focus-next' },
{ key: 'Shift+Tab', action: 'focus-prev' }
]
}
]
} as const satisfies Morfo;

Loading…
Cancel
Save

Powered by TurnKey Linux.