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 00000000..eaa36b08 Binary files /dev/null and b/html-form-ports/screenshot.png differ diff --git a/html-form-ports/ts/index.html b/html-form-ports/ts/index.html new file mode 100644 index 00000000..5edcd6a6 --- /dev/null +++ b/html-form-ports/ts/index.html @@ -0,0 +1,12 @@ + + + + + + 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({});