@bitforgehq/ui 0.3.1 → 0.3.3

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 (49) hide show
  1. package/CHANGELOG.md +157 -0
  2. package/README.md +9 -8
  3. package/ai/MANIFEST.index.json +18 -0
  4. package/ai/MANIFEST.json +380 -31
  5. package/ai/REFERENCE.index.json +63 -0
  6. package/ai/REFERENCE.json +265 -7
  7. package/ai/UI_GUIDE.md +59 -33
  8. package/ai/components/app-frame.md +335 -0
  9. package/ai/components/card.md +56 -2
  10. package/ai/components/empty-state.md +99 -6
  11. package/ai/components/header.md +14 -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 +152 -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 +7 -0
  23. package/ai/reference/bf-select-harness.md +4 -0
  24. package/ai/reference/bf-sidebar-state-service.md +21 -0
  25. package/ai/reference/bf-sidebar-toggle-harness.md +32 -0
  26. package/ai/reference/bf-size.md +1 -0
  27. package/ai/reference/bf-stat-appearance.md +33 -0
  28. package/ai/reference/bf-stat-variant.md +1 -1
  29. package/ai/reference/bf-table-harness.md +7 -1
  30. package/ai/reference/bf-table-stack.md +29 -0
  31. package/ai/reference/bf_sidebar_state_storage.md +20 -0
  32. package/ai/reference/provide-bf-labels.md +4 -3
  33. package/ai/reference/provide-bf-sidebar-state.md +20 -0
  34. package/bin/bf-ui.js +433 -3
  35. package/fesm2022/bitforgehq-ui-mobile.mjs +11 -2
  36. package/fesm2022/bitforgehq-ui-mobile.mjs.map +1 -1
  37. package/fesm2022/bitforgehq-ui-overlay.mjs +571 -446
  38. package/fesm2022/bitforgehq-ui-overlay.mjs.map +1 -1
  39. package/fesm2022/bitforgehq-ui-testing.mjs +22 -2
  40. package/fesm2022/bitforgehq-ui-testing.mjs.map +1 -1
  41. package/fesm2022/bitforgehq-ui.mjs +777 -127
  42. package/fesm2022/bitforgehq-ui.mjs.map +1 -1
  43. package/llms-full.txt +1605 -43
  44. package/llms.txt +11 -0
  45. package/package.json +1 -1
  46. package/theme.css +13 -0
  47. package/types/bitforgehq-ui-overlay.d.ts +51 -0
  48. package/types/bitforgehq-ui-testing.d.ts +49 -1
  49. package/types/bitforgehq-ui.d.ts +284 -20
