@bitforgehq/ui 0.3.3 → 0.5.0-next.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 +394 -0
- package/README.md +14 -14
- package/ai/MANIFEST.index.json +101 -2
- package/ai/MANIFEST.json +2630 -746
- package/ai/REFERENCE.index.json +196 -0
- package/ai/REFERENCE.json +1625 -140
- package/ai/UI_GUIDE.md +83 -21
- package/ai/components/app-frame.md +1 -1
- package/ai/components/badge.md +39 -2
- package/ai/components/chat-composer.md +357 -0
- package/ai/components/chat-message.md +251 -0
- package/ai/components/chat-thread.md +521 -0
- package/ai/components/checkbox.md +7 -0
- package/ai/components/combobox.md +12 -0
- package/ai/components/date-input.md +9 -0
- package/ai/components/datetime-input.md +2 -0
- package/ai/components/field.md +10 -0
- package/ai/components/file-input.md +498 -0
- package/ai/components/header.md +1 -1
- package/ai/components/image-input.md +368 -0
- package/ai/components/image-viewer.md +309 -0
- package/ai/components/list-item.md +105 -9
- package/ai/components/media-card.md +478 -0
- package/ai/components/menu-button.md +2 -0
- package/ai/components/menu.md +10 -0
- package/ai/components/multi-combobox.md +7 -0
- package/ai/components/nav-group.md +10 -3
- package/ai/components/nav-item.md +11 -2
- package/ai/components/number-input.md +2 -7
- package/ai/components/popover.md +13 -0
- package/ai/components/radio-group.md +6 -0
- package/ai/components/segmented.md +14 -2
- package/ai/components/select.md +100 -42
- package/ai/components/sidebar-toggle.md +16 -9
- package/ai/components/sidebar.md +41 -13
- package/ai/components/step.md +112 -0
- package/ai/components/steps.md +124 -0
- package/ai/components/tab-group.md +18 -2
- package/ai/components/table.md +115 -4
- package/ai/components/theme-menu-button.md +1 -0
- package/ai/components/time-input.md +2 -0
- package/ai/components/timeline-event.md +144 -0
- package/ai/components/timeline.md +201 -0
- package/ai/components/toggle.md +11 -1
- package/ai/components/toolbar.md +7 -18
- package/ai/reference/bf-action-directive.md +3 -1
- package/ai/reference/bf-badge-emphasis.md +27 -0
- package/ai/reference/bf-chat-composer-harness.md +64 -0
- package/ai/reference/bf-chat-message-role.md +27 -0
- package/ai/reference/bf-chat-message-status.md +28 -0
- package/ai/reference/bf-fade-in-directive.md +18 -0
- package/ai/reference/bf-file-input-harness.md +67 -0
- package/ai/reference/bf-file-input-variant.md +27 -0
- package/ai/reference/bf-image-input-harness.md +67 -0
- package/ai/reference/bf-image-input-shape.md +27 -0
- package/ai/reference/bf-image-viewer-actions-context.md +29 -0
- package/ai/reference/bf-image-viewer-harness.md +87 -0
- package/ai/reference/bf-image-viewer-image.md +25 -0
- package/ai/reference/bf-image-viewer-options.md +33 -0
- package/ai/reference/bf-image-viewer-origin.md +23 -0
- package/ai/reference/bf-image-viewer-ref.md +55 -0
- package/ai/reference/bf-image-viewer-service.md +32 -0
- package/ai/reference/bf-labels.md +105 -74
- package/ai/reference/bf-media-card-aspect.md +28 -0
- package/ai/reference/bf-media-card-harness.md +66 -0
- package/ai/reference/bf-motion-list-directive.md +22 -0
- package/ai/reference/bf-prose-directive.md +16 -0
- package/ai/reference/bf-rejected-file-reason.md +28 -0
- package/ai/reference/bf-rejected-file.md +28 -0
- package/ai/reference/bf-select-harness.md +14 -5
- package/ai/reference/bf-select-option.md +2 -0
- package/ai/reference/bf-size.md +3 -0
- package/ai/reference/bf-step-heading-level.md +28 -0
- package/ai/reference/bf-table-align.md +1 -1
- package/ai/reference/bf-table-column-align.md +33 -0
- package/ai/reference/bf-table-column.md +7 -7
- package/ai/reference/bf-table-harness.md +12 -0
- package/ai/reference/bf-timeline-tone.md +31 -0
- package/ai/reference/bf-toggle-harness.md +55 -0
- package/ai/reference/bf_table_sort_select.md +23 -0
- package/ai/reference/provide-bf-labels.md +10 -3
- package/ai/reference/provide-bf-select.md +20 -0
- package/bin/bf-ui.js +1 -0
- package/dark-ink.css +124 -0
- package/fesm2022/bitforgehq-ui-mobile.mjs +2 -1
- package/fesm2022/bitforgehq-ui-mobile.mjs.map +1 -1
- package/fesm2022/bitforgehq-ui-overlay.mjs +2649 -606
- package/fesm2022/bitforgehq-ui-overlay.mjs.map +1 -1
- package/fesm2022/bitforgehq-ui-testing.mjs +406 -11
- package/fesm2022/bitforgehq-ui-testing.mjs.map +1 -1
- package/fesm2022/bitforgehq-ui.mjs +11149 -6957
- package/fesm2022/bitforgehq-ui.mjs.map +1 -1
- package/llms-full.txt +5219 -141
- package/llms.txt +40 -1
- package/package.json +2 -1
- package/theme.css +84 -0
- package/types/bitforgehq-ui-overlay.d.ts +684 -7
- package/types/bitforgehq-ui-testing.d.ts +510 -7
- package/types/bitforgehq-ui.d.ts +1757 -186
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
# bf-image-viewer
|
|
2
|
+
|
|
3
|
+
Full-screen image viewer that zooms from a thumbnail and steps through a set
|
|
4
|
+
|
|
5
|
+
`BfImageViewerComponent` from `@bitforgehq/ui/overlay` · Overlays · since 0.5.0
|
|
6
|
+
|
|
7
|
+
## Example
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
import {
|
|
11
|
+
Component,
|
|
12
|
+
ElementRef,
|
|
13
|
+
Injector,
|
|
14
|
+
type TemplateRef,
|
|
15
|
+
inject,
|
|
16
|
+
signal,
|
|
17
|
+
viewChild,
|
|
18
|
+
viewChildren,
|
|
19
|
+
} from '@angular/core';
|
|
20
|
+
import { BfActionDirective, BfButtonComponent, BfMediaCardComponent } from '@bitforgehq/ui';
|
|
21
|
+
import {
|
|
22
|
+
type BfImageViewerActionsContext,
|
|
23
|
+
type BfImageViewerImage,
|
|
24
|
+
BfImageViewerService,
|
|
25
|
+
} from '@bitforgehq/ui/overlay';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The bf-image-viewer overview.
|
|
29
|
+
*
|
|
30
|
+
* Live demo of `BfImageViewerService`: a grid of `bf-media-card` thumbnails opens the viewer on
|
|
31
|
+
* the clicked one, zooming out of its picture. The arrow keys or the side buttons step through the set, and closing zooms
|
|
32
|
+
* back into the thumbnail of the image shown. The "Use as cover" action closes the viewer and
|
|
33
|
+
* keeps the pick.
|
|
34
|
+
*/
|
|
35
|
+
@Component({
|
|
36
|
+
selector: 'app-example-image-viewer-overview',
|
|
37
|
+
imports: [BfActionDirective, BfButtonComponent, BfMediaCardComponent],
|
|
38
|
+
template: `
|
|
39
|
+
<div class="flex flex-col gap-3">
|
|
40
|
+
<div class="grid grid-cols-2 gap-3 @md:grid-cols-5">
|
|
41
|
+
@for (image of images; track image.src; let i = $index) {
|
|
42
|
+
<bf-media-card #card aspect="1/1" [src]="image.src" [alt]="image.label">
|
|
43
|
+
<button bfAction type="button" class="text-label text-start" (click)="open(i)">
|
|
44
|
+
{{ image.caption ?? 'Untitled' }}
|
|
45
|
+
</button>
|
|
46
|
+
</bf-media-card>
|
|
47
|
+
}
|
|
48
|
+
</div>
|
|
49
|
+
<p data-status aria-live="polite" class="text-caption text-text-muted">{{ status() }}</p>
|
|
50
|
+
</div>
|
|
51
|
+
|
|
52
|
+
<ng-template #actions let-image let-ref="ref">
|
|
53
|
+
<bf-button variant="primary" size="sm" (clicked)="useAsCover(image); ref.close()">
|
|
54
|
+
Use as cover
|
|
55
|
+
</bf-button>
|
|
56
|
+
</ng-template>
|
|
57
|
+
`,
|
|
58
|
+
})
|
|
59
|
+
export class ImageViewerOverviewExampleComponent {
|
|
60
|
+
private readonly viewer = inject(BfImageViewerService);
|
|
61
|
+
private readonly injector = inject(Injector);
|
|
62
|
+
|
|
63
|
+
protected readonly images: readonly BfImageViewerImage[] = [
|
|
64
|
+
{ src: 'media/skyline.svg', label: 'Neon skyline at dusk', caption: 'Starfall, concept 1' },
|
|
65
|
+
{ src: 'media/forest.svg', label: 'Pine forest under a pale sun', caption: 'Rootbound' },
|
|
66
|
+
{ src: 'media/harbour.svg', label: 'Harbour at sunset', caption: 'Tidewalk' },
|
|
67
|
+
{ src: 'media/desert.svg', label: 'Dunes under an orange sun' },
|
|
68
|
+
{ src: 'media/glacier.svg', label: 'Glacier under a small sun', caption: 'Coldlight' },
|
|
69
|
+
];
|
|
70
|
+
protected readonly status = signal('No cover picked.');
|
|
71
|
+
|
|
72
|
+
private readonly cards = viewChildren<BfMediaCardComponent, ElementRef<HTMLElement>>('card', {
|
|
73
|
+
read: ElementRef,
|
|
74
|
+
});
|
|
75
|
+
private readonly actions =
|
|
76
|
+
viewChild.required<TemplateRef<BfImageViewerActionsContext>>('actions');
|
|
77
|
+
|
|
78
|
+
protected open(index: number): void {
|
|
79
|
+
this.viewer.open({
|
|
80
|
+
images: this.images,
|
|
81
|
+
index,
|
|
82
|
+
// Any image's thumbnail, so closing zooms into the one shown.
|
|
83
|
+
origin: (i) => this.cards()[i]?.nativeElement.querySelector('img'),
|
|
84
|
+
actions: this.actions(),
|
|
85
|
+
injector: this.injector,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
protected useAsCover(image: BfImageViewerImage): void {
|
|
90
|
+
this.status.set(`Cover: ${image.label}.`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## Overview
|
|
96
|
+
|
|
97
|
+
The full-screen image viewer (lightbox) `BfImageViewerService.open()` renders: one image large
|
|
98
|
+
over a dark scrim, with a counter, previous and next buttons, a close button, an optional
|
|
99
|
+
caption and actions. It opens with a zoom out of the clicked thumbnail and steps with a
|
|
100
|
+
cross-fade. Apps do not place it in templates; open it with the service.
|
|
101
|
+
|
|
102
|
+
A full-screen image viewer (a lightbox) from `@bitforgehq/ui/overlay` (needs `@angular/cdk`, no Capacitor). `BfImageViewerService.open()` shows one image large over a dark scrim and steps through a set: previous and next buttons, the arrow keys, a counter ("2 of 5"), the caption and optional actions. It zooms out of the thumbnail that was clicked and back into it, and returns a `BfImageViewerRef`.
|
|
103
|
+
|
|
104
|
+
## When to use
|
|
105
|
+
|
|
106
|
+
Use `BfImageViewerService` to look at a picture larger than the page shows it, such as a card's cover, a gallery or the results of an image generator, and to step through a set of them. Use [BfDialogService](https://bitforge-ui.web.app/api/bf-dialog-service) instead for a picture that comes with a form or a decision, and [bf-media-card](https://bitforge-ui.web.app/components/media-card) for the thumbnails themselves.
|
|
107
|
+
|
|
108
|
+
## Basic usage
|
|
109
|
+
|
|
110
|
+
Call `open()` with the `images` (each a `src`, a `label`, which becomes the image's `alt`, and an optional `caption`) and the `index` to show first. Pass the clicked thumbnail as `origin`, or a function that returns the thumbnail of any image (`(index) => thumbnails[index]`), so closing after a few steps zooms into the right one. Actions under the image (a download link, a "Use this one" button) come from an `actions` template, whose context is `{ $implicit: image, index, ref }`; inject `BfImageViewerRef` or use `ref` to close or step from it.
|
|
111
|
+
|
|
112
|
+
```ts
|
|
113
|
+
import {
|
|
114
|
+
Component,
|
|
115
|
+
ElementRef,
|
|
116
|
+
Injector,
|
|
117
|
+
type TemplateRef,
|
|
118
|
+
inject,
|
|
119
|
+
signal,
|
|
120
|
+
viewChild,
|
|
121
|
+
viewChildren,
|
|
122
|
+
} from '@angular/core';
|
|
123
|
+
import { BfActionDirective, BfButtonComponent, BfMediaCardComponent } from '@bitforgehq/ui';
|
|
124
|
+
import {
|
|
125
|
+
type BfImageViewerActionsContext,
|
|
126
|
+
type BfImageViewerImage,
|
|
127
|
+
BfImageViewerService,
|
|
128
|
+
} from '@bitforgehq/ui/overlay';
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* The bf-image-viewer overview.
|
|
132
|
+
*
|
|
133
|
+
* Live demo of `BfImageViewerService`: a grid of `bf-media-card` thumbnails opens the viewer on
|
|
134
|
+
* the clicked one, zooming out of its picture. The arrow keys or the side buttons step through the set, and closing zooms
|
|
135
|
+
* back into the thumbnail of the image shown. The "Use as cover" action closes the viewer and
|
|
136
|
+
* keeps the pick.
|
|
137
|
+
*/
|
|
138
|
+
@Component({
|
|
139
|
+
selector: 'app-example-image-viewer-overview',
|
|
140
|
+
imports: [BfActionDirective, BfButtonComponent, BfMediaCardComponent],
|
|
141
|
+
template: `
|
|
142
|
+
<div class="flex flex-col gap-3">
|
|
143
|
+
<div class="grid grid-cols-2 gap-3 @md:grid-cols-5">
|
|
144
|
+
@for (image of images; track image.src; let i = $index) {
|
|
145
|
+
<bf-media-card #card aspect="1/1" [src]="image.src" [alt]="image.label">
|
|
146
|
+
<button bfAction type="button" class="text-label text-start" (click)="open(i)">
|
|
147
|
+
{{ image.caption ?? 'Untitled' }}
|
|
148
|
+
</button>
|
|
149
|
+
</bf-media-card>
|
|
150
|
+
}
|
|
151
|
+
</div>
|
|
152
|
+
<p data-status aria-live="polite" class="text-caption text-text-muted">{{ status() }}</p>
|
|
153
|
+
</div>
|
|
154
|
+
|
|
155
|
+
<ng-template #actions let-image let-ref="ref">
|
|
156
|
+
<bf-button variant="primary" size="sm" (clicked)="useAsCover(image); ref.close()">
|
|
157
|
+
Use as cover
|
|
158
|
+
</bf-button>
|
|
159
|
+
</ng-template>
|
|
160
|
+
`,
|
|
161
|
+
})
|
|
162
|
+
export class ImageViewerOverviewExampleComponent {
|
|
163
|
+
private readonly viewer = inject(BfImageViewerService);
|
|
164
|
+
private readonly injector = inject(Injector);
|
|
165
|
+
|
|
166
|
+
protected readonly images: readonly BfImageViewerImage[] = [
|
|
167
|
+
{ src: 'media/skyline.svg', label: 'Neon skyline at dusk', caption: 'Starfall, concept 1' },
|
|
168
|
+
{ src: 'media/forest.svg', label: 'Pine forest under a pale sun', caption: 'Rootbound' },
|
|
169
|
+
{ src: 'media/harbour.svg', label: 'Harbour at sunset', caption: 'Tidewalk' },
|
|
170
|
+
{ src: 'media/desert.svg', label: 'Dunes under an orange sun' },
|
|
171
|
+
{ src: 'media/glacier.svg', label: 'Glacier under a small sun', caption: 'Coldlight' },
|
|
172
|
+
];
|
|
173
|
+
protected readonly status = signal('No cover picked.');
|
|
174
|
+
|
|
175
|
+
private readonly cards = viewChildren<BfMediaCardComponent, ElementRef<HTMLElement>>('card', {
|
|
176
|
+
read: ElementRef,
|
|
177
|
+
});
|
|
178
|
+
private readonly actions =
|
|
179
|
+
viewChild.required<TemplateRef<BfImageViewerActionsContext>>('actions');
|
|
180
|
+
|
|
181
|
+
protected open(index: number): void {
|
|
182
|
+
this.viewer.open({
|
|
183
|
+
images: this.images,
|
|
184
|
+
index,
|
|
185
|
+
// Any image's thumbnail, so closing zooms into the one shown.
|
|
186
|
+
origin: (i) => this.cards()[i]?.nativeElement.querySelector('img'),
|
|
187
|
+
actions: this.actions(),
|
|
188
|
+
injector: this.injector,
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
protected useAsCover(image: BfImageViewerImage): void {
|
|
193
|
+
this.status.set(`Cover: ${image.label}.`);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
## Stepping and the ref
|
|
199
|
+
|
|
200
|
+
The ref's `index` is a signal of the shown image. `next()` and `previous()` wrap around at both ends, `goTo(index)` jumps (a negative index counts from the end), and `close()` closes; `afterClosed` resolves once the viewer has left the page, and `closed()` turns true. A viewer of one image shows no counter and no step buttons.
|
|
201
|
+
|
|
202
|
+
```ts
|
|
203
|
+
import { Component, Injector, inject, signal } from '@angular/core';
|
|
204
|
+
import { BfButtonComponent } from '@bitforgehq/ui';
|
|
205
|
+
import { BfImageViewerService } from '@bitforgehq/ui/overlay';
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* One image, no thumbnail.
|
|
209
|
+
*
|
|
210
|
+
* Without an `origin` the viewer fades in and out; with a single image it shows no counter and
|
|
211
|
+
* no step buttons. `afterClosed` resolves once it has gone.
|
|
212
|
+
*/
|
|
213
|
+
@Component({
|
|
214
|
+
selector: 'app-example-image-viewer-single',
|
|
215
|
+
imports: [BfButtonComponent],
|
|
216
|
+
template: `
|
|
217
|
+
<div class="flex flex-col items-start gap-3">
|
|
218
|
+
<bf-button variant="tonal" (clicked)="open()">View the key art</bf-button>
|
|
219
|
+
<p data-status aria-live="polite" class="text-caption text-text-muted">{{ status() }}</p>
|
|
220
|
+
</div>
|
|
221
|
+
`,
|
|
222
|
+
})
|
|
223
|
+
export class ImageViewerSingleExampleComponent {
|
|
224
|
+
private readonly viewer = inject(BfImageViewerService);
|
|
225
|
+
private readonly injector = inject(Injector);
|
|
226
|
+
protected readonly status = signal('');
|
|
227
|
+
|
|
228
|
+
protected async open(): Promise<void> {
|
|
229
|
+
const ref = this.viewer.open({
|
|
230
|
+
images: [
|
|
231
|
+
{ src: 'media/glacier.svg', label: 'Glacier under a small sun', caption: 'Key art' },
|
|
232
|
+
],
|
|
233
|
+
injector: this.injector,
|
|
234
|
+
});
|
|
235
|
+
this.status.set('Viewer open.');
|
|
236
|
+
await ref.afterClosed;
|
|
237
|
+
this.status.set('Viewer closed.');
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
## Motion
|
|
243
|
+
|
|
244
|
+
The viewer opens with a zoom: the image's frame starts on the thumbnail's box, cropped as the thumbnail crops it, and grows to its place in 260 ms on the overlay curve (`--ease-ios`), while the scrim and the controls fade in. With no `origin`, or one that is off screen, it fades in instead. Closing zooms back into the thumbnail of the shown image when it is still on screen, else fades out. Stepping cross-fades the images. Under `prefers-reduced-motion` the viewer only fades, in 150 ms, and the images appear without a fade.
|
|
245
|
+
|
|
246
|
+
## Loading and failing
|
|
247
|
+
|
|
248
|
+
While a large image loads, the viewer shows its thumbnail (the `origin` image, when there is one) with a spinner over it, and the frame already has the thumbnail's aspect ratio; the image fades in over it when it arrives (`bfFadeIn`). An image that fails to load shows the `imageLoadFailed` label ("The image could not be loaded") in its place.
|
|
249
|
+
|
|
250
|
+
## Desktop
|
|
251
|
+
|
|
252
|
+
Built for desktop and touch alike: the image fills the space between the top bar (counter and close) and the caption row, at its own aspect ratio. The previous and next buttons sit on the image's sides and the close button at the top end, each a `tonal` `bf-icon-button` that follows `provideBfDefaults()`; their hover tint and focus ring are the button's. A click on the scrim or beside the image closes the viewer; a click on the image, the caption or the actions does not. The page behind it is inert and does not scroll.
|
|
253
|
+
|
|
254
|
+
## Theming
|
|
255
|
+
|
|
256
|
+
- The viewer is a `.dark` sub-tree, so its text and buttons take their dark-mode tokens in both colour schemes; the scrim is `bg-scrim/90`.
|
|
257
|
+
- Counter `text-label`, caption `text-body`, failure message `text-body text-text-secondary`.
|
|
258
|
+
- Buttons: `tonal` `bf-icon-button`s with lucide `ChevronLeft`, `ChevronRight` (mirrored under `dir="rtl"`) and `X`; the spinner is a `lg` `bf-spinner`.
|
|
259
|
+
- Motion: `OVERLAY_DURATION` (260 ms) and `OVERLAY_EASING` from the overlay core, `OVERLAY_REDUCED_DURATION` (150 ms) under reduced motion; the images fade on `duration-slow`.
|
|
260
|
+
|
|
261
|
+
## Accessibility
|
|
262
|
+
|
|
263
|
+
The viewer is a modal `role="dialog"` named by `ariaLabel` or the `imageViewer` label ("Image viewer"), with `aria-modal="true"`. On open, focus moves to the close button and is trapped in the viewer; on close it returns to the element that opened it. The page behind it is `inert`. Every image has its `label` as `alt`, the step buttons are named by `imageViewerPrevious` and `imageViewerNext`, and after each step a polite live region announces the position and the label ("3 of 5, Harbour at sunset"); the counter itself is the `imageViewerPosition` label. The service is a root singleton: pass `injector: inject(Injector)` so a feature-level `provideBfLabels()` names its parts.
|
|
264
|
+
|
|
265
|
+
### Keyboard
|
|
266
|
+
|
|
267
|
+
| Key | Action |
|
|
268
|
+
| ------------------------------------------------ | ----------------------------------------------------------- |
|
|
269
|
+
| <kbd>ArrowRight</kbd> / <kbd>ArrowLeft</kbd> | Next / previous image, wrapping (swapped under `dir="rtl"`) |
|
|
270
|
+
| <kbd>Home</kbd> / <kbd>End</kbd> | First / last image |
|
|
271
|
+
| <kbd>Escape</kbd> | Closes the viewer |
|
|
272
|
+
| <kbd>Tab</kbd> / <kbd>Shift</kbd>+<kbd>Tab</kbd> | Next / previous control, wrapping inside the viewer |
|
|
273
|
+
| <kbd>Enter</kbd> / <kbd>Space</kbd> | Activates the focused button |
|
|
274
|
+
|
|
275
|
+
## Notes
|
|
276
|
+
|
|
277
|
+
### Do
|
|
278
|
+
|
|
279
|
+
- Give every image a `label` that says what it shows; it is all a screen reader gets of the picture.
|
|
280
|
+
- Pass `origin` (a function for a gallery), so the image visibly comes from and returns to its thumbnail.
|
|
281
|
+
- Keep actions to one or two buttons that act on the shown image.
|
|
282
|
+
|
|
283
|
+
### Don't
|
|
284
|
+
|
|
285
|
+
- Don't open the viewer for a picture that already fills the page.
|
|
286
|
+
- Don't put a form in the actions; open a dialog for that.
|
|
287
|
+
|
|
288
|
+
## Migration notes
|
|
289
|
+
|
|
290
|
+
### 0.5.0
|
|
291
|
+
|
|
292
|
+
New: `BfImageViewerService` in `@bitforgehq/ui/overlay`, a full-screen image viewer (lightbox).
|
|
293
|
+
`open({ images: [{ src, label, caption? }], index?, origin?, actions?, ariaLabel?,
|
|
294
|
+
closeOnNavigation?, injector? })` (new types `BfImageViewerOptions`, `BfImageViewerImage`,
|
|
295
|
+
`BfImageViewerOrigin`, `BfImageViewerActionsContext`) renders `bf-image-viewer` over a dark scrim
|
|
296
|
+
and returns a `BfImageViewerRef` (`index()`, `next()`, `previous()`, `goTo()`, `close()`,
|
|
297
|
+
`afterClosed`, `closed()`). Previous and next buttons, the arrow keys (mirrored under rtl), Home
|
|
298
|
+
and End step through the set, wrapping at both ends; a counter shows "2 of 5" and a polite live
|
|
299
|
+
region announces each step. It is a modal dialog: focus moves to its close button and is trapped,
|
|
300
|
+
`Escape`, the close button and a click on the scrim close it, the page behind is `inert`, and
|
|
301
|
+
focus returns to the opener. The `actions` template (context `{ $implicit: image, index, ref }`)
|
|
302
|
+
sits beside the caption. It zooms out of `origin` (the clicked thumbnail, or a function returning
|
|
303
|
+
any image's thumbnail) with a 260 ms FLIP on the overlay curve and zooms back into it on close
|
|
304
|
+
when it is on screen, else fades; images cross-fade on a step; reduced motion only fades. While a
|
|
305
|
+
large image loads it shows the thumbnail and a spinner, then fades the image in. New labels
|
|
306
|
+
`imageViewer`, `imageViewerPrevious`, `imageViewerNext` and `imageViewerPosition`.
|
|
307
|
+
`@bitforgehq/ui/testing`: new `BfImageViewerHarness` (`getIndex()`, `getCount()`,
|
|
308
|
+
`getCounterText()`, `getSrc()`, `getAlt()`, `getCaption()`, `next()`, `previous()`,
|
|
309
|
+
`pressKey()`, `close()`, `loadImage()`, `failImage()`, `isLoaded()`, `hasFailed()`).
|
|
@@ -100,6 +100,7 @@ export class ListItemOverviewExampleComponent {
|
|
|
100
100
|
One row of a list: leading slot (avatar, icon tile), title and subtitle, trailing slot
|
|
101
101
|
(time, toggle, badge) and an optional chevron for rows that navigate. A navigation row puts a
|
|
102
102
|
`bfAction` link in the `title` slot, so the row stays a list item and the link covers it.
|
|
103
|
+
`unread` marks a row not yet opened with a dot and a bolder title.
|
|
103
104
|
|
|
104
105
|
A row of a list: a leading slot (an avatar, an icon), the title and subtitle, a trailing slot (a time, a toggle, a chip) and an optional chevron. Place rows in a [`bf-list`](https://bitforge-ui.web.app/components/list), which gives them the list semantics and the inset container; a row can also stand alone. A row is static text, a navigation link or an action button, never more than one of them.
|
|
105
106
|
|
|
@@ -230,6 +231,87 @@ export class ListItemActionsExampleComponent {
|
|
|
230
231
|
}
|
|
231
232
|
```
|
|
232
233
|
|
|
234
|
+
## Unread rows
|
|
235
|
+
|
|
236
|
+
`unread` marks a row the user has not opened yet, such as a new message or notification: a small `primary` dot at the row's inline start, on the title line, and the title in a bolder weight. It is not colour alone: the row also reads a visually hidden state before the title, from the `listItemUnread` label ("Unread"; set it with `provideBfLabels({ listItemUnread: 'Ungelesen' })` in a German app), so a screen reader says "Unread, Scan finished". The dot sits in the row's own inline-start padding (or the gap after a leading avatar or icon), so read and unread rows keep their text aligned and nothing moves when a row is marked read; without `unread` nothing changes. Clear `unread` when the row is opened.
|
|
237
|
+
|
|
238
|
+
```ts
|
|
239
|
+
import { Component, computed, signal } from '@angular/core';
|
|
240
|
+
import { BfActionDirective, BfListComponent, BfListItemComponent } from '@bitforgehq/ui';
|
|
241
|
+
|
|
242
|
+
interface Notice {
|
|
243
|
+
readonly id: string;
|
|
244
|
+
readonly title: string;
|
|
245
|
+
readonly subtitle: string;
|
|
246
|
+
readonly time: string;
|
|
247
|
+
readonly unread: boolean;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* An inbox with unread rows.
|
|
252
|
+
*
|
|
253
|
+
* Each title is a stretched `<button bfAction>`; opening a row marks it read.
|
|
254
|
+
*/
|
|
255
|
+
@Component({
|
|
256
|
+
selector: 'app-example-list-item-unread',
|
|
257
|
+
imports: [BfActionDirective, BfListComponent, BfListItemComponent],
|
|
258
|
+
template: `
|
|
259
|
+
<bf-list>
|
|
260
|
+
@for (notice of notices(); track notice.id) {
|
|
261
|
+
<bf-list-item [unread]="notice.unread" [subtitle]="notice.subtitle">
|
|
262
|
+
<button bfSlot="title" bfAction type="button" class="text-start" (click)="open(notice)">
|
|
263
|
+
{{ notice.title }}
|
|
264
|
+
</button>
|
|
265
|
+
<span bfSlot="trailing" class="text-caption text-text-muted">{{ notice.time }}</span>
|
|
266
|
+
</bf-list-item>
|
|
267
|
+
}
|
|
268
|
+
</bf-list>
|
|
269
|
+
<p data-status aria-live="polite" class="text-caption text-text-muted mt-3">{{ status() }}</p>
|
|
270
|
+
`,
|
|
271
|
+
})
|
|
272
|
+
export class ListItemUnreadExampleComponent {
|
|
273
|
+
protected readonly notices = signal<readonly Notice[]>([
|
|
274
|
+
{
|
|
275
|
+
id: 'scan',
|
|
276
|
+
title: 'Scan finished',
|
|
277
|
+
subtitle: '3 new findings on api.example.com',
|
|
278
|
+
time: '9:41',
|
|
279
|
+
unread: true,
|
|
280
|
+
},
|
|
281
|
+
{
|
|
282
|
+
id: 'cert',
|
|
283
|
+
title: 'Certificate expires soon',
|
|
284
|
+
subtitle: 'shop.example.com, in 12 days',
|
|
285
|
+
time: '8:15',
|
|
286
|
+
unread: true,
|
|
287
|
+
},
|
|
288
|
+
{
|
|
289
|
+
id: 'report',
|
|
290
|
+
title: 'Weekly report',
|
|
291
|
+
subtitle: 'Ready to download',
|
|
292
|
+
time: 'Mon',
|
|
293
|
+
unread: false,
|
|
294
|
+
},
|
|
295
|
+
]);
|
|
296
|
+
private readonly opened = signal('');
|
|
297
|
+
|
|
298
|
+
protected readonly status = computed(() => {
|
|
299
|
+
const unread = this.notices().filter((notice) => notice.unread).length;
|
|
300
|
+
const opened = this.opened();
|
|
301
|
+
return opened ? `${unread} unread · Opened: ${opened}` : `${unread} unread`;
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
protected open(notice: Notice): void {
|
|
305
|
+
this.opened.set(notice.title);
|
|
306
|
+
this.notices.update((list) =>
|
|
307
|
+
list.map((item) => (item.id === notice.id ? { ...item, unread: false } : item)),
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
With a `bfAction` link or button in the `title` slot, the state becomes part of that element's accessible name: the directive sets `aria-labelledby` to the state text and the element itself (it gives the element an id if it has none, and keeps an `aria-labelledby` of its own after the state), so the link reads "Unread, Scan finished" when tabbed to or listed, and the state text is hidden from the tree so it is read once. An `interactive` row holds the state text inside its button, so its name starts with it too.
|
|
314
|
+
|
|
233
315
|
## Desktop
|
|
234
316
|
|
|
235
317
|
An `interactive` row and a row holding a `bfAction` link take a `surface-muted` fill and the pointer cursor while hovered; a static row has none. Hover styles apply only on devices that can hover, so a tap never leaves one stuck. The ring is drawn around the row on keyboard focus only. A navigation row keeps the browser's link behaviour: Ctrl/Cmd-click and a middle click open it in a new tab. The trailing slot sits above the stretched link, so a toggle or button there takes its own click. Rows have one height; they do not follow `provideBfDefaults()`.
|
|
@@ -240,10 +322,11 @@ An `interactive` row and a row holding a `bfAction` link take a `surface-muted`
|
|
|
240
322
|
- Text: the title, the title slot and the subtitle wrap (`wrap-anywhere`); with `truncate` each keeps one line (`truncate`).
|
|
241
323
|
- `interactive` rows and rows holding a `bfAction` link add a pointer, a `surface-muted` hover, a `surface-pressed` fill while pressed (the row's action, for a `bfAction` row) and a `ring` focus ring; `interactive` rows dim to `text-disabled` when `disabled` and have no pressed state then.
|
|
242
324
|
- The `chevron` mark uses `text-subtle`.
|
|
325
|
+
- `unread`: the dot is `primary` (`CanvasText` in forced colours), `rounded-full` and 8 px (`size-2`); the title turns `font-extrabold`.
|
|
243
326
|
|
|
244
327
|
## Accessibility
|
|
245
328
|
|
|
246
|
-
`bf-list-item` sets `role="listitem"` on its host only inside a `bf-list`; a standalone row has no role, so it never claims a list parent that is not there. A non-interactive row is a plain `<div>`, while `[interactive]="true"` renders it as a native `<button>` so it gets keyboard activation and the button role for free. A navigation row's `bfAction` link in the `title` slot is a real `<a>` inside the list item ([WAI-ARIA APG link pattern](https://www.w3.org/WAI/ARIA/apg/patterns/link/)): exposed as `link` with the title as its name, reached with Tab (in Safari and iOS WKWebView, Tab reaches links only when the user turns on "Press Tab to highlight each item" (Option+Tab otherwise); VoiceOver and Full Keyboard Access are unaffected), followed with Enter (not Space), opened in a new tab with Ctrl/Cmd-click; under forced colours a system-coloured outline around the whole row marks the focused link. Making the host itself a link would take the row out of the list's `listitem` structure, so the link lives inside it. `chevron` is a purely visual CSS mark (an `::after` pseudo-element), so it adds nothing to the accessibility tree.
|
|
329
|
+
`bf-list-item` sets `role="listitem"` on its host only inside a `bf-list`; a standalone row has no role, so it never claims a list parent that is not there. A non-interactive row is a plain `<div>`, while `[interactive]="true"` renders it as a native `<button>` so it gets keyboard activation and the button role for free. A navigation row's `bfAction` link in the `title` slot is a real `<a>` inside the list item ([WAI-ARIA APG link pattern](https://www.w3.org/WAI/ARIA/apg/patterns/link/)): exposed as `link` with the title as its name, reached with Tab (in Safari and iOS WKWebView, Tab reaches links only when the user turns on "Press Tab to highlight each item" (Option+Tab otherwise); VoiceOver and Full Keyboard Access are unaffected), followed with Enter (not Space), opened in a new tab with Ctrl/Cmd-click; under forced colours a system-coloured outline around the whole row marks the focused link. Making the host itself a link would take the row out of the list's `listitem` structure, so the link lives inside it. `chevron` is a purely visual CSS mark (an `::after` pseudo-element), so it adds nothing to the accessibility tree. An `unread` row's dot carries a visually hidden (`sr-only`) state text from the `listItemUnread` label, read before the title ("Unread, Scan finished"); on a row with a `bfAction` the text is `aria-hidden` and the action names itself with `aria-labelledby` from it and its own content, so the state is part of the link's or button's name and is read once. The row stays a `listitem`.
|
|
247
330
|
|
|
248
331
|
### Keyboard
|
|
249
332
|
|
|
@@ -255,14 +338,15 @@ An `interactive` row and a row holding a `bfAction` link take a `surface-muted`
|
|
|
255
338
|
|
|
256
339
|
## Inputs
|
|
257
340
|
|
|
258
|
-
| Name | Type | Default | Description
|
|
259
|
-
| ------------- | --------- | ------- |
|
|
260
|
-
| `title` | `string` | `''` | Primary text. For a navigation row, project an `<a bfSlot="title" bfAction>` instead.
|
|
261
|
-
| `subtitle` | `string` | `''` | Secondary text under the title.
|
|
262
|
-
| `interactive` | `boolean` | `false` | Render the row as a button that emits `clicked` (an action row; a row that navigates puts a `bfAction` link in the `title` slot instead).
|
|
263
|
-
| `chevron` | `boolean` | `false` | Show a trailing chevron (navigation rows).
|
|
264
|
-
| `disabled` | `boolean` | `false` | Disable an interactive row.
|
|
265
|
-
| `truncate` | `boolean` | `false` | Keep the title, the title slot and the subtitle on one line each, cut with an ellipsis. By default they wrap, so nothing is lost at large text sizes.
|
|
341
|
+
| Name | Type | Default | Description |
|
|
342
|
+
| ------------- | --------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
343
|
+
| `title` | `string` | `''` | Primary text. For a navigation row, project an `<a bfSlot="title" bfAction>` instead. |
|
|
344
|
+
| `subtitle` | `string` | `''` | Secondary text under the title. |
|
|
345
|
+
| `interactive` | `boolean` | `false` | Render the row as a button that emits `clicked` (an action row; a row that navigates puts a `bfAction` link in the `title` slot instead). |
|
|
346
|
+
| `chevron` | `boolean` | `false` | Show a trailing chevron (navigation rows). |
|
|
347
|
+
| `disabled` | `boolean` | `false` | Disable an interactive row. |
|
|
348
|
+
| `truncate` | `boolean` | `false` | Keep the title, the title slot and the subtitle on one line each, cut with an ellipsis. By default they wrap, so nothing is lost at large text sizes. |
|
|
349
|
+
| `unread` | `boolean` | `false` | Mark the row as not yet opened: a dot at the inline start, a bolder title and a visually hidden state (the `listItemUnread` label) read before the title. |
|
|
266
350
|
|
|
267
351
|
## Outputs
|
|
268
352
|
|
|
@@ -283,14 +367,26 @@ An `interactive` row and a row holding a `bfAction` link take a `surface-muted`
|
|
|
283
367
|
|
|
284
368
|
- Keep titles short; a long one wraps. Add `truncate` only where one line per row is the pattern (a mail list).
|
|
285
369
|
- Make a row that navigates a `bfAction` link in the `title` slot, and a row that acts on the page `interactive`; put secondary actions in the trailing slot.
|
|
370
|
+
- Set `unread` on rows not yet opened (messages, notifications) and clear it when the row is opened.
|
|
286
371
|
|
|
287
372
|
### Don't
|
|
288
373
|
|
|
289
374
|
- Don't nest interactive controls inside an `interactive` row (it is one button); use a navigation row with a trailing control instead.
|
|
290
375
|
- Don't wrap a `bf-list-item` in an `<a>`, and don't navigate from an `interactive` row's `(clicked)`.
|
|
376
|
+
- Don't mark unread rows with a coloured title or a badge of your own; `unread` gives the dot, the weight and the screen-reader state together.
|
|
291
377
|
|
|
292
378
|
## Migration notes
|
|
293
379
|
|
|
380
|
+
### 0.4.0
|
|
381
|
+
|
|
382
|
+
New `unread` input (boolean, default false) for a row not yet opened: a `primary` dot at the
|
|
383
|
+
row's inline start on the title line, a bolder title (`font-extrabold`) and a visually hidden
|
|
384
|
+
state read before the title, from the new label key `listItemUnread` ("Unread"). A row's
|
|
385
|
+
`bfAction` link or button then names itself `aria-labelledby="<state> <itself>"` ("Unread,
|
|
386
|
+
Report"; an id is added if it has none, and its own `aria-labelledby` is kept after the state).
|
|
387
|
+
The dot sits in the row's own inline-start padding, so read and unread rows stay aligned. Opt-in: without `unread` nothing
|
|
388
|
+
changes.
|
|
389
|
+
|
|
294
390
|
### 0.3.0
|
|
295
391
|
|
|
296
392
|
**Behaviour:** logical properties: text aligns to the inline start and the chevron sits at the
|