diff --git a/change/@fluentui-react-components-7470075f-b93d-4527-8daa-b2bca22b7cd1.json b/change/@fluentui-react-components-7470075f-b93d-4527-8daa-b2bca22b7cd1.json new file mode 100644 index 0000000000000..4f92c81514494 --- /dev/null +++ b/change/@fluentui-react-components-7470075f-b93d-4527-8daa-b2bca22b7cd1.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "comment": "Picker prototype", + "packageName": "@fluentui/react-components", + "email": "asamec@microsoft.com", + "dependentChangeType": "patch" +} diff --git a/packages/react-components/react-components/package.json b/packages/react-components/react-components/package.json index 5f5c5e2e2e5ba..03cda64fb940a 100644 --- a/packages/react-components/react-components/package.json +++ b/packages/react-components/react-components/package.json @@ -86,7 +86,9 @@ "@fluentui/react-rating": "^9.0.10", "@fluentui/react-search": "^9.0.6", "@fluentui/react-teaching-popover": "^9.1.6", - "@fluentui/react-tag-picker": "^9.0.4" + "@fluentui/react-tag-picker": "^9.0.4", + "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/LexicalEditor/AutocompletePlugin.tsx b/packages/react-components/react-components/stories/ContentEditableTags/LexicalEditor/AutocompletePlugin.tsx new file mode 100644 index 0000000000000..ca5ae1f32fcc4 --- /dev/null +++ b/packages/react-components/react-components/stories/ContentEditableTags/LexicalEditor/AutocompletePlugin.tsx @@ -0,0 +1,126 @@ +import { makeStyles, mergeClasses, shorthands } from '@fluentui/react-components'; +import * as React from 'react'; +import { people } from '../data'; +import { AutocompletePluginCore } from './AutocompletePluginCore'; + +const useStyles = makeStyles({ + root: { + 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 React.useMemo( + () => (filter.length ? options.filter(option => option.toLowerCase().includes(filter.toLowerCase())) : []), + [filter], + ); +}; + +export const AutocompletePlugin = ({ id }: { id: string }) => { + const [isOpen, setIsOpen] = React.useState(true); + const [query, setQuery] = React.useState(''); + const [selectedIndex, setSelectedIndex] = React.useState(0); + + const styles = useStyles(); + const filtered = useFilteredList(query); + + React.useEffect(() => { + setSelectedIndex(0); + setIsOpen(!!filtered.length); + }, [filtered]); + + const selectedItem = React.useMemo(() => filtered[selectedIndex], [selectedIndex, filtered]); + + React.useEffect(() => {}, [selectedItem]); + + const onArrowKeyUp = React.useCallback( + event => { + if (isOpen) { + setSelectedIndex(currentIndex => { + return Math.max(0, currentIndex - 1); + }); + event.preventDefault(); + event.stopImmediatePropagation(); + return true; + } + return false; + }, + [isOpen, selectedIndex], + ); + + const onArrowKeyDown = 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 ( + setQuery(newQuery)} + autocompleteItem={isOpen ? selectedItem : undefined} + onArrowKeyUp={onArrowKeyUp} + onArrowKeyDown={onArrowKeyDown} + onEscape={() => { + if (isOpen) { + setIsOpen(false); + return true; + } + return false; + }} + > + {({ onClick, getItemId }) => { + return ( +
+ {filtered.map((option, index) => ( +
{ + setSelectedIndex(index); + }} + onClick={onClick} + > + {option} +
+ ))} +
+ ); + }} +
+ ); +}; diff --git a/packages/react-components/react-components/stories/ContentEditableTags/LexicalEditor/AutocompletePluginCore.tsx b/packages/react-components/react-components/stories/ContentEditableTags/LexicalEditor/AutocompletePluginCore.tsx new file mode 100644 index 0000000000000..51cb25835e2fe --- /dev/null +++ b/packages/react-components/react-components/stories/ContentEditableTags/LexicalEditor/AutocompletePluginCore.tsx @@ -0,0 +1,215 @@ +import * as React from 'react'; +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; +import { + $getSelection, + $isDecoratorNode, + $isNodeSelection, + $isRangeSelection, + COMMAND_PRIORITY_CRITICAL, + COMMAND_PRIORITY_EDITOR, + INSERT_PARAGRAPH_COMMAND, + KEY_ARROW_DOWN_COMMAND, + KEY_ARROW_LEFT_COMMAND, + KEY_ARROW_RIGHT_COMMAND, + KEY_ARROW_UP_COMMAND, + KEY_BACKSPACE_COMMAND, + KEY_DELETE_COMMAND, + KEY_ENTER_COMMAND, + KEY_ESCAPE_COMMAND, + SELECTION_CHANGE_COMMAND, +} from 'lexical'; +import { $createNamePillNode } from './NamePillNode'; + +const getAutocompleteItemId = (autocompleteId: string, value: string) => { + return `${autocompleteId}_item-${value}`; +}; + +type AutocompletePluginCoreProps = { + id: string; + isOpen: boolean; + onQueryChange: (newQuery: string) => void; + children: (renderProps: { onClick: () => void; getItemId: typeof getAutocompleteItemId }) => React.ReactElement; + autocompleteItem?: string; + onArrowKeyUp?: (event: KeyboardEvent) => boolean; + onArrowKeyDown?: (event: KeyboardEvent) => boolean; + onEscape?: (event: KeyboardEvent) => boolean; + query?: string; +}; + +export const AutocompletePluginCore: React.FC = ({ + id, + isOpen, + onQueryChange, + autocompleteItem, + children, + onArrowKeyUp, + onArrowKeyDown, + onEscape, + query, +}) => { + 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 (autocompleteItem) { + const node = sel.getNodes()[0]; + const newNode = $createNamePillNode(autocompleteItem); + node.replace(newNode); + newNode.selectEnd(); + return true; + } + } + return false; + }, [autocompleteItem]); + + const onBackspace = React.useCallback(() => { + const sel = $getSelection(); + + if ($isNodeSelection(sel) && sel.getNodes().length === 1) { + const selectedNode = sel.getNodes()[0]; + if ($isDecoratorNode(selectedNode)) { + // Have to move the selection at the end and let the native capslock handle the deletion + // otherwise 2 of the nodes will be deleted for some reason. This feels like a bug in lexical. + selectedNode.selectEnd(); + } + } + return false; + }, []); + + const onDelete = React.useCallback(() => { + const sel = $getSelection(); + + if ($isNodeSelection(sel) && sel.getNodes().length === 1) { + const selectedNode = sel.getNodes()[0]; + if ($isDecoratorNode(selectedNode)) { + // Have to move the selection at the start and let the native delete handle the deletion + // otherwise 2 of the nodes will be deleted for some reason. This feels like a bug in lexical. + selectedNode.selectStart(); + } + } + + return false; + }, []); + + 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(KEY_BACKSPACE_COMMAND, onBackspace, COMMAND_PRIORITY_CRITICAL); + }); + React.useEffect(() => { + return editor.registerCommand(KEY_DELETE_COMMAND, onDelete, COMMAND_PRIORITY_CRITICAL); + }); + + // Update the activedescendant on the parent element to the currently selected item + React.useEffect(() => { + const editorElement = editor.getRootElement(); + if (editorElement && autocompleteItem) { + editorElement.setAttribute('aria-activedescendant', getAutocompleteItemId(id, autocompleteItem)); + } + }, [autocompleteItem, editor, id, query]); + + React.useEffect(() => { + return editor.registerCommand( + SELECTION_CHANGE_COMMAND, + () => { + const sel = $getSelection(); + if (!sel) { + return; + } + + const editorElement = editor.getRootElement(); + + const nodes = sel.getNodes(); + if ($isRangeSelection(sel) && nodes.length === 1) { + onQueryChange(nodes[0].getTextContent()); + if (!isOpen) { + editorElement?.setAttribute('aria-activedescendant', ''); + } + } + + if ($isNodeSelection(sel)) { + const htmlElement = editor.getElementByKey(nodes[0].__key); + + if (editorElement && htmlElement) { + editorElement.setAttribute('aria-activedescendant', htmlElement.id); + } + // when node is selected, clear query so that the autocomplete is not shown + onQueryChange(''); + } + }, + COMMAND_PRIORITY_EDITOR, + ); + }, [editor, isOpen, autocompleteItem, query, onQueryChange]); + + React.useEffect(() => { + return editor.registerCommand( + INSERT_PARAGRAPH_COMMAND, + () => { + return true; + }, + COMMAND_PRIORITY_CRITICAL, + ); + }, [editor]); + + React.useEffect(() => { + return editor.registerCommand( + KEY_ARROW_LEFT_COMMAND, + () => { + editor.getRootElement()?.setAttribute('aria-activedescendant', ''); + return false; + }, + COMMAND_PRIORITY_CRITICAL, + ); + }, [editor]); + React.useEffect(() => { + return editor.registerCommand( + KEY_ARROW_RIGHT_COMMAND, + () => { + editor.getRootElement()?.setAttribute('aria-activedescendant', ''); + return false; + }, + COMMAND_PRIORITY_CRITICAL, + ); + }, [editor]); + + React.useEffect(() => { + return editor.registerCommand( + KEY_ARROW_UP_COMMAND, + payload => { + return onArrowKeyUp?.(payload) ?? false; + }, + COMMAND_PRIORITY_CRITICAL, + ); + }, [editor, onArrowKeyUp]); + + React.useEffect(() => { + return editor.registerCommand( + KEY_ARROW_DOWN_COMMAND, + payload => { + return onArrowKeyDown?.(payload) ?? false; + }, + COMMAND_PRIORITY_CRITICAL, + ); + }, [editor, onArrowKeyDown]); + + const onClick = React.useCallback(() => { + editor.update(appendSelectedItem); + }, [appendSelectedItem, editor]); + + return children({ onClick, getItemId: getAutocompleteItemId }); +}; diff --git a/packages/react-components/react-components/stories/ContentEditableTags/LexicalEditor/NamePillNode.tsx b/packages/react-components/react-components/stories/ContentEditableTags/LexicalEditor/NamePillNode.tsx new file mode 100644 index 0000000000000..0f15c8d758dce --- /dev/null +++ b/packages/react-components/react-components/stories/ContentEditableTags/LexicalEditor/NamePillNode.tsx @@ -0,0 +1,70 @@ +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; + el.id = this.__id; + el.setAttribute('role', 'option'); + el.setAttribute('aria-selected', 'true'); + 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/LexicalEditor/index.tsx b/packages/react-components/react-components/stories/ContentEditableTags/LexicalEditor/index.tsx new file mode 100644 index 0000000000000..1147e2d8df2c4 --- /dev/null +++ b/packages/react-components/react-components/stories/ContentEditableTags/LexicalEditor/index.tsx @@ -0,0 +1,54 @@ +import { AriaLiveAnnouncer } from '@fluentui/react-aria'; +import { AutoFocusPlugin } from '@lexical/react/LexicalAutoFocusPlugin'; +import { LexicalComposer } from '@lexical/react/LexicalComposer'; +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 { AutocompletePlugin } from './AutocompletePlugin'; +import { NamePillNode } from './NamePillNode'; + +import { getId } from '@fluentui/react'; + +const theme = {}; + +// 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 LexicalEditor() { + const id = getId('autocomplete'); + + const initialConfig = { + namespace: 'MyEditor', + theme, + onError, + nodes: [NamePillNode], + }; + + return ( + + + + } + placeholder={
} + ErrorBoundary={LexicalErrorBoundary} + /> + + + +
+
+ ); +} diff --git a/packages/react-components/react-components/stories/ContentEditableTags/LexicalPicker.stories.tsx b/packages/react-components/react-components/stories/ContentEditableTags/LexicalPicker.stories.tsx new file mode 100644 index 0000000000000..c245fc9f20948 --- /dev/null +++ b/packages/react-components/react-components/stories/ContentEditableTags/LexicalPicker.stories.tsx @@ -0,0 +1,12 @@ +import * as React from 'react'; +import { LexicalEditor } from './LexicalEditor'; +import { Prototype } from './utils/stories'; + +export const LexicalPicker: React.FC = () => { + return ( + +

Lexical picker

+ +
+ ); +}; 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..4437047211918 --- /dev/null +++ b/packages/react-components/react-components/stories/ContentEditableTags/ListOfContentEditableTags.stories.mdx @@ -0,0 +1,10 @@ +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/SelectionManipulationPicker.stories.tsx b/packages/react-components/react-components/stories/ContentEditableTags/SelectionManipulationPicker.stories.tsx new file mode 100644 index 0000000000000..f01f611937b4d --- /dev/null +++ b/packages/react-components/react-components/stories/ContentEditableTags/SelectionManipulationPicker.stories.tsx @@ -0,0 +1,12 @@ +import * as React from 'react'; +import { Prototype } from './utils/stories'; +import { SelectionManipulationPickerRenderer } from './SelectionManipulationPickerRenderer'; + +export const SelectionManipulationPicker: React.FC = () => { + return ( + +

Selection manipulation picker

+ +
+ ); +}; diff --git a/packages/react-components/react-components/stories/ContentEditableTags/SelectionManipulationPickerRenderer.tsx b/packages/react-components/react-components/stories/ContentEditableTags/SelectionManipulationPickerRenderer.tsx new file mode 100644 index 0000000000000..16b2806974b89 --- /dev/null +++ b/packages/react-components/react-components/stories/ContentEditableTags/SelectionManipulationPickerRenderer.tsx @@ -0,0 +1,199 @@ +import * as React from 'react'; +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'; + +import { people } from './data'; + +const options = people.map(person => ({ + id: person.split(' ')[0], + text: person, +})); + +export const SelectionManipulationPickerRenderer = () => { + const { targetDocument } = useFluent(); + const { overrideLeftArrow, overrideRightArrow, moveCaretFromItem, getCaretPosition } = useCaretManipulation(); + + const [open, setOpen] = React.useState(false); + const [value, setValue] = React.useState( + 'JohnnyBobSomebodyMark', + ); + 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) { + 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 ?? ''); + } + } + }, [getCaretPosition, inputRef, value]); + + const onInputChange = React.useCallback( + (newValue: string | null) => { + setValue(newValue); + }, + [setValue], + ); + + const onInputKeyDown = React.useCallback( + (event: React.KeyboardEvent) => { + let preventDefault: boolean | undefined | void = false; + switch (event.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; + case 'ArrowLeft': + preventDefault = overrideLeftArrow(inputRef.current); + break; + case 'ArrowRight': + preventDefault = overrideRightArrow(inputRef.current); + break; + default: + if (isKeyCharacter(event)) { + moveCaretFromItem(inputRef.current); + } + // alert(preventDefault); + break; + } + + if (preventDefault) { + event.preventDefault(); + } + }, + [selectActive, setOpen, overrideLeftArrow, overrideRightArrow, moveCaretFromItem, inputRef], + ); + + 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-label="Choose person" + aria-controls={open ? listboxId : undefined} + aria-expanded={open} + /> + {open && ( +
+ {options.map(option => ( +
+ {option.text} +
+ ))} +
+ )} + + ); +}; + +interface ContenteditableProps extends React.HTMLAttributes { + inputRef: React.Ref; + value: string | null; + onContentChange: (newValue: string | null) => void; +} +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.innerHTML !== value) { + contentEditableRef.current.innerHTML = value ?? ''; + } + }, [value]); + + return ( +
{ + const newValue = (event.target as HTMLDivElement).innerHTML; + onContentChange(newValue); + }} + {...props} + /> + ); +}; 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..932817e64856d --- /dev/null +++ b/packages/react-components/react-components/stories/ContentEditableTags/index.stories.tsx @@ -0,0 +1,6 @@ +export { SelectionManipulationPicker } from './SelectionManipulationPicker.stories'; +export { LexicalPicker } from './LexicalPicker.stories'; + +export default { + title: 'Concepts/Developer/Accessibility/ContentEditableTags', +}; 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/stories.tsx b/packages/react-components/react-components/stories/ContentEditableTags/utils/stories.tsx new file mode 100644 index 0000000000000..62912329ad45f --- /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} + + ); +}; 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/useCaretManipulation.ts b/packages/react-components/react-components/stories/ContentEditableTags/utils/useCaretManipulation.ts new file mode 100644 index 0000000000000..8e80dd5d64b48 --- /dev/null +++ b/packages/react-components/react-components/stories/ContentEditableTags/utils/useCaretManipulation.ts @@ -0,0 +1,129 @@ +import { useFluent } from '@fluentui/react-components'; + +export const useCaretManipulation = () => { + const { targetDocument } = useFluent(); + + const getRange = (element: HTMLElement | null) => { + const win = targetDocument?.defaultView; + if (!win || element === null) { + return null; + } + const selection = win.getSelection(); + if (!selection || selection.rangeCount === 0) { + 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) { + // alert(range.startOffset); + // alert(range.endContainer.tkextContent); + range.setStart(startContainer, 0); + range.setEnd(range.endContainer, 0); + return true; + } + return false; + }; + + 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 endContainerNextSiblingText = endContainerNextSibling.firstChild as ChildNode; + const endContainreNextSiblingRange = range.cloneRange(); + endContainreNextSiblingRange.selectNodeContents(endContainerNextSiblingText); + 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); + // alert(endParentNextSiblingRange.endOffset); + 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; + } + }; + + const moveCaretFromItem = (element: HTMLElement | null) => { + const range = getRange(element); + if (range === null) { + return; + } + 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; + if (prevSibling === null || prevSibling.nodeType !== Node.TEXT_NODE) { + prevSibling = targetDocument?.createTextNode('X') as Text; + startParent.parentNode?.insertBefore(prevSibling, startParent); + } + const newPosition = prevSibling.toString().length; + // range.setStart(prevSibling, newPosition); + range.setStart(prevSibling, 0); + range.setEnd(prevSibling, newPosition); + } + }; + + 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 { + overrideLeftArrow, + overrideRightArrow, + moveCaretFromItem, + getCaretPosition, + }; +}; 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, + }; +} 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; +}; diff --git a/yarn.lock b/yarn.lock index b127c1f3db60f..deeb0863b8af4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2416,6 +2416,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.24": version "3.0.0-beta.24" resolved "https://registry.yarnpkg.com/@linaria/babel-preset/-/babel-preset-3.0.0-beta.24.tgz#921b396afc37ebb5df84b8a02d37a56ea49cb54b" @@ -16345,6 +16546,11 @@ levn@^0.4.1: prelude-ls "^1.2.1" type-check "~0.4.0" +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" @@ -19317,7 +19523,12 @@ prettyjson@^1.2.1: colors "1.4.0" minimist "^1.2.0" -prismjs@^1.16.0, prismjs@^1.8.4, prismjs@~1.16.0: +prismjs@^1.16.0, prismjs@^1.27.0, prismjs@^1.8.4: + version "1.29.0" + resolved "https://registry.yarnpkg.com/prismjs/-/prismjs-1.29.0.tgz#f113555a8fa9b57c35e637bba27509dcf802dd12" + integrity sha512-Kx/1w86q/epKcmte75LNrEoT+lX8pBpavuAbvJWRXar7Hz8jrtF+e3vY751p0R8H9HdArwaCTNDDzHg/ScJK1Q== + +prismjs@~1.16.0: version "1.16.0" resolved "https://registry.yarnpkg.com/prismjs/-/prismjs-1.16.0.tgz#406eb2c8aacb0f5f0f1167930cb83835d10a4308" integrity sha512-OA4MKxjFZHSvZcisLGe14THYsug/nF6O1f0pAJc0KN0wTyAcLqmsbE+lTGKSpyh+9pEW57+k6pg2AfYR+coyHA== @@ -19787,10 +19998,10 @@ react-element-to-jsx-string@^14.0.2, react-element-to-jsx-string@^14.3.4: is-plain-object "5.0.0" react-is "17.0.2" -react-error-boundary@^3.1.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/react-error-boundary/-/react-error-boundary-3.1.0.tgz#9487443df2f9ba1db90d8ab52351814907ea4af3" - integrity sha512-lmPrdi5SLRJR+AeJkqdkGlW/CRkAUvZnETahK58J4xb5wpbfDngasEGu+w0T1iXEhVrYBJZeW+c4V1hILCnMWQ== +react-error-boundary@^3.1.0, 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"