@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
@@ -0,0 +1,144 @@
1
+ import {
2
+ CONNECTION_STATUS_BAR_CLASS,
3
+ CONNECTION_STATUS_CLASS,
4
+ } from "./connection-status.js"
5
+
6
+ export const FS_CONNECTION_STATUS_TAG = "fs-connection-status" as const
7
+
8
+ const STANDARD_OFFLINE = "Ingen forbindelse. Det du skriver blir ikke lagret."
9
+ const STANDARD_ONLINE = "Forbindelsen er tilbake."
10
+
11
+ /** Hvor lenge «tilbake på nett» blir stående. */
12
+ const KVITTERING_MS = 4000
13
+
14
+ /**
15
+ * Sier fra når forbindelsen til serveren er borte.
16
+ *
17
+ * Serveren kan per definisjon ikke fortelle deg dette. Er den utilgjengelig,
18
+ * kommer det ingenting derfra. Derfor er dette en frittstående komponent som
19
+ * eier alt innholdet sitt.
20
+ *
21
+ * Den er ikke pynt i en app som henter innhold fra serveren underveis. En
22
+ * side som mister strømmen sin ser helt normal ut, men er frosset, og en
23
+ * bruker som fyller ut et skjema inn i en død forbindelse mister alt.
24
+ *
25
+ * `navigator.onLine` sier bare om maskinen har et nettverk, ikke om serveren
26
+ * svarer. Derfor finnes `reportFailure()` og `reportSuccess()`, som appen
27
+ * kaller når et kall feiler eller lykkes.
28
+ *
29
+ * ```ts
30
+ * const status = document.querySelector("fs-connection-status")
31
+ * try {
32
+ * await fetch("/api/lagre", { method: "POST", body })
33
+ * status.reportSuccess()
34
+ * } catch {
35
+ * status.reportFailure()
36
+ * }
37
+ * ```
38
+ */
39
+ export class FsConnectionStatus extends HTMLElement {
40
+ static observedAttributes = ["offline-text", "online-text"]
41
+
42
+ private bar?: HTMLElement
43
+ private timer?: number
44
+ /** Sant når appen har meldt at et kall feilet, uavhengig av navigator. */
45
+ private failing = false
46
+
47
+ get offlineText(): string {
48
+ return this.getAttribute("offline-text") ?? STANDARD_OFFLINE
49
+ }
50
+
51
+ get onlineText(): string {
52
+ return this.getAttribute("online-text") ?? STANDARD_ONLINE
53
+ }
54
+
55
+ connectedCallback(): void {
56
+ this.classList.add(CONNECTION_STATUS_CLASS)
57
+ window.addEventListener("offline", this.handleOffline)
58
+ window.addEventListener("online", this.handleOnline)
59
+ if (!navigator.onLine) this.showOffline()
60
+ }
61
+
62
+ disconnectedCallback(): void {
63
+ window.removeEventListener("offline", this.handleOffline)
64
+ window.removeEventListener("online", this.handleOnline)
65
+ if (this.timer) window.clearTimeout(this.timer)
66
+ this.timer = undefined
67
+ }
68
+
69
+ private ensureBar(): HTMLElement {
70
+ if (this.bar) return this.bar
71
+
72
+ const bar = document.createElement("div")
73
+ bar.className = CONNECTION_STATUS_BAR_CLASS
74
+ // polite og ikke assertive: beskjeden er viktig, men skal ikke avbryte
75
+ // midt i et ord. Den blir stående til forbindelsen er tilbake.
76
+ bar.setAttribute("role", "status")
77
+ bar.setAttribute("aria-live", "polite")
78
+ this.append(bar)
79
+ this.bar = bar
80
+ return bar
81
+ }
82
+
83
+ private showOffline(): void {
84
+ if (this.timer) window.clearTimeout(this.timer)
85
+ const bar = this.ensureBar()
86
+ bar.dataset.state = "offline"
87
+ bar.textContent = this.offlineText
88
+ this.emit("connection-lost")
89
+ }
90
+
91
+ private showOnline(): void {
92
+ if (!this.bar) return
93
+ const bar = this.bar
94
+ bar.dataset.state = "online"
95
+ bar.textContent = this.onlineText
96
+ this.emit("connection-restored")
97
+
98
+ this.timer = window.setTimeout(() => {
99
+ bar.remove()
100
+ this.bar = undefined
101
+ }, KVITTERING_MS)
102
+ }
103
+
104
+ private handleOffline = (): void => {
105
+ this.showOffline()
106
+ }
107
+
108
+ private handleOnline = (): void => {
109
+ if (this.failing) return
110
+ this.showOnline()
111
+ }
112
+
113
+ private emit(navn: string): void {
114
+ this.dispatchEvent(new CustomEvent(navn, { bubbles: true, composed: true }))
115
+ }
116
+
117
+ /** Meld fra at et kall til serveren feilet. */
118
+ reportFailure(): void {
119
+ if (this.failing) return
120
+ this.failing = true
121
+ this.showOffline()
122
+ }
123
+
124
+ /** Meld fra at et kall til serveren lyktes igjen. */
125
+ reportSuccess(): void {
126
+ if (!this.failing) return
127
+ this.failing = false
128
+ this.showOnline()
129
+ }
130
+ }
131
+
132
+ declare global {
133
+ interface HTMLElementTagNameMap {
134
+ "fs-connection-status": FsConnectionStatus
135
+ }
136
+ }
137
+
138
+ export function defineFsConnectionStatus(
139
+ tagName = FS_CONNECTION_STATUS_TAG,
140
+ ): void {
141
+ if (!customElements.get(tagName)) {
142
+ customElements.define(tagName, FsConnectionStatus)
143
+ }
144
+ }
@@ -0,0 +1,212 @@
1
+ import {
2
+ SESSION_TIMEOUT_CLASS,
3
+ SESSION_TIMEOUT_DIALOG_CLASS,
4
+ } from "./session-timeout.js"
5
+
6
+ export const FS_SESSION_TIMEOUT_TAG = "fs-session-timeout" as const
7
+
8
+ /** Sekundene som skal leses opp. Hvert sekund ville vært uutholdelig. */
9
+ const ANNONSER_VED = new Set([120, 60, 30, 10])
10
+
11
+ function klokke(sekunder: number): string {
12
+ const m = Math.floor(sekunder / 60)
13
+ const s = sekunder % 60
14
+ return `${m}:${String(s).padStart(2, "0")}`
15
+ }
16
+
17
+ function ord(sekunder: number): string {
18
+ if (sekunder >= 60) {
19
+ const m = Math.round(sekunder / 60)
20
+ return m === 1 ? "ett minutt" : `${m} minutter`
21
+ }
22
+ return sekunder === 1 ? "ett sekund" : `${sekunder} sekunder`
23
+ }
24
+
25
+ /**
26
+ * Varsler før en innlogget økt går ut.
27
+ *
28
+ * Dette er tingen hver store organisasjon bygger selv og gjør feil. Enten
29
+ * kommer det ingen advarsel, og brukeren mister et halvutfylt søknadsskjema,
30
+ * eller så stjeler dialogen fokus midt i en setning, eller så leser
31
+ * skjermleseren nedtellingen hvert sekund.
32
+ *
33
+ * Komponenten er frittstående fordi nedtellingen er klientens klokke. Tallet
34
+ * endrer seg ut fra når brukeren sist rørte tastaturet, og det er ingenting
35
+ * en server kan sende. Den bærer heller ingen verdi inn i en innsending.
36
+ *
37
+ * Dialogen er en ekte `<dialog>` med `showModal()`, så fokusfellen, Escape og
38
+ * topplaget kommer fra nettleseren. Fokus går tilbake dit brukeren var.
39
+ *
40
+ * ```html
41
+ * <fs-session-timeout class="fs-session-timeout" warn-at="1500" expires-at="1800"
42
+ * data-ignore-morph></fs-session-timeout>
43
+ * ```
44
+ *
45
+ * ```ts
46
+ * document.querySelector("fs-session-timeout")
47
+ * .addEventListener("session-extend", () => fetch("/forleng", { method: "POST" }))
48
+ * ```
49
+ */
50
+ export class FsSessionTimeout extends HTMLElement {
51
+ static observedAttributes = ["warn-at", "expires-at"]
52
+
53
+ private dialog?: HTMLDialogElement
54
+ private countElement?: HTMLElement
55
+ private liveElement?: HTMLElement
56
+ private ticker?: number
57
+ private lastActivity = Date.now()
58
+ private forrigeFokus: HTMLElement | null = null
59
+
60
+ /** Sekunder uten aktivitet før varselet kommer. */
61
+ get warnAt(): number {
62
+ return Number(this.getAttribute("warn-at")) || 25 * 60
63
+ }
64
+
65
+ /** Sekunder uten aktivitet før økten er ute. */
66
+ get expiresAt(): number {
67
+ return Number(this.getAttribute("expires-at")) || 30 * 60
68
+ }
69
+
70
+ connectedCallback(): void {
71
+ this.classList.add(SESSION_TIMEOUT_CLASS)
72
+ this.build()
73
+
74
+ for (const navn of ["pointerdown", "keydown", "scroll"] as const) {
75
+ document.addEventListener(navn, this.registerActivity, { passive: true })
76
+ }
77
+ this.ticker = window.setInterval(() => this.tick(), 1000)
78
+ }
79
+
80
+ disconnectedCallback(): void {
81
+ for (const navn of ["pointerdown", "keydown", "scroll"] as const) {
82
+ document.removeEventListener(navn, this.registerActivity)
83
+ }
84
+ if (this.ticker) window.clearInterval(this.ticker)
85
+ this.ticker = undefined
86
+ }
87
+
88
+ private build(): void {
89
+ if (this.dialog) return
90
+
91
+ const dialog = document.createElement("dialog")
92
+ dialog.className = SESSION_TIMEOUT_DIALOG_CLASS
93
+ // alertdialog og ikke dialog: dette er en avbrytelse brukeren ikke ba om,
94
+ // og da skal skjermleseren lese hele innholdet når den åpnes.
95
+ dialog.setAttribute("aria-labelledby", "fs-session-timeout-title")
96
+ dialog.setAttribute("role", "alertdialog")
97
+
98
+ const tittel = document.createElement("h2")
99
+ tittel.className = "fs-session-timeout__title"
100
+ tittel.id = "fs-session-timeout-title"
101
+ tittel.textContent = "Du blir snart logget ut"
102
+
103
+ const tekst = document.createElement("p")
104
+ tekst.className = "fs-session-timeout__text"
105
+ tekst.append("Vi logger deg ut om ")
106
+
107
+ const count = document.createElement("span")
108
+ count.className = "fs-session-timeout__count"
109
+ // Tallet oppdateres hvert sekund. Leses det opp hver gang, blir dialogen
110
+ // ubrukelig med skjermleser, så opplesningen skjer i live-området under.
111
+ count.setAttribute("aria-hidden", "true")
112
+ tekst.append(count, " for å beskytte opplysningene dine.")
113
+
114
+ const live = document.createElement("span")
115
+ live.className = "fs-sr-only"
116
+ live.setAttribute("role", "status")
117
+ live.setAttribute("aria-live", "polite")
118
+
119
+ const knapper = document.createElement("div")
120
+ knapper.className = "fs-session-timeout__actions"
121
+
122
+ const fortsett = document.createElement("button")
123
+ fortsett.type = "button"
124
+ fortsett.className = "fs-button"
125
+ fortsett.textContent = "Fortsett å være innlogget"
126
+ fortsett.addEventListener("click", () => this.extend())
127
+
128
+ const loggUt = document.createElement("button")
129
+ loggUt.type = "button"
130
+ loggUt.className = "fs-button"
131
+ loggUt.dataset.variant = "secondary"
132
+ loggUt.textContent = "Logg ut nå"
133
+ loggUt.addEventListener("click", () => {
134
+ this.close()
135
+ this.emit("session-logout")
136
+ })
137
+
138
+ knapper.append(fortsett, loggUt)
139
+ dialog.append(tittel, tekst, live, knapper)
140
+ this.append(dialog)
141
+
142
+ this.dialog = dialog
143
+ this.countElement = count
144
+ this.liveElement = live
145
+ }
146
+
147
+ private registerActivity = (): void => {
148
+ if (this.dialog?.open) return
149
+ this.lastActivity = Date.now()
150
+ }
151
+
152
+ private tick(): void {
153
+ const gått = Math.floor((Date.now() - this.lastActivity) / 1000)
154
+ const igjen = this.expiresAt - gått
155
+
156
+ if (igjen <= 0) {
157
+ this.close()
158
+ this.emit("session-expired")
159
+ this.lastActivity = Date.now()
160
+ return
161
+ }
162
+
163
+ if (gått < this.warnAt) return
164
+
165
+ if (!this.dialog?.open) this.open()
166
+
167
+ if (this.countElement) this.countElement.textContent = klokke(igjen)
168
+ if (this.liveElement && ANNONSER_VED.has(igjen)) {
169
+ this.liveElement.textContent = `Du blir logget ut om ${ord(igjen)}.`
170
+ }
171
+ }
172
+
173
+ private open(): void {
174
+ if (!this.dialog || this.dialog.open) return
175
+ // Fokus skal tilbake dit brukeren var. Uten dette starter neste
176
+ // tastetrykk på toppen av siden, midt i et skjema.
177
+ this.forrigeFokus = document.activeElement as HTMLElement | null
178
+ this.dialog.showModal()
179
+ this.emit("session-warn")
180
+ }
181
+
182
+ private close(): void {
183
+ if (!this.dialog?.open) return
184
+ this.dialog.close()
185
+ if (this.liveElement) this.liveElement.textContent = ""
186
+ this.forrigeFokus?.focus()
187
+ this.forrigeFokus = null
188
+ }
189
+
190
+ private emit(navn: string): void {
191
+ this.dispatchEvent(new CustomEvent(navn, { bubbles: true, composed: true }))
192
+ }
193
+
194
+ /** Forlenger økten og lukker varselet. Kall den når serveren har svart. */
195
+ extend(): void {
196
+ this.lastActivity = Date.now()
197
+ this.close()
198
+ this.emit("session-extend")
199
+ }
200
+ }
201
+
202
+ declare global {
203
+ interface HTMLElementTagNameMap {
204
+ "fs-session-timeout": FsSessionTimeout
205
+ }
206
+ }
207
+
208
+ export function defineFsSessionTimeout(tagName = FS_SESSION_TIMEOUT_TAG): void {
209
+ if (!customElements.get(tagName)) {
210
+ customElements.define(tagName, FsSessionTimeout)
211
+ }
212
+ }
@@ -0,0 +1,43 @@
1
+ @import "../../css/button/button.css";
2
+
3
+ @layer fristil {
4
+ fs-session-timeout {
5
+ display: contents;
6
+ }
7
+
8
+ .fs-session-timeout__dialog {
9
+ max-inline-size: var(--fs-session-timeout-width, 26rem);
10
+ padding: var(--fs-session-timeout-padding, var(--size-5));
11
+ color: var(--semantic-page-foreground);
12
+ background: var(--semantic-page-background);
13
+ border: 1px solid var(--semantic-divider-30);
14
+ border-radius: var(--fs-session-timeout-radius, var(--size-1));
15
+ box-shadow: var(--semantic-shadow-overlay);
16
+ }
17
+
18
+ .fs-session-timeout__dialog::backdrop {
19
+ background: var(--semantic-overlay-backdrop);
20
+ }
21
+
22
+ .fs-session-timeout__title {
23
+ margin: 0 0 var(--size-2);
24
+ font-size: var(--font-size-l);
25
+ line-height: 1.3;
26
+ }
27
+
28
+ .fs-session-timeout__text {
29
+ margin: 0 0 var(--size-4);
30
+ line-height: 1.5;
31
+ }
32
+
33
+ .fs-session-timeout__count {
34
+ font-variant-numeric: tabular-nums;
35
+ font-weight: 600;
36
+ }
37
+
38
+ .fs-session-timeout__actions {
39
+ display: flex;
40
+ flex-wrap: wrap;
41
+ gap: var(--size-2);
42
+ }
43
+ }
@@ -0,0 +1,42 @@
1
+ import { attributes } from "../../css/shared.js"
2
+
3
+ export const SESSION_TIMEOUT_CLASS = "fs-session-timeout" as const
4
+ export const SESSION_TIMEOUT_DIALOG_CLASS =
5
+ "fs-session-timeout__dialog" as const
6
+
7
+ export type SessionTimeoutOptions = {
8
+ /** Sekunder uten aktivitet før varselet kommer. Standard: 25 minutter. */
9
+ warnAt?: number
10
+ /** Sekunder uten aktivitet før økten er ute. Standard: 30 minutter. */
11
+ expiresAt?: number
12
+ }
13
+
14
+ export type SessionTimeoutAttributes = {
15
+ class: typeof SESSION_TIMEOUT_CLASS
16
+ "warn-at"?: string
17
+ "expires-at"?: string
18
+ "data-ignore-morph": ""
19
+ }
20
+
21
+ /**
22
+ * Attributtene for varselet om at innlogget økt går ut.
23
+ *
24
+ * Nedtellingen er klientens klokke, og endrer seg hvert sekund ut fra når
25
+ * brukeren sist rørte tastaturet. Det er en tilstand ingen server kan sende,
26
+ * og derfor eier komponenten alt inni seg. `data-ignore-morph` er ikke
27
+ * valgfri: uten den river Datastars morfing dialogen bort mens den står åpen.
28
+ *
29
+ * ```ts
30
+ * <fs-session-timeout {...fs.sessionTimeout({ warnAt: 1500, expiresAt: 1800 })} />
31
+ * ```
32
+ */
33
+ export const sessionTimeout = ({
34
+ warnAt = 25 * 60,
35
+ expiresAt = 30 * 60,
36
+ }: SessionTimeoutOptions = {}): SessionTimeoutAttributes =>
37
+ attributes({
38
+ class: SESSION_TIMEOUT_CLASS,
39
+ "warn-at": warnAt === 25 * 60 ? undefined : String(warnAt),
40
+ "expires-at": expiresAt === 30 * 60 ? undefined : String(expiresAt),
41
+ "data-ignore-morph": "" as const,
42
+ })
@@ -1,14 +1,11 @@
1
- import { html, LitElement } from "lit"
1
+ import { TOAST_CLASS, TOAST_CLOSE_CLASS } from "./toast.js"
2
2
 
