Repository navigation
Components es
🌐 Esta página en: English · Português · Español · Français · Deutsch
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.
| 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.
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].
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 esebuildy 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:
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 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.
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.
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).
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.
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.
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.
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.
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.
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.
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:
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.
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.
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ó.
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
Selecteden iOS,accessibilitySelecteden macOS,Selecteden 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.
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".
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.
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.
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.
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.
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
#appdel shell de la aplicación tiene altura EXACTA de viewport (height: 100dvh, grid, hijos conmin-height: 0): una página APP (raíz conHeight = 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
ScrollViewsin unHeightexplícito usa por defectoheight: 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.
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/SetColWidthson 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/CommitEditviven 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
SheetSurfaceque 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
Draggableinvisible 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 UNController.Resizedeshacible. Los suelos (MinColWidth24,MinRowHeight14) mantienen agarrable una astilla, acotados en el componente, no en elMin/Maxdel 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 divrole="grid"enfocable conuser-select: none(un arrastre de selección pinta la banda, no el barrido azul del navegador), keydown → elSheetKeymaptranspilado, 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 delcursorde CSS): la web emite la declaración, Photon registra unaCursorRegionque elCursorAtdel host responde de arriba hacia abajo, y el shell de macOS mapea aNSCursor. 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.
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:
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.
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.
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.
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.
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í.
Desde 0.2.0-preview.20
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: contentsno 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.
Desde 0.2.0-preview.23
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.
Desde 0.2.0-preview.16
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.
Desde 0.2.0-preview.15
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.
- 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,
CodeBlockyCodeEditor - Integración con el servidor: SSR, SEO y configuración del servidor
🏁 Empieza aquí
🏗️ Arquitectura
- Visión general de la arquitectura
- Componentes write-once
- Superficie declarativa
- Arquitectura de paquetes
- Componentes
- Estilos
- Localización
- Analytics y GTM
📱 Write-once
- Motor Photon
- Design System
- Capacidades
- Almacenamiento
- Formularios
- Editor de código
- Markdown
- Mermaid
- Renderizado de email
⚙️ Compilación
⚡ Runtime
🔌 Servidor
🎨 Ecosistema
🚀 Desarrollo