@blotch/dolan 22.2.5 → 22.2.7
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 +29 -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 +17 -0
- package/dist/ui/components/icon/icon.ts +19 -17
- 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
|
@@ -16,16 +16,18 @@ import { LYNX_ELEMENTS } from '@blotch/angular-lynx';
|
|
|
16
16
|
import { type AnimationHandle, popIn, popOut } from '../../utils/animate';
|
|
17
17
|
import { cn } from '../../utils/cn';
|
|
18
18
|
|
|
19
|
+
/**
|
|
20
|
+
* The group and each item are their host elements, styled through `host`.
|
|
21
|
+
* Angular gives a component's host every static attribute the app writes on
|
|
22
|
+
* it, so a class on <ui-radio-group> or <ui-radio-group-item> lands on the
|
|
23
|
+
* host whatever the component does: with a styled view inside as well, a gap
|
|
24
|
+
* or padding applied twice.
|
|
25
|
+
*/
|
|
19
26
|
@Component({
|
|
20
27
|
selector: 'ui-radio-group',
|
|
21
|
-
standalone: true,
|
|
22
|
-
imports: [LYNX_ELEMENTS],
|
|
23
28
|
encapsulation: ViewEncapsulation.None,
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
<ng-content />
|
|
27
|
-
</view>
|
|
28
|
-
`,
|
|
29
|
+
host: { '[class]': 'containerClass()' },
|
|
30
|
+
template: '<ng-content />',
|
|
29
31
|
})
|
|
30
32
|
export class UiRadioGroup {
|
|
31
33
|
readonly value = model<string>('');
|
|
@@ -47,22 +49,23 @@ export class UiRadioGroup {
|
|
|
47
49
|
|
|
48
50
|
@Component({
|
|
49
51
|
selector: 'ui-radio-group-item',
|
|
50
|
-
standalone: true,
|
|
51
52
|
imports: [LYNX_ELEMENTS],
|
|
52
53
|
encapsulation: ViewEncapsulation.None,
|
|
54
|
+
host: {
|
|
55
|
+
'[class]': 'itemClass()',
|
|
56
|
+
'(bindtap)': 'select()',
|
|
57
|
+
},
|
|
53
58
|
template: `
|
|
54
|
-
<view [class]="
|
|
55
|
-
<view
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
/>
|
|
63
|
-
</view>
|
|
64
|
-
<text [class]="labelClass()"><ng-content /></text>
|
|
59
|
+
<view [class]="circleClass()">
|
|
60
|
+
<view
|
|
61
|
+
#dot
|
|
62
|
+
[class]="dotClass()"
|
|
63
|
+
[style]="
|
|
64
|
+
isSelected() ? 'opacity: 1;' : 'opacity: 0; transform: scale(0.6);'
|
|
65
|
+
"
|
|
66
|
+
/>
|
|
65
67
|
</view>
|
|
68
|
+
<text [class]="labelClass()"><ng-content /></text>
|
|
66
69
|
`,
|
|
67
70
|
})
|
|
68
71
|
export class UiRadioGroupItem {
|
|
@@ -42,6 +42,8 @@ const renderScrollArea = async () => {
|
|
|
42
42
|
...result,
|
|
43
43
|
host: result.componentRef.instance as ScrollAreaHost,
|
|
44
44
|
scrollView: () => result.container.querySelector('scroll-view')!,
|
|
45
|
+
/** The scroll area's box, its host, which the scroll view fills. */
|
|
46
|
+
box: () => result.container.querySelector('scroll-view')!.parentElement!,
|
|
45
47
|
/** The view inside the scroll view that lays out the content. */
|
|
46
48
|
content: () => result.getByText('Body').parentElement!,
|
|
47
49
|
};
|
|
@@ -61,13 +63,23 @@ const watchAttributes = () => {
|
|
|
61
63
|
|
|
62
64
|
describe('UiScrollArea', () => {
|
|
63
65
|
it('renders projected content in a vertical scroll view', async () => {
|
|
64
|
-
const { scrollView, content } = await renderScrollArea();
|
|
66
|
+
const { scrollView, box, content } = await renderScrollArea();
|
|
65
67
|
expect(scrollView().getAttribute('scroll-orientation')).toBe('vertical');
|
|
66
|
-
expect(scrollView().classList).toContain('
|
|
68
|
+
expect(scrollView().classList).toContain('grow');
|
|
69
|
+
expect(box().classList).toContain('w-full');
|
|
70
|
+
expect(box().classList).toContain('bg-card');
|
|
67
71
|
expect(content().classList).toContain('flex-col');
|
|
68
72
|
expect(content().classList).toContain('gap-2');
|
|
69
73
|
});
|
|
70
74
|
|
|
75
|
+
// Angular gives a component's host every static attribute written on it,
|
|
76
|
+
// so the scroll view taking the class as well applied it twice (issue
|
|
77
|
+
// #181).
|
|
78
|
+
it('puts its class on one element, its box', async () => {
|
|
79
|
+
const { container, box } = await renderScrollArea();
|
|
80
|
+
expect([...container.querySelectorAll('.bg-card')]).toEqual([box()]);
|
|
81
|
+
});
|
|
82
|
+
|
|
71
83
|
it('gives the scroll view an explicit height, which it needs to scroll', async () => {
|
|
72
84
|
const { host, scrollView } = await renderScrollArea();
|
|
73
85
|
expect(scrollView().getAttribute('style') ?? '').not.toContain('height');
|
|
@@ -19,6 +19,18 @@ import { cn } from '../../utils/cn';
|
|
|
19
19
|
// removed the attribute when false, and a missing attribute means Lynx's
|
|
20
20
|
// default: on, for `bounces` and `scroll-bar-enable`. So neither could be
|
|
21
21
|
// turned off. (`[attr.x]` would send the string "false" instead.)
|
|
22
|
+
//
|
|
23
|
+
// The host is the scroll area's box, and the <scroll-view> fills it.
|
|
24
|
+
// Angular gives a component's host every static attribute the app writes
|
|
25
|
+
// on it, so a class on <ui-scroll-area> lands on the host whatever the
|
|
26
|
+
// component does with its `class` input: on the <scroll-view> as well, a
|
|
27
|
+
// margin or padding applied twice. Padding the box insets the scroll view;
|
|
28
|
+
// `contentClass` pads what scrolls. `grow` fills a box with a height of its
|
|
29
|
+
// own (`h-64`, `flex-1`), and a box without one takes the scroll view's,
|
|
30
|
+
// which `height` sets: the scroll view keeps its own height as its flex
|
|
31
|
+
// basis. Not `h-full`: a percentage height doesn't reliably resolve on a
|
|
32
|
+
// flex item in Lynx.
|
|
33
|
+
host: { '[class]': 'hostClass()' },
|
|
22
34
|
template: `
|
|
23
35
|
<scroll-view
|
|
24
36
|
[attr.scroll-orientation]="orientation()"
|
|
@@ -27,7 +39,7 @@ import { cn } from '../../utils/cn';
|
|
|
27
39
|
[enable-nested-scroll]="nestedScroll()"
|
|
28
40
|
[attr.upper-threshold]="upperThreshold()"
|
|
29
41
|
[attr.lower-threshold]="lowerThreshold()"
|
|
30
|
-
|
|
42
|
+
class="grow"
|
|
31
43
|
[style]="scrollStyle()"
|
|
32
44
|
(bindscrolltoupper)="scrolledToStart.emit()"
|
|
33
45
|
(bindscrolltolower)="scrolledToEnd.emit()"
|
|
@@ -55,8 +67,8 @@ export class UiScrollArea {
|
|
|
55
67
|
readonly scrolledToStart = output<void>();
|
|
56
68
|
readonly scrolledToEnd = output<void>();
|
|
57
69
|
|
|
58
|
-
protected readonly
|
|
59
|
-
cn('w-full', this.userClass()),
|
|
70
|
+
protected readonly hostClass = computed(() =>
|
|
71
|
+
cn('w-full flex-col flex', this.userClass()),
|
|
60
72
|
);
|
|
61
73
|
|
|
62
74
|
// Explicit height is required for scroll-view to scroll in Lynx
|
|
@@ -41,6 +41,12 @@ class FieldHost {
|
|
|
41
41
|
readonly field = viewChild.required(UiSearchField);
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
@Component({
|
|
45
|
+
imports: [UiSearchField],
|
|
46
|
+
template: `<ui-search-field placeholder="Songs and artists" class="mx-4" />`,
|
|
47
|
+
})
|
|
48
|
+
class StaticAttributesHost {}
|
|
49
|
+
|
|
44
50
|
/** A bar that holds the field in a shape of its own, as the dock does. */
|
|
45
51
|
const container = {
|
|
46
52
|
searching: signal(false),
|
|
@@ -203,6 +209,25 @@ afterEach(() => {
|
|
|
203
209
|
Object.assign(screen, { fieldBottom: 820, pageBottom: 874, fails: false });
|
|
204
210
|
});
|
|
205
211
|
|
|
212
|
+
describe('UiSearchField’s static attributes', () => {
|
|
213
|
+
// Angular gives a component's host every static attribute written on it
|
|
214
|
+
// (issue #181): the placeholder made a second match for
|
|
215
|
+
// getByPlaceholderText.
|
|
216
|
+
it('puts its class on one element, and its placeholder only on its input', async () => {
|
|
217
|
+
vi.stubGlobal('SystemInfo', IOS_26);
|
|
218
|
+
const { container, getAllByPlaceholderText } = await render(
|
|
219
|
+
StaticAttributesHost,
|
|
220
|
+
{
|
|
221
|
+
providers: [{ provide: LynxAccessibilitySettings, useValue: settings }],
|
|
222
|
+
},
|
|
223
|
+
);
|
|
224
|
+
expect(container.querySelectorAll('.mx-4')).toHaveLength(1);
|
|
225
|
+
expect(getAllByPlaceholderText('Songs and artists')).toEqual([
|
|
226
|
+
container.querySelector('input'),
|
|
227
|
+
]);
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
|
|
206
231
|
describe('UiSearchField on iOS 26 and later', () => {
|
|
207
232
|
it('is a glass capsule with a magnifier and a search box that searches on return', async () => {
|
|
208
233
|
const { getByRole, field, fieldHost, glass, icons } =
|
|
@@ -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();
|