7.0 KiB
<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>), con validación
compartida (minValue/maxValue/validate de rango) y una Label única que
nombra el rango completo.
Superficie
<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 tipadaTimeOnInvalid),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.csssobre 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
::beforeanclado 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 - Single:
time-field· Soma:src/uix/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]getsfield.css's:focus-withintreatment; 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.