|
|
# `<TimeRangeField>` — eidos
|
|
|
|
|
|
Fecha de revisión: 2026-07-10 (dossier S4, re-auditoría 2026-07-07).
|
|
|
|
|
|
Entrada de rango horario segmento a segmento: dos endpoints que SON
|
|
|
`<TimeField>` compuestos (mismo patrón que su gemelo
|
|
|
[`<DateRangeField>`](../date-range-field/README.md)), con validación
|
|
|
compartida (`minValue`/`maxValue`/`validate` de rango) y una Label única que
|
|
|
nombra el rango completo.
|
|
|
|
|
|
## Superficie
|
|
|
|
|
|
```svelte
|
|
|
<script lang="ts">
|
|
|
import { TimeRangeField } from '$uix/eidos/components/time-range-field';
|
|
|
import { Time } from '$libs/days';
|
|
|
let value = $state({ start: new Time(9, 0), end: new Time(17, 30) });
|
|
|
</script>
|
|
|
|
|
|
<TimeRangeField bind:value variant="surface" size="md">
|
|
|
<TimeRangeField.Label>Horario</TimeRangeField.Label>
|
|
|
<TimeRangeField.Input type="start">
|
|
|
{#snippet children({ segments })}
|
|
|
{#each segments as seg}
|
|
|
<TimeRangeField.Segment part={seg.part}>{seg.value}</TimeRangeField.Segment>
|
|
|
{/each}
|
|
|
{/snippet}
|
|
|
</TimeRangeField.Input>
|
|
|
<TimeRangeField.Input type="end">
|
|
|
{#snippet children({ segments })}
|
|
|
{#each segments as seg}
|
|
|
<TimeRangeField.Segment part={seg.part}>{seg.value}</TimeRangeField.Segment>
|
|
|
{/each}
|
|
|
{/snippet}
|
|
|
</TimeRangeField.Input>
|
|
|
</TimeRangeField>
|
|
|
```
|
|
|
|
|
|
Partes: root (provider, `role="group"`), `Label` (compartida, fila propia),
|
|
|
`Input` ×2 (`type='start'|'end'` — cada uno monta un `TimeField.Provider >
|
|
|
TimeField.Input` real), `Segment` (re-export del de time-field — misma receta).
|
|
|
|
|
|
- Props eidos: `variant` / `color` / `size` (vocabulario de TimeField;
|
|
|
los data-attrs cascados del root aterrizan en el ROOT del TimeField
|
|
|
compuesto de cada endpoint).
|
|
|
- Props headless (soma): `value: TimeRange`, `onValueChange`,
|
|
|
`onStartValueChange`/`onEndValueChange`, `placeholder`, `validate`
|
|
|
(validador de rango `{start,end}`), `onInvalid` (razón tipada
|
|
|
`TimeOnInvalid`), `minValue`/`maxValue`, `disabled`, `readonly`,
|
|
|
`start/endReadonlySegments`, `required`, `granularity`, `hideTimeZone`,
|
|
|
`hourCycle`, `locale`, `dir`, `errorMessageId`.
|
|
|
|
|
|
Tokens públicos: `--time-range-field-row-gap`,
|
|
|
`--time-range-field-separator-{glyph,color}`. Nada más a propósito — el
|
|
|
chrome del endpoint es la API de Field/TimeField (ver Decisiones).
|
|
|
|
|
|
## Baseline
|
|
|
|
|
|
Patrón WAI-ARIA Spinbutton por segmento (el que cita time-field single):
|
|
|
<https://www.w3.org/WAI/ARIA/apg/patterns/spinbutton/>. Sin equivalente air.
|
|
|
|
|
|
Soma posee: composición de un TimeField real por endpoint (valor por slice,
|
|
|
placeholder controlado compartido para que un endpoint no mueva el ancla del
|
|
|
otro), validación de rango, flags mergeados con `Field.Provider` (OR).
|
|
|
|
|
|
## Talla y tema
|
|
|
|
|
|
**7 clave(s) pública(s)** en `lib/recipes/base.ts` (bloque `time-range-field`).
|
|
|
Es el contrato vivo: la pestaña **Tokens** de su demo lista estas mismas
|
|
|
claves y las resuelve sobre el escenario.
|
|
|
|
|
|
| Token (`--time-range-field-…`) | Valor por defecto |
|
|
|
| ------------------------------ | ------------------------------ |
|
|
|
| `column-gap` | `var(--space-3)` |
|
|
|
| `row-gap` | `var(--space-1-5)` |
|
|
|
| `label-fg` | `var(--color-content-primary)` |
|
|
|
| `label-font-size` | `var(--size-sm-font-size)` |
|
|
|
| `label-font-weight` | `var(--font-weight-regular)` |
|
|
|
| `separator-fg` | `var(--color-content-muted)` |
|
|
|
| `separator-glyph` | `"'—'"` |
|
|
|
|
|
|
## Comparativa
|
|
|
|
|
|
| Lib | TimeRangeField dedicado | Endpoints segmentados | Validación de rango compartida |
|
|
|
|---|---|---|---|
|
|
|
| React Aria | No (TimeField single; el rango se compone a mano) | ✓ (single) | Manual |
|
|
|
| Bits UI | No (DateRangeField sí; sin variante time) | — | — |
|
|
|
| Ark UI | No | — | — |
|
|
|
| MUI X (Pro) | ✓ (`MultiInputTimeRangeField`) | ✓ | ✓ |
|
|
|
| **Eidos (este)** | ✓ | ✓ (TimeField real ×2) | ✓ (`validate` de rango + min/max) |
|
|
|
|
|
|
El patrón de referencia real es el gemelo interno `DateRangeField` — misma
|
|
|
delegación per-endpoint, misma Label compartida, mismo separador.
|
|
|
|
|
|
## Decisiones
|
|
|
|
|
|
- **Delegación by-design** (`expression: 'delegated'`, veredicto S11a): el
|
|
|
morfo del rango declara 0 eventos. Los eventos sema (commit de segmentos)
|
|
|
los emiten los DOS runtimes de TimeField compuestos, scoped por endpoint —
|
|
|
ahí es donde pertenecen. El provider del rango no posee vocabulario propio.
|
|
|
- **Receta LAYOUT-ONLY**: todo el chrome del endpoint — caja, borde, radio,
|
|
|
padding, focus ring, invalid, disabled, segmentos — viene de `field.css` /
|
|
|
`time-field.css` sobre el `[data-field]` propio de cada endpoint. Esta
|
|
|
receta solo coloca grid (label + endpoints + separador). Cero tokens de
|
|
|
chrome (mandato anti-alias S6).
|
|
|
- **Separador vía `::before`** anclado a la columna central del grid
|
|
|
(`:has([data-endpoint='end'])`), glifo themeable
|
|
|
(`--time-range-field-separator-glyph`, default `—`) — el consumidor no
|
|
|
renderiza `<span>—</span>`.
|
|
|
- **Label compartida un paso por debajo del control** (doctrina de label de
|
|
|
Field); el root NO es `[data-field]`, así que usa los valores foundation
|
|
|
equivalentes.
|
|
|
- **Sin partes `Description`/`Hint`** — es el primitivo *field*; la
|
|
|
descripción pertenece al `<Field>` envolvente (igual que el gemelo).
|
|
|
|
|
|
## Gaps
|
|
|
|
|
|
| Gap | Disposición | Detalle |
|
|
|
| --- | --- | --- |
|
|
|
| El `data-invalid` de RANGO no pinta (end < start valida en soma y marca el root, pero los endpoints solo reciben `disabled/readonly/required` — no el invalid de rango) | **implementar** | Espejo del gap del gemelo drf ("per-endpoint coloring"): propagar el invalid de rango a los endpoints o tintar desde el root. Mismo fix para ambos ranges (ficha F-3/F-1). |
|
|
|
| Sin evento de rango-completo (ambos endpoints fijados) | **diferir** | Decisión conjunta con date-range-field (ficha F-1 compartida); hoy el commit per-endpoint es el contrato. |
|
|
|
| Tests del wrapper de composición | **implementar** | Pasada SYS-2 (rango inválido, readonly segments por endpoint, placeholder compartido). |
|
|
|
|
|
|
## Referencias
|
|
|
|
|
|
- Gemelo: [`date-range-field/README.md`](../date-range-field/README.md)
|
|
|
- Single: [`time-field`](../time-field/) · Soma:
|
|
|
[`src/uix/soma/components/time-range-field/`](../../../soma/components/time-range-field/)
|
|
|
- MUI X Time Range Field: <https://mui.com/x/react-date-pickers/time-range-field/>
|
|
|
- React Aria TimeField: <https://react-spectrum.adobe.com/react-aria/TimeField.html>
|
|
|
- Bits UI Date Range Field (patrón análogo): <https://bits-ui.com/docs/components/date-range-field>
|
|
|
|
|
|
## Audit exceptions
|
|
|
|
|
|
- `R-1.5 exception:` the focus ring lives on each composed endpoint — a REAL `<TimeField>` whose `[data-field]` / `[data-field-control]` gets `field.css`'s `:focus-within` treatment; this recipe is layout-only (see the recipe header).
|
|
|
- `R-1.3 exception:` readonly is forwarded to both endpoint TimeFields (`readonly={provider.isReadonly}`) and paints there via the shared Field layer; the layout-only recipe adds nothing.
|
|
|
- `R-1.4 exception:` endpoint-level invalid (min/max) paints on each composed TimeField via the shared Field layer. RANGE-level invalid not painting is a tracked gap (see Gaps) shared with date-range-field.
|