@mmlogic/components
v0.3.2
Published
Stencil.js web component library for dynamic forms and virtual-scroll data tables
Maintainers
Readme
@mmlogic/components
A Stencil.js v4 web component library for rendering dynamic forms and paginated data tables from API descriptors. Components are framework-agnostic (plain HTML/JS) and an Angular output target is included.
Installation
npm install @mmlogic/componentsUsage
Vanilla HTML / JS
<link rel="stylesheet" href="node_modules/@mmlogic/components/dist/mosterdcomponents/mosterdcomponents.css" />
<script type="module" src="node_modules/@mmlogic/components/dist/mosterdcomponents/mosterdcomponents.esm.js"></script>
<mrd-form id="my-form"></mrd-form>
<script>
const form = document.getElementById('my-form');
form.layout = { /* ClientLayout descriptor */ };
form.values = { name: 'Alice' };
form.addEventListener('mrdSubmit', e => console.log(e.detail));
</script>Via CDN (unpkg)
<link rel="stylesheet" href="https://unpkg.com/@mmlogic/components/dist/mosterdcomponents/mosterdcomponents.css" />
<script type="module" src="https://unpkg.com/@mmlogic/components/dist/mosterdcomponents/mosterdcomponents.esm.js"></script>Angular
// app.module.ts
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { defineCustomElements } from '@mmlogic/components/loader';
defineCustomElements();
@NgModule({ schemas: [CUSTOM_ELEMENTS_SCHEMA] })
export class AppModule {}Components
<mrd-form>
Renders a complete form from a layout descriptor. Handles validation, RTL layout (Arabic locale), and orchestrates file uploads, relation search, and dependent dropdowns.
<mrd-form id="form" locale="nl-NL" show-cancel></mrd-form>const form = document.getElementById('form');
form.layout = {
title: 'New invoice',
items: [
{ type: 'FIELD', field: { name: 'description', label: 'Description', dataType: 'TEXT', required: true } },
{ type: 'FIELD', field: { name: 'amount', label: 'Amount', dataType: 'CURRENCY' } },
]
};
form.addEventListener('mrdSubmit', e => console.log(e.detail));
form.addEventListener('mrdCancel', () => history.back());Props
| Prop | Type | Default | Description |
|---|---|---|---|
| layout | ClientLayout | required | Form descriptor with title and items[] |
| values | Record<string, any> | {} | Initial field values; re-setting this prop re-fills the form (edit mode) |
| locale | string | navigator.language | BCP 47 locale for labels, validation messages, and formatting |
| referenceHref | string | '' | Absolute href of a parent/context object. Combined with referenceClass, mrd-form automatically pre-fills the matching relation field so dependent dropdowns load without any host-side form logic. |
| referenceClass | string | '' | mostSignificantClass of the parent object (e.g. 'clientAgreements'). Used to locate the relation field to pre-fill. |
| showCancel | boolean | false | When true, shows a Cancel button next to Submit in the footer. |
Events
| Event | Detail | Description |
|---|---|---|
| mrdSubmit | Record<string, any> | Fired on valid submit. Payload contains only layout fields; relation values are normalised to plain href strings. |
| mrdCancel | — | Fired when the Cancel button is clicked. Host handles navigation. |
| mrdSearch | { query: string; relatedClass: string } | Relation search requested. Host calls field.setSearchResults(results) with the API response. |
| mrdFetchAll | see below | All records requested for a DROPDOWN relation. Host calls field.setAllRecords(results). |
| mrdUpload | { name: string; file: File } | A file was selected. Host uploads the file and calls form.setFieldValue(name, uri) when done. The field shows a loading spinner until the URI arrives. |
Methods
| Method | Description |
|---|---|
| setFieldValue(name, value) | Inject a value into a named field from outside (e.g. after a file upload completes). |
| setSearchResults(results) | (on mrd-relation-field) Populate the search autocomplete list. |
| setAllRecords(results) | (on mrd-relation-field) Populate a DROPDOWN <select> with all records. |
mrdFetchAll event detail
{
name: string; // field name — use to find the mrd-relation-field element
relatedClass: string; // dot-notation class (e.g. "legalmanager.budget")
mostSignificantClass?: string; // URL segment (e.g. "budgets")
commonRelation?: string; // name of the dependency field (for dependent dropdowns)
filter?: string; // query param name
filterValue?: string; // href of the selected dependency; empty string = clear list
}Full integration example
// mrdSearch — typeahead for RELATION fields
form.addEventListener('mrdSearch', async (e) => {
const { query, relatedClass } = e.detail;
const data = await fetch(`/api/search/${relatedClass}?q=${query}`).then(r => r.json());
const field = form.querySelector(`mrd-relation-field[name="${relatedClass}"]`);
field?.setSearchResults(data.map(r => ({ id: r._links.self.href, label: r.name })));
});
// mrdFetchAll — populate DROPDOWN selects (including dependent dropdowns)
form.addEventListener('mrdFetchAll', async (e) => {
const { name, mostSignificantClass, filter, filterValue } = e.detail;
const field = form.querySelector(`mrd-relation-field[name="${name}"]`);
if (!field) return;
if (filter && !filterValue) { // dependency cleared → empty the list
field.setAllRecords([]);
return;
}
let url = `/data/${tenantId}/${mostSignificantClass}`;
if (filter && filterValue) url += `?${filter}=${encodeURIComponent(filterValue)}`;
const result = await fetch(url).then(r => r.json());
const records = Object.values(result._embedded ?? {})[0] ?? [];
field.setAllRecords(records.map(r => ({ id: r._links.self.href, label: r.name })));
});
// mrdUpload — stream file to server before submit
form.addEventListener('mrdUpload', async (e) => {
const { name, file } = e.detail;
const formData = new FormData();
formData.append('file', file);
// Get pre-signed upload URL from server, then POST the file
const uploadUrl = await fetch('/api/upload').then(r => r.json());
const [uri] = await fetch(uploadUrl, { method: 'POST', body: formData }).then(r => r.json());
// Return the stored URI to the field — spinner disappears, value is ready for submit
form.setFieldValue(name, uri);
});<mrd-table>
A virtual-scroll paginated table with column sorting, Excel-style filtering, toolbar actions, and a pagination footer. Only visible rows are in the DOM; pages are fetched on demand via an event.
<mrd-table id="table"></mrd-table>const table = document.getElementById('table');
table.columns = view.columns; // TableColumn[]
table.totalElements = page.totalElements;
table.pageSize = page.size;
table.tableHeight = 500; // px, fixed container height
table.locale = 'nl-NL'; // en / nl / ar / fr
table.defaultSort = view.defaultSort ?? '';
table.actions = [
{ action: 'create', label: 'New record', icon: 'assets/sprites.svg#icon-plus', variant: 'primary' },
{ action: 'export', label: 'Export to Excel', icon: 'assets/sprites.svg#icon-file-excel', disabled: true },
];
let activeFilters = [];
table.addEventListener('mrdFilter', (e) => {
activeFilters = e.detail.filters; // ColumnFilter[]
});
table.addEventListener('mrdLoadPage', async (e) => {
const { page, sort } = e.detail;
const params = new URLSearchParams();
if (page > 0) params.set('page', String(page));
if (sort) params.set('sort', sort);
activeFilters.forEach(f => {
if (f.operator === 'isEmpty') { params.set(f.field, ''); return; }
if (f.operator === 'isNotEmpty') { params.set(f.field + '_notempty', 'true'); return; }
if (f.values?.length) { params.set(f.field, f.values.join(',')); return; }
if (f.value != null) params.set(f.field, String(f.value));
if (f.from != null) params.set(f.field + '_from', String(f.from));
if (f.to != null) params.set(f.field + '_to', String(f.to));
});
const qs = params.toString();
const result = await fetch(qs ? `${dataUrl}?${qs}` : dataUrl).then(r => r.json());
const rows = Object.values(result._embedded ?? {})[0] ?? [];
await table.setPage(page, rows);
});
table.addEventListener('mrdAction', (e) => {
if (e.detail.action === 'create') location.href = '/new';
if (e.detail.action === 'export') window.open(excelUrl, '_blank');
});
table.addEventListener('mrdRowClick', (e) => {
location.href = e.detail._links.self.href;
});
await table.init();
await table.setPage(0, page0Rows); // inject pre-fetched page 0 without a second requestProps
| Prop | Type | Default | Description |
|---|---|---|---|
| columns | TableColumn[] | [] | Column definitions |
| totalElements | number | 0 | Total record count. 0 = non-paginated mode (use rows prop instead) |
| pageSize | number | 20 | Records per page (must match API page size) |
| rowHeight | number | 36 | Row height in px |
| tableHeight | number | 500 | Fixed scroll-container height in px |
| locale | string | navigator.language | Locale for cell formatting and built-in labels (en/nl/ar/fr) |
| defaultSort | string | '' | Initial sort, e.g. "timestamp,desc" or "name" |
| rows | Record[] | [] | Rows for non-paginated mode |
| actions | TableAction[] | [] | Toolbar action buttons ({ action, label, icon?, variant?, disabled? }) |
Events
| Event | Detail | Description |
|---|---|---|
| mrdLoadPage | { page: number; sort: string } | Fired when a page needs to be fetched |
| mrdRowClick | row object | Fired when a data row is clicked |
| mrdAction | { action: string } | Fired when a toolbar action button is clicked |
| mrdFilter | { filters: ColumnFilter[] } | Fired when active column filters change |
Methods
| Method | Description |
|---|---|
| init() | Reset state and render window. Call after all props are set and before setPage(0, …). |
| setPage(page, rows) | Inject fetched rows for a page. Automatically clamps the render window when the page is shorter than pageSize — prevents shimmer rows beyond actual data. |
Sort format: "fieldName" for ASC, "fieldName,desc" for DESC — append directly as &sort=<value>.
Column filtering
A filter-toggle button (▼) in the toolbar activates filter mode. Clicking ▾ in any column header opens a popup with sort and type-specific filter controls. Supported per data type:
| DataType | Filter | |---|---| | TEXT, EMAIL, HYPERLINK, RELATION | Starts with / equals / is empty / is not empty | | INTEGER, DECIMAL, PERCENTAGE, CURRENCY | Exact value or from–to range | | DATE, DATETIME, TIME | Exact date or from–to range | | BOOLEAN | All / Yes / No | | LIST | Checkbox list | | FILE, IMAGE | Not supported |
Field components
All leaf components can be used standalone. Each emits mrdChange and mrdBlur.
| Component | DataType | Description |
|---|---|---|
| <mrd-text-field> | TEXT | Single-line text input |
| <mrd-textarea-field> | TEXTBLOCK | Rich text editor (Quill, lazy-loaded) |
| <mrd-number-field> | INTEGER, DECIMAL, PERCENTAGE | Numeric input |
| <mrd-currency-field> | CURRENCY | Amount + ISO-4217 currency selector |
| <mrd-boolean-field> | BOOLEAN | Toggle switch |
| <mrd-date-field> | DATE | Date picker |
| <mrd-datetime-field> | DATETIME | Date + time picker |
| <mrd-time-field> | TIME | Time picker |
| <mrd-email-field> | EMAIL | Email input with validation |
| <mrd-hyperlink-field> | HYPERLINK | URL input with validation |
| <mrd-list-field> | LIST | Single dropdown or multi-checkbox |
| <mrd-file-field> | FILE | Drag-and-drop file upload with upload spinner |
| <mrd-image-field> | IMAGE | Drag-and-drop image upload with local preview and spinner |
| <mrd-relation-field> | RELATION | Typeahead search (event-driven) or DROPDOWN select |
Common props
| Prop | Type | Description |
|---|---|---|
| name | string | Field name, echoed in event detail |
| label | string | Visible label |
| value | any | Current value |
| required | boolean | Validates on blur; shows error when empty |
| disabled | boolean | Disables the input |
| locale | string | BCP 47 locale |
Common events
| Event | Detail | Description |
|---|---|---|
| mrdChange | { name: string; value: any } | Fired on every value change |
| mrdBlur | { name: string; value: any } | Fired on blur (triggers validation) |
| mrdUpload | { name: string; file: File } | (file/image only) Fired immediately when a file is selected so the host can stream it to the server |
File / image upload flow
When a file is picked:
- The field emits
mrdUploadwith the rawFileobject. - The field immediately shows a spinner and updates its value to the
File(blocking submit). - The host uploads the file and calls
form.setFieldValue(name, uri)(orfield.value = uri). - The field detects the string URI, clears the spinner, and is ready for submit.
The submit payload always contains the final URI string — never a File object.
Theming
All design tokens are CSS custom properties. The default primary colour is green (#16a34a). Override on :root or any ancestor to match your brand:
:root {
--mrd-color-primary: #16a34a; /* green-600 — change to your brand colour */
--mrd-color-primary-light: #dcfce7;
--mrd-color-primary-dark: #15803d;
--mrd-color-primary-hover: #166534;
--mrd-shadow-focus: 0 0 0 3px rgb(22 163 74 / 0.2); /* match primary */
--mrd-color-neutral-100: #f3f4f6;
--mrd-border-radius: 0.375rem;
--mrd-font-size-sm: 0.875rem;
}The full list is defined in src/global/variables.scss and exposed in the built file dist/mosterdcomponents/mosterdcomponents.css.
TypeScript types
import type { ClientLayout, ClientLayoutItem, RelationSearchResult } from '@mmlogic/components';
import type { TableColumn, TableAction, ColumnFilter } from '@mmlogic/components/dist/types/utils/cell-renderer';License
MIT
