Create the library
Inside an existing Angular workspace, generate the library that will ship the components.
ng generate library uiInside an existing Angular workspace, generate the library that will ship the components.
ng generate library uiAdd the following dependencies. There is no PostCSS setup here, the consuming app owns the build:
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:
npm install -D tailwindcss tailwindcss-animatepnpm add -D tailwindcss tailwindcss-animateyarn add -D tailwindcss tailwindcss-animatebun add -D tailwindcss tailwindcss-animateAdd these lines inside compilerOptions on your tsconfig.json. Do not add baseUrl, it is an error from TypeScript 6 on.
{
"compilerOptions": {
"paths": {
"@/*": ["./projects/ui/src/lib/*"]
}
}
}Create projects/ui/src/lib/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 projects/ui/src/lib/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 projects/ui/src/lib/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 projects/ui/src/lib/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 projects/ui/src/lib/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 projects/ui/src/lib/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 projects/ui/src/lib/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 projects/ui/src/lib/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 projects/ui/src/lib/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 projects/ui/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 './lib/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 */
}
}ng-packagr only publishes what the entry point reaches, so declare the theme as an asset. With output "/" it lands at the package root:
{
"$schema": "../../node_modules/ng-packagr/ng-package.schema.json",
"dest": "../../dist/ui",
"lib": {
"entryFile": "src/public-api.ts"
},
"assets": [
{
"glob": "styles.css",
"input": "src",
"output": "/"
}
]
}Create a components.json file in the root of your workspace.
{
"$schema": "https://zardui.com/schema.json",
"style": "css",
"icons": "lucide",
"rtl": false,
"projectType": "angular-library",
"appConfigFile": "",
"packageManager": "npm",
"tailwind": {
"css": "projects/ui/src/styles.css",
"baseColor": "neutral"
},
"baseUrl": "projects/ui/src/lib",
"aliases": {
"components": "@/shared/components",
"utils": "@/shared/utils",
"core": "@/shared/core",
"services": "@/shared/services"
}
}You can now start adding components to your project.
The application that installs this library compiles its CSS, so it needs the Tailwind PostCSS pipeline: install it with npm install -D tailwindcss @tailwindcss/postcss postcss tailwindcss-animate (the theme loads that plugin) and add this .postcssrc.json at its root. Then it registers provideZard() in its app.config.ts, imports the theme with @import "<your-library>/styles.css", and imports the components from the library entry point — export them from public-api.ts. Inside the library the CLI writes relative imports, so the published package resolves without the @/ alias.
{
"plugins": {
"@tailwindcss/postcss": {}
}
}Inside an existing Angular workspace, generate the library that will ship the components. Since Tailwind is the core of the project, we do not recommend using other pre-processors.
ng generate library uiRun the cli at the workspace root. Pick "Angular Library" on the first question:
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