@sneat/ui 0.1.0

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.
Files changed (31) hide show
  1. package/eslint.config.js +7 -0
  2. package/ng-package.json +7 -0
  3. package/package.json +16 -0
  4. package/project.json +38 -0
  5. package/src/index.ts +3 -0
  6. package/src/lib/components/index.ts +2 -0
  7. package/src/lib/components/sneat-base-modal.component.ts +22 -0
  8. package/src/lib/components/sneat-base.component.spec.ts +71 -0
  9. package/src/lib/components/sneat-base.component.ts +78 -0
  10. package/src/lib/focus.ts +19 -0
  11. package/src/lib/selector/index.ts +6 -0
  12. package/src/lib/selector/multi-selector/index.ts +2 -0
  13. package/src/lib/selector/multi-selector/multi-selector.component.html +26 -0
  14. package/src/lib/selector/multi-selector/multi-selector.component.spec.ts +147 -0
  15. package/src/lib/selector/multi-selector/multi-selector.component.ts +79 -0
  16. package/src/lib/selector/multi-selector/multi-selector.service.spec.ts +91 -0
  17. package/src/lib/selector/multi-selector/multi-selector.service.ts +49 -0
  18. package/src/lib/selector/select-from-list/index.ts +1 -0
  19. package/src/lib/selector/select-from-list/select-from-list.component.html +210 -0
  20. package/src/lib/selector/select-from-list/select-from-list.component.spec.ts +297 -0
  21. package/src/lib/selector/select-from-list/select-from-list.component.ts +283 -0
  22. package/src/lib/selector/selector-base.component.ts +43 -0
  23. package/src/lib/selector/selector-base.service.ts +62 -0
  24. package/src/lib/selector/selector-interfaces.ts +28 -0
  25. package/src/lib/selector/selector-options.ts +18 -0
  26. package/src/test-setup.ts +3 -0
  27. package/tsconfig.json +13 -0
  28. package/tsconfig.lib.json +19 -0
  29. package/tsconfig.lib.prod.json +7 -0
  30. package/tsconfig.spec.json +31 -0
  31. package/vite.config.mts +10 -0
