@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
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
import {
|
|
2
|
-
type ElementRef,
|
|
3
2
|
Component,
|
|
3
|
+
ElementRef,
|
|
4
4
|
ViewEncapsulation,
|
|
5
5
|
booleanAttribute,
|
|
6
6
|
computed,
|
|
7
7
|
effect,
|
|
8
|
+
inject,
|
|
8
9
|
input,
|
|
9
10
|
model,
|
|
10
11
|
output,
|
|
11
12
|
signal,
|
|
12
|
-
viewChild,
|
|
13
13
|
} from '@angular/core';
|
|
14
14
|
import { type FormValueControl } from '@angular/forms/signals';
|
|
15
15
|
import { LYNX_ELEMENTS } from '@blotch/angular-lynx';
|
|
@@ -19,75 +19,82 @@ import { cn } from '../../utils/cn';
|
|
|
19
19
|
|
|
20
20
|
@Component({
|
|
21
21
|
selector: 'ui-textarea',
|
|
22
|
-
standalone: true,
|
|
23
22
|
imports: [LYNX_ELEMENTS],
|
|
24
23
|
encapsulation: ViewEncapsulation.None,
|
|
24
|
+
// The host is the field's column. Angular gives a component's host every
|
|
25
|
+
// static attribute the app writes on it, so a class on <ui-textarea> lands
|
|
26
|
+
// there whatever the component does: a column inside the host applied it
|
|
27
|
+
// twice. A placeholder lands there too, and goes: it belongs to the
|
|
28
|
+
// <textarea>, and a second element with it is a second match for
|
|
29
|
+
// getByPlaceholderText.
|
|
30
|
+
host: {
|
|
31
|
+
'[class]': 'containerClass()',
|
|
32
|
+
'[attr.placeholder]': 'null',
|
|
33
|
+
},
|
|
25
34
|
template: `
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
></textarea>
|
|
84
|
-
</view>
|
|
85
|
-
@if (error()) {
|
|
86
|
-
<text [class]="errorClass()">{{ error() }}</text>
|
|
87
|
-
} @else if (helperText()) {
|
|
88
|
-
<text [class]="helperClass()">{{ helperText() }}</text>
|
|
89
|
-
}
|
|
35
|
+
@if (label()) {
|
|
36
|
+
<text [class]="labelClass()">{{ label() }}</text>
|
|
37
|
+
}
|
|
38
|
+
<!-- The min/max height lives on this wrapper, not the textarea: a native
|
|
39
|
+
Lynx textarea only auto-grows when it drives its container's height
|
|
40
|
+
(via height:100% below). Pinning min/max-height on the textarea itself
|
|
41
|
+
locks it to a fixed box that scrolls but never grows. -->
|
|
42
|
+
<view [class]="textareaWrapperClass()" [style]="wrapperStyle()">
|
|
43
|
+
<!-- Focus ring on its own overlay layer so it can fade (see input.ts for
|
|
44
|
+
the full rationale): box-shadow is animatable:no on Lynx, opacity is,
|
|
45
|
+
so we transition the layer's opacity. First child = painted behind
|
|
46
|
+
the textarea on Lynx (strict source order). Source order alone is NOT
|
|
47
|
+
enough on web: CSS paints a positioned (absolute) sibling above a
|
|
48
|
+
static one regardless of source order, so this transparent overlay
|
|
49
|
+
would swallow every tap — no focus, no typing. The textarea below
|
|
50
|
+
carries position:relative + z-index:1 so it sits above the overlay on
|
|
51
|
+
BOTH platforms (required on web). The wrapper's z-0 makes it the
|
|
52
|
+
stacking context for that z-index; without it Lynx stacks the native
|
|
53
|
+
textarea against an ancestor outside the scroll-view, so it stays
|
|
54
|
+
fixed on screen while the page scrolls. No pointer-events
|
|
55
|
+
(which errors the Lynx build). OUTSET only; Lynx doesn't render inset
|
|
56
|
+
box-shadows. -->
|
|
57
|
+
<view
|
|
58
|
+
class="rounded-xl opacity-0 absolute bottom-0 left-0 right-0 top-0"
|
|
59
|
+
[style.transition]="'opacity 150ms ease'"
|
|
60
|
+
[style.box-shadow]="ringShadow()"
|
|
61
|
+
[style.opacity]="ringVisible() ? 1 : 0"
|
|
62
|
+
></view>
|
|
63
|
+
<!-- [attr.disabled]="disabled() || undefined": passing 'undefined'
|
|
64
|
+
removes the attribute entirely; passing 'false' would set
|
|
65
|
+
disabled="false" which Lynx still treats as disabled. -->
|
|
66
|
+
<!-- [value] not [attr.value]: same reason as UiInput — routes through
|
|
67
|
+
LynxTextarea.ngOnChanges so value changes call invoke("setValue")
|
|
68
|
+
rather than the ineffective __SetAttribute path. -->
|
|
69
|
+
<!-- [attr.enable-scroll-bar]: only surface the scrollbar once maxLines is
|
|
70
|
+
set (i.e. the field can actually scroll). Native scrolling past
|
|
71
|
+
max-height works regardless; this just shows the indicator.
|
|
72
|
+
height:100% makes the textarea fill (and grow) the wrapper; the
|
|
73
|
+
pinned line-height keeps the wrapper's line math exact. -->
|
|
74
|
+
<!-- [attr.placeholder]: suppress the placeholder whenever a value is
|
|
75
|
+
present. The browser hides a placeholder under a value automatically,
|
|
76
|
+
but Lynx's iOS textarea renders the placeholder as a separate overlay
|
|
77
|
+
view that only hides on textViewDidChange — so an initial value set via
|
|
78
|
+
the setValue UIMethod leaves the placeholder stacked on top of it. Not
|
|
79
|
+
emitting the attribute when there's a value matches web semantics and
|
|
80
|
+
avoids the overlap on all platforms. -->
|
|
81
|
+
<textarea
|
|
82
|
+
[attr.placeholder]="value() ? null : placeholder()"
|
|
83
|
+
[value]="value()"
|
|
84
|
+
[attr.disabled]="disabled() || undefined"
|
|
85
|
+
[attr.enable-scroll-bar]="maxLines() != null ? 'true' : null"
|
|
86
|
+
class="text-sm text-foreground"
|
|
87
|
+
style="border: none; background: transparent; height: 100%; width: 100%; line-height: 20px; position: relative; z-index: 1;"
|
|
88
|
+
(bindinput)="onInput($any($event))"
|
|
89
|
+
(bindfocus)="onFocus()"
|
|
90
|
+
(bindblur)="onBlur()"
|
|
91
|
+
></textarea>
|
|
90
92
|
</view>
|
|
93
|
+
@if (error()) {
|
|
94
|
+
<text [class]="errorClass()">{{ error() }}</text>
|
|
95
|
+
} @else if (helperText()) {
|
|
96
|
+
<text [class]="helperClass()">{{ helperText() }}</text>
|
|
97
|
+
}
|
|
91
98
|
`,
|
|
92
99
|
})
|
|
93
100
|
export class UiTextarea implements FormValueControl<string> {
|
|
@@ -109,7 +116,7 @@ export class UiTextarea implements FormValueControl<string> {
|
|
|
109
116
|
readonly focused = output<void>();
|
|
110
117
|
readonly blurred = output<void>();
|
|
111
118
|
|
|
112
|
-
readonly
|
|
119
|
+
readonly #host = inject(ElementRef).nativeElement;
|
|
113
120
|
readonly #isFocused = signal(false);
|
|
114
121
|
#shakeAnim?: AnimationHandle;
|
|
115
122
|
#previousError = '';
|
|
@@ -121,10 +128,9 @@ export class UiTextarea implements FormValueControl<string> {
|
|
|
121
128
|
*/
|
|
122
129
|
protected readonly shakeOnError = effect(() => {
|
|
123
130
|
const err = this.error();
|
|
124
|
-
|
|
125
|
-
if (err && !this.#previousError && el) {
|
|
131
|
+
if (err && !this.#previousError) {
|
|
126
132
|
this.#shakeAnim?.cancel();
|
|
127
|
-
this.#shakeAnim = shake(
|
|
133
|
+
this.#shakeAnim = shake(this.#host);
|
|
128
134
|
}
|
|
129
135
|
this.#previousError = err;
|
|
130
136
|
});
|
|
@@ -71,8 +71,8 @@ class ToggleGroupHost {
|
|
|
71
71
|
}
|
|
72
72
|
|
|
73
73
|
/**
|
|
74
|
-
* The
|
|
75
|
-
*
|
|
74
|
+
* The toggle or toggle group item a label is projected into: its host, which
|
|
75
|
+
* takes the presses.
|
|
76
76
|
*/
|
|
77
77
|
const viewOf = (label: Element) => label.parentElement!;
|
|
78
78
|
|
|
@@ -129,6 +129,14 @@ describe('UiToggle', () => {
|
|
|
129
129
|
};
|
|
130
130
|
};
|
|
131
131
|
|
|
132
|
+
// Angular gives a component's host every static attribute written on it,
|
|
133
|
+
// so a toggle that also styled a view inside its host applied the class
|
|
134
|
+
// twice (issue #181).
|
|
135
|
+
it('puts its class on one element, the toggle', async () => {
|
|
136
|
+
const { container, toggle } = await renderToggle();
|
|
137
|
+
expect([...container.querySelectorAll('.w-20')]).toEqual([toggle()]);
|
|
138
|
+
});
|
|
139
|
+
|
|
132
140
|
it('renders its content unpressed, with the given class', async () => {
|
|
133
141
|
const { toggle } = await renderToggle();
|
|
134
142
|
expect(toggle().classList).toContain('bg-transparent');
|
|
@@ -217,6 +225,15 @@ describe('UiToggleGroup', () => {
|
|
|
217
225
|
expect(group().classList).toContain('w-full');
|
|
218
226
|
});
|
|
219
227
|
|
|
228
|
+
// Angular gives a component's host every static attribute written on it,
|
|
229
|
+
// so a group or item that also styled a view inside its host applied the
|
|
230
|
+
// class twice (issue #181).
|
|
231
|
+
it('puts a class written on the group or an item on that one element', async () => {
|
|
232
|
+
const { container, group, item } = await renderGroup();
|
|
233
|
+
expect([...container.querySelectorAll('.w-full')]).toEqual([group()]);
|
|
234
|
+
expect([...container.querySelectorAll('.flex-1')]).toEqual([item('Left')]);
|
|
235
|
+
});
|
|
236
|
+
|
|
220
237
|
it('selects one item at a time, and deselects it on a second tap', async () => {
|
|
221
238
|
const { host, item } = await renderGroup();
|
|
222
239
|
await tap(item('Left'));
|
|
@@ -338,6 +355,18 @@ class SegmentedGroupHost {
|
|
|
338
355
|
readonly disabled = signal(false);
|
|
339
356
|
}
|
|
340
357
|
|
|
358
|
+
@Component({
|
|
359
|
+
imports: [UiToggleGroup, UiToggleGroupItem, LYNX_ELEMENTS],
|
|
360
|
+
template: `
|
|
361
|
+
<ui-toggle-group variant="segmented">
|
|
362
|
+
<ui-toggle-group-item value="bold" accessibility-label="Bold"
|
|
363
|
+
><text>B</text></ui-toggle-group-item
|
|
364
|
+
>
|
|
365
|
+
</ui-toggle-group>
|
|
366
|
+
`,
|
|
367
|
+
})
|
|
368
|
+
class StaticLabelHost {}
|
|
369
|
+
|
|
341
370
|
/**
|
|
342
371
|
* A group inside something that colors its icons, such as glass.
|
|
343
372
|
*/
|
|
@@ -388,18 +417,15 @@ describe('UiToggleGroup, segmented', () => {
|
|
|
388
417
|
const result = await render(SegmentedGroupHost, {
|
|
389
418
|
providers: [{ provide: LynxAccessibilitySettings, useValue: settings }],
|
|
390
419
|
});
|
|
420
|
+
const track = () => viewOf(result.getByText('Day')).parentElement!;
|
|
391
421
|
/**
|
|
392
|
-
* An item's
|
|
422
|
+
* An item, the track's flex item: around its label, or the last, around
|
|
423
|
+
* the star icon.
|
|
393
424
|
*/
|
|
394
425
|
const item = (label: string) =>
|
|
395
426
|
label === 'Star'
|
|
396
|
-
?
|
|
427
|
+
? (track().lastElementChild as HTMLElement)
|
|
397
428
|
: viewOf(result.getByText(label));
|
|
398
|
-
/**
|
|
399
|
-
* An item's host, the track's flex item.
|
|
400
|
-
*/
|
|
401
|
-
const slot = (label: string) => item(label).parentElement!;
|
|
402
|
-
const track = () => slot('Day').parentElement!;
|
|
403
429
|
/**
|
|
404
430
|
* The indicator's frame, which moves between the items.
|
|
405
431
|
*/
|
|
@@ -407,7 +433,7 @@ describe('UiToggleGroup, segmented', () => {
|
|
|
407
433
|
track().querySelector<HTMLElement>('.h-full .bottom-0.top-0');
|
|
408
434
|
const measure = async () => {
|
|
409
435
|
for (const label of ['Day', 'Week', 'Month', 'Star']) {
|
|
410
|
-
fireEvent.layoutchange(
|
|
436
|
+
fireEvent.layoutchange(item(label), {
|
|
411
437
|
detail: { width: label === 'Month' ? 160 : 80 },
|
|
412
438
|
});
|
|
413
439
|
}
|
|
@@ -417,7 +443,6 @@ describe('UiToggleGroup, segmented', () => {
|
|
|
417
443
|
...result,
|
|
418
444
|
host: result.componentRef.instance as SegmentedGroupHost,
|
|
419
445
|
item,
|
|
420
|
-
slot,
|
|
421
446
|
track,
|
|
422
447
|
frame,
|
|
423
448
|
measure,
|
|
@@ -429,13 +454,13 @@ describe('UiToggleGroup, segmented', () => {
|
|
|
429
454
|
};
|
|
430
455
|
|
|
431
456
|
it("draws the platform's segmented control, its items sharing the track", async () => {
|
|
432
|
-
const { track,
|
|
457
|
+
const { track, item, frame } = await renderSegmented();
|
|
433
458
|
expect(track().classList).toContain('rounded-full');
|
|
434
459
|
expect(track().classList).toContain('bg-muted');
|
|
435
|
-
expect(
|
|
436
|
-
// An item's own class sizes
|
|
437
|
-
expect(
|
|
438
|
-
expect(
|
|
460
|
+
expect(item('Day').classList).toContain('flex-1');
|
|
461
|
+
// An item's own class sizes it in the track.
|
|
462
|
+
expect(item('Month').classList).toContain('flex-[2]');
|
|
463
|
+
expect(item('Month').classList).not.toContain('flex-1');
|
|
439
464
|
expect(item('Day').classList).toContain('justify-center');
|
|
440
465
|
expect(frame()).not.toBeNull();
|
|
441
466
|
});
|
|
@@ -448,6 +473,12 @@ describe('UiToggleGroup, segmented', () => {
|
|
|
448
473
|
expect(host.range()).toEqual(['Day']);
|
|
449
474
|
});
|
|
450
475
|
|
|
476
|
+
// The accessibility label names one element, the item (issue #181).
|
|
477
|
+
it('names one element with an accessibility label written on an item', async () => {
|
|
478
|
+
const { getAllByAccessibilityLabel } = await render(StaticLabelHost);
|
|
479
|
+
expect(getAllByAccessibilityLabel('Bold')).toHaveLength(1);
|
|
480
|
+
});
|
|
481
|
+
|
|
451
482
|
it('marks the selected item selected for screen readers', async () => {
|
|
452
483
|
const { getByRole, tap } = await renderSegmented();
|
|
453
484
|
expect(getByRole('button', { name: 'Week', selected: true })).toBeTruthy();
|
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
import {
|
|
2
|
-
type ElementRef,
|
|
3
2
|
Component,
|
|
3
|
+
ElementRef,
|
|
4
4
|
ViewEncapsulation,
|
|
5
5
|
booleanAttribute,
|
|
6
6
|
computed,
|
|
7
7
|
contentChildren,
|
|
8
|
+
effect,
|
|
8
9
|
inject,
|
|
9
10
|
input,
|
|
10
11
|
model,
|
|
11
|
-
viewChild,
|
|
12
12
|
} from '@angular/core';
|
|
13
|
-
import { LYNX_ELEMENTS } from '@blotch/angular-lynx';
|
|
13
|
+
import { type BaseLynxElement, LYNX_ELEMENTS } from '@blotch/angular-lynx';
|
|
14
14
|
import { cva, type VariantProps } from 'class-variance-authority';
|
|
15
15
|
|
|
16
16
|
import {
|
|
@@ -86,23 +86,25 @@ const itemIconColor = () => {
|
|
|
86
86
|
|
|
87
87
|
@Component({
|
|
88
88
|
selector: 'ui-toggle',
|
|
89
|
-
imports: [LYNX_ELEMENTS],
|
|
90
89
|
encapsulation: ViewEncapsulation.None,
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
90
|
+
// Each toggle, group and item is its host element, styled through `host`,
|
|
91
|
+
// rather than a view inside the host. Angular gives a component's host
|
|
92
|
+
// every static attribute the app writes on it, so `<ui-toggle
|
|
93
|
+
// class="px-6">` puts `px-6` on the host whatever the component does with
|
|
94
|
+
// its `class` input: with a styled view inside as well, the padding applied
|
|
95
|
+
// twice. On the host alone, a class also acts where the toggle sits in its
|
|
96
|
+
// parent's layout, and an item's accessibility-label names one element.
|
|
97
|
+
host: {
|
|
98
|
+
'[class]': 'containerClass()',
|
|
99
|
+
'(bindtouchstart)': 'onPressStart()',
|
|
100
|
+
'(bindtouchend)': 'onPressEnd()',
|
|
101
|
+
'(bindtouchcancel)': 'onPressCancel()',
|
|
102
|
+
'(bindmousedown)': 'onPressStart()',
|
|
103
|
+
'(bindmouseup)': 'onPressEnd()',
|
|
104
|
+
'(bindmouseleave)': 'onPressCancel()',
|
|
105
|
+
'(bindtap)': 'onTap()',
|
|
106
|
+
},
|
|
107
|
+
template: '<ng-content />',
|
|
106
108
|
})
|
|
107
109
|
export class UiToggle {
|
|
108
110
|
readonly pressed = model(false);
|
|
@@ -111,7 +113,7 @@ export class UiToggle {
|
|
|
111
113
|
readonly disabled = input(false, { transform: booleanAttribute });
|
|
112
114
|
readonly userClass = input<string>('', { alias: 'class' });
|
|
113
115
|
|
|
114
|
-
readonly
|
|
116
|
+
readonly #host = inject(ElementRef).nativeElement;
|
|
115
117
|
#pressAnim?: AnimationHandle;
|
|
116
118
|
|
|
117
119
|
protected readonly containerClass = computed(() =>
|
|
@@ -126,18 +128,18 @@ export class UiToggle {
|
|
|
126
128
|
protected onPressStart(): void {
|
|
127
129
|
if (this.disabled()) return;
|
|
128
130
|
this.#pressAnim?.cancel();
|
|
129
|
-
this.#pressAnim = pressDown(this
|
|
131
|
+
this.#pressAnim = pressDown(this.#host);
|
|
130
132
|
}
|
|
131
133
|
|
|
132
134
|
protected onPressEnd(): void {
|
|
133
135
|
if (this.disabled()) return;
|
|
134
136
|
this.#pressAnim?.cancel();
|
|
135
|
-
this.#pressAnim = pressRelease(this
|
|
137
|
+
this.#pressAnim = pressRelease(this.#host);
|
|
136
138
|
}
|
|
137
139
|
|
|
138
140
|
protected onPressCancel(): void {
|
|
139
141
|
this.#pressAnim?.cancel();
|
|
140
|
-
this.#pressAnim = pressRelease(this
|
|
142
|
+
this.#pressAnim = pressRelease(this.#host);
|
|
141
143
|
}
|
|
142
144
|
|
|
143
145
|
protected onTap(): void {
|
|
@@ -163,7 +165,7 @@ export class UiToggle {
|
|
|
163
165
|
*/
|
|
164
166
|
@Component({
|
|
165
167
|
selector: 'ui-toggle-group',
|
|
166
|
-
imports: [
|
|
168
|
+
imports: [UiSegmentedIndicator],
|
|
167
169
|
encapsulation: ViewEncapsulation.None,
|
|
168
170
|
providers: [
|
|
169
171
|
UiSegmentedControl,
|
|
@@ -171,16 +173,15 @@ export class UiToggle {
|
|
|
171
173
|
],
|
|
172
174
|
// The mouse leaving the row lets go of an indicator dragged in the web
|
|
173
175
|
// preview, where a drag gets no touchcancel.
|
|
176
|
+
host: {
|
|
177
|
+
'[class]': 'containerClass()',
|
|
178
|
+
'(bindmouseleave)': 'segmentedControl.cancel()',
|
|
179
|
+
},
|
|
174
180
|
template: `
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
@if (segmentedControl.underlay()) {
|
|
180
|
-
<ui-segmented-indicator />
|
|
181
|
-
}
|
|
182
|
-
<ng-content />
|
|
183
|
-
</view>
|
|
181
|
+
@if (segmentedControl.underlay()) {
|
|
182
|
+
<ui-segmented-indicator />
|
|
183
|
+
}
|
|
184
|
+
<ng-content />
|
|
184
185
|
`,
|
|
185
186
|
})
|
|
186
187
|
export class UiToggleGroup implements SegmentedHost {
|
|
@@ -254,38 +255,32 @@ export class UiToggleGroup implements SegmentedHost {
|
|
|
254
255
|
encapsulation: ViewEncapsulation.None,
|
|
255
256
|
// Icons projected into the item inject this: they're declared inside it.
|
|
256
257
|
providers: [{ provide: UI_ICON_COLOR, useFactory: itemIconColor }],
|
|
258
|
+
// The accessibility attributes are attribute bindings: a property binding
|
|
259
|
+
// on a component's host is checked against the DOM schema, which has no
|
|
260
|
+
// `accessibility-label`.
|
|
257
261
|
host: {
|
|
258
|
-
'[class]': '
|
|
262
|
+
'[class]': 'containerClass()',
|
|
263
|
+
'[style]': 'containerStyle()',
|
|
264
|
+
'[attr.accessibility-element]': "accessibilityLabel() ? 'true' : null",
|
|
265
|
+
'[attr.accessibility-traits]': "accessibilityLabel() ? 'button' : null",
|
|
266
|
+
'[attr.accessibility-label]': 'accessibilityLabel() || null',
|
|
259
267
|
// The segmented control places its indicator from each item's width.
|
|
260
268
|
'(bindlayoutchange)': 'measure($event)',
|
|
269
|
+
'(bindtouchstart)': 'onPressStart($event)',
|
|
270
|
+
'(bindtouchmove)': 'onPressMove($event)',
|
|
271
|
+
'(bindtouchend)': 'onPressEnd()',
|
|
272
|
+
'(bindtouchcancel)': 'onPressCancel()',
|
|
273
|
+
'(bindmousedown)': 'onPressStart($event)',
|
|
274
|
+
'(bindmousemove)': 'onPressMove($event)',
|
|
275
|
+
'(bindmouseup)': 'onPressEnd()',
|
|
276
|
+
'(bindmouseleave)': 'onPointerLeave()',
|
|
277
|
+
'(bindtap)': 'onTap()',
|
|
261
278
|
},
|
|
262
279
|
template: `
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
<
|
|
267
|
-
#container
|
|
268
|
-
[class]="containerClass()"
|
|
269
|
-
[style]="containerStyle()"
|
|
270
|
-
[attr.accessibility-element]="accessibilityLabel() ? 'true' : null"
|
|
271
|
-
[attr.accessibility-traits]="accessibilityLabel() ? 'button' : null"
|
|
272
|
-
[attr.accessibility-label]="accessibilityLabel() || null"
|
|
273
|
-
[lynxAccessibilityTraits]="{ selected: selectedTrait() }"
|
|
274
|
-
(bindtouchstart)="onPressStart($event)"
|
|
275
|
-
(bindtouchmove)="onPressMove($event)"
|
|
276
|
-
(bindtouchend)="onPressEnd()"
|
|
277
|
-
(bindtouchcancel)="onPressCancel()"
|
|
278
|
-
(bindmousedown)="onPressStart($event)"
|
|
279
|
-
(bindmousemove)="onPressMove($event)"
|
|
280
|
-
(bindmouseup)="onPressEnd()"
|
|
281
|
-
(bindmouseleave)="onPointerLeave()"
|
|
282
|
-
(bindtap)="onTap()"
|
|
283
|
-
>
|
|
284
|
-
@if (stateLayerClass(); as stateLayer) {
|
|
285
|
-
<view [class]="stateLayer"></view>
|
|
286
|
-
}
|
|
287
|
-
<ng-content />
|
|
288
|
-
</view>
|
|
280
|
+
@if (stateLayerClass(); as stateLayer) {
|
|
281
|
+
<view [class]="stateLayer"></view>
|
|
282
|
+
}
|
|
283
|
+
<ng-content />
|
|
289
284
|
`,
|
|
290
285
|
})
|
|
291
286
|
export class UiToggleGroupItem {
|
|
@@ -303,17 +298,27 @@ export class UiToggleGroupItem {
|
|
|
303
298
|
});
|
|
304
299
|
readonly userClass = input<string>('', { alias: 'class' });
|
|
305
300
|
|
|
306
|
-
|
|
307
|
-
readonly containerRef = viewChild.required<ElementRef>('container');
|
|
301
|
+
readonly #host: BaseLynxElement = inject(ElementRef).nativeElement;
|
|
308
302
|
#pressAnim?: AnimationHandle;
|
|
309
303
|
|
|
310
304
|
protected readonly isSelected = computed(() =>
|
|
311
305
|
this.#group.isSelected(this.itemValue()),
|
|
312
306
|
);
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* A labeled item is `selected` to a screen reader while pressed, which iOS
|
|
310
|
+
* reads. The trait is added to the host's own `button`, as
|
|
311
|
+
* lynxAccessibilityTraits does on an element in a template: on iOS it
|
|
312
|
+
* reads `button,selected`, and Android, which reads one trait, keeps
|
|
313
|
+
* `button`. Only a labeled item is an accessibility element to be
|
|
314
|
+
* `selected`.
|
|
315
|
+
*/
|
|
316
|
+
protected readonly markingSelected = effect(() => {
|
|
317
|
+
this.#host.setAccessibilityTrait(
|
|
318
|
+
'selected',
|
|
319
|
+
!!this.accessibilityLabel() && this.isSelected(),
|
|
320
|
+
);
|
|
321
|
+
});
|
|
317
322
|
|
|
318
323
|
/** Whether the item is a segment of a segmented control. */
|
|
319
324
|
readonly #segment = computed(() => this.#segmented.enabled());
|
|
@@ -327,20 +332,13 @@ export class UiToggleGroupItem {
|
|
|
327
332
|
this.#segment() ? this.#segmented.iconColor(this.itemValue()) : undefined,
|
|
328
333
|
);
|
|
329
334
|
|
|
330
|
-
/**
|
|
331
|
-
* A segment's host is the track's flex item, an equal share of it. Angular
|
|
332
|
-
* puts the app's static `class` on the host as well as on the item, so the
|
|
333
|
-
* two are merged here: `class="flex-[2]"` makes a wider segment.
|
|
334
|
-
*/
|
|
335
|
-
protected readonly hostClass = computed(() =>
|
|
336
|
-
this.#segment()
|
|
337
|
-
? cn(this.#segmented.segmentHostClass, this.userClass())
|
|
338
|
-
: '',
|
|
339
|
-
);
|
|
340
|
-
|
|
341
335
|
protected readonly containerClass = computed(() => {
|
|
342
336
|
if (this.#segment()) {
|
|
337
|
+
// The host is both the segment's slot in the track (an equal share of
|
|
338
|
+
// it) and its own view, so an app's `class="flex-[2]"` makes a wider
|
|
339
|
+
// segment.
|
|
343
340
|
return cn(
|
|
341
|
+
this.#segmented.segmentHostClass,
|
|
344
342
|
this.#segmented.segmentClass(
|
|
345
343
|
this.itemValue(),
|
|
346
344
|
segmentPlace(this.#group.items(), this),
|
|
@@ -384,7 +382,7 @@ export class UiToggleGroupItem {
|
|
|
384
382
|
return;
|
|
385
383
|
}
|
|
386
384
|
this.#pressAnim?.cancel();
|
|
387
|
-
this.#pressAnim = pressDown(this
|
|
385
|
+
this.#pressAnim = pressDown(this.#host);
|
|
388
386
|
}
|
|
389
387
|
|
|
390
388
|
protected onPressMove(event: unknown): void {
|
|
@@ -398,7 +396,7 @@ export class UiToggleGroupItem {
|
|
|
398
396
|
return;
|
|
399
397
|
}
|
|
400
398
|
this.#pressAnim?.cancel();
|
|
401
|
-
this.#pressAnim = pressRelease(this
|
|
399
|
+
this.#pressAnim = pressRelease(this.#host);
|
|
402
400
|
}
|
|
403
401
|
|
|
404
402
|
protected onPressCancel(): void {
|
|
@@ -428,6 +426,6 @@ export class UiToggleGroupItem {
|
|
|
428
426
|
|
|
429
427
|
#release(): void {
|
|
430
428
|
this.#pressAnim?.cancel();
|
|
431
|
-
this.#pressAnim = pressRelease(this
|
|
429
|
+
this.#pressAnim = pressRelease(this.#host);
|
|
432
430
|
}
|
|
433
431
|
}
|
|
@@ -22,6 +22,12 @@ class TypographyHost {
|
|
|
22
22
|
| 'muted' = 'p';
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
@Component({
|
|
26
|
+
imports: [UiText],
|
|
27
|
+
template: `<ui-text variant="muted" class="mb-4 text-center">Note</ui-text>`,
|
|
28
|
+
})
|
|
29
|
+
class StaticClassHost {}
|
|
30
|
+
|
|
25
31
|
describe('typographyVariants', () => {
|
|
26
32
|
it('returns default variant classes', () => {
|
|
27
33
|
expect(typographyVariants()).toContain('text-base');
|
|
@@ -52,4 +58,14 @@ describe('UiText', () => {
|
|
|
52
58
|
const { getByText } = await render(UiText);
|
|
53
59
|
expect(getByText).toBeTruthy();
|
|
54
60
|
});
|
|
61
|
+
|
|
62
|
+
// A class on a component lands on its host too (issue #181): the text is
|
|
63
|
+
// one <text>, so the class styles one element.
|
|
64
|
+
it('is one text, with the class written on it', async () => {
|
|
65
|
+
const { container, getByText } = await render(StaticClassHost);
|
|
66
|
+
const text = getByText('Note');
|
|
67
|
+
expect(text.tagName.toLowerCase()).toBe('text');
|
|
68
|
+
expect(text.classList).toContain('text-muted-foreground');
|
|
69
|
+
expect([...container.querySelectorAll('.mb-4')]).toEqual([text]);
|
|
70
|
+
});
|
|
55
71
|
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Component, ViewEncapsulation, computed, input } from '@angular/core';
|
|
2
|
-
import {
|
|
2
|
+
import { registerHostElement } from '@blotch/angular-lynx';
|
|
3
3
|
import { cva, type VariantProps } from 'class-variance-authority';
|
|
4
4
|
|
|
5
5
|
import { cn } from '../../utils/cn';
|
|
@@ -27,12 +27,18 @@ export type TypographyVariant = NonNullable<
|
|
|
27
27
|
VariantProps<typeof typographyVariants>['variant']
|
|
28
28
|
>;
|
|
29
29
|
|
|
30
|
+
/**
|
|
31
|
+
* The text is its host, a `<text>` (registerHostElement, below), styled
|
|
32
|
+
* through `host`. Angular gives a component's host every static attribute the
|
|
33
|
+
* app writes on it, so `<ui-text class="mb-4">` puts `mb-4` on the host
|
|
34
|
+
* whatever the component does: with a styled `<text>` inside a view host as
|
|
35
|
+
* well, the margin applied twice.
|
|
36
|
+
*/
|
|
30
37
|
@Component({
|
|
31
38
|
selector: 'ui-text',
|
|
32
|
-
standalone: true,
|
|
33
|
-
imports: [LYNX_ELEMENTS],
|
|
34
39
|
encapsulation: ViewEncapsulation.None,
|
|
35
|
-
|
|
40
|
+
host: { '[class]': 'textClass()' },
|
|
41
|
+
template: '<ng-content />',
|
|
36
42
|
})
|
|
37
43
|
export class UiText {
|
|
38
44
|
readonly variant = input<TypographyVariant>('p');
|
|
@@ -42,3 +48,5 @@ export class UiText {
|
|
|
42
48
|
cn(typographyVariants({ variant: this.variant() }), this.userClass()),
|
|
43
49
|
);
|
|
44
50
|
}
|
|
51
|
+
|
|
52
|
+
registerHostElement(UiText, 'text');
|