KomponentenDie Komponentenbibliothek (eQuantic.UI.Components) ist write-once: Jede Komponente wird einmal in C# gegen das abstrakte Vokabular in eQuantic.UI.Primitives geschrieben und je Ziel realisiert, DOM + CSS im Web, GPU-Pixel über Photon auf Native. Nicht "zwei ähnliche APIs", sondern buchstäblich dieselbe Klasse. Die Architektur (Schichten, Realisierer, Hydration-Parität) ist in Write-once-Komponenten dokumentiert. StatelessComponent
Hängt nur von seinen Eigenschaften ab. Überschreiben Sie Build(ComponentContext), um den Baum zurückzugeben.
StatefulComponent
Hält dauerhaften internen Zustand; SetState löst einen Neuaufbau aus.
ComponentContext ist die moduslose Tür, durch die alles Zielabhängige kommt: das Theme, die Dichte, die Textmessung (MeasureText/MonoAdvance) und die per Konstruktor genommenen Fähigkeitsdienste (IThemeController, ITextClipboard, Gerätefähigkeiten). Komponenten schreiben Tokens, nie aufgelöste Farben, sodass ein gebauter Baum sich hell oder dunkel realisiert. Events sind typisierte Eigenschaften (OnPressed, OnChanged, OnSelectionChanged, …), die C#-Delegates tragen; derselbe Handler läuft auf beiden Zielen.
Die Web-Schicht auf niedriger Ebene (HtmlElement, ClassName, DynamicElement) existiert weiterhin für reine Web-Notluken. Siehe Styling. Eine Komponente darf über einer eigenen Basis geschrieben werden
Die Basis einer Komponente muss nicht direkt StatelessComponent oder StatefulComponent sein. Eine eigene Basis (die einen gemeinsamen Rahmen, gemeinsames Padding, einen Prefetch hält, den mehrere Seiten wiederholen) ist etwas Gewöhnliches zu schreiben, und alles, was die Unterklasse deklariert, wandert in ihren Zwilling: Konstruktoren, Felder, Eigenschaften, Instanzmethoden, statische Methoden und [ServerAction]-Stubs.
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));
Zwei Build-Fälle, und eine Leserin trifft beide sofort:
•
Das Kind überschreibt Build. Sein Zwilling trägt dieses build und jedes Mitglied, das der Körper erreicht, die geerbten eingeschlossen.
•
Das Kind tut es nicht und erbt das der Basis. Sein Zwilling trägt überhaupt kein build, und die Prototypenkette antwortet: Das der Basis ist das, was läuft.
Eine abstrakte Basis, die Mitglieder und kein Build deklariert, wird mit diesen Mitgliedern ausgegeben, sodass ein Kind, das in sie hineinruft, sie dort findet.
Was eine Basis NICHT tragen darf, ist eine [ServerAction]. Der Server registriert eine Aktion unter dem Namen der KONKRETEN Komponente (er überspringt abstrakte Typen und geht geerbte Methoden ab), also würde eine auf der Basis deklarierte Aktion unter einer Id aufgerufen, die nichts bedient, und die Seite bekäme "action not found", ohne Fehler auf einer der beiden Seiten. EQ2012 verweigert die Form: Deklarieren Sie die Aktion auf der konkreten Komponente. Auf der Komponente selbst ist eine [ServerAction] in Ordnung, ob stateful oder stateless.
Aktionen und Eingaben: Button, IconButton, TextInput, SearchField, Checkbox, Switch, RadioGroup, Select, Slider, Stepper, SegmentedControl.
Flächen und Darstellung: Card, Divider, Badge, Chip, Avatar, Banner, ProgressBar, EmptyState, Skeleton.
Navigation: Tabs, AppBar, BottomNavigation, NavigationRail, Breadcrumb, Pagination, PageIndicator, Menu, Drawer. Die Leiste und die Schiene sind DIESELBEN Ziele für zwei Fensterbreiten: Geben Sie eine NavItem-Liste beiden innerhalb eines AdaptiveNode, und die Shell folgt dem Fenster, Leiste unter einem Telefon und Schiene am Anfangsrand eines Tablets, ohne Listener und ohne zweiten Zustand. Die Leiste nimmt 3 bis 5 Ziele, die Schiene 3 bis 7 (sie ist höher, als eine Leiste breit ist); darüber hinaus ist die Antwort bei beiden ein Drawer.
Seit 0.2.0-preview.29
Die Leiste und die Schiene teilen das Ziel-MODELL und nicht ihre Metriken: Das Handoff gibt jeder die eigenen (B4: eine Pille 56×26, eine Md-24-Glyphe, ein Label 11/700; C16: eine Pille 52×30 in einer Zelle 80×56, Dense 20, ein Caption 12, das auf 700 geht, wenn es ausgewählt ist, und eine 1dp-Linie am Endrand). Die Schiene war aus der Leiste gebaut worden, war also die Leiste auf die Seite gestellt, und drei Shells zeichneten den Rand, den sie nun selbst malt.
Seit 0.2.0-preview.31
Die Ziele der Schiene sitzen dort, wo Alignment es sagt (standardmäßig oben, wie bei Material, zentriert unter einem Header). Sie nimmt das MainAlign des Vokabulars selbst statt eines schienenförmigen Enums, und sie wartete auf eine Scheibe des Transpilers, nicht auf eine des Designs: Eine Komponente, die ihre Enum-Eigenschaft in einen Platz des Vokabulars weiterleitet, brauchte, dass diese Eigenschaft den Zwilling als UNION erreicht und nicht als nackten String.
Seit 0.2.0-preview.31
ListDetail ist die dritte Form und die erste Komponente im Katalog, die selbst ADAPTIV ist: eine Liste neben einem Detail auf einem breiten Fenster, ein Bereich auf einmal auf einem Telefon.
Seit 0.2.0-preview.31
Die Anwendung besitzt die DATEN: einen Listenknoten und einen Detailknoten, sobald etwas gewählt ist. Die Komponente besitzt die REGEL, und das ist der Teil, den jede Anwendung gerade erneut schreiben wollte und zweimal geschrieben hätte, weil die zwei Breiten wie zwei verschiedene Bildschirme aussehen und keine sind:
list: Inbox.List(_selected, i => SetState(() => _selected = i)),
detail: _selected is { } chosen ? Inbox.Detail(chosen) : null,
onBack: () => SetState(() => _selected = null))
Title = _selected is { } open ? Inbox.Items[open].Title : null,
Placeholder = EmptyState(Icons.Mail, "Pick a message"),
Ein Feld antwortet auf beide Formen. Auf einem Telefon ist ein Detail von null ein ORT: Die Liste ist der Bildschirm, und zurück wählt ab; jenseits von TwoPaneFrom (840dp, die Expanded-Klasse der Spezifikation) ist es nur eine Abwesenheit, die Placeholder füllt. Die Zurück-Affordanz wird NUR auf dem kompakten Bereich gezeichnet: In der Breite sind beide Bereiche sichtbar, und ein Control, das Sie zu etwas zurückbringt, das Sie sehen können, ist die Art, wie ein adaptives Layout sich kaputt liest. dotnet new equantic-native --shell list-detail erzeugt genau den Aufruf von oben.
Das kompakte Detail antwortet auf ZURÜCK: Es bindet Escape an OnBack, die Taste, mit der ein Desktop eine Ebene schließt, und die, an die Photons Android-Shell ihre Zurück-Gestik schickt (Photon), also verlässt zurück auf einem Telefon das Detail für die Liste, und in einem schmalen Browserfenster tut Escape dasselbe. Nichts sonst bindet sie: In der Breite sind beide Bereiche auf dem Bildschirm, und mit der Liste auf dem Bildschirm gehört zurück dem System, das die Anwendung verlässt. Ein ehrlicher Zaun, zum Tausch des kompakten Bereichs, der eine Navigation und nicht nur ein neues Layout ist: Der Tastaturfokus bleibt, wo er war, statt in den angekommenen Bereich zu wandern (das Vokabular hat noch kein zielneutrales "fokussiere diesen Teilbaum").
Overlays: Dialog, Toast, BottomSheet (ziehen zum Verwerfen), Popover, Tooltip (durch Hover enthüllt, nur Zeiger, null JS).
Listen und Daten: List/ListItem, ListView (recycelnd, siehe unten), Accordion, Table, DataTable, Spreadsheet (siehe unten).
Eine Reihe ist 52 / 68 / 88 hoch für eine, zwei und drei Zeilen (SubtitleLines = 2 bittet um die dritte, die existiert, damit ein Untertitel einer Einstellung einen zweiten Satz nehmen kann), und die List rückt jeden Trenner bis dorthin ein, wo die Reihe DARÜBER ihren Text beginnt, 16 + Leading + Gap, sodass eine Haarlinie nie quer über ein Icon schneidet. Sie leitet das aus dem Platz ab: Ein Icon trägt seine Größe und ein Avatar seine Stufe, und nur ein Platz, den die Anwendung selbst gebaut hat, muss eine LeadingWidth nennen.
Seit 0.2.0-preview.31
Ein Zaun, benannt, weil er eine Lücke und keine Vorliebe ist: Eine AUSWÄHLBARE Liste ist weiterhin eine Reihe einzelner Tab-Stopps, nicht ein Stopp mit ↑/↓ und Leertaste. ListItem.Selected nennt deshalb aria-current="page" (ein Navigationsziel) und nicht aria-selected auf einer listbox: Der wandernde Stopp gehört dem Container, und bis List ihn komponiert, würde eine option-Reihe die Tab-Reihenfolge verlassen, ohne dass etwas sie zurückbringt.
Berührungsinteraktion: PullToRefresh, SwipeableRow. Eine Reihe, die offen ruht, behält das Transform, das ihre eigene Box zeichnet, das ihres Hovers eingeschlossen: Der Versatz ist das einzelne translate, das der Browser zusammen mit transform anwendet, so wie Photon die beiden komponiert, und ein Wischen, das vor dem Öffnen endet, gleitet zurück (seit 0.2.0-preview.61).
Code: CodeBlock (nur lesen) und CodeEditor (Caret, Auswahl, Tastatur). Das vollständige Modell und beide Flächen sind auf der Seite Code-Editor dokumentiert. Jede Komponente wird mit Pins des Web-Realisierers, nativen Golden-Bildern (hell + dunkel), gepinnten transpilierten Fixtures, die in vitest laufen, und dem lebenden Showroom ausgeliefert (/ und /shared im DefaultUIDashboard: SSR + Hydration-Identität + Interaktion, Ende zu Ende von der Playwright-Suite verifiziert). Systeme, die neben der Bibliothek ausgeliefert werden: das Bewegungssystem für Zustandsübergänge (Presence Ein-/Ausgang), die Zeiger-Pipeline (Hover, Ziehen zum Verwerfen mit Slop/Cancel/Glide), der Scroll-Compositor (echtes Anheften mit Pinned), verankerte Overlays und die modale Fokusverwaltung (siehe unten).
Der Interaktionskontrakt des Design Systems (§10 der Spezifikation) wird von den Realisierern umgesetzt, also erbt ihn jede Komponente, statt dass jede improvisiert. Was folgt, ist das ausgelieferte Verhalten.
Hover ist abgeleitet und feuert auf Berührung nie
Seit 0.2.0-preview.24
Es gibt keinen sechsten Farbplatz: Die Hover-Füllung eines gefüllten Controls ist abgeleitet (die Kanalmitte von Base → Pressed, als VariantColors.Hover offengelegt), und die stillen Varianten (Outline, Ghost, IconButton Standard) hovern auf SurfaceSubtle, der Füllung, die ihr Pressed-Zustand schon nutzt. Button und IconButton schreiben sie; im Web lebt jede :hover-Regel, die die atomare Engine ausgibt, hinter @media (hover: hover), sodass der klebende Tap-Hover eines Touch-Browsers überhaupt keine Regel findet, und auf Photon überspringt eine als Berührung etikettierte Bewegung den Hover ganz. Gedrückt schlägt immer Hover, und Hover hinein und hinaus nehmen dieselbe 100-ms-Stufe wie die Druckrückmeldung.
Zusammengesetzte Auswahl-Controls sind EIN Tab-Stopp
Seit 0.2.0-preview.24
RadioGroup und SegmentedControl folgen dem nativen Kontrakt von <input type=radio>: Die Gruppe ist ein einzelnes fokussierbares role="radiogroup", das den Namen der Gruppe trägt, die Pfeiltasten bewegen die Auswahl und laufen an den Enden um (Ab ist das Nächste in einer Radiogroup: Leserichtung, nicht Slider-Wert), und jede Option ist ein role="radio", das aria-checked nennt, außerhalb der Tab-Reihenfolge, per Zeiger weiterhin drückbar. Das Vokabular trägt dies als PressableRole.Radio, und die Form ist seither über die Komposita übernommen worden: Die Elemente von Tabs sind role="tab" und nennen aria-selected (ein Tab wird gewählt, nicht angehakt) unter dem einzelnen Stopp der Leiste, und die Reihen von Menu/Select sind menuitem/option (siehe unten).
Modale Flächen besitzen die Tastatur und geben sie zurück
Seit 0.2.0-preview.24
Ein offenes, modales Overlay (worauf Dialog, Drawer und BottomSheet aufbauen) ist im Web ein Dialog: role="dialog", aria-modal="true" (die Seite dahinter ist für assistive Technik untätig) und tabindex="-1", damit die Ebene selbst der Rückfall für den Anfangsfokus ist (ein Sheet ohne etwas Fokussierbares fokussiert das Sheet, wie die Spezifikation sagt). Während es offen ist, kreisen Tab und Umschalt+Tab darin, und eine focusin-Wache holt alles zurück, was auf anderem Weg entkommt; wenn es schließt (entfernt, über Overlay.Motion geschlossen und montiert geblieben, oder mitten in der Ausgangsanimation) kehrt der Fokus zu dem Element zurück, das es geöffnet hat, auf jedem Schließweg. Dialoge stapeln sich zuletzt hinein, zuerst hinaus. Toast fängt nie (es ist die nicht modale Ebene), und Popover wird absichtlich nicht gefangen: Ein Popover ist eine nicht modale Fläche, also läuft Tab aus ihm hinaus.
Seit 0.2.0-preview.29
Die Ebene nennt auch ihren NAMEN (Overlay.Label, da der Dialog seinen Titel als aria-label weitergibt), eine destruktive Bestätigung ist ein role="alertdialog", bestimmt angekündigt (Overlay.Alert), und der Anfangsfokus bevorzugt die SICHERE Aktion: Der Dialog markiert seinen Ghost-Button (Button.InitialFocus), sodass ein aus Reflex gedrücktes Enter abbricht statt zu zerstören.
Die eigenen Tasten einer Komponente antworten dort, wo die Tastatur ist
Seit 0.2.0-preview.59
Ein Shortcut antwortet, wo auch immer die Tastatur ist, solange sein Teilbaum montiert ist, und der zuletzt montierte gewinnt einen Akkord: Was das ⌘K einer Palette und das Escape eines Dialogs brauchen. Die eigenen Tasten einer Komponente sind die andere Sorte, und FocusScoped ist für sie: Der Akkord antwortet nur, solange der Fokus innerhalb des Teilbaums ist, und eine Taste, die kein Bereich hält, wird in Ruhe gelassen, im Web dem Browser.
new Shortcut(form, KeyChord.Command("s"), Save) { FocusScoped = true }
Das ⌘F des Code-Editors und das F7 des Diffs werden so deklariert. Mit zwei Editoren auf einer Seite öffnete ⌘F die Suche des zuletzt montierten, wo auch immer die Tastatur war. Es öffnet die Suche dessen, in den jetzt getippt wird, und mit der Tastatur in keinem von beiden behält der Browser seine eigene Suche. Flutters Shortcuts antworten immer für den Fokus: Dieser ist von jener Sorte, neben dem seitenweiten.
Seit 0.2.0-preview.60
Ein Akkord, der nur gilt, solange etwas auf dem Bildschirm ist, bleibt im Baum und sagt mit Enabled, ob er lebt: Ist er es nicht, bindet er nichts, also geht die Taste weiter zu dem, was sie sonst nehmen würde, so wie eine Action, die nicht aktiviert ist, sie in Flutter durchlässt. Stattdessen um seinen Teilbaum herum montiert und abmontiert, würde er diesen Teilbaum im Baum verschieben.
new Shortcut(layers, KeyChord.Escape, CloseFind) { FocusScoped = true, Enabled = findOpen }
Die Suchleiste des Code-Editors schließt so: Escape schließt die Leiste des Editors, in dem die Tastatur ist, die Leiste oder den Code, und während die Leiste geschlossen ist, behält ein Dialog um den Editor sein eigenes Escape. Select, Menu, TimePicker und DatePicker halten die Tasten ihres Panels genauso, also verschiebt das Öffnen eines Panels nichts im Baum, und der Tastaturfokus bleibt, wo er war.
Von zwei Shortcuts eines Akkords, bei denen einer den anderen umhüllt, antwortet der innere, im Web und auf Photon, so wie in Flutter die Shortcuts dem Fokus am nächsten zuerst antworten: In einem Dialog schließt das Escape der Suchleiste die Leiste, und das nächste Escape schließt den Dialog.
Felder nennen assistiver Technik ihren Namen und ihren Fehler
Seit 0.2.0-preview.24
TextInput gibt sein Label an die Eingabe als zugänglichen Namen weiter, seinen Hilfe- oder Fehlertext als zugängliche Beschreibung (über aria-describedby verknüpft, höflich angekündigt, wenn sie auf einen Fehler umschlägt), und die Fehlerhaftigkeit als aria-invalid. Was ein Screenreader hört, ist genau die Zeichenkette, die die sehende Person liest. SearchField und die Suchleiste des Code-Editors erheben ihren Platzhalter zu einem echten Namen: Ein Platzhalter verschwindet unter dem Text, er tritt also nie an die Stelle eines Namens.
Der Zustand einer Checkbox ist ein Attribut, nie ihr Name
Seit 0.2.0-preview.29
Checkbox ist ein role="checkbox" und Switch ein role="switch", jedes nennt seinen Zustand als aria-checked, mit "mixed" für die unbestimmte Checkbox, und nur dort, ARIAs eigene Regel. Der zugängliche NAME sagt, wofür das Control da ist, und er ändert sich nie, wenn der Zustand sich ändert (ein Name, der umschaltet, liest sich wie ein anderes Control, das auftaucht). Nativ trägt der Semantikbaum dieselbe Antwort (SemanticCheck Off/On/Mixed), die macOS auf den numerischen Wert von AXCheckBox abbildet, sodass VoiceOver den Zustand in der Sprache des Nutzers ankündigt, und darum haben die alten "Checked"/"On"-Zeichenketten das SDK ganz verlassen.
Ein Slider sagt, was er hält
Seit 0.2.0-preview.55
Ein Slider nennt seinen WERT, nicht nur seinen Namen: aria-valuenow, aria-valuemin und aria-valuemax im Web und den Wert des semantischen Knotens auf Photon, sodass VoiceOver und TalkBack dieselbe Zeichenkette ankündigen. ARIA verlangt, dass role="slider" aria-valuenow trägt, und jeder Slider vor dieser Version kündigte sein Label und nichts weiter an. Wenn die Zahl nicht das ist, was ein Mensch sagen würde, sagt valueText es in Worten, und ein Reader kündigt diese Worte ANSTELLE der Zahl an:
Slider(budget, onBudget, valueText: "R$ 400");
Im Vokabular ist es Adjustable.Value, ein RangeValue(Now, Min, Max) mit einem optionalen Text, und die Rolle folgt dem Wert: Ein Adjustable in seiner Standardrolle ist ein Slider, wenn es einen hat, und eine group, wenn es keinen hat, und genau das ist ein fokussierbarer Container, den die Pfeiltasten verstellen. Ein Wert auf Tablist oder Radiogroup, Rollen, die keinen tragen, wird nicht ausgegeben.
Eine Live-Region sagt, was sich gerade geändert hat
Seit 0.2.0-preview.56
Manche Änderungen haben keinen Fokus, der sie trägt: Ein Toast erscheint, ein Banner tauscht seinen Text, ein Filter lässt drei Ergebnisse, wo es vierzig waren. Ein Screenreader sagt zu all dem nichts, es sei denn, die Region, die sich geändert hat, kündigt sich selbst an. LiveRegion umhüllt den Teil, der sich ändert, und beide Realisierer sprechen sie: aria-live im Web (polite, oder assertive für LiveRegionUrgency.Assertive) und der Ankündigungsplatz der nativen Semantik auf Photon, und das ist, was VoiceOver und TalkBack lesen.
LiveRegion(results, label: "Results");
Banner und Toast tragen schon eine, also wird, was sie sagen, angekündigt, ohne dass der Aufrufer darum bittet. Die Ankündigung wird EINMAL JE ÄNDERUNG berechnet, aus dem Frame, in dem der Inhalt gezeichnet wird, und nicht aus dem Semantik-Gang: Dieser Gang antwortet einer Plattformbrücke, wann immer assistive Technik neugierig ist, was nie, einmal oder dreimal im selben Frame sein kann, und eine Ankündigung, die daran hängt, würde sich eine Anzahl von Malen wiederholen, die niemand gewählt hat.
Eine Navigation sagt, WO SIE SIND
Seit 0.2.0-preview.31
Eine Leiste kündigte ihre drei Ziele identisch an, "Activity, button", "Search, button", "Profile, button", weil das aktive eine Pille und ein Farbton war, was für einen Screenreader überhaupt nichts ist. Es ist das eine Stück Zustand, für dessen Tragen eine Navigation existiert, und keine der drei Schreibweisen, die das Vokabular hatte, konnte es sagen: Ein Ziel ist nicht GEDRÜCKT (es schaltet nicht um), nicht ANGEHAKT (es wird nichts gewählt) und nicht AUSGEWÄHLT (ein Tab wechselt ein Panel; ein Ziel wechselt, wo Sie SIND).
Also trat PressableRole.Destination dem Satz bei, und Link.Current daneben für das eigene Navigationsidiom des Webs. BottomNavigation, NavigationRail und ein als Selected markiertes ListItem nennen es für Sie; eine handgebaute Seitenleiste aus Links sagt Current = true an jenem, der zur Route passt.
•
Web: aria-current="page", und nur am aktuellen. aria-current="false" ist erlaubt, nutzlos und wird vorgelesen, also sind neun Links, die "nicht aktuell" ankündigen, schlimmer als Schweigen.
•
Native: Der Semantikbaum trägt das Bit, und jede Brücke meldet es mit dem nächstliegenden Wort, das ihre Plattform hat: das Selected-Trait auf iOS, accessibilitySelected auf macOS, Selected in Androids Node-Info.
•
Tab-Reihenfolge: Ein Ziel BEHÄLT seinen Tab-Stopp, anders als ein Tab oder ein Radio. Eine Navigation ist eine Liste von Links, kein Kompositum mit einem Einstiegspunkt, und wer die Tastatur nutzt und eine Leiste erreicht, erwartet, sie abzugehen.
Tooltips erreichen die Tastatur, und Escape verwirft sie
Seit 0.2.0-preview.29
Tastaturfokus enthüllt einen Tooltip genau wie Hover (:has(:focus-visible), focus-visible und nicht focus-within, weil ein Tooltip, der bei jedem Mausklick aufspringt, im Weg ist), die Pille ist eine echte role="tooltip", auf die der Auslöser mit aria-describedby zeigt (ein Screenreader hört den Hinweis nach dem Namen des Controls, ohne dass etwas enthüllt wird), und Escape verbirgt einen enthüllten Tooltip, OHNE Zeiger oder Fokus zu bewegen, und das ist die Verwerfbarkeitsklausel von WCAG 1.4.13. Den Auslöser zu verlassen hebt die Unterdrückung auf, der nächste Besuch enthüllt also wieder: Esc ist ein "nicht jetzt", nie ein "nie wieder".
Ein Auslöser sagt, ob er offen ist
Seit 0.2.0-preview.29
Ein gedrehter Chevron ist Farbe, und Farbe sagt assistiver Technik nichts. Pressable.Expanded nennt die Aufklappung: aria-expanded im Web, das Aufgeklappt-Bit des nativen Semantikknotens (VoiceOvers "expanded"/"collapsed" auf macOS). Und null zieht dieselbe Linie, die Selected zieht: "steuert überhaupt keine Aufklappung" ist eine andere Antwort als "steuert eine, derzeit geschlossen". Die Header des Accordion, das Feld des Select und der Auslöser des Menu nennen es alle.
Menüs und Selects sprechen ihr ganzes Muster
Seit 0.2.0-preview.29
Ein offenes Menu ist ein role="menu" aus menuitem-Reihen (deaktivierte Befehle eingeschlossen, abgeblendet angekündigt statt verborgen), sein Auslöser trägt aria-haspopup/aria-controls. Ein offenes Select ist das vollständige Combobox-Muster: Das FELD ist die role="combobox", das Panel eine role="listbox" aus numerierten option-Reihen, die aria-selected nennen. Und in beiden wird die Tastaturhervorhebung, die die Pfeile bewegen und die sonst nur Farbe wäre, als aria-activedescendant am Auslöser genannt, sodass ein Screenreader, der der Tastatur folgt, jeden Schritt hört. Der Fokus selbst verlässt den Auslöser nie, und das ist die eigene Anordnung des Combobox-Musters.
Eingebaute Zeichenketten leben hinter einer Naht
Seit 0.2.0-preview.24
Keine Komponente verdrahtet eine an den Nutzer gerichtete Zeichenkette fest: die Verwerfen-Affordanzen, der Suchplatzhalter, die Labels der Suchleiste lesen alle SdkStrings, die resx-gestützte Naht, die die Lokalisierungsspur je Kultur füllt. Der Satz SCHRUMPFTE in preview.29: Die alten Umschaltankündigungen ("Checked", "On") gingen, als der Zustand einer Checkbox in aria-checked wanderte, weil die Plattform den Zustand in der Sprache des Nutzers ankündigt, was kein resx von uns erreichen könnte. ListView: die recycelnde Liste
Eine Liste, die nur die Reihen BAUT, die Sie sehen können: Geben Sie ihr eine Anzahl, eine feste Ausdehnung je Element und einen Builder je Index, und sie materialisiert das sichtbare Fenster plus einen Overscan-Rand. Der Rest der Liste sind zwei Abstandshalter, sodass das Layout (und die Scrollleiste) die wahre Inhaltshöhe sehen, während zehntausend Reihen kosten, was ein Bildschirm kostet. Write-once: Dasselbe C# läuft auf Photon und im Web.
new ListView(count: 10_000, itemExtent: 44, itemBuilder: i => RowFor(i))
Height = SizeValue.Fill, // die Liste ist ein FENSTER, geben Sie ihr eines
Das Fenster konvergiert statt zu blockieren: Der erste Frame baut gegen eine Viewport-Vermutung, der realisierte Frame meldet den wahren Viewport und Versatz über die Ausgangskanäle der ScrollView (OnScrolled / OnViewportChanged, auf beiden Zielen verdrahtet), und der nächste Frame baut das korrigierte Fenster. Pixel scrollen ohne Neuaufbau; der Zustand ändert sich nur, wenn eine Reihe den Overscan-Rand überschreitet. Umfang (v1): vertikale Listen mit festem ItemExtent; variable Ausdehnungen sind nicht enthalten.
Layout-Regeln, die Fenster überall funktionieren lassen (SDK-Invarianten, keine Pflichten der Anwendung):
•
Der #app-Rahmen der Anwendungs-Shell hat EXAKTE Viewport-Höhe (height: 100dvh, grid, Kinder mit min-height: 0): Eine APP-Seite (Wurzel mit Height = Fill) löst sich zu genau einem Viewport auf und scrollt intern; eine DOKUMENT-Seite (Wurzel mit automatischer Höhe) läuft über den Rahmen hinaus, und der Body scrollt, wie ein Dokument es immer tut.
•
Eine ScrollView ohne ausdrückliche Height ist im Web standardmäßig height: 100%, weil eine Scroll-Ansicht das Fenster IST, das ihr Elternteil ihr gibt, nie ihr Inhalt (native Parität: Der Realisierer gibt ihr Layout-Grenzen und beschneidet immer).
•
Die Hydration übernimmt die data-eq-*-Marker des Frameworks aus dem Client-Baum, weil SSR clientseitige Identitäten nicht kennen kann, und die späteren Durchgänge finden ihre Elemente daran.
Spreadsheet: das editierbare Gitter
Eine Tabellenkalkulation mit Excel-Bedienbarkeit, auf demselben Prinzip gebaut wie der Code-Editor: der Controller trägt alles, die Pixel sind Arithmetik. Ein C#-Modell + eine C#-Komponente rendern auf Photon und im DOM; der Browser-Build transpiliert genau dieselben Quellen. Das Modell (eQuantic.UI.Primitives/Sheet, wörtlich geteilt):
•
SheetDocument: spärliche Zellen (leer ist abwesend), Größen je Zeile und Spalte, logische Ausdehnung. SetCell/SetRowHeight/SetColWidth sind der Lade- und Vorschauweg, absichtlich nicht rückgängig machbar.
•
CellRef/SheetRange: A1-Adressierung; Anker-/Fokus-Auswahl in 2D. Zellen werden über einen gepackten int (row × 16384 + col) geschlüsselt, weil ein Record-Struct ein nutzloser Schlüssel für eine JS-Map ist.
•
SheetController: Excels Semantik als Methoden. Pfeile/Umschalt erweitern, Strg+Pfeil springt an die Datenkante, Tab/Enter gehen (und laufen um) innerhalb einer Auswahl, während das Rechteck still steht (aktive Zelle ≠ Auswahlfokus, absichtlich), Auswahlen über Kopfbänder, Zeilen und Spalten einfügen/löschen/skalieren mit spärlichem, auf Inversen gebautem Undo/Redo, TSV-Kopieren/Einfügen, das Excels Quoting spricht, und der Entwurf fürs Editieren in der Zelle: BeginEdit/TypeIntoDraft/CommitEdit leben im Controller, sodass beide Ziele identisch editieren und ein bestätigter Entwurf als ein Schritt rückgängig geht.
•
SheetKeymap: DIE eine Tastatur. Tippen ersetzt (Excels Schnelleingabe), F2 editiert an Ort und Stelle, Enter/Tab bestätigen und rücken weiter, Escape verwirft, Pfeile bestätigen und bewegen dann, ⌘A/⌘Z. Der native Host und das Web-Lowering delegieren beide hierher; der Dialekt kann nicht abzweigen.
Die Komponente (eQuantic.UI.Components/Spreadsheet.cs):
•
Spaltenköpfe oben fest; Zeilenköpfe scrollen MIT den Zeilen; das sichtbare Fenster der Zellen wird in eine SheetSurface gehüllt, die INNERHALB des Scrolls lebt, sodass Marken sich mit dem Inhalt verschieben und ein Klick auf ein gescrolltes Gitter die Zelle auswählt, die tatsächlich unter dem Zeiger liegt, von Bauart.
•
Zeilen virtualisieren nach Art der ListView (Fenster + Abstandshalter); das Auswahlband, der Ring der aktiven Zelle und der Editierentwurf mit Caret werden AUF die Zellen gemalt, in der Komponente, sodass beide Ziele optisch identisch sind, weil es realisiererseitig nichts gibt, was abdriften könnte.
•
Auswahl im Excel-Kern: Kopfklicks wählen ihre ganze Zeile oder Spalte (die Ecke wählt alles, und Köpfe schattieren, wenn das Band der Auswahl sie kreuzt); Umschalt+Klick streckt vom Anker, ohne die aktive Zelle zu bewegen; ein einfaches Ziehen streicht einen Bereich auf beiden Zielen.
•
Der Füllgriff, Excels kleines Quadrat an der unteren rechten Ecke der Auswahl, zieht eine Vorschau entlang der DOMINANTEN Achse und gießt den Quellblock beim Loslassen darüber, phasenweise mit Umlauf in alle vier Richtungen gefliest, als EIN Undo-Schritt. ⌘D/⌘R gießen nach unten und nach rechts durch dieselbe Maschine in der gemeinsamen Keymap. Die Semantik der Gestik (BeginFill/UpdateFill/CommitFill, Fill, FillDown, FillRight, SelectTo) lebt im Controller, sodass beide Ziele identisch füllen.
•
Skalieren durch Ziehen: Der Endrand jedes Kopfes trägt einen unsichtbaren 6dp-Draggable-Griff (Stack + Positioned, Follows = false). Bewegungen zeigen direkt im Dokument eine Vorschau (SetColWidth/SetRowHeight); das Loslassen spult die Vorschau zurück und setzt die ganze Gestik als EIN rückgängig machbares Controller.Resize. Die Böden (MinColWidth 24, MinRowHeight 14) halten einen Splitter greifbar, in der Komponente begrenzt und nicht im Min/Max des Knotens (die bauen mitten im Ziehen neu). Innerhalb der ScrollView gewinnt der Griff über das Scrollen: Eine Ziehfläche ist die spezifischere Gestik (Host-Regel, PressDown).
•
Verdrahtung der Interaktion: Der native Host leitet Klicks, Auswahlziehen, Tasten, TSV-Zwischenablage und IME-sicheren Text an den Controller; das Web-Lowering (lowerSheetSurface) ist ein fokussierbares role="grid"-div mit user-select: none (ein Auswahlziehen malt das Band, nicht den blauen Strich des Browsers), keydown → die transpilierte SheetKeymap, Doppelklick editiert, und Kopieren/Ausschneiden/Einfügen reiten auf den eigenen Zwischenablage-Events des Browsers in TSV.
•
Zeigercursor sind Vokabular (BoxStyle.Cursor, der Spiegel des CSS-cursor): Das Web gibt die Deklaration aus, Photon registriert eine CursorRegion, die das CursorAt des Hosts von oben nach unten beantwortet, und die macOS-Shell bildet auf NSCursor ab. Die Skaliergriffe sagen col-resize/row-resize und der Füllgriff sagt crosshair, auf beiden Zielen, aus demselben Komponentencode.
Umfang (v1): keine Formel-Engine, keine Formatierung je Zelle, keine Verbünde, keine fixierten Bereiche. Die Virtualisierung ist nur vertikal (Spalten materialisieren), und es gibt kein 2D-Scrollen, also werden Spalten jenseits der Breite des Bereichs beschnitten. Die Suiten für Controller, Editieren und Komponente treiben beide Achsen des Skalierens durch den Host; der transpilierte Zwilling spielt dieselben Züge unter vitest, dazu Web-Flächen-Spezifikationen mit echten DOM-Events.
Ein Abzeichen in der Ecke, und der Kontrakt, der Positioned ist
Positioned ist ein Kontrakt mit dem ELTERNTEIL: wie ein Flex-Gewicht bedeutet es sonst nirgends etwas. Es muss Kind eines Stack sein, und bis 0.2.0-preview.18 hieß das direktes Kind: Eine Komponente, deren Build eines zurückgab, wurde nicht erkannt, fiel auf ihr Kind zurück und trat dem Fluss bei. Ein Eckenbutton rendete über der Platte, zu der er gehörte, hart am linken Rand, auf beiden Zielen und stillschweigend.
Beide Realisierer lösen nun durch die Komponente auf, also funktioniert das hier:
CopyAction(code), // sein Build gibt Positioned(top: 0, end: 0) zurück
Ein positioniertes Kind nimmt weiterhin nicht an der INHALTSGRÖSSE des Stacks teil, sonst würde ein Eckenabzeichen die Platte wachsen lassen, auf der es sitzt.
Nach Bruchteil platziert, um die eigene Größe verschoben
Seit 0.2.0-preview.61
Ein Rand ist ein Punktversatz, ein BRUCHTEIL des Stacks, oder beides addiert; dann verschieben ShiftX und ShiftY das Kind um Bruchteile seiner EIGENEN Größe (Flutters FractionalTranslation). So wird etwas auf einer Box platziert, deren Größe die Anwendung nie erfährt: ein Tooltip, 16 über dem Punkt bei 50% / 25% einer Karte zentriert, welche Breite die Karte am Ende auch hat.
Positioned(tooltip, top: -16, startFraction: 0.5f, topFraction: 0.25f, shiftX: -0.5f, shiftY: -1f)
Das Web schreibt left: 50%; top: calc(25% - 16px); transform: translate(-50%, -100%). Photon platziert mit derselben Arithmetik in seinem Layout-Durchgang, also liegt die Trefferregion dort, wo das Kind gezeichnet wird, so wie ein CSS-Transform es im Browser lässt.
Ein Gitter, das eine Spur so oft wiederholt, wie sie passt
Seit 0.2.0-preview.61
GridTrack.AutoFill(min) ist so viele Spalten, wie Spuren von mindestens min Breite passen, der Gap des Gitters zwischen ihnen, und sie teilen sich, was übrig bleibt: das Kartengitter, das jedes Handoff als repeat(auto-fill, minmax(210px, 1fr)) zeichnet, was das Web als minmax(min(210px, 100%), 1fr) schreibt, damit ein Gitter, das schmaler als eine Spur ist, eine Spalte eigener Breite ist statt eines Überlaufs, so wie Photon es zeichnet. Das Minimum ist mindestens 1dp und das Gewicht mindestens 1.
Grid([GridTrack.AutoFill(210)], gap: 10, width: SizeValue.Fill, children: [.. cards])
Es ist die ganze Spaltenliste des Gitters: Neben einer anderen Spur bräuchte es einen Löser, um die Breite zu teilen, und der Konstruktor verweigert sie mit dem Grund. Auf einer Breite, die das Gitter aus seinem eigenen Inhalt bemisst, gibt es nichts zu teilen, es ist also eine Spalte an ihrem Minimum, was CSS dort auch antwortet.
Wissen, wann etwas auf dem Bildschirm ist
Seit 0.2.0-preview.20
InView(Heading(section.Title), visible => SetState(() => _active = visible ? section.Id : _active))
Die Frage, die ein Inhaltsverzeichnis wirklich stellt, ist welche Überschrift die Leserin ansieht, und die einzige Art, sie zu beantworten, war die Scrollposition der Seite, die auf einer ScrollView lebt, der Artikel musste also in eine gehüllt werden. Das ändert das Scrollmodell der ganzen Seite: Der klebende Header klebt nicht mehr am Fenster, die eigene Scroll-Wiederherstellung des Browsers funktioniert nicht mehr, und die Adressleiste versteckt sich auf einem Telefon nicht mehr.
Ein roher Versatz hätte auch nicht viel geholfen. Er ist eine Zahl in den Koordinaten der Seite, und "ist diese Überschrift darüber hinaus" zu beantworten braucht die Position jeder Überschrift, eine Messung, die eine Write-once-Komponente nichts angeht und die sie auf beiden Zielen nicht gleich machen kann. Anwesenheit ist die Frage, also ist Anwesenheit, was dies meldet.
•
Feuert nur auf den Übergängen: true, wenn das Kind ins Blickfeld kommt, false, wenn es es verlässt. Nie einmal je Frame, während es dort sitzt.
•
Threshold (0 bis 1) verlangt mehr als einen Splitter: eine Überschrift, halb oben heraus, ist nicht die, auf der die Leserin ist.
•
Nicht sichtbar ist der angenommene Zustand, eine Komponente, die mit ihrem Kind außerhalb des Bildschirms montiert, hört also nichts. Dreißig Überschriften kündigen keine dreißig Abwesenheiten an.
•
Layout-durchlässig: Es nimmt keinen Platz und zeichnet nichts.
Im Web ist es ein IntersectionObserver: kein Scroll-Listener, also keine Arbeit in den Frames, in denen nichts überschreitet, und die Messung passiert außerhalb des Haupt-Threads. Photon vergleicht Grenzen mit der Fläche einmal je Frame, auf einem Gang, den es schon macht.
Beide beschneiden. Eine Reihe, die aus einer ScrollView oder aus einer beschneidenden Box herausgescrollt ist, ist nicht auf dem Bildschirm. Auf Photon verengt sich, was als sichtbar gilt, durch jeden Schnitt, so wie Malen und Eingabe sich schon verengen; im Web beschneidet der Observer selbst an jedem Vorfahren. So lädt eine gefensterte Liste nur, was die Leserin sehen kann, und das ist die Arbeit, die dies billig macht. Das Beschneiden verengt sich auf Photon seit 0.2.0-preview.21.
Zwei Dinge mussten im Web wahr sein, bevor irgendetwas davon etwas meldete, und beide waren falsch, als dies ausgeliefert wurde (behoben in 0.2.0-preview.21):
•
Der Marker gehört auf das KIND, nie auf die layout-durchlässige Hülle. display: contents erzeugt keine Box, also beobachtet ein auf die Hülle gerichteter Observer ein 0×0-Rechteck am Ursprung. Er meldet treu, über einen Ort, an dem die Überschrift nie ist.
•
Die Observer hängen sich an, nachdem der Durchgang GESCHRIEBEN wurde. Ein Durchgang erzeugt einen Baum; der Render-Manager schreibt ihn, sobald der Durchgang zurückkehrt. Das Festschreiben innerhalb des Durchgangs suchte Elemente, die noch nicht existierten, fand keine und löschte die Deklarationen, sodass eine frisch hydrierte Seite jeden markierten Knoten ohne Observer trug und erst ein spätes Neurendern sie anhängte. Auf einer Seite, auf der sich sonst nie etwas ändert, ist das nie.
Wo ein Link die Leserin zurücklässt
Seit 0.2.0-preview.23
Link("/docs/Storage", Text("Storage")) { KeepsPosition = true }
Eine Navigation beginnt die neue Seite oben, was für einen Link richtig ist, der Sie woandershin bringt, und falsch für einen, der ein Panel neben einer Liste tauscht, in der Sie halb unten waren. Eine Dokumentations-Seitenleiste ist der Fall, der ihm den Namen gibt: Die Shell wird bewahrt, und nur ein paar hundert Knoten werden geflickt, und dann springt alles nach oben, was sich für die Person, die hinsieht, genau so liest, als hätte die Seite neu geladen.
Chrome, das sein EIGENES Scrollen behält, hatte das Problem nie und braucht dies nicht. Es ist für die Layouts, in denen Liste und Inhalt ein Scrollen teilen.
Ein Overlay zeigen, das nichts geöffnet hat
Seit 0.2.0-preview.16
InFlow(Dialog("Delete this?", "It cannot be undone.", [new DialogAction("Delete")]))
InFlow(Drawer(content)) // kein `open: true` nötig, siehe unten
InFlow(BottomSheet(content))
InFlow(Popover(trigger, panel))
Ein Dialog, ein Drawer, ein Sheet und ein Popover existieren erst, sobald etwas sie öffnet, und wenn es das tut, nehmen sie den ganzen Viewport: ein Scrim über der Seite, die Fläche davor zentriert oder angeheftet, eine Escape-Bindung, eine Eingangsanimation. Eine Seite, die eines ZEIGEN will (Dokumentation, eine Design-Durchsicht, eine Suite visueller Regression), will die Fläche und nichts davon. Fünf Komponenten rendeten leer.
InFlow nennt die Absicht; jedes Overlay antwortet darauf, indem es sein Panel baut und nichts weiter, weil die Komponente das Einzige ist, das weiß, was ihre Fläche ist.
Im Fluss gibt es kein offen oder geschlossen: Das Panel ist platziert, also ist es da. Open bestimmt, ob die EBENE existiert, und es gibt keine Ebene. Ein Drawer umschließt seinen Inhalt, statt den Viewport zu füllen (die volle Höhe gehört dem Rand, an dem er angeheftet ist, und er hat keinen Rand); ein Popover zeichnet sein Panel ohne seinen Auslöser; ein PullToRefresh ruht mit unbedecktem Indikator, da nichts gezogen werden kann.
Es ist kein Vorschaumodus: Die Fläche ist echt, mit echten Aktionen. Was wegfällt, ist die Darstellung. Und die Absicht endet mit dem Knoten: Ein wirklich geöffneter Dialog neben einem in der Vorschau nimmt weiterhin den Viewport.
Einen Zustand zeigen, der nicht eingetreten ist
Seit 0.2.0-preview.15
Simulated(SimulatedState.Hovered, Button("Save"))
Simulated(SimulatedState.Pressed | SimulatedState.Focused, IconButton(Icons.Check))
Ein gedrückter Button kann einem Konstruktor nicht übergeben werden: gedrückt ist etwas, das der Host beobachtet, nicht etwas, das ein Baum nennt. Also konnten eine Dokumentationsgalerie, eine Design-Durchsicht und eine Suite visueller Regression immer nur den Ruhezustand eines Controls zeigen, und die drei Zustände, die eine Leserin am meisten vergleichen will, waren die drei, die keine Seite zeichnen konnte.
Ein Knoten statt einer Eigenschaft an jeder interaktiven Komponente: Er komponiert, er deckt die Komponenten ab, die noch nicht existieren, und er funktioniert für ein Control, das drei Ebenen tief in einer Karte in der Vorschau steckt. Geschachtelte Vorschauen KOMBINIEREN sich: eine gehoverte Karte, die einen gedrückten Button hält, sind zwei Knoten, und der innere schaltet den Hover des äußeren nicht ab.
Es ändert nur, was GEZEICHNET wird. Nichts wird aufgerufen, kein Zustand wird betreten, kein Handler läuft, und der Zustand endet mit dem Knoten: Das Control daneben verhält sich normal.
Im Web können :hover und :focus-visible nicht aus CSS erzwungen werden (nichts kann es, und das ist der Sinn einer Pseudoklasse), die Diffs, die jene Regeln tragen, werden also in die Basisregel eingefaltet. Dieselben Deklarationen, die Vorschau ist also die echte Sache und nicht eine Annäherung an sie. Ein simulierter Druck nutzt genau denselben Selektorkörper, den ein echter Druck nutzt, und das ist, was die beiden am Abdriften hindert.
•
Code-Editor: das Editor-Modell, CodeBlock und CodeEditor