commit working state

Too many changes to split them up. This commit contains roughly two months worth of work.

This commit introduces the first aggregate and rewrites the ticket system to follow DDD patterns.
It adds Spartan NG as the new component library. TanStack Query to replace GraphQL. Oh and Prisma is
almost over as well.
This commit is contained in:
Marcel Arndt 2026-01-19 12:21:48 +01:00
parent 62e663d053
commit ce676f20a4
334 changed files with 64333 additions and 52554 deletions

5
.gitignore vendored
View File

@ -2,4 +2,9 @@
.vscode/
.env
.env.prod
.env.stage
.DS_Store
node_modules/
dist/

View File

@ -0,0 +1,34 @@
---
- name: Nodes initialisieren und härten
hosts: all
become: true
roles:
- role: common
tags: common
- role: ssh_hardening
tags: ssh
- role: ufw_firewall
tags: firewall
- role: fail2ban
tags: fail2ban
handlers:
- name: restart sshd
ansible.builtin.service:
name: ssh
state: restarted
- name: restart fail2ban
ansible.builtin.service:
name: fail2ban
state: restarted
- name: Setup Ceph Cluster and CephFS
hosts: all
become: true
roles:
- role: ceph_setup
- name: Docker Swarm initialisieren
hosts: all
become: true
roles:
- role: docker_swarm

View File

@ -0,0 +1,13 @@
- name: Infrastruktur Dienste bereitstellen
hosts: all
gather_facts: true
roles:
# - traefik
# - portainer
# - hyperdx
- kestra
# - authentik
# # - dockge
# - signoz
# - signoz-infra
# - leantime

View File

@ -0,0 +1,6 @@
[client.admin]
key = AQDfUDBpnhHKNBAAwIRER17JF8flD9GSeNtFlg==
caps mds = "allow *"
caps mgr = "allow *"
caps mon = "allow *"
caps osd = "allow *"

View File

@ -0,0 +1,4 @@
# minimal ceph.conf for f103f9ee-d058-11f0-aee8-920006d45637
[global]
fsid = f103f9ee-d058-11f0-aee8-920006d45637
mon_host = [v2:10.0.1.2:3300/0,v1:10.0.1.2:6789/0]

View File

@ -45,4 +45,4 @@
# - signoz-infra
# - leantime
- kestra
- hyperdx
# - hyperdx

View File

@ -28,6 +28,18 @@
run_once: true
delegate_to: "{{ groups['managers'][0] }}"
- name: HYPERDX | shared-observability Netzwerk erstellen
community.docker.docker_network:
name: shared-observability
driver: overlay
state: present
attachable: yes
ipam_config:
- subnet: '172.16.201.0/24'
gateway: '172.16.201.1'
run_once: true
delegate_to: "{{ groups['managers'][0] }}"
- name: HYPERDX | Generate Compose file
ansible.builtin.template:
src: docker-compose.yml.j2

5
service/.dockerignore Normal file
View File

@ -0,0 +1,5 @@
node_modules
dist
.git
.local
.vscode

View File

@ -0,0 +1,5 @@
.angular
.vscode
dist
node_modules

View File

@ -0,0 +1,10 @@
{
"overrides": [
{
"files": "*.html",
"options": {
"parser": "angular"
}
}
]
}

View File

@ -1,18 +1,37 @@
FROM node:20.19.1-alpine AS builder
FROM node:20.19.1-slim AS builder
WORKDIR /app/builder
COPY . .
ENV LIBC=glibc
ENV npm_config_arch=x64
ENV npm_config_platform=linux
RUN apt-get update && apt-get install -y --no-install-recommends \
python3 \
make \
g++ \
ca-certificates \
&& rm -rf /var/lib/apt/lists/*
RUN npm install \
&& npm run build
COPY package*.json .
COPY packages/shared-dtos/package*.json ./packages/shared-dtos/
# COPY data-hub/data-connector/package*.json ./data-hub/data-connector/
COPY app-hub/dashboard/package*.json ./app-hub/dashboard/
RUN npm ci --verbose
COPY packages/shared-dtos ./packages/shared-dtos/
COPY app-hub/dashboard ./app-hub/dashboard/
WORKDIR /app/builder/app-hub/dashboard
RUN npm run build
FROM nginx:1.19.2
WORKDIR /usr/share/nginx/html
COPY --from=builder /app/builder/dist/dashboard/browser ./
COPY --from=builder /app/builder/nginx.conf /etc/nginx/nginx.conf
COPY --from=builder /app/builder/docker-entrypoint.sh /docker-entrypoint.sh
COPY --from=builder /app/builder/app-hub/dashboard/dist/dashboard/browser ./
COPY --from=builder /app/builder/app-hub/dashboard/nginx.conf /etc/nginx/nginx.conf
COPY --from=builder /app/builder/app-hub/dashboard/docker-entrypoint.sh /docker-entrypoint.sh
ENTRYPOINT ["/docker-entrypoint.sh"]

View File

@ -18,7 +18,7 @@
},
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:application",
"builder": "@angular/build:application",
"options": {
"outputPath": "dist/dashboard",
"index": "src/index.html",
@ -37,7 +37,7 @@
],
"styles": [
"src/m3-theme.scss",
"node_modules/@ngxpert/hot-toast/src/styles/styles.css",
"../../node_modules/@ngxpert/hot-toast/src/styles/styles.css",
"src/tailwind.css",
"src/styles.scss"
],
@ -49,7 +49,7 @@
{
"type": "initial",
"maximumWarning": "500kB",
"maximumError": "2MB"
"maximumError": "2.5MB"
},
{
"type": "anyComponentStyle",
@ -80,7 +80,7 @@
"defaultConfiguration": "production"
},
"serve": {
"builder": "@angular-devkit/build-angular:dev-server",
"builder": "@angular/build:dev-server",
"configurations": {
"production": {
"buildTarget": "dashboard:build:production"
@ -92,10 +92,10 @@
"defaultConfiguration": "development"
},
"extract-i18n": {
"builder": "@angular-devkit/build-angular:extract-i18n"
"builder": "@angular/build:extract-i18n"
},
"test": {
"builder": "@angular-devkit/build-angular:karma",
"builder": "@angular/build:karma",
"options": {
"polyfills": [
"zone.js",
@ -119,5 +119,31 @@
}
}
}
},
"schematics": {
"@schematics/angular:component": {
"type": "component"
},
"@schematics/angular:directive": {
"type": "directive"
},
"@schematics/angular:service": {
"type": "service"
},
"@schematics/angular:guard": {
"typeSeparator": "."
},
"@schematics/angular:interceptor": {
"typeSeparator": "."
},
"@schematics/angular:module": {
"typeSeparator": "."
},
"@schematics/angular:pipe": {
"typeSeparator": "."
},
"@schematics/angular:resolver": {
"typeSeparator": "."
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -11,26 +11,26 @@
},
"private": true,
"dependencies": {
"@angular/animations": "^19.2.6",
"@angular/cdk": "^19.2.9",
"@angular/common": "^19.2.6",
"@angular/compiler": "^19.2.6",
"@angular/core": "^19.2.6",
"@angular/forms": "^19.2.6",
"@angular/material": "^19.2.9",
"@angular/material-date-fns-adapter": "^19.2.9",
"@angular/platform-browser": "^19.2.6",
"@angular/platform-browser-dynamic": "^19.2.6",
"@angular/router": "^19.2.6",
"@apollo/client": "^3.13.8",
"@angular/animations": "^21.1.0",
"@angular/cdk": "^21.1.0",
"@angular/common": "^21.1.0",
"@angular/compiler": "^21.1.0",
"@angular/core": "^21.1.0",
"@angular/forms": "^21.1.0",
"@angular/material": "^21.1.0",
"@angular/material-date-fns-adapter": "^21.1.0",
"@angular/platform-browser": "^21.1.0",
"@angular/platform-browser-dynamic": "^21.1.0",
"@angular/router": "^21.1.0",
"@apollo/client": "^4.1.0",
"@azure/msal-angular": "^3.0.22",
"@azure/msal-browser": "^3.19.1",
"@hyperdx/browser": "^0.22.0",
"@ng-icons/core": "^31.4.0",
"@ng-icons/lucide": "^31.4.0",
"@ng-icons/core": "^33.0.0",
"@ng-icons/lucide": "^33.0.0",
"@ngneat/dialog": "^5.1.1",
"@ngneat/overview": "^6.0.0",
"@ngxpert/hot-toast": "^3.0.0",
"@ngneat/overview": "^7.0.0",
"@ngxpert/hot-toast": "^6.0.0",
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/auto-instrumentations-web": "^0.45.1",
"@opentelemetry/context-zone": "^1.30.1",
@ -42,30 +42,32 @@
"@opentelemetry/resources": "^1.30.1",
"@opentelemetry/sdk-trace-web": "^1.30.1",
"@opentelemetry/semantic-conventions": "^1.34.0",
"@spartan-ng/brain": "^0.0.1-alpha.561",
"apollo-angular": "^10.0.3",
"@spartan-ng/brain": "0.0.1-alpha.605",
"@tanstack/angular-query-experimental": "^5.90.20",
"apollo-angular": "^13.0.0",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"date-fns": "^3.6.0",
"echarts": "^5.5.1",
"graphql": "^16.11.0",
"graphql": "^16.12.0",
"graphql-ws": "^5.16.0",
"ngx-scrollbar": "^19.1.1",
"rxjs": "~7.8.0",
"tailwind-merge": "^3.3.1",
"tslib": "^2.3.0",
"zone.js": "~0.15.0"
},
"devDependencies": {
"@angular-devkit/build-angular": "^19.2.7",
"@angular/cli": "^19.2.7",
"@angular/compiler-cli": "^19.2.6",
"@angular/localize": "^19.2.6",
"@angular/build": "^21.1.0",
"@angular/cli": "^21.1.0",
"@angular/compiler-cli": "^21.1.0",
"@angular/localize": "^21.1.0",
"@graphql-codegen/cli": "^5.0.5",
"@graphql-codegen/typescript": "^4.1.6",
"@graphql-codegen/typescript-apollo-angular": "^4.0.0",
"@graphql-codegen/typescript-operations": "^4.6.0",
"@material/material-color-utilities": "^0.3.0",
"@spartan-ng/cli": "^0.0.1-alpha.561",
"@spartan-ng/cli": "0.0.1-alpha.605",
"@tailwindcss/postcss": "^4.1.4",
"@types/jasmine": "~5.1.0",
"autoprefixer": "^10.4.19",
@ -75,9 +77,10 @@
"karma-coverage": "~2.2.0",
"karma-jasmine": "~5.1.0",
"karma-jasmine-html-reporter": "~2.1.0",
"postcss": "^8.4.39",
"postcss": "^8.5.3",
"prettier": "^3.8.0",
"tailwindcss": "^4.1.4",
"tw-animate-css": "^1.4.0",
"typescript": "~5.5.2"
"typescript": "~5.9.3"
}
}
}

View File

@ -9,18 +9,39 @@ import { provideRouter, withComponentInputBinding } from '@angular/router';
import { provideHttpClient } from '@angular/common/http';
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
import { provideHotToastConfig } from '@ngxpert/hot-toast';
import {
provideTanStackQuery,
QueryClient,
} from '@tanstack/angular-query-experimental';
import { withDevtools } from '@tanstack/angular-query-experimental/devtools';
import { environment } from '../environments/environment';
import { routes } from './app.routes';
import { MsAuthenticationModule } from './core/components/ms-authentication/ms-authentication.module';
import { GlobalErrorHandler } from './core/telemetry/global-error-handler';
import { graphqlProvider } from './graphql.provider';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
// Daten gelten für 2 Minute als "frisch" (kein Refetch)
staleTime: 2000 * 60 * 1,
retry: 2,
refetchOnWindowFocus: true,
},
},
});
declare global {
interface Window {
__TANSTACK_QUERY_CLIENT__: import('@tanstack/query-core').QueryClient;
}
}
// This code is for all users
window.__TANSTACK_QUERY_CLIENT__ = queryClient;
export const appConfig: ApplicationConfig = {
providers: [
{
provide: ErrorHandler,
useClass: GlobalErrorHandler,
},
provideZoneChangeDetection({ eventCoalescing: true }),
provideRouter(routes, withComponentInputBinding()),
provideAnimationsAsync(),
@ -32,6 +53,7 @@ export const appConfig: ApplicationConfig = {
postLogoutRedirectUri: environment.msAuth.postLogoutRedirectUri,
})
),
provideTanStackQuery(queryClient, withDevtools()),
provideHttpClient(),
graphqlProvider,
provideHotToastConfig(),

View File

@ -53,7 +53,7 @@ export const routes: Routes = [
{
path: 'tickets',
loadChildren: () =>
import('./pages/ticket-system/ticket-system.module').then(
import('./pages/tickets/ticket-system.module').then(
(m) => m.TicketSystemModule
),
canActivate: [MsalGuard],
@ -61,6 +61,18 @@ export const routes: Routes = [
title: 'Ticketsystem',
},
},
{
path: 'playground',
loadComponent: () =>
import('./pages/playground/playground.component').then(
(m) => m.PlaygroundComponent
),
canActivate: [MsalGuard, roleGuard],
data: {
title: 'Playground',
roles: ['manager', 'admin'],
},
},
{
path: '',
pathMatch: 'full',

View File

@ -0,0 +1,31 @@
import { HlmAlertDialog } from './lib/hlm-alert-dialog';
import { HlmAlertDialogActionButton } from './lib/hlm-alert-dialog-action-button';
import { HlmAlertDialogCancelButton } from './lib/hlm-alert-dialog-cancel-button';
import { HlmAlertDialogContent } from './lib/hlm-alert-dialog-content';
import { HlmAlertDialogDescription } from './lib/hlm-alert-dialog-description';
import { HlmAlertDialogFooter } from './lib/hlm-alert-dialog-footer';
import { HlmAlertDialogHeader } from './lib/hlm-alert-dialog-header';
import { HlmAlertDialogOverlay } from './lib/hlm-alert-dialog-overlay';
import { HlmAlertDialogTitle } from './lib/hlm-alert-dialog-title';
export * from './lib/hlm-alert-dialog';
export * from './lib/hlm-alert-dialog-action-button';
export * from './lib/hlm-alert-dialog-cancel-button';
export * from './lib/hlm-alert-dialog-content';
export * from './lib/hlm-alert-dialog-description';
export * from './lib/hlm-alert-dialog-footer';
export * from './lib/hlm-alert-dialog-header';
export * from './lib/hlm-alert-dialog-overlay';
export * from './lib/hlm-alert-dialog-title';
export const HlmAlertDialogImports = [
HlmAlertDialogContent,
HlmAlertDialogDescription,
HlmAlertDialogFooter,
HlmAlertDialogHeader,
HlmAlertDialogOverlay,
HlmAlertDialogTitle,
HlmAlertDialogActionButton,
HlmAlertDialogCancelButton,
HlmAlertDialog,
] as const;

View File

@ -0,0 +1,8 @@
import { Directive } from '@angular/core';
import { HlmButton } from '@spartan-ng/helm/button';
@Directive({
selector: 'button[hlmAlertDialogAction]',
hostDirectives: [{ directive: HlmButton, inputs: ['variant', 'size'] }],
})
export class HlmAlertDialogActionButton {}

View File

@ -0,0 +1,9 @@
import { Directive } from '@angular/core';
import { HlmButton, provideBrnButtonConfig } from '@spartan-ng/helm/button';
@Directive({
selector: 'button[hlmAlertDialogCancel]',
providers: [provideBrnButtonConfig({ variant: 'outline' })],
hostDirectives: [{ directive: HlmButton, inputs: ['variant', 'size'] }],
})
export class HlmAlertDialogCancelButton {}

View File

@ -0,0 +1,28 @@
import { ChangeDetectionStrategy, Component, computed, input, signal } from '@angular/core';
import { injectExposesStateProvider } from '@spartan-ng/brain/core';
import { hlm } from '@spartan-ng/helm/utils';
import type { ClassValue } from 'clsx';
@Component({
selector: 'hlm-alert-dialog-content',
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
'[class]': '_computedClass()',
'[attr.data-state]': 'state()',
},
template: `
<ng-content />
`,
})
export class HlmAlertDialogContent {
private readonly _stateProvider = injectExposesStateProvider({ optional: true, host: true });
public readonly state = this._stateProvider?.state ?? signal('closed');
public readonly userClass = input<ClassValue>('', { alias: 'class' });
protected readonly _computedClass = computed(() =>
hlm(
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 relative z-50 mx-auto grid w-full max-w-[calc(100%-2rem)] gap-4 rounded-lg border p-6 shadow-lg data-[state=closed]:duration-200 data-[state=open]:duration-200 sm:mx-0 sm:max-w-lg',
this.userClass(),
),
);
}

View File

@ -0,0 +1,16 @@
import { Directive, computed, input } from '@angular/core';
import { BrnAlertDialogDescription } from '@spartan-ng/brain/alert-dialog';
import { hlm } from '@spartan-ng/helm/utils';
import type { ClassValue } from 'clsx';
@Directive({
selector: '[hlmAlertDialogDescription]',
hostDirectives: [BrnAlertDialogDescription],
host: {
'[class]': '_computedClass()',
},
})
export class HlmAlertDialogDescription {
public readonly userClass = input<ClassValue>('', { alias: 'class' });
protected readonly _computedClass = computed(() => hlm('text-muted-foreground text-sm', this.userClass()));
}

View File

@ -0,0 +1,20 @@
import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core';
import { hlm } from '@spartan-ng/helm/utils';
import type { ClassValue } from 'clsx';
@Component({
selector: 'hlm-alert-dialog-footer',
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
'[class]': '_computedClass()',
},
template: `
<ng-content />
`,
})
export class HlmAlertDialogFooter {
public readonly userClass = input<ClassValue>('', { alias: 'class' });
protected readonly _computedClass = computed(() =>
hlm('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', this.userClass()),
);
}

View File

@ -0,0 +1,20 @@
import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core';
import { hlm } from '@spartan-ng/helm/utils';
import type { ClassValue } from 'clsx';
@Component({
selector: 'hlm-alert-dialog-header',
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
'[class]': '_computedClass()',
},
template: `
<ng-content />
`,
})
export class HlmAlertDialogHeader {
public readonly userClass = input<ClassValue>('', { alias: 'class' });
protected readonly _computedClass = computed(() =>
hlm('flex flex-col gap-2 text-center sm:text-left', this.userClass()),
);
}

View File

@ -0,0 +1,26 @@
import { Directive, computed, effect, input, untracked } from '@angular/core';
import { injectCustomClassSettable } from '@spartan-ng/brain/core';
import { hlm } from '@spartan-ng/helm/utils';
import type { ClassValue } from 'clsx';
@Directive({
selector: '[hlmAlertDialogOverlay],brn-alert-dialog-overlay[hlm]',
})
export class HlmAlertDialogOverlay {
private readonly _classSettable = injectCustomClassSettable({ optional: true, host: true });
public readonly userClass = input<ClassValue>('', { alias: 'class' });
protected readonly _computedClass = computed(() =>
hlm(
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 bg-black/50',
this.userClass(),
),
);
constructor() {
effect(() => {
const classValue = this._computedClass();
untracked(() => this._classSettable?.setClassToCustomElement(classValue));
});
}
}

View File

@ -0,0 +1,16 @@
import { Directive, computed, input } from '@angular/core';
import { BrnAlertDialogTitle } from '@spartan-ng/brain/alert-dialog';
import { hlm } from '@spartan-ng/helm/utils';
import type { ClassValue } from 'clsx';
@Directive({
selector: '[hlmAlertDialogTitle]',
hostDirectives: [BrnAlertDialogTitle],
host: {
'[class]': '_computedClass()',
},
})
export class HlmAlertDialogTitle {
public readonly userClass = input<ClassValue>('', { alias: 'class' });
protected readonly _computedClass = computed(() => hlm('text-lg font-semibold', this.userClass()));
}

View File

@ -0,0 +1,29 @@
import { ChangeDetectionStrategy, Component, forwardRef } from '@angular/core';
import {
BRN_ALERT_DIALOG_DEFAULT_OPTIONS,
BrnAlertDialog,
BrnAlertDialogOverlay,
} from '@spartan-ng/brain/alert-dialog';
import { BrnDialog, provideBrnDialogDefaultOptions } from '@spartan-ng/brain/dialog';
import { HlmAlertDialogOverlay } from './hlm-alert-dialog-overlay';
@Component({
selector: 'hlm-alert-dialog',
exportAs: 'hlmAlertDialog',
imports: [BrnAlertDialogOverlay, HlmAlertDialogOverlay],
providers: [
{
provide: BrnDialog,
useExisting: forwardRef(() => HlmAlertDialog),
},
provideBrnDialogDefaultOptions({
...BRN_ALERT_DIALOG_DEFAULT_OPTIONS,
}),
],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<brn-alert-dialog-overlay hlm />
<ng-content />
`,
})
export class HlmAlertDialog extends BrnAlertDialog {}

View File

@ -0,0 +1,23 @@
import { HlmAutocomplete } from './lib/hlm-autocomplete';
import { HlmAutocompleteEmpty } from './lib/hlm-autocomplete-empty';
import { HlmAutocompleteGroup } from './lib/hlm-autocomplete-group';
import { HlmAutocompleteItem } from './lib/hlm-autocomplete-item';
import { HlmAutocompleteList } from './lib/hlm-autocomplete-list';
import { HlmAutocompleteTrigger } from './lib/hlm-autocomplete-trigger';
export * from './lib/hlm-autocomplete';
export * from './lib/hlm-autocomplete-empty';
export * from './lib/hlm-autocomplete-group';
export * from './lib/hlm-autocomplete-item';
export * from './lib/hlm-autocomplete-list';
export * from './lib/hlm-autocomplete-trigger';
export * from './lib/hlm-autocomplete.token';
export const HlmAutocompleteImports = [
HlmAutocomplete,
HlmAutocompleteEmpty,
HlmAutocompleteGroup,
HlmAutocompleteItem,
HlmAutocompleteList,
HlmAutocompleteTrigger,
] as const;

View File

@ -0,0 +1,14 @@
import { Directive, computed, input } from '@angular/core';
import { hlm } from '@spartan-ng/helm/utils';
import type { ClassValue } from 'clsx';
@Directive({
selector: '[hlmAutocompleteEmpty]',
host: {
'[class]': '_computedClass()',
},
})
export class HlmAutocompleteEmpty {
public readonly userClass = input<ClassValue>('', { alias: 'class' });
protected readonly _computedClass = computed(() => hlm('py-6 text-center text-sm', this.userClass()));
}

View File

@ -0,0 +1,27 @@
import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core';
import { BrnAutocompleteGroup } from '@spartan-ng/brain/autocomplete';
import { hlm } from '@spartan-ng/helm/utils';
@Component({
selector: 'hlm-autocomplete-group',
changeDetection: ChangeDetectionStrategy.OnPush,
hostDirectives: [
{
directive: BrnAutocompleteGroup,
inputs: ['id'],
},
],
host: {
'[class]': '_computedClass()',
},
template: '<ng-content />',
})
export class HlmAutocompleteGroup {
/** The user defined class */
public readonly userClass = input<string>('', { alias: 'class' });
/** The styles to apply */
protected readonly _computedClass = computed(() =>
hlm('text-foreground block overflow-hidden p-1', this.userClass()),
);
}

