@vvedantb/ui 0.3.2 → 0.4.0-rc.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.
Files changed (31) hide show
  1. package/README.md +66 -19
  2. package/app/README.md +8 -8
  3. package/package.json +5 -30
  4. package/src/composer/Composer.tsx +1 -0
  5. package/src/composer/index.ts +1 -0
  6. package/src/composer/input-area/ComposerSlashMenu.tsx +137 -0
  7. package/src/composer/input-area/ComposerSlashRow.tsx +75 -0
  8. package/src/composer/input-area/ConnectionStatusMenuItem.tsx +34 -0
  9. package/src/composer/input-area/FadePresence.tsx +31 -0
  10. package/src/composer/input-area/InputActions.tsx +63 -0
  11. package/src/composer/input-area/InputArea.tsx +513 -0
  12. package/src/composer/input-area/InputBanners.tsx +61 -0
  13. package/src/composer/input-area/PromoBanner.tsx +53 -0
  14. package/src/composer/input-area/composerCompact.ts +52 -0
  15. package/src/composer/input-area/composerPrediction.ts +51 -0
  16. package/src/composer/input-area/composerSlashItems.ts +206 -0
  17. package/src/composer/input-area/composerSlashOptionId.ts +7 -0
  18. package/src/composer/input-area/composerSlashPosition.ts +40 -0
  19. package/src/composer/input-area/composerSlashTrigger.ts +50 -0
  20. package/src/composer/input-area/index.ts +30 -0
  21. package/src/composer/input-area/inputPresets.ts +14 -0
  22. package/src/composer/input-area/motion.ts +7 -0
  23. package/src/composer/input-area/types.ts +25 -0
  24. package/src/composer/input-area/useComposerCompact.ts +46 -0
  25. package/src/composer/input-area/useComposerSlashMenu.ts +203 -0
  26. package/src/composer/input-area/useInputAreaPrediction.ts +49 -0
  27. package/src/index.ts +21 -0
  28. package/src/ui/button.tsx +11 -2
  29. package/src/ui/dropdown-menu.tsx +35 -7
  30. package/tokens/README.md +2 -2
  31. package/src/sonner.ts +0 -2