@@ -141,9 +141,9 @@ export class SidebarOverviewExampleComponent {
141
141
 
142
142
  The vertical navigation of a desktop app: a `nav` landmark with a `header` slot (the brand),
143
143
  the destinations (`a[bfNavItem]` links, optionally in `bf-nav-group`s) and a `footer` slot.
144
- `collapsed` turns it into an icon rail; the consumer owns the toggle button and binds
145
- `[(collapsed)]`. Below a breakpoint hide it (`class="hidden md:flex"`) and drive a
146
- `bf-menu-button` from the same routes.
144
+ `collapsed` turns it into an icon rail: put a `bf-sidebar-toggle` (`@bitforgehq/ui/overlay`) in
145
+ it, or bind `[(collapsed)]` to your own button. Below a breakpoint hide it
146
+ (`class="hidden md:flex"`) and drive a `bf-menu-button` from the same routes.
147
147
 
148
148
  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.
149
149
 
@@ -157,11 +157,178 @@ Put `bfNavItem` on your own `<a routerLink routerLinkActive ariaCurrentWhenActiv
157
157
 
158
158
  ## Collapsing to an icon rail
159
159
 
160
- `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).
160
+ `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).
161
+
162
+ ## Remember the collapsed sidebar
163
+
164
+ `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.
165
+
166
+ ```ts
167
+ import { Component, inject } from '@angular/core';
168
+ import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
169
+ import { BfNavItemComponent, BfSidebarComponent, BfSidebarStateService } from '@bitforgehq/ui';
170
+ import { BfIconComponent } from '@bitforgehq/ui/icons';
171
+ import { BfSidebarToggleComponent } from '@bitforgehq/ui/overlay';
172
+ import { Inbox, PanelLeftClose, PanelLeftOpen } from 'lucide';
173
+
174
+ /**
175
+ * A sidebar that opens collapsed or expanded, as the user left it.
176
+ *
177
+ * `[(collapsed)]` binds `BfSidebarStateService.collapsed`; with `provideBfSidebarState()` in the
178
+ * app's providers every change is stored under `bf-sidebar-collapsed` (`localStorage` in the
179
+ * browser) and read back at the next start.
180
+ *
181
+ * @preview none
182
+ */
183
+ @Component({
184
+ selector: 'app-example-sidebar-remembered',
185
+ imports: [
186
+ RouterLink,
187
+ RouterLinkActive,
188
+ RouterOutlet,
189
+ BfIconComponent,
190
+ BfNavItemComponent,
191
+ BfSidebarComponent,
192
+ BfSidebarToggleComponent,
193
+ ],
194
+ template: `
195
+ <div class="flex min-h-dvh">
196
+ <bf-sidebar [(collapsed)]="sidebarState.collapsed" class="sticky top-0 h-dvh">
197
+ <a bfNavItem routerLink="/inbox" routerLinkActive ariaCurrentWhenActive="page">
198
+ <bf-icon bfSlot="icon" [icon]="inbox" />
199
+ Inbox
200
+ </a>
201
+ <bf-sidebar-toggle bfSlot="footer">
202
+ <bf-icon bfSlot="collapse-icon" [icon]="panelClose" />
203
+ <bf-icon bfSlot="expand-icon" [icon]="panelOpen" />
204
+ </bf-sidebar-toggle>
205
+ </bf-sidebar>
206
+ <main class="min-w-0 flex-1 p-6"><router-outlet /></main>
207
+ </div>
208
+ `,
209
+ })
210
+ export class SidebarRememberedExampleComponent {
211
+ // app.config.ts: providers: [provideBfSidebarState()]
212
+ protected readonly sidebarState = inject(BfSidebarStateService);
213
+ protected readonly inbox = Inbox;
214
+ protected readonly panelOpen = PanelLeftOpen;
215
+ protected readonly panelClose = PanelLeftClose;
216
+ }
217
+ ```
218
+
219
+ ## Footer
220
+
221
+ 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.
222
+
223
+ ```ts
224
+ import { Component, computed, signal } from '@angular/core';
225
+ import { RouterLink, RouterLinkActive } from '@angular/router';
226
+ import { type BfColorScheme, BfNavItemComponent, BfSidebarComponent } from '@bitforgehq/ui';
227
+ import { BfIconComponent } from '@bitforgehq/ui/icons';
228
+ import { BfSidebarToggleComponent, BfThemeMenuButtonComponent } from '@bitforgehq/ui/overlay';
229
+ import {
230
+ Inbox,
231
+ type IconNode,
232
+ Moon,
233
+ PanelLeftClose,
234
+ PanelLeftOpen,
235
+ Settings,
236
+ Sun,
237
+ SunMoon,
238
+ Users,
239
+ } from 'lucide';
240
+
241
+ interface NavLink {
242
+ readonly path: string;
243
+ readonly label: string;
244
+ readonly icon: IconNode;
245
+ }
246
+
247
+ /**
248
+ * A sidebar footer: settings, theme, toggle and version.
249
+ *
250
+ * The footer slot holds a Settings link (`a[bfNavItem]`), a row with an icon-only
251
+ * `bf-theme-menu-button` and the `bf-sidebar-toggle`, and a version line. Expanded, the two
252
+ * buttons sit at either end of the row; as an icon rail the row stacks vertically and the version
253
+ * line hides. The theme binds a local signal so the docs site keeps its theme; an app binds
254
+ * `BfColorSchemeService.scheme`.
255
+ */
256
+ @Component({
257
+ selector: 'app-example-sidebar-footer',
258
+ imports: [
259
+ RouterLink,
260
+ RouterLinkActive,
261
+ BfIconComponent,
262
+ BfNavItemComponent,
263
+ BfSidebarComponent,
264
+ BfSidebarToggleComponent,
265
+ BfThemeMenuButtonComponent,
266
+ ],
267
+ template: `
268
+ <div class="border-border bg-bg rounded-card flex h-[26rem] overflow-hidden border">
269
+ <bf-sidebar label="Footer demo" [(collapsed)]="collapsed">
270
+ @for (link of links; track link.path) {
271
+ <a bfNavItem [routerLink]="link.path" routerLinkActive ariaCurrentWhenActive="page">
272
+ <bf-icon bfSlot="icon" [icon]="link.icon" />
273
+ {{ link.label }}
274
+ </a>
275
+ }
276
+ <a
277
+ bfSlot="footer"
278
+ bfNavItem
279
+ routerLink="/theming"
280
+ routerLinkActive
281
+ ariaCurrentWhenActive="page"
282
+ >
283
+ <bf-icon bfSlot="icon" [icon]="settings" />
284
+ Settings
285
+ </a>
286
+ <div bfSlot="footer" [class]="rowClasses()">
287
+ <bf-theme-menu-button iconOnly variant="text" placement="above-start" [(value)]="scheme">
288
+ <bf-icon bfSlot="icon" [icon]="themeIcon()" />
289
+ </bf-theme-menu-button>
290
+ <bf-sidebar-toggle>
291
+ <bf-icon bfSlot="collapse-icon" [icon]="panelClose" />
292
+ <bf-icon bfSlot="expand-icon" [icon]="panelOpen" />
293
+ </bf-sidebar-toggle>
294
+ </div>
295
+ <p bfSlot="footer" class="text-caption text-text-muted ps-3" [class.hidden]="collapsed()">
296
+ Version 0.3.2
297
+ </p>
298
+ </bf-sidebar>
299
+ <div class="min-w-0 flex-1 p-4">
300
+ <p data-status aria-live="polite" class="text-caption text-text-muted wrap-anywhere">
301
+ {{ collapsed() ? 'Icon rail' : 'Expanded' }}, theme {{ scheme() }}.
302
+ </p>
303
+ </div>
304
+ </div>
305
+ `,
306
+ })
307
+ export class SidebarFooterExampleComponent {
308
+ readonly collapsed = signal(false);
309
+ /** Stands in for `BfColorSchemeService.scheme`. */
310
+ readonly scheme = signal<BfColorScheme>('system');
311
+
312
+ /** Expanded: one row, the buttons at either end. Collapsed: a column in the rail. */
313
+ protected readonly rowClasses = computed(() =>
314
+ this.collapsed() ? 'flex flex-col items-center gap-1' : 'flex items-center justify-between',
315
+ );
316
+ protected readonly themeIcon = computed(
317
+ () => ({ system: SunMoon, light: Sun, dark: Moon })[this.scheme()],
318
+ );
319
+ protected readonly links: readonly NavLink[] = [
320
+ { path: '/components/nav-item', label: 'Inbox', icon: Inbox },
321
+ { path: '/components/nav-group', label: 'Team', icon: Users },
322
+ ];
323
+ protected readonly settings = Settings;
324
+ protected readonly panelOpen = PanelLeftOpen;
325
+ protected readonly panelClose = PanelLeftClose;
326
+ }
327
+ ```
161
328
 
