@bitforgehq/ui 0.3.2 → 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.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,47 @@ All notable changes to `@bitforgehq/ui`. One `##` section per version; one `###`
4
4
 
5
5
  The heading for the version being worked on carries `(unreleased)` in place of a date. At release time, that placeholder is replaced with the release date (`## 0.3.1 (unreleased)` becomes `## 0.3.1 (YYYY-MM-DD)`) in the same commit that bumps `projects/ui/package.json`. See "Releasing" in the root `README.md`. 0.3.0 is the first version published to npm; the 0.2.0 and 0.1.0 sections below are the history of versions that were never published.
6
6
 
7
+ ## 0.3.3 (2026-10-29)
8
+
9
+ ### header
10
+
11
+ Fix: `bf-header` measures its title and slots in the next animation frame after a resize, not
12
+ inside the `ResizeObserver` callback. Its measurement writes the title inset and height, which
13
+ resize the observed elements, and WebKit then reported "ResizeObserver loop completed with
14
+ undelivered notifications": an error event that an app's global error listener
15
+ (`provideBrowserGlobalErrorListeners()`) logs as an error. Several resizes in one frame now
16
+ measure once.
17
+
18
+ ### app-frame
19
+
20
+ Fix: the narrow header bar puts the `menu` slot at the inline start, before the `brand` (0.3.2 put
21
+ it after the brand), with the actions at the end as before: the top app bar convention. The menu
22
+ also comes first in the tab order. No API change; an app needs no change unless it relied on the
23
+ brand coming first.
24
+
25
+ ### table
26
+
27
+ Fix: stacked rows (`stack="narrow"` and `stack="coarse"`) show their column labels again when the
28
+ package is installed from npm. 0.3.2 wrote the label as `content-[attr(data-label)_/_""]`; the
29
+ library build escaped its quotes in the FESM (`\"\"`), so Tailwind built the rule for a class no
30
+ cell carries and every label `::before` computed to `none`. The cells now carry
31
+ `before:content-data-label`, a utility `theme.css` defines (below). Apps that added the
32
+ workaround `@source inline('@max-xl:before:content-[attr(data-label)_/_""] pointer-coarse:before:content-[attr(data-label)_/_""]')`
33
+ can remove it. consumer-smoke now opens a stacked table built from the packed package in
34
+ Chromium, under a fine and a coarse pointer, and fails if any FESM class string holds a
35
+ backslash escape.
36
+
37
+ The stacked Sort by select's first option is Not sorted (new label key `tableSortNone`, "Not
38
+ sorted"): it shows while `sort` is null, so the select is no longer an empty box, and choosing it
39
+ sets `sort` back to null. New `BfTableHarness.getSortSelect()` returns that select's
40
+ `BfSelectHarness` (`null` when the table cannot stack or has no sortable column).
41
+
42
+ ### theme
43
+
44
+ New utility `content-data-label` in `theme.css`: `content: attr(data-label) / ''` (the element's
45
+ `data-label`, with empty alternative text). The stacked `bf-table` uses it behind its stack
46
+ variants; apps can use it with `before:` the same way.
47
+
7
48
  ## 0.3.2 (2026-10-29)
8
49
 
9
50
  ### docs site
package/ai/MANIFEST.json CHANGED
@@ -521,16 +521,16 @@
521
521
  "file": "src/components/app-frame/app-frame.component.ts",
522
522
  "category": "Navigation",
523
523
  "summary": "Desktop app frame with skip link, sticky sidebar, narrow header and main content",
524
- "description": "The frame of a desktop web app: a skip link, a sticky full-height sidebar column from the\nbreakpoint up, a header bar with the brand, the narrow-screen menu and actions below it, and\nthe `main` landmark with a content column of a set maximum width.",
524
+ "description": "The frame of a desktop web app: a skip link, a sticky full-height sidebar column from the\nbreakpoint up, a header bar with the narrow-screen menu, the brand and actions below it, and\nthe `main` landmark with a content column of a set maximum width.",
525
525
  "overview": "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.",
526
- "example": "<bf-app-frame>\n <bf-sidebar bfSlot=\"sidebar\" label=\"Main\">…</bf-sidebar>\n <span bfSlot=\"brand\" class=\"text-heading\">Atlas</span>\n <bf-menu-button bfSlot=\"menu\" [menu]=\"navMenu\">Sections</bf-menu-button>\n <router-outlet />\n</bf-app-frame>",
526
+ "example": "<bf-app-frame>\n <bf-sidebar bfSlot=\"sidebar\" label=\"Main\">…</bf-sidebar>\n <bf-menu-button bfSlot=\"menu\" [menu]=\"navMenu\">Sections</bf-menu-button>\n <span bfSlot=\"brand\" class=\"text-heading\">Atlas</span>\n <router-outlet />\n</bf-app-frame>",
527
527
  "primaryExample": "app-frame/overview",