3
3
  export const FS_TOAST_TAG = "fs-toast" as const
4
4
 
5
- export const TOAST_CLASS = "fs-toast" as const
6
- export const TOAST_CLOSE_CLASS = "fs-toast__close" as const
7
-
8
5
  export const toastColors = ["neutral", "success", "warning", "danger"] as const
9
6
  export type ToastColor = (typeof toastColors)[number]
10
7
 
11
- export type ToastOptions = {
8
+ export type ShowOptions = {
12
9
  /** Hva meldingen betyr. Standard: `neutral`. */
13
10
  color?: ToastColor
14
11
  /** Millisekunder før meldingen forsvinner. `0` lar den bli stående. */
@@ -20,8 +17,15 @@ export type ToastOptions = {
20
17
  /**
21
18
  * Køen av korte meldinger i hjørnet av skjermen.
22
19
  *
23
- * Elementet er beholderen, ikke meldingen. Du legger det inn én gang i
24
- * appen og kaller `show()` når noe skal meldes.
20
+ * Dette er den eneste frittstående komponenten, og den eneste delen av pakken
21
+ * du kaller i stedet for å skrive. Elementet er beholderen, ikke meldingen:
22
+ * du legger det inn én gang i appen og kaller `show()` når noe skal meldes.
23
+ * Meldingene finnes ikke før en hendelse på klienten skaper dem, så det er
24
+ * ingenting for serveren å rendre.
25
+ *
26
+ * Serveren skriver beholderen med `fs.toast()`, og den setter
27
+ * `data-ignore-morph`. Uten det river Datastars morfing meldingene bort ved
28
+ * neste patch, fordi serverens utgave av regionen er tom.
25
29
  *
26
30
  * Beholderen er en `role="status"`-region, ikke `role="alert"`. En melding
27
31
  * som dukker opp i hjørnet skal ikke avbryte det skjermleseren holder på med;
@@ -39,40 +43,45 @@ export type ToastOptions = {
39
43
  * })
40
44
  * ```
41
45
  */
42
- export class FsToast extends LitElement {
43
- static properties = {
44
- duration: { type: Number },
45
- label: { type: String },
46
- }
46
+ export class FsToast extends HTMLElement {
47
+ static observedAttributes = ["duration", "label"]
47
48
 
48
49
  /** Standard levetid i millisekunder. `0` lar meldingene bli stående. */
49
- duration = 6000
50
- /** Tekst som sier hva regionen er. Blir `aria-label`. */
51
- label = "Meldinger"
52
-
53
- createRenderRoot() {
54
- return this
50
+ get duration(): number {
51
+ // `0` er en gyldig verdi og betyr at meldingene blir stående. Uten
52
+ // sjekken mot null her forsvant de likevel etter seks sekunder, og bare
53
+ // `show(..., { duration: 0 })` virket.
54
+ const rå = this.getAttribute("duration")
55
+ if (rå === null) return 6000
56
+ const value = Number(rå)
57
+ return Number.isFinite(value) && value >= 0 ? value : 6000
55
58
  }
56
59
 
57
- render() {
58
- return html`<slot></slot>`
60
+ set duration(value: number) {
61
+ this.setAttribute("duration", String(value))
59
62
  }
60
63
 
61
- connectedCallback() {
62
- super.connectedCallback()
63
- this.setAttribute("role", "status")
64
- this.setAttribute("aria-live", "polite")
65
- this.setAttribute("aria-label", this.label)
64
+ connectedCallback(): void {
65
+ // Skrev serveren regionen med fs.toast(), står alt dette allerede. Her
66
+ // settes det bare når det mangler, så en ren HTML-side uten bygger også
67
+ // får en region skjermleseren forstår.
68
+ if (!this.hasAttribute("role")) this.setAttribute("role", "status")
69
+ if (!this.hasAttribute("aria-live")) {
70
+ this.setAttribute("aria-live", "polite")
71
+ }
72
+ if (!this.hasAttribute("aria-label")) {
73
+ this.setAttribute("aria-label", this.getAttribute("label") ?? "Meldinger")
74
+ }
66
75
  }
67
76
 
68
- updated() {
77
+ attributeChangedCallback(navn: string, _gammel: string, ny: string): void {
69
78
  // `label` kan settes etter at elementet står i DOM-en, for eksempel av et
70
79
  // rammeverk som fyller inn attributtene i et senere steg.
71
- this.setAttribute("aria-label", this.label)
80
+ if (navn === "label" && ny) this.setAttribute("aria-label", ny)
72
81
  }
73
82
 
74
83
  /** Viser en melding, og returnerer elementet den ble lagt i. */
75
- show(message: string, options: ToastOptions = {}): HTMLElement {
84
+ show(message: string, options: ShowOptions = {}): HTMLElement {
76
85
  const {
77
86
  color = "neutral",
78
87
  duration = this.duration,
@@ -116,7 +125,7 @@ export class FsToast extends LitElement {
116
125
  }
117
126
 
118
127
  /** Fjerner en melding. */
119
- dismiss(toast: HTMLElement) {
128
+ dismiss(toast: HTMLElement): void {
120
129
  if (!this.contains(toast)) return
121
130
  toast.remove()
122
131
 
@@ -126,7 +135,7 @@ export class FsToast extends LitElement {
126
135
  }
127
136
 
128
137
  /** Fjerner alle meldingene. */
129
- clear() {
138
+ clear(): void {
130
139
  for (const toast of this.querySelectorAll(`.${TOAST_CLASS}`)) {
131
140
  toast.remove()
132
141
  }
@@ -0,0 +1,45 @@
1
+ import { attributes } from "../../css/shared.js"
2
+
3
+ export const TOAST_CLASS = "fs-toast" as const
4
+ export const TOAST_CLOSE_CLASS = "fs-toast__close" as const
5
+
6
+ export type ToastOptions = {
7
+ /** Tekst som sier hva regionen er. Blir `aria-label`. */
8
+ label?: string
9
+ }
10
+
11
+ export type ToastAttributes = {
12
+ region: {
13
+ role: "status"
14
+ "aria-live": "polite"
15
+ "aria-label": string
16
+ "data-ignore-morph": ""
17
+ }
18
+ toast: { class: typeof TOAST_CLASS }
19
+ close: { class: typeof TOAST_CLOSE_CLASS }
20
+ }
21
+
22
+ /**
23
+ * Attributtene for varselregionen.
24
+ *
25
+ * Regionen er `role="status"` og ikke `role="alert"`. En melding som kommer
26
+ * fordi noe gikk bra skal ikke avbryte det skjermleseren holder på med.
27
+ *
28
+ * `data-ignore-morph` er ikke valgfri. `<fs-toast>` lager og fjerner sine egne
29
+ * elementer inni regionen, og uten attributtet river Datastars morfing dem bort
30
+ * ved neste patch, fordi serverens utgave av regionen er tom. Attributtet må
31
+ * stå i HTML-en serveren sender: komponenten kan ikke sette det på seg selv,
32
+ * siden morfingen leser det fra serverens node.
33
+ */
34
+ export const toast = ({
35
+ label = "Varsler",
36
+ }: ToastOptions = {}): ToastAttributes => ({
37
+ region: attributes({
38
+ role: "status" as const,
39
+ "aria-live": "polite" as const,
40
+ "aria-label": label,
41
+ "data-ignore-morph": "" as const,
42
+ }),
43
+ toast: { class: TOAST_CLASS },
44
+ close: { class: TOAST_CLOSE_CLASS },
45
+ })
@@ -0,0 +1,55 @@
1
+ import { attributes } from "../../css/shared.js"
2
+
3
+ export const ERROR_SUMMARY_CLASS = "fs-error-summary" as const
4
+ export const ERROR_SUMMARY_TITLE_CLASS = "fs-error-summary__title" as const
5
+
6
+ export type ErrorSummaryOptions = {
7
+ /** Antall feil serveren fant. Er det null, skjules boksen. */
8
+ count?: number
9
+ /** Id på boksen, slik at skjemaet kan flytte fokus hit etter innsending. */
10
+ id?: string
11
+ }
12
+
13
+ export type ErrorSummaryAttributes = {
14
+ container: {
15
+ class: typeof ERROR_SUMMARY_CLASS
16
+ role: "alert"
17
+ tabindex: "-1"
18
+ id?: string
19
+ hidden?: true
20
+ }
21
+ title: {
22
+ class: typeof ERROR_SUMMARY_TITLE_CLASS
23
+ }
24
+ }
25
+
26
+ /**
27
+ * Attributtene for en feiloppsummering.
28
+ *
29
+ * Serveren skriver hele boksen, også overskriften og lista. `<fs-error-summary>`
30
+ * flytter bare fokus og håndterer klikk på lenkene. Tidligere lagde komponenten
31
+ * overskriften selv, og da forsvant den ved hver morfing i Datastar.
32
+ *
33
+ * ```ts
34
+ * const feil = fs.errorSummary({ count: errors.length, id: "feil" })
35
+ * ```
36
+ * ```html
37
+ * <fs-error-summary {...feil.container}>
38
+ * <h2 {...feil.title}>Du må rette 2 feil</h2>
39
+ * <ul><li><a href="#epost">E-posten mangler @</a></li></ul>
40
+ * </fs-error-summary>
41
+ * ```
42
+ */
43
+ export const errorSummary = ({
44
+ count = 0,
45
+ id,
46
+ }: ErrorSummaryOptions = {}): ErrorSummaryAttributes => ({
47
+ container: attributes({
48
+ class: ERROR_SUMMARY_CLASS,
49
+ role: "alert" as const,
50
+ tabindex: "-1" as const,
51
+ id,
52
+ hidden: count === 0 ? (true as const) : undefined,
53
+ }),
54
+ title: { class: ERROR_SUMMARY_TITLE_CLASS },
55
+ })