@blotch/dolan 22.2.4 → 22.2.6
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/dist/commands/add.test.js +2 -0
- package/dist/commands/add.test.js.map +1 -1
- package/dist/commands/doctor.js +78 -21
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/doctor.test.js +167 -26
- package/dist/commands/doctor.test.js.map +1 -1
- package/dist/commands/init.test.js +4 -7
- package/dist/commands/init.test.js.map +1 -1
- package/dist/commands/update.d.ts +3 -1
- package/dist/commands/update.js +78 -15
- package/dist/commands/update.js.map +1 -1
- package/dist/commands/update.test.js +257 -2
- package/dist/commands/update.test.js.map +1 -1
- package/dist/lockfile.d.ts +10 -0
- package/dist/lockfile.js +20 -1
- package/dist/lockfile.js.map +1 -1
- package/dist/lockfile.test.js +43 -3
- package/dist/lockfile.test.js.map +1 -1
- package/dist/registry.js +73 -11
- package/dist/registry.js.map +1 -1
- package/dist/registry.test.js +16 -3
- package/dist/registry.test.js.map +1 -1
- package/dist/test-utils.d.ts +24 -2
- package/dist/test-utils.js +41 -2
- package/dist/test-utils.js.map +1 -1
- package/dist/ui/boolean-inputs.test.ts +76 -0
- package/dist/ui/components/action-sheet/action-sheet.test.ts +370 -1
- package/dist/ui/components/action-sheet/action-sheet.ts +306 -28
- package/dist/ui/components/action-sheet/index.ts +1 -0
- package/dist/ui/components/alert-dialog/alert-dialog.test.ts +552 -8
- package/dist/ui/components/alert-dialog/alert-dialog.ts +644 -36
- package/dist/ui/components/alert-dialog/index.ts +5 -0
- package/dist/ui/components/area-chart/area-chart.ts +3 -2
- package/dist/ui/components/badge/badge.ts +2 -1
- package/dist/ui/components/bottom-sheet/bottom-sheet.test.ts +968 -3
- package/dist/ui/components/bottom-sheet/bottom-sheet.ts +1021 -95
- package/dist/ui/components/bottom-sheet/index.ts +7 -1
- package/dist/ui/components/button/button.test.ts +36 -1
- package/dist/ui/components/button/button.ts +17 -7
- package/dist/ui/components/button-group/button-group.test.ts +53 -9
- package/dist/ui/components/button-group/button-group.ts +67 -21
- package/dist/ui/components/card/card.test.ts +16 -0
- package/dist/ui/components/card/card.ts +2 -1
- package/dist/ui/components/chart/chart-presets.ts +6 -6
- package/dist/ui/components/chart/chart.test.ts +14 -0
- package/dist/ui/components/chart/chart.ts +10 -3
- package/dist/ui/components/checkbox/checkbox.ts +2 -1
- package/dist/ui/components/collapsible/collapsible.ts +2 -1
- package/dist/ui/components/dialog/dialog.ts +6 -6
- package/dist/ui/components/dock/dock-scroll.ts +9 -0
- package/dist/ui/components/dock/dock-search.test.ts +594 -0
- package/dist/ui/components/dock/dock.test.ts +4 -1
- package/dist/ui/components/dock/dock.ts +529 -51
- package/dist/ui/components/dynamic-color/dynamic-color.test.ts +144 -0
- package/dist/ui/components/dynamic-color/dynamic-color.ts +146 -0
- package/dist/ui/components/dynamic-color/index.ts +5 -0
- package/dist/ui/components/glass/glass.test.ts +39 -1
- package/dist/ui/components/glass/glass.ts +87 -17
- package/dist/ui/components/glass/index.ts +1 -0
- package/dist/ui/components/icon/icon-theme.test.ts +28 -0
- package/dist/ui/components/icon/icon-theme.ts +38 -7
- package/dist/ui/components/input/input.ts +2 -1
- package/dist/ui/components/label/label.ts +8 -2
- package/dist/ui/components/line-chart/line-chart.ts +3 -2
- package/dist/ui/components/list/list.ts +5 -4
- package/dist/ui/components/menu/menu-popup.test.ts +18 -14
- package/dist/ui/components/menu/menu-popup.ts +24 -3
- package/dist/ui/components/menu/menu.test.ts +99 -5
- package/dist/ui/components/menu/menu.ts +74 -34
- package/dist/ui/components/nav-drawer/nav-drawer.ts +3 -2
- package/dist/ui/components/navigation-bar/index.ts +1 -0
- package/dist/ui/components/navigation-bar/navigation-bar.test.ts +425 -0
- package/dist/ui/components/navigation-bar/navigation-bar.ts +411 -0
- package/dist/ui/components/popover/index.ts +9 -0
- package/dist/ui/components/popover/popover-panel.test.ts +735 -0
- package/dist/ui/components/popover/popover-panel.ts +603 -0
- package/dist/ui/components/popover/popover.test.ts +286 -0
- package/dist/ui/components/popover/popover.ts +207 -0
- package/dist/ui/components/progress/progress.ts +2 -1
- package/dist/ui/components/radio-group/radio-group.ts +2 -1
- package/dist/ui/components/scroll-area/scroll-area.ts +4 -3
- package/dist/ui/components/search-field/index.ts +15 -0
- package/dist/ui/components/search-field/keyboard.test.ts +147 -0
- package/dist/ui/components/search-field/keyboard.ts +123 -0
- package/dist/ui/components/search-field/search-field.test.ts +549 -0
- package/dist/ui/components/search-field/search-field.ts +628 -0
- package/dist/ui/components/segmented-control/index.ts +8 -0
- package/dist/ui/components/segmented-control/segmented-control.test.ts +861 -0
- package/dist/ui/components/segmented-control/segmented-control.ts +884 -0
- package/dist/ui/components/select/select.ts +2 -1
- package/dist/ui/components/sheet/sheet.test.ts +90 -3
- package/dist/ui/components/sheet/sheet.ts +59 -4
- package/dist/ui/components/slider/index.ts +1 -0
- package/dist/ui/components/slider/slider.test.ts +695 -0
- package/dist/ui/components/slider/slider.ts +901 -0
- package/dist/ui/components/switch/index.ts +1 -1
- package/dist/ui/components/switch/switch.test.ts +464 -48
- package/dist/ui/components/switch/switch.ts +613 -43
- package/dist/ui/components/tabs/index.ts +7 -1
- package/dist/ui/components/tabs/tabs.test.ts +278 -2
- package/dist/ui/components/tabs/tabs.ts +233 -30
- package/dist/ui/components/textarea/textarea.ts +2 -1
- package/dist/ui/components/toggle/index.ts +1 -0
- package/dist/ui/components/toggle/toggle.test.ts +310 -4
- package/dist/ui/components/toggle/toggle.ts +241 -26
- package/dist/ui/components/toolbar/bar-scroll.test.ts +310 -0
- package/dist/ui/components/toolbar/bar-scroll.ts +372 -0
- package/dist/ui/components/toolbar/index.ts +12 -0
- package/dist/ui/components/toolbar/toolbar.test.ts +438 -0
- package/dist/ui/components/toolbar/toolbar.ts +535 -0
- package/dist/utils/analyze.d.ts +10 -5
- package/dist/utils/analyze.js +3 -2
- package/dist/utils/analyze.js.map +1 -1
- package/dist/utils/installed.d.ts +13 -1
- package/dist/utils/installed.js +17 -0
- package/dist/utils/installed.js.map +1 -1
- package/dist/utils/installed.test.js +39 -1
- package/dist/utils/installed.test.js.map +1 -1
- package/dist/utils/tailwind.d.ts +25 -5
- package/dist/utils/tailwind.js +53 -14
- package/dist/utils/tailwind.js.map +1 -1
- package/dist/utils/tailwind.test.js +85 -22
- package/dist/utils/tailwind.test.js.map +1 -1
- package/package.json +1 -1
|
@@ -2,7 +2,9 @@ import {
|
|
|
2
2
|
type ElementRef,
|
|
3
3
|
Component,
|
|
4
4
|
ViewEncapsulation,
|
|
5
|
+
booleanAttribute,
|
|
5
6
|
computed,
|
|
7
|
+
contentChildren,
|
|
6
8
|
inject,
|
|
7
9
|
input,
|
|
8
10
|
model,
|
|
@@ -17,6 +19,14 @@ import {
|
|
|
17
19
|
pressRelease,
|
|
18
20
|
} from '../../utils/animate';
|
|
19
21
|
import { cn } from '../../utils/cn';
|
|
22
|
+
import { UI_ICON_COLOR } from '../icon/icon-theme';
|
|
23
|
+
import {
|
|
24
|
+
type SegmentedHost,
|
|
25
|
+
segmentPlace,
|
|
26
|
+
UI_SEGMENTED_HOST,
|
|
27
|
+
UiSegmentedControl,
|
|
28
|
+
UiSegmentedIndicator,
|
|
29
|
+
} from '../segmented-control/segmented-control';
|
|
20
30
|
|
|
21
31
|
const toggleVariants = cva('items-center rounded-md flex justify-center', {
|
|
22
32
|
variants: {
|
|
@@ -43,9 +53,39 @@ export type ToggleSize = NonNullable<
|
|
|
43
53
|
VariantProps<typeof toggleVariants>['size']
|
|
44
54
|
>;
|
|
45
55
|
|
|
56
|
+
/**
|
|
57
|
+
* A group's look: a toggle variant for every item, or `'segmented'` for the
|
|
58
|
+
* platform's segmented control (see UiToggleGroup).
|
|
59
|
+
*/
|
|
60
|
+
export type ToggleGroupVariant = ToggleVariant | 'segmented';
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* `UiToggleGroupItem`, for the group's content query. A call rather than
|
|
64
|
+
* the class itself: for a class, Angular's test build keeps an arrow
|
|
65
|
+
* function that nothing calls, which an app's coverage report would show as
|
|
66
|
+
* uncovered.
|
|
67
|
+
*/
|
|
68
|
+
const itemType = () => UiToggleGroupItem;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The group, as the segmented control it provides reads it. Named, not
|
|
72
|
+
* inline: the component's metadata keeps its own copy of an inline factory,
|
|
73
|
+
* which nothing calls.
|
|
74
|
+
*/
|
|
75
|
+
const toggleGroupHost = (): SegmentedHost => inject(UiToggleGroup);
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The color icons projected into an item draw with (see UI_ICON_COLOR): a
|
|
79
|
+
* Material segment's content color, or else whatever surrounds the group.
|
|
80
|
+
*/
|
|
81
|
+
const itemIconColor = () => {
|
|
82
|
+
const item = inject(UiToggleGroupItem);
|
|
83
|
+
const surrounding = inject(UI_ICON_COLOR, { optional: true, skipSelf: true });
|
|
84
|
+
return computed(() => item.iconColor() ?? surrounding?.());
|
|
85
|
+
};
|
|
86
|
+
|
|
46
87
|
@Component({
|
|
47
88
|
selector: 'ui-toggle',
|
|
48
|
-
standalone: true,
|
|
49
89
|
imports: [LYNX_ELEMENTS],
|
|
50
90
|
encapsulation: ViewEncapsulation.None,
|
|
51
91
|
template: `
|
|
@@ -68,7 +108,7 @@ export class UiToggle {
|
|
|
68
108
|
readonly pressed = model(false);
|
|
69
109
|
readonly variant = input<ToggleVariant>('default');
|
|
70
110
|
readonly size = input<ToggleSize>('default');
|
|
71
|
-
readonly disabled = input(false);
|
|
111
|
+
readonly disabled = input(false, { transform: booleanAttribute });
|
|
72
112
|
readonly userClass = input<string>('', { alias: 'class' });
|
|
73
113
|
|
|
74
114
|
readonly containerRef = viewChild<ElementRef>('container');
|
|
@@ -106,27 +146,77 @@ export class UiToggle {
|
|
|
106
146
|
}
|
|
107
147
|
}
|
|
108
148
|
|
|
149
|
+
/**
|
|
150
|
+
* A row of toggles where one (`type="single"`) or several
|
|
151
|
+
* (`type="multiple"`) are pressed. Its `variant` and `size` style every item.
|
|
152
|
+
*
|
|
153
|
+
* `variant="segmented"` draws the platform's segmented control instead
|
|
154
|
+
* (see UiSegmentedControl): iOS 26's capsule with a thumb that lifts into
|
|
155
|
+
* Liquid Glass as it slides between the items, iOS 13 to 18's raised thumb
|
|
156
|
+
* before iOS 26, and Material 3 Expressive's connected button group on
|
|
157
|
+
* Android. The items share the row's width. A single selection has one
|
|
158
|
+
* indicator that slides, which can also be dragged across the items, and
|
|
159
|
+
* keeps one item selected: tapping it again doesn't clear it, as a
|
|
160
|
+
* segmented control's segment doesn't. In a multiple selection each pressed
|
|
161
|
+
* item draws its own thumb, and nothing slides: one indicator can't mark
|
|
162
|
+
* several items. `size` and the other variants don't apply to it.
|
|
163
|
+
*/
|
|
109
164
|
@Component({
|
|
110
165
|
selector: 'ui-toggle-group',
|
|
111
|
-
|
|
112
|
-
imports: [LYNX_ELEMENTS],
|
|
166
|
+
imports: [LYNX_ELEMENTS, UiSegmentedIndicator],
|
|
113
167
|
encapsulation: ViewEncapsulation.None,
|
|
168
|
+
providers: [
|
|
169
|
+
UiSegmentedControl,
|
|
170
|
+
{ provide: UI_SEGMENTED_HOST, useFactory: toggleGroupHost },
|
|
171
|
+
],
|
|
172
|
+
// The mouse leaving the row lets go of an indicator dragged in the web
|
|
173
|
+
// preview, where a drag gets no touchcancel.
|
|
114
174
|
template: `
|
|
115
|
-
<view
|
|
175
|
+
<view
|
|
176
|
+
[class]="containerClass()"
|
|
177
|
+
(bindmouseleave)="segmentedControl.cancel()"
|
|
178
|
+
>
|
|
179
|
+
@if (segmentedControl.underlay()) {
|
|
180
|
+
<ui-segmented-indicator />
|
|
181
|
+
}
|
|
116
182
|
<ng-content />
|
|
117
183
|
</view>
|
|
118
184
|
`,
|
|
119
185
|
})
|
|
120
|
-
export class UiToggleGroup {
|
|
186
|
+
export class UiToggleGroup implements SegmentedHost {
|
|
121
187
|
readonly type = input<'single' | 'multiple'>('single');
|
|
122
188
|
readonly value = model<string[]>([]);
|
|
123
|
-
|
|
189
|
+
/**
|
|
190
|
+
* `'default'` or `'outline'` for every item, or `'segmented'` for the
|
|
191
|
+
* platform's segmented control.
|
|
192
|
+
*/
|
|
193
|
+
readonly variant = input<ToggleGroupVariant>('default');
|
|
124
194
|
readonly size = input<ToggleSize>('default');
|
|
125
|
-
readonly disabled = input(false);
|
|
195
|
+
readonly disabled = input(false, { transform: booleanAttribute });
|
|
126
196
|
readonly userClass = input<string>('', { alias: 'class' });
|
|
127
197
|
|
|
198
|
+
protected readonly segmentedControl = inject(UiSegmentedControl);
|
|
199
|
+
/** The items in the group, in order. */
|
|
200
|
+
readonly items = contentChildren(itemType(), { descendants: true });
|
|
201
|
+
|
|
202
|
+
/** Whether the group is drawn as a segmented control (SegmentedHost). */
|
|
203
|
+
readonly segmented = computed(() => this.variant() === 'segmented');
|
|
204
|
+
/** The items' values, in order (SegmentedHost). */
|
|
205
|
+
readonly segmentValues = computed(() =>
|
|
206
|
+
this.items().map((item) => item.itemValue()),
|
|
207
|
+
);
|
|
208
|
+
/** The pressed items' values (SegmentedHost). */
|
|
209
|
+
readonly selectedValues = this.value.asReadonly();
|
|
210
|
+
/** Whether several items can be pressed (SegmentedHost). */
|
|
211
|
+
readonly multiple = computed(() => this.type() === 'multiple');
|
|
212
|
+
|
|
128
213
|
protected readonly containerClass = computed(() =>
|
|
129
|
-
cn(
|
|
214
|
+
cn(
|
|
215
|
+
this.segmented()
|
|
216
|
+
? this.segmentedControl.trackClass()
|
|
217
|
+
: 'flex-row items-center gap-1 flex',
|
|
218
|
+
this.userClass(),
|
|
219
|
+
),
|
|
130
220
|
);
|
|
131
221
|
|
|
132
222
|
toggle(val: string): void {
|
|
@@ -135,8 +225,9 @@ export class UiToggleGroup {
|
|
|
135
225
|
const current = this.value();
|
|
136
226
|
// Single mode allows deselection: tapping the active item again clears
|
|
137
227
|
// the selection to []. This differs from a radio group, which requires
|
|
138
|
-
// one item to always be selected.
|
|
139
|
-
|
|
228
|
+
// one item to always be selected. A segmented control's segment stays
|
|
229
|
+
// selected, as on iOS and Android.
|
|
230
|
+
this.value.set(current.includes(val) && !this.segmented() ? [] : [val]);
|
|
140
231
|
} else {
|
|
141
232
|
this.value.update((list) =>
|
|
142
233
|
list.includes(val) ? list.filter((v) => v !== val) : [...list, val],
|
|
@@ -144,6 +235,14 @@ export class UiToggleGroup {
|
|
|
144
235
|
}
|
|
145
236
|
}
|
|
146
237
|
|
|
238
|
+
/**
|
|
239
|
+
* Selects one item, as dragging the segmented control's indicator onto it
|
|
240
|
+
* does (SegmentedHost).
|
|
241
|
+
*/
|
|
242
|
+
select(val: string): void {
|
|
243
|
+
this.value.set([val]);
|
|
244
|
+
}
|
|
245
|
+
|
|
147
246
|
isSelected(val: string): boolean {
|
|
148
247
|
return this.value().includes(val);
|
|
149
248
|
}
|
|
@@ -151,68 +250,184 @@ export class UiToggleGroup {
|
|
|
151
250
|
|
|
152
251
|
@Component({
|
|
153
252
|
selector: 'ui-toggle-group-item',
|
|
154
|
-
standalone: true,
|
|
155
253
|
imports: [LYNX_ELEMENTS],
|
|
156
254
|
encapsulation: ViewEncapsulation.None,
|
|
255
|
+
// Icons projected into the item inject this: they're declared inside it.
|
|
256
|
+
providers: [{ provide: UI_ICON_COLOR, useFactory: itemIconColor }],
|
|
257
|
+
host: {
|
|
258
|
+
'[class]': 'hostClass()',
|
|
259
|
+
// The segmented control places its indicator from each item's width.
|
|
260
|
+
'(bindlayoutchange)': 'measure($event)',
|
|
261
|
+
},
|
|
157
262
|
template: `
|
|
263
|
+
<!-- A labeled item is selected to a screen reader while pressed, which
|
|
264
|
+
iOS reads. lynxAccessibilityTraits adds the trait to button on iOS,
|
|
265
|
+
and leaves Android, which reads one trait, with button. -->
|
|
158
266
|
<view
|
|
159
267
|
#container
|
|
160
268
|
[class]="containerClass()"
|
|
161
|
-
|
|
269
|
+
[style]="containerStyle()"
|
|
270
|
+
[attr.accessibility-element]="accessibilityLabel() ? 'true' : null"
|
|
271
|
+
[attr.accessibility-traits]="accessibilityLabel() ? 'button' : null"
|
|
272
|
+
[attr.accessibility-label]="accessibilityLabel() || null"
|
|
273
|
+
[lynxAccessibilityTraits]="{ selected: selectedTrait() }"
|
|
274
|
+
(bindtouchstart)="onPressStart($event)"
|
|
275
|
+
(bindtouchmove)="onPressMove($event)"
|
|
162
276
|
(bindtouchend)="onPressEnd()"
|
|
163
277
|
(bindtouchcancel)="onPressCancel()"
|
|
164
|
-
(bindmousedown)="onPressStart()"
|
|
278
|
+
(bindmousedown)="onPressStart($event)"
|
|
279
|
+
(bindmousemove)="onPressMove($event)"
|
|
165
280
|
(bindmouseup)="onPressEnd()"
|
|
166
|
-
(bindmouseleave)="
|
|
281
|
+
(bindmouseleave)="onPointerLeave()"
|
|
167
282
|
(bindtap)="onTap()"
|
|
168
283
|
>
|
|
284
|
+
@if (stateLayerClass(); as stateLayer) {
|
|
285
|
+
<view [class]="stateLayer"></view>
|
|
286
|
+
}
|
|
169
287
|
<ng-content />
|
|
170
288
|
</view>
|
|
171
289
|
`,
|
|
172
290
|
})
|
|
173
291
|
export class UiToggleGroupItem {
|
|
174
292
|
readonly #group = inject(UiToggleGroup);
|
|
293
|
+
readonly #segmented = inject(UiSegmentedControl);
|
|
175
294
|
|
|
176
295
|
readonly itemValue = input.required<string>({ alias: 'value' });
|
|
296
|
+
/**
|
|
297
|
+
* What a screen reader says for the item. With it, the item is one
|
|
298
|
+
* accessibility element, a button that's `selected` while pressed. An
|
|
299
|
+
* item of only an icon needs one.
|
|
300
|
+
*/
|
|
301
|
+
readonly accessibilityLabel = input<string | undefined>(undefined, {
|
|
302
|
+
alias: 'accessibility-label',
|
|
303
|
+
});
|
|
177
304
|
readonly userClass = input<string>('', { alias: 'class' });
|
|
178
305
|
|
|
179
|
-
|
|
306
|
+
// Required: always in the template.
|
|
307
|
+
readonly containerRef = viewChild.required<ElementRef>('container');
|
|
180
308
|
#pressAnim?: AnimationHandle;
|
|
181
309
|
|
|
182
310
|
protected readonly isSelected = computed(() =>
|
|
183
311
|
this.#group.isSelected(this.itemValue()),
|
|
184
312
|
);
|
|
313
|
+
/** Only a labeled item is an accessibility element to be `selected`. */
|
|
314
|
+
protected readonly selectedTrait = computed(
|
|
315
|
+
() => !!this.accessibilityLabel() && this.isSelected(),
|
|
316
|
+
);
|
|
185
317
|
|
|
186
|
-
|
|
187
|
-
|
|
318
|
+
/** Whether the item is a segment of a segmented control. */
|
|
319
|
+
readonly #segment = computed(() => this.#segmented.enabled());
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* The color of icons projected into the item, as a token, or undefined
|
|
323
|
+
* for the surrounding one. A Material segment's content takes the color
|
|
324
|
+
* of its fill's content.
|
|
325
|
+
*/
|
|
326
|
+
readonly iconColor = computed(() =>
|
|
327
|
+
this.#segment() ? this.#segmented.iconColor(this.itemValue()) : undefined,
|
|
328
|
+
);
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* A segment's host is the track's flex item, an equal share of it. Angular
|
|
332
|
+
* puts the app's static `class` on the host as well as on the item, so the
|
|
333
|
+
* two are merged here: `class="flex-[2]"` makes a wider segment.
|
|
334
|
+
*/
|
|
335
|
+
protected readonly hostClass = computed(() =>
|
|
336
|
+
this.#segment()
|
|
337
|
+
? cn(this.#segmented.segmentHostClass, this.userClass())
|
|
338
|
+
: '',
|
|
339
|
+
);
|
|
340
|
+
|
|
341
|
+
protected readonly containerClass = computed(() => {
|
|
342
|
+
if (this.#segment()) {
|
|
343
|
+
return cn(
|
|
344
|
+
this.#segmented.segmentClass(
|
|
345
|
+
this.itemValue(),
|
|
346
|
+
segmentPlace(this.#group.items(), this),
|
|
347
|
+
),
|
|
348
|
+
this.userClass(),
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
return cn(
|
|
188
352
|
toggleVariants({
|
|
189
|
-
variant
|
|
353
|
+
// The group's variant is a toggle variant unless it's segmented.
|
|
354
|
+
variant: this.#group.variant() as ToggleVariant,
|
|
190
355
|
size: this.#group.size(),
|
|
191
356
|
}),
|
|
192
357
|
this.isSelected() ? 'bg-accent' : 'bg-transparent',
|
|
193
358
|
this.#group.disabled() && 'opacity-50',
|
|
194
359
|
this.userClass(),
|
|
195
|
-
)
|
|
360
|
+
);
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
protected readonly containerStyle = computed(() =>
|
|
364
|
+
this.#segment() ? this.#segmented.segmentStyle(this.itemValue()) : null,
|
|
196
365
|
);
|
|
197
366
|
|
|
198
|
-
protected
|
|
367
|
+
protected readonly stateLayerClass = computed(() =>
|
|
368
|
+
this.#segment() ? this.#segmented.stateLayerClass(this.itemValue()) : null,
|
|
369
|
+
);
|
|
370
|
+
|
|
371
|
+
protected measure(event: unknown): void {
|
|
372
|
+
this.#segmented.measure(this.itemValue(), event);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* A press. A segment's touches go to the segmented control, which draws
|
|
377
|
+
* their feedback and moves its indicator; other items shrink under the
|
|
378
|
+
* finger.
|
|
379
|
+
*/
|
|
380
|
+
protected onPressStart(event: unknown): void {
|
|
199
381
|
if (this.#group.disabled()) return;
|
|
382
|
+
if (this.#segment()) {
|
|
383
|
+
this.#segmented.press(this.itemValue(), event);
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
200
386
|
this.#pressAnim?.cancel();
|
|
201
|
-
this.#pressAnim = pressDown(this.containerRef()
|
|
387
|
+
this.#pressAnim = pressDown(this.containerRef().nativeElement);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
protected onPressMove(event: unknown): void {
|
|
391
|
+
this.#segmented.move(event);
|
|
202
392
|
}
|
|
203
393
|
|
|
204
394
|
protected onPressEnd(): void {
|
|
205
395
|
if (this.#group.disabled()) return;
|
|
396
|
+
if (this.#segment()) {
|
|
397
|
+
this.#segmented.release();
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
206
400
|
this.#pressAnim?.cancel();
|
|
207
|
-
this.#pressAnim = pressRelease(this.containerRef()
|
|
401
|
+
this.#pressAnim = pressRelease(this.containerRef().nativeElement);
|
|
208
402
|
}
|
|
209
403
|
|
|
210
404
|
protected onPressCancel(): void {
|
|
211
|
-
this.#
|
|
212
|
-
|
|
405
|
+
if (this.#segment()) {
|
|
406
|
+
this.#segmented.cancel();
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
this.#release();
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* The mouse leaving one segment doesn't end a drag across the others.
|
|
414
|
+
*/
|
|
415
|
+
protected onPointerLeave(): void {
|
|
416
|
+
if (this.#segment()) {
|
|
417
|
+
this.#segmented.leave(this.itemValue());
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
this.#release();
|
|
213
421
|
}
|
|
214
422
|
|
|
215
423
|
protected onTap(): void {
|
|
216
|
-
this.#
|
|
424
|
+
if (this.#segmented.tap(this.itemValue())) {
|
|
425
|
+
this.#group.toggle(this.itemValue());
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
#release(): void {
|
|
430
|
+
this.#pressAnim?.cancel();
|
|
431
|
+
this.#pressAnim = pressRelease(this.containerRef().nativeElement);
|
|
217
432
|
}
|
|
218
433
|
}
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
import { Component, signal } from '@angular/core';
|
|
2
|
+
import {
|
|
3
|
+
fireEvent,
|
|
4
|
+
render,
|
|
5
|
+
waitForUpdate,
|
|
6
|
+
} from '@blotch/angular-lynx-testing-library';
|
|
7
|
+
import { LYNX_ELEMENTS } from '@blotch/angular-lynx';
|
|
8
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
9
|
+
import { fireLynxEvent } from '../../../test-utils/fire-lynx-event';
|
|
10
|
+
import { provideGlass } from '../glass/glass-design';
|
|
11
|
+
import { UiBarScroll, UiBarState } from './bar-scroll';
|
|
12
|
+
|
|
13
|
+
@Component({
|
|
14
|
+
imports: [LYNX_ELEMENTS, UiBarScroll],
|
|
15
|
+
template: `
|
|
16
|
+
@if (first()) {
|
|
17
|
+
<scroll-view ui-bar-scroll id="first" scroll-orientation="vertical">
|
|
18
|
+
<view style="height: 2000px"></view>
|
|
19
|
+
</scroll-view>
|
|
20
|
+
}
|
|
21
|
+
@if (second()) {
|
|
22
|
+
<scroll-view ui-bar-scroll id="second" scroll-orientation="vertical">
|
|
23
|
+
<view style="height: 2000px"></view>
|
|
24
|
+
</scroll-view>
|
|
25
|
+
}
|
|
26
|
+
`,
|
|
27
|
+
})
|
|
28
|
+
class ScrollHost {
|
|
29
|
+
readonly first = signal(true);
|
|
30
|
+
readonly second = signal(false);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const IOS_26 = { platform: 'iOS', osVersion: '26.0' };
|
|
34
|
+
const IOS_18 = { platform: 'iOS', osVersion: '18.5' };
|
|
35
|
+
const ANDROID = { platform: 'Android', osVersion: '15' };
|
|
36
|
+
const WEB = { platform: 'web', osVersion: '' };
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The bars a screen would have, as a navigation bar and a toolbar say.
|
|
40
|
+
*/
|
|
41
|
+
const topBar = (largeTitle: string | null = null) => ({
|
|
42
|
+
height: signal(44),
|
|
43
|
+
largeTitle: signal(largeTitle),
|
|
44
|
+
});
|
|
45
|
+
const bottomBar = () => ({ height: signal(52) });
|
|
46
|
+
|
|
47
|
+
const renderScroll = async (
|
|
48
|
+
platform: { platform: string; osVersion: string } = IOS_26,
|
|
49
|
+
) => {
|
|
50
|
+
vi.stubGlobal('SystemInfo', platform);
|
|
51
|
+
const result = await render(ScrollHost);
|
|
52
|
+
const host = result.componentRef.instance as ScrollHost;
|
|
53
|
+
const state = result.componentRef.injector.get(UiBarState);
|
|
54
|
+
const scrollView = (id = 'first') =>
|
|
55
|
+
result.container.querySelector<HTMLElement>(`#${id}`)!;
|
|
56
|
+
/**
|
|
57
|
+
* The spacers the scroll view puts at its ends for the bars.
|
|
58
|
+
*/
|
|
59
|
+
const topSpacer = () => scrollView().firstElementChild as HTMLElement;
|
|
60
|
+
const bottomSpacer = () => scrollView().lastElementChild as HTMLElement;
|
|
61
|
+
const update = async (change: () => void) => {
|
|
62
|
+
change();
|
|
63
|
+
await waitForUpdate();
|
|
64
|
+
};
|
|
65
|
+
/**
|
|
66
|
+
* Scrolls the content to `scrollTop`, as Lynx reports it.
|
|
67
|
+
*/
|
|
68
|
+
const scrollTo = (scrollTop: number, id = 'first') =>
|
|
69
|
+
fireEvent.scroll(scrollView(id), {
|
|
70
|
+
detail: { scrollTop, scrollHeight: 2000 },
|
|
71
|
+
});
|
|
72
|
+
/**
|
|
73
|
+
* Lays the scroll view out `height` px tall, and its spacers.
|
|
74
|
+
*/
|
|
75
|
+
const layOut = async (height: number, top = 0, bottom = 0) => {
|
|
76
|
+
fireEvent.layoutchange(scrollView(), { detail: { width: 402, height } });
|
|
77
|
+
fireEvent.layoutchange(topSpacer(), {
|
|
78
|
+
detail: { width: 402, height: top },
|
|
79
|
+
});
|
|
80
|
+
fireEvent.layoutchange(bottomSpacer(), {
|
|
81
|
+
detail: { width: 402, height: bottom },
|
|
82
|
+
});
|
|
83
|
+
await waitForUpdate();
|
|
84
|
+
};
|
|
85
|
+
return {
|
|
86
|
+
...result,
|
|
87
|
+
host,
|
|
88
|
+
state,
|
|
89
|
+
scrollView,
|
|
90
|
+
topSpacer,
|
|
91
|
+
bottomSpacer,
|
|
92
|
+
update,
|
|
93
|
+
scrollTo,
|
|
94
|
+
layOut,
|
|
95
|
+
};
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The scroll view's mask as [how much shows, where] stops, however the DOM
|
|
100
|
+
* writes it back: jsdom versions differ, writing opaque black as
|
|
101
|
+
* `rgb(0, 0, 0)` and leaving out the default `to bottom`.
|
|
102
|
+
*/
|
|
103
|
+
const maskStops = (element: HTMLElement): [number, string][] =>
|
|
104
|
+
Array.from(
|
|
105
|
+
element.style
|
|
106
|
+
.getPropertyValue('mask-image')
|
|
107
|
+
.matchAll(
|
|
108
|
+
/rgba?\(0, 0, 0(?:, ([\d.]+))?\) (calc\([^)]*\)|[\d.]+(?:%|px))/g,
|
|
109
|
+
),
|
|
110
|
+
([, alpha, at]): [number, string] => [
|
|
111
|
+
alpha === undefined ? 1 : Number(alpha),
|
|
112
|
+
at,
|
|
113
|
+
],
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
afterEach(() => {
|
|
117
|
+
vi.unstubAllGlobals();
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
describe('UiBarScroll', () => {
|
|
121
|
+
it('makes no room and draws no edge without bars over it', async () => {
|
|
122
|
+
const { topSpacer, bottomSpacer, scrollView, layOut, queryByRole } =
|
|
123
|
+
await renderScroll();
|
|
124
|
+
await layOut(800);
|
|
125
|
+
expect(topSpacer().style.height).toBe('');
|
|
126
|
+
expect(bottomSpacer().style.height).toBe('');
|
|
127
|
+
expect(scrollView().style.getPropertyValue('mask-image')).toBe('');
|
|
128
|
+
expect(queryByRole('heading')).toBeNull();
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('starts the content below the top bar and ends it above the bottom bar', async () => {
|
|
132
|
+
const { state, topSpacer, bottomSpacer, update } = await renderScroll();
|
|
133
|
+
await update(() => {
|
|
134
|
+
state.topBar.set(topBar());
|
|
135
|
+
state.bottomBar.set(bottomBar());
|
|
136
|
+
});
|
|
137
|
+
// The status bar and the home indicator count too. jsdom versions write
|
|
138
|
+
// the sum in different orders.
|
|
139
|
+
expect(topSpacer().style.height).toContain('44px');
|
|
140
|
+
expect(topSpacer().style.height).toContain('env(safe-area-inset-top)');
|
|
141
|
+
expect(bottomSpacer().style.height).toContain('52px');
|
|
142
|
+
expect(bottomSpacer().style.height).toContain(
|
|
143
|
+
'env(safe-area-inset-bottom)',
|
|
144
|
+
);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('puts the large title at the top of the content, and tells the bar when it has scrolled under', async () => {
|
|
148
|
+
const { state, update, getByRole, scrollTo } = await renderScroll();
|
|
149
|
+
await update(() => state.topBar.set(topBar('Inbox')));
|
|
150
|
+
const title = getByRole('heading', { name: 'Inbox' });
|
|
151
|
+
expect(title.className).toContain('text-[34px]');
|
|
152
|
+
expect(title.parentElement?.className).toContain('h-[52px]');
|
|
153
|
+
|
|
154
|
+
scrollTo(20);
|
|
155
|
+
expect(state.scrolledUnderTop()).toBe(true);
|
|
156
|
+
expect(state.largeTitleUnder()).toBe(false);
|
|
157
|
+
scrollTo(44);
|
|
158
|
+
expect(state.largeTitleUnder()).toBe(true);
|
|
159
|
+
scrollTo(0);
|
|
160
|
+
expect(state.scrolledUnderTop()).toBe(false);
|
|
161
|
+
expect(state.largeTitleUnder()).toBe(false);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('draws Material’s large title, the medium flexible app bar’s', async () => {
|
|
165
|
+
const { state, update, getByRole, scrollTo } = await renderScroll(ANDROID);
|
|
166
|
+
await update(() => state.topBar.set(topBar('Inbox')));
|
|
167
|
+
const title = getByRole('heading', { name: 'Inbox' });
|
|
168
|
+
expect(title.className).toContain('text-[28px]');
|
|
169
|
+
expect(title.parentElement?.className).toContain('h-[48px]');
|
|
170
|
+
scrollTo(40);
|
|
171
|
+
expect(state.largeTitleUnder()).toBe(true);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('says whether content continues under the bottom bar', async () => {
|
|
175
|
+
const { state, scrollView, scrollTo, layOut } = await renderScroll();
|
|
176
|
+
// Before the scroll view is laid out, it can't tell.
|
|
177
|
+
scrollTo(0);
|
|
178
|
+
expect(state.scrolledUnderBottom()).toBe(false);
|
|
179
|
+
|
|
180
|
+
await layOut(800);
|
|
181
|
+
expect(state.scrolledUnderBottom()).toBe(true);
|
|
182
|
+
scrollTo(1200);
|
|
183
|
+
expect(state.scrolledUnderBottom()).toBe(false);
|
|
184
|
+
|
|
185
|
+
// iOS and Android say when the content's height changes.
|
|
186
|
+
fireLynxEvent(scrollView(), 'contentsizechanged', {
|
|
187
|
+
detail: { scrollTop: 1200, scrollHeight: 2400 },
|
|
188
|
+
});
|
|
189
|
+
expect(state.scrolledUnderBottom()).toBe(true);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('fades content out under glass bars, from their measured room', async () => {
|
|
193
|
+
const { state, scrollView, update, layOut } = await renderScroll();
|
|
194
|
+
await update(() => {
|
|
195
|
+
state.topBar.set(topBar());
|
|
196
|
+
state.bottomBar.set(bottomBar());
|
|
197
|
+
});
|
|
198
|
+
// Not laid out yet: no edge.
|
|
199
|
+
expect(scrollView().style.getPropertyValue('mask-image')).toBe('');
|
|
200
|
+
|
|
201
|
+
// 1000px tall, with 100px for the top bar and 50px for the bottom one.
|
|
202
|
+
await layOut(1000, 100, 50);
|
|
203
|
+
expect(scrollView().style.getPropertyValue('mask-image')).toContain(
|
|
204
|
+
'linear-gradient(',
|
|
205
|
+
);
|
|
206
|
+
expect(maskStops(scrollView())).toEqual([
|
|
207
|
+
[0, '0.00%'],
|
|
208
|
+
[0, '7.00%'],
|
|
209
|
+
[0.12, '9.00%'],
|
|
210
|
+
[1, '10.00%'],
|
|
211
|
+
[1, '95.00%'],
|
|
212
|
+
[0.12, '95.50%'],
|
|
213
|
+
[0, '96.50%'],
|
|
214
|
+
[0, '100.00%'],
|
|
215
|
+
]);
|
|
216
|
+
|
|
217
|
+
// A top bar alone fades the top only, and a bottom bar alone the bottom.
|
|
218
|
+
await layOut(1000, 100, 0);
|
|
219
|
+
expect(maskStops(scrollView()).slice(-2)).toEqual([
|
|
220
|
+
[1, '10.00%'],
|
|
221
|
+
[1, '100.00%'],
|
|
222
|
+
]);
|
|
223
|
+
await layOut(1000, 0, 50);
|
|
224
|
+
expect(maskStops(scrollView()).slice(0, 2)).toEqual([
|
|
225
|
+
[1, '0.00%'],
|
|
226
|
+
[1, '95.00%'],
|
|
227
|
+
]);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it('fades with lengths in the web preview, whose scroll view reports no layout', async () => {
|
|
231
|
+
const { state, scrollView, update, topSpacer, bottomSpacer } =
|
|
232
|
+
await renderScroll(WEB);
|
|
233
|
+
await update(() => {
|
|
234
|
+
state.topBar.set(topBar());
|
|
235
|
+
state.bottomBar.set(bottomBar());
|
|
236
|
+
});
|
|
237
|
+
fireEvent.layoutchange(topSpacer(), {
|
|
238
|
+
detail: { width: 402, height: 100 },
|
|
239
|
+
});
|
|
240
|
+
await waitForUpdate();
|
|
241
|
+
expect(maskStops(scrollView())).toEqual([
|
|
242
|
+
[0, '0px'],
|
|
243
|
+
[0, '70px'],
|
|
244
|
+
[0.12, '90px'],
|
|
245
|
+
[1, '100px'],
|
|
246
|
+
[1, 'calc(100% - 0px)'],
|
|
247
|
+
]);
|
|
248
|
+
|
|
249
|
+
fireEvent.layoutchange(bottomSpacer(), {
|
|
250
|
+
detail: { width: 402, height: 50 },
|
|
251
|
+
});
|
|
252
|
+
await waitForUpdate();
|
|
253
|
+
expect(maskStops(scrollView()).slice(-4)).toEqual([
|
|
254
|
+
[1, 'calc(100% - 50px)'],
|
|
255
|
+
[0.12, 'calc(100% - 45px)'],
|
|
256
|
+
[0, 'calc(100% - 35px)'],
|
|
257
|
+
[0, 'calc(100% - 0px)'],
|
|
258
|
+
]);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it('leaves the edge to Android’s glass bars, as its scroll view takes no mask', async () => {
|
|
262
|
+
vi.stubGlobal('SystemInfo', ANDROID);
|
|
263
|
+
const result = await render(ScrollHost, {
|
|
264
|
+
providers: [provideGlass({ android: 'glass' })],
|
|
265
|
+
});
|
|
266
|
+
const state = result.componentRef.injector.get(UiBarState);
|
|
267
|
+
state.topBar.set(topBar());
|
|
268
|
+
await waitForUpdate();
|
|
269
|
+
const scrollView = result.container.querySelector<HTMLElement>('#first')!;
|
|
270
|
+
fireEvent.layoutchange(scrollView, {
|
|
271
|
+
detail: { width: 402, height: 1000 },
|
|
272
|
+
});
|
|
273
|
+
fireEvent.layoutchange(scrollView.firstElementChild!, {
|
|
274
|
+
detail: { width: 402, height: 100 },
|
|
275
|
+
});
|
|
276
|
+
await waitForUpdate();
|
|
277
|
+
expect(scrollView.style.getPropertyValue('mask-image')).toBe('');
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it('leaves the edge to the bars before iOS 26, which mark it with their background', async () => {
|
|
281
|
+
const { state, scrollView, update, layOut } = await renderScroll(IOS_18);
|
|
282
|
+
await update(() => state.topBar.set(topBar()));
|
|
283
|
+
await layOut(1000, 100);
|
|
284
|
+
expect(scrollView().style.getPropertyValue('mask-image')).toBe('');
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it('lets the last scroll view tell the bars, and resets them when it goes', async () => {
|
|
288
|
+
const { host, state, update, scrollTo } = await renderScroll();
|
|
289
|
+
scrollTo(200);
|
|
290
|
+
expect(state.scrolledUnderTop()).toBe(true);
|
|
291
|
+
|
|
292
|
+
// A second scroll view, a sheet's say, takes over.
|
|
293
|
+
await update(() => host.second.set(true));
|
|
294
|
+
scrollTo(0, 'second');
|
|
295
|
+
expect(state.scrolledUnderTop()).toBe(false);
|
|
296
|
+
scrollTo(200);
|
|
297
|
+
expect(state.scrolledUnderTop()).toBe(false);
|
|
298
|
+
|
|
299
|
+
// The first going leaves the second's state alone.
|
|
300
|
+
scrollTo(300, 'second');
|
|
301
|
+
await update(() => host.first.set(false));
|
|
302
|
+
expect(state.scrolledUnderTop()).toBe(true);
|
|
303
|
+
expect(state.content()).not.toBeNull();
|
|
304
|
+
|
|
305
|
+
// The last going resets it, for the next screen.
|
|
306
|
+
await update(() => host.second.set(false));
|
|
307
|
+
expect(state.content()).toBeNull();
|
|
308
|
+
expect(state.scrolledUnderTop()).toBe(false);
|
|
309
|
+
});
|
|
310
|
+
});
|