@bitforgehq/ui 0.5.0-next.3 → 0.5.0-next.4

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.
@@ -0,0 +1,452 @@
1
+ # bf-image-tile
2
+
3
+ Small card for thumbnail grids: a picture and title that act as one target
4
+
5
+ `BfImageTileComponent` from `@bitforgehq/ui` · Data display · since 0.5.0
6
+
7
+ ## Example
8
+
9
+ ```ts
10
+ import { Component, DestroyRef, inject, signal } from '@angular/core';
11
+ import { BfActionDirective, BfBadgeComponent, BfImageTileComponent } from '@bitforgehq/ui';
12
+ import { BfIconComponent } from '@bitforgehq/ui/icons';
13
+ import { Box, ImagePlus } from 'lucide';
14
+
15
+ /** One model in the grid, as the app tracks it. */
16
+ interface Model {
17
+ readonly id: string;
18
+ readonly name: string;
19
+ readonly src: string;
20
+ readonly alt: string;
21
+ readonly versions: number;
22
+ readonly updated: string;
23
+ }
24
+
25
+ /** How long the simulated render of the last model takes. */
26
+ const RENDER_MS = 3000;
27
+
28
+ /**
29
+ * The bf-image-tile overview.
30
+ *
31
+ * A grid of 3D models, one picture each: a click selects a tile (the ring, and "Selected" read
32
+ * before its title), a badge counts the versions, the last picture is still rendering and fades
33
+ * in when it arrives, and a model without a render yet shows the placeholder. The titles differ
34
+ * in length, yet every tile in a row is as tall.
35
+ */
36
+ @Component({
37
+ selector: 'app-example-image-tile-overview',
38
+ imports: [BfActionDirective, BfBadgeComponent, BfIconComponent, BfImageTileComponent],
39
+ template: `
40
+ <div class="grid grid-cols-2 gap-4 @md:grid-cols-4">
41
+ @for (model of models(); track model.id) {
42
+ <bf-image-tile
43
+ [src]="model.src"
44
+ [alt]="model.alt"
45
+ [loading]="model.id === 'lantern' && rendering()"
46
+ [selected]="model.id === current()"
47
+ >
48
+ <h2>
49
+ <button bfAction type="button" (click)="current.set(model.id)">{{ model.name }}</button>
50
+ </h2>
51
+ <span bfSlot="meta">{{ model.updated }}</span>
52
+ @if (model.versions > 1) {
53
+ <bf-badge bfSlot="badge" variant="info"
54
+ ><bf-icon bfSlot="icon-start" [icon]="box" />{{ model.versions }} versions</bf-badge
55
+ >
56
+ }
57
+ <bf-icon bfSlot="placeholder-icon" [icon]="imagePlus" />
58
+ </bf-image-tile>
59
+ }
60
+ </div>
61
+ <p aria-live="polite" class="text-caption text-text-muted mt-3">
62
+ Selected: {{ currentName() }}
63
+ </p>
64
+ `,
65
+ })
66
+ export class ImageTileOverviewExampleComponent {
67
+ protected readonly box = Box;
68
+ protected readonly imagePlus = ImagePlus;
69
+ protected readonly current = signal('ship');
70
+ protected readonly rendering = signal(true);
71
+ protected readonly models = signal<Model[]>([
72
+ {
73
+ id: 'ship',
74
+ name: 'Harbour ship',
75
+ src: 'media/harbour.svg',
76
+ alt: 'A ship in a harbour',
77
+ versions: 3,
78
+ updated: 'Today',
79
+ },
80
+ {
81
+ id: 'tower',
82
+ name: 'Glacier watchtower with a long rope bridge',
83
+ src: 'media/glacier.svg',
84
+ alt: 'A tower on a glacier',
85
+ versions: 1,
86
+ updated: 'Yesterday',
87
+ },
88
+ { id: 'well', name: 'Desert well', src: '', alt: '', versions: 1, updated: 'Last week' },
89
+ {
90
+ id: 'lantern',
91
+ name: 'Forest lantern',
92
+ src: '',
93
+ alt: 'A lantern in a pine forest',
94
+ versions: 2,
95
+ updated: 'Rendering…',
96
+ },
97
+ ]);
98
+
99
+ constructor() {
100
+ const timer = setTimeout(() => {
101
+ this.models.update((models) =>
102
+ models.map((m) =>
103
+ m.id === 'lantern' ? { ...m, src: 'media/forest.svg', updated: 'Just now' } : m,
104
+ ),
105
+ );
106
+ this.rendering.set(false);
107
+ }, RENDER_MS);
108
+ inject(DestroyRef).onDestroy(() => clearTimeout(timer));
109
+ }
110
+
111
+ protected currentName(): string {
112
+ return this.models().find((m) => m.id === this.current())?.name ?? '';
113
+ }
114
+ }
115
+ ```
116
+
117
+ ## Overview
118
+
119
+ Small card for a grid of thumbnails: mostly picture, at a fixed aspect ratio (square by
120
+ default), with a title and an optional meta line below it or, with `captionPlacement="overlay"`,
121
+ on a soft gradient over its bottom. Without a picture yet it shows a placeholder (an icon and a
122
+ text); while one loads a shimmering skeleton; the picture fades in when it arrives; a failed load
123
+ shows a short message. Make the whole tile one link or button with a `bfAction` in its title, as
124
+ on `bf-card`: it then lifts on hover, zooms the picture slightly and shows the focus ring.
125
+ `selected` draws a ring. The title clamps to two lines and always takes two, so tiles in a grid
126
+ row are as tall whatever their titles.
127
+
128
+ A small card for a grid of thumbnails: 3D models with one render each, a board of reference pictures, a gallery. It is mostly picture, at a fixed aspect ratio (square by default), with a title and an optional meta line below it or over it on a soft gradient. Like `bf-media-card` it owns the whole life of the picture: a placeholder before there is one, a shimmering skeleton while it loads, a fade when it arrives and a short message when it fails.
129
+
130
+ ## When to use
131
+
132
+ Use `bf-image-tile` for many pictures in a grid, where each one opens or selects something and the picture says more than the text. Use [bf-media-card](https://bitforge-ui.web.app/components/media-card) instead when a card also needs a body text, a footer with buttons or a select-several check button, and [bf-card](https://bitforge-ui.web.app/components/card) for a card without a picture. Open the full-size picture with [bf-image-viewer](https://bitforge-ui.web.app/components/image-viewer).
133
+
134
+ ## Basic usage
135
+
136
+ Give each tile the picture's `src` and `alt`, a heading with a `bfAction` button or link as its title, and an optional `bfSlot="meta"` line such as a date or a count. A `bf-badge` in `bfSlot="badge"` sits in the picture's top start corner. `selected` marks the current tile with a ring. The titles differ in length, yet every tile in a row is as tall.
137
+
138
+ ```ts
139
+ import { Component, DestroyRef, inject, signal } from '@angular/core';
140
+ import { BfActionDirective, BfBadgeComponent, BfImageTileComponent } from '@bitforgehq/ui';
141
+ import { BfIconComponent } from '@bitforgehq/ui/icons';
142
+ import { Box, ImagePlus } from 'lucide';
143
+
144
+ /** One model in the grid, as the app tracks it. */
145
+ interface Model {
146
+ readonly id: string;
147
+ readonly name: string;
148
+ readonly src: string;
149
+ readonly alt: string;
150
+ readonly versions: number;
151
+ readonly updated: string;
152
+ }
153
+
154
+ /** How long the simulated render of the last model takes. */
155
+ const RENDER_MS = 3000;
156
+
157
+ /**
158
+ * The bf-image-tile overview.
159
+ *
160
+ * A grid of 3D models, one picture each: a click selects a tile (the ring, and "Selected" read
161
+ * before its title), a badge counts the versions, the last picture is still rendering and fades
162
+ * in when it arrives, and a model without a render yet shows the placeholder. The titles differ
163
+ * in length, yet every tile in a row is as tall.
164
+ */
165
+ @Component({
166
+ selector: 'app-example-image-tile-overview',
167
+ imports: [BfActionDirective, BfBadgeComponent, BfIconComponent, BfImageTileComponent],
168
+ template: `
169
+ <div class="grid grid-cols-2 gap-4 @md:grid-cols-4">
170
+ @for (model of models(); track model.id) {
171
+ <bf-image-tile
172
+ [src]="model.src"
173
+ [alt]="model.alt"
174
+ [loading]="model.id === 'lantern' && rendering()"
175
+ [selected]="model.id === current()"
176
+ >
177
+ <h2>
178
+ <button bfAction type="button" (click)="current.set(model.id)">{{ model.name }}</button>
179
+ </h2>
180
+ <span bfSlot="meta">{{ model.updated }}</span>
181
+ @if (model.versions > 1) {
182
+ <bf-badge bfSlot="badge" variant="info"
183
+ ><bf-icon bfSlot="icon-start" [icon]="box" />{{ model.versions }} versions</bf-badge
184
+ >
185
+ }
186
+ <bf-icon bfSlot="placeholder-icon" [icon]="imagePlus" />
187
+ </bf-image-tile>
188
+ }
189
+ </div>
190
+ <p aria-live="polite" class="text-caption text-text-muted mt-3">
191
+ Selected: {{ currentName() }}
192
+ </p>
193
+ `,
194
+ })
195
+ export class ImageTileOverviewExampleComponent {
196
+ protected readonly box = Box;
197
+ protected readonly imagePlus = ImagePlus;
198
+ protected readonly current = signal('ship');
199
+ protected readonly rendering = signal(true);
200
+ protected readonly models = signal<Model[]>([
201
+ {
202
+ id: 'ship',
203
+ name: 'Harbour ship',
204
+ src: 'media/harbour.svg',
205
+ alt: 'A ship in a harbour',
206
+ versions: 3,
207
+ updated: 'Today',
208
+ },
209
+ {
210
+ id: 'tower',
211
+ name: 'Glacier watchtower with a long rope bridge',
212
+ src: 'media/glacier.svg',
213
+ alt: 'A tower on a glacier',
214
+ versions: 1,
215
+ updated: 'Yesterday',
216
+ },
217
+ { id: 'well', name: 'Desert well', src: '', alt: '', versions: 1, updated: 'Last week' },
218
+ {
219
+ id: 'lantern',
220
+ name: 'Forest lantern',
221
+ src: '',
222
+ alt: 'A lantern in a pine forest',
223
+ versions: 2,
224
+ updated: 'Rendering…',
225
+ },
226
+ ]);
227
+
228
+ constructor() {
229
+ const timer = setTimeout(() => {
230
+ this.models.update((models) =>
231
+ models.map((m) =>
232
+ m.id === 'lantern' ? { ...m, src: 'media/forest.svg', updated: 'Just now' } : m,
233
+ ),
234
+ );
235
+ this.rendering.set(false);
236
+ }, RENDER_MS);
237
+ inject(DestroyRef).onDestroy(() => clearTimeout(timer));
238
+ }
239
+
240
+ protected currentName(): string {
241
+ return this.models().find((m) => m.id === this.current())?.name ?? '';
242
+ }
243
+ }
244
+ ```
245
+
246
+ ## States
247
+
248
+ Without a `src` the tile shows a placeholder: the `bfSlot="placeholder-icon"` icon and `placeholderLabel`, or the `imageTileEmpty` label ("No image yet"). `loading` shows a `bf-skeleton` shimmer while there is no `src` yet (a render that is still being made), and the same shimmer shows while a `src` loads. The picture fades in over 260 ms (`bfFadeIn`) while the skeleton fades out under it; under reduced motion it appears at once. A picture that fails to load, or one the app could not make (`failed`), shows `failedLabel` or the `imageLoadFailed` label ("The image could not be loaded"). A new `src` starts over and forgets a load error; `failed` is the app's to clear. `loaded` and `errored` report the picture's events.
249
+
250
+ ```ts
251
+ import { Component } from '@angular/core';
252
+ import { BfImageTileComponent } from '@bitforgehq/ui';
253
+ import { BfIconComponent } from '@bitforgehq/ui/icons';
254
+ import { ImagePlus } from 'lucide';
255
+
256
+ /**
257
+ * The four states of a tile side by side.
258
+ *
259
+ * Without `src` the placeholder shows (an icon and "No image yet"); `loading` shows the skeleton;
260
+ * a `src` fades in when it loads; `failed` (or a load error) shows a short message.
261
+ */
262
+ @Component({
263
+ selector: 'app-example-image-tile-states',
264
+ imports: [BfIconComponent, BfImageTileComponent],
265
+ template: `
266
+ <div class="grid grid-cols-2 gap-4 @md:grid-cols-4">
267
+ <bf-image-tile>
268
+ <h2>Placeholder</h2>
269
+ <bf-icon bfSlot="placeholder-icon" [icon]="imagePlus" />
270
+ </bf-image-tile>
271
+ <bf-image-tile loading><h2>Loading</h2></bf-image-tile>
272
+ <bf-image-tile src="media/forest.svg" alt="A pine forest under a pale sun">
273
+ <h2>Loaded</h2>
274
+ </bf-image-tile>
275
+ <bf-image-tile failed failedLabel="Render failed"><h2>Failed</h2></bf-image-tile>
276
+ </div>
277
+ `,
278
+ })
279
+ export class ImageTileStatesExampleComponent {
280
+ protected readonly imagePlus = ImagePlus;
281
+ }
282
+ ```
283
+
284
+ ## Caption over the picture
285
+
286
+ `captionPlacement="overlay"` lays the title and the meta line on a soft gradient over the bottom of the picture, so the tile is as tall as its picture and a board of pictures stays dense. The gradient is the `scrim` colour, darkest under the text, and the caption uses the dark theme's text colours, so it reads on light and dark pictures in both themes. `aspect` is `1/1` (default), `4/3`, `16/9` or `3/4` (portrait); the picture covers its area and is cropped to it.
287
+
288
+ ```ts
289
+ import { Component } from '@angular/core';
290
+ import { BfActionDirective, BfBadgeComponent, BfImageTileComponent } from '@bitforgehq/ui';
291
+ import { BfIconComponent } from '@bitforgehq/ui/icons';
292
+ import { Check } from 'lucide';
293
+
294
+ /** One reference picture on the board. */
295
+ interface Reference {
296
+ readonly id: string;
297
+ readonly title: string;
298
+ readonly source: string;
299
+ readonly src: string;
300
+ readonly reference: boolean;
301
+ }
302
+
303
+ /**
304
+ * A board of reference pictures with the caption over the picture.
305
+ *
306
+ * `captionPlacement="overlay"` lays the title and meta line on a soft gradient over the bottom of
307
+ * a 4:3 picture; each tile is a link, and a check badge marks the chosen references.
308
+ */
309
+ @Component({
310
+ selector: 'app-example-image-tile-overlay',
311
+ imports: [BfActionDirective, BfBadgeComponent, BfIconComponent, BfImageTileComponent],
312
+ template: `
313
+ <div class="grid grid-cols-1 gap-4 @sm:grid-cols-2 @lg:grid-cols-3">
314
+ @for (item of references; track item.id) {
315
+ <bf-image-tile [src]="item.src" alt="" aspect="4/3" captionPlacement="overlay">
316
+ <h2>
317
+ <a bfAction [href]="'#' + item.id">{{ item.title }}</a>
318
+ </h2>
319
+ <span bfSlot="meta">{{ item.source }}</span>
320
+ @if (item.reference) {
321
+ <bf-badge bfSlot="badge" variant="success" emphasis="strong"
322
+ ><bf-icon bfSlot="icon-start" [icon]="check" />Reference</bf-badge
323
+ >
324
+ }
325
+ </bf-image-tile>
326
+ }
327
+ </div>
328
+ `,
329
+ })
330
+ export class ImageTileOverlayExampleComponent {
331
+ protected readonly check = Check;
332
+ protected readonly references: Reference[] = [
333
+ {
334
+ id: 'skyline',
335
+ title: 'Skyline at dusk',
336
+ source: 'Moodboard',
337
+ src: 'media/skyline.svg',
338
+ reference: true,
339
+ },
340
+ {
341
+ id: 'desert',
342
+ title: 'Dunes in the late afternoon, warm light from the west',
343
+ source: 'Upload',
344
+ src: 'media/desert.svg',
345
+ reference: false,
346
+ },
347
+ {
348
+ id: 'glacier',
349
+ title: 'Glacier',
350
+ source: 'Moodboard',
351
+ src: 'media/glacier.svg',
352
+ reference: true,
353
+ },
354
+ ];
355
+ }
356
+ ```
357
+
358
+ ## Grids of tiles
359
+
360
+ Put the tiles straight into a CSS grid (`grid grid-cols-2 gap-4`, more columns on wider screens). Each tile stretches to its row. The title is clamped to two lines with an ellipsis and always takes the height of two, so a short title and a long one make tiles of the same height; give every tile in a grid a meta line, or none, for the same reason. Below the picture the caption adds 12 px of padding on each side.
361
+
362
+ ## Desktop
363
+
364
+ A tile with a `bfAction` shows the pointer and, while the pointer is over it, lifts by 2 px to `shadow-floating` and zooms its picture by 5 % inside its frame; pressing it sets it back down. The lift runs on the base timing (200 ms) and the zoom on the slow timing (260 ms). Under reduced motion neither moves: only the shadow changes. The focus ring shows around the whole tile while its action has keyboard focus, not after a click. Text under the stretched action cannot be selected with the mouse.
365
+
366
+ ## Theming
367
+
368
+ - Tile: `bg-surface`, `text-text`, `rounded-card`, `shadow-raised` (`shadow-floating` on hover).
369
+ - Picture area: `bg-surface-sunken`; the skeleton is a `bf-skeleton` rect; the placeholder and failure texts are `text-caption text-text-muted`.
370
+ - Caption: the title is `text-label-sm` bold, the meta line `text-caption text-text-muted`; over the picture, a `scrim` gradient (80 % to transparent) with the `.dark` text tokens.
371
+ - Selection: a 2 px `primary` outline 2 px outside the tile.
372
+ - Forced colours: a 1 px `CanvasText` border keeps each tile's edge; the selection ring is `Highlight`.
373
+ - Focus: `ring-4 ring-ring` around the tile.
374
+ - Motion: `duration-base` (200 ms) for the lift, shadow and selection ring, `duration-slow` (260 ms) for the zoom and the picture's fade, on `ease-md`; no movement under reduced motion.
375
+
376
+ ## Accessibility
377
+
378
+ The tile is a plain `<div>`; the picture is an `<img>` with `alt`. When the title names the picture well enough, as in a board of references, leave `alt` empty so it is not read twice. A clickable tile follows the stretched-link pattern of `bf-card`: the only interactive element is the `bfAction` link or button in its title, and its name is the title. A selected tile starts that name with the `imageTileSelected` label, so a screen reader hears "Selected, Harbour ship"; without a `bfAction` the same text is read in the tile. While loading, the picture area is `aria-busy="true"` with a visually hidden "Loading", not a live region, so a grid of loading tiles is not announced tile by tile. Under forced colours the selection ring becomes a `Highlight` outline and the focus indicator a system outline around the tile.
379
+
380
+ ### Keyboard
381
+
382
+ | Key | Action |
383
+ | ---------------- | ---------------------------------------------------------- |
384
+ | <kbd>Tab</kbd> | Moves to the tile's `bfAction` link or button |
385
+ | <kbd>Enter</kbd> | Follows a `bfAction` link or activates a `bfAction` button |
386
+ | <kbd>Space</kbd> | Activates a `bfAction` button; a link ignores it |
387
+
388
+ ## Inputs
389
+
390
+ | Name | Type | Default | Description |
391
+ | ------------------ | --------------------------------------------------------- | --------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
392
+ | `src` | `string` | `''` | URL of the picture; empty shows the placeholder, or the skeleton while `loading`. A new URL starts over: skeleton, fade-in, and a failed load is forgotten. |
393
+ | `alt` | `string` | `''` | Alternative text of the picture; empty marks it decorative (the title names the tile). |
394
+ | `aspect` | `BfImageTileAspect` (`'1/1' \| '4/3' \| '16/9' \| '3/4'`) | `'1/1'` | Aspect ratio of the picture: `1/1` (default), `4/3`, `16/9` or `3/4`. |
395
+ | `captionPlacement` | `BfImageTileCaptionPlacement` (`'below' \| 'overlay'`) | `'below'` | Where the title and the meta line sit: `below` the picture (default) or `overlay`, on a gradient over its bottom. |
396
+ | `loading` | `boolean` | `false` | Shows the skeleton while there is no `src` yet (a picture that is still being made). |
397
+ | `failed` | `boolean` | `false` | Shows the failure message (the app could not make the picture); the app clears it. A load error of `src` shows it too, until `src` changes. |
398
+ | `selected` | `boolean` | `false` | Marks the tile as the selected one: a ring around it, and "Selected" read before its title. |
399
+ | `placeholderLabel` | `string` | `''` | Placeholder text while there is no picture; empty uses `provideBfLabels()`'s `imageTileEmpty` ("No image yet"). |
400
+ | `failedLabel` | `string` | `''` | Failure message; empty uses `provideBfLabels()`'s `imageLoadFailed`. |
401
+
402
+ ## Outputs
403
+
404
+ | Name | Payload | Description |
405
+ | --------- | ------- | ------------------------------------------------------------------------- |
406
+ | `loaded` | `void` | The picture of `src` has loaded. |
407
+ | `errored` | `void` | The picture of `src` failed to load (the tile shows the failure message). |
408
+
409
+ ## Slots
410
+
411
+ - `placeholder-icon`: The placeholder's icon, shown with its text while there is no picture.
412
+ - `badge`: A `bf-badge` in the picture's top start corner.
413
+ - `default`: The title: a heading holding the tile's `bfAction` link or button.
414
+ - `meta`: A short line under the title, such as a date or a count.
415
+
416
+ ## Notes
417
+
418
+ ### Do
419
+
420
+ - Use a heading for the title, at the level that fits the page, with the `bfAction` inside it.
421
+ - Set `loading` while a picture is being made, so the tile holds its place.
422
+ - Keep the meta line short: a date, a count, a source.
423
+
424
+ ### Don't
425
+
426
+ - Don't put more than one `bfAction` in a tile, or other buttons in it; use a `bf-media-card` for a card with actions.
427
+ - Don't project your own `<img>` for the picture; pass `src`, so the tile handles loading, fading and failing.
428
+ - Don't rely on the ring alone to mark the selected tile in your own text: the tile already tells assistive technology.
429
+
430
+ ## Migration notes
431
+
432
+ ### 0.5.0
433
+
434
+ New: `bf-image-tile`, a small card for grids of thumbnails (3D models with one render each, a
435
+ board of reference pictures). It is mostly picture, at a fixed `aspect` (new type
436
+ `BfImageTileAspect`: `'1/1'` (default), `'4/3'`, `'16/9'`, `'3/4'`), with the title (default slot,
437
+ a heading holding a `bfAction` link or button) and an optional `bfSlot="meta"` line below the
438
+ picture, or with `captionPlacement="overlay"` (new type `BfImageTileCaptionPlacement`: `'below'`
439
+ (default), `'overlay'`) on a soft `scrim` gradient over its bottom. States share
440
+ `bf-media-card`'s logic: without `src` a placeholder (`bfSlot="placeholder-icon"` and
441
+ `placeholderLabel`, default: the new label `imageTileEmpty`, "No image yet"); `loading`, and a
442
+ `src` that is loading, show a `bf-skeleton` shimmer (`aria-busy`); the picture fades in over
443
+ 260 ms; a load error or `failed` shows `failedLabel` (default `imageLoadFailed`). `loaded` and
444
+ `errored` report the picture's events. A `bfAction` makes the whole tile one link or button: the
445
+ pointer, a 2 px lift to `shadow-floating` and a 5 % zoom of the picture on hover (no movement
446
+ under reduced motion), and the focus ring. `selected` draws a 2 px primary ring and starts the
447
+ action's name with the new label `imageTileSelected` ("Selected, Harbour ship"). `bfSlot="badge"`
448
+ puts a `bf-badge` in the picture's top start corner. The title clamps to two lines and always
449
+ takes two, so tiles in a grid row are as tall. `@bitforgehq/ui/testing`: new
450
+ `BfImageTileHarness` (`getTitle()`, `getMeta()`, `getState()`, `getSrc()`, `getAlt()`,
451
+ `getPlaceholderText()`, `getFailedMessage()`, `isSelected()`, `isClickable()`, `click()`,
452
+ `loadImage()`, `failImage()`).
@@ -288,6 +288,55 @@ export class MenuButtonSizesExampleComponent {
288
288
  }
