Skip to content

Components

Edgar Mesquita edited this page Aug 10, 2026 · 13 revisions

Components

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.

The component model

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 — 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.

Catalog

Actions & inputsButton, IconButton, TextInput, SearchField, Checkbox, Switch, RadioGroup, Select, Slider, Stepper, SegmentedControl.

Surfaces & displayCard, Divider, Badge, Chip, Avatar, Banner, ProgressBar, EmptyState, Skeleton.

NavigationTabs, AppBar, BottomNavigation, Breadcrumb, Pagination, PageIndicator, Menu, Drawer.

OverlaysDialog, Toast, BottomSheet (drag-to-dismiss), Popover, Tooltip (hover-revealed, zero JS).

Lists & dataList/ListItem, ListView (recycling — below), Accordion, Table, DataTable, Spreadsheet (below).

Touch interactionPullToRefresh, SwipeableRow.

CodeCodeBlock (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) and anchored overlays.

ListView — the recycling list

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 #app frame has EXACT viewport height (height: 100dvh, grid, children min-height: 0): an APP page (root Height = 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 ScrollView without an explicit Height defaults to height: 100% on web — 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 — SSR cannot know client-side identities, and the after-pass sweeps find their elements by them.

Spreadsheet — the editable grid

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/SetColWidth are 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/CommitEdit live 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 SheetSurface that lives INSIDE the scroll — marks translate with the content, so 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 — 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 — 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 Draggable grip (Stack + Positioned, Follows = false). Moves preview straight into the document (SetColWidth/SetRowHeight); the release rewinds the preview and lands the whole gesture as ONE undoable Controller.Resize. Floors (MinColWidth 24, MinRowHeight 14) keep a sliver grabbable — clamped in the component, not in the node's Min/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 focusable role="grid" div with user-select: none (a selection drag paints the band, not the browser's blue sweep), keydown → the transpiled SheetKeymap, dblclick edits, copy/cut/paste ride the browser's own clipboard events in TSV.
  • Pointer cursors are vocabulary (BoxStyle.Cursor, the CSS cursor mirror): web emits the declaration, Photon registers a CursorRegion the host's CursorAt answers topmost-first, and the macOS shell maps to NSCursor. 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 — 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.


Showing an overlay that nothing opened

Since 0.2.0-preview.15

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.

Showing a state that has not happened

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 — 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.

See Also

Clone this wiki locally