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:
Nino Righi
2025-11-05 15:31:13 +00:00
committed by Lorenz Hilpert
parent a52928d212
commit eb0d96698c
33 changed files with 1211 additions and 536 deletions

View File

@@ -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;

View 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.',
},
},
},
};

View File

@@ -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)"

View File

@@ -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();

View File

@@ -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';

View File

@@ -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', () => {

View File

@@ -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() {

View File

@@ -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')"

View File

@@ -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:

View File

@@ -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;
}

View File

@@ -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',

View File

@@ -0,0 +1 @@
export * from './switch-menu-button.component';

View File

@@ -0,0 +1,8 @@
@let inp = input();
@if (inp) {
<ui-switch
[icon]="icon()"
[checked]="checked()"
(checkedChange)="onToggle($event)"
></ui-switch>
}

View File

@@ -0,0 +1,3 @@
.filter-switch-menu-button {
@apply flex;
}

View File

@@ -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);
});
});

View File

@@ -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
View 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
```

View 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: {},
},
];

View 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"
}
}
}

View File

@@ -0,0 +1,2 @@
export * from './lib/switch.component';
export * from './lib/types';

View 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;
}

View 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>

View 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();
});
});
});

View 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());
}
}
}

View File

@@ -0,0 +1,6 @@
export const IconSwitchColor = {
Primary: 'primary',
} as const;
export type IconSwitchColor =
(typeof IconSwitchColor)[keyof typeof IconSwitchColor];

View File

@@ -0,0 +1 @@
@import "lib/switch";

View 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(),
);

View 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"
}
]
}

View 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"]
}

View 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"]
}

View 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
View File

File diff suppressed because it is too large Load Diff

View File

@@ -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"],