feat(uix-components): apply persistence + a11ySemantic + live-region messages to 6 morfos

Per the canonical book §6.2 + §9.2 mapping, six morfos with `signal.warn`,
`signal.alert` or `signal.notify` events get explicit `persistence` +
`a11ySemantic` annotations, and their providers wire up `clearTarget` +
caller-message integration with the shared live region.

**Morfo annotations**:

| Morfo / event                              | persistence    | a11ySemantic                                            |
|--------------------------------------------|----------------|---------------------------------------------------------|
| announce.signal-alert                      | untilAction    | requiresPersistentTrace + requiresLiveRegion            |
| form.signal-warn-invalid                   | untilFix       | requiresPersistentTrace + requiresFocusMove + reducedMotionFallback='text' |
| file-upload.signal-warn-reject             | untilFix       | requiresPersistentTrace + reducedMotionFallback='text'  |
| password-field.signal-notify-caps-state    | stateBound     | requiresLiveRegion + reducedMotionFallback='text'       |
| tags-input.signal-warn-reject              | untilFix       | requiresPersistentTrace + reducedMotionFallback='text'  |
| textarea.signal-warn-count-overflow        | untilFix       | requiresPersistentTrace + reducedMotionFallback='text'  |

**Provider wiring**:
- form-provider: clearTarget on submit, message="{N} form errors..." via
  FORM_LANGS.ERROR_SUMMARY_*.
- file-upload-provider: clearTarget before each accept/reject cycle, message
  via new FILE_UPLOAD_LANGS.REJECT_SUMMARY_{SINGLE,MULTI}.
- password-field-provider: clearTarget on caps-off transition, message
  via existing PASSWORD_FIELD_LANGS.CAPS_WARNING.
- tags-input-provider: helper `emitWarnReject` (clear + emit + message
  via new TAGS_INPUT_LANGS.REJECT_WARNING), called from addTagValue,
  addFromInput, handlePaste. Successful add also clears (the add is the fix).
- textarea-provider: emits only on transition INTO overflow, clears in
  transition OUT (untilFix projection persists). Message via new
  TEXTAREA_LANGS.OVERFLOW_WARNING with {{max}}.

Three new lang catalog entries (reject/overflow warnings) merged into
the existing components.{name}.* namespaces.

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

@ -27,5 +27,13 @@ export const fileUploadLangs = {
remove: {
es: "Eliminar archivo",
en: "Remove file"
},
'reject-summary-single': {
es: 'Se rechazó 1 archivo',
en: '1 file was rejected'
},
'reject-summary-multi': {
es: 'Se rechazaron {{count}} archivos',
en: '{{count}} files were rejected'
}
} satisfies LangNode;

@ -19,5 +19,9 @@ export const tagsInputLangs = {
clear: {
es: "Limpiar todas las etiquetas",
en: "Clear all tags"
},
'reject-warning': {
es: 'No se pudo añadir la etiqueta',
en: 'Tag could not be added'
}
} satisfies LangNode;

@ -15,5 +15,9 @@ export const textareaLangs = {
count: {
es: '{{count}} de {{max}}',
en: '{{count}} of {{max}}'
},
'overflow-warning': {
es: 'Límite de {{max}} caracteres alcanzado',
en: 'Character limit of {{max}} reached'
}
} satisfies LangNode;

