-
Notifications
You must be signed in to change notification settings - Fork 1
Components
🌐 This page in: English · Português
The component library (eQuantic.UI.Components) is write-once: every component is authored
once in C# against the abstract vocabulary in eQuantic.UI.Primitives, and realized per target,
DOM + CSS on the web, GPU pixels through Photon on native. Not "two similar APIs",
literally the same class. The architecture (layers, realizers, hydration parity) is documented in
Write-Once Components.
| Type | Description |
|---|---|
StatelessComponent |
Depends only on its properties. Override Build(ComponentContext) to return the tree. |
StatefulComponent |
Holds persistent internal state; SetState triggers a rebuild. |
ComponentContext is the mode-free door everything target-dependent comes through: the theme,
Density, text measurement (MeasureText/MonoAdvance), and capability services
taken by constructor (IThemeController, ITextClipboard, device capabilities). Components author
tokens, never resolved colors, so one built tree realizes in light or dark.
Events are typed properties (OnPressed, OnChanged, OnSelectionChanged, …) carrying C#
delegates; the same handler runs on both targets.
The low-level web layer (
HtmlElement,ClassName,DynamicElement) still exists for web-only escape hatches. See Styling.
Actions & inputs: Button, IconButton, TextInput, SearchField, Checkbox, Switch,
RadioGroup, Select, Slider, Stepper, SegmentedControl.
Surfaces & display: Card, Divider, Badge, Chip, Avatar, Banner, ProgressBar,
EmptyState, Skeleton.
Navigation: Tabs, AppBar, BottomNavigation, NavigationRail, Breadcrumb, Pagination,
PageIndicator, Menu, Drawer. The bar and the rail are the SAME destinations for two window
widths: hand one NavItem list to both inside an AdaptiveNode and the shell follows the window,
bar under a phone and rail down the leading edge of a tablet, with no listener and no second state.
The bar takes 3-5 destinations, the rail 3-7 (it is taller than a bar is wide); past that the answer
is a Drawer on either.
Since 0.2.0-preview.29
The bar and the rail share the destination MODEL and not their metrics — the handoff gives each its own (B4: a 56×26 pill, a Md 24 glyph, an 11/700 label; C16: a 52×30 pill in an 80×56 cell, Dense 20, a Caption 12 that goes to 700 when selected, and a 1dp border on the trailing edge). The rail had been built from the bar, so it was the bar stood on its side, and three shells drew the edge it now paints itself.
Since 0.2.0-preview.31
The rail's destinations sit where Alignment says (Material's top by default, centre under a
header). It takes the vocabulary's own MainAlign rather than a rail-shaped enum, and it waited on
a transpiler slice, not a design one: a component forwarding its enum property into a vocabulary
slot needed that property to reach the twin as a UNION and not as a bare string.
Since 0.2.0-preview.31
ListDetail is the third shape, and the first component in the catalog that is itself ADAPTIVE: a
list beside a detail on a wide window, one pane at a time on a phone.
Since 0.2.0-preview.31
The app owns the DATA — a list node, and a detail node once something is chosen. The component owns the RULE, which is the part every app was about to write again, and would have written twice, because the two widths look like two different screens and are not:
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"),
}One field answers both shapes. On a phone Detail being null is a PLACE — the list is the screen,
and back un-chooses; past TwoPaneFrom (840dp, the spec's Expanded class) it is only an absence,
filled by Placeholder. The back affordance is drawn on the compact pane ONLY: wide, both panes are
visible, and a control returning you to something you can see is how an adaptive layout reads as
broken. dotnet new equantic-native --shell list-detail scaffolds exactly the call above.
Honest fences, both about the compact pane swap, which is a navigation and not just a re-layout:
keyboard focus stays where it was rather than moving into the pane that arrived (the vocabulary has
no target-neutral "focus this subtree" yet), and the platform back gesture is not wired to OnBack
(a host concern, per shell).
Overlays: Dialog, Toast, BottomSheet (drag-to-dismiss), Popover, Tooltip
(hover-revealed, pointer-only, zero JS).
Lists & data: List/ListItem, ListView (recycling, below), Accordion, Table,
DataTable, Spreadsheet (below).
A row is 52 / 68 / 88 tall for one, two and three lines (SubtitleLines = 2 asks for the third,
which exists so a settings subtitle can take a second sentence), and the List insets each divider
to where the row ABOVE starts its text — 16 + leading + gap, so a hairline never cuts across an
icon. It derives that from the slot: an Icon carries its size and an Avatar its tier, and only a
slot the app built itself has to state a LeadingWidth.
Since 0.2.0-preview.31
One fence, named because it is a gap and not a preference: a SELECTABLE list is still a row of
individual tab stops, not one stop with ↑/↓ and Space. ListItem.Selected therefore states
aria-current="page" (a navigation destination) and not aria-selected on a listbox — the roving
stop belongs to the container, and until List composes it, an option row would leave the tab
order with nothing to put it back.
Touch interaction: PullToRefresh, SwipeableRow.
Code: CodeBlock (read-only) and CodeEditor (caret, selection, keyboard). The full model
and both surfaces are documented on the Code Editor page.
Each component ships with web realizer pins, native golden images (light + dark), pinned
transpiled fixtures executed in vitest, and the live showroom (/ and /shared in
DefaultUIDashboard: SSR + hydration identity + interaction verified end-to-end by the Playwright
suite). Systems shipping alongside the library: the state-transition motion system (Presence
enter/exit), the pointer pipeline (hover, drag-to-dismiss with slop/cancel/glide), the scroll
compositor (real Sticky pinning), anchored overlays, and modal focus management (below).
The design system's interaction contract (spec §10) is implemented by the realizers, so every component inherits it rather than each one improvising. What follows is the shipped behavior.
Since 0.2.0-preview.24
There is no sixth color slot: a filled control's hover fill is derived (the channel midpoint
of Base → Pressed, exposed as VariantColors.Hover), and quiet variants (Outline, Ghost,
IconButton Standard) hover on SurfaceSubtle, the fill their pressed state already uses.
Button and IconButton author it; on the web every :hover rule the atomic engine emits lives
behind @media (hover: hover), so a touch browser's sticky tap-hover finds no rule at all, and on
Photon a touch-labelled move skips hover entirely. Pressed always beats hover, and hover in/out
rides the same 100 ms rung as pressed feedback.
Since 0.2.0-preview.24
RadioGroup and SegmentedControl follow the native <input type=radio> contract: the group is
a single focusable role="radiogroup" carrying the group's name, arrow keys move the selection
and wrap at the ends (Down is next in a radiogroup: reading order, not slider value), and every
option is a role="radio" stating aria-checked, out of the tab order, still pressable by
pointer. The vocabulary carries this as PressableRole.Radio, and the shape has since been adopted
across the composites: Tabs items are role="tab" stating aria-selected (a tab is picked,
not checked) under the strip's single stop, and Menu/Select rows are menuitem/option
(see below).
Since 0.2.0-preview.24
An open, modal Overlay (which is what Dialog, Drawer and BottomSheet build on) is a
dialog on the web: role="dialog", aria-modal="true" (the page behind is inert to assistive
tech), and tabindex="-1" so the layer itself is the initial-focus fallback (a sheet with nothing
focusable focuses the sheet, per spec). While it is open, Tab and Shift+Tab cycle inside and a
focusin guard pulls back anything that escapes by another route; when it closes (removed,
closed keep-mounted through Overlay.Motion, or mid exit animation) focus returns to the
element that opened it, on every close path. Dialogs stack last-in-first-out. Toast never
traps (it is the non-modal layer), and Popover is deliberately not trapped: a popover is a
non-modal surface, so Tab walks out of it.
Since 0.2.0-preview.29
The layer also says its NAME (Overlay.Label, since the Dialog passes its title as aria-label), a
destructive confirm is a role="alertdialog" announced assertively (Overlay.Alert), and the
initial focus prefers the SAFE action: the Dialog marks its Ghost button
(Button.InitialFocus), so Enter pressed on reflex cancels instead of destroying.
Since 0.2.0-preview.24
TextInput passes its label to the entry as the accessible name, its helper/error caption as the
accessible description (associated via aria-describedby, announced politely when it swaps to
an error), and error-ness as aria-invalid. What a screen reader hears is exactly the string the
sighted user reads. SearchField and the code editor's find bar promote their placeholder to a
real name: a placeholder vanishes under text, so it never substitutes for one.
Since 0.2.0-preview.29
Checkbox is a role="checkbox" and Switch a role="switch", each stating its state as
aria-checked, with "mixed" for the indeterminate checkbox, and only there, ARIA's own rule. The
accessible NAME is what the control is for, and it never changes when the state does (a name that
toggles reads as a different control appearing). Natively the semantics tree carries the same
answer (SemanticCheck Off/On/Mixed), which macOS maps onto AXCheckBox's numeric value, so
VoiceOver announces the state in the user's language, which is why the old "Checked"/"On"
strings left the SDK entirely.
Since 0.2.0-preview.31
A bar announced its three destinations identically — "Activity, button", "Search, button", "Profile, button" — because the active one was a pill and a tint, which is nothing at all to a screen reader. It is the one piece of state a navigation exists to carry, and none of the three spellings the vocabulary had could say it: a destination is not PRESSED (it does not toggle), not CHECKED (nothing is being chosen), and not SELECTED (a tab switches a panel; a destination changes where you ARE).
So PressableRole.Destination joined the set, and Link.Current beside it for the web's own
navigation idiom. BottomNavigation, NavigationRail and a ListItem marked Selected state it
for you; a hand-built sidebar of Links says Current = true on the one that matches the route.
-
Web:
aria-current="page"— and only on the current one.aria-current="false"is legal, useless, and read out loud, so nine links announcing "not current" is worse than silence. -
Native: the semantics tree carries the bit, and each bridge reports it with the nearest word
its platform has: the
Selectedtrait on iOS,accessibilitySelectedon macOS,Selectedon Android's node info. - Tab order: a destination KEEPS its Tab stop, unlike a tab or a radio. A navigation is a list of links, not a composite with one entry point, and a keyboard user reaching a bar expects to walk it.
Since 0.2.0-preview.29
Keyboard focus reveals a Tooltip exactly like hover (:has(:focus-visible), focus-visible and not
focus-within, because a tooltip popping on every mouse click is in the way), the pill is a real
role="tooltip" the trigger points at with aria-describedby (a screen reader hears the hint
after the control's name without anything being revealed), and Escape hides a revealed tooltip
WITHOUT moving pointer or focus, which is WCAG 1.4.13's dismissable clause. Leaving the trigger clears the
suppression, so the next visit reveals again: Esc is a "not now", never a "never again".
Since 0.2.0-preview.29
A rotated chevron is paint, and paint says nothing to assistive tech. Pressable.Expanded states
the disclosure: aria-expanded on the web, the expanded bit of the native semantics node
(VoiceOver's "expanded"/"collapsed" on macOS). And null draws the same line Selected does:
"controls no disclosure at all" is a different answer from "controls one, currently closed". The
Accordion's headers, the Select's field and the Menu's trigger all state it.
Since 0.2.0-preview.29
An open Menu is a role="menu" of menuitem rows (disabled commands included, announced
dimmed rather than hidden), its trigger carrying aria-haspopup/aria-controls. An open Select is the
full combobox pattern: the FIELD is the role="combobox", the panel a role="listbox" of
numbered option rows stating aria-selected. And in both, the keyboard highlight the arrows
move, which would otherwise be only paint, is stated as aria-activedescendant on the trigger, so a screen
reader following the keyboard hears every step. Focus itself never leaves the trigger, which is
the combobox pattern's own arrangement.
Since 0.2.0-preview.24
No component hardcodes a user-facing string: dismiss affordances, the search placeholder, the
find bar's labels all read SdkStrings, the resx-backed seam the
localization track fills per culture. The set SHRANK in preview.29: the old
toggle announcements ("Checked", "On") left when a check's state moved into aria-checked, because
the platform announces state in the user's language, which no resx of ours could match.
A list that only BUILDS the rows you can see: give it a count, a fixed per-item extent and a builder by index, and it materializes the visible window plus an overscan margin. The rest of the list is two spacers, so layout (and the scrollbar) see the true content height while ten thousand rows cost what a screenful costs. Write-once: the same C# runs on Photon and on the web.
new ListView(count: 10_000, itemExtent: 44, itemBuilder: i => RowFor(i))
{
Width = SizeValue.Fill,
Height = SizeValue.Fill, // the list is a WINDOW, give it one
}The window converges rather than blocks: the first frame builds against a viewport guess, the
realized frame reports the true viewport and offset through the ScrollView's out-channels
(OnScrolled / OnViewportChanged, wired on both targets), and the next frame builds the
corrected window. Pixels scroll without a rebuild; state only changes when a row crosses the
overscan margin. Scope (v1): vertical lists with a fixed ItemExtent; variable extents are not
included.
Layout rules that make windows work everywhere (SDK invariants, not app chores):
- The app shell's
#appframe has EXACT viewport height (height: 100dvh, grid, childrenmin-height: 0): an APP page (rootHeight = Fill) resolves to exactly one viewport and scrolls internally; a DOCUMENT page (auto-height root) overflows the frame and the body scrolls as a document always does. - A
ScrollViewwithout an explicitHeightdefaults toheight: 100%on web, because a scroll view IS the window its parent gives it, never its content (native parity: the realizer hands it layout bounds and clips always). - Hydration adopts
data-eq-*framework markers from the client tree, because SSR cannot know client-side identities, and the after-pass sweeps find their elements by them.
An Excel-usability spreadsheet, built on the same principle as the Code Editor: the controller carries everything, the pixels are arithmetic. One C# model + one C# component render on Photon and the DOM; the browser build transpiles the very same sources.
The model (eQuantic.UI.Primitives/Sheet, shared verbatim):
-
SheetDocument: sparse cells (empty is absent), per-row/column sizes, logical extent.SetCell/SetRowHeight/SetColWidthare the load/preview path, deliberately not undoable. -
CellRef/SheetRange: A1 addressing; anchor/focus selection in 2D. Cells key by a packed int (row × 16384 + col) because a record struct is a useless JS Map key. -
SheetController: Excel's semantics as methods. Arrows/Shift extend, Ctrl+arrow data-edge jumps, Tab/Enter walking (and wrapping) inside a selection while the rectangle stands still (active cell ≠ selection focus, deliberately), header band selections, row/column insert/delete/resize with sparse inverse-based undo/redo, TSV copy/paste speaking Excel's quoting, and the in-cell editing draft:BeginEdit/TypeIntoDraft/CommitEditlive in the controller, so both targets edit identically and a committed draft undoes as one step. -
SheetKeymap: the ONE keyboard. Typing replaces (Excel's quick-entry), F2 edits in place, Enter/Tab commit-and-step, Escape discards, arrows commit-then-move, ⌘A/⌘Z. The native host and the web lowering both delegate here; the dialect cannot fork.
The component (eQuantic.UI.Components/Spreadsheet.cs):
- Column headers fixed on top; row headers scroll WITH the rows; the visible window of cells wraps
in a
SheetSurfacethat lives INSIDE the scroll, so marks translate with the content and a click on a scrolled grid selects the cell actually under the pointer, by construction. - Rows virtualize the ListView way (window + spacers); selection band, active-cell ring and the editing draft+caret paint ON the cells, in the component, so both targets are visually identical because there is nothing realizer-side to drift.
- Excel-core selection: header clicks select their whole row/column (the corner selects all, and headers shade when the selection's band crosses them); shift+click stretches from the anchor without moving the active cell; a plain drag sweeps a range on both targets.
-
The fill handle, Excel's little square on the selection's bottom-right corner, drags a
preview along the DOMINANT axis and pours the source block across it on release, tiling
wrap-around in phase in all four directions, as ONE undo step. ⌘D/⌘R pour down/right through the
same engine in the shared keymap. The gesture's semantics (
BeginFill/UpdateFill/CommitFill,Fill,FillDown,FillRight,SelectTo) live in the controller, so both targets fill identically. -
Resize by drag: every header's trailing edge carries an invisible 6dp
Draggablegrip (Stack+Positioned,Follows = false). Moves preview straight into the document (SetColWidth/SetRowHeight); the release rewinds the preview and lands the whole gesture as ONE undoableController.Resize. Floors (MinColWidth24,MinRowHeight14) keep a sliver grabbable, clamped in the component, not in the node'sMin/Max(those rebuild mid-drag). Inside the ScrollView the grip wins over the scroll: a drag surface is the more specific gesture (host rule,PressDown). - Interaction wiring: the native host routes clicks/drag-select/keys/TSV clipboard/IME-safe text
to the controller; the web lowering (
lowerSheetSurface) is a focusablerole="grid"div withuser-select: none(a selection drag paints the band, not the browser's blue sweep), keydown → the transpiledSheetKeymap, dblclick edits, copy/cut/paste ride the browser's own clipboard events in TSV. -
Pointer cursors are vocabulary (
BoxStyle.Cursor, the CSScursormirror): web emits the declaration, Photon registers aCursorRegionthe host'sCursorAtanswers topmost-first, and the macOS shell maps toNSCursor. The resize grips say col-resize/row-resize and the fill handle says crosshair, on both targets, from the same component code.
Scope (v1): no formula engine, no per-cell formatting, no merges, no frozen panes. Virtualization is vertical only (columns materialize), and there is no 2D scroll, so columns past the pane's width are clipped. Controller, editing and component suites drive both axes of resize through the host; the transpiled twin runs the same moves under vitest, plus web-surface specs with real DOM events.
Positioned is a contract with the PARENT: like a flex weight, it means nothing anywhere else. It
has to be a child of a Stack, and until 0.2.0-preview.18 that meant a direct child: a component
whose Build returned one was not recognised, degraded to its child, and joined the flow. A corner
button rendered above the slab it belonged to, hard against the left edge, on both targets and
silently.
Both realizers now resolve through the component, so this works:
Stack(children: [
CodeSlab(code),
CopyAction(code), // its Build returns Positioned(top: 0, end: 0)
])A positioned child still takes no part in the stack's CONTENT size, or a corner badge would grow the slab it sits on.
Since 0.2.0-preview.20
InView(Heading(section.Title), visible => SetState(() => _active = visible ? section.Id : _active))The question a table of contents actually asks is which heading is the reader looking at, and the
only way to answer it was the page's scroll position, which lives on a ScrollView, so the article
had to be wrapped in one. That changes the scroll model of the whole page: the sticky header stops
sticking to the window, the browser's own scroll restoration stops working, and the address bar
stops hiding on a phone.
A raw offset would not have helped much either. It is a number in the page's coordinates, and answering "is this heading past it" needs every heading's position, measurement a write-once component has no business doing, and cannot do the same way on both targets. Presence is the question, so presence is what this reports.
- Fires on the transitions only: true when the child comes into view, false when it leaves. Never once a frame while it sits there.
-
Threshold(0–1) asks for more than a sliver: a heading half off the top is not the one the reader is on. - Not visible is the assumed state, so a component that mounts with its child off screen hears nothing. Thirty headings do not announce thirty absences.
- Layout-transparent: it takes no space and draws nothing.
On the web it is an IntersectionObserver: no scroll listener, so no work on the frames where
nothing crosses, and the measuring happens off the main thread. Photon compares bounds with the
surface once a frame, on a walk it is already doing.
Both clip. A row scrolled out of a ScrollView, or out of a Box that clips, is not on screen. On
Photon what counts as visible narrows through each clip, the same way paint and input already
narrow; on the web the observer clips to every ancestor by itself. So a windowed list only loads
what the reader can see, which is the job this makes cheap. Clipping narrowed on Photon since
0.2.0-preview.21.
Two things had to be true on the web before any of it reported anything, and both were false when this shipped (fixed in 0.2.0-preview.21):
-
The marker goes on the CHILD, never on the layout-transparent wrapper.
display: contentsgenerates no box, so an observer pointed at the wrapper watches a 0×0 rectangle at the origin. It reports faithfully, about a place the heading never is. - The observers attach after the pass has been WRITTEN. A pass produces a tree; the render manager writes it once the pass returns. Committing inside the pass looked for elements that did not exist yet, found none, and cleared the declarations, so a freshly hydrated page carried every marked node with no observer, and only a later re-render attached them. On a page where nothing else ever changes, that is never.
Since 0.2.0-preview.23
Link("/docs/Storage", Text("Storage")) { KeepsPosition = true }A navigation starts the new page at its top, which is right for a link that takes you somewhere else and wrong for one that swaps a panel beside a list you were half-way down. A documentation sidebar is the case that names it: the shell is preserved and only a couple of hundred nodes are patched, and then everything jumps to the top, which reads, to the person looking at it, exactly like the page reloaded.
Chrome that keeps its OWN scrolling never had the problem and does not need this. It is for the layouts where the list and the content share one scroll.
Since 0.2.0-preview.16
InFlow(Dialog("Delete this?", "It cannot be undone.", [new DialogAction("Delete")]))
InFlow(Drawer(content)) // no `open: true` needed, see below
InFlow(BottomSheet(content))
InFlow(Popover(trigger, panel))A dialog, a drawer, a sheet and a popover only exist once something opens them, and when they do they take the whole viewport: a scrim over the page, the surface centred or pinned in front of it, an Escape binding, an enter animation. A page that wants to SHOW one (documentation, a design review, a visual-regression suite) wants the surface and none of that. Five components rendered blank.
InFlow states the intent; each overlay answers it by building its panel and nothing else, because
the component is the only thing that knows what its surface is.
In the flow there is no open or closed: the panel is placed, therefore it is there. Open
governs whether the LAYER exists, and there is no layer. A drawer hugs its content rather than
filling the viewport (full height belongs to the edge it is pinned to, and it has no edge); a
popover draws its panel without its trigger; a PullToRefresh rests with its indicator uncovered,
since nothing can be pulled.
It is not a preview mode: the surface is real, with real actions. What is dropped is the presentation. And the intent ends with the node: a dialog opened for real beside a previewed one still takes the viewport.
Since 0.2.0-preview.15
Simulated(SimulatedState.Hovered, Button("Save"))
Simulated(SimulatedState.Pressed | SimulatedState.Focused, IconButton(Icons.Check))A pressed button cannot be handed to a constructor: pressed is something the host observes, not something a tree states. So a documentation gallery, a design review and a visual-regression suite could only ever show a control's rest state, and the three states a reader most wants to compare were the three no page could draw.
One node rather than a property on every interactive component: it composes, it covers the components that do not exist yet, and it works for a control nested three levels inside a card being previewed. Nested previews COMBINE: a hovered card holding a pressed button is two nodes, and the inner one does not turn the outer's hover off.
It changes only what is DRAWN. Nothing is invoked, no state is entered, no handler runs, and the state ends with the node: the control beside it behaves normally.
On the web, :hover and :focus-visible cannot be forced from CSS (nothing can, which is the
point of a pseudo-class), so the diffs those rules carry are folded into the base rule instead.
Same declarations, so the preview is the real thing rather than an approximation of it. A simulated
press reuses the very selector body a real press uses, which is what keeps the two from drifting.
- Write-Once Components: the shared architecture these components ride on
- Design System: tokens, density, theming, the atomic style engine
-
Code Editor: the editor model,
CodeBlockandCodeEditor - Server Integration: SSR, SEO, and server configuration
🌐 English · Português
🏁 Start here
📱 Write-once
- Write-Once Components
- Declarative Surface
- Photon Engine
- Design System
- Capabilities
- Storage
- Forms
- Code Editor
- Markdown
- Mermaid
- Email Rendering
🏗️ Architecture
⚙️ Compilation
- Compiler
- Compile-Time Evaluation
- Supported C# Features
- External Type Resolution
- Build Flow
- Diagnostics
⚡ Runtime
🔌 Server
🎨 Ecosystem
🚀 Development