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 HlmAccordionContent { + constructor() { + classes( + () => + 'text-sm transition-all data-[state=closed]:h-0 data-[state=open]:h-(--brn-accordion-content-height)', + ); + } +} diff --git a/packages/angular/src/lib/ui/accordion/src/lib/hlm-accordion-item.ts b/packages/angular/src/lib/ui/accordion/src/lib/hlm-accordion-item.ts new file mode 100644 index 0000000..4876b51 --- /dev/null +++ b/packages/angular/src/lib/ui/accordion/src/lib/hlm-accordion-item.ts @@ -0,0 +1,22 @@ +import { Directive } from '@angular/core'; +import { BrnAccordionItem } from '@spartan-ng/brain/accordion'; +import { classes } from '../../../utils/src'; + +@Directive({ + selector: '[hlmAccordionItem],hlm-accordion-item', + hostDirectives: [ + { + directive: BrnAccordionItem, + inputs: ['isOpened', 'disabled'], + outputs: ['openedChange'], + }, + ], + host: { + 'data-slot': 'accordion-item', + }, +}) +export class HlmAccordionItem { + constructor() { + classes(() => 'not-last:border-b flex flex-col'); + } +} diff --git a/packages/angular/src/lib/ui/accordion/src/lib/hlm-accordion-trigger.ts b/packages/angular/src/lib/ui/accordion/src/lib/hlm-accordion-trigger.ts new file mode 100644 index 0000000..e95acc8 --- /dev/null +++ b/packages/angular/src/lib/ui/accordion/src/lib/hlm-accordion-trigger.ts @@ -0,0 +1,40 @@ +import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core'; +import { NgIcon, provideIcons } from '@ng-icons/core'; +import { phosphorCaretDown, phosphorCaretUp } from '@ng-icons/phosphor-icons/regular'; +import { BrnAccordionImports } from '@spartan-ng/brain/accordion'; +import { hlm } from '../../../utils/src'; +import type { ClassValue } from 'clsx'; + +@Component({ + selector: 'hlm-accordion-trigger', + imports: [BrnAccordionImports, NgIcon], + providers: [provideIcons({ phosphorCaretDown, phosphorCaretUp })], + changeDetection: ChangeDetectionStrategy.OnPush, + 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. +
+
+ `, + }), +}; diff --git a/packages/angular/src/lib/ui/accordion/src/lib/hlm-accordion.ts b/packages/angular/src/lib/ui/accordion/src/lib/hlm-accordion.ts new file mode 100644 index 0000000..af753de --- /dev/null +++ b/packages/angular/src/lib/ui/accordion/src/lib/hlm-accordion.ts @@ -0,0 +1,16 @@ +import { Directive } from '@angular/core'; +import { BrnAccordion } from '@spartan-ng/brain/accordion'; +import { classes } from '../../../utils/src'; + +@Directive({ + selector: '[hlmAccordion], hlm-accordion', + hostDirectives: [{ directive: BrnAccordion, inputs: ['type', 'orientation'] }], + host: { + 'data-slot': 'accordion', + }, +}) +export class HlmAccordion { + constructor() { + classes(() => 'flex w-full flex-col'); + } +} diff --git a/packages/angular/src/lib/ui/alert-dialog/src/index.ts b/packages/angular/src/lib/ui/alert-dialog/src/index.ts new file mode 100644 index 0000000..144ce39 --- /dev/null +++ b/packages/angular/src/lib/ui/alert-dialog/src/index.ts @@ -0,0 +1,40 @@ +import { HlmAlertDialog } from './lib/hlm-alert-dialog'; +import { HlmAlertDialogAction } from './lib/hlm-alert-dialog-action'; +import { HlmAlertDialogCancel } from './lib/hlm-alert-dialog-cancel'; +import { HlmAlertDialogContent } from './lib/hlm-alert-dialog-content'; +import { HlmAlertDialogDescription } from './lib/hlm-alert-dialog-description'; +import { HlmAlertDialogFooter } from './lib/hlm-alert-dialog-footer'; +import { HlmAlertDialogHeader } from './lib/hlm-alert-dialog-header'; +import { HlmAlertDialogMedia } from './lib/hlm-alert-dialog-media'; +import { HlmAlertDialogOverlay } from './lib/hlm-alert-dialog-overlay'; +import { HlmAlertDialogPortal } from './lib/hlm-alert-dialog-portal'; +import { HlmAlertDialogTitle } from './lib/hlm-alert-dialog-title'; +import { HlmAlertDialogTrigger } from './lib/hlm-alert-dialog-trigger'; + +export * from './lib/hlm-alert-dialog'; +export * from './lib/hlm-alert-dialog-action'; +export * from './lib/hlm-alert-dialog-cancel'; +export * from './lib/hlm-alert-dialog-content'; +export * from './lib/hlm-alert-dialog-description'; +export * from './lib/hlm-alert-dialog-footer'; +export * from './lib/hlm-alert-dialog-header'; +export * from './lib/hlm-alert-dialog-media'; +export * from './lib/hlm-alert-dialog-overlay'; +export * from './lib/hlm-alert-dialog-portal'; +export * from './lib/hlm-alert-dialog-title'; +export * from './lib/hlm-alert-dialog-trigger'; + +export const HlmAlertDialogImports = [ + HlmAlertDialog, + HlmAlertDialogAction, + HlmAlertDialogCancel, + HlmAlertDialogContent, + HlmAlertDialogDescription, + HlmAlertDialogFooter, + HlmAlertDialogHeader, + HlmAlertDialogMedia, + HlmAlertDialogOverlay, + HlmAlertDialogPortal, + HlmAlertDialogTitle, + HlmAlertDialogTrigger, +] as const; diff --git a/packages/angular/src/lib/ui/alert-dialog/src/lib/hlm-alert-dialog-action.ts b/packages/angular/src/lib/ui/alert-dialog/src/lib/hlm-alert-dialog-action.ts new file mode 100644 index 0000000..968b8c4 --- /dev/null +++ b/packages/angular/src/lib/ui/alert-dialog/src/lib/hlm-alert-dialog-action.ts @@ -0,0 +1,11 @@ +import { Directive, input } from '@angular/core'; +import { HlmButton } from '../../../button/src'; + +@Directive({ + selector: 'button[hlmAlertDialogAction]', + hostDirectives: [{ directive: HlmButton, inputs: ['variant', 'size'] }], + host: { 'data-slot': 'alert-dialog-action', '[type]': 'type()' }, +}) +export class HlmAlertDialogAction { + public readonly type = input<'button' | 'submit' | 'reset'>('button'); +} diff --git a/packages/angular/src/lib/ui/alert-dialog/src/lib/hlm-alert-dialog-cancel.ts b/packages/angular/src/lib/ui/alert-dialog/src/lib/hlm-alert-dialog-cancel.ts new file mode 100644 index 0000000..13da550 --- /dev/null +++ b/packages/angular/src/lib/ui/alert-dialog/src/lib/hlm-alert-dialog-cancel.ts @@ -0,0 +1,13 @@ +import { Directive, input } from '@angular/core'; +import { BrnDialogClose } from '@spartan-ng/brain/dialog'; +import { HlmButton, provideBrnButtonConfig } from '../../../button/src'; + +@Directive({ + selector: 'button[hlmAlertDialogCancel]', + providers: [provideBrnButtonConfig({ variant: 'outline' })], + hostDirectives: [BrnDialogClose, { directive: HlmButton, inputs: ['variant', 'size'] }], + host: { 'data-slot': 'alert-dialog-cancel', '[type]': 'type()' }, +}) +export class HlmAlertDialogCancel { + public readonly type = input<'button' | 'submit' | 'reset'>('button'); +} diff --git a/packages/angular/src/lib/ui/alert-dialog/src/lib/hlm-alert-dialog-content.ts b/packages/angular/src/lib/ui/alert-dialog/src/lib/hlm-alert-dialog-content.ts new file mode 100644 index 0000000..7ff4392 --- /dev/null +++ b/packages/angular/src/lib/ui/alert-dialog/src/lib/hlm-alert-dialog-content.ts @@ -0,0 +1,26 @@ +import { Directive, input, signal } from '@angular/core'; +import { injectExposesStateProvider } from '@spartan-ng/brain/core'; +import type { AlertDialogSizeName } from '@surfnet/curve-contracts'; +import { classes } from '../../../utils/src'; + +@Directive({ + selector: '[hlmAlertDialogContent],hlm-alert-dialog-content', + host: { + 'data-slot': 'alert-dialog-content', + '[attr.data-state]': 'state()', + '[attr.data-size]': 'size()', + }, +}) +export class HlmAlertDialogContent { + private readonly _stateProvider = injectExposesStateProvider({ optional: true, host: true }); + public readonly state = this._stateProvider?.state ?? signal('closed'); + + public readonly size = input('default'); + + constructor() { + classes( + () => + '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 bg-popover text-popover-foreground ring-foreground/10 gap-6 rounded-xl p-6 ring-1 duration-100 data-[size=default]:max-w-xs data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-lg group/alert-dialog-content grid w-full outline-none', + ); + } +} diff --git a/packages/angular/src/lib/ui/alert-dialog/src/lib/hlm-alert-dialog-description.ts b/packages/angular/src/lib/ui/alert-dialog/src/lib/hlm-alert-dialog-description.ts new file mode 100644 index 0000000..165476d --- /dev/null +++ b/packages/angular/src/lib/ui/alert-dialog/src/lib/hlm-alert-dialog-description.ts @@ -0,0 +1,17 @@ +import { Directive } from '@angular/core'; +import { BrnAlertDialogDescription } from '@spartan-ng/brain/alert-dialog'; +import { classes } from '../../../utils/src'; + +@Directive({ + selector: '[hlmAlertDialogDescription]', + hostDirectives: [BrnAlertDialogDescription], + host: { 'data-slot': 'alert-dialog-description' }, +}) +export class HlmAlertDialogDescription { + constructor() { + classes( + () => + 'text-muted-foreground *:[a]:hover:text-foreground text-sm text-balance md:text-pretty *:[a]:underline *:[a]:underline-offset-3', + ); + } +} diff --git a/packages/angular/src/lib/ui/alert-dialog/src/lib/hlm-alert-dialog-footer.ts b/packages/angular/src/lib/ui/alert-dialog/src/lib/hlm-alert-dialog-footer.ts new file mode 100644 index 0000000..7db8204 --- /dev/null +++ b/packages/angular/src/lib/ui/alert-dialog/src/lib/hlm-alert-dialog-footer.ts @@ -0,0 +1,15 @@ +import { Directive } from '@angular/core'; +import { classes } from '../../../utils/src'; + +@Directive({ + selector: '[hlmAlertDialogFooter],hlm-alert-dialog-footer', + host: { 'data-slot': 'alert-dialog-footer' }, +}) +export class HlmAlertDialogFooter { + constructor() { + classes( + () => + 'flex flex-col-reverse gap-2 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end', + ); + } +} diff --git a/packages/angular/src/lib/ui/alert-dialog/src/lib/hlm-alert-dialog-header.ts b/packages/angular/src/lib/ui/alert-dialog/src/lib/hlm-alert-dialog-header.ts new file mode 100644 index 0000000..2cb088f --- /dev/null +++ b/packages/angular/src/lib/ui/alert-dialog/src/lib/hlm-alert-dialog-header.ts @@ -0,0 +1,15 @@ +import { Directive } from '@angular/core'; +import { classes } from '../../../utils/src'; + +@Directive({ + selector: '[hlmAlertDialogHeader],hlm-alert-dialog-header', + host: { 'data-slot': 'alert-dialog-header' }, +}) +export class HlmAlertDialogHeader { + constructor() { + classes( + () => + 'grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-6 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-start sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]', + ); + } +} diff --git a/packages/angular/src/lib/ui/alert-dialog/src/lib/hlm-alert-dialog-media.ts b/packages/angular/src/lib/ui/alert-dialog/src/lib/hlm-alert-dialog-media.ts new file mode 100644 index 0000000..0ea95a7 --- /dev/null +++ b/packages/angular/src/lib/ui/alert-dialog/src/lib/hlm-alert-dialog-media.ts @@ -0,0 +1,15 @@ +import { Directive } from '@angular/core'; +import { classes } from '../../../utils/src'; + +@Directive({ + selector: '[hlmAlertDialogMedia],hlm-alert-dialog-media', + host: { 'data-slot': 'alert-dialog-media' }, +}) +export class HlmAlertDialogMedia { + constructor() { + classes( + () => + "bg-muted mb-2 inline-flex size-16 items-center justify-center rounded-md sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[ng-icon:not([class*='text-'])]:text-[calc(var(--spacing)*8)]", + ); + } +} diff --git a/packages/angular/src/lib/ui/alert-dialog/src/lib/hlm-alert-dialog-overlay.ts b/packages/angular/src/lib/ui/alert-dialog/src/lib/hlm-alert-dialog-overlay.ts new file mode 100644 index 0000000..e77bdaa --- /dev/null +++ b/packages/angular/src/lib/ui/alert-dialog/src/lib/hlm-alert-dialog-overlay.ts @@ -0,0 +1,28 @@ +import { Directive, computed, effect, input, untracked } from '@angular/core'; +import { BrnAlertDialogOverlay } from '@spartan-ng/brain/alert-dialog'; +import { injectCustomClassSettable } from '@spartan-ng/brain/core'; +import { hlm } from '../../../utils/src'; +import type { ClassValue } from 'clsx'; + +@Directive({ + selector: '[hlmAlertDialogOverlay],hlm-alert-dialog-overlay', + hostDirectives: [BrnAlertDialogOverlay], +}) +export class HlmAlertDialogOverlay { + private readonly _classSettable = injectCustomClassSettable({ optional: true, host: true }); + + public readonly userClass = input('', { alias: 'class' }); + protected readonly _computedClass = computed(() => + 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', + this.userClass(), + ), + ); + + constructor() { + effect(() => { + const classValue = this._computedClass(); + untracked(() => this._classSettable?.setClassToCustomElement(classValue)); + }); + } +} diff --git a/packages/angular/src/lib/ui/alert-dialog/src/lib/hlm-alert-dialog-portal.ts b/packages/angular/src/lib/ui/alert-dialog/src/lib/hlm-alert-dialog-portal.ts new file mode 100644 index 0000000..6585075 --- /dev/null +++ b/packages/angular/src/lib/ui/alert-dialog/src/lib/hlm-alert-dialog-portal.ts @@ -0,0 +1,8 @@ +import { Directive } from '@angular/core'; +import { BrnAlertDialogContent } from '@spartan-ng/brain/alert-dialog'; + +@Directive({ + selector: '[hlmAlertDialogPortal]', + hostDirectives: [{ directive: BrnAlertDialogContent, inputs: ['context', 'class'] }], +}) +export class HlmAlertDialogPortal {} diff --git a/packages/angular/src/lib/ui/alert-dialog/src/lib/hlm-alert-dialog-title.ts b/packages/angular/src/lib/ui/alert-dialog/src/lib/hlm-alert-dialog-title.ts new file mode 100644 index 0000000..7e0c96b --- /dev/null +++ b/packages/angular/src/lib/ui/alert-dialog/src/lib/hlm-alert-dialog-title.ts @@ -0,0 +1,17 @@ +import { Directive } from '@angular/core'; +import { BrnAlertDialogTitle } from '@spartan-ng/brain/alert-dialog'; +import { classes } from '../../../utils/src'; + +@Directive({ + selector: '[hlmAlertDialogTitle]', + hostDirectives: [BrnAlertDialogTitle], + host: { 'data-slot': 'alert-dialog-title' }, +}) +export class HlmAlertDialogTitle { + constructor() { + classes( + () => + 'text-lg font-medium sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2', + ); + } +} diff --git a/packages/angular/src/lib/ui/alert-dialog/src/lib/hlm-alert-dialog-trigger.ts b/packages/angular/src/lib/ui/alert-dialog/src/lib/hlm-alert-dialog-trigger.ts new file mode 100644 index 0000000..ffcf8cf --- /dev/null +++ b/packages/angular/src/lib/ui/alert-dialog/src/lib/hlm-alert-dialog-trigger.ts @@ -0,0 +1,14 @@ +import { Directive } from '@angular/core'; +import { BrnAlertDialogTrigger } from '@spartan-ng/brain/alert-dialog'; + +@Directive({ + selector: 'button[hlmAlertDialogTrigger],button[hlmAlertDialogTriggerFor]', + hostDirectives: [ + { + directive: BrnAlertDialogTrigger, + inputs: ['id', 'brnAlertDialogTriggerFor: hlmAlertDialogTriggerFor', 'type'], + }, + ], + host: { 'data-slot': 'alert-dialog-trigger' }, +}) +export class HlmAlertDialogTrigger {} diff --git a/packages/angular/src/lib/ui/alert-dialog/src/lib/hlm-alert-dialog.stories.ts b/packages/angular/src/lib/ui/alert-dialog/src/lib/hlm-alert-dialog.stories.ts new file mode 100644 index 0000000..1e4c4b3 --- /dev/null +++ b/packages/angular/src/lib/ui/alert-dialog/src/lib/hlm-alert-dialog.stories.ts @@ -0,0 +1,101 @@ +import { NgIcon, provideIcons } from '@ng-icons/core'; +import { phosphorWarning } from '@ng-icons/phosphor-icons/regular'; +import { moduleMetadata, type Meta, type StoryObj } from '@storybook/angular'; +import { alertDialogContract } from '@surfnet/curve-contracts'; + +import { HlmButton } from '../../../button/src'; +import { HlmAlertDialog, HlmAlertDialogImports } from '..'; + +const meta: Meta = { + title: 'Components/AlertDialog', + component: HlmAlertDialog, + decorators: [ + moduleMetadata({ + imports: [HlmAlertDialogImports, HlmButton, NgIcon], + providers: [provideIcons({ phosphorWarning })], + }), + ], + parameters: { + docs: { + description: { + component: alertDialogContract.docs.description, + }, + }, + }, +}; + +export default meta; + +type Story = StoryObj; + +/** The confirm/cancel pattern — interrupts the user before an irreversible action. */ +export const Default: Story = { + render: () => ({ + template: ` + + + + +