289
289
  ```
290
290
 
291
+ ## Full width
292
+
293
+ `fullWidth` stretches the host and its button to the width of their container, as on
294
+ `bf-button`: in a grid of actions, a "More" menu button then fills its cell like the buttons
295
+ beside it. The label and caret stay centred. It does nothing with `iconOnly`, which stays a
296
+ square.
297
+
298
+ ```ts
299
+ import { Component, signal } from '@angular/core';
300
+ import { BfButtonComponent } from '@bitforgehq/ui';
301
+ import {
302
+ BfMenuButtonComponent,
303
+ BfMenuComponent,
304
+ BfMenuItemDirective,
305
+ } from '@bitforgehq/ui/overlay';
306
+
307
+ /**
308
+ * A two-column grid of actions on a card, where a menu button fills its cell like the buttons
309
+ * beside it.
310
+ *
311
+ * `fullWidth` stretches the host and its button, as on `bf-button`.
312
+ */
313
+ @Component({
314
+ selector: 'app-example-menu-button-full-width',
315
+ imports: [BfButtonComponent, BfMenuButtonComponent, BfMenuComponent, BfMenuItemDirective],
316
+ template: `
317
+ <div class="grid max-w-sm grid-cols-2 gap-2">
318
+ <bf-button variant="outline" fullWidth (clicked)="result.set('Opened.')">Open</bf-button>
319
+ <bf-button variant="outline" fullWidth (clicked)="result.set('Duplicated.')"
320
+ >Duplicate</bf-button
321
+ >
322
+ <bf-button variant="outline" fullWidth (clicked)="result.set('Shared.')">Share</bf-button>
323
+ <bf-menu-button [menu]="more" fullWidth>More</bf-menu-button>
324
+ </div>
325
+ <p aria-live="polite" class="text-caption text-text-muted mt-3">{{ result() }}</p>
326
+
327
+ <ng-template #more>
328
+ <bf-menu>
329
+ <button bfMenuItem (triggered)="result.set('Renamed.')">Rename</button>
330
+ <button bfMenuItem (triggered)="result.set('Archived.')">Archive</button>
331
+ </bf-menu>
332
+ </ng-template>
333
+ `,
334
+ })
335
+ export class MenuButtonFullWidthExampleComponent {
336
+ protected readonly result = signal('');
337
+ }
338
+ ```
339
+
291
340
  ## Desktop
292
341
 
293
342
  Hover and focus look like `bf-button` (or `bf-icon-button` with `iconOnly`): the fill, border or text darkens on hover with the pointer cursor, and the focus ring shows on keyboard focus only. A click opens the menu; a second click on the button closes it. Under a fine pointer the visible box is the hit area. Desktop density is the default `md` step; `provideBfDefaults({ size: 'sm' })`, or `{ size: 'sm' }` in a component's `providers`, makes a dense toolbar, table or sidebar ([density guide](https://bitforge-ui.web.app/density)).
@@ -327,15 +376,16 @@ The keys inside the open menu are on the [bf-menu](https://bitforge-ui.web.app/c
327
376
 
328
377
  ## Inputs
329
378
 
330
- | Name | Type | Default | Description |
331
- | ----------- | ---------------------------------------------------------------------------------- | --------------- | -------------------------------------------------------------------------------------------------------------------------------- |
332
- | `menu` | `TemplateRef<unknown>` | required | The `ng-template` holding the `bf-menu` to open. |
333
- | `variant` | `BfMenuButtonVariant` (`'outline' \| 'text' \| 'tonal'`) | `'outline'` | Visual style: outline (default), text, tonal. |
334
- | `size` | `BfSize \| undefined` (`'sm' \| 'md' \| 'lg' \| undefined`) | `undefined` | Height step: sm 32, md 40, lg 48 px; unset follows `provideBfDefaults()` (`controlSize`, `md` by default). |
335
- | `placement` | `BfMenuPlacement` (`'below-start' \| 'below-end' \| 'above-start' \| 'above-end'`) | `'below-start'` | Where the menu opens against the button; it flips when it would overflow. |
336
- | `label` | `string` | `''` | Accessible name (`aria-label`). Required with `iconOnly`; with a visible label leave it empty or start it with the visible text. |
337
- | `iconOnly` | `boolean` | `false` | Square button holding only the projected icon, like `bf-icon-button`; set `label`. |
338
- | `disabled` | `boolean` | `false` | Disables the button; the menu cannot open. |
379
+ | Name | Type | Default | Description |
380
+ | ----------- | ---------------------------------------------------------------------------------- | --------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
381
+ | `menu` | `TemplateRef<unknown>` | required | The `ng-template` holding the `bf-menu` to open. |
382
+ | `variant` | `BfMenuButtonVariant` (`'outline' \| 'text' \| 'tonal'`) | `'outline'` | Visual style: outline (default), text, tonal. |
383
+ | `size` | `BfSize \| undefined` (`'sm' \| 'md' \| 'lg' \| undefined`) | `undefined` | Height step: sm 32, md 40, lg 48 px; unset follows `provideBfDefaults()` (`controlSize`, `md` by default). |
384
+ | `placement` | `BfMenuPlacement` (`'below-start' \| 'below-end' \| 'above-start' \| 'above-end'`) | `'below-start'` | Where the menu opens against the button; it flips when it would overflow. |
385
+ | `label` | `string` | `''` | Accessible name (`aria-label`). Required with `iconOnly`; with a visible label leave it empty or start it with the visible text. |
386
+ | `iconOnly` | `boolean` | `false` | Square button holding only the projected icon, like `bf-icon-button`; set `label`. |
387
+ | `fullWidth` | `boolean` | `false` | Stretches the host and the button to the width of their container, as `bf-button`'s `fullWidth`; the label and caret stay centred. Ignored with `iconOnly`. |
388
+ | `disabled` | `boolean` | `false` | Disables the button; the menu cannot open. |
339
389
 
340
390
  ## Outputs
341
391
 
@@ -363,6 +413,12 @@ The keys inside the open menu are on the [bf-menu](https://bitforge-ui.web.app/c
363
413
 
364
414
  ## Migration notes
365
415
 
416
+ ### 0.5.0
417
+
418
+ New `fullWidth` input, as on `bf-button`: the host and its button stretch to the width of their
419
+ container (label and caret stay centred), so app CSS no longer needs to reach into the inner
420
+ `<button>`. Ignored with `iconOnly`.
421
+
366
422
  ### 0.3.0
367
423
 
368
424
  New `bf-menu-button` in `@bitforgehq/ui/overlay`: a native `<button>` that opens a `bf-menu`
@@ -42,7 +42,9 @@ export class PaginationOverviewExampleComponent {
42
42
  Page navigation for a long list or table: Previous and Next buttons around the page numbers,
43
43
  the current one marked, the hidden runs shown as an ellipsis. `compact` shows "Page 3 of 12"
44
44
  between the arrows instead, for narrow screens. The page count comes from `pageCount`, or from
45
- `total` and `pageSize`; the consumer shows the rows of `page`.
45
+ `total` and `pageSize`; the consumer shows the rows of `page`. A compact pagination with its own
46
+ `positionLabel`, `previousLabel` and `nextLabel` steps through anything numbered, such as the
47
+ versions of a model ("‹ v3 of 7 ›").
46
48
 
47
49
  Pagination splits a long list or table into pages and lets the user move between them: Previous and Next buttons around the page numbers, the current page marked, the first and the last page always in reach, and an ellipsis for each run of pages in between. `bf-pagination` holds only the page number. You bind `[(page)]` and show that page's rows, sliced on the client or fetched from the server. It works the same in both shells: in a desktop web app and in the Capacitor shell.
48
50
 
@@ -89,6 +91,41 @@ export class PaginationCompactExampleComponent {
89
91
  }
90
92
  ```
