@fristil/designsystem 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/CHANGELOG.md +112 -0
  2. package/README.md +22 -7
  3. package/dist/components/frittstaende/connection-status/connection-status.d.ts +28 -0
  4. package/dist/components/frittstaende/connection-status/connection-status.d.ts.map +1 -0
  5. package/dist/components/frittstaende/connection-status/connection-status.js +22 -0
  6. package/dist/components/frittstaende/connection-status/connection-status.js.map +1 -0
  7. package/dist/components/frittstaende/connection-status/fs-connection-status.d.ts +54 -0
  8. package/dist/components/frittstaende/connection-status/fs-connection-status.d.ts.map +1 -0
  9. package/dist/components/frittstaende/connection-status/fs-connection-status.js +123 -0
  10. package/dist/components/frittstaende/connection-status/fs-connection-status.js.map +1 -0
  11. package/dist/components/frittstaende/session-timeout/fs-session-timeout.d.ts +56 -0
  12. package/dist/components/frittstaende/session-timeout/fs-session-timeout.d.ts.map +1 -0
  13. package/dist/components/frittstaende/session-timeout/fs-session-timeout.js +179 -0
  14. package/dist/components/frittstaende/session-timeout/fs-session-timeout.js.map +1 -0
  15. package/dist/components/frittstaende/session-timeout/session-timeout.d.ts +28 -0
  16. package/dist/components/frittstaende/session-timeout/session-timeout.d.ts.map +1 -0
  17. package/dist/components/frittstaende/session-timeout/session-timeout.js +22 -0
  18. package/dist/components/frittstaende/session-timeout/session-timeout.js.map +1 -0
  19. package/dist/components/frittstaende/toast/fs-toast.d.ts +16 -22
  20. package/dist/components/frittstaende/toast/fs-toast.d.ts.map +1 -1
  21. package/dist/components/frittstaende/toast/fs-toast.js +38 -27
  22. package/dist/components/frittstaende/toast/fs-toast.js.map +1 -1
  23. package/dist/components/frittstaende/toast/toast.d.ts +34 -0
  24. package/dist/components/frittstaende/toast/toast.d.ts.map +1 -0
  25. package/dist/components/frittstaende/toast/toast.js +26 -0
  26. package/dist/components/frittstaende/toast/toast.js.map +1 -0
  27. package/dist/components/ramme/error-summary/error-summary.d.ts +39 -0
  28. package/dist/components/ramme/error-summary/error-summary.d.ts.map +1 -0
  29. package/dist/components/ramme/error-summary/error-summary.js +31 -0
  30. package/dist/components/ramme/error-summary/error-summary.js.map +1 -0
  31. package/dist/components/ramme/error-summary/fs-error-summary.d.ts +16 -42
  32. package/dist/components/ramme/error-summary/fs-error-summary.d.ts.map +1 -1
  33. package/dist/components/ramme/error-summary/fs-error-summary.js +57 -92
  34. package/dist/components/ramme/error-summary/fs-error-summary.js.map +1 -1
  35. package/dist/components/ramme/field/field-core.d.ts +31 -0
  36. package/dist/components/ramme/field/field-core.d.ts.map +1 -1
  37. package/dist/components/ramme/field/field-core.js +31 -0
  38. package/dist/components/ramme/field/field-core.js.map +1 -1
  39. package/dist/components/ramme/field/fs-field.d.ts +38 -40
  40. package/dist/components/ramme/field/fs-field.d.ts.map +1 -1
  41. package/dist/components/ramme/field/fs-field.js +127 -54
  42. package/dist/components/ramme/field/fs-field.js.map +1 -1
  43. package/dist/components/ramme/popover/fs-popover.d.ts +20 -27
  44. package/dist/components/ramme/popover/fs-popover.d.ts.map +1 -1
  45. package/dist/components/ramme/popover/fs-popover.js +68 -54
  46. package/dist/components/ramme/popover/fs-popover.js.map +1 -1
  47. package/dist/components/ramme/popover/popover.d.ts +43 -0
  48. package/dist/components/ramme/popover/popover.d.ts.map +1 -0
  49. package/dist/components/ramme/popover/popover.js +49 -0
  50. package/dist/components/ramme/popover/popover.js.map +1 -0
  51. package/dist/components/ramme/suggestion/fs-suggestion.d.ts +75 -0
  52. package/dist/components/ramme/suggestion/fs-suggestion.d.ts.map +1 -0
  53. package/dist/components/ramme/suggestion/fs-suggestion.js +232 -0
  54. package/dist/components/ramme/suggestion/fs-suggestion.js.map +1 -0
  55. package/dist/components/ramme/suggestion/suggestion.d.ts +97 -0
  56. package/dist/components/ramme/suggestion/suggestion.d.ts.map +1 -0
  57. package/dist/components/ramme/suggestion/suggestion.js +79 -0
  58. package/dist/components/ramme/suggestion/suggestion.js.map +1 -0
  59. package/dist/components/ramme/tabs/fs-tabs.d.ts +31 -36
  60. package/dist/components/ramme/tabs/fs-tabs.d.ts.map +1 -1
  61. package/dist/components/ramme/tabs/fs-tabs.js +62 -84
  62. package/dist/components/ramme/tabs/fs-tabs.js.map +1 -1
  63. package/dist/components/ramme/tabs/tabs.d.ts +50 -0
  64. package/dist/components/ramme/tabs/tabs.d.ts.map +1 -0
  65. package/dist/components/ramme/tabs/tabs.js +54 -0
  66. package/dist/components/ramme/tabs/tabs.js.map +1 -0
  67. package/dist/fs.d.ts +94 -0
  68. package/dist/fs.d.ts.map +1 -1
  69. package/dist/fs.js +42 -0
  70. package/dist/fs.js.map +1 -1
  71. package/dist/index.d.ts +10 -3
  72. package/dist/index.d.ts.map +1 -1
  73. package/dist/index.js +10 -3
  74. package/dist/index.js.map +1 -1
  75. package/dist/jsx/react.d.ts +35 -50
  76. package/dist/jsx/react.d.ts.map +1 -1
  77. package/dist/react.d.ts +142 -1
  78. package/dist/react.d.ts.map +1 -1
  79. package/dist/react.js +71 -2
  80. package/dist/react.js.map +1 -1
  81. package/package.json +15 -18
  82. package/src/components/frittstaende/connection-status/connection-status.css +33 -0
  83. package/src/components/frittstaende/connection-status/connection-status.ts +41 -0
  84. package/src/components/frittstaende/connection-status/fs-connection-status.ts +144 -0
  85. package/src/components/frittstaende/session-timeout/fs-session-timeout.ts +212 -0
  86. package/src/components/frittstaende/session-timeout/session-timeout.css +43 -0
  87. package/src/components/frittstaende/session-timeout/session-timeout.ts +42 -0
  88. package/src/components/frittstaende/toast/fs-toast.ts +39 -30
  89. package/src/components/frittstaende/toast/toast.ts +45 -0
  90. package/src/components/ramme/error-summary/error-summary.ts +55 -0
  91. package/src/components/ramme/error-summary/fs-error-summary.ts +62 -119
  92. package/src/components/ramme/field/field-core.ts +32 -0
  93. package/src/components/ramme/field/fs-field.ts +144 -61
  94. package/src/components/ramme/popover/fs-popover.ts +78 -72
  95. package/src/components/ramme/popover/popover.ts +79 -0
  96. package/src/components/ramme/suggestion/fs-suggestion.ts +265 -0
  97. package/src/components/ramme/suggestion/suggestion.ts +105 -0
  98. package/src/components/ramme/tabs/fs-tabs.ts +67 -98
  99. package/src/components/ramme/tabs/tabs.ts +104 -0
  100. package/dist/components/frittstaende/calendar/fs-calendar.d.ts +0 -52
  101. package/dist/components/frittstaende/calendar/fs-calendar.d.ts.map +0 -1
  102. package/dist/components/frittstaende/calendar/fs-calendar.js +0 -462
  103. package/dist/components/frittstaende/calendar/fs-calendar.js.map +0 -1
  104. package/dist/components/frittstaende/date-field/fs-date-field.d.ts +0 -110
  105. package/dist/components/frittstaende/date-field/fs-date-field.d.ts.map +0 -1
  106. package/dist/components/frittstaende/date-field/fs-date-field.js +0 -264
  107. package/dist/components/frittstaende/date-field/fs-date-field.js.map +0 -1
  108. package/dist/components/frittstaende/suggestion/fs-suggestion.d.ts +0 -133
  109. package/dist/components/frittstaende/suggestion/fs-suggestion.d.ts.map +0 -1
  110. package/dist/components/frittstaende/suggestion/fs-suggestion.js +0 -280
  111. package/dist/components/frittstaende/suggestion/fs-suggestion.js.map +0 -1
  112. package/src/components/frittstaende/calendar/fs-calendar.ts +0 -525
  113. package/src/components/frittstaende/date-field/date-field.css +0 -54
  114. package/src/components/frittstaende/date-field/fs-date-field.ts +0 -309
  115. package/src/components/frittstaende/suggestion/fs-suggestion.ts +0 -333
  116. /package/src/components/{frittstaende → ramme}/suggestion/suggestion.css +0 -0
