eQuantic.UIeQuantic.UI
Docs
Playground
GitHub
DocsArchitecture
Composants
Edit this page
34 min read
La bibliothèque de composants (eQuantic.UI.Components) est write-once : chaque composant est écrit une fois en C# contre le vocabulaire abstrait d'eQuantic.UI.Primitives, et réalisé selon la cible, DOM + CSS sur le web, pixels GPU via Photon en natif. Pas "deux API similaires", littéralement la même classe. L'architecture (couches, réalisateurs, parité d'hydratation) est documentée dans Composants write-once.
Le modèle de composants
Type
Description
StatelessComponent
Ne dépend que de ses propriétés. Surchargez Build(ComponentContext) pour renvoyer l'arbre.
StatefulComponent
Détient un état interne persistant ; SetState déclenche une reconstruction.
ComponentContext est la porte sans modes par laquelle passe tout ce qui dépend de la cible : le thème, la densité, la mesure de texte (MeasureText/MonoAdvance) et les services de capacité pris par constructeur (IThemeController, ITextClipboard, les capacités de l'appareil). Les composants écrivent des tokens, jamais des couleurs résolues, si bien qu'un seul arbre construit se réalise en clair comme en sombre.
Les événements sont des propriétés typées (OnPressed, OnChanged, OnSelectionChanged, …) qui portent des délégués C# ; le même gestionnaire tourne sur les deux cibles.
La couche web bas niveau (HtmlElement, ClassName, DynamicElement) existe toujours pour les échappatoires propres au web. Voir Styles.
Un composant peut s'écrire sur une base à vous
La base d'un composant n'a pas à être StatelessComponent ou StatefulComponent directement. Une base à vous (qui détient un cadre partagé, un padding partagé, un prefetch que plusieurs pages répètent) est une chose ordinaire à écrire, et tout ce que la sous-classe déclare passe dans son jumeau : constructeurs, champs, propriétés, méthodes d'instance, méthodes statiques et 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));
}
Deux cas de Build, et un lecteur rencontre les deux tout de suite :
•
L'enfant surcharge Build. Son jumeau porte ce build et chaque membre que le corps atteint, les membres hérités compris.
•
L'enfant ne le fait pas, et hérite de celui de la base. Son jumeau ne porte aucun build, et c'est la chaîne de prototypes qui répond : celui de la base est celui qui tourne.
Une base abstraite qui déclare des membres et aucun Build est émise avec ces membres, donc un enfant qui l'appelle les y trouve.
Ce qu'une base ne peut PAS porter, c'est un [ServerAction]. Le serveur enregistre une action sous le nom du composant CONCRET (il saute les types abstraits et parcourt les méthodes héritées), donc une action déclarée sur la base serait invoquée sous un id que rien ne sert, et la page recevrait "action not found" sans aucune erreur d'aucun côté. EQ2012 refuse la forme : déclarez l'action sur le composant concret. Sur le composant lui-même, un [ServerAction] convient, qu'il soit stateful ou stateless.
Catalogue
Actions et saisies : Button, IconButton, TextInput, SearchField, Checkbox, Switch, RadioGroup, Select, Slider, Stepper, SegmentedControl.
Surfaces et affichage : Card, Divider, Badge, Chip, Avatar, Banner, ProgressBar, EmptyState, Skeleton.
Navigation : Tabs, AppBar, BottomNavigation, NavigationRail, Breadcrumb, Pagination, PageIndicator, Menu, Drawer. La barre et le rail sont les MÊMES destinations pour deux largeurs de fenêtre : remettez une liste de NavItem aux deux dans un AdaptiveNode et le shell suit la fenêtre, barre sous un téléphone et rail le long du bord initial d'une tablette, sans listener et sans second état. La barre prend 3 à 5 destinations, le rail 3 à 7 (il est plus haut qu'une barre n'est large) ; au-delà, la réponse est un Drawer dans les deux cas.
Depuis 0.2.0-preview.29
La barre et le rail partagent le MODÈLE de destination et non leurs métriques : le handoff donne à chacun les siennes (B4 : une pilule 56×26, un glyphe Md 24, un libellé 11/700 ; C16 : une pilule 52×30 dans une cellule 80×56, Dense 20, un Caption 12 qui passe à 700 quand il est sélectionné, et une bordure de 1dp sur le bord final). Le rail avait été construit à partir de la barre, c'était donc la barre mise sur le côté, et trois shells dessinaient le bord qu'il peint désormais lui-même.
Depuis 0.2.0-preview.31
Les destinations du rail se placent là où Alignment le dit (en haut par défaut, comme Material, centrées sous un en-tête). Il prend le MainAlign du vocabulaire lui-même plutôt qu'un enum en forme de rail, et il a attendu une tranche du transpileur, pas une tranche de design : un composant qui transmet sa propriété d'enum vers un emplacement du vocabulaire avait besoin que cette propriété atteigne le jumeau comme une UNION et non comme une simple chaîne.
Depuis 0.2.0-preview.31
ListDetail est la troisième forme, et le premier composant du catalogue qui est lui-même ADAPTATIF : une liste à côté d'un détail sur une fenêtre large, un volet à la fois sur un téléphone.
Depuis 0.2.0-preview.31
L'application possède les DONNÉES : un nœud de liste, et un nœud de détail une fois quelque chose choisi. Le composant possède la RÈGLE, qui est la part que chaque application était sur le point d'écrire à nouveau, et aurait écrite deux fois, parce que les deux largeurs ressemblent à deux écrans différents et n'en sont pas :
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 seul champ répond aux deux formes. Sur un téléphone, Detail à null est un LIEU : la liste est l'écran, et retour dé-choisit ; au-delà de TwoPaneFrom (840dp, la classe Expanded de la spécification) ce n'est qu'une absence, que Placeholder remplit. L'affordance de retour n'est dessinée QUE sur le volet compact : en large, les deux volets sont visibles, et un contrôle qui vous ramène à quelque chose que vous voyez déjà, c'est ainsi qu'un layout adaptatif se lit comme cassé. dotnet new equantic-native --shell list-detail génère exactement l'appel ci-dessus.
Le détail compact répond à RETOUR : il lie Escape à OnBack, la touche avec laquelle un bureau ferme une couche et celle à laquelle le shell Android de Photon envoie son geste de retour (Photon), donc sur un téléphone retour quitte le détail pour la liste, et dans une fenêtre de navigateur étroite Escape fait de même. Rien d'autre ne la lie : en large les deux volets sont à l'écran, et avec la liste à l'écran retour appartient au système, qui quitte l'application.
Une limite honnête, à propos de l'échange du volet compact, qui est une navigation et pas seulement une remise en page : le focus clavier reste où il était plutôt que de passer dans le volet qui est arrivé (le vocabulaire n'a pas encore de "donne le focus à ce sous-arbre" neutre vis-à-vis de la cible).
Overlays : Dialog, Toast, BottomSheet (glisser pour fermer), Popover, Tooltip (révélé au survol, pointeur seulement, zéro JS).
Listes et données : List/ListItem, ListView (à recyclage, plus bas), Accordion, Table, DataTable, Spreadsheet (plus bas).
Une rangée mesure 52 / 68 / 88 de haut pour une, deux et trois lignes (SubtitleLines = 2 demande la troisième, qui existe pour qu'un sous-titre de réglage puisse prendre une seconde phrase), et la List rentre chaque séparateur jusqu'à l'endroit où la rangée AU-DESSUS commence son texte, 16 + leading + gap, si bien qu'un filet ne coupe jamais une icône. Elle le déduit de l'emplacement : une Icon porte sa taille et un Avatar son palier, et seul un emplacement que l'application a construit elle-même doit énoncer une LeadingWidth.
Depuis 0.2.0-preview.31
Une limite, nommée parce que c'est un manque et non une préférence : une liste SÉLECTIONNABLE reste une rangée d'arrêts de tabulation individuels, pas un arrêt avec ↑/↓ et Espace. ListItem.Selected énonce donc aria-current="page" (une destination de navigation) et non aria-selected sur une listbox : l'arrêt itinérant appartient au conteneur, et jusqu'à ce que List le compose, une rangée option quitterait l'ordre de tabulation sans rien pour l'y remettre.
Interaction tactile : PullToRefresh, SwipeableRow. Une rangée qui reste ouverte conserve le transform que sa propre boîte dessine, celui de son survol compris : le décalage est le translate individuel que le navigateur applique en même temps que transform, comme Photon compose les deux, et un glissement qui s'arrête avant l'ouverture revient en douceur (depuis 0.2.0-preview.61).
Code : CodeBlock (lecture seule) et CodeEditor (caret, sélection, clavier). Le modèle complet et les deux surfaces sont documentés sur la page Éditeur de code.
Chaque composant est livré avec des pins du réalisateur web, des images golden natives (clair + sombre), des fixtures transpilées et épinglées exécutées dans vitest, et le showroom vivant (/ et /shared dans DefaultUIDashboard : SSR + identité d'hydratation + interaction vérifiées de bout en bout par la suite Playwright). Les systèmes livrés aux côtés de la bibliothèque : le système de mouvement par transition d'état (entrée/sortie de Presence), la pipeline de pointeur (survol, glisser-pour-fermer avec slop/cancel/glide), le compositeur de scroll (vrai épinglage par Pinned), les overlays ancrés, et la gestion du focus modal (plus bas).
Le contrat d'interaction
Le contrat d'interaction du design system (§10 de la spécification) est implémenté par les réalisateurs, donc chaque composant en hérite au lieu que chacun improvise. Ce qui suit est le comportement livré.
Le survol est dérivé, et il ne se déclenche jamais au tactile
Depuis 0.2.0-preview.24
Il n'y a pas de sixième emplacement de couleur : le remplissage de survol d'un contrôle plein est dérivé (le milieu par canal de Base → Pressed, exposé comme VariantColors.Hover), et les variantes discrètes (Outline, Ghost, IconButton Standard) survolent sur SurfaceSubtle, le remplissage que leur état pressé utilise déjà. Button et IconButton l'écrivent ; sur le web, chaque règle :hover que le moteur atomique émet vit derrière @media (hover: hover), si bien que le tap-survol collant d'un navigateur tactile ne trouve aucune règle, et sur Photon un mouvement étiqueté tactile saute entièrement le survol. Pressé bat toujours survolé, et l'entrée et la sortie du survol prennent le même cran de 100 ms que le retour de pression.
Les contrôles de sélection composites sont UN seul arrêt de tabulation
Depuis 0.2.0-preview.24
RadioGroup et SegmentedControl suivent le contrat natif d'<input type=radio> : le groupe est un unique role="radiogroup" focalisable qui porte le nom du groupe, les flèches déplacent la sélection et bouclent aux extrémités (Bas est le suivant dans un radiogroup : ordre de lecture, pas valeur de slider), et chaque option est un role="radio" qui énonce aria-checked, hors de l'ordre de tabulation, toujours pressable au pointeur. Le vocabulaire porte cela comme PressableRole.Radio, et la forme a depuis été adoptée dans les composites : les éléments de Tabs sont role="tab" et énoncent aria-selected (un onglet est choisi, pas coché) sous l'arrêt unique de la bande, et les rangées de Menu/Select sont menuitem/option (voir plus bas).
Les surfaces modales possèdent le clavier, et le rendent
Depuis 0.2.0-preview.24
Un Overlay modal et ouvert (ce sur quoi Dialog, Drawer et BottomSheet se construisent) est un dialogue sur le web : role="dialog", aria-modal="true" (la page derrière est inerte pour les technologies d'assistance), et tabindex="-1" pour que la couche elle-même soit le repli du focus initial (une feuille sans rien de focalisable focalise la feuille, selon la spécification). Pendant qu'elle est ouverte, Tab et Maj+Tab tournent à l'intérieur et une garde focusin rattrape tout ce qui s'échappe par une autre voie ; quand elle se ferme (retirée, fermée en restant montée via Overlay.Motion, ou au milieu de l'animation de sortie) le focus revient à l'élément qui l'a ouverte, sur tous les chemins de fermeture. Les dialogues s'empilent en dernier entré premier sorti. Toast ne piège jamais (c'est la couche non modale), et Popover n'est délibérément pas piégé : un popover est une surface non modale, donc Tab en sort.
Depuis 0.2.0-preview.29
La couche énonce aussi son NOM (Overlay.Label, puisque le Dialog passe son titre comme aria-label), une confirmation destructrice est un role="alertdialog" annoncé de façon assertive (Overlay.Alert), et le focus initial préfère l'action SÛRE : le Dialog marque son bouton Ghost (Button.InitialFocus), si bien qu'un Entrée pressé par réflexe annule au lieu de détruire.
Les touches propres d'un composant répondent là où est le clavier
Depuis 0.2.0-preview.59
Un Shortcut répond où que soit le clavier pendant que son sous-arbre est monté, et le dernier monté gagne un accord : ce dont le ⌘K d'une palette et l'Escape d'un dialogue ont besoin. Les touches propres d'un composant sont de l'autre sorte, et FocusScoped est pour elles : l'accord ne répond que pendant que le focus est dans le sous-arbre, et une touche qu'aucune portée ne détient est laissée tranquille, au navigateur sur le web.
1
new Shortcut(form, KeyChord.Command("s"), Save) { FocusScoped = true }
Le ⌘F de l'éditeur de code et le F7 du diff sont déclarés ainsi. Avec deux éditeurs sur une page, ⌘F ouvrait la recherche du dernier monté, où que soit le clavier. Il ouvre la recherche de celui dans lequel on tape maintenant, et avec le clavier dans aucun des deux le navigateur garde sa propre recherche. Les Shortcuts de Flutter répondent toujours au focus : celui-ci est de cette sorte, à côté de celui qui vaut pour toute la page.
Depuis 0.2.0-preview.60
Un accord qui ne vaut que pendant que quelque chose est à l'écran reste dans l'arbre et dit s'il est vivant avec Enabled : s'il ne l'est pas, il ne lie rien, donc la touche continue vers ce qui la prendrait autrement, comme une Action non activée la laisse passer dans Flutter. Monté et démonté autour de son sous-arbre à la place, il déplacerait ce sous-arbre dans l'arbre.
1
new Shortcut(layers, KeyChord.Escape, CloseFind) { FocusScoped = true, Enabled = findOpen }
La barre de recherche de l'éditeur de code se ferme ainsi : Escape ferme la barre de l'éditeur dans lequel se trouve le clavier, la barre ou le code, et pendant que la barre est fermée un dialogue autour de l'éditeur garde son propre Escape. Select, Menu, TimePicker et DatePicker tiennent les touches de leur panneau de la même façon, donc ouvrir un panneau ne déplace rien dans l'arbre et le focus clavier reste où il était.
De deux raccourcis d'un même accord dont l'un enveloppe l'autre, c'est celui de l'intérieur qui répond, sur le web et sur Photon, comme les raccourcis les plus proches du focus répondent d'abord dans Flutter : dans un dialogue, l'Escape de la barre de recherche ferme la barre, et l'Escape suivant ferme le dialogue.
Les champs disent aux technologies d'assistance leur nom, et leur erreur
Depuis 0.2.0-preview.24
TextInput passe son libellé à la saisie comme nom accessible, son texte d'aide ou d'erreur comme description accessible (associée via aria-describedby, annoncée poliment quand elle bascule en erreur), et le caractère erroné comme aria-invalid. Ce qu'un lecteur d'écran entend est exactement la chaîne que lit la personne qui voit. SearchField et la barre de recherche de l'éditeur de code promeuvent leur placeholder en nom réel : un placeholder disparaît sous le texte, il ne se substitue donc jamais à un nom.
L'état d'une case est un attribut, jamais son nom
Depuis 0.2.0-preview.29
Checkbox est un role="checkbox" et Switch un role="switch", chacun énonçant son état comme aria-checked, avec "mixed" pour la case indéterminée, et seulement là, la règle propre d'ARIA. Le NOM accessible dit à quoi sert le contrôle, et il ne change jamais quand l'état change (un nom qui bascule se lit comme un contrôle différent qui apparaît). En natif, l'arbre de sémantique porte la même réponse (SemanticCheck Off/On/Mixed), que macOS fait correspondre à la valeur numérique d'AXCheckBox, si bien que VoiceOver annonce l'état dans la langue de l'utilisateur, et c'est pourquoi les anciennes chaînes "Checked"/"On" ont entièrement quitté le SDK.
Un slider dit ce qu'il contient
Depuis 0.2.0-preview.55
Un Slider énonce sa VALEUR, pas seulement son nom : aria-valuenow, aria-valuemin et aria-valuemax sur le web, et la valeur du nœud sémantique sur Photon, si bien que VoiceOver et TalkBack annoncent la même chaîne. ARIA exige que role="slider" porte aria-valuenow, et chaque slider antérieur à cette version annonçait son libellé et rien d'autre. Quand le nombre n'est pas ce qu'une personne dirait, valueText le dit en mots, et un lecteur annonce ces mots À LA PLACE du nombre :
1
Slider(budget, onBudget, valueText: "R$ 400");
Sur le vocabulaire c'est Adjustable.Value, un RangeValue(Now, Min, Max) avec un Text facultatif, et le rôle suit la valeur : un Adjustable à son rôle par défaut est un slider quand il en a une et un group quand il n'en a pas, ce qui est exactement ce qu'est un conteneur focalisable que les flèches ajustent. Une valeur sur Tablist ou Radiogroup, des rôles qui n'en portent aucune, n'est pas émise.
Une région vivante dit ce qui vient de changer
Depuis 0.2.0-preview.56
Certains changements n'ont pas de focus pour les porter : un toast apparaît, une bannière change son texte, un filtre laisse trois résultats là où il y en avait quarante. Un lecteur d'écran n'en dit rien à moins que la région qui a changé ne s'annonce. LiveRegion enveloppe la partie qui change, et les deux réalisateurs la parlent : aria-live sur le web (polite, ou assertive pour LiveRegionUrgency.Assertive), et l'emplacement d'annonce de la sémantique native sur Photon, qui est ce que lisent VoiceOver et TalkBack.
1
LiveRegion(results, label: "Results");
Banner et Toast en portent déjà une, donc ce qu'ils disent est annoncé sans que l'appelant le demande. L'annonce est calculée UNE FOIS PAR CHANGEMENT, depuis la frame dans laquelle le contenu est dessiné plutôt que depuis le parcours de sémantique : ce parcours répond à un pont de plateforme dès que la technologie d'assistance est curieuse, ce qui peut être jamais, une fois ou trois fois dans la même frame, et une annonce accrochée à lui se répéterait un nombre de fois que personne n'a choisi.
Une navigation dit OÙ VOUS ÊTES
Depuis 0.2.0-preview.31
Une barre annonçait ses trois destinations de façon identique, "Activity, button", "Search, button", "Profile, button", parce que l'active était une pilule et une teinte, ce qui n'est absolument rien pour un lecteur d'écran. C'est le seul morceau d'état qu'une navigation existe pour porter, et aucune des trois orthographes que le vocabulaire avait ne pouvait le dire : une destination n'est pas PRESSÉE (elle ne bascule pas), pas COCHÉE (rien n'est en train d'être choisi), et pas SÉLECTIONNÉE (un onglet change un panneau ; une destination change où vous ÊTES).
Alors PressableRole.Destination a rejoint l'ensemble, et Link.Current à côté pour l'idiome de navigation propre au web. BottomNavigation, NavigationRail et un ListItem marqué Selected l'énoncent pour vous ; une barre latérale construite à la main avec des Link dit Current = true sur celui qui correspond à la route.
•
Web : aria-current="page", et seulement sur l'actuel. aria-current="false" est légal, inutile, et lu à voix haute, donc neuf liens annonçant "pas actuel" est pire que le silence.
•
Natif : l'arbre de sémantique porte le bit, et chaque pont le rapporte avec le mot le plus proche que sa plateforme possède : le trait Selected sur iOS, accessibilitySelected sur macOS, Selected sur l'info de nœud d'Android.
•
Ordre de tabulation : une destination GARDE son arrêt de tabulation, contrairement à un onglet ou à un radio. Une navigation est une liste de liens, pas un composite avec un point d'entrée unique, et qui utilise le clavier et atteint une barre s'attend à la parcourir.
Les infobulles atteignent le clavier, et Escape les écarte
Depuis 0.2.0-preview.29
Le focus clavier révèle un Tooltip exactement comme le survol (:has(:focus-visible), focus-visible et non focus-within, parce qu'une infobulle qui surgit à chaque clic de souris est dans le chemin), la pilule est un vrai role="tooltip" que le déclencheur désigne avec aria-describedby (un lecteur d'écran entend l'indication après le nom du contrôle sans que rien ne soit révélé), et Escape masque une infobulle révélée SANS déplacer pointeur ni focus, ce qui est la clause de rejet de la WCAG 1.4.13. Quitter le déclencheur efface la suppression, donc la visite suivante révèle à nouveau : Échap est un "pas maintenant", jamais un "plus jamais".
Un déclencheur dit s'il est ouvert
Depuis 0.2.0-preview.29
Un chevron pivoté est de la peinture, et la peinture ne dit rien aux technologies d'assistance. Pressable.Expanded énonce le dépliement : aria-expanded sur le web, le bit déplié du nœud de sémantique native (le "expanded"/"collapsed" de VoiceOver sur macOS). Et null trace la même ligne que Selected : "ne contrôle aucun dépliement" est une réponse différente de "en contrôle un, actuellement fermé". Les en-têtes de l'Accordion, le champ du Select et le déclencheur du Menu l'énoncent tous.
Les menus et les selects parlent tout leur motif
Depuis 0.2.0-preview.29
Un Menu ouvert est un role="menu" de rangées menuitem (les commandes désactivées comprises, annoncées grisées plutôt que cachées), son déclencheur portant aria-haspopup/aria-controls. Un Select ouvert est le motif combobox complet : le CHAMP est le role="combobox", le panneau une role="listbox" de rangées option numérotées qui énoncent aria-selected. Et dans les deux, la surbrillance clavier que les flèches déplacent, qui ne serait sinon que de la peinture, est énoncée comme aria-activedescendant sur le déclencheur, si bien qu'un lecteur d'écran qui suit le clavier entend chaque étape. Le focus lui-même ne quitte jamais le déclencheur, ce qui est l'arrangement propre au motif combobox.
Les chaînes intégrées vivent derrière une seule couture
Depuis 0.2.0-preview.24
Aucun composant ne code en dur une chaîne destinée à l'utilisateur : les affordances de fermeture, le placeholder de recherche, les libellés de la barre de recherche lisent tous SdkStrings, la couture adossée au resx que la voie de localisation remplit par culture. L'ensemble a RÉTRÉCI en preview.29 : les anciennes annonces de bascule ("Checked", "On") sont parties quand l'état d'une case est passé dans aria-checked, parce que la plateforme annonce l'état dans la langue de l'utilisateur, ce qu'aucun resx de notre part ne pourrait égaler.
ListView : la liste à recyclage
Une liste qui ne CONSTRUIT que les rangées que vous pouvez voir : donnez-lui un compte, une étendue fixe par élément et un constructeur par index, et elle matérialise la fenêtre visible plus une marge d'overscan. Le reste de la liste, ce sont deux espaceurs, si bien que le layout (et la barre de défilement) voient la vraie hauteur du contenu tandis que dix mille rangées coûtent ce que coûte un écran. Write-once : le même C# tourne sur Photon et sur le web.
1
2
3
4
5
new ListView(count: 10_000, itemExtent: 44, itemBuilder: i => RowFor(i))
{
Width = SizeValue.Fill,
Height = SizeValue.Fill, // la liste est une FENÊTRE, donnez-lui en une
}
La fenêtre converge au lieu de bloquer : la première frame construit contre une supposition de viewport, la frame réalisée rapporte le vrai viewport et le vrai décalage par les canaux de sortie du ScrollView (OnScrolled / OnViewportChanged, câblés sur les deux cibles), et la frame suivante construit la fenêtre corrigée. Les pixels défilent sans reconstruction ; l'état ne change que quand une rangée franchit la marge d'overscan. Portée (v1) : listes verticales à ItemExtent fixe ; les étendues variables ne sont pas incluses.
Des règles de layout qui font marcher les fenêtres partout (invariants du SDK, pas des corvées d'application) :
•
Le cadre #app du shell de l'application a une hauteur EXACTE de viewport (height: 100dvh, grid, enfants à min-height: 0) : une page APP (racine à Height = Fill) se résout à exactement un viewport et défile en interne ; une page DOCUMENT (racine à hauteur automatique) déborde du cadre et le body défile comme un document le fait toujours.
•
Un ScrollView sans Height explicite vaut height: 100% par défaut sur le web, parce qu'une vue de défilement EST la fenêtre que son parent lui donne, jamais son contenu (parité native : le réalisateur lui remet des bornes de layout et rogne toujours).
•
L'hydratation adopte les marqueurs data-eq-* du framework depuis l'arbre du client, parce que le SSR ne peut pas connaître les identités côté client, et les passes ultérieures retrouvent leurs éléments par eux.
Spreadsheet : la grille éditable
Un tableur à l'ergonomie d'Excel, bâti sur le même principe que l'Éditeur de code : le contrôleur porte tout, les pixels sont de l'arithmétique. Un modèle C# + un composant C# se rendent sur Photon et sur le DOM ; le build du navigateur transpile exactement les mêmes sources.
Le modèle (eQuantic.UI.Primitives/Sheet, partagé mot pour mot) :
•
SheetDocument : cellules creuses (vide est absent), tailles par ligne et par colonne, étendue logique. SetCell/SetRowHeight/SetColWidth sont le chemin de chargement et d'aperçu, délibérément non annulables.
•
CellRef/SheetRange : adressage A1 ; sélection ancre/focus en 2D. Les cellules s'indexent par un int empaqueté (row × 16384 + col) parce qu'un record struct est une clé inutile pour un Map JS.
•
SheetController : la sémantique d'Excel sous forme de méthodes. Flèches/Maj étendent, Ctrl+flèche saute au bord des données, Tab/Entrée parcourent (et bouclent) dans une sélection pendant que le rectangle reste immobile (cellule active ≠ focus de sélection, délibérément), sélections par bande d'en-tête, insertion/suppression/redimensionnement de lignes et colonnes avec undo/redo creux fondé sur des inverses, copier/coller TSV parlant l'échappement d'Excel, et le **brouillon d'édition dans la cellule** : BeginEdit/TypeIntoDraft/CommitEdit vivent dans le contrôleur, donc les deux cibles éditent à l'identique et un brouillon validé s'annule en une seule étape.
•
SheetKeymap : LE clavier unique. Taper remplace (la saisie rapide d'Excel), F2 édite sur place, Entrée/Tab valident et avancent, Escape abandonne, les flèches valident puis déplacent, ⌘A/⌘Z. L'hôte natif et le lowering web délèguent tous deux ici ; le dialecte ne peut pas bifurquer.
Le composant (eQuantic.UI.Components/Spreadsheet.cs) :
•
En-têtes de colonnes fixes en haut ; les en-têtes de lignes défilent AVEC les lignes ; la fenêtre visible de cellules s'enveloppe dans un SheetSurface qui vit DANS le défilement, si bien que les marques se translatent avec le contenu et qu'un clic sur une grille défilée sélectionne la cellule réellement sous le pointeur, par construction.
•
Les lignes se virtualisent à la manière du ListView (fenêtre + espaceurs) ; la bande de sélection, l'anneau de la cellule active et le brouillon d'édition avec son caret se peignent SUR les cellules, dans le composant, donc les deux cibles sont visuellement identiques parce qu'il n'y a rien côté réalisateur qui puisse dériver.
•
Sélection façon Excel : les clics d'en-tête sélectionnent toute leur ligne ou colonne (le coin sélectionne tout, et les en-têtes s'ombrent quand la bande de la sélection les traverse) ; Maj+clic étire depuis l'ancre sans déplacer la cellule active ; un glissement simple balaie une plage sur les deux cibles.
•
La poignée de remplissage, le petit carré d'Excel au coin inférieur droit de la sélection, traîne un aperçu le long de l'axe DOMINANT et déverse le bloc source dessus au relâchement, en carrelant en phase avec rebouclage dans les quatre directions, comme UNE étape d'annulation. ⌘D/⌘R déversent vers le bas et vers la droite par le même moteur dans le keymap partagé. La sémantique du geste (BeginFill/UpdateFill/CommitFill, Fill, FillDown, FillRight, SelectTo) vit dans le contrôleur, donc les deux cibles remplissent à l'identique.
•
Redimensionner au glissement : le bord final de chaque en-tête porte une poignée Draggable invisible de 6dp (Stack + Positioned, Follows = false). Les mouvements s'aperçoivent directement dans le document (SetColWidth/SetRowHeight) ; le relâchement rembobine l'aperçu et pose tout le geste comme UN Controller.Resize annulable. Les planchers (MinColWidth 24, MinRowHeight 14) gardent un filet saisissable, bornés dans le composant et non dans les Min/Max du nœud (ceux-là reconstruisent au milieu du glissement). Dans le ScrollView la poignée gagne sur le défilement : une surface de glissement est le geste le plus spécifique (règle de l'hôte, PressDown).
•
Câblage de l'interaction : l'hôte natif achemine les clics, le glissement de sélection, les touches, le presse-papiers TSV et le texte compatible IME vers le contrôleur ; le lowering web (lowerSheetSurface) est une div role="grid" focalisable avec user-select: none (un glissement de sélection peint la bande, pas le balayage bleu du navigateur), keydown → le SheetKeymap transpilé, le double-clic édite, et copier/couper/coller passent par les événements de presse-papiers du navigateur en TSV.
•
Les curseurs de pointeur sont du vocabulaire (BoxStyle.Cursor, le miroir du cursor CSS) : le web émet la déclaration, Photon enregistre une CursorRegion à laquelle le CursorAt de l'hôte répond du plus haut au plus bas, et le shell macOS fait correspondre à NSCursor. Les poignées de redimensionnement disent col-resize/row-resize et la poignée de remplissage dit crosshair, sur les deux cibles, depuis le même code de composant.
Portée (v1) : pas de moteur de formules, pas de mise en forme par cellule, pas de fusions, pas de volets figés. La virtualisation est verticale seulement (les colonnes se matérialisent), et il n'y a pas de défilement 2D, donc les colonnes au-delà de la largeur du volet sont rognées. Les suites de contrôleur, d'édition et de composant mènent les deux axes de redimensionnement à travers l'hôte ; le jumeau transpilé joue les mêmes mouvements sous vitest, plus des spécifications de surface web avec de vrais événements DOM.
Une pastille dans le coin, et le contrat qu'est Positioned
Positioned est un contrat avec le PARENT : comme un poids de flex, il ne signifie rien ailleurs. Il doit être enfant d'un Stack, et jusqu'à la 0.2.0-preview.18 cela voulait dire enfant direct : un composant dont le Build en renvoyait un n'était pas reconnu, dégradait vers son enfant et rejoignait le flux. Un bouton de coin se rendait au-dessus de la dalle à laquelle il appartenait, collé au bord gauche, sur les deux cibles et en silence.
Les deux réalisateurs résolvent désormais à travers le composant, donc ceci marche :
1
2
3
4
Stack(children: [
CodeSlab(code),
CopyAction(code), // son Build renvoie Positioned(top: 0, end: 0)
])
Un enfant positionné ne participe toujours pas à la taille du CONTENU du stack, sinon une pastille de coin ferait grandir la dalle sur laquelle elle se pose.
Placé par fraction, décalé par sa propre taille
Depuis 0.2.0-preview.61
Un bord est un décalage en points, une FRACTION du stack, ou les deux additionnés ; ensuite ShiftX et ShiftY déplacent l'enfant de fractions de sa PROPRE taille (le FractionalTranslation de Flutter). C'est ainsi que quelque chose se place sur une boîte dont l'application n'apprend jamais la taille : une infobulle centrée 16 au-dessus du point à 50% / 25% d'une carte, quelle que soit la largeur que la carte finit par avoir.
1
Positioned(tooltip, top: -16, startFraction: 0.5f, topFraction: 0.25f, shiftX: -0.5f, shiftY: -1f)
Le web écrit left: 50%; top: calc(25% - 16px); transform: translate(-50%, -100%). Photon place avec la même arithmétique dans sa passe de layout, donc la région de clic est là où l'enfant est dessiné, comme un transform CSS le laisse dans le navigateur.
Une grille qui répète une piste autant de fois qu'il en tient
Depuis 0.2.0-preview.61
GridTrack.AutoFill(min) est autant de colonnes qu'il tient de pistes d'au moins min de large, le gap de la grille entre elles, en partageant ce qui reste : la grille de cartes que chaque handoff dessine en repeat(auto-fill, minmax(210px, 1fr)), que le web écrit en minmax(min(210px, 100%), 1fr) pour qu'une grille plus étroite qu'une piste soit une colonne de sa propre largeur plutôt qu'un débordement, comme Photon la dessine. Le minimum est d'au moins 1dp et le poids d'au moins 1.
1
Grid([GridTrack.AutoFill(210)], gap: 10, width: SizeValue.Fill, children: [.. cards])
C'est toute la liste de colonnes de la grille : à côté d'une autre piste, il faudrait un solveur pour partager la largeur, et le constructeur la refuse avec la raison. Sur une largeur que la grille dimensionne depuis son propre contenu il n'y a rien à diviser, c'est donc une colonne à son minimum, ce que CSS répond là aussi.
Savoir quand quelque chose est à l'écran
Depuis 0.2.0-preview.20
1
InView(Heading(section.Title), visible => SetState(() => _active = visible ? section.Id : _active))
La question qu'une table des matières pose réellement est quel titre le lecteur regarde-t-il, et la seule façon d'y répondre était la position de défilement de la page, qui vit sur un ScrollView, donc l'article devait être enveloppé dans un. Cela change le modèle de défilement de toute la page : l'en-tête collant cesse de coller à la fenêtre, la restauration de défilement propre au navigateur cesse de marcher, et la barre d'adresse cesse de se cacher sur un téléphone.
Un décalage brut n'aurait pas beaucoup aidé non plus. C'est un nombre dans les coordonnées de la page, et répondre "ce titre est-il au-delà" demande la position de chaque titre, une mesure qu'un composant write-once n'a pas à faire, et qu'il ne peut pas faire de la même façon sur les deux cibles. La présence est la question, donc la présence est ce que ceci rapporte.
•
Se déclenche sur les transitions seulement : true quand l'enfant entre dans la vue, false quand il en sort. Jamais une fois par frame pendant qu'il y reste.
•
Threshold (0 à 1) demande plus qu'un filet : un titre à moitié sorti par le haut n'est pas celui que le lecteur regarde.
•
Non visible est l'état supposé, donc un composant qui se monte avec son enfant hors écran n'entend rien. Trente titres n'annoncent pas trente absences.
•
Transparent au layout : il ne prend pas de place et ne dessine rien.
Sur le web c'est un IntersectionObserver : aucun listener de défilement, donc aucun travail sur les frames où rien ne franchit, et la mesure se fait hors du thread principal. Photon compare les bornes avec la surface une fois par frame, dans un parcours qu'il fait déjà.
Les deux rognent. Une rangée défilée hors d'un ScrollView, ou hors d'une Box qui rogne, n'est pas à l'écran. Sur Photon ce qui compte comme visible se resserre à travers chaque rognage, de la même façon que la peinture et l'entrée se resserrent déjà ; sur le web l'observer rogne à chaque ancêtre de lui-même. Ainsi une liste en fenêtre ne charge que ce que le lecteur peut voir, ce qui est le travail que ceci rend bon marché. Le rognage se resserre sur Photon depuis la 0.2.0-preview.21.
Deux choses devaient être vraies sur le web avant que tout cela ne rapporte quoi que ce soit, et les deux étaient fausses à la livraison (corrigé en 0.2.0-preview.21) :
•
Le marqueur va sur l'ENFANT, jamais sur l'enveloppe transparente au layout. display: contents ne génère aucune boîte, donc un observer pointé sur l'enveloppe surveille un rectangle de 0×0 à l'origine. Il rapporte fidèlement, à propos d'un endroit où le titre n'est jamais.
•
Les observers s'attachent après que la passe a été ÉCRITE. Une passe produit un arbre ; le gestionnaire de rendu l'écrit une fois la passe revenue. Valider à l'intérieur de la passe cherchait des éléments qui n'existaient pas encore, n'en trouvait aucun et effaçait les déclarations, si bien qu'une page fraîchement hydratée portait chaque nœud marqué sans observer, et que seul un re-rendu ultérieur les attachait. Sur une page où rien d'autre ne change jamais, c'est jamais.
Où un lien laisse le lecteur
Depuis 0.2.0-preview.23
1
Link("/docs/Storage", Text("Storage")) { KeepsPosition = true }
Une navigation commence la nouvelle page en haut, ce qui est juste pour un lien qui vous emmène ailleurs et faux pour un lien qui échange un panneau à côté d'une liste que vous aviez parcourue à moitié. Une barre latérale de documentation est le cas qui le nomme : le shell est préservé et seuls deux ou trois cents nœuds sont rapiécés, et puis tout saute en haut, ce qui se lit, pour la personne qui regarde, exactement comme si la page s'était rechargée.
Le chrome qui garde son PROPRE défilement n'a jamais eu le problème et n'en a pas besoin. C'est pour les layouts où la liste et le contenu partagent un seul défilement.
Montrer un overlay que rien n'a ouvert
Depuis 0.2.0-preview.16
1
2
3
4
InFlow(Dialog("Delete this?", "It cannot be undone.", [new DialogAction("Delete")]))
InFlow(Drawer(content)) // pas besoin d'`open: true`, voir plus bas
InFlow(BottomSheet(content))
InFlow(Popover(trigger, panel))
Un dialogue, un panneau latéral, une feuille et un popover n'existent qu'une fois que quelque chose les ouvre, et quand c'est le cas ils prennent tout le viewport : un scrim sur la page, la surface centrée ou ancrée devant elle, une liaison d'Escape, une animation d'entrée. Une page qui veut en MONTRER un (de la documentation, une revue de design, une suite de régression visuelle) veut la surface et rien de tout cela. Cinq composants se rendaient vides.
InFlow énonce l'intention ; chaque overlay y répond en construisant son panneau et rien d'autre, parce que le composant est la seule chose qui sait ce qu'est sa surface.
Dans le flux il n'y a ni ouvert ni fermé : le panneau est placé, donc il est là. Open gouverne si la COUCHE existe, et il n'y a pas de couche. Un panneau latéral épouse son contenu au lieu de remplir le viewport (la pleine hauteur appartient au bord auquel il est ancré, et il n'a pas de bord) ; un popover dessine son panneau sans son déclencheur ; un PullToRefresh repose avec son indicateur découvert, puisqu'il n'y a rien à tirer.
Ce n'est pas un mode aperçu : la surface est réelle, avec de vraies actions. Ce qui est abandonné, c'est la présentation. Et l'intention s'arrête au nœud : un dialogue ouvert pour de vrai à côté d'un dialogue en aperçu prend toujours le viewport.
Montrer un état qui n'a pas eu lieu
Depuis 0.2.0-preview.15
1
2
Simulated(SimulatedState.Hovered, Button("Save"))
Simulated(SimulatedState.Pressed | SimulatedState.Focused, IconButton(Icons.Check))
Un bouton pressé ne peut pas être remis à un constructeur : pressé est quelque chose que l'hôte observe, pas quelque chose qu'un arbre énonce. Donc une galerie de documentation, une revue de design et une suite de régression visuelle ne pouvaient jamais montrer que l'état de repos d'un contrôle, et les trois états qu'un lecteur veut le plus comparer étaient les trois qu'aucune page ne pouvait dessiner.
Un nœud plutôt qu'une propriété sur chaque composant interactif : il compose, il couvre les composants qui n'existent pas encore, et il marche pour un contrôle imbriqué trois niveaux dans une carte en aperçu. Les aperçus imbriqués se COMBINENT : une carte survolée contenant un bouton pressé, ce sont deux nœuds, et celui de l'intérieur n'éteint pas le survol de celui de l'extérieur.
Il ne change que ce qui est DESSINÉ. Rien n'est invoqué, aucun état n'est entré, aucun gestionnaire ne tourne, et l'état s'arrête au nœud : le contrôle à côté se comporte normalement.
Sur le web, :hover et :focus-visible ne peuvent pas être forcés depuis CSS (rien ne peut l'être, c'est tout l'intérêt d'une pseudo-classe), donc les diffs que ces règles portent sont repliés dans la règle de base. Les mêmes déclarations, donc l'aperçu est la chose réelle plutôt qu'une approximation. Une pression simulée réutilise le corps de sélecteur même qu'une vraie pression utilise, et c'est ce qui empêche les deux de dériver.
Voir aussi
•
Composants write-once : l'architecture partagée sur laquelle roulent ces composants
•
Design System : tokens, densité, theming, le moteur de styles atomique
•
Éditeur de code : le modèle de l'éditeur, CodeBlock et CodeEditor
•
Intégration serveur : SSR, SEO et configuration serveur