From 47640250318a512dfc5fb9f8a385a08a63f8aca4 Mon Sep 17 00:00:00 2001 From: amish1188 Date: Sat, 29 Aug 2026 15:02:08 +0200 Subject: [PATCH 1/4] feat(nve-segment): nye komponenter nve-segment og nve-segment-group --- doc-site/.vitepress/config.mts | 2 + doc-site/components/nve-radio-group.md | 5 - doc-site/components/nve-radio.md | 2 +- doc-site/components/nve-segment-group.md | 299 ++++++++++++++++++ doc-site/components/nve-segment.md | 53 ++++ .../nve-checkbox-group.component.ts | 18 +- .../nve-checkbox/nve-checkbox.component.ts | 2 +- .../nve-combobox/nve-combobox.component.ts | 221 +++++++------ .../nve-radio-group.component.ts | 139 +++----- .../nve-radio/nve-radio.component.ts | 31 +- .../nve-segment-group.component.ts | 239 ++++++++++++++ .../nve-segment-group.styles.ts | 23 ++ .../nve-segment-group.test.ts | 159 ++++++++++ .../nve-segment/nve-segment.component.ts | 119 +++++++ .../nve-segment/nve-segment.styles.ts | 107 +++++++ src/interfaces/NveComponent.interface.ts | 2 +- src/interfaces/radiocontrol.ts | 15 + src/nve-designsystem.ts | 4 + src/shared/radiocontrol.ts | 96 ++++++ src/validation/validateForm.ts | 1 + tsconfig.json | 1 + vite.config.ts | 1 + 22 files changed, 1318 insertions(+), 221 deletions(-) create mode 100644 doc-site/components/nve-segment-group.md create mode 100644 doc-site/components/nve-segment.md create mode 100644 src/components/nve-segment-group/nve-segment-group.component.ts create mode 100644 src/components/nve-segment-group/nve-segment-group.styles.ts create mode 100644 src/components/nve-segment-group/nve-segment-group.test.ts create mode 100644 src/components/nve-segment/nve-segment.component.ts create mode 100644 src/components/nve-segment/nve-segment.styles.ts create mode 100644 src/interfaces/radiocontrol.ts create mode 100644 src/shared/radiocontrol.ts diff --git a/doc-site/.vitepress/config.mts b/doc-site/.vitepress/config.mts index 0ea6ad80..06ec006a 100644 --- a/doc-site/.vitepress/config.mts +++ b/doc-site/.vitepress/config.mts @@ -101,6 +101,8 @@ export default defineConfig({ alias: { '@interfaces': path.resolve(__dirname, '../../src/interfaces'), '@styles': path.resolve(__dirname, '../../src/styles'), + '@validation': path.resolve(__dirname, '../../src/validation'), + '@shared': path.resolve(__dirname, '../../src/shared'), }, }, }, diff --git a/doc-site/components/nve-radio-group.md b/doc-site/components/nve-radio-group.md index d404bc6a..9badb862 100644 --- a/doc-site/components/nve-radio-group.md +++ b/doc-site/components/nve-radio-group.md @@ -283,10 +283,6 @@ Du kan fjerne feiltilstanden ved å sette errorMessage ``` -> - -``` - ## Tilgjengelighet @@ -297,4 +293,3 @@ Radiogruppen er implementert i tråd med [anbefalingene](https://www.w3.org/WAI/ - nve-radio har intern tilstand for checked, pos og setsize, som styres av nve-radio-group. Disse brukes til å sette riktige ARIA-attributter (aria-checked aria-posinset,aria-setsize) på radio-knappen. - aria-posinset og aria-setsize brukes for å fortelle brukeren hvor i gruppen den aktuelle radio-knappen ligger, og hvor mange valg som finnes totalt. - aria-disabled og aria-invalid settes på nve-radio basert på egenskapene disabled og invalid. -``` diff --git a/doc-site/components/nve-radio.md b/doc-site/components/nve-radio.md index 7fc433fa..60b3aa37 100644 --- a/doc-site/components/nve-radio.md +++ b/doc-site/components/nve-radio.md @@ -20,7 +20,7 @@ outline: [2, 3] -

nve-radio er ikke bygget med native input og label. Det er et bevisst valg for å kunne legge role="radio" direkte på host-elementet og styre tilgjengeligheten mer presist med aria-setsize, aria-posinset og aria-checked. Les mer i tilgjengelighet seksjonen i nve-radio-group.

+

nve-radio er ikke bygget med native input og label. Det er et bevisst valg for å kunne legge role="radio" direkte på host-elementet og styre tilgjengeligheten mer presist med aria-setsize, aria-posinset og aria-checked. Les mer i tilgjengelighet seksjonen i nve-radio-group.

nve-radio har interne tilstander for size og diff --git a/doc-site/components/nve-segment-group.md b/doc-site/components/nve-segment-group.md new file mode 100644 index 00000000..8c29c9b0 --- /dev/null +++ b/doc-site/components/nve-segment-group.md @@ -0,0 +1,299 @@ +--- +layout: component +outline: [2, 3] +--- + + + +```html + + Datamaskin + Mobil + Papir og blyant + +``` + + + + +

nve-segment er ikke bygget med native input og label. Det er et bevisst valg for å kunne legge role="radio" direkte på host-elementet og styre tilgjengeligheten mer presist med aria-setsize, aria-posinset og aria-checked. Les mer i tilgjengelighet seksjonen.

+ +

nve-segment-group og nve-segment bruker ikke name-attributtet slik vanlige HTML-radiofelter gjør. Komponenten støtter ikke native form submission, så name blir ikke brukt til å sende inn en verdi i et skjema.

+ +

name er heller ikke nødvendig for å gruppere radio-knappene. Gruppelogikken styres av nve-segment-group, som sørger for at bare ett alternativ kan være valgt om gangen og emitterer valgt verdi via komponentens change-hendelse.

+ +

For denne komponenten er det derfor value på valgt nve-segment og change-hendelsen fra nve-segment-group som brukes for å lese brukerens valg.

+
+ +## Eksempler + +### Ledetekst + +Bruk label for å vise en tydelig ledetekst for feltet. Attributtet er påkrevd – hvert skjemafelt skal ha en ledetekst som skjermlesere kan bruke for å forstå hva feltet gjelder. + + + +```html + + Datamaskin + Mobil + Papir og blyant + +``` + + + +### Toggletip + +Bruk sporet label-toggletip for å vise en toggletip ved siden av ledeteksten. +Sporet er beregnet for en nve-toggletip-komponent. + +Husk å legge til aria-labelnve-toggletip. + + + +```html + + + Ekstra info her. + + Datamaskin + Mobil + Papir og blyant + +``` + + + +### Påkrevd + +Bruk required for å vise et stjernesymbol på slutten av ledeteksten som markerer at feltet er påkrevd. +Bruk i tillegg requiredLabel for å vise en forklarende tekst sammen med stjernen (for eksempel 'obligatorisk'). Dette gir brukerne en bedre forståelse av at feltet er påkrevd, siden ikke alle brukere forstår eller oppfatter stjernesymbolet alene. + + + +```html + + Datamaskin + Mobil + Papir og blyant + +``` + + + +### Hjelptekst + +Bruk helpText for å vise hjelpetekst over feltet. + + + +```html + + Datamaskin + Mobil + Papir og blyant + +``` + + + +### Hint-tekst + +Bruk hint for å vise hint-tekst under feltet. + + + +```html + + Datamaskin + Mobil + Papir og blyant + +``` + + + +### Størrelse + +Størrelsen styres på gruppenivå. Bruk size for å velge størrelse: + +- small +- medium - er standard +- large + + + +```html + + Datamaskin + Mobil + Papir og blyant + + + + Datamaskin + Mobil + Papir og blyant + + + + Datamaskin + Mobil + Papir og blyant + +``` + + + +### Rammestil + +Segmentene i gruppen kan vises med avrundede kanter på de ytterste segmentene. Bruk pill-attributtet på nve-segment-group for å aktivere denne stilen. + + + +```html + + Datamaskin + Mobil + Papir og blyant + +``` + + + +### Deaktivert + +Bruk attributtet disabled for å hindre at verdien kan endres i hele gruppen. For å deaktivere enkelte segmenter, bruk disabled direkte på dem. + + +

Disabled bør brukes med måte, ettersom deaktiverte kontroller kan være vanskelige for noen brukere å forstå +eller oppdage, særlig for dem som bruker hjelpemiddelteknologi. Når det er mulig, bør man vurdere å la knappen være aktiv +og heller gi tydelig veiledning eller valideringsmeldinger som forklarer hva som må gjøres før handlingen kan fullføres.

