npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@layrstack/ui

v0.1.3

Published

Reusable standalone Angular UI components, shells, typed data tables, and record forms for LayrStack applications.

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 AppShellComponent and AppSidebarNavComponent
  • Wrapper controls for buttons, inputs, dates, selects, toggles, files, images, rich text, dialogs, menus, tabs, messages, and toast
  • AppDataTableComponent with pagination, sorting, search, filters, inline edits, and frozen desktop actions
  • AppRecordFormComponent and AppRecordFormOverlayComponent driven by the same typed fields as the table
  • AppNestedDataTableComponent for expandable parent/child records
  • provideLayrStackUi() 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 quill

With pnpm:

pnpm create @angular my-layrstack-app --standalone --routing --style css
cd my-layrstack-app
pnpm add @layrstack/ui primeng @primeng/themes primeicons quill

1. 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", provide totalRecords, and handle pageRequested to load the next server page.
  • Use showEditAction, showDeleteAction, showViewAction, rowActions, and tableActions to 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:

  1. Update packages/ui/package.json using 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.
  2. Update this README with public API or setup changes.
  3. Run lint, build, and a non-publishing dry run.
  4. Review the generated packages/ui/dist contents. It is the only folder that is published.
  5. 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-run

Review 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 version

The publish script runs from packages/ui/dist; do not run npm publish from the monorepo root.