View File

@ -0,0 +1,33 @@
import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core';
import { BrnAutocompleteItem } from '@spartan-ng/brain/autocomplete';
import { hlm } from '@spartan-ng/helm/utils';
@Component({
selector: 'button[hlm-autocomplete-item]',
changeDetection: ChangeDetectionStrategy.OnPush,
hostDirectives: [
{
directive: BrnAutocompleteItem,
inputs: ['value', 'disabled', 'id'],
outputs: ['selected'],
},
],
host: {
'[class]': '_computedClass()',
},
template: `
<ng-content />
`,
})
export class HlmAutocompleteItem {
/** The user defined class */
public readonly userClass = input<string>('', { alias: 'class' });
/** The styles to apply */
protected readonly _computedClass = computed(() =>
hlm(
'data-[selected]:bg-accent data-[selected=true]:text-accent-foreground [&>ng-icon]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-start text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[hidden]:hidden [&>ng-icon]:pointer-events-none [&>ng-icon]:shrink-0 [&>ng-icon]:text-base',
this.userClass(),
),
);
}

View File

@ -0,0 +1,27 @@
import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core';
import { BrnAutocompleteList } from '@spartan-ng/brain/autocomplete';
import { hlm } from '@spartan-ng/helm/utils';
@Component({
selector: 'hlm-autocomplete-list',
changeDetection: ChangeDetectionStrategy.OnPush,
hostDirectives: [
{
directive: BrnAutocompleteList,
inputs: ['id'],
},
],
host: {
'[class]': '_computedClass()',
},
template: '<ng-content />',
})
export class HlmAutocompleteList {
/** The user defined class */
public readonly userClass = input<string>('', { alias: 'class' });
/** The styles to apply */
protected readonly _computedClass = computed(() =>
hlm('block max-h-60 overflow-x-hidden overflow-y-auto', this.userClass()),
);
}

View File

@ -0,0 +1,32 @@
import type { BooleanInput } from '@angular/cdk/coercion';
import { booleanAttribute, Directive, ElementRef, inject, input } from '@angular/core';
import { BrnDialog } from '@spartan-ng/brain/dialog';
@Directive({
selector: '[hlmAutocompleteTrigger]',
host: {
'(click)': 'open()',
},
})
export class HlmAutocompleteTrigger {
private readonly _host = inject(ElementRef, { host: true });
private readonly _brnDialog = inject(BrnDialog, { optional: true });
/** Whether the trigger is disabled. */
public readonly disabledTrigger = input<boolean, BooleanInput>(false, {
transform: booleanAttribute,
});
constructor() {
if (!this._brnDialog) return;
this._brnDialog.mutableAttachTo.set(this._host.nativeElement);
}
open() {
if (this.disabledTrigger()) return;
this._brnDialog?.open();
}
}

View File

@ -0,0 +1,35 @@
import { inject, InjectionToken, type ValueProvider } from '@angular/core';
export type TransformValueToString<T> = (option: T) => string;
export interface HlmAutocompleteConfig<T, V = T> {
transformValueToSearch: TransformValueToString<T>;
transformOptionToString: TransformValueToString<T>;
transformOptionToValue: ((option: T) => V) | undefined;
requireSelection: boolean;
showClearBtn: boolean;
debounceTime: number;
}
function getDefaultConfig<T, V = T>(): HlmAutocompleteConfig<T, V> {
return {
transformValueToSearch: (option: T) => (typeof option === 'string' ? option : String(option)),
transformOptionToString: (option: T) => (typeof option === 'string' ? option : String(option)),
transformOptionToValue: undefined,
requireSelection: false,
showClearBtn: false,
debounceTime: 150,
};
}
const HlmAutocompleteConfigToken = new InjectionToken<HlmAutocompleteConfig<unknown, unknown>>('HlmAutocompleteConfig');
export function provideHlmAutocompleteConfig<T, V = T>(config: Partial<HlmAutocompleteConfig<T, V>>): ValueProvider {
return { provide: HlmAutocompleteConfigToken, useValue: { ...getDefaultConfig(), ...config } };
}
export function injectHlmAutocompleteConfig<T, V = T>(): HlmAutocompleteConfig<T, V> {
return (
(inject(HlmAutocompleteConfigToken, { optional: true }) as HlmAutocompleteConfig<T, V> | null) ?? getDefaultConfig()
);
}

