Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
11 changes: 11 additions & 0 deletions .changeset/angular-additional-components.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 6 additions & 0 deletions .changeset/pagination-parity.md
Original file line number Diff line number Diff line change
@@ -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.
9 changes: 9 additions & 0 deletions .changeset/react-additional-components.md
Original file line number Diff line number Diff line change
@@ -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`.
4 changes: 2 additions & 2 deletions apps/angular-app/angular.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,8 @@
"budgets": [
{
"type": "initial",
"maximumWarning": "500kB",
"maximumError": "1MB"
"maximumWarning": "1MB",
"maximumError": "1.5MB"
},
{
"type": "anyComponentStyle",
Expand Down
44 changes: 44 additions & 0 deletions docs/decision-log.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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.
3 changes: 3 additions & 0 deletions packages/angular/ng-package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@
"@tanstack/angular-table",
"class-variance-authority",
"clsx",
"embla-carousel",
"embla-carousel-angular",
"ngx-scrollbar",
"tailwind-merge",
"tslib"
]
Expand Down
5 changes: 4 additions & 1 deletion packages/angular/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
16 changes: 16 additions & 0 deletions packages/angular/src/lib/ui/accordion/src/index.ts
Original file line number Diff line number Diff line change
@@ -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;
Original file line number Diff line number Diff line change
@@ -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: `
<div
class="pt-0 pb-4 [&_a]:hover:text-foreground [&_a]:underline [&_a]:underline-offset-3 [&_p:not(:last-child)]:mb-4"
>
<ng-content />
</div>
`,
})
export class HlmAccordionContent {
constructor() {
classes(
() =>
'text-sm transition-all data-[state=closed]:h-0 data-[state=open]:h-(--brn-accordion-content-height)',
);
}
}
Original file line number Diff line number Diff line change
@@ -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');
}
}
Original file line number Diff line number Diff line change
@@ -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: `
<h3 brnAccordionHeader class="flex">
<button brnAccordionTrigger data-slot="accordion-trigger" [class]="_computedTriggerClass()">
<ng-content />
<ng-icon
name="phosphorCaretDown"
data-slot="accordion-trigger-icon"
class="pointer-events-none shrink-0 group-aria-expanded/accordion-trigger:hidden"
/>
<ng-icon
name="phosphorCaretUp"
data-slot="accordion-trigger-icon"
class="pointer-events-none shrink-0 group-aria-expanded/accordion-trigger:inline group-aria-[expanded=false]/accordion-trigger:hidden"
/>
</button>
</h3>
`,
})
export class HlmAccordionTrigger {
public readonly triggerClass = input<ClassValue>('');

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(),
),
);
}
104 changes: 104 additions & 0 deletions packages/angular/src/lib/ui/accordion/src/lib/hlm-accordion.stories.ts
Original file line number Diff line number Diff line change
@@ -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<AccordionArgs> = {
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<AccordionArgs>;

/** The default accordion — tweak `type` via the controls. */
export const Default: Story = {
render: (args) => ({
props: args,
template: `
<div hlmAccordion ${argsToTemplate(args)} class="w-96">
<div hlmAccordionItem [isOpened]="true">
<hlm-accordion-trigger>Is it accessible?</hlm-accordion-trigger>
<hlm-accordion-content>Yes. It adheres to the WAI-ARIA accordion design pattern.</hlm-accordion-content>
</div>
<div hlmAccordionItem>
<hlm-accordion-trigger>Is it styled?</hlm-accordion-trigger>
<hlm-accordion-content>Yes. It comes with default styles that match the rest of the design system.</hlm-accordion-content>
</div>
<div hlmAccordionItem>
<hlm-accordion-trigger>Is it animated?</hlm-accordion-trigger>
<hlm-accordion-content>Yes. It's animated by default, but you can disable it if you prefer.</hlm-accordion-content>
</div>
</div>
`,
}),
};

/** With `type="multiple"`, several items can stay expanded at the same time. */
export const Multiple: Story = {
render: () => ({
template: `
<div hlmAccordion type="multiple" class="w-96">
<div hlmAccordionItem [isOpened]="true">
<hlm-accordion-trigger>Is it accessible?</hlm-accordion-trigger>
<hlm-accordion-content>Yes. It adheres to the WAI-ARIA accordion design pattern.</hlm-accordion-content>
</div>
<div hlmAccordionItem [isOpened]="true">
<hlm-accordion-trigger>Is it styled?</hlm-accordion-trigger>
<hlm-accordion-content>Yes. It comes with default styles that match the rest of the design system.</hlm-accordion-content>
</div>
<div hlmAccordionItem>
<hlm-accordion-trigger>Is it animated?</hlm-accordion-trigger>
<hlm-accordion-content>Yes. It's animated by default, but you can disable it if you prefer.</hlm-accordion-content>
</div>
</div>
`,
}),
};

/** A single item can be disabled independently of the rest of the accordion. */
export const DisabledItem: Story = {
render: () => ({
template: `
<div hlmAccordion class="w-96">
<div hlmAccordionItem [isOpened]="true">
<hlm-accordion-trigger>Is it accessible?</hlm-accordion-trigger>
<hlm-accordion-content>Yes. It adheres to the WAI-ARIA accordion design pattern.</hlm-accordion-content>
</div>
<div hlmAccordionItem [disabled]="true">
<hlm-accordion-trigger>Disabled item</hlm-accordion-trigger>
<hlm-accordion-content>This content is unreachable while the item is disabled.</hlm-accordion-content>
</div>
</div>
`,
}),
};
16 changes: 16 additions & 0 deletions packages/angular/src/lib/ui/accordion/src/lib/hlm-accordion.ts
Original file line number Diff line number Diff line change
@@ -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');
}
}
40 changes: 40 additions & 0 deletions packages/angular/src/lib/ui/alert-dialog/src/index.ts
Original file line number Diff line number Diff line change
@@ -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;
Loading
Loading