diff --git a/indeks-css/css/components/form/combobox.css b/indeks-css/css/components/form/combobox.css index e8d36b6f..1a56c0da 100644 --- a/indeks-css/css/components/form/combobox.css +++ b/indeks-css/css/components/form/combobox.css @@ -13,7 +13,19 @@ - __listbox popover-panel med forslag - __option to-linjers list item (checkmark + label + description) - __option-* check / label / description - - __no-hits melding når filteret gir 0 treff */ + - __no-hits melding når filteret gir 0 treff + + Selektorstrategi (som text-field.css / field.css): der web componenten setter + et stabilt kjøretids-anker bruker vi det som PRIMÆR selektor og beholder BEM- + klassen som backup — `:where(, .ix-combobox__)`: + - listbox → [role='listbox'] (settes i IxCombobox._wire) + - option → [role='option'] (+ [data-active]/[aria-selected] tilstand) + - toggle → .ix-text-field > button (eneste knapp i feltet) + - chips → [data-field='chips'] (WC leser wrapperen slik) + BEM-klassene beholdes uansett: de er querySelector-hooks i JS, og fungerer som + no-flash-anker før connectedCallback rekker å sette role. Delene UTEN trygt + anker (__option-check/label/description, __no-hits — role=status er tvetydig + mot den skjulte results-regionen) forblir rene klasse-selektorer. */ :where(ix-combobox, .ix-combobox) { display: block; @@ -23,7 +35,7 @@ /* ── Chips-wrapper (kun multiselect) ──────────────────────────────────────── Chipene selv er og styles av chip.css. Her styles kun oppsettet: flyt over flere linjer ved mange valg (1.4.10). */ -:where(.ix-combobox__chips) { +:where(.ix-combobox [data-field='chips'], .ix-combobox__chips) { display: flex; flex-wrap: wrap; gap: var(--ix-spacing-xs); @@ -31,7 +43,7 @@ } /* Tom wrapper skal ikke ta plass før noe er valgt. */ -:where(.ix-combobox__chips:empty) { +:where(.ix-combobox [data-field='chips']:empty, .ix-combobox__chips:empty) { display: none; } @@ -45,7 +57,7 @@ position: relative; } -:where(.ix-combobox__toggle) { +:where(.ix-combobox .ix-text-field > button, .ix-combobox__toggle) { all: unset; box-sizing: border-box; @@ -78,7 +90,7 @@ padding-inline-end: calc(var(--ix-spacing-sm) + 1.5em); } -:where(.ix-combobox__toggle)::after { +:where(.ix-combobox .ix-text-field > button, .ix-combobox__toggle)::after { content: ''; width: 1em; height: 1em; @@ -97,22 +109,25 @@ } /* Chevron peker opp når lista er åpen. Host [data-open] settes av web componenten. */ +:where(ix-combobox[data-open], .ix-combobox[data-open]) .ix-text-field > button::after, :where(ix-combobox[data-open], .ix-combobox[data-open]) .ix-combobox__toggle::after { transform: rotate(180deg); } -:where(.ix-combobox__toggle:focus-visible) { +:where(.ix-combobox .ix-text-field > button:focus-visible, .ix-combobox__toggle:focus-visible) { outline: var(--ix-outline-default); outline-offset: calc(-1 * var(--ix-outline-offset-default)); } /* Knappen er en gjennomsiktig overlay oppå inputen — input eier ramme og flate. */ -:where(.ix-text-field) > .ix-combobox__toggle { +:where(.ix-combobox .ix-text-field > button, .ix-text-field > .ix-combobox__toggle) { background-color: transparent; border: none; } /* Disabled / read-only felt toner ned chevronen sammen med inputen. */ +:where(.ix-text-field) > input:disabled ~ button, +:where(.ix-text-field) > input:read-only ~ button, :where(.ix-text-field) > input:disabled ~ .ix-combobox__toggle, :where(.ix-text-field) > input:read-only ~ .ix-combobox__toggle { color: var(--ix-color-foreground-main-disabled); @@ -127,7 +142,7 @@ popover-API, ingen avhengighet, hele browserslist). Panelet ligger position: fixed i viewport-koordinater. Ingen shadow-token finnes ennå, så panelet løftes med tydelig kant + flate, som tooltip. */ -:where(.ix-combobox__listbox) { +:where(.ix-combobox [role='listbox'], .ix-combobox__listbox) { position: fixed; margin: 0; padding: var(--ix-spacing-2xs); @@ -137,6 +152,12 @@ border: var(--ix-border-width-default) solid var(--ix-color-border-main-default); border-radius: var(--ix-border-radius-sm); + /* Egen min-bredde uavhengig av feltet. _position() (IxCombobox.ts) setter + inline width = feltbredden; når feltet er smalt (f.eks. landvelgeren i + phone-number-field viser bare `+47`) ville lista blitt for smal for + landnavn. min-width vinner over inline width, så lista forblir lesbar. + 16rem (256px) < 320px mobil, og JS klemmer left ≥ 8px, så ingen overflow. */ + min-width: 16rem; max-height: 16rem; overflow-y: auto; z-index: var(--ix-z-index-overlay); @@ -144,7 +165,7 @@ /* Lukket: web componenten setter hidden. [hidden] i seg selv gir display: none, men vi er eksplisitte her siden panelet ellers har display fra listen. */ -:where(.ix-combobox__listbox[hidden]) { +:where(.ix-combobox [role='listbox'][hidden], .ix-combobox__listbox[hidden]) { display: none; } @@ -153,7 +174,7 @@ plass alltid (checkmark er visibility:hidden når ikke valgt), slik at label ikke hopper når valgt tilstand endres. Valgt vises med checkmark (struktur) + fetere label — ikke farge alene (WCAG 1.4.1). */ -:where(.ix-combobox__option) { +:where(.ix-combobox [role='option'], .ix-combobox__option) { display: grid; grid-template-columns: auto 1fr; align-items: center; @@ -173,12 +194,12 @@ componenten — IKKE :hover alene, for tastatur og mus skal se likt ut. Lyseblå flate; valgt tilstand vises i tillegg med checkmark (ikke farge alene, WCAG 1.4.1). */ -:where(.ix-combobox__option:hover), -:where(.ix-combobox__option[data-active]) { +:where(.ix-combobox [role='option']:hover, .ix-combobox__option:hover), +:where(.ix-combobox [role='option'][data-active], .ix-combobox__option[data-active]) { background-color: var(--ix-color-fill-info-subtle); } -:where(.ix-combobox__option[aria-disabled='true']) { +:where(.ix-combobox [role='option'][aria-disabled='true'], .ix-combobox__option[aria-disabled='true']) { color: var(--ix-color-foreground-main-disabled); /* stylelint-disable-next-line plugin/no-unsupported-browser-features */ cursor: not-allowed; @@ -223,12 +244,12 @@ } /* Valgt option viser krysset (begge moduser). */ -:where(.ix-combobox__option[aria-selected='true']) .ix-combobox__option-check { +:where(.ix-combobox [role='option'][aria-selected='true'], .ix-combobox__option[aria-selected='true']) .ix-combobox__option-check { visibility: visible; } /* Valgt option får litt tyngre label så tilstanden ikke hviler på farge alene. */ -:where(.ix-combobox__option[aria-selected='true']) .ix-combobox__option-label { +:where(.ix-combobox [role='option'][aria-selected='true'], .ix-combobox__option[aria-selected='true']) .ix-combobox__option-label { font-weight: var(--ix-font-weight-medium); } @@ -252,7 +273,7 @@ Playwright kjører med reducedMotion: 'reduce'; stopp chevron-rotasjonens transition slik at snapshots blir deterministiske. */ @media (prefers-reduced-motion: reduce) { - :where(.ix-combobox__toggle)::after { + :where(.ix-combobox .ix-text-field > button, .ix-combobox__toggle)::after { transition: none; } } diff --git a/indeks-css/css/components/form/index.css b/indeks-css/css/components/form/index.css index 1f0529eb..dd167a2a 100644 --- a/indeks-css/css/components/form/index.css +++ b/indeks-css/css/components/form/index.css @@ -7,6 +7,7 @@ @import './date-field.css'; @import './form.css'; @import './label.css'; +@import './phone-number-field.css'; @import './radio-group.css'; @import './select.css'; @import './validation-message.css'; diff --git a/indeks-css/css/components/form/phone-number-field.css b/indeks-css/css/components/form/phone-number-field.css new file mode 100644 index 00000000..afdd7c07 --- /dev/null +++ b/indeks-css/css/components/form/phone-number-field.css @@ -0,0 +1,113 @@ +/* PhoneNumberField — telefonnummer med landvelger. + + Sammensatt komponent: en gruppe (role="group") som binder sammen en + eksisterende (landkode) og et -tekstfelt (nummer) + under én felles label og én felles valideringsmelding. + + ARIA-lim-mønster (som ix-radio-group / ix-checkbox-group): web componenten + kobler eksisterende native elementer i light DOM — + den eier ikke innholdet. Selve feltene styles av combobox.css og + text-field.css; denne filen styrer KUN gruppe-oppsettet og side-ved-side- + layouten. Legend/description/error gjenbruker samme data-field-navn og + samme visuelle språk som field.css / radio-group.css. */ + +:where(ix-phone-number-field, .ix-phone-number-field) { + display: flex; + flex-direction: column; + gap: var(--ix-spacing-2xs); + padding: 0; + border: 0; +} + +/* ── Legend (felles label) ────────────────────────────────────────────────── + Samme uttrykk som radio-group sin legend. Ikonet foran (f.eks. lås ved + read-only) farges rødt ved feil, som ellers i skjema. */ +:where(ix-phone-number-field, .ix-phone-number-field) [data-field='legend'] { + display: inline-flex; + align-items: center; + gap: var(--ix-spacing-2xs); + font-size: var(--ix-font-size-md); + font-weight: var(--ix-font-weight-medium); + color: var(--ix-color-foreground-main-default); +} + +:where(ix-phone-number-field[data-state='error'], .ix-phone-number-field[data-state='error'], ix-phone-number-field[aria-invalid='true']) [data-field='legend'] ix-icon { + color: var(--ix-color-foreground-danger-default); +} + +/* ── Description (felles hjelpetekst) ───────────────────────────────────────── */ +:where(ix-phone-number-field, .ix-phone-number-field) [data-field='description'] { + font-size: var(--ix-font-size-md); + color: var(--ix-color-foreground-main-subtle); + line-height: 150%; + margin: 0; +} + +/* stylelint-disable-next-line plugin/no-unsupported-browser-features */ +:where(ix-phone-number-field, .ix-phone-number-field) [data-field='description']:empty { + display: none; +} + +/* ── Error (felles valideringsmelding) ────────────────────────────────────── + role="status"/aria-live settes av web componenten. Tom melding skjules + visuelt med :empty — samme mønster som [data-field="error"] i ix-field. */ +:where(ix-phone-number-field, .ix-phone-number-field) [data-field='error'] { + display: inline-flex; + align-items: center; + gap: var(--ix-spacing-2xs); + font-size: var(--ix-font-size-md); + color: var(--ix-color-foreground-danger-default); +} + +/* stylelint-disable-next-line plugin/no-unsupported-browser-features */ +:where(ix-phone-number-field, .ix-phone-number-field) [data-field='error']:empty { + display: none; +} + +/* ── Feltene side ved side ────────────────────────────────────────────────── + Landvelger og nummerfelt ligger alltid på samme rad — også på 320 px. Vi + bruker `nowrap` (ikke `wrap`) fordi landvelgeren nå er smal (kun kallekode + `+47` vises som valgt verdi), så begge feltene får plass selv på den minste + skjermen. Nummerfeltet tar resten (flex: 1, min-width: 0), så gruppa aldri + tvinger horisontal scroll (WCAG 1.4.10). Ingen fast bredde på selve gruppa. */ +:where(ix-phone-number-field, .ix-phone-number-field) [data-field='items'] { + display: flex; + flex-direction: row; + flex-wrap: nowrap; + align-items: flex-start; + gap: var(--ix-spacing-sm); +} + +/* Landvelger: smal, fast bredde. Vi MÅ sette en eksplisitt `flex-basis` (ikke + `auto`) — med `auto` arver kolonnen inputens intrinsiske bredde (~20 tegn), + og siden den ikke skal krympe (`flex-shrink: 0`) ville feltet blitt altfor + bredt. Kolonnen viser bare kallekoden (`+47`), så 5.5rem gir rom til koden + + chevron-reservasjonen. `min-width: 0` fjerner flex-itemets default + `min-width: auto`, ellers kan det brede barnet (comboboxens input) presse + kolonnen forbi basisen — samme grunn som nummerfeltet under. Selve + nedtrekkslista har egen min-width (combobox.css) så landnavn er lesbare der. */ +:where(ix-phone-number-field, .ix-phone-number-field) [data-field='country'] { + flex: 0 0 5.5rem; + min-width: 0; +} + +/* Nummerfelt: tar resterende plass. min-width: 0 lar det krympe under sin + intrinsiske bredde i stedet for å tvinge horisontal scroll. */ +:where(ix-phone-number-field, .ix-phone-number-field) [data-field='number'] { + flex: 1 1 auto; + min-width: 0; +} + +/* ── Disabled ──────────────────────────────────────────────────────────────── + Selve feltene toner seg ned via sin egen disabled-styling (combobox/text- + field). Her demper vi legend/description på gruppenivå slik radio-group gjør, + uten å blokkere pointer-events på feltene (de håndterer det selv). */ +:where(ix-phone-number-field[data-state='disabled'], .ix-phone-number-field[data-state='disabled']) [data-field='legend'], +:where(ix-phone-number-field[data-state='disabled'], .ix-phone-number-field[data-state='disabled']) [data-field='description'] { + opacity: var(--ix-opacity-disabled); +} + +/* ── ReadOnly ──────────────────────────────────────────────────────────────── */ +:where(ix-phone-number-field[data-state='readonly'], .ix-phone-number-field[data-state='readonly']) [data-field='legend'] { + color: var(--ix-color-foreground-main-read-only); +} diff --git a/indeks-docs/docs/komponenter/skjema/accessibility/phone-number-field.json b/indeks-docs/docs/komponenter/skjema/accessibility/phone-number-field.json new file mode 100644 index 00000000..1f4efb5a --- /dev/null +++ b/indeks-docs/docs/komponenter/skjema/accessibility/phone-number-field.json @@ -0,0 +1,111 @@ +{ + "component": "PhoneNumberField", + "lastReviewed": "2026-07-15", + "consumerResponsibilities": [ + { + "summary": "Skriv beskrivende felles label", + "details": "label er gruppens tilgjengelige navn (leses som legend av skjermlesere). \"Mobilnummer\" er bedre enn \"Nummer\". Den beskriver hva de to feltene til sammen samler inn — ikke handlingen.", + "wcag": ["1.3.1", "2.4.6", "3.3.2"] + }, + { + "summary": "Gi begge feltene et tilgjengelig navn", + "details": "countryLabel og numberLabel er påkrevde aria-label på henholdsvis landvelgeren og nummerfeltet. Feltene har ingen egen synlig label — gruppens legend navngir helheten, mens aria-label skiller de to. \"Landkode\" og \"Telefonnummer\" på riktig språk.", + "wcag": ["3.3.2", "4.1.2"] + }, + { + "summary": "Skriv konkret feilmelding", + "details": "errorMessage skal forklare hva brukeren må gjøre. \"Skriv inn et gyldig telefonnummer\" er bedre enn \"Ugyldig\". Komponenten viser meldingen på gruppenivå, setter aria-invalid på host og annonserer den polite — men teksten er ditt ansvar.", + "wcag": ["3.3.1", "3.3.3"] + }, + { + "summary": "Send inn combobox-tekstene på riktig språk", + "details": "noHitsText (påkrevd), toggleLabel og resultsText videreføres til landvelgeren og må oversettes (bokmål, nynorsk, engelsk). Den innebygde landlista lokaliseres med locale, men UI-chrome har ingen fallback.", + "wcag": ["3.1.2"] + }, + { + "summary": "Vurder bruk av disabled", + "details": "Et deaktivert felt er vanskelig å forstå uten forklaring, og disabled bryter kontrastkravet (1.4.3 unntar disabled-elementer). Vurder om feltet heller bør forklares eller skjules.", + "wcag": ["3.3.2"] + } + ], + "issues": [ + { + "summary": "Nummerformatet er norsk-spesifikt", + "details": "Standardformatet (numberFormat=\"phone\") er norsk 8-sifret (123 45 678) uavhengig av valgt land. Endring av landkode reformaterer ikke nummeret. For andre lands nummerlengder må konsumenten sende eget format/formatPattern. Dette er en kjent begrensning i v1, ikke et WCAG-brudd.", + "wcag": [] + } + ], + "handled": [ + { "id": "1.3.1", "notes": "ix-phone-number-field setter role=\"group\", aria-labelledby til den felles legend, og aria-describedby til description og error. De to kontrollene (ix-combobox og nummer-input) beholder sin egen ARIA." }, + { "id": "1.3.5", "notes": "Nummerfeltet får autocomplete=\"tel-national\", type=\"tel\" og inputmode=\"numeric\" slik at feltets formål eksponeres programmatisk og nettleser/hjelpemidler kan autofylle telefonnummer." }, + { "id": "1.4.1", "notes": "Valgt land vises som tekst — kallekoden (f.eks. +47) i landfeltet, med landnavnet som sekundær tekst i lista. Valgt-tilstand i lista markeres med checkmark (form) — ikke kun farge. Feiltilstand vises både med aria-invalid, tekstmelding og farge." }, + { "id": "1.4.3", "notes": "Tekst bruker --ix-color-foreground-main-default mot standard bakgrunn. Forventes å oppfylle 4,5:1. Verifiser ved egendefinerte bakgrunnsfarger." }, + { "id": "1.4.10", "notes": "Reflower korrekt ned til 320 px: den smale landvelgeren (kun kallekode) og nummerfeltet ligger side ved side på én rad hele veien; nummerfeltet krymper (min-width: 0) i stedet for å tvinge horisontal scroll." }, + { "id": "1.4.11", "notes": "Feltenes border og fokus-indikatorer arves fra combobox.css og text-field.css (--ix-color-border-main-default / --ix-color-fill-*). Forventes å oppfylle 3:1." }, + { "id": "1.4.12", "notes": "Bruker relative enheter og tåler økt line-height, bokstav- og ordavstand uten tap av innhold." }, + { "id": "2.1.1", "notes": "Fullt tastaturopererbar: Tab når landvelger så nummerfelt, combobox har eget APG-tastaturmønster (piltast/Enter/Escape), nummerfeltet er en vanlig tekst-input." }, + { "id": "2.4.3", "notes": "Fokusrekkefølge følger DOM: landvelger før nummerfelt, som samsvarer med leserekkefølgen." }, + { "id": "2.4.7", "notes": ":focus-visible på begge feltene gir tydelig outline (arvet fra underliggende komponenter)." }, + { "id": "2.5.5", "notes": "Toggle-knapp, alternativer og inputfelter oppfyller minimum 44×44 px klikkflate (arvet fra combobox/text-field)." }, + { "id": "2.5.8", "notes": "Klikkeflaten på 44×44 px oppfyller WCAG 2.2 AA-kravet (24×24 px) med god margin." }, + { "id": "3.3.1", "notes": "ix-phone-number-field setter aria-invalid=\"true\" på host når errorMessage har innhold, og kobler feilmeldingen via aria-describedby." }, + { "id": "3.3.2", "notes": "Felles legend + per-felt aria-label gir instruksjoner/etiketter for begge kontrollene." }, + { "id": "3.3.3", "notes": "errorMessage brukes til å gi konkret feilmelding. Komponenten viser den, men teksten er konsumentens ansvar." }, + { "id": "4.1.2", "notes": "role=\"group\" med aria-labelledby gir gruppa navn og rolle. De to kontrollene eksponerer egne roller/navn/verdier (combobox-mønster og native input)." }, + { "id": "4.1.3", "notes": "ix-phone-number-field setter aria-live=\"polite\" på error-elementet slik at endringer i feilmeldingen annonseres uten å avbryte brukeren." } + ], + "notRelevant": [ + { "id": "1.1.1" }, + { "id": "1.2.1" }, + { "id": "1.2.2" }, + { "id": "1.2.3" }, + { "id": "1.2.4" }, + { "id": "1.2.5" }, + { "id": "1.3.3" }, + { "id": "1.3.4" }, + { "id": "1.4.2" }, + { "id": "1.4.4", "notes": "Bruker relative enheter — skalerer korrekt ved 200 % zoom, men det er ikke en aktiv tilpasning i komponenten." }, + { "id": "1.4.5" }, + { "id": "1.4.13" }, + { "id": "2.1.2" }, + { "id": "2.1.4" }, + { "id": "2.2.1" }, + { "id": "2.2.2" }, + { "id": "2.3.1" }, + { "id": "2.4.1" }, + { "id": "2.4.2" }, + { "id": "2.4.4", "reason": "Ingen lenker i komponenten." }, + { "id": "2.4.5" }, + { "id": "2.4.11" }, + { "id": "2.5.1" }, + { "id": "2.5.2" }, + { "id": "2.5.4" }, + { "id": "2.5.6" }, + { "id": "2.5.7" }, + { "id": "3.1.1" }, + { "id": "3.1.2", "reason": "Alt innhold er på gruppens språk; komponenten setter ikke lang på deler." }, + { "id": "3.2.1" }, + { "id": "3.2.2" }, + { "id": "3.2.3" }, + { "id": "3.2.4" }, + { "id": "3.2.6" }, + { "id": "3.3.4" }, + { "id": "3.3.7" }, + { "id": "3.3.8" } + ], + "keyboard": [ + { "key": "Tab", "action": "Flytter fokus til landvelgeren, deretter til nummerfeltet" }, + { "key": "Pil ned / Pil opp", "action": "Åpner og navigerer i landlista når landvelgeren har fokus" }, + { "key": "Enter", "action": "Velger markert land i landlista" }, + { "key": "Escape", "action": "Lukker landlista uten å endre valg" }, + { "key": "Skriving", "action": "I landvelgeren filtrerer teksten lista; i nummerfeltet skrives telefonnummeret (formateres til 123 45 678)" } + ], + "screenReader": [ + "Ved fokus inn i komponenten: \"[label], gruppe\" — leses opp som group med legend som navn", + "Ved fokus på landvelgeren: \"[countryLabel], combobox\" med APG-annonsering av alternativer og antall treff", + "Hvert alternativ leses i DOM-rekkefølge: kallekoden først, så landnavnet (f.eks. «+47, Norge»). Valgt verdi som vises i landfeltet er kallekoden.", + "Ved fokus på nummerfeltet: \"[numberLabel], redigering, tekst\"", + "Ved feiltilstand: aria-invalid annonseres som «ugyldig» på gruppa, og feilmeldingsteksten leses opp polite via aria-live", + "Når description er satt: leses opp som del av gruppe-konteksten via aria-describedby" + ] +} diff --git a/indeks-docs/docs/komponenter/skjema/combobox-html.mdx b/indeks-docs/docs/komponenter/skjema/combobox-html.mdx index d9c32cd1..489293b8 100644 --- a/indeks-docs/docs/komponenter/skjema/combobox-html.mdx +++ b/indeks-docs/docs/komponenter/skjema/combobox-html.mdx @@ -83,6 +83,11 @@ Skal ett eller flere alternativer være valgt når siden lastes, markerer du dem Sverige +46 +
+ + Danmark + +45 +
@@ -119,14 +124,17 @@ I flervalg gir du hvert forhåndsvalgt alternativ `aria-selected="true"` og lar
Norge + +47
Sverige + +46
Danmark + +45
diff --git a/indeks-docs/docs/komponenter/skjema/combobox.mdx b/indeks-docs/docs/komponenter/skjema/combobox.mdx index 5aa001b9..dc245f71 100644 --- a/indeks-docs/docs/komponenter/skjema/combobox.mdx +++ b/indeks-docs/docs/komponenter/skjema/combobox.mdx @@ -27,26 +27,6 @@ Combobox er en sammensatt komponent med en del markup. [Combobox i ren HTML](./c ## Kom i gang - - -```tsx live - -``` - -Prøv å skrive for å filtrere lista. Søket treffer både **label**, **verdi** og **beskrivelse** — så «+47» eller «47» finner Norge like godt som «Norge». Filtreringen er locale-bevisst (norsk collator, æ/ø/å-korrekt og aksent-uavhengig). `noHitsText` og `toggleLabel` er tekster du selv oversetter. - - ```jsx live @@ -84,15 +64,6 @@ Prøv å skrive for å filtrere lista. Søket treffer både **label**, **verdi** `` kobler ARIA (role=combobox/listbox/option, aria-activedescendant), håndterer tastatur og filtrering, og synkroniserer valgte verdier til den skjulte ` @@ -144,6 +128,26 @@ I React setter du startverdi med `defaultValue` (ukontrollert) eller `value` (ko Norge vises i feltet fra start fordi optionen har `aria-selected="true"`. Verken `value` på inputen eller ` + + +```tsx live + +``` + +I React setter du startverdi med `defaultValue` (ukontrollert) eller `value` (kontrollert) — `'47'` i single, `['47', '46']` i flervalg. + @@ -168,9 +172,9 @@ function KontrollertLand() { value={land} onChange={setLand} options={[ - { value: '47', label: 'Norge' }, - { value: '46', label: 'Sverige' }, - { value: '45', label: 'Danmark' }, + { value: '47', label: 'Norge', description: '+47' }, + { value: '46', label: 'Sverige', description: '+46' }, + { value: '45', label: 'Danmark', description: '+45' }, ]} />

Valgt verdi: {land || '(ingen)'}

@@ -188,28 +192,6 @@ Søk/filtrering skjer inne i web-komponenten (locale-bevisst, treffer label, ver I flervalg holdes lista åpen etter valg, inputfeltet tømmes, og valgte alternativer vises som chips. Chips nås med piltast og fjernes med Backspace eller klikk. - - -```tsx live - -``` - - ```jsx live @@ -232,14 +214,17 @@ I flervalg holdes lista åpen etter valg, inputfeltet tømmes, og valgte alterna
Norge + +47
-
+
Sverige + +46
Danmark + +45
@@ -250,29 +235,32 @@ I flervalg holdes lista åpen etter valg, inputfeltet tømmes, og valgte alterna ```
-
- -### Med hjelpetekst - - ```tsx live ``` + + +### Med hjelpetekst + + ```jsx live @@ -288,10 +276,17 @@ I flervalg holdes lista åpen etter valg, inputfeltet tømmes, og valgte alterna
Norge + +47
Sverige + +46 +
+
+ + Danmark + +45
@@ -302,28 +297,29 @@ I flervalg holdes lista åpen etter valg, inputfeltet tømmes, og valgte alterna ```
-
- -### Med feilmelding - - ```tsx live ``` + + +### Med feilmelding + + ```jsx live @@ -338,10 +334,17 @@ I flervalg holdes lista åpen etter valg, inputfeltet tømmes, og valgte alterna
Norge + +47
Sverige + +46 +
+
+ + Danmark + +45
@@ -352,28 +355,29 @@ I flervalg holdes lista åpen etter valg, inputfeltet tømmes, og valgte alterna ```
-
- -### Deaktivert - - ```tsx live ``` + + +### Deaktivert + + ```jsx live @@ -388,6 +392,17 @@ I flervalg holdes lista åpen etter valg, inputfeltet tømmes, og valgte alterna
Norge + +47 +
+
+ + Sverige + +46 +
+
+ + Danmark + +45
@@ -397,6 +412,24 @@ I flervalg holdes lista åpen etter valg, inputfeltet tømmes, og valgte alterna
``` + + + +```tsx live + +``` + diff --git a/indeks-docs/docs/komponenter/skjema/phone-number-field.mdx b/indeks-docs/docs/komponenter/skjema/phone-number-field.mdx new file mode 100644 index 00000000..c132bc1b --- /dev/null +++ b/indeks-docs/docs/komponenter/skjema/phone-number-field.mdx @@ -0,0 +1,444 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; +import AccessibilityTable, { KeyboardAndScreenReader } from '@site/src/components/AccessibilityTable'; +import a11yData from './accessibility/phone-number-field.json'; + +# PhoneNumberField + +PhoneNumberField lar brukeren oppgi et telefonnummer med landkode. Komponenten setter sammen en søkbar **landvelger** (der brukeren velger _land_, ikke skriver tallkode) og et **nummerfelt** som formateres mens man skriver — under én felles label og én felles feilmelding. + +:::info Sammensatt komponent +PhoneNumberField reimplementerer ingenting selv: den grupperer en [Combobox](./combobox) (landkode) og et [TextField](./textfield) med [formatering](./textfield-formatering) (nummer). All filtrering, virtual focus, formatering og form-synk kommer uendret fra de to. +::: + +## Egnet til + +- Skjemaer der brukeren skal oppgi telefonnummer med landkode +- Tjenester som møter kunder fra flere land (velg land fremfor å kunne tallkoden) +- Situasjoner der landkode-feltet ikke bør se like «tungt» ut som nummeret + +## Uegnet til + +- Rene norske nummer der landkode er unødvendig (bruk et vanlig [TextField](./textfield) med `format="phone"`) +- Fri tekst / nummer som ikke skal valideres som telefon + +## Kom i gang + + + + +```jsx live + + Mobilnummer +
+
+ +
+ + +
+ +
+
+
+ +
+ +
+
+
+
+ +
+``` + +Du skriver minimal markup: en **tom** `` (bare input + chevron + tom `.ix-combobox__listbox`) og et bart nummer-``. `` gjør resten: + +- **Fyller landlista** fra den innebygde, lokaliserbare lista (`data-locale`) — eller din egen `data-countries`-JSON — og setter forhåndsvalg via `data-default-country-code`/`data-country-code`. +- **Stamper nummer-feltet** med `type="tel"`, `inputmode="numeric"`, `autocomplete="tel-national"` og `data-format="phone"` når de mangler. +- **Setter gruppe-ARIA:** `role="group"`, kobler `data-field="legend"` via `aria-labelledby`, `data-field="error"` via `aria-describedby` + `aria-live="polite"`, og propagerer `disabled`/`readonly`/`required` til begge kontrollene. + +Den rører aldri combobox-ens eller nummerfeltets egen indre ARIA, og fyller kun en `.ix-combobox__listbox` som er tom — skriver du options selv, beholdes de. + +
+ + +```tsx live + +``` + +Landvelgeren bruker den innebygde landlista (Norge øverst, så Norden, så alfabetisk). Skriv i nummerfeltet — det formateres til `123 45 678` mens du skriver, men `onChange` og form-innsending gir alltid den **rå** verdien uten mellomrom. + + +
+ +## Eksempler + +### Med beskrivelse + + + + +```jsx live + + Mobilnummer + Vi bruker nummeret kun til SMS-varsling. Format: 123 45 678 +
+
+ +
+ + +
+ +
+
+
+ +
+ +
+
+
+
+ +
+``` + +
+ + +```tsx live + +``` + + +
+ +### Kontrollert vs. ukontrollert (React) + +Komponenten har to uavhengige verdier: **landkode** og **nummer**. Begge kan være kontrollert eller ukontrollert, hver for seg: + +- **Landkode:** `defaultCountryCode` (ukontrollert) eller `countryCode` + `onCountryCodeChange` (kontrollert). Verdien er landkoden uten `+`, f.eks. `"47"`. +- **Nummer:** `defaultValue` (ukontrollert) eller `value` + `onChange` (kontrollert). `onChange` får alltid den **rå** verdien uten separatorer. + +Å bytte land reformaterer ikke nummeret — formatet er uavhengig av valgt land. + +```tsx live +function KontrollertNummer() { + const [land, setLand] = React.useState('47'); + const [nummer, setNummer] = React.useState('12345678'); + return ( + <> + +

Landkode: +{land || '(ingen)'} — Nummer (rått): {nummer || '(tomt)'}

+ + ); +} +``` + +### Egen landliste + +Utelates `countries` fyller web-komponenten inn den innebygde standardlista i språket fra `locale` (`nb` | `nn` | `en`). Send inn din egen `countries` for å begrense eller utvide — samme form som `Combobox`-options (`value` = landkode, `label` = kallekode `+kode` (vises som valgt verdi), `description` = landnavn). I ren HTML tilsvarer dette `data-countries` med en JSON-array av samme form. + +```tsx live + +``` + +### Engelsk landliste + +```tsx live + +``` + +### Med feilmelding + + + + +```jsx live + + Mobilnummer +
+
+ +
+ + +
+ +
+
+
+ +
+ +
+
+
+
+ Skriv inn et gyldig telefonnummer +
+``` + +Ikke-tomt innhold i `data-field="error"` setter automatisk `aria-invalid="true"` på host og annonseres polite. + +
+ + +```tsx live + +``` + + +
+ +### Deaktivert + + + + +```jsx live + + Mobilnummer +
+
+ +
+ + +
+ +
+
+
+ +
+ +
+
+
+
+ +
+``` + +`disabled` på `` propageres til både landvelgeren og nummerfeltet — du trenger ikke sette det på hvert felt selv. + +
+ + +```tsx live + +``` + +`disabled` propageres til både landvelgeren og nummerfeltet. + + +
+ +## Retningslinjer + +### Velg land — ikke skriv tallkode + +Poenget med komponenten er at brukeren skal velge _land_ i lista, ikke måtte kjenne tallkoden på forhånd. I lista er kallekoden (`+47`) primærtekst og landnavnet vises som sekundær beskrivelse — slik at valgt verdi i det smale landfeltet blir kort og entydig (`+47`), mens landnavnet gir kontekst når man leter. Brukeren kan søke på både landnavn og kode. + +### Ikke bruk emoji-flagg + +Emoji-flagg vises ikke på Windows (viser «NO»/«SE» i stedet), leses inkonsistent av skjermlesere, og små flagg er visuelt tvetydige (Norge/Island). Komponenten er derfor bevisst laget uten flagg — vi viser kallekode + landnavn som tekst. Slik oppfører den seg likt på tvers av alle tjenester. + +### Ett tydelig nummerfelt + +Nummerfeltet skal være bredere enn landvelgeren, slik at det er åpenbart hvor nummeret skrives. Det unngår den klassiske feilen der landkode- og nummerfelt er like brede og brukeren skriver i feil felt. + +### Formatet er norsk-spesifikt i v1 + +Standardformatet er norsk 8-sifret (`123 45 678`) uansett valgt land. For andre lands nummerlengder må du sende eget `numberFormat`/`numberFormatPattern`. Formateringen er kun visuell — rå verdi (uten mellomrom) er alltid det som sendes. + +## Tilgjengelighet / universell utforming + +### Gruppe med felles navn + +`` setter `role="group"` og kobler den felles labelen (`data-field="legend"`) via `aria-labelledby`. Skjermlesere annonserer de to feltene som én gruppe med et felles navn. De to kontrollene beholder sin egen rolle, navn og verdi — combobox følger [ARIA APG combobox-mønsteret](https://www.w3.org/WAI/ARIA/apg/patterns/combobox/), nummerfeltet er en native `input`. + +### Feltets formål eksponeres (1.3.5) + +Web-komponenten stamper nummerfeltet med `type="tel"`, `inputmode="numeric"` og `autocomplete="tel-national"` (når de mangler) slik at mobilt tastatur viser tall, og nettleser/hjelpemidler kan autofylle telefonnummer. + +### Felles feilmelding annonseres + +Feilmeldingen ligger på gruppenivå. Når `errorMessage` har innhold setter komponenten `aria-invalid="true"` på host, kobler meldingen via `aria-describedby`, og annonserer den `polite` via `aria-live` (avbryter ikke brukeren). + +### Fokusrekkefølge og tastatur + +Tab flytter fokus fra landvelger til nummerfelt (samme som leserekkefølgen). Landvelgeren er fullt tastaturopererbar (piltast/Enter/Escape), nummerfeltet er et vanlig tekstfelt. + +### i18n — send inn oversatt tekst + +`label`, `countryLabel`, `numberLabel`, `noHitsText` (påkrevd), `toggleLabel` og `resultsText` må sendes inn på riktig språk (bokmål, nynorsk, engelsk) — det finnes ingen fallback. Den innebygde landlista lokaliseres med `locale` (`data-locale` i ren HTML), men er også fullt overstyrbar via `countries` (`data-countries`). + + + +### WCAG-kriterier + + + +## Props / API + + + + +### PhoneNumberFieldProps + +| Prop | Type | Påkrevd | Standard | Beskrivelse | +|------|------|---------|----------|-------------| +| `label` | `string` | Ja | | Synlig felles label over begge feltene (legend) | +| `countryLabel` | `string` | Ja | | aria-label på landvelgeren (i18n) | +| `numberLabel` | `string` | Ja | | aria-label på nummerfeltet (i18n) | +| `noHitsText` | `string` | Ja | | Tekst i landlista ved 0 treff (i18n) | +| `description` | `string` | Nei | | Felles hjelpetekst under label | +| `errorMessage` | `string` | Nei | | Felles feilmelding. Setter `aria-invalid` når den har innhold | +| `placeholder` | `string` | Nei | | **Frarådes** — placeholder er et a11y-antimønster. Bruk `description` for hjelpetekst/format-hint i stedet | +| `toggleLabel` | `string` | Nei | | aria-label på landvelgerens chevron-knapp (i18n) | +| `resultsText` | `string` | Nei | | Mal for annonsering av antall treff i landlista, `{n}` = antall (i18n) | +| `countryCode` | `string` | Nei | | Kontrollert landkode (uten `+`, f.eks. `"47"`) | +| `defaultCountryCode` | `string` | Nei | | Ukontrollert start-landkode | +| `onCountryCodeChange` | `(countryCode: string) => void` | Nei | | Kalles med ny landkode ved valg | +| `value` | `string` | Nei | | Kontrollert nummer (rå verdi uten separatorer) | +| `defaultValue` | `string` | Nei | | Ukontrollert start-nummer (rå verdi) | +| `onChange` | `(value: string) => void` | Nei | | Kalles med rå nummer-verdi ved endring | +| `countries` | `CountryOption[]` | Nei | innebygd liste | Overstyr landlista | +| `locale` | `'nb' \| 'nn' \| 'en'` | Nei | `'nb'` | Språk for den innebygde standardlista | +| `numberFormat` | `string \| FieldFormatter` | Nei | `'phone'` | Format for nummeret (norsk 8-sifret som standard) | +| `numberFormatPattern` | `string` | Nei | | Pattern-streng (`0` = siffer). Alternativ til `numberFormat` | +| `numberFormatLive` | `boolean` | Nei | | Overstyr formatterens live-modus | +| `disabled` | `boolean` | Nei | | Deaktiverer begge feltene | +| `readOnly` | `boolean` | Nei | | Skrivebeskytter begge feltene | +| `required` | `boolean` | Nei | | Markerer både landvelger og nummerfelt som påkrevd | +| `name` | `string` | Nei | | Navn på nummerfeltet ved form-innsending | +| `countryName` | `string` | Nei | | Navn på landkode-feltet ved form-innsending | +| `className` | `string` | Nei | | CSS-klasse på `` | +| `id` | `string` | Nei | | id på gruppen | +| `ref` | `Ref` | Nei | | Ref videresendes til `` | + +### CountryOption + +Samme form som `ComboboxOption`. Utelates `countries` fyller web-komponenten inn sin innebygde standardliste (lokalisert med `locale`). + +| Felt | Type | Beskrivelse | +|------|------|-------------| +| `value` | `string` | Landkoden uten `+` (brukes som `countryCode`) | +| `label` | `string` | Kallekoden, f.eks. `+47` — vist som valgt verdi (primærtekst) | +| `description` | `string` | Landnavnet (sekundær tekst i lista) | + + + + +### `` + +| Attributt | Påkrevd | Beskrivelse | +|-----------|---------|-------------| +| `disabled` | Nei | Deaktiverer begge kontrollene | +| `readonly` | Nei | Skrivebeskytter begge kontrollene | +| `required` | Nei | Markerer begge de indre ``-ene som påkrevd (propageres) | +| `data-locale` | Nei | Språk for den innebygde landlista (`nb` \| `nn` \| `en`, standard `nb`) | +| `data-countries` | Nei | JSON-array som overstyrer landlista (`{value,label,description}[]`) | +| `data-country-code` | Nei | Kontrollert forhåndsvalgt landkode (uten `+`) | +| `data-default-country-code` | Nei | Ukontrollert start-landkode (uten `+`) | + +### Delelementer (light DOM) + +| Del | Påkrevd | Beskrivelse | +|-----|---------|-------------| +| `[data-field="legend"]` | Ja | Felles label. Kobles via `aria-labelledby` | +| `[data-field="description"]` | Nei | Felles hjelpetekst. Kobles via `aria-describedby` | +| `[data-field="items"]` | Ja | Wrapper for de to feltene (styrer side-ved-side-layout) | +| `[data-field="country"] > ix-combobox` | Ja | Landvelgeren. Skriv den **tom** (input + chevron + tom `.ix-combobox__listbox`) — web-komponenten fyller lista. Gi input `aria-label` og et `name` (ikke egen ` + + +## Tilpasning med CSS + +| Element | Selektor | +|---------|----------| +| Rot | `.ix-phone-number-field` (eller `ix-phone-number-field`) | +| Felt-wrapper | `[data-field="items"]` | +| Landvelger-kolonne | `[data-field="country"]` | +| Nummer-kolonne | `[data-field="number"]` | + +## Relatert + +- [Combobox](./combobox) — landvelgeren som gjenbrukes +- [TextField](./textfield) og [formatering](./textfield-formatering) — nummerfeltet og `format="phone"` +- [ValidationMessage](./validation-message) — feilmeldingens markup diff --git a/indeks-docs/sidebars.ts b/indeks-docs/sidebars.ts index ce16ce44..3819ff26 100644 --- a/indeks-docs/sidebars.ts +++ b/indeks-docs/sidebars.ts @@ -175,6 +175,7 @@ const sidebars: SidebarsConfig = { }, 'komponenter/skjema/date-field', 'komponenter/skjema/label', + 'komponenter/skjema/phone-number-field', { type: 'category', label: 'RadioGroup', diff --git a/indeks-docs/src/theme/ReactLiveScope/index.tsx b/indeks-docs/src/theme/ReactLiveScope/index.tsx index 4f03146c..cf629666 100644 --- a/indeks-docs/src/theme/ReactLiveScope/index.tsx +++ b/indeks-docs/src/theme/ReactLiveScope/index.tsx @@ -23,6 +23,7 @@ import { Message, MessageRegion, Modal, + PhoneNumberField, ProgressBar, RadioButton, RadioGroup, @@ -72,6 +73,7 @@ const ReactLiveScope = { Message, MessageRegion, Modal, + PhoneNumberField, ProgressBar, RadioButton, RadioGroup, diff --git a/indeks-react/lib/ui/components/form/index.ts b/indeks-react/lib/ui/components/form/index.ts index dc7d776e..77a88542 100644 --- a/indeks-react/lib/ui/components/form/index.ts +++ b/indeks-react/lib/ui/components/form/index.ts @@ -16,6 +16,8 @@ export { RadioGroup } from './radio-group/RadioGroup'; export type { RadioGroupProps, RadioOption } from './radio-group/RadioGroup'; export { RadioButton } from './radio-group/RadioButton'; export type { RadioButtonProps } from './radio-group/RadioButton'; +export { PhoneNumberField } from './phone-number-field/PhoneNumberField'; +export type { PhoneNumberFieldProps, CountryOption, CountryLocale } from './phone-number-field/PhoneNumberField'; export { Select } from './select/Select'; export type { SelectProps, SelectOption, SelectOptionGroup } from './select/Select'; export { TextField } from './text-field/TextField'; diff --git a/indeks-react/lib/ui/components/form/phone-number-field/PhoneNumberField.test.tsx b/indeks-react/lib/ui/components/form/phone-number-field/PhoneNumberField.test.tsx new file mode 100644 index 00000000..a3dc8d18 --- /dev/null +++ b/indeks-react/lib/ui/components/form/phone-number-field/PhoneNumberField.test.tsx @@ -0,0 +1,168 @@ +import { render } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { PhoneNumberField } from './PhoneNumberField'; + +// De indre custom-elementene (ix-field / ix-combobox / ix-phone-number-field) +// registreres i testSetup.ts (som importerer @sb1/indeks-web) og kjører derfor sin +// connectedCallback i jsdom — WC-ene er aktive i disse testene. React er et tynt lag: +// disse testene verifiserer at prop-ene videresendes riktig og at WC-en (som eier +// landlista, forhåndsvalg, nummer-defaults og required-propagering) gjør jobben sin +// gjennom React-markupen. Selve landdata-logikken dekkes av indeks-web sine tester. + +function renderField(props: Partial> = {}) { + return render( + + ); +} + +describe('PhoneNumberField', () => { + it('rendrer felles legend med label-teksten', () => { + renderField(); + const legend = document.querySelector('[data-field="legend"]'); + expect(legend?.textContent).toBe('Mobilnummer'); + }); + + it('rendrer både landvelger og nummerfelt', () => { + renderField(); + expect(document.querySelector('ix-combobox')).toBeTruthy(); + // Nummerfeltet er en type="tel"-input i sitt eget data-field="number". + const number = document.querySelector('[data-field="number"] input'); + expect(number?.getAttribute('type')).toBe('tel'); + }); + + it('setter type=tel, inputmode=numeric og autocomplete=tel-national på nummerfeltet', () => { + renderField(); + const input = document.querySelector('[data-field="number"] input')!; + expect(input.getAttribute('type')).toBe('tel'); + expect(input.getAttribute('inputmode')).toBe('numeric'); + expect(input.getAttribute('autocomplete')).toBe('tel-national'); + }); + + it('slår på phone-formatering som default (data-format="phone")', () => { + renderField(); + const input = document.querySelector('[data-field="number"] input')!; + expect(input.getAttribute('data-format')).toBe('phone'); + }); + + it('bruker aria-label på begge feltene (ingen egen synlig label)', () => { + renderField(); + const combobox = document.querySelector('ix-combobox input'); + const number = document.querySelector('[data-field="number"] input'); + expect(combobox?.getAttribute('aria-label')).toBe('Landkode'); + expect(number?.getAttribute('aria-label')).toBe('Telefonnummer'); + // Ingen synlige