refactor: clean up checklist structure + node renderer

This commit is contained in:
Calum H. (IMB11)
2026-08-10 11:58:16 +01:00
parent 1a56233c96
commit eb5897b553
21 changed files with 1373 additions and 1051 deletions
@@ -1,4 +1,4 @@
import type { Component, FunctionalComponent, SVGAttributes } from 'vue'
import type { FunctionalComponent, SVGAttributes } from 'vue'
import { markRaw } from 'vue'
import { Priority } from '../priority.ts'
@@ -254,39 +254,43 @@ export function withOnClick<T extends object>(node: T): T & Clickable {
})
}
export interface ComponentNodePropsContext {
export interface NodePropsContext {
onImageUpload?: (file: File) => Promise<string>
toggleSetValue?: (value: string) => void
nodeFacts?: { needsAttention: boolean; fixActionable: boolean }
tooltip?: Record<string, unknown>
}
export type BuiltinRendererKey =
| 'action'
| 'checkbox'
| 'toggle'
| 'dropdown'
| 'text'
| 'markdown'
export type NodeRendererDescriptor =
| { type: BuiltinRendererKey }
| { type: 'custom'; key: string }
export interface Renderable {
_component: Component | undefined
_rendererKey: string | undefined
_componentProps?: (ctx: ComponentNodePropsContext) => Record<string, unknown>
_renderer: NodeRendererDescriptor
_modelProp: string
}
export function withComponent<T extends object>(
export function withRenderer<T extends object>(
node: T,
opts: {
component?: Component
rendererKey?: string
renderer: NodeRendererDescriptor
modelProp?: string
componentProps?: Renderable['_componentProps']
},
): T & Renderable {
return Object.assign(node, {
_component: opts.component,
_rendererKey: opts.rendererKey,
_componentProps: opts.componentProps,
_renderer: opts.renderer,
_modelProp: opts.modelProp ?? 'modelValue',
})
}
export interface Configurable {
_extraProps: ((ctx: ComponentNodePropsContext) => Record<string, unknown>) | undefined
_extraProps: ((ctx: NodePropsContext) => Record<string, unknown>) | undefined
}
export function withExtraProps<T extends object>(node: T): T & Configurable {
@@ -1,39 +0,0 @@
<template>
<Button
v-tooltip="tooltip"
:type="color === 'standard' ? 'base' : 'colored'"
:color="color === 'standard' ? undefined : color"
:disabled="disabled"
:aria-label="icon ? label : undefined"
@click="emit('update:modelValue', !modelValue)"
>
<component :is="icon" v-if="icon" />
<template v-else>{{ label }}</template>
</Button>
</template>
<script lang="ts" setup>
import { Button } from '@modrinth/ui'
import type { Component } from 'vue'
import { computed } from 'vue'
const props = defineProps<{
modelValue: boolean
label?: string
icon?: Component
disabled?: boolean
needsAttention?: boolean
fixActionable?: boolean
tooltip?: Record<string, unknown>
}>()
const emit = defineEmits<{
'update:modelValue': [boolean]
}>()
const color = computed(() => {
if (!props.modelValue) return 'standard'
if (props.needsAttention) return 'orange'
return props.fixActionable ? 'blue' : 'brand'
})
</script>
@@ -1,401 +0,0 @@
<script lang="ts" setup>
import { Button, IconButton } from '@modrinth/ui'
import { renderString } from '@modrinth/utils'
import type { Component } from 'vue'
import { computed, inject, watchEffect } from 'vue'
import type { AnyNode, ChildNode, HasChildren } from '../builder'
import type {
ComponentNodePropsContext,
Enableable,
HasValue,
Identified,
OnChangeFn,
TweakDef,
} from '../capabilities'
import { CHECKLIST_META_KEY } from '../context'
import type { Writer } from '../mutate'
import { childWriter, originScope, writeNodeValue } from '../mutate'
import {
getBooleanChildState,
getEffectiveValue,
hasCap,
hasChildrenCap,
hasIdCap,
hasOptionsCap,
hasValueCap,
isNodeActive,
isShown,
resolveChildren,
withStateDefaults,
} from '../resolve'
import type { NodeState, Reactive } from '../state'
import { resolve } from '../state'
import ActionButton from './ActionButton.vue'
const metaCtx = inject(CHECKLIST_META_KEY)
const props = defineProps<{
nodes: ChildNode[]
state: Record<string, NodeState>
write: Writer
onImageUpload?: (file: File) => Promise<string>
flex?: boolean
titleDepth?: number
appComponents?: Record<string, Component>
globalState?: Record<string, Record<string, NodeState>>
}>()
type RenderableValueNode = AnyNode &
HasValue &
Identified &
Partial<Enableable> & {
_component: Component | undefined
_rendererKey: string | undefined
_modelProp: string
_componentProps?: (ctx: ComponentNodePropsContext) => Record<string, unknown>
_extraProps?: (ctx: ComponentNodePropsContext) => Record<string, unknown>
_tweaks?: TweakDef[]
}
function resolveComponent(node: RenderableValueNode): Component | undefined {
return (
node._component ?? (node._rendererKey ? props.appComponents?.[node._rendererKey] : undefined)
)
}
function titleClass(depth: number): string {
if (depth === 0) return 'text-lg font-extrabold text-contrast'
if (depth === 1) return 'text-base font-semibold'
if (depth === 2) return 'text-sm font-semibold'
return ''
}
function getTitle(node: object): string | undefined {
if (!hasCap(node, '_title')) return undefined
const title = node._title as Reactive<string> | undefined
if (title === undefined) return undefined
return resolve(title) || undefined
}
function needsAttention(node: object): boolean {
return metaCtx?.value.attentionMap.get(node) ?? false
}
function isFixActionable(node: object): boolean {
return metaCtx?.value.metaMap.get(node)?.isFixActionable ?? false
}
const wrappedState = computed(() => withStateDefaults(props.state, props.nodes, props.write))
function isEnabled(node: Partial<Enableable>): boolean {
if (node._enabled === undefined) return true
if (typeof node._enabled === 'function') return node._enabled(wrappedState.value)
return resolve(node._enabled)
}
function toggleSetValue(node: RenderableValueNode, value: string): void {
const current = getEffectiveValue(
node,
props.state[node.id],
wrappedState.value,
) as unknown as string[]
const set = new Set(Array.isArray(current) ? current : [])
if (set.has(value)) set.delete(value)
else set.add(value)
writeNodeValue(node, props.state, props.write, Array.from(set) as never, wrappedState.value)
}
const DROPDOWN_TRIGGER_CHROME_PX = 16 * 2 + 10 + 20 + 2
let measureEl: HTMLSpanElement | null = null
function measureLabelWidth(label: string): number {
if (typeof document === 'undefined') return 0
if (!measureEl) {
measureEl = document.createElement('span')
measureEl.className = 'min-w-0 truncate text-primary font-semibold leading-tight'
Object.assign(measureEl.style, {
position: 'absolute',
visibility: 'hidden',
whiteSpace: 'nowrap',
left: '-9999px',
top: '0',
})
document.body.appendChild(measureEl)
}
measureEl.textContent = label
return measureEl.getBoundingClientRect().width
}
const dropdownMinWidthCache = new Map<string, string>()
function getDropdownMinWidth(options: { label: string }[]): string {
const key = options.map((o) => o.label).join(' ')
const cached = dropdownMinWidthCache.get(key)
if (cached) return cached
const maxLabelWidth = Math.max(0, ...options.map((o) => measureLabelWidth(o.label)))
const result = `${Math.ceil(maxLabelWidth) + DROPDOWN_TRIGGER_CHROME_PX}px`
dropdownMinWidthCache.set(key, result)
return result
}
function componentProps(node: RenderableValueNode): Record<string, unknown> {
const ctx: ComponentNodePropsContext = {
onImageUpload: props.onImageUpload,
toggleSetValue: (value) => toggleSetValue(node, value),
nodeFacts: { needsAttention: needsAttention(node), fixActionable: isFixActionable(node) },
tooltip: resolveTooltip(node),
}
const dropdownStyle = hasOptionsCap(node)
? {
class: '!w-auto max-w-full',
style: { minWidth: getDropdownMinWidth(node._options as unknown as { label: string }[]) },
}
: undefined
return {
disabled: !isEnabled(node),
...dropdownStyle,
...node._componentProps?.(ctx),
...node._extraProps?.(ctx),
}
}
function containerScope(node: HasChildren & Partial<Identified>): {
state: Record<string, NodeState>
write: Writer
} {
if (hasCap(node, '_stateOrigin') && node._stateOrigin && props.globalState) {
return originScope(props.globalState, node._stateOrigin as string[])
}
if (!hasIdCap(node)) return { state: props.state, write: props.write }
const raw = props.state[node.id]
const state =
raw && typeof raw === 'object' && !(raw instanceof Set)
? (raw as Record<string, NodeState>)
: {}
return { state, write: childWriter(props.state, props.write, node.id) }
}
function valueScope(node: HasValue & Identified): {
state: Record<string, NodeState>
write: Writer
} {
const state = getBooleanChildState(props.state[node.id])
return { state, write: childWriter(props.state, props.write, node.id) }
}
const TOOLTIP_BASE = {
delay: { show: 500, hide: 0 },
triggers: ['hover', 'focus'],
placement: 'top',
}
function resolveTooltip(node: object): Record<string, unknown> | undefined {
if (hasCap(node, '_tooltip')) {
const t = node._tooltip as
| Reactive<string>
| ((state: Record<string, NodeState>) => string)
| undefined
if (t !== undefined) {
const content = typeof t === 'function' ? t(wrappedState.value) : resolve(t)
if (content) return { ...TOOLTIP_BASE, content }
}
}
const hasSegments = hasCap(node, '_segments')
const html = hasSegments ? metaCtx?.value.tooltipHtml.get(node) : undefined
return html ? { ...TOOLTIP_BASE, content: html, html: true } : undefined
}
function clickButton(node: object): void {
if (!hasCap(node, '_onClick')) return
;(node._onClick as (state: Record<string, NodeState>) => void)?.(wrappedState.value)
}
function tweakCurrent(node: RenderableValueNode): unknown {
return getEffectiveValue(node, props.state[node.id], wrappedState.value)
}
function tweakResult(tweak: TweakDef, node: RenderableValueNode): unknown {
return tweak.compute(tweakCurrent(node), wrappedState.value)
}
function tweakEnabled(tweak: TweakDef, node: RenderableValueNode): boolean {
const result = tweakResult(tweak, node)
return result !== null && result !== undefined && result !== tweakCurrent(node)
}
function tweakTooltip(
tweak: TweakDef,
node: RenderableValueNode,
): Record<string, unknown> | undefined {
if (!tweakEnabled(tweak, node)) return undefined
const content = tweakResult(tweak, node)
return content ? { ...TOOLTIP_BASE, content: String(content) } : undefined
}
function tweakLabel(tweak: TweakDef, node: RenderableValueNode): string {
const result = tweakResult(tweak, node)
return result !== null && result !== undefined ? String(result) : 'Apply suggested value'
}
function applyTweak(tweak: TweakDef, node: RenderableValueNode): void {
const result = tweakResult(tweak, node)
if (result !== null && result !== undefined) {
updateValue(node, result)
}
}
function nodeKey(item: ChildNode, idx: number): string {
return typeof item === 'object' && item !== null && hasIdCap(item) ? item.id : `n-${idx}`
}
function modelProp(item: object): string {
return (item as RenderableValueNode)._modelProp
}
function updateEvent(item: object): string {
return `update:${modelProp(item)}`
}
function updateValue(item: RenderableValueNode, v: unknown): void {
const onChange = hasCap(item, '_onChange')
? (item._onChange as OnChangeFn | undefined)
: undefined
if (onChange) {
const result = onChange(v as string, { override: (ov) => ({ __override: ov }) })
if (result && typeof result === 'object' && '__override' in result) {
writeNodeValue(item, props.state, props.write, result.__override as never, wrappedState.value)
return
}
}
writeNodeValue(item, props.state, props.write, v as never, wrappedState.value)
}
const seenOnChangeValues = new Map<object, unknown>()
watchEffect(() => {
for (const node of props.nodes) {
if (typeof node !== 'object' || node === null) continue
if (!hasCap(node, '_onChange') || !(node as { _onChange: unknown })._onChange) continue
if (!hasValueCap(node) || !hasIdCap(node)) continue
if (!isShown(node as AnyNode)) continue
const value = getEffectiveValue(node, props.state[node.id], wrappedState.value)
if (seenOnChangeValues.has(node) && seenOnChangeValues.get(node) === value) continue
seenOnChangeValues.set(node, value)
const onChange = (node as RenderableValueNode)._onChange as OnChangeFn | undefined
onChange?.(value as never, { override: (ov) => ({ __override: ov }) })
}
})
</script>
<template>
<div :class="[flex ? 'flex flex-wrap gap-2' : 'space-y-4', 'w-full']">
<template v-for="(item, idx) in nodes" :key="nodeKey(item, idx)">
<template v-if="typeof item !== 'object' || item === null">
<template v-if="typeof item === 'string'">{{ item }}</template>
<component :is="item" v-else />
</template>
<template v-else-if="isShown(item)">
<div
:class="
hasChildrenCap(item) && !hasValueCap(item)
? 'w-full'
: !getTitle(item)
? 'contents'
: undefined
"
>
<div v-if="getTitle(item)" class="mb-2" :class="titleClass(titleDepth ?? 0)">
<!-- eslint-disable vue/no-v-html -- title text is author-controlled (stage definitions), not user input -->
<span
v-html="renderString(getTitle(item)!).replace(/^<p>([\s\S]*)<\/p>\n?$/, '$1')"
/><span v-if="needsAttention(item)" class="text-red">*</span>
<!-- eslint-enable vue/no-v-html -->
</div>
<template v-if="hasChildrenCap(item) && !hasValueCap(item)">
<NodeRenderer
:nodes="resolveChildren(item, containerScope(item).state)"
:state="containerScope(item).state"
:write="containerScope(item).write"
:on-image-upload="onImageUpload"
:app-components="appComponents"
:global-state="globalState"
:flex="(item as any)._layout !== 'column'"
:title-depth="getTitle(item) !== undefined ? (titleDepth ?? 0) + 1 : titleDepth"
/>
</template>
<template v-else-if="hasValueCap(item) && hasIdCap(item)">
<component
:is="resolveComponent(item as RenderableValueNode)"
v-if="resolveComponent(item as RenderableValueNode) === ActionButton"
v-bind="componentProps(item as RenderableValueNode)"
:[modelProp(item)]="
getEffectiveValue(item as RenderableValueNode, state[item.id], wrappedState)
"
@[updateEvent(item)]="(v: unknown) => updateValue(item as RenderableValueNode, v)"
/>
<component
:is="resolveComponent(item as RenderableValueNode)"
v-else
v-tooltip="resolveTooltip(item)"
v-bind="componentProps(item as RenderableValueNode)"
:[modelProp(item)]="
getEffectiveValue(item as RenderableValueNode, state[item.id], wrappedState)
"
@[updateEvent(item)]="(v: unknown) => updateValue(item as RenderableValueNode, v)"
/>
<template
v-for="(tweak, tIdx) in (item as RenderableValueNode)._tweaks ?? []"
:key="`tweak-${tIdx}`"
>
<IconButton
v-tooltip="tweakTooltip(tweak, item as RenderableValueNode)"
:label="tweakLabel(tweak, item as RenderableValueNode)"
:disabled="!tweakEnabled(tweak, item as RenderableValueNode)"
@click="applyTweak(tweak, item as RenderableValueNode)"
>
<component :is="tweak.icon" />
</IconButton>
</template>
</template>
<template v-else-if="hasCap(item, '_onClick')">
<Button
v-tooltip="resolveTooltip(item)"
:disabled="!isEnabled(item as any)"
:aria-label="(item as any)._icon ? (item as any).label : undefined"
@click="clickButton(item)"
>
<component :is="(item as any)._icon" v-if="(item as any)._icon" />
<template v-else>{{ (item as any).label }}</template>
</Button>
</template>
</div>
</template>
</template>
<template v-for="(item, idx) in nodes" :key="`children-${nodeKey(item, idx)}`">
<NodeRenderer
v-if="
typeof item === 'object' &&
item !== null &&
isShown(item) &&
hasValueCap(item) &&
hasIdCap(item) &&
hasChildrenCap(item) &&
isNodeActive(item, state[item.id], wrappedState) &&
resolveChildren(item, valueScope(item).state).length
"
:nodes="resolveChildren(item, valueScope(item).state)"
:state="valueScope(item).state"
:write="valueScope(item).write"
:on-image-upload="onImageUpload"
:app-components="appComponents"
:global-state="globalState"
:title-depth="getTitle(item) !== undefined ? (titleDepth ?? 0) + 1 : titleDepth"
class="w-full"
/>
</template>
</div>
</template>
@@ -1,11 +0,0 @@
import type { InjectionKey, Ref } from 'vue'
import type { NodeMeta } from './node-meta'
export interface ChecklistMetaContext {
metaMap: Map<object, NodeMeta>
attentionMap: Map<object, boolean>
tooltipHtml: Map<object, string>
}
export const CHECKLIST_META_KEY: InjectionKey<Ref<ChecklistMetaContext>> = Symbol('checklistMeta')
+10 -53
View File
@@ -1,10 +1,6 @@
import { Checkbox, Combobox, MarkdownEditor, StyledInput, Toggle } from '@modrinth/ui'
import { markRaw } from 'vue'
import { withAutoProps, withChildren } from './builder'
import type { ComponentNodePropsContext, Configurable } from './capabilities'
import type { Configurable, NodePropsContext } from './capabilities'
import {
withComponent,
withEditable,
withEnabled,
withExtraProps,
@@ -17,6 +13,7 @@ import {
withOnClick,
withPriority,
withRequired,
withRenderer,
withSelectable,
withShown,
withStageMeta,
@@ -26,7 +23,6 @@ import {
withTweak,
withValue,
} from './capabilities'
import ActionButton from './components/ActionButton.vue'
import { pipe } from './pipe'
import type { NodeState, NodeStateWithChildren } from './state'
@@ -68,17 +64,7 @@ export function toggle(id: string, label: string) {
withFix,
withEnabled,
(n) => withValue(n, booleanValue),
(n) =>
withComponent(n, {
component: markRaw(ActionButton),
componentProps: (ctx) => ({
label: n.label,
icon: n._icon,
needsAttention: ctx.nodeFacts?.needsAttention ?? false,
fixActionable: ctx.nodeFacts?.fixActionable ?? false,
tooltip: ctx.tooltip,
}),
}),
(n) => withRenderer(n, { renderer: { type: 'action' } }),
)
}
@@ -108,11 +94,7 @@ export function check(id: string, label: string) {
withFix,
withEnabled,
(n) => withValue(n, booleanValue),
(n) =>
withComponent(n, {
component: markRaw(Checkbox),
componentProps: () => ({ label }),
}),
(n) => withRenderer(n, { renderer: { type: 'checkbox' } }),
withExtraProps,
),
)
@@ -133,7 +115,7 @@ export function toggleSwitch(id: string, label: string) {
withFix,
withEnabled,
(n) => withValue(n, booleanValue),
(n) => withComponent(n, { component: markRaw(Toggle) }),
(n) => withRenderer(n, { renderer: { type: 'toggle' } }),
withExtraProps,
),
)
@@ -227,19 +209,7 @@ export function dropdown(id: string) {
(n) => withValue(n, dropdownValue),
withNoneLabel,
withOptions,
(n) =>
withComponent(n, {
component: markRaw(Combobox),
componentProps: () => ({
options: [
...(n._none !== undefined ? [{ value: '', label: n._none }] : []),
...n._options.map((o) => ({ value: o.value, label: o.label })),
],
triggerClass:
'!bg-[var(--color-button-bg)] !rounded-[var(--radius-md)] !shadow-[var(--shadow-inset-sm),0_0_0_0_transparent]',
dropdownClass: '!rounded-[var(--radius-md)] !bg-[var(--color-button-bg)] !border-0',
}),
}),
(n) => withRenderer(n, { renderer: { type: 'dropdown' } }),
)
}
@@ -278,11 +248,7 @@ export function text(id: string) {
(n) => Object.assign(n, { _showTooltip: true, _imperativeSync: true }),
(n) => withValue(n, stringValue),
withTweak,
(n) =>
withComponent(n, {
component: markRaw(StyledInput),
componentProps: () => ({ class: 'min-w-40 flex-1', autocomplete: 'off' }),
}),
(n) => withRenderer(n, { renderer: { type: 'text' } }),
withExtraProps,
),
)
@@ -304,16 +270,7 @@ export function markdown(id: string) {
withEnabled,
withEditable,
(n) => withValue(n, stringValue),
(n) =>
withComponent(n, {
component: markRaw(MarkdownEditor),
componentProps: (ctx) => ({
maxHeight: 300,
disabled: false,
headingButtons: false,
onImageUpload: ctx.onImageUpload,
}),
}),
(n) => withRenderer(n, { renderer: { type: 'markdown' } }),
withExtraProps,
),
)
@@ -353,7 +310,7 @@ export function appComponent(id: string, rendererKey: string) {
withFix,
withEnabled,
(n) => withValue(n, stringValueBehavior),
(n) => withComponent(n, { rendererKey }),
(n) => withRenderer(n, { renderer: { type: 'custom', key: rendererKey } }),
withExtraProps,
)
return Object.assign(node, {
@@ -364,7 +321,7 @@ export function appComponent(id: string, rendererKey: string) {
)
return this
},
props(this: Configurable, fn: (ctx: ComponentNodePropsContext) => Record<string, unknown>) {
props(this: Configurable, fn: (ctx: NodePropsContext) => Record<string, unknown>) {
this._extraProps = fn
return this
},
@@ -1,7 +1,6 @@
export * from './builder'
export * from './capabilities'
export * from './collect'
export * from './context'
export * from './factories'
export * from './fix'
export * from './messages'