mirror of
https://dev.azure.com/hugendubel/ISA/_git/ISA-Frontend
synced 2025-12-28 22:42:11 +01:00
Merged PR 2005: feat(shared-filter, ui-switch): add switch filter menu button for inline toggle filters
feat(shared-filter, ui-switch): add switch filter menu button for inline toggle filters
Add a new SwitchMenuButtonComponent that renders filter inputs as compact toggle switches
without an overlay menu. This provides a more streamlined UX for simple boolean/single-option
filters directly in the controls panel.
Key changes:
- Create new switch-menu module with button component and tests
- Extend FilterControlsPanelComponent to accept switchFilters input array
- Rename IconSwitchComponent to SwitchComponent for consistency
- Update filter actions to use 'target' property instead of 'group' for filtering
- Add isEmptyFilterInput support for NumberRange inputs
- Export switch-menu module from shared/filter public API
The switch button auto-commits on toggle and uses the checkbox filter model internally,
allowing simple configuration like:
switchFilters = [{ filter: stockFilter, icon: 'isaFiliale' }]
This implementation follows the existing filter architecture patterns and maintains
full accessibility support through ARIA attributes and keyboard navigation.
Ref: #5427
This commit is contained in:
committed by
Lorenz Hilpert
parent
a52928d212
commit
eb0d96698c
@@ -20,6 +20,7 @@
|
||||
@import "../../../libs/ui/skeleton-loader/src/skeleton-loader.scss";
|
||||
@import "../../../libs/ui/tooltip/src/tooltip.scss";
|
||||
@import "../../../libs/ui/label/src/label.scss";
|
||||
@import "../../../libs/ui/switch/src/switch.scss";
|
||||
|
||||
.input-control {
|
||||
@apply rounded border border-solid border-[#AEB7C1] px-4 py-[1.125rem] outline-none;
|
||||
|
||||
104
apps/isa-app/stories/ui/switch/ui-icon-switch.stories.ts
Normal file
104
apps/isa-app/stories/ui/switch/ui-icon-switch.stories.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { argsToTemplate, type Meta, type StoryObj } from '@storybook/angular';
|
||||
import { IconSwitchComponent, IconSwitchColor } from '@isa/ui/switch';
|
||||
import { provideIcons } from '@ng-icons/core';
|
||||
import { isaFiliale, IsaIcons, isaNavigationDashboard } from '@isa/icons';
|
||||
|
||||
type IconSwitchComponentInputs = {
|
||||
icon: string;
|
||||
checked: boolean;
|
||||
color: IconSwitchColor;
|
||||
disabled: boolean;
|
||||
};
|
||||
|
||||
const meta: Meta<IconSwitchComponentInputs> = {
|
||||
component: IconSwitchComponent,
|
||||
title: 'ui/switch/IconSwitch',
|
||||
decorators: [
|
||||
(story) => ({
|
||||
...story(),
|
||||
applicationConfig: {
|
||||
providers: [provideIcons(IsaIcons)],
|
||||
},
|
||||
}),
|
||||
],
|
||||
argTypes: {
|
||||
icon: {
|
||||
control: { type: 'select' },
|
||||
options: Object.keys(IsaIcons),
|
||||
description: 'The name of the icon to display in the switch',
|
||||
},
|
||||
checked: {
|
||||
control: 'boolean',
|
||||
description: 'Whether the switch is checked (on) or not (off)',
|
||||
},
|
||||
color: {
|
||||
control: { type: 'select' },
|
||||
options: Object.values(IconSwitchColor),
|
||||
description: 'Determines the switch color theme',
|
||||
},
|
||||
disabled: {
|
||||
control: 'boolean',
|
||||
description: 'Disables the switch when true',
|
||||
},
|
||||
},
|
||||
args: {
|
||||
icon: 'isaFiliale',
|
||||
checked: false,
|
||||
color: 'primary',
|
||||
disabled: false,
|
||||
},
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: `<ui-icon-switch ${argsToTemplate(args)}></ui-icon-switch>`,
|
||||
}),
|
||||
};
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<IconSwitchComponent>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {},
|
||||
};
|
||||
|
||||
export const Enabled: Story = {
|
||||
args: {
|
||||
checked: true,
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'The switch in its enabled/checked state with the primary color theme.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: {
|
||||
checked: false,
|
||||
disabled: true,
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'The switch in a disabled state. User interactions are prevented.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const EnabledAndDisabled: Story = {
|
||||
args: {
|
||||
checked: true,
|
||||
disabled: true,
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'The switch in both enabled and disabled states simultaneously.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -1,5 +1,8 @@
|
||||
<reward-header></reward-header>
|
||||
<filter-controls-panel (triggerSearch)="search($event)"></filter-controls-panel>
|
||||
<filter-controls-panel
|
||||
[switchFilters]="displayStockFilterSwitch()"
|
||||
(triggerSearch)="search($event)"
|
||||
></filter-controls-panel>
|
||||
<reward-list
|
||||
[searchTrigger]="searchTrigger()"
|
||||
(searchTriggerChange)="searchTrigger.set($event)"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
inject,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
@@ -12,6 +13,7 @@ import {
|
||||
FilterControlsPanelComponent,
|
||||
SearchTrigger,
|
||||
FilterService,
|
||||
FilterInput,
|
||||
} from '@isa/shared/filter';
|
||||
import { RewardHeaderComponent } from './reward-header/reward-header.component';
|
||||
import { RewardListComponent } from './reward-list/reward-list.component';
|
||||
@@ -59,6 +61,21 @@ export class RewardCatalogComponent {
|
||||
|
||||
#filterService = inject(FilterService);
|
||||
|
||||
displayStockFilterSwitch = computed(() => {
|
||||
const stockInput = this.#filterService
|
||||
.inputs()
|
||||
?.filter((input) => input.target === 'filter')
|
||||
?.find((input) => input.key === 'stock') as FilterInput | undefined;
|
||||
return stockInput
|
||||
? [
|
||||
{
|
||||
filter: stockInput,
|
||||
icon: 'isaFiliale',
|
||||
},
|
||||
]
|
||||
: [];
|
||||
});
|
||||
|
||||
search(trigger: SearchTrigger): void {
|
||||
this.searchTrigger.set(trigger); // Ist entweder 'scan', 'input', 'filter' oder 'orderBy'
|
||||
this.#filterService.commit();
|
||||
|
||||
@@ -5,5 +5,6 @@ export * from './lib/types';
|
||||
export * from './lib/actions';
|
||||
export * from './lib/menus/filter-menu';
|
||||
export * from './lib/menus/input-menu';
|
||||
export * from './lib/menus/switch-menu';
|
||||
export * from './lib/order-by';
|
||||
export * from './lib/controls-panel';
|
||||
|
||||
@@ -13,8 +13,8 @@ describe('FilterActionsComponent', () => {
|
||||
providers: [
|
||||
mockProvider(FilterService, {
|
||||
inputs: jest.fn().mockReturnValue([
|
||||
{ group: 'filter', key: 'key1' },
|
||||
{ group: 'other', key: 'key2' },
|
||||
{ target: 'filter', key: 'key1', group: 'filter' },
|
||||
{ target: 'input', key: 'key2', group: 'other' },
|
||||
]),
|
||||
commit: jest.fn(),
|
||||
reset: jest.fn(),
|
||||
@@ -31,9 +31,11 @@ describe('FilterActionsComponent', () => {
|
||||
expect(spectator.component).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should filter inputs by group "filter"', () => {
|
||||
it('should filter inputs by target "filter"', () => {
|
||||
const filteredInputs = spectator.component.filterInputs();
|
||||
expect(filteredInputs).toEqual([{ group: 'filter', key: 'key1' }]);
|
||||
expect(filteredInputs).toEqual([
|
||||
{ target: 'filter', key: 'key1', group: 'filter' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should call commit and emit applied when onApply is called without inputKey', () => {
|
||||
|
||||
@@ -54,10 +54,10 @@ export class FilterActionsComponent {
|
||||
canApply = input<boolean>(true);
|
||||
|
||||
/**
|
||||
* Computed signal that filters inputs to only include those with 'filter' group
|
||||
* Computed signal that filters inputs to only include those with target 'filter'
|
||||
*/
|
||||
filterInputs = computed(() =>
|
||||
this.filterService.inputs().filter((input) => input.group === 'filter'),
|
||||
this.filterService.inputs().filter((input) => input.target === 'filter'),
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -85,7 +85,7 @@ export class FilterActionsComponent {
|
||||
* Resets filter values to their defaults
|
||||
*
|
||||
* If inputKey is provided, only that specific filter input is reset.
|
||||
* Otherwise, all filter inputs in the 'filter' group are reset.
|
||||
* Otherwise, all filter inputs with target 'filter' are reset.
|
||||
* After resetting, all changes are committed.
|
||||
*/
|
||||
onReset() {
|
||||
|
||||
@@ -13,6 +13,14 @@
|
||||
}
|
||||
|
||||
<div class="flex flex-row gap-4 items-center">
|
||||
@for (switchFilter of switchFilters(); track switchFilter.filter.key) {
|
||||
<filter-switch-menu-button
|
||||
[filterInput]="switchFilter.filter"
|
||||
[icon]="switchFilter.icon"
|
||||
(toggled)="triggerSearch.emit('filter')"
|
||||
></filter-switch-menu-button>
|
||||
}
|
||||
|
||||
@if (hasFilter()) {
|
||||
<filter-filter-menu-button
|
||||
(applied)="triggerSearch.emit('filter')"
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
Component,
|
||||
computed,
|
||||
inject,
|
||||
input,
|
||||
linkedSignal,
|
||||
output,
|
||||
signal,
|
||||
@@ -15,8 +16,9 @@ import { IconButtonComponent } from '@isa/ui/buttons';
|
||||
import { OrderByToolbarComponent } from '../order-by';
|
||||
import { Breakpoint, breakpoint } from '@isa/ui/layout';
|
||||
import { InputType, SearchTrigger } from '../types';
|
||||
import { FilterService, TextFilterInput } from '../core';
|
||||
import { FilterService, TextFilterInput, FilterInput } from '../core';
|
||||
import { SearchBarInputComponent } from '../inputs';
|
||||
import { SwitchMenuButtonComponent } from '../menus/switch-menu';
|
||||
|
||||
/**
|
||||
* Filter controls panel component that provides a unified interface for search and filtering operations.
|
||||
@@ -50,6 +52,7 @@ import { SearchBarInputComponent } from '../inputs';
|
||||
FilterMenuButtonComponent,
|
||||
IconButtonComponent,
|
||||
OrderByToolbarComponent,
|
||||
SwitchMenuButtonComponent,
|
||||
],
|
||||
host: {
|
||||
'[class]': "['filter-controls-panel']",
|
||||
@@ -68,6 +71,23 @@ export class FilterControlsPanelComponent {
|
||||
*/
|
||||
inputKey = signal('qs');
|
||||
|
||||
/**
|
||||
* Optional array of switch filter configurations to display as toggle switches.
|
||||
* Each item should be a filter input with an associated icon.
|
||||
* Switch filters are rendered to the left of the filter menu button.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* switchFilters = [
|
||||
* {
|
||||
* filter: availabilityFilter,
|
||||
* icon: 'isaActionCheck'
|
||||
* }
|
||||
* ]
|
||||
* ```
|
||||
*/
|
||||
switchFilters = input<Array<{ filter: FilterInput; icon: string }>>([]);
|
||||
|
||||
/**
|
||||
* Output event that emits when any search action is triggered.
|
||||
* Provides the specific SearchTrigger type to indicate how the search was initiated:
|
||||
|
||||
@@ -526,6 +526,10 @@ export class FilterService {
|
||||
return !input.start && !input.stop;
|
||||
}
|
||||
|
||||
if (input.type === InputType.NumberRange) {
|
||||
return input.min == null && input.max == null;
|
||||
}
|
||||
|
||||
this.#logger.warn(`Input type not supported`, () => ({
|
||||
input,
|
||||
method: 'isEmptyFilter',
|
||||
@@ -540,6 +544,7 @@ export class FilterService {
|
||||
* For text inputs, checks if the value is falsy.
|
||||
* For checkbox inputs, checks if the selected array is empty.
|
||||
* For date range inputs, checks if both start and stop are falsy.
|
||||
* For number range inputs, checks if both min and max are null or undefined.
|
||||
*
|
||||
* @param filterInput - The filter input to check
|
||||
* @returns True if the filter input is empty, false otherwise
|
||||
@@ -569,6 +574,10 @@ export class FilterService {
|
||||
return !currentInputState.start && !currentInputState.stop;
|
||||
}
|
||||
|
||||
if (currentInputState.type === InputType.NumberRange) {
|
||||
return currentInputState.min == null && currentInputState.max == null;
|
||||
}
|
||||
|
||||
this.logUnsupportedInputType(currentInputState, 'isEmptyFilterInput');
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ describe('CheckboxInputComponent', () => {
|
||||
group: 'group',
|
||||
key: 'key',
|
||||
type: InputType.Checkbox,
|
||||
target: 'filter' as const,
|
||||
options: [option],
|
||||
selected: [],
|
||||
label: 'label',
|
||||
@@ -60,6 +61,7 @@ describe('CheckboxInputComponent', () => {
|
||||
group: 'group',
|
||||
key: 'key',
|
||||
type: InputType.Checkbox,
|
||||
target: 'filter' as const,
|
||||
options: [option],
|
||||
selected: [],
|
||||
label: 'label',
|
||||
@@ -102,6 +104,7 @@ describe('CheckboxInputComponent', () => {
|
||||
group: 'group',
|
||||
key: 'key',
|
||||
type: InputType.Checkbox,
|
||||
target: 'filter' as const,
|
||||
options: [option],
|
||||
selected: ['opt'],
|
||||
label: 'label',
|
||||
|
||||
1
libs/shared/filter/src/lib/menus/switch-menu/index.ts
Normal file
1
libs/shared/filter/src/lib/menus/switch-menu/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './switch-menu-button.component';
|
||||
@@ -0,0 +1,8 @@
|
||||
@let inp = input();
|
||||
@if (inp) {
|
||||
<ui-switch
|
||||
[icon]="icon()"
|
||||
[checked]="checked()"
|
||||
(checkedChange)="onToggle($event)"
|
||||
></ui-switch>
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
.filter-switch-menu-button {
|
||||
@apply flex;
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { SwitchMenuButtonComponent } from './switch-menu-button.component';
|
||||
import { FilterService } from '../../core';
|
||||
import { InputType } from '../../types';
|
||||
import { signal } from '@angular/core';
|
||||
|
||||
describe('SwitchMenuButtonComponent', () => {
|
||||
let component: SwitchMenuButtonComponent;
|
||||
let fixture: ComponentFixture<SwitchMenuButtonComponent>;
|
||||
let mockFilterService: jest.Mocked<Partial<FilterService>>;
|
||||
|
||||
beforeEach(async () => {
|
||||
mockFilterService = {
|
||||
inputs: signal([
|
||||
{
|
||||
type: InputType.Checkbox,
|
||||
key: 'test-switch',
|
||||
label: 'Test Switch',
|
||||
group: 'test-group',
|
||||
target: 'filter' as const,
|
||||
options: [{ key: 'option1', label: 'Option 1' }],
|
||||
selected: [],
|
||||
},
|
||||
]),
|
||||
isDefaultFilterInput: jest.fn().mockReturnValue(true),
|
||||
setInputCheckboxOptionSelected: jest.fn(),
|
||||
} as any;
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [SwitchMenuButtonComponent],
|
||||
providers: [{ provide: FilterService, useValue: mockFilterService }],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(SwitchMenuButtonComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.componentRef.setInput('filterInput', {
|
||||
type: InputType.Checkbox,
|
||||
key: 'test-switch',
|
||||
label: 'Test Switch',
|
||||
group: 'test-group',
|
||||
target: 'filter' as const,
|
||||
options: [{ key: 'option1', label: 'Option 1' }],
|
||||
selected: [],
|
||||
});
|
||||
fixture.componentRef.setInput('icon', 'isaActionCheck');
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should retrieve the correct checkbox input', () => {
|
||||
const input = component.input();
|
||||
expect(input).toBeDefined();
|
||||
expect(input.key).toBe('test-switch');
|
||||
});
|
||||
|
||||
it('should return false for checked when no options are selected', () => {
|
||||
expect(component.checked()).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true for checked when options are selected', () => {
|
||||
fixture.componentRef.setInput('filterInput', {
|
||||
type: InputType.Checkbox,
|
||||
key: 'test-switch',
|
||||
label: 'Test Switch',
|
||||
group: 'test-group',
|
||||
target: 'filter' as const,
|
||||
options: [{ key: 'option1', label: 'Option 1' }],
|
||||
selected: [{ key: 'option1', label: 'Option 1' }],
|
||||
});
|
||||
|
||||
mockFilterService.inputs = signal([
|
||||
{
|
||||
type: InputType.Checkbox,
|
||||
key: 'test-switch',
|
||||
label: 'Test Switch',
|
||||
group: 'test-group',
|
||||
target: 'filter' as const,
|
||||
options: [{ key: 'option1', label: 'Option 1' }],
|
||||
selected: [{ key: 'option1', label: 'Option 1' }],
|
||||
},
|
||||
]) as any;
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.checked()).toBe(true);
|
||||
});
|
||||
|
||||
it('should call setInputCheckboxOptionSelected when toggled', () => {
|
||||
component.onToggle(true);
|
||||
|
||||
expect(
|
||||
mockFilterService.setInputCheckboxOptionSelected,
|
||||
).toHaveBeenCalledWith({ key: 'option1', label: 'Option 1' }, true, {
|
||||
commit: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should retrieve the first option', () => {
|
||||
const option = component.option();
|
||||
expect(option).toEqual({ key: 'option1', label: 'Option 1' });
|
||||
});
|
||||
|
||||
it('should emit toggled event when onToggle is called', () => {
|
||||
const spy = jest.fn();
|
||||
component.toggled.subscribe(spy);
|
||||
|
||||
component.onToggle(true);
|
||||
|
||||
expect(spy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should check if input is in default state', () => {
|
||||
expect(component.isDefaultInputState()).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
inject,
|
||||
input,
|
||||
output,
|
||||
ViewEncapsulation,
|
||||
} from '@angular/core';
|
||||
import { CheckboxFilterInput, FilterInput, FilterService } from '../../core';
|
||||
import { InputType } from '../../types';
|
||||
import { SwitchComponent } from '@isa/ui/switch';
|
||||
|
||||
/**
|
||||
* A switch button component for filtering that displays as a toggle switch.
|
||||
* Unlike the input-menu-button, this component renders directly without an overlay menu.
|
||||
* It's designed for simple boolean/single-option filters where a compact toggle is preferred.
|
||||
*
|
||||
* Features:
|
||||
* - Direct toggle interaction (no menu)
|
||||
* - Auto-commits on toggle
|
||||
* - Compact visual footprint
|
||||
* - Uses checkbox filter model internally
|
||||
*
|
||||
* @example
|
||||
* ```html
|
||||
* <filter-switch-menu-button
|
||||
* [filterInput]="availabilityFilter"
|
||||
* [icon]="'isaActionCheck'"
|
||||
* (toggled)="handleFilterToggle()">
|
||||
* </filter-switch-menu-button>
|
||||
* ```
|
||||
*/
|
||||
@Component({
|
||||
selector: 'filter-switch-menu-button',
|
||||
templateUrl: './switch-menu-button.component.html',
|
||||
styleUrls: ['./switch-menu-button.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
standalone: true,
|
||||
imports: [SwitchComponent],
|
||||
host: {
|
||||
'[class]': "['filter-switch-menu-button']",
|
||||
},
|
||||
})
|
||||
export class SwitchMenuButtonComponent {
|
||||
/** Filter service for managing filter state */
|
||||
#filter = inject(FilterService);
|
||||
|
||||
/**
|
||||
* The filter input configuration used to render the switch.
|
||||
*/
|
||||
filterInput = input.required<FilterInput>();
|
||||
|
||||
/**
|
||||
* The icon name to display in the switch.
|
||||
*/
|
||||
icon = input.required<string>();
|
||||
|
||||
/**
|
||||
* Emits an event when the switch is toggled.
|
||||
*/
|
||||
toggled = output<void>();
|
||||
|
||||
/**
|
||||
* Computed property that retrieves the checkbox filter input from FilterService.
|
||||
* This component uses the checkbox model internally while rendering as a switch.
|
||||
*/
|
||||
input = computed<CheckboxFilterInput>(() => {
|
||||
const filterInput = this.filterInput();
|
||||
const inputs = this.#filter.inputs();
|
||||
return inputs.find(
|
||||
(input) =>
|
||||
input.key === filterInput.key && input.type === InputType.Checkbox,
|
||||
) as CheckboxFilterInput;
|
||||
});
|
||||
|
||||
/**
|
||||
* Computed property that determines if the switch is checked.
|
||||
* For switch inputs, we consider it checked if there's at least one selected option.
|
||||
* Typically used with single-option checkbox filters.
|
||||
*/
|
||||
checked = computed<boolean>(() => {
|
||||
const input = this.input();
|
||||
return input?.selected && input.selected.length > 0;
|
||||
});
|
||||
|
||||
/**
|
||||
* Computed property that gets the first option from the checkbox input.
|
||||
* Switch inputs typically work with single-option checkbox configurations.
|
||||
*/
|
||||
option = computed(() => {
|
||||
const input = this.input();
|
||||
return input?.options?.[0];
|
||||
});
|
||||
|
||||
/**
|
||||
* Determines whether the current input state is the default state.
|
||||
*/
|
||||
isDefaultInputState = computed(() => {
|
||||
const input = this.filterInput();
|
||||
return this.#filter.isDefaultFilterInput(input);
|
||||
});
|
||||
|
||||
/**
|
||||
* Handles the switch toggle event.
|
||||
* When toggled, selects or deselects the first option in the checkbox filter.
|
||||
*
|
||||
* @param checked - The new checked state
|
||||
*/
|
||||
onToggle(checked: boolean): void {
|
||||
const option = this.option();
|
||||
if (option) {
|
||||
this.#filter.setInputCheckboxOptionSelected(option, checked, {
|
||||
commit: true,
|
||||
});
|
||||
}
|
||||
this.toggled.emit();
|
||||
}
|
||||
}
|
||||
185
libs/ui/switch/README.md
Normal file
185
libs/ui/switch/README.md
Normal file
@@ -0,0 +1,185 @@
|
||||
# UI Switch Library
|
||||
|
||||
This library provides a toggle switch component with an icon for Angular applications.
|
||||
|
||||
## Components
|
||||
|
||||
### IconSwitchComponent
|
||||
|
||||
A toggle switch component that displays an icon and supports two states (on/off).
|
||||
|
||||
**Important**: Use only when the icon meaning is universally clear, otherwise prefer a labeled switch.
|
||||
|
||||
#### Features
|
||||
|
||||
- ✅ Customizable icon via `ng-icons`
|
||||
- ✅ Two-way binding with model signal
|
||||
- ✅ Hover state styling
|
||||
- ✅ Disabled state handling
|
||||
- ✅ Keyboard navigation support (Enter, Space)
|
||||
- ✅ Fully accessible with ARIA attributes
|
||||
- ✅ Smooth animations
|
||||
|
||||
#### Usage
|
||||
|
||||
```typescript
|
||||
import { Component } from '@angular/core';
|
||||
import { IconSwitchComponent } from '@isa/ui/switch';
|
||||
import { provideIcons } from '@ng-icons/core';
|
||||
import { isaNavigationDashboard } from '@isa/icons';
|
||||
|
||||
@Component({
|
||||
selector: 'app-example',
|
||||
standalone: true,
|
||||
imports: [IconSwitchComponent],
|
||||
providers: [provideIcons({ isaNavigationDashboard })],
|
||||
template: `
|
||||
<ui-icon-switch
|
||||
icon="isaNavigationDashboard"
|
||||
[(checked)]="isEnabled"
|
||||
[disabled]="false">
|
||||
</ui-icon-switch>
|
||||
`,
|
||||
})
|
||||
export class ExampleComponent {
|
||||
isEnabled = false;
|
||||
}
|
||||
```
|
||||
|
||||
#### Inputs
|
||||
|
||||
| Property | Type | Default | Description |
|
||||
| ---------- | ---------------- | ----------- | ---------------------------------------------------- |
|
||||
| `icon` | `string` | (required) | The name of the icon to display |
|
||||
| `checked` | `boolean` | `false` | Two-way bindable signal for the checked state |
|
||||
| `color` | `IconSwitchColor`| `'primary'` | The color theme of the switch |
|
||||
| `disabled` | `boolean` | `false` | Whether the switch is disabled |
|
||||
| `tabIndex` | `number` | `0` | The tab index for keyboard navigation |
|
||||
|
||||
#### Two-Way Binding
|
||||
|
||||
The `checked` property uses Angular's new model signal syntax for two-way binding:
|
||||
|
||||
```html
|
||||
<!-- Two-way binding -->
|
||||
<ui-icon-switch icon="isaNavigationDashboard" [(checked)]="isEnabled"></ui-icon-switch>
|
||||
|
||||
<!-- One-way binding -->
|
||||
<ui-icon-switch icon="isaNavigationDashboard" [checked]="isEnabled"></ui-icon-switch>
|
||||
|
||||
<!-- Event handling -->
|
||||
<ui-icon-switch
|
||||
icon="isaNavigationDashboard"
|
||||
[checked]="isEnabled"
|
||||
(checkedChange)="onToggle($event)">
|
||||
</ui-icon-switch>
|
||||
```
|
||||
|
||||
#### Accessibility
|
||||
|
||||
The component follows WAI-ARIA best practices:
|
||||
|
||||
- `role="switch"` - Identifies the element as a switch
|
||||
- `aria-checked` - Indicates the current state
|
||||
- `aria-disabled` - Indicates when the switch is disabled
|
||||
- `tabindex` - Allows keyboard focus and navigation
|
||||
- Keyboard support:
|
||||
- `Enter` - Toggles the switch
|
||||
- `Space` - Toggles the switch
|
||||
|
||||
#### Styling
|
||||
|
||||
The component uses the following CSS classes:
|
||||
|
||||
- `.ui-icon-switch` - Main component class
|
||||
- `.ui-icon-switch__primary` - Primary color theme (currently the only supported theme)
|
||||
- `.ui-icon-switch__track` - The pill-shaped background track
|
||||
- `.ui-icon-switch__track--checked` - Applied when checked
|
||||
- `.ui-icon-switch__thumb` - The circular toggle indicator with icon
|
||||
- `.ui-icon-switch__thumb--checked` - Applied when checked
|
||||
- `.disabled` - Applied when disabled
|
||||
|
||||
#### Color Themes
|
||||
|
||||
Currently, only the `primary` theme is supported, which uses:
|
||||
|
||||
- **Unchecked**: Neutral gray background (`isa-neutral-300`)
|
||||
- **Checked**: Secondary blue background (`isa-secondary-600`)
|
||||
- **Hover (unchecked)**: Darker neutral gray (`isa-neutral-400`)
|
||||
- **Hover (checked)**: Darker secondary blue (`isa-secondary-700`)
|
||||
- **Thumb**: White background with icon color matching the state
|
||||
|
||||
#### Examples
|
||||
|
||||
**Basic usage:**
|
||||
|
||||
```html
|
||||
<ui-icon-switch
|
||||
icon="isaNavigationDashboard"
|
||||
[(checked)]="dashboardEnabled">
|
||||
</ui-icon-switch>
|
||||
```
|
||||
|
||||
**Disabled switch:**
|
||||
|
||||
```html
|
||||
<ui-icon-switch
|
||||
icon="isaNavigationDashboard"
|
||||
[(checked)]="isEnabled"
|
||||
[disabled]="true">
|
||||
</ui-icon-switch>
|
||||
```
|
||||
|
||||
**With event handling:**
|
||||
|
||||
```typescript
|
||||
@Component({
|
||||
template: `
|
||||
<ui-icon-switch
|
||||
icon="isaNavigationDashboard"
|
||||
[(checked)]="isEnabled"
|
||||
(checkedChange)="handleToggle($event)">
|
||||
</ui-icon-switch>
|
||||
`,
|
||||
})
|
||||
export class ExampleComponent {
|
||||
isEnabled = false;
|
||||
|
||||
handleToggle(checked: boolean): void {
|
||||
console.log('Switch toggled:', checked);
|
||||
// Perform action based on state
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Design Guidelines
|
||||
|
||||
**When to use Icon Switch:**
|
||||
|
||||
- ✅ The icon meaning is universally clear (e.g., home, dashboard, notification)
|
||||
- ✅ Space is limited
|
||||
- ✅ The action is binary (on/off, enabled/disabled)
|
||||
|
||||
**When NOT to use Icon Switch:**
|
||||
|
||||
- ❌ The icon meaning is ambiguous or context-specific
|
||||
- ❌ Multiple related switches need differentiation
|
||||
- ❌ Users need explicit labels for clarity
|
||||
|
||||
For cases where labels are needed, consider using a standard labeled switch component instead.
|
||||
|
||||
## Development
|
||||
|
||||
### Running unit tests
|
||||
|
||||
Run `nx test ui-switch` to execute the unit tests with Vitest.
|
||||
|
||||
### Running Storybook
|
||||
|
||||
The component has a Storybook story at `apps/isa-app/stories/ui/switch/ui-icon-switch.stories.ts`.
|
||||
|
||||
Run Storybook to see the component in action:
|
||||
|
||||
```bash
|
||||
npm run storybook
|
||||
```
|
||||
34
libs/ui/switch/eslint.config.cjs
Normal file
34
libs/ui/switch/eslint.config.cjs
Normal file
@@ -0,0 +1,34 @@
|
||||
const nx = require('@nx/eslint-plugin');
|
||||
const baseConfig = require('../../../eslint.config.js');
|
||||
|
||||
module.exports = [
|
||||
...baseConfig,
|
||||
...nx.configs['flat/angular'],
|
||||
...nx.configs['flat/angular-template'],
|
||||
{
|
||||
files: ['**/*.ts'],
|
||||
rules: {
|
||||
'@angular-eslint/directive-selector': [
|
||||
'error',
|
||||
{
|
||||
type: 'attribute',
|
||||
prefix: 'lib',
|
||||
style: 'camelCase',
|
||||
},
|
||||
],
|
||||
'@angular-eslint/component-selector': [
|
||||
'error',
|
||||
{
|
||||
type: 'element',
|
||||
prefix: 'lib',
|
||||
style: 'kebab-case',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['**/*.html'],
|
||||
// Override or add rules here
|
||||
rules: {},
|
||||
},
|
||||
];
|
||||
20
libs/ui/switch/project.json
Normal file
20
libs/ui/switch/project.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "ui-switch",
|
||||
"$schema": "../../../node_modules/nx/schemas/project-schema.json",
|
||||
"sourceRoot": "libs/ui/switch/src",
|
||||
"prefix": "lib",
|
||||
"projectType": "library",
|
||||
"tags": [],
|
||||
"targets": {
|
||||
"test": {
|
||||
"executor": "@nx/vite:test",
|
||||
"outputs": ["{options.reportsDirectory}"],
|
||||
"options": {
|
||||
"reportsDirectory": "../../../coverage/libs/ui/switch"
|
||||
}
|
||||
},
|
||||
"lint": {
|
||||
"executor": "@nx/eslint:lint"
|
||||
}
|
||||
}
|
||||
}
|
||||
2
libs/ui/switch/src/index.ts
Normal file
2
libs/ui/switch/src/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './lib/switch.component';
|
||||
export * from './lib/types';
|
||||
84
libs/ui/switch/src/lib/_switch.scss
Normal file
84
libs/ui/switch/src/lib/_switch.scss
Normal file
@@ -0,0 +1,84 @@
|
||||
.ui-switch {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
|
||||
&.disabled {
|
||||
@apply cursor-default pointer-events-none opacity-50;
|
||||
}
|
||||
}
|
||||
|
||||
.ui-switch__track {
|
||||
position: relative;
|
||||
width: 5.25rem;
|
||||
height: 3rem;
|
||||
border-radius: 1.5rem;
|
||||
transition: background-color 0.2s ease-in-out;
|
||||
@apply bg-isa-neutral-400;
|
||||
|
||||
&--checked {
|
||||
@apply bg-isa-secondary-400;
|
||||
}
|
||||
}
|
||||
|
||||
.ui-switch__thumb {
|
||||
position: absolute;
|
||||
left: 0.375rem;
|
||||
width: 1.875rem;
|
||||
height: 1.875rem;
|
||||
border-radius: 50%;
|
||||
transition: all 0.3s ease-in-out;
|
||||
@apply bg-isa-white;
|
||||
top: 50%;
|
||||
transform: translate(0.1875rem, -50%);
|
||||
|
||||
&--checked {
|
||||
transform: translate(2.2rem, -50%);
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
}
|
||||
|
||||
.ui-switch:hover & {
|
||||
transform: translate(0.1875rem, -50%) scale(0.9);
|
||||
}
|
||||
|
||||
.ui-switch:hover &--checked {
|
||||
transform: translate(2.25rem, -50%) scale(0.9);
|
||||
}
|
||||
}
|
||||
|
||||
.ui-switch__icon {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 0.75rem;
|
||||
transform: translateY(-50%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
pointer-events: none;
|
||||
transition: color 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.ui-switch__primary {
|
||||
.ui-switch__track {
|
||||
@apply bg-isa-neutral-400;
|
||||
|
||||
&--checked {
|
||||
@apply bg-isa-secondary-400;
|
||||
}
|
||||
}
|
||||
|
||||
.ui-switch__icon {
|
||||
@apply text-isa-white;
|
||||
}
|
||||
|
||||
.ui-switch__track--checked .ui-switch__icon {
|
||||
@apply text-isa-secondary-400;
|
||||
}
|
||||
}
|
||||
|
||||
.ui-switch:disabled,
|
||||
.ui-switch.disabled {
|
||||
@apply cursor-default;
|
||||
}
|
||||
9
libs/ui/switch/src/lib/switch.component.html
Normal file
9
libs/ui/switch/src/lib/switch.component.html
Normal file
@@ -0,0 +1,9 @@
|
||||
<div class="ui-switch__track" [class.ui-switch__track--checked]="checked()">
|
||||
<div
|
||||
class="ui-switch__thumb"
|
||||
[class.ui-switch__thumb--checked]="checked()"
|
||||
></div>
|
||||
<div class="ui-switch__icon">
|
||||
<ng-icon [name]="icon()" size="1.5rem"></ng-icon>
|
||||
</div>
|
||||
</div>
|
||||
202
libs/ui/switch/src/lib/switch.component.spec.ts
Normal file
202
libs/ui/switch/src/lib/switch.component.spec.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { SwitchComponent } from './switch.component';
|
||||
import { provideIcons } from '@ng-icons/core';
|
||||
import { isaNavigationDashboard } from '@isa/icons';
|
||||
|
||||
describe('SwitchComponent', () => {
|
||||
let component: SwitchComponent;
|
||||
let fixture: ComponentFixture<SwitchComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [SwitchComponent],
|
||||
providers: [provideIcons({ isaNavigationDashboard })],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(SwitchComponent);
|
||||
component = fixture.componentInstance;
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
describe('initial state', () => {
|
||||
it('should have default values', () => {
|
||||
fixture.componentRef.setInput('icon', 'isaNavigationDashboard');
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.checked()).toBe(false);
|
||||
expect(component.color()).toBe('primary');
|
||||
expect(component.disabled()).toBe(false);
|
||||
});
|
||||
|
||||
it('should render with unchecked state', () => {
|
||||
fixture.componentRef.setInput('icon', 'isaNavigationDashboard');
|
||||
fixture.detectChanges();
|
||||
|
||||
const track = fixture.nativeElement.querySelector('.ui-switch__track');
|
||||
expect(track).toBeTruthy();
|
||||
expect(track.classList.contains('ui-switch__track--checked')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('checked state', () => {
|
||||
it('should update when checked input changes', () => {
|
||||
fixture.componentRef.setInput('icon', 'isaNavigationDashboard');
|
||||
fixture.componentRef.setInput('checked', true);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.checked()).toBe(true);
|
||||
});
|
||||
|
||||
it('should apply checked class to track', () => {
|
||||
fixture.componentRef.setInput('icon', 'isaNavigationDashboard');
|
||||
fixture.componentRef.setInput('checked', true);
|
||||
fixture.detectChanges();
|
||||
|
||||
const track = fixture.nativeElement.querySelector('.ui-switch__track');
|
||||
expect(track.classList.contains('ui-switch__track--checked')).toBe(true);
|
||||
});
|
||||
|
||||
it('should apply checked class to thumb', () => {
|
||||
fixture.componentRef.setInput('icon', 'isaNavigationDashboard');
|
||||
fixture.componentRef.setInput('checked', true);
|
||||
fixture.detectChanges();
|
||||
|
||||
const thumb = fixture.nativeElement.querySelector('.ui-switch__thumb');
|
||||
expect(thumb.classList.contains('ui-switch__thumb--checked')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toggle functionality', () => {
|
||||
beforeEach(() => {
|
||||
fixture.componentRef.setInput('icon', 'isaNavigationDashboard');
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should toggle checked state on click', () => {
|
||||
expect(component.checked()).toBe(false);
|
||||
|
||||
fixture.nativeElement.click();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.checked()).toBe(true);
|
||||
|
||||
fixture.nativeElement.click();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.checked()).toBe(false);
|
||||
});
|
||||
|
||||
it('should toggle on Enter key', () => {
|
||||
expect(component.checked()).toBe(false);
|
||||
|
||||
const event = new KeyboardEvent('keydown', { key: 'Enter' });
|
||||
fixture.nativeElement.dispatchEvent(event);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.checked()).toBe(true);
|
||||
});
|
||||
|
||||
it('should toggle on Space key', () => {
|
||||
expect(component.checked()).toBe(false);
|
||||
|
||||
const event = new KeyboardEvent('keydown', { key: ' ' });
|
||||
fixture.nativeElement.dispatchEvent(event);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.checked()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('disabled state', () => {
|
||||
beforeEach(() => {
|
||||
fixture.componentRef.setInput('icon', 'isaNavigationDashboard');
|
||||
fixture.componentRef.setInput('disabled', true);
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should not toggle when disabled and clicked', () => {
|
||||
expect(component.checked()).toBe(false);
|
||||
|
||||
fixture.nativeElement.click();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.checked()).toBe(false);
|
||||
});
|
||||
|
||||
it('should apply disabled class', () => {
|
||||
expect(fixture.nativeElement.classList.contains('disabled')).toBe(true);
|
||||
});
|
||||
|
||||
it('should have aria-disabled attribute', () => {
|
||||
expect(fixture.nativeElement.getAttribute('aria-disabled')).toBe('true');
|
||||
});
|
||||
});
|
||||
|
||||
describe('accessibility', () => {
|
||||
beforeEach(() => {
|
||||
fixture.componentRef.setInput('icon', 'isaNavigationDashboard');
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should have role="switch"', () => {
|
||||
expect(fixture.nativeElement.getAttribute('role')).toBe('switch');
|
||||
});
|
||||
|
||||
it('should have correct aria-checked when unchecked', () => {
|
||||
expect(fixture.nativeElement.getAttribute('aria-checked')).toBe('false');
|
||||
});
|
||||
|
||||
it('should have correct aria-checked when checked', () => {
|
||||
fixture.componentRef.setInput('checked', true);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.nativeElement.getAttribute('aria-checked')).toBe('true');
|
||||
});
|
||||
|
||||
it('should be keyboard focusable', () => {
|
||||
expect(fixture.nativeElement.getAttribute('tabindex')).toBe('0');
|
||||
});
|
||||
|
||||
it('should support custom tabindex', () => {
|
||||
fixture.componentRef.setInput('tabIndex', 5);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.nativeElement.getAttribute('tabindex')).toBe('5');
|
||||
});
|
||||
});
|
||||
|
||||
describe('color theme', () => {
|
||||
beforeEach(() => {
|
||||
fixture.componentRef.setInput('icon', 'isaNavigationDashboard');
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should apply primary color class by default', () => {
|
||||
expect(
|
||||
fixture.nativeElement.classList.contains('ui-switch__primary'),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should apply correct color class', () => {
|
||||
fixture.componentRef.setInput('color', 'primary');
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(
|
||||
fixture.nativeElement.classList.contains('ui-switch__primary'),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('icon rendering', () => {
|
||||
it('should render the provided icon', () => {
|
||||
fixture.componentRef.setInput('icon', 'isaNavigationDashboard');
|
||||
fixture.detectChanges();
|
||||
|
||||
const icon = fixture.nativeElement.querySelector('ng-icon');
|
||||
expect(icon).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
102
libs/ui/switch/src/lib/switch.component.ts
Normal file
102
libs/ui/switch/src/lib/switch.component.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
input,
|
||||
model,
|
||||
ViewEncapsulation,
|
||||
} from '@angular/core';
|
||||
import { NgIconComponent, provideIcons } from '@ng-icons/core';
|
||||
import { IconSwitchColor } from './types';
|
||||
import { IsaIcons } from '@isa/icons';
|
||||
|
||||
/**
|
||||
* A toggle switch component that displays an icon and supports two states (on/off).
|
||||
* Use only when the icon meaning is universally clear, otherwise prefer a labeled switch.
|
||||
*
|
||||
* The component uses reactive signals to manage its state and provides a visually
|
||||
* appealing toggle animation between enabled and disabled states.
|
||||
*
|
||||
* Features:
|
||||
* - Customizable icon
|
||||
* - Two-way binding with model signal
|
||||
* - Hover state styling
|
||||
* - Disabled state handling
|
||||
* - Keyboard navigation support
|
||||
* - Accessible with ARIA attributes
|
||||
*
|
||||
* @property icon - The name of the icon to display in the switch
|
||||
* @property checked - A two-way bindable signal indicating whether the switch is on (true) or off (false)
|
||||
* @property color - The color theme of the switch (currently only 'primary' is supported)
|
||||
* @property colorClass - A computed CSS class based on the current color
|
||||
* @property disabled - A boolean flag indicating whether the switch is disabled
|
||||
* @property disabledClass - A computed CSS class that adds a 'disabled' style when the switch is disabled
|
||||
* @property tabIndex - The tab index for keyboard navigation
|
||||
*
|
||||
* @example
|
||||
* ```html
|
||||
* <ui-switch
|
||||
* icon="isaHome"
|
||||
* [(checked)]="isEnabled"
|
||||
* [disabled]="false">
|
||||
* </ui-switch>
|
||||
* ```
|
||||
*
|
||||
* @remarks
|
||||
* - The switch uses a pill-shaped design with a circular toggle indicator
|
||||
* - The icon is always visible and moves with the toggle indicator
|
||||
* - Click and Enter/Space key events toggle the switch state
|
||||
*/
|
||||
@Component({
|
||||
selector: 'ui-switch',
|
||||
templateUrl: './switch.component.html',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
standalone: true,
|
||||
imports: [NgIconComponent],
|
||||
host: {
|
||||
'[class]': '["ui-switch", colorClass(), disabledClass()]',
|
||||
'[tabindex]': 'tabIndex()',
|
||||
'[attr.role]': '"switch"',
|
||||
'[attr.aria-checked]': 'checked()',
|
||||
'[attr.aria-disabled]': 'disabled()',
|
||||
'(click)': 'toggle()',
|
||||
'(keydown.enter)': 'toggle()',
|
||||
'(keydown.space)': 'toggle(); $event.preventDefault()',
|
||||
},
|
||||
providers: [provideIcons(IsaIcons)],
|
||||
})
|
||||
export class SwitchComponent {
|
||||
/** The name of the icon to display */
|
||||
icon = input.required<string>();
|
||||
|
||||
/** Two-way bindable signal for the checked state */
|
||||
checked = model<boolean>(false);
|
||||
|
||||
/** The color theme of the switch */
|
||||
color = input<IconSwitchColor>('primary');
|
||||
|
||||
/** Computed class based on the current color */
|
||||
colorClass = computed(() => `ui-switch__${this.color()}`);
|
||||
|
||||
/** Whether the switch is disabled */
|
||||
disabled = input<boolean>(false);
|
||||
|
||||
/** Computed CSS class for the disabled state */
|
||||
disabledClass = computed(() => (this.disabled() ? 'disabled' : ''));
|
||||
|
||||
/** The tab index for keyboard navigation */
|
||||
tabIndex = input<number>(0);
|
||||
|
||||
/** Computed class for the checked state */
|
||||
checkedClass = computed(() => (this.checked() ? 'ui-switch__checked' : ''));
|
||||
|
||||
/**
|
||||
* Toggles the switch state unless it's disabled
|
||||
*/
|
||||
toggle(): void {
|
||||
if (!this.disabled()) {
|
||||
this.checked.set(!this.checked());
|
||||
}
|
||||
}
|
||||
}
|
||||
6
libs/ui/switch/src/lib/types.ts
Normal file
6
libs/ui/switch/src/lib/types.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export const IconSwitchColor = {
|
||||
Primary: 'primary',
|
||||
} as const;
|
||||
|
||||
export type IconSwitchColor =
|
||||
(typeof IconSwitchColor)[keyof typeof IconSwitchColor];
|
||||
1
libs/ui/switch/src/switch.scss
Normal file
1
libs/ui/switch/src/switch.scss
Normal file
@@ -0,0 +1 @@
|
||||
@import "lib/switch";
|
||||
13
libs/ui/switch/src/test-setup.ts
Normal file
13
libs/ui/switch/src/test-setup.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import '@angular/compiler';
|
||||
import '@analogjs/vitest-angular/setup-zone';
|
||||
|
||||
import {
|
||||
BrowserTestingModule,
|
||||
platformBrowserTesting,
|
||||
} from '@angular/platform-browser/testing';
|
||||
import { getTestBed } from '@angular/core/testing';
|
||||
|
||||
getTestBed().initTestEnvironment(
|
||||
BrowserTestingModule,
|
||||
platformBrowserTesting(),
|
||||
);
|
||||
30
libs/ui/switch/tsconfig.json
Normal file
30
libs/ui/switch/tsconfig.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"importHelpers": true,
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"noImplicitOverride": true,
|
||||
"noPropertyAccessFromIndexSignature": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"module": "preserve"
|
||||
},
|
||||
"angularCompilerOptions": {
|
||||
"enableI18nLegacyMessageIdFormat": false,
|
||||
"strictInjectionParameters": true,
|
||||
"strictInputAccessModifiers": true,
|
||||
"typeCheckHostBindings": true,
|
||||
"strictTemplates": true
|
||||
},
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.spec.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
27
libs/ui/switch/tsconfig.lib.json
Normal file
27
libs/ui/switch/tsconfig.lib.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../../dist/out-tsc",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"inlineSources": true,
|
||||
"types": []
|
||||
},
|
||||
"exclude": [
|
||||
"src/**/*.spec.ts",
|
||||
"src/test-setup.ts",
|
||||
"jest.config.ts",
|
||||
"src/**/*.test.ts",
|
||||
"vite.config.ts",
|
||||
"vite.config.mts",
|
||||
"vitest.config.ts",
|
||||
"vitest.config.mts",
|
||||
"src/**/*.test.tsx",
|
||||
"src/**/*.spec.tsx",
|
||||
"src/**/*.test.js",
|
||||
"src/**/*.spec.js",
|
||||
"src/**/*.test.jsx",
|
||||
"src/**/*.spec.jsx"
|
||||
],
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
29
libs/ui/switch/tsconfig.spec.json
Normal file
29
libs/ui/switch/tsconfig.spec.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../../dist/out-tsc",
|
||||
"types": [
|
||||
"vitest/globals",
|
||||
"vitest/importMeta",
|
||||
"vite/client",
|
||||
"node",
|
||||
"vitest"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"vite.config.ts",
|
||||
"vite.config.mts",
|
||||
"vitest.config.ts",
|
||||
"vitest.config.mts",
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.spec.ts",
|
||||
"src/**/*.test.tsx",
|
||||
"src/**/*.spec.tsx",
|
||||
"src/**/*.test.js",
|
||||
"src/**/*.spec.js",
|
||||
"src/**/*.test.jsx",
|
||||
"src/**/*.spec.jsx",
|
||||
"src/**/*.d.ts"
|
||||
],
|
||||
"files": ["src/test-setup.ts"]
|
||||
}
|
||||
27
libs/ui/switch/vite.config.mts
Normal file
27
libs/ui/switch/vite.config.mts
Normal file
@@ -0,0 +1,27 @@
|
||||
/// <reference types='vitest' />
|
||||
import { defineConfig } from 'vite';
|
||||
import angular from '@analogjs/vite-plugin-angular';
|
||||
import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin';
|
||||
import { nxCopyAssetsPlugin } from '@nx/vite/plugins/nx-copy-assets.plugin';
|
||||
|
||||
export default defineConfig(() => ({
|
||||
root: __dirname,
|
||||
cacheDir: '../../../node_modules/.vite/libs/ui/switch',
|
||||
plugins: [angular(), nxViteTsPaths(), nxCopyAssetsPlugin(['*.md'])],
|
||||
// Uncomment this if you are using workers.
|
||||
// worker: {
|
||||
// plugins: [ nxViteTsPaths() ],
|
||||
// },
|
||||
test: {
|
||||
watch: false,
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
include: ['{src,tests}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
|
||||
setupFiles: ['src/test-setup.ts'],
|
||||
reporters: ['default'],
|
||||
coverage: {
|
||||
reportsDirectory: '../../../coverage/libs/ui/switch',
|
||||
provider: 'v8' as const,
|
||||
},
|
||||
},
|
||||
}));
|
||||
540
package-lock.json
generated
540
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -138,6 +138,7 @@
|
||||
"@isa/ui/progress-bar": ["libs/ui/progress-bar/src/index.ts"],
|
||||
"@isa/ui/search-bar": ["libs/ui/search-bar/src/index.ts"],
|
||||
"@isa/ui/skeleton-loader": ["libs/ui/skeleton-loader/src/index.ts"],
|
||||
"@isa/ui/switch": ["libs/ui/switch/src/index.ts"],
|
||||
"@isa/ui/toolbar": ["libs/ui/toolbar/src/index.ts"],
|
||||
"@isa/ui/tooltip": ["libs/ui/tooltip/src/index.ts"],
|
||||
"@isa/utils/ean-validation": ["libs/utils/ean-validation/src/index.ts"],
|
||||
|
||||
Reference in New Issue
Block a user