Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 13 additions & 7 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,30 +16,36 @@ No test framework or linter is configured. Node `>=24.9.0`, npm `>=10.0.2`.

## Architecture

Single-file Lit 3 web component (`src/widget-table.ts`) wrapping `@vaadin/grid` (peer dependency, externalized in the bundle). The component is registered as `widget-table-versionplaceholder`; the Vite build replaces `versionplaceholder` with the package version via `@rollup/plugin-replace`, so the live tag is e.g. `widget-table-1.1.17`. Consumers must use the version-suffixed tag matching the installed package — this lets multiple widget versions coexist on one page in the IronFlock dashboard.
Single-file Lit 3 web component (`src/widget-table.ts`) wrapping `@vaadin/grid` (peer dependency, externalized in the bundle), with `@material/web`'s icon button for edit columns (peer dependency, externalized too: Studio's import map provides both and loads the Material Symbols font `md-icon` draws with). The component is registered as `widget-table-versionplaceholder`; the Vite build replaces `versionplaceholder` with the package version via `@rollup/plugin-replace`, so the live tag is e.g. `widget-table-1.1.17`. Consumers must use the version-suffixed tag matching the installed package — this lets multiple widget versions coexist on one page in the IronFlock dashboard.

Build pipeline (`vite.config.ts`):
- Library mode, ES-only output, entry `src/widget-table.ts` → `dist/widget-table.js`.
- Externalizes `lit*`, `@lit*`, `@vaadin*` so consumers provide them.
- Externalizes `lit*`, `@lit*`, `@vaadin*` and `@material/web` so consumers provide them.
- `tslib` aliased to `tslib/tslib.es6.js`; `process.env.NODE_ENV` defined to `"production"`.

### IronFlock platform contract

The widget exposes two reactive properties consumed by the host dashboard:
The widget exposes two reactive properties consumed by the host dashboard (it needs no `route`: it never reads the URL, only asks Studio to change it):
- `inputData: TableConfiguration` — typed by `src/definition-schema.d.ts`, generated from `src/definition-schema.json`. The JSON schema is the source of truth for both the runtime type and the dashboard's config UI (note the non-standard `order`, `dataDrivenDisabled`, `condition.showIfValueIn`, and `color: true` keys consumed by the IronFlock config editor).
- `theme: { theme_name, theme_object }` — ECharts-style theme object. `registerTheme()` resolves colors with this precedence: CSS custom properties (`--re-text-color`, `--re-hover-color`, `--re-border-color`) on the host element override `theme_object` values. These are not snapshotted: `registerTheme()` stores a `var(--re-…, <theme value>)` chain, so a change to the host property repaints the tile live without the widget being told. The grid paints no background of its own (`--grid-bg-color` stays unset): studio's `widget-item` already paints `--re-tile-background-color` on the tile, and painting it again here composited a translucent tile colour twice, and the grid, its rows and its cells each painting it stacked it several times. Only the sticky header is opaque: `headerSurfaceColor()` composites the tile as displayed (`--re-tile-background-color` over `--re-background-color` over white, read at render time, cached by `opaqueSurface()`), so rows scrolling under it stay hidden.

The widget acts on the platform only through two events dispatched on itself (`bubbles: true, composed: true`): `param-submit` `{ params: { [key]: value } }` from an edit column's button (Studio merges the parameter into the page URL, hands every widget the new `route` and re-queries bindings whose filter uses it), and `nav-submit` `{ path }` from a `button` or `image` link to a board page. Nothing handles them in the Widget Editor's preview or the AI chat.

### Data shape and rendering

`inputData.columns` is column-oriented (each column carries its own `values[]`). `transformInputData()` pivots columns into row records keyed `col_<index>` for Vaadin Grid, then reverses (newest-first). Vaadin sort paths use `col_<index>.value`.
`inputData.columns` is column-oriented (each column carries its own `values[]`, bound separately in Studio, so rows line up by position only and a row has no identity of its own). `transformInputData()` pivots columns into row records keyed `col_<index>` for Vaadin Grid, in the order the data arrives (oldest first, as Studio delivers it). Vaadin sort paths use `col_<index>.value`.

Cell rendering dispatches on `column.type` in `renderCell()`: `string`, `number` (with `precision`), `boolean`, `state` (parses `stateMap` string `"'ONLINE': 'green', ..."` into a colored `.statusbox`), `button`, `image`, `timestamp`, `edit`.

Links (`renderLink()`, for `button` and `image`): a link with a scheme or `//` is a web address and opens in a new tab (`rel="noopener noreferrer"`); anything else is a board page and dispatches `nav-submit`, from a real `<button>` rather than an anchor with a relative `href`, which a middle-click would open against Studio's origin. An empty link leaves the content unlinked.

