Skip to content

Commit

Permalink
Add @floating-ui for popovers (anchor positioning does not work in Fi…
Browse files Browse the repository at this point in the history
…refox) and abstract popover behaviour, adding ErrorPopover as inheritor

With some tidying to do for popover positioning, display and update
  • Loading branch information
jonathonherbert committed Jul 19, 2024
1 parent 8a0e4a6 commit 667a1b0
Show file tree
Hide file tree
Showing 14 changed files with 249 additions and 98 deletions.
Binary file added bun.lockb
Binary file not shown.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{ "dependencies": { "@floating-ui/dom": "^1.6.7" } }
8 changes: 0 additions & 8 deletions project/metals.sbt

This file was deleted.

Binary file modified prosemirror-client/bun.lockb
Binary file not shown.
6 changes: 3 additions & 3 deletions prosemirror-client/src/cqlInput/CqlInput.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { test, mock, expect } from "bun:test";
import {
contentEditableTestId,
createCqlInput,
popoverTestId,
typeaheadTestId,
} from "./CqlInput";
import { TestCqlService } from "./fixtures/TestCqlService";
import { findByTestId } from "@testing-library/dom";
Expand Down Expand Up @@ -68,7 +68,7 @@ const selectPopoverOption = async (
container: HTMLElement,
optionLabel: string
) => {
const popoverContainer = await findByShadowTestId(container, popoverTestId);
const popoverContainer = await findByShadowTestId(container, typeaheadTestId);
await findByShadowText(popoverContainer, optionLabel);
await typeIntoInput(container, "{Enter}");
};
Expand All @@ -94,7 +94,7 @@ test("displays a popover when a tag prompt is entered", async () => {
await moveCursorToEnd(container);
await typeIntoInput(container, "+");

const popoverContainer = await findByShadowTestId(container, popoverTestId);
const popoverContainer = await findByShadowTestId(container, typeaheadTestId);
await findByShadowText(popoverContainer, "Tag");
await findByShadowText(popoverContainer, "Section");
});
Expand Down
43 changes: 28 additions & 15 deletions prosemirror-client/src/cqlInput/CqlInput.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,16 +63,6 @@ template.innerHTML = `
border-radius: ${baseBorderRadius}px;
}
#cql-popover {
width: 500px;
margin: 0;
padding: 0;
top: anchor(end);
font-size: ${baseFontSize}px;
border-radius: ${baseBorderRadius}px;
position-anchor: --cql-input;
}
.Cql__Option {
padding: 5px;
}
Expand Down Expand Up @@ -105,11 +95,27 @@ template.innerHTML = `
border-radius: ${baseBorderRadius}px;
border: none;
}
.Cql__TypeaheadPopover, .Cql__ErrorPopover {
display: block;
width: 500px;
margin: 0;
padding: 0;
top: anchor(end);
font-size: ${baseFontSize}px;
border-radius: ${baseBorderRadius}px;
position-anchor: --cql-input;
}
.Cql__ErrorPopover {
width: max-content;
}
</style>
`;

export const contentEditableTestId = "cql-input-contenteditable";
export const popoverTestId = "cql-input-popover";
export const typeaheadTestId = "cql-input-typeahead";
export const errorTestId = "cql-input-error";

export const createCqlInput = (
cqlService: CqlServiceInterface,
Expand All @@ -120,13 +126,19 @@ export const createCqlInput = (

connectedCallback() {
const cqlInputId = "cql-input";
const cqlPopoverId = "cql-popover";
const cqlTypeaheadId = "cql-typeahead";
const cqlErrorId = "cql-error";
const shadow = this.attachShadow({ mode: "open" });

shadow.innerHTML = `<div id="${cqlInputId}"></div><div id="${cqlPopoverId}" data-testid="${popoverTestId}" popover anchor="${cqlInputId}"></div>`;
shadow.innerHTML = `
<div id="${cqlInputId}"></div>
<div id="${cqlTypeaheadId}" class="Cql__TypeaheadPopover" data-testid="${typeaheadTestId}" popover anchor="${cqlInputId}"></div>
<div id="${cqlErrorId}" class="Cql__ErrorPopover" data-testid="${errorTestId}" popover></div>
`;
shadow.appendChild(template.content.cloneNode(true));
const cqlInput = shadow.getElementById(cqlInputId)!;
const cqlPopover = shadow.getElementById(cqlPopoverId)!;
const typeaheadEl = shadow.getElementById(cqlTypeaheadId)!;
const errorEl = shadow.getElementById(cqlErrorId)!;

const onChange = (detail: QueryChangeEventDetail) => {
this.dispatchEvent(
Expand All @@ -139,7 +151,8 @@ export const createCqlInput = (
const editorNode = createEditor({
initialValue: this.getAttribute("initial-value") ?? "",
mountEl: cqlInput,
popoverEl: cqlPopover,
typeaheadEl,
errorEl,
cqlService,
debugEl,
onChange,
Expand Down
51 changes: 51 additions & 0 deletions prosemirror-client/src/cqlInput/ErrorPopover.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { Mapping } from "prosemirror-transform";
import { EditorView } from "prosemirror-view";
import { CqlError } from "../services/CqlService";
import { Popover } from "./Popover";

export class ErrorPopover extends Popover {
private debugContainer: HTMLElement | undefined;

public constructor(
public view: EditorView,
public popoverEl: HTMLElement,
debugEl?: HTMLElement
) {
super(view, popoverEl);
if (debugEl) {
this.debugContainer = document.createElement("div");
debugEl.appendChild(this.debugContainer);
}
}

public updateErrorMessage = async (
error: CqlError | undefined,
mapping: Mapping
) => {
if (!error) {
this.popoverEl.innerHTML = "";
this.popoverEl.hidePopover();
return;
}

this.updateDebugContainer(error);

this.popoverEl.innerHTML = error.message;

await this.renderElementAtPos(
error.position ? mapping.map(error.position) : undefined
);

this.popoverEl.showPopover();
};

private updateDebugContainer = (error: CqlError) => {
if (this.debugContainer) {
this.debugContainer.innerHTML = `<div>
<h2>Error</h3>
<div>Position: ${error.position ?? "No position given"}</div>
<div>Message: ${error.message}</div>
</div>`;
}
};
}
64 changes: 64 additions & 0 deletions prosemirror-client/src/cqlInput/Popover.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { arrow, computePosition, flip, offset, shift } from "@floating-ui/dom";
import { EditorView } from "prosemirror-view";

export abstract class Popover {
public constructor(public view: EditorView, public popoverEl: HTMLElement) {}

protected async renderElementAtPos(position: number | undefined) {
const element = this.getVirtualElementFromView(position);

if (!element) {
return;
}

const { x, y } = await computePosition(element, this.popoverEl, {
placement: "bottom-start",
middleware: [flip(), shift(), offset({ mainAxis: 15, crossAxis: -30 }), arrow()],
});

this.popoverEl.setAttribute("style", `left: ${x}px; top: ${y}px`);
}

private getVirtualElementFromView = (
position: number | undefined
):
| undefined
| {
getBoundingClientRect: () => {
width: number;
height: number;
x: number;
y: number;
top: number;
left: number;
right: number;
bottom: number;
};
} => {
if (position) {
try {
const { top, right, bottom, left } = this.view.coordsAtPos(position);
return {
getBoundingClientRect: () => {
const a = {
width: right - left,
height: bottom - top,
x: left,
y: top,
top,
left,
right,
bottom,
};
console.log(a);
return a;
},
};
} catch (e) {
return undefined;
}
}

return this.view.dom;
};
}
97 changes: 49 additions & 48 deletions prosemirror-client/src/cqlInput/TypeaheadPopover.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,15 @@ import { findNodeAt } from "./utils";
import { EditorView } from "prosemirror-view";
import { chip, schema } from "./schema";
import { TextSelection } from "prosemirror-state";
import { Popover } from "./Popover";

type MenuItem = {
label: string;
value: string;
description: string;
};

export class TypeaheadPopover {
export class TypeaheadPopover extends Popover {
private debugContainer: HTMLElement | undefined;
private currentSuggestion: TypeaheadSuggestion | undefined;
private currentOptionIndex = 0;
Expand All @@ -21,6 +22,7 @@ export class TypeaheadPopover {
public popoverEl: HTMLElement,
debugEl?: HTMLElement
) {
super(view, popoverEl);
popoverEl.addEventListener("click", (e: MouseEvent) => {
if (
e.target instanceof HTMLElement &&
Expand All @@ -41,63 +43,58 @@ export class TypeaheadPopover {
!!this.currentSuggestion?.suggestions.TextSuggestion;

public updateItemsFromSuggestions = (
suggestions: TypeaheadSuggestion[],
typeaheadSuggestions: TypeaheadSuggestion[],
mapping: Mapping
) => {
if (
suggestions.length &&
this.view.state.selection.from === this.view.state.selection.to
!typeaheadSuggestions.length ||
this.view.state.selection.from !== this.view.state.selection.to
) {
const currentState = this.view.state;
const mappedSuggestions = suggestions.map((suggestion) => {
const start = mapping.map(suggestion.from, -1);
const end = mapping.map(suggestion.to);
return { ...suggestion, from: start, to: end };
});

this.updateDebugSuggestions(mappedSuggestions);

const suggestionThatCoversSelection = mappedSuggestions.find(
({ from, to, suggestions }) =>
currentState.selection.from >= from &&
currentState.selection.to <= to &&
Object.keys(suggestions).length
);
this.currentSuggestion = undefined;
this.popoverEl.hidePopover?.();
this.popoverEl.innerHTML = "";
return;
}

const currentState = this.view.state;
const mappedSuggestions = typeaheadSuggestions.map((suggestion) => {
const start = mapping.map(suggestion.from, -1);
const end = mapping.map(suggestion.to);
return { ...suggestion, from: start, to: end };
});

if (suggestionThatCoversSelection) {
this.currentSuggestion = suggestionThatCoversSelection;
const { from, to, suggestions } = suggestionThatCoversSelection;
const chipPos = findNodeAt(from, currentState.doc, chip);
const domSelectionAnchor = this.view.nodeDOM(chipPos) as HTMLElement;
this.updateDebugSuggestions(mappedSuggestions);

const suggestionThatCoversSelection = mappedSuggestions.find(
({ from, to, suggestions }) =>
currentState.selection.from >= from &&
currentState.selection.to <= to &&
Object.keys(suggestions).length
);

if (!domSelectionAnchor) {
this.popoverEl.hidePopover();
return;
}
if (!suggestionThatCoversSelection) {
this.currentSuggestion = undefined;
this.popoverEl.hidePopover?.();
return;
}

const { left } = domSelectionAnchor.getBoundingClientRect();
this.popoverEl.style.left = `${left}px`;
this.currentSuggestion = suggestionThatCoversSelection;
const { from, to, suggestions } = suggestionThatCoversSelection;
const chipPos = findNodeAt(from, currentState.doc, chip);

if (suggestions.TextSuggestion) {
this.renderTextSuggestion(suggestions.TextSuggestion.suggestions);
}
this.renderElementAtPos(chipPos);

if (suggestions.DateSuggestion) {
const value = this.view.state.doc.textBetween(from, to);
if (suggestions.TextSuggestion) {
this.renderTextSuggestion(suggestions.TextSuggestion.suggestions);
}

this.renderDateSuggestion(value);
}
if (suggestions.DateSuggestion) {
const value = this.view.state.doc.textBetween(from, to);

this.popoverEl.showPopover?.();
} else {
this.currentSuggestion = undefined;
this.popoverEl.hidePopover?.();
}
} else {
this.currentSuggestion = undefined;
this.popoverEl.hidePopover?.();
this.popoverEl.innerHTML = "";
this.renderDateSuggestion(value);
}

this.popoverEl.showPopover?.();
};

public moveSelectionUp = () => this.moveSelection(-1);
Expand Down Expand Up @@ -153,7 +150,11 @@ export class TypeaheadPopover {
.map(({ label, description }, index) => {
return `<div class="Cql__Option ${
index === this.currentOptionIndex ? "Cql__Option--is-selected" : ""
}" data-index="${index}"><div class="Cql__OptionLabel">${label}</div>${description ? `<div class="Cql__OptionDescription">${description}</div>`: ""}</div>`;
}" data-index="${index}"><div class="Cql__OptionLabel">${label}</div>${
description
? `<div class="Cql__OptionDescription">${description}</div>`
: ""
}</div>`;
})
.join("");
}
Expand Down Expand Up @@ -189,7 +190,7 @@ export class TypeaheadPopover {
private updateDebugSuggestions = (suggestions: TypeaheadSuggestion[]) => {
if (this.debugContainer) {
this.debugContainer.innerHTML = `
<h2>Typeahead</h3>
<h2>Typeahead</h2>
<p>Current selection: ${this.view.state.selection.from}-${
this.view.state.selection.to
}
Expand Down
Loading

0 comments on commit 667a1b0

Please sign in to comment.