+
+ + + +```html + + Datamaskin + Mobil + Papir og blyant + +``` + + + +### Forhåndsvalgte verdier + +Bruk value for å vise en forhåndsvalgt verdi. Verdien må samsvare med value-attributtet til en av nve-segment-komponentene i gruppen. + + + +```html + + Datamaskin + Mobil + Papir og blyant + +``` + + + +## Validering + +For mer informasjon om hvordan nve-segment-group og andre skjemakomponenter valideres, se siden om validering. + +### Intern validering + +Intern validering gjør det mulig å validere verdien i nve-segment-group ved hjelp av valideringsregler. + +Valideringsreglene kjøres når du kaller validateForm() i skjemaets submit-handler. + +Du kan importere metoden slik: + +```ts +import { validateForm } from 'nve-designsystem/validation/validateForm.js'; +``` + + + +```html +
+ + Datamaskin + Mobil + Papir og blyant + + + Send inn +
+ + +``` + +
+ +### Ekstern validering + +Bruker du et eksternt valideringssystem, kan du sette komponenten i feiltilstand ved hjelp av errorMessage. + + + +```html +
+ + Datamaskin + Mobil + Papir og blyant + + + Send inn +
+``` + +
+ +Du kan fjerne feiltilstanden ved å sette errorMessage til en tom streng. + + + +```html +
+ + Datamaskin + Mobil + Papir og blyant + + + Send inn +
+``` + +
+ +## Tilgjengelighet + +Segmentgruppen er implementert i tråd med [anbefalingene](https://www.w3.org/WAI/ARIA/apg/patterns/radio/) i WAI-ARIA Authoring Practices Guide for radiogrupper, for å støtte tilgjengelig bruk i samsvar med WCAG. + +- Siden segmentgruppen er bygget med fieldset og legend, kan den første segment-knappen som får fokus ved tab-navigasjon lese opp ledeteksten sammen med eventuell tilleggstekst fra helpText, hint og errorMessage. Tillegsteksten legges til i aria-describedbyfieldset. (VoiceOver i Safari på macOS kan slite litt med aria-describedby og ikke lese den). +- nve-segment har intern tilstand for checked, pos og setsize, som styres av nve-segment-group. Disse brukes til å sette riktige ARIA-attributter (aria-checked aria-posinset,aria-setsize) på segment-knappen. +- aria-posinset og aria-setsize brukes for å fortelle brukeren hvor i gruppen den aktuelle segment-knappen ligger, og hvor mange valg som finnes totalt. +- aria-disabled og aria-invalid settes på nve-segment basert på egenskapene disabled og invalid. + +slooooots + +remove errormessage hint and helptext if not defined diff --git a/doc-site/components/nve-segment.md b/doc-site/components/nve-segment.md new file mode 100644 index 00000000..8d73462e --- /dev/null +++ b/doc-site/components/nve-segment.md @@ -0,0 +1,53 @@ +--- +layout: component +outline: [2, 3] +--- + + +

+ nve-segment skal kun brukes i + nve-segment-group. + Se eksempler og anbefalt bruk der. +

+
+ + + +```html +Segment +``` + + + + +

nve-segment er ikke bygget med native input og label. Det er et bevisst valg for å kunne legge role="radio" direkte på host-elementet og styre tilgjengeligheten mer presist med aria-setsize, aria-posinset og aria-checked. Les mer i tilgjengelighet seksjonen i nve-segment-group.

+ +

+ nve-segment har interne tilstander for size og + checked, men disse styres normalt av + nve-segment-group. Komponenten er ikke ment å brukes alene: gruppen sørger for at kun ett + alternativ kan være valgt, oppdaterer ARIA-attributter, og håndterer fokus/tastaturnavigasjon (piltaster). +

+
+ +## Eksempler + +### Start og end spor + +Bruk start og end spor for å legge til ikoner. + + + +```html +Datamaskin +``` + + + + + +```html +Mobil +``` + + diff --git a/src/components/nve-checkbox-group/nve-checkbox-group.component.ts b/src/components/nve-checkbox-group/nve-checkbox-group.component.ts index 57a94bdf..ad7bb476 100644 --- a/src/components/nve-checkbox-group/nve-checkbox-group.component.ts +++ b/src/components/nve-checkbox-group/nve-checkbox-group.component.ts @@ -34,7 +34,7 @@ export default class NveCheckboxGroup extends LitElement implements FormValidati /** Om sjekkboks-gruppen er deaktivert */ @property({ type: Boolean }) disabled = false; /** Feilmelding som vises ved valideringsfeil. Hvis den er satt blir input-felt ugyldig og feil melding vises.*/ - @property({ type: String, reflect: true }) errorMessage = ''; + @property({ type: String, reflect: true }) errorMessage: string | undefined = undefined; /** Hjelpetekst som vises over feltet */ @property({ type: String, reflect: true }) helpText?: string = undefined; /** Hint-tekst som vises under feltet */ @@ -192,9 +192,11 @@ export default class NveCheckboxGroup extends LitElement implements FormValidati true )} - ${this.helpText - ? html`

${this.helpText}

` - : nothing} + ${ + this.helpText + ? html`

${this.helpText}

` + : nothing + }
- ${!this.activeErrorMessage && this.hint - ? html`

${this.hint}

` - : nothing} + ${ + !this.activeErrorMessage && this.hint + ? html`

${this.hint}

` + : nothing + }

${this.activeErrorMessage ?? ''} diff --git a/src/components/nve-checkbox/nve-checkbox.component.ts b/src/components/nve-checkbox/nve-checkbox.component.ts index e24ca7f0..0b1c6f0f 100644 --- a/src/components/nve-checkbox/nve-checkbox.component.ts +++ b/src/components/nve-checkbox/nve-checkbox.component.ts @@ -37,7 +37,7 @@ export default class NveCheckbox extends LitElement implements FormValidationCom @property({ attribute: false }) validationRules: Array = []; @state() internalValidationMessage = ''; - @property({ type: String, reflect: true }) errorMessage = ''; + @property({ type: String, reflect: true }) errorMessage: string | undefined = undefined; private readonly checkboxId = `checkbox-${++id}`; diff --git a/src/components/nve-combobox/nve-combobox.component.ts b/src/components/nve-combobox/nve-combobox.component.ts index 98e9dea3..63065dbb 100644 --- a/src/components/nve-combobox/nve-combobox.component.ts +++ b/src/components/nve-combobox/nve-combobox.component.ts @@ -74,13 +74,13 @@ export default class NveCombobox extends LitElement implements FormValidationCom /** Om alle valgte alternativer kan fjernes */ @property({ type: Boolean }) clearable = false; /** Feilmelding som vises ved valideringsfeil. Hvis den er satt blir felt ugyldig og feil melding vises. */ - @property({ type: String, reflect: true }) errorMessage = ''; + @property({ type: String, reflect: true }) errorMessage: string | undefined = undefined; /** Om feltet skal bruke filled variant */ @property({ type: Boolean }) filled = false; /** Hjelpetekst som vises over feltet */ - @property({ type: String, reflect: true }) helpText = ''; + @property({ type: String, reflect: true }) helpText?: string = undefined; /** Hint-tekst som vises under feltet */ - @property({ type: String, reflect: true }) hint = ''; + @property({ type: String, reflect: true }) hint?: string = undefined; /** Ledetekst */ @property({ type: String }) label: string = ''; /** Tekst som vises for å markere at et felt er obligatorisk */ @@ -95,7 +95,7 @@ export default class NveCombobox extends LitElement implements FormValidationCom /** Aria-label for knappen som fjerner et valgt alternativ i multiselect */ @property({ type: String }) removeTagAriaLabel = 'Slett'; /** Tekst som vises for å markere at et felt er obligatorisk */ - @property({ type: String, reflect: true }) requiredLabel = ''; + @property({ type: String, reflect: true }) requiredLabel?: string = undefined; /** Om bruker kan redigere verdien i inputfeltet */ @property({ type: Boolean }) editable = false; /** Initialt valgte verdier */ @@ -1038,9 +1038,11 @@ export default class NveCombobox extends LitElement implements FormValidationCom this.handleLabelClick )} - ${this.helpText - ? html`

${this.helpText}

` - : nothing} + ${ + this.helpText + ? html`

${this.helpText}

