From d5add322d8ec1e5e92c67ad6a4105d6828fad6fd Mon Sep 17 00:00:00 2001 From: Roman Bruckner Date: Sun, 7 Jun 2026 14:29:14 +0200 Subject: [PATCH] feat: add html-form-ports demo Data-mapping demo: elements render HTML via foreignObject and each form field / list row gets a port in a single absolute-position ports group. Port coordinates are measured from the rendered HTML, so they stay aligned with any layout. Values propagate along the mapping links with live snap preview and direction validation. --- html-form-ports/README.md | 15 ++ html-form-ports/screenshot.png | Bin 0 -> 39312 bytes html-form-ports/ts/index.html | 12 + html-form-ports/ts/package.json | 24 ++ html-form-ports/ts/src/form-element.ts | 239 ++++++++++++++++++++ html-form-ports/ts/src/html-ports-view.ts | 61 +++++ html-form-ports/ts/src/interface-element.ts | 195 ++++++++++++++++ html-form-ports/ts/src/main.ts | 183 +++++++++++++++ html-form-ports/ts/styles.css | 132 +++++++++++ html-form-ports/ts/tsconfig.json | 21 ++ html-form-ports/ts/vite.config.ts | 3 + 11 files changed, 885 insertions(+) create mode 100644 html-form-ports/README.md create mode 100644 html-form-ports/screenshot.png create mode 100644 html-form-ports/ts/index.html create mode 100644 html-form-ports/ts/package.json create mode 100644 html-form-ports/ts/src/form-element.ts create mode 100644 html-form-ports/ts/src/html-ports-view.ts create mode 100644 html-form-ports/ts/src/interface-element.ts create mode 100644 html-form-ports/ts/src/main.ts create mode 100644 html-form-ports/ts/styles.css create mode 100644 html-form-ports/ts/tsconfig.json create mode 100644 html-form-ports/ts/vite.config.ts diff --git a/html-form-ports/README.md b/html-form-ports/README.md new file mode 100644 index 00000000..755e3a2f --- /dev/null +++ b/html-form-ports/README.md @@ -0,0 +1,15 @@ +# JointJS: HTML Form Ports + +The HTML Form Ports demo showcases a small data-mapping application built from elements that render HTML inside a ``. A form element has a port directly under each of its fields; interface elements (input and output) are lists of items with a port next to each row. All ports belong to a single ports group with an absolute position layout — the port coordinates are measured from the rendered HTML, so they stay aligned with the content regardless of the layout. Values propagate along the mapping links: from the input interface into the form's input fields, through the form's computed fields (filled dynamically from the input fields), and on to the output interface. Form values are kept in sync with the element model in both directions. + +## Available Versions + +- [TypeScript](./ts/) + +## How to Run + +```bash +cd ts +npm install +npm run dev +``` diff --git a/html-form-ports/screenshot.png b/html-form-ports/screenshot.png new file mode 100644 index 0000000000000000000000000000000000000000..eaa36b080af1bf91e69eb8466782228b5b43876c GIT binary patch literal 39312 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 X?y + + + + + HTML Form Ports + + +
+ + + diff --git a/html-form-ports/ts/package.json b/html-form-ports/ts/package.json new file mode 100644 index 00000000..5bdf1598 --- /dev/null +++ b/html-form-ports/ts/package.json @@ -0,0 +1,24 @@ +{ + "name": "@joint/demo-html-form-ports-ts", + "version": "4.2.3", + "main": "src/main.ts", + "homepage": "https://jointjs.com", + "author": { + "name": "client IO", + "url": "https://client.io" + }, + "license": "http://jointjs.com/license", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build" + }, + "devDependencies": { + "vite": "^7.3.1", + "typescript": "^5.9.3" + }, + "dependencies": { + "@joint/core": "4.2.4" + } +} diff --git a/html-form-ports/ts/src/form-element.ts b/html-form-ports/ts/src/form-element.ts new file mode 100644 index 00000000..2b8f7b33 --- /dev/null +++ b/html-form-ports/ts/src/form-element.ts @@ -0,0 +1,239 @@ +import { dia, util } from '@joint/core'; + +import { HtmlPortsElementView } from './html-ports-view'; + +import type { mvc } from '@joint/core'; + +export interface FieldDefinition { + name: string; + label: string; + placeholder?: string; + // Computed fields are read-only — their values are derived from the + // input fields and their ports are meant to be mapped to an output. + computed?: boolean; + // CSS flex-basis of the field wrapper — fields wrap into rows freely + // (e.g. two fields in the first row, one full-width field in the second). + basis: string; +} + +export const FIELDS: FieldDefinition[] = [ + { name: 'firstName', label: 'First name', placeholder: 'John', basis: '40%' }, + { name: 'lastName', label: 'Last name', placeholder: 'Doe', basis: '40%' }, + { name: 'company', label: 'Company', placeholder: 'Acme', basis: '100%' }, + { name: 'fullName', label: 'Full name', computed: true, basis: '100%' }, + { name: 'email', label: 'Email', computed: true, basis: '100%' } +]; + +// Vertical distance between the bottom of an input and the center of its port. +const PORT_OFFSET = 12; + +export class FormElement extends dia.Element { + + defaults(): Partial { + return { + ...super.defaults, + type: 'FormElement', + size: { width: 280, height: 360 }, + title: 'Form', + fields: { + firstName: '', + lastName: '', + company: '', + fullName: '', + email: '' + }, + attrs: { + root: { + cursor: 'move', + magnet: false + }, + body: { + width: 'calc(w)', + height: 'calc(h)', + fill: '#ffffff', + stroke: '#a0a0a0', + strokeWidth: 1, + rx: 6, + ry: 6 + }, + fo: { + x: 0, + y: 0, + width: 'calc(w)', + height: 'calc(h)' + } + }, + ports: { + groups: { + // A single ports group. The layout is absolute — the exact + // port coordinates are measured from the rendered HTML + // inputs by the FormElementView. + field: { + position: 'absolute', + attrs: { + portBody: { + magnet: true, + r: 5, + fill: '#4666e5', + stroke: '#4666e5', + strokeWidth: 2, + cursor: 'crosshair' + } + }, + markup: util.svg` + + ` + } + }, + items: FIELDS.map((field) => ({ + id: field.name, + group: 'field', + // Placeholder coordinates — updated by the view after render. + args: { x: 0, y: 0 }, + // Visually distinguish the in ports (editable fields, + // hollow) from the out ports (computed fields, solid) — + // only the fill differs, so both look the same size. + attrs: field.computed ? {} : { + portBody: { + fill: '#ffffff' + } + } + })) + } + }; + } + + preinitialize(): void { + this.markup = util.svg` + + + `; + } + + initialize(attributes?: dia.Element.Attributes, options?: dia.Cell.Options): void { + super.initialize(attributes, options); + this.on('change:fields', () => this.updateComputedFields()); + this.updateComputedFields(); + } + + // Fill the computed fields from the input fields. + protected updateComputedFields(): void { + const { firstName = '', lastName = '', company = '' } = this.get('fields'); + const fullName = [firstName, lastName].filter(Boolean).join(' '); + const email = (firstName && lastName && company) + ? `${firstName}.${lastName}@${company}.com`.replace(/\s+/g, '').toLowerCase() + : ''; + this.prop('fields/fullName', fullName); + this.prop('fields/email', email); + } +} + +export class FormElementView extends HtmlPortsElementView { + + private form: HTMLFormElement | null = null; + + override presentationAttributes(): dia.CellView.PresentationAttributes { + return dia.ElementView.addPresentationAttributes({ + size: ['PORT_POSITIONS'], + fields: ['FORM_FIELDS'] + }); + } + + override initFlag(): dia.CellView.FlagLabel { + return ['RENDER', 'FORM_FIELDS']; + } + + override events(): mvc.EventsHash { + return { + 'input input': 'onFieldInput' + }; + } + + override confirmUpdate(flags: number, opt: Record): number { + let remaining = super.confirmUpdate(flags, opt); + if (this.hasFlag(remaining, 'FORM_FIELDS')) { + this.updateFields(); + remaining = this.removeFlag(remaining, 'FORM_FIELDS'); + } + return remaining; + } + + override onRender(): void { + this.renderForm(); + this.updateFields(); + } + + protected renderForm(): void { + const fo = this.findNode('fo') as SVGForeignObjectElement; + if (!fo) return; + fo.replaceChildren(); + const form = document.createElement('form'); + form.className = 'form-element'; + const title = document.createElement('div'); + title.className = 'form-element-title'; + title.textContent = String(this.model.get('title') ?? ''); + form.appendChild(title); + let dividerRendered = false; + FIELDS.forEach((field) => { + if (field.computed && !dividerRendered) { + const divider = document.createElement('div'); + divider.className = 'form-element-divider'; + divider.textContent = 'Computed'; + form.appendChild(divider); + dividerRendered = true; + } + const wrapper = document.createElement('label'); + wrapper.className = 'form-element-field'; + wrapper.style.flexBasis = field.basis; + const caption = document.createElement('span'); + caption.textContent = field.label; + wrapper.appendChild(caption); + const control = document.createElement('input'); + control.type = 'text'; + control.placeholder = field.placeholder ?? ''; + control.dataset.field = field.name; + if (field.computed) { + control.readOnly = true; + control.classList.add('computed'); + } + wrapper.appendChild(control); + form.appendChild(wrapper); + }); + fo.appendChild(form); + this.form = form; + } + + protected onFieldInput(evt: dia.Event): void { + const control = evt.target as HTMLInputElement; + const { field } = control.dataset; + if (!field) return; + this.model.prop(['fields', field], control.value); + } + + protected updateFields(): void { + const { form, model } = this; + if (!form) return; + form.querySelectorAll('[data-field]').forEach((control) => { + const value = String(model.prop(['fields', control.dataset.field]) ?? ''); + // Avoid resetting the caret of the control the user is typing in. + if (control.value !== value) { + control.value = value; + } + }); + } + + // Place each port directly under its input. + protected updatePortPositions(): void { + const { form } = this; + if (!form) return; + const args: Record = {}; + form.querySelectorAll('[data-field]').forEach((control) => { + const rect = this.localRect(control); + args[control.dataset.field!] = { + x: rect.x + rect.width / 2, + y: rect.y + rect.height + PORT_OFFSET + }; + }); + this.setPortArgs(args); + } +} diff --git a/html-form-ports/ts/src/html-ports-view.ts b/html-form-ports/ts/src/html-ports-view.ts new file mode 100644 index 00000000..f6978380 --- /dev/null +++ b/html-form-ports/ts/src/html-ports-view.ts @@ -0,0 +1,61 @@ +import { dia, util } from '@joint/core'; + +import type { g } from '@joint/core'; + +// Base view for elements whose ports are aligned with their rendered HTML +// content. The port coordinates are measured from the DOM, so they stay in +// sync with the HTML layout (rows, wrapping, CSS changes) — the ports only +// need a single group with an absolute position layout. +export abstract class HtmlPortsElementView extends dia.ElementView { + + override presentationAttributes(): dia.CellView.PresentationAttributes { + return dia.ElementView.addPresentationAttributes({ + size: ['PORT_POSITIONS'] + }); + } + + // Note: 'PORT_POSITIONS' is intentionally not part of the init flags. + // The initial measurement happens in `onMount()` — the HTML has no + // dimensions before the view is attached to the DOM. + override confirmUpdate(flags: number, opt: Record): number { + let remaining = super.confirmUpdate(flags, opt); + if (this.hasFlag(remaining, 'PORT_POSITIONS')) { + this.updatePortPositions(); + remaining = this.removeFlag(remaining, 'PORT_POSITIONS'); + } + return remaining; + } + + protected override onMount(isInitialMount: boolean): void { + super.onMount(isInitialMount); + // The HTML can only be measured once the view is attached to the DOM. + // Defer to the next frame so the resulting port update is processed + // outside of the current paper update batch. + util.nextFrame(() => { + if (this.paper) this.updatePortPositions(); + }); + } + + // The bounding box of an HTML node in the element's local coordinate + // system (zoom-safe). + protected localRect(node: Element): g.Rect { + const rect = this.paper!.clientToLocalRect(node.getBoundingClientRect()); + const position = this.model.position(); + return rect.translate(-position.x, -position.y); + } + + // Update the coordinates of multiple ports in a single 'ports' change, + // so the ports are only re-rendered once. + protected setPortArgs(args: Record): void { + const { model } = this; + const items: dia.Element.Port[] = (model.get('ports')?.items ?? []).map( + (item: dia.Element.Port) => { + const itemArgs = (item.id === undefined) ? null : args[item.id]; + return itemArgs ? { ...item, args: itemArgs } : item; + } + ); + model.prop('ports/items', items, { rewrite: true }); + } + + protected abstract updatePortPositions(): void; +} diff --git a/html-form-ports/ts/src/interface-element.ts b/html-form-ports/ts/src/interface-element.ts new file mode 100644 index 00000000..496ae3f8 --- /dev/null +++ b/html-form-ports/ts/src/interface-element.ts @@ -0,0 +1,195 @@ +import { dia, util } from '@joint/core'; + +import { HtmlPortsElementView } from './html-ports-view'; + +export interface InterfaceItem { + id: string; + label: string; + value?: string; +} + +// A data-mapping interface — a titled list of items, each with a port on the +// element's side ('left' or 'right'). The ports share a single group with an +// absolute position layout; the coordinates are measured from the rendered +// HTML rows by the InterfaceElementView. +export class InterfaceElement extends dia.Element { + + defaults(): Partial { + return { + ...super.defaults, + type: 'InterfaceElement', + size: { width: 180, height: 120 }, + title: 'Interface', + side: 'right', + items: [], + attrs: { + root: { + cursor: 'move', + magnet: false + }, + body: { + width: 'calc(w)', + height: 'calc(h)', + fill: '#fbf5e0', + stroke: '#705d10', + strokeWidth: 1, + rx: 6, + ry: 6 + }, + fo: { + x: 0, + y: 0, + width: 'calc(w)', + height: 'calc(h)' + } + }, + ports: { + groups: { + item: { + position: 'absolute', + attrs: { + portBody: { + magnet: true, + r: 5, + fill: '#705d10', + stroke: '#705d10', + strokeWidth: 2, + cursor: 'crosshair' + } + }, + markup: util.svg` + + ` + } + }, + items: [] + } + }; + } + + preinitialize(): void { + this.markup = util.svg` + + + `; + } + + initialize(attributes?: dia.Element.Attributes, options?: dia.Cell.Options): void { + super.initialize(attributes, options); + // One port per item — placeholder coordinates, updated by the view. + // In ports (output interface, side 'left') are hollow, out ports + // (input interface, side 'right') are solid — only the fill differs. + const isInPort = this.get('side') === 'left'; + this.prop('ports/items', this.getItems().map((item) => ({ + id: item.id, + group: 'item', + args: { x: 0, y: 0 }, + attrs: isInPort ? { + portBody: { + fill: '#ffffff' + } + } : {} + }))); + } + + getItems(): InterfaceItem[] { + return this.get('items') ?? []; + } + + getItemValue(id: string): string { + return this.getItems().find((item) => item.id === id)?.value ?? ''; + } + + setItemValue(id: string, value: string): void { + const index = this.getItems().findIndex((item) => item.id === id); + if (index === -1) return; + this.prop(['items', index, 'value'], value); + } +} + +export class InterfaceElementView extends HtmlPortsElementView { + + private list: HTMLDivElement | null = null; + + override presentationAttributes(): dia.CellView.PresentationAttributes { + return dia.ElementView.addPresentationAttributes({ + size: ['PORT_POSITIONS'], + items: ['ITEMS'] + }); + } + + override initFlag(): dia.CellView.FlagLabel { + return ['RENDER', 'ITEMS']; + } + + override confirmUpdate(flags: number, opt: Record): number { + let remaining = super.confirmUpdate(flags, opt); + if (this.hasFlag(remaining, 'ITEMS')) { + this.updateItemValues(); + remaining = this.removeFlag(remaining, 'ITEMS'); + } + return remaining; + } + + override onRender(): void { + this.renderList(); + this.updateItemValues(); + } + + protected renderList(): void { + const fo = this.findNode('fo') as SVGForeignObjectElement; + if (!fo) return; + fo.replaceChildren(); + const model = this.model as InterfaceElement; + const list = document.createElement('div'); + list.className = 'interface-element'; + const title = document.createElement('div'); + title.className = 'interface-element-title'; + title.textContent = String(model.get('title') ?? ''); + list.appendChild(title); + model.getItems().forEach((item) => { + const row = document.createElement('div'); + row.className = 'interface-element-item'; + row.dataset.item = item.id; + const label = document.createElement('span'); + label.className = 'interface-element-label'; + label.textContent = item.label; + row.appendChild(label); + const value = document.createElement('span'); + value.className = 'interface-element-value'; + row.appendChild(value); + list.appendChild(row); + }); + fo.appendChild(list); + this.list = list; + } + + protected updateItemValues(): void { + const { list } = this; + if (!list) return; + const model = this.model as InterfaceElement; + list.querySelectorAll('[data-item]').forEach((row) => { + const value = model.getItemValue(row.dataset.item!); + row.querySelector('.interface-element-value')!.textContent = value; + // A long value is truncated with an ellipsis — show it in full + // on hover. + row.title = value; + }); + } + + // Place each port on the element's side, vertically centered to its row. + protected updatePortPositions(): void { + const { list, model } = this; + if (!list) return; + const x = model.get('side') === 'left' ? 0 : model.size().width; + const args: Record = {}; + list.querySelectorAll('[data-item]').forEach((row) => { + const rect = this.localRect(row); + args[row.dataset.item!] = { + x, + y: rect.y + rect.height / 2 + }; + }); + this.setPortArgs(args); + } +} diff --git a/html-form-ports/ts/src/main.ts b/html-form-ports/ts/src/main.ts new file mode 100644 index 00000000..68fcdfe2 --- /dev/null +++ b/html-form-ports/ts/src/main.ts @@ -0,0 +1,183 @@ +import { dia, shapes } from '@joint/core'; + +import { FIELDS, FormElement, FormElementView } from './form-element'; +import { InterfaceElement, InterfaceElementView } from './interface-element'; + +import '../styles.css'; + +const cellNamespace = { + ...shapes, + FormElement, + FormElementView, + InterfaceElement, + InterfaceElementView +}; + +// Mapping direction: data flows out of "out" ports into "in" ports. +// - input interface (side 'right') rows are out ports +// - output interface (side 'left') rows are in ports +// - form input fields are in ports, computed fields are out ports +function isOutPort(cell: dia.Cell, port?: string): boolean { + if (!port) return false; + if (cell instanceof InterfaceElement) return cell.get('side') === 'right'; + if (cell instanceof FormElement) return FIELDS.find((field) => field.name === port)?.computed === true; + return false; +} + +function isInPort(cell: dia.Cell, port?: string): boolean { + if (!port) return false; + if (cell instanceof InterfaceElement) return cell.get('side') === 'left'; + if (cell instanceof FormElement) return FIELDS.find((field) => field.name === port)?.computed !== true; + return false; +} + +// A single source of truth for the mapping link style — used both by the +// interactive link creation (defaultLink) and the seeded mappings. +function createMappingLink(): shapes.standard.Link { + return new shapes.standard.Link({ + attrs: { + line: { + stroke: '#4666e5', + strokeWidth: 2 + } + } + }); +} + +const graph = new dia.Graph({}, { cellNamespace }); +const paper = new dia.Paper({ + model: graph, + cellViewNamespace: cellNamespace, + width: '100%', + height: '100%', + gridSize: 10, + background: { color: '#f3f7f6' }, + linkPinning: false, + snapLinks: { radius: 20 }, + defaultLink: () => createMappingLink(), + defaultConnector: { name: 'smooth' }, + defaultAnchor: { name: 'midSide', args: { useModelGeometry: true, padding: 6 }}, + defaultConnectionPoint: { name: 'anchor' }, + // New links can only start from an out port. + validateMagnet: (cellView, magnet) => + isOutPort(cellView.model, cellView.findAttribute('port', magnet) ?? undefined), + // Links go from an out port to an in port and each in port accepts + // at most one inbound link. + validateConnection: (sourceView, sourceMagnet, targetView, targetMagnet, _end, linkView) => { + if (sourceView === targetView) return false; + const sourcePort = sourceView.findAttribute('port', sourceMagnet) ?? undefined; + const targetPort = targetView.findAttribute('port', targetMagnet) ?? undefined; + if (!isOutPort(sourceView.model, sourcePort)) return false; + if (!isInPort(targetView.model, targetPort)) return false; + const otherInboundLinks = graph + .getConnectedLinks(targetView.model, { inbound: true }) + .filter((link) => link !== linkView.model && link.target().port === targetPort); + return otherInboundLinks.length === 0; + }, + // Let the user interact with the form controls inside the elements + // (focus, type) without starting an element drag. + guard: (evt) => (evt.target as HTMLElement).tagName === 'INPUT' +}); + +document.getElementById('paper-container')!.appendChild(paper.el); + +const input = new InterfaceElement({ + position: { x: 20, y: 120 }, + size: { width: 180, height: 130 }, + title: 'Input', + side: 'right', + items: [ + { id: 'firstName', label: 'First name', value: 'John' }, + { id: 'lastName', label: 'Last name', value: 'Doe' }, + { id: 'company', label: 'Company', value: 'Acme' } + ] +}); + +const form = new FormElement({ + position: { x: 260, y: 60 }, + title: 'Person mapping', + fields: { firstName: '', lastName: '', company: '' } +}); + +const output = new InterfaceElement({ + position: { x: 580, y: 250 }, + size: { width: 200, height: 100 }, + title: 'Output', + side: 'left', + items: [ + { id: 'fullName', label: 'Full name' }, + { id: 'email', label: 'Email' } + ] +}); + +const mappings = [ + { source: { id: input.id, port: 'firstName' }, target: { id: form.id, port: 'firstName' }}, + { source: { id: input.id, port: 'lastName' }, target: { id: form.id, port: 'lastName' }}, + { source: { id: input.id, port: 'company' }, target: { id: form.id, port: 'company' }}, + { source: { id: form.id, port: 'fullName' }, target: { id: output.id, port: 'fullName' }}, + { source: { id: form.id, port: 'email' }, target: { id: output.id, port: 'email' }} +].map((ends) => createMappingLink().set(ends)); + +graph.addCells([input, form, output, ...mappings]); + +// Data propagation along the mapping links. +function getPortValue(cell: dia.Cell | null, port?: string): string { + if (!port) return ''; + if (cell instanceof InterfaceElement) return cell.getItemValue(port); + if (cell instanceof FormElement) return String(cell.prop(['fields', port]) ?? ''); + return ''; +} + +function setPortValue(cell: dia.Cell | null, port: string | undefined, value: string): void { + if (!port) return; + if (cell instanceof InterfaceElement) { + cell.setItemValue(port, value); + } else if (cell instanceof FormElement) { + cell.prop(['fields', port], value); + } +} + +function propagateLink(link: dia.Link): void { + const sourcePort = link.source().port; + const targetPort = link.target().port; + if (!sourcePort || !targetPort) return; + setPortValue(link.getTargetCell(), targetPort, getPortValue(link.getSourceCell(), sourcePort)); +} + +graph.on('remove', (cell: dia.Cell) => { + if (!cell.isLink()) return; + // Clear the target of the removed mapping (form field or output item) — + // dependent computed fields and mappings recalculate via 'change:fields'. + setPortValue(cell.getTargetCell(), cell.target().port, ''); +}); + +// A click removes a link. +paper.on('link:pointerclick', (linkView) => linkView.model.remove()); + +// Live preview while a link is being dragged — propagate the value as soon +// as the arrowhead snaps to a port and clear it again when it snaps away. +paper.on('link:snap:connect', (linkView) => { + propagateLink(linkView.model); +}); + +paper.on('link:snap:disconnect', (linkView, _evt, prevCellView, prevMagnet, arrowhead) => { + if (arrowhead !== 'target') return; + const cell = prevCellView.model; + const port = prevCellView.findAttribute('port', prevMagnet); + if (!port || cell.isLink()) return; + setPortValue(cell, port, ''); + // Restore the value from another mapping still connected to the port. + graph.getConnectedLinks(cell, { inbound: true }) + .filter((link) => link !== linkView.model && link.target().port === port) + .forEach(propagateLink); +}); + +graph.on('change:fields', (element: dia.Element) => { + graph.getConnectedLinks(element, { outbound: true }).forEach(propagateLink); +}); + +graph.getLinks().forEach(propagateLink); + +// Expose for console experiments (e.g. `form.prop('fields/firstName', 'Jane')` +// or resizing to watch the ports re-align with the inputs). +Object.assign(window, { graph, paper, input, form, output }); diff --git a/html-form-ports/ts/styles.css b/html-form-ports/ts/styles.css new file mode 100644 index 00000000..2059f739 --- /dev/null +++ b/html-form-ports/ts/styles.css @@ -0,0 +1,132 @@ +* { + box-sizing: border-box; +} + +html, +body { + margin: 0; + height: 100%; + font-family: sans-serif; +} + +#paper-container { + position: absolute; + inset: 0; + overflow: hidden; +} + +/* The HTML form rendered inside the form element's . */ + +.form-element { + display: flex; + flex-wrap: wrap; + align-content: flex-start; + column-gap: 12px; + width: 100%; + height: 100%; + margin: 0; + padding: 12px; + font-size: 12px; + color: #333333; +} + +.form-element-title { + flex-basis: 100%; + margin-bottom: 8px; + font-size: 14px; + font-weight: bold; + user-select: none; +} + +.form-element-divider { + flex-basis: 100%; + margin-bottom: 8px; + padding-top: 6px; + border-top: 1px dashed #c0c0c0; + font-size: 10px; + text-transform: uppercase; + letter-spacing: 1px; + color: #999999; + user-select: none; +} + +.form-element-field { + display: flex; + flex-direction: column; + flex-grow: 1; + gap: 4px; + /* Leave room below each input for its port. */ + margin-bottom: 28px; +} + +.form-element-field > span { + user-select: none; + color: #666666; +} + +.form-element-field > input { + width: 100%; + padding: 4px 6px; + border: 1px solid #c0c0c0; + border-radius: 4px; + font-size: 12px; + font-family: inherit; +} + +.form-element-field > input:focus { + outline: 2px solid #4666e5; + outline-offset: -1px; +} + +.form-element-field > input.computed { + background: #f0f3ff; + border-style: dashed; + color: #4666e5; + cursor: default; +} + +.form-element-field > input.computed:focus { + outline: none; +} + +/* The list rendered inside the interface element's . */ + +.interface-element { + display: flex; + flex-direction: column; + width: 100%; + height: 100%; + padding: 10px 14px; + font-size: 12px; + color: #4d400b; + user-select: none; +} + +.interface-element-title { + margin-bottom: 8px; + font-size: 14px; + font-weight: bold; +} + +.interface-element-item { + display: flex; + justify-content: space-between; + gap: 8px; + padding: 6px 0; + border-top: 1px solid #e4d9ae; +} + +.interface-element-label { + white-space: nowrap; + color: #705d10; +} + +.interface-element-value { + /* Allow the flex item to shrink so a long value is truncated with an + ellipsis instead of growing the row to a second line. */ + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-weight: bold; +} diff --git a/html-form-ports/ts/tsconfig.json b/html-form-ports/ts/tsconfig.json new file mode 100644 index 00000000..96deda4b --- /dev/null +++ b/html-form-ports/ts/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "module": "esnext", + "moduleResolution": "bundler", + "target": "ES2022", + "lib": [ + "dom", + "ES2022" + ], + "strict": true, + "strictFunctionTypes": false, + "forceConsistentCasingInFileNames": true, + "noImplicitReturns": true, + "noUnusedLocals": true, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "noEmit": true, + "types": ["vite/client"] + } +} diff --git a/html-form-ports/ts/vite.config.ts b/html-form-ports/ts/vite.config.ts new file mode 100644 index 00000000..c049f46e --- /dev/null +++ b/html-form-ports/ts/vite.config.ts @@ -0,0 +1,3 @@ +import { defineConfig } from 'vite'; + +export default defineConfig({});