@hecto/ui-core
v3.0.0
Published
Framework-neutral contracts and state transitions shared by every Hecto adapter (React, Vue, Angular). This package owns **pure logic only** — no DOM, no framework runtime, no JSX. Anything here must be importable from a plain Node script without pulling
Readme
@hecto/ui-core
Framework-neutral contracts and state transitions shared by every Hecto adapter (React, Vue, Angular). This package owns pure logic only — no DOM, no framework runtime, no JSX. Anything here must be importable from a plain Node script without pulling in React/Vue/Angular.
The extraction pattern (template: Select)
When a component's interaction logic (open/closed, active index, keyboard navigation, controlled/uncontrolled resolution, etc.) needs to be identical across frameworks, extract it once as a pure reducer here, then bind it per framework with a thin adapter hook/composable/service.
State + actions live in
ui-coreas data, not classes.SelectStateis a plain readonly interface;SelectActionis a discriminated union. The reducer (reduceSelect(state, action): SelectState) is a pure function — same inputs always produce the same output, no side effects, no framework types anywhere in its signature.export interface SelectState { readonly open: boolean; readonly value?: string; readonly activeIndex: number; } export type SelectAction = | { type: "toggle" } | { type: "close" } | { type: "open" } | { type: "move"; direction: -1 | 1; options: readonly SelectOption[] } | { type: "select"; value: string; options: readonly SelectOption[] } | { type: "activate"; index: number }; export function reduceSelect(state: SelectState, action: SelectAction): SelectState; export function createSelectState( options: readonly SelectOption[], value?: string, open?: boolean, ): SelectState;Each framework adapter binds it with the thinnest glue that framework offers, and nothing else. The reducer must not be reimplemented, wrapped in extra logic, or duplicated per framework.
- React (
packages/ui/src/react/useSelectState.ts):React.useReducer(reduceSelect, createSelectState(...)). - Vue: a
computed/refpair driven by the samereduceSelectcalls inside a composable. - Angular: a signal updated by calling
reduceSelectinside the component/service.
The binding hook owns only wiring (framework state primitive ↔ reducer call). Business rules — which arrow key moves which direction, what
selectdoes toactiveIndex, disabled-option skipping — stay inreduceSelectso all three frameworks get them for free and in sync.- React (
The public component API never changes shape because of this. Props, events, and exported types for the component (e.g. React's
SelectProps) are untouched by the extraction — only the component's internal implementation swaps localuseStatecalls for the shared reducer via the binding hook. Existing tests (unit, Storybook, etc.) must keep passing unmodified; if they don't, the extraction changed observable behavior and that's a bug in the port, not an acceptable side effect.Value commit vs. interaction state stay separate. Controlled/ uncontrolled value resolution (a prop-driven concern, e.g. React's
useControllable) is a different responsibility from open/active-index interaction state, and is not folded into theui-corereducer. Each framework keeps its own idiomatic controlled-value primitive and calls into the shared reducer for the rest.
Applying this to other components
Follow the same four steps for any component whose interaction logic must be identical across frameworks (Dialog's open/focus-restore transitions, Combobox filtering + active index, Carousel index clamping, etc.):
- Model the state as a plain readonly interface and the transitions as a discriminated-union action type.
- Write one pure reducer function in
ui-core(plus an initializer if the state needs one). - Add one binding hook/composable/service per framework, in that framework's package, that does nothing but wire the reducer into that framework's state primitive.
- Leave the component's public props/events and existing tests untouched —
the reducer replaces internal
useStatecalls, not the component contract.
DialogState/reduceDialog, cycleFocusIndex, and toggleValue in this
package are smaller building blocks in the same spirit — reusable pure
transitions extracted once so adapters don't reimplement them.
Styling entries
Three CSS exports, one JS rule: every class string a component renders lives
in its own module under src/classes/ (shared runs in classes/shared.ts),
re-exported through src/classes.ts so bundlers can drop unused components
wholesale via the package sideEffects field.
| Entry | Contents | Use when |
| --- | --- | --- |
| @hecto/ui-core/styles.css | utilities + tokens (all themes) + semantic classes + framework-neutral global utilities | zero-build default: one import, everything works |
| @hecto/ui-core/utilities.css | every component's compiled Tailwind utilities, no token definitions | you compose tokens/.vs-* layers yourself |
| @hecto/ui-core/semantic.css | semantic .vs-* rules plus framework-neutral global utilities, no imports | the lean path below |
Global utility partials are authored in src/global-utilities/ and listed once
in global-utilities.mjs. The full styles.css imports every partial. The
build inlines those partials into semantic.css, while bounded
dist/components/*.css leaves receive only the families referenced by their
source closure. The partials are copied to dist/global-utilities/*.css as
side-effectful CSS assets; do not hand-edit generated CSS.
Lean CSS: compile only what you use
styles.css ships every component's utilities and all 76 themes (~149 KB
resolved, ~26 KB gz). A consumer already running Tailwind v4 can instead compile a
sheet scoped to the components and themes they actually ship, because each
component's class strings live in their own dist/classes/<component>.js
module that @source can scan:
@import "tailwindcss/theme.css";
@import "tailwindcss/utilities.css";
@import "@hecto/tokens/base.css";
@import "@hecto/tokens/themes/dark.css"; /* just the themes you ship */
@import "@hecto/tokens/tailwind.css";
@import "@hecto/ui-core/semantic.css";
@theme { --shadow-*: initial; }
@source "./node_modules/@hecto/ui-core/dist/classes/shared.js";
@source "./node_modules/@hecto/ui-core/dist/classes/button.js";
@source "./node_modules/@hecto/ui-core/dist/classes/spinner.js";Measured for Button (base + dark theme): ~66 KB raw / ~11 KB gz vs
~149 KB / ~26 KB for the full styles.css chain. Each extra component or
theme adds roughly 1–3 KB.
Rules of the recipe:
- Always scan
classes/shared.js— cross-component runs (focus ring, floating panel, overlay scrim) live there, referenced by the other modules at runtime so their text never appears in the component files. - Scan what a contract composes from:
combobox.jsreusesselect.js;popover.jsanddropdown-menu.jsreusetrigger-button.js; Button's loading state rendersspinner.js. semantic.cssafter the compiled utilities — it carries only the rules Tailwind can't express (keyframes, vendor pseudo-elements) and assumes tokens + utilities are already in scope.- Light theme is
base.css's default paint; named themes override it viadata-themeon<html>.
Owned native widget inputs
For repeated native Gantt or Kanban rendering, acquire a snapshot in the caller's
scope when input data changes, then pass it as the component's snapshot prop:
import { createGanttSnapshot, createKanbanSnapshot } from '@hecto/ui-core'
import { createSelectSnapshot } from '@hecto/ui-machines/select'
const timeline = createGanttSnapshot({ rows, columns, getRowId: row => row.name })
const board = createKanbanSnapshot(cards)
const choices = createSelectSnapshot(options)
// Gantt({ snapshot: timeline, onRangeChange }), Kanban({ columns, snapshot: board })
// Select({ snapshot: choices, onValueChange })Acquisition reads the documented fields once, copies immutable records and builds
keyed indexes. Acquire again for every data revision. Duplicate IDs throw before
rendering. Existing rows, cards and options props retain their general getter
behavior; snapshots are an explicit alternative. Gantt derives shared geometry
once and applies draft ranges only to the active bar. Kanban snapshots are
controlled inputs: accept a move by supplying a new snapshot, or retain the old
snapshot to reject it. Select preserves string versus record selection values;
record values refer to the acquired immutable records.
Gantt pointer movement is coalesced per animation frame, refreshes layout after resize or scroll, and releases listeners on cancel, lost capture, track removal or disposal. A final pointer-up applies a pending move before requesting the drop.
