diff --git a/packages/web-forms/src/components/controls/RankControl.vue b/packages/web-forms/src/components/controls/RankControl.vue index 66cd3fb0..54b7c84c 100644 --- a/packages/web-forms/src/components/controls/RankControl.vue +++ b/packages/web-forms/src/components/controls/RankControl.vue @@ -92,7 +92,8 @@ const swapItems = (index: number, newPosition: number) => { :disabled="question.currentState.readonly" ghost-class="fade-moving" class="rank-control" - @update="setValues"> + @update="setValues" + >
{ :class="{ 'moving': highlight.index.value === index }" tabindex="0" @keydown.up.prevent="moveUp(index)" - @keydown.down.prevent="moveDown(index)"> + @keydown.down.prevent="moveDown(index)" + >
diff --git a/packages/xforms-engine/src/client/hierarchy.ts b/packages/xforms-engine/src/client/hierarchy.ts index f5e8a354..bcd0a432 100644 --- a/packages/xforms-engine/src/client/hierarchy.ts +++ b/packages/xforms-engine/src/client/hierarchy.ts @@ -22,8 +22,8 @@ export type AnyControlNode = | AnyInputNode | AnyNoteNode | AnyRangeNode - | SelectNode | RankNode + | SelectNode | TriggerNode; // prettier-ignore diff --git a/packages/xforms-engine/src/instance/RankControl.ts b/packages/xforms-engine/src/instance/RankControl.ts index 48ae6ccc..6e54fb7a 100644 --- a/packages/xforms-engine/src/instance/RankControl.ts +++ b/packages/xforms-engine/src/instance/RankControl.ts @@ -9,7 +9,6 @@ import type { } from '../client/RankNode.ts'; import type { TextRange } from '../client/TextRange.ts'; import type { XFormsXPathElement } from '../integration/xpath/adapter/XFormsXPathNode.ts'; -import { createItemset } from '../lib/reactivity/createBaseItemset.ts'; import type { CurrentState } from '../lib/reactivity/node-state/createCurrentState.ts'; import type { EngineState } from '../lib/reactivity/node-state/createEngineState.ts'; import type { SharedNodeState } from '../lib/reactivity/node-state/createSharedNodeState.ts'; @@ -28,6 +27,7 @@ import { RankFunctionalityError, RankValueTypeError } from '../error/RankError.t import { BaseItemCollectionCodec } from '../lib/codecs/BaseItemCollectionCodec.ts'; import { sharedValueCodecs } from '../lib/codecs/getSharedValueCodec.ts'; import type { AnyNodeDefinition } from '../parse/model/NodeDefinition.ts'; +import { createItemCollection } from '../lib/reactivity/createItemCollection.ts'; type AssertRangeNodeDefinition = (definition: RankDefinition) => asserts definition is RankDefinition<'string'>; const assertRangeNodeDefinition: AssertRangeNodeDefinition = (definition) => { @@ -77,7 +77,7 @@ export class RankControl const codec = new BaseItemCollectionCodec(sharedValueCodecs.string); super(parent, definition, codec); - const valueOptions = createItemset(this, definition.bodyElement.itemset); + const valueOptions = createItemCollection(this); const mapOptionsByValue: Accessor = this.scope.runTask(() => { return createMemo(() => { return new Map(valueOptions().map((item) => [item.value, item])); diff --git a/packages/xforms-engine/src/instance/SelectControl.ts b/packages/xforms-engine/src/instance/SelectControl.ts index 5a216d2c..ee7a4a59 100644 --- a/packages/xforms-engine/src/instance/SelectControl.ts +++ b/packages/xforms-engine/src/instance/SelectControl.ts @@ -13,7 +13,7 @@ import type { ValueType } from '../client/ValueType.ts'; import { SelectValueTypeError } from '../error/SelectValueTypeError.ts'; import type { XFormsXPathElement } from '../integration/xpath/adapter/XFormsXPathNode.ts'; import { getSelectCodec } from '../lib/codecs/select/getSelectCodec.ts'; -import { createSelectItems } from '../lib/reactivity/createSelectItems.ts'; +import { createItemCollection } from '../lib/reactivity/createItemCollection.ts'; import type { CurrentState } from '../lib/reactivity/node-state/createCurrentState.ts'; import type { EngineState } from '../lib/reactivity/node-state/createEngineState.ts'; import type { SharedNodeState } from '../lib/reactivity/node-state/createSharedNodeState.ts'; @@ -93,7 +93,7 @@ export class SelectControl this.appearances = definition.bodyElement.appearances; this.selectType = definition.bodyElement.type; - const valueOptions = createSelectItems(this); + const valueOptions = createItemCollection(this); const mapOptionsByValue: Accessor = this.scope.runTask(() => { return createMemo(() => { diff --git a/packages/xforms-engine/src/lib/reactivity/createBaseItemset.ts b/packages/xforms-engine/src/lib/reactivity/createItemCollection.ts similarity index 55% rename from packages/xforms-engine/src/lib/reactivity/createBaseItemset.ts rename to packages/xforms-engine/src/lib/reactivity/createItemCollection.ts index 124a393e..5636f531 100644 --- a/packages/xforms-engine/src/lib/reactivity/createBaseItemset.ts +++ b/packages/xforms-engine/src/lib/reactivity/createItemCollection.ts @@ -2,21 +2,71 @@ import { UpsertableMap } from '@getodk/common/lib/collections/UpsertableMap.ts'; import type { Accessor } from 'solid-js'; import { createMemo } from 'solid-js'; import type { ActiveLanguage } from '../../client/FormLanguage.ts'; +import type { SelectItem } from '../../client/SelectNode.ts'; +import type { RankItem } from '../../client/RankNode.ts'; import type { TextRange as ClientTextRange } from '../../client/TextRange.ts'; import type { EvaluationContext } from '../../instance/internal-api/EvaluationContext.ts'; +import type { TranslationContext } from '../../instance/internal-api/TranslationContext.ts'; +import type { SelectControl } from '../../instance/SelectControl.ts'; +import type { RankControl } from '../../instance/RankControl.ts'; +import { TextChunk } from '../../instance/text/TextChunk.ts'; +import { TextRange } from '../../instance/text/TextRange.ts'; import type { EngineXPathNode } from '../../integration/xpath/adapter/kind.ts'; import type { EngineXPathEvaluator } from '../../integration/xpath/EngineXPathEvaluator.ts'; +import type { ItemDefinition } from '../../parse/body/control/ItemDefinition.ts'; import type { ItemsetDefinition } from '../../parse/body/control/ItemsetDefinition.ts'; import { createComputedExpression } from './createComputedExpression.ts'; import type { ReactiveScope } from './scope.ts'; import { createTextRange } from './text/createTextRange.ts'; -import type { RankControl } from '../../instance/RankControl.ts'; -import type { SelectControl } from '../../instance/SelectControl.ts'; -import type { TranslationContext } from '../../instance/internal-api/TranslationContext.ts'; -import { TextChunk } from '../../instance/text/TextChunk.ts'; -import { TextRange } from '../../instance/text/TextRange.ts'; -type ItemsetControl = SelectControl | RankControl; +type ItemCollectionControl = SelectControl | RankControl; +type ItemType = SelectItem | RankItem +type DerivedItemLabel = ClientTextRange<'item-label', 'form-derived'>; + +const derivedItemLabel = (context: TranslationContext, value: string): DerivedItemLabel => { + const chunk = new TextChunk(context, 'literal', value); + + return new TextRange('form-derived', 'item-label', [chunk]); +}; + +const createItemLabel = ( + context: EvaluationContext, + definition: ItemDefinition +): Accessor> => { + const { label, value } = definition; + + if (label == null) { + return () => derivedItemLabel(context, value); + } + + return createTextRange(context, 'item-label', label); +}; + +interface SourceValueItem { + readonly value: string; + readonly label: ClientTextRange<'item-label'>; +} + +const createTranslatedStaticItems = ( + control: ItemCollectionControl, + items: readonly ItemDefinition[] +): Accessor => { + return control.scope.runTask(() => { + const labeledItems = items.map((item) => { + const { value } = item; + const label = createItemLabel(control, item); + + return () => ({ + value, + label: label(), + }); + }); + + return createMemo(() => { + return labeledItems.map((item) => item()); + }); + }); +}; class ItemsetItemEvaluationContext implements EvaluationContext { readonly isAttached: Accessor; @@ -25,7 +75,7 @@ class ItemsetItemEvaluationContext implements EvaluationContext { readonly contextReference: Accessor; readonly getActiveLanguage: Accessor; - constructor(control: ItemsetControl, readonly contextNode: EngineXPathNode) { + constructor(control: ItemCollectionControl, readonly contextNode: EngineXPathNode) { this.isAttached = control.isAttached; this.scope = control.scope; this.evaluator = control.evaluator; @@ -34,14 +84,6 @@ class ItemsetItemEvaluationContext implements EvaluationContext { } } -type DerivedItemLabel = ClientTextRange<'item-label', 'form-derived'>; - -export const derivedItemLabel = (context: TranslationContext, value: string): DerivedItemLabel => { - const chunk = new TextChunk(context, 'literal', value); - - return new TextRange('form-derived', 'item-label', [chunk]); -}; - const createItemsetItemLabel = ( context: EvaluationContext, definition: ItemsetDefinition, @@ -50,7 +92,9 @@ const createItemsetItemLabel = ( const { label } = definition; if (label == null) { - return createMemo(() => derivedItemLabel(context, itemValue())); + return createMemo(() => { + return derivedItemLabel(context, itemValue()); + }); } return createTextRange(context, 'item-label', label); @@ -61,33 +105,38 @@ interface ItemsetItem { value(): string; } -const createItemsetItems = (control: ItemsetControl, itemset: ItemsetDefinition): Accessor => { +const createItemsetItems = ( + control: ItemCollectionControl, + itemset: ItemsetDefinition +): Accessor => { return control.scope.runTask(() => { - const itemNodes = createComputedExpression(control, itemset.nodes, { defaultValue: [] }); + const itemNodes = createComputedExpression(control, itemset.nodes, { + defaultValue: [], + }); const itemsCache = new UpsertableMap(); return createMemo(() => { return itemNodes().map((itemNode) => { return itemsCache.upsert(itemNode, () => { const context = new ItemsetItemEvaluationContext(control, itemNode); - const value = createComputedExpression(context, itemset.value, { defaultValue: '' }); + const value = createComputedExpression(context, itemset.value, { + defaultValue: '', + }); const label = createItemsetItemLabel(context, itemset, value); - return { label, value }; + return { + label, + value, + }; }); }); }); }); }; -export interface SourceValueItem { - readonly value: string; - readonly label: ClientTextRange<'item-label'>; -} - -export const createItemset = ( - control: ItemsetControl, - itemset: ItemsetDefinition, +const createItemset = ( + control: ItemCollectionControl, + itemset: ItemsetDefinition ): Accessor => { return control.scope.runTask(() => { const itemsetItems = createItemsetItems(control, itemset); @@ -102,3 +151,25 @@ export const createItemset = ( }); }); }; + +/** + * Creates a reactive computation of a {@link ItemCollectionControl}'s + * {@link ItemType}s, in support of the field's `valueOptions`. + * + * - The control defined with static ``s will compute to an corresponding + * static list of items. + * - The control defined with a computed `` will compute to a reactive list + * of items. + * - Items of both will produce {@link ItemType.label | labels} reactive to + * their appropriate dependencies (whether relative to the itemset item node, + * referencing a form's `itext` translations, etc). + */ +export const createItemCollection = (control: ItemCollectionControl): Accessor => { + const { items, itemset } = control.definition.bodyElement; + + if (itemset != null) { + return createItemset(control, itemset); + } + + return createTranslatedStaticItems(control, items); +}; diff --git a/packages/xforms-engine/src/lib/reactivity/createSelectItems.ts b/packages/xforms-engine/src/lib/reactivity/createSelectItems.ts deleted file mode 100644 index fa55ecf8..00000000 --- a/packages/xforms-engine/src/lib/reactivity/createSelectItems.ts +++ /dev/null @@ -1,67 +0,0 @@ -import type { Accessor } from 'solid-js'; -import { createMemo } from 'solid-js'; -import type { SelectItem } from '../../client/SelectNode.ts'; -import type { TextRange as ClientTextRange } from '../../client/TextRange.ts'; -import type { EvaluationContext } from '../../instance/internal-api/EvaluationContext.ts'; -import type { SelectControl } from '../../instance/SelectControl.ts'; -import type { ItemDefinition } from '../../parse/body/control/ItemDefinition.ts'; -import { createTextRange } from './text/createTextRange.ts'; -import { type SourceValueItem, createItemset, derivedItemLabel } from './createBaseItemset.ts'; - -const createSelectItemLabel = ( - context: EvaluationContext, - definition: ItemDefinition -): Accessor> => { - const { label, value } = definition; - - if (label == null) { - return () => derivedItemLabel(context, value); - } - - return createTextRange(context, 'item-label', label); -}; - -interface SourceValueSelectItem extends SourceValueItem { } - -const createTranslatedStaticSelectItems = ( - select: SelectControl, - items: readonly ItemDefinition[] -): Accessor => { - return select.scope.runTask(() => { - const labeledItems = items.map((item) => { - const { value } = item; - const label = createSelectItemLabel(select, item); - - return () => ({ - value, - label: label(), - }); - }); - - return createMemo(() => { - return labeledItems.map((item) => item()); - }); - }); -}; - -/** - * Creates a reactive computation of a {@link SelectControl}'s - * {@link SelectItem}s, in support of the field's `valueOptions`. - * - * - Selects defined with static ``s will compute to an corresponding - * static list of items. - * - Selects defined with a computed `` will compute to a reactive list - * of items. - * - Items of both will produce {@link SelectItem.label | labels} reactive to - * their appropriate dependencies (whether relative to the itemset item node, - * referencing a form's `itext` translations, etc). - */ -export const createSelectItems = (select: SelectControl): Accessor => { - const { items, itemset } = select.definition.bodyElement; - - if (itemset != null) { - return createItemset(select, itemset); - } - - return createTranslatedStaticSelectItems(select, items); -}; diff --git a/packages/xforms-engine/src/parse/body/control/RankControlDefinition.ts b/packages/xforms-engine/src/parse/body/control/RankControlDefinition.ts index 03525476..5ecd5b23 100644 --- a/packages/xforms-engine/src/parse/body/control/RankControlDefinition.ts +++ b/packages/xforms-engine/src/parse/body/control/RankControlDefinition.ts @@ -1,9 +1,10 @@ import type { LocalNamedElement } from '@getodk/common/types/dom.ts'; -import { getItemsetElement } from '../../../lib/dom/query.ts'; +import { getItemElements, getItemsetElement } from '../../../lib/dom/query.ts'; import type { XFormDefinition } from '../../XFormDefinition.ts'; import type { BodyElementParentContext } from '../BodyDefinition.ts'; import { ControlDefinition } from './ControlDefinition.ts'; import { ItemsetDefinition } from './ItemsetDefinition.ts'; +import { ItemDefinition } from './ItemDefinition.ts'; export type RankType = 'rank'; export interface RankElement extends LocalNamedElement {} @@ -17,9 +18,10 @@ export class RankControlDefinition extends ControlDefinition { return RankControlDefinition.isRankElement(element); } - readonly type: RankType = 'rank'; + readonly type: RankType; readonly element: RankElement; - readonly itemset: ItemsetDefinition; + readonly itemset: ItemsetDefinition | null; + readonly items: readonly ItemDefinition[]; constructor(form: XFormDefinition, parent: BodyElementParentContext, element: Element) { if (!RankControlDefinition.isRankElement(element)) { @@ -30,7 +32,20 @@ export class RankControlDefinition extends ControlDefinition { this.type = element.localName as RankType; this.element = element; - this.itemset = new ItemsetDefinition(form, this, getItemsetElement(element)); + const itemsetElement = getItemsetElement(element); + const itemElements = getItemElements(element); + + if (itemsetElement === null) { + this.itemset = null; + this.items = itemElements.map((itemElement) => new ItemDefinition(form, this, itemElement)); + } else { + if (itemElements.length > 0) { + throw new Error(`<${element.nodeName}> has both and children`); + } + + this.items = []; + this.itemset = new ItemsetDefinition(form, this, itemsetElement); + } } override toJSON() { diff --git a/packages/xforms-engine/test/lib/reactivity/createSelectItems.test.ts b/packages/xforms-engine/test/lib/reactivity/createSelectItems.test.ts index c53c925b..524f0d68 100644 --- a/packages/xforms-engine/test/lib/reactivity/createSelectItems.test.ts +++ b/packages/xforms-engine/test/lib/reactivity/createSelectItems.test.ts @@ -15,16 +15,16 @@ import { import { describe, expect, it } from 'vitest'; import { initializeForm } from '../../../src/instance/index.ts'; import type { SelectControl } from '../../../src/instance/SelectControl.ts'; -import type { createSelectItems } from '../../../src/lib/reactivity/createSelectItems.ts'; +import type { createItemCollection } from '../../../src/lib/reactivity/createItemCollection.ts'; import { reactiveTestScope } from '../../helpers/reactive/internal.ts'; /** * @todo Consider these alternative testing strategies: * - * - Reducing tests of reactive internals like {@link createSelectItems} to more + * - Reducing tests of reactive internals like {@link createItemCollection} to more * conventional unit tests: If there's a reasonable way to do that, it would * probably begin (especially in this case) with relaxing the - * {@link createSelectItems} signature to accept something more minimal than a + * {@link createItemCollection} signature to accept something more minimal than a * {@link SelectControl}. However, after some reflection on the efforts to port * JavaRosa tests, there's quite a lot of value in form-level integration * tests. We might benefit instead from...