@ -31,13 +31,28 @@ export const announceMorfo = {
// Assertive message posted (error or urgent notice — interrupts speech).
// Per book cap. 24: `signal.alert + threat` — "atiende ahora."
// Was misclassified as commit.submit + threat.
//
// Persistence: book §6.2 — signal.threat = untilAction. The
// projection stays until the caller clears (typically when the
// user acknowledges the alert via a Toast action, dismisses the
// banner, or the next message overwrites it).
name: 'signal-alert',
semantic: {
family: 'signal',
verb: 'alert',
target: v.partRef('provider'),
intent: 'threat',
sequence: 'post'
sequence: 'post',
persistence: 'untilAction'
},
// Book §9.2: signal.alert + threat needs a persistent trace AND
// a live region. Focus move is handled by the assertive aria-live
// behavior of the underlying region — we don't yank focus
// programmatically because Announce is visually hidden; doing so
// would teleport sighted users to nowhere.
a11ySemantic: {
requiresPersistentTrace: true,
requiresLiveRegion: true
}
},
{

@ -33,13 +33,27 @@ export const fileUploadMorfo = {
}
},
{
// Persistence: book §6.2 — signal.risk = untilFix. A rejected
// file (too big, wrong type, too many) must keep its warning
// visible until the user removes the offending file or chooses
// a valid one. Provider clears via `runtime.clearTarget(provider)`
// when the file list becomes valid.
name: 'signal-warn-reject',
semantic: {
family: 'signal',
verb: 'warn',
target: v.partRef('provider'),
intent: 'risk',
sequence: 'post'
sequence: 'post',
persistence: 'untilFix'
},
// Book §9.2: signal.warn + risk ⇒ persistentTrace +
// reducedMotionFallback='text'. Caller passes the rejection
// reason as `message` so the live region announces e.g.
// "File too large — choose under 5MB."
a11ySemantic: {
requiresPersistentTrace: true,
reducedMotionFallback: 'text'
}
},
{

@ -22,13 +22,32 @@ export const formMorfo = {
}
},
{
// Persistence: book §6.2 — signal.risk = untilFix. A validation
// warning must stay perceptible until the user fixes the field;
// auto-clearing after 240 ms would hide the warning the moment
// the user looks away. Form provider clears it by calling
// `runtime.clearSignal(id)` when validation passes (or
// `runtime.clearTarget(formElement)` to clear all field warnings
// at once on a successful submit).
name: 'signal-warn-invalid',
semantic: {
family: 'signal',
verb: 'warn',
target: v.partRef('provider'),
intent: 'risk',
sequence: 'post'
sequence: 'post',
persistence: 'untilFix'
},
// Book §9.2: signal.warn + risk ⇒ persistentTrace +
// reducedMotionFallback='text'. Focus move is also useful here
// because the provider can land focus on the first invalid
// field; the runtime focuses `target` (the form element), and
// `focusFirstInvalid()` in the provider drills further from
// there.
a11ySemantic: {
requiresPersistentTrace: true,
requiresFocusMove: true,
reducedMotionFallback: 'text'
}
},
{

@ -75,13 +75,30 @@ export const passwordFieldMorfo = {
// cap. 24, this is the system orienting attention to a relevant
// condition without urgency (`signal.notify + neutral`). Not a
// context shift; not a commit (the user didn't decide anything).
//
// Persistence: `stateBound`. The indicator's lifecycle tracks
// the caps-lock OS state, not a single emit. Provider emits on
// turn-on (signal stays projected) and clears on turn-off via
// `runtime.clearSignal(id)` so the visual cue mirrors reality.
// Without this, the indicator would flash for 240ms and vanish
// while caps lock is still on — a regression.
name: 'signal-notify-caps-state',
semantic: {
family: 'signal',
verb: 'notify',
target: v.partRef('caps-lock-indicator'),
intent: 'neutral',
sequence: 'post'
sequence: 'post',
persistence: 'stateBound'
},
// Live region: caps lock is a discoverability problem for users
// who can't see the indicator (visually impaired, password
// field with masked dots). Announcing it politely solves this
// without interrupting typing flow. No persistent trace flag —
// the indicator IS the trace.
a11ySemantic: {
requiresLiveRegion: true,
reducedMotionFallback: 'text'
}
}
],

@ -22,13 +22,22 @@ export const tagsInputMorfo = {
}
},
{
// Book §6.2 — signal.risk = untilFix. A rejected tag (duplicate,
// invalid format, max limit reached) keeps the warning visible
// until the user fixes the input. Provider clears via
// `runtime.clearTarget(input)` on the next accept/reject pass.
name: 'signal-warn-reject',
semantic: {
family: 'signal',
verb: 'warn',
target: v.partRef('input'),
intent: 'risk',
sequence: 'post'
sequence: 'post',
persistence: 'untilFix'
},
a11ySemantic: {
requiresPersistentTrace: true,
reducedMotionFallback: 'text'
}
},
{

@ -45,13 +45,23 @@ export const textareaMorfo = {
// a corregible warning (signal.warn + risk), NOT a context shift:
// the limit was reached, the user can correct by deleting; nothing
// in the operational regime changed.
//
// Persistence: book §6.2 — signal.risk = untilFix. The warning
// stays projected while the user is at the cap; provider clears
// via `runtime.clearTarget(input)` the moment the value drops
// back under maxLength.
name: 'signal-warn-count-overflow',
semantic: {
family: 'signal',
verb: 'warn',
target: v.partRef('input'),
intent: 'risk',
sequence: 'post'
sequence: 'post',
persistence: 'untilFix'
},
a11ySemantic: {
requiresPersistentTrace: true,
reducedMotionFallback: 'text'
}
}
],

@ -37,7 +37,8 @@ function installSomaHarness() {
const soma = {
dom,
langs: {
ts: (key: string) => key
ts: (key: string) => key,
t: (key: string) => key
},
runtime: (morfo: Morfo, sources: Omit<SomaRuntimeSources, 'dom' | 'eventEngine'>) =>
createSomaRuntime(morfo, {

@ -188,9 +188,29 @@ export class FileUploadProvider {
this.opts.onFilesChange.current?.(next);
void this.runtime.trigger('commit-set-add', target ? { fallbackTarget: target } : undefined);
}
// `signal-warn-reject` is `untilFix` (book §6.2): we clear any prior
// rejection warning on the provider before reading the new accept/
// reject outcome. If the next call has no rejections, the user
// effectively fixed it — no need to re-emit, the clear is the fix.
const provider = this.runtime.partRef('provider');
if (provider) this.runtime.clearTarget(provider);
if (rejected.length > 0) {
this.opts.onReject.current?.(rejected);
void this.runtime.trigger('signal-warn-reject', target ? { fallbackTarget: target } : undefined);
// Resolve the live-region message (a11ySemantic.requiresLiveRegion +
// reducedMotionFallback='text'). Singular vs plural by rejected
// count. Authors get a localized summary; per-file reasons live
// in the inline UI / onReject callback consumer surface.
const message =
rejected.length === 1
? this.soma.langs.ts(FILE_UPLOAD_LANGS.REJECT_SUMMARY_SINGLE)
: this.soma.langs.t(FILE_UPLOAD_LANGS.REJECT_SUMMARY_MULTI, {
count: String(rejected.length)
});
void this.runtime.trigger('signal-warn-reject', {
...(target ? { fallbackTarget: target } : {}),
message
});
}
}
@ -199,6 +219,10 @@ export class FileUploadProvider {
const next = this.opts.files.current.filter((_, i) => i !== index);
this.opts.files.current = next;
this.opts.onFilesChange.current?.(next);
// Removing a file can resolve a TOO_MANY_FILES rejection — clear any
// outstanding warn signal so the indicator doesn't lie about state.
const provider = this.runtime.partRef('provider');
if (provider) this.runtime.clearTarget(provider);
void this.runtime.trigger('commit-remove', target ? { fallbackTarget: target } : undefined);
}
@ -206,6 +230,9 @@ export class FileUploadProvider {
if (this.isDisabled) return;
this.opts.files.current = [];
this.opts.onFilesChange.current?.([]);
// Wiping the list clears any rejection state along with it.
const provider = this.runtime.partRef('provider');
if (provider) this.runtime.clearTarget(provider);
void this.runtime.trigger('commit-reset', target ? { fallbackTarget: target } : undefined);
}

@ -4,5 +4,7 @@ export const FILE_UPLOAD_LANGS = {
TRIGGER: '#?components.file-upload.trigger|Select files',
REMOVE: '#?components.file-upload.remove|Remove file',
CLEAR: '#?common.buttons.clear|Clear all',
LIST: '#?components.file-upload.list|Selected files'
LIST: '#?components.file-upload.list|Selected files',
REJECT_SUMMARY_SINGLE: '#?components.file-upload.reject-summary-single|1 file was rejected',
REJECT_SUMMARY_MULTI: '#?components.file-upload.reject-summary-multi|{{count}} files were rejected'
} as const;

@ -122,11 +122,26 @@ export class FormProvider<TForm extends Form = Form> {
e.preventDefault();
const ok = await this.form.submit(e);
const target = this.opts.ref?.current;
// `signal-warn-invalid` is `untilFix` (book §6.2): clear any prior
// warning before deciding whether to re-emit (failed) or skip
// (succeeded). The clear IS the fix when the form now validates.
const provider = this.runtime.partRef('provider');
if (provider) this.runtime.clearTarget(provider);
if (!ok) {
void this.runtime.trigger(
'signal-warn-invalid',
target ? { fallbackTarget: target } : undefined
);
// Resolve the error-summary text from the existing FORM_LANGS
// catalog so the live region (a11ySemantic.requiresLiveRegion)
// announces it. Singular vs plural by # of invalid fields.
const invalidFieldCount = Object.keys(this.form.issues).length;
const message =
invalidFieldCount <= 1
? this.soma.langs.ts(FORM_LANGS.ERROR_SUMMARY_SINGLE)
: this.soma.langs.t(FORM_LANGS.ERROR_SUMMARY_TITLE, {
count: String(invalidFieldCount)
});
void this.runtime.trigger('signal-warn-invalid', {
...(target ? { fallbackTarget: target } : {}),
message
});
this.focusFirstInvalid();
return;
}
@ -218,6 +233,11 @@ export class FormResetProvider {
readonly onclick = () => {
this.provider.form.reset();
const target = this.provider.opts.ref?.current;
// Reset wipes the form state — outstanding warn signals are no
// longer applicable. Clear before reset so the UI doesn't briefly
// show a stale warning on the freshly reset form.
const provider = this.provider.runtime.partRef('provider');
if (provider) this.provider.runtime.clearTarget(provider);
void this.provider.runtime.trigger(
'commit-reset',
target ? { fallbackTarget: target } : undefined

@ -226,7 +226,21 @@ export class PasswordFieldProvider {
setCapsActive(active: boolean) {
if (this.capsActive === active) return;
this.capsActive = active;
void this.runtime.trigger('signal-notify-caps-state');
// `signal-notify-caps-state` is `stateBound` (book §6.2): the engine
// keeps `data-event-*` projected past the hold so eidos can keep the
// indicator styled while caps lock is on. When caps lock goes off,
// the provider OWNS the cleanup — without `clearTarget` here, the
// indicator would stay tinted as if caps were still active.
const indicator = this.runtime.partRef('caps-lock-indicator');
if (indicator) this.runtime.clearTarget(indicator);
if (active) {
// `a11ySemantic.requiresLiveRegion` is true; the message is the
// localized "Caps Lock is on" string already shipped in the
// password-field langs catalog.
void this.runtime.trigger('signal-notify-caps-state', {
message: this.soma.langs.ts(PASSWORD_FIELD_LANGS.CAPS_WARNING)
});
}
}
updateCapsFromEvent(e: KeyboardEvent | FocusEvent | undefined) {

@ -1,5 +1,6 @@
/** Idlangref constants for the TagsInput component. */
export const TAGS_INPUT_LANGS = {
REMOVE: '#?components.tags-input.remove|Remove tag',
CLEAR: '#?components.tags-input.clear|Clear all tags'
CLEAR: '#?components.tags-input.clear|Clear all tags',
REJECT_WARNING: '#?components.tags-input.reject-warning|Tag could not be added'
} as const;

@ -90,13 +90,28 @@ export class TagsInputProvider {
return root.querySelectorAll<HTMLElement>(`[${attrs.item}]`)[index] ?? undefined;
}
private emitWarnReject(target?: HTMLElement): void {
// `signal-warn-reject` is `untilFix` (book §6.2): clear any prior
// warning on the input before re-emitting, so the next valid
// keystroke / paste / Enter clears the warning naturally.
const input = this.runtime.partRef('input');
if (input) this.runtime.clearTarget(input);
void this.runtime.trigger('signal-warn-reject', {
fallbackTarget: target ?? this.inputRef.current ?? undefined,
message: this.soma.langs.ts(TAGS_INPUT_LANGS.REJECT_WARNING)
});
}
private emitAddResult(result: AddTagResult, target?: HTMLElement): void {
if (result === 'added') {
// A successful add is the natural "fix" for any outstanding
// reject warning — clear it so the live region doesn't keep
// announcing stale state.
const input = this.runtime.partRef('input');
if (input) this.runtime.clearTarget(input);
void this.runtime.trigger('commit-set-add', { fallbackTarget: target });
} else if (result === 'rejected') {
void this.runtime.trigger('signal-warn-reject', {
fallbackTarget: target ?? this.inputRef.current ?? undefined
});
this.emitWarnReject(target);
}
}
@ -185,9 +200,7 @@ export class TagsInputProvider {
});
}
if (rejected) {
void this.runtime.trigger('signal-warn-reject', {
fallbackTarget: target ?? this.inputRef.current ?? undefined
});
this.emitWarnReject(target);
}
}
@ -213,9 +226,7 @@ export class TagsInputProvider {
});
}
if (rejected) {
void this.runtime.trigger('signal-warn-reject', {
fallbackTarget: target ?? this.inputRef.current ?? undefined
});
this.emitWarnReject(target);
}
}

@ -1,5 +1,7 @@
/** Idlangref constants for the TextArea component. */
export const TEXTAREA_LANGS = {
LABEL: '#?components.textarea.label|Text area',
COUNT: '#?components.textarea.count|{{count}} of {{max}}'
COUNT: '#?components.textarea.count|{{count}} of {{max}}',
OVERFLOW_WARNING:
'#?components.textarea.overflow-warning|Character limit of {{max}} reached'
} as const;

@ -199,9 +199,27 @@ export class TextAreaProvider {
let clamped = next;
if (max !== undefined && next.length > max) {
clamped = next.slice(0, max);
const wasOverflow = this.overflow;
this.overflow = true;
void this.runtime.trigger('signal-warn-count-overflow');
// `signal-warn-count-overflow` is `untilFix` (book §6.2). Only
// re-emit on the TRANSITION into overflow — repeated emits while
// already in overflow would replace the projection unnecessarily.
// The `untilFix` projection persists, so subsequent keystrokes at
// the cap don't need re-announcement.
if (!wasOverflow) {
void this.runtime.trigger('signal-warn-count-overflow', {
message: this.soma.langs.t(TEXTAREA_LANGS.OVERFLOW_WARNING, {
max: String(max)
})
});
}
} else {
if (this.overflow) {
// Transitioning out of overflow — clear the persistent warn
// so the indicator + live region reflect the fix.
const input = this.runtime.partRef('input');
if (input) this.runtime.clearTarget(input);
}
this.overflow = false;
}
if (clamped === this.opts.value.current) return;

Loading…
Cancel
Save

Powered by TurnKey Linux.