162
329
  ## Responsive layouts
163
330
 
164
- 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`.
331
+ 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.
165
332
 
166
333
  ```ts
167
334
  import { Component } from '@angular/core';
@@ -319,7 +486,7 @@ The sidebar is a desktop pattern. Its items take a `surface-muted` fill and the
319
486
 
320
487
  ## Accessibility
321
488
 
322
- `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.
489
+ `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.
323
490
 
324
491
  ### Keyboard
325
492
 
@@ -330,10 +497,10 @@ The sidebar is a desktop pattern. Its items take a `surface-muted` fill and the
330
497
 
331
498
  ## Inputs
332
499
 
333
- | Name | Type | Default | Description |
334
- | ----------- | --------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
335
- | `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. |
336
- | `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. |
500
+ | Name | Type | Default | Description |
501
+ | ----------- | --------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
502
+ | `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. |
503
+ | `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. |
337
504
 
338
505
  ## Slots
339
506
 
@@ -347,6 +514,7 @@ The sidebar is a desktop pattern. Its items take a `surface-muted` fill and the
347
514
 
348
515
  - Mark the current page with `routerLinkActive` and `ariaCurrentWhenActive="page"` on every link.
349
516
  - Drive the sidebar, the small-screen menu and the shell's tab bar from one array of destinations.