` + : nothing + }
- ${this.multiple && this._selectedValues.length - ? this._selectedValues - .filter((value) => !this.collapsedTagIds.includes(value)) - .map( - (value) => - html`` - ) - : nothing} - ${this.indicatorCount > 0 - ? html`` - : nothing} + ${ + this._options.find((opt) => opt?.value === value)?.textLabel || + this._options.find((opt) => opt?.value === value)?.label || + '' + } + + ` + ) + : nothing + } + ${ + this.indicatorCount > 0 + ? html`` + : nothing + }
${this._options .filter((opt) => this._selectedValues.includes(opt.value)) @@ -1133,66 +1142,80 @@ export default class NveCombobox extends LitElement implements FormValidationCom />
- ${this.clearable && this._selectedValues.length && !this.readonly && !this.disabled - ? html`` - : nothing} - ${this.disabled || this.readonly - ? nothing - : html``} + ${ + this.clearable && this._selectedValues.length && !this.readonly && !this.disabled + ? html`` + : nothing + } + ${ + this.disabled || this.readonly + ? nothing + : html`` + } ${this.disabled ? html`` : nothing} ${this.readonly ? html`` : nothing} - ${!!this.activeErrorMessage - ? html`` - : nothing} + ${ + !!this.activeErrorMessage + ? html`` + : nothing + }
- ${this.expanded && this.visibleOptions.length - ? html`
    - ${this.visibleOptions.map((option) => { - return html`
  • this.handleClickOption(option.value)} - > - ${this._selectedValues.includes(option.value) - ? html`` - : nothing} - ${ifDefined(option?.label)} -
  • `; - })} -
` - : nothing} + ${ + this.expanded && this.visibleOptions.length + ? html`
    + ${this.visibleOptions.map((option) => { + return html`
  • this.handleClickOption(option.value)} + > + ${ + this._selectedValues.includes(option.value) + ? html`` + : nothing + } + ${ifDefined(option?.label)} +
  • `; + })} +
` + : nothing + }
- ${!this.activeErrorMessage && this.hint - ? html`

${this.hint}

` - : nothing} + ${ + !this.activeErrorMessage && this.hint + ? html`

${this.hint}

` + : nothing + }

${this.activeErrorMessage ?? ''} diff --git a/src/components/nve-radio-group/nve-radio-group.component.ts b/src/components/nve-radio-group/nve-radio-group.component.ts index a03c5f68..e8d10174 100644 --- a/src/components/nve-radio-group/nve-radio-group.component.ts +++ b/src/components/nve-radio-group/nve-radio-group.component.ts @@ -9,6 +9,8 @@ import formField from '@styles/formField'; import NveRadio from '../nve-radio/nve-radio.component'; import { getLabel, labelStyles } from '../../templates/label'; import type { ValidationRule } from '@validation/validateForm'; +import { IRadioControl } from '@interfaces/radiocontrol'; +import { handleRadioControlKeyDown, selectRadioControl, selectRadioControlWithFocus } from '@shared/radiocontrol'; let id = 0; // for å generere unike id-er. Brukes for å koble label og hint tekster til riktig fieldset via aria-describedby. /** @@ -27,11 +29,11 @@ export default class NveRadioGroup extends LitElement implements FormValidationC /** Om radio-gruppen er deaktivert */ @property({ type: Boolean }) disabled = false; /** Feilmelding som vises ved valideringsfeil. Hvis den er satt blir input-felt ugyldig og feil melding vises */ - @property({ type: String, reflect: true }) errorMessage = ''; + @property({ type: String, reflect: true }) errorMessage: string | undefined = undefined; /** Hjelpetekst som vises over feltet */ - @property({ type: String, reflect: true }) helpText = ''; + @property({ type: String, reflect: true }) helpText?: string = undefined; /** Hint-tekst som vises under feltet */ - @property({ type: String, reflect: true }) hint = ''; + @property({ type: String, reflect: true }) hint?: string = undefined; /** Ledetekst for radio-gruppen */ @property({ type: String }) label: string | undefined = undefined; /** Retning for gruppen av radioknapper */ @@ -39,13 +41,13 @@ export default class NveRadioGroup extends LitElement implements FormValidationC /** Om inputfeltet er obligatorisk */ @property({ type: Boolean }) required = false; /** Ekstra tekst som vises for obligatoriske felt. * er en standard og vises alltid */ - @property({ type: String }) requiredLabel = ''; + @property({ type: String }) requiredLabel?: string = undefined; /** Størrelse på radio-knappene */ @property({ type: String }) size: 'small' | 'medium' | 'large' = 'medium'; /** Tooltip-tekst for ledetekst */ @property({ type: String }) tooltip = ''; /** Verdi for den valgte radio-knappen */ - @property({ type: String, reflect: true }) value = ''; + @property({ type: String, reflect: true }) value?: string = undefined; @property({ attribute: false }) validationRules: Array = []; @queryAssignedElements({ selector: 'nve-radio' }) private radios!: NveRadio[]; @@ -60,11 +62,23 @@ export default class NveRadioGroup extends LitElement implements FormValidationC * @param e Event som utløses når en radio-knapp endres. */ private handleChange(e: Event) { - const radio = e.target as NveRadio; - if (radio.tagName.toLowerCase() === 'nve-radio' && !radio.disabled) { - this.selectRadioWithFocus(radio); + const radioControl = e.target as IRadioControl; + + if (radioControl.tagName.toLowerCase() !== 'nve-radio' || radioControl.disabled) { + return; } + + selectRadioControlWithFocus(radioControl, this.radios); + this.value = radioControl.value; this.internalValidationMessage = ''; + + this.dispatchEvent( + new CustomEvent('change', { + detail: { value: radioControl.value }, + bubbles: true, + composed: true, + }) + ); } /** @@ -72,57 +86,13 @@ export default class NveRadioGroup extends LitElement implements FormValidationC * @param e Event som utløses når en tast trykkes ned. */ private handleKeyDown(e: KeyboardEvent) { - if (this.radios.length === 0 || this.disabled) return; - - const currentIndex = this.radios.findIndex((radio) => radio.contains(document.activeElement)); - if (currentIndex === -1) return; - - let nextIndex = currentIndex; + const selected = handleRadioControlKeyDown(e, this.radios, this.disabled); - if (e.key === 'Enter' || e.key === ' ') { - this.selectRadioWithFocus(this.radios[currentIndex]); - e.preventDefault(); - } - - if (e.key === 'ArrowRight' || e.key === 'ArrowDown') { - nextIndex = this.getNextEnabledIndex(currentIndex, 1, this.radios); - e.preventDefault(); - } else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') { - nextIndex = this.getNextEnabledIndex(currentIndex, -1, this.radios); - e.preventDefault(); - } - - if (nextIndex !== currentIndex) { - this.selectRadioWithFocus(this.radios[nextIndex]); - } - } - - /** - * Finner neste tilgjengelige radio-knapp basert på gjeldende indeks og retning. - * @param fromIndex Indexen som navigasjonen starter fra. - * @param delta Retningen for navigasjonen. 1 for fremover, -1 for bakover. - * @returns Indexen til neste tilgjengelige radio-knapp, eller -1 hvis alle er deaktivert. - */ - private getNextEnabledIndex(fromIndex: number, delta: 1 | -1, radios: NveRadio[]): number { - const count = radios.length; - for (let step = 1; step <= count; step++) { - const index = (fromIndex + delta * step + count) % count; - if (!radios[index].disabled) return index; - } - return -1; // alle radio-knapper er deaktivert - } + if (!selected) return; - /** - * Velger en radio-knapp og oppdaterer tilstanden til gruppen. - * @param radio Radio-knappen som skal velges. - */ - private selectRadio(radio: NveRadio) { - this.radios.forEach((r) => (r.checked = false)); + this.value = selected.value; + this.internalValidationMessage = ''; - // Merk den valgte radio-knappen - radio.checked = true; - this.value = radio.value || ''; - this.updateTabIndexes(); this.dispatchEvent( new CustomEvent('change', { detail: { value: this.value }, @@ -132,37 +102,7 @@ export default class NveRadioGroup extends LitElement implements FormValidationC ); } - /** - * Velger en radio-knapp og setter fokus på den. - * @param radio Radio-knappen som skal velges og få fokus. - */ - private selectRadioWithFocus(radio: NveRadio) { - this.selectRadio(radio); - radio.focus(); - } - - /** - * Setter riktig tabIndex på radio-knappene basert på hvilken som er valgt. - * Hvis ingen er valgt, settes tabIndex på den første radio-knappen. - * Det gjøres for å sikre at kun én radio-knapp er tabbable i gruppen. Resten skal navigeres med piltastene. - */ - private updateTabIndexes() { - if (this.disabled) return; - const checkedRadio = this.radios.find((radio) => radio.checked); - - this.radios.forEach((radio) => { - if (checkedRadio) { - // Hvis det finnes en valgt radio knapp, sett tabIndex der - radio.tabIndex = radio === checkedRadio ? 0 : -1; - } else { - // Hvis ingen er valgt, sett tabIndex på den første radio-knappen - radio.tabIndex = radio === this.radios[0] ? 0 : -1; - } - }); - } - private handleSlotChange() { - this.updateTabIndexes(); // Sett interne attributter på radio-knappene const checkedRadio = this.radios.find((radio) => radio.checked); this.radios.forEach((radio, index) => { @@ -178,15 +118,8 @@ export default class NveRadioGroup extends LitElement implements FormValidationC // sett størrelse på radio-knappene radio.size = this.size; } - }); - - requestAnimationFrame(() => { - if (this.value) { - const radioToSelect = this.radios.find((radio) => radio.value === this.value); - - if (radioToSelect) { - this.selectRadio(radioToSelect); - } + if (this.value && radio.value === this.value) { + selectRadioControl(radio, this.radios); } }); } @@ -276,9 +209,11 @@ export default class NveRadioGroup extends LitElement implements FormValidationC true )} - ${this.helpText - ? html`

