@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.
Files changed (116) hide show
  1. package/CHANGELOG.md +136 -0
  2. package/README.md +22 -7
  3. package/dist/components/frittstaende/connection-status/connection-status.d.ts +28 -0
  4. package/dist/components/frittstaende/connection-status/connection-status.d.ts.map +1 -0
  5. package/dist/components/frittstaende/connection-status/connection-status.js +22 -0
  6. package/dist/components/frittstaende/connection-status/connection-status.js.map +1 -0
  7. package/dist/components/frittstaende/connection-status/fs-connection-status.d.ts +54 -0
  8. package/dist/components/frittstaende/connection-status/fs-connection-status.d.ts.map +1 -0
  9. package/dist/components/frittstaende/connection-status/fs-connection-status.js +123 -0
  10. package/dist/components/frittstaende/connection-status/fs-connection-status.js.map +1 -0
  11. package/dist/components/frittstaende/session-timeout/fs-session-timeout.d.ts +56 -0
  12. package/dist/components/frittstaende/session-timeout/fs-session-timeout.d.ts.map +1 -0
  13. package/dist/components/frittstaende/session-timeout/fs-session-timeout.js +183 -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 +37 -0
  36. package/dist/components/ramme/field/field-core.d.ts.map +1 -1
  37. package/dist/components/ramme/field/field-core.js +37 -0
  38. package/dist/components/ramme/field/field-core.js.map +1 -1
  39. package/dist/components/ramme/field/fs-field.d.ts +39 -40
  40. package/dist/components/ramme/field/fs-field.d.ts.map +1 -1
  41. package/dist/components/ramme/field/fs-field.js +128 -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 +77 -2
  80. package/dist/react.js.map +1 -1
  81. package/package.json +15 -18
  82. package/src/components/frittstaende/connection-status/connection-status.css +33 -0
  83. package/src/components/frittstaende/connection-status/connection-status.ts +41 -0
  84. package/src/components/frittstaende/connection-status/fs-connection-status.ts +144 -0
  85. package/src/components/frittstaende/session-timeout/fs-session-timeout.ts +216 -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 +38 -0
  93. package/src/components/ramme/field/fs-field.ts +145 -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,159 +1,101 @@
1
- import { html, LitElement } from "lit"
2
-
3
1
  export const FS_ERROR_SUMMARY_TAG = "fs-error-summary" as const
4
2
 
5
- export const ERROR_SUMMARY_CLASS = "fs-error-summary" as const
6
- export const ERROR_SUMMARY_TITLE_CLASS = "fs-error-summary__title" as const
7
-
8
3
  /**
9
- * Samler feilene i et skjema og sender brukeren til feltet som feilet.
4
+ * Sender brukeren fra en feil i oppsummeringen til feltet som feilet.
5
+ *
6
+ * Serveren skriver hele boksen med `fs.errorSummary()`: klassen, `role`,
7
+ * `tabindex`, overskriften og lista. Komponenten lager ingenting. Den gjorde
8
+ * det før, og da forsvant både klassen og overskriften ved første morfing i
9
+ * Datastar, uten å komme tilbake.
10
10
  *
11
- * Du skriver lista med feil selv, som lenker til id-en på hvert felt.
12
- * Komponenten gjør tre ting som er lette å glemme, og som avgjør om
13
- * oppsummeringen er til nytte:
11
+ * Det som er igjen er to ting nettleseren ikke gjør selv:
14
12
  *
15
- * 1. Flytter fokus til boksen når den kommer til syne, så den som hører siden
13
+ * 1. Fokus flyttes til boksen når den kommer til syne, så den som hører siden
16
14
  * får vite at innsendingen stoppet.
17
- * 2. Gir fokus til selve kontrollen når en lenke følges. En vanlig ankerlenke
15
+ * 2. Fokus gis til selve kontrollen når en lenke følges. En vanlig ankerlenke
18
16
  * ruller bare dit, og neste tastetrykk fortsetter der fokus sto før.
19
- * 3. Holder boksen skjult mens lista er tom, så en tom overskrift ikke blir
20
- * stående i skjemaet.
21
17
  *
22
18
  * ```html
23
- * <fs-error-summary heading="Skjemaet har to feil">
19
+ * <fs-error-summary class="fs-error-summary" role="alert" tabindex="-1">
20
+ * <h2 class="fs-error-summary__title">Skjemaet har to feil</h2>
24
21
  * <ul class="fs-list">
25
22
  * <li><a href="#epost">Skriv en gyldig e-postadresse</a></li>
26
23
  * </ul>
27
24
  * </fs-error-summary>
28
25
  * ```
29
26
  */