517
+ - Remember the collapsed state with `BfSidebarStateService` and `provideBfSidebarState()`.
350
518
 
351
519
  ### Don't
352
520
 
@@ -355,6 +523,16 @@ The sidebar is a desktop pattern. Its items take a `surface-muted` fill and the
355
523
 
356
524
  ## Migration notes
357
525
 
526
+ ### 0.3.2
527
+
528
+ New `BfSidebarStateService` (a `collapsed` signal) and `provideBfSidebarState()` in
529
+ `@bitforgehq/ui`: bind the sidebar's `[(collapsed)]` to `sidebarState.collapsed` and the provider
530
+ stores every change under `bf-sidebar-collapsed` in `BF_SIDEBAR_STATE_STORAGE` (`localStorage` in
531
+ the browser, memory elsewhere), read back at the next start; without the provider nothing is
532
+ written, and on the server nothing is read. The sidebar's `nav` now has an id, for a toggle's
533
+ `aria-controls`. The docs gain "Remember the collapsed sidebar" and a "Footer" composition
534
+ (Settings link, theme menu, toggle, version line) that works expanded and as an icon rail.
535
+
358
536
  ### 0.3.1
359
537
 
360
538
  New `bf-sidebar`: the vertical navigation of a desktop app, a `nav` landmark with `header`,
@@ -61,7 +61,7 @@ Use `bf-stat` for a few headline figures the reader should take in at a glance,
61
61
 
62
62
  ## Cards
63
63
 
64
- `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.
64
+ `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.
65
65
 
66
66
  ```ts
67
67
  import { Component } from '@angular/core';
@@ -103,6 +103,60 @@ export class StatOverviewExampleComponent {
103
103
  }
104
104
  ```
105
105
 
106
+ ## Tiles
107
+
108
+ `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.
109
+
110
+ ```ts
111
+ import { Component } from '@angular/core';
112
+ import { BfCardComponent, BfStatComponent } from '@bitforgehq/ui';
113
+
114
+ /**
115
+ * A dashboard with one surface per level.
116
+ *
117
+ * The page holds raised cards, and every card holds sunken stat tiles: each level has one surface
118
+ * style, so the eye reads the cards as the groups and the tiles as their figures.
119
+ */
120
+ @Component({
121
+ selector: 'app-example-stat-dashboard',
122
+ imports: [BfCardComponent, BfStatComponent],
123
+ template: `
124
+ <div class="grid gap-4 @lg:grid-cols-2">
125
+ <bf-card>
126
+ <h3 class="text-heading">Sales</h3>
127
+ <div class="grid grid-cols-[repeat(auto-fit,minmax(9rem,1fr))] gap-3">
128
+ <bf-stat label="Revenue" value="€48,200" variant="tile" delta="+12%" trend="up">
129
+ vs. last month
130
+ </bf-stat>
131
+ <bf-stat label="Orders" value="1,204" variant="tile" delta="+3%" trend="up">
132
+ vs. last month
133
+ </bf-stat>
134
+ </div>
135
+ </bf-card>
136
+ <bf-card>
137
+ <h3 class="text-heading">Support</h3>
138
+ <div class="grid grid-cols-[repeat(auto-fit,minmax(9rem,1fr))] gap-3">
139
+ <bf-stat
140
+ label="Open tickets"
141
+ value="18"
142
+ variant="tile"
143
+ delta="-4"
144
+ trend="down"
145
+ trendIntent="negative"
146
+ >
147
+ vs. last week
148
+ </bf-stat>
149
+ <bf-stat label="First reply" value="2 h" variant="tile" delta="0" trend="flat">
150
+ vs. last week
151
+ </bf-stat>
152
+ </div>
153
+ </bf-card>
154
+ </div>
155
+ `,
156
+ })
157
+ export class StatDashboardExampleComponent {}
158
+ ```
159
+
106
160
  ## Plain
107
161
 
108
162
  `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`.
@@ -182,8 +236,9 @@ A stat is not interactive: no hover, no pointer cursor, no focus stop. Its size
182
236
  - Label (`dt`): `text-caption` in `text-muted`. Value (`dd`): `text-title` in `text`, with tabular figures (`tabular-nums`) so numbers line up across stats.
183
237
  - Supporting line: `text-caption` in `text-muted`.
184
238
  - 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.
185
- - Icon tile: `surface-sunken` background, `text-muted` icon, `tile` radius, 40 px.
239
+ - Icon tile: `surface-sunken` background (`surface` on a `tile` stat), `text-muted` icon, `tile` radius, 40 px.
186
240
  - `card`: `surface`, the `card` radius, `--spacing-card` padding (`p-card`) and a 1.5 px `border-strong` edge, as the `flat` `bf-card`.
241
+ - `tile`: `surface-sunken`, the `card` radius and `--spacing-card` padding, no border.
187
242
 
188
243
  ## Accessibility
189
244
 
@@ -191,14 +246,14 @@ A stat is not interactive: no hover, no pointer cursor, no focus stop. Its size
191
246
 
192
247
  ## Inputs
193
248
 
194
- | Name | Type | Default | Description |
195
- | ------------- | -------------------------------------------------------------------- | ------------ | ---------------------------------------------------------------------------------------- |
196
- | `label` | `string` | required | What the figure measures, shown above it. |
197
- | `value` | `string` | required | The figure, already formatted (`45 min`, `1,204`, `98%`). |
198
- | `variant` | `BfStatVariant` (`'plain' \| 'card'`) | `'plain'` | Surface: plain (default) on its container, or card, an outlined tile of its own. |
199
- | `delta` | `string` | `''` | A change shown in a pill beside the value, already formatted (`+12%`); empty shows none. |
200
- | `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. |
201
- | `trendIntent` | `BfStatTrendIntent` (`'positive' \| 'negative'`) | `'positive'` | Whether up is good (positive, default) or down is good (negative, such as costs). |
249
+ | Name | Type | Default | Description |
250
+ | ------------- | -------------------------------------------------------------------- | ------------ | ----------------------------------------------------------------------------------------------- |
251
+ | `label` | `string` | required | What the figure measures, shown above it. |
252
+ | `value` | `string` | required | The figure, already formatted (`45 min`, `1,204`, `98%`). |
253
+ | `variant` | `BfStatAppearance` (`'plain' \| 'card' \| 'tile'`) | `'plain'` | Surface: plain (default) on its container, card (an outlined tile) or tile (sunken, no border). |
254
+ | `delta` | `string` | `''` | A change shown in a pill beside the value, already formatted (`+12%`); empty shows none. |
255
+ | `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. |
256
+ | `trendIntent` | `BfStatTrendIntent` (`'positive' \| 'negative'`) | `'positive'` | Whether up is good (positive, default) or down is good (negative, such as costs). |
202
257
 
203
258
  ## Slots
204
259
 
@@ -215,12 +270,21 @@ A stat is not interactive: no hover, no pointer cursor, no focus stop. Its size
215
270
 
216
271
  ### Don't
217
272
 
273
+ - Don't put `card` stats (outlined) inside a raised `bf-card`; use `tile`.
218
274
  - Don't use a stat for a value the user edits or acts on; give it its own control.
219
275
  - Don't put long text in `value`; it is set at the `title` size for a short figure.
220
276
  - Don't put the comparison period in `delta`; keep it to the change (`+12%`) and say "vs. last month" in the supporting line.
221
277
 
222
278
  ## Migration notes
223
279
 
280
+ ### 0.3.2
281
+
282
+ New `variant="tile"`: a sunken, borderless tile (`surface-sunken`, the `card` radius and
283
+ padding) for a row of stats inside a raised `bf-card`; on it the icon tile turns `surface`.
284
+ `variant` now takes the new named type `BfStatAppearance` (`BfStatVariant | 'tile'`); the
285
+ released `BfStatVariant` keeps its two members, so `Record`s over it keep compiling. New
286
+ `stat/dashboard` example with one surface per level.
287
+
224
288
  ### 0.3.1
225
289
 
226
290
  New: `bf-stat`, one labelled figure in its own `dl`, with the value at the `title` size in
@@ -103,13 +103,14 @@ export class TableOverviewExampleComponent {
103
103
 
104
104
  Data table for desktop: column headers that set a sort, optional row selection with
105
105
  checkboxes, custom cells from `bfTableCell` templates and an empty slot. It never sorts or
106
- filters itself; the consumer sorts the rows in a `computed()` from `sort`.
106
+ filters itself; the consumer sorts the rows in a `computed()` from `sort`. With `stack` it
107
+ renders each row as a tile of label/value pairs in a narrow space or under touch.
107
108
 
108
109
  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.
109
110
 
110
111
  ## When to use
111
112
 
112
- 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.
113
+ 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.
113
114
 
114
115
  ## Columns and rows
115
116
 
@@ -400,9 +401,116 @@ export class TablePagedExampleComponent {
400
401
 
401
402
  `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.
