diff --git a/.changeset/angular-additional-components.md b/.changeset/angular-additional-components.md
new file mode 100644
index 0000000..d3fd5d6
--- /dev/null
+++ b/.changeset/angular-additional-components.md
@@ -0,0 +1,11 @@
+---
+'@surfnet/curve-angular': minor
+---
+
+Add 36 components to close the gap with the Figma component library: `accordion`, `alert`, `alert-dialog`, `aspect-ratio`, `badge`, `button-group`, `calendar`, `carousel`, `combobox`, `command`, `context-menu`, `date-picker`, `dialog`, `empty`, `hover-card`, `input-otp`, `item`, `kbd`, `native-select`, `navigation-menu`, `pagination`, `popover`, `progress`, `radio-group`, `resizable`, `scroll-area`, `sheet`, `skeleton`, `slider`, `sonner`, `spinner`, `switch`, `tabs`, `toggle`, `toggle-group`, and `tooltip`.
+
+- Every component has a `@surfnet/curve-contracts` entry and a Storybook story covering its full surface. Where a component exposes a styling axis, the input is typed from the contract so a stray `variant`/`size`/`orientation` value fails the type-check.
+- `sheet`, `skeleton`, and `tooltip` were already vendored internally (as dependencies of `sidebar`) but not exported — they are now part of the public API.
+- The newly vendored components use Phosphor icons instead of Spartan's default Lucide set, to match this package's existing icon convention.
+- Bump `@spartan-ng/brain` to `0.0.1-alpha.720` so `navigation-menu` can expose trigger `align` (and match React's Alignment story). Overlay/dialog host bindings that brain removed (`closeDelay`, `restoreFocus`, close `delay`) are dropped from the helm wrappers.
+- A few documented gaps remain where Spartan's primitives don't expose an identical surface (e.g. `scroll-area`'s orientation) — noted in the component source rather than papered over.
diff --git a/.changeset/pagination-parity.md b/.changeset/pagination-parity.md
new file mode 100644
index 0000000..dd5e897
--- /dev/null
+++ b/.changeset/pagination-parity.md
@@ -0,0 +1,6 @@
+---
+'@surfnet/curve-react': minor
+'@surfnet/curve-angular': patch
+---
+
+Align pagination across frameworks: add React `NumberedPagination`, and bring Previous/Next (`iconOnly`, logical padding, RTL icon flip, configurable `aria-label`) and Ellipsis (`srOnlyText`, decorative icon) in line with Angular.
diff --git a/.changeset/react-additional-components.md b/.changeset/react-additional-components.md
new file mode 100644
index 0000000..504b7af
--- /dev/null
+++ b/.changeset/react-additional-components.md
@@ -0,0 +1,9 @@
+---
+'@surfnet/curve-react': minor
+---
+
+Add 36 components to close the gap with the Figma component library: `accordion`, `alert`, `alert-dialog`, `aspect-ratio`, `badge`, `button-group`, `calendar`, `carousel`, `combobox`, `command`, `context-menu`, `date-picker`, `dialog`, `empty`, `hover-card`, `input-otp`, `item`, `kbd`, `native-select`, `navigation-menu`, `pagination`, `popover`, `progress`, `radio-group`, `resizable`, `scroll-area`, `sheet`, `skeleton`, `slider`, `sonner`, `spinner`, `switch`, `tabs`, `toggle`, `toggle-group`, and `tooltip`.
+
+- Every component has a `@surfnet/curve-contracts` entry and a Storybook story covering its full surface. Where a component exposes a styling axis, the prop is typed from the contract so a stray `variant`/`size`/`orientation`/`side` value fails the type-check.
+- `date-picker` is hand-composed from `Calendar` + `Popover`, since shadcn ships no registry item for it.
+- `chart` and `drawer` are deferred until there is an Angular equivalent, to keep framework parity — see ADR-019 in `docs/decision-log.md`.
diff --git a/apps/angular-app/angular.json b/apps/angular-app/angular.json
index ba22d3f..58f9e51 100644
--- a/apps/angular-app/angular.json
+++ b/apps/angular-app/angular.json
@@ -28,8 +28,8 @@
"budgets": [
{
"type": "initial",
- "maximumWarning": "500kB",
- "maximumError": "1MB"
+ "maximumWarning": "1MB",
+ "maximumError": "1.5MB"
},
{
"type": "anyComponentStyle",
diff --git a/docs/decision-log.md b/docs/decision-log.md
index 9e8bdc7..f07a2b6 100644
--- a/docs/decision-log.md
+++ b/docs/decision-log.md
@@ -45,6 +45,7 @@ the replacement.
| 16 | [Component scope built in parity](#adr-016--component-scope-built-in-parity) | Accepted | 2026-06-30 |
| 17 | [Prove it in a real app](#adr-017--prove-it-in-a-real-app) | Proposed | 2026-06-30 |
| 18 | [Relative imports for vendored helm cross-references](#adr-018--relative-imports-for-vendored-helm-cross-references) | Accepted | 2026-07-01 |
+| 19 | [Only add components available in both frameworks](#adr-019--only-add-components-available-in-both-frameworks) | Accepted | 2026-07-13 |
### Open questions (not yet decided)
@@ -408,3 +409,46 @@ relative imports are the minimal fix within the current single-entry-point archi
imports" guidance was reversed — see the **Notes** section there. Run
`fix-helm-imports` after every future `ng g` run and verify with
`grep -r "@spartan-ng/helm" packages/angular/dist` (should be empty) before publishing.
+
+---
+
+## ADR-019 — Only add components available in both frameworks
+
+**Status:** Accepted · **Date:** 2026-07-13
+
+**Context.** A gap analysis against the Figma component library (41 missing components)
+was worked through by vendoring each one from shadcn (React) and Spartan (Angular). Two
+of them — **Chart** and **Drawer** — have a shadcn/Base UI implementation but no Spartan
+equivalent: Spartan ships no `chart` generator at all, and its closest primitive to Drawer
+is `sheet` (a side panel), which is a different pattern and already vendored as its own
+component. Three more from the original gap list — **Login** (full custom flow), **Top
+navigation**, and **Typography** — aren't vendorable primitives in either registry at all:
+Login/Top navigation need bespoke composition, and Typography is prose styling, not a
+shadcn/Spartan component.
+
+**Decision.** Ship only components that land in **both** `@surfnet/curve-react` and
+`@surfnet/curve-angular`. Chart and Drawer are removed from `@surfnet/curve-react` (they were
+briefly added React-only) rather than kept as a one-sided addition. Login, Top navigation,
+and Typography stay out of scope entirely for now — none of the three were ever vendored.
+
+**Rationale.** ADR-016 already set the precedent that this system is built **in parity**;
+a component that exists in only one framework quietly breaks that contract and forces
+consumers of the other framework to special-case it. Better to track the gap explicitly
+here than let per-framework drift creep in one component at a time.
+
+**Consequences.** Follow-up work, to be picked up when the blocker clears:
+
+- **Chart** — re-add to `@surfnet/curve-react` once Spartan ships a chart component, or
+ scope a custom Angular chart wrapper (e.g. over `ngx-charts` or a direct `recharts`-equivalent)
+ if Spartan support doesn't materialize.
+- **Drawer** — re-add to `@surfnet/curve-react` once Spartan ships a distinct
+ bottom/edge-drawer primitive (not just `sheet`), or build a custom Angular equivalent on top
+ of `@spartan-ng/brain/dialog` (the primitive `sheet` already uses). Re-check
+ spartan.ng/components periodically — this is exactly the kind of gap that closes silently.
+- **Login** — needs a custom, hand-composed full flow (form, validation, provider hooks) in
+ both frameworks; not something a CLI vendors. Design input needed before scoping.
+- **Top navigation** — same: a bespoke composition (likely built from `NavigationMenu` +
+ `Avatar` + `DropdownMenu`), not a registry component.
+- **Typography** — decide whether this becomes a real component (e.g. `Heading`/`Text`
+ wrapper components with contract-enforced size/weight scales) or stays documentation-only
+ (a Storybook foundations page, like the existing Design Tokens stories) before building it.
diff --git a/packages/angular/ng-package.json b/packages/angular/ng-package.json
index ade6b65..99ffafa 100644
--- a/packages/angular/ng-package.json
+++ b/packages/angular/ng-package.json
@@ -14,6 +14,9 @@
"@tanstack/angular-table",
"class-variance-authority",
"clsx",
+ "embla-carousel",
+ "embla-carousel-angular",
+ "ngx-scrollbar",
"tailwind-merge",
"tslib"
]
diff --git a/packages/angular/package.json b/packages/angular/package.json
index c180b67..6baae44 100644
--- a/packages/angular/package.json
+++ b/packages/angular/package.json
@@ -35,10 +35,13 @@
"@angular/router": ">=21.0.0 <23.0.0",
"@fontsource-variable/geist": "5.2.9",
"@ng-icons/phosphor-icons": ">=32.0.0 <34.0.0",
- "@spartan-ng/brain": "0.0.1-alpha.715",
+ "@spartan-ng/brain": "0.0.1-alpha.720",
"@tanstack/angular-table": "^8.21.4",
"class-variance-authority": "0.7.1",
"clsx": "2.1.1",
+ "embla-carousel": ">=8.0.0 <9.0.0",
+ "embla-carousel-angular": ">=21.0.0 <23.0.0",
+ "ngx-scrollbar": "^19.1.5",
"tailwind-merge": "3.6.0",
"tslib": "2.8.1"
},
diff --git a/packages/angular/src/lib/ui/accordion/src/index.ts b/packages/angular/src/lib/ui/accordion/src/index.ts
new file mode 100644
index 0000000..e1bc7b6
--- /dev/null
+++ b/packages/angular/src/lib/ui/accordion/src/index.ts
@@ -0,0 +1,16 @@
+import { HlmAccordion } from './lib/hlm-accordion';
+import { HlmAccordionContent } from './lib/hlm-accordion-content';
+import { HlmAccordionItem } from './lib/hlm-accordion-item';
+import { HlmAccordionTrigger } from './lib/hlm-accordion-trigger';
+
+export * from './lib/hlm-accordion';
+export * from './lib/hlm-accordion-content';
+export * from './lib/hlm-accordion-item';
+export * from './lib/hlm-accordion-trigger';
+
+export const HlmAccordionImports = [
+ HlmAccordion,
+ HlmAccordionItem,
+ HlmAccordionContent,
+ HlmAccordionTrigger,
+] as const;
diff --git a/packages/angular/src/lib/ui/accordion/src/lib/hlm-accordion-content.ts b/packages/angular/src/lib/ui/accordion/src/lib/hlm-accordion-content.ts
new file mode 100644
index 0000000..a194555
--- /dev/null
+++ b/packages/angular/src/lib/ui/accordion/src/lib/hlm-accordion-content.ts
@@ -0,0 +1,27 @@
+import { ChangeDetectionStrategy, Component } from '@angular/core';
+import { BrnAccordionContent } from '@spartan-ng/brain/accordion';
+import { classes } from '../../../utils/src';
+
+@Component({
+ selector: 'hlm-accordion-content',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ hostDirectives: [{ directive: BrnAccordionContent, inputs: ['style'] }],
+ host: {
+ 'data-slot': 'accordion-content',
+ },
+ template: `
+
+ `,
+})
+export class HlmAccordionTrigger {
+ public readonly triggerClass = input('');
+
+ protected readonly _computedTriggerClass = computed(() =>
+ hlm(
+ 'focus-visible:ring-ring/50 focus-visible:border-ring focus-visible:after:border-ring **:data-[slot=accordion-trigger-icon]:text-muted-foreground! rounded-md py-4 text-start text-sm font-medium hover:underline focus-visible:ring-3 **:data-[slot=accordion-trigger-icon]:ms-auto **:data-[slot=accordion-trigger-icon]:text-[calc(var(--spacing)*4)] group/accordion-trigger relative flex flex-1 items-start justify-between border border-transparent transition-all outline-none aria-disabled:pointer-events-none aria-disabled:opacity-50',
+ this.triggerClass(),
+ ),
+ );
+}
diff --git a/packages/angular/src/lib/ui/accordion/src/lib/hlm-accordion.stories.ts b/packages/angular/src/lib/ui/accordion/src/lib/hlm-accordion.stories.ts
new file mode 100644
index 0000000..e6d1935
--- /dev/null
+++ b/packages/angular/src/lib/ui/accordion/src/lib/hlm-accordion.stories.ts
@@ -0,0 +1,104 @@
+import { argsToTemplate, moduleMetadata, type Meta, type StoryObj } from '@storybook/angular';
+import { accordionContract } from '@surfnet/curve-contracts';
+
+import { HlmAccordion, HlmAccordionImports } from '..';
+
+// `type` is contributed by the BrnAccordion host directive rather than HlmAccordion
+// itself, so widen the story args to expose it as a control.
+type AccordionArgs = HlmAccordion & {
+ type: 'single' | 'multiple';
+};
+
+const meta: Meta = {
+ title: 'Components/Accordion',
+ component: HlmAccordion,
+ decorators: [
+ moduleMetadata({
+ imports: [HlmAccordionImports],
+ }),
+ ],
+ parameters: {
+ docs: {
+ description: {
+ component: accordionContract.docs.description,
+ },
+ },
+ },
+ argTypes: {
+ type: {
+ control: 'radio',
+ options: ['single', 'multiple'],
+ description: 'Whether one or multiple items can be expanded at the same time.',
+ table: { defaultValue: { summary: 'single' } },
+ },
+ },
+ args: {
+ type: 'single',
+ },
+};
+
+export default meta;
+type Story = StoryObj;
+
+/** The default accordion — tweak `type` via the controls. */
+export const Default: Story = {
+ render: (args) => ({
+ props: args,
+ template: `
+
+
+ Is it accessible?
+ Yes. It adheres to the WAI-ARIA accordion design pattern.
+
+
+ Is it styled?
+ Yes. It comes with default styles that match the rest of the design system.
+
+
+ Is it animated?
+ Yes. It's animated by default, but you can disable it if you prefer.
+
+
+ `,
+ }),
+};
+
+/** With `type="multiple"`, several items can stay expanded at the same time. */
+export const Multiple: Story = {
+ render: () => ({
+ template: `
+
+
+ Is it accessible?
+ Yes. It adheres to the WAI-ARIA accordion design pattern.
+
+
+ Is it styled?
+ Yes. It comes with default styles that match the rest of the design system.
+
+
+ Is it animated?
+ Yes. It's animated by default, but you can disable it if you prefer.
+
+
+ `,
+ }),
+};
+
+/** A single item can be disabled independently of the rest of the accordion. */
+export const DisabledItem: Story = {
+ render: () => ({
+ template: `
+
+
+ Is it accessible?
+ Yes. It adheres to the WAI-ARIA accordion design pattern.
+
+
+ Disabled item
+ This content is unreachable while the item is disabled.
+
+ `,
+})
+class AlertVariants {
+ @Input() alertContract!: typeof alertContract;
+}
+
+/** Every visual variant side by side. */
+export const Variants: Story = {
+ render: () => ({
+ moduleMetadata: {
+ imports: [AlertVariants],
+ },
+ props: { alertContract },
+ template: '',
+ }),
+};
+
+/** Destructive alert used to surface an error, with title and description. */
+export const Destructive: Story = {
+ render: () => ({
+ template: `
+
+
+
Unable to process payment
+
Please verify your billing details and try again.
+
+ `,
+ }),
+};
+
+/** An action slot (e.g. a button) anchored to the top-right of the alert. */
+export const WithAction: Story = {
+ render: () => ({
+ template: `
+
+
+
Update available
+
A new version is ready to install.
+
+
+
+
+ `,
+ }),
+};
+
+/** Title only — no icon or description. */
+export const TitleOnly: Story = {
+ render: () => ({
+ template: `
+
+
Heads up, this action can't be undone.
+
+ `,
+ }),
+};
diff --git a/packages/angular/src/lib/ui/alert/src/lib/hlm-alert.ts b/packages/angular/src/lib/ui/alert/src/lib/hlm-alert.ts
new file mode 100644
index 0000000..d811cee
--- /dev/null
+++ b/packages/angular/src/lib/ui/alert/src/lib/hlm-alert.ts
@@ -0,0 +1,37 @@
+import { Directive, input } from '@angular/core';
+import { classes } from '../../../utils/src';
+import type { AlertVariantName } from '@surfnet/curve-contracts';
+import { cva, type VariantProps } from 'class-variance-authority';
+
+const alertVariants = cva(
+ "grid gap-0.5 rounded-lg border px-4 py-3 text-start text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>ng-icon]:grid-cols-[auto_1fr] has-[>ng-icon]:gap-x-2.5 *:[ng-icon]:row-span-2 *:[ng-icon]:translate-y-0.5 *:[ng-icon]:text-current *:[ng-icon:not([class*='text-'])]:text-[calc(var(--spacing)*4)] group/alert relative w-full",
+ {
+ variants: {
+ variant: {
+ default: 'bg-card text-card-foreground',
+ destructive:
+ 'text-destructive bg-card *:data-[slot=alert-description]:text-destructive/90 *:[ng-icon]:text-current',
+ } satisfies Record,
+ },
+ defaultVariants: {
+ variant: 'default',
+ },
+ },
+);
+
+export type AlertVariants = VariantProps;
+
+@Directive({
+ selector: 'hlm-alert,[hlmAlert]',
+ host: {
+ 'data-slot': 'alert',
+ role: 'alert',
+ },
+})
+export class HlmAlert {
+ public readonly variant = input('default');
+
+ constructor() {
+ classes(() => alertVariants({ variant: this.variant() }));
+ }
+}
diff --git a/packages/angular/src/lib/ui/aspect-ratio/src/index.ts b/packages/angular/src/lib/ui/aspect-ratio/src/index.ts
new file mode 100644
index 0000000..b7483f8
--- /dev/null
+++ b/packages/angular/src/lib/ui/aspect-ratio/src/index.ts
@@ -0,0 +1,5 @@
+import { HlmAspectRatio } from './lib/hlm-aspect-ratio';
+
+export * from './lib/hlm-aspect-ratio';
+
+export const HlmAspectRatioImports = [HlmAspectRatio] as const;
diff --git a/packages/angular/src/lib/ui/aspect-ratio/src/lib/hlm-aspect-ratio.stories.ts b/packages/angular/src/lib/ui/aspect-ratio/src/lib/hlm-aspect-ratio.stories.ts
new file mode 100644
index 0000000..659a63b
--- /dev/null
+++ b/packages/angular/src/lib/ui/aspect-ratio/src/lib/hlm-aspect-ratio.stories.ts
@@ -0,0 +1,126 @@
+import { type Meta, type StoryObj, moduleMetadata } from '@storybook/angular';
+import { aspectRatioContract } from '@surfnet/curve-contracts';
+import { HlmAspectRatio, HlmAspectRatioImports } from '..';
+import { HlmCardImports } from '../../../card/src';
+
+const meta: Meta = {
+ title: 'Components/AspectRatio',
+ component: HlmAspectRatio,
+ decorators: [
+ moduleMetadata({
+ imports: [HlmAspectRatioImports, HlmCardImports],
+ }),
+ ],
+ parameters: {
+ docs: {
+ description: {
+ component: aspectRatioContract.docs.description,
+ },
+ },
+ },
+ argTypes: {
+ ratio: {
+ control: 'text',
+ description:
+ 'Width / height ratio the content is constrained to — a number or a `"w/h"` string.',
+ table: {
+ defaultValue: { summary: '1' },
+ },
+ },
+ },
+ args: {
+ ratio: '16/9',
+ },
+};
+
+export default meta;
+type Story = StoryObj;
+
+/** The default aspect ratio — tweak the ratio via the controls. */
+export const Default: Story = {
+ render: ({ ratio }) => ({
+ props: { ratio },
+ template: `
+
+
+
+ `,
+ }),
+};
+
+/** Common ratios side by side, each constraining the same photo. */
+export const Ratios: Story = {
+ render: () => ({
+ template: `
+
+
+
+
+
+ Square — 1/1
+
+
+
+
+
+ Standard — 4/3
+
+
+
+
+
+ Widescreen — 16/9
+
+
+
+
+
+ Ultrawide — 21/9
+
+
+ `,
+ }),
+};
+
+/** Composed inside `hlm-card` — a media block constrained to 16/9 above the copy. */
+export const InCard: Story = {
+ render: () => ({
+ template: `
+
+
+
Mountain sunrise
+
A 16/9 image constrained by AspectRatio.
+
+
+
+
+
+
+
+ `,
+ }),
+};
diff --git a/packages/angular/src/lib/ui/aspect-ratio/src/lib/hlm-aspect-ratio.ts b/packages/angular/src/lib/ui/aspect-ratio/src/lib/hlm-aspect-ratio.ts
new file mode 100644
index 0000000..6c93b77
--- /dev/null
+++ b/packages/angular/src/lib/ui/aspect-ratio/src/lib/hlm-aspect-ratio.ts
@@ -0,0 +1,35 @@
+import { type NumberInput, coerceNumberProperty } from '@angular/cdk/coercion';
+import { Directive, input } from '@angular/core';
+import { classes } from '../../../utils/src';
+
+const parseDividedString = (value: NumberInput): NumberInput => {
+ if (typeof value !== 'string' || !value.includes('/')) return value;
+ return value
+ .split('/')
+ .map((v) => Number.parseInt(v, 10))
+ .reduce((a, b) => a / b);
+};
+
+@Directive({
+ selector: '[hlmAspectRatio]',
+ host: {
+ 'data-slot': 'aspect-ratio',
+ '[style.--ratio]': 'ratio()',
+ },
+})
+export class HlmAspectRatio {
+ /**
+ * Aspect ratio of the element, defined as width / height.
+ */
+ public readonly ratio = input(1, {
+ alias: 'hlmAspectRatio',
+ transform: (value: NumberInput) => {
+ const coerced = coerceNumberProperty(parseDividedString(value));
+ return coerced <= 0 ? 1 : coerced;
+ },
+ });
+
+ constructor() {
+ classes(() => 'relative aspect-(--ratio)');
+ }
+}
diff --git a/packages/angular/src/lib/ui/badge/src/index.ts b/packages/angular/src/lib/ui/badge/src/index.ts
new file mode 100644
index 0000000..19293cf
--- /dev/null
+++ b/packages/angular/src/lib/ui/badge/src/index.ts
@@ -0,0 +1,5 @@
+import { HlmBadge } from './lib/hlm-badge';
+
+export * from './lib/hlm-badge';
+
+export const HlmBadgeImports = [HlmBadge] as const;
diff --git a/packages/angular/src/lib/ui/badge/src/lib/hlm-badge.stories.ts b/packages/angular/src/lib/ui/badge/src/lib/hlm-badge.stories.ts
new file mode 100644
index 0000000..13432a9
--- /dev/null
+++ b/packages/angular/src/lib/ui/badge/src/lib/hlm-badge.stories.ts
@@ -0,0 +1,141 @@
+import { Component, Input } from '@angular/core';
+import { NgIcon, provideIcons } from '@ng-icons/core';
+import { phosphorCheck } from '@ng-icons/phosphor-icons/regular';
+import { argsToTemplate, moduleMetadata, type Meta, type StoryObj } from '@storybook/angular';
+import { badgeContract } from '@surfnet/curve-contracts';
+
+import { HlmBadge, HlmBadgeImports } from '..';
+
+const meta: Meta = {
+ title: 'Components/Badge',
+ component: HlmBadge,
+ decorators: [
+ moduleMetadata({
+ imports: [HlmBadgeImports, NgIcon],
+ providers: [provideIcons({ phosphorCheck })],
+ }),
+ ],
+ parameters: {
+ docs: {
+ description: {
+ component: badgeContract.docs.description,
+ },
+ },
+ },
+ argTypes: {
+ variant: {
+ control: 'select',
+ options: [...badgeContract.props.variants],
+ description: 'Visual style of the badge.',
+ table: {
+ type: {
+ summary: badgeContract.props.variants.join(' | '),
+ },
+ defaultValue: { summary: badgeContract.defaults.variants },
+ },
+ },
+ },
+ args: {
+ variant: badgeContract.defaults.variants,
+ },
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+/** The default badge — rendered with the default args; tweak them via the controls. */
+export const Default: Story = {
+ render: (args) => ({
+ props: args,
+ template: `Badge`,
+ }),
+};
+
+@Component({
+ selector: 'badge-variants',
+ imports: [HlmBadgeImports],
+ template: `
+
+ `,
+})
+class BadgeVariants {
+ @Input() badgeContract!: typeof badgeContract;
+}
+
+/** Every visual variant side by side. */
+export const Variants: Story = {
+ render: () => ({
+ moduleMetadata: {
+ imports: [BadgeVariants],
+ },
+ props: { badgeContract: badgeContract },
+ template: '',
+ }),
+};
+
+/**
+ * Icons alongside text. Tag the icon with `data-icon="inline-start"` or
+ * `data-icon="inline-end"` so the badge tightens the padding on that side.
+ */
+export const WithIcon: Story = {
+ render: () => ({
+ template: `
+
+
+
+ Verified
+
+
`,
+ }),
+};
+
+/** Numeric counts, sized to stay circular at one or two digits. */
+export const Counts: Story = {
+ render: () => ({
+ template: `
+
+ 8
+ 99
+ 20+
+
`,
+ }),
+};
+
+/** The `hlmBadge` directive works on any host element — here it renders as an anchor. */
+export const AsLink: Story = {
+ render: () => ({
+ template: `Visit SURF`,
+ }),
+};
+
+/** Realistic composed usage: status labels attached to a list of items. */
+export const StatusList: Story = {
+ render: () => ({
+ template: `
+
+ `,
+ }),
+};
+
+/** Buttons stacked in a column instead of a row. */
+export const Vertical: Story = {
+ render: () => ({
+ template: `
+
+
+
+
+
+ `,
+ }),
+};
+
+/**
+ * Nesting groups (with a `hlmButtonGroupSeparator` between them) splits the buttons into
+ * logical clusters while keeping the connected-border look within each cluster.
+ */
+export const WithSeparator: Story = {
+ render: () => ({
+ template: `
+
+ `,
+ }),
+};
+
+/**
+ * A real-world composed example: a search field grouped with a submit button and an
+ * overflow menu trigger. The group's CSS makes the `` flex to fill the space.
+ */
+export const WithInput: Story = {
+ render: () => ({
+ template: `
+
+
+
+ Toggle columns
+
+
+
+
+
+ `,
+ }),
+};
+
+/** Radio items for choosing a single option from a set. */
+export const WithRadioItems: Story = {
+ render: () => ({
+ template: `
+
+ Right click here
+
+
+
+ View
+
+
+
+
+
+ `,
+ }),
+};
+
+/** A submenu nested inside the main menu, opened via a nested trigger item. */
+export const WithSubmenu: Story = {
+ render: () => ({
+ template: `
+
+ Right click here
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `,
+ }),
+};
diff --git a/packages/angular/src/lib/ui/date-picker/src/index.ts b/packages/angular/src/lib/ui/date-picker/src/index.ts
new file mode 100644
index 0000000..c524253
--- /dev/null
+++ b/packages/angular/src/lib/ui/date-picker/src/index.ts
@@ -0,0 +1,26 @@
+import { HlmDatePicker } from './lib/hlm-date-picker';
+import { HlmDatePickerAnchor } from './lib/hlm-date-picker-anchor';
+import { HlmDatePickerInput } from './lib/hlm-date-picker-input';
+import { HlmDatePickerMulti } from './lib/hlm-date-picker-multi';
+import { HlmDatePickerTrigger } from './lib/hlm-date-picker-trigger';
+import { HlmDateRangePicker } from './lib/hlm-date-range-picker';
+
+export * from './lib/hlm-date-picker';
+export * from './lib/hlm-date-picker-anchor';
+export * from './lib/hlm-date-picker-input';
+export * from './lib/hlm-date-picker-multi';
+export * from './lib/hlm-date-picker-multi.token';
+export * from './lib/hlm-date-picker-trigger';
+export * from './lib/hlm-date-picker-trigger.token';
+export * from './lib/hlm-date-picker.token';
+export * from './lib/hlm-date-range-picker';
+export * from './lib/hlm-date-range-picker.token';
+
+export const HlmDatePickerImports = [
+ HlmDatePicker,
+ HlmDatePickerAnchor,
+ HlmDatePickerInput,
+ HlmDatePickerMulti,
+ HlmDateRangePicker,
+ HlmDatePickerTrigger,
+] as const;
diff --git a/packages/angular/src/lib/ui/date-picker/src/lib/hlm-date-picker-anchor.ts b/packages/angular/src/lib/ui/date-picker/src/lib/hlm-date-picker-anchor.ts
new file mode 100644
index 0000000..bfa8a16
--- /dev/null
+++ b/packages/angular/src/lib/ui/date-picker/src/lib/hlm-date-picker-anchor.ts
@@ -0,0 +1,23 @@
+import { Directive, effect, ElementRef, inject, input, linkedSignal } from '@angular/core';
+import { BrnOverlay } from '@spartan-ng/brain/overlay';
+import { BrnPopover } from '@spartan-ng/brain/popover';
+
+@Directive({ selector: '[hlmDatePickerAnchor]' })
+export class HlmDatePickerAnchor {
+ private readonly _host = inject(ElementRef, { host: true });
+ private readonly _brnOverlay = inject(BrnOverlay, { optional: true });
+
+ public readonly hlmDatePickerAnchorForInput = input(undefined, {
+ alias: 'hlmDatePickerAnchorFor',
+ });
+
+ public readonly hlmDatePickerAnchorFor = linkedSignal(this.hlmDatePickerAnchorForInput);
+
+ constructor() {
+ effect(() => {
+ this.hlmDatePickerAnchorFor()?.setOrigin(this._host.nativeElement);
+ });
+
+ this._brnOverlay?.setOrigin(this._host.nativeElement);
+ }
+}
diff --git a/packages/angular/src/lib/ui/date-picker/src/lib/hlm-date-picker-input.ts b/packages/angular/src/lib/ui/date-picker/src/lib/hlm-date-picker-input.ts
new file mode 100644
index 0000000..0a456b9
--- /dev/null
+++ b/packages/angular/src/lib/ui/date-picker/src/lib/hlm-date-picker-input.ts
@@ -0,0 +1,197 @@
+import { BooleanInput } from '@angular/cdk/coercion';
+import {
+ booleanAttribute,
+ ChangeDetectionStrategy,
+ Component,
+ computed,
+ input,
+ linkedSignal,
+} from '@angular/core';
+import { NgIcon, provideIcons } from '@ng-icons/core';
+import { phosphorCalendar, phosphorX } from '@ng-icons/phosphor-icons/regular';
+import { HlmButtonImports } from '../../../button/src';
+import { HlmInputGroupImports } from '../../../input-group/src';
+import { HlmDatePickerAnchor } from './hlm-date-picker-anchor';
+import {
+ HlmDatePickerTriggerBase,
+ provideHlmDatePickerTrigger,
+} from './hlm-date-picker-trigger.token';
+import { injectHlmDatePicker, injectHlmDatePickerConfig } from './hlm-date-picker.token';
+
+@Component({
+ selector: 'hlm-date-picker-input',
+ imports: [HlmInputGroupImports, HlmButtonImports, HlmDatePickerAnchor, NgIcon],
+ providers: [
+ provideIcons({ phosphorCalendar, phosphorX }),
+ provideHlmDatePickerTrigger(HlmDatePickerInput),
+ ],
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ template: `
+
+
+
+ @if (_showClearButton()) {
+
+ }
+
+
+
+ `,
+})
+export class HlmDatePickerInput implements HlmDatePickerTriggerBase {
+ private static _nextId = 0;
+ private readonly _datePicker = injectHlmDatePicker();
+ private readonly _config = injectHlmDatePickerConfig();
+
+ protected readonly _popover = this._datePicker.popover;
+ protected readonly _disabled = this._datePicker.disabledState;
+
+ public readonly inputId = input(`hlm-date-picker-input-${HlmDatePickerInput._nextId++}`);
+
+ public readonly placeholder = input('');
+
+ public readonly inputValue = input('');
+
+ /**
+ * Parses input text into a date value. Return `undefined` for invalid
+ * input - the picker's date is cleared while the text is preserved so
+ * the user can fix it.
+ *
+ * Defaults to `parseDate` from `HlmDatePickerConfig`.
+ */
+ public readonly parseDate = input<(value: string) => T | undefined>(this._config.parseDate);
+
+ public readonly forceInvalid = input(false, {
+ transform: booleanAttribute,
+ });
+
+ /** Show a clear button that resets the input and picker date. Hidden when empty. */
+ public readonly showClear = input(true, { transform: booleanAttribute });
+
+ /** Open the popover on input click. */
+ public readonly openOnClick = input(false, {
+ transform: booleanAttribute,
+ });
+
+ /** Accessible label for the clear button. */
+ public readonly clearAriaLabel = input('Clear date');
+
+ /** Accessible label for the calendar trigger button. */
+ public readonly calendarAriaLabel = input('Open calendar');
+
+ /** @internal Id used by the trigger contract for labeling. */
+ public readonly triggerId = this.inputId;
+
+ /**
+ * Text shown in the input. Mirrors the picker's `formattedDate` and the
+ * parent's `inputValue`, and accepts user writes via `_handleInputChange`.
+ * Commits only happen on blur / Enter, so in-progress text isn't clobbered.
+ */
+ protected readonly _inputValue = linkedSignal<
+ { formatted: string | undefined; inputValue: string },
+ string
+ >({
+ source: () => ({
+ formatted: this._datePicker.formattedDate(),
+ inputValue: this.inputValue(),
+ }),
+ computation: (source, previous) => {
+ // First render: prefer formatted, fall back to inputValue.
+ if (previous === undefined) {
+ return source.formatted ?? source.inputValue;
+ }
+
+ // Picker's formatted date changed - snap to canonical format.
+ if (source.formatted !== previous.source.formatted) {
+ if (source.formatted !== undefined) {
+ return source.formatted;
+ }
+ // Cleared externally vs. user has invalid text in flight: only
+ // mirror the clear when the displayed text was in sync.
+ return previous.value === previous.source.formatted ? '' : previous.value;
+ }
+
+ // Parent updated inputValue - reflect it.
+ if (source.inputValue !== previous.source.inputValue) {
+ return source.inputValue;
+ }
+
+ return previous.value;
+ },
+ });
+
+ protected _handleInputChange(event: Event) {
+ const text = (event.target as HTMLInputElement).value;
+ this._inputValue.set(text);
+ }
+
+ protected readonly _showClearButton = computed(
+ () => this.showClear() && this._inputValue().length > 0,
+ );
+
+ protected _clear() {
+ this._inputValue.set('');
+ this._datePicker.updateDate?.(undefined);
+ this._datePicker.touched?.();
+ }
+
+ protected _handleEnter(event: Event) {
+ event.preventDefault();
+ this._commitDate();
+ this._popover().close();
+ }
+
+ protected _commitDate() {
+ const value = this._inputValue();
+
+ if (!value) {
+ this._datePicker.updateDate?.(undefined);
+ this._datePicker.touched?.();
+ return;
+ }
+
+ // Invalid parse: clear the picker date, keep the text so the user can fix it.
+ const parsed = this.parseDate()(value);
+ this._datePicker.updateDate?.(parsed ?? undefined);
+ this._datePicker.touched?.();
+ }
+
+ protected _open() {
+ this._popover().open();
+ }
+
+ protected _handleClick() {
+ if (this.openOnClick()) {
+ this._open();
+ }
+ }
+}
diff --git a/packages/angular/src/lib/ui/date-picker/src/lib/hlm-date-picker-multi.token.ts b/packages/angular/src/lib/ui/date-picker/src/lib/hlm-date-picker-multi.token.ts
new file mode 100644
index 0000000..033ab85
--- /dev/null
+++ b/packages/angular/src/lib/ui/date-picker/src/lib/hlm-date-picker-multi.token.ts
@@ -0,0 +1,48 @@
+import { inject, InjectionToken, type ValueProvider } from '@angular/core';
+
+export interface HlmDatePickerMultiConfig {
+ /**
+ * If true, the date picker will close when the max selection of dates is reached.
+ */
+ autoCloseOnMaxSelection: boolean;
+
+ /**
+ * Defines how the date should be displayed in the UI.
+ *
+ * @param dates
+ * @returns formatted date
+ */
+ formatDates: (dates: T[]) => string;
+
+ /**
+ * Defines how the date should be transformed before saving to model/form.
+ *
+ * @param dates
+ * @returns transformed date
+ */
+ transformDates: (dates: T[]) => T[];
+}
+
+function getDefaultConfig(): HlmDatePickerMultiConfig {
+ return {
+ formatDates: (dates) =>
+ dates.map((date) => (date instanceof Date ? date.toDateString() : `${date}`)).join(', '),
+ transformDates: (dates) => dates,
+ autoCloseOnMaxSelection: false,
+ };
+}
+
+const HlmDatePickerMultiConfigToken = new InjectionToken>(
+ 'HlmDatePickerMultiConfig',
+);
+
+export function provideHlmDatePickerMultiConfig(
+ config: Partial>,
+): ValueProvider {
+ return { provide: HlmDatePickerMultiConfigToken, useValue: { ...getDefaultConfig(), ...config } };
+}
+
+export function injectHlmDatePickerMultiConfig(): HlmDatePickerMultiConfig {
+ const injectedConfig = inject(HlmDatePickerMultiConfigToken, { optional: true });
+ return injectedConfig ? (injectedConfig as HlmDatePickerMultiConfig) : getDefaultConfig();
+}
diff --git a/packages/angular/src/lib/ui/date-picker/src/lib/hlm-date-picker-multi.ts b/packages/angular/src/lib/ui/date-picker/src/lib/hlm-date-picker-multi.ts
new file mode 100644
index 0000000..94845d7
--- /dev/null
+++ b/packages/angular/src/lib/ui/date-picker/src/lib/hlm-date-picker-multi.ts
@@ -0,0 +1,189 @@
+import type { BooleanInput, NumberInput } from '@angular/cdk/coercion';
+import {
+ booleanAttribute,
+ ChangeDetectionStrategy,
+ Component,
+ computed,
+ contentChild,
+ forwardRef,
+ input,
+ linkedSignal,
+ numberAttribute,
+ output,
+ signal,
+ viewChild,
+} from '@angular/core';
+import { type ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
+import type { BrnDialogState } from '@spartan-ng/brain/dialog';
+import { BrnFieldControl, provideBrnLabelable } from '@spartan-ng/brain/field';
+import type { ChangeFn, TouchFn } from '@spartan-ng/brain/forms';
+import { BrnPopover } from '@spartan-ng/brain/popover';
+import { HlmCalendarMulti } from '../../../calendar/src';
+import { HlmPopoverImports } from '../../../popover/src';
+import { injectHlmDatePickerMultiConfig } from './hlm-date-picker-multi.token';
+import { HlmDatePickerTriggerToken } from './hlm-date-picker-trigger.token';
+import { HlmDatePickerBase, provideHlmDatePicker } from './hlm-date-picker.token';
+
+export const HLM_DATE_PICKER_MUTLI_VALUE_ACCESSOR = {
+ provide: NG_VALUE_ACCESSOR,
+ useExisting: forwardRef(() => HlmDatePickerMulti),
+ multi: true,
+};
+
+@Component({
+ selector: 'hlm-date-picker-multi',
+ imports: [HlmPopoverImports, HlmCalendarMulti],
+ providers: [
+ HLM_DATE_PICKER_MUTLI_VALUE_ACCESSOR,
+ provideHlmDatePicker(HlmDatePickerMulti),
+ provideBrnLabelable(HlmDatePickerMulti),
+ ],
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ hostDirectives: [BrnFieldControl],
+ host: { class: 'block' },
+ template: `
+
+
+
+
+
+
+
+
+
+ `,
+})
+export class HlmDatePickerMulti implements HlmDatePickerBase, ControlValueAccessor {
+ private readonly _config = injectHlmDatePickerMultiConfig();
+
+ public readonly popover = viewChild.required(BrnPopover);
+
+ private readonly _trigger = contentChild(HlmDatePickerTriggerToken);
+
+ /** Show dropdowns to navigate between months or years. */
+ public readonly captionLayout = input<
+ 'dropdown' | 'label' | 'dropdown-months' | 'dropdown-years'
+ >('label');
+
+ /** The minimum date that can be selected.*/
+ public readonly min = input();
+
+ /** The maximum date that can be selected. */
+ public readonly max = input();
+
+ /** The minimum selectable dates. */
+ public readonly minSelection = input(undefined, {
+ transform: numberAttribute,
+ });
+
+ /** The maximum selectable dates. */
+ public readonly maxSelection = input(undefined, {
+ transform: numberAttribute,
+ });
+
+ /** Determine if the date picker is disabled. */
+ public readonly disabled = input(false, {
+ transform: booleanAttribute,
+ });
+
+ /** The selected value. */
+ public readonly date = input();
+
+ protected readonly _mutableDate = linkedSignal(this.date);
+
+ /** If true, the date picker will close when the max selection of dates is reached. */
+ public readonly autoCloseOnMaxSelection = input(
+ this._config.autoCloseOnMaxSelection,
+ {
+ transform: booleanAttribute,
+ },
+ );
+
+ /** Defines how the date should be displayed in the UI. */
+ public readonly formatDates = input<(date: T[]) => string>(this._config.formatDates);
+
+ /** Defines how the date should be transformed before saving to model/form. */
+ public readonly transformDates = input<(date: T[]) => T[]>(this._config.transformDates);
+
+ protected readonly _popoverState = signal(null);
+
+ protected readonly _disabled = linkedSignal(this.disabled);
+
+ /** @internal The disabled state as a readonly signal */
+ public readonly disabledState = this._disabled.asReadonly();
+
+ public readonly formattedDate = computed(() => {
+ const dates = this._mutableDate();
+ return dates ? this.formatDates()(dates) : undefined;
+ });
+
+ public readonly dateChange = output();
+
+ public readonly labelableId = computed(() => this._trigger()?.triggerId());
+
+ public readonly hasDate = computed(() => !!this._mutableDate()?.length);
+
+ protected _onChange?: ChangeFn;
+ protected _onTouched?: TouchFn;
+
+ protected _handleChange(value: T[] | undefined) {
+ if (value === undefined) return;
+
+ if (this._disabled()) return;
+ const transformedDate = value !== undefined ? this.transformDates()(value) : value;
+
+ this._mutableDate.set(transformedDate);
+ this._onChange?.(transformedDate);
+ this.dateChange.emit(transformedDate);
+
+ if (this.autoCloseOnMaxSelection() && this._mutableDate()?.length === this.maxSelection()) {
+ this._popoverState.set('closed');
+ }
+ }
+
+ /** CONTROL VALUE ACCESSOR */
+ public writeValue(value: T[] | null): void {
+ this._mutableDate.set(value ? this.transformDates()(value) : undefined);
+ }
+
+ public registerOnChange(fn: ChangeFn): void {
+ this._onChange = fn;
+ }
+
+ public registerOnTouched(fn: TouchFn): void {
+ this._onTouched = fn;
+ }
+
+ public setDisabledState(isDisabled: boolean): void {
+ this._disabled.set(isDisabled);
+ }
+
+ public open() {
+ this._popoverState.set('open');
+ }
+
+ public close() {
+ this._popoverState.set('closed');
+ }
+
+ public reset() {
+ this._mutableDate.set(undefined);
+ this._onChange?.([]);
+ this.dateChange.emit([]);
+ }
+}
diff --git a/packages/angular/src/lib/ui/date-picker/src/lib/hlm-date-picker-trigger.token.ts b/packages/angular/src/lib/ui/date-picker/src/lib/hlm-date-picker-trigger.token.ts
new file mode 100644
index 0000000..322d872
--- /dev/null
+++ b/packages/angular/src/lib/ui/date-picker/src/lib/hlm-date-picker-trigger.token.ts
@@ -0,0 +1,15 @@
+import { type ExistingProvider, InjectionToken, type Signal, type Type } from '@angular/core';
+
+export interface HlmDatePickerTriggerBase {
+ triggerId: Signal;
+}
+
+export const HlmDatePickerTriggerToken = new InjectionToken(
+ 'HlmDatePickerTriggerToken',
+);
+
+export function provideHlmDatePickerTrigger(
+ instance: Type,
+): ExistingProvider {
+ return { provide: HlmDatePickerTriggerToken, useExisting: instance };
+}
diff --git a/packages/angular/src/lib/ui/date-picker/src/lib/hlm-date-picker-trigger.ts b/packages/angular/src/lib/ui/date-picker/src/lib/hlm-date-picker-trigger.ts
new file mode 100644
index 0000000..9bc694b
--- /dev/null
+++ b/packages/angular/src/lib/ui/date-picker/src/lib/hlm-date-picker-trigger.ts
@@ -0,0 +1,104 @@
+import { BooleanInput } from '@angular/cdk/coercion';
+import {
+ booleanAttribute,
+ ChangeDetectionStrategy,
+ Component,
+ computed,
+ inject,
+ input,
+} from '@angular/core';
+import { NgIcon, provideIcons } from '@ng-icons/core';
+import { phosphorCaretDown } from '@ng-icons/phosphor-icons/regular';
+import { BrnFieldControl, BrnFieldControlDescribedBy } from '@spartan-ng/brain/field';
+import { ButtonVariants, HlmButtonImports } from '../../../button/src';
+import { HlmPopoverTrigger } from '../../../popover/src';
+import { hlm } from '../../../utils/src';
+import { ClassValue } from 'clsx';
+import {
+ HlmDatePickerTriggerBase,
+ provideHlmDatePickerTrigger,
+} from './hlm-date-picker-trigger.token';
+import { injectHlmDatePicker } from './hlm-date-picker.token';
+
+@Component({
+ selector: 'hlm-date-picker-trigger',
+ imports: [HlmButtonImports, HlmPopoverTrigger, NgIcon, BrnFieldControlDescribedBy],
+ providers: [
+ provideIcons({ phosphorCaretDown }),
+ provideHlmDatePickerTrigger(HlmDatePickerTrigger),
+ ],
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ host: { 'data-slot': 'date-picker-trigger' },
+ template: `
+
+ `,
+})
+export class HlmDatePickerTrigger implements HlmDatePickerTriggerBase {
+ private static _nextId = 0;
+
+ private readonly _fieldControl = inject(BrnFieldControl, { optional: true });
+ private readonly _datePicker = injectHlmDatePicker();
+
+ private readonly _invalid = this._fieldControl?.invalid;
+ protected readonly _spartanInvalid = computed(
+ () => this.forceInvalid() || this._fieldControl?.spartanInvalid(),
+ );
+ protected readonly _dirty = this._fieldControl?.dirty;
+ protected readonly _touched = this._fieldControl?.touched;
+
+ protected readonly _ariaInvalid = computed(() => (this._invalid?.() ? 'true' : null));
+
+ public readonly userClass = input('', { alias: 'class' });
+ protected readonly _computedClass = computed(() =>
+ hlm('data-placeholder:text-muted-foreground w-64 justify-between', this.userClass()),
+ );
+
+ protected readonly _isPlaceholder = computed(() => !this._datePicker.hasDate());
+
+ /** The id of the button that opens the date picker. */
+ public readonly buttonId = input(`hlm-date-picker-${++HlmDatePickerTrigger._nextId}`);
+
+ /** @internal The id of the button that opens the date picker, used for labeling. */
+ public readonly triggerId = this.buttonId;
+
+ /** Forces the invalid state visually, regardless of form control state. */
+ public readonly forceInvalid = input(false, {
+ transform: booleanAttribute,
+ });
+
+ public readonly variant = input('outline');
+
+ public readonly showTrigger = input(true, { transform: booleanAttribute });
+
+ protected readonly _popover = this._datePicker.popover;
+ protected readonly _disabled = this._datePicker.disabledState;
+ protected readonly _formattedDate = this._datePicker.formattedDate;
+}
diff --git a/packages/angular/src/lib/ui/date-picker/src/lib/hlm-date-picker.stories.ts b/packages/angular/src/lib/ui/date-picker/src/lib/hlm-date-picker.stories.ts
new file mode 100644
index 0000000..92da4a7
--- /dev/null
+++ b/packages/angular/src/lib/ui/date-picker/src/lib/hlm-date-picker.stories.ts
@@ -0,0 +1,169 @@
+import { argsToTemplate, type Meta, moduleMetadata, type StoryObj } from '@storybook/angular';
+import { datePickerContract } from '@surfnet/curve-contracts';
+
+import { HlmButtonImports } from '../../../button/src';
+import { HlmDatePicker, HlmDatePickerImports } from '..';
+
+const meta: Meta> = {
+ title: 'Components/DatePicker',
+ component: HlmDatePicker,
+ decorators: [
+ moduleMetadata({
+ imports: [HlmDatePickerImports, HlmButtonImports],
+ }),
+ ],
+ parameters: {
+ docs: {
+ description: {
+ component: datePickerContract.docs.description,
+ },
+ },
+ },
+ argTypes: {
+ captionLayout: {
+ control: 'select',
+ options: ['label', 'dropdown', 'dropdown-months', 'dropdown-years'],
+ description: 'How the month/year caption is rendered in the calendar popover.',
+ table: {
+ defaultValue: { summary: 'label' },
+ },
+ },
+ disabled: {
+ control: 'boolean',
+ description: 'Determine if the date picker is disabled.',
+ table: {
+ defaultValue: { summary: 'false' },
+ },
+ },
+ autoCloseOnSelect: {
+ control: 'boolean',
+ description: 'If true, the date picker closes automatically once a date is selected.',
+ table: {
+ defaultValue: { summary: 'false' },
+ },
+ },
+ },
+ args: {
+ captionLayout: 'label',
+ disabled: false,
+ autoCloseOnSelect: false,
+ },
+};
+
+export default meta;
+type Story = StoryObj>;
+
+/** A single-date picker with a button trigger — tweak the caption, disabled and auto-close controls. */
+export const Default: Story = {
+ render: (args) => ({
+ props: { ...args, date: undefined as Date | undefined },
+ template: `
+
+ Pick a date
+
+ `,
+ }),
+};
+
+/** Pre-filled with a selected date. */
+export const WithValue: Story = {
+ render: () => ({
+ props: { date: new Date() as Date | undefined },
+ template: `
+
+ Pick a date
+
+ `,
+ }),
+};
+
+/**
+ * A text-input trigger instead of a button — type a date directly or use the
+ * calendar/clear buttons. Built from `hlm-date-picker-input`.
+ */
+export const TextInputTrigger: Story = {
+ render: () => ({
+ props: { date: undefined as Date | undefined },
+ template: `
+
+
+
+
+
+ `,
+ }),
+};
+
+/** A contiguous range with a start and end date via `hlm-date-range-picker`. */
+export const Range: Story = {
+ render: () => ({
+ props: { range: undefined as [Date, Date] | undefined },
+ template: `
+
+ Pick a date range
+
+ `,
+ }),
+};
+
+/** Multiple, individually toggled dates via `hlm-date-picker-multi`, capped to 3 selections. */
+export const Multiple: Story = {
+ render: () => ({
+ props: { dates: [] as Date[] },
+ template: `
+
+ Pick up to 3 dates
+
+ `,
+ }),
+};
+
+/** Bounded to a navigable `min`/`max` range — days outside it can't be selected. */
+export const BoundedDates: Story = {
+ render: () => {
+ const today = new Date();
+ const min = new Date(today);
+ min.setDate(today.getDate() - 7);
+ const max = new Date(today);
+ max.setDate(today.getDate() + 14);
+
+ return {
+ props: { date: undefined as Date | undefined, min, max },
+ template: `
+
+ Pick a date
+
+ `,
+ };
+ },
+};
+
+/** Disabled state — the trigger is inert and the popover cannot be opened. */
+export const Disabled: Story = {
+ render: () => ({
+ template: `
+
+ Pick a date
+
+ `,
+ }),
+};
+
+/**
+ * Custom footer content projected via `[hlmDatePickerFooter]`, e.g. quick
+ * actions to jump to today or clear the selection.
+ */
+export const WithFooterActions: Story = {
+ render: () => ({
+ props: { date: new Date() as Date | undefined, today: new Date() },
+ template: `
+
+ Pick a date
+
+
+
+
+
+ `,
+ }),
+};
diff --git a/packages/angular/src/lib/ui/date-picker/src/lib/hlm-date-picker.token.ts b/packages/angular/src/lib/ui/date-picker/src/lib/hlm-date-picker.token.ts
new file mode 100644
index 0000000..a9d8a13
--- /dev/null
+++ b/packages/angular/src/lib/ui/date-picker/src/lib/hlm-date-picker.token.ts
@@ -0,0 +1,93 @@
+import {
+ type ExistingProvider,
+ inject,
+ InjectionToken,
+ type Signal,
+ type Type,
+ type ValueProvider,
+} from '@angular/core';
+import type { BrnPopover } from '@spartan-ng/brain/popover';
+
+export interface HlmDatePickerBase {
+ popover: Signal;
+ disabledState: Signal;
+ formattedDate: Signal;
+ hasDate: Signal;
+ /** Commit a date to the picker (e.g. from a parsed input). Pass `undefined` to clear. Optional. */
+ updateDate?(value: T | undefined): void;
+ // used for ControlValueAccessor
+ touched?(): void;
+}
+
+export const HlmDatePickerToken = new InjectionToken>(
+ 'HlmDatePickerToken',
+);
+
+export function provideHlmDatePicker(instance: Type>): ExistingProvider {
+ return { provide: HlmDatePickerToken, useExisting: instance };
+}
+
+/**
+ * Inject the date picker component.
+ */
+export function injectHlmDatePicker(): HlmDatePickerBase {
+ return inject(HlmDatePickerToken) as HlmDatePickerBase;
+}
+
+export interface HlmDatePickerConfig {
+ /**
+ * If true, the date picker will close when a date is selected.
+ */
+ autoCloseOnSelect: boolean;
+
+ /**
+ * Defines how the date should be displayed in the UI.
+ *
+ * @param date
+ * @returns formatted date
+ */
+ formatDate: (date: T) => string;
+
+ /**
+ * Defines how the date should be transformed before saving to model/form.
+ *
+ * @param date
+ * @returns transformed date
+ */
+ transformDate: (date: T) => T;
+
+ /**
+ * Parse a user-entered string into a date.
+ *
+ * @param value the raw string from the input
+ * @returns the parsed date, or `undefined` when the value can't be parsed
+ */
+ parseDate: (value: string) => T | undefined;
+}
+
+function getDefaultConfig(): HlmDatePickerConfig {
+ return {
+ formatDate: (date) => (date instanceof Date ? date.toDateString() : `${date}`),
+ transformDate: (date) => date,
+ parseDate: (value) => {
+ const date = new Date(value);
+ return isNaN(date.getTime()) ? undefined : (date as T);
+ },
+ autoCloseOnSelect: false,
+ };
+}
+
+const HlmDatePickerConfigToken = new InjectionToken>(
+ 'HlmDatePickerConfig',
+);
+
+export function provideHlmDatePickerConfig(
+ config: Partial>,
+): ValueProvider {
+ return { provide: HlmDatePickerConfigToken, useValue: { ...getDefaultConfig(), ...config } };
+}
+
+export function injectHlmDatePickerConfig(): HlmDatePickerConfig {
+ const injectedConfig = inject(HlmDatePickerConfigToken, { optional: true });
+ return injectedConfig ? (injectedConfig as HlmDatePickerConfig) : getDefaultConfig();
+}
diff --git a/packages/angular/src/lib/ui/date-picker/src/lib/hlm-date-picker.ts b/packages/angular/src/lib/ui/date-picker/src/lib/hlm-date-picker.ts
new file mode 100644
index 0000000..a758eaf
--- /dev/null
+++ b/packages/angular/src/lib/ui/date-picker/src/lib/hlm-date-picker.ts
@@ -0,0 +1,194 @@
+import type { BooleanInput } from '@angular/cdk/coercion';
+import {
+ booleanAttribute,
+ ChangeDetectionStrategy,
+ Component,
+ computed,
+ contentChild,
+ forwardRef,
+ input,
+ linkedSignal,
+ output,
+ signal,
+ viewChild,
+} from '@angular/core';
+import { type ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
+import type { BrnDialogState } from '@spartan-ng/brain/dialog';
+import { BrnFieldControl, provideBrnLabelable } from '@spartan-ng/brain/field';
+import type { ChangeFn, TouchFn } from '@spartan-ng/brain/forms';
+import { BrnPopover } from '@spartan-ng/brain/popover';
+import { HlmCalendar } from '../../../calendar/src';
+import { HlmPopoverImports } from '../../../popover/src';
+import { HlmDatePickerTriggerToken } from './hlm-date-picker-trigger.token';
+import {
+ HlmDatePickerBase,
+ injectHlmDatePickerConfig,
+ provideHlmDatePicker,
+} from './hlm-date-picker.token';
+
+export const HLM_DATE_PICKER_VALUE_ACCESSOR = {
+ provide: NG_VALUE_ACCESSOR,
+ useExisting: forwardRef(() => HlmDatePicker),
+ multi: true,
+};
+
+@Component({
+ selector: 'hlm-date-picker',
+ imports: [HlmPopoverImports, HlmCalendar],
+ providers: [
+ HLM_DATE_PICKER_VALUE_ACCESSOR,
+ provideHlmDatePicker(HlmDatePicker),
+ provideBrnLabelable(HlmDatePicker),
+ ],
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ hostDirectives: [BrnFieldControl],
+ host: { class: 'block' },
+ template: `
+
+
+
+
+
+
+
+
+
+ `,
+})
+export class HlmDatePicker implements HlmDatePickerBase, ControlValueAccessor {
+ private readonly _config = injectHlmDatePickerConfig();
+
+ public readonly popover = viewChild.required(BrnPopover);
+
+ private readonly _trigger = contentChild(HlmDatePickerTriggerToken);
+
+ /** Show dropdowns to navigate between months or years. */
+ public readonly captionLayout = input<
+ 'dropdown' | 'label' | 'dropdown-months' | 'dropdown-years'
+ >('label');
+
+ /** The minimum date that can be selected.*/
+ public readonly min = input();
+
+ /** The maximum date that can be selected. */
+ public readonly max = input();
+
+ /** Determine if the date picker is disabled. */
+ public readonly disabled = input(false, {
+ transform: booleanAttribute,
+ });
+
+ /** The selected value. */
+ public readonly date = input();
+
+ /** The date the calendar focuses on first open when no date is selected. */
+ public readonly defaultFocusedDate = input();
+
+ protected readonly _mutableDate = linkedSignal(this.date);
+
+ /** If true, the date picker will close when a date is selected. */
+ public readonly autoCloseOnSelect = input(this._config.autoCloseOnSelect, {
+ transform: booleanAttribute,
+ });
+
+ /** Defines how the date should be displayed in the UI. */
+ public readonly formatDate = input<(date: T) => string>(this._config.formatDate);
+
+ /** Defines how the date should be transformed before saving to model/form. */
+ public readonly transformDate = input<(date: T) => T>(this._config.transformDate);
+
+ protected readonly _popoverState = signal(null);
+
+ protected readonly _disabled = linkedSignal(this.disabled);
+
+ /** @internal The disabled state as a readonly signal */
+ public readonly disabledState = this._disabled.asReadonly();
+
+ public readonly formattedDate = computed(() => {
+ const date = this._mutableDate();
+ return date ? this.formatDate()(date) : undefined;
+ });
+
+ public readonly dateChange = output();
+
+ public readonly labelableId = computed(() => this._trigger()?.triggerId());
+
+ public readonly hasDate = computed(() => !!this._mutableDate());
+
+ protected _onChange?: ChangeFn;
+ protected _onTouched?: TouchFn;
+
+ protected _handleChange(value: T | undefined) {
+ if (this._disabled()) return;
+ this.updateDate(value);
+
+ if (this.autoCloseOnSelect()) {
+ this._popoverState.set('closed');
+ }
+ }
+
+ /**
+ * Commit a date to the picker. Updates the internal model, notifies form
+ * controls, and emits `dateChange`. Unlike `_handleChange`, this does not
+ * close the popover - it's intended to be called from a text input that
+ * is parsing user-entered values while typing.
+ */
+ public updateDate(value: T | undefined) {
+ if (this._disabled()) return;
+ const transformedDate = value !== undefined ? this.transformDate()(value) : undefined;
+
+ this._mutableDate.set(transformedDate);
+ this._onChange?.(transformedDate as T);
+ this.dateChange.emit(transformedDate as T);
+ }
+
+ /** CONTROL VALUE ACCESSOR */
+ public writeValue(value: T | null): void {
+ this._mutableDate.set(value ? this.transformDate()(value) : undefined);
+ }
+
+ public registerOnChange(fn: ChangeFn): void {
+ this._onChange = fn;
+ }
+
+ public registerOnTouched(fn: TouchFn): void {
+ this._onTouched = fn;
+ }
+
+ public touched(): void {
+ this._onTouched?.();
+ }
+
+ public setDisabledState(isDisabled: boolean): void {
+ this._disabled.set(isDisabled);
+ }
+
+ public open() {
+ this._popoverState.set('open');
+ }
+
+ public close() {
+ this._popoverState.set('closed');
+ }
+
+ public reset() {
+ this._mutableDate.set(undefined);
+ this._onChange?.(undefined as T);
+ this.dateChange.emit(undefined as T);
+ }
+}
diff --git a/packages/angular/src/lib/ui/date-picker/src/lib/hlm-date-range-picker.token.ts b/packages/angular/src/lib/ui/date-picker/src/lib/hlm-date-range-picker.token.ts
new file mode 100644
index 0000000..0589ab4
--- /dev/null
+++ b/packages/angular/src/lib/ui/date-picker/src/lib/hlm-date-range-picker.token.ts
@@ -0,0 +1,51 @@
+import { inject, InjectionToken, type ValueProvider } from '@angular/core';
+
+export interface HlmDateRangePickerConfig {
+ /**
+ * If true, the date picker will close when the max selection of dates is reached.
+ */
+ autoCloseOnEndSelection: boolean;
+
+ /**
+ * Defines how the date should be displayed in the UI.
+ *
+ * @param dates
+ * @returns formatted date
+ */
+ formatDates: (dates: [T | undefined, T | undefined]) => string;
+
+ /**
+ * Defines how the date should be transformed before saving to model/form.
+ *
+ * @param dates
+ * @returns transformed date
+ */
+ transformDates: (dates: [T, T]) => [T, T];
+}
+
+function getDefaultConfig(): HlmDateRangePickerConfig {
+ return {
+ formatDates: (dates) =>
+ dates
+ .filter(Boolean)
+ .map((date) => (date instanceof Date ? date.toDateString() : `${date}`))
+ .join(' - '),
+ transformDates: (dates) => dates,
+ autoCloseOnEndSelection: false,
+ };
+}
+
+const HlmDateRangePickerConfigToken = new InjectionToken>(
+ 'HlmDateRangePickerConfig',
+);
+
+export function provideHlmDateRangePickerConfig(
+ config: Partial>,
+): ValueProvider {
+ return { provide: HlmDateRangePickerConfigToken, useValue: { ...getDefaultConfig(), ...config } };
+}
+
+export function injectHlmDateRangePickerConfig(): HlmDateRangePickerConfig {
+ const injectedConfig = inject(HlmDateRangePickerConfigToken, { optional: true });
+ return injectedConfig ? (injectedConfig as HlmDateRangePickerConfig) : getDefaultConfig();
+}
diff --git a/packages/angular/src/lib/ui/date-picker/src/lib/hlm-date-range-picker.ts b/packages/angular/src/lib/ui/date-picker/src/lib/hlm-date-range-picker.ts
new file mode 100644
index 0000000..30ad5c2
--- /dev/null
+++ b/packages/angular/src/lib/ui/date-picker/src/lib/hlm-date-range-picker.ts
@@ -0,0 +1,207 @@
+import type { BooleanInput } from '@angular/cdk/coercion';
+import {
+ booleanAttribute,
+ ChangeDetectionStrategy,
+ Component,
+ computed,
+ contentChild,
+ forwardRef,
+ input,
+ linkedSignal,
+ output,
+ signal,
+ untracked,
+ viewChild,
+} from '@angular/core';
+import { type ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
+import type { BrnDialogState } from '@spartan-ng/brain/dialog';
+import { BrnFieldControl, provideBrnLabelable } from '@spartan-ng/brain/field';
+import type { ChangeFn, TouchFn } from '@spartan-ng/brain/forms';
+import { BrnPopover } from '@spartan-ng/brain/popover';
+import { HlmCalendarRange } from '../../../calendar/src';
+import { HlmPopoverImports } from '../../../popover/src';
+import { HlmDatePickerTriggerToken } from './hlm-date-picker-trigger.token';
+import { HlmDatePickerBase, provideHlmDatePicker } from './hlm-date-picker.token';
+import { injectHlmDateRangePickerConfig } from './hlm-date-range-picker.token';
+
+export const HLM_DATE_RANGE_PICKER_VALUE_ACCESSOR = {
+ provide: NG_VALUE_ACCESSOR,
+ useExisting: forwardRef(() => HlmDateRangePicker),
+ multi: true,
+};
+
+@Component({
+ selector: 'hlm-date-range-picker',
+ imports: [HlmPopoverImports, HlmCalendarRange],
+ providers: [
+ HLM_DATE_RANGE_PICKER_VALUE_ACCESSOR,
+ provideHlmDatePicker(HlmDateRangePicker),
+ provideBrnLabelable(HlmDateRangePicker),
+ ],
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ hostDirectives: [BrnFieldControl],
+ host: { class: 'block' },
+ template: `
+
+
+
+
+
+
+
+
+
+ `,
+})
+export class HlmDateRangePicker implements HlmDatePickerBase, ControlValueAccessor {
+ private readonly _config = injectHlmDateRangePickerConfig();
+
+ public readonly popover = viewChild.required(BrnPopover);
+
+ private readonly _trigger = contentChild(HlmDatePickerTriggerToken);
+
+ /** Show dropdowns to navigate between months or years. */
+ public readonly captionLayout = input<
+ 'dropdown' | 'label' | 'dropdown-months' | 'dropdown-years'
+ >('label');
+
+ /** The minimum date that can be selected.*/
+ public readonly min = input();
+
+ /** The maximum date that can be selected. */
+ public readonly max = input();
+
+ /** Determine if the date picker is disabled. */
+ public readonly disabled = input(false, {
+ transform: booleanAttribute,
+ });
+
+ /** The selected value. */
+ public readonly date = input<[T, T]>();
+
+ protected readonly _mutableDate = linkedSignal(this.date);
+
+ protected readonly _start = linkedSignal(() => this._mutableDate()?.[0]);
+ protected readonly _end = linkedSignal(() => this._mutableDate()?.[1]);
+
+ /** If true, the date picker will close when the end date is selected */
+ public readonly autoCloseOnEndSelection = input(
+ this._config.autoCloseOnEndSelection,
+ {
+ transform: booleanAttribute,
+ },
+ );
+
+ /** Defines how the date should be displayed in the UI. */
+ public readonly formatDates = input<(dates: [T | undefined, T | undefined]) => string>(
+ this._config.formatDates,
+ );
+
+ /** Defines how the date should be transformed before saving to model/form. */
+ public readonly transformDates = input<(date: [T, T]) => [T, T]>(this._config.transformDates);
+
+ protected readonly _popoverState = signal(null);
+
+ protected readonly _disabled = linkedSignal(this.disabled);
+
+ /** @internal The disabled state as a readonly signal */
+ public readonly disabledState = this._disabled.asReadonly();
+
+ public readonly formattedDate = computed(() => {
+ const start = this._start();
+ const end = this._end();
+ return start || end ? this.formatDates()([start, end]) : undefined;
+ });
+
+ public readonly dateChange = output<[T, T] | null>();
+
+ public readonly labelableId = computed(() => this._trigger()?.triggerId());
+
+ public readonly hasDate = computed(() => !!this._start() || !!this._end());
+
+ protected _onChange?: ChangeFn<[T, T] | null>;
+ protected _onTouched?: TouchFn;
+
+ protected _handleStartDayChange(value: T | undefined) {
+ this._start.set(value);
+ }
+
+ protected _handleEndDateChange(value: T | undefined): void {
+ this._end.set(value);
+ if (this._disabled()) return;
+
+ const start = this._start();
+ if (start && value) {
+ const transformedDates = this.transformDates()([start, value]);
+ this._mutableDate.set(transformedDates);
+ this.dateChange.emit(transformedDates);
+ this._onChange?.(transformedDates);
+
+ if (this.autoCloseOnEndSelection()) {
+ this._popoverState.set('closed');
+ }
+ }
+ }
+
+ /** CONTROL VALUE ACCESSOR */
+ public writeValue(value: [T, T] | null): void {
+ untracked(() => {
+ if (!value) {
+ this._mutableDate.set(undefined);
+ } else {
+ this._mutableDate.set(this.transformDates()(value));
+ }
+ });
+ }
+
+ public registerOnChange(fn: ChangeFn<[T, T] | null>): void {
+ this._onChange = fn;
+ }
+
+ public registerOnTouched(fn: TouchFn): void {
+ this._onTouched = fn;
+ }
+
+ public setDisabledState(isDisabled: boolean): void {
+ this._disabled.set(isDisabled);
+ }
+
+ public open() {
+ this._popoverState.set('open');
+ }
+
+ public close() {
+ this._popoverState.set('closed');
+ }
+
+ public reset() {
+ this._mutableDate.set(undefined);
+ this._start.set(undefined);
+ this._end.set(undefined);
+ this._onChange?.(null);
+ this.dateChange.emit(null);
+ }
+
+ protected _onClose(): void {
+ const dates = this._mutableDate();
+ if (this._start() && !this._end() && dates) {
+ this._start.set(dates[0]);
+ this._end.set(dates[1]);
+ }
+ }
+}
diff --git a/packages/angular/src/lib/ui/dialog/src/index.ts b/packages/angular/src/lib/ui/dialog/src/index.ts
new file mode 100644
index 0000000..a8e9313
--- /dev/null
+++ b/packages/angular/src/lib/ui/dialog/src/index.ts
@@ -0,0 +1,35 @@
+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 { HlmDialogPortal } from './lib/hlm-dialog-portal';
+import { HlmDialogTitle } from './lib/hlm-dialog-title';
+import { HlmDialogTrigger } from './lib/hlm-dialog-trigger';
+
+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-portal';
+export * from './lib/hlm-dialog-title';
+export * from './lib/hlm-dialog-trigger';
+export * from './lib/hlm-dialog.service';
+
+export const HlmDialogImports = [
+ HlmDialog,
+ HlmDialogContent,
+ HlmDialogDescription,
+ HlmDialogFooter,
+ HlmDialogHeader,
+ HlmDialogOverlay,
+ HlmDialogPortal,
+ HlmDialogTitle,
+ HlmDialogTrigger,
+ HlmDialogClose,
+] as const;
diff --git a/packages/angular/src/lib/ui/dialog/src/lib/hlm-dialog-close.ts b/packages/angular/src/lib/ui/dialog/src/lib/hlm-dialog-close.ts
new file mode 100644
index 0000000..47c28c9
--- /dev/null
+++ b/packages/angular/src/lib/ui/dialog/src/lib/hlm-dialog-close.ts
@@ -0,0 +1,9 @@
+import { Directive } from '@angular/core';
+import { BrnDialogClose } from '@spartan-ng/brain/dialog';
+
+@Directive({
+ selector: 'button[hlmDialogClose]',
+ hostDirectives: [BrnDialogClose],
+ host: { 'data-slot': 'dialog-close' },
+})
+export class HlmDialogClose {}
diff --git a/packages/angular/src/lib/ui/dialog/src/lib/hlm-dialog-content.ts b/packages/angular/src/lib/ui/dialog/src/lib/hlm-dialog-content.ts
new file mode 100644
index 0000000..3f79680
--- /dev/null
+++ b/packages/angular/src/lib/ui/dialog/src/lib/hlm-dialog-content.ts
@@ -0,0 +1,74 @@
+import type { BooleanInput } from '@angular/cdk/coercion';
+import type { ComponentType } from '@angular/cdk/portal';
+import { NgComponentOutlet } from '@angular/common';
+import {
+ booleanAttribute,
+ ChangeDetectionStrategy,
+ Component,
+ computed,
+ inject,
+ input,
+} from '@angular/core';
+import { provideIcons } from '@ng-icons/core';
+import { phosphorX } from '@ng-icons/phosphor-icons/regular';
+import { BrnDialogRef, injectBrnDialogContext } from '@spartan-ng/brain/dialog';
+import { HlmButton } from '../../../button/src';
+import { HlmIconImports } from '../../../icon/src';
+import { classes } from '../../../utils/src';
+import { HlmDialogClose } from './hlm-dialog-close';
+
+type HlmDialogContentContext = {
+ $component?: ComponentType;
+ $dynamicComponentClass?: string;
+ $showCloseButton?: boolean;
+};
+
+@Component({
+ selector: 'hlm-dialog-content',
+ imports: [NgComponentOutlet, HlmIconImports, HlmButton, HlmDialogClose],
+ providers: [provideIcons({ phosphorX })],
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ host: {
+ 'data-slot': 'dialog-content',
+ '[attr.data-state]': 'state()',
+ },
+ template: `
+ @if (component) {
+
+ } @else {
+
+ }
+
+ @if (showCloseButton()) {
+
+ }
+ `,
+})
+export class HlmDialogContent {
+ private readonly _dialogRef = inject(BrnDialogRef);
+ private readonly _dialogContext = injectBrnDialogContext({
+ optional: true,
+ });
+
+ public readonly showCloseButton = input(
+ this._dialogContext?.$showCloseButton ?? true,
+ {
+ transform: booleanAttribute,
+ },
+ );
+
+ public readonly state = computed(() => this._dialogRef?.state() ?? 'closed');
+
+ public readonly component = this._dialogContext?.$component;
+ private readonly _dynamicComponentClass = this._dialogContext?.$dynamicComponentClass;
+
+ constructor() {
+ classes(() => [
+ 'bg-popover text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 ring-foreground/10 grid max-w-[calc(100%-2rem)] gap-6 rounded-xl p-6 text-sm ring-1 duration-100 sm:max-w-md relative mx-auto w-full outline-none sm:mx-0',
+ this._dynamicComponentClass,
+ ]);
+ }
+}
diff --git a/packages/angular/src/lib/ui/dialog/src/lib/hlm-dialog-description.ts b/packages/angular/src/lib/ui/dialog/src/lib/hlm-dialog-description.ts
new file mode 100644
index 0000000..9c04530
--- /dev/null
+++ b/packages/angular/src/lib/ui/dialog/src/lib/hlm-dialog-description.ts
@@ -0,0 +1,17 @@
+import { Directive } from '@angular/core';
+import { BrnDialogDescription } from '@spartan-ng/brain/dialog';
+import { classes } from '../../../utils/src';
+
+@Directive({
+ selector: '[hlmDialogDescription]',
+ hostDirectives: [BrnDialogDescription],
+ host: { 'data-slot': 'dialog-description' },
+})
+export class HlmDialogDescription {
+ constructor() {
+ classes(
+ () =>
+ 'text-muted-foreground *:[a]:hover:text-foreground text-sm *:[a]:underline *:[a]:underline-offset-3',
+ );
+ }
+}
diff --git a/packages/angular/src/lib/ui/dialog/src/lib/hlm-dialog-footer.ts b/packages/angular/src/lib/ui/dialog/src/lib/hlm-dialog-footer.ts
new file mode 100644
index 0000000..c738b92
--- /dev/null
+++ b/packages/angular/src/lib/ui/dialog/src/lib/hlm-dialog-footer.ts
@@ -0,0 +1,12 @@
+import { Directive } from '@angular/core';
+import { classes } from '../../../utils/src';
+
+@Directive({
+ selector: '[hlmDialogFooter],hlm-dialog-footer',
+ host: { 'data-slot': 'dialog-footer' },
+})
+export class HlmDialogFooter {
+ constructor() {
+ classes(() => 'flex flex-col-reverse gap-2 sm:flex-row sm:justify-end');
+ }
+}
diff --git a/packages/angular/src/lib/ui/dialog/src/lib/hlm-dialog-header.ts b/packages/angular/src/lib/ui/dialog/src/lib/hlm-dialog-header.ts
new file mode 100644
index 0000000..dc7de4e
--- /dev/null
+++ b/packages/angular/src/lib/ui/dialog/src/lib/hlm-dialog-header.ts
@@ -0,0 +1,12 @@
+import { Directive } from '@angular/core';
+import { classes } from '../../../utils/src';
+
+@Directive({
+ selector: '[hlmDialogHeader],hlm-dialog-header',
+ host: { 'data-slot': 'dialog-header' },
+})
+export class HlmDialogHeader {
+ constructor() {
+ classes(() => 'gap-2 flex flex-col');
+ }
+}
diff --git a/packages/angular/src/lib/ui/dialog/src/lib/hlm-dialog-overlay.ts b/packages/angular/src/lib/ui/dialog/src/lib/hlm-dialog-overlay.ts
new file mode 100644
index 0000000..4f1358c
--- /dev/null
+++ b/packages/angular/src/lib/ui/dialog/src/lib/hlm-dialog-overlay.ts
@@ -0,0 +1,27 @@
+import { computed, Directive, effect, input, untracked } from '@angular/core';
+import { injectCustomClassSettable } from '@spartan-ng/brain/core';
+import { BrnDialogOverlay } from '@spartan-ng/brain/dialog';
+import { hlm } from '../../../utils/src';
+import type { ClassValue } from 'clsx';
+
+export const hlmDialogOverlayClass = hlm(
+ 'data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs',
+);
+
+@Directive({
+ selector: '[hlmDialogOverlay],hlm-dialog-overlay',
+ hostDirectives: [BrnDialogOverlay],
+})
+export class HlmDialogOverlay {
+ private readonly _classSettable = injectCustomClassSettable({ optional: true, host: true });
+
+ public readonly userClass = input('', { alias: 'class' });
+ protected readonly _computedClass = computed(() => hlm(hlmDialogOverlayClass, this.userClass()));
+
+ constructor() {
+ effect(() => {
+ const newClass = this._computedClass();
+ untracked(() => this._classSettable?.setClassToCustomElement(newClass));
+ });
+ }
+}
diff --git a/packages/angular/src/lib/ui/dialog/src/lib/hlm-dialog-portal.ts b/packages/angular/src/lib/ui/dialog/src/lib/hlm-dialog-portal.ts
new file mode 100644
index 0000000..356038e
--- /dev/null
+++ b/packages/angular/src/lib/ui/dialog/src/lib/hlm-dialog-portal.ts
@@ -0,0 +1,8 @@
+import { Directive } from '@angular/core';
+import { BrnDialogContent } from '@spartan-ng/brain/dialog';
+
+@Directive({
+ selector: '[hlmDialogPortal]',
+ hostDirectives: [{ directive: BrnDialogContent, inputs: ['context', 'class'] }],
+})
+export class HlmDialogPortal {}
diff --git a/packages/angular/src/lib/ui/dialog/src/lib/hlm-dialog-title.ts b/packages/angular/src/lib/ui/dialog/src/lib/hlm-dialog-title.ts
new file mode 100644
index 0000000..e829a0c
--- /dev/null
+++ b/packages/angular/src/lib/ui/dialog/src/lib/hlm-dialog-title.ts
@@ -0,0 +1,14 @@
+import { Directive } from '@angular/core';
+import { BrnDialogTitle } from '@spartan-ng/brain/dialog';
+import { classes } from '../../../utils/src';
+
+@Directive({
+ selector: '[hlmDialogTitle]',
+ hostDirectives: [BrnDialogTitle],
+ host: { 'data-slot': 'dialog-title' },
+})
+export class HlmDialogTitle {
+ constructor() {
+ classes(() => 'leading-none font-medium');
+ }
+}
diff --git a/packages/angular/src/lib/ui/dialog/src/lib/hlm-dialog-trigger.ts b/packages/angular/src/lib/ui/dialog/src/lib/hlm-dialog-trigger.ts
new file mode 100644
index 0000000..3e80b83
--- /dev/null
+++ b/packages/angular/src/lib/ui/dialog/src/lib/hlm-dialog-trigger.ts
@@ -0,0 +1,14 @@
+import { Directive } from '@angular/core';
+import { BrnDialogTrigger } from '@spartan-ng/brain/dialog';
+
+@Directive({
+ selector: 'button[hlmDialogTrigger],button[hlmDialogTriggerFor]',
+ hostDirectives: [
+ {
+ directive: BrnDialogTrigger,
+ inputs: ['id', 'brnDialogTriggerFor: hlmDialogTriggerFor', 'type'],
+ },
+ ],
+ host: { 'data-slot': 'dialog-trigger' },
+})
+export class HlmDialogTrigger {}
diff --git a/packages/angular/src/lib/ui/dialog/src/lib/hlm-dialog.service.ts b/packages/angular/src/lib/ui/dialog/src/lib/hlm-dialog.service.ts
new file mode 100644
index 0000000..30b74d2
--- /dev/null
+++ b/packages/angular/src/lib/ui/dialog/src/lib/hlm-dialog.service.ts
@@ -0,0 +1,45 @@
+import type { ComponentType } from '@angular/cdk/portal';
+import { inject, Injectable, type TemplateRef } 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 = BrnDialogOptions & {
+ contentClass?: string;
+ showCloseButton?: boolean;
+ context?: DialogContext;
+};
+
+@Injectable({
+ providedIn: 'root',
+})
+export class HlmDialogService {
+ private readonly _brnDialogService = inject(BrnDialogService);
+
+ public open(
+ component: ComponentType | TemplateRef,
+ options?: Partial,
+ ) {
+ const mergedOptions = {
+ ...(options ?? {}),
+ backdropClass: cssClassesToArray(`${hlmDialogOverlayClass} ${options?.backdropClass ?? ''}`),
+ context: {
+ ...(options?.context && typeof options.context === 'object' ? options.context : {}),
+ $component: component,
+ $dynamicComponentClass: options?.contentClass,
+ $showCloseButton: options?.showCloseButton,
+ },
+ };
+
+ return this._brnDialogService.open(
+ HlmDialogContent,
+ undefined,
+ mergedOptions.context,
+ mergedOptions,
+ );
+ }
+}
diff --git a/packages/angular/src/lib/ui/dialog/src/lib/hlm-dialog.stories.ts b/packages/angular/src/lib/ui/dialog/src/lib/hlm-dialog.stories.ts
new file mode 100644
index 0000000..92985df
--- /dev/null
+++ b/packages/angular/src/lib/ui/dialog/src/lib/hlm-dialog.stories.ts
@@ -0,0 +1,87 @@
+import { moduleMetadata, type Meta, type StoryObj } from '@storybook/angular';
+import { dialogContract } from '@surfnet/curve-contracts';
+
+import { HlmButton } from '../../../button/src';
+import { HlmInput } from '../../../input/src';
+import { HlmLabel } from '../../../label/src';
+import { HlmDialog, HlmDialogImports } from '..';
+
+const meta: Meta = {
+ title: 'Components/Dialog',
+ component: HlmDialog,
+ decorators: [
+ moduleMetadata({
+ imports: [HlmDialogImports, HlmButton, HlmInput, HlmLabel],
+ }),
+ ],
+ parameters: {
+ docs: {
+ description: {
+ component: dialogContract.docs.description,
+ },
+ },
+ },
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+/** A composed dialog with a trigger, a form, and a footer action. */
+export const Default: Story = {
+ render: () => ({
+ template: `
+
+
+
+
+
Edit profile
+
+ Make changes to your profile here. Click save when you're done.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `,
+ }),
+};
+
+/**
+ * `showCloseButton="false"` on `hlm-dialog-content` hides the corner close icon; pair it
+ * with a `hlmDialogClose` button in the footer to give the user a single, explicit way to
+ * dismiss.
+ */
+export const WithoutCloseButton: Story = {
+ render: () => ({
+ template: `
+
+
+
+
+
Terms of service
+
+ Please read and accept our terms of service before continuing.
+
+ `,
+ }),
+};
+
+/** Any focusable element can act as the trigger, e.g. plain text. */
+export const TextTrigger: Story = {
+ render: () => ({
+ template: `
+
+ `,
+})
+class ItemSizes {
+ @Input() itemContract!: typeof itemContract;
+}
+
+/** Every size, from extra-compact to standard. */
+export const Sizes: Story = {
+ render: () => ({
+ moduleMetadata: {
+ imports: [ItemSizes],
+ },
+ props: { itemContract },
+ template: '',
+ }),
+};
+
+/**
+ * `hlmItemMedia`'s own `variant` — a distinct axis from `hlmItem`'s. `icon` badges an icon,
+ * `image` fixes a square thumbnail, `default` renders the media as-is.
+ */
+export const MediaVariants: Story = {
+ render: () => ({
+ template: `
+
+
+
+
+
+
+
Default
+
${itemContract.docs.mediaVariants.default}
+
+
+
+
+
+
+
+
Icon
+
${itemContract.docs.mediaVariants.icon}
+
+
+
+
+
+
+
+
Image
+
${itemContract.docs.mediaVariants.image}
+
+
+
+ `,
+ }),
+};
+
+/** A grouped list of items with a separator, e.g. an account settings page. */
+export const Group: Story = {
+ render: () => ({
+ template: `
+
+
+
+
+
+
+
Profile
+
Update your name, photo, and bio.
+
+
+
+
+
+
+
+
+
+
+
+
Billing
+
Manage your plan and payment methods.
+
+
+
+
+
+
+
+
+
+
+
+
Notifications
+
Choose what you want to be notified about.
+
+
+
+
+
+
+ `,
+ }),
+};
+
+/** `hlmItemHeader` and `hlmItemFooter` wrap content that spans the full width of the item, below the main row. */
+export const WithHeaderAndFooter: Story = {
+ render: () => ({
+ template: `
+