@webwriter/quiz 1.1.3 → 2.0.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 (167) hide show
  1. package/.github/workflows/publish.yml +8 -0
  2. package/LICENSE +6 -6
  3. package/README.md +778 -0
  4. package/assets/icons/grip-horizontal.svg +8 -0
  5. package/assets/icons/grip-vertical.svg +8 -0
  6. package/assets/icons/truefalse.svg +6 -0
  7. package/custom-elements.json +2779 -0
  8. package/custom.d.ts +180 -176
  9. package/dist/api/index.d.ts +27 -0
  10. package/dist/api/index.js +40 -0
  11. package/dist/migrate.js +1434 -0
  12. package/dist/widgets/webwriter-choice-item.js +4036 -3895
  13. package/dist/widgets/webwriter-choice.js +2269 -5821
  14. package/dist/widgets/webwriter-gap-item.js +3987 -0
  15. package/dist/widgets/webwriter-gap.js +2908 -0
  16. package/dist/widgets/webwriter-mark.js +4335 -3489
  17. package/dist/widgets/webwriter-order-item.js +4014 -3306
  18. package/dist/widgets/webwriter-order.js +2944 -4386
  19. package/dist/widgets/webwriter-pairing-item.js +1058 -2421
  20. package/dist/widgets/webwriter-pairing.js +2993 -4383
  21. package/dist/widgets/webwriter-quiz-hint.js +2469 -0
  22. package/dist/widgets/webwriter-quiz.js +4933 -6099
  23. package/dist/widgets/webwriter-speech.js +6077 -5230
  24. package/dist/widgets/webwriter-task-prompt.js +4775 -2165
  25. package/dist/widgets/webwriter-task.js +2364 -8382
  26. package/dist/widgets/webwriter-text.js +4169 -4337
  27. package/dist/widgets/webwriter-true-false.js +4674 -0
  28. package/editing-config.json +106 -0
  29. package/lit-localize.json +21 -15
  30. package/localization/de.xlf +407 -74
  31. package/localization/es.xlf +407 -69
  32. package/localization/fr.xlf +407 -69
  33. package/localization/generated/de.ts +112 -29
  34. package/localization/generated/es.ts +112 -29
  35. package/localization/generated/fr.ts +112 -29
  36. package/localization/generated/index.d.ts +5 -0
  37. package/localization/generated/index.js +2 -2
  38. package/localization/generated/it.ts +112 -29
  39. package/localization/generated/locale-codes.js +31 -79
  40. package/localization/generated/nl.ts +112 -29
  41. package/localization/it.xlf +407 -69
  42. package/localization/nl.xlf +407 -69
  43. package/package.json +119 -252
  44. package/scripts/apply-translations.mjs +97 -0
  45. package/src/api/index.ts +72 -0
  46. package/src/lib/artboard.ts +686 -0
  47. package/src/lib/draggable-grid.ts +432 -0
  48. package/src/lib/encrypted-property.ts +202 -0
  49. package/src/lib/hint-popup.ts +152 -0
  50. package/src/lib/utils.ts +97 -0
  51. package/src/lib/webwriter-quirks.ts +121 -0
  52. package/src/migrate/index.ts +622 -0
  53. package/src/migrate/legacy-solution.ts +362 -0
  54. package/src/snippets/order.html +9 -8
  55. package/src/widgets/choice/webwriter-choice-item.ts +316 -0
  56. package/src/widgets/choice/webwriter-choice.ts +328 -0
  57. package/src/widgets/choice/webwriter-true-false.ts +113 -0
  58. package/src/widgets/dnd/webwriter-dnd-draggable.ts +28 -0
  59. package/src/widgets/dnd/webwriter-dnd-dropzone.ts +9 -0
  60. package/src/widgets/dnd/webwriter-dnd.ts +36 -0
  61. package/src/widgets/gap/webwriter-gap-item.ts +250 -0
  62. package/src/widgets/gap/webwriter-gap.ts +447 -0
  63. package/src/widgets/order/webwriter-order-item.ts +243 -0
  64. package/src/widgets/order/webwriter-order.ts +282 -0
  65. package/src/widgets/pairing/webwriter-pairing-item.ts +46 -0
  66. package/src/widgets/pairing/webwriter-pairing.ts +934 -0
  67. package/src/widgets/speech/audio-player.ts +325 -0
  68. package/src/widgets/speech/audio-recorder.ts +285 -0
  69. package/src/widgets/speech/waveform.ts +113 -0
  70. package/src/widgets/speech/webwriter-speech.ts +87 -0
  71. package/src/widgets/webwriter-mark.ts +374 -432
  72. package/src/widgets/webwriter-quiz-hint.ts +36 -0
  73. package/src/widgets/webwriter-quiz.ts +320 -282
  74. package/src/widgets/webwriter-task-prompt.ts +49 -24
  75. package/src/widgets/webwriter-task.ts +216 -547
  76. package/src/widgets/webwriter-text.ts +291 -156
  77. package/tsconfig.api.json +12 -0
  78. package/tsconfig.json +8 -7
  79. package/dist/widgets/webwriter-choice.css +0 -444
  80. package/dist/widgets/webwriter-cloze-gap.css +0 -444
  81. package/dist/widgets/webwriter-cloze-gap.js +0 -5866
  82. package/dist/widgets/webwriter-cloze.css +0 -444
  83. package/dist/widgets/webwriter-cloze.js +0 -3786
  84. package/dist/widgets/webwriter-mark.css +0 -444
  85. package/dist/widgets/webwriter-order.css +0 -444
  86. package/dist/widgets/webwriter-pairing-item.css +0 -444
  87. package/dist/widgets/webwriter-pairing.css +0 -444
  88. package/dist/widgets/webwriter-quiz.css +0 -444
  89. package/dist/widgets/webwriter-task-explainer.css +0 -444
  90. package/dist/widgets/webwriter-task-explainer.js +0 -2872
  91. package/dist/widgets/webwriter-task-hint.css +0 -444
  92. package/dist/widgets/webwriter-task-hint.js +0 -2811
  93. package/dist/widgets/webwriter-task.css +0 -444
  94. package/dist/widgets/webwriter-text.css +0 -444
  95. package/localization/bg.xlf +0 -69
  96. package/localization/cs.xlf +0 -69
  97. package/localization/da.xlf +0 -69
  98. package/localization/el.xlf +0 -69
  99. package/localization/et.xlf +0 -69
  100. package/localization/fi.xlf +0 -69
  101. package/localization/generated/bg.ts +0 -30
  102. package/localization/generated/cs.ts +0 -30
  103. package/localization/generated/da.ts +0 -30
  104. package/localization/generated/el.ts +0 -30
  105. package/localization/generated/et.ts +0 -30
  106. package/localization/generated/fi.ts +0 -30
  107. package/localization/generated/hu.ts +0 -30
  108. package/localization/generated/id.ts +0 -30
  109. package/localization/generated/ja.ts +0 -30
  110. package/localization/generated/ko.ts +0 -30
  111. package/localization/generated/lt.ts +0 -30
  112. package/localization/generated/lv.ts +0 -30
  113. package/localization/generated/nb.ts +0 -30
  114. package/localization/generated/pl.ts +0 -30
  115. package/localization/generated/pt-BR.ts +0 -30
  116. package/localization/generated/pt-PT.ts +0 -30
  117. package/localization/generated/ro.ts +0 -30
  118. package/localization/generated/ru.ts +0 -30
  119. package/localization/generated/sk.ts +0 -30
  120. package/localization/generated/sl.ts +0 -30
  121. package/localization/generated/sv.ts +0 -30
  122. package/localization/generated/tr.ts +0 -30
  123. package/localization/generated/uk.ts +0 -30
  124. package/localization/generated/zh-hans.ts +0 -30
  125. package/localization/hu.xlf +0 -69
  126. package/localization/id.xlf +0 -69
  127. package/localization/ja.xlf +0 -69
  128. package/localization/ko.xlf +0 -69
  129. package/localization/lt.xlf +0 -69
  130. package/localization/lv.xlf +0 -69
  131. package/localization/nb.xlf +0 -69
  132. package/localization/pl.xlf +0 -69
  133. package/localization/pt-BR.xlf +0 -69
  134. package/localization/pt-PT.xlf +0 -69
  135. package/localization/ro.xlf +0 -69
  136. package/localization/ru.xlf +0 -69
  137. package/localization/sk.xlf +0 -69
  138. package/localization/sl.xlf +0 -69
  139. package/localization/sv.xlf +0 -69
  140. package/localization/tr.xlf +0 -69
  141. package/localization/uk.xlf +0 -69
  142. package/localization/zh-hans.xlf +0 -69
  143. package/src/lib/combobox.ts +0 -456
  144. package/src/lib/highlighter-fill.svg +0 -6
  145. package/src/snippets/Beispiel-Choice.html +0 -57
  146. package/src/snippets/Beispiel-Mark.html +0 -14
  147. package/src/snippets/Beispiel-Order.html +0 -52
  148. package/src/snippets/Beispiel-Text.html +0 -20
  149. package/src/snippets/blank.html +0 -1
  150. package/src/snippets/choice.html +0 -8
  151. package/src/snippets/cloze.html +0 -4
  152. package/src/snippets/mark.html +0 -4
  153. package/src/snippets/pairing.html +0 -10
  154. package/src/snippets/speech.html +0 -4
  155. package/src/snippets/text.html +0 -4
  156. package/src/snippets/wordsearch.html +0 -4
  157. package/src/widgets/webwriter-choice-item.ts +0 -254
  158. package/src/widgets/webwriter-choice.ts +0 -353
  159. package/src/widgets/webwriter-cloze-gap.ts +0 -216
  160. package/src/widgets/webwriter-cloze.ts +0 -136
  161. package/src/widgets/webwriter-order-item.ts +0 -441
  162. package/src/widgets/webwriter-order.ts +0 -327
  163. package/src/widgets/webwriter-pairing-item.ts +0 -161
  164. package/src/widgets/webwriter-pairing.ts +0 -193
  165. package/src/widgets/webwriter-speech.ts +0 -273
  166. package/src/widgets/webwriter-task-explainer.ts +0 -43
  167. package/src/widgets/webwriter-task-hint.ts +0 -23