402
403
 
404
+ ## Stacked rows
405
+
406
+ `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.
407
+
408
+ 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, since the hidden header's buttons are not shown. Its first option, Not sorted, shows while `sort` is null and sets it back to null; then every sortable column, ascending and descending (`tableSortBy`, `tableSortNone`, `tableSortAscending`, `tableSortDescending` labels). Choosing a column option sets `sort`, as a header click does. In a test, `BfTableHarness.getSortSelect()` returns that select's `BfSelectHarness`.
409
+
410
+ ```ts
411
+ import { Component, computed, signal } from '@angular/core';
412
+ import {
413
+ BfBadgeComponent,
414
+ BfSegmentedComponent,
415
+ BfTableCellDirective,
416
+ BfTableComponent,
417
+ type BfSegmentOption,
418
+ type BfTableColumn,
419
+ type BfTableSort,
420
+ } from '@bitforgehq/ui';
421
+
422
+ interface Task {
423
+ id: string;
424
+ title: string;
425
+ project: string;
426
+ status: 'Open' | 'Done';
427
+ hours: number;
428
+ }
429
+
430
+ const TASKS: readonly Task[] = [
431
+ { id: 'brief', title: 'Write the brief', project: 'Atlas', status: 'Done', hours: 3 },
432
+ { id: 'moodboard', title: 'Bloom moodboard', project: 'Bloom', status: 'Open', hours: 6 },
433
+ { id: 'deck', title: 'Pitch deck', project: 'Atlas', status: 'Open', hours: 9 },
434
+ ];
435
+
436
+ type Width = 'phone' | 'full';
437
+
438
+ /**
439
+ * Stacked rows on a narrow table.
440
+ *
441
+ * `stack="narrow"` turns each row into a tile of label/value pairs while the table is narrower
442
+ * than 36rem: the selection checkbox and the first cell head the tile, and a bar above the tiles
443
+ * holds Select all and a Sort by select, whose first option, Not sorted, shows while `sort` is
444
+ * null and clears it. Switch the box between phone width and full width.
445
+ */
446
+ @Component({
447
+ selector: 'app-example-table-stacked',
448
+ imports: [BfTableComponent, BfTableCellDirective, BfBadgeComponent, BfSegmentedComponent],
449
+ template: `
450
+ <!-- A stacking table is a size container: its width comes from its parent, never its columns. -->
451
+ <div class="flex w-152 max-w-full flex-col gap-3">
452
+ <bf-segmented label="Box width" [options]="widths" [(value)]="width" />
453
+ <div [class]="width() === 'phone' ? 'w-full max-w-sm' : 'w-full'">
454
+ <bf-table
455
+ label="Stacked tasks"
456
+ stack="narrow"
457
+ selectable
458
+ [rows]="rows()"
459
+ [columns]="columns"
460
+ [(sort)]="sort"
461
+ [(selection)]="selection"
462
+ >
463
+ <ng-template bfTableCell="status" [bfTableCellOf]="rows()" let-task>
464
+ <bf-badge [variant]="task.status === 'Done' ? 'success' : 'neutral'">
465
+ {{ task.status }}
466
+ </bf-badge>
467
+ </ng-template>
468
+ </bf-table>
469
+ </div>
470
+ <p data-status aria-live="polite" class="text-caption text-text-muted">{{ status() }}</p>
471
+ </div>
472
+ `,
473
+ })
474
+ export class TableStackedExampleComponent {
475
+ readonly widths: BfSegmentOption<Width>[] = [
476
+ { value: 'phone', label: 'Phone' },
477
+ { value: 'full', label: 'Full width' },
478
+ ];
479
+ readonly width = signal<Width | null>('phone');
480
+ readonly columns: readonly BfTableColumn[] = [
481
+ { key: 'title', label: 'Task', sortable: true },
482
+ { key: 'project', label: 'Project' },
483
+ { key: 'status', label: 'Status' },
484
+ { key: 'hours', label: 'Hours', sortable: true, align: 'end' },
485
+ ];
486
+ readonly sort = signal<BfTableSort | null>(null);
487
+ readonly selection = signal<readonly string[]>([]);
488
+
489
+ /** The table never sorts: the page sorts its rows from `sort`. */
490
+ readonly rows = computed(() => {
491
+ const sort = this.sort();
492
+ if (!sort) {
493
+ return TASKS;
494
+ }
495
+ const factor = sort.direction === 'ascending' ? 1 : -1;
496
+ return [...TASKS].sort((a, b) =>
497
+ sort.column === 'hours'
498
+ ? (a.hours - b.hours) * factor
499
+ : a.title.localeCompare(b.title) * factor,
500
+ );
501
+ });
502
+
503
+ readonly status = computed(() => {
504
+ const sort = this.sort();
505
+ const sorted = sort ? `Sorted by ${sort.column}, ${sort.direction}` : 'Unsorted';
506
+ return `${sorted} · ${this.selection().length} selected.`;
507
+ });
508
+ }
509
+ ```
510
+
403
511
  ## Narrow screens and touch
404
512
 
405
- 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.
513
+ 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.
406
514
 
407
515
  ```ts
