@webwriter/quiz
Version:
Add interactive tasks (multiple choice, order, free text, highlighting, or speech input). Make a quiz out of multiple tasks.
456 lines (376 loc) • 13 kB
text/typescript
import { LitElement, html, css, CSSResultArray, CSSResult, PropertyValueMap, PropertyValues } from "lit"
import { customElement, property, query, queryAssignedElements } from "lit/decorators.js"
import SlInput from "@shoelace-style/shoelace/dist/components/input/input.component.js"
import SlOption from "@shoelace-style/shoelace/dist/components/option/option.component.js"
import IconChevronDown from "bootstrap-icons/icons/chevron-down.svg"
import { ifDefined } from "lit/directives/if-defined.js"
import { styleMap } from "lit/directives/style-map.js"
("ww-combobox")
export class Combobox extends LitElement {
static shadowRootOptions = {...LitElement.shadowRootOptions, delegatesFocus: true}
constructor() {
super()
this.addEventListener("focus", e => {this.active = true; this.open = true})
this.addEventListener("focusout", e => {
this.active = this.open = false
})
this.addEventListener("blur", e => {
this.active = this.open = false
})
this.value = this.defaultValue = this.multiple? []: ""
}
tabIndex = 0
({attribute: false}) //@ts-ignore
accessor value: string[] | string
({attribute: false}) //@ts-ignore
accessor defaultValue: string[] | string
("input") //@ts-ignore
accessor input: HTMLInputElement
("#toggle")
accessor toggleButton: HTMLInputElement
("slot:not([name])")
accessor slotElement: HTMLSlotElement
()
accessor optionElements: HTMLElement[]
({type: String, attribute: true}) //@ts-ignore
accessor autocomplete: SlInput["autocomplete"] = "off"
({type: Boolean, attribute: true, reflect: true})
accessor active: boolean
({type: Boolean, attribute: true, reflect: true})
accessor inputDisabled: boolean = false
({type: Boolean, attribute: true, reflect: true})
accessor multiple: boolean = false
({type: String}) //@ts-ignore
accessor placeholder: string
({type: String, attribute: true, reflect: true})
accessor emptyValue: string
({type: Boolean, attribute: true, reflect: true})
accessor open: boolean = false
({type: Boolean, attribute: true, reflect: true})
accessor suggestions: boolean = false
({type: String, attribute: "help-text"}) //@ts-ignore
accessor helpText: string
({type: Number, attribute: true})
accessor fixLength: number
({type: String, attribute: true})
accessor separator: string = " "
({type: String, attribute: true})
accessor placement = "bottom" as const
({type: Boolean, attribute: true, reflect: true})
accessor autosize = false
({type: Boolean, attribute: true, reflect: true})
accessor disabled = false
({type: Boolean, attribute: true, reflect: true})
accessor required = false
({type: String, attribute: true, reflect: true})
accessor type: "button" | "checkbox" | "color" | "date" | "datetime-local" | "email" | "file" | "hidden" | "image" | "month" | "number" | "password" | "radio" | "range" | "reset" | "search" | "submit" | "tel" | "text" | "time" | "url" | "week"
({type: String, attribute: true, reflect: true})
accessor label: string
get validity() {
return this.input.validity
}
get validationMessage() {
return this.input.validationMessage
}
setValidity(isValid: boolean) {
const host = this
const required = Boolean(this.required)
host.toggleAttribute('data-required', required)
host.toggleAttribute('data-optional', !required)
host.toggleAttribute('data-invalid', !isValid)
host.toggleAttribute('data-valid', isValid)
}
focus() {
if(this.inputDisabled && this.suggestions) {
this.open = !this.open
super.focus()
}
else {
this.input?.focus()
}
}
blur() {
this.open = false
}
getForm() {
return this.input.form
}
static styles: CSSResult | CSSResultArray = [SlInput.styles, css`
* {
font-family: var(--sl-font-sans);
}
:host {
display: flex;
flex-direction: column;
}
[part=base] {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
position: relative;
border: solid var(--sl-input-border-width) var(--sl-input-border-color);
border-radius: var(--sl-input-border-radius-medium);
max-width: 100%;
min-height: var(--sl-input-height-medium);
padding: 0 var(--sl-input-spacing-medium);
padding-right: 0;
background: white;
}
:host(:not([inputDisabled]):not([disabled])) {
cursor: text;
}
:host([inputDisabled]:not([disabled])), :host([inputDisabled]:not([disabled])) input {
cursor: pointer;
}
:host([multiple]) [part=base] {
flex-wrap: wrap;
}
:host([filled]) [part=base] {
background: var(--sl-input-filled-background-color);
}
:host([active]) [part=base] {
border-color: var(--sl-input-border-color-focus);
box-shadow: 0 0 0 var(--sl-focus-ring-width) var(--sl-input-focus-ring-color);
}
:host([disabled]) [part=base] {
background: none;
}
:host(:hover) [part=base] {
border-color: var(--sl-color-gray-400);
}
#values {
display: flex;
flex-direction: row;
align-items: center;
gap: 0.25rem;
padding-top: 0.25rem;
padding-bottom: 0.25rem;
flex-wrap: wrap;
}
#values > * {
margin-top: 0.125rem;
margin-bottom: 0.125rem;
}
#options {
display: block;
font-family: var(--sl-font-sans);
font-size: var(--sl-font-size-medium);
font-weight: var(--sl-font-weight-normal);
box-shadow: var(--sl-shadow-large);
background: var(--sl-panel-background-color);
border: solid var(--sl-panel-border-width) var(--sl-panel-border-color);
border-radius: var(--sl-border-radius-medium);
padding-block: var(--sl-spacing-x-small);
padding-inline: 0;
overflow: auto;
overscroll-behavior: none;
width: 100%;
max-height: var(--auto-size-available-height);
}
:host(:is(:not([open]), :not([suggestions]))) #options {
display: none;
}
#options::part(base) {
width: 100%;
border-top: none;
}
input {
flex-grow: 1;
display: inline-flex;
border: none;
outline: none;
background: transparent;
font-size: var(--sl-input-font-size-small);
}
:host(:not([suggestions])) sl-menu, :host(:not([suggestions])) #toggle {
display: none;
}
#toggle {
padding-right: var(--sl-input-spacing-medium);
}
:host([size=small]) #toggle {
padding-right: var(--sl-spacing-2x-small);
font-size: var(--sl-input-font-size-small);
}
#toggle::part(base) {
padding: 0;
}
#toggle::part(base) {
transition: transform 0.25s ease;
}
:host([open]) #toggle::part(base) {
transform: rotate(180deg);
}
:host([open]) {
z-index: 100;
}
sl-dropdown::part(panel) {
width: 100%;
}
[part=suffix] {
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-end;
max-width: 50%;
flex-grow: 0;
}
[part=label]::slotted(*), label {
align-self: flex-start;
margin-bottom: var(--sl-spacing-3x-small);
}
:host([required]) [part=label]::after {
content: "*";
margin-left: 0.25ch;
}
:host(:not([help-text])) #help-text {
display: none;
}
#help-text {
font-size: var(--sl-input-help-text-font-size-medium);
color: var(--sl-input-help-text-color);
margin-top: var(--sl-spacing-3x-small);
}
slot[name=prefix]::slotted(:last-child) {
margin-right: 1ch;
}
:host([size=small]) [part=base] {
min-height: var(--sl-input-height-small);
padding: 0 var(--sl-input-spacing-small);
}
#hide {
height: 0;
position: absolute;
right: 0;
bottom: 0;
white-space: pre;
overflow: hidden;
font-size: var(--sl-input-font-size-small);
}
`]
handleTextInput(e: Event) {
const input = e.target as HTMLInputElement
e.preventDefault()
if(!this.multiple && this.fixLength && input.value.length < this.fixLength) {
input.value = this.value as string
}
else if(!this.multiple) {
this.value = input.value
}
else if(input.value === this.separator) {
input.value = ""
}
else {
this.value = [...this.valueList.slice(0, -1), ...input.value.split(this.separator)]
input.value = this.valueList.at(-1)!
}
this.dispatchInput()
this.setValidity(this.validity.valid)
}
handleInputKeydown(e: KeyboardEvent) {
const input = e.target as HTMLInputElement
if(this.multiple && e.key === "Backspace" && input.selectionStart === 0 && input.selectionEnd === 0) {
e.preventDefault()
this.value = this.valueList.slice(0, -1)
}
this.requestUpdate()
}
handleOptionClick = (e: PointerEvent) => {
const option = (e.target as HTMLElement).closest("sl-option") as SlOption | null
if(option) {
this.value = option.value
this.focus()
this.open = false
this.dispatchChange()
this.requestUpdate()
}
}
dispatchChange() {
this.dispatchEvent(new CustomEvent("sl-change", {composed: true, bubbles: true}))
}
dispatchInput() {
this.dispatchEvent(new CustomEvent("sl-input", {composed: true, bubbles: true}))
}
get valueList() {
return (this.multiple? this.value || []: [this.value]) as string[]
}
get inputSize() {
return !this.multiple? undefined: Math.min(
Math.max(this.placeholder?.length ?? 1, this.valueList.at(-1)!?.length ?? 1 - 1),
40
)
}
protected firstUpdated(_changedProperties: PropertyValues): void {
super.firstUpdated(_changedProperties)
this.requestUpdate()
}
connectedCallback(): void {
super.connectedCallback()
this.requestUpdate()
}
("#hide")
accessor hideEl: HTMLElement
("input")
accessor inputEl: HTMLInputElement
get inputWidth() {
return this.hideEl? this.hideEl.offsetWidth: 0
}
get inputValue() {
const value = this.multiple? this.valueList.at(-1) ?? "": String(this.value ?? "")
return value !== this.emptyValue? value: ""
}
render() {
const input = html`
<input
part="input"
type=${this.type}
size=${ifDefined(this.inputSize)}
.value=${this.inputValue}
placeholder=${this.multiple && this.valueList.length > 1? "": this.placeholder}
?disabled=${this.disabled || this.inputDisabled}
@input=${this.handleTextInput}
@change=${this.dispatchChange}
@keydown=${this.handleInputKeydown}
autocomplete=${this.autocomplete as any}
style=${this.autosize? styleMap({width: `calc(${this.inputWidth}px + 2ch)`}): undefined}
>
`
return html`
<slot name="label" part="label" @click=${this.focus}>
<label>${this.label}</label>
</slot>
<div part="base" id="anchor" tabindex=${0}>
<slot name="prefix"></slot>
${this.multiple? html`
<span id="values">
<slot name="values"></slot>
${this.valueList.slice(0, -1).map(v => html`
<sl-tag size="small" variant="primary">${v}</sl-tag>
`)}
${input}
</span>
`: input}
<div part="suffix">
<slot name="suffix"></slot>
<sl-icon-button
slot="trigger"
part="trigger"
id="toggle"
src=${IconChevronDown}
@focus=${(e: Event) => e.stopPropagation()}
@click=${(e: Event) => {this.open = !this.open; e.stopPropagation()}}
@mousedown=${(e: Event) => {e.stopPropagation(); e.preventDefault()}}
></sl-icon-button>
</div>
</div>
<sl-popup anchor="anchor" placement=${this.placement} strategy="fixed" ?active=${this.open} auto-size="vertical" auto-size-padding="10" flip shift>
<sl-menu id="options" @click=${this.handleOptionClick}>
<slot></slot>
</sl-menu>
</sl-popup>
<div id="help-text">${this.helpText}</div>
<span id="hide">${this.inputEl?.value}</span>
`
}
}