@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,11 +1,14 @@
|
|
|
1
|
-
import { html, LitElement } from "lit"
|
|
2
|
-
|
|
3
1
|
export const FS_POPOVER_TAG = "fs-popover" as const
|
|
4
2
|
|
|
5
|
-
export const POPOVER_CLASS = "fs-popover" as const
|
|
6
|
-
|
|
7
3
|
type Placement = "bottom-start" | "bottom-end" | "top-start" | "top-end"
|
|
8
4
|
|
|
5
|
+
const PLACEMENTS: readonly Placement[] = [
|
|
6
|
+
"bottom-start",
|
|
7
|
+
"bottom-end",
|
|
8
|
+
"top-start",
|
|
9
|
+
"top-end",
|
|
10
|
+
]
|
|
11
|
+
|
|
9
12
|
/**
|
|
10
13
|
* Et panel som henger under en knapp, og som lukker seg selv.
|
|
11
14
|
*
|
|
@@ -13,94 +16,99 @@ type Placement = "bottom-start" | "bottom-end" | "top-start" | "top-end"
|
|
|
13
16
|
* løser tre ting som ellers krever kode: panelet legger seg over alt annet
|
|
14
17
|
* uten `z-index`, Escape lukker det, og et klikk utenfor lukker det.
|
|
15
18
|
*
|
|
16
|
-
* Det topplaget gjør
|
|
17
|
-
* ennå ikke i alle nettlesere, så posisjonen regnes ut her
|
|
18
|
-
* plass på skjermen.
|
|
19
|
+
* Det topplaget ikke gjør er å plassere panelet. `position-anchor` finnes
|
|
20
|
+
* ennå ikke i alle nettlesere, så posisjonen regnes ut her.
|
|
19
21
|
*
|
|
20
|
-
*
|
|
22
|
+
* Serveren skriver koblingen med `fs.popover()`: `aria-controls` på knappen,
|
|
23
|
+
* klassen og `popover` på panelet. Komponenten setter bare `aria-expanded`,
|
|
24
|
+
* som endrer seg når brukeren klikker, og posisjonen. Begge står i
|
|
25
|
+
* `data-preserve-attr` fra byggeren, ellers river morfingen dem bort.
|
|
21
26
|
*
|
|
22
27
|
* ```html
|
|
23
28
|
* <fs-popover placement="bottom-end">
|
|
24
|
-
* <button slot="trigger" class="fs-button"
|
|
25
|
-
*
|
|
29
|
+
* <button slot="trigger" class="fs-button" aria-controls="meny" aria-expanded="false"
|
|
30
|
+
* data-preserve-attr="aria-expanded">Handlinger</button>
|
|
31
|
+
* <ul id="meny" class="fs-popover" popover="manual" data-preserve-attr="style">…</ul>
|
|
26
32
|
* </fs-popover>
|
|
27
33
|
* ```
|
|
28
34
|
*/
|
|
29
|
-
export class FsPopover extends
|
|
30
|
-
static
|
|
31
|
-
open: { type: Boolean, reflect: true },
|
|
32
|
-
placement: { type: String },
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
/** Om panelet er åpent. Speiles, så CSS kan treffe tilstanden. */
|
|
36
|
-
open = false
|
|
37
|
-
/** Hvilken kant panelet henger fra. Standard: `bottom-start`. */
|
|
38
|
-
placement: Placement = "bottom-start"
|
|
35
|
+
export class FsPopover extends HTMLElement {
|
|
36
|
+
static observedAttributes = ["open", "placement"]
|
|
39
37
|
|
|
40
38
|
private panel?: HTMLElement
|
|
41
39
|
private triggerElement?: HTMLElement
|
|
40
|
+
private observer?: MutationObserver
|
|
42
41
|
|
|
43
|
-
|
|
44
|
-
|
|
42
|
+
/** Om panelet er åpent. Speiles, så CSS kan treffe tilstanden. */
|
|
43
|
+
get open(): boolean {
|
|
44
|
+
return this.hasAttribute("open")
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
-
|
|
48
|
-
|
|
47
|
+
set open(value: boolean) {
|
|
48
|
+
if (value) this.setAttribute("open", "")
|
|
49
|
+
else this.removeAttribute("open")
|
|
49
50
|
}
|
|
50
51
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
52
|
+
/** Hvilken kant panelet henger fra. Standard: `bottom-start`. */
|
|
53
|
+
get placement(): Placement {
|
|
54
|
+
const value = this.getAttribute("placement") as Placement | null
|
|
55
|
+
return value && PLACEMENTS.includes(value) ? value : "bottom-start"
|
|
55
56
|
}
|
|
56
57
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
window.removeEventListener("scroll", this.reposition, true)
|
|
60
|
-
super.disconnectedCallback()
|
|
58
|
+
set placement(value: Placement) {
|
|
59
|
+
this.setAttribute("placement", value)
|
|
61
60
|
}
|
|
62
61
|
|
|
63
|
-
|
|
62
|
+
connectedCallback(): void {
|
|
63
|
+
window.addEventListener("resize", this.reposition)
|
|
64
|
+
window.addEventListener("scroll", this.reposition, true)
|
|
65
|
+
this.observer = new MutationObserver(() => this.sync())
|
|
66
|
+
this.observer.observe(this, { childList: true })
|
|
64
67
|
this.sync()
|
|
65
68
|
}
|
|
66
69
|
|
|
67
|
-
|
|
68
|
-
this.
|
|
70
|
+
disconnectedCallback(): void {
|
|
71
|
+
window.removeEventListener("resize", this.reposition)
|
|
72
|
+
window.removeEventListener("scroll", this.reposition, true)
|
|
73
|
+
document.removeEventListener("click", this.handleOutsideClick, true)
|
|
74
|
+
document.removeEventListener("keydown", this.handleKeydown)
|
|
75
|
+
this.observer?.disconnect()
|
|
76
|
+
this.observer = undefined
|
|
77
|
+
this.triggerElement?.removeEventListener("click", this.handleTriggerClick)
|
|
78
|
+
// Referansen må nullstilles, ellers ser `sync()` at knappen er den samme
|
|
79
|
+
// når elementet settes inn igjen, og hopper over å feste lytteren på
|
|
80
|
+
// nytt. Da lar panelet seg ikke åpne lenger.
|
|
81
|
+
this.triggerElement = undefined
|
|
82
|
+
this.panel = undefined
|
|
69
83
|
}
|
|
70
84
|
|
|
71
|
-
|
|
72
|
-
this.sync()
|
|
85
|
+
attributeChangedCallback(): void {
|
|
86
|
+
if (this.isConnected) this.sync()
|
|
73
87
|
}
|
|
74
88
|
|
|
75
|
-
private sync() {
|
|
89
|
+
private sync(): void {
|
|
76
90
|
const trigger = this.querySelector<HTMLElement>("[slot='trigger']")
|
|
77
91
|
const panel = [...this.children].find(
|
|
78
92
|
(child): child is HTMLElement =>
|
|
79
93
|
child instanceof HTMLElement &&
|
|
80
|
-
child.getAttribute("slot") !== "trigger"
|
|
81
|
-
child.tagName !== "SLOT",
|
|
94
|
+
child.getAttribute("slot") !== "trigger",
|
|
82
95
|
)
|
|
83
96
|
|
|
84
97
|
if (!trigger || !panel) return
|
|
85
98
|
|
|
86
|
-
this.triggerElement
|
|
99
|
+
if (this.triggerElement !== trigger) {
|
|
100
|
+
this.triggerElement?.removeEventListener("click", this.handleTriggerClick)
|
|
101
|
+
trigger.addEventListener("click", this.handleTriggerClick)
|
|
102
|
+
this.triggerElement = trigger
|
|
103
|
+
}
|
|
87
104
|
this.panel = panel
|
|
88
105
|
|
|
89
|
-
|
|
90
|
-
|
|
106
|
+
// Eneste attributtet komponenten eier. Resten skrev serveren.
|
|
107
|
+
const expanded = String(this.open)
|
|
108
|
+
if (trigger.getAttribute("aria-expanded") !== expanded) {
|
|
109
|
+
trigger.setAttribute("aria-expanded", expanded)
|
|
91
110
|
}
|
|
92
111
|
|
|
93
|
-
panel.classList.add(POPOVER_CLASS)
|
|
94
|
-
// `manual` og ikke `auto`: vi lukker selv, slik at knappen kan brukes til
|
|
95
|
-
// å lukke igjen uten at nettleseren rekker å lukke først.
|
|
96
|
-
panel.setAttribute("popover", "manual")
|
|
97
|
-
|
|
98
|
-
trigger.setAttribute("aria-expanded", String(this.open))
|
|
99
|
-
trigger.setAttribute("aria-controls", panel.id)
|
|
100
|
-
|
|
101
|
-
trigger.removeEventListener("click", this.handleTriggerClick)
|
|
102
|
-
trigger.addEventListener("click", this.handleTriggerClick)
|
|
103
|
-
|
|
104
112
|
if (this.open) {
|
|
105
113
|
if (!panel.matches(":popover-open")) panel.showPopover()
|
|
106
114
|
this.reposition()
|
|
@@ -113,17 +121,17 @@ export class FsPopover extends LitElement {
|
|
|
113
121
|
}
|
|
114
122
|
}
|
|
115
123
|
|
|
116
|
-
private handleTriggerClick = () => {
|
|
124
|
+
private handleTriggerClick = (): void => {
|
|
117
125
|
this.toggle()
|
|
118
126
|
}
|
|
119
127
|
|
|
120
|
-
private handleOutsideClick = (event: Event) => {
|
|
128
|
+
private handleOutsideClick = (event: Event): void => {
|
|
121
129
|
const target = event.target as Node
|
|
122
130
|
if (this.contains(target) || this.panel?.contains(target)) return
|
|
123
131
|
this.hide()
|
|
124
132
|
}
|
|
125
133
|
|
|
126
|
-
private handleKeydown = (event: KeyboardEvent) => {
|
|
134
|
+
private handleKeydown = (event: KeyboardEvent): void => {
|
|
127
135
|
if (event.key !== "Escape") return
|
|
128
136
|
this.hide()
|
|
129
137
|
// Fokus tilbake til knappen. Uten dette står fokus på et panel som ikke
|
|
@@ -132,7 +140,7 @@ export class FsPopover extends LitElement {
|
|
|
132
140
|
}
|
|
133
141
|
|
|
134
142
|
/** Regner ut hvor panelet skal stå, mot knappens plass på skjermen. */
|
|
135
|
-
private reposition = () => {
|
|
143
|
+
private reposition = (): void => {
|
|
136
144
|
if (!this.open || !this.panel || !this.triggerElement) return
|
|
137
145
|
|
|
138
146
|
const anchor = this.triggerElement.getBoundingClientRect()
|
|
@@ -159,34 +167,32 @@ export class FsPopover extends LitElement {
|
|
|
159
167
|
this.panel.style.setProperty("--fs-popover-left", `${Math.round(left)}px`)
|
|
160
168
|
}
|
|
161
169
|
|
|
162
|
-
|
|
163
|
-
show() {
|
|
164
|
-
if (this.open) return
|
|
165
|
-
this.open = true
|
|
170
|
+
private emit(open: boolean): void {
|
|
166
171
|
this.dispatchEvent(
|
|
167
172
|
new CustomEvent("popover-toggle", {
|
|
168
|
-
detail: { open
|
|
173
|
+
detail: { open },
|
|
169
174
|
bubbles: true,
|
|
170
175
|
composed: true,
|
|
171
176
|
}),
|
|
172
177
|
)
|
|
173
178
|
}
|
|
174
179
|
|
|
180
|
+
/** Åpner panelet. */
|
|
181
|
+
show(): void {
|
|
182
|
+
if (this.open) return
|
|
183
|
+
this.open = true
|
|
184
|
+
this.emit(true)
|
|
185
|
+
}
|
|
186
|
+
|
|
175
187
|
/** Lukker panelet. */
|
|
176
|
-
hide() {
|
|
188
|
+
hide(): void {
|
|
177
189
|
if (!this.open) return
|
|
178
190
|
this.open = false
|
|
179
|
-
this.
|
|
180
|
-
new CustomEvent("popover-toggle", {
|
|
181
|
-
detail: { open: false },
|
|
182
|
-
bubbles: true,
|
|
183
|
-
composed: true,
|
|
184
|
-
}),
|
|
185
|
-
)
|
|
191
|
+
this.emit(false)
|
|
186
192
|
}
|
|
187
193
|
|
|
188
194
|
/** Åpner eller lukker. */
|
|
189
|
-
toggle() {
|
|
195
|
+
toggle(): void {
|
|
190
196
|
if (this.open) this.hide()
|
|
191
197
|
else this.show()
|
|
192
198
|
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { attributes } from "../../css/shared.js"
|
|
2
|
+
|
|
3
|
+
export const POPOVER_CLASS = "fs-popover" as const
|
|
4
|
+
|
|
5
|
+
export type PopoverOptions = {
|
|
6
|
+
/** Id på panelet. Kobler knappen til panelet med `aria-controls`. */
|
|
7
|
+
id: string
|
|
8
|
+
/** Panelet er åpent. Standard: lukket. */
|
|
9
|
+
open?: boolean
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export type PopoverAttributes = {
|
|
13
|
+
host: {
|
|
14
|
+
open?: ""
|
|
15
|
+
"data-preserve-attr": "open"
|
|
16
|
+
}
|
|
17
|
+
trigger: {
|
|
18
|
+
slot: "trigger"
|
|
19
|
+
"aria-expanded": "true" | "false"
|
|
20
|
+
"aria-controls": string
|
|
21
|
+
"data-preserve-attr": "aria-expanded"
|
|
22
|
+
}
|
|
23
|
+
panel: {
|
|
24
|
+
class: typeof POPOVER_CLASS
|
|
25
|
+
id: string
|
|
26
|
+
popover: "manual"
|
|
27
|
+
"data-preserve-attr": "style"
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Attributtene for et sprettoppvindu.
|
|
33
|
+
*
|
|
34
|
+
* Serveren skriver både knappen og panelet med koblingen mellom dem.
|
|
35
|
+
* `<fs-popover>` regner ut posisjonen og lukker ved klikk utenfor.
|
|
36
|
+
*
|
|
37
|
+
* ```ts
|
|
38
|
+
* const boks = fs.popover({ id: "hjelp" })
|
|
39
|
+
* ```
|
|
40
|
+
* ```html
|
|
41
|
+
* <fs-popover>
|
|
42
|
+
* <button {...boks.trigger}>Hva betyr dette?</button>
|
|
43
|
+
* <div {...boks.panel}>Vi bruker fødselsdatoen til å finne riktig sats.</div>
|
|
44
|
+
* </fs-popover>
|
|
45
|
+
* ```
|
|
46
|
+
*/
|
|
47
|
+
export const popover = ({
|
|
48
|
+
id,
|
|
49
|
+
open = false,
|
|
50
|
+
}: PopoverOptions): PopoverAttributes => ({
|
|
51
|
+
// Om panelet er åpent står som `open` på verten, og det er brukerens
|
|
52
|
+
// tilstand, ikke serverens. Uten dette lukker morfingen panelet i det
|
|
53
|
+
// serveren patcher området rundt.
|
|
54
|
+
host: attributes({
|
|
55
|
+
// `open` må stå på verten, ikke bare i knappens aria-expanded. Uten det
|
|
56
|
+
// sa markupen at panelet var åpent mens komponenten mente det var lukket.
|
|
57
|
+
open: open ? ("" as const) : undefined,
|
|
58
|
+
"data-preserve-attr": "open" as const,
|
|
59
|
+
}),
|
|
60
|
+
trigger: attributes({
|
|
61
|
+
slot: "trigger" as const,
|
|
62
|
+
"aria-expanded": (open ? "true" : "false") as "true" | "false",
|
|
63
|
+
"aria-controls": id,
|
|
64
|
+
// Komponenten endrer aria-expanded når panelet åpnes. Uten dette fjerner
|
|
65
|
+
// Datastars morfing det igjen, siden serverens utgave sier noe annet.
|
|
66
|
+
"data-preserve-attr": "aria-expanded" as const,
|
|
67
|
+
}),
|
|
68
|
+
panel: attributes({
|
|
69
|
+
class: POPOVER_CLASS,
|
|
70
|
+
id,
|
|
71
|
+
// `manual` og ikke `auto`: komponenten lukker selv, slik at knappen kan
|
|
72
|
+
// brukes til å lukke igjen uten at nettleseren rekker å lukke først.
|
|
73
|
+
popover: "manual" as const,
|
|
74
|
+
// Posisjonen regnes ut mot knappens plass på skjermen og settes som
|
|
75
|
+
// `--fs-popover-top` og `--fs-popover-left` i style-attributtet. Uten
|
|
76
|
+
// dette river morfingen posisjonen bort, og panelet hopper til hjørnet.
|
|
77
|
+
"data-preserve-attr": "style" as const,
|
|
78
|
+
}),
|
|
79
|
+
})
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import {
|
|
2
|
+
SUGGESTION_EMPTY_CLASS,
|
|
3
|
+
SUGGESTION_OPTION_CLASS,
|
|
4
|
+
} from "./suggestion.js"
|
|
5
|
+
|
|
6
|
+
export const FS_SUGGESTION_TAG = "fs-suggestion" as const
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Tastaturet og filtreringen i et felt med forslagsliste.
|
|
10
|
+
*
|
|
11
|
+
* Serveren skriver feltet og lista med `fs.suggestion()`. Komponenten rendrer
|
|
12
|
+
* ingenting. Den lagde tidligere hele feltet selv, og da fantes det verken
|
|
13
|
+
* ledetekst eller inndatafelt før skriptet hadde kjørt, og ingenting ble med
|
|
14
|
+
* i innsendingen.
|
|
15
|
+
*
|
|
16
|
+
* Det komponenten gjør er det nettleseren ikke gjør: filtrerer alternativene
|
|
17
|
+
* mens brukeren skriver, flytter markeringen med piltastene, og holder
|
|
18
|
+
* `aria-activedescendant` i synk slik at skjermleseren leser opp alternativet
|
|
19
|
+
* uten at fokus forlater feltet.
|
|
20
|
+
*
|
|
21
|
+
* Patcher serveren lista selv, som i en Datastar-app, slår filtreringen seg
|
|
22
|
+
* av: da er det serveren som bestemmer hva som vises.
|
|
23
|
+
*
|
|
24
|
+
* ```html
|
|
25
|
+
* <fs-suggestion>
|
|
26
|
+
* <label class="fs-label" for="kommune">Kommune</label>
|
|
27
|
+
* <div class="fs-suggestion__field">
|
|
28
|
+
* <input class="fs-input" id="kommune" role="combobox" aria-controls="kommune-list"
|
|
29
|
+
* aria-expanded="false" data-preserve-attr="aria-expanded aria-activedescendant">
|
|
30
|
+
* <ul class="fs-suggestion__list" id="kommune-list" role="listbox" hidden
|
|
31
|
+
* data-preserve-attr="hidden">
|
|
32
|
+
* <li class="fs-suggestion__option" id="kommune-option-0" role="option">Bergen</li>
|
|
33
|
+
* </ul>
|
|
34
|
+
* </div>
|
|
35
|
+
* </fs-suggestion>
|
|
36
|
+
* ```
|
|
37
|
+
*/
|
|
38
|
+
export class FsSuggestion extends HTMLElement {
|
|
39
|
+
static observedAttributes = ["server-filtered"]
|
|
40
|
+
|
|
41
|
+
private activeIndex = -1
|
|
42
|
+
private observer?: MutationObserver
|
|
43
|
+
private control?: HTMLInputElement
|
|
44
|
+
/** Alternativene som alt har fått lytteren sin. */
|
|
45
|
+
private readonly bundne = new WeakSet<HTMLElement>()
|
|
46
|
+
/** Sant mens komponenten selv sender hendelser, så den ikke svarer seg selv. */
|
|
47
|
+
private choosing = false
|
|
48
|
+
|
|
49
|
+
connectedCallback(): void {
|
|
50
|
+
this.observer = new MutationObserver(() => this.bind())
|
|
51
|
+
this.observer.observe(this, { childList: true, subtree: true })
|
|
52
|
+
this.bind()
|
|
53
|
+
document.addEventListener("click", this.handleOutsideClick, true)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
disconnectedCallback(): void {
|
|
57
|
+
document.removeEventListener("click", this.handleOutsideClick, true)
|
|
58
|
+
this.observer?.disconnect()
|
|
59
|
+
this.observer = undefined
|
|
60
|
+
this.unbind()
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Slår av filtreringen på klienten. Da er det serveren som bestemmer. */
|
|
64
|
+
get serverFiltered(): boolean {
|
|
65
|
+
return this.hasAttribute("server-filtered")
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
private get listElement(): HTMLElement | null {
|
|
69
|
+
return this.querySelector<HTMLElement>("[role='listbox']")
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
private get options(): HTMLElement[] {
|
|
73
|
+
return [
|
|
74
|
+
...this.querySelectorAll<HTMLElement>(`.${SUGGESTION_OPTION_CLASS}`),
|
|
75
|
+
]
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Alternativene som er synlige nå. */
|
|
79
|
+
private get visible(): HTMLElement[] {
|
|
80
|
+
return this.options.filter((option) => !option.hidden)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
private unbind(): void {
|
|
84
|
+
const control = this.control
|
|
85
|
+
if (!control) return
|
|
86
|
+
control.removeEventListener("input", this.handleInput)
|
|
87
|
+
control.removeEventListener("keydown", this.handleKeydown)
|
|
88
|
+
control.removeEventListener("focus", this.handleFocus)
|
|
89
|
+
this.control = undefined
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
private bind(): void {
|
|
93
|
+
const control = this.querySelector<HTMLInputElement>("[role='combobox']")
|
|
94
|
+
if (!control) return
|
|
95
|
+
|
|
96
|
+
if (control !== this.control) {
|
|
97
|
+
this.unbind()
|
|
98
|
+
control.addEventListener("input", this.handleInput)
|
|
99
|
+
control.addEventListener("keydown", this.handleKeydown)
|
|
100
|
+
control.addEventListener("focus", this.handleFocus)
|
|
101
|
+
this.control = control
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Alternativene byttes ut uavhengig av feltet: i en Datastar-app sender
|
|
105
|
+
// serveren en ny liste mens brukeren skriver. Lå dette bak sjekken over,
|
|
106
|
+
// fikk de nye alternativene aldri lytteren sin, og valg med mus sluttet
|
|
107
|
+
// å virke etter første oppdatering.
|
|
108
|
+
for (const option of this.options) {
|
|
109
|
+
if (this.bundne.has(option)) continue
|
|
110
|
+
option.addEventListener("mousedown", this.handleOptionMouseDown)
|
|
111
|
+
this.bundne.add(option)
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
private setOpen(open: boolean): void {
|
|
116
|
+
const list = this.listElement
|
|
117
|
+
if (!list || !this.control) return
|
|
118
|
+
|
|
119
|
+
list.hidden = !open
|
|
120
|
+
this.control.setAttribute("aria-expanded", String(open))
|
|
121
|
+
|
|
122
|
+
if (!open) {
|
|
123
|
+
this.activeIndex = -1
|
|
124
|
+
this.control.removeAttribute("aria-activedescendant")
|
|
125
|
+
for (const option of this.options) {
|
|
126
|
+
option.setAttribute("aria-selected", "false")
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
private filter(): void {
|
|
132
|
+
if (this.serverFiltered) return
|
|
133
|
+
|
|
134
|
+
const query = (this.control?.value ?? "").trim().toLowerCase()
|
|
135
|
+
for (const option of this.options) {
|
|
136
|
+
const label = (option.textContent ?? "").trim().toLowerCase()
|
|
137
|
+
option.hidden = query !== "" && !label.includes(query)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const treff = this.visible.length
|
|
141
|
+
const empty = this.querySelector<HTMLElement>(`.${SUGGESTION_EMPTY_CLASS}`)
|
|
142
|
+
if (empty) empty.hidden = treff > 0
|
|
143
|
+
|
|
144
|
+
this.announce(treff)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Melder antall treff.
|
|
149
|
+
*
|
|
150
|
+
* Uten dette får den som ikke ser skjermen ingen beskjed om at lista
|
|
151
|
+
* snevret seg inn mens hun skrev. Teksten er klientgenerert, så elementet
|
|
152
|
+
* har `data-ignore-morph` fra byggeren.
|
|
153
|
+
*/
|
|
154
|
+
private announce(treff: number): void {
|
|
155
|
+
const status = this.querySelector<HTMLElement>("[role='status']")
|
|
156
|
+
if (!status) return
|
|
157
|
+
|
|
158
|
+
const tekst =
|
|
159
|
+
treff === 0 ? "Ingen treff" : treff === 1 ? "Ett treff" : `${treff} treff`
|
|
160
|
+
|
|
161
|
+
if (status.textContent !== tekst) status.textContent = tekst
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
private markActive(index: number): void {
|
|
165
|
+
const synlige = this.visible
|
|
166
|
+
if (synlige.length === 0 || !this.control) return
|
|
167
|
+
|
|
168
|
+
const neste = (index + synlige.length) % synlige.length
|
|
169
|
+
this.activeIndex = neste
|
|
170
|
+
|
|
171
|
+
for (const option of this.options) {
|
|
172
|
+
option.setAttribute("aria-selected", "false")
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const aktiv = synlige[neste]
|
|
176
|
+
aktiv.setAttribute("aria-selected", "true")
|
|
177
|
+
// aria-activedescendant flytter skjermleserens lesepunkt uten at fokus
|
|
178
|
+
// forlater feltet. Uten den leses alternativet aldri opp.
|
|
179
|
+
this.control.setAttribute("aria-activedescendant", aktiv.id)
|
|
180
|
+
aktiv.scrollIntoView({ block: "nearest" })
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
private choose(option: HTMLElement): void {
|
|
184
|
+
if (!this.control) return
|
|
185
|
+
|
|
186
|
+
const value =
|
|
187
|
+
option.getAttribute("data-value") ?? (option.textContent ?? "").trim()
|
|
188
|
+
this.control.value = value
|
|
189
|
+
|
|
190
|
+
// Flagget må stå før hendelsene sendes. `input` er den samme hendelsen
|
|
191
|
+
// komponenten selv lytter på, så uten dette åpner lista seg igjen i det
|
|
192
|
+
// øyeblikket brukeren har valgt noe.
|
|
193
|
+
this.choosing = true
|
|
194
|
+
this.control.dispatchEvent(new Event("input", { bubbles: true }))
|
|
195
|
+
this.control.dispatchEvent(new Event("change", { bubbles: true }))
|
|
196
|
+
this.choosing = false
|
|
197
|
+
|
|
198
|
+
this.setOpen(false)
|
|
199
|
+
this.dispatchEvent(
|
|
200
|
+
new CustomEvent("suggestion-select", {
|
|
201
|
+
detail: { value },
|
|
202
|
+
bubbles: true,
|
|
203
|
+
composed: true,
|
|
204
|
+
}),
|
|
205
|
+
)
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
private handleInput = (): void => {
|
|
209
|
+
if (this.choosing) return
|
|
210
|
+
this.filter()
|
|
211
|
+
this.setOpen(true)
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
private handleFocus = (): void => {
|
|
215
|
+
this.filter()
|
|
216
|
+
this.setOpen(true)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
private handleKeydown = (event: KeyboardEvent): void => {
|
|
220
|
+
const åpen = this.listElement?.hidden === false
|
|
221
|
+
|
|
222
|
+
if (event.key === "ArrowDown") {
|
|
223
|
+
event.preventDefault()
|
|
224
|
+
if (!åpen) {
|
|
225
|
+
this.filter()
|
|
226
|
+
this.setOpen(true)
|
|
227
|
+
}
|
|
228
|
+
this.markActive(this.activeIndex + 1)
|
|
229
|
+
} else if (event.key === "ArrowUp") {
|
|
230
|
+
event.preventDefault()
|
|
231
|
+
this.markActive(this.activeIndex - 1)
|
|
232
|
+
} else if (event.key === "Enter" && åpen && this.activeIndex >= 0) {
|
|
233
|
+
event.preventDefault()
|
|
234
|
+
const valgt = this.visible[this.activeIndex]
|
|
235
|
+
if (valgt) this.choose(valgt)
|
|
236
|
+
} else if (event.key === "Escape" && åpen) {
|
|
237
|
+
event.preventDefault()
|
|
238
|
+
this.setOpen(false)
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
private handleOptionMouseDown = (event: Event): void => {
|
|
243
|
+
// mousedown og ikke click: feltet mister ellers fokus før valget rekker
|
|
244
|
+
// å skje, og lista lukker seg først.
|
|
245
|
+
event.preventDefault()
|
|
246
|
+
this.choose(event.currentTarget as HTMLElement)
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
private handleOutsideClick = (event: Event): void => {
|
|
250
|
+
if (this.contains(event.target as Node)) return
|
|
251
|
+
this.setOpen(false)
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
declare global {
|
|
256
|
+
interface HTMLElementTagNameMap {
|
|
257
|
+
"fs-suggestion": FsSuggestion
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export function defineFsSuggestion(tagName = FS_SUGGESTION_TAG): void {
|
|
262
|
+
if (!customElements.get(tagName)) {
|
|
263
|
+
customElements.define(tagName, FsSuggestion)
|
|
264
|
+
}
|
|
265
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { attributes } from "../../css/shared.js"
|
|
2
|
+
import {
|
|
3
|
+
computeFieldAttributes,
|
|
4
|
+
type FieldOptions,
|
|
5
|
+
} from "../field/field-core.js"
|
|
6
|
+
|
|
7
|
+
export const SUGGESTION_CLASS = "fs-suggestion" as const
|
|
8
|
+
export const SUGGESTION_FIELD_CLASS = "fs-suggestion__field" as const
|
|
9
|
+
export const SUGGESTION_LIST_CLASS = "fs-suggestion__list" as const
|
|
10
|
+
export const SUGGESTION_OPTION_CLASS = "fs-suggestion__option" as const
|
|
11
|
+
export const SUGGESTION_EMPTY_CLASS = "fs-suggestion__empty" as const
|
|
12
|
+
export const SUGGESTION_STATUS_CLASS = "fs-sr-only" as const
|
|
13
|
+
|
|
14
|
+
export type SuggestionOptions = Omit<FieldOptions, "id"> & {
|
|
15
|
+
/** Felles id-stamme. Kontrollen blir `${id}`, lista `${id}-list`. */
|
|
16
|
+
id: string
|
|
17
|
+
/** Antall alternativer serveren sender med. Standard: 0. */
|
|
18
|
+
count?: number
|
|
19
|
+
/** Alternativet som er markert med piltastene. Standard: ingen. */
|
|
20
|
+
activeIndex?: number
|
|
21
|
+
/** Lista er åpen. Standard: lukket. */
|
|
22
|
+
open?: boolean
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Attributtene for et felt med forslagsliste.
|
|
27
|
+
*
|
|
28
|
+
* Serveren skriver både feltet og lista. Komponenten filtrerer, tar
|
|
29
|
+
* piltastene og holder `aria-activedescendant` i synk. Den rendrer ingenting:
|
|
30
|
+
* tidligere lagde den hele feltet selv, og da fantes det ikke noe å fylle ut
|
|
31
|
+
* før skriptet hadde kjørt.
|
|
32
|
+
*
|
|
33
|
+
* Attributtene som endrer seg mens brukeren skriver står i
|
|
34
|
+
* `data-preserve-attr`, ellers river Datastars morfing dem bort.
|
|
35
|
+
*/
|
|
36
|
+
export const suggestion = ({
|
|
37
|
+
id,
|
|
38
|
+
count = 0,
|
|
39
|
+
activeIndex = -1,
|
|
40
|
+
open = false,
|
|
41
|
+
...field
|
|
42
|
+
}: SuggestionOptions) => {
|
|
43
|
+
const computed = computeFieldAttributes({ ...field, id })
|
|
44
|
+
const listId = `${id}-list`
|
|
45
|
+
const statusId = `${id}-status`
|
|
46
|
+
const optionId = (index: number) => `${id}-option-${index}`
|
|
47
|
+
const aktiv = open && activeIndex >= 0 && activeIndex < count
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
label: computed.label,
|
|
51
|
+
field: { class: SUGGESTION_FIELD_CLASS },
|
|
52
|
+
control: attributes({
|
|
53
|
+
...computed.control,
|
|
54
|
+
class: "fs-input" as const,
|
|
55
|
+
type: "text" as const,
|
|
56
|
+
role: "combobox" as const,
|
|
57
|
+
autocomplete: "off" as const,
|
|
58
|
+
"aria-expanded": (open ? "true" : "false") as "true" | "false",
|
|
59
|
+
"aria-controls": listId,
|
|
60
|
+
"aria-describedby": [computed.control["aria-describedby"], statusId]
|
|
61
|
+
.filter(Boolean)
|
|
62
|
+
.join(" "),
|
|
63
|
+
"aria-autocomplete": "list" as const,
|
|
64
|
+
"aria-activedescendant": aktiv ? optionId(activeIndex) : undefined,
|
|
65
|
+
"data-preserve-attr": "aria-expanded aria-activedescendant" as const,
|
|
66
|
+
}),
|
|
67
|
+
list: attributes({
|
|
68
|
+
class: SUGGESTION_LIST_CLASS,
|
|
69
|
+
id: listId,
|
|
70
|
+
role: "listbox" as const,
|
|
71
|
+
hidden: open ? undefined : (true as const),
|
|
72
|
+
"data-preserve-attr": "hidden" as const,
|
|
73
|
+
}),
|
|
74
|
+
options: Array.from({ length: count }, (_, index) =>
|
|
75
|
+
attributes({
|
|
76
|
+
class: SUGGESTION_OPTION_CLASS,
|
|
77
|
+
id: optionId(index),
|
|
78
|
+
role: "option" as const,
|
|
79
|
+
"aria-selected": (index === activeIndex ? "true" : "false") as
|
|
80
|
+
| "true"
|
|
81
|
+
| "false",
|
|
82
|
+
"data-preserve-attr": "aria-selected hidden" as const,
|
|
83
|
+
}),
|
|
84
|
+
),
|
|
85
|
+
empty: { class: SUGGESTION_EMPTY_CLASS },
|
|
86
|
+
/**
|
|
87
|
+
* Området som melder antall treff til skjermlesere.
|
|
88
|
+
*
|
|
89
|
+
* Teksten skrives av komponenten mens brukeren taster, så den finnes ikke
|
|
90
|
+
* i serverens utgave. `data-ignore-morph` hindrer at neste patch tømmer
|
|
91
|
+
* den. Elementet må likevel stå i markupen serveren sender, ellers finnes
|
|
92
|
+
* det ikke noe å melde i før skriptet har kjørt.
|
|
93
|
+
*/
|
|
94
|
+
status: {
|
|
95
|
+
class: SUGGESTION_STATUS_CLASS,
|
|
96
|
+
id: statusId,
|
|
97
|
+
role: "status" as const,
|
|
98
|
+
"aria-live": "polite" as const,
|
|
99
|
+
"data-ignore-morph": "" as const,
|
|
100
|
+
},
|
|
101
|
+
help: computed.help,
|
|
102
|
+
error: computed.error,
|
|
103
|
+
state: computed.state,
|
|
104
|
+
}
|
|
105
|
+
}
|