408
516
  import { Component } from '@angular/core';
@@ -471,6 +579,7 @@ A selectable table tints a row `surface-muted` while hovered (a selected row `pr
471
579
  - Rows: `text` on the page's surface, divided by `border` lines; hover (selectable only) `surface-muted`; selected `primary-soft`, hover `primary-soft-hover`.
472
580
  - Sort arrow: a 1.5 px CSS chevron in the header's text colour.
473
581
  - Focus ring: `ring`. Header buttons: `rounded-field`.
582
+ - Stacked rows: tiles on `surface-sunken` with `rounded-tile`, labels `text-label-xs` bold in `text-muted`.
474
583
  - Text: `text-body` (`text-label-sm` at `sm`), `tabular-nums` in `end` columns.
475
584
 
476
585
  Put the table on a `bf-card` (or another surface) yourself; it has no background or border of its own.
@@ -479,6 +588,8 @@ Put the table on a `bf-card` (or another surface) yourself; it has no background
479
588
 
480
589
  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.
481
590
 
591
+ 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) / ""`, the `content-data-label` utility of `theme.css`), so a screen reader reads the column header once, not the label as well.
592
+
482
593
  ### Keyboard
483
594
 
484
595
  | Key | Action |
@@ -489,6 +600,8 @@ A native `table` with a visually hidden `caption` from `label`, `th scope="col"`
489
600
  | <kbd>Space</kbd> | Toggles the focused checkbox, or sorts by the focused header |
490
601
  | <kbd>Enter</kbd> | Sorts by the focused header |
491
602
 
603
+ A stacked table's Sort by select is a native select with its own keys.
604
+
492
605
  Text fields, selects and composite widgets (radios, comboboxes, sliders) in a cell keep their own arrow keys.
493
606
 
494
607
  ## Inputs
@@ -502,6 +615,7 @@ Text fields, selects and composite widgets (radios, comboboxes, sliders) in a ce
502
615
  | `selectable` | `boolean` | `false` | Adds a first column of checkboxes that select rows. |
503
616
  | `selectAllLabel` | `string` | `''` | Accessible name of the header checkbox; empty falls back to `provideBfLabels()`'s `tableSelectAll` ("Select all rows"). |
504
617
  | `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). |
618
+ | `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). |
505
619
  | `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)]`. |
506
620
  | `selection` | `readonly string[]` | `[]` | The keys (`rowKey`) of the selected rows; two-way bindable. Two-way: `[(selection)]`. |
