eQuantic.UIeQuantic.UI
Docs
Playground
GitHub
DocsArquitectura
Componentes
Edit this page
34 min read
La biblioteca de componentes (eQuantic.UI.Components) es write-once: cada componente se escribe una vez en C# contra el vocabulario abstracto de eQuantic.UI.Primitives, y se realiza según el destino, DOM + CSS en la web, píxeles de GPU a través de Photon en nativo. No "dos APIs parecidas", literalmente la misma clase. La arquitectura (capas, realizadores, paridad de hidratación) está documentada en Componentes write-once.
El modelo de componentes
Tipo
Descripción
StatelessComponent
Depende solo de sus propiedades. Sobrescribe Build(ComponentContext) para devolver el árbol.
StatefulComponent
Guarda estado interno persistente; SetState dispara una reconstrucción.
ComponentContext es la puerta sin modos por la que pasa todo lo que depende del destino: el tema, la densidad, la medición de texto (MeasureText/MonoAdvance) y los servicios de capacidad tomados por constructor (IThemeController, ITextClipboard, capacidades del dispositivo). Los componentes escriben tokens, nunca colores resueltos, así que un solo árbol construido se realiza en claro o en oscuro.
Los eventos son propiedades tipadas (OnPressed, OnChanged, OnSelectionChanged, …) que llevan delegados de C#; el mismo manejador corre en los dos destinos.
La capa web de bajo nivel (HtmlElement, ClassName, DynamicElement) sigue existiendo para las vías de escape solo-web. Vea Estilos.
Un componente puede escribirse sobre una base tuya
La base de un componente no tiene que ser StatelessComponent ni StatefulComponent directamente. Una base tuya (que guarde un marco compartido, un padding compartido, un prefetch que varias páginas repiten) es algo corriente de escribir, y todo lo que declara la subclase cruza a su gemelo: constructores, campos, propiedades, métodos de instancia, métodos estáticos y los stubs de [ServerAction].
1
2
3
4
5
6
7
8
9
10
11
12
13
14
public abstract class CardBase : StatelessComponent
{
protected VisualNode Frame(VisualNode child) =>
Box(new BoxStyle { Padding = EdgeInsets.All(Space.S4) }, child);
}
public sealed class Tile : CardBase
{
public Tile(string caption) => Caption = caption;
public string Caption { get; init; }
public override VisualNode Build(ComponentContext context) => Frame(Text(Caption));
}
Dos casos de Build, y quien lee se encuentra con los dos de inmediato:
•
El hijo sobrescribe Build. Su gemelo lleva ese build y cada miembro al que llega el cuerpo, los heredados incluidos.
•
El hijo no lo hace, y hereda el de la base. Su gemelo no lleva ningún build, y responde la cadena de prototipos: el de la base es el que corre.
Una base abstracta que declara miembros y ningún Build se emite con esos miembros, así que un hijo que llame hacia ella los encuentra ahí.
Lo que una base NO puede llevar es un [ServerAction]. El servidor registra una acción bajo el nombre del componente CONCRETO (se salta los tipos abstractos y recorre los métodos heredados), así que una acción declarada en la base se invocaría bajo un id que nada sirve, y la página recibiría "action not found" sin ningún error en ninguno de los dos lados. EQ2012 rechaza la forma: declara la acción en el componente concreto. En el componente mismo un [ServerAction] está bien, sea stateful o stateless.
Acciones y entradas: Button, IconButton, TextInput, SearchField, Checkbox, Switch, RadioGroup, Select, Slider, Stepper, SegmentedControl.
Superficies y presentación: Card, Divider, Badge, Chip, Avatar, Banner, ProgressBar, EmptyState, Skeleton.
Navegación: Tabs, AppBar, BottomNavigation, NavigationRail, Breadcrumb, Pagination, PageIndicator, Menu, Drawer. La barra y el raíl son los MISMOS destinos para dos anchos de ventana: entrega una lista de NavItem a los dos dentro de un AdaptiveNode y el shell sigue a la ventana, barra bajo un teléfono y raíl por el borde inicial de una tableta, sin ningún listener y sin un segundo estado. La barra admite de 3 a 5 destinos, el raíl de 3 a 7 (es más alto de lo que una barra es ancha); pasado eso la respuesta es un Drawer en cualquiera de los dos.
Desde 0.2.0-preview.29
La barra y el raíl comparten el MODELO de destino y no sus métricas: el handoff le da a cada uno las suyas (B4: una píldora de 56×26, un glifo Md 24, una etiqueta 11/700; C16: una píldora de 52×30 en una celda de 80×56, Dense 20, un Caption 12 que pasa a 700 cuando está seleccionado, y un borde de 1dp en el borde final). El raíl se había construido a partir de la barra, así que era la barra puesta de lado, y tres shells dibujaban el borde que ahora se pinta él mismo.
Desde 0.2.0-preview.31
Los destinos del raíl se sitúan donde dice Alignment (arriba por defecto, como Material, centrados bajo una cabecera). Toma el MainAlign del propio vocabulario en lugar de un enum con forma de raíl, y esperó a una porción del transpilador, no a una de diseño: un componente que reenvía su propiedad de enum a una ranura del vocabulario necesitaba que esa propiedad llegara al gemelo como UNIÓN y no como una cadena pelada.
Desde 0.2.0-preview.31
ListDetail es la tercera forma, y el primer componente del catálogo que es él mismo ADAPTATIVO: una lista al lado de un detalle en una ventana ancha, un panel a la vez en un teléfono.
Desde 0.2.0-preview.31
La aplicación es dueña de los DATOS: un nodo de lista, y un nodo de detalle una vez que se elige algo. El componente es dueño de la REGLA, que es la parte que cada aplicación estaba a punto de escribir otra vez, y habría escrito dos veces, porque los dos anchos parecen dos pantallas distintas y no lo son:
1
2
3
4
5
6
7
8
9
new ListDetail(
list: Inbox.List(_selected, i => SetState(() => _selected = i)),
detail: _selected is { } chosen ? Inbox.Detail(chosen) : null,
onBack: () => SetState(() => _selected = null))
{
ListTitle = "Inbox",
Title = _selected is { } open ? Inbox.Items[open].Title : null,
Placeholder = EmptyState(Icons.Mail, "Pick a message"),
}
Un solo campo responde a las dos formas. En un teléfono que Detail sea null es un LUGAR: la lista es la pantalla, y atrás des-elige; pasado TwoPaneFrom (840dp, la clase Expanded de la especificación) es solo una ausencia, que llena Placeholder. El recurso de volver atrás se dibuja SOLO en el panel compacto: en ancho los dos paneles son visibles, y un control que te devuelve a algo que ya ves es la forma en la que un layout adaptativo se lee como roto. dotnet new equantic-native --shell list-detail genera exactamente la llamada de arriba.
El detalle compacto responde a ATRÁS: liga Escape a OnBack, la tecla con la que un escritorio cierra una capa y aquella a la que el shell de Android de Photon envía su gesto de volver (Photon), así que en un teléfono atrás deja el detalle por la lista, y en una ventana de navegador estrecha Escape hace lo mismo. Nada más la liga: en ancho los dos paneles están en pantalla, y con la lista en pantalla atrás es del sistema, que sale de la aplicación.
Una valla honesta, sobre el cambio del panel compacto, que es una navegación y no solo un re-layout: el foco del teclado se queda donde estaba en lugar de moverse al panel que llegó (el vocabulario todavía no tiene un "enfoca este subárbol" neutral respecto al destino).
Overlays: Dialog, Toast, BottomSheet (arrastrar para descartar), Popover, Tooltip (revelado por hover, solo puntero, cero JS).
Listas y datos: List/ListItem, ListView (con reciclado, más abajo), Accordion, Table, DataTable, Spreadsheet (más abajo).
Una fila mide 52 / 68 / 88 de alto para una, dos y tres líneas (SubtitleLines = 2 pide la tercera, que existe para que el subtítulo de un ajuste pueda tomar una segunda frase), y la List mete cada divisor hasta donde la fila DE ARRIBA empieza su texto, 16 + leading + gap, así que una línea fina nunca corta un icono. Lo deriva de la ranura: un Icon lleva su tamaño y un Avatar su nivel, y solo una ranura que la aplicación construyó ella misma tiene que declarar un LeadingWidth.
Desde 0.2.0-preview.31
Una valla, nombrada porque es un hueco y no una preferencia: una lista SELECCIONABLE sigue siendo una fila de paradas de tabulación individuales, no una parada con ↑/↓ y Espacio. Por eso ListItem.Selected declara aria-current="page" (un destino de navegación) y no aria-selected en un listbox: la parada móvil pertenece al contenedor, y hasta que List la componga, una fila option dejaría el orden de tabulación sin nada que lo devuelva.
Interacción táctil: PullToRefresh, SwipeableRow. Una fila que descansa abierta conserva el transform que dibuja su propia caja, el de su hover incluido: el desplazamiento es el translate individual que el navegador aplica junto con transform, igual que Photon compone los dos, y un deslizamiento que termina sin llegar a abrir se desliza de vuelta (desde 0.2.0-preview.61).
Código: CodeBlock (solo lectura) y CodeEditor (caret, selección, teclado). El modelo completo y las dos superficies están documentados en la página Editor de código.
Cada componente se entrega con pins del realizador web, imágenes golden nativas (claro + oscuro), fixtures transpilados y fijados que se ejecutan en vitest, y el showroom en vivo (/ y /shared en DefaultUIDashboard: SSR + identidad de hidratación + interacción verificadas de punta a punta por la suite de Playwright). Sistemas que se entregan junto a la biblioteca: el sistema de movimiento por transición de estado (entrada/salida de Presence), la pipeline de puntero (hover, arrastrar para descartar con slop/cancel/glide), el compositor de scroll (anclado real con Pinned), los overlays anclados y la gestión de foco modal (más abajo).
El contrato de interacción
El contrato de interacción del design system (§10 de la especificación) lo implementan los realizadores, así que cada componente lo hereda en lugar de improvisarlo uno a uno. Lo que sigue es el comportamiento entregado.
El hover es derivado, y nunca se dispara en táctil
Desde 0.2.0-preview.24
No hay una sexta ranura de color: el relleno de hover de un control relleno es derivado (el punto medio por canal de Base → Pressed, expuesto como VariantColors.Hover), y las variantes discretas (Outline, Ghost, IconButton Standard) hacen hover sobre SurfaceSubtle, el relleno que su estado pressed ya usa. Button e IconButton lo escriben; en la web cada regla :hover que emite el motor atómico vive detrás de @media (hover: hover), así que el tap-hover pegajoso de un navegador táctil no encuentra ninguna regla, y en Photon un movimiento etiquetado como táctil se salta el hover por completo. Pressed siempre le gana a hover, y la entrada y salida del hover van en el mismo peldaño de 100 ms que la respuesta de pressed.
Los controles de selección compuestos son UNA parada de tabulación
Desde 0.2.0-preview.24
RadioGroup y SegmentedControl siguen el contrato nativo de <input type=radio>: el grupo es un único role="radiogroup" enfocable que lleva el nombre del grupo, las flechas mueven la selección y dan la vuelta en los extremos (Abajo es el siguiente en un radiogroup: orden de lectura, no valor de slider), y cada opción es un role="radio" que declara aria-checked, fuera del orden de tabulación, todavía pulsable con el puntero. El vocabulario lleva esto como PressableRole.Radio, y la forma se ha adoptado desde entonces en los compuestos: los elementos de Tabs son role="tab" que declaran aria-selected (una pestaña se escoge, no se marca) bajo la única parada de la tira, y las filas de Menu/Select son menuitem/option (ver más abajo).
Las superficies modales son dueñas del teclado, y lo devuelven
Desde 0.2.0-preview.24
Un Overlay modal y abierto (que es sobre lo que se construyen Dialog, Drawer y BottomSheet) es un diálogo en la web: role="dialog", aria-modal="true" (la página de detrás es inerte para la tecnología de apoyo), y tabindex="-1" para que la capa misma sea el recurso de foco inicial (una hoja sin nada enfocable enfoca la hoja, según la especificación). Mientras está abierta, Tab y Shift+Tab circulan dentro y una guarda de focusin recupera cualquier cosa que se escape por otra vía; cuando se cierra (eliminada, cerrada manteniéndose montada a través de Overlay.Motion, o a mitad de la animación de salida) el foco vuelve al elemento que la abrió, en todos los caminos de cierre. Los diálogos se apilan en orden inverso al de entrada. Toast nunca atrapa (es la capa no modal), y Popover deliberadamente no está atrapado: un popover es una superficie no modal, así que Tab sale de él.
Desde 0.2.0-preview.29
La capa también declara su NOMBRE (Overlay.Label, ya que el Dialog pasa su título como aria-label), una confirmación destructiva es un role="alertdialog" anunciado de forma asertiva (Overlay.Alert), y el foco inicial prefiere la acción SEGURA: el Dialog marca su botón Ghost (Button.InitialFocus), así que un Enter pulsado por reflejo cancela en lugar de destruir.
Las teclas propias de un componente responden donde está el teclado
Desde 0.2.0-preview.59
Un Shortcut responde donde sea que esté el teclado mientras su subárbol está montado, y el último montado gana un acorde: lo que necesitan el ⌘K de una paleta y el Escape de un diálogo. Las teclas propias de un componente son del otro tipo, y FocusScoped es para ellas: el acorde responde solo mientras el foco está dentro del subárbol, y una tecla que ningún ámbito tiene se deja en paz, al navegador en la web.
1
new Shortcut(form, KeyChord.Command("s"), Save) { FocusScoped = true }
El ⌘F del editor de código y el F7 del diff se declaran así. Con dos editores en una página, ⌘F abría el buscar del último montado, donde quiera que estuviese el teclado. Abre el buscar de aquel en el que se está escribiendo ahora, y con el teclado en ninguno de los dos el navegador conserva su propio buscar. Los Shortcuts de Flutter siempre responden al foco: este es de ese tipo, al lado del que vale para toda la página.
Desde 0.2.0-preview.60
Un acorde que solo vale mientras algo está en pantalla se queda en el árbol y declara si está vivo con Enabled: si no, no liga nada, así que la tecla sigue hacia lo que sea que la tomaría, igual que una Action que no está habilitada la deja pasar en Flutter. Montado y desmontado alrededor de su subárbol en cambio, movería ese subárbol dentro del árbol.
1
new Shortcut(layers, KeyChord.Escape, CloseFind) { FocusScoped = true, Enabled = findOpen }
La barra de buscar del editor de código se cierra así: Escape cierra la barra del editor en el que está el teclado, la barra o el código, y mientras la barra está cerrada un diálogo alrededor del editor conserva su propio Escape. Select, Menu, TimePicker y DatePicker sostienen las teclas de su panel igual, así que abrir un panel no mueve nada en el árbol y el foco del teclado se queda donde estaba.
De dos atajos del mismo acorde donde uno envuelve al otro, responde el de dentro, en la web y en Photon, igual que los atajos más cercanos al foco responden primero en Flutter: en un diálogo, el Escape de la barra de buscar cierra la barra, y el siguiente Escape cierra el diálogo.
Los campos le dicen a la tecnología de apoyo su nombre, y su error
Desde 0.2.0-preview.24
TextInput pasa su etiqueta a la entrada como el nombre accesible, su texto de ayuda o de error como la descripción accesible (asociada vía aria-describedby, anunciada con cortesía cuando cambia a un error), y la condición de error como aria-invalid. Lo que oye un lector de pantalla es exactamente la cadena que lee quien ve. SearchField y la barra de buscar del editor de código promueven su placeholder a nombre real: un placeholder desaparece bajo el texto, así que nunca sustituye a uno.
El estado de una casilla es un atributo, nunca su nombre
Desde 0.2.0-preview.29
Checkbox es un role="checkbox" y Switch un role="switch", cada uno declarando su estado como aria-checked, con "mixed" para la casilla indeterminada, y solo ahí, la regla propia de ARIA. El NOMBRE accesible es para qué sirve el control, y nunca cambia cuando cambia el estado (un nombre que alterna se lee como si apareciera un control distinto). De forma nativa el árbol de semántica lleva la misma respuesta (SemanticCheck Off/On/Mixed), que macOS mapea al valor numérico de AXCheckBox, así que VoiceOver anuncia el estado en el idioma del usuario, que es por lo que las viejas cadenas "Checked"/"On" salieron del SDK por completo.
Un slider dice lo que tiene
Desde 0.2.0-preview.55
Un Slider declara su VALOR, no solo su nombre: aria-valuenow, aria-valuemin y aria-valuemax en la web, y el valor del nodo semántico en Photon, así que VoiceOver y TalkBack anuncian la misma cadena. ARIA exige que role="slider" lleve aria-valuenow, y cada slider anterior a esta versión anunciaba su etiqueta y nada más. Cuando el número no es lo que diría una persona, valueText lo dice en palabras, y un lector anuncia esas palabras EN LUGAR del número:
1
Slider(budget, onBudget, valueText: "R$ 400");
En el vocabulario es Adjustable.Value, un RangeValue(Now, Min, Max) con un Text opcional, y el rol sigue al valor: un Adjustable en su rol por defecto es un slider cuando tiene uno y un group cuando no tiene ninguno, que es lo que es un contenedor enfocable que las flechas ajustan. Un valor en Tablist o Radiogroup, roles que no llevan ninguno, no se emite.
Una región viva dice lo que acaba de cambiar
Desde 0.2.0-preview.56
Algunos cambios no tienen foco que los lleve: aparece un toast, un banner cambia su texto, un filtro deja tres resultados donde había cuarenta. Un lector de pantalla no dice nada de todo eso a menos que la región que cambió se anuncie. LiveRegion envuelve la parte que cambia, y los dos realizadores la hablan: aria-live en la web (polite, o assertive para LiveRegionUrgency.Assertive), y la ranura de anuncio de la semántica nativa en Photon, que es lo que leen VoiceOver y TalkBack.
1
LiveRegion(results, label: "Results");
Banner y Toast ya llevan una, así que lo que dicen se anuncia sin que quien llama lo pida. El anuncio se calcula UNA VEZ POR CAMBIO, desde el frame en el que se dibuja el contenido y no desde el recorrido de semántica: ese recorrido responde a un puente de plataforma cuando la tecnología de apoyo tiene curiosidad, lo que puede ser nunca, una vez o tres veces en el mismo frame, y un anuncio colgado de él se repetiría un número de veces que nadie eligió.
Una navegación dice DÓNDE ESTÁS
Desde 0.2.0-preview.31
Una barra anunciaba sus tres destinos de forma idéntica, "Activity, button", "Search, button", "Profile, button", porque el activo era una píldora y un tinte, que es absolutamente nada para un lector de pantalla. Es la única pieza de estado que una navegación existe para llevar, y ninguna de las tres grafías que tenía el vocabulario podía decirla: un destino no está PRESSED (no alterna), no está CHECKED (no se está eligiendo nada), y no está SELECTED (una pestaña cambia un panel; un destino cambia dónde ESTÁS).
Así que PressableRole.Destination se sumó al conjunto, y Link.Current a su lado para el modismo de navegación propio de la web. BottomNavigation, NavigationRail y un ListItem marcado Selected lo declaran por ti; una barra lateral construida a mano con Links dice Current = true en el que coincide con la ruta.
•
Web: aria-current="page", y solo en el actual. aria-current="false" es legal, inútil y se lee en voz alta, así que nueve enlaces anunciando "no actual" es peor que el silencio.
•
Nativo: el árbol de semántica lleva el bit, y cada puente lo reporta con la palabra más cercana que tiene su plataforma: el trait Selected en iOS, accessibilitySelected en macOS, Selected en la info de nodo de Android.
•
Orden de tabulación: un destino CONSERVA su parada de tabulación, al contrario que una pestaña o un radio. Una navegación es una lista de enlaces, no un compuesto con un único punto de entrada, y quien usa el teclado y llega a una barra espera recorrerla.
Los tooltips llegan al teclado, y Escape los descarta
Desde 0.2.0-preview.29
El foco del teclado revela un Tooltip exactamente como el hover (:has(:focus-visible), focus-visible y no focus-within, porque un tooltip que salta en cada clic de ratón está en medio), la píldora es un role="tooltip" real al que el disparador apunta con aria-describedby (un lector de pantalla oye la pista después del nombre del control sin que nada se revele), y Escape oculta un tooltip revelado SIN mover puntero ni foco, que es la cláusula de descartabilidad de la WCAG 1.4.13. Salir del disparador limpia la supresión, así que la siguiente visita vuelve a revelar: Esc es un "no ahora", nunca un "nunca más".
Un disparador dice si está abierto
Desde 0.2.0-preview.29
Un chevron rotado es pintura, y la pintura no dice nada a la tecnología de apoyo. Pressable.Expanded declara el despliegue: aria-expanded en la web, el bit de expandido del nodo de semántica nativa (el "expanded"/"collapsed" de VoiceOver en macOS). Y null traza la misma línea que traza Selected: "no controla ningún despliegue" es una respuesta distinta de "controla uno, ahora cerrado". Las cabeceras del Accordion, el campo del Select y el disparador del Menu lo declaran todos.
Los menús y los selects hablan todo su patrón
Desde 0.2.0-preview.29
Un Menu abierto es un role="menu" de filas menuitem (los comandos deshabilitados incluidos, anunciados atenuados en vez de escondidos), con su disparador llevando aria-haspopup/aria-controls. Un Select abierto es el patrón combobox completo: el CAMPO es el role="combobox", el panel un role="listbox" de filas option numeradas que declaran aria-selected. Y en los dos, el resalte de teclado que mueven las flechas, que de otro modo sería solo pintura, se declara como aria-activedescendant en el disparador, así que un lector de pantalla que sigue al teclado oye cada paso. El foco mismo nunca sale del disparador, que es la disposición propia del patrón combobox.
Las cadenas incorporadas viven detrás de una sola costura
Desde 0.2.0-preview.24
Ningún componente lleva una cadena de cara al usuario escrita a mano: los recursos para descartar, el placeholder de búsqueda, las etiquetas de la barra de buscar, todos leen SdkStrings, la costura respaldada por resx que la vía de localización llena por cultura. El conjunto se ENCOGIÓ en la preview.29: los viejos anuncios de alternancia ("Checked", "On") se fueron cuando el estado de una casilla pasó a aria-checked, porque la plataforma anuncia el estado en el idioma del usuario, que ningún resx nuestro podría igualar.
ListView: la lista con reciclado
Una lista que solo CONSTRUYE las filas que puedes ver: dale un recuento, una extensión fija por elemento y un constructor por índice, y materializa la ventana visible más un margen de overscan. El resto de la lista son dos espaciadores, así que el layout (y la barra de scroll) ven la altura real del contenido mientras diez mil filas cuestan lo que cuesta una pantalla. Write-once: el mismo C# corre en Photon y en la web.
1
2
3
4
5
new ListView(count: 10_000, itemExtent: 44, itemBuilder: i => RowFor(i))
{
Width = SizeValue.Fill,
Height = SizeValue.Fill, // la lista es una VENTANA, dale una
}
La ventana converge en lugar de bloquear: el primer frame construye contra una suposición de viewport, el frame realizado reporta el viewport y el desplazamiento reales por los canales de salida del ScrollView (OnScrolled / OnViewportChanged, conectados en los dos destinos), y el siguiente frame construye la ventana corregida. Los píxeles hacen scroll sin una reconstrucción; el estado solo cambia cuando una fila cruza el margen de overscan. Alcance (v1): listas verticales con un ItemExtent fijo; las extensiones variables no están incluidas.
Reglas de layout que hacen que las ventanas funcionen en todas partes (invariantes del SDK, no tareas de la aplicación):
•
El marco #app del shell de la aplicación tiene altura EXACTA de viewport (height: 100dvh, grid, hijos con min-height: 0): una página APP (raíz con Height = Fill) resuelve a exactamente un viewport y hace scroll internamente; una página DOCUMENTO (raíz de altura automática) desborda el marco y el body hace scroll como siempre hace un documento.
•
Un ScrollView sin un Height explícito usa por defecto height: 100% en la web, porque una vista de scroll ES la ventana que le da su padre, nunca su contenido (paridad nativa: el realizador le entrega los límites de layout y recorta siempre).
•
La hidratación adopta los marcadores data-eq-* del framework del árbol del cliente, porque el SSR no puede conocer identidades del lado del cliente, y las pasadas posteriores encuentran sus elementos por ellos.
Spreadsheet: la cuadrícula editable
Una hoja de cálculo con usabilidad de Excel, construida sobre el mismo principio que el Editor de código: el controlador lleva todo, los píxeles son aritmética. Un modelo en C# + un componente en C# se renderizan en Photon y en el DOM; el build del navegador transpila exactamente las mismas fuentes.
El modelo (eQuantic.UI.Primitives/Sheet, compartido literalmente):
•
SheetDocument: celdas dispersas (vacío es ausente), tamaños por fila y por columna, extensión lógica. SetCell/SetRowHeight/SetColWidth son el camino de carga y de previsualización, deliberadamente no deshacibles.
•
CellRef/SheetRange: direccionamiento A1; selección ancla/foco en 2D. Las celdas se indexan por un int empaquetado (row × 16384 + col) porque un record struct es una clave inútil para un Map de JS.
•
SheetController: la semántica de Excel como métodos. Flechas/Shift extienden, Ctrl+flecha salta al borde de los datos, Tab/Enter recorren (y dan la vuelta) dentro de una selección mientras el rectángulo se queda quieto (celda activa ≠ foco de la selección, deliberadamente), selecciones por banda de cabecera, insertar/borrar/redimensionar filas y columnas con undo/redo disperso basado en inversas, copiar/pegar TSV hablando el entrecomillado de Excel, y el **borrador de edición en celda**: BeginEdit/TypeIntoDraft/CommitEdit viven en el controlador, así que los dos destinos editan de forma idéntica y un borrador confirmado se deshace como un solo paso.
•
SheetKeymap: el ÚNICO teclado. Escribir reemplaza (la entrada rápida de Excel), F2 edita en el sitio, Enter/Tab confirman y avanzan, Escape descarta, las flechas confirman y luego mueven, ⌘A/⌘Z. El host nativo y el lowering web delegan los dos aquí; el dialecto no puede bifurcarse.
El componente (eQuantic.UI.Components/Spreadsheet.cs):
•
Cabeceras de columna fijas arriba; las cabeceras de fila hacen scroll CON las filas; la ventana visible de celdas se envuelve en un SheetSurface que vive DENTRO del scroll, así que las marcas se trasladan con el contenido y un clic en una cuadrícula desplazada selecciona la celda que de verdad está bajo el puntero, por construcción.
•
Las filas se virtualizan a la manera del ListView (ventana + espaciadores); la banda de selección, el anillo de la celda activa y el borrador de edición con su caret se pintan SOBRE las celdas, en el componente, así que los dos destinos son visualmente idénticos porque no hay nada del lado del realizador que pueda desviarse.
•
Selección al estilo Excel: los clics en cabecera seleccionan toda su fila o columna (la esquina selecciona todo, y las cabeceras se sombrean cuando la banda de la selección las cruza); shift+clic estira desde el ancla sin mover la celda activa; un arrastre normal barre un rango en los dos destinos.
•
El tirador de relleno, el cuadradito de Excel en la esquina inferior derecha de la selección, arrastra una previsualización por el eje DOMINANTE y vierte el bloque de origen por él al soltar, embaldosando en fase con vuelta en las cuatro direcciones, como UN solo paso de undo. ⌘D/⌘R vierten hacia abajo y a la derecha por el mismo motor en el keymap compartido. La semántica del gesto (BeginFill/UpdateFill/CommitFill, Fill, FillDown, FillRight, SelectTo) vive en el controlador, así que los dos destinos rellenan de forma idéntica.
•
Redimensionar arrastrando: el borde final de cada cabecera lleva un asidero Draggable invisible de 6dp (Stack + Positioned, Follows = false). Los movimientos previsualizan directamente en el documento (SetColWidth/SetRowHeight); al soltar se rebobina la previsualización y se asienta todo el gesto como UN Controller.Resize deshacible. Los suelos (MinColWidth 24, MinRowHeight 14) mantienen agarrable una astilla, acotados en el componente, no en el Min/Max del nodo (esos reconstruyen a mitad del arrastre). Dentro del ScrollView el asidero le gana al scroll: una superficie de arrastre es el gesto más específico (regla del host, PressDown).
•
Conexión de la interacción: el host nativo encamina clics, arrastre de selección, teclas, portapapeles TSV y texto a prueba de IME al controlador; el lowering web (lowerSheetSurface) es un div role="grid" enfocable con user-select: none (un arrastre de selección pinta la banda, no el barrido azul del navegador), keydown → el SheetKeymap transpilado, doble clic edita, y copiar/cortar/pegar van sobre los eventos de portapapeles propios del navegador en TSV.
•
Los cursores de puntero son vocabulario (BoxStyle.Cursor, el espejo del cursor de CSS): la web emite la declaración, Photon registra una CursorRegion que el CursorAt del host responde de arriba hacia abajo, y el shell de macOS mapea a NSCursor. Los asideros de redimensionado dicen col-resize/row-resize y el tirador de relleno dice crosshair, en los dos destinos, desde el mismo código de componente.
Alcance (v1): sin motor de fórmulas, sin formato por celda, sin combinaciones, sin paneles congelados. La virtualización es solo vertical (las columnas se materializan), y no hay scroll en 2D, así que las columnas más allá del ancho del panel quedan recortadas. Las suites de controlador, de edición y de componente recorren los dos ejes de redimensionado a través del host; el gemelo transpilado corre los mismos movimientos bajo vitest, más especificaciones de superficie web con eventos de DOM reales.
Una insignia en la esquina, y el contrato que Positioned es
Positioned es un contrato con el PADRE: como un peso de flex, no significa nada en ningún otro sitio. Tiene que ser hijo de un Stack, y hasta la 0.2.0-preview.18 eso quería decir hijo directo: un componente cuyo Build devolvía uno no era reconocido, degradaba a su hijo y se unía al flujo. Un botón de esquina se renderizaba encima de la losa a la que pertenecía, pegado al borde izquierdo, en los dos destinos y en silencio.
Los dos realizadores resuelven ahora a través del componente, así que esto funciona:
1
2
3
4
Stack(children: [
CodeSlab(code),
CopyAction(code), // su Build devuelve Positioned(top: 0, end: 0)
])
Un hijo posicionado sigue sin participar en el tamaño del CONTENIDO del stack, o una insignia de esquina haría crecer la losa sobre la que se apoya.
Colocado por fracción, desplazado por su propio tamaño
Desde 0.2.0-preview.61
Un borde es un desplazamiento en puntos, una FRACCIÓN del stack, o los dos sumados; luego ShiftX y ShiftY mueven al hijo por fracciones de su PROPIO tamaño (el FractionalTranslation de Flutter). Así es como algo se coloca sobre una caja cuyo tamaño la aplicación nunca llega a conocer: un tooltip centrado 16 por encima del punto al 50% / 25% de un mapa, sea cual sea el ancho que acabe teniendo el mapa.
1
Positioned(tooltip, top: -16, startFraction: 0.5f, topFraction: 0.25f, shiftX: -0.5f, shiftY: -1f)
La web escribe left: 50%; top: calc(25% - 16px); transform: translate(-50%, -100%). Photon coloca con la misma aritmética en su pasada de layout, así que la región de impacto está donde se dibuja el hijo, igual que un transform de CSS lo deja en el navegador.
Una cuadrícula que repite una pista tantas veces como quepa
Desde 0.2.0-preview.61
GridTrack.AutoFill(min) son tantas columnas como quepan con pistas de al menos min de ancho, el gap de la cuadrícula entre ellas, repartiendo lo que queda: la cuadrícula de tarjetas que cada handoff dibuja como repeat(auto-fill, minmax(210px, 1fr)), que la web escribe como minmax(min(210px, 100%), 1fr) para que una cuadrícula más estrecha que una pista sea una columna de su propio ancho en lugar de un desborde, igual que la dibuja Photon. El mínimo es de al menos 1dp y el peso de al menos 1.
1
Grid([GridTrack.AutoFill(210)], gap: 10, width: SizeValue.Fill, children: [.. cards])
Es toda la lista de columnas de la cuadrícula: al lado de otra pista necesitaría un solucionador para repartir el ancho, y el constructor la rechaza con la razón. En un ancho que la cuadrícula dimensiona desde su propio contenido no hay nada que dividir, así que es una columna en su mínimo, que es lo que responde CSS ahí.
Saber cuándo algo está en pantalla
Desde 0.2.0-preview.20
1
InView(Heading(section.Title), visible => SetState(() => _active = visible ? section.Id : _active))
La pregunta que una tabla de contenidos hace de verdad es qué encabezado está mirando quien lee, y la única manera de responderla era la posición de scroll de la página, que vive en un ScrollView, así que el artículo tenía que envolverse en uno. Eso cambia el modelo de scroll de toda la página: la cabecera fija deja de fijarse a la ventana, la restauración de scroll propia del navegador deja de funcionar, y la barra de direcciones deja de esconderse en un teléfono.
Un desplazamiento crudo tampoco habría ayudado mucho. Es un número en las coordenadas de la página, y responder "está este encabezado más allá de él" necesita la posición de cada encabezado, una medición que un componente write-once no tiene por qué hacer, y no puede hacer igual en los dos destinos. La presencia es la pregunta, así que la presencia es lo que esto reporta.
•
Se dispara solo en las transiciones: true cuando el hijo entra en vista, false cuando sale. Nunca una vez por frame mientras está ahí.
•
Threshold (0 a 1) pide más que una astilla: un encabezado medio fuera por arriba no es el que está mirando quien lee.
•
No visible es el estado supuesto, así que un componente que se monta con su hijo fuera de pantalla no oye nada. Treinta encabezados no anuncian treinta ausencias.
•
Transparente al layout: no toma espacio y no dibuja nada.
En la web es un IntersectionObserver: ningún listener de scroll, así que ningún trabajo en los frames donde nada cruza, y la medición ocurre fuera del hilo principal. Photon compara los límites con la superficie una vez por frame, en un recorrido que ya está haciendo.
Los dos recortan. Una fila desplazada fuera de un ScrollView, o fuera de una Box que recorta, no está en pantalla. En Photon lo que cuenta como visible se estrecha a través de cada recorte, igual que ya se estrechan la pintura y la entrada; en la web el observer recorta a cada ancestro por sí mismo. Así que una lista en ventana solo carga lo que quien lee puede ver, que es el trabajo que esto hace barato. El recorte se estrecha en Photon desde la 0.2.0-preview.21.
Dos cosas tenían que ser verdad en la web antes de que nada de esto reportara algo, y las dos eran falsas cuando se entregó (corregido en la 0.2.0-preview.21):
•
El marcador va en el HIJO, nunca en el envoltorio transparente al layout. display: contents no genera ninguna caja, así que un observer apuntado al envoltorio vigila un rectángulo de 0×0 en el origen. Reporta con fidelidad, sobre un lugar donde el encabezado nunca está.
•
Los observers se adjuntan después de que la pasada se haya ESCRITO. Una pasada produce un árbol; el gestor de render lo escribe una vez que la pasada vuelve. Confirmar dentro de la pasada buscaba elementos que todavía no existían, no encontraba ninguno y limpiaba las declaraciones, así que una página recién hidratada llevaba cada nodo marcado sin observer, y solo un re-render posterior los adjuntaba. En una página donde nada más cambia nunca, eso es nunca.
Dónde deja un enlace a quien lee
Desde 0.2.0-preview.23
1
Link("/docs/Storage", Text("Storage")) { KeepsPosition = true }
Una navegación empieza la página nueva en su parte de arriba, lo que está bien para un enlace que te lleva a otro sitio y mal para uno que cambia un panel al lado de una lista por la que ibas a mitad de camino. Una barra lateral de documentación es el caso que le da nombre: el shell se preserva y solo se parchean un par de cientos de nodos, y entonces todo salta arriba, lo que se lee, para la persona que lo está mirando, exactamente como si la página se hubiera recargado.
El chrome que conserva su PROPIO scroll nunca tuvo el problema y no necesita esto. Es para los layouts donde la lista y el contenido comparten un solo scroll.
Mostrar un overlay que nada abrió
Desde 0.2.0-preview.16
1
2
3
4
InFlow(Dialog("Delete this?", "It cannot be undone.", [new DialogAction("Delete")]))
InFlow(Drawer(content)) // no hace falta `open: true`, ver más abajo
InFlow(BottomSheet(content))
InFlow(Popover(trigger, panel))
Un diálogo, un cajón, una hoja y un popover solo existen una vez que algo los abre, y cuando lo hacen se quedan con todo el viewport: un scrim sobre la página, la superficie centrada o anclada delante de ella, una ligadura de Escape, una animación de entrada. Una página que quiere MOSTRAR uno (documentación, una revisión de diseño, una suite de regresión visual) quiere la superficie y nada de eso. Cinco componentes se renderizaban en blanco.
InFlow declara la intención; cada overlay la responde construyendo su panel y nada más, porque el componente es lo único que sabe qué es su superficie.
En el flujo no hay abierto ni cerrado: el panel está colocado, por lo tanto está ahí. Open gobierna si la CAPA existe, y no hay ninguna capa. Un cajón abraza su contenido en lugar de llenar el viewport (la altura completa pertenece al borde al que está anclado, y no tiene borde); un popover dibuja su panel sin su disparador; un PullToRefresh descansa con su indicador descubierto, ya que no hay nada que tirar.
No es un modo de previsualización: la superficie es real, con acciones reales. Lo que se descarta es la presentación. Y la intención termina con el nodo: un diálogo abierto de verdad al lado de uno previsualizado sigue quedándose con el viewport.
Mostrar un estado que no ha ocurrido
Desde 0.2.0-preview.15
1
2
Simulated(SimulatedState.Hovered, Button("Save"))
Simulated(SimulatedState.Pressed | SimulatedState.Focused, IconButton(Icons.Check))
Un botón pulsado no se le puede entregar a un constructor: pulsado es algo que el host observa, no algo que un árbol declara. Así que una galería de documentación, una revisión de diseño y una suite de regresión visual solo podían mostrar el estado de reposo de un control, y los tres estados que quien lee más quiere comparar eran los tres que ninguna página podía dibujar.
Un nodo en lugar de una propiedad en cada componente interactivo: compone, cubre los componentes que todavía no existen, y funciona para un control anidado tres niveles dentro de una tarjeta que se está previsualizando. Las previsualizaciones anidadas se COMBINAN: una tarjeta con hover que contiene un botón pulsado son dos nodos, y el de dentro no apaga el hover del de fuera.
Cambia solo lo que se DIBUJA. Nada se invoca, no se entra en ningún estado, no corre ningún manejador, y el estado termina con el nodo: el control que está a su lado se comporta con normalidad.
En la web, :hover y :focus-visible no se pueden forzar desde CSS (nada se puede, que es el punto de una pseudoclase), así que los diffs que llevan esas reglas se plegan dentro de la regla base. Las mismas declaraciones, así que la previsualización es la cosa real y no una aproximación de ella. Una pulsación simulada reutiliza el mismísimo cuerpo de selector que usa una pulsación real, que es lo que evita que las dos se desvíen.
Ver también
•
Componentes write-once: la arquitectura compartida sobre la que van estos componentes
•
Design System: tokens, densidad, theming, el motor de estilos atómico
•
Editor de código: el modelo del editor, CodeBlock y CodeEditor
•
Integración con el servidor: SSR, SEO y configuración del servidor