From dc26fe0f94006259090a691250129ea1d56b27dd Mon Sep 17 00:00:00 2001 From: Adam Samec Date: Mon, 22 Apr 2024 13:58:32 +0200 Subject: [PATCH 01/30] Base for content editable tags --- .../ContentEditableTags.stories.tsx | 12 ++++ .../ContentEditableTagsRenderer.tsx | 8 +++ .../ListOfContentEditableTags.stories.mdx | 9 +++ .../stories/ContentEditableTags/data.ts | 72 +++++++++++++++++++ .../ContentEditableTags/index.stories.tsx | 5 ++ .../ContentEditableTags/utils/stories.tsx | 48 +++++++++++++ 6 files changed, 154 insertions(+) create mode 100644 packages/react-components/react-components/stories/ContentEditableTags/ContentEditableTags.stories.tsx create mode 100644 packages/react-components/react-components/stories/ContentEditableTags/ContentEditableTagsRenderer.tsx create mode 100644 packages/react-components/react-components/stories/ContentEditableTags/ListOfContentEditableTags.stories.mdx create mode 100644 packages/react-components/react-components/stories/ContentEditableTags/data.ts create mode 100644 packages/react-components/react-components/stories/ContentEditableTags/index.stories.tsx create mode 100644 packages/react-components/react-components/stories/ContentEditableTags/utils/stories.tsx diff --git a/packages/react-components/react-components/stories/ContentEditableTags/ContentEditableTags.stories.tsx b/packages/react-components/react-components/stories/ContentEditableTags/ContentEditableTags.stories.tsx new file mode 100644 index 0000000000000..d5bff3a182d07 --- /dev/null +++ b/packages/react-components/react-components/stories/ContentEditableTags/ContentEditableTags.stories.tsx @@ -0,0 +1,12 @@ +import * as React from 'react'; +import { Prototype } from './utils/stories'; +import { ContentEditableTagsRenderer } from './ContentEditableTagsRenderer'; + +export const ContentEditableTags: React.FC = () => { + return ( + +

Content editable tags

+ +
+ ); +}; diff --git a/packages/react-components/react-components/stories/ContentEditableTags/ContentEditableTagsRenderer.tsx b/packages/react-components/react-components/stories/ContentEditableTags/ContentEditableTagsRenderer.tsx new file mode 100644 index 0000000000000..f08ccccc1b8ee --- /dev/null +++ b/packages/react-components/react-components/stories/ContentEditableTags/ContentEditableTagsRenderer.tsx @@ -0,0 +1,8 @@ +import * as React from 'react'; +import { Tag } from '@fluentui/react-components'; + +import { people } from './data'; + +export const ContentEditableTagsRenderer = () => { + return
; +}; diff --git a/packages/react-components/react-components/stories/ContentEditableTags/ListOfContentEditableTags.stories.mdx b/packages/react-components/react-components/stories/ContentEditableTags/ListOfContentEditableTags.stories.mdx new file mode 100644 index 0000000000000..02656af567ed3 --- /dev/null +++ b/packages/react-components/react-components/stories/ContentEditableTags/ListOfContentEditableTags.stories.mdx @@ -0,0 +1,9 @@ +import { Meta } from '@storybook/addon-docs'; +import { FullscreenLink } from './utils/stories'; +export const parentPath = 'concepts-developer-accessibility-contenteditabletags'; + + + +# Content editable tags + +- diff --git a/packages/react-components/react-components/stories/ContentEditableTags/data.ts b/packages/react-components/react-components/stories/ContentEditableTags/data.ts new file mode 100644 index 0000000000000..7e3bae7308ceb --- /dev/null +++ b/packages/react-components/react-components/stories/ContentEditableTags/data.ts @@ -0,0 +1,72 @@ +export const people = [ + 'Alex Manning', + 'Adam Dalton', + 'Albert Kidd', + 'Bob Hartmann', + 'Bill Whitman', + 'Brian White', + 'Charlie Donohue', + 'Carl Mccaffrey', + 'Chase Roberts', + 'Donald Lee', + 'David Lloyd', + 'Daniel Williams', + 'Esther Chiu', + 'Estelle Lapin', + 'Emily Dupres', + 'Franz Kramer', + 'Francis Goth', + 'Fanny Chantal', + 'Geoff Wilson', + 'George Michaels', + 'Gale Benson', + 'Harry Wilkes', + 'Hannibal Troy', + 'Hector Gonzales', + 'Indira Singh', + 'Ian Elba', + 'Isaac Bend', + 'Jamie Doyle', + 'James Reilly', + 'Jude Ryan', + 'Karl Olafsson', + 'Kai Mitsuko', + 'Katherine Decker', + 'Leonard Dihlmann', + 'Lukas Stanek', + 'Leah Faber ', + 'Max Mustermann', + 'Malcom Middleton', + 'Morgan Stanley', + 'Nils Stanek', + 'Natalia Susek', + 'Noel Walsh', + 'Otto Liebermann', + 'Owen Wilson', + 'Olivia Wright', + 'Peter Murphy', + 'Paige Hart', + 'Patrik Flaherty', + 'Rob Mccaffrey', + 'Robin Fitzpatrick', + 'Ruby Yu', + 'Simon Baker', + 'Sebastian Kant', + 'Sarah Cortez', + 'Tony McGuire', + 'Thomas Smyth', + 'Tania Lackner', + 'Ulla Thamm', + 'Umi Safin', + 'Una Bentley', + 'Victor Schmidt', + 'Victoria Bennet', + 'Valentin Lungo', + 'Xavier Lacalle', + 'Yves Deley', + 'Yasmin Targa', + 'Yvette Davidson', + 'Zachary Stephenson', + 'Zander Goi', + 'Zack Bonnet', +]; diff --git a/packages/react-components/react-components/stories/ContentEditableTags/index.stories.tsx b/packages/react-components/react-components/stories/ContentEditableTags/index.stories.tsx new file mode 100644 index 0000000000000..b3aa00cbaa4eb --- /dev/null +++ b/packages/react-components/react-components/stories/ContentEditableTags/index.stories.tsx @@ -0,0 +1,5 @@ +export { ContentEditableTags } from './ContentEditableTags.stories'; + +export default { + title: 'Concepts/Developer/Accessibility/ContentEditableTags', +}; diff --git a/packages/react-components/react-components/stories/ContentEditableTags/utils/stories.tsx b/packages/react-components/react-components/stories/ContentEditableTags/utils/stories.tsx new file mode 100644 index 0000000000000..50450cbcdf802 --- /dev/null +++ b/packages/react-components/react-components/stories/ContentEditableTags/utils/stories.tsx @@ -0,0 +1,48 @@ +import * as React from 'react'; +import { useFluent } from '@fluentui/react-components'; + +const APP_TITLE = 'Content editable tags'; +const APP_TITLE_SEPARATOR = ' | '; + +interface FullscreenLinkProps { + parent: string; + story: string; + content: string; +} + +// https://storybook.js.org/addons/@storybook/addon-links does not allow opening a story in new tab +// so this is a naive attempt for opening a story in full screen +export const FullscreenLink = (props: FullscreenLinkProps) => ( + + {props.content} + +); + +export const TagsListLink: React.FC = props => ( + + {props.children} + +); + +export const BackLink = () => Go back to main menu; + +export const Prototype: React.FC<{ pageTitle: string }> = ({ pageTitle, children }) => { + const { targetDocument } = useFluent(); + + React.useEffect(() => { + if (targetDocument) { + targetDocument.title = pageTitle + APP_TITLE_SEPARATOR + APP_TITLE; + } + }, [targetDocument, pageTitle]); + + return ( + <> + +
+ {children} + + ); +}; From f29e0128d59b4c50d247ebeff2be850876bebe30 Mon Sep 17 00:00:00 2001 From: Adam Samec Date: Mon, 6 May 2024 16:53:28 +0200 Subject: [PATCH 02/30] Fix contenteditable editting and errors and warnings --- .../ContentEditableTagsRenderer.tsx | 175 +++++++++++++++++- .../ContentEditableTags/utils/constants.ts | 1 + .../ContentEditableTags/utils/types.ts | 15 ++ .../utils/useActiveDescendant.ts | 138 ++++++++++++++ .../utils/useOptionWalker.ts | 72 +++++++ 5 files changed, 399 insertions(+), 2 deletions(-) create mode 100644 packages/react-components/react-components/stories/ContentEditableTags/utils/constants.ts create mode 100644 packages/react-components/react-components/stories/ContentEditableTags/utils/types.ts create mode 100644 packages/react-components/react-components/stories/ContentEditableTags/utils/useActiveDescendant.ts create mode 100644 packages/react-components/react-components/stories/ContentEditableTags/utils/useOptionWalker.ts diff --git a/packages/react-components/react-components/stories/ContentEditableTags/ContentEditableTagsRenderer.tsx b/packages/react-components/react-components/stories/ContentEditableTags/ContentEditableTagsRenderer.tsx index f08ccccc1b8ee..eafe44b715bcf 100644 --- a/packages/react-components/react-components/stories/ContentEditableTags/ContentEditableTagsRenderer.tsx +++ b/packages/react-components/react-components/stories/ContentEditableTags/ContentEditableTagsRenderer.tsx @@ -1,8 +1,179 @@ import * as React from 'react'; -import { Tag } from '@fluentui/react-components'; +import { useFluent } from '@fluentui/react-components'; +import { usePositioning } from '@fluentui/react-positioning'; +import { useId, useMergedRefs, useOnClickOutside } from '@fluentui/react-utilities'; + +import { ActiveDescendantImperativeRef } from './utils/types'; +import { useActiveDescendant } from './utils/useActiveDescendant'; import { people } from './data'; +const options = people.map(person => ({ + id: person.split(' ')[0], + text: person, +})); + export const ContentEditableTagsRenderer = () => { - return
; + const { targetDocument } = useFluent(); + + const [open, setOpen] = React.useState(false); + const [value, setValue] = React.useState(''); + const [selected, setSelected] = React.useState(null); + const listboxId = useId('listbox'); + + const { containerRef } = usePositioning({ + align: 'start', + position: 'below', + matchTargetSize: 'width', + }); + const listboxHtmlRef = React.useRef(null); + const activeDescendantImperativeRef = React.useRef(null); + + const { activeParentRef, listboxRef: listboxActiveDescendantRef } = useActiveDescendant({ + matchOption: el => el.getAttribute('role') === 'option', + imperativeRef: activeDescendantImperativeRef, + }); + + const listboxRef = useMergedRefs(listboxHtmlRef, containerRef, listboxActiveDescendantRef); + const inputRef = useMergedRefs(activeParentRef); + + useOnClickOutside({ + callback: () => { + setOpen(false); + }, + refs: [inputRef, listboxHtmlRef], + element: targetDocument, + }); + + React.useEffect(() => { + if (!activeDescendantImperativeRef.current?.active()) { + activeDescendantImperativeRef.current?.first(); + } + }, [value]); + + const selectActive = React.useCallback(() => { + const activeId = activeDescendantImperativeRef.current?.active(); + if (activeId) { + const next = options.find(x => x.id === activeId); + if (next) { + setValue(next.text); + setSelected(next.id); + } else { + const selectedText = options.find(x => x.id === selected)?.text; + setValue(selectedText ?? ''); + } + } + }, [selected]); + + const onInputChange = React.useCallback( + (newValue: string | null) => { + setValue(newValue); + }, + [setValue], + ); + + const onInputKeyDown = React.useCallback( + (e: React.KeyboardEvent) => { + let preventDefault: boolean | undefined | void = false; + switch (e.key) { + case 'Enter': + selectActive(); + setOpen(false); + preventDefault = true; + break; + case 'Escape': + setOpen(false); + preventDefault = true; + break; + case 'ArrowDown': + setOpen(true); + if (activeDescendantImperativeRef.current?.active()) { + activeDescendantImperativeRef.current?.next(); + } else { + activeDescendantImperativeRef.current?.first(); + } + preventDefault = true; + break; + case 'ArrowUp': + setOpen(true); + activeDescendantImperativeRef.current?.prev(); + preventDefault = true; + break; + case 'Tab': + selectActive(); + setOpen(false); + break; + default: + setOpen(true); + } + + if (preventDefault) { + e.preventDefault(); + } + }, + [selectActive, setOpen], + ); + + React.useEffect(() => { + if (open) { + activeDescendantImperativeRef.current?.first(); + } + }, [open]); + + React.useEffect(() => { + if (value === '') { + setSelected(null); + activeDescendantImperativeRef.current?.blur(); + } + }, [value]); + + return ( + <> + } + value={value} + onContentChange={onInputChange} + onKeyDown={onInputKeyDown} + data-selected={selected} + aria-controls={open ? listboxId : undefined} + aria-expanded={open} + /> + {open && ( +
+ {options.map(option => ( +
+ {option.text} +
+ ))} +
+ )} + + ); +}; + +interface ContenteditableProps extends React.HTMLAttributes { + innerRef: React.Ref; + value: string | null; + onContentChange: (newValue: string | null) => void; +} +const Contenteditable: React.FC = ({ value, onContentChange, ...props }) => { + const contentEditableRef = React.useRef(null); + + React.useEffect(() => { + if (contentEditableRef.current && contentEditableRef.current.textContent !== value) { + contentEditableRef.current.textContent = value; + } + }); + + return ( +
{ + const newValue = (event.target as HTMLDivElement).textContent; + onContentChange(newValue); + }} + {...props} + /> + ); }; diff --git a/packages/react-components/react-components/stories/ContentEditableTags/utils/constants.ts b/packages/react-components/react-components/stories/ContentEditableTags/utils/constants.ts new file mode 100644 index 0000000000000..6edc5fefa7d92 --- /dev/null +++ b/packages/react-components/react-components/stories/ContentEditableTags/utils/constants.ts @@ -0,0 +1 @@ +export const ACTIVEDESCENDANT_ATTRIBUTE = 'data-activedescendant'; diff --git a/packages/react-components/react-components/stories/ContentEditableTags/utils/types.ts b/packages/react-components/react-components/stories/ContentEditableTags/utils/types.ts new file mode 100644 index 0000000000000..f9bb5b40d06df --- /dev/null +++ b/packages/react-components/react-components/stories/ContentEditableTags/utils/types.ts @@ -0,0 +1,15 @@ +import * as React from 'react'; + +export interface ActiveDescendantImperativeRef { + first: () => void; + next: () => void; + prev: () => void; + blur: () => void; + active: () => string | undefined; + focus: (id: string) => void; +} + +export interface ActiveDescendantOptions { + matchOption: (el: HTMLElement) => boolean; + imperativeRef?: React.RefObject; +} diff --git a/packages/react-components/react-components/stories/ContentEditableTags/utils/useActiveDescendant.ts b/packages/react-components/react-components/stories/ContentEditableTags/utils/useActiveDescendant.ts new file mode 100644 index 0000000000000..dcc82c665454a --- /dev/null +++ b/packages/react-components/react-components/stories/ContentEditableTags/utils/useActiveDescendant.ts @@ -0,0 +1,138 @@ +import * as React from 'react'; +import { useOptionWalker } from './useOptionWalker'; +import type { ActiveDescendantOptions } from './types'; +import { ACTIVEDESCENDANT_ATTRIBUTE } from './constants'; + +export function useActiveDescendant( + options: ActiveDescendantOptions, +) { + const { imperativeRef, matchOption } = options; + const activeParentRef = React.useRef(null); + const { listboxRef, optionWalker } = useOptionWalker({ matchOption }); + const getActiveDescendant = () => { + return listboxRef.current?.querySelector(`[${ACTIVEDESCENDANT_ATTRIBUTE}]`); + }; + + const scrollActiveIntoView = (active: HTMLElement) => { + if (!listboxRef.current) { + return; + } + + if (listboxRef.current.offsetHeight >= listboxRef.current.scrollHeight) { + return; + } + + const { offsetHeight, offsetTop } = active; + const { offsetHeight: parentOffsetHeight, scrollTop } = listboxRef.current; + + const isAbove = offsetTop < scrollTop; + const isBelow = offsetTop + offsetHeight > scrollTop + parentOffsetHeight; + + const buffer = 2; + + if (isAbove) { + listboxRef.current.scrollTo(0, offsetTop - buffer); + } + + if (isBelow) { + listboxRef.current.scrollTo(0, offsetTop - parentOffsetHeight + offsetHeight + buffer); + } + }; + + const setActiveDescendant = (nextActive: HTMLElement | undefined) => { + const active = getActiveDescendant(); + if (active) { + active.removeAttribute(ACTIVEDESCENDANT_ATTRIBUTE); + } + + if (nextActive) { + nextActive.setAttribute(ACTIVEDESCENDANT_ATTRIBUTE, ''); + scrollActiveIntoView(nextActive); + activeParentRef.current?.setAttribute('aria-activedescendant', nextActive.id); + } else { + activeParentRef.current?.removeAttribute('aria-activedescendant'); + } + }; + + React.useImperativeHandle(imperativeRef, () => ({ + first: () => { + if (!listboxRef.current || !activeParentRef.current) { + return; + } + + optionWalker.setCurrent(listboxRef.current); + const first = optionWalker.first(); + if (first) { + setActiveDescendant(first); + } + }, + next: () => { + if (!listboxRef.current || !activeParentRef.current) { + return; + } + + const active = getActiveDescendant(); + if (!active) { + return; + } + + optionWalker.setCurrent(active); + const next = optionWalker.next(); + if (next) { + setActiveDescendant(next); + } + }, + prev: () => { + if (!listboxRef.current || !activeParentRef.current) { + return; + } + + const active = getActiveDescendant(); + if (!active) { + return; + } + + optionWalker.setCurrent(active); + if (!matchOption(active)) { + optionWalker.prev(); + } + + const next = optionWalker.prev(); + + if (next && next !== listboxRef.current) { + setActiveDescendant(next); + } + }, + blur: () => { + if (!activeParentRef.current) { + return; + } + + setActiveDescendant(undefined); + }, + active: () => { + if (listboxRef.current) { + return getActiveDescendant()?.id; + } + }, + + focus: (id: string) => { + if (!listboxRef.current) { + return; + } + + optionWalker.setCurrent(listboxRef.current); + let cur = optionWalker.next(); + + while (cur && cur.id !== id) { + cur = optionWalker.next(); + } + + if (cur) { + setActiveDescendant(cur); + } + }, + })); + + return { listboxRef, activeParentRef }; +} diff --git a/packages/react-components/react-components/stories/ContentEditableTags/utils/useOptionWalker.ts b/packages/react-components/react-components/stories/ContentEditableTags/utils/useOptionWalker.ts new file mode 100644 index 0000000000000..7e528ff54746b --- /dev/null +++ b/packages/react-components/react-components/stories/ContentEditableTags/utils/useOptionWalker.ts @@ -0,0 +1,72 @@ +import * as React from 'react'; +import { useFluent_unstable as useFluent } from '@fluentui/react-shared-contexts'; +import { isHTMLElement, useIsomorphicLayoutEffect } from '@fluentui/react-utilities'; + +interface UseOptionWalkerOptions { + matchOption: (el: HTMLElement) => boolean; +} + +export function useOptionWalker(options: UseOptionWalkerOptions) { + const { matchOption } = options; + const { targetDocument } = useFluent(); + const treeWalkerRef = React.useRef(null); + const listboxRef = React.useRef(null); + + const optionFilter = React.useCallback( + (node: Node) => { + if (isHTMLElement(node) && matchOption(node)) { + return NodeFilter.FILTER_ACCEPT; + } + + return NodeFilter.FILTER_SKIP; + }, + [matchOption], + ); + + useIsomorphicLayoutEffect(() => { + if (!targetDocument || !listboxRef.current) { + return; + } + + treeWalkerRef.current = targetDocument.createTreeWalker(listboxRef.current, NodeFilter.SHOW_ELEMENT, optionFilter); + }, [targetDocument, optionFilter]); + + const optionWalker = React.useMemo( + () => ({ + first: () => { + if (!treeWalkerRef.current) { + return null; + } + + return treeWalkerRef.current.firstChild() as HTMLElement | null; + }, + next: () => { + if (!treeWalkerRef.current) { + return null; + } + + return treeWalkerRef.current.nextNode() as HTMLElement | null; + }, + prev: () => { + if (!treeWalkerRef.current) { + return null; + } + + return treeWalkerRef.current.previousNode() as HTMLElement | null; + }, + setCurrent: (el: HTMLElement) => { + if (!treeWalkerRef.current) { + return; + } + + treeWalkerRef.current.currentNode = el; + }, + }), + [], + ); + + return { + optionWalker, + listboxRef, + }; +} From 5a6d8a775e343fd1832962aa68df4f6f4d30c985 Mon Sep 17 00:00:00 2001 From: Adam Samec Date: Mon, 6 May 2024 17:06:12 +0200 Subject: [PATCH 03/30] Fix selection from dropdown --- .../ContentEditableTagsRenderer.tsx | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/packages/react-components/react-components/stories/ContentEditableTags/ContentEditableTagsRenderer.tsx b/packages/react-components/react-components/stories/ContentEditableTags/ContentEditableTagsRenderer.tsx index eafe44b715bcf..29e4469ac03b0 100644 --- a/packages/react-components/react-components/stories/ContentEditableTags/ContentEditableTagsRenderer.tsx +++ b/packages/react-components/react-components/stories/ContentEditableTags/ContentEditableTagsRenderer.tsx @@ -73,9 +73,9 @@ export const ContentEditableTagsRenderer = () => { ); const onInputKeyDown = React.useCallback( - (e: React.KeyboardEvent) => { + (event: React.KeyboardEvent) => { let preventDefault: boolean | undefined | void = false; - switch (e.key) { + switch (event.key) { case 'Enter': selectActive(); setOpen(false); @@ -104,11 +104,11 @@ export const ContentEditableTagsRenderer = () => { setOpen(false); break; default: - setOpen(true); + // setOpen(true); } if (preventDefault) { - e.preventDefault(); + event.preventDefault(); } }, [selectActive, setOpen], @@ -130,7 +130,7 @@ export const ContentEditableTagsRenderer = () => { return ( <> } + inputRef={inputRef as React.Ref} value={value} onContentChange={onInputChange} onKeyDown={onInputKeyDown} @@ -152,12 +152,13 @@ export const ContentEditableTagsRenderer = () => { }; interface ContenteditableProps extends React.HTMLAttributes { - innerRef: React.Ref; + inputRef: React.Ref; value: string | null; onContentChange: (newValue: string | null) => void; } -const Contenteditable: React.FC = ({ value, onContentChange, ...props }) => { +const Contenteditable: React.FC = ({ inputRef, value, onContentChange, ...props }) => { const contentEditableRef = React.useRef(null); + const ref = useMergedRefs(inputRef, contentEditableRef); React.useEffect(() => { if (contentEditableRef.current && contentEditableRef.current.textContent !== value) { @@ -168,7 +169,7 @@ const Contenteditable: React.FC = ({ value, onContentChang return (
{ const newValue = (event.target as HTMLDivElement).textContent; onContentChange(newValue); From 04273debdc682d82d3b7073b44f7f86fc18ed026 Mon Sep 17 00:00:00 2001 From: Adam Samec Date: Mon, 6 May 2024 18:51:13 +0200 Subject: [PATCH 04/30] Insert dropdown value into caret position --- .../ContentEditableTagsRenderer.tsx | 12 ++++++---- .../ContentEditableTags/utils/selection.ts | 23 +++++++++++++++++++ 2 files changed, 31 insertions(+), 4 deletions(-) create mode 100644 packages/react-components/react-components/stories/ContentEditableTags/utils/selection.ts diff --git a/packages/react-components/react-components/stories/ContentEditableTags/ContentEditableTagsRenderer.tsx b/packages/react-components/react-components/stories/ContentEditableTags/ContentEditableTagsRenderer.tsx index 29e4469ac03b0..b480adae2e645 100644 --- a/packages/react-components/react-components/stories/ContentEditableTags/ContentEditableTagsRenderer.tsx +++ b/packages/react-components/react-components/stories/ContentEditableTags/ContentEditableTagsRenderer.tsx @@ -3,6 +3,7 @@ import { useFluent } from '@fluentui/react-components'; import { usePositioning } from '@fluentui/react-positioning'; import { useId, useMergedRefs, useOnClickOutside } from '@fluentui/react-utilities'; +import { useCaretPosition } from './utils/selection'; import { ActiveDescendantImperativeRef } from './utils/types'; import { useActiveDescendant } from './utils/useActiveDescendant'; @@ -15,6 +16,7 @@ const options = people.map(person => ({ export const ContentEditableTagsRenderer = () => { const { targetDocument } = useFluent(); + const { getCaretPosition } = useCaretPosition(); const [open, setOpen] = React.useState(false); const [value, setValue] = React.useState(''); @@ -56,14 +58,16 @@ export const ContentEditableTagsRenderer = () => { if (activeId) { const next = options.find(x => x.id === activeId); if (next) { - setValue(next.text); + const position = inputRef.current ? getCaretPosition(inputRef.current) : 0; + const newValue = value ? [value.slice(0, position), next.text, value.slice(position)].join('') : next.text; + setValue(newValue); setSelected(next.id); } else { - const selectedText = options.find(x => x.id === selected)?.text; - setValue(selectedText ?? ''); + // const selectedText = options.find(x => x.id === selected)?.text; + // setValue(selectedText ?? ''); } } - }, [selected]); + }, [selected, getCaretPosition, inputRef, value]); const onInputChange = React.useCallback( (newValue: string | null) => { diff --git a/packages/react-components/react-components/stories/ContentEditableTags/utils/selection.ts b/packages/react-components/react-components/stories/ContentEditableTags/utils/selection.ts new file mode 100644 index 0000000000000..5631c09fb7522 --- /dev/null +++ b/packages/react-components/react-components/stories/ContentEditableTags/utils/selection.ts @@ -0,0 +1,23 @@ +import { useFluent } from '@fluentui/react-components'; + +export const useCaretPosition = () => { + const { targetDocument } = useFluent(); + + const getCaretPosition = (element: HTMLElement) => { + const win = targetDocument?.defaultView; + if (!win) { + return 0; + } + const sel = win.getSelection(); + if (!sel || sel.rangeCount === 0) { + return 0; + } + const range = sel.getRangeAt(0); + const preCaretRange = range.cloneRange(); + preCaretRange.selectNodeContents(element); + preCaretRange.setEnd(range.endContainer, range.endOffset); + const caretOffset = preCaretRange.toString().length; + return caretOffset; + }; + return { getCaretPosition }; +}; From 233c94c7d6ed18c62d8a005c59ba8b7bcb0bb32a Mon Sep 17 00:00:00 2001 From: Adam Samec Date: Tue, 7 May 2024 15:32:21 +0200 Subject: [PATCH 05/30] Add field aria-label --- .../stories/ContentEditableTags/ContentEditableTagsRenderer.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/react-components/react-components/stories/ContentEditableTags/ContentEditableTagsRenderer.tsx b/packages/react-components/react-components/stories/ContentEditableTags/ContentEditableTagsRenderer.tsx index b480adae2e645..0305e82885d56 100644 --- a/packages/react-components/react-components/stories/ContentEditableTags/ContentEditableTagsRenderer.tsx +++ b/packages/react-components/react-components/stories/ContentEditableTags/ContentEditableTagsRenderer.tsx @@ -139,6 +139,7 @@ export const ContentEditableTagsRenderer = () => { onContentChange={onInputChange} onKeyDown={onInputKeyDown} data-selected={selected} + aria-label="Choose person" aria-controls={open ? listboxId : undefined} aria-expanded={open} /> From e25601027bcf338d4a151511eba2a70a0c0a0f25 Mon Sep 17 00:00:00 2001 From: Adam Samec Date: Tue, 7 May 2024 16:17:46 +0200 Subject: [PATCH 06/30] Remove unused dependancy --- .../stories/ContentEditableTags/ContentEditableTagsRenderer.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-components/react-components/stories/ContentEditableTags/ContentEditableTagsRenderer.tsx b/packages/react-components/react-components/stories/ContentEditableTags/ContentEditableTagsRenderer.tsx index 0305e82885d56..62c87d4c55092 100644 --- a/packages/react-components/react-components/stories/ContentEditableTags/ContentEditableTagsRenderer.tsx +++ b/packages/react-components/react-components/stories/ContentEditableTags/ContentEditableTagsRenderer.tsx @@ -67,7 +67,7 @@ export const ContentEditableTagsRenderer = () => { // setValue(selectedText ?? ''); } } - }, [selected, getCaretPosition, inputRef, value]); + }, [getCaretPosition, inputRef, value]); const onInputChange = React.useCallback( (newValue: string | null) => { From 71a4fdb1a7ea59b2e6457891252450656a5f6688 Mon Sep 17 00:00:00 2001 From: Adam Samec Date: Tue, 7 May 2024 19:04:34 +0200 Subject: [PATCH 07/30] Work on picker item selection not yet done --- .../ContentEditableTagsRenderer.tsx | 27 ++++++++---- .../ContentEditableTags/utils/selection.ts | 44 ++++++++++++++++++- 2 files changed, 61 insertions(+), 10 deletions(-) diff --git a/packages/react-components/react-components/stories/ContentEditableTags/ContentEditableTagsRenderer.tsx b/packages/react-components/react-components/stories/ContentEditableTags/ContentEditableTagsRenderer.tsx index 62c87d4c55092..54a46991a163c 100644 --- a/packages/react-components/react-components/stories/ContentEditableTags/ContentEditableTagsRenderer.tsx +++ b/packages/react-components/react-components/stories/ContentEditableTags/ContentEditableTagsRenderer.tsx @@ -16,10 +16,12 @@ const options = people.map(person => ({ export const ContentEditableTagsRenderer = () => { const { targetDocument } = useFluent(); - const { getCaretPosition } = useCaretPosition(); + const { overrideSelection, getCaretPosition } = useCaretPosition(); const [open, setOpen] = React.useState(false); - const [value, setValue] = React.useState(''); + const [value, setValue] = React.useState( + 'JohnnySomebodyMark', + ); const [selected, setSelected] = React.useState(null); const listboxId = useId('listbox'); @@ -103,19 +105,25 @@ export const ContentEditableTagsRenderer = () => { activeDescendantImperativeRef.current?.prev(); preventDefault = true; break; + case 'ArrowLeft': + preventDefault = overrideSelection(inputRef.current, 'Left'); + break; + case 'ArrowRight': + preventDefault = overrideSelection(inputRef.current, 'Right'); + break; case 'Tab': selectActive(); setOpen(false); break; default: - // setOpen(true); + break; } if (preventDefault) { event.preventDefault(); } }, - [selectActive, setOpen], + [selectActive, setOpen, inputRef, overrideSelection], ); React.useEffect(() => { @@ -166,17 +174,18 @@ const Contenteditable: React.FC = ({ inputRef, value, onCo const ref = useMergedRefs(inputRef, contentEditableRef); React.useEffect(() => { - if (contentEditableRef.current && contentEditableRef.current.textContent !== value) { - contentEditableRef.current.textContent = value; + if (contentEditableRef.current && contentEditableRef.current.innerHTML !== value) { + contentEditableRef.current.innerHTML = value ?? ''; } - }); + }, [value]); return (
{ - const newValue = (event.target as HTMLDivElement).textContent; + const newValue = (event.target as HTMLDivElement).innerHTML; onContentChange(newValue); }} {...props} diff --git a/packages/react-components/react-components/stories/ContentEditableTags/utils/selection.ts b/packages/react-components/react-components/stories/ContentEditableTags/utils/selection.ts index 5631c09fb7522..9d4ed9cacedf7 100644 --- a/packages/react-components/react-components/stories/ContentEditableTags/utils/selection.ts +++ b/packages/react-components/react-components/stories/ContentEditableTags/utils/selection.ts @@ -3,6 +3,45 @@ import { useFluent } from '@fluentui/react-components'; export const useCaretPosition = () => { const { targetDocument } = useFluent(); + const overrideSelection = (element: HTMLElement | null, direction: string) => { + const win = targetDocument?.defaultView; + if (!win || element === null) { + return false; + } + const selection = win.getSelection(); + if (!selection || selection.rangeCount === 0) { + return false; + } + const range = selection.getRangeAt(0); + + if (direction === 'Left') { + // Override the left boundary of selection + const startContainer = range.startContainer; + const startOffset = range.startOffset; + const startParent = startContainer.parentNode as HTMLElement; + if (startParent?.classList.contains('pickedItem') && startOffset !== 0) { + range.setStart(startContainer, 0); + return true; + } + } else if (direction === 'Right') { + // Override the right boundary of selection + const endContainer = range.endContainer; + const endOffset = range.endOffset; + const endParent = endContainer.parentNode as HTMLElement; + // const prevSibling = endParent.previousSibling; + const endContainerRange = range.cloneRange(); + endContainerRange.selectNodeContents(endContainer); + const nextSibling = endContainer.nextSibling; + if (nextSibling && endParent?.classList.contains('pickerInput') && endOffset === endContainerRange.endOffset) { + // alert(endOffset); + const nextSiblingRange = range.cloneRange(); + nextSiblingRange.selectNodeContents(nextSibling); + range.setEnd(nextSiblingRange.endContainer, nextSiblingRange.endOffset); + return true; + } + } + }; + const getCaretPosition = (element: HTMLElement) => { const win = targetDocument?.defaultView; if (!win) { @@ -19,5 +58,8 @@ export const useCaretPosition = () => { const caretOffset = preCaretRange.toString().length; return caretOffset; }; - return { getCaretPosition }; + return { + overrideSelection, + getCaretPosition, + }; }; From 9bab1c6aadbe870e4e88db1e693c50246ceed7a6 Mon Sep 17 00:00:00 2001 From: Adam Samec Date: Thu, 9 May 2024 12:55:01 +0200 Subject: [PATCH 08/30] Proper selection when navigating in input --- .../stories/ContentEditableTags/utils/selection.ts | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/packages/react-components/react-components/stories/ContentEditableTags/utils/selection.ts b/packages/react-components/react-components/stories/ContentEditableTags/utils/selection.ts index 9d4ed9cacedf7..c1fbf73f7a0da 100644 --- a/packages/react-components/react-components/stories/ContentEditableTags/utils/selection.ts +++ b/packages/react-components/react-components/stories/ContentEditableTags/utils/selection.ts @@ -28,17 +28,23 @@ export const useCaretPosition = () => { const endContainer = range.endContainer; const endOffset = range.endOffset; const endParent = endContainer.parentNode as HTMLElement; - // const prevSibling = endParent.previousSibling; const endContainerRange = range.cloneRange(); endContainerRange.selectNodeContents(endContainer); - const nextSibling = endContainer.nextSibling; - if (nextSibling && endParent?.classList.contains('pickerInput') && endOffset === endContainerRange.endOffset) { - // alert(endOffset); + const nextSibling = endContainer.nextSibling as HTMLElement; + const isEndRightBeforeItem = + nextSibling && endParent?.classList.contains('pickerInput') && endOffset === endContainerRange.endOffset; + if (isEndRightBeforeItem) { const nextSiblingRange = range.cloneRange(); nextSiblingRange.selectNodeContents(nextSibling); range.setEnd(nextSiblingRange.endContainer, nextSiblingRange.endOffset); return true; } + endContainerRange.selectNodeContents(endContainer); + const isEndInsideItem = endParent?.classList.contains('pickedItem') && endOffset < endContainerRange.endOffset; + if (isEndInsideItem) { + range.selectNodeContents(endContainer); + return true; + } } }; From 877c3fd00ccecb50c1e71039af5b209dc2ec8e06 Mon Sep 17 00:00:00 2001 From: Adam Samec Date: Thu, 9 May 2024 12:59:11 +0200 Subject: [PATCH 09/30] Refactor useCaretManipulation --- .../ContentEditableTags/ContentEditableTagsRenderer.tsx | 4 ++-- .../utils/{selection.ts => useCaretManipulation.ts} | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) rename packages/react-components/react-components/stories/ContentEditableTags/utils/{selection.ts => useCaretManipulation.ts} (98%) diff --git a/packages/react-components/react-components/stories/ContentEditableTags/ContentEditableTagsRenderer.tsx b/packages/react-components/react-components/stories/ContentEditableTags/ContentEditableTagsRenderer.tsx index 54a46991a163c..d86fb42697b11 100644 --- a/packages/react-components/react-components/stories/ContentEditableTags/ContentEditableTagsRenderer.tsx +++ b/packages/react-components/react-components/stories/ContentEditableTags/ContentEditableTagsRenderer.tsx @@ -3,7 +3,7 @@ import { useFluent } from '@fluentui/react-components'; import { usePositioning } from '@fluentui/react-positioning'; import { useId, useMergedRefs, useOnClickOutside } from '@fluentui/react-utilities'; -import { useCaretPosition } from './utils/selection'; +import { useCaretManipulation } from './utils/useCaretManipulation'; import { ActiveDescendantImperativeRef } from './utils/types'; import { useActiveDescendant } from './utils/useActiveDescendant'; @@ -16,7 +16,7 @@ const options = people.map(person => ({ export const ContentEditableTagsRenderer = () => { const { targetDocument } = useFluent(); - const { overrideSelection, getCaretPosition } = useCaretPosition(); + const { overrideSelection, getCaretPosition } = useCaretManipulation(); const [open, setOpen] = React.useState(false); const [value, setValue] = React.useState( diff --git a/packages/react-components/react-components/stories/ContentEditableTags/utils/selection.ts b/packages/react-components/react-components/stories/ContentEditableTags/utils/useCaretManipulation.ts similarity index 98% rename from packages/react-components/react-components/stories/ContentEditableTags/utils/selection.ts rename to packages/react-components/react-components/stories/ContentEditableTags/utils/useCaretManipulation.ts index c1fbf73f7a0da..f76dc2c5a6b26 100644 --- a/packages/react-components/react-components/stories/ContentEditableTags/utils/selection.ts +++ b/packages/react-components/react-components/stories/ContentEditableTags/utils/useCaretManipulation.ts @@ -1,6 +1,6 @@ import { useFluent } from '@fluentui/react-components'; -export const useCaretPosition = () => { +export const useCaretManipulation = () => { const { targetDocument } = useFluent(); const overrideSelection = (element: HTMLElement | null, direction: string) => { From f80936126f52cf1d8bbd17288e8cca3989d3bebe Mon Sep 17 00:00:00 2001 From: Adam Samec Date: Thu, 9 May 2024 16:38:54 +0200 Subject: [PATCH 10/30] Caret override and refactor --- .../ContentEditableTagsRenderer.tsx | 10 +- .../utils/useCaretManipulation.ts | 97 ++++++++++++------- 2 files changed, 67 insertions(+), 40 deletions(-) diff --git a/packages/react-components/react-components/stories/ContentEditableTags/ContentEditableTagsRenderer.tsx b/packages/react-components/react-components/stories/ContentEditableTags/ContentEditableTagsRenderer.tsx index d86fb42697b11..b9bd720d999fa 100644 --- a/packages/react-components/react-components/stories/ContentEditableTags/ContentEditableTagsRenderer.tsx +++ b/packages/react-components/react-components/stories/ContentEditableTags/ContentEditableTagsRenderer.tsx @@ -16,11 +16,11 @@ const options = people.map(person => ({ export const ContentEditableTagsRenderer = () => { const { targetDocument } = useFluent(); - const { overrideSelection, getCaretPosition } = useCaretManipulation(); + const { overrideLeftArrow, overrideRightArrow, getCaretPosition } = useCaretManipulation(); const [open, setOpen] = React.useState(false); const [value, setValue] = React.useState( - 'JohnnySomebodyMark', + 'JohnnyBobSomebodyMark', ); const [selected, setSelected] = React.useState(null); const listboxId = useId('listbox'); @@ -106,10 +106,10 @@ export const ContentEditableTagsRenderer = () => { preventDefault = true; break; case 'ArrowLeft': - preventDefault = overrideSelection(inputRef.current, 'Left'); + preventDefault = overrideLeftArrow(inputRef.current); break; case 'ArrowRight': - preventDefault = overrideSelection(inputRef.current, 'Right'); + preventDefault = overrideRightArrow(inputRef.current); break; case 'Tab': selectActive(); @@ -123,7 +123,7 @@ export const ContentEditableTagsRenderer = () => { event.preventDefault(); } }, - [selectActive, setOpen, inputRef, overrideSelection], + [selectActive, setOpen, overrideLeftArrow, overrideRightArrow, inputRef], ); React.useEffect(() => { diff --git a/packages/react-components/react-components/stories/ContentEditableTags/utils/useCaretManipulation.ts b/packages/react-components/react-components/stories/ContentEditableTags/utils/useCaretManipulation.ts index f76dc2c5a6b26..5e2d930f8e13f 100644 --- a/packages/react-components/react-components/stories/ContentEditableTags/utils/useCaretManipulation.ts +++ b/packages/react-components/react-components/stories/ContentEditableTags/utils/useCaretManipulation.ts @@ -3,48 +3,74 @@ import { useFluent } from '@fluentui/react-components'; export const useCaretManipulation = () => { const { targetDocument } = useFluent(); - const overrideSelection = (element: HTMLElement | null, direction: string) => { + const getRange = (element: HTMLElement | null) => { const win = targetDocument?.defaultView; if (!win || element === null) { - return false; + return null; } const selection = win.getSelection(); if (!selection || selection.rangeCount === 0) { - return false; + return null; } const range = selection.getRangeAt(0); + return range; + }; + + const overrideLeftArrow = (element: HTMLElement | null) => { + const range = getRange(element); + if (range === null) { + return false; + } + const startContainer = range.startContainer; + const startOffset = range.startOffset; + const startParent = startContainer.parentNode as HTMLElement; + if (startParent?.classList.contains('pickedItem') && startOffset !== 0) { + range.setStart(startContainer, 0); + return true; + } + return false; + }; - if (direction === 'Left') { - // Override the left boundary of selection - const startContainer = range.startContainer; - const startOffset = range.startOffset; - const startParent = startContainer.parentNode as HTMLElement; - if (startParent?.classList.contains('pickedItem') && startOffset !== 0) { - range.setStart(startContainer, 0); - return true; - } - } else if (direction === 'Right') { - // Override the right boundary of selection - const endContainer = range.endContainer; - const endOffset = range.endOffset; - const endParent = endContainer.parentNode as HTMLElement; - const endContainerRange = range.cloneRange(); - endContainerRange.selectNodeContents(endContainer); - const nextSibling = endContainer.nextSibling as HTMLElement; - const isEndRightBeforeItem = - nextSibling && endParent?.classList.contains('pickerInput') && endOffset === endContainerRange.endOffset; - if (isEndRightBeforeItem) { - const nextSiblingRange = range.cloneRange(); - nextSiblingRange.selectNodeContents(nextSibling); - range.setEnd(nextSiblingRange.endContainer, nextSiblingRange.endOffset); - return true; - } - endContainerRange.selectNodeContents(endContainer); - const isEndInsideItem = endParent?.classList.contains('pickedItem') && endOffset < endContainerRange.endOffset; - if (isEndInsideItem) { - range.selectNodeContents(endContainer); - return true; - } + const overrideRightArrow = (element: HTMLElement | null) => { + const range = getRange(element); + if (range === null) { + return false; + } + const endContainer = range.endContainer; + const startOffset = range.startOffset; + const endOffset = range.endOffset; + const endParent = endContainer.parentNode as HTMLElement; + const endContainerRange = range.cloneRange(); + endContainerRange.selectNodeContents(endContainer); + const endContainerNextSibling = endContainer.nextSibling as HTMLElement; + const isTextEndRightBeforeItem = + endContainerNextSibling && + endParent?.classList.contains('pickerInput') && + endOffset === endContainerRange.endOffset; + if (isTextEndRightBeforeItem) { + const endContainreNextSiblingRange = range.cloneRange(); + endContainreNextSiblingRange.selectNodeContents(endContainerNextSibling); + range.setEnd(endContainreNextSiblingRange.endContainer, endContainreNextSiblingRange.endOffset); + return true; + } + const endParentNextSibling = endParent.nextSibling as HTMLElement; + const endParentNextSiblingText = endParentNextSibling.firstChild as ChildNode; + const isItemEndRightBeforeItem = + startOffset === endOffset && + endOffset === endContainerRange.endOffset && + endParent.classList.contains('pickedItem') && + endParentNextSibling.classList.contains('pickedItem'); + if (isItemEndRightBeforeItem) { + const endParentNextSiblingRange = range.cloneRange(); + endParentNextSiblingRange.selectNodeContents(endParentNextSiblingText); + range.setEnd(endParentNextSiblingRange.endContainer, endParentNextSiblingRange.endOffset); + return true; + } + endContainerRange.selectNodeContents(endContainer); + const isEndInsideItem = endParent?.classList.contains('pickedItem') && endOffset < endContainerRange.endOffset; + if (isEndInsideItem) { + range.selectNodeContents(endContainer); + return true; } }; @@ -65,7 +91,8 @@ export const useCaretManipulation = () => { return caretOffset; }; return { - overrideSelection, + overrideLeftArrow, + overrideRightArrow, getCaretPosition, }; }; From a3cf4775fc0318b94b12f377c544b6fe5a353345 Mon Sep 17 00:00:00 2001 From: Adam Samec Date: Thu, 9 May 2024 20:06:00 +0200 Subject: [PATCH 11/30] Still buggy --- .../ContentEditableTagsRenderer.tsx | 17 ++++++---- .../utils/useCaretManipulation.ts | 33 ++++++++++++++++++- .../ContentEditableTags/utils/utils.ts | 8 +++++ 3 files changed, 51 insertions(+), 7 deletions(-) create mode 100644 packages/react-components/react-components/stories/ContentEditableTags/utils/utils.ts diff --git a/packages/react-components/react-components/stories/ContentEditableTags/ContentEditableTagsRenderer.tsx b/packages/react-components/react-components/stories/ContentEditableTags/ContentEditableTagsRenderer.tsx index b9bd720d999fa..f9ec4c8647eb4 100644 --- a/packages/react-components/react-components/stories/ContentEditableTags/ContentEditableTagsRenderer.tsx +++ b/packages/react-components/react-components/stories/ContentEditableTags/ContentEditableTagsRenderer.tsx @@ -3,6 +3,7 @@ import { useFluent } from '@fluentui/react-components'; import { usePositioning } from '@fluentui/react-positioning'; import { useId, useMergedRefs, useOnClickOutside } from '@fluentui/react-utilities'; +import { isKeyCharacter } from './utils/utils'; import { useCaretManipulation } from './utils/useCaretManipulation'; import { ActiveDescendantImperativeRef } from './utils/types'; import { useActiveDescendant } from './utils/useActiveDescendant'; @@ -16,7 +17,7 @@ const options = people.map(person => ({ export const ContentEditableTagsRenderer = () => { const { targetDocument } = useFluent(); - const { overrideLeftArrow, overrideRightArrow, getCaretPosition } = useCaretManipulation(); + const { overrideLeftArrow, overrideRightArrow, moveCaretFromItem, getCaretPosition } = useCaretManipulation(); const [open, setOpen] = React.useState(false); const [value, setValue] = React.useState( @@ -105,17 +106,21 @@ export const ContentEditableTagsRenderer = () => { activeDescendantImperativeRef.current?.prev(); preventDefault = true; break; + case 'Tab': + selectActive(); + setOpen(false); + break; case 'ArrowLeft': preventDefault = overrideLeftArrow(inputRef.current); break; case 'ArrowRight': preventDefault = overrideRightArrow(inputRef.current); break; - case 'Tab': - selectActive(); - setOpen(false); - break; default: + if (isKeyCharacter(event)) { + preventDefault = moveCaretFromItem(inputRef.current, event.key); + } + // alert(preventDefault); break; } @@ -123,7 +128,7 @@ export const ContentEditableTagsRenderer = () => { event.preventDefault(); } }, - [selectActive, setOpen, overrideLeftArrow, overrideRightArrow, inputRef], + [selectActive, setOpen, overrideLeftArrow, overrideRightArrow, moveCaretFromItem, inputRef], ); React.useEffect(() => { diff --git a/packages/react-components/react-components/stories/ContentEditableTags/utils/useCaretManipulation.ts b/packages/react-components/react-components/stories/ContentEditableTags/utils/useCaretManipulation.ts index 5e2d930f8e13f..89bc11178dc39 100644 --- a/packages/react-components/react-components/stories/ContentEditableTags/utils/useCaretManipulation.ts +++ b/packages/react-components/react-components/stories/ContentEditableTags/utils/useCaretManipulation.ts @@ -48,8 +48,9 @@ export const useCaretManipulation = () => { endParent?.classList.contains('pickerInput') && endOffset === endContainerRange.endOffset; if (isTextEndRightBeforeItem) { + const endContainerNextSiblingText = endContainerNextSibling.firstChild as ChildNode; const endContainreNextSiblingRange = range.cloneRange(); - endContainreNextSiblingRange.selectNodeContents(endContainerNextSibling); + endContainreNextSiblingRange.selectNodeContents(endContainerNextSiblingText); range.setEnd(endContainreNextSiblingRange.endContainer, endContainreNextSiblingRange.endOffset); return true; } @@ -63,6 +64,7 @@ export const useCaretManipulation = () => { if (isItemEndRightBeforeItem) { const endParentNextSiblingRange = range.cloneRange(); endParentNextSiblingRange.selectNodeContents(endParentNextSiblingText); + // alert(endParentNextSiblingRange.endOffset); range.setEnd(endParentNextSiblingRange.endContainer, endParentNextSiblingRange.endOffset); return true; } @@ -74,6 +76,34 @@ export const useCaretManipulation = () => { } }; + const moveCaretFromItem = (element: HTMLElement | null, character: string) => { + const range = getRange(element); + if (range === null) { + return false; + } + const startContainer = range.startContainer; + const startOffset = range.startOffset; + const startParent = startContainer.parentNode as HTMLElement; + // const startContainerRange = range.cloneRange(); + // startContainerRange.selectNodeContents(startContainer); + // const isAtItemEnd = startParent?.classList.contains('pickedItem') && startOffset === startContainerRange.endOffset; + const isAtItemStart = startParent?.classList.contains('pickedItem') && startOffset === 0; + if (isAtItemStart) { + let prevSibling = startParent.previousSibling; + let characterInserted = false; + if (prevSibling === null || prevSibling.nodeType !== Node.TEXT_NODE) { + prevSibling = targetDocument?.createTextNode(character) as Text; + startParent.parentNode?.insertBefore(prevSibling, startParent); + characterInserted = true; + } + const newPosition = prevSibling.toString().length; + range.setStart(prevSibling, newPosition); + range.setEnd(prevSibling, newPosition); + return characterInserted; + } + return false; + }; + const getCaretPosition = (element: HTMLElement) => { const win = targetDocument?.defaultView; if (!win) { @@ -93,6 +123,7 @@ export const useCaretManipulation = () => { return { overrideLeftArrow, overrideRightArrow, + moveCaretFromItem, getCaretPosition, }; }; diff --git a/packages/react-components/react-components/stories/ContentEditableTags/utils/utils.ts b/packages/react-components/react-components/stories/ContentEditableTags/utils/utils.ts new file mode 100644 index 0000000000000..4b91f7400dd33 --- /dev/null +++ b/packages/react-components/react-components/stories/ContentEditableTags/utils/utils.ts @@ -0,0 +1,8 @@ +export const isKeyCharacter = event => { + const isNumeric = typeof event.which === 'number' && event.which > 0 && event.which !== 8; + const isModifier = event.key.length > 1 || event.ctrlKey || event.metaKey || event.altKey; + if (isNumeric && !isModifier) { + return true; + } + return false; +}; From 933164ca3d9bbdbc6ac06c87ed4fbf665156366b Mon Sep 17 00:00:00 2001 From: Adam Samec Date: Mon, 13 May 2024 15:15:39 +0200 Subject: [PATCH 12/30] Some fixes --- .../ContentEditableTagsRenderer.tsx | 2 +- .../utils/useCaretManipulation.ts | 16 ++++++++-------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/react-components/react-components/stories/ContentEditableTags/ContentEditableTagsRenderer.tsx b/packages/react-components/react-components/stories/ContentEditableTags/ContentEditableTagsRenderer.tsx index f9ec4c8647eb4..65dee94c5b467 100644 --- a/packages/react-components/react-components/stories/ContentEditableTags/ContentEditableTagsRenderer.tsx +++ b/packages/react-components/react-components/stories/ContentEditableTags/ContentEditableTagsRenderer.tsx @@ -118,7 +118,7 @@ export const ContentEditableTagsRenderer = () => { break; default: if (isKeyCharacter(event)) { - preventDefault = moveCaretFromItem(inputRef.current, event.key); + moveCaretFromItem(inputRef.current); } // alert(preventDefault); break; diff --git a/packages/react-components/react-components/stories/ContentEditableTags/utils/useCaretManipulation.ts b/packages/react-components/react-components/stories/ContentEditableTags/utils/useCaretManipulation.ts index 89bc11178dc39..8e80dd5d64b48 100644 --- a/packages/react-components/react-components/stories/ContentEditableTags/utils/useCaretManipulation.ts +++ b/packages/react-components/react-components/stories/ContentEditableTags/utils/useCaretManipulation.ts @@ -25,7 +25,10 @@ export const useCaretManipulation = () => { const startOffset = range.startOffset; const startParent = startContainer.parentNode as HTMLElement; if (startParent?.classList.contains('pickedItem') && startOffset !== 0) { + // alert(range.startOffset); + // alert(range.endContainer.tkextContent); range.setStart(startContainer, 0); + range.setEnd(range.endContainer, 0); return true; } return false; @@ -76,10 +79,10 @@ export const useCaretManipulation = () => { } }; - const moveCaretFromItem = (element: HTMLElement | null, character: string) => { + const moveCaretFromItem = (element: HTMLElement | null) => { const range = getRange(element); if (range === null) { - return false; + return; } const startContainer = range.startContainer; const startOffset = range.startOffset; @@ -90,18 +93,15 @@ export const useCaretManipulation = () => { const isAtItemStart = startParent?.classList.contains('pickedItem') && startOffset === 0; if (isAtItemStart) { let prevSibling = startParent.previousSibling; - let characterInserted = false; if (prevSibling === null || prevSibling.nodeType !== Node.TEXT_NODE) { - prevSibling = targetDocument?.createTextNode(character) as Text; + prevSibling = targetDocument?.createTextNode('X') as Text; startParent.parentNode?.insertBefore(prevSibling, startParent); - characterInserted = true; } const newPosition = prevSibling.toString().length; - range.setStart(prevSibling, newPosition); + // range.setStart(prevSibling, newPosition); + range.setStart(prevSibling, 0); range.setEnd(prevSibling, newPosition); - return characterInserted; } - return false; }; const getCaretPosition = (element: HTMLElement) => { From 48d1e8c9fc0b8f2657560b63de55c9ff22985884 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20Vyhn=C3=A1lek?= Date: Tue, 14 May 2024 15:33:46 +0200 Subject: [PATCH 13/30] wip --- .../ContentEditableEntities/Entity.tsx | 87 ++++++++++++++++++ .../ContentEditableEntities/entityParser.ts | 40 ++++++++ .../ContentEditableEntities/index.tsx | 92 +++++++++++++++++++ .../ContentEditableEntities/manager.ts | 28 ++++++ .../useContentEditableEntitites.tsx | 7 ++ .../ContentEditableTags.stories.tsx | 4 + 6 files changed, 258 insertions(+) create mode 100644 packages/react-components/react-components/stories/ContentEditableTags/ContentEditableEntities/Entity.tsx create mode 100644 packages/react-components/react-components/stories/ContentEditableTags/ContentEditableEntities/entityParser.ts create mode 100644 packages/react-components/react-components/stories/ContentEditableTags/ContentEditableEntities/index.tsx create mode 100644 packages/react-components/react-components/stories/ContentEditableTags/ContentEditableEntities/manager.ts create mode 100644 packages/react-components/react-components/stories/ContentEditableTags/ContentEditableEntities/useContentEditableEntitites.tsx diff --git a/packages/react-components/react-components/stories/ContentEditableTags/ContentEditableEntities/Entity.tsx b/packages/react-components/react-components/stories/ContentEditableTags/ContentEditableEntities/Entity.tsx new file mode 100644 index 0000000000000..c35a8162e5c2e --- /dev/null +++ b/packages/react-components/react-components/stories/ContentEditableTags/ContentEditableEntities/Entity.tsx @@ -0,0 +1,87 @@ +import * as React from 'react'; +import { renderToString } from 'react-dom/server'; + +export class Entity { + public static EntityName: string; + + protected value: string; + protected editable: boolean = false; + protected element: HTMLElement | null = null; + protected id: string; + + public static parse(node: HTMLElement): Entity | null { + throw new Error('Method not implemented.'); + } + + constructor(value: string, id?: string) { + this.id = id ?? Math.random().toString(36).substr(2, 9); + this.value = value; + } + + public render() { + throw new Error('Method not implemented.'); + } +} + +export class TextEntity extends Entity { + public static entityName = 'TextEntity'; + + public static parse(node: HTMLElement): Entity | null { + const textContent = node.textContent || ''; + const entityId = node.dataset.entityid || ''; + if (textContent.trim() === '') { + return null; + } else { + return new TextEntity(textContent, entityId); + } + } + + public static parseNode(node: Node): Entity | null { + const textContent = node.textContent || ''; + if (textContent.trim() === '') { + return null; + } else { + return new TextEntity(textContent); + } + } + + constructor(value: string, id?: string) { + super(value, id); + this.editable = true; + } + + public render() { + return renderToString( + + {this.value} + , + ); + } +} + +export class AtomicEntity extends Entity { + public static entityName = 'AtomicEntity'; + + public static parse(node: HTMLElement): Entity | null { + const textContent = node.textContent || ''; + const entityId = node.dataset.entityid || ''; + if (textContent.trim() === '') { + return null; + } else { + return new AtomicEntity(textContent, entityId); + } + } + + public render() { + return renderToString( + + {this.value} + , + ); + } +} diff --git a/packages/react-components/react-components/stories/ContentEditableTags/ContentEditableEntities/entityParser.ts b/packages/react-components/react-components/stories/ContentEditableTags/ContentEditableEntities/entityParser.ts new file mode 100644 index 0000000000000..e176a7cd4aacc --- /dev/null +++ b/packages/react-components/react-components/stories/ContentEditableTags/ContentEditableEntities/entityParser.ts @@ -0,0 +1,40 @@ +import { Entity, AtomicEntity, TextEntity } from './Entity'; + +const isTextNode = (node: Node): node is Node => node.nodeType === Node.TEXT_NODE; +const isElement = (node: Node): node is HTMLElement => node.nodeType === Node.ELEMENT_NODE; + +export const parseEntities: (nodes: NodeList) => Entity[] = nodes => { + const entities: Entity[] = []; + for (let i = 0; i < nodes.length; i++) { + const node = nodes[i]; + + if (isTextNode(node)) { + const entity = TextEntity.parseNode(node); + if (entity) { + entities.push(entity); + } + } + + if (isElement(node)) { + const entityName = (node as HTMLElement).getAttribute('data-entityName'); + + if (!entityName) { + continue; + } + + if (entityName === TextEntity.entityName) { + const entity = TextEntity.parse(node); + if (entity) { + entities.push(entity); + } + } + if (entityName === AtomicEntity.entityName) { + const entity = AtomicEntity.parse(node); + if (entity) { + entities.push(entity); + } + } + } + } + return entities; +}; diff --git a/packages/react-components/react-components/stories/ContentEditableTags/ContentEditableEntities/index.tsx b/packages/react-components/react-components/stories/ContentEditableTags/ContentEditableEntities/index.tsx new file mode 100644 index 0000000000000..0c77ebca650ff --- /dev/null +++ b/packages/react-components/react-components/stories/ContentEditableTags/ContentEditableEntities/index.tsx @@ -0,0 +1,92 @@ +import { canUseDOM, useIsomorphicLayoutEffect } from '@fluentui/utilities'; +import * as React from 'react'; +import { useFluent } from '../../../src/index'; +import { createContentEditableManager } from './manager'; +import { AtomicEntity, Entity, TextEntity } from './Entity'; +import { parseEntities } from './entityParser'; + +export const ContentEditableEntities = () => { + const itemRef = React.useRef(null); + const { targetDocument } = useFluent(); + + const [entities, setEntitites] = React.useState([ + new TextEntity('hey'), + new AtomicEntity('Forest Gump'), + new AtomicEntity('James Hetfield'), + ]); + + const updateEntities = (newEntitites: Entity[]) => { + setEntitites(newEntitites); + }; + + const [manager, _] = React.useState(canUseDOM() ? createContentEditableManager(updateEntities) : null); + + useIsomorphicLayoutEffect(() => { + if (itemRef.current && manager) { + manager.attachInput(itemRef.current, targetDocument!); + } + }, [itemRef, manager, targetDocument]); + + return ( + { + // Replace entities with parsed + const newE = parseEntities(nodes); + // contentEditableRef.current!.innerHTML = ''; + updateEntities(newE); + }} + value={entities.map(entity => entity.render()).join('')} + /> + ); + + // return ( + //
{ + // const nodes = event.target.childNodes; + // // Replace entities with parsed + // const newE = parseEntities(nodes); + // // contentEditableRef.current!.innerHTML = ''; + // updateEntities(newE); + // }} + // > + // {entities.map(entity => entity.render())} + //
+ // ); +}; + +const ContentEditable = props => { + const cursorPosition = React.useRef(0); + const elementPosition = React.useRef(-1); + const { targetDocument } = useFluent(); + + const contentEditableRef = React.useRef(null); + + React.useEffect(() => { + if (contentEditableRef.current!.innerHTML !== props.value) { + const selection = window.getSelection(); + const range = document.createRange(); + + range.setStart(contentEditableRef.current!, cursorPosition.current); + range.collapse(true); + selection!.removeAllRanges(); + + contentEditableRef.current!.innerHTML = props.value; + + selection!.addRange(range); + // text_div.focus(); + } + }); + + return ( +
{ + props.onNodesUpdated(event.target.childNodes); + }} + /> + ); +}; diff --git a/packages/react-components/react-components/stories/ContentEditableTags/ContentEditableEntities/manager.ts b/packages/react-components/react-components/stories/ContentEditableTags/ContentEditableEntities/manager.ts new file mode 100644 index 0000000000000..6d85fd211ba03 --- /dev/null +++ b/packages/react-components/react-components/stories/ContentEditableTags/ContentEditableEntities/manager.ts @@ -0,0 +1,28 @@ +import { AtomicEntity, Entity, TextEntity } from './Entity'; +import { parseEntities } from './entityParser'; + +export const createContentEditableManager = (updateEntities: (entitites: Entity[]) => void) => { + let document: Document; + let input: HTMLElement; + + function attachInput(el: HTMLElement, doc: Document) { + input = el; + document = doc; + + // Init element + input.setAttribute('contenteditable', 'true'); + } + + function getInputProps() { + return { + onInput: e => { + const nodes = e.target.childNodes; + // Replace entities with parsed + const newE = parseEntities(nodes); + updateEntities(newE); + }, + }; + } + + return { attachInput, getInputProps }; +}; diff --git a/packages/react-components/react-components/stories/ContentEditableTags/ContentEditableEntities/useContentEditableEntitites.tsx b/packages/react-components/react-components/stories/ContentEditableTags/ContentEditableEntities/useContentEditableEntitites.tsx new file mode 100644 index 0000000000000..2fa97a28e0513 --- /dev/null +++ b/packages/react-components/react-components/stories/ContentEditableTags/ContentEditableEntities/useContentEditableEntitites.tsx @@ -0,0 +1,7 @@ +import * as React from 'react'; + +export const useContentEditableEntitites = () => { + const ref = React.useRef(null); + + return { ref }; +}; diff --git a/packages/react-components/react-components/stories/ContentEditableTags/ContentEditableTags.stories.tsx b/packages/react-components/react-components/stories/ContentEditableTags/ContentEditableTags.stories.tsx index d5bff3a182d07..2ce2d2ceccf74 100644 --- a/packages/react-components/react-components/stories/ContentEditableTags/ContentEditableTags.stories.tsx +++ b/packages/react-components/react-components/stories/ContentEditableTags/ContentEditableTags.stories.tsx @@ -1,12 +1,16 @@ import * as React from 'react'; import { Prototype } from './utils/stories'; import { ContentEditableTagsRenderer } from './ContentEditableTagsRenderer'; +import { ContentEditableEntities } from './ContentEditableEntities'; export const ContentEditableTags: React.FC = () => { return (

Content editable tags

+ +

Content editable - entities

+
); }; From bc1980d132a449f8e1d94f0af786ba961ce12450 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20Vyhn=C3=A1lek?= Date: Thu, 23 May 2024 12:12:55 +0200 Subject: [PATCH 14/30] lexical --- .../react-components/package.json | 4 +- .../ContentEditableEntities/Entity.tsx | 13 +- .../ContentEditableTags.stories.tsx | 6 +- .../Lexical2/AutocompletePlugin.tsx | 193 ++++++++++++++++ .../Lexical2/HorizontalRuleNode.tsx | 171 ++++++++++++++ .../Lexical2/NamePillNode.tsx | 67 ++++++ .../Lexical2/TagPicker.tsx | 66 ++++++ .../ContentEditableTags/Lexical2/index.tsx | 102 ++++++++ yarn.lock | 218 ++++++++++++++++++ 9 files changed, 833 insertions(+), 7 deletions(-) create mode 100644 packages/react-components/react-components/stories/ContentEditableTags/Lexical2/AutocompletePlugin.tsx create mode 100644 packages/react-components/react-components/stories/ContentEditableTags/Lexical2/HorizontalRuleNode.tsx create mode 100644 packages/react-components/react-components/stories/ContentEditableTags/Lexical2/NamePillNode.tsx create mode 100644 packages/react-components/react-components/stories/ContentEditableTags/Lexical2/TagPicker.tsx create mode 100644 packages/react-components/react-components/stories/ContentEditableTags/Lexical2/index.tsx diff --git a/packages/react-components/react-components/package.json b/packages/react-components/react-components/package.json index 72ec35af736d9..0722e9bf9afff 100644 --- a/packages/react-components/react-components/package.json +++ b/packages/react-components/react-components/package.json @@ -87,7 +87,9 @@ "@fluentui/react-rating": "^9.0.7", "@fluentui/react-search": "^9.0.3", "@fluentui/react-teaching-popover": "^9.1.3", - "@fluentui/react-tag-picker": "^9.0.0" + "@fluentui/react-tag-picker": "^9.0.0", + "lexical": "^0.15.0", + "@lexical/react": "^0.15.0" }, "peerDependencies": { "@types/react": ">=16.14.0 <19.0.0", diff --git a/packages/react-components/react-components/stories/ContentEditableTags/ContentEditableEntities/Entity.tsx b/packages/react-components/react-components/stories/ContentEditableTags/ContentEditableEntities/Entity.tsx index c35a8162e5c2e..41de225725411 100644 --- a/packages/react-components/react-components/stories/ContentEditableTags/ContentEditableEntities/Entity.tsx +++ b/packages/react-components/react-components/stories/ContentEditableTags/ContentEditableEntities/Entity.tsx @@ -51,12 +51,15 @@ export class TextEntity extends Entity { } public render() { - return renderToString( - - {this.value} - , - ); + return this.value; } + // public render() { + // return renderToString( + // + // {this.value} + // , + // ); + // } } export class AtomicEntity extends Entity { diff --git a/packages/react-components/react-components/stories/ContentEditableTags/ContentEditableTags.stories.tsx b/packages/react-components/react-components/stories/ContentEditableTags/ContentEditableTags.stories.tsx index 2ce2d2ceccf74..3668c7fe0a186 100644 --- a/packages/react-components/react-components/stories/ContentEditableTags/ContentEditableTags.stories.tsx +++ b/packages/react-components/react-components/stories/ContentEditableTags/ContentEditableTags.stories.tsx @@ -2,15 +2,19 @@ import * as React from 'react'; import { Prototype } from './utils/stories'; import { ContentEditableTagsRenderer } from './ContentEditableTagsRenderer'; import { ContentEditableEntities } from './ContentEditableEntities'; +import { LexicalEditor2 } from './Lexical2'; export const ContentEditableTags: React.FC = () => { return ( -

Content editable tags

+ {/*

Content editable tags

Content editable - entities

+ */} +

Lexical

+
); }; diff --git a/packages/react-components/react-components/stories/ContentEditableTags/Lexical2/AutocompletePlugin.tsx b/packages/react-components/react-components/stories/ContentEditableTags/Lexical2/AutocompletePlugin.tsx new file mode 100644 index 0000000000000..fca2280b596e0 --- /dev/null +++ b/packages/react-components/react-components/stories/ContentEditableTags/Lexical2/AutocompletePlugin.tsx @@ -0,0 +1,193 @@ +import { makeStyles, mergeClasses, shorthands, useAnnounce } from '@fluentui/react-components'; +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; +import { people } from '../data'; +import { + $getSelection, + SELECTION_CHANGE_COMMAND, + KEY_ENTER_COMMAND, + COMMAND_PRIORITY_EDITOR, + $isRangeSelection, + COMMAND_PRIORITY_CRITICAL, + INSERT_PARAGRAPH_COMMAND, + KEY_ARROW_UP_COMMAND, + KEY_ARROW_DOWN_COMMAND, + $isNodeSelection, +} from 'lexical'; +import * as React from 'react'; +import { $createNamePillNode } from './NamePillNode'; + +const useStyles = makeStyles({ + root: { + // Stack the label above the field with a gap + display: 'grid', + gridTemplateRows: 'repeat(1fr)', + justifyItems: 'start', + ...shorthands.gap('2px'), + maxWidth: '400px', + }, + hidden: { + display: 'none', + }, + item: { + cursor: 'pointer', + }, + selected: { + fontWeight: 'bold', + }, +}); + +const options = people; + +const useFilteredList = (filter: string) => { + return options.filter(option => option.toLowerCase().includes(filter.toLowerCase())); +}; + +export type AutocompletePluginProps = { + query: string; +}; + +export const AutocompletePlugin = () => { + const [editor] = useLexicalComposerContext(); + const [isOpen, setIsOpen] = React.useState(true); + const [query, setQuery] = React.useState(''); + const [selectedIndex, setSelectedIndex] = React.useState(0); + const { announce } = useAnnounce(); + + const styles = useStyles(); + const filtered = useFilteredList(query); + + React.useEffect(() => { + return editor.registerCommand( + SELECTION_CHANGE_COMMAND, + () => { + const sel = $getSelection(); + if ($isRangeSelection(sel) && sel.getNodes().length === 1) { + const node = sel.getNodes()[0]; + setQuery(node.getTextContent()); + } + }, + COMMAND_PRIORITY_EDITOR, + ); + }, [editor]); + + const appendSelectedItem = React.useCallback(() => { + const sel = $getSelection(); + + // handle deletion of single selected node + if ($isNodeSelection(sel) && sel.getNodes().length === 1) { + const selectedNode = sel.getNodes()[0]; + selectedNode.remove(); + return true; + } + + // handle adding of new node from selection + if ($isRangeSelection(sel) && sel.getNodes().length === 1) { + const newValue = filtered[selectedIndex]; + if (newValue) { + const node = sel.getNodes()[0]; + const newNode = $createNamePillNode(newValue); + node.replace(newNode); + newNode.selectEnd(); + return true; + } + } + return false; + }, [editor, filtered, selectedIndex]); + + React.useEffect(() => { + return editor.registerCommand(KEY_ENTER_COMMAND, appendSelectedItem, COMMAND_PRIORITY_CRITICAL); + }); + + React.useEffect(() => { + return editor.registerCommand( + KEY_ARROW_UP_COMMAND, + payload => { + const event = payload; + if (isOpen) { + setSelectedIndex(currentIndex => { + return Math.max(0, currentIndex - 1); + }); + event.preventDefault(); + event.stopImmediatePropagation(); + return true; + } + return false; + }, + COMMAND_PRIORITY_CRITICAL, + ); + }, [editor, isOpen]); + + React.useEffect(() => { + return editor.registerCommand( + KEY_ARROW_DOWN_COMMAND, + payload => { + const event = payload; + + if (isOpen) { + setSelectedIndex(currentIndex => { + return Math.min(currentIndex + 1, filtered.length - 1); + }); + event.preventDefault(); + event.stopImmediatePropagation(); + return true; + } + return false; + }, + COMMAND_PRIORITY_CRITICAL, + ); + }, [editor, isOpen, filtered]); + + React.useEffect(() => { + return editor.registerCommand( + INSERT_PARAGRAPH_COMMAND, + () => { + return true; + }, + COMMAND_PRIORITY_CRITICAL, + ); + }, [editor]); + + React.useEffect(() => { + if (query.length) { + setIsOpen(true); + setSelectedIndex(0); + } else { + setIsOpen(false); + setSelectedIndex(0); + } + }, [query]); + + React.useEffect(() => { + if (!isOpen || !query) return; + if (filtered.length) { + announce(`Found ${filtered.length} options`, { batchId: 'found-options' }); + } else { + announce(`No options found`, { batchId: 'found-options' }); + } + }, [filtered.length, isOpen]); + + React.useEffect(() => { + if (!open || !query) return; + const option = filtered[selectedIndex]; + if (option) { + announce(option, { batchId: 'selected-option' }); + } + }, [selectedIndex]); + + return ( +
+ {filtered.map((option, index) => ( +
{ + setSelectedIndex(index); + }} + onClick={() => editor.update(appendSelectedItem)} + > + {option} +
+ ))} +
+ ); +}; diff --git a/packages/react-components/react-components/stories/ContentEditableTags/Lexical2/HorizontalRuleNode.tsx b/packages/react-components/react-components/stories/ContentEditableTags/Lexical2/HorizontalRuleNode.tsx new file mode 100644 index 0000000000000..64e59218fc5c7 --- /dev/null +++ b/packages/react-components/react-components/stories/ContentEditableTags/Lexical2/HorizontalRuleNode.tsx @@ -0,0 +1,171 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import type { + DOMConversionMap, + DOMConversionOutput, + DOMExportOutput, + EditorConfig, + LexicalCommand, + LexicalNode, + NodeKey, + SerializedLexicalNode, +} from 'lexical'; + +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; +import { useLexicalNodeSelection } from '@lexical/react/useLexicalNodeSelection'; +import { addClassNamesToElement, mergeRegister, removeClassNamesFromElement } from '@lexical/utils'; +import { + $applyNodeReplacement, + $getNodeByKey, + $getSelection, + $isNodeSelection, + CLICK_COMMAND, + COMMAND_PRIORITY_LOW, + createCommand, + DecoratorNode, + KEY_BACKSPACE_COMMAND, + KEY_DELETE_COMMAND, +} from 'lexical'; +import * as React from 'react'; +import { useCallback, useEffect } from 'react'; + +export type SerializedHorizontalRuleNode = SerializedLexicalNode; + +export const INSERT_HORIZONTAL_RULE_COMMAND: LexicalCommand = createCommand('INSERT_HORIZONTAL_RULE_COMMAND'); + +function HorizontalRuleComponent({ nodeKey }: { nodeKey: NodeKey }) { + const [editor] = useLexicalComposerContext(); + const [isSelected, setSelected, clearSelection] = useLexicalNodeSelection(nodeKey); + + const $onDelete = useCallback( + (event: KeyboardEvent) => { + if (isSelected && $isNodeSelection($getSelection())) { + event.preventDefault(); + const node = $getNodeByKey(nodeKey); + if ($isHorizontalRuleNode(node)) { + node.remove(); + return true; + } + } + return false; + }, + [isSelected, nodeKey], + ); + + useEffect(() => { + return mergeRegister( + editor.registerCommand( + CLICK_COMMAND, + (event: MouseEvent) => { + const hrElem = editor.getElementByKey(nodeKey); + + if (event.target === hrElem) { + if (!event.shiftKey) { + clearSelection(); + } + setSelected(!isSelected); + return true; + } + + return false; + }, + COMMAND_PRIORITY_LOW, + ), + editor.registerCommand(KEY_DELETE_COMMAND, $onDelete, COMMAND_PRIORITY_LOW), + editor.registerCommand(KEY_BACKSPACE_COMMAND, $onDelete, COMMAND_PRIORITY_LOW), + ); + }, [clearSelection, editor, isSelected, nodeKey, $onDelete, setSelected]); + + useEffect(() => { + const hrElem = editor.getElementByKey(nodeKey); + const isSelectedClassName = 'selected'; + + if (hrElem !== null) { + if (isSelected) { + addClassNamesToElement(hrElem, isSelectedClassName); + } else { + removeClassNamesFromElement(hrElem, isSelectedClassName); + } + } + }, [editor, isSelected, nodeKey]); + + return null; +} + +export class HorizontalRuleNode extends DecoratorNode { + static getType(): string { + return 'horizontalrule'; + } + + static clone(node: HorizontalRuleNode): HorizontalRuleNode { + return new HorizontalRuleNode(node.__key); + } + + static importJSON(serializedNode: SerializedHorizontalRuleNode): HorizontalRuleNode { + return $createHorizontalRuleNode(); + } + + static importDOM(): DOMConversionMap | null { + return { + hr: () => ({ + conversion: $convertHorizontalRuleElement, + priority: 0, + }), + }; + } + + exportJSON(): SerializedLexicalNode { + return { + type: 'horizontalrule', + version: 1, + }; + } + + exportDOM(): DOMExportOutput { + return { element: document.createElement('hr') }; + } + + createDOM(config: EditorConfig): HTMLElement { + const element = document.createElement('hr'); + addClassNamesToElement(element, config.theme.hr); + return element; + } + + getTextContent(): string { + return '\n'; + } + + isInline(): false { + return false; + } + + isIsolated(): false { + return false; + } + + updateDOM(): boolean { + return false; + } + + decorate(): JSX.Element { + return ; + } +} + +function $convertHorizontalRuleElement(): DOMConversionOutput { + return { node: $createHorizontalRuleNode() }; +} + +export function $createHorizontalRuleNode(): HorizontalRuleNode { + return $applyNodeReplacement(new HorizontalRuleNode()); +} + +export function $isHorizontalRuleNode(node: LexicalNode | null | undefined): node is HorizontalRuleNode { + return node instanceof HorizontalRuleNode; +} diff --git a/packages/react-components/react-components/stories/ContentEditableTags/Lexical2/NamePillNode.tsx b/packages/react-components/react-components/stories/ContentEditableTags/Lexical2/NamePillNode.tsx new file mode 100644 index 0000000000000..1fcee9330977d --- /dev/null +++ b/packages/react-components/react-components/stories/ContentEditableTags/Lexical2/NamePillNode.tsx @@ -0,0 +1,67 @@ +import * as React from 'react'; +import { DecoratorNode, NodeKey, LexicalNode } from 'lexical'; +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; +import { useLexicalNodeSelection } from '@lexical/react/useLexicalNodeSelection'; + +const NamePillComponent = ({ node, id }) => { + // const [editor] = useLexicalComposerContext(); + const [isSelected, setSelected, clearSelection] = useLexicalNodeSelection(node.__key); + + React.useEffect(() => { + console.log('selected', isSelected); + }, [isSelected]); + + return ( + + {id} + + ); +}; + +export class NamePillNode extends DecoratorNode { + __id: string; + + public static getType(): string { + return 'name-pill'; + } + + static clone(node: NamePillNode): NamePillNode { + return new NamePillNode(node.__id, node.__key); + } + + constructor(id: string, key?: NodeKey) { + super(key); + this.__id = id; + } + + createDOM(): HTMLElement { + const el = document.createElement('span'); + el.ariaLabel = this.__id; + return el; + } + + updateDOM(): false { + return true; + } + + decorate(): React.ReactNode { + return ; + } +} + +export function $createNamePillNode(id: string): NamePillNode { + return new NamePillNode(id); +} + +export function $isNamePillNode(node: LexicalNode | null | undefined): node is NamePillNode { + return node instanceof NamePillNode; +} diff --git a/packages/react-components/react-components/stories/ContentEditableTags/Lexical2/TagPicker.tsx b/packages/react-components/react-components/stories/ContentEditableTags/Lexical2/TagPicker.tsx new file mode 100644 index 0000000000000..7dfdc6314f118 --- /dev/null +++ b/packages/react-components/react-components/stories/ContentEditableTags/Lexical2/TagPicker.tsx @@ -0,0 +1,66 @@ +import * as React from 'react'; +import { + TagPicker, + TagPickerList, + TagPickerInput, + TagPickerControl, + TagPickerProps, + TagPickerOption, + TagPickerGroup, +} from '@fluentui/react-components'; +import { Tag, Avatar, Field } from '@fluentui/react-components'; + +const options = [ + 'John Doe', + 'Jane Doe', + 'Max Mustermann', + 'Erika Mustermann', + 'Pierre Dupont', + 'Amelie Dupont', + 'Mario Rossi', + 'Maria Rossi', +]; + +export const TagPickerDemo = () => { + const [selectedOptions, setSelectedOptions] = React.useState([]); + const onOptionSelect: TagPickerProps['onOptionSelect'] = (e, data) => { + setSelectedOptions(data.selectedOptions); + }; + const tagPickerOptions = options.filter(option => !selectedOptions.includes(option)); + + return ( + + + + + {selectedOptions.map(option => ( + } + value={option} + > + {option} + + ))} + + + + + {tagPickerOptions.length > 0 + ? tagPickerOptions.map(option => ( + } + value={option} + key={option} + > + {option} + + )) + : 'No options available'} + + + + ); +}; diff --git a/packages/react-components/react-components/stories/ContentEditableTags/Lexical2/index.tsx b/packages/react-components/react-components/stories/ContentEditableTags/Lexical2/index.tsx new file mode 100644 index 0000000000000..fda65340f8d8d --- /dev/null +++ b/packages/react-components/react-components/stories/ContentEditableTags/Lexical2/index.tsx @@ -0,0 +1,102 @@ +import { + $createParagraphNode, + $createTextNode, + $getRoot, + $getSelection, + $isNodeSelection, + COMMAND_PRIORITY_EDITOR, + SELECTION_CHANGE_COMMAND, +} from 'lexical'; +import { useEffect } from 'react'; + +import { AutoFocusPlugin } from '@lexical/react/LexicalAutoFocusPlugin'; +import { LexicalComposer } from '@lexical/react/LexicalComposer'; +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; +import { ContentEditable } from '@lexical/react/LexicalContentEditable'; +import LexicalErrorBoundary from '@lexical/react/LexicalErrorBoundary'; +import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin'; +import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin'; +import * as React from 'react'; +import { $createHorizontalRuleNode, HorizontalRuleNode } from '../Lexical/HorizontalRuleNode'; +import { $createNamePillNode, NamePillNode } from './NamePillNode'; +import { AutocompletePlugin } from './AutocompletePlugin'; +import { AriaLiveAnnouncer } from '@fluentui/react-aria'; + +const theme = {}; + +const DefaultValuePlugin = () => { + const [editor] = useLexicalComposerContext(); + useEffect(() => { + editor.update(() => { + const paragraph = $createParagraphNode(); + paragraph.append( + $createTextNode('Hello '), + $createHorizontalRuleNode(), + $createTextNode('OK, '), + $createNamePillNode('Bill'), + $createNamePillNode('Sam'), + $createTextNode(', how are you?'), + ); + $getRoot().append(paragraph); + }); + }, []); + return null; +}; + +const AccessibilityAnnouncePlugin = () => { + const [editor] = useLexicalComposerContext(); + const [lastAnnouncement, setLastAnnouncement] = React.useState(''); + + useEffect(() => { + return editor.registerCommand( + SELECTION_CHANGE_COMMAND, + () => { + const sel = $getSelection(); + if ($isNodeSelection(sel)) { + const node = sel.getNodes()[0]; + const htmlElement = editor.getElementByKey(node.__key); + setLastAnnouncement(htmlElement?.ariaLabel || ''); + } else { + setLastAnnouncement(''); + } + return false; + }, + COMMAND_PRIORITY_EDITOR, + ); + }, [editor]); + + return
{lastAnnouncement}
; +}; + +// Catch any errors that occur during Lexical updates and log them +// or throw them as needed. If you don't throw them, Lexical will +// try to recover gracefully without losing user data. +function onError(error) { + console.error(error); +} + +export function LexicalEditor2() { + const initialConfig = { + namespace: 'MyEditor', + theme, + onError, + nodes: [HorizontalRuleNode, NamePillNode], + }; + + return ( + + + } + placeholder={
} + ErrorBoundary={LexicalErrorBoundary} + /> + + + + + +
+
+ ); +} diff --git a/yarn.lock b/yarn.lock index 6dd111929b887..75c60b09c7014 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2410,6 +2410,207 @@ resolved "https://registry.yarnpkg.com/@leichtgewicht/ip-codec/-/ip-codec-2.0.4.tgz#b2ac626d6cb9c8718ab459166d4bb405b8ffa78b" integrity sha512-Hcv+nVC0kZnQ3tD9GVu5xSMR4VVYOteQIr/hwFPVEvPdlXqgGEuRjiheChHgdM+JyqdgNcmzZOX/tnl0JOiI7A== +"@lexical/clipboard@0.15.0": + version "0.15.0" + resolved "https://registry.yarnpkg.com/@lexical/clipboard/-/clipboard-0.15.0.tgz#1a8bf8dde44f65658182b5573bfe3cf57bcad585" + integrity sha512-binCltK7KiURQJFogvueYfmDNEKynN/lmZrCLFp2xBjEIajqw4WtOVLJZ33engdqNlvj0JqrxrWxbKG+yvUwrg== + dependencies: + "@lexical/html" "0.15.0" + "@lexical/list" "0.15.0" + "@lexical/selection" "0.15.0" + "@lexical/utils" "0.15.0" + lexical "0.15.0" + +"@lexical/code@0.15.0": + version "0.15.0" + resolved "https://registry.yarnpkg.com/@lexical/code/-/code-0.15.0.tgz#aee697f652528455c3782aa1d029f068e4314e58" + integrity sha512-n185gjinGhz/M4BW1ayNPYAEgwW4T/NEFl2Wey/O+07W3zvh9k9ai7RjWd0c8Qzqc4DLlqvibvWPebWObQHA4w== + dependencies: + "@lexical/utils" "0.15.0" + lexical "0.15.0" + prismjs "^1.27.0" + +"@lexical/devtools-core@0.15.0": + version "0.15.0" + resolved "https://registry.yarnpkg.com/@lexical/devtools-core/-/devtools-core-0.15.0.tgz#99512a860e07c88f3d9da66458da6878fe890609" + integrity sha512-kK/IVEiQyqs2DsY4QRYFaFiKQMpaAukAl8PXmNeGTZ7cfFVsP29E4n0/pjY+oxmiRvxbO1s2i14q58nfuhj4VQ== + dependencies: + "@lexical/html" "0.15.0" + "@lexical/link" "0.15.0" + "@lexical/mark" "0.15.0" + "@lexical/table" "0.15.0" + "@lexical/utils" "0.15.0" + lexical "0.15.0" + +"@lexical/dragon@0.15.0": + version "0.15.0" + resolved "https://registry.yarnpkg.com/@lexical/dragon/-/dragon-0.15.0.tgz#d6c4f56c4cec4583460cfc2f32af8425ac21b0d5" + integrity sha512-hg2rGmxVJF7wmN6psuKw3EyhcNF7DtOYwUCBpjFZVshzAjsNEBfEnqhiMkSVSlN4+WOfM7LS+B88PTKPcnFGbQ== + dependencies: + lexical "0.15.0" + +"@lexical/hashtag@0.15.0": + version "0.15.0" + resolved "https://registry.yarnpkg.com/@lexical/hashtag/-/hashtag-0.15.0.tgz#6edc0d6c0fe124baabc8eecbdc768bd6a91fe491" + integrity sha512-EP6KKvS6BY/8Vh1MLQYeOcYaxnvrLsUkvXXr+Fg8N477Us54Ju69pPO563mbWt7/bpnL9Sh0fbk82JtxqPWpSg== + dependencies: + "@lexical/utils" "0.15.0" + lexical "0.15.0" + +"@lexical/history@0.15.0": + version "0.15.0" + resolved "https://registry.yarnpkg.com/@lexical/history/-/history-0.15.0.tgz#7d32d09c3ccf153ac212dc653ccf04deb220b5e6" + integrity sha512-r+pzR2k/51AL6l8UfXeVe/GWPIeWY1kEOuKx9nsYB9tmAkTF66tTFz33DJIMWBVtAHWN7Dcdv0/yy6q8R6CAUQ== + dependencies: + "@lexical/utils" "0.15.0" + lexical "0.15.0" + +"@lexical/html@0.15.0": + version "0.15.0" + resolved "https://registry.yarnpkg.com/@lexical/html/-/html-0.15.0.tgz#f616b831efafe77c597ca7ddc5bac4ec69de69c9" + integrity sha512-x/sfGvibwo8b5Vso4ppqNyS/fVve6Rn+TmvP/0eWOaa0I3aOQ57ulfcK6p/GTe+ZaEi8vW64oZPdi8XDgwSRaA== + dependencies: + "@lexical/selection" "0.15.0" + "@lexical/utils" "0.15.0" + lexical "0.15.0" + +"@lexical/link@0.15.0": + version "0.15.0" + resolved "https://registry.yarnpkg.com/@lexical/link/-/link-0.15.0.tgz#601a49cdd91d87fba07828ad2c492e2c4d52b579" + integrity sha512-KBV/zWk5FxqZGNcq3IKGBDCcS4t0uteU1osAIG+pefo4waTkOOgibxxEJDop2QR5wtjkYva3Qp0D8ZyJDMMMlw== + dependencies: + "@lexical/utils" "0.15.0" + lexical "0.15.0" + +"@lexical/list@0.15.0": + version "0.15.0" + resolved "https://registry.yarnpkg.com/@lexical/list/-/list-0.15.0.tgz#f1b073f2ca04816b12dda4d6ceef97bb5e286a8c" + integrity sha512-JuF4k7uo4rZFOSZGrmkxo1+sUrwTKNBhhJAiCgtM+6TO90jppxzCFNKur81yPzF1+g4GWLC9gbjzKb52QPb6cQ== + dependencies: + "@lexical/utils" "0.15.0" + lexical "0.15.0" + +"@lexical/mark@0.15.0": + version "0.15.0" + resolved "https://registry.yarnpkg.com/@lexical/mark/-/mark-0.15.0.tgz#8eb61379bdf599daea07af50ea2c65163d47fc20" + integrity sha512-cdePA98sOJRc4/HHqcOcPBFq4UDwzaFJOK1N1E6XUGcXH1GU8zHtV1ElTgmbsGkyjBRwhR+OqKm9eso1PBOUkg== + dependencies: + "@lexical/utils" "0.15.0" + lexical "0.15.0" + +"@lexical/markdown@0.15.0": + version "0.15.0" + resolved "https://registry.yarnpkg.com/@lexical/markdown/-/markdown-0.15.0.tgz#1e41dcbd03d18092ea57c8dfe6b1219acdadb87d" + integrity sha512-wu1EP758l452BovDa7i9ZAeWuFj+YY0bc2mNc08nfZ9GqdGMej1JIguY4CwIROCYVizprL9Ocn0avH1uv9b8fA== + dependencies: + "@lexical/code" "0.15.0" + "@lexical/link" "0.15.0" + "@lexical/list" "0.15.0" + "@lexical/rich-text" "0.15.0" + "@lexical/text" "0.15.0" + "@lexical/utils" "0.15.0" + lexical "0.15.0" + +"@lexical/offset@0.15.0": + version "0.15.0" + resolved "https://registry.yarnpkg.com/@lexical/offset/-/offset-0.15.0.tgz#f21d78cb70ab2470bb9a0fa9194a403504a4362e" + integrity sha512-VO1f3m8+RRdRjuXMtCBhi1COVKRC2LhP8AFYxnFlvbV+Waz9R5xB9pqFFUe4RtyqyTLmOUj6+LtsUFhq+23voQ== + dependencies: + lexical "0.15.0" + +"@lexical/overflow@0.15.0": + version "0.15.0" + resolved "https://registry.yarnpkg.com/@lexical/overflow/-/overflow-0.15.0.tgz#f3ad45dd7eec2d94d07251bc0a879f8942ae4dea" + integrity sha512-9qKVCvh9Oka+bzR3th+UWdTEeMZXYy1ZxWbjSxefRMgQxzCvqSuVioK/065gPbvGga9EfvgLLLBDXZm8ISbJQA== + dependencies: + lexical "0.15.0" + +"@lexical/plain-text@0.15.0": + version "0.15.0" + resolved "https://registry.yarnpkg.com/@lexical/plain-text/-/plain-text-0.15.0.tgz#3d5b1d929e7d65698c6886224794664f52fe3952" + integrity sha512-yeK466mXb4xaCCJouGzEHQs59fScHxF8Asq0azNyJmkhQWYrU7WdckHf2xj8ItZFFPyj7lvwKRDYnoy4HQD7Mg== + dependencies: + "@lexical/clipboard" "0.15.0" + "@lexical/selection" "0.15.0" + "@lexical/utils" "0.15.0" + lexical "0.15.0" + +"@lexical/react@^0.15.0": + version "0.15.0" + resolved "https://registry.yarnpkg.com/@lexical/react/-/react-0.15.0.tgz#b1f469239e77b4ac2e917cab3d5d13bb6f433efa" + integrity sha512-TWDp/F9cKwjGreLzIdHKlPUeTn275rR6j1VXrBffNwC5ovxWcKLVRg502eY5xvRQH3lkKQpFgIFbJW4KTvhFsQ== + dependencies: + "@lexical/clipboard" "0.15.0" + "@lexical/code" "0.15.0" + "@lexical/devtools-core" "0.15.0" + "@lexical/dragon" "0.15.0" + "@lexical/hashtag" "0.15.0" + "@lexical/history" "0.15.0" + "@lexical/link" "0.15.0" + "@lexical/list" "0.15.0" + "@lexical/mark" "0.15.0" + "@lexical/markdown" "0.15.0" + "@lexical/overflow" "0.15.0" + "@lexical/plain-text" "0.15.0" + "@lexical/rich-text" "0.15.0" + "@lexical/selection" "0.15.0" + "@lexical/table" "0.15.0" + "@lexical/text" "0.15.0" + "@lexical/utils" "0.15.0" + "@lexical/yjs" "0.15.0" + lexical "0.15.0" + react-error-boundary "^3.1.4" + +"@lexical/rich-text@0.15.0": + version "0.15.0" + resolved "https://registry.yarnpkg.com/@lexical/rich-text/-/rich-text-0.15.0.tgz#b587f9ffe853800227d1c22841ae6ac3a71bb675" + integrity sha512-76tXh/eeEOHl91HpFEXCc/tUiLrsa9RcSyvCzRZahk5zqYvQPXma/AUfRzuSMf2kLwDEoauKAVqNFQcbPhqwpQ== + dependencies: + "@lexical/clipboard" "0.15.0" + "@lexical/selection" "0.15.0" + "@lexical/utils" "0.15.0" + lexical "0.15.0" + +"@lexical/selection@0.15.0": + version "0.15.0" + resolved "https://registry.yarnpkg.com/@lexical/selection/-/selection-0.15.0.tgz#e8675e701b8a66af35e20cbe945d7b99941341cd" + integrity sha512-S+AQC6eJiQYSa5zOPuecN85prCT0Bcb8miOdJaE17Zh+vgdUH5gk9I0tEBeG5T7tkSpq6lFiEqs2FZSfaHflbQ== + dependencies: + lexical "0.15.0" + +"@lexical/table@0.15.0": + version "0.15.0" + resolved "https://registry.yarnpkg.com/@lexical/table/-/table-0.15.0.tgz#7b44863a42fc0ff193c2d3da4065217e4e63df2b" + integrity sha512-3IRBg8IoIHetqKozRQbJQ2aPyG0ziXZ+lc8TOIAGs6METW/wxntaV+rTNrODanKAgvk2iJTIyfFkYjsqS9+VFg== + dependencies: + "@lexical/utils" "0.15.0" + lexical "0.15.0" + +"@lexical/text@0.15.0": + version "0.15.0" + resolved "https://registry.yarnpkg.com/@lexical/text/-/text-0.15.0.tgz#091f86150eeb6bef02bf6440a6c626c977344adf" + integrity sha512-WsAkAt9T1RH1iDrVuWeoRUeMCOAWar5oSFtnQ4m9vhT/zuf5b8efK87GiqCH00ZAn4DGzOuAfyXlMFqBVCQdkQ== + dependencies: + lexical "0.15.0" + +"@lexical/utils@0.15.0": + version "0.15.0" + resolved "https://registry.yarnpkg.com/@lexical/utils/-/utils-0.15.0.tgz#bc92b5dd1f3805dfe89479ff13508d8ad498d52e" + integrity sha512-/6954LDmTcVFgexhy5WOZDa4TxNQOEZNrf8z7TRAFiAQkihcME/GRoq1en5cbXoVNF8jv5AvNyyc7x0MByRJ6A== + dependencies: + "@lexical/list" "0.15.0" + "@lexical/selection" "0.15.0" + "@lexical/table" "0.15.0" + lexical "0.15.0" + +"@lexical/yjs@0.15.0": + version "0.15.0" + resolved "https://registry.yarnpkg.com/@lexical/yjs/-/yjs-0.15.0.tgz#b3a5b75e6df68980763c5a88f5093ee0fc35f3c7" + integrity sha512-Rf4AIu620Cq90li6GU58gkzlGRdntHP4ZeZrbJ3ToW7vEEnkW6Wl9/HhO647GG4OL5w46M0iWvx1b1b8xjYT1w== + dependencies: + "@lexical/offset" "0.15.0" + lexical "0.15.0" + "@linaria/babel-preset@^3.0.0-beta.22", "@linaria/babel-preset@^3.0.0-beta.23": version "3.0.0-beta.23" resolved "https://registry.yarnpkg.com/@linaria/babel-preset/-/babel-preset-3.0.0-beta.23.tgz#1e8c32c85495fdd9db4d3f1ccd4d29fe17cb1d16" @@ -16279,6 +16480,11 @@ levn@~0.3.0: prelude-ls "~1.1.2" type-check "~0.3.2" +lexical@0.15.0, lexical@^0.15.0: + version "0.15.0" + resolved "https://registry.yarnpkg.com/lexical/-/lexical-0.15.0.tgz#1c471d7e4ad7140830bbb4cd244c4e65cfce3794" + integrity sha512-/7HrPAmtgsc1F+qpv5bFwoQZ6CbH/w3mPPL2AW5P75/QYrqKz4bhvJrc2jozIX0GxtuT/YUYT7w+1sZMtUWbOg== + li@^1.3.0: version "1.3.0" resolved "https://registry.yarnpkg.com/li/-/li-1.3.0.tgz#22c59bcaefaa9a8ef359cf759784e4bf106aea1b" @@ -19358,6 +19564,11 @@ prismjs@^1.16.0, prismjs@^1.8.4, prismjs@~1.16.0: optionalDependencies: clipboard "^2.0.0" +prismjs@^1.27.0: + version "1.29.0" + resolved "https://registry.yarnpkg.com/prismjs/-/prismjs-1.29.0.tgz#f113555a8fa9b57c35e637bba27509dcf802dd12" + integrity sha512-Kx/1w86q/epKcmte75LNrEoT+lX8pBpavuAbvJWRXar7Hz8jrtF+e3vY751p0R8H9HdArwaCTNDDzHg/ScJK1Q== + proc-log@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/proc-log/-/proc-log-3.0.0.tgz#fb05ef83ccd64fd7b20bbe9c8c1070fc08338dd8" @@ -19812,6 +20023,13 @@ react-error-boundary@^3.1.0: dependencies: "@babel/runtime" "^7.12.5" +react-error-boundary@^3.1.4: + version "3.1.4" + resolved "https://registry.yarnpkg.com/react-error-boundary/-/react-error-boundary-3.1.4.tgz#255db92b23197108757a888b01e5b729919abde0" + integrity sha512-uM9uPzZJTF6wRQORmSrvOIgt4lJ9MC1sNgEOj2XGsDTRE4kmpWxg7ENK9EWNKJRMAOY9z0MuF4yIfl6gp4sotA== + dependencies: + "@babel/runtime" "^7.12.5" + react-fast-compare@^2.0.1, react-fast-compare@^2.0.4: version "2.0.4" resolved "https://registry.yarnpkg.com/react-fast-compare/-/react-fast-compare-2.0.4.tgz#e84b4d455b0fec113e0402c329352715196f81f9" From 6cfc97e3f5a50f32d278c1b78fd72cc1d643b60a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20Vyhn=C3=A1lek?= Date: Thu, 23 May 2024 12:15:40 +0200 Subject: [PATCH 15/30] fix --- .../stories/ContentEditableTags/Lexical2/index.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/react-components/react-components/stories/ContentEditableTags/Lexical2/index.tsx b/packages/react-components/react-components/stories/ContentEditableTags/Lexical2/index.tsx index fda65340f8d8d..8560a515beea6 100644 --- a/packages/react-components/react-components/stories/ContentEditableTags/Lexical2/index.tsx +++ b/packages/react-components/react-components/stories/ContentEditableTags/Lexical2/index.tsx @@ -17,7 +17,6 @@ import LexicalErrorBoundary from '@lexical/react/LexicalErrorBoundary'; import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin'; import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin'; import * as React from 'react'; -import { $createHorizontalRuleNode, HorizontalRuleNode } from '../Lexical/HorizontalRuleNode'; import { $createNamePillNode, NamePillNode } from './NamePillNode'; import { AutocompletePlugin } from './AutocompletePlugin'; import { AriaLiveAnnouncer } from '@fluentui/react-aria'; @@ -80,7 +79,7 @@ export function LexicalEditor2() { namespace: 'MyEditor', theme, onError, - nodes: [HorizontalRuleNode, NamePillNode], + nodes: [NamePillNode], }; return ( From 0cde4c1745d513aaa6d6698cfc92b01b9f833a02 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20Vyhn=C3=A1lek?= Date: Fri, 24 May 2024 11:22:11 +0200 Subject: [PATCH 16/30] split to 2 functions --- .../Lexical2/AutocompletePlugin.tsx | 178 +++++++++++------- 1 file changed, 109 insertions(+), 69 deletions(-) diff --git a/packages/react-components/react-components/stories/ContentEditableTags/Lexical2/AutocompletePlugin.tsx b/packages/react-components/react-components/stories/ContentEditableTags/Lexical2/AutocompletePlugin.tsx index fca2280b596e0..1963cc6cd25d9 100644 --- a/packages/react-components/react-components/stories/ContentEditableTags/Lexical2/AutocompletePlugin.tsx +++ b/packages/react-components/react-components/stories/ContentEditableTags/Lexical2/AutocompletePlugin.tsx @@ -46,29 +46,22 @@ export type AutocompletePluginProps = { query: string; }; -export const AutocompletePlugin = () => { - const [editor] = useLexicalComposerContext(); - const [isOpen, setIsOpen] = React.useState(true); - const [query, setQuery] = React.useState(''); - const [selectedIndex, setSelectedIndex] = React.useState(0); - const { announce } = useAnnounce(); - - const styles = useStyles(); - const filtered = useFilteredList(query); +type AutocompletePluginLexicalHooksProps = { + onQueryChange: (newQuery: string) => void; + newPillCandidate?: string; + onKeyUp: (event: KeyboardEvent) => boolean; + onKeyDown: (event: KeyboardEvent) => boolean; + children: (renderProps: { appendCandidate: () => void }) => React.ReactElement; +}; - React.useEffect(() => { - return editor.registerCommand( - SELECTION_CHANGE_COMMAND, - () => { - const sel = $getSelection(); - if ($isRangeSelection(sel) && sel.getNodes().length === 1) { - const node = sel.getNodes()[0]; - setQuery(node.getTextContent()); - } - }, - COMMAND_PRIORITY_EDITOR, - ); - }, [editor]); +const AutocompletePluginLexicalHooks: React.FC = ({ + onQueryChange, + newPillCandidate, + children, + onKeyUp, + onKeyDown, +}) => { + const [editor] = useLexicalComposerContext(); const appendSelectedItem = React.useCallback(() => { const sel = $getSelection(); @@ -82,17 +75,16 @@ export const AutocompletePlugin = () => { // handle adding of new node from selection if ($isRangeSelection(sel) && sel.getNodes().length === 1) { - const newValue = filtered[selectedIndex]; - if (newValue) { + if (newPillCandidate) { const node = sel.getNodes()[0]; - const newNode = $createNamePillNode(newValue); + const newNode = $createNamePillNode(newPillCandidate); node.replace(newNode); newNode.selectEnd(); return true; } } return false; - }, [editor, filtered, selectedIndex]); + }, [editor, newPillCandidate]); React.useEffect(() => { return editor.registerCommand(KEY_ENTER_COMMAND, appendSelectedItem, COMMAND_PRIORITY_CRITICAL); @@ -100,52 +92,59 @@ export const AutocompletePlugin = () => { React.useEffect(() => { return editor.registerCommand( - KEY_ARROW_UP_COMMAND, - payload => { - const event = payload; - if (isOpen) { - setSelectedIndex(currentIndex => { - return Math.max(0, currentIndex - 1); - }); - event.preventDefault(); - event.stopImmediatePropagation(); - return true; + SELECTION_CHANGE_COMMAND, + () => { + const sel = $getSelection(); + if ($isRangeSelection(sel) && sel.getNodes().length === 1) { + const node = sel.getNodes()[0]; + onQueryChange(node.getTextContent()); } - return false; + }, + COMMAND_PRIORITY_EDITOR, + ); + }, [editor]); + + React.useEffect(() => { + return editor.registerCommand( + INSERT_PARAGRAPH_COMMAND, + () => { + return true; }, COMMAND_PRIORITY_CRITICAL, ); - }, [editor, isOpen]); + }, [editor]); React.useEffect(() => { return editor.registerCommand( - KEY_ARROW_DOWN_COMMAND, + KEY_ARROW_UP_COMMAND, payload => { - const event = payload; - - if (isOpen) { - setSelectedIndex(currentIndex => { - return Math.min(currentIndex + 1, filtered.length - 1); - }); - event.preventDefault(); - event.stopImmediatePropagation(); - return true; - } - return false; + return onKeyUp(payload); }, COMMAND_PRIORITY_CRITICAL, ); - }, [editor, isOpen, filtered]); + }, [editor, onKeyUp]); React.useEffect(() => { return editor.registerCommand( - INSERT_PARAGRAPH_COMMAND, - () => { - return true; + KEY_ARROW_DOWN_COMMAND, + payload => { + return onKeyDown(payload); }, COMMAND_PRIORITY_CRITICAL, ); - }, [editor]); + }, [editor, onKeyDown]); + + return children({ appendCandidate: appendSelectedItem }); +}; + +export const AutocompletePlugin = () => { + const [isOpen, setIsOpen] = React.useState(true); + const [query, setQuery] = React.useState(''); + const [selectedIndex, setSelectedIndex] = React.useState(0); + const { announce } = useAnnounce(); + + const styles = useStyles(); + const filtered = useFilteredList(query); React.useEffect(() => { if (query.length) { @@ -174,20 +173,61 @@ export const AutocompletePlugin = () => { } }, [selectedIndex]); + const onKeyUp = React.useCallback( + event => { + if (isOpen) { + setSelectedIndex(currentIndex => { + return Math.max(0, currentIndex - 1); + }); + event.preventDefault(); + event.stopImmediatePropagation(); + return true; + } + return false; + }, + [isOpen, selectedIndex], + ); + + const onKeyDown = React.useCallback( + event => { + if (isOpen) { + setSelectedIndex(currentIndex => { + return Math.min(currentIndex + 1, filtered.length - 1); + }); + event.preventDefault(); + event.stopImmediatePropagation(); + return true; + } + return false; + }, + [isOpen, selectedIndex], + ); + return ( -
- {filtered.map((option, index) => ( -
{ - setSelectedIndex(index); - }} - onClick={() => editor.update(appendSelectedItem)} - > - {option} -
- ))} -
+ setQuery(newQuery)} + newPillCandidate={filtered[selectedIndex]} + onKeyUp={onKeyUp} + onKeyDown={onKeyDown} + > + {({ appendCandidate }) => { + return ( +
+ {filtered.map((option, index) => ( +
{ + setSelectedIndex(index); + }} + onClick={() => appendCandidate()} + > + {option} +
+ ))} +
+ ); + }} +
); }; From 210dd61384d02b1e6af02c2c13afc4c7936b09cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20Vyhn=C3=A1lek?= Date: Fri, 24 May 2024 11:39:37 +0200 Subject: [PATCH 17/30] split to 2 files --- .../Lexical2/AutocompletePlugin.tsx | 126 +----------------- .../Lexical2/AutocompletePluginCore.tsx | 106 +++++++++++++++ 2 files changed, 112 insertions(+), 120 deletions(-) create mode 100644 packages/react-components/react-components/stories/ContentEditableTags/Lexical2/AutocompletePluginCore.tsx diff --git a/packages/react-components/react-components/stories/ContentEditableTags/Lexical2/AutocompletePlugin.tsx b/packages/react-components/react-components/stories/ContentEditableTags/Lexical2/AutocompletePlugin.tsx index 1963cc6cd25d9..a245aeb5b65ab 100644 --- a/packages/react-components/react-components/stories/ContentEditableTags/Lexical2/AutocompletePlugin.tsx +++ b/packages/react-components/react-components/stories/ContentEditableTags/Lexical2/AutocompletePlugin.tsx @@ -1,24 +1,10 @@ import { makeStyles, mergeClasses, shorthands, useAnnounce } from '@fluentui/react-components'; -import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; -import { people } from '../data'; -import { - $getSelection, - SELECTION_CHANGE_COMMAND, - KEY_ENTER_COMMAND, - COMMAND_PRIORITY_EDITOR, - $isRangeSelection, - COMMAND_PRIORITY_CRITICAL, - INSERT_PARAGRAPH_COMMAND, - KEY_ARROW_UP_COMMAND, - KEY_ARROW_DOWN_COMMAND, - $isNodeSelection, -} from 'lexical'; import * as React from 'react'; -import { $createNamePillNode } from './NamePillNode'; +import { people } from '../data'; +import { AutocompletePluginCore } from './AutocompletePluginCore'; const useStyles = makeStyles({ root: { - // Stack the label above the field with a gap display: 'grid', gridTemplateRows: 'repeat(1fr)', justifyItems: 'start', @@ -42,101 +28,6 @@ const useFilteredList = (filter: string) => { return options.filter(option => option.toLowerCase().includes(filter.toLowerCase())); }; -export type AutocompletePluginProps = { - query: string; -}; - -type AutocompletePluginLexicalHooksProps = { - onQueryChange: (newQuery: string) => void; - newPillCandidate?: string; - onKeyUp: (event: KeyboardEvent) => boolean; - onKeyDown: (event: KeyboardEvent) => boolean; - children: (renderProps: { appendCandidate: () => void }) => React.ReactElement; -}; - -const AutocompletePluginLexicalHooks: React.FC = ({ - onQueryChange, - newPillCandidate, - children, - onKeyUp, - onKeyDown, -}) => { - const [editor] = useLexicalComposerContext(); - - const appendSelectedItem = React.useCallback(() => { - const sel = $getSelection(); - - // handle deletion of single selected node - if ($isNodeSelection(sel) && sel.getNodes().length === 1) { - const selectedNode = sel.getNodes()[0]; - selectedNode.remove(); - return true; - } - - // handle adding of new node from selection - if ($isRangeSelection(sel) && sel.getNodes().length === 1) { - if (newPillCandidate) { - const node = sel.getNodes()[0]; - const newNode = $createNamePillNode(newPillCandidate); - node.replace(newNode); - newNode.selectEnd(); - return true; - } - } - return false; - }, [editor, newPillCandidate]); - - React.useEffect(() => { - return editor.registerCommand(KEY_ENTER_COMMAND, appendSelectedItem, COMMAND_PRIORITY_CRITICAL); - }); - - React.useEffect(() => { - return editor.registerCommand( - SELECTION_CHANGE_COMMAND, - () => { - const sel = $getSelection(); - if ($isRangeSelection(sel) && sel.getNodes().length === 1) { - const node = sel.getNodes()[0]; - onQueryChange(node.getTextContent()); - } - }, - COMMAND_PRIORITY_EDITOR, - ); - }, [editor]); - - React.useEffect(() => { - return editor.registerCommand( - INSERT_PARAGRAPH_COMMAND, - () => { - return true; - }, - COMMAND_PRIORITY_CRITICAL, - ); - }, [editor]); - - React.useEffect(() => { - return editor.registerCommand( - KEY_ARROW_UP_COMMAND, - payload => { - return onKeyUp(payload); - }, - COMMAND_PRIORITY_CRITICAL, - ); - }, [editor, onKeyUp]); - - React.useEffect(() => { - return editor.registerCommand( - KEY_ARROW_DOWN_COMMAND, - payload => { - return onKeyDown(payload); - }, - COMMAND_PRIORITY_CRITICAL, - ); - }, [editor, onKeyDown]); - - return children({ appendCandidate: appendSelectedItem }); -}; - export const AutocompletePlugin = () => { const [isOpen, setIsOpen] = React.useState(true); const [query, setQuery] = React.useState(''); @@ -147,13 +38,8 @@ export const AutocompletePlugin = () => { const filtered = useFilteredList(query); React.useEffect(() => { - if (query.length) { - setIsOpen(true); - setSelectedIndex(0); - } else { - setIsOpen(false); - setSelectedIndex(0); - } + setSelectedIndex(0); + setIsOpen(!!query.length); }, [query]); React.useEffect(() => { @@ -204,7 +90,7 @@ export const AutocompletePlugin = () => { ); return ( - setQuery(newQuery)} newPillCandidate={filtered[selectedIndex]} onKeyUp={onKeyUp} @@ -228,6 +114,6 @@ export const AutocompletePlugin = () => {
); }} - + ); }; diff --git a/packages/react-components/react-components/stories/ContentEditableTags/Lexical2/AutocompletePluginCore.tsx b/packages/react-components/react-components/stories/ContentEditableTags/Lexical2/AutocompletePluginCore.tsx new file mode 100644 index 0000000000000..325627a24351c --- /dev/null +++ b/packages/react-components/react-components/stories/ContentEditableTags/Lexical2/AutocompletePluginCore.tsx @@ -0,0 +1,106 @@ +import * as React from 'react'; +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; +import { + $getSelection, + $isNodeSelection, + $isRangeSelection, + COMMAND_PRIORITY_CRITICAL, + COMMAND_PRIORITY_EDITOR, + INSERT_PARAGRAPH_COMMAND, + KEY_ARROW_DOWN_COMMAND, + KEY_ARROW_UP_COMMAND, + KEY_ENTER_COMMAND, + SELECTION_CHANGE_COMMAND, +} from 'lexical'; +import { $createNamePillNode } from './NamePillNode'; + +type AutocompletePluginCoreProps = { + onQueryChange: (newQuery: string) => void; + newPillCandidate?: string; + onKeyUp: (event: KeyboardEvent) => boolean; + onKeyDown: (event: KeyboardEvent) => boolean; + children: (renderProps: { appendCandidate: () => void }) => React.ReactElement; +}; + +export const AutocompletePluginCore: React.FC = ({ + onQueryChange, + newPillCandidate, + children, + onKeyUp, + onKeyDown, +}) => { + const [editor] = useLexicalComposerContext(); + + const appendSelectedItem = React.useCallback(() => { + const sel = $getSelection(); + + // handle deletion of single selected node + if ($isNodeSelection(sel) && sel.getNodes().length === 1) { + const selectedNode = sel.getNodes()[0]; + selectedNode.remove(); + return true; + } + + // handle adding of new node from selection + if ($isRangeSelection(sel) && sel.getNodes().length === 1) { + if (newPillCandidate) { + const node = sel.getNodes()[0]; + const newNode = $createNamePillNode(newPillCandidate); + node.replace(newNode); + newNode.selectEnd(); + return true; + } + } + return false; + }, [editor, newPillCandidate]); + + React.useEffect(() => { + return editor.registerCommand(KEY_ENTER_COMMAND, appendSelectedItem, COMMAND_PRIORITY_CRITICAL); + }); + + React.useEffect(() => { + return editor.registerCommand( + SELECTION_CHANGE_COMMAND, + () => { + const sel = $getSelection(); + if ($isRangeSelection(sel) && sel.getNodes().length === 1) { + const node = sel.getNodes()[0]; + onQueryChange(node.getTextContent()); + } + }, + COMMAND_PRIORITY_EDITOR, + ); + }, [editor]); + + React.useEffect(() => { + return editor.registerCommand( + INSERT_PARAGRAPH_COMMAND, + () => { + return true; + }, + COMMAND_PRIORITY_CRITICAL, + ); + }, [editor]); + + React.useEffect(() => { + return editor.registerCommand( + KEY_ARROW_UP_COMMAND, + payload => { + return onKeyUp(payload); + }, + COMMAND_PRIORITY_CRITICAL, + ); + }, [editor, onKeyUp]); + + React.useEffect(() => { + return editor.registerCommand( + KEY_ARROW_DOWN_COMMAND, + payload => { + return onKeyDown(payload); + }, + COMMAND_PRIORITY_CRITICAL, + ); + }, [editor, onKeyDown]); + + return children({ appendCandidate: appendSelectedItem }); +}; From fb6ed8f7b35cff65d736c721df7866fa441c6820 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20Vyhn=C3=A1lek?= Date: Fri, 24 May 2024 14:14:30 +0200 Subject: [PATCH 18/30] improve a11y --- .../Lexical2/AutocompletePlugin.tsx | 51 +++++++++------ .../Lexical2/AutocompletePluginCore.tsx | 42 +++++++++---- .../ContentEditableTags/Lexical2/index.tsx | 62 +------------------ 3 files changed, 64 insertions(+), 91 deletions(-) diff --git a/packages/react-components/react-components/stories/ContentEditableTags/Lexical2/AutocompletePlugin.tsx b/packages/react-components/react-components/stories/ContentEditableTags/Lexical2/AutocompletePlugin.tsx index a245aeb5b65ab..b5712d2e8ae12 100644 --- a/packages/react-components/react-components/stories/ContentEditableTags/Lexical2/AutocompletePlugin.tsx +++ b/packages/react-components/react-components/stories/ContentEditableTags/Lexical2/AutocompletePlugin.tsx @@ -42,24 +42,18 @@ export const AutocompletePlugin = () => { setIsOpen(!!query.length); }, [query]); - React.useEffect(() => { - if (!isOpen || !query) return; - if (filtered.length) { - announce(`Found ${filtered.length} options`, { batchId: 'found-options' }); - } else { - announce(`No options found`, { batchId: 'found-options' }); - } - }, [filtered.length, isOpen]); + const selectedItem = React.useMemo(() => filtered[selectedIndex], [selectedIndex, filtered]); - React.useEffect(() => { - if (!open || !query) return; - const option = filtered[selectedIndex]; - if (option) { - announce(option, { batchId: 'selected-option' }); - } - }, [selectedIndex]); + // React.useEffect(() => { + // if (!isOpen || !query) return; + // if (filtered.length) { + // announce(`Found ${filtered.length} options`, { batchId: 'found-options' }); + // } else { + // announce(`No options found`, { batchId: 'found-options' }); + // } + // }, [filtered.length, isOpen]); - const onKeyUp = React.useCallback( + const onArrowKeyUp = React.useCallback( event => { if (isOpen) { setSelectedIndex(currentIndex => { @@ -74,7 +68,17 @@ export const AutocompletePlugin = () => { [isOpen, selectedIndex], ); - const onKeyDown = React.useCallback( + React.useEffect(() => { + if (!isOpen || !query) return; + if (selectedItem) { + announce(`${selectedItem} ${selectedIndex + 1} of ${filtered.length}`, { + batchId: 'selected-option', + polite: false, + }); + } + }, [selectedItem, isOpen, query, selectedIndex, filtered]); + + const onArrowKeyDown = React.useCallback( event => { if (isOpen) { setSelectedIndex(currentIndex => { @@ -93,8 +97,17 @@ export const AutocompletePlugin = () => { setQuery(newQuery)} newPillCandidate={filtered[selectedIndex]} - onKeyUp={onKeyUp} - onKeyDown={onKeyDown} + onArrowKeyUp={onArrowKeyUp} + onArrowKeyDown={onArrowKeyDown} + announce={announce} + onEscape={() => { + if (isOpen) { + setIsOpen(false); + announce('Autocomplete closed', { batchId: 'autocomplete-closed' }); + return true; + } + return false; + }} > {({ appendCandidate }) => { return ( diff --git a/packages/react-components/react-components/stories/ContentEditableTags/Lexical2/AutocompletePluginCore.tsx b/packages/react-components/react-components/stories/ContentEditableTags/Lexical2/AutocompletePluginCore.tsx index 325627a24351c..6d426363c8943 100644 --- a/packages/react-components/react-components/stories/ContentEditableTags/Lexical2/AutocompletePluginCore.tsx +++ b/packages/react-components/react-components/stories/ContentEditableTags/Lexical2/AutocompletePluginCore.tsx @@ -10,24 +10,29 @@ import { KEY_ARROW_DOWN_COMMAND, KEY_ARROW_UP_COMMAND, KEY_ENTER_COMMAND, + KEY_ESCAPE_COMMAND, SELECTION_CHANGE_COMMAND, } from 'lexical'; import { $createNamePillNode } from './NamePillNode'; type AutocompletePluginCoreProps = { onQueryChange: (newQuery: string) => void; - newPillCandidate?: string; - onKeyUp: (event: KeyboardEvent) => boolean; - onKeyDown: (event: KeyboardEvent) => boolean; children: (renderProps: { appendCandidate: () => void }) => React.ReactElement; + newPillCandidate?: string; + onArrowKeyUp?: (event: KeyboardEvent) => boolean; + onArrowKeyDown?: (event: KeyboardEvent) => boolean; + onEscape?: (event: KeyboardEvent) => boolean; + announce?: (message: string, options?: { batchId: string }) => void; }; export const AutocompletePluginCore: React.FC = ({ onQueryChange, newPillCandidate, children, - onKeyUp, - onKeyDown, + onArrowKeyUp, + onArrowKeyDown, + announce, + onEscape, }) => { const [editor] = useLexicalComposerContext(); @@ -48,6 +53,7 @@ export const AutocompletePluginCore: React.FC = ({ const newNode = $createNamePillNode(newPillCandidate); node.replace(newNode); newNode.selectEnd(); + announce?.(`Added ${newPillCandidate}`, { batchId: 'added-pill' }); return true; } } @@ -57,15 +63,27 @@ export const AutocompletePluginCore: React.FC = ({ React.useEffect(() => { return editor.registerCommand(KEY_ENTER_COMMAND, appendSelectedItem, COMMAND_PRIORITY_CRITICAL); }); + React.useEffect(() => { + return editor.registerCommand(KEY_ESCAPE_COMMAND, onEscape, COMMAND_PRIORITY_CRITICAL); + }); React.useEffect(() => { return editor.registerCommand( SELECTION_CHANGE_COMMAND, () => { const sel = $getSelection(); - if ($isRangeSelection(sel) && sel.getNodes().length === 1) { - const node = sel.getNodes()[0]; - onQueryChange(node.getTextContent()); + if (!sel) return; + + const nodes = sel.getNodes(); + if ($isRangeSelection(sel) && nodes.length === 1) { + onQueryChange(nodes[0].getTextContent()); + } + + if ($isNodeSelection(sel)) { + const htmlElement = editor.getElementByKey(nodes[0].__key); + htmlElement?.ariaLabel && announce?.(htmlElement?.ariaLabel); + // when node is selected, clear query so that the autocomplete is not shown + onQueryChange(''); } }, COMMAND_PRIORITY_EDITOR, @@ -86,21 +104,21 @@ export const AutocompletePluginCore: React.FC = ({ return editor.registerCommand( KEY_ARROW_UP_COMMAND, payload => { - return onKeyUp(payload); + return onArrowKeyUp?.(payload) ?? false; }, COMMAND_PRIORITY_CRITICAL, ); - }, [editor, onKeyUp]); + }, [editor, onArrowKeyUp]); React.useEffect(() => { return editor.registerCommand( KEY_ARROW_DOWN_COMMAND, payload => { - return onKeyDown(payload); + return onArrowKeyDown?.(payload) ?? false; }, COMMAND_PRIORITY_CRITICAL, ); - }, [editor, onKeyDown]); + }, [editor, onArrowKeyDown]); return children({ appendCandidate: appendSelectedItem }); }; diff --git a/packages/react-components/react-components/stories/ContentEditableTags/Lexical2/index.tsx b/packages/react-components/react-components/stories/ContentEditableTags/Lexical2/index.tsx index 8560a515beea6..ad811e65ddc71 100644 --- a/packages/react-components/react-components/stories/ContentEditableTags/Lexical2/index.tsx +++ b/packages/react-components/react-components/stories/ContentEditableTags/Lexical2/index.tsx @@ -1,72 +1,16 @@ -import { - $createParagraphNode, - $createTextNode, - $getRoot, - $getSelection, - $isNodeSelection, - COMMAND_PRIORITY_EDITOR, - SELECTION_CHANGE_COMMAND, -} from 'lexical'; -import { useEffect } from 'react'; - +import { AriaLiveAnnouncer } from '@fluentui/react-aria'; import { AutoFocusPlugin } from '@lexical/react/LexicalAutoFocusPlugin'; import { LexicalComposer } from '@lexical/react/LexicalComposer'; -import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; import { ContentEditable } from '@lexical/react/LexicalContentEditable'; import LexicalErrorBoundary from '@lexical/react/LexicalErrorBoundary'; import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin'; import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin'; import * as React from 'react'; -import { $createNamePillNode, NamePillNode } from './NamePillNode'; import { AutocompletePlugin } from './AutocompletePlugin'; -import { AriaLiveAnnouncer } from '@fluentui/react-aria'; +import { NamePillNode } from './NamePillNode'; const theme = {}; -const DefaultValuePlugin = () => { - const [editor] = useLexicalComposerContext(); - useEffect(() => { - editor.update(() => { - const paragraph = $createParagraphNode(); - paragraph.append( - $createTextNode('Hello '), - $createHorizontalRuleNode(), - $createTextNode('OK, '), - $createNamePillNode('Bill'), - $createNamePillNode('Sam'), - $createTextNode(', how are you?'), - ); - $getRoot().append(paragraph); - }); - }, []); - return null; -}; - -const AccessibilityAnnouncePlugin = () => { - const [editor] = useLexicalComposerContext(); - const [lastAnnouncement, setLastAnnouncement] = React.useState(''); - - useEffect(() => { - return editor.registerCommand( - SELECTION_CHANGE_COMMAND, - () => { - const sel = $getSelection(); - if ($isNodeSelection(sel)) { - const node = sel.getNodes()[0]; - const htmlElement = editor.getElementByKey(node.__key); - setLastAnnouncement(htmlElement?.ariaLabel || ''); - } else { - setLastAnnouncement(''); - } - return false; - }, - COMMAND_PRIORITY_EDITOR, - ); - }, [editor]); - - return
{lastAnnouncement}
; -}; - // Catch any errors that occur during Lexical updates and log them // or throw them as needed. If you don't throw them, Lexical will // try to recover gracefully without losing user data. @@ -92,8 +36,6 @@ export function LexicalEditor2() { /> - - From 39583da1ecaa8bb4c244aee86740d2639c415725 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20Vyhn=C3=A1lek?= Date: Mon, 27 May 2024 11:31:56 +0200 Subject: [PATCH 19/30] move to activedescendant for autocomplete --- .../Lexical2/AutocompletePlugin.tsx | 39 +++++++++---------- .../Lexical2/AutocompletePluginCore.tsx | 33 ++++++++++++---- .../ContentEditableTags/Lexical2/index.tsx | 8 +++- 3 files changed, 49 insertions(+), 31 deletions(-) diff --git a/packages/react-components/react-components/stories/ContentEditableTags/Lexical2/AutocompletePlugin.tsx b/packages/react-components/react-components/stories/ContentEditableTags/Lexical2/AutocompletePlugin.tsx index b5712d2e8ae12..9e72ddfb3408f 100644 --- a/packages/react-components/react-components/stories/ContentEditableTags/Lexical2/AutocompletePlugin.tsx +++ b/packages/react-components/react-components/stories/ContentEditableTags/Lexical2/AutocompletePlugin.tsx @@ -28,7 +28,7 @@ const useFilteredList = (filter: string) => { return options.filter(option => option.toLowerCase().includes(filter.toLowerCase())); }; -export const AutocompletePlugin = () => { +export const AutocompletePlugin = ({ id }: { id: string }) => { const [isOpen, setIsOpen] = React.useState(true); const [query, setQuery] = React.useState(''); const [selectedIndex, setSelectedIndex] = React.useState(0); @@ -44,14 +44,7 @@ export const AutocompletePlugin = () => { const selectedItem = React.useMemo(() => filtered[selectedIndex], [selectedIndex, filtered]); - // React.useEffect(() => { - // if (!isOpen || !query) return; - // if (filtered.length) { - // announce(`Found ${filtered.length} options`, { batchId: 'found-options' }); - // } else { - // announce(`No options found`, { batchId: 'found-options' }); - // } - // }, [filtered.length, isOpen]); + React.useEffect(() => {}, [selectedItem]); const onArrowKeyUp = React.useCallback( event => { @@ -68,15 +61,15 @@ export const AutocompletePlugin = () => { [isOpen, selectedIndex], ); - React.useEffect(() => { - if (!isOpen || !query) return; - if (selectedItem) { - announce(`${selectedItem} ${selectedIndex + 1} of ${filtered.length}`, { - batchId: 'selected-option', - polite: false, - }); - } - }, [selectedItem, isOpen, query, selectedIndex, filtered]); + // React.useEffect(() => { + // if (!isOpen || !query) return; + // if (selectedItem) { + // announce(`${selectedItem} ${selectedIndex + 1} of ${filtered.length}`, { + // batchId: 'selected-option', + // polite: false, + // }); + // } + // }, [selectedItem, isOpen, query, selectedIndex, filtered]); const onArrowKeyDown = React.useCallback( event => { @@ -95,8 +88,9 @@ export const AutocompletePlugin = () => { return ( setQuery(newQuery)} - newPillCandidate={filtered[selectedIndex]} + autocompleteItem={isOpen ? selectedItem : undefined} onArrowKeyUp={onArrowKeyUp} onArrowKeyDown={onArrowKeyDown} announce={announce} @@ -109,11 +103,14 @@ export const AutocompletePlugin = () => { return false; }} > - {({ appendCandidate }) => { + {({ appendCandidate, getItemId }) => { return ( -
+