commit working state
Too many changes to split them up. This commit contains roughly two months worth of work. This commit introduces the first aggregate and rewrites the ticket system to follow DDD patterns. It adds Spartan NG as the new component library. TanStack Query to replace GraphQL. Oh and Prisma is almost over as well.
This commit is contained in:
parent
62e663d053
commit
ce676f20a4
|
|
@ -2,4 +2,9 @@
|
|||
.vscode/
|
||||
|
||||
.env
|
||||
.env.prod
|
||||
.env.stage
|
||||
.DS_Store
|
||||
|
||||
node_modules/
|
||||
dist/
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
- name: Infrastruktur Dienste bereitstellen
|
||||
hosts: all
|
||||
gather_facts: true
|
||||
roles:
|
||||
# - traefik
|
||||
# - portainer
|
||||
# - hyperdx
|
||||
- kestra
|
||||
# - authentik
|
||||
# # - dockge
|
||||
# - signoz
|
||||
# - signoz-infra
|
||||
# - leantime
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
[client.admin]
|
||||
key = AQDfUDBpnhHKNBAAwIRER17JF8flD9GSeNtFlg==
|
||||
caps mds = "allow *"
|
||||
caps mgr = "allow *"
|
||||
caps mon = "allow *"
|
||||
caps osd = "allow *"
|
||||
|
|
@ -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]
|
||||
|
|
@ -45,4 +45,4 @@
|
|||
# - signoz-infra
|
||||
# - leantime
|
||||
- kestra
|
||||
- hyperdx
|
||||
# - hyperdx
|
||||
|
|
@ -28,6 +28,18 @@
|
|||
run_once: true
|
||||
delegate_to: "{{ groups['managers'][0] }}"
|
||||
|
||||
- name: HYPERDX | shared-observability Netzwerk erstellen
|
||||
community.docker.docker_network:
|
||||
name: shared-observability
|
||||
driver: overlay
|
||||
state: present
|
||||
attachable: yes
|
||||
ipam_config:
|
||||
- subnet: '172.16.201.0/24'
|
||||
gateway: '172.16.201.1'
|
||||
run_once: true
|
||||
delegate_to: "{{ groups['managers'][0] }}"
|
||||
|
||||
- name: HYPERDX | Generate Compose file
|
||||
ansible.builtin.template:
|
||||
src: docker-compose.yml.j2
|
||||
|
|
|
|||
|
|
@ -0,0 +1,5 @@
|
|||
node_modules
|
||||
dist
|
||||
.git
|
||||
.local
|
||||
.vscode
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
|
||||
.angular
|
||||
.vscode
|
||||
dist
|
||||
node_modules
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"overrides": [
|
||||
{
|
||||
"files": "*.html",
|
||||
"options": {
|
||||
"parser": "angular"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -1,18 +1,37 @@
|
|||
FROM node:20.19.1-alpine AS builder
|
||||
FROM node:20.19.1-slim AS builder
|
||||
|
||||
WORKDIR /app/builder
|
||||
|
||||
COPY . .
|
||||
ENV LIBC=glibc
|
||||
ENV npm_config_arch=x64
|
||||
ENV npm_config_platform=linux
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
python3 \
|
||||
make \
|
||||
g++ \
|
||||
ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN npm install \
|
||||
&& npm run build
|
||||
COPY package*.json .
|
||||
COPY packages/shared-dtos/package*.json ./packages/shared-dtos/
|
||||
# COPY data-hub/data-connector/package*.json ./data-hub/data-connector/
|
||||
COPY app-hub/dashboard/package*.json ./app-hub/dashboard/
|
||||
|
||||
RUN npm ci --verbose
|
||||
|
||||
COPY packages/shared-dtos ./packages/shared-dtos/
|
||||
COPY app-hub/dashboard ./app-hub/dashboard/
|
||||
|
||||
WORKDIR /app/builder/app-hub/dashboard
|
||||
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:1.19.2
|
||||
|
||||
WORKDIR /usr/share/nginx/html
|
||||
COPY --from=builder /app/builder/dist/dashboard/browser ./
|
||||
COPY --from=builder /app/builder/nginx.conf /etc/nginx/nginx.conf
|
||||
COPY --from=builder /app/builder/docker-entrypoint.sh /docker-entrypoint.sh
|
||||
COPY --from=builder /app/builder/app-hub/dashboard/dist/dashboard/browser ./
|
||||
COPY --from=builder /app/builder/app-hub/dashboard/nginx.conf /etc/nginx/nginx.conf
|
||||
COPY --from=builder /app/builder/app-hub/dashboard/docker-entrypoint.sh /docker-entrypoint.sh
|
||||
|
||||
ENTRYPOINT ["/docker-entrypoint.sh"]
|
||||
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@
|
|||
},
|
||||
"architect": {
|
||||
"build": {
|
||||
"builder": "@angular-devkit/build-angular:application",
|
||||
"builder": "@angular/build:application",
|
||||
"options": {
|
||||
"outputPath": "dist/dashboard",
|
||||
"index": "src/index.html",
|
||||
|
|
@ -37,7 +37,7 @@
|
|||
],
|
||||
"styles": [
|
||||
"src/m3-theme.scss",
|
||||
"node_modules/@ngxpert/hot-toast/src/styles/styles.css",
|
||||
"../../node_modules/@ngxpert/hot-toast/src/styles/styles.css",
|
||||
"src/tailwind.css",
|
||||
"src/styles.scss"
|
||||
],
|
||||
|
|
@ -49,7 +49,7 @@
|
|||
{
|
||||
"type": "initial",
|
||||
"maximumWarning": "500kB",
|
||||
"maximumError": "2MB"
|
||||
"maximumError": "2.5MB"
|
||||
},
|
||||
{
|
||||
"type": "anyComponentStyle",
|
||||
|
|
@ -80,7 +80,7 @@
|
|||
"defaultConfiguration": "production"
|
||||
},
|
||||
"serve": {
|
||||
"builder": "@angular-devkit/build-angular:dev-server",
|
||||
"builder": "@angular/build:dev-server",
|
||||
"configurations": {
|
||||
"production": {
|
||||
"buildTarget": "dashboard:build:production"
|
||||
|
|
@ -92,10 +92,10 @@
|
|||
"defaultConfiguration": "development"
|
||||
},
|
||||
"extract-i18n": {
|
||||
"builder": "@angular-devkit/build-angular:extract-i18n"
|
||||
"builder": "@angular/build:extract-i18n"
|
||||
},
|
||||
"test": {
|
||||
"builder": "@angular-devkit/build-angular:karma",
|
||||
"builder": "@angular/build:karma",
|
||||
"options": {
|
||||
"polyfills": [
|
||||
"zone.js",
|
||||
|
|
@ -119,5 +119,31 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"schematics": {
|
||||
"@schematics/angular:component": {
|
||||
"type": "component"
|
||||
},
|
||||
"@schematics/angular:directive": {
|
||||
"type": "directive"
|
||||
},
|
||||
"@schematics/angular:service": {
|
||||
"type": "service"
|
||||
},
|
||||
"@schematics/angular:guard": {
|
||||
"typeSeparator": "."
|
||||
},
|
||||
"@schematics/angular:interceptor": {
|
||||
"typeSeparator": "."
|
||||
},
|
||||
"@schematics/angular:module": {
|
||||
"typeSeparator": "."
|
||||
},
|
||||
"@schematics/angular:pipe": {
|
||||
"typeSeparator": "."
|
||||
},
|
||||
"@schematics/angular:resolver": {
|
||||
"typeSeparator": "."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -11,26 +11,26 @@
|
|||
},
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@angular/animations": "^19.2.6",
|
||||
"@angular/cdk": "^19.2.9",
|
||||
"@angular/common": "^19.2.6",
|
||||
"@angular/compiler": "^19.2.6",
|
||||
"@angular/core": "^19.2.6",
|
||||
"@angular/forms": "^19.2.6",
|
||||
"@angular/material": "^19.2.9",
|
||||
"@angular/material-date-fns-adapter": "^19.2.9",
|
||||
"@angular/platform-browser": "^19.2.6",
|
||||
"@angular/platform-browser-dynamic": "^19.2.6",
|
||||
"@angular/router": "^19.2.6",
|
||||
"@apollo/client": "^3.13.8",
|
||||
"@angular/animations": "^21.1.0",
|
||||
"@angular/cdk": "^21.1.0",
|
||||
"@angular/common": "^21.1.0",
|
||||
"@angular/compiler": "^21.1.0",
|
||||
"@angular/core": "^21.1.0",
|
||||
"@angular/forms": "^21.1.0",
|
||||
"@angular/material": "^21.1.0",
|
||||
"@angular/material-date-fns-adapter": "^21.1.0",
|
||||
"@angular/platform-browser": "^21.1.0",
|
||||
"@angular/platform-browser-dynamic": "^21.1.0",
|
||||
"@angular/router": "^21.1.0",
|
||||
"@apollo/client": "^4.1.0",
|
||||
"@azure/msal-angular": "^3.0.22",
|
||||
"@azure/msal-browser": "^3.19.1",
|
||||
"@hyperdx/browser": "^0.22.0",
|
||||
"@ng-icons/core": "^31.4.0",
|
||||
"@ng-icons/lucide": "^31.4.0",
|
||||
"@ng-icons/core": "^33.0.0",
|
||||
"@ng-icons/lucide": "^33.0.0",
|
||||
"@ngneat/dialog": "^5.1.1",
|
||||
"@ngneat/overview": "^6.0.0",
|
||||
"@ngxpert/hot-toast": "^3.0.0",
|
||||
"@ngneat/overview": "^7.0.0",
|
||||
"@ngxpert/hot-toast": "^6.0.0",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@opentelemetry/auto-instrumentations-web": "^0.45.1",
|
||||
"@opentelemetry/context-zone": "^1.30.1",
|
||||
|
|
@ -42,30 +42,32 @@
|
|||
"@opentelemetry/resources": "^1.30.1",
|
||||
"@opentelemetry/sdk-trace-web": "^1.30.1",
|
||||
"@opentelemetry/semantic-conventions": "^1.34.0",
|
||||
"@spartan-ng/brain": "^0.0.1-alpha.561",
|
||||
"apollo-angular": "^10.0.3",
|
||||
"@spartan-ng/brain": "0.0.1-alpha.605",
|
||||
"@tanstack/angular-query-experimental": "^5.90.20",
|
||||
"apollo-angular": "^13.0.0",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^3.6.0",
|
||||
"echarts": "^5.5.1",
|
||||
"graphql": "^16.11.0",
|
||||
"graphql": "^16.12.0",
|
||||
"graphql-ws": "^5.16.0",
|
||||
"ngx-scrollbar": "^19.1.1",
|
||||
"rxjs": "~7.8.0",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"tslib": "^2.3.0",
|
||||
"zone.js": "~0.15.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@angular-devkit/build-angular": "^19.2.7",
|
||||
"@angular/cli": "^19.2.7",
|
||||
"@angular/compiler-cli": "^19.2.6",
|
||||
"@angular/localize": "^19.2.6",
|
||||
"@angular/build": "^21.1.0",
|
||||
"@angular/cli": "^21.1.0",
|
||||
"@angular/compiler-cli": "^21.1.0",
|
||||
"@angular/localize": "^21.1.0",
|
||||
"@graphql-codegen/cli": "^5.0.5",
|
||||
"@graphql-codegen/typescript": "^4.1.6",
|
||||
"@graphql-codegen/typescript-apollo-angular": "^4.0.0",
|
||||
"@graphql-codegen/typescript-operations": "^4.6.0",
|
||||
"@material/material-color-utilities": "^0.3.0",
|
||||
"@spartan-ng/cli": "^0.0.1-alpha.561",
|
||||
"@spartan-ng/cli": "0.0.1-alpha.605",
|
||||
"@tailwindcss/postcss": "^4.1.4",
|
||||
"@types/jasmine": "~5.1.0",
|
||||
"autoprefixer": "^10.4.19",
|
||||
|
|
@ -75,9 +77,10 @@
|
|||
"karma-coverage": "~2.2.0",
|
||||
"karma-jasmine": "~5.1.0",
|
||||
"karma-jasmine-html-reporter": "~2.1.0",
|
||||
"postcss": "^8.4.39",
|
||||
"postcss": "^8.5.3",
|
||||
"prettier": "^3.8.0",
|
||||
"tailwindcss": "^4.1.4",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "~5.5.2"
|
||||
"typescript": "~5.9.3"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -9,18 +9,39 @@ import { provideRouter, withComponentInputBinding } from '@angular/router';
|
|||
import { provideHttpClient } from '@angular/common/http';
|
||||
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
|
||||
import { provideHotToastConfig } from '@ngxpert/hot-toast';
|
||||
import {
|
||||
provideTanStackQuery,
|
||||
QueryClient,
|
||||
} from '@tanstack/angular-query-experimental';
|
||||
|
||||
import { withDevtools } from '@tanstack/angular-query-experimental/devtools';
|
||||
import { environment } from '../environments/environment';
|
||||
import { routes } from './app.routes';
|
||||
import { MsAuthenticationModule } from './core/components/ms-authentication/ms-authentication.module';
|
||||
import { GlobalErrorHandler } from './core/telemetry/global-error-handler';
|
||||
import { graphqlProvider } from './graphql.provider';
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
// Daten gelten für 2 Minute als "frisch" (kein Refetch)
|
||||
staleTime: 2000 * 60 * 1,
|
||||
retry: 2,
|
||||
refetchOnWindowFocus: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__TANSTACK_QUERY_CLIENT__: import('@tanstack/query-core').QueryClient;
|
||||
}
|
||||
}
|
||||
|
||||
// This code is for all users
|
||||
window.__TANSTACK_QUERY_CLIENT__ = queryClient;
|
||||
|
||||
export const appConfig: ApplicationConfig = {
|
||||
providers: [
|
||||
{
|
||||
provide: ErrorHandler,
|
||||
useClass: GlobalErrorHandler,
|
||||
},
|
||||
provideZoneChangeDetection({ eventCoalescing: true }),
|
||||
provideRouter(routes, withComponentInputBinding()),
|
||||
provideAnimationsAsync(),
|
||||
|
|
@ -32,6 +53,7 @@ export const appConfig: ApplicationConfig = {
|
|||
postLogoutRedirectUri: environment.msAuth.postLogoutRedirectUri,
|
||||
})
|
||||
),
|
||||
provideTanStackQuery(queryClient, withDevtools()),
|
||||
provideHttpClient(),
|
||||
graphqlProvider,
|
||||
provideHotToastConfig(),
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@ export const routes: Routes = [
|
|||
{
|
||||
path: 'tickets',
|
||||
loadChildren: () =>
|
||||
import('./pages/ticket-system/ticket-system.module').then(
|
||||
import('./pages/tickets/ticket-system.module').then(
|
||||
(m) => m.TicketSystemModule
|
||||
),
|
||||
canActivate: [MsalGuard],
|
||||
|
|
@ -61,6 +61,18 @@ export const routes: Routes = [
|
|||
title: 'Ticketsystem',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'playground',
|
||||
loadComponent: () =>
|
||||
import('./pages/playground/playground.component').then(
|
||||
(m) => m.PlaygroundComponent
|
||||
),
|
||||
canActivate: [MsalGuard, roleGuard],
|
||||
data: {
|
||||
title: 'Playground',
|
||||
roles: ['manager', 'admin'],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '',
|
||||
pathMatch: 'full',
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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 {}
|
||||
|
|
@ -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 {}
|
||||
|
|
@ -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(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -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()));
|
||||
}
|
||||
|
|
@ -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()),
|
||||
);
|
||||
}
|
||||
|
|
@ -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()),
|
||||
);
|
||||
}
|
||||
|
|
@ -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));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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()));
|
||||
}
|
||||
|
|
@ -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 {}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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()));
|
||||
}
|
||||
|
|
@ -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()),
|
||||
);
|
||||
}
|
||||
|
|
@ -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(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -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()),
|
||||
);
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
import { HlmBadge } from './lib/hlm-badge';
|
||||
|
||||
export * from './lib/hlm-badge';
|
||||
|
||||
export const HlmBadgeImports = [HlmBadge] as const;
|
||||
|
|
@ -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');
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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()),
|
||||
);
|
||||
}
|
||||
|
|
@ -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()));
|
||||
}
|
||||
|
|
@ -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()));
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
import { Directive, computed, input } from '@angular/core';
|
||||
import { hlm } from '@spartan-ng/helm/utils';
|
||||
import type { ClassValue } from 'clsx';
|
||||
|
||||
@Directive({
|
||||
selector: '[hlmCardFooter]',
|
||||
host: {
|
||||
'[class]': '_computedClass()',
|
||||
},
|
||||
})
|
||||
export class HlmCardFooter {
|
||||
public readonly userClass = input<ClassValue>('', { alias: 'class' });
|
||||
protected readonly _computedClass = computed(() => hlm('flex items-center px-6 [.border-t]:pt-4', this.userClass()));
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
import { Directive, computed, input } from '@angular/core';
|
||||
import { hlm } from '@spartan-ng/helm/utils';
|
||||
import type { ClassValue } from 'clsx';
|
||||
|
||||
@Directive({
|
||||
selector: '[hlmCardHeader]',
|
||||
host: {
|
||||
'[class]': '_computedClass()',
|
||||
},
|
||||
})
|
||||
export class HlmCardHeader {
|
||||
public readonly userClass = input<ClassValue>('', { alias: 'class' });
|
||||
protected readonly _computedClass = computed(() =>
|
||||
hlm(
|
||||
'@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-4',
|
||||
this.userClass(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -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()));
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
import { Directive, computed, input } from '@angular/core';
|
||||
import { hlm } from '@spartan-ng/helm/utils';
|
||||
import { type VariantProps, cva } from 'class-variance-authority';
|
||||
import type { ClassValue } from 'clsx';
|
||||
|
||||
export const cardVariants = cva('bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-4 shadow-sm', {
|
||||
variants: {},
|
||||
defaultVariants: {},
|
||||
});
|
||||
export type CardVariants = VariantProps<typeof cardVariants>;
|
||||
|
||||
@Directive({
|
||||
selector: '[hlmCard]',
|
||||
host: {
|
||||
'[class]': '_computedClass()',
|
||||
},
|
||||
})
|
||||
export class HlmCard {
|
||||
public readonly userClass = input<ClassValue>('', { alias: 'class' });
|
||||
protected readonly _computedClass = computed(() => hlm(cardVariants(), this.userClass()));
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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()));
|
||||
}
|
||||
|
|
@ -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()),
|
||||
);
|
||||
}
|
||||
|
|
@ -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()),
|
||||
);
|
||||
}
|
||||
|
|
@ -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()),
|
||||
);
|
||||
}
|
||||
|
|
@ -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(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -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()),
|
||||
);
|
||||
}
|
||||
|
|
@ -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(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -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()),
|
||||
);
|
||||
}
|
||||
|
|
@ -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()));
|
||||
}
|
||||
|
|
@ -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()),
|
||||
);
|
||||
}
|
||||
|
|
@ -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()),
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -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()));
|
||||
}
|
||||
|
|
@ -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()),
|
||||
);
|
||||
}
|
||||
|
|
@ -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()),
|
||||
);
|
||||
}
|
||||
|
|
@ -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));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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()));
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
import type { ComponentType } from '@angular/cdk/portal';
|
||||
import { Injectable, type TemplateRef, inject } from '@angular/core';
|
||||
import {
|
||||
type BrnDialogOptions,
|
||||
BrnDialogService,
|
||||
cssClassesToArray,
|
||||
} from '@spartan-ng/brain/dialog';
|
||||
import { HlmDialogContent } from './hlm-dialog-content';
|
||||
import { hlmDialogOverlayClass } from './hlm-dialog-overlay';
|
||||
|
||||
export type HlmDialogOptions<DialogContext = unknown> = BrnDialogOptions & {
|
||||
contentClass?: string;
|
||||
context?: DialogContext;
|
||||
};
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class HlmDialogService {
|
||||
private readonly _brnDialogService = inject(BrnDialogService);
|
||||
|
||||
public open(component: ComponentType<unknown> | TemplateRef<unknown>, options?: Partial<HlmDialogOptions>) {
|
||||
const mergedOptions = {
|
||||
...(options ?? {}),
|
||||
backdropClass: cssClassesToArray(`${hlmDialogOverlayClass} ${options?.backdropClass ?? ''}`),
|
||||
context: {
|
||||
...(options?.context && typeof options.context === 'object' ? options.context : {}),
|
||||
$component: component,
|
||||
$dynamicComponentClass: options?.contentClass,
|
||||
},
|
||||
};
|
||||
|
||||
return this._brnDialogService.open(HlmDialogContent, undefined, mergedOptions.context, mergedOptions);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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()),
|
||||
);
|
||||
}
|
||||
|
|
@ -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.');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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()));
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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()),
|
||||
);
|
||||
}
|
||||
|
|
@ -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());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -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());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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());
|
||||
});
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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()));
|
||||
}
|
||||
|
|
@ -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()));
|
||||
}
|
||||
|
|
@ -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()));
|
||||
}
|
||||
|
|
@ -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(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -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()),
|
||||
);
|
||||
}
|
||||
|
|
@ -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()),
|
||||
);
|
||||
}
|
||||
|
|
@ -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()));
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -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(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
import { HlmScrollArea } from './lib/hlm-scroll-area';
|
||||
|
||||
export * from './lib/hlm-scroll-area';
|
||||
|
||||
export const HlmScrollAreaImports = [HlmScrollArea] as const;
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
import { Directive, computed, input } from '@angular/core';
|
||||
import { hlm } from '@spartan-ng/helm/utils';
|
||||
import type { ClassValue } from 'clsx';
|
||||
|
||||
@Directive({
|
||||
selector: 'ng-scrollbar[hlm]',
|
||||
host: {
|
||||
'[class]': '_computedClass()',
|
||||
'[style.--scrollbar-border-radius.px]': '100',
|
||||
'[style.--scrollbar-offset]': '3',
|
||||
'[style.--scrollbar-thumb-color]': '"hsl(var(--border))"',
|
||||
'[style.--scrollbar-thumb-hover-color]': '"hsl(var(--border))"',
|
||||
'[style.--scrollbar-thickness]': '7',
|
||||
},
|
||||
})
|
||||
export class HlmScrollArea {
|
||||
protected readonly _computedClass = computed(() => hlm('block', this.userClass()));
|
||||
public readonly userClass = input<ClassValue>('', { alias: 'class' });
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue