refactor(soma): share segmented field ui helpers

astra
dev 2 weeks ago
parent f1be0f75e5
commit fc49406277

@ -419,6 +419,39 @@ No se modificaron markup, CSS ni comportamiento. El gate completo pasa
27/27 de `apps/base`; el smoke comprueba arranque y consola, pero no constituye
una comparación visual específica de ColorPicker.
### Pre-flight de Soma para utilidades UI de DateField y TimeField (2026-09-23)
El contrato local y las referencias actuales mantienen inputs segmentados con
partes editables y literales, navegación de teclado, localización y validación:
| Aspecto | [React Aria DateField](https://react-spectrum.adobe.com/react-aria/DateField.html) / [TimeField](https://react-spectrum.adobe.com/react-aria/TimeField.html) | [Ark Date Input](https://ark-ui.com/docs/components/date-input) | [Bits DateField](https://www.bits-ui.com/docs/components/date-field) / [TimeField](https://www.bits-ui.com/docs/components/time-field) | Decisión UIX |
| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- |
| Anatomía | Campo con segmentos editables | Root, Label, Control, Input y segmentos | Root, Label, Input y Segment | Conservar providers, partes y llamadas Morfo separadas. |
| Valor y precisión | Objetos de fecha/hora, granularidad y ciclo horario | Granularidad de fecha u hora mediante formatter | Placeholder, granularidad y bindings controlados | No compartir estado, conversión, cascadas ni commit. |
| Interacción | Spinbuttons, teclado y ARIA por segmento | Entrada segmentada localizada | Literales y segmentos diferenciados | Compartir sólo utilidades DOM literalmente iguales. |
La comparación de 2.609 líneas no vacías confirma diferencias reales en
flechas, borrado, readonly, cascadas, commits y atributos de los segmentos. La
actuación se limita a cuatro repeticiones de `soma/datetime`: producir un bag
nuevo de atributos editables para cada provider, resolver de forma protegida el
id descriptivo ya montado y conservar una sola implementación de
`getFirstSegment` y del filtrado DOM por listas de partes permitidas. No cambian
valores, formatos, estado, teclado, foco, ARIA, eventos, markup, CSS ni API
pública. La línea base pasa 2 archivos y 16 pruebas; la auditoría dirigida
obtiene PASS para los dos componentes.
Resultado: DateField y TimeField conservan providers y estado independientes,
pero comparten las cuatro operaciones mecánicas. Los cuatro archivos de
producción pasan de 2.809 a 2.807 líneas no vacías: **2 líneas netas menos** y
cuatro fuentes de divergencia eliminadas. Las pruebas dirigidas de DateField,
TimeField y el consumidor ColorField pasan 21/21; la auditoría mantiene PASS
2/2 y `check:gate` conserva los 89 errores preexistentes dentro del ledger. La
primera suite completa detectó que `focus-census` exige el literal
`tabindex: 0` en cada provider; el parámetro obligatorio conserva esa evidencia
local. La segunda ejecución del gate cierra 474/474 archivos y 5.486/5.486
pruebas, además del build y smoke 27/27 de `apps/base`. No se hizo comparación
visual específica porque no cambian markup, estilos ni atributos resultantes.
### Pre-flight de Soma para las partes de ítem de los dos menús (2026-09-22)
El usuario autorizó la deduplicación interna sin cambios de comportamiento, API

@ -61,8 +61,10 @@ import {
} from '$libs/days';
import {
areAllSegmentsFilledDOM,
createEditableSegmentAttrs,
getAnnouncer,
getFirstSegment,
getMountedDescriptionId,
getSegments,
getValueFromSegmentsDOM,
initSegmentStates,
@ -673,22 +675,7 @@ export class DateFieldProvider {
return out;
});
readonly sharedSegmentAttrs = {
role: 'spinbutton' as const,
contenteditable: 'true' as const,
tabindex: 0,
spellcheck: false,
inputmode: 'numeric' as const,
autocorrect: 'off' as const,
enterkeyhint: 'next' as const,
style: 'caret-color: transparent;',
// Block every form of text insertion (typed chars, paste, drop, IME
// composition, mobile autocomplete). `keydown.preventDefault()` is not
// enough on its own — IME / composition paths bypass it. The segment
// content is driven entirely by `segmentValues`, so the contenteditable
// surface must never accept direct mutations.
onbeforeinput: (e: Event) => e.preventDefault()
};
readonly sharedSegmentAttrs = createEditableSegmentAttrs({ tabindex: 0 });
getLabelledBy(segmentId: string): string {
return `${segmentId} ${this.getLabelNode()?.id ?? ''}`;
@ -969,15 +956,13 @@ export class DateFieldInputProvider {
readonly segments = $derived.by(() => this.provider.segmentContents);
readonly ariaDescribedBy = $derived.by(() => {
try {
const doc = this.provider.soma.dom.getDocument(this.opts.ref.current);
const exists = doc.getElementById(this.provider.descriptionId);
return exists ? this.provider.descriptionId : undefined;
} catch {
return undefined;
}
});
readonly ariaDescribedBy = $derived.by(() =>
getMountedDescriptionId(
this.provider.soma.dom,
this.opts.ref.current,
this.provider.descriptionId
)
);
readonly props = $derived.by(() => {
const labelledBy =

@ -48,8 +48,10 @@ import {
} from '$libs/days';
import {
areAllTimeSegmentsFilledDOM,
createEditableSegmentAttrs,
getAnnouncer,
getFirstSegment,
getMountedDescriptionId,
getSegments,
initTimeSegmentStates,
isAcceptableSegmentKey,
@ -337,17 +339,7 @@ export class TimeFieldProvider {
readonly segmentContents = $derived.by(() => this.allSegmentContent.arr);
readonly sharedSegmentAttrs = {
role: 'spinbutton' as const,
contenteditable: 'true' as const,
tabindex: 0,
spellcheck: false,
inputmode: 'numeric' as const,
autocorrect: 'off' as const,
enterkeyhint: 'next' as const,
style: 'caret-color: transparent;',
onbeforeinput: (e: Event) => e.preventDefault()
};
readonly sharedSegmentAttrs = createEditableSegmentAttrs({ tabindex: 0 });
// ── Label / field helpers ───────────────────────────────────────────────
@ -572,15 +564,13 @@ export class TimeFieldInputProvider {
readonly segments = $derived.by(() => this.provider.segmentContents);
readonly ariaDescribedBy = $derived.by(() => {
try {
const doc = this.provider.soma.dom.getDocument(this.opts.ref.current);
const exists = doc.getElementById(this.provider.descriptionId);
return exists ? this.provider.descriptionId : undefined;
} catch {
return undefined;
}
});
readonly ariaDescribedBy = $derived.by(() =>
getMountedDescriptionId(
this.provider.soma.dom,
this.opts.ref.current,
this.provider.descriptionId
)
);
readonly props = $derived.by(() => {
const labelledBy = this.opts.ariaLabelledBy.current ?? this.provider.labelNode?.id;

@ -30,6 +30,21 @@ import { getSegments } from './segments';
// ── Segment UI state ────────────────────────────────────────────────────────
/** Build the shared editable spinbutton attrs as a fresh object per provider. */
export function createEditableSegmentAttrs({ tabindex }: { tabindex: 0 }) {
return {
role: 'spinbutton' as const,
contenteditable: 'true' as const,
tabindex,
spellcheck: false,
inputmode: 'numeric' as const,
autocorrect: 'off' as const,
enterkeyhint: 'next' as const,
style: 'caret-color: transparent;',
onbeforeinput: (event: Event) => event.preventDefault()
};
}
/**
* Per-segment UI flags used by providers to track typing state:
* - `lastKeyZero` — last keystroke was `'0'` (affects next-digit handling)
@ -76,22 +91,14 @@ export function initTimeSegmentStates(): TimeSegmentStateMap {
* is present (granularity may hide time parts, `hideTimeZone` may hide the
* tz part, etc.).
*/
function getUsedSegments(fieldNode: HTMLElement | null): EditableSegmentPart[] {
if (!fieldNode) return [];
return getSegments(fieldNode)
.map((el) => el.dataset.segment)
.filter((part): part is EditableSegmentPart =>
EDITABLE_SEGMENT_PARTS.includes(part as EditableSegmentPart)
);
}
function getUsedTimeSegments(fieldNode: HTMLElement | null): EditableTimeSegmentPart[] {
function getUsedSegments<Part extends string>(
fieldNode: HTMLElement | null,
parts: readonly Part[]
): Part[] {
if (!fieldNode) return [];
return getSegments(fieldNode)
.map((el) => el.dataset.segment)
.filter((part): part is EditableTimeSegmentPart =>
EDITABLE_TIME_SEGMENT_PARTS.includes(part as EditableTimeSegmentPart)
);
.filter((part): part is Part => parts.includes(part as Part));
}
/** DOM-aware wrapper: reads the used parts list from `fieldNode`. */
@ -102,7 +109,7 @@ export function getValueFromSegmentsDOM(props: {
}): DateValue {
return getValueFromSegmentsPure({
segmentObj: props.segmentObj,
partsList: getUsedSegments(props.fieldNode),
partsList: getUsedSegments(props.fieldNode, EDITABLE_SEGMENT_PARTS),
dateRef: props.dateRef
});
}
@ -112,7 +119,10 @@ export function areAllSegmentsFilledDOM(
segmentValues: SegmentValueObj,
fieldNode: HTMLElement | null
): boolean {
return areAllSegmentsFilledPure(segmentValues, getUsedSegments(fieldNode));
return areAllSegmentsFilledPure(
segmentValues,
getUsedSegments(fieldNode, EDITABLE_SEGMENT_PARTS)
);
}
/** Time-only DOM-aware wrapper: checks filled status against rendered time parts. */
@ -120,7 +130,10 @@ export function areAllTimeSegmentsFilledDOM(
segmentValues: TimeSegmentValueObj,
fieldNode: HTMLElement | null
): boolean {
return areAllTimeSegmentsFilledPure(segmentValues, getUsedTimeSegments(fieldNode));
return areAllTimeSegmentsFilledPure(
segmentValues,
getUsedSegments(fieldNode, EDITABLE_TIME_SEGMENT_PARTS)
);
}
/** True when the element identified by `id` is the first segment in `fieldNode`. */
@ -129,11 +142,6 @@ export function isFirstSegment(id: string, fieldNode: HTMLElement | null): boole
return segments.length ? segments[0]!.id === id : false;
}
/** First focusable segment element, or `undefined` when the field is empty. */
export function getFirstSegment(fieldNode: HTMLElement | null): HTMLElement | undefined {
return getSegments(fieldNode)[0];
}
// ── Keyboard ────────────────────────────────────────────────────────────────
const acceptableSegmentKeys: readonly string[] = [
@ -199,8 +207,21 @@ export function removeDescriptionElement(
}
}
/** Return the description id only while its DOM node exists in the target document. */
export function getMountedDescriptionId(
dom: Pick<ActiveDom, 'getDocument'>,
target: Node | null | undefined,
id: string
): string | undefined {
try {
return dom.getDocument(target).getElementById(id) ? id : undefined;
} catch {
return undefined;
}
}
// Re-export segment discovery for convenience.
export { getSegments } from './segments';
export { getFirstSegment, getSegments } from './segments';
// Re-export segment obj / part types for local use.
export type { DateSegmentObj, DateAndTimeSegmentObj };

@ -10,11 +10,13 @@ export {
getValueFromSegmentsDOM,
areAllSegmentsFilledDOM,
areAllTimeSegmentsFilledDOM,
createEditableSegmentAttrs,
isFirstSegment,
getFirstSegment,
isAcceptableSegmentKey,
setDescription,
removeDescriptionElement
removeDescriptionElement,
getMountedDescriptionId
} from './helpers';
export type { SegmentState, DateSegmentObj, DateAndTimeSegmentObj } from './helpers';

Loading…
Cancel
Save

Powered by TurnKey Linux.