@fristil/designsystem 0.3.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 +121 -0
  2. package/README.md +22 -9
  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 +16 -25
  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,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 ikke er å plassere panelet. `position-anchor` finnes
17
- * ennå ikke i alle nettlesere, så posisjonen regnes ut her, mot knappens
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
- * Du skriver knappen og innholdet selv, og komponenten kobler dem sammen:
22
+ * Serveren skriver koblingen med `fs.popover()`: `aria-controls` 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">Handlinger</button>
25
- * <ul class="fs-list" data-variant="plain">…</ul>
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 LitElement {
30
- static properties = {
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
- createRenderRoot() {
44
- return this
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
- render() {
48
- return html`<slot @slotchange=${this.handleSlotChange}></slot>`
47
+ set open(value: boolean) {
48
+ if (value) this.setAttribute("open", "")
49
+ else this.removeAttribute("open")
49
50
  }
50
51
 
51
- connectedCallback() {
52
- super.connectedCallback()
53
- window.addEventListener("resize", this.reposition)
54
- window.addEventListener("scroll", this.reposition, true)
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
- disconnectedCallback() {
58
- window.removeEventListener("resize", this.reposition)
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
- firstUpdated() {
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
- updated() {
68
- this.sync()
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
- private handleSlotChange = () => {
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 = trigger
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
- if (!panel.id) {
90
- panel.id = `fs-popover-${Math.random().toString(36).slice(2, 9)}`
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
- /** Åpner panelet. */
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: true },
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.dispatchEvent(
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
+ }