Skip to content

Commit

Permalink
Move option state into renderer
Browse files Browse the repository at this point in the history
Fixes #26
  • Loading branch information
jonathonherbert committed Jan 2, 2025
1 parent 91c06d0 commit b5b80d5
Show file tree
Hide file tree
Showing 6 changed files with 172 additions and 71 deletions.
13 changes: 7 additions & 6 deletions client/src/cqlInput/editor/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -450,14 +450,15 @@ export const createCqlPlugin = ({

switch (event.code) {
case "ArrowUp":
typeaheadPopover.moveSelectionUp();
return true;
return typeaheadPopover.handleAction("up");
case "ArrowDown":
typeaheadPopover.moveSelectionDown();
return true;
return typeaheadPopover.handleAction("down");
case "ArrowLeft":
return typeaheadPopover.handleAction("left");
case "ArrowRight":
return typeaheadPopover.handleAction("right");
case "Enter":
typeaheadPopover.applyOption();
return true;
return typeaheadPopover.handleAction("enter");
}
},
// Serialise outgoing content to a CQL string for portability in both plain text and html
Expand Down
22 changes: 16 additions & 6 deletions client/src/cqlInput/popover/TypeaheadPopover.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,26 @@ import {
import { EditorView } from "prosemirror-view";
import { h, render } from "preact";
import {
ActionHandler,
PopoverContainer,
PopoverRendererState,
} from "./components/PopoverContainer";

export const CLASS_PENDING = "Cql__Typeahead--pending";

export class TypeaheadPopover extends Popover {
public handleAction: ActionHandler = () => {
console.warn(
"[TypeaheadPopover]: No action handler has been registered by the popover renderer"
);
return undefined;
};
private updateRendererState:
| ((state: PopoverRendererState) => void)
| undefined;
private currentSuggestion: TypeaheadSuggestion | undefined;
private currentOptionIndex = 0;
private isPending = false;
private listener: ((state: PopoverRendererState) => void) | undefined;

public constructor(
public view: EditorView,
Expand All @@ -27,12 +36,15 @@ export class TypeaheadPopover extends Popover {

render(
<PopoverContainer
subscribe={(listener) => {
this.listener = listener;
subscribeToState={(updateRendererState) => {
this.updateRendererState = updateRendererState;

// Ensure the initial state of the renderer is in sync with this class's state.
this.updateRenderer();
}}
subscribeToAction={(handleAction) => {
this.handleAction = handleAction;
}}
onSelect={this.applyValueToInput}
/>,
popoverEl
Expand Down Expand Up @@ -92,8 +104,6 @@ export class TypeaheadPopover extends Popover {
this.show(node as HTMLElement);
};

public moveSelectionUp = () => this.moveSelection(-1);

public moveSelectionDown = () => this.moveSelection(1);

public applyOption = () => {
Expand All @@ -116,7 +126,7 @@ export class TypeaheadPopover extends Popover {
};

private updateRenderer = () => {
this.listener?.({
this.updateRendererState?.({
suggestion: this.currentSuggestion,
isPending: this.isPending,
currentOptionIndex: this.currentOptionIndex,
Expand Down
61 changes: 57 additions & 4 deletions client/src/cqlInput/popover/components/DateSuggestionContent.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,73 @@
import { h } from "preact";
import { useEffect, useRef, useState } from "preact/hooks";
import { DateSuggestion } from "../../../lang/types";
import { ActionSubscriber } from "./PopoverContainer";
import { wrapSelection } from "./utils";

export const DateSuggestionContent = ({
suggestion,
onSelect,
currentOptionIndex,
subscribeToAction,
}: {
suggestion: DateSuggestion;
onSelect: (value: string) => void;
currentOptionIndex: number | undefined;
subscribeToAction: ActionSubscriber;
}) => {
const currentItemRef = useRef<HTMLDivElement | null>(null);
const indexOffset = 1; // The first index is to support an unselected state
const [tabIndex, setTabIndex] = useState(0);

const [currentOptionIndex, setCurrentOptionIndex] = useState(0);

useEffect(() => {
subscribeToAction((action) => {
switch (action) {
case "up":
setCurrentOptionIndex(
wrapSelection(
currentOptionIndex,
-1,
suggestion.suggestions.length + indexOffset
)
);
return true;
case "down": {
setCurrentOptionIndex(
wrapSelection(
currentOptionIndex,
1,
suggestion.suggestions.length + indexOffset
)
);
return true;
}
case "left": {
if (currentOptionIndex < indexOffset) {
return;
}
setTabIndex(wrapSelection(tabIndex, -1, tabs.length));
return true;
}
case "right": {
if (currentOptionIndex < indexOffset) {
return;
}
setTabIndex(wrapSelection(tabIndex, 1, tabs.length));
return true;
}
case "enter": {
onSelect(
suggestion.suggestions[currentOptionIndex + indexOffset].value
);
return true;
}
default: {
break;
}
}
});
}, [subscribeToAction, currentOptionIndex, tabIndex, suggestion]);

useEffect(() => {
if (currentOptionIndex !== undefined) {
currentItemRef.current?.scrollIntoView({ block: "nearest" });
Expand All @@ -24,13 +78,12 @@ export const DateSuggestionContent = ({
{
label: "Relative",
content: suggestion.suggestions.map(({ label, value }, index) => {
const isSelected = index === currentOptionIndex;
const isSelected = index + indexOffset === currentOptionIndex;
const selectedClass = isSelected ? "Cql__Option--is-selected" : "";

return (
<div
class={`Cql__Option ${selectedClass}`}
data-index={index}
ref={isSelected ? currentItemRef : null}
onClick={() => onSelect(value)}
>
Expand Down
22 changes: 15 additions & 7 deletions client/src/cqlInput/popover/components/PopoverContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,21 +10,29 @@ export type PopoverRendererState = {
isPending: boolean;
};

type StateSubscriber = (sub: (state: PopoverRendererState) => void) => void;

export type Actions = "left" | "right" | "up" | "down" | "enter";
export type ActionHandler = (action: Actions) => true | undefined;
export type ActionSubscriber = (handler: ActionHandler) => void;

type PopoverProps = {
subscribe: (sub: (state: PopoverRendererState) => void) => void;
subscribeToState: StateSubscriber;
subscribeToAction: ActionSubscriber;
onSelect: (value: string) => void;
};

export const PopoverContainer: FunctionComponent<PopoverProps> = ({
subscribe,
subscribeToState,
subscribeToAction,
onSelect,
}) => {
const [state, setState] = useState<PopoverRendererState | undefined>();
useEffect(() => {
subscribe((state) => {
subscribeToState((state) => {
setState(state);
});
}, [subscribe]);
}, [subscribeToState]);

if (!state?.suggestion) {
return <div>No results</div>;
Expand All @@ -36,16 +44,16 @@ export const PopoverContainer: FunctionComponent<PopoverProps> = ({
<TextSuggestionContent
suggestion={state.suggestion}
onSelect={onSelect}
currentOptionIndex={state.currentOptionIndex}
subscribeToAction={subscribeToAction}
></TextSuggestionContent>
);
case "DATE":
return (
<DateSuggestionContent
suggestion={state.suggestion}
onSelect={onSelect}
currentOptionIndex={state.currentOptionIndex}
subscribeToAction={subscribeToAction}
></DateSuggestionContent>
);
}
};
};
123 changes: 75 additions & 48 deletions client/src/cqlInput/popover/components/TextSuggestionContent.tsx
Original file line number Diff line number Diff line change
@@ -1,61 +1,88 @@

import { h } from "preact";
import { useEffect, useRef } from "preact/hooks";
import { useEffect, useRef, useState } from "preact/hooks";
import { TextSuggestion } from "../../../lang/types";
import { ActionSubscriber } from "./PopoverContainer";
import { wrapSelection } from "./utils";

const numberFormat = new Intl.NumberFormat();

export const TextSuggestionContent = ({
suggestion,
onSelect,
currentOptionIndex,
}: {
suggestion: TextSuggestion;
onSelect: (value: string) => void;
currentOptionIndex: number | undefined;
}) => {
const showCount = suggestion.position === "chipValue";
const showValue = suggestion.position === "chipValue";
const showDescription = suggestion.position === "chipKey";
const currentItemRef = useRef<HTMLDivElement | null>(null);
suggestion,
onSelect,
subscribeToAction,
}: {
suggestion: TextSuggestion;
onSelect: (value: string) => void;
subscribeToAction: ActionSubscriber;
}) => {
const showCount = suggestion.position === "chipValue";
const showValue = suggestion.position === "chipValue";
const showDescription = suggestion.position === "chipKey";
const currentItemRef = useRef<HTMLDivElement | null>(null);
const [currentOptionIndex, setCurrentOptionIndex] = useState(0);

useEffect(() => {
if (currentOptionIndex !== undefined) {
currentItemRef.current?.scrollIntoView({ block: "nearest" });
useEffect(() => {
subscribeToAction((action) => {
switch (action) {
case "up":
setCurrentOptionIndex(
wrapSelection(currentOptionIndex, -1, suggestion.suggestions.length)
);
return true;
case "down": {
setCurrentOptionIndex(
wrapSelection(currentOptionIndex, 1, suggestion.suggestions.length)
);
return true;
}
case "enter": {
onSelect(suggestion.suggestions[currentOptionIndex].value);
return true;
}
default: {
break;
}
}
}, [currentOptionIndex]);
});
}, [subscribeToAction, currentOptionIndex, suggestion]);

if (!suggestion.suggestions.length) {
return (
<div class="Cql__Option">
<div class="Cql__OptionLabel">No results</div>
</div>
);
useEffect(() => {
if (currentOptionIndex !== undefined) {
currentItemRef.current?.scrollIntoView({ block: "nearest" });
}
}, [currentOptionIndex]);

if (!suggestion.suggestions.length) {
return (
<div class="Cql__Option">
<div class="Cql__OptionLabel">No results</div>
</div>
);
}

return suggestion.suggestions.map(
({ label, description, value, count }, index) => {
const isSelected = index === currentOptionIndex;
const selectedClass = isSelected ? "Cql__Option--is-selected" : "";
return (
<div
class={`Cql__Option ${selectedClass}`}
data-index={index}
ref={isSelected ? currentItemRef : null}
onClick={() => onSelect(value)}
>
<div class="Cql__OptionLabel">
{label}
{showCount && count !== undefined && (
<div class="Cql__OptionCount">{numberFormat.format(count)}</div>
)}
</div>
{showValue && <div class="Cql__OptionValue">{value}</div>}
{showDescription && description && (
<div class="Cql__OptionDescription">{description}</div>
return suggestion.suggestions.map(
({ label, description, value, count }, index) => {
const isSelected = index === currentOptionIndex;
const selectedClass = isSelected ? "Cql__Option--is-selected" : "";
return (
<div
class={`Cql__Option ${selectedClass}`}
data-index={index}
ref={isSelected ? currentItemRef : null}
onClick={() => onSelect(value)}
>
<div class="Cql__OptionLabel">
{label}
{showCount && count !== undefined && (
<div class="Cql__OptionCount">{numberFormat.format(count)}</div>
)}
</div>
);
}
);
};
{showValue && <div class="Cql__OptionValue">{value}</div>}
{showDescription && description && (
<div class="Cql__OptionDescription">{description}</div>
)}
</div>
);
}
);
};
2 changes: 2 additions & 0 deletions client/src/cqlInput/popover/components/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export const wrapSelection = (current: number, by: number, length: number) =>
(current + by + (by < 0 ? length : 0)) % length;

0 comments on commit b5b80d5

Please sign in to comment.