Create workspace
Create an Nx workspace with an Angular application. Since Tailwind is the core of the project, we do not recommend using other pre-processors.
npx create-nx-workspace@latest my-workspace --preset=angular-monorepo --style=cssCreate an Nx workspace with an Angular application. Since Tailwind is the core of the project, we do not recommend using other pre-processors.
npx create-nx-workspace@latest my-workspace --preset=angular-monorepo --style=cssAdd the following dependencies at the root of the workspace:
npm install @angular/cdk class-variance-authority clsx tailwind-merge @ng-icons/core @ng-icons/lucidepnpm add @angular/cdk class-variance-authority clsx tailwind-merge @ng-icons/core @ng-icons/lucideyarn add @angular/cdk class-variance-authority clsx tailwind-merge @ng-icons/core @ng-icons/lucidebun add @angular/cdk class-variance-authority clsx tailwind-merge @ng-icons/core @ng-icons/lucideAdd the following dev dependencies at the root of the workspace:
npm install -D tailwindcss @tailwindcss/postcss postcss tailwindcss-animatepnpm add -D tailwindcss @tailwindcss/postcss postcss tailwindcss-animateyarn add -D tailwindcss @tailwindcss/postcss postcss tailwindcss-animatebun add -D tailwindcss @tailwindcss/postcss postcss tailwindcss-animateCreate a .postcssrc.json inside the application, at apps/my-app/. The Angular build looks for it from the stylesheet upwards.
{
"plugins": {
"@tailwindcss/postcss": {}
}
}Add these lines inside compilerOptions on your tsconfig.base.json. Do not add baseUrl, it is an error from TypeScript 6 on.
{
"compilerOptions": {
"paths": {
"@/*": ["./apps/my-app/src/app/*"]
}
}
}Create apps/my-app/src/app/shared/core/directives/ with:
import {
Directive,
type EmbeddedViewRef,
inject,
input,
type OnDestroy,
TemplateRef,
ViewContainerRef,
effect,
type EffectRef,
} from '@angular/core';
export function isTemplateRef<C = unknown>(value: unknown): value is TemplateRef<C> {
return value instanceof TemplateRef;
}
export interface ZardStringTemplateOutletContext {
$implicit: unknown;
[key: string]: unknown;
}
@Directive({
selector: '[zStringTemplateOutlet]',
exportAs: 'zStringTemplateOutlet',
})
export class ZardStringTemplateOutletDirective<T = unknown> implements OnDestroy {
private readonly viewContainer = inject(ViewContainerRef);
private readonly templateRef = inject(TemplateRef<void>);
private embeddedViewRef: EmbeddedViewRef<ZardStringTemplateOutletContext> | null = null;
private readonly context = {} as ZardStringTemplateOutletContext;
#isFirstChange = true;
#lastOutletWasTemplate = false;
#lastTemplateRef: TemplateRef<void> | null = null;
#lastContext?: ZardStringTemplateOutletContext;
readonly zStringTemplateOutletContext = input<ZardStringTemplateOutletContext | undefined>(undefined);
readonly zStringTemplateOutlet = input.required<T | TemplateRef<void>>();
#hasContextShapeChanged(context: ZardStringTemplateOutletContext | undefined): boolean {
if (!context) {
return false;
}
const prevCtxKeys = Object.keys(this.#lastContext || {});
const currCtxKeys = Object.keys(context || {});
if (prevCtxKeys.length === currCtxKeys.length) {
for (const propName of currCtxKeys) {
if (!prevCtxKeys.includes(propName)) {
return true;
}
}
return false;
} else {
return true;
}
}
#shouldViewBeRecreated(
stringTemplateOutlet: TemplateRef<void> | T,
stringTemplateOutletContext: ZardStringTemplateOutletContext | undefined,
): boolean {
const isTemplate = isTemplateRef(stringTemplateOutlet);
const shouldOutletRecreate =
this.#isFirstChange ||
isTemplate !== this.#lastOutletWasTemplate ||
(isTemplate && stringTemplateOutlet !== this.#lastTemplateRef);
const shouldContextRecreate = this.#hasContextShapeChanged(stringTemplateOutletContext);
return shouldContextRecreate || shouldOutletRecreate;
}
#updateTrackingState(
stringTemplateOutlet: TemplateRef<void> | T,
stringTemplateOutletContext: ZardStringTemplateOutletContext | undefined,
): void {
const isTemplate = isTemplateRef(stringTemplateOutlet);
if (this.#isFirstChange && !isTemplate) {
this.#isFirstChange = false;
}
if (stringTemplateOutletContext !== undefined) {
this.#lastContext = stringTemplateOutletContext;
}
this.#lastOutletWasTemplate = isTemplate;
this.#lastTemplateRef = isTemplate ? stringTemplateOutlet : null;
}
readonly #viewEffect: EffectRef = effect(() => {
const stringTemplateOutlet = this.zStringTemplateOutlet();
const stringTemplateOutletContext = this.zStringTemplateOutletContext();
if (!this.#isFirstChange && isTemplateRef(stringTemplateOutlet)) {
this.#isFirstChange = true;
}
if (!isTemplateRef(stringTemplateOutlet)) {
this.context['$implicit'] = stringTemplateOutlet as T;
}
const recreateView = this.#shouldViewBeRecreated(stringTemplateOutlet, stringTemplateOutletContext);
this.#updateTrackingState(stringTemplateOutlet, stringTemplateOutletContext);
if (recreateView) {
this.#recreateView(
stringTemplateOutlet as TemplateRef<ZardStringTemplateOutletContext>,
stringTemplateOutletContext,
);
} else {
this.#updateContext(stringTemplateOutlet, stringTemplateOutletContext);
}
});
#recreateView(
outlet: TemplateRef<ZardStringTemplateOutletContext>,
context: ZardStringTemplateOutletContext | undefined,
): void {
this.viewContainer.clear();
if (isTemplateRef(outlet)) {
this.embeddedViewRef = this.viewContainer.createEmbeddedView(outlet, context);
} else {
this.embeddedViewRef = this.viewContainer.createEmbeddedView(this.templateRef, this.context);
}
}
#updateContext(outlet: TemplateRef<void> | T, context: ZardStringTemplateOutletContext | undefined): void {
const newCtx = isTemplateRef(outlet) ? context : this.context;
let oldCtx = this.embeddedViewRef?.context;
if (!oldCtx) {
oldCtx = newCtx;
} else if (newCtx && typeof newCtx === 'object') {
for (const propName of Object.keys(newCtx)) {
oldCtx[propName] = newCtx[propName];
}
}
this.#lastContext = oldCtx;
}
static ngTemplateContextGuard<T>(
_dir: ZardStringTemplateOutletDirective<T>,
_ctx: unknown,
): _ctx is ZardStringTemplateOutletContext {
return true;
}
ngOnDestroy(): void {
this.#viewEffect.destroy();
this.viewContainer.clear();
this.embeddedViewRef = null;
}
}import { computed, Directive, inject, Injectable, input } from '@angular/core';
@Injectable({ providedIn: 'root' })
class ZardIdInternalService {
private counter = 0;
/** The next sequence number. Unique for the lifetime of the injector. */
next(): number {
return ++this.counter;
}
}
/**
* Gives an element a unique id, so `for`/`aria-labelledby` pairs can be wired up
* inside a component without asking the consumer for one.
*
* ```html
* <span zardId="checkbox" #z="zardId"></span>
* <input [id]="z.id()" />
* <label [for]="z.id()">…</label>
* ```
*/
@Directive({
selector: '[zardId]',
exportAs: 'zardId',
})
export class ZardIdDirective {
/**
* Claimed once, when the directive is constructed.
*
* The id used to be produced by a `computed()` that incremented the counter in
* its body — a side effect inside a primitive Angular is free to re-evaluate.
* Each re-evaluation handed back a different id while the rendered DOM still
* carried the old one, silently breaking the `for`/`id` pair.
*/
private readonly sequence = inject(ZardIdInternalService).next();
/** Prefix for the generated id, so ids read as `checkbox-3` rather than `3`. */
readonly zardId = input('ssr');
/** `<prefix>-<n>`, with `n` fixed for the life of the directive. */
readonly id = computed(() => `${this.zardId()}-${this.sequence}`);
}Create apps/my-app/src/app/shared/core/overlay/ with:
import type { OverlayRef } from '@angular/cdk/overlay';
import { isPlatformBrowser } from '@angular/common';
import { EventEmitter, signal } from '@angular/core';
import { outputToObservable } from '@angular/core/rxjs-interop';
import { filter, takeUntil, type Observable } from 'rxjs';
import { isTopmostOverlay, popOverlay, pushOverlay } from './overlay-stack';
/** The keys that dismiss the topmost overlay. `Esc` is the legacy IE/Edge spelling. */
const ESCAPE_KEYS = ['Escape', 'Esc'] as const;
/** Which of the two footer buttons fired. */
export const enum ZardOverlayTrigger {
CANCEL = 'cancel',
OK = 'ok',
}
/**
* A callback the consumer passes for the OK / cancel buttons.
*
* Returning `false` keeps the overlay open — which is how a form stays up when
* validation fails.
*/
export type ZardOverlayCallback<T> = ((instance: T) => false | void | object) | EventEmitter<T> | undefined;
/** The subset of an overlay's options this base class reads. */
export interface ZardOverlayRefOptions<T> {
zDuration?: number;
zOnOk?: ZardOverlayCallback<T>;
zOnCancel?: ZardOverlayCallback<T>;
}
/**
* The lifecycle every service-opened overlay shares.
*
* Dialog, sheet, alert-dialog and drawer each had their own copy of this: the
* same signals, the same Escape wiring, the same dispose-once timer. The copies
* drifted — one kept no stack, another swallowed dispose errors — so the parts
* that are genuinely per-overlay are now the only things a subclass writes:
*
* {@link defaultDuration} how long the leave animation lasts
* {@link playLeaveAnimation} what marks the container as leaving
* {@link closesOnOutsidePointer} whether a press outside dismisses
*
* Subclasses call {@link attach} at the end of their constructor, once their own
* fields are initialized.
*/
export abstract class ZardOverlayRefBase<T = unknown, R = unknown> {
/** Element focused before the overlay opened, used to restore focus on close. */
private readonly previouslyFocusedElement: HTMLElement | null;
/** Pending dispose timer; cleared if dispose runs early or twice. */
private disposeTimer: ReturnType<typeof setTimeout> | null = null;
private disposed = false;
private readonly _isClosing = signal(false);
private readonly _result = signal<R | undefined>(undefined);
private readonly _componentInstance = signal<T | null>(null);
/** True from the moment {@link close} is called until the overlay is disposed. */
readonly isClosing = this._isClosing.asReadonly();
/** Result passed to {@link close}, available after it's called. */
readonly result = this._result.asReadonly();
/** Instance of the component projected as content, or null for templates / strings. */
readonly componentInstance = this._componentInstance.asReadonly();
protected constructor(
protected readonly overlayRef: OverlayRef | null,
private readonly overlayOptions: ZardOverlayRefOptions<T>,
private readonly platformId: object,
) {
this.previouslyFocusedElement = isPlatformBrowser(platformId)
? (document.activeElement as HTMLElement | null)
: null;
}
/** How long the leave animation runs when `zDuration` is not given. */
protected abstract get defaultDuration(): number;
/** Marks the container as leaving, so the CSS transition can run before dispose. */
protected abstract playLeaveAnimation(): void;
/** Whether a press outside the overlay should dismiss it. */
protected closesOnOutsidePointer(): boolean {
return true;
}
/** Whether Escape should dismiss it, on top of being the topmost overlay. */
protected closesOnEscape(): boolean {
return true;
}
/**
* Whether what a footer callback returns becomes the overlay's `result`.
*
* A confirm dialog answers yes or no, so alert-dialog closes with nothing and
* keeps `result()` undefined.
*/
protected forwardsCallbackResult(): boolean {
return true;
}
/**
* What the footer callbacks receive.
*
* The content component's instance, so a form can be read before it closes.
* Alert-dialog has no content component and hands over its `zData` instead.
*/
protected callbackArgument(): T {
return this._componentInstance() as T;
}
/**
* Wires the overlay up and registers it as the topmost one.
*
* Called by the subclass rather than by this constructor: the outputs come from
* the container, and a subclass field holding it is not initialized yet while
* the base constructor runs.
*/
protected attach(outputs: { ok: Observable<unknown>; cancel: Observable<unknown> } | null): void {
if (!this.overlayRef || !outputs) {
return;
}
pushOverlay(this);
const detached$ = this.overlayRef.detachments();
// If the overlay is torn down externally (parent destroyed, app shutdown, etc.),
// make sure stack and focus state are cleaned up.
detached$.subscribe(() => this.dispose());
outputs.cancel.pipe(takeUntil(detached$)).subscribe(() => this.trigger(ZardOverlayTrigger.CANCEL));
outputs.ok.pipe(takeUntil(detached$)).subscribe(() => this.trigger(ZardOverlayTrigger.OK));
if (this.closesOnOutsidePointer()) {
this.overlayRef
.outsidePointerEvents()
.pipe(takeUntil(detached$))
.subscribe(() => this.close());
}
this.overlayRef
.keydownEvents()
.pipe(
filter(event => ESCAPE_KEYS.includes(event.key as (typeof ESCAPE_KEYS)[number])),
takeUntil(detached$),
)
.subscribe(event => {
if (isTopmostOverlay(this) && this.closesOnEscape()) {
event.preventDefault();
this.close();
}
});
}
/** Turns a container's two outputs into the shape {@link attach} wants. */
protected static outputsOf(container: {
okTriggered: Parameters<typeof outputToObservable>[0];
cancelTriggered: Parameters<typeof outputToObservable>[0];
}): { ok: Observable<unknown>; cancel: Observable<unknown> } {
return {
ok: outputToObservable(container.okTriggered),
cancel: outputToObservable(container.cancelTriggered),
};
}
/** Internal: set the component instance once attached. */
setComponentInstance(instance: T | null): void {
this._componentInstance.set(instance);
}
close(result?: R): void {
if (this._isClosing()) {
return;
}
this._isClosing.set(true);
this._result.set(result);
if (isPlatformBrowser(this.platformId)) {
this.playLeaveAnimation();
}
this.disposeTimer = setTimeout(() => this.dispose(), this.overlayOptions.zDuration ?? this.defaultDuration);
}
private dispose(): void {
if (this.disposed) {
return;
}
this.disposed = true;
if (this.disposeTimer !== null) {
clearTimeout(this.disposeTimer);
this.disposeTimer = null;
}
if (this.overlayRef) {
if (this.overlayRef.hasAttached()) {
this.overlayRef.detachBackdrop();
}
this.overlayRef.dispose();
}
popOverlay(this);
if (isPlatformBrowser(this.platformId) && this.previouslyFocusedElement?.isConnected) {
this.previouslyFocusedElement.focus();
}
}
private trigger(action: ZardOverlayTrigger): void {
const callback = action === ZardOverlayTrigger.OK ? this.overlayOptions.zOnOk : this.overlayOptions.zOnCancel;
if (callback instanceof EventEmitter) {
callback.emit(this.callbackArgument());
return;
}
if (typeof callback === 'function') {
const result = callback(this.callbackArgument()) as R | false;
// `false` keeps the overlay open — a form that failed validation stays up.
if (result !== false) {
this.close(this.forwardsCallbackResult() ? (result as R) : undefined);
}
return;
}
this.close();
}
}/**
* One stack for every overlay that can be dismissed with Escape.
*
* Dialog, sheet and alert-dialog each used to keep their own private static
* stack, and the drawer kept none. Each type therefore believed it was topmost
* whenever it was topmost *of its own kind*, so one Escape closed a dialog and
* the sheet underneath it at the same time, and Escape closed every open drawer
* at once. They share this stack now, so "topmost" means what it says.
*
* Entries are compared by identity, so the stack holds nothing but the refs
* themselves and never needs to know their types.
*/
const stack: object[] = [];
/** Registers a newly opened overlay as the topmost one. */
export function pushOverlay(ref: object): void {
if (!stack.includes(ref)) {
stack.push(ref);
}
}
/** Removes an overlay from the stack. Safe to call more than once. */
export function popOverlay(ref: object): void {
const index = stack.indexOf(ref);
if (index >= 0) {
stack.splice(index, 1);
}
}
/** Whether `ref` is the overlay Escape should act on. */
export function isTopmostOverlay(ref: object): boolean {
return stack.length > 0 && stack[stack.length - 1] === ref;
}
/** How many overlays are open. Exposed for tests. */
export function openOverlayCount(): number {
return stack.length;
}Create apps/my-app/src/app/shared/core/i18n/ with:
import type { ZardCalendarI18n, ZardCalendarLabels, ZardI18nInterface } from './i18n.types';
export const DEFAULT_LOCALE = 'en-US';
export const DEFAULT_CALENDAR_LABELS: ZardCalendarLabels = {
today: 'Today',
nextMonth: 'Next month',
previousMonth: 'Previous month',
nextYear: 'Next year',
previousYear: 'Previous year',
chooseMonth: 'Choose the month',
chooseYear: 'Choose the year',
selected: 'Selected',
rangeStart: 'Range start',
rangeEnd: 'Range end',
inRange: 'In range',
outsideMonth: 'Outside month',
disabled: 'Disabled',
};
export const DEFAULT_CALENDAR_I18N: ZardCalendarI18n = {
weekStartsOn: 0,
labels: DEFAULT_CALENDAR_LABELS,
};
export const DEFAULT_ZARD_I18N: ZardI18nInterface = {
locale: DEFAULT_LOCALE,
calendar: DEFAULT_CALENDAR_I18N,
};import { makeEnvironmentProviders } from '@angular/core';
import type { EnvironmentProviders } from '@angular/core';
import { DEFAULT_LOCALE } from './i18n.constants';
import { ZARD_I18N, ZARD_LOCALE } from './i18n.tokens';
import type { ZardI18nInterface } from './i18n.types';
export function provideZardI18n(locale: ZardI18nInterface | string = DEFAULT_LOCALE): EnvironmentProviders {
if (typeof locale === 'string') {
return makeEnvironmentProviders([{ provide: ZARD_LOCALE, useValue: locale }]);
}
return makeEnvironmentProviders([
{ provide: ZARD_LOCALE, useValue: locale.locale },
{ provide: ZARD_I18N, useValue: locale },
]);
}import { computed, inject, Injectable, LOCALE_ID, signal } from '@angular/core';
import type { Signal } from '@angular/core';
import { DEFAULT_LOCALE } from './i18n.constants';
import { ZARD_I18N, ZARD_LOCALE } from './i18n.tokens';
import type { ZardI18nInterface } from './i18n.types';
import { getPresetLocale } from './locales/registry';
function isPlainObject(item: unknown): item is Record<string, unknown> {
return typeof item === 'object' && item !== null && !Array.isArray(item);
}
function deepMerge<T extends Record<string, unknown>>(target: T, source: Partial<T>): T {
const result: Record<string, unknown> = { ...target };
for (const [key, sourceVal] of Object.entries(source)) {
const targetVal = target[key];
if (isPlainObject(sourceVal) && isPlainObject(targetVal)) {
result[key] = deepMerge(targetVal, sourceVal);
} else if (sourceVal !== undefined) {
result[key] = sourceVal;
}
}
return result as T;
}
@Injectable({
providedIn: 'root',
})
export class ZardI18nService {
private readonly injectedI18n = inject(ZARD_I18N, { optional: true });
private readonly injectedLocale = inject(ZARD_LOCALE, { optional: true });
private readonly ngLocaleId = inject(LOCALE_ID, { optional: true });
private readonly state = signal<ZardI18nInterface>(this.initInitialState());
readonly locale: Signal<string> = computed(() => this.state().locale);
readonly current: Signal<ZardI18nInterface> = this.state.asReadonly();
setLocale(locale: ZardI18nInterface | string): void {
if (typeof locale !== 'string') {
this.state.set(locale);
return;
}
const preset = getPresetLocale(locale);
this.state.set({ ...(preset ?? {}), locale });
}
setTranslation(translation: Partial<ZardI18nInterface>): void {
this.state.update(
current =>
deepMerge(current as Record<string, unknown>, translation as Record<string, unknown>) as ZardI18nInterface,
);
}
getLocale(): string {
return this.locale();
}
getCurrent(): ZardI18nInterface {
return this.state();
}
getLocaleData<T>(key: string, defaultValue?: T): Signal<T> {
return computed(() => {
const data = (this.state() as Record<string, unknown>)[key];
return (data !== undefined ? data : defaultValue) as T;
});
}
private initInitialState(): ZardI18nInterface {
if (this.injectedI18n) {
return this.injectedI18n;
}
const resolvedLocale =
this.injectedLocale ??
(typeof this.ngLocaleId === 'string' && this.ngLocaleId ? this.ngLocaleId : DEFAULT_LOCALE);
const preset = getPresetLocale(resolvedLocale);
return { ...(preset ?? {}), locale: resolvedLocale };
}
}import { InjectionToken } from '@angular/core';
import type { ZardI18nInterface } from './i18n.types';
export const ZARD_LOCALE = new InjectionToken<string>('ZARD_LOCALE');
export const ZARD_I18N = new InjectionToken<ZardI18nInterface>('ZARD_I18N');export interface ZardCalendarLabels {
today: string;
nextMonth: string;
previousMonth: string;
nextYear: string;
previousYear: string;
chooseMonth?: string;
chooseYear?: string;
selected?: string;
rangeStart?: string;
rangeEnd?: string;
inRange?: string;
outsideMonth?: string;
disabled?: string;
}
export type ZardDayOfWeek = 0 | 1 | 2 | 3 | 4 | 5 | 6;
export type ZardWeekStartsOn = ZardDayOfWeek;
export interface ZardCalendarI18n {
weekStartsOn?: ZardDayOfWeek;
labels?: Partial<ZardCalendarLabels>;
}
export interface ZardI18nInterface {
locale: string;
calendar?: ZardCalendarI18n;
[key: string]: unknown;
}export * from './i18n.types';
export * from './i18n.constants';
export * from './i18n.tokens';
export * from './i18n.service';
export * from './i18n.provider';
export * from './locales/registry';
export * from './locales/en-us';
export * from './locales/fr-fr';
export * from './locales/es-es';
export * from './locales/de-de';
export * from './locales/it-it';
export * from './locales/pt-br';
export * from './locales/pt-pt';Create apps/my-app/src/app/shared/core/i18n/locales/ with:
import type { ZardI18nInterface } from '../i18n.types';
export const de_DE: ZardI18nInterface = {
locale: 'de-DE',
calendar: {
weekStartsOn: 1,
labels: {
today: 'Heute',
nextMonth: 'Nächster Monat',
previousMonth: 'Vorheriger Monat',
nextYear: 'Nächstes Jahr',
previousYear: 'Vorheriges Jahr',
chooseMonth: 'Monat auswählen',
chooseYear: 'Jahr auswählen',
selected: 'Ausgewählt',
rangeStart: 'Bereichsbeginn',
rangeEnd: 'Bereichsende',
inRange: 'Im Bereich',
outsideMonth: 'Außerhalb des Monats',
disabled: 'Deaktiviert',
},
},
};import { DEFAULT_CALENDAR_I18N } from '../i18n.constants';
import type { ZardI18nInterface } from '../i18n.types';
export const en_US: ZardI18nInterface = {
locale: 'en-US',
calendar: DEFAULT_CALENDAR_I18N,
};import type { ZardI18nInterface } from '../i18n.types';
export const es_ES: ZardI18nInterface = {
locale: 'es-ES',
calendar: {
weekStartsOn: 1,
labels: {
today: 'Hoy',
nextMonth: 'Mes siguiente',
previousMonth: 'Mes anterior',
nextYear: 'Año siguiente',
previousYear: 'Año anterior',
chooseMonth: 'Elegir el mes',
chooseYear: 'Elegir el año',
selected: 'Seleccionado',
rangeStart: 'Inicio de rango',
rangeEnd: 'Fin de rango',
inRange: 'En rango',
outsideMonth: 'Fuera de mes',
disabled: 'Deshabilitado',
},
},
};import type { ZardI18nInterface } from '../i18n.types';
export const fr_FR: ZardI18nInterface = {
locale: 'fr-FR',
calendar: {
weekStartsOn: 1,
labels: {
today: "Aujourd'hui",
nextMonth: 'Mois suivant',
previousMonth: 'Mois précédent',
nextYear: 'Année suivante',
previousYear: 'Année précédente',
chooseMonth: 'Choisir le mois',
chooseYear: "Choisir l'année",
selected: 'Sélectionné',
rangeStart: 'Début de plage',
rangeEnd: 'Fin de plage',
inRange: 'Dans la plage',
outsideMonth: 'Hors du mois',
disabled: 'Désactivé',
},
},
};import type { ZardI18nInterface } from '../i18n.types';
export const it_IT: ZardI18nInterface = {
locale: 'it-IT',
calendar: {
weekStartsOn: 1,
labels: {
today: 'Oggi',
nextMonth: 'Mese successivo',
previousMonth: 'Mese precedente',
nextYear: 'Anno successivo',
previousYear: 'Anno precedente',
chooseMonth: 'Scegli il mese',
chooseYear: "Scegli l'anno",
selected: 'Selezionato',
rangeStart: 'Inizio intervallo',
rangeEnd: 'Fine intervallo',
inRange: "Nell'intervallo",
outsideMonth: 'Fuori mese',
disabled: 'Disabilitato',
},
},
};import type { ZardI18nInterface } from '../i18n.types';
export const pt_BR: ZardI18nInterface = {
locale: 'pt-BR',
calendar: {
weekStartsOn: 0,
labels: {
today: 'Hoje',
nextMonth: 'Próximo mês',
previousMonth: 'Mês anterior',
nextYear: 'Próximo ano',
previousYear: 'Ano anterior',
chooseMonth: 'Escolher mês',
chooseYear: 'Escolher ano',
selected: 'Selecionado',
rangeStart: 'Início do intervalo',
rangeEnd: 'Fim do intervalo',
inRange: 'No intervalo',
outsideMonth: 'Fora do mês',
disabled: 'Desativado',
},
},
};import type { ZardI18nInterface } from '../i18n.types';
export const pt_PT: ZardI18nInterface = {
locale: 'pt-PT',
calendar: {
weekStartsOn: 1,
labels: {
today: 'Hoje',
nextMonth: 'Mês seguinte',
previousMonth: 'Mês anterior',
nextYear: 'Ano seguinte',
previousYear: 'Ano anterior',
chooseMonth: 'Escolher mês',
chooseYear: 'Escolher ano',
selected: 'Selecionado',
rangeStart: 'Início do intervalo',
rangeEnd: 'Fim do intervalo',
inRange: 'No intervalo',
outsideMonth: 'Fora do mês',
disabled: 'Desativado',
},
},
};import type { ZardI18nInterface } from '../i18n.types';
import { de_DE } from './de-de';
import { en_US } from './en-us';
import { es_ES } from './es-es';
import { fr_FR } from './fr-fr';
import { it_IT } from './it-it';
import { pt_BR } from './pt-br';
import { pt_PT } from './pt-pt';
export const STANDARD_LOCALES: Record<string, ZardI18nInterface> = {
'en-US': en_US,
en: en_US,
'fr-FR': fr_FR,
fr: fr_FR,
'es-ES': es_ES,
es: es_ES,
'de-DE': de_DE,
de: de_DE,
'it-IT': it_IT,
it: it_IT,
'pt-BR': pt_BR,
'pt-PT': pt_PT,
pt: pt_BR,
};
export function getPresetLocale(locale: string): ZardI18nInterface | undefined {
if (!locale) {
return undefined;
}
return (
STANDARD_LOCALES[locale] ??
STANDARD_LOCALES[locale.toLowerCase()] ??
STANDARD_LOCALES[locale.split(/[-_]/)[0].toLowerCase()]
);
}Create apps/my-app/src/app/shared/core/provider/event-manager-plugins/ with:
import type { ListenerOptions } from '@angular/core';
import { EventManagerPlugin } from '@angular/platform-browser';
export class ZardDebounceEventManagerPlugin extends EventManagerPlugin {
override supports(eventName: string): boolean {
return /\.debounce(?:\.|$)/.test(eventName);
}
override addEventListener(
element: HTMLElement,
eventName: string,
handler: (event: Event) => void,
options?: ListenerOptions,
// eslint-disable-next-line
): Function {
// Expected format: "event.debounce.delay" (e.g., "input.debounce.150")
// If delay is omitted or invalid, defaults to 300ms
const [event, , delay] = eventName.split('.');
const parsedDelay = Number.parseInt(delay);
const resolvedDelay = Number.isNaN(parsedDelay) ? 300 : parsedDelay;
let timeoutId!: ReturnType<typeof setTimeout>;
const listener = (event: Event) => {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => handler(event), resolvedDelay);
};
const unsubscribe = this.manager.addEventListener(element, event, listener, options);
return () => {
clearTimeout(timeoutId);
unsubscribe();
};
}
}import type { ListenerOptions } from '@angular/core';
import { EventManagerPlugin } from '@angular/platform-browser';
/**
* Angular EventManagerPlugin that provides event modifier syntax for templates.
*
* Supports modifiers: .prevent, .stop, .stop-immediate, .prevent-with-stop
* Supports key filters: enter, escape, {enter,space}
*
* @example
* Prevent default on any click
* (click.prevent)="handler()"
*
* @example
* Prevent default only on Enter key
* (keydown.enter.prevent)="handler()"
*
* @example
* Prevent default on more keys like Enter and Space key
* (keydown.{enter,space}.prevent)="handler()"
*
* @example
* Stop propagation
* (click.stop)="handler()"
*/
export class ZardEventManagerPlugin extends EventManagerPlugin {
#keywords = ['prevent', 'stop', 'stop-immediate', 'prevent-with-stop'];
override supports(eventName: string): boolean {
return this.#keywords.some(keyword => eventName.endsWith(`.${keyword}`));
}
override addEventListener(
element: HTMLElement,
eventName: string,
handler: (event: Event) => void,
options?: ListenerOptions,
// eslint-disable-next-line
): Function {
const { event, keyword, keys } = this.#provideEventFrom(eventName, this.#keywords);
return this.manager.addEventListener(
element,
event,
(event: Event) => {
const isKeyboardEvent = event instanceof KeyboardEvent;
const isElementDisabled = element.getAttribute('aria-disabled') === 'true';
const shouldApplyModifier =
(!keys.length || (isKeyboardEvent && keys.includes(event.key.toLowerCase()))) && !isElementDisabled;
if (shouldApplyModifier) {
switch (keyword) {
case 'stop':
event.stopPropagation();
break;
case 'stop-immediate':
event.stopImmediatePropagation();
break;
case 'prevent-with-stop':
event.preventDefault();
event.stopPropagation();
break;
default:
event.preventDefault();
break;
}
}
handler(event);
},
options,
);
}
#provideEventFrom(eventName: string, keywords: string[]): { event: string; keyword: string; keys: string[] } {
const eventNameSubstrings = eventName.split('.');
let event = '';
let keys: string[] = [];
let keyword = '';
for (const substring of eventNameSubstrings) {
if (substring.startsWith('{')) {
keys = this.#extractKeys(substring);
continue;
} else if (keywords.includes(substring)) {
keyword = substring;
break;
} else if (!event) {
event = substring;
} else {
event += `.${substring}`;
}
}
return { event, keyword, keys };
}
#extractKeys(substring: string): string[] {
const stringList = substring.substring(1, substring.length - 1);
return stringList
.split(',')
.map(raw => {
const s = raw.toLowerCase().trim();
return s === 'space' ? ' ' : s;
})
.filter(Boolean);
}
}Create apps/my-app/src/app/shared/core/provider/ with:
import { makeEnvironmentProviders, type EnvironmentProviders } from '@angular/core';
import { EVENT_MANAGER_PLUGINS } from '@angular/platform-browser';
import { ZardDebounceEventManagerPlugin } from './event-manager-plugins/zard-debounce-event-manager-plugin';
import { ZardEventManagerPlugin } from './event-manager-plugins/zard-event-manager-plugin';
export function provideZard(): EnvironmentProviders {
const eventManagerPlugins = [
{
provide: EVENT_MANAGER_PLUGINS,
useClass: ZardEventManagerPlugin,
multi: true,
},
{
provide: EVENT_MANAGER_PLUGINS,
useClass: ZardDebounceEventManagerPlugin,
multi: true,
},
];
return makeEnvironmentProviders([...eventManagerPlugins]);
}Create apps/my-app/src/app/shared/core/css/ with:
/*
* `no-scrollbar` lives here, not in utilities.css, because combobox and command
* carry it in their variants — it has to ship with every install, while
* utilities.css is opt-in.
*/
@utility no-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}
/*
* `scrollbar-thin` is opt-in, for the same reason `no-scrollbar` lives here rather
* than in `utilities.css`: it has to be available to every install, not layered on
* top as an extra registry item.
*
* It used to ship differently — a CLI constant literally named `windowsScrollbar`
* wrote a bare, unscoped `::-webkit-scrollbar` rule into every project's global
* stylesheet, restyling every scroll surface in the host app (not just zard's),
* and repainting them on any `--muted` / `--muted-foreground` change. That constant
* is gone; this utility is the scoped replacement a consumer applies deliberately.
*
* `scrollbar-width` / `scrollbar-color` are the standard CSS properties and the
* primary mechanism here — Firefox and current Chromium/Edge honor them directly,
* so the thin, token-colored scrollbar renders without any WebKit-specific rule.
* The `&::-webkit-scrollbar*` rules are a scoped fallback for WebKit engines that
* still ignore `scrollbar-color` (older Safari); they never apply outside this
* class.
*/
@utility scrollbar-thin {
scrollbar-width: thin;
scrollbar-color: var(--muted-foreground) var(--muted);
&::-webkit-scrollbar {
width: 6px;
height: 6px;
}
&::-webkit-scrollbar-thumb {
background: var(--muted-foreground);
border-radius: 5px;
}
&::-webkit-scrollbar-track {
border-radius: 5px;
background: var(--muted);
}
}
@theme inline {
--animate-caret-blink: caret-blink 1s ease-out infinite;
@keyframes caret-blink {
0%,
70%,
100% {
opacity: 1;
}
20%,
50% {
opacity: 0;
}
}
@keyframes accordion-down {
from {
height: 0;
}
to {
height: var(--radix-accordion-content-height, var(--accordion-panel-height, auto));
}
}
@keyframes accordion-up {
from {
height: var(--radix-accordion-content-height, var(--accordion-panel-height, auto));
}
to {
height: 0;
}
}
}
/* Custom variants */
@custom-variant data-open {
&:where([data-state='open']),
&:where([data-open]:not([data-open='false'])) {
@slot;
}
}
@custom-variant data-closed {
&:where([data-state='closed']),
&:where([data-closed]:not([data-closed='false'])) {
@slot;
}
}
@custom-variant data-checked {
&:where([data-state='checked']),
&:where([data-checked]:not([data-checked='false'])) {
@slot;
}
}
@custom-variant data-unchecked {
&:where([data-state='unchecked']),
&:where([data-unchecked]:not([data-unchecked='false'])) {
@slot;
}
}
@custom-variant data-selected {
&:where([data-selected]:not([data-selected='false'])) {
@slot;
}
}
@custom-variant data-disabled {
&:where([data-disabled='true']),
&:where([data-disabled]:not([data-disabled='false'])) {
@slot;
}
}
@custom-variant data-active {
&:where([data-state='active']),
&:where([data-active]:not([data-active='false'])) {
@slot;
}
}
@custom-variant data-horizontal {
&:where([data-orientation='horizontal']) {
@slot;
}
}
@custom-variant data-vertical {
&:where([data-orientation='vertical']) {
@slot;
}
}Create apps/my-app/src/app/shared/core/index.ts with:
export * from './directives/string-template-outlet.directive';
export * from './directives/id.directive';
export * from './overlay/overlay-ref';
export * from './overlay/overlay-stack';
export * from './provider/providezard';
export * from './provider/event-manager-plugins/zard-debounce-event-manager-plugin';
export * from './provider/event-manager-plugins/zard-event-manager-plugin';Create a utils folder at apps/my-app/src/app/shared/utils with:
export * from './merge-classes';
export * from './noop';
export * from './number';import { type ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
export type { ClassValue };
/** Merges CVA variants with a consumer's `class` input, last write winning. */
export function mergeClasses(...inputs: ClassValue[]): string {
return twMerge(clsx(inputs));
}/** Does nothing. Used as the initial value of a ControlValueAccessor callback. */
export const noopFn = (): void => void 0;function clamp(value: number, [min, max]: [number, number]): number {
return Math.min(max, Math.max(min, value));
}
function roundToStep(value: number, min: number, step: number): number {
return Math.round((value - min) / step) * step + min;
}
function convertValueToPercentage(value: number, min: number, max: number): number {
return ((value - min) / (max - min)) * 100;
}
export { clamp, roundToStep, convertValueToPercentage };Add the following to apps/my-app/src/styles.css. You can learn more about using CSS variables for theming in the theming section.
@layer ng-icon, theme, base, components, utilities;
@import 'tailwindcss';
@import './app/shared/core/css/zard';
@plugin "tailwindcss-animate";
@custom-variant dark (&:is(.dark *));
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: oklch(0.985 0 0);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.809 0.105 251.813);
--chart-2: oklch(0.623 0.214 259.815);
--chart-3: oklch(0.546 0.245 262.881);
--chart-4: oklch(0.488 0.243 264.376);
--chart-5: oklch(0.424 0.199 265.638);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--destructive-foreground: oklch(0.205 0 0);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.809 0.105 251.813);
--chart-2: oklch(0.623 0.214 259.815);
--chart-3: oklch(0.546 0.245 262.881);
--chart-4: oklch(0.488 0.243 264.376);
--chart-5: oklch(0.424 0.199 265.638);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
input[type="number"]::-webkit-inner-spin-button,
input[type="number"]::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
input[type="number"] {
-moz-appearance: textfield;
appearance: textfield; /* Added for general compatibility */
}
}Add provideZard() to the providers of your apps/my-app/src/app/app.config.ts
import { ApplicationConfig } from '@angular/core';
import { provideZard } from '@/shared/core/provider/providezard';
export const appConfig: ApplicationConfig = {
providers: [
provideZard(),
],
};Create a components.json file in the root of your workspace.
{
"$schema": "https://zardui.com/schema.json",
"style": "css",
"icons": "lucide",
"rtl": false,
"projectType": "nx",
"appConfigFile": "apps/my-app/src/app/app.config.ts",
"packageManager": "npm",
"tailwind": {
"css": "apps/my-app/src/styles.css",
"baseColor": "neutral"
},
"baseUrl": "apps/my-app/src/app",
"aliases": {
"components": "@/shared/components",
"utils": "@/shared/utils",
"core": "@/shared/core",
"services": "@/shared/services"
}
}You can now start adding components to your project.
Create an Nx workspace with an Angular application. Since Tailwind is the core of the project, we do not recommend using other pre-processors.
npx create-nx-workspace@latest my-workspace --preset=angular-monorepo --style=cssRun the cli at the workspace root. Pick "Nx" on the first question, then the app that receives the components:
npx zard-cli@latest initpnpm dlx zard-cli@latest inityarn zard-cli@latest initbunx zard-cli@latest initYou can now start adding components to your project. Open the components page and select what component you want to install