@blotch/dolan 22.2.6 → 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
|
@@ -295,8 +295,10 @@ describe('UiCartesianPreset', () => {
|
|
|
295
295
|
const { container, componentRef, getByText } = await render(PresetHost);
|
|
296
296
|
const root = chartRoot(container);
|
|
297
297
|
expect(root.getAttribute('accessibility-label')).toBe('Sales');
|
|
298
|
-
|
|
299
|
-
expect(root.getAttribute('style')).toContain(
|
|
298
|
+
// The chart's box, which the root view fills.
|
|
299
|
+
expect(root.parentElement!.getAttribute('style')).toContain(
|
|
300
|
+
'height: 160px',
|
|
301
|
+
);
|
|
300
302
|
expect(getByText('Units')).toBeTruthy();
|
|
301
303
|
expect(getByText('Day')).toBeTruthy();
|
|
302
304
|
|
|
@@ -305,6 +307,18 @@ describe('UiCartesianPreset', () => {
|
|
|
305
307
|
expect(getByText('Week')).toBeTruthy();
|
|
306
308
|
});
|
|
307
309
|
|
|
310
|
+
// Angular gives a component's host every static attribute written on it,
|
|
311
|
+
// so a preset that passed its class on to its chart as well applied it
|
|
312
|
+
// twice (issue #181).
|
|
313
|
+
it("puts its class on one element, the preset's own box", async () => {
|
|
314
|
+
const { container } = await render(PresetHost);
|
|
315
|
+
const box = chartRoot(container).parentElement!;
|
|
316
|
+
expect([...container.querySelectorAll('.mt-2')]).toEqual([
|
|
317
|
+
box.parentElement,
|
|
318
|
+
]);
|
|
319
|
+
expect(box.parentElement!.classList).toContain('w-full');
|
|
320
|
+
});
|
|
321
|
+
|
|
308
322
|
it('hands a tap over as the datum, or null off the points', async () => {
|
|
309
323
|
const { container, componentRef } = await render(PresetHost);
|
|
310
324
|
const host = componentRef.instance as PresetHost;
|
|
@@ -1,10 +1,17 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
Directive,
|
|
3
|
+
booleanAttribute,
|
|
4
|
+
computed,
|
|
5
|
+
input,
|
|
6
|
+
viewChild,
|
|
7
|
+
} from '@angular/core';
|
|
2
8
|
import { scaleLinear } from '@tanstack/charts/scales/linear';
|
|
3
9
|
import type {
|
|
4
10
|
ChartPoint,
|
|
5
11
|
ChartPositionScaleOptions,
|
|
6
12
|
} from '@tanstack/charts/types';
|
|
7
13
|
|
|
14
|
+
import { cn } from '../../utils/cn';
|
|
8
15
|
import { UiChart } from './chart';
|
|
9
16
|
import type { ChartColorSchemeOption } from './chart-theme';
|
|
10
17
|
import type { ChartZoomAxes } from './chart-zoom';
|
|
@@ -184,8 +191,16 @@ const chartType = () => UiChart;
|
|
|
184
191
|
* The inputs and zoom API every cartesian preset shares. A preset extends this
|
|
185
192
|
* and adds its own data and mark inputs, and renders `<ui-chart #chart>`
|
|
186
193
|
* with the shared bindings.
|
|
194
|
+
*
|
|
195
|
+
* A preset's host is the chart's box in the page, and takes the app's
|
|
196
|
+
* classes; the chart inside fills its width, at the preset's `height`.
|
|
197
|
+
* Angular gives a component's host every static attribute the app writes on
|
|
198
|
+
* it, so a class on `<ui-line-chart>` lands on the preset's host whatever the
|
|
199
|
+
* preset does with its `class` input: passed on to the chart as well, a
|
|
200
|
+
* margin applied twice, and a width class sized the chart a second time
|
|
201
|
+
* inside the first.
|
|
187
202
|
*/
|
|
188
|
-
@Directive()
|
|
203
|
+
@Directive({ host: { '[class]': 'hostClass()' } })
|
|
189
204
|
export abstract class UiCartesianPreset<TDatum> {
|
|
190
205
|
/** Fixed width in px. Omit to fill the container. */
|
|
191
206
|
readonly width = input<number>();
|
|
@@ -221,6 +236,11 @@ export abstract class UiCartesianPreset<TDatum> {
|
|
|
221
236
|
|
|
222
237
|
protected readonly chart = viewChild.required(chartType());
|
|
223
238
|
|
|
239
|
+
// Like the chart's box: as wide as its parent, and not squeezed by a column.
|
|
240
|
+
protected readonly hostClass = computed(() =>
|
|
241
|
+
cn('w-full flex-shrink-0', this.userClass()),
|
|
242
|
+
);
|
|
243
|
+
|
|
224
244
|
/**
|
|
225
245
|
* The shared inputs, as the options every preset's definition takes.
|
|
226
246
|
*/
|
|
@@ -268,7 +288,6 @@ export const PRESET_CHART_BINDINGS = `
|
|
|
268
288
|
[height]="height()"
|
|
269
289
|
[label]="label()"
|
|
270
290
|
[colorScheme]="colorScheme()"
|
|
271
|
-
[class]="userClass()"
|
|
272
291
|
[zoomable]="zoomable()"
|
|
273
292
|
[zoomAxes]="zoomAxes()"
|
|
274
293
|
[showZoomControls]="showZoomControls()"
|
|
@@ -103,6 +103,14 @@ class ChartHost {
|
|
|
103
103
|
readonly chart = viewChild.required(UiChart);
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
+
@Component({
|
|
107
|
+
imports: [UiChart],
|
|
108
|
+
template: `<ui-chart [definition]="definition" class="mt-2" />`,
|
|
109
|
+
})
|
|
110
|
+
class StaticClassHost {
|
|
111
|
+
readonly definition = barChart();
|
|
112
|
+
}
|
|
113
|
+
|
|
106
114
|
/**
|
|
107
115
|
* Renders the host 300px wide, after `configure` sets its inputs. Without a
|
|
108
116
|
* width the chart waits for its layout (see "measures its layout").
|
|
@@ -125,6 +133,12 @@ const renderChart = async (
|
|
|
125
133
|
const chartRoot = (container: Element) =>
|
|
126
134
|
container.querySelector('[accessibility-label]')!;
|
|
127
135
|
|
|
136
|
+
/**
|
|
137
|
+
* The chart's box, its host, which its classes and size style and the root
|
|
138
|
+
* view fills.
|
|
139
|
+
*/
|
|
140
|
+
const chartBox = (container: Element) => chartRoot(container).parentElement!;
|
|
141
|
+
|
|
128
142
|
/**
|
|
129
143
|
* The SVG each layer draws: under the marks, the marks, over the marks.
|
|
130
144
|
*/
|
|
@@ -206,6 +220,17 @@ const tap = async (root: Element, at: { x: number; y: number }) => {
|
|
|
206
220
|
};
|
|
207
221
|
|
|
208
222
|
describe('UiChart', () => {
|
|
223
|
+
// Angular gives a component's host every static attribute written on it,
|
|
224
|
+
// so the root view taking the class as well applied it twice (issue
|
|
225
|
+
// #181).
|
|
226
|
+
it('puts its class on one element, its box', async () => {
|
|
227
|
+
const { container } = await render(StaticClassHost);
|
|
228
|
+
expect([...container.querySelectorAll('.mt-2')]).toEqual([
|
|
229
|
+
chartBox(container),
|
|
230
|
+
]);
|
|
231
|
+
expect(chartBox(container).classList).toContain('h-[200px]');
|
|
232
|
+
});
|
|
233
|
+
|
|
209
234
|
describe('rendering', () => {
|
|
210
235
|
it('draws nothing until it has a size, and ignores input until then', async () => {
|
|
211
236
|
const { container, componentRef } = await render(ChartHost);
|
|
@@ -234,9 +259,7 @@ describe('UiChart', () => {
|
|
|
234
259
|
});
|
|
235
260
|
|
|
236
261
|
it('draws marks, empty focus layers and native labels', async () => {
|
|
237
|
-
const { container
|
|
238
|
-
host.height.set(200),
|
|
239
|
-
);
|
|
262
|
+
const { container } = await renderChart((host) => host.height.set(200));
|
|
240
263
|
const [under, marks, over] = layers(container);
|
|
241
264
|
expect(layers(container)).toHaveLength(3);
|
|
242
265
|
expect(marks).toContain('<rect');
|
|
@@ -246,8 +269,12 @@ describe('UiChart', () => {
|
|
|
246
269
|
expect(labelTexts(container)).toEqual(
|
|
247
270
|
expect.arrayContaining(['Jan', 'Feb', 'Mar', 'Revenue']),
|
|
248
271
|
);
|
|
249
|
-
expect(
|
|
250
|
-
|
|
272
|
+
expect(chartBox(container).getAttribute('style')).toContain(
|
|
273
|
+
'width: 300px',
|
|
274
|
+
);
|
|
275
|
+
expect(chartBox(container).getAttribute('style')).toContain(
|
|
276
|
+
'height: 200px',
|
|
277
|
+
);
|
|
251
278
|
const layerStyle = container.querySelector('svg')!.getAttribute('style');
|
|
252
279
|
expect(layerStyle).toContain('width: 300px');
|
|
253
280
|
expect(layerStyle).toContain('height: 200px');
|
|
@@ -375,7 +402,9 @@ describe('UiChart', () => {
|
|
|
375
402
|
const root = chartRoot(container);
|
|
376
403
|
const layerStyle = () =>
|
|
377
404
|
container.querySelector('svg')?.getAttribute('style');
|
|
378
|
-
expect(
|
|
405
|
+
expect(chartBox(container).getAttribute('style') ?? '').not.toContain(
|
|
406
|
+
'width',
|
|
407
|
+
);
|
|
379
408
|
|
|
380
409
|
fireEvent.layoutchange(root, { detail: { width: 250, height: 120 } });
|
|
381
410
|
await waitForUpdate();
|
|
@@ -211,10 +211,21 @@ type TouchPoint = {
|
|
|
211
211
|
selector: 'ui-chart',
|
|
212
212
|
imports: [LYNX_ELEMENTS, LynxGestureDetector, NgTemplateOutlet],
|
|
213
213
|
encapsulation: ViewEncapsulation.None,
|
|
214
|
+
// The host is the chart's box, sized by its classes and `width`/`height`,
|
|
215
|
+
// and the root view fills it. Angular gives a component's host every
|
|
216
|
+
// static attribute the app writes on it, so a class on <ui-chart> lands on
|
|
217
|
+
// the host whatever the component does with its `class` input: on the root
|
|
218
|
+
// view as well, a margin or padding applied twice. The root stays a view of
|
|
219
|
+
// its own for the pan gesture and the accessibility element, which bind to
|
|
220
|
+
// it. `flex-1`, not `h-full`: a percentage height doesn't reliably resolve
|
|
221
|
+
// on a flex item in Lynx.
|
|
222
|
+
host: {
|
|
223
|
+
'[class]': 'rootClass()',
|
|
224
|
+
'[style]': 'rootStyle()',
|
|
225
|
+
},
|
|
214
226
|
template: `
|
|
215
227
|
<view
|
|
216
|
-
|
|
217
|
-
[style]="rootStyle()"
|
|
228
|
+
class="flex-1 relative"
|
|
218
229
|
[lynxGesture]="zoomable() ? panGesture : noGesture"
|
|
219
230
|
[accessibility-element]="true"
|
|
220
231
|
[accessibility-label]="label()"
|
|
@@ -385,7 +396,7 @@ export class UiChart {
|
|
|
385
396
|
#touchStart: TouchPoint | null = null;
|
|
386
397
|
|
|
387
398
|
protected readonly rootClass = computed(() =>
|
|
388
|
-
cn('h-[200px] w-full flex-shrink-0
|
|
399
|
+
cn('h-[200px] w-full flex-shrink-0 flex-col flex', this.userClass()),
|
|
389
400
|
);
|
|
390
401
|
|
|
391
402
|
protected readonly rootStyle = computed(() => {
|
|
@@ -41,8 +41,8 @@ const renderCheckbox = async () => {
|
|
|
41
41
|
return {
|
|
42
42
|
...result,
|
|
43
43
|
host: result.componentRef.instance as CheckboxHost,
|
|
44
|
-
/** The 44pt tap target around the box. */
|
|
45
|
-
target: () => result.container.
|
|
44
|
+
/** The 44pt tap target around the box: the checkbox's host. */
|
|
45
|
+
target: () => result.container.firstElementChild!,
|
|
46
46
|
box: () => result.container.querySelector('.rounded-sm')!,
|
|
47
47
|
checkmark: () => result.getByText('✓'),
|
|
48
48
|
};
|
|
@@ -61,13 +61,20 @@ describe('UiCheckbox', () => {
|
|
|
61
61
|
});
|
|
62
62
|
afterEach(() => animate.mockRestore());
|
|
63
63
|
|
|
64
|
-
it('renders an empty box
|
|
64
|
+
it('renders an empty box', async () => {
|
|
65
65
|
const { box, checkmark } = await renderCheckbox();
|
|
66
66
|
expect(box().classList).toContain('bg-transparent');
|
|
67
|
-
expect(box().classList).toContain('mr-2');
|
|
68
67
|
expect(checkmark().getAttribute('style')).toContain('opacity: 0');
|
|
69
68
|
});
|
|
70
69
|
|
|
70
|
+
// A class on a component lands on its host too (issue #181): the class is
|
|
71
|
+
// the box's, so the host, the tap target, drops its copy.
|
|
72
|
+
it('puts its class on one element, the box', async () => {
|
|
73
|
+
const { container, box, target } = await renderCheckbox();
|
|
74
|
+
expect([...container.querySelectorAll('.mr-2')]).toEqual([box()]);
|
|
75
|
+
expect((target() as HTMLElement).style.width).toBe('2.75rem');
|
|
76
|
+
});
|
|
77
|
+
|
|
71
78
|
it('checks and unchecks on tap, emitting `changed`', async () => {
|
|
72
79
|
const { host, target, box, checkmark } = await renderCheckbox();
|
|
73
80
|
|
|
@@ -15,25 +15,32 @@ import { LYNX_ELEMENTS } from '@blotch/angular-lynx';
|
|
|
15
15
|
import { type AnimationHandle, popIn, popOut } from '../../utils/animate';
|
|
16
16
|
import { cn } from '../../utils/cn';
|
|
17
17
|
|
|
18
|
+
/**
|
|
19
|
+
* The host is the checkbox's 44pt tap target, with the box inside, and the
|
|
20
|
+
* app's class is the box's. Angular also gives the host every static
|
|
21
|
+
* attribute the app writes on <ui-checkbox>, so the class styled the target
|
|
22
|
+
* too: a margin applied twice. The host drops it, and takes its own size
|
|
23
|
+
* from an inline style, which dropping the class leaves alone.
|
|
24
|
+
*/
|
|
18
25
|
@Component({
|
|
19
26
|
selector: 'ui-checkbox',
|
|
20
|
-
standalone: true,
|
|
21
27
|
imports: [LYNX_ELEMENTS],
|
|
22
28
|
encapsulation: ViewEncapsulation.None,
|
|
29
|
+
host: {
|
|
30
|
+
'[attr.class]': 'null',
|
|
31
|
+
style:
|
|
32
|
+
'width: 2.75rem; height: 2.75rem; display: flex; align-items: center; justify-content: center;',
|
|
33
|
+
'(bindtap)': 'toggle()',
|
|
34
|
+
},
|
|
23
35
|
template: `
|
|
24
|
-
<view
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
[style]="checked() ? 'opacity: 1;' : 'opacity: 0;'"
|
|
33
|
-
>
|
|
34
|
-
✓
|
|
35
|
-
</text>
|
|
36
|
-
</view>
|
|
36
|
+
<view [class]="boxClass()">
|
|
37
|
+
<text
|
|
38
|
+
#checkmark
|
|
39
|
+
[class]="checkClass()"
|
|
40
|
+
[style]="checked() ? 'opacity: 1;' : 'opacity: 0;'"
|
|
41
|
+
>
|
|
42
|
+
✓
|
|
43
|
+
</text>
|
|
37
44
|
</view>
|
|
38
45
|
`,
|
|
39
46
|
})
|
|
@@ -68,8 +68,6 @@ const CANCEL = 3;
|
|
|
68
68
|
describe('UiCollapsible', () => {
|
|
69
69
|
it("merges each part's class with its own", async () => {
|
|
70
70
|
const { container, host, trigger, content } = await renderCollapsible();
|
|
71
|
-
// The host element carries the class too, so look for the view that has
|
|
72
|
-
// both its own and the given class.
|
|
73
71
|
expect(container.querySelector('.flex-col.gap-2')).not.toBeNull();
|
|
74
72
|
expect(trigger().classList).toContain('flex-row');
|
|
75
73
|
expect(trigger().classList).toContain('px-2');
|
|
@@ -80,6 +78,22 @@ describe('UiCollapsible', () => {
|
|
|
80
78
|
expect(content()!.classList).toContain('pt-1');
|
|
81
79
|
});
|
|
82
80
|
|
|
81
|
+
// A class on a component lands on its host too (issue #181): each part is
|
|
82
|
+
// its host, so each class applies once, and closed content, which is
|
|
83
|
+
// hidden, takes no room for its padding.
|
|
84
|
+
it('puts each class on one element', async () => {
|
|
85
|
+
const { container, host, trigger, content } = await renderCollapsible();
|
|
86
|
+
expect(container.querySelectorAll('.gap-2')).toHaveLength(1);
|
|
87
|
+
expect([...container.querySelectorAll('.px-2')]).toEqual([trigger()]);
|
|
88
|
+
const [closed] = container.querySelectorAll('.pt-1');
|
|
89
|
+
expect(closed!.classList).toContain('hidden');
|
|
90
|
+
|
|
91
|
+
host.open.set(true);
|
|
92
|
+
await waitForUpdate();
|
|
93
|
+
expect([...container.querySelectorAll('.pt-1')]).toEqual([content()]);
|
|
94
|
+
expect(content()!.classList).not.toContain('hidden');
|
|
95
|
+
});
|
|
96
|
+
|
|
83
97
|
it('opens and closes when the trigger is tapped', async () => {
|
|
84
98
|
const { host, trigger, content } = await renderCollapsible();
|
|
85
99
|
// Closed, the content isn't mounted at all.
|
|
@@ -150,7 +164,7 @@ describe('UiCollapsible', () => {
|
|
|
150
164
|
{ transform: 'translateY(-8px)', opacity: 0 },
|
|
151
165
|
{ transform: 'translateY(0)', opacity: 1 },
|
|
152
166
|
],
|
|
153
|
-
// No fill: once it ends, the content's
|
|
167
|
+
// No fill: once it ends, the content's own style applies.
|
|
154
168
|
expect.objectContaining({ fill: 'none' }),
|
|
155
169
|
]);
|
|
156
170
|
const [firstKey] = startedOn(content());
|
|
@@ -160,13 +174,9 @@ describe('UiCollapsible', () => {
|
|
|
160
174
|
fireEvent.tap(trigger());
|
|
161
175
|
await waitForUpdate();
|
|
162
176
|
await waitForUpdate();
|
|
163
|
-
// Reopening stops the previous reveal before it plays a new one
|
|
164
|
-
|
|
165
|
-
expect(
|
|
166
|
-
CANCEL,
|
|
167
|
-
firstKey,
|
|
168
|
-
]);
|
|
169
|
-
expect(startedOn(content())).toHaveLength(1);
|
|
177
|
+
// Reopening stops the previous reveal before it plays a new one.
|
|
178
|
+
expect(animate).toHaveBeenCalledWith(content(), [CANCEL, firstKey]);
|
|
179
|
+
expect(startedOn(content())).toHaveLength(2);
|
|
170
180
|
});
|
|
171
181
|
|
|
172
182
|
it("doesn't animate content that closed before its reveal began", async () => {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
|
-
type ElementRef,
|
|
3
2
|
Component,
|
|
3
|
+
ElementRef,
|
|
4
4
|
ViewEncapsulation,
|
|
5
5
|
booleanAttribute,
|
|
6
6
|
computed,
|
|
@@ -8,23 +8,23 @@ import {
|
|
|
8
8
|
inject,
|
|
9
9
|
input,
|
|
10
10
|
model,
|
|
11
|
-
viewChild,
|
|
12
11
|
} from '@angular/core';
|
|
13
|
-
import { LYNX_ELEMENTS } from '@blotch/angular-lynx';
|
|
14
12
|
|
|
15
13
|
import { type AnimationHandle, revealIn } from '../../utils/animate';
|
|
16
14
|
import { cn } from '../../utils/cn';
|
|
17
15
|
|
|
16
|
+
/**
|
|
17
|
+
* Each part is its host element, styled through `host`, rather than a view
|
|
18
|
+
* inside the host. Angular gives a component's host every static attribute the
|
|
19
|
+
* app writes on it, so `<ui-collapsible class="rounded-lg border">` puts those
|
|
20
|
+
* classes on the host whatever the component does: with a styled view inside
|
|
21
|
+
* as well, the border was drawn twice.
|
|
22
|
+
*/
|
|
18
23
|
@Component({
|
|
19
24
|
selector: 'ui-collapsible',
|
|
20
|
-
standalone: true,
|
|
21
|
-
imports: [LYNX_ELEMENTS],
|
|
22
25
|
encapsulation: ViewEncapsulation.None,
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
<ng-content />
|
|
26
|
-
</view>
|
|
27
|
-
`,
|
|
26
|
+
host: { '[class]': 'containerClass()' },
|
|
27
|
+
template: '<ng-content />',
|
|
28
28
|
})
|
|
29
29
|
export class UiCollapsible {
|
|
30
30
|
readonly open = model(false);
|
|
@@ -44,14 +44,12 @@ export class UiCollapsible {
|
|
|
44
44
|
|
|
45
45
|
@Component({
|
|
46
46
|
selector: 'ui-collapsible-trigger',
|
|
47
|
-
standalone: true,
|
|
48
|
-
imports: [LYNX_ELEMENTS],
|
|
49
47
|
encapsulation: ViewEncapsulation.None,
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
48
|
+
host: {
|
|
49
|
+
'[class]': 'triggerClass()',
|
|
50
|
+
'(bindtap)': 'onTap()',
|
|
51
|
+
},
|
|
52
|
+
template: '<ng-content />',
|
|
55
53
|
})
|
|
56
54
|
export class UiCollapsibleTrigger {
|
|
57
55
|
readonly #collapsible = inject(UiCollapsible);
|
|
@@ -72,26 +70,19 @@ export class UiCollapsibleTrigger {
|
|
|
72
70
|
|
|
73
71
|
@Component({
|
|
74
72
|
selector: 'ui-collapsible-content',
|
|
75
|
-
standalone: true,
|
|
76
|
-
imports: [LYNX_ELEMENTS],
|
|
77
73
|
encapsulation: ViewEncapsulation.None,
|
|
78
74
|
// Mount the content only while open, via `@if`. On Lynx this works because the
|
|
79
75
|
// renderer recreates a destroyed element's native painting node when it is
|
|
80
76
|
// re-inserted (see LynxElement.#recreateSubtree in @blotch/angular-lynx): when
|
|
81
|
-
// the panel reopens, the projected `<ng-content>`
|
|
82
|
-
//
|
|
83
|
-
//
|
|
84
|
-
//
|
|
85
|
-
// animation ends.
|
|
77
|
+
// the panel reopens, the projected `<ng-content>` is rebuilt from cache and
|
|
78
|
+
// renders again on every open. While closed the host is hidden, so the
|
|
79
|
+
// classes the app wrote on it (which Angular puts on the host either way)
|
|
80
|
+
// take no room. revealIn animates from opacity:0 / translateY(-8px) with
|
|
81
|
+
// fill:'none', so the host's own style takes over once the animation ends.
|
|
82
|
+
host: { '[class]': 'contentClass()' },
|
|
86
83
|
template: `
|
|
87
84
|
@if (collapsible.open()) {
|
|
88
|
-
<
|
|
89
|
-
#content
|
|
90
|
-
[class]="contentClass()"
|
|
91
|
-
style="opacity: 1; transform: translateY(0);"
|
|
92
|
-
>
|
|
93
|
-
<ng-content />
|
|
94
|
-
</view>
|
|
85
|
+
<ng-content />
|
|
95
86
|
}
|
|
96
87
|
`,
|
|
97
88
|
})
|
|
@@ -99,7 +90,7 @@ export class UiCollapsibleContent {
|
|
|
99
90
|
protected readonly collapsible = inject(UiCollapsible);
|
|
100
91
|
readonly userClass = input<string>('', { alias: 'class' });
|
|
101
92
|
|
|
102
|
-
readonly
|
|
93
|
+
readonly #host = inject(ElementRef).nativeElement;
|
|
103
94
|
#anim?: AnimationHandle;
|
|
104
95
|
|
|
105
96
|
/**
|
|
@@ -107,22 +98,20 @@ export class UiCollapsibleContent {
|
|
|
107
98
|
* constructor: Angular's test build keeps a function for a constructor that
|
|
108
99
|
* nothing calls, which an app's coverage report would show as uncovered.
|
|
109
100
|
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
* leaves nothing to animate.
|
|
101
|
+
* Deferred one macrotask so the re-inserted content is committed to the
|
|
102
|
+
* native tree before we animate it. By then the panel may have closed
|
|
103
|
+
* again, which leaves nothing to animate.
|
|
114
104
|
*/
|
|
115
105
|
protected readonly revealing = effect(() => {
|
|
116
106
|
if (!this.collapsible.open()) return;
|
|
117
107
|
setTimeout(() => {
|
|
118
|
-
|
|
119
|
-
if (!el) return;
|
|
108
|
+
if (!this.collapsible.open()) return;
|
|
120
109
|
this.#anim?.cancel();
|
|
121
|
-
this.#anim = revealIn(
|
|
110
|
+
this.#anim = revealIn(this.#host, { fromY: -8, fill: 'none' });
|
|
122
111
|
}, 0);
|
|
123
112
|
});
|
|
124
113
|
|
|
125
114
|
protected readonly contentClass = computed(() =>
|
|
126
|
-
cn('flex-col flex', this.userClass()),
|
|
115
|
+
this.collapsible.open() ? cn('flex-col flex', this.userClass()) : 'hidden',
|
|
127
116
|
);
|
|
128
117
|
}
|
|
@@ -123,7 +123,6 @@ describe('UiDialog', () => {
|
|
|
123
123
|
expect(getByText('Title').classList).toContain('text-xl');
|
|
124
124
|
expect(getByText('Description').classList).toContain('text-sm');
|
|
125
125
|
expect(getByText('Description').classList).toContain('italic');
|
|
126
|
-
// Each part's own view, not its host element, which keeps the bare class.
|
|
127
126
|
const header = container.querySelector('.flex-col.gap-2');
|
|
128
127
|
expect(header?.classList).not.toContain('gap-1.5');
|
|
129
128
|
const footer = container.querySelector('.pt-4');
|
|
@@ -131,6 +130,23 @@ describe('UiDialog', () => {
|
|
|
131
130
|
expect(footer?.classList).not.toContain('justify-end');
|
|
132
131
|
});
|
|
133
132
|
|
|
133
|
+
// Angular gives a component's host every static attribute written on it
|
|
134
|
+
// (issue #181), so each class also styled a host: the dialog's in the page,
|
|
135
|
+
// where it is written, and each part's around its own view or text.
|
|
136
|
+
it('puts each class on one element: the panel, or the part', async () => {
|
|
137
|
+
const { container, getByText, panel } = await renderDialog();
|
|
138
|
+
expect([...container.querySelectorAll('.max-w-sm')]).toEqual([panel()]);
|
|
139
|
+
// The header's: the footer has a gap-2 of its own.
|
|
140
|
+
expect(container.querySelectorAll('.flex-col.gap-2')).toHaveLength(1);
|
|
141
|
+
expect(container.querySelectorAll('.justify-start')).toHaveLength(1);
|
|
142
|
+
expect([...container.querySelectorAll('.text-xl')]).toEqual([
|
|
143
|
+
getByText('Title'),
|
|
144
|
+
]);
|
|
145
|
+
expect([...container.querySelectorAll('.italic')]).toEqual([
|
|
146
|
+
getByText('Description'),
|
|
147
|
+
]);
|
|
148
|
+
});
|
|
149
|
+
|
|
134
150
|
it('stays hidden until it is opened', async () => {
|
|
135
151
|
const { host, overlay, panel } = await renderDialog();
|
|
136
152
|
await after(CLOSED);
|
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
signal,
|
|
12
12
|
viewChild,
|
|
13
13
|
} from '@angular/core';
|
|
14
|
-
import { LYNX_ELEMENTS } from '@blotch/angular-lynx';
|
|
14
|
+
import { LYNX_ELEMENTS, registerHostElement } from '@blotch/angular-lynx';
|
|
15
15
|
|
|
16
16
|
import {
|
|
17
17
|
type AnimationHandle,
|
|
@@ -29,6 +29,11 @@ import { cn } from '../../utils/cn';
|
|
|
29
29
|
selector: 'ui-dialog',
|
|
30
30
|
imports: [LYNX_ELEMENTS],
|
|
31
31
|
encapsulation: ViewEncapsulation.None,
|
|
32
|
+
// The dialog's class is its panel's, inside the overlay. Angular also gives
|
|
33
|
+
// the host every static attribute the app writes on <ui-dialog>, and the
|
|
34
|
+
// host sits in the page's layout where the dialog is written, so a
|
|
35
|
+
// `class="p-8"` padded the page there too. The host drops it.
|
|
36
|
+
host: { '[attr.class]': 'null' },
|
|
32
37
|
template: `
|
|
33
38
|
<overlay [attr.visible]="overlayVisible()" [style]="overlayStyle()">
|
|
34
39
|
<view #backdrop [class]="backdropClass()" (bindtap)="onBackdropTap()">
|
|
@@ -220,15 +225,17 @@ export class UiDialog implements OnDestroy {
|
|
|
220
225
|
}
|
|
221
226
|
}
|
|
222
227
|
|
|
228
|
+
/**
|
|
229
|
+
* The parts are their host elements, styled through `host`: a class the app
|
|
230
|
+
* writes on a part lands on its host anyway (see UiDialog), so a view or text
|
|
231
|
+
* inside it styled with the same class applied it twice. The title and
|
|
232
|
+
* description are text, so their hosts are `<text>` elements.
|
|
233
|
+
*/
|
|
223
234
|
@Component({
|
|
224
235
|
selector: 'ui-dialog-header',
|
|
225
|
-
imports: [LYNX_ELEMENTS],
|
|
226
236
|
encapsulation: ViewEncapsulation.None,
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
<ng-content />
|
|
230
|
-
</view>
|
|
231
|
-
`,
|
|
237
|
+
host: { '[class]': 'containerClass()' },
|
|
238
|
+
template: '<ng-content />',
|
|
232
239
|
})
|
|
233
240
|
export class UiDialogHeader {
|
|
234
241
|
readonly userClass = input<string>('', { alias: 'class' });
|
|
@@ -240,9 +247,9 @@ export class UiDialogHeader {
|
|
|
240
247
|
|
|
241
248
|
@Component({
|
|
242
249
|
selector: 'ui-dialog-title',
|
|
243
|
-
imports: [LYNX_ELEMENTS],
|
|
244
250
|
encapsulation: ViewEncapsulation.None,
|
|
245
|
-
|
|
251
|
+
host: { '[class]': 'textClass()' },
|
|
252
|
+
template: '<ng-content />',
|
|
246
253
|
})
|
|
247
254
|
export class UiDialogTitle {
|
|
248
255
|
readonly userClass = input<string>('', { alias: 'class' });
|
|
@@ -252,11 +259,13 @@ export class UiDialogTitle {
|
|
|
252
259
|
);
|
|
253
260
|
}
|
|
254
261
|
|
|
262
|
+
registerHostElement(UiDialogTitle, 'text');
|
|
263
|
+
|
|
255
264
|
@Component({
|
|
256
265
|
selector: 'ui-dialog-description',
|
|
257
|
-
imports: [LYNX_ELEMENTS],
|
|
258
266
|
encapsulation: ViewEncapsulation.None,
|
|
259
|
-
|
|
267
|
+
host: { '[class]': 'textClass()' },
|
|
268
|
+
template: '<ng-content />',
|
|
260
269
|
})
|
|
261
270
|
export class UiDialogDescription {
|
|
262
271
|
readonly userClass = input<string>('', { alias: 'class' });
|
|
@@ -266,15 +275,13 @@ export class UiDialogDescription {
|
|
|
266
275
|
);
|
|
267
276
|
}
|
|
268
277
|
|
|
278
|
+
registerHostElement(UiDialogDescription, 'text');
|
|
279
|
+
|
|
269
280
|
@Component({
|
|
270
281
|
selector: 'ui-dialog-footer',
|
|
271
|
-
imports: [LYNX_ELEMENTS],
|
|
272
282
|
encapsulation: ViewEncapsulation.None,
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
<ng-content />
|
|
276
|
-
</view>
|
|
277
|
-
`,
|
|
283
|
+
host: { '[class]': 'containerClass()' },
|
|
284
|
+
template: '<ng-content />',
|
|
278
285
|
})
|
|
279
286
|
export class UiDialogFooter {
|
|
280
287
|
readonly userClass = input<string>('', { alias: 'class' });
|
|
@@ -101,6 +101,16 @@ class DockHost {
|
|
|
101
101
|
readonly items = viewChildren(UiDockItem);
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
+
@Component({
|
|
105
|
+
imports: [UiDock, UiDockItem],
|
|
106
|
+
template: `
|
|
107
|
+
<ui-dock>
|
|
108
|
+
<ui-dock-item route="/" icon="home" label="Home" class="pt-4" />
|
|
109
|
+
</ui-dock>
|
|
110
|
+
`,
|
|
111
|
+
})
|
|
112
|
+
class StaticClassHost {}
|
|
113
|
+
|
|
104
114
|
/**
|
|
105
115
|
* A dock with only an action of the app's own, and no items.
|
|
106
116
|
*/
|
|
@@ -685,15 +695,30 @@ describe('UiDockItem', () => {
|
|
|
685
695
|
expect(scale()).toBe('scale(1)');
|
|
686
696
|
});
|
|
687
697
|
|
|
688
|
-
it("merges an item's class
|
|
698
|
+
it("merges an item's class with its own, the given class winning", async () => {
|
|
689
699
|
const { host, item } = await renderDock(IOS_26);
|
|
690
|
-
|
|
691
|
-
expect(content().classList).toContain('gap-0.5');
|
|
700
|
+
expect(item('Inbox').classList).toContain('gap-0.5');
|
|
692
701
|
|
|
693
702
|
host.inboxClass.set('gap-2');
|
|
694
703
|
await waitForUpdate();
|
|
695
|
-
expect(
|
|
696
|
-
expect(
|
|
704
|
+
expect(item('Inbox').classList).toContain('gap-2');
|
|
705
|
+
expect(item('Inbox').classList).not.toContain('gap-0.5');
|
|
706
|
+
});
|
|
707
|
+
|
|
708
|
+
// Angular gives a component's host every static attribute written on it,
|
|
709
|
+
// so an item that also styled a view inside its host applied the class
|
|
710
|
+
// twice (issue #181).
|
|
711
|
+
it('puts a class written on an item on that one element', async () => {
|
|
712
|
+
const { container } = await render(StaticClassHost, {
|
|
713
|
+
providers: [
|
|
714
|
+
provideRouter(routes),
|
|
715
|
+
onPlatform(IOS_26),
|
|
716
|
+
{ provide: LynxAccessibilitySettings, useValue: settings },
|
|
717
|
+
],
|
|
718
|
+
});
|
|
719
|
+
expect([...container.querySelectorAll('.pt-4')]).toEqual([
|
|
720
|
+
container.querySelector('[accessibility-label="Home"]'),
|
|
721
|
+
]);
|
|
697
722
|
});
|
|
698
723
|
});
|
|
699
724
|
|