aria-grid-demo: live examples of @jambotron/aria-grid with Jambotron light/dark and Synthwave themes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-06 03:10:36 +01:00
co-authored by Claude Opus 5.5
commit 9d2c1e0b4c
32 changed files with 7519 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
.git
.angular
.vscode
node_modules
dist
*.log
+17
View File
@@ -0,0 +1,17 @@
# Editor configuration, see https://editorconfig.org
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
[*.ts]
quote_type = single
ij_typescript_use_double_quotes = false
[*.md]
max_line_length = off
trim_trailing_whitespace = false
+44
View File
@@ -0,0 +1,44 @@
# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
# Compiled output
/dist
/tmp
/out-tsc
/bazel-out
# Node
/node_modules
npm-debug.log
yarn-error.log
# IDEs and editors
.idea/
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# Visual Studio Code
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
!.vscode/mcp.json
.history/*
# Miscellaneous
/.angular/cache
.sass-cache/
/connect.lock
/coverage
/libpeerconnection.log
testem.log
/typings
__screenshots__/
# System files
.DS_Store
Thumbs.db
+1
View File
@@ -0,0 +1 @@
@jambotron:registry=https://gitea.jambotron.com/api/packages/jambotron_gang/npm/
+12
View File
@@ -0,0 +1,12 @@
{
"printWidth": 100,
"singleQuote": true,
"overrides": [
{
"files": "*.html",
"options": {
"parser": "angular"
}
}
]
}
+4
View File
@@ -0,0 +1,4 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846
"recommendations": ["angular.ng-template"]
}
+20
View File
@@ -0,0 +1,20 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "ng serve",
"type": "chrome",
"request": "launch",
"preLaunchTask": "npm: start",
"url": "http://localhost:4200/"
},
{
"name": "ng test",
"type": "chrome",
"request": "launch",
"preLaunchTask": "npm: test",
"url": "http://localhost:9876/debug.html"
}
]
}
+42
View File
@@ -0,0 +1,42 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?LinkId=733558
"version": "2.0.0",
"tasks": [
{
"type": "npm",
"script": "start",
"isBackground": true,
"problemMatcher": {
"owner": "typescript",
"pattern": "$tsc",
"background": {
"activeOnStart": true,
"beginsPattern": {
"regexp": "Changes detected"
},
"endsPattern": {
"regexp": "bundle generation (complete|failed)"
}
}
}
},
{
"type": "npm",
"script": "test",
"isBackground": true,
"problemMatcher": {
"owner": "typescript",
"pattern": "$tsc",
"background": {
"activeOnStart": true,
"beginsPattern": {
"regexp": "Changes detected"
},
"endsPattern": {
"regexp": "bundle generation (complete|failed)"
}
}
}
}
]
}
+14
View File
@@ -0,0 +1,14 @@
# 1. Build the Angular app. Debian images, not Alpine: Alpine binaries die with "Bus error" on some Jambotron VMs.
FROM node:22-bookworm-slim AS build
WORKDIR /app
COPY package.json package-lock.json .npmrc ./
RUN npm ci --no-audit --no-fund
COPY . .
RUN npx ng build --configuration=production
# 2. Serve the static files
FROM nginx:1.27
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist/aria-grid-demo/browser /usr/share/nginx/html
EXPOSE 80
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 CMD curl -fsS http://localhost/ >/dev/null || exit 1
+30
View File
@@ -0,0 +1,30 @@
# aria-grid-demo
Live examples of [`@jambotron/aria-grid`](https://gitea.jambotron.com/jambotron_gang/-/packages/npm/@jambotron%2Faria-grid) —
an Angular data grid on Angular Aria. Three examples and a theme switch:
1. **Columns, sorting, paging** — cell templates, a column without sorting, filter and the empty state.
2. **Selection and bulk actions** — `JtGridBulkService`: an action without confirmation, with a required reason,
with a dangerous confirmation; "locked" rows fail on purpose to show "done N, failed M".
3. **Row actions and highlight** — icons in the row, the "⋮" menu, dangerous and disabled actions, the open row highlighted.
Themes: **Jambotron light**, **Jambotron dark** (the tokens of jambotron.com) and **Synthwave** (the colors of
reactive-mail-server's web mail). A theme is one attribute — `<html data-theme="…">`; everything is in `src/styles.scss`.
The grid language switch (English / Russian) shows `provideJtGridLabels()`.
## Run
```bash
npm ci # @jambotron scope comes from the Gitea npm registry (.npmrc), no token needed
npx ng serve # http://localhost:4200
```
## Deploy on Jambotron
Upload the repository (ZIP) as a project, pick `docker-compose.yml` in the deploy wizard, give the `web` service a domain,
port `80`. The image builds the app (`node:22-bookworm-slim`) and serves it with nginx (`nginx:1.27`, Debian);
any unknown path falls back to `index.html`.
```bash
docker compose up -d --build
```
+97
View File
@@ -0,0 +1,97 @@
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"cli": {
"packageManager": "npm"
},
"newProjectRoot": "projects",
"projects": {
"aria-grid-demo": {
"projectType": "application",
"schematics": {
"@schematics/angular:component": {
"style": "scss",
"skipTests": true
},
"@schematics/angular:class": {
"skipTests": true
},
"@schematics/angular:directive": {
"skipTests": true
},
"@schematics/angular:guard": {
"skipTests": true
},
"@schematics/angular:interceptor": {
"skipTests": true
},
"@schematics/angular:pipe": {
"skipTests": true
},
"@schematics/angular:resolver": {
"skipTests": true
},
"@schematics/angular:service": {
"skipTests": true
}
},
"root": "",
"sourceRoot": "src",
"prefix": "app",
"architect": {
"build": {
"builder": "@angular/build:application",
"options": {
"browser": "src/main.ts",
"tsConfig": "tsconfig.app.json",
"inlineStyleLanguage": "scss",
"assets": [
{
"glob": "**/*",
"input": "public"
}
],
"styles": [
"src/styles.scss"
]
},
"configurations": {
"production": {
"budgets": [
{
"type": "initial",
"maximumWarning": "1MB",
"maximumError": "2MB"
},
{
"type": "anyComponentStyle",
"maximumWarning": "4kB",
"maximumError": "8kB"
}
],
"outputHashing": "all"
},
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true
}
},
"defaultConfiguration": "production"
},
"serve": {
"builder": "@angular/build:dev-server",
"configurations": {
"production": {
"buildTarget": "aria-grid-demo:build:production"
},
"development": {
"buildTarget": "aria-grid-demo:build:development"
}
},
"defaultConfiguration": "development"
}
}
}
}
}
+10
View File
@@ -0,0 +1,10 @@
# Deploy on Jambotron: pick this file in the deploy wizard, give the "web" service a domain, port 80.
name: aria-grid-demo
services:
web:
build: .
restart: unless-stopped
expose:
- "80"
mem_limit: 64m
+19
View File
@@ -0,0 +1,19 @@
server {
listen 80;
root /usr/share/nginx/html;
# Hashed bundles never change — cache for a year; index.html — always fresh
location ~* \.(js|css|woff2?|ico|png|svg)$ {
expires 1y;
add_header Cache-Control "public, immutable";
try_files $uri =404;
}
location / {
add_header Cache-Control "no-cache";
try_files $uri $uri/ /index.html;
}
gzip on;
gzip_types text/css application/javascript image/svg+xml;
}
+6088
View File
File diff suppressed because it is too large Load Diff
+34
View File
@@ -0,0 +1,34 @@
{
"name": "aria-grid-demo",
"version": "0.0.0",
"scripts": {
"ng": "ng",
"start": "ng serve",
"build": "ng build",
"watch": "ng build --watch --configuration development",
"test": "ng test"
},
"private": true,
"packageManager": "npm@10.9.9",
"dependencies": {
"@angular/aria": "^22.2.6",
"@angular/cdk": "^22.2.6",
"@angular/common": "^22.2.0",
"@angular/compiler": "^22.2.0",
"@angular/core": "^22.2.0",
"@angular/forms": "^22.2.0",
"@angular/material": "^22.2.6",
"@angular/platform-browser": "^22.2.0",
"@angular/router": "^22.2.0",
"@jambotron/aria-grid": "^0.1.0",
"rxjs": "~7.8.0",
"tslib": "^2.3.0"
},
"devDependencies": {
"@angular/build": "^22.2.4",
"@angular/cli": "^22.2.4",
"@angular/compiler-cli": "^22.2.0",
"prettier": "^3.8.1",
"typescript": "~6.0.2"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+11
View File
@@ -0,0 +1,11 @@
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
import { provideJtGridLabels } from '@jambotron/aria-grid';
import { lang, RU_LABELS } from './i18n/labels';
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
// Grid texts: a function, so they follow the language switch. English needs nothing — it is the default.
provideJtGridLabels(() => (lang() === 'ru' ? RU_LABELS : {}))
]
};
+49
View File
@@ -0,0 +1,49 @@
<header class="top">
<div class="brand">
<span class="brand-mark" aria-hidden="true">▦</span>
<div>
<h1>&#64;jambotron/aria-grid</h1>
<p>Angular data grid on Angular Aria — live examples</p>
</div>
</div>
<div class="switches">
<!-- jt-select comes with the grid package: the same combobox the grid uses for the page size -->
<jt-select label="Theme" [options]="themes" [value]="theme()" (valueChange)="$event && theme.set($event)" />
<jt-select label="Grid language" [options]="langs" [value]="lang()" (valueChange)="$event && lang.set($event)" />
</div>
</header>
<main>
<section class="panel" aria-labelledby="ex1">
<h2 id="ex1"><span>1</span> Columns, sorting, paging</h2>
<p class="lead">
Click a header to sort: ascending → descending → original order. "Owner" has no <code>sort</code>, so it is not a button.
Type in the filter — the page and the sort stay, an empty result shows <code>emptyMessage</code>.
</p>
<demo-basic-example />
</section>
<section class="panel" aria-labelledby="ex2">
<h2 id="ex2"><span>2</span> Selection and bulk actions</h2>
<p class="lead">
"Restart" runs at once, "Stop" asks for a reason, "Delete" asks for confirmation. Servers marked
<span class="chip chip--locked">locked</span> fail on purpose: the rest still run, and the result says "done N, failed M".
The selection survives sorting and paging.
</p>
<demo-bulk-example />
</section>
<section class="panel" aria-labelledby="ex3">
<h2 id="ex3"><span>3</span> Row actions and highlight</h2>
<p class="lead">
Two icons in the row, the rest in the "⋮" menu, "Delete" last in red. On locked servers "Rename" and "Delete" are disabled.
"Open" highlights the row while its details are shown.
</p>
<demo-row-actions-example />
</section>
</main>
<footer class="foot">
Deployed on <a href="https://jambotron.com" target="_blank" rel="noopener">jambotron.com</a> ·
source <a href="https://gitea.jambotron.com/jambotron_gang/aria-grid-demo" target="_blank" rel="noopener">aria-grid-demo</a>
</footer>
+23
View File
@@ -0,0 +1,23 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { JtSelectComponent, JtSelectOption } from '@jambotron/aria-grid';
import { BasicExampleComponent } from './examples/basic-example';
import { BulkExampleComponent } from './examples/bulk-example';
import { RowActionsExampleComponent } from './examples/row-actions-example';
import { Lang, lang } from './i18n/labels';
import { THEMES, ThemeService } from './theme/theme.service';
@Component({
selector: 'app-root',
imports: [JtSelectComponent, BasicExampleComponent, BulkExampleComponent, RowActionsExampleComponent],
templateUrl: './app.html',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class App {
protected readonly theme = inject(ThemeService).theme;
protected readonly themes = THEMES;
protected readonly lang = lang;
protected readonly langs: JtSelectOption<Lang>[] = [
{ value: 'en', label: 'English' },
{ value: 'ru', label: 'Русский' }
];
}
+55
View File
@@ -0,0 +1,55 @@
import { delay, Observable, of, throwError } from 'rxjs';
export type ServerStatus = 'running' | 'stopped' | 'error';
export interface Server {
id: number;
name: string;
region: string;
status: ServerStatus;
cpu: number; // load, %
memoryGb: number;
created: Date;
owner: string;
locked: boolean; // protected server: every action on it fails (shows "failed N" in bulk actions)
}
const REGIONS = ['eu-central', 'eu-north', 'us-east', 'us-west', 'ap-south'];
const ROLES = ['web', 'api', 'db', 'cache', 'queue', 'mail', 'worker', 'search'];
const OWNERS = ['alice', 'bob', 'carol', 'dave', 'erin', 'frank'];
const STATUSES: ServerStatus[] = ['running', 'running', 'running', 'stopped', 'error'];
// Same list on every load: a small seeded generator instead of Math.random()
function seeded(seed: number): () => number {
return () => {
seed = (seed * 1664525 + 1013904223) % 4294967296;
return seed / 4294967296;
};
}
export function makeServers(count = 137): Server[] {
const random = seeded(42);
const pick = <T>(list: T[]) => list[Math.floor(random() * list.length)];
const start = Date.UTC(2025, 0, 1);
return Array.from({ length: count }, (_, i) => {
const status = pick(STATUSES);
return {
id: i + 1,
name: `${pick(ROLES)}-${String(i + 1).padStart(3, '0')}`,
region: pick(REGIONS),
status,
cpu: status === 'running' ? Math.round(random() * 100) : 0,
memoryGb: pick([1, 2, 4, 8, 16, 32]),
created: new Date(start + Math.floor(random() * 640) * 86_400_000),
owner: pick(OWNERS),
locked: random() < 0.08
};
});
}
/** Pretend backend: answers after a short delay; a locked server always fails. */
export function fakeCall(server: Server): Observable<void> {
return server.locked
? throwError(() => new Error(`${server.name} is locked`)).pipe(delay(120))
: of(undefined).pipe(delay(120));
}
+80
View File
@@ -0,0 +1,80 @@
import { ChangeDetectionStrategy, Component, computed, signal } from '@angular/core';
import { DatePipe } from '@angular/common';
import { JtGridCellDirective, JtGridColumn, JtGridComponent } from '@jambotron/aria-grid';
import { makeServers, Server } from '../data/servers';
import { text } from '../i18n/labels';
import { CodeComponent } from './code';
/** Example 1: columns, cell templates, sorting, paging, filtering and the empty state. */
@Component({
selector: 'demo-basic-example',
imports: [JtGridComponent, JtGridCellDirective, DatePipe, CodeComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<label class="filter">
<span>Filter</span>
<input type="search" placeholder="name, region or owner" [value]="filter()" (input)="filter.set($any($event.target).value)" />
</label>
<jt-grid [rows]="rows()" [columns]="columns()" [trackBy]="byId" [label]="t().servers"
[initialSort]="{ key: 'name', direction: 'asc' }" [pageSize]="10" [emptyMessage]="t().nothing">
<ng-template jtGridCell="status" let-row>
<span class="chip chip--{{ row.status }}">{{ statusText(row.status) }}</span>
</ng-template>
<ng-template jtGridCell="cpu" let-row>
<span class="meter" [style.--value]="row.cpu"><span>{{ row.cpu }}%</span></span>
</ng-template>
<ng-template jtGridCell="memoryGb" let-row>{{ row.memoryGb }} GB</ng-template>
<ng-template jtGridCell="created" let-row>{{ row.created | date: 'mediumDate' }}</ng-template>
</jt-grid>
<demo-code [code]="code" />
`
})
export class BasicExampleComponent {
protected readonly t = text;
protected readonly statusText = (s: Server['status']) => this.t()[s];
private readonly all = makeServers();
protected readonly filter = signal('');
// rows is a plain input: filter the array yourself, the grid keeps the page and the sort
protected readonly rows = computed(() => {
const q = this.filter().trim().toLowerCase();
return q ? this.all.filter(s => [s.name, s.region, s.owner].some(v => v.includes(q))) : this.all;
});
// computed — so the column titles follow the language switch
protected readonly columns = computed<JtGridColumn<Server>[]>(() => {
const t = this.t();
return [
{ key: 'name', label: t.name, sort: s => s.name },
{ key: 'region', label: t.region, sort: s => s.region },
{ key: 'status', label: t.status, sort: s => s.status },
{ key: 'cpu', label: t.cpu, sort: s => s.cpu, align: 'end' },
{ key: 'memoryGb', label: t.memory, sort: s => s.memoryGb, align: 'end' },
{ key: 'created', label: t.created, sort: s => s.created },
{ key: 'owner', label: t.owner } // no sort — the header is not a button
];
});
protected readonly byId = (s: Server) => s.id;
protected readonly code = `columns: JtGridColumn<Server>[] = [
{ key: 'name', label: 'Name', sort: s => s.name },
{ key: 'status', label: 'Status', sort: s => s.status },
{ key: 'cpu', label: 'CPU', sort: s => s.cpu, align: 'end' },
{ key: 'created', label: 'Created', sort: s => s.created },
{ key: 'owner', label: 'Owner' } // not sortable
];
byId = (s: Server) => s.id;
<jt-grid [rows]="rows()" [columns]="columns" [trackBy]="byId" label="Servers"
[initialSort]="{ key: 'name', direction: 'asc' }" [pageSize]="10"
emptyMessage="No servers match the filter.">
<ng-template jtGridCell="status" let-row>
<span class="chip chip--{{ row.status }}">{{ row.status }}</span>
</ng-template>
<ng-template jtGridCell="created" let-row>{{ row.created | date }}</ng-template>
<!-- no template for "name" and "owner": the grid prints row[key] -->
</jt-grid>`;
}
+127
View File
@@ -0,0 +1,127 @@
import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
import {
JtGridBulkEvent, JtGridBulkResult, JtGridBulkService, JtGridCellDirective, JtGridColumn, JtGridComponent, RowAction
} from '@jambotron/aria-grid';
import { fakeCall, makeServers, Server } from '../data/servers';
import { text } from '../i18n/labels';
import { CodeComponent } from './code';
/** Example 2: row selection, "select all", bulk actions through JtGridBulkService (confirm, reason, partial failure). */
@Component({
selector: 'demo-bulk-example',
imports: [JtGridComponent, JtGridCellDirective, CodeComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<jt-grid [rows]="servers()" [columns]="columns()" [trackBy]="byId" [label]="t().servers" [pageSize]="10"
[bulkActions]="bulk()" (bulkAction)="onBulk($event)" [rowSubject]="nameOf">
<ng-template jtGridCell="status" let-row>
<span class="chip chip--{{ row.status }}">{{ statusText(row.status) }}</span>
@if (row.locked) {
<span class="chip chip--locked">{{ t().locked }}</span>
}
</ng-template>
</jt-grid>
<p class="log" aria-live="polite">{{ log() }}</p>
<button type="button" class="link" (click)="reset()">Reset the list</button>
<demo-code [code]="code" />
`
})
export class BulkExampleComponent {
private readonly bulkService = inject(JtGridBulkService);
protected readonly t = text;
protected readonly statusText = (s: Server['status']) => this.t()[s];
protected readonly servers = signal(makeServers(48));
protected readonly log = signal('Select rows (or the header checkbox for all of them) and pick an action above the table.');
protected readonly columns = computed<JtGridColumn<Server>[]>(() => {
const t = this.t();
return [
{ key: 'name', label: t.name, sort: s => s.name },
{ key: 'status', label: t.status, sort: s => s.status },
{ key: 'owner', label: t.owner, sort: s => s.owner }
];
});
// Non-empty bulkActions = the checkbox column appears
protected readonly bulk = computed<RowAction[]>(() => {
const t = this.t();
return [
{ value: 'restart', label: t.restart, icon: 'restart_alt' },
{ value: 'stop', label: t.stop, icon: 'stop_circle' },
{ value: 'delete', label: t.delete, icon: 'delete', danger: true }
];
});
protected readonly byId = (s: Server) => s.id;
protected readonly nameOf = (s: Server) => s.name;
protected onBulk(event: JtGridBulkEvent<Server>): void {
const t = this.t();
switch (event.action) {
case 'restart':
// No confirm — a safe action runs at once
this.bulkService.run({ rows: event.rows, label: t.restart, run: fakeCall })
.subscribe(result => this.done('restart', result, () => 'running'));
break;
case 'stop':
// reason: true — one required reason for all rows, passed to run() as the second argument
this.bulkService.run({ rows: event.rows, label: t.stop, confirm: t.reasonStop, reason: true, run: (s, _reason) => fakeCall(s) })
.subscribe(result => this.done('stop', result, () => 'stopped'));
break;
case 'delete':
this.bulkService.run({ rows: event.rows, label: t.delete, confirm: t.confirmDelete, danger: true, run: fakeCall })
.subscribe(result => {
const gone = new Set(result.done.map(s => s.id));
this.servers.update(list => list.filter(s => !gone.has(s.id)));
this.report('delete', result);
});
break;
}
}
protected reset(): void {
this.servers.set(makeServers(48));
this.log.set('List restored.');
}
private done(action: string, result: JtGridBulkResult<Server>, status: () => Server['status']): void {
const ok = new Set(result.done.map(s => s.id));
this.servers.update(list => list.map(s => ok.has(s.id) ? { ...s, status: status(), cpu: 0 } : s));
this.report(action, result);
}
private report(action: string, result: JtGridBulkResult<Server>): void {
const failed = result.failed.map(s => s.name).join(', ');
this.log.set(`${action}: done ${result.done.length}` + (failed ? `, failed ${result.failed.length} (locked: ${failed})` : ''));
}
protected readonly code = `bulk: RowAction[] = [
{ value: 'restart', label: 'Restart', icon: 'restart_alt' },
{ value: 'stop', label: 'Stop', icon: 'stop_circle' },
{ value: 'delete', label: 'Delete', icon: 'delete', danger: true }
];
<jt-grid [rows]="servers()" [columns]="columns" [trackBy]="byId"
[bulkActions]="bulk" (bulkAction)="onBulk($event)" [rowSubject]="nameOf" />
private readonly bulkService = inject(JtGridBulkService);
onBulk(e: JtGridBulkEvent<Server>) {
if (e.action === 'delete') {
this.bulkService.run({
rows: e.rows, label: 'Delete', danger: true,
confirm: 'Delete the selected servers? This cannot be undone.',
run: s => this.api.delete(s.id) // Observable; one row failing does not stop the rest
}).subscribe(({ done, failed }) => this.reload()); // cancelled -> no emission at all
}
if (e.action === 'stop') {
this.bulkService.run({
rows: e.rows, label: 'Stop', reason: true,
confirm: 'Why are these servers being stopped?',
run: (s, reason) => this.api.stop(s.id, reason)
}).subscribe(() => this.reload());
}
}`;
}
+16
View File
@@ -0,0 +1,16 @@
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
/** The example's source under a "Show code" toggle. */
@Component({
selector: 'demo-code',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<details class="code">
<summary>Show code</summary>
<pre><code>{{ code() }}</code></pre>
</details>
`
})
export class CodeComponent {
readonly code = input.required<string>();
}
+120
View File
@@ -0,0 +1,120 @@
import { ChangeDetectionStrategy, Component, computed, signal } from '@angular/core';
import { DatePipe } from '@angular/common';
import { JtGridCellDirective, JtGridColumn, JtGridComponent, JtGridRowEvent, RowAction } from '@jambotron/aria-grid';
import { makeServers, Server } from '../data/servers';
import { text } from '../i18n/labels';
import { CodeComponent } from './code';
/** Example 3: row actions (icons in the row, the rest in "⋮", dangerous ones last), disabled actions, row highlight. */
@Component({
selector: 'demo-row-actions-example',
imports: [JtGridComponent, JtGridCellDirective, DatePipe, CodeComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<jt-grid [rows]="servers()" [columns]="columns()" [trackBy]="byId" [label]="t().servers" [pageSize]="10"
[rowActions]="actionsOf" (rowAction)="onRow($event)" [rowSubject]="nameOf" [highlight]="isOpen">
<ng-template jtGridCell="status" let-row>
<span class="chip chip--{{ row.status }}">{{ statusText(row.status) }}</span>
@if (row.locked) {
<span class="chip chip--locked">{{ t().locked }}</span>
}
</ng-template>
</jt-grid>
@if (opened(); as s) {
<aside class="details" aria-live="polite">
<strong>{{ s.name }}</strong> · {{ s.region }} · {{ s.memoryGb }} GB · {{ statusText(s.status) }} ·
{{ s.owner }} · {{ s.created | date: 'mediumDate' }}
<button type="button" class="link" (click)="openedId.set(null)">Close</button>
</aside>
}
<p class="log" aria-live="polite">{{ log() }}</p>
<demo-code [code]="code" />
`
})
export class RowActionsExampleComponent {
protected readonly t = text;
protected readonly statusText = (s: Server['status']) => this.t()[s];
protected readonly servers = signal(makeServers(30));
protected readonly openedId = signal<number | null>(null);
protected readonly opened = computed(() => this.servers().find(s => s.id === this.openedId()) ?? null);
protected readonly log = signal('Use the icons in a row or its "⋮" menu. Arrow keys move between cells, Enter opens the menu.');
protected readonly columns = computed<JtGridColumn<Server>[]>(() => {
const t = this.t();
return [
{ key: 'name', label: t.name, sort: s => s.name },
{ key: 'region', label: t.region, sort: s => s.region },
{ key: 'status', label: t.status, sort: s => s.status }
];
});
// Called for every row: the set of actions may depend on the row's state.
// Arrow functions — so "this" works when the grid calls them.
protected readonly actionsOf = (s: Server): RowAction[] => {
const t = this.t();
return [
{ value: 'open', label: t.open, icon: 'open_in_new', inline: true },
{ value: 'copy', label: t.copy, icon: 'content_copy', inline: true },
s.status === 'running'
? { value: 'stop', label: t.stop, icon: 'stop_circle' }
: { value: 'start', label: t.start, icon: 'play_circle' },
{ value: 'rename', label: t.rename, icon: 'edit', disabled: s.locked },
{ value: 'delete', label: t.delete, icon: 'delete', danger: true, disabled: s.locked }
];
};
protected readonly byId = (s: Server) => s.id;
protected readonly nameOf = (s: Server) => s.name;
protected readonly isOpen = (s: Server) => s.id === this.openedId();
protected onRow({ action, row }: JtGridRowEvent<Server>): void {
switch (action) {
case 'open':
this.openedId.set(row.id);
break;
case 'copy':
navigator.clipboard?.writeText(row.name).catch(() => undefined);
break;
case 'start':
case 'stop':
this.servers.update(list => list.map(s => s.id === row.id ? { ...s, status: action === 'start' ? 'running' : 'stopped' } : s));
break;
case 'rename': {
const name = prompt('New name', row.name)?.trim();
if (name) {
this.servers.update(list => list.map(s => s.id === row.id ? { ...s, name } : s));
}
break;
}
case 'delete':
// Row actions do not ask by themselves — confirm dangerous ones on the page
if (confirm(`Delete ${row.name}?`)) {
this.servers.update(list => list.filter(s => s.id !== row.id));
}
break;
}
this.log.set(`${action}: ${row.name}`);
}
protected readonly code = `actionsOf = (s: Server): RowAction[] => [
{ value: 'open', label: 'Open', icon: 'open_in_new', inline: true }, // icon in the row
{ value: 'copy', label: 'Copy name', icon: 'content_copy', inline: true }, // (two at most)
s.status === 'running'
? { value: 'stop', label: 'Stop', icon: 'stop_circle' } // in the "⋮" menu
: { value: 'start', label: 'Start', icon: 'play_circle' },
{ value: 'rename', label: 'Rename', icon: 'edit', disabled: s.locked },
{ value: 'delete', label: 'Delete', icon: 'delete', danger: true, disabled: s.locked } // last, red
];
isOpen = (s: Server) => s.id === this.openedId();
<jt-grid [rows]="servers()" [columns]="columns" [trackBy]="byId"
[rowActions]="actionsOf" (rowAction)="onRow($event)"
[rowSubject]="nameOf" [highlight]="isOpen" />
onRow({ action, row }: JtGridRowEvent<Server>) {
if (action === 'open') this.openedId.set(row.id);
if (action === 'delete' && confirm('Delete ' + row.name + '?')) this.api.delete(row.id);
}`;
}
+53
View File
@@ -0,0 +1,53 @@
import { signal } from '@angular/core';
import { JtGridLabels } from '@jambotron/aria-grid';
export type Lang = 'en' | 'ru';
/** Current language of the grid texts. A plain signal: provideJtGridLabels() reads it on every change detection. */
export const lang = signal<Lang>('en');
// English is the grid's default, so only Russian has to be written out
export const RU_LABELS: JtGridLabels = {
actions: 'Действия',
moreActions: 'Ещё действия',
selectAll: 'Выбрать все',
selectRow: 'Выбрать',
selected: 'Выбрано',
bulkActions: 'Действия с выбранными',
clearSelection: 'Снять выбор',
pages: 'Страницы',
of: 'из',
pageSize: 'Строк на странице',
first: 'Первая страница',
previous: 'Предыдущая страница',
next: 'Следующая страница',
last: 'Последняя страница',
done: 'выполнено',
failed: 'с ошибкой',
cancel: 'Отмена',
reason: 'Причина'
};
/** Texts of the demo's own columns and actions, so the switch changes the whole grid, not only its built-in parts. */
export const TEXT = {
en: {
name: 'Name', region: 'Region', status: 'Status', cpu: 'CPU', memory: 'Memory', created: 'Created', owner: 'Owner',
running: 'running', stopped: 'stopped', error: 'error', locked: 'locked',
open: 'Open', copy: 'Copy name', start: 'Start', stop: 'Stop', restart: 'Restart', rename: 'Rename', delete: 'Delete',
servers: 'Servers', nothing: 'No servers match the filter.',
confirmDelete: 'Delete the selected servers? This cannot be undone.',
reasonStop: 'Why are these servers being stopped? The reason goes to their owners.'
},
ru: {
name: 'Имя', region: 'Регион', status: 'Статус', cpu: 'CPU', memory: 'Память', created: 'Создан', owner: 'Владелец',
running: 'работает', stopped: 'остановлен', error: 'ошибка', locked: 'защищён',
open: 'Открыть', copy: 'Копировать имя', start: 'Запустить', stop: 'Остановить', restart: 'Перезапустить',
rename: 'Переименовать', delete: 'Удалить',
servers: 'Серверы', nothing: 'Под фильтр ничего не подходит.',
confirmDelete: 'Удалить выбранные серверы? Отменить нельзя.',
reasonStop: 'Почему серверы останавливаются? Причину получат владельцы.'
}
} as const;
export type Text = { readonly [K in keyof (typeof TEXT)['en']]: string };
export const text = (): Text => TEXT[lang()];
+42
View File
@@ -0,0 +1,42 @@
import { DOCUMENT, effect, inject, Injectable, signal } from '@angular/core';
export type Theme = 'jambotron-light' | 'jambotron-dark' | 'synthwave';
export const THEMES: { value: Theme; label: string }[] = [
{ value: 'jambotron-light', label: 'Jambotron light' },
{ value: 'jambotron-dark', label: 'Jambotron dark' },
{ value: 'synthwave', label: 'Synthwave (reactive-mail-server)' }
];
const KEY = 'aria-grid-demo.theme';
/** The theme is one attribute on <html>: data-theme="…". All colors of the page and the grid hang on it (styles.scss). */
@Injectable({ providedIn: 'root' })
export class ThemeService {
private readonly document = inject(DOCUMENT);
readonly theme = signal<Theme>(this.initial());
constructor() {
effect(() => {
const theme = this.theme();
this.document.documentElement.dataset['theme'] = theme;
try {
localStorage.setItem(KEY, theme);
} catch {
// private window — the choice just is not remembered
}
});
}
private initial(): Theme {
try {
const saved = localStorage.getItem(KEY) as Theme | null;
if (saved && THEMES.some(t => t.value === saved)) {
return saved;
}
} catch {
// no storage — fall through to the system setting
}
return this.document.defaultView?.matchMedia('(prefers-color-scheme: dark)').matches ? 'jambotron-dark' : 'jambotron-light';
}
}
+26
View File
@@ -0,0 +1,26 @@
<!doctype html>
<html lang="en" data-theme="jambotron-light">
<head>
<meta charset="utf-8">
<title>@jambotron/aria-grid — demo</title>
<base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="Live examples of @jambotron/aria-grid, an Angular data grid on Angular Aria: sorting, paging, selection, bulk and row actions, themes.">
<link rel="icon" type="image/x-icon" href="favicon.ico">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@500;700&family=Rajdhani:wght@400;500;600&family=Roboto:wght@400;500&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
<!-- theme before Angular starts: no flash of the wrong colors -->
<script>
try {
var t = localStorage.getItem('aria-grid-demo.theme');
if (!t) t = matchMedia('(prefers-color-scheme: dark)').matches ? 'jambotron-dark' : 'jambotron-light';
document.documentElement.dataset.theme = t;
} catch (e) {}
</script>
</head>
<body>
<app-root></app-root>
</body>
</html>
+6
View File
@@ -0,0 +1,6 @@
import { bootstrapApplication } from '@angular/platform-browser';
import { appConfig } from './app/app.config';
import { App } from './app/app';
bootstrapApplication(App, appConfig)
.catch((err) => console.error(err));
+417
View File
@@ -0,0 +1,417 @@
// Three themes on one attribute: <html data-theme="jambotron-light | jambotron-dark | synthwave">.
// The grid is painted only by --jt-* variables (its defaults sit on :where(:root), so any value below wins);
// Material (checkboxes, buttons, dialog, snack bar of the grid) takes its colors from the same variables.
@use '@angular/material' as mat;
@use '@jambotron/aria-grid/styles/aria-grid';
// ---------------------------------------------------------------------------------------------------------------------
// Jambotron light — the tokens of jambotron.com (_tokens.scss), the part the grid and this page use
[data-theme='jambotron-light'] {
color-scheme: light;
--app-bg: radial-gradient(1200px 600px at 10% -10%, #d7f6fb 0%, transparent 60%),
radial-gradient(900px 500px at 110% 10%, #fde9c4 0%, transparent 55%), #eef2f7;
--app-panel: #e7edf3c2;
--app-panel-border: rgba(10, 16, 48, 0.12);
--app-surface: #f3f6fa;
--app-code-bg: rgba(10, 16, 48, 0.05);
--app-font: Roboto, 'Helvetica Neue', sans-serif;
--app-font-display: Orbitron, Roboto, sans-serif;
--app-title: #0b6e80;
--jt-accent: #14c8e6;
--jt-accent-text: #074b5a;
--jt-on-accent: #050a24;
--jt-on-accent-text: #ffffff;
--jt-danger: #ef4444;
--jt-danger-text: #8a1414;
--jt-success: #22c55e;
--jt-success-text: #0c4f26;
--jt-warning: #ff9a2e;
--jt-warning-text: #6b3a00;
--jt-text: #141a33;
--jt-text-muted: #39425f;
--jt-text-disabled: rgba(20, 26, 51, 0.45);
--jt-divider: rgba(10, 16, 48, 0.12);
--jt-field-bg: rgba(255, 255, 255, 0.55);
--jt-field-border: rgba(10, 16, 48, 0.28);
--jt-focus-ring: 0 0 0 3px rgba(20, 200, 230, 0.45);
--jt-glow-accent: 0 0 10px rgba(20, 200, 230, 0.35);
--jt-popup-bg: #eef2f7e6;
--jt-popup-border: rgba(10, 16, 48, 0.12);
--jt-shadow-lg: 0 16px 48px rgba(10, 16, 48, 0.2);
}
// Jambotron dark — night sky, neon cyan
[data-theme='jambotron-dark'] {
color-scheme: dark;
--app-bg: radial-gradient(1200px 700px at 15% -10%, #1c2a6b 0%, transparent 60%),
radial-gradient(900px 600px at 110% 0%, #3b1d5e 0%, transparent 55%), #050a24;
--app-panel: rgba(10, 16, 44, 0.72);
--app-panel-border: rgba(62, 230, 255, 0.22);
--app-surface: #0c1234;
--app-code-bg: rgba(5, 10, 36, 0.6);
--app-font: Roboto, 'Helvetica Neue', sans-serif;
--app-font-display: Orbitron, Roboto, sans-serif;
--app-title: #7ff0ff;
--jt-accent: #3ee6ff;
--jt-accent-text: #7ff0ff;
--jt-on-accent: #050a24;
--jt-on-accent-text: #050a24;
--jt-danger: #f87171;
--jt-danger-text: #fecaca;
--jt-success: #34d399;
--jt-success-text: #86efac;
--jt-warning: #ff9a2e;
--jt-warning-text: #ffd98a;
--jt-text: #eef2ff;
--jt-text-muted: #ccd4ec;
--jt-text-disabled: rgba(238, 242, 255, 0.45);
--jt-divider: rgba(195, 204, 230, 0.16);
--jt-field-bg: rgba(5, 10, 36, 0.55);
--jt-field-border: rgba(195, 204, 230, 0.3);
--jt-focus-ring: 0 0 0 3px rgba(62, 230, 255, 0.5);
--jt-glow-accent: 0 0 14px rgba(62, 230, 255, 0.55);
--jt-popup-bg: rgba(8, 12, 36, 0.95);
--jt-popup-border: rgba(62, 230, 255, 0.22);
--jt-shadow-lg: 0 16px 48px rgba(0, 0, 0, 0.55);
}
// Synthwave — the colors of reactive-mail-server's web mail (frontend/src/material.scss there)
[data-theme='synthwave'] {
color-scheme: dark;
--app-bg: linear-gradient(180deg, transparent 55%, rgba(255, 0, 127, 0.18) 100%),
repeating-linear-gradient(90deg, rgba(255, 0, 127, 0.12) 0 1px, transparent 1px 64px),
repeating-linear-gradient(0deg, rgba(0, 240, 255, 0.08) 0 1px, transparent 1px 64px), #0d0221;
--app-panel: rgba(15, 12, 35, 0.78);
--app-panel-border: rgba(255, 0, 127, 0.4);
--app-surface: #140a2e;
--app-code-bg: rgba(2, 6, 23, 0.7);
--app-font: Rajdhani, sans-serif;
--app-font-display: Orbitron, sans-serif;
--app-title: #ff007f;
--jt-accent: #00f0ff;
--jt-accent-text: #00f0ff;
--jt-on-accent: #0d0221;
--jt-on-accent-text: #0d0221;
--jt-danger: #ff4d6d;
--jt-danger-text: #ff8fa3;
--jt-success: #00ff88;
--jt-success-text: #7dffbf;
--jt-warning: #ffbe0b;
--jt-warning-text: #ffd76a;
--jt-text: #e2e8f0;
--jt-text-muted: #94a3b8;
--jt-text-disabled: rgba(226, 232, 240, 0.4);
--jt-divider: rgba(0, 240, 255, 0.14);
--jt-field-bg: rgba(2, 6, 23, 0.6);
--jt-field-border: rgba(0, 240, 255, 0.35);
--jt-focus-ring: 0 0 0 3px #ffbe0b;
--jt-glow-accent: 0 0 12px rgba(0, 240, 255, 0.6);
--jt-popup-bg: rgba(10, 5, 24, 0.97);
--jt-popup-border: rgba(255, 0, 127, 0.45);
--jt-shadow-lg: 0 16px 48px rgba(0, 0, 0, 0.6), 0 0 20px rgba(255, 0, 127, 0.25);
--jt-radius-sm: 8px;
--jt-radius-md: 12px;
.jt-table {
font-size: 0.95rem;
th {
font-family: Orbitron, sans-serif;
font-size: 0.68rem;
letter-spacing: 0.08em;
color: #00f0ff;
}
}
}
// ---------------------------------------------------------------------------------------------------------------------
// Material: light or dark by color-scheme (set by the theme above), roles mapped to the --jt-* variables
html {
@include mat.theme(
(
color: (primary: mat.$cyan-palette, tertiary: mat.$violet-palette, theme-type: color-scheme),
typography: (plain-family: var(--app-font), brand-family: var(--app-font-display)),
density: 0,
),
(
primary: var(--jt-accent-text),
on-primary: var(--jt-on-accent-text),
error: var(--jt-danger-text),
on-error: var(--jt-on-accent-text),
background: var(--app-surface),
on-background: var(--jt-text),
surface: var(--app-surface),
on-surface: var(--jt-text),
on-surface-variant: var(--jt-text-muted),
surface-container: var(--app-surface),
surface-container-high: var(--app-surface),
surface-container-highest: var(--app-surface),
outline: var(--jt-field-border),
outline-variant: var(--jt-divider),
inverse-surface: var(--jt-text),
inverse-on-surface: var(--app-surface),
)
);
@include mat.checkbox-overrides((
selected-icon-color: var(--jt-accent),
selected-hover-icon-color: var(--jt-accent),
selected-focus-icon-color: var(--jt-accent),
selected-checkmark-color: var(--jt-on-accent),
));
}
// ---------------------------------------------------------------------------------------------------------------------
// Page
*,
*::before,
*::after {
box-sizing: border-box;
}
html,
body {
margin: 0;
min-height: 100%;
}
body {
background: var(--app-bg);
background-attachment: fixed;
color: var(--jt-text);
font-family: var(--app-font);
line-height: 1.5;
transition: background var(--jt-duration-base) var(--jt-easing), color var(--jt-duration-base) var(--jt-easing);
}
a {
color: var(--jt-accent-text);
}
code {
font-family: 'JetBrains Mono', Consolas, monospace;
font-size: 0.85em;
}
.top {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 16px;
max-width: 1180px;
margin: 0 auto;
padding: 28px 16px 8px;
}
.brand {
display: flex;
align-items: center;
gap: 14px;
h1 {
margin: 0;
font-family: var(--app-font-display);
font-size: clamp(1.2rem, 3vw, 1.7rem);
color: var(--app-title);
text-shadow: var(--jt-glow-accent);
}
p {
margin: 2px 0 0;
color: var(--jt-text-muted);
}
}
.brand-mark {
display: grid;
place-items: center;
width: 44px;
height: 44px;
border-radius: var(--jt-radius-md);
background: var(--jt-accent);
color: var(--jt-on-accent);
font-size: 1.6rem;
box-shadow: var(--jt-glow-accent);
}
.switches {
display: flex;
flex-wrap: wrap;
gap: 12px;
jt-select {
min-width: 220px;
}
}
main {
display: grid;
gap: 24px;
max-width: 1180px;
margin: 0 auto;
padding: 16px;
}
.panel {
min-width: 0;
padding: 20px;
border: 1px solid var(--app-panel-border);
border-radius: var(--jt-radius-md);
background: var(--app-panel);
backdrop-filter: blur(8px);
h2 {
display: flex;
align-items: center;
gap: 10px;
margin: 0 0 6px;
font-family: var(--app-font-display);
font-size: 1.05rem;
letter-spacing: 0.02em;
span {
display: grid;
place-items: center;
width: 26px;
height: 26px;
border-radius: 50%;
background: var(--jt-accent);
color: var(--jt-on-accent);
font-size: 0.8rem;
}
}
}
.lead {
margin: 0 0 16px;
color: var(--jt-text-muted);
max-width: 85ch;
}
.filter {
display: inline-flex;
flex-direction: column;
gap: 4px;
margin-bottom: 12px;
font-size: 0.8rem;
color: var(--jt-text-muted);
input {
width: min(320px, 80vw);
padding: 8px 12px;
border: 1px solid var(--jt-field-border);
border-radius: var(--jt-radius-sm);
background: var(--jt-field-bg);
color: var(--jt-text);
font: inherit;
font-size: 0.95rem;
&:focus-visible {
outline: none;
box-shadow: var(--jt-focus-ring);
}
}
}
.chip {
display: inline-block;
margin-right: 4px;
padding: 1px 8px;
border-radius: var(--jt-radius-pill);
font-size: 0.75rem;
font-weight: 500;
border: 1px solid currentColor;
}
.chip--running {
color: var(--jt-success-text);
}
.chip--stopped {
color: var(--jt-text-muted);
}
.chip--error {
color: var(--jt-danger-text);
}
.chip--locked {
color: var(--jt-warning-text);
}
// CPU load bar
.meter {
display: inline-flex;
align-items: center;
justify-content: flex-end;
width: 90px;
height: 18px;
padding: 0 6px;
border-radius: var(--jt-radius-sm);
background: linear-gradient(90deg, color-mix(in srgb, var(--jt-accent) 35%, transparent) calc(var(--value) * 1%), var(--jt-divider) 0);
font-variant-numeric: tabular-nums;
font-size: 0.8rem;
}
.log {
min-height: 1.5em;
margin: 12px 0 4px;
color: var(--jt-text-muted);
font-size: 0.9rem;
}
.details {
margin-top: 12px;
padding: 10px 14px;
border-left: 3px solid var(--jt-accent);
border-radius: var(--jt-radius-sm);
background: color-mix(in srgb, var(--jt-accent) 10%, transparent);
}
.link {
margin-left: 8px;
padding: 0;
border: 0;
background: none;
color: var(--jt-accent-text);
font: inherit;
text-decoration: underline;
cursor: pointer;
}
.code {
margin-top: 12px;
summary {
cursor: pointer;
color: var(--jt-accent-text);
font-weight: 500;
}
pre {
overflow-x: auto;
margin: 8px 0 0;
padding: 14px;
border: 1px solid var(--jt-divider);
border-radius: var(--jt-radius-sm);
background: var(--app-code-bg);
font-size: 0.82rem;
line-height: 1.45;
}
}
// The grid's own empty state uses this class
.jt-muted {
color: var(--jt-text-muted);
}
.foot {
max-width: 1180px;
margin: 0 auto;
padding: 8px 16px 32px;
color: var(--jt-text-muted);
font-size: 0.85rem;
}
+14
View File
@@ -0,0 +1,14 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"extends": "./tsconfig.json",
"compilerOptions": {
"types": []
},
"include": [
"src/**/*.ts"
],
"exclude": [
"src/**/*.spec.ts"
]
}
+28
View File
@@ -0,0 +1,28 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"compileOnSave": false,
"compilerOptions": {
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"skipLibCheck": true,
"isolatedModules": true,
"experimentalDecorators": true,
"importHelpers": true,
"target": "ES2022",
"module": "preserve"
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true
},
"files": [],
"references": [
{
"path": "./tsconfig.app.json"
}
]
}
+14
View File
@@ -0,0 +1,14 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"extends": "./tsconfig.json",
"compilerOptions": {
"types": [
"vitest/globals"
]
},
"include": [
"src/**/*.d.ts",
"src/**/*.spec.ts"
]
}