@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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
|
-
type ElementRef,
|
|
3
2
|
Component,
|
|
3
|
+
ElementRef,
|
|
4
4
|
ViewEncapsulation,
|
|
5
5
|
computed,
|
|
6
6
|
contentChildren,
|
|
@@ -10,7 +10,6 @@ import {
|
|
|
10
10
|
model,
|
|
11
11
|
output,
|
|
12
12
|
signal,
|
|
13
|
-
viewChild,
|
|
14
13
|
} from '@angular/core';
|
|
15
14
|
import { LYNX_ELEMENTS, LynxAccessibilitySettings } from '@blotch/angular-lynx';
|
|
16
15
|
|
|
@@ -53,13 +52,16 @@ const tabsListHost = (): SegmentedHost => inject(UiTabsList).segmentedHost;
|
|
|
53
52
|
|
|
54
53
|
@Component({
|
|
55
54
|
selector: 'ui-tabs',
|
|
56
|
-
imports: [LYNX_ELEMENTS],
|
|
57
55
|
encapsulation: ViewEncapsulation.None,
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
56
|
+
// Each part is its host element, styled through `host`, rather than a view
|
|
57
|
+
// inside the host. Angular gives a component's host every static attribute
|
|
58
|
+
// the app writes on it, so `<ui-tabs-content class="p-4">` puts `p-4` on
|
|
59
|
+
// the host whatever the component does with its `class` input: with a
|
|
60
|
+
// styled view inside as well, the padding applied twice. On the host alone,
|
|
61
|
+
// a class also acts where the part sits in its parent's layout (`flex-1`,
|
|
62
|
+
// margins).
|
|
63
|
+
host: { '[class]': 'containerClass()' },
|
|
64
|
+
template: '<ng-content />',
|
|
63
65
|
})
|
|
64
66
|
export class UiTabs {
|
|
65
67
|
readonly value = model<string>('');
|
|
@@ -139,7 +141,7 @@ export class UiTabs {
|
|
|
139
141
|
*/
|
|
140
142
|
@Component({
|
|
141
143
|
selector: 'ui-tabs-list',
|
|
142
|
-
imports: [
|
|
144
|
+
imports: [UiSegmentedIndicator],
|
|
143
145
|
encapsulation: ViewEncapsulation.None,
|
|
144
146
|
providers: [
|
|
145
147
|
UiSegmentedControl,
|
|
@@ -147,17 +149,16 @@ export class UiTabs {
|
|
|
147
149
|
],
|
|
148
150
|
// The mouse leaving the row lets go of an indicator dragged in the web
|
|
149
151
|
// preview, where a drag gets no touchcancel.
|
|
152
|
+
host: {
|
|
153
|
+
'accessibility-traits': 'tabbar',
|
|
154
|
+
'[class]': 'listClass()',
|
|
155
|
+
'(bindmouseleave)': 'segmentedControl.cancel()',
|
|
156
|
+
},
|
|
150
157
|
template: `
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
>
|
|
156
|
-
@if (segmentedControl.underlay()) {
|
|
157
|
-
<ui-segmented-indicator />
|
|
158
|
-
}
|
|
159
|
-
<ng-content />
|
|
160
|
-
</view>
|
|
158
|
+
@if (segmentedControl.underlay()) {
|
|
159
|
+
<ui-segmented-indicator />
|
|
160
|
+
}
|
|
161
|
+
<ng-content />
|
|
161
162
|
`,
|
|
162
163
|
})
|
|
163
164
|
export class UiTabsList {
|
|
@@ -208,9 +209,19 @@ export class UiTabsList {
|
|
|
208
209
|
imports: [LYNX_ELEMENTS],
|
|
209
210
|
encapsulation: ViewEncapsulation.None,
|
|
210
211
|
host: {
|
|
211
|
-
'[class]': '
|
|
212
|
+
'[class]': 'triggerClass()',
|
|
213
|
+
'[style]': 'triggerStyle()',
|
|
212
214
|
// The segmented control places its indicator from each tab's width.
|
|
213
215
|
'(bindlayoutchange)': 'measure($event)',
|
|
216
|
+
'(bindtap)': 'onTap()',
|
|
217
|
+
'(bindtouchstart)': 'press($event)',
|
|
218
|
+
'(bindtouchmove)': 'move($event)',
|
|
219
|
+
'(bindtouchend)': 'release()',
|
|
220
|
+
'(bindtouchcancel)': 'cancel()',
|
|
221
|
+
'(bindmousedown)': 'press($event)',
|
|
222
|
+
'(bindmousemove)': 'move($event)',
|
|
223
|
+
'(bindmouseup)': 'release()',
|
|
224
|
+
'(bindmouseleave)': 'leave()',
|
|
214
225
|
},
|
|
215
226
|
// The label is the accessibility element, a button (a tab, in the list's
|
|
216
227
|
// tab bar) named by its own text. A view would need a label of its own.
|
|
@@ -218,30 +229,15 @@ export class UiTabsList {
|
|
|
218
229
|
// lynxAccessibilityTraits adds the trait to `button` on iOS, and leaves
|
|
219
230
|
// Android, which reads one trait, with `button`.
|
|
220
231
|
template: `
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
(bindmousedown)="press($event)"
|
|
231
|
-
(bindmousemove)="move($event)"
|
|
232
|
-
(bindmouseup)="release()"
|
|
233
|
-
(bindmouseleave)="leave()"
|
|
234
|
-
>
|
|
235
|
-
@if (stateLayerClass(); as stateLayer) {
|
|
236
|
-
<view [class]="stateLayer"></view>
|
|
237
|
-
}
|
|
238
|
-
<text
|
|
239
|
-
accessibility-traits="button"
|
|
240
|
-
[lynxAccessibilityTraits]="{ selected: isActive() }"
|
|
241
|
-
[class]="textClass()"
|
|
242
|
-
><ng-content
|
|
243
|
-
/></text>
|
|
244
|
-
</view>
|
|
232
|
+
@if (stateLayerClass(); as stateLayer) {
|
|
233
|
+
<view [class]="stateLayer"></view>
|
|
234
|
+
}
|
|
235
|
+
<text
|
|
236
|
+
accessibility-traits="button"
|
|
237
|
+
[lynxAccessibilityTraits]="{ selected: isActive() }"
|
|
238
|
+
[class]="textClass()"
|
|
239
|
+
><ng-content
|
|
240
|
+
/></text>
|
|
245
241
|
`,
|
|
246
242
|
})
|
|
247
243
|
export class UiTabsTrigger {
|
|
@@ -254,7 +250,7 @@ export class UiTabsTrigger {
|
|
|
254
250
|
readonly triggerValue = input.required<string>({ alias: 'value' });
|
|
255
251
|
readonly userClass = input<string>('', { alias: 'class' });
|
|
256
252
|
|
|
257
|
-
readonly
|
|
253
|
+
readonly #host = inject(ElementRef).nativeElement;
|
|
258
254
|
#anim?: AnimationHandle;
|
|
259
255
|
#previousActive?: boolean;
|
|
260
256
|
|
|
@@ -270,16 +266,6 @@ export class UiTabsTrigger {
|
|
|
270
266
|
this.#segmented?.enabled() ? this.#segmented : null,
|
|
271
267
|
);
|
|
272
268
|
|
|
273
|
-
/**
|
|
274
|
-
* A segment's host is the track's flex item, an equal share of it. Angular
|
|
275
|
-
* puts the app's static `class` on the host as well as on the trigger, so
|
|
276
|
-
* the two are merged here: `class="flex-[2]"` makes a wider tab.
|
|
277
|
-
*/
|
|
278
|
-
protected readonly hostClass = computed(() => {
|
|
279
|
-
const segment = this.#segment();
|
|
280
|
-
return segment ? cn(segment.segmentHostClass, this.userClass()) : '';
|
|
281
|
-
});
|
|
282
|
-
|
|
283
269
|
protected readonly triggerStyle = computed(
|
|
284
270
|
() => this.#segment()?.segmentStyle(this.triggerValue()) ?? null,
|
|
285
271
|
);
|
|
@@ -296,7 +282,7 @@ export class UiTabsTrigger {
|
|
|
296
282
|
/** Animates the trigger's background when it's selected or deselected. */
|
|
297
283
|
protected readonly animatingSelection = effect(() => {
|
|
298
284
|
const active = this.isActive();
|
|
299
|
-
const el = this
|
|
285
|
+
const el = this.#host;
|
|
300
286
|
const wasActive = this.#previousActive;
|
|
301
287
|
this.#previousActive = active;
|
|
302
288
|
// The first run only records the starting state, and only a change has
|
|
@@ -348,8 +334,12 @@ export class UiTabsTrigger {
|
|
|
348
334
|
const segment = this.#segment();
|
|
349
335
|
if (segment) {
|
|
350
336
|
// The list provides the segmented control, so a segment is in one.
|
|
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
|
+
// tab.
|
|
351
340
|
const place = segmentPlace(this.#list!.triggers(), this);
|
|
352
341
|
return cn(
|
|
342
|
+
segment.segmentHostClass,
|
|
353
343
|
segment.segmentClass(this.triggerValue(), place),
|
|
354
344
|
this.userClass(),
|
|
355
345
|
);
|
|
@@ -420,34 +410,28 @@ export class UiTabsTrigger {
|
|
|
420
410
|
|
|
421
411
|
@Component({
|
|
422
412
|
selector: 'ui-tabs-content',
|
|
423
|
-
imports: [LYNX_ELEMENTS],
|
|
424
413
|
encapsulation: ViewEncapsulation.None,
|
|
425
414
|
host: {
|
|
415
|
+
'[class]': 'contentClass()',
|
|
426
416
|
// Hide the WHOLE host when inactive so it reserves no layout space.
|
|
427
417
|
//
|
|
428
418
|
// The caller writes `class="flex-1"` on <ui-tabs-content>, and Angular
|
|
429
419
|
// applies that to the host element. All three content hosts live as siblings
|
|
430
420
|
// inside the flex-col <ui-tabs>, so three flex-1 hosts each grow to fill a
|
|
431
421
|
// third of the available height — pushing each panel lower than the last
|
|
432
|
-
// (the "Done tab starts at the very bottom" stacking).
|
|
433
|
-
// view doesn't help: the flex-1 host still claims its third.
|
|
422
|
+
// (the "Done tab starts at the very bottom" stacking).
|
|
434
423
|
//
|
|
435
424
|
// Toggling the host's own `display` collapses inactive hosts to 0×0 (Lynx
|
|
436
425
|
// `display: none`) so they claim no space at all, while the single active
|
|
437
|
-
// host keeps its flex-1 and fills the tabs area.
|
|
438
|
-
//
|
|
426
|
+
// host keeps its flex-1 and fills the tabs area. The inline display wins
|
|
427
|
+
// over the `flex` in the host's classes.
|
|
439
428
|
'[style.display]': "isActive() ? 'flex' : 'none'",
|
|
440
|
-
'[style.flexDirection]': "'column'",
|
|
441
429
|
// A hidden panel's 0×0 doesn't stop iOS drawing its text: each line is
|
|
442
430
|
// laid out at 0×0 too, and its glyphs spill out from the panel's corner,
|
|
443
431
|
// over the tab list. Clipping the hidden host to its 0×0 hides them.
|
|
444
432
|
'[style.overflow]': "isActive() ? null : 'hidden'",
|
|
445
433
|
},
|
|
446
|
-
template:
|
|
447
|
-
<view #content [class]="contentClass()">
|
|
448
|
-
<ng-content />
|
|
449
|
-
</view>
|
|
450
|
-
`,
|
|
434
|
+
template: '<ng-content />',
|
|
451
435
|
})
|
|
452
436
|
export class UiTabsContent {
|
|
453
437
|
readonly #tabs = inject(UiTabs);
|
|
@@ -456,7 +440,7 @@ export class UiTabsContent {
|
|
|
456
440
|
readonly contentValue = input.required<string>({ alias: 'value' });
|
|
457
441
|
readonly userClass = input<string>('', { alias: 'class' });
|
|
458
442
|
|
|
459
|
-
readonly
|
|
443
|
+
readonly #host = inject(ElementRef).nativeElement;
|
|
460
444
|
#anim?: AnimationHandle;
|
|
461
445
|
#isFirstRender = true;
|
|
462
446
|
|
|
@@ -476,8 +460,7 @@ export class UiTabsContent {
|
|
|
476
460
|
* that vanished and never came back. Keeping every panel mounted and only
|
|
477
461
|
* toggling `display` never destroys or re-parents anything: inactive panels
|
|
478
462
|
* collapse to 0×0 (Lynx `display: none`) so they take no space, and the
|
|
479
|
-
* active one simply reappears in place.
|
|
480
|
-
* always exists, so `contentRef` is required.
|
|
463
|
+
* active one simply reappears in place.
|
|
481
464
|
*/
|
|
482
465
|
protected readonly revealing = effect(() => {
|
|
483
466
|
if (!this.isActive()) return;
|
|
@@ -485,7 +468,7 @@ export class UiTabsContent {
|
|
|
485
468
|
setTimeout(() => {
|
|
486
469
|
// Under Reduce Motion the panel just appears.
|
|
487
470
|
if (this.#settings.reduceMotion()) return;
|
|
488
|
-
const el = this
|
|
471
|
+
const el = this.#host;
|
|
489
472
|
|
|
490
473
|
this.#anim?.cancel();
|
|
491
474
|
|
|
@@ -504,8 +487,8 @@ export class UiTabsContent {
|
|
|
504
487
|
}, 0);
|
|
505
488
|
});
|
|
506
489
|
|
|
507
|
-
// Visibility is controlled
|
|
508
|
-
//
|
|
490
|
+
// Visibility is controlled by the host's inline display (see `host`); its
|
|
491
|
+
// classes make it a flex column when shown.
|
|
509
492
|
protected readonly contentClass = computed(() =>
|
|
510
493
|
cn('mt-2 flex-col flex', this.userClass()),
|
|
511
494
|
);
|
|
@@ -56,7 +56,7 @@ const renderTextarea = async () => {
|
|
|
56
56
|
...result,
|
|
57
57
|
host: result.componentRef.instance as TextareaHost,
|
|
58
58
|
field: () => result.container.querySelector('textarea')!,
|
|
59
|
-
// The field's
|
|
59
|
+
// The field's column: its host.
|
|
60
60
|
column: () => result.container.querySelector('.flex-col')!,
|
|
61
61
|
wrapper: () => result.container.querySelector('.bg-muted') as HTMLElement,
|
|
62
62
|
// The layer that draws the focus ring around the field.
|
|
@@ -84,6 +84,16 @@ describe('UiTextarea', () => {
|
|
|
84
84
|
expect(queryByText('Add a note')).toBeNull();
|
|
85
85
|
});
|
|
86
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 } =
|
|
92
|
+
await renderTextarea();
|
|
93
|
+
expect(container.querySelectorAll('.w-full')).toHaveLength(1);
|
|
94
|
+
expect(getAllByPlaceholderText('Add a note')).toEqual([field()]);
|
|
95
|
+
});
|
|
96
|
+
|
|
87
97
|
it('shows its label', async () => {
|
|
88
98
|
const { host, getByText } = await renderTextarea();
|
|
89
99
|
host.label.set('Notes');
|
|
@@ -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();
|