@layrstack/ui
v0.1.3
Published
Reusable standalone Angular UI components, shells, typed data tables, and record forms for LayrStack applications.
Maintainers
Readme
@layrstack/ui
Reusable standalone Angular UI components for LayrStack applications. It provides design tokens, PrimeNG configuration, responsive shells, typed Data Table and record-form composites, and wrappers for common form controls.
Included
- Responsive
AppShellComponentandAppSidebarNavComponent - Wrapper controls for buttons, inputs, dates, selects, toggles, files, images, rich text, dialogs, menus, tabs, messages, and toast
AppDataTableComponentwith pagination, sorting, search, filters, inline edits, and frozen desktop actionsAppRecordFormComponentandAppRecordFormOverlayComponentdriven by the same typed fields as the tableAppNestedDataTableComponentfor expandable parent/child recordsprovideLayrStackUi()for PrimeNG setup
Requirements
- Angular 21
- PrimeNG 21
- Node.js 20.19+ or 22.12+
Angular, PrimeNG, PrimeIcons, and Quill are peer dependencies. The consuming app keeps a single runtime copy of each.
Use in a new empty Angular application
Create a standalone application and install the package plus its peer dependencies:
npm create @angular@latest my-layrstack-app -- --standalone --routing --style=css
cd my-layrstack-app
npm install @layrstack/ui primeng @primeng/themes primeicons quillWith pnpm:
pnpm create @angular my-layrstack-app --standalone --routing --style css
cd my-layrstack-app
pnpm add @layrstack/ui primeng @primeng/themes primeicons quill1. Import the supplied global stylesheet
Add this first in src/styles.css:
@import '@layrstack/ui/styles.css';It adds LayrStack tokens, compact control sizing, page defaults, and PrimeIcons. The components still work without it, but will not have the LayrStack visual system.
2. Configure Angular providers
For a newly generated app, update src/app/app.config.ts:
import { ApplicationConfig } from '@angular/core';
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
import { provideRouter } from '@angular/router';
import { provideLayrStackUi } from '@layrstack/ui';
import { routes } from './app.routes';
export const appConfig: ApplicationConfig = {
providers: [provideRouter(routes), provideAnimationsAsync(), provideLayrStackUi({ ripple: true })],
};Register provideLayrStackUi() once at the bootstrap boundary. It applies the PrimeNG Aura preset and provides Toast and ConfirmDialog services.
3. Minimal root component
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { RouterOutlet } from '@angular/router';
@Component({
selector: 'app-root',
standalone: true,
imports: [RouterOutlet],
changeDetection: ChangeDetectionStrategy.OnPush,
template: '<router-outlet />',
})
export class AppComponent {}Complete starter: login, shell, dashboard, and table page
@layrstack/ui supplies presentation and interaction components. Authentication, API calls, tokens, and route authorization remain application responsibilities. This example deliberately uses a local AuthService so it runs in a new application; replace signIn() with your backend request and store only the session information your security model permits.
1. Add the routes
Create src/app/app.routes.ts:
import { Routes } from '@angular/router';
import { ApplicationShellComponent } from './application-shell.component';
import { DashboardPageComponent } from './dashboard-page.component';
import { LoginPageComponent } from './login-page.component';
import { CustomersPageComponent } from './customers-page.component';
export const routes: Routes = [
{ path: 'login', component: LoginPageComponent },
{
path: '',
component: ApplicationShellComponent,
children: [
{ path: 'dashboard', component: DashboardPageComponent },
{ path: 'customers', component: CustomersPageComponent },
{ path: '', pathMatch: 'full', redirectTo: 'dashboard' },
],
},
{ path: '**', redirectTo: 'dashboard' },
];In a production app, add your own canActivate guard to the shell route. The UI library intentionally does not make security decisions.
2. Create a small application-owned auth service
Create src/app/auth.service.ts:
import { Injectable, signal } from '@angular/core';
@Injectable({ providedIn: 'root' })
export class AuthService {
readonly signedIn = signal(false);
async signIn(email: string, password: string): Promise<boolean> {
// Replace with POST /auth/login. Do not log or persist a plaintext password.
if (!email || !password) return false;
this.signedIn.set(true);
return true;
}
signOut(): void {
// Also revoke/clear your real session token here.
this.signedIn.set(false);
}
}3. Create the login page
Create src/app/login-page.component.ts:
import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
import { Router } from '@angular/router';
import { AppButtonComponent, AppPasswordInputComponent, AppTextInputComponent } from '@layrstack/ui';
import { AuthService } from './auth.service';
@Component({
standalone: true,
imports: [AppButtonComponent, AppPasswordInputComponent, AppTextInputComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
styles: `
:host { display: grid; min-height: 100dvh; place-items: center; padding: 1.5rem; }
form { display: grid; gap: 1rem; width: min(100%, 24rem); padding: 1.5rem; background: white; border: 1px solid var(--ls-border); border-radius: 0.75rem; }
h1, p { margin: 0; } p { color: var(--ls-text-muted); } .error { color: var(--ls-danger); font-size: 0.875rem; }
`,
template: `
<form>
<h1>Welcome back</h1>
<p>Sign in to your workspace.</p>
<app-text-input name="email" label="Email" type="email" [value]="email()" (valueChanged)="email.set($event)" />
<app-password-input name="password" label="Password" [value]="password()" (valueChanged)="password.set($event)" />
@if (error()) { <span class="error">{{ error() }}</span> }
<app-button label="Sign in" icon="pi pi-sign-in" (pressed)="submit()" />
</form>
`,
})
export class LoginPageComponent {
private readonly auth = inject(AuthService);
private readonly router = inject(Router);
readonly email = signal('');
readonly password = signal('');
readonly error = signal('');
async submit(): Promise<void> {
const accepted = await this.auth.signIn(this.email(), this.password());
if (accepted) await this.router.navigate(['/dashboard']);
else this.error.set('Enter your email address and password.');
}
}4. Create the responsive shell
Create src/app/application-shell.component.ts:
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { Router, RouterOutlet } from '@angular/router';
import { AppShellComponent, AppSidebarNavComponent, type MenuItem } from '@layrstack/ui';
import { AuthService } from './auth.service';
@Component({
standalone: true,
imports: [AppShellComponent, AppSidebarNavComponent, RouterOutlet],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<app-shell #shell brand="Acme" topbarTitle="Operations" (logoutPressed)="logout()">
<div sidebar>
<app-sidebar-nav
[items]="navigationItems"
[activeLabel]="activeLabel"
(selected)="navigate($event); shell.closeSidebarIfMobile()"
/>
</div>
<router-outlet />
</app-shell>
`,
})
export class ApplicationShellComponent {
private readonly router = inject(Router);
private readonly auth = inject(AuthService);
activeLabel = 'Dashboard';
readonly navigationItems: MenuItem[] = [
{ label: 'WORKSPACE', kind: 'section' },
{ label: 'Dashboard', icon: 'pi pi-home' },
{ label: 'Customers', icon: 'pi pi-users' },
];
async navigate(item: MenuItem): Promise<void> {
this.activeLabel = item.label;
await this.router.navigate([item.label === 'Customers' ? '/customers' : '/dashboard']);
}
async logout(): Promise<void> {
this.auth.signOut();
await this.router.navigate(['/login']);
}
}AppShellComponent switches to a drawer below 700px. Calling closeSidebarIfMobile() after navigation keeps the mobile experience focused on the destination page.
5. Create a dashboard page
Create src/app/dashboard-page.component.ts:
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { AppPageHeaderComponent } from '@layrstack/ui';
@Component({
standalone: true,
imports: [AppPageHeaderComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
styles: `
:host { display: grid; gap: 1.5rem; padding: clamp(1rem, 3vw, 2rem); }
.metrics { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 1rem; }
.metric { padding: 1.25rem; background: white; border: 1px solid var(--ls-border); border-radius: 0.75rem; }
.metric span { color: var(--ls-text-muted); } .metric strong { display: block; margin-top: 0.35rem; font-size: 1.75rem; }
@media (max-width: 700px) { .metrics { grid-template-columns: 1fr; } }
`,
template: `
<app-page-header title="Dashboard" subtitle="Your workspace at a glance." />
<section class="metrics" aria-label="Workspace metrics">
<article class="metric"><span>Customers</span><strong>2</strong></article>
<article class="metric"><span>Open tasks</span><strong>8</strong></article>
<article class="metric"><span>Revenue</span><strong>$12,400</strong></article>
</section>
`,
})
export class DashboardPageComponent {}6. Add the table page
Create src/app/customers-page.component.ts using the complete CRUD example in the next section. The page is already registered at /customers; the shell navigation will take the user there.
How to use shell components
AppShellComponent owns the application frame. Project the sidebar content with the sidebar slot and place feature pages in a router outlet. On screens below 700px it becomes a drawer automatically.
Routes
import { Routes } from '@angular/router';
import { ApplicationShellComponent } from './application-shell.component';
import { CustomersPageComponent } from './customers-page.component';
export const routes: Routes = [
{
path: '',
component: ApplicationShellComponent,
children: [
{ path: 'customers', component: CustomersPageComponent },
{ path: '', pathMatch: 'full', redirectTo: 'customers' },
],
},
];Shell component
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { Router, RouterOutlet } from '@angular/router';
import { AppShellComponent, AppSidebarNavComponent, type MenuItem } from '@layrstack/ui';
@Component({
standalone: true,
imports: [AppShellComponent, AppSidebarNavComponent, RouterOutlet],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<app-shell #shell brand="Acme" topbarTitle="Operations">
<div sidebar>
<app-sidebar-nav
[items]="navigationItems"
activeLabel="Customers"
(selected)="navigate($event); shell.closeSidebarIfMobile()"
/>
</div>
<router-outlet />
</app-shell>
`,
})
export class ApplicationShellComponent {
private readonly router = inject(Router);
readonly navigationItems: MenuItem[] = [
{ label: 'WORKSPACE', kind: 'section' },
{ label: 'Customers', icon: 'pi pi-users' },
];
navigate(item: MenuItem): void {
if (item.label === 'Customers') void this.router.navigate(['/customers']);
}
}Use the public closeSidebarIfMobile() method after sidebar navigation so the mobile drawer closes. The desktop menu button collapses the sidebar.
How to use table components
Define fields once, then reuse them for table display, inline edit, filters, and the record form. The example below is working in-memory CRUD; replace signal updates with API calls in production.
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import {
AppDataTableComponent,
AppRecordFormOverlayComponent,
type ColumnDefinition,
type FieldDefinition,
type FilterDefinition,
type InlineEditEvent,
} from '@layrstack/ui';
type Customer = Record<string, unknown> & {
id: string;
name: string;
status: 'active' | 'draft';
enabled: boolean;
};
@Component({
standalone: true,
imports: [AppDataTableComponent, AppRecordFormOverlayComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<app-data-table
title="Customers"
subtitle="Manage customer records."
addLabel="Add customer"
[rows]="records()"
[columns]="columns"
[fields]="fields"
[filters]="filters"
[pageSize]="10"
[rowsPerPageOptions]="[10, 25, 50]"
[showAdvancedFilters]="false"
[showEditAction]="true"
[showDeleteAction]="true"
[inlineEdit]="true"
(addPressed)="beginCreate()"
(editRequested)="beginEdit($event)"
(deleteRequested)="remove($event)"
(inlineEdited)="applyInlineEdit($event)"
/>
<app-record-form-overlay
[title]="creating() ? 'Add customer' : 'Edit customer'"
mode="sidebar"
[visible]="formOpen()"
[fields]="fields"
[initialValue]="selected()"
[submitLabel]="creating() ? 'Create customer' : 'Save customer'"
(visibleChanged)="formOpen.set($event)"
(save)="save($event)"
(cancelled)="formOpen.set(false)"
/>
`,
})
export class CustomersPageComponent {
readonly formOpen = signal(false);
readonly creating = signal(false);
readonly records = signal<Customer[]>([
{ id: 'cus_001', name: 'Acme Inc.', status: 'active', enabled: true },
{ id: 'cus_002', name: 'Northstar Labs', status: 'draft', enabled: false },
]);
readonly selected = signal<Customer>(this.records()[0]);
readonly fields: FieldDefinition[] = [
{ id: 'name', key: 'name', label: 'Customer name', type: 'text', position: 1, required: true },
{
id: 'status',
key: 'status',
label: 'Status',
type: 'single-select',
position: 2,
options: [
{ label: 'Active', value: 'active', severity: 'success' },
{ label: 'Draft', value: 'draft', severity: 'warn' },
],
},
{ id: 'enabled', key: 'enabled', label: 'Enabled', type: 'boolean', position: 3 },
];
readonly columns: ColumnDefinition[] = [
{ key: 'name', label: 'Customer', type: 'text', sortable: true, editable: true },
{ key: 'status', label: 'Status', type: 'single-select', sortable: true, editable: true },
{ key: 'enabled', label: 'Enabled', type: 'boolean', editable: true },
];
readonly filters: FilterDefinition[] = [
{ id: 'filter-name', key: 'name', label: 'Customer name', type: 'text', position: 1, clearable: true },
{
id: 'filter-status',
key: 'status',
label: 'Status',
type: 'single-select',
position: 2,
clearable: true,
options: [
{ label: 'Active', value: 'active' },
{ label: 'Draft', value: 'draft' },
],
},
{ id: 'filter-enabled', key: 'enabled', label: 'Enabled', type: 'boolean', position: 3, clearable: true },
];
beginCreate(): void {
this.creating.set(true);
this.selected.set({ id: crypto.randomUUID(), name: '', status: 'draft', enabled: true });
this.formOpen.set(true);
}
beginEdit(record: Record<string, unknown>): void {
this.creating.set(false);
this.selected.set(record as Customer);
this.formOpen.set(true);
}
applyInlineEdit(event: InlineEditEvent): void {
this.records.update((current) =>
current.map((record) =>
record.id === event.record['id'] ? ({ ...record, [event.field.key]: event.value } as Customer) : record,
),
);
}
save(value: Record<string, unknown>): void {
const customer = value as Customer;
this.records.update((current) =>
this.creating() ? [customer, ...current] : current.map((record) => (record.id === customer.id ? customer : record)),
);
this.formOpen.set(false);
}
remove(record: Record<string, unknown>): void {
this.records.update((current) => current.filter((customer) => customer.id !== record['id']));
}
}Table behavior
- For local arrays, use the default
serverSide="false"; the table handles pagination, search, sort, and typed filters in memory. - For API-backed data, set
serverSide="true", providetotalRecords, and handlepageRequestedto load the next server page. - Use
showEditAction,showDeleteAction,showViewAction,rowActions, andtableActionsto configure the frozen desktop action column. showAdvancedFilters="false"starts filters collapsed. The table toolbar provides the Filters button.- File, image, avatar, multi-select, and rich-text inline edits use the shared overlay editor. The record form edits every field type.
Supported field types
text, number, currency, percentage, progress, boolean, date, datetime, time, password, email, phone, url, file, image, avatar, single-select, multi-select, and rich-text.
Use currencyCode, locale, maxInlineTags, maxInlineMedia, imageAlt, accept, multiple, and option severity where applicable.
Other standalone components
import {
AppButtonComponent,
AppDialogComponent,
AppMessageComponent,
AppPageHeaderComponent,
AppSelectComponent,
AppTabsComponent,
AppTextInputComponent,
AppToggleComponent,
} from '@layrstack/ui';Import only the components used by a feature. Use their typed inputs and outputs instead of native form controls to retain consistent styling, overlay behavior, and accessibility.
Tenant theme override
Add overrides after the global stylesheet import:
:root[data-tenant-theme='violet'] {
--ls-primary: #7c3aed;
--ls-primary-hover: #6d28d9;
--ls-primary-soft: #ede9fe;
--ls-success: #15803d;
--ls-success-soft: #dcfce7;
}Update, release, and publish
This workspace is prepared as 0.1.3. Do not reuse a version that already exists in npm: npm versions are immutable.
For every library change:
- Update
packages/ui/package.jsonusing semantic versioning:- Patch (
0.1.3→0.1.4) for backwards-compatible fixes and documentation. - Minor (
0.1.3→0.2.0) for backwards-compatible components or inputs. - Major (
0.1.3→1.0.0) for removed/renamed public APIs or other breaking changes.
- Patch (
- Update this README with public API or setup changes.
- Run lint, build, and a non-publishing dry run.
- Review the generated
packages/ui/distcontents. It is the only folder that is published. - Publish after authenticating to the npm account or organization that owns
@layrstack.
# Run from the monorepo root
pnpm --filter @layrstack/ui lint
pnpm --filter @layrstack/ui build
pnpm --filter @layrstack/ui publish:dry-runReview the dry-run output. Then authenticate with the npm account that owns the @layrstack scope and publish only the built package:
npm login
pnpm --filter @layrstack/ui publish:public
npm view @layrstack/ui versionThe publish script runs from packages/ui/dist; do not run npm publish from the monorepo root.