${this.helpText}

` - : nothing} + ${ + this.helpText + ? html`

${this.helpText}

` + : nothing + }
- ${!this.activeErrorMessage && this.hint - ? html`

${this.hint}

` - : nothing} + ${ + !this.activeErrorMessage && this.hint + ? html`

${this.hint}

` + : nothing + }

${this.activeErrorMessage ?? ''} diff --git a/src/components/nve-radio/nve-radio.component.ts b/src/components/nve-radio/nve-radio.component.ts index aa0d19f5..23e16761 100644 --- a/src/components/nve-radio/nve-radio.component.ts +++ b/src/components/nve-radio/nve-radio.component.ts @@ -37,6 +37,7 @@ export default class NveRadio extends LitElement implements INveComponent { connectedCallback() { super.connectedCallback(); this.setAttribute('role', 'radio'); + this.addEventListener('click', this.handleClick); } updated(changedProperties: PropertyValues) { @@ -44,14 +45,27 @@ export default class NveRadio extends LitElement implements INveComponent { this.setAttribute('aria-checked', String(this.checked)); } if (changedProperties.has('disabled')) { - this.setAttribute('aria-disabled', String(this.disabled)); + this.toggleAttribute('aria-disabled', this.disabled); } + if (changedProperties.has('invalid')) { - this.setAttribute('aria-invalid', String(this.invalid)); + this.toggleAttribute('aria-invalid', this.invalid); + } + + if (changedProperties.has('pos')) { + if (this.pos !== null) { + this.setAttribute('aria-posinset', String(this.pos)); + } else { + this.removeAttribute('aria-posinset'); + } } - if (changedProperties.has('pos') || changedProperties.has('setsize')) { - this.setAttribute('aria-posinset', String(this.pos)); - this.setAttribute('aria-setsize', String(this.setsize)); + + if (changedProperties.has('setsize')) { + if (this.setsize !== null) { + this.setAttribute('aria-setsize', String(this.setsize)); + } else { + this.removeAttribute('aria-setsize'); + } } } @@ -91,7 +105,6 @@ export default class NveRadio extends LitElement implements INveComponent { 'radio--disabled': this.disabled, 'radio--invalid': this.invalid, })} - @click=${this.handleClick} part="base" > @@ -100,3 +113,9 @@ export default class NveRadio extends LitElement implements INveComponent { `; } } + +declare global { + interface HTMLElementTagNameMap { + 'nve-radio': NveRadio; + } +} diff --git a/src/components/nve-segment-group/nve-segment-group.component.ts b/src/components/nve-segment-group/nve-segment-group.component.ts new file mode 100644 index 00000000..e8ba6f33 --- /dev/null +++ b/src/components/nve-segment-group/nve-segment-group.component.ts @@ -0,0 +1,239 @@ +import { html, LitElement, nothing, PropertyValues } from 'lit'; +import { customElement, property, queryAssignedElements, state } from 'lit/decorators.js'; +import { FormValidationComponent } from '@interfaces/NveComponent.interface'; +import styles from './nve-segment-group.styles'; +import { NveSegment } from 'nve-designsystem'; +import { ValidationRule } from '@validation/validateForm'; +import { ifDefined } from 'lit/directives/if-defined.js'; +import { classMap } from 'lit/directives/class-map.js'; +import { getLabel, labelStyles } from '../../templates/label'; +import formField from '@styles/formField'; +import { IRadioControl } from '@interfaces/radiocontrol'; +import { handleRadioControlKeyDown, selectRadioControl, selectRadioControlWithFocus } from '@shared/radiocontrol'; + +let id = 0; // for å generere unike id-er. Brukes for å koble label og hint tekster til riktig fieldset via aria-describedby. + +/** + * En gruppe av nve-segment-knapper. Fungerer som en radio-gruppe. Kun én segment-knapp i en gitt gruppe kan være valgt om gangen. + * + * @event change når en segment-knapp i gruppen blir valgt. Inneholder den valgte verdien. + * + * @csspart base Hovedcontaineren for segment-gruppen, som er en fieldset. + * @csspart help-text Teksten som vises under ledeteksten for å gi ekstra informasjon. + * @csspart hint-text Teksten som vises under segment-knappene for å gi ekstra informasjon eller feilmeldinger. + * @csspart error-text Teksten som vises under segment-knappene for å vise feilmeldinger. + */ +@customElement('nve-segment-group') +export default class NveSegmentGroup extends LitElement implements FormValidationComponent { + @property({ type: String }) testId: string | undefined = undefined; + /** Om radio-gruppen er deaktivert */ + @property({ type: Boolean }) disabled = false; + /** Feilmelding som vises ved valideringsfeil. Hvis den er satt blir input-felt ugyldig og feil melding vises */ + @property({ type: String, reflect: true }) errorMessage: string | undefined = undefined; + /** Hjelpetekst som vises over feltet */ + @property({ type: String, reflect: true }) helpText?: string = undefined; + /** Hint-tekst som vises under feltet */ + @property({ type: String, reflect: true }) hint?: string = undefined; + /** Ledetekst for radio-gruppen */ + @property({ type: String }) label: string | undefined = undefined; + /** Om segment skal ha pill-stil */ + @property({ type: Boolean }) pill = false; + /** Om inputfeltet er obligatorisk */ + @property({ type: Boolean }) required = false; + /** Ekstra teks, t som vises for obligatoriske felt. * er en standard og vises alltid */ + @property({ type: String }) requiredLabel?: string = undefined; + /** Størrelse på radio-knappene */ + @property({ type: String }) size: 'small' | 'medium' | 'large' = 'medium'; + /** Tooltip-tekst for ledetekst */ + @property({ type: String }) tooltip = ''; + /** Verdi for den valgte radio-knappen */ + @property({ type: String, reflect: true }) value?: string = undefined; + @property({ attribute: false }) validationRules: Array = []; + @queryAssignedElements({ selector: 'nve-segment' }) + private segments!: NveSegment[]; + @state() internalValidationMessage = ''; + + static styles = [styles, labelStyles, formField]; + + private segmentGroupName = `nve-segment-group-${id++}`; + /** + * Håndterer endring av valgt segment-knapp. + * @param e Event som utløses når en segment-knapp endres. + */ + private handleChange(e: Event) { + const radioControl = e.target as IRadioControl; + + if (radioControl.tagName.toLowerCase() !== 'nve-segment' || radioControl.disabled) { + return; + } + + selectRadioControlWithFocus(radioControl, this.segments); + this.value = radioControl.value; + this.internalValidationMessage = ''; + + this.dispatchEvent( + new CustomEvent('change', { + detail: { value: radioControl.value }, + bubbles: true, + composed: true, + }) + ); + } + + /** + * Håndterer tastetrykk for navigasjon mellom segment-knapper. + * @param e Event som utløses når en tast trykkes ned. + */ + private handleKeyDown(e: KeyboardEvent) { + const selected = handleRadioControlKeyDown(e, this.segments, this.disabled); + + if (!selected) return; + + this.value = selected.value; + this.internalValidationMessage = ''; + + this.dispatchEvent( + new CustomEvent('change', { + detail: { value: this.value }, + bubbles: true, + composed: true, + }) + ); + } + + private handleSlotChange() { + // Sett interne attributter på segment-knappene + const checkedSegment = this.segments.find((segment) => segment.checked); + this.segments.forEach((segment, index) => { + if (!this.disabled) { + // Sett tabIndex basert på hvilken segment-knapp som er valgt, 0 er standard + segment.tabIndex = segment === checkedSegment || (!checkedSegment && segment === this.segments[0]) ? 0 : -1; + } + segment.pill = this.pill; + // Sett posisjon + segment.pos = index + 1; + // Sett gruppestørrelse + segment.setsize = this.segments.length; + if (this.size !== 'medium') { + // sett størrelse på segment-knappene + segment.size = this.size; + } + if (this.value && segment.value === this.value) { + selectRadioControl(segment, this.segments); + } + }); + } + + firstUpdated() { + if (!this.label) { + console.warn( + 'nve-segment-group: label is not set. It is recommended to set a label for each component for better accessibility.' + ); + } + } + + updated(changedProperties: PropertyValues) { + super.updated(changedProperties); + if (changedProperties.has('errorMessage') || changedProperties.has('internalValidationMessage')) { + this.segments.forEach((r) => (r.invalid = !!this.activeErrorMessage)); + } + if (changedProperties.has('disabled')) { + this.segments.forEach((radio) => { + if (!radio.disabled) { + radio.disabled = this.disabled; + } + }); + } + } + + validate() { + for (const rule of this.validationRules) { + const result = rule(this.value); + + if (result !== true) { + if (typeof result !== 'string' || result.trim() === '') { + console.warn( + 'Validation rule failed without returning an error message. Ensure you added error message to the rule.' + ); + } + this.internalValidationMessage = + typeof result === 'string' && result.trim() !== '' ? result : 'The value is invalid.'; + return false; + } + } + + this.internalValidationMessage = ''; + return true; + } + + private get activeErrorMessage() { + return this.errorMessage || this.internalValidationMessage; + } + + render() { + const helpTextId = `${this.segmentGroupName}-helptext`; + const hintTextId = `${this.segmentGroupName}-hinttext`; + const errorTextId = `${this.segmentGroupName}-errortext`; + + const describedBy = [ + this.helpText ? helpTextId : null, + this.activeErrorMessage ? errorTextId : null, + this.hint ? hintTextId : null, + ] + .filter(Boolean) + .join(' '); + + return html` +

