@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
|
@@ -1115,8 +1115,12 @@ export class UiDock implements SearchFieldContainer {
|
|
|
1115
1115
|
],
|
|
1116
1116
|
host: {
|
|
1117
1117
|
// The host is the tap target and takes an equal share of the bar's row,
|
|
1118
|
-
// which the selection capsule's width counts on.
|
|
1119
|
-
|
|
1118
|
+
// which the selection capsule's width counts on. It's also the item's
|
|
1119
|
+
// column, styled here rather than on a view inside it: Angular gives a
|
|
1120
|
+
// component's host every static attribute the app writes on it, so a
|
|
1121
|
+
// class on <ui-dock-item> lands on the host whatever the component does
|
|
1122
|
+
// with its `class` input, and a styled view inside applied it twice.
|
|
1123
|
+
'[class]': 'contentClass()',
|
|
1120
1124
|
// One accessibility element per item, named by its label, so a screen
|
|
1121
1125
|
// reader reads "Inbox, 3, button" instead of an unlabeled SVG and a word.
|
|
1122
1126
|
// LynxRouterLinkActive adds `selected` to `button` while the route is
|
|
@@ -1140,35 +1144,33 @@ export class UiDock implements SearchFieldContainer {
|
|
|
1140
1144
|
'(bindtap)': 'pressed.emit()',
|
|
1141
1145
|
},
|
|
1142
1146
|
template: `
|
|
1143
|
-
<view [class]="
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1147
|
+
<view [class]="iconFrameClass()">
|
|
1148
|
+
@if (design() === 'material') {
|
|
1149
|
+
<!-- Material's active indicator: a pill behind the icon that
|
|
1150
|
+
widens from its center as the item is selected. -->
|
|
1151
|
+
<view
|
|
1152
|
+
class="h-8 w-14 rounded-full bg-primary absolute left-0 top-0"
|
|
1153
|
+
[style]="indicatorStyle()"
|
|
1154
|
+
></view>
|
|
1155
|
+
}
|
|
1156
|
+
<view class="relative">
|
|
1157
|
+
<svg [attr.content]="iconSvg()" style="width: 24px; height: 24px;" />
|
|
1158
|
+
@if (badgeText()) {
|
|
1159
|
+
<!-- Hangs off the icon's top-right corner, as on a native tab
|
|
1160
|
+
bar. min-w-4 keeps a one-digit badge round; longer ones
|
|
1161
|
+
grow into a pill. -->
|
|
1148
1162
|
<view
|
|
1149
|
-
class="h-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
<svg [attr.content]="iconSvg()" style="width: 24px; height: 24px;" />
|
|
1155
|
-
@if (badgeText()) {
|
|
1156
|
-
<!-- Hangs off the icon's top-right corner, as on a native tab
|
|
1157
|
-
bar. min-w-4 keeps a one-digit badge round; longer ones
|
|
1158
|
-
grow into a pill. -->
|
|
1159
|
-
<view
|
|
1160
|
-
class="h-4 min-w-4 items-center rounded-full bg-destructive px-1 flex absolute -right-2.5 -top-1 justify-center"
|
|
1163
|
+
class="h-4 min-w-4 items-center rounded-full bg-destructive px-1 flex absolute -right-2.5 -top-1 justify-center"
|
|
1164
|
+
>
|
|
1165
|
+
<text
|
|
1166
|
+
class="text-[10px] font-semibold leading-4 text-destructive-foreground"
|
|
1167
|
+
>{{ badgeText() }}</text
|
|
1161
1168
|
>
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
>{{ badgeText() }}</text
|
|
1165
|
-
>
|
|
1166
|
-
</view>
|
|
1167
|
-
}
|
|
1168
|
-
</view>
|
|
1169
|
+
</view>
|
|
1170
|
+
}
|
|
1169
1171
|
</view>
|
|
1170
|
-
<text [class]="labelClass()">{{ label() }}</text>
|
|
1171
1172
|
</view>
|
|
1173
|
+
<text [class]="labelClass()">{{ label() }}</text>
|
|
1172
1174
|
`,
|
|
1173
1175
|
})
|
|
1174
1176
|
export class UiDockItem {
|
|
@@ -33,6 +33,15 @@ describe('UiEmptyState', () => {
|
|
|
33
33
|
expect(container.querySelector('.py-6')).toBeTruthy();
|
|
34
34
|
});
|
|
35
35
|
|
|
36
|
+
// Static attributes land on a component's host too (issue #181): the
|
|
37
|
+
// padding once, on the empty state itself, and the title only as its text.
|
|
38
|
+
it('puts its class on one element, and its title only in its text', async () => {
|
|
39
|
+
const { container, queryAllByTitle } = await render(EmptyStateHost);
|
|
40
|
+
expect(container.querySelectorAll('.py-6')).toHaveLength(1);
|
|
41
|
+
expect(container.querySelector('.py-6')?.classList).toContain('flex-col');
|
|
42
|
+
expect(queryAllByTitle('No results')).toEqual([]);
|
|
43
|
+
});
|
|
44
|
+
|
|
36
45
|
it('adds the icon and description when they are set', async () => {
|
|
37
46
|
const { componentRef, container, getByText } = await render(EmptyStateHost);
|
|
38
47
|
const host = componentRef.instance as EmptyStateHost;
|
|
@@ -5,22 +5,31 @@ import { cn } from '../../utils/cn';
|
|
|
5
5
|
import { type IconName } from '../icon/icons';
|
|
6
6
|
import { UiIcon } from '../icon/icon';
|
|
7
7
|
|
|
8
|
+
/**
|
|
9
|
+
* The empty state is its host, styled through `host`. Angular gives a
|
|
10
|
+
* component's host every static attribute the app writes on it, so a class on
|
|
11
|
+
* <ui-empty-state> lands on the host whatever the component does: with a
|
|
12
|
+
* styled view inside as well, the padding applied twice. Its title lands
|
|
13
|
+
* there too, and goes: it's shown in a <text>, and on the web preview a
|
|
14
|
+
* `title` attribute is a tooltip (and a second match for getByTitle).
|
|
15
|
+
*/
|
|
8
16
|
@Component({
|
|
9
17
|
selector: 'ui-empty-state',
|
|
10
|
-
standalone: true,
|
|
11
18
|
imports: [LYNX_ELEMENTS, UiIcon],
|
|
12
19
|
encapsulation: ViewEncapsulation.None,
|
|
20
|
+
host: {
|
|
21
|
+
'[class]': 'containerClass()',
|
|
22
|
+
'[attr.title]': 'null',
|
|
23
|
+
},
|
|
13
24
|
template: `
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
<ng-content />
|
|
23
|
-
</view>
|
|
25
|
+
@if (icon()) {
|
|
26
|
+
<ui-icon [name]="icon()!" size="lg" class="mb-3" />
|
|
27
|
+
}
|
|
28
|
+
<text [class]="titleClass()">{{ title() }}</text>
|
|
29
|
+
@if (description()) {
|
|
30
|
+
<text [class]="descriptionClass()">{{ description() }}</text>
|
|
31
|
+
}
|
|
32
|
+
<ng-content />
|
|
24
33
|
`,
|
|
25
34
|
})
|
|
26
35
|
export class UiEmptyState {
|
|
@@ -105,6 +105,23 @@ describe('UiIcon', () => {
|
|
|
105
105
|
});
|
|
106
106
|
});
|
|
107
107
|
|
|
108
|
+
@Component({
|
|
109
|
+
imports: [UiIcon],
|
|
110
|
+
template: `<ui-icon name="search" class="mb-3" />`,
|
|
111
|
+
})
|
|
112
|
+
class StaticClassHost {}
|
|
113
|
+
|
|
114
|
+
describe('UiIcon on its own', () => {
|
|
115
|
+
// A class on a component lands on its host too (issue #181): the host is
|
|
116
|
+
// the <svg>, so the margin applies once.
|
|
117
|
+
it('is one svg, with the class written on it', async () => {
|
|
118
|
+
const { container } = await render(StaticClassHost);
|
|
119
|
+
const svgs = [...container.querySelectorAll('svg')];
|
|
120
|
+
expect(svgs).toHaveLength(1);
|
|
121
|
+
expect([...container.querySelectorAll('.mb-3')]).toEqual(svgs);
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
108
125
|
describe('ICONS registry', () => {
|
|
109
126
|
it('exports at least one icon', () => {
|
|
110
127
|
expect(Object.keys(ICONS).length).toBeGreaterThan(0);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { Component, ViewEncapsulation, input } from '@angular/core';
|
|
2
|
-
import {
|
|
1
|
+
import { Component, ViewEncapsulation, computed, input } from '@angular/core';
|
|
2
|
+
import { registerHostElement } from '@blotch/angular-lynx';
|
|
3
3
|
|
|
4
4
|
import { cn } from '../../utils/cn';
|
|
5
5
|
import { injectIconColor } from './icon-theme';
|
|
@@ -8,6 +8,12 @@ import { type IconName, ICONS } from './icons';
|
|
|
8
8
|
const SIZE_MAP = { xs: 16, sm: 20, md: 24, lg: 32 } as const;
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
|
+
* The icon is its host, an `<svg>` (registerHostElement, below). Angular gives
|
|
12
|
+
* a component's host every static attribute the app writes on it, so
|
|
13
|
+
* `<ui-icon class="mb-3">` puts `mb-3` on the host whatever the component
|
|
14
|
+
* does: with a styled `<svg>` inside a view host as well, the margin applied
|
|
15
|
+
* twice.
|
|
16
|
+
*
|
|
11
17
|
* SVG markup is passed via [attr.content] rather than as child elements.
|
|
12
18
|
* Lynx's native <svg> element can't contain child nodes in the flat element
|
|
13
19
|
* model. Passing the full SVG string as the `content` attribute is how Lynx
|
|
@@ -15,15 +21,14 @@ const SIZE_MAP = { xs: 16, sm: 20, md: 24, lg: 32 } as const;
|
|
|
15
21
|
*/
|
|
16
22
|
@Component({
|
|
17
23
|
selector: 'ui-icon',
|
|
18
|
-
imports: [LYNX_ELEMENTS],
|
|
19
24
|
encapsulation: ViewEncapsulation.None,
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
25
|
+
host: {
|
|
26
|
+
'[attr.content]': 'svgContent()',
|
|
27
|
+
'[style.width.px]': 'sizePx()',
|
|
28
|
+
'[style.height.px]': 'sizePx()',
|
|
29
|
+
'[class]': 'svgClass()',
|
|
30
|
+
},
|
|
31
|
+
template: '',
|
|
27
32
|
})
|
|
28
33
|
export class UiIcon {
|
|
29
34
|
readonly name = input.required<IconName>();
|
|
@@ -60,12 +65,9 @@ export class UiIcon {
|
|
|
60
65
|
.join(this.#resolveColor(this.color()));
|
|
61
66
|
}
|
|
62
67
|
|
|
63
|
-
protected
|
|
64
|
-
const px = SIZE_MAP[this.size()];
|
|
65
|
-
return `width: ${px}px; height: ${px}px;`;
|
|
66
|
-
}
|
|
68
|
+
protected readonly sizePx = computed(() => SIZE_MAP[this.size()]);
|
|
67
69
|
|
|
68
|
-
protected svgClass()
|
|
69
|
-
return cn(this.userClass());
|
|
70
|
-
}
|
|
70
|
+
protected readonly svgClass = computed(() => cn(this.userClass()));
|
|
71
71
|
}
|
|
72
|
+
|
|
73
|
+
registerHostElement(UiIcon, 'svg');
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Component, signal } from '@angular/core';
|
|
2
|
+
import { FormField, form } from '@angular/forms/signals';
|
|
2
3
|
import {
|
|
3
4
|
fireEvent,
|
|
4
5
|
render,
|
|
@@ -34,6 +35,7 @@ import { UiInput } from './input';
|
|
|
34
35
|
(changed)="changes.push($event)"
|
|
35
36
|
(focused)="events.push('focused')"
|
|
36
37
|
(blurred)="events.push('blurred')"
|
|
38
|
+
(touch)="events.push('touch')"
|
|
37
39
|
/>
|
|
38
40
|
`,
|
|
39
41
|
})
|
|
@@ -53,7 +55,7 @@ const renderInput = async () => {
|
|
|
53
55
|
...result,
|
|
54
56
|
host: result.componentRef.instance as InputHost,
|
|
55
57
|
field: () => result.container.querySelector('input')!,
|
|
56
|
-
// The field's
|
|
58
|
+
// The field's column: its host.
|
|
57
59
|
column: () => result.container.querySelector('.flex-col')!,
|
|
58
60
|
wrapper: () => result.container.querySelector('.bg-muted')!,
|
|
59
61
|
// The layer that draws the focus ring around the field.
|
|
@@ -82,6 +84,15 @@ describe('UiInput', () => {
|
|
|
82
84
|
expect(queryByText('you@example.com')).toBeNull();
|
|
83
85
|
});
|
|
84
86
|
|
|
87
|
+
// Angular gives a component's host every static attribute written on it
|
|
88
|
+
// (issue #181): the class styled the column twice, and the placeholder made
|
|
89
|
+
// a second match for getByPlaceholderText.
|
|
90
|
+
it('puts its class and placeholder on one element each', async () => {
|
|
91
|
+
const { container, field, getAllByPlaceholderText } = await renderInput();
|
|
92
|
+
expect(container.querySelectorAll('.w-full')).toHaveLength(1);
|
|
93
|
+
expect(getAllByPlaceholderText('you@example.com')).toEqual([field()]);
|
|
94
|
+
});
|
|
95
|
+
|
|
85
96
|
it('shows its label', async () => {
|
|
86
97
|
const { host, getByText } = await renderInput();
|
|
87
98
|
host.label.set('Email');
|
|
@@ -89,6 +100,23 @@ describe('UiInput', () => {
|
|
|
89
100
|
expect(getByText('Email').classList).toContain('font-medium');
|
|
90
101
|
});
|
|
91
102
|
|
|
103
|
+
// Issue #180: the label sat beside the field, so screen readers read the
|
|
104
|
+
// placeholder, or nothing once the field held text.
|
|
105
|
+
it('names the native field after its label, for screen readers', async () => {
|
|
106
|
+
const { host, field, getByRole, getByText } = await renderInput();
|
|
107
|
+
// A screen reader reaches the field with or without a label.
|
|
108
|
+
expect(field().getAttribute('accessibility-element')).toBe('true');
|
|
109
|
+
expect(field().hasAttribute('accessibility-label')).toBe(false);
|
|
110
|
+
|
|
111
|
+
host.label.set('Email');
|
|
112
|
+
await waitForUpdate();
|
|
113
|
+
expect(getByRole('textbox', { name: 'Email' })).toBe(field());
|
|
114
|
+
// The label the field reads isn't a second stop of its own.
|
|
115
|
+
expect(
|
|
116
|
+
getByText('Email').getAttribute('accessibility-elements-hidden'),
|
|
117
|
+
).toBe('true');
|
|
118
|
+
});
|
|
119
|
+
|
|
92
120
|
it('updates `value` and emits `changed` as the user types', async () => {
|
|
93
121
|
const { host, field } = await renderInput();
|
|
94
122
|
// Lynx puts the typed text in the event's detail, not on its target.
|
|
@@ -126,7 +154,7 @@ describe('UiInput', () => {
|
|
|
126
154
|
setAttribute.mockRestore();
|
|
127
155
|
});
|
|
128
156
|
|
|
129
|
-
it('shows a focus ring while focused, and emits `focused` and `
|
|
157
|
+
it('shows a focus ring while focused, and emits `focused`, `blurred` and `touch`', async () => {
|
|
130
158
|
const { host, field, ring } = await renderInput();
|
|
131
159
|
expect(ring().style.getPropertyValue('opacity')).toBe('0');
|
|
132
160
|
|
|
@@ -140,7 +168,7 @@ describe('UiInput', () => {
|
|
|
140
168
|
fireEvent.blur(field());
|
|
141
169
|
await waitForUpdate();
|
|
142
170
|
expect(ring().style.getPropertyValue('opacity')).toBe('0');
|
|
143
|
-
expect(host.events).toEqual(['focused', 'blurred']);
|
|
171
|
+
expect(host.events).toEqual(['focused', 'blurred', 'touch']);
|
|
144
172
|
});
|
|
145
173
|
|
|
146
174
|
it('shows its helper text', async () => {
|
|
@@ -200,6 +228,30 @@ describe('UiInput', () => {
|
|
|
200
228
|
]);
|
|
201
229
|
});
|
|
202
230
|
|
|
231
|
+
// Issue #180: without `touch`, a field's error could only show once
|
|
232
|
+
// submit() marked every field touched.
|
|
233
|
+
it('marks a Signal Forms field touched when it loses focus', async () => {
|
|
234
|
+
@Component({
|
|
235
|
+
imports: [FormField, UiInput],
|
|
236
|
+
template: `<ui-input label="Email" [formField]="signIn.email" />`,
|
|
237
|
+
})
|
|
238
|
+
class SignalHost {
|
|
239
|
+
readonly model = signal({ email: '' });
|
|
240
|
+
readonly signIn = form(this.model);
|
|
241
|
+
}
|
|
242
|
+
const { componentRef, getByRole } = await render(SignalHost);
|
|
243
|
+
const host = componentRef.instance as SignalHost;
|
|
244
|
+
const email = getByRole('textbox', { name: 'Email' });
|
|
245
|
+
|
|
246
|
+
fireEvent.focus(email);
|
|
247
|
+
fireEvent.input(email, { detail: { value: 'jane@example.com' } });
|
|
248
|
+
expect(host.model().email).toBe('jane@example.com');
|
|
249
|
+
expect(host.signIn.email().touched()).toBe(false);
|
|
250
|
+
|
|
251
|
+
fireEvent.blur(email);
|
|
252
|
+
expect(host.signIn.email().touched()).toBe(true);
|
|
253
|
+
});
|
|
254
|
+
|
|
203
255
|
it('makes the wrapper a stacking context so the field scrolls with it', async () => {
|
|
204
256
|
// On Lynx a z-indexed element inside a scroll-view only follows the scroll
|
|
205
257
|
// when an ancestor inside the scroll content is a stacking context. Without
|
|
@@ -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,69 +19,88 @@ import { cn } from '../../utils/cn';
|
|
|
19
19
|
|
|
20
20
|
@Component({
|
|
21
21
|
selector: 'ui-input',
|
|
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-input> 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
|
+
// <input>, 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
|
-
|
|
35
|
+
<!-- The <input> below carries the label as its accessibility-label, so
|
|
36
|
+
screen readers read it with the field. Hidden here, or VoiceOver and
|
|
37
|
+
TalkBack would stop on it a second time. -->
|
|
38
|
+
@if (label()) {
|
|
39
|
+
<text [class]="labelClass()" accessibility-elements-hidden="true">{{
|
|
40
|
+
label()
|
|
41
|
+
}}</text>
|
|
42
|
+
}
|
|
43
|
+
<!-- Focus ring is an inline box-shadow on a dedicated overlay layer, not a
|
|
44
|
+
Tailwind ring-* class (the Lynx preset doesn't wire up --tw-ring-*, so
|
|
45
|
+
ring-* renders nothing on device). It rides its OWN layer so it can
|
|
46
|
+
fade: box-shadow is animatable:no on Lynx (the core rejects a
|
|
47
|
+
transition: box-shadow), but opacity is animatable, so we transition
|
|
48
|
+
the layer's opacity instead. The overlay is the FIRST child so Lynx,
|
|
49
|
+
which paints in strict source order, keeps the <input> on top and
|
|
50
|
+
tappable. Source order alone is NOT enough on web: CSS paints a
|
|
51
|
+
positioned (absolute) sibling above a static one regardless of source
|
|
52
|
+
order, so this transparent overlay would swallow every tap — no focus,
|
|
53
|
+
no typing. The <input> below therefore carries position:relative +
|
|
54
|
+
z-index:1 so it sits above the overlay on BOTH platforms (required on
|
|
55
|
+
web). On Lynx, though, a z-indexed element is stacked against its
|
|
56
|
+
nearest stacking context, and a scroll-view isn't one unless it sets
|
|
57
|
+
z-index itself — so the native input would stay fixed on screen while
|
|
58
|
+
the page scrolled (only its placeholder visibly floats). The wrapper's
|
|
59
|
+
z-0 makes it that stacking context, keeping the input's z-index local
|
|
60
|
+
so it scrolls with the field. pointer-events is avoided — it errors
|
|
61
|
+
the Lynx build. The shadow is OUTSET only: Lynx doesn't render inset box-shadows,
|
|
62
|
+
so the crisp 1px "border" is an outset ring hugging the edge, not a
|
|
63
|
+
real inset border. -->
|
|
64
|
+
<view [class]="inputWrapperClass()">
|
|
65
|
+
<view
|
|
66
|
+
class="rounded-xl opacity-0 absolute bottom-0 left-0 right-0 top-0"
|
|
67
|
+
[style.transition]="'opacity 150ms ease'"
|
|
68
|
+
[style.box-shadow]="ringShadow()"
|
|
69
|
+
[style.opacity]="ringVisible() ? 1 : 0"
|
|
70
|
+
></view>
|
|
71
|
+
<!-- [attr.disabled]="disabled() || undefined": passing 'undefined'
|
|
72
|
+
removes the attribute entirely; passing 'false' would set
|
|
73
|
+
disabled="false" which Lynx still treats as disabled. -->
|
|
74
|
+
<!-- [value] not [attr.value]: [attr.value] calls renderer.setAttribute()
|
|
75
|
+
which routes to __SetAttribute — a no-op for live text on Lynx native
|
|
76
|
+
inputs. [value] is an Angular input binding that triggers
|
|
77
|
+
LynxInput.ngOnChanges, where the setValue UIMethod override lives.
|
|
78
|
+
Without this, form model resets would update the signal but the
|
|
79
|
+
native input would keep showing the user's old typed text. -->
|
|
80
|
+
<!-- accessibility-element: on iOS, Lynx makes only <text> and <image>
|
|
81
|
+
accessibility elements by default, so the UITextField under an
|
|
82
|
+
<input> isn't one. VoiceOver then read the placeholder inside it,
|
|
83
|
+
or nothing once it held text. As one, it reads its
|
|
84
|
+
accessibility-label, the label, then its text. -->
|
|
85
|
+
<input
|
|
86
|
+
accessibility-element="true"
|
|
87
|
+
[attr.accessibility-label]="label() || null"
|
|
88
|
+
[attr.placeholder]="placeholder()"
|
|
89
|
+
[attr.type]="type()"
|
|
90
|
+
[value]="value()"
|
|
91
|
+
[attr.disabled]="disabled() || undefined"
|
|
92
|
+
class="text-sm text-foreground"
|
|
93
|
+
style="border: none; background: transparent; height: 100%; width: 100%; position: relative; z-index: 1;"
|
|
94
|
+
(bindinput)="onInput($any($event))"
|
|
95
|
+
(bindfocus)="onFocus()"
|
|
96
|
+
(bindblur)="onBlur()"
|
|
97
|
+
/>
|
|
84
98
|
</view>
|
|
99
|
+
@if (error()) {
|
|
100
|
+
<text [class]="errorClass()">{{ error() }}</text>
|
|
101
|
+
} @else if (helperText()) {
|
|
102
|
+
<text [class]="helperClass()">{{ helperText() }}</text>
|
|
103
|
+
}
|
|
85
104
|
`,
|
|
86
105
|
})
|
|
87
106
|
export class UiInput implements FormValueControl<string> {
|
|
@@ -97,8 +116,15 @@ export class UiInput implements FormValueControl<string> {
|
|
|
97
116
|
readonly changed = output<string>();
|
|
98
117
|
readonly focused = output<void>();
|
|
99
118
|
readonly blurred = output<void>();
|
|
119
|
+
/**
|
|
120
|
+
* The field lost focus, for forms, which mark it as touched as they do a
|
|
121
|
+
* native input (FormUiControl's `touch`). Signal Forms, reactive forms and
|
|
122
|
+
* template-driven forms all listen for it; a `touched` model's change
|
|
123
|
+
* isn't read by any of them.
|
|
124
|
+
*/
|
|
125
|
+
readonly touch = output<void>();
|
|
100
126
|
|
|
101
|
-
readonly
|
|
127
|
+
readonly #host = inject(ElementRef).nativeElement;
|
|
102
128
|
readonly #isFocused = signal(false);
|
|
103
129
|
#shakeAnim?: AnimationHandle;
|
|
104
130
|
#previousError = '';
|
|
@@ -111,10 +137,9 @@ export class UiInput implements FormValueControl<string> {
|
|
|
111
137
|
*/
|
|
112
138
|
protected readonly shakeOnError = effect(() => {
|
|
113
139
|
const err = this.error();
|
|
114
|
-
|
|
115
|
-
if (err && !this.#previousError && el) {
|
|
140
|
+
if (err && !this.#previousError) {
|
|
116
141
|
this.#shakeAnim?.cancel();
|
|
117
|
-
this.#shakeAnim = shake(
|
|
142
|
+
this.#shakeAnim = shake(this.#host);
|
|
118
143
|
}
|
|
119
144
|
this.#previousError = err;
|
|
120
145
|
});
|
|
@@ -179,6 +204,7 @@ export class UiInput implements FormValueControl<string> {
|
|
|
179
204
|
protected onBlur(): void {
|
|
180
205
|
this.#isFocused.set(false);
|
|
181
206
|
this.blurred.emit();
|
|
207
|
+
this.touch.emit();
|
|
182
208
|
}
|
|
183
209
|
|
|
184
210
|
/**
|
|
@@ -11,6 +11,12 @@ class LabelHost {
|
|
|
11
11
|
readonly disabled = signal(false);
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
+
@Component({
|
|
15
|
+
imports: [UiLabel],
|
|
16
|
+
template: `<ui-label class="mb-2">Email</ui-label>`,
|
|
17
|
+
})
|
|
18
|
+
class StaticClassHost {}
|
|
19
|
+
|
|
14
20
|
describe('UiLabel', () => {
|
|
15
21
|
it('renders its text, dimmed while disabled', async () => {
|
|
16
22
|
const { componentRef, getByText } = await render(LabelHost);
|
|
@@ -22,4 +28,13 @@ describe('UiLabel', () => {
|
|
|
22
28
|
await waitForUpdate();
|
|
23
29
|
expect(label.classList).toContain('opacity-50');
|
|
24
30
|
});
|
|
31
|
+
|
|
32
|
+
// A class on a component lands on its host too (issue #181): the label is
|
|
33
|
+
// one <text>, so the class styles one element.
|
|
34
|
+
it('is one text, with the class written on it', async () => {
|
|
35
|
+
const { container, getByText } = await render(StaticClassHost);
|
|
36
|
+
const label = getByText('Email');
|
|
37
|
+
expect(label.tagName.toLowerCase()).toBe('text');
|
|
38
|
+
expect([...container.querySelectorAll('.mb-2')]).toEqual([label]);
|
|
39
|
+
});
|
|
25
40
|
});
|
|
@@ -5,16 +5,22 @@ import {
|
|
|
5
5
|
computed,
|
|
6
6
|
input,
|
|
7
7
|
} from '@angular/core';
|
|
8
|
-
import {
|
|
8
|
+
import { registerHostElement } from '@blotch/angular-lynx';
|
|
9
9
|
|
|
10
10
|
import { cn } from '../../utils/cn';
|
|
11
11
|
|
|
12
|
+
/**
|
|
13
|
+
* The label is its host, a `<text>` (registerHostElement, below), styled
|
|
14
|
+
* through `host`. Angular gives a component's host every static attribute the
|
|
15
|
+
* app writes on it, so `<ui-label class="mb-2">` puts `mb-2` on the host
|
|
16
|
+
* whatever the component does: with a styled `<text>` inside a view host as
|
|
17
|
+
* well, the margin applied twice.
|
|
18
|
+
*/
|
|
12
19
|
@Component({
|
|
13
20
|
selector: 'ui-label',
|
|
14
|
-
standalone: true,
|
|
15
|
-
imports: [LYNX_ELEMENTS],
|
|
16
21
|
encapsulation: ViewEncapsulation.None,
|
|
17
|
-
|
|
22
|
+
host: { '[class]': 'labelClass()' },
|
|
23
|
+
template: '<ng-content />',
|
|
18
24
|
})
|
|
19
25
|
export class UiLabel {
|
|
20
26
|
readonly disabled = input(false, { transform: booleanAttribute });
|
|
@@ -28,3 +34,5 @@ export class UiLabel {
|
|
|
28
34
|
),
|
|
29
35
|
);
|
|
30
36
|
}
|
|
37
|
+
|
|
38
|
+
registerHostElement(UiLabel, 'text');
|
|
@@ -45,6 +45,8 @@ const renderList = async () => {
|
|
|
45
45
|
...result,
|
|
46
46
|
host: result.componentRef.instance as ListHost,
|
|
47
47
|
list: () => result.container.querySelector('list')!,
|
|
48
|
+
/** The list's box, its host, which the <list> fills. */
|
|
49
|
+
box: () => result.container.querySelector('list')!.parentElement!,
|
|
48
50
|
};
|
|
49
51
|
};
|
|
50
52
|
|
|
@@ -65,12 +67,20 @@ describe('UiList', () => {
|
|
|
65
67
|
// Lynx `<list>` is virtualized: items join the native tree only through
|
|
66
68
|
// its componentAtIndex callback, which a unit test doesn't drive. So this
|
|
67
69
|
// checks the list itself rather than the projected item.
|
|
68
|
-
const { list } = await renderList();
|
|
70
|
+
const { list, box } = await renderList();
|
|
69
71
|
expect(list().getAttribute('list-type')).toBe('single');
|
|
70
72
|
expect(list().getAttribute('span-count')).toBe('1');
|
|
71
73
|
expect(list().getAttribute('scroll-orientation')).toBe('vertical');
|
|
72
|
-
expect(list().classList).toContain('
|
|
73
|
-
expect(
|
|
74
|
+
expect(list().classList).toContain('grow');
|
|
75
|
+
expect(box().classList).toContain('w-full');
|
|
76
|
+
expect(box().classList).toContain('bg-card');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// Angular gives a component's host every static attribute written on it,
|
|
80
|
+
// so the <list> taking the class as well applied it twice (issue #181).
|
|
81
|
+
it('puts its class on one element, its box', async () => {
|
|
82
|
+
const { container, box } = await renderList();
|
|
83
|
+
expect([...container.querySelectorAll('.bg-card')]).toEqual([box()]);
|
|
74
84
|
});
|
|
75
85
|
|
|
76
86
|
it('gives the list an explicit height, which it needs to scroll', async () => {
|