@blotch/dolan 22.2.6 → 22.2.8
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/install-packages.test.js +7 -2
- package/dist/commands/install-packages.test.js.map +1 -1
- package/dist/registry.js +8 -2
- package/dist/registry.js.map +1 -1
- package/dist/registry.test.js +1 -0
- package/dist/registry.test.js.map +1 -1
- package/dist/ui/components/accordion/accordion.test.ts +24 -7
- package/dist/ui/components/accordion/accordion.ts +34 -44
- package/dist/ui/components/action-sheet/action-sheet.test.ts +18 -1
- package/dist/ui/components/action-sheet/action-sheet.ts +55 -60
- package/dist/ui/components/alert/alert.test.ts +17 -0
- package/dist/ui/components/alert/alert.ts +23 -14
- package/dist/ui/components/alert-dialog/alert-dialog.test.ts +19 -4
- package/dist/ui/components/alert-dialog/alert-dialog.ts +31 -20
- package/dist/ui/components/aspect-ratio/aspect-ratio.test.ts +38 -2
- package/dist/ui/components/aspect-ratio/aspect-ratio.ts +11 -8
- package/dist/ui/components/avatar/avatar.test.ts +7 -0
- package/dist/ui/components/avatar/avatar.ts +35 -30
- package/dist/ui/components/badge/badge.test.ts +9 -0
- package/dist/ui/components/badge/badge.ts +12 -10
- package/dist/ui/components/bottom-sheet/bottom-sheet.test.ts +7 -0
- package/dist/ui/components/bottom-sheet/bottom-sheet.ts +5 -0
- package/dist/ui/components/button/button.test.ts +59 -3
- package/dist/ui/components/button/button.ts +67 -62
- package/dist/ui/components/button-group/button-group.test.ts +37 -4
- package/dist/ui/components/button-group/button-group.ts +60 -51
- package/dist/ui/components/card/card.test.ts +35 -2
- package/dist/ui/components/card/card.ts +41 -53
- package/dist/ui/components/chart/chart-presets.test.ts +16 -2
- package/dist/ui/components/chart/chart-presets.ts +22 -3
- package/dist/ui/components/chart/chart.test.ts +35 -6
- package/dist/ui/components/chart/chart.ts +14 -3
- package/dist/ui/components/checkbox/checkbox.test.ts +11 -4
- package/dist/ui/components/checkbox/checkbox.ts +21 -14
- package/dist/ui/components/collapsible/collapsible.test.ts +20 -10
- package/dist/ui/components/collapsible/collapsible.ts +29 -40
- package/dist/ui/components/dialog/dialog.test.ts +17 -1
- package/dist/ui/components/dialog/dialog.ts +24 -17
- package/dist/ui/components/dock/dock.test.ts +30 -5
- package/dist/ui/components/dock/dock.ts +29 -27
- package/dist/ui/components/empty-state/empty-state.test.ts +9 -0
- package/dist/ui/components/empty-state/empty-state.ts +20 -11
- package/dist/ui/components/icon/icon.test.ts +134 -2
- package/dist/ui/components/icon/icon.ts +57 -18
- package/dist/ui/components/input/input.test.ts +55 -3
- package/dist/ui/components/input/input.ts +91 -65
- package/dist/ui/components/label/label.test.ts +15 -0
- package/dist/ui/components/label/label.ts +12 -4
- package/dist/ui/components/list/list.test.ts +13 -3
- package/dist/ui/components/list/list.ts +14 -2
- package/dist/ui/components/menu/menu.test.ts +8 -0
- package/dist/ui/components/menu/menu.ts +4 -0
- package/dist/ui/components/nav-drawer/nav-drawer.test.ts +34 -21
- package/dist/ui/components/nav-drawer/nav-drawer.ts +60 -68
- package/dist/ui/components/navigation-bar/navigation-bar.test.ts +13 -0
- package/dist/ui/components/navigation-bar/navigation-bar.ts +5 -0
- package/dist/ui/components/pagination/pagination.test.ts +9 -0
- package/dist/ui/components/pagination/pagination.ts +52 -49
- package/dist/ui/components/popover/popover.test.ts +3 -0
- package/dist/ui/components/progress/progress.test.ts +7 -0
- package/dist/ui/components/progress/progress.ts +8 -6
- package/dist/ui/components/radio-group/radio-group.test.ts +8 -0
- package/dist/ui/components/radio-group/radio-group.ts +22 -19
- package/dist/ui/components/scroll-area/scroll-area.test.ts +14 -2
- package/dist/ui/components/scroll-area/scroll-area.ts +15 -3
- package/dist/ui/components/search-field/search-field.test.ts +25 -0
- package/dist/ui/components/search-field/search-field.ts +7 -1
- package/dist/ui/components/select/select.test.ts +32 -5
- package/dist/ui/components/select/select.ts +32 -11
- package/dist/ui/components/separator/separator.test.ts +3 -0
- package/dist/ui/components/sheet/sheet.test.ts +18 -0
- package/dist/ui/components/sheet/sheet.ts +24 -24
- package/dist/ui/components/skeleton/skeleton.test.ts +8 -0
- package/dist/ui/components/skeleton/skeleton.ts +13 -8
- package/dist/ui/components/slider/slider.test.ts +13 -1
- package/dist/ui/components/slider/slider.ts +16 -3
- package/dist/ui/components/spinner/spinner.test.ts +16 -1
- package/dist/ui/components/spinner/spinner.ts +15 -7
- package/dist/ui/components/switch/switch.test.ts +13 -2
- package/dist/ui/components/switch/switch.ts +15 -1
- package/dist/ui/components/tab-bar/tab-bar.test.ts +32 -6
- package/dist/ui/components/tab-bar/tab-bar.ts +28 -24
- package/dist/ui/components/tabs/tabs.test.ts +34 -32
- package/dist/ui/components/tabs/tabs.ts +56 -73
- package/dist/ui/components/textarea/textarea.test.ts +11 -1
- package/dist/ui/components/textarea/textarea.ts +77 -71
- package/dist/ui/components/toggle/toggle.test.ts +47 -16
- package/dist/ui/components/toggle/toggle.ts +78 -80
- package/dist/ui/components/typography/typography.test.ts +16 -0
- package/dist/ui/components/typography/typography.ts +12 -4
- package/package.json +1 -1
|
@@ -141,7 +141,13 @@ const iconSvg = (icon: IconName, color: string): string =>
|
|
|
141
141
|
selector: 'ui-search-field',
|
|
142
142
|
imports: [LYNX_ELEMENTS, NgTemplateOutlet, UiGlass, UiGlassGroup],
|
|
143
143
|
encapsulation: ViewEncapsulation.None,
|
|
144
|
-
|
|
144
|
+
// Angular gives a component's host every static attribute the app writes on
|
|
145
|
+
// it. The placeholder belongs to the <input>, so the host drops its copy: a
|
|
146
|
+
// second element with it is a second match for getByPlaceholderText.
|
|
147
|
+
host: {
|
|
148
|
+
'[class]': 'hostClass()',
|
|
149
|
+
'[attr.placeholder]': 'null',
|
|
150
|
+
},
|
|
145
151
|
template: `
|
|
146
152
|
<ng-template #field>
|
|
147
153
|
@if (showsBack()) {
|
|
@@ -41,7 +41,9 @@ const renderSelect = async () => {
|
|
|
41
41
|
...result,
|
|
42
42
|
host: result.componentRef.instance as SelectHost,
|
|
43
43
|
overlay,
|
|
44
|
-
|
|
44
|
+
// The field is the host; the row inside it takes the taps.
|
|
45
|
+
field: container.querySelector('.border-input')!,
|
|
46
|
+
trigger: container.querySelector('.border-input')!.firstElementChild!,
|
|
45
47
|
triggerText: () => container.querySelector('.border-input text')!,
|
|
46
48
|
// An item's tappable row, found by its label in the sheet.
|
|
47
49
|
item: (label: string) => within(overlay).getByText(label).parentElement!,
|
|
@@ -66,10 +68,35 @@ const waitForClose = (overlay: Element) =>
|
|
|
66
68
|
|
|
67
69
|
describe('UiSelect', () => {
|
|
68
70
|
it('shows the placeholder, muted, until a value is chosen', async () => {
|
|
69
|
-
const { triggerText,
|
|
71
|
+
const { triggerText, field } = await renderSelect();
|
|
70
72
|
expect(triggerText().textContent).toBe('Pick a fruit');
|
|
71
73
|
expect(triggerText().classList).toContain('text-muted-foreground');
|
|
72
|
-
expect(
|
|
74
|
+
expect(field.classList).toContain('w-48');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// Static attributes land on a component's host too (issue #181): the
|
|
78
|
+
// width applies once, to the field, and the placeholder is only shown.
|
|
79
|
+
it('puts its class on one element, and its placeholder only in its text', async () => {
|
|
80
|
+
@Component({
|
|
81
|
+
imports: [UiSelect, UiSelectItem],
|
|
82
|
+
template: `
|
|
83
|
+
<ui-select placeholder="Pick a fruit" class="w-48">
|
|
84
|
+
<ui-select-item value="apple" label="Apple" class="font-bold" />
|
|
85
|
+
</ui-select>
|
|
86
|
+
`,
|
|
87
|
+
})
|
|
88
|
+
class StaticAttributesHost {}
|
|
89
|
+
|
|
90
|
+
const { container, getByText, queryAllByPlaceholderText } =
|
|
91
|
+
await render(StaticAttributesHost);
|
|
92
|
+
expect([...container.querySelectorAll('.w-48')]).toEqual([
|
|
93
|
+
container.querySelector('.border-input'),
|
|
94
|
+
]);
|
|
95
|
+
expect(queryAllByPlaceholderText('Pick a fruit')).toEqual([]);
|
|
96
|
+
expect(getByText('Pick a fruit')).toBeTruthy();
|
|
97
|
+
expect([...container.querySelectorAll('.font-bold')]).toEqual([
|
|
98
|
+
getByText('Apple').parentElement,
|
|
99
|
+
]);
|
|
73
100
|
});
|
|
74
101
|
|
|
75
102
|
it("shows the chosen item's label", async () => {
|
|
@@ -122,10 +149,10 @@ describe('UiSelect', () => {
|
|
|
122
149
|
});
|
|
123
150
|
|
|
124
151
|
it("dims and doesn't open while disabled", async () => {
|
|
125
|
-
const { host, overlay, trigger, getByText } = await renderSelect();
|
|
152
|
+
const { host, overlay, field, trigger, getByText } = await renderSelect();
|
|
126
153
|
host.disabled.set(true);
|
|
127
154
|
await waitForUpdate();
|
|
128
|
-
expect(
|
|
155
|
+
expect(field.classList).toContain('opacity-50');
|
|
129
156
|
|
|
130
157
|
fireEvent.tap(trigger);
|
|
131
158
|
fireEvent.tap(getByText('Choose'));
|
|
@@ -25,17 +25,34 @@ const CHECK_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" f
|
|
|
25
25
|
*/
|
|
26
26
|
const selectItem = () => UiSelectItem;
|
|
27
27
|
|
|
28
|
+
/**
|
|
29
|
+
* The host is the field: its border, fill and height, and the app's class.
|
|
30
|
+
* Angular gives a component's host every static attribute the app writes on
|
|
31
|
+
* it, so a class on <ui-select> lands on the host whatever the component
|
|
32
|
+
* does: passed on to a field inside it as well, a margin applied twice. The
|
|
33
|
+
* trigger that takes the tap fills the field, beside the sheet rather than
|
|
34
|
+
* around it, so a tap in the sheet doesn't bubble to the trigger. The
|
|
35
|
+
* placeholder is shown in a text, so the host drops its copy: an element with
|
|
36
|
+
* the attribute is a match for getByPlaceholderText.
|
|
37
|
+
*/
|
|
28
38
|
@Component({
|
|
29
39
|
selector: 'ui-select',
|
|
30
|
-
standalone: true,
|
|
31
40
|
imports: [LYNX_ELEMENTS, UiBottomSheet],
|
|
32
41
|
encapsulation: ViewEncapsulation.None,
|
|
42
|
+
host: {
|
|
43
|
+
'[class]': 'hostClass()',
|
|
44
|
+
'[attr.placeholder]': 'null',
|
|
45
|
+
},
|
|
33
46
|
// `sheetOpen` is bound both ways by hand rather than with `[(open)]`: for a
|
|
34
47
|
// two-way binding Angular also compiles a fallback that assigns the field,
|
|
35
48
|
// which never runs for a signal and so shows as an uncovered branch in an
|
|
36
49
|
// app's coverage report.
|
|
37
50
|
template: `
|
|
38
|
-
|
|
51
|
+
<!-- The trigger fills the field: the row stretches it to its height. -->
|
|
52
|
+
<view
|
|
53
|
+
class="flex-1 flex-row items-center px-3 py-2 flex justify-between"
|
|
54
|
+
(bindtap)="toggle()"
|
|
55
|
+
>
|
|
39
56
|
<text [class]="valueTextClass()">{{ displayText() }}</text>
|
|
40
57
|
<svg [attr.content]="chevronSvg" style="width: 16px; height: 16px;" />
|
|
41
58
|
</view>
|
|
@@ -73,9 +90,9 @@ export class UiSelect {
|
|
|
73
90
|
return item?.label() ?? val;
|
|
74
91
|
});
|
|
75
92
|
|
|
76
|
-
protected readonly
|
|
93
|
+
protected readonly hostClass = computed(() =>
|
|
77
94
|
cn(
|
|
78
|
-
'h-10 flex-row
|
|
95
|
+
'h-10 flex-row rounded-md border border-input bg-background flex',
|
|
79
96
|
this.disabled() && 'opacity-50',
|
|
80
97
|
this.userClass(),
|
|
81
98
|
),
|
|
@@ -110,18 +127,22 @@ export class UiSelect {
|
|
|
110
127
|
}
|
|
111
128
|
}
|
|
112
129
|
|
|
130
|
+
/**
|
|
131
|
+
* Each item is its host, styled through `host` (see UiSelect).
|
|
132
|
+
*/
|
|
113
133
|
@Component({
|
|
114
134
|
selector: 'ui-select-item',
|
|
115
|
-
standalone: true,
|
|
116
135
|
imports: [LYNX_ELEMENTS],
|
|
117
136
|
encapsulation: ViewEncapsulation.None,
|
|
137
|
+
host: {
|
|
138
|
+
'[class]': 'containerClass()',
|
|
139
|
+
'(bindtap)': 'onTap()',
|
|
140
|
+
},
|
|
118
141
|
template: `
|
|
119
|
-
<
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
}
|
|
124
|
-
</view>
|
|
142
|
+
<text [class]="labelClass()">{{ label() }}</text>
|
|
143
|
+
@if (isSelected()) {
|
|
144
|
+
<svg [attr.content]="checkSvg" style="width: 16px; height: 16px;" />
|
|
145
|
+
}
|
|
125
146
|
`,
|
|
126
147
|
})
|
|
127
148
|
export class UiSelectItem {
|
|
@@ -18,6 +18,9 @@ describe('UiSeparator', () => {
|
|
|
18
18
|
const line = container.querySelector('.bg-border')!;
|
|
19
19
|
expect(line.classList).toContain('h-px');
|
|
20
20
|
expect(line.classList).toContain('my-2');
|
|
21
|
+
// One element: a static class lands on the host, so a view inside it
|
|
22
|
+
// would take it twice (issue #181).
|
|
23
|
+
expect(container.querySelectorAll('.my-2')).toHaveLength(1);
|
|
21
24
|
|
|
22
25
|
(componentRef.instance as SeparatorHost).orientation.set('vertical');
|
|
23
26
|
await waitForUpdate();
|
|
@@ -100,6 +100,24 @@ describe('UiSheet', () => {
|
|
|
100
100
|
expect(container.querySelector('.pt-4')?.classList).toContain('flex-row');
|
|
101
101
|
});
|
|
102
102
|
|
|
103
|
+
// Angular gives a component's host every static attribute written on it
|
|
104
|
+
// (issue #181), so each class also styled a host: the sheet's in the page,
|
|
105
|
+
// where it is written, and each part's around its own view or text.
|
|
106
|
+
it('puts each class on one element: the content, or the part', async () => {
|
|
107
|
+
const { container, getByText } = await renderSheet();
|
|
108
|
+
const content = container.querySelector('.px-6')!;
|
|
109
|
+
expect([...container.querySelectorAll('.gap-4')]).toEqual([content]);
|
|
110
|
+
// In the content: the bottom sheet draws views of its own with these.
|
|
111
|
+
expect(content.querySelectorAll('.items-center')).toHaveLength(1);
|
|
112
|
+
expect(content.querySelectorAll('.flex-row')).toHaveLength(1);
|
|
113
|
+
expect([...container.querySelectorAll('.text-xl')]).toEqual([
|
|
114
|
+
getByText('Title'),
|
|
115
|
+
]);
|
|
116
|
+
expect([...container.querySelectorAll('.italic')]).toEqual([
|
|
117
|
+
getByText('Body'),
|
|
118
|
+
]);
|
|
119
|
+
});
|
|
120
|
+
|
|
103
121
|
it('opens with `open`, and reports a dismissal through it and `closed`', async () => {
|
|
104
122
|
const { host, overlay, backdrop } = await renderSheet();
|
|
105
123
|
expect(overlay.getAttribute('visible')).toBe('false');
|
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
output,
|
|
8
8
|
viewChild,
|
|
9
9
|
} from '@angular/core';
|
|
10
|
-
import { LYNX_ELEMENTS } from '@blotch/angular-lynx';
|
|
10
|
+
import { LYNX_ELEMENTS, registerHostElement } from '@blotch/angular-lynx';
|
|
11
11
|
|
|
12
12
|
import {
|
|
13
13
|
type BottomSheetVariant,
|
|
@@ -32,17 +32,25 @@ const bottomSheetType = () => UiBottomSheet;
|
|
|
32
32
|
* variant and the detents, which pass straight through.
|
|
33
33
|
*
|
|
34
34
|
* The consumer's `class` and the built-in padding are applied to an inner
|
|
35
|
-
* content wrapper (not the panel) — the panel chrome
|
|
36
|
-
* background) comes from the primitive.
|
|
35
|
+
* content wrapper (not the panel, nor the sheet's host) — the panel chrome
|
|
36
|
+
* (rounded corners, border, background) comes from the primitive.
|
|
37
37
|
*
|
|
38
38
|
* Mark the control that presents it with `[uiSheetSource]="sheet"`, and the
|
|
39
39
|
* glass sheet grows out of it (see UiSheetSource).
|
|
40
40
|
*/
|
|
41
41
|
@Component({
|
|
42
42
|
selector: 'ui-sheet',
|
|
43
|
-
standalone: true,
|
|
44
43
|
imports: [LYNX_ELEMENTS, UiBottomSheet],
|
|
45
44
|
encapsulation: ViewEncapsulation.None,
|
|
45
|
+
// The sheet's class is its content's, inside the overlay. Angular also
|
|
46
|
+
// gives the host every static attribute the app writes on <ui-sheet>, and
|
|
47
|
+
// the host sits in the page's layout where the sheet is written, so a
|
|
48
|
+
// `class="px-8"` padded the page there too. The host drops it. The parts
|
|
49
|
+
// below are their host elements for the same reason: a class on a part
|
|
50
|
+
// lands on its host anyway, so a view or text inside it styled with the
|
|
51
|
+
// same class applied it twice. The title and description are text, so
|
|
52
|
+
// their hosts are `<text>` elements.
|
|
53
|
+
host: { '[attr.class]': 'null' },
|
|
46
54
|
// `open` is bound both ways by hand rather than with `[(open)]`: for a
|
|
47
55
|
// two-way binding Angular also compiles a fallback that assigns the field,
|
|
48
56
|
// which never runs for a signal and so shows as an uncovered branch in an
|
|
@@ -110,14 +118,9 @@ export class UiSheet implements SheetSourceTarget {
|
|
|
110
118
|
|
|
111
119
|
@Component({
|
|
112
120
|
selector: 'ui-sheet-header',
|
|
113
|
-
standalone: true,
|
|
114
|
-
imports: [LYNX_ELEMENTS],
|
|
115
121
|
encapsulation: ViewEncapsulation.None,
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
<ng-content />
|
|
119
|
-
</view>
|
|
120
|
-
`,
|
|
122
|
+
host: { '[class]': 'containerClass()' },
|
|
123
|
+
template: '<ng-content />',
|
|
121
124
|
})
|
|
122
125
|
export class UiSheetHeader {
|
|
123
126
|
readonly userClass = input<string>('', { alias: 'class' });
|
|
@@ -129,10 +132,9 @@ export class UiSheetHeader {
|
|
|
129
132
|
|
|
130
133
|
@Component({
|
|
131
134
|
selector: 'ui-sheet-title',
|
|
132
|
-
standalone: true,
|
|
133
|
-
imports: [LYNX_ELEMENTS],
|
|
134
135
|
encapsulation: ViewEncapsulation.None,
|
|
135
|
-
|
|
136
|
+
host: { '[class]': 'textClass()' },
|
|
137
|
+
template: '<ng-content />',
|
|
136
138
|
})
|
|
137
139
|
export class UiSheetTitle {
|
|
138
140
|
readonly userClass = input<string>('', { alias: 'class' });
|
|
@@ -142,12 +144,13 @@ export class UiSheetTitle {
|
|
|
142
144
|
);
|
|
143
145
|
}
|
|
144
146
|
|
|
147
|
+
registerHostElement(UiSheetTitle, 'text');
|
|
148
|
+
|
|
145
149
|
@Component({
|
|
146
150
|
selector: 'ui-sheet-description',
|
|
147
|
-
standalone: true,
|
|
148
|
-
imports: [LYNX_ELEMENTS],
|
|
149
151
|
encapsulation: ViewEncapsulation.None,
|
|
150
|
-
|
|
152
|
+
host: { '[class]': 'textClass()' },
|
|
153
|
+
template: '<ng-content />',
|
|
151
154
|
})
|
|
152
155
|
export class UiSheetDescription {
|
|
153
156
|
readonly userClass = input<string>('', { alias: 'class' });
|
|
@@ -157,16 +160,13 @@ export class UiSheetDescription {
|
|
|
157
160
|
);
|
|
158
161
|
}
|
|
159
162
|
|
|
163
|
+
registerHostElement(UiSheetDescription, 'text');
|
|
164
|
+
|
|
160
165
|
@Component({
|
|
161
166
|
selector: 'ui-sheet-footer',
|
|
162
|
-
standalone: true,
|
|
163
|
-
imports: [LYNX_ELEMENTS],
|
|
164
167
|
encapsulation: ViewEncapsulation.None,
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
<ng-content />
|
|
168
|
-
</view>
|
|
169
|
-
`,
|
|
168
|
+
host: { '[class]': 'containerClass()' },
|
|
169
|
+
template: '<ng-content />',
|
|
170
170
|
})
|
|
171
171
|
export class UiSheetFooter {
|
|
172
172
|
readonly userClass = input<string>('', { alias: 'class' });
|
|
@@ -18,6 +18,14 @@ describe('UiSkeleton', () => {
|
|
|
18
18
|
expect(skeleton?.classList).toContain('w-32');
|
|
19
19
|
});
|
|
20
20
|
|
|
21
|
+
// A class on a component lands on its host too (issue #181): the host is
|
|
22
|
+
// the placeholder, so its size is set once.
|
|
23
|
+
it('is one element, with the class written on it', async () => {
|
|
24
|
+
const { container } = await render(SkeletonHost);
|
|
25
|
+
expect(container.querySelectorAll('.h-4')).toHaveLength(1);
|
|
26
|
+
expect(container.querySelector('.h-4')?.classList).toContain('bg-muted');
|
|
27
|
+
});
|
|
28
|
+
|
|
21
29
|
it('pulses', async () => {
|
|
22
30
|
const animate = vi.spyOn(lynxMainThread(), '__ElementAnimate');
|
|
23
31
|
const { container } = await render(SkeletonHost);
|
|
@@ -1,28 +1,33 @@
|
|
|
1
1
|
import {
|
|
2
|
-
type ElementRef,
|
|
3
2
|
Component,
|
|
3
|
+
ElementRef,
|
|
4
4
|
ViewEncapsulation,
|
|
5
5
|
computed,
|
|
6
6
|
effect,
|
|
7
|
+
inject,
|
|
7
8
|
input,
|
|
8
|
-
viewChild,
|
|
9
9
|
} from '@angular/core';
|
|
10
|
-
import { LYNX_ELEMENTS } from '@blotch/angular-lynx';
|
|
11
10
|
|
|
12
11
|
import { pulse } from '../../utils/animate';
|
|
13
12
|
import { cn } from '../../utils/cn';
|
|
14
13
|
|
|
14
|
+
/**
|
|
15
|
+
* The skeleton is its host, styled through `host`. Angular gives a
|
|
16
|
+
* component's host every static attribute the app writes on it, so a class on
|
|
17
|
+
* <ui-skeleton> lands on the host whatever the component does: with a styled
|
|
18
|
+
* view inside as well, a margin applied twice, and the placeholder's size had
|
|
19
|
+
* to be set on both.
|
|
20
|
+
*/
|
|
15
21
|
@Component({
|
|
16
22
|
selector: 'ui-skeleton',
|
|
17
|
-
standalone: true,
|
|
18
|
-
imports: [LYNX_ELEMENTS],
|
|
19
23
|
encapsulation: ViewEncapsulation.None,
|
|
20
|
-
|
|
24
|
+
host: { '[class]': 'skeletonClass()' },
|
|
25
|
+
template: '',
|
|
21
26
|
})
|
|
22
27
|
export class UiSkeleton {
|
|
23
28
|
readonly userClass = input<string>('', { alias: 'class' });
|
|
24
29
|
|
|
25
|
-
readonly
|
|
30
|
+
readonly #host = inject(ElementRef).nativeElement;
|
|
26
31
|
|
|
27
32
|
/**
|
|
28
33
|
* Pulses the skeleton. A field rather than a constructor: Angular's test
|
|
@@ -30,7 +35,7 @@ export class UiSkeleton {
|
|
|
30
35
|
* app's coverage report would show as uncovered.
|
|
31
36
|
*/
|
|
32
37
|
protected readonly pulsing = effect(() => {
|
|
33
|
-
pulse(this
|
|
38
|
+
pulse(this.#host);
|
|
34
39
|
});
|
|
35
40
|
|
|
36
41
|
protected readonly skeletonClass = computed(() =>
|
|
@@ -141,10 +141,22 @@ describe('UiSlider', () => {
|
|
|
141
141
|
settings.reduceTransparency.set(false);
|
|
142
142
|
});
|
|
143
143
|
|
|
144
|
+
// Static attributes land on a component's host too (issue #181): the
|
|
145
|
+
// margin applies once, and only the slider itself is named Volume.
|
|
146
|
+
it('puts its class and accessibility label on one element each', async () => {
|
|
147
|
+
const { container, slider, getAllByAccessibilityLabel } =
|
|
148
|
+
await renderSlider();
|
|
149
|
+
expect([...container.querySelectorAll('.mt-2')]).toEqual([
|
|
150
|
+
slider.parentElement,
|
|
151
|
+
]);
|
|
152
|
+
expect(getAllByAccessibilityLabel('Volume')).toEqual([slider]);
|
|
153
|
+
});
|
|
154
|
+
|
|
144
155
|
it('shows its value on a track in the theme colors', async () => {
|
|
145
156
|
const { slider, track, fill, thumb } = await renderSlider();
|
|
146
157
|
|
|
147
|
-
expect(slider.classList).toContain('mt-2');
|
|
158
|
+
expect(slider.parentElement!.classList).toContain('mt-2');
|
|
159
|
+
expect(slider.classList).toContain('w-full');
|
|
148
160
|
expect(style(slider, 'height')).toBe('20px');
|
|
149
161
|
expect(track.classList).toContain('bg-secondary');
|
|
150
162
|
expect(fill.classList).toContain('bg-primary');
|
|
@@ -304,6 +304,19 @@ type Press = {
|
|
|
304
304
|
selector: 'ui-slider',
|
|
305
305
|
imports: [LYNX_ELEMENTS, UiGlass],
|
|
306
306
|
encapsulation: ViewEncapsulation.None,
|
|
307
|
+
// The host holds the slider, with the app's class. Angular gives a
|
|
308
|
+
// component's host every static attribute the app writes on it, so a class
|
|
309
|
+
// on <ui-slider> lands on the host whatever the component does: passed on
|
|
310
|
+
// to the root as well, a margin or padding applied twice. The root fills
|
|
311
|
+
// the host, and stays a view of its own: Lynx reads `consume-slide-event` as
|
|
312
|
+
// an array and `accessibility-element` as a boolean, which only a property
|
|
313
|
+
// binding on a Lynx element passes (a host binding would make them
|
|
314
|
+
// strings). Its accessibility-label is the root's, so the host drops its
|
|
315
|
+
// copy: two elements named the same would each be read.
|
|
316
|
+
host: {
|
|
317
|
+
'[class]': 'hostClass()',
|
|
318
|
+
'[attr.accessibility-label]': 'null',
|
|
319
|
+
},
|
|
307
320
|
// Only the root takes touches: everything inside it ignores them, so a
|
|
308
321
|
// touch's `x` on iOS and Android is relative to the slider, wherever the
|
|
309
322
|
// finger lands (Lynx gives `x` relative to the element touched).
|
|
@@ -497,9 +510,9 @@ export class UiSlider implements FormValueControl<number> {
|
|
|
497
510
|
: `${Math.round(this.#fraction() * 100)}%`;
|
|
498
511
|
});
|
|
499
512
|
|
|
500
|
-
protected readonly
|
|
501
|
-
|
|
502
|
-
);
|
|
513
|
+
protected readonly hostClass = computed(() => cn(this.userClass()));
|
|
514
|
+
|
|
515
|
+
protected readonly rootClass = computed(() => cn('w-full relative'));
|
|
503
516
|
|
|
504
517
|
protected readonly rootStyle = computed(() => ({
|
|
505
518
|
height: px(this.#geometry().height),
|
|
@@ -17,12 +17,18 @@ class SpinnerHost {
|
|
|
17
17
|
readonly className = signal('');
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
@Component({
|
|
21
|
+
imports: [UiSpinner],
|
|
22
|
+
template: `<ui-spinner class="mx-2" />`,
|
|
23
|
+
})
|
|
24
|
+
class StaticClassHost {}
|
|
25
|
+
|
|
20
26
|
const renderSpinner = async () => {
|
|
21
27
|
const result = await render(SpinnerHost);
|
|
22
28
|
return {
|
|
23
29
|
...result,
|
|
24
30
|
host: result.componentRef.instance as SpinnerHost,
|
|
25
|
-
// The spinner
|
|
31
|
+
// The spinner: its host, which holds the svg.
|
|
26
32
|
spinner: () => result.container.querySelector('svg')!.parentElement!,
|
|
27
33
|
svgContent: () =>
|
|
28
34
|
result.container.querySelector('svg')!.getAttribute('content'),
|
|
@@ -75,6 +81,15 @@ describe('UiSpinner', () => {
|
|
|
75
81
|
expect(spinner().getAttribute('class')).toBe('ml-2');
|
|
76
82
|
});
|
|
77
83
|
|
|
84
|
+
// A class on a component lands on its host too (issue #181): the host is
|
|
85
|
+
// the spinner, so the margin applies once.
|
|
86
|
+
it('puts a static class on one element, the spinner', async () => {
|
|
87
|
+
const { container } = await render(StaticClassHost);
|
|
88
|
+
expect([...container.querySelectorAll('.mx-2')]).toEqual([
|
|
89
|
+
container.querySelector('svg')!.parentElement,
|
|
90
|
+
]);
|
|
91
|
+
});
|
|
92
|
+
|
|
78
93
|
it('turns continuously', async () => {
|
|
79
94
|
const animate = vi.spyOn(lynxMainThread(), '__ElementAnimate');
|
|
80
95
|
const { spinner } = await renderSpinner();
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import {
|
|
2
|
-
type ElementRef,
|
|
3
2
|
Component,
|
|
3
|
+
ElementRef,
|
|
4
4
|
ViewEncapsulation,
|
|
5
5
|
computed,
|
|
6
6
|
effect,
|
|
7
|
+
inject,
|
|
7
8
|
input,
|
|
8
|
-
viewChild,
|
|
9
9
|
} from '@angular/core';
|
|
10
10
|
import { LYNX_ELEMENTS } from '@blotch/angular-lynx';
|
|
11
11
|
|
|
@@ -20,15 +20,23 @@ const SIZE_MAP = { xs: 16, sm: 20, md: 24, lg: 32 } as const;
|
|
|
20
20
|
selector: 'ui-spinner',
|
|
21
21
|
imports: [LYNX_ELEMENTS],
|
|
22
22
|
encapsulation: ViewEncapsulation.None,
|
|
23
|
+
// The host is the box that turns, sized and styled through `host`, with the
|
|
24
|
+
// drawing inside. Angular gives a component's host every static attribute
|
|
25
|
+
// the app writes on it, so a class on <ui-spinner> lands on the host
|
|
26
|
+
// whatever the component does: with a styled box inside as well, a margin
|
|
27
|
+
// applied twice.
|
|
28
|
+
//
|
|
23
29
|
// SVG markup is passed via [attr.content] rather than as child elements.
|
|
24
30
|
// Lynx's native <svg> element can't contain child nodes in the flat element
|
|
25
31
|
// model — the renderer doesn't support nested SVG elements. Passing the full
|
|
26
32
|
// SVG markup string as the `content` attribute is the Lynx-native way to
|
|
27
33
|
// embed vector graphics.
|
|
34
|
+
host: {
|
|
35
|
+
'[class]': 'containerClass()',
|
|
36
|
+
'[style]': 'sizeStyle()',
|
|
37
|
+
},
|
|
28
38
|
template: `
|
|
29
|
-
<
|
|
30
|
-
<svg [attr.content]="svgContent()" style="width: 100%; height: 100%;" />
|
|
31
|
-
</view>
|
|
39
|
+
<svg [attr.content]="svgContent()" style="width: 100%; height: 100%;" />
|
|
32
40
|
`,
|
|
33
41
|
})
|
|
34
42
|
export class UiSpinner {
|
|
@@ -43,7 +51,7 @@ export class UiSpinner {
|
|
|
43
51
|
|
|
44
52
|
readonly #resolveColor = injectIconColor();
|
|
45
53
|
|
|
46
|
-
readonly
|
|
54
|
+
readonly #host = inject(ElementRef).nativeElement;
|
|
47
55
|
|
|
48
56
|
/**
|
|
49
57
|
* Turns the spinner for as long as it's shown. A field rather than a
|
|
@@ -51,7 +59,7 @@ export class UiSpinner {
|
|
|
51
59
|
* nothing calls, which an app's coverage report would show as uncovered.
|
|
52
60
|
*/
|
|
53
61
|
protected readonly rotation = effect(() => {
|
|
54
|
-
this.
|
|
62
|
+
this.#host.animate(
|
|
55
63
|
[{ transform: 'rotate(0deg)' }, { transform: 'rotate(360deg)' }],
|
|
56
64
|
{
|
|
57
65
|
duration: 1600,
|
|
@@ -119,10 +119,21 @@ describe('UiSwitch', () => {
|
|
|
119
119
|
settings.reduceTransparency.set(false);
|
|
120
120
|
});
|
|
121
121
|
|
|
122
|
-
|
|
122
|
+
// Static attributes land on a component's host too (issue #181): the
|
|
123
|
+
// margin applies once, and only the switch itself is named Wi-Fi.
|
|
124
|
+
it('puts its class and accessibility label on one element each', async () => {
|
|
125
|
+
const { container, track, getAllByAccessibilityLabel } =
|
|
126
|
+
await renderSwitch();
|
|
127
|
+
expect([...container.querySelectorAll('.ml-2')]).toEqual([
|
|
128
|
+
track().parentElement,
|
|
129
|
+
]);
|
|
130
|
+
expect(getAllByAccessibilityLabel('Wi-Fi')).toEqual([track()]);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('renders off, with the thumb at the left, in a host with the given class', async () => {
|
|
123
134
|
const { track, fill, thumb } = await renderSwitch();
|
|
124
135
|
expect(fill().classList).toContain('bg-input');
|
|
125
|
-
expect(track().classList).toContain('ml-2');
|
|
136
|
+
expect(track().parentElement!.classList).toContain('ml-2');
|
|
126
137
|
expect(style(track(), 'width')).toBe('44px');
|
|
127
138
|
expect(thumb().classList).toContain('bg-background');
|
|
128
139
|
// Centered 12px in: 2px from the edge for a 20px thumb.
|
|
@@ -264,6 +264,19 @@ type Press = {
|
|
|
264
264
|
selector: 'ui-switch',
|
|
265
265
|
imports: [LYNX_ELEMENTS, UiGlass],
|
|
266
266
|
encapsulation: ViewEncapsulation.None,
|
|
267
|
+
// The host holds the switch, with the app's class. Angular gives a
|
|
268
|
+
// component's host every static attribute the app writes on it, so a class
|
|
269
|
+
// on <ui-switch> lands on the host whatever the component does: passed on
|
|
270
|
+
// to the root as well, a margin applied twice. The root stays a view of its
|
|
271
|
+
// own: Lynx reads `consume-slide-event` as an array and
|
|
272
|
+
// `accessibility-element` as a boolean, which only a property binding on a
|
|
273
|
+
// Lynx element passes (a host binding would make them strings). Its
|
|
274
|
+
// accessibility-label is the root's, so the host drops its copy: two
|
|
275
|
+
// elements named the same would each be read.
|
|
276
|
+
host: {
|
|
277
|
+
'[class]': 'hostClass()',
|
|
278
|
+
'[attr.accessibility-label]': 'null',
|
|
279
|
+
},
|
|
267
280
|
// Only the root takes touches: what's inside ignores them, so every touch
|
|
268
281
|
// and its `x` belong to the switch.
|
|
269
282
|
//
|
|
@@ -406,12 +419,13 @@ export class UiSwitch implements FormCheckboxControl {
|
|
|
406
419
|
() => this.#systemDrawsGlass || !this.#settings.reduceTransparency(),
|
|
407
420
|
);
|
|
408
421
|
|
|
422
|
+
protected readonly hostClass = computed(() => cn(this.userClass()));
|
|
423
|
+
|
|
409
424
|
protected readonly rootClass = computed(() =>
|
|
410
425
|
cn(
|
|
411
426
|
'relative',
|
|
412
427
|
this.disabled() &&
|
|
413
428
|
(this.look() === 'material' ? 'opacity-[0.38]' : 'opacity-50'),
|
|
414
|
-
this.userClass(),
|
|
415
429
|
),
|
|
416
430
|
);
|
|
417
431
|
|
|
@@ -71,6 +71,16 @@ class TabBarHost {
|
|
|
71
71
|
readonly items = viewChildren(UiTabBarItem);
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
+
@Component({
|
|
75
|
+
imports: [UiTabBar, UiTabBarItem],
|
|
76
|
+
template: `
|
|
77
|
+
<ui-tab-bar class="mt-1">
|
|
78
|
+
<ui-tab-bar-item route="/" icon="home" label="Home" class="pt-4" />
|
|
79
|
+
</ui-tab-bar>
|
|
80
|
+
`,
|
|
81
|
+
})
|
|
82
|
+
class StaticClassHost {}
|
|
83
|
+
|
|
74
84
|
/** The theme's light colors, as icon-theme.ts resolves them for an SVG. */
|
|
75
85
|
const PRIMARY = 'rgba(24, 24, 27, 1)';
|
|
76
86
|
const MUTED_FOREGROUND = 'rgba(113, 113, 122, 1)';
|
|
@@ -132,7 +142,9 @@ describe('UiTabBar', () => {
|
|
|
132
142
|
|
|
133
143
|
it('lays the bar out as a row above the bottom safe area', async () => {
|
|
134
144
|
const { container, componentRef } = await render(UiTabBar);
|
|
135
|
-
|
|
145
|
+
// Rendered on its own, the bar's host is the root the test renders into.
|
|
146
|
+
const bar = container;
|
|
147
|
+
expect(bar.getAttribute('accessibility-traits')).toBe('tabbar');
|
|
136
148
|
expect(bar.classList).toContain('flex-row');
|
|
137
149
|
expect(bar.classList).toContain('pb-safe');
|
|
138
150
|
expect(bar.classList).toContain('border-t');
|
|
@@ -267,14 +279,28 @@ describe('UiTabBar', () => {
|
|
|
267
279
|
expect(scale()).toBe('scale(1)');
|
|
268
280
|
});
|
|
269
281
|
|
|
270
|
-
it("merges an item's class
|
|
282
|
+
it("merges an item's class with its own, the given class winning", async () => {
|
|
271
283
|
const { host, tab } = await renderTabBar();
|
|
272
|
-
|
|
273
|
-
expect(content().classList).toContain('pt-2');
|
|
284
|
+
expect(tab('Inbox').classList).toContain('pt-2');
|
|
274
285
|
|
|
275
286
|
host.inboxClass.set('pt-3');
|
|
276
287
|
await waitForUpdate();
|
|
277
|
-
expect(
|
|
278
|
-
expect(
|
|
288
|
+
expect(tab('Inbox').classList).toContain('pt-3');
|
|
289
|
+
expect(tab('Inbox').classList).not.toContain('pt-2');
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
// Angular gives a component's host every static attribute written on it,
|
|
293
|
+
// so a bar or item that also styled a view inside its host applied the
|
|
294
|
+
// class twice (issue #181).
|
|
295
|
+
it('puts a class written on the bar or an item on that one element', async () => {
|
|
296
|
+
const { container } = await render(StaticClassHost, {
|
|
297
|
+
providers: [provideRouter(routes)],
|
|
298
|
+
});
|
|
299
|
+
expect([...container.querySelectorAll('.mt-1')]).toEqual([
|
|
300
|
+
container.querySelector('[accessibility-traits="tabbar"]'),
|
|
301
|
+
]);
|
|
302
|
+
expect([...container.querySelectorAll('.pt-4')]).toEqual([
|
|
303
|
+
container.querySelector('[accessibility-label="Home"]'),
|
|
304
|
+
]);
|
|
279
305
|
});
|
|
280
306
|
});
|