parent
5f7b2354a8
commit
e6eee766ee
@ -0,0 +1,178 @@
|
|||||||
|
# Words - continuation notes
|
||||||
|
|
||||||
|
Fecha: 2026-05-26
|
||||||
|
|
||||||
|
## Contexto
|
||||||
|
|
||||||
|
Trabajo acotado al ecosistema `words`, salvo el documento previo
|
||||||
|
`src/uix/eidos/eidos-motion.md` de la arquitectura de motion. El usuario pidio
|
||||||
|
mejorar `Words` porque el toolbar y los popovers no estaban aprovechando bien
|
||||||
|
Eidos.
|
||||||
|
|
||||||
|
Antes de tocar componente se releyeron:
|
||||||
|
|
||||||
|
- `web/routes/uix/lib/COMPONENT_AUDIT_GUIDE.md`
|
||||||
|
- `web/routes/uix/lib/DEMO_AUTHORING_GUIDE.md`
|
||||||
|
- `src/uix/eidos/components/README.md`
|
||||||
|
|
||||||
|
## Hecho
|
||||||
|
|
||||||
|
### Toolbar y componentes Eidos
|
||||||
|
|
||||||
|
- `Words` root renderiza el toolbar automatico con wrappers locales de Eidos:
|
||||||
|
`Toolbar`, `ToolbarGroup`, `CommandButton`, `HeadingPicker`, `LinkEditor` y
|
||||||
|
`FindReplace`.
|
||||||
|
- `WordsCommandButton` envuelve `Button` de Eidos y soporta:
|
||||||
|
- `variant`, `size`, `rounded`, `iconOnly`
|
||||||
|
- `toolbarItem` para evitar marcadores de toolbar cuando se usa dentro de
|
||||||
|
paneles flotantes.
|
||||||
|
- Se corrigio el bug por el que los iconos no llegaban al slot `icon` de
|
||||||
|
`Button`; los botones icon-only ya renderizan SVG.
|
||||||
|
- Se elimino `words-tool-popover.svelte`; `Words` usa `Popover` real de Eidos.
|
||||||
|
|
||||||
|
### Heading picker y link editor
|
||||||
|
|
||||||
|
- `Words.HeadingPicker` usa `Popover.Content` portaled con:
|
||||||
|
- `side="top"`
|
||||||
|
- `align="center"`
|
||||||
|
- `sideOffset={8}`
|
||||||
|
- `arrowPadding={8}`
|
||||||
|
- `Popover.Arrow`
|
||||||
|
- El panel de headings usa `CommandButton toolbarItem={false}` para no heredar
|
||||||
|
estilos de toolbar dentro del panel.
|
||||||
|
- `Words.LinkEditor` usa `Popover.Content` centrado, con flecha y acciones con
|
||||||
|
`Button` de Eidos.
|
||||||
|
- Se agrego `[data-words-floating]` como puente de tokens para contenido
|
||||||
|
portaled. Esto evita que los popovers pierdan variables CSS internas de
|
||||||
|
`Words`.
|
||||||
|
|
||||||
|
### Find/replace
|
||||||
|
|
||||||
|
- Se implemento `FindReplace` transversal en Morfo/Soma/Eidos:
|
||||||
|
- Morfo declara la parte `find-replace` y el evento `commit-set-replace`.
|
||||||
|
- Soma mantiene `findQuery`, matches, indice activo y operaciones de
|
||||||
|
reemplazo.
|
||||||
|
- Eidos pinta el panel, inputs, contador, botones e highlights.
|
||||||
|
- El motor soporta:
|
||||||
|
- busqueda case-insensitive por defecto
|
||||||
|
- opcion case-sensitive a nivel engine
|
||||||
|
- highlights en DOM con `mark[data-words-find-match]`
|
||||||
|
- highlight activo con `data-words-find-active`
|
||||||
|
- reemplazo de todas las coincidencias de forma estable.
|
||||||
|
- `Words.FindReplace` paso de banda fija bajo el toolbar a popover:
|
||||||
|
- trigger compacto `[data-words-find-trigger]`
|
||||||
|
- `Popover.Content` centrado con `Popover.Arrow`
|
||||||
|
- panel `role="search"` dentro del popover
|
||||||
|
- `toolbarItem={false}` para uso manual fuera de toolbar.
|
||||||
|
- La demo permite activar/desactivar `FindReplace` y refleja la parte en los
|
||||||
|
snippets.
|
||||||
|
|
||||||
|
### Editor features
|
||||||
|
|
||||||
|
- Text alignment: `align-left`, `align-center`, `align-right`,
|
||||||
|
`align-justify`.
|
||||||
|
- Heading picker: H1/H2/H3/Paragraph.
|
||||||
|
- Indentacion de listas:
|
||||||
|
- `Tab` aumenta indent
|
||||||
|
- `Shift+Tab` reduce indent
|
||||||
|
- `increase-indent` / `decrease-indent` en toolbar full.
|
||||||
|
- Soft breaks con `Shift+Enter`.
|
||||||
|
- Triple click selecciona bloque entero.
|
||||||
|
- Import/export:
|
||||||
|
- HTML preserva indent de listas.
|
||||||
|
- Markdown preserva indent de listas.
|
||||||
|
- Sincronizacion externa de `value` sin remount del editor.
|
||||||
|
|
||||||
|
### Documentacion
|
||||||
|
|
||||||
|
- `README.md` de `words` actualizado con decisiones, fases V1.1 y estado de
|
||||||
|
find/replace.
|
||||||
|
- Demo `web/routes/uix/components/words/+page.svelte` actualizada con controles,
|
||||||
|
snippets y tablas API/A11y/Recipe.
|
||||||
|
|
||||||
|
## Verificado
|
||||||
|
|
||||||
|
Comandos ejecutados y estado:
|
||||||
|
|
||||||
|
- `npx prettier --check ...words... web/routes/uix/components/words/+page.svelte`
|
||||||
|
- OK.
|
||||||
|
- `npx vitest run src/uix/soma/components/words/engine/engine.test.ts src/uix/soma/components/words/words-provider.svelte.test.ts src/uix/soma/components/words/words-content.svelte.test.ts src/uix/eidos/recipe-css-contract.test.ts src/uix/eidos/component-api-contract.test.ts src/uix/eidos/component-visual-attrs.test.ts`
|
||||||
|
- 6 files passed, 96 tests passed.
|
||||||
|
- `node --import tsx/esm scripts/component-audit.ts --only words`
|
||||||
|
- PASS.
|
||||||
|
- `node --import tsx/esm scripts/eidos-lint.ts words`
|
||||||
|
- invalid: 0.
|
||||||
|
|
||||||
|
Browser:
|
||||||
|
|
||||||
|
- Dev server activo en `http://127.0.0.1:5174/uix/components/words`.
|
||||||
|
- Verificado que el trigger de find/replace abre popover:
|
||||||
|
- `data-state="open"`
|
||||||
|
- `side="top"`
|
||||||
|
- `alignDelta: 0`
|
||||||
|
- `hasArrow: true`
|
||||||
|
- `inputCount: 2`
|
||||||
|
- `buttonCount: 3`
|
||||||
|
- Verificado previamente que el heading picker renderiza 4 botones con icono y
|
||||||
|
no se solapa con find/replace.
|
||||||
|
|
||||||
|
## Bloqueos conocidos
|
||||||
|
|
||||||
|
`npm run check` global no queda verde por un error fuera del ambito `words`:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
src/uix/soma/components/command/command-provider.svelte.ts:384:8
|
||||||
|
Error: Cannot find name 'attrs'. Did you mean 'Attr'?
|
||||||
|
```
|
||||||
|
|
||||||
|
Ese fichero estaba modificado fuera del scope de `words`; no se toco para
|
||||||
|
respetar el limite pedido por el usuario. Tambien aparecen 24 warnings
|
||||||
|
preexistentes en demos/componentes no relacionados y errores de carga en
|
||||||
|
`tmp/lexical` por dependencias ausentes.
|
||||||
|
|
||||||
|
## Pendiente para manana
|
||||||
|
|
||||||
|
1. Reabrir la demo de `Words` y hacer una pasada visual completa:
|
||||||
|
- heading picker
|
||||||
|
- link editor
|
||||||
|
- find/replace
|
||||||
|
- toolbar horizontal y vertical
|
||||||
|
- mobile/narrow viewport si aplica.
|
||||||
|
2. Confirmar con browser que los tres popovers de `Words` tienen:
|
||||||
|
- `align="center"`
|
||||||
|
- `Popover.Arrow`
|
||||||
|
- `side="top"`
|
||||||
|
- sin solape visual incoherente.
|
||||||
|
3. Revisar si `side="top"` debe quedar fijo en `Words` o si algunos paneles
|
||||||
|
deberian permitir flip visual manteniendo flecha.
|
||||||
|
4. Si se autoriza salir del ambito `words`, corregir el error externo de
|
||||||
|
`src/uix/soma/components/command/command-provider.svelte.ts` (`attrs` fuera
|
||||||
|
de scope) y repetir `npm run check`.
|
||||||
|
5. Revisar posibles siguientes features del editor:
|
||||||
|
- code blocks
|
||||||
|
- slash commands
|
||||||
|
- tablas
|
||||||
|
- imagenes/archivos
|
||||||
|
- find options UI: case sensitive, whole word, regex si se decide.
|
||||||
|
6. Antes de nuevas features, repetir el pre-flight obligatorio y preparar una
|
||||||
|
mini matriz contra Radix/Bits/Ark/React Aria/Chakra/MUI segun corresponda.
|
||||||
|
|
||||||
|
## Archivos tocados principales
|
||||||
|
|
||||||
|
- `src/uix/eidos/components/words/words.svelte`
|
||||||
|
- `src/uix/eidos/components/words/words-command-button.svelte`
|
||||||
|
- `src/uix/eidos/components/words/words-heading-picker.svelte`
|
||||||
|
- `src/uix/eidos/components/words/words-link-editor.svelte`
|
||||||
|
- `src/uix/eidos/components/words/words-find-replace.svelte`
|
||||||
|
- `src/uix/eidos/components/words/words.css`
|
||||||
|
- `src/uix/eidos/components/words/types.ts`
|
||||||
|
- `src/uix/eidos/components/words/README.md`
|
||||||
|
- `src/uix/soma/components/words/**`
|
||||||
|
- `src/uix/morfo/components/words.ts`
|
||||||
|
- `web/routes/uix/components/words/+page.svelte`
|
||||||
|
|
||||||
|
## Nota de continuidad
|
||||||
|
|
||||||
|
No hacer `git reset`, `stash` ni `checkout` de ficheros: el worktree tiene
|
||||||
|
cambios previos del usuario y cambios de esta sesion. Trabajar por diffs
|
||||||
|
acotados y respetar el scope que indique el usuario al retomar.
|
||||||
@ -1,14 +1,64 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { Button } from '$uix/eidos/components/button';
|
||||||
import * as Words from '$soma/components/words';
|
import * as Words from '$soma/components/words';
|
||||||
import type { WordsCommandButtonProps } from './types';
|
import type { WordsCommandButtonProps } from './types';
|
||||||
|
|
||||||
let { children, ...rest }: WordsCommandButtonProps = $props();
|
let {
|
||||||
|
children,
|
||||||
|
child: outerChild,
|
||||||
|
variant = 'ghost',
|
||||||
|
size = 'xs',
|
||||||
|
rounded = 'md',
|
||||||
|
iconOnly = true,
|
||||||
|
toolbarItem = true,
|
||||||
|
...rest
|
||||||
|
}: WordsCommandButtonProps = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if children}
|
<Words.CommandButton {...rest}>
|
||||||
<Words.CommandButton {...rest}>
|
{#snippet child({ props })}
|
||||||
{@render children()}
|
{#if outerChild}
|
||||||
</Words.CommandButton>
|
{@render outerChild({
|
||||||
{:else}
|
props: {
|
||||||
<Words.CommandButton {...rest} />
|
...props,
|
||||||
{/if}
|
'data-toolbar-button': toolbarItem ? '' : undefined,
|
||||||
|
'data-toolbar-group-item': toolbarItem ? '' : undefined
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
{:else}
|
||||||
|
{@const label = props['aria-label']}
|
||||||
|
{#if children}
|
||||||
|
<Button
|
||||||
|
{...props}
|
||||||
|
{variant}
|
||||||
|
{size}
|
||||||
|
{rounded}
|
||||||
|
{iconOnly}
|
||||||
|
intent="neutral"
|
||||||
|
color="neutral"
|
||||||
|
data-toolbar-button={toolbarItem ? '' : undefined}
|
||||||
|
data-toolbar-group-item={toolbarItem ? '' : undefined}
|
||||||
|
>
|
||||||
|
{#snippet icon()}
|
||||||
|
{@render children()}
|
||||||
|
{/snippet}
|
||||||
|
{typeof label === 'string' ? label : rest.command}
|
||||||
|
</Button>
|
||||||
|
{:else}
|
||||||
|
<Button
|
||||||
|
{...props}
|
||||||
|
{variant}
|
||||||
|
{size}
|
||||||
|
{rounded}
|
||||||
|
iconOnly={false}
|
||||||
|
intent="neutral"
|
||||||
|
color="neutral"
|
||||||
|
data-toolbar-button={toolbarItem ? '' : undefined}
|
||||||
|
data-toolbar-group-item={toolbarItem ? '' : undefined}
|
||||||
|
>
|
||||||
|
{rest.command}
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
</Words.CommandButton>
|
||||||
|
|||||||
@ -0,0 +1,149 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Search, Replace, ChevronUp, ChevronDown } from '$uix/eidos/components/icon';
|
||||||
|
import { Button } from '$uix/eidos/components/button';
|
||||||
|
import { Popover } from '$uix/eidos/components/popover';
|
||||||
|
import * as Words from '$soma/components/words';
|
||||||
|
import type { FindReplaceSnippetProps } from '$soma/components/words';
|
||||||
|
import type { WordsFindReplaceProps } from './types';
|
||||||
|
|
||||||
|
let { children: customChildren, toolbarItem = true, ...rest }: WordsFindReplaceProps = $props();
|
||||||
|
|
||||||
|
let open = $state(false);
|
||||||
|
let replacement = $state('');
|
||||||
|
|
||||||
|
type FindReplaceChildProps = FindReplaceSnippetProps & {
|
||||||
|
props: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Words.FindReplace {...rest}>
|
||||||
|
{#snippet child(props)}
|
||||||
|
{@const sp = props as FindReplaceChildProps}
|
||||||
|
<Popover bind:open>
|
||||||
|
<Popover.Trigger
|
||||||
|
data-words-find-trigger
|
||||||
|
data-toolbar-button={toolbarItem ? '' : undefined}
|
||||||
|
data-toolbar-group-item={toolbarItem ? '' : undefined}
|
||||||
|
data-has-matches={sp.hasFindMatches ? '' : undefined}
|
||||||
|
aria-label="Find and replace"
|
||||||
|
title="Find and replace"
|
||||||
|
disabled={sp.disabled || undefined}
|
||||||
|
>
|
||||||
|
<Search size="sm" decorative />
|
||||||
|
</Popover.Trigger>
|
||||||
|
<Popover.Portal>
|
||||||
|
<Popover.Content
|
||||||
|
data-words-floating
|
||||||
|
side="top"
|
||||||
|
align="center"
|
||||||
|
sideOffset={8}
|
||||||
|
arrowPadding={8}
|
||||||
|
size="sm"
|
||||||
|
width="min(24rem, calc(100vw - var(--space-4)))"
|
||||||
|
>
|
||||||
|
<Popover.Arrow />
|
||||||
|
<div {...sp.props}>
|
||||||
|
{#if customChildren}
|
||||||
|
{@render customChildren(sp)}
|
||||||
|
{:else}
|
||||||
|
<div data-words-find-row>
|
||||||
|
<span data-words-find-label aria-hidden="true">
|
||||||
|
<Search size="sm" decorative />
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
data-words-find-input
|
||||||
|
value={sp.findQuery}
|
||||||
|
aria-label="Find text"
|
||||||
|
placeholder="Find..."
|
||||||
|
disabled={sp.disabled || undefined}
|
||||||
|
oninput={(e) => {
|
||||||
|
sp.setFindQuery(e.currentTarget.value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span data-words-find-counter aria-live="polite">
|
||||||
|
{#if sp.hasFindMatches}
|
||||||
|
{sp.currentMatchIndex + 1} / {sp.findMatches.length}
|
||||||
|
{:else}
|
||||||
|
0 / 0
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
data-words-find-prev
|
||||||
|
aria-label="Previous match"
|
||||||
|
variant="ghost"
|
||||||
|
size="xs"
|
||||||
|
rounded="md"
|
||||||
|
iconOnly
|
||||||
|
intent="neutral"
|
||||||
|
color="neutral"
|
||||||
|
disabled={sp.disabled || !sp.hasFindMatches}
|
||||||
|
onclick={() => sp.findPrev()}
|
||||||
|
>
|
||||||
|
{#snippet icon()}
|
||||||
|
<ChevronUp size="sm" decorative />
|
||||||
|
{/snippet}
|
||||||
|
Previous match
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
data-words-find-next
|
||||||
|
aria-label="Next match"
|
||||||
|
variant="ghost"
|
||||||
|
size="xs"
|
||||||
|
rounded="md"
|
||||||
|
iconOnly
|
||||||
|
intent="neutral"
|
||||||
|
color="neutral"
|
||||||
|
disabled={sp.disabled || !sp.hasFindMatches}
|
||||||
|
onclick={() => sp.findNext()}
|
||||||
|
>
|
||||||
|
{#snippet icon()}
|
||||||
|
<ChevronDown size="sm" decorative />
|
||||||
|
{/snippet}
|
||||||
|
Next match
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div data-words-replace-row>
|
||||||
|
<span data-words-replace-label aria-hidden="true">
|
||||||
|
<Replace size="sm" decorative />
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
data-words-replace-input
|
||||||
|
value={replacement}
|
||||||
|
aria-label="Replace with"
|
||||||
|
placeholder="Replace..."
|
||||||
|
disabled={sp.disabled || sp.readonly || undefined}
|
||||||
|
oninput={(e) => {
|
||||||
|
replacement = e.currentTarget.value;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
data-words-replace-all
|
||||||
|
aria-label="Replace all"
|
||||||
|
variant="solid"
|
||||||
|
size="xs"
|
||||||
|
rounded="md"
|
||||||
|
iconOnly
|
||||||
|
disabled={sp.disabled || sp.readonly || !sp.hasFindMatches}
|
||||||
|
onclick={() => {
|
||||||
|
sp.replaceAllText(replacement);
|
||||||
|
replacement = '';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{#snippet icon()}
|
||||||
|
<Replace size="sm" decorative />
|
||||||
|
{/snippet}
|
||||||
|
Replace all
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</Popover.Content>
|
||||||
|
</Popover.Portal>
|
||||||
|
</Popover>
|
||||||
|
{/snippet}
|
||||||
|
</Words.FindReplace>
|
||||||
@ -1,117 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { X } from '$uix/eidos/components/icon';
|
|
||||||
import { Popover } from '$uix/eidos/components/popover';
|
|
||||||
import type { WordsToolPopoverProps } from './types';
|
|
||||||
|
|
||||||
let {
|
|
||||||
open = $bindable(false),
|
|
||||||
tool,
|
|
||||||
triggerLabel,
|
|
||||||
triggerTitle = triggerLabel,
|
|
||||||
closeLabel = 'Close',
|
|
||||||
side = 'bottom',
|
|
||||||
sideOffset = 6,
|
|
||||||
align = 'start',
|
|
||||||
alignOffset,
|
|
||||||
width = 'min(92vw, 32em)',
|
|
||||||
minWidth = 'min(92vw, 24em)',
|
|
||||||
maxWidth = 'min(92vw, 32em)',
|
|
||||||
collisionPadding,
|
|
||||||
avoidCollisions,
|
|
||||||
trigger,
|
|
||||||
children,
|
|
||||||
onTriggerPointerDown,
|
|
||||||
onBeforeDismiss,
|
|
||||||
onCancel
|
|
||||||
}: WordsToolPopoverProps = $props();
|
|
||||||
|
|
||||||
let closeBlocked = false;
|
|
||||||
|
|
||||||
function canDismiss(): boolean {
|
|
||||||
const valid = onBeforeDismiss?.() ?? true;
|
|
||||||
closeBlocked = !valid;
|
|
||||||
return valid;
|
|
||||||
}
|
|
||||||
|
|
||||||
function guardDismiss(event: Event) {
|
|
||||||
if (canDismiss()) return;
|
|
||||||
event.preventDefault();
|
|
||||||
}
|
|
||||||
|
|
||||||
function preserveEditorSelection(event: PointerEvent) {
|
|
||||||
onTriggerPointerDown?.(event);
|
|
||||||
event.preventDefault();
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleOpenChange(nextOpen: boolean) {
|
|
||||||
if (!nextOpen && !canDismiss()) {
|
|
||||||
open = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
closeBlocked = false;
|
|
||||||
open = nextOpen;
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleCloseAutoFocus(event: Event) {
|
|
||||||
if (!closeBlocked) return;
|
|
||||||
event.preventDefault();
|
|
||||||
}
|
|
||||||
|
|
||||||
function close() {
|
|
||||||
closeBlocked = false;
|
|
||||||
open = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function cancel() {
|
|
||||||
onCancel?.();
|
|
||||||
close();
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<Popover bind:open onOpenChange={handleOpenChange}>
|
|
||||||
<Popover.Trigger
|
|
||||||
data-words-tool-popover-trigger
|
|
||||||
data-words-link-trigger={tool === 'link' || undefined}
|
|
||||||
data-tool={tool}
|
|
||||||
aria-label={triggerLabel}
|
|
||||||
title={triggerTitle}
|
|
||||||
onpointerdown={preserveEditorSelection}
|
|
||||||
>
|
|
||||||
{@render trigger?.()}
|
|
||||||
</Popover.Trigger>
|
|
||||||
<Popover.Portal>
|
|
||||||
<Popover.Content
|
|
||||||
data-words-tool-popover
|
|
||||||
data-words-link-popover={tool === 'link' || undefined}
|
|
||||||
data-tool={tool}
|
|
||||||
size="sm"
|
|
||||||
{side}
|
|
||||||
{align}
|
|
||||||
{sideOffset}
|
|
||||||
{alignOffset}
|
|
||||||
{width}
|
|
||||||
{minWidth}
|
|
||||||
{maxWidth}
|
|
||||||
{collisionPadding}
|
|
||||||
{avoidCollisions}
|
|
||||||
onCloseAutoFocus={handleCloseAutoFocus}
|
|
||||||
onInteractOutside={guardDismiss}
|
|
||||||
onEscapeKeydown={guardDismiss}
|
|
||||||
>
|
|
||||||
<Popover.Arrow />
|
|
||||||
<div data-words-tool-popover-row>
|
|
||||||
<div data-words-tool-popover-body>
|
|
||||||
{@render children?.({ open, close })}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
data-words-tool-popover-close
|
|
||||||
type="button"
|
|
||||||
aria-label={closeLabel}
|
|
||||||
onclick={cancel}
|
|
||||||
>
|
|
||||||
<X size="sm" decorative />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</Popover.Content>
|
|
||||||
</Popover.Portal>
|
|
||||||
</Popover>
|
|
||||||
@ -1,10 +1,33 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { mergeProps } from '$uix/soma/props';
|
||||||
|
import { Toolbar } from '$uix/eidos/components/toolbar';
|
||||||
import * as Words from '$soma/components/words';
|
import * as Words from '$soma/components/words';
|
||||||
import type { WordsToolbarProps } from './types';
|
import type { WordsToolbarProps } from './types';
|
||||||
|
|
||||||
let { children, ...rest }: WordsToolbarProps = $props();
|
let {
|
||||||
|
orientation = 'horizontal',
|
||||||
|
'aria-label': ariaLabel = 'Formatting toolbar',
|
||||||
|
children,
|
||||||
|
child: outerChild,
|
||||||
|
...rest
|
||||||
|
}: WordsToolbarProps = $props();
|
||||||
|
|
||||||
|
const toolbarAriaLabel = $derived(ariaLabel ?? undefined);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Words.Toolbar {...rest}>
|
<Words.Toolbar {...rest} {orientation} aria-label={toolbarAriaLabel}>
|
||||||
{@render children?.()}
|
{#snippet child({ props: wordsProps })}
|
||||||
|
<Toolbar {orientation} aria-label={toolbarAriaLabel} size="sm" variant="ghost">
|
||||||
|
{#snippet child({ props: toolbarProps })}
|
||||||
|
{@const props = mergeProps(toolbarProps, wordsProps)}
|
||||||
|
{#if outerChild}
|
||||||
|
{@render outerChild({ props })}
|
||||||
|
{:else}
|
||||||
|
<div {...props}>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
</Toolbar>
|
||||||
|
{/snippet}
|
||||||
</Words.Toolbar>
|
</Words.Toolbar>
|
||||||
|
|||||||
@ -0,0 +1,483 @@
|
|||||||
|
# Eidos Motion
|
||||||
|
|
||||||
|
Documento de arquitectura propuesto para dotar a la capa visual de UIX de un
|
||||||
|
sistema transversal de animaciones.
|
||||||
|
|
||||||
|
## Tesis
|
||||||
|
|
||||||
|
UIX tiene dos familias de animacion:
|
||||||
|
|
||||||
|
1. **Motion de senal**: Sema escribe una ocurrencia transitoria en el DOM
|
||||||
|
mediante `data-event-*`. Eidos gestiona el canal visual de Sema: lee esa
|
||||||
|
ocurrencia y la convierte en animacion, color, intensidad y politica de
|
||||||
|
reduced motion.
|
||||||
|
2. **Motion visual**: Eidos anima estado, presencia, interaccion y layout a
|
||||||
|
partir de atributos como `data-state`, `data-starting-style`,
|
||||||
|
`data-ending-style`, `data-archetype` y referencias visuales propias.
|
||||||
|
|
||||||
|
Sema no decide keyframes, easing ni duraciones CSS. Soma no decide visualidad.
|
||||||
|
Eidos es el runtime visual que resuelve ambos mundos.
|
||||||
|
|
||||||
|
```text
|
||||||
|
Sema visual channel
|
||||||
|
-> data-event, data-event-family, data-event-intent, data-event-phase
|
||||||
|
-> ventana semantica / hold de la senal
|
||||||
|
|
||||||
|
Eidos signal motion
|
||||||
|
-> lee data-event-*
|
||||||
|
-> resuelve motion registrada por event/family/intent
|
||||||
|
-> aplica CSS, WAAPI o driver visual
|
||||||
|
|
||||||
|
Eidos visual motion
|
||||||
|
-> lee data-state, data-starting-style, data-ending-style, data-archetype...
|
||||||
|
-> aplica presets de presencia, interaccion y layout
|
||||||
|
```
|
||||||
|
|
||||||
|
## Objetivos
|
||||||
|
|
||||||
|
- Registrar animaciones reutilizables en Eidos.
|
||||||
|
- Permitir que los componentes Eidos referencien una motion por nombre.
|
||||||
|
- Mantener las primitivas existentes de motion como fuente atomica:
|
||||||
|
duracion, easing, distancia, escala y stagger.
|
||||||
|
- Centralizar reduced motion en Eidos usando la preferencia global proyectada
|
||||||
|
en DOM.
|
||||||
|
- Mantener el canal visual de Sema como entrada de senales, no como motor CSS.
|
||||||
|
- Soportar animaciones avanzadas como `genie`, `shared-element` o
|
||||||
|
`fly-to-target` mediante drivers visuales de Eidos.
|
||||||
|
|
||||||
|
## No objetivos
|
||||||
|
|
||||||
|
- No introducir una dependencia obligatoria de Framer Motion, Motion One ni
|
||||||
|
ningun motor JS externo en el core.
|
||||||
|
- No mover presencia visual a Soma.
|
||||||
|
- No hacer que Sema conozca nombres visuales como `genie`, `scale-fade` o
|
||||||
|
`drawer`.
|
||||||
|
- No reutilizar `data-motion` para presets visuales. Ese atributo ya representa
|
||||||
|
la preferencia global `allow` / `reduce`.
|
||||||
|
|
||||||
|
## Ownership
|
||||||
|
|
||||||
|
| Capa | Responsabilidad |
|
||||||
|
| --- | --- |
|
||||||
|
| Morfo | Declara atributos estructurales y estados compartidos por capas. |
|
||||||
|
| Soma | Sincroniza estado, ARIA, presence lifecycle y attrs como `data-state`, `data-starting-style`, `data-ending-style`. |
|
||||||
|
| Sema | Emite senales perceptivas y escribe `data-event-*` durante el hold del canal visual. |
|
||||||
|
| Eidos | Gestiona el canal visual de Sema, registra motions, resuelve tokens, aplica CSS/WAAPI/drivers y reduced motion. |
|
||||||
|
| ActivePrefs DOM projection | Proyecta preferencia global: `data-motion="allow"` o `data-motion="reduce"`. |
|
||||||
|
|
||||||
|
## Registro de motion
|
||||||
|
|
||||||
|
Eidos deberia tener un registro tipado de animaciones. Las primitivas actuales
|
||||||
|
siguen siendo atomicas; el registro compone esas primitivas en recetas
|
||||||
|
semanticas.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type EidosMotionKind = 'presence' | 'event' | 'interaction' | 'layout';
|
||||||
|
|
||||||
|
type EidosMotionDriver = 'css' | 'eidos-rect' | 'waapi';
|
||||||
|
|
||||||
|
type EidosReducedMotionPolicy = 'instant' | 'opacity-only' | 'none';
|
||||||
|
|
||||||
|
interface EidosMotionDefinition {
|
||||||
|
kind: EidosMotionKind;
|
||||||
|
driver?: EidosMotionDriver;
|
||||||
|
duration?: DurationKey;
|
||||||
|
ease?: EaseKey;
|
||||||
|
distance?: MotionDistanceKey;
|
||||||
|
scale?: MotionScaleKey;
|
||||||
|
stagger?: boolean;
|
||||||
|
reduce?: EidosReducedMotionPolicy;
|
||||||
|
requires?: Array<'sourceRect' | 'targetRect' | 'placement' | 'direction'>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
La configuracion podria quedar asi:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface EidosConfig {
|
||||||
|
motion?: {
|
||||||
|
registry?: Record<string, EidosMotionDefinition>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Las primitivas actuales no deberian moverse dentro de `registry`. El registro
|
||||||
|
usa claves de primitivas ya definidas por Eidos.
|
||||||
|
|
||||||
|
## Nombres de referencia
|
||||||
|
|
||||||
|
Usar nombres con prefijo semantico:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const motionRegistry = {
|
||||||
|
'event.announce': {
|
||||||
|
kind: 'event',
|
||||||
|
duration: 'normal',
|
||||||
|
ease: 'out',
|
||||||
|
reduce: 'opacity-only'
|
||||||
|
},
|
||||||
|
'event.dismiss': {
|
||||||
|
kind: 'event',
|
||||||
|
duration: 'fast',
|
||||||
|
ease: 'in',
|
||||||
|
reduce: 'instant'
|
||||||
|
},
|
||||||
|
'presence.fade': {
|
||||||
|
kind: 'presence',
|
||||||
|
duration: 'normal',
|
||||||
|
ease: 'out',
|
||||||
|
reduce: 'opacity-only'
|
||||||
|
},
|
||||||
|
'presence.scale-fade': {
|
||||||
|
kind: 'presence',
|
||||||
|
duration: 'moderate',
|
||||||
|
ease: 'spring',
|
||||||
|
scale: 'enter',
|
||||||
|
reduce: 'opacity-only'
|
||||||
|
},
|
||||||
|
'presence.genie': {
|
||||||
|
kind: 'presence',
|
||||||
|
driver: 'eidos-rect',
|
||||||
|
duration: 'slow',
|
||||||
|
ease: 'spring',
|
||||||
|
reduce: 'opacity-only',
|
||||||
|
requires: ['sourceRect', 'targetRect']
|
||||||
|
},
|
||||||
|
'interaction.press': {
|
||||||
|
kind: 'interaction',
|
||||||
|
duration: 'fast',
|
||||||
|
ease: 'out',
|
||||||
|
scale: 'press',
|
||||||
|
reduce: 'instant'
|
||||||
|
},
|
||||||
|
'layout.indicator': {
|
||||||
|
kind: 'layout',
|
||||||
|
duration: 'fast',
|
||||||
|
ease: 'default',
|
||||||
|
reduce: 'instant'
|
||||||
|
}
|
||||||
|
} satisfies Record<string, EidosMotionDefinition>;
|
||||||
|
```
|
||||||
|
|
||||||
|
## API publica de componentes
|
||||||
|
|
||||||
|
Los wrappers Eidos pueden exponer una prop visual `motion`.
|
||||||
|
|
||||||
|
```svelte
|
||||||
|
<Dialog motion="presence.scale-fade" />
|
||||||
|
<Popover motion="presence.scale-fade" />
|
||||||
|
<Drawer motion="presence.drawer-end" />
|
||||||
|
<Tabs motion="presence.slide-inline" />
|
||||||
|
<Button motion="interaction.press" />
|
||||||
|
```
|
||||||
|
|
||||||
|
Para componentes multiparte, aceptar un mapa por parte:
|
||||||
|
|
||||||
|
```svelte
|
||||||
|
<Dialog
|
||||||
|
motion={{
|
||||||
|
overlay: 'presence.fade',
|
||||||
|
content: 'presence.scale-fade'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
La API publica usa `motion`; el DOM no debe usar `data-motion` para esa
|
||||||
|
referencia. En DOM se proyecta como referencia visual de Eidos:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div data-dialog-content data-motion-ref="presence.scale-fade">
|
||||||
|
```
|
||||||
|
|
||||||
|
Motivo: `data-motion` ya pertenece a la preferencia global de motion.
|
||||||
|
|
||||||
|
## Contrato DOM
|
||||||
|
|
||||||
|
Atributos propuestos:
|
||||||
|
|
||||||
|
| Atributo | Owner | Uso |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `data-motion="allow\|reduce"` | ActivePrefs DOM projection | Preferencia global transversal. |
|
||||||
|
| `data-motion-ref="presence.scale-fade"` | Eidos | Referencia a una motion registrada. |
|
||||||
|
| `data-motion-origin="top\|right\|bottom\|left\|center"` | Eidos | Origen visual para transforms. |
|
||||||
|
| `data-motion-direction="from-start\|from-end\|to-start\|to-end"` | Soma/Morfo cuando sea estado real | Direccion de transicion cuando afecta a comportamiento o layout. |
|
||||||
|
| `data-motion-target` | Eidos | Marcador opcional para drivers con rect target. |
|
||||||
|
| `data-starting-style` | Soma Presence | Hook de entrada. |
|
||||||
|
| `data-ending-style` | Soma Presence | Hook de salida. |
|
||||||
|
| `data-event-*` | Sema VisualChannel | Senal perceptiva transitoria. |
|
||||||
|
|
||||||
|
Regla: un atributo visual puro puede vivir solo en Eidos. Si el atributo afecta
|
||||||
|
a semantica, accesibilidad, lifecycle o comportamiento, debe declararse en
|
||||||
|
Morfo/Soma.
|
||||||
|
|
||||||
|
## CSS transversal
|
||||||
|
|
||||||
|
Crear un modulo `motion.css` importado por `index.css` cerca de
|
||||||
|
`archetypes.css` y `events.css`.
|
||||||
|
|
||||||
|
```css
|
||||||
|
[data-motion-ref='presence.fade'] {
|
||||||
|
transition:
|
||||||
|
opacity var(--motion-presence-fade-duration)
|
||||||
|
var(--motion-presence-fade-ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-motion-ref='presence.fade'][data-starting-style],
|
||||||
|
[data-motion-ref='presence.fade'][data-ending-style] {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-motion-ref='presence.scale-fade'] {
|
||||||
|
transition:
|
||||||
|
opacity var(--motion-presence-scale-fade-duration)
|
||||||
|
var(--motion-presence-scale-fade-ease),
|
||||||
|
transform var(--motion-presence-scale-fade-duration)
|
||||||
|
var(--motion-presence-scale-fade-ease);
|
||||||
|
transform-origin: var(--motion-origin, center);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-motion-ref='presence.scale-fade'][data-starting-style],
|
||||||
|
[data-motion-ref='presence.scale-fade'][data-ending-style] {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(var(--motion-scale-enter));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`events.css` puede migrar gradualmente para consumir el mismo registro:
|
||||||
|
|
||||||
|
```css
|
||||||
|
[data-event-family='commit'][data-event-phase='active'] {
|
||||||
|
animation: var(--motion-event-commit-animation);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Reduced motion
|
||||||
|
|
||||||
|
La preferencia global ya se proyecta como `data-motion`. Eidos debe convertirla
|
||||||
|
en politica visual.
|
||||||
|
|
||||||
|
```css
|
||||||
|
[data-motion='reduce'] [data-motion-ref] {
|
||||||
|
animation-duration: 1ms;
|
||||||
|
transition-duration: 1ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-motion='reduce'] [data-motion-ref][data-motion-reduce='opacity-only'] {
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Tambien conviene mantener fallback por media query:
|
||||||
|
|
||||||
|
```css
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
[data-motion-ref] {
|
||||||
|
animation-duration: 1ms;
|
||||||
|
transition-duration: 1ms;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
La politica por motion debe venir del registro:
|
||||||
|
|
||||||
|
- `instant`: reduce a cambio inmediato.
|
||||||
|
- `opacity-only`: conserva fades breves y elimina transformaciones.
|
||||||
|
- `none`: mantiene la animacion porque representa feedback critico y ya es
|
||||||
|
segura.
|
||||||
|
|
||||||
|
## Driver `css`
|
||||||
|
|
||||||
|
El driver por defecto. No requiere medicion ni JavaScript. Sirve para:
|
||||||
|
|
||||||
|
- fade
|
||||||
|
- scale-fade
|
||||||
|
- slide-inline
|
||||||
|
- slide-block
|
||||||
|
- collapse-block con CSS vars conocidas
|
||||||
|
- press
|
||||||
|
- indicator transitions
|
||||||
|
- signal reactions basicas
|
||||||
|
|
||||||
|
Debe cubrir la mayoria de componentes.
|
||||||
|
|
||||||
|
## Driver `eidos-rect`
|
||||||
|
|
||||||
|
Driver visual para animaciones que necesitan geometria. Eidos mide rects y
|
||||||
|
escribe variables CSS o invoca WAAPI.
|
||||||
|
|
||||||
|
Casos:
|
||||||
|
|
||||||
|
- `presence.genie`
|
||||||
|
- `layout.shared-element`
|
||||||
|
- `layout.fly-to-target`
|
||||||
|
- `presence.expand-from-trigger`
|
||||||
|
|
||||||
|
Entrada esperada:
|
||||||
|
|
||||||
|
```svelte
|
||||||
|
<Dialog motion="presence.genie" motionTarget={dockButton} />
|
||||||
|
<Toast motion="presence.genie" motionTarget="#notification-center" />
|
||||||
|
```
|
||||||
|
|
||||||
|
Variables que puede escribir Eidos:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div
|
||||||
|
data-motion-ref="presence.genie"
|
||||||
|
style="
|
||||||
|
--motion-source-x: 120px;
|
||||||
|
--motion-source-y: 80px;
|
||||||
|
--motion-source-w: 480px;
|
||||||
|
--motion-source-h: 320px;
|
||||||
|
--motion-target-x: 32px;
|
||||||
|
--motion-target-y: 720px;
|
||||||
|
--motion-target-w: 48px;
|
||||||
|
--motion-target-h: 48px;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
```
|
||||||
|
|
||||||
|
Para `genie`, el efecto puede empezar como CSS aproximado y evolucionar a
|
||||||
|
WAAPI si el calculo de keyframes necesita interpolacion mas precisa.
|
||||||
|
|
||||||
|
## Driver `waapi`
|
||||||
|
|
||||||
|
Driver reservado para animaciones que no se expresan bien con CSS estatico.
|
||||||
|
Debe seguir siendo opt-in por motion registrada, no dependencia general de los
|
||||||
|
componentes.
|
||||||
|
|
||||||
|
Responsabilidades:
|
||||||
|
|
||||||
|
- Construir keyframes desde rects y tokens.
|
||||||
|
- Respetar reduced motion.
|
||||||
|
- Cancelar animaciones obsoletas cuando el nodo se desmonta o cambia de estado.
|
||||||
|
- No bloquear a Soma salvo por el lifecycle que Soma ya obtiene con
|
||||||
|
`getAnimations()`.
|
||||||
|
|
||||||
|
## Gestion del canal visual de Sema
|
||||||
|
|
||||||
|
Sema escribe la senal:
|
||||||
|
|
||||||
|
```html
|
||||||
|
data-event="dismiss"
|
||||||
|
data-event-family="commit"
|
||||||
|
data-event-intent="fulfill"
|
||||||
|
data-event-phase="active"
|
||||||
|
```
|
||||||
|
|
||||||
|
Eidos resuelve esa entrada contra el registro. La resolucion puede seguir este
|
||||||
|
orden:
|
||||||
|
|
||||||
|
1. `event.{data-event}`
|
||||||
|
2. `event.{data-event-family}.{data-event-intent}`
|
||||||
|
3. `event.{data-event-family}`
|
||||||
|
4. fallback global de `event.default`
|
||||||
|
|
||||||
|
Ejemplo:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
resolveSignalMotion({
|
||||||
|
event: 'announce',
|
||||||
|
family: 'commit',
|
||||||
|
intent: 'fulfill'
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Resultado posible:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
'event.commit.fulfill'
|
||||||
|
```
|
||||||
|
|
||||||
|
El `hold` de Sema no debe usarse como duracion CSS. El hold define cuanto vive
|
||||||
|
la senal en DOM; la duracion visual pertenece a Eidos.
|
||||||
|
|
||||||
|
## Defaults por componente
|
||||||
|
|
||||||
|
Cada componente puede tener default visual sin obligar a la persona usuaria a
|
||||||
|
pasar `motion`.
|
||||||
|
|
||||||
|
Ejemplos iniciales:
|
||||||
|
|
||||||
|
| Componente | Parte | Motion default |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Dialog | overlay | `presence.fade` |
|
||||||
|
| Dialog | content | `presence.scale-fade` |
|
||||||
|
| Popover | content | `presence.scale-fade` |
|
||||||
|
| Drawer | content | `presence.drawer` |
|
||||||
|
| Accordion | content | `presence.collapse-block` |
|
||||||
|
| Tabs | content | `presence.fade` o `presence.slide-inline` |
|
||||||
|
| NavigationMenu | content | `presence.slide-inline` con direccion |
|
||||||
|
| Button | root | `interaction.press` |
|
||||||
|
| Switch/Slider | thumb | `layout.indicator` o `interaction.press` |
|
||||||
|
|
||||||
|
La prop `motion` debe permitir anular el default:
|
||||||
|
|
||||||
|
```svelte
|
||||||
|
<Dialog motion="none" />
|
||||||
|
<Dialog motion="presence.genie" motionTarget="#dock-settings" />
|
||||||
|
```
|
||||||
|
|
||||||
|
## Migracion
|
||||||
|
|
||||||
|
### Fase 0: documentacion
|
||||||
|
|
||||||
|
- Corregir la deriva documental donde Sema parezca propietario de motion
|
||||||
|
visual.
|
||||||
|
- Dejar claro que Eidos gestiona el canal visual de Sema.
|
||||||
|
|
||||||
|
### Fase 1: contrato base
|
||||||
|
|
||||||
|
- Agregar tipos del registro.
|
||||||
|
- Agregar validacion de nombres y definiciones.
|
||||||
|
- Generar CSS vars por motion registrada.
|
||||||
|
- Crear `motion.css`.
|
||||||
|
- Mantener `events.css` y `archetypes.css` funcionando como hoy.
|
||||||
|
|
||||||
|
### Fase 2: canarios
|
||||||
|
|
||||||
|
Migrar pocos componentes para probar todos los casos:
|
||||||
|
|
||||||
|
- Dialog o Popover: overlay + content + presence lifecycle.
|
||||||
|
- Drawer: origen/direccion.
|
||||||
|
- Accordion: collapse.
|
||||||
|
- Tabs: reemplazar `motionPreset` por `motion`.
|
||||||
|
- NavigationMenu: aislar o renombrar el uso actual de `data-motion` como
|
||||||
|
direccion.
|
||||||
|
|
||||||
|
### Fase 3: canal visual de Sema
|
||||||
|
|
||||||
|
- Migrar `events.css` para resolver motions de tipo `event`.
|
||||||
|
- Mantener compatibilidad con selectores actuales `data-event-*`.
|
||||||
|
- Probar intents: `neutral`, `affirm`, `fulfill`, `risk`, `threat`.
|
||||||
|
|
||||||
|
### Fase 4: drivers avanzados
|
||||||
|
|
||||||
|
- Introducir `eidos-rect` para `presence.genie`.
|
||||||
|
- Agregar pruebas con target rect y reduced motion.
|
||||||
|
- Evaluar si WAAPI aporta valor para shared element y fly-to-target.
|
||||||
|
|
||||||
|
## Pruebas recomendadas
|
||||||
|
|
||||||
|
- Test de contrato: las claves del registro producen CSS vars estables.
|
||||||
|
- Test de validacion: `motion="unknown"` falla temprano o queda en fallback
|
||||||
|
controlado.
|
||||||
|
- Test de DOM attrs: permitir `data-motion-ref`, `data-motion-origin` y
|
||||||
|
`data-motion-direction` donde aplique.
|
||||||
|
- Test anti-colision: no introducir nuevos usos de `data-motion` para presets.
|
||||||
|
- Test reduced motion: `data-motion="reduce"` reduce transforms y duraciones.
|
||||||
|
- Test presence: Soma espera las animaciones resultantes antes de desmontar.
|
||||||
|
|
||||||
|
## Decision propuesta
|
||||||
|
|
||||||
|
Adoptar `motion` como prop publica de Eidos y `data-motion-ref` como contrato
|
||||||
|
DOM interno.
|
||||||
|
|
||||||
|
```svelte
|
||||||
|
<Dialog motion="presence.scale-fade" />
|
||||||
|
<Dialog motion="presence.genie" motionTarget="#dock-settings" />
|
||||||
|
```
|
||||||
|
|
||||||
|
Eidos registra y resuelve motions. Sema emite senales. Soma conserva lifecycle.
|
||||||
|
Morfo declara los atributos que cruzan capas. La animacion queda centralizada
|
||||||
|
en el runtime visual sin romper la separacion actual de UIX.
|
||||||
@ -0,0 +1,39 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { mergeProps } from '../../../props';
|
||||||
|
import { createId } from '../../../id';
|
||||||
|
import { readableActive, writableActive } from '$libs/reactive';
|
||||||
|
import { WordsFindReplaceProvider } from '../words-provider.svelte';
|
||||||
|
import type { WordsFindReplaceProps } from '../types';
|
||||||
|
|
||||||
|
const uid = $props.id();
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
id = createId(uid, 'words-find-replace'),
|
||||||
|
children,
|
||||||
|
child,
|
||||||
|
...restProps
|
||||||
|
}: WordsFindReplaceProps = $props();
|
||||||
|
|
||||||
|
const state = WordsFindReplaceProvider.create({
|
||||||
|
id: readableActive(() => id),
|
||||||
|
ref: writableActive(
|
||||||
|
() => ref,
|
||||||
|
(v) => (ref = v as HTMLDivElement | null)
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
|
const mergedProps = $derived(mergeProps(restProps, state.props));
|
||||||
|
const divProps = $derived.by(() => {
|
||||||
|
const { ...props } = mergedProps;
|
||||||
|
return props;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if child}
|
||||||
|
{@render child({ ...state.snippetProps, props: mergedProps })}
|
||||||
|
{:else}
|
||||||
|
<div {...divProps}>
|
||||||
|
{@render children?.(state.snippetProps)}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
@ -0,0 +1,91 @@
|
|||||||
|
import { createText, type WordsDocument, type WordsText } from './document';
|
||||||
|
import { getTextEntries } from './selection';
|
||||||
|
import { updateNodeAtPath, type WordsPath } from './path';
|
||||||
|
|
||||||
|
export interface WordsTextMatch {
|
||||||
|
readonly path: WordsPath;
|
||||||
|
readonly start: number;
|
||||||
|
readonly end: number;
|
||||||
|
readonly text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FindOptions {
|
||||||
|
readonly caseSensitive?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findTextInDocument(
|
||||||
|
document: WordsDocument,
|
||||||
|
query: string,
|
||||||
|
options: FindOptions = {}
|
||||||
|
): readonly WordsTextMatch[] {
|
||||||
|
if (!query) return [];
|
||||||
|
const matches: WordsTextMatch[] = [];
|
||||||
|
const entries = getTextEntries(document);
|
||||||
|
const flags = options.caseSensitive ? 'g' : 'gi';
|
||||||
|
const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
const regex = new RegExp(escaped, flags);
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
const text = entry.text.text;
|
||||||
|
const entryMatches = [...text.matchAll(regex)];
|
||||||
|
for (const match of entryMatches) {
|
||||||
|
const start = match.index!;
|
||||||
|
const end = start + match[0].length;
|
||||||
|
matches.push({
|
||||||
|
path: entry.path,
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
text: match[0]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function replaceTextAtMatch(
|
||||||
|
document: WordsDocument,
|
||||||
|
match: WordsTextMatch,
|
||||||
|
replacement: string
|
||||||
|
): WordsDocument {
|
||||||
|
return updateNodeAtPath(document, match.path, (node) => {
|
||||||
|
if (!isWordsText(node)) return node;
|
||||||
|
const before = node.text.slice(0, match.start);
|
||||||
|
const after = node.text.slice(match.end);
|
||||||
|
return createText(before + replacement + after, node.marks);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function replaceAllTextInDocument(
|
||||||
|
document: WordsDocument,
|
||||||
|
query: string,
|
||||||
|
replacement: string,
|
||||||
|
options: FindOptions = {}
|
||||||
|
): WordsDocument {
|
||||||
|
const matches = findTextInDocument(document, query, options);
|
||||||
|
if (!matches.length) return document;
|
||||||
|
|
||||||
|
// Group matches by their text node path so we can process each node back-to-front
|
||||||
|
const byPath = new Map<string, WordsTextMatch[]>();
|
||||||
|
for (const match of matches) {
|
||||||
|
const key = match.path.join(',');
|
||||||
|
const list = byPath.get(key);
|
||||||
|
if (list) list.push(match);
|
||||||
|
else byPath.set(key, [match]);
|
||||||
|
}
|
||||||
|
|
||||||
|
let nextDocument = document;
|
||||||
|
for (const pathMatches of byPath.values()) {
|
||||||
|
// Sort descending by start so offsets remain valid as we mutate
|
||||||
|
pathMatches.sort((a, b) => b.start - a.start);
|
||||||
|
for (const match of pathMatches) {
|
||||||
|
nextDocument = replaceTextAtMatch(nextDocument, match, replacement);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nextDocument;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isWordsText(node: unknown): node is WordsText {
|
||||||
|
return typeof node === 'object' && node !== null && 'type' in node && (node as { type: string }).type === 'text';
|
||||||
|
}
|
||||||
Loading…
Reference in new issue