91
93
 
94
+ ## Stepping through versions
95
+
96
+ A compact pagination also steps through anything else that is numbered, such as the versions of a model: "‹ v3 of 7 ›". `positionLabel` takes a `BfPaginationPositionLabel`, a function from the page and the count to the position text (`` (page, count) => `v${page} of ${count}` ``); unset, the text comes from `pageOf`. `previousLabel` and `nextLabel` name the arrows ("Previous version") where "Previous page" would be wrong, and `label` names the navigation. Use it instead of a [bf-segmented](https://bitforge-ui.web.app/components/segmented) control once there are more than four versions; the arrows are disabled at the ends, as on any pagination.
97
+
98
+ ```ts
99
+ import { Component, signal } from '@angular/core';
100
+ import { BfPaginationComponent, type BfPaginationPositionLabel } from '@bitforgehq/ui';
101
+
102
+ /**
103
+ * A compact pager that steps through the versions of a model, "‹ v3 of 7 ›".
104
+ *
105
+ * `positionLabel` writes the position text; `previousLabel` and `nextLabel` name the arrows.
106
+ */
107
+ @Component({
108
+ selector: 'app-example-pagination-versions',
109
+ imports: [BfPaginationComponent],
110
+ template: `
111
+ <bf-pagination
112
+ compact
113
+ size="sm"
114
+ label="Versions"
115
+ previousLabel="Previous version"
116
+ nextLabel="Next version"
117
+ [positionLabel]="versionOf"
118
+ [pageCount]="7"
119
+ [(page)]="version"
120
+ />
121
+ `,
122
+ })
123
+ export class PaginationVersionsExampleComponent {
124
+ readonly version = signal(3);
125
+ protected readonly versionOf: BfPaginationPositionLabel = (page, count) => `v${page} of ${count}`;
126
+ }
127
+ ```
128
+
92
129
  ## With a table
93
130
 
94
131
  Put the pagination under the table it pages, aligned to the inline end or centred, and show only the current page's rows: slice them in a `computed()` from `page`, or fetch the page from the server in a `httpResource()` keyed on it. When the user sorts or filters, set `page` back to 1. The [table](https://bitforge-ui.web.app/components/table) page has a paged example.
@@ -123,16 +160,19 @@ At the first and the last page the arrow is disabled with `aria-disabled="true"`
123
160
 
124
161
  ## Inputs
125
162
 
126
- | Name | Type | Default | Description |
127
- | ----------- | ----------------------------------------------------------- | ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
128
- | `pageCount` | `number` | `0` | The number of pages; wins over `total` and `pageSize` when above 0. |
129
- | `total` | `number` | `0` | The number of items, used with `pageSize` while `pageCount` is 0. |
130
- | `pageSize` | `number` | `10` | Items per page, used with `total` while `pageCount` is 0. |
131
- | `siblings` | `number` | `1` | How many pages to show on each side of the current one. |
132
- | `compact` | `boolean` | `false` | Shows "Page 3 of 12" between the arrows instead of the page buttons, for narrow screens. |
133
- | `size` | `BfSize \| undefined` (`'sm' \| 'md' \| 'lg' \| undefined`) | `undefined` | Button height step: sm 32, md 40, lg 48 px (44 px hit area under a coarse pointer); unset follows `provideBfDefaults()` (`controlSize`, `md` by default). |
134
- | `label` | `string` | `''` | Accessible name of the navigation; empty falls back to `provideBfLabels()`'s `pagination` ("Pagination"). |
135
- | `page` | `number` | `1` | The current page, 1-based; two-way bindable. The buttons set it; show the rows of this page. Two-way: `[(page)]`. |
163
+ | Name | Type | Default | Description |
164
+ | --------------- | ----------------------------------------------------------- | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
165
+ | `pageCount` | `number` | `0` | The number of pages; wins over `total` and `pageSize` when above 0. |
166
+ | `total` | `number` | `0` | The number of items, used with `pageSize` while `pageCount` is 0. |
167
+ | `pageSize` | `number` | `10` | Items per page, used with `total` while `pageCount` is 0. |
168
+ | `siblings` | `number` | `1` | How many pages to show on each side of the current one. |
169
+ | `compact` | `boolean` | `false` | Shows "Page 3 of 12" between the arrows instead of the page buttons, for narrow screens. |
170
+ | `positionLabel` | `BfPaginationPositionLabel \| undefined` | `undefined` | Writes the compact position text from the page and the count, such as "v3 of 7"; unset uses `provideBfLabels()`'s `pageOf` ("Page 3 of 12"). Only used with `compact`. |
171
+ | `previousLabel` | `string` | `''` | Accessible name of the previous button, such as "Previous version"; empty falls back to `provideBfLabels()`'s `previousPage` ("Previous page"). |
172
+ | `nextLabel` | `string` | `''` | Accessible name of the next button, such as "Next version"; empty falls back to `provideBfLabels()`'s `nextPage` ("Next page"). |
173
+ | `size` | `BfSize \| undefined` (`'sm' \| 'md' \| 'lg' \| undefined`) | `undefined` | Button height step: sm 32, md 40, lg 48 px (44 px hit area under a coarse pointer); unset follows `provideBfDefaults()` (`controlSize`, `md` by default). |
174
+ | `label` | `string` | `''` | Accessible name of the navigation; empty falls back to `provideBfLabels()`'s `pagination` ("Pagination"). |
175
+ | `page` | `number` | `1` | The current page, 1-based; two-way bindable. The buttons set it; show the rows of this page. Two-way: `[(page)]`. |
136
176
 
137
177
  ## Methods
138
178
 
@@ -159,6 +199,14 @@ Goes to a page: sets `page` when it is another page within 1…count; else does
159
199
 
160
200
  ## Migration notes
161
201
 
202
+ ### 0.5.0
203
+
204
+ New `positionLabel` input (new type `BfPaginationPositionLabel`, `(page, count) => string`) that
205
+ writes a compact pagination's position text, such as "v3 of 7"; unset it is the `pageOf` label as
206
+ before. New `previousLabel` and `nextLabel` inputs name the arrows ("Previous version"); empty
207
+ they fall back to `previousPage` and `nextPage`. Together they make a compact pager for stepping
208
+ through versions.
209
+
162
210
  ### 0.3.1
163
211
 
164
212
  New `bf-pagination` in `@bitforgehq/ui`, page navigation for both shells: a `nav` (named by