From 9b2af50b671f0f40103963fb4e71c64862342297 Mon Sep 17 00:00:00 2001 From: amish1188 Date: Sun, 23 Aug 2026 11:28:46 +0200 Subject: [PATCH] =?UTF-8?q?fix(nve-switch):=20fikse=20bryter=20med=20rikti?= =?UTF-8?q?g=20st=C3=B8rrelsen=20pa=20mobil=20+=20en=20del=20funskjonalite?= =?UTF-8?q?t?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 3 +- doc-site/components/nve-switch.md | 127 ++++++++++--- .../nve-switch/nve-switch.component.ts | 120 ++++-------- .../nve-switch/nve-switch.styles.ts | 171 ++++++++++-------- src/components/nve-switch/nve-switch.test.ts | 13 +- 5 files changed, 233 insertions(+), 201 deletions(-) diff --git a/.gitignore b/.gitignore index c63948fa..ae100666 100644 --- a/.gitignore +++ b/.gitignore @@ -11,4 +11,5 @@ dist/ *.tgz doc-site/.vitepress/cache doc-site/.vitepress/dist -.idea/ \ No newline at end of file +.idea/ +custom-elements-manifest.mjs \ No newline at end of file diff --git a/doc-site/components/nve-switch.md b/doc-site/components/nve-switch.md index 2194f4f5..5863d8eb 100644 --- a/doc-site/components/nve-switch.md +++ b/doc-site/components/nve-switch.md @@ -1,5 +1,6 @@ --- layout: component +outline: [2, 3] --- @@ -10,33 +11,73 @@ layout: component + +

+ nve-switch bygger på et native <input>-element + med type checkbox og støtter relevante + egenskaper fra sjekkboksen: +

+ + + +
+ +

+ Komponenten videresender relevante hendelser fra det interne input-elementet slik at de kan lyttes til utenfor komponentens Shadow DOM. +

+ + + +
+ +

+ Det native switch-attributtet brukes foreløpig ikke på grunn av + begrenset nettleserstøtte. Komponenten bruker i stedet + role="switch". +

