feat(frontend): composant ev-icon en SVG inline pour les types d'alerte
Cinq tracés (spike, threshold, anomaly, outage, sensor) dessinés en trait sur currentColor, dimensionnés par font-size comme ev-brand. Décoratif par défaut (aria-hidden), rôle image et libellé accessible quand `label` est fourni. Aucune bibliothèque : la CSP du reverse proxy interdit les scripts tiers, pas le SVG inline.
This commit is contained in:
@@ -0,0 +1,37 @@
|
|||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
focusable="false"
|
||||||
|
[attr.role]="label() ? 'img' : null"
|
||||||
|
[attr.aria-label]="label()"
|
||||||
|
[attr.aria-hidden]="label() ? null : 'true'"
|
||||||
|
>
|
||||||
|
@switch (name()) {
|
||||||
|
@case ('spike') {
|
||||||
|
<polyline points="3 17 8 12 11 15 21 5" />
|
||||||
|
<polyline points="15 5 21 5 21 11" />
|
||||||
|
}
|
||||||
|
@case ('threshold') {
|
||||||
|
<line x1="3" y1="10" x2="21" y2="10" stroke-dasharray="3 3" />
|
||||||
|
<polyline points="4 19 9 13 13 15 20 6" />
|
||||||
|
}
|
||||||
|
@case ('anomaly') {
|
||||||
|
<polyline points="3 12 7 12 9.5 6 12.5 18 15 12 21 12" />
|
||||||
|
}
|
||||||
|
@case ('outage') {
|
||||||
|
<path d="M7.5 6.5a7 7 0 1 0 9 0" />
|
||||||
|
<line x1="12" y1="3" x2="12" y2="11" />
|
||||||
|
}
|
||||||
|
@case ('sensor') {
|
||||||
|
<circle cx="12" cy="13" r="2" />
|
||||||
|
<path d="M8.5 9.5a5 5 0 0 0 0 7" />
|
||||||
|
<path d="M15.5 9.5a5 5 0 0 1 0 7" />
|
||||||
|
<path d="M5.5 6.5a9 9 0 0 0 0 13" />
|
||||||
|
<path d="M18.5 6.5a9 9 0 0 1 0 13" />
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1,12 @@
|
|||||||
|
:host {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 1em;
|
||||||
|
height: 1em;
|
||||||
|
vertical-align: -0.125em;
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { Icon, IconName } from './icon';
|
||||||
|
|
||||||
|
const NOMS: IconName[] = ['spike', 'threshold', 'anomaly', 'outage', 'sensor'];
|
||||||
|
|
||||||
|
function rendre(name: IconName, label: string | null = null) {
|
||||||
|
const fixture = TestBed.createComponent(Icon);
|
||||||
|
fixture.componentRef.setInput('name', name);
|
||||||
|
fixture.componentRef.setInput('label', label);
|
||||||
|
fixture.detectChanges();
|
||||||
|
return fixture.nativeElement as HTMLElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Icon', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
TestBed.configureTestingModule({ imports: [Icon] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dessine un tracé distinct pour chacun des cinq types', () => {
|
||||||
|
const traces = NOMS.map((name) => rendre(name).querySelector('svg')?.innerHTML.trim());
|
||||||
|
|
||||||
|
for (const trace of traces) {
|
||||||
|
expect(trace).toBeTruthy();
|
||||||
|
}
|
||||||
|
expect(new Set(traces).size).toBe(NOMS.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reste décoratif sans libellé', () => {
|
||||||
|
const svg = rendre('spike').querySelector('svg');
|
||||||
|
|
||||||
|
expect(svg?.getAttribute('aria-hidden')).toBe('true');
|
||||||
|
expect(svg?.hasAttribute('role')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('expose un rôle image et un libellé accessible quand on lui en donne un', () => {
|
||||||
|
const svg = rendre('outage', 'Coupure').querySelector('svg');
|
||||||
|
|
||||||
|
expect(svg?.getAttribute('role')).toBe('img');
|
||||||
|
expect(svg?.getAttribute('aria-label')).toBe('Coupure');
|
||||||
|
expect(svg?.hasAttribute('aria-hidden')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hérite de la couleur du parent via currentColor', () => {
|
||||||
|
const svg = rendre('sensor').querySelector('svg');
|
||||||
|
|
||||||
|
expect(svg?.getAttribute('stroke')).toBe('currentColor');
|
||||||
|
expect(svg?.getAttribute('fill')).toBe('none');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { Component, input } from '@angular/core';
|
||||||
|
|
||||||
|
export type IconName = 'spike' | 'threshold' | 'anomaly' | 'outage' | 'sensor';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'ev-icon',
|
||||||
|
standalone: true,
|
||||||
|
templateUrl: './icon.html',
|
||||||
|
styleUrl: './icon.scss',
|
||||||
|
})
|
||||||
|
export class Icon {
|
||||||
|
name = input.required<IconName>();
|
||||||
|
label = input<string | null>(null);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user