507
621
 
@@ -542,15 +656,48 @@ Selects or clears one row.
542
656
 
543
657
  - Give every table a `label`, and every sortable column a short header.
544
658
  - Sort and filter the rows in a `computed()` from `sort` and your filters.
545
- - Render the rows as `bf-list-item`s below `md` and inside the Capacitor shell.
659
+ - 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`.
546
660
 
547
661
  ### Don't
548
662
 
549
- - Don't put a table on a phone screen that scrolls sideways; use the list.
663
+ - Don't put a table on a phone screen that scrolls sideways; stack it or use the list.
664
+ - Don't put a stacking table in a parent that sizes it from its content: the container query needs a width from outside.
550
665
  - Don't make a whole row clickable; put a link or button in a custom cell.
551
666
 
552
667
  ## Migration notes
553
668
 
669
+ ### 0.3.3
670
+
671
+ Fix: stacked rows (`stack="narrow"` and `stack="coarse"`) show their column labels again when the
672
+ package is installed from npm. 0.3.2 wrote the label as `content-[attr(data-label)_/_""]`; the
673
+ library build escaped its quotes in the FESM (`\"\"`), so Tailwind built the rule for a class no
674
+ cell carries and every label `::before` computed to `none`. The cells now carry
675
+ `before:content-data-label`, a utility `theme.css` defines (below). Apps that added the
676
+ workaround `@source inline('@max-xl:before:content-[attr(data-label)_/_""] pointer-coarse:before:content-[attr(data-label)_/_""]')`
677
+ can remove it. consumer-smoke now opens a stacked table built from the packed package in
678
+ Chromium, under a fine and a coarse pointer, and fails if any FESM class string holds a
679
+ backslash escape.
680
+
681
+ The stacked Sort by select's first option is Not sorted (new label key `tableSortNone`, "Not
682
+ sorted"): it shows while `sort` is null, so the select is no longer an empty box, and choosing it
683
+ sets `sort` back to null. New `BfTableHarness.getSortSelect()` returns that select's
684
+ `BfSelectHarness` (`null` when the table cannot stack or has no sortable column).
685
+
686
+ ### 0.3.2
687
+
688
+ New opt-in `stack` input (`BfTableStack`: `'never'` by default, so existing tables do not
689
+ change, `'narrow'` or `'coarse'`): while the table is narrower than 36rem (a container query on
690
+ the host, which then gets `@container`) or, for `'coarse'`, under a coarse pointer at any width,
691
+ each body row renders as a sunken tile of label/value pairs. The markup stays a `table` with
692
+ explicit `table`, `rowgroup`, `row`, `columnheader` and `cell` roles (some browsers drop a
693
+ table's semantics once its display changes), the header row is visually hidden but still read,
694
+ the selection checkbox and the first cell head each tile, and every other cell shows its column
695
+ label (`data-label`, a `::before` with empty alternative text, so it is not read twice). A bar
696
+ above the tiles holds a labelled Select all checkbox and a Sort by `bf-select`, since the hidden
697
+ header's controls are not shown. New label keys `tableSortBy` ("Sort by"), `tableSortAscending`
698
+ and `tableSortDescending` (`"Hours, ascending"`). `BfTableHarness` reads and drives a stacked
699
+ table the same way. New `table/stacked` example and a Stacked rows docs section.
700
+
554
701
  ### 0.3.1
555
702
 
556
703
  New `bf-table` and `ng-template[bfTableCell]` (`BfTableCellDirective`) in `@bitforgehq/ui`, a
@@ -194,7 +194,7 @@ fires no keyboard event).
194
194
 
195
195
  ## Desktop
196
196
 
197
- `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.
197
+ `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.
198
198
 
199
199
  ```ts
200
200
  import { Component } from '@angular/core';
@@ -365,6 +365,10 @@ content to the top.
365
365
 
366
366
  ## Migration notes
367
367
 
368
+ ### 0.3.2
369
+
370
+ The `## Desktop` docs point at `bf-app-frame` for the frame around the desktop navigation.
371
+
368
372
  ### 0.3.1
369
373
 
370
374
  The `## Desktop` docs name the desktop replacement: a `bf-top-nav` or `bf-sidebar` of