Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions assets/styles/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -105,3 +105,11 @@
white-space: normal;
text-overflow: clip;
}

.prompt-suggestion-search-clear {
@apply absolute right-2 top-1/2 -translate-y-1/2 inline-flex items-center justify-center rounded text-dark-400 hover:text-dark-700 dark:hover:text-dark-200 transition-colors;
}

.prompt-suggestion mark {
@apply rounded-sm bg-yellow-200 px-0.5 text-dark-900 dark:bg-yellow-500/40 dark:text-yellow-100;
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ export default class extends Controller {
"collapseButton",
"expandCollapseWrapper",
"suggestionList",
"searchInput",
"clearSearchButton",
"noResults",
"formModal",
"formInput",
"formTitle",
Expand Down Expand Up @@ -49,6 +52,12 @@ export default class extends Controller {

declare readonly hasSuggestionListTarget: boolean;
declare readonly suggestionListTarget: HTMLElement;
declare readonly hasSearchInputTarget: boolean;
declare readonly searchInputTarget: HTMLInputElement;
declare readonly hasClearSearchButtonTarget: boolean;
declare readonly clearSearchButtonTarget: HTMLButtonElement;
declare readonly hasNoResultsTarget: boolean;
declare readonly noResultsTarget: HTMLElement;
declare readonly hasFormModalTarget: boolean;
declare readonly formModalTarget: HTMLElement;
declare readonly hasFormInputTarget: boolean;
Expand Down Expand Up @@ -76,6 +85,10 @@ export default class extends Controller {
/** Index of the suggestion pending deletion */
deleteIndex: number | null = null;

connect(): void {
this.applySearchFilter();
}

// ─── Display: insert, hover, expand/collapse ────────────────

/**
Expand Down Expand Up @@ -114,8 +127,12 @@ export default class extends Controller {
* Show all hidden suggestions and toggle expand/collapse buttons.
*/
expand(): void {
if (this.hasActiveSearchQuery()) {
return;
}

// Show all hidden suggestion rows (the parent wrapper divs)
this.suggestionTargets.forEach((button) => {
this.getSuggestionButtons().forEach((button) => {
const row = button.closest("[data-index]") as HTMLElement | null;
if (row) {
row.classList.remove("hidden");
Expand All @@ -134,7 +151,11 @@ export default class extends Controller {
* Hide suggestions beyond maxVisible and toggle expand/collapse buttons.
*/
collapse(): void {
this.suggestionTargets.forEach((button, index) => {
if (this.hasActiveSearchQuery()) {
return;
}

this.getSuggestionButtons().forEach((button, index) => {
if (index >= this.maxVisibleValue) {
const row = button.closest("[data-index]") as HTMLElement | null;
if (row) {
Expand All @@ -151,6 +172,20 @@ export default class extends Controller {
}
}

handleSearchInput(): void {
this.applySearchFilter();
}

clearSearch(): void {
if (!this.hasSearchInputTarget) {
return;
}

this.searchInputTarget.value = "";
this.applySearchFilter();
this.searchInputTarget.focus();
}

// ─── Add / Edit modal ───────────────────────────────────────

/**
Expand Down Expand Up @@ -375,7 +410,7 @@ export default class extends Controller {
].join(" ");
button.className =
"prompt-suggestion flex-1 px-3 py-1.5 text-xs border border-dark-300 dark:border-dark-600 text-dark-600 dark:text-dark-400 hover:bg-dark-100 dark:hover:bg-dark-700 hover:text-dark-900 dark:hover:text-dark-100 cursor-pointer";
button.textContent = text;
this.setButtonTextWithHighlight(button, text, "");

const actions = document.createElement("div");
actions.className =
Expand Down Expand Up @@ -414,8 +449,7 @@ export default class extends Controller {
container.appendChild(row);
});

// Update expand/collapse buttons based on the new suggestion count
this.updateExpandCollapseState(suggestions.length);
this.applySearchFilter();
}

/**
Expand Down Expand Up @@ -449,6 +483,145 @@ export default class extends Controller {
}
}

private applySearchFilter(): void {
const query = this.getSearchQuery();
const suggestionButtons = this.getSuggestionButtons();
let matchingCount = 0;

suggestionButtons.forEach((button, index) => {
const row = button.closest("[data-index]") as HTMLElement | null;
const text = button.dataset.text ?? "";

if (query === "") {
this.setButtonTextWithHighlight(button, text, "");
if (row) {
row.classList.toggle("hidden", index >= this.maxVisibleValue);
}

return;
}

const isMatch = text.toLowerCase().includes(query);
this.setButtonTextWithHighlight(button, text, isMatch ? query : "");

if (row) {
row.classList.toggle("hidden", !isMatch);
}

if (isMatch) {
matchingCount++;
}
});

this.updateClearSearchButtonVisibility(query !== "");

if (query === "") {
this.showNoResults(false);
this.updateExpandCollapseState(suggestionButtons.length);

return;
}

this.hideExpandCollapseControlsWhileSearching();
this.showNoResults(matchingCount === 0);
}

private getSearchQuery(): string {
if (!this.hasSearchInputTarget) {
return "";
}

return this.searchInputTarget.value.trim().toLowerCase();
}

private hasActiveSearchQuery(): boolean {
return this.getSearchQuery() !== "";
}

private getSuggestionButtons(): HTMLButtonElement[] {
if (!this.hasSuggestionListTarget) {
return this.suggestionTargets;
}

const buttons = Array.from(
this.suggestionListTarget.querySelectorAll<HTMLButtonElement>(
'[data-prompt-suggestions-target="suggestion"]',
),
);

if (buttons.length > 0 || this.suggestionListTarget.children.length === 0) {
return buttons;
}

return this.suggestionTargets;
}

private updateClearSearchButtonVisibility(show: boolean): void {
if (this.hasClearSearchButtonTarget) {
this.clearSearchButtonTarget.classList.toggle("hidden", !show);
}
}

private showNoResults(show: boolean): void {
if (this.hasNoResultsTarget) {
this.noResultsTarget.classList.toggle("hidden", !show);
}
}

private hideExpandCollapseControlsWhileSearching(): void {
if (this.hasExpandCollapseWrapperTarget) {
this.expandCollapseWrapperTarget.classList.add("hidden");
}

if (this.hasExpandButtonTarget) {
this.expandButtonTarget.classList.add("hidden");
}

if (this.hasCollapseButtonTarget) {
this.collapseButtonTarget.classList.add("hidden");
}
}

private setButtonTextWithHighlight(button: HTMLButtonElement, text: string, query: string): void {
if (query === "") {
button.textContent = text;

return;
}

const normalizedText = text.toLowerCase();
const normalizedQuery = query.toLowerCase();
const firstMatchIndex = normalizedText.indexOf(normalizedQuery);

if (firstMatchIndex === -1) {
button.textContent = text;

return;
}

const fragment = document.createDocumentFragment();
let cursor = 0;

while (cursor < text.length) {
const matchIndex = normalizedText.indexOf(normalizedQuery, cursor);
if (matchIndex === -1) {
fragment.append(document.createTextNode(text.slice(cursor)));
break;
}

if (matchIndex > cursor) {
fragment.append(document.createTextNode(text.slice(cursor, matchIndex)));
}

const mark = document.createElement("mark");
mark.textContent = text.slice(matchIndex, matchIndex + query.length);
fragment.append(mark);
cursor = matchIndex + query.length;
}

button.replaceChildren(fragment);
}

private sanitizeText(raw: string): string {
return (
raw
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,24 @@
</button>
</div>

<div class="relative mb-2">
<input type="text"
{{ stimulus_target('prompt-suggestions', 'searchInput') }}
{{ stimulus_action('prompt-suggestions', 'handleSearchInput', 'input') }}
placeholder="{{ 'editor.prompt_suggestion_search_placeholder'|trans }}"
class="w-full px-3 py-1.5 pr-8 text-xs border border-dark-300 dark:border-dark-600 rounded-md bg-white dark:bg-dark-800 text-dark-900 dark:text-dark-100 placeholder-dark-400 dark:placeholder-dark-500 focus:outline-none focus:ring-1 focus:ring-primary-500 focus:border-primary-500">
<button type="button"
{{ stimulus_target('prompt-suggestions', 'clearSearchButton') }}
{{ stimulus_action('prompt-suggestions', 'clearSearch', 'click') }}
class="prompt-suggestion-search-clear hidden"
aria-label="{{ 'editor.prompt_suggestion_search_clear'|trans }}"
title="{{ 'editor.prompt_suggestion_search_clear'|trans }}">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-3.5 h-3.5">
<path d="M6.28 5.22a.75.75 0 0 0-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 1 0 1.06 1.06L10 11.06l3.72 3.72a.75.75 0 1 0 1.06-1.06L11.06 10l3.72-3.72a.75.75 0 0 0-1.06-1.06L10 8.94 6.28 5.22Z" />
</svg>
</button>
</div>

{# Suggestions list #}
<div {{ stimulus_target('prompt-suggestions', 'suggestionList') }}
class="flex flex-col gap-2">
Expand Down Expand Up @@ -67,6 +85,11 @@
{% endfor %}
</div>

<div {{ stimulus_target('prompt-suggestions', 'noResults') }}
class="hidden py-2 text-xs text-dark-500 dark:text-dark-400">
{{ 'editor.prompt_suggestion_no_results'|trans }}
</div>

<div {{ stimulus_target('prompt-suggestions', 'expandCollapseWrapper') }}
class="mt-2 {{ promptSuggestions|length <= 3 ? 'hidden' : '' }}">
<button type="button"
Expand Down
Loading