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

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