diff --git a/l10n/en-US/viewer.ftl b/l10n/en-US/viewer.ftl index 1d0e9ed02a4bc..81405d96e6f0b 100644 --- a/l10n/en-US/viewer.ftl +++ b/l10n/en-US/viewer.ftl @@ -271,6 +271,10 @@ pdfjs-rendering-error = An error occurred while rendering the page. ## Annotations +# .aria-label for the button that opens an editable combo box's choices. +pdfjs-annotation-combo-box-button = + .aria-label = Show choices + # Variables: # $dateObj (Date) - the modification date and time of the annotation pdfjs-annotation-date-time-string = { DATETIME($dateObj, dateStyle: "short", timeStyle: "medium") } diff --git a/src/core/annotation.js b/src/core/annotation.js index f1c55335a2be6..55a3fe29d6a85 100644 --- a/src/core/annotation.js +++ b/src/core/annotation.js @@ -4016,6 +4016,7 @@ class ChoiceWidgetAnnotation extends WidgetAnnotation { // Process field flags for the display layer. this.data.combo = this.hasFieldFlag(AnnotationFieldFlag.COMBO); + this.data.edit = this.hasFieldFlag(AnnotationFieldFlag.EDIT); this.data.multiSelect = this.hasFieldFlag(AnnotationFieldFlag.MULTISELECT); this._hasText = true; } @@ -4028,7 +4029,7 @@ class ChoiceWidgetAnnotation extends WidgetAnnotation { id: this.data.id, value, defaultValue: this.data.defaultFieldValue, - editable: !this.data.readOnly, + editable: this.data.combo && this.data.edit, name: this.data.fieldName, rect: this.data.rect, numItems: this.data.fieldValue.length, diff --git a/src/display/annotation_layer.js b/src/display/annotation_layer.js index 92beb92e423ff..1275b8dabd8aa 100644 --- a/src/display/annotation_layer.js +++ b/src/display/annotation_layer.js @@ -57,6 +57,7 @@ import { import { AnnotationStorage } from "./annotation_storage.js"; import { ColorConverters } from "../shared/scripting_utils.js"; import { DOMSVGFactory } from "./svg_factory.js"; +import { MathClamp } from "../shared/math_clamp.js"; const DEFAULT_FONT_SIZE = 9; const GetElementsByNameSet = new WeakSet(); @@ -106,6 +107,13 @@ class AnnotationElementFactory { } return new PushButtonWidgetAnnotationElement(parameters); case "Ch": + if ( + parameters.renderForms && + parameters.data.combo && + parameters.data.edit + ) { + return new EditableComboWidgetAnnotationElement(parameters); + } return new ChoiceWidgetAnnotationElement(parameters); case "Sig": return new SignatureWidgetAnnotationElement(parameters); @@ -1241,9 +1249,12 @@ class LinkAnnotationElement extends AnnotationElement { const { id } = field; allIds.push(id); switch (field.type) { - case "text": { + case "text": + case "combobox": + case "listbox": { const value = field.defaultValue || ""; - storage.setValue(id, { value }); + // Clear formatting when resetting the value. + storage.setValue(id, { value, formattedValue: null }); break; } case "checkbox": @@ -1252,12 +1263,6 @@ class LinkAnnotationElement extends AnnotationElement { storage.setValue(id, { value }); break; } - case "combobox": - case "listbox": { - const value = field.defaultValue || ""; - storage.setValue(id, { value }); - break; - } default: continue; } @@ -1473,17 +1478,16 @@ class TextWidgetAnnotationElement extends WidgetAnnotationElement { super(parameters, { isRenderable }); } - setPropertyOnSiblings(base, key, value, keyInStorage) { - const storage = this.annotationStorage; - for (const element of this._getElementsByName( - base.name, - /* skipId = */ base.id - )) { - if (element.domElement) { - element.domElement[key] = value; - } - storage.setValue(element.id, { [keyInStorage]: value }); - } + _toDisplayValue(value) { + return value ?? ""; + } + + _toStorageValue(value) { + return value; + } + + _getSandboxActions(actions) { + return actions; } render() { @@ -1500,7 +1504,7 @@ class TextWidgetAnnotationElement extends WidgetAnnotationElement { const storedData = storage.getValue(id, { value: this.data.fieldValue, }); - let textContent = storedData.value || ""; + let textContent = this._toDisplayValue(storedData.value); const maxLen = storage.getValue(id, { charLimit: this.data.maxLen, }).charLimit; @@ -1566,18 +1570,20 @@ class TextWidgetAnnotationElement extends WidgetAnnotationElement { } element.addEventListener("input", event => { - storage.setValue(id, { value: event.target.value }); - this.setPropertyOnSiblings( - element, - "value", - event.target.value, - "value" - ); + const displayValue = event.target.value; + const value = this._toStorageValue(displayValue); + storage.setValue(id, { value }); + for (const sibling of this._getElementsByName(element.name, id)) { + if (sibling.domElement) { + sibling.domElement.value = displayValue; + } + storage.setValue(sibling.id, { value }); + } elementData.formattedValue = null; }); element.addEventListener("resetform", event => { - const defaultValue = this.data.defaultFieldValue ?? ""; + const defaultValue = this._toDisplayValue(this.data.defaultFieldValue); element.value = elementData.userValue = defaultValue; elementData.formattedValue = null; }); @@ -1626,82 +1632,97 @@ class TextWidgetAnnotationElement extends WidgetAnnotationElement { target.value = value; } } - elementData.lastCommittedValue = target.value; + // Equal labels can have different export values. + elementData.lastCommittedValue = this._toStorageValue(target.value); elementData.commitKey = 1; if (!this.data.actions?.has("Focus")) { elementData.focused = true; } }); - element.addEventListener("updatefromsandbox", jsEvent => { - this.container.classList.add("sandboxModified"); - const actions = { - value(event) { - elementData.userValue = event.detail.value ?? ""; - if (!hasDateOrTime) { - storage.setValue(id, { - value: elementData.userValue.toString(), - }); - } - event.target.value = elementData.userValue; - }, - formattedValue(event) { - const { formattedValue } = event.detail; - elementData.formattedValue = formattedValue; - if ( - formattedValue !== null && - formattedValue !== undefined && - event.target !== document.activeElement - ) { - // Input hasn't the focus so display formatted string - event.target.value = formattedValue; - } + const sandboxActions = this._getSandboxActions({ + value: event => { + const { detail } = event; + const value = detail.value ?? ""; + // Keep typed labels unchanged so selRange offsets stay valid. + const isTyped = !!detail.selRange; + elementData.userValue = isTyped + ? value + : this._toDisplayValue(value); + // Preserve formatting for edit replies and explicit format updates. + const isFormatOutdated = + !isTyped && !detail.selection && !("formattedValue" in detail); + if (isFormatOutdated) { + elementData.formattedValue = null; + } + if (!hasDateOrTime) { + const storedValue = isTyped ? this._toStorageValue(value) : value; const data = { - formattedValue, + value: storedValue.toString(), }; - if (hasDateOrTime) { - // If the field is a date or time, we store the formatted value - // in the `value` property, so that it can be used by the - // `Keystroke` action. - data.value = formattedValue; + if (isFormatOutdated) { + data.formattedValue = null; } storage.setValue(id, data); - }, - selRange(event) { - event.target.setSelectionRange(...event.detail.selRange); - }, - charLimit: event => { - const { charLimit } = event.detail; - const { target } = event; - if (charLimit === 0) { - target.removeAttribute("maxLength"); - return; - } + } + event.target.value = elementData.userValue; + }, + formattedValue(event) { + const { formattedValue } = event.detail; + elementData.formattedValue = formattedValue; + if ( + formattedValue !== null && + formattedValue !== undefined && + event.target !== document.activeElement + ) { + event.target.value = formattedValue; + } + const data = { + formattedValue, + }; + if (hasDateOrTime) { + // Date/time fields store formatted text for Keystroke actions. + data.value = formattedValue; + } + storage.setValue(id, data); + }, + selRange(event) { + event.target.setSelectionRange(...event.detail.selRange); + }, + charLimit: event => { + const { charLimit } = event.detail; + const { target } = event; + if (charLimit === 0) { + target.removeAttribute("maxLength"); + return; + } - target.setAttribute("maxLength", charLimit); - let value = elementData.userValue; - if (!value || value.length <= charLimit) { - return; - } - value = value.slice(0, charLimit); - target.value = elementData.userValue = value; - storage.setValue(id, { value }); - - this.linkService.eventBus?.dispatch("dispatcheventinsandbox", { - source: this, - detail: { - id, - name: "Keystroke", - value, - willCommit: true, - commitKey: 1, - selStart: target.selectionStart, - selEnd: target.selectionEnd, - }, - }); - }, - }; - this._dispatchEventFromSandbox(actions, jsEvent); + target.setAttribute("maxLength", charLimit); + let value = elementData.userValue; + if (!value || value.length <= charLimit) { + return; + } + value = value.slice(0, charLimit); + target.value = elementData.userValue = value; + storage.setValue(id, { value }); + + this.linkService.eventBus?.dispatch("dispatcheventinsandbox", { + source: this, + detail: { + id, + name: "Keystroke", + value, + willCommit: true, + commitKey: 1, + selStart: target.selectionStart, + selEnd: target.selectionEnd, + }, + }); + }, + }); + element.addEventListener("updatefromsandbox", jsEvent => { + this.container.classList.add("sandboxModified"); + this._dispatchEventFromSandbox(sandboxActions, jsEvent); }); // Even if the field hasn't any actions @@ -1725,10 +1746,11 @@ class TextWidgetAnnotationElement extends WidgetAnnotationElement { return; } const { value } = event.target; - if (elementData.lastCommittedValue === value) { + const committedValue = this._toStorageValue(value); + if (elementData.lastCommittedValue === committedValue) { return; } - elementData.lastCommittedValue = value; + elementData.lastCommittedValue = committedValue; // Save the entered value elementData.userValue = value; this.linkService.eventBus?.dispatch("dispatcheventinsandbox", { @@ -1736,7 +1758,7 @@ class TextWidgetAnnotationElement extends WidgetAnnotationElement { detail: { id, name: "Keystroke", - value, + value: committedValue, willCommit: true, commitKey, selStart: event.target.selectionStart, @@ -1781,13 +1803,14 @@ class TextWidgetAnnotationElement extends WidgetAnnotationElement { target.type = "text"; } elementData.userValue = value; - if (elementData.lastCommittedValue !== value) { + const committedValue = this._toStorageValue(value); + if (elementData.lastCommittedValue !== committedValue) { this.linkService.eventBus?.dispatch("dispatcheventinsandbox", { source: this, detail: { id, name: "Keystroke", - value, + value: committedValue, willCommit: true, commitKey: elementData.commitKey, selStart: event.target.selectionStart, @@ -1875,7 +1898,7 @@ class TextWidgetAnnotationElement extends WidgetAnnotationElement { ["mouseleave", "Mouse Exit"], ["mouseup", "Mouse Up"], ], - event => event.target.value + event => this._toStorageValue(event.target.value) ); } @@ -1933,6 +1956,378 @@ class TextWidgetAnnotationElement extends WidgetAnnotationElement { } } +class EditableComboWidgetAnnotationElement extends TextWidgetAnnotationElement { + #items; + + #copyStyle; + + #rebuild; + + #pendingCommit = null; + + constructor(parameters) { + super(parameters); + this.#items = this.annotationStorage.getValue(this.data.id, { + items: this.data.options, + }).items; + } + + _toDisplayValue(value) { + if (Array.isArray(value)) { + value = value[0]; + } + return this.#getOption(value)?.displayValue ?? value ?? ""; + } + + #getOption(value) { + return this.#items.find(({ exportValue }) => exportValue === value); + } + + // Prefer the current field value when labels are shared. + #getItem(label) { + const item = this.#getOption(this.#value); + return item?.displayValue === label + ? item + : this.#items.find(({ displayValue }) => displayValue === label); + } + + _toStorageValue(value) { + return this.#getItem(value)?.exportValue ?? value; + } + + get #value() { + const { value } = this.annotationStorage.getValue(this.data.id, { + value: this.data.fieldValue, + }); + return Array.isArray(value) ? value[0] : value; + } + + #dispatchEvent(detail) { + this.linkService.eventBus?.dispatch("dispatcheventinsandbox", { + source: this, + detail: { id: this.data.id, name: "Keystroke", ...detail }, + }); + } + + _getSandboxActions(actions) { + const target = this.contentElement; + // Update the text widget's cached editing value too. + const setValue = value => actions.value({ detail: { value }, target }); + const setItems = items => { + const previous = this.#getOption(this.#value); + this.#items = items; + this.annotationStorage.setValue(this.data.id, { items }); + this.#rebuild(); + // Refresh the label without discarding existing formatting. + const current = this.#getOption(this.#value); + if ( + previous && + current && + previous.displayValue !== current.displayValue + ) { + const { formattedValue = null } = this.annotationStorage.getRawValue( + this.data.id + ); + const detail = { value: current.exportValue, formattedValue }; + actions.value({ detail, target }); + actions.formattedValue({ detail, target }); + } + }; + // Apply script color changes to the arrow and list too. + const setColor = name => event => { + this._commonActions[name](event); + this.#copyStyle(); + }; + return { + ...actions, + bgColor: setColor("bgColor"), + fgColor: setColor("fgColor"), + fillColor: setColor("fillColor"), + textColor: setColor("textColor"), + selection: event => { + const value = this.#pendingCommit; + this.#pendingCommit = null; + if (value === null || value !== event.detail.value) { + return; + } + // Commit after Action, even when the selection reply is synchronous. + queueMicrotask(() => + this.#dispatchEvent({ value, willCommit: true, commitKey: 1 }) + ); + }, + editable: event => { + const { editable } = event.detail; + event.target.readOnly = !editable; + this.annotationStorage.setValue(this.data.id, { editable }); + }, + items: event => setItems(event.detail.items), + insert: event => { + const { index, displayValue, exportValue } = event.detail.insert; + setItems( + this.#items.toSpliced(index, 0, { displayValue, exportValue }) + ); + }, + remove: event => setItems(this.#items.toSpliced(event.detail.remove, 1)), + clear: () => { + setItems([]); + setValue(""); + }, + indices: event => { + const item = this.#items[event.detail.indices[0]]; + setValue(item?.exportValue ?? ""); + }, + }; + } + + render() { + const container = super.render(); + container.classList.add("comboBox"); + const input = this.contentElement; + const list = document.createElement("div"); + list.id = `${AnnotationPrefix}${this.data.id}_listbox`; + list.className = "comboList"; + list.role = "listbox"; + list.popover = "auto"; + list.setAttribute( + "aria-label", + this.data.alternativeText || this.data.fieldName + ); + const anchor = `--pdfjs-combo-${this.data.id}`; + input.style.setProperty("anchor-name", anchor); + list.style.setProperty("position-anchor", anchor); + input.role = "combobox"; + input.setAttribute("aria-expanded", "false"); + input.setAttribute("aria-controls", list.id); + // Restore the scripted editable state. + if (this.annotationStorage.getRawValue(this.data.id)?.editable === false) { + input.readOnly = true; + } + + const button = document.createElement("button"); + button.className = "comboButton"; + button.type = "button"; + button.tabIndex = -1; + button.dataset.l10nId = "pdfjs-annotation-combo-box-button"; + button.popoverTargetElement = list; + this.#copyStyle = () => { + const { backgroundColor, color, fontSize } = input.style; + Object.assign(button.style, { color, fontSize }); + Object.assign(list.style, { + color, + fontSize, + backgroundImage: + backgroundColor === "transparent" + ? "" + : `linear-gradient(${backgroundColor}, ${backgroundColor})`, + }); + }; + this.#copyStyle(); + button.addEventListener("mousedown", event => { + event.preventDefault(); + // Blur other form fields before picking an option. + const { activeElement } = document; + if (activeElement !== input && GetElementsByNameSet.has(activeElement)) { + input.focus({ preventScroll: true }); + } + }); + if (this.enableScripting && this.hasJSActions) { + this._setEventListeners( + button, + null, + [ + ["mousedown", "Mouse Down"], + ["mouseup", "Mouse Up"], + ], + () => this._toStorageValue(input.value) + ); + } + list.addEventListener("mousedown", event => event.preventDefault()); + + let active = -1; + const isOpen = () => list.matches(":popover-open"); + const currentIndex = () => this.#items.indexOf(this.#getItem(input.value)); + const scrollToOption = option => { + // Scroll the list without scrolling the page. + const top = option.offsetTop; + const bottom = top + option.offsetHeight; + if (top < list.scrollTop) { + list.scrollTop = top; + } else if (bottom > list.scrollTop + list.clientHeight) { + list.scrollTop = bottom - list.clientHeight; + } + }; + const activate = (index, scroll = true) => { + list.children[active]?.setAttribute("aria-selected", "false"); + active = index; + const option = list.children[index]; + if (!option) { + input.removeAttribute("aria-activedescendant"); + return; + } + option.setAttribute("aria-selected", "true"); + input.setAttribute("aria-activedescendant", option.id); + if (scroll) { + scrollToOption(option); + } + }; + const show = () => { + list.showPopover(); + activate(currentIndex()); + input.select(); + }; + const close = () => { + list.hidePopover(); + activate(-1); + }; + const pick = (index, keepOpen = false) => { + const { displayValue, exportValue } = this.#items[index]; + const oldValue = input.value; + // Store the export value before resolving a shared label. + this.annotationStorage.setValue(this.data.id, { value: exportValue }); + input.value = displayValue; + input.dispatchEvent(new Event("input")); + if (this.enableScripting && this.hasJSActions) { + // Unfocused picks commit after the sandbox accepts the selection. + this.#pendingCommit = + document.activeElement === input ? null : exportValue; + this.#dispatchEvent({ + value: oldValue, + change: displayValue, + changeEx: exportValue, + willCommit: false, + }); + this.#dispatchEvent({ name: "Action", value: exportValue }); + } + if (keepOpen) { + activate(index); + } else { + close(); + } + }; + const createOptions = () => { + const fragment = new DocumentFragment(); + for (const [index, { displayValue }] of this.#items.entries()) { + const option = document.createElement("div"); + option.role = "option"; + option.id = `${list.id}_${index}`; + option.textContent = displayValue; + option.setAttribute("aria-selected", "false"); + fragment.append(option); + } + list.replaceChildren(fragment); + }; + this.#rebuild = () => { + activate(-1); + list.replaceChildren(); + button.hidden = this.#items.length === 0; + if (isOpen()) { + if (button.hidden) { + close(); + } else { + createOptions(); + } + } + }; + this.#rebuild(); + // Populate options before keyboard navigation activates one. + list.addEventListener("beforetoggle", event => { + if (event.newState === "open" && !list.hasChildNodes()) { + createOptions(); + } + }); + const getIndex = target => + Array.prototype.indexOf.call(list.children, target); + list.addEventListener("click", ({ target }) => { + const index = getIndex(target); + if (index !== -1) { + pick(index); + } + }); + list.addEventListener("mousemove", event => { + if (event.movementX || event.movementY) { + const index = getIndex(event.target); + if (index !== -1) { + activate(index, /* scroll = */ false); + } + } + }); + list.addEventListener("toggle", () => { + const open = isOpen(); + input.setAttribute("aria-expanded", open); + if (!open) { + activate(-1); + } else if (active === -1) { + list.scrollTop = 0; + activate(currentIndex()); + } + }); + input.addEventListener("blur", close); + input.addEventListener("beforeinput", () => activate(-1)); + container.addEventListener( + "keydown", + event => { + // Without items, the keys are the text field ones. + if (event.isComposing || !this.#items.length) { + return; + } + const open = isOpen(); + switch (event.key) { + case "ArrowDown": + case "ArrowUp": { + const down = event.key === "ArrowDown"; + // Alt+Down opens the list and Alt+Up closes it. + if (event.altKey) { + if (down && !open) { + show(); + } else if (!down && open) { + close(); + } else if (!open) { + return; + } + break; + } + // Select adjacent options without wrapping. + const index = active === -1 ? currentIndex() : active; + const last = this.#items.length - 1; + let next = down ? 0 : last; + if (index !== -1) { + next = MathClamp(index + (down ? 1 : -1), 0, last); + } + if (next !== index) { + pick(next, /* keepOpen = */ open); + } + input.select(); + break; + } + case "Enter": + // Enter toggles the list; commit on blur. + if (!open) { + show(); + } else if (active === -1 || active === currentIndex()) { + close(); + } else { + pick(active); + } + break; + case "Escape": + if (!open) { + return; + } + close(); + break; + default: + return; + } + event.preventDefault(); + event.stopImmediatePropagation(); + }, + true + ); + container.append(button, list); + return container; + } +} + class SignatureWidgetAnnotationElement extends WidgetAnnotationElement { constructor(parameters) { super(parameters, { isRenderable: !!parameters.data.hasOwnCanvas }); @@ -2351,9 +2746,6 @@ class ChoiceWidgetAnnotationElement extends WidgetAnnotationElement { }); selectedValues = getValue(/* isExport */ false); }, - editable(event) { - event.target.disabled = !event.detail.editable; - }, }; this._dispatchEventFromSandbox(actions, jsEvent); }); diff --git a/src/scripting_api/event.js b/src/scripting_api/event.js index fe3872db3d195..cde3ad5164ced 100644 --- a/src/scripting_api/event.js +++ b/src/scripting_api/event.js @@ -32,7 +32,7 @@ function createEvent(data) { return { __proto__: EVENT_PROTOTYPE, change: data.change || "", - changeEx: data.changeEx || null, + changeEx: data.changeEx ?? null, commitKey: data.commitKey || 0, fieldFull: data.fieldFull || false, keyDown: data.keyDown || false, @@ -203,47 +203,55 @@ class EventDispatcher { return; } - if (event.rc) { - if (event.willCommit) { + if (event.willCommit) { + if (event.rc) { this.#runValidation(source, event); } else { - if (FieldInternals.getIsChoice(source)) { - FieldInternals.setValue(source, savedChange.changeEx); - FieldInternals.send(source, { - value: FieldInternals.getValue(source), - }); - return; - } + FieldInternals.send(source, { + value: "", + formattedValue: null, + selRange: [0, 0], + }); + } + return; + } - const value = mergeChange(event); - FieldInternals.setValue(source, value); - let selStart, selEnd; - if ( - event.selStart !== savedChange.selStart || - event.selEnd !== savedChange.selEnd - ) { - // Selection has been changed by the script so apply the changes. - selStart = event.selStart; - selEnd = event.selEnd; - } else { - selEnd = selStart = savedChange.selStart + event.change.length; - } - FieldInternals.send(source, { value, selRange: [selStart, selEnd] }); + if ( + FieldInternals.getIsChoice(source) && + (savedChange.changeEx !== null || !source.editable) + ) { + if (event.rc) { + FieldInternals.setValue(source, savedChange.changeEx); } - } else if (!event.willCommit) { + // Distinguish selection replies from script assignments. + FieldInternals.send(source, { + value: FieldInternals.getValue(source), + selection: true, + }); + return; + } + + if (!event.rc) { FieldInternals.send(source, { value: savedChange.value, selRange: [savedChange.selStart, savedChange.selEnd], }); + return; + } + + const value = mergeChange(event); + FieldInternals.setValue(source, value); + let selStart, selEnd; + if ( + event.selStart !== savedChange.selStart || + event.selEnd !== savedChange.selEnd + ) { + selStart = event.selStart; + selEnd = event.selEnd; } else { - // Entry is not valid (rc == false) and it's a commit - // so just clear the field. - FieldInternals.send(source, { - value: "", - formattedValue: null, - selRange: [0, 0], - }); + selEnd = selStart = savedChange.selStart + event.change.length; } + FieldInternals.send(source, { value, selRange: [selStart, selEnd] }); } #formatAll() { diff --git a/src/scripting_api/field.js b/src/scripting_api/field.js index df1027bd80ac5..a327128babb67 100644 --- a/src/scripting_api/field.js +++ b/src/scripting_api/field.js @@ -68,6 +68,8 @@ class Field { #hasDateOrTime; + #hasTextEntry; + #hasValue; #id; @@ -155,6 +157,8 @@ class Field { this.#getTerminalChildren = data.getTerminalChildren; this.#globalEval = data.globalEval; this.#hasDateOrTime = !!data.hasDatetimeHTML; + // Changing editable later cannot replace a . + expect( + await myeval( + '(getField("select").editable = true, getField("select").value = "custom", getField("select").value)' + ) + ).toEqual("US"); + expect(send_queue.get(id).value).toEqual("US"); + }); + + it("should select an option with an empty export value", async () => { + const combos = createCombos([true, false], { + value: "FR", + items: [ + { displayValue: "France", exportValue: "FR" }, + { displayValue: "None", exportValue: "" }, + ], + }); + for (const { name, id } of combos) { + await sandbox.dispatchEventInSandbox({ + id, + name: "Keystroke", + value: "France", + change: "None", + changeEx: "", + willCommit: false, + }); + expect(send_queue.get(id)) + .withContext(name) + .toEqual({ id, value: "", selection: true }); + expect(await myeval(`getField("${name}").currentValueIndices`)) + .withContext(name) + .toEqual(1); + } + }); + + it("should keep the value of a combo when a selection is rejected", async () => { + const combos = createCombos([true, false], { + value: "A", + items: [ + { displayValue: "Same", exportValue: "A" }, + { displayValue: "Same", exportValue: "B" }, + ], + actions: { + Keystroke: [ + `if (!event.willCommit && event.changeEx === "B") event.rc = false;`, + ], + }, + }); + for (const { name, id } of combos) { + await sandbox.dispatchEventInSandbox({ + id, + name: "Keystroke", + value: "Same", + change: "Same", + changeEx: "B", + willCommit: false, + }); + expect(send_queue.get(id)) + .withContext(name) + .toEqual({ id, value: "A", selection: true }); + expect(await myeval(`getField("${name}").value`)) + .withContext(name) + .toEqual("A"); + } + }); + + it("should accept custom values and labels only for editable combos", async () => { + const [{ id }] = createCombos([true], { + value: "FR", + items: [{ displayValue: "France", exportValue: "FR" }], + }); + expect( + await myeval( + '(getField("editable").value = "Custom", [getField("editable").value, getField("editable").currentValueIndices])' + ) + ).toEqual(["Custom", -1]); + expect( + await myeval( + '(getField("editable").value = "France", [getField("editable").value, getField("editable").currentValueIndices])' + ) + ).toEqual(["FR", 0]); + await sandbox.dispatchEventInSandbox({ + id, + name: "Keystroke", + value: "C", + change: "ustom", + selStart: 1, + selEnd: 1, + willCommit: false, + }); + expect(await myeval('getField("editable").value')).toEqual("Custom"); + await sandbox.dispatchEventInSandbox({ + id, + name: "Keystroke", + value: "Franc", + change: "e", + selStart: 5, + selEnd: 5, + willCommit: false, + }); + // Echo the typed label while storing its export value. + expect(send_queue.get(id)).toEqual({ + id, + value: "France", + selRange: [6, 6], + }); + expect(await myeval('getField("editable").value')).toEqual("FR"); + await sandbox.dispatchEventInSandbox({ + id, + name: "Keystroke", + value: "F", + change: "R", + selStart: 1, + selEnd: 1, + willCommit: false, + }); + expect(send_queue.get(id)).toEqual({ id, value: "FR", selRange: [2, 2] }); + expect( + await myeval( + '[getField("editable").value, getField("editable").currentValueIndices]' + ) + ).toEqual(["FR", 0]); + expect( + await myeval( + '(getField("editable").editable = false, getField("editable").value = "Ignored", getField("editable").value)' + ) + ).toEqual("FR"); + }); + it("should send a value, execute an action and get back a new value", async () => { function compute(n) { let s = 0; diff --git a/web/annotation_layer_builder.css b/web/annotation_layer_builder.css index 8b57f55ab71ac..e334f7bff248c 100644 --- a/web/annotation_layer_builder.css +++ b/web/annotation_layer_builder.css @@ -221,6 +221,128 @@ padding: 0; } + .comboBox { + > input { + padding-inline: 4px 18px; + } + + > .comboButton { + position: absolute; + inset-block: 0; + inset-inline-end: 2px; + width: 18px; + border: none; + background: transparent; + padding: 0; + font-family: sans-serif; + line-height: normal; + + /* Use system FieldText instead of the scripted color. */ + @media (forced-colors: active) { + color: FieldText !important; + } + + &::before { + content: ""; + position: absolute; + inset-inline: 0; + top: 50%; + height: 1lh; + translate: 0 -50%; + background-color: currentColor; + mask: url("data:image/svg+xml;charset=UTF-8,") + center / contain no-repeat; + } + } + + > input:disabled ~ .comboButton { + display: none; + } + + > .comboList { + --combo-list-border-color: var( + --panel-border-color, + light-dark(rgb(240 240 244), rgb(82 82 94)) + ); + --combo-list-border-radius: 6px; + --combo-list-box-shadow: var( + --panel-box-shadow, + 0 0 4px rgb(0 0 0 / 0.2) + ); + /*#if MOZCENTRAL*/ + --combo-option-active-bg: -moz-menuhover; + --combo-option-active-fg: -moz-menuhovertext; + /*#else*/ + --combo-option-active-bg: color-mix( + in srgb, + currentColor 12%, + transparent + ); + --combo-option-active-fg: currentColor; + /*#endif*/ + + /*#if MOZCENTRAL*/ + @media (-moz-windows-mica-popups) { + --combo-list-border-color: color-mix( + in srgb, + CanvasText 30%, + transparent + ); + --combo-list-border-radius: 8px; + --combo-list-box-shadow: 0 14px 40px rgb(0 0 0 / 0.26); + } + /*#endif*/ + + @media (forced-colors: active) { + --combo-list-border-color: Canvas; + --combo-list-box-shadow: none; + --combo-option-active-bg: SelectedItem; + --combo-option-active-fg: SelectedItemText; + /* Override the inline text color. */ + color: CanvasText !important; + } + + padding: 4px; + box-sizing: border-box; + border: 1px solid var(--combo-list-border-color); + border-radius: var(--combo-list-border-radius); + box-shadow: var(--combo-list-box-shadow); + background: Canvas; + font-family: sans-serif; + max-block-size: min(50vh, 20em); + + @supports (position-area: block-end) { + margin: 0; + inset: auto; + position-area: block-end span-inline-end; + position-try-fallbacks: flip-block; + min-inline-size: anchor-size(width); + } + + > [role="option"] { + padding: 0.3333em 6px; + border-radius: var(--border-radius-xsmall, 2px); + white-space: pre; + cursor: default; + + /*#if MOZCENTRAL*/ + @media (-moz-platform: linux) { + padding: 0.25em 0.5em; + } + /*#endif*/ + + &[aria-selected="true"] { + background-color: var(--combo-option-active-bg); + color: var(--combo-option-active-fg); + } + + @media (pointer: coarse) { + padding-block: 0.9167em; + } + } + } + } + .textWidgetAnnotation textarea { resize: none; } @@ -233,12 +355,15 @@ cursor: not-allowed; } + /* Match input hover specificity so :focus still wins. */ .textWidgetAnnotation :is(input, textarea):hover, + .textWidgetAnnotation:has(> :where(.comboButton):hover) > input, .choiceWidgetAnnotation select:hover, .buttonWidgetAnnotation:is(.checkBox, .radioButton) input:hover { border: 2px solid var(--input-hover-border-color); } .textWidgetAnnotation :is(input, textarea):hover, + .textWidgetAnnotation:has(> :where(.comboButton):hover) > input, .choiceWidgetAnnotation select:hover, .buttonWidgetAnnotation.checkBox input:hover { border-radius: 2px;