parent
5b1308bdf3
commit
f0a46781de
@ -1,6 +1,24 @@ |
|||||||
|
function onPulseEnd(e: AnimationEvent): void { |
||||||
|
if (e.animationName === "pulse-bg" && e.target instanceof HTMLElement) { |
||||||
|
e.target.classList.remove("pulse") |
||||||
|
e.target.removeEventListener("animationend", onPulseEnd) |
||||||
|
e.target.removeEventListener("animationcancel", onPulseEnd) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
export function pulseElement(element: HTMLElement) { |
export function pulseElement(element: HTMLElement) { |
||||||
|
element.removeEventListener("animationend", onPulseEnd) |
||||||
|
element.removeEventListener("animationcancel", onPulseEnd) |
||||||
|
if (element.classList.contains("pulse")) { |
||||||
|
const anim = |
||||||
|
element.getAnimations().find(v => v instanceof CSSAnimation && v.animationName === "pulse-bg") |
||||||
|
if (anim) { |
||||||
|
anim.finish() |
||||||
|
anim.play() |
||||||
|
} |
||||||
|
} else { |
||||||
element.classList.add("pulse") |
element.classList.add("pulse") |
||||||
element.style.animation = "none"; |
} |
||||||
getComputedStyle(element).animation |
element.addEventListener("animationend", onPulseEnd) |
||||||
setTimeout(element.style.animation = "") |
element.addEventListener("animationcancel", onPulseEnd) |
||||||
} |
} |
||||||
|
@ -1,16 +1,82 @@ |
|||||||
import type { RollTable, RollTableDatabase } from '../common/rolltable'; |
import type { RollTableDatabase, RollTableDetailsAndResults, RollTableResultFull } from '../common/rolltable'; |
||||||
import { DOMLoaded } from './onload'; |
import { DOMLoaded } from './onload'; |
||||||
|
import { scrapeResponseLists } from './scraper'; |
||||||
|
import { htmlTableIdentifier } from '../common/template'; |
||||||
|
import escapeHTML from 'escape-html'; |
||||||
|
|
||||||
class ResponseList { |
class ResponseLists { |
||||||
readonly db: RollTableDatabase |
readonly db: RollTableDatabase |
||||||
constructor(db: RollTableDatabase) { |
readonly listsElement: HTMLElement |
||||||
|
constructor(db: RollTableDatabase, listsElement: HTMLElement) { |
||||||
this.db = db |
this.db = db |
||||||
|
this.listsElement = listsElement |
||||||
|
} |
||||||
|
|
||||||
|
addSelectionListener(listener: (e: CustomEvent<RollTableResultFull<RollTableDetailsAndResults>>) => void, options?: boolean|EventListenerOptions): void { |
||||||
|
this.listsElement.addEventListener("resultselected", listener, options) |
||||||
|
} |
||||||
|
|
||||||
|
configureHandlers(): this { |
||||||
|
this.listsElement.addEventListener("click", (e) => { |
||||||
|
if (e.target instanceof HTMLElement && e.target.classList.contains("makeResponseActive")) { |
||||||
|
const response = e.target.closest(`.response`) |
||||||
|
if (!response) { |
||||||
|
console.log("no response") |
||||||
|
return |
||||||
|
} |
||||||
|
const mappingId = response.id && response.id.startsWith("response-") ? parseInt(response.id.substring("response-".length), 10) : NaN |
||||||
|
if (isNaN(mappingId)) { |
||||||
|
console.log("no mapping ID") |
||||||
|
return |
||||||
|
} |
||||||
|
const result = this.db.mappings.get(mappingId) |
||||||
|
if (!result) { |
||||||
|
console.log("no result") |
||||||
|
return |
||||||
|
} |
||||||
|
const ev = new CustomEvent<RollTableResultFull<RollTableDetailsAndResults>>("resultselected", { |
||||||
|
bubbles: true, |
||||||
|
cancelable: true, |
||||||
|
detail: result |
||||||
|
}) |
||||||
|
if (e.target.dispatchEvent(ev)) { |
||||||
|
this.setActiveElementForTable(result) |
||||||
|
const button = response.querySelector(`.resultText`) as HTMLElement |
||||||
|
if (button) { |
||||||
|
button.focus() |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
}) |
||||||
|
return this |
||||||
|
} |
||||||
|
|
||||||
|
setActiveElementForTable(result: RollTableResultFull<RollTableDetailsAndResults>) { |
||||||
|
const oldActive = this.listsElement.querySelector(`#responses-${escapeHTML(htmlTableIdentifier(result.table))} .response.active`) |
||||||
|
const newActive = this.listsElement.querySelector(`#response-${escapeHTML(`${result.mappingId}`)}`) |
||||||
|
if (!newActive || oldActive === newActive) { |
||||||
|
return |
||||||
|
} |
||||||
|
newActive.classList.add("active") |
||||||
|
if (!oldActive) { |
||||||
|
return |
||||||
|
} |
||||||
|
oldActive.classList.remove("active") |
||||||
} |
} |
||||||
} |
} |
||||||
|
|
||||||
function initResponseList(): ResponseList { |
function initResponseList(): ResponseLists { |
||||||
throw Error("not yet implemented") |
const listsElement = document.querySelector<HTMLElement>(`#responseLists`) |
||||||
|
if (!listsElement) { |
||||||
|
throw Error(`can't find #responseLists`) |
||||||
|
} |
||||||
|
const lists = scrapeResponseLists(listsElement) |
||||||
|
if (!lists) { |
||||||
|
throw Error(`can't parse #responseLists`) |
||||||
|
} |
||||||
|
const {db, active} = lists |
||||||
|
return new ResponseLists(db, listsElement).configureHandlers() |
||||||
} |
} |
||||||
|
|
||||||
export const responseList: Promise<ResponseList> = DOMLoaded.then(() => initResponseList()) |
export const responseLists: Promise<ResponseLists> = DOMLoaded.then(() => initResponseList()) |
||||||
export const db: Promise<RollTableDatabase> = responseList.then(r => r.db) |
export const db: Promise<RollTableDatabase> = responseLists.then(r => r.db) |
||||||
|
@ -0,0 +1,142 @@ |
|||||||
|
import { |
||||||
|
type ButtonFeatures, |
||||||
|
ButtonType, |
||||||
|
type CheckboxFeatures, |
||||||
|
type ElementFeatures, |
||||||
|
type FormFeatures, |
||||||
|
HyperlinkDestination, |
||||||
|
type HyperlinkFeatures, |
||||||
|
type LabelFeatures, |
||||||
|
type TemplateBuilder |
||||||
|
} from '../common/template'; |
||||||
|
|
||||||
|
function tag<T extends keyof HTMLElementTagNameMap>(tagName: T, features: ElementFeatures, contents: Node[]): HTMLElementTagNameMap[T] { |
||||||
|
const element = document.createElement(tagName) |
||||||
|
if (typeof features.id !== "undefined") { |
||||||
|
element.id = features.id |
||||||
|
} |
||||||
|
if (typeof features.classes !== "undefined") { |
||||||
|
if (typeof features.classes === "string") { |
||||||
|
element.classList.add(features.classes) |
||||||
|
} else { |
||||||
|
for (const className of features.classes) { |
||||||
|
element.classList.add(className) |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
if (typeof features.data !== "undefined") { |
||||||
|
for (const [key, value] of features.data) { |
||||||
|
element.dataset[key] = value |
||||||
|
} |
||||||
|
} |
||||||
|
for (const node of contents) { |
||||||
|
element.appendChild(node) |
||||||
|
} |
||||||
|
return element |
||||||
|
} |
||||||
|
|
||||||
|
class DOMTemplateBuilderImpl implements TemplateBuilder<Node> { |
||||||
|
makeButton(features: ButtonFeatures, ...contents: Node[]): HTMLButtonElement { |
||||||
|
const element = tag('button', features, contents) |
||||||
|
element.classList.add("button") |
||||||
|
element.type = features.type ?? ButtonType.Button |
||||||
|
if (typeof features.name === "string") { |
||||||
|
element.name = features.name |
||||||
|
} |
||||||
|
if (typeof features.value === "string") { |
||||||
|
element.value = features.value |
||||||
|
} |
||||||
|
return element |
||||||
|
} |
||||||
|
|
||||||
|
makeCheckbox(features: CheckboxFeatures, ...contents: Node[]): HTMLInputElement { |
||||||
|
const element = tag('input', features, contents) |
||||||
|
element.type = "checkbox" |
||||||
|
element.name = features.name |
||||||
|
if (features.checked) { |
||||||
|
element.checked = true |
||||||
|
} |
||||||
|
if (typeof features.value === "string") { |
||||||
|
element.value = features.value |
||||||
|
} |
||||||
|
return element |
||||||
|
} |
||||||
|
|
||||||
|
makeDiv(features: ElementFeatures, ...contents: Node[]): HTMLDivElement { |
||||||
|
return tag('div', features, contents); |
||||||
|
} |
||||||
|
|
||||||
|
makeFooter(features: ElementFeatures, ...contents: Node[]): HTMLElement { |
||||||
|
return tag('footer', features, contents); |
||||||
|
} |
||||||
|
|
||||||
|
makeForm(features: FormFeatures, ...contents: Node[]): HTMLFormElement { |
||||||
|
const element = tag('form', features, contents) |
||||||
|
element.action = features.action |
||||||
|
element.method = features.method |
||||||
|
return element; |
||||||
|
} |
||||||
|
|
||||||
|
makeHeader(features: ElementFeatures, ...contents: Node[]): HTMLElement { |
||||||
|
return tag('header', features, contents) |
||||||
|
} |
||||||
|
|
||||||
|
makeHeading1(features: ElementFeatures, ...contents: Node[]): HTMLHeadingElement { |
||||||
|
return tag('h1', features, contents); |
||||||
|
} |
||||||
|
|
||||||
|
makeHeading2(features: ElementFeatures, ...contents: Node[]): HTMLHeadingElement { |
||||||
|
return tag('h2', features, contents); |
||||||
|
} |
||||||
|
|
||||||
|
makeHyperlink(features: HyperlinkFeatures, ...contents: Node[]): HTMLAnchorElement { |
||||||
|
const element = tag('a', features, contents) |
||||||
|
element.href = features.url |
||||||
|
if (features.destination === HyperlinkDestination.External) { |
||||||
|
element.rel = "external nofollow noreferrer" |
||||||
|
} |
||||||
|
if (features.asButton) { |
||||||
|
element.classList.add("button") |
||||||
|
element.draggable = false |
||||||
|
} |
||||||
|
return element; |
||||||
|
} |
||||||
|
|
||||||
|
makeLabel(features: LabelFeatures, ...contents: Node[]): HTMLLabelElement { |
||||||
|
const element = tag('label', features, contents) |
||||||
|
if (typeof features.forId === "string") { |
||||||
|
element.htmlFor = features.forId |
||||||
|
} |
||||||
|
return element; |
||||||
|
} |
||||||
|
|
||||||
|
makeListItem(features: ElementFeatures, ...contents: Node[]): HTMLLIElement { |
||||||
|
return tag('li', features, contents) |
||||||
|
} |
||||||
|
|
||||||
|
makeNav(features: ElementFeatures, ...contents: Node[]): HTMLElement { |
||||||
|
return tag('nav', features, contents) |
||||||
|
} |
||||||
|
|
||||||
|
makeNoscript(features: ElementFeatures, ...contents: Node[]): HTMLElement { |
||||||
|
return tag('noscript', features, contents); |
||||||
|
} |
||||||
|
|
||||||
|
makeParagraph(features: ElementFeatures, ...contents: Node[]): HTMLParagraphElement { |
||||||
|
return tag('p', features, contents); |
||||||
|
} |
||||||
|
|
||||||
|
makeSpan(features: ElementFeatures, ...contents: Node[]): HTMLSpanElement { |
||||||
|
return tag('span', features, contents); |
||||||
|
} |
||||||
|
|
||||||
|
makeText(text: string): Text { |
||||||
|
return document.createTextNode(text); |
||||||
|
} |
||||||
|
|
||||||
|
makeUnorderedList(features: ElementFeatures, ...contents: Node[]): HTMLUListElement { |
||||||
|
return tag('ul', features, contents); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
export const DOMTemplateBuilder = new DOMTemplateBuilderImpl() |
@ -0,0 +1,16 @@ |
|||||||
|
import type { RollTableDetailsAndResults, RollTableResultFull } from '../../common/rolltable'; |
||||||
|
import type { RerollEventDetail } from '../generator-entrypoint'; |
||||||
|
|
||||||
|
interface CustomEventMap { |
||||||
|
"resultselected": CustomEvent<RollTableResultFull<RollTableDetailsAndResults>>; |
||||||
|
"reroll": CustomEvent<RerollEventDetail> |
||||||
|
} |
||||||
|
declare global { |
||||||
|
interface HTMLElement { |
||||||
|
addEventListener<K extends keyof CustomEventMap>(type: K, |
||||||
|
listener: (this: Document, ev: CustomEventMap[K]) => void, |
||||||
|
options?: boolean|EventListenerOptions): void; |
||||||
|
dispatchEvent<K extends keyof CustomEventMap>(ev: CustomEventMap[K]): boolean; |
||||||
|
dispatchEvent<K extends keyof HTMLElementEventMap>(ev: HTMLElementEventMap[K]): boolean; |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,73 @@ |
|||||||
|
import type { |
||||||
|
RollTableAuthor, |
||||||
|
} from '../rolltable'; |
||||||
|
import { |
||||||
|
AttributionAuthor, |
||||||
|
reconstituteAttributionAuthor, |
||||||
|
reconstituteAttributionAuthorIfExists |
||||||
|
} from './AttributionAuthor'; |
||||||
|
import { Fragment } from 'preact'; |
||||||
|
import { |
||||||
|
AttributionSet, |
||||||
|
type AttributionSetProps, |
||||||
|
reconstituteAttributionSet, |
||||||
|
reconstituteAttributionSetIfExists |
||||||
|
} from './AttributionSet'; |
||||||
|
import type { PropsWithChildren } from 'preact/compat'; |
||||||
|
|
||||||
|
export interface AttributionPropsFull { |
||||||
|
author: RollTableAuthor|null |
||||||
|
set: AttributionSetProps |
||||||
|
} |
||||||
|
|
||||||
|
export interface AttributionPropsEmpty { |
||||||
|
author?: null |
||||||
|
set?: null |
||||||
|
} |
||||||
|
|
||||||
|
export type AttributionProps = AttributionPropsFull|AttributionPropsEmpty |
||||||
|
|
||||||
|
export interface PartialAttributionPropsFull { |
||||||
|
author?: Partial<RollTableAuthor>|null |
||||||
|
set?: Partial<AttributionSetProps> |
||||||
|
} |
||||||
|
|
||||||
|
export interface PartialAttributionPropsEmpty { |
||||||
|
author?: null |
||||||
|
set?: null |
||||||
|
} |
||||||
|
|
||||||
|
export type PartialAttributionProps = PartialAttributionPropsFull|PartialAttributionPropsEmpty |
||||||
|
|
||||||
|
export function reconstituteAttribution(div: HTMLDivElement, partial?: PartialAttributionProps): AttributionProps { |
||||||
|
const set = reconstituteAttributionSetIfExists( |
||||||
|
div.querySelector<HTMLParagraphElement>(".resultSet"), |
||||||
|
partial?.set) |
||||||
|
const author = reconstituteAttributionAuthorIfExists( |
||||||
|
div.querySelector<HTMLParagraphElement>(".author"), |
||||||
|
partial?.author) |
||||||
|
if (!set) { |
||||||
|
return {} |
||||||
|
} else { |
||||||
|
return { |
||||||
|
set: set, |
||||||
|
author: author, |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
export function Attribution({author, set, children}: AttributionProps & PropsWithChildren) { |
||||||
|
return <div class="attribution"> |
||||||
|
<div class="attributionBubble"> |
||||||
|
{set |
||||||
|
? <Fragment> |
||||||
|
{author && <AttributionAuthor {...author} />} |
||||||
|
<AttributionSet {...set} /> |
||||||
|
</Fragment> |
||||||
|
: <p> |
||||||
|
<span>Authorship unknown</span> |
||||||
|
</p>} |
||||||
|
{children} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
} |
@ -0,0 +1,28 @@ |
|||||||
|
import type { RollTableAuthor } from '../rolltable'; |
||||||
|
|
||||||
|
export function reconstituteAttributionAuthorIfExists(element: HTMLParagraphElement | null, partial?: Partial<RollTableAuthor>|null): RollTableAuthor|null { |
||||||
|
if (!element || partial === null) { |
||||||
|
return null |
||||||
|
} |
||||||
|
return reconstituteAttributionAuthor(element, partial) |
||||||
|
} |
||||||
|
|
||||||
|
export function reconstituteAttributionAuthor(p: HTMLParagraphElement, partial?: Partial<RollTableAuthor>): RollTableAuthor { |
||||||
|
return { |
||||||
|
id: partial?.id ?? parseInt(p.dataset.id!!), |
||||||
|
name: partial?.name ?? p.querySelector<HTMLElement>(".authorName")!.innerText, |
||||||
|
url: typeof partial?.url !== "undefined" ? partial.url : (p.querySelector<HTMLAnchorElement>(".authorUrl")?.href ?? null), |
||||||
|
relation: partial?.relation ?? p.querySelector<HTMLElement>(".authorRelation")!.innerText, |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
export function AttributionAuthor({ relation, id, url, name }: RollTableAuthor) { |
||||||
|
return <p class="author" data-id={id}> |
||||||
|
<span class="authorRelation">{relation}</span> |
||||||
|
{" "} |
||||||
|
<span class="authorName">{url |
||||||
|
? <a class="authorUrl" href={url} rel="external nofollow noreferrer">{name}</a> |
||||||
|
: name |
||||||
|
}</span> |
||||||
|
</p> |
||||||
|
} |
@ -0,0 +1,26 @@ |
|||||||
|
import type { RollTableResultSet } from '../rolltable'; |
||||||
|
import { Fragment } from 'preact'; |
||||||
|
|
||||||
|
export type AttributionSetProps = Pick<RollTableResultSet, "name"|"id"|"global"> |
||||||
|
|
||||||
|
export function reconstituteAttributionSetIfExists(element: HTMLParagraphElement | null, partial?: Partial<AttributionSetProps>|null): AttributionSetProps|null { |
||||||
|
if (!element || partial === null) { |
||||||
|
return null |
||||||
|
} |
||||||
|
return reconstituteAttributionSet(element, partial) |
||||||
|
} |
||||||
|
|
||||||
|
export function reconstituteAttributionSet(p: HTMLParagraphElement, partial?: Partial<AttributionSetProps>): AttributionSetProps { |
||||||
|
return { |
||||||
|
id: partial?.id ?? parseInt(p.dataset.id!!), |
||||||
|
name: partial?.name ?? p.querySelector<HTMLElement>(".setName")?.innerText ?? null, |
||||||
|
global: partial?.global ?? p.classList.contains('global'), |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
export function AttributionSet({global, name, id}: AttributionSetProps) { |
||||||
|
return <p class={`resultSet${global ? ' global' : ''}`} data-id={id}> |
||||||
|
<span class="setRelation">in {name ? 'the' : 'a'} {global ? 'global' : 'server-local'} set</span> |
||||||
|
{name && <Fragment>{' '}<span class="setName">{name}</span></Fragment>} |
||||||
|
</p> |
||||||
|
} |
@ -0,0 +1,33 @@ |
|||||||
|
.button { |
||||||
|
border: none; |
||||||
|
padding: 0.5rem; |
||||||
|
font-size: 1rem; |
||||||
|
text-align: center; |
||||||
|
text-decoration: none; |
||||||
|
color: black; |
||||||
|
font-family: inherit; |
||||||
|
background-color: lightgray; |
||||||
|
cursor: pointer; |
||||||
|
user-select: none; |
||||||
|
border-radius: 0.8rem 0.4rem; |
||||||
|
box-shadow: 0 0 black; |
||||||
|
transform: none; |
||||||
|
transition: background-color 0.1s ease, transform 0.1s ease, box-shadow 0.1s ease; |
||||||
|
|
||||||
|
&:disabled { |
||||||
|
background-color: slategray; |
||||||
|
color: #333; |
||||||
|
cursor: inherit; |
||||||
|
} |
||||||
|
|
||||||
|
&:not(:disabled):hover, &:not(:disabled):focus { |
||||||
|
background-color: darkgray; |
||||||
|
box-shadow: -0.2rem 0.2rem black; |
||||||
|
transform: translate(0.2rem, -0.2rem); |
||||||
|
} |
||||||
|
|
||||||
|
&:not(:disabled):active { |
||||||
|
box-shadow: 0 0 black; |
||||||
|
transform: none; |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,33 @@ |
|||||||
|
import type { PropsWithChildren } from 'preact/compat'; |
||||||
|
|
||||||
|
export interface LinkButtonProps { |
||||||
|
"class"?: string |
||||||
|
type?: "link" |
||||||
|
href: string |
||||||
|
external?: boolean |
||||||
|
} |
||||||
|
|
||||||
|
export interface FormButtonProps { |
||||||
|
"class"?: string |
||||||
|
type: HTMLButtonElement["type"] |
||||||
|
href?: null |
||||||
|
external?: null |
||||||
|
} |
||||||
|
|
||||||
|
export type ButtonProps = LinkButtonProps|FormButtonProps |
||||||
|
|
||||||
|
export function Button({"class": className, type, href, external, children}: ButtonProps & PropsWithChildren) { |
||||||
|
if (href) { |
||||||
|
return <a |
||||||
|
class={`button${className ? " " + className : ""}`} |
||||||
|
href={href} |
||||||
|
{...(external ? {rel: "external nofollow noreferrer"} : {})} |
||||||
|
draggable={false}> |
||||||
|
{children} |
||||||
|
</a> |
||||||
|
} else { |
||||||
|
return <button type={type} class={`button${className ? " " + className : ""}`}> |
||||||
|
{children} |
||||||
|
</button> |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,63 @@ |
|||||||
|
@import "TableHeader"; |
||||||
|
@import "GeneratedResult"; |
||||||
|
|
||||||
|
.generatedElement { |
||||||
|
list-style: none; |
||||||
|
} |
||||||
|
|
||||||
|
.generatedHead { |
||||||
|
user-select: text; |
||||||
|
margin: 0.5rem 0 0 0; |
||||||
|
display: flex; |
||||||
|
flex-flow: row nowrap; |
||||||
|
} |
||||||
|
|
||||||
|
.generatedHead .generatedLabel span { |
||||||
|
display: inline; |
||||||
|
user-select: text; |
||||||
|
} |
||||||
|
|
||||||
|
.generatedLabel { |
||||||
|
flex: 1 1 auto; |
||||||
|
display: inline-flex; |
||||||
|
flex-flow: row nowrap; |
||||||
|
align-items: center; |
||||||
|
justify-content: left; |
||||||
|
cursor: pointer; |
||||||
|
padding-right: 0.2rem; |
||||||
|
user-select: text; |
||||||
|
} |
||||||
|
|
||||||
|
.generatedHead:hover .generatedSelect, .generatedHead .generatedSelect:focus { |
||||||
|
background-color: #90909030; |
||||||
|
border: 0.1rem solid darkgray; |
||||||
|
filter: brightness(105%) saturate(105%); |
||||||
|
transform: scale(120%) rotate(15deg); |
||||||
|
} |
||||||
|
|
||||||
|
.generatedHead .generatedSelect:active { |
||||||
|
filter: brightness(80%) saturate(80%); |
||||||
|
transform: scale(80%) rotate(30deg); |
||||||
|
} |
||||||
|
|
||||||
|
.generatedSelect { |
||||||
|
flex: 0 0 auto; |
||||||
|
appearance: none; |
||||||
|
cursor: pointer; |
||||||
|
font-size: 1.5rem; |
||||||
|
margin: 0; |
||||||
|
transition: filter 0.3s ease, transform 0.3s ease, border-bottom-width 0.3s ease, border-top-width 0.3s ease, border-left-width 0.3s ease, border-right-width 0.3s ease; |
||||||
|
width: 2rem; |
||||||
|
height: 2rem; |
||||||
|
text-align: center; |
||||||
|
line-height: 2rem; |
||||||
|
border-radius: 1rem; |
||||||
|
|
||||||
|
&::after { |
||||||
|
content: '🔒' |
||||||
|
} |
||||||
|
|
||||||
|
&:checked::after { |
||||||
|
content: '🎲'; |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,30 @@ |
|||||||
|
import { TableEmoji, TableHeaderDataset, tableIdentifier, type TableProps, TableTitle } from './TableHeader'; |
||||||
|
import { GeneratedResult, type GeneratedResultProps } from './GeneratedResult'; |
||||||
|
|
||||||
|
export type GeneratedElementProps = { |
||||||
|
table: TableProps |
||||||
|
selected: boolean|null |
||||||
|
} & GeneratedResultProps |
||||||
|
|
||||||
|
// TODO: reconstitute this
|
||||||
|
// TODO: get a callback for checkbox value changes
|
||||||
|
|
||||||
|
export function GeneratedElement(props: GeneratedElementProps) { |
||||||
|
const checkId = `selected-${tableIdentifier(props.table)}` |
||||||
|
return <li class="generatedElement" id={`generated-${tableIdentifier(props.table)}`}> |
||||||
|
<h2 class="generatedHead"> |
||||||
|
<label |
||||||
|
class="generatedLabel tableHeader" |
||||||
|
{...(props.selected !== null ? {"for": checkId} : {})} |
||||||
|
{...TableHeaderDataset(props.table)}> |
||||||
|
<TableEmoji {...props.table} /> |
||||||
|
{' '} |
||||||
|
<TableTitle {...props.table} /> |
||||||
|
</label> |
||||||
|
{props.selected !== null |
||||||
|
? <input type="checkbox" class="generatedSelect" id={checkId} name={checkId} checked={props.selected} /> |
||||||
|
: null} |
||||||
|
</h2> |
||||||
|
<GeneratedResult {...props} /> |
||||||
|
</li> |
||||||
|
} |
@ -0,0 +1,10 @@ |
|||||||
|
@import "Attribution"; |
||||||
|
@import "ResultText"; |
||||||
|
|
||||||
|
.generatedResult { |
||||||
|
margin: 0; |
||||||
|
padding: 0; |
||||||
|
appearance: none; |
||||||
|
font: inherit; |
||||||
|
border: 0; |
||||||
|
} |
@ -0,0 +1,64 @@ |
|||||||
|
import { |
||||||
|
Attribution, |
||||||
|
type AttributionPropsEmpty, type AttributionPropsFull, |
||||||
|
type PartialAttributionProps, type PartialAttributionPropsEmpty, |
||||||
|
reconstituteAttribution, |
||||||
|
} from './Attribution'; |
||||||
|
import { responseIdPrefix } from './util'; |
||||||
|
import { |
||||||
|
reconstituteResultText, |
||||||
|
ResultText, |
||||||
|
type ResultTextPropsFull, |
||||||
|
type ResultTextPropsLimited |
||||||
|
} from './ResultText'; |
||||||
|
import { Button } from './Button'; |
||||||
|
|
||||||
|
export type GeneratedResultPropsFull = { |
||||||
|
includesResponses: boolean |
||||||
|
} & AttributionPropsFull & ResultTextPropsFull |
||||||
|
|
||||||
|
export type GeneratedResultPropsLimited = { |
||||||
|
includesResponses?: null |
||||||
|
} & AttributionPropsEmpty & ResultTextPropsLimited |
||||||
|
|
||||||
|
export type GeneratedResultProps = GeneratedResultPropsFull|GeneratedResultPropsLimited |
||||||
|
|
||||||
|
|
||||||
|
export type PartialGeneratedResultPropsFull = { |
||||||
|
includesResponses?: boolean |
||||||
|
} & PartialAttributionProps & Partial<ResultTextPropsFull> |
||||||
|
|
||||||
|
export type PartialGeneratedResultPropsLimited = { |
||||||
|
includesResponses?: null |
||||||
|
} & PartialAttributionPropsEmpty & Partial<ResultTextPropsLimited> |
||||||
|
|
||||||
|
export type PartialGeneratedResultProps = PartialGeneratedResultPropsFull|PartialGeneratedResultPropsLimited |
||||||
|
|
||||||
|
export function reconstituteGeneratedResult(div: HTMLDivElement, partial?: PartialGeneratedResultProps): GeneratedResultProps { |
||||||
|
const result = |
||||||
|
reconstituteResultText(div.querySelector(".resultText")!, partial) |
||||||
|
const attribution = |
||||||
|
reconstituteAttribution(div.querySelector(".attribution")!, partial) |
||||||
|
if (result.updated && attribution.set) { |
||||||
|
return { |
||||||
|
includesResponses: !!div.querySelector(".jumpToResponse"), |
||||||
|
...attribution, |
||||||
|
...result, |
||||||
|
} |
||||||
|
} else { |
||||||
|
return { |
||||||
|
text: result.text |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
export function GeneratedResult(props: GeneratedResultProps) { |
||||||
|
return <div class="generatedResult attributed"> |
||||||
|
<ResultText {...props} /> |
||||||
|
<Attribution {...props}> |
||||||
|
{props.includesResponses |
||||||
|
? <p><Button class={"jumpToResponse"} href={`#${responseIdPrefix}${props.mappingId}`}>Jump to Result in List</Button></p> |
||||||
|
: null} |
||||||
|
</Attribution> |
||||||
|
</div> |
||||||
|
} |
@ -0,0 +1,7 @@ |
|||||||
|
footer { |
||||||
|
display: block; |
||||||
|
margin: 0.75rem 0 0 0; |
||||||
|
font-size: 0.75rem; |
||||||
|
user-select: none; |
||||||
|
text-align: center; |
||||||
|
} |
@ -0,0 +1,37 @@ |
|||||||
|
.resultText { |
||||||
|
flex: 1 1 auto; |
||||||
|
appearance: none; |
||||||
|
background-color: transparent; |
||||||
|
color: inherit; |
||||||
|
font-size: inherit; |
||||||
|
font-family: inherit; |
||||||
|
text-decoration: none; |
||||||
|
border: 0; |
||||||
|
padding: 0.2rem 0.5rem; |
||||||
|
cursor: pointer; |
||||||
|
text-align: left; |
||||||
|
word-wrap: normal; |
||||||
|
display: block; |
||||||
|
width: 100%; |
||||||
|
box-sizing: border-box; |
||||||
|
white-space: normal; |
||||||
|
user-select: text; |
||||||
|
transition: background-color 0.2s ease; |
||||||
|
border-radius: 0.3rem; |
||||||
|
|
||||||
|
&:hover:not(:active) { |
||||||
|
background-color: #BFBFBF60; |
||||||
|
} |
||||||
|
|
||||||
|
&:focus:not(:active) { |
||||||
|
background-color: #9F9FFF90; |
||||||
|
} |
||||||
|
|
||||||
|
&:focus:hover:not(:active) { |
||||||
|
background-color: #8F8FDF90; |
||||||
|
} |
||||||
|
|
||||||
|
&:active { |
||||||
|
background-color: #3F3FFFA0; |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,38 @@ |
|||||||
|
export interface ResultTextPropsBase { |
||||||
|
text: string |
||||||
|
} |
||||||
|
|
||||||
|
export interface ResultTextPropsFull extends ResultTextPropsBase { |
||||||
|
mappingId: number |
||||||
|
textId: number |
||||||
|
updated: Date |
||||||
|
} |
||||||
|
|
||||||
|
export interface ResultTextPropsLimited extends ResultTextPropsBase { |
||||||
|
mappingId?: null |
||||||
|
textId?: null |
||||||
|
updated?: null |
||||||
|
} |
||||||
|
|
||||||
|
export function reconstituteResultText(button: HTMLButtonElement, partial: Partial<ResultTextProps> = {}): ResultTextProps { |
||||||
|
const text = button.innerText |
||||||
|
if (typeof partial.mappingId ?? button.dataset["mappingId"] === "undefined") { |
||||||
|
return {text} |
||||||
|
} else { |
||||||
|
return { |
||||||
|
text, |
||||||
|
mappingId: partial.mappingId ?? parseInt(button.dataset["mappingId"]!), |
||||||
|
textId: partial.textId ?? parseInt(button.dataset["textId"]!), |
||||||
|
updated: partial.updated ?? new Date(parseInt(button.dataset["updated"]!)) |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
export type ResultTextProps = ResultTextPropsFull|ResultTextPropsLimited |
||||||
|
|
||||||
|
export function ResultText({text, mappingId, textId, updated}: ResultTextProps) { |
||||||
|
return <button className="resultText" |
||||||
|
{...(updated |
||||||
|
? { "data-mapping-id": mappingId, "data-text-id": textId, "data-updated": updated.getTime() } |
||||||
|
: {})}>{text}</button> |
||||||
|
} |
@ -0,0 +1,14 @@ |
|||||||
|
.tableHeader { |
||||||
|
font-size: 1.25rem; |
||||||
|
font-weight: bold; |
||||||
|
display: flex; |
||||||
|
justify-content: stretch; |
||||||
|
align-items: baseline; |
||||||
|
margin-bottom: 0; |
||||||
|
} |
||||||
|
|
||||||
|
.tableEmoji { |
||||||
|
font-size: 1.75rem; |
||||||
|
padding-right: 0.5rem; |
||||||
|
user-select: text; |
||||||
|
} |
@ -0,0 +1,73 @@ |
|||||||
|
import slug from 'slug'; |
||||||
|
|
||||||
|
// TODO: reconstitute the three things here
|
||||||
|
|
||||||
|
export type TableFullProps = TableIdentifierFullProps & TableHeaderFullProps & TableEmojiProps & TableTitleProps |
||||||
|
export type TableLimitedProps = TableIdentifierLimitedProps & TableHeaderLimitedProps & TableEmojiProps & TableTitleProps |
||||||
|
export type TableProps = TableFullProps|TableLimitedProps |
||||||
|
export interface TableIdentifierFullProps { |
||||||
|
identifier: string |
||||||
|
title: string |
||||||
|
} |
||||||
|
|
||||||
|
export interface TableIdentifierLimitedProps { |
||||||
|
identifier?: null |
||||||
|
title: string |
||||||
|
} |
||||||
|
|
||||||
|
export type TableIdentifierProps = TableIdentifierFullProps|TableIdentifierLimitedProps |
||||||
|
|
||||||
|
export function tableIdentifier({ identifier, title }: TableIdentifierProps): string { |
||||||
|
if (typeof identifier === 'string') { |
||||||
|
return slug(identifier); |
||||||
|
} else { |
||||||
|
return slug(title); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
export interface TableHeaderFullProps { |
||||||
|
ordinal: number, |
||||||
|
id: number, |
||||||
|
name: string, |
||||||
|
identifier: string |
||||||
|
} |
||||||
|
|
||||||
|
export interface TableHeaderLimitedProps { |
||||||
|
ordinal: number, |
||||||
|
id?: null, |
||||||
|
name?: null, |
||||||
|
identifier?: null |
||||||
|
} |
||||||
|
|
||||||
|
export type TableHeaderProps = TableHeaderFullProps|TableHeaderLimitedProps |
||||||
|
|
||||||
|
export function TableHeaderDataset({ ordinal, id, name, identifier }: TableHeaderProps): Record<`data-${string}`, string> { |
||||||
|
if (typeof identifier === "string") { |
||||||
|
return { |
||||||
|
"data-ordinal": `${ordinal}`, |
||||||
|
"data-id": `${id}`, |
||||||
|
"data-name": name, |
||||||
|
"data-identifier": identifier |
||||||
|
} |
||||||
|
} else { |
||||||
|
return { |
||||||
|
"data-ordinal": `${ordinal}`, |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
export interface TableEmojiProps { |
||||||
|
emoji: string |
||||||
|
} |
||||||
|
|
||||||
|
export function TableEmoji({emoji}: TableEmojiProps) { |
||||||
|
return <span class="tableEmoji">{emoji}</span> |
||||||
|
} |
||||||
|
|
||||||
|
export interface TableTitleProps { |
||||||
|
title: string |
||||||
|
} |
||||||
|
|
||||||
|
export function TableTitle({title}: TableTitleProps) { |
||||||
|
return <span class="tableTitle">{title}</span> |
||||||
|
} |
@ -0,0 +1,2 @@ |
|||||||
|
// TODO: move this to the response file
|
||||||
|
export const responseIdPrefix = "response-" |
@ -0,0 +1,131 @@ |
|||||||
|
import { |
||||||
|
type ButtonFeatures, |
||||||
|
ButtonType, |
||||||
|
type CheckboxFeatures, |
||||||
|
type ElementFeatures, extendClasses, |
||||||
|
type FormFeatures, |
||||||
|
HyperlinkDestination, |
||||||
|
type HyperlinkFeatures, |
||||||
|
type LabelFeatures, |
||||||
|
type TemplateBuilder |
||||||
|
} from '../../common/template'; |
||||||
|
import escapeHTML from 'escape-html'; |
||||||
|
import { kebabCase } from 'change-case'; |
||||||
|
|
||||||
|
function tag(tagName: string, features: ElementFeatures, attributes: string[], contents: string[]): string { |
||||||
|
if (typeof features.id !== "undefined") { |
||||||
|
attributes.push(`id="${escapeHTML(features.id)}"`) |
||||||
|
} |
||||||
|
if (typeof features.classes !== "undefined") { |
||||||
|
attributes.push(`class="${typeof features.classes === "string" |
||||||
|
? escapeHTML(features.classes) |
||||||
|
: Array.from(features.classes).map(escapeHTML).join(" ")}"`)
|
||||||
|
} |
||||||
|
if (typeof features.data !== "undefined") { |
||||||
|
for (const [key, value] of features.data) { |
||||||
|
attributes.push(`data-${escapeHTML(kebabCase(key))}="${escapeHTML(value)}"`) |
||||||
|
} |
||||||
|
} |
||||||
|
return `<${tagName}${attributes.length === 0 ? "" : " " + attributes.join(" ")}>${contents.join("")}</${tagName}>` |
||||||
|
} |
||||||
|
|
||||||
|
class StringTemplateBuilderImpl implements TemplateBuilder<string> { |
||||||
|
|
||||||
|
makeButton(features: ButtonFeatures, ...contents: string[]): string { |
||||||
|
const attributes = [ |
||||||
|
`type="${escapeHTML(features.type ?? ButtonType.Button)}"`, |
||||||
|
] |
||||||
|
if (typeof features.name === "string") { |
||||||
|
attributes.push(`name="${escapeHTML(features.name)}"`) |
||||||
|
} |
||||||
|
if (typeof features.value === "string") { |
||||||
|
attributes.push(`value="${escapeHTML(features.value)}"`) |
||||||
|
} |
||||||
|
return tag('button', {...features, classes: extendClasses(features.classes, "button")}, attributes, contents) |
||||||
|
} |
||||||
|
|
||||||
|
makeCheckbox(features: CheckboxFeatures, ...contents: string[]): string { |
||||||
|
const attributes = [`type="checkbox"`, `name="${escapeHTML(features.name)}"`] |
||||||
|
if (features.checked) { |
||||||
|
attributes.push("checked") |
||||||
|
} |
||||||
|
if (typeof features.value === "string") { |
||||||
|
attributes.push(`value="${escapeHTML(features.value)}"`) |
||||||
|
} |
||||||
|
return tag('input', features, attributes, contents); |
||||||
|
} |
||||||
|
|
||||||
|
makeDiv(features: ElementFeatures, ...contents: string[]): string { |
||||||
|
return tag('div', features, [], contents); |
||||||
|
} |
||||||
|
|
||||||
|
makeFooter(features: ElementFeatures, ...contents: string[]): string { |
||||||
|
return tag('footer', features, [], contents); |
||||||
|
} |
||||||
|
|
||||||
|
makeForm(features: FormFeatures, ...contents: string[]): string { |
||||||
|
const attributes = [`action="${escapeHTML(features.action)}"`, `method="${escapeHTML(features.method)}"`] |
||||||
|
return tag('form', features, attributes, contents); |
||||||
|
} |
||||||
|
|
||||||
|
makeHeader(features: ElementFeatures, ...contents: string[]): string { |
||||||
|
return tag('header', features, [], contents) |
||||||
|
} |
||||||
|
|
||||||
|
makeHeading1(features: ElementFeatures, ...contents: string[]): string { |
||||||
|
return tag('h1', features, [], contents); |
||||||
|
} |
||||||
|
|
||||||
|
makeHeading2(features: ElementFeatures, ...contents: string[]): string { |
||||||
|
return tag('h2', features, [], contents); |
||||||
|
} |
||||||
|
|
||||||
|
makeHyperlink(features: HyperlinkFeatures, ...contents: string[]): string { |
||||||
|
const attributes = [`href="${escapeHTML(features.url)}"`] |
||||||
|
if (features.destination === HyperlinkDestination.External) { |
||||||
|
attributes.push(`rel="external nofollow noreferrer"`) |
||||||
|
} |
||||||
|
if (features.asButton) { |
||||||
|
attributes.push(`draggable="false"`) |
||||||
|
} |
||||||
|
return tag('a', {...features, classes: extendClasses(features.classes, features.asButton ? ["button"] : [])}, attributes, contents); |
||||||
|
} |
||||||
|
|
||||||
|
makeLabel(features: LabelFeatures, ...contents: string[]): string { |
||||||
|
const attributes = [] |
||||||
|
if (typeof features.forId === "string") { |
||||||
|
attributes.push(`for="${escapeHTML(features.forId)}"`) |
||||||
|
} |
||||||
|
return tag('label', features, attributes, contents); |
||||||
|
} |
||||||
|
|
||||||
|
makeListItem(features: ElementFeatures, ...contents: string[]): string { |
||||||
|
return tag('li', features, [], contents) |
||||||
|
} |
||||||
|
|
||||||
|
makeNav(features: ElementFeatures, ...contents: string[]): string { |
||||||
|
return tag('nav', features, [], contents) |
||||||
|
} |
||||||
|
|
||||||
|
makeNoscript(features: ElementFeatures, ...contents: string[]): string { |
||||||
|
return tag('noscript', features, [], contents); |
||||||
|
} |
||||||
|
|
||||||
|
makeParagraph(features: ElementFeatures, ...contents: string[]): string { |
||||||
|
return tag('p', features, [], contents); |
||||||
|
} |
||||||
|
|
||||||
|
makeSpan(features: ElementFeatures, ...contents: string[]): string { |
||||||
|
return tag('span', features, [], contents); |
||||||
|
} |
||||||
|
|
||||||
|
makeText(text: string): string { |
||||||
|
return escapeHTML(text); |
||||||
|
} |
||||||
|
|
||||||
|
makeUnorderedList(features: ElementFeatures, ...contents: string[]): string { |
||||||
|
return tag('ul', features, [], contents); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
export const StringTemplateBuilder = new StringTemplateBuilderImpl() |
Loading…
Reference in new issue