@@ -0,0 +1,36 @@
1
+ import { msg } from "@lit/localize";
2
+ import { LitElementWw } from "@webwriter/lit";
3
+ import { css, html } from "lit";
4
+ import { customElement } from "lit/decorators.js";
5
+ import { styleMap } from "lit/directives/style-map.js";
6
+ import LOCALIZE from "../../localization/generated";
7
+
8
+ declare global {
9
+ interface HTMLElementTagNameMap {
10
+ "webwriter-quiz-hint": WebwriterQuizHint;
11
+ }
12
+ }
13
+
14
+ /**
15
+ * A generic hint element that can be used inside a variety of elements that
16
+ * explicitly mention it in their documentation, such as `<webwriter-task>`.
17
+ *
18
+ * Unless otherwise specified, it should be assigned to the `hint` slot of its
19
+ * parent element.
20
+ */
21
+ @customElement("webwriter-quiz-hint")
22
+ export class WebwriterQuizHint extends LitElementWw {
23
+ /** @internal */
24
+ localize = LOCALIZE;
25
+
26
+ static styles = css`
27
+ slot {
28
+ display: inline-block !important;
29
+ min-width: 10ch;
30
+ }
31
+ `;
32
+
33
+ render() {
34
+ return html`<slot style=${styleMap({ "--ww-placeholder": `"${msg("Hint")}"` })}></slot>`;
35
+ }
36
+ }
@@ -1,282 +1,320 @@
1
- import {css, html, PropertyValues} from "lit"
2
- import {LitElementWw, option} from "@webwriter/lit"
3
- import {customElement, property, query, queryAssignedElements} from "lit/decorators.js"
4
-
5
- import SlButton from "@shoelace-style/shoelace/dist/components/button/button.component.js"
6
- import SlButtonGroup from "@shoelace-style/shoelace/dist/components/button-group/button-group.component.js"
7
- import SlDropdown from "@shoelace-style/shoelace/dist/components/dropdown/dropdown.component.js"
8
- import SlMenu from "@shoelace-style/shoelace/dist/components/menu/menu.component.js"
9
- import SlMenuItem from "@shoelace-style/shoelace/dist/components/menu-item/menu-item.component.js"
10
- import SlIcon from "@shoelace-style/shoelace/dist/components/icon/icon.component.js"
11
- import IconUIChecksGrid from "bootstrap-icons/icons/ui-checks-grid.svg"
12
- import Icon123 from "bootstrap-icons/icons/123.svg"
13
- import IconCardText from "bootstrap-icons/icons/card-text.svg"
14
- import IconHighlighter from "bootstrap-icons/icons/highlighter.svg"
15
- import IconSubtract from "bootstrap-icons/icons/subtract.svg"
16
- import IconBodyText from "bootstrap-icons/icons/body-text.svg"
17
- import IconMic from "bootstrap-icons/icons/mic.svg"
18
- import IconImage from "bootstrap-icons/icons/image.svg"
19
- import IconSearch from "bootstrap-icons/icons/search.svg"
20
- import IconGrid3x3Gap from "bootstrap-icons/icons/grid-3x3-gap.svg"
21
-
22
- import "@shoelace-style/shoelace/dist/themes/light.css"
23
- import type { WebwriterTask } from "./webwriter-task.js"
24
-
25
- import LOCALIZE from "../../localization/generated"
26
- import {msg} from "@lit/localize"
27
-
28
-
29
- function shuffle<T>(a: T[]) {
30
- for (let i = a.length - 1; i > 0; i--) {
31
- const j = Math.floor(Math.random() * (i + 1));
32
- [a[i], a[j]] = [a[j], a[i]];
33
- }
34
- return a;
35
- }
36
-
37
- declare global {interface HTMLElementTagNameMap {
38
- "webwriter-quiz": WebwriterQuiz;
39
- }}
40
-
41
- @customElement("webwriter-quiz")
42
- export class WebwriterQuiz extends LitElementWw {
43
-
44
- localize = LOCALIZE
45
-
46
- get answerTypes() {
47
- return {
48
- "webwriter-choice": {
49
- label: msg("Choice"),
50
- icon: IconUIChecksGrid
51
- },
52
- "webwriter-order": {
53
- label: msg("Order"),
54
- icon: Icon123
55
- },
56
- "webwriter-text": {
57
- label: msg("Text"),
58
- icon: IconCardText
59
- },
60
- "webwriter-mark": {
61
- label: msg("Mark"),
62
- icon: IconHighlighter
63
- },/*
64
- "webwriter-pairing": {
65
- label: this.msg("Pairing"),
66
- icon: IconSubtract
67
- },
68
- "webwriter-cloze": {
69
- label: this.msg("Cloze"),
70
- icon: IconBodyText
71
- },*/
72
- "webwriter-speech": {
73
- label: msg("Speech"),
74
- //advanced: true,
75
- icon: IconMic
76
- },
77
- /*"webwriter-wordsearch": {
78
- label: this.msg("Word Search"),
79
- advanced: true,
80
- icon: IconSearch
81
- },
82
- "webwriter-memory": {
83
- label: this.msg("Memory"),
84
- advanced: true,
85
- icon: IconGrid3x3Gap
86
- }*/
87
- }
88
- }
89
-
90
- static scopedElements = {
91
- "sl-button": SlButton,
92
- "sl-button-group": SlButtonGroup,
93
- "sl-icon": SlIcon,
94
- "sl-dropdown": SlDropdown,
95
- "sl-menu": SlMenu,
96
- "sl-menu-item": SlMenuItem,
97
- }
98
-
99
- addTask(answerTypeName: string) {
100
- const task = this.ownerDocument.createElement("webwriter-task") as WebwriterTask
101
- task.setAttribute("counter", this.counter)
102
- const prompt = this.ownerDocument.createElement("webwriter-task-prompt")
103
- const p = this.ownerDocument.createElement("p")
104
- prompt.append(p)
105
- prompt.slot = "prompt"
106
- const answer = this.ownerDocument.createElement(answerTypeName)
107
- task.appendChild(prompt)
108
- task.appendChild(answer)
109
- this.appendChild(task)
110
- document.getSelection().setBaseAndExtent(p, 0, p, 0)
111
- }
112
-
113
- static styles = css`
114
- :host {
115
- border-top: 1px solid darkgray;
116
- border-bottom: 1px solid darkgray;
117
- padding: 1ch;
118
- display: flex !important;
119
- flex-direction: column;
120
- gap: 2rem;
121
- counter-reset: task;
122
- }
123
-
124
- :host(:not([contenteditable=true]):not([contenteditable=""])) .author-only {
125
- display: none;
126
- }
127
-
128
- :host(:is([contenteditable=true], [contenteditable=""])) .user-only {
129
- display: none;
130
- }
131
-
132
- :host(:is([contenteditable=true], [contenteditable=""])) ::slotted(*) {
133
- order: unset !important;
134
- }
135
-
136
- sl-button-group::part(base) {
137
- display: flex;
138
- }
139
-
140
- sl-button-group > *:not(sl-dropdown) {
141
- flex-grow: 1;
142
- }
143
-
144
- sl-dropdown[data-empty] {
145
- display: none;
146
- }
147
-
148
- sl-button:not([caret])::part(label) {
149
- padding: 0;
150
- display: flex;
151
- flex-direction: row;
152
- width: 100%;
153
- justify-content: space-evenly;
154
- align-items: center;
155
- }
156
-
157
- sl-icon {
158
- width: 18px;
159
- height: 18px;
160
- }
161
-
162
- sl-menu::part(menu) {
163
- z-index: 10000;
164
- }
165
-
166
- .user-actions {
167
- & #submit {
168
- flex-grow: 3;
169
- }
170
-
171
- & #reset {
172
- flex-grow: 1;
173
- }
174
- }
175
- `
176
-
177
- @property({type: Boolean, attribute: true, reflect: true})
178
- @option({type: Boolean, label: {"en": "Random Task Order"}})
179
- accessor randomOrder = false
180
-
181
- get counter(): "number" | "roman" | "roman-capitalized" | "alphabetical" | "alphabetical-capitalized" {
182
- return this.tasks[0]?.counter
183
- }
184
-
185
- @property({attribute: true}) //@ts-ignore
186
- @option({
187
- type: "select",
188
- label: {
189
- "en": "Counter"
190
- },
191
- options: [
192
- {value: undefined, label: {"en": "None"}},
193
- {value: "number", label: {"en": "1. 2. 3."}},
194
- {value: "roman", label: {"en": "i. ii. iii."}},
195
- {value: "roman-capitalized", label: {"en": "I. II. III."}},
196
- {value: "alphabetical", label: {"en": "a. b. c."}},
197
- {value: "alphabetical-capitalized", label: {"en": "A. B. C."}},
198
- ]
199
- })
200
- set counter(value) {
201
- this.tasks.forEach(el => el.counter = value)
202
- }
203
-
204
- shuffleTasks() {
205
- const n = this.tasks.length
206
- const nums = shuffle([...(new Array(n)).keys()])
207
- this.tasks.forEach((el, i) => el.style.order = String(nums[i]))
208
- }
209
-
210
- handleSubmit(e: Event) {
211
- this.submitted = true
212
- this.dispatchEvent(new Event("submit"))
213
- this.tasks.forEach(task => task.handleSubmit())
214
- }
215
-
216
- handleReset = () => {
217
- this.tasks.forEach(task => task.handleReset())
218
- this.requestUpdate()
219
- this.submitted = false
220
- }
221
-
222
- observer: MutationObserver
223
-
224
- connectedCallback(): void {
225
- super.connectedCallback()
226
- this.observer = new MutationObserver(() => {
227
- if(!this.contentEditable && this.randomOrder) {
228
- this.shuffleTasks()
229
- }
230
- })
231
- this.observer.observe(this, {childList: true})
232
- }
233
-
234
- disconnectedCallback(): void {
235
- super.disconnectedCallback()
236
- this.observer.disconnect()
237
- }
238
-
239
- @query("slot")
240
- accessor slotEl: HTMLSlotElement
241
-
242
- @queryAssignedElements()
243
- accessor tasks: WebwriterTask[]
244
-
245
- @property({type: Boolean, attribute: true, reflect: true})
246
- accessor submitted = false
247
-
248
- get isChanged() {
249
- return this.tasks.some(task => task.isChanged)
250
- }
251
-
252
- render() {
253
- const basicAnswerTypes = Object.keys(this.answerTypes).filter(k => !this.answerTypes[k]?.advanced)
254
- const otherAnswerTypes = Object.keys(this.answerTypes).filter(k => this.answerTypes[k]?.advanced)
255
- return html`
256
- <slot ?inert=${this.submitted} @ww-answer-change=${() => this.requestUpdate()}></slot>
257
- <sl-button-group class="user-only user-actions">
258
- <sl-button id="submit" @click=${this.handleSubmit}>${msg("Submit")}</sl-button>
259
- <sl-button ?disabled=${!this.isChanged} id="reset" @click=${this.handleReset}>${msg("Reset")}</sl-button>
260
- </sl-button-group>
261
- <sl-button-group class="author-only">
262
- ${basicAnswerTypes.map(k => html`
263
- <sl-button @click=${() => this.addTask(k)}>
264
- <sl-icon src=${this.answerTypes[k]?.icon}></sl-icon>
265
- ${this.answerTypes[k].label}
266
- </sl-button>
267
- `)}
268
- <sl-dropdown data-empty=${!otherAnswerTypes.length} placement="bottom-end" hoist>
269
- <sl-button slot="trigger" caret></sl-button>
270
- <sl-menu>
271
- ${otherAnswerTypes.map(k => html`
272
- <sl-menu-item @click=${() => this.addTask(k)}>
273
- <sl-icon src=${this.answerTypes[k]?.icon}></sl-icon>
274
- ${this.answerTypes[k].label}
275
- </sl-menu-item>
276
- `)}
277
- </sl-menu>
278
- </sl-dropdown>
279
- </sl-button-group>
280
- `
281
- }
282
- }
1
+ import { msg } from "@lit/localize";
2
+ import SlButton from "@shoelace-style/shoelace/dist/components/button/button.component.js";
3
+ import SlDialog from "@shoelace-style/shoelace/dist/components/dialog/dialog.component.js";
4
+ import SlDropdown from "@shoelace-style/shoelace/dist/components/dropdown/dropdown.component.js";
5
+ import SlIcon from "@shoelace-style/shoelace/dist/components/icon/icon.component.js";
6
+ import SlProgressBar from "@shoelace-style/shoelace/dist/components/progress-bar/progress-bar.component.js";
7
+ import { LitElementWw, type OptionDeclaration } from "@webwriter/lit";
8
+ import AddIcon from "bootstrap-icons/icons/plus-lg.svg";
9
+ import { css, html } from "lit";
10
+ import { customElement, property, state } from "lit/decorators.js";
11
+ import { createRef, ref } from "lit/directives/ref.js";
12
+ import { getQuizTypes, onQuizTypesChanged, type IWebWriterQuizType, type QuizType } from "../api";
13
+ import type { WebwriterTask } from "./webwriter-task";
14
+
15
+ /**
16
+ * A quiz groups multiple `<webwriter-task>` elements into one exercise that is
17
+ * submitted, graded and reset as a whole.
18
+ *
19
+ * The tasks must be assigned to the default slot.
20
+ */
21
+ @customElement("webwriter-quiz")
22
+ export class WebwriterQuiz extends LitElementWw {
23
+ /** @internal */
24
+ get dynamicOptions(): Record<string, OptionDeclaration> {
25
+ return {
26
+ "detailed-feedback": { type: "boolean", label: { _: msg("Show detailed feedback") } },
27
+ "confirm-submit": { type: "boolean", label: { _: msg("Require confirmation before submitting") } },
28
+ "confirm-reset": { type: "boolean", label: { _: msg("Require confirmation before resetting") } },
29
+ "hide-points": { type: "boolean", label: { _: msg("Hide points") } },
30
+ };
31
+ }
32
+
33
+ /** @internal */
34
+ static scopedElements = {
35
+ "sl-button": SlButton,
36
+ "sl-dialog": SlDialog,
37
+ "sl-icon": SlIcon,
38
+ "sl-dropdown": SlDropdown,
39
+ "sl-progress-bar": SlProgressBar,
40
+ };
41
+
42
+ static styles = css`
43
+ :host {
44
+ container-type: inline-size;
45
+ }
46
+
47
+ .header-placeholder {
48
+ font-weight: bold;
49
+ margin-bottom: var(--sl-spacing-x-small);
50
+ }
51
+
52
+ ::slotted(webwriter-task:not(:last-child)) {
53
+ margin-bottom: var(--sl-spacing-2x-large);
54
+ }
55
+
56
+ .insert-task-grid {
57
+ display: grid;
58
+ grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
59
+ gap: var(--sl-spacing-small);
60
+
61
+ sl-button::part(base) {
62
+ justify-content: flex-start;
63
+ }
64
+ }
65
+
66
+ .add-question {
67
+ margin-top: var(--sl-spacing-small);
68
+
69
+ display: flex;
70
+ justify-content: center;
71
+
72
+ sl-dropdown::part(panel) {
73
+ background-color: var(--sl-color-neutral-0);
74
+ /* Ensure that panel follows the rounded corners of the button */
75
+ border-radius: calc(var(--sl-input-border-radius-medium) + var(--sl-spacing-x-small));
76
+ }
77
+
78
+ .dropdown-container {
79
+ width: 100cqw;
80
+ box-sizing: border-box;
81
+ padding: var(--sl-spacing-x-small);
82
+ }
83
+ }
84
+
85
+ .actions {
86
+ margin-top: 1em;
87
+ display: flex;
88
+ justify-content: space-between;
89
+ align-items: center;
90
+ }
91
+
92
+ .total-result {
93
+ display: flex;
94
+ gap: var(--sl-spacing-small);
95
+ align-items: center;
96
+
97
+ sl-progress-bar {
98
+ width: 300px;
99
+ }
100
+ }
101
+ `;
102
+
103
+ /**
104
+ * Whether learners see per-answer feedback and the solution after submitting.
105
+ */
106
+ @property({ type: Boolean, attribute: "detailed-feedback", reflect: true })
107
+ accessor detailedFeedback: boolean = false;
108
+
109
+ /**
110
+ * Whether learners have to confirm a dialog before submitting.
111
+ */
112
+ @property({ type: Boolean, attribute: "confirm-submit", reflect: true })
113
+ accessor confirmSubmit: boolean = false;
114
+
115
+ /**
116
+ * Whether learners have to confirm a dialog before resetting.
117
+ */
118
+ @property({ type: Boolean, attribute: "confirm-reset", reflect: true })
119
+ accessor confirmReset: boolean = false;
120
+
121
+ /**
122
+ * Whether the points of each task and the total score are hidden.
123
+ */
124
+ @property({ type: Boolean, attribute: "hide-points", reflect: true })
125
+ accessor hidePoints: boolean = false;
126
+
127
+ @state() private accessor quizTypes: readonly QuizType[] = getQuizTypes();
128
+
129
+ @state() private accessor totalResult: { score: number; maximum: number } | undefined;
130
+
131
+ @state() private accessor confirmationAction: "submit" | "reset" | undefined;
132
+
133
+ private unsubscribe?: () => void;
134
+
135
+ private addQuestionDropdownRef = createRef<SlDropdown>();
136
+
137
+ connectedCallback() {
138
+ super.connectedCallback();
139
+ this.unsubscribe = onQuizTypesChanged(types => {
140
+ this.quizTypes = [...types];
141
+ });
142
+ }
143
+
144
+ disconnectedCallback() {
145
+ super.disconnectedCallback();
146
+ this.unsubscribe?.();
147
+ this.quizTypes = [];
148
+ }
149
+
150
+ protected updated(changedProperties: Map<PropertyKey, unknown>) {
151
+ if (changedProperties.has("hidePoints")) {
152
+ this.getTasks().forEach(task => task.requestUpdate?.());
153
+ }
154
+ }
155
+
156
+ private insertQuizType(quizType: QuizType) {
157
+ const task = document.createElement("webwriter-task");
158
+
159
+ const prompt = document.createElement("webwriter-task-prompt");
160
+ prompt.appendChild(document.createElement("p"));
161
+ task.appendChild(prompt);
162
+ const quiz = quizType.createInstance();
163
+ task.appendChild(quiz);
164
+ this.appendChild(task);
165
+
166
+ this.addQuestionDropdownRef.value?.hide();
167
+ this.requestUpdate();
168
+ }
169
+
170
+ private InsertQuestionGrid() {
171
+ return html`<div class="insert-task-grid">
172
+ ${this.quizTypes.map(
173
+ quizType =>
174
+ html`<sl-button @click=${() => this.insertQuizType(quizType)}>
175
+ <sl-icon src=${quizType.icon ?? ""} slot="prefix"></sl-icon>
176
+ ${quizType.getName()}
177
+ </sl-button>`,
178
+ )}
179
+ </div>`;
180
+ }
181
+
182
+ private getTasks() {
183
+ return Array.from(this.children).filter((child): child is WebwriterTask => child.tagName === "WEBWRITER-TASK");
184
+ }
185
+
186
+ private getTaskApi(task: WebwriterTask): IWebWriterQuizType | undefined {
187
+ return Array.from(task.children).find(
188
+ (child): child is HTMLElement & IWebWriterQuizType =>
189
+ typeof (child as Partial<IWebWriterQuizType>).checkValidity === "function",
190
+ );
191
+ }
192
+
193
+ private getTaskPoints(task: WebwriterTask) {
194
+ const points = Number(task.getAttribute("points") ?? 1);
195
+ return Number.isFinite(points) && points > 0 ? points : 1;
196
+ }
197
+
198
+ private formatScore(score: number) {
199
+ return Number(score.toFixed(2));
200
+ }
201
+
202
+ private getResultPercentage(result: { score: number; maximum: number }) {
203
+ if (result.maximum <= 0) return 0;
204
+ return (result.score / result.maximum) * 100;
205
+ }
206
+
207
+ private resetAnswers() {
208
+ for (const task of this.getTasks()) {
209
+ this.getTaskApi(task)?.reset();
210
+ task.resetFeedback();
211
+ }
212
+ this.totalResult = undefined;
213
+ }
214
+
215
+ private submit() {
216
+ const tasksWithApi = this.getTasks().map(task => ({
217
+ task,
218
+ api: this.getTaskApi(task),
219
+ }));
220
+ const validities = tasksWithApi.map(({ task, api }) => {
221
+ const valid = api?.checkValidity() ?? false;
222
+ task.showRequiredWarning(!valid);
223
+ task.showScore(undefined);
224
+ return valid;
225
+ });
226
+
227
+ this.totalResult = undefined;
228
+ if (validities.some(valid => !valid)) return;
229
+
230
+ let score = 0;
231
+ let maximum = 0;
232
+ for (const { task, api } of tasksWithApi) {
233
+ const answerScore = api?.checkAnswer?.(this.detailedFeedback);
234
+ if (answerScore === undefined) continue;
235
+
236
+ const points = this.getTaskPoints(task);
237
+ const taskScore = Math.max(0, Math.min(1, answerScore)) * points;
238
+ task.showScore(taskScore);
239
+ score += taskScore;
240
+ maximum += points;
241
+ }
242
+ this.totalResult = { score, maximum };
243
+ }
244
+
245
+ private ConfirmationDialog() {
246
+ const isSubmit = this.confirmationAction === "submit";
247
+
248
+ const cancel = () => (this.confirmationAction = undefined);
249
+ const confirm = () => {
250
+ const action = this.confirmationAction;
251
+ this.confirmationAction = undefined;
252
+ if (action === "submit") this.submit();
253
+ else if (action === "reset") this.resetAnswers();
254
+ };
255
+
256
+ return html`<sl-dialog
257
+ label=${isSubmit ? msg("Submit answers?") : msg("Reset answers?")}
258
+ ?open=${this.confirmationAction !== undefined}
259
+ @sl-request-close=${cancel}
260
+ @sl-after-hide=${cancel}
261
+ >
262
+ ${isSubmit ? msg("Submit your answers for evaluation?") : msg("Reset all answers and start over?")}
263
+ <sl-button slot="footer" @click=${cancel}>${msg("Cancel")}</sl-button>
264
+ <sl-button slot="footer" variant=${isSubmit ? "primary" : "danger"} @click=${confirm}>
265
+ ${isSubmit ? msg("Submit") : msg("Reset answers")}
266
+ </sl-button>
267
+ </sl-dialog>`;
268
+ }
269
+
270
+ render() {
271
+ if (!this.isContentEditable) {
272
+ return html`<slot></slot>
273
+
274
+ <div class="actions">
275
+ ${this.totalResult
276
+ ? html`<div class="total-result">
277
+ <sl-progress-bar .value=${this.getResultPercentage(this.totalResult)}></sl-progress-bar>
278
+ <strong
279
+ >${this.formatScore(this.totalResult.score)} / ${this.formatScore(this.totalResult.maximum)}</strong
280
+ >
281
+ </div>`
282
+ : html`<sl-button
283
+ variant="primary"
284
+ @click=${() => (this.confirmSubmit ? (this.confirmationAction = "submit") : this.submit())}
285
+ >
286
+ ${msg("Submit")}
287
+ </sl-button>`}
288
+ <sl-button @click=${() => (this.confirmReset ? (this.confirmationAction = "reset") : this.resetAnswers())}>
289
+ ${msg("Reset answers")}
290
+ </sl-button>
291
+ </div>
292
+
293
+ ${this.ConfirmationDialog()}`;
294
+ }
295
+
296
+ if (this.children.length > 0) {
297
+ return html`<slot
298
+ @slotchange=${() => {
299
+ // Update numbering in the quiz header in case the order changed
300
+ Array.from(this.children)
301
+ .filter(c => c.tagName === "WEBWRITER-TASK")
302
+ .forEach(t => (t as WebwriterTask).requestUpdate?.());
303
+ this.requestUpdate();
304
+ }}
305
+ ></slot>
306
+ <div class="add-question">
307
+ <sl-dropdown placement="bottom" distance="4" ${ref(this.addQuestionDropdownRef)}>
308
+ <sl-button slot="trigger" caret>
309
+ <sl-icon slot="prefix" src=${AddIcon}></sl-icon>
310
+ ${msg("Add Question")}
311
+ </sl-button>
312
+ <div class="dropdown-container">${this.InsertQuestionGrid()}</div>
313
+ </sl-dropdown>
314
+ </div>`;
315
+ } else {
316
+ return html`<div class="header-placeholder">${msg("Question 1")}</div>
317
+ ${this.InsertQuestionGrid()}`;
318
+ }
319
+ }
320
+ }