@bitforgehq/ui 0.3.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (144) hide show
  1. package/CHANGELOG.md +482 -0
  2. package/README.md +17 -11
  3. package/ai/MANIFEST.index.json +288 -0
  4. package/ai/MANIFEST.json +8716 -3926
  5. package/ai/REFERENCE.index.json +413 -0
  6. package/ai/REFERENCE.json +4710 -2047
  7. package/ai/UI_GUIDE.md +185 -57
  8. package/ai/components/back-button.md +42 -2
  9. package/ai/components/badge.md +17 -7
  10. package/ai/components/bar-chart.md +188 -0
  11. package/ai/components/breadcrumb-item.md +121 -0
  12. package/ai/components/breadcrumb.md +277 -0
  13. package/ai/components/button.md +82 -15
  14. package/ai/components/calendar.md +300 -0
  15. package/ai/components/card.md +63 -2
  16. package/ai/components/chart-card.md +283 -0
  17. package/ai/components/combobox.md +411 -0
  18. package/ai/components/date-input.md +396 -0
  19. package/ai/components/datetime-input.md +215 -0
  20. package/ai/components/description-item.md +111 -0
  21. package/ai/components/description-list.md +160 -0
  22. package/ai/components/donut-chart.md +169 -0
  23. package/ai/components/header.md +67 -2
  24. package/ai/components/icon-button.md +74 -16
  25. package/ai/components/icon.md +63 -7
  26. package/ai/components/line-chart.md +234 -0
  27. package/ai/components/list.md +70 -6
  28. package/ai/components/menu.md +5 -0
  29. package/ai/components/multi-combobox.md +257 -0
  30. package/ai/components/nav-group.md +178 -0
  31. package/ai/components/nav-item.md +210 -0
  32. package/ai/components/page-header.md +237 -0
  33. package/ai/components/pagination.md +173 -0
  34. package/ai/components/popover.md +250 -0
  35. package/ai/components/side-panel.md +391 -0
  36. package/ai/components/sidebar.md +363 -0
  37. package/ai/components/skeleton.md +215 -0
  38. package/ai/components/sparkline.md +188 -0
  39. package/ai/components/stat.md +233 -0
  40. package/ai/components/tab-group.md +226 -0
  41. package/ai/components/tab-panel.md +163 -0
  42. package/ai/components/table.md +568 -0
  43. package/ai/components/tabs.md +42 -2
  44. package/ai/components/theme-menu-button.md +182 -0
  45. package/ai/components/theme-switch.md +170 -0
  46. package/ai/components/time-input.md +240 -0
  47. package/ai/components/toolbar.md +289 -0
  48. package/ai/components/tooltip.md +230 -0
  49. package/ai/components/top-nav.md +159 -0
  50. package/ai/reference/bf-badge-appearance.md +36 -0
  51. package/ai/reference/bf-badge-variant.md +1 -1
  52. package/ai/reference/bf-bar-chart-orientation.md +27 -0
  53. package/ai/reference/bf-button-appearance.md +36 -0
  54. package/ai/reference/bf-button-variant.md +1 -1
  55. package/ai/reference/bf-calendar-disabled-date.md +23 -0
  56. package/ai/reference/bf-calendar-harness.md +51 -0
  57. package/ai/reference/bf-calendar-weekday.md +31 -0
  58. package/ai/reference/bf-chart-card-heading-level.md +26 -0
  59. package/ai/reference/bf-chart-curve.md +28 -0
  60. package/ai/reference/bf-chart-harness.md +51 -0
  61. package/ai/reference/bf-chart-legend-position.md +30 -0
  62. package/ai/reference/bf-chart-series.md +23 -0
  63. package/ai/reference/bf-chart-value-format.md +23 -0
  64. package/ai/reference/bf-color-scheme-service.md +30 -0
  65. package/ai/reference/bf-color-scheme.md +31 -0
  66. package/ai/reference/bf-combobox-filter.md +27 -0
  67. package/ai/reference/bf-combobox-harness.md +89 -0
  68. package/ai/reference/bf-combobox-option.md +33 -0
  69. package/ai/reference/bf-compare-with.md +2 -0
  70. package/ai/reference/bf-date-input-harness.md +67 -0
  71. package/ai/reference/bf-date-input-picker.md +27 -0
  72. package/ai/reference/bf-date-time-input-harness.md +76 -0
  73. package/ai/reference/bf-date-time-input-picker.md +27 -0
  74. package/ai/reference/bf-description-list-layout.md +26 -0
  75. package/ai/reference/bf-donut-slice.md +22 -0
  76. package/ai/reference/bf-labels.md +67 -20
  77. package/ai/reference/bf-menu-button-variant.md +1 -0
  78. package/ai/reference/bf-menu-placement.md +1 -0
  79. package/ai/reference/bf-overlay-options.md +28 -27
  80. package/ai/reference/bf-overlay-placement.md +34 -0
  81. package/ai/reference/bf-overlay-position.md +1 -1
  82. package/ai/reference/bf-overlay-service.md +5 -3
  83. package/ai/reference/bf-page-header-heading-level.md +26 -0
  84. package/ai/reference/bf-pagination-harness.md +61 -0
  85. package/ai/reference/bf-platform-service.md +17 -15
  86. package/ai/reference/bf-popover-harness.md +42 -0
  87. package/ai/reference/bf-popover-placement.md +28 -0
  88. package/ai/reference/bf-popover-trigger-directive.md +56 -0
  89. package/ai/reference/bf-side-panel-config.md +35 -0
  90. package/ai/reference/bf-side-panel-harness.md +34 -0
  91. package/ai/reference/bf-side-panel-ref.md +44 -0
  92. package/ai/reference/bf-side-panel-service.md +43 -0
  93. package/ai/reference/bf-side-panel-template-context.md +32 -0
  94. package/ai/reference/bf-side-panel-width.md +28 -0
  95. package/ai/reference/bf-size.md +12 -0
  96. package/ai/reference/bf-skeleton-shape.md +28 -0
  97. package/ai/reference/bf-stat-trend-intent.md +27 -0
  98. package/ai/reference/bf-stat-trend.md +27 -0
  99. package/ai/reference/bf-stat-variant.md +26 -0
  100. package/ai/reference/bf-tab-group-harness.md +44 -0
  101. package/ai/reference/bf-table-align.md +26 -0
  102. package/ai/reference/bf-table-cell-context.md +24 -0
  103. package/ai/reference/bf-table-cell-directive.md +22 -0
  104. package/ai/reference/bf-table-column.md +28 -0
  105. package/ai/reference/bf-table-harness.md +77 -0
  106. package/ai/reference/bf-table-row-key.md +21 -0
  107. package/ai/reference/bf-table-sort-direction.md +26 -0
  108. package/ai/reference/bf-table-sort.md +27 -0
  109. package/ai/reference/bf-theme.md +5 -4
  110. package/ai/reference/bf-time-input-harness.md +67 -0
  111. package/ai/reference/bf-time-input-hour-cycle.md +27 -0
  112. package/ai/reference/bf-time-input-picker.md +27 -0
  113. package/ai/reference/bf-tooltip-directive.md +43 -0
  114. package/ai/reference/bf-tooltip-harness.md +43 -0
  115. package/ai/reference/bf-tooltip-placement.md +28 -0
  116. package/ai/reference/bf_color_scheme_storage.md +20 -0
  117. package/ai/reference/bf_side_panel_data.md +17 -0
  118. package/ai/reference/bf_theme_storage.md +3 -1
  119. package/ai/reference/provide-bf-color-scheme.md +20 -0
  120. package/ai/reference/provide-bf-labels.md +12 -4
  121. package/ai/reference/slide-in-from-end.md +24 -0
  122. package/ai/reference/slide-out-to-end.md +24 -0
  123. package/fesm2022/bitforgehq-ui-charts.mjs +3045 -0
  124. package/fesm2022/bitforgehq-ui-charts.mjs.map +1 -0
  125. package/fesm2022/bitforgehq-ui-icons.mjs +107 -3
  126. package/fesm2022/bitforgehq-ui-icons.mjs.map +1 -1
  127. package/fesm2022/bitforgehq-ui-mobile.mjs +14 -59
  128. package/fesm2022/bitforgehq-ui-mobile.mjs.map +1 -1
  129. package/fesm2022/bitforgehq-ui-overlay.mjs +6436 -33
  130. package/fesm2022/bitforgehq-ui-overlay.mjs.map +1 -1
  131. package/fesm2022/bitforgehq-ui-testing.mjs +618 -1
  132. package/fesm2022/bitforgehq-ui-testing.mjs.map +1 -1
  133. package/fesm2022/bitforgehq-ui.mjs +5092 -153
  134. package/fesm2022/bitforgehq-ui.mjs.map +1 -1
  135. package/llms-full.txt +25177 -14398
  136. package/llms.txt +95 -1
  137. package/package.json +7 -3
  138. package/theme.css +101 -0
  139. package/types/bitforgehq-ui-charts.d.ts +967 -0
  140. package/types/bitforgehq-ui-icons.d.ts +13 -1
  141. package/types/bitforgehq-ui-mobile.d.ts +25 -18
  142. package/types/bitforgehq-ui-overlay.d.ts +1844 -7
  143. package/types/bitforgehq-ui-testing.d.ts +748 -1
  144. package/types/bitforgehq-ui.d.ts +2136 -44