+
+ +## Retningslinjer + +- Gi alltid en tydelig label. +- Ikke endre label basert på bryterens tilstand. Labelen skal beskrive hva bryteren styrer, ikke hvilken handling som utføres. Bruk for eksempel «Vis info som fast label i stedet for å bytte mellom «Vis info og «Skjul info. +- Bruk nve-switch når valget representerer en av/på-tilstand og endringen skal få effekt med én gang. +- Hvis endringen utløser en asynkron operasjon, oppdater checked med én gang for å gi umiddelbar tilbakemelding, og rull tilbake dersom operasjonen feiler. + ## Eksempler ### Varianter -Bruk variant for å velge farge, default er standard. +Du kan bruke variant for å sette farger (når bryteren er på) : + +- default som er standard +- primary ```html - Default - Primary - +Default Primary ``` ### Med ikoner +Bruk officon- eller onicon-sporet for å inkludere ikoner. + ```html - - - - - + ``` @@ -47,7 +88,8 @@ Se også [nve-darkmode-switch](/components/nve-darkmode-switch) ### Label -For å sette label foran switchen bruk `label-position="start"`. `end` er default. +For å sette label foran bryteren bruk label-position="start". +end er default. ```html @@ -56,36 +98,69 @@ For å sette label foran switchen bruk `label-position="start"`. `end` er defaul -### Bruk `disabled` for å deaktivere bryteren +### Gruppe med brytere + +Hvis flere brytere presenteres som en logisk gruppe med en synlig ledetekst, skal gruppen markeres semantisk på én av følgende måter: + +- Plasser bryterne i et element med `role="group"`, og bruk `aria-labelledby` for å referere til elementet som inneholder gruppens ledetekst. + + + +```html +
+ Gruppe + Bryter 1 + Bryter 2 +
+``` + +
+ +- Plasser bryterne i et `
`, og bruk `` som ledetekst for gruppen. ```html - +
+ Gruppe + Bryter 1 + Bryter 2 +
```
-### Hent ut verdien +### Deaktivert + + +

+ En deaktivert bryter (disabled) kan ikke få fokus og blir derfor ofte ikke oppdaget av + brukere som navigerer med tastatur eller skjermleser. Bruk disabled med omhu, og vurder + å gi en tydelig forklaring i tekst på hvorfor feltet er deaktivert. +

+
-Du kan hente ut `checked`-verdien akkurat som med en vanlig html-checkbox, enten via elementet eller på en event +Bruk attributtet disabled for å hindre at bryter kan trykkes på. -Dersom du har html: + ```html - + ``` -Så kan du ha + + +## Tilgjengelighet -```javascript -const checked = document.querySelector('nve-switch').checked; -// checked er nå true eller false +### Ledeteksten -// På event: -function changehandler(event) { - const checked = event.target.checked; -} -``` +Bryter må alltid ha en ledetekst. + +**Viktig:** Ledeteksten til en bryter skal ikke endres når bryterens tilstand endres. Ledeteksten skal beskrive hva bryteren styrer, mens bryterens av/på-tilstand formidles av selve bryteren. +
+Unngå derfor å bytte mellom for eksempel «Vis varsler» og «Skjul varsler» når bryterens tilstand endres. + +### Beskrivelse -Du kan også bruke attributten `checked` på `nve-switch` for å sette verdien via lytting på change, tilsvarende som for en checkbox +nve-switch er en webkomponent med Shadow DOM. ARIA-referanser som aria-describedby, som baserer seg på en ID-referanse til et annet element, kan ikke uten videre brukes på tvers av Shadow DOM-grensen. +Hvis bryteren trenger mer forklaring enn det som er hensiktsmessig å ha i ledeteksten, kan du plassere en nve-toggletip ved siden av bryteren med utfyllende informasjon. Ledeteksten til bryteren bør fortsatt være tydelig nok til at brukeren kan forstå hva bryteren styrer uten å måtte åpne tilleggsinformasjonen. diff --git a/src/components/nve-switch/nve-switch.component.ts b/src/components/nve-switch/nve-switch.component.ts index 5c19f72f..1f1fb297 100644 --- a/src/components/nve-switch/nve-switch.component.ts +++ b/src/components/nve-switch/nve-switch.component.ts @@ -1,7 +1,6 @@ -import { customElement, property, query, state } from 'lit/decorators.js'; +import { customElement, property, query } from 'lit/decorators.js'; import { INveComponent } from '@interfaces/NveComponent.interface'; -import { CSSResultArray, html, LitElement, TemplateResult } from 'lit'; -import { watch } from '../../utils/watch'; +import { CSSResultArray, html, LitElement } from 'lit'; import { ifDefined } from 'lit/directives/if-defined.js'; import { live } from 'lit/directives/live.js'; import { classMap } from 'lit/directives/class-map.js'; @@ -13,10 +12,7 @@ import styles from './nve-switch.styles'; * @slot onicon - Det første ikonet (til venstre). Synlig når status er PÅ * @slot officon - Det andre ikonet (til høyre). Synlig når status er AV * - * @event blur Bryter mister fokus * @event change Bryter endres - * @event input Bryter endres - * @event focus Bryter får fokus * * @csspart base Topp-element * @csspart control Element rundt bryteren @@ -28,99 +24,49 @@ export default class NveSwitch extends LitElement implements INveComponent { constructor() { super(); } - @property({ reflect: true, type: String }) testId: string = ''; - - /** Hidden checkbox som holder state */ - @query('input[type="checkbox"]') input!: HTMLInputElement; - @state() private hasFocus = false; - @property() title = ''; // make reactive to pass through - + @property({ type: String }) testId: string = ''; /** Navn på switch */ @property() name = ''; - /** Switchens verdi, bruk i forms */ @property() value: string = ''; @property({ type: Boolean, reflect: true }) disabled = false; - /** Verdien til switchen. */ @property({ type: Boolean, reflect: true }) checked = false; - /** Bestemmer fargevariant */ @property() variant: 'primary' | 'default' = 'default'; - /** Plassering av label-tekst i forhold til bryteren */ @property({ attribute: 'label-position' }) labelPosition: 'start' | 'end' = 'end'; - static styles: CSSResultArray = [styles]; - private emit(eventname: string): void { - const event = new CustomEvent(eventname, { - bubbles: true, - cancelable: false, - composed: true, - detail: {}, - }); - this.dispatchEvent(event); - } - - private handleBlur() { - this.hasFocus = false; - } - - private handleInput() { - //do nothing, bubble event - } - - private handleClick() { - this.checked = !this.checked; - this.emit('change'); - } - - private handleFocus() { - this.hasFocus = true; - } - private handleKeyDown(event: KeyboardEvent) { - if (event.key === 'ArrowLeft') { - event.preventDefault(); - this.checked = false; - this.emit('change'); - this.emit('input'); - } + /** Hidden checkbox som holder state */ + @query('input[type="checkbox"]') input!: HTMLInputElement; - if (event.key === 'ArrowRight') { - event.preventDefault(); - this.checked = true; - this.emit('change'); - this.emit('input'); - } - } - @watch('checked', { waitUntilFirstUpdate: true }) - handleCheckedChange() { - this.input!.checked = this.checked; // force a sync update - } + static styles: CSSResultArray = [styles]; - /** click, focus og blur sendes til input (checkbox) */ - click() { - this.input!.click(); - } focus(options?: FocusOptions) { this.input!.focus(options); } - blur() { - this.input!.blur(); + + private handleChange(e: Event) { + const input = e.target as HTMLInputElement; + this.checked = input.checked; + this.dispatchEvent( + new CustomEvent('change', { + bubbles: true, + composed: true, + detail: { value: this.value }, //usikker om vi trenger value her + }) + ); } - render(): TemplateResult { - return html`
+ render() { + return html` -
`; + `; } } diff --git a/src/components/nve-switch/nve-switch.styles.ts b/src/components/nve-switch/nve-switch.styles.ts index 3adbe19f..defa21fa 100644 --- a/src/components/nve-switch/nve-switch.styles.ts +++ b/src/components/nve-switch/nve-switch.styles.ts @@ -1,118 +1,135 @@ import { css } from 'lit'; export default css` + * { + box-sizing: border-box; + } + + :host { + --height: 1.5rem; + --width: 3rem; + --thumb-size: 1.125rem; + --thumb-offset: calc((var(--height) - var(--thumb-size)) / 2); + --thumb-background: var(--color-interactive-foreground-secondary-enabled); + --thumb-background-checked: var(--color-interactive-foreground-primary-enabled); + --control-background: var(--color-interactive-background-secondary-enabled); + --control-background-checked: var(--color-interactive-background-primary-enabled); + --control-background-hover: var(--color-interactive-background-secondary-hover); + --control-background-checked-hover: var(--color-interactive-background-primary-hover); + } + .switch { - display: flex; + display: inline-flex; align-items: center; gap: var(--spacing-x-small); - --hover-offset: 0px; cursor: pointer; + font: var(--typography-label-medium-light); + color: var(--color-neutrals-foreground-primary); } - .switch.switch--disabled { - cursor: not-allowed; + + .switch__label--start { + flex-direction: row-reverse; + } + + .switch__input { + position: absolute; + width: 1px; + height: 1px; + margin: -1px; + padding: 0; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; + border: 0; + } + + .switch__input:focus-visible + .switch__control { + outline: 2px solid var(--color-interactive-border-accessibility-focus); + outline-offset: 2px; } .switch__control { display: flex; align-items: center; justify-content: center; - height: var(--sizing-2x-small); - border-radius: 1rem; position: relative; - --left: calc(0% + 4px); - width: 48px; - --label-color: var(--color-neutrals-foreground-primary); - --on-color: var(--color-neutrals-foreground-subtle); - --off-color: var(--color-neutrals-background-secondary); - --thumb-color: var(--on-color); - background-color: var(--off-color); + width: var(--width); + height: var(--height); + border-radius: var(--border-radius-pill); + background: var(--control-background); transition: background-color 0.3s ease-in-out; } - .switch.switch--disabled .switch__control { - --thumb-color: var(--color-interactive-foreground-secondary-disabled); - --on-color: var(--color-interactive-background-primary-disabled); - --off-color: var(--color-interactive-background-secondary-disabled); - } - - .switch.switch--disabled.switch--checked .switch__control { - --thumb-color: var(--color-interactive-background-secondary-disabled); - } - - .switch--checked .switch__control { - /* 100% - bredde på thumb + 4px */ - --left: calc(100% - var(--font-size-x-small) - 4px); - --thumb-color: var(--off-color); - background-color: var(--on-color); - } - .switch__thumb { + content: ''; position: absolute; - left: var(--left); - height: 18px; - width: 18px; - border-radius: 2rem; - translate: var(--hover-offset, 0); - z-index: 1; - background-color: var(--thumb-color); + left: var(--thumb-offset); + height: var(--thumb-size); + width: var(--thumb-size); + border-radius: 50%; + background: var(--thumb-background); transition: - left 0.3s ease-in-out, - translate 0.1s, + transform 0.3s ease-in-out, background-color 0.3s ease-in-out; } - .switch__icon { - display: flex; - align-items: center; - justify-content: center; - position: absolute; - color: var(--on-color); - transition: color 0.3s ease-in-out; - } - ::slotted(nve-icon) { - line-height: 1; - font-size: var(--font-size-small); + .switch--primary { + --control-background-checked: var(--color-feedback-background-emphasized-info); + --control-background-checked-hover: var(--color-supplemental-emphasized-blue-background); } - .switch__icon.switch__onicon { - left: calc(0% + 6px); - /* Disse fargene skal være motsatt av bakgrunnen */ - color: var(--off-color); + .switch__input:checked + .switch__control { + background-color: var(--control-background-checked); } - .switch__icon.switch__officon { - left: calc(100% - var(--font-size-2x-small) - 6px); - color: var(--on-color); + .switch__input:not(:disabled) + .switch__control:hover { + background-color: var(--control-background-hover); } - .switch input[type='checkbox'] { - clip: rect(0, 0, 0, 0); - position: absolute; + .switch__input:checked:not(:disabled) + .switch__control:hover { + background-color: var(--control-background-checked-hover); } - .switch.switch--focused:has(:focus-visible) .switch__control { - outline: 2px solid var(--color-interactive-border-accessibility-focus); - outline-offset: 1px; - } - .switch:not(.switch--disabled):hover { - --hover-offset: 2px; + .switch__input:checked + .switch__control .switch__thumb { + background-color: var(--thumb-background-checked); + transform: translateX(calc(var(--width) - var(--thumb-size) - var(--thumb-offset) * 2)); } - .switch:not(.switch--disabled).switch--checked:hover { - --hover-offset: -2px; + + /* Ikoner */ + ::slotted(nve-icon) { + line-height: 1; + font-size: var(--font-size-small); } - .switch.switch--disabled { - opacity: var(--disabled); + + .switch__icon { + display: none; + position: absolute; } - .switch--label-start { - justify-content: flex-end; - flex-direction: row-reverse; + .switch__officon { + display: block; + right: var(--thumb-offset); + color: var(--thumb-background); } - .switch--primary { - &.switch--checked .switch__control { - --on-color: var(--color-interactive-border-accessibility-focus); - --off-color: var(--color-neutrals-background-secondary); + .switch__input:checked + .switch__control { + .switch__officon { + display: none; } + + .switch__onicon { + display: block; + left: var(--thumb-offset); + color: var(--thumb-background-checked); + } + } + + /* Deaktivert */ + .switch--disabled { + cursor: not-allowed; + --control-background: var(--color-interactive-background-secondary-disabled); + --control-background-checked: var(--color-interactive-background-primary-disabled); + --thumb-background: var(--color-interactive-foreground-secondary-disabled); + --thumb-background-checked: var(--color-interactive-foreground-primary-disabled); } `; diff --git a/src/components/nve-switch/nve-switch.test.ts b/src/components/nve-switch/nve-switch.test.ts index 4aa341e3..6f38d91b 100644 --- a/src/components/nve-switch/nve-switch.test.ts +++ b/src/components/nve-switch/nve-switch.test.ts @@ -12,16 +12,9 @@ describe('nve-switch', () => { fixtureCleanup(); }); - it('should have default as default variant', async () => { - const el = await fixture(html``); - expect(el.variant).toBe('default'); - const label = el.shadowRoot?.querySelector('label[part="base"]'); - expect(label?.classList.contains('switch--default')).toBe(true); - }); - it('should apply primary variant class', async () => { const el = await fixture(html``); - const label = el.shadowRoot?.querySelector('label[part="base"]'); + const label = el.shadowRoot?.querySelector('span[part="control"]'); expect(el.variant).toBe('primary'); expect(label?.classList.contains('switch--primary')).toBe(true); }); @@ -37,13 +30,13 @@ describe('nve-switch', () => { const el = await fixture(html``); const label = el.shadowRoot?.querySelector('label[part="base"]'); expect(el.labelPosition).toBe('end'); - expect(label?.classList.contains('switch--label-start')).toBe(false); + expect(label?.classList.contains('switch__label--start')).toBe(false); }); it('should apply switch--label-start class when label-position="start"', async () => { const el = await fixture(html``); const label = el.shadowRoot?.querySelector('label[part="base"]'); expect(el.labelPosition).toBe('start'); - expect(label?.classList.contains('switch--label-start')).toBe(true); + expect(label?.classList.contains('switch__label--start')).toBe(true); }); });