@astryxdesign/core 0.7.1-canary.ff40b78 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +116 -0
- package/dist/Button/Button.d.ts +1 -1
- package/dist/Button/Button.d.ts.map +1 -1
- package/dist/Button/Button.js +5 -2
- package/dist/Calendar/Calendar.d.ts.map +1 -1
- package/dist/Calendar/Calendar.js +191 -32
- package/dist/Chat/ChatComposerInput.d.ts +9 -0
- package/dist/Chat/ChatComposerInput.d.ts.map +1 -1
- package/dist/Chat/ChatComposerInput.js +5 -0
- package/dist/Chat/ChatMessage.d.ts +11 -0
- package/dist/Chat/ChatMessage.d.ts.map +1 -1
- package/dist/Chat/ChatMessage.js +18 -9
- package/dist/Chat/useTriggerMenu.d.ts +9 -2
- package/dist/Chat/useTriggerMenu.d.ts.map +1 -1
- package/dist/Chat/useTriggerMenu.js +222 -50
- package/dist/CodeBlock/CodeBlock.d.ts +10 -1
- package/dist/CodeBlock/CodeBlock.d.ts.map +1 -1
- package/dist/CodeBlock/CodeBlock.js +158 -38
- package/dist/CodeBlock/tokenizer.d.ts +39 -0
- package/dist/CodeBlock/tokenizer.d.ts.map +1 -1
- package/dist/CodeBlock/tokenizer.js +108 -0
- package/dist/CommandPalette/CommandPalette.d.ts +9 -5
- package/dist/CommandPalette/CommandPalette.d.ts.map +1 -1
- package/dist/CommandPalette/CommandPalette.js +38 -20
- package/dist/CommandPalette/CommandPaletteEmpty.d.ts +4 -3
- package/dist/CommandPalette/CommandPaletteEmpty.d.ts.map +1 -1
- package/dist/CommandPalette/CommandPaletteEmpty.js +4 -3
- package/dist/CommandPalette/CommandPaletteInput.d.ts +5 -4
- package/dist/CommandPalette/CommandPaletteInput.d.ts.map +1 -1
- package/dist/CommandPalette/CommandPaletteInput.js +35 -18
- package/dist/CommandPalette/CommandPaletteItem.d.ts +11 -6
- package/dist/CommandPalette/CommandPaletteItem.d.ts.map +1 -1
- package/dist/CommandPalette/CommandPaletteItem.js +8 -9
- package/dist/CommandPalette/CommandPaletteList.d.ts +4 -2
- package/dist/CommandPalette/CommandPaletteList.d.ts.map +1 -1
- package/dist/CommandPalette/CommandPaletteList.js +10 -5
- package/dist/ComplexSelector/ComplexSelector.d.ts +8 -5
- package/dist/ComplexSelector/ComplexSelector.d.ts.map +1 -1
- package/dist/ComplexSelector/ComplexSelector.js +12 -3
- package/dist/ContextMenu/ContextMenu.d.ts +3 -3
- package/dist/ContextMenu/ContextMenu.d.ts.map +1 -1
- package/dist/ContextMenu/ContextMenu.js +62 -48
- package/dist/DateInput/DateInput.js +1 -1
- package/dist/DateInput/MonthScroller.d.ts.map +1 -1
- package/dist/DateInput/MonthScroller.js +12 -4
- package/dist/DateRangeInput/DateRangeInput.d.ts.map +1 -1
- package/dist/DateRangeInput/DateRangeInput.js +12 -2
- package/dist/Dialog/Dialog.d.ts +1 -1
- package/dist/Dialog/Dialog.d.ts.map +1 -1
- package/dist/Dialog/Dialog.js +16 -5
- package/dist/Dialog/DialogHeader.d.ts +1 -1
- package/dist/Dialog/DialogHeader.d.ts.map +1 -1
- package/dist/Dialog/DialogHeader.js +7 -7
- package/dist/Divider/Divider.d.ts +1 -0
- package/dist/Divider/Divider.d.ts.map +1 -1
- package/dist/Divider/Divider.js +5 -4
- package/dist/DropdownMenu/DropdownMenu.d.ts.map +1 -1
- package/dist/DropdownMenu/DropdownMenu.js +161 -65
- package/dist/DropdownMenu/DropdownMenuCheckboxItem.d.ts +7 -5
- package/dist/DropdownMenu/DropdownMenuCheckboxItem.d.ts.map +1 -1
- package/dist/DropdownMenu/DropdownMenuCheckboxItem.js +12 -5
- package/dist/Field/InputClearButton.d.ts +8 -4
- package/dist/Field/InputClearButton.d.ts.map +1 -1
- package/dist/Field/InputClearButton.js +23 -7
- package/dist/FormLayout/FormLayout.d.ts.map +1 -1
- package/dist/FormLayout/FormLayout.js +1 -0
- package/dist/Grid/Grid.d.ts +13 -4
- package/dist/Grid/Grid.d.ts.map +1 -1
- package/dist/Grid/Grid.js +7 -1
- package/dist/HoverCard/useHoverCard.d.ts +1 -1
- package/dist/HoverCard/useHoverCard.d.ts.map +1 -1
- package/dist/HoverCard/useHoverCard.js +67 -49
- package/dist/InputGroup/InputGroup.d.ts +2 -4
- package/dist/InputGroup/InputGroup.d.ts.map +1 -1
- package/dist/InputGroup/InputGroup.js +2 -4
- package/dist/InputGroup/groupStyles.d.ts +10 -2
- package/dist/InputGroup/groupStyles.d.ts.map +1 -1
- package/dist/InputGroup/groupStyles.js +19 -2
- package/dist/Layer/useLayerDismissal.d.ts +9 -9
- package/dist/Layer/useLayerDismissal.js +9 -9
- package/dist/Markdown/Markdown.d.ts.map +1 -1
- package/dist/Markdown/Markdown.js +25 -4
- package/dist/MobileNav/MobileNav.d.ts +7 -0
- package/dist/MobileNav/MobileNav.d.ts.map +1 -1
- package/dist/MobileNav/MobileNav.js +45 -2
- package/dist/MultiSelector/MultiSelector.d.ts +7 -4
- package/dist/MultiSelector/MultiSelector.d.ts.map +1 -1
- package/dist/MultiSelector/MultiSelector.js +24 -3
- package/dist/NumberInput/NumberInput.js +1 -1
- package/dist/PowerSearch/PowerSearchEditPopover.d.ts.map +1 -1
- package/dist/PowerSearch/PowerSearchEditPopover.js +6 -0
- package/dist/Resizable/ResizeHandle.d.ts +1 -1
- package/dist/Resizable/ResizeHandle.d.ts.map +1 -1
- package/dist/Resizable/ResizeHandle.js +83 -15
- package/dist/Resizable/useResizable.d.ts +23 -0
- package/dist/Resizable/useResizable.d.ts.map +1 -1
- package/dist/Resizable/useResizable.js +78 -0
- package/dist/Selector/Selector.d.ts +6 -3
- package/dist/Selector/Selector.d.ts.map +1 -1
- package/dist/Selector/Selector.js +28 -6
- package/dist/SideNav/SideNav.d.ts.map +1 -1
- package/dist/SideNav/SideNav.js +10 -2
- package/dist/Slider/Slider.d.ts.map +1 -1
- package/dist/Slider/Slider.js +18 -7
- package/dist/Tokenizer/Tokenizer.d.ts.map +1 -1
- package/dist/Tokenizer/Tokenizer.js +16 -4
- package/dist/Toolbar/Toolbar.d.ts +2 -1
- package/dist/Toolbar/Toolbar.d.ts.map +1 -1
- package/dist/Toolbar/Toolbar.js +3 -1
- package/dist/TreeList/TreeList.d.ts.map +1 -1
- package/dist/TreeList/TreeList.js +6 -3
- package/dist/TreeList/TreeListItem.d.ts.map +1 -1
- package/dist/TreeList/TreeListItem.js +7 -1
- package/dist/astryx.css +22 -0
- package/dist/hooks/useScrollOverflow.d.ts.map +1 -1
- package/dist/hooks/useScrollOverflow.js +55 -12
- package/dist/hooks/useTreeFocus.d.ts +21 -0
- package/dist/hooks/useTreeFocus.d.ts.map +1 -1
- package/dist/hooks/useTreeFocus.js +40 -2
- package/dist/i18n/generated-locales/en.generated.d.ts.map +1 -1
- package/dist/i18n/generated-locales/en.generated.js +3 -0
- package/dist/i18n/generated-locales/pseudo.generated.d.ts.map +1 -1
- package/dist/i18n/generated-locales/pseudo.generated.js +3 -0
- package/dist/reset.a11y.chromium.spec.js +6 -1
- package/dist/reset.until-found.a11y.browser.spec.d.ts +2 -0
- package/dist/reset.until-found.a11y.browser.spec.d.ts.map +1 -0
- package/dist/reset.until-found.a11y.browser.spec.js +71 -0
- package/dist/theme/syntax/SyntaxTheme.d.ts.map +1 -1
- package/dist/theme/syntax/SyntaxTheme.js +43 -2
- package/dist/theme/syntax/defineSyntaxTheme.d.ts +18 -5
- package/dist/theme/syntax/defineSyntaxTheme.d.ts.map +1 -1
- package/dist/theme/syntax/defineSyntaxTheme.js +38 -9
- package/dist/theme/syntax/index.d.ts +1 -1
- package/dist/theme/syntax/index.d.ts.map +1 -1
- package/dist/theme/syntax/tokens.d.ts +12 -2
- package/dist/theme/syntax/tokens.d.ts.map +1 -1
- package/dist/theme/syntax/tokens.js +25 -4
- package/dist/utils/plainDate.d.ts +12 -0
- package/dist/utils/plainDate.d.ts.map +1 -1
- package/dist/utils/plainDate.js +63 -10
- package/locales/en.json +13 -1
- package/locales/pseudo.json +9 -0
- package/package.json +4 -4
- package/src/AlertDialog/AlertDialog.doc.mjs +6 -0
- package/src/AlertDialog/AlertDialog.spec.md +205 -0
- package/src/AlertDialog/AlertDialog.test.tsx +3 -14
- package/src/AlertDialog/__tests__/AlertDialog.a11y.chromium.spec.ts +115 -0
- package/src/AlertDialog/__tests__/AlertDialog.a11y.renders.tsx +52 -0
- package/src/AlertDialog/__tests__/AlertDialog.a11y.states.ts +68 -0
- package/src/AlertDialog/__tests__/AlertDialog.a11y.test.tsx +144 -0
- package/src/Button/Button.test.tsx +37 -0
- package/src/Button/Button.tsx +5 -1
- package/src/Calendar/Calendar.doc.mjs +234 -60
- package/src/Calendar/Calendar.spec.md +209 -0
- package/src/Calendar/Calendar.test.tsx +559 -20
- package/src/Calendar/Calendar.tsx +258 -41
- package/src/Calendar/__tests__/Calendar.a11y.chromium.spec.ts +126 -0
- package/src/Calendar/__tests__/Calendar.a11y.known-failures.ts +12 -0
- package/src/Calendar/__tests__/Calendar.a11y.renders.tsx +57 -0
- package/src/Calendar/__tests__/Calendar.a11y.states.ts +139 -0
- package/src/Calendar/__tests__/Calendar.a11y.test.tsx +137 -0
- package/src/Carousel/Carousel.test.tsx +54 -71
- package/src/Carousel/__tests__/Carousel.a11y.chromium.spec.ts +192 -0
- package/src/Carousel/__tests__/Carousel.a11y.known-failures.ts +27 -0
- package/src/Carousel/__tests__/Carousel.a11y.renders.tsx +39 -0
- package/src/Carousel/__tests__/Carousel.a11y.states.ts +139 -0
- package/src/Carousel/__tests__/Carousel.a11y.test.tsx +127 -0
- package/src/Chat/ChatComposerInput.doc.mjs +1 -1
- package/src/Chat/ChatComposerInput.spec.md +94 -55
- package/src/Chat/ChatComposerInput.test.tsx +202 -1
- package/src/Chat/ChatComposerInput.tsx +13 -0
- package/src/Chat/ChatMessage.doc.mjs +31 -16
- package/src/Chat/ChatMessage.spec.md +265 -0
- package/src/Chat/ChatMessage.test.tsx +246 -0
- package/src/Chat/ChatMessage.tsx +30 -7
- package/src/Chat/ChatSystemMessage.spec.md +36 -37
- package/src/Chat/ChatSystemMessage.test.tsx +4 -4
- package/src/Chat/useTriggerMenu.test.tsx +994 -0
- package/src/Chat/useTriggerMenu.tsx +278 -46
- package/src/CodeBlock/CodeBlock.doc.mjs +7 -0
- package/src/CodeBlock/CodeBlock.spec.md +40 -41
- package/src/CodeBlock/CodeBlock.test.tsx +89 -9
- package/src/CodeBlock/CodeBlock.tsx +205 -25
- package/src/CodeBlock/__tests__/CodeBlockOverflowStop.a11y.chromium.spec.ts +125 -0
- package/src/CodeBlock/__tests__/DiffRowPaint.a11y.chromium.spec.ts +93 -0
- package/src/CodeBlock/tokenizer.test.ts +112 -0
- package/src/CodeBlock/tokenizer.ts +106 -0
- package/src/Collapsible/CollapsibleGroup.spec.md +17 -8
- package/src/Collapsible/__tests__/CollapsibleGroup.a11y.renders.tsx +45 -0
- package/src/Collapsible/__tests__/CollapsibleGroup.a11y.states.ts +139 -0
- package/src/Collapsible/__tests__/CollapsibleGroup.a11y.test.tsx +135 -0
- package/src/Collapsible/__tests__/CollapsibleGroupAccordion.a11y.chromium.spec.ts +128 -0
- package/src/CommandPalette/CommandPalette.doc.mjs +126 -23
- package/src/CommandPalette/CommandPalette.spec.md +100 -49
- package/src/CommandPalette/CommandPalette.test.tsx +241 -2
- package/src/CommandPalette/CommandPalette.tsx +47 -21
- package/src/CommandPalette/CommandPaletteEmpty.tsx +4 -3
- package/src/CommandPalette/CommandPaletteInput.doc.mjs +61 -4
- package/src/CommandPalette/CommandPaletteInput.spec.md +215 -0
- package/src/CommandPalette/CommandPaletteInput.test.tsx +375 -51
- package/src/CommandPalette/CommandPaletteInput.tsx +74 -21
- package/src/CommandPalette/CommandPaletteItem.doc.mjs +73 -22
- package/src/CommandPalette/CommandPaletteItem.spec.md +223 -0
- package/src/CommandPalette/CommandPaletteItem.test.tsx +200 -5
- package/src/CommandPalette/CommandPaletteItem.tsx +16 -11
- package/src/CommandPalette/CommandPaletteList.doc.mjs +3 -3
- package/src/CommandPalette/CommandPaletteList.spec.md +41 -36
- package/src/CommandPalette/CommandPaletteList.test.tsx +80 -0
- package/src/CommandPalette/CommandPaletteList.tsx +10 -5
- package/src/CommandPalette/__tests__/CommandPalette.a11y.chromium.spec.ts +599 -0
- package/src/ComplexSelector/ComplexSelector.doc.mjs +10 -8
- package/src/ComplexSelector/ComplexSelector.spec.md +14 -9
- package/src/ComplexSelector/ComplexSelector.test.tsx +64 -16
- package/src/ComplexSelector/ComplexSelector.tsx +37 -8
- package/src/ContextMenu/ContextMenu.doc.mjs +6 -0
- package/src/ContextMenu/ContextMenu.spec.md +51 -53
- package/src/ContextMenu/ContextMenu.test.tsx +132 -1
- package/src/ContextMenu/ContextMenu.tsx +78 -48
- package/src/DateInput/DateInput.doc.mjs +52 -0
- package/src/DateInput/DateInput.spec.md +27 -15
- package/src/DateInput/DateInput.test.tsx +38 -0
- package/src/DateInput/DateInput.tsx +1 -1
- package/src/DateInput/DateInputTouch.test.tsx +22 -0
- package/src/DateInput/MonthScroller.tsx +12 -4
- package/src/DateRangeInput/DateRangeInput.test.tsx +49 -0
- package/src/DateRangeInput/DateRangeInput.tsx +20 -2
- package/src/Dialog/Dialog.test.tsx +27 -1
- package/src/Dialog/Dialog.tsx +16 -5
- package/src/Dialog/DialogHeader.test.tsx +27 -2
- package/src/Dialog/DialogHeader.tsx +7 -8
- package/src/Dialog/__tests__/Dialog.a11y.chromium.spec.ts +21 -14
- package/src/Divider/Divider.doc.mjs +5 -3
- package/src/Divider/Divider.spec.md +140 -54
- package/src/Divider/Divider.test.tsx +15 -0
- package/src/Divider/Divider.tsx +6 -4
- package/src/DropdownMenu/DropdownMenu.doc.mjs +2 -2
- package/src/DropdownMenu/DropdownMenu.spec.md +121 -45
- package/src/DropdownMenu/DropdownMenu.test.tsx +288 -23
- package/src/DropdownMenu/DropdownMenu.tsx +212 -81
- package/src/DropdownMenu/DropdownMenuCheckboxItem.doc.mjs +38 -2
- package/src/DropdownMenu/DropdownMenuCheckboxItem.tsx +32 -18
- package/src/DropdownMenu/DropdownMenuSelectable.test.tsx +146 -1
- package/src/DropdownMenu/__tests__/MenuButton.a11y.chromium.spec.ts +136 -0
- package/src/DropdownMenu/__tests__/MenuButton.a11y.known-failures.ts +12 -0
- package/src/DropdownMenu/__tests__/MenuButton.a11y.renders.tsx +67 -0
- package/src/DropdownMenu/__tests__/MenuButton.a11y.states.ts +141 -0
- package/src/DropdownMenu/__tests__/MenuButton.a11y.test.tsx +138 -0
- package/src/DropdownMenu/__tests__/MenuHeaderFooter.a11y.chromium.spec.ts +40 -5
- package/src/Field/InputClearButton.test.tsx +29 -4
- package/src/Field/InputClearButton.tsx +26 -8
- package/src/FieldStatus/__tests__/StatusMessage.a11y.chromium.spec.ts +21 -14
- package/src/FormLayout/FormLayout.tsx +1 -0
- package/src/FormLayout/__snapshots__/FormLayout.test.tsx.snap +1 -1
- package/src/Grid/Grid.doc.mjs +14 -11
- package/src/Grid/Grid.spec.md +50 -32
- package/src/Grid/Grid.tsx +13 -4
- package/src/HoverCard/HoverCard.test.tsx +46 -1
- package/src/HoverCard/useHoverCard.tsx +68 -48
- package/src/InputGroup/InputGroup.doc.mjs +11 -1
- package/src/InputGroup/InputGroup.tsx +2 -4
- package/src/InputGroup/groupStyles.ts +40 -2
- package/src/Layer/useLayerDismissal.ts +9 -9
- package/src/Link/__tests__/Link.a11y.chromium.spec.ts +142 -0
- package/src/Link/__tests__/Link.a11y.renders.tsx +97 -0
- package/src/Link/__tests__/Link.a11y.states.ts +104 -0
- package/src/Link/__tests__/Link.a11y.test.tsx +127 -0
- package/src/Markdown/Markdown.doc.mjs +1 -1
- package/src/Markdown/Markdown.fr23.perf.test.ts +161 -40
- package/src/Markdown/Markdown.spec.md +45 -23
- package/src/Markdown/Markdown.test.tsx +38 -0
- package/src/Markdown/Markdown.tsx +24 -2
- package/src/Markdown/plugins/semanticFence.test.tsx +3 -2
- package/src/MobileNav/MobileNav.tsx +50 -2
- package/src/MobileNav/MobileNavFocus.test.tsx +553 -0
- package/src/MoreMenu/MoreMenu.spec.md +18 -7
- package/src/MoreMenu/MoreMenu.test.tsx +38 -0
- package/src/MultiSelector/MultiSelector.doc.mjs +8 -8
- package/src/MultiSelector/MultiSelector.spec.md +1 -1
- package/src/MultiSelector/MultiSelector.test.tsx +188 -21
- package/src/MultiSelector/MultiSelector.tsx +50 -11
- package/src/NumberInput/NumberInput.test.tsx +24 -0
- package/src/NumberInput/NumberInput.tsx +1 -1
- package/src/NumberInput/__tests__/NumberInput.a11y.chromium.spec.ts +22 -15
- package/src/PowerSearch/PowerSearchEditPopover.test.tsx +78 -0
- package/src/PowerSearch/PowerSearchEditPopover.tsx +6 -0
- package/src/Resizable/Resizable.spec.md +41 -9
- package/src/Resizable/ResizeHandle.test.tsx +565 -2
- package/src/Resizable/ResizeHandle.tsx +126 -21
- package/src/Resizable/__tests__/ResizeHandle.a11y.chromium.spec.ts +119 -0
- package/src/Resizable/__tests__/ResizeHandle.a11y.known-failures.ts +39 -0
- package/src/Resizable/__tests__/ResizeHandle.a11y.renders.tsx +80 -0
- package/src/Resizable/__tests__/ResizeHandle.a11y.states.ts +197 -0
- package/src/Resizable/__tests__/ResizeHandle.a11y.test.tsx +123 -0
- package/src/Resizable/useResizable.ts +95 -0
- package/src/Selector/Selector.doc.mjs +7 -6
- package/src/Selector/Selector.spec.md +37 -24
- package/src/Selector/Selector.test.tsx +176 -10
- package/src/Selector/Selector.tsx +59 -14
- package/src/Selector/__tests__/Combobox.a11y.known-failures.ts +27 -0
- package/src/Selector/__tests__/Combobox.a11y.states.ts +145 -0
- package/src/Selector/__tests__/Selector.combobox.a11y.chromium.spec.ts +108 -0
- package/src/Selector/__tests__/Selector.combobox.a11y.test.tsx +126 -0
- package/src/SideNav/SideNav.test.tsx +35 -0
- package/src/SideNav/SideNav.tsx +16 -2
- package/src/Slider/Slider.spec.md +43 -17
- package/src/Slider/Slider.test.tsx +134 -0
- package/src/Slider/Slider.tsx +29 -6
- package/src/Slider/__tests__/Slider.a11y.chromium.spec.ts +112 -0
- package/src/Slider/__tests__/Slider.a11y.known-failures.ts +12 -0
- package/src/Slider/__tests__/Slider.a11y.renders.tsx +42 -0
- package/src/Slider/__tests__/Slider.a11y.states.ts +106 -0
- package/src/Slider/__tests__/Slider.a11y.test.tsx +105 -0
- package/src/Switch/__tests__/Switch.a11y.chromium.spec.ts +25 -18
- package/src/TabList/__tests__/Tabs.a11y.chromium.spec.ts +19 -12
- package/src/TextInput/__tests__/TextInput.a11y.chromium.spec.ts +21 -14
- package/src/ToggleButton/__tests__/ToggleButton.a11y.chromium.spec.ts +18 -11
- package/src/Tokenizer/Tokenizer.test.tsx +87 -0
- package/src/Tokenizer/Tokenizer.tsx +31 -3
- package/src/Toolbar/Toolbar.test.tsx +131 -4
- package/src/Toolbar/Toolbar.tsx +3 -1
- package/src/TreeList/TreeList.spec.md +33 -8
- package/src/TreeList/TreeList.test.tsx +72 -0
- package/src/TreeList/TreeList.tsx +10 -6
- package/src/TreeList/TreeListItem.tsx +5 -0
- package/src/TreeList/__tests__/TreeList.a11y.chromium.spec.ts +125 -0
- package/src/TreeList/__tests__/TreeList.a11y.renders.tsx +44 -0
- package/src/TreeList/__tests__/TreeList.a11y.states.ts +164 -0
- package/src/TreeList/__tests__/TreeList.a11y.test.tsx +138 -0
- package/src/TreeList/__tests__/TreeListRowNames.a11y.chromium.spec.ts +82 -0
- package/src/hooks/useScrollOverflow.test.tsx +222 -0
- package/src/hooks/useScrollOverflow.ts +55 -13
- package/src/hooks/useTreeFocus.test.tsx +37 -0
- package/src/hooks/useTreeFocus.ts +55 -2
- package/src/i18n/generated-locales/en.generated.ts +3 -0
- package/src/i18n/generated-locales/pseudo.generated.ts +3 -0
- package/src/reset.a11y.chromium.spec.ts +9 -1
- package/src/reset.css +8 -3
- package/src/reset.until-found.a11y.browser.spec.ts +75 -0
- package/src/reset.until-found.test.ts +32 -0
- package/src/stylexValidation.source-build.test.mjs +42 -0
- package/src/theme/syntax/SyntaxTheme.tsx +46 -5
- package/src/theme/syntax/defineSyntaxTheme.diff.test.ts +91 -0
- package/src/theme/syntax/defineSyntaxTheme.ts +75 -15
- package/src/theme/syntax/index.ts +5 -1
- package/src/theme/syntax/tokens.ts +32 -2
- package/src/theme/syntax/useSyntaxTheme.diff.test.tsx +40 -0
- package/src/utils/plainDate.test.ts +147 -0
- package/src/utils/plainDate.ts +75 -10
|
@@ -0,0 +1,994 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file useTriggerMenu.test.tsx
|
|
5
|
+
* @input Uses vitest, @testing-library/react, useTriggerMenu hook
|
|
6
|
+
* @output Unit tests for trigger detection, multi-word queries, IME guard, pending-search Enter, stale and late results, and Escape dismissal
|
|
7
|
+
* @position Testing; validates useTriggerMenu.tsx implementation
|
|
8
|
+
*
|
|
9
|
+
* SYNC: When useTriggerMenu.tsx changes, update tests to match new behavior
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import {describe, it, expect, vi, beforeEach, afterEach} from 'vitest';
|
|
13
|
+
import {renderHook, act} from '@testing-library/react';
|
|
14
|
+
import {useTriggerMenu} from './useTriggerMenu';
|
|
15
|
+
import type {ChatComposerTrigger} from './ChatComposerInput';
|
|
16
|
+
import {createStaticSource} from '../Typeahead/createStaticSource';
|
|
17
|
+
import type {SearchableItem, SearchSource} from '../Typeahead/types';
|
|
18
|
+
|
|
19
|
+
const USERS: SearchableItem[] = [
|
|
20
|
+
{id: 'cindy', label: 'Cindy Zhang'},
|
|
21
|
+
{id: 'alex', label: 'Alex Johnson'},
|
|
22
|
+
{id: 'sam', label: 'Sam Rivera'},
|
|
23
|
+
{id: 'obrien', label: "O'Brien"},
|
|
24
|
+
{id: 'jeanluc', label: "Jean-Luc's Task"},
|
|
25
|
+
{id: 'maria', label: 'María José García de la Fuente'},
|
|
26
|
+
{id: 'rdj', label: 'Robert Downey Jr.'},
|
|
27
|
+
{id: 'q4_review', label: 'Design Review: Q4'},
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
const COMMANDS: SearchableItem[] = [
|
|
31
|
+
{id: 'summarize', label: 'summarize'},
|
|
32
|
+
{id: 'translate', label: 'translate'},
|
|
33
|
+
{id: 'search', label: 'search'},
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
function createMentionTrigger(
|
|
37
|
+
overrides?: Partial<ChatComposerTrigger>,
|
|
38
|
+
): ChatComposerTrigger {
|
|
39
|
+
return {
|
|
40
|
+
character: '@',
|
|
41
|
+
searchSource: createStaticSource(USERS),
|
|
42
|
+
onSelect: item => ({
|
|
43
|
+
value: `@${item.id}`,
|
|
44
|
+
label: `@${item.label}`,
|
|
45
|
+
variant: 'blue' as const,
|
|
46
|
+
}),
|
|
47
|
+
hasMultiWordQuery: true,
|
|
48
|
+
...overrides,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function createCommandTrigger(
|
|
53
|
+
overrides?: Partial<ChatComposerTrigger>,
|
|
54
|
+
): ChatComposerTrigger {
|
|
55
|
+
return {
|
|
56
|
+
character: '/',
|
|
57
|
+
searchSource: createStaticSource(COMMANDS),
|
|
58
|
+
onSelect: item => `/${item.label} `,
|
|
59
|
+
hasMultiWordQuery: false,
|
|
60
|
+
...overrides,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
describe('useTriggerMenu', () => {
|
|
65
|
+
let editable: HTMLDivElement;
|
|
66
|
+
|
|
67
|
+
beforeEach(() => {
|
|
68
|
+
editable = document.createElement('div');
|
|
69
|
+
editable.contentEditable = 'true';
|
|
70
|
+
document.body.appendChild(editable);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
afterEach(() => {
|
|
74
|
+
editable.remove();
|
|
75
|
+
const selection = window.getSelection();
|
|
76
|
+
selection?.removeAllRanges();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// Typing in a real editor grows the same text node; reuse it so state tied to
|
|
80
|
+
// the node (an Escape dismissal) behaves as it does in a browser. Assigning
|
|
81
|
+
// `editable.textContent` directly models an external rewrite instead.
|
|
82
|
+
function setCursor(text: string, cursorOffset?: number) {
|
|
83
|
+
const existing = editable.firstChild;
|
|
84
|
+
if (
|
|
85
|
+
existing?.nodeType === Node.TEXT_NODE &&
|
|
86
|
+
editable.childNodes.length === 1 &&
|
|
87
|
+
text !== ''
|
|
88
|
+
) {
|
|
89
|
+
(existing as Text).data = text;
|
|
90
|
+
} else {
|
|
91
|
+
editable.textContent = text;
|
|
92
|
+
}
|
|
93
|
+
const textNode = editable.firstChild;
|
|
94
|
+
const offset = cursorOffset ?? text.length;
|
|
95
|
+
|
|
96
|
+
const selection = window.getSelection();
|
|
97
|
+
if (!selection) {
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
selection.removeAllRanges();
|
|
101
|
+
|
|
102
|
+
if (textNode) {
|
|
103
|
+
const range = document.createRange();
|
|
104
|
+
range.setStart(textNode, offset);
|
|
105
|
+
range.collapse(true);
|
|
106
|
+
selection.addRange(range);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function renderTriggerHook(triggers?: ChatComposerTrigger[]) {
|
|
111
|
+
return renderHook(() =>
|
|
112
|
+
useTriggerMenu({
|
|
113
|
+
triggers: triggers ?? [createMentionTrigger(), createCommandTrigger()],
|
|
114
|
+
editableRef: {current: editable},
|
|
115
|
+
onInsertToken: vi.fn(),
|
|
116
|
+
onInsertText: vi.fn(),
|
|
117
|
+
onEmitChange: vi.fn(),
|
|
118
|
+
debounceMs: 0,
|
|
119
|
+
}),
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// 1. Single-token / behavior unchanged
|
|
124
|
+
it('1. single-token / command behavior is unchanged and terminates on space', async () => {
|
|
125
|
+
const {result} = renderTriggerHook();
|
|
126
|
+
|
|
127
|
+
await act(async () => {
|
|
128
|
+
setCursor('/summarize');
|
|
129
|
+
result.current.handleInput();
|
|
130
|
+
await Promise.resolve();
|
|
131
|
+
});
|
|
132
|
+
expect(result.current.state.isActive).toBe(true);
|
|
133
|
+
expect(result.current.state.query).toBe('summarize');
|
|
134
|
+
expect(result.current.state.activeTrigger?.character).toBe('/');
|
|
135
|
+
|
|
136
|
+
// Space immediately terminates single-token trigger
|
|
137
|
+
await act(async () => {
|
|
138
|
+
setCursor('/summarize ');
|
|
139
|
+
result.current.handleInput();
|
|
140
|
+
await Promise.resolve();
|
|
141
|
+
});
|
|
142
|
+
expect(result.current.state.isActive).toBe(false);
|
|
143
|
+
|
|
144
|
+
// Further typing after space remains inactive
|
|
145
|
+
await act(async () => {
|
|
146
|
+
setCursor('/summarize now');
|
|
147
|
+
result.current.handleInput();
|
|
148
|
+
await Promise.resolve();
|
|
149
|
+
});
|
|
150
|
+
expect(result.current.state.isActive).toBe(false);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
// 2. hasMultiWordQuery: true allows multi-word names with spaces, punctuation, etc.
|
|
154
|
+
it('2. allows multi-word queries without word count or punctuation cutoffs', async () => {
|
|
155
|
+
const {result} = renderTriggerHook();
|
|
156
|
+
|
|
157
|
+
// Multi-word name: "María José García de la Fuente" (6 words)
|
|
158
|
+
await act(async () => {
|
|
159
|
+
setCursor('@María José García de la Fuente');
|
|
160
|
+
result.current.handleInput();
|
|
161
|
+
await Promise.resolve();
|
|
162
|
+
});
|
|
163
|
+
expect(result.current.state.isActive).toBe(true);
|
|
164
|
+
expect(result.current.state.query).toBe('María José García de la Fuente');
|
|
165
|
+
expect(result.current.state.items.some(u => u.id === 'maria')).toBe(true);
|
|
166
|
+
|
|
167
|
+
// Name with dot: "Robert Downey Jr."
|
|
168
|
+
await act(async () => {
|
|
169
|
+
setCursor('@Robert Downey Jr.');
|
|
170
|
+
result.current.handleInput();
|
|
171
|
+
await Promise.resolve();
|
|
172
|
+
});
|
|
173
|
+
expect(result.current.state.isActive).toBe(true);
|
|
174
|
+
expect(result.current.state.query).toBe('Robert Downey Jr.');
|
|
175
|
+
expect(result.current.state.items.some(u => u.id === 'rdj')).toBe(true);
|
|
176
|
+
|
|
177
|
+
// Phrase with colon: "Design Review: Q4"
|
|
178
|
+
await act(async () => {
|
|
179
|
+
setCursor('@Design Review: Q4');
|
|
180
|
+
result.current.handleInput();
|
|
181
|
+
await Promise.resolve();
|
|
182
|
+
});
|
|
183
|
+
expect(result.current.state.isActive).toBe(true);
|
|
184
|
+
expect(result.current.state.query).toBe('Design Review: Q4');
|
|
185
|
+
expect(result.current.state.items.some(u => u.id === 'q4_review')).toBe(
|
|
186
|
+
true,
|
|
187
|
+
);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
// 3. Trailing-space browse-all (@ )
|
|
191
|
+
it('3. supports trailing-space browse-all query', async () => {
|
|
192
|
+
const {result} = renderTriggerHook();
|
|
193
|
+
|
|
194
|
+
await act(async () => {
|
|
195
|
+
setCursor('@ ');
|
|
196
|
+
result.current.handleInput();
|
|
197
|
+
await Promise.resolve();
|
|
198
|
+
});
|
|
199
|
+
expect(result.current.state.isActive).toBe(true);
|
|
200
|
+
expect(result.current.state.query).toBe(' ');
|
|
201
|
+
|
|
202
|
+
await act(async () => {
|
|
203
|
+
setCursor('@Project ');
|
|
204
|
+
result.current.handleInput();
|
|
205
|
+
await Promise.resolve();
|
|
206
|
+
});
|
|
207
|
+
expect(result.current.state.isActive).toBe(true);
|
|
208
|
+
expect(result.current.state.query).toBe('Project ');
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
// 4. Bounded length (64 chars)
|
|
212
|
+
it('4. bounds active multi-word query length to 64 characters', async () => {
|
|
213
|
+
const {result} = renderTriggerHook();
|
|
214
|
+
const query64 = 'a'.repeat(64);
|
|
215
|
+
const query65 = 'a'.repeat(65);
|
|
216
|
+
|
|
217
|
+
await act(async () => {
|
|
218
|
+
setCursor(`@${query64}`);
|
|
219
|
+
result.current.handleInput();
|
|
220
|
+
await Promise.resolve();
|
|
221
|
+
});
|
|
222
|
+
expect(result.current.state.isActive).toBe(true);
|
|
223
|
+
expect(result.current.state.query).toBe(query64);
|
|
224
|
+
|
|
225
|
+
await act(async () => {
|
|
226
|
+
setCursor(`@${query65}`);
|
|
227
|
+
result.current.handleInput();
|
|
228
|
+
await Promise.resolve();
|
|
229
|
+
});
|
|
230
|
+
expect(result.current.state.isActive).toBe(false);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
// 5. Apostrophe/hyphen preserved
|
|
234
|
+
it("5. preserves apostrophes and hyphens without terminating (O'Brien, Jean-Luc's Task)", async () => {
|
|
235
|
+
const {result} = renderTriggerHook();
|
|
236
|
+
|
|
237
|
+
await act(async () => {
|
|
238
|
+
setCursor("@O'Brien");
|
|
239
|
+
result.current.handleInput();
|
|
240
|
+
await Promise.resolve();
|
|
241
|
+
});
|
|
242
|
+
expect(result.current.state.isActive).toBe(true);
|
|
243
|
+
expect(result.current.state.query).toBe("O'Brien");
|
|
244
|
+
|
|
245
|
+
await act(async () => {
|
|
246
|
+
setCursor("@Jean-Luc's Task");
|
|
247
|
+
result.current.handleInput();
|
|
248
|
+
await Promise.resolve();
|
|
249
|
+
});
|
|
250
|
+
expect(result.current.state.isActive).toBe(true);
|
|
251
|
+
expect(result.current.state.query).toBe("Jean-Luc's Task");
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
// 6. Double space does not close
|
|
255
|
+
it('6. tolerates double spaces without closing', async () => {
|
|
256
|
+
const {result} = renderTriggerHook();
|
|
257
|
+
|
|
258
|
+
await act(async () => {
|
|
259
|
+
setCursor('@Project Alpha');
|
|
260
|
+
result.current.handleInput();
|
|
261
|
+
await Promise.resolve();
|
|
262
|
+
});
|
|
263
|
+
expect(result.current.state.isActive).toBe(true);
|
|
264
|
+
expect(result.current.state.query).toBe('Project Alpha');
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
// 7. Cursor repositioned into an earlier mention binds correctly
|
|
268
|
+
it('7. binds correctly to earlier mention when cursor is repositioned', async () => {
|
|
269
|
+
const {result} = renderTriggerHook();
|
|
270
|
+
const fullText = 'Hello @First and @Second Name';
|
|
271
|
+
|
|
272
|
+
// Reposition cursor right after "@First and" (offset 16)
|
|
273
|
+
await act(async () => {
|
|
274
|
+
setCursor(fullText, 16);
|
|
275
|
+
result.current.handleInput();
|
|
276
|
+
await Promise.resolve();
|
|
277
|
+
});
|
|
278
|
+
expect(result.current.state.isActive).toBe(true);
|
|
279
|
+
expect(result.current.state.query).toBe('First and');
|
|
280
|
+
|
|
281
|
+
// Reposition cursor at end of fullText
|
|
282
|
+
await act(async () => {
|
|
283
|
+
setCursor(fullText, fullText.length);
|
|
284
|
+
result.current.handleInput();
|
|
285
|
+
await Promise.resolve();
|
|
286
|
+
});
|
|
287
|
+
expect(result.current.state.isActive).toBe(true);
|
|
288
|
+
expect(result.current.state.query).toBe('Second Name');
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
// 8. Word-boundary gate holds
|
|
292
|
+
it('8. preserves word-boundary gate (email@domain.com, foo/bar do not activate)', async () => {
|
|
293
|
+
const {result} = renderTriggerHook();
|
|
294
|
+
|
|
295
|
+
await act(async () => {
|
|
296
|
+
setCursor('email@domain.com');
|
|
297
|
+
result.current.handleInput();
|
|
298
|
+
await Promise.resolve();
|
|
299
|
+
});
|
|
300
|
+
expect(result.current.state.isActive).toBe(false);
|
|
301
|
+
|
|
302
|
+
await act(async () => {
|
|
303
|
+
setCursor('foo/bar');
|
|
304
|
+
result.current.handleInput();
|
|
305
|
+
await Promise.resolve();
|
|
306
|
+
});
|
|
307
|
+
expect(result.current.state.isActive).toBe(false);
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
// 9. Newline hard-terminates even with hasMultiWordQuery: true
|
|
311
|
+
it('9. terminates on newline even with hasMultiWordQuery: true', async () => {
|
|
312
|
+
const {result} = renderTriggerHook();
|
|
313
|
+
|
|
314
|
+
await act(async () => {
|
|
315
|
+
setCursor('@Project\nAlpha');
|
|
316
|
+
result.current.handleInput();
|
|
317
|
+
await Promise.resolve();
|
|
318
|
+
});
|
|
319
|
+
expect(result.current.state.isActive).toBe(false);
|
|
320
|
+
|
|
321
|
+
await act(async () => {
|
|
322
|
+
setCursor('@\n');
|
|
323
|
+
result.current.handleInput();
|
|
324
|
+
await Promise.resolve();
|
|
325
|
+
});
|
|
326
|
+
expect(result.current.state.isActive).toBe(false);
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
// 10. Punctuation trigger characters (e.g. ':' emoji trigger) activate correctly
|
|
330
|
+
it('10. allows triggers configured with punctuation characters (e.g. ":")', async () => {
|
|
331
|
+
const emojiTrigger: ChatComposerTrigger = {
|
|
332
|
+
character: ':',
|
|
333
|
+
searchSource: createStaticSource([
|
|
334
|
+
{id: 'smile', label: 'smile'},
|
|
335
|
+
{id: 'wave', label: 'wave'},
|
|
336
|
+
]),
|
|
337
|
+
onSelect: item => `:${item.label}:`,
|
|
338
|
+
hasMultiWordQuery: false,
|
|
339
|
+
};
|
|
340
|
+
const {result} = renderTriggerHook([emojiTrigger, createMentionTrigger()]);
|
|
341
|
+
|
|
342
|
+
await act(async () => {
|
|
343
|
+
setCursor(':smile');
|
|
344
|
+
result.current.handleInput();
|
|
345
|
+
await Promise.resolve();
|
|
346
|
+
});
|
|
347
|
+
expect(result.current.state.isActive).toBe(true);
|
|
348
|
+
expect(result.current.state.activeTrigger?.character).toBe(':');
|
|
349
|
+
expect(result.current.state.query).toBe('smile');
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
// 11. Non-opt-in triggers retain punctuation in queries (e.g. /v1.2, @john.doe)
|
|
353
|
+
it('11. allows punctuation in queries for triggers that do not opt into hasMultiWordQuery', async () => {
|
|
354
|
+
const singleTokenMention: ChatComposerTrigger = {
|
|
355
|
+
character: '@',
|
|
356
|
+
searchSource: createStaticSource([{id: 'john.doe', label: 'john.doe'}]),
|
|
357
|
+
onSelect: item => `@${item.label}`,
|
|
358
|
+
hasMultiWordQuery: false,
|
|
359
|
+
};
|
|
360
|
+
const {result} = renderTriggerHook([
|
|
361
|
+
singleTokenMention,
|
|
362
|
+
createCommandTrigger(),
|
|
363
|
+
]);
|
|
364
|
+
|
|
365
|
+
// Command query with dot: /v1.2
|
|
366
|
+
await act(async () => {
|
|
367
|
+
setCursor('/v1.2');
|
|
368
|
+
result.current.handleInput();
|
|
369
|
+
await Promise.resolve();
|
|
370
|
+
});
|
|
371
|
+
expect(result.current.state.isActive).toBe(true);
|
|
372
|
+
expect(result.current.state.query).toBe('v1.2');
|
|
373
|
+
|
|
374
|
+
// Single-token mention query with dot: @john.doe
|
|
375
|
+
await act(async () => {
|
|
376
|
+
setCursor('@john.doe');
|
|
377
|
+
result.current.handleInput();
|
|
378
|
+
await Promise.resolve();
|
|
379
|
+
});
|
|
380
|
+
expect(result.current.state.isActive).toBe(true);
|
|
381
|
+
expect(result.current.state.query).toBe('john.doe');
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
// 12. No matches: hides menu when hasMultiWordQuery matches nothing without emptySearchText
|
|
385
|
+
it('12. hides menu when multi-word query matches nothing and no emptySearchText is set', async () => {
|
|
386
|
+
const {result} = renderTriggerHook();
|
|
387
|
+
|
|
388
|
+
await act(async () => {
|
|
389
|
+
setCursor('@NonExistentUser12345');
|
|
390
|
+
result.current.handleInput();
|
|
391
|
+
await Promise.resolve();
|
|
392
|
+
});
|
|
393
|
+
expect(result.current.state.isActive).toBe(true);
|
|
394
|
+
expect(result.current.state.items).toEqual([]);
|
|
395
|
+
// renderMenu returns null when hasMultiWordQuery matches nothing and emptySearchText is not set
|
|
396
|
+
expect(result.current.renderMenu()).toBeNull();
|
|
397
|
+
expect(result.current.ariaProps['aria-expanded']).toBe(false);
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
// 13. Callers with emptySearchText still display empty panel
|
|
401
|
+
it('13. shows empty panel when caller provides emptySearchText', async () => {
|
|
402
|
+
const customEmptyTrigger = createMentionTrigger({
|
|
403
|
+
emptySearchText: 'No matching team members',
|
|
404
|
+
});
|
|
405
|
+
const {result} = renderTriggerHook([customEmptyTrigger]);
|
|
406
|
+
|
|
407
|
+
await act(async () => {
|
|
408
|
+
setCursor('@NonExistentUser');
|
|
409
|
+
result.current.handleInput();
|
|
410
|
+
await Promise.resolve();
|
|
411
|
+
});
|
|
412
|
+
expect(result.current.state.isActive).toBe(true);
|
|
413
|
+
expect(result.current.renderMenu()).not.toBeNull();
|
|
414
|
+
expect(result.current.ariaProps['aria-expanded']).toBe(true);
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
// 13b. `emptySearchText: null` is "not given", so the menu still hides.
|
|
418
|
+
it('13b. hides the menu for an unmatched multi-word query when emptySearchText is null', async () => {
|
|
419
|
+
const {result} = renderTriggerHook([
|
|
420
|
+
createMentionTrigger({emptySearchText: null}),
|
|
421
|
+
]);
|
|
422
|
+
|
|
423
|
+
await act(async () => {
|
|
424
|
+
setCursor('@NonExistentUser');
|
|
425
|
+
result.current.handleInput();
|
|
426
|
+
await Promise.resolve();
|
|
427
|
+
});
|
|
428
|
+
expect(result.current.state.isActive).toBe(true);
|
|
429
|
+
expect(result.current.renderMenu()).toBeNull();
|
|
430
|
+
expect(result.current.ariaProps['aria-expanded']).toBe(false);
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
// 14. Enter with no matching row does not consume event
|
|
434
|
+
it('14. does not consume Enter when there is no matching row', async () => {
|
|
435
|
+
const {result} = renderTriggerHook();
|
|
436
|
+
|
|
437
|
+
await act(async () => {
|
|
438
|
+
setCursor('@NonExistentUser');
|
|
439
|
+
result.current.handleInput();
|
|
440
|
+
await Promise.resolve();
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
const enterEvent = {
|
|
444
|
+
key: 'Enter',
|
|
445
|
+
preventDefault: vi.fn(),
|
|
446
|
+
nativeEvent: {} as KeyboardEvent,
|
|
447
|
+
} as unknown as React.KeyboardEvent;
|
|
448
|
+
|
|
449
|
+
let consumed = false;
|
|
450
|
+
act(() => {
|
|
451
|
+
consumed = result.current.handleKeyDown(enterEvent);
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
expect(consumed).toBe(false);
|
|
455
|
+
expect(enterEvent.preventDefault).not.toHaveBeenCalled();
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
// 15. Guard IME composition: Enter during IME does not select
|
|
459
|
+
it('15. guards IME composition so Enter during candidate selection does not pick a row', async () => {
|
|
460
|
+
const {result} = renderTriggerHook();
|
|
461
|
+
|
|
462
|
+
await act(async () => {
|
|
463
|
+
setCursor('@Cindy');
|
|
464
|
+
result.current.handleInput();
|
|
465
|
+
await Promise.resolve();
|
|
466
|
+
});
|
|
467
|
+
expect(result.current.state.items.length).toBeGreaterThan(0);
|
|
468
|
+
|
|
469
|
+
const imeEnterEvent = {
|
|
470
|
+
key: 'Enter',
|
|
471
|
+
preventDefault: vi.fn(),
|
|
472
|
+
nativeEvent: {isComposing: true} as unknown as KeyboardEvent,
|
|
473
|
+
} as unknown as React.KeyboardEvent;
|
|
474
|
+
|
|
475
|
+
let consumed = false;
|
|
476
|
+
act(() => {
|
|
477
|
+
consumed = result.current.handleKeyDown(imeEnterEvent);
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
expect(consumed).toBe(false);
|
|
481
|
+
expect(imeEnterEvent.preventDefault).not.toHaveBeenCalled();
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
// 16. Guard stale results: older query search cannot overwrite newer results or be pickable
|
|
485
|
+
it('16. guards against stale search results from in-flight queries', async () => {
|
|
486
|
+
let resolveFirstSearch: (items: SearchableItem[]) => void = () => {};
|
|
487
|
+
let resolveSecondSearch: (items: SearchableItem[]) => void = () => {};
|
|
488
|
+
|
|
489
|
+
const asyncSource: SearchSource = {
|
|
490
|
+
async search(query: string) {
|
|
491
|
+
if (query === 'C') {
|
|
492
|
+
return new Promise(resolve => {
|
|
493
|
+
resolveFirstSearch = resolve;
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
if (query === 'Ci') {
|
|
497
|
+
return new Promise(resolve => {
|
|
498
|
+
resolveSecondSearch = resolve;
|
|
499
|
+
});
|
|
500
|
+
}
|
|
501
|
+
return [];
|
|
502
|
+
},
|
|
503
|
+
bootstrap: async () => [],
|
|
504
|
+
};
|
|
505
|
+
|
|
506
|
+
const asyncTrigger: ChatComposerTrigger = {
|
|
507
|
+
character: '@',
|
|
508
|
+
searchSource: asyncSource,
|
|
509
|
+
onSelect: item => `@${item.id}`,
|
|
510
|
+
hasMultiWordQuery: true,
|
|
511
|
+
};
|
|
512
|
+
|
|
513
|
+
const {result} = renderTriggerHook([asyncTrigger]);
|
|
514
|
+
|
|
515
|
+
// Type @C (search 1 starts)
|
|
516
|
+
await act(async () => {
|
|
517
|
+
setCursor('@C');
|
|
518
|
+
result.current.handleInput();
|
|
519
|
+
});
|
|
520
|
+
expect(result.current.state.isLoading).toBe(true);
|
|
521
|
+
expect(result.current.state.items).toEqual([]);
|
|
522
|
+
|
|
523
|
+
// Type @Ci (search 2 starts)
|
|
524
|
+
await act(async () => {
|
|
525
|
+
setCursor('@Ci');
|
|
526
|
+
result.current.handleInput();
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
// Older search 1 completes later with stale data
|
|
530
|
+
await act(async () => {
|
|
531
|
+
resolveFirstSearch([{id: 'stale', label: 'Stale Item'}]);
|
|
532
|
+
await Promise.resolve();
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
// Stale result should be ignored
|
|
536
|
+
expect(result.current.state.items).toEqual([]);
|
|
537
|
+
|
|
538
|
+
// Newer search 2 completes
|
|
539
|
+
await act(async () => {
|
|
540
|
+
resolveSecondSearch([{id: 'cindy', label: 'Cindy Zhang'}]);
|
|
541
|
+
await Promise.resolve();
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
expect(result.current.state.items).toEqual([
|
|
545
|
+
{id: 'cindy', label: 'Cindy Zhang'},
|
|
546
|
+
]);
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
// 17. Escape keeps menu hidden while same query keeps growing
|
|
550
|
+
it('17. keeps menu hidden after Escape while that same query keeps growing', async () => {
|
|
551
|
+
const {result} = renderTriggerHook();
|
|
552
|
+
|
|
553
|
+
// Type @Cindy -> active
|
|
554
|
+
await act(async () => {
|
|
555
|
+
setCursor('@Cindy');
|
|
556
|
+
result.current.handleInput();
|
|
557
|
+
await Promise.resolve();
|
|
558
|
+
});
|
|
559
|
+
expect(result.current.state.isActive).toBe(true);
|
|
560
|
+
|
|
561
|
+
// Press Escape -> dismissed
|
|
562
|
+
const escapeEvent = {
|
|
563
|
+
key: 'Escape',
|
|
564
|
+
preventDefault: vi.fn(),
|
|
565
|
+
nativeEvent: {} as KeyboardEvent,
|
|
566
|
+
} as unknown as React.KeyboardEvent;
|
|
567
|
+
|
|
568
|
+
act(() => {
|
|
569
|
+
result.current.handleKeyDown(escapeEvent);
|
|
570
|
+
});
|
|
571
|
+
expect(result.current.state.isActive).toBe(false);
|
|
572
|
+
|
|
573
|
+
// User continues typing in the same query "@Cindy " -> stays hidden
|
|
574
|
+
await act(async () => {
|
|
575
|
+
setCursor('@Cindy ');
|
|
576
|
+
result.current.handleInput();
|
|
577
|
+
await Promise.resolve();
|
|
578
|
+
});
|
|
579
|
+
expect(result.current.state.isActive).toBe(false);
|
|
580
|
+
|
|
581
|
+
// User continues typing in the same query "@Cindy Zhang" -> stays hidden
|
|
582
|
+
await act(async () => {
|
|
583
|
+
setCursor('@Cindy Zhang');
|
|
584
|
+
result.current.handleInput();
|
|
585
|
+
await Promise.resolve();
|
|
586
|
+
});
|
|
587
|
+
expect(result.current.state.isActive).toBe(false);
|
|
588
|
+
|
|
589
|
+
// User backspaces past the Escape point to "@Cin" -> reactivates
|
|
590
|
+
await act(async () => {
|
|
591
|
+
setCursor('@Cin');
|
|
592
|
+
result.current.handleInput();
|
|
593
|
+
await Promise.resolve();
|
|
594
|
+
});
|
|
595
|
+
expect(result.current.state.isActive).toBe(true);
|
|
596
|
+
expect(result.current.state.query).toBe('Cin');
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
// 18. Enter while newer search is pending does not pick previous query's item
|
|
600
|
+
it('18. does not pick a row on Enter while a newer search is pending (isLoading)', async () => {
|
|
601
|
+
let resolveFirstSearch: (items: SearchableItem[]) => void = () => {};
|
|
602
|
+
let resolveSecondSearch: (items: SearchableItem[]) => void = () => {};
|
|
603
|
+
|
|
604
|
+
const asyncSource: SearchSource = {
|
|
605
|
+
async search(query: string) {
|
|
606
|
+
if (query === 'C') {
|
|
607
|
+
return new Promise(resolve => {
|
|
608
|
+
resolveFirstSearch = resolve;
|
|
609
|
+
});
|
|
610
|
+
}
|
|
611
|
+
if (query === 'Ci') {
|
|
612
|
+
return new Promise(resolve => {
|
|
613
|
+
resolveSecondSearch = resolve;
|
|
614
|
+
});
|
|
615
|
+
}
|
|
616
|
+
return [];
|
|
617
|
+
},
|
|
618
|
+
bootstrap: async () => [],
|
|
619
|
+
};
|
|
620
|
+
|
|
621
|
+
const asyncTrigger: ChatComposerTrigger = {
|
|
622
|
+
character: '@',
|
|
623
|
+
searchSource: asyncSource,
|
|
624
|
+
onSelect: vi.fn(item => `@${item.id}`),
|
|
625
|
+
hasMultiWordQuery: true,
|
|
626
|
+
};
|
|
627
|
+
|
|
628
|
+
const {result} = renderTriggerHook([asyncTrigger]);
|
|
629
|
+
|
|
630
|
+
// Type @C (search 1 starts)
|
|
631
|
+
await act(async () => {
|
|
632
|
+
setCursor('@C');
|
|
633
|
+
result.current.handleInput();
|
|
634
|
+
});
|
|
635
|
+
|
|
636
|
+
// Resolve search 1 -> rows populated
|
|
637
|
+
await act(async () => {
|
|
638
|
+
resolveFirstSearch([{id: 'cindy', label: 'Cindy Zhang'}]);
|
|
639
|
+
await Promise.resolve();
|
|
640
|
+
});
|
|
641
|
+
expect(result.current.state.items.length).toBe(1);
|
|
642
|
+
expect(result.current.state.isLoading).toBe(false);
|
|
643
|
+
|
|
644
|
+
// Type @Ci (search 2 starts, isLoading becomes true, older items preserved in state)
|
|
645
|
+
await act(async () => {
|
|
646
|
+
setCursor('@Ci');
|
|
647
|
+
result.current.handleInput();
|
|
648
|
+
});
|
|
649
|
+
expect(result.current.state.isLoading).toBe(true);
|
|
650
|
+
|
|
651
|
+
// Press Enter while newer search is still loading
|
|
652
|
+
const enterEvent = {
|
|
653
|
+
key: 'Enter',
|
|
654
|
+
preventDefault: vi.fn(),
|
|
655
|
+
nativeEvent: {} as KeyboardEvent,
|
|
656
|
+
} as unknown as React.KeyboardEvent;
|
|
657
|
+
|
|
658
|
+
let consumed = false;
|
|
659
|
+
act(() => {
|
|
660
|
+
consumed = result.current.handleKeyDown(enterEvent);
|
|
661
|
+
});
|
|
662
|
+
|
|
663
|
+
// Enter picks no row from the earlier query and passes through, so the
|
|
664
|
+
// composer sends the message as typed (component:ChatComposerInput FR13).
|
|
665
|
+
expect(consumed).toBe(false);
|
|
666
|
+
expect(enterEvent.preventDefault).not.toHaveBeenCalled();
|
|
667
|
+
expect(asyncTrigger.onSelect).not.toHaveBeenCalled();
|
|
668
|
+
|
|
669
|
+
// Now resolve search 2
|
|
670
|
+
await act(async () => {
|
|
671
|
+
resolveSecondSearch([{id: 'cindy_new', label: 'Cindy New'}]);
|
|
672
|
+
await Promise.resolve();
|
|
673
|
+
});
|
|
674
|
+
expect(result.current.state.isLoading).toBe(false);
|
|
675
|
+
});
|
|
676
|
+
|
|
677
|
+
// 19. Arrows are not swallowed when multi-word no-matches menu is hidden
|
|
678
|
+
it('19. does not swallow ArrowDown or ArrowUp when multi-word query has no matches and menu is hidden', async () => {
|
|
679
|
+
const {result} = renderTriggerHook();
|
|
680
|
+
|
|
681
|
+
// Type query with no matches -> menu is hidden (renderMenu is null, isMenuVisible is false)
|
|
682
|
+
await act(async () => {
|
|
683
|
+
setCursor('@UnknownNameThatDoesNotMatch');
|
|
684
|
+
result.current.handleInput();
|
|
685
|
+
await Promise.resolve();
|
|
686
|
+
});
|
|
687
|
+
|
|
688
|
+
expect(result.current.state.isActive).toBe(true);
|
|
689
|
+
expect(result.current.state.items.length).toBe(0);
|
|
690
|
+
expect(result.current.renderMenu()).toBeNull();
|
|
691
|
+
|
|
692
|
+
const arrowDownEvent = {
|
|
693
|
+
key: 'ArrowDown',
|
|
694
|
+
preventDefault: vi.fn(),
|
|
695
|
+
nativeEvent: {} as KeyboardEvent,
|
|
696
|
+
} as unknown as React.KeyboardEvent;
|
|
697
|
+
|
|
698
|
+
let consumedDown = false;
|
|
699
|
+
act(() => {
|
|
700
|
+
consumedDown = result.current.handleKeyDown(arrowDownEvent);
|
|
701
|
+
});
|
|
702
|
+
|
|
703
|
+
expect(consumedDown).toBe(false);
|
|
704
|
+
expect(arrowDownEvent.preventDefault).not.toHaveBeenCalled();
|
|
705
|
+
|
|
706
|
+
const arrowUpEvent = {
|
|
707
|
+
key: 'ArrowUp',
|
|
708
|
+
preventDefault: vi.fn(),
|
|
709
|
+
nativeEvent: {} as KeyboardEvent,
|
|
710
|
+
} as unknown as React.KeyboardEvent;
|
|
711
|
+
|
|
712
|
+
let consumedUp = false;
|
|
713
|
+
act(() => {
|
|
714
|
+
consumedUp = result.current.handleKeyDown(arrowUpEvent);
|
|
715
|
+
});
|
|
716
|
+
|
|
717
|
+
expect(consumedUp).toBe(false);
|
|
718
|
+
expect(arrowUpEvent.preventDefault).not.toHaveBeenCalled();
|
|
719
|
+
});
|
|
720
|
+
|
|
721
|
+
// 20. Stale search resolving after Escape does not populate items or reopen menu
|
|
722
|
+
it('20. ignores async search results that resolve after Escape dismisses the menu', async () => {
|
|
723
|
+
let resolveSearch: (items: SearchableItem[]) => void = () => {};
|
|
724
|
+
|
|
725
|
+
const asyncSource: SearchSource = {
|
|
726
|
+
async search(query: string) {
|
|
727
|
+
if (query === 'C') {
|
|
728
|
+
return new Promise(resolve => {
|
|
729
|
+
resolveSearch = resolve;
|
|
730
|
+
});
|
|
731
|
+
}
|
|
732
|
+
return [];
|
|
733
|
+
},
|
|
734
|
+
bootstrap: async () => [],
|
|
735
|
+
};
|
|
736
|
+
|
|
737
|
+
const asyncTrigger: ChatComposerTrigger = {
|
|
738
|
+
character: '@',
|
|
739
|
+
searchSource: asyncSource,
|
|
740
|
+
onSelect: item => `@${item.id}`,
|
|
741
|
+
hasMultiWordQuery: true,
|
|
742
|
+
};
|
|
743
|
+
|
|
744
|
+
const {result} = renderTriggerHook([asyncTrigger]);
|
|
745
|
+
|
|
746
|
+
// Type @C -> search starts
|
|
747
|
+
await act(async () => {
|
|
748
|
+
setCursor('@C');
|
|
749
|
+
result.current.handleInput();
|
|
750
|
+
});
|
|
751
|
+
expect(result.current.state.isActive).toBe(true);
|
|
752
|
+
expect(result.current.state.isLoading).toBe(true);
|
|
753
|
+
|
|
754
|
+
// Press Escape -> reset called, state inactive
|
|
755
|
+
const escapeEvent = {
|
|
756
|
+
key: 'Escape',
|
|
757
|
+
preventDefault: vi.fn(),
|
|
758
|
+
nativeEvent: {} as KeyboardEvent,
|
|
759
|
+
} as unknown as React.KeyboardEvent;
|
|
760
|
+
|
|
761
|
+
act(() => {
|
|
762
|
+
result.current.handleKeyDown(escapeEvent);
|
|
763
|
+
});
|
|
764
|
+
expect(result.current.state.isActive).toBe(false);
|
|
765
|
+
|
|
766
|
+
// Later, the pending search resolves
|
|
767
|
+
await act(async () => {
|
|
768
|
+
resolveSearch([{id: 'cindy', label: 'Cindy Zhang'}]);
|
|
769
|
+
await Promise.resolve();
|
|
770
|
+
});
|
|
771
|
+
|
|
772
|
+
// Results must be dropped and state must remain clean and empty
|
|
773
|
+
expect(result.current.state.isActive).toBe(false);
|
|
774
|
+
expect(result.current.state.items).toEqual([]);
|
|
775
|
+
expect(result.current.state.isLoading).toBe(false);
|
|
776
|
+
});
|
|
777
|
+
|
|
778
|
+
function keyEvent(key: string, extra?: Partial<React.KeyboardEvent>) {
|
|
779
|
+
return {
|
|
780
|
+
key,
|
|
781
|
+
shiftKey: false,
|
|
782
|
+
preventDefault: vi.fn(),
|
|
783
|
+
nativeEvent: {} as KeyboardEvent,
|
|
784
|
+
...extra,
|
|
785
|
+
} as unknown as React.KeyboardEvent;
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
function deferredSource() {
|
|
789
|
+
const pending = new Map<string, (items: SearchableItem[]) => void>();
|
|
790
|
+
const source: SearchSource = {
|
|
791
|
+
async search(query: string) {
|
|
792
|
+
return new Promise<SearchableItem[]>(resolve => {
|
|
793
|
+
pending.set(query, resolve);
|
|
794
|
+
});
|
|
795
|
+
},
|
|
796
|
+
bootstrap: async () => [],
|
|
797
|
+
};
|
|
798
|
+
return {
|
|
799
|
+
source,
|
|
800
|
+
resolve: (q: string, items: SearchableItem[]) => pending.get(q)?.(items),
|
|
801
|
+
};
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
// 21. Pending-search Enter applies to triggers that never opted in.
|
|
805
|
+
it('21. Enter passes through and picks no row while a single-token trigger search is pending', async () => {
|
|
806
|
+
const {source, resolve} = deferredSource();
|
|
807
|
+
const onSelect = vi.fn((item: SearchableItem) => `/${item.id} `);
|
|
808
|
+
const trigger = createCommandTrigger({searchSource: source, onSelect});
|
|
809
|
+
const {result} = renderTriggerHook([trigger]);
|
|
810
|
+
|
|
811
|
+
await act(async () => {
|
|
812
|
+
setCursor('/s');
|
|
813
|
+
result.current.handleInput();
|
|
814
|
+
});
|
|
815
|
+
await act(async () => {
|
|
816
|
+
resolve('s', [{id: 'summarize', label: 'summarize'}]);
|
|
817
|
+
await Promise.resolve();
|
|
818
|
+
});
|
|
819
|
+
await act(async () => {
|
|
820
|
+
setCursor('/su');
|
|
821
|
+
result.current.handleInput();
|
|
822
|
+
});
|
|
823
|
+
expect(result.current.state.isLoading).toBe(true);
|
|
824
|
+
|
|
825
|
+
const enter = keyEvent('Enter');
|
|
826
|
+
let consumed = false;
|
|
827
|
+
act(() => {
|
|
828
|
+
consumed = result.current.handleKeyDown(enter);
|
|
829
|
+
});
|
|
830
|
+
expect(consumed).toBe(false);
|
|
831
|
+
expect(enter.preventDefault).not.toHaveBeenCalled();
|
|
832
|
+
expect(onSelect).not.toHaveBeenCalled();
|
|
833
|
+
|
|
834
|
+
// Tab has nothing current to pick either.
|
|
835
|
+
const tab = keyEvent('Tab');
|
|
836
|
+
act(() => {
|
|
837
|
+
consumed = result.current.handleKeyDown(tab);
|
|
838
|
+
});
|
|
839
|
+
expect(consumed).toBe(false);
|
|
840
|
+
expect(onSelect).not.toHaveBeenCalled();
|
|
841
|
+
|
|
842
|
+
// Arrows stay with the menu but cannot build a selection on stale rows.
|
|
843
|
+
const down = keyEvent('ArrowDown');
|
|
844
|
+
act(() => {
|
|
845
|
+
consumed = result.current.handleKeyDown(down);
|
|
846
|
+
});
|
|
847
|
+
expect(consumed).toBe(true);
|
|
848
|
+
expect(result.current.state.highlightedIndex).toBe(0);
|
|
849
|
+
|
|
850
|
+
// Shift+Enter keeps its editor meaning (a line break ends the query).
|
|
851
|
+
const shiftEnter = keyEvent('Enter', {shiftKey: true});
|
|
852
|
+
act(() => {
|
|
853
|
+
consumed = result.current.handleKeyDown(shiftEnter);
|
|
854
|
+
});
|
|
855
|
+
expect(consumed).toBe(false);
|
|
856
|
+
});
|
|
857
|
+
|
|
858
|
+
// 22. A hidden menu does not swallow Escape.
|
|
859
|
+
it('22. does not consume Escape while an unmatched multi-word query hides the menu', async () => {
|
|
860
|
+
const {result} = renderTriggerHook();
|
|
861
|
+
|
|
862
|
+
await act(async () => {
|
|
863
|
+
setCursor('@team please review');
|
|
864
|
+
result.current.handleInput();
|
|
865
|
+
await Promise.resolve();
|
|
866
|
+
});
|
|
867
|
+
expect(result.current.state.isActive).toBe(true);
|
|
868
|
+
expect(result.current.renderMenu()).toBeNull();
|
|
869
|
+
|
|
870
|
+
const escape = keyEvent('Escape');
|
|
871
|
+
let consumed = true;
|
|
872
|
+
act(() => {
|
|
873
|
+
consumed = result.current.handleKeyDown(escape);
|
|
874
|
+
});
|
|
875
|
+
expect(consumed).toBe(false);
|
|
876
|
+
expect(escape.preventDefault).not.toHaveBeenCalled();
|
|
877
|
+
});
|
|
878
|
+
|
|
879
|
+
// 23. A result arriving after its query ended does not reopen the menu.
|
|
880
|
+
it('23. discards a result that arrives after the editor no longer holds the query', async () => {
|
|
881
|
+
const {source, resolve} = deferredSource();
|
|
882
|
+
const {result} = renderTriggerHook([
|
|
883
|
+
createMentionTrigger({searchSource: source}),
|
|
884
|
+
]);
|
|
885
|
+
|
|
886
|
+
await act(async () => {
|
|
887
|
+
setCursor('@jor');
|
|
888
|
+
result.current.handleInput();
|
|
889
|
+
});
|
|
890
|
+
expect(result.current.state.isActive).toBe(true);
|
|
891
|
+
|
|
892
|
+
// The composer clears the editor on submit without an input event.
|
|
893
|
+
act(() => {
|
|
894
|
+
editable.textContent = '';
|
|
895
|
+
});
|
|
896
|
+
await act(async () => {
|
|
897
|
+
resolve('jor', [{id: 'jordan', label: 'Jordan Lee'}]);
|
|
898
|
+
await Promise.resolve();
|
|
899
|
+
});
|
|
900
|
+
|
|
901
|
+
expect(result.current.state.isActive).toBe(false);
|
|
902
|
+
expect(result.current.state.items).toEqual([]);
|
|
903
|
+
expect(result.current.renderMenu()).toBeNull();
|
|
904
|
+
});
|
|
905
|
+
|
|
906
|
+
// 24. FR11: another trigger's character inside a multi-word query is text.
|
|
907
|
+
it('24. continues a multi-word query through another trigger character', async () => {
|
|
908
|
+
const {result} = renderTriggerHook();
|
|
909
|
+
|
|
910
|
+
await act(async () => {
|
|
911
|
+
setCursor('@jordan /s');
|
|
912
|
+
result.current.handleInput();
|
|
913
|
+
await Promise.resolve();
|
|
914
|
+
});
|
|
915
|
+
expect(result.current.state.activeTrigger?.character).toBe('@');
|
|
916
|
+
expect(result.current.state.query).toBe('jordan /s');
|
|
917
|
+
});
|
|
918
|
+
|
|
919
|
+
// 25. FR11: the same trigger's character after a boundary starts a new query.
|
|
920
|
+
it('25. restarts a multi-word query at the same trigger character after a space', async () => {
|
|
921
|
+
const {result} = renderTriggerHook();
|
|
922
|
+
|
|
923
|
+
await act(async () => {
|
|
924
|
+
setCursor('@jordan @sa');
|
|
925
|
+
result.current.handleInput();
|
|
926
|
+
await Promise.resolve();
|
|
927
|
+
});
|
|
928
|
+
expect(result.current.state.activeTrigger?.character).toBe('@');
|
|
929
|
+
expect(result.current.state.query).toBe('sa');
|
|
930
|
+
});
|
|
931
|
+
|
|
932
|
+
// 26. FR10: with no open multi-word query, another trigger after a space activates.
|
|
933
|
+
it('26. activates a single-token trigger after a closed query', async () => {
|
|
934
|
+
const {result} = renderTriggerHook();
|
|
935
|
+
|
|
936
|
+
await act(async () => {
|
|
937
|
+
setCursor('hello /su');
|
|
938
|
+
result.current.handleInput();
|
|
939
|
+
await Promise.resolve();
|
|
940
|
+
});
|
|
941
|
+
expect(result.current.state.activeTrigger?.character).toBe('/');
|
|
942
|
+
expect(result.current.state.query).toBe('su');
|
|
943
|
+
});
|
|
944
|
+
|
|
945
|
+
// 26b. FR10: a tab is a boundary before a trigger and ends a single-token query.
|
|
946
|
+
it('26b. treats a tab as a trigger boundary and as the end of a single-token query', async () => {
|
|
947
|
+
const {result} = renderTriggerHook();
|
|
948
|
+
|
|
949
|
+
await act(async () => {
|
|
950
|
+
setCursor('hi\t/su');
|
|
951
|
+
result.current.handleInput();
|
|
952
|
+
await Promise.resolve();
|
|
953
|
+
});
|
|
954
|
+
expect(result.current.state.activeTrigger?.character).toBe('/');
|
|
955
|
+
expect(result.current.state.query).toBe('su');
|
|
956
|
+
|
|
957
|
+
await act(async () => {
|
|
958
|
+
setCursor('/su\tx');
|
|
959
|
+
result.current.handleInput();
|
|
960
|
+
await Promise.resolve();
|
|
961
|
+
});
|
|
962
|
+
expect(result.current.state.isActive).toBe(false);
|
|
963
|
+
});
|
|
964
|
+
|
|
965
|
+
// 27. A visible empty menu keeps the arrows.
|
|
966
|
+
it('27. keeps ArrowDown and ArrowUp while a visible menu shows its empty message', async () => {
|
|
967
|
+
const {result} = renderTriggerHook();
|
|
968
|
+
|
|
969
|
+
await act(async () => {
|
|
970
|
+
setCursor('/zzz');
|
|
971
|
+
result.current.handleInput();
|
|
972
|
+
await Promise.resolve();
|
|
973
|
+
});
|
|
974
|
+
expect(result.current.state.items).toEqual([]);
|
|
975
|
+
expect(result.current.renderMenu()).not.toBeNull();
|
|
976
|
+
|
|
977
|
+
for (const key of ['ArrowDown', 'ArrowUp']) {
|
|
978
|
+
const event = keyEvent(key);
|
|
979
|
+
let consumed = false;
|
|
980
|
+
act(() => {
|
|
981
|
+
consumed = result.current.handleKeyDown(event);
|
|
982
|
+
});
|
|
983
|
+
expect(consumed).toBe(true);
|
|
984
|
+
expect(event.preventDefault).toHaveBeenCalled();
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
// Enter has nothing to pick, so it keeps its editor meaning.
|
|
988
|
+
let consumedEnter = true;
|
|
989
|
+
act(() => {
|
|
990
|
+
consumedEnter = result.current.handleKeyDown(keyEvent('Enter'));
|
|
991
|
+
});
|
|
992
|
+
expect(consumedEnter).toBe(false);
|
|
993
|
+
});
|
|
994
|
+
});
|