@@ -95,12 +95,12 @@ export class ButtonOverviewExampleComponent {
95
95
 
96
96
  Also matches: `a[bfButton]`
97
97
 
98
- Pill-shaped action button with five variants, three sizes, a loading state and icon slots. Put
98
+ Pill-shaped action button with six variants, three sizes, a loading state and icon slots. Put
99
99
  the label in the default slot and icons in the `icon-start` / `icon-end` slots; never place a
100
100
  `bf-button` inside another interactive element. For navigation, put `bfButton` on your own `<a>`
101
101
  (with `href` or `routerLink`): it renders as a real link that looks like the button.
102
102
 
103
- Buttons trigger an action or move the user forward. The shape is a pill in every variant; the variant encodes emphasis, so a screen reads correctly at a glance: one `primary`, `tonal` beside it, `text` for low-emphasis actions inside cards and rows.
103
+ Buttons trigger an action or move the user forward. The shape is a pill in every variant; the variant encodes emphasis, so a screen reads correctly at a glance: one `primary`, `tonal` beside it, `text` for low-emphasis actions inside cards and rows, `ghost` for quiet neutral actions and navigation.
104
104
 
105
105
  ## When to use
106
106
 
@@ -140,13 +140,13 @@ export class ButtonBasicExampleComponent {
140
140
 
141
141
  ## Variants
142
142
 
143
- `primary` (filled sage), `tonal` (soft tint), `outline`, `text` and `danger`.
143
+ `primary` (filled), `tonal` (soft tint), `outline`, `text`, `danger` and `ghost`. `ghost` is the quiet neutral one: muted text with no fill at rest and a neutral surface on hover, with the `text` variant's padding. Use it for secondary toolbar actions and navigation rows, where a row of `text` buttons would read as a row of coloured links and `tonal` would tint every one. `variant` takes a `BfButtonAppearance` (the five `BfButtonVariant` styles plus `ghost`).
144
144
 
145
145
  ```ts
146
146
  import { Component } from '@angular/core';
147
147
  import { BfButtonComponent } from '@bitforgehq/ui';
148
148
 
149
- /** The five button variants. */
149
+ /** The six button variants. */
150
150
  @Component({
151
151
  selector: 'app-example-button-variants',
152
152
  imports: [BfButtonComponent],
@@ -157,6 +157,7 @@ import { BfButtonComponent } from '@bitforgehq/ui';
157
157
  <bf-button variant="outline">Outline</bf-button>
158
158
  <bf-button variant="text">Text</bf-button>
159
159
  <bf-button variant="danger">Delete</bf-button>
160
+ <bf-button variant="ghost">Ghost</bf-button>
160
161
  </div>
161
162
  `,
162
163
  })
@@ -300,9 +301,57 @@ export class ButtonLinkExampleComponent {
300
301
  }