Are you absolutely sure?

+

+ This action cannot be undone. This will permanently delete your account and remove + your data from our servers. +

+
+ + + + +
+
+ `, + }), +}; + +/** A destructive confirmation, using an icon in the media slot and a destructive action button. */ +export const WithMedia: Story = { + render: () => ({ + template: ` + + + + +
+ +
+

Delete account?

+

+ This will permanently delete your account and all associated data. This action + cannot be undone. +

+
+ + + + +
+
+ `, + }), +}; + +/** The compact (`sm`) content size — footer buttons switch to a two-column grid. */ +export const Small: Story = { + render: () => ({ + template: ` + + + + +

Leave without saving?

+

Your changes will be lost.

+
+ + + + +
+
+ `, + }), +}; diff --git a/packages/angular/src/lib/ui/alert-dialog/src/lib/hlm-alert-dialog.ts b/packages/angular/src/lib/ui/alert-dialog/src/lib/hlm-alert-dialog.ts new file mode 100644 index 0000000..c4daae2 --- /dev/null +++ b/packages/angular/src/lib/ui/alert-dialog/src/lib/hlm-alert-dialog.ts @@ -0,0 +1,25 @@ +import { ChangeDetectionStrategy, Component, forwardRef } from '@angular/core'; +import { BRN_ALERT_DIALOG_DEFAULT_OPTIONS, BrnAlertDialog } from '@spartan-ng/brain/alert-dialog'; +import { BrnDialog, provideBrnDialogDefaultOptions } from '@spartan-ng/brain/dialog'; +import { HlmAlertDialogOverlay } from './hlm-alert-dialog-overlay'; + +@Component({ + selector: 'hlm-alert-dialog', + exportAs: 'hlmAlertDialog', + imports: [HlmAlertDialogOverlay], + providers: [ + { + provide: BrnDialog, + useExisting: forwardRef(() => HlmAlertDialog), + }, + provideBrnDialogDefaultOptions({ + ...BRN_ALERT_DIALOG_DEFAULT_OPTIONS, + }), + ], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + + + `, +}) +export class HlmAlertDialog extends BrnAlertDialog {} diff --git a/packages/angular/src/lib/ui/alert/src/index.ts b/packages/angular/src/lib/ui/alert/src/index.ts new file mode 100644 index 0000000..81ad999 --- /dev/null +++ b/packages/angular/src/lib/ui/alert/src/index.ts @@ -0,0 +1,16 @@ +import { HlmAlert } from './lib/hlm-alert'; +import { HlmAlertAction } from './lib/hlm-alert-action'; +import { HlmAlertDescription } from './lib/hlm-alert-description'; +import { HlmAlertTitle } from './lib/hlm-alert-title'; + +export * from './lib/hlm-alert'; +export * from './lib/hlm-alert-action'; +export * from './lib/hlm-alert-description'; +export * from './lib/hlm-alert-title'; + +export const HlmAlertImports = [ + HlmAlert, + HlmAlertAction, + HlmAlertDescription, + HlmAlertTitle, +] as const; diff --git a/packages/angular/src/lib/ui/alert/src/lib/hlm-alert-action.ts b/packages/angular/src/lib/ui/alert/src/lib/hlm-alert-action.ts new file mode 100644 index 0000000..ee5c9a7 --- /dev/null +++ b/packages/angular/src/lib/ui/alert/src/lib/hlm-alert-action.ts @@ -0,0 +1,14 @@ +import { Directive } from '@angular/core'; +import { classes } from '../../../utils/src'; + +@Directive({ + selector: '[hlmAlertAction]', + host: { + 'data-slot': 'alert-action', + }, +}) +export class HlmAlertAction { + constructor() { + classes(() => 'absolute end-3 top-2.5'); + } +} diff --git a/packages/angular/src/lib/ui/alert/src/lib/hlm-alert-description.ts b/packages/angular/src/lib/ui/alert/src/lib/hlm-alert-description.ts new file mode 100644 index 0000000..9c2da1a --- /dev/null +++ b/packages/angular/src/lib/ui/alert/src/lib/hlm-alert-description.ts @@ -0,0 +1,17 @@ +import { Directive } from '@angular/core'; +import { classes } from '../../../utils/src'; + +@Directive({ + selector: '[hlmAlertDescription]', + host: { + 'data-slot': 'alert-description', + }, +}) +export class HlmAlertDescription { + constructor() { + classes( + () => + 'text-muted-foreground text-sm text-balance md:text-pretty [&_p:not(:last-child)]:mb-4 [&_a]:hover:text-foreground [&_a]:underline [&_a]:underline-offset-3', + ); + } +} diff --git a/packages/angular/src/lib/ui/alert/src/lib/hlm-alert-title.ts b/packages/angular/src/lib/ui/alert/src/lib/hlm-alert-title.ts new file mode 100644 index 0000000..7e2c056 --- /dev/null +++ b/packages/angular/src/lib/ui/alert/src/lib/hlm-alert-title.ts @@ -0,0 +1,17 @@ +import { Directive } from '@angular/core'; +import { classes } from '../../../utils/src'; + +@Directive({ + selector: '[hlmAlertTitle]', + host: { + 'data-slot': 'alert-title', + }, +}) +export class HlmAlertTitle { + constructor() { + classes( + () => + 'font-medium group-has-[>ng-icon]/alert:col-start-2 [&_a]:hover:text-foreground [&_a]:underline [&_a]:underline-offset-3', + ); + } +} diff --git a/packages/angular/src/lib/ui/alert/src/lib/hlm-alert.stories.ts b/packages/angular/src/lib/ui/alert/src/lib/hlm-alert.stories.ts new file mode 100644 index 0000000..118ea89 --- /dev/null +++ b/packages/angular/src/lib/ui/alert/src/lib/hlm-alert.stories.ts @@ -0,0 +1,132 @@ +import { Component, Input } from '@angular/core'; +import { NgIcon, provideIcons } from '@ng-icons/core'; +import { phosphorCheckCircle, phosphorWarningCircle } from '@ng-icons/phosphor-icons/regular'; +import { argsToTemplate, moduleMetadata, type Meta, type StoryObj } from '@storybook/angular'; +import { alertContract } from '@surfnet/curve-contracts'; + +import { HlmButton } from '../../../button/src/lib/hlm-button'; +import { HlmAlert, HlmAlertImports } from '..'; + +const meta: Meta = { + title: 'Components/Alert', + component: HlmAlert, + decorators: [ + moduleMetadata({ + imports: [HlmAlertImports, HlmButton, NgIcon], + providers: [provideIcons({ phosphorCheckCircle, phosphorWarningCircle })], + }), + ], + parameters: { + docs: { + description: { + component: alertContract.docs.description, + }, + }, + }, + argTypes: { + variant: { + control: 'select', + options: [...alertContract.props.variants], + description: 'Visual style of the alert.', + table: { + type: { + summary: alertContract.props.variants.join(' | '), + }, + defaultValue: { summary: alertContract.defaults.variants }, + }, + }, + }, + args: { + variant: alertContract.defaults.variants, + }, +}; + +export default meta; + +type Story = StoryObj; + +/** The default alert — rendered with an icon, title, and description; tweak the variant via controls. */ +export const Default: Story = { + render: (args) => ({ + props: args, + template: ` + + +
Update available
+

A new version is ready to install.

+
+ `, + }), +}; + +@Component({ + selector: 'alert-variants', + imports: [HlmAlertImports, NgIcon], + template: ` +
+ @for (variant of alertContract.props.variants; track variant) { +
+ +
{{ variant.charAt(0).toUpperCase() + variant.slice(1) }}
+

{{ alertContract.docs.variants[variant] }}

+
+ } +
+ `, +}) +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: ` +
+ Mountain landscape +
+ `, + }), +}; + +/** Common ratios side by side, each constraining the same photo. */ +export const Ratios: Story = { + render: () => ({ + template: ` +
+
+
+ Square — 1/1 +
+
Square — 1/1
+
+
+
+ Standard — 4/3 +
+
Standard — 4/3
+
+
+
+ Widescreen — 16/9 +
+
Widescreen — 16/9
+
+
+
+ Ultrawide — 21/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.

+
+
+
+ Mountain range at sunrise +
+
+
+ `, + }), +}; 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: ` +
+ @for (variant of badgeContract.props.variants; track variant) { + + {{ variant.charAt(0).toUpperCase() + variant.slice(1) }} + + } +
+ `, +}) +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: ` +
    +
  • + Deploy to production + Live +
  • +
  • + Migrate database schema + In progress +
  • +
  • + Rotate API credentials + Overdue +
  • +
  • + Archive old backups + Scheduled +
  • +
`, + }), +}; diff --git a/packages/angular/src/lib/ui/badge/src/lib/hlm-badge.ts b/packages/angular/src/lib/ui/badge/src/lib/hlm-badge.ts new file mode 100644 index 0000000..fcb62ba --- /dev/null +++ b/packages/angular/src/lib/ui/badge/src/lib/hlm-badge.ts @@ -0,0 +1,43 @@ +import { Directive, input } from '@angular/core'; +import { classes } from '../../../utils/src'; +import type { BadgeVariantName } from '@surfnet/curve-contracts'; +import { type VariantProps, cva } from 'class-variance-authority'; + +const badgeVariantClasses = { + default: 'bg-primary text-primary-foreground [a]:hover:bg-primary/80', + secondary: 'bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80', + destructive: + 'bg-destructive/10 [a]:hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 text-destructive dark:bg-destructive/20', + outline: 'border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground', + ghost: 'hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50', + link: 'text-primary underline-offset-4 hover:underline', +} satisfies Record; + +const badgeVariants = cva( + 'h-5 gap-1 rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium transition-all has-data-[icon=inline-end]:pe-1.5 has-data-[icon=inline-start]:ps-1.5 [&>ng-icon]:text-[calc(var(--spacing)*3)] group/badge focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 inline-flex w-fit shrink-0 items-center justify-center overflow-hidden whitespace-nowrap focus-visible:ring-[3px] [&>ng-icon]:pointer-events-none', + { + variants: { + variant: badgeVariantClasses, + }, + defaultVariants: { + variant: 'default', + }, + }, +); + +export type BadgeVariants = VariantProps; + +@Directive({ + selector: '[hlmBadge],hlm-badge', + host: { + 'data-slot': 'badge', + '[attr.data-variant]': 'variant()', + }, +}) +export class HlmBadge { + public readonly variant = input('default'); + + constructor() { + classes(() => badgeVariants({ variant: this.variant() })); + } +} diff --git a/packages/angular/src/lib/ui/button-group/src/index.ts b/packages/angular/src/lib/ui/button-group/src/index.ts new file mode 100644 index 0000000..d559424 --- /dev/null +++ b/packages/angular/src/lib/ui/button-group/src/index.ts @@ -0,0 +1,13 @@ +import { HlmButtonGroup } from './lib/hlm-button-group'; +import { HlmButtonGroupSeparator } from './lib/hlm-button-group-separator'; +import { HlmButtonGroupText } from './lib/hlm-button-group-text'; + +export * from './lib/hlm-button-group'; +export * from './lib/hlm-button-group-separator'; +export * from './lib/hlm-button-group-text'; + +export const HlmButtonGroupImports = [ + HlmButtonGroup, + HlmButtonGroupText, + HlmButtonGroupSeparator, +] as const; diff --git a/packages/angular/src/lib/ui/button-group/src/lib/hlm-button-group-separator.ts b/packages/angular/src/lib/ui/button-group/src/lib/hlm-button-group-separator.ts new file mode 100644 index 0000000..db1f1a2 --- /dev/null +++ b/packages/angular/src/lib/ui/button-group/src/lib/hlm-button-group-separator.ts @@ -0,0 +1,21 @@ +import { Directive } from '@angular/core'; +import { BrnSeparator, provideBrnSeparatorConfig } from '@spartan-ng/brain/separator'; +import { classes } from '../../../utils/src'; + +@Directive({ + selector: '[hlmButtonGroupSeparator],hlm-button-group-separator', + providers: [provideBrnSeparatorConfig({ orientation: 'vertical' })], + hostDirectives: [{ directive: BrnSeparator, inputs: ['orientation', 'decorative'] }], + host: { + 'data-slot': 'button-group-separator', + }, +}) +export class HlmButtonGroupSeparator { + constructor() { + classes(() => [ + 'bg-input relative self-stretch data-horizontal:mx-px data-horizontal:w-auto data-vertical:my-px data-vertical:h-auto', + // separator classes + 'shrink-0 data-horizontal:h-px data-vertical:w-px data-vertical:self-stretch', + ]); + } +} diff --git a/packages/angular/src/lib/ui/button-group/src/lib/hlm-button-group-text.ts b/packages/angular/src/lib/ui/button-group/src/lib/hlm-button-group-text.ts new file mode 100644 index 0000000..0185470 --- /dev/null +++ b/packages/angular/src/lib/ui/button-group/src/lib/hlm-button-group-text.ts @@ -0,0 +1,17 @@ +import { Directive } from '@angular/core'; +import { classes } from '../../../utils/src'; + +@Directive({ + selector: '[hlmButtonGroupText],hlm-button-group-text', + host: { + 'data-slot': 'button-group-text', + }, +}) +export class HlmButtonGroupText { + constructor() { + classes( + () => + "bg-muted gap-2 rounded-md border px-2.5 text-sm font-medium shadow-xs [&_ng-icon:not([class*='text-'])]:text-[calc(var(--spacing)*4)] flex items-center [&_ng-icon]:pointer-events-none", + ); + } +} diff --git a/packages/angular/src/lib/ui/button-group/src/lib/hlm-button-group.stories.ts b/packages/angular/src/lib/ui/button-group/src/lib/hlm-button-group.stories.ts new file mode 100644 index 0000000..850b30f --- /dev/null +++ b/packages/angular/src/lib/ui/button-group/src/lib/hlm-button-group.stories.ts @@ -0,0 +1,133 @@ +import { NgIcon, provideIcons } from '@ng-icons/core'; +import { + phosphorCopy, + phosphorDotsThree, + phosphorMagnifyingGlass, +} from '@ng-icons/phosphor-icons/regular'; +import { argsToTemplate, type Meta, moduleMetadata, type StoryObj } from '@storybook/angular'; +import { buttonGroupContract } from '@surfnet/curve-contracts'; + +import { HlmButtonGroup, HlmButtonGroupImports } from '..'; +import { HlmButtonImports, HlmInputImports } from '../../../../../public-api'; + +const meta: Meta = { + title: 'Components/ButtonGroup', + component: HlmButtonGroup, + decorators: [ + moduleMetadata({ + imports: [HlmButtonGroupImports, HlmButtonImports, HlmInputImports, NgIcon], + providers: [provideIcons({ phosphorCopy, phosphorDotsThree, phosphorMagnifyingGlass })], + }), + ], + parameters: { + docs: { + description: { + component: buttonGroupContract.docs.description, + }, + }, + }, + argTypes: { + orientation: { + control: 'inline-radio', + options: buttonGroupContract.props.orientations, + description: buttonGroupContract.props.orientations + .map( + (orientation) => + `\`${orientation}\` — ${buttonGroupContract.docs.orientations[orientation]}`, + ) + .join('\n\n'), + table: { + defaultValue: { summary: buttonGroupContract.defaults.orientations }, + }, + }, + }, + args: { + orientation: buttonGroupContract.defaults.orientations, + }, +}; + +export default meta; +type Story = StoryObj; + +/** The default button group — rendered with the default args; tweak `orientation` via the controls. */ +export const Default: Story = { + render: (args) => ({ + props: args, + 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: ` +
+
+ +
+
+
+ + +
+
+ `, + }), +}; + +/** `hlmButtonGroupText` renders a non-interactive, label-styled slot alongside the buttons. */ +export const WithText: Story = { + render: () => ({ + template: ` +
+ +
Page 1 of 10
+ +
+ `, + }), +}; + +/** + * 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: ` +
+ + + +
+ `, + }), +}; diff --git a/packages/angular/src/lib/ui/button-group/src/lib/hlm-button-group.ts b/packages/angular/src/lib/ui/button-group/src/lib/hlm-button-group.ts new file mode 100644 index 0000000..2dac966 --- /dev/null +++ b/packages/angular/src/lib/ui/button-group/src/lib/hlm-button-group.ts @@ -0,0 +1,37 @@ +import { Directive, input } from '@angular/core'; +import { classes } from '../../../utils/src'; +import type { ButtonGroupOrientationName } from '@surfnet/curve-contracts'; +import { cva } from 'class-variance-authority'; + +const buttonGroupVariants = cva( + "has-[>[data-slot=button-group]]:gap-2 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-md flex w-fit items-stretch *:focus-visible:relative *:focus-visible:z-10 [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1", + { + variants: { + orientation: { + horizontal: + '[&>[data-slot]:not(:has(~[data-slot]))]:rounded-e-md! [&>*:not(:first-child)]:rounded-s-none [&>*:not(:first-child)]:border-s-0 [&>*:not(:last-child)]:rounded-e-none', + vertical: + '[&>[data-slot]:not(:has(~[data-slot]))]:rounded-b-md! flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none', + } satisfies Record, + }, + defaultVariants: { + orientation: 'horizontal', + }, + }, +); + +@Directive({ + selector: '[hlmButtonGroup],hlm-button-group', + host: { + 'data-slot': 'button-group', + role: 'group', + '[attr.data-orientation]': 'orientation()', + }, +}) +export class HlmButtonGroup { + constructor() { + classes(() => buttonGroupVariants({ orientation: this.orientation() })); + } + + public readonly orientation = input<'horizontal' | 'vertical'>('horizontal'); +} diff --git a/packages/angular/src/lib/ui/calendar/src/index.ts b/packages/angular/src/lib/ui/calendar/src/index.ts new file mode 100644 index 0000000..d53a68b --- /dev/null +++ b/packages/angular/src/lib/ui/calendar/src/index.ts @@ -0,0 +1,9 @@ +import { HlmCalendar } from './lib/hlm-calendar'; +import { HlmCalendarMulti } from './lib/hlm-calendar-multi'; +import { HlmCalendarRange } from './lib/hlm-calendar-range'; + +export * from './lib/hlm-calendar'; +export * from './lib/hlm-calendar-multi'; +export * from './lib/hlm-calendar-range'; + +export const HlmCalendarImports = [HlmCalendar, HlmCalendarMulti, HlmCalendarRange] as const; diff --git a/packages/angular/src/lib/ui/calendar/src/lib/hlm-calendar-multi.ts b/packages/angular/src/lib/ui/calendar/src/lib/hlm-calendar-multi.ts new file mode 100644 index 0000000..ad8834c --- /dev/null +++ b/packages/angular/src/lib/ui/calendar/src/lib/hlm-calendar-multi.ts @@ -0,0 +1,246 @@ +import type { BooleanInput, NumberInput } from '@angular/cdk/coercion'; +import { NgTemplateOutlet } from '@angular/common'; +import { + booleanAttribute, + ChangeDetectionStrategy, + Component, + computed, + input, + model, + numberAttribute, + viewChild, +} from '@angular/core'; +import { NgIcon, provideIcons } from '@ng-icons/core'; +import { phosphorCaretLeft, phosphorCaretRight } from '@ng-icons/phosphor-icons/regular'; +import { + BrnCalendarImports, + BrnCalendarMulti, + injectBrnCalendarI18n, + type Weekday, +} from '@spartan-ng/brain/calendar'; +import { injectDateAdapter } from '@spartan-ng/brain/date-time'; +import { buttonVariants } from '../../../button/src'; +import { HlmIcon } from '../../../icon/src'; +import { HlmSelectImports } from '../../../select/src'; +import { hlm } from '../../../utils/src'; +import type { ClassValue } from 'clsx'; + +@Component({ + selector: 'hlm-calendar-multi', + imports: [BrnCalendarImports, NgIcon, HlmIcon, NgTemplateOutlet, HlmSelectImports], + viewProviders: [provideIcons({ phosphorCaretLeft, phosphorCaretRight })], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +
+
+ +
+
+
+ + + + + + + + @for (month of _i18n.config().months(); track month) { + {{ month }} + } + + + + + + + + + + + + @for (year of _i18n.config().years(); track year) { + {{ year }} + } + + + + + @let heading = _heading(); + @switch (captionLayout()) { + @case ('dropdown') { + + + } + @case ('dropdown-months') { + +
{{ heading.year }}
+ } + @case ('dropdown-years') { +
{{ heading.month }}
+ + } + @case ('label') { +
{{ heading.header }}
+ } + } +
+ +
+ + + +
+
+
+ + + + + + + + + + + @for (date of week; track _dateAdapter.getTime(date)) { + + } + + +
+ {{ _i18n.config().formatWeekdayName(weekday) }} +
+ +
+
+
+ `, +}) +export class HlmCalendarMulti { + public readonly calendarClass = input(''); + + protected readonly _computedCalenderClass = computed(() => + hlm('rounded-md border p-3', this.calendarClass()), + ); + + /** Access the calendar i18n */ + protected readonly _i18n = injectBrnCalendarI18n(); + + /** Access the date time adapter */ + protected readonly _dateAdapter = injectDateAdapter(); + + /** The days to highlight. */ + public readonly highlightDays = input([]); + + /** The minimum date that can be selected.*/ + public readonly min = input(); + + /** The maximum date that can be selected. */ + public readonly max = input(); + + /** Show dropdowns to navigate between months or years. */ + public readonly captionLayout = input< + 'dropdown' | 'label' | 'dropdown-months' | 'dropdown-years' + >('label'); + + /** 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 = model(); + + /** Whether a specific date is disabled. */ + public readonly dateDisabled = input<(date: T) => boolean>(() => false); + + /** The day the week starts on */ + public readonly weekStartsOn = input(undefined, { + transform: (v: unknown) => numberAttribute(v) as Weekday, + }); + + /** The default focused date. */ + public readonly defaultFocusedDate = input(); + + /** Access the calendar directive */ + private readonly _calendar = viewChild.required(BrnCalendarMulti); + + /** Get the heading for the current month and year */ + protected readonly _heading = computed(() => { + const config = this._i18n.config(); + const date = this._calendar().focusedDate(); + + return { + header: config.formatHeader( + this._dateAdapter.getMonth(date), + this._dateAdapter.getYear(date), + ), + month: config.formatMonth(this._dateAdapter.getMonth(date)), + year: config.formatYear(this._dateAdapter.getYear(date)), + }; + }); + + protected readonly _btnClass = hlm( + buttonVariants({ variant: 'ghost' }), + 'size-8 p-0 font-normal aria-selected:opacity-100', + 'data-[outside]:text-muted-foreground data-[outside]:aria-selected:bg-accent/50 data-[outside]:aria-selected:text-muted-foreground data-[outside]:opacity-50 data-[outside]:aria-selected:opacity-30', + 'data-[today]:bg-accent data-[today]:text-accent-foreground', + 'data-[selected]:bg-primary data-[selected]:text-primary-foreground data-[selected]:hover:bg-primary data-[selected]:hover:text-primary-foreground data-[selected]:focus:bg-primary data-[selected]:focus:text-primary-foreground', + 'data-[disabled]:text-muted-foreground data-[disabled]:opacity-50', + 'dark:hover:text-accent-foreground', + "data-[highlighted]:before:content-['']", + 'data-[highlighted]:before:absolute', + 'data-[highlighted]:before:bottom-1', + 'data-[highlighted]:before:left-1/2', + 'data-[highlighted]:before:h-1', + 'data-[highlighted]:before:w-1', + 'data-[highlighted]:before:-translate-x-1/2', + 'data-[highlighted]:before:rounded-full', + 'data-[highlighted]:before:bg-destructive', + ); + + protected readonly _selectClass = 'gap-0 px-1.5 py-2 [&>ng-icon]:ml-1'; +} diff --git a/packages/angular/src/lib/ui/calendar/src/lib/hlm-calendar-range.ts b/packages/angular/src/lib/ui/calendar/src/lib/hlm-calendar-range.ts new file mode 100644 index 0000000..062f3f7 --- /dev/null +++ b/packages/angular/src/lib/ui/calendar/src/lib/hlm-calendar-range.ts @@ -0,0 +1,239 @@ +import type { BooleanInput, NumberInput } from '@angular/cdk/coercion'; +import { NgTemplateOutlet } from '@angular/common'; +import { + booleanAttribute, + ChangeDetectionStrategy, + Component, + computed, + input, + model, + numberAttribute, + viewChild, +} from '@angular/core'; +import { NgIcon, provideIcons } from '@ng-icons/core'; +import { phosphorCaretLeft, phosphorCaretRight } from '@ng-icons/phosphor-icons/regular'; +import { + BrnCalendarImports, + BrnCalendarRange, + injectBrnCalendarI18n, + type Weekday, +} from '@spartan-ng/brain/calendar'; +import { injectDateAdapter } from '@spartan-ng/brain/date-time'; +import { buttonVariants } from '../../../button/src'; +import { HlmIcon } from '../../../icon/src'; +import { HlmSelectImports } from '../../../select/src'; +import { hlm } from '../../../utils/src'; +import type { ClassValue } from 'clsx'; + +@Component({ + selector: 'hlm-calendar-range', + imports: [BrnCalendarImports, NgIcon, HlmIcon, HlmSelectImports, NgTemplateOutlet], + viewProviders: [provideIcons({ phosphorCaretLeft, phosphorCaretRight })], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +
+
+ +
+
+
+ + + + + + + + @for (month of _i18n.config().months(); track month) { + {{ month }} + } + + + + + + + + + + + + @for (year of _i18n.config().years(); track year) { + {{ year }} + } + + + + + @let heading = _heading(); + @switch (captionLayout()) { + @case ('dropdown') { + + + } + @case ('dropdown-months') { + +
{{ heading.year }}
+ } + @case ('dropdown-years') { +
{{ heading.month }}
+ + } + @case ('label') { +
{{ heading.header }}
+ } + } +
+ +
+ + + +
+
+
+ + + + + + + + + + + @for (date of week; track _dateAdapter.getTime(date)) { + + } + + +
+ {{ _i18n.config().formatWeekdayName(weekday) }} +
+ +
+
+
+ `, +}) +export class HlmCalendarRange { + public readonly calendarClass = input(''); + + protected readonly _computedCalenderClass = computed(() => + hlm('rounded-md border p-3', this.calendarClass()), + ); + + /** Access the calendar i18n */ + protected readonly _i18n = injectBrnCalendarI18n(); + + /** Access the date time adapter */ + protected readonly _dateAdapter = injectDateAdapter(); + + /** The days to highlight. */ + public readonly highlightDays = input([]); + + /** The minimum date that can be selected.*/ + public readonly min = input(); + + /** The maximum date that can be selected. */ + public readonly max = input(); + + /** Show dropdowns to navigate between months or years. */ + public readonly captionLayout = input< + 'dropdown' | 'label' | 'dropdown-months' | 'dropdown-years' + >('label'); + + /** Determine if the date picker is disabled. */ + public readonly disabled = input(false, { + transform: booleanAttribute, + }); + + /** The start date of the range. */ + public readonly startDate = model(); + + /** The end date of the range. */ + public readonly endDate = model(); + + /** Whether a specific date is disabled. */ + public readonly dateDisabled = input<(date: T) => boolean>(() => false); + + /** The day the week starts on */ + public readonly weekStartsOn = input(undefined, { + transform: (v: unknown) => numberAttribute(v) as Weekday, + }); + + /** The default focused date. */ + public readonly defaultFocusedDate = input(); + + /** Access the calendar directive */ + private readonly _calendar = viewChild.required(BrnCalendarRange); + + /** Get the heading for the current month and year */ + protected readonly _heading = computed(() => { + const config = this._i18n.config(); + const date = this._calendar().focusedDate(); + + return { + header: config.formatHeader( + this._dateAdapter.getMonth(date), + this._dateAdapter.getYear(date), + ), + month: config.formatMonth(this._dateAdapter.getMonth(date)), + year: config.formatYear(this._dateAdapter.getYear(date)), + }; + }); + + protected readonly _btnClass = hlm( + buttonVariants({ variant: 'ghost' }), + 'relative z-10 size-8 border-0 p-0 font-normal aria-selected:opacity-100', + 'data-[outside]:text-muted-foreground data-[outside]:aria-selected:text-muted-foreground', + 'data-[today]:bg-accent data-[today]:text-accent-foreground', + 'data-[selected]:bg-primary data-[selected]:text-primary-foreground data-[selected]:focus:bg-primary data-[selected]:focus:text-primary-foreground', + 'data-[disabled]:text-muted-foreground data-[disabled]:opacity-50', + 'data-[range-between]:bg-accent data-[range-between]:text-accent-foreground data-[range-between]:rounded-none', + 'dark:hover:text-accent-foreground', + "data-[highlighted]:before:content-['']", + 'data-[highlighted]:before:absolute', + 'data-[highlighted]:before:bottom-1', + 'data-[highlighted]:before:left-1/2', + 'data-[highlighted]:before:h-1', + 'data-[highlighted]:before:w-1', + 'data-[highlighted]:before:-translate-x-1/2', + 'data-[highlighted]:before:rounded-full', + 'data-[highlighted]:before:bg-destructive', + ); + + protected readonly _selectClass = 'gap-0 px-1.5 py-2 [&>ng-icon]:ml-1'; +} diff --git a/packages/angular/src/lib/ui/calendar/src/lib/hlm-calendar.stories.ts b/packages/angular/src/lib/ui/calendar/src/lib/hlm-calendar.stories.ts new file mode 100644 index 0000000..acd24e8 --- /dev/null +++ b/packages/angular/src/lib/ui/calendar/src/lib/hlm-calendar.stories.ts @@ -0,0 +1,126 @@ +import { argsToTemplate, type Meta, moduleMetadata, type StoryObj } from '@storybook/angular'; +import { calendarContract } from '@surfnet/curve-contracts'; + +import { HlmButtonImports } from '../../../button/src'; +import { HlmPopoverImports } from '../../../popover/src'; + +import { HlmCalendar, HlmCalendarImports } from '..'; + +const meta: Meta> = { + title: 'Components/Calendar', + component: HlmCalendar, + decorators: [ + moduleMetadata({ + imports: [HlmCalendarImports, HlmButtonImports, HlmPopoverImports], + }), + ], + parameters: { + docs: { + description: { + component: calendarContract.docs.description, + }, + }, + }, + argTypes: { + captionLayout: { + control: 'select', + options: ['label', 'dropdown', 'dropdown-months', 'dropdown-years'], + description: 'How the month/year caption is rendered — a plain label or dropdown selectors.', + table: { + defaultValue: { summary: 'label' }, + }, + }, + }, + args: { + captionLayout: 'label', + }, +}; + +export default meta; +type Story = StoryObj>; + +/** A single-date calendar bound to the `date` two-way model — tweak the caption control. */ +export const Default: Story = { + render: (args) => ({ + props: { ...args, date: new Date() }, + template: ``, + }), +}; + +/** Multiple, individually toggled dates via `hlm-calendar-multi`. */ +export const Multiple: Story = { + render: () => ({ + props: { dates: [] as Date[] }, + template: ``, + }), +}; + +/** A contiguous range with a start and end date via `hlm-calendar-range`. */ +export const Range: Story = { + render: () => ({ + props: { start: undefined as Date | undefined, end: undefined as Date | undefined }, + template: ``, + }), +}; + +/** Dropdown month/year selectors instead of a static caption — useful for jumping across years. */ +export const DropdownCaption: Story = { + render: () => ({ + props: { date: new Date() }, + template: ``, + }), +}; + +/** Weekends disabled via `dateDisabled` — disabled days cannot be selected. */ +export const DisabledDates: Story = { + render: () => ({ + props: { + date: undefined as Date | undefined, + isWeekend: (d: Date) => d.getDay() === 0 || d.getDay() === 6, + }, + template: ``, + }), +}; + +/** Highlighted dates alongside a bounded `min`/`max` navigable range. */ +export const HighlightedAndBounded: Story = { + render: () => { + const today = new Date(); + const highlight = new Date(today); + highlight.setDate(today.getDate() + 3); + 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, + highlights: [highlight], + min, + max, + }, + template: ``, + }; + }, +}; + +/** + * Composed inside a Popover trigger — the same pattern the `HlmDatePicker` component + * uses internally, built here directly from `hlm-calendar` and the popover primitives. + */ +export const InPopover: Story = { + render: () => ({ + props: { date: undefined as Date | undefined }, + template: ` + + + + + + + `, + }), +}; diff --git a/packages/angular/src/lib/ui/calendar/src/lib/hlm-calendar.ts b/packages/angular/src/lib/ui/calendar/src/lib/hlm-calendar.ts new file mode 100644 index 0000000..5157c53 --- /dev/null +++ b/packages/angular/src/lib/ui/calendar/src/lib/hlm-calendar.ts @@ -0,0 +1,234 @@ +import type { BooleanInput, NumberInput } from '@angular/cdk/coercion'; +import { NgTemplateOutlet } from '@angular/common'; +import { + booleanAttribute, + ChangeDetectionStrategy, + Component, + computed, + input, + model, + numberAttribute, + viewChild, +} from '@angular/core'; +import { NgIcon, provideIcons } from '@ng-icons/core'; +import { phosphorCaretLeft, phosphorCaretRight } from '@ng-icons/phosphor-icons/regular'; +import { + BrnCalendar, + BrnCalendarImports, + injectBrnCalendarI18n, + type Weekday, +} from '@spartan-ng/brain/calendar'; +import { injectDateAdapter } from '@spartan-ng/brain/date-time'; +import { buttonVariants } from '../../../button/src'; +import { HlmIcon } from '../../../icon/src'; +import { HlmSelectImports } from '../../../select/src'; +import { hlm } from '../../../utils/src'; +import type { ClassValue } from 'clsx'; + +@Component({ + selector: 'hlm-calendar', + imports: [BrnCalendarImports, NgIcon, HlmIcon, HlmSelectImports, NgTemplateOutlet], + viewProviders: [provideIcons({ phosphorCaretLeft, phosphorCaretRight })], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +
+
+ +
+
+
+ + + + + + + + @for (month of _i18n.config().months(); track month) { + {{ month }} + } + + + + + + + + + + + + @for (year of _i18n.config().years(); track year) { + {{ year }} + } + + + + + @let heading = _heading(); + @switch (captionLayout()) { + @case ('dropdown') { + + + } + @case ('dropdown-months') { + +
{{ heading.year }}
+ } + @case ('dropdown-years') { +
{{ heading.month }}
+ + } + @case ('label') { +
{{ heading.header }}
+ } + } +
+ +
+ + + +
+
+
+ + + + + + + + + + + @for (date of week; track _dateAdapter.getTime(date)) { + + } + + +
+ {{ _i18n.config().formatWeekdayName(weekday) }} +
+ +
+
+
+ `, +}) +export class HlmCalendar { + public readonly calendarClass = input(''); + + protected readonly _computedCalenderClass = computed(() => + hlm('rounded-md border p-3', this.calendarClass()), + ); + + /** Access the calendar i18n */ + protected readonly _i18n = injectBrnCalendarI18n(); + + /** Access the date time adapter */ + protected readonly _dateAdapter = injectDateAdapter(); + + /** The days to highlight. */ + public readonly highlightDays = input([]); + + /** The minimum date that can be selected.*/ + public readonly min = input(); + + /** The maximum date that can be selected. */ + public readonly max = input(); + + /** Show dropdowns to navigate between months or years. */ + public readonly captionLayout = input< + 'dropdown' | 'label' | 'dropdown-months' | 'dropdown-years' + >('label'); + + /** Determine if the date picker is disabled. */ + public readonly disabled = input(false, { + transform: booleanAttribute, + }); + + /** The selected value. */ + public readonly date = model(); + + /** Whether a specific date is disabled. */ + public readonly dateDisabled = input<(date: T) => boolean>(() => false); + + /** The day the week starts on */ + public readonly weekStartsOn = input(undefined, { + transform: (v: unknown) => numberAttribute(v) as Weekday, + }); + + /** The default focused date. */ + public readonly defaultFocusedDate = input(); + + /** Access the calendar directive */ + private readonly _calendar = viewChild.required(BrnCalendar); + + /** Get the heading for the current month and year */ + protected readonly _heading = computed(() => { + const config = this._i18n.config(); + const date = this._calendar().focusedDate(); + + return { + header: config.formatHeader( + this._dateAdapter.getMonth(date), + this._dateAdapter.getYear(date), + ), + month: config.formatMonth(this._dateAdapter.getMonth(date)), + year: config.formatYear(this._dateAdapter.getYear(date)), + }; + }); + + protected readonly _btnClass = hlm( + buttonVariants({ variant: 'ghost' }), + 'size-8 p-0 font-normal aria-selected:opacity-100', + 'data-[outside]:text-muted-foreground data-[outside]:aria-selected:bg-accent/50 data-[outside]:aria-selected:text-muted-foreground data-[outside]:opacity-50 data-[outside]:aria-selected:opacity-30', + 'data-[today]:bg-accent data-[today]:text-accent-foreground', + 'data-[selected]:bg-primary data-[selected]:text-primary-foreground data-[selected]:hover:bg-primary dark:hover:text-accent-foreground data-[selected]:focus:bg-primary data-[selected]:focus:text-primary-foreground', + 'data-[disabled]:text-muted-foreground data-[disabled]:opacity-50', + 'dark:hover:text-accent-foreground', + "data-[highlighted]:before:content-['']", + 'data-[highlighted]:before:absolute', + 'data-[highlighted]:before:bottom-1', + 'data-[highlighted]:before:left-1/2', + 'data-[highlighted]:before:h-1', + 'data-[highlighted]:before:w-1', + 'data-[highlighted]:before:-translate-x-1/2', + 'data-[highlighted]:before:rounded-full', + 'data-[highlighted]:before:bg-destructive', + ); + + protected readonly _selectClass = 'gap-0 px-1.5 py-2 [&>ng-icon]:ml-1'; +} diff --git a/packages/angular/src/lib/ui/carousel/src/index.ts b/packages/angular/src/lib/ui/carousel/src/index.ts new file mode 100644 index 0000000..a191a6f --- /dev/null +++ b/packages/angular/src/lib/ui/carousel/src/index.ts @@ -0,0 +1,22 @@ +import { HlmCarousel } from './lib/hlm-carousel'; +import { HlmCarouselContent } from './lib/hlm-carousel-content'; +import { HlmCarouselItem } from './lib/hlm-carousel-item'; +import { HlmCarouselNext } from './lib/hlm-carousel-next'; +import { HlmCarouselPrevious } from './lib/hlm-carousel-previous'; +import { HlmCarouselSlideDisplay } from './lib/hlm-carousel-slide-display'; + +export * from './lib/hlm-carousel'; +export * from './lib/hlm-carousel-content'; +export * from './lib/hlm-carousel-item'; +export * from './lib/hlm-carousel-next'; +export * from './lib/hlm-carousel-previous'; +export * from './lib/hlm-carousel-slide-display'; + +export const HlmCarouselImports = [ + HlmCarousel, + HlmCarouselContent, + HlmCarouselItem, + HlmCarouselPrevious, + HlmCarouselNext, + HlmCarouselSlideDisplay, +] as const; diff --git a/packages/angular/src/lib/ui/carousel/src/lib/hlm-carousel-content.ts b/packages/angular/src/lib/ui/carousel/src/lib/hlm-carousel-content.ts new file mode 100644 index 0000000..4217b93 --- /dev/null +++ b/packages/angular/src/lib/ui/carousel/src/lib/hlm-carousel-content.ts @@ -0,0 +1,17 @@ +import { Directive, inject } from '@angular/core'; +import { classes } from '../../../utils/src'; +import { HlmCarousel } from './hlm-carousel'; + +@Directive({ + selector: '[hlmCarouselContent],hlm-carousel-content', + host: { + 'data-slot': 'carousel-content', + }, +}) +export class HlmCarouselContent { + private readonly _orientation = inject(HlmCarousel).orientation; + + constructor() { + classes(() => ['flex', this._orientation() === 'horizontal' ? '-ml-4' : '-mt-4 flex-col']); + } +} diff --git a/packages/angular/src/lib/ui/carousel/src/lib/hlm-carousel-item.ts b/packages/angular/src/lib/ui/carousel/src/lib/hlm-carousel-item.ts new file mode 100644 index 0000000..afedf9f --- /dev/null +++ b/packages/angular/src/lib/ui/carousel/src/lib/hlm-carousel-item.ts @@ -0,0 +1,22 @@ +import { Directive, inject } from '@angular/core'; +import { classes } from '../../../utils/src'; +import { HlmCarousel } from './hlm-carousel'; + +@Directive({ + selector: '[hlmCarouselItem],hlm-carousel-item', + host: { + 'data-slot': 'carousel-item', + role: 'group', + 'aria-roledescription': 'slide', + }, +}) +export class HlmCarouselItem { + private readonly _orientation = inject(HlmCarousel).orientation; + + constructor() { + classes(() => [ + 'min-w-0 shrink-0 grow-0 basis-full', + this._orientation() === 'horizontal' ? 'pl-4' : 'pt-4', + ]); + } +} diff --git a/packages/angular/src/lib/ui/carousel/src/lib/hlm-carousel-next.ts b/packages/angular/src/lib/ui/carousel/src/lib/hlm-carousel-next.ts new file mode 100644 index 0000000..8fbfa0e --- /dev/null +++ b/packages/angular/src/lib/ui/carousel/src/lib/hlm-carousel-next.ts @@ -0,0 +1,55 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + effect, + inject, + untracked, +} from '@angular/core'; +import { NgIcon, provideIcons } from '@ng-icons/core'; +import { phosphorArrowRight } from '@ng-icons/phosphor-icons/regular'; +import { HlmButton, provideBrnButtonConfig } from '../../../button/src'; +import { HlmIcon } from '../../../icon/src'; +import { hlm } from '../../../utils/src'; +import { HlmCarousel } from './hlm-carousel'; + +@Component({ + selector: 'button[hlm-carousel-next], button[hlmCarouselNext]', + imports: [NgIcon, HlmIcon], + providers: [ + provideIcons({ phosphorArrowRight }), + provideBrnButtonConfig({ variant: 'outline', size: 'icon' }), + ], + changeDetection: ChangeDetectionStrategy.OnPush, + hostDirectives: [{ directive: HlmButton, inputs: ['variant', 'size'] }], + host: { + 'data-slot': 'carousel-next', + '[disabled]': 'isDisabled()', + '(click)': '_carousel.scrollNext()', + }, + template: ` + + Next slide + `, +}) +export class HlmCarouselNext { + private readonly _button = inject(HlmButton); + protected readonly _carousel = inject(HlmCarousel); + private readonly _computedClass = computed(() => + hlm( + 'rounded-full absolute h-8 w-8', + this._carousel.orientation() === 'horizontal' + ? '-end-12 top-1/2 -translate-y-1/2' + : '-bottom-12 left-1/2 -translate-x-1/2 rotate-90', + ), + ); + protected readonly isDisabled = () => !this._carousel.canScrollNext(); + + constructor() { + effect(() => { + const computedClass = this._computedClass(); + + untracked(() => this._button.setClass(computedClass)); + }); + } +} diff --git a/packages/angular/src/lib/ui/carousel/src/lib/hlm-carousel-previous.ts b/packages/angular/src/lib/ui/carousel/src/lib/hlm-carousel-previous.ts new file mode 100644 index 0000000..b1d6cd1 --- /dev/null +++ b/packages/angular/src/lib/ui/carousel/src/lib/hlm-carousel-previous.ts @@ -0,0 +1,56 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + effect, + inject, + untracked, +} from '@angular/core'; +import { NgIcon, provideIcons } from '@ng-icons/core'; +import { phosphorArrowLeft } from '@ng-icons/phosphor-icons/regular'; +import { HlmButton, provideBrnButtonConfig } from '../../../button/src'; +import { HlmIcon } from '../../../icon/src'; +import { hlm } from '../../../utils/src'; +import { HlmCarousel } from './hlm-carousel'; + +@Component({ + selector: 'button[hlm-carousel-previous], button[hlmCarouselPrevious]', + imports: [NgIcon, HlmIcon], + providers: [ + provideIcons({ phosphorArrowLeft }), + provideBrnButtonConfig({ variant: 'outline', size: 'icon' }), + ], + changeDetection: ChangeDetectionStrategy.OnPush, + hostDirectives: [{ directive: HlmButton, inputs: ['variant', 'size'] }], + host: { + 'data-slot': 'carousel-previous', + '[disabled]': 'isDisabled()', + '(click)': '_carousel.scrollPrev()', + }, + template: ` + + Previous slide + `, +}) +export class HlmCarouselPrevious { + private readonly _button = inject(HlmButton); + + protected readonly _carousel = inject(HlmCarousel); + + private readonly _computedClass = computed(() => + hlm( + 'rounded-full absolute h-8 w-8', + this._carousel.orientation() === 'horizontal' + ? '-start-12 top-1/2 -translate-y-1/2' + : '-top-12 left-1/2 -translate-x-1/2 rotate-90', + ), + ); + protected readonly isDisabled = () => !this._carousel.canScrollPrev(); + + constructor() { + effect(() => { + const computedClass = this._computedClass(); + untracked(() => this._button.setClass(computedClass)); + }); + } +} diff --git a/packages/angular/src/lib/ui/carousel/src/lib/hlm-carousel-slide-display.ts b/packages/angular/src/lib/ui/carousel/src/lib/hlm-carousel-slide-display.ts new file mode 100644 index 0000000..56ec07a --- /dev/null +++ b/packages/angular/src/lib/ui/carousel/src/lib/hlm-carousel-slide-display.ts @@ -0,0 +1,33 @@ +import { ChangeDetectionStrategy, Component, computed, inject, input } from '@angular/core'; +import type { ClassValue } from 'clsx'; +import { HlmCarousel } from './hlm-carousel'; + +@Component({ + selector: 'hlm-carousel-slide-display', + changeDetection: ChangeDetectionStrategy.OnPush, + host: { + 'data-slot': 'carousel-slide-display', + }, + template: ` + {{ _labelContent() }} + + `, +}) +export class HlmCarouselSlideDisplay { + protected readonly _carousel = inject(HlmCarousel); + + protected readonly _currentSlide = computed(() => this._carousel.currentSlide() + 1); + + public readonly slideClass = input('text-muted-foreground text-sm'); + + /** Screen reader only text for the slide display */ + public readonly label = input('Slide'); + + protected readonly _labelContent = computed(() => { + const currentSlide = this._currentSlide(); + const slideCount = this._carousel.slideCount(); + return `${this.label()} ${currentSlide} of ${slideCount} is displayed`; + }); +} diff --git a/packages/angular/src/lib/ui/carousel/src/lib/hlm-carousel.stories.ts b/packages/angular/src/lib/ui/carousel/src/lib/hlm-carousel.stories.ts new file mode 100644 index 0000000..d6fdd8b --- /dev/null +++ b/packages/angular/src/lib/ui/carousel/src/lib/hlm-carousel.stories.ts @@ -0,0 +1,194 @@ +import { type Meta, type StoryObj, argsToTemplate, moduleMetadata } from '@storybook/angular'; +import { carouselContract } from '@surfnet/curve-contracts'; + +import { HlmCarousel, HlmCarouselImports } from '..'; +import { HlmCardImports } from '../../../card/src'; + +const meta: Meta = { + title: 'Components/Carousel', + component: HlmCarousel, + decorators: [ + moduleMetadata({ + imports: [HlmCarouselImports, HlmCardImports], + }), + ], + parameters: { + docs: { + description: { + component: carouselContract.docs.description, + }, + }, + }, + argTypes: { + orientation: { + control: 'inline-radio', + options: carouselContract.props.orientations, + description: carouselContract.props.orientations + .map( + (orientation) => + `\`${orientation}\` — ${carouselContract.docs.orientations[orientation]}`, + ) + .join('\n\n'), + table: { + defaultValue: { summary: carouselContract.defaults.orientations }, + }, + }, + }, + args: { + orientation: carouselContract.defaults.orientations, + }, +}; + +export default meta; +type Story = StoryObj; + +/** Interactive playground — flip `orientation` in the controls to see both directions. */ +export const Default: Story = { + render: ({ ...args }) => ({ + props: args, + template: ` + + + +
+
+ 1 +
+
+
+ +
+
+ 2 +
+
+
+ +
+
+ 3 +
+
+
+ +
+
+ 4 +
+
+
+ +
+
+ 5 +
+
+
+
+ + +
+ `, + }), +}; + +/** `orientation="horizontal"` — slides pan left/right (the default). */ +export const Horizontal: Story = { + render: () => ({ + template: ` + + + +
+
+ 1 +
+
+
+ +
+
+ 2 +
+
+
+ +
+
+ 3 +
+
+
+ +
+
+ 4 +
+
+
+ +
+
+ 5 +
+
+
+
+ + +
+ `, + }), +}; + +/** `orientation="vertical"` — slides pan up/down. */ +export const Vertical: Story = { + render: () => ({ + template: ` + + + +
+
+ 1 +
+
+
+ +
+
+ 2 +
+
+
+ +
+
+ 3 +
+
+
+ +
+
+ 4 +
+
+
+ +
+
+ 5 +
+
+
+
+ + +
+ `, + }), +}; diff --git a/packages/angular/src/lib/ui/carousel/src/lib/hlm-carousel.ts b/packages/angular/src/lib/ui/carousel/src/lib/hlm-carousel.ts new file mode 100644 index 0000000..a3f4fd6 --- /dev/null +++ b/packages/angular/src/lib/ui/carousel/src/lib/hlm-carousel.ts @@ -0,0 +1,106 @@ +import { Directionality } from '@angular/cdk/bidi'; +import { + ChangeDetectionStrategy, + Component, + type InputSignal, + type Signal, + computed, + inject, + input, + signal, + viewChild, +} from '@angular/core'; +import { classes } from '../../../utils/src'; +import type { EmblaEventType, EmblaOptionsType, EmblaPluginType } from 'embla-carousel'; +import { EmblaCarouselDirective } from 'embla-carousel-angular'; +import type { CarouselOrientationName } from '@surfnet/curve-contracts'; + +@Component({ + selector: 'hlm-carousel', + imports: [EmblaCarouselDirective], + changeDetection: ChangeDetectionStrategy.OnPush, + host: { + 'data-slot': 'carousel', + role: 'region', + 'aria-roledescription': 'carousel', + '(keydown)': 'onKeydown($event)', + }, + template: ` +
+ +
+ + `, +}) +export class HlmCarousel { + protected readonly _emblaCarousel = viewChild.required(EmblaCarouselDirective); + private readonly _dir = inject(Directionality); + + public readonly orientation = input('horizontal'); + public readonly options: InputSignal | undefined> = + input>(); + public readonly plugins: InputSignal = input([]); + + protected readonly _emblaOptions: Signal = computed(() => ({ + // Default embla's direction to the ambient layout direction so the carousel scrolls RTL + // automatically; an explicit `direction` in `options` still wins. + direction: this._dir.valueSignal(), + ...this.options(), + axis: this.orientation() === 'horizontal' ? 'x' : 'y', + })); + + private readonly _canScrollPrev = signal(false); + public readonly canScrollPrev = this._canScrollPrev.asReadonly(); + private readonly _canScrollNext = signal(false); + public readonly canScrollNext = this._canScrollNext.asReadonly(); + + private readonly _currentSlide = signal(0); + public readonly currentSlide = this._currentSlide.asReadonly(); + private readonly _slideCount = signal(0); + public readonly slideCount = this._slideCount.asReadonly(); + + constructor() { + classes(() => 'relative'); + } + + protected onEmblaEvent(event: EmblaEventType) { + const emblaApi = this._emblaCarousel().emblaApi; + + if (!emblaApi) { + return; + } + + if (event === 'select' || event === 'init' || event === 'reInit') { + this._canScrollPrev.set(emblaApi.canScrollPrev()); + this._canScrollNext.set(emblaApi.canScrollNext()); + + this._currentSlide.set(emblaApi.selectedScrollSnap()); + this._slideCount.set(emblaApi.scrollSnapList().length); + } + } + + protected onKeydown(event: KeyboardEvent) { + if (event.key === 'ArrowLeft') { + event.preventDefault(); + this._emblaCarousel().scrollPrev(); + } else if (event.key === 'ArrowRight') { + event.preventDefault(); + this._emblaCarousel().scrollNext(); + } + } + + scrollPrev() { + this._emblaCarousel().scrollPrev(); + } + + scrollNext() { + this._emblaCarousel().scrollNext(); + } +} diff --git a/packages/angular/src/lib/ui/combobox/src/index.ts b/packages/angular/src/lib/ui/combobox/src/index.ts new file mode 100644 index 0000000..bb81237 --- /dev/null +++ b/packages/angular/src/lib/ui/combobox/src/index.ts @@ -0,0 +1,64 @@ +import { HlmCombobox } from './lib/hlm-combobox'; +import { HlmComboboxChip } from './lib/hlm-combobox-chip'; +import { HlmComboboxChipInput } from './lib/hlm-combobox-chip-input'; +import { HlmComboboxChips } from './lib/hlm-combobox-chips'; +import { HlmComboboxContent } from './lib/hlm-combobox-content'; +import { HlmComboboxEmpty } from './lib/hlm-combobox-empty'; +import { HlmComboboxGroup } from './lib/hlm-combobox-group'; +import { HlmComboboxInput } from './lib/hlm-combobox-input'; +import { HlmComboboxItem } from './lib/hlm-combobox-item'; +import { HlmComboboxLabel } from './lib/hlm-combobox-label'; +import { HlmComboboxList } from './lib/hlm-combobox-list'; +import { HlmComboboxMultiple } from './lib/hlm-combobox-multiple'; +import { HlmComboboxPlaceholder } from './lib/hlm-combobox-placeholder'; +import { HlmComboboxPortal } from './lib/hlm-combobox-portal'; +import { HlmComboboxSeparator } from './lib/hlm-combobox-separator'; +import { HlmComboboxStatus } from './lib/hlm-combobox-status'; +import { HlmComboboxTrigger } from './lib/hlm-combobox-trigger'; +import { HlmComboboxValue } from './lib/hlm-combobox-value'; +import { HlmComboboxValueTemplate } from './lib/hlm-combobox-value-template'; +import { HlmComboboxValues } from './lib/hlm-combobox-values'; + +export * from './lib/hlm-combobox'; +export * from './lib/hlm-combobox-chip'; +export * from './lib/hlm-combobox-chip-input'; +export * from './lib/hlm-combobox-chips'; +export * from './lib/hlm-combobox-content'; +export * from './lib/hlm-combobox-empty'; +export * from './lib/hlm-combobox-group'; +export * from './lib/hlm-combobox-input'; +export * from './lib/hlm-combobox-item'; +export * from './lib/hlm-combobox-label'; +export * from './lib/hlm-combobox-list'; +export * from './lib/hlm-combobox-multiple'; +export * from './lib/hlm-combobox-placeholder'; +export * from './lib/hlm-combobox-portal'; +export * from './lib/hlm-combobox-separator'; +export * from './lib/hlm-combobox-status'; +export * from './lib/hlm-combobox-trigger'; +export * from './lib/hlm-combobox-value'; +export * from './lib/hlm-combobox-value-template'; +export * from './lib/hlm-combobox-values'; + +export const HlmComboboxImports = [ + HlmCombobox, + HlmComboboxChip, + HlmComboboxChipInput, + HlmComboboxChips, + HlmComboboxContent, + HlmComboboxEmpty, + HlmComboboxGroup, + HlmComboboxInput, + HlmComboboxItem, + HlmComboboxLabel, + HlmComboboxList, + HlmComboboxMultiple, + HlmComboboxPlaceholder, + HlmComboboxPortal, + HlmComboboxSeparator, + HlmComboboxStatus, + HlmComboboxTrigger, + HlmComboboxValue, + HlmComboboxValueTemplate, + HlmComboboxValues, +] as const; diff --git a/packages/angular/src/lib/ui/combobox/src/lib/hlm-combobox-chip-input.ts b/packages/angular/src/lib/ui/combobox/src/lib/hlm-combobox-chip-input.ts new file mode 100644 index 0000000..7e7ae87 --- /dev/null +++ b/packages/angular/src/lib/ui/combobox/src/lib/hlm-combobox-chip-input.ts @@ -0,0 +1,14 @@ +import { Directive } from '@angular/core'; +import { BrnComboboxChipInput } from '@spartan-ng/brain/combobox'; +import { classes } from '../../../utils/src'; + +@Directive({ + selector: 'input[hlmComboboxChipInput]', + hostDirectives: [{ directive: BrnComboboxChipInput, inputs: ['id', 'aria-invalid'] }], + host: { 'data-slot': 'combobox-chip-input' }, +}) +export class HlmComboboxChipInput { + constructor() { + classes(() => 'placeholder:text-muted-foreground min-w-16 flex-1 outline-none'); + } +} diff --git a/packages/angular/src/lib/ui/combobox/src/lib/hlm-combobox-chip-remove.ts b/packages/angular/src/lib/ui/combobox/src/lib/hlm-combobox-chip-remove.ts new file mode 100644 index 0000000..6b79dfe --- /dev/null +++ b/packages/angular/src/lib/ui/combobox/src/lib/hlm-combobox-chip-remove.ts @@ -0,0 +1,18 @@ +import { Directive } from '@angular/core'; +import { BrnComboboxChipRemove } from '@spartan-ng/brain/combobox'; +import { buttonVariants } from '../../../button/src'; +import { classes } from '../../../utils/src'; + +@Directive({ + selector: 'button[hlmComboboxChipRemove]', + hostDirectives: [BrnComboboxChipRemove], + host: { 'data-slot': 'combobox-chip-remove' }, +}) +export class HlmComboboxChipRemove { + constructor() { + classes(() => [ + '-ms-1 opacity-50 hover:opacity-100', + buttonVariants({ variant: 'ghost', size: 'icon-xs' }), + ]); + } +} diff --git a/packages/angular/src/lib/ui/combobox/src/lib/hlm-combobox-chip.ts b/packages/angular/src/lib/ui/combobox/src/lib/hlm-combobox-chip.ts new file mode 100644 index 0000000..aa61761 --- /dev/null +++ b/packages/angular/src/lib/ui/combobox/src/lib/hlm-combobox-chip.ts @@ -0,0 +1,35 @@ +import type { BooleanInput } from '@angular/cdk/coercion'; +import { booleanAttribute, ChangeDetectionStrategy, Component, input } from '@angular/core'; +import { NgIcon, provideIcons } from '@ng-icons/core'; +import { phosphorX } from '@ng-icons/phosphor-icons/regular'; +import { BrnComboboxChip } from '@spartan-ng/brain/combobox'; +import { classes } from '../../../utils/src'; +import { HlmComboboxChipRemove } from './hlm-combobox-chip-remove'; + +@Component({ + selector: 'hlm-combobox-chip', + imports: [NgIcon, HlmComboboxChipRemove], + providers: [provideIcons({ phosphorX })], + changeDetection: ChangeDetectionStrategy.OnPush, + hostDirectives: [{ directive: BrnComboboxChip, inputs: ['value'] }], + host: { 'data-slot': 'combobox-chip' }, + template: ` + + + @if (showRemove()) { + + } + `, +}) +export class HlmComboboxChip { + public readonly showRemove = input(true, { transform: booleanAttribute }); + + constructor() { + classes( + () => + 'bg-muted text-foreground flex h-[calc(--spacing(5.5))] w-fit items-center justify-center gap-1 rounded-sm px-1.5 text-xs font-medium whitespace-nowrap has-data-[slot=combobox-chip-remove]:pe-0 has-disabled:pointer-events-none has-disabled:cursor-not-allowed has-disabled:opacity-50', + ); + } +} diff --git a/packages/angular/src/lib/ui/combobox/src/lib/hlm-combobox-chips.ts b/packages/angular/src/lib/ui/combobox/src/lib/hlm-combobox-chips.ts new file mode 100644 index 0000000..cdde39a --- /dev/null +++ b/packages/angular/src/lib/ui/combobox/src/lib/hlm-combobox-chips.ts @@ -0,0 +1,35 @@ +import { BooleanInput } from '@angular/cdk/coercion'; +import { booleanAttribute, computed, Directive, input } from '@angular/core'; +import { + BrnComboboxAnchor, + BrnComboboxPopoverTrigger, + injectBrnComboboxBase, +} from '@spartan-ng/brain/combobox'; +import { classes } from '../../../utils/src'; + +@Directive({ + selector: '[hlmComboboxChips],hlm-combobox-chips', + hostDirectives: [BrnComboboxAnchor, BrnComboboxPopoverTrigger], + host: { + 'data-slot': 'combobox-chips', + '[attr.data-matches-spartan-invalid]': '_spartanInvalid() ? "true" : null', + }, +}) +export class HlmComboboxChips { + private readonly _combobox = injectBrnComboboxBase(); + + public readonly forceInvalid = input(false, { + transform: booleanAttribute, + }); + + protected readonly _spartanInvalid = computed( + () => this.forceInvalid() || this._combobox.controlState?.()?.spartanInvalid, + ); + + constructor() { + classes( + () => + 'dark:bg-input/30 border-input focus-within:border-ring focus-within:ring-ring/50 data-[matches-spartan-invalid=true]:ring-destructive/20 dark:data-[matches-spartan-invalid=true]:ring-destructive/40 data-[matches-spartan-invalid=true]:border-destructive dark:data-[matches-spartan-invalid=true]:border-destructive/50 flex min-h-9 flex-wrap items-center gap-1.5 rounded-md border bg-transparent bg-clip-padding px-2.5 py-1.5 text-sm shadow-xs transition-[color,box-shadow] focus-within:ring-3 has-data-[slot=combobox-chip]:px-1.5 data-[matches-spartan-invalid=true]:ring-3', + ); + } +} diff --git a/packages/angular/src/lib/ui/combobox/src/lib/hlm-combobox-content.ts b/packages/angular/src/lib/ui/combobox/src/lib/hlm-combobox-content.ts new file mode 100644 index 0000000..a717b88 --- /dev/null +++ b/packages/angular/src/lib/ui/combobox/src/lib/hlm-combobox-content.ts @@ -0,0 +1,15 @@ +import { Directive } from '@angular/core'; +import { BrnComboboxContent } from '@spartan-ng/brain/combobox'; +import { classes } from '../../../utils/src'; + +@Directive({ + selector: '[hlmComboboxContent],hlm-combobox-content', + hostDirectives: [BrnComboboxContent], +}) +export class HlmComboboxContent { + constructor() { + classes(() => [ + 'bg-popover text-popover-foreground data-open:animate-in **:has-[[data-slot=input-group-control]:focus-visible]:border-input data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 **:data-[slot=input-group]:bg-input/30 **:data-[slot=input-group]:border-input/30 max-h-72 min-w-36 overflow-hidden rounded-md shadow-md ring-1 duration-100 **:has-[[data-slot=input-group-control]:focus-visible]:ring-0 **:data-[slot=input-group]:m-1 **:data-[slot=input-group]:mb-0 **:data-[slot=input-group]:h-8 **:data-[slot=input-group]:shadow-none group/combobox-content relative flex w-(--brn-combobox-width) flex-col p-0', + ]); + } +} diff --git a/packages/angular/src/lib/ui/combobox/src/lib/hlm-combobox-empty.ts b/packages/angular/src/lib/ui/combobox/src/lib/hlm-combobox-empty.ts new file mode 100644 index 0000000..3b3d141 --- /dev/null +++ b/packages/angular/src/lib/ui/combobox/src/lib/hlm-combobox-empty.ts @@ -0,0 +1,17 @@ +import { Directive } from '@angular/core'; +import { BrnComboboxEmpty } from '@spartan-ng/brain/combobox'; +import { classes } from '../../../utils/src'; + +@Directive({ + selector: '[hlmComboboxEmpty],hlm-combobox-empty', + hostDirectives: [BrnComboboxEmpty], + host: { 'data-slot': 'combobox-empty' }, +}) +export class HlmComboboxEmpty { + constructor() { + classes( + () => + 'text-muted-foreground hidden w-full items-center justify-center gap-2 py-2 text-center text-sm group-data-empty/combobox-content:flex', + ); + } +} diff --git a/packages/angular/src/lib/ui/combobox/src/lib/hlm-combobox-group.ts b/packages/angular/src/lib/ui/combobox/src/lib/hlm-combobox-group.ts new file mode 100644 index 0000000..1ad9043 --- /dev/null +++ b/packages/angular/src/lib/ui/combobox/src/lib/hlm-combobox-group.ts @@ -0,0 +1,14 @@ +import { Directive } from '@angular/core'; +import { BrnComboboxGroup } from '@spartan-ng/brain/combobox'; +import { classes } from '../../../utils/src'; + +@Directive({ + selector: '[hlmComboboxGroup]', + hostDirectives: [BrnComboboxGroup], + host: { 'data-slot': 'combobox-group' }, +}) +export class HlmComboboxGroup { + constructor() { + classes(() => 'data-hidden:hidden'); + } +} diff --git a/packages/angular/src/lib/ui/combobox/src/lib/hlm-combobox-input.ts b/packages/angular/src/lib/ui/combobox/src/lib/hlm-combobox-input.ts new file mode 100644 index 0000000..9d2f32d --- /dev/null +++ b/packages/angular/src/lib/ui/combobox/src/lib/hlm-combobox-input.ts @@ -0,0 +1,76 @@ +import { BooleanInput } from '@angular/cdk/coercion'; +import { booleanAttribute, ChangeDetectionStrategy, Component, input } from '@angular/core'; +import { NgIcon, provideIcons } from '@ng-icons/core'; +import { phosphorCaretDown, phosphorX } from '@ng-icons/phosphor-icons/regular'; +import { BrnComboboxImports, BrnComboboxPopoverTrigger } from '@spartan-ng/brain/combobox'; +import { HlmInputGroupImports } from '../../../input-group/src'; + +@Component({ + selector: 'hlm-combobox-input', + imports: [HlmInputGroupImports, NgIcon, BrnComboboxImports, BrnComboboxPopoverTrigger], + providers: [provideIcons({ phosphorCaretDown, phosphorX })], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + + + + + @if (showTrigger()) { + + } + + @if (showClear()) { + + } + + + + + `, +}) +export class HlmComboboxInput { + private static _id = 0; + + public readonly inputId = input(`hlm-combobox-input-${HlmComboboxInput._id++}`); + public readonly placeholder = input(''); + + public readonly showTrigger = input(true, { transform: booleanAttribute }); + public readonly showClear = input(false, { transform: booleanAttribute }); + public readonly forceInvalid = input(false, { + transform: booleanAttribute, + }); + + /** Manual override for aria-invalid. When not set, auto-detects from the parent combobox error state. */ + public readonly ariaInvalidOverride = input(undefined, { + transform: (v: BooleanInput) => (v === '' || v === undefined ? undefined : booleanAttribute(v)), + alias: 'aria-invalid', + }); +} diff --git a/packages/angular/src/lib/ui/combobox/src/lib/hlm-combobox-item.ts b/packages/angular/src/lib/ui/combobox/src/lib/hlm-combobox-item.ts new file mode 100644 index 0000000..f9ac432 --- /dev/null +++ b/packages/angular/src/lib/ui/combobox/src/lib/hlm-combobox-item.ts @@ -0,0 +1,36 @@ +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { NgIcon, provideIcons } from '@ng-icons/core'; +import { phosphorCheck } from '@ng-icons/phosphor-icons/regular'; +import { BrnComboboxItem } from '@spartan-ng/brain/combobox'; +import { classes } from '../../../utils/src'; + +@Component({ + selector: 'hlm-combobox-item', + imports: [NgIcon], + providers: [provideIcons({ phosphorCheck })], + changeDetection: ChangeDetectionStrategy.OnPush, + hostDirectives: [{ directive: BrnComboboxItem, inputs: ['id', 'disabled', 'value'] }], + host: { 'data-slot': 'combobox-item' }, + template: ` + + @if (_active()) { +