From c34fd1fd31608317cb90dc74a96e50aadfe343b1 Mon Sep 17 00:00:00 2001 From: dev Date: Tue, 26 May 2026 19:42:35 +0200 Subject: [PATCH] feat(uix-components): apply persistence + a11ySemantic + live-region messages to 6 morfos MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/uix/langs/components/file-upload.ts | 8 +++++ src/uix/langs/components/tags-input.ts | 4 +++ src/uix/langs/components/textarea.ts | 4 +++ src/uix/morfo/components/announce.ts | 17 ++++++++++- src/uix/morfo/components/file-upload.ts | 16 +++++++++- src/uix/morfo/components/form.ts | 21 +++++++++++++- src/uix/morfo/components/password-field.ts | 19 +++++++++++- src/uix/morfo/components/tags-input.ts | 11 ++++++- src/uix/morfo/components/textarea.ts | 12 +++++++- .../file-upload-provider.svelte.test.ts | 3 +- .../file-upload-provider.svelte.ts | 29 ++++++++++++++++++- src/uix/soma/components/file-upload/langs.ts | 4 ++- .../components/form/form-provider.svelte.ts | 28 +++++++++++++++--- .../password-field-provider.svelte.ts | 16 +++++++++- src/uix/soma/components/tags-input/langs.ts | 3 +- .../tags-input/tags-input-provider.svelte.ts | 29 +++++++++++++------ src/uix/soma/components/textarea/langs.ts | 4 ++- .../textarea/textarea-provider.svelte.ts | 20 ++++++++++++- 18 files changed, 222 insertions(+), 26 deletions(-) diff --git a/src/uix/langs/components/file-upload.ts b/src/uix/langs/components/file-upload.ts index 4c6cd9413..a442a407d 100644 --- a/src/uix/langs/components/file-upload.ts +++ b/src/uix/langs/components/file-upload.ts @@ -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; diff --git a/src/uix/langs/components/tags-input.ts b/src/uix/langs/components/tags-input.ts index 2ab668827..6f8a03500 100644 --- a/src/uix/langs/components/tags-input.ts +++ b/src/uix/langs/components/tags-input.ts @@ -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; diff --git a/src/uix/langs/components/textarea.ts b/src/uix/langs/components/textarea.ts index c16f5dd79..1a883671f 100644 --- a/src/uix/langs/components/textarea.ts +++ b/src/uix/langs/components/textarea.ts @@ -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; diff --git a/src/uix/morfo/components/announce.ts b/src/uix/morfo/components/announce.ts index cea5c5abf..3b821c9fe 100644 --- a/src/uix/morfo/components/announce.ts +++ b/src/uix/morfo/components/announce.ts @@ -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 } }, { diff --git a/src/uix/morfo/components/file-upload.ts b/src/uix/morfo/components/file-upload.ts index 62b06c9df..f174bdc9b 100644 --- a/src/uix/morfo/components/file-upload.ts +++ b/src/uix/morfo/components/file-upload.ts @@ -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' } }, { diff --git a/src/uix/morfo/components/form.ts b/src/uix/morfo/components/form.ts index 28d4ad04b..5845702bf 100644 --- a/src/uix/morfo/components/form.ts +++ b/src/uix/morfo/components/form.ts @@ -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' } }, { diff --git a/src/uix/morfo/components/password-field.ts b/src/uix/morfo/components/password-field.ts index ad349c64f..6fe3b9e72 100644 --- a/src/uix/morfo/components/password-field.ts +++ b/src/uix/morfo/components/password-field.ts @@ -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' } } ], diff --git a/src/uix/morfo/components/tags-input.ts b/src/uix/morfo/components/tags-input.ts index 0e15ccf08..0aa792537 100644 --- a/src/uix/morfo/components/tags-input.ts +++ b/src/uix/morfo/components/tags-input.ts @@ -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' } }, { diff --git a/src/uix/morfo/components/textarea.ts b/src/uix/morfo/components/textarea.ts index c9db5e142..134d66d83 100644 --- a/src/uix/morfo/components/textarea.ts +++ b/src/uix/morfo/components/textarea.ts @@ -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' } } ], diff --git a/src/uix/soma/components/file-upload/file-upload-provider.svelte.test.ts b/src/uix/soma/components/file-upload/file-upload-provider.svelte.test.ts index f9c8e5fa2..bd921ae4a 100644 --- a/src/uix/soma/components/file-upload/file-upload-provider.svelte.test.ts +++ b/src/uix/soma/components/file-upload/file-upload-provider.svelte.test.ts @@ -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) => createSomaRuntime(morfo, { diff --git a/src/uix/soma/components/file-upload/file-upload-provider.svelte.ts b/src/uix/soma/components/file-upload/file-upload-provider.svelte.ts index 38d2f6b44..4a39d3a49 100644 --- a/src/uix/soma/components/file-upload/file-upload-provider.svelte.ts +++ b/src/uix/soma/components/file-upload/file-upload-provider.svelte.ts @@ -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); } diff --git a/src/uix/soma/components/file-upload/langs.ts b/src/uix/soma/components/file-upload/langs.ts index d48de66d2..415891082 100644 --- a/src/uix/soma/components/file-upload/langs.ts +++ b/src/uix/soma/components/file-upload/langs.ts @@ -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; diff --git a/src/uix/soma/components/form/form-provider.svelte.ts b/src/uix/soma/components/form/form-provider.svelte.ts index 1eaa49f97..6987a2616 100644 --- a/src/uix/soma/components/form/form-provider.svelte.ts +++ b/src/uix/soma/components/form/form-provider.svelte.ts @@ -122,11 +122,26 @@ export class FormProvider { 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 diff --git a/src/uix/soma/components/password-field/password-field-provider.svelte.ts b/src/uix/soma/components/password-field/password-field-provider.svelte.ts index b3d8f6813..eef5643eb 100644 --- a/src/uix/soma/components/password-field/password-field-provider.svelte.ts +++ b/src/uix/soma/components/password-field/password-field-provider.svelte.ts @@ -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) { diff --git a/src/uix/soma/components/tags-input/langs.ts b/src/uix/soma/components/tags-input/langs.ts index 1496c5d31..636e3a160 100644 --- a/src/uix/soma/components/tags-input/langs.ts +++ b/src/uix/soma/components/tags-input/langs.ts @@ -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; diff --git a/src/uix/soma/components/tags-input/tags-input-provider.svelte.ts b/src/uix/soma/components/tags-input/tags-input-provider.svelte.ts index 1d06d02f1..f3f46e0ad 100644 --- a/src/uix/soma/components/tags-input/tags-input-provider.svelte.ts +++ b/src/uix/soma/components/tags-input/tags-input-provider.svelte.ts @@ -90,13 +90,28 @@ export class TagsInputProvider { return root.querySelectorAll(`[${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); } } diff --git a/src/uix/soma/components/textarea/langs.ts b/src/uix/soma/components/textarea/langs.ts index 647a333ec..e737f2775 100644 --- a/src/uix/soma/components/textarea/langs.ts +++ b/src/uix/soma/components/textarea/langs.ts @@ -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; diff --git a/src/uix/soma/components/textarea/textarea-provider.svelte.ts b/src/uix/soma/components/textarea/textarea-provider.svelte.ts index ccb6d9103..28773c921 100644 --- a/src/uix/soma/components/textarea/textarea-provider.svelte.ts +++ b/src/uix/soma/components/textarea/textarea-provider.svelte.ts @@ -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;