@@ -0,0 +1,210 @@
1
+ @if (value) {
2
+ <ion-item
3
+ [lines]="lastItemLines"
4
+ [class]="{ 'sneat-tiny-end-padding': !isReadonly }"
5
+ >
6
+ @if ($selectedItem()?.iconName) {
7
+ <ion-icon
8
+ slot="start"
9
+ [name]="$selectedItem()?.iconName"
10
+ [color]="
11
+ $selectedItem()?.iconColor || $selectedItem()?.labelColor || 'medium'
12
+ "
13
+ />
14
+ }
15
+ <ion-select
16
+ #selectInput
17
+ interface="popover"
18
+ [label]="label"
19
+ [(ngModel)]="value"
20
+ (ionChange)="onSelectChanged()"
21
+ [disabled]="isReadonly || !!$isProcessing()"
22
+ >
23
+ @for (item of items; track item.id) {
24
+ <ion-select-option [value]="item.id">
25
+ {{ item.emoji }}
26
+ @if (item.shortTitle) {
27
+ {{ item.shortTitle }}
28
+ } @else {
29
+ {{ item.title }}
30
+ }
31
+ </ion-select-option>
32
+ }
33
+ <!-- <ion-select-option value="other">OTHER</ion-select-option>-->
34
+ </ion-select>
35
+ @if ($isProcessing()) {
36
+ <ion-spinner name="lines-small" color="medium" slot="end" />
37
+ } @else if (!isReadonly) {
38
+ <ion-buttons slot="end" class="ion-no-margin">
39
+ <ion-button color="medium" title="Deselect" (click)="deselect()">
40
+ <ion-icon name="close-outline" />
41
+ </ion-button>
42
+ </ion-buttons>
43
+ }
44
+ </ion-item>
45
+ } @else {
46
+ @if (isFilterable) {
47
+ <ion-item class="sneat-tiny-end-padding">
48
+ <ion-input
49
+ #filterInput
50
+ color="medium"
51
+ placeholder="filter"
52
+ [label]="filterLabel"
53
+ [value]="$filter()"
54
+ (ionChange)="onFilterChanged($event, 'ionChange')"
55
+ (ionInput)="onFilterChanged($event, 'ionInput')"
56
+ />
57
+ @if ($filter()) {
58
+ <ion-buttons slot="end">
59
+ @if (canAdd && $hiddenCount()) {
60
+ <ion-button
61
+ title="Use this"
62
+ color="primary"
63
+ (click)="onAdd($event)"
64
+ >
65
+ <ion-icon name="add-outline" />
66
+ <ion-label>Add</ion-label>
67
+ </ion-button>
68
+ }
69
+ <ion-button (click)="clearFilter()" title="Clear filter">
70
+ <ion-icon name="close-outline" />
71
+ </ion-button>
72
+ </ion-buttons>
73
+ }
74
+ </ion-item>
75
+ }
76
+
77
+ @if (items && !items.length) {
78
+ <ion-item-divider>
79
+ <ion-label>No items yet.</ion-label>
80
+ </ion-item-divider>
81
+ }
82
+
83
+ @if (labelPlacement) {
84
+ <ion-radio-group [(ngModel)]="value" (ionChange)="onRadioChanged($event)">
85
+ <ion-list class="ion-no-padding" lines="full">
86
+ @if (listLabel === "divider") {
87
+ <ion-item [color]="listLabelColor">
88
+ <ion-label>{{ label }}</ion-label>
89
+ </ion-item>
90
+ }
91
+ @for (item of $displayItems(); track item.id) {
92
+ <ion-item
93
+ [lines]="item.description1 ? 'inset' : 'full'"
94
+ (click)="select(item)"
95
+ tappable="true"
96
+ >
97
+ @switch (selectMode) {
98
+ @case ("multiple") {
99
+ <ion-checkbox
100
+ slot="start"
101
+ [value]="item.id"
102
+ [checked]="false"
103
+ [disabled]="isDisabled"
104
+ (ionChange)="onCheckboxChange($event, item)"
105
+ />
106
+ <ion-label>
107
+ <!-- TODO: duplicate code with the next case -->
108
+ {{ item.emoji }}
109
+ @if (item.shortTitle) {
110
+ @if (item.longTitle) {
111
+ {{ item.longTitle }} - {{ item.shortTitle }}
112
+ } @else {
113
+ {{ item.title }} - {{ item.shortTitle }}
114
+ }
115
+ } @else if (item.longTitle) {
116
+ {{ item.longTitle }}
117
+ } @else {
118
+ {{ item.title }}
119
+ }
120
+ </ion-label>
121
+ }
122
+ @case ("single") {
123
+ <ion-radio
124
+ [value]="item.id"
125
+ [labelPlacement]="labelPlacement"
126
+ [justify]="
127
+ justify ||
128
+ (!labelPlacement || labelPlacement === 'start'
129
+ ? 'space-between'
130
+ : 'start')
131
+ "
132
+ >
133
+ {{ item.emoji }}
134
+ @if (item.shortTitle) {
135
+ @if (item.longTitle) {
136
+ {{ item.longTitle }} - {{ item.shortTitle }}
137
+ } @else {
138
+ {{ item.title }} - {{ item.shortTitle }}
139
+ }
140
+ } @else if (item.longTitle) {
141
+ {{ item.longTitle }}
142
+ } @else {
143
+ {{ item.title }}
144
+ }
145
+ </ion-radio>
146
+ }
147
+ }
148
+ </ion-item>
149
+ @if (item.description1) {
150
+ <ion-item-divider>
151
+ <ion-label>
152
+ {{ item.description1 }}
153
+ @if (item.description2) {
154
+ <i>{{ item.description2 }}</i>
155
+ }
156
+ </ion-label>
157
+ </ion-item-divider>
158
+ }
159
+ }
160
+ </ion-list>
161
+ </ion-radio-group>
162
+ } @else {
163
+ <ion-item-group>
164
+ @for (item of $displayItems(); track item.id; let last = $last) {
165
+ <ion-item
166
+ [lines]="item.description1 ? 'inset' : last ? lastItemLines : 'full'"
167
+ button
168
+ (click)="select(item)"
169
+ >
170
+ @if (item.iconName) {
171
+ <ion-icon
172
+ slot="start"
173
+ [name]="item.iconName"
174
+ [color]="item.iconColor || item.labelColor || 'medium'"
175
+ />
176
+ }
177
+ @if (!labelPlacement) {
178
+ <ion-label [color]="item.labelColor">
179
+ <span class="ion-margin-end">{{ item.emoji }}</span>
180
+ {{ item.title }}
181
+ </ion-label>
182
+ }
183
+ </ion-item>
184
+ @if (item.description1) {
185
+ <ion-item [lines]="last ? lastItemLines : 'full'">
186
+ <ion-label color="medium">
187
+ {{ item.description1 }}
188
+ @if (item.description2) {
189
+ <i>{{ item.description2 }}</i>
190
+ }
191
+ </ion-label>
192
+ </ion-item>
193
+ }
194
+ }
195
+ </ion-item-group>
196
+ }
197
+ @if ($hiddenCount(); as hiddenCount) {
198
+ <ion-item-divider>
199
+ <ion-label color="medium"
200
+ >{{ hiddenCount }} out of {{ items?.length }} items are hidden by filter
201
+ </ion-label>
202
+ <ion-buttons slot="end">
203
+ <ion-button (click)="clearFilter()">
204
+ <ion-icon name="close-outline" slot="start" />
205
+ <ion-label>Clear filter</ion-label>
206
+ </ion-button>
207
+ </ion-buttons>
208
+ </ion-item-divider>
209
+ }
210
+ }
@@ -0,0 +1,297 @@
1
+ import { CUSTOM_ELEMENTS_SCHEMA, SimpleChange } from '@angular/core';
2
+ import {
3
+ ComponentFixture,
4
+ fakeAsync,
5
+ TestBed,
6
+ tick,
7
+ waitForAsync,
8
+ } from '@angular/core/testing';
9
+ import { ErrorLogger } from '@sneat/core';
10
+ import { SelectFromListComponent } from './select-from-list.component';
11
+ import { of } from 'rxjs';
12
+ import { ISelectItem } from '../selector-interfaces';
13
+
14
+ describe('SelectFromListComponent', () => {
15
+ let component: SelectFromListComponent;
16
+ let fixture: ComponentFixture<SelectFromListComponent>;
17
+ let errorLoggerMock: ErrorLogger;
18
+
19
+ const mockItems: ISelectItem[] = [
20
+ { id: '1', title: 'Apple' },
21
+ { id: '2', title: 'Banana', longTitle: 'Yellow Banana' },
22
+ { id: '3', title: 'Cherry' },
23
+ ];
24
+
25
+ beforeEach(waitForAsync(async () => {
26
+ errorLoggerMock = {
27
+ logError: vi.fn(),
28
+ logErrorHandler: vi.fn(() => vi.fn()),
29
+ };
30
+
31
+ await TestBed.configureTestingModule({
32
+ imports: [SelectFromListComponent],
33
+ providers: [{ provide: ErrorLogger, useValue: errorLoggerMock }],
34
+ schemas: [CUSTOM_ELEMENTS_SCHEMA],
35
+ })
36
+ .overrideComponent(SelectFromListComponent, {
37
+ set: { imports: [], schemas: [CUSTOM_ELEMENTS_SCHEMA] },
38
+ })
39
+ .compileComponents();
40
+ fixture = TestBed.createComponent(SelectFromListComponent);
41
+ component = fixture.componentInstance;
42
+ fixture.detectChanges();
43
+ }));
44
+
45
+ it('should create', () => {
46
+ expect(component).toBeTruthy();
47
+ });
48
+
49
+ describe('ngOnChanges', () => {
50
+ it('should update items and apply filter when items change', () => {
51
+ component.items = mockItems;
52
+ component.ngOnChanges({
53
+ items: new SimpleChange(undefined, mockItems, true),
54
+ });
55
+ // @ts-expect-error accessing protected member
56
+ expect(component.$displayItems()).toEqual(mockItems);
57
+ });
58
+
59
+ it('should subscribe to items$ and update items when items$ changes', () => {
60
+ const items$ = of(mockItems);
61
+ component.items$ = items$;
62
+ component.ngOnChanges({
63
+ items$: new SimpleChange(undefined, items$, true),
64
+ });
65
+ // @ts-expect-error accessing protected member
66
+ expect(component.$displayItems()).toEqual(mockItems);
67
+ });
68
+ });
69
+
70
+ describe('filtering', () => {
71
+ beforeEach(() => {
72
+ component.items = mockItems;
73
+ component.ngOnChanges({
74
+ items: new SimpleChange(undefined, mockItems, true),
75
+ });
76
+ });
77
+
78
+ it('should filter items by title', () => {
79
+ // @ts-expect-error accessing protected member
80
+ component.onFilterChanged(
81
+ { detail: { value: 'app' } } as CustomEvent,
82
+ 'ionInput',
83
+ );
84
+ // @ts-expect-error accessing protected member
85
+ expect(component.$displayItems()?.length).toBe(1);
86
+ // @ts-expect-error accessing protected member
87
+ expect(component.$displayItems()?.[0].id).toBe('1');
88
+ });
89
+
90
+ it('should filter items by longTitle', () => {
91
+ // @ts-expect-error accessing protected member
92
+ component.onFilterChanged(
93
+ { detail: { value: 'yellow' } } as CustomEvent,
94
+ 'ionInput',
95
+ );
96
+ // @ts-expect-error accessing protected member
97
+ expect(component.$displayItems()?.length).toBe(1);
98
+ // @ts-expect-error accessing protected member
99
+ expect(component.$displayItems()?.[0].id).toBe('2');
100
+ });
101
+
102
+ it('should use custom filter function if provided', () => {
103
+ component.filterItem = (item, filter) => item.id === filter;
104
+ // @ts-expect-error accessing protected member
105
+ component.onFilterChanged({ detail: { value: '3' } } as CustomEvent, 'ionInput');
106
+ // @ts-expect-error accessing protected member
107
+ expect(component.$displayItems()?.length).toBe(1);
108
+ // @ts-expect-error accessing protected member
109
+ expect(component.$displayItems()?.[0].id).toBe('3');
110
+ });
111
+
112
+ it('should clear filter', () => {
113
+ // @ts-expect-error accessing protected member
114
+ component.onFilterChanged(
115
+ { detail: { value: 'app' } } as CustomEvent,
116
+ 'ionInput',
117
+ );
118
+ // @ts-expect-error accessing protected member
119
+ component.clearFilter();
120
+ // @ts-expect-error accessing protected member
121
+ expect(component.$filter()).toBe('');
122
+ // @ts-expect-error accessing protected member
123
+ expect(component.$displayItems()?.length).toBe(3);
124
+ });
125
+ });
126
+
127
+ describe('sorting', () => {
128
+ beforeEach(() => {
129
+ component.items = [
130
+ { id: 'b', title: 'Banana' },
131
+ { id: 'a', title: 'Apple' },
132
+ ];
133
+ });
134
+
135
+ it('should sort items by title', () => {
136
+ component.sortBy = 'title';
137
+ component.ngOnChanges({
138
+ items: new SimpleChange(undefined, component.items, true),
139
+ });
140
+ // @ts-expect-error accessing protected member
141
+ expect(component.$displayItems()?.[0].title).toBe('Apple');
142
+ });
143
+
144
+ it('should sort items by id', () => {
145
+ component.sortBy = 'id';
146
+ component.ngOnChanges({
147
+ items: new SimpleChange(undefined, component.items, true),
148
+ });
149
+ // @ts-expect-error accessing protected member
150
+ expect(component.$displayItems()?.[0].id).toBe('a');
151
+ });
152
+ });
153
+
154
+ describe('selection', () => {
155
+ beforeEach(() => {
156
+ component.items = mockItems;
157
+ component.ngOnChanges({
158
+ items: new SimpleChange(undefined, mockItems, true),
159
+ });
160
+ });
161
+
162
+ it('should select item in single mode', () => {
163
+ const onChange = vi.fn();
164
+ component.registerOnChange(onChange);
165
+ // @ts-expect-error accessing protected member
166
+ component.select(mockItems[0]);
167
+ // @ts-expect-error accessing protected member
168
+ expect(component.$selectedItem()).toEqual(mockItems[0]);
169
+ expect(onChange).toHaveBeenCalledWith('1');
170
+ });
171
+
172
+ it('should handle radio change in single mode', () => {
173
+ const onChange = vi.fn();
174
+ component.registerOnChange(onChange);
175
+ // @ts-expect-error accessing protected member
176
+ component.onRadioChanged({ detail: { value: '2' } } as CustomEvent);
177
+ expect(component.value).toBe('2');
178
+ expect(onChange).toHaveBeenCalledWith('2');
179
+ });
180
+
181
+ it('should handle select change', () => {
182
+ const onChange = vi.fn();
183
+ component.registerOnChange(onChange);
184
+ component.value = '3';
185
+ // @ts-expect-error accessing protected member
186
+ component.onSelectChanged({} as Event);
187
+ expect(onChange).toHaveBeenCalledWith('3');
188
+ });
189
+
190
+ it('should deselect item', () => {
191
+ const onChange = vi.fn();
192
+ component.registerOnChange(onChange);
193
+ // @ts-expect-error accessing protected member
194
+ component.deselect();
195
+ expect(component.value).toBe('');
196
+ expect(onChange).toHaveBeenCalledWith('');
197
+ });
198
+ });
199
+
200
+ describe('ControlValueAccessor', () => {
201
+ it('should write value', () => {
202
+ component.writeValue('test');
203
+ expect(component.value).toBe('test');
204
+ });
205
+
206
+ it('should register on change', () => {
207
+ const fn = vi.fn();
208
+ component.registerOnChange(fn);
209
+ // @ts-expect-error accessing protected member
210
+ component.onChange('test');
211
+ expect(fn).toHaveBeenCalledWith('test');
212
+ });
213
+
214
+ it('should register on touched', () => {
215
+ const fn = vi.fn();
216
+ component.registerOnTouched(fn);
217
+ component.onTouched();
218
+ expect(fn).toHaveBeenCalled();
219
+ });
220
+
221
+ it('should set disabled state', () => {
222
+ component.setDisabledState(true);
223
+ // @ts-expect-error accessing protected member
224
+ expect(component.isDisabled).toBe(true);
225
+ });
226
+ });
227
+
228
+ describe('focus', () => {
229
+ it('should call setFocus on filterInput after timeout', fakeAsync(() => {
230
+ const filterInputMock = {
231
+ setFocus: vi.fn(() => Promise.resolve()),
232
+ };
233
+ component.filterInput = filterInputMock as unknown;
234
+ component.focus();
235
+ tick(100);
236
+ expect(filterInputMock.setFocus).toHaveBeenCalled();
237
+ }));
238
+ });
239
+
240
+ describe('onAdd', () => {
241
+ it('should use current filter as value when onAdd is called', () => {
242
+ const onChange = vi.fn();
243
+ component.registerOnChange(onChange);
244
+ // @ts-expect-error accessing protected member
245
+ component.$filter.set('New Item');
246
+ const event = { preventDefault: vi.fn() };
247
+ // @ts-expect-error accessing protected member
248
+ component.onAdd(event as Event);
249
+ expect(event.preventDefault).toHaveBeenCalled();
250
+ expect(component.value).toBe('New Item');
251
+ expect(onChange).toHaveBeenCalledWith('New Item');
252
+ });
253
+ });
254
+
255
+ describe('additional coverage', () => {
256
+ it('should ignore radio change if select mode is not single', () => {
257
+ component.selectMode = 'multiple';
258
+ component.value = '1';
259
+ // @ts-expect-error accessing protected member
260
+ component.onRadioChanged({ detail: { value: '2' } } as CustomEvent);
261
+ expect(component.value).toBe('1');
262
+ });
263
+
264
+ it('should handle checkbox change', () => {
265
+ const itemBefore = component.value;
266
+ // @ts-expect-error accessing protected member
267
+ component.onCheckboxChange({} as Event, mockItems[0]);
268
+ expect(component.value).toBe(itemBefore);
269
+ });
270
+
271
+ it('should handle onChange with an ID that does not exist in items', () => {
272
+ component.items = mockItems;
273
+ // @ts-expect-error accessing protected member
274
+ component.onChange('non-existent');
275
+ // @ts-expect-error accessing protected member
276
+ expect(component.$selectedItem()).toBeUndefined();
277
+ });
278
+
279
+ it('should ignore select if select mode is multiple', () => {
280
+ component.selectMode = 'multiple';
281
+ const onChange = vi.fn();
282
+ component.registerOnChange(onChange);
283
+ // @ts-expect-error accessing protected member
284
+ component.select(mockItems[0]);
285
+ expect(onChange).not.toHaveBeenCalled();
286
+ });
287
+
288
+ it('should clear filter when selecting an item', () => {
289
+ // @ts-expect-error accessing protected member
290
+ component.$filter.set('app');
291
+ // @ts-expect-error accessing protected member
292
+ component.select(mockItems[0]);
293
+ // @ts-expect-error accessing protected member
294
+ expect(component.$filter()).toBe('');
295
+ });
296
+ });
297
+ });