Cell rendering dispatches on `column.type` in `renderCell()`: `string`, `number` (with `precision`), `boolean`, `state` (parses `stateMap` string `"'ONLINE': 'green', ..."` into a colored `.statusbox`), `button`, `image`, `timestamp`. Timestamps use a hand-rolled Unicode LDML subset (`yyyy MM dd HH mm ss SSS`) — see `parseTimestamp` / `formatTimestamp`. If `timestampParseFormat` is empty, values are treated as Unix epoch milliseconds.
Edit column (`renderEditButton()`): the cell's value is the record key, sent as `param-submit` under `styling.parameterKey` (`parameterValue()` turns text, numbers and booleans into text and refuses objects); a blank key renders the button disabled with the reason as the wrapper's `title` (a disabled Material button has `pointer-events: none`, so its own title never shows). Decisions that came out of reading vaadin and Material: the column is a plain `vaadin-grid-column` (sorting by the key means nothing, and `GridSortColumnMixin` forbids a header renderer), its header is centred by `columnHeaderRenderer` with a `.cell.cell-center` div (cell content is a flex container, so vaadin's `textAlign` alone does not centre a bare header); vaadin's focus utils do not count `md-icon-button` as focusable, so the button carries `focus-target` (Enter in the cell focuses it) and the click sets `skipCellActivate` so the row's active item does not toggle; the button is sized in `em` and coloured with `currentColor` tokens (Material's 40px default would make the rows taller, and `currentColor` inside its shadow DOM resolves to the `.cell` colour chain); the default width is `calc(2 * var(--cell-padding-horizontal) + 2.5em)`. `default-data.json` has no edit column: it seeds every new tile and the AI's example, and an edit column only makes sense next to a form or a filtered widget. The demo adds one. Timestamps use a hand-rolled Unicode LDML subset (`yyyy MM dd HH mm ss SSS`) — see `parseTimestamp` / `formatTimestamp`. If `timestampParseFormat` is empty, values are treated as Unix epoch milliseconds.

Text alignment is column-type-driven (`getTextAlign`): numbers/timestamps end, booleans/state/image center, others start.
Text alignment is column-type-driven (`getTextAlign`): numbers/timestamps end, booleans/state/image/edit center, others start.

### Demo harness

`demo/index.html` builds the tag dynamically from `package.json` version using `lit/static-html`, applies `demo/themes/light.json`, and randomizes select cell values every 1s via the external `ObjectRandomizer.js` script — useful for verifying reactivity but means the demo will not run fully offline.
`demo/index.html` builds the tag dynamically from `package.json` version using `lit/static-html`, applies `demo/themes/light.json`, loads the Material Symbols font, adds a demo-only edit column (values from the Name column, `parameterKey: 'edit'`) and a board-page link to the default config, stands in for Studio by logging `param-submit` (mirrored into the demo URL) and `nav-submit`, and randomizes select cell values every 1s via the external `ObjectRandomizer.js` script — useful for verifying reactivity but means the demo will not run fully offline.

## Releasing

Expand Down
74 changes: 52 additions & 22 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,20 +10,18 @@ A Lit 3.x web component for rendering data tables with sortable columns, powered
npm i @ironflock/widget-table
```

**Peer Dependencies:** This widget requires `@vaadin/grid` to be installed in your application.
**Peer Dependencies:** This widget requires `@vaadin/grid` and `@material/web` to be installed in your application. An `edit` column draws its icon with `md-icon`, which needs the "Material Symbols Outlined" font on the page (IronFlock Studio loads it; a standalone page adds the Google Fonts link, see `demo/index.html`).

## Usage

```html
<script type="module">
import '@ironflock/widget-table'
</script>

<widget-table-1.2.0></widget-table-1.2.0>
```
The custom element's tag carries the installed version (`widget-table-<version>`), so several versions can share a page. Build it from the package's version rather than writing it out:

```javascript
const table = document.querySelector('widget-table-1.2.0')
import '@ironflock/widget-table'
import { version } from '@ironflock/widget-table/package.json' with { type: 'json' }

