From c61ba78d358db935c91483a30bbfaa2b30154f1d Mon Sep 17 00:00:00 2001 From: dev Date: Wed, 20 May 2026 21:55:08 +0200 Subject: [PATCH] =?UTF-8?q?date-range-picker:=20expand=20morfo=20+=20READM?= =?UTF-8?q?E=20=E2=86=92=20PASS=20audit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- scripts/component-audit.ts | 11 +- .../components/date-range-picker/README.md | 136 +++++++------ src/uix/langs/components/date-range-picker.ts | 12 +- src/uix/morfo/components/date-range-picker.ts | 180 +++++++++++++++++- 4 files changed, 268 insertions(+), 71 deletions(-) diff --git a/scripts/component-audit.ts b/scripts/component-audit.ts index 162147482..0d38db83c 100644 --- a/scripts/component-audit.ts +++ b/scripts/component-audit.ts @@ -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`)); diff --git a/src/uix/eidos/components/date-range-picker/README.md b/src/uix/eidos/components/date-range-picker/README.md index ebc9a2365..315379b65 100644 --- a/src/uix/eidos/components/date-range-picker/README.md +++ b/src/uix/eidos/components/date-range-picker/README.md @@ -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. | diff --git a/src/uix/langs/components/date-range-picker.ts b/src/uix/langs/components/date-range-picker.ts index d09880760..6454edcde 100644 --- a/src/uix/langs/components/date-range-picker.ts +++ b/src/uix/langs/components/date-range-picker.ts @@ -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; diff --git a/src/uix/morfo/components/date-range-picker.ts b/src/uix/morfo/components/date-range-picker.ts index f122af41c..684b87b9c 100644 --- a/src/uix/morfo/components/date-range-picker.ts +++ b/src/uix/morfo/components/date-range-picker.ts @@ -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;