@@ -1,309 +0,0 @@
1
- import { html, LitElement } from "lit"
2
- import { ifDefined } from "lit/directives/if-defined.js"
3
- import { live } from "lit/directives/live.js"
4
-
5
- import { computeFieldAttributes } from "../../ramme/field/field-core.js"
6
- import { defineFsCalendar } from "../calendar/fs-calendar.js"
7
-
8
- export const FS_DATE_FIELD_TAG = "fs-date-field" as const
9
-
10
- function uniqueId(prefix: string): string {
11
- return `${prefix}-${Math.random().toString(36).slice(2, 9)}`
12
- }
13
-
14
- function isoToDisplay(iso: string): string {
15
- const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso)
16
- if (!match) return iso
17
- return `${match[3]}-${match[2]}-${match[1]}`
18
- }
19
-
20
- /** ISO-streng, eller null om teksten ikke er en dato som finnes. */
21
- function displayToIso(display: string): string | null {
22
- const match = /^(\d{2})-(\d{2})-(\d{4})$/.exec(display)
23
- if (!match) return null
24
- const day = Number(match[1])
25
- const month = Number(match[2])
26
- const year = Number(match[3])
27
- // Date.UTC ruller over ved ugyldige datoer: 32-06-2026 blir 2026-07-02
28
- const date = new Date(Date.UTC(year, month - 1, day))
29
- if (
30
- date.getUTCFullYear() !== year ||
31
- date.getUTCMonth() !== month - 1 ||
32
- date.getUTCDate() !== day
33
- ) {
34
- return null
35
- }
36
- return `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`
37
- }
38
-
39
- export class FsDateField extends LitElement {
40
- static properties = {
41
- label: { type: String },
42
- value: { type: String, reflect: true },
43
- name: { type: String },
44
- placeholder: { type: String },
45
- helpText: { type: String, attribute: "help-text" },
46
- errorText: { type: String, attribute: "error-text" },
47
- required: { type: Boolean, reflect: true },
48
- optional: { type: Boolean, reflect: true },
49
- disabled: { type: Boolean, reflect: true },
50
- readonly: { type: Boolean, reflect: true },
51
- invalid: { type: Boolean, reflect: true },
52
- controlId: { type: String, attribute: "control-id" },
53
- describedBy: { type: String, attribute: "described-by" },
54
- }
55
-
56
- label = "Dato"
57
- value = ""
58
- name?: string
59
- placeholder = "DD-MM-YYYY"
60
- helpText?: string
61
- errorText?: string
62
- required = false
63
- optional = false
64
- disabled = false
65
- readonly = false
66
- invalid = false
67
- controlId?: string
68
- describedBy?: string
69
-
70
- /** Sann når brukeren har skrevet en dato som ikke finnes. */
71
- private _inputInvalid = false
72
-
73
- /**
74
- * Teksten brukeren har skrevet, når den ikke er en gyldig dato.
75
- *
76
- * Halvskrevet tekst som «01-01-20» kan ikke bli til en ISO-dato, så
77
- * `value` står stille mens den skrives. Uten at teksten er en del av
78
- * tilstanden her, ville malen tegnet feltet tomt igjen ved neste
79
- * oppdatering, og brukeren mistet det de holdt på med.
80
- */
81
- private _typed: string | null = null
82
-
83
- /** Det feltet skal vise: brukerens egen tekst, ellers `value`. */
84
- private get displayValue() {
85
- return this._typed ?? isoToDisplay(this.value)
86
- }
87
-
88
- /**
89
- * Settes `value` utenfra, er brukerens tekst ikke lenger gyldig.
90
- *
91
- * Det gjelder også når komponenten setter `value` selv etter at teksten
92
- * ble en fullstendig dato, og det er riktig: da viser `value` det samme.
93
- */
94
- willUpdate(changed: Map<string, unknown>) {
95
- if (changed.has("value")) this._typed = null
96
- }
97
-
98
- private readonly uid = uniqueId("fs-date-field")
99
-
100
- constructor() {
101
- super()
102
- defineFsCalendar()
103
- }
104
-
105
- createRenderRoot() {
106
- return this
107
- }
108
-
109
- render() {
110
- const invalid = this.invalid || this._inputInvalid
111
-
112
- // Tilgjengelighetskontrakten regnes ut ett sted, det samme som
113
- // `<fs-field>` og `fs.field()` bruker. Komponenten hadde tidligere sin
114
- // egen utgave av den samme logikken, og de to kunne gå fra hverandre.
115
- const field = computeFieldAttributes({
116
- id: this.inputId,
117
- help: Boolean(this.helpText),
118
- error: Boolean(this.errorText),
119
- required: this.required ? "symbol" : undefined,
120
- optional: this.optional,
121
- invalid,
122
- disabled: this.disabled,
123
- describedBy: this.describedBy ? [this.describedBy] : [],
124
- helpId: this.helpId,
125
- errorId: this.errorId,
126
- })
127
-
128
- return html`
129
- <div class="fs-date-field">
130
- <label
131
- class="fs-label"
132
- for=${field.label.for}
133
- data-required=${ifDefined(field.label["data-required"])}
134
- data-optional=${ifDefined(field.label["data-optional"])}
135
- aria-disabled=${ifDefined(field.label["aria-disabled"])}
136
- >${this.label}</label
137
- >
138
- <div class="fs-date-field__field">
139
- <input
140
- id=${field.control.id}
141
- class="fs-input"
142
- type="text"
143
- aria-describedby=${ifDefined(field.control["aria-describedby"])}
144
- aria-invalid=${ifDefined(field.control["aria-invalid"])}
145
- data-state=${ifDefined(field.control["data-state"])}
146
- .value=${live(this.displayValue)}
147
- name=${this.name ?? ""}
148
- placeholder=${this.placeholder}
149
- ?required=${this.required}
150
- ?disabled=${this.disabled}
151
- ?readonly=${this.readonly}
152
- @input=${this.handleInput}
153
- @blur=${this.handleInputBlur}
154
- @keydown=${this.handleInputKeydown}
155
- />
156
- <button
157
- type="button"
158
- class="fs-date-field__icon-btn"
159
- aria-label="Åpne kalender"
160
- tabindex="-1"
161
- ?disabled=${this.disabled}
162
- @click=${this.handleIconClick}
163
- >
164
- <svg
165
- xmlns="http://www.w3.org/2000/svg"
166
- viewBox="0 0 24 24"
167
- fill="none"
168
- stroke="currentColor"
169
- stroke-width="2"
170
- stroke-linecap="round"
171
- stroke-linejoin="round"
172
- width="1rem"
173
- height="1rem"
174
- aria-hidden="true"
175
- >
176
- <rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect>
177
- <line x1="16" y1="2" x2="16" y2="6"></line>
178
- <line x1="8" y1="2" x2="8" y2="6"></line>
179
- <line x1="3" y1="10" x2="21" y2="10"></line>
180
- </svg>
181
- </button>
182
- <fs-calendar
183
- id=${this.calendarId}
184
- .value=${this.value}
185
- ?disabled=${this.disabled}
186
- trigger-hidden
187
- class="fs-date-field__calendar"
188
- @date-select=${this.handleCalendarSelect}
189
- ></fs-calendar>
190
- </div>
191
-
192
- ${this.helpText ? html`<p class="fs-help-text" id=${this.helpId}>${this.helpText}</p>` : null}
193
- ${
194
- this.errorText
195
- ? html`<p class="fs-error-text" id=${field.error.id} ?hidden=${Boolean(field.error.hidden)}>${this.errorText}</p>`
196
- : null
197
- }
198
- </div>
199
- `
200
- }
201
-
202
- private get inputId() {
203
- return this.controlId || `${this.uid}-input`
204
- }
205
-
206
- private get calendarId() {
207
- return `${this.uid}-calendar`
208
- }
209
-
210
- private get helpId() {
211
- return `${this.uid}-help`
212
- }
213
-
214
- private get errorId() {
215
- return `${this.uid}-error`
216
- }
217
-
218
- private get inputElement() {
219
- return this.querySelector(`#${this.inputId}`) as HTMLInputElement | null
220
- }
221
-
222
- private get calendarElement() {
223
- return this.querySelector(`#${this.calendarId}`) as HTMLElement | null
224
- }
225
-
226
- private handleIconClick = (event: Event) => {
227
- event.stopPropagation()
228
- const calendar = this.calendarElement as
229
- | (HTMLElement & {
230
- open?: boolean
231
- openPopup?: () => void
232
- closePopup?: () => void
233
- })
234
- | null
235
- if (!calendar) return
236
- if (calendar.open) {
237
- calendar.closePopup?.()
238
- } else {
239
- calendar.openPopup?.()
240
- }
241
- }
242
-
243
- private handleInputKeydown = (event: KeyboardEvent) => {
244
- if (event.key === " " || event.key === "ArrowDown") {
245
- event.preventDefault()
246
- const calendar = this.calendarElement as
247
- | (HTMLElement & { openPopup?: () => void })
248
- | null
249
- calendar?.openPopup?.()
250
- }
251
- }
252
-
253
- private handleInput = (event: Event) => {
254
- const target = event.currentTarget as HTMLInputElement
255
- const iso = displayToIso(target.value)
256
- if (iso !== null) {
257
- this._inputInvalid = false
258
- this._typed = null
259
- this.value = iso
260
- this.requestUpdate()
261
- } else {
262
- this._typed = target.value
263
- this.requestUpdate()
264
- }
265
- // Ugyldig inndata skal ikke endre this.value. Behold siste gyldige ISO.
266
- this.dispatchEvent(new Event("input", { bubbles: true, composed: true }))
267
- this.dispatchEvent(new Event("change", { bubbles: true, composed: true }))
268
- }
269
-
270
- private handleInputBlur = (event: FocusEvent) => {
271
- const target = event.currentTarget as HTMLInputElement
272
- const raw = target.value
273
- if (raw === "") {
274
- this._inputInvalid = false
275
- } else {
276
- this._inputInvalid = displayToIso(raw) === null
277
- }
278
- this.requestUpdate()
279
- }
280
-
281
- private handleCalendarSelect = (event: Event) => {
282
- const custom = event as CustomEvent<{ value?: string }>
283
- const selected = custom.detail?.value
284
- if (!selected) return
285
-
286
- this._inputInvalid = false
287
- this._typed = null
288
- this.value = selected
289
- this.requestUpdate()
290
- this.dispatchEvent(new Event("input", { bubbles: true, composed: true }))
291
- this.dispatchEvent(new Event("change", { bubbles: true, composed: true }))
292
-
293
- queueMicrotask(() => {
294
- this.inputElement?.focus()
295
- })
296
- }
297
- }
298
-
299
- declare global {
300
- interface HTMLElementTagNameMap {
301
- "fs-date-field": FsDateField
302
- }
303
- }
304
-
305
- export function defineFsDateField(tagName = FS_DATE_FIELD_TAG): void {
306
- if (!customElements.get(tagName)) {
307
- customElements.define(tagName, FsDateField)
308
- }
309
- }
@@ -1,333 +0,0 @@
1
- import { html, LitElement } from "lit"
2
- import { ifDefined } from "lit/directives/if-defined.js"
3
- import { live } from "lit/directives/live.js"
4
-
5
- import { computeFieldAttributes } from "../../ramme/field/field-core.js"
6
-
7
- export const FS_SUGGESTION_TAG = "fs-suggestion" as const
8
-
9
- type Option = { value: string; label: string }
10
-
11
- /**
12
- * Et tekstfelt med forslag som snevres inn mens brukeren skriver.
13
- *
14
- * Alternativene skrives som `<option>`-barn, slik de ville stått i en
15
- * `<datalist>`. Komponenten leser dem én gang og bygger lista selv, fordi
16
- * nettleserens egen `<datalist>` ikke kan styles, oppfører seg ulikt i hver
17
- * nettleser, og ikke melder antall treff til skjermlesere.
18
- *
19
- * Feltet er et fritekstfelt, ikke en nedtrekksliste. Brukeren kan skrive noe
20
- * som ikke står i lista. Skal bare gitte verdier godtas, er
21
- * [Select](../../css/select/select.js) riktig.
22
- *
23
- * ```html
24
- * <fs-suggestion label="Kommune" name="kommune">
25
- * <option>Bergen</option>
26
- * <option>Oslo</option>
27
- * </fs-suggestion>
28
- * ```
29
- */
30
- export class FsSuggestion extends LitElement {
31
- static properties = {
32
- label: { type: String },
33
- value: { type: String, reflect: true },
34
- name: { type: String },
35
- placeholder: { type: String },
36
- helpText: { type: String, attribute: "help-text" },
37
- errorText: { type: String, attribute: "error-text" },
38
- invalid: { type: Boolean, reflect: true },
39
- required: { type: Boolean, reflect: true },
40
- optional: { type: Boolean, reflect: true },
41
- disabled: { type: Boolean, reflect: true },
42
- controlId: { type: String, attribute: "control-id" },
43
- describedBy: { type: String, attribute: "described-by" },
44
- noResultsText: { type: String, attribute: "no-results-text" },
45
- open: { type: Boolean, reflect: true },
46
- }
47
-
48
- label = ""
49
- value = ""
50
- name?: string
51
- placeholder?: string
52
- helpText?: string
53
- errorText?: string
54
- invalid = false
55
- required = false
56
- optional = false
57
- disabled = false
58
- controlId?: string
59
- describedBy?: string
60
- noResultsText = "Ingen treff"
61
- open = false
62
-
63
- /** Alternativet som er markert med piltastene. */
64
- private activeIndex = -1
65
- private options: Option[] = []
66
- private readonly uid =
67
- `fs-suggestion-${Math.random().toString(36).slice(2, 9)}`
68
-
69
- createRenderRoot() {
70
- return this
71
- }
72
-
73
- private observer?: MutationObserver
74
-
75
- connectedCallback() {
76
- super.connectedCallback()
77
- this.readOptions()
78
- document.addEventListener("click", this.handleOutsideClick, true)
79
-
80
- // Alternativene byttes ofte ut mens brukeren skriver, for eksempel med
81
- // treff fra en server. Uten dette ville bare det første settet telt.
82
- this.observer = new MutationObserver(() => {
83
- if (this.querySelector("option")) {
84
- this.readOptions()
85
- this.requestUpdate()
86
- }
87
- })
88
- this.observer.observe(this, { childList: true })
89
- }
90
-
91
- disconnectedCallback() {
92
- document.removeEventListener("click", this.handleOutsideClick, true)
93
- this.observer?.disconnect()
94
- this.observer = undefined
95
- super.disconnectedCallback()
96
- }
97
-
98
- /** Leser `<option>`-barna og fjerner dem fra visningen. */
99
- private readOptions() {
100
- const options = [...this.querySelectorAll("option")]
101
- if (options.length === 0) return
102
-
103
- this.options = options.map((option) => ({
104
- value: option.value || (option.textContent ?? "").trim(),
105
- label: (option.textContent ?? "").trim() || option.value,
106
- }))
107
-
108
- for (const option of options) option.remove()
109
- }
110
-
111
- /** Alternativene som passer med det som står i feltet. */
112
- private get filteredOptions(): Option[] {
113
- const query = this.value.trim().toLowerCase()
114
- if (query === "") return this.options
115
- return this.options.filter((option) =>
116
- option.label.toLowerCase().includes(query),
117
- )
118
- }
119
-
120
- private get inputId() {
121
- return this.controlId || `${this.uid}-input`
122
- }
123
-
124
- private get listId() {
125
- return `${this.uid}-list`
126
- }
127
-
128
- private get statusId() {
129
- return `${this.uid}-status`
130
- }
131
-
132
- render() {
133
- const field = computeFieldAttributes({
134
- id: this.inputId,
135
- help: Boolean(this.helpText),
136
- error: Boolean(this.errorText),
137
- helpId: `${this.uid}-help`,
138
- errorId: `${this.uid}-error`,
139
- required: this.required ? "symbol" : undefined,
140
- optional: this.optional,
141
- invalid: this.invalid,
142
- disabled: this.disabled,
143
- describedBy: [this.statusId, this.describedBy ?? ""].filter(Boolean),
144
- })
145
-
146
- const matches = this.filteredOptions
147
- const open = this.open && !this.disabled
148
-
149
- return html`
150
- <label class="fs-label" for=${field.label.for}
151
- data-required=${ifDefined(field.label["data-required"])}
152
- data-optional=${ifDefined(field.label["data-optional"])}
153
- aria-disabled=${ifDefined(field.label["aria-disabled"])}
154
- >${this.label}</label
155
- >
156
-
157
- <div class="fs-suggestion__field">
158
- <input
159
- class="fs-input"
160
- id=${field.control.id}
161
- name=${ifDefined(this.name)}
162
- type="text"
163
- role="combobox"
164
- autocomplete="off"
165
- .value=${live(this.value)}
166
- placeholder=${ifDefined(this.placeholder)}
167
- aria-expanded=${String(open)}
168
- aria-controls=${this.listId}
169
- aria-autocomplete="list"
170
- aria-activedescendant=${ifDefined(
171
- open && this.activeIndex >= 0
172
- ? `${this.uid}-option-${this.activeIndex}`
173
- : undefined,
174
- )}
175
- aria-describedby=${ifDefined(field.control["aria-describedby"])}
176
- aria-invalid=${ifDefined(field.control["aria-invalid"])}
177
- data-state=${ifDefined(field.control["data-state"])}
178
- ?disabled=${this.disabled}
179
- @input=${this.handleInput}
180
- @keydown=${this.handleKeydown}
181
- @focus=${this.handleFocus}
182
- />
183
-
184
- <ul
185
- class="fs-suggestion__list"
186
- id=${this.listId}
187
- role="listbox"
188
- ?hidden=${!open}
189
- >
190
- ${matches.map(
191
- (option, index) => html`<li
192
- class="fs-suggestion__option"
193
- id=${`${this.uid}-option-${index}`}
194
- role="option"
195
- aria-selected=${String(index === this.activeIndex)}
196
- @mousedown=${(event: Event) => this.handleOptionClick(event, option)}
197
- >
198
- ${option.label}
199
- </li>`,
200
- )}
201
- ${
202
- matches.length === 0
203
- ? html`<li class="fs-suggestion__empty" role="presentation">
204
- ${this.noResultsText}
205
- </li>`
206
- : null
207
- }
208
- </ul>
209
- </div>
210
-
211
- ${
212
- this.helpText
213
- ? html`<p class="fs-help-text" id=${field.help.id}>${this.helpText}</p>`
214
- : null
215
- }
216
- ${
217
- this.errorText
218
- ? html`<p
219
- class="fs-error-text"
220
- id=${field.error.id}
221
- ?hidden=${Boolean(field.error.hidden)}
222
- >
223
- ${this.errorText}
224
- </p>`
225
- : null
226
- }
227
-
228
- <p class="fs-sr-only" id=${this.statusId} role="status">
229
- ${open ? this.statusText(matches.length) : ""}
230
- </p>
231
- `
232
- }
233
-
234
- /** Antall treff, som tekst skjermleseren leser opp. */
235
- private statusText(count: number): string {
236
- if (count === 0) return this.noResultsText
237
- return count === 1 ? "1 forslag" : `${count} forslag`
238
- }
239
-
240
- private handleFocus = () => {
241
- if (!this.disabled) this.show()
242
- }
243
-
244
- private handleInput = (event: Event) => {
245
- const input = event.target as HTMLInputElement
246
- this.value = input.value
247
- this.activeIndex = -1
248
- this.show()
249
- }
250
-
251
- private handleKeydown = (event: KeyboardEvent) => {
252
- const matches = this.filteredOptions
253
-
254
- if (event.key === "Escape") {
255
- event.preventDefault()
256
- this.hide()
257
- return
258
- }
259
-
260
- if (event.key === "ArrowDown" || event.key === "ArrowUp") {
261
- event.preventDefault()
262
- if (!this.open) this.show()
263
- if (matches.length === 0) return
264
-
265
- const step = event.key === "ArrowDown" ? 1 : -1
266
- const next = this.activeIndex + step
267
-
268
- this.activeIndex =
269
- next < 0 ? matches.length - 1 : next >= matches.length ? 0 : next
270
- this.requestUpdate()
271
- return
272
- }
273
-
274
- if (event.key === "Enter" && this.open && this.activeIndex >= 0) {
275
- event.preventDefault()
276
- this.select(matches[this.activeIndex])
277
- }
278
- }
279
-
280
- private handleOptionClick = (event: Event, option: Option) => {
281
- // mousedown og ikke click: feltet mister ellers fokus før valget rekker
282
- // å skje, og lista lukker seg med den.
283
- event.preventDefault()
284
- this.select(option)
285
- }
286
-
287
- private handleOutsideClick = (event: Event) => {
288
- if (!this.open) return
289
- if (this.contains(event.target as Node)) return
290
- this.hide()
291
- }
292
-
293
- /** Velger et alternativ og melder fra. */
294
- select(option: Option) {
295
- this.value = option.value
296
- this.hide()
297
-
298
- const input = this.querySelector("input")
299
- if (input) input.value = option.value
300
-
301
- this.dispatchEvent(
302
- new CustomEvent("suggestion-select", {
303
- detail: { value: option.value, label: option.label },
304
- bubbles: true,
305
- composed: true,
306
- }),
307
- )
308
- this.dispatchEvent(new Event("change", { bubbles: true }))
309
- }
310
-
311
- /** Åpner forslagslista. */
312
- show() {
313
- this.open = true
314
- }
315
-
316
- /** Lukker forslagslista. */
317
- hide() {
318
- this.open = false
319
- this.activeIndex = -1
320
- }
321
- }
322
-
323
- declare global {
324
- interface HTMLElementTagNameMap {
325
- "fs-suggestion": FsSuggestion
326
- }
327
- }
328
-
329
- export function defineFsSuggestion(tagName = FS_SUGGESTION_TAG): void {
330
- if (!customElements.get(tagName)) {
331
- customElements.define(tagName, FsSuggestion)
332
- }
333
- }