+ + ${getLabel( + this.segmentGroupName, + this.label, + this.required, + this.requiredLabel, + html``, + undefined, + true + )} + + ${ + this.helpText + ? html`

${this.helpText}

` + : nothing + } +
+ +
+ + ${ + !this.activeErrorMessage && this.hint + ? html`

${this.hint}

` + : nothing + } + +

+ ${this.activeErrorMessage ?? ''} +

+
+ `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'nve-segment-group': NveSegmentGroup; + } +} diff --git a/src/components/nve-segment-group/nve-segment-group.styles.ts b/src/components/nve-segment-group/nve-segment-group.styles.ts new file mode 100644 index 00000000..8d3c724d --- /dev/null +++ b/src/components/nve-segment-group/nve-segment-group.styles.ts @@ -0,0 +1,23 @@ +import { css } from 'lit'; + +export default css` + .field { + display: flex; + flex-direction: column; + padding: 0; + margin: 0; + gap: var(--spacing-x-small); + min-inline-size: unset; + margin-inline: 0; + border-width: 0; + border-style: none; + border-color: unset; + border-image: none; + padding-block: 0; + padding-inline: 0; + } + .segment-group { + display: flex; + align-items: center; + } +`; diff --git a/src/components/nve-segment-group/nve-segment-group.test.ts b/src/components/nve-segment-group/nve-segment-group.test.ts new file mode 100644 index 00000000..5684669d --- /dev/null +++ b/src/components/nve-segment-group/nve-segment-group.test.ts @@ -0,0 +1,159 @@ +import { afterAll, describe, expect, it } from 'vitest'; +import { fixture, fixtureCleanup } from '@open-wc/testing'; +import { html } from 'lit'; +import NveSegmentGroup from './nve-segment-group.component'; +import NveSegment from '../nve-segment/nve-segment.component'; + +if (!customElements.get('nve-segment-group')) { + customElements.define('nve-segment-group', NveSegmentGroup); +} + +if (!customElements.get('nve-segment')) { + customElements.define('nve-segment', NveSegment); +} + +async function waitForSlotWork(el: NveSegmentGroup) { + await el.updateComplete; + await new Promise((resolve) => requestAnimationFrame(() => resolve(undefined))); + await el.updateComplete; +} + +describe('nve-segment-group', () => { + afterAll(() => { + fixtureCleanup(); + }); + + it('is attached to the DOM', async () => { + const el = await fixture(html``); + expect(document.body.contains(el)).toBe(true); + }); + + it('renders helpText and hint with the correct classes', async () => { + const el = await fixture(html` + + Planlagt + + `); + + const helpText = el.shadowRoot?.querySelector('.field__help-text'); + const hintText = el.shadowRoot?.querySelector('.field__hint-text'); + + expect(helpText).not.toBeNull(); + expect(helpText?.textContent?.trim()).toBe('Hjelpetekst'); + expect(hintText).not.toBeNull(); + expect(hintText?.textContent?.trim()).toBe('Hinttekst'); + }); + + it('propagates disabled to child segments that are not already disabled', async () => { + const el = await fixture(html` + + Planlagt + Pågående + + `); + + await el.updateComplete; + const slot = el.shadowRoot?.querySelector('slot:not([name])'); + slot?.dispatchEvent(new Event('slotchange')); + await waitForSlotWork(el); + + const segments: NveSegment[] = Array.from(el.querySelectorAll('nve-segment')); + const bases = segments.map((segment) => segment.shadowRoot?.querySelector('.segment')); + + expect(segments[0].disabled).toBe(true); + expect(segments[1].disabled).toBe(true); + expect(bases[0]?.classList.contains('segment--disabled')).toBe(true); + expect(bases[1]?.classList.contains('segment--disabled')).toBe(true); + }); + + it('propagates size to child segments', async () => { + const el = await fixture(html` + + Planlagt + Pågående + + `); + + await el.updateComplete; + const slot = el.shadowRoot?.querySelector('slot:not([name])'); + slot?.dispatchEvent(new Event('slotchange')); + await waitForSlotWork(el); + + const segments: NveSegment[] = Array.from(el.querySelectorAll('nve-segment')); + + for (const segment of segments) { + const base = segment.shadowRoot?.querySelector('.segment'); + expect(segment.size).toBe('large'); + expect(base?.classList.contains('segment--large')).toBe(true); + expect(base?.classList.contains('segment--medium')).toBe(false); + } + }); + + it('selects the matching segment when value is set initially', async () => { + const el = await fixture(html` + + Planlagt + Pågående + + `); + + await el.updateComplete; + const slot = el.shadowRoot?.querySelector('slot:not([name])'); + slot?.dispatchEvent(new Event('slotchange')); + await waitForSlotWork(el); + + const segments: NveSegment[] = Array.from(el.querySelectorAll('nve-segment')); + expect(segments[0].getAttribute('aria-checked')).toBe('false'); + expect(segments[1].getAttribute('aria-checked')).toBe('true'); + }); + + describe('validate method', () => { + it('returns true when no validation rules are set', async () => { + const el = await fixture( + html` + Planlagt + Pågående + ` + ); + const isValid = el.validate(); + expect(isValid).toBe(true); + expect(el.internalValidationMessage).toBe(''); + }); + + it('returns false and sets internalValidationMessage when a validation rule fails', async () => { + const el = await fixture(html` + (value.trim() === '' ? 'Feltet kan ikke være tomt' : true)]} + > + Planlagt + Pågående + + `); + + el.value = ''; + const isValid = el.validate(); + expect(isValid).toBe(false); + expect(el.internalValidationMessage).toBe('Feltet kan ikke være tomt'); + }); + + it('returns true and clears internalValidationMessage when all validation rules pass', async () => { + const el = await fixture(html` + (value.trim() === '' ? 'Feltet kan ikke være tomt' : true)]} + > + Planlagt + Pågående + + `); + + el.value = 'Valid value'; + const isValid = el.validate(); + expect(isValid).toBe(true); + expect(el.internalValidationMessage).toBe(''); + }); + }); +}); diff --git a/src/components/nve-segment/nve-segment.component.ts b/src/components/nve-segment/nve-segment.component.ts new file mode 100644 index 00000000..3b7a1026 --- /dev/null +++ b/src/components/nve-segment/nve-segment.component.ts @@ -0,0 +1,119 @@ +import { html, LitElement, PropertyValues } from 'lit'; +import { customElement, property, state } from 'lit/decorators.js'; +import { INveComponent } from '@interfaces/NveComponent.interface'; +import styles from './nve-segment.styles'; +import { ifDefined } from 'lit/directives/if-defined.js'; +import { classMap } from 'lit/directives/class-map.js'; +import { IRadioControl } from '@interfaces/radiocontrol'; + +/** + * En enkel segment som skal brukes i nve-segment-group. Segment er bygget på + * radio-knapp prinsippet. Segment kan ha en start- og end-slot for å legge til ikoner eller andre elementer. + * + * @event segment-select Når segment-knappen blir valgt. Inneholder den valgte verdien. + * + * @csspart base Hovedcontaineren for segment-knappen, som er en span. + * @csspart label Innholdet i segment-knappen, vanligvis tekst. + */ +@customElement('nve-segment') +export default class NveSegment extends LitElement implements INveComponent, IRadioControl { + @property({ type: String }) testId: string | undefined = undefined; + /** Verdi for segment-knappen. Skal samsvare med value i nve-segment-group for at knappen skal være valgt. */ + @property({ type: String }) value = ''; + /** Om segment-knappen er deaktivert */ + @property({ type: Boolean, reflect: true }) disabled = false; + /** Om segment-knappen skal ha pill-stil */ + @property({ type: Boolean, reflect: true }) pill = false; + /** Om segment-knappen er ugyldig */ + @property({ type: Boolean, reflect: true }) invalid = false; //TODO: vurder om vi trenger det + + /** Størrelse på segment-knappen */ + @state() size = 'medium'; + /** Om segment-knappen er valgt */ + @state() checked = false; + /** Posisjonen til segment-knappen i gruppen */ + @state() pos: number | null = null; + /** Størrelsen på gruppen av segment-knapper */ + @state() setsize: number | null = null; + + static styles = [styles]; + + connectedCallback() { + super.connectedCallback(); + this.setAttribute('role', 'radio'); + this.addEventListener('click', this.handleClick); + } + + updated(changedProperties: PropertyValues) { + if (changedProperties.has('checked')) { + this.setAttribute('aria-checked', String(this.checked)); + } + if (changedProperties.has('disabled')) { + this.toggleAttribute('aria-disabled', this.disabled); + } + + if (changedProperties.has('invalid')) { + this.toggleAttribute('aria-invalid', this.invalid); + } + + if (changedProperties.has('pos')) { + if (this.pos !== null) { + this.setAttribute('aria-posinset', String(this.pos)); + } else { + this.removeAttribute('aria-posinset'); + } + } + + if (changedProperties.has('setsize')) { + if (this.setsize !== null) { + this.setAttribute('aria-setsize', String(this.setsize)); + } else { + this.removeAttribute('aria-setsize'); + } + } + } + + /** + * Håndterer klikk på segment-knappen. Hvis knappen ikke er deaktivert, sender den en 'segment-select' event + * med den valgte verdien. Event sendes for intern kontroll i nve-segment-group, og skal ikke håndteres direkte av + * brukere av nve-segment. For å håndtere endring av valgt segment-knapp, bruk 'change' eventen på nve-segment-group. + */ + private handleClick() { + if (this.disabled) return; + + this.dispatchEvent( + new CustomEvent('segment-select', { + bubbles: true, + composed: true, + detail: { value: this.value }, + }) + ); + } + + render() { + return html` + + + + + + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'nve-segment': NveSegment; + } +} diff --git a/src/components/nve-segment/nve-segment.styles.ts b/src/components/nve-segment/nve-segment.styles.ts new file mode 100644 index 00000000..862015ad --- /dev/null +++ b/src/components/nve-segment/nve-segment.styles.ts @@ -0,0 +1,107 @@ +import { css } from 'lit'; + +export default css` + * { + box-sizing: border-box; + } + + :host { + --_border-left-width: 0; + --_border-radius: 0; + width: fit-content; + outline: none; + } + :host(:first-of-type) { + --_border-left-width: var(--border-width-default); + --_border-radius: 4px 0px 0px 4px; + } + + :host(:last-of-type) { + --_border-radius: 0px 4px 4px 0px; + } + + :host(:first-of-type:last-of-type) { + --_border-radius: 4px; + } + + :host([pill]:first-of-type) { + --_border-left-width: var(--border-width-default); + --_border-radius: var(--border-radius-pill) 0px 0px var(--border-radius-pill); + } + + :host([pill]:last-of-type) { + --_border-radius: 0px var(--border-radius-pill) var(--border-radius-pill) 0px; + } + + :host([pill]:first-of-type:last-of-type) { + --_border-radius: var(--border-radius-pill); + } + + .segment { + display: inline-flex; + gap: var(--spacing-2x-small); + align-items: center; + justify-content: center; + padding: var(--_padding, var(--spacing-small)); + font: var(--typography-label-small-light); + line-height: 1rem; + color: var(--_color, var(--color-interactive-foreground-secondary-enabled)); + min-height: var(--_min-height, var(--sizing-fixed-sizing-large)); + min-width: 3em; + border-color: var(--_border-color, var(--color-neutrals-border-subtle)); + border-style: solid; + border-top-width: var(--border-width-default); + border-bottom-width: var(--border-width-default); + border-left-width: var(--_border-left-width, 0); + border-right-width: var(--border-width-default); + border-radius: var(--_border-radius); + background: var(--_bg-color, var(--color-neutrals-background-primary)); + cursor: pointer; + } + + .segment:not(.segment--checked):not(.segment--disabled):hover { + --_bg-color: var(--color-interactive-background-tertiary-hover); + --_border-color: var(--color-interactive-background-tertiary-hover); + } + + .segment--small { + --_padding: var(--spacing-2x-small); + --_min-height: var(--sizing-fixed-sizing-medium); + ::slotted(nve-icon) { + --icon-size: var(--font-size-medium); + } + } + + .segment--medium { + --_padding: var(--spacing-x-small); + --_min-height: var(--sizing-fixed-sizing-large); + } + + .segment--large { + --_padding: var(--spacing-small); + --_min-height: var(--sizing-fixed-sizing-x-large); + font-size: var(--typography-label-large-light); + } + + .segment--disabled { + --_bg-color: var(--color-interactive-background-tertiary-disabled); + --_color: var(--color-interactive-foreground-secondary-disabled); + cursor: not-allowed; + } + + .segment--checked { + --_bg-color: var(--color-interactive-background-primary-enabled); + --_color: var(--color-interactive-foreground-primary-enabled); + --_border-color: var(--color-interactive-background-primary-enabled); + } + + :host(:focus-visible) { + position: relative; + z-index: 1; + } + + :host(:focus-visible) .segment { + outline: 2px solid var(--color-interactive-border-accessibility-focus); + outline-offset: 1px; + } +`; diff --git a/src/interfaces/NveComponent.interface.ts b/src/interfaces/NveComponent.interface.ts index 7f93db24..8ac27b76 100644 --- a/src/interfaces/NveComponent.interface.ts +++ b/src/interfaces/NveComponent.interface.ts @@ -22,6 +22,6 @@ export interface INveComponent { export interface FormValidationComponent extends LitElement, INveComponent { validate(): boolean; validationRules: Array; - errorMessage: string; + errorMessage: string | undefined; internalValidationMessage: string; } diff --git a/src/interfaces/radiocontrol.ts b/src/interfaces/radiocontrol.ts new file mode 100644 index 00000000..02e3e727 --- /dev/null +++ b/src/interfaces/radiocontrol.ts @@ -0,0 +1,15 @@ +/** + * Interface for radio kontrol komponenter. + */ +export interface IRadioControl extends HTMLElement { + /** Om radio-kontroll er valgt */ + checked: boolean; + /** Om radio-kontroll er deaktivert */ + disabled: boolean; + /** Verdi for radio-kontroll */ + value: string; + /** Posisjonen til radio-kontroll i gruppen */ + pos: number | null; + /** Størrelsen på gruppen av radio-kontroller */ + setsize: number | null; +} diff --git a/src/nve-designsystem.ts b/src/nve-designsystem.ts index d294bdc3..470eefc6 100644 --- a/src/nve-designsystem.ts +++ b/src/nve-designsystem.ts @@ -1,5 +1,7 @@ /** Alle komponenter som er tilgjengelige, i alfabetisk rekkefølge. */ /** Denne filen blir genererert av pnpm run add-component */ + + export { default as NveAccordion } from './components/nve-accordion/nve-accordion.component'; export { default as NveAccordionItem } from './components/nve-accordion-item/nve-accordion-item.component'; export { default as NveAlert } from './components/nve-alert/nve-alert.component'; @@ -32,6 +34,8 @@ export { default as NvePopup } from './components/nve-popup/nve-popup.component' export { default as NveRadio } from './components/nve-radio/nve-radio.component'; export { default as NveRadioGroup } from './components/nve-radio-group/nve-radio-group.component'; export { default as NveRelativeTime } from './components/nve-relative-time/nve-relative-time.component'; +export { default as NveSegment } from './components/nve-segment/nve-segment.component'; +export { default as NveSegmentGroup } from './components/nve-segment-group/nve-segment-group.component'; export { default as NveSkeleton } from './components/nve-skeleton/nve-skeleton.component'; export { default as NveSpinner } from './components/nve-spinner/nve-spinner.component'; export { default as NveStep } from './components/nve-stepper/nve-step/nve-step.component'; diff --git a/src/shared/radiocontrol.ts b/src/shared/radiocontrol.ts new file mode 100644 index 00000000..4582ae63 --- /dev/null +++ b/src/shared/radiocontrol.ts @@ -0,0 +1,96 @@ +import { IRadioControl } from '@interfaces/radiocontrol'; + +/** + * Velger en radio-knapp og setter fokus på den. + * @param radio Radio-knappen som skal velges og få fokus. + */ +export function selectRadioControlWithFocus(radioControl: IRadioControl, radioControls: IRadioControl[]) { + selectRadioControl(radioControl, radioControls); + radioControl.focus(); +} + +/** + * Velger en radio-knapp og oppdaterer tilstanden til gruppen. + * @param radio Radio-knappen som skal velges. + */ +export function selectRadioControl(radioControl: IRadioControl, radioControls: IRadioControl[]) { + radioControls.forEach((radio) => { + radio.checked = radio === radioControl; + }); + + updateTabIndexes(radioControl, radioControls); +} + +/** + * Setter riktig tabIndex på radio-knappene basert på hvilken som er valgt. + * Hvis ingen er valgt, settes tabIndex på den første radio-knappen. + * Det gjøres for å sikre at kun én radio-knapp er tabbable i gruppen. Resten skal navigeres med piltastene. + */ +function updateTabIndexes(radioControl: IRadioControl, radioControls: IRadioControl[]) { + if (radioControl.disabled) return; + const checkedRadioControl = radioControls.find((radio) => radio.checked); + + radioControls.forEach((radio) => { + if (checkedRadioControl) { + // Hvis det finnes en valgt radio knapp, sett tabIndex der + radio.tabIndex = radio === checkedRadioControl ? 0 : -1; + } else { + // Hvis ingen er valgt, sett tabIndex på den første radio-knappen + radio.tabIndex = radio === radioControls[0] ? 0 : -1; + } + }); +} + +function getNextEnabledIndex(fromIndex: number, delta: 1 | -1, radioControls: IRadioControl[]): number { + const count = radioControls.length; + for (let step = 1; step <= count; step++) { + const index = (fromIndex + delta * step + count) % count; + if (!radioControls[index].disabled) return index; + } + return -1; // alle radio-knapper er deaktivert +} + +/** + * Håndterer tastetrykk for navigasjon mellom radio-knapper. + * @param e Event som utløses når en tast trykkes ned. + */ +export function handleRadioControlKeyDown( + e: KeyboardEvent, + radioControls: IRadioControl[], + disabled: boolean +): IRadioControl | undefined { + if (radioControls.length === 0 || disabled) return; + + const currentIndex = radioControls.findIndex((radio) => radio.contains(document.activeElement)); + + if (currentIndex === -1) return; + + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + + const current = radioControls[currentIndex]; + + if (current.disabled) return; + + selectRadioControlWithFocus(current, radioControls); + return current; + } + + let nextIndex = -1; + + if (e.key === 'ArrowRight' || e.key === 'ArrowDown') { + nextIndex = getNextEnabledIndex(currentIndex, 1, radioControls); + } else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') { + nextIndex = getNextEnabledIndex(currentIndex, -1, radioControls); + } + + if (nextIndex === -1) return; + + e.preventDefault(); + + const next = radioControls[nextIndex]; + + selectRadioControlWithFocus(next, radioControls); + + return next; +} diff --git a/src/validation/validateForm.ts b/src/validation/validateForm.ts index 925b285b..77ccc6df 100644 --- a/src/validation/validateForm.ts +++ b/src/validation/validateForm.ts @@ -7,6 +7,7 @@ const NVE_VALIDATABLE_SELECTOR = [ 'nve-checkbox', 'nve-checkbox-group', 'nve-combobox', + 'nve-segment-group', ].join(','); /** diff --git a/tsconfig.json b/tsconfig.json index bfb742ba..57adc551 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -28,6 +28,7 @@ "@interfaces/*": ["./src/interfaces/*"], "@styles/*": ["./src/styles/*"], "@validation/*": ["./src/validation/*"], + "@shared/*": ["./src/shared/*"], "nve-designsystem": ["./src/nve-designsystem"] } }, diff --git a/vite.config.ts b/vite.config.ts index 51505af5..9f075f5f 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -50,6 +50,7 @@ export default defineConfig(({ mode }) => { { find: '@interfaces', replacement: resolve(__dirname, 'src/interfaces') }, { find: '@validation', replacement: resolve(__dirname, 'src/validation') }, { find: '@styles', replacement: resolve(__dirname, 'src/styles') }, + { find: '@shared', replacement: resolve(__dirname, 'src/shared') }, ], }) as Plugin, replaceImportAfterBuild(), From e177604469cd8e7222d40c83ec0f53d59d58e671 Mon Sep 17 00:00:00 2001 From: amish1188 Date: Sat, 29 Aug 2026 15:24:32 +0200 Subject: [PATCH 2/4] update docs --- doc-site/components/nve-segment-group.md | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/doc-site/components/nve-segment-group.md b/doc-site/components/nve-segment-group.md index 8c29c9b0..eae4040b 100644 --- a/doc-site/components/nve-segment-group.md +++ b/doc-site/components/nve-segment-group.md @@ -25,6 +25,23 @@ outline: [2, 3]

For denne komponenten er det derfor value på valgt nve-segment og change-hendelsen fra nve-segment-group som brukes for å lese brukerens valg.

+## Retningslinjer + +nve-segment-group brukes når brukeren skal velge ett alternativ fra en liten gruppe gjensidig utelukkende valg. + +Komponenten fungerer semantisk som en radiogruppe og passer godt til for eksempel visningsmodus, periode eller andre innstillinger der kun ett alternativ kan være aktivt om gangen. + +Bruk nve-segment-group når: + +- det finnes et lite antall alternativer +- kun ett alternativ kan være valgt om gangen +- alternativene har omtrent samme betydning og viktighet +- alle alternativene bør være synlige samtidig + +nve-segment-group skal ikke brukes som faner. Segmenter representerer et valg av verdi, mens faner brukes til å navigere mellom ulike innholdsområder. Bruk en egen tab-komponent når valget styrer hvilket innholdspanel som vises. + +Unngå segmentgruppen dersom det finnes mange alternativer eller alternativene inneholder mye tekst. I slike tilfeller vil en vanlig radiogruppe ofte være enklere å lese og bruke. + ## Eksempler ### Ledetekst @@ -171,7 +188,7 @@ Bruk attributtet disabled for å hindre at verdie

Disabled bør brukes med måte, ettersom deaktiverte kontroller kan være vanskelige for noen brukere å forstå -eller oppdage, særlig for dem som bruker hjelpemiddelteknologi. Når det er mulig, bør man vurdere å la knappen være aktiv +eller oppdage, særlig for dem som bruker hjelpemiddelteknologi. Når det er mulig, bør man vurdere å la kontrolleren være aktiv og heller gi tydelig veiledning eller valideringsmeldinger som forklarer hva som må gjøres før handlingen kan fullføres.

@@ -187,7 +204,7 @@ og heller gi tydelig veiledning eller valideringsmeldinger som forklarer hva som -### Forhåndsvalgte verdier +### Forhåndsvalgt verdi Bruk value for å vise en forhåndsvalgt verdi. Verdien må samsvare med value-attributtet til en av nve-segment-komponentene i gruppen. From 9030ae0f0db9e5b794a226d0f19a522d5af80022 Mon Sep 17 00:00:00 2001 From: amish1188 Date: Mon, 31 Aug 2026 11:57:45 +0200 Subject: [PATCH 3/4] update --- .../nve-checkbox-group.component.ts | 3 +-- .../nve-radio-group.component.ts | 4 +--- .../nve-radio/nve-radio.component.ts | 5 +++++ .../nve-segment-group.component.ts | 6 ++---- .../nve-segment/nve-segment.component.ts | 5 +++++ src/shared/radiocontrol.ts | 20 +++++++------------ 6 files changed, 21 insertions(+), 22 deletions(-) diff --git a/src/components/nve-checkbox-group/nve-checkbox-group.component.ts b/src/components/nve-checkbox-group/nve-checkbox-group.component.ts index ad7bb476..6598a75f 100644 --- a/src/components/nve-checkbox-group/nve-checkbox-group.component.ts +++ b/src/components/nve-checkbox-group/nve-checkbox-group.component.ts @@ -194,7 +194,7 @@ export default class NveCheckboxGroup extends LitElement implements FormValidati ${ this.helpText - ? html`

${this.helpText}

` + ? html`

${this.helpText}

` : nothing }
- ${ !this.activeErrorMessage && this.hint ? html`

${this.hint}

` diff --git a/src/components/nve-radio-group/nve-radio-group.component.ts b/src/components/nve-radio-group/nve-radio-group.component.ts index e8d10174..0ecb8573 100644 --- a/src/components/nve-radio-group/nve-radio-group.component.ts +++ b/src/components/nve-radio-group/nve-radio-group.component.ts @@ -139,9 +139,7 @@ export default class NveRadioGroup extends LitElement implements FormValidationC } if (changedProperties.has('disabled')) { this.radios.forEach((radio) => { - if (!radio.disabled) { - radio.disabled = this.disabled; - } + radio.disabled = this.disabled; }); } } diff --git a/src/components/nve-radio/nve-radio.component.ts b/src/components/nve-radio/nve-radio.component.ts index 23e16761..a24f1f48 100644 --- a/src/components/nve-radio/nve-radio.component.ts +++ b/src/components/nve-radio/nve-radio.component.ts @@ -40,6 +40,11 @@ export default class NveRadio extends LitElement implements INveComponent { this.addEventListener('click', this.handleClick); } + disconnectedCallback(): void { + super.disconnectedCallback(); + this.removeEventListener('click', this.handleClick); + } + updated(changedProperties: PropertyValues) { if (changedProperties.has('checked')) { this.setAttribute('aria-checked', String(this.checked)); diff --git a/src/components/nve-segment-group/nve-segment-group.component.ts b/src/components/nve-segment-group/nve-segment-group.component.ts index e8ba6f33..03addc44 100644 --- a/src/components/nve-segment-group/nve-segment-group.component.ts +++ b/src/components/nve-segment-group/nve-segment-group.component.ts @@ -2,7 +2,7 @@ import { html, LitElement, nothing, PropertyValues } from 'lit'; import { customElement, property, queryAssignedElements, state } from 'lit/decorators.js'; import { FormValidationComponent } from '@interfaces/NveComponent.interface'; import styles from './nve-segment-group.styles'; -import { NveSegment } from 'nve-designsystem'; +import type NveSegment from '../nve-segment/nve-segment.component'; import { ValidationRule } from '@validation/validateForm'; import { ifDefined } from 'lit/directives/if-defined.js'; import { classMap } from 'lit/directives/class-map.js'; @@ -139,9 +139,7 @@ export default class NveSegmentGroup extends LitElement implements FormValidatio } if (changedProperties.has('disabled')) { this.segments.forEach((radio) => { - if (!radio.disabled) { - radio.disabled = this.disabled; - } + radio.disabled = this.disabled; }); } } diff --git a/src/components/nve-segment/nve-segment.component.ts b/src/components/nve-segment/nve-segment.component.ts index 3b7a1026..f2a204e3 100644 --- a/src/components/nve-segment/nve-segment.component.ts +++ b/src/components/nve-segment/nve-segment.component.ts @@ -44,6 +44,11 @@ export default class NveSegment extends LitElement implements INveComponent, IRa this.addEventListener('click', this.handleClick); } + disconnectedCallback(): void { + super.disconnectedCallback(); + this.removeEventListener('click', this.handleClick); + } + updated(changedProperties: PropertyValues) { if (changedProperties.has('checked')) { this.setAttribute('aria-checked', String(this.checked)); diff --git a/src/shared/radiocontrol.ts b/src/shared/radiocontrol.ts index 4582ae63..63ec0b04 100644 --- a/src/shared/radiocontrol.ts +++ b/src/shared/radiocontrol.ts @@ -18,26 +18,20 @@ export function selectRadioControl(radioControl: IRadioControl, radioControls: I radio.checked = radio === radioControl; }); - updateTabIndexes(radioControl, radioControls); + updateTabIndexes(radioControls); } /** * Setter riktig tabIndex på radio-knappene basert på hvilken som er valgt. - * Hvis ingen er valgt, settes tabIndex på den første radio-knappen. + * Hvis ingen er valgt, settes tabIndex på den første aktiverte radio-knappen. * Det gjøres for å sikre at kun én radio-knapp er tabbable i gruppen. Resten skal navigeres med piltastene. */ -function updateTabIndexes(radioControl: IRadioControl, radioControls: IRadioControl[]) { - if (radioControl.disabled) return; - const checkedRadioControl = radioControls.find((radio) => radio.checked); - +function updateTabIndexes(radioControls: IRadioControl[]) { + const checkedEnabled = radioControls.find((radio) => radio.checked && !radio.disabled); + const firstEnabled = radioControls.find((radio) => !radio.disabled); + const tabbable = checkedEnabled ?? firstEnabled; radioControls.forEach((radio) => { - if (checkedRadioControl) { - // Hvis det finnes en valgt radio knapp, sett tabIndex der - radio.tabIndex = radio === checkedRadioControl ? 0 : -1; - } else { - // Hvis ingen er valgt, sett tabIndex på den første radio-knappen - radio.tabIndex = radio === radioControls[0] ? 0 : -1; - } + radio.tabIndex = tabbable && radio === tabbable ? 0 : -1; }); } From 9da6056f678c5b8c7417dd7981d4d9676a2229a2 Mon Sep 17 00:00:00 2001 From: amish1188 Date: Mon, 31 Aug 2026 12:01:04 +0200 Subject: [PATCH 4/4] update #2 --- doc-site/components/nve-segment-group.md | 6 +----- .../nve-segment-group/nve-segment-group.component.ts | 2 +- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/doc-site/components/nve-segment-group.md b/doc-site/components/nve-segment-group.md index eae4040b..8196c853 100644 --- a/doc-site/components/nve-segment-group.md +++ b/doc-site/components/nve-segment-group.md @@ -107,7 +107,7 @@ Bruk helpText for å vise hjelpetekst over feltet ```html - Datamaskin + Datamaskin Mobil Papir og blyant @@ -310,7 +310,3 @@ Segmentgruppen er implementert i tråd med [anbefalingene](https://www.w3.org/WA - nve-segment har intern tilstand for checked, pos og setsize, som styres av nve-segment-group. Disse brukes til å sette riktige ARIA-attributter (aria-checked aria-posinset,aria-setsize) på segment-knappen. - aria-posinset og aria-setsize brukes for å fortelle brukeren hvor i gruppen den aktuelle segment-knappen ligger, og hvor mange valg som finnes totalt. - aria-disabled og aria-invalid settes på nve-segment basert på egenskapene disabled og invalid. - -slooooots - -remove errormessage hint and helptext if not defined diff --git a/src/components/nve-segment-group/nve-segment-group.component.ts b/src/components/nve-segment-group/nve-segment-group.component.ts index 03addc44..6cd86a8b 100644 --- a/src/components/nve-segment-group/nve-segment-group.component.ts +++ b/src/components/nve-segment-group/nve-segment-group.component.ts @@ -40,7 +40,7 @@ export default class NveSegmentGroup extends LitElement implements FormValidatio @property({ type: Boolean }) pill = false; /** Om inputfeltet er obligatorisk */ @property({ type: Boolean }) required = false; - /** Ekstra teks, t som vises for obligatoriske felt. * er en standard og vises alltid */ + /** Ekstra tekst som vises for obligatoriske felt. * er en standard og vises alltid */ @property({ type: String }) requiredLabel?: string = undefined; /** Størrelse på radio-knappene */ @property({ type: String }) size: 'small' | 'medium' | 'large' = 'medium';