@webwriter/quiz 1.1.5 → 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 (165) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +778 -0
  3. package/assets/icons/grip-horizontal.svg +8 -0
  4. package/assets/icons/grip-vertical.svg +8 -0
  5. package/assets/icons/truefalse.svg +6 -0
  6. package/custom-elements.json +2779 -0
  7. package/custom.d.ts +5 -1
  8. package/dist/api/index.d.ts +27 -0
  9. package/dist/api/index.js +40 -0
  10. package/dist/migrate.js +1434 -0
  11. package/dist/widgets/webwriter-choice-item.js +4095 -3818
  12. package/dist/widgets/webwriter-choice.js +2179 -5598
  13. package/dist/widgets/webwriter-gap-item.js +3987 -0
  14. package/dist/widgets/webwriter-gap.js +2908 -0
  15. package/dist/widgets/webwriter-mark.js +4347 -3368
  16. package/dist/widgets/webwriter-order-item.js +4038 -3197
  17. package/dist/widgets/webwriter-order.js +2885 -4200
  18. package/dist/widgets/webwriter-pairing-item.js +1070 -2330
  19. package/dist/widgets/webwriter-pairing.js +2968 -4231
  20. package/dist/widgets/webwriter-quiz-hint.js +2469 -0
  21. package/dist/widgets/webwriter-quiz.js +3337 -4364
  22. package/dist/widgets/webwriter-speech.js +6100 -5120
  23. package/dist/widgets/webwriter-task-prompt.js +4787 -2068
  24. package/dist/widgets/webwriter-task.js +2264 -8157
  25. package/dist/widgets/webwriter-text.js +4075 -4132
  26. package/dist/widgets/webwriter-true-false.js +4674 -0
  27. package/editing-config.json +106 -0
  28. package/lit-localize.json +20 -14
  29. package/localization/de.xlf +395 -65
  30. package/localization/es.xlf +396 -61
  31. package/localization/fr.xlf +396 -61
  32. package/localization/generated/de.ts +97 -15
  33. package/localization/generated/es.ts +97 -15
  34. package/localization/generated/fr.ts +98 -16
  35. package/localization/generated/index.d.ts +5 -0
  36. package/localization/generated/it.ts +96 -14
  37. package/localization/generated/locale-codes.js +0 -48
  38. package/localization/generated/nl.ts +99 -17
  39. package/localization/it.xlf +396 -61
  40. package/localization/nl.xlf +396 -61
  41. package/package.json +120 -254
  42. package/scripts/apply-translations.mjs +97 -0
  43. package/src/api/index.ts +72 -0
  44. package/src/lib/artboard.ts +686 -0
  45. package/src/lib/draggable-grid.ts +432 -0
  46. package/src/lib/encrypted-property.ts +202 -0
  47. package/src/lib/hint-popup.ts +152 -0
  48. package/src/lib/utils.ts +97 -0
  49. package/src/lib/webwriter-quirks.ts +121 -0
  50. package/src/migrate/index.ts +622 -0
  51. package/src/migrate/legacy-solution.ts +362 -0
  52. package/src/snippets/order.html +8 -7
  53. package/src/widgets/choice/webwriter-choice-item.ts +316 -0
  54. package/src/widgets/choice/webwriter-choice.ts +328 -0
  55. package/src/widgets/choice/webwriter-true-false.ts +113 -0
  56. package/src/widgets/dnd/webwriter-dnd-draggable.ts +28 -0
  57. package/src/widgets/dnd/webwriter-dnd-dropzone.ts +9 -0
  58. package/src/widgets/dnd/webwriter-dnd.ts +36 -0
  59. package/src/widgets/gap/webwriter-gap-item.ts +250 -0
  60. package/src/widgets/gap/webwriter-gap.ts +447 -0
  61. package/src/widgets/order/webwriter-order-item.ts +243 -0
  62. package/src/widgets/order/webwriter-order.ts +282 -0
  63. package/src/widgets/pairing/webwriter-pairing-item.ts +46 -0
  64. package/src/widgets/pairing/webwriter-pairing.ts +934 -0
  65. package/src/widgets/speech/audio-player.ts +325 -0
  66. package/src/widgets/speech/audio-recorder.ts +285 -0
  67. package/src/widgets/speech/waveform.ts +113 -0
  68. package/src/widgets/speech/webwriter-speech.ts +87 -0
  69. package/src/widgets/webwriter-mark.ts +366 -424
  70. package/src/widgets/webwriter-quiz-hint.ts +36 -0
  71. package/src/widgets/webwriter-quiz.ts +318 -280
  72. package/src/widgets/webwriter-task-prompt.ts +43 -18
  73. package/src/widgets/webwriter-task.ts +212 -551
  74. package/src/widgets/webwriter-text.ts +286 -172
  75. package/tsconfig.api.json +12 -0
  76. package/tsconfig.json +7 -6
  77. package/dist/widgets/webwriter-choice.css +0 -444
  78. package/dist/widgets/webwriter-cloze-gap.css +0 -444
  79. package/dist/widgets/webwriter-cloze-gap.js +0 -5727
  80. package/dist/widgets/webwriter-cloze.css +0 -444
  81. package/dist/widgets/webwriter-cloze.js +0 -3653
  82. package/dist/widgets/webwriter-mark.css +0 -444
  83. package/dist/widgets/webwriter-order.css +0 -444
  84. package/dist/widgets/webwriter-pairing-item.css +0 -444
  85. package/dist/widgets/webwriter-pairing.css +0 -444
  86. package/dist/widgets/webwriter-quiz.css +0 -444
  87. package/dist/widgets/webwriter-task-explainer.css +0 -444
  88. package/dist/widgets/webwriter-task-explainer.js +0 -2769
  89. package/dist/widgets/webwriter-task-hint.css +0 -444
  90. package/dist/widgets/webwriter-task-hint.js +0 -2708
  91. package/dist/widgets/webwriter-task.css +0 -444
  92. package/dist/widgets/webwriter-text.css +0 -444
  93. package/localization/bg.xlf +0 -72
  94. package/localization/cs.xlf +0 -72
  95. package/localization/da.xlf +0 -72
  96. package/localization/el.xlf +0 -72
  97. package/localization/et.xlf +0 -72
  98. package/localization/fi.xlf +0 -72
  99. package/localization/generated/bg.ts +0 -31
  100. package/localization/generated/cs.ts +0 -31
  101. package/localization/generated/da.ts +0 -31
  102. package/localization/generated/el.ts +0 -31
  103. package/localization/generated/et.ts +0 -31
  104. package/localization/generated/fi.ts +0 -31
  105. package/localization/generated/hu.ts +0 -31
  106. package/localization/generated/id.ts +0 -31
  107. package/localization/generated/ja.ts +0 -31
  108. package/localization/generated/ko.ts +0 -31
  109. package/localization/generated/lt.ts +0 -31
  110. package/localization/generated/lv.ts +0 -31
  111. package/localization/generated/nb.ts +0 -31
  112. package/localization/generated/pl.ts +0 -31
  113. package/localization/generated/pt-BR.ts +0 -31
  114. package/localization/generated/pt-PT.ts +0 -31
  115. package/localization/generated/ro.ts +0 -31
  116. package/localization/generated/ru.ts +0 -31
  117. package/localization/generated/sk.ts +0 -31
  118. package/localization/generated/sl.ts +0 -31
  119. package/localization/generated/sv.ts +0 -31
  120. package/localization/generated/tr.ts +0 -31
  121. package/localization/generated/uk.ts +0 -31
  122. package/localization/generated/zh-hans.ts +0 -31
  123. package/localization/hu.xlf +0 -72
  124. package/localization/id.xlf +0 -72
  125. package/localization/ja.xlf +0 -72
  126. package/localization/ko.xlf +0 -72
  127. package/localization/lt.xlf +0 -72
  128. package/localization/lv.xlf +0 -72
  129. package/localization/nb.xlf +0 -72
  130. package/localization/pl.xlf +0 -72
  131. package/localization/pt-BR.xlf +0 -72
  132. package/localization/pt-PT.xlf +0 -72
  133. package/localization/ro.xlf +0 -72
  134. package/localization/ru.xlf +0 -72
  135. package/localization/sk.xlf +0 -72
  136. package/localization/sl.xlf +0 -72
  137. package/localization/sv.xlf +0 -72
  138. package/localization/tr.xlf +0 -72
  139. package/localization/uk.xlf +0 -72
  140. package/localization/zh-hans.xlf +0 -72
  141. package/src/lib/combobox.ts +0 -456
  142. package/src/lib/highlighter-fill.svg +0 -6
  143. package/src/snippets/Beispiel-Choice.html +0 -57
  144. package/src/snippets/Beispiel-Mark.html +0 -14
  145. package/src/snippets/Beispiel-Order.html +0 -52
  146. package/src/snippets/Beispiel-Text.html +0 -20
  147. package/src/snippets/blank.html +0 -1
  148. package/src/snippets/choice.html +0 -8
  149. package/src/snippets/cloze.html +0 -4
  150. package/src/snippets/mark.html +0 -4
  151. package/src/snippets/pairing.html +0 -10
  152. package/src/snippets/speech.html +0 -4
  153. package/src/snippets/text.html +0 -4
  154. package/src/snippets/wordsearch.html +0 -4
  155. package/src/widgets/webwriter-choice-item.ts +0 -257
  156. package/src/widgets/webwriter-choice.ts +0 -353
  157. package/src/widgets/webwriter-cloze-gap.ts +0 -216
  158. package/src/widgets/webwriter-cloze.ts +0 -136
  159. package/src/widgets/webwriter-order-item.ts +0 -441
  160. package/src/widgets/webwriter-order.ts +0 -327
  161. package/src/widgets/webwriter-pairing-item.ts +0 -161
  162. package/src/widgets/webwriter-pairing.ts +0 -193
  163. package/src/widgets/webwriter-speech.ts +0 -273
  164. package/src/widgets/webwriter-task-explainer.ts +0 -43
  165. 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
-
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
+ */
41
21
  @customElement("webwriter-quiz")
42
22
  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
- }
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
+ }