This commit is contained in:
Marcel Arndt 2026-01-19 12:21:48 +01:00
parent 62e663d053
commit d448ca6516
290 changed files with 50594 additions and 51540 deletions

4
.gitignore vendored
View File

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

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 # - signoz-infra
# - leantime # - leantime
- kestra - kestra
- hyperdx # - hyperdx

View File

@ -28,6 +28,18 @@
run_once: true run_once: true
delegate_to: "{{ groups['managers'][0] }}" 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 - name: HYPERDX | Generate Compose file
ansible.builtin.template: ansible.builtin.template:
src: docker-compose.yml.j2 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 WORKDIR /app/builder
COPY . . 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 install \ ENV LIBC=glibc
&& npm run build 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 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 FROM nginx:1.19.2
WORKDIR /usr/share/nginx/html WORKDIR /usr/share/nginx/html
COPY --from=builder /app/builder/dist/dashboard/browser ./ COPY --from=builder /app/builder/app-hub/dashboard/dist/dashboard/browser ./
COPY --from=builder /app/builder/nginx.conf /etc/nginx/nginx.conf COPY --from=builder /app/builder/app-hub/dashboard/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/docker-entrypoint.sh /docker-entrypoint.sh
ENTRYPOINT ["/docker-entrypoint.sh"] ENTRYPOINT ["/docker-entrypoint.sh"]

File diff suppressed because it is too large Load Diff

View File

@ -23,6 +23,7 @@
"@angular/platform-browser-dynamic": "^19.2.6", "@angular/platform-browser-dynamic": "^19.2.6",
"@angular/router": "^19.2.6", "@angular/router": "^19.2.6",
"@apollo/client": "^3.13.8", "@apollo/client": "^3.13.8",
"@avicenna/shared-dtos": "^1.0.0",
"@azure/msal-angular": "^3.0.22", "@azure/msal-angular": "^3.0.22",
"@azure/msal-browser": "^3.19.1", "@azure/msal-browser": "^3.19.1",
"@hyperdx/browser": "^0.22.0", "@hyperdx/browser": "^0.22.0",
@ -43,6 +44,7 @@
"@opentelemetry/sdk-trace-web": "^1.30.1", "@opentelemetry/sdk-trace-web": "^1.30.1",
"@opentelemetry/semantic-conventions": "^1.34.0", "@opentelemetry/semantic-conventions": "^1.34.0",
"@spartan-ng/brain": "^0.0.1-alpha.561", "@spartan-ng/brain": "^0.0.1-alpha.561",
"@tanstack/angular-query-experimental": "^5.90.20",
"apollo-angular": "^10.0.3", "apollo-angular": "^10.0.3",
"class-variance-authority": "^0.7.0", "class-variance-authority": "^0.7.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
@ -50,6 +52,7 @@
"echarts": "^5.5.1", "echarts": "^5.5.1",
"graphql": "^16.11.0", "graphql": "^16.11.0",
"graphql-ws": "^5.16.0", "graphql-ws": "^5.16.0",
"ngx-scrollbar": "^19.1.1",
"rxjs": "~7.8.0", "rxjs": "~7.8.0",
"tailwind-merge": "^3.3.1", "tailwind-merge": "^3.3.1",
"tslib": "^2.3.0", "tslib": "^2.3.0",
@ -76,6 +79,7 @@
"karma-jasmine": "~5.1.0", "karma-jasmine": "~5.1.0",
"karma-jasmine-html-reporter": "~2.1.0", "karma-jasmine-html-reporter": "~2.1.0",
"postcss": "^8.4.39", "postcss": "^8.4.39",
"prettier": "^3.8.0",
"tailwindcss": "^4.1.4", "tailwindcss": "^4.1.4",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
"typescript": "~5.5.2" "typescript": "~5.5.2"

View File

@ -9,12 +9,38 @@ import { provideRouter, withComponentInputBinding } from '@angular/router';
import { provideHttpClient } from '@angular/common/http'; import { provideHttpClient } from '@angular/common/http';
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async'; import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
import { provideHotToastConfig } from '@ngxpert/hot-toast'; 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 { environment } from '../environments/environment';
import { routes } from './app.routes'; import { routes } from './app.routes';
import { MsAuthenticationModule } from './core/components/ms-authentication/ms-authentication.module'; import { MsAuthenticationModule } from './core/components/ms-authentication/ms-authentication.module';
import { GlobalErrorHandler } from './core/telemetry/global-error-handler'; import { GlobalErrorHandler } from './core/telemetry/global-error-handler';
import { graphqlProvider } from './graphql.provider'; 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 = { export const appConfig: ApplicationConfig = {
providers: [ providers: [
{ {
@ -32,6 +58,7 @@ export const appConfig: ApplicationConfig = {
postLogoutRedirectUri: environment.msAuth.postLogoutRedirectUri, postLogoutRedirectUri: environment.msAuth.postLogoutRedirectUri,
}) })
), ),
provideTanStackQuery(queryClient, withDevtools()),
provideHttpClient(), provideHttpClient(),
graphqlProvider, graphqlProvider,
provideHotToastConfig(), provideHotToastConfig(),

View File

@ -53,7 +53,7 @@ export const routes: Routes = [
{ {
path: 'tickets', path: 'tickets',
loadChildren: () => loadChildren: () =>
import('./pages/ticket-system/ticket-system.module').then( import('./pages/tickets/ticket-system.module').then(
(m) => m.TicketSystemModule (m) => m.TicketSystemModule
), ),
canActivate: [MsalGuard], canActivate: [MsalGuard],

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-6', 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-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: '[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-6 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,38 @@
import type { ComponentType } from '@angular/cdk/portal';
import { Injectable, type TemplateRef, inject } from '@angular/core';
import {
type BrnDialogOptions,
BrnDialogService,
DEFAULT_BRN_DIALOG_OPTIONS,
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 = {
...DEFAULT_BRN_DIALOG_OPTIONS,
...(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' });
}

View File

@ -0,0 +1,31 @@
import { HlmSelect } from './lib/hlm-select';
import { HlmSelectContent } from './lib/hlm-select-content';
import { HlmSelectGroup } from './lib/hlm-select-group';
import { HlmSelectLabel } from './lib/hlm-select-label';
import { HlmSelectOption } from './lib/hlm-select-option';
import { HlmSelectScrollDown } from './lib/hlm-select-scroll-down';
import { HlmSelectScrollUp } from './lib/hlm-select-scroll-up';
import { HlmSelectTrigger } from './lib/hlm-select-trigger';
import { HlmSelectValue } from './lib/hlm-select-value';
export * from './lib/hlm-select';
export * from './lib/hlm-select-content';
export * from './lib/hlm-select-group';
export * from './lib/hlm-select-label';
export * from './lib/hlm-select-option';
export * from './lib/hlm-select-scroll-down';
export * from './lib/hlm-select-scroll-up';
export * from './lib/hlm-select-trigger';
export * from './lib/hlm-select-value';
export const HlmSelectImports = [
HlmSelectContent,
HlmSelectTrigger,
HlmSelectOption,
HlmSelectValue,
HlmSelect,
HlmSelectScrollUp,
HlmSelectScrollDown,
HlmSelectLabel,
HlmSelectGroup,
] as const;

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