30
- export class FsErrorSummary extends LitElement {
31
- static properties = {
32
- heading: { type: String },
33
- autofocus: { type: Boolean, reflect: true },
34
- }
35
-
36
- /** Overskriften over lista. */
37
- heading = "Skjemaet har feil"
38
- /** Flytt fokus hit når boksen kommer til syne. Standard: på. */
39
- autofocus = true
27
+ export class FsErrorSummary extends HTMLElement {
28
+ static observedAttributes = ["autofocus", "hidden"]
40
29
 
41
30
  private hasFocused = false
42
- /** Tittelen komponenten selv har laget, til forskjell fra konsumentens. */
43
- private ownTitle?: HTMLElement
44
-
45
- createRenderRoot() {
46
- return this
47
- }
48
-
49
- render() {
50
- return html`<slot @slotchange=${this.handleSlotChange}></slot>`
51
- }
52
-
53
- /**
54
- * Ser etter at lista med feil endrer seg.
55
- *
56
- * Komponenten rendrer i vanlig DOM, og da melder ikke `slotchange` fra når
57
- * konsumenten bytter ut innholdet. Feilene kommer og går mens brukeren
58
- * fyller ut skjemaet, så endringen må observeres.
59
- */
60
31
  private observer?: MutationObserver
61
- private syncing = false
32
+ private readonly links = new Set<HTMLAnchorElement>()
62
33
 
63
- connectedCallback() {
64
- super.connectedCallback()
65
- this.observer = new MutationObserver(() => {
66
- if (!this.syncing) this.sync()
67
- })
34
+ connectedCallback(): void {
35
+ // Feilene kommer og går mens brukeren retter, og `slotchange` melder ikke
36
+ // fra i vanlig DOM. Bare childList: komponenten setter ingen attributter
37
+ // barna, så observatøren kan ikke utløse seg selv.
38
+ this.observer = new MutationObserver(() => this.sync())
68
39
  this.observer.observe(this, { childList: true, subtree: true })
40
+ this.sync()
69
41
  }
70
42
 
71
- disconnectedCallback() {
43
+ attributeChangedCallback(): void {
44
+ // Serveren kan sende boksen skjult og senere bare ta bort `hidden` på
45
+ // den samme noden. Uten dette får en oppsummering som nettopp ble synlig
46
+ // aldri fokus, og det er hele grunnen til at komponenten finnes.
47
+ if (this.isConnected) this.sync()
48
+ }
49
+
50
+ disconnectedCallback(): void {
72
51
  this.observer?.disconnect()
73
52
  this.observer = undefined
74
- super.disconnectedCallback()
53
+ for (const link of this.links) {
54
+ link.removeEventListener("click", this.handleLinkClick)
55
+ }
56
+ this.links.clear()
75
57
  }
76
58
 
77
- firstUpdated() {
78
- this.sync()
59
+ private get shouldFocus(): boolean {
60
+ const value = this.getAttribute("autofocus")
61
+ return value !== "false"
79
62
  }
80
63
 
81
- updated() {
82
- this.sync()
83
- }
64
+ private sync(): void {
65
+ const links = [
66
+ ...this.querySelectorAll<HTMLAnchorElement>("li a[href^='#']"),
67
+ ]
84
68
 
85
- private handleSlotChange = () => {
86
- this.sync()
87
- }
69
+ if (links.length === 0) {
70
+ this.hasFocused = false
71
+ return
72
+ }
88
73
 
89
- private get items(): HTMLAnchorElement[] {
90
- return [...this.querySelectorAll<HTMLAnchorElement>("li a[href^='#']")]
91
- }
74
+ for (const link of links) {
75
+ if (this.links.has(link)) continue
76
+ link.addEventListener("click", this.handleLinkClick)
77
+ this.links.add(link)
78
+ }
92
79
 
93
- private sync() {
94
- if (this.syncing) return
95
- this.syncing = true
96
-
97
- try {
98
- const links = this.items
99
- const empty = links.length === 0
100
-
101
- this.hidden = empty
102
- if (empty) {
103
- this.hasFocused = false
104
- return
105
- }
106
-
107
- // Boksen er selve elementet. Vi flytter ikke konsumentens markup inn i
108
- // et omslag: da ville Lit-delen vår og innholdet byttet plass i DOM-en.
109
- this.classList.add(ERROR_SUMMARY_CLASS)
110
- this.setAttribute("role", "alert")
111
- this.tabIndex = -1
112
-
113
- let title = this.querySelector<HTMLElement>(
114
- `.${ERROR_SUMMARY_TITLE_CLASS}`,
115
- )
116
- if (!title) {
117
- title = document.createElement("h2")
118
- title.className = ERROR_SUMMARY_TITLE_CLASS
119
- this.prepend(title)
120
- this.ownTitle = title
121
- }
122
-
123
- // Overskriften følger `heading` så lenge det er vi som har laget den.
124
- // Antallet feil endrer seg mens brukeren retter, og en overskrift som
125
- // ble stående på det første tallet er verre enn ingen: den som hører
126
- // siden får vite at det er to feil igjen når det er én.
127
- // Har konsumenten skrevet tittelen selv, er den deres.
128
- //
129
- // Sammenligningen er ikke pynt: å skrive den samme teksten på nytt
130
- // teller som en endring for MutationObserver, og da kaller den sync()
131
- // igjen i det uendelige.
132
- const skalSkrive = title === this.ownTitle || !title.textContent?.trim()
133
- if (skalSkrive && title.textContent !== this.heading) {
134
- title.textContent = this.heading
135
- }
136
-
137
- for (const link of links) {
138
- link.removeEventListener("click", this.handleLinkClick)
139
- link.addEventListener("click", this.handleLinkClick)
140
- }
141
-
142
- if (this.autofocus && !this.hasFocused) {
143
- this.hasFocused = true
144
- this.focus()
145
- }
146
- } finally {
147
- this.syncing = false
80
+ // Boksen er skjult av serveren når lista er tom. Er den synlig nå, og vi
81
+ // ikke har flyttet fokus hit ennå, er det denne innsendingen som feilet.
82
+ if (this.shouldFocus && !this.hasFocused && !this.hidden) {
83
+ this.hasFocused = true
84
+ this.focus()
148
85
  }
149
86
  }
150
87
 
151
- private handleLinkClick = (event: Event) => {
88
+ private handleLinkClick = (event: Event): void => {
152
89
  const link = event.currentTarget as HTMLAnchorElement
153
90
  const id = link.getAttribute("href")?.slice(1)
154
91
  if (!id) return
155
92
 
156
- const target = document.getElementById(id)
93
+ // Oppslaget går mot rota komponenten selv står i, ikke mot `document`.
94
+ // Ligger skjemaet i en skyggerot, som i en forhåndsvisning eller inne i
95
+ // en annen komponent, finner `document.getElementById` ingenting, og
96
+ // lenken blir en vanlig ankerlenke uten fokusflytting.
97
+ const rot = this.getRootNode() as Document | ShadowRoot
98
+ const target = rot.getElementById?.(id) ?? document.getElementById(id)
157
99
  if (!target) return
158
100
 
159
101
  event.preventDefault()
@@ -161,7 +103,8 @@ export class FsErrorSummary extends LitElement {
161
103
  // Er lenken til en ledetekst, skal fokus til kontrollen den peker på.
162
104
  const control =
163
105
  target instanceof HTMLLabelElement && target.htmlFor
164
- ? document.getElementById(target.htmlFor)
106
+ ? (rot.getElementById?.(target.htmlFor) ??
107
+ document.getElementById(target.htmlFor))
165
108
  : target
166
109
 
167
110
  const focusable = control ?? target
@@ -130,3 +130,41 @@ export function computeFieldAttributes(
130
130
  state: invalid ? "invalid" : "default",
131
131
  }
132
132
  }
133
+
134
+ /**
135
+ * Attributtene `<fs-field>` setter, per element den rører.
136
+ *
137
+ * Lista trengs bare når to ting gjelder samtidig.
138
+ *
139
+ * Lages markupen med JavaScript, skal den kalle `computeFieldAttributes` og
140
+ * skrive attributtene selv. Da trengs verken komponenten eller denne lista.
141
+ * Blir markupen til uten JavaScript, i en Go-mal eller håndskrevet HTML, gjør
142
+ * `<fs-field>` koblingen i nettleseren, og på en side serveren sender én gang
143
+ * er det alt som skal til.
144
+ *
145
+ * Oppdaterer serveren det samme området mens brukeren holder på, som i en
146
+ * Datastar- eller htmx-app, må malen i tillegg skrive lista som
147
+ * `data-preserve-attr` på hvert av de tre elementene. Morfingen leser den fra
148
+ * serverens node, per element, så komponenten kan ikke beskytte seg selv.
149
+ *
150
+ * Det holder ikke å beskytte kontrollen alene. Komponenten legger også
151
+ * `fs-label` og markeringene på ledeteksten, og skjuler feilmeldingen. Uten
152
+ * lister på de to andre forsvinner klassen og feilmeldingen ved neste
153
+ * morfing, som er nøyaktig den feilen kontrakten skal hindre.
154
+ *
155
+ * ```html
156
+ * <label class="fs-label" for="epost"
157
+ * data-preserve-attr="class data-required data-optional aria-disabled">E-post</label>
158
+ * <input class="fs-input" id="epost"
159
+ * data-preserve-attr="id aria-describedby aria-invalid data-state disabled aria-disabled">
160
+ * <p class="fs-error-text" hidden data-preserve-attr="hidden">Skriv en gyldig adresse.</p>
161
+ * ```
162
+ */
163
+ export const FIELD_PRESERVED_ATTRIBUTES = {
164
+ /** På ledeteksten. */
165
+ label: "class data-required data-optional aria-disabled",
166
+ /** På selve kontrollen. */
167
+ control: "id aria-describedby aria-invalid data-state disabled aria-disabled",
168
+ /** På feilmeldingen. */
169
+ error: "hidden",
170
+ } as const
@@ -1,5 +1,3 @@
1
- import { html, LitElement } from "lit"
2
-
3
1
  import { computeFieldAttributes } from "./field-core.js"
4
2
 
5
3
  export const FS_FIELD_TAG = "fs-field" as const
@@ -8,79 +6,175 @@ function uniqueId(prefix: string): string {
8
6
  return `${prefix}-${Math.random().toString(36).slice(2, 9)}`
9
7
  }
10
8
 
11
- export class FsField extends LitElement {
12
- static properties = {
13
- invalid: { type: Boolean, reflect: true },
14
- disabled: { type: Boolean, reflect: true },
15
- optional: { type: Boolean, reflect: true },
16
- requiredMarker: { type: String, attribute: "required-marker" },
17
- controlId: { type: String, attribute: "control-id" },
18
- describedBy: { type: String, attribute: "described-by" },
9
+ function setOrRemove(
10
+ element: HTMLElement,
11
+ name: string,
12
+ value: string | undefined,
13
+ ): void {
14
+ if (value === undefined) {
15
+ element.removeAttribute(name)
16
+ } else if (element.getAttribute(name) !== value) {
17
+ element.setAttribute(name, value)
19
18
  }
19
+ }
20
20
 
21
- invalid = false
22
- disabled = false
23
- optional = false
24
- requiredMarker: "none" | "symbol" | "text" = "none"
25
- controlId?: string
26
- describedBy?: string
21
+ /**
22
+ * Kobler ledetekst, kontroll, hjelpetekst og feilmelding i vanlig DOM.
23
+ *
24
+ * Komponenten er for markup som blir til uten JavaScript: en Go-mal, en
25
+ * PHP-fil, en Razor-visning eller håndskrevet HTML. Lages markupen med
26
+ * JavaScript, uansett hvor koden kjører, skal `fs.field()` skrive
27
+ * attributtene i stedet, og da trengs ikke dette elementet.
28
+ *
29
+ * Komponenten rendrer ingenting. Den satte tidligere et `<slot>`-element inn i
30
+ * vanlig DOM, og siden serveren ikke visste om det, fjernet Datastars morfing
31
+ * det ved hver patch. Se `FIELD_PRESERVED_ATTRIBUTES` for hva serveren må
32
+ * skrive for at koblingen skal overleve en morfing.
33
+ */
34
+ export class FsField extends HTMLElement {
35
+ static observedAttributes = [
36
+ "invalid",
37
+ "disabled",
38
+ "optional",
39
+ "required-marker",
40
+ "control-id",
41
+ "described-by",
42
+ ]
43
+
44
+ private observer?: MutationObserver
45
+
46
+ /**
47
+ * Egenskapene speiler attributtene.
48
+ *
49
+ * Tilstanden bor i attributtet og ikke i et felt på klassen, slik at det
50
+ * serveren sendte og det komponenten mener alltid er det samme. En egen
51
+ * `invalid`-variabel ville kunne si noe annet enn markupen etter en morfing.
52
+ */
53
+ get invalid(): boolean {
54
+ return this.hasAttribute("invalid")
55
+ }
27
56
 
28
- createRenderRoot() {
29
- return this
57
+ set invalid(value: boolean) {
58
+ this.toggleAttribute("invalid", value)
30
59
  }
31
60
 
32
- render() {
33
- return html`<slot @slotchange=${this.handleSlotChange}></slot>`
61
+ get disabled(): boolean {
62
+ return this.hasAttribute("disabled")
34
63
  }
35
64
 
36
- firstUpdated() {
37
- this.syncA11y()
65
+ set disabled(value: boolean) {
66
+ this.toggleAttribute("disabled", value)
38
67
  }
39
68
 
40
- updated() {
41
- this.syncA11y()
69
+ get optional(): boolean {
70
+ return this.hasAttribute("optional")
42
71
  }
43
72
 
44
- private handleSlotChange = () => {
45
- this.syncA11y()
73
+ set optional(value: boolean) {
74
+ this.toggleAttribute("optional", value)
46
75
  }
47
76
 
48
- private syncA11y() {
49
- const label = this.querySelector("label") as HTMLLabelElement | null
50
- const control = this.querySelector(
51
- "input:not([type='hidden']), textarea, select",
52
- ) as HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement | null
53
- const help = this.querySelector(
77
+ get requiredMarker(): "none" | "symbol" | "text" {
78
+ const value = this.getAttribute("required-marker")
79
+ return value === "symbol" || value === "text" ? value : "none"
80
+ }
81
+
82
+ set requiredMarker(value: "none" | "symbol" | "text") {
83
+ if (value === "none") this.removeAttribute("required-marker")
84
+ else this.setAttribute("required-marker", value)
85
+ }
86
+
87
+ get controlId(): string | undefined {
88
+ return this.getAttribute("control-id") ?? undefined
89
+ }
90
+
91
+ set controlId(value: string | undefined) {
92
+ if (value === undefined) this.removeAttribute("control-id")
93
+ else this.setAttribute("control-id", value)
94
+ }
95
+
96
+ get describedBy(): string | undefined {
97
+ return this.getAttribute("described-by") ?? undefined
98
+ }
99
+
100
+ set describedBy(value: string | undefined) {
101
+ if (value === undefined) this.removeAttribute("described-by")
102
+ else this.setAttribute("described-by", value)
103
+ }
104
+
105
+ connectedCallback(): void {
106
+ // `slotchange` melder ikke fra i vanlig DOM, og innholdet byttes ut mens
107
+ // brukeren fyller ut skjemaet. Bare childList: å sette et attributt på et
108
+ // barn utløser da ingen ny runde, så observatøren kan ikke gå i ring.
109
+ this.observer = new MutationObserver(() => this.sync())
110
+ this.observer.observe(this, { childList: true, subtree: true })
111
+ this.sync()
112
+ }
113
+
114
+ disconnectedCallback(): void {
115
+ this.observer?.disconnect()
116
+ this.observer = undefined
117
+ }
118
+
119
+ attributeChangedCallback(): void {
120
+ if (this.isConnected) this.sync()
121
+ }
122
+
123
+ private sync(): void {
124
+ const label = this.querySelector("label")
125
+ const control = this.querySelector<
126
+ HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement
127
+ >("input:not([type='hidden']), textarea, select")
128
+ const help = this.querySelector<HTMLElement>(
54
129
  ".fs-help-text, [data-role='help']",
55
- ) as HTMLElement | null
56
- const error = this.querySelector(
130
+ )
131
+ const error = this.querySelector<HTMLElement>(
57
132
  ".fs-error-text, [data-role='error']",
58
- ) as HTMLElement | null
133
+ )
59
134
 
60
135
  if (!control) return
61
136
 
62
137
  if (help && !help.id) help.id = uniqueId("fs-field-help")
63
138
  if (error && !error.id) error.id = uniqueId("fs-field-error")
64
139
 
65
- // Selve kontrakten regnes ut av den delte kjernen, som fs.field() også
66
- // bruker. Denne komponenten gjør bare én ting utover det: å sette
67
- // resultatet elementer som allerede står i DOM-en.
140
+ // Markeringene leses også fra markupen. Skrev serveren dem med
141
+ // `fs.field()`, står de ledeteksten, og en komponent som bare så på
142
+ // sine egne attributter ville fjernet dem igjen. I React ga det en
143
+ // hydreringsfeil: serveren sendte `data-required="symbol"`, komponenten
144
+ // tok det bort, og så mente React at HTML-en ikke stemte.
145
+ const marker =
146
+ this.getAttribute("required-marker") ??
147
+ label?.getAttribute("data-required") ??
148
+ null
149
+ const disabled =
150
+ this.hasAttribute("disabled") || control.hasAttribute("disabled")
151
+
152
+ // Tilstanden leses fra markupen, ikke bare fra et attributt på verten.
153
+ // Skrev serveren feltet med `fs.field()`, står svaret allerede på
154
+ // kontrollen, og en komponent som regnet ut sitt eget ville fjernet det
155
+ // igjen. Da kranglet de to halvdelene av API-et med hverandre.
156
+ const invalid =
157
+ this.hasAttribute("invalid") ||
158
+ control.getAttribute("aria-invalid") === "true"
159
+
68
160
  const computed = computeFieldAttributes({
69
- id: this.controlId || control.id || uniqueId("fs-field-control"),
161
+ id:
162
+ this.getAttribute("control-id") ||
163
+ control.id ||
164
+ uniqueId("fs-field-control"),
70
165
  help: Boolean(help),
71
166
  error: Boolean(error),
72
167
  helpId: help?.id,
73
168
  errorId: error?.id,
74
- required:
75
- this.requiredMarker === "symbol" || this.requiredMarker === "text"
76
- ? this.requiredMarker
77
- : undefined,
78
- optional: this.optional,
79
- invalid: this.invalid,
80
- disabled: this.disabled,
169
+ required: marker === "symbol" || marker === "text" ? marker : undefined,
170
+ optional:
171
+ this.hasAttribute("optional") ||
172
+ label?.hasAttribute("data-optional") === true,
173
+ invalid,
174
+ disabled,
81
175
  describedBy: [
82
176
  control.getAttribute("aria-describedby") ?? "",
83
- this.describedBy ?? "",
177
+ this.getAttribute("described-by") ?? "",
84
178
  ].filter(Boolean),
85
179
  })
86
180
 
@@ -95,8 +189,10 @@ export class FsField extends LitElement {
95
189
  }
96
190
 
97
191
  if (error) {
192
+ // Bare `hidden`. Et skjult element er allerede ute av
193
+ // tilgjengelighetstreet, så `aria-hidden` var overflødig, og ga en
194
+ // hydreringsfeil i React fordi serveren ikke skriver det.
98
195
  error.hidden = Boolean(computed.error.hidden)
99
- error.setAttribute("aria-hidden", String(Boolean(computed.error.hidden)))
100
196
  }
101
197
 
102
198
  setOrRemove(
@@ -106,7 +202,7 @@ export class FsField extends LitElement {
106
202
  )
107
203
  setOrRemove(control, "aria-invalid", computed.control["aria-invalid"])
108
204
 
109
- if (this.disabled) {
205
+ if (disabled) {
110
206
  control.setAttribute("disabled", "")
111
207
  control.setAttribute("aria-disabled", "true")
112
208
  } else {
@@ -129,18 +225,6 @@ export class FsField extends LitElement {
129
225
  }
130
226
  }
131
227
 
132
- function setOrRemove(
133
- element: HTMLElement,
134
- name: string,
135
- value: string | undefined,
136
- ) {
137
- if (value === undefined) {
138
- element.removeAttribute(name)
139
- } else {
140
- element.setAttribute(name, value)
141
- }
142
- }
143
-
144
228
  declare global {
145
229
  interface HTMLElementTagNameMap {
146
230
  "fs-field": FsField