fix(soma): el dropzone de file-upload deja de robar clicks ajenos — un gesto, una ocurrencia

Lo habia dejado ANOTADO en la ola 6 por no cascadear, y estaba mal
clasificado: es la clase que este eje persigue (una ocurrencia por gesto),
encontrada por mi propia medicion y en un fichero que ya estaba editando.

Y al medirlo de verdad, el mecanismo resulto MAS ANCHO que el diagnostico de
la nota. No es solo el Trigger burbujeando: el <input type=file> oculto vive
tambien DENTRO del Dropzone, asi que el click() SINTETICO que dispara
openPicker vuelve a entrar por el mismo manejador. Medido en navegador: UN
click en el dropzone llegaba DOS veces, la segunda con
target = INPUT[hidden-input] — solo el guard de reentrada que la plataforma
pone en click() lo paraba ahi. O sea que TODO click al dropzone duplicaba, no
unicamente el del boton.

Arreglo: guard por PROPIEDAD en el onclick del dropzone — `closest` sobre los
marcadores de `trigger` y `hidden-input`, construidos con partMarkerAttr y
nunca a mano, para que un renombrado de parte rompa aqui en vez de ensanchar
el alcance en silencio. Es el idioma que table ya practica.

Test visto FALLAR con la anidacion real del DOM (trigger + hidden input
dentro del dropzone) y los tres casos: la superficie propia abre, el input
oculto no re-abre, el boton no re-abre. Medido despues en navegador: click en
el dropzone → UN contact-trigger-picker en DROPZONE; click en el boton → UNO
en TRIGGER.

file-upload 5/5 · check 69 = base, 0 propios.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
alpha-0.1-dir-prefs
dev 2 months ago
parent 4854df3c47
commit e1cedb81ba

@ -87,15 +87,23 @@ supresión por labelledby sobre un prop sin default.
**Deuda menor, medida y sin urgencia** (no la re-descubras):
- ⚠️ **file-upload emite `contact-trigger-picker` DOS veces por un gesto**
(medido 2026-08-13): el Trigger vive DENTRO del Dropzone, cuyo `onclick` no
filtra el click burbujeado, así que pulsar el botón dispara los dos
manejadores — dos ocurrencias y dos `hiddenInput.click()` para una sola
mano. **PREEXISTENTE**, verificado contra HEAD: el `onclick` del dropzone es
idéntico byte a byte antes de la ola de colecciones. Es la clase «una
ocurrencia por gesto» que la doctrina nombra; el arreglo es un guard de
propiedad en el dropzone (`e.target` dentro del trigger → no re-abrir), y no
se tocó por no cascadear la ola.
- ~~⚠️ **file-upload emite `contact-trigger-picker` DOS veces por un gesto**~~
**ARREGLADO 2026-08-13**, y el mecanismo era MÁS ANCHO que el diagnóstico
inicial: no es sólo el Trigger burbujeando — el `<input type=file>` oculto
también vive DENTRO del Dropzone, así que el `click()` SINTÉTICO que dispara
`openPicker` vuelve a entrar por el mismo manejador. Medido: **un** click en
el dropzone llegaba **dos** veces, la segunda con
`target = INPUT[hidden-input]`; sólo el guard de reentrada que la plataforma
pone en `click()` lo paraba en dos. Es decir, TODO click al dropzone
duplicaba, no sólo el del botón. Arreglo: guard **por propiedad** en el
`onclick` del dropzone (`closest` sobre los marcadores de `trigger` y
`hidden-input`, construidos con `partMarkerAttr`, nunca a mano). Test visto
fallar con la anidación real; medido después: click en el dropzone → UN
`contact-trigger-picker` en DROPZONE, click en el botón → UNO en TRIGGER.
⚠️ Lección de clasificación: lo anoté como «adyacente, no cascadear» y era
la clase que este eje persigue —una ocurrencia por gesto—, encontrada por mi
propia medida en un fichero que ya estaba editando. Eso se arregla, no se
anota.
- **10 de 17 harnesses tienen un `ts` que IGNORA su clave** — una clave errónea
*pero existente* pasaría su aserción del bag. Lo tapa parcialmente