View File

@ -0,0 +1,426 @@
import type { BooleanInput } from '@angular/cdk/coercion';
import { NgTemplateOutlet } from '@angular/common';
import {
booleanAttribute,
ChangeDetectionStrategy,
Component,
computed,
effect,
ElementRef,
forwardRef,
inject,
input,
linkedSignal,
model,
output,
type TemplateRef,
viewChild,
} from '@angular/core';
import { type ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
import { provideIcons } from '@ng-icons/core';
import {
lucideChevronDown,
lucideCircleX,
lucideSearch,
} from '@ng-icons/lucide';
import {
BrnAutocomplete,
BrnAutocompleteEmpty,
BrnAutocompleteImports,
} from '@spartan-ng/brain/autocomplete';
import { debouncedSignal } from '@spartan-ng/brain/core';
import type { ChangeFn, TouchFn } from '@spartan-ng/brain/forms';
import { BrnPopoverImports } from '@spartan-ng/brain/popover';
import { HlmIconImports } from '@spartan-ng/helm/icon';
import { HlmPopoverImports } from '@spartan-ng/helm/popover';
import { hlm } from '@spartan-ng/helm/utils';
import type { ClassValue } from 'clsx';
import { HlmAutocompleteEmpty } from './hlm-autocomplete-empty';
import { HlmAutocompleteGroup } from './hlm-autocomplete-group';
import { HlmAutocompleteItem } from './hlm-autocomplete-item';
import { HlmAutocompleteList } from './hlm-autocomplete-list';
import { HlmInputGroupImports } from '@spartan-ng/helm/input-group';
import { HlmAutocompleteTrigger } from './hlm-autocomplete-trigger';
import { injectHlmAutocompleteConfig } from './hlm-autocomplete.token';
export const HLM_AUTOCOMPLETE_VALUE_ACCESSOR = {
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => HlmAutocomplete),
multi: true,
};
@Component({
selector: 'hlm-autocomplete',
imports: [
NgTemplateOutlet,
BrnAutocomplete,
BrnAutocompleteEmpty,
HlmAutocompleteEmpty,
HlmAutocompleteGroup,
HlmAutocompleteItem,
HlmAutocompleteList,
HlmAutocompleteTrigger,
BrnPopoverImports,
HlmPopoverImports,
HlmIconImports,
HlmInputGroupImports,
BrnAutocompleteImports,
],
providers: [
HLM_AUTOCOMPLETE_VALUE_ACCESSOR,
provideIcons({ lucideSearch, lucideChevronDown, lucideCircleX }),
],
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
'[class]': '_computedClass()',
},
template: `
@let transformer = transformOptionToValue();
<brn-popover
#popover
align="start"
autoFocus="first-heading"
sideOffset="5"
closeDelay="100"
(closed)="_closed()"
[closeOnOutsidePointerEvents]="true"
>
<div brnAutocomplete (selectionCleared)="_selectionCleared()">
<div
hlmInputGroup
hlmAutocompleteTrigger
[class]="_computedAutocompleteSearchClass()"
[disabledTrigger]="!_search() || _disabled()"
>
<input
#input
brnAutocompleteSearchInput
hlmInputGroupInput
type="text"
autocomplete="off"
[id]="inputId()"
[class]="_computedAutocompleteInputClass()"
[placeholder]="searchPlaceholderText()"
[disabled]="_disabled()"
[value]="search()"
(input)="_searchChanged($event)"
data-bwignore="true"
data-1p-ignore="true"
data-lpignore="true"
/>
<div hlmInputGroupAddon>
<ng-icon name="lucideSearch" />
</div>
<div hlmInputGroupAddon align="inline-end">
@if (showClearBtn() && value() !== undefined) {
<button
hlmInputGroupButton
type="button"
tabindex="-1"
aria-label="clear"
[disabled]="_disabled()"
(click)="_selectionCleared()"
size="icon-xs"
>
<ng-icon name="lucideCircleX" />
</button>
}
<button
hlmInputGroupButton
type="button"
tabindex="-1"
[attr.aria-label]="ariaLabelToggleButton()"
[disabled]="_disabled()"
(click)="_toggleOptions()"
size="icon-xs"
>
<ng-icon name="lucideChevronDown" />
</button>
</div>
</div>
<div
*brnPopoverContent="let ctx"
hlmPopoverContent
class="p-0"
[style.width.px]="_elementRef.nativeElement.offsetWidth"
>
<hlm-autocomplete-list
[class]="_computedAutocompleteListClass()"
[class.hidden]="filteredOptions().length === 0"
>
<hlm-autocomplete-group>
@for (option of filteredOptions(); track option) {
<button
hlm-autocomplete-item
[class]="_computedAutocompleteItemClass()"
[value]="transformer ? transformer(option) : option"
(selected)="_optionSelected(option)"
>
@if (optionTemplate(); as optionTemplate) {
<ng-container
*ngTemplateOutlet="
optionTemplate;
context: { $implicit: option }
"
/>
} @else {
{{ transformOptionToString()(option) }}
}
</button>
}
</hlm-autocomplete-group>
</hlm-autocomplete-list>
<div
*brnAutocompleteEmpty
hlmAutocompleteEmpty
[class]="_computedAutocompleteEmptyClass()"
>
@if (loading()) {
<ng-content select="[loading]">{{ loadingText() }}</ng-content>
} @else {
<ng-content select="[empty]">{{ emptyText() }}</ng-content>
}
</div>
</div>
</div>
</brn-popover>
`,
})
export class HlmAutocomplete<T, V = T> implements ControlValueAccessor {
private static _id = 0;
private readonly _config = injectHlmAutocompleteConfig<T, V>();
private readonly _brnAutocomplete = viewChild.required(BrnAutocomplete);
private readonly _inputRef = viewChild.required('input', {
read: ElementRef,
});
protected readonly _elementRef = inject(ElementRef<HTMLElement>);
/** The user defined class */
public readonly userClass = input<ClassValue>('', { alias: 'class' });
protected readonly _computedClass = computed(() =>
hlm('block w-full', this.userClass())
);
/** Custom class for the autocomplete search container. */
public readonly autocompleteSearchClass = input<ClassValue>('');
protected readonly _computedAutocompleteSearchClass = computed(() =>
hlm('', this.autocompleteSearchClass())
);
/** Custom class for the autocomplete input. */
public readonly autocompleteInputClass = input<ClassValue>('');
protected readonly _computedAutocompleteInputClass = computed(() =>
hlm('', this.autocompleteInputClass())
);
/** Custom class for the autocomplete list. */
public readonly autocompleteListClass = input<ClassValue>('');
protected readonly _computedAutocompleteListClass = computed(() =>
hlm('', this.autocompleteListClass())
);
/** Custom class for each autocomplete item. */
public readonly autocompleteItemClass = input<ClassValue>('');
protected readonly _computedAutocompleteItemClass = computed(() =>
hlm('', this.autocompleteItemClass())
);
/** Custom class for the empty and loading state container. */
public readonly autocompleteEmptyClass = input<ClassValue>('');
protected readonly _computedAutocompleteEmptyClass = computed(() =>
hlm('', this.autocompleteEmptyClass())
);
/** The list of filtered options to display in the autocomplete. */
public readonly filteredOptions = input<T[]>([]);
/** The selected value. */
public readonly value = model<T | V>();
/** Debounce time in milliseconds for the search input. */
public readonly debounceTime = input<number>(this._config.debounceTime);
/** The search query. */
public readonly search = model<string>('');
/** Debounced search query. */
protected readonly _search = debouncedSignal(
this.search,
this.debounceTime()
);
/** Function to transform an option value to a search string. Defaults to identity function for strings. */
public readonly transformValueToSearch = input<(option: T) => string>(
this._config.transformValueToSearch
);
/** Whether selection of an option is required. */
public readonly requireSelection = input<boolean, BooleanInput>(
this._config.requireSelection,
{
transform: booleanAttribute,
}
);
/** Function to transform an option value to a display string. Defaults to identity function for strings. */
public readonly transformOptionToString = input<(option: T) => string>(
this._config.transformOptionToString
);
/** Function to transform the object to the value. */
public readonly transformOptionToValue = input<
((option: T) => V) | undefined
>(this._config.transformOptionToValue);
/** Function to display the selected value as a string. */
public readonly displayWith = input<((value: V) => string) | undefined>(
undefined
);
/** Computed function to get the display value for the selected option. */
protected readonly _displaySearchValue = computed(() => {
const displayWith = this.displayWith();
if (displayWith) {
return displayWith;
} else {
return this.transformValueToSearch();
}
});
/** Optional template for rendering each option. */
public readonly optionTemplate =
input<TemplateRef<HlmAutocompleteOption<T>>>();
/** Whether the autocomplete is in a loading state. */
public readonly loading = input<boolean, BooleanInput>(false, {
transform: booleanAttribute,
});
/** Whether to show the clear button when a option is selected. */
public readonly showClearBtn = input<boolean, BooleanInput>(
this._config.showClearBtn,
{
transform: booleanAttribute,
}
);
/** Placeholder text for the input field. */
public readonly searchPlaceholderText = input('Select an option');
/** Text to display when loading options. */
public readonly loadingText = input('Loading options...');
/** Text to display when no options are found. */
public readonly emptyText = input('No options found');
/** Aria label for the toggle button. */
public readonly ariaLabelToggleButton = input('Toggle options');
/** The id of the input field. */
public readonly inputId = input(
`hlm-autocomplete-input-${++HlmAutocomplete._id}`
);
/** Whether the autocomplete is disabled. */
public readonly disabled = input<boolean, BooleanInput>(false, {
transform: booleanAttribute,
});
protected readonly _disabled = linkedSignal(() => this.disabled());
/** Emitted when the selected value changes. */
public readonly valueChange = output<T | V | null>();
/** Emitted when the search query changes. */
public readonly searchChange = output<string>();
protected _onChange?: ChangeFn<T | V | null>;
protected _onTouched?: TouchFn;
constructor() {
effect(() => {
const search = this._search();
this.searchChange.emit(search);
});
}
protected _searchChanged(event: Event) {
const value = (event.target as HTMLInputElement).value;
this.search.set(value ?? '');
if (!this._brnAutocomplete().isExpanded() && value.length > 0) {
this._brnAutocomplete().open();
}
}
/** Toggle the options panel */
protected _toggleOptions() {
if (this._search() || this.filteredOptions().length > 0) {
// only toggle if there's a search term or options to show
this._brnAutocomplete().toggle();
}
this._inputRef().nativeElement.focus();
}
/** Clear the current selection and search input */
protected _selectionCleared() {
this.value.set(undefined);
this._onChange?.(null);
this.valueChange.emit(null);
this.search.set('');
}
protected _optionSelected(option: T) {
const transformer = this.transformOptionToValue();
const value = transformer ? transformer(option) : option;
this.value.set(value);
this._onChange?.(value);
this.valueChange.emit(value);
const searchValue = this._displaySearchValue()(value as any);
this.search.set(searchValue ?? '');
this._brnAutocomplete().close();
}
/** CONTROL VALUE ACCESSOR */
public writeValue(value: T | V | null): void {
this.value.set(value ? value : undefined);
const searchValue = value ? this._displaySearchValue()(value as any) : '';
this.search.set(searchValue);
}
public registerOnChange(fn: ChangeFn<T | V | null>): void {
this._onChange = fn;
}
public registerOnTouched(fn: TouchFn): void {
this._onTouched = fn;
}
public setDisabledState(isDisabled: boolean): void {
this._disabled.set(isDisabled);
}
protected _closed() {
if (this.requireSelection()) {
const value = this.value();
const searchValue = value ? this._displaySearchValue()(value as any) : '';
this.search.set(searchValue ?? '');
}
}
}
export interface HlmAutocompleteOption<T> {
$implicit: T;
}

View File

@ -0,0 +1,5 @@
import { HlmBadge } from './lib/hlm-badge';
export * from './lib/hlm-badge';
export const HlmBadgeImports = [HlmBadge] as const;

View File

@ -0,0 +1,37 @@
import { Directive, computed, input } from '@angular/core';
import { hlm } from '@spartan-ng/helm/utils';
import { type VariantProps, cva } from 'class-variance-authority';
import type { ClassValue } from 'clsx';
const badgeVariants = cva(
'focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-md border px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] [&_ng-icon]:pointer-events-none [&_ng-icon]:size-3',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground [a&]:hover:bg-primary/90 border-transparent',
secondary: 'bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90 border-transparent',
destructive:
'bg-destructive [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60 border-transparent text-white',
outline: 'text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground',
},
},
defaultVariants: {
variant: 'default',
},
},
);
export type BadgeVariants = VariantProps<typeof badgeVariants>;
@Directive({
selector: '[hlmBadge]',
host: {
'[class]': '_computedClass()',
},
})
export class HlmBadge {
protected readonly _computedClass = computed(() => hlm(badgeVariants({ variant: this.variant() }), this.userClass()));
public readonly userClass = input<ClassValue>('', { alias: 'class' });
public readonly variant = input<BadgeVariants['variant']>('default');
}

View File

@ -0,0 +1,6 @@
import { HlmButton } from './lib/hlm-button';
export * from './lib/hlm-button';
export * from './lib/hlm-button.token';
export const HlmButtonImports = [HlmButton] as const;

View File