301
302
  ```
302
303
 
304
+ ## Navigation
305
+
306
+ A row of `a[bfButton variant="ghost"]` is a top navigation or a sidebar. The link of the current page shows it: `ghost` and `text` links with `aria-current="page"` take the tonal fill (`outline` draws its border in primary), so the template needs no `[variant]` switch. With the router, add `routerLinkActive` and `ariaCurrentWhenActive="page"` to each link and the router sets `aria-current` for you. Put the links in a `<nav>` with an `aria-label`.
307
+
308
+ ```ts
309
+ import { Component, signal } from '@angular/core';
310
+ import { BfButtonComponent } from '@bitforgehq/ui';
311
+
312
+ interface NavLink {
313
+ readonly id: string;
314
+ readonly label: string;
315
+ }
316
+
317
+ /**
318
+ * A top navigation of ghost links.
319
+ *
320
+ * The current page carries `aria-current="page"` and shows it; with the router,
321
+ * `routerLinkActive` and `ariaCurrentWhenActive="page"` set it instead of the click handler.
322
+ */
323
+ @Component({
324
+ selector: 'app-example-button-nav',
325
+ imports: [BfButtonComponent],
326
+ template: `
327
+ <nav aria-label="Main" class="flex flex-wrap items-center gap-1">
328
+ @for (link of links; track link.id) {
329
+ <a
330
+ bfButton
331
+ variant="ghost"
332
+ [href]="'#' + link.id"
333
+ [attr.aria-current]="link.id === current() ? 'page' : null"
334
+ (click)="$event.preventDefault(); current.set(link.id)"
335
+ >{{ link.label }}</a
336
+ >
337
+ }
338
+ </nav>
339
+ `,
340
+ })
341
+ export class ButtonNavExampleComponent {
342
+ protected readonly links: readonly NavLink[] = [
343
+ { id: 'overview', label: 'Overview' },
344
+ { id: 'projects', label: 'Projects' },
345
+ { id: 'team', label: 'Team' },
346
+ { id: 'settings', label: 'Settings' },
347
+ ];
348
+ protected readonly current = signal('overview');
349
+ }
350
+ ```
351
+
303
352
  ## Desktop
304
353
 
305
- Hover darkens the fill (`primary`, `tonal`, `danger`), the border (`outline`) or the text (`text`), with the pointer cursor; a disabled button shows `not-allowed`. Hover styles apply only on devices that can hover, so a tap never leaves one stuck. The focus ring shows on keyboard focus only (`focus-visible`), never after a mouse click. Under a fine pointer the visible box is the hit area, so `sm` (32 px) buttons sit side by side in toolbars and table rows without overlapping hit areas. Desktop density is the default `md` step; `provideBfDefaults({ size: 'sm' })`, or `{ size: 'sm' }` in a component's `providers`, makes a dense toolbar, table or sidebar ([density guide](https://bitforge-ui.web.app/density)).
354
+ Hover darkens the fill (`primary`, `tonal`, `danger`), the border (`outline`) or the text (`text`), or lays a neutral surface under `ghost`, with the pointer cursor; a disabled button shows `not-allowed`. Hover styles apply only on devices that can hover, so a tap never leaves one stuck. The focus ring shows on keyboard focus only (`focus-visible`), never after a mouse click. Under a fine pointer the visible box is the hit area, so `sm` (32 px) buttons sit side by side in toolbars and table rows without overlapping hit areas. Desktop density is the default `md` step; `provideBfDefaults({ size: 'sm' })`, or `{ size: 'sm' }` in a component's `providers`, makes a dense toolbar, table or sidebar ([density guide](https://bitforge-ui.web.app/density)).
306
355
 
307
356
  ## Theming
308
357
 
@@ -313,10 +362,16 @@ Consumes tokens by variant, rest → hover:
313
362
  - `outline`: `surface` / `text` / `border-strong` (the control boundary, ≥ 3:1) → `text-muted` border; focus swaps the border to `primary`
314
363
  - `text`: `primary-text` (primary as text, ≥ 4.5:1 on `bg` and `surface`) → `primary-strong`
315
364
  - `danger`: `danger-soft` / `danger` → `danger` / `danger-soft`
365
+ - `ghost`: transparent / `text-muted` → `surface-muted` / `text`
366
+
367
+ Current page (`a[bfButton]` with `aria-current="page"`): `ghost` and `text` take `primary-soft` /
368
+ `primary-strong` (→ `primary-soft-hover` on hover, `primary-soft-pressed` while pressed), `outline` a `primary` border with
369
+ `primary-strong` text; under forced colours a `LinkText` outline marks it instead.
316
370
 
317
371
  Pressed (while the mouse button, a touch or the Space key holds it down): `primary` →
318
372
  `primary-pressed`; `tonal`, `outline` and `text` → `primary-soft-pressed` (the `text` label turns
319
- `primary-strong`); `danger` → `danger-pressed` with `danger-soft` text. `a[bfButton]` presses the
373
+ `primary-strong`); `danger` → `danger-pressed` with `danger-soft` text; `ghost` →
374
+ `surface-pressed` with `text`. `a[bfButton]` presses the
320
375
  same way. A disabled or loading button has no pressed state, and under forced colours the system
321
376
  colours win.
322
377
 
@@ -328,7 +383,7 @@ Icon and label sit 8 px apart. A label longer than its container truncates with
328
383
 
329
384
  `bf-button` renders a native `<button>`; its `type` reflects the `type` input. Native `disabled` is set only by `disabled`. While `loading`, the button stays focusable and in the tab order with `aria-disabled="true"` and `aria-busy="true"`, so focus does not fall to `<body>` when an action starts; a click or Enter/Space is cancelled (`preventDefault`) and emits nothing. The spinner inside is `decorative`, so it adds no second status announcement; announce the outcome in your own message. Being a real button, it gets keyboard activation (Enter/Space) and the button role for free — no ARIA role is added. The focus-visible ring uses the `ring` token.
330
385
 
331
- `a[bfButton]` is your own `<a>`, so it keeps the link role and link keyboard behaviour ([WAI-ARIA APG link pattern](https://www.w3.org/WAI/ARIA/apg/patterns/link/)): it is exposed as `link`, is in the tab order while it has an `href` (in Safari and iOS WKWebView, Tab reaches links only when the user turns on "Press Tab to highlight each item" (Option+Tab otherwise); VoiceOver and Full Keyboard Access are unaffected), activates with Enter only, and opens in a new tab with Ctrl/Cmd-click. Use it whenever activation navigates to a URL, and `bf-button` whenever it performs an action on the page ([APG button pattern](https://www.w3.org/WAI/ARIA/apg/patterns/button/)). No ARIA is added, and none of `disabled`, `loading` or `type` applies.
386
+ `a[bfButton]` is your own `<a>`, so it keeps the link role and link keyboard behaviour ([WAI-ARIA APG link pattern](https://www.w3.org/WAI/ARIA/apg/patterns/link/)): it is exposed as `link`, is in the tab order while it has an `href` (in Safari and iOS WKWebView, Tab reaches links only when the user turns on "Press Tab to highlight each item" (Option+Tab otherwise); VoiceOver and Full Keyboard Access are unaffected), activates with Enter only, and opens in a new tab with Ctrl/Cmd-click. Use it whenever activation navigates to a URL, and `bf-button` whenever it performs an action on the page ([APG button pattern](https://www.w3.org/WAI/ARIA/apg/patterns/button/)). No ARIA is added, and none of `disabled`, `loading` or `type` applies. The current page is the `aria-current="page"` you (or `routerLinkActive` with `ariaCurrentWhenActive`) set: screen readers announce it ("current page"), and the fill only mirrors it.
332
387
 
333
388
  ### Keyboard
334
389
 
@@ -340,14 +395,14 @@ Icon and label sit 8 px apart. A label longer than its container truncates with
340
395
 
341
396
  ## Inputs
342
397
 
343
- | Name | Type | Default | Description |
344
- | ----------- | ----------------------------------------------------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
345
- | `variant` | `BfButtonVariant` (`'primary' \| 'tonal' \| 'outline' \| 'text' \| 'danger'`) | `'primary'` | Visual style: primary (filled), tonal (soft tint), outline, text, danger. |
346
- | `size` | `BfSize \| undefined` (`'sm' \| 'md' \| 'lg' \| undefined`) | `undefined` | Height step: sm 32, md 40, lg 48 px; unset follows `provideBfDefaults()` (`controlSize`, `md` by default). |
347
- | `type` | `BfButtonType` (`'button' \| 'submit' \| 'reset'`) | `'button'` | Native button type. Use `submit` inside a form. Ignored on `a[bfButton]`. |
348
- | `disabled` | `boolean` | `false` | Disables the control and swallows clicks. Ignored on `a[bfButton]`: a link is never disabled; render a disabled `bf-button` instead. |
349
- | `loading` | `boolean` | `false` | Shows a spinner instead of the start icon and blocks activation. The button stays focusable (`aria-disabled` and `aria-busy`, never native `disabled`) and keeps its variant fill. Ignored on `a[bfButton]`. |
350
- | `fullWidth` | `boolean` | `false` | Stretches the button to its container's width. |
398
+ | Name | Type | Default | Description |
399
+ | ----------- | ------------------------------------------------------------------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
400
+ | `variant` | `BfButtonAppearance` (`'primary' \| 'tonal' \| 'outline' \| 'text' \| 'danger' \| 'ghost'`) | `'primary'` | Visual style: primary (filled), tonal (soft tint), outline, text, danger, or ghost (quiet neutral). On `a[bfButton]`, `ghost`, `text` and `outline` also show `aria-current="page"`. |
401
+ | `size` | `BfSize \| undefined` (`'sm' \| 'md' \| 'lg' \| undefined`) | `undefined` | Height step: sm 32, md 40, lg 48 px; unset follows `provideBfDefaults()` (`controlSize`, `md` by default). |
402
+ | `type` | `BfButtonType` (`'button' \| 'submit' \| 'reset'`) | `'button'` | Native button type. Use `submit` inside a form. Ignored on `a[bfButton]`. |
403
+ | `disabled` | `boolean` | `false` | Disables the control and swallows clicks. Ignored on `a[bfButton]`: a link is never disabled; render a disabled `bf-button` instead. |
404
+ | `loading` | `boolean` | `false` | Shows a spinner instead of the start icon and blocks activation. The button stays focusable (`aria-disabled` and `aria-busy`, never native `disabled`) and keeps its variant fill. Ignored on `a[bfButton]`. |
405
+ | `fullWidth` | `boolean` | `false` | Stretches the button to its container's width. |
351
406
 
352
407
  ## Outputs
353
408
 
@@ -368,10 +423,12 @@ Icon and label sit 8 px apart. A label longer than its container truncates with
368
423
  - Use `primary` once per screen for the main action; `tonal` for secondary actions next to it; `text` for low-emphasis actions inside cards and lists.
369
424
  - Bind `[loading]` to the pending state of the action so the user gets feedback and cannot double-submit.
370
425
  - Use `type="submit"` inside a `<form>` so Enter submits.
426
+ - Build a top nav or a sidebar from `a[bfButton variant="ghost"]` with `routerLinkActive` and `ariaCurrentWhenActive="page"`.
371
427
 
372
428
  ### Don't
373
429
 
374
430
  - Don't nest `bf-button` in an `<a>` or another button, and don't navigate from `(clicked)`; put `bfButton` on the `<a>` itself (`<a bfButton routerLink="…">`).
431
+ - Don't switch `[variant]` to mark the current page; set `aria-current="page"` and the link shows it.
375
432
  - Don't disable a link; render a disabled `bf-button` while the destination is unavailable.
376
433
  - Don't add `dark:` classes or colors on the element; the theme handles both.
377
434
  - Don't put a `bf-icon` in the default slot; use the `icon-start` / `icon-end` slots so spacing stays right.
@@ -379,6 +436,16 @@ Icon and label sit 8 px apart. A label longer than its container truncates with
379
436
 
380
437
  ## Migration notes
381
438
 
439
+ ### 0.3.1
440
+
441
+ New `ghost` variant: quiet and neutral (`text-muted` label, no fill at rest, `surface-muted` on
442
+ hover, `surface-pressed` on press), with the `text` variant's sizes, padding and disabled state,
443
+ for secondary toolbar actions and navigation rows. `a[bfButton]` with `aria-current="page"` shows
444
+ the current page: `ghost` and `text` links take the tonal fill, `outline` a primary border, so a
445
+ top nav built with `routerLinkActive` and `ariaCurrentWhenActive="page"` needs no `[variant]`
446
+ switch. `variant` is now typed `BfButtonAppearance` (the five `BfButtonVariant` styles plus
447
+ `ghost`); `BfButtonVariant` keeps its five members, so code that maps over it keeps compiling.
448
+
382
449
  ### 0.3.0
383
450
 
384
451
  **Behaviour:** `size` is a `BfSize` and, unset, follows `provideBfDefaults()`'s `controlSize`
@@ -0,0 +1,300 @@
1
+ # bf-calendar
2
+
3
+ Inline month calendar for picking one date, with month and year jumps
4
+
5
+ `BfCalendarComponent` from `@bitforgehq/ui` · Forms · since 0.3.1
6
+
7
+ ## Example
8
+
9
+ ```ts
10
+ import { Component, computed, signal } from '@angular/core';
11
+ import { BfCalendarComponent } from '@bitforgehq/ui';
12
+
13
+ /**
14
+ * The bf-calendar overview.
15
+ *
16
+ * Live demo of `bf-calendar`: a due date picked by click or keyboard (the value is a plain ISO
17
+ * date string), the header's month buttons and month and year jump, and a status line with the
18
+ * picked date and the month on show.
19
+ */
20
+ @Component({
21
+ selector: 'app-example-calendar-overview',
22
+ imports: [BfCalendarComponent],
23
+ template: `
24
+ <div class="flex flex-col gap-3">
25
+ <bf-calendar label="Due date" locale="en-GB" [(value)]="due" [(month)]="month" />
26
+ <p data-status aria-live="polite" class="text-caption text-text-muted">{{ status() }}</p>
27
+ </div>
28
+ `,
29
+ })
30
+ export class CalendarOverviewExampleComponent {
31
+ /** The picked date, 'YYYY-MM-DD'. */
32
+ readonly due = signal<string | null>('2026-10-14');
33
+ /** The month on show, 'YYYY-MM'; null opens on the due date's month. */
34
+ readonly month = signal<string | null>(null);
35
+
36
+ /** The picked date, and the month on show once the user has browsed to one. */
37
+ readonly status = computed(() => {
38
+ const month = this.month();
39
+ return `Due: ${this.due() ?? 'none'}${month ? ` · showing ${month}` : ''}`;
40
+ });
41
+ }
42
+ ```
43
+
44
+ ## Overview
45
+
46
+ Inline month calendar for picking one date, an ARIA grid with full keyboard support. The value
47
+ is a plain ISO date string ('YYYY-MM-DD', never a Date with a time zone) or `null`; month and
48
+ weekday names come from `Intl` in the calendar's `locale`; implements the Signal Forms value
49
+ contract.
50
+
51
+ A calendar shows one month as a grid of days for picking a single date. Its value is a plain ISO date string (`'2026-10-14'`), never a `Date` with a time of day, so no time zone can shift the day. Month and weekday names come from `Intl` in the calendar's locale, the header jumps to another month or year in two clicks, and it binds to Signal Forms like any other field.
52
+
53
+ ## When to use
54
+
55
+ Use `bf-calendar` for an inline calendar that sits on the page: a booking page, a dashboard filter, a side panel where the month is always in view. For a date in a form field use [bf-date-input](https://bitforge-ui.web.app/components/date-input) instead: a typed date field that opens this calendar in a popover. Use [bf-select](https://bitforge-ui.web.app/components/select) or [bf-segmented](https://bitforge-ui.web.app/components/segmented) instead when the choice is a few fixed dates (today, tomorrow, next week). The calendar works the same in both shells: in a Capacitor app make it `lg` (48 px cells) for touch.
56
+
57
+ ## Value and month
58
+
59
+ `value` is the picked date as `'YYYY-MM-DD'`, `null` while none is picked. `(changed)` emits the new date right after the user picks a different one, never for a value you set; `(picked)` emits on every pick, also of the date that already was the value, so a popover around the calendar knows when to close.
60
+
61
+ `month` (`'YYYY-MM'`) is the month on show, two-way bindable. Left `null`, the calendar opens on the value's month, else on today's (kept inside `min` and `max`); the previous/next buttons, the month and year views and the keyboard write it. Bind `[(month)]` to keep it, or set it to open on another month. Once the user has browsed away, a new `value` you set does not move the view; set `month` with it. `today` sets the date marked as today (for an app that works in another time zone than the device); unset it is the device's local date.
62
+
63
+ Only the days of the shown month are rendered; the cells of the neighbouring months stay empty. So every date appears once, the keyboard moves through one month at a time and crosses into the next one by moving past its end, and a screen reader never reads a date twice. The grid always takes six weeks of height, so the calendar never changes height between months.
64
+
65
+ ## Min, max and disabled dates
66
+
67
+ `min` and `max` (ISO dates) bound the dates that can be picked; earlier and later days show disabled, the previous/next buttons stop at the months that hold them, and the year view lists only their years. `disabledDate` rules out more dates: a function called with each ISO date, here every weekend. Disabled days stay focusable (`aria-disabled`, as the ARIA grid pattern asks), so the arrow keys step onto them and past them, but a click, <kbd>Enter</kbd> or <kbd>Space</kbd> does nothing.
68
+
69
+ ```ts
70
+ import { Component, signal } from '@angular/core';
71
+ import { BfCalendarComponent, type BfCalendarDisabledDate } from '@bitforgehq/ui';
72
+
73
+ /**
74
+ * Min, max and disabled weekends.
75
+ *
76
+ * A delivery date between 2 November and 18 December 2026 on working days only: `min` and `max`
77
+ * bound the range (the month buttons stop there) and `disabledDate` rules out Saturdays and
78
+ * Sundays.
79
+ */
80
+ @Component({
81
+ selector: 'app-example-calendar-min-max-disabled',
82
+ imports: [BfCalendarComponent],
83
+ template: `
84
+ <bf-calendar
85
+ label="Delivery date"
86
+ locale="en-GB"
87
+ min="2026-11-02"
88
+ max="2026-12-18"
89
+ [disabledDate]="weekends"
90
+ [(value)]="delivery"
91
+ />
92
+ `,
93
+ })
94
+ export class CalendarMinMaxDisabledExampleComponent {
95
+ readonly delivery = signal<string | null>('2026-11-04');
96
+
97
+ /** Saturdays and Sundays; an ISO date parses as UTC midnight, so getUTCDay is its weekday. */
98
+ readonly weekends: BfCalendarDisabledDate = (iso) => [0, 6].includes(new Date(iso).getUTCDay());
99
+ }
100
+ ```
101
+
102
+ ## Locale and first day of the week
103
+
104
+ `locale` (a BCP 47 tag) sets the month and weekday names, the full date each day is announced with, and the first column of the week. Empty, it follows the document's `lang`, else English. `firstDayOfWeek` (`0` Sunday to `6` Saturday) overrides the week the locale gives; without it the calendar asks `Intl.Locale`'s week info and falls back to Monday where the runtime has none. Here a German calendar, which starts on Monday. Put a `lang` on the element around it, so a screen reader reads the names in that language. The button names (previous/next month, choose month, choose year) are labels: translate them with `provideBfLabels()`.
105
+
106
+ ```ts
107
+ import { Component, signal } from '@angular/core';
108
+ import { BfCalendarComponent } from '@bitforgehq/ui';
109
+
110
+ /**
111
+ * A German calendar.
112
+ *
113
+ * `locale="de"` gives German month and weekday names and a week that starts on Monday; the `lang`
114
+ * around it makes a screen reader read them in German.
115
+ */
116
+ @Component({
117
+ selector: 'app-example-calendar-locale',
118
+ imports: [BfCalendarComponent],
119
+ template: `
120
+ <div lang="de">
121
+ <bf-calendar label="Liefertermin" locale="de" [(value)]="delivery" />
122
+ </div>
123
+ `,
124
+ })
125
+ export class CalendarLocaleExampleComponent {
126
+ readonly delivery = signal<string | null>('2026-10-14');
127
+ }
128
+ ```
129
+
130
+ ## Sizes
131
+
132
+ `size` sets the cell: `sm` 32 px, `md` 40 px (default), `lg` 48 px; the previous/next buttons and the title button take the same height. Unset, `size` follows `provideBfDefaults()`'s `controlSize`. Under a coarse pointer (touch) every day cell and header button is at least 44 px: the `sm` and `md` cells grow to 44 px (the calendar gets wider), `lg` stays 48 px. The calendar is seven cells wide and shrinks with a narrow column, the columns getting narrower and the rows keeping their height.
133
+
134
+ ```ts
135
+ import { Component } from '@angular/core';
136
+ import { BfCalendarComponent } from '@bitforgehq/ui';
137
+
138
+ /**
139
+ * Calendar sizes.
140
+ *
141
+ * The sm, md and lg steps (32, 40 and 48 px cells); under a coarse pointer the sm and md cells
142
+ * grow to 44 px.
143
+ */
144
+ @Component({
145
+ selector: 'app-example-calendar-sizes',
146
+ imports: [BfCalendarComponent],
147
+ template: `
148
+ <div class="flex flex-wrap items-start gap-6">
149
+ <bf-calendar size="sm" label="Small" locale="en-GB" value="2026-10-14" />
150
+ <bf-calendar label="Medium" locale="en-GB" value="2026-10-14" />
151
+ <bf-calendar size="lg" label="Large" locale="en-GB" value="2026-10-14" />
152
+ </div>
153
+ `,
154
+ })
155
+ export class CalendarSizesExampleComponent {}
156
+ ```
157
+
158
+ ## Month and year views
159
+
160
+ The title button (the month and year) opens the month view: the twelve months of the year, the shown one selected. Its title (the year) opens the year view, a scrolling list of years (those of `min` to `max`, else a hundred years either side). Picking a year opens its months; picking a month shows its days. <kbd>Escape</kbd> in either view returns to the days without picking.
161
+
162
+ ## With Signal Forms
163
+
164
+ Bind the field with `[formField]`. Required, touched, errors, disabled and readonly flow in; the first error shows under the calendar once focus has left it, and `errorMessage` shows at once. `readonly` shows the value and lets the user browse months but blocks picking; `disabled` blocks everything and takes the calendar out of the tab order. `name` is accepted for Signal Forms but not rendered (the calendar has no native form element). `focus()` focuses the day that holds the tab stop.
165
+
166
+ ## Naming the calendar
167
+
168
+ `label` is the calendar's accessible name (it is not shown), such as "Due date". Without it use `ariaLabel`, or `ariaLabelledby` pointing at a visible heading. The day grid itself is named by the month and year in the header. In specs, `BfCalendarHarness` from `@bitforgehq/ui/testing` finds a calendar by its `label` and picks a date by its ISO string.
169
+
170
+ ## Desktop
171
+
172
+ Hovering a pickable day tints its mark `surface-muted` (the selected day `primary-hover`) with the pointer cursor; disabled days show the not-allowed cursor and no tint, and a read-only calendar has neither. Hover styles apply only on devices that can hover. The focus ring shows on keyboard focus only, on the day's round mark. Under a fine pointer each cell is its own hit area at every size; desktop density is `md`, and `provideBfDefaults({ size: 'sm' })` makes a dense calendar for a sidebar or a toolbar popover ([density guide](https://bitforge-ui.web.app/density)).
173
+
174
+ ## Theming
175
+
176
+ - Day mark: `text`, hover `surface-muted`, pressed `surface-pressed`. Selected: `primary` with `on-primary`, hover `primary-hover`, pressed `primary-pressed`, bold (under `forced-colors` the system `Highlight` / `HighlightText` pair).
177
+ - Today: a 1.5 px inset `primary-strong` ring and bold text, so it never rests on colour alone (`CanvasText` under `forced-colors`).
178
+ - Disabled day: `text-disabled`; a disabled selected day keeps a `surface-sunken` pill with a `text-disabled` ring (`GrayText` under `forced-colors`).
179
+ - Weekday header: `text-muted`. Header buttons: `text-muted`, hover `surface-muted` with `text`; the title `text`, bold.
180
+ - Focus ring: `ring`. Error: a 1.5 px `danger-border` ring around the grid, the message below in `danger`.
181
+ - Shape: the marks, month and year pills and header buttons use `rounded-control` (`9999px` by default, so the day marks are round); the error ring `rounded-card`.
182
+
183
+ ## Accessibility
184
+
185
+ The calendar is a `role="group"` named by `label` (else `ariaLabel` or `ariaLabelledby`). The days are a `table` with `role="grid"`, named by the header's month and year (`aria-labelledby`); weekday columns are `th scope="col"` with the short name shown and the full name in `abbr`. Each day is a `gridcell` named by its full date in the locale ("Wednesday, 14 October 2026"); the selected day has `aria-selected="true"` (the others `"false"`), today `aria-current="date"`, and disabled days `aria-disabled="true"`: they stay focusable so the keyboard can move past them. One day holds the tab stop (roving `tabindex`): the last one moved to, else the selected day, else today, else the first day of the month inside `min`. A polite live region announces the month and year when they change. The grid carries `aria-readonly`, `aria-required`, `aria-disabled`, and once touched and invalid `aria-invalid` with `aria-errormessage`; the group's `aria-describedby` names the error. The previous/next buttons are named from the `calendarPreviousMonth` and `calendarNextMonth` labels and turn `aria-disabled` (keeping focus) past `min`/`max`; the title button reads its text followed by `calendarChooseMonth` or `calendarChooseYear`. The month and year views are grids too, with the shown month or year selected and the current one `aria-current="date"`; each month is named with its year. The chevrons and the caret are CSS borders and mirror under `dir="rtl"`, where <kbd>ArrowLeft</kbd> and <kbd>ArrowRight</kbd> swap as the days run right to left. Focus moves into the new grid when a view opens or closes.
186
+
187
+ ### Keyboard
188
+
189
+ Moving never picks; <kbd>Enter</kbd> or <kbd>Space</kbd> does. Moves stop at `min` and `max`. While `readonly` the keys still move, and picking a day does nothing.
190
+
191
+ | Key | Where | Action |
192
+ | -------------------------------------------------------- | -------------- | ---------------------------------------------------------------------------------------- |
193
+ | <kbd>Tab</kbd> / <kbd>Shift</kbd>+<kbd>Tab</kbd> | Calendar | Moves between the title button, the previous/next buttons and the grid's one tab stop |
194
+ | <kbd>ArrowRight</kbd> / <kbd>ArrowLeft</kbd> | Days | Next / previous day, into the neighbouring month at the ends (swapped under `dir="rtl"`) |
195
+ | <kbd>ArrowDown</kbd> / <kbd>ArrowUp</kbd> | Days | Same weekday next / previous week |
196
+ | <kbd>PageDown</kbd> / <kbd>PageUp</kbd> | Days | Same day next / previous month (the 31st becomes the month's last day) |
197
+ | <kbd>Shift</kbd>+<kbd>PageDown</kbd> / <kbd>PageUp</kbd> | Days | Same day next / previous year |
198
+ | <kbd>Home</kbd> / <kbd>End</kbd> | Days | First / last day of the week |
199
+ | <kbd>Enter</kbd> / <kbd>Space</kbd> | Days | Picks the focused day (not a disabled one) |
200
+ | <kbd>Enter</kbd> / <kbd>Space</kbd> | Header buttons | The title opens the month (then the year) view; previous/next show the month |
201
+ | <kbd>ArrowRight</kbd> / <kbd>ArrowLeft</kbd> | Months, years | Next / previous month or year (swapped under `dir="rtl"`) |
202
+ | <kbd>ArrowDown</kbd> / <kbd>ArrowUp</kbd> | Months, years | Next / previous row (three months, four years) |
203
+ | <kbd>Home</kbd> / <kbd>End</kbd> | Months, years | First / last of the row |
204
+ | <kbd>PageDown</kbd> / <kbd>PageUp</kbd> | Months, years | Same month next / previous year; twenty years on or back |
205
+ | <kbd>Enter</kbd> / <kbd>Space</kbd> | Months, years | A month shows its days; a year opens its months |
206
+ | <kbd>Escape</kbd> | Months, years | Back to the days without picking (stops there, so a popover around it stays open) |
207
+
208
+ ## Inputs
209
+
210
+ | Name | Type | Default | Description |
211
+ | ---------------- | --------------------------------------------------------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
212
+ | `min` | `string \| undefined` | `undefined` | The earliest date that can be picked (ISO); earlier dates show disabled. Set by `[formField]`; do not bind it yourself. |
213
+ | `max` | `string \| undefined` | `undefined` | The latest date that can be picked (ISO); later dates show disabled. Set by `[formField]`; do not bind it yourself. |
214
+ | `disabledDate` | `BfCalendarDisabledDate \| null` | `null` | Marks more dates as not pickable (weekends, booked days); called with each ISO date. |
215
+ | `firstDayOfWeek` | `BfCalendarWeekday \| undefined` (`0 \| 1 \| 2 \| 3 \| 4 \| 5 \| 6 \| undefined`) | `undefined` | The first column's weekday, 0 Sunday to 6 Saturday; unset follows the locale's week (else Monday). |
216
+ | `locale` | `string` | `''` | BCP 47 locale of the month and weekday names and of the week; empty follows the document's `lang`, else English. |
217
+ | `today` | `string \| undefined` | `undefined` | The date marked as today (ISO); unset is the device's local date. |
218
+ | `size` | `BfSize \| undefined` (`'sm' \| 'md' \| 'lg' \| undefined`) | `undefined` | Cell size: sm 32, md 40, lg 48 px (44 px under a coarse pointer); unset follows `provideBfDefaults()` (`controlSize`, `md` by default). |
219
+ | `disabled` | `boolean` | `false` | Disables the whole calendar: nothing is focusable or pickable. Set by `[formField]`; do not bind it yourself. |
220
+ | `readonly` | `boolean` | `false` | Shows the value and lets the user browse the months, but blocks picking. Set by `[formField]`; do not bind it yourself. |
221
+ | `required` | `boolean` | `false` | Marks the date required (`aria-required` on the day grid). Set by `[formField]`; do not bind it yourself. |
222
+ | `invalid` | `boolean` | `false` | Field validity from Signal Forms. Set by `[formField]`; do not bind it yourself. |
223
+ | `touched` | `boolean` | `false` | Whether the user has left the calendar; errors show only once touched. Set by `[formField]`; do not bind it yourself. |
224
+ | `errors` | `readonly ValidationError.WithOptionalFieldTree[]` | `[]` | Validation errors from Signal Forms; the first message is shown once touched. Set by `[formField]`; do not bind it yourself. |
225
+ | `errorMessage` | `string` | `''` | Explicit error text; takes precedence over Signal Forms errors. |
226
+ | `label` | `string` | `''` | Accessible name of the calendar (not rendered visibly), such as "Due date". |
227
+ | `ariaLabel` | `string` | `''` | Accessible name when `label` is not set. |
228
+ | `ariaLabelledby` | `string` | `''` | Id(s) of the element(s) that name the calendar (a visible heading) when `label` is not set. |
229
+ | `name` | `string` | `''` | Name of the bound field. Not rendered (the calendar has no native form element); kept so Signal Forms can bind it. Set by `[formField]`; do not bind it yourself. |
230
+ | `id` | `string` | `generated` | The calendar's id; generated when not set. |
231
+ | `value` | `string \| null` | `null` | The picked date as an ISO string ('YYYY-MM-DD'), `null` while none is picked; two-way bindable and bound by `[formField]`. Two-way: `[(value)]`. |
232
+ | `month` | `string \| null` | `null` | The month on show ('YYYY-MM'), two-way bindable; `null` shows the value's month, else today's. The previous/next buttons, the month and year views and the keyboard set it. Two-way: `[(month)]`. |
233
+
234
+ ## Outputs
235
+
236
+ | Name | Payload | Description |
237
+ | --------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
238
+ | `changed` | `string` | Emitted with the new date right after the user picks a different one. |
239
+ | `picked` | `string` | Emitted with the date the user picked (click, Enter or Space), also when it already was the value, so a popover around the calendar can close. |
240
+ | `touch` | `void` | Emitted when focus leaves the calendar, so Signal Forms marks the field touched. Set by `[formField]`; do not bind it yourself. |
241
+
242
+ ## Methods
243
+
244
+ ### `isDateDisabled(iso: string): boolean`
245
+
246
+ Whether a date cannot be picked: it is outside `min`…`max` or `disabledDate` rules it out.
247
+
248
+ | Parameter | Type | Description |
249
+ | --------- | -------- | ---------------------- |
250
+ | `iso` | `string` | The ISO date to check. |
251
+
252
+ ### `focus(options?: FocusOptions): void`
253
+
254
+ Focuses the cell that owns the tab stop of the grid on show; Signal Forms calls it from
255
+ `focusBoundControl()`.
256
+
257
+ | Parameter | Type | Description |
258
+ | ---------- | -------------- | --------------------------------------------------------------------- |
259
+ | `options?` | `FocusOptions` | Passed on to the native `focus()`, such as `{ preventScroll: true }`. |
260
+
261
+ ### `select(iso: string): void`
262
+
263
+ Picks `iso` as a click on its day would: ignored while disabled or read-only, and for an
264
+ invalid or disabled date. Shows its month.
265
+
266
+ | Parameter | Type | Description |
267
+ | --------- | -------- | --------------------- |
268
+ | `iso` | `string` | The ISO date to pick. |
269
+
270
+ ## Notes
271
+
272
+ ### Do
273
+
274
+ - Give the calendar a `label` (or `ariaLabel` / `ariaLabelledby`); it is the accessible name.
275
+ - Store and send the value as the ISO string it is; turn it into a `Date` only to display or compute, at UTC midnight.
276
+ - Bound the range with `min` and `max` rather than `disabledDate` alone, so the buttons and the year view stop there too.
277
+
278
+ ### Don't
279
+
280
+ - Don't use it for a date range or several dates; it picks one date.
281
+ - Don't put a calendar in a dense form row for an ordinary date field; a date input with a popover is the field form.
282
+
283
+ ## Migration notes
284
+
285
+ ### 0.3.1
286
+
287
+ New `bf-calendar` in `@bitforgehq/ui`: an inline month calendar for picking one date. The value
288
+ is a plain ISO date string (`'YYYY-MM-DD'`) or `null`, never a `Date`, so no time zone shifts
289
+ the day; `(changed)` emits a new pick and `(picked)` every pick (for a popover to close).
290
+ `[(month)]` (`'YYYY-MM'`) is the month on show. `min`, `max` and `disabledDate`
291
+ (`BfCalendarDisabledDate`) disable dates; `firstDayOfWeek` (`BfCalendarWeekday`) and `locale`
292
+ (default the document's `lang`) set the week and the `Intl` month and weekday names; `today`
293
+ overrides the device's date; `size` sets the cells (32/40/48 px, 44 px under a coarse pointer).
294
+ The header's title opens a month view and then a year view for quick jumps. An ARIA grid with
295
+ roving focus: arrows by day and week (mirrored in RTL), Page Up/Down by month (Shift by year),
296
+ Home/End to the week's ends, Enter/Space picks, Escape leaves the jump views; disabled days stay
297
+ focusable with `aria-disabled`. Signal Forms value contract like `bf-segmented`. New label keys
298
+ `calendarPreviousMonth`, `calendarNextMonth`, `calendarChooseMonth`, `calendarChooseYear`, and
299
+ a `BfCalendarHarness` in `@bitforgehq/ui/testing` (`getMonthLabel()`, `selectDate(iso)`,
300
+ `getSelected()`, `nextMonth()`, `previousMonth()`).
@@ -148,6 +148,60 @@ import { BfCardComponent } from '@bitforgehq/ui';
148
148
  export class CardVariantsExampleComponent {}
149
149
  ```
