From a907724d26738d3bf4cce167eb859234b2970bf4 Mon Sep 17 00:00:00 2001 From: John Traas Date: Thu, 19 Dec 2024 14:11:51 +0100 Subject: [PATCH] docs(text editor): add new trigger map examples --- .../text-editor-custom-triggerMap.tsx | 312 ++++++++++++++++++ src/components/text-editor/text-editor.tsx | 1 + .../text-editor/text-editor.types.ts | 15 +- 3 files changed, 327 insertions(+), 1 deletion(-) create mode 100644 src/components/text-editor/examples/text-editor-custom-triggerMap.tsx diff --git a/src/components/text-editor/examples/text-editor-custom-triggerMap.tsx b/src/components/text-editor/examples/text-editor-custom-triggerMap.tsx new file mode 100644 index 0000000000..0ddd8a3214 --- /dev/null +++ b/src/components/text-editor/examples/text-editor-custom-triggerMap.tsx @@ -0,0 +1,312 @@ +/* eslint-disable multiline-ternary */ +import { Component, h, State, Element, Watch } from '@stencil/core'; +import { + LimelMenuListCustomEvent, + MenuItem, + TextEditor, + TriggerEventDetail, + TriggerCharacter, + TriggerMap, + CustomElementDefinition, +} from '@limetech/lime-elements'; +import { + ARROW_DOWN, + ARROW_UP, + ENTER, + ESCAPE, + TAB, +} from '../../../util/keycodes'; + +/** + * Trigger Maps + * + * Trigger maps are a way to define a set of triggers, their corresponding + * custom elements and mapping data to properties of the custom element. + * + * :::note + * The trigger map is used to insert the custom element into the text editor + * ::: + */ +@Component({ + tag: 'limel-example-text-editor-using-trigger-maps', + shadow: true, + styleUrl: 'text-editor-custom-triggers.scss', +}) +export class TextEditorUsingTriggerMaps { + @State() + private value: string = ''; + + @State() + private isPickerOpen: boolean = false; + + @State() + private query: string = ''; + + @State() + private items: Array> = [ + { text: 'Wolverine', value: 1, icon: 'wolf', selected: true }, + { text: 'Captain America', value: 2, icon: 'captain_america' }, + { text: 'Superman', value: 3, icon: 'superman' }, + { text: 'Tony Stark', value: 4, icon: 'iron_man' }, + { text: 'Batman', value: 5, icon: 'batman_old' }, + ]; + + @State() + private customElements: CustomElementDefinition[] = []; + + @State() + private registeredTriggers: TriggerCharacter[] = []; + + private triggerFunction?: TextEditor; + private triggerChar: TriggerCharacter | undefined; + + @Element() + private host: HTMLLimelPopoverElement; + + private triggerMap: TriggerMap = { + '@': { + customElement: { + tagName: 'limel-chip', + attributes: ['text', 'icon'], + }, + mapAttributes: (item: MenuItem) => ({ + text: item.text, + icon: item.icon, + }), + }, + '#': { + customElement: { + tagName: 'limel-header', + attributes: [ + 'icon', + 'heading', + 'subheading', + 'subheadingDivider', + 'supportingText', + ], + }, + mapAttributes: (item: MenuItem) => ({ + icon: item.icon, + heading: item.text, + subheading: 'Subheading', + subheadingDivider: '<->', + supportingText: 'supporting text', + }), + }, + }; + + @Watch('isPickerOpen') + protected watchOpen() { + this.setupEventHandlers(); + } + + public componentWillLoad() { + this.setupEventHandlers(); + this.setCustomElementsAndTriggers(); + } + + public render() { + return [ + this.renderPicker(), + , + ]; + } + + private renderPicker() { + if (!this.isPickerOpen) { + return; + } + + const filteredItems = this.getFilteredItems(); + + return ( + + {filteredItems.length === 0 + ? this.renderEmptyMessage() + : this.renderList(filteredItems)} + + ); + } + + private renderList(items: Array>) { + return ( + + ); + } + + private renderEmptyMessage() { + return
No matches found.
; + } + + private setCustomElementsAndTriggers = () => { + this.customElements = Object.values(this.triggerMap).map( + (item) => item.customElement as CustomElementDefinition, + ); + + this.registeredTriggers = Object.keys( + this.triggerMap, + ) as TriggerCharacter[]; + }; + + private setupEventHandlers = () => { + if (this.isPickerOpen) { + this.host.addEventListener('keydown', this.handleKeyPress, { + capture: true, + }); + } else { + this.host.removeEventListener('keydown', this.handleKeyPress, { + capture: true, + }); + } + }; + + private handleKeyPress = (event: KeyboardEvent) => { + if (!this.isPickerOpen || !this.triggerChar) { + return; + } + + const capturedKeys = [ESCAPE, ARROW_UP, ARROW_DOWN, ENTER, TAB]; + if (!capturedKeys.includes(event.key)) { + return; + } + + event.stopPropagation(); + event.preventDefault(); + + const handlers = { + [ESCAPE]: this.cancelTrigger, + [ENTER]: this.selectHighlightedItem, + [TAB]: this.selectHighlightedItem, + [ARROW_DOWN]: this.moveSelection, + [ARROW_UP]: this.moveSelection, + }; + + const handler = handlers[event.key]; + if (handler) { + handler(event); + } + }; + + private moveSelection = (event: KeyboardEvent) => { + const increment = + (event.key as typeof ARROW_DOWN | typeof ARROW_UP) === ARROW_DOWN + ? 1 + : -1; + const numberOfItems = this.items.length; + const currentIndex = this.items.findIndex((item) => item.selected); + + const newIndex = + (currentIndex + increment + numberOfItems) % numberOfItems; + + this.updateSelection(newIndex); + }; + + private updateSelection = (newIndex: number) => { + this.items = this.items.map((item) => ({ ...item, selected: false })); + + const filteredItems = this.getFilteredItems(); + const selectedItem = filteredItems[newIndex]; + + if (selectedItem) { + this.items = this.items.map((item) => + item.value === selectedItem.value + ? { ...item, selected: true } + : item, + ); + } + }; + + private selectHighlightedItem = () => { + const selectedItem = this.getFilteredItems().find( + (item) => item.selected, + ); + if (!selectedItem) { + return; + } + + this.insertItem(selectedItem); + }; + + private getFilteredItems = (): Array> => { + const query = this.query.trim(); + if (!query) { + return this.items; + } + + return this.items.filter((item) => + item.text.toLowerCase().includes(query), + ); + }; + + private cancelTrigger = () => { + this.isPickerOpen = false; + this.triggerFunction?.stopTrigger(); + this.resetTrigger(); + }; + + private handleTriggerStart = (event: CustomEvent) => { + this.triggerFunction = event.detail.textEditor; + this.triggerChar = event.detail.trigger; + this.isPickerOpen = true; + }; + + private handleTriggerStop = () => { + this.resetTrigger(); + }; + + private handleTriggerChange = (event: CustomEvent) => { + this.query = event.detail.value.toLowerCase(); + }; + + private handleChange = (event: CustomEvent) => { + this.value = event.detail; + }; + + private resetTrigger = () => { + this.query = ''; + this.triggerChar = undefined; + this.isPickerOpen = false; + }; + + private insertItem = (item: MenuItem) => { + const definition = + this.triggerChar && this.triggerMap[this.triggerChar]; + if (!definition || !this.triggerFunction) { + return; + } + + // Insert as a chip using the trigger definition + this.triggerFunction.insert({ + node: { + tagName: definition.customElement.tagName, + attributes: definition.mapAttributes(item), + }, + children: [this.triggerChar + item.text], + }); + + this.triggerFunction.stopTrigger(); + this.resetTrigger(); + }; + + private handleListInteraction = ( + event: LimelMenuListCustomEvent>, + ) => { + this.insertItem(event.detail); + }; +} diff --git a/src/components/text-editor/text-editor.tsx b/src/components/text-editor/text-editor.tsx index f810d1a0fd..2503f02070 100644 --- a/src/components/text-editor/text-editor.tsx +++ b/src/components/text-editor/text-editor.tsx @@ -26,6 +26,7 @@ import { EditorUiType } from './types'; * @exampleComponent limel-example-text-editor-composite * @exampleComponent limel-example-text-editor-custom-element * @exampleComponent limel-example-text-editor-triggers + * @exampleComponent limel-example-text-editor-using-trigger-maps * @beta */ @Component({ diff --git a/src/components/text-editor/text-editor.types.ts b/src/components/text-editor/text-editor.types.ts index 1cd402aeee..5b6afae7d8 100644 --- a/src/components/text-editor/text-editor.types.ts +++ b/src/components/text-editor/text-editor.types.ts @@ -1,4 +1,17 @@ -import { CustomElement } from '../../global/shared-types/custom-element.types'; +import { + CustomElement, + CustomElementDefinition, +} from '../../global/shared-types/custom-element.types'; +import { ListItem } from '../list/list-item.types'; + +export type TriggerMap = { + [K in TriggerCharacter]?: EditorNodeDefinition; +}; + +export interface EditorNodeDefinition { + customElement: CustomElementDefinition; + mapAttributes: (item: ListItem) => { [key: string]: any }; +} /** * @alpha