From b5b80d5a1407d280ff9b834b04d8f16c631c1339 Mon Sep 17 00:00:00 2001 From: Jonathon Herbert Date: Thu, 2 Jan 2025 22:03:22 +0000 Subject: [PATCH] Move option state into renderer Fixes https://github.com/guardian/cql/issues/26 --- client/src/cqlInput/editor/plugin.ts | 13 +- .../src/cqlInput/popover/TypeaheadPopover.tsx | 22 +++- .../components/DateSuggestionContent.tsx | 61 ++++++++- .../popover/components/PopoverContainer.tsx | 22 +++- .../components/TextSuggestionContent.tsx | 123 +++++++++++------- .../src/cqlInput/popover/components/utils.ts | 2 + 6 files changed, 172 insertions(+), 71 deletions(-) create mode 100644 client/src/cqlInput/popover/components/utils.ts diff --git a/client/src/cqlInput/editor/plugin.ts b/client/src/cqlInput/editor/plugin.ts index 7ebfcdc..2864972 100644 --- a/client/src/cqlInput/editor/plugin.ts +++ b/client/src/cqlInput/editor/plugin.ts @@ -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 diff --git a/client/src/cqlInput/popover/TypeaheadPopover.tsx b/client/src/cqlInput/popover/TypeaheadPopover.tsx index e6d1ea5..6c10f3e 100644 --- a/client/src/cqlInput/popover/TypeaheadPopover.tsx +++ b/client/src/cqlInput/popover/TypeaheadPopover.tsx @@ -6,6 +6,7 @@ import { import { EditorView } from "prosemirror-view"; import { h, render } from "preact"; import { + ActionHandler, PopoverContainer, PopoverRendererState, } from "./components/PopoverContainer"; @@ -13,10 +14,18 @@ import { 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, @@ -27,12 +36,15 @@ export class TypeaheadPopover extends Popover { render( { - 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 @@ -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 = () => { @@ -116,7 +126,7 @@ export class TypeaheadPopover extends Popover { }; private updateRenderer = () => { - this.listener?.({ + this.updateRendererState?.({ suggestion: this.currentSuggestion, isPending: this.isPending, currentOptionIndex: this.currentOptionIndex, diff --git a/client/src/cqlInput/popover/components/DateSuggestionContent.tsx b/client/src/cqlInput/popover/components/DateSuggestionContent.tsx index f896328..b42a83f 100644 --- a/client/src/cqlInput/popover/components/DateSuggestionContent.tsx +++ b/client/src/cqlInput/popover/components/DateSuggestionContent.tsx @@ -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(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" }); @@ -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 (
onSelect(value)} > diff --git a/client/src/cqlInput/popover/components/PopoverContainer.tsx b/client/src/cqlInput/popover/components/PopoverContainer.tsx index 33cdf8a..6be4cd7 100644 --- a/client/src/cqlInput/popover/components/PopoverContainer.tsx +++ b/client/src/cqlInput/popover/components/PopoverContainer.tsx @@ -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 = ({ - subscribe, + subscribeToState, + subscribeToAction, onSelect, }) => { const [state, setState] = useState(); useEffect(() => { - subscribe((state) => { + subscribeToState((state) => { setState(state); }); - }, [subscribe]); + }, [subscribeToState]); if (!state?.suggestion) { return
No results
; @@ -36,7 +44,7 @@ export const PopoverContainer: FunctionComponent = ({ ); case "DATE": @@ -44,8 +52,8 @@ export const PopoverContainer: FunctionComponent = ({ ); } -}; \ No newline at end of file +}; diff --git a/client/src/cqlInput/popover/components/TextSuggestionContent.tsx b/client/src/cqlInput/popover/components/TextSuggestionContent.tsx index 04ce5fa..242cf72 100644 --- a/client/src/cqlInput/popover/components/TextSuggestionContent.tsx +++ b/client/src/cqlInput/popover/components/TextSuggestionContent.tsx @@ -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(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(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 ( -
-
No results
-
- ); + useEffect(() => { + if (currentOptionIndex !== undefined) { + currentItemRef.current?.scrollIntoView({ block: "nearest" }); } + }, [currentOptionIndex]); + + if (!suggestion.suggestions.length) { + return ( +
+
No results
+
+ ); + } - return suggestion.suggestions.map( - ({ label, description, value, count }, index) => { - const isSelected = index === currentOptionIndex; - const selectedClass = isSelected ? "Cql__Option--is-selected" : ""; - return ( -
onSelect(value)} - > -
- {label} - {showCount && count !== undefined && ( -
{numberFormat.format(count)}
- )} -
- {showValue &&
{value}
} - {showDescription && description && ( -
{description}
+ return suggestion.suggestions.map( + ({ label, description, value, count }, index) => { + const isSelected = index === currentOptionIndex; + const selectedClass = isSelected ? "Cql__Option--is-selected" : ""; + return ( +
onSelect(value)} + > +
+ {label} + {showCount && count !== undefined && ( +
{numberFormat.format(count)}
)}
- ); - } - ); - }; \ No newline at end of file + {showValue &&
{value}
} + {showDescription && description && ( +
{description}
+ )} +
+ ); + } + ); +}; diff --git a/client/src/cqlInput/popover/components/utils.ts b/client/src/cqlInput/popover/components/utils.ts new file mode 100644 index 0000000..b54fa16 --- /dev/null +++ b/client/src/cqlInput/popover/components/utils.ts @@ -0,0 +1,2 @@ +export const wrapSelection = (current: number, by: number, length: number) => + (current + by + (by < 0 ? length : 0)) % length;