@ -225,6 +225,58 @@ describe('FileUploadProvider', () => {
dom.dispose();
});
it('the dropzone owns only ITS OWN click — one gesture, one picker (2026-08-13)', async () => {
const { dom } = installSomaHarness();
const opts = createFileUploadOpts();
const { result: provider, cleanup } = withEffectRoot(() => FileUploadProvider.create(opts));
vi.spyOn(FileUploadProvider.ctx, 'get').mockReturnValue(provider);
// The real DOM shape, which is what produces the defect: BOTH the
// trigger and the hidden input live INSIDE the dropzone.
const dzEl = document.createElement('div');
const triggerEl = document.createElement('button');
triggerEl.setAttribute('data-file-upload-trigger', '');
const hiddenEl = document.createElement('input');
hiddenEl.type = 'file';
hiddenEl.setAttribute('data-file-upload-hidden-input', '');
dzEl.append(triggerEl, hiddenEl);
document.body.appendChild(dzEl);
const dropzone = FileUploadDropzoneProvider.create({
id: state('file-upload-dropzone'),
ref: state<HTMLElement | null>(dzEl)
});
FileUploadHiddenInputProvider.create({
id: state('file-upload-input'),
ref: state<HTMLElement | null>(hiddenEl)
});
await tick();
const opened = vi.spyOn(provider, 'openPicker');
// 1 · The user clicks the dropzone surface itself: it owns that gesture.
dropzone.onclick({ target: dzEl } as never);
expect(opened).toHaveBeenCalledTimes(1);
// 2 · `openPicker` fires `hiddenInput.click()`, and the hidden input is a
// DESCENDANT — the synthetic click bubbles straight back into this same
// handler. Re-opening there mints a second occurrence (and a second
// picker) for ONE hand. Measured in the browser 2026-08-13: a single
// dropzone click reached the handler twice, the second with
// `target = INPUT[hidden-input]`.
dropzone.onclick({ target: hiddenEl } as never);
expect(opened).toHaveBeenCalledTimes(1);
// 3 · Same for the trigger inside it: the button owns its own click and
// emits with ITS surface, so the dropzone must stay out of the way.
dropzone.onclick({ target: triggerEl } as never);
expect(opened).toHaveBeenCalledTimes(1);
opened.mockRestore();
cleanup();
dom.dispose();
});
it('exposes item metadata, removal, progress clamping and clear trigger state', () => {
const { dom } = installSomaHarness();
const opts = createFileUploadOpts();

@ -10,8 +10,19 @@ import type { FileRejection, FileRejectionReason } from './types';
import { formatBytes } from './format';
import { fileUploadMorfo } from '../../../morfo/components/file-upload';
import { partMarkerAttr } from '$uix/morfo';
import type { SomaRuntime, SomaRuntimePart } from '../../runtime.svelte';
/**
* Surfaces that own their own click INSIDE the dropzone. Built from the
* canonical marker function, never a hand-written string, so a part rename
* breaks here instead of silently widening the dropzone's reach.
*/
const DROPZONE_INNER_OWNERS = [
`[${partMarkerAttr(fileUploadMorfo.kebab, 'trigger')}]`,
`[${partMarkerAttr(fileUploadMorfo.kebab, 'hidden-input')}]`
].join(',');
// ── Helpers ──────────────────────────────────────────────────────────────────
function isAccepted(file: File, accept: string): boolean {
@ -317,7 +328,24 @@ export class FileUploadDropzoneProvider {
});
}
readonly onclick = () => {
/**
* Click-to-open, guarded BY OWNERSHIP. Two descendants re-enter this
* handler by bubbling and neither is this surface's gesture:
*
* - the hidden `<input type=file>`, whose SYNTHETIC click `openPicker`
* itself fires — so every dropzone click came back a second time
* (measured 2026-08-13: one click, two dispatches, the second with
* `target = INPUT[hidden-input]`; only the platform's re-entrancy guard
* on `click()` stopped it at two);
* - the Trigger button, which owns its own click and emits with ITS
* surface (`allowedTargets`).
*
* Either way, re-opening here mints a SECOND occurrence — and a second
* picker — for one hand.
*/
readonly onclick = (e?: SomaMouseEvent<HTMLElement>) => {
const from = e?.target as Element | null | undefined;
if (from?.closest?.(DROPZONE_INNER_OWNERS)) return;
this.provider.openPicker(this.opts.ref.current ?? undefined);
};

Loading…
Cancel
Save

Powered by TurnKey Linux.