const table = document.createElement(`widget-table-${version}`)
document.body.append(table)
table.inputData = {
title: 'Device Status',
subTitle: 'Current readings',
Expand Down Expand Up @@ -66,9 +64,11 @@ interface TableConfiguration {

interface Column {
header: string
type: 'string' | 'number' | 'boolean' | 'state' | 'button' | 'image'
type: 'string' | 'number' | 'boolean' | 'state' | 'button' | 'image' | 'timestamp' | 'edit'
values: { value: string; link?: string }[]
styling?: {
parameterKey?: string // edit: the URL parameter the button sets
iconName?: string // edit: Material Symbols icon name, 'edit' by default
precision?: number // Decimal places for number type
stateMap?: string // e.g. "'ONLINE': 'green', 'OFFLINE': 'red'"
width?: string // Column width e.g. '150px'
Expand All @@ -82,29 +82,59 @@ interface Column {

## Column Types

| Type | Description |
| --------- | --------------------------------------------------- |
| `string` | Plain text display |
| `number` | Numeric value with optional precision formatting |
| `boolean` | Boolean display |
| `state` | Status indicator with colored dot based on stateMap |
| `button` | Clickable button with optional link |
| `image` | Image display with optional link |
| Type | Description |
| ----------- | ----------------------------------------------------------------------------- |
| `string` | Plain text display |
| `number` | Numeric value with optional precision formatting |
| `boolean` | Boolean display |
| `state` | Status indicator with colored dot based on stateMap |
| `timestamp` | Epoch milliseconds, or a `timestampParseFormat`, shown as `timestampFormat` |
| `button` | The value as a link (see Links) |
| `image` | The value as an image, with an optional link (see Links) |
| `edit` | An icon button that sends the value as a URL parameter (see Edit column) |

### Links

A `button` or `image` cell's `link` opens when it is clicked. A web address (one with a scheme, such as `https://example.com`) opens in a new tab. A path without a scheme, such as `/machines/press-1`, is a board page: the widget dispatches `nav-submit` and IronFlock Studio opens the page in place (`/x` from the board root, anything else next to the current page). An empty link shows the value or image without a link.

### Edit column

A column of type `edit` shows one icon button per row (a pen, or `styling.iconName`) instead of its values. Bind its values to the record's key column: a click dispatches `param-submit` with that key under `styling.parameterKey`, Studio writes it into the page URL, and a `widget-form` whose "Open on URL parameter" names the same parameter opens pre-filled with the record. Any other widget on the page can follow the parameter too, with a Parameter filter on its binding. Without a `parameterKey` the button is disabled, with the reason as its tooltip; an empty value shows the empty-cell placeholder.

```javascript
{
header: 'Edit',
type: 'edit',
values: [{ value: 'press-1' }, { value: 'press-2' }], // bound to machine_id in Studio
styling: { parameterKey: 'edit' }
}
```

The column is centred, not sortable, and as narrow as its button unless `styling.width` says otherwise.

## Events

The widget never changes the page URL itself. It asks the host to, on the element:

| Event | Detail | When |
| -------------- | -------------------------------------- | -------------------------------------------------------------- |
| `param-submit` | `{ params: { [parameterKey]: value } }` | An `edit` button is clicked; Studio merges it into the page URL |
| `nav-submit` | `{ path }` | A `button` or `image` link to a board page is clicked |

Both bubble and are composed.

## Features

- **Sortable columns**: Click headers to sort ascending/descending
- **Row actions**: Links to pages or web addresses, and an edit button that hands the row to a form
- **Resizable columns**: Drag column borders to resize
- **Horizontal overflow**: Enable scrolling for wide tables
- **Theme support**: Integrates with IronFlock theming system
- **State indicators**: Visual status dots with customizable color mapping

## Theming

The widget respects CSS custom properties:

- `--re-text-color`: Text color override
- `--re-tile-background-color`: Background color override
The widget respects the CSS custom property `--re-text-color` (text, including the edit buttons). It paints no background of its own: the host's tile shows through.

Pass a theme object for ECharts-style theming:

Expand Down
44 changes: 41 additions & 3 deletions demo/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,13 @@
height: 100%;
}
</style>
<!-- The icon font of md-icon, which Studio's page loads. -->
<link
rel="preload"
href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL@20..48,100..700,0..1"
as="style"
onload="this.onload=null;this.rel='stylesheet'"
/>
</head>

<body>
Expand All @@ -41,22 +48,53 @@
import data from '../src/default-data.json'
import themeObject from './themes/light.json'

// Demo-only additions (default-data.json seeds every new tile): a
// link to a board page in the Link column, and an edit column whose
// values are the Name column's, the record key it hands over.
// Remove the parameterKey to see the disabled button and its tip.
const columns = data.columns.map((col) => ({
...col,
values: col.values.map((cell) => ({ ...cell }))
}))
columns[5].values[1].link = '/machines/mayflower'
columns.push({
header: 'Edit',
type: 'edit',
values: columns[1].values.map(({ value }) => ({ value })),
styling: { parameterKey: 'edit' }
})
const demoData = { ...data, columns }

const tag = unsafeStatic(`widget-table-${packageJson.version}`)
const theme = { theme_name: 'light', theme_object: themeObject }
render(
html`
<${tag} .theme="${theme}" .inputData=${data}>
<${tag} .theme="${theme}" .inputData=${demoData}>
web component is not registered. Make sure to use the tag with the correct version
string from the package.json
</${tag}>
`,
document.getElementById('demo')
)
const widget = document.getElementById('demo').children[0]
console.log('data', data)
console.log('data', demoData)

// A stand-in for Studio, which merges param-submit into the page URL
// (undefined deletes a key) and opens the page a nav-submit names.
widget.addEventListener('param-submit', (event) => {
console.log('param-submit', event.detail.params)
const url = new URL(location.href)
for (const [key, value] of Object.entries(event.detail.params ?? {})) {
if (value === undefined || value === null) url.searchParams.delete(key)
else url.searchParams.set(key, value)
}
history.replaceState(null, '', url)
})
widget.addEventListener('nav-submit', (event) => console.log('nav-submit', event.detail.path))

const keyPathsToRandomize = ['columns.1.values.0.value', 'columns.2.values.0.value']
const inter = setInterval(() => {
const randomizedObj = randomizeObjectValues(data, keyPathsToRandomize)
const randomizedObj = randomizeObjectValues(demoData, keyPathsToRandomize)

// console.log('random', randomizedObj.dataseries[0].data[0])
widget.inputData = randomizedObj
Expand Down
Loading
Loading