@bitforgehq/ui 0.3.1 → 0.3.2

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 (45) hide show
  1. package/CHANGELOG.md +116 -0
  2. package/README.md +9 -8
  3. package/ai/MANIFEST.index.json +18 -0
  4. package/ai/MANIFEST.json +368 -31
  5. package/ai/REFERENCE.index.json +63 -0
  6. package/ai/REFERENCE.json +247 -5
  7. package/ai/UI_GUIDE.md +59 -33
  8. package/ai/components/app-frame.md +328 -0
  9. package/ai/components/card.md +56 -2
  10. package/ai/components/empty-state.md +99 -6
  11. package/ai/components/header.md +5 -1
  12. package/ai/components/sidebar-toggle.md +287 -0
  13. package/ai/components/sidebar.md +188 -10
  14. package/ai/components/stat.md +74 -10
  15. package/ai/components/table.md +134 -5
  16. package/ai/components/tabs.md +5 -1
  17. package/ai/components/toolbar.md +69 -4
  18. package/ai/reference/bf-app-frame-breakpoint.md +27 -0
  19. package/ai/reference/bf-app-frame-content-width.md +29 -0
  20. package/ai/reference/bf-empty-state-heading-level.md +1 -1
  21. package/ai/reference/bf-empty-state-heading.md +33 -0
  22. package/ai/reference/bf-labels.md +6 -0
  23. package/ai/reference/bf-sidebar-state-service.md +21 -0
  24. package/ai/reference/bf-sidebar-toggle-harness.md +32 -0
  25. package/ai/reference/bf-size.md +1 -0
  26. package/ai/reference/bf-stat-appearance.md +33 -0
  27. package/ai/reference/bf-stat-variant.md +1 -1
  28. package/ai/reference/bf-table-harness.md +1 -1
  29. package/ai/reference/bf-table-stack.md +29 -0
  30. package/ai/reference/bf_sidebar_state_storage.md +20 -0
  31. package/ai/reference/provide-bf-labels.md +4 -3
  32. package/ai/reference/provide-bf-sidebar-state.md +20 -0
  33. package/bin/bf-ui.js +433 -3
  34. package/fesm2022/bitforgehq-ui-overlay.mjs +571 -446
  35. package/fesm2022/bitforgehq-ui-overlay.mjs.map +1 -1
  36. package/fesm2022/bitforgehq-ui-testing.mjs +18 -2
  37. package/fesm2022/bitforgehq-ui-testing.mjs.map +1 -1
  38. package/fesm2022/bitforgehq-ui.mjs +759 -127
  39. package/fesm2022/bitforgehq-ui.mjs.map +1 -1
  40. package/llms-full.txt +1560 -43
  41. package/llms.txt +11 -0
  42. package/package.json +1 -1
  43. package/types/bitforgehq-ui-overlay.d.ts +51 -0
  44. package/types/bitforgehq-ui-testing.d.ts +35 -1
  45. package/types/bitforgehq-ui.d.ts +276 -20
package/llms-full.txt CHANGED
@@ -117,7 +117,12 @@ the widgets style their native controls themselves.
117
117
  Optional per-app overrides go after the import. A brand colour is a set: override
118
118
  `--color-primary` together with `--color-primary-text` (primary as text, ≥ 4.5:1 on `bg` and
119
119
  `surface`) and the rest of the `--color-primary-*` tokens, in `@theme` and in `.dark`; the docs
120
- site's theme configurator derives the whole contrast-checked set from one colour.
120
+ site's theme configurator derives the whole contrast-checked set from one colour. From a
121
+ terminal, `bunx bf-ui theme '<hex>' [--dark '<hex>']` prints the set (`primary`, its hover,
122
+ pressed, soft and strong steps, `on-primary`, `primary-text`, `selected`, `ring`) as a `@theme`
123
+ and a `.dark` block with each contrast pair's ratio, and exits 1 naming the pairs below their
124
+ minimum and a shade that passes; paste its output, never hand-pick the steps. Example (the
125
+ Meadow Sage green): `bunx bf-ui theme '#238652' --dark '#5cc48a'`.
121
126
  `--radius-control` (`9999px`, pills) reshapes buttons, chips, badges, segmented, the tab bar, the
122
127
  action bar and toggles at once:
123
128
 
@@ -138,16 +143,17 @@ Render library components only in the browser, for example in a `@defer` block o
138
143
  route. Support will be decided before 1.0.
139
144
 
140
145
  The one server-side check is a render smoke, not a support test: the library repository's
141
- `tools/ssr-smoke.ts` renders 42 fixtures with `renderApplication()` (every root widget, `bf-icon`,
146
+ `tools/ssr-smoke.ts` renders 43 fixtures with `renderApplication()` (every root widget, `bf-icon`,
142
147
  `bf-number-input`, a `/charts` line chart, a `/mobile` page with `provideBfShell()`, `bf-page`,
143
148
  `bf-header` and `bf-content`, `bf-tabs` with `bf-stack-outlet` under the router, and an `/overlay`
144
- dialog), and all of them render without an error. 27 of them render identically twice. The other 15
149
+ dialog), and all of them render without an error. 27 of them render identically twice. The other 16
145
150
  number their ids differently on every render, from a module-level counter, so they could not
146
- hydrate: `bf-calendar`, `bf-checkbox`, `bf-input`, `bf-radio-group`, `bf-segmented`, `bf-select`,
147
- `bf-sidebar`, `bf-tab-group`, `bf-table`, `bf-textarea`, `bf-theme-switch`, `bf-toggle`,
148
- `bf-line-chart`, `bf-number-input` and the `/overlay` dialog. Hydration itself is still not
149
- supported or tested, and more stands in its way: `/mobile` writes to `<html>` and adds a keyboard
150
- probe to `<body>`, and an `/overlay` dialog renders outside the app root and marks it inert.
151
+ hydrate: `bf-app-frame`, `bf-calendar`, `bf-checkbox`, `bf-input`, `bf-radio-group`, `bf-segmented`,
152
+ `bf-select`, `bf-sidebar`, `bf-tab-group`, `bf-table`, `bf-textarea`, `bf-theme-switch`,
153
+ `bf-toggle`, `bf-line-chart`, `bf-number-input` and the `/overlay` dialog. Hydration itself is still
154
+ not supported or tested, and more stands in its way: `/mobile` writes to `<html>` and adds a
155
+ keyboard probe to `<body>`, and an `/overlay` dialog renders outside the app root and marks it
156
+ inert.
151
157
 
152
158
  ## Keeping this guide in sync
153
159
 
@@ -335,8 +341,19 @@ protected readonly colorScheme = inject(BfColorSchemeService);
335
341
  `bf-tabs`, `bf-back-button` and `bf-header` are Capacitor shell parts. A desktop web app (no
336
342
  `provideBfShell()`) uses their desktop counterparts from `@bitforgehq/ui`:
337
343
 
338
- - **Navigation:** `bf-sidebar` (groups in `bf-nav-group`, `[(collapsed)]` for an icon rail; the
339
- app owns the toggle button) or `bf-top-nav` for a few sections, holding
344
+ - **App frame:** wrap the whole app in `<bf-app-frame>` (never build the frame by hand): the
345
+ `bf-sidebar` in `bfSlot="sidebar"` (sticky, full height, from `md` up), and below the
346
+ breakpoint a header bar with `bfSlot="brand"`, `bfSlot="menu"` (a `bf-menu-button` of
347
+ `a[bfMenuItem]` links from the same destinations array) and `bfSlot="actions"` (the theme
348
+ menu); the router outlet is the content. It brings the skip link and the focusable main
349
+ landmark; `breakpoint` and `contentWidth` adjust it. Desktop only: never inside
350
+ `provideBfShell()`.
351
+ - **Sidebar collapse:** `<bf-sidebar-toggle>` (`/overlay`, icons in `bfSlot="collapse-icon"` and
352
+ `bfSlot="expand-icon"`) in the sidebar footer beside the icon-only theme menu; with
353
+ `provideBfSidebarState()` bind `[(collapsed)]="sidebarState.collapsed"`
354
+ (`BfSidebarStateService`) so the choice is remembered.
355
+ - **Navigation:** `bf-sidebar` (groups in `bf-nav-group`, `[(collapsed)]` for an icon rail) or
356
+ `bf-top-nav` for a few sections, holding
340
357
  `<a bfNavItem routerLink="…" routerLinkActive ariaCurrentWhenActive="page">` links with an icon
341
358
  in `bfSlot="icon"` and an optional `[badge]` count. The router sets `aria-current="page"` and
342
359
  the item shows it. Drive the sidebar, a below-`md` `bf-menu-button` of `a[bfMenuItem]` links and,
@@ -354,8 +371,9 @@ protected readonly colorScheme = inject(BfColorSchemeService);
354
371
  controls default to `sm`, and `status` is a polite live region for the result count.
355
372
  - **Table:** `<bf-table [rows] [columns] [(sort)] selectable [(selection)]>` with
356
373
  `ng-template[bfTableCell]` for rich cells and `bfSlot="empty"` for the empty state; the app
357
- sorts the rows in a `computed()`, and a selection shows a `bf-action-bar`. Below `md` or on a
358
- coarse pointer render the same rows as `bf-list-item`s.
374
+ sorts the rows in a `computed()`, and a selection shows a `bf-action-bar`. `stack="narrow"`
375
+ (below 36rem of its own width) or `stack="coarse"` turns each row into a label/value tile with
376
+ a sort and select bar; in the Capacitor shell use `bf-list-item` rows instead.
359
377
  - **Details:** `bf-description-list` of `div[bfDescriptionItem]` label/value pairs, and `bf-stat`
360
378
  figure tiles in a grid; both work in both shells.
361
379
  - **Cards in a grid:** the grid stretches each `bf-card` and its `bfSlot="footer"` sits on the
@@ -384,6 +402,12 @@ protected readonly colorScheme = inject(BfColorSchemeService);
384
402
  `bf-sparkline` inside a `bf-chart-card` (title, period slot, loading, empty and error states),
385
403
  themed by the `chart-1`…`chart-8` tokens. Every chart takes a `label` and offers its data as a
386
404
  table.
405
+ - **Surfaces:** one surface style per level. A raised `bf-card` holds sunken tiles
406
+ (`bf-stat variant="tile"`), never an outlined surface (`variant="card"` or a `flat` card) inside
407
+ a raised one; next to raised cards on a dashboard, use raised cards.
408
+ - **Empty pages:** a 404, "no access" or "nothing here yet" page is a `bf-empty-state` with
409
+ `headingLevel` 1, centred in the content area, with the main action and an optional link.
410
+ - **Toolbar on a phone:** `bf-toolbar` keeps its status and actions together on the last row.
387
411
 
388
412
  ## Overlays (`@bitforgehq/ui/overlay`)
389
413
 
@@ -1230,6 +1254,7 @@ notes.
1230
1254
 
1231
1255
  | Component | Entry point | Purpose |
1232
1256
  | --- | --- | --- |
1257
+ | `bf-app-frame` | `@bitforgehq/ui` | Desktop app frame with skip link, sticky sidebar, narrow header and main content |
1233
1258
  | `bf-back-button` | `@bitforgehq/ui/mobile` | Header back button that pops the page stack or falls back to a default route |
1234
1259
  | `bf-breadcrumb` | `@bitforgehq/ui` | Desktop breadcrumb trail of links to the current page, with optional collapsing |
1235
1260
  | `li[bfBreadcrumbItem]` | `@bitforgehq/ui` | One level of a breadcrumb trail: a link, or the current page as text |
@@ -1242,6 +1267,7 @@ notes.
1242
1267
  | `bf-pagination` | `@bitforgehq/ui` | Previous, next and numbered page buttons for a long list or table |
1243
1268
  | `bf-refresher` | `@bitforgehq/ui/mobile` | Pull-to-refresh indicator with haptic feedback, native iOS bounce and an Android-style pull |
1244
1269
  | `bf-sidebar` | `@bitforgehq/ui` | Vertical app navigation with brand, grouped links, footer and an icon rail mode |
1270
+ | `bf-sidebar-toggle` | `@bitforgehq/ui/overlay` | Icon button inside a sidebar that collapses it to an icon rail and back |
1245
1271
  | `bf-stack-outlet` | `@bitforgehq/ui/mobile` | Router outlet keeping a live page stack with slide transitions and iOS swipe back |
1246
1272
  | `bf-tab` | `@bitforgehq/ui` | One tab bar destination with an icon, a label and an optional count badge |
1247
1273
  | `bf-tab-bar` | `@bitforgehq/ui` | Floating pill tab bar for mobile apps with arrow-key selection and count badges |
@@ -2920,6 +2946,337 @@ focusable content. `provideBfShell()` now also provides `BfAlertDialogService`.
2920
2946
 
2921
2947
  ---
2922
2948
 
