Citation dialog: show placeholder after a new bubble is added (#5932)

After the first bubble is added, the focused input gets a placeholder
indicating that typing a number will add it as a page to the just-added
bubble. The placeholder is truncated if it's too close to the edge in
multi-item citations.

Also add a tip to the item details popup explaining that locators can
be typed into the main input field, with a link to the documentation.
The tip stops appearing once a typed locator has been used.

---------

Co-authored-by: Dan Stillman <dstillman@zotero.org>
This commit is contained in:
Bogdan Abaev 2026-06-01 09:16:20 -07:00 committed by Dan Stillman
parent 40f949ba94
commit 0f2d3e103d
8 changed files with 143 additions and 10 deletions

View file

@ -36,6 +36,7 @@
this._body = this.querySelector('.bubble-input.body');
this._body.addEventListener('click', this._onBodyClick.bind(this));
this._lastFocusedInput = null;
this.showJustAddedPlaceholder = false;
Utils.init(this);
DragDropHandler.init(this);
@ -123,11 +124,10 @@
if (isOnlyInput) {
document.l10n.setAttributes(this._body.firstChild, `integration-citationDialog-single-input-${dialogType}`);
}
// otherwise, add a regular aria descriptions and placeholders to all inputs
// otherwise, set default placeholders for all inputs, with special handling
// of the last input after a bubble is added with visible placeholder
else {
for (let input of [...this.querySelectorAll(".input")]) {
document.l10n.setAttributes(input, `integration-citationDialog-input-${dialogType}`);
}
Utils.setupInputPlaceholders(this.showJustAddedPlaceholder, dialogType);
}
// If any two inputs end up next to each other (e.g. after bubble is deleted),
// have them merged
@ -343,6 +343,13 @@
if (!Utils.isInputEmpty(input) || !this.contains(event.relatedTarget)) {
this._lastFocusedInput = input;
}
// Collapse a placeholder input back to regular size once focus leaves.
// The placeholder attributes are reset on the next refresh()
if (input.classList.contains("just-added-placeholder")) {
input.classList.remove("just-added-placeholder");
input.removeAttribute("title");
input.style.minWidth = "";
}
});
return input;
}
@ -604,6 +611,87 @@
return spanWidth;
},
getTextWidth(text) {
let span = document.createElement("span");
span.classList = "input";
span.innerText = text;
this.bubbleInput._body.appendChild(span);
let spanWidth = span.getBoundingClientRect().width;
span.remove();
return spanWidth;
},
// Return the longest prefix of `text` such that prefix + "…" fits within maxWidth,
// or the original text if it already fits. Used for placeholder truncation, since
// text-overflow:ellipsis doesn't work on <input> in Firefox chrome.
truncateToWidth(text, maxWidth) {
if (this.getTextWidth(text) <= maxWidth) return text;
let ellipsis = "…";
for (let i = text.length - 1; i > 0; i--) {
let candidate = text.slice(0, i) + ellipsis;
if (this.getTextWidth(candidate) <= maxWidth) {
return candidate;
}
}
return ellipsis;
},
// Set aria-descriptions and placeholders on every input in the bubble-input.
// For the just-added input (the last one, when showJustAddedPlaceholder is set), the
// placeholder is truncated to the space remaining on its line and passed to Fluent as
// a variable.
setupInputPlaceholders(showJustAddedPlaceholder, dialogType) {
let allInputs = [...this.bubbleInput.querySelectorAll(".input")];
let lastInput = allInputs[allInputs.length - 1];
let bodyRight = this.bubbleInput._body.getBoundingClientRect().right;
for (let input of allInputs) {
let isJustAdded = showJustAddedPlaceholder && input === lastInput;
if (!isJustAdded) {
// If the just-added placeholder was dismissed (e.g. by right-arrow at the
// end of the input) while the input is still focused and empty, keep the
// visible placeholder but fall back to the default search prompt, since
// searching still works. It collapses when the input loses focus (see the
// blur handler in _createInputElem())
if (input.classList.contains("just-added-placeholder")
&& document.activeElement == input && !input.value) {
let placeholder = this.truncateToWidth(
" " + Zotero.getString("integration-citationDialog-search-for-items"),
parseFloat(input.style.minWidth) || Infinity);
document.l10n.setAttributes(input, "integration-citationDialog-just-added-input-citation", { placeholder, title: "" });
continue;
}
input.classList.remove("just-added-placeholder");
document.l10n.setAttributes(input, `integration-citationDialog-input-${dialogType}`);
// Clear any stale title and min-width left over from a previous just-added state
input.removeAttribute("title");
input.style.minWidth = "";
}
if (isJustAdded && !input.classList.contains("just-added-placeholder")) {
input.classList.add("just-added-placeholder");
// Leading NBSP gives a small visual gap between the cursor and the placeholder
// text (CSS padding/text-indent on input or ::placeholder both move the cursor too)
let fullPlaceholder = " " + Zotero.getString("integration-citationDialog-just-added-input-placeholder");
let availableWidth = bodyRight - input.getBoundingClientRect().left - 20;
let placeholderWidth = this.getTextWidth(fullPlaceholder);
let placeholder, title, minWidth;
if (availableWidth >= placeholderWidth) {
placeholder = fullPlaceholder;
title = "";
minWidth = placeholderWidth;
}
else {
placeholder = this.truncateToWidth(fullPlaceholder, availableWidth);
title = fullPlaceholder;
minWidth = availableWidth;
}
// min-width keeps the placeholder visible when the input is empty, but
// lets the input grow to fit content the user types beyond the placeholder.
input.style.minWidth = minWidth + 'px';
document.l10n.setAttributes(input, "integration-citationDialog-just-added-input-citation", { placeholder, title });
}
}
},
// If a bubble is removed between two inputs we need to combine them
combineNeighboringInputs(startingNode) {
let node = startingNode;

View file

@ -1206,6 +1206,7 @@ class ListLayout extends Layout {
const IOManager = {
sectionExpandedStatus: {},
_skipInputAcceptOnEnterUntil: 0,
_timesItemsAdded: 0,
// most essential IO functionality that is added immediately on load
preInit() {
@ -1406,7 +1407,12 @@ const IOManager = {
// If no locator is provided, record which bubbles were just added.
// If a locator is typed next, these bubbles will receive it.
this._justAddedBubbles = bubbleItems;
// Only show the placeholder guidance on the first add -- after
// that, the user presumably knows about the shortcut
_id("bubble-input").showJustAddedPlaceholder = DIALOG_STATE.isCitingItems()
&& this._timesItemsAdded < 1;
}
this._timesItemsAdded++;
await CitationDataManager.addItems({ bubbleItems, index });
// Refresh the itemTree if in library mode
if (currentLayout.type == "library") {
@ -1695,6 +1701,9 @@ const IOManager = {
input.value = "";
input.dispatchEvent(new Event('input', { bubbles: true }));
this.updateBubbleInput();
// The typed-locator shortcut has been used, so stop showing the tip
// about it in the item details popup
Zotero.Prefs.set("integration.citationDialogShowLocatorTip", false);
return;
}
}
@ -1735,9 +1744,14 @@ const IOManager = {
_deleteItem(dialogReferenceID) {
CitationDataManager.deleteItem({ dialogReferenceID });
// If the citation is emptied, show the placeholder guidance again on the next add
if (!CitationDataManager.items.length) {
this._timesItemsAdded = 0;
}
if (currentLayout.type == "library") {
libraryLayout.refreshItemsView();
}
this._clearJustAddedBubbles();
this.updateBubbleInput();
// Always refresh items list to make sure the opened and selected items are up to date
currentLayout.refreshItemsList();
@ -1835,9 +1849,14 @@ const IOManager = {
bubbleItem.label = "page";
}
IOManager._hideLoadingSpinner();
// Clear the input and update bubbles
// Clear the input and update bubbles. The placeholder stays, since both of its
// suggestions still apply -- typed digits keep appending to the locator, and
// any other input starts a search
input.value = "";
IOManager.updateBubbleInput();
// The typed-locator shortcut has been used, so stop showing the tip
// about it in the item details popup
Zotero.Prefs.set("integration.citationDialogShowLocatorTip", false);
// Disable Enter on input from accepting the dialog for the next 500ms;
// If one intends to confirmed the numeric locator by pressing Enter (via _handleInputEnter),
// we ensure that the Enter keypress won't happen right after when the locator is added to
@ -1851,11 +1870,14 @@ const IOManager = {
// and Enter is presses, just-added bubbles get that locator.
_clearJustAddedBubbles(event) {
if (!this._justAddedBubbles) return;
// on keydown, only proceed if it's an arrow key
let navigationKeys = ["ArrowUp", "ArrowDown", "ArrowRight", "ArrowLeft"];
if (event && event.type == "keydown" && !navigationKeys.includes(event.key)) return;
// On keydown, only proceed for left/right arrows, which move to another
// reference (e.g. to explicitly search for a year). Up/down arrows just move
// the list selection while focus remains in the input, so locator entry
// stays active.
if (event && event.type == "keydown" && !["ArrowLeft", "ArrowRight"].includes(event.key)) return;
// clear just added bubbles and update bubble input to reflect that
this._justAddedBubbles = null;
_id("bubble-input").showJustAddedPlaceholder = false;
this.updateBubbleInput();
},

View file

@ -31,7 +31,9 @@
xmlns="http://www.w3.org/1999/xhtml"
xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
drawintitlebar-platforms="mac,win"
resizable="false">
resizable="false"
windowtype="zotero:citation-dialog"
>
<head>
<title data-l10n-id="integration-citationDialog"></title>
<link rel="localization" href="zotero.ftl"/>
@ -154,6 +156,9 @@
<!--fx128: size="0" forces select have default native style -->
<select name="locator" id="label" class="details-label" size="0"></select>
<input id="locator" class="details-data" aria-labelledby="label" aria-describedby="itemDetails-combinedInfo"/>
<div class="details-locator-info" data-l10n-id="integration-citationDialog-details-locator-info">
<a data-l10n-name="docs-link" class="link" tabindex="0" href="https://www.zotero.org/support/word_processor_plugin_usage#page_and_other_locators" onclick="Zotero.launchURL(this.href)"/>
</div>
<label class="details-label" for="prefix" data-l10n-id="integration-citationDialog-details-prefix"></label>
<input id="prefix" class="details-data"/>

View file

@ -99,6 +99,9 @@ export class CitationDialogPopupsHandler {
popup.style.top = `${bubbleRect.bottom + 10}px`;
this._getNode("#itemDetails .show").hidden = !this.bubbleItem.item.id;
// Hide the typed-locator tip once the shortcut has been used
this._getNode("#itemDetails .details-locator-info").hidden
= !Zotero.Prefs.get("integration.citationDialogShowLocatorTip");
let topLevelItem = this.bubbleItem.item.topLevelItem;
// Add header and fill inputs with their values

View file

@ -30,6 +30,7 @@ integration-citationDialog-section-cited = { $count ->
integration-citationDialog-details-suffix = Suffix
integration-citationDialog-details-prefix = Prefix
integration-citationDialog-details-suppressAuthor = Omit Author
integration-citationDialog-details-locator-info = Tip: You can also type page numbers and other locators directly into the main field. <a data-l10n-name="docs-link">Learn more</a>
integration-citationDialog-details-includeComments = Include Comments
integration-citationDialog-details-remove = { general-remove }
integration-citationDialog-details-done =
@ -77,6 +78,11 @@ integration-citationDialog-aria-bubble =
integration-citationDialog-single-input-citation =
.placeholder = { integration-citationDialog-search-for-items }
.aria-description = Press Tab to select items to add to this citation. Press Escape to discard the changes and close the dialog.
integration-citationDialog-just-added-input-placeholder = Type “10-15” to cite pages, or search for items
integration-citationDialog-just-added-input-citation =
.placeholder = { $placeholder }
.title = { $title }
.aria-description = { integration-citationDialog-general-instructions }
integration-citationDialog-input-citation =
.placeholder = { integration-citationDialog-search-for-items }
.aria-description = { integration-citationDialog-general-instructions }

View file

@ -152,6 +152,7 @@ pref("extensions.zotero.integration.keepAddCitationDialogRaised", false);
pref("extensions.zotero.integration.upgradeTemplateDelayedOn", 0);
pref("extensions.zotero.integration.dontPromptMendeleyImport", false);
pref("extensions.zotero.integration.citationDialogMode", "last-used");
pref("extensions.zotero.integration.citationDialogShowLocatorTip", true);
pref("extensions.zotero.integration.annotationDialogIncludeComments", true);
pref("extensions.zotero.integration.citationPreviewShown", true);

View file

@ -786,6 +786,14 @@
height: 24px; // same height as input
}
}
.details-locator-info {
padding-inline-start: 5px;
grid-column: 2;
// pull closer to the locator input above
margin-top: -4px;
color: var(--fill-secondary);
font-size: .93rem;
}
}
.buttons {
display: flex;

View file

@ -52,7 +52,7 @@ bubble-input {
// inputs initially occupy no width so they don't misalign bubbles at the
// start of each line. On focus, set their width for the cursor to appear
// and offset it by negative margin to avoid bubbles shifting
&.empty:not(.full-width):focus {
&.empty:not(.full-width):not(.just-added-placeholder):focus {
min-width: 1px;
margin-inline-start: -1px;
}