diff --git a/packages/cutie-client/src/styles/baseStyles.ts b/packages/cutie-client/src/styles/baseStyles.ts index fdb7319..fc321e8 100644 --- a/packages/cutie-client/src/styles/baseStyles.ts +++ b/packages/cutie-client/src/styles/baseStyles.ts @@ -27,6 +27,29 @@ const BASE_STYLES = ` max-width: 100%; height: auto; } + + .cutie-item-container table { + border-collapse: collapse; + margin: 0.75rem 0; + } + + .cutie-item-container caption { + text-align: left; + font-weight: 600; + padding: 0.5rem 0; + } + + .cutie-item-container th, + .cutie-item-container td { + border: 1px solid var(--cutie-border); + padding: 0.5rem 0.75rem; + text-align: left; + vertical-align: top; + } + + .cutie-item-container thead th { + background-color: var(--cutie-bg-alt); + } `; export function registerBaseStyles(styleManager: StyleManager): void { diff --git a/packages/cutie-client/src/transformer/handlers/hottextInteraction.spec.ts b/packages/cutie-client/src/transformer/handlers/hottextInteraction.spec.ts new file mode 100644 index 0000000..5712c23 --- /dev/null +++ b/packages/cutie-client/src/transformer/handlers/hottextInteraction.spec.ts @@ -0,0 +1,503 @@ +/* spell-checker: ignore hottext radiogroup */ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { ItemStateImpl } from '../../state/itemState'; +import { registry } from '../registry'; +import type { TransformContext } from '../types'; + +// Side-effect import to register both handlers (hottext + hottext-interaction) +import './hottextInteraction'; + +function createQtiDocument(interactionHtml: string, responseDeclaration = ''): Document { + const html = ` + + + ${responseDeclaration} + ${interactionHtml} + + + `; + const parser = new DOMParser(); + return parser.parseFromString(html, 'text/html'); +} + +/** + * Build a transformChildren that routes element children through the registry + * so the HottextHandler runs and the parent can find [data-hottext-identifier]. + * Elements without a handler are shallow-cloned and their children recursed, + * mirroring the real transformer's passthrough behavior. + */ +function makeTransformChildren(context: TransformContext): (el: Element) => DocumentFragment { + const transform = (el: Element): DocumentFragment => { + const frag = document.createDocumentFragment(); + for (const node of Array.from(el.childNodes)) { + if (node.nodeType === Node.ELEMENT_NODE) { + const child = node as Element; + const handler = registry.getAll().find((r) => r.handler.canHandle(child)); + if (handler) { + frag.appendChild(handler.handler.transform(child, context)); + } else { + const clone = child.cloneNode(false) as Element; + clone.appendChild(transform(child)); + frag.appendChild(clone); + } + } else { + frag.appendChild(node.cloneNode(true)); + } + } + return frag; + }; + return transform; +} + +function transformInteraction(doc: Document, itemState: ItemStateImpl): DocumentFragment { + const interaction = doc.querySelector('qti-hottext-interaction')!; + + const context: TransformContext = { itemState }; + context.transformChildren = makeTransformChildren(context); + + const handler = registry.getAll().find((r) => r.handler.canHandle(interaction)); + return handler!.handler.transform(interaction, context); +} + +function render(doc: Document, itemState: ItemStateImpl): HTMLElement { + const fragment = transformInteraction(doc, itemState); + const container = document.createElement('div'); + container.appendChild(fragment); + return container; +} + +// `.focus()` and `document.activeElement` only work for elements attached to +// the document, so arrow/focus tests attach the rendered container and clean up +// in an afterEach. +const attached: HTMLElement[] = []; +function attach(container: HTMLElement): HTMLElement { + document.body.appendChild(container); + attached.push(container); + return container; +} + +const MULTI_DECL = ` + +`; +const SINGLE_DECL = ` + +`; + +const passage = ` +

+ Alpha Item A + beta Item B + gamma Item C + delta Item D. +

+`; + +describe('hottextInteraction', () => { + let itemState: ItemStateImpl; + + beforeEach(() => { + itemState = new ItemStateImpl(); + }); + + afterEach(() => { + while (attached.length) attached.pop()!.remove(); + }); + + describe('rendering', () => { + it('renders a toggle button for each qti-hottext', () => { + const doc = createQtiDocument( + `${passage}`, + MULTI_DECL + ); + const container = render(doc, itemState); + + const buttons = container.querySelectorAll('.cutie-hottext'); + expect(buttons.length).toBe(4); + // Rendered as inline (not