150
150
 
151
+ ## Footer
152
+
153
+ Content in `bfSlot="footer"` sits on the card's bottom edge: it takes the free space above it, so in a grid of cards (a `bf-card` fills its grid cell, `h-full`) every footer lines up, however differently the descriptions wrap, and the body keeps its 12 px gap above the footer. Put the footer's parts in one element (`<div bfSlot="footer" class="flex …">`), so they move together.
154
+
155
+ ```ts
156
+ import { Component } from '@angular/core';
157
+ import { BfBadgeComponent, BfButtonComponent, BfCardComponent } from '@bitforgehq/ui';
158
+
159
+ interface Project {
160
+ readonly id: string;
161
+ readonly name: string;
162
+ readonly summary: string;
163
+ readonly status: string;
164
+ }
165
+
166
+ /** A grid of cards whose descriptions wrap differently, with every footer on the bottom edge. */
167
+ @Component({
168
+ selector: 'app-example-card-grid',
169
+ imports: [BfBadgeComponent, BfButtonComponent, BfCardComponent],
170
+ template: `
171
+ <div class="grid gap-4 @md:grid-cols-3">
172
+ @for (project of projects; track project.id) {
173
+ <bf-card>
174
+ <h3 class="text-heading">{{ project.name }}</h3>
175
+ <p class="text-body text-text-muted">{{ project.summary }}</p>
176
+ <div bfSlot="footer" class="flex items-center justify-between gap-2">
177
+ <bf-badge>{{ project.status }}</bf-badge>
178
+ <bf-button variant="outline" size="sm">Open</bf-button>
179
+ </div>
180
+ </bf-card>
181
+ }
182
+ </div>
183
+ `,
184
+ })
185
+ export class CardGridExampleComponent {
186
+ readonly projects: readonly Project[] = [
187
+ { id: 'atlas', name: 'Atlas rebrand', summary: 'New logo and colours.', status: 'Active' },
188
+ {
189
+ id: 'harbor',
190
+ name: 'Harbor onboarding',
191
+ summary:
192
+ 'Rewrite the first-run flow so a new team reaches its first shared board in under five minutes, with sample data.',
193
+ status: 'Planning',
194
+ },
195
+ {
196
+ id: 'lumen',
197
+ name: 'Lumen reports',
198
+ summary: 'Weekly PDF exports for the finance team, scheduled per workspace.',
199
+ status: 'Review',
200
+ },
201
+ ];
202
+ }
203
+ ```
204
+
151
205
  ## Clickable card