@@ -0,0 +1,51 @@
1
+ /** Trailing-edge debounce: how long the user must pause before asking the model. */
2
+ const PREDICTION_IDLE_MS = 500;
3
+
4
+ /** Below this, there is not enough text to continue usefully. */
5
+ const PREDICTION_MIN_CHARS = 12;
6
+
7
+ /**
8
+ * True when no completion should be requested for this draft. Skips short
9
+ * drafts, in-progress `@` or `/` tokens (the slash menu owns those), and text
10
+ * that already ends a sentence.
11
+ */
12
+ export function shouldSkipPrediction(value: string): boolean {
13
+ if (value.trim().length < PREDICTION_MIN_CHARS) return true;
14
+ const lastWord = value.split(/\s/).pop() ?? "";
15
+ if (lastWord.startsWith("@") || lastWord.startsWith("/")) return true;
16
+ return /[.!?]\s*$/.test(value);
17
+ }
18
+
19
+ /**
20
+ * The model returns a bare continuation. Add the joining space here so the
21
+ * ghost text matches what accepting it produces.
22
+ */
23
+ export function ghostSuggestion(value: string, completion: string): string {
24
+ return /\s$/.test(value) || /^\s/.test(completion)
25
+ ? completion
26
+ : ` ${completion}`;
27
+ }
28
+
29
+ /**
30
+ * Waits for a typing pause, then asks for a completion. Returns a cancel
31
+ * function that clears the timer; a late reply is dropped once cancelled.
32
+ */
33
+ export function scheduleCompletion(
34
+ value: string,
35
+ complete: (text: string) => Promise<string>,
36
+ onResult: (completion: string) => void,
37
+ idleMs: number = PREDICTION_IDLE_MS,
38
+ ): () => void {
39
+ let cancelled = false;
40
+ const timer = setTimeout(() => {
41
+ complete(value)
42
+ .then((completion) => {
43
+ if (!cancelled && completion) onResult(completion);
44
+ })
45
+ .catch(console.error);
46
+ }, idleMs);
47
+ return () => {
48
+ cancelled = true;
49
+ clearTimeout(timer);
50
+ };
51
+ }
@@ -0,0 +1,206 @@
1
+ import type { ActionFeatures, BannerFeatures, SkillChipPreview } from "./types";
2
+ import { matchesSlashQuery } from "./composerSlashTrigger";
3
+
4
+ export type SlashGroup = "Actions" | "Skills";
5
+
6
+ const SLASH_GROUPS: ReadonlyArray<SlashGroup> = ["Actions", "Skills"];
7
+
8
+ export type SlashIconKey = "plus" | "paperclip" | "lock" | "book" | "file";
9
+
10
+ type SlashActionKey =
11
+ | "new-chat"
12
+ | "attach"
13
+ | "privacy"
14
+ | "artifacts"
15
+ | "new-artifact";
16
+
17
+ type SlashItemBase = {
18
+ id: string;
19
+ label: string;
20
+ description?: string;
21
+ group: SlashGroup;
22
+ icon: SlashIconKey;
23
+ keywords: string[];
24
+ active?: boolean;
25
+ badge?: string;
26
+ preview?: SkillChipPreview;
27
+ };
28
+
29
+ export type ComposerSlashItem =
30
+ | (SlashItemBase & { kind: "action"; action: SlashActionKey })
31
+ | (SlashItemBase & { kind: "skill-hub"; skillId: string })
32
+ | (SlashItemBase & { kind: "skill-manual"; skillId: string });
33
+
34
+ type HubSkill = {
35
+ _id: string;
36
+ name: string;
37
+ description: string;
38
+ slug: string;
39
+ icon?: string;
40
+ kind?: "style" | "persona";
41
+ installed: boolean;
42
+ enabled: boolean;
43
+ };
44
+
45
+ type ManualSkill = {
46
+ _id: string;
47
+ name: string;
48
+ description: string;
49
+ activation: string;
50
+ };
51
+
52
+ export function buildComposerSlashItems(args: {
53
+ actionFeatures: ActionFeatures;
54
+ bannerFeatures: BannerFeatures;
55
+ showFileUpload: boolean;
56
+ includeNewChat: boolean;
57
+ hubSkills: HubSkill[];
58
+ manualSkills: ManualSkill[];
59
+ selectedManualIds: ReadonlyArray<string>;
60
+ extraItems?: ComposerSlashItem[];
61
+ includeArtifacts?: boolean;
62
+ }): ComposerSlashItem[] {
63
+ const actions: ComposerSlashItem[] = [];
64
+ if (args.includeNewChat) {
65
+ actions.push({
66
+ id: "action:new-chat",
67
+ label: "new-chat",
68
+ description: "Start a new conversation",
69
+ group: "Actions",
70
+ kind: "action",
71
+ action: "new-chat",
72
+ icon: "plus",
73
+ keywords: ["new", "chat", "reset"],
74
+ });
75
+ }
76
+ if (args.showFileUpload) {
77
+ actions.push({
78
+ id: "action:attach",
79
+ label: "attach",
80
+ description: "Attach a file or image",
81
+ group: "Actions",
82
+ kind: "action",
83
+ action: "attach",
84
+ icon: "paperclip",
85
+ keywords: ["file", "upload", "image", "pdf"],
86
+ });
87
+ }
88
+ actions.push(
89
+ toggleAction(
90
+ "privacy",
91
+ "private",
92
+ "Temporary chat that stays out of the sidebar",
93
+ "lock",
94
+ ["privacy", "temporary"],
95
+ args.bannerFeatures.privacy,
96
+ ),
97
+ );
98
+ if (args.includeArtifacts !== false) {
99
+ actions.push({
100
+ id: "action:artifacts",
101
+ label: "artifacts",
102
+ description: "Open your Artifacts library",
103
+ group: "Actions",
104
+ kind: "action",
105
+ action: "artifacts",
106
+ icon: "file",
107
+ keywords: ["artifact", "document", "html"],
108
+ });
109
+ actions.push({
110
+ id: "action:new-artifact",
111
+ label: "new-artifact",
112
+ description: "Create a new artifact",
113
+ group: "Actions",
114
+ kind: "action",
115
+ action: "new-artifact",
116
+ icon: "file",
117
+ keywords: ["artifact", "create", "new"],
118
+ });
119
+ }
120
+
121
+ const hubItems: ComposerSlashItem[] = args.hubSkills
122
+ .filter((skill) => skill.installed)
123
+ .map((skill) => ({
124
+ id: `skill-hub:${skill._id}`,
125
+ label: skill.slug || skill.name,
126
+ description: skill.description,
127
+ group: "Skills",
128
+ kind: "skill-hub",
129
+ skillId: skill._id,
130
+ icon: "book",
131
+ keywords: [skill.name, skill.slug],
132
+ active: skill.enabled,
133
+ badge: "Skill",
134
+ preview: {
135
+ id: skill._id,
136
+ kind: "hub",
137
+ name: skill.name,
138
+ description: skill.description,
139
+ icon: skill.icon,
140
+ sourceLabel: skill.kind === "persona" ? "Persona" : "Hub",
141
+ installed: skill.installed,
142
+ enabled: skill.enabled,
143
+ },
144
+ }));
145
+ const manualItems: ComposerSlashItem[] = args.manualSkills
146
+ .filter((skill) => skill.activation === "manual")
147
+ .map((skill) => ({
148
+ id: `skill-manual:${skill._id}`,
149
+ label: skill.name,
150
+ description: skill.description,
151
+ group: "Skills",
152
+ kind: "skill-manual",
153
+ skillId: skill._id,
154
+ icon: "book",
155
+ keywords: [skill.name],
156
+ active: args.selectedManualIds.includes(skill._id),
157
+ badge: "Manual",
158
+ preview: {
159
+ id: skill._id,
160
+ kind: "manual",
161
+ name: skill.name,
162
+ description: skill.description,
163
+ sourceLabel: "Custom",
164
+ installed: true,
165
+ enabled: args.selectedManualIds.includes(skill._id),
166
+ },
167
+ }));
168
+ const skills = [...hubItems, ...manualItems].sort((a, b) =>
169
+ a.label.localeCompare(b.label),
170
+ );
171
+
172
+ const extras = args.extraItems ?? [];
173
+ return [...actions, ...skills, ...extras];
174
+ }
175
+
176
+ export function filterAndGroupSlashItems(
177
+ items: ComposerSlashItem[],
178
+ query: string,
179
+ ): Array<{ group: SlashGroup; items: ComposerSlashItem[] }> {
180
+ const filtered = items.filter((item) => matchesSlashQuery(item, query));
181
+ return SLASH_GROUPS.flatMap((group) => {
182
+ const groupItems = filtered.filter((item) => item.group === group);
183
+ return groupItems.length > 0 ? [{ group, items: groupItems }] : [];
184
+ });
185
+ }
186
+
187
+ function toggleAction(
188
+ action: SlashActionKey,
189
+ label: string,
190
+ description: string,
191
+ icon: SlashIconKey,
192
+ keywords: string[],
193
+ active: boolean,
194
+ ): ComposerSlashItem {
195
+ return {
196
+ id: `action:${action}`,
197
+ label,
198
+ description,
199
+ group: "Actions",
200
+ kind: "action",
201
+ action,
202
+ icon,
203
+ keywords,
204
+ active,
205
+ };
206
+ }
@@ -0,0 +1,7 @@
1
+ export function optionId(listboxId: string, itemId: string): string {
2
+ return `${toIdToken(listboxId)}-option-${toIdToken(itemId)}`;
3
+ }
4
+
5
+ function toIdToken(value: string): string {
6
+ return value.replaceAll(/[^A-Za-z0-9_-]/g, "-");
7
+ }
@@ -0,0 +1,40 @@
1
+ export interface ComposerSlashPlacement {
2
+ top: number;
3
+ left: number;
4
+ width: number;
5
+ placement: "above" | "below";
6
+ maxHeight: number;
7
+ }
8
+
9
+ const POPUP_GAP = 6;
10
+ const VIEWPORT_PADDING = 8;
11
+ const PANEL_MAX_HEIGHT = 340;
12
+ const PANEL_MIN_ABOVE = 200;
13
+
14
+ export function computePanelPopupPlacement(
15
+ anchor: DOMRect,
16
+ viewport: { width: number; height: number },
17
+ ): ComposerSlashPlacement {
18
+ const spaceAbove = anchor.top - VIEWPORT_PADDING - POPUP_GAP;
19
+ const spaceBelow =
20
+ viewport.height - anchor.bottom - VIEWPORT_PADDING - POPUP_GAP;
21
+ const placeAbove = spaceAbove >= PANEL_MIN_ABOVE || spaceAbove >= spaceBelow;
22
+ const placement = placeAbove ? "above" : "below";
23
+ const available = placeAbove ? spaceAbove : spaceBelow;
24
+
25
+ return {
26
+ top: placeAbove ? anchor.top - POPUP_GAP : anchor.bottom + POPUP_GAP,
27
+ left: anchor.left,
28
+ width: anchor.width,
29
+ placement,
30
+ maxHeight: Math.max(160, Math.min(PANEL_MAX_HEIGHT, available)),
31
+ };
32
+ }
33
+
34
+ export function viewportSize(): { width: number; height: number } {
35
+ const visual = window.visualViewport;
36
+ return {
37
+ width: visual?.width ?? window.innerWidth,
38
+ height: visual?.height ?? window.innerHeight,
39
+ };
40
+ }
@@ -0,0 +1,50 @@
1
+ export type SlashTrigger = {
2
+ query: string;
3
+ startIndex: number;
4
+ };
5
+
6
+ export function isValidSlashTrigger(
7
+ value: string,
8
+ triggerIndex: number,
9
+ ): boolean {
10
+ const textAfter = value.slice(triggerIndex + 1);
11
+ if (textAfter.includes("\n") || /\s/.test(textAfter)) {
12
+ return false;
13
+ }
14
+ const charBefore = triggerIndex > 0 ? value[triggerIndex - 1] : "";
15
+ return (
16
+ triggerIndex === 0 || (charBefore !== undefined && /\s/.test(charBefore))
17
+ );
18
+ }
19
+
20
+ export function findSlashTrigger(value: string): SlashTrigger | null {
21
+ const slashIndex = value.lastIndexOf("/");
22
+ if (slashIndex === -1 || !isValidSlashTrigger(value, slashIndex)) {
23
+ return null;
24
+ }
25
+ return {
26
+ query: value.slice(slashIndex + 1),
27
+ startIndex: slashIndex,
28
+ };
29
+ }
30
+
31
+ export function stripSlashTrigger(
32
+ value: string,
33
+ startIndex: number,
34
+ query: string,
35
+ ): string {
36
+ const before = value.slice(0, startIndex);
37
+ const after = value.slice(startIndex + 1 + query.length);
38
+ return `${before}${after}`;
39
+ }
40
+
41
+ export function matchesSlashQuery(
42
+ item: { label: string; description?: string; keywords: string[] },
43
+ query: string,
44
+ ): boolean {
45
+ const needle = query.toLowerCase();
46
+ if (needle.length === 0) return true;
47
+ const haystack =
48
+ `${item.label} ${item.description ?? ""} ${item.keywords.join(" ")}`.toLowerCase();
49
+ return haystack.includes(needle);
50
+ }
@@ -0,0 +1,30 @@
1
+ export { InputArea, DEFAULT_ATTACH_ACCEPT } from "./InputArea";
2
+ export type { InputAreaProps, InputAreaSlashContext } from "./InputArea";
3
+ export { InputActions } from "./InputActions";
4
+ export type { InputActionsProps } from "./InputActions";
5
+ export { ConnectionStatusMenuItem } from "./ConnectionStatusMenuItem";
6
+ export type { ConnectionStatusMenuItemProps } from "./ConnectionStatusMenuItem";
7
+ export { ComposerSlashMenu } from "./ComposerSlashMenu";
8
+ export { ComposerSlashRow } from "./ComposerSlashRow";
9
+ export { InputBanners, defaultInputBannerStyles } from "./InputBanners";
10
+ export type { InputBannersProps, InputBannerStyles } from "./InputBanners";
11
+ export { PromoBanner } from "./PromoBanner";
12
+ export type { PromoBannerProps } from "./PromoBanner";
13
+ export { FadePresence } from "./FadePresence";
14
+ export type { FadePresenceProps } from "./FadePresence";
15
+ export { motionFast } from "./motion";
16
+ export { isComposerCompact } from "./composerCompact";
17
+ export { useComposerCompact } from "./useComposerCompact";
18
+ export { ghostSuggestion, scheduleCompletion, shouldSkipPrediction } from "./composerPrediction";
19
+ export { useInputAreaPrediction } from "./useInputAreaPrediction";
20
+ export { buildComposerSlashItems, filterAndGroupSlashItems } from "./composerSlashItems";
21
+ export type { ComposerSlashItem, SlashGroup, SlashIconKey } from "./composerSlashItems";
22
+ export { findSlashTrigger, isValidSlashTrigger, matchesSlashQuery, stripSlashTrigger } from "./composerSlashTrigger";
23
+ export type { SlashTrigger } from "./composerSlashTrigger";
24
+ export { computePanelPopupPlacement, viewportSize } from "./composerSlashPosition";
25
+ export type { ComposerSlashPlacement } from "./composerSlashPosition";
26
+ export { optionId as composerSlashOptionId } from "./composerSlashOptionId";
27
+ export { useComposerSlashMenu } from "./useComposerSlashMenu";
28
+ export { MAIN_CHAT_ACTION_FEATURES, CONTEXT_CHAT_ACTION_FEATURES, CONTEXT_CHAT_DISPLAY_OPTIONS } from "./inputPresets";
29
+ export { DEFAULT_BANNER_FEATURES } from "./types";
30
+ export type { ActionFeatures, BannerFeatures, SkillChipPreview } from "./types";
@@ -0,0 +1,14 @@
1
+ import type { ActionFeatures } from "./types";
2
+
3
+ export const MAIN_CHAT_ACTION_FEATURES: ActionFeatures = {
4
+ dictate: true,
5
+ };
6
+
7
+ export const CONTEXT_CHAT_ACTION_FEATURES: ActionFeatures = {
8
+ dictate: true,
9
+ };
10
+
11
+ export const CONTEXT_CHAT_DISPLAY_OPTIONS = {
12
+ showBanners: true,
13
+ showUpgradeBanner: true,
14
+ } satisfies Record<string, boolean>;
@@ -0,0 +1,7 @@
1
+ import { motionDuration, motionEase } from "../../motion/presets";
2
+
3
+ /** Fast transition used by the stop button, slash menu and banners. */
4
+ export const motionFast = {
5
+ duration: motionDuration.fast,
6
+ ease: motionEase,
7
+ } as const;
@@ -0,0 +1,25 @@
1
+ /** Feature flags for the action row. Mirrors the shape the app presets use. */
2
+ export type ActionFeatures = {
3
+ dictate?: boolean;
4
+ };
5
+
6
+ /** Feature flags that show a banner above the input. */
7
+ export type BannerFeatures = {
8
+ privacy: boolean;
9
+ };
10
+
11
+ export const DEFAULT_BANNER_FEATURES: BannerFeatures = {
12
+ privacy: false,
13
+ };
14
+
15
+ /** Data for the hover preview of a skill row. The app renders it through `renderSlashItem`. */
16
+ export type SkillChipPreview = {
17
+ id: string;
18
+ kind: "hub" | "manual";
19
+ name: string;
20
+ description: string;
21
+ icon?: string;
22
+ sourceLabel: string;
23
+ installed: boolean;
24
+ enabled: boolean;
25
+ };
@@ -0,0 +1,46 @@
1
+ import { useRef, type RefObject } from "react";
2
+ import {
3
+ draftExceedsPillWidth,
4
+ editorOverflowsHorizontally,
5
+ isComposerCompact,
6
+ readEditorInnerWidth,
7
+ } from "./composerCompact";
8
+
9
+ export function useComposerCompact({
10
+ value,
11
+ fileCount,
12
+ chipCount,
13
+ }: {
14
+ value: string;
15
+ fileCount: number;
16
+ chipCount: number;
17
+ }): { chromeRef: RefObject<HTMLDivElement | null>; compact: boolean } {
18
+ "use no memo";
19
+ const chromeRef = useRef<HTMLDivElement>(null);
20
+ const pillWidthRef = useRef(0);
21
+ const wasCompactRef = useRef(true);
22
+
23
+ const editorNode =
24
+ chromeRef.current?.querySelector("[data-slot=input-group-control]") ??
25
+ chromeRef.current?.querySelector("textarea");
26
+ const editor = editorNode instanceof HTMLElement ? editorNode : null;
27
+ const font = editor ? getComputedStyle(editor).font : "14px sans-serif";
28
+ if (editor && wasCompactRef.current) {
29
+ const inner = readEditorInnerWidth(editor);
30
+ if (inner > 0) pillWidthRef.current = inner;
31
+ }
32
+ const exceedsPill =
33
+ (editor !== null &&
34
+ wasCompactRef.current &&
35
+ editorOverflowsHorizontally(editor)) ||
36
+ draftExceedsPillWidth(value, pillWidthRef.current, font);
37
+ const compact = isComposerCompact({
38
+ value,
39
+ fileCount,
40
+ chipCount,
41
+ exceedsPill,
42
+ });
43
+ wasCompactRef.current = compact;
44
+
45
+ return { chromeRef, compact };
46
+ }
@@ -0,0 +1,203 @@
1
+ import {
2
+ useEffect,
3
+ useId,
4
+ useState,
5
+ type KeyboardEvent,
6
+ type RefObject,
7
+ } from "react";
8
+ import type { ComposerSlashItem } from "./composerSlashItems";
9
+ import { filterAndGroupSlashItems } from "./composerSlashItems";
10
+ import { optionId } from "./composerSlashOptionId";
11
+ import {
12
+ computePanelPopupPlacement,
13
+ viewportSize,
14
+ type ComposerSlashPlacement,
15
+ } from "./composerSlashPosition";
16
+ import {
17
+ findSlashTrigger,
18
+ stripSlashTrigger,
19
+ type SlashTrigger,
20
+ } from "./composerSlashTrigger";
21
+
22
+ export function useComposerSlashMenu({
23
+ value,
24
+ onValueChange,
25
+ items,
26
+ textareaRef,
27
+ onSelect,
28
+ enabled = true,
29
+ }: {
30
+ value: string;
31
+ onValueChange: (value: string) => void;
32
+ items: ComposerSlashItem[];
33
+ textareaRef: RefObject<HTMLTextAreaElement | null>;
34
+ onSelect: (item: ComposerSlashItem) => void;
35
+ /** When false the menu never opens. */
36
+ enabled?: boolean;
37
+ }) {
38
+ const listboxId = useId();
39
+ const trigger = enabled ? findSlashTrigger(value) : null;
40
+ const [open, setOpen] = useState(false);
41
+ const [selectedIndex, setSelectedIndex] = useState(0);
42
+ const [placement, setPlacement] = useState<ComposerSlashPlacement | null>(
43
+ null,
44
+ );
45
+
46
+ useEffect(() => {
47
+ const next = enabled ? findSlashTrigger(value) : null;
48
+ if (next === null) {
49
+ setOpen(false);
50
+ return;
51
+ }
52
+ setOpen(true);
53
+ setSelectedIndex(0);
54
+ }, [value, enabled]);
55
+
56
+ const showPopup = open && trigger !== null;
57
+ const groups = filterAndGroupSlashItems(items, trigger?.query ?? "");
58
+ const visibleItems = groups.flatMap((group) => group.items);
59
+ const activeItem = showPopup ? visibleItems[selectedIndex] : undefined;
60
+
61
+ useEffect(() => {
62
+ if (!showPopup) {
63
+ setPlacement(null);
64
+ return;
65
+ }
66
+ let attached = false;
67
+ const update = () => {
68
+ if (document.visibilityState !== "visible") return;
69
+ requestAnimationFrame(() => {
70
+ const el = textareaRef.current;
71
+ if (!el) return;
72
+ const panelAnchor = el.closest("[data-composer-slash-anchor]");
73
+ const rect = panelAnchor
74
+ ? panelAnchor.getBoundingClientRect()
75
+ : el.getBoundingClientRect();
76
+ setPlacement(computePanelPopupPlacement(rect, viewportSize()));
77
+ });
78
+ };
79
+ const syncLayoutListeners = () => {
80
+ const shouldAttach = document.visibilityState === "visible";
81
+ if (shouldAttach && !attached) {
82
+ window.addEventListener("scroll", update, true);
83
+ window.addEventListener("resize", update);
84
+ attached = true;
85
+ } else if (!shouldAttach && attached) {
86
+ window.removeEventListener("scroll", update, true);
87
+ window.removeEventListener("resize", update);
88
+ attached = false;
89
+ }
90
+ };
91
+ const onVisibilityChange = () => {
92
+ syncLayoutListeners();
93
+ update();
94
+ };
95
+ update();
96
+ syncLayoutListeners();
97
+ document.addEventListener("visibilitychange", onVisibilityChange);
98
+ return () => {
99
+ document.removeEventListener("visibilitychange", onVisibilityChange);
100
+ if (attached) {
101
+ window.removeEventListener("scroll", update, true);
102
+ window.removeEventListener("resize", update);
103
+ }
104
+ };
105
+ }, [showPopup, trigger?.query, trigger?.startIndex, value, textareaRef]);
106
+
107
+ const close = () => {
108
+ setOpen(false);
109
+ setSelectedIndex(0);
110
+ };
111
+
112
+ const applyItem = (item: ComposerSlashItem, current: SlashTrigger) => {
113
+ onValueChange(stripSlashTrigger(value, current.startIndex, current.query));
114
+ close();
115
+ onSelect(item);
116
+ requestAnimationFrame(() => textareaRef.current?.focus());
117
+ };
118
+
119
+ const insertActiveItem = () => {
120
+ if (trigger === null) return;
121
+ const item = visibleItems[selectedIndex];
122
+ if (item) applyItem(item, trigger);
123
+ };
124
+
125
+ const onKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>): boolean => {
126
+ if (!showPopup) return false;
127
+ if (visibleItems.length > 0) {
128
+ if (event.key === "ArrowDown") {
129
+ event.preventDefault();
130
+ setSelectedIndex((prev) =>
131
+ prev >= visibleItems.length - 1 ? 0 : prev + 1,
132
+ );
133
+ return true;
134
+ }
135
+ if (event.key === "ArrowUp") {
136
+ event.preventDefault();
137
+ setSelectedIndex((prev) =>
138
+ prev <= 0 ? visibleItems.length - 1 : prev - 1,
139
+ );
140
+ return true;
141
+ }
142
+ if (event.key === "Enter") {
143
+ if (event.nativeEvent.isComposing) return true;
144
+ event.preventDefault();
145
+ event.stopPropagation();
146
+ insertActiveItem();
147
+ return true;
148
+ }
149
+ if (event.key === "Tab") {
150
+ event.preventDefault();
151
+ insertActiveItem();
152
+ return true;
153
+ }
154
+ }
155
+ if (event.key === "Escape") {
156
+ event.preventDefault();
157
+ close();
158
+ textareaRef.current?.focus();
159
+ return true;
160
+ }
161
+ return false;
162
+ };
163
+
164
+ const onBlur = () => {
165
+ if (showPopup) close();
166
+ };
167
+
168
+ return {
169
+ showPopup,
170
+ trigger,
171
+ listboxId,
172
+ selectedIndex,
173
+ setSelectedIndex,
174
+ placement,
175
+ visibleItems,
176
+ onKeyDown,
177
+ onBlur,
178
+ onSelectItem: (item: ComposerSlashItem) => {
179
+ if (trigger === null) return;
180
+ applyItem(item, trigger);
181
+ },
182
+ comboboxProps: {
183
+ role: "combobox",
184
+ "aria-multiline": true,
185
+ "aria-haspopup": "listbox",
186
+ "aria-autocomplete": "list",
187
+ "aria-expanded": showPopup,
188
+ "aria-controls": showPopup ? listboxId : undefined,
189
+ "aria-activedescendant":
190
+ activeItem && showPopup
191
+ ? optionId(listboxId, activeItem.id)
192
+ : undefined,
193
+ } satisfies {
194
+ role: "combobox";
195
+ "aria-multiline": boolean;
196
+ "aria-haspopup": "listbox";
197
+ "aria-autocomplete": "list";
198
+ "aria-expanded": boolean;
199
+ "aria-controls": string | undefined;
200
+ "aria-activedescendant": string | undefined;
201
+ },
202
+ };
203
+ }