528
528
  "examples": [
529
529
  {
530
530
  "id": "app-frame/overview",
531
531
  "title": "The bf-app-frame overview",
532
532
  "file": "projects/docs/src/app/examples/app-frame/overview-example.component.ts",
533
- "source": "import { Component, signal } from '@angular/core';\nimport { RouterLink, RouterLinkActive } from '@angular/router';\nimport {\n BfAppFrameComponent,\n BfIconButtonComponent,\n BfNavItemComponent,\n BfSidebarComponent,\n} from '@bitforgehq/ui';\nimport { BfIconComponent } from '@bitforgehq/ui/icons';\nimport {\n BfMenuButtonComponent,\n BfMenuComponent,\n BfMenuItemDirective,\n} from '@bitforgehq/ui/overlay';\nimport { Bell, type IconNode, Layers, Menu, PanelLeft, PanelTop } from 'lucide';\n\n/** One top-level destination of the app, shared by the sidebar and the narrow-screen menu. */\ninterface Destination {\n readonly path: string;\n readonly label: string;\n readonly icon: IconNode;\n}\n\n/**\n * The bf-app-frame overview.\n *\n * Live demo of `bf-app-frame` in a box of fixed height: one destinations array drives the\n * `bf-sidebar` (from `md` up) and the narrow header's `bf-menu-button`; the header also holds the\n * brand and a notifications action. Tab into the frame to reveal its skip link; Enter moves focus\n * to the content. The result line reports it.\n */\n@Component({\n selector: 'app-example-app-frame-overview',\n imports: [\n RouterLink,\n RouterLinkActive,\n BfAppFrameComponent,\n BfIconButtonComponent,\n BfIconComponent,\n BfMenuButtonComponent,\n BfMenuComponent,\n BfMenuItemDirective,\n BfNavItemComponent,\n BfSidebarComponent,\n ],\n template: `\n <!-- In an app the frame is the root of the page; here a fixed height stands in for the\n viewport, so the sidebar and the header bar stick inside the box. -->\n <div (focusin)=\"onFocus($event)\">\n <bf-app-frame\n mainId=\"app-frame-demo-content\"\n class=\"border-border rounded-card h-96 overflow-auto border\"\n >\n <bf-sidebar bfSlot=\"sidebar\" label=\"App frame demo\">\n <span bfSlot=\"header\" class=\"text-heading ps-3\">Atlas</span>\n @for (destination of destinations; track destination.path) {\n <a\n bfNavItem\n [routerLink]=\"destination.path\"\n routerLinkActive\n ariaCurrentWhenActive=\"page\"\n >\n <bf-icon bfSlot=\"icon\" [icon]=\"destination.icon\" />\n {{ destination.label }}\n </a>\n }\n </bf-sidebar>\n\n <span bfSlot=\"brand\" class=\"text-heading\">Atlas</span>\n <bf-menu-button bfSlot=\"menu\" variant=\"text\" [menu]=\"navMenu\">\n <bf-icon bfSlot=\"icon-start\" [icon]=\"menu\" />\n Sections\n </bf-menu-button>\n <bf-icon-button\n bfSlot=\"actions\"\n variant=\"text\"\n label=\"Notifications\"\n (clicked)=\"status.set('Notifications opened.')\"\n >\n <bf-icon [icon]=\"bell\" />\n </bf-icon-button>\n\n <div class=\"flex flex-col gap-3\">\n <h2 class=\"text-title\">Inbox</h2>\n <p class=\"text-body text-text-muted max-w-prose\">\n The page sits in the content column, beside the sidebar from the md breakpoint up and\n under the header bar below it.\n </p>\n <p data-status aria-live=\"polite\" class=\"text-caption text-text-muted\">{{ status() }}</p>\n @for (row of rows; track row) {\n <div class=\"bg-surface rounded-tile text-body p-4\">{{ row }}</div>\n }\n </div>\n </bf-app-frame>\n </div>\n\n <ng-template #navMenu>\n <bf-menu>\n @for (destination of destinations; track destination.path) {\n <a\n bfMenuItem\n [routerLink]=\"destination.path\"\n routerLinkActive\n ariaCurrentWhenActive=\"page\"\n >{{ destination.label }}</a\n >\n }\n </bf-menu>\n </ng-template>\n `,\n})\nexport class AppFrameOverviewExampleComponent {\n /** In an app, derive this from the route config, so a new route appears on every surface. */\n protected readonly destinations: readonly Destination[] = [\n { path: '/components/app-frame', label: 'App frame', icon: PanelLeft },\n { path: '/components/sidebar', label: 'Sidebar', icon: PanelTop },\n { path: '/components/menu-button', label: 'Menu button', icon: Layers },\n ];\n protected readonly rows = [\n 'Review the brand brief',\n 'Plan the launch',\n 'Write the release notes',\n ];\n protected readonly bell = Bell;\n protected readonly menu = Menu;\n readonly status = signal('Tab into the frame to reveal the skip link.');\n\n /** Reports when focus lands on the content, as the skip link moves it there. */\n protected onFocus(event: FocusEvent): void {\n if (event.target instanceof HTMLElement && event.target.id === 'app-frame-demo-content') {\n this.status.set('Skipped to the content.');\n }\n }\n}\n",
533
+ "source": "import { Component, signal } from '@angular/core';\nimport { RouterLink, RouterLinkActive } from '@angular/router';\nimport {\n BfAppFrameComponent,\n BfIconButtonComponent,\n BfNavItemComponent,\n BfSidebarComponent,\n} from '@bitforgehq/ui';\nimport { BfIconComponent } from '@bitforgehq/ui/icons';\nimport {\n BfMenuButtonComponent,\n BfMenuComponent,\n BfMenuItemDirective,\n} from '@bitforgehq/ui/overlay';\nimport { Bell, type IconNode, Layers, Menu, PanelLeft, PanelTop } from 'lucide';\n\n/** One top-level destination of the app, shared by the sidebar and the narrow-screen menu. */\ninterface Destination {\n readonly path: string;\n readonly label: string;\n readonly icon: IconNode;\n}\n\n/**\n * The bf-app-frame overview.\n *\n * Live demo of `bf-app-frame` in a box of fixed height: one destinations array drives the\n * `bf-sidebar` (from `md` up) and the narrow header's `bf-menu-button`, at the header's inline\n * start; the brand follows it, and a notifications action sits at the end. Tab into the frame to reveal its skip link; Enter moves focus\n * to the content. The result line reports it.\n */\n@Component({\n selector: 'app-example-app-frame-overview',\n imports: [\n RouterLink,\n RouterLinkActive,\n BfAppFrameComponent,\n BfIconButtonComponent,\n BfIconComponent,\n BfMenuButtonComponent,\n BfMenuComponent,\n BfMenuItemDirective,\n BfNavItemComponent,\n BfSidebarComponent,\n ],\n template: `\n <!-- In an app the frame is the root of the page; here a fixed height stands in for the\n viewport, so the sidebar and the header bar stick inside the box. -->\n <div (focusin)=\"onFocus($event)\">\n <bf-app-frame\n mainId=\"app-frame-demo-content\"\n class=\"border-border rounded-card h-96 overflow-auto border\"\n >\n <bf-sidebar bfSlot=\"sidebar\" label=\"App frame demo\">\n <span bfSlot=\"header\" class=\"text-heading ps-3\">Atlas</span>\n @for (destination of destinations; track destination.path) {\n <a\n bfNavItem\n [routerLink]=\"destination.path\"\n routerLinkActive\n ariaCurrentWhenActive=\"page\"\n >\n <bf-icon bfSlot=\"icon\" [icon]=\"destination.icon\" />\n {{ destination.label }}\n </a>\n }\n </bf-sidebar>\n\n <bf-menu-button bfSlot=\"menu\" variant=\"text\" [menu]=\"navMenu\">\n <bf-icon bfSlot=\"icon-start\" [icon]=\"menu\" />\n Sections\n </bf-menu-button>\n <span bfSlot=\"brand\" class=\"text-heading\">Atlas</span>\n <bf-icon-button\n bfSlot=\"actions\"\n variant=\"text\"\n label=\"Notifications\"\n (clicked)=\"status.set('Notifications opened.')\"\n >\n <bf-icon [icon]=\"bell\" />\n </bf-icon-button>\n\n <div class=\"flex flex-col gap-3\">\n <h2 class=\"text-title\">Inbox</h2>\n <p class=\"text-body text-text-muted max-w-prose\">\n The page sits in the content column, beside the sidebar from the md breakpoint up and\n under the header bar below it.\n </p>\n <p data-status aria-live=\"polite\" class=\"text-caption text-text-muted\">{{ status() }}</p>\n @for (row of rows; track row) {\n <div class=\"bg-surface rounded-tile text-body p-4\">{{ row }}</div>\n }\n </div>\n </bf-app-frame>\n </div>\n\n <ng-template #navMenu>\n <bf-menu>\n @for (destination of destinations; track destination.path) {\n <a\n bfMenuItem\n [routerLink]=\"destination.path\"\n routerLinkActive\n ariaCurrentWhenActive=\"page\"\n >{{ destination.label }}</a\n >\n }\n </bf-menu>\n </ng-template>\n `,\n})\nexport class AppFrameOverviewExampleComponent {\n /** In an app, derive this from the route config, so a new route appears on every surface. */\n protected readonly destinations: readonly Destination[] = [\n { path: '/components/app-frame', label: 'App frame', icon: PanelLeft },\n { path: '/components/sidebar', label: 'Sidebar', icon: PanelTop },\n { path: '/components/menu-button', label: 'Menu button', icon: Layers },\n ];\n protected readonly rows = [\n 'Review the brand brief',\n 'Plan the launch',\n 'Write the release notes',\n ];\n protected readonly bell = Bell;\n protected readonly menu = Menu;\n readonly status = signal('Tab into the frame to reveal the skip link.');\n\n /** Reports when focus lands on the content, as the skip link moves it there. */\n protected onFocus(event: FocusEvent): void {\n if (event.target instanceof HTMLElement && event.target.id === 'app-frame-demo-content') {\n this.status.set('Skipped to the content.');\n }\n }\n}\n",
534
534
  "preview": "inline"
535
535
  },
536
536
  {
@@ -551,7 +551,7 @@
551
551
  "usage": [
552
552
  {
553
553
  "heading": "Slots and layout",
554
- "body": "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."
554
+ "body": "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 menu at the inline start, then the brand, and the actions at the end: the top app bar convention, and the menu comes first in the tab order too. The order is fixed, whatever order the slots have in your template. Keep the brand in the sidebar's own `header` slot too, for the wide layout."
555
555
  },
556
556
  {
557
557
  "heading": "Breakpoint and content width",
@@ -602,12 +602,12 @@
602
602
  "doc": "The `bf-sidebar`, shown from the breakpoint up; it fills the column's height."
603
603
  },
604
604
  {
605
- "name": "brand",
606
- "doc": "The app's name or logo in the narrow header bar."
605
+ "name": "menu",
606
+ "doc": "The narrow-screen navigation: a `bf-menu-button` of `a[bfMenuItem]` links, at the header bar's inline start."
607
607
  },
608
608
  {
609
- "name": "menu",
610
- "doc": "The narrow-screen navigation: a `bf-menu-button` of `a[bfMenuItem]` links."
609
+ "name": "brand",
610
+ "doc": "The app's name or logo in the narrow header bar, after the menu."
611
611
  },
612
612
  {
613
613
  "name": "actions",
@@ -620,6 +620,10 @@
620
620
  ],
621
621
  "methods": [],
622
622
  "changelog": [
623
+ {
624
+ "version": "0.3.3",
625
+ "text": "Fix: the narrow header bar puts the `menu` slot at the inline start, before the `brand` (0.3.2 put\nit after the brand), with the actions at the end as before: the top app bar convention. The menu\nalso comes first in the tab order. No API change; an app needs no change unless it relied on the\nbrand coming first."
626
+ },
623
627
  {
624
628
  "version": "0.3.2",
625
629
  "text": "New `bf-app-frame` in `@bitforgehq/ui`: the frame of a desktop web app. Slots `sidebar` (a\n`bf-sidebar`, in a column that sticks to the viewport at full height from the breakpoint up),\n`brand`, `menu` (the narrow-screen `bf-menu-button`) and `actions` (the header bar shown below\nthe breakpoint) and the default slot (the page, in the `main` landmark). `breakpoint`\n(`BfAppFrameBreakpoint`: `'md'` default, `'lg'`), `contentWidth` (`BfAppFrameContentWidth`:\n`'md'` 48rem, `'lg'` 64rem, `'xl'` 80rem default, `'full'`) and `mainId`. A skip link, first in\nthe tab order and hidden until focused, moves focus to the main landmark by script, so it works\nunder `<base href=\"/\">`; new label key `skipToContent` (\"Skip to main content\"). Inside another\n`main` landmark the content drops its `main` role. Desktop only: the Capacitor shell keeps\n`bf-tabs`."
@@ -4825,6 +4829,10 @@
4825
4829
  ],
4826
4830
  "methods": [],
4827
4831
  "changelog": [
4832
+ {
4833
+ "version": "0.3.3",
4834
+ "text": "Fix: `bf-header` measures its title and slots in the next animation frame after a resize, not\ninside the `ResizeObserver` callback. Its measurement writes the title inset and height, which\nresize the observed elements, and WebKit then reported \"ResizeObserver loop completed with\nundelivered notifications\": an error event that an app's global error listener\n(`provideBrowserGlobalErrorListeners()`) logs as an error. Several resizes in one frame now\nmeasure once."
4835
+ },
4828
4836
  {
4829
4837
  "version": "0.3.2",
4830
4838
  "text": "The `## Desktop` docs say `bf-app-frame` supplies the app-level header bar below its breakpoint."
@@ -10689,7 +10697,7 @@
10689
10697
  "id": "table/stacked",
10690
10698
  "title": "Stacked rows on a narrow table",
10691
10699
  "file": "projects/docs/src/app/examples/table/stacked-example.component.ts",
10692
- "source": "import { Component, computed, signal } from '@angular/core';\nimport {\n BfBadgeComponent,\n BfSegmentedComponent,\n BfTableCellDirective,\n BfTableComponent,\n type BfSegmentOption,\n type BfTableColumn,\n type BfTableSort,\n} from '@bitforgehq/ui';\n\ninterface Task {\n id: string;\n title: string;\n project: string;\n status: 'Open' | 'Done';\n hours: number;\n}\n\nconst TASKS: readonly Task[] = [\n { id: 'brief', title: 'Write the brief', project: 'Atlas', status: 'Done', hours: 3 },\n { id: 'moodboard', title: 'Bloom moodboard', project: 'Bloom', status: 'Open', hours: 6 },\n { id: 'deck', title: 'Pitch deck', project: 'Atlas', status: 'Open', hours: 9 },\n];\n\ntype Width = 'phone' | 'full';\n\n/**\n * Stacked rows on a narrow table.\n *\n * `stack=\"narrow\"` turns each row into a tile of label/value pairs while the table is narrower\n * than 36rem: the selection checkbox and the first cell head the tile, and a bar above the tiles\n * holds Select all and a Sort by select. Switch the box between phone width and full width.\n */\n@Component({\n selector: 'app-example-table-stacked',\n imports: [BfTableComponent, BfTableCellDirective, BfBadgeComponent, BfSegmentedComponent],\n template: `\n <!-- A stacking table is a size container: its width comes from its parent, never its columns. -->\n <div class=\"flex w-152 max-w-full flex-col gap-3\">\n <bf-segmented label=\"Box width\" [options]=\"widths\" [(value)]=\"width\" />\n <div [class]=\"width() === 'phone' ? 'w-full max-w-sm' : 'w-full'\">\n <bf-table\n label=\"Stacked tasks\"\n stack=\"narrow\"\n selectable\n [rows]=\"rows()\"\n [columns]=\"columns\"\n [(sort)]=\"sort\"\n [(selection)]=\"selection\"\n >\n <ng-template bfTableCell=\"status\" [bfTableCellOf]=\"rows()\" let-task>\n <bf-badge [variant]=\"task.status === 'Done' ? 'success' : 'neutral'\">\n {{ task.status }}\n </bf-badge>\n </ng-template>\n </bf-table>\n </div>\n <p data-status aria-live=\"polite\" class=\"text-caption text-text-muted\">{{ status() }}</p>\n </div>\n `,\n})\nexport class TableStackedExampleComponent {\n readonly widths: BfSegmentOption<Width>[] = [\n { value: 'phone', label: 'Phone' },\n { value: 'full', label: 'Full width' },\n ];\n readonly width = signal<Width | null>('phone');\n readonly columns: readonly BfTableColumn[] = [\n { key: 'title', label: 'Task', sortable: true },\n { key: 'project', label: 'Project' },\n { key: 'status', label: 'Status' },\n { key: 'hours', label: 'Hours', sortable: true, align: 'end' },\n ];\n readonly sort = signal<BfTableSort | null>(null);\n readonly selection = signal<readonly string[]>([]);\n\n /** The table never sorts: the page sorts its rows from `sort`. */\n readonly rows = computed(() => {\n const sort = this.sort();\n if (!sort) {\n return TASKS;\n }\n const factor = sort.direction === 'ascending' ? 1 : -1;\n return [...TASKS].sort((a, b) =>\n sort.column === 'hours'\n ? (a.hours - b.hours) * factor\n : a.title.localeCompare(b.title) * factor,\n );\n });\n\n readonly status = computed(() => {\n const sort = this.sort();\n const sorted = sort ? `Sorted by ${sort.column}, ${sort.direction}` : 'Unsorted';\n return `${sorted} · ${this.selection().length} selected.`;\n });\n}\n",
10700
+ "source": "import { Component, computed, signal } from '@angular/core';\nimport {\n BfBadgeComponent,\n BfSegmentedComponent,\n BfTableCellDirective,\n BfTableComponent,\n type BfSegmentOption,\n type BfTableColumn,\n type BfTableSort,\n} from '@bitforgehq/ui';\n\ninterface Task {\n id: string;\n title: string;\n project: string;\n status: 'Open' | 'Done';\n hours: number;\n}\n\nconst TASKS: readonly Task[] = [\n { id: 'brief', title: 'Write the brief', project: 'Atlas', status: 'Done', hours: 3 },\n { id: 'moodboard', title: 'Bloom moodboard', project: 'Bloom', status: 'Open', hours: 6 },\n { id: 'deck', title: 'Pitch deck', project: 'Atlas', status: 'Open', hours: 9 },\n];\n\ntype Width = 'phone' | 'full';\n\n/**\n * Stacked rows on a narrow table.\n *\n * `stack=\"narrow\"` turns each row into a tile of label/value pairs while the table is narrower\n * than 36rem: the selection checkbox and the first cell head the tile, and a bar above the tiles\n * holds Select all and a Sort by select, whose first option, Not sorted, shows while `sort` is\n * null and clears it. Switch the box between phone width and full width.\n */\n@Component({\n selector: 'app-example-table-stacked',\n imports: [BfTableComponent, BfTableCellDirective, BfBadgeComponent, BfSegmentedComponent],\n template: `\n <!-- A stacking table is a size container: its width comes from its parent, never its columns. -->\n <div class=\"flex w-152 max-w-full flex-col gap-3\">\n <bf-segmented label=\"Box width\" [options]=\"widths\" [(value)]=\"width\" />\n <div [class]=\"width() === 'phone' ? 'w-full max-w-sm' : 'w-full'\">\n <bf-table\n label=\"Stacked tasks\"\n stack=\"narrow\"\n selectable\n [rows]=\"rows()\"\n [columns]=\"columns\"\n [(sort)]=\"sort\"\n [(selection)]=\"selection\"\n >\n <ng-template bfTableCell=\"status\" [bfTableCellOf]=\"rows()\" let-task>\n <bf-badge [variant]=\"task.status === 'Done' ? 'success' : 'neutral'\">\n {{ task.status }}\n </bf-badge>\n </ng-template>\n </bf-table>\n </div>\n <p data-status aria-live=\"polite\" class=\"text-caption text-text-muted\">{{ status() }}</p>\n </div>\n `,\n})\nexport class TableStackedExampleComponent {\n readonly widths: BfSegmentOption<Width>[] = [\n { value: 'phone', label: 'Phone' },\n { value: 'full', label: 'Full width' },\n ];\n readonly width = signal<Width | null>('phone');\n readonly columns: readonly BfTableColumn[] = [\n { key: 'title', label: 'Task', sortable: true },\n { key: 'project', label: 'Project' },\n { key: 'status', label: 'Status' },\n { key: 'hours', label: 'Hours', sortable: true, align: 'end' },\n ];\n readonly sort = signal<BfTableSort | null>(null);\n readonly selection = signal<readonly string[]>([]);\n\n /** The table never sorts: the page sorts its rows from `sort`. */\n readonly rows = computed(() => {\n const sort = this.sort();\n if (!sort) {\n return TASKS;\n }\n const factor = sort.direction === 'ascending' ? 1 : -1;\n return [...TASKS].sort((a, b) =>\n sort.column === 'hours'\n ? (a.hours - b.hours) * factor\n : a.title.localeCompare(b.title) * factor,\n );\n });\n\n readonly status = computed(() => {\n const sort = this.sort();\n const sorted = sort ? `Sorted by ${sort.column}, ${sort.direction}` : 'Unsorted';\n return `${sorted} · ${this.selection().length} selected.`;\n });\n}\n",
10693
10701
  "preview": "inline"
10694
10702
  },
10695
10703
  {
@@ -10731,7 +10739,7 @@
10731
10739
  },
10732
10740
  {
10733
10741
  "heading": "Stacked rows",
10734
- "body": "`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.\n\nStacked, 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.\n\n<!-- example: table/stacked -->"
10742
+ "body": "`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.\n\nStacked, 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`.\n\n<!-- example: table/stacked -->"
10735
10743
  },
10736
10744
  {
10737
10745
  "heading": "Narrow screens and touch",
@@ -10882,6 +10890,10 @@
10882
10890
  }
10883
10891
  ],
10884
10892
  "changelog": [
10893
+ {
10894
+ "version": "0.3.3",
10895
+ "text": "Fix: stacked rows (`stack=\"narrow\"` and `stack=\"coarse\"`) show their column labels again when the\npackage is installed from npm. 0.3.2 wrote the label as `content-[attr(data-label)_/_\"\"]`; the\nlibrary build escaped its quotes in the FESM (`\\\"\\\"`), so Tailwind built the rule for a class no\ncell carries and every label `::before` computed to `none`. The cells now carry\n`before:content-data-label`, a utility `theme.css` defines (below). Apps that added the\nworkaround `@source inline('@max-xl:before:content-[attr(data-label)_/_\"\"] pointer-coarse:before:content-[attr(data-label)_/_\"\"]')`\ncan remove it. consumer-smoke now opens a stacked table built from the packed package in\nChromium, under a fine and a coarse pointer, and fails if any FESM class string holds a\nbackslash escape.\n\nThe stacked Sort by select's first option is Not sorted (new label key `tableSortNone`, \"Not\nsorted\"): it shows while `sort` is null, so the select is no longer an empty box, and choosing it\nsets `sort` back to null. New `BfTableHarness.getSortSelect()` returns that select's\n`BfSelectHarness` (`null` when the table cannot stack or has no sortable column)."
10896
+ },
10885
10897
  {
10886
10898
  "version": "0.3.2",
10887
10899
  "text": "New opt-in `stack` input (`BfTableStack`: `'never'` by default, so existing tables do not\nchange, `'narrow'` or `'coarse'`): while the table is narrower than 36rem (a container query on\nthe host, which then gets `@container`) or, for `'coarse'`, under a coarse pointer at any width,\neach body row renders as a sunken tile of label/value pairs. The markup stays a `table` with\nexplicit `table`, `rowgroup`, `row`, `columnheader` and `cell` roles (some browsers drop a\ntable's semantics once its display changes), the header row is visually hidden but still read,\nthe selection checkbox and the first cell head each tile, and every other cell shows its column\nlabel (`data-label`, a `::before` with empty alternative text, so it is not read twice). A bar\nabove the tiles holds a labelled Select all checkbox and a Sort by `bf-select`, since the hidden\nheader's controls are not shown. New label keys `tableSortBy` (\"Sort by\"), `tableSortAscending`\nand `tableSortDescending` (`\"Hours, ascending\"`). `BfTableHarness` reads and drives a stacked\ntable the same way. New `table/stacked` example and a Stacked rows docs section."
@@ -10895,7 +10907,7 @@
10895
10907
  "whenToUse": "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.",
10896
10908
  "desktop": "A selectable table tints a row `surface-muted` while hovered (a selected row `primary-soft-hover`); a table without selection does not, because its rows are not targets. Sortable headers darken from `text-muted` to `text` on hover and show the pointer. Hover styles apply only on devices that can hover. The focus ring shows on keyboard focus only, on the header button, the checkbox or the control in a custom cell. Desktop density is the default `md` step; `sm` suits a dense admin page ([density guide](https://bitforge-ui.web.app/density)).",
10897
10909
  "theming": "- Header: `text-label-xs`, bold, `text-muted`; a sortable header's hover `text`. A `border` line under the header row.\n- Rows: `text` on the page's surface, divided by `border` lines; hover (selectable only) `surface-muted`; selected `primary-soft`, hover `primary-soft-hover`.\n- Sort arrow: a 1.5 px CSS chevron in the header's text colour.\n- Focus ring: `ring`. Header buttons: `rounded-field`.\n- Stacked rows: tiles on `surface-sunken` with `rounded-tile`, labels `text-label-xs` bold in `text-muted`.\n- Text: `text-body` (`text-label-sm` at `sm`), `tabular-nums` in `end` columns.\n\nPut the table on a `bf-card` (or another surface) yourself; it has no background or border of its own.",
10898
- "accessibility": "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.\n\nA 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.\n\n### Keyboard\n\n| Key | Action |\n| ------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| <kbd>Tab</kbd> / <kbd>Shift</kbd>+<kbd>Tab</kbd> | Moves through the header buttons, checkboxes and the controls in custom cells in document order |\n| <kbd>ArrowDown</kbd> | On a button, link or checkbox in a row (or the header): moves focus to the control at the same place in the nearest row below that has one; nothing happens at the last row |\n| <kbd>ArrowUp</kbd> | The same, upwards, up to the header row |\n| <kbd>Space</kbd> | Toggles the focused checkbox, or sorts by the focused header |\n| <kbd>Enter</kbd> | Sorts by the focused header |\n\nA stacked table's Sort by select is a native select with its own keys.\n\nText fields, selects and composite widgets (radios, comboboxes, sliders) in a cell keep their own arrow keys.",
10910
+ "accessibility": "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.\n\nA 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.\n\n### Keyboard\n\n| Key | Action |\n| ------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| <kbd>Tab</kbd> / <kbd>Shift</kbd>+<kbd>Tab</kbd> | Moves through the header buttons, checkboxes and the controls in custom cells in document order |\n| <kbd>ArrowDown</kbd> | On a button, link or checkbox in a row (or the header): moves focus to the control at the same place in the nearest row below that has one; nothing happens at the last row |\n| <kbd>ArrowUp</kbd> | The same, upwards, up to the header row |\n| <kbd>Space</kbd> | Toggles the focused checkbox, or sorts by the focused header |\n| <kbd>Enter</kbd> | Sorts by the focused header |\n\nA stacked table's Sort by select is a native select with its own keys.\n\nText fields, selects and composite widgets (radios, comboboxes, sliders) in a cell keep their own arrow keys.",
10899
10911
  "notes": "## Do\n\n- Give every table a `label`, and every sortable column a short header.\n- Sort and filter the rows in a `computed()` from `sort` and your filters.\n- 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`.\n\n## Don't\n\n- Don't put a table on a phone screen that scrolls sideways; stack it or use the list.\n- Don't put a stacking table in a parent that sizes it from its content: the container query needs a width from outside.\n- Don't make a whole row clickable; put a link or button in a custom cell."
10900
10912
  },
10901
10913
  {
package/ai/REFERENCE.json CHANGED
@@ -1075,6 +1075,15 @@
1075
1075
  "readonly": true,
1076
1076
  "default": "'Sort by'"
1077
1077
  },
1078
+ {
1079
+ "name": "tableSortNone",
1080
+ "kind": "property",
1081
+ "type": "string",
1082
+ "required": true,
1083
+ "doc": "A stacked bf-table's first sort option: rows in the order given, no column sorted.",
1084
+ "readonly": true,
1085
+ "default": "'Not sorted'"
1086
+ },
1078
1087
  {
1079
1088
  "name": "tableSortAscending",
1080
1089
  "kind": "property",
@@ -2599,7 +2608,7 @@
2599
2608
  "entryPoint": "@bitforgehq/ui",
2600
2609
  "file": "src/config/labels.ts",
2601
2610
  "summary": "Replaces built-in strings for an app or a sub-tree, such as a translation",
2602
- "description": "Replaces built-in strings, merged over the parent's (validation merged key by key); a key left\nout or set to `undefined` keeps the parent's string. In `ApplicationConfig.providers` it covers\nevery string. In a component's `providers` it covers that sub-tree's widgets and form controls\n(`loading`, `dismiss`, `tabBar`, `tabBadge`, `back`, `refreshing`, `tableSelectAll`, `tableSortBy`,\n`tableSortAscending`, `tableSortDescending`, `theme`, `themeSystem`, `themeLight`, `themeDark`,\n`breadcrumb`, `breadcrumbExpand`, `navigation`, `navBadge`, `sidebarCollapse`, `sidebarExpand`,\n`skipToContent`, `pagination`, `previousPage`, `nextPage`, `pageOf`, `goToPage`, `tabGroup`,\n`calendarPreviousMonth`, `calendarNextMonth`, `calendarChooseMonth`, `calendarChooseYear`,\n`statTrendUp`, `statTrendDown`, `statTrendFlat`, `comboboxEmpty`, `comboboxRemove`,\n`dateInputDay`, `dateInputMonth`, `dateInputYear`, `dateInputOpenCalendar`, `dateInputInvalid`,\n`dateInputUnavailable`, `timeInputHour`, `timeInputMinute`, `timeInputSecond`,\n`timeInputDayPeriod`, `timeInputOpenList`, `timeInputInvalid`, `timeInputMin`, `timeInputMax`,\n`chart`, `chartLegend`, `chartValues`, `chartShowData`, `chartHideData`, `chartCategory`,\n`chartValue`, `chartEmpty`, `chartError`, `validation`); the overlay services (`ok`, `cancel`,\n`actions`, `sheet`, `modal`, `close`, `expand`, `collapse`, `sheetPosition`, `dialog`,\n`notifications`) are root singletons and pick it up only when the caller passes its injector:\n`open(…, { injector: inject(Injector) })`.",
2611
+ "description": "Replaces built-in strings, merged over the parent's (validation merged key by key); a key left\nout or set to `undefined` keeps the parent's string. In `ApplicationConfig.providers` it covers\nevery string. In a component's `providers` it covers that sub-tree's widgets and form controls\n(`loading`, `dismiss`, `tabBar`, `tabBadge`, `back`, `refreshing`, `tableSelectAll`, `tableSortBy`,\n`tableSortNone`, `tableSortAscending`, `tableSortDescending`, `theme`, `themeSystem`, `themeLight`, `themeDark`,\n`breadcrumb`, `breadcrumbExpand`, `navigation`, `navBadge`, `sidebarCollapse`, `sidebarExpand`,\n`skipToContent`, `pagination`, `previousPage`, `nextPage`, `pageOf`, `goToPage`, `tabGroup`,\n`calendarPreviousMonth`, `calendarNextMonth`, `calendarChooseMonth`, `calendarChooseYear`,\n`statTrendUp`, `statTrendDown`, `statTrendFlat`, `comboboxEmpty`, `comboboxRemove`,\n`dateInputDay`, `dateInputMonth`, `dateInputYear`, `dateInputOpenCalendar`, `dateInputInvalid`,\n`dateInputUnavailable`, `timeInputHour`, `timeInputMinute`, `timeInputSecond`,\n`timeInputDayPeriod`, `timeInputOpenList`, `timeInputInvalid`, `timeInputMin`, `timeInputMax`,\n`chart`, `chartLegend`, `chartValues`, `chartShowData`, `chartHideData`, `chartCategory`,\n`chartValue`, `chartEmpty`, `chartError`, `validation`); the overlay services (`ok`, `cancel`,\n`actions`, `sheet`, `modal`, `close`, `expand`, `collapse`, `sheetPosition`, `dialog`,\n`notifications`) are root singletons and pick it up only when the caller passes its injector:\n`open(…, { injector: inject(Injector) })`.",
2603
2612
  "members": [],
2604
2613
  "methods": [
2605
2614
  {
@@ -2614,7 +2623,7 @@
2614
2623
  }
2615
2624
  ],
2616
2625
  "returns": "Provider",
2617
- "doc": "Replaces built-in strings, merged over the parent's (validation merged key by key); a key left\nout or set to `undefined` keeps the parent's string. In `ApplicationConfig.providers` it covers\nevery string. In a component's `providers` it covers that sub-tree's widgets and form controls\n(`loading`, `dismiss`, `tabBar`, `tabBadge`, `back`, `refreshing`, `tableSelectAll`, `tableSortBy`,\n`tableSortAscending`, `tableSortDescending`, `theme`, `themeSystem`, `themeLight`, `themeDark`,\n`breadcrumb`, `breadcrumbExpand`, `navigation`, `navBadge`, `sidebarCollapse`, `sidebarExpand`,\n`skipToContent`, `pagination`, `previousPage`, `nextPage`, `pageOf`, `goToPage`, `tabGroup`,\n`calendarPreviousMonth`, `calendarNextMonth`, `calendarChooseMonth`, `calendarChooseYear`,\n`statTrendUp`, `statTrendDown`, `statTrendFlat`, `comboboxEmpty`, `comboboxRemove`,\n`dateInputDay`, `dateInputMonth`, `dateInputYear`, `dateInputOpenCalendar`, `dateInputInvalid`,\n`dateInputUnavailable`, `timeInputHour`, `timeInputMinute`, `timeInputSecond`,\n`timeInputDayPeriod`, `timeInputOpenList`, `timeInputInvalid`, `timeInputMin`, `timeInputMax`,\n`chart`, `chartLegend`, `chartValues`, `chartShowData`, `chartHideData`, `chartCategory`,\n`chartValue`, `chartEmpty`, `chartError`, `validation`); the overlay services (`ok`, `cancel`,\n`actions`, `sheet`, `modal`, `close`, `expand`, `collapse`, `sheetPosition`, `dialog`,\n`notifications`) are root singletons and pick it up only when the caller passes its injector:\n`open(…, { injector: inject(Injector) })`."
2626
+ "doc": "Replaces built-in strings, merged over the parent's (validation merged key by key); a key left\nout or set to `undefined` keeps the parent's string. In `ApplicationConfig.providers` it covers\nevery string. In a component's `providers` it covers that sub-tree's widgets and form controls\n(`loading`, `dismiss`, `tabBar`, `tabBadge`, `back`, `refreshing`, `tableSelectAll`, `tableSortBy`,\n`tableSortNone`, `tableSortAscending`, `tableSortDescending`, `theme`, `themeSystem`, `themeLight`, `themeDark`,\n`breadcrumb`, `breadcrumbExpand`, `navigation`, `navBadge`, `sidebarCollapse`, `sidebarExpand`,\n`skipToContent`, `pagination`, `previousPage`, `nextPage`, `pageOf`, `goToPage`, `tabGroup`,\n`calendarPreviousMonth`, `calendarNextMonth`, `calendarChooseMonth`, `calendarChooseYear`,\n`statTrendUp`, `statTrendDown`, `statTrendFlat`, `comboboxEmpty`, `comboboxRemove`,\n`dateInputDay`, `dateInputMonth`, `dateInputYear`, `dateInputOpenCalendar`, `dateInputInvalid`,\n`dateInputUnavailable`, `timeInputHour`, `timeInputMinute`, `timeInputSecond`,\n`timeInputDayPeriod`, `timeInputOpenList`, `timeInputInvalid`, `timeInputMin`, `timeInputMax`,\n`chart`, `chartLegend`, `chartValues`, `chartShowData`, `chartHideData`, `chartCategory`,\n`chartValue`, `chartEmpty`, `chartError`, `validation`); the overlay services (`ok`, `cancel`,\n`actions`, `sheet`, `modal`, `close`, `expand`, `collapse`, `sheetPosition`, `dialog`,\n`notifications`) are root singletons and pick it up only when the caller passes its injector:\n`open(…, { injector: inject(Injector) })`."
2618
2627
  }
2619
2628
  ],
2620
2629
  "slots": [],
@@ -9628,7 +9637,7 @@
9628
9637
  ],
9629
9638
  "slots": [],
9630
9639
  "references": [],
9631
- "usedBy": [],
9640
+ "usedBy": ["bf-table-harness"],
9632
9641
  "since": "0.3.0",
9633
9642
  "signature": "class BfSelectHarness extends ComponentHarness"
9634
9643
  },
@@ -9839,6 +9848,13 @@
9839
9848
  "returns": "Promise<BfTableSort | null>",
9840
9849
  "doc": "The sorted column (its `key`) and direction, from `aria-sort`."
9841
9850
  },
9851
+ {
9852
+ "name": "getSortSelect",
9853
+ "signature": "getSortSelect(): Promise<BfSelectHarness | null>",
9854
+ "params": [],
9855
+ "returns": "Promise<BfSelectHarness | null>",
9856
+ "doc": "The Sort by select in a stacked table's bar: its first option, Not sorted (`tableSortNone`),\nsets `sort` to null and shows while it is; then each sortable column ascending and descending."
9857
+ },
9842
9858
  {
9843
9859
  "name": "selectRow",
9844
9860
  "signature": "selectRow(index: number): Promise<void>",
@@ -9869,7 +9885,7 @@
9869
9885
  }
9870
9886
  ],