152
206
 
153
207
  A card that opens something is a stretched link: its heading holds the one interactive element, an `<a bfAction>` (navigation, with `href` or `routerLink`) or a `<button bfAction>` (an action on the page), and `bfAction` stretches that element's hit area over the whole card with an `::after`. The heading stays a heading, the link's name is the title alone, and the card shows the pointer, the `shadow-floating` hover and the focus ring (drawn on the card while the link has keyboard focus). Import `BfActionDirective` from `@bitforgehq/ui`.
@@ -252,14 +306,15 @@ The card renders a plain `<div>` with no role. A clickable card follows the stre
252
306
 
253
307
  - `header`: Content above the body, such as a title row.
254
308
  - `default`: The card body.
255
- - `footer`: Content below the body, such as its actions.
309
+ - `footer`: Content below the body, such as its actions; it sits on the card's bottom edge.
256
310
 
257
311
  ## Notes
258
312
 
259
313
  ### Do
260
314
 
261
- - One idea per card; use `bf-list` for repeated rows.
315
+ - One idea per card; use `bf-list` for repeated rows (`[inset]="false" divided class="-mx-card"` runs them edge to edge on the card).
262
316
  - Use `lg` padding for cards that hold a heading and several blocks.
317
+ - Put a card's actions and status in one `bfSlot="footer"` element, so they line up across a grid.
263
318
  - Make a card clickable with one `bfAction` link or button in its heading; give other controls in it `class="relative z-1"`.
264
319
 
265
320
  ### Don't
@@ -271,6 +326,12 @@ The card renders a plain `<div>` with no role. A clickable card follows the stre
271
326
 
272
327
  ## Migration notes
273
328
 
329
+ ### 0.3.1
330
+
331
+ The footer (`bfSlot="footer"`) sits on the card's bottom edge (`mt-auto`), so in a grid of cards
332
+ whose descriptions wrap differently every footer lines up. A card whose footer already followed
333
+ the body directly looks the same unless the card is taller than its content.
334
+
274
335
  ### 0.3.0
275
336
 
276
337
  **Behaviour:** logical properties: content aligns to the inline start (`text-start`).