diff --git a/dsiUnits-js/src/dsiUnitInput.js b/dsiUnits-js/src/dsiUnitInput.js index 460e2d58b82f6d681161fc7c8360c3961b318fd1..00d7db445f3f733cdd394797ff82cee2778753b7 100644 --- a/dsiUnits-js/src/dsiUnitInput.js +++ b/dsiUnits-js/src/dsiUnitInput.js @@ -1,3 +1,4 @@ +// src/dsiUnitInput.js import { DSIUnit } from "./dsiUnit.js"; import { dsiPrefixesHTML, dsiUnitsHTML, dsiKeyWords } from "./unitStrings.js"; @@ -13,14 +14,13 @@ export class DSIUnitInput extends HTMLElement { constructor() { super(); this.attachShadow({ mode: "open" }); - // Internal state: store raw value + // Internal state: store raw value. this._rawValue = ""; - // Live update flag: if true, update suggestions and rendering on each keystroke. this.liveUpdate = true; - // Allowed tokens (may be overridden via attribute) this.allowedTokens = defaultAllowedTokens; + this.selectedSuggestionIndex = -1; - // Create our elements. + // Create elements. this.input = document.createElement("input"); this.input.type = "text"; this.input.id = "dsiInput"; @@ -31,20 +31,23 @@ export class DSIUnitInput extends HTMLElement { this.display = document.createElement("div"); // For rendered output on blur. this.display.id = "dsiDisplay"; + this.display.style.cursor = "text"; // Make it clickable for editing. - // Wrap in a container (position: relative for suggestions). + // Container for input and suggestions. const container = document.createElement("div"); container.style.position = "relative"; container.appendChild(this.input); container.appendChild(this.suggestions); container.appendChild(this.display); - // Append styles into the shadow DOM. + // Append styles. const style = document.createElement("style"); style.textContent = ` #dsiInput { width: 400px; font-family: monospace; + padding: 4px; + box-sizing: border-box; } .autocomplete-list { position: absolute; @@ -59,17 +62,23 @@ export class DSIUnitInput extends HTMLElement { padding: 4px; cursor: pointer; } - .autocomplete-item:hover { - background-color: #eee; + .autocomplete-item.selected { + background-color: #bde4ff; } #dsiDisplay { margin-top: 5px; + padding: 4px; + border: 1px solid #ccc; + display: none; + width: 400px; + font-family: monospace; } `; this.shadowRoot.append(style, container); // Bind event handlers. this.onInput = this.onInput.bind(this); + this.onKeyDown = this.onKeyDown.bind(this); this.onBlur = this.onBlur.bind(this); this.onFocus = this.onFocus.bind(this); } @@ -89,25 +98,53 @@ export class DSIUnitInput extends HTMLElement { } connectedCallback() { - // Check for external suggestions list. const suggestionsAttr = this.getAttribute("suggestions-list"); if (suggestionsAttr) { this.allowedTokens = suggestionsAttr.split(",").map(s => s.trim()); } this.input.addEventListener("input", this.onInput); + this.input.addEventListener("keydown", this.onKeyDown); this.input.addEventListener("blur", this.onBlur); this.input.addEventListener("focus", this.onFocus); + this.display.addEventListener("click", this.onFocus); } disconnectedCallback() { this.input.removeEventListener("input", this.onInput); + this.input.removeEventListener("keydown", this.onKeyDown); this.input.removeEventListener("blur", this.onBlur); this.input.removeEventListener("focus", this.onFocus); + this.display.removeEventListener("click", this.onFocus); + } + + // Determine contextual suggestions based on previous complete token. + getContextualSuggestions() { + const value = this.input.value; + const tokens = value.split("\\").filter(Boolean); + if (tokens.length <= 1) { + return this.allowedTokens; + } + const prevToken = tokens[tokens.length - 2]; + if (prevToken in dsiPrefixesHTML) { + // After a prefix, suggest only allowed units. + return Object.keys(dsiUnitsHTML); + } else if (prevToken in dsiUnitsHTML) { + // After a unit, suggest all allowed tokens. + // If "\per" exists in the value, omit "per". + if (value.includes("\\per")) { + return this.allowedTokens.filter(token => token !== "per"); + } + return this.allowedTokens; + } else if (prevToken === "per" || prevToken === "tothe") { + return this.allowedTokens; + } else { + return this.allowedTokens; + } } onInput(e) { - // Store the raw value. this._rawValue = this.input.value; + this.selectedSuggestionIndex = -1; this.updateSuggestions(); if (this.liveUpdate) { this.renderOutput(); @@ -117,19 +154,29 @@ export class DSIUnitInput extends HTMLElement { updateSuggestions() { const cursorPos = this.input.selectionStart; const value = this.input.value; + // Check: if caret is inside the braces of a "tothe" token, do not show suggestions. + const regexTothe = /\\tothe\{([^}]*)$/; const substring = value.substring(0, cursorPos); - // Look for a pattern: a backslash followed by at least one letter at the end. - const regex = /\\([a-zA-Z]+)$/; + const matchTothe = substring.match(regexTothe); + if (matchTothe) { + this.suggestions.style.display = "none"; + return; + } + + // Use regex to capture current token (may be empty). + const regex = /\\([a-zA-Z]*)$/; const match = substring.match(regex); + let currentToken = ""; if (match) { - const currentToken = match[1]; - const suggestions = this.allowedTokens.filter(token => - token.startsWith(currentToken) - ); - this.showSuggestions(suggestions, substring.length - currentToken.length); - } else { - this.suggestions.style.display = "none"; + currentToken = match[1]; // might be empty } + // Determine suggestions based on context. + const contextSuggestions = this.getContextualSuggestions(); + const suggestions = currentToken === "" + ? contextSuggestions + : contextSuggestions.filter(token => token.startsWith(currentToken)); + this.selectedSuggestionIndex = suggestions.length > 0 ? 0 : -1; + this.showSuggestions(suggestions, value.length - currentToken.length); } showSuggestions(list, tokenStartIndex) { @@ -138,30 +185,81 @@ export class DSIUnitInput extends HTMLElement { this.suggestions.style.display = "none"; return; } - list.forEach(token => { + list.forEach((token, index) => { const item = document.createElement("div"); item.textContent = "\\" + token; item.className = "autocomplete-item"; + if (index === this.selectedSuggestionIndex) { + item.classList.add("selected"); + } item.addEventListener("mousedown", (e) => { e.preventDefault(); - const value = this.input.value; - const before = value.substring(0, tokenStartIndex); - const after = value.substring(this.input.selectionStart); - this.input.value = before + "\\" + token + after; - this._rawValue = this.input.value; - this.suggestions.style.display = "none"; - this.input.focus(); - this.renderOutput(); + this.acceptSuggestion(token, tokenStartIndex); }); this.suggestions.appendChild(item); }); this.suggestions.style.display = "block"; } + onKeyDown(e) { + if (this.suggestions.style.display !== "none") { + const items = this.suggestions.querySelectorAll(".autocomplete-item"); + if (e.key === "ArrowDown") { + e.preventDefault(); + if (items.length > 0) { + this.selectedSuggestionIndex = (this.selectedSuggestionIndex + 1) % items.length; + this.updateSuggestionSelection(); + } + } else if (e.key === "ArrowUp") { + e.preventDefault(); + if (items.length > 0) { + this.selectedSuggestionIndex = (this.selectedSuggestionIndex - 1 + items.length) % items.length; + this.updateSuggestionSelection(); + } + } else if (["Enter", "Tab", "ArrowRight"].includes(e.key)) { + if (this.selectedSuggestionIndex !== -1) { + e.preventDefault(); + const selectedItem = items[this.selectedSuggestionIndex]; + const token = selectedItem.textContent.slice(1); // remove backslash + const tokenStartIndex = this.input.value.lastIndexOf("\\"); + this.acceptSuggestion(token, tokenStartIndex); + } + } + } + } + + updateSuggestionSelection() { + const items = this.suggestions.querySelectorAll(".autocomplete-item"); + items.forEach((item, idx) => { + if (idx === this.selectedSuggestionIndex) { + item.classList.add("selected"); + } else { + item.classList.remove("selected"); + } + }); + } + + acceptSuggestion(token, tokenStartIndex) { + const value = this.input.value; + const before = value.substring(0, tokenStartIndex); + const after = value.substring(this.input.selectionStart); + if (token === "tothe") { + // Append {} and place caret inside. + this.input.value = before + "\\" + token + "{}" + after; + this._rawValue = this.input.value; + const newPos = before.length + token.length + 2; + this.input.setSelectionRange(newPos, newPos); + } else { + this.input.value = before + "\\" + token + after; + this._rawValue = this.input.value; + } + this.suggestions.style.display = "none"; + this.renderOutput(); + } + renderOutput() { try { const unit = new DSIUnit(this.input.value); - // Display the rendered output using the DSIUnit's toHTML(). this.display.innerHTML = unit.toHTML(); if (unit.warnings && unit.warnings.length > 0) { this.display.title = unit.warnings.join("; "); @@ -174,7 +272,6 @@ export class DSIUnitInput extends HTMLElement { } onBlur(e) { - // On blur, hide the input and show the rendered output. this.renderOutput(); this.input.style.display = "none"; this.display.style.display = "block"; @@ -182,18 +279,17 @@ export class DSIUnitInput extends HTMLElement { } onFocus(e) { - // On focus, restore the raw value for editing. this.input.style.display = "block"; this.display.style.display = "none"; this.input.value = this._rawValue; + this.input.focus(); + this.input.setSelectionRange(this.input.value.length, this.input.value.length); } - // Expose the current raw value. get value() { return this._rawValue; } - // Allow setting live update mode. set live(value) { this.liveUpdate = Boolean(value); } diff --git a/dsiUnits-js/tests/visual_test.html b/dsiUnits-js/tests/visual_test.html index 696c5c852840040e8b1b7313938cfcdc61573e73..52ac585b4badac379b7c799fadf482bf6468cbd7 100644 --- a/dsiUnits-js/tests/visual_test.html +++ b/dsiUnits-js/tests/visual_test.html @@ -89,6 +89,9 @@ display: block; margin-bottom: 40px; } + .autocomplete-item.selected { + background-color: #bde4ff; + } </style> </head> <body>