9871
9887
  "slots": [],
9872
- "references": ["bf-table-sort"],
9888
+ "references": ["bf-select-harness", "bf-table-sort"],
9873
9889
  "usedBy": [],
9874
9890
  "since": "0.3.1",
9875
9891
  "signature": "class BfTableHarness extends ComponentHarness"
@@ -34,8 +34,8 @@ interface Destination {
34
34
  * The bf-app-frame overview.
35
35
  *
36
36
  * Live demo of `bf-app-frame` in a box of fixed height: one destinations array drives the
37
- * `bf-sidebar` (from `md` up) and the narrow header's `bf-menu-button`; the header also holds the
38
- * brand and a notifications action. Tab into the frame to reveal its skip link; Enter moves focus
37
+ * `bf-sidebar` (from `md` up) and the narrow header's `bf-menu-button`, at the header's inline
38
+ * start; the brand follows it, and a notifications action sits at the end. Tab into the frame to reveal its skip link; Enter moves focus
39
39
  * to the content. The result line reports it.
40
40
  */
41
41
  @Component({
@@ -75,11 +75,11 @@ interface Destination {
75
75
  }
76
76
  </bf-sidebar>
77
77
 
78
- <span bfSlot="brand" class="text-heading">Atlas</span>
79
78
  <bf-menu-button bfSlot="menu" variant="text" [menu]="navMenu">
80
79
  <bf-icon bfSlot="icon-start" [icon]="menu" />
81
80
  Sections
82
81
  </bf-menu-button>
82
+ <span bfSlot="brand" class="text-heading">Atlas</span>
83
83
  <bf-icon-button
84
84
  bfSlot="actions"
85
85
  variant="text"
@@ -146,7 +146,7 @@ export class AppFrameOverviewExampleComponent {
146
146
  ## Overview
147
147
 
148
148
  The frame of a desktop web app: a skip link, a sticky full-height sidebar column from the
149
- breakpoint up, a header bar with the brand, the narrow-screen menu and actions below it, and
149
+ breakpoint up, a header bar with the narrow-screen menu, the brand and actions below it, and
150
150
  the `main` landmark with a content column of a set maximum width.
151
151
 
152
152
  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.
@@ -157,7 +157,7 @@ Use `bf-app-frame` once, at the root of a desktop web app whose top-level destin
157
157
 
158
158
  ## Slots and layout
159
159
 
160
- 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.
160
+ 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 menu at the inline start, then the brand, and the actions at the end: the top app bar convention, and the menu comes first in the tab order too. The order is fixed, whatever order the slots have in your template. Keep the brand in the sidebar's own `header` slot too, for the wide layout.
161
161
 
162
162
  ## Breakpoint and content width
163
163
 
@@ -294,8 +294,8 @@ The skip link is a native `<a>` with an href to the main landmark and is the fir
294
294
  ## Slots
295
295
 
296
296
  - `sidebar`: The `bf-sidebar`, shown from the breakpoint up; it fills the column's height.
297
- - `brand`: The app's name or logo in the narrow header bar.
298
- - `menu`: The narrow-screen navigation: a `bf-menu-button` of `a[bfMenuItem]` links.
297
+ - `menu`: The narrow-screen navigation: a `bf-menu-button` of `a[bfMenuItem]` links, at the header bar's inline start.
298
+ - `brand`: The app's name or logo in the narrow header bar, after the menu.
299
299
  - `actions`: End actions of the narrow header bar, such as the theme menu.
300
300
  - `default`: The page: what the router shows, inside the `main` landmark.
301
301
 
@@ -314,6 +314,13 @@ The skip link is a native `<a>` with an href to the main landmark and is the fir
314
314
 
315
315
  ## Migration notes
316
316
 
317
+ ### 0.3.3
318
+
319
+ Fix: the narrow header bar puts the `menu` slot at the inline start, before the `brand` (0.3.2 put
320
+ it after the brand), with the actions at the end as before: the top app bar convention. The menu
321
+ also comes first in the tab order. No API change; an app needs no change unless it relied on the
322
+ brand coming first.
323
+
317
324
  ### 0.3.2
318
325
 
319
326
  New `bf-app-frame` in `@bitforgehq/ui`: the frame of a desktop web app. Slots `sidebar` (a
@@ -748,6 +748,15 @@ The title is not in the tab order; after a push, `bf-stack-outlet` focuses it wi
748
748
 
749
749
  ## Migration notes
750
750
 
751
+ ### 0.3.3
752
+
753
+ Fix: `bf-header` measures its title and slots in the next animation frame after a resize, not
754
+ inside the `ResizeObserver` callback. Its measurement writes the title inset and height, which
755
+ resize the observed elements, and WebKit then reported "ResizeObserver loop completed with
756
+ undelivered notifications": an error event that an app's global error listener
757
+ (`provideBrowserGlobalErrorListeners()`) logs as an error. Several resizes in one frame now
758
+ measure once.
759
+
751
760
  ### 0.3.2
752
761
 
753
762
  The `## Desktop` docs say `bf-app-frame` supplies the app-level header bar below its breakpoint.
@@ -405,7 +405,7 @@ export class TablePagedExampleComponent {
405
405
 
406
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
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 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.
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
409
 
410
410
  ```ts
411
411
  import { Component, computed, signal } from '@angular/core';
@@ -440,7 +440,8 @@ type Width = 'phone' | 'full';
440
440
  *
441
441
  * `stack="narrow"` turns each row into a tile of label/value pairs while the table is narrower
442
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. Switch the box between phone width and full width.
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.
444
445
  */
445
446
  @Component({
446
447
  selector: 'app-example-table-stacked',
@@ -587,7 +588,7 @@ Put the table on a `bf-card` (or another surface) yourself; it has no background
587
588
 
588
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.
589
590
 
590
- 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.
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.
591
592
 
592
593
  ### Keyboard
593
594
 
@@ -665,6 +666,23 @@ Selects or clears one row.
665
666
 
666
667
  ## Migration notes
667
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
+
668
686
  ### 0.3.2
669
687
 
670
688
  New opt-in `stack` input (`BfTableStack`: `'never'` by default, so existing tables do not
@@ -33,6 +33,7 @@ import { type BfLabels } from '@bitforgehq/ui';
33
33
  | `notifications` | `string` | `'Notifications'` | The desktop toast stack's region name. |
34
34
  | `tableSelectAll` | `string` | `'Select all rows'` | bf-table's header checkbox, which selects or clears every row. |
35
35
  | `tableSortBy` | `string` | `'Sort by'` | The label of a stacked bf-table's sort select. |
36
+ | `tableSortNone` | `string` | `'Not sorted'` | A stacked bf-table's first sort option: rows in the order given, no column sorted. |
36
37
  | `tableSortAscending` | `(column: string) => string` | `` (column) => `${column}, ascending` `` | A stacked bf-table's sort option for a column in ascending order. |
37
38
  | `tableSortDescending` | `(column: string) => string` | `` (column) => `${column}, descending` `` | A stacked bf-table's sort option for a column in descending order. |
38
39
  | `theme` | `string` | `'Theme'` | bf-theme-switch's and bf-theme-menu-button's name without a label. |
@@ -68,3 +68,7 @@ Focuses the native select.
68
68
  ### `blur(): Promise<void>`
69
69
 
70
70
  Blurs the native select, which marks a bound form field touched.
71
+
72
+ ## Used by
73
+
74
+ - [BfTableHarness](bf-table-harness.md)
@@ -54,6 +54,11 @@ Throws for an unknown or unsortable column.
54
54
 
55
55
  The sorted column (its `key`) and direction, from `aria-sort`.
56
56
 
57
+ ### `getSortSelect(): Promise<BfSelectHarness | null>`
58
+
59
+ The Sort by select in a stacked table's bar: its first option, Not sorted (`tableSortNone`),
60
+ sets `sort` to null and shows while it is; then each sortable column ascending and descending.
61
+
57
62
  ### `selectRow(index: number): Promise<void>`
58
63
 
59
64
  Checks one row's checkbox, as a user would; a selected row stays selected. Throws when the
@@ -74,4 +79,5 @@ The number of selected rows (checked row checkboxes).
74
79
 
75
80
  ## Uses
76
81
 
82
+ - [BfSelectHarness](bf-select-harness.md)
77
83
  - [BfTableSort](bf-table-sort.md)
@@ -8,7 +8,7 @@ Replaces built-in strings, merged over the parent's (validation merged key by ke
8
8
  out or set to `undefined` keeps the parent's string. In `ApplicationConfig.providers` it covers
9
9
  every string. In a component's `providers` it covers that sub-tree's widgets and form controls
10
10
  (`loading`, `dismiss`, `tabBar`, `tabBadge`, `back`, `refreshing`, `tableSelectAll`, `tableSortBy`,
11
- `tableSortAscending`, `tableSortDescending`, `theme`, `themeSystem`, `themeLight`, `themeDark`,
11
+ `tableSortNone`, `tableSortAscending`, `tableSortDescending`, `theme`, `themeSystem`, `themeLight`, `themeDark`,
12
12
  `breadcrumb`, `breadcrumbExpand`, `navigation`, `navBadge`, `sidebarCollapse`, `sidebarExpand`,
13
13
  `skipToContent`, `pagination`, `previousPage`, `nextPage`, `pageOf`, `goToPage`, `tabGroup`,
14
14
  `calendarPreviousMonth`, `calendarNextMonth`, `calendarChooseMonth`, `calendarChooseYear`,
@@ -2959,13 +2959,22 @@ var BfHeaderComponent = class BfHeaderComponent {
2959
2959
  };
2960
2960
  measure();
2961
2961
  if (typeof ResizeObserver !== "undefined") {
2962
- const observer = new ResizeObserver(measure);
2962
+ let frame = null;
2963
+ const observer = new ResizeObserver(() => {
2964
+ frame ??= requestAnimationFrame(() => {
2965
+ frame = null;
2966
+ measure();
2967
+ });
2968
+ });
2963
2969
  observer.observe(toolbar);
2964
2970
  observer.observe(start);
2965
2971
  observer.observe(end);
2966
2972
  for (const element of [title, sizer]) if (element) observer.observe(element);
2967
2973
  if (block) observer.observe(block);
2968
- onCleanup(() => observer.disconnect());
2974
+ onCleanup(() => {
2975
+ observer.disconnect();
2976
+ if (frame !== null) cancelAnimationFrame(frame);
2977
+ });
2969
2978
  }
2970
2979
  });
2971
2980
  let destroyed = false;