1// Pure HTML-string builders for the query bar. No Stimulus/DOM state here â 2// everything takes plain data in and returns markup, so the controller only 3// has to orchestrate state changes and hand data to these. 4import { 5 getFieldSchema, 6 humanize, 7 isHiddenValuePredicate, 8 operationLabels, 9 valueOptionsFor, 10} from "controllers/query_bar_schema"; 11 12export function escapeHtml(value) { 13 return String(value).replace( 14 /[&<>"']/g, 15 (char) => 16 ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[ 17 char 18 ], 19 ); 20} 21 22export function placeholderForStage(stage, hasClauses) { 23 if (stage === "operator") return "Choose operatorâ¦"; 24 if (stage === "value") return "Valueâ¦"; 25 return hasClauses ? "AND fieldâ¦" : "Filter⦠e.g. status:active"; 26} 27 28export function draftInputMarkup() { 29 return `<input type="text" autocomplete="off" spellcheck="false" role="combobox" aria-expanded="false" 30 class="min-w-32 flex-1 border-0 bg-transparent p-1 text-sm text-base-content outline-none placeholder:text-base-content/40" 31 data-query-bar-target="input" 32 data-action="input->query-bar#onInput keydown->query-bar#onKeydown paste->query-bar#onPaste blur->query-bar#onBlur focus->query-bar#onFocus" />`; 33} 34 35// A new clause keeps its completed draft segments visible while the next 36// segment is being selected. These spans are intentionally non-interactive: 37// Backspace is the small, reliable way to move back through a draft. 38export function draftClauseMarkup(draft) { 39 if (draft.stage === "field") return draftInputMarkup(); 40 41 const fieldLabel = [...draft.path, draft.attribute].map(humanize).join(" â "); 42 const fieldSegment = `<span class="font-medium text-base-content">${escapeHtml(fieldLabel)}</span>`; 43 44 if (draft.stage === "operator") { 45 return `${fieldSegment}${segmentInputMarkup(draft.text)}`; 46 } 47 48 const operatorLabel = 49 operationLabels[draft.predicate]?.label || draft.predicate; 50 const operatorSegment = `<span class="text-base-content/60">${escapeHtml(operatorLabel)}</span>`; 51 return `${fieldSegment}${operatorSegment}${segmentInputMarkup(draft.text)}`; 52} 53 54// A small, bordered input that replaces a single clause segment in place 55// while it's being edited, sized to its content via the `size` attribute so 56// it doesn't balloon into the rest of the bar. 57export function segmentInputMarkup(text) { 58 const size = Math.max(4, text.length + 2); 59 return `<input type="text" autocomplete="off" spellcheck="false" role="combobox" aria-expanded="false" size="${size}" 60 class="w-48 max-w-full flex-none rounded border border-primary bg-transparent px-1 py-0.5 text-sm text-base-content outline-none" 61 data-query-bar-target="input" 62 data-action="input->query-bar#onInput keydown->query-bar#onKeydown paste->query-bar#onPaste blur->query-bar#onBlur focus->query-bar#onFocus" />`; 63} 64 65export function clauseValueLabel(clause, fieldSchema) { 66 if (isHiddenValuePredicate(clause.predicate)) return null; 67 68 const options = valueOptionsFor(fieldSchema, clause.predicate); 69 const values = Array.isArray(clause.value) ? clause.value : [clause.value]; 70 return values.map((value) => options?.[value] ?? value).join(", "); 71} 72 73// `activeStage`, when set to "field" | "operator" | "value", swaps that one 74// segment for an inline input while the other segments stay as plain buttons 75// â so editing a clause never removes it or its siblings from view. 76export function clauseMarkup( 77 schemaTree, 78 clause, 79 activeStage = null, 80 activeText = "", 81) { 82 const fieldSchema = getFieldSchema( 83 schemaTree, 84 clause.path, 85 clause.attribute, 86 ) || { type: "string" }; 87 const fieldLabel = [...clause.path, clause.attribute] 88 .map(humanize) 89 .join(" â "); 90 const operatorLabel = 91 operationLabels[clause.predicate]?.label || clause.predicate; 92 const valueLabel = clauseValueLabel(clause, fieldSchema); 93 94 const fieldSegment = 95 activeStage === "field" 96 ? segmentInputMarkup(activeText) 97 : `<button type="button" class="font-medium text-base-content hover:underline" data-stage="field" data-action="click->
97query-bar#editSegment">${escapeHtml(fieldLabel)}</button>`; 98 99 const operatorSegment = 100 activeStage === "operator" 101 ? segmentInputMarkup(activeText) 102 : `<button type="button" class="text-base-content/60 hover:underline" data-stage="operator" data-action="click->query-bar#editSegment">${escapeHtml(operatorLabel)}</button>`; 103 104 const valueSegment = 105 activeStage === "value" 106 ? segmentInputMarkup(activeText) 107 : valueLabel === null 108 ? "" 109 : `<button type="button" class="font-semibold text-primary hover:underline" data-stage="value" data-action="click->query-bar#editSegment">${escapeHtml(valueLabel)}</button>`; 110 111 return ` 112 <span class="inline-flex items-center gap-1 rounded-md bg-base-200 px-2 py-1 text-sm" data-clause-id="${clause.id}"> 113 ${fieldSegment} 114 ${operatorSegment} 115 ${valueSegment} 116 <button type="button" class="text-base-content/40 hover:text-error" data-action="click->query-bar#removeClause" aria-label="Remove filter">Ã</button> 117 </span> 118 `; 119} 120 121export function suggestionListMarkup(suggestions, highlightedIndex) { 122 return `<ul role="listbox">${suggestions 123 .map( 124 (item, index) => ` 125 <li role="option" aria-selected="${index === highlightedIndex}"> 126 <button type="button" 127 class="flex w-full items-center justify-between gap-2 px-3 py-1.5 text-left text-sm ${index === highlightedIndex ? "bg-base-200" : ""} ${item.kind === "association" ? "italic text-base-content/60" : "text-base-content"}" 128 data-action="pointerdown->query-bar#pickSuggestion mouseenter->query-bar#hoverSuggestion" 129 data-index="${index}" 130 > 131 <span>${escapeHtml(item.label)}</span> 132 ${item.selected ? '<span aria-hidden="true">â</span>' : ""} 133 </button> 134 </li>`, 135 ) 136 .join("")}</ul>`; 137};
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.