@ng-zen/cli 22.9.0-next.1 → 22.10.0-next.1
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/package.json +3 -1
- package/schematics/ui/files/checkbox/checkbox.html +36 -0
- package/schematics/ui/files/checkbox/checkbox.scss +54 -31
- package/schematics/ui/files/checkbox/checkbox.spec.ts +94 -2
- package/schematics/ui/files/checkbox/checkbox.stories.ts +8 -28
- package/schematics/ui/files/checkbox/checkbox.ts +8 -21
- package/schematics/ui/files/form-control/form-control.spec.ts +18 -1
- package/schematics/ui/files/form-control/form-control.stories.ts +19 -4
- package/schematics/ui/files/form-control/form-control.ts +30 -4
- package/schematics/ui/files/hint/hint.scss +48 -0
- package/schematics/ui/files/hint/hint.spec.ts +54 -0
- package/schematics/ui/files/hint/hint.stories.ts +57 -0
- package/schematics/ui/files/hint/hint.ts +48 -0
- package/schematics/ui/files/hint/index.ts +1 -0
- package/schematics/ui/files/input/input.html +29 -0
- package/schematics/ui/files/input/input.scss +2 -1
- package/schematics/ui/files/input/input.spec.ts +107 -2
- package/schematics/ui/files/input/input.stories.ts +23 -16
- package/schematics/ui/files/input/input.ts +7 -14
- package/schematics/ui/files/label/index.ts +1 -0
- package/schematics/ui/files/label/label.html +6 -0
- package/schematics/ui/files/label/label.scss +52 -0
- package/schematics/ui/files/label/label.spec.ts +77 -0
- package/schematics/ui/files/label/label.stories.ts +67 -0
- package/schematics/ui/files/label/label.ts +57 -0
- package/schematics/ui/files/radio/radio.html +34 -0
- package/schematics/ui/files/radio/radio.scss +40 -27
- package/schematics/ui/files/radio/radio.spec.ts +40 -0
- package/schematics/ui/files/radio/radio.stories.ts +12 -25
- package/schematics/ui/files/radio/radio.ts +8 -20
- package/schematics/ui/files/switch/switch.html +31 -0
- package/schematics/ui/files/switch/switch.scss +34 -2
- package/schematics/ui/files/switch/switch.spec.ts +102 -2
- package/schematics/ui/files/switch/switch.stories.ts +12 -13
- package/schematics/ui/files/switch/switch.ts +7 -17
- package/schematics/ui/index.js +18 -8
- package/schematics/ui/index.js.map +1 -1
- package/schematics/ui/index.ts +19 -7
- package/schematics/ui/schema.js.map +1 -1
- package/schematics/ui/schema.json +2 -0
- package/schematics/ui/schema.ts +2 -0
|
@@ -12,60 +12,64 @@ $disabled-opacity: var(--zen-disabled-opacity, 0.6);
|
|
|
12
12
|
--zen-radio-border: 1px solid hsl(0deg 0% 80%);
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
input {
|
|
16
|
-
position: absolute;
|
|
17
|
-
cursor: pointer;
|
|
18
|
-
opacity: 0;
|
|
19
|
-
height: 100%;
|
|
20
|
-
width: 100%;
|
|
21
|
-
padding: 0;
|
|
22
|
-
margin: 0;
|
|
23
|
-
top: 0;
|
|
24
|
-
left: 0;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
15
|
/* stylelint-disable-next-line no-duplicate-selectors -- separate variables and styles */
|
|
28
16
|
:host {
|
|
17
|
+
display: inline-grid;
|
|
18
|
+
grid-template-columns: var(--zen-radio-size) 1fr;
|
|
19
|
+
gap: 0.25rem 0.5rem;
|
|
20
|
+
align-items: center;
|
|
21
|
+
|
|
22
|
+
&:has(input[type='radio']:disabled) {
|
|
23
|
+
opacity: $disabled-opacity;
|
|
24
|
+
|
|
25
|
+
&,
|
|
26
|
+
input,
|
|
27
|
+
label {
|
|
28
|
+
cursor: not-allowed;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
input[type='radio'] {
|
|
34
|
+
appearance: none;
|
|
35
|
+
grid-column: 1;
|
|
36
|
+
grid-row: 1;
|
|
29
37
|
border: var(--zen-radio-border);
|
|
30
38
|
border-radius: var(--zen-radio-border-radius);
|
|
31
39
|
height: var(--zen-radio-size);
|
|
32
40
|
width: var(--zen-radio-size);
|
|
33
41
|
background-color: white;
|
|
34
42
|
cursor: pointer;
|
|
35
|
-
|
|
43
|
+
margin: 0;
|
|
44
|
+
padding: 0;
|
|
36
45
|
transition:
|
|
37
46
|
background-color ease,
|
|
38
47
|
border-color ease;
|
|
39
48
|
transition-duration: $transition-duration;
|
|
40
|
-
user-select: none;
|
|
41
|
-
justify-content: center;
|
|
42
|
-
display: grid;
|
|
43
|
-
place-items: center;
|
|
44
49
|
|
|
45
|
-
&:
|
|
50
|
+
&:checked {
|
|
46
51
|
border-color: var(--zen-radio-appearance);
|
|
47
52
|
}
|
|
48
53
|
|
|
49
|
-
&:
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
&,
|
|
53
|
-
input {
|
|
54
|
-
cursor: not-allowed;
|
|
55
|
-
}
|
|
54
|
+
&:disabled {
|
|
55
|
+
cursor: not-allowed;
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
-
&:
|
|
58
|
+
&:focus-visible {
|
|
59
59
|
outline: $outline;
|
|
60
60
|
box-shadow: $focus-shadow;
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
-
&:
|
|
63
|
+
&:user-invalid {
|
|
64
64
|
border-color: $error;
|
|
65
65
|
}
|
|
66
66
|
}
|
|
67
67
|
|
|
68
68
|
.radio-dot {
|
|
69
|
+
grid-column: 1;
|
|
70
|
+
grid-row: 1;
|
|
71
|
+
place-self: center center;
|
|
72
|
+
pointer-events: none;
|
|
69
73
|
width: calc(var(--zen-radio-size) * 0.4);
|
|
70
74
|
height: calc(var(--zen-radio-size) * 0.4);
|
|
71
75
|
background-color: var(--zen-radio-appearance);
|
|
@@ -84,3 +88,12 @@ input {
|
|
|
84
88
|
opacity: 1;
|
|
85
89
|
}
|
|
86
90
|
}
|
|
91
|
+
|
|
92
|
+
zen-label {
|
|
93
|
+
grid-column: 2;
|
|
94
|
+
grid-row: 1;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
zen-hint {
|
|
98
|
+
grid-column: 1 / -1;
|
|
99
|
+
}
|
|
@@ -45,6 +45,46 @@ describe('ZenRadio', () => {
|
|
|
45
45
|
expect(inputElement.checked).toBe(false);
|
|
46
46
|
});
|
|
47
47
|
|
|
48
|
+
it('should render label linked to radio input id and handle required state', () => {
|
|
49
|
+
fixture.componentRef.setInput('label', 'Option Label');
|
|
50
|
+
fixture.detectChanges();
|
|
51
|
+
|
|
52
|
+
const labelEl = fixture.debugElement.query(By.css('label')).nativeElement as HTMLLabelElement;
|
|
53
|
+
const inputEl = fixture.debugElement.query(By.css('input')).nativeElement as HTMLInputElement;
|
|
54
|
+
|
|
55
|
+
expect(labelEl).toBeTruthy();
|
|
56
|
+
expect(labelEl.textContent?.trim()).toBe('Option Label');
|
|
57
|
+
expect(labelEl.getAttribute('for')).toBe(inputEl.id);
|
|
58
|
+
|
|
59
|
+
fixture.componentRef.setInput('required', true);
|
|
60
|
+
fixture.detectChanges();
|
|
61
|
+
|
|
62
|
+
const requiredSpan = fixture.debugElement.query(By.css('zen-label .required'))?.nativeElement;
|
|
63
|
+
expect(requiredSpan).toBeTruthy();
|
|
64
|
+
expect(requiredSpan.textContent).toBe('*');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('should render hint, warn, and errors on radio', () => {
|
|
68
|
+
fixture.componentRef.setInput('hint', 'Choose one option');
|
|
69
|
+
fixture.detectChanges();
|
|
70
|
+
let hintEl = fixture.debugElement.query(By.css('zen-hint')).nativeElement;
|
|
71
|
+
expect(hintEl.getAttribute('data-severity')).toBe('neutral');
|
|
72
|
+
expect(hintEl.textContent?.trim()).toBe('Choose one option');
|
|
73
|
+
|
|
74
|
+
fixture.componentRef.setInput('warn', 'Selection cannot be changed');
|
|
75
|
+
fixture.detectChanges();
|
|
76
|
+
hintEl = fixture.debugElement.query(By.css('zen-hint')).nativeElement;
|
|
77
|
+
expect(hintEl.getAttribute('data-severity')).toBe('warning');
|
|
78
|
+
|
|
79
|
+
fixture.componentRef.setInput('invalid', true);
|
|
80
|
+
fixture.componentRef.setInput('touched', true);
|
|
81
|
+
fixture.componentRef.setInput('errors', [{ message: 'Please select an option' }]);
|
|
82
|
+
fixture.detectChanges();
|
|
83
|
+
hintEl = fixture.debugElement.query(By.css('zen-hint')).nativeElement;
|
|
84
|
+
expect(hintEl.getAttribute('data-severity')).toBe('error');
|
|
85
|
+
expect(hintEl.textContent?.trim()).toBe('Please select an option');
|
|
86
|
+
});
|
|
87
|
+
|
|
48
88
|
it('should show radio dot when checked', () => {
|
|
49
89
|
component.value.set('test-value');
|
|
50
90
|
fixture.detectChanges();
|
|
@@ -56,13 +56,12 @@ type Story = StoryObj<Options>;
|
|
|
56
56
|
export const Default: Story = {};
|
|
57
57
|
|
|
58
58
|
export const WithLabel: Story = {
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
`,
|
|
59
|
+
args: {
|
|
60
|
+
label: 'Option 1',
|
|
61
|
+
},
|
|
62
|
+
render: args => ({
|
|
63
|
+
props: args,
|
|
64
|
+
template: '<zen-radio name="label-example" option="option1" [label]="label" />',
|
|
66
65
|
}),
|
|
67
66
|
};
|
|
68
67
|
|
|
@@ -75,9 +74,9 @@ export const WithSignalForm: Story = {
|
|
|
75
74
|
parameters: {
|
|
76
75
|
docs: {
|
|
77
76
|
source: {
|
|
78
|
-
code: `<zen-radio [formField]="form.color" option="red" />
|
|
79
|
-
<zen-radio [formField]="form.color" option="green" />
|
|
80
|
-
<zen-radio [formField]="form.color" option="blue" />`,
|
|
77
|
+
code: `<zen-radio [formField]="form.color" option="red" label="Red" />
|
|
78
|
+
<zen-radio [formField]="form.color" option="green" label="Green" />
|
|
79
|
+
<zen-radio [formField]="form.color" option="blue" label="Blue" />`,
|
|
81
80
|
},
|
|
82
81
|
},
|
|
83
82
|
},
|
|
@@ -93,21 +92,9 @@ export const WithSignalForm: Story = {
|
|
|
93
92
|
{{ form.color().value() }}
|
|
94
93
|
</div>
|
|
95
94
|
<div style="display: flex; flex-direction: column; gap: 0.5rem">
|
|
96
|
-
|
|
97
|
-
<label
|
|
98
|
-
|
|
99
|
-
Red
|
|
100
|
-
</label>
|
|
101
|
-
<!-- eslint-disable-next-line @angular-eslint/template/label-has-associated-control -->
|
|
102
|
-
<label style="display: flex; align-items: center; gap: 0.25rem">
|
|
103
|
-
<zen-radio option="green" [formField]="form.color" />
|
|
104
|
-
Green
|
|
105
|
-
</label>
|
|
106
|
-
<!-- eslint-disable-next-line @angular-eslint/template/label-has-associated-control -->
|
|
107
|
-
<label style="display: flex; align-items: center; gap: 0.25rem">
|
|
108
|
-
<zen-radio option="blue" [formField]="form.color" />
|
|
109
|
-
Blue
|
|
110
|
-
</label>
|
|
95
|
+
<zen-radio label="Red" option="red" [formField]="form.color" />
|
|
96
|
+
<zen-radio label="Green" option="green" [formField]="form.color" />
|
|
97
|
+
<zen-radio label="Blue" option="blue" [formField]="form.color" />
|
|
111
98
|
</div>
|
|
112
99
|
</div>
|
|
113
100
|
`,
|
|
@@ -1,6 +1,9 @@
|
|
|
1
|
+
import { NgTemplateOutlet } from '@angular/common';
|
|
1
2
|
import { afterNextRender, Component, computed, DestroyRef, inject, input, model } from '@angular/core';
|
|
2
3
|
|
|
3
4
|
import { ZenFormControl } from '../form-control';
|
|
5
|
+
import { ZenHint } from '../hint';
|
|
6
|
+
import { ZenLabel } from '../label';
|
|
4
7
|
import { ZenRadioRegistry } from './radio.registry';
|
|
5
8
|
|
|
6
9
|
/**
|
|
@@ -13,8 +16,8 @@ import { ZenRadioRegistry } from './radio.registry';
|
|
|
13
16
|
* Use together with a shared `[formField]` binding:
|
|
14
17
|
*
|
|
15
18
|
* ```html
|
|
16
|
-
* <zen-radio [formField]="form.color" name="color" option="red" />
|
|
17
|
-
* <zen-radio [formField]="form.color" name="color" option="green" />
|
|
19
|
+
* <zen-radio [formField]="form.color" name="color" option="red" label="Red" />
|
|
20
|
+
* <zen-radio [formField]="form.color" name="color" option="green" label="Green" />
|
|
18
21
|
* ```
|
|
19
22
|
*
|
|
20
23
|
* ### CSS Custom Properties
|
|
@@ -38,24 +41,9 @@ import { ZenRadioRegistry } from './radio.registry';
|
|
|
38
41
|
*/
|
|
39
42
|
@Component({
|
|
40
43
|
selector: 'zen-radio',
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
type="radio"
|
|
45
|
-
[attr.aria-checked]="checked()"
|
|
46
|
-
[attr.aria-disabled]="disabled()"
|
|
47
|
-
[attr.aria-invalid]="invalid() || null"
|
|
48
|
-
[checked]="checked()"
|
|
49
|
-
[disabled]="disabled()"
|
|
50
|
-
[name]="name()"
|
|
51
|
-
[value]="option()"
|
|
52
|
-
(change)="onRadioChange()"
|
|
53
|
-
/>
|
|
54
|
-
@if (checked()) {
|
|
55
|
-
<span class="radio-dot"></span>
|
|
56
|
-
}
|
|
57
|
-
`,
|
|
58
|
-
styleUrls: ['./radio.scss'],
|
|
44
|
+
imports: [NgTemplateOutlet, ZenHint, ZenLabel],
|
|
45
|
+
templateUrl: './radio.html',
|
|
46
|
+
styleUrl: './radio.scss',
|
|
59
47
|
})
|
|
60
48
|
export class ZenRadio extends ZenFormControl<string | null> {
|
|
61
49
|
/**
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
<button
|
|
2
|
+
role="switch"
|
|
3
|
+
type="button"
|
|
4
|
+
[attr.aria-checked]="value()"
|
|
5
|
+
[attr.data-checked]="value()"
|
|
6
|
+
[attr.data-disabled]="disabled()"
|
|
7
|
+
[disabled]="disabled()"
|
|
8
|
+
[id]="id()"
|
|
9
|
+
(click)="onInput(!value())"
|
|
10
|
+
(keydown)="onKeyDown($event)"
|
|
11
|
+
>
|
|
12
|
+
<span class="thumb"></span>
|
|
13
|
+
</button>
|
|
14
|
+
@if (label()) {
|
|
15
|
+
<zen-label [disabled]="disabled()" [for]="id()" [required]="required()">{{ label() }}</zen-label>
|
|
16
|
+
}
|
|
17
|
+
@if (shouldShowError()) {
|
|
18
|
+
@for (error of errors(); track $index) {
|
|
19
|
+
<zen-hint severity="error">
|
|
20
|
+
@if (errorTemplate()) {
|
|
21
|
+
<ng-container *ngTemplateOutlet="errorTemplate()!; context: { $implicit: error }" />
|
|
22
|
+
} @else {
|
|
23
|
+
{{ error.message }}
|
|
24
|
+
}
|
|
25
|
+
</zen-hint>
|
|
26
|
+
}
|
|
27
|
+
} @else if (warn()) {
|
|
28
|
+
<zen-hint severity="warning">{{ warn() }}</zen-hint>
|
|
29
|
+
} @else if (hint()) {
|
|
30
|
+
<zen-hint>{{ hint() }}</zen-hint>
|
|
31
|
+
}
|
|
@@ -16,17 +16,39 @@ $disabled-opacity: var(--zen-disabled-opacity, 0.6);
|
|
|
16
16
|
|
|
17
17
|
/* stylelint-disable-next-line no-duplicate-selectors -- separate variables and styles */
|
|
18
18
|
:host {
|
|
19
|
+
display: inline-grid;
|
|
20
|
+
grid-template-columns: var(--zen-switch-width) 1fr;
|
|
21
|
+
gap: 0.25rem 0.5rem;
|
|
22
|
+
align-items: center;
|
|
23
|
+
|
|
24
|
+
&:has(button:disabled) {
|
|
25
|
+
opacity: $disabled-opacity;
|
|
26
|
+
|
|
27
|
+
&,
|
|
28
|
+
button,
|
|
29
|
+
label {
|
|
30
|
+
cursor: not-allowed;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
button[role='switch'] {
|
|
19
36
|
$thumb-x-center-position: calc(var(--zen-switch-height) / 2 - var(--zen-switch-thumb-size) / 2);
|
|
20
37
|
|
|
38
|
+
grid-column: 1;
|
|
39
|
+
grid-row: 1;
|
|
21
40
|
background: var(--zen-switch-background);
|
|
22
41
|
height: var(--zen-switch-height);
|
|
23
42
|
width: var(--zen-switch-width);
|
|
24
43
|
cursor: pointer;
|
|
44
|
+
border: none;
|
|
25
45
|
border-radius: 9999px;
|
|
26
46
|
position: relative;
|
|
27
47
|
display: flex;
|
|
28
48
|
place-items: center;
|
|
29
49
|
user-select: none;
|
|
50
|
+
padding: 0;
|
|
51
|
+
margin: 0;
|
|
30
52
|
|
|
31
53
|
// Animation
|
|
32
54
|
transition-property: background-color, border-color;
|
|
@@ -72,12 +94,22 @@ $disabled-opacity: var(--zen-disabled-opacity, 0.6);
|
|
|
72
94
|
}
|
|
73
95
|
}
|
|
74
96
|
|
|
75
|
-
&[data-disabled='true']
|
|
97
|
+
&[data-disabled='true'],
|
|
98
|
+
&:disabled {
|
|
76
99
|
cursor: not-allowed;
|
|
77
100
|
opacity: $disabled-opacity;
|
|
78
101
|
}
|
|
79
102
|
|
|
80
|
-
&:active:not(
|
|
103
|
+
&:active:not(:disabled) .thumb {
|
|
81
104
|
width: calc(var(--zen-switch-thumb-size) + 2px);
|
|
82
105
|
}
|
|
83
106
|
}
|
|
107
|
+
|
|
108
|
+
zen-label {
|
|
109
|
+
grid-column: 2;
|
|
110
|
+
grid-row: 1;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
zen-hint {
|
|
114
|
+
grid-column: 1 / -1;
|
|
115
|
+
}
|
|
@@ -1,13 +1,39 @@
|
|
|
1
|
-
import { provideZonelessChangeDetection } from '@angular/core';
|
|
1
|
+
import { Component, provideZonelessChangeDetection, signal } from '@angular/core';
|
|
2
2
|
import { TestBed } from '@angular/core/testing';
|
|
3
|
+
import type { ValidationError } from '@angular/forms/signals';
|
|
4
|
+
import { By } from '@angular/platform-browser';
|
|
3
5
|
import { beforeEach, describe, expect, it } from 'vitest';
|
|
4
6
|
|
|
5
7
|
import { ZenSwitch } from './switch';
|
|
6
8
|
|
|
9
|
+
@Component({
|
|
10
|
+
imports: [ZenSwitch],
|
|
11
|
+
template: `
|
|
12
|
+
<zen-switch
|
|
13
|
+
[errors]="errors()"
|
|
14
|
+
[hint]="hint()"
|
|
15
|
+
[invalid]="invalid()"
|
|
16
|
+
[label]="label()"
|
|
17
|
+
[required]="required()"
|
|
18
|
+
[touched]="touched()"
|
|
19
|
+
[warn]="warn()"
|
|
20
|
+
/>
|
|
21
|
+
`,
|
|
22
|
+
})
|
|
23
|
+
class TestSwitchHostComponent {
|
|
24
|
+
readonly label = signal('');
|
|
25
|
+
readonly hint = signal('');
|
|
26
|
+
readonly warn = signal('');
|
|
27
|
+
readonly invalid = signal(false);
|
|
28
|
+
readonly touched = signal(false);
|
|
29
|
+
readonly required = signal(false);
|
|
30
|
+
readonly errors = signal<ValidationError.WithOptionalFieldTree[]>([]);
|
|
31
|
+
}
|
|
32
|
+
|
|
7
33
|
describe('ZenSwitch', () => {
|
|
8
34
|
beforeEach(async () => {
|
|
9
35
|
await TestBed.configureTestingModule({
|
|
10
|
-
imports: [ZenSwitch],
|
|
36
|
+
imports: [ZenSwitch, TestSwitchHostComponent],
|
|
11
37
|
providers: [provideZonelessChangeDetection()],
|
|
12
38
|
}).compileComponents();
|
|
13
39
|
});
|
|
@@ -17,4 +43,78 @@ describe('ZenSwitch', () => {
|
|
|
17
43
|
const component = fixture.componentInstance;
|
|
18
44
|
expect(component).toBeTruthy();
|
|
19
45
|
});
|
|
46
|
+
|
|
47
|
+
it('should render label linked to switch button id and handle required state', () => {
|
|
48
|
+
const fixture = TestBed.createComponent(TestSwitchHostComponent);
|
|
49
|
+
fixture.componentInstance.label.set('Enable feature');
|
|
50
|
+
fixture.detectChanges();
|
|
51
|
+
|
|
52
|
+
const labelEl = fixture.nativeElement.querySelector('label') as HTMLLabelElement;
|
|
53
|
+
const buttonEl = fixture.nativeElement.querySelector('button[role="switch"]') as HTMLButtonElement;
|
|
54
|
+
|
|
55
|
+
expect(labelEl).toBeTruthy();
|
|
56
|
+
expect(labelEl.textContent?.trim()).toBe('Enable feature');
|
|
57
|
+
expect(labelEl.getAttribute('for')).toBe(buttonEl.id);
|
|
58
|
+
|
|
59
|
+
fixture.componentInstance.required.set(true);
|
|
60
|
+
fixture.detectChanges();
|
|
61
|
+
|
|
62
|
+
const requiredSpan = fixture.nativeElement.querySelector('zen-label .required');
|
|
63
|
+
expect(requiredSpan).toBeTruthy();
|
|
64
|
+
expect(requiredSpan.textContent).toBe('*');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('should toggle value on click', () => {
|
|
68
|
+
const fixture = TestBed.createComponent(ZenSwitch);
|
|
69
|
+
fixture.detectChanges();
|
|
70
|
+
|
|
71
|
+
const buttonEl = fixture.nativeElement.querySelector('button') as HTMLButtonElement;
|
|
72
|
+
expect(fixture.componentInstance.value()).toBe(false);
|
|
73
|
+
|
|
74
|
+
buttonEl.click();
|
|
75
|
+
expect(fixture.componentInstance.value()).toBe(true);
|
|
76
|
+
|
|
77
|
+
buttonEl.click();
|
|
78
|
+
expect(fixture.componentInstance.value()).toBe(false);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('should handle keyboard interaction', () => {
|
|
82
|
+
const fixture = TestBed.createComponent(ZenSwitch);
|
|
83
|
+
fixture.detectChanges();
|
|
84
|
+
|
|
85
|
+
const component = fixture.componentInstance;
|
|
86
|
+
component.onKeyDown(new KeyboardEvent('keydown', { code: 'Space' }));
|
|
87
|
+
expect(component.value()).toBe(true);
|
|
88
|
+
|
|
89
|
+
component.onKeyDown(new KeyboardEvent('keydown', { code: 'ArrowLeft' }));
|
|
90
|
+
expect(component.value()).toBe(false);
|
|
91
|
+
|
|
92
|
+
component.onKeyDown(new KeyboardEvent('keydown', { code: 'ArrowRight' }));
|
|
93
|
+
expect(component.value()).toBe(true);
|
|
94
|
+
|
|
95
|
+
component.onKeyDown(new KeyboardEvent('keydown', { code: 'Enter' }));
|
|
96
|
+
expect(component.value()).toBe(false);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('should render hint, warn, and errors', () => {
|
|
100
|
+
const fixture = TestBed.createComponent(TestSwitchHostComponent);
|
|
101
|
+
fixture.componentInstance.hint.set('Informational hint');
|
|
102
|
+
fixture.detectChanges();
|
|
103
|
+
|
|
104
|
+
let hintEl = fixture.debugElement.query(By.css('zen-hint')).nativeElement;
|
|
105
|
+
expect(hintEl.getAttribute('data-severity')).toBe('neutral');
|
|
106
|
+
|
|
107
|
+
fixture.componentInstance.warn.set('Warning text');
|
|
108
|
+
fixture.detectChanges();
|
|
109
|
+
hintEl = fixture.debugElement.query(By.css('zen-hint')).nativeElement;
|
|
110
|
+
expect(hintEl.getAttribute('data-severity')).toBe('warning');
|
|
111
|
+
|
|
112
|
+
fixture.componentInstance.invalid.set(true);
|
|
113
|
+
fixture.componentInstance.touched.set(true);
|
|
114
|
+
fixture.componentInstance.errors.set([{ kind: 'required', message: 'Switch must be enabled' }]);
|
|
115
|
+
fixture.detectChanges();
|
|
116
|
+
hintEl = fixture.debugElement.query(By.css('zen-hint')).nativeElement;
|
|
117
|
+
expect(hintEl.getAttribute('data-severity')).toBe('error');
|
|
118
|
+
expect(hintEl.textContent?.trim()).toBe('Switch must be enabled');
|
|
119
|
+
});
|
|
20
120
|
});
|
|
@@ -42,6 +42,16 @@ type Story = StoryObj<Options>;
|
|
|
42
42
|
|
|
43
43
|
export const Default: Story = {};
|
|
44
44
|
|
|
45
|
+
export const WithLabel: Story = {
|
|
46
|
+
args: {
|
|
47
|
+
label: 'Enable email notifications',
|
|
48
|
+
},
|
|
49
|
+
render: args => ({
|
|
50
|
+
props: args,
|
|
51
|
+
template: '<zen-switch [label]="label" />',
|
|
52
|
+
}),
|
|
53
|
+
};
|
|
54
|
+
|
|
45
55
|
export const WithSignalForm: Story = {
|
|
46
56
|
render: () => ({
|
|
47
57
|
moduleMetadata: { imports: [NgComponentOutlet] },
|
|
@@ -51,13 +61,7 @@ export const WithSignalForm: Story = {
|
|
|
51
61
|
parameters: {
|
|
52
62
|
docs: {
|
|
53
63
|
source: {
|
|
54
|
-
code:
|
|
55
|
-
<div style="display: flex; align-items: center; gap: 0.5rem;">
|
|
56
|
-
<zen-switch id="notifications" [formField]="form.notifications" />
|
|
57
|
-
<label for="notifications">
|
|
58
|
-
{{ form.notifications().value() ? 'Notifications on' : 'Notifications off' }}
|
|
59
|
-
</label>
|
|
60
|
-
</div>`,
|
|
64
|
+
code: `<zen-switch label="Notifications" [formField]="form.notifications" />`,
|
|
61
65
|
},
|
|
62
66
|
},
|
|
63
67
|
},
|
|
@@ -66,12 +70,7 @@ export const WithSignalForm: Story = {
|
|
|
66
70
|
@Component({
|
|
67
71
|
standalone: true,
|
|
68
72
|
template: `
|
|
69
|
-
<
|
|
70
|
-
<zen-switch id="notifications" [formField]="form.notifications" />
|
|
71
|
-
<label for="notifications">
|
|
72
|
-
{{ form.notifications().value() ? 'Notifications on' : 'Notifications off' }}
|
|
73
|
-
</label>
|
|
74
|
-
</div>
|
|
73
|
+
<zen-switch label="Notifications" [formField]="form.notifications" />
|
|
75
74
|
`,
|
|
76
75
|
imports: [FormField, ZenSwitch],
|
|
77
76
|
})
|
|
@@ -1,6 +1,9 @@
|
|
|
1
|
+
import { NgTemplateOutlet } from '@angular/common';
|
|
1
2
|
import { Component, model } from '@angular/core';
|
|
2
3
|
|
|
3
4
|
import { ZenFormControl } from '../form-control';
|
|
5
|
+
import { ZenHint } from '../hint';
|
|
6
|
+
import { ZenLabel } from '../label';
|
|
4
7
|
|
|
5
8
|
/**
|
|
6
9
|
* ZenSwitch is a toggle switch component for boolean values backed by
|
|
@@ -9,7 +12,7 @@ import { ZenFormControl } from '../form-control';
|
|
|
9
12
|
* Connect it to a Signal Forms field with `[formField]`:
|
|
10
13
|
*
|
|
11
14
|
* ```html
|
|
12
|
-
* <zen-switch [formField]="myForm.notifications" />
|
|
15
|
+
* <zen-switch [formField]="myForm.notifications" label="Notifications" />
|
|
13
16
|
* ```
|
|
14
17
|
*
|
|
15
18
|
* Supports keyboard interaction: `Enter`, `Space`, `ArrowRight`, and
|
|
@@ -30,28 +33,15 @@ import { ZenFormControl } from '../form-control';
|
|
|
30
33
|
*
|
|
31
34
|
* @extends {ZenFormControl<boolean>}
|
|
32
35
|
*
|
|
33
|
-
* @author Konrad
|
|
36
|
+
* @author Konrad Stępień
|
|
34
37
|
* @license {@link https://github.com/kstepien3/ng-zen/blob/master/LICENSE|BSD-2-Clause}
|
|
35
38
|
* @see [GitHub](https://github.com/kstepien3/ng-zen)
|
|
36
39
|
*/
|
|
37
40
|
@Component({
|
|
38
41
|
selector: 'zen-switch',
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
<!-- @if (value()) { ✓ } -->
|
|
42
|
-
<!-- @if (!value()) { ─ } -->
|
|
43
|
-
</span>
|
|
44
|
-
`,
|
|
42
|
+
imports: [NgTemplateOutlet, ZenHint, ZenLabel],
|
|
43
|
+
templateUrl: './switch.html',
|
|
45
44
|
styleUrl: './switch.scss',
|
|
46
|
-
|
|
47
|
-
host: {
|
|
48
|
-
tabindex: '0',
|
|
49
|
-
role: 'switch',
|
|
50
|
-
'[attr.data-disabled]': 'disabled()',
|
|
51
|
-
'[attr.data-checked]': 'value()',
|
|
52
|
-
'(click)': 'onInput(!this.value())',
|
|
53
|
-
'(keydown)': 'onKeyDown($event)',
|
|
54
|
-
},
|
|
55
45
|
})
|
|
56
46
|
export class ZenSwitch extends ZenFormControl<boolean> {
|
|
57
47
|
/** Holds the current switch value. */
|
package/schematics/ui/index.js
CHANGED
|
@@ -7,16 +7,25 @@ const workspace_1 = require("@schematics/angular/utility/workspace");
|
|
|
7
7
|
const utils_1 = require("../../utils");
|
|
8
8
|
const DEFAULT_GENERATION_PATH = 'ui'; // src/app/ui
|
|
9
9
|
const FORM_CONTROL_DEPS = new Set(['input', 'checkbox', 'switch', 'radio']);
|
|
10
|
+
const FORM_HELPER_COMPONENTS = [
|
|
11
|
+
{ name: 'form-control', file: 'form-control/form-control.ts' },
|
|
12
|
+
{ name: 'hint', file: 'hint/hint.ts' },
|
|
13
|
+
{ name: 'label', file: 'label/label.ts' },
|
|
14
|
+
];
|
|
10
15
|
function resolveFormControlDependency(tree, logger, ui, workingDirectory) {
|
|
11
16
|
if (!ui.some(u => FORM_CONTROL_DEPS.has(u)))
|
|
12
17
|
return;
|
|
13
|
-
const
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
18
|
+
for (const { name, file } of FORM_HELPER_COMPONENTS) {
|
|
19
|
+
if (ui.includes(name))
|
|
20
|
+
continue;
|
|
21
|
+
const targetPath = (0, core_1.normalize)(`${workingDirectory}/${file}`);
|
|
22
|
+
if (tree.exists(targetPath)) {
|
|
23
|
+
logger.info(`ℹ ${name} skipped — already exists`);
|
|
24
|
+
}
|
|
25
|
+
else {
|
|
26
|
+
ui.push(name);
|
|
27
|
+
logger.info(`✔ ${name} included (auto-included for form components)`);
|
|
28
|
+
}
|
|
20
29
|
}
|
|
21
30
|
}
|
|
22
31
|
function uiGenerator({ ui: selected, project, ...options }) {
|
|
@@ -31,7 +40,8 @@ function uiGenerator({ ui: selected, project, ...options }) {
|
|
|
31
40
|
const workingDirectory = (0, core_1.normalize)((0, core_1.join)(options.currentDirectory, options.path));
|
|
32
41
|
const ui = [...selected];
|
|
33
42
|
resolveFormControlDependency(tree, context.logger, ui, workingDirectory);
|
|
34
|
-
|
|
43
|
+
const deduplicatedUi = Array.from(new Set(ui));
|
|
44
|
+
return (0, schematics_1.chain)([...(0, utils_1.applyFileTemplateUtil)(deduplicatedUi, { ...options, path: workingDirectory })]);
|
|
35
45
|
};
|
|
36
46
|
}
|
|
37
47
|
//# sourceMappingURL=index.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../../../../src/schematics/ui/index.ts"],"names":[],"mappings":";;
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../../../../src/schematics/ui/index.ts"],"names":[],"mappings":";;AAsCA,kCAqBC;AA3DD,+CAA6D;AAC7D,2DAAiF;AACjF,qEAAuF;AAEvF,uCAAoD;AAGpD,MAAM,uBAAuB,GAAG,IAAI,CAAC,CAAC,aAAa;AAEnD,MAAM,iBAAiB,GAAwB,IAAI,GAAG,CAAC,CAAC,OAAO,EAAE,UAAU,EAAE,QAAQ,EAAE,OAAO,CAAC,CAAC,CAAC;AAEjG,MAAM,sBAAsB,GAA8C;IACxE,EAAE,IAAI,EAAE,cAAc,EAAE,IAAI,EAAE,8BAA8B,EAAE;IAC9D,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,cAAc,EAAE;IACtC,EAAE,IAAI,EAAE,OAAO,EAAE,IAAI,EAAE,gBAAgB,EAAE;CAC1C,CAAC;AAEF,SAAS,4BAA4B,CACnC,IAAU,EACV,MAAkC,EAClC,EAAY,EACZ,gBAAsB;IAEtB,IAAI,CAAC,EAAE,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,iBAAiB,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC;QAAE,OAAO;IAEpD,KAAK,MAAM,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,sBAAsB,EAAE,CAAC;QACpD,IAAI,EAAE,CAAC,QAAQ,CAAC,IAAI,CAAC;YAAE,SAAS;QAEhC,MAAM,UAAU,GAAG,IAAA,gBAAS,EAAC,GAAG,gBAAgB,IAAI,IAAI,EAAE,CAAC,CAAC;QAC5D,IAAI,IAAI,CAAC,MAAM,CAAC,UAAU,CAAC,EAAE,CAAC;YAC5B,MAAM,CAAC,IAAI,CAAC,KAAK,IAAI,2BAA2B,CAAC,CAAC;QACpD,CAAC;aAAM,CAAC;YACN,EAAE,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;YACd,MAAM,CAAC,IAAI,CAAC,KAAK,IAAI,+CAA+C,CAAC,CAAC;QACxE,CAAC;IACH,CAAC;AACH,CAAC;AAED,SAAgB,WAAW,CAAC,EAAE,EAAE,EAAE,QAAQ,EAAE,OAAO,EAAE,GAAG,OAAO,EAAa;IAC1E,OAAO,KAAK,EAAE,IAAU,EAAE,OAAyB,EAAE,EAAE;QACrD,MAAM,SAAS,GAAG,MAAM,IAAA,wBAAY,EAAC,IAAI,CAAC,CAAC;QAC3C,MAAM,WAAW,GAAG,OAAO,IAAK,SAAS,CAAC,UAAU,CAAC,gBAAgB,CAAY,CAAC;QAClF,MAAM,UAAU,GAAG,SAAS,CAAC,QAAQ,CAAC,GAAG,CAAC,WAAW,CAAC,CAAC;QAEvD,IAAI,CAAC,UAAU,EAAE,CAAC;YAChB,MAAM,IAAI,KAAK,CAAC,YAAY,WAAW,2BAA2B,CAAC,CAAC;QACtE,CAAC;QAED,OAAO,CAAC,IAAI,KAAK,CAAC,IAAA,4BAAgB,EAAC,UAAU,CAAC,GAAG,GAAG,GAAG,uBAAuB,CAAS,CAAC;QAExF,MAAM,gBAAgB,GAAG,IAAA,gBAAS,EAAC,IAAA,WAAI,EAAC,OAAO,CAAC,gBAAgB,EAAE,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC;QACjF,MAAM,EAAE,GAAG,CAAC,GAAG,QAAQ,CAAC,CAAC;QAEzB,4BAA4B,CAAC,IAAI,EAAE,OAAO,CAAC,MAAM,EAAE,EAAE,EAAE,gBAAgB,CAAC,CAAC;QAEzE,MAAM,cAAc,GAAG,KAAK,CAAC,IAAI,CAAC,IAAI,GAAG,CAAC,EAAE,CAAC,CAAC,CAAC;QAE/C,OAAO,IAAA,kBAAK,EAAC,CAAC,GAAG,IAAA,6BAAqB,EAAC,cAAc,EAAE,EAAE,GAAG,OAAO,EAAE,IAAI,EAAE,gBAAgB,EAAE,CAAC,CAAC,CAAC,CAAC;IACnG,CAAC,CAAC;AACJ,CAAC","sourcesContent":["import { join, normalize, Path } from '@angular-devkit/core';\nimport { chain, Rule, SchematicContext, Tree } from '@angular-devkit/schematics';\nimport { buildDefaultPath, getWorkspace } from '@schematics/angular/utility/workspace';\n\nimport { applyFileTemplateUtil } from '../../utils';\nimport { Schema as UiOptions, UiType } from './schema';\n\nconst DEFAULT_GENERATION_PATH = 'ui'; // src/app/ui\n\nconst FORM_CONTROL_DEPS: ReadonlySet<UiType> = new Set(['input', 'checkbox', 'switch', 'radio']);\n\nconst FORM_HELPER_COMPONENTS: readonly { name: UiType; file: string }[] = [\n { name: 'form-control', file: 'form-control/form-control.ts' },\n { name: 'hint', file: 'hint/hint.ts' },\n { name: 'label', file: 'label/label.ts' },\n];\n\nfunction resolveFormControlDependency(\n tree: Tree,\n logger: SchematicContext['logger'],\n ui: UiType[],\n workingDirectory: Path\n): void {\n if (!ui.some(u => FORM_CONTROL_DEPS.has(u))) return;\n\n for (const { name, file } of FORM_HELPER_COMPONENTS) {\n if (ui.includes(name)) continue;\n\n const targetPath = normalize(`${workingDirectory}/${file}`);\n if (tree.exists(targetPath)) {\n logger.info(`ℹ ${name} skipped — already exists`);\n } else {\n ui.push(name);\n logger.info(`✔ ${name} included (auto-included for form components)`);\n }\n }\n}\n\nexport function uiGenerator({ ui: selected, project, ...options }: UiOptions): Rule {\n return async (tree: Tree, context: SchematicContext) => {\n const workspace = await getWorkspace(tree);\n const projectName = project ?? (workspace.extensions['defaultProject'] as string);\n const projectObj = workspace.projects.get(projectName);\n\n if (!projectObj) {\n throw new Error(`Project \"${projectName}\" not found in workspace.`);\n }\n\n options.path ??= (buildDefaultPath(projectObj) + '/' + DEFAULT_GENERATION_PATH) as Path;\n\n const workingDirectory = normalize(join(options.currentDirectory, options.path));\n const ui = [...selected];\n\n resolveFormControlDependency(tree, context.logger, ui, workingDirectory);\n\n const deduplicatedUi = Array.from(new Set(ui));\n\n return chain([...applyFileTemplateUtil(deduplicatedUi, { ...options, path: workingDirectory })]);\n };\n}\n"]}
|