2949
+ # bf-app-frame
2950
+
2951
+ Desktop app frame with skip link, sticky sidebar, narrow header and main content
2952
+
2953
+ `BfAppFrameComponent` from `@bitforgehq/ui` · Navigation · since 0.3.2
2954
+
2955
+ ## Example
2956
+
2957
+ ```ts
2958
+ import { Component, signal } from '@angular/core';
2959
+ import { RouterLink, RouterLinkActive } from '@angular/router';
2960
+ import {
2961
+ BfAppFrameComponent,
2962
+ BfIconButtonComponent,
2963
+ BfNavItemComponent,
2964
+ BfSidebarComponent,
2965
+ } from '@bitforgehq/ui';
2966
+ import { BfIconComponent } from '@bitforgehq/ui/icons';
2967
+ import {
2968
+ BfMenuButtonComponent,
2969
+ BfMenuComponent,
2970
+ BfMenuItemDirective,
2971
+ } from '@bitforgehq/ui/overlay';
2972
+ import { Bell, type IconNode, Layers, Menu, PanelLeft, PanelTop } from 'lucide';
2973
+
2974
+ /** One top-level destination of the app, shared by the sidebar and the narrow-screen menu. */
2975
+ interface Destination {
2976
+ readonly path: string;
2977
+ readonly label: string;
2978
+ readonly icon: IconNode;
2979
+ }
2980
+
2981
+ /**
2982
+ * The bf-app-frame overview.
2983
+ *
2984
+ * Live demo of `bf-app-frame` in a box of fixed height: one destinations array drives the
2985
+ * `bf-sidebar` (from `md` up) and the narrow header's `bf-menu-button`; the header also holds the
2986
+ * brand and a notifications action. Tab into the frame to reveal its skip link; Enter moves focus
2987
+ * to the content. The result line reports it.
2988
+ */
2989
+ @Component({
2990
+ selector: 'app-example-app-frame-overview',
2991
+ imports: [
2992
+ RouterLink,
2993
+ RouterLinkActive,
2994
+ BfAppFrameComponent,
2995
+ BfIconButtonComponent,
2996
+ BfIconComponent,
2997
+ BfMenuButtonComponent,
2998
+ BfMenuComponent,
2999
+ BfMenuItemDirective,
3000
+ BfNavItemComponent,
3001
+ BfSidebarComponent,
3002
+ ],
3003
+ template: `
3004
+ <!-- In an app the frame is the root of the page; here a fixed height stands in for the
3005
+ viewport, so the sidebar and the header bar stick inside the box. -->
3006
+ <div (focusin)="onFocus($event)">
3007
+ <bf-app-frame
3008
+ mainId="app-frame-demo-content"
3009
+ class="border-border rounded-card h-96 overflow-auto border"
3010
+ >
3011
+ <bf-sidebar bfSlot="sidebar" label="App frame demo">
3012
+ <span bfSlot="header" class="text-heading ps-3">Atlas</span>
3013
+ @for (destination of destinations; track destination.path) {
3014
+ <a
3015
+ bfNavItem
3016
+ [routerLink]="destination.path"
3017
+ routerLinkActive
3018
+ ariaCurrentWhenActive="page"
3019
+ >
3020
+ <bf-icon bfSlot="icon" [icon]="destination.icon" />
3021
+ {{ destination.label }}
3022
+ </a>
3023
+ }
3024
+ </bf-sidebar>
3025
+
3026
+ <span bfSlot="brand" class="text-heading">Atlas</span>
3027
+ <bf-menu-button bfSlot="menu" variant="text" [menu]="navMenu">
3028
+ <bf-icon bfSlot="icon-start" [icon]="menu" />
3029
+ Sections
3030
+ </bf-menu-button>
3031
+ <bf-icon-button
3032
+ bfSlot="actions"
3033
+ variant="text"
3034
+ label="Notifications"
3035
+ (clicked)="status.set('Notifications opened.')"
3036
+ >
3037
+ <bf-icon [icon]="bell" />
3038
+ </bf-icon-button>
3039
+
3040
+ <div class="flex flex-col gap-3">
3041
+ <h2 class="text-title">Inbox</h2>
3042
+ <p class="text-body text-text-muted max-w-prose">
3043
+ The page sits in the content column, beside the sidebar from the md breakpoint up and
3044
+ under the header bar below it.
3045
+ </p>
3046
+ <p data-status aria-live="polite" class="text-caption text-text-muted">{{ status() }}</p>
3047
+ @for (row of rows; track row) {
3048
+ <div class="bg-surface rounded-tile text-body p-4">{{ row }}</div>
3049
+ }
3050
+ </div>
3051
+ </bf-app-frame>
3052
+ </div>
3053
+
3054
+ <ng-template #navMenu>
3055
+ <bf-menu>
3056
+ @for (destination of destinations; track destination.path) {
3057
+ <a
3058
+ bfMenuItem
3059
+ [routerLink]="destination.path"
3060
+ routerLinkActive
3061
+ ariaCurrentWhenActive="page"
3062
+ >{{ destination.label }}</a
3063
+ >
3064
+ }
3065
+ </bf-menu>
3066
+ </ng-template>
3067
+ `,
3068
+ })
3069
+ export class AppFrameOverviewExampleComponent {
3070
+ /** In an app, derive this from the route config, so a new route appears on every surface. */
3071
+ protected readonly destinations: readonly Destination[] = [
3072
+ { path: '/components/app-frame', label: 'App frame', icon: PanelLeft },
3073
+ { path: '/components/sidebar', label: 'Sidebar', icon: PanelTop },
3074
+ { path: '/components/menu-button', label: 'Menu button', icon: Layers },
3075
+ ];
3076
+ protected readonly rows = [
3077
+ 'Review the brand brief',
3078
+ 'Plan the launch',
3079
+ 'Write the release notes',
3080
+ ];
3081
+ protected readonly bell = Bell;
3082
+ protected readonly menu = Menu;
3083
+ readonly status = signal('Tab into the frame to reveal the skip link.');
3084
+
3085
+ /** Reports when focus lands on the content, as the skip link moves it there. */
3086
+ protected onFocus(event: FocusEvent): void {
3087
+ if (event.target instanceof HTMLElement && event.target.id === 'app-frame-demo-content') {
3088
+ this.status.set('Skipped to the content.');
3089
+ }
3090
+ }
3091
+ }
3092
+ ```
3093
+
3094
+ ## Overview
3095
+
3096
+ The frame of a desktop web app: a skip link, a sticky full-height sidebar column from the
3097
+ breakpoint up, a header bar with the brand, the narrow-screen menu and actions below it, and
3098
+ the `main` landmark with a content column of a set maximum width.
3099
+
3100
+ The frame around a desktop web app's pages: a skip link, a [`bf-sidebar`](https://bitforge-ui.web.app/components/sidebar) column that sticks to the viewport at its full height from a breakpoint up, a header bar below the breakpoint with the brand, the narrow-screen [`bf-menu-button`](https://bitforge-ui.web.app/components/menu-button) and end actions, and the `main` landmark with a content column of a set maximum width. It replaces the `hidden md:flex` sidebar, the `md:hidden` menu button and the padded `main` an app would otherwise build by hand. The frame imports no overlay and never reads the router: the menu is your own `bf-menu-button`, projected into a slot.
3101
+
3102
+ ## When to use
3103
+
3104
+ Use `bf-app-frame` once, at the root of a desktop web app whose top-level destinations live in a `bf-sidebar`, with your `router-outlet` as its content. Use [bf-top-nav](https://bitforge-ui.web.app/components/top-nav) in your own header instead for an app with a handful of sections and no sidebar. In the Capacitor shell use [bf-tabs](https://bitforge-ui.web.app/components/tabs) with a [bf-tab-bar](https://bitforge-ui.web.app/components/tab-bar), and a [bf-page](https://bitforge-ui.web.app/components/page) with a [bf-header](https://bitforge-ui.web.app/components/header) per page, instead; never put the frame inside [`provideBfShell()`](https://bitforge-ui.web.app/api/provide-bf-shell).
3105
+
3106
+ ## Slots and layout
3107
+
3108
+ Put the `bf-sidebar` in `bfSlot="sidebar"`, the app's name or logo in `bfSlot="brand"`, the narrow-screen `bf-menu-button` of `a[bfMenuItem]` router links in `bfSlot="menu"` and end actions (the theme menu, the account) in `bfSlot="actions"`; everything else is the page. From the breakpoint up the sidebar column sits at the inline start, `sticky top-0` and `h-dvh` tall, and the `bf-sidebar` inside fills it and scrolls its own destinations; the header bar is hidden. Below it the sidebar column is hidden and the header bar (`surface`, a bottom border, sticky at the top) shows the brand at the start, then the menu, and the actions at the end. Keep the brand in the sidebar's own `header` slot too, for the wide layout.
3109
+
3110
+ ## Breakpoint and content width
3111
+
3112
+ `breakpoint` (`BfAppFrameBreakpoint`) is the viewport width from which the sidebar shows: `md` (768 px, the default) or `lg` (1024 px) for a wide sidebar that would crowd a tablet. `contentWidth` (`BfAppFrameContentWidth`) caps the content column, centred in the space beside the sidebar: `md` 48rem, `lg` 64rem, `xl` 80rem (the default) and `full` for no limit. The content is padded 16 px below the breakpoint and 32 px from it up.
3113
+
3114
+ ```ts
3115
+ import { Component } from '@angular/core';
3116
+ import { RouterOutlet } from '@angular/router';
3117
+ import { BfAppFrameComponent, BfNavItemComponent, BfSidebarComponent } from '@bitforgehq/ui';
3118
+
3119
+ /**
3120
+ * A narrow reading column, with the sidebar from lg up.
3121
+ *
3122
+ * A settings or reading app keeps its lines short: `contentWidth="md"` caps the content column at
3123
+ * 48rem (`lg` 64rem, the default `xl` 80rem, `full` no limit), and `breakpoint="lg"` keeps the
3124
+ * header bar up to 1024 px, for a sidebar that would crowd a tablet. `mainId` names the main
3125
+ * landmark, the skip link's target.
3126
+ *
3127
+ * @preview none
3128
+ */
3129
+ @Component({
3130
+ selector: 'app-example-app-frame-content-width',
3131
+ imports: [RouterOutlet, BfAppFrameComponent, BfNavItemComponent, BfSidebarComponent],
3132
+ template: `
3133
+ <bf-app-frame contentWidth="md" breakpoint="lg" mainId="content">
3134
+ <bf-sidebar bfSlot="sidebar" label="Settings">
3135
+ <a bfNavItem href="/account">Account</a>
3136
+ <a bfNavItem href="/billing">Billing</a>
3137
+ </bf-sidebar>
3138
+ <span bfSlot="brand" class="text-heading">Settings</span>
3139
+ <router-outlet />
3140
+ </bf-app-frame>
3141
+ `,
3142
+ })
3143
+ export class AppFrameContentWidthExampleComponent {}
3144
+ ```
3145
+
3146
+ ## Skip link and the main landmark
3147
+
3148
+ The first element in the tab order is a "Skip to main content" link (`provideBfLabels()`'s `skipToContent`), visually hidden until it has focus, then shown as a pill at the top inline-start corner. Activating it moves focus to the frame's main landmark (`tabindex="-1"`, its id from `mainId`, generated when left out) by script and cancels the link's navigation. That interception matters: under `<base href="/">`, which every Angular app has, a bare `#id` link resolves against the base, so following it would load the root URL instead of moving focus. The href stays for the link's semantics. Nested inside another `main` landmark (a docs preview, as on this page) the content drops its `main` role, so the document keeps exactly one.
3149
+
3150
+ ## One array of destinations
3151
+
3152
+ Keep one array of destinations, ideally derived from your route config, and drive every surface from it: the sidebar's `a[bfNavItem]` links, the narrow menu's `a[bfMenuItem]` links and, in the Capacitor shell, the `bf-tab`s of a `bf-tab-bar` (where each destination's path is a child route of the tabs and the tab's id). The overview at the top of this page drives the sidebar and the menu from one array; the shell keeps `bf-tabs` and has no frame:
3153
+
3154
+ ```ts
3155
+ import { Component } from '@angular/core';
3156
+ import { BfTabBarComponent, BfTabComponent } from '@bitforgehq/ui';
3157
+ import { BfIconComponent } from '@bitforgehq/ui/icons';
3158
+ import { BfStackOutletComponent, BfTabsComponent } from '@bitforgehq/ui/mobile';
3159
+ import { Folder, type IconNode, Inbox, Settings } from 'lucide';
3160
+
3161
+ /** One top-level destination: `path` is the route under the tabs, and the tab's id. */
3162
+ interface Destination {
3163
+ readonly path: string;
3164
+ readonly label: string;
3165
+ readonly icon: IconNode;
3166
+ }
3167
+
3168
+ /**
3169
+ * The same destinations in the Capacitor shell.
3170
+ *
3171
+ * Inside `provideBfShell()` there is no `bf-app-frame`: the array that feeds the frame's sidebar
3172
+ * and narrow menu drives the `bf-tab`s of a `bf-tab-bar` in `bf-tabs` instead, and each page
3173
+ * brings its own `bf-page` with a `bf-header`. Each destination's `path` is a child route of
3174
+ * `app` and the tab's id; on desktop the same array feeds `a[bfNavItem]` and `a[bfMenuItem]`
3175
+ * links (`routerLink="/app/{path}"`).
3176
+ *
3177
+ * @preview none
3178
+ */
3179
+ @Component({
3180
+ selector: 'app-example-app-frame-shell',
3181
+ imports: [
3182
+ BfIconComponent,
3183
+ BfStackOutletComponent,
3184
+ BfTabBarComponent,
3185
+ BfTabComponent,
3186
+ BfTabsComponent,
3187
+ ],
3188
+ template: `
3189
+ <bf-tabs tabsPath="app">
3190
+ <bf-stack-outlet />
3191
+ <bf-tab-bar>
3192
+ @for (destination of destinations; track destination.path) {
3193
+ <bf-tab [id]="destination.path" [label]="destination.label"
3194
+ ><bf-icon [icon]="destination.icon"
3195
+ /></bf-tab>
3196
+ }
3197
+ </bf-tab-bar>
3198
+ </bf-tabs>
3199
+ `,
3200
+ })
3201
+ export class AppFrameShellExampleComponent {
3202
+ protected readonly destinations: readonly Destination[] = [
3203
+ { path: 'inbox', label: 'Inbox', icon: Inbox },
3204
+ { path: 'projects', label: 'Projects', icon: Folder },
3205
+ { path: 'settings', label: 'Settings', icon: Settings },
3206
+ ];
3207
+ }
3208
+ ```
3209
+
3210
+ ## Desktop
3211
+
3212
+ The frame is a desktop pattern. The skip link shows its focus ring on keyboard focus only (`focus-visible`); it stays out of sight for pointer users, who never focus it. The sidebar column keeps its height while the page scrolls under the pointer, and the narrow header bar stays at the top. The breakpoint follows the viewport, not a container, so a frame inside a smaller box (as in the preview above) still switches at the viewport's width. Under a coarse pointer (a touchscreen laptop) the sidebar's items and the projected controls take their own touch heights; the frame adds none.
3213
+
3214
+ ## Theming
3215
+
3216
+ - Frame: `bg` background, `text` colour.
3217
+ - Narrow header bar: `surface` with a `border` line at the bottom, at least 56 px tall.
3218
+ - Skip link: `surface` background, `text`, `shadow-floating`, the `control` (pill) radius and the `ring` focus ring.
3219
+ - Content: 16 px padding below the breakpoint, 32 px from it up; `max-w-3xl`, `max-w-5xl`, `max-w-7xl` or no cap by `contentWidth`.
3220
+ - The sidebar's own tokens are on the [sidebar](https://bitforge-ui.web.app/components/sidebar#theming) page.
3221
+
3222
+ ## Accessibility
3223
+
3224
+ The skip link is a native `<a>` with an href to the main landmark and is the first focusable element, so a keyboard user reaches the content in one step; it moves focus by script (see above). The content is a `role="main"` landmark with `tabindex="-1"`, so it takes focus without joining the tab order, and drops the role inside another `main`. The narrow header bar is a native `header` (the banner landmark at the root of a page). Name the `bf-sidebar` by `label` and the menu button by its text; the frame names nothing else itself. Hidden regions use `display: none`, so the sidebar below the breakpoint and the header bar from it up are out of the accessibility tree too.
3225
+
3226
+ ### Keyboard
3227
+
3228
+ | Key | Action |
3229
+ | ------------------------------------------------ | ------------------------------------------------------------------------- |
3230
+ | <kbd>Tab</kbd> | From the top of the page, focuses the skip link first and reveals it |
3231
+ | <kbd>Enter</kbd> | On the skip link, moves focus to the main content |
3232
+ | <kbd>Tab</kbd> / <kbd>Shift</kbd>+<kbd>Tab</kbd> | Moves through the sidebar or the header bar, then the page, in that order |
3233
+
3234
+ ## Inputs
3235
+
3236
+ | Name | Type | Default | Description |
3237
+ | --- | --- | --- | --- |
3238
+ | `breakpoint` | `BfAppFrameBreakpoint` (`'md' \| 'lg'`) | `'md'` | The viewport width from which the sidebar column replaces the narrow header bar. |
3239
+ | `contentWidth` | `BfAppFrameContentWidth` (`'md' \| 'lg' \| 'xl' \| 'full'`) | `'xl'` | The maximum width of the content column (`md` 48rem, `lg` 64rem, `xl` 80rem, `full`). |
3240
+ | `mainId` | `string` | `generated` | The id of the `main` landmark, the skip link's target; generated when left out. |
3241
+
3242
+ ## Slots
3243
+
3244
+ - `sidebar`: The `bf-sidebar`, shown from the breakpoint up; it fills the column's height.
3245
+ - `brand`: The app's name or logo in the narrow header bar.
3246
+ - `menu`: The narrow-screen navigation: a `bf-menu-button` of `a[bfMenuItem]` links.
3247
+ - `actions`: End actions of the narrow header bar, such as the theme menu.
3248
+ - `default`: The page: what the router shows, inside the `main` landmark.
3249
+
3250
+ ## Notes
3251
+
3252
+ ### Do
3253
+
3254
+ - Put the frame at the root of the app, with the `router-outlet` as its content, so the sidebar stays put between pages.
3255
+ - Drive the sidebar, the narrow menu and the shell's tab bar from one array of destinations.
3256
+ - Keep one `h1` per page in the content; the frame renders no heading.
3257
+
3258
+ ### Don't
3259
+
3260
+ - Don't use `bf-app-frame` inside the Capacitor shell; use `bf-tabs` with a `bf-tab-bar` and `bf-page` there.
3261
+ - Don't add your own skip link or hide the `bf-sidebar` with `hidden md:flex` inside the frame; the frame does both.
3262
+
3263
+ ## Migration notes
3264
+
3265
+ ### 0.3.2
3266
+
3267
+ New `bf-app-frame` in `@bitforgehq/ui`: the frame of a desktop web app. Slots `sidebar` (a
3268
+ `bf-sidebar`, in a column that sticks to the viewport at full height from the breakpoint up),
3269
+ `brand`, `menu` (the narrow-screen `bf-menu-button`) and `actions` (the header bar shown below
3270
+ the breakpoint) and the default slot (the page, in the `main` landmark). `breakpoint`
3271
+ (`BfAppFrameBreakpoint`: `'md'` default, `'lg'`), `contentWidth` (`BfAppFrameContentWidth`:
3272
+ `'md'` 48rem, `'lg'` 64rem, `'xl'` 80rem default, `'full'`) and `mainId`. A skip link, first in
3273
+ the tab order and hidden until focused, moves focus to the main landmark by script, so it works
3274
+ under `<base href="/">`; new label key `skipToContent` ("Skip to main content"). Inside another
3275
+ `main` landmark the content drops its `main` role. Desktop only: the Capacitor shell keeps
3276
+ `bf-tabs`.
3277
+
3278
+ ---
3279
+
2923
3280
  # bf-avatar
2924
3281
 
2925
3282
  Round avatar showing a photo or initials, in three sizes and four color variants
@@ -5078,7 +5435,7 @@ Surface for a piece of content: a project summary, a stat, a focus prompt. Four
5078
5435
  the design: raised (default), tint, ink and flat. To make the whole card clickable, put a
5079
5436
  `bfAction` link or button in its heading (the stretched-link pattern).
5080
5437
 
5081
- Cards group a piece of content on the canvas. The raised card is the workhorse; tint highlights a stat or a callout; ink is the dark focus card; flat sits inside another surface.
5438
+ Cards group a piece of content on the canvas. The raised card is the workhorse; tint highlights a stat or a callout; ink is the dark focus card; flat is the outlined card for a page level that should stay quiet, such as a dense grid of cards.
5082
5439
 
5083
5440
  ## When to use
5084
5441
 
@@ -5131,6 +5488,53 @@ import { BfCardComponent } from '@bitforgehq/ui';
5131
5488
  export class CardVariantsExampleComponent {}
5132
5489
  ```
5133
5490
 
5491
+ ## Nesting surfaces
5492
+
5493
+ Each level of a page takes one surface style, and a level inside a card steps down, never up:
5494
+
5495
+ - **Raised card, then sunken tile.** Inside a raised card, group figures or blocks on sunken, borderless tiles: [bf-stat](https://bitforge-ui.web.app/components/stat) `variant="tile"`, or your own block on `bg-surface-sunken` with the `card` radius.
5496
+ - **Never an outlined surface inside a raised one.** A `flat` card or a `card` stat inside a raised card draws a border on top of a shadow, two edges for one level; use a tile instead. Don't nest raised cards either.
5497
+ - **One surface style per level of a dashboard.** All cards on the page are raised (or all flat), and all tiles in them are sunken; mixing raised and flat cards side by side, or tiles and outlined stats in one card, reads as different kinds of content. The [bf-stat dashboard example](https://bitforge-ui.web.app/components/stat) puts raised cards on the page and sunken tiles in each.
5498
+
5499
+ ```ts
5500
+ import { Component } from '@angular/core';
5501
+ import { BfCardComponent, BfStatComponent } from '@bitforgehq/ui';
5502
+ import { BfIconComponent } from '@bitforgehq/ui/icons';
5503
+ import { CircleCheck, Clock, ListTodo } from 'lucide';
5504
+
5505
+ /**
5506
+ * A raised card holding a row of sunken tiles.
5507
+ *
5508
+ * One surface per level: the card is raised on the page, and the stats inside it are sunken,
5509
+ * borderless tiles (`bf-stat variant="tile"`), never outlined surfaces.
5510
+ */
5511
+ @Component({
5512
+ selector: 'app-example-card-nesting',
5513
+ imports: [BfCardComponent, BfIconComponent, BfStatComponent],
5514
+ template: `
5515
+ <bf-card padding="lg">
5516
+ <h3 class="text-heading">This week</h3>
5517
+ <div class="grid grid-cols-[repeat(auto-fit,minmax(9rem,1fr))] gap-3">
5518
+ <bf-stat label="Open tasks" value="14" variant="tile">
5519
+ <bf-icon bfSlot="icon" [icon]="listTodo" />
5520
+ </bf-stat>
5521
+ <bf-stat label="Done" value="23" variant="tile">
5522
+ <bf-icon bfSlot="icon" [icon]="circleCheck" />
5523
+ </bf-stat>
5524
+ <bf-stat label="Time logged" value="31 h" variant="tile">
5525
+ <bf-icon bfSlot="icon" [icon]="clock" />
5526
+ </bf-stat>
5527
+ </div>
5528
+ </bf-card>
5529
+ `,
5530
+ })
5531
+ export class CardNestingExampleComponent {
5532
+ protected readonly listTodo = ListTodo;
5533
+ protected readonly circleCheck = CircleCheck;
5534
+ protected readonly clock = Clock;
5535
+ }
5536
+ ```
5537
+
5134
5538
  ## Footer
5135
5539
 
5136
5540
  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.
@@ -5302,13 +5706,20 @@ The card renders a plain `<div>` with no role. A clickable card follows the stre
5302
5706
 
5303
5707
  ### Don't
5304
5708
 
5305
- - Don't nest raised cards; use `flat` inside.
5709
+ - Don't nest raised cards, and don't put an outlined surface (`flat`, a `card` stat) inside a raised one; use sunken tiles (`bf-stat variant="tile"`) inside.
5710
+ - Don't mix surface styles on one level of a dashboard; keep one per level.
5306
5711
  - Don't use `interactive` (deprecated) or wrap a card in an `<a>`; both hide the heading and nest interactive content.
5307
5712
  - Don't put more than one `bfAction` in a card.
5308
5713
  - Don't put a `text` button on an ink card — its primary colour fails contrast on ink; use `tonal` or `outline`.
5309
5714
 
5310
5715
  ## Migration notes
5311
5716
 
5717
+ ### 0.3.2
5718
+
5719
+ New "Nesting surfaces" docs section: a raised card holds sunken tiles, never an outlined
5720
+ surface (`flat`, a `card` stat), and each level of a dashboard keeps one surface style. New
5721
+ `card/nesting` example. No code change.
5722
+
5312
5723
  ### 0.3.1
5313
5724
 
5314
5725
  The footer (`bfSlot="footer"`) sits on the card's bottom edge (`mt-auto`), so in a grid of cards
@@ -10011,6 +10422,89 @@ export class EmptyStateActionExampleComponent {
10011
10422
 
10012
10423
  Set `headingLevel` to `3` when the empty state sits inside a section that already opened with an `h2`, so the page outline stays in order.
10013
10424
 
10425
+ ## As the page
10426
+
10427
+ When the empty state is the whole page (a not-found page, a no-access page, an app's first run), its title is the page's heading: set `headingLevel` to `1` so it renders the `h1`, and give the page no other `h1`. Centre it in the content area: a column with a minimum height (`min-h-[60vh]`) and `justify-center`. Put the primary action first in the `actions` slot (a `bfButton` link home) and an optional secondary link after it (a `text` `bfButton`, such as "Contact support").
10428
+
10429
+ The preview below sits inside this docs page, which already has its `h1`, so it keeps `headingLevel` 2 in a shorter area; a real page uses `1`, as in the code-only example after it.
10430
+
10431
+ ```ts
10432
+ import { Component } from '@angular/core';
10433
+ import { RouterLink } from '@angular/router';
10434
+ import { BfButtonComponent, BfEmptyStateComponent } from '@bitforgehq/ui';
10435
+ import { BfIconComponent } from '@bitforgehq/ui/icons';
10436
+ import { FileQuestionMark } from 'lucide';
10437
+
10438
+ /**
10439
+ * An empty state as a whole page.
10440
+ *
10441
+ * Centred in a tall content area, with a primary action and a secondary link. This preview sits
10442
+ * inside a docs page that has its own `h1`, so it uses `headingLevel` 2; on a real not-found page
10443
+ * the empty state is the page, so set it to 1 (see the next example).
10444
+ */
10445
+ @Component({
10446
+ selector: 'app-example-empty-state-page',
10447
+ imports: [RouterLink, BfButtonComponent, BfEmptyStateComponent, BfIconComponent],
10448
+ template: `
10449
+ <div class="flex min-h-96 flex-col justify-center">
10450
+ <bf-empty-state
10451
+ title="Page not found"
10452
+ description="The page may have moved, or the link has a typo."
10453
+ [headingLevel]="2"
10454
+ >
10455
+ <bf-icon bfSlot="icon" [icon]="fileQuestion" size="lg" />
10456
+ <a bfSlot="actions" bfButton routerLink="/">Go to the home page</a>
10457
+ <a bfSlot="actions" bfButton variant="text" href="mailto:support@example.com">
10458
+ Contact support
10459
+ </a>
10460
+ </bf-empty-state>
10461
+ </div>
10462
+ `,
10463
+ })
10464
+ export class EmptyStatePageExampleComponent {
10465
+ protected readonly fileQuestion = FileQuestionMark;
10466
+ }
10467
+ ```
10468
+
10469
+ ```ts
10470
+ import { Component } from '@angular/core';
10471
+ import { RouterLink } from '@angular/router';
10472
+ import { BfButtonComponent, BfEmptyStateComponent } from '@bitforgehq/ui';
10473
+ import { BfIconComponent } from '@bitforgehq/ui/icons';
10474
+ import { FileQuestionMark } from 'lucide';
10475
+
10476
+ /**
10477
+ * A not-found page whose empty state is the page heading.
10478
+ *
10479
+ * The route's whole content: `headingLevel` 1 renders the title as the page's `h1`, and the
10480
+ * `min-h-[60vh]` area centres the empty state vertically in the content column.
10481
+ *
10482
+ * @preview none
10483
+ */
10484
+ @Component({
10485
+ selector: 'app-example-empty-state-not-found-page',
10486
+ imports: [RouterLink, BfButtonComponent, BfEmptyStateComponent, BfIconComponent],
10487
+ template: `
10488
+ <div class="flex min-h-[60vh] flex-col justify-center">
10489
+ <bf-empty-state
10490
+ title="Page not found"
10491
+ description="The page may have moved, or the link has a typo."
10492
+ [headingLevel]="1"
10493
+ >
10494
+ <bf-icon bfSlot="icon" [icon]="fileQuestion" size="lg" />
10495
+ <a bfSlot="actions" bfButton routerLink="/">Go to the home page</a>
10496
+ <a bfSlot="actions" bfButton variant="text" href="mailto:support@example.com">
10497
+ Contact support
10498
+ </a>
10499
+ </bf-empty-state>
10500
+ </div>
10501
+ `,
10502
+ })
10503
+ export class EmptyStateNotFoundPageExampleComponent {
10504
+ protected readonly fileQuestion = FileQuestionMark;
10505
+ }
10506
+ ```
10507
+
10014
10508
  ## Theming
10015
10509
 
10016
10510
  - Icon tile: `surface-sunken` background, `text-muted` icon color, `tile` radius.
@@ -10018,7 +10512,7 @@ Set `headingLevel` to `3` when the empty state sits inside a section that alread
10018
10512
 
10019
10513
  ## Accessibility
10020
10514
 
10021
- The title renders as an `<h2>` by default and an `<h3>` when `headingLevel` is `3`, so it slots into the surrounding page outline instead of always claiming the top level. The icon and action slots carry their own accessibility — an icon should stay decorative, and the action needs its own accessible name.
10515
+ The title renders as an `<h2>` by default, an `<h3>` when `headingLevel` is `3` and an `<h1>` when it is `1` (only when the empty state is the page and the page has no other `h1`), so it slots into the surrounding page outline instead of always claiming the top level. The icon and action slots carry their own accessibility — an icon should stay decorative, and the action needs its own accessible name.
10022
10516
 
10023
10517
  ## Inputs
10024
10518
 
@@ -10026,7 +10520,7 @@ The title renders as an `<h2>` by default and an `<h3>` when `headingLevel` is `
10026
10520
  | --- | --- | --- | --- |
10027
10521
  | `title` | `string` | required | What is missing, as a short title. |
10028
10522
  | `description` | `string` | `''` | One sentence on how to fill it. |
10029
- | `headingLevel` | `BfEmptyStateHeadingLevel` (`2 \| 3`) | `2` | Heading level of the title so the empty state fits the page outline. |
10523
+ | `headingLevel` | `BfEmptyStateHeading` (`2 \| 3 \| 1`) | `2` | Heading level of the title so the empty state fits the page outline; 1 when it is the page. |
10030
10524
 
10031
10525
  ## Slots
10032
10526
 
@@ -10042,10 +10536,20 @@ The title renders as an `<h2>` by default and an `<h3>` when `headingLevel` is `
10042
10536
 
10043
10537
  ### Don't
10044
10538
 
10539
+ - Don't set `headingLevel` to `1` on an empty state inside a page that already has its own `h1`.
10045
10540
  - Don't use `bf-empty-state` for a loading screen; that is `bf-spinner`.
10046
10541
 
10047
10542
  ## Migration notes
10048
10543
 
10544
+ ### 0.3.2
10545
+
10546
+ `headingLevel` now takes the new named type `BfEmptyStateHeading` (`BfEmptyStateHeadingLevel |
10547
+ 1`): `1` renders the title as the page's `h1`, for an empty state that is the page (a not-found
10548
+ or no-access page). The released `BfEmptyStateHeadingLevel` keeps its two members, so `Record`s
10549
+ over it keep compiling. New "As the page" docs section with the `empty-state/page` example
10550
+ (centred in a tall area, a primary action and a secondary link) and the code-only
10551
+ `empty-state/not-found-page`.
10552
+
10049
10553
  ### 0.3.0
10050
10554
 
10051
10555
  New exported `BfEmptyStateHeadingLevel` (`2 | 3`), the type of `headingLevel` (was an inline union
@@ -10992,7 +11496,7 @@ inside one.
10992
11496
 
10993
11497
  ## Desktop
10994
11498
 
10995
- `bf-header` is part of the Capacitor shell (`provideBfShell()`), shaped like an iOS or Material app bar. On a desktop web page use [bf-page-header](https://bitforge-ui.web.app/components/page-header): the page's one `h1` (`title`) with an optional `subtitle`, badges in `meta`, and the actions in `actions` (the main action as a primary [bf-button](https://bitforge-ui.web.app/components/button), secondary ones as `ghost` or `outline` buttons, the rest in an icon-only [bf-menu-button](https://bitforge-ui.web.app/components/menu-button) labelled "More actions"); the actions wrap under the title in a narrow window. On a nested page, put a [bf-breadcrumb](https://bitforge-ui.web.app/components/breadcrumb) in its `breadcrumb` slot, and a [bf-toolbar](https://bitforge-ui.web.app/components/toolbar) under it for search and filters. The two are siblings rather than one shell-aware widget: the large title and the scroll-under surface are phone patterns that depend on `bf-content`.
11499
+ `bf-header` is part of the Capacitor shell (`provideBfShell()`), shaped like an iOS or Material app bar. On a desktop web page use [bf-page-header](https://bitforge-ui.web.app/components/page-header): the page's one `h1` (`title`) with an optional `subtitle`, badges in `meta`, and the actions in `actions` (the main action as a primary [bf-button](https://bitforge-ui.web.app/components/button), secondary ones as `ghost` or `outline` buttons, the rest in an icon-only [bf-menu-button](https://bitforge-ui.web.app/components/menu-button) labelled "More actions"); the actions wrap under the title in a narrow window. On a nested page, put a [bf-breadcrumb](https://bitforge-ui.web.app/components/breadcrumb) in its `breadcrumb` slot, and a [bf-toolbar](https://bitforge-ui.web.app/components/toolbar) under it for search and filters. Below the breakpoint, [bf-app-frame](https://bitforge-ui.web.app/components/app-frame) supplies the app-level header bar (brand, navigation menu, actions) above the page header. The two are siblings rather than one shell-aware widget: the large title and the scroll-under surface are phone patterns that depend on `bf-content`.
10996
11500
 
10997
11501
  ```ts
10998
11502
  import { Component, signal } from '@angular/core';
@@ -11132,6 +11636,10 @@ The title is not in the tab order; after a push, `bf-stack-outlet` focuses it wi
11132
11636
 
11133
11637
  ## Migration notes
11134
11638
 
11639
+ ### 0.3.2
11640
+
11641
+ The `## Desktop` docs say `bf-app-frame` supplies the app-level header bar below its breakpoint.
11642
+
11135
11643
  ### 0.3.1
11136
11644
 
11137
11645
  The `## Desktop` docs and When to use name the desktop replacement, `bf-page-header` (with
@@ -20212,9 +20720,9 @@ export class SidebarOverviewExampleComponent {
20212
20720
 
20213
20721
  The vertical navigation of a desktop app: a `nav` landmark with a `header` slot (the brand),
20214
20722
  the destinations (`a[bfNavItem]` links, optionally in `bf-nav-group`s) and a `footer` slot.
20215
- `collapsed` turns it into an icon rail; the consumer owns the toggle button and binds
20216
- `[(collapsed)]`. Below a breakpoint hide it (`class="hidden md:flex"`) and drive a
20217
- `bf-menu-button` from the same routes.
20723
+ `collapsed` turns it into an icon rail: put a `bf-sidebar-toggle` (`@bitforgehq/ui/overlay`) in
20724
+ it, or bind `[(collapsed)]` to your own button. Below a breakpoint hide it
20725
+ (`class="hidden md:flex"`) and drive a `bf-menu-button` from the same routes.
20218
20726
 
20219
20727
  The vertical navigation of a desktop web app: a `nav` landmark down the inline-start edge with a `header` slot for the brand, the destinations as [`a[bfNavItem]`](https://bitforge-ui.web.app/components/nav-item) links (optionally in titled [`bf-nav-group`](https://bitforge-ui.web.app/components/nav-group)s) and a `footer` slot for settings or the account. `collapsed` turns it into an icon rail. Its horizontal sibling for apps with few sections is [`bf-top-nav`](https://bitforge-ui.web.app/components/top-nav); both share the same item, so the current page, the badge and the focus ring look the same in either.
20220
20728
 
@@ -20228,11 +20736,178 @@ Put `bfNavItem` on your own `<a routerLink routerLinkActive ariaCurrentWhenActiv
20228
20736
 
20229
20737
  ## Collapsing to an icon rail
20230
20738
 
20231
- `collapsed` is a model: bind `[(collapsed)]` to your own toggle button, such as a text `bf-icon-button` in the header whose label says what the next press does ("Collapse sidebar", "Expand sidebar"). The widget has no toggle of its own, because the button needs an icon and a widget never imports one. Collapsed, every item shows only its icon, centred in a square; its label stays in the DOM as screen-reader text, so its accessible name does not change, and becomes the link's `title`, the tooltip a pointer user sees on hover. A count badge becomes a small dot that is still announced, and the group headings are hidden visually but still name their groups. The width follows the rail (`w-auto`); expanded the sidebar is `w-60` (240 px).
20739
+ `collapsed` is a model. Put a [`bf-sidebar-toggle`](https://bitforge-ui.web.app/components/sidebar-toggle) (`@bitforgehq/ui/overlay`) in the header or footer: it flips the model, names itself by what the next press does ("Collapse sidebar", "Expand sidebar") and takes its two icons as slots, since a widget never imports one. Bind `[(collapsed)]` to keep the state in your own signal, or bind it to your own button outside the sidebar. Collapsed, every item shows only its icon, centred in a square; its label stays in the DOM as screen-reader text, so its accessible name does not change, and becomes the link's `title`, the tooltip a pointer user sees on hover. A count badge becomes a small dot that is still announced, and the group headings are hidden visually but still name their groups. The width follows the rail (`w-auto`); expanded the sidebar is `w-60` (240 px).
20740
+
20741
+ ## Remember the collapsed sidebar
20742
+
20743
+ `BfSidebarStateService` (from `@bitforgehq/ui`) holds a `collapsed` signal: bind the sidebar's `[(collapsed)]` to it (`sidebarState.collapsed`) and add `provideBfSidebarState()` to the app's providers, and every change is stored under `bf-sidebar-collapsed` (`localStorage` in the browser, `BF_SIDEBAR_STATE_STORAGE` to keep it elsewhere) and read back at the next start, so the app opens as the user left it. Without the provider the signal still holds the state but nothing is stored; on the server it starts expanded and reads nothing. The widget itself injects no service.
20744
+
20745
+ ```ts
20746
+ import { Component, inject } from '@angular/core';
20747
+ import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
20748
+ import { BfNavItemComponent, BfSidebarComponent, BfSidebarStateService } from '@bitforgehq/ui';
20749
+ import { BfIconComponent } from '@bitforgehq/ui/icons';
20750
+ import { BfSidebarToggleComponent } from '@bitforgehq/ui/overlay';
20751
+ import { Inbox, PanelLeftClose, PanelLeftOpen } from 'lucide';
20752
+
20753
+ /**
20754
+ * A sidebar that opens collapsed or expanded, as the user left it.
20755
+ *
20756
+ * `[(collapsed)]` binds `BfSidebarStateService.collapsed`; with `provideBfSidebarState()` in the
20757
+ * app's providers every change is stored under `bf-sidebar-collapsed` (`localStorage` in the
20758
+ * browser) and read back at the next start.
20759
+ *
20760
+ * @preview none
20761
+ */
20762
+ @Component({
20763
+ selector: 'app-example-sidebar-remembered',
20764
+ imports: [
20765
+ RouterLink,
20766
+ RouterLinkActive,
20767
+ RouterOutlet,
20768
+ BfIconComponent,
20769
+ BfNavItemComponent,
20770
+ BfSidebarComponent,
20771
+ BfSidebarToggleComponent,
20772
+ ],
20773
+ template: `
20774
+ <div class="flex min-h-dvh">
20775
+ <bf-sidebar [(collapsed)]="sidebarState.collapsed" class="sticky top-0 h-dvh">
20776
+ <a bfNavItem routerLink="/inbox" routerLinkActive ariaCurrentWhenActive="page">
20777
+ <bf-icon bfSlot="icon" [icon]="inbox" />
20778
+ Inbox
20779
+ </a>
20780
+ <bf-sidebar-toggle bfSlot="footer">
20781
+ <bf-icon bfSlot="collapse-icon" [icon]="panelClose" />
20782
+ <bf-icon bfSlot="expand-icon" [icon]="panelOpen" />
20783
+ </bf-sidebar-toggle>
20784
+ </bf-sidebar>
20785
+ <main class="min-w-0 flex-1 p-6"><router-outlet /></main>
20786
+ </div>
20787
+ `,
20788
+ })
20789
+ export class SidebarRememberedExampleComponent {
20790
+ // app.config.ts: providers: [provideBfSidebarState()]
20791
+ protected readonly sidebarState = inject(BfSidebarStateService);
20792
+ protected readonly inbox = Inbox;
20793
+ protected readonly panelOpen = PanelLeftOpen;
20794
+ protected readonly panelClose = PanelLeftClose;
20795
+ }
20796
+ ```
20797
+
20798
+ ## Footer
20799
+
20800
+ The footer slot takes several elements, stacked with a 4 px gap: a Settings link as an `a[bfNavItem]`, a row holding an icon-only text [`bf-theme-menu-button`](https://bitforge-ui.web.app/components/theme-menu-button) and the `bf-sidebar-toggle`, and an optional version line in `text-caption text-text-muted`. Expanded, the row puts the two buttons at either end (`flex items-center justify-between`). As an icon rail every footer element is centred: switch the row to a column (`flex flex-col items-center gap-1`) so both buttons fit the rail, and hide the version line (`[class.hidden]="collapsed()"`), which has no icon to shrink to. Open the theme menu upwards (`placement="above-start"`), since the footer sits at the bottom of the screen.
20801
+
20802
+ ```ts
20803
+ import { Component, computed, signal } from '@angular/core';
20804
+ import { RouterLink, RouterLinkActive } from '@angular/router';
20805
+ import { type BfColorScheme, BfNavItemComponent, BfSidebarComponent } from '@bitforgehq/ui';
20806
+ import { BfIconComponent } from '@bitforgehq/ui/icons';
20807
+ import { BfSidebarToggleComponent, BfThemeMenuButtonComponent } from '@bitforgehq/ui/overlay';
20808
+ import {
20809
+ Inbox,
20810
+ type IconNode,
20811
+ Moon,
20812
+ PanelLeftClose,
20813
+ PanelLeftOpen,
20814
+ Settings,
20815
+ Sun,
20816
+ SunMoon,
20817
+ Users,
20818
+ } from 'lucide';
20819
+
20820
+ interface NavLink {
20821
+ readonly path: string;
20822
+ readonly label: string;
20823
+ readonly icon: IconNode;
20824
+ }
20825
+
20826
+ /**
20827
+ * A sidebar footer: settings, theme, toggle and version.
20828
+ *
20829
+ * The footer slot holds a Settings link (`a[bfNavItem]`), a row with an icon-only
20830
+ * `bf-theme-menu-button` and the `bf-sidebar-toggle`, and a version line. Expanded, the two
20831
+ * buttons sit at either end of the row; as an icon rail the row stacks vertically and the version
20832
+ * line hides. The theme binds a local signal so the docs site keeps its theme; an app binds
20833
+ * `BfColorSchemeService.scheme`.
20834
+ */
20835
+ @Component({
20836
+ selector: 'app-example-sidebar-footer',
20837
+ imports: [
20838
+ RouterLink,
20839
+ RouterLinkActive,
20840
+ BfIconComponent,
20841
+ BfNavItemComponent,
20842
+ BfSidebarComponent,
20843
+ BfSidebarToggleComponent,
20844
+ BfThemeMenuButtonComponent,
20845
+ ],
20846
+ template: `
20847
+ <div class="border-border bg-bg rounded-card flex h-[26rem] overflow-hidden border">
20848
+ <bf-sidebar label="Footer demo" [(collapsed)]="collapsed">
20849
+ @for (link of links; track link.path) {
20850
+ <a bfNavItem [routerLink]="link.path" routerLinkActive ariaCurrentWhenActive="page">
20851
+ <bf-icon bfSlot="icon" [icon]="link.icon" />
20852
+ {{ link.label }}
20853
+ </a>
20854
+ }
20855
+ <a
20856
+ bfSlot="footer"
20857
+ bfNavItem
20858
+ routerLink="/theming"
20859
+ routerLinkActive
20860
+ ariaCurrentWhenActive="page"
20861
+ >
20862
+ <bf-icon bfSlot="icon" [icon]="settings" />
20863
+ Settings
20864
+ </a>
20865
+ <div bfSlot="footer" [class]="rowClasses()">
20866
+ <bf-theme-menu-button iconOnly variant="text" placement="above-start" [(value)]="scheme">
20867
+ <bf-icon bfSlot="icon" [icon]="themeIcon()" />
20868
+ </bf-theme-menu-button>
20869
+ <bf-sidebar-toggle>
20870
+ <bf-icon bfSlot="collapse-icon" [icon]="panelClose" />
20871
+ <bf-icon bfSlot="expand-icon" [icon]="panelOpen" />
20872
+ </bf-sidebar-toggle>
20873
+ </div>
20874
+ <p bfSlot="footer" class="text-caption text-text-muted ps-3" [class.hidden]="collapsed()">
20875
+ Version 0.3.2
20876
+ </p>
20877
+ </bf-sidebar>
20878
+ <div class="min-w-0 flex-1 p-4">
20879
+ <p data-status aria-live="polite" class="text-caption text-text-muted wrap-anywhere">
20880
+ {{ collapsed() ? 'Icon rail' : 'Expanded' }}, theme {{ scheme() }}.
20881
+ </p>
20882
+ </div>
20883
+ </div>
20884
+ `,
20885
+ })
20886
+ export class SidebarFooterExampleComponent {
20887
+ readonly collapsed = signal(false);
20888
+ /** Stands in for `BfColorSchemeService.scheme`. */
20889
+ readonly scheme = signal<BfColorScheme>('system');
20890
+
20891
+ /** Expanded: one row, the buttons at either end. Collapsed: a column in the rail. */
20892
+ protected readonly rowClasses = computed(() =>
20893
+ this.collapsed() ? 'flex flex-col items-center gap-1' : 'flex items-center justify-between',
20894
+ );
20895
+ protected readonly themeIcon = computed(
20896
+ () => ({ system: SunMoon, light: Sun, dark: Moon })[this.scheme()],
20897
+ );
20898
+ protected readonly links: readonly NavLink[] = [
20899
+ { path: '/components/nav-item', label: 'Inbox', icon: Inbox },
20900
+ { path: '/components/nav-group', label: 'Team', icon: Users },
20901
+ ];
20902
+ protected readonly settings = Settings;
20903
+ protected readonly panelOpen = PanelLeftOpen;
20904
+ protected readonly panelClose = PanelLeftClose;
20905
+ }
20906
+ ```
20232
20907
 
20233
20908
  ## Responsive layouts
20234
20909
 
20235
- Below a breakpoint the sidebar does not turn itself into a menu: a root widget cannot render `bf-menu`. Keep one array of destinations (ideally derived from your route config) and drive every surface from it: the sidebar from `md` up (`class="hidden md:flex"` on `bf-sidebar`; its own `flex` loses to your `hidden`), and below it a [`bf-menu-button`](https://bitforge-ui.web.app/components/menu-button) of `a[bfMenuItem]` router links (`class="md:hidden"`, from `@bitforgehq/ui/overlay`). Each surface keeps the router's `routerLinkActive`.
20910
+ Below a breakpoint the sidebar does not turn itself into a menu: a root widget cannot render `bf-menu`. Keep one array of destinations (ideally derived from your route config) and drive every surface from it: the sidebar from `md` up (`class="hidden md:flex"` on `bf-sidebar`; its own `flex` loses to your `hidden`), and below it a [`bf-menu-button`](https://bitforge-ui.web.app/components/menu-button) of `a[bfMenuItem]` router links (`class="md:hidden"`, from `@bitforgehq/ui/overlay`). Each surface keeps the router's `routerLinkActive`. [`bf-app-frame`](https://bitforge-ui.web.app/components/app-frame) does this layout for you: it shows the sidebar in a sticky column from the breakpoint up and the menu button in a header bar below it.
20236
20911
 
20237
20912
  ```ts
20238
20913
  import { Component } from '@angular/core';
@@ -20390,7 +21065,7 @@ The sidebar is a desktop pattern. Its items take a `surface-muted` fill and the
20390
21065
 
20391
21066
  ## Accessibility
20392
21067
 
20393
- `bf-sidebar` renders a native `<nav>` landmark named by `label`, else `provideBfLabels()`'s `navigation` ("Main"); give each landmark on a page a different name. The header, the destinations and the footer all sit inside it, in that DOM order. The current page is the link the router marks with `aria-current="page"`; the tonal fill is never the only cue under forced colours, where an outline marks it. Collapsing changes only what is visible: every item keeps its label as screen-reader text and each group its name, so the accessible names stay the same. The consumer's toggle button is a native button; name it by what it does ("Collapse sidebar") or give it `aria-pressed`, and keep it in the sidebar or right before it.
21068
+ `bf-sidebar` renders a native `<nav>` landmark named by `label`, else `provideBfLabels()`'s `navigation` ("Main"); give each landmark on a page a different name. The header, the destinations and the footer all sit inside it, in that DOM order. The current page is the link the router marks with `aria-current="page"`; the tonal fill is never the only cue under forced colours, where an outline marks it. Collapsing changes only what is visible: every item keeps its label as screen-reader text and each group its name, so the accessible names stay the same. `bf-sidebar-toggle` is a native button named by what it does ("Collapse sidebar"), with `aria-expanded` and `aria-controls` on the `nav`; your own toggle should do the same, and sit in the sidebar or right before it.
20394
21069
 
20395
21070
  ### Keyboard
20396
21071
 
@@ -20404,7 +21079,7 @@ The sidebar is a desktop pattern. Its items take a `surface-muted` fill and the
20404
21079
  | Name | Type | Default | Description |
20405
21080
  | --- | --- | --- | --- |
20406
21081
  | `label` | `string` | `''` | Accessible name of the navigation; empty falls back to `provideBfLabels()`'s `navigation` ("Main"). Name each landmark differently when a page has more than one. |
20407
- | `collapsed` | `boolean` | `false` | Collapses the sidebar to an icon rail: items show their icon only, their label stays for screen readers and becomes a tooltip. Bind `[(collapsed)]` to your own toggle button. |
21082
+ | `collapsed` | `boolean` | `false` | Collapses the sidebar to an icon rail: items show their icon only, their label stays for screen readers and becomes a tooltip. A `bf-sidebar-toggle` inside flips it; bind `[(collapsed)]` to keep it in your state. |
20408
21083
 
20409
21084
  ## Slots
20410
21085
 
@@ -20418,6 +21093,7 @@ The sidebar is a desktop pattern. Its items take a `surface-muted` fill and the
20418
21093
 
20419
21094
  - Mark the current page with `routerLinkActive` and `ariaCurrentWhenActive="page"` on every link.
20420
21095
  - Drive the sidebar, the small-screen menu and the shell's tab bar from one array of destinations.
21096
+ - Remember the collapsed state with `BfSidebarStateService` and `provideBfSidebarState()`.
20421
21097
 
20422
21098
  ### Don't
20423
21099
 
@@ -20426,6 +21102,16 @@ The sidebar is a desktop pattern. Its items take a `surface-muted` fill and the
20426
21102
 
20427
21103
  ## Migration notes
20428
21104
 
21105
+ ### 0.3.2
21106
+
21107
+ New `BfSidebarStateService` (a `collapsed` signal) and `provideBfSidebarState()` in
21108
+ `@bitforgehq/ui`: bind the sidebar's `[(collapsed)]` to `sidebarState.collapsed` and the provider
21109
+ stores every change under `bf-sidebar-collapsed` in `BF_SIDEBAR_STATE_STORAGE` (`localStorage` in
21110
+ the browser, memory elsewhere), read back at the next start; without the provider nothing is
21111
+ written, and on the server nothing is read. The sidebar's `nav` now has an id, for a toggle's
21112
+ `aria-controls`. The docs gain "Remember the collapsed sidebar" and a "Footer" composition
21113
+ (Settings link, theme menu, toggle, version line) that works expanded and as an icon rail.
21114
+
20429
21115
  ### 0.3.1
20430
21116
 
20431
21117
  New `bf-sidebar`: the vertical navigation of a desktop app, a `nav` landmark with `header`,
@@ -20435,6 +21121,296 @@ breakpoint, hide it with `hidden md:flex` and drive a `bf-menu-button` from the
20435
21121
 
20436
21122
  ---
20437
21123
 
21124
+ # bf-sidebar-toggle
21125
+
21126
+ Icon button inside a sidebar that collapses it to an icon rail and back
21127
+
21128
+ `BfSidebarToggleComponent` from `@bitforgehq/ui/overlay` · Navigation · since 0.3.2
21129
+
21130
+ ## Example
21131
+
21132
+ ```ts
21133
+ import { Component, computed, signal } from '@angular/core';
21134
+ import { RouterLink, RouterLinkActive } from '@angular/router';
21135
+ import { BfNavItemComponent, BfSidebarComponent } from '@bitforgehq/ui';
21136
+ import { BfIconComponent } from '@bitforgehq/ui/icons';
21137
+ import { BfSidebarToggleComponent } from '@bitforgehq/ui/overlay';
21138
+ import { type IconNode, Layers, PanelLeftClose, PanelLeftOpen, PanelTop } from 'lucide';
21139
+
21140
+ interface NavLink {
21141
+ readonly path: string;
21142
+ readonly label: string;
21143
+ readonly icon: IconNode;
21144
+ }
21145
+
21146
+ /**
21147
+ * The bf-sidebar-toggle overview.
21148
+ *
21149
+ * Live demo of `bf-sidebar-toggle` in the header of a `bf-sidebar`: a press collapses the
21150
+ * sidebar to an icon rail and the next one expands it again. Its name and tooltip say what the
21151
+ * next press does, and only the matching icon slot shows. The sidebar's `[(collapsed)]` keeps
21152
+ * the state in a signal, and the result line reports it.
21153
+ */
21154
+ @Component({
21155
+ selector: 'app-example-sidebar-toggle-overview',
21156
+ imports: [
21157
+ RouterLink,
21158
+ RouterLinkActive,
21159
+ BfIconComponent,
21160
+ BfNavItemComponent,
21161
+ BfSidebarComponent,
21162
+ BfSidebarToggleComponent,
21163
+ ],
21164
+ template: `
21165
+ <div class="border-border bg-bg rounded-card flex h-72 overflow-hidden border">
21166
+ <bf-sidebar label="Toggle demo" [(collapsed)]="collapsed">
21167
+ <div bfSlot="header" class="flex items-center justify-between gap-2">
21168
+ @if (!collapsed()) {
21169
+ <span class="text-heading text-text ps-3">Atlas</span>
21170
+ }
21171
+ <bf-sidebar-toggle>
21172
+ <bf-icon bfSlot="collapse-icon" [icon]="panelClose" />
21173
+ <bf-icon bfSlot="expand-icon" [icon]="panelOpen" />
21174
+ </bf-sidebar-toggle>
21175
+ </div>
21176
+ @for (link of links; track link.path) {
21177
+ <a bfNavItem [routerLink]="link.path" routerLinkActive ariaCurrentWhenActive="page">
21178
+ <bf-icon bfSlot="icon" [icon]="link.icon" />
21179
+ {{ link.label }}
21180
+ </a>
21181
+ }
21182
+ </bf-sidebar>
21183
+ <div class="min-w-0 flex-1 p-4">
21184
+ <p data-status aria-live="polite" class="text-caption text-text-muted wrap-anywhere">
21185
+ {{ status() }}
21186
+ </p>
21187
+ </div>
21188
+ </div>
21189
+ `,
21190
+ })
21191
+ export class SidebarToggleOverviewExampleComponent {
21192
+ readonly collapsed = signal(false);
21193
+ readonly status = computed(() =>
21194
+ this.collapsed() ? 'Sidebar collapsed to an icon rail.' : 'Sidebar expanded.',
21195
+ );
21196
+
21197
+ protected readonly links: readonly NavLink[] = [
21198
+ { path: '/components/sidebar-toggle', label: 'Sidebar toggle', icon: PanelTop },
21199
+ { path: '/components/sidebar', label: 'Sidebar', icon: Layers },
21200
+ ];
21201
+ protected readonly panelOpen = PanelLeftOpen;
21202
+ protected readonly panelClose = PanelLeftClose;
21203
+ }
21204
+ ```
21205
+
21206
+ ## Overview
21207
+
21208
+ The collapse button of a `bf-sidebar`: a text icon button placed inside the sidebar (usually in
21209
+ its header or footer) that flips the sidebar between full width and an icon rail. Its name and
21210
+ tooltip say what the next press does ("Collapse sidebar", "Expand sidebar"), `aria-expanded`
21211
+ follows the sidebar and `aria-controls` points at its `nav`. It shows the `collapse-icon` slot
21212
+ while expanded and the `expand-icon` slot while collapsed. It reads and writes the sidebar's
21213
+ `collapsed` model, so `[(collapsed)]` on the sidebar sees every press. Outside a `bf-sidebar`
21214
+ (in a `bf-top-nav`, or on its own) it renders nothing.
21215
+
21216
+ The sidebar toggle collapses a [`bf-sidebar`](https://bitforge-ui.web.app/components/sidebar) to an icon rail and expands it again, from `@bitforgehq/ui/overlay` (it shows a `bfTooltip`, so it needs `@angular/cdk`; no Capacitor). Put it inside the sidebar, in its header or footer: it reads and writes the sidebar's `collapsed` model, so the sidebar's `[(collapsed)]` sees every press and the toggle needs no binding of its own. It is a text icon button whose name and tooltip say what the next press does. Import `BfSidebarToggleComponent` from `@bitforgehq/ui/overlay`.
21217
+
21218
+ ## When to use
21219
+
21220
+ Use `bf-sidebar-toggle` for the collapse button of a desktop app's `bf-sidebar`. Bind your own [bf-icon-button](https://bitforge-ui.web.app/components/icon-button) to the sidebar's `[(collapsed)]` instead when the button must sit outside the sidebar, in a top bar. In the Capacitor shell there is no sidebar: use [bf-tabs](https://bitforge-ui.web.app/components/tabs) with a `bf-tab-bar` instead.
21221
+
21222
+ ## Icons
21223
+
21224
+ The toggle never picks an icon. Project two, `bfSlot="collapse-icon"` (shown while the sidebar is expanded, such as Lucide's `PanelLeftClose`) and `bfSlot="expand-icon"` (shown while it is an icon rail, `PanelLeftOpen`); only the one that matches the state shows. Under a right-to-left layout pick the mirrored icons (`PanelRightClose`, `PanelRightOpen`), since the sidebar then sits at the right.
21225
+
21226
+ ## Placement
21227
+
21228
+ The toggle renders only inside a `bf-sidebar`. In a `bf-top-nav`, which never collapses, or on its own it renders nothing and takes no space, so a misplaced toggle never shows a button that does nothing. In the header, put it after the brand and show the brand only while expanded; in the footer, put it beside the theme menu ([footer composition](https://bitforge-ui.web.app/components/sidebar#footer)). It keeps its place in the tab order either way, so a keyboard user finds it where they left it.
21229
+
21230
+ ```ts
21231
+ import { Component, computed, signal } from '@angular/core';
21232
+ import { RouterLink, RouterLinkActive } from '@angular/router';
21233
+ import { type BfColorScheme, BfNavItemComponent, BfSidebarComponent } from '@bitforgehq/ui';
21234
+ import { BfIconComponent } from '@bitforgehq/ui/icons';
21235
+ import { BfSidebarToggleComponent, BfThemeMenuButtonComponent } from '@bitforgehq/ui/overlay';
21236
+ import {
21237
+ Inbox,
21238
+ type IconNode,
21239
+ Moon,
21240
+ PanelLeftClose,
21241
+ PanelLeftOpen,
21242
+ Settings,
21243
+ Sun,
21244
+ SunMoon,
21245
+ Users,
21246
+ } from 'lucide';
21247
+
21248
+ interface NavLink {
21249
+ readonly path: string;
21250
+ readonly label: string;
21251
+ readonly icon: IconNode;
21252
+ }
21253
+
21254
+ /**
21255
+ * A sidebar footer: settings, theme, toggle and version.
21256
+ *
21257
+ * The footer slot holds a Settings link (`a[bfNavItem]`), a row with an icon-only
21258
+ * `bf-theme-menu-button` and the `bf-sidebar-toggle`, and a version line. Expanded, the two
21259
+ * buttons sit at either end of the row; as an icon rail the row stacks vertically and the version
21260
+ * line hides. The theme binds a local signal so the docs site keeps its theme; an app binds
21261
+ * `BfColorSchemeService.scheme`.
21262
+ */
21263
+ @Component({
21264
+ selector: 'app-example-sidebar-footer',
21265
+ imports: [
21266
+ RouterLink,
21267
+ RouterLinkActive,
21268
+ BfIconComponent,
21269
+ BfNavItemComponent,
21270
+ BfSidebarComponent,
21271
+ BfSidebarToggleComponent,
21272
+ BfThemeMenuButtonComponent,
21273
+ ],
21274
+ template: `
21275
+ <div class="border-border bg-bg rounded-card flex h-[26rem] overflow-hidden border">
21276
+ <bf-sidebar label="Footer demo" [(collapsed)]="collapsed">
21277
+ @for (link of links; track link.path) {
21278
+ <a bfNavItem [routerLink]="link.path" routerLinkActive ariaCurrentWhenActive="page">
21279
+ <bf-icon bfSlot="icon" [icon]="link.icon" />
21280
+ {{ link.label }}
21281
+ </a>
21282
+ }
21283
+ <a
21284
+ bfSlot="footer"
21285
+ bfNavItem
21286
+ routerLink="/theming"
21287
+ routerLinkActive
21288
+ ariaCurrentWhenActive="page"
21289
+ >
21290
+ <bf-icon bfSlot="icon" [icon]="settings" />
21291
+ Settings
21292
+ </a>
21293
+ <div bfSlot="footer" [class]="rowClasses()">
21294
+ <bf-theme-menu-button iconOnly variant="text" placement="above-start" [(value)]="scheme">
21295
+ <bf-icon bfSlot="icon" [icon]="themeIcon()" />
21296
+ </bf-theme-menu-button>
21297
+ <bf-sidebar-toggle>
21298
+ <bf-icon bfSlot="collapse-icon" [icon]="panelClose" />
21299
+ <bf-icon bfSlot="expand-icon" [icon]="panelOpen" />
21300
+ </bf-sidebar-toggle>
21301
+ </div>
21302
+ <p bfSlot="footer" class="text-caption text-text-muted ps-3" [class.hidden]="collapsed()">
21303
+ Version 0.3.2
21304
+ </p>
21305
+ </bf-sidebar>
21306
+ <div class="min-w-0 flex-1 p-4">
21307
+ <p data-status aria-live="polite" class="text-caption text-text-muted wrap-anywhere">
21308
+ {{ collapsed() ? 'Icon rail' : 'Expanded' }}, theme {{ scheme() }}.
21309
+ </p>
21310
+ </div>
21311
+ </div>
21312
+ `,
21313
+ })
21314
+ export class SidebarFooterExampleComponent {
21315
+ readonly collapsed = signal(false);
21316
+ /** Stands in for `BfColorSchemeService.scheme`. */
21317
+ readonly scheme = signal<BfColorScheme>('system');
21318
+
21319
+ /** Expanded: one row, the buttons at either end. Collapsed: a column in the rail. */
21320
+ protected readonly rowClasses = computed(() =>
21321
+ this.collapsed() ? 'flex flex-col items-center gap-1' : 'flex items-center justify-between',
21322
+ );
21323
+ protected readonly themeIcon = computed(
21324
+ () => ({ system: SunMoon, light: Sun, dark: Moon })[this.scheme()],
21325
+ );
21326
+ protected readonly links: readonly NavLink[] = [
21327
+ { path: '/components/nav-item', label: 'Inbox', icon: Inbox },
21328
+ { path: '/components/nav-group', label: 'Team', icon: Users },
21329
+ ];
21330
+ protected readonly settings = Settings;
21331
+ protected readonly panelOpen = PanelLeftOpen;
21332
+ protected readonly panelClose = PanelLeftClose;
21333
+ }
21334
+ ```
21335
+
21336
+ ## Remembering the state
21337
+
21338
+ The toggle holds no state. To open the app with the sidebar as the user left it, bind the sidebar to `BfSidebarStateService` from `@bitforgehq/ui` and add `provideBfSidebarState()` to the app's providers ([remember the collapsed sidebar](https://bitforge-ui.web.app/components/sidebar#remember-the-collapsed-sidebar)).
21339
+
21340
+ ## Sizes
21341
+
21342
+ `size` is `sm` 32, `md` 40 or `lg` 48 px, square; unset it follows `provideBfDefaults()`'s `controlSize`, as `bf-icon-button` does.
21343
+
21344
+ ## Labels
21345
+
21346
+ The name and tooltip read `sidebarCollapse` ("Collapse sidebar") while the sidebar is expanded and `sidebarExpand` ("Expand sidebar") while it is an icon rail. Translate them with `provideBfLabels()`.
21347
+
21348
+ ## Testing
21349
+
21350
+ `BfSidebarToggleHarness` from `@bitforgehq/ui/testing` reads the name with `getLabel()`, the state with `isExpanded()` and presses it with `toggle()`; filter it with `label: 'Collapse sidebar'`.
21351
+
21352
+ ## Desktop
21353
+
21354
+ The toggle looks and behaves like a text `bf-icon-button`: the muted text colour, a `surface-muted` fill and the pointer cursor on hover, a pressed tint while pressed, and the focus ring on keyboard focus only (`focus-visible`). Its tooltip shows after a 500 ms rest of a fine pointer and at once on keyboard focus: above the button while expanded, at the inline end of the rail while collapsed. Under a fine pointer the visible box is the hit area; under a coarse pointer (a touchscreen laptop) a `sm` or `md` toggle extends vertically to 44 px, and no tooltip shows.
21355
+
21356
+ ## Theming
21357
+
21358
+ The toggle consumes `bf-icon-button`'s text variant: `text-muted` at rest, `text` and `surface-muted` on hover, `primary-soft-pressed` while pressed, and the `ring` token for focus. The tooltip consumes `bf-tooltip`'s tokens.
21359
+
21360
+ ## Accessibility
21361
+
21362
+ - A native `<button type="button">` named by `aria-label`: "Collapse sidebar" while expanded, "Expand sidebar" while collapsed, so the name says what a press does. The tooltip shows the same text and describes the button while it shows.
21363
+ - `aria-expanded` is `true` while the sidebar is at full width and `false` as an icon rail; `aria-controls` points at the sidebar's `nav`.
21364
+ - The projected icons add nothing to the name (`bf-icon` is `aria-hidden`).
21365
+ - Collapsing keeps focus on the toggle, and every nav item keeps its accessible name, so nothing a screen reader user relies on moves.
21366
+
21367
+ ### Keyboard
21368
+
21369
+ | Key | Action |
21370
+ | ----------------------------------- | ------------------------------------------------ |
21371
+ | <kbd>Enter</kbd> / <kbd>Space</kbd> | Collapses or expands the sidebar |
21372
+ | <kbd>Escape</kbd> | Hides the tooltip while it shows |
21373
+ | <kbd>Tab</kbd> | Moves on to the next control, hiding the tooltip |
21374
+
21375
+ ## Inputs
21376
+
21377
+ | Name | Type | Default | Description |
21378
+ | --- | --- | --- | --- |
21379
+ | `size` | `BfSize \| undefined` (`'sm' \| 'md' \| 'lg' \| undefined`) | `undefined` | Height step: sm 32, md 40, lg 48 px (square); unset follows `provideBfDefaults()` (`controlSize`, `md` by default). |
21380
+
21381
+ ## Slots
21382
+
21383
+ - `collapse-icon`: The icon shown while the sidebar is expanded (a panel closing).
21384
+ - `expand-icon`: The icon shown while the sidebar is an icon rail (a panel opening).
21385
+
21386
+ ## Notes
21387
+
21388
+ ### Do
21389
+
21390
+ - Put the toggle inside the `bf-sidebar` it collapses, in its header or footer.
21391
+ - Project both icon slots, a panel closing and a panel opening.
21392
+ - Bind the sidebar to `BfSidebarStateService.collapsed` with `provideBfSidebarState()`, so the choice survives a reload.
21393
+
21394
+ ### Don't
21395
+
21396
+ - Don't put it outside a `bf-sidebar`; it renders nothing there. Use a `bf-icon-button` bound to `[(collapsed)]` instead.
21397
+ - Don't add your own `aria-label` or tooltip; the toggle names itself by what the next press does.
21398
+
21399
+ ## Migration notes
21400
+
21401
+ ### 0.3.2
21402
+
21403
+ New `bf-sidebar-toggle` in `@bitforgehq/ui/overlay`: a text icon button placed inside a
21404
+ `bf-sidebar` (header or footer) that flips the sidebar between full width and an icon rail by
21405
+ writing its `collapsed` model, so `[(collapsed)]` sees every press. Its name and `bfTooltip` say
21406
+ what the next press does (new label keys `sidebarCollapse` "Collapse sidebar" and `sidebarExpand`
21407
+ "Expand sidebar"); `aria-expanded` follows the sidebar and `aria-controls` points at its `nav`.
21408
+ Icons are the `collapse-icon` and `expand-icon` slots, only the matching one shown; `size` as
21409
+ `bf-icon-button`. Outside a sidebar (in a `bf-top-nav`, or alone) it renders nothing. A
21410
+ `BfSidebarToggleHarness` in `@bitforgehq/ui/testing` (`getLabel()`, `isExpanded()`, `toggle()`).
21411
+
21412
+ ---
21413
+
20438
21414
  # bf-skeleton
20439
21415
 
20440
21416
  Loading placeholder shaped as text lines, a rectangle or a circle, with a shimmer
@@ -21745,7 +22721,7 @@ Use `bf-stat` for a few headline figures the reader should take in at a glance,
21745
22721
 
21746
22722
  ## Cards
21747
22723
 
21748
- `variant="card"` gives each stat its own outlined tile, the `flat` card surface. In a grid the cards stretch to the row's height, so a row of stats lines up even when one has a supporting line and another has none. Put an icon in the `icon` slot (`bfSlot="icon"`, a `bf-icon`) to show what the figure is about.
22724
+ `variant="card"` gives each stat its own outlined tile, the `flat` card surface, for stats that sit on the page itself; inside a raised card use `tile` instead. In a grid the cards stretch to the row's height, so a row of stats lines up even when one has a supporting line and another has none. Put an icon in the `icon` slot (`bfSlot="icon"`, a `bf-icon`) to show what the figure is about.
21749
22725
 
21750
22726
  ```ts
21751
22727
  import { Component } from '@angular/core';
@@ -21787,6 +22763,60 @@ export class StatOverviewExampleComponent {
21787
22763
  }
21788
22764
  ```
21789
22765
 
22766
+ ## Tiles
22767
+
22768
+ `variant="tile"` is a sunken, borderless tile (`surface-sunken`, the `card` radius and padding) for a row of stats inside a raised [bf-card](https://bitforge-ui.web.app/components/card): a raised card holds sunken tiles, never outlined ones. On a tile the icon tile turns `surface`, so it still shows. Keep one surface style per level of a dashboard: raised cards on the page, tiles in each card.
22769
+
22770
+ ```ts
22771
+ import { Component } from '@angular/core';
22772
+ import { BfCardComponent, BfStatComponent } from '@bitforgehq/ui';
22773
+
22774
+ /**
22775
+ * A dashboard with one surface per level.
22776
+ *
22777
+ * The page holds raised cards, and every card holds sunken stat tiles: each level has one surface
22778
+ * style, so the eye reads the cards as the groups and the tiles as their figures.
22779
+ */
22780
+ @Component({
22781
+ selector: 'app-example-stat-dashboard',
22782
+ imports: [BfCardComponent, BfStatComponent],
22783
+ template: `
22784
+ <div class="grid gap-4 @lg:grid-cols-2">
22785
+ <bf-card>
22786
+ <h3 class="text-heading">Sales</h3>
22787
+ <div class="grid grid-cols-[repeat(auto-fit,minmax(9rem,1fr))] gap-3">
22788
+ <bf-stat label="Revenue" value="€48,200" variant="tile" delta="+12%" trend="up">
22789
+ vs. last month
22790
+ </bf-stat>
22791
+ <bf-stat label="Orders" value="1,204" variant="tile" delta="+3%" trend="up">
22792
+ vs. last month
22793
+ </bf-stat>
22794
+ </div>
22795
+ </bf-card>
22796
+ <bf-card>
22797
+ <h3 class="text-heading">Support</h3>
22798
+ <div class="grid grid-cols-[repeat(auto-fit,minmax(9rem,1fr))] gap-3">
22799
+ <bf-stat
22800
+ label="Open tickets"
22801
+ value="18"
22802
+ variant="tile"
22803
+ delta="-4"
22804
+ trend="down"
22805
+ trendIntent="negative"
22806
+ >
22807
+ vs. last week
22808
+ </bf-stat>
22809
+ <bf-stat label="First reply" value="2 h" variant="tile" delta="0" trend="flat">
22810
+ vs. last week
22811
+ </bf-stat>
22812
+ </div>
22813
+ </bf-card>
22814
+ </div>
22815
+ `,
22816
+ })
22817
+ export class StatDashboardExampleComponent {}
22818
+ ```
22819
+
21790
22820
  ## Plain
21791
22821
 
21792
22822
  `variant="plain"` (the default) draws no surface, for stats that sit on a card or a page section of their own. The default slot is the supporting line under the value: a short trend ("+12% this week") or a `bf-badge`.
@@ -21866,8 +22896,9 @@ A stat is not interactive: no hover, no pointer cursor, no focus stop. Its size
21866
22896
  - Label (`dt`): `text-caption` in `text-muted`. Value (`dd`): `text-title` in `text`, with tabular figures (`tabular-nums`) so numbers line up across stats.
21867
22897
  - Supporting line: `text-caption` in `text-muted`.
21868
22898
  - Delta pill: the badge's label shape (`control` radius, `text-micro` bold, tabular figures); good changes `success-soft` with `success` text, bad ones `danger-soft` with `danger` text, flat or no trend `neutral-soft` with `neutral` text. The arrow mark is drawn with borders in `currentColor`, so it takes the pill's text colour.
21869
- - Icon tile: `surface-sunken` background, `text-muted` icon, `tile` radius, 40 px.
22899
+ - Icon tile: `surface-sunken` background (`surface` on a `tile` stat), `text-muted` icon, `tile` radius, 40 px.
21870
22900
  - `card`: `surface`, the `card` radius, `--spacing-card` padding (`p-card`) and a 1.5 px `border-strong` edge, as the `flat` `bf-card`.
22901
+ - `tile`: `surface-sunken`, the `card` radius and `--spacing-card` padding, no border.
21871
22902
 
21872
22903
  ## Accessibility
21873
22904
 
@@ -21879,7 +22910,7 @@ A stat is not interactive: no hover, no pointer cursor, no focus stop. Its size
21879
22910
  | --- | --- | --- | --- |
21880
22911
  | `label` | `string` | required | What the figure measures, shown above it. |
21881
22912
  | `value` | `string` | required | The figure, already formatted (`45 min`, `1,204`, `98%`). |
21882
- | `variant` | `BfStatVariant` (`'plain' \| 'card'`) | `'plain'` | Surface: plain (default) on its container, or card, an outlined tile of its own. |
22913
+ | `variant` | `BfStatAppearance` (`'plain' \| 'card' \| 'tile'`) | `'plain'` | Surface: plain (default) on its container, card (an outlined tile) or tile (sunken, no border). |
21883
22914
  | `delta` | `string` | `''` | A change shown in a pill beside the value, already formatted (`+12%`); empty shows none. |
21884
22915
  | `trend` | `BfStatTrend \| undefined` (`'up' \| 'down' \| 'flat' \| undefined`) | `undefined` | The delta's direction: an arrow mark and a hidden word; left unset, the pill is neutral. |
21885
22916
  | `trendIntent` | `BfStatTrendIntent` (`'positive' \| 'negative'`) | `'positive'` | Whether up is good (positive, default) or down is good (negative, such as costs). |
@@ -21899,12 +22930,21 @@ A stat is not interactive: no hover, no pointer cursor, no focus stop. Its size
21899
22930
 
21900
22931
  ### Don't
21901
22932
 
22933
+ - Don't put `card` stats (outlined) inside a raised `bf-card`; use `tile`.
21902
22934
  - Don't use a stat for a value the user edits or acts on; give it its own control.
21903
22935
  - Don't put long text in `value`; it is set at the `title` size for a short figure.
21904
22936
  - Don't put the comparison period in `delta`; keep it to the change (`+12%`) and say "vs. last month" in the supporting line.
21905
22937
 
21906
22938
  ## Migration notes
21907
22939
 
22940
+ ### 0.3.2
22941
+
22942
+ New `variant="tile"`: a sunken, borderless tile (`surface-sunken`, the `card` radius and
22943
+ padding) for a row of stats inside a raised `bf-card`; on it the icon tile turns `surface`.
22944
+ `variant` now takes the new named type `BfStatAppearance` (`BfStatVariant | 'tile'`); the
22945
+ released `BfStatVariant` keeps its two members, so `Record`s over it keep compiling. New
22946
+ `stat/dashboard` example with one surface per level.
22947
+
21908
22948
  ### 0.3.1
21909
22949
 
21910
22950
  New: `bf-stat`, one labelled figure in its own `dl`, with the value at the `title` size in
@@ -23146,13 +24186,14 @@ export class TableOverviewExampleComponent {
23146
24186
 
23147
24187
  Data table for desktop: column headers that set a sort, optional row selection with
23148
24188
  checkboxes, custom cells from `bfTableCell` templates and an empty slot. It never sorts or
23149
- filters itself; the consumer sorts the rows in a `computed()` from `sort`.
24189
+ filters itself; the consumer sorts the rows in a `computed()` from `sort`. With `stack` it
24190
+ renders each row as a tile of label/value pairs in a narrow space or under touch.
23150
24191
 
23151
24192
  A data table for desktop pages: rows of records the user compares across columns, with headers that sort, an optional checkbox column that selects rows, custom cells and an empty state. `bf-table` is data-driven: you pass `rows` and `columns`, and it never sorts or filters the rows itself. Its headers set the `sort` model and you sort the rows in a `computed()`, on the client or on the server.
23152
24193
 
23153
24194
  ## When to use
23154
24195
 
23155
- Use `bf-table` on a desktop page for records the user scans and compares column by column: tasks, invoices, members. On a phone or in the Capacitor shell use [bf-list](https://bitforge-ui.web.app/components/list) rows instead. Use a [bf-list](https://bitforge-ui.web.app/components/list) on desktop too when each record is one line of text with a status, and a description list for the fields of a single record.
24196
+ Use `bf-table` on a desktop page for records the user scans and compares column by column: tasks, invoices, members. On a phone or in the Capacitor shell use [bf-list](https://bitforge-ui.web.app/components/list) rows instead, or let the table stack its rows into tiles with `stack` (see Stacked rows). Use a [bf-list](https://bitforge-ui.web.app/components/list) on desktop too when each record is one line of text with a status, and a description list for the fields of a single record.
23156
24197
 
23157
24198
  ## Columns and rows
23158
24199
 
@@ -23443,9 +24484,115 @@ export class TablePagedExampleComponent {
23443
24484
 
23444
24485
  `size` sets the row height: `sm` 40 px, `md` 48 px (default), `lg` 56 px, with a 32, 40 or 48 px header. Unset, it follows `provideBfDefaults()`'s `controlSize`, so `{ size: 'sm' }` in a page's `providers` makes a dense table. The checkboxes take the same step. Under a coarse pointer every row is at least 44 px tall, and the sortable header buttons are 44 px targets; a cell grows to fit its content and is never covered by a hit-area extension.
23445
24486
 
24487
+ ## Stacked rows
24488
+
24489
+ `stack` lets the table itself fit a narrow space. With `stack="narrow"` it stacks while the table is narrower than 36rem (576 px at the default root size, Tailwind's `xl` container size): a container query on the table's own width, so a table in a narrow column or a side panel stacks even on a wide screen. The host is then a size container (`container-type: inline-size`), so give the table its width from its parent (a block or `w-full` / `flex-1`), not from its columns. With `stack="coarse"` it stacks under a coarse pointer at any width (phones, tablets, the Capacitor shell). The default `never` keeps the table as it is.
24490
+
24491
+ Stacked, each row is a sunken tile (`surface-sunken`, `rounded-tile`, the tile a raised card holds; a selected row keeps `primary-soft`). Its checkbox and first cell head the tile in bold; every other cell shows its column label at the start and its value at the end. The header row is visually hidden, and a bar above the tiles holds a labelled Select all checkbox (with `selectable`) and a Sort by select of every sortable column, ascending and descending (`tableSortBy`, `tableSortAscending`, `tableSortDescending` labels), since the hidden header's buttons are not shown. Choosing an option sets `sort`, as a header click does.
24492
+
24493
+ ```ts
24494
+ import { Component, computed, signal } from '@angular/core';
24495
+ import {
24496
+ BfBadgeComponent,
24497
+ BfSegmentedComponent,
24498
+ BfTableCellDirective,
24499
+ BfTableComponent,
24500
+ type BfSegmentOption,
24501
+ type BfTableColumn,
24502
+ type BfTableSort,
24503
+ } from '@bitforgehq/ui';
24504
+
24505
+ interface Task {
24506
+ id: string;
24507
+ title: string;
24508
+ project: string;
24509
+ status: 'Open' | 'Done';
24510
+ hours: number;
24511
+ }
24512
+
24513
+ const TASKS: readonly Task[] = [
24514
+ { id: 'brief', title: 'Write the brief', project: 'Atlas', status: 'Done', hours: 3 },
24515
+ { id: 'moodboard', title: 'Bloom moodboard', project: 'Bloom', status: 'Open', hours: 6 },
24516
+ { id: 'deck', title: 'Pitch deck', project: 'Atlas', status: 'Open', hours: 9 },
24517
+ ];
24518
+
24519
+ type Width = 'phone' | 'full';
24520
+
24521
+ /**
24522
+ * Stacked rows on a narrow table.
24523
+ *
24524
+ * `stack="narrow"` turns each row into a tile of label/value pairs while the table is narrower
24525
+ * than 36rem: the selection checkbox and the first cell head the tile, and a bar above the tiles
24526
+ * holds Select all and a Sort by select. Switch the box between phone width and full width.
24527
+ */
24528
+ @Component({
24529
+ selector: 'app-example-table-stacked',
24530
+ imports: [BfTableComponent, BfTableCellDirective, BfBadgeComponent, BfSegmentedComponent],
24531
+ template: `
24532
+ <!-- A stacking table is a size container: its width comes from its parent, never its columns. -->
24533
+ <div class="flex w-152 max-w-full flex-col gap-3">
24534
+ <bf-segmented label="Box width" [options]="widths" [(value)]="width" />
24535
+ <div [class]="width() === 'phone' ? 'w-full max-w-sm' : 'w-full'">
24536
+ <bf-table
24537
+ label="Stacked tasks"
24538
+ stack="narrow"
24539
+ selectable
24540
+ [rows]="rows()"
24541
+ [columns]="columns"
24542
+ [(sort)]="sort"
24543
+ [(selection)]="selection"
24544
+ >
24545
+ <ng-template bfTableCell="status" [bfTableCellOf]="rows()" let-task>
24546
+ <bf-badge [variant]="task.status === 'Done' ? 'success' : 'neutral'">
24547
+ {{ task.status }}
24548
+ </bf-badge>
24549
+ </ng-template>
24550
+ </bf-table>
24551
+ </div>
24552
+ <p data-status aria-live="polite" class="text-caption text-text-muted">{{ status() }}</p>
24553
+ </div>
24554
+ `,
24555
+ })
24556
+ export class TableStackedExampleComponent {
24557
+ readonly widths: BfSegmentOption<Width>[] = [
24558
+ { value: 'phone', label: 'Phone' },
24559
+ { value: 'full', label: 'Full width' },
24560
+ ];
24561
+ readonly width = signal<Width | null>('phone');
24562
+ readonly columns: readonly BfTableColumn[] = [
24563
+ { key: 'title', label: 'Task', sortable: true },
24564
+ { key: 'project', label: 'Project' },
24565
+ { key: 'status', label: 'Status' },
24566
+ { key: 'hours', label: 'Hours', sortable: true, align: 'end' },
24567
+ ];
24568
+ readonly sort = signal<BfTableSort | null>(null);
24569
+ readonly selection = signal<readonly string[]>([]);
24570
+
24571
+ /** The table never sorts: the page sorts its rows from `sort`. */
24572
+ readonly rows = computed(() => {
24573
+ const sort = this.sort();
24574
+ if (!sort) {
24575
+ return TASKS;
24576
+ }
24577
+ const factor = sort.direction === 'ascending' ? 1 : -1;
24578
+ return [...TASKS].sort((a, b) =>
24579
+ sort.column === 'hours'
24580
+ ? (a.hours - b.hours) * factor
24581
+ : a.title.localeCompare(b.title) * factor,
24582
+ );
24583
+ });
24584
+
24585
+ readonly status = computed(() => {
24586
+ const sort = this.sort();
24587
+ const sorted = sort ? `Sorted by ${sort.column}, ${sort.direction}` : 'Unsorted';
24588
+ return `${sorted} · ${this.selection().length} selected.`;
24589
+ });
24590
+ }
24591
+ ```
24592
+
23446
24593
  ## Narrow screens and touch
23447
24594
 
23448
- A table needs width. Below the `md` breakpoint, render the same rows as a divided `bf-list` of `bf-list-item`s (a `md:hidden` wrapper) and the table only from `md` up (a `hidden md:block` wrapper), from the one `rows` array. Inside the Capacitor shell use the list alone. The table's wrapper scrolls sideways when its columns cannot fit, but that is a last resort, not a layout for phones.
24595
+ A table needs width. When stacked tiles are not what a phone should show (long records, a summary per row), render the same rows as a divided `bf-list` of `bf-list-item`s below the `md` breakpoint (a `md:hidden` wrapper) and the table only from `md` up (a `hidden md:block` wrapper), from the one `rows` array. Inside the Capacitor shell use the list alone, or a table with `stack="coarse"`. The table's wrapper scrolls sideways when its columns cannot fit, but that is a last resort, not a layout for phones.
23449
24596
 
23450
24597
  ```ts
23451
24598
  import { Component } from '@angular/core';
@@ -23514,6 +24661,7 @@ A selectable table tints a row `surface-muted` while hovered (a selected row `pr
23514
24661
  - Rows: `text` on the page's surface, divided by `border` lines; hover (selectable only) `surface-muted`; selected `primary-soft`, hover `primary-soft-hover`.
23515
24662
  - Sort arrow: a 1.5 px CSS chevron in the header's text colour.
23516
24663
  - Focus ring: `ring`. Header buttons: `rounded-field`.
24664
+ - Stacked rows: tiles on `surface-sunken` with `rounded-tile`, labels `text-label-xs` bold in `text-muted`.
23517
24665
  - Text: `text-body` (`text-label-sm` at `sm`), `tabular-nums` in `end` columns.
23518
24666
 
23519
24667
  Put the table on a `bf-card` (or another surface) yourself; it has no background or border of its own.
@@ -23522,6 +24670,8 @@ Put the table on a `bf-card` (or another surface) yourself; it has no background
23522
24670
 
23523
24671
  A native `table` with a visually hidden `caption` from `label`, `th scope="col"` headers and one `tr` per row. A sorted column's `th` carries `aria-sort="ascending"` or `"descending"`; the others carry none. A sortable header is a native `button` inside the `th`, so its name is the column label; the arrow is `aria-hidden`. The header checkbox is named by `selectAllLabel`, else `provideBfLabels()`'s `tableSelectAll` ("Select all rows"), and is mixed (`indeterminate`) while some rows are selected. Each row checkbox is named by the row's first data cell through `aria-labelledby`, so it reads as the row's title. The empty row's cell spans every column.
23524
24672
 
24673
+ A table that can stack (`stack` other than `never`) sets explicit `table`, `rowgroup`, `row`, `columnheader` and `cell` roles and names the table from its caption with `aria-labelledby`, because Safari and some screen readers drop a table's semantics once CSS changes its display; so a stacked table still reads as a table with its column headers. Stacked, the header row is visually hidden but stays in the accessibility tree; its sort buttons and checkbox are hidden (a plain label takes a sortable header's place), so no control is focusable where nobody can see it, and the bar's Select all checkbox and Sort by select replace them. A cell's visible label is CSS generated content with empty alternative text (`content: attr(data-label) / ""`), so a screen reader reads the column header once, not the label as well.
24674
+
23525
24675
  ### Keyboard
23526
24676
 
23527
24677
  | Key | Action |
@@ -23532,6 +24682,8 @@ A native `table` with a visually hidden `caption` from `label`, `th scope="col"`
23532
24682
  | <kbd>Space</kbd> | Toggles the focused checkbox, or sorts by the focused header |
23533
24683
  | <kbd>Enter</kbd> | Sorts by the focused header |
23534
24684
 
24685
+ A stacked table's Sort by select is a native select with its own keys.
24686
+
23535
24687
  Text fields, selects and composite widgets (radios, comboboxes, sliders) in a cell keep their own arrow keys.
23536
24688
 
23537
24689
  ## Inputs
@@ -23545,6 +24697,7 @@ Text fields, selects and composite widgets (radios, comboboxes, sliders) in a ce
23545
24697
  | `selectable` | `boolean` | `false` | Adds a first column of checkboxes that select rows. |
23546
24698
  | `selectAllLabel` | `string` | `''` | Accessible name of the header checkbox; empty falls back to `provideBfLabels()`'s `tableSelectAll` ("Select all rows"). |
23547
24699
  | `size` | `BfSize \| undefined` (`'sm' \| 'md' \| 'lg' \| undefined`) | `undefined` | Row height step: sm 40, md 48, lg 56 px (the header 32/40/48; at least 44 px under a coarse pointer); unset follows `provideBfDefaults()` (`controlSize`, `md` by default). |
24700
+ | `stack` | `BfTableStack` (`'never' \| 'narrow' \| 'coarse'`) | `'never'` | When the rows render as cards of label/value pairs: `never`, `narrow` (the table is narrower than 36rem) or `coarse` (under a coarse pointer, at any width). |
23548
24701
  | `sort` | `BfTableSort \| null` | `null` | The sorted column and direction, `null` while unsorted; two-way bindable. A sortable header sets it, and the consumer sorts `rows` from it. Two-way: `[(sort)]`. |
23549
24702
  | `selection` | `readonly string[]` | `[]` | The keys (`rowKey`) of the selected rows; two-way bindable. Two-way: `[(selection)]`. |
23550
24703
 
@@ -23585,15 +24738,31 @@ Selects or clears one row.
23585
24738
 
23586
24739
  - Give every table a `label`, and every sortable column a short header.
23587
24740
  - Sort and filter the rows in a `computed()` from `sort` and your filters.
23588
- - Render the rows as `bf-list-item`s below `md` and inside the Capacitor shell.
24741
+ - Use `stack="narrow"` for a table that may sit in a narrow column, or `stack="coarse"` in the Capacitor shell; otherwise render the rows as `bf-list-item`s below `md`.
23589
24742
 
23590
24743
  ### Don't
23591
24744
 
23592
- - Don't put a table on a phone screen that scrolls sideways; use the list.
24745
+ - Don't put a table on a phone screen that scrolls sideways; stack it or use the list.
24746
+ - Don't put a stacking table in a parent that sizes it from its content: the container query needs a width from outside.
23593
24747
  - Don't make a whole row clickable; put a link or button in a custom cell.
23594
24748
 
23595
24749
  ## Migration notes
23596
24750
 
24751
+ ### 0.3.2
24752
+
24753
+ New opt-in `stack` input (`BfTableStack`: `'never'` by default, so existing tables do not
24754
+ change, `'narrow'` or `'coarse'`): while the table is narrower than 36rem (a container query on
24755
+ the host, which then gets `@container`) or, for `'coarse'`, under a coarse pointer at any width,
24756
+ each body row renders as a sunken tile of label/value pairs. The markup stays a `table` with
24757
+ explicit `table`, `rowgroup`, `row`, `columnheader` and `cell` roles (some browsers drop a
24758
+ table's semantics once its display changes), the header row is visually hidden but still read,
24759
+ the selection checkbox and the first cell head each tile, and every other cell shows its column
24760
+ label (`data-label`, a `::before` with empty alternative text, so it is not read twice). A bar
24761
+ above the tiles holds a labelled Select all checkbox and a Sort by `bf-select`, since the hidden
24762
+ header's controls are not shown. New label keys `tableSortBy` ("Sort by"), `tableSortAscending`
24763
+ and `tableSortDescending` (`"Hours, ascending"`). `BfTableHarness` reads and drives a stacked
24764
+ table the same way. New `table/stacked` example and a Stacked rows docs section.
24765
+
23597
24766
  ### 0.3.1
23598
24767
 
23599
24768
  New `bf-table` and `ng-template[bfTableCell]` (`BfTableCellDirective`) in `@bitforgehq/ui`, a
@@ -23808,7 +24977,7 @@ fires no keyboard event).
23808
24977
 
23809
24978
  ## Desktop
23810
24979
 
23811
- `bf-tabs` is part of the Capacitor shell (`provideBfShell()`). On a desktop web app, put the top-level destinations in a [bf-top-nav](https://bitforge-ui.web.app/components/top-nav) (a few sections) or a [bf-sidebar](https://bitforge-ui.web.app/components/sidebar) (more, in groups, collapsible to an icon rail) of `a[bfNavItem]` links, each with `routerLink`, `routerLinkActive` and `ariaCurrentWhenActive="page"`, above a plain `router-outlet`. The router sets `aria-current="page"` on the link of the current route and the item shows it. Drive both from one array of destinations: inside the shell the same array feeds the `bf-tab`s of a [bf-tab-bar](https://bitforge-ui.web.app/components/tab-bar), and below a breakpoint a [bf-menu-button](https://bitforge-ui.web.app/components/menu-button) of `a[bfMenuItem]` links (see bf-sidebar, Responsive). Inside the shell under a mouse, the tab links take the `bf-tab` hover and focus styles.
24980
+ `bf-tabs` is part of the Capacitor shell (`provideBfShell()`). On a desktop web app, put the top-level destinations in a [bf-top-nav](https://bitforge-ui.web.app/components/top-nav) (a few sections) or a [bf-sidebar](https://bitforge-ui.web.app/components/sidebar) (more, in groups, collapsible to an icon rail) of `a[bfNavItem]` links, each with `routerLink`, `routerLinkActive` and `ariaCurrentWhenActive="page"`, above a plain `router-outlet`. The router sets `aria-current="page"` on the link of the current route and the item shows it. Drive both from one array of destinations: inside the shell the same array feeds the `bf-tab`s of a [bf-tab-bar](https://bitforge-ui.web.app/components/tab-bar), and below a breakpoint a [bf-menu-button](https://bitforge-ui.web.app/components/menu-button) of `a[bfMenuItem]` links (see bf-sidebar, Responsive). Wrap the desktop app in a [bf-app-frame](https://bitforge-ui.web.app/components/app-frame): it holds the sidebar from `md` up and, below it, a header bar with that menu button. Inside the shell under a mouse, the tab links take the `bf-tab` hover and focus styles.
23812
24981
 
23813
24982
  ```ts
23814
24983
  import { Component } from '@angular/core';
@@ -23979,6 +25148,10 @@ content to the top.
23979
25148
 
23980
25149
  ## Migration notes
23981
25150
 
25151
+ ### 0.3.2
25152
+
25153
+ The `## Desktop` docs point at `bf-app-frame` for the frame around the desktop navigation.
25154
+
23982
25155
  ### 0.3.1
23983
25156
 
23984
25157
  The `## Desktop` docs name the desktop replacement: a `bf-top-nav` or `bf-sidebar` of
@@ -26142,7 +27315,7 @@ export class ToolbarStatusExampleComponent {
26142
27315
 
26143
27316
  ## Wrapping
26144
27317
 
26145
- The row is a wrapping flex row with an 8 px gap between items and between rows. In a narrow column the filters wrap onto new rows, and the actions wrap as one group, kept together at the inline end. Give a search field a width (`class="w-full sm:w-56"`) so it fills a phone-wide row and stays compact on a wide one.
27318
+ The row is a wrapping flex row with an 8 px gap between items and between rows. The status and the actions are one group that takes the rest of the last row: on a wide row the status follows the view switch and the actions sit at the inline end; when the group no longer fits beside the filters, it wraps onto a row of its own as a whole, with the status at the start and the actions at the end. So the actions never end up alone on a row, and they stay on the last filter row whenever the group fits there. Give a search field a width (`class="w-full sm:w-56"`) so it fills a phone-wide row and stays compact on a wide one.
26146
27319
 
26147
27320
  ```ts
26148
27321
  import { Component } from '@angular/core';
@@ -26157,8 +27330,8 @@ import {
26157
27330
  /**
26158
27331
  * A toolbar that wraps.
26159
27332
  *
26160
- * In a narrow column the filters wrap onto new rows 8 px apart, and the actions move down
26161
- * together, still at the inline end.
27333
+ * In a narrow column the filters wrap onto new rows 8 px apart, and the status and the actions
27334
+ * move down together: the count at the start of the last row, the actions at the inline end.
26162
27335
  */
26163
27336
  @Component({
26164
27337
  selector: 'app-example-toolbar-wrapping',
@@ -26187,6 +27360,63 @@ export class ToolbarWrappingExampleComponent {
26187
27360
  }
26188
27361
  ```
26189
27362
 
27363
+ ## Phone width
27364
+
27365
+ At phone width (in a desktop web app's narrow window or a narrow column) the search takes a row, the filters share the next, and the status and actions share the last row. A toolbar without actions keeps the status as a plain item after the filters.
27366
+
27367
+ ```ts
27368
+ import { Component, signal } from '@angular/core';
27369
+ import {
27370
+ BfButtonComponent,
27371
+ BfInputComponent,
27372
+ BfSelectComponent,
27373
+ BfToolbarComponent,
27374
+ type BfSelectOption,
27375
+ } from '@bitforgehq/ui';
27376
+
27377
+ /**
27378
+ * A toolbar at phone width.
27379
+ *
27380
+ * In a phone-wide column the search fills its own row, the two filters share the next one, and
27381
+ * the status and the Refresh action share the last row: the count at the start, the action at the
27382
+ * end, never alone on a row of its own.
27383
+ */
27384
+ @Component({
27385
+ selector: 'app-example-toolbar-narrow',
27386
+ imports: [BfButtonComponent, BfInputComponent, BfSelectComponent, BfToolbarComponent],
27387
+ template: `
27388
+ <div class="max-w-xs">
27389
+ <bf-toolbar label="Order filters" [status]="count()">
27390
+ <bf-input
27391
+ type="search"
27392
+ ariaLabel="Search orders"
27393
+ placeholder="Search orders"
27394
+ class="w-full"
27395
+ />
27396
+ <bf-select ariaLabel="Status" [options]="statuses" value="open" />
27397
+ <bf-select ariaLabel="Period" [options]="periods" value="week" />
27398
+ <bf-button bfSlot="actions" variant="outline" (clicked)="refresh()">Refresh</bf-button>
27399
+ </bf-toolbar>
27400
+ </div>
27401
+ `,
27402
+ })
27403
+ export class ToolbarNarrowExampleComponent {
27404
+ protected readonly count = signal('12 orders');
27405
+ protected readonly statuses: BfSelectOption[] = [
27406
+ { value: 'open', label: 'Open' },
27407
+ { value: 'shipped', label: 'Shipped' },
27408
+ ];
27409
+ protected readonly periods: BfSelectOption[] = [
27410
+ { value: 'week', label: 'This week' },
27411
+ { value: 'month', label: 'This month' },
27412
+ ];
27413
+
27414
+ protected refresh(): void {
27415
+ this.count.set('12 orders, updated');
27416
+ }
27417
+ }
27418
+ ```
27419
+
26190
27420
  ## Desktop
26191
27421
 
26192
27422
  `bf-toolbar` is a desktop pattern. Every control and field projected into it resolves its unset `size` to `sm` (32 px tall), because its `providers` set `provideBfDefaults({ size: 'sm' })` for its content; a control that sets `size` itself keeps it, and nothing outside the toolbar changes ([density guide](https://bitforge-ui.web.app/density)). Hover, the pointer cursor and the `focus-visible` ring come from each control; the row adds none of its own. Under a fine pointer each control's visual box is its hit area; under a coarse pointer the `sm` touch extensions reach up to 7.5 px past the visual edge, which the 8 px gap keeps off the neighbouring row.
@@ -26217,7 +27447,7 @@ The `status` element is `role="status"` (an `aria-live="polite"` region), always
26217
27447
 
26218
27448
  - `default`: The search field and the filters (bf-input, bf-select, bf-chip-group), in order.
26219
27449
  - `view`: A view switch after the filters, such as a bf-segmented of list and grid.
26220
- - `actions`: The trailing actions, kept together at the inline end of the row.
27450
+ - `actions`: The trailing actions, kept together at the inline end of the row, beside the status.
26221
27451
 
26222
27452
  ## Notes
26223
27453
 
@@ -26233,6 +27463,14 @@ The `status` element is `role="status"` (an `aria-live="polite"` region), always
26233
27463
 
26234
27464
  ## Migration notes
26235
27465
 
27466
+ ### 0.3.2
27467
+
27468
+ On a narrow row the actions no longer end up alone on a row of their own: the status and the
27469
+ actions are now one group that takes the rest of the last row and wraps as a whole, with the
27470
+ status at the start and the actions at the inline end. The wide layout is unchanged, and a
27471
+ toolbar without actions keeps the status as a plain item of the row. New `toolbar/narrow`
27472
+ example at phone width.
27473
+
26236
27474
  ### 0.3.1
26237
27475
 
26238
27476
  New `bf-toolbar` in `@bitforgehq/ui`: a wrapping `role="group"` row for the search field and
@@ -26759,6 +27997,29 @@ const BF_LABELS: InjectionToken<BfLabels>
26759
27997
 
26760
27998
  ---
26761
27999
 
28000
+ # BF_SIDEBAR_STATE_STORAGE
28001
+
28002
+ Injection token for the store that keeps the collapsed sidebar between visits
28003
+
28004
+ Injection token from `@bitforgehq/ui` · since 0.3.2
28005
+
28006
+ Where `BfSidebarStateService` keeps whether the sidebar is collapsed (the key
28007
+ `bf-sidebar-collapsed`, `'true'` or `'false'`): `localStorage` when it is available, otherwise
28008
+ an in-memory map. Provide another store (`sessionStorage`, or an in-memory one in tests) to keep
28009
+ it elsewhere.
28010
+
28011
+ ```ts
28012
+ import { BF_SIDEBAR_STATE_STORAGE } from '@bitforgehq/ui';
28013
+ ```
28014
+
28015
+ ## Signature
28016
+
28017
+ ```ts
28018
+ const BF_SIDEBAR_STATE_STORAGE: InjectionToken<Pick<Storage, "getItem" | "setItem">>
28019
+ ```
28020
+
28021
+ ---
28022
+
26762
28023
  # BF_TAB_BAR
26763
28024
 
26764
28025
  Injection token through which each tab reaches its enclosing tab bar
@@ -26886,6 +28147,68 @@ type BfAlertVariant = 'neutral' | 'success' | 'warning' | 'danger'
26886
28147
 
26887
28148
  ---
26888
28149
 
28150
+ # BfAppFrameBreakpoint
28151
+
28152
+ The breakpoint where an app frame switches from its header bar to the sidebar
28153
+
28154
+ Type from `@bitforgehq/ui` · since 0.3.2
28155
+
28156
+ The viewport width from which `bf-app-frame` shows its sidebar column instead of the narrow
28157
+ header bar: Tailwind's `md` (768 px) or `lg` (1024 px).
28158
+
28159
+ ```ts
28160
+ import { type BfAppFrameBreakpoint } from '@bitforgehq/ui';
28161
+ ```
28162
+
28163
+ ## Signature
28164
+
28165
+ ```ts
28166
+ type BfAppFrameBreakpoint = 'md' | 'lg'
28167
+ ```
28168
+
28169
+ ## Values
28170
+
28171
+ - `md`
28172
+ - `lg`
28173
+
28174
+ ## Used by
28175
+
28176
+ - [bf-app-frame](../components/app-frame.md)
28177
+
28178
+ ---
28179
+
28180
+ # BfAppFrameContentWidth
28181
+
28182
+ The maximum width of an app frame's content column, from medium to full
28183
+
28184
+ Type from `@bitforgehq/ui` · since 0.3.2
28185
+
28186
+ The maximum width of `bf-app-frame`'s content column: `md` 48rem, `lg` 64rem, `xl` 80rem, or
28187
+ `full` for no limit.
28188
+
28189
+ ```ts
28190
+ import { type BfAppFrameContentWidth } from '@bitforgehq/ui';
28191
+ ```
28192
+
28193
+ ## Signature
28194
+
28195
+ ```ts
28196
+ type BfAppFrameContentWidth = 'md' | 'lg' | 'xl' | 'full'
28197
+ ```
28198
+
28199
+ ## Values
28200
+
28201
+ - `md`
28202
+ - `lg`
28203
+ - `xl`
28204
+ - `full`
28205
+
28206
+ ## Used by
28207
+
28208
+ - [bf-app-frame](../components/app-frame.md)
28209
+
28210
+ ---
28211
+
26889
28212
  # BfAutocapitalize
26890
28213
 
26891
28214
  How a phone keyboard capitalises the text typed into a field
@@ -27555,6 +28878,42 @@ type BfDescriptionListLayout = 'rows' | 'grid'
27555
28878
 
27556
28879
  ---
27557
28880
 
28881
+ # BfEmptyStateHeading
28882
+
28883
+ The heading level of an empty state title, one for a whole page
28884
+
28885
+ Type from `@bitforgehq/ui` · since 0.3.2
28886
+
28887
+ The heading level a `bf-empty-state` title takes: a `BfEmptyStateHeadingLevel`, or `1` when the
28888
+ empty state is the page itself (a not-found or no-access page). A new type, so `Record`s over
28889
+ the released `BfEmptyStateHeadingLevel` keep compiling.
28890
+
28891
+ ```ts
28892
+ import { type BfEmptyStateHeading } from '@bitforgehq/ui';
28893
+ ```
28894
+
28895
+ ## Signature
28896
+
28897
+ ```ts
28898
+ type BfEmptyStateHeading = BfEmptyStateHeadingLevel | 1
28899
+ ```
28900
+
28901
+ ## Values
28902
+
28903
+ - `2`
28904
+ - `3`
28905
+ - `1`
28906
+
28907
+ ## Uses
28908
+
28909
+ - [BfEmptyStateHeadingLevel](bf-empty-state-heading-level.md)
28910
+
28911
+ ## Used by
28912
+
28913
+ - [bf-empty-state](../components/empty-state.md)
28914
+
28915
+ ---
28916
+
27558
28917
  # BfEmptyStateHeadingLevel
27559
28918
 
27560
28919
  The heading level of an empty state title, two for a screen or three
@@ -27580,7 +28939,7 @@ type BfEmptyStateHeadingLevel = 2 | 3
27580
28939
 
27581
28940
  ## Used by
27582
28941
 
27583
- - [bf-empty-state](../components/empty-state.md)
28942
+ - [BfEmptyStateHeading](bf-empty-state-heading.md)
27584
28943
 
27585
28944
  ---
27586
28945
 
@@ -27975,6 +29334,9 @@ import { type BfLabels } from '@bitforgehq/ui';
27975
29334
  | `dialog` | `string` | `'Dialog'` | A dialog's or side panel's name without an ariaLabel, a title or a heading. |
27976
29335
  | `notifications` | `string` | `'Notifications'` | The desktop toast stack's region name. |
27977
29336
  | `tableSelectAll` | `string` | `'Select all rows'` | bf-table's header checkbox, which selects or clears every row. |
29337
+ | `tableSortBy` | `string` | `'Sort by'` | The label of a stacked bf-table's sort select. |
29338
+ | `tableSortAscending` | `(column: string) => string` | `` (column) => `${column}, ascending` `` | A stacked bf-table's sort option for a column in ascending order. |
29339
+ | `tableSortDescending` | `(column: string) => string` | `` (column) => `${column}, descending` `` | A stacked bf-table's sort option for a column in descending order. |
27978
29340
  | `theme` | `string` | `'Theme'` | bf-theme-switch's and bf-theme-menu-button's name without a label. |
27979
29341
  | `themeSystem` | `string` | `'System'` | The theme choice that follows the system's colour scheme. |
27980
29342
  | `themeLight` | `string` | `'Light'` | The theme choice that forces the light colour scheme. |
@@ -27983,6 +29345,9 @@ import { type BfLabels } from '@bitforgehq/ui';
27983
29345
  | `breadcrumbExpand` | `(count: number) => string` | `` (count) => (count === 1 ? 'Show 1 more level' : `Show ${count} more levels`) `` | bf-breadcrumb's ellipsis button, which shows the levels a `maxItems` trail hides. |
27984
29346
  | `navigation` | `string` | `'Main'` | bf-sidebar's and bf-top-nav's navigation name. |
27985
29347
  | `navBadge` | `(count: number) => string` | `` (count) => `${count} new` `` | a[bfNavItem]'s announcement of its count badge. |
29348
+ | `sidebarCollapse` | `string` | `'Collapse sidebar'` | bf-sidebar-toggle's name and tooltip while the sidebar is expanded. |
29349
+ | `sidebarExpand` | `string` | `'Expand sidebar'` | bf-sidebar-toggle's name and tooltip while the sidebar is an icon rail. |
29350
+ | `skipToContent` | `string` | `'Skip to main content'` | bf-app-frame's skip link, the first stop of the tab order, which moves focus to the content. |
27986
29351
  | `pagination` | `string` | `'Pagination'` | bf-pagination's navigation name. |
27987
29352
  | `previousPage` | `string` | `'Previous page'` | bf-pagination's button to the previous page. |
27988
29353
  | `nextPage` | `string` | `'Next page'` | bf-pagination's button to the next page. |
@@ -28311,6 +29676,30 @@ interface BfSelectOption<T = string>
28311
29676
 
28312
29677
  ---
28313
29678
 
29679
+ # BfSidebarStateService
29680
+
29681
+ The sidebar's collapsed state, kept in storage between visits of the app
29682
+
29683
+ Service from `@bitforgehq/ui` · since 0.3.2
29684
+
29685
+ Whether the app's `bf-sidebar` is collapsed to an icon rail, remembered between visits: the app
29686
+ binds the sidebar's `[(collapsed)]` to `sidebarState.collapsed`. With `provideBfSidebarState()`
29687
+ every change is stored in `BF_SIDEBAR_STATE_STORAGE` under `bf-sidebar-collapsed`; without it
29688
+ the signal still holds the state but nothing is written. Storage is read in the browser only, so a
29689
+ server render starts expanded and writes nothing. The widgets never inject it.
29690
+
29691
+ ```ts
29692
+ import { BfSidebarStateService } from '@bitforgehq/ui';
29693
+ ```
29694
+
29695
+ ## Properties
29696
+
29697
+ | Name | Type | Default | Description |
29698
+ | --- | --- | --- | --- |
29699
+ | `collapsed` | `WritableSignal<boolean>` | | Whether the sidebar is collapsed: the stored state in the browser, else false. Bind it two-way to `bf-sidebar`'s `collapsed`. |
29700
+
29701
+ ---
29702
+
28314
29703
  # BfSize
28315
29704
 
28316
29705
  The three height steps every sized field and control shares
@@ -28361,6 +29750,7 @@ type BfSize = 'sm' | 'md' | 'lg'
28361
29750
  - [bf-segmented](../components/segmented.md)
28362
29751
  - [BfSegmentedSize](bf-segmented-size.md)
28363
29752
  - [bf-select](../components/select.md)
29753
+ - [bf-sidebar-toggle](../components/sidebar-toggle.md)
28364
29754
  - [bf-tab-group](../components/tab-group.md)
28365
29755
  - [bf-table](../components/table.md)
28366
29756
  - [bf-textarea](../components/textarea.md)
@@ -28432,6 +29822,42 @@ type BfSpinnerSize = 'sm' | 'md' | 'lg'
28432
29822
 
28433
29823
  ---
28434
29824
 
29825
+ # BfStatAppearance
29826
+
29827
+ The three stat surfaces, plain, an outlined card or a sunken tile
29828
+
29829
+ Type from `@bitforgehq/ui` · since 0.3.2
29830
+
29831
+ The surface of a `bf-stat`, as its `variant` input takes it: a `BfStatVariant` or tile (a
29832
+ sunken, borderless tile for a stats row inside a raised card). A new type, so `Record`s over
29833
+ the released `BfStatVariant` keep compiling.
29834
+
29835
+ ```ts
29836
+ import { type BfStatAppearance } from '@bitforgehq/ui';
29837
+ ```
29838
+
29839
+ ## Signature
29840
+
29841
+ ```ts
29842
+ type BfStatAppearance = BfStatVariant | 'tile'
29843
+ ```
29844
+
29845
+ ## Values
29846
+
29847
+ - `plain`
29848
+ - `card`
29849
+ - `tile`
29850
+
29851
+ ## Uses
29852
+
29853
+ - [BfStatVariant](bf-stat-variant.md)
29854
+
29855
+ ## Used by
29856
+
29857
+ - [bf-stat](../components/stat.md)
29858
+
29859
+ ---
29860
+
28435
29861
  # BfStatTrend
28436
29862
 
28437
29863
  The three directions of a stat's delta pill, up, down or no change
@@ -28517,7 +29943,7 @@ type BfStatVariant = 'plain' | 'card'
28517
29943
 
28518
29944
  ## Used by
28519
29945
 
28520
- - [bf-stat](../components/stat.md)
29946
+ - [BfStatAppearance](bf-stat-appearance.md)
28521
29947
 
28522
29948
  ---
28523
29949
 
@@ -28825,6 +30251,38 @@ type BfTableSortDirection = 'ascending' | 'descending'
28825
30251
 
28826
30252
  ---
28827
30253
 
30254
+ # BfTableStack
30255
+
30256
+ When a table stacks its rows into cards: never, when narrow, or under touch
30257
+
30258
+ Type from `@bitforgehq/ui` · since 0.3.2
30259
+
30260
+ When a `bf-table` renders each row as a card of label/value pairs: `never` (the default),
30261
+ `narrow` while the table is narrower than 36rem (a container query), or `coarse` under a coarse
30262
+ pointer at any width.
30263
+
30264
+ ```ts
30265
+ import { type BfTableStack } from '@bitforgehq/ui';
30266
+ ```
30267
+
30268
+ ## Signature
30269
+
30270
+ ```ts
30271
+ type BfTableStack = 'never' | 'narrow' | 'coarse'
30272
+ ```
30273
+
30274
+ ## Values
30275
+
30276
+ - `never`
30277
+ - `narrow`
30278
+ - `coarse`
30279
+
30280
+ ## Used by
30281
+
30282
+ - [bf-table](../components/table.md)
30283
+
30284
+ ---
30285
+
28828
30286
  # BfTabLinks
28829
30287
 
28830
30288
  The URLs a router-aware parent gives a tab bar to render its tabs as links
@@ -29032,9 +30490,10 @@ Function from `@bitforgehq/ui` · since 0.3.0
29032
30490
  Replaces built-in strings, merged over the parent's (validation merged key by key); a key left
29033
30491
  out or set to `undefined` keeps the parent's string. In `ApplicationConfig.providers` it covers
29034
30492
  every string. In a component's `providers` it covers that sub-tree's widgets and form controls
29035
- (`loading`, `dismiss`, `tabBar`, `tabBadge`, `back`, `refreshing`, `tableSelectAll`, `theme`,
29036
- `themeSystem`, `themeLight`, `themeDark`, `breadcrumb`, `breadcrumbExpand`, `navigation`,
29037
- `navBadge`, `pagination`, `previousPage`, `nextPage`, `pageOf`, `goToPage`, `tabGroup`,
30493
+ (`loading`, `dismiss`, `tabBar`, `tabBadge`, `back`, `refreshing`, `tableSelectAll`, `tableSortBy`,
30494
+ `tableSortAscending`, `tableSortDescending`, `theme`, `themeSystem`, `themeLight`, `themeDark`,
30495
+ `breadcrumb`, `breadcrumbExpand`, `navigation`, `navBadge`, `sidebarCollapse`, `sidebarExpand`,
30496
+ `skipToContent`, `pagination`, `previousPage`, `nextPage`, `pageOf`, `goToPage`, `tabGroup`,
29038
30497
  `calendarPreviousMonth`, `calendarNextMonth`, `calendarChooseMonth`, `calendarChooseYear`,
29039
30498
  `statTrendUp`, `statTrendDown`, `statTrendFlat`, `comboboxEmpty`, `comboboxRemove`,
29040
30499
  `dateInputDay`, `dateInputMonth`, `dateInputYear`, `dateInputOpenCalendar`, `dateInputInvalid`,
@@ -29072,6 +30531,29 @@ function provideBfLabels(overrides: BfLabelsOverrides): Provider
29072
30531
 
29073
30532
  ---
29074
30533
 
30534
+ # provideBfSidebarState
30535
+
30536
+ Remembers the collapsed sidebar between visits, for desktop web apps
30537
+
30538
+ Function from `@bitforgehq/ui` · since 0.3.2
30539
+
30540
+ Lets `BfSidebarStateService` remember the collapsed sidebar: it stores every change of its
30541
+ `collapsed` signal (key `bf-sidebar-collapsed`, `localStorage` in the browser), so the app opens
30542
+ with the sidebar as the user left it. Add it to `ApplicationConfig.providers` and bind the
30543
+ sidebar's `[(collapsed)]` to `sidebarState.collapsed`.
30544
+
30545
+ ```ts
30546
+ import { provideBfSidebarState } from '@bitforgehq/ui';
30547
+ ```
30548
+
30549
+ ## Signature
30550
+
30551
+ ```ts
30552
+ function provideBfSidebarState(): EnvironmentProviders
30553
+ ```
30554
+
30555
+ ---
30556
+
29075
30557
  # BF_DESKTOP_TOAST_DEFAULTS
29076
30558
 
29077
30559
  The library defaults of the desktop toast settings, bottom end with three visible
@@ -35794,6 +37276,41 @@ Blurs the native select, which marks a bound form field touched.
35794
37276
 
35795
37277
  ---
35796
37278
 
37279
+ # BfSidebarToggleHarness
37280
+
37281
+ Test harness for a sidebar toggle: reads and flips the collapsed sidebar
37282
+
37283
+ Class from `@bitforgehq/ui/testing` · since 0.3.2
37284
+
37285
+ Harness for `bf-sidebar-toggle`, the button that collapses a `bf-sidebar` to an icon rail. Its
37286
+ `label` filter matches the button's name ("Collapse sidebar" or "Expand sidebar").
37287
+
37288
+ ```ts
37289
+ import { BfSidebarToggleHarness } from '@bitforgehq/ui/testing';
37290
+ ```
37291
+
37292
+ ## Signature
37293
+
37294
+ ```ts
37295
+ class BfSidebarToggleHarness extends ComponentHarness
37296
+ ```
37297
+
37298
+ ## Methods
37299
+
37300
+ ### `getLabel(): Promise<string>`
37301
+
37302
+ The button's name, which says what the next press does.
37303
+
37304
+ ### `isExpanded(): Promise<boolean>`
37305
+
37306
+ Whether the sidebar is at full width (`aria-expanded`).
37307
+
37308
+ ### `toggle(): Promise<void>`
37309
+
37310
+ Flips the sidebar with a click.
37311
+
37312
+ ---
37313
+
35797
37314
  # BfSidePanelHarness
35798
37315
 
35799
37316
  Test harness for a side panel: its title, its close button and its state
@@ -35886,7 +37403,7 @@ Class from `@bitforgehq/ui/testing` · since 0.3.1
35886
37403
 
35887
37404
  Harness for `bf-table`. It works by column header text and row position, so a test never needs
35888
37405
  the column keys or the row keys (except `getSort()`, which returns the column's `key`, as the
35889
- `sort` model holds it).
37406
+ `sort` model holds it). It reads and drives a stacked table (`stack`) the same way.
35890
37407
 
35891
37408
  ```ts
35892
37409
  import { BfTableHarness } from '@bitforgehq/ui/testing';