@ -0,0 +1,22 @@
import { InjectionToken, type ValueProvider, inject } from '@angular/core';
import type { ButtonVariants } from './hlm-button';
export interface BrnButtonConfig {
variant: ButtonVariants['variant'];
size: ButtonVariants['size'];
}
const defaultConfig: BrnButtonConfig = {
variant: 'default',
size: 'default',
};
const BrnButtonConfigToken = new InjectionToken<BrnButtonConfig>('BrnButtonConfig');
export function provideBrnButtonConfig(config: Partial<BrnButtonConfig>): ValueProvider {
return { provide: BrnButtonConfigToken, useValue: { ...defaultConfig, ...config } };
}
export function injectBrnButtonConfig(): BrnButtonConfig {
return inject(BrnButtonConfigToken, { optional: true }) ?? defaultConfig;
}

View File

@ -0,0 +1,67 @@
import { Directive, computed, input, signal } from '@angular/core';
import { BrnButton } from '@spartan-ng/brain/button';
import { hlm } from '@spartan-ng/helm/utils';
import { type VariantProps, cva } from 'class-variance-authority';
import type { ClassValue } from 'clsx';
import { injectBrnButtonConfig } from './hlm-button.token';
export const buttonVariants = cva(
"focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&_ng-icon]:pointer-events-none [&_ng-icon]:shrink-0 [&_ng-icon:not([class*='text-'])]:text-base",
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
destructive:
'bg-destructive hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60 text-white',
outline:
'bg-background hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 border shadow-xs',
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-9 px-4 py-2 has-[>ng-icon]:px-3',
sm: 'h-8 gap-1.5 rounded-md px-3 has-[>ng-icon]:px-2.5',
lg: 'h-10 rounded-md px-6 has-[>ng-icon]:px-4',
icon: 'size-9',
'icon-sm': 'size-8',
'icon-lg': 'size-10',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
);
export type ButtonVariants = VariantProps<typeof buttonVariants>;
@Directive({
selector: 'button[hlmBtn], a[hlmBtn]',
exportAs: 'hlmBtn',
hostDirectives: [{ directive: BrnButton, inputs: ['disabled'] }],
host: {
'data-slot': 'button',
'[class]': '_computedClass()',
},
})
export class HlmButton {
private readonly _config = injectBrnButtonConfig();
private readonly _additionalClasses = signal<ClassValue>('');
public readonly userClass = input<ClassValue>('', { alias: 'class' });
protected readonly _computedClass = computed(() =>
hlm(buttonVariants({ variant: this.variant(), size: this.size() }), this.userClass(), this._additionalClasses()),
);
public readonly variant = input<ButtonVariants['variant']>(this._config.variant);
public readonly size = input<ButtonVariants['size']>(this._config.size);
setClass(classes: string): void {
this._additionalClasses.set(classes);
}
}

View File

@ -0,0 +1,25 @@
import { HlmCard } from './lib/hlm-card';
import { HlmCardAction } from './lib/hlm-card-action';
import { HlmCardContent } from './lib/hlm-card-content';
import { HlmCardDescription } from './lib/hlm-card-description';
import { HlmCardFooter } from './lib/hlm-card-footer';
import { HlmCardHeader } from './lib/hlm-card-header';
import { HlmCardTitle } from './lib/hlm-card-title';
export * from './lib/hlm-card';
export * from './lib/hlm-card-action';
export * from './lib/hlm-card-content';
export * from './lib/hlm-card-description';
export * from './lib/hlm-card-footer';
export * from './lib/hlm-card-header';
export * from './lib/hlm-card-title';
export const HlmCardImports = [
HlmCard,
HlmCardHeader,
HlmCardFooter,
HlmCardTitle,
HlmCardDescription,
HlmCardContent,
HlmCardAction,
] as const;

View File

@ -0,0 +1,16 @@
import { Directive, computed, input } from '@angular/core';
import { hlm } from '@spartan-ng/helm/utils';
import type { ClassValue } from 'clsx';
@Directive({
selector: '[hlmCardAction]',
host: {
'[class]': '_computedClass()',
},
})
export class HlmCardAction {
public readonly userClass = input<ClassValue>('', { alias: 'class' });
protected readonly _computedClass = computed(() =>
hlm('col-start-2 row-span-2 row-start-1 self-start justify-self-end', this.userClass()),
);
}

View File

@ -0,0 +1,14 @@
import { Directive, computed, input } from '@angular/core';
import { hlm } from '@spartan-ng/helm/utils';
import type { ClassValue } from 'clsx';
@Directive({
selector: '[hlmCardContent]',
host: {
'[class]': '_computedClass()',
},
})
export class HlmCardContent {
public readonly userClass = input<ClassValue>('', { alias: 'class' });
protected readonly _computedClass = computed(() => hlm('px-6', this.userClass()));
}

View File

@ -0,0 +1,14 @@
import { Directive, computed, input } from '@angular/core';
import { hlm } from '@spartan-ng/helm/utils';
import type { ClassValue } from 'clsx';
@Directive({
selector: '[hlmCardDescription]',
host: {
'[class]': '_computedClass()',
},
})
export class HlmCardDescription {
public readonly userClass = input<ClassValue>('', { alias: 'class' });
protected readonly _computedClass = computed(() => hlm('text-muted-foreground text-sm', this.userClass()));
}

View File

@ -0,0 +1,14 @@
import { Directive, computed, input } from '@angular/core';
import { hlm } from '@spartan-ng/helm/utils';
import type { ClassValue } from 'clsx';
@Directive({
selector: '[hlmCardFooter]',
host: {
'[class]': '_computedClass()',
},
})
export class HlmCardFooter {
public readonly userClass = input<ClassValue>('', { alias: 'class' });
protected readonly _computedClass = computed(() => hlm('flex items-center px-6 [.border-t]:pt-4', this.userClass()));
}

View File

@ -0,0 +1,19 @@
import { Directive, computed, input } from '@angular/core';
import { hlm } from '@spartan-ng/helm/utils';
import type { ClassValue } from 'clsx';
@Directive({
selector: '[hlmCardHeader]',
host: {
'[class]': '_computedClass()',
},
})
export class HlmCardHeader {
public readonly userClass = input<ClassValue>('', { alias: 'class' });
protected readonly _computedClass = computed(() =>
hlm(
'@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-4',
this.userClass(),
),
);
}

View File

@ -0,0 +1,14 @@
import { Directive, computed, input } from '@angular/core';
import { hlm } from '@spartan-ng/helm/utils';
import type { ClassValue } from 'clsx';
@Directive({
selector: '[hlmCardTitle]',
host: {
'[class]': '_computedClass()',
},
})
export class HlmCardTitle {
public readonly userClass = input<ClassValue>('', { alias: 'class' });
protected readonly _computedClass = computed(() => hlm('leading-none font-semibold', this.userClass()));
}

View File

@ -0,0 +1,21 @@
import { Directive, computed, input } from '@angular/core';
import { hlm } from '@spartan-ng/helm/utils';
import { type VariantProps, cva } from 'class-variance-authority';
import type { ClassValue } from 'clsx';
export const cardVariants = cva('bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-4 shadow-sm', {
variants: {},
defaultVariants: {},
});
export type CardVariants = VariantProps<typeof cardVariants>;
@Directive({
selector: '[hlmCard]',
host: {
'[class]': '_computedClass()',
},
})
export class HlmCard {
public readonly userClass = input<ClassValue>('', { alias: 'class' });
protected readonly _computedClass = computed(() => hlm(cardVariants(), this.userClass()));
}

View File

@ -0,0 +1,43 @@
import { HlmCommand } from './lib/hlm-command';
import { HlmCommandDialog } from './lib/hlm-command-dialog';
import { HlmCommandDialogCloseButton } from './lib/hlm-command-dialog-close-button';
import { HlmCommandEmpty } from './lib/hlm-command-empty';
import { HlmCommandGroup } from './lib/hlm-command-group';
import { HlmCommandGroupLabel } from './lib/hlm-command-group-label';
import { HlmCommandIcon } from './lib/hlm-command-icon';
import { HlmCommandItem } from './lib/hlm-command-item';
import { HlmCommandList } from './lib/hlm-command-list';
import { HlmCommandSearch } from './lib/hlm-command-search';
import { HlmCommandSearchInput } from './lib/hlm-command-search-input';
import { HlmCommandSeparator } from './lib/hlm-command-separator';
import { HlmCommandShortcut } from './lib/hlm-command-shortcut';
export * from './lib/hlm-command';
export * from './lib/hlm-command-dialog';
export * from './lib/hlm-command-dialog-close-button';
export * from './lib/hlm-command-empty';
export * from './lib/hlm-command-group';
export * from './lib/hlm-command-group-label';
export * from './lib/hlm-command-icon';
export * from './lib/hlm-command-item';
export * from './lib/hlm-command-list';
export * from './lib/hlm-command-search';
export * from './lib/hlm-command-search-input';
export * from './lib/hlm-command-separator';
export * from './lib/hlm-command-shortcut';
export const HlmCommandImports = [
HlmCommand,
HlmCommandItem,
HlmCommandSeparator,
HlmCommandGroup,
HlmCommandList,
HlmCommandShortcut,
HlmCommandIcon,
HlmCommandDialogCloseButton,
HlmCommandDialog,
HlmCommandSearchInput,
HlmCommandSearch,
HlmCommandGroupLabel,
HlmCommandEmpty,
] as const;

View File

@ -0,0 +1,24 @@
import { Directive, computed, input } from '@angular/core';
import { BrnDialogClose } from '@spartan-ng/brain/dialog';
import { HlmButton, provideBrnButtonConfig } from '@spartan-ng/helm/button';
import { provideHlmIconConfig } from '@spartan-ng/helm/icon';
import { hlm } from '@spartan-ng/helm/utils';
import type { ClassValue } from 'clsx';
@Directive({
selector: '[hlmCommandDialogCloseBtn]',
providers: [provideBrnButtonConfig({ variant: 'ghost' }), provideHlmIconConfig({ size: 'xs' })],
hostDirectives: [HlmButton, BrnDialogClose],
host: {
'[class]': '_computedClass()',
},
})
export class HlmCommandDialogCloseButton {
public readonly userClass = input<ClassValue>('', { alias: 'class' });
protected readonly _computedClass = computed(() =>
hlm(
'focus-visible:ring-ring hover:bg-accent hover:text-accent-foreground ring-offset-background absolute top-3 right-3 inline-flex !h-5 h-10 !w-5 items-center justify-center rounded-md !p-1 px-4 py-2 text-sm font-medium transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none',
this.userClass(),
),
);
}

View File

@ -0,0 +1,41 @@
import { Directive, ElementRef, Renderer2, computed, contentChild, effect, inject, input, signal } from '@angular/core';
import { BrnCommandSearchInputToken } from '@spartan-ng/brain/command';
import { injectExposesStateProvider } from '@spartan-ng/brain/core';
import { hlm } from '@spartan-ng/helm/utils';
import type { ClassValue } from 'clsx';
@Directive({
selector: '[hlmCommandDialog]',
host: {
'[class]': '_computedClass()',
},
})
export class HlmCommandDialog {
private readonly _stateProvider = injectExposesStateProvider({ host: true });
public readonly state = this._stateProvider.state ?? signal('closed').asReadonly();
private readonly _renderer = inject(Renderer2);
private readonly _element = inject(ElementRef);
/** Access the search field */
private readonly _searchInput = contentChild(BrnCommandSearchInputToken, { read: ElementRef });
public readonly userClass = input<ClassValue>('', { alias: 'class' });
protected readonly _computedClass = computed(() =>
hlm(
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-top-[2%] data-[state=open]:slide-in-from-top-[2%]',
this.userClass(),
),
);
constructor() {
effect(() => {
this._renderer.setAttribute(this._element.nativeElement, 'data-state', this.state());
const searchInput = this._searchInput();
if (this.state() === 'open' && searchInput) {
searchInput.nativeElement.focus();
}
});
}
}

View File

@ -0,0 +1,14 @@
import { Directive, computed, input } from '@angular/core';
import { hlm } from '@spartan-ng/helm/utils';
import type { ClassValue } from 'clsx';
@Directive({
selector: '[hlmCommandEmpty]',
host: {
'[class]': '_computedClass()',
},
})
export class HlmCommandEmpty {
public readonly userClass = input<ClassValue>('', { alias: 'class' });
protected readonly _computedClass = computed(() => hlm('py-6 text-center text-sm', this.userClass()));
}

View File

@ -0,0 +1,21 @@
import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core';
import { hlm } from '@spartan-ng/helm/utils';
@Component({
selector: 'hlm-command-group-label',
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
role: 'presentation',
'[class]': '_computedClass()',
},
template: '<ng-content />',
})
export class HlmCommandGroupLabel {
/** The user defined class */
public readonly userClass = input<string>('', { alias: 'class' });
/** The styles to apply */
protected readonly _computedClass = computed(() =>
hlm('text-muted-foreground px-2 py-1.5 text-xs font-medium', this.userClass()),
);
}

View File

@ -0,0 +1,27 @@
import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core';
import { BrnCommandGroup } from '@spartan-ng/brain/command';
import { hlm } from '@spartan-ng/helm/utils';
@Component({
selector: 'hlm-command-group',
changeDetection: ChangeDetectionStrategy.OnPush,
hostDirectives: [
{
directive: BrnCommandGroup,
inputs: ['id'],
},
],
host: {
'[class]': '_computedClass()',
},
template: '<ng-content />',
})
export class HlmCommandGroup {
/** The user defined class */
public readonly userClass = input<string>('', { alias: 'class' });
/** The styles to apply */
protected readonly _computedClass = computed(() =>
hlm('text-foreground block overflow-hidden p-1 data-[hidden]:hidden', this.userClass()),
);
}

View File

