@fristil/designsystem 0.4.0 → 0.5.1
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.
- package/CHANGELOG.md +136 -0
- package/README.md +22 -7
- package/dist/components/frittstaende/connection-status/connection-status.d.ts +28 -0
- package/dist/components/frittstaende/connection-status/connection-status.d.ts.map +1 -0
- package/dist/components/frittstaende/connection-status/connection-status.js +22 -0
- package/dist/components/frittstaende/connection-status/connection-status.js.map +1 -0
- package/dist/components/frittstaende/connection-status/fs-connection-status.d.ts +54 -0
- package/dist/components/frittstaende/connection-status/fs-connection-status.d.ts.map +1 -0
- package/dist/components/frittstaende/connection-status/fs-connection-status.js +123 -0
- package/dist/components/frittstaende/connection-status/fs-connection-status.js.map +1 -0
- package/dist/components/frittstaende/session-timeout/fs-session-timeout.d.ts +56 -0
- package/dist/components/frittstaende/session-timeout/fs-session-timeout.d.ts.map +1 -0
- package/dist/components/frittstaende/session-timeout/fs-session-timeout.js +183 -0
- package/dist/components/frittstaende/session-timeout/fs-session-timeout.js.map +1 -0
- package/dist/components/frittstaende/session-timeout/session-timeout.d.ts +28 -0
- package/dist/components/frittstaende/session-timeout/session-timeout.d.ts.map +1 -0
- package/dist/components/frittstaende/session-timeout/session-timeout.js +22 -0
- package/dist/components/frittstaende/session-timeout/session-timeout.js.map +1 -0
- package/dist/components/frittstaende/toast/fs-toast.d.ts +16 -22
- package/dist/components/frittstaende/toast/fs-toast.d.ts.map +1 -1
- package/dist/components/frittstaende/toast/fs-toast.js +38 -27
- package/dist/components/frittstaende/toast/fs-toast.js.map +1 -1
- package/dist/components/frittstaende/toast/toast.d.ts +34 -0
- package/dist/components/frittstaende/toast/toast.d.ts.map +1 -0
- package/dist/components/frittstaende/toast/toast.js +26 -0
- package/dist/components/frittstaende/toast/toast.js.map +1 -0
- package/dist/components/ramme/error-summary/error-summary.d.ts +39 -0
- package/dist/components/ramme/error-summary/error-summary.d.ts.map +1 -0
- package/dist/components/ramme/error-summary/error-summary.js +31 -0
- package/dist/components/ramme/error-summary/error-summary.js.map +1 -0
- package/dist/components/ramme/error-summary/fs-error-summary.d.ts +16 -42
- package/dist/components/ramme/error-summary/fs-error-summary.d.ts.map +1 -1
- package/dist/components/ramme/error-summary/fs-error-summary.js +57 -92
- package/dist/components/ramme/error-summary/fs-error-summary.js.map +1 -1
- package/dist/components/ramme/field/field-core.d.ts +37 -0
- package/dist/components/ramme/field/field-core.d.ts.map +1 -1
- package/dist/components/ramme/field/field-core.js +37 -0
- package/dist/components/ramme/field/field-core.js.map +1 -1
- package/dist/components/ramme/field/fs-field.d.ts +39 -40
- package/dist/components/ramme/field/fs-field.d.ts.map +1 -1
- package/dist/components/ramme/field/fs-field.js +128 -54
- package/dist/components/ramme/field/fs-field.js.map +1 -1
- package/dist/components/ramme/popover/fs-popover.d.ts +20 -27
- package/dist/components/ramme/popover/fs-popover.d.ts.map +1 -1
- package/dist/components/ramme/popover/fs-popover.js +68 -54
- package/dist/components/ramme/popover/fs-popover.js.map +1 -1
- package/dist/components/ramme/popover/popover.d.ts +43 -0
- package/dist/components/ramme/popover/popover.d.ts.map +1 -0
- package/dist/components/ramme/popover/popover.js +49 -0
- package/dist/components/ramme/popover/popover.js.map +1 -0
- package/dist/components/ramme/suggestion/fs-suggestion.d.ts +75 -0
- package/dist/components/ramme/suggestion/fs-suggestion.d.ts.map +1 -0
- package/dist/components/ramme/suggestion/fs-suggestion.js +232 -0
- package/dist/components/ramme/suggestion/fs-suggestion.js.map +1 -0
- package/dist/components/ramme/suggestion/suggestion.d.ts +97 -0
- package/dist/components/ramme/suggestion/suggestion.d.ts.map +1 -0
- package/dist/components/ramme/suggestion/suggestion.js +79 -0
- package/dist/components/ramme/suggestion/suggestion.js.map +1 -0
- package/dist/components/ramme/tabs/fs-tabs.d.ts +31 -36
- package/dist/components/ramme/tabs/fs-tabs.d.ts.map +1 -1
- package/dist/components/ramme/tabs/fs-tabs.js +62 -84
- package/dist/components/ramme/tabs/fs-tabs.js.map +1 -1
- package/dist/components/ramme/tabs/tabs.d.ts +50 -0
- package/dist/components/ramme/tabs/tabs.d.ts.map +1 -0
- package/dist/components/ramme/tabs/tabs.js +54 -0
- package/dist/components/ramme/tabs/tabs.js.map +1 -0
- package/dist/fs.d.ts +94 -0
- package/dist/fs.d.ts.map +1 -1
- package/dist/fs.js +42 -0
- package/dist/fs.js.map +1 -1
- package/dist/index.d.ts +10 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +10 -3
- package/dist/index.js.map +1 -1
- package/dist/jsx/react.d.ts +35 -50
- package/dist/jsx/react.d.ts.map +1 -1
- package/dist/react.d.ts +142 -1
- package/dist/react.d.ts.map +1 -1
- package/dist/react.js +77 -2
- package/dist/react.js.map +1 -1
- package/package.json +15 -18
- package/src/components/frittstaende/connection-status/connection-status.css +33 -0
- package/src/components/frittstaende/connection-status/connection-status.ts +41 -0
- package/src/components/frittstaende/connection-status/fs-connection-status.ts +144 -0
- package/src/components/frittstaende/session-timeout/fs-session-timeout.ts +216 -0
- package/src/components/frittstaende/session-timeout/session-timeout.css +43 -0
- package/src/components/frittstaende/session-timeout/session-timeout.ts +42 -0
- package/src/components/frittstaende/toast/fs-toast.ts +39 -30
- package/src/components/frittstaende/toast/toast.ts +45 -0
- package/src/components/ramme/error-summary/error-summary.ts +55 -0
- package/src/components/ramme/error-summary/fs-error-summary.ts +62 -119
- package/src/components/ramme/field/field-core.ts +38 -0
- package/src/components/ramme/field/fs-field.ts +145 -61
- package/src/components/ramme/popover/fs-popover.ts +78 -72
- package/src/components/ramme/popover/popover.ts +79 -0
- package/src/components/ramme/suggestion/fs-suggestion.ts +265 -0
- package/src/components/ramme/suggestion/suggestion.ts +105 -0
- package/src/components/ramme/tabs/fs-tabs.ts +67 -98
- package/src/components/ramme/tabs/tabs.ts +104 -0
- package/dist/components/frittstaende/calendar/fs-calendar.d.ts +0 -52
- package/dist/components/frittstaende/calendar/fs-calendar.d.ts.map +0 -1
- package/dist/components/frittstaende/calendar/fs-calendar.js +0 -462
- package/dist/components/frittstaende/calendar/fs-calendar.js.map +0 -1
- package/dist/components/frittstaende/date-field/fs-date-field.d.ts +0 -110
- package/dist/components/frittstaende/date-field/fs-date-field.d.ts.map +0 -1
- package/dist/components/frittstaende/date-field/fs-date-field.js +0 -264
- package/dist/components/frittstaende/date-field/fs-date-field.js.map +0 -1
- package/dist/components/frittstaende/suggestion/fs-suggestion.d.ts +0 -133
- package/dist/components/frittstaende/suggestion/fs-suggestion.d.ts.map +0 -1
- package/dist/components/frittstaende/suggestion/fs-suggestion.js +0 -280
- package/dist/components/frittstaende/suggestion/fs-suggestion.js.map +0 -1
- package/src/components/frittstaende/calendar/fs-calendar.ts +0 -525
- package/src/components/frittstaende/date-field/date-field.css +0 -54
- package/src/components/frittstaende/date-field/fs-date-field.ts +0 -309
- package/src/components/frittstaende/suggestion/fs-suggestion.ts +0 -333
- /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
|
-
}
|
|
File without changes
|