@ -0,0 +1,18 @@
import { computed, Directive, input } from '@angular/core';
import { provideHlmIconConfig } from '@spartan-ng/helm/icon';
import { hlm } from '@spartan-ng/helm/utils';
import type { ClassValue } from 'clsx';
@Directive({
selector: '[hlmCommandIcon]',
providers: [provideHlmIconConfig({ size: 'sm' })],
host: {
'[class]': '_computedClass()',
},
})
export class HlmCommandIcon {
public readonly userClass = input<ClassValue>('', { alias: 'class' });
protected readonly _computedClass = computed(() =>
hlm('text-muted-foreground pointer-events-none shrink-0', this.userClass()),
);
}

View File

@ -0,0 +1,33 @@
import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core';
import { BrnCommandItem } from '@spartan-ng/brain/command';
import { hlm } from '@spartan-ng/helm/utils';
@Component({
selector: 'button[hlm-command-item]',
changeDetection: ChangeDetectionStrategy.OnPush,
hostDirectives: [
{
directive: BrnCommandItem,
inputs: ['value', 'disabled', 'id'],
outputs: ['selected'],
},
],
host: {
'[class]': '_computedClass()',
},
template: `
<ng-content />
`,
})
export class HlmCommandItem {
/** The user defined class */
public readonly userClass = input<string>('', { alias: 'class' });
/** The styles to apply */
protected readonly _computedClass = computed(() =>
hlm(
'data-[selected]:bg-accent data-[selected=true]:text-accent-foreground [&>ng-icon]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[hidden]:hidden [&>ng-icon]:pointer-events-none [&>ng-icon]:shrink-0 [&>ng-icon]:text-base',
this.userClass(),
),
);
}

View File

@ -0,0 +1,27 @@
import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core';
import { BrnCommandList } from '@spartan-ng/brain/command';
import { hlm } from '@spartan-ng/helm/utils';
@Component({
selector: 'hlm-command-list',
changeDetection: ChangeDetectionStrategy.OnPush,
hostDirectives: [
{
directive: BrnCommandList,
inputs: ['id'],
},
],
host: {
'[class]': '_computedClass()',
},
template: '<ng-content />',
})
export class HlmCommandList {
/** The user defined class */
public readonly userClass = input<string>('', { alias: 'class' });
/** The styles to apply */
protected readonly _computedClass = computed(() =>
hlm('max-h-[300px] overflow-x-hidden overflow-y-auto', this.userClass()),
);
}

View File

@ -0,0 +1,26 @@
import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core';
import { BrnCommandSearchInput } from '@spartan-ng/brain/command';
import { hlm } from '@spartan-ng/helm/utils';
import type { ClassValue } from 'clsx';
@Component({
selector: 'input[hlm-command-search-input]',
changeDetection: ChangeDetectionStrategy.OnPush,
hostDirectives: [{ directive: BrnCommandSearchInput, inputs: ['value'] }],
host: {
'[class]': '_computedClass()',
},
template: '',
})
export class HlmCommandSearchInput {
/** The user defined class */
public readonly userClass = input<ClassValue>('', { alias: 'class' });
/** The styles to apply */
protected readonly _computedClass = computed(() =>
hlm(
'placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none disabled:cursor-not-allowed disabled:opacity-50',
this.userClass(),
),
);
}

View File

@ -0,0 +1,25 @@
import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core';
import { provideHlmIconConfig } from '@spartan-ng/helm/icon';
import { hlm } from '@spartan-ng/helm/utils';
import type { ClassValue } from 'clsx';
@Component({
selector: 'hlm-command-search',
providers: [provideHlmIconConfig({ size: 'sm' })],
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
'[class]': '_computedClass()',
},
template: `
<ng-content />
`,
})
export class HlmCommandSearch {
/** The user defined class */
public readonly userClass = input<ClassValue>('', { alias: 'class' });
/** The styles to apply */
protected readonly _computedClass = computed(() =>
hlm('flex h-9 items-center gap-2 border-b px-3 [&>_ng-icon]:flex-none [&>_ng-icon]:opacity-50', this.userClass()),
);
}

View File

@ -0,0 +1,19 @@
import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core';
import { hlm } from '@spartan-ng/helm/utils';
@Component({
selector: 'hlm-command-separator',
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
role: 'separator',
'[class]': '_computedClass()',
},
template: '',
})
export class HlmCommandSeparator {
/** The user defined class */
public readonly userClass = input<string>('', { alias: 'class' });
/** The styles to apply */
protected readonly _computedClass = computed(() => hlm('bg-border -mx-1 block h-px', this.userClass()));
}

View File

@ -0,0 +1,18 @@
import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core';
import { hlm } from '@spartan-ng/helm/utils';
import type { ClassValue } from 'clsx';
@Component({
selector: 'hlm-command-shortcut',
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
'[class]': '_computedClass()',
},
template: '<ng-content />',
})
export class HlmCommandShortcut {
public readonly userClass = input<ClassValue>('', { alias: 'class' });
protected readonly _computedClass = computed(() =>
hlm('text-muted-foreground ml-auto text-xs tracking-widest', this.userClass()),
);
}

View File

@ -0,0 +1,31 @@
import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core';
import { BrnCommand } from '@spartan-ng/brain/command';
import { hlm } from '@spartan-ng/helm/utils';
import type { ClassValue } from 'clsx';
@Component({
selector: 'hlm-command',
changeDetection: ChangeDetectionStrategy.OnPush,
hostDirectives: [
{
directive: BrnCommand,
inputs: ['id', 'filter'],
outputs: ['valueChange'],
},
],
host: {
'[class]': '_computedClass()',
},
template: `
<ng-content />
`,
})
export class HlmCommand {
/** The user defined class */
public readonly userClass = input<ClassValue>('', { alias: 'class' });
/** The styles to apply */
protected readonly _computedClass = computed(() =>
hlm('bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md', this.userClass()),
);
}

View File

@ -0,0 +1,29 @@
import { HlmDialog } from './lib/hlm-dialog';
import { HlmDialogClose } from './lib/hlm-dialog-close';
import { HlmDialogContent } from './lib/hlm-dialog-content';
import { HlmDialogDescription } from './lib/hlm-dialog-description';
import { HlmDialogFooter } from './lib/hlm-dialog-footer';
import { HlmDialogHeader } from './lib/hlm-dialog-header';
import { HlmDialogOverlay } from './lib/hlm-dialog-overlay';
import { HlmDialogTitle } from './lib/hlm-dialog-title';
export * from './lib/hlm-dialog';
export * from './lib/hlm-dialog-close';
export * from './lib/hlm-dialog-content';
export * from './lib/hlm-dialog-description';
export * from './lib/hlm-dialog-footer';
export * from './lib/hlm-dialog-header';
export * from './lib/hlm-dialog-overlay';
export * from './lib/hlm-dialog-title';
export * from './lib/hlm-dialog.service';
export const HlmDialogImports = [
HlmDialog,
HlmDialogClose,
HlmDialogContent,
HlmDialogDescription,
HlmDialogFooter,
HlmDialogHeader,
HlmDialogOverlay,
HlmDialogTitle,
] as const;

View File

@ -0,0 +1,20 @@
import { Directive, computed, input } from '@angular/core';
import { hlm } from '@spartan-ng/helm/utils';
import type { ClassValue } from 'clsx';
@Directive({
selector: '[hlmDialogClose],[brnDialogClose][hlm]',
host: {
'[class]': '_computedClass()',
},
})
export class HlmDialogClose {
public readonly userClass = input<ClassValue>('', { alias: 'class' });
protected readonly _computedClass = computed(() =>
hlm(
'ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 flex items-center justify-center rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-none disabled:pointer-events-none [&_ng-icon]:shrink-0',
this.userClass(),
),
);
}

View File

@ -0,0 +1,50 @@
import { NgComponentOutlet } from '@angular/common';
import { ChangeDetectionStrategy, Component, computed, inject, input } from '@angular/core';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { lucideX } from '@ng-icons/lucide';
import { BrnDialogClose, BrnDialogRef, injectBrnDialogContext } from '@spartan-ng/brain/dialog';
import { HlmIcon } from '@spartan-ng/helm/icon';
import { hlm } from '@spartan-ng/helm/utils';
import type { ClassValue } from 'clsx';
import { HlmDialogClose } from './hlm-dialog-close';
@Component({
selector: 'hlm-dialog-content',
imports: [NgComponentOutlet, BrnDialogClose, HlmDialogClose, NgIcon, HlmIcon],
providers: [provideIcons({ lucideX })],
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
'[class]': '_computedClass()',
'[attr.data-state]': 'state()',
},
template: `
@if (component) {
<ng-container [ngComponentOutlet]="component" />
} @else {
<ng-content />
}
<button brnDialogClose hlm>
<span class="sr-only">Close</span>
<ng-icon hlm size="sm" name="lucideX" />
</button>
`,
})
export class HlmDialogContent {
private readonly _dialogRef = inject(BrnDialogRef);
private readonly _dialogContext = injectBrnDialogContext({ optional: true });
public readonly state = computed(() => this._dialogRef?.state() ?? 'closed');
public readonly component = this._dialogContext?.$component;
private readonly _dynamicComponentClass = this._dialogContext?.$dynamicComponentClass;
public readonly userClass = input<ClassValue>('', { alias: 'class' });
protected readonly _computedClass = computed(() =>
hlm(
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 relative z-50 mx-auto grid w-full max-w-[calc(100%-2rem)] gap-4 rounded-lg border p-6 shadow-lg data-[state=closed]:duration-200 data-[state=open]:duration-200 sm:mx-0 sm:max-w-lg',
this.userClass(),
this._dynamicComponentClass,
),
);
}

View File

@ -0,0 +1,16 @@
import { Directive, computed, input } from '@angular/core';
import { BrnDialogDescription } from '@spartan-ng/brain/dialog';
import { hlm } from '@spartan-ng/helm/utils';
import type { ClassValue } from 'clsx';
@Directive({
selector: '[hlmDialogDescription]',
hostDirectives: [BrnDialogDescription],
host: {
'[class]': '_computedClass()',
},
})
export class HlmDialogDescription {
public readonly userClass = input<ClassValue>('', { alias: 'class' });
protected readonly _computedClass = computed(() => hlm('text-muted-foreground text-sm', this.userClass()));
}

View File

@ -0,0 +1,20 @@
import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core';
import { hlm } from '@spartan-ng/helm/utils';
import type { ClassValue } from 'clsx';
@Component({
selector: 'hlm-dialog-footer',
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
'[class]': '_computedClass()',
},
template: `
<ng-content />
`,
})
export class HlmDialogFooter {
public readonly userClass = input<ClassValue>('', { alias: 'class' });
protected readonly _computedClass = computed(() =>
hlm('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', this.userClass()),
);
}

View File

@ -0,0 +1,20 @@
import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core';
import { hlm } from '@spartan-ng/helm/utils';
import type { ClassValue } from 'clsx';
@Component({
selector: 'hlm-dialog-header',
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
'[class]': '_computedClass()',
},
template: `
<ng-content />
`,
})
export class HlmDialogHeader {
public readonly userClass = input<ClassValue>('', { alias: 'class' });
protected readonly _computedClass = computed(() =>
hlm('flex flex-col gap-2 text-center sm:text-left', this.userClass()),
);
}

View File

@ -0,0 +1,24 @@
import { Directive, computed, effect, input, untracked } from '@angular/core';
import { injectCustomClassSettable } from '@spartan-ng/brain/core';
import { hlm } from '@spartan-ng/helm/utils';
import type { ClassValue } from 'clsx';
export const hlmDialogOverlayClass =
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 bg-black/50';
@Directive({
selector: '[hlmDialogOverlay],brn-dialog-overlay[hlm]',
})
export class HlmDialogOverlay {
private readonly _classSettable = injectCustomClassSettable({ optional: true, host: true });
public readonly userClass = input<ClassValue>('', { alias: 'class' });
protected readonly _computedClass = computed(() => hlm(hlmDialogOverlayClass, this.userClass()));
constructor() {
effect(() => {
const newClass = this._computedClass();
untracked(() => this._classSettable?.setClassToCustomElement(newClass));
});
}
}

View File

@ -0,0 +1,16 @@
import { Directive, computed, input } from '@angular/core';
import { BrnDialogTitle } from '@spartan-ng/brain/dialog';
import { hlm } from '@spartan-ng/helm/utils';
import type { ClassValue } from 'clsx';
@Directive({
selector: '[hlmDialogTitle]',
hostDirectives: [BrnDialogTitle],
host: {
'[class]': '_computedClass()',
},
})
export class HlmDialogTitle {
public readonly userClass = input<ClassValue>('', { alias: 'class' });
protected readonly _computedClass = computed(() => hlm('text-lg leading-none font-semibold', this.userClass()));
}

View File

@ -0,0 +1,35 @@
import type { ComponentType } from '@angular/cdk/portal';
import { Injectable, type TemplateRef, inject } from '@angular/core';
import {
type BrnDialogOptions,
BrnDialogService,
cssClassesToArray,
} from '@spartan-ng/brain/dialog';
import { HlmDialogContent } from './hlm-dialog-content';
import { hlmDialogOverlayClass } from './hlm-dialog-overlay';
export type HlmDialogOptions<DialogContext = unknown> = BrnDialogOptions & {
contentClass?: string;
context?: DialogContext;
};
@Injectable({
providedIn: 'root',
})
export class HlmDialogService {
private readonly _brnDialogService = inject(BrnDialogService);
public open(component: ComponentType<unknown> | TemplateRef<unknown>, options?: Partial<HlmDialogOptions>) {
const mergedOptions = {
...(options ?? {}),
backdropClass: cssClassesToArray(`${hlmDialogOverlayClass} ${options?.backdropClass ?? ''}`),
context: {
...(options?.context && typeof options.context === 'object' ? options.context : {}),
$component: component,
$dynamicComponentClass: options?.contentClass,
},
};
return this._brnDialogService.open(HlmDialogContent, undefined, mergedOptions.context, mergedOptions);
}
}

View File

@ -0,0 +1,24 @@
import { ChangeDetectionStrategy, Component, forwardRef } from '@angular/core';
import { BrnDialog, BrnDialogOverlay, provideBrnDialogDefaultOptions } from '@spartan-ng/brain/dialog';
import { HlmDialogOverlay } from './hlm-dialog-overlay';
@Component({
selector: 'hlm-dialog',
exportAs: 'hlmDialog',
imports: [BrnDialogOverlay, HlmDialogOverlay],
providers: [
{
provide: BrnDialog,
useExisting: forwardRef(() => HlmDialog),
},
provideBrnDialogDefaultOptions({
// add custom options here
}),
],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<brn-dialog-overlay hlm />
<ng-content />
`,
})
export class HlmDialog extends BrnDialog {}

View File

@ -0,0 +1,9 @@
import { HlmError } from './lib/hlm-error';
import { HlmFormField } from './lib/hlm-form-field';
import { HlmHint } from './lib/hlm-hint';
export * from './lib/hlm-error';
export * from './lib/hlm-form-field';
export * from './lib/hlm-hint';
export const HlmFormFieldImports = [HlmFormField, HlmError, HlmHint] as const;

View File

@ -0,0 +1,17 @@
import { computed, Directive, input } from '@angular/core';
import { hlm } from '@spartan-ng/helm/utils';
import type { ClassValue } from 'clsx';
@Directive({
// eslint-disable-next-line @angular-eslint/directive-selector
selector: 'hlm-error',
host: {
'[class]': '_computedClass()',
},
})
export class HlmError {
public readonly userClass = input<ClassValue>('', { alias: 'class' });
protected readonly _computedClass = computed(() =>
hlm('text-destructive block text-sm font-medium', this.userClass()),
);
}

View File

@ -0,0 +1,52 @@
import {
ChangeDetectionStrategy,
Component,
computed,
contentChild,
contentChildren,
effect,
input,
} from '@angular/core';
import { BrnFormFieldControl } from '@spartan-ng/brain/form-field';
import { hlm } from '@spartan-ng/helm/utils';
import type { ClassValue } from 'clsx';
import { HlmError } from './hlm-error';
@Component({
selector: 'hlm-form-field',
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
'[class]': '_computedClass()',
},
template: `
<ng-content />
@switch (_hasDisplayedMessage()) {
@case ('error') {
<ng-content select="hlm-error" />
}
@default {
<ng-content select="hlm-hint" />
}
}
`,
})
export class HlmFormField {
public readonly userClass = input<ClassValue>('', { alias: 'class' });
protected readonly _computedClass = computed(() => hlm('block space-y-2', this.userClass()));
public readonly control = contentChild(BrnFormFieldControl);
public readonly errorChildren = contentChildren(HlmError);
protected readonly _hasDisplayedMessage = computed<'error' | 'hint'>(() =>
this.errorChildren() && this.errorChildren().length > 0 && this.control()?.errorState() ? 'error' : 'hint',
);
constructor() {
effect(() => {
if (!this.control()) {
throw new Error('hlm-form-field must contain a BrnFormFieldControl.');
}
});
}
}

View File

@ -0,0 +1,15 @@
import { computed, Directive, input } from '@angular/core';
import { hlm } from '@spartan-ng/helm/utils';
import type { ClassValue } from 'clsx';
@Directive({
// eslint-disable-next-line @angular-eslint/directive-selector
selector: 'hlm-hint',
host: {
'[class]': '_computedClass()',
},
})
export class HlmHint {
public readonly userClass = input<ClassValue>('', { alias: 'class' });
protected readonly _computedClass = computed(() => hlm('text-muted-foreground block text-sm', this.userClass()));
}

View File

@ -0,0 +1,7 @@
import { NgIcon } from '@ng-icons/core';
import { HlmIcon } from './lib/hlm-icon';
export * from './lib/hlm-icon';
export * from './lib/hlm-icon.token';
export const HlmIconImports = [HlmIcon, NgIcon] as const;

View File

@ -0,0 +1,20 @@
import { InjectionToken, type ValueProvider, inject } from '@angular/core';
import type { IconSize } from './hlm-icon';
export interface HlmIconConfig {
size: IconSize;
}
const defaultConfig: HlmIconConfig = {
size: 'base',
};
const HlmIconConfigToken = new InjectionToken<HlmIconConfig>('HlmIconConfig');
export function provideHlmIconConfig(config: Partial<HlmIconConfig>): ValueProvider {
return { provide: HlmIconConfigToken, useValue: { ...defaultConfig, ...config } };
}
export function injectHlmIconConfig(): HlmIconConfig {
return inject(HlmIconConfigToken, { optional: true }) ?? defaultConfig;
}

View File

@ -0,0 +1,35 @@
import { Directive, computed, input } from '@angular/core';
import { injectHlmIconConfig } from './hlm-icon.token';
export type IconSize = 'xs' | 'sm' | 'base' | 'lg' | 'xl' | 'none' | (Record<never, never> & string);
@Directive({
selector: 'ng-icon[hlmIcon], ng-icon[hlm]',
host: {
'[style.--ng-icon__size]': '_computedSize()',
},
})
export class HlmIcon {
private readonly _config = injectHlmIconConfig();
public readonly size = input<IconSize>(this._config.size);
protected readonly _computedSize = computed(() => {
const size = this.size();
switch (size) {
case 'xs':
return '12px';
case 'sm':
return '16px';
case 'base':
return '24px';
case 'lg':
return '32px';
case 'xl':
return '48px';
default: {
return size;
}
}
});
}

View File

@ -0,0 +1,22 @@
import { HlmInputGroup } from './lib/hlm-input-group';
import { HlmInputGroupAddon } from './lib/hlm-input-group-addon';
import { HlmInputGroupButton } from './lib/hlm-input-group-button';
import { HlmInputGroupInput } from './lib/hlm-input-group-input';
import { HlmInputGroupText } from './lib/hlm-input-group-text';
import { HlmInputGroupTextarea } from './lib/hlm-input-group-textarea';
export * from './lib/hlm-input-group';
export * from './lib/hlm-input-group-addon';
export * from './lib/hlm-input-group-button';
export * from './lib/hlm-input-group-input';
export * from './lib/hlm-input-group-text';
export * from './lib/hlm-input-group-textarea';
export const HlmInputGroupImports = [
HlmInputGroup,
HlmInputGroupAddon,
HlmInputGroupButton,
HlmInputGroupInput,
HlmInputGroupText,
HlmInputGroupTextarea,
] as const;

View File

@ -0,0 +1,42 @@
import { computed, Directive, input } from '@angular/core';
import { hlm } from '@spartan-ng/helm/utils';
import { cva, type VariantProps } from 'class-variance-authority';
import type { ClassValue } from 'clsx';
const inputGroupAddonVariants = cva(
"text-muted-foreground flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium select-none group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>ng-icon:not([class*='text-'])]:text-base",
{
variants: {
align: {
'inline-start': 'order-first pl-3 has-[>button]:ml-[-0.45rem] has-[>kbd]:ml-[-0.35rem]',
'inline-end': 'order-last pr-3 has-[>button]:mr-[-0.45rem] has-[>kbd]:mr-[-0.35rem]',
'block-start':
'order-first w-full justify-start px-3 pt-3 group-has-[>input]/input-group:pt-2.5 [.border-b]:pb-3',
'block-end': 'order-last w-full justify-start px-3 pb-3 group-has-[>input]/input-group:pb-2.5 [.border-t]:pt-3',
},
},
defaultVariants: {
align: 'inline-start',
},
},
);
type InputGroupAddonVariants = VariantProps<typeof inputGroupAddonVariants>;
@Directive({
selector: 'hlm-input-group-addon,[hlmInputGroupAddon]',
host: {
role: 'group',
'data-slot': 'input-group-addon',
'[attr.data-align]': 'align()',
'[class]': '_computedClass()',
},
})
export class HlmInputGroupAddon {
public readonly align = input<InputGroupAddonVariants['align']>('inline-start');
public readonly userClass = input<ClassValue>('', { alias: 'class' });
protected readonly _computedClass = computed(() =>
hlm(inputGroupAddonVariants({ align: this.align() }), this.userClass()),
);
}

View File

@ -0,0 +1,56 @@
import { computed, Directive, effect, inject, input } from '@angular/core';
import { HlmButton, provideBrnButtonConfig } from '@spartan-ng/helm/button';
import { hlm } from '@spartan-ng/helm/utils';
import { cva, type VariantProps } from 'class-variance-authority';
import type { ClassValue } from 'clsx';
const inputGroupAddonVariants = cva('flex items-center gap-2 text-sm shadow-none', {
variants: {
size: {
xs: "h-6 gap-1 rounded-[calc(var(--radius)-5px)] px-2 has-[>ng-icon]:px-2 [&>ng-icon:not([class*='text-'])]:text-sm",
sm: 'h-8 gap-1.5 rounded-md px-2.5 has-[>ng-icon]:px-2.5',
'icon-xs': 'size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>ng-icon]:p-0',
'icon-sm': 'size-8 p-0 has-[>ng-icon]:p-0',
},
},
defaultVariants: {
size: 'xs',
},
});
type InputGroupAddonVariants = VariantProps<typeof inputGroupAddonVariants>;
@Directive({
selector: 'button[hlmInputGroupButton]',
providers: [
provideBrnButtonConfig({
variant: 'ghost',
}),
],
hostDirectives: [
{
directive: HlmButton,
inputs: ['variant'],
},
],
host: {
'[attr.data-size]': 'size()',
'[type]': 'type()',
},
})
export class HlmInputGroupButton {
private readonly _hlmButton = inject(HlmButton);
public readonly size = input<InputGroupAddonVariants['size']>('xs');
public readonly userClass = input<ClassValue>('', { alias: 'class' });
public readonly type = input<'button' | 'submit' | 'reset'>('button');
protected readonly _computedClass = computed(() =>
hlm(inputGroupAddonVariants({ size: this.size() }), this.userClass()),
);
constructor() {
effect(() => {
this._hlmButton.setClass(this._computedClass());
});
}
}

View File

@ -0,0 +1,31 @@
import { computed, Directive, effect, inject, input } from '@angular/core';
import { HlmInput } from '@spartan-ng/helm/input';
import { hlm } from '@spartan-ng/helm/utils';
import type { ClassValue } from 'clsx';
@Directive({
selector: 'input[hlmInputGroupInput]',
hostDirectives: [HlmInput],
host: {
'data-slot': 'input-group-control',
'[class]': '_computedClass()',
},
})
export class HlmInputGroupInput {
private readonly _hlmInput = inject(HlmInput);
public readonly userClass = input<ClassValue>('', { alias: 'class' });
protected readonly _computedClass = computed(() =>
hlm(
'flex-1 rounded-none border-0 bg-transparent shadow-none focus-visible:ring-0 dark:bg-transparent',
this.userClass(),
),
);
constructor() {
effect(() => {
this._hlmInput.setClass(this._computedClass());
});
}
}

View File

@ -0,0 +1,20 @@
import { computed, Directive, input } from '@angular/core';
import { hlm } from '@spartan-ng/helm/utils';
import type { ClassValue } from 'clsx';
@Directive({
selector: 'span[hlmInputGroupText]',
host: {
'[class]': '_computedClass()',
},
})
export class HlmInputGroupText {
public readonly userClass = input<ClassValue>('', { alias: 'class' });
protected readonly _computedClass = computed(() =>
hlm(
`text-muted-foreground flex items-center gap-2 text-sm [&_ng-icon]:pointer-events-none [&_ng-icon:not([class*='text-'])]:text-base`,
this.userClass(),
),
);
}

View File

@ -0,0 +1,31 @@
import { computed, Directive, effect, inject, input } from '@angular/core';
import { HlmTextarea } from '@spartan-ng/helm/textarea';
import { hlm } from '@spartan-ng/helm/utils';
import type { ClassValue } from 'clsx';
@Directive({
selector: 'textarea[hlmInputGroupTextarea]',
hostDirectives: [HlmTextarea],
host: {
'data-slot': 'input-group-control',
'[class]': '_computedClass()',
},
})
export class HlmInputGroupTextarea {
private readonly _hlmInput = inject(HlmTextarea);
public readonly userClass = input<ClassValue>('', { alias: 'class' });
protected readonly _computedClass = computed(() =>
hlm(
'flex-1 resize-none rounded-none border-0 bg-transparent py-3 shadow-none focus-visible:ring-0 dark:bg-transparent',
this.userClass(),
),
);
constructor() {
effect(() => {
this._hlmInput.setClass(this._computedClass());
});
}
}

View File

@ -0,0 +1,33 @@
import { computed, Directive, input } from '@angular/core';
import { hlm } from '@spartan-ng/helm/utils';
import type { ClassValue } from 'clsx';
@Directive({
selector: '[hlmInputGroup],hlm-input-group',
host: {
'data-slot': 'input-group',
role: 'group',
'[class]': '_computedClass()',
},
})
export class HlmInputGroup {
public readonly userClass = input<ClassValue>('', { alias: 'class' });
protected readonly _computedClass = computed(() =>
hlm(
'group/input-group border-input dark:bg-input/30 relative flex w-full items-center rounded-md border shadow-xs transition-[color,box-shadow] outline-none',
'h-9 min-w-0 has-[>textarea]:h-auto',
// Variants based on alignment.
'has-[>[data-align=inline-start]]:[&>input]:pl-2',
'has-[>[data-align=inline-end]]:[&>input]:pr-2',
'has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-start]]:[&>input]:pb-3',
'has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3',
// Focus state.
'has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot=input-group-control]:focus-visible]:ring-[3px]',
// Error state.
'has-[>.ng-invalid.ng-touched]:ring-destructive/20 has-[>.ng-invalid.ng-touched]:border-destructive dark:has-[>.ng-invalid.ng-touched]:ring-destructive/40',
this.userClass(),
),
);
}

View File

@ -0,0 +1,31 @@
import { HlmNumberedPagination } from './lib/hlm-numbered-pagination';
import { HlmNumberedPaginationQueryParams } from './lib/hlm-numbered-pagination-query-params';
import { HlmPagination } from './lib/hlm-pagination';
import { HlmPaginationContent } from './lib/hlm-pagination-content';
import { HlmPaginationEllipsis } from './lib/hlm-pagination-ellipsis';
import { HlmPaginationItem } from './lib/hlm-pagination-item';
import { HlmPaginationLink } from './lib/hlm-pagination-link';
import { HlmPaginationNext } from './lib/hlm-pagination-next';
import { HlmPaginationPrevious } from './lib/hlm-pagination-previous';
export * from './lib/hlm-numbered-pagination';
export * from './lib/hlm-numbered-pagination-query-params';
export * from './lib/hlm-pagination';
export * from './lib/hlm-pagination-content';
export * from './lib/hlm-pagination-ellipsis';
export * from './lib/hlm-pagination-item';
export * from './lib/hlm-pagination-link';
export * from './lib/hlm-pagination-next';
export * from './lib/hlm-pagination-previous';
export const HlmPaginationImports = [
HlmPagination,
HlmPaginationContent,
HlmPaginationItem,
HlmPaginationLink,
HlmPaginationPrevious,
HlmPaginationNext,
HlmPaginationEllipsis,
HlmNumberedPagination,
HlmNumberedPaginationQueryParams,
] as const;

View File

@ -0,0 +1,179 @@
import type { BooleanInput, NumberInput } from '@angular/cdk/coercion';
import {
ChangeDetectionStrategy,
Component,
booleanAttribute,
computed,
input,
model,
numberAttribute,
untracked,
} from '@angular/core';
import { FormsModule } from '@angular/forms';
import { BrnSelectImports } from '@spartan-ng/brain/select';
import { HlmSelectImports } from '@spartan-ng/helm/select';
import { createPageArray, outOfBoundCorrection } from './hlm-numbered-pagination';
import { HlmPagination } from './hlm-pagination';
import { HlmPaginationContent } from './hlm-pagination-content';
import { HlmPaginationEllipsis } from './hlm-pagination-ellipsis';
import { HlmPaginationItem } from './hlm-pagination-item';
import { HlmPaginationLink } from './hlm-pagination-link';
import { HlmPaginationNext } from './hlm-pagination-next';
import { HlmPaginationPrevious } from './hlm-pagination-previous';
@Component({
selector: 'hlm-numbered-pagination-query-params',
imports: [
FormsModule,
HlmPagination,
HlmPaginationContent,
HlmPaginationItem,
HlmPaginationPrevious,
HlmPaginationNext,
HlmPaginationLink,
HlmPaginationEllipsis,
BrnSelectImports,
HlmSelectImports,
],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="flex items-center justify-between gap-2 px-4 py-2">
<div class="flex items-center gap-1 text-sm text-nowrap text-gray-600">
<b>{{ totalItems() }}</b>
total items |
<b>{{ _lastPageNumber() }}</b>
pages
</div>
<nav hlmPagination>
<ul hlmPaginationContent>
@if (showEdges() && !_isFirstPageActive()) {
<li hlmPaginationItem>
<hlm-pagination-previous
[link]="link()"
[queryParams]="{ page: currentPage() - 1 }"
queryParamsHandling="merge"
/>
</li>
}
@for (page of _pages(); track page) {
<li hlmPaginationItem>
@if (page === '...') {
<hlm-pagination-ellipsis />
} @else {
<a
hlmPaginationLink
[link]="currentPage() !== page ? link() : undefined"
[queryParams]="{ page }"
queryParamsHandling="merge"
[isActive]="currentPage() === page"
>
{{ page }}
</a>
}
</li>
}
@if (showEdges() && !_isLastPageActive()) {
<li hlmPaginationItem>
<hlm-pagination-next
[link]="link()"
[queryParams]="{ page: currentPage() + 1 }"
queryParamsHandling="merge"
/>
</li>
}
</ul>
</nav>
<!-- Show Page Size selector -->
<brn-select [(ngModel)]="itemsPerPage" class="ml-auto" placeholder="Page size">
<hlm-select-trigger class="w-fit">
<hlm-select-value />
</hlm-select-trigger>
<hlm-select-content>
@for (pageSize of _pageSizesWithCurrent(); track pageSize) {
<hlm-option [value]="pageSize">{{ pageSize }} / page</hlm-option>
}
</hlm-select-content>
</brn-select>
</div>
`,
})
export class HlmNumberedPaginationQueryParams {
/**
* The current (active) page.
*/
public readonly currentPage = model.required<number>();
/**
* The number of items per paginated page.
*/
public readonly itemsPerPage = model.required<number>();
/**
* The total number of items in the collection. Only useful when
* doing server-side paging, where the collection size is limited
* to a single page returned by the server API.
*/
public readonly totalItems = input.required<number, NumberInput>({
transform: numberAttribute,
});
/**
* The URL path to use for the pagination links.
* Defaults to '.' (current path).
*/
public readonly link = input<string>('.');
/**
* The number of page links to show.
*/
public readonly maxSize = input<number, NumberInput>(7, {
transform: numberAttribute,
});
/**
* Show the first and last page buttons.
*/
public readonly showEdges = input<boolean, BooleanInput>(true, {
transform: booleanAttribute,
});
/**
* The page sizes to show.
* Defaults to [10, 20, 50, 100]
*/
public readonly pageSizes = input<number[]>([10, 20, 50, 100]);
protected readonly _pageSizesWithCurrent = computed(() => {
const pageSizes = this.pageSizes();
return pageSizes.includes(this.itemsPerPage())
? pageSizes // if current page size is included, return the same array
: [...pageSizes, this.itemsPerPage()].sort((a, b) => a - b); // otherwise, add current page size and sort the array
});
protected readonly _isFirstPageActive = computed(() => this.currentPage() === 1);
protected readonly _isLastPageActive = computed(() => this.currentPage() === this._lastPageNumber());
protected readonly _lastPageNumber = computed(() => {
if (this.totalItems() < 1) {
// when there are 0 or fewer (an error case) items, there are no "pages" as such,
// but it makes sense to consider a single, empty page as the last page.
return 1;
}
return Math.ceil(this.totalItems() / this.itemsPerPage());
});
protected readonly _pages = computed(() => {
const correctedCurrentPage = outOfBoundCorrection(this.totalItems(), this.itemsPerPage(), this.currentPage());
if (correctedCurrentPage !== this.currentPage()) {
// update the current page
untracked(() => this.currentPage.set(correctedCurrentPage));
}
return createPageArray(correctedCurrentPage, this.itemsPerPage(), this.totalItems(), this.maxSize());
});
}

View File

@ -0,0 +1,268 @@
import type { BooleanInput, NumberInput } from '@angular/cdk/coercion';
import {
ChangeDetectionStrategy,
Component,
booleanAttribute,
computed,
input,
model,
numberAttribute,
untracked,
} from '@angular/core';
import { FormsModule } from '@angular/forms';
import { BrnSelectImports } from '@spartan-ng/brain/select';
import { HlmSelectImports } from '@spartan-ng/helm/select';
import { HlmPagination } from './hlm-pagination';
import { HlmPaginationContent } from './hlm-pagination-content';
import { HlmPaginationEllipsis } from './hlm-pagination-ellipsis';
import { HlmPaginationItem } from './hlm-pagination-item';
import { HlmPaginationLink } from './hlm-pagination-link';
import { HlmPaginationNext } from './hlm-pagination-next';
import { HlmPaginationPrevious } from './hlm-pagination-previous';
@Component({
selector: 'hlm-numbered-pagination',
imports: [
FormsModule,
HlmPagination,
HlmPaginationContent,
HlmPaginationItem,
HlmPaginationPrevious,
HlmPaginationNext,
HlmPaginationLink,
HlmPaginationEllipsis,
BrnSelectImports,
HlmSelectImports,
],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="flex items-center justify-between gap-2 px-4 py-2">
<div class="flex items-center gap-1 text-sm text-nowrap text-gray-600">
<b>{{ totalItems() }}</b>
total items |
<b>{{ _lastPageNumber() }}</b>
pages
</div>
<nav hlmPagination>
<ul hlmPaginationContent>
@if (showEdges() && !_isFirstPageActive()) {
<li hlmPaginationItem (click)="goToPrevious()">
<hlm-pagination-previous />
</li>
}
@for (page of _pages(); track page) {
<li hlmPaginationItem>
@if (page === '...') {
<hlm-pagination-ellipsis />
} @else {
<a hlmPaginationLink [isActive]="currentPage() === page" (click)="currentPage.set(page)">
{{ page }}
</a>
}
</li>
}
@if (showEdges() && !_isLastPageActive()) {
<li hlmPaginationItem (click)="goToNext()">
<hlm-pagination-next />
</li>
}
</ul>
</nav>
<!-- Show Page Size selector -->
<brn-select [(ngModel)]="itemsPerPage" class="ml-auto" placeholder="Page size">
<hlm-select-trigger class="w-fit">
<hlm-select-value />
</hlm-select-trigger>
<hlm-select-content>
@for (pageSize of _pageSizesWithCurrent(); track pageSize) {
<hlm-option [value]="pageSize">{{ pageSize }} / page</hlm-option>
}
</hlm-select-content>
</brn-select>
</div>
`,
})
export class HlmNumberedPagination {
/**
* The current (active) page.
*/
public readonly currentPage = model.required<number>();
/**
* The number of items per paginated page.
*/
public readonly itemsPerPage = model.required<number>();
/**
* The total number of items in the collection. Only useful when
* doing server-side paging, where the collection size is limited
* to a single page returned by the server API.
*/
public readonly totalItems = input.required<number, NumberInput>({
transform: numberAttribute,
});
/**
* The number of page links to show.
*/
public readonly maxSize = input<number, NumberInput>(7, {
transform: numberAttribute,
});
/**
* Show the first and last page buttons.
*/
public readonly showEdges = input<boolean, BooleanInput>(true, {
transform: booleanAttribute,
});
/**
* The page sizes to show.
* Defaults to [10, 20, 50, 100]
*/
public readonly pageSizes = input<number[]>([10, 20, 50, 100]);
protected readonly _pageSizesWithCurrent = computed(() => {
const pageSizes = this.pageSizes();
return pageSizes.includes(this.itemsPerPage())
? pageSizes // if current page size is included, return the same array
: [...pageSizes, this.itemsPerPage()].sort((a, b) => a - b); // otherwise, add current page size and sort the array
});
protected readonly _isFirstPageActive = computed(() => this.currentPage() === 1);
protected readonly _isLastPageActive = computed(() => this.currentPage() === this._lastPageNumber());
protected readonly _lastPageNumber = computed(() => {
if (this.totalItems() < 1) {
// when there are 0 or fewer (an error case) items, there are no "pages" as such,
// but it makes sense to consider a single, empty page as the last page.
return 1;
}
return Math.ceil(this.totalItems() / this.itemsPerPage());
});
protected readonly _pages = computed(() => {
const correctedCurrentPage = outOfBoundCorrection(this.totalItems(), this.itemsPerPage(), this.currentPage());
if (correctedCurrentPage !== this.currentPage()) {
// update the current page
untracked(() => this.currentPage.set(correctedCurrentPage));
}
return createPageArray(correctedCurrentPage, this.itemsPerPage(), this.totalItems(), this.maxSize());
});
protected goToPrevious(): void {
this.currentPage.set(this.currentPage() - 1);
}
protected goToNext(): void {
this.currentPage.set(this.currentPage() + 1);
}
protected goToFirst(): void {
this.currentPage.set(1);
}
protected goToLast(): void {
this.currentPage.set(this._lastPageNumber());
}
}
type Page = number | '...';
/**
* Checks that the instance.currentPage property is within bounds for the current page range.
* If not, return a correct value for currentPage, or the current value if OK.
*
* Copied from 'ngx-pagination' package
*/
export function outOfBoundCorrection(totalItems: number, itemsPerPage: number, currentPage: number): number {
const totalPages = Math.ceil(totalItems / itemsPerPage);
if (totalPages < currentPage && 0 < totalPages) {
return totalPages;
}
if (currentPage < 1) {
return 1;
}
return currentPage;
}
/**
* Returns an array of Page objects to use in the pagination controls.
*
* Copied from 'ngx-pagination' package
*/
export function createPageArray(
currentPage: number,
itemsPerPage: number,
totalItems: number,
paginationRange: number,
): Page[] {
// paginationRange could be a string if passed from attribute, so cast to number.
paginationRange = +paginationRange;
const pages: Page[] = [];
// Return 1 as default page number
// Make sense to show 1 instead of empty when there are no items
const totalPages = Math.max(Math.ceil(totalItems / itemsPerPage), 1);
const halfWay = Math.ceil(paginationRange / 2);
const isStart = currentPage <= halfWay;
const isEnd = totalPages - halfWay < currentPage;
const isMiddle = !isStart && !isEnd;
const ellipsesNeeded = paginationRange < totalPages;
let i = 1;
while (i <= totalPages && i <= paginationRange) {
let label: number | '...';
const pageNumber = calculatePageNumber(i, currentPage, paginationRange, totalPages);
const openingEllipsesNeeded = i === 2 && (isMiddle || isEnd);
const closingEllipsesNeeded = i === paginationRange - 1 && (isMiddle || isStart);
if (ellipsesNeeded && (openingEllipsesNeeded || closingEllipsesNeeded)) {
label = '...';
} else {
label = pageNumber;
}
pages.push(label);
i++;
}
return pages;
}
/**
* Given the position in the sequence of pagination links [i],
* figure out what page number corresponds to that position.
*
* Copied from 'ngx-pagination' package
*/
function calculatePageNumber(i: number, currentPage: number, paginationRange: number, totalPages: number) {
const halfWay = Math.ceil(paginationRange / 2);
if (i === paginationRange) {
return totalPages;
}
if (i === 1) {
return i;
}
if (paginationRange < totalPages) {
if (totalPages - halfWay < currentPage) {
return totalPages - paginationRange + i;
}
if (halfWay < currentPage) {
return currentPage - halfWay + i;
}
return i;
}
return i;
}

View File

@ -0,0 +1,21 @@
import { Directive, computed, input } from '@angular/core';
import { hlm } from '@spartan-ng/helm/utils';
import { type VariantProps, cva } from 'class-variance-authority';
import type { ClassValue } from 'clsx';
export const paginationContentVariants = cva('flex flex-row items-center gap-1', {
variants: {},
defaultVariants: {},
});
export type PaginationContentVariants = VariantProps<typeof paginationContentVariants>;
@Directive({
selector: '[hlmPaginationContent]',
host: {
'[class]': '_computedClass()',
},
})
export class HlmPaginationContent {
public readonly userClass = input<ClassValue>('', { alias: 'class' });
protected readonly _computedClass = computed(() => hlm(paginationContentVariants(), this.userClass()));
}

View File

@ -0,0 +1,26 @@
import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { lucideEllipsis } from '@ng-icons/lucide';
import { HlmIcon } from '@spartan-ng/helm/icon';
import { hlm } from '@spartan-ng/helm/utils';
import type { ClassValue } from 'clsx';
@Component({
selector: 'hlm-pagination-ellipsis',
imports: [NgIcon, HlmIcon],
providers: [provideIcons({ lucideEllipsis })],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<span [class]="_computedClass()">
<ng-icon hlm size="sm" name="lucideEllipsis" />
<span class="sr-only">{{ srOnlyText() }}</span>
</span>
`,
})
export class HlmPaginationEllipsis {
public readonly userClass = input<ClassValue>('', { alias: 'class' });
/** Screen reader only text for the ellipsis */
public readonly srOnlyText = input<string>('More pages');
protected readonly _computedClass = computed(() => hlm('flex size-9 items-center justify-center', this.userClass()));
}

View File

@ -0,0 +1,22 @@
import { Directive, computed, input } from '@angular/core';
import { hlm } from '@spartan-ng/helm/utils';
import { type VariantProps, cva } from 'class-variance-authority';
import type { ClassValue } from 'clsx';
export const paginationItemVariants = cva('', {
variants: {},
defaultVariants: {},
});
export type PaginationItemVariants = VariantProps<typeof paginationItemVariants>;
@Directive({
selector: '[hlmPaginationItem]',
host: {
'[class]': '_computedClass()',
},
})
export class HlmPaginationItem {
public readonly userClass = input<ClassValue>('', { alias: 'class' });
protected readonly _computedClass = computed(() => hlm(paginationItemVariants(), this.userClass()));
}

View File

@ -0,0 +1,60 @@
import type { BooleanInput } from '@angular/cdk/coercion';
import { Directive, booleanAttribute, computed, input } from '@angular/core';
import { RouterLink } from '@angular/router';
import { type ButtonVariants, buttonVariants } from '@spartan-ng/helm/button';
import { hlm } from '@spartan-ng/helm/utils';
import { type VariantProps, cva } from 'class-variance-authority';
import type { ClassValue } from 'clsx';
export const paginationLinkVariants = cva('', {
variants: {},
defaultVariants: {},
});
export type PaginationLinkVariants = VariantProps<typeof paginationLinkVariants>;
@Directive({
selector: '[hlmPaginationLink]',
hostDirectives: [
{
directive: RouterLink,
inputs: [
'target',
'queryParams',
'fragment',
'queryParamsHandling',
'state',
'info',
'relativeTo',
'preserveFragment',
'skipLocationChange',
'replaceUrl',
'routerLink: link',
],
},
],
host: {
'[class]': '_computedClass()',
'[attr.aria-current]': 'isActive() ? "page" : null',
},
})
export class HlmPaginationLink {
public readonly userClass = input<ClassValue>('', { alias: 'class' });
/** Whether the link is active (i.e., the current page). */
public readonly isActive = input<boolean, BooleanInput>(false, { transform: booleanAttribute });
/** The size of the button. */
public readonly size = input<ButtonVariants['size']>('icon');
/** The link to navigate to the page. */
public readonly link = input<RouterLink['routerLink']>();
protected readonly _computedClass = computed(() =>
hlm(
paginationLinkVariants(),
this.link() === undefined ? 'cursor-pointer' : '',
buttonVariants({
variant: this.isActive() ? 'outline' : 'ghost',
size: this.size(),
}),
this.userClass(),
),
);
}

View File

@ -0,0 +1,56 @@
import type { BooleanInput } from '@angular/cdk/coercion';
import { booleanAttribute, ChangeDetectionStrategy, Component, computed, input } from '@angular/core';
import type { RouterLink } from '@angular/router';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { lucideChevronRight } from '@ng-icons/lucide';
import type { ButtonVariants } from '@spartan-ng/helm/button';
import { HlmIcon } from '@spartan-ng/helm/icon';
import { hlm } from '@spartan-ng/helm/utils';
import type { ClassValue } from 'clsx';
import { HlmPaginationLink } from './hlm-pagination-link';
@Component({
selector: 'hlm-pagination-next',
imports: [HlmPaginationLink, NgIcon, HlmIcon],
providers: [provideIcons({ lucideChevronRight })],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<a
[class]="_computedClass()"
hlmPaginationLink
[link]="link()"
[queryParams]="queryParams()"
[queryParamsHandling]="queryParamsHandling()"
[size]="_size()"
[attr.aria-label]="ariaLabel()"
>
<span [class]="_labelClass()">{{ text() }}</span>
<ng-icon hlm size="sm" name="lucideChevronRight" />
</a>
`,
})
export class HlmPaginationNext {
public readonly userClass = input<ClassValue>('', { alias: 'class' });
/** The link to navigate to the next page. */
public readonly link = input<RouterLink['routerLink']>();
/** The query parameters to pass to the next page. */
public readonly queryParams = input<RouterLink['queryParams']>();
/** How to handle query parameters when navigating to the next page. */
public readonly queryParamsHandling = input<RouterLink['queryParamsHandling']>();
/** The aria-label for the next page link. */
public readonly ariaLabel = input<string>('Go to next page', { alias: 'aria-label' });
/** The text to display for the next page link. */
public readonly text = input<string>('Next');
/** Whether the button should only display the icon. */
public readonly iconOnly = input<boolean, BooleanInput>(false, {
transform: booleanAttribute,
});
protected readonly _labelClass = computed(() => (this.iconOnly() ? 'sr-only' : 'hidden sm:block'));
protected readonly _size = computed<ButtonVariants['size']>(() => (this.iconOnly() ? 'icon' : 'default'));
protected readonly _computedClass = computed(() =>
hlm('gap-1', !this.iconOnly() ? 'sm:pr-2.5' : '', this.userClass()),
);
}

View File

@ -0,0 +1,56 @@
import type { BooleanInput } from '@angular/cdk/coercion';
import { booleanAttribute, ChangeDetectionStrategy, Component, computed, input } from '@angular/core';
import type { RouterLink } from '@angular/router';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { lucideChevronLeft } from '@ng-icons/lucide';
import type { ButtonVariants } from '@spartan-ng/helm/button';
import { HlmIcon } from '@spartan-ng/helm/icon';
import { hlm } from '@spartan-ng/helm/utils';
import type { ClassValue } from 'clsx';
import { HlmPaginationLink } from './hlm-pagination-link';
@Component({
selector: 'hlm-pagination-previous',
imports: [HlmPaginationLink, NgIcon, HlmIcon],
providers: [provideIcons({ lucideChevronLeft })],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<a
[class]="_computedClass()"
hlmPaginationLink
[link]="link()"
[queryParams]="queryParams()"
[queryParamsHandling]="queryParamsHandling()"
[size]="_size()"
[attr.aria-label]="ariaLabel()"
>
<ng-icon hlm size="sm" name="lucideChevronLeft" />
<span [class]="_labelClass()">{{ text() }}</span>
</a>
`,
})
export class HlmPaginationPrevious {
public readonly userClass = input<ClassValue>('', { alias: 'class' });
/** The link to navigate to the previous page. */
public readonly link = input<RouterLink['routerLink']>();
/** The query parameters to pass to the previous page. */
public readonly queryParams = input<RouterLink['queryParams']>();
/** How to handle query parameters when navigating to the previous page. */
public readonly queryParamsHandling = input<RouterLink['queryParamsHandling']>();
/** The aria-label for the previous page link. */
public readonly ariaLabel = input<string>('Go to previous page', { alias: 'aria-label' });
/** The text to display for the previous page link. */
public readonly text = input<string>('Previous');
/** Whether the button should only display the icon. */
public readonly iconOnly = input<boolean, BooleanInput>(false, {
transform: booleanAttribute,
});
protected readonly _labelClass = computed(() => (this.iconOnly() ? 'sr-only' : 'hidden sm:block'));
protected readonly _size = computed<ButtonVariants['size']>(() => (this.iconOnly() ? 'icon' : 'default'));
protected readonly _computedClass = computed(() =>
hlm('gap-1', !this.iconOnly() ? 'sm:pl-2.5' : '', this.userClass()),
);
}

View File

@ -0,0 +1,27 @@
import { Directive, computed, input } from '@angular/core';
import { hlm } from '@spartan-ng/helm/utils';
import { type VariantProps, cva } from 'class-variance-authority';
import type { ClassValue } from 'clsx';
export const paginationVariants = cva('mx-auto flex w-full justify-center', {
variants: {},
defaultVariants: {},
});
export type PaginationVariants = VariantProps<typeof paginationVariants>;
@Directive({
selector: '[hlmPagination]',
host: {
role: 'navigation',
'[class]': '_computedClass()',
'[attr.aria-label]': 'ariaLabel()',
},
})
export class HlmPagination {
public readonly userClass = input<ClassValue>('', { alias: 'class' });
/** The aria-label for the pagination component. */
public readonly ariaLabel = input<string>('pagination', { alias: 'aria-label' });
protected readonly _computedClass = computed(() => hlm(paginationVariants(), this.userClass()));
}

View File

@ -0,0 +1,7 @@
import { HlmPopoverClose } from './lib/hlm-popover-close';
import { HlmPopoverContent } from './lib/hlm-popover-content';
export * from './lib/hlm-popover-close';
export * from './lib/hlm-popover-content';
export const HlmPopoverImports = [HlmPopoverContent, HlmPopoverClose] as const;

View File

@ -0,0 +1,19 @@
import { Directive, computed, input } from '@angular/core';
import { hlm } from '@spartan-ng/helm/utils';
import type { ClassValue } from 'clsx';
@Directive({
selector: '[hlmPopoverClose],[brnPopoverClose][hlm]',
host: {
'[class]': '_computedClass()',
},
})
export class HlmPopoverClose {
public readonly userClass = input<ClassValue>('', { alias: 'class' });
protected readonly _computedClass = computed(() =>
hlm(
'ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-none disabled:pointer-events-none',
this.userClass(),
),
);
}

View File

@ -0,0 +1,31 @@
import { Directive, ElementRef, Renderer2, computed, effect, inject, input, signal } from '@angular/core';
import { injectExposesStateProvider } from '@spartan-ng/brain/core';
import { hlm } from '@spartan-ng/helm/utils';
import type { ClassValue } from 'clsx';
@Directive({
selector: '[hlmPopoverContent],[brnPopoverContent][hlm]',
host: {
'[class]': '_computedClass()',
},
})
export class HlmPopoverContent {
private readonly _stateProvider = injectExposesStateProvider({ host: true });
public state = this._stateProvider.state ?? signal('closed');
private readonly _renderer = inject(Renderer2);
private readonly _element = inject(ElementRef);
constructor() {
effect(() => {
this._renderer.setAttribute(this._element.nativeElement, 'data-state', this.state());
});
}
public readonly userClass = input<ClassValue>('', { alias: 'class' });
protected readonly _computedClass = computed(() =>
hlm(
'border-border bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative w-72 rounded-md border p-4 shadow-md outline-none',
this.userClass(),
),
);
}

View File

@ -0,0 +1,5 @@
import { HlmScrollArea } from './lib/hlm-scroll-area';
export * from './lib/hlm-scroll-area';
export const HlmScrollAreaImports = [HlmScrollArea] as const;

View File

@ -0,0 +1,19 @@
import { Directive, computed, input } from '@angular/core';
import { hlm } from '@spartan-ng/helm/utils';
import type { ClassValue } from 'clsx';
@Directive({
selector: 'ng-scrollbar[hlm]',
host: {
'[class]': '_computedClass()',
'[style.--scrollbar-border-radius.px]': '100',
'[style.--scrollbar-offset]': '3',
'[style.--scrollbar-thumb-color]': '"hsl(var(--border))"',
'[style.--scrollbar-thumb-hover-color]': '"hsl(var(--border))"',
'[style.--scrollbar-thickness]': '7',
},
})
export class HlmScrollArea {
protected readonly _computedClass = computed(() => hlm('block', this.userClass()));
public readonly userClass = input<ClassValue>('', { alias: 'class' });
}